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 @@
{{.Description}}
+