From 67defae912a3253130c8c48fd46af907bcbaf08e Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Sun, 2 Aug 2026 00:16:32 +0600 Subject: [PATCH] highlight code server-side, and let a block quote a file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chroma at render time, emitting CSS classes rather than inline colour, handed to a `code` theme fragment. Highlighting works with scripting off, in a feed reader, in a browser that never runs JavaScript. No lighter pure-Go option exists — every "alternative to chroma" is JavaScript, which the reference theme is gated against. A fence's info string carries the rest: title, numbers, start, hl=3,7-9, and file=name lines=A-B, which reads the snippet out of a file beside the bundle and numbers it by that file's own lines. So a post quotes several parts of one program without the copies drifting from it, and a reader can find what they are looking at. Verified on the real binary: the same file at lines 5-10 and 12-14, each numbered as it really is, with different lines tinted. Not a new package: a new one could not import the key=value parser this repo already has, because ADR-0069 forbids a feature importing its sibling, and a second parser for the same syntax is what §6 stops. Two costs, both stated in the ADR rather than buried. The binary goes from ~15MB to 19MB, for a project whose story is one small binary. And the reference theme now carries a token palette — the first thing in it that is a taste rather than a demonstration — kept to eight classes for that reason. The demo quotes a shell file, not a Go one: a .go file under examples/ joins the module and has to compile, which the build gate caught before it shipped. 6 of 9 modules, ext 2188/3500. --- HARNESS.md | 4 + cmd/khosra/example_test.go | 4 + docs/content-model.md | 18 ++ docs/decisions.md | 25 +++ docs/state.md | 5 +- docs/surface.md | 67 +++--- docs/theme-contract.md | 8 + .../demo-site/content/pages/colophon.en.md | 6 + examples/demo-site/content/pages/serve.sh | 7 + go.mod | 7 +- go.sum | 10 + internal/ext/shortcodes/code.go | 211 ++++++++++++++++++ internal/ext/shortcodes/code_test.go | 84 +++++++ internal/ext/shortcodes/shortcodes.go | 2 + .../render/templates/shortcodes/code.html | 7 + internal/render/templates/theme.css | 20 ++ scripts/allowed-deps.txt | 1 + 17 files changed, 455 insertions(+), 31 deletions(-) create mode 100644 examples/demo-site/content/pages/serve.sh create mode 100644 internal/ext/shortcodes/code.go create mode 100644 internal/ext/shortcodes/code_test.go create mode 100644 internal/render/templates/shortcodes/code.html diff --git a/HARNESS.md b/HARNESS.md index 14ceba2..8708b02 100644 --- a/HARNESS.md +++ b/HARNESS.md @@ -84,6 +84,10 @@ cannot own a **route** until the extension registry exists. When it does, they l come back down rather than stay as headroom. The two ceilings exist so that "core stops growing, ext rises" is observable, and that stops being true the moment leaves are allowed into core. +**The binary grew for syntax highlighting.** chroma is larger than khosra and takes it from ~15MB to +~20MB (ADR-0075). That was a deliberate trade for a site that shows code constantly, and it is the one place +where "one small binary" got less true. + **Context is a budget, and three pieces of it are mechanical.** The limit on this project is how much work fits in a session, so `docs/context-economy.md` holds the reading, searching and reporting disciplines — read the compressed form first, batch calls, script anything repeatable, never pay twice diff --git a/cmd/khosra/example_test.go b/cmd/khosra/example_test.go index abbac36..a05f6ff 100644 --- a/cmd/khosra/example_test.go +++ b/cmd/khosra/example_test.go @@ -126,6 +126,10 @@ var exampleFeatures = []featureCase{ expect: []string{`
`, "💡"}}, {what: "an icon renders through the theme, and an unknown one keeps its text", path: "/pages/colophon/", code: 200, expect: []string{"💡", ":nosuchicon:"}}, + {what: "a code block is highlighted server-side, from a file, with its own line numbers", path: "/pages/colophon/", code: 200, + expect: []string{`class="chroma"`, "
serve.sh — the ways to run it
", + `class="line hl"`, ">7<"}, + absent: []string{"`, "Serve your own"}, absent: []string{"` and no script | | `:::aside{title=…}` … `:::` | `aside` | `.Args.title`, `.Body`. Beside the text where there is room, in the flow where there is not | @@ -137,6 +138,13 @@ theme labels an untitled admonition with `{{t .Lang "warn"}}` and a contents lis so a Bengali page reads সতর্কতা and সূচিপত্র rather than English (ADR-0067). Words the *author* wrote — `.Args.title`, `.Body` — are never touched. +**A code block is highlighted by the engine and framed by the theme.** `.Body` is `
` with
+token classes — `k` keyword, `s` string, `c` comment, `m` number, `nf` function, `ln` line number, and a
+`line hl` for a highlighted row. The palette is a stylesheet's, never inline, so dark mode is the theme's as
+usual. The reference theme frames it in a `
` with the title as a `
`, and +stops there: a copy button needs a script, and the reference theme is gated script-free (ADR-0026), so that +belongs in a theme of your own. + A **container** call wraps content: `:::name{…}`, a body, then `:::`. It renders through the fragment of that name with `.Body` already HTML, and one level only — a `:::` inside closes the one it is in. A theme with no template for the kind renders nothing, and the engine writes the body out unwrapped, so an unknown kind costs diff --git a/examples/demo-site/content/pages/colophon.en.md b/examples/demo-site/content/pages/colophon.en.md index 5f7fa05..50b57a3 100644 --- a/examples/demo-site/content/pages/colophon.en.md +++ b/examples/demo-site/content/pages/colophon.en.md @@ -18,6 +18,12 @@ theme of your own might answer with an SVG. A name it does not know, like :nosuc `khosra -site /path/to/site`. Grouped panels open one at a time, and nothing here runs a script. ::: +A block may take its content from a file beside the bundle, so a post can quote several parts of one program +without the copies drifting from it — each keeping the line numbers it really has: + +```sh file=serve.sh lines=4-7 hl=6 title="serve.sh — the ways to run it" +``` + :::tip{title="Nothing is hidden"} Panels are `
`, which every browser opens without help. A reader with scripting off sees the same page. ::: diff --git a/examples/demo-site/content/pages/serve.sh b/examples/demo-site/content/pages/serve.sh new file mode 100644 index 0000000..ce8b6d4 --- /dev/null +++ b/examples/demo-site/content/pages/serve.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Everything this demo needs, and nothing it does not. + +make demo # this site, on localhost:8080 +khosra -site /path/to/site # any other site root +khosra -site . -poll 0 # an immutable deployment: never look for changes +khosra check -site /path/to/site # exit non-zero if anything is wrong diff --git a/go.mod b/go.mod index 8feb564..6ae554b 100644 --- a/go.mod +++ b/go.mod @@ -9,4 +9,9 @@ require ( require github.com/yuin/goldmark v1.8.5 -require golang.org/x/image v0.44.0 +require ( + github.com/alecthomas/chroma/v2 v2.27.0 + golang.org/x/image v0.44.0 +) + +require github.com/dlclark/regexp2/v2 v2.2.1 // indirect diff --git a/go.sum b/go.sum index 9db6365..99fdbd8 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,13 @@ +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/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= +github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +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/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= diff --git a/internal/ext/shortcodes/code.go b/internal/ext/shortcodes/code.go new file mode 100644 index 0000000..f527116 --- /dev/null +++ b/internal/ext/shortcodes/code.go @@ -0,0 +1,211 @@ +package shortcodes + +import ( + "bytes" + "io/fs" + "log/slog" + "path" + "strconv" + "strings" + + "html/template" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" + + "khosra/internal/render" +) + +// codeFragment is the theme template a highlighted block renders through. The engine produces token spans +// and the theme produces everything around them — the frame, the caption, a copy button (ADR-0075). +const codeFragment = "code" + +// codeKind is one fenced block after its info string has been read. +var codeKind = ast.NewNodeKind("ShortcodeCode") + +type codeBlock struct { + ast.BaseBlock + args map[string]string + lang string + // source is what to highlight: the fence's own lines, or the file it named. + source []byte + // first is the number the first displayed line carries, and marked are the lines to tint. Both are in the + // numbering the reader sees, so a slice of a file highlights by that file's line numbers. + first int + marked [][2]int + // numbered is whether a gutter is drawn at all. + numbered bool +} + +func (n *codeBlock) Kind() ast.NodeKind { return codeKind } + +func (n *codeBlock) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) } + +// blocks reads every fenced block's info string and replaces the node with one the renderer can highlight. +// +// A transformer, because a block may take its content from a file and only the parse context knows which +// bundle this is — the same reason an include is one (ADR-0038). +type code struct{} + +func (code) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) { + origin, _ := render.OriginFrom(pc) + source := reader.Source() + var fenced []*ast.FencedCodeBlock + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + if f, is := n.(*ast.FencedCodeBlock); is { + fenced = append(fenced, f) + } + } + return ast.WalkContinue, nil + }) + for _, f := range fenced { + if block := readFence(f, source, origin); block != nil { + f.Parent().ReplaceChild(f.Parent(), f, block) + } + } +} + +// readFence turns one fenced block into a codeBlock, or nil to leave it exactly as goldmark rendered it. +func readFence(f *ast.FencedCodeBlock, source []byte, origin render.Origin) *codeBlock { + lang := string(f.Language(source)) + args := map[string]string{} + if f.Info != nil { + rest := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(string(f.Info.Segment.Value(source))), lang)) + for rest != "" { + key, value, remainder, ok := argument(rest) + if !ok { + break + } + args[key] = value + rest = remainder + } + } + block := &codeBlock{args: args, lang: lang, first: 1} + if name := args["file"]; name != "" { + body, from, ok := fromFile(origin, name, args["lines"]) + if !ok { + return nil + } + block.source, block.first, block.numbered = body, from, true + } else { + var buf bytes.Buffer + for i := 0; i < f.Lines().Len(); i++ { + line := f.Lines().At(i) + buf.Write(line.Value(source)) + } + block.source = buf.Bytes() + } + if start, err := strconv.Atoi(args["start"]); err == nil && start > 0 { + block.first, block.numbered = start, true + } + if args["numbers"] != "" { + block.numbered = true + } + block.marked = ranges(args["hl"]) + return block +} + +// fromFile reads a snippet out of a file beside the bundle, and reports the line number it starts at. +// +// The same containment rule an include keeps: a name with `..` is refused, so a block cannot publish a +// template or a dotfile (ADR-0038). +func fromFile(origin render.Origin, name, span string) ([]byte, int, bool) { + if origin.Files == nil || strings.Contains(name, "..") { + slog.Error("code block cannot read that file", "file", name) + return nil, 0, false + } + data, err := fs.ReadFile(origin.Files, path.Join(origin.Dir, name)) + if err != nil { + slog.Error("code block cannot read that file", "file", name, "err", err) + return nil, 0, false + } + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + from, to := 1, len(lines) + if pair := ranges(span); len(pair) == 1 { + from, to = pair[0][0], pair[0][1] + } + if from < 1 { + from = 1 + } + if to > len(lines) || to < from { + to = len(lines) + } + return []byte(strings.Join(lines[from-1:to], "\n") + "\n"), from, true +} + +// ranges reads `3,7-9` into the pairs chroma wants. A number on its own is a range of one. +func ranges(spec string) [][2]int { + var out [][2]int + for _, part := range strings.Split(spec, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + lo, hi, split := strings.Cut(part, "-") + start, err := strconv.Atoi(lo) + if err != nil { + continue + } + end := start + if split { + if end, err = strconv.Atoi(hi); err != nil { + continue + } + } + out = append(out, [2]int{start, end}) + } + return out +} + +// renderCode highlights the block and hands the result to the theme. +// +// Classes rather than inline colour, so the palette lives in a stylesheet the theme owns and a reader's dark +// mode is the theme's business — the feature decides which token this is and nothing about how it looks +// (ADR-0036, ADR-0075). +func (f fragments) renderCode(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + block := n.(*codeBlock) + lexer := lexers.Get(block.lang) + if lexer == nil { + lexer = lexers.Fallback + } + tokens, err := chroma.Coalesce(lexer).Tokenise(nil, string(block.source)) + if err != nil { + slog.Error("cannot highlight", "lang", block.lang, "err", err) + return ast.WalkContinue, nil + } + options := []html.Option{html.WithClasses(true)} + if block.numbered { + options = append(options, html.WithLineNumbers(true), html.BaseLineNumber(block.first)) + } + if len(block.marked) > 0 { + options = append(options, html.HighlightLines(block.marked)) + } + var highlighted bytes.Buffer + if err := html.New(options...).Format(&highlighted, styles.Fallback, tokens); err != nil { + slog.Error("cannot format", "lang", block.lang, "err", err) + return ast.WalkContinue, nil + } + args := map[string]string{"lang": block.lang} + for k, v := range block.args { + args[k] = v + } + out, err := f.partial(codeFragment, render.Fragment{Args: args, Body: template.HTML(highlighted.String())}) + if err != nil { + slog.Error("skipping code fragment", "err", err) + out = highlighted.Bytes() + } + if _, err := w.Write(out); err != nil { + return ast.WalkStop, err + } + return ast.WalkSkipChildren, nil +} diff --git a/internal/ext/shortcodes/code_test.go b/internal/ext/shortcodes/code_test.go new file mode 100644 index 0000000..2f3ecb3 --- /dev/null +++ b/internal/ext/shortcodes/code_test.go @@ -0,0 +1,84 @@ +package shortcodes + +import ( + "strings" + "testing" + "testing/fstest" +) + +// codeFS is a bundle with a file a post quotes parts of. +func codeFS(body string) fstest.MapFS { + return fstest.MapFS{ + "content/posts/c/index.md": {Data: []byte("---\ntitle: C\n---\n" + body)}, + "content/posts/c/server.go": {Data: []byte( + "package main\n\nimport \"net/http\"\n\nfunc Serve() {\n\tmux := http.NewServeMux()\n\treturn\n}\n")}, + } +} + +func TestCodeIsHighlightedIntoClasses(t *testing.T) { + got := bundle(t, codeFS("```go\npackage main\n```\n"), "posts/c") + for _, want := range []string{`class="chroma"`, `class="kn"`, "package"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q:\n%s", want, got) + } + } + // Classes, never inline colour: the palette is the theme's (ADR-0075). + if strings.Contains(got, "style=\"color:") { + t.Errorf("colour belongs in the stylesheet, not the markup:\n%s", got) + } +} + +func TestAFenceCarriesATitleNumbersAndHighlights(t *testing.T) { + got := bundle(t, codeFS("```go title=\"main.go\" numbers=yes hl=2\npackage main\n\nfunc main() {}\n```\n"), "posts/c") + for _, want := range []string{"
main.go
", `class="ln"`, `class="line hl"`} { + if !strings.Contains(got, want) { + t.Errorf("missing %q:\n%s", want, got) + } + } +} + +// The point of reading from a file: quoting parts of the same one across a post, each keeping the line +// numbers it really has (ADR-0075). +func TestASnippetComesFromAFileWithItsOwnLineNumbers(t *testing.T) { + got := bundle(t, codeFS("```go file=server.go lines=5-7 hl=6\n```\n\n```go file=server.go lines=1-1\n```\n"), "posts/c") + if !strings.Contains(got, "NewServeMux") { + t.Errorf("the snippet should carry the file's lines:\n%s", got) + } + if strings.Contains(got, "import") { + t.Errorf("only the requested lines, not the whole file:\n%s", got) + } + for _, want := range []string{">5<", ">6<", ">7<"} { + if !strings.Contains(got, want) { + t.Errorf("the file's own numbering should show, missing %q:\n%s", want, got) + } + } + if strings.Count(got, `class="chroma"`) != 2 { + t.Errorf("both snippets should render:\n%s", got) + } +} + +func TestACodeBlockCannotReadOutsideItsBundle(t *testing.T) { + fsys := codeFS("```go file=../../../secret.go\n```\n") + fsys["secret.go"] = &fstest.MapFile{Data: []byte("const Secret = 1\n")} + if got := bundle(t, fsys, "posts/c"); strings.Contains(got, "Secret") { + t.Errorf("a code block stays inside its bundle:\n%s", got) + } +} + +func TestAnUnknownLanguageStillRenders(t *testing.T) { + got := bundle(t, codeFS("```nosuchlang\nsome text\n```\n"), "posts/c") + if !strings.Contains(got, "some text") { + t.Errorf("the code must survive an unknown language:\n%s", got) + } +} + +func TestRangesReadsWhatChromaWants(t *testing.T) { + for _, c := range []struct { + in string + want int + }{{"", 0}, {"3", 1}, {"3,7-9", 2}, {"junk", 0}, {"2-4,junk,6", 2}} { + if got := len(ranges(c.in)); got != c.want { + t.Errorf("ranges(%q) gave %d pairs, want %d", c.in, got, c.want) + } + } +} diff --git a/internal/ext/shortcodes/shortcodes.go b/internal/ext/shortcodes/shortcodes.go index 2956e35..e517770 100644 --- a/internal/ext/shortcodes/shortcodes.go +++ b/internal/ext/shortcodes/shortcodes.go @@ -58,6 +58,7 @@ func (e extension) Extend(md goldmark.Markdown) { util.Prioritized(includes{md: md}, 100), util.Prioritized(bodies{md: md}, 150), util.Prioritized(tables{}, 160), + util.Prioritized(code{}, 170), ), parser.WithInlineParsers(util.Prioritized(icons{}, 500)), ) @@ -290,6 +291,7 @@ func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { reg.Register(kind, f.render) reg.Register(iconKind, f.renderIcon) reg.Register(containerKind, f.renderContainer) + reg.Register(codeKind, f.renderCode) } // render writes the theme's fragment for this call. diff --git a/internal/render/templates/shortcodes/code.html b/internal/render/templates/shortcodes/code.html new file mode 100644 index 0000000..f451b21 --- /dev/null +++ b/internal/render/templates/shortcodes/code.html @@ -0,0 +1,7 @@ +{{/* The engine highlights and the theme frames. `.Body` is chroma's token spans, already classed, so the + palette below in theme.css is the theme's to change — and a copy button, which needs a script, belongs + in a theme of your own rather than here (ADR-0026, ADR-0075). */}} +{{define "code"}}
+{{- with .Args.title}}
{{.}}
{{end}} +{{- .Body}} +
{{end}} diff --git a/internal/render/templates/theme.css b/internal/render/templates/theme.css index 4e2b651..ee3e73f 100644 --- a/internal/render/templates/theme.css +++ b/internal/render/templates/theme.css @@ -30,6 +30,17 @@ aside.side { border-left: 3px solid #d8d5cd; border-radius: 0; padding: 0 0 0 1r .side-title { font-weight: 600; margin: 0 0 0.5rem; } @media (min-width: 55rem) { aside.side { float: right; width: 12rem; margin: 0.25rem 0 1rem 1.5rem; } } mark { background: #fbf1a9; color: #16161a; padding: 0 0.15em; } +figure.code { margin: 1.5rem 0; } +figure.code figcaption { font-size: 0.85em; color: #6b6b6b; padding-bottom: 0.25rem; } +.chroma { overflow-x: auto; padding: 0.75rem; background: #f3f2ee; } +.chroma .lnt { color: #9a9a9a; padding-right: 0.75rem; user-select: none; } +.chroma .hl { background: #e6e3d8; display: block; } +.chroma .k, .chroma .kd, .chroma .kn { color: #7a3e9d; } +.chroma .s, .chroma .s1, .chroma .s2 { color: #2a6b2a; } +.chroma .c, .chroma .c1, .chroma .cm { color: #6b6b6b; font-style: italic; } +.chroma .m, .chroma .mi, .chroma .mf { color: #a0522d; } +.chroma .nf, .chroma .nx { color: #1a4d7a; } +.chroma .nt, .chroma .nb { color: #8a5a00; } nav.toc { border-top: 1px solid #d8d5cd; border-bottom: 1px solid #d8d5cd; padding: 0.5rem 0; margin: 1.5rem 0; font-size: 0.95em; } nav.toc ol { list-style: none; padding-left: 0; margin: 0; } .toc-title { font-weight: 600; margin: 0 0 0.35rem; } @@ -44,4 +55,13 @@ nav.toc .toc-4 { padding-left: 2rem; } .admonition, aside.side { border-left-color: #33333c; } mark { background: #5d5320; color: #f2efe6; } nav.toc { border-color: #33333c; } + figure.code figcaption { color: #9a9a9a; } + .chroma { background: #22222a; } + .chroma .hl { background: #2f2f3a; } + .chroma .k, .chroma .kd, .chroma .kn { color: #c8a0e8; } + .chroma .s, .chroma .s1, .chroma .s2 { color: #9ed49e; } + .chroma .c, .chroma .c1, .chroma .cm { color: #8a8a8a; } + .chroma .m, .chroma .mi, .chroma .mf { color: #e0a878; } + .chroma .nf, .chroma .nx { color: #8ab4dd; } + .chroma .nt, .chroma .nb { color: #d8b46a; } } diff --git a/scripts/allowed-deps.txt b/scripts/allowed-deps.txt index 7f68568..c05c831 100644 --- a/scripts/allowed-deps.txt +++ b/scripts/allowed-deps.txt @@ -7,3 +7,4 @@ github.com/yuin/goldmark golang.org/x/text # NFC normalisation (ADR-0015); collation later if earned golang.org/x/image # CatmullRom resampling + WebP decode (ADR-0040); no stdlib resizer exists gopkg.in/yaml.v3 # frontmatter + site declaration (ADR-0020) +github.com/alecthomas/chroma/v2 # server-side syntax highlighting (ADR-0075); the only mature pure-Go one