# 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) 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 ├── content.odin # Page struct, scan_content, 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 ├── opengraph.odin # Open_Graph struct + og_init/og_for_page ├── frontmatter.odin # JSON frontmatter parser ├── defaults.odin # DEFAULTS_PATH constant (#directory) ├── main.odin # Entry point └── defaults/layouts/ # Bundled default templates ``` ### Source files (main package) | File | Responsibility | |---|---| | `main.odin` | Entry point. Sets `context.logger`, calls `init_site`, `build_vfs`, `site_load_content`, `render_site`. Optional Spall profiling via `SPALL` config flag. | | `site.odin` | `Flags` (CLI), `Config_File` (thor.json), `Site` (runtime state + arena + VFS + pages + modules). `Feature` enum. 5-step `init_site`. Imports `md "markdown"` for `Extension` enum. | | `content.odin` | `Page` struct, `scan_content` (section-aware walk that handles leaf bundles), `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`. Layers defaults → modules → site. | | `assets.odin` | `copy_assets_dir` — iterates VFS entries with `assets/` prefix, minifies CSS, copies verbatim or via `os.copy_file`. | | `opengraph.odin` | `Open_Graph` struct (fields ordered per OGP spec). `og_init(site)` for site defaults, `og_for_page(site, page, base)` for page-specific OG data. | | `frontmatter.odin` | JSON frontmatter parser (`{ }` delimited). Supports `layout` field for template override. | | `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 (`ensure_parser`, `load_grammar`, `grammar_cache`), 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 | 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 + 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, menu: string, body_html: string, draft: bool, is_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. ## 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). - **`Site`** — runtime state: arena, pages, modules, VFS, `features: bit_set[Feature]`, `markdown_extensions: bit_set[md.Extension]`. **`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`. ```json { "title": "...", "base_url": "...", "modules": ["../path/to/module"], "markdown_extensions": { "emoji": true, "sidenotes": true, "alerts": true, "highlight": true, "sections": true }, "params": { "author": "...", "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. `vfs_get` lazily reads file contents on first access. Content is **not yet in the VFS** — `scan_content` 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. Site defaults set via `og_init(site)` (site_name, description, default image, locale). Page-specific fields via `og_for_page(site, page, base)` (copies base, overrides url/title/type/is_article/section/published_time). 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 (`{{ {{> nav}}{{$content}}{{/content}}{{> footer}} {{

{{page_title}}

{{&body}}
{{/content}} {{/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_iso}}`) — no separate `has_date` bool needed. ```odin Base_Data :: struct { now: datetime.DateTime, params: json.Value, body: string, title: string, og: Open_Graph, } Page_Data :: struct { using base: Base_Data, // fields promoted via reflection fallback page_title: string, date_iso: string, date_display: string, } 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 may transform the resolved value before iteration: ```handlebars {{#posts | group_by year}} {{key}}: {{#items}}{{title}}, {{/items}} {{/posts}} ``` Currently only `group_by ` is implemented. 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 Helix's compiled `.so` files. - Highlight queries (`.scm`) loaded from Helix's runtime directory. - Paths hardcoded in `treesitter/treesitter.odin` (`GRAPHS_PATH`, `QUERIES_PATH`) — Nix store paths, Helix-version-dependent. (See `TODOS.md`.) - 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 `
`/``/`