krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

4485496a075180586eab7210ec46a6b6f4285770

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-24T02:46:42Z

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.
 e2e/issue_test.go                  | 14 +++++++++++++-
 e2e/web_test.go                    |  5 +++++
 internal/httpd/web.go              | 38 ++++++++++++++++++++++++++++++++++----
 internal/web/static/style.css      |  6 +++++-
 internal/web/templates/issue.html  |  4 ++--
 internal/web/templates/layout.html |  2 ++
 internal/web/templates/mr.html     |  4 ++--
 7 files changed, 63 insertions(+), 10 deletions(-)

diff --git a/e2e/issue_test.go b/e2e/issue_test.go
index 536929d..193a498 100644
--- a/e2e/issue_test.go
+++ b/e2e/issue_test.go
@@ -132,7 +132,8 @@ func TestIssueLifecycleOverBareSSH(t *testing.T) {
 		t.Fatalf("missing repo: exit %d, want 3", code)
 	}
 
-	// Web read views: list shows the issue, detail shows the comment.
+	// Web read views: list shows the issue, detail shows the comment, and
+	// bodies render as markdown (goldmark drops raw HTML).
 	status, body := inst.get(t, "/alice/proj/issues")
 	if status != 200 || !strings.Contains(body, "first bug") {
 		t.Fatalf("issues page: %d", status)
@@ -141,6 +142,17 @@ func TestIssueLifecycleOverBareSSH(t *testing.T) {
 	if status != 200 || !strings.Contains(body, "me too") || !strings.Contains(body, "bug") {
 		t.Fatalf("issue detail: %d\n%s", status, body)
 	}
+	if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/proj",
+		"--title", "'md body'", "--body", "'has **bold** and <script>x</script>'"); code != 0 {
+		t.Fatal("md issue create failed")
+	}
+	status, body = inst.get(t, "/alice/proj/issues/3")
+	if status != 200 || !strings.Contains(body, "<strong>bold</strong>") {
+		t.Fatalf("markdown body not rendered: %d\n%s", status, body)
+	}
+	if strings.Contains(body, "<script>x</script>") {
+		t.Fatal("raw HTML survived in issue body")
+	}
 
 	// Private repos hide their issues from non-readers, as not-found.
 	if _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 {
diff --git a/e2e/web_test.go b/e2e/web_test.go
index 36f0f66..bdff646 100644
--- a/e2e/web_test.go
+++ b/e2e/web_test.go
@@ -83,6 +83,11 @@ func TestWebUI(t *testing.T) {
 	if !strings.Contains(body, "<h1>hello site</h1>") || !strings.Contains(body, "<em>markdown</em>") {
 		t.Fatalf("README not rendered:\n%s", body)
 	}
+	for _, tab := range []string{">issues<", ">merge requests<"} {
+		if !strings.Contains(body, tab) {
+			t.Fatalf("repo header missing %s tab", tab)
+		}
+	}
 
 	// Subdirectory tree and blob with highlighting.
 	status, body = inst.get(t, "/alice/site/tree/main/src")
diff --git a/internal/httpd/web.go b/internal/httpd/web.go
index 70b48ff..1c8f395 100644
--- a/internal/httpd/web.go
+++ b/internal/httpd/web.go
@@ -342,6 +342,34 @@ func pickReadme(entries []gitutil.TreeEntry) string {
 	return best
 }
 
+// mdHTML renders user-authored markdown (issue and MR bodies, comments).
+// goldmark's default renderer drops raw HTML, so this is safe as-is.
+func mdHTML(raw string) template.HTML {
+	if strings.TrimSpace(raw) == "" {
+		return ""
+	}
+	var buf bytes.Buffer
+	if goldmark.Convert([]byte(raw), &buf) != nil {
+		return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
+	}
+	return template.HTML(buf.String())
+}
+
+// renderedComment pairs a comment with its rendered body for templates.
+type renderedComment struct {
+	Author    string
+	CreatedAt string
+	BodyHTML  template.HTML
+}
+
+func renderComments(cs []store.IssueComment) []renderedComment {
+	var out []renderedComment
+	for _, c := range cs {
+		out = append(out, renderedComment{c.Author, c.CreatedAt, mdHTML(c.Body)})
+	}
+	return out
+}
+
 // ugcPolicy sanitizes rendered repo content before it enters the forge's
 // origin: markdown is already safe (goldmark drops raw HTML), but org-mode
 // output and repo-authored HTML are not.
@@ -548,8 +576,9 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
 	s.render(w, "issue.html", struct {
 		repoPage
 		Issue    store.Issue
-		Comments []store.IssueComment
-	}{p, iss, comments})
+		BodyHTML template.HTML
+		Comments []renderedComment
+	}{p, iss, mdHTML(iss.Body), renderComments(comments)})
 }
 
 func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
