9.4 KiB
Thor — Odin Static Site Generator
Thor is a static site generator written in Odin, replacing Hugo for the sbrow.github.io blog. It lives at ./thor/ as a git subtree with its own flake.nix.
Architecture
thor.json ← site config (title, base_url, author, params, sectionate)
content/ ← markdown and HTML content files
layouts/ ← Mustache templates + partials (including icons)
assets/ ← CSS (Tufte-based, no build step) and JS
public/ ← build output (generated)
Source files
| File | Responsibility |
|---|---|
main.odin |
Entry point. Sets context.logger, calls init_site, walk_content, render_site |
site.odin |
Site struct (config + arena), init_site, load_site_config, site_merge, site_allocator, destroy_site |
frontmatter.odin |
JSON frontmatter parser ({ } delimited) |
content.odin |
Page struct, content walker, page loader, cmark integration, full markdown pipeline |
footnotes.odin |
Note definition stripping (pre-cmark) + sidenote/marginnote injection (post-cmark) |
alerts.odin |
GitHub alert post-processor (> [!CAUTION] → styled blockquote) |
emoji.odin |
Emoji shortcode expander (:shrug: → ¯\_(ツ)_/¯), post-cmark |
highlight.odin |
Post-cmark Tree-sitter syntax highlighter; loads grammars/queries from Helix, caches loaded grammars, reports syntax errors with file/line |
tree_sitter.odin |
C FFI bindings for Tree-sitter (TSParser, TSQuery, TSQueryCursor, node traversal, dlopen/dlsym) |
sectionate.odin |
wrap_sections proc — splits HTML at <h2 into <section> wrappers |
render.odin |
Mustache template rendering, all page types, RSS, sitemap, robots.txt, load_partials recursive scan |
feed.odin |
RSS feed + sitemap XML generation |
mustache/ |
Mustache template engine (spec-compliant, replaces vendored odin-mustache) |
Icon SVGs live as HTML partials in layouts/partials/icons/ (home, github, rss, chevron_up, star).
Data flow
thor.json → init_site → Site (config + Dynamic_Arena)
↓
content/ → walk_content → []Page (with body_html from pipeline)
↓
layouts/*.html → render_site (Mustache render_in_layout) → public/
Config system
Site has params: json.Value for arbitrary user-defined data from thor.json. Social links and other template-only data live under "params". sectionate: bool controls automatic <section> wrapping at <h2> boundaries.
{
"title": "...",
"base_url": "...",
"author": "...",
"sectionate": true,
"params": {
"social": [
{ "name": "github", "url": "...", "icon": "icons/github" }
]
}
}
Templates access params via dotted keys: {{#params.social}}, {{>* icon}}.
Config precedence: CLI flags > thor.json values > hardcoded defaults.
Markdown pipeline (in content.odin load_page)
raw markdown
→ expand_emoji (pre-cmark: :shortcode: → unicode)
→ strip_definitions (pre-cmark: extract [^id]: definitions)
→ cmark markdown_to_html (Unsafe mode for HTML passthrough)
→ expand_emoji (post-cmark: :shortcode: → unicode, avoids cmark escape issues)
→ inject_sidenotes (post-cmark: [^id] → <label><input><span> markup)
→ inject_alerts (post-cmark: [!TYPE] blockquotes → styled alerts)
→ highlight_code (post-cmark: tree-sitter per code block, with error reporting)
→ wrap_sections (post-cmark: if site.sectionate, wraps content in <section> at <h2>)
.html content files skip cmark entirely — body is used as-is.
Syntax highlighting
Build-time highlighting via Tree-sitter C FFI. No client-side JavaScript.
- Grammars loaded via
dlopenfrom Helix's compiled.sofiles - Highlight queries (
.scm) loaded from Helix's runtime directory - Paths hardcoded in
tree_sitter.odin(Nix store paths, Helix-version-dependent) - Capture names mapped to CSS classes:
keyword→.hl-keyword,constant.numeric.integer→.hl-constant-numeric-integer, etc. - Atom-one-dark color theme in
main.css - Failed grammar loads are cached (no retries) and logged via
log.warnf - Syntax errors detected via
ts_node_has_error, reported with file path and line number relative to code block
Memory management
Siteowns amem.Dynamic_Arenainit_sitecallsmem.dynamic_arena_init(&site.arena, alignment = 64)— the 64-byte alignment is required by Odin's map runtime (MAP_CACHE_LINE_SIZE)- Config loading (flags + JSON) uses the arena allocator explicitly
site_allocator(site)returns the arena allocator for callersdestroy_sitefrees the arenamain.odinsetscontext.logger = log.create_console_logger()— without this, alllog.*calls are silently dropped- Not yet wired:
context.allocatoris not set to the arena inmain.odin, so rendering and content processing still use the heap allocator
Building
Local development
nix develop
# From blog root:
odin run ./thor -- -drafts
cp assets/css/main.css public/css/main.css
cp assets/js/main.js public/js/main.js
caddy run # serves public/ on blog.localhost
No CSS build step — main.css is static Tufte-based CSS, no preprocessor or compiler needed.
Production build
nix build # runs thor + copies CSS/JS, outputs to ./result/
Tests
cd thor
odin test . # site + mustache smoke tests
odin test mustache # mustache spec + targeted tests (37 total)
Mustache engine
Spec-compliant Mustache implementation at mustache/. Passes all 170 tests across 7 official spec files (interpolation, sections, inverted, comments, partials, dynamic-names, inheritance).
Files
| File | Responsibility |
|---|---|
mustache.odin |
Public API (parse, render, Template), parser (parse_section), post-parse de-indent (deindent_blocks), renderer (render_nodes), indent helpers |
tokenizer.odin |
Tokenizer (template string → []Token), two-pass standalone whitespace detection (trim_standalone_whitespace) |
data.odin |
Reflection-based data model: effective (union/distinct peeling), lookup_in, resolve_name, is_truthy, any_to_string, list_info, write_value, format_f64 |
spec_test.odin |
JSON spec test runner — loads spec/specs/*.json, runs each test case |
Architecture
parse(source) → tokenize → trim_standalone_whitespace → parse_section → deindent_blocks → Template
render(tmpl, data, partials) → render_nodes (walks flat node array against context stack) → string
- Flat
[dynamic]Nodearray withfirst_child/child_countindices — one allocation, onedelete. Pre-order layout: children stored contiguously after their parent. render_nodestakesall_nodes(full array, for absolute child access) +nodes(current slice). Index-based loop, skips children after sections/blocks viai += 1 + child_count.- Context stack:
^[dynamic]anywithappend/popfor section push/pop. effective(a)peels Named/Distinct/Union layers (includingjson.Value) so all downstream operations can switch on baseType_Infovariant directly.- Two-pass standalone detection: detect using original token values, then trim left-to-right with
left_done/right_donetracking to prevent double-trims. Cascadingcheck_left/check_rightskip adjacent standalone-eligible tags. - Block indent:
deindent_blocksruns post-parse — finds common indent of direct text children, sets block's intrinsic indent if empty, removes common indent. Renderer applies block indent at output level viawrite_indented. - Partial/parent indent:
Templatestoressourcefor indent re-parse.render_templatecallsindent_lines(source, indent)then re-parses with temp allocator when indent is non-empty. - Block overrides (
Block_Overridestruct): carriesall_nodes+ child range so override content from different templates renders correctly.merge_block_overridespropagates overrides through multi-level inheritance chains.
Not implemented
- Lambdas (
~lambdas.json) - Set delimiters (
{{= =}},delimiters.json)
Known limitations
- cmark allocates via C malloc, not the arena. HTML output leaks until process exit.
- CSS/JS cache busting uses manual
?v=Nquery params instead of content hashing. json.Valueparams require 64-byte aligned arena (workaround fordynamic_arena_allocator_procignoring per-allocation alignment).- Tree-sitter grammar/query paths hardcoded in
tree_sitter.odin(Nix store hashes, Helix-version-dependent). TSQueryCaptureneeds explicit_padding: u32field for C ABI compatibility (40-byte sizeof).- highlight.js removed; syntax highlighting is build-time only (no fallback if Tree-sitter fails).
format_f64in mustache brute-forces shortest float representation (Odin'sstrconvdoesn't produce shortest round-trip for all values like3.3).- Block indent uses output-level indentation (
write_indented), not source-level re-parse. Multi-line interpolated content inside a standalone block would get incorrectly indented. No spec test exercises this.
Design decisions
See HUGO.md for analysis of why thor doesn't need Hugo's shortcode context isolation.
See mustache/PARTIAL_INDENT.md for whitespace handling analysis.
See mustache/SPEC.md for the original implementation specification.
TODO
See TODOS.md for the full list.