# Thor β€” Odin Static Site Generator Thor is a static site generator written in [Odin](https://odin-lang.org), replacing Hugo for the `sbrow.github.io` blog. It lives at `./thor/` as a git subtree with its own `flake.nix`. ## 🚫 DO NOT EDIT THE DOCS β€” BY HUMANS, FOR HUMANS > **THE DOCUMENTATION UNDER `thor/site/` IS HANDWRITTEN, BY HUMANS, FOR HUMANS.** > > **NO AI, AGENT, BOT, ASSISTANT, OR OTHER NON-HUMAN MAY EDIT, REWRITE, > REPHRASE, REFORMAT, "IMPROVE," SUMMARIZE, OR GENERATE ANY FILE UNDER > `thor/site/` β€” EVER.** > > These are not machine artifacts. A human wrote every word. AI may be > consulted as a sanity check, but the prose stays human. If you are not a > human, do not touch these files. See `thor/site/content/ai.md`. ## 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, Template_Context, sort_pages, RSS, sitemap β”œβ”€β”€ menus.odin # Menu_Entry, DEFAULT_WEIGHT, build_menus, collect_auto_menus, merge_page_menus, parse_page_menus, parse_config_menus β”œβ”€β”€ site.odin # Config (Flags, Config_File, Site, Site_Context), 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, generate_description β”œβ”€β”€ opengraph.odin # Open_Graph struct + og_for_site/og_for_page β”œβ”€β”€ frontmatter.odin # JSON frontmatter parser (supports nested og + lastmod + weight + menus) β”œβ”€β”€ 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), `Site_Context` (template-facing: `title`, `description`, `base_url`, `params`, `og`, `menus`), `Site` (runtime state + arena + VFS + pages + `og`). `Feature` enum. 5-step `init_site`. Config menu parsing in `site_apply_config`. | | `content.odin` | `Page` struct (includes `weight`, `menus: map[string]Menu_Entry`, `og`), `Pending_File` struct, `scan_content_files` (section-aware walk that handles leaf bundles), `collect_languages` (pre-scan for code fence languages), `load_page` (falls back to file mtime when no frontmatter date), `infer_layout`. Calls `md.process()` for the markdown pipeline. | | `render.odin` | Template rendering: `render_site`, `render_page_html`, `render_home_html`, `render_section`. `Template_Context` (unified render struct with `site: Site_Context`, `page: Page`, `menus`, `posts`, `pages`). 3-frame context stack via `[]any{ctx.site, ctx.page, ctx}`. `sort_pages` (weight primary, date secondary). `to_title_case` for section display names. VFS-based template loading with fallback chain (`get_template`). | | `menus.odin` | Menu system: `Menu_Entry {name, url, weight: Maybe(int)}`, `DEFAULT_WEIGHT = 10`. `build_menus` (priority chain: config β†’ auto + page frontmatter, then `warn_all_duplicate_weights`). `collect_auto_menus` (sections + root-level pages, skips pages with explicit `"menus": "main"` frontmatter). `merge_page_menus` (frontmatter entries with effective weight fallback via nil check). `parse_page_menus` (string/array/object forms). `parse_config_menus` (from thor.json). `sort_menu_entries` / `compare_menu_entries` (weight primary via `.? or_else DEFAULT_WEIGHT`, name secondary). `warn_duplicate_weights` / `warn_all_duplicate_weights` (log when two entries in same menu have same explicitly-set weight). | | `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). Description falls back to `generate_description(generate_summary(body_html))`. | | `frontmatter.odin` | JSON frontmatter parser (`{ }` delimited). Supports `layout`, `lastmod`, `weight: Maybe(int)`, `menus`, and nested `og` object (via `json_get_open_graph`). Helpers: `json_get_string`, `json_get_bool`, `json_get_int` (returns `Maybe(int)`, nil for absent/invalid). | | `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 `

` into `
` wrappers | | | `highlight.odin` | Syntax highlighting via tree-sitter. Imports `../treesitter`. | | | `heading_ids.odin` | `inject_heading_ids` β€” adds `id` attributes to `

`-`

