summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--PLAN.md83
-rw-r--r--README.md3
-rw-r--r--main.go11
-rw-r--r--main_test.go14
4 files changed, 100 insertions, 11 deletions
diff --git a/PLAN.md b/PLAN.md
index 2992012..5b4a5cd 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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.
diff --git a/README.md b/README.md
index 79405e6..afcc659 100644
--- a/README.md
+++ b/README.md
@@ -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. |
diff --git a/main.go b/main.go
index 8fabda0..299724e 100644
--- a/main.go
+++ b/main.go
@@ -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())