# 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`. ## 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 `

` into `
` wrappers | | | `highlight.odin` | Syntax highlighting via tree-sitter. Imports `../treesitter`. | | `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) → render_site → load_partials + get_template (VFS + fallback chain) → render_page_html / render_home_html / render_section → 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, 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: `"
_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 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 }, "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 **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). ```odin 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: ```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 `
`/``/`