Commit e278948b45
e278948b45ed675b9da3799bb89de836e3f2a954
parent: 14a47d9502
Verified · cmc ci/build: success
cmc <hello@cleberg.net> · 2026-08-25T20:12:14Z
Class-based syntax highlighting with light and dark palettes
chroma emits classes instead of inline colors; the stylesheet appends
two generated palettes — friendly by default, github-dark under the
same prefers-color-scheme query the rest of the site uses — with the
site's --code-bg kept as the background in both. Ref #10
e2e/design_test.go
+10
| @@ -101,6 +101,16 @@ func TestReadmeRelativeLinks(t *testing.T) { |
| 101 | 101 | if _, body := inst.get(t, "/alice/site/blob/main/img/logo.png"); !strings.Contains(body, `<img src="/alice/site/raw/main/img/logo.png"`) { |
| 102 | 102 | t.Errorf("blob image preview missing:\n%s", body) |
| 103 | 103 | } |
| 104 | // Highlighting is class-based so the palette follows the color scheme: |
| 105 | // no inline colors on code, and the stylesheet carries both palettes. |
| 106 | if _, body := inst.get(t, "/alice/site/blob/main/README.md"); !strings.Contains(body, `class="chroma"`) || |
| 107 | strings.Contains(body, "style=\"color") { |
| 108 | t.Errorf("highlighting not class-based:\n%.2000s", body) |
| 109 | } |
| 110 | if _, css := inst.get(t, "/static/style.css"); strings.Count(css, "/* Background */") < 2 || |
| 111 | !strings.Contains(css, ".chroma, .bg { background: var(--code-bg)") { |
| 112 | t.Error("stylesheet missing dual syntax palettes") |
| 113 | } |
| 104 | 114 | // Explore rows carry topics, license, and updated date. |
| 105 | 115 | inst.ssh(t, aliceKey, "", "repo", "topics", "add", "alice/site", "web") |
| 106 | 116 | // Bare 0BSD grant (no notice-retention clause), wrapped mid-sentence. |
internal/httpd/routes.go
+3 −3
| @@ -129,9 +129,9 @@ func (s *Server) Handler() http.Handler { |
| 129 | 129 | |
| 130 | 130 | // securityHeaders sets defensive response headers on every reply. The CSP |
| 131 | 131 | // is strict where it can be: no scripts at all (the UI needs none), no |
| 132 | | // plugins, no embedding. Inline styles are allowed because chroma emits |
| 133 | | // inline style attributes on highlighted code and label chips carry their |
| 134 | | // color inline. Images may load from anywhere so external README images |
| 132 | // plugins, no embedding. Inline styles are allowed because label chips |
| 133 | // carry their color inline (chroma is class-based so the syntax palette |
| 134 | // can follow the color scheme). Images may load from anywhere so external README images |
| 135 | 135 | // still render; they are the one thing a reader-facing forge can't police |
| 136 | 136 | // without a proxy. |
| 137 | 137 | func (s *Server) securityHeaders(next http.Handler) http.Handler { |
internal/httpd/web.go
+20 −4
| @@ -53,6 +53,7 @@ func (s *Server) siteName() string { |
| 53 | 53 | func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) { |
| 54 | 54 | w.Header().Set("Content-Type", "text/css; charset=utf-8") |
| 55 | 55 | w.Write(web.StyleCSS) |
| 56 | w.Write(chromaCSS) |
| 56 | 57 | } |
| 57 | 58 | |
| 58 | 59 | func (s *Server) favicon(w http.ResponseWriter, r *http.Request) { |
| @@ -734,25 +735,40 @@ type numberedLine struct { |
| 734 | 735 | Text string |
| 735 | 736 | } |
| 736 | 737 | |
| 738 | // chromaFormatter emits class-based markup (no inline colors), so the |
| 739 | // stylesheet can swap palettes with the color scheme. |
| 740 | var chromaFormatter = html.New(html.WithClasses(true), |
| 741 | html.WithLineNumbers(true), html.LineNumbersInTable(false), |
| 742 | html.WithLinkableLineNumbers(true, "L")) |
| 743 | |
| 737 | 744 | func highlight(filePath string, data []byte) template.HTML { |
| 738 | 745 | lexer := lexers.Match(filePath) |
| 739 | 746 | if lexer == nil { |
| 740 | 747 | lexer = lexers.Fallback |
| 741 | 748 | } |
| 742 | | style := styles.Get("friendly") |
| 743 | | formatter := html.New(html.WithLineNumbers(true), html.LineNumbersInTable(false), |
| 744 | | html.WithLinkableLineNumbers(true, "L")) |
| 745 | 749 | iterator, err := lexer.Tokenise(nil, string(data)) |
| 746 | 750 | if err != nil { |
| 747 | 751 | return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>") |
| 748 | 752 | } |
| 749 | 753 | var buf bytes.Buffer |
| 750 | | if err := formatter.Format(&buf, style, iterator); err != nil { |
| 754 | if err := chromaFormatter.Format(&buf, styles.Get("friendly"), iterator); err != nil { |
| 751 | 755 | return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>") |
| 752 | 756 | } |
| 753 | 757 | return template.HTML(buf.String()) |
| 754 | 758 | } |
| 755 | 759 | |
| 760 | // chromaCSS is both syntax palettes: light by default, dark under the same |
| 761 | // media query the rest of the stylesheet uses. The site's --code-bg stays |
| 762 | // the background either way. |
| 763 | var chromaCSS = func() []byte { |
| 764 | var buf bytes.Buffer |
| 765 | chromaFormatter.WriteCSS(&buf, styles.Get("friendly")) |
| 766 | buf.WriteString("\n@media (prefers-color-scheme: dark) {\n") |
| 767 | chromaFormatter.WriteCSS(&buf, styles.Get("github-dark")) |
| 768 | buf.WriteString("}\n.chroma, .bg { background: var(--code-bg) !important; }\n") |
| 769 | return buf.Bytes() |
| 770 | }() |
| 771 | |
| 756 | 772 | func (s *Server) raw(w http.ResponseWriter, r *http.Request) { |
| 757 | 773 | p, ok := s.repoFor(w, r, r.PathValue("ref")) |
| 758 | 774 | if !ok { |