Commit 9d8fd17820

9d8fd17820457f0c60320c9f4a8b0da1db45a6b0

parent: ddab521653

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

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

web: markdown headings carry ids, and the stylesheet revalidates

goldmark was built without auto heading ids, so a .md README or wiki
page had no section links while org pages did; task-list boxes had no
styling. The 62 KB stylesheet was served with no ETag or Cache-Control
while fonts got a week. Headings now carry ids, task boxes are sized
to the prose, and the stylesheet carries a hash ETag with a day's
lifetime and answers a matching If-None-Match with 304.

Closes #132

Layout: unified · split

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>hello site</h1>") || !strings.Contains(body, "<em>markdown</em>") { 88 if !strings.Contains(body, "<h1 id=\"hello-site\">hello site</h1>") || !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/anchors_test.go added +40
@@ -0,0 +1,40 @@
1package httpd
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "strings"
7 "testing"
8
9 "gitbay.org/gitbay/internal/config"
10)
11
12// Markdown headings carry ids, so a README section can be deep-linked
13// the way org headings already could (#132).
14func TestMarkdownHeadingAnchors(t *testing.T) {
15 out := string(renderReadme("README.md", []byte("# Getting started\n\n## Two words\n\n- [ ] a task\n")))
16 for _, want := range []string{`id="getting-started"`, `id="two-words"`, `type="checkbox"`} {
17 if !strings.Contains(out, want) {
18 t.Errorf("rendered markdown lacks %s:\n%s", want, out)
19 }
20 }
21}
22
23// The stylesheet carries an ETag and a cache lifetime; a revalidation
24// with the same tag is a 304 with no body (#132).
25func TestStylesheetRevalidates(t *testing.T) {
26 s := New(config.Default(), nil)
27 first := httptest.NewRecorder()
28 s.stylesheet(first, httptest.NewRequest("GET", "/static/style.css", nil))
29 tag := first.Header().Get("ETag")
30 if first.Code != 200 || tag == "" || first.Body.Len() == 0 || !strings.Contains(first.Header().Get("Cache-Control"), "max-age") {
31 t.Fatalf("first fetch: %d etag=%q cc=%q bytes=%d", first.Code, tag, first.Header().Get("Cache-Control"), first.Body.Len())
32 }
33 req := httptest.NewRequest("GET", "/static/style.css", nil)
34 req.Header.Set("If-None-Match", tag)
35 second := httptest.NewRecorder()
36 s.stylesheet(second, req)
37 if second.Code != http.StatusNotModified || second.Body.Len() != 0 {
38 t.Fatalf("revalidation: %d with %d bytes", second.Code, second.Body.Len())
39 }
40}
internal/httpd/web.go +25 −2
@@ -2,6 +2,8 @@ package httpd
2 2
3import ( 3import (
4 "bytes" 4 "bytes"
5 "crypto/sha256"
6 "encoding/hex"
5 "errors" 7 "errors"
6 "fmt" 8 "fmt"
7 "hash/fnv" 9 "hash/fnv"
@@ -30,6 +32,7 @@ import (
30 "github.com/yuin/goldmark" 32 "github.com/yuin/goldmark"
31 highlighting "github.com/yuin/goldmark-highlighting/v2" 33 highlighting "github.com/yuin/goldmark-highlighting/v2"
32 "github.com/yuin/goldmark/extension" 34 "github.com/yuin/goldmark/extension"
35 "github.com/yuin/goldmark/parser"
33 36
34 "gitbay.org/gitbay/internal/autolink" 37 "gitbay.org/gitbay/internal/autolink"
35 "gitbay.org/gitbay/internal/control" 38 "gitbay.org/gitbay/internal/control"
@@ -61,7 +64,23 @@ func (s *Server) siteName() string {
61 return strings.TrimSuffix(h, "/") 64 return strings.TrimSuffix(h, "/")
62} 65}
63 66
67// stylesheetETag is the hash of what stylesheet serves, computed once:
68// a browser revalidates with If-None-Match and gets a 304 until a deploy
69// changes the bytes (#132).
70var stylesheetETag = func() string {
71 h := sha256.New()
72 h.Write(web.StyleCSS)
73 h.Write(chromaCSS)
74 return `"` + hex.EncodeToString(h.Sum(nil))[:16] + `"`
75}()
76
64func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) { 77func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
78 w.Header().Set("ETag", stylesheetETag)
79 w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
80 if r.Header.Get("If-None-Match") == stylesheetETag {
81 w.WriteHeader(http.StatusNotModified)
82 return
83 }
65 w.Header().Set("Content-Type", "text/css; charset=utf-8") 84 w.Header().Set("Content-Type", "text/css; charset=utf-8")
66 w.Write(web.StyleCSS) 85 w.Write(web.StyleCSS)
67 w.Write(chromaCSS) 86 w.Write(chromaCSS)
@@ -1011,8 +1030,12 @@ func pickReadme(entries []gitutil.TreeEntry) string {
1011// task lists) on top of CommonMark, with class-based fence highlighting 1030// task lists) on top of CommonMark, with class-based fence highlighting
1012// (the palette lives in the stylesheet, per scheme). Raw HTML is still 1031// (the palette lives in the stylesheet, per scheme). Raw HTML is still
1013// dropped. 1032// dropped.
1014var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM, 1033// Headings carry ids so a README or wiki section can be linked to, the
1015 highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true))))) 1034// way org headings already are (#132).
1035var markdown = goldmark.New(
1036 goldmark.WithParserOptions(parser.WithAutoHeadingID()),
1037 goldmark.WithExtensions(extension.GFM,
1038 highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
1016 1039
1017// fenceHighlight renders one code block with chroma classes, for org and 1040// fenceHighlight renders one code block with chroma classes, for org and
1018// anything else outside goldmark. Unknown languages fall back to plain. 1041// anything else outside goldmark. Unknown languages fall back to plain.
internal/web/static/style.css +3
@@ -763,6 +763,9 @@ button.linklike:hover { text-decoration: underline; filter: none; }
763 763
764/* rendered markdown / org / html */ 764/* rendered markdown / org / html */
765.rendered { max-width: 100%; overflow-wrap: anywhere; } 765.rendered { max-width: 100%; overflow-wrap: anywhere; }
766/* GFM task lists: the box sits in the line, sized to the prose. */
767.rendered li input[type="checkbox"] { width: 0.9em; height: 0.9em; margin: 0 0.35em 0 0; vertical-align: -0.05em; accent-color: var(--accent); }
768.rendered li:has(> input[type="checkbox"]) { list-style: none; margin-left: -1.2em; }
766/* line length is a reading constraint, so it applies to prose only — 769/* line length is a reading constraint, so it applies to prose only —
767 tables, code blocks and images keep the full column */ 770 tables, code blocks and images keep the full column */
768.rendered > p, 771.rendered > p,