diff --git a/fluffy/templates/markdown.html b/fluffy/templates/markdown.html deleted file mode 100644 index 09c3429..0000000 --- a/fluffy/templates/markdown.html +++ /dev/null @@ -1,20 +0,0 @@ -{% set page_name = 'markdown' %} -{% extends 'layouts/text.html' %} - -{% block extra_head %} - -{% endblock %} - -{% block info %} - {{num_lines(text)}} {{'line'|pluralize(num_lines(text))}} of Markdown -{% endblock info %} - -{% block text %} -
-
- {{text|markdown|safe}} -
-
-{% endblock %} - -{% block inline_js %}{% endblock %} diff --git a/go.mod b/go.mod index 8145b3d..ccb8927 100644 --- a/go.mod +++ b/go.mod @@ -31,5 +31,6 @@ require ( github.com/aws/smithy-go v1.20.4 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/yuin/goldmark v1.7.8 // indirect golang.org/x/sys v0.24.0 // indirect ) diff --git a/go.sum b/go.sum index 7779b97..f64539a 100644 --- a/go.sum +++ b/go.sum @@ -54,6 +54,8 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= diff --git a/scss/markdown.scss b/scss/markdown.scss deleted file mode 100644 index 6d78f81..0000000 --- a/scss/markdown.scss +++ /dev/null @@ -1,104 +0,0 @@ -$source-code-font: 'Source Code Pro', monospace; - -.page-markdown { - #container { - width: 960px; - } - - .text { - background-color: white !important; - font-size: 15px; - - padding: 24px; - line-height: 1.5em; - color: #111; - - & > *:first-child { - margin-top: 0 !important; - } - - h1, h2, h3, h4, h5, h6 { - font-weight: bold; - margin-top: 26px; - } - - h1 { font-size: 28px; } - h1, h2 { - border-bottom: solid 1px #eee; - padding-bottom: 16px; - margin-bottom: 14px; - } - h2 { font-size: 24px; } - h3 { font-size: 18px; } - h4 { font-size: 16px; } - h5 { font-size: 14px; } - h6 { font-size: 14px; color: #777; } - - blockquote { - color: #555; - padding: 0 15px; - border-left: solid 5px #ddd; - } - - $source-code-bg-color: #f6f6f6; - - pre { - font-family: $source-code-font; - overflow-x: auto; - background-color: $source-code-bg-color; - padding: 14px; - margin-bottom: 10px; - } - - code { - font-family: $source-code-font; - padding: 3px 6px; - font-size: 12px; - background-color: $source-code-bg-color; - border-radius: 2px; - } - - a { - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } - } - - ul, ol { - padding-left: 35px; - - li { - display: list-item; - margin: 5px 0; - } - } - - ul { - list-style: disc; - } - - ol { - list-style: decimal; - - li { - padding-left: 5px; - } - } - - td, th { - border: solid 1px #ddd; - padding: 8px; - } - - th { - font-weight: bold; - background-color: #f1f1f1; - } - - img { - max-width: 100%; - } -} diff --git a/scss/paste.scss b/scss/paste.scss index 04feed9..ce295ff 100644 --- a/scss/paste.scss +++ b/scss/paste.scss @@ -1,3 +1,6 @@ +$source-code-font: 'Source Code Pro', monospace; +$paste-padding-top: 2px; + .page-paste { #style { float: right; @@ -7,6 +10,15 @@ display: none; } } + + &.markdown { + #container { + width: 960px; + margin-left: auto; + margin-right: auto; + } + } + #container { width: auto; @@ -24,51 +36,23 @@ } } - #paste { - font-family: 'Source Code Pro', monospace; - - $paste-padding-top: 2px; - - .line-numbers { - padding: $paste-padding-top 0; - /* color comes from the pygments styles */ - border-right-width: 1px; - border-right-style: solid; - - float: left; - line-height: 1.25em; - text-align: right; - - - a { - cursor: pointer; - display: block; - text-decoration: none; - padding-left: 8px; - padding-right: 5px; - } - } - - .text { - padding: $paste-padding-top 0; - overflow-x: auto; - - .highlight { - // Allow width to expand to match content so block element - // children (e.g. lines) extend the full width. - display: inline-block; - min-width: 100%; - } + .line-numbers { + font-family: $source-code-font; + padding: $paste-padding-top 0; + /* color comes from the pygments styles */ + border-right-width: 1px; + border-right-style: solid; - .highlight > pre { - line-height: 125%; + float: left; + line-height: 1.25em; + text-align: right; - // These are line spans (sadly, no class is output by pygments) - & > span { - padding-left: 5px; - display: block; - } - } + a { + cursor: pointer; + display: block; + text-decoration: none; + padding-left: 8px; + padding-right: 5px; } } @@ -92,4 +76,124 @@ } } } + + .text.plain-text { + font-family: $source-code-font; + padding: $paste-padding-top 0; + overflow-x: auto; + + .highlight { + // Allow width to expand to match content so block element + // children (e.g. lines) extend the full width. + display: inline-block; + min-width: 100%; + } + + .highlight > pre { + line-height: 125%; + + // These are line spans (sadly, no class is output by pygments) + & > span { + padding-left: 5px; + display: block; + } + } + } + + .text.rich-text { + background-color: white !important; + font-size: 15px; + + padding: 24px; + line-height: 1.5em; + color: #111; + + & > *:first-child { + margin-top: 0 !important; + } + + h1, h2, h3, h4, h5, h6 { + font-weight: bold; + margin-top: 26px; + } + + h1 { font-size: 28px; } + h1, h2 { + border-bottom: solid 1px #eee; + padding-bottom: 16px; + margin-bottom: 14px; + } + h2 { font-size: 24px; } + h3 { font-size: 18px; } + h4 { font-size: 16px; } + h5 { font-size: 14px; } + h6 { font-size: 14px; color: #777; } + + blockquote { + color: #555; + padding: 0 15px; + border-left: solid 5px #ddd; + } + + $source-code-bg-color: #f6f6f6; + + pre { + font-family: $source-code-font; + overflow-x: auto; + background-color: $source-code-bg-color; + padding: 14px; + margin-bottom: 10px; + } + + code { + font-family: $source-code-font; + padding: 3px 6px; + font-size: 12px; + background-color: $source-code-bg-color; + border-radius: 2px; + } + + a { + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + ul, ol { + padding-left: 35px; + + li { + display: list-item; + margin: 5px 0; + } + } + + ul { + list-style: disc; + } + + ol { + list-style: decimal; + + li { + padding-left: 5px; + } + } + + td, th { + border: solid 1px #ddd; + padding: 8px; + } + + th { + font-weight: bold; + background-color: #f1f1f1; + } + + img { + max-width: 100%; + } + } } diff --git a/server/highlighting/highlighting.go b/server/highlighting/highlighting.go index d4498c9..a198f8f 100644 --- a/server/highlighting/highlighting.go +++ b/server/highlighting/highlighting.go @@ -57,22 +57,93 @@ var Styles = []StyleCategory{ }, } +// Text represents a single piece of text. +// +// A single paste usually consists of a single Text, but in the case of a diff, it may contain +// multiple Texts. +type Text struct { + Text string + // Array index corresponds to zero-indexed line number in this text, + // and the value is the array of zero-indexed line numbers that line + // corresponds to in the original text. + LineNumberMapping [][]int +} + +func simpleText(text string) *Text { + // This is a little tricky because whether lines end in newlines depends on the source of the + // text. Our approach is to not count a final \n if it's present since the user probably + // doesn't intend to create an empty line at the end of the text. We do count multiple newlines + // though since that is more clear. + // "a" => 1 line + // "a\n" => 1 line + // "a\nb" => 2 lines + // "a\nb\n" => 2 lines + // "a\nb\n\n" => 3 lines + lineCount := strings.Count(text, "\n") + 1 + if strings.HasSuffix(text, "\n") { + lineCount -= 1 + } + mapping := make([][]int, lineCount) + for i := range mapping { + mapping[i] = []int{i} + } + return &Text{ + Text: text, + LineNumberMapping: mapping, + } +} + +// Highlighter is an interface for syntax highlighting. +// +// A Highlighter is responsible for taking a piece of text and returning an HTML representation. type Highlighter interface { + // Name returns the name of the highlighter (e.g. "Python"). Name() string - IsDiff() bool + // RenderAsDiff returns true if the paste should be rendered as a diff. + RenderAsDiff() bool + // RenderAsRichText returns true if the paste should be rendered as rich text, without line + // numbers or other plaintext formatting. Useful for e.g. Markdown. + RenderAsRichText() bool + // ExtraHTMLClasses returns a extra CSS classes that should be added. + ExtraHTMLClasses() []string + // GenerateTexts takes a piece of text and returns a slice of Texts. + // Generally this will return a single Text, but in the case of a diff, it may return multiple. + GenerateTexts(text string) []*Text + // Highlight takes a piece of text and returns an HTML representation. Highlight(text *Text) (template.HTML, error) } +func GuessHighlighterForPaste(text string, language string) Highlighter { + if language == "rendered-markdown" { + return &MarkdownHighlighter{} + } + + // TODO: implement + return &PlainTextHighlighter{} +} + type PlainTextHighlighter struct{} func (p *PlainTextHighlighter) Name() string { return "Plain Text" } -func (p *PlainTextHighlighter) IsDiff() bool { +func (p *PlainTextHighlighter) RenderAsDiff() bool { + return false +} + +func (p *PlainTextHighlighter) RenderAsRichText() bool { return false } +func (p *PlainTextHighlighter) ExtraHTMLClasses() []string { + return nil +} + +func (p *PlainTextHighlighter) GenerateTexts(text string) []*Text { + return []*Text{simpleText(text)} +} + func (p *PlainTextHighlighter) Highlight(text *Text) (template.HTML, error) { var html strings.Builder for _, line := range strings.Split(text.Text, "\n") { @@ -81,11 +152,3 @@ func (p *PlainTextHighlighter) Highlight(text *Text) (template.HTML, error) { } return template.HTML(html.String()), nil } - -type Text struct { - Text string - // Array index corresponds to zero-indexed line number in this text, - // and the value is the array of zero-indexed line numbers that line - // corresponds to in the original text. - LineNumberMapping [][]int -} diff --git a/server/highlighting/markdown.go b/server/highlighting/markdown.go new file mode 100644 index 0000000..1e8acce --- /dev/null +++ b/server/highlighting/markdown.go @@ -0,0 +1,50 @@ +package highlighting + +import ( + "fmt" + "html/template" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" +) + +type MarkdownHighlighter struct{} + +func (m *MarkdownHighlighter) Name() string { + return "Rendered Markdown" +} + +func (m *MarkdownHighlighter) RenderAsDiff() bool { + return false +} + +func (m *MarkdownHighlighter) RenderAsRichText() bool { + return true +} + +func (p *MarkdownHighlighter) ExtraHTMLClasses() []string { + return []string{"markdown"} +} + +func (p *MarkdownHighlighter) GenerateTexts(text string) []*Text { + return []*Text{simpleText(text)} +} + +func (m *MarkdownHighlighter) Highlight(text *Text) (template.HTML, error) { + md := goldmark.New( + // TODO: add syntax highlighting + goldmark.WithExtensions(extension.GFM), + goldmark.WithParserOptions( + parser.WithAutoHeadingID(), + ), + ) + + var html strings.Builder + if err := md.Convert([]byte(text.Text), &html); err != nil { + return "", fmt.Errorf("rendering Markdown: %w", err) + } + + return template.HTML(html.String()), nil +} diff --git a/server/security/csp.go b/server/security/csp.go index f52cb3f..c5f0035 100644 --- a/server/security/csp.go +++ b/server/security/csp.go @@ -40,10 +40,18 @@ func NewCSPMiddleware(conf *config.Config, logger logging.Logger, next http.Hand return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() csp := strings.Builder{} - fmt.Fprintf(&csp, "default-src 'self' %s; script-src https://ajax.googleapis.com %[1]s ", fileURLBase) + // default-src + fmt.Fprintf(&csp, "default-src 'self' %s", fileURLBase) if isDevStaticFileRequest(conf, r) { - fmt.Fprintf(&csp, "'unsafe-inline'") + // Needed for embedded images in rendered markdown dev pastes. + fmt.Fprintf(&csp, " *") + } + + // script-src + fmt.Fprintf(&csp, "; script-src https://ajax.googleapis.com %s", fileURLBase) + if isDevStaticFileRequest(conf, r) { + fmt.Fprintf(&csp, " 'unsafe-inline'") } else { nonceBytes := make([]byte, 16) if _, err := rand.Read(nonceBytes); err != nil { @@ -53,10 +61,14 @@ func NewCSPMiddleware(conf *config.Config, logger logging.Logger, next http.Hand } nonce := hex.EncodeToString(nonceBytes) ctx = context.WithValue(ctx, cspNonceKey{}, nonce) - fmt.Fprintf(&csp, "'nonce-%s'", nonce) + fmt.Fprintf(&csp, " 'nonce-%s'", nonce) } - fmt.Fprintf(&csp, "; style-src 'self' https://fonts.googleapis.com %s; font-src https://fonts.gstatic.com %[1]s", fileURLBase) + // style-src + fmt.Fprintf(&csp, "; style-src 'self' https://fonts.googleapis.com %s", fileURLBase) + + // font-src + fmt.Fprintf(&csp, "; font-src https://fonts.gstatic.com %s", fileURLBase) w.Header().Set("Content-Security-Policy", csp.String()) next.ServeHTTP(w, r.WithContext(ctx)) diff --git a/server/security/csp_test.go b/server/security/csp_test.go index 058cdac..c6f114f 100644 --- a/server/security/csp_test.go +++ b/server/security/csp_test.go @@ -31,7 +31,7 @@ var ( cspDevStaticFileRegexp = regexp.MustCompile( strings.Join( []string{ - `default-src 'self' https://fancy-cdn.com`, + `default-src 'self' https://fancy-cdn.com \*`, `script-src https://ajax.googleapis.com https://fancy-cdn.com 'unsafe-inline'`, `style-src 'self' https://fonts.googleapis.com https://fancy-cdn.com`, `font-src https://fonts.gstatic.com https://fancy-cdn.com`, diff --git a/server/templates/include/base.html b/server/templates/include/base.html index 0a7d592..f624be0 100644 --- a/server/templates/include/base.html +++ b/server/templates/include/base.html @@ -40,6 +40,3 @@

{{.Meta.Conf.Bra {{template "inlineJS" .}} - - diff --git a/server/templates/paste.html b/server/templates/paste.html index 86fa913..a76599a 100644 --- a/server/templates/paste.html +++ b/server/templates/paste.html @@ -1,7 +1,7 @@ {{define "inlineJS"}} - - {{.Meta.InlineJS "js/paste-inline.js"}} + + {{.Meta.InlineJS "js/paste-inline.js"}} {{end}} {{define "highlightStart"}} @@ -28,7 +28,7 @@ } - {{if .Highlighter.IsDiff}} + {{if .Highlighter.RenderAsDiff}}
Side-by-Side
Unified
@@ -79,14 +79,20 @@ {{define "text"}} {{range $text := .Texts}}
- -
- {{$.Highlighter.Highlight $text}} -
+ {{if $.Highlighter.RenderAsRichText}} +
+ {{$.Highlighter.Highlight $text}} +
+ {{else}} +
+ {{range $i, $numbers := $text.LineNumberMapping}} + {{plusOne $i}} + {{end}} +
+
+ {{$.Highlighter.Highlight $text}} +
+ {{end}}
{{end}} {{end}} diff --git a/server/views/uploads.go b/server/views/uploads.go index 834ee7f..7e96cba 100644 --- a/server/views/uploads.go +++ b/server/views/uploads.go @@ -266,6 +266,18 @@ func normalizeFormText(text string) string { return strings.ReplaceAll(text, "\r\n", "\n") } +func unifiedDiff(text1, text2 string) string { + return "TODO: unifiedDiff" +} + +func normalizeTextAndLanguage(text, diffText1, diffText2, language string) (string, string) { + if language == "diff-between-two-texts" { + return unifiedDiff(normalizeFormText(diffText1), normalizeFormText(diffText2)), "diff" + } else { + return normalizeFormText(text), language + } +} + func HandlePaste(conf *config.Config, logger logging.Logger) http.HandlerFunc { pasteTmpl := conf.Templates.Must("paste.html") @@ -287,17 +299,19 @@ func HandlePaste(conf *config.Config, logger logging.Logger) http.HandlerFunc { } } - fmt.Printf("r.Headers: %v\n", r.Header["Content-Type"]) - fmt.Printf("r.Form: %v\n", r.Form) - fmt.Printf("r.PostForm: %v\n", r.PostForm) - _, jsonResponse := r.URL.Query()["json"] if _, ok := r.Form["json"]; ok { jsonResponse = true } fmt.Printf("jsonResponse: %v\n", jsonResponse) - text := normalizeFormText(r.Form.Get("text")) + text, language := normalizeTextAndLanguage( + r.Form.Get("text"), + r.Form.Get("diff1"), + r.Form.Get("diff2"), + r.Form.Get("language"), + ) + highlighter := highlighting.GuessHighlighterForPaste(text, language) // Raw paste rawKey, err := uploads.GenUniqueObjectKey() @@ -322,8 +336,9 @@ func HandlePaste(conf *config.Config, logger logging.Logger) http.HandlerFunc { } pasteMeta, err := meta.NewMeta(r.Context(), conf, meta.PageConfig{ - ID: "paste", - IsStatic: true, + ID: "paste", + IsStatic: true, + ExtraHTMLClasses: highlighter.ExtraHTMLClasses(), }) if err != nil { logger.Error(r.Context(), "creating meta", "error", err) @@ -359,13 +374,8 @@ func HandlePaste(conf *config.Config, logger logging.Logger) http.HandlerFunc { CopyAndEditText: text, RawURL: conf.FileURL(rawFile.Key()).String(), Styles: highlighting.Styles, - Highlighter: &highlighting.PlainTextHighlighter{}, - Texts: []*highlighting.Text{ - { - Text: text, - LineNumberMapping: mapping, - }, - }, + Highlighter: highlighter, + Texts: highlighter.GenerateTexts(text), } // Terminal output gets its own preferred theme setting since many people