detached fcc1bcd

Merge 3ed746903cd6a04584ae699b9d178cd5e6d53513 into c899179b684c4aa1d03e39b65ed70dc93cf8bf4c

commit
fcc1bcd71b3bb2f780173671362295b095b43dfd
author
ermite · · 2026-10-20 14:40 UTC
merge of
c899179 + 3ed7469 · diff against the first parent
signature
unsigned
4 files · +239 -6
M internal/adapters/web/highlight.go +7 -5
@@ -166,7 +166,9 @@ func tokeniseInto(ctx context.Context, lx chroma.Lexer, src string, buf []chroma
166166 }
167167 toks = append(toks, t)
168168 }
169- if !tokensMatch(toks, src) {
169+ // The last tokens may have run past the deadline: the result is then
170+ // dropped, as promised. One iterator call itself cannot be preempted.
171+ if ctx.Err() != nil || !tokensMatch(toks, src) {
170172 return nil, false
171173 }
172174 return toks, true
@@ -264,17 +266,17 @@ func invisibleName(r rune) string {
264266 // writeMarked writes s escaped for HTML, each invisible character replaced
265267 // by <span class="ctl" title="U+202E right-to-left override">&lt;U+202E&gt;
266268 // </span>. The character itself is never output.
267-func writeMarked(b *strings.Builder, s string) {
269+func writeMarked(b interface{ WriteString(string) (int, error) }, s string) {
268270 for {
269271 i := strings.IndexFunc(s, invisible)
270272 if i < 0 {
271- b.WriteString(html.EscapeString(s))
273+ _, _ = b.WriteString(html.EscapeString(s))
272274 return
273275 }
274- b.WriteString(html.EscapeString(s[:i]))
276+ _, _ = b.WriteString(html.EscapeString(s[:i]))
275277 r, size := utf8.DecodeRuneInString(s[i:])
276278 cp := codePoint(r)
277- b.WriteString(`<span class="ctl" title="` + cp + ` ` + invisibleName(r) + `">&lt;` + cp + `&gt;</span>`)
279+ _, _ = b.WriteString(`<span class="ctl" title="` + cp + ` ` + invisibleName(r) + `">&lt;` + cp + `&gt;</span>`)
278280 s = s[i+size:]
279281 }
280282 }
@@ -166,7 +166,9 @@ func tokeniseInto(ctx context.Context, lx chroma.Lexer, src string, buf []chroma
166 }166 }
167 toks = append(toks, t)167 toks = append(toks, t)
168 }168 }
169- if !tokensMatch(toks, src) {169+ // The last tokens may have run past the deadline: the result is then
170+ // dropped, as promised. One iterator call itself cannot be preempted.
171+ if ctx.Err() != nil || !tokensMatch(toks, src) {
170 return nil, false172 return nil, false
171 }173 }
172 return toks, true174 return toks, true
@@ -264,17 +266,17 @@ func invisibleName(r rune) string {
264 // writeMarked writes s escaped for HTML, each invisible character replaced266 // writeMarked writes s escaped for HTML, each invisible character replaced
265 // by <span class="ctl" title="U+202E right-to-left override">&lt;U+202E&gt;267 // by <span class="ctl" title="U+202E right-to-left override">&lt;U+202E&gt;
266 // </span>. The character itself is never output.268 // </span>. The character itself is never output.
267-func writeMarked(b *strings.Builder, s string) {269+func writeMarked(b interface{ WriteString(string) (int, error) }, s string) {
268 for {270 for {
269 i := strings.IndexFunc(s, invisible)271 i := strings.IndexFunc(s, invisible)
270 if i < 0 {272 if i < 0 {
271- b.WriteString(html.EscapeString(s))273+ _, _ = b.WriteString(html.EscapeString(s))
272 return274 return
273 }275 }
274- b.WriteString(html.EscapeString(s[:i]))276+ _, _ = b.WriteString(html.EscapeString(s[:i]))
275 r, size := utf8.DecodeRuneInString(s[i:])277 r, size := utf8.DecodeRuneInString(s[i:])
276 cp := codePoint(r)278 cp := codePoint(r)
277- b.WriteString(`<span class="ctl" title="` + cp + ` ` + invisibleName(r) + `">&lt;` + cp + `&gt;</span>`)279+ _, _ = b.WriteString(`<span class="ctl" title="` + cp + ` ` + invisibleName(r) + `">&lt;` + cp + `&gt;</span>`)
278 s = s[i+size:]280 s = s[i+size:]
279 }281 }
280 }282 }
M internal/adapters/web/markdown.go +3 -1
@@ -10,6 +10,7 @@ import (
1010 "regexp"
1111 "strconv"
1212 "strings"
13+ "time"
1314
1415 "github.com/yuin/goldmark"
1516 "github.com/yuin/goldmark/ast"
@@ -45,7 +46,7 @@ func newMarkdown() *markdown {
4546 goldmark.WithExtensions(extension.GFM),
4647 goldmark.WithParserOptions(parser.WithAutoHeadingID()),
4748 goldmark.WithRendererOptions(
48- renderer.WithNodeRenderers(util.Prioritized(altRenderer{}, 100)),
49+ renderer.WithNodeRenderers(util.Prioritized(altRenderer{}, 100), util.Prioritized(fencedRenderer{}, 100)),
4950 // No html.WithUnsafe(): raw HTML blocks and inline tags are dropped.
5051 // Table alignment as an align attribute: the CSP forbids style="".
5152 extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute),
@@ -111,6 +112,7 @@ func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64)
111112 noteReferences(t, source, notes)
112113 }
113114
115+ doc.SetAttributeString(markdownBudgetKey, &markdownLexBudget{deadline: time.Now().Add(markdownLexTime)})
114116 var buf bytes.Buffer
115117 if err := m.md.Renderer().Render(&buf, source, doc); err != nil {
116118 return "", nil, err
@@ -10,6 +10,7 @@ import (
10 "regexp"10 "regexp"
11 "strconv"11 "strconv"
12 "strings"12 "strings"
13+ "time"
13 14
14 "github.com/yuin/goldmark"15 "github.com/yuin/goldmark"
15 "github.com/yuin/goldmark/ast"16 "github.com/yuin/goldmark/ast"
@@ -45,7 +46,7 @@ func newMarkdown() *markdown {
45 goldmark.WithExtensions(extension.GFM),46 goldmark.WithExtensions(extension.GFM),
46 goldmark.WithParserOptions(parser.WithAutoHeadingID()),47 goldmark.WithParserOptions(parser.WithAutoHeadingID()),
47 goldmark.WithRendererOptions(48 goldmark.WithRendererOptions(
48- renderer.WithNodeRenderers(util.Prioritized(altRenderer{}, 100)),49+ renderer.WithNodeRenderers(util.Prioritized(altRenderer{}, 100), util.Prioritized(fencedRenderer{}, 100)),
49 // No html.WithUnsafe(): raw HTML blocks and inline tags are dropped.50 // No html.WithUnsafe(): raw HTML blocks and inline tags are dropped.
50 // Table alignment as an align attribute: the CSP forbids style="".51 // Table alignment as an align attribute: the CSP forbids style="".
51 extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute),52 extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute),
@@ -111,6 +112,7 @@ func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64)
111 noteReferences(t, source, notes)112 noteReferences(t, source, notes)
112 }113 }
113 114
115+ doc.SetAttributeString(markdownBudgetKey, &markdownLexBudget{deadline: time.Now().Add(markdownLexTime)})
114 var buf bytes.Buffer116 var buf bytes.Buffer
115 if err := m.md.Renderer().Render(&buf, source, doc); err != nil {117 if err := m.md.Renderer().Render(&buf, source, doc); err != nil {
116 return "", nil, err118 return "", nil, err
A internal/adapters/web/markdown_highlight.go +165 -0
@@ -0,0 +1,165 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "strings"
8+ "time"
9+
10+ "github.com/alecthomas/chroma/v2"
11+ "github.com/alecthomas/chroma/v2/lexers"
12+ "github.com/yuin/goldmark/ast"
13+ "github.com/yuin/goldmark/renderer"
14+ goldhtml "github.com/yuin/goldmark/renderer/html"
15+ "github.com/yuin/goldmark/util"
16+)
17+
18+const (
19+ markdownBlockBytes = 64 << 10
20+ markdownDocumentBytes = 256 << 10
21+ markdownLexTime = 200 * time.Millisecond
22+ markdownBudgetKey = "markdown-lex-budget"
23+)
24+
25+// Budget belongs to the parsed document, never to the shared renderer.
26+// Charge attempts, including failures, so repeated bad blocks cannot bypass it.
27+type markdownLexBudget struct {
28+ bytes, blocks int
29+ deadline time.Time
30+}
31+
32+type fencedRenderer struct{}
33+
34+// Capture goldmark's default renderer rather than duplicate its escaping rules.
35+type fencedFallback struct{ render renderer.NodeRendererFunc }
36+
37+func (f *fencedFallback) Register(k ast.NodeKind, fn renderer.NodeRendererFunc) {
38+ if k == ast.KindFencedCodeBlock {
39+ f.render = fn
40+ }
41+}
42+
43+func (fencedRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
44+ var fallback fencedFallback
45+ goldhtml.NewRenderer().RegisterFuncs(&fallback)
46+ r.Register(ast.KindFencedCodeBlock, func(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
47+ n := node.(*ast.FencedCodeBlock)
48+ if !entering {
49+ return ast.WalkContinue, nil
50+ }
51+ toks, lang, ok := markdownTokens(n, source)
52+ if !ok {
53+ // Default rendering is plain escaped text. Omit unsafe language classes.
54+ if !safeMarkdownLanguage(string(n.Language(source))) {
55+ plain := ast.NewFencedCodeBlock(nil)
56+ plain.SetLines(n.Lines())
57+ n = plain
58+ }
59+ _, err := fallback.render(w, source, n, true)
60+ if err != nil {
61+ return ast.WalkStop, err
62+ }
63+ return fallback.render(w, source, n, false)
64+ }
65+ _, _ = w.WriteString(`<pre class="chroma"><code class="language-` + lang + `">`)
66+ // EnsureNL lexers may append a newline: never emit bytes not in the block.
67+ remaining := 0
68+ for i := 0; i < n.Lines().Len(); i++ {
69+ line := n.Lines().At(i)
70+ remaining += len(line.Value(source))
71+ }
72+ // The tokens are not coalesced (chroma's coalescer copies strings
73+ // quadratically): adjacent tokens of one class share a span here.
74+ open := ""
75+ for _, tk := range toks {
76+ v := tk.Value
77+ if len(v) > remaining {
78+ v = v[:remaining]
79+ }
80+ remaining -= len(v)
81+ if v == "" {
82+ continue
83+ }
84+ if c := tokenClass(tk.Type); c != open {
85+ if open != "" {
86+ _, _ = w.WriteString(`</span>`)
87+ }
88+ if c != "" {
89+ _, _ = w.WriteString(`<span class="` + c + `">`)
90+ }
91+ open = c
92+ }
93+ writeMarked(w, v)
94+ }
95+ if open != "" {
96+ _, _ = w.WriteString(`</span>`)
97+ }
98+ _, _ = w.WriteString("</code></pre>\n")
99+ return ast.WalkSkipChildren, nil
100+ })
101+}
102+
103+func safeMarkdownLanguage(s string) bool {
104+ if s == "" {
105+ return false
106+ }
107+ for _, c := range s {
108+ switch {
109+ case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', strings.ContainsRune("_+.#-", c):
110+ default:
111+ return false
112+ }
113+ }
114+ return true
115+}
116+
117+func markdownTokens(n *ast.FencedCodeBlock, source []byte) ([]chroma.Token, string, bool) {
118+ var lang string
119+ if n.Info != nil {
120+ if words := strings.Fields(string(n.Info.Segment.Value(source))); len(words) > 0 {
121+ lang = words[0]
122+ }
123+ }
124+ if !safeMarkdownLanguage(lang) {
125+ return nil, "", false
126+ }
127+ value, _ := n.OwnerDocument().AttributeString(markdownBudgetKey)
128+ budget, ok := value.(*markdownLexBudget)
129+ if !ok || budget.blocks >= 128 || time.Now().After(budget.deadline) {
130+ return nil, "", false
131+ }
132+ size := 0
133+ for i := 0; i < n.Lines().Len(); i++ {
134+ line := n.Lines().At(i)
135+ size += len(line.Value(source))
136+ if size > markdownBlockBytes {
137+ return nil, "", false
138+ }
139+ }
140+ if size > markdownDocumentBytes-budget.bytes {
141+ return nil, "", false
142+ }
143+ budget.blocks++
144+ budget.bytes += size
145+ lx := lexers.Get(lang)
146+ if lx == nil {
147+ return nil, "", false
148+ }
149+ select {
150+ case lexSlots <- struct{}{}:
151+ defer func() { <-lexSlots }()
152+ default:
153+ return nil, "", false
154+ }
155+ var code strings.Builder
156+ code.Grow(size)
157+ for i := 0; i < n.Lines().Len(); i++ {
158+ line := n.Lines().At(i)
159+ code.Write(line.Value(source))
160+ }
161+ ctx, cancel := context.WithDeadline(context.Background(), budget.deadline)
162+ defer cancel()
163+ toks, ok := tokenise(ctx, lx, code.String())
164+ return toks, lang, ok
165+}
@@ -0,0 +1,165 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "strings"
8+ "time"
9+
10+ "github.com/alecthomas/chroma/v2"
11+ "github.com/alecthomas/chroma/v2/lexers"
12+ "github.com/yuin/goldmark/ast"
13+ "github.com/yuin/goldmark/renderer"
14+ goldhtml "github.com/yuin/goldmark/renderer/html"
15+ "github.com/yuin/goldmark/util"
16+)
17+
18+const (
19+ markdownBlockBytes = 64 << 10
20+ markdownDocumentBytes = 256 << 10
21+ markdownLexTime = 200 * time.Millisecond
22+ markdownBudgetKey = "markdown-lex-budget"
23+)
24+
25+// Budget belongs to the parsed document, never to the shared renderer.
26+// Charge attempts, including failures, so repeated bad blocks cannot bypass it.
27+type markdownLexBudget struct {
28+ bytes, blocks int
29+ deadline time.Time
30+}
31+
32+type fencedRenderer struct{}
33+
34+// Capture goldmark's default renderer rather than duplicate its escaping rules.
35+type fencedFallback struct{ render renderer.NodeRendererFunc }
36+
37+func (f *fencedFallback) Register(k ast.NodeKind, fn renderer.NodeRendererFunc) {
38+ if k == ast.KindFencedCodeBlock {
39+ f.render = fn
40+ }
41+}
42+
43+func (fencedRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
44+ var fallback fencedFallback
45+ goldhtml.NewRenderer().RegisterFuncs(&fallback)
46+ r.Register(ast.KindFencedCodeBlock, func(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
47+ n := node.(*ast.FencedCodeBlock)
48+ if !entering {
49+ return ast.WalkContinue, nil
50+ }
51+ toks, lang, ok := markdownTokens(n, source)
52+ if !ok {
53+ // Default rendering is plain escaped text. Omit unsafe language classes.
54+ if !safeMarkdownLanguage(string(n.Language(source))) {
55+ plain := ast.NewFencedCodeBlock(nil)
56+ plain.SetLines(n.Lines())
57+ n = plain
58+ }
59+ _, err := fallback.render(w, source, n, true)
60+ if err != nil {
61+ return ast.WalkStop, err
62+ }
63+ return fallback.render(w, source, n, false)
64+ }
65+ _, _ = w.WriteString(`<pre class="chroma"><code class="language-` + lang + `">`)
66+ // EnsureNL lexers may append a newline: never emit bytes not in the block.
67+ remaining := 0
68+ for i := 0; i < n.Lines().Len(); i++ {
69+ line := n.Lines().At(i)
70+ remaining += len(line.Value(source))
71+ }
72+ // The tokens are not coalesced (chroma's coalescer copies strings
73+ // quadratically): adjacent tokens of one class share a span here.
74+ open := ""
75+ for _, tk := range toks {
76+ v := tk.Value
77+ if len(v) > remaining {
78+ v = v[:remaining]
79+ }
80+ remaining -= len(v)
81+ if v == "" {
82+ continue
83+ }
84+ if c := tokenClass(tk.Type); c != open {
85+ if open != "" {
86+ _, _ = w.WriteString(`</span>`)
87+ }
88+ if c != "" {
89+ _, _ = w.WriteString(`<span class="` + c + `">`)
90+ }
91+ open = c
92+ }
93+ writeMarked(w, v)
94+ }
95+ if open != "" {
96+ _, _ = w.WriteString(`</span>`)
97+ }
98+ _, _ = w.WriteString("</code></pre>\n")
99+ return ast.WalkSkipChildren, nil
100+ })
101+}
102+
103+func safeMarkdownLanguage(s string) bool {
104+ if s == "" {
105+ return false
106+ }
107+ for _, c := range s {
108+ switch {
109+ case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', strings.ContainsRune("_+.#-", c):
110+ default:
111+ return false
112+ }
113+ }
114+ return true
115+}
116+
117+func markdownTokens(n *ast.FencedCodeBlock, source []byte) ([]chroma.Token, string, bool) {
118+ var lang string
119+ if n.Info != nil {
120+ if words := strings.Fields(string(n.Info.Segment.Value(source))); len(words) > 0 {
121+ lang = words[0]
122+ }
123+ }
124+ if !safeMarkdownLanguage(lang) {
125+ return nil, "", false
126+ }
127+ value, _ := n.OwnerDocument().AttributeString(markdownBudgetKey)
128+ budget, ok := value.(*markdownLexBudget)
129+ if !ok || budget.blocks >= 128 || time.Now().After(budget.deadline) {
130+ return nil, "", false
131+ }
132+ size := 0
133+ for i := 0; i < n.Lines().Len(); i++ {
134+ line := n.Lines().At(i)
135+ size += len(line.Value(source))
136+ if size > markdownBlockBytes {
137+ return nil, "", false
138+ }
139+ }
140+ if size > markdownDocumentBytes-budget.bytes {
141+ return nil, "", false
142+ }
143+ budget.blocks++
144+ budget.bytes += size
145+ lx := lexers.Get(lang)
146+ if lx == nil {
147+ return nil, "", false
148+ }
149+ select {
150+ case lexSlots <- struct{}{}:
151+ defer func() { <-lexSlots }()
152+ default:
153+ return nil, "", false
154+ }
155+ var code strings.Builder
156+ code.Grow(size)
157+ for i := 0; i < n.Lines().Len(); i++ {
158+ line := n.Lines().At(i)
159+ code.Write(line.Value(source))
160+ }
161+ ctx, cancel := context.WithDeadline(context.Background(), budget.deadline)
162+ defer cancel()
163+ toks, ok := tokenise(ctx, lx, code.String())
164+ return toks, lang, ok
165+}
M internal/adapters/web/markdown_test.go +64 -0
@@ -232,3 +232,67 @@ func TestNoteMarkdown(t *testing.T) {
232232 assert.NotContains(t, string(h), "<script")
233233 assert.Contains(t, string(h), `title="note 2"`)
234234 }
235+
236+func TestMarkdownFencedHighlight(t *testing.T) {
237+ tests := []struct {
238+ name, info, code string
239+ highlighted bool
240+ }{
241+ {"go", "go", "package main\nfunc main() {}\n", true},
242+ {"info suffix", "go example", "package main\n", true},
243+ {"unknown", "not-a-real-language", "<script>\n", false},
244+ {"empty", "", "<script>\n", false},
245+ {"html", "html", "<script>alert(1)</script>\n", true},
246+ {"injection", `go" onmouseover=x`, "package main\n", false},
247+ {"oversize", "go", strings.Repeat("x", markdownBlockBytes+1) + "\n", false},
248+ }
249+ for _, tt := range tests {
250+ t.Run(tt.name, func(t *testing.T) {
251+ src := "```" + tt.info + "\n" + tt.code + "```\n"
252+ for _, untrusted := range []bool{false, true} {
253+ var h string
254+ if untrusted {
255+ out, err := newMarkdown().renderUntrusted([]byte(src), "test-")
256+ require.NoError(t, err)
257+ h = string(out)
258+ } else {
259+ h, _ = renderMD(t, src)
260+ }
261+ assert.Equal(t, tt.highlighted, strings.Contains(h, `<pre class="chroma">`))
262+ assert.NotContains(t, h, "<script>")
263+ assert.NotContains(t, h, `style=`)
264+ assert.NotContains(t, h, `onmouseover`)
265+ if tt.name == "go" {
266+ assert.Contains(t, h, `<span class="kn">package</span>`)
267+ }
268+ if tt.name == "html" {
269+ assert.Contains(t, h, "&lt;")
270+ assert.Contains(t, h, "script")
271+ }
272+ if !tt.highlighted {
273+ assert.NotContains(t, h, "<span")
274+ assert.Contains(t, h, "<pre><code")
275+ }
276+ }
277+ })
278+ }
279+}
280+
281+func TestMarkdownHighlightMarkers(t *testing.T) {
282+ h, _ := renderMD(t, "```go\n// invisible \u202e\n```\n")
283+ assert.Contains(t, h, `class="ctl"`)
284+ assert.Contains(t, h, "U+202E")
285+ assert.NotContains(t, h, "\u202e")
286+}
287+
288+func TestMarkdownHighlightDocumentBudget(t *testing.T) {
289+ // Even thousands of tiny blocks may only attempt 128 lexes per document.
290+ h, _ := renderMD(t, strings.Repeat("```go\npackage main\n```\n\n", 1000))
291+ assert.LessOrEqual(t, strings.Count(h, `<pre class="chroma">`), 128)
292+ assert.Contains(t, h, "<pre><code")
293+ // The byte budget also stops blocks before the block-count budget.
294+ block := "```go\n//" + strings.Repeat("x", markdownBlockBytes-4) + "\n```\n\n"
295+ h, _ = renderMD(t, strings.Repeat(block, 6))
296+ assert.LessOrEqual(t, strings.Count(h, `<pre class="chroma">`), 4)
297+ assert.Contains(t, h, "<pre><code")
298+}
@@ -232,3 +232,67 @@ func TestNoteMarkdown(t *testing.T) {
232 assert.NotContains(t, string(h), "<script")232 assert.NotContains(t, string(h), "<script")
233 assert.Contains(t, string(h), `title="note 2"`)233 assert.Contains(t, string(h), `title="note 2"`)
234 }234 }
235+
236+func TestMarkdownFencedHighlight(t *testing.T) {
237+ tests := []struct {
238+ name, info, code string
239+ highlighted bool
240+ }{
241+ {"go", "go", "package main\nfunc main() {}\n", true},
242+ {"info suffix", "go example", "package main\n", true},
243+ {"unknown", "not-a-real-language", "<script>\n", false},
244+ {"empty", "", "<script>\n", false},
245+ {"html", "html", "<script>alert(1)</script>\n", true},
246+ {"injection", `go" onmouseover=x`, "package main\n", false},
247+ {"oversize", "go", strings.Repeat("x", markdownBlockBytes+1) + "\n", false},
248+ }
249+ for _, tt := range tests {
250+ t.Run(tt.name, func(t *testing.T) {
251+ src := "```" + tt.info + "\n" + tt.code + "```\n"
252+ for _, untrusted := range []bool{false, true} {
253+ var h string
254+ if untrusted {
255+ out, err := newMarkdown().renderUntrusted([]byte(src), "test-")
256+ require.NoError(t, err)
257+ h = string(out)
258+ } else {
259+ h, _ = renderMD(t, src)
260+ }
261+ assert.Equal(t, tt.highlighted, strings.Contains(h, `<pre class="chroma">`))
262+ assert.NotContains(t, h, "<script>")
263+ assert.NotContains(t, h, `style=`)
264+ assert.NotContains(t, h, `onmouseover`)
265+ if tt.name == "go" {
266+ assert.Contains(t, h, `<span class="kn">package</span>`)
267+ }
268+ if tt.name == "html" {
269+ assert.Contains(t, h, "&lt;")
270+ assert.Contains(t, h, "script")
271+ }
272+ if !tt.highlighted {
273+ assert.NotContains(t, h, "<span")
274+ assert.Contains(t, h, "<pre><code")
275+ }
276+ }
277+ })
278+ }
279+}
280+
281+func TestMarkdownHighlightMarkers(t *testing.T) {
282+ h, _ := renderMD(t, "```go\n// invisible \u202e\n```\n")
283+ assert.Contains(t, h, `class="ctl"`)
284+ assert.Contains(t, h, "U+202E")
285+ assert.NotContains(t, h, "\u202e")
286+}
287+
288+func TestMarkdownHighlightDocumentBudget(t *testing.T) {
289+ // Even thousands of tiny blocks may only attempt 128 lexes per document.
290+ h, _ := renderMD(t, strings.Repeat("```go\npackage main\n```\n\n", 1000))
291+ assert.LessOrEqual(t, strings.Count(h, `<pre class="chroma">`), 128)
292+ assert.Contains(t, h, "<pre><code")
293+ // The byte budget also stops blocks before the block-count budget.
294+ block := "```go\n//" + strings.Repeat("x", markdownBlockBytes-4) + "\n```\n\n"
295+ h, _ = renderMD(t, strings.Repeat(block, 6))
296+ assert.LessOrEqual(t, strings.Count(h, `<pre class="chroma">`), 4)
297+ assert.Contains(t, h, "<pre><code")
298+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 44

↑↓ moveenter openesc close