diff --git a/AGENTS.md b/AGENTS.md index 5cd142a..6db54d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,11 +16,12 @@ There is no linter/formatter config and no CI. Verify with the two test commands ## Landmines -- **`main.go` has `//go:embed all:frontend/dist`** — plain `go build` / `go vet` / `gopls` FAIL if `frontend/dist` doesn't exist. Run `cd frontend && pnpm install && pnpm run build` first, or `touch` a placeholder file into `frontend/dist/`. Don't "fix" errors about it; generate the directory. +- **`main.go` has `//go:embed all:frontend/dist`** — plain `go build` / `go vet` / `gopls` FAIL if `frontend/dist` doesn't exist. Run `cd frontend && pnpm install && pnpm run build` first, or `touch` a placeholder file into `frontend/dist/`. Don't "fix" errors about it; generate the directory. Conversely, after editing frontend CSS/JS, `go build ./...` alone does NOT re-embed `dist` — Go's build cache reuses the old binary. Rebuild the runnable app with `wails build -tags webkit2_41` (which recompiles the frontend) before screenshotting or manual testing, or you'll verify stale CSS. - **Linux webkit tag**: on systems with webkit2gtk-4.1, every `wails dev`/`wails build` needs `-tags webkit2_41` or linking fails. - **All Go code is `package main` at repo root.** New backend files must also be `package main` in the root, or the Wails binding generation and embed break. - **`frontend/wailsjs/` is generated** by the Wails CLI from Go method signatures. Never hand-edit; after adding/changing a bound Go method, run `wails dev` or `wails build` once to regenerate, and re-run `pnpm run check`. - **Security contract (test-enforced in `render_test.go`)**: rendering must keep stripping raw HTML (`SkipHTML`) and `javascript:` links (`Safelink`), and keep `nofollow noreferrer noopener` + `target="_blank"` on links. The app is read-only by design; do not add editing or relax these blackfriday flags. +- **Syntax highlighting runs server-side in Go** (`highlight.go`): fenced code blocks whose info string names a Chroma-recognised language are tokenised once at render time into Pygments-class ``s (no inline styles), coloured by rules in `markdown.css` (`--syn-*` tokens, Digital Rust Syntax Highlighting Spec). It uses a `blackfriday.Renderer` decorator (`highlightingRenderer`) that emits only `tagHighlighted`-marked CodeBlock nodes unescaped and delegates everything else to the stock renderer — do NOT swap in `HTMLBlock` nodes (SkipHTML drops them) and do not relax the flags to pass markup through. The class-attribute language word is gated by `attrSafeLanguage` because stock blackfriday copies the info word into `class="language-…"` unescaped. - **File watching is polling** (`os.Stat` every 800 ms in `app.go`), not fsnotify. Change detection compares mtime AND size; edits that preserve both within one tick are missed. Deletion silently stops the watcher (frontend keeps stale content — intentional). - **`NewApp` takes an `onChange func(path string)`** callback instead of emitting events directly, because `app.ctx` is nil until `startup` runs; `main.go` wires the callback to `EventsEmit("file-changed", ...)`. Preserve this indirection when refactoring startup. - The frontend sets `{@html doc.html}` on backend-rendered output; sanitization lives ONLY in the Go blackfriday flags, not the frontend. diff --git a/DESIGN.md b/DESIGN.md index 4789c59..f8c9664 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -102,11 +102,24 @@ components: rounded: "{rounded.md}" padding: "8px 16px" code-fence: - backgroundColor: "rgba(70, 18, 10, 0.45)" + backgroundColor: "#110402" textColor: "{colors.text-secondary}" typography: mono rounded: "{rounded.md}" padding: "16px" + syntax-highlighting: + note: "Token classes emitted by the Go backend (Chroma) on fenced code blocks; coloured per the Digital Rust Syntax Highlighting Specification. Daylight world re-inks each role toward the ink end of its ramp." + keyword: "{colors.fatal-error}" + constant: "#d3ae6e" + type: "{colors.dead-thread}" + attribute: "#a07e43" + builtin: "#a07e43" + function: "{colors.stack-overglow}" + string: "{colors.memory-leek}" + number: "#d78556" + operator: "#d7968c" + comment: "#ac6d4a" + error: "{colors.amber-alert-text}" --- # Design System: Sheaf @@ -255,7 +268,7 @@ All chrome is machined, pressable plates: 2px walls, an inner top bevel, a hard - **Links:** prose links set in Digital Rust and heat to Fatal Error on hover. External (http/https/mailto) links open in the system browser via `OpenExternal`; relative links to other Markdown files open in-place as documents via `OpenRelative`; in-document `#fragment` links scroll the sheet. ### Content plates (in-document) -- **Code fence:** inset well (45% Bad-Sector-alpha fill, 2px wall, inner lowlight), mono at 0.875rem. +- **Code fence:** deep near-black inset well (#110402, 2px wall, inner lowlight) set apart from the body stock, mono at 0.875rem. Fences whose info string names a recognised language arrive syntax-highlighted from the Go backend (Chroma), each token a short Pygments-class span coloured per the Digital Rust Syntax Highlighting Specification: keywords Fatal Error, functions Stack Overglow, types Dead Thread, strings Memory Leek, numbers Thermal Throttle, constants Bright Stack Overglow, comments Dim Thermal Throttle italic, operators/punctuation Copper Trace; diff fences paint additions Memory Leek 15% and deletions Amber Alert 15% over the whole line. Unrecognised or language-less fences render as plain mono ink, untouched. The daylight world re-inks every token role toward the ink end of its ramp so the highlighting holds on cream stock. - **Inline code:** 55% fill chip, 1px rule wall, 2px radius, Stack Overglow ink. - **Blockquote:** panel note — Null Pointer fill, subtle wall, sanctioned 3px Dead Thread left bar, inner bevel. - **Table:** walled plate (2px wall, separate spacing, radiused corner cells), caps head row on Null Pointer, hover row on Bad Sector. diff --git a/README.md b/README.md index 1115f9d..c8f32f8 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Features: - Instant render of GFM-style Markdown: tables, fenced code, strikethrough, autolinks, footnotes, heading anchors, smart punctuation +- Syntax highlighting for fenced code blocks, tokenised server-side (Chroma) + in the Digital Rust theme; languages without a lexer render as plain fences - Relative images and links resolved against the document's directory, so README screenshots and sibling files render in place (served read-only from that directory only, with symlink and `..` escape checks) diff --git a/app.go b/app.go index 123e4a1..95ab9ee 100644 --- a/app.go +++ b/app.go @@ -220,12 +220,8 @@ func isMarkdownFile(path string) bool { } func renderMarkdown(input []byte) string { - renderer := blackfriday.NewHTMLRenderer(blackfriday.HTMLRendererParameters{ - Flags: htmlFlags, - }) - html := blackfriday.Run(input, - blackfriday.WithExtensions(extensions), - blackfriday.WithRenderer(renderer), - ) - return string(html) + parser := blackfriday.New(blackfriday.WithExtensions(extensions)) + ast := parser.Parse(input) + rewriteDestinations(ast) + return renderAST(ast) } diff --git a/assets.go b/assets.go index 3cb9696..bb88c95 100644 --- a/assets.go +++ b/assets.go @@ -1,7 +1,6 @@ package main import ( - "bytes" "net/http" "net/url" "os" @@ -142,12 +141,5 @@ func renderDocumentHTML(input []byte) string { parser := blackfriday.New(blackfriday.WithExtensions(extensions)) ast := parser.Parse(input) rewriteDestinations(ast) - renderer := blackfriday.NewHTMLRenderer(blackfriday.HTMLRendererParameters{Flags: htmlFlags}) - var buf bytes.Buffer - renderer.RenderHeader(&buf, ast) - ast.Walk(func(node *blackfriday.Node, entering bool) blackfriday.WalkStatus { - return renderer.RenderNode(&buf, node, entering) - }) - renderer.RenderFooter(&buf, ast) - return buf.String() + return renderAST(ast) } diff --git a/frontend/src/dr-tokens.css b/frontend/src/dr-tokens.css index 5a06da6..eee7dd0 100644 --- a/frontend/src/dr-tokens.css +++ b/frontend/src/dr-tokens.css @@ -202,10 +202,12 @@ --code-ink: var(--stack-overglow); /* Markdown material tokens. - Inline code and fences are warm ink marks on the sheet (not floating - wells); only citation panels and table heads step up in elevation. */ + Inline code is a warm ink chip on the sheet; the code fence drops to a + deep near-black well (#110402) to set it apart from the body stock and + lift the syntax ink; only citation panels and table heads step up in + elevation. */ --code-inline-bg: rgba(70, 18, 10, 0.55); - --code-fence-bg: rgba(70, 18, 10, 0.45); + --code-fence-bg: #110402; --quote-bg: var(--null-pointer); --table-head-bg: var(--null-pointer); --table-row-hover: var(--bad-sector); @@ -291,5 +293,23 @@ --table-head-bg: #f0e2cf; --table-row-hover: #f5ead9; --target-flash: rgba(175, 46, 26, 0.12); + + /* Syntax highlighting, daylight-printed: the same Digital Rust + roles re-inked to hold on cream stock (deepened toward the ink + end of each accent's ramp; comments and structure stay dim). */ + --syn-keyword: #b0503f; /* Fatal Error, deepened */ + --syn-constant: #8a6a2f; /* Stack Overglow, deepened */ + --syn-type: #3f5f5c; /* Dead Thread, deepened */ + --syn-attribute: #7d5f33; /* Dim Stack Overglow, deepened */ + --syn-builtin: #7d5f33; /* Dim Stack Overglow, deepened */ + --syn-function: #8a6a2f; /* Stack Overglow, deepened */ + --syn-string: #5a6b38; /* Memory Leek, deepened */ + --syn-number: #9c5426; /* Thermal Throttle, deepened */ + --syn-operator: #a05a4e; /* Copper Trace, deepened */ + --syn-comment: #8a5a3c; /* Dim Thermal Throttle, deepened */ + --syn-error: #a8483b; /* Amber Alert Text, daylight */ + --syn-meta: #4f5b8c; /* Blue Screen, deepened */ + --syn-output: #4d241a; /* body ink */ + --syn-diff-del-text: #8c4a3e; /* Phantom Current, deepened */ } } diff --git a/frontend/src/markdown.css b/frontend/src/markdown.css index ecc9c03..4e5eeea 100644 --- a/frontend/src/markdown.css +++ b/frontend/src/markdown.css @@ -105,6 +105,27 @@ /* ── Inline code and fences ───────────────────────────────────────────── */ +/* Syntax-token inks ride custom properties so the daylight world can + re-ink them; the dark values are the Digital Rust Syntax Highlighting + Specification verbatim. Defaults are set here (the property wins over + the rule's fallback), daylight overrides live in dr-tokens.css. */ +:root { + --syn-keyword: #cf8175; /* Fatal Error */ + --syn-constant: #d3ae6e; /* Bright Stack Overglow */ + --syn-type: #6fa5a0; /* Dead Thread */ + --syn-attribute: #a07e43; /* Dim Stack Overglow */ + --syn-builtin: #a07e43; /* Dim Stack Overglow */ + --syn-function: #d4a759; /* Stack Overglow */ + --syn-string: #8fa667; /* Memory Leek */ + --syn-number: #d78556; /* Thermal Throttle */ + --syn-operator: #d7968c; /* Copper Trace */ + --syn-comment: #ac6d4a; /* Dim Thermal Throttle */ + --syn-error: #cb6353; /* Amber Alert Text (AA on dark fences) */ + --syn-meta: #8791b0; /* Blue Screen */ + --syn-output: #efd5d1; /* Phosphor Smoke */ + --syn-diff-del-text: #e7c0ba; /* Phantom Current */ +} + .markdown-body code { font-family: var(--dr-font-mono); font-size: 0.92em; @@ -151,6 +172,103 @@ border-radius: 0; } +/* ── Syntax highlighting (Digital Rust) ───────────────────────────────── + Fences with a recognised language arrive pre-tokenised from the Go + backend (Chroma), each token wrapped in a short Pygments-class span. The + rules below are the formatter's generated stylesheet for the Digital + Rust style, re-scoped under .markdown-body so they cannot leak into + plain fences or chrome, and routed through the --syn-* inks above. + Unrecognised languages render unspanned and simply inherit the fence's + ink. */ + +.markdown-body pre code .err { color: var(--syn-error, #cb6353); } + +/* keywords & storage */ +.markdown-body pre code .k, +.markdown-body pre code .kd, +.markdown-body pre code .kn, +.markdown-body pre code .kp, +.markdown-body pre code .kr { color: var(--syn-keyword, #cf8175); } +/* special values: booleans, nulls, this/self */ +.markdown-body pre code .kc { color: var(--syn-constant, #d3ae6e); } +/* type keywords (int, string) */ +.markdown-body pre code .kt { color: var(--syn-type, #6fa5a0); } + +/* names */ +.markdown-body pre code .na { color: var(--syn-attribute, #a07e43); } +.markdown-body pre code .nb, +.markdown-body pre code .bp { color: var(--syn-builtin, #a07e43); } +.markdown-body pre code .nc, +.markdown-body pre code .ne, +.markdown-body pre code .nn, +.markdown-body pre code .nt { color: var(--syn-type, #6fa5a0); } +.markdown-body pre code .nd { color: var(--syn-type, #6fa5a0); font-style: italic; } +.markdown-body pre code .nf, +.markdown-body pre code .fm, +.markdown-body pre code .ni { color: var(--syn-function, #d4a759); } +.markdown-body pre code .no, +.markdown-body pre code .nl { color: var(--syn-constant, #d3ae6e); } + +/* literals */ +.markdown-body pre code .l, +.markdown-body pre code .ld, +.markdown-body pre code .m, +.markdown-body pre code .mb, +.markdown-body pre code .mf, +.markdown-body pre code .mh, +.markdown-body pre code .mi, +.markdown-body pre code .il, +.markdown-body pre code .mo { color: var(--syn-number, #d78556); } +.markdown-body pre code .s, +.markdown-body pre code .sb, +.markdown-body pre code .sc, +.markdown-body pre code .s1, +.markdown-body pre code .s2, +.markdown-body pre code .se, +.markdown-body pre code .sh, +.markdown-body pre code .sr, +.markdown-body pre code .sx, +.markdown-body pre code .cpf { color: var(--syn-string, #8fa667); } +/* string affixes, quotes and ${} braces are delimiters, not content */ +.markdown-body pre code .sa, +.markdown-body pre code .dl, +.markdown-body pre code .si { color: var(--syn-operator, #d7968c); } +.markdown-body pre code .sd { color: var(--syn-string, #8fa667); font-style: italic; } +.markdown-body pre code .ss { color: var(--syn-constant, #d3ae6e); } + +/* operators & punctuation */ +.markdown-body pre code .o, +.markdown-body pre code .ow, +.markdown-body pre code .or, +.markdown-body pre code .p { color: var(--syn-operator, #d7968c); } + +/* comments & preprocessor */ +.markdown-body pre code .c, +.markdown-body pre code .ch, +.markdown-body pre code .cm, +.markdown-body pre code .c1, +.markdown-body pre code .cs { color: var(--syn-comment, #ac6d4a); font-style: italic; } +.markdown-body pre code .cp { color: var(--syn-keyword, #cf8175); } + +/* diff & generic markup */ +.markdown-body pre code .gd { + color: var(--syn-diff-del-text, #e7c0ba); + background: var(--amber-alert-15); +} +.markdown-body pre code .gi { + color: inherit; + background: var(--memory-leek-15); +} +.markdown-body pre code .ge { font-style: italic; } +.markdown-body pre code .gs { font-weight: 700; } +.markdown-body pre code .gh { color: var(--syn-function, #d4a759); } +.markdown-body pre code .gu { color: var(--syn-meta, #8791b0); } +.markdown-body pre code .go { color: var(--syn-output, #efd5d1); } +.markdown-body pre code .gp { color: var(--syn-builtin, #a07e43); } +.markdown-body pre code .gr, +.markdown-body pre code .gt { color: var(--syn-error, #cb6353); } +.markdown-body pre code .gl { text-decoration: underline; } + /* ── Lists ─────────────────────────────────────────────────────────────── */ .markdown-body ul, diff --git a/go.mod b/go.mod index 52bc829..b2536c3 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module Sheaf go 1.25.0 require ( + github.com/alecthomas/chroma/v2 v2.27.0 github.com/russross/blackfriday/v2 v2.1.0 github.com/wailsapp/wails/v2 v2.14.0 ) @@ -10,6 +11,7 @@ require ( require ( git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/bep/debounce v1.2.1 // indirect + github.com/dlclark/regexp2/v2 v2.2.1 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/go.sum b/go.sum index 494c2a1..c15623e 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,17 @@ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= +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/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= +github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= @@ -12,6 +20,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +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/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs= diff --git a/highlight.go b/highlight.go new file mode 100644 index 0000000..82c5f90 --- /dev/null +++ b/highlight.go @@ -0,0 +1,292 @@ +package main + +import ( + "bytes" + "io" + "strings" + + "github.com/alecthomas/chroma/v2" + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" + "github.com/russross/blackfriday/v2" +) + +// Syntax highlighting. Every fenced code block whose info string names a +// Chroma-recognised language is tokenised once, at render time in the +// backend, and ships as short Pygments-class spans with no inline styles — a +// document's highlighting costs one HTML parse in the webview and zero +// JavaScript. The Digital Rust colours live in markdown.css against those +// classes (vendored from the formatter's WriteCSS output, re-scoped to +// article fences); the style below exists only so the formatter has a +// complete token->colour map, since the emitted classes are fixed and +// palette-independent. +// +// Highlighted blocks are emitted by a blackfriday.Renderer decorator, not by +// rewriting the AST into HTMLBlocks: the sanitization contract pins SkipHTML, +// which silently drops every HTMLBlock, and the renderer's HTMLFlags are +// read-only after construction. Only nodes markHighlighted tags pass through +// unescaped; everything else delegates to the stock HTMLRenderer untouched. + +var highlightFormatter = chromahtml.New( + chromahtml.WithClasses(true), + chromahtml.WithAllClasses(true), + chromahtml.PreventSurroundingPre(true), + chromahtml.WithCSSComments(false), +) + +// digitalRustStyle is the projection of the Digital Rust Syntax Highlighting +// Specification onto Chroma token types. The style has no parent, so every +// type is spelled out; inheritance to subtypes (LiteralStringBacktick -> +// LiteralString, and so on) covers the rest. Italics carry the spec's +// styling modifiers for comments and decorators. +var digitalRustStyle = styles.Register(chroma.MustNewStyle("digitalrust", chroma.StyleEntries{ + chroma.Background: "bg:#340d07 #f7eae8", + chroma.PreWrapper: "bg:#340d07 #f7eae8", + chroma.Line: "#f7eae8", + chroma.CodeLine: "#f7eae8", + chroma.Text: "#f7eae8", + chroma.Whitespace: "#f7eae8", + chroma.Error: "#bf5747", + chroma.Other: "#f7eae8", + chroma.Keyword: "#cf8175", + chroma.KeywordConstant: "#d3ae6e", + chroma.KeywordDeclaration: "#cf8175", + chroma.KeywordNamespace: "#cf8175", + chroma.KeywordPseudo: "#cf8175", + chroma.KeywordReserved: "#cf8175", + chroma.KeywordType: "#6fa5a0", + chroma.Name: "#f7eae8", + chroma.NameAttribute: "#a07e43", + chroma.NameBuiltin: "#a07e43", + chroma.NameBuiltinPseudo: "#a07e43", + chroma.NameClass: "#6fa5a0", + chroma.NameConstant: "#d3ae6e", + chroma.NameDecorator: "italic #6fa5a0", + chroma.NameEntity: "#d4a759", + chroma.NameException: "#6fa5a0", + chroma.NameFunction: "#d4a759", + chroma.NameFunctionMagic: "#d4a759", + chroma.NameKeyword: "#cf8175", + chroma.NameLabel: "#d3ae6e", + chroma.NameNamespace: "#6fa5a0", + chroma.NameOperator: "#d7968c", + chroma.NameOther: "#f7eae8", + chroma.NameProperty: "#f7eae8", + chroma.NameTag: "#6fa5a0", + chroma.NameVariable: "#f7eae8", + chroma.NameVariableClass: "#f7eae8", + chroma.NameVariableGlobal: "#f7eae8", + chroma.NameVariableMagic: "#f7eae8", + chroma.Literal: "#d78556", + chroma.LiteralDate: "#d78556", + chroma.LiteralString: "#8fa667", + // Affix (prefixes like r"", b"") and Interpol (${...} braces) are + // delimiters, not string content: Copper Trace per the spec's + // interpolation-delimiter rule. + chroma.StringAffix: "#d7968c", + chroma.StringBacktick: "#8fa667", + chroma.StringChar: "#8fa667", + chroma.StringDelimiter: "#d7968c", + chroma.StringDoc: "italic #8fa667", + chroma.StringDouble: "#8fa667", + chroma.StringEscape: "#8fa667", + chroma.StringHeredoc: "#8fa667", + chroma.StringInterpol: "#d7968c", + chroma.StringOther: "#8fa667", + chroma.StringRegex: "#8fa667", + chroma.StringSingle: "#8fa667", + chroma.StringSymbol: "#d3ae6e", + chroma.Number: "#d78556", + chroma.NumberBin: "#d78556", + chroma.NumberFloat: "#d78556", + chroma.NumberHex: "#d78556", + chroma.NumberInteger: "#d78556", + chroma.NumberOct: "#d78556", + chroma.Operator: "#d7968c", + chroma.OperatorWord: "#d7968c", + chroma.Punctuation: "#d7968c", + chroma.Comment: "italic #ac6d4a", + chroma.CommentHashbang: "italic #ac6d4a", + chroma.CommentMultiline: "italic #ac6d4a", + chroma.CommentSingle: "italic #ac6d4a", + chroma.CommentSpecial: "italic #ac6d4a", + chroma.CommentPreproc: "#cf8175", + // Include paths in preprocessor directives are strings per the spec. + chroma.CommentPreprocFile: "#8fa667", + chroma.Generic: "#f7eae8", + chroma.GenericDeleted: "#e7c0ba", + chroma.GenericEmph: "italic #f7eae8", + chroma.GenericError: "#bf5747", + chroma.GenericHeading: "#d4a759", + chroma.GenericInserted: "#f7eae8", + chroma.GenericOutput: "#efd5d1", + chroma.GenericPrompt: "#a07e43", + chroma.GenericStrong: "bold #f7eae8", + chroma.GenericSubheading: "#8791b0", + chroma.GenericTraceback: "#bf5747", + chroma.GenericUnderline: "underline #f7eae8", +})) + +// highlightCode tokenises source as the given language and returns the +// tokens as classed HTML spans, ready to nest inside blackfriday's +//
 wrapper. Whitespace spans are unwrapped back to bare text:
