detached 06c0b24

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +242 -5
M go.mod +2 -0
@@ -16,6 +16,7 @@ require (
1616 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
1717 connectrpc.com/connect v1.21.0
1818 github.com/a-h/templ v0.3.1020
19+ github.com/alecthomas/chroma/v2 v2.27.0
1920 github.com/bnema/zerowrap v1.4.1
2021 github.com/spf13/cobra v1.10.2
2122 github.com/stretchr/testify v1.12.1
@@ -64,6 +65,7 @@ require (
6465 github.com/containerd/errdefs/pkg v0.3.0 // indirect
6566 github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
6667 github.com/distribution/reference v0.6.0 // indirect
68+ github.com/dlclark/regexp2/v2 v2.2.1 // indirect
6769 github.com/docker/cli v29.8.0+incompatible // indirect
6870 github.com/docker/docker-credential-helpers v0.9.9 // indirect
6971 github.com/docker/go-connections v0.8.1 // indirect
@@ -16,6 +16,7 @@ require (
16 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c316 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
17 connectrpc.com/connect v1.21.017 connectrpc.com/connect v1.21.0
18 github.com/a-h/templ v0.3.102018 github.com/a-h/templ v0.3.1020
19+ github.com/alecthomas/chroma/v2 v2.27.0
19 github.com/bnema/zerowrap v1.4.120 github.com/bnema/zerowrap v1.4.1
20 github.com/spf13/cobra v1.10.221 github.com/spf13/cobra v1.10.2
21 github.com/stretchr/testify v1.12.122 github.com/stretchr/testify v1.12.1
@@ -64,6 +65,7 @@ require (
64 github.com/containerd/errdefs/pkg v0.3.0 // indirect65 github.com/containerd/errdefs/pkg v0.3.0 // indirect
65 github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect66 github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
66 github.com/distribution/reference v0.6.0 // indirect67 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 // indirect69 github.com/docker/cli v29.8.0+incompatible // indirect
68 github.com/docker/docker-credential-helpers v0.9.9 // indirect70 github.com/docker/docker-credential-helpers v0.9.9 // indirect
69 github.com/docker/go-connections v0.8.1 // indirect71 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+// &lt;U+202E&gt;</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 + `">&lt;` + code + `&gt;</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+// &lt;U+202E&gt;</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 + `">&lt;` + code + `&gt;</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
143143 if err != nil {
144144 return nil, err
145145 }
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) {
146153 var best *pb.TreeEntry
147154 rank := len(readmeNames)
148- for _, e := range tree.Msg.GetEntries() {
155+ for _, e := range entries {
149156 if e.GetKind() != pb.EntryKind_ENTRY_KIND_FILE && e.GetKind() != pb.EntryKind_ENTRY_KIND_EXECUTABLE {
150157 continue
151158 }
@@ -163,7 +170,7 @@ func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*rendered
163170 rd.Note = best.GetName() + " is too large to show here (" + humanSize(best.GetSize()) + ")."
164171 return &renderedReadme{readme: rd}, nil
165172 }
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)
167174 if err != nil {
168175 return nil, err
169176 }
@@ -173,13 +180,13 @@ func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*rendered
173180 }
174181 name := strings.ToLower(best.GetName())
175182 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)
177184 if err == nil {
178185 rd.HTML = h
179186 return &renderedReadme{readme: rd, outline: outline}, nil
180187 }
181188 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")
183190 }
184191 rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped
185192 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, err144 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.TreeEntry153 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 continue157 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}, nil171 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, err175 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 = h185 rd.HTML = h
179 return &renderedReadme{readme: rd, outline: outline}, nil186 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 -- escaped191 rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped
185 return &renderedReadme{readme: rd}, nil192 return &renderedReadme{readme: rd}, nil

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 2

Tags 45

↑↓ moveenter openesc close