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 | 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 | 137 | status, body := inst.get(t, "/alice/proj/issues") |
| 137 | 138 | if status != 200 || !strings.Contains(body, "first bug") { |
| 138 | 139 | t.Fatalf("issues page: %d", status) |
| @@ -141,6 +142,17 @@ func TestIssueLifecycleOverBareSSH(t *testing.T) { |
| 141 | 142 | if status != 200 || !strings.Contains(body, "me too") || !strings.Contains(body, "bug") { |
| 142 | 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 | 157 | // Private repos hide their issues from non-readers, as not-found. |
| 146 | 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 | 83 | if !strings.Contains(body, "<h1>hello site</h1>") || !strings.Contains(body, "<em>markdown</em>") { |
| 84 | 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 | 92 | // Subdirectory tree and blob with highlighting. |
| 88 | 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 | 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. |
| 347 | func 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. |
| 359 | type renderedComment struct { |
| 360 | Author string |
| 361 | CreatedAt string |
| 362 | BodyHTML template.HTML |
| 363 | } |
| 364 | |
| 365 | func 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 | 373 | // ugcPolicy sanitizes rendered repo content before it enters the forge's |
| 346 | 374 | // origin: markdown is already safe (goldmark drops raw HTML), but org-mode |
| 347 | 375 | // output and repo-authored HTML are not. |
| @@ -548,8 +576,9 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) { |
| 548 | 576 | s.render(w, "issue.html", struct { |
| 549 | 577 | repoPage |
| 550 | 578 | Issue store.Issue |
| 551 | | Comments []store.IssueComment |
| 552 | | }{p, iss, comments}) |
| 579 | BodyHTML template.HTML |
| 580 | Comments []renderedComment |
| 581 | }{p, iss, mdHTML(iss.Body), renderComments(comments)}) |
| 553 | 582 | } |
| 554 | 583 | |
| 555 | 584 | func (s *Server) mrs(w http.ResponseWriter, r *http.Request) { |
| @@ -605,10 +634,11 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { |
| 605 | 634 | s.render(w, "mr.html", struct { |
| 606 | 635 | repoPage |
| 607 | 636 | MR store.MR |
| 608 | | Comments []store.IssueComment |
| 637 | BodyHTML template.HTML |
| 638 | Comments []renderedComment |
| 609 | 639 | Reviews []store.MRReview |
| 610 | 640 | DiffLines []diffLine |
| 611 | | }{p, m, comments, reviews, lines}) |
| 641 | }{p, m, mdHTML(m.Body), renderComments(comments), reviews, lines}) |
| 612 | 642 | } |
| 613 | 643 | |
| 614 | 644 | func (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 | 34 | colors in both schemes so unstyled tokens stay legible. */ |
| 35 | 35 | .code { background: #f8f8f8; color: #1a1a1a; } |
| 36 | 36 | .code pre { margin: 0; background: transparent !important; } |
| 37 | | pre.message { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; } |
| 37 | pre.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); } |
| 38 | 42 | pre.diff { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; overflow-x: auto; } |
| 39 | 43 | pre.diff .add { color: var(--ok); } |
| 40 | 44 | pre.diff .del { color: var(--bad); } |
internal/web/templates/issue.html
+2 −2
| @@ -5,9 +5,9 @@ |
| 5 | 5 | <p class="crumbs">by {{.Issue.Author}} at {{.Issue.CreatedAt}} |
| 6 | 6 | {{if .Issue.Labels}} · labels: {{range .Issue.Labels}}{{.}} {{end}}{{end}} |
| 7 | 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 | 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 | 11 | {{end}} |
| 12 | 12 | {{if .Viewer}} |
| 13 | 13 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment"> |
internal/web/templates/layout.html
+2
| @@ -22,6 +22,8 @@ |
| 22 | 22 | <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a> |
| 23 | 23 | <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a> |
| 24 | 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 | 27 | <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">archive</a> |
| 26 | 28 | </nav> |
| 27 | 29 | <p class="clone">clone: <code>git clone {{.CloneURL}}</code></p> |
internal/web/templates/mr.html
+2 −2
| @@ -4,10 +4,10 @@ |
| 4 | 4 | <h2>!{{.MR.Number}} {{.MR.Title}} <span class="badge badge-unsigned">{{.MR.State}}</span></h2> |
| 5 | 5 | <p class="crumbs">by {{.MR.Author}} · {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} → {{.MR.TargetRef}} |
| 6 | 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 | 8 | {{range .Reviews}}<p>review: {{.Reviewer}} — {{.Verdict}}{{if .Stale}} <span class="badge badge-signed_key_expired">stale</span>{{end}}</p>{{end}} |
| 9 | 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 | 11 | {{end}} |
| 12 | 12 | {{if .Viewer}} |
| 13 | 13 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment"> |