diff --git a/go.mod b/go.mod index aff84ea..116cc1e 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,9 @@ go 1.26.1 require ( github.com/andybalholm/brotli v1.2.1 + github.com/microcosm-cc/bluemonday v1.0.27 github.com/starfederation/datastar-go v1.2.2 + github.com/yuin/goldmark v1.8.2 golang.org/x/crypto v0.52.0 golang.org/x/sync v0.20.0 modernc.org/sqlite v1.51.0 @@ -12,8 +14,10 @@ require ( require ( github.com/CAFxX/httpcompression v0.0.9 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect diff --git a/go.sum b/go.sum index 55ece62..8086c25 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/CAFxX/httpcompression v0.0.9/go.mod h1:XX8oPZA+4IDcfZ0A71Hz0mZsv/YJOg github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -14,6 +16,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= @@ -22,6 +26,8 @@ github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYW github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= @@ -47,6 +53,8 @@ github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= diff --git a/markdown.go b/markdown.go new file mode 100644 index 0000000..e00526e --- /dev/null +++ b/markdown.go @@ -0,0 +1,46 @@ +package main + +import ( + "bytes" + "html/template" + + "github.com/microcosm-cc/bluemonday" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + ghtml "github.com/yuin/goldmark/renderer/html" +) + +// Markdown rendering for issue bodies and comments. We render on the READ side +// (the raw markdown source is what's stored), then sanitize the output — these +// are user-supplied, so bluemonday is mandatory. goldmark is left in its default +// safe mode (raw HTML in the source is not rendered) and the output is sanitized +// regardless, so it's safe twice over. +var ( + md = goldmark.New( + // GFM: tables, strikethrough, autolinks, task lists. Hard wraps so a single + // newline becomes
— matches what people type in the comment box. + goldmark.WithExtensions(extension.GFM), + goldmark.WithRendererOptions(ghtml.WithHardWraps()), + ) + mdPolicy = newMarkdownPolicy() +) + +func newMarkdownPolicy() *bluemonday.Policy { + p := bluemonday.UGCPolicy() // headings, lists, code, blockquote, emphasis, links, tables… + p.RequireNoFollowOnLinks(true) + p.AddTargetBlankToFullyQualifiedLinks(true) + return p +} + +// renderMarkdown converts markdown source to sanitized HTML safe to inject. On a +// render error it falls back to escaped plain text (never raw). +func renderMarkdown(src string) template.HTML { + if src == "" { + return "" + } + var buf bytes.Buffer + if err := md.Convert([]byte(src), &buf); err != nil { + return template.HTML(template.HTMLEscapeString(src)) + } + return template.HTML(mdPolicy.SanitizeBytes(buf.Bytes())) +} diff --git a/render.go b/render.go index 848abbf..6b1b4be 100644 --- a/render.go +++ b/render.go @@ -75,7 +75,7 @@ type listView struct { type commentView struct { Author string - Body string + Body template.HTML // markdown-rendered + sanitized When string Stop int // index of this comment in the detail focus ring (j/k via $dcur) Key string // stable DOM id (created-ms) so morph preserves existing comments @@ -88,7 +88,7 @@ type detailInner struct { Creator string Assignee string When string - Description string + Description template.HTML // markdown-rendered + sanitized Labels []Label Comments []commentView } @@ -220,14 +220,14 @@ func buildInner(d *IssueDetail) *detailInner { cv := make([]commentView, 0, len(d.Comments)) for _, c := range d.Comments { cv = append(cv, commentView{ - Author: c.Author, Body: c.Body, When: relWhen(c.Created), + Author: c.Author, Body: renderMarkdown(c.Body), When: relWhen(c.Created), Key: strconv.FormatInt(c.Created.UnixMilli(), 10), }) } return &detailInner{ ShortID: d.ShortID, Title: d.Title, Open: d.Open, Creator: d.Creator, Assignee: d.Assignee, When: relWhen(d.Modified), - Description: d.Description, Labels: d.Labels, Comments: cv, + Description: renderMarkdown(d.Description), Labels: d.Labels, Comments: cv, } } diff --git a/static/app.css b/static/app.css index 1329e25..35e473d 100644 --- a/static/app.css +++ b/static/app.css @@ -151,6 +151,42 @@ body { background: var(--bg); color: var(--fg); font-size: 14px; } .issue-sub { color: var(--muted); font-size: 12px; margin: 8px 0; } .issue-labels { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; } .issue-body { line-height: 1.6; } + +/* Rendered markdown (issue bodies + comments) — goldmark output, sanitized. */ +.markdown { line-height: 1.6; } +.markdown > :first-child { margin-top: 0; } +.markdown > :last-child { margin-bottom: 0; } +.markdown p { margin: 0 0 10px; } +.markdown ul, .markdown ol { margin: 0 0 10px; padding-left: 22px; } +.markdown li { margin: 2px 0; } +.markdown li > ul, .markdown li > ol { margin: 2px 0; } +.markdown h1, .markdown h2, .markdown h3, .markdown h4 { margin: 16px 0 8px; line-height: 1.3; } +.markdown h1 { font-size: 1.4em; } +.markdown h2 { font-size: 1.25em; } +.markdown h3 { font-size: 1.1em; } +.markdown h4 { font-size: 1em; } +.markdown a { color: var(--accent); text-decoration: none; } +.markdown a:hover { text-decoration: underline; } +.markdown code { + font: 0.88em ui-monospace, SFMono-Regular, Menlo, monospace; + background: var(--bg); border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px; +} +.markdown pre { + background: var(--bg); border: 1px solid var(--line); border-radius: 8px; + padding: 12px 14px; overflow: auto; margin: 0 0 10px; +} +.markdown pre code { background: none; border: none; padding: 0; font-size: 0.86em; } +.markdown blockquote { + margin: 0 0 10px; padding: 2px 12px; color: var(--muted); + border-left: 3px solid var(--line); +} +.markdown hr { border: none; border-top: 1px solid var(--line); margin: 14px 0; } +.markdown table { border-collapse: collapse; margin: 0 0 10px; } +.markdown th, .markdown td { border: 1px solid var(--line); padding: 5px 10px; text-align: left; } +.markdown th { background: var(--panel2); } +.markdown img { max-width: 100%; border-radius: 8px; } +.markdown del { color: var(--muted); } +.markdown input[type="checkbox"] { margin-right: 6px; } .comments { margin-top: 22px; border-top: 1px solid var(--line); padding-top: 14px; } .comment { padding: 10px 0; border-bottom: 1px solid var(--line); diff --git a/templates/fragments.html b/templates/fragments.html index 6f04ff9..4ed3cb1 100644 --- a/templates/fragments.html +++ b/templates/fragments.html @@ -167,7 +167,7 @@
{{range .Labels}}{{.Name}}{{end}}
-

{{.Description}}

+
{{.Description}}
{{len .Comments}} comment{{if ne (len .Comments) 1}}s{{end}} @@ -176,7 +176,7 @@ {{range .Comments}}
{{.Author}} {{.When}}
-
{{.Body}}
+
{{.Body}}
{{else}}
No comments.