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 | 30 | mustGit(t, dir, env, "push", "-q", "origin", "main") |
| 31 | 31 | |
| 32 | 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 | 34 | t.Fatalf("markdown not rendered: %d\n%s", status, body) |
| 35 | 35 | } |
| 36 | 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 | 85 | if status != 200 || !strings.Contains(body, "src/") || !strings.Contains(body, "README.md") { |
| 86 | 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 | 89 | t.Fatalf("README not rendered:\n%s", body) |
| 90 | 90 | } |
| 91 | 91 | for _, tab := range []string{">Issues<", ">Merge requests<"} { |
internal/httpd/headings_test.go
added
+20
| @@ -0,0 +1,20 @@ |
| 1 | package httpd |
| 2 | |
| 3 | import ( |
| 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). |
| 10 | func 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 | 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. |
| 1249 | var 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. |
| 1255 | func 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 | |
| 1247 | 1262 | func renderReadme(name string, raw []byte) template.HTML { |
| 1248 | 1263 | plain := func() template.HTML { |
| 1249 | 1264 | return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>") |
| @@ -1257,9 +1272,9 @@ func renderReadme(name string, raw []byte) template.HTML { |
| 1257 | 1272 | if markdown.Convert(raw, &buf) != nil { |
| 1258 | 1273 | return plain() |
| 1259 | 1274 | } |
| 1260 | | return template.HTML(buf.String()) |
| 1275 | return demoteHeadings(template.HTML(buf.String())) |
| 1261 | 1276 | case ".org": |
| 1262 | | return renderOrg(name, raw, true, plain) |
| 1277 | return demoteHeadings(renderOrg(name, raw, true, plain)) |
| 1263 | 1278 | case ".html", ".htm": |
| 1264 | 1279 | return template.HTML(ugcPolicy.Sanitize(string(raw))) |
| 1265 | 1280 | default: |