Gitfed
bastien-mrq/gitfed/ Commits/ 40d1d95

Add server-side syntax highlighting for file views (chroma)

No client-side JS, inline per-token styles already covered by the existing style-src 'unsafe-inline' — no CSP change needed. Falls back to plain <pre> for any file chroma doesn't recognize a language for.

bastien-mrq 2026-07-29 19:26 commit 40d1d95243daba155685a1d2e3a9eedd8422d44a parent 2c86382f5a162be9930471f9eb7e683af85928df
7 files changed +114 −10
M CHANGELOG.md +4 −0
M ROADMAP.md +0 −9
M cmd/gitfed-web/handlers_repo.go +5 −0
A cmd/gitfed-web/highlight.go +53 −0
A cmd/gitfed-web/highlight_test.go +39 −0
M go.mod +3 −1
M go.sum +10 −0
CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md index 326689f..29b20ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ A bullet starting with `**BREAKING:**` flags a change gitfed-ctl's update wizard makes you acknowledge individually before it will let you upgrade past that version. +## 1.2.12 + +- Server-side syntax highlighting for file views, using [chroma](https://github.com/alecthomas/chroma) (Go, no client-side JS) — matches the approach ROADMAP.md already called out given gitfed's strict CSP (a single hashed inline script; an external JS highlighter would either break that or have to be folded into the hashed blob). Highlighted output uses inline per-token styles, already allowed by the existing `style-src 'unsafe-inline'`, so no CSP change was needed. Falls back to the previous plain `<pre>` rendering for any file chroma doesn't recognize a language for. + ## 1.2.11 - Four small UI improvements from ROADMAP.md's "Petits gains": the login link in the nav is now a proper accent CTA button instead of plain text; the "Commits" button on the repo page shows the total count (`git rev-list --count`, new `CountCommits` on `admin.Ops`); the commit list is more compact (tighter row padding); and files in the repo tree now get a type-specific icon (code/doc/image/config) instead of the same generic file glyph for everything.
ROADMAP.md
diff --git a/ROADMAP.md b/ROADMAP.md index 0ec277f..7d4672a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -80,15 +80,6 @@ celui-là la prochaine fois qu'on rouvre ce document. - **Switch de langue directement dans la preview d'un README** quand un fichier sœur existe (`README.md` / `README.fr.md`) — aucune détection de ce type n'existe aujourd'hui, à construire. *(effort moyen)*. -- **Coloration syntaxique des blocs de code.** Recommandation : plutôt - qu'une lib JS côté client (HighlightJS envisagé au départ), une - bibliothèque Go **côté serveur** type - [chroma](https://github.com/alecthomas/chroma) — gitfed a un CSP - strict (un seul script inline, hashé) ; ajouter une lib JS externe - casse cette posture ou oblige à l'intégrer entièrement dans le blob - hashé. chroma produit le HTML coloré à la génération, sans JS client, - cohérent avec le "un seul binaire Go, aucune dépendance externe" déjà - revendiqué. *(effort moyen)*. - **Détection heuristique du type de licence** (MIT, Apache-2.0, GPL, ...) par le contenu plutôt que se contenter d'afficher le fichier brut — une heuristique simple (quelques chaînes caractéristiques par
cmd/gitfed-web/handlers_repo.go
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go index 87e6a8c..b3b42a3 100644 --- a/cmd/gitfed-web/handlers_repo.go +++ b/cmd/gitfed-web/handlers_repo.go @@ -363,6 +363,11 @@ func renderFileContent(repo, dir, filename, content string) (template.HTML, erro if strings.HasSuffix(strings.ToLower(filename), ".md") || strings.HasSuffix(strings.ToLower(filename), ".markdown") { return renderRepoMarkdown(repo, dir, content) } + if html, ok, err := highlightCode(filename, content); err != nil { + return "", err + } else if ok { + return html, nil + } var buf bytes.Buffer if err := plainTpl.Execute(&buf, content); err != nil { return "", err
cmd/gitfed-web/highlight.go
diff --git a/cmd/gitfed-web/highlight.go b/cmd/gitfed-web/highlight.go new file mode 100644 index 0000000..2c66c80 --- /dev/null +++ b/cmd/gitfed-web/highlight.go @@ -0,0 +1,53 @@ +package main + +import ( + "bytes" + "html/template" + + "github.com/alecthomas/chroma/v2" + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" +) + +// chromaFormatter emits inline per-token styles rather than CSS classes +// (chromahtml.WithClasses(false), the default) — no separate stylesheet or +// CSP change needed, since style-src already allows 'unsafe-inline' for the +// inline style="" attributes the rest of the app already relies on (see +// security_headers.go). PreventSurroundingPre(true) drops chroma's own +// <pre> wrapper so highlightCode can reuse gitfed's existing, already- +// themed "pre, code" rule (shellHeadSrc) instead of nesting two of them. +var chromaFormatter = chromahtml.New(chromahtml.PreventSurroundingPre(true), chromahtml.WithClasses(false)) + +// chromaStyle is a fixed built-in theme (Nord) rather than something +// derived from gitfed's own CSS tokens — matching them exactly would mean +// hand-authoring a chroma style for every token type, well past what a +// "small win" like server-side highlighting needs; Nord's cool dark +// blue-grey palette already sits close to gitfed's own. +var chromaStyle = styles.Get("nord") + +// highlightCode renders content as syntax-highlighted HTML for filename's +// detected language, wrapped in a plain <pre><code> so it inherits the +// same global styling as the unhighlighted fallback. ok is false (with a +// nil error) when no lexer matches the filename — the caller falls back to +// plain preformatted text in that case, same as before this existed. +func highlightCode(filename, content string) (html template.HTML, ok bool, err error) { + lexer := lexers.Match(filename) + if lexer == nil { + return "", false, nil + } + lexer = chroma.Coalesce(lexer) + + iterator, err := lexer.Tokenise(nil, content) + if err != nil { + return "", false, err + } + + var buf bytes.Buffer + buf.WriteString("<pre><code>") + if err := chromaFormatter.Format(&buf, chromaStyle, iterator); err != nil { + return "", false, err + } + buf.WriteString("</code></pre>") + return template.HTML(buf.String()), true, nil +}
cmd/gitfed-web/highlight_test.go
diff --git a/cmd/gitfed-web/highlight_test.go b/cmd/gitfed-web/highlight_test.go new file mode 100644 index 0000000..d6aec0d --- /dev/null +++ b/cmd/gitfed-web/highlight_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "strings" + "testing" +) + +func TestHighlightCodeKnownLanguage(t *testing.T) { + html, ok, err := highlightCode("main.go", "package main\n\nfunc main() {}\n") + if err != nil { + t.Fatalf("highlightCode: %v", err) + } + if !ok { + t.Fatal("expected a lexer match for main.go") + } + s := string(html) + if !strings.HasPrefix(s, "<pre><code>") || !strings.HasSuffix(s, "</code></pre>") { + t.Fatalf("expected content wrapped in a single <pre><code>...</code></pre>, got: %s", s) + } + if !strings.Contains(s, "package") { + t.Fatalf("expected the source text to survive highlighting, got: %s", s) + } + if !strings.Contains(s, "style=") { + t.Fatalf("expected inline per-token styles (CSP relies on style-src 'unsafe-inline', not classes), got: %s", s) + } +} + +func TestHighlightCodeUnknownLanguage(t *testing.T) { + html, ok, err := highlightCode("data.bin", "whatever") + if err != nil { + t.Fatalf("highlightCode: %v", err) + } + if ok { + t.Fatalf("expected no lexer match for data.bin, got %q", html) + } + if html != "" { + t.Fatalf("expected empty output when ok=false, got %q", html) + } +}
go.mod
diff --git a/go.mod b/go.mod index 58fe35b..e142be8 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,11 @@ module git.neuromancer.ovh/bastien-mrq/gitfed go 1.25.4 require ( + github.com/alecthomas/chroma/v2 v2.27.0 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/yuin/goldmark v1.8.4 go.etcd.io/bbolt v1.5.0 golang.org/x/crypto v0.54.0 ) @@ -20,6 +22,7 @@ require ( github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/dlclark/regexp2/v2 v2.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -31,7 +34,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yuin/goldmark v1.8.4 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect )
go.sum
diff --git a/go.sum b/go.sum index b98766e..42306bc 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= +github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -28,8 +34,12 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= +github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=