highlight code server-side, and let a block quote a file

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.
This commit is contained in:
Claude Opus 5
2026-08-02 00:16:32 +06:00
committed by bdeshi
parent 4c5bda98ab
commit 67defae912
17 changed files with 455 additions and 31 deletions
+4
View File
@@ -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
+4
View File
@@ -126,6 +126,10 @@ var exampleFeatures = []featureCase{
expect: []string{`<details name="run" open>`, "💡"}},
{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"`, "<figcaption>serve.sh — the ways to run it</figcaption>",
`class="line hl"`, ">7<"},
absent: []string{"<script", `style="color:`}},
{what: "grouped panels are tabs, and need no script", path: "/pages/colophon/", code: 200,
expect: []string{`<details name="run" open>`, "<summary>Serve your own</summary>"}, absent: []string{"<script"}},
{what: "a lone panel is an expando", path: "/pages/about/", code: 200,
+18
View File
@@ -308,6 +308,24 @@ edit lands promptly — unless `-poll` was passed, in which case the operator's
off by default and is not a bare boolean: revealing unpublished work should be impossible to enable by
fumbling an argument.
## Code blocks
A fenced block is highlighted server-side, with no script (ADR-0075). Its info string carries the language
first and then options:
| Option | Means |
|---|---|
| `title="main.go"` | a caption above the block |
| `numbers=yes` | draw the line-number gutter |
| `start=42` | number the first line 42 |
| `hl=3,7-9` | tint those lines, in the numbering the reader sees |
| `file=server.go` | take the content from that file beside the bundle instead of the fence |
| `lines=5-30` | with `file=`, only those lines — and the gutter shows the file's own numbering |
So a post may quote several parts of one program, each keeping the line numbers it really has, without the
copies drifting from the file. A name containing `..` is refused, exactly as an include is: a block cannot
publish a template or a dotfile.
## The Markdown dialect
CommonMark, plus a named set of extensions and nothing else (ADR-0058). Every one is parse-phase, so none of
+25
View File
@@ -1244,3 +1244,28 @@ budgeting ahead. Expensive — "core stops growing after Arc 2" (invariant 9) is
now moved three times, and each move makes it a weaker claim. The two ceilings still say something only
because `ext` rose far more than core did: 75% against 7%.
Revisit if: core reaches 3000 — and then the answer is a route seam, not a fourth raise.
## ADR-0075 — Syntax highlighting is chroma's, colour is the theme's, content may come from a file
Date: 2026-08-02 · Status: accepted (queue entry 8; first dependency since ADR-0040)
Decision: fenced blocks are highlighted at render time by `chroma`, emitted as CSS classes rather than inline
colour, and handed to a `code` theme fragment with the language, any title, and the highlighted body. A
fence's info string carries `title=`, `numbers=yes`, `start=N`, `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. The feature lives
in `internal/ext/shortcodes` rather than a package of its own, and adds `chroma` and its `regexp2` — 6 of 9
modules, and ~5MB of binary.
Why: colour is not decoration on a site that shows code constantly, and no lighter pure-Go option exists —
every "alternative to chroma" is JavaScript, which the reference theme is gated against. Classes rather than
inline colour because a palette is presentation and belongs where dark mode already lives (ADR-0036). Reading
from a file is what lets a post quote several parts of one program without the copies drifting from it, and
the line numbers stay the file's, so a reader can find what they are looking at. It is not a new package
because a new one could not import the `key=value` parser this repo already has — ADR-0069 forbids a feature
importing its sibling — and writing a second parser for the same syntax is what CLAUDE.md §6 stops.
Consequence: cheap — highlighting works with scripting off, in a feed reader, in a browser that never runs
JavaScript; a title, real line numbers and highlighted ranges cost the author one info string; and a copy
button, which does need a script, is a theme's own business rather than the engine's. Expensive — the binary
roughly doubles, from ~15MB to ~20MB, for a project whose sovereignty story is one small binary; chroma is
larger than khosra; and the reference theme now carries a token palette, which is the first thing in it that
is a taste rather than a demonstration, kept to eight classes for that reason.
Revisit if: the binary size becomes the thing people notice, in which case chroma can build with fewer
lexers, or the feature can be dropped for `class="language-x"` and nothing else — the fragment would not
change.
+3 -2
View File
@@ -25,7 +25,7 @@ table owns.
| `internal/render/view.go` | the theme contract in Go, and now actually all of it: `Page`, `List`, `Sequence`, `Extras`, `Item`, `Partial`, `Fragment` (with `Body`, `Headings` and `Lang` — ADR-0064, ADR-0065, ADR-0067), `Heading`, `Picture`, `Origin` |
| `internal/render/chrome.go` | the engine's own words: phrase table, month names, digits, and the `t`/`num`/`day` template funcs (ADR-0034), including the words a shortcode fragment supplies when the author gives none (ADR-0067) |
| `internal/render/templates/` | reference theme, complete (six icon names map to Unicode, no assets — ADR-0063): `base.html` (shell, navigation, language links, feed and OpenGraph), `page.html` (bundle, sequence, tags, extras), `list.html`, `extras.html`, `shortcodes/` — seven fragment files rather than one, and a site may use either form (ADR-0071) — with the `sizes` its own layout implies (ADR-0068), `theme.css` (ADR-0026, ADR-0049) |
| `internal/ext/shortcodes/` | first feature: `::name{key=value}` block parser and node renderer, rendering through a theme fragment (ADR-0036). `figure`, `gallery`, `include`, plus the derivative pass and remembered picture inspection, bounded and least-recently-used in `memo.go` (ADR-0042, ADR-0044, ADR-0073). `FootnotePrefix` namespaces an included file's footnote ids (ADR-0058). Directive syntax since ADR-0059, plus `icons.go`: `:name:` inline, rendered by the theme's one `icon` fragment (ADR-0063), `containers.go`: `:::name{…}``:::` wrapping a rendered body (ADR-0064), and `toc.go`: the document's headings for a `::toc` call, to a depth the call may set (ADR-0065, ADR-0066). `Merge` splices includes before the parse for a bundle that asks for it (ADR-0066) |
| `internal/ext/shortcodes/` | first feature: `::name{key=value}` block parser and node renderer, rendering through a theme fragment (ADR-0036). `figure`, `gallery`, `include`, plus the derivative pass and remembered picture inspection, bounded and least-recently-used in `memo.go` (ADR-0042, ADR-0044, ADR-0073). `FootnotePrefix` namespaces an included file's footnote ids (ADR-0058). Directive syntax since ADR-0059, plus `icons.go`: `:name:` inline, rendered by the theme's one `icon` fragment (ADR-0063), `containers.go`: `:::name{…}``:::` wrapping a rendered body (ADR-0064), `toc.go`: the document's headings for a `::toc` call, to a depth the call may set (ADR-0065, ADR-0066), and `code.go`: chroma highlighting, a fence's options, and snippets read from a file (ADR-0075). `Merge` splices includes before the parse for a bundle that asks for it (ADR-0066) |
| `internal/ext/notation/` | the inline marks CommonMark lacks: `~sub~`, `^sup^`, `==mark==`, and `~~strike~~`, which it owns so a single tilde can mean subscript (ADR-0061). `abbr.go` adds `*[TERM]:` definitions and the pass that expands them (ADR-0062) |
| `internal/ext/scaffold/` | writes one draft directory bundle into a site root through `os.Root`: never an overwrite |
| `internal/ext/watch/` | polls `content/` and `templates/` on an interval it is given, ignores editor droppings, and reports a settled change (ADR-0022, ADR-0048, ADR-0056). `site.yaml` is deliberately not fingerprinted (ADR-0055) |
@@ -72,7 +72,8 @@ panels on `pages/colophon`, the include, table, glossary and contents list on `w
A `Dockerfile` ships the binary alone: the site root is a mounted volume, never copied in (ADR-0010, ADR-0011).
Dependencies: four, all allowlisted — `goldmark`, `golang.org/x/text`, `golang.org/x/image`, `gopkg.in/yaml.v3`.
Dependencies: five direct, all allowlisted — `goldmark`, `golang.org/x/text`, `golang.org/x/image`,
`gopkg.in/yaml.v3`, `chroma` (ADR-0075); six modules of nine with `regexp2` indirect.
## Counters — the earn-it authority
+39 -28
View File
@@ -6,7 +6,7 @@ Every top-level declaration in the engine, with its line. Read this before openi
file: it answers "where does X live" and "what is in this package" without the bodies. What each
file is *for* lives in `state.md`; why it is that way lives in `decisions.md`.
## cmd/khosra — 309 lines + 288 test
## cmd/khosra — 309 lines + 292 test
check.go 45 · main.go 181 · new.go 42 · wire.go 41
@@ -175,10 +175,21 @@ doc.go 8 · scaffold.go 94
- scaffold.go:76 func titleFrom(key string) string
- scaffold.go:85 func mkdirAll(root *os.Root, dir string) error
## internal/ext/shortcodes — 1098 lines + 758 test
## internal/ext/shortcodes — 1311 lines + 842 test
containers.go 173 · doc.go 7 · icons.go 129 · images.go 241 · memo.go 73 · shortcodes.go 380 · toc.go 95
code.go 211 · containers.go 173 · doc.go 7 · icons.go 129 · images.go 241 · memo.go 73 · shortcodes.go 382 · toc.go 95
- code.go:27 const codeFragment = "code"
- code.go:30 var codeKind = ast.NewNodeKind("ShortcodeCode")
- code.go:32 type codeBlock struct
- code.go:46 func (n *codeBlock) Kind() ast.NodeKind { return codeKind }
- code.go:48 func (n *codeBlock) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- code.go:54 type code struct{}
- code.go:56 func (code) Transform(doc *ast.Document, reader text.Reader, pc parser.Context)
- code.go:76 func readFence(f *ast.FencedCodeBlock, source []byte, origin render.Origin) *codeBlock
- code.go:119 func fromFile(origin render.Origin, name, span string) ([]byte, int, bool)
- code.go:144 func ranges(spec string) [][2]int
- code.go:172 func (f fragments) renderCode(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
- containers.go:22 var containerKind = ast.NewNodeKind("ShortcodeContainer")
- containers.go:24 type container struct
- containers.go:34 func (n *container) Kind() ast.NodeKind { return containerKind }
@@ -230,31 +241,31 @@ containers.go 173 · doc.go 7 · icons.go 129 · images.go 241 · memo.go 73 ·
- shortcodes.go:37 func New(partial render.Partial) goldmark.Extender
- shortcodes.go:41 type extension struct
- shortcodes.go:50 func (e extension) Extend(md goldmark.Markdown)
- shortcodes.go:69 var nested = parser.NewContextKey()
- shortcodes.go:74 var includedAs = parser.NewContextKey()
- shortcodes.go:76 const footnoteKey = "khosra footnote-prefix"
- shortcodes.go:84 func FootnotePrefix(n ast.Node) []byte
- shortcodes.go:102 type includes struct
- shortcodes.go:106 func (in includes) Transform(doc *ast.Document, reader text.Reader, pc parser.Context)
- shortcodes.go:130 func (in includes) convert(name string, pc parser.Context) ([]byte, error)
- shortcodes.go:164 func pending(doc *ast.Document) []*node
- shortcodes.go:182 var kind = ast.NewNodeKind("Shortcode")
- shortcodes.go:185 type node struct
- shortcodes.go:204 func (n *node) Kind() ast.NodeKind { return kind }
- shortcodes.go:206 func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- shortcodes.go:209 type blocks struct{}
- shortcodes.go:211 func (blocks) Trigger() []byte { return []byte{' '} }
- shortcodes.go:213 func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State)
- shortcodes.go:247 func gallery(pc parser.Context) []render.Picture
- shortcodes.go:274 func (blocks) Continue(n ast.Node, reader text.Reader, pc parser.Context) parser.State
- shortcodes.go:278 func (blocks) Close(n ast.Node, reader text.Reader, pc parser.Context) {}
- shortcodes.go:280 func (blocks) CanInterruptParagraph() bool { return true }
- shortcodes.go:282 func (blocks) CanAcceptIndentedLine() bool { return false }
- shortcodes.go:285 type fragments struct
- shortcodes.go:289 func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer)
- shortcodes.go:299 func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
- shortcodes.go:327 func parse(line, prefix string) (name string, args map[string]string, ok bool)
- shortcodes.go:362 func argument(s string) (key, value, rest string, ok bool)
- shortcodes.go:70 var nested = parser.NewContextKey()
- shortcodes.go:75 var includedAs = parser.NewContextKey()
- shortcodes.go:77 const footnoteKey = "khosra footnote-prefix"
- shortcodes.go:85 func FootnotePrefix(n ast.Node) []byte
- shortcodes.go:103 type includes struct
- shortcodes.go:107 func (in includes) Transform(doc *ast.Document, reader text.Reader, pc parser.Context)
- shortcodes.go:131 func (in includes) convert(name string, pc parser.Context) ([]byte, error)
- shortcodes.go:165 func pending(doc *ast.Document) []*node
- shortcodes.go:183 var kind = ast.NewNodeKind("Shortcode")
- shortcodes.go:186 type node struct
- shortcodes.go:205 func (n *node) Kind() ast.NodeKind { return kind }
- shortcodes.go:207 func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- shortcodes.go:210 type blocks struct{}
- shortcodes.go:212 func (blocks) Trigger() []byte { return []byte{' '} }
- shortcodes.go:214 func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State)
- shortcodes.go:248 func gallery(pc parser.Context) []render.Picture
- shortcodes.go:275 func (blocks) Continue(n ast.Node, reader text.Reader, pc parser.Context) parser.State
- shortcodes.go:279 func (blocks) Close(n ast.Node, reader text.Reader, pc parser.Context) {}
- shortcodes.go:281 func (blocks) CanInterruptParagraph() bool { return true }
- shortcodes.go:283 func (blocks) CanAcceptIndentedLine() bool { return false }
- shortcodes.go:286 type fragments struct
- shortcodes.go:290 func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer)
- shortcodes.go:301 func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
- shortcodes.go:329 func parse(line, prefix string) (name string, args map[string]string, ok bool)
- shortcodes.go:364 func argument(s string) (key, value, rest string, ok bool)
- toc.go:16 const tocName = "toc"
- toc.go:23 type tables struct{}
- toc.go:25 func (tables) Transform(doc *ast.Document, reader text.Reader, pc parser.Context)
+8
View File
@@ -128,6 +128,7 @@ first thing on the page.
| `::gallery` | `gallery` | `.Pictures` — every picture beside the bundle, in filename order |
| `:name:` | `icon` | `.Args.name` — the name as written, nothing else |
| `:::note{title=…}``:::` | `note` | `.Args.title`, `.Body` — the rendered content |
| a fenced code block | `code` | `.Args.lang`, `.Args.title`, and `.Body` — chroma's token spans, already classed (ADR-0075) |
| `::toc{depth=N}` | `toc` | `.Headings` — the document's headings in order, no deeper than `depth` if given |
| `:::details{summary=… group=… open=…}``:::` | `details` | `.Args`, `.Body`. Siblings sharing a `group` open one at a time, through `<details name>` 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 `<pre class="chroma">` 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 `<figure class="code">` with the title as a `<figcaption>`, 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
@@ -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 `<details>`, which every browser opens without help. A reader with scripting off sees the same page.
:::
@@ -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
+6 -1
View File
@@ -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
+10
View File
@@ -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=
+211
View File
@@ -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
}
+84
View File
@@ -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{"<figcaption>main.go</figcaption>", `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)
}
}
}
+2
View File
@@ -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.
@@ -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"}}<figure class="code"{{with .Args.lang}} data-lang="{{.}}"{{end}}>
{{- with .Args.title}}<figcaption>{{.}}</figcaption>{{end}}
{{- .Body}}
</figure>{{end}}
+20
View File
@@ -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; }
}
+1
View File
@@ -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