Commit ab515bab17

ab515bab173c501ad2228f0af8068a8730f8b5ea

parent: 15961c4783

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-04 03:01 UTC

web: a rendered document's headings sit under the page's own

A README's h1 followed the repository's h1 as a second top-level
heading, and a wiki page's followed the page title. Rendered markdown
and org move every heading down one level; ids and anchors are
untouched.

Closes #133

Layout: unified · split

e2e/blobrender_test.go +1 −1
@@ -30,7 +30,7 @@ func TestBlobRendersMarkup(t *testing.T) {
30 mustGit(t, dir, env, "push", "-q", "origin", "main") 30 mustGit(t, dir, env, "push", "-q", "origin", "main")
31 31
32 status, body := inst.get(t, "/alice/docs/blob/main/docs/notes.md") 32 status, body := inst.get(t, "/alice/docs/blob/main/docs/notes.md")
33 if status != 200 || !strings.Contains(body, "<h1") || !strings.Contains(body, "Field notes</h1>") { 33 if status != 200 || !strings.Contains(body, `<h2 id="field-notes">Field notes</h2>`) {
34 t.Fatalf("markdown not rendered: %d\n%s", status, body) 34 t.Fatalf("markdown not rendered: %d\n%s", status, body)
35 } 35 }
36 if !strings.Contains(body, `href="?view=source"`) || !strings.Contains(body, "<strong>rendered</strong>") { 36 if !strings.Contains(body, `href="?view=source"`) || !strings.Contains(body, "<strong>rendered</strong>") {
e2e/web_test.go +1 −1
@@ -85,7 +85,7 @@ func TestWebUI(t *testing.T) {
85 if status != 200 || !strings.Contains(body, "src/") || !strings.Contains(body, "README.md") { 85 if status != 200 || !strings.Contains(body, "src/") || !strings.Contains(body, "README.md") {
86 t.Fatalf("repo home: %d\n%s", status, body) 86 t.Fatalf("repo home: %d\n%s", status, body)
87 } 87 }
88 if !strings.Contains(body, "<h1 id=\"hello-site\">hello site</h1>") || !strings.Contains(body, "<em>markdown</em>") { 88 if !strings.Contains(body, "<h2 id=\"hello-site\">hello site</h2>") || !strings.Contains(body, "<em>markdown</em>") {
89 t.Fatalf("README not rendered:\n%s", body) 89 t.Fatalf("README not rendered:\n%s", body)
90 } 90 }
91 for _, tab := range []string{">Issues<", ">Merge requests<"} { 91 for _, tab := range []string{">Issues<", ">Merge requests<"} {
internal/httpd/headings_test.go added +20
@@ -0,0 +1,20 @@
1package httpd
2
3import (
4 "strings"
5 "testing"
6)
7
8// A rendered README sits under the page's own h1, so its headings move
9// down one level, ids intact; h6 stays h6 (#133).
10func TestDemoteHeadings(t *testing.T) {
11 out := string(renderReadme("README.md", []byte("# Top\n\n## Next\n\n###### Deep\n")))
12 for _, want := range []string{`<h2 id="top">Top</h2>`, `<h3 id="next">Next</h3>`, `<h6 id="deep">Deep</h6>`} {
13 if !strings.Contains(out, want) {
14 t.Errorf("lacks %s:\n%s", want, out)
15 }
16 }
17 if strings.Contains(out, "<h1") {
18 t.Errorf("an h1 survived:\n%s", out)
19 }
20}
internal/httpd/web.go +17 −2
@@ -1244,6 +1244,21 @@ func renderOrg(name string, raw []byte, contents bool, fallback func() template.
1244 return template.HTML(ugcPolicy.Sanitize(out)) 1244 return template.HTML(ugcPolicy.Sanitize(out))
1245} 1245}
1246 1246
1247// headingTag matches an opening or closing h1..h5 tag, so a rendered
1248// document's headings can move down one level.
1249var headingTag = regexp.MustCompile(`<(/?)h([1-5])([\s>])`)
1250
1251// demoteHeadings moves every heading in a rendered document down one
1252// level: the page it sits on already has its h1 (the repository, the
1253// file, the wiki page), so a README's own h1 would be a second top-level
1254// heading in the outline (#133). Ids and anchors are untouched.
1255func demoteHeadings(h template.HTML) template.HTML {
1256 return template.HTML(headingTag.ReplaceAllStringFunc(string(h), func(m string) string {
1257 sub := headingTag.FindStringSubmatch(m)
1258 return "<" + sub[1] + "h" + string(rune(sub[2][0]+1)) + sub[3]
1259 }))
1260}
1261
1247func renderReadme(name string, raw []byte) template.HTML { 1262func renderReadme(name string, raw []byte) template.HTML {
1248 plain := func() template.HTML { 1263 plain := func() template.HTML {
1249 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>") 1264 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
@@ -1257,9 +1272,9 @@ func renderReadme(name string, raw []byte) template.HTML {
1257 if markdown.Convert(raw, &buf) != nil { 1272 if markdown.Convert(raw, &buf) != nil {
1258 return plain() 1273 return plain()
1259 } 1274 }
1260 return template.HTML(buf.String()) 1275 return demoteHeadings(template.HTML(buf.String()))
1261 case ".org": 1276 case ".org":
1262 return renderOrg(name, raw, true, plain) 1277 return demoteHeadings(renderOrg(name, raw, true, plain))
1263 case ".html", ".htm": 1278 case ".html", ".htm":
1264 return template.HTML(ugcPolicy.Sanitize(string(raw))) 1279 return template.HTML(ugcPolicy.Sanitize(string(raw)))
1265 default: 1280 default: