26 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, params, modules, og)
content/ ← markdown and HTML content files
layouts/ ← Mustache templates + partials (user overrides)
assets/ ← CSS (Tufte-based), JS, fonts, images
thor/defaults/ ← bundled default templates (embedded via #directory)
public/ ← build output (generated)
Package structure
thor/
├── treesitter/ # FFI types + grammar management (standalone package)
├── markdown/ # Content transformation pipeline (imports ../treesitter)
├── mustache/ # Template engine with lambdas + pipe filters + diagnostics
├── content.odin # Page struct, Pending_File, scan_content_files, collect_languages, load_page
├── render.odin # Template rendering, data structs, RSS, sitemap
├── site.odin # Config (Flags, Config_File, Site), init_site
├── minify.odin # HTML/CSS minification (imports treesitter)
├── feed.odin # RSS + sitemap generation
├── vfs.odin # Union file system (defaults → modules → site)
├── assets.odin # VFS-based asset copying
├── html.odin # HTML helpers: strip_html_tags, unescape_html, generate_summary (word-count truncation), generate_description (scrub to plain text)
├── opengraph.odin # Open_Graph struct + og_for_site/og_for_page
├── frontmatter.odin # JSON frontmatter parser (supports nested og + lastmod)
├── defaults.odin # DEFAULTS_PATH constant (#directory)
├── main.odin # Entry point
├── bench/ # Template rendering benchmark
└── defaults/layouts/ # Bundled default templates
Source files (main package)
| File | Responsibility |
|---|---|
main.odin |
Entry point. Sets context.logger, calls init_site, build_vfs, wires treesitter.grammar_dir/query_dir from config, site_load_content, render_site. Optional Spall profiling via SPALL config flag. |
site.odin |
Flags (CLI), Config_File (thor.json, includes og: Open_Graph), Site (runtime state + arena + VFS + pages + modules + og). Feature enum. 5-step init_site. Imports md "markdown" for Extension enum. |
content.odin |
Page struct (includes lastmod, og), Pending_File struct, scan_content_files (section-aware walk that handles leaf bundles), collect_languages (pre-scan for code fence languages), load_page, infer_layout. Calls md.process() for the markdown pipeline. |
render.odin |
Template rendering: render_site, render_page_html, render_home_html, render_section. Data structs (Base_Data, Page_Data, Home_Data, Section_Data). VFS-based template loading with fallback chain (get_template). |
minify.odin |
HTML/CSS minification via tree-sitter. Imports ts "treesitter". |
feed.odin |
RSS feed + sitemap XML. Uses page.url for canonical URLs. |
vfs.odin |
Union file system: VFS, build_vfs, mount_dir, mount_subdir, mount_recursive, vfs_get, vfs_get_entry, vfs_entry_data. Layers defaults → modules → site. |
assets.odin |
copy_assets_dir — iterates VFS entries with assets/ prefix, minifies CSS, copies verbatim or via os.copy_file. |
html.odin |
strip_html_tags, unescape_html, generate_summary (word-count truncation, zero-alloc), generate_description (HTML→plain text: strip tags, decode entities, collapse whitespace). |
opengraph.odin |
Open_Graph struct (fields ordered per OGP spec, is_article: Maybe(bool)). og_for_site(site) for site defaults (from config + derived), og_for_page(site_og, page) for page-specific (overlay page.og + derive from page data). |
frontmatter.odin |
JSON frontmatter parser ({ } delimited). Supports layout, lastmod, and nested og object (via json_get_open_graph). |
defaults.odin |
DEFAULTS_PATH constant, resolved at compile time via #directory so bundled templates ship in the binary. |
Subpackages
| Package | Files | Responsibility |
|---|---|---|
treesitter/ |
treesitter.odin |
FFI types (Parser, Node, Query, etc.), @(link_prefix="ts_") foreign bindings, grammar management (Grammar_Store with persistent allocator, load_language/compile_query building blocks, ensure_parser/load_grammar lazy loading, preload_grammar/preload_grammars for parallel loading with sync.Mutex cache protection), statically-linked HTML/CSS grammars |
markdown/ |
markdown.odin |
Extension enum, DEFAULT_EXTENSIONS, process(body, ext, file_path) — full pipeline, parse_extension_list, apply_extension_config |
footnotes.odin |
strip_definitions (pre-cmark), inject_notes (post-cmark) |
|
alerts.odin |
inject_alerts — GitHub alert blocks (> [!NOTE]) → styled blockquotes with semantic class names (alert-note etc.) |
|
emoji.odin |
expand_emoji — :shortcode: → unicode emoji |
|
sectionate.odin |
wrap_sections — splits HTML at <h2> into <section> wrappers |
|
highlight.odin |
Syntax highlighting via tree-sitter. Imports ../treesitter. |
|
mustache/ |
See Mustache engine below | Template engine |
bench/ |
bench.odin + templates/ |
Standalone template rendering benchmark. Generates 500 posts + 100 comments, renders with indented partials + inheritance + pipes. --dump <path> for output validation, positional arg for iteration count (default 250). |
Icon SVGs live as HTML partials in layouts/partials/icons/ (home, github, rss, chevron_up, star).
Data flow
thor.json → find_config → init_site (5-step)
→ build_vfs (defaults/layouts → modules → site/layouts, site/assets)
→ site_load_content (scan_content_files + collect_languages + preload_grammars + load_page + url computation)
→ render_site
→ load_partials + get_template (VFS + fallback chain)
→ render_page_html / render_home_html / render_section
→ optional minify_html
→ public/
Page struct
Page :: struct {
section: string, // "" for root, "posts", etc.
slug: string,
layout: string, // inferred or frontmatter override
permalink: string, // relative URL path
url: string, // full canonical URL (base_url + permalink)
title: string,
description: string,
date: string,
lastmod: string,
menu: string,
body_html: string,
draft: bool,
is_starred: bool,
og: Open_Graph, // per-page OG overrides from frontmatter
_is_index: bool `private`,
}
No Page_Type enum — page type is inferred from section + _is_index. Layout is inferred via infer_layout(section, is_index):
- Home (root index):
"home" - Section index:
"<section>_index"(e.g."posts_index") - Section page: singularized section (e.g.
"post") - Root page:
"page"
Template fallback chain (in get_template): for content pages, post → page → base; for section indexes, posts_index → section_index → page → base. Fallbacks logged at debug level. Frontmatter layout field overrides the inferred value.
Config system
Config is split into three structs with a clear 5-step initialization flow:
Flags— CLI args only. Parsed bycore:flags. Includes path overrides (--content,--assets,--output,--layouts), build-mode toggles (-drafts,-watch,-minify), and-ext/-no-extfor markdown extension overrides.Config_File— parsed fromthor.jsonviajson.unmarshal_string. Holds title, paths,markdown_extensions(JSON),params(JSON),modules(JSON array of relative paths),og(Open_Graphstruct for site-level OG defaults).Site— runtime state: arena, pages, modules, VFS,features: bit_set[Feature],markdown_extensions: bit_set[md.Extension],og: Open_Graph(resolved site-level OG).
Feature enum — Drafts, Minify, Watch. Checked with .Minify in site.features.
markdown.Extension enum (in the markdown package, not main) — Emoji, Sidenotes, Alerts, Highlight, Sections. Default is md.DEFAULT_EXTENSIONS (currently .Emoji, .Sidenotes, .Alerts). Configurable via:
thor.json:"markdown_extensions": { "emoji": true, "highlight": false, ... }- CLI:
-ext:highlight,sections(enable) /-no-ext:emoji(disable). Comma-separated, case-insensitive.
find_config — walks up from CWD looking for thor.json. Falls back to ./thor.json.
Config precedence: CLI flags > thor.json values > hardcoded defaults.
{
"title": "...",
"base_url": "...",
"modules": ["../path/to/module"],
"og": {
"image": "https://example.com/og.png"
},
"date": {
"format": "2 Jan 2006",
"timezone": "America/New_York"
},
"grammars": "~/.config/helix/runtime/grammars/",
"queries": "/path/to/tree-sitter/queries",
"markdown_extensions": { "emoji": true, "highlight": false },
"params": {
"social": [
{ "name": "github", "url": "...", "icon": "icons/github" }
]
}
}
VFS (Union File System)
Layered directory resolution for templates and assets: site layouts/ → module layouts/ → defaults/layouts/.
VFS :: struct { files: map[string]VFS_Entry }
VFS_Entry :: struct { fs_path: string, data: []byte }
build_vfs mounts in reverse precedence (defaults first, site last overwrites). DEFAULTS_PATH resolved at compile time via #directory, so bundled templates ship inside the binary. Modules configured via "modules": ["../path"] in thor.json — each module contributes layouts/ and assets/ subdirectories.
Three access patterns:
vfs_get(vfs, path) -> ([]byte, bool)— data only (lazy-loaded from disk)vfs_get_entry(vfs, path) -> (VFS_Entry, []byte, bool)— entry + data (for callers that needfs_pathfor diagnostics)vfs_entry_data(entry) -> ([]byte, bool)— data from an entry already in hand (avoids redundant map lookup when iteratingvfs.files)
Content is not yet in the VFS — scan_content_files still uses direct filesystem reads. (See TODOS.md.)
Open Graph
Open_Graph struct in opengraph.odin with fields ordered per ogp.me spec. is_article is Maybe(bool) — nil means "unset" (distinguished from explicitly false).
Site-level (og_for_site): starts from Config_File.og (user-supplied defaults from thor.json), then fills empty fields derivable from Site:
site_name ← site.titlelocale ← "en_US"(default if unset)
Page-level (og_for_page): copies site OG, derives page-specific fields, then overlays Page.og (from frontmatter):
url ← page.urltitle ← page.title(falls back tosite_nameif empty)type ← "article" if !page._is_index else "website"is_article ← !page._is_indexsection ← page.sectionpublished_time / modified_time ← page.date / page.lastmoddescription ← page.description, elsegenerate_description(generate_summary(body_html))(scrubbed plain text)
Paths through maps (e.g. params.*) are silently allowed — not validated. Templates access via {{og.url}}, {{og.title}}, {{#og.is_article}}, etc.
Markdown pipeline
Lives in the markdown package. Entry point: md.process(body, ext, file_path). All .html content files skip the pipeline entirely — body is used as-is.
raw markdown
→ md.strip_definitions (if .Sidenotes — pre-cmark)
→ cmark markdown_to_html (Unsafe mode for HTML passthrough)
→ md.expand_emoji (if .Emoji — post-cmark)
→ md.inject_notes (if .Sidenotes — post-cmark)
→ md.inject_alerts (if .Alerts — post-cmark)
→ md.highlight_code (if .Highlight — post-cmark)
→ md.wrap_sections (if .Sections — post-cmark)
Each step is gated by bit_set[md.Extension].
Template system
Templates use Mustache with template inheritance ({{<base}} / {{$block}}):
<!-- base.html -->
<body>{{> nav}}{{$main}}{{/main}}{{> footer}}</body>
<!-- page.html (content layout) -->
{{<base}}
{{$main}}
<main><article><h1>{{page_title}}</h1>{{&content}}</article></main>
{{/main}}
{{/base}}
Data is passed as typed structs (not map[string]any). Mustache resolves struct fields via Odin reflection, including using-embedded fields. Date presence is checked via string truthiness ({{#date}}) — no separate has_date bool needed. Dates are stored as raw ISO strings; presentation formatting happens in the template via the format pipe (see Pipes extension below).
Base_Data :: struct {
now: string, // UTC ISO 8601 build timestamp
params: json.Value,
content: string,
title: string,
description: string,
og: Open_Graph,
date_format: string, // from site.date.format (thor.json)
timezone: ^datetime.TZ_Region, // loaded from site.date.timezone or local, owned by Site
}
Page_Data :: struct {
using base: Base_Data, // fields promoted via reflection fallback
page_title: string,
date: string, // raw ISO 8601; formatted via `| format` in templates
}
Home_Data :: struct {
using base: Base_Data,
pages: [dynamic]Page_Context,
}
Section_Data :: struct {
using base: Base_Data,
page_title: string,
posts: [dynamic]Page_Context, // flat list; year grouping done in template via pipe
}
render_site pre-parses all partials and the base layout once (via mustache.parse), then per-layout templates are cached in get_template. Year-based grouping on section index pages is done in the template via {{#posts | group_by year}} (see Pipes extension below) — there is no Year_Section Go-side struct.
Pipes extension
Section tags and interpolation tags may transform the resolved value before rendering:
{{#posts | group_by year}}
{{key}}: {{#items}}{{title}}, {{/items}}
{{/posts}}
<!-- Interpolation pipe: format a date for display -->
<time datetime="{{date}}">{{date | format}}</time>
Currently implemented: group_by <field> (list → list-of-groups) and format (ISO date string → display string like "15 Mar 2026"). The format pipe resolves date_format (string) and timezone (^datetime.TZ_Region) from the data context. When timezone is non-nil, dates are DST-aware converted before formatting. The MST token reflects the active timezone abbreviation (e.g. "EST"/"EDT") or the source offset (e.g. "UTC-04:00") when no target tz is configured. TZ data is loaded once by init_site via timezone.region_load using the site arena allocator, stored on Site.tz, and freed when the arena is destroyed. Filter results live in context.temp_allocator (render-scoped). See mustache/EXTENSIONS.md for syntax details, caps (MAX_PIPES, MAX_PIPE_ARGS), and the Group struct shape.
Comments
page.html includes {{> comments}}. The comments.html partial self-guards with {{#og.is_article}} so it only renders on article pages — no separate is_post flag.
Syntax highlighting
Build-time highlighting via Tree-sitter C FFI. No client-side JavaScript.
- HTML and CSS grammars statically linked via Nix (
mkGrammarStaticLibinthor/flake.nix). Always available, nodlopen. - Other grammars (bash, odin, nu, etc.) loaded via
dlopenfrom.sofiles. Pre-scanned from content code fences and loaded in parallel viapreload_grammars(one thread per language,sync.MutexonGrammar_Store.cache).Grammar_Store.allocatoris the OS heap (set byinit_persistentbefore arena override) so grammars persist across watch-mode rebuilds. - Grammar and query paths configured via
thor.json(grammars,queries). Flow:thor.json→Config_File→Site→main.odinsetstreesitter.grammar_dir/treesitter.query_dir. Tilde (~/) expanded byexpand_pathinsite.odin. Paths logged at startup. - Grammar loading split:
ensure_parser(parser only, used by minify) vsload_grammar(parser + query, used by highlight). - Capture names mapped to CSS classes:
keyword→.hl-keyword, etc. - Atom-one-dark color theme in
main.css.
Minification
Optional, enabled with -minify flag (.Minify in Feature bit_set).
- HTML — tree-sitter parses output, strips comments, removes inter-tag whitespace, preserves
<pre>/<code>/<textarea>/<script>/<style>content. Applied after template rendering. - CSS — tree-sitter parses
.cssfiles inassets/, strips comments, collapses whitespace, trims around{};:,. Applied duringcopy_assets_dir. - Non-CSS files in
assets/copied verbatim.
Memory management
Siteowns amem.Dynamic_Arenainit_sitecallsmem.dynamic_arena_init(&site.arena)(Odin's default alignment suffices)- 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 droppedcontext.allocatoris set tosite_allocator(&site)in the main loopcontext.temp_allocatorfreed per watch-loop iteration viadefer free_all
Spall profiling
Optional, compiled out by default. Enabled with -define:SPALL=true:
odin build . -define:SPALL=true -o:speed -out:thor-prof
./thor-prof -drafts # generates thor.spall
Uses core:prof/spall with @(instrumentation_enter)/@(instrumentation_exit) hooks — every function auto-instrumented, no manual annotation needed.
Building
Local development
nix develop
# From blog root:
odin run ./thor -- -drafts
# Assets (CSS/JS/fonts) are copied/minified automatically by thor
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, outputs to ./result/
Tests
cd thor
odin test . # main package tests (site, frontmatter)
odin test . -all-packages # includes mustache specs, lambdas, pipes, diagnostics, markdown tests
Benchmark
cd thor
odin build bench -o:speed
./bench.bin # 250 iterations, prints timing
./bench.bin --dump output.html # render once, write to file for diff validation
./bench.bin 1000 # custom iteration count
Mustache engine
Spec-compliant implementation at mustache/. See mustache/SPEC.md for the implementation specification, mustache/EXTENSIONS.md for non-standard extensions (pipes), and mustache/diagnostic.odin for the rust-style error formatter.
Files
| File | Responsibility |
|---|---|
mustache.odin |
Public API (parse, render, Template), parser (parse_section), renderer (render_nodes with Indent_State for partial indentation), template inheritance (merge_block_overrides), delete_template/delete_partials. Pipe support in Variable/Unescaped/Section/Inverted tags. |
tokenizer.odin |
Tokenizer (template string → []Token), standalone whitespace detection |
data.odin |
Reflection-based data model: base_value (peels union/any/nested-any layers), lookup_in (structs + maps, handles Type_Info_Any value kind in maps), resolve_name, is_truthy, any_to_string, list_info, extract_list_element, call_interp_lambda/call_section_lambda |
pipes.odin |
Pipes extension: Pipe_Filter AST, parse_pipeline (takes pos), apply_pipeline, apply_filter (switch dispatch: group_by + format), apply_group_by, apply_format. Stored on Node.filters; render-scoped results in temp allocator. |
diagnostic.odin |
Rust-style error formatter: format_error (multi-line context, ANSI colors via core:terminal/ansi, colorize param), format_render_error (formats Error), line_col, line_text, context_extent, count_lines, digit_count, should_colorize. |
suggest.odin |
Strict-warning helpers: validate_key_path (walks dotted path, crosses maps silently), suggest_correction (Levenshtein via core:strings/levenshtein_distance), collect_struct_keys (via reflection, recurses into using), struct_has_field (distinguishes missing field from nil value — needed for Maybe(bool)), collect_partial_names, collect_block_names. |
spec_test.odin |
JSON spec test runner — loads spec/specs/*.json, runs each test case. Uses log.nil_logger() to suppress expected warnings. |
lambda_test.odin |
Spec lambda tests |
pipes_test.odin |
Pipe filter tests (group_by + format) |
diagnostic_test.odin |
Golden-output tests for format_error (multi-line context, edge cases, alignment, caret position, hint) + parser error message brace-escaping |
suggest_test.odin |
Tests for validate_key_path, suggest_correction, struct_has_field with Maybe(bool) and using-promoted fields |
Architecture
parse(source, path) → tokenize → trim_standalone_whitespace → parse_section → Template
render(tmpl, data, partials) → render_nodes (walks flat node array against context stack) → string
- Two-phase API:
parse()produces a reusableTemplate,render()walks it against data. Templates parsed once, rendered many times. - Flat
[dynamic]Nodearray withchildren: []Nodeslices (pre-order layout; slices point into the backing array). EachNodecarriespos: int(byte offset into source) for diagnostics. Templatecarriessourceandpath— used by diagnostics to show file location and source context.- Context stack:
^[dynamic]anywithappend/popfor section push/pop. render_nodestakesTemplateby value (not^Template) — Odin's calling convention promotes to pointer when efficient. Eliminates "local copy" patterns at call sites.Block_Override.source: Template— carries the template that defined the override, so warnings inside block overrides point at the correct file.base_valuepeels Named/Distinct/Union layers (includingjson.Value). Also unwraps nestedany-of-any(which occurs whenmap[string]anyvalues are read via runtime map internals).lookup_inresolves keys on structs (viareflect.struct_field_value_by_namewithallow_using = true) and maps. DetectsType_Info_Anyvalue kind in maps and reads the inner any directly to avoid double-wrap.- Template inheritance:
{{<parent}}loads parent from partials,{{$block}}defines overridable sections.merge_block_overridespropagates overrides through multi-level chains. - Dynamic partial names:
{{>*key}}resolves partial name from data context at render time. - Render-time partial indentation:
Indent_Statethreadsat_line_startthroughrender_nodesso partial indent is applied at render time (viawrite_indentedon Text nodes) instead of reparsing the partial's source.render_templatewrites initial indent, creates state, callsrender_nodes. Data-injected newlines don't pick up indent (Variables don't updateat_line_start).
Diagnostics
Rust-style error messages with multi-line source context, caret underlines, and Levenshtein suggestions. ANSI colors via core:terminal/ansi, gated on should_colorize() (TTY detection on stderr).
Error types: Error_Body{msg, pos, kind} where kind is Error_Kind.Syntax (parse-time) or Error_Kind.Data (render-time). Error is a single-variant union wrapping Error_Body (nilable for != nil / or_return).
Strict-by-default warnings — render_nodes emits log.warnf diagnostics for:
- Unknown keys in
{{k}},{{{k}}},{{#k}},{{^k}}(viavalidate_key_path+suggest_correction) - Missing partials (
{{> name}}not in partials map) - Missing parent templates (
{{<name}}not in partials map) - Unmatched block overrides (
{{$name}}doesn't match any block in parent template)
Exceptions (no warning):
{{.}}and dot-prefixed names (current context)- Paths that cross a map (e.g.,
params.*— user-defined namespace) Maybe(bool)fields with nil value (field exists, value is nil — distinguished viastruct_has_field)
Block override source tracking: Block_Override.source: Template ensures warnings inside block overrides point at the override's source file (e.g., page.html), not the parent template (base.html).
Lambdas
Spec-compliant. Stored as any values in the data context.
- Interpolation lambdas:
proc() -> string,proc() -> int,proc() -> bool— called viacall_interp_lambda, result stringified and escaped. - Section lambdas:
proc(string) -> string,proc(string) -> int,proc(string) -> bool— called viacall_section_lambdawith the raw section text (node.content). String result is re-parsed as mustache and rendered against the current context stack.
Pipes
{{key | op args…}} for interpolation, {{#key | op args…}}…{{/key}} for sections. Stored as [dynamic; MAX_PIPES]Pipe_Filter on each Node. Applied in the renderer via apply_pipeline before truthiness/interpolation. Implemented filters:
group_by <field>— list →[dynamic]GroupwhereGroup{key, items}format— ISO 8601 date string → display string (e.g., "15 Mar 2026")
See mustache/EXTENSIONS.md.
Not implemented
- Set delimiters (
{{= =}},delimiters.json) - Partial invocation stack in diagnostics (warnings inside partials point at the partial file but don't show the
{{> name}}invocation site — see TODOS.md)
Known limitations
- cmark allocates via C malloc, not the arena. HTML output leaks until process exit (problematic in watch mode — see
TODOS.md). - CSS/JS cache busting uses manual
?v=Nquery params instead of content hashing. - Tree-sitter grammar/query paths must be configured manually via
thor.json(grammars,queries) — no auto-discovery. HTML/CSS are statically linked. map[string]anyonly works throughlookup_in's special-case handling; thor otherwise uses structs.format_f64in mustache brute-forces shortest float representation.- Content directory not mounted in VFS (modules can ship templates/assets but not content packs yet).
Design decisions
You may never, ever remove TODO: or FIXME: comments. Those are for humans, not machines.
See HUGO.md for analysis of why thor doesn't need Hugo's shortcode context isolation.
See mustache/SPEC.md for the original implementation specification.
See mustache/EXTENSIONS.md for non-standard extensions (pipes).
TODO
See TODOS.md for the full list.