` from heading text. Slug-based, deduplicated. | | `mustache/` | See [Mustache engine](#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 ` 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 + build_menus + warn_all_duplicate_weights) β†’ render_site β†’ load_partials + get_template (VFS + fallback chain) β†’ render_page_html / render_home_html / render_section (3-frame context stack: site, page, ctx) β†’ optional minify_html β†’ public/ ``` ## Page struct ```odin 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, year: string, weight: Maybe(int), // page ordering (nil = unset, defaults to DEFAULT_WEIGHT at comparison time) lastmod: string, menus: map[string]Menu_Entry, // frontmatter menu assignments content: string, // rendered HTML body og: Open_Graph, draft: bool, starred: bool, _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: `"
_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. ## Menus Menu system in `menus.odin`. `Menu_Entry :: struct {name: string, url: string, weight: int}`. `DEFAULT_WEIGHT = 10`. ### Sources (priority chain, no mixing) 1. **Config menus** (`"menus"` key in `thor.json`) β€” exclusive. `"menus": {}` = explicit opt-out (no menus). Config entries sorted by weight. 2. **Auto-menus + page frontmatter** β€” always run together when no config menus: - Auto: one entry per section directory + one per root-level non-index page. Alphabetical. - Page frontmatter: `"menus": "main"` (string), `["main", "footer"]` (array), or `{"main": {"weight": 30}}` (object with per-menu weight). Merged with auto entries, sorted by weight. ### Weight All weight fields use `Maybe(int)` β€” nil means "unset," `some(v)` means explicitly set. This distinguishes `"weight": 10` (explicit) from no weight key (defaults to `DEFAULT_WEIGHT` at comparison time via `.? or_else DEFAULT_WEIGHT`). Eliminates the old `0`-as-sentinel pattern from `json_get_int`. - `Page.weight: Maybe(int)` β€” page-level ordering. nil = unset. Affects `sort_pages` (weight primary, date secondary). - `Menu_Entry.weight: Maybe(int)` β€” per-menu ordering. nil for auto-generated entries and string/array frontmatter forms. Explicit value from object frontmatter form `{"weight": N}`. - Effective weight in `merge_page_menus`: per-menu weight if set, else falls back to `page.weight`. Both `Maybe(int)`, so nil propagates naturally β€” no value-based sentinel check. - Sorted ascending via `.? or_else DEFAULT_WEIGHT`, name alphabetical for ties. ### Templates ```html {{#menus.main}}
  • {{name}}
  • {{/menus.main}} ``` `Template_Context.menus` resolves above `Page.menus` (frontmatter assignments) on the 3-frame context stack. Accessible as `{{#menus.main}}` or `{{#site.menus.main}}`. ### Duplicate weight warnings `warn_duplicate_weights` (called from `build_menus` after all menus are sorted) logs a warning when two entries in the same menu have the same explicitly-set weight. Only non-nil weights are checked β€” nil (unset/default) entries are never flagged, so auto-generated entries don't produce noise. The warning includes the menu name, weight value, and both entry names. ## Config system Config is split into three structs with a clear 5-step initialization flow: - **`Flags`** β€” CLI args only. Parsed by `core:flags`. Includes path overrides (`--content`, `--assets`, `--output`, `--layouts`), build-mode toggles (`-drafts`, `-watch`, `-minify`), and `-ext`/`-no-ext` for markdown extension overrides. - **`Config_File`** β€” parsed from `thor.json` via `json.unmarshal_string`. Holds title, paths, `markdown_extensions` (JSON), `params` (JSON), `modules` (JSON array of relative paths), `og` (`Open_Graph` struct 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`, `HeadingIDs`. Default is `md.DEFAULT_EXTENSIONS` (currently `.Emoji, .Sidenotes, .Alerts, .HeadingIDs`). 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`. ```json { "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 }, "menus": { "main": [ {"name": "Home", "url": "/", "weight": 1}, {"name": "About", "url": "/about/"} ] }, "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/`. ```odin 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 need `fs_path` for diagnostics) - `vfs_entry_data(entry) -> ([]byte, bool)` β€” data from an entry already in hand (avoids redundant map lookup when iterating `vfs.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](https://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.title` - `locale ← "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.url` - `title ← page.title` (falls back to `site_name` if empty) - `type ← "article" if !page._is_index else "website"` - `is_article ← !page._is_index` - `section ← page.section` - `published_time / modified_time ← page.date / page.lastmod` - `description ← page.description`, else `generate_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.inject_heading_ids (if .HeadingIDs β€” post-cmark, pre-sections) β†’ md.wrap_sections (if .Sections β€” post-cmark) ``` Each step is gated by `bit_set[md.Extension]`. ## Template system Templates use Mustache with template inheritance (`{{ {{> nav}}{{$main}}{{/main}}{{> footer}} {{

    {{page.title}}

    {{&content}}
    {{/main}} {{/base}} ``` Data is passed as a single `Template_Context` struct. `render_template` passes a 3-frame context stack `[]any{ctx.site, ctx.page, ctx}` to `mustache.render`, which auto-detects `[]any` and expands each element into a stack frame. Name resolution walks top-to-bottom: `Template_Context` β†’ `Page` β†’ `Site_Context`. Fields not found on the top frame fall through to lower frames. ```odin Template_Context :: struct { site: Site_Context, // site-level data (title, description, base_url, params, og) menus: map[string][]Menu_Entry, // generated menu data (copied from site, resolves above Page.menus) now: string, // UTC ISO 8601 build timestamp title: string, // computed browser title ("Page | Site") date_format: string, // from site.date.format (thor.json) timezone: ^datetime.TZ_Region, // for format pipe og: Open_Graph, // computed per-page OG page: Page, // current page pages: [dynamic]Page, // home page list posts: [dynamic]Page, // section post list } ``` `Site_Context` is embedded in `Site` via `using site_context`. Fields like `site.title`, `site.menus`, `site.params` are accessed directly on `Site` through promotion. `Template_Context.menus` is copied from `site.menus` to resolve above `Page.menus` (frontmatter assignments) on the context stack. `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). ### Pipes extension Section tags and interpolation tags may transform the resolved value before rendering: ```handlebars {{#posts | group_by year}} {{key}}: {{#items}}{{title}}, {{/items}} {{/posts}} ``` Currently implemented: `group_by ` (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 (`mkGrammarStaticLib` in `thor/flake.nix`). Always available, no `dlopen`. - **Other grammars** (bash, odin, nu, etc.) loaded via `dlopen` from `.so` files. Pre-scanned from content code fences and loaded in parallel via `preload_grammars` (one thread per language, `sync.Mutex` on `Grammar_Store.cache`). `Grammar_Store.allocator` is the OS heap (set by `init_persistent` before 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.odin` sets `treesitter.grammar_dir`/`treesitter.query_dir`. Tilde (`~/`) expanded by `expand_path` in `site.odin`. Paths logged at startup. - Grammar loading split: `ensure_parser` (parser only, used by minify) vs `load_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 `
    `/``/`