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.
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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=