diff options
| author | t <t@tjp.lol> | 2026-07-13 11:38:42 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-07-31 16:18:12 -0600 |
| commit | 9f4971c92628052b8c51342b83b9aef966a4c318 (patch) | |
| tree | 8d237adecde8ddbc421e09697977205743118ae3 | |
| parent | 7711dcbfe619b616f2d78acbd524d289066576c1 (diff) | |
Plan fenced-code highlighting and add path template helpers
| -rw-r--r-- | PLAN.md | 83 | ||||
| -rw-r--r-- | README.md | 3 | ||||
| -rw-r--r-- | main.go | 11 | ||||
| -rw-r--r-- | main_test.go | 14 |
4 files changed, 100 insertions, 11 deletions
@@ -21,7 +21,8 @@ Goldmark parsing. omitted raw HTML node back on. - Continue using `html/template`; do not mark destinations or other user-controlled strings as `template.URL`. -- Add no dependencies and no configuration or plugin layer. +- Add exactly one production dependency, `github.com/alecthomas/chroma/v3`, for + fenced-code syntax highlighting. Add no configuration or plugin layer. - Do not expose Goldmark AST nodes to site templates. - Implement production behavior before changing tests that currently assume sections are rendered during discovery. @@ -60,6 +61,10 @@ instead of silently unused templates. Other template names remain unaffected. task checkboxes, thematic breaks, and hard breaks. - `.Destination`, `.Title`, `.Alt`, `.Language`, and `.ID` are plain strings so `html/template` applies the correct contextual escaping. +- `markdown/fenced_code_block` also receives `.HighlightedContent`. It is + `template.HTML` containing only Chroma-generated token markup, with no code + block wrappers, and is empty when the fence has no known language or cannot + be highlighted. Its `.Content` remains the escaped source-code fallback. - Optional string values are the empty string when absent. - No generic `.Node`, `.Attributes`, `.Text`, or metadata map is exposed. @@ -73,7 +78,7 @@ instead of silently unused templates. Other template names remain unaffected. | `markdown/paragraph` | `Content template.HTML` | Rendered inline paragraph contents. | | `markdown/blockquote` | `Content template.HTML` | Rendered block contents. | | `markdown/code_block` | `Content template.HTML` | Escaped contents of an indented code block. The template owns both `<pre>` and `<code>` wrappers. | -| `markdown/fenced_code_block` | `Content template.HTML`, `Language string` | Escaped code and the first word of the fence info string. The template owns both wrappers. | +| `markdown/fenced_code_block` | `Content template.HTML`, `HighlightedContent template.HTML`, `Language string` | Escaped code, optional Chroma token markup, and the first word of the fence info string. The template owns both wrappers. | | `markdown/unordered_list` | `Content template.HTML`, `Tight bool` | Rendered list items. | | `markdown/ordered_list` | `Content template.HTML`, `Start int`, `Tight bool` | Rendered list items and the first source marker's number. `Start` is populated even when it is `1`. | | `markdown/list_item` | `Content template.HTML` | Rendered block contents, including nested lists. | @@ -147,7 +152,7 @@ nodes do not emit their own HTML elements and also have no templates. {{end}} {{define "markdown/fenced_code_block"}} -<pre class="weft"><code class="weft{{with .Language}} language-{{.}}{{end}}">{{.Content}}</code></pre> +<pre class="weft chroma"><code class="weft language-{{.Language}}">{{if .HighlightedContent}}{{.HighlightedContent}}{{else}}{{.Content}}{{end}}</code></pre> {{end}} {{define "markdown/table_header"}} @@ -159,6 +164,53 @@ nodes do not emit their own HTML elements and also have no templates. {{end}} ``` +## Build-time syntax highlighting + +Defining `markdown/fenced_code_block` activates build-time highlighting through +`github.com/alecthomas/chroma/v3`. It emits static, class-based HTML; the site +supplies matching CSS for the fixed Chroma token-class prefix in its normal +stylesheet, including any light/dark theme rules. No JavaScript, client-side +highlighter, external executable, generated stylesheet, or new CLI option is +involved. + +The Weft fenced-code handler owns the node when this template is defined: + +1. Extract the raw fence contents and the existing first-word `.Language`. +2. Look up that exact language in Chroma. Do not guess a lexer for blank or + unknown info strings. +3. For a known lexer, format the code with Chroma's class-based HTML formatter, + configured not to emit its own code-block wrappers, and assign that trusted + fragment to `.HighlightedContent`. +4. On an absent/unknown language or any Chroma tokenization/formatting failure, + leave `.HighlightedContent` empty. `.Content` remains the escaped source in + every case, so templates have a deterministic safe fallback. +5. Execute the fenced-code template. Indented `markdown/code_block` nodes are + not highlighted by this feature. + +Only Chroma's output may become `template.HTML`; the Markdown code source must +never be trusted directly. A fence containing text such as +`</span><script>…</script>` must remain escaped even when highlighting succeeds. + +### Rejected integrations + +Goldmark remains the renderer that walks every rendered AST subtree. Weft +renders one direct child of the document at a time solely to preserve the +existing `.Sections` contract—one `template.HTML` fragment for each top-level +Markdown node. Rendering the whole document once would lose those source-node +boundaries; moving the same per-child buffering into a document renderer would +only obscure the current `renderSections` responsibility. + +Do not register `goldmark-highlighting` as a Goldmark extension. Like Weft's +planned handler, it registers a renderer for `ast.KindFencedCodeBlock`, but it +writes the complete highlighted block, including its own wrappers, directly to +Goldmark's output writer. It therefore cannot compose with a template that +owns the wrappers and needs a `.HighlightedContent` value. Its useful +integration is already just Chroma tokenization and HTML formatting, and its +fenced-block render method is not a public fragment-returning API; invoking it +and stripping wrappers would be brittle. It also depends on Chroma v2 rather +than the chosen v3. Call Chroma directly from Weft's fenced-code handler +instead. + ## Build lifecycle refactor Current discovery renders `.Sections` before site templates are loaded. Split @@ -170,7 +222,8 @@ parsing from rendering: fields. Leave `.Sections` empty. 3. Discover and load the complete site template set as today. 4. Validate `markdown/` template names and configure the Markdown template - renderer before Goldmark's renderer is used for the first time. + renderer, including the Chroma-backed fenced-code handler when that template + is defined, before Goldmark's renderer is used for the first time. 5. Render every page's retained top-level AST nodes into `.Sections`. 6. Only after every page has sections, render Markdown pages and explicit `.html.tmpl` or `.xml.tmpl` outputs. This preserves feeds and other templates @@ -208,7 +261,8 @@ an unoverridden sibling subtype while still recursively rendering its children. Leaf handlers extract their values directly: -- Escape and normalize code before assigning trusted `.Content`. +- Escape and normalize code before assigning trusted `.Content`. The fenced-code + handler additionally follows the build-time syntax-highlighting contract. - Flatten image descendants to plain `.Alt` independently of inline override templates. - Resolve email autolink destinations to include `mailto:`. @@ -259,14 +313,16 @@ Keep the implementation to the existing files unless the renderer makes rendering sections. 2. Add the post-template-load section-rendering phase. 3. Add the node-template lookup, typed template-data values, recursive - renderer, hard-break handler, and table adapter. + renderer, hard-break handler, table adapter, and direct Chroma v3 + fenced-code highlighter. 4. Keep `renderSections` responsible for the top-level `.Sections` boundary, but have it use the newly configured renderer. 5. Update `README.md` with the lifecycle-neutral public contract, full template - table, list-numbering rule, escaping behavior, and representative examples. + table, list-numbering rule, escaping/highlighting behavior, and + representative examples. -No CLI flag, manifest change, dependency, default-template file, or public AST -wrapper is needed. +No CLI flag, manifest change, default-template file, or public AST wrapper is +needed. Add only the Chroma v3 production dependency. ## Checks after production behavior exists @@ -280,8 +336,10 @@ end-to-end checks that covers the contract: 3. **Subtype fallback:** Defining only `markdown/strong`, one heading level, or one list type leaves sibling subtypes at Goldmark defaults while nested overrides still apply. -4. **Block fields:** Heading IDs, fenced-code language, escaped code content, - and blockquote content reach templates correctly. +4. **Block fields and highlighting:** Heading IDs, fenced-code language, + escaped code content, Chroma token markup for a known language, an empty + highlighted value for blank/unknown languages, and blockquote content reach + templates correctly. Highlighted hostile source remains escaped. 5. **List contract:** Tight and loose lists report `.Tight`; ordered lists beginning with `1`, repeated `1`, repeated `3`, and arbitrary later numbers expose only the correct first-item `.Start`; nested lists remain recursive. @@ -317,4 +375,7 @@ state. Do not retain eager production rendering solely for those tests. - Existing sites with no `markdown/` templates produce the same pages. - `.Sections`, page inventory, feed generation, link rewriting, validation, output ownership, and atomic writes retain their current public behavior. +- Fenced-code highlighting produces static Chroma markup only when its node + template is defined; blank/unknown languages fall back to escaped source and + no client-side JavaScript is required. - Documentation defines every template name and every available field. @@ -68,6 +68,9 @@ have no Markdown title, modification time, or sections. | Function | Result | | --- | --- | | `rel from target` | Makes a root-relative site target relative to the directory containing `from`. HTTP(S), `mailto:`, and fragment targets pass through. A trailing `/` is preserved, so `rel "about/index.html" "/"` returns `../`. | +| `splitPath value` | Splits a slash-separated path into components. | +| `joinPath parts` | Joins path components with `/`. | +| `dirPath value` | Removes the final path component without leaving a trailing `/`. | | `sortPages pages keys...` | Returns a copy. `"title"` sorts ascending case-insensitively. Date keys sort newest first, using the first valid value for each page; `"mod_time"` selects `.ModTime` and other keys select frontmatter values. | | `filterPages pages key value` | Keeps pages whose value stringifies equally. `"title"` and `"output"` select `.Title` and `.OutputPath`; other keys select `.Meta[key]`. | | `slicePages pages start end` | Returns the half-open range `[start:end]`, clamping both bounds instead of failing. | @@ -394,6 +394,9 @@ func (s *site) loadTemplates() error { func templateFuncs() template.FuncMap { return template.FuncMap{ "rel": relativePath, + "splitPath": func(value string) []string { return strings.Split(value, "/") }, + "joinPath": func(parts []string) string { return strings.Join(parts, "/") }, + "dirPath": dirPath, "sortPages": sortPages, "filterPages": filterPages, "slicePages": slicePages, @@ -404,6 +407,14 @@ func templateFuncs() template.FuncMap { } } +func dirPath(value string) string { + dir := path.Dir(value) + if dir == "." { + return "" + } + return dir +} + func relativePath(from, target string) string { if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "mailto:") || strings.HasPrefix(target, "#") { return target diff --git a/main_test.go b/main_test.go index a1eda14..66d793f 100644 --- a/main_test.go +++ b/main_test.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "encoding/xml" + "html/template" "os" "path/filepath" "strings" @@ -371,6 +372,19 @@ func TestSortingFilteringSlicing(t *testing.T) { } } +func TestPathTemplateFunctions(t *testing.T) { + tmpl := template.Must(template.New("path").Funcs(templateFuncs()).Parse( + `{{$parts := splitPath "notes/sync/recipes/soup.md"}}{{dirPath (joinPath (slice $parts 2))}}|{{dirPath "soup.md"}}`, + )) + var output strings.Builder + if err := tmpl.Execute(&output, nil); err != nil { + t.Fatal(err) + } + if got := output.String(); got != "recipes|" { + t.Fatalf("path = %q, want recipes|", got) + } +} + func TestUnchangedOutputsKeepMtime(t *testing.T) { root := t.TempDir() writeTestFile(t, root, "layout.tmpl", testLayouts()) |
