Scheme-aware highlighting everywhere code renders !59

merged merged by cmc on 2026-08-25 20:34 UTC · krz/gitbay:unpin-code into main

5 files changed, +66 −13

Layout: unified · split

e2e/design_test.go +4 −2
@@ -28,7 +28,8 @@ func TestReadmeRelativeLinks(t *testing.T) {
28 "# site\n\n[guide](docs/guide.md) and [export](docs/paper.html) and "+ 28 "# site\n\n[guide](docs/guide.md) and [export](docs/paper.html) and "+
29 "[abs](https://example.org/x) here\n\n![logo](img/logo.png)\n"+ 29 "[abs](https://example.org/x) here\n\n![logo](img/logo.png)\n"+
30 "![ext](https://example.org/pic.png)\n\n"+ 30 "![ext](https://example.org/pic.png)\n\n"+
31 "| flag | effect |\n|------|--------|\n| `-v` | verbose |\n"), 0o644) 31 "| flag | effect |\n|------|--------|\n| `-v` | verbose |\n\n"+
32 "```go\nfunc main() {}\n```\n"), 0o644)
32 os.WriteFile(filepath.Join(dir, "docs", "guide.md"), []byte("# guide\n"), 0o644) 33 os.WriteFile(filepath.Join(dir, "docs", "guide.md"), []byte("# guide\n"), 0o644)
33 os.WriteFile(filepath.Join(dir, "docs", "paper.org"), []byte("* paper\n"), 0o644) 34 os.WriteFile(filepath.Join(dir, "docs", "paper.org"), []byte("* paper\n"), 0o644)
34 os.WriteFile(filepath.Join(dir, "img", "logo.png"), []byte{0x89, 0x50}, 0o644) 35 os.WriteFile(filepath.Join(dir, "img", "logo.png"), []byte{0x89, 0x50}, 0o644)
@@ -48,6 +49,7 @@ func TestReadmeRelativeLinks(t *testing.T) {
48 `src="https://example.org/pic.png"`, // remote image untouched 49 `src="https://example.org/pic.png"`, // remote image untouched
49 `href="https://example.org/x"`, // absolute untouched 50 `href="https://example.org/x"`, // absolute untouched
50 "<table>", "<td>verbose</td>", // GFM table renders 51 "<table>", "<td>verbose</td>", // GFM table renders
52 `<span class="kd">func</span>`, // fenced code highlighted via classes
51 `href="/alice/site/blob/main/README.md">README.md</a>`, // clickable card header 53 `href="/alice/site/blob/main/README.md">README.md</a>`, // clickable card header
52 `<th>name</th>`, // file table column headers 54 `<th>name</th>`, // file table column headers
53 } { 55 } {
@@ -108,7 +110,7 @@ func TestReadmeRelativeLinks(t *testing.T) {
108 t.Errorf("highlighting not class-based:\n%.2000s", body) 110 t.Errorf("highlighting not class-based:\n%.2000s", body)
109 } 111 }
110 if _, css := inst.get(t, "/static/style.css"); strings.Count(css, "/* Background */") < 2 || 112 if _, css := inst.get(t, "/static/style.css"); strings.Count(css, "/* Background */") < 2 ||
111 !strings.Contains(css, ".chroma, .bg { background: var(--code-bg)") { 113 !strings.Contains(css, ".chroma, .bg { background: transparent") {
112 t.Error("stylesheet missing dual syntax palettes") 114 t.Error("stylesheet missing dual syntax palettes")
113 } 115 }
114 // Explore rows carry topics, license, and updated date. 116 // Explore rows carry topics, license, and updated date.
go.mod +2 −1
@@ -10,6 +10,8 @@ require (
10 github.com/niklasfasching/go-org v1.9.1 10 github.com/niklasfasching/go-org v1.9.1
11 github.com/spf13/cobra v1.10.2 11 github.com/spf13/cobra v1.10.2
12 github.com/yuin/goldmark v1.8.5 12 github.com/yuin/goldmark v1.8.5
13 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
14 go.yaml.in/yaml/v3 v3.0.4
13 golang.org/x/crypto v0.55.0 15 golang.org/x/crypto v0.55.0
14 golang.org/x/net v0.57.0 16 golang.org/x/net v0.57.0
15 golang.org/x/term v0.45.0 17 golang.org/x/term v0.45.0
@@ -30,7 +32,6 @@ require (
30 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 32 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
31 github.com/russross/blackfriday/v2 v2.1.0 // indirect 33 github.com/russross/blackfriday/v2 v2.1.0 // indirect
32 github.com/spf13/pflag v1.0.9 // indirect 34 github.com/spf13/pflag v1.0.9 // indirect
33 go.yaml.in/yaml/v3 v3.0.4 // indirect
34 golang.org/x/sys v0.47.0 // indirect 35 golang.org/x/sys v0.47.0 // indirect
35 golang.org/x/text v0.41.0 // indirect 36 golang.org/x/text v0.41.0 // indirect
36 modernc.org/libc v1.74.4 // indirect 37 modernc.org/libc v1.74.4 // indirect
go.sum +12
@@ -4,8 +4,10 @@ github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67
4github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= 4github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
5github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 5github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
6github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 6github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
7github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
7github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= 8github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
8github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= 9github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
10github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
9github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= 11github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
10github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 12github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
11github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 13github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
@@ -14,6 +16,10 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg
14github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= 16github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
15github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= 17github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
16github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 18github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
19github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
20github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
21github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
22github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
17github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= 23github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
18github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= 24github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
19github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 25github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -48,8 +54,13 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
48github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 54github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
49github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 55github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
50github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 56github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
57github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
58github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
59github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
51github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= 60github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
52github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 61github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
62github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
63github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
53go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 64go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
54go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 65go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
55golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= 66golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
@@ -70,6 +81,7 @@ golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
70golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= 81golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
71gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 82gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
72gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 83gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
84gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
73modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= 85modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
74modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= 86modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
75modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= 87modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
internal/httpd/web.go +45 −7
@@ -23,6 +23,7 @@ import (
23 "github.com/microcosm-cc/bluemonday" 23 "github.com/microcosm-cc/bluemonday"
24 "github.com/niklasfasching/go-org/org" 24 "github.com/niklasfasching/go-org/org"
25 "github.com/yuin/goldmark" 25 "github.com/yuin/goldmark"
26 highlighting "github.com/yuin/goldmark-highlighting/v2"
26 "github.com/yuin/goldmark/extension" 27 "github.com/yuin/goldmark/extension"
27 28
28 "gitbay.org/gitbay/internal/autolink" 29 "gitbay.org/gitbay/internal/autolink"
@@ -765,7 +766,7 @@ var chromaCSS = func() []byte {
765 chromaFormatter.WriteCSS(&buf, styles.Get("friendly")) 766 chromaFormatter.WriteCSS(&buf, styles.Get("friendly"))
766 buf.WriteString("\n@media (prefers-color-scheme: dark) {\n") 767 buf.WriteString("\n@media (prefers-color-scheme: dark) {\n")
767 chromaFormatter.WriteCSS(&buf, styles.Get("github-dark")) 768 chromaFormatter.WriteCSS(&buf, styles.Get("github-dark"))
768 buf.WriteString("}\n.chroma, .bg { background: var(--code-bg) !important; }\n") 769 buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
769 return buf.Bytes() 770 return buf.Bytes()
770}() 771}()
771 772
@@ -828,8 +829,30 @@ func pickReadme(entries []gitutil.TreeEntry) string {
828} 829}
829 830
830// markdown is the shared renderer: GFM (tables, strikethrough, autolinks, 831// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
831// task lists) on top of CommonMark. Raw HTML is still dropped. 832// task lists) on top of CommonMark, with class-based fence highlighting
832var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM)) 833// (the palette lives in the stylesheet, per scheme). Raw HTML is still
834// dropped.
835var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM,
836 highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
837
838// fenceHighlight renders one code block with chroma classes, for org and
839// anything else outside goldmark. Unknown languages fall back to plain.
840func fenceHighlight(source, lang string) string {
841 lexer := lexers.Get(lang)
842 if lexer == nil {
843 lexer = lexers.Fallback
844 }
845 iterator, err := lexer.Tokenise(nil, source)
846 if err != nil {
847 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
848 }
849 var buf bytes.Buffer
850 f := html.New(html.WithClasses(true))
851 if err := f.Format(&buf, styles.Get("friendly"), iterator); err != nil {
852 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
853 }
854 return buf.String()
855}
833 856
834// mdHTML renders user-authored markdown (issue and MR bodies, comments). 857// mdHTML renders user-authored markdown (issue and MR bodies, comments).
835// goldmark's default renderer drops raw HTML, so this is safe as-is. 858// goldmark's default renderer drops raw HTML, so this is safe as-is.
@@ -921,8 +944,16 @@ func renderComments(cs []store.IssueComment, md func(string) template.HTML) []re
921 944
922// ugcPolicy sanitizes rendered repo content before it enters the forge's 945// ugcPolicy sanitizes rendered repo content before it enters the forge's
923// origin: markdown is already safe (goldmark drops raw HTML), but org-mode 946// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
924// output and repo-authored HTML are not. 947// output and repo-authored HTML are not. Chroma's highlighting classes
925var ugcPolicy = bluemonday.UGCPolicy() 948// must survive; the pattern admits only short token codes, not the site's
949// own class names.
950var ugcPolicy = func() *bluemonday.Policy {
951 p := bluemonday.UGCPolicy()
952 p.AllowAttrs("class").
953 Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
954 OnElements("span", "pre", "code", "div")
955 return p
956}()
926 957
927// renderReadme renders a README by extension: markdown, org-mode, and 958// renderReadme renders a README by extension: markdown, org-mode, and
928// (sanitized) HTML richly; everything else as escaped plaintext. 959// (sanitized) HTML richly; everything else as escaped plaintext.
@@ -942,11 +973,18 @@ func renderReadme(name string, raw []byte) template.HTML {
942 return template.HTML(buf.String()) 973 return template.HTML(buf.String())
943 case ".org": 974 case ".org":
944 doc := org.New().Parse(bytes.NewReader(raw), name) 975 doc := org.New().Parse(bytes.NewReader(raw), name)
945 html, err := doc.Write(org.NewHTMLWriter()) 976 writer := org.NewHTMLWriter()
977 writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
978 if inline {
979 return "<code>" + template.HTMLEscapeString(source) + "</code>"
980 }
981 return fenceHighlight(source, lang)
982 }
983 out, err := doc.Write(writer)
946 if err != nil { 984 if err != nil {
947 return plain() 985 return plain()
948 } 986 }
949 return template.HTML(ugcPolicy.Sanitize(html)) 987 return template.HTML(ugcPolicy.Sanitize(out))
950 case ".html", ".htm": 988 case ".html", ".htm":
951 return template.HTML(ugcPolicy.Sanitize(string(raw))) 989 return template.HTML(ugcPolicy.Sanitize(string(raw)))
952 default: 990 default:
internal/web/static/style.css +3 −3
@@ -457,9 +457,9 @@ button.linklike:hover { text-decoration: underline; filter: none; }
457 margin-top: var(--sp-4); 457 margin-top: var(--sp-4);
458 overflow-x: auto; 458 overflow-x: auto;
459} 459}
460/* chroma emits inline styles for a light background; pin code blocks to 460/* Highlighting is class-based with a palette per scheme, so code blocks
461 light colors in both schemes so unstyled tokens stay legible. */ 461 follow the site's own colors. */
462.code { background: #f8f8f8; color: #1a1a1a; padding: var(--sp-3) var(--sp-4); color-scheme: light; } 462.code { background: var(--code-bg); color: var(--fg); padding: var(--sp-3) var(--sp-4); }
463.code pre { margin: 0; background: transparent !important; } 463.code pre { margin: 0; background: transparent !important; }
464@media (prefers-color-scheme: dark) { 464@media (prefers-color-scheme: dark) {
465 .code { border-color: var(--line); } 465 .code { border-color: var(--line); }