feat(web): add the chroma highlighter and a directory-level README helper
Adds highlight.go (chroma lexing by file name with CSS classes, one numbered line span with a #L<n> anchor per line, visible markers for bidi and control characters) and splits summary.readme into readmeIn so tree pages can reuse it. chroma v2.27.0 becomes a direct dependency. The tree, blob and raw pages, the chroma theme CSS and the tests are not written yet; the highlighter is not wired to a route.
- commit
- 06c0b245200e7a261e38ecde6da4e65a9e407c3e
- author
- brice · · 2026-10-10 13:06 UTC
- committer
- brice · · 2026-10-10 13:25 UTC
- parent
- 4f18354
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +242 -5
M go.mod +2 -0
| @@ -16,6 +16,7 @@ require ( | |||
| 16 | 16 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | |
| 17 | 17 | connectrpc.com/connect v1.21.0 | |
| 18 | 18 | github.com/a-h/templ v0.3.1020 | |
| 19 | + | github.com/alecthomas/chroma/v2 v2.27.0 | |
| 19 | 20 | github.com/bnema/zerowrap v1.4.1 | |
| 20 | 21 | github.com/spf13/cobra v1.10.2 | |
| 21 | 22 | github.com/stretchr/testify v1.12.1 | |
| @@ -64,6 +65,7 @@ require ( | |||
| 64 | 65 | github.com/containerd/errdefs/pkg v0.3.0 // indirect | |
| 65 | 66 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect | |
| 66 | 67 | github.com/distribution/reference v0.6.0 // indirect | |
| 68 | + | github.com/dlclark/regexp2/v2 v2.2.1 // indirect | |
| 67 | 69 | github.com/docker/cli v29.8.0+incompatible // indirect | |
| 68 | 70 | github.com/docker/docker-credential-helpers v0.9.9 // indirect | |
| 69 | 71 | github.com/docker/go-connections v0.8.1 // indirect | |
| @@ -16,6 +16,7 @@ require ( | |||||
| 16 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | 16 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | ||
| 17 | connectrpc.com/connect v1.21.0 | 17 | connectrpc.com/connect v1.21.0 | ||
| 18 | github.com/a-h/templ v0.3.1020 | 18 | github.com/a-h/templ v0.3.1020 | ||
| 19 | + | github.com/alecthomas/chroma/v2 v2.27.0 | |||
| 19 | github.com/bnema/zerowrap v1.4.1 | 20 | github.com/bnema/zerowrap v1.4.1 | ||
| 20 | github.com/spf13/cobra v1.10.2 | 21 | github.com/spf13/cobra v1.10.2 | ||
| 21 | github.com/stretchr/testify v1.12.1 | 22 | github.com/stretchr/testify v1.12.1 | ||
| @@ -64,6 +65,7 @@ require ( | |||||
| 64 | github.com/containerd/errdefs/pkg v0.3.0 // indirect | 65 | github.com/containerd/errdefs/pkg v0.3.0 // indirect | ||
| 65 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect | 66 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect | ||
| 66 | github.com/distribution/reference v0.6.0 // indirect | 67 | github.com/distribution/reference v0.6.0 // indirect | ||
| 68 | + | github.com/dlclark/regexp2/v2 v2.2.1 // indirect | |||
| 67 | github.com/docker/cli v29.8.0+incompatible // indirect | 69 | github.com/docker/cli v29.8.0+incompatible // indirect | ||
| 68 | github.com/docker/docker-credential-helpers v0.9.9 // indirect | 70 | github.com/docker/docker-credential-helpers v0.9.9 // indirect | ||
| 69 | github.com/docker/go-connections v0.8.1 // indirect | 71 | github.com/docker/go-connections v0.8.1 // indirect | ||
M go.sum +6 -1 generated file: collapsed
A internal/adapters/web/highlight.go +223 -0
| @@ -0,0 +1,223 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "html" | |
| 7 | + | "html/template" | |
| 8 | + | "path" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | "unicode" | |
| 12 | + | "unicode/utf8" | |
| 13 | + | ||
| 14 | + | "github.com/alecthomas/chroma/v2" | |
| 15 | + | "github.com/alecthomas/chroma/v2/lexers" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // code is a source file ready for the code view: one <span class="line"> per | |
| 19 | + | // line, each with its number as an anchor, tokens as chroma CSS classes (the | |
| 20 | + | // colours are in static/css/55-chroma.css, no inline style). | |
| 21 | + | type code struct { | |
| 22 | + | html template.HTML | |
| 23 | + | lines int | |
| 24 | + | // lang is the name of the language the file was highlighted as, "" for | |
| 25 | + | // plain text. | |
| 26 | + | lang string | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // highlight renders src, the content of the file name, for the code view. | |
| 30 | + | // The language comes from the file name. Whatever goes wrong while lexing | |
| 31 | + | // (no lexer, a lexer error or timeout) degrades to plain text: the source is | |
| 32 | + | // never lost or altered. Characters that can mislead a reader (bidi | |
| 33 | + | // controls, invisible and control characters) are replaced by visible | |
| 34 | + | // markers. | |
| 35 | + | func highlight(name, src string) code { | |
| 36 | + | src = normaliseText(src) | |
| 37 | + | var lines [][]chroma.Token | |
| 38 | + | lang := "" | |
| 39 | + | if lx := lexerFor(name); lx != nil { | |
| 40 | + | if toks, ok := tokenise(lx, src); ok { | |
| 41 | + | lines, lang = chroma.SplitTokensIntoLines(toks), lx.Config().Name | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | if lines == nil { | |
| 45 | + | for _, l := range splitSource(src) { | |
| 46 | + | lines = append(lines, []chroma.Token{{Type: chroma.Text, Value: l}}) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | var b strings.Builder | |
| 50 | + | b.Grow(len(src) + len(lines)*64) | |
| 51 | + | for i, line := range lines { | |
| 52 | + | n := strconv.Itoa(i + 1) | |
| 53 | + | b.WriteString(`<span class="line" id="L` + n + `"><a class="ln" href="#L` + n + `">` + n + `</a>`) | |
| 54 | + | for _, tk := range line { | |
| 55 | + | v := strings.TrimRight(tk.Value, "\n") | |
| 56 | + | if v == "" { | |
| 57 | + | continue | |
| 58 | + | } | |
| 59 | + | if c := tokenClass(tk.Type); c != "" { | |
| 60 | + | b.WriteString(`<span class="` + c + `">`) | |
| 61 | + | writeMarked(&b, v) | |
| 62 | + | b.WriteString(`</span>`) | |
| 63 | + | } else { | |
| 64 | + | writeMarked(&b, v) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | b.WriteString("</span>\n") | |
| 68 | + | } | |
| 69 | + | // #nosec G203 -- every piece of the source goes through writeMarked, which | |
| 70 | + | // escapes it; the rest is markup and class names of this file. | |
| 71 | + | return code{html: template.HTML(b.String()), lines: len(lines), lang: lang} | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // maxViewLines bounds the lines of the code view: every line costs about a | |
| 75 | + | // hundred bytes of HTML, and a megabyte of line feeds must not become a | |
| 76 | + | // hundred megabytes of page. | |
| 77 | + | const maxViewLines = 20000 | |
| 78 | + | ||
| 79 | + | // countLines is the number of lines of src, as the code view numbers them. | |
| 80 | + | func countLines(src []byte) int { | |
| 81 | + | n := strings.Count(string(src), "\n") | |
| 82 | + | if len(src) > 0 && src[len(src)-1] != '\n' { | |
| 83 | + | n++ | |
| 84 | + | } | |
| 85 | + | return n | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // normaliseText makes src valid UTF-8 with "\n" line ends. | |
| 89 | + | func normaliseText(src string) string { | |
| 90 | + | if !utf8.ValidString(src) { | |
| 91 | + | src = strings.ToValidUTF8(src, "\uFFFD") | |
| 92 | + | } | |
| 93 | + | if strings.Contains(src, "\r") { | |
| 94 | + | src = strings.ReplaceAll(src, "\r\n", "\n") | |
| 95 | + | src = strings.ReplaceAll(src, "\r", "\n") | |
| 96 | + | } | |
| 97 | + | return src | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // lexerFor is the lexer for a file name, nil for plain text. | |
| 101 | + | func lexerFor(name string) chroma.Lexer { | |
| 102 | + | lx := lexers.Match(path.Base(name)) | |
| 103 | + | if lx == nil { | |
| 104 | + | return nil | |
| 105 | + | } | |
| 106 | + | return chroma.Coalesce(lx) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // tokenise lexes src. It reports false when the tokens do not add up to the | |
| 110 | + | // source, which is what a lexer that gave up half-way (its regexp timeout) | |
| 111 | + | // leaves behind. | |
| 112 | + | func tokenise(lx chroma.Lexer, src string) ([]chroma.Token, bool) { | |
| 113 | + | it, err := lx.Tokenise(&chroma.TokeniseOptions{State: "root", EnsureLF: true}, src) | |
| 114 | + | if err != nil { | |
| 115 | + | return nil, false | |
| 116 | + | } | |
| 117 | + | toks := it.Tokens() | |
| 118 | + | n := 0 | |
| 119 | + | for _, t := range toks { | |
| 120 | + | n += len(t.Value) | |
| 121 | + | } | |
| 122 | + | if strings.TrimRight(src, "\n") == "" { | |
| 123 | + | return nil, n == len(src) || n == len(src)+1 | |
| 124 | + | } | |
| 125 | + | // A lexer may add a final line feed (EnsureNL) but nothing else. | |
| 126 | + | return toks, len(strings.TrimRight(joinTokens(toks), "\n")) == len(strings.TrimRight(src, "\n")) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func joinTokens(toks []chroma.Token) string { | |
| 130 | + | var b strings.Builder | |
| 131 | + | for _, t := range toks { | |
| 132 | + | b.WriteString(t.Value) | |
| 133 | + | } | |
| 134 | + | return b.String() | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // splitSource splits plain text into lines, without the empty element after | |
| 138 | + | // a final line feed. | |
| 139 | + | func splitSource(src string) []string { | |
| 140 | + | if src == "" { | |
| 141 | + | return nil | |
| 142 | + | } | |
| 143 | + | ls := strings.Split(src, "\n") | |
| 144 | + | if ls[len(ls)-1] == "" { | |
| 145 | + | ls = ls[:len(ls)-1] | |
| 146 | + | } | |
| 147 | + | return ls | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // tokenClass is the CSS class of a token type, "" for none, found the way | |
| 151 | + | // chroma's HTML formatter finds it: the type, then its parents. | |
| 152 | + | func tokenClass(t chroma.TokenType) string { | |
| 153 | + | for t != 0 { | |
| 154 | + | if c, ok := chroma.StandardTypes[t]; ok { | |
| 155 | + | return c | |
| 156 | + | } | |
| 157 | + | t = t.Parent() | |
| 158 | + | } | |
| 159 | + | return chroma.StandardTypes[t] | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // misleading are the characters, other than the controls, that the code | |
| 163 | + | // view shows as markers instead of itself: the bidirectional controls of | |
| 164 | + | // "Trojan Source" attacks (CVE-2021-42574) and the characters with no | |
| 165 | + | // glyph. | |
| 166 | + | var misleading = map[rune]string{ | |
| 167 | + | 0x061C: "arabic letter mark", | |
| 168 | + | 0x200B: "zero width space", | |
| 169 | + | 0x200E: "left-to-right mark", | |
| 170 | + | 0x200F: "right-to-left mark", | |
| 171 | + | 0x202A: "left-to-right embedding", | |
| 172 | + | 0x202B: "right-to-left embedding", | |
| 173 | + | 0x202C: "pop directional formatting", | |
| 174 | + | 0x202D: "left-to-right override", | |
| 175 | + | 0x202E: "right-to-left override", | |
| 176 | + | 0x2060: "word joiner", | |
| 177 | + | 0x2066: "left-to-right isolate", | |
| 178 | + | 0x2067: "right-to-left isolate", | |
| 179 | + | 0x2068: "first strong isolate", | |
| 180 | + | 0x2069: "pop directional isolate", | |
| 181 | + | 0xFEFF: "zero width no-break space (byte order mark)", | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // markerName says whether r is shown as a marker, and how it is called. | |
| 185 | + | // Tab is ordinary; the line feed never reaches here. | |
| 186 | + | func markerName(r rune) (string, bool) { | |
| 187 | + | if n, ok := misleading[r]; ok { | |
| 188 | + | return n, true | |
| 189 | + | } | |
| 190 | + | switch { | |
| 191 | + | case r == '\t': | |
| 192 | + | return "", false | |
| 193 | + | case r == 0x2028 || r == 0x2029: | |
| 194 | + | return "line or paragraph separator", true | |
| 195 | + | case unicode.IsControl(r): | |
| 196 | + | return "control character", true | |
| 197 | + | } | |
| 198 | + | return "", false | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func isMarked(r rune) bool { _, ok := markerName(r); return ok } | |
| 202 | + | ||
| 203 | + | // writeMarked writes s escaped for HTML, with each misleading character | |
| 204 | + | // replaced by <span class="ctl" title="U+202E right-to-left override"> | |
| 205 | + | // <U+202E></span>. The character itself is not output. | |
| 206 | + | func writeMarked(b *strings.Builder, s string) { | |
| 207 | + | for { | |
| 208 | + | i := strings.IndexFunc(s, isMarked) | |
| 209 | + | if i < 0 { | |
| 210 | + | b.WriteString(html.EscapeString(s)) | |
| 211 | + | return | |
| 212 | + | } | |
| 213 | + | b.WriteString(html.EscapeString(s[:i])) | |
| 214 | + | r, size := utf8.DecodeRuneInString(s[i:]) | |
| 215 | + | name, _ := markerName(r) | |
| 216 | + | code := "U+" + strings.ToUpper(strconv.FormatInt(int64(r)+0x10000, 16)[1:]) | |
| 217 | + | if r > 0xFFFF { | |
| 218 | + | code = "U+" + strings.ToUpper(strconv.FormatInt(int64(r), 16)) | |
| 219 | + | } | |
| 220 | + | b.WriteString(`<span class="ctl" title="` + code + ` ` + name + `"><` + code + `></span>`) | |
| 221 | + | s = s[i+size:] | |
| 222 | + | } | |
| 223 | + | } | |
| @@ -0,0 +1,223 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "html" | |||
| 7 | + | "html/template" | |||
| 8 | + | "path" | |||
| 9 | + | "strconv" | |||
| 10 | + | "strings" | |||
| 11 | + | "unicode" | |||
| 12 | + | "unicode/utf8" | |||
| 13 | + | ||||
| 14 | + | "github.com/alecthomas/chroma/v2" | |||
| 15 | + | "github.com/alecthomas/chroma/v2/lexers" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // code is a source file ready for the code view: one <span class="line"> per | |||
| 19 | + | // line, each with its number as an anchor, tokens as chroma CSS classes (the | |||
| 20 | + | // colours are in static/css/55-chroma.css, no inline style). | |||
| 21 | + | type code struct { | |||
| 22 | + | html template.HTML | |||
| 23 | + | lines int | |||
| 24 | + | // lang is the name of the language the file was highlighted as, "" for | |||
| 25 | + | // plain text. | |||
| 26 | + | lang string | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // highlight renders src, the content of the file name, for the code view. | |||
| 30 | + | // The language comes from the file name. Whatever goes wrong while lexing | |||
| 31 | + | // (no lexer, a lexer error or timeout) degrades to plain text: the source is | |||
| 32 | + | // never lost or altered. Characters that can mislead a reader (bidi | |||
| 33 | + | // controls, invisible and control characters) are replaced by visible | |||
| 34 | + | // markers. | |||
| 35 | + | func highlight(name, src string) code { | |||
| 36 | + | src = normaliseText(src) | |||
| 37 | + | var lines [][]chroma.Token | |||
| 38 | + | lang := "" | |||
| 39 | + | if lx := lexerFor(name); lx != nil { | |||
| 40 | + | if toks, ok := tokenise(lx, src); ok { | |||
| 41 | + | lines, lang = chroma.SplitTokensIntoLines(toks), lx.Config().Name | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | if lines == nil { | |||
| 45 | + | for _, l := range splitSource(src) { | |||
| 46 | + | lines = append(lines, []chroma.Token{{Type: chroma.Text, Value: l}}) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | var b strings.Builder | |||
| 50 | + | b.Grow(len(src) + len(lines)*64) | |||
| 51 | + | for i, line := range lines { | |||
| 52 | + | n := strconv.Itoa(i + 1) | |||
| 53 | + | b.WriteString(`<span class="line" id="L` + n + `"><a class="ln" href="#L` + n + `">` + n + `</a>`) | |||
| 54 | + | for _, tk := range line { | |||
| 55 | + | v := strings.TrimRight(tk.Value, "\n") | |||
| 56 | + | if v == "" { | |||
| 57 | + | continue | |||
| 58 | + | } | |||
| 59 | + | if c := tokenClass(tk.Type); c != "" { | |||
| 60 | + | b.WriteString(`<span class="` + c + `">`) | |||
| 61 | + | writeMarked(&b, v) | |||
| 62 | + | b.WriteString(`</span>`) | |||
| 63 | + | } else { | |||
| 64 | + | writeMarked(&b, v) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | b.WriteString("</span>\n") | |||
| 68 | + | } | |||
| 69 | + | // #nosec G203 -- every piece of the source goes through writeMarked, which | |||
| 70 | + | // escapes it; the rest is markup and class names of this file. | |||
| 71 | + | return code{html: template.HTML(b.String()), lines: len(lines), lang: lang} | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // maxViewLines bounds the lines of the code view: every line costs about a | |||
| 75 | + | // hundred bytes of HTML, and a megabyte of line feeds must not become a | |||
| 76 | + | // hundred megabytes of page. | |||
| 77 | + | const maxViewLines = 20000 | |||
| 78 | + | ||||
| 79 | + | // countLines is the number of lines of src, as the code view numbers them. | |||
| 80 | + | func countLines(src []byte) int { | |||
| 81 | + | n := strings.Count(string(src), "\n") | |||
| 82 | + | if len(src) > 0 && src[len(src)-1] != '\n' { | |||
| 83 | + | n++ | |||
| 84 | + | } | |||
| 85 | + | return n | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // normaliseText makes src valid UTF-8 with "\n" line ends. | |||
| 89 | + | func normaliseText(src string) string { | |||
| 90 | + | if !utf8.ValidString(src) { | |||
| 91 | + | src = strings.ToValidUTF8(src, "\uFFFD") | |||
| 92 | + | } | |||
| 93 | + | if strings.Contains(src, "\r") { | |||
| 94 | + | src = strings.ReplaceAll(src, "\r\n", "\n") | |||
| 95 | + | src = strings.ReplaceAll(src, "\r", "\n") | |||
| 96 | + | } | |||
| 97 | + | return src | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // lexerFor is the lexer for a file name, nil for plain text. | |||
| 101 | + | func lexerFor(name string) chroma.Lexer { | |||
| 102 | + | lx := lexers.Match(path.Base(name)) | |||
| 103 | + | if lx == nil { | |||
| 104 | + | return nil | |||
| 105 | + | } | |||
| 106 | + | return chroma.Coalesce(lx) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // tokenise lexes src. It reports false when the tokens do not add up to the | |||
| 110 | + | // source, which is what a lexer that gave up half-way (its regexp timeout) | |||
| 111 | + | // leaves behind. | |||
| 112 | + | func tokenise(lx chroma.Lexer, src string) ([]chroma.Token, bool) { | |||
| 113 | + | it, err := lx.Tokenise(&chroma.TokeniseOptions{State: "root", EnsureLF: true}, src) | |||
| 114 | + | if err != nil { | |||
| 115 | + | return nil, false | |||
| 116 | + | } | |||
| 117 | + | toks := it.Tokens() | |||
| 118 | + | n := 0 | |||
| 119 | + | for _, t := range toks { | |||
| 120 | + | n += len(t.Value) | |||
| 121 | + | } | |||
| 122 | + | if strings.TrimRight(src, "\n") == "" { | |||
| 123 | + | return nil, n == len(src) || n == len(src)+1 | |||
| 124 | + | } | |||
| 125 | + | // A lexer may add a final line feed (EnsureNL) but nothing else. | |||
| 126 | + | return toks, len(strings.TrimRight(joinTokens(toks), "\n")) == len(strings.TrimRight(src, "\n")) | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func joinTokens(toks []chroma.Token) string { | |||
| 130 | + | var b strings.Builder | |||
| 131 | + | for _, t := range toks { | |||
| 132 | + | b.WriteString(t.Value) | |||
| 133 | + | } | |||
| 134 | + | return b.String() | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // splitSource splits plain text into lines, without the empty element after | |||
| 138 | + | // a final line feed. | |||
| 139 | + | func splitSource(src string) []string { | |||
| 140 | + | if src == "" { | |||
| 141 | + | return nil | |||
| 142 | + | } | |||
| 143 | + | ls := strings.Split(src, "\n") | |||
| 144 | + | if ls[len(ls)-1] == "" { | |||
| 145 | + | ls = ls[:len(ls)-1] | |||
| 146 | + | } | |||
| 147 | + | return ls | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // tokenClass is the CSS class of a token type, "" for none, found the way | |||
| 151 | + | // chroma's HTML formatter finds it: the type, then its parents. | |||
| 152 | + | func tokenClass(t chroma.TokenType) string { | |||
| 153 | + | for t != 0 { | |||
| 154 | + | if c, ok := chroma.StandardTypes[t]; ok { | |||
| 155 | + | return c | |||
| 156 | + | } | |||
| 157 | + | t = t.Parent() | |||
| 158 | + | } | |||
| 159 | + | return chroma.StandardTypes[t] | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // misleading are the characters, other than the controls, that the code | |||
| 163 | + | // view shows as markers instead of itself: the bidirectional controls of | |||
| 164 | + | // "Trojan Source" attacks (CVE-2021-42574) and the characters with no | |||
| 165 | + | // glyph. | |||
| 166 | + | var misleading = map[rune]string{ | |||
| 167 | + | 0x061C: "arabic letter mark", | |||
| 168 | + | 0x200B: "zero width space", | |||
| 169 | + | 0x200E: "left-to-right mark", | |||
| 170 | + | 0x200F: "right-to-left mark", | |||
| 171 | + | 0x202A: "left-to-right embedding", | |||
| 172 | + | 0x202B: "right-to-left embedding", | |||
| 173 | + | 0x202C: "pop directional formatting", | |||
| 174 | + | 0x202D: "left-to-right override", | |||
| 175 | + | 0x202E: "right-to-left override", | |||
| 176 | + | 0x2060: "word joiner", | |||
| 177 | + | 0x2066: "left-to-right isolate", | |||
| 178 | + | 0x2067: "right-to-left isolate", | |||
| 179 | + | 0x2068: "first strong isolate", | |||
| 180 | + | 0x2069: "pop directional isolate", | |||
| 181 | + | 0xFEFF: "zero width no-break space (byte order mark)", | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // markerName says whether r is shown as a marker, and how it is called. | |||
| 185 | + | // Tab is ordinary; the line feed never reaches here. | |||
| 186 | + | func markerName(r rune) (string, bool) { | |||
| 187 | + | if n, ok := misleading[r]; ok { | |||
| 188 | + | return n, true | |||
| 189 | + | } | |||
| 190 | + | switch { | |||
| 191 | + | case r == '\t': | |||
| 192 | + | return "", false | |||
| 193 | + | case r == 0x2028 || r == 0x2029: | |||
| 194 | + | return "line or paragraph separator", true | |||
| 195 | + | case unicode.IsControl(r): | |||
| 196 | + | return "control character", true | |||
| 197 | + | } | |||
| 198 | + | return "", false | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func isMarked(r rune) bool { _, ok := markerName(r); return ok } | |||
| 202 | + | ||||
| 203 | + | // writeMarked writes s escaped for HTML, with each misleading character | |||
| 204 | + | // replaced by <span class="ctl" title="U+202E right-to-left override"> | |||
| 205 | + | // <U+202E></span>. The character itself is not output. | |||
| 206 | + | func writeMarked(b *strings.Builder, s string) { | |||
| 207 | + | for { | |||
| 208 | + | i := strings.IndexFunc(s, isMarked) | |||
| 209 | + | if i < 0 { | |||
| 210 | + | b.WriteString(html.EscapeString(s)) | |||
| 211 | + | return | |||
| 212 | + | } | |||
| 213 | + | b.WriteString(html.EscapeString(s[:i])) | |||
| 214 | + | r, size := utf8.DecodeRuneInString(s[i:]) | |||
| 215 | + | name, _ := markerName(r) | |||
| 216 | + | code := "U+" + strings.ToUpper(strconv.FormatInt(int64(r)+0x10000, 16)[1:]) | |||
| 217 | + | if r > 0xFFFF { | |||
| 218 | + | code = "U+" + strings.ToUpper(strconv.FormatInt(int64(r), 16)) | |||
| 219 | + | } | |||
| 220 | + | b.WriteString(`<span class="ctl" title="` + code + ` ` + name + `"><` + code + `></span>`) | |||
| 221 | + | s = s[i+size:] | |||
| 222 | + | } | |||
| 223 | + | } | |||
M internal/adapters/web/summary.go +11 -4
| @@ -143,9 +143,16 @@ func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*rendered | |||
| 143 | 143 | if err != nil { | |
| 144 | 144 | return nil, err | |
| 145 | 145 | } | |
| 146 | + | return s.readmeIn(ctx, d.name(), d.head.GetCommitId(), ref, "", tree.Msg.GetEntries()) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // readmeIn picks the README among the entries of the directory dir ("" for | |
| 150 | + | // the root) of the commit rev, reads it and renders it. Links in it are | |
| 151 | + | // relative to dir, on the ref ref. It is nil when the directory has none. | |
| 152 | + | func (s *Server) readmeIn(ctx context.Context, repo, rev, ref, dir string, entries []*pb.TreeEntry) (*renderedReadme, error) { | |
| 146 | 153 | var best *pb.TreeEntry | |
| 147 | 154 | rank := len(readmeNames) | |
| 148 | - | for _, e := range tree.Msg.GetEntries() { | |
| 155 | + | for _, e := range entries { | |
| 149 | 156 | if e.GetKind() != pb.EntryKind_ENTRY_KIND_FILE && e.GetKind() != pb.EntryKind_ENTRY_KIND_EXECUTABLE { | |
| 150 | 157 | continue | |
| 151 | 158 | } | |
| @@ -163,7 +170,7 @@ func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*rendered | |||
| 163 | 170 | rd.Note = best.GetName() + " is too large to show here (" + humanSize(best.GetSize()) + ")." | |
| 164 | 171 | return &renderedReadme{readme: rd}, nil | |
| 165 | 172 | } | |
| 166 | - | body, binary, err := s.readBlob(ctx, d.name(), d.head.GetCommitId(), best.GetPath(), maxReadmeBytes) | |
| 173 | + | body, binary, err := s.readBlob(ctx, repo, rev, best.GetPath(), maxReadmeBytes) | |
| 167 | 174 | if err != nil { | |
| 168 | 175 | return nil, err | |
| 169 | 176 | } | |
| @@ -173,13 +180,13 @@ func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*rendered | |||
| 173 | 180 | } | |
| 174 | 181 | name := strings.ToLower(best.GetName()) | |
| 175 | 182 | if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") { | |
| 176 | - | h, outline, err := s.md.render(body, d.name(), ref, "") | |
| 183 | + | h, outline, err := s.md.render(body, repo, ref, dir) | |
| 177 | 184 | if err == nil { | |
| 178 | 185 | rd.HTML = h | |
| 179 | 186 | return &renderedReadme{readme: rd, outline: outline}, nil | |
| 180 | 187 | } | |
| 181 | 188 | log := zerowrap.FromCtx(ctx) | |
| 182 | - | log.Warn().Err(err).Str("repo", d.name()).Msg("render README, showing plain text") | |
| 189 | + | log.Warn().Err(err).Str("repo", repo).Msg("render README, showing plain text") | |
| 183 | 190 | } | |
| 184 | 191 | rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped | |
| 185 | 192 | return &renderedReadme{readme: rd}, nil | |
| @@ -143,9 +143,16 @@ func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*rendered | |||||
| 143 | if err != nil { | 143 | if err != nil { | ||
| 144 | return nil, err | 144 | return nil, err | ||
| 145 | } | 145 | } | ||
| 146 | + | return s.readmeIn(ctx, d.name(), d.head.GetCommitId(), ref, "", tree.Msg.GetEntries()) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // readmeIn picks the README among the entries of the directory dir ("" for | |||
| 150 | + | // the root) of the commit rev, reads it and renders it. Links in it are | |||
| 151 | + | // relative to dir, on the ref ref. It is nil when the directory has none. | |||
| 152 | + | func (s *Server) readmeIn(ctx context.Context, repo, rev, ref, dir string, entries []*pb.TreeEntry) (*renderedReadme, error) { | |||
| 146 | var best *pb.TreeEntry | 153 | var best *pb.TreeEntry | ||
| 147 | rank := len(readmeNames) | 154 | rank := len(readmeNames) | ||
| 148 | - | for _, e := range tree.Msg.GetEntries() { | 155 | + | for _, e := range entries { |
| 149 | if e.GetKind() != pb.EntryKind_ENTRY_KIND_FILE && e.GetKind() != pb.EntryKind_ENTRY_KIND_EXECUTABLE { | 156 | if e.GetKind() != pb.EntryKind_ENTRY_KIND_FILE && e.GetKind() != pb.EntryKind_ENTRY_KIND_EXECUTABLE { | ||
| 150 | continue | 157 | continue | ||
| 151 | } | 158 | } | ||
| @@ -163,7 +170,7 @@ func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*rendered | |||||
| 163 | rd.Note = best.GetName() + " is too large to show here (" + humanSize(best.GetSize()) + ")." | 170 | rd.Note = best.GetName() + " is too large to show here (" + humanSize(best.GetSize()) + ")." | ||
| 164 | return &renderedReadme{readme: rd}, nil | 171 | return &renderedReadme{readme: rd}, nil | ||
| 165 | } | 172 | } | ||
| 166 | - | body, binary, err := s.readBlob(ctx, d.name(), d.head.GetCommitId(), best.GetPath(), maxReadmeBytes) | 173 | + | body, binary, err := s.readBlob(ctx, repo, rev, best.GetPath(), maxReadmeBytes) |
| 167 | if err != nil { | 174 | if err != nil { | ||
| 168 | return nil, err | 175 | return nil, err | ||
| 169 | } | 176 | } | ||
| @@ -173,13 +180,13 @@ func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*rendered | |||||
| 173 | } | 180 | } | ||
| 174 | name := strings.ToLower(best.GetName()) | 181 | name := strings.ToLower(best.GetName()) | ||
| 175 | if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") { | 182 | if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") { | ||
| 176 | - | h, outline, err := s.md.render(body, d.name(), ref, "") | 183 | + | h, outline, err := s.md.render(body, repo, ref, dir) |
| 177 | if err == nil { | 184 | if err == nil { | ||
| 178 | rd.HTML = h | 185 | rd.HTML = h | ||
| 179 | return &renderedReadme{readme: rd, outline: outline}, nil | 186 | return &renderedReadme{readme: rd, outline: outline}, nil | ||
| 180 | } | 187 | } | ||
| 181 | log := zerowrap.FromCtx(ctx) | 188 | log := zerowrap.FromCtx(ctx) | ||
| 182 | - | log.Warn().Err(err).Str("repo", d.name()).Msg("render README, showing plain text") | 189 | + | log.Warn().Err(err).Str("repo", repo).Msg("render README, showing plain text") |
| 183 | } | 190 | } | ||
| 184 | rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped | 191 | rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped | ||
| 185 | return &renderedReadme{readme: rd}, nil | 192 | return &renderedReadme{readme: rd}, nil | ||