+// whitespace never takes a colour, and the wrapper would double the span
+// count (and the webview's DOM) for nothing. ok is false for unknown
+// languages or lexer failures, and the caller falls back to the stock
+// escaped rendering.
+func highlightCode(source, language string) (html []byte, ok bool) {
+	lexer := lexers.Get(language)
+	if lexer == nil {
+		return nil, false
+	}
+	lexer = chroma.Coalesce(lexer)
+	iterator, err := lexer.Tokenise(nil, source)
+	if err != nil {
+		return nil, false
+	}
+	var buf bytes.Buffer
+	buf.Grow(len(source) + len(source)/2 + 64)
+	if err := highlightFormatter.Format(&buf, digitalRustStyle, iterator); err != nil {
+		return nil, false
+	}
+	return unwrapWhitespaceSpans(buf.Bytes()), true
+}
+
+var (
+	whitespaceSpanOpen  = []byte(``)
+	whitespaceSpanClose = []byte(``)
+)
+
+// unwrapWhitespaceSpans replaces every … with its
+// inner (already escaped) text. Whitespace spans contain no nested markup,
+// so the close tag is always the immediate next one.
+func unwrapWhitespaceSpans(html []byte) []byte {
+	for {
+		i := bytes.Index(html, whitespaceSpanOpen)
+		if i < 0 {
+			return html
+		}
+		rest := html[i+len(whitespaceSpanOpen):]
+		j := bytes.Index(rest, whitespaceSpanClose)
+		if j < 0 {
+			return html
+		}
+		inner := rest[:j]
+		out := make([]byte, 0, len(html)-len(whitespaceSpanOpen)-len(whitespaceSpanClose))
+		out = append(out, html[:i]...)
+		out = append(out, inner...)
+		out = append(out, rest[j+len(whitespaceSpanClose):]...)
+		html = out
+	}
+}
+
+// fenceLanguage extracts the language word from a code fence info string
+// (the first space/tab-separated word), lowercased for lexer lookup.
+func fenceLanguage(info []byte) string {
+	language := string(info)
+	if i := strings.IndexAny(language, " \t"); i >= 0 {
+		language = language[:i]
+	}
+	return strings.ToLower(language)
+}
+
+// attrSafeLanguage reports whether language can sit inside the double-quoted
+// class attribute without escaping. The stock blackfriday renderer copies
+// the info word into class="language-…" unescaped, so a fence like
+// ```x">