@@ -605,10 +634,11 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
 	s.render(w, "mr.html", struct {
 		repoPage
 		MR        store.MR
-		Comments  []store.IssueComment
+		BodyHTML  template.HTML
+		Comments  []renderedComment
 		Reviews   []store.MRReview
 		DiffLines []diffLine
-	}{p, m, comments, reviews, lines})
+	}{p, m, mdHTML(m.Body), renderComments(comments), reviews, lines})
 }
 
 func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/web/static/style.css b/internal/web/static/style.css
index 28d5afb..4285d28 100644
--- a/internal/web/static/style.css
+++ b/internal/web/static/style.css
@@ -34,7 +34,11 @@ p.clone code { background: var(--code-bg); padding: 0.15rem 0.4rem; border-radiu
    colors in both schemes so unstyled tokens stay legible. */
 .code { background: #f8f8f8; color: #1a1a1a; }
 .code pre { margin: 0; background: transparent !important; }
-pre.message { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; }
+pre.message { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; white-space: pre-wrap; overflow-wrap: anywhere; }
+.rendered { max-width: 100%; overflow-wrap: anywhere; }
+.rendered pre { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; overflow-x: auto; }
+.rendered code { background: var(--code-bg); padding: 0.1rem 0.3rem; border-radius: 3px; }
+.rendered blockquote { border-left: 3px solid var(--line); margin-left: 0; padding-left: 1rem; color: var(--muted); }
 pre.diff { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; overflow-x: auto; }
 pre.diff .add { color: var(--ok); }
 pre.diff .del { color: var(--bad); }
diff --git a/internal/web/templates/issue.html b/internal/web/templates/issue.html
index a5f4549..0f374aa 100644
--- a/internal/web/templates/issue.html
+++ b/internal/web/templates/issue.html
@@ -5,9 +5,9 @@
 <p class="crumbs">by {{.Issue.Author}} at {{.Issue.CreatedAt}}
 {{if .Issue.Labels}} · labels: {{range .Issue.Labels}}{{.}} {{end}}{{end}}
 {{if .Issue.Assignees}} · assigned: {{range .Issue.Assignees}}{{.}} {{end}}{{end}}</p>
-{{if .Issue.Body}}<pre class="message">{{.Issue.Body}}</pre>{{end}}
+{{if .BodyHTML}}<div class="rendered">{{.BodyHTML}}</div>{{end}}
 {{range .Comments}}
-<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div>
+<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><div class="rendered">{{.BodyHTML}}</div></div>
 {{end}}
 {{if .Viewer}}
 <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment">
diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html
index fb77ffd..5f55c43 100644
--- a/internal/web/templates/layout.html
+++ b/internal/web/templates/layout.html
@@ -22,6 +22,8 @@
   <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a>
   <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a>
   <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a>
+  <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues">issues</a>
+  <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs">merge requests</a>
   <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">archive</a>
 </nav>
 <p class="clone">clone: <code>git clone {{.CloneURL}}</code></p>
diff --git a/internal/web/templates/mr.html b/internal/web/templates/mr.html
index 8a9046e..c9cfc5c 100644
--- a/internal/web/templates/mr.html
+++ b/internal/web/templates/mr.html
@@ -4,10 +4,10 @@
 <h2>!{{.MR.Number}} {{.MR.Title}} <span class="badge badge-unsigned">{{.MR.State}}</span></h2>
 <p class="crumbs">by {{.MR.Author}} · {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} → {{.MR.TargetRef}}
  @ <code>{{.MR.HeadSHA}}</code></p>
-{{if .MR.Body}}<pre class="message">{{.MR.Body}}</pre>{{end}}
+{{if .BodyHTML}}<div class="rendered">{{.BodyHTML}}</div>{{end}}
 {{range .Reviews}}<p>review: {{.Reviewer}} — {{.Verdict}}{{if .Stale}} <span class="badge badge-signed_key_expired">stale</span>{{end}}</p>{{end}}
 {{range .Comments}}
-<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div>
+<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><div class="rendered">{{.BodyHTML}}</div></div>
 {{end}}
 {{if .Viewer}}
 <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment">