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 | 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>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 | 89 | t.Fatalf("README not rendered:\n%s", body) |
| 90 | 90 | } |
| 91 | 91 | for _, tab := range []string{">Issues<", ">Merge requests<"} { |
internal/httpd/anchors_test.go
added
+40
| @@ -0,0 +1,40 @@ |
| 1 | package httpd |
| 2 | |
| 3 | import ( |
| 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). |
| 14 | func 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). |
| 25 | func 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 | |
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 5 | 7 | "errors" |
| 6 | 8 | "fmt" |
| 7 | 9 | "hash/fnv" |
| @@ -30,6 +32,7 @@ import ( |
| 30 | 32 | "github.com/yuin/goldmark" |
| 31 | 33 | highlighting "github.com/yuin/goldmark-highlighting/v2" |
| 32 | 34 | "github.com/yuin/goldmark/extension" |
| 35 | "github.com/yuin/goldmark/parser" |
| 33 | 36 | |
| 34 | 37 | "gitbay.org/gitbay/internal/autolink" |
| 35 | 38 | "gitbay.org/gitbay/internal/control" |
| @@ -61,7 +64,23 @@ func (s *Server) siteName() string { |
| 61 | 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). |
| 70 | var 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 | |
| 64 | 77 | func (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 | 84 | w.Header().Set("Content-Type", "text/css; charset=utf-8") |
| 66 | 85 | w.Write(web.StyleCSS) |
| 67 | 86 | w.Write(chromaCSS) |
| @@ -1011,8 +1030,12 @@ func pickReadme(entries []gitutil.TreeEntry) string { |
| 1011 | 1030 | // task lists) on top of CommonMark, with class-based fence highlighting |
| 1012 | 1031 | // (the palette lives in the stylesheet, per scheme). Raw HTML is still |
| 1013 | 1032 | // dropped. |
| 1014 | | var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM, |
| 1015 | | highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true))))) |
| 1033 | // Headings carry ids so a README or wiki section can be linked to, the |
| 1034 | // way org headings already are (#132). |
| 1035 | var markdown = goldmark.New( |
| 1036 | goldmark.WithParserOptions(parser.WithAutoHeadingID()), |
| 1037 | goldmark.WithExtensions(extension.GFM, |
| 1038 | highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true))))) |
| 1016 | 1039 | |
| 1017 | 1040 | // fenceHighlight renders one code block with chroma classes, for org and |
| 1018 | 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 | 764 | /* rendered markdown / org / html */ |
| 765 | 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 | 769 | /* line length is a reading constraint, so it applies to prose only — |
| 767 | 770 | tables, code blocks and images keep the full column */ |
| 768 | 771 | .rendered > p, |