Class-based syntax highlighting with light and dark palettes !58

merged merged by cmc on 2026-08-25 20:12 UTC · krz/gitbay:dark-highlight into main

3 files changed, +33 −7

Layout: unified · split

e2e/design_test.go +10
@@ -101,6 +101,16 @@ func TestReadmeRelativeLinks(t *testing.T) {
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"`) { 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 t.Errorf("blob image preview missing:\n%s", body) 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 // Explore rows carry topics, license, and updated date. 114 // Explore rows carry topics, license, and updated date.
105 inst.ssh(t, aliceKey, "", "repo", "topics", "add", "alice/site", "web") 115 inst.ssh(t, aliceKey, "", "repo", "topics", "add", "alice/site", "web")
106 // Bare 0BSD grant (no notice-retention clause), wrapped mid-sentence. 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// securityHeaders sets defensive response headers on every reply. The CSP 130// securityHeaders sets defensive response headers on every reply. The CSP
131// is strict where it can be: no scripts at all (the UI needs none), no 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 132// plugins, no embedding. Inline styles are allowed because label chips
133// inline style attributes on highlighted code and label chips carry their 133// carry their color inline (chroma is class-based so the syntax palette
134// color inline. Images may load from anywhere so external README images 134// can follow the color scheme). Images may load from anywhere so external README images
135// still render; they are the one thing a reader-facing forge can't police 135// still render; they are the one thing a reader-facing forge can't police
136// without a proxy. 136// without a proxy.
137func (s *Server) securityHeaders(next http.Handler) http.Handler { 137func (s *Server) securityHeaders(next http.Handler) http.Handler {
internal/httpd/web.go +20 −4
@@ -53,6 +53,7 @@ func (s *Server) siteName() string {
53func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) { 53func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
54 w.Header().Set("Content-Type", "text/css; charset=utf-8") 54 w.Header().Set("Content-Type", "text/css; charset=utf-8")
55 w.Write(web.StyleCSS) 55 w.Write(web.StyleCSS)
56 w.Write(chromaCSS)
56} 57}
57 58
58func (s *Server) favicon(w http.ResponseWriter, r *http.Request) { 59func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
@@ -734,25 +735,40 @@ type numberedLine struct {
734 Text string 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.
740var chromaFormatter = html.New(html.WithClasses(true),
741 html.WithLineNumbers(true), html.LineNumbersInTable(false),
742 html.WithLinkableLineNumbers(true, "L"))
743
737func highlight(filePath string, data []byte) template.HTML { 744func highlight(filePath string, data []byte) template.HTML {
738 lexer := lexers.Match(filePath) 745 lexer := lexers.Match(filePath)
739 if lexer == nil { 746 if lexer == nil {
740 lexer = lexers.Fallback 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 iterator, err := lexer.Tokenise(nil, string(data)) 749 iterator, err := lexer.Tokenise(nil, string(data))
746 if err != nil { 750 if err != nil {
747 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>") 751 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
748 } 752 }
749 var buf bytes.Buffer 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 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>") 755 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
752 } 756 }
753 return template.HTML(buf.String()) 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.
763var 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
756func (s *Server) raw(w http.ResponseWriter, r *http.Request) { 772func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
757 p, ok := s.repoFor(w, r, r.PathValue("ref")) 773 p, ok := s.repoFor(w, r, r.PathValue("ref"))
758 if !ok { 774 if !ok {