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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user