Commit 4485496a07

4485496a075180586eab7210ec46a6b6f4285770

parent: 8828f0b3d0

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 02:46 UTC

web: markdown issue/MR bodies, issues and MR nav tabs

Issue and MR bodies and comments render through goldmark (raw HTML
dropped) instead of plaintext pre, fixing long single-line bodies
overflowing the page; remaining pre.message uses pre-wrap. Repo header
gains issues and merge requests tabs. Closes #5.

Layout: unified · split

e2e/issue_test.go +13 −1
@@ -132,7 +132,8 @@ func TestIssueLifecycleOverBareSSH(t *testing.T) {
132 t.Fatalf("missing repo: exit %d, want 3", code) 132 t.Fatalf("missing repo: exit %d, want 3", code)
133 } 133 }
134 134
135 // Web read views: list shows the issue, detail shows the comment. 135 // Web read views: list shows the issue, detail shows the comment, and
136 // bodies render as markdown (goldmark drops raw HTML).
136 status, body := inst.get(t, "/alice/proj/issues") 137 status, body := inst.get(t, "/alice/proj/issues")
137 if status != 200 || !strings.Contains(body, "first bug") { 138 if status != 200 || !strings.Contains(body, "first bug") {
138 t.Fatalf("issues page: %d", status) 139 t.Fatalf("issues page: %d", status)
@@ -141,6 +142,17 @@ func TestIssueLifecycleOverBareSSH(t *testing.T) {
141 if status != 200 || !strings.Contains(body, "me too") || !strings.Contains(body, "bug") { 142 if status != 200 || !strings.Contains(body, "me too") || !strings.Contains(body, "bug") {
142 t.Fatalf("issue detail: %d\n%s", status, body) 143 t.Fatalf("issue detail: %d\n%s", status, body)
143 } 144 }
145 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/proj",
146 "--title", "'md body'", "--body", "'has **bold** and <script>x</script>'"); code != 0 {
147 t.Fatal("md issue create failed")
148 }
149 status, body = inst.get(t, "/alice/proj/issues/3")
150 if status != 200 || !strings.Contains(body, "<strong>bold</strong>") {
151 t.Fatalf("markdown body not rendered: %d\n%s", status, body)
152 }
153 if strings.Contains(body, "<script>x</script>") {
154 t.Fatal("raw HTML survived in issue body")
155 }
144 156
145 // Private repos hide their issues from non-readers, as not-found. 157 // Private repos hide their issues from non-readers, as not-found.
146 if _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 { 158 if _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 {
e2e/web_test.go +5
@@ -83,6 +83,11 @@ func TestWebUI(t *testing.T) {
83 if !strings.Contains(body, "<h1>hello site</h1>") || !strings.Contains(body, "<em>markdown</em>") { 83 if !strings.Contains(body, "<h1>hello site</h1>") || !strings.Contains(body, "<em>markdown</em>") {
84 t.Fatalf("README not rendered:\n%s", body) 84 t.Fatalf("README not rendered:\n%s", body)
85 } 85 }
86 for _, tab := range []string{">issues<", ">merge requests<"} {
87 if !strings.Contains(body, tab) {
88 t.Fatalf("repo header missing %s tab", tab)
89 }
90 }
86 91
87 // Subdirectory tree and blob with highlighting. 92 // Subdirectory tree and blob with highlighting.
88 status, body = inst.get(t, "/alice/site/tree/main/src") 93 status, body = inst.get(t, "/alice/site/tree/main/src")
internal/httpd/web.go +34 −4
@@ -342,6 +342,34 @@ func pickReadme(entries []gitutil.TreeEntry) string {
342 return best 342 return best
343} 343}
344 344
345// mdHTML renders user-authored markdown (issue and MR bodies, comments).
346// goldmark's default renderer drops raw HTML, so this is safe as-is.
347func mdHTML(raw string) template.HTML {
348 if strings.TrimSpace(raw) == "" {
349 return ""
350 }
351 var buf bytes.Buffer
352 if goldmark.Convert([]byte(raw), &buf) != nil {
353 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
354 }
355 return template.HTML(buf.String())
356}
357
358// renderedComment pairs a comment with its rendered body for templates.
359type renderedComment struct {
360 Author string
361 CreatedAt string
362 BodyHTML template.HTML
363}
364
365func renderComments(cs []store.IssueComment) []renderedComment {
366 var out []renderedComment
367 for _, c := range cs {
368 out = append(out, renderedComment{c.Author, c.CreatedAt, mdHTML(c.Body)})
369 }
370 return out
371}
372
345// ugcPolicy sanitizes rendered repo content before it enters the forge's 373// ugcPolicy sanitizes rendered repo content before it enters the forge's
346// origin: markdown is already safe (goldmark drops raw HTML), but org-mode 374// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
347// output and repo-authored HTML are not. 375// output and repo-authored HTML are not.
@@ -548,8 +576,9 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
548 s.render(w, "issue.html", struct { 576 s.render(w, "issue.html", struct {
549 repoPage 577 repoPage
550 Issue store.Issue 578 Issue store.Issue
551 Comments []store.IssueComment 579 BodyHTML template.HTML
552 }{p, iss, comments}) 580 Comments []renderedComment
581 }{p, iss, mdHTML(iss.Body), renderComments(comments)})
553} 582}
554 583
555func (s *Server) mrs(w http.ResponseWriter, r *http.Request) { 584func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
@@ -605,10 +634,11 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
605 s.render(w, "mr.html", struct { 634 s.render(w, "mr.html", struct {
606 repoPage 635 repoPage
607 MR store.MR 636 MR store.MR
608 Comments []store.IssueComment 637 BodyHTML template.HTML
638 Comments []renderedComment
609 Reviews []store.MRReview 639 Reviews []store.MRReview
610 DiffLines []diffLine 640 DiffLines []diffLine
611 }{p, m, comments, reviews, lines}) 641 }{p, m, mdHTML(m.Body), renderComments(comments), reviews, lines})
612} 642}
613 643
614func (s *Server) refs(w http.ResponseWriter, r *http.Request) { 644func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
internal/web/static/style.css +5 −1
@@ -34,7 +34,11 @@ p.clone code { background: var(--code-bg); padding: 0.15rem 0.4rem; border-radiu
34 colors in both schemes so unstyled tokens stay legible. */ 34 colors in both schemes so unstyled tokens stay legible. */
35.code { background: #f8f8f8; color: #1a1a1a; } 35.code { background: #f8f8f8; color: #1a1a1a; }
36.code pre { margin: 0; background: transparent !important; } 36.code pre { margin: 0; background: transparent !important; }
37pre.message { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; } 37pre.message { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; white-space: pre-wrap; overflow-wrap: anywhere; }
38.rendered { max-width: 100%; overflow-wrap: anywhere; }
39.rendered pre { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; overflow-x: auto; }
40.rendered code { background: var(--code-bg); padding: 0.1rem 0.3rem; border-radius: 3px; }
41.rendered blockquote { border-left: 3px solid var(--line); margin-left: 0; padding-left: 1rem; color: var(--muted); }
38pre.diff { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; overflow-x: auto; } 42pre.diff { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; overflow-x: auto; }
39pre.diff .add { color: var(--ok); } 43pre.diff .add { color: var(--ok); }
40pre.diff .del { color: var(--bad); } 44pre.diff .del { color: var(--bad); }
internal/web/templates/issue.html +2 −2
@@ -5,9 +5,9 @@
5<p class="crumbs">by {{.Issue.Author}} at {{.Issue.CreatedAt}} 5<p class="crumbs">by {{.Issue.Author}} at {{.Issue.CreatedAt}}
6{{if .Issue.Labels}} · labels: {{range .Issue.Labels}}{{.}} {{end}}{{end}} 6{{if .Issue.Labels}} · labels: {{range .Issue.Labels}}{{.}} {{end}}{{end}}
7{{if .Issue.Assignees}} · assigned: {{range .Issue.Assignees}}{{.}} {{end}}{{end}}</p> 7{{if .Issue.Assignees}} · assigned: {{range .Issue.Assignees}}{{.}} {{end}}{{end}}</p>
8{{if .Issue.Body}}<pre class="message">{{.Issue.Body}}</pre>{{end}} 8{{if .BodyHTML}}<div class="rendered">{{.BodyHTML}}</div>{{end}}
9{{range .Comments}} 9{{range .Comments}}
10<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div> 10<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><div class="rendered">{{.BodyHTML}}</div></div>
11{{end}} 11{{end}}
12{{if .Viewer}} 12{{if .Viewer}}
13<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment"> 13<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment">
internal/web/templates/layout.html +2
@@ -22,6 +22,8 @@
22 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a> 22 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a>
23 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a> 23 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a>
24 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a> 24 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a>
25 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues">issues</a>
26 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs">merge requests</a>
25 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">archive</a> 27 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">archive</a>
26</nav> 28</nav>
27<p class="clone">clone: <code>git clone {{.CloneURL}}</code></p> 29<p class="clone">clone: <code>git clone {{.CloneURL}}</code></p>
internal/web/templates/mr.html +2 −2
@@ -4,10 +4,10 @@
4<h2>!{{.MR.Number}} {{.MR.Title}} <span class="badge badge-unsigned">{{.MR.State}}</span></h2> 4<h2>!{{.MR.Number}} {{.MR.Title}} <span class="badge badge-unsigned">{{.MR.State}}</span></h2>
5<p class="crumbs">by {{.MR.Author}} · {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} → {{.MR.TargetRef}} 5<p class="crumbs">by {{.MR.Author}} · {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} → {{.MR.TargetRef}}
6 @ <code>{{.MR.HeadSHA}}</code></p> 6 @ <code>{{.MR.HeadSHA}}</code></p>
7{{if .MR.Body}}<pre class="message">{{.MR.Body}}</pre>{{end}} 7{{if .BodyHTML}}<div class="rendered">{{.BodyHTML}}</div>{{end}}
8{{range .Reviews}}<p>review: {{.Reviewer}} — {{.Verdict}}{{if .Stale}} <span class="badge badge-signed_key_expired">stale</span>{{end}}</p>{{end}} 8{{range .Reviews}}<p>review: {{.Reviewer}} — {{.Verdict}}{{if .Stale}} <span class="badge badge-signed_key_expired">stale</span>{{end}}</p>{{end}}
9{{range .Comments}} 9{{range .Comments}}
10<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div> 10<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><div class="rendered">{{.BodyHTML}}</div></div>
11{{end}} 11{{end}}
12{{if .Viewer}} 12{{if .Viewer}}
13<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment"> 13<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment">