Compare commits

...

18 Commits

Author SHA1 Message Date
Spencer Brower 0307ab647f fix: Fixed a bug with the weight checking. 2026-07-30 14:34:27 -04:00
Spencer Brower 9e19b0dc2c build(nix): Updated prettier config. 2026-07-30 13:51:20 -04:00
Spencer Brower 6d97a23568 chore: Removed completed todos. 2026-07-30 13:20:58 -04:00
Spencer Brower 89c2ffbf78 style: Cleaned up #partial switches. 2026-07-30 11:12:31 -04:00
Spencer Brower 4659aa8dc2 feat: Added duplicate weights warning. 2026-07-30 11:07:31 -04:00
Spencer Brower d03e3469b3 refactor: weights are now wrapped in a Maybe. 2026-07-30 09:58:53 -04:00
Spencer Brower df14d76a85 feat: Added weight field to Page. 2026-07-29 15:20:35 -04:00
Spencer Brower c48afafd8a feat: Menu items get sorted by weight. 2026-07-29 15:15:02 -04:00
Spencer Brower 92d7b7525a refactor: Page.menu: string is now `Page.menus: map[string]Menu_Entry 2026-07-29 15:15:02 -04:00
Spencer Brower b54eb76495 nix: Fixed broken package build. 2026-07-29 15:15:02 -04:00
Spencer Brower 137b691a71 chore: Added PROBLEMS.md 2026-07-29 15:15:02 -04:00
Spencer Brower 06e411a329 chore: Updated TODOS.md 2026-07-29 15:15:02 -04:00
Spencer Brower 2299dcd652 style: Ran typos. 2026-07-29 15:15:01 -04:00
Spencer Brower 40bd2ad810 style: Ran nixpkgs-fmt. 2026-07-29 15:15:01 -04:00
Spencer Brower eabb8a1430 style: ran deadnix. 2026-07-29 15:15:01 -04:00
Spencer Brower c634e2e718 style: Ran odinfmt. 2026-07-29 15:15:01 -04:00
Spencer Brower f839ec0f04 build(nix): Updated all flake inputs. 2026-07-29 15:15:01 -04:00
Spencer Brower df0ed5a8f1 feat: Added menu configuration. 2026-07-29 15:15:01 -04:00
59 changed files with 1733 additions and 299 deletions
+87 -38
View File
@@ -21,15 +21,16 @@ thor/
├── markdown/ # Content transformation pipeline (imports ../treesitter) ├── markdown/ # Content transformation pipeline (imports ../treesitter)
├── mustache/ # Template engine with lambdas + pipe filters + diagnostics ├── mustache/ # Template engine with lambdas + pipe filters + diagnostics
├── content.odin # Page struct, Pending_File, scan_content_files, collect_languages, load_page ├── content.odin # Page struct, Pending_File, scan_content_files, collect_languages, load_page
├── render.odin # Template rendering, data structs, RSS, sitemap ├── render.odin # Template rendering, Template_Context, sort_pages, RSS, sitemap
├── site.odin # Config (Flags, Config_File, Site), init_site ├── 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) ├── minify.odin # HTML/CSS minification (imports treesitter)
├── feed.odin # RSS + sitemap generation ├── feed.odin # RSS + sitemap generation
├── vfs.odin # Union file system (defaults → modules → site) ├── vfs.odin # Union file system (defaults → modules → site)
├── assets.odin # VFS-based asset copying ├── 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) ├── html.odin # HTML helpers: strip_html_tags, unescape_html, generate_summary, generate_description
├── opengraph.odin # Open_Graph struct + og_for_site/og_for_page ├── opengraph.odin # Open_Graph struct + og_for_site/og_for_page
├── frontmatter.odin # JSON frontmatter parser (supports nested og + lastmod) ├── frontmatter.odin # JSON frontmatter parser (supports nested og + lastmod + weight + menus)
├── defaults.odin # DEFAULTS_PATH constant (#directory) ├── defaults.odin # DEFAULTS_PATH constant (#directory)
├── main.odin # Entry point ├── main.odin # Entry point
├── bench/ # Template rendering benchmark ├── bench/ # Template rendering benchmark
@@ -41,16 +42,17 @@ thor/
| File | Responsibility | | 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. | | `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. | | `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 `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. | | `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`. Data structs (`Base_Data`, `Page_Data`, `Home_Data`, `Section_Data`). VFS-based template loading with fallback chain (`get_template`). | | `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"`. | | `minify.odin` | HTML/CSS minification via tree-sitter. Imports `ts "treesitter"`. |
| `feed.odin` | RSS feed + sitemap XML. Uses `page.url` for canonical URLs. | | `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. | | `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`. | | `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). | | `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). | | `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`, and nested `og` object (via `json_get_open_graph`). | | `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. | | `defaults.odin` | `DEFAULTS_PATH` constant, resolved at compile time via `#directory` so bundled templates ship in the binary. |
### Subpackages ### Subpackages
@@ -64,6 +66,7 @@ thor/
| | `emoji.odin` | `expand_emoji``:shortcode:` → unicode emoji | | | `emoji.odin` | `expand_emoji``:shortcode:` → unicode emoji |
| | `sectionate.odin` | `wrap_sections` — splits HTML at `<h2>` into `<section>` wrappers | | | `sectionate.odin` | `wrap_sections` — splits HTML at `<h2>` into `<section>` wrappers |
| | `highlight.odin` | Syntax highlighting via tree-sitter. Imports `../treesitter`. | | | `highlight.odin` | Syntax highlighting via tree-sitter. Imports `../treesitter`. |
| | `heading_ids.odin` | `inject_heading_ids` — adds `id` attributes to `<h1>`-`<h6>` from heading text. Slug-based, deduplicated. |
| `mustache/` | See [Mustache engine](#mustache-engine) below | Template engine | | `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 <path>` for output validation, positional arg for iteration count (default 250). | | `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). |
@@ -74,10 +77,10 @@ Icon SVGs live as HTML partials in `layouts/partials/icons/` (home, github, rss,
``` ```
thor.json → find_config → init_site (5-step) thor.json → find_config → init_site (5-step)
→ build_vfs (defaults/layouts → modules → site/layouts, site/assets) → build_vfs (defaults/layouts → modules → site/layouts, site/assets)
→ site_load_content (scan_content_files + collect_languages + preload_grammars + load_page + url computation) → site_load_content (scan_content_files + collect_languages + preload_grammars + load_page + url computation + build_menus + warn_all_duplicate_weights)
→ render_site → render_site
→ load_partials + get_template (VFS + fallback chain) → load_partials + get_template (VFS + fallback chain)
→ render_page_html / render_home_html / render_section → render_page_html / render_home_html / render_section (3-frame context stack: site, page, ctx)
→ optional minify_html → optional minify_html
→ public/ → public/
``` ```
@@ -94,12 +97,14 @@ Page :: struct {
title: string, title: string,
description: string, description: string,
date: string, date: string,
year: string,
weight: Maybe(int), // page ordering (nil = unset, defaults to DEFAULT_WEIGHT at comparison time)
lastmod: string, lastmod: string,
menu: string, menus: map[string]Menu_Entry, // frontmatter menu assignments
body_html: string, content: string, // rendered HTML body
og: Open_Graph,
draft: bool, draft: bool,
is_starred: bool, starred: bool,
og: Open_Graph, // per-page OG overrides from frontmatter
_is_index: bool `private`, _is_index: bool `private`,
} }
``` ```
@@ -113,6 +118,40 @@ No `Page_Type` enum — page type is inferred from section + `_is_index`. Layout
**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. **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}}
<li><a href="{{url}}">{{name}}</a></li>
{{/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 system
Config is split into three structs with a clear 5-step initialization flow: Config is split into three structs with a clear 5-step initialization flow:
@@ -146,6 +185,12 @@ Config precedence: `CLI flags > thor.json values > hardcoded defaults`.
"grammars": "~/.config/helix/runtime/grammars/", "grammars": "~/.config/helix/runtime/grammars/",
"queries": "/path/to/tree-sitter/queries", "queries": "/path/to/tree-sitter/queries",
"markdown_extensions": { "emoji": true, "highlight": false }, "markdown_extensions": { "emoji": true, "highlight": false },
"menus": {
"main": [
{"name": "Home", "url": "/", "weight": 1},
{"name": "About", "url": "/about/"}
]
},
"params": { "params": {
"social": [ "social": [
{ "name": "github", "url": "...", "icon": "icons/github" } { "name": "github", "url": "...", "icon": "icons/github" }
@@ -225,36 +270,26 @@ Templates use Mustache with template inheritance (`{{<base}}` / `{{$block}}`):
{{/base}} {{/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). 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 ```odin
Base_Data :: struct { 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 now: string, // UTC ISO 8601 build timestamp
params: json.Value, title: string, // computed browser title ("Page | Site")
content: string,
title: string,
description: string,
og: Open_Graph,
date_format: string, // from site.date.format (thor.json) date_format: string, // from site.date.format (thor.json)
timezone: ^datetime.TZ_Region, // loaded from site.date.timezone or local, owned by Site timezone: ^datetime.TZ_Region, // for format pipe
} og: Open_Graph, // computed per-page OG
Page_Data :: struct { page: Page, // current page
using base: Base_Data, // fields promoted via reflection fallback pages: [dynamic]Page, // home page list
page.title: string, posts: [dynamic]Page, // section post list
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. `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 ### Pipes extension
@@ -449,6 +484,20 @@ See `HUGO.md` for analysis of why thor doesn't need Hugo's shortcode context iso
See `mustache/SPEC.md` for the original implementation specification. See `mustache/SPEC.md` for the original implementation specification.
See `mustache/EXTENSIONS.md` for non-standard extensions (pipes). See `mustache/EXTENSIONS.md` for non-standard extensions (pipes).
## Odin language facts
These are things that are easy to get wrong:
- **Proc arguments are immutable.** You cannot assign to a parameter directly. To get a mutable copy, shadow it: `x := x`. If you need to modify the source, pass a pointer `^x`.
- **`for` each loops use `item, idx` order**, not `idx, item`. Correct: `for item, idx in arr`. Wrong: `for idx, item in arr`.
- **`make([dynamic]T, n, allocator)` sets capacity, not length.** To get length=0 with capacity=n, use `make([dynamic]T, 0, n, allocator)`. Using `make([dynamic]T, n, allocator)` creates `len=n` with `n` zero-initialized elements.
- `#partial switch` is usually a code smell. prefer a `case all, extra, types:` branch.
- you don't usually need to create arena allocators in tests, instead use context.temp_allocator if you want to simplify cleanup.
- you don't need to manually set up a tracking allocator in tests. the context.allocator will warn you about leaks.
- **`Maybe(T)` unwrap syntax:** `value.? or_else default`. Not `value or_else default``or_else` works on the `?T` returned by `.?`, not on `Maybe(T)` directly.
- **`Maybe(T)` equality:** `a == b` works directly between two `Maybe(T)` values (nil == nil → true, some(5) == some(5) → true, nil == some(5) → false). Also `a == 5` works (int coerces to `Maybe(int)`).
- **File logger in tests:** `log.create_file_logger(&f)` + `context.logger = logger` captures log output. Must be set inline in the test proc (not via a helper proc) for context propagation. Clean up with `log.destroy_file_logger(logger)` then `os.read_entire_file_from_path` to verify output.
## TODO ## TODO
See `TODOS.md` for the full list. See `TODOS.md` for the full list.
+294
View File
@@ -0,0 +1,294 @@
# Thor — UX Problems Catalog
Adversarial review of error messages, behavioral inconsistencies, and user
frustration points. Established as a baseline on commit `314cab2`.
Each entry cites the source location so it can be tracked to a fix.
---
## Severity legend
- **Critical** — user mistake produces silent wrong output or an unhelpful
fatal error with no path forward.
- **High** — error or warning is emitted but missing "where" or "how to fix."
- **Medium** — inconsistency or gotcha that causes confusion or rework.
- **Low** — polish / minor frustration.
---
## A. Silent wrong output (no error, wrong result)
These are the most dangerous — the user gets *no signal* that something is wrong.
### A1. Non-JSON frontmatter silently treated as body content — Critical
`frontmatter.odin:26`
Thor expects JSON frontmatter delimited by bare `{` / `}` lines. A user
coming from Hugo/Jekyll writes YAML (`---`) or TOML (`+++`) frontmatter. It is
silently swallowed into the markdown body. No title, no date, no draft flag —
and no error. Likely the #1 onboarding trap.
### A2. Unknown `thor.json` keys silently ignored — Critical
`site.odin:162`
`json.unmarshal_string` skips unknown fields. A typo like `"tittle"` instead
of `"title"` produces a silently-empty title. No warning. (`TODOS.md` already
wants a JSON schema.)
### A3. Draft pages silently excluded — High
`content.odin:64`
When `-drafts` isn't passed, draft pages vanish with no log. User adds a
page, forgets the flag, page doesn't appear — zero feedback.
### A4. Naive singularization for layout inference — High
`content.odin:202`
`posts``post` (correct), but `series``serie`, `news``new`. The
layout silently falls through the fallback chain to `page`/`base`. No
"layout 'serie' not found for section 'series'" message — only a debug log
that's off by default.
### A5. `base_url` defaults to `localhost:8080` — Critical
`site.odin:100`
Forgetting to set it means every canonical URL, OG tag, and RSS link points
to localhost. No warning. Devastating in production builds.
### A6. Missing `content/` produces empty build — High
`content.odin:82`
`scan_content_files` logs a `warnf`, the build proceeds with zero pages, then
`log.infof("Rendered 0 pages")`. No fatal error, no "did you create
content/?" guidance.
### A7. RSS emits sentinel epoch date silently — Medium
`feed.odin:33`
Pages without a date get `"Mon, 01 Jan 0001 00:00:00 +0000"` in `<pubDate>`.
No warning that a page is dateless in the feed.
### A8. `format_rfc822` returns raw ISO on parse failure — Medium
`feed.odin:122-125`
`// TODO: should indicate error somehow` — short/malformed dates get embedded
verbatim in `<pubDate>`, producing invalid RSS with no warning.
---
## B. Error messages missing "where" or "how to fix"
### B1. `render_template` blanks the entire page on error — Critical
`render.odin:119-133`
A single bad tag/pipe anywhere produces `log.errorf` + `return ""`. The
output file is silently written empty. In a `nix build` (no visible
terminal), the user sees a blank page with zero clue why. Already noted in
`TODOS.md`.
### B2. Malformed `thor.json` degrades to defaults — Critical
`site.odin:162-168`
A JSON syntax error is a `warnf`, then `site_apply_path_defaults` kicks in.
The site builds with wrong paths and produces a confusing empty result — the
cause is two hops removed from the symptom.
### B3. Frontmatter parse error has no file location — Critical
`frontmatter.odin:41`
`"failed to parse frontmatter JSON: %v"` — no filename. On a 100-post site
the user can't find the bad file. Worse: `ok=false` silently drops the page
entirely.
### B4. `get_template` returns empty `Template{}` on missing base — High
`render.odin:88-89`
`"base.html not found in VFS"` — no guidance on how to fix (create the file,
check modules, etc.).
### B5. `dlopen` failures lack the OS reason and fix guidance — High
`treesitter/treesitter.odin:200-219`
"cannot load grammar %s (%s)" shows the path but not *why* (no `dlerror()`).
No guidance: "set the 'grammars' key in thor.json" or "this .so may be for a
different tree-sitter ABI."
### B6. Menu-mix fatal lacks location — High
`menus.odin:42`
`"cannot mix config menus with frontmatter menus"` — doesn't name which pages
have frontmatter menus.
### B7. Minify error doesn't name the page — Medium
`minify.odin:33`
"minify: HTML parse errors, skipping minification" — across 50 pages, which
one?
### B8. Timezone load failure is a warning with no guidance — Medium
`site.odin:148`
Doesn't state impact (dates render in UTC) or suggest valid names. Already
in `TODOS.md`.
### B9. No "config not found" message — Medium
`site.odin:113`
Silently falls back to `./thor.json`. Wrong-directory runs produce a
confusing default build.
---
## C. Silent skip of invalid user input
### C1. Unknown markdown extensions silently ignored (CLI) — High
`markdown/markdown.odin:49-68`
`parse_extension_list` has a switch with no default case. `-ext:higlight`
(typo for `highlight`) is silently a no-op.
### C2. Unknown markdown extensions silently ignored (config) — High
`markdown/markdown.odin:71-90`
`apply_extension_config` has a `// TODO: Silently discards invalid values.`
Unknown keys in `thor.json`'s `markdown_extensions` are silently dropped.
Non-boolean values are `or_continue`d.
---
## D. Naming inconsistencies
### D1. Markdown extensions have 3+ names — Medium
| Context | Name |
|---|---|
| `thor.json` key | `markdown_extensions` |
| CLI flag | `-ext` / `-no-ext` |
| Struct fields | `md_enable` / `md_disable` |
| JSON/CLI values | `emoji`, `sidenotes` (lowercase) |
| Enum members | `.Emoji`, `.Sidenotes` (PascalCase) |
### D2. `-ext` usage string omits `heading_ids` — Medium
`site.odin:91`
The help text lists `emoji,sidenotes,alerts,highlight,sections` but the enum
also has `HeadingIDs`. Users can't discover it from `--help`.
### D3. Starred field has three names — Low
- `Page.starred` (`content.odin:28`)
- `Frontmatter.isStarred` (`frontmatter.odin:17`) — so the JSON key is `isStarred`
- `AGENTS.md:101` says `is_starred` (stale)
### D4. Inconsistent error severity for similar failures — Medium
- Template **parse** error → `log.errorf` + `os.exit(1)` (fatal) — `render.odin:37-49`
- Template **render** error → `log.errorf` + return `""` (non-fatal, blank page) — `render.odin:126-131`
- Config parse error → `warnf` + fallback to defaults — `site.odin:163-165`
Same category of failure (user wrote something wrong) with wildly different
consequences.
### D5. Dead `os.exit(1)` after `log.fatalf` — Low
`render.odin:33`, `menus.odin:43`
`fatalf` already exits; the following line is dead code.
---
## E. Configuration gotchas
### E1. `"menus": {}` is a stealth opt-out — Medium
`menus.odin:31-38`
An empty object silently disables *all* auto-menus. A user who adds the key
intending to configure later quietly loses their nav. The semantics (absent
≠ empty) are undocumented outside code comments.
### E2. Config precedence is invisible — Medium
`site.odin`
CLI > JSON > defaults, but there's no "resolved config" log. Debugging "why
is my base_url wrong?" requires reading source.
### E3. `format` pipe logs ERROR but still renders — Medium
`pipes.odin:261-265`
Missing `date.format` produces `log.errorf` but falls back to
`DEFAULT_DATE_FORMAT`. The severity says "error" but the behavior says
"warning."
---
## F. File/directory behavior surprises
### F1. Root dirs = sections, nested dirs = leaf bundles — Medium
`content.odin:108-127`
This meaningful semantic distinction is entirely implicit.
`content/about/team.md` is a leaf bundle (page "about" with body from
team.md), not a section "about" with page "team". No error or guidance when
the user's mental model differs.
### F2. Missing `layouts/` silently uses defaults — High
`vfs.odin:38-40`
`mount_dir` returns silently if the directory doesn't exist. Wrong path →
all user templates missing → defaults used. No "layouts directory X not
found" message.
### F3. Missing section index silently synthesized — Medium
`render.odin:316-326`
A section with pages but no `index.md` gets a synthetic `Page` with only a
title. No warning. User expecting an error gets a mostly-blank page.
### F4. Windows line endings silently break frontmatter — Medium
`frontmatter.odin:26`
`has_prefix(content, "{\n")` fails on `\r\n`; the JSON is treated as body.
Zero feedback.
---
## G. Template authoring frustrations
### G1. Template fallback chain is silent at Info level — Medium
`render.odin:84`
Only `log.debugf`, which is off by default (`main.odin:48` sets `.Info`).
User's custom layout silently ignored, defaults used.
### G2. Render error blanks entire page — Critical
`render.odin:126-131`
(Same as B1 — restated here for the template-authoring perspective.) One bad
tag → whole page `""`. The most impactful silent failure in the system.
---
## Cross-cutting themes
1. **Debug-level logging masks important fallbacks.** Layout fallbacks,
template misses, and grammar skips are all `debugf` — invisible at the
default Info level. Users never learn their customizations were ignored.
2. **The system fails open, not closed.** Missing files, missing
directories, missing config — all silently fall back to defaults rather
than surfacing the problem. Friendly until it isn't.
3. **No "resolved state" visibility.** There's no way for a user to see what
thor actually loaded: which layouts, which config values, which pages
were skipped as drafts. The build is a black box.
---
## Gold-standard examples to emulate
These are the parts of the codebase that already do it right:
- **Mustache diagnostics** (`mustache/diagnostic.odin` + `suggest.odin`):
rust-style multi-line context, caret underlines, Levenshtein "did you
mean?" hints, file:line:col.
- **Treesitter query version-mismatch** (`treesitter/treesitter.odin:299-315`):
explains the likely cause, shows both grammar/query versions, flags
mismatches explicitly.
+33 -9
View File
@@ -2,25 +2,39 @@
- Polish existing features before moving on to new ones. - Polish existing features before moving on to new ones.
- [ ] Improve diagnostics - [ ] Improve diagnostics
- [ ] All Diagnostics should show:
- [ ] *What* went wrong
- [ ] *where* (in the file)
- [ ] *where* (in the stack trace)
- [ ] *how* you can fix it (if applicable)
- [ ] Create a Location struct that somewhat matches Odin's [Source_Code_Location](https://pkg.odin-lang.org/base/runtime/#Source_Code_Location)?
- Note that odin's version doesn't contain the stack trace.
- [ ] show "stack traces" in template error diagnostics - [ ] show "stack traces" in template error diagnostics
- [ ] better diagnostics for syntax errors in treesitter. - [ ] better diagnostics for syntax errors in treesitter.
- [ ] Ensure diagnostics for MAX_CONTEXT_DEPTH are good. - [ ] Ensure diagnostics for MAX_CONTEXT_DEPTH are good.
- [ ] improve matching weights message.
- [ ] show a proper diagnostic for timezones - [ ] show a proper diagnostic for timezones
- currently "unable to load timezone 'America/New_Yorkskie'" - currently "unable to load timezone 'America/New_Yorkskie'"
- want rust style diagnostic and better message, maybe "unknown timezone 'America/New_Yorkskie'" - want rust style diagnostic and better message, maybe "unknown timezone 'America/New_Yorkskie'"
- [x] Simplify / unify template context stack. Come up with a name for it. - [ ] Test menu diagnostics
- [x] `render_template` should accept `Template_Context`, not `any` - [ ] Honestly, Test **all** diagnostics
- [ ] Need to be careful about diagnostics across module boundaries.
- we don't necessarily want to warn users about theme designers mistakes. (though perhaps we do)
- [ ] consider reporting duplicate weights outside of menus
- [ ] Load grammars dynamically - [ ] Load grammars dynamically
- [x] consider adding a limit to the context stack in mustache.
- [x] Add heading ids as a default on extension.
- [ ] starred must be a param. - [ ] starred must be a param.
- [x] Add a `#config(MAX_CONTEXT_DEPTH, 16?)` to `mustache`.
- [ ] Documentation - [ ] Documentation
- [ ] talk about the context stack (and its limit). - [ ] talk about the context stack (and its limit).
- [ ] menu system - [ ] highlight the differences in the way menus are handled.
- [ ] like Hugo's, but warn(/fail?) if menus are defined in the config *and* pages. - [ ] consider sites with data based urls.
- i.e. force the user to choose one or the other.
- [ ] Don't show annoying log output in tests. - [ ] Don't show annoying log output in tests.
- [ ] improve home link customization.
- [ ] currently an accessibility issue.
- [ ] support JSON5 in in frontmatter
- [ ] Create a json schema file for `thor.json`.
- [ ] cleanup `#partial switch`es.
- [ ] improve json diagnostics.
- i.e. "Missing quotes around string", etc.
## Performance ## Performance
@@ -44,6 +58,8 @@
- `await` the highlighted code. - `await` the highlighted code.
- [ ] can markdown extensions run in parallel? - [ ] can markdown extensions run in parallel?
- [ ] enforce MAX_SLUG_LENGTH - [ ] enforce MAX_SLUG_LENGTH
- [ ] enforce MAX_CONTEXT_DEPTH
- [ ] ensure struct fields are ordered correctly
## Remove Privileged content ## Remove Privileged content
@@ -78,6 +94,9 @@
- [ ] Do we *need* mustache.Date_Components, or can we use core:time/datetime.DateTime? - [ ] Do we *need* mustache.Date_Components, or can we use core:time/datetime.DateTime?
## General ## General
- [ ] Menus
- [ ] configure opt-out of automatic sections being added to menu.
- [ ] nested menus (i.e. `parent` support)
- [ ] get rid of the global variables in the `treesitter` package. - [ ] get rid of the global variables in the `treesitter` package.
- [ ] Consider using `or_else` when applying default values to structs. i.e. - [ ] Consider using `or_else` when applying default values to structs. i.e.
```odin ```odin
@@ -96,6 +115,8 @@ main :: proc () {
- [ ] Integrity hash - [ ] Integrity hash
- Allows users to verify their output didn't change after upgrading to a new version - Allows users to verify their output didn't change after upgrading to a new version
- [ ] Content-hash fingerprinting for CSS and JS cache busting - [ ] Content-hash fingerprinting for CSS and JS cache busting
- [ ] come up with scrapers / scrape sources to harvest site data
- we'll use this to help us sculpt defaults.
- [ ] merge `render_{section,home_html,page_html}` procs. - [ ] merge `render_{section,home_html,page_html}` procs.
- [ ] try to combine render_page_html and render_home_html? - [ ] try to combine render_page_html and render_home_html?
- [ ] Debug log stats. (analytics) - [ ] Debug log stats. (analytics)
@@ -107,7 +128,6 @@ main :: proc () {
- [ ] enabled features / extensions - [ ] enabled features / extensions
- [ ] etc - [ ] etc
- [ ] Avoid `json.Value` / `json.Object` where possible. - [ ] Avoid `json.Value` / `json.Object` where possible.
- [ ] Create a json schema file for `thor.json`.
- [ ] make `parse` an overload of `parse_text/parse_inline` and `parse_file`, or something. - [ ] make `parse` an overload of `parse_text/parse_inline` and `parse_file`, or something.
- [ ] Add page params - [ ] Add page params
- [ ] We must remove all mention of `posts` from the odin code. - [ ] We must remove all mention of `posts` from the odin code.
@@ -116,6 +136,10 @@ main :: proc () {
- [ ] running ./thor/thor still logs the debug message: using config /home/spencer/github.com/sbrow.github.io/thor.json - [ ] running ./thor/thor still logs the debug message: using config /home/spencer/github.com/sbrow.github.io/thor.json
- wrong cwd? - wrong cwd?
- [ ] Clean up the default layouts - [ ] Clean up the default layouts
- [ ] Menus
- [ ] Detailed frontmatter menu form ("menu": {"main": {"weight": 5}})
- [ ] Menu active state (pre-compute is_active based on page.permalink prefix match)
- [ ] Page.weight field for general-purpose page ordering (menus, lists, related posts)
- [ ] if no `html` tag detected in output, re-render output with base template - [ ] if no `html` tag detected in output, re-render output with base template
(or whatever template is next in the chain) (or whatever template is next in the chain)
- [ ] Add `-production` flag - [ ] Add `-production` flag
+31 -10
View File
@@ -1,11 +1,11 @@
package bench package bench
import "../mustache"
import "core:fmt" import "core:fmt"
import "core:mem" import "core:mem"
import "core:os" import "core:os"
import "core:strconv" import "core:strconv"
import "core:time" import "core:time"
import "../mustache"
Tag :: struct { Tag :: struct {
name: string, name: string,
@@ -116,8 +116,12 @@ main :: proc() {
seconds := time.duration_seconds(elapsed) seconds := time.duration_seconds(elapsed)
per_render_ms := seconds * 1000 / f64(iterations) per_render_ms := seconds * 1000 / f64(iterations)
fmt.printfln("iterations=%d total=%.3fs per_render=%.3fms", fmt.printfln(
iterations, seconds, per_render_ms) "iterations=%d total=%.3fs per_render=%.3fms",
iterations,
seconds,
per_render_ms,
)
} }
parse_file :: proc(name: string) -> mustache.Template { parse_file :: proc(name: string) -> mustache.Template {
@@ -139,15 +143,26 @@ parse_file :: proc(name: string) -> mustache.Template {
generate_data :: proc() -> Page_Data { generate_data :: proc() -> Page_Data {
years := []string { years := []string {
"2025", "2024", "2023", "2022", "2021", "2025",
"2020", "2019", "2018", "2017", "2016", "2024",
"2023",
"2022",
"2021",
"2020",
"2019",
"2018",
"2017",
"2016",
} }
posts := make([dynamic]Post, 0, 500) posts := make([dynamic]Post, 0, 500)
for year in years { for year in years {
for i in 0 ..< 50 { for i in 0 ..< 50 {
tags := make([dynamic]Tag, 0, 3) tags := make([dynamic]Tag, 0, 3)
append(&tags, Tag{name = fmt.aprintf("%s-notes", year), slug = fmt.aprintf("%s-notes", year)}) append(
&tags,
Tag{name = fmt.aprintf("%s-notes", year), slug = fmt.aprintf("%s-notes", year)},
)
append(&tags, Tag{name = "writing", slug = "writing"}) append(&tags, Tag{name = "writing", slug = "writing"})
append(&tags, Tag{name = "archive", slug = "archive"}) append(&tags, Tag{name = "archive", slug = "archive"})
@@ -159,7 +174,9 @@ generate_data :: proc() -> Page_Data {
author = fmt.aprintf("Author %d", i % 5) author = fmt.aprintf("Author %d", i % 5)
} }
append(&posts, Post{ append(
&posts,
Post {
title = fmt.aprintf("Post %d from %s", i, year), title = fmt.aprintf("Post %d from %s", i, year),
url = fmt.aprintf("/%s/post-%d", year, i), url = fmt.aprintf("/%s/post-%d", year, i),
date = fmt.aprintf("%s-%02d-%02dT10:00:00Z", year, month, day), date = fmt.aprintf("%s-%02d-%02dT10:00:00Z", year, month, day),
@@ -167,7 +184,8 @@ generate_data :: proc() -> Page_Data {
excerpt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", excerpt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
author = author, author = author,
tags = tags, tags = tags,
}) },
)
} }
} }
@@ -176,11 +194,14 @@ generate_data :: proc() -> Page_Data {
year := years[i % len(years)] year := years[i % len(years)]
month := (i % 12) + 1 month := (i % 12) + 1
day := (i % 28) + 1 day := (i % 28) + 1
append(&comments, Comment{ append(
&comments,
Comment {
author = fmt.aprintf("Commenter %d", i), author = fmt.aprintf("Commenter %d", i),
date = fmt.aprintf("%s-%02d-%02dT12:00:00Z", year, month, day), date = fmt.aprintf("%s-%02d-%02dT12:00:00Z", year, month, day),
body = fmt.aprintf("Great post! This is comment number %d.", i), body = fmt.aprintf("Great post! This is comment number %d.", i),
}) },
)
} }
nav_items := make([dynamic]Nav_Item, 0, 8) nav_items := make([dynamic]Nav_Item, 0, 8)
-1
View File
@@ -127,4 +127,3 @@ to_benchmark :: proc($f: formatter) -> benchmark {
} \ } \
) )
} }
-1
View File
@@ -41,4 +41,3 @@ parse_2_digits :: proc(s: string, offset: int) -> int {
} }
return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30) return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30)
} }
-1
View File
@@ -123,4 +123,3 @@ emit_hour_12 :: proc(b: ^strings.Builder, dt: common.Date_Components, pad: bool)
fmt.sbprintf(b, format, h12) fmt.sbprintf(b, format, h12)
} }
-1
View File
@@ -130,4 +130,3 @@ emit_hour_12 :: proc(b: ^strings.Builder, dt: common.Date_Components, pad: bool)
fmt.sbprintf(b, format, h12) fmt.sbprintf(b, format, h12)
} }
-1
View File
@@ -125,4 +125,3 @@ emit_am_pm :: proc(b: ^strings.Builder, dt: common.Date_Components) {
emit_am_pm_lower :: proc(b: ^strings.Builder, dt: common.Date_Components) { emit_am_pm_lower :: proc(b: ^strings.Builder, dt: common.Date_Components) {
strings.write_string(b, "pm" if dt.hour >= 12 else "am") strings.write_string(b, "pm" if dt.hour >= 12 else "am")
} }
-1
View File
@@ -41,4 +41,3 @@ parse_2_digits :: proc(s: string, offset: int) -> int {
} }
return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30) return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30)
} }
-1
View File
@@ -124,4 +124,3 @@ emit_hour_12 :: proc(b: ^strings.Builder, dt: common.Date_Components, pad: bool)
fmt.sbprintf(b, format, h12) fmt.sbprintf(b, format, h12)
} }
+26 -13
View File
@@ -20,8 +20,9 @@ Page :: struct {
description: string, description: string,
date: string, date: string,
year: string, year: string,
weight: Maybe(int),
lastmod: string, lastmod: string,
menu: string, menus: map[string]Menu_Entry,
content: string, content: string,
og: Open_Graph, og: Open_Graph,
draft: bool, draft: bool,
@@ -69,6 +70,8 @@ site_load_content :: proc(site: ^Site) {
for &page in site.pages { for &page in site.pages {
page.url = fmt.tprintf("%s%s", site.base_url, page.permalink) page.url = fmt.tprintf("%s%s", site.base_url, page.permalink)
} }
build_menus(site)
} }
// scan_content_files walks the content directory and collects Pending_File // scan_content_files walks the content directory and collects Pending_File
@@ -236,24 +239,25 @@ load_page :: proc(
if page.date == "" { if page.date == "" {
info, stat_err := os.stat(file_path, context.allocator) info, stat_err := os.stat(file_path, context.allocator)
if stat_err == nil { if stat_err == nil {
page.date, _ = time.time_to_rfc3339(info.modification_time, 0, false, context.allocator) page.date, _ = time.time_to_rfc3339(
info.modification_time,
0,
false,
context.allocator,
)
os.file_info_delete(info, context.allocator) os.file_info_delete(info, context.allocator)
log.warnf("no date in frontmatter for %s, using file modification time: %s", file_path, page.date) log.warnf(
"no date in frontmatter for %s, using file modification time: %s",
file_path,
page.date,
)
} }
} }
page.year = get_year(page.date) page.year = get_year(page.date)
page.weight = fm.weight
page.lastmod = fm.lastmod page.lastmod = fm.lastmod
page.draft = fm.draft page.draft = fm.draft
page.starred = fm.isStarred page.starred = fm.isStarred
page.menu = fm.menu
page.layout = fm.layout if fm.layout != "" else infer_layout(section, is_index)
page.og = fm.og
if strings.has_suffix(file_path, ".html") {
page.content = strings.clone(body)
} else {
page.content = md.process(body, ext, file_path)
}
if section == "" && is_index { if section == "" && is_index {
page.permalink = "/" page.permalink = "/"
@@ -265,6 +269,16 @@ load_page :: proc(
page.permalink = fmt.aprintf("/%s/%s/", section, slug) page.permalink = fmt.aprintf("/%s/%s/", section, slug)
} }
page.menus = parse_page_menus(fm.menus, page, context.allocator)
page.layout = fm.layout if fm.layout != "" else infer_layout(section, is_index)
page.og = fm.og
if strings.has_suffix(file_path, ".html") {
page.content = strings.clone(body)
} else {
page.content = md.process(body, ext, file_path)
}
ok = true ok = true
return return
} }
@@ -280,4 +294,3 @@ strip_extension :: proc(name: string) -> string {
} }
return name[:dot] return name[:dot]
} }
-1
View File
@@ -3,4 +3,3 @@ package main
import "core:os" import "core:os"
DEFAULTS_PATH :: #directory + os.Path_Separator_String + "defaults" DEFAULTS_PATH :: #directory + os.Path_Separator_String + "defaults"
+1
View File
@@ -0,0 +1 @@
{{site.title}}
+4 -1
View File
@@ -1,7 +1,10 @@
<header> <header>
<nav> <nav>
<ul> <ul>
<li><a href="/">{{title}}</a></li> <li><a href="/">{{> home-link}}</a></li>
{{#menus.main}}
<li><a href="{{url}}">{{name}}</a></li>
{{/menus.main}}
</ul> </ul>
</nav> </nav>
</header> </header>
-1
View File
@@ -149,4 +149,3 @@ xml_escape :: proc(s: string) -> string {
r, _ = strings.replace_all(r, ">", "&gt;") r, _ = strings.replace_all(r, ">", "&gt;")
return r return r
} }
+22 -14
View File
@@ -12,13 +12,10 @@
}; };
outputs = outputs =
inputs@{ inputs@{ flake-parts
self, , nixpkgs
flake-parts, , ...
nixpkgs,
nixpkgs-unstable,
# , process-compose-flake # , process-compose-flake
treefmt-nix,
}: }:
flake-parts.lib.mkFlake { inherit inputs; } { flake-parts.lib.mkFlake { inherit inputs; } {
imports = [ imports = [
@@ -28,11 +25,10 @@
systems = [ "x86_64-linux" ]; systems = [ "x86_64-linux" ];
perSystem = perSystem =
{ { pkgs
pkgs, , system
system, , inputs'
inputs', , ...
...
}: }:
let let
mkGrammarStaticLib = name: src: pkgs.stdenv.mkDerivation { mkGrammarStaticLib = name: src: pkgs.stdenv.mkDerivation {
@@ -71,7 +67,7 @@
config.allowUnfree = true; config.allowUnfree = true;
overlays = [ overlays = [
(final: prev: { unstable = inputs'.nixpkgs-unstable.legacyPackages; }) (_final: _prev: { unstable = inputs'.nixpkgs-unstable.legacyPackages; })
]; ];
}; };
@@ -95,11 +91,22 @@
settings.formatter.prettier = { settings.formatter.prettier = {
excludes = [ excludes = [
"public/**" "public/**"
"resources/js/modernizr.js" "mustache/spec/specs/**"
"storage/app/caniuse.json" "*.html"
"*.md" "*.md"
]; ];
}; };
settings.formatter.ols = {
command = "${pkgs.bash}/bin/bash";
options = [
"-euc"
''
${pkgs.ols}/bin/odinfmt -w .
''
];
includes = [ "*.odin" ];
};
}; };
#process-compose.default.settings.processes = { }; #process-compose.default.settings.processes = { };
@@ -118,6 +125,7 @@
pkgs.git pkgs.git
pkgs.cmark pkgs.cmark
pkgs.tree-sitter pkgs.tree-sitter
pkgs.tzdata
html-grammar html-grammar
css-grammar css-grammar
]; ];
+20 -3
View File
@@ -10,7 +10,8 @@ Frontmatter :: struct {
date: string, date: string,
lastmod: string, lastmod: string,
publishDate: string, publishDate: string,
menu: string, weight: Maybe(int),
menus: json.Value,
layout: string, layout: string,
og: Open_Graph, og: Open_Graph,
draft: bool, draft: bool,
@@ -53,9 +54,12 @@ parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok
fm.date = json_get_string(obj, "date") fm.date = json_get_string(obj, "date")
fm.lastmod = json_get_string(obj, "lastmod") fm.lastmod = json_get_string(obj, "lastmod")
fm.publishDate = json_get_string(obj, "publishDate") fm.publishDate = json_get_string(obj, "publishDate")
fm.weight = json_get_int(obj, "weight")
fm.draft = json_get_bool(obj, "draft") fm.draft = json_get_bool(obj, "draft")
fm.isStarred = json_get_bool(obj, "isStarred") fm.isStarred = json_get_bool(obj, "isStarred")
fm.menu = json_get_string(obj, "menu") if v, ok := obj["menus"]; ok {
fm.menus = v
}
fm.layout = json_get_string(obj, "layout") fm.layout = json_get_string(obj, "layout")
fm.og = json_get_open_graph(obj, "og") fm.og = json_get_open_graph(obj, "og")
@@ -81,6 +85,20 @@ json_get_bool :: proc(obj: json.Object, key: string) -> bool {
return false return false
} }
json_get_int :: proc(obj: json.Object, key: string) -> Maybe(int) {
if v, ok := obj[key]; ok {
switch val in v {
case json.Integer:
return int(val)
case json.Float:
return int(val)
case json.Boolean, json.String, json.Array, json.Object, json.Null:
return nil
}
}
return nil
}
json_get_open_graph :: proc(obj: json.Object, key: string) -> Open_Graph { json_get_open_graph :: proc(obj: json.Object, key: string) -> Open_Graph {
og: Open_Graph og: Open_Graph
if v, ok := obj[key]; ok { if v, ok := obj[key]; ok {
@@ -99,4 +117,3 @@ json_get_open_graph :: proc(obj: json.Object, key: string) -> Open_Graph {
} }
return og return og
} }
+1 -3
View File
@@ -82,9 +82,7 @@ test_description_plain_text :: proc(t: ^testing.T) {
@(test) @(test)
test_description_highlighted_code :: proc(t: ^testing.T) { test_description_highlighted_code :: proc(t: ^testing.T) {
result := generate_description( result := generate_description(`<pre><code><span class="hl-keyword">if</span> x</code></pre>`)
`<pre><code><span class="hl-keyword">if</span> x</code></pre>`,
)
testing.expect_value(t, result, "if x") testing.expect_value(t, result, "if x")
} }
-28
View File
@@ -1,28 +0,0 @@
package main
import "core:fmt"
Ctx :: struct {
title: string,
using page: Page,
site: Site,
}
Page :: struct {
title: string,
}
Site :: struct {
title: string,
}
main :: proc() {
site := Ctx {
site = Site{title = "foo"},
page = Page{title = "bar"},
}
fmt.printfln("%+v", site)
fmt.printf("%+v", site)
}
-1
View File
@@ -94,4 +94,3 @@ when SPALL {
spall._buffer_end(&spall_ctx, &spall_buffer) spall._buffer_end(&spall_ctx, &spall_buffer)
} }
} }
-1
View File
@@ -7,4 +7,3 @@ import "core:testing"
test_true :: proc(t: ^testing.T) { test_true :: proc(t: ^testing.T) {
testing.expect(t, true) testing.expect(t, true)
} }
-1
View File
@@ -95,4 +95,3 @@ transform_alert :: proc(sb: ^strings.Builder, bq: string) {
strings.write_string(sb, " ") strings.write_string(sb, " ")
strings.write_string(sb, rest) strings.write_string(sb, rest)
} }
-1
View File
@@ -101,4 +101,3 @@ test_multiple_alerts_render_together :: proc(t: ^testing.T) {
</blockquote>`, </blockquote>`,
) )
} }
-1
View File
@@ -434,4 +434,3 @@ expand_emoji :: proc(text: string) -> string {
return strings.to_string(sb) return strings.to_string(sb)
} }
-1
View File
@@ -30,4 +30,3 @@ test_emoji_skips_invalid_shortcodes :: proc(t: ^testing.T) {
testing.expect_value(t, expand_emoji(":Smile:"), ":Smile:") testing.expect_value(t, expand_emoji(":Smile:"), ":Smile:")
testing.expect_value(t, expand_emoji(": not real :"), ": not real :") testing.expect_value(t, expand_emoji(": not real :"), ": not real :")
} }
-1
View File
@@ -210,4 +210,3 @@ strip_p_tags :: proc(html: string) -> string {
} }
return s return s
} }
-1
View File
@@ -119,4 +119,3 @@ test_inject_notes_missing_ref :: proc(t: ^testing.T) {
testing.expect(t, strings.contains(out, "[^missing]")) testing.expect(t, strings.contains(out, "[^missing]"))
testing.expect(t, strings.contains(out, "[*missing]")) testing.expect(t, strings.contains(out, "[*missing]"))
} }
-1
View File
@@ -193,4 +193,3 @@ make_unique :: proc(slug: string, seen: ^map[string]bool) -> string {
} }
return "" return ""
} }
+12 -6
View File
@@ -12,7 +12,11 @@ test_heading_simple :: proc(t: ^testing.T) {
@(test) @(test)
test_heading_dedup :: proc(t: ^testing.T) { test_heading_dedup :: proc(t: ^testing.T) {
result := inject_heading_ids("<h2>Intro</h2><p>text</p><h2>Intro</h2>") result := inject_heading_ids("<h2>Intro</h2><p>text</p><h2>Intro</h2>")
testing.expect_value(t, result, `<h2 id="intro">Intro</h2><p>text</p><h2 id="intro-1">Intro</h2>`) testing.expect_value(
t,
result,
`<h2 id="intro">Intro</h2><p>text</p><h2 id="intro-1">Intro</h2>`,
)
} }
@(test) @(test)
@@ -36,7 +40,9 @@ test_heading_punctuation :: proc(t: ^testing.T) {
@(test) @(test)
test_heading_all_levels :: proc(t: ^testing.T) { test_heading_all_levels :: proc(t: ^testing.T) {
result := inject_heading_ids("<h1>A</h1><h2>B</h2><h3>C</h3><h4>D</h4><h5>E</h5><h6>F</h6>") result := inject_heading_ids("<h1>A</h1><h2>B</h2><h3>C</h3><h4>D</h4><h5>E</h5><h6>F</h6>")
testing.expect_value(t, result, testing.expect_value(
t,
result,
`<h1 id="a">A</h1>` + `<h1 id="a">A</h1>` +
`<h2 id="b">B</h2>` + `<h2 id="b">B</h2>` +
`<h3 id="c">C</h3>` + `<h3 id="c">C</h3>` +
@@ -88,9 +94,9 @@ test_heading_numbers :: proc(t: ^testing.T) {
@(test) @(test)
test_heading_triple_dedup :: proc(t: ^testing.T) { test_heading_triple_dedup :: proc(t: ^testing.T) {
result := inject_heading_ids("<h2>Foo</h2><h2>Foo</h2><h2>Foo</h2>") result := inject_heading_ids("<h2>Foo</h2><h2>Foo</h2><h2>Foo</h2>")
testing.expect_value(t, result, testing.expect_value(
`<h2 id="foo">Foo</h2>` + t,
`<h2 id="foo-1">Foo</h2>` + result,
`<h2 id="foo-2">Foo</h2>`, `<h2 id="foo">Foo</h2>` + `<h2 id="foo-1">Foo</h2>` + `<h2 id="foo-2">Foo</h2>`,
) )
} }
-1
View File
@@ -305,4 +305,3 @@ highlight_code :: proc(html: string, file_path: string) -> string {
} }
return strings.to_string(sb) return strings.to_string(sb)
} }
-1
View File
@@ -88,4 +88,3 @@ apply_extension_config :: proc(ext: ^bit_set[Extension], config: json.Object) {
} }
} }
} }
-1
View File
@@ -43,4 +43,3 @@ wrap_sections :: proc(html: string) -> string {
return html return html
} }
} }
-1
View File
@@ -46,4 +46,3 @@ test_wrap_sections_doesnt_split_content :: proc(t: ^testing.T) {
"<section><h1>Big</h1><h3>Small</h3></section>", "<section><h1>Big</h1><h3>Small</h3></section>",
) )
} }
+396
View File
@@ -0,0 +1,396 @@
package main
import "core:encoding/json"
import "core:fmt"
import "core:log"
import "core:mem"
import "core:os"
import "core:strings"
DEFAULT_WEIGHT :: 10
Menu_Entry :: struct {
name: string,
url: string,
weight: Maybe(int),
}
// parse_page_menus converts raw frontmatter JSON into map[string]Menu_Entry.
// Supports three forms:
// "menus": "main" → {main: {name=title, url=permalink, weight=nil}}
// "menus": ["main", "footer"] → {main: {...}, footer: {...}}
// "menus": {"main": {"weight": 30}} → {main: {name=title, url=permalink, weight=30}}
parse_page_menus :: proc(
raw: json.Value,
page: Page,
allocator: mem.Allocator,
) -> map[string]Menu_Entry {
result: map[string]Menu_Entry
if raw == nil {
return nil
}
switch v in raw {
case json.String:
result = make(map[string]Menu_Entry, allocator)
result[string(v)] = Menu_Entry {
name = page.title,
url = page.permalink,
}
case json.Array:
result = make(map[string]Menu_Entry, allocator)
for item in v {
if s, ok := item.(json.String); ok {
result[string(s)] = Menu_Entry {
name = page.title,
url = page.permalink,
}
} else {
log.warnf("menus: ignoring non-string item in menus array: %v", item)
}
}
case json.Object:
result = make(map[string]Menu_Entry, allocator)
for menu_name, entry_val in v {
weight: Maybe(int) = nil
if entry_obj, ok := entry_val.(json.Object); ok {
if w, ok := entry_obj["weight"]; ok {
switch wval in w {
case json.Integer:
weight = int(wval)
case json.Float:
weight = int(wval)
case json.Boolean, json.String, json.Array, json.Object, json.Null:
log.warnf(
"menus: '%s' entry 'weight' must be a number, got %v",
menu_name,
w,
)
}
}
if _, has_name := entry_obj["name"]; has_name {
log.warnf(
"menus: '%s' entry 'name' override not yet supported, ignoring",
menu_name,
)
}
if _, has_url := entry_obj["url"]; has_url {
log.warnf(
"menus: '%s' entry 'url' override not yet supported, ignoring",
menu_name,
)
}
} else {
log.warnf(
"menus: '%s' entry must be an object, got %v, using defaults",
menu_name,
entry_val,
)
}
result[menu_name] = Menu_Entry {
name = page.title,
url = page.permalink,
weight = weight,
}
}
case json.Null:
return nil
case json.Integer, json.Float, json.Boolean:
log.warnf("menus: expected string, array, or object, got %v", raw)
return nil
}
if page.title == "" {
log.warnf("menus: page '%s' has no title, menu entry will be blank", page.permalink)
}
return result
}
// build_menus populates site.menus:
// 1. Config menus (thor.json "menus" key present) — exclusive, preserves array order
// 2. Auto-menus (sections + root-level pages) + page frontmatter menus — merged, sorted
//
// If "menus" is present but empty ({}) it means explicit opt-out: no menus.
// Config menus cannot be mixed with page frontmatter menus (error).
build_menus :: proc(site: ^Site) {
if site.menus != nil {
has_menus := false
for page in site.pages {
if len(page.menus) > 0 {
has_menus = true
break
}
}
// Already populated from config in site_apply_config
if len(site.menus) == 0 {
// Explicit opt-out ("menus": {})
if has_menus {
log.warnf(
"menus: config has empty menus but pages have frontmatter menu entries; ignoring page menus",
)
}
return
}
// Config menus active
if has_menus {
log.fatalf("menus: cannot mix config menus with frontmatter menus")
os.exit(1)
}
warn_all_duplicate_weights(site)
return
}
// No config menus — auto-generate, then merge page menus on top
collect_auto_menus(site)
merge_page_menus(site)
warn_all_duplicate_weights(site)
}
// merge_page_menus collects frontmatter menu entries from pages and merges
// them into site.menus (which may already contain auto-generated entries).
// If no pages have menus set, this is a no-op.
merge_page_menus :: proc(site: ^Site) {
alloc := site_allocator(site)
// Collect page entries by menu name
page_entries := make(map[string][dynamic]Menu_Entry, 16, alloc)
for page in site.pages {
for menu_name, entry in page.menus {
if _, ok := page_entries[menu_name]; !ok {
page_entries[menu_name] = make([dynamic]Menu_Entry, 0, 4, alloc)
}
// Effective weight: per-menu weight if set, else page.weight
effective := entry.weight
if effective == nil {
effective = page.weight
}
append(
&page_entries[menu_name],
Menu_Entry{name = entry.name, url = entry.url, weight = effective},
)
}
}
if len(page_entries) == 0 {
return
}
if site.menus == nil {
site.menus = make(map[string][]Menu_Entry, alloc)
}
for menu_name, entries in page_entries {
sort_menu_entries(entries[:])
if existing, ok := site.menus[menu_name]; ok {
// Merge with existing auto-generated entries
merged := make([dynamic]Menu_Entry, 0, len(existing) + len(entries), alloc)
append(&merged, ..existing)
append(&merged, ..entries[:])
sort_menu_entries(merged[:])
site.menus[menu_name] = merged[:]
} else {
site.menus[menu_name] = entries[:]
}
}
}
collect_auto_menus :: proc(site: ^Site) {
alloc := site_allocator(site)
sections: map[string]bool
for page in site.pages {
if page._is_index {
continue
}
if page.section != "" {
sections[page.section] = true
}
}
entries := make([dynamic]Menu_Entry, 0, 8, alloc)
// Section entries (one per section directory)
for section in sections {
name := to_title_case(section, alloc)
url := fmt.aprintf("/%s/", section, allocator = alloc)
skip := false
for page in site.pages {
if page.section == section && page._is_index {
url = page.permalink
if page.title != "" {
name = page.title
}
if _, has_main := page.menus["main"]; has_main {
skip = true
}
break
}
}
if !skip {
append(&entries, Menu_Entry{name = name, url = url})
}
}
// Root-level page entries (section = "", not index)
for page in site.pages {
if page._is_index || page.section != "" || page.title == "" {
continue
}
if _, has_main := page.menus["main"]; has_main {
continue
}
append(&entries, Menu_Entry{name = page.title, url = page.permalink, weight = page.weight})
}
if len(entries) == 0 {
return
}
sort_menu_entries(entries[:])
site.menus = make(map[string][]Menu_Entry, alloc)
site.menus["main"] = entries[:]
}
compare_menu_entries :: proc(a, b: Menu_Entry) -> int {
aw := a.weight.? or_else DEFAULT_WEIGHT
bw := b.weight.? or_else DEFAULT_WEIGHT
if aw != bw do return aw - bw
a_set := a.weight != nil
b_set := b.weight != nil
if a_set != b_set {
return a_set ? -1 : 1
}
return strings.compare(a.name, b.name)
}
sort_menu_entries :: proc(entries: []Menu_Entry) {
for i in 1 ..< len(entries) {
key := entries[i]
j := i - 1
for j >= 0 && compare_menu_entries(entries[j], key) > 0 {
entries[j + 1] = entries[j]
j -= 1
}
entries[j + 1] = key
}
}
// warn_duplicate_weights logs a warning for each pair of adjacent entries
// (pre-sorted) that have the same explicitly-set weight. Entries with nil
// weight (unset/default) are never flagged.
warn_duplicate_weights :: proc(menu_name: string, entries: []Menu_Entry) {
for i in 0 ..< len(entries) - 1 {
if entries[i].weight != nil && entries[i].weight == entries[i + 1].weight {
log.warnf(
"menus('%s'): '%s' and '%s' share the same menu weight (%d).",
menu_name,
entries[i].name,
entries[i + 1].name,
entries[i].weight,
)
}
}
}
warn_all_duplicate_weights :: proc(site: ^Site) {
for menu_name, entries in site.menus {
warn_duplicate_weights(menu_name, entries)
}
}
// parse_config_menus converts raw JSON from thor.json into map[string][]Menu_Entry.
// Entries are sorted by weight, then name.
parse_config_menus :: proc(
raw: json.Value,
allocator := context.allocator,
) -> map[string][]Menu_Entry {
obj, ok := raw.(json.Object)
if !ok || len(obj) == 0 {
return nil
}
result := make(map[string][]Menu_Entry, allocator)
for menu_name, menu_val in obj {
arr, ok := menu_val.(json.Array)
if !ok {
log.warnf("menus: '%s' is not an array, skipping", menu_name)
continue
}
entries := make([dynamic]Menu_Entry, 0, len(arr), allocator)
for item, idx in arr {
entry_obj, ok := item.(json.Object)
if !ok {
log.warnf("menus: '%s' entry %d is not an object, skipping", menu_name, idx)
continue
}
name := ""
url := ""
weight: Maybe(int) = nil
if v, ok := entry_obj["name"]; ok {
if s, ok2 := v.(json.String); ok2 {
name = string(s)
} else {
log.warnf(
"menus: '%s' entry %d: 'name' must be a string, got %v, skipping",
menu_name,
idx,
v,
)
continue
}
}
if v, ok := entry_obj["url"]; ok {
if s, ok2 := v.(json.String); ok2 {
url = string(s)
} else {
log.warnf(
"menus: '%s' entry %d: 'url' must be a string, got %v, skipping",
menu_name,
idx,
v,
)
continue
}
}
if v, ok := entry_obj["weight"]; ok {
switch wval in v {
case json.Integer:
weight = int(wval)
case json.Float:
weight = int(wval)
case json.Null, json.Boolean, json.String, json.Array, json.Object:
log.warnf(
"menus: '%s' entry %d: 'weight' must be a number, got %v",
menu_name,
idx,
v,
)
}
}
if name == "" {
log.warnf("menus: '%s' entry %d missing 'name', skipping", menu_name, idx)
continue
}
append(&entries, Menu_Entry{name = name, url = url, weight = weight})
}
sort_menu_entries(entries[:])
result[menu_name] = entries[:]
}
return result
}
+517
View File
@@ -0,0 +1,517 @@
#+test
package main
import "core:encoding/json"
import "core:log"
import "core:mem"
import "core:os"
import "core:strings"
import "core:testing"
make_page :: proc(title: string, permalink: string) -> Page {
return Page{title = title, permalink = permalink}
}
parse_raw :: proc(s: string) -> json.Value {
v, _ := json.parse_string(s, spec = .JSON)
return v
}
@(test)
test_menus_string_form :: proc(t: ^testing.T) {
arena: mem.Dynamic_Arena
mem.dynamic_arena_init(&arena)
defer mem.dynamic_arena_destroy(&arena)
context.allocator = mem.dynamic_arena_allocator(&arena)
page := make_page("About", "/about/")
menus := parse_page_menus(parse_raw(`"main"`), page, context.allocator)
testing.expect(t, len(menus) == 1, "expected 1 menu")
entry, ok := menus["main"]
testing.expect(t, ok, "expected 'main' menu")
testing.expect_value(t, entry.name, "About")
testing.expect_value(t, entry.url, "/about/")
testing.expect(t, entry.weight == nil, "string form should have nil weight")
}
@(test)
test_menus_array_form :: proc(t: ^testing.T) {
arena: mem.Dynamic_Arena
mem.dynamic_arena_init(&arena)
defer mem.dynamic_arena_destroy(&arena)
context.allocator = mem.dynamic_arena_allocator(&arena)
page := make_page("Contact", "/contact/")
menus := parse_page_menus(parse_raw(`["main", "footer"]`), page, context.allocator)
testing.expect(t, len(menus) == 2, "expected 2 menus")
main, ok1 := menus["main"]
testing.expect(t, ok1)
testing.expect_value(t, main.name, "Contact")
footer, ok2 := menus["footer"]
testing.expect(t, ok2)
testing.expect_value(t, footer.name, "Contact")
}
@(test)
test_menus_object_with_weight :: proc(t: ^testing.T) {
arena: mem.Dynamic_Arena
mem.dynamic_arena_init(&arena)
defer mem.dynamic_arena_destroy(&arena)
context.allocator = mem.dynamic_arena_allocator(&arena)
page := make_page("Posts", "/posts/")
menus := parse_page_menus(parse_raw(`{"main": {"weight": 30}}`), page, context.allocator)
testing.expect(t, len(menus) == 1)
entry, ok := menus["main"]
testing.expect(t, ok)
testing.expect_value(t, entry.weight, 30)
testing.expect_value(t, entry.name, "Posts")
}
@(test)
test_menus_object_no_weight :: proc(t: ^testing.T) {
arena: mem.Dynamic_Arena
mem.dynamic_arena_init(&arena)
defer mem.dynamic_arena_destroy(&arena)
context.allocator = mem.dynamic_arena_allocator(&arena)
page := make_page("About", "/about/")
menus := parse_page_menus(parse_raw(`{"main": {}}`), page, context.allocator)
testing.expect(t, len(menus) == 1)
entry, ok := menus["main"]
testing.expect(t, ok)
testing.expect(t, entry.weight == nil, "object without weight key should have nil weight")
}
@(test)
test_menus_nil_input :: proc(t: ^testing.T) {
arena: mem.Dynamic_Arena
mem.dynamic_arena_init(&arena)
defer mem.dynamic_arena_destroy(&arena)
context.allocator = mem.dynamic_arena_allocator(&arena)
page := make_page("Test", "/test/")
menus := parse_page_menus(nil, page, context.allocator)
testing.expect(t, menus == nil, "nil input should return nil")
}
@(test)
test_menus_invalid_type :: proc(t: ^testing.T) {
context.logger = log.nil_logger()
arena: mem.Dynamic_Arena
mem.dynamic_arena_init(&arena)
defer mem.dynamic_arena_destroy(&arena)
context.allocator = mem.dynamic_arena_allocator(&arena)
page := make_page("Test", "/test/")
menus := parse_page_menus(parse_raw(`42`), page, context.allocator)
testing.expect(t, menus == nil, "integer should return nil")
}
@(test)
test_menus_array_non_string :: proc(t: ^testing.T) {
context.logger = log.nil_logger()
arena: mem.Dynamic_Arena
mem.dynamic_arena_init(&arena)
defer mem.dynamic_arena_destroy(&arena)
context.allocator = mem.dynamic_arena_allocator(&arena)
page := make_page("Test", "/test/")
menus := parse_page_menus(parse_raw(`["main", 42, "footer"]`), page, context.allocator)
testing.expect(t, len(menus) == 2, "42 should be dropped")
_, ok1 := menus["main"]
testing.expect(t, ok1)
_, ok2 := menus["footer"]
testing.expect(t, ok2)
}
@(test)
test_menus_object_non_object_value :: proc(t: ^testing.T) {
context.logger = log.nil_logger()
arena: mem.Dynamic_Arena
mem.dynamic_arena_init(&arena)
defer mem.dynamic_arena_destroy(&arena)
context.allocator = mem.dynamic_arena_allocator(&arena)
page := make_page("Test", "/test/")
menus := parse_page_menus(parse_raw(`{"main": "oops"}`), page, context.allocator)
testing.expect(t, len(menus) == 1, "entry created with defaults")
entry, ok := menus["main"]
testing.expect(t, ok)
testing.expect(t, entry.weight == nil, "non-object value should have nil weight")
}
@(test)
test_menus_non_numeric_weight :: proc(t: ^testing.T) {
context.logger = log.nil_logger()
arena: mem.Dynamic_Arena
mem.dynamic_arena_init(&arena)
defer mem.dynamic_arena_destroy(&arena)
context.allocator = mem.dynamic_arena_allocator(&arena)
page := make_page("Test", "/test/")
menus := parse_page_menus(parse_raw(`{"main": {"weight": "30"}}`), page, context.allocator)
entry, ok := menus["main"]
testing.expect(t, ok)
testing.expect(t, entry.weight == nil, "non-numeric weight should have nil weight")
}
@(test)
test_menus_empty_title :: proc(t: ^testing.T) {
context.logger = log.nil_logger()
arena: mem.Dynamic_Arena
mem.dynamic_arena_init(&arena)
defer mem.dynamic_arena_destroy(&arena)
context.allocator = mem.dynamic_arena_allocator(&arena)
page := make_page("", "/test/")
menus := parse_page_menus(parse_raw(`"main"`), page, context.allocator)
testing.expect(t, len(menus) == 1)
entry, ok := menus["main"]
testing.expect(t, ok)
testing.expect_value(t, entry.name, "")
}
@(test)
test_menus_object_with_float_weight :: proc(t: ^testing.T) {
arena: mem.Dynamic_Arena
mem.dynamic_arena_init(&arena)
defer mem.dynamic_arena_destroy(&arena)
context.allocator = mem.dynamic_arena_allocator(&arena)
page := make_page("Test", "/test/")
menus := parse_page_menus(parse_raw(`{"main": {"weight": 15.0}}`), page, context.allocator)
entry, ok := menus["main"]
testing.expect(t, ok)
testing.expect_value(t, entry.weight, 15)
}
@(test)
test_menus_null_json :: proc(t: ^testing.T) {
arena: mem.Dynamic_Arena
mem.dynamic_arena_init(&arena)
defer mem.dynamic_arena_destroy(&arena)
context.allocator = mem.dynamic_arena_allocator(&arena)
page := make_page("Test", "/test/")
menus := parse_page_menus(parse_raw(`null`), page, context.allocator)
testing.expect(t, menus == nil, "null JSON should return nil")
}
// --- sort_menu_entries tests ---
@(test)
test_sort_weight_orders_correctly :: proc(t: ^testing.T) {
entries := []Menu_Entry {
{name = "Zeta", url = "/z/"},
{name = "Alpha", url = "/a/", weight = 5},
{name = "Beta", url = "/b/", weight = 1},
}
sort_menu_entries(entries)
// weight 1 first, then weight 5, then nil (DEFAULT_WEIGHT)
testing.expect_value(t, entries[0].name, "Beta")
testing.expect_value(t, entries[1].name, "Alpha")
testing.expect_value(t, entries[2].name, "Zeta")
}
@(test)
test_sort_equal_weights_alphabetical :: proc(t: ^testing.T) {
entries := []Menu_Entry {
{name = "Zebra", url = "/z/"},
{name = "Apple", url = "/a/"},
{name = "Mango", url = "/m/"},
}
sort_menu_entries(entries)
testing.expect_value(t, entries[0].name, "Apple")
testing.expect_value(t, entries[1].name, "Mango")
testing.expect_value(t, entries[2].name, "Zebra")
}
@(test)
test_sort_mixed_weights :: proc(t: ^testing.T) {
entries := []Menu_Entry {
{name = "Charlie", url = "/c/"},
{name = "Alpha", url = "/a/"},
{name = "Bravo", url = "/b/", weight = 3},
{name = "Delta", url = "/d/", weight = 1},
}
sort_menu_entries(entries)
// weight 1 (Delta), weight 3 (Bravo), then nil weight alphabetical (Alpha, Charlie)
testing.expect_value(t, entries[0].name, "Delta")
testing.expect_value(t, entries[1].name, "Bravo")
testing.expect_value(t, entries[2].name, "Alpha")
testing.expect_value(t, entries[3].name, "Charlie")
}
@(test)
test_sort_explicit_zero_before_nil :: proc(t: ^testing.T) {
// Explicit weight 0 is distinguishable from unset (nil → DEFAULT_WEIGHT).
// This is the key behavioral improvement of Maybe(int).
entries := []Menu_Entry {
{name = "Unset", url = "/u/"},
{name = "ExplicitZero", url = "/0/", weight = 0},
{name = "ExplicitFive", url = "/5/", weight = 5},
}
sort_menu_entries(entries)
// weight 0 first, then weight 5, then nil (DEFAULT_WEIGHT = 10)
testing.expect_value(t, entries[0].name, "ExplicitZero")
testing.expect_value(t, entries[1].name, "ExplicitFive")
testing.expect_value(t, entries[2].name, "Unset")
}
@(test)
test_config_weight_parsing_and_sort :: proc(t: ^testing.T) {
arena: mem.Dynamic_Arena
mem.dynamic_arena_init(&arena)
defer mem.dynamic_arena_destroy(&arena)
context.allocator = mem.dynamic_arena_allocator(&arena)
raw := parse_raw(
`{
"main": [
{"name": "Heavy", "url": "/h/", "weight": 20},
{"name": "Light", "url": "/l/", "weight": 1},
{"name": "Default", "url": "/d/"}
]
}`,
)
menus := parse_config_menus(raw, context.allocator)
main, ok := menus["main"]
testing.expect(t, ok)
testing.expect(t, len(main) == 3)
testing.expect_value(t, main[0].name, "Light")
testing.expect_value(t, main[0].weight, 1)
testing.expect_value(t, main[1].name, "Default")
testing.expect(t, main[1].weight == nil, "entry without weight should be nil")
testing.expect_value(t, main[2].name, "Heavy")
testing.expect_value(t, main[2].weight, 20)
}
// --- json_get_int tests ---
@(test)
test_json_get_int_integer :: proc(t: ^testing.T) {
obj, _ := json.parse_string(`{"weight": 5}`, spec = .JSON)
defer json.destroy_value(obj)
o, _ := obj.(json.Object)
testing.expect_value(t, json_get_int(o, "weight"), 5)
}
@(test)
test_json_get_int_float :: proc(t: ^testing.T) {
obj, _ := json.parse_string(`{"weight": 5.0}`, spec = .JSON)
defer json.destroy_value(obj)
o, _ := obj.(json.Object)
testing.expect_value(t, json_get_int(o, "weight"), 5)
}
@(test)
test_json_get_int_missing :: proc(t: ^testing.T) {
obj, _ := json.parse_string(`{}`, spec = .JSON)
defer json.destroy_value(obj)
o, _ := obj.(json.Object)
testing.expect(t, json_get_int(o, "weight") == nil, "missing key should return nil")
}
@(test)
test_json_get_int_non_numeric :: proc(t: ^testing.T) {
obj, _ := json.parse_string(`{"weight": "5"}`, spec = .JSON)
defer json.destroy_value(obj)
o, _ := obj.(json.Object)
testing.expect(t, json_get_int(o, "weight") == nil, "non-numeric should return nil")
}
// --- sort_pages tests ---
@(test)
test_sort_pages_weight_primary :: proc(t: ^testing.T) {
pages := make(#soa[dynamic]Page, 0, 3)
defer delete(pages)
append(&pages, Page{title = "Gamma", date = "2025-01-03"})
append(&pages, Page{title = "Alpha", date = "2025-01-01", weight = 5})
append(&pages, Page{title = "Beta", date = "2025-01-02", weight = 1})
sort_pages(pages[:])
// weight 1, weight 5, then nil weight (DEFAULT_WEIGHT)
testing.expect_value(t, pages.title[0], "Beta")
testing.expect_value(t, pages.title[1], "Alpha")
testing.expect_value(t, pages.title[2], "Gamma")
}
@(test)
test_sort_pages_equal_weights_by_date :: proc(t: ^testing.T) {
pages := make(#soa[dynamic]Page, 0, 3)
defer delete(pages)
append(&pages, Page{title = "Old", date = "2025-01-01"})
append(&pages, Page{title = "New", date = "2025-06-01"})
append(&pages, Page{title = "Mid", date = "2025-03-01"})
sort_pages(pages[:])
// All nil weight → date descending
testing.expect_value(t, pages.title[0], "New")
testing.expect_value(t, pages.title[1], "Mid")
testing.expect_value(t, pages.title[2], "Old")
}
@(test)
test_sort_pages_mixed :: proc(t: ^testing.T) {
pages := make(#soa[dynamic]Page, 0, 4)
defer delete(pages)
append(&pages, Page{title = "DefaultOld", date = "2025-01-01"})
append(&pages, Page{title = "DefaultNew", date = "2025-06-01"})
append(&pages, Page{title = "Heavy", date = "2025-03-01", weight = 20})
append(&pages, Page{title = "Light", date = "2025-02-01", weight = 1})
sort_pages(pages[:])
// weight 1, nil weight (DefaultNew by date), nil weight (DefaultOld by date), weight 20
testing.expect_value(t, pages.title[0], "Light")
testing.expect_value(t, pages.title[1], "DefaultNew")
testing.expect_value(t, pages.title[2], "DefaultOld")
testing.expect_value(t, pages.title[3], "Heavy")
}
// --- merge_page_menus effective weight tests ---
@(test)
test_merge_page_menus_weight_fallback :: proc(t: ^testing.T) {
site: Site
mem.dynamic_arena_init(&site.arena)
defer mem.dynamic_arena_destroy(&site.arena)
context.allocator = site_allocator(&site)
page := make_page("Test", "/test/")
page.weight = 3
page.menus = parse_page_menus(parse_raw(`"main"`), page, site_allocator(&site))
site.pages = make(#soa[dynamic]Page, 0, 1, site_allocator(&site))
append(&site.pages, page)
// Don't call collect_auto_menus — test merge_page_menus in isolation
merge_page_menus(&site)
main, ok := site.menus["main"]
testing.expect(t, ok)
testing.expect(t, len(main) == 1, "expected exactly 1 entry")
testing.expect_value(t, main[0].name, "Test")
testing.expect_value(t, main[0].weight, 3)
}
@(test)
test_auto_menus_no_duplicate_with_frontmatter :: proc(t: ^testing.T) {
site: Site
mem.dynamic_arena_init(&site.arena)
defer mem.dynamic_arena_destroy(&site.arena)
context.allocator = site_allocator(&site)
// Root-level page with explicit "menus": "main"
page := make_page("Ideas", "/ideas/")
page.menus = parse_page_menus(parse_raw(`"main"`), page, site_allocator(&site))
site.pages = make(#soa[dynamic]Page, 0, 1, site_allocator(&site))
append(&site.pages, page)
collect_auto_menus(&site)
merge_page_menus(&site)
main, ok := site.menus["main"]
testing.expect(t, ok)
testing.expect(t, len(main) == 1, "expected exactly 1 entry (no duplicate)")
testing.expect_value(t, main[0].name, "Ideas")
}
// --- warn_duplicate_weights tests ---
@(test)
test_warn_duplicate_weights_explicit :: proc(t: ^testing.T) {
path := "/tmp/thor_test_warn_explicit.log"
os.remove(path)
f, err := os.open(path, os.O_RDWR | os.O_CREATE | os.O_TRUNC)
if err != nil {
testing.expect(t, false, "failed to open temp log file")
return
}
logger := log.create_file_logger(f)
context.logger = logger
entries := []Menu_Entry {
{name = "Alpha", url = "/a/", weight = 5},
{name = "Beta", url = "/b/", weight = 5},
}
warn_duplicate_weights("main", entries)
log.destroy_file_logger(logger)
data, _ := os.read_entire_file_from_path(path, context.temp_allocator)
output := string(data)
os.remove(path)
testing.expect(t, strings.contains(output, "duplicate weight 5"), "expected weight in warning")
testing.expect(t, strings.contains(output, "Alpha"), "expected first entry name")
testing.expect(t, strings.contains(output, "Beta"), "expected second entry name")
testing.expect(t, strings.contains(output, "'main'"), "expected menu name in warning")
}
@(test)
test_warn_duplicate_weights_nil_not_flagged :: proc(t: ^testing.T) {
path := "/tmp/thor_test_warn_nil.log"
os.remove(path)
f, err := os.open(path, os.O_RDWR | os.O_CREATE | os.O_TRUNC)
if err != nil {
testing.expect(t, false, "failed to open temp log file")
return
}
logger := log.create_file_logger(f)
context.logger = logger
entries := []Menu_Entry{{name = "Alpha", url = "/a/"}, {name = "Beta", url = "/b/"}}
warn_duplicate_weights("main", entries)
log.destroy_file_logger(logger)
data, _ := os.read_entire_file_from_path(path, context.temp_allocator)
output := string(data)
os.remove(path)
testing.expect(t, output == "", "nil-weight entries should not produce warnings")
}
@(test)
test_warn_duplicate_weights_explicit_default :: proc(t: ^testing.T) {
path := "/tmp/thor_test_warn_default.log"
os.remove(path)
f, err := os.open(path, os.O_RDWR | os.O_CREATE | os.O_TRUNC)
if err != nil {
testing.expect(t, false, "failed to open temp log file")
return
}
logger := log.create_file_logger(f)
context.logger = logger
entries := []Menu_Entry {
{name = "Alpha", url = "/a/", weight = 10},
{name = "Beta", url = "/b/", weight = 10},
}
warn_duplicate_weights("main", entries)
log.destroy_file_logger(logger)
data, _ := os.read_entire_file_from_path(path, context.temp_allocator)
output := string(data)
os.remove(path)
testing.expect(
t,
strings.contains(output, "duplicate weight 10"),
"explicit weight 10 (== DEFAULT_WEIGHT) should warn — this is the Maybe(int) win",
)
}
+16 -16
View File
@@ -108,22 +108,22 @@ collect_html_ranges :: proc(
type_str := string(ts.node_type(child)) type_str := string(ts.node_type(child))
if type_str == "comment" { if type_str == "comment" {
append(comments, Range{ append(
start = ts.node_start_byte(child), comments,
end = ts.node_end_byte(child), Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
}) )
} else if type_str == "script_element" || type_str == "style_element" { } else if type_str == "script_element" || type_str == "style_element" {
append(preserves, Range{ append(
start = ts.node_start_byte(child), preserves,
end = ts.node_end_byte(child), Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
}) )
} else if type_str == "element" { } else if type_str == "element" {
tag := html_tag_name(child, source) tag := html_tag_name(child, source)
if is_preserve_tag(tag) { if is_preserve_tag(tag) {
append(preserves, Range{ append(
start = ts.node_start_byte(child), preserves,
end = ts.node_end_byte(child), Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
}) )
} else { } else {
collect_html_ranges(child, source, comments, preserves) collect_html_ranges(child, source, comments, preserves)
} }
@@ -244,10 +244,10 @@ collect_css_comments :: proc(node: ts.Node, comments: ^[dynamic]Range) {
for i in 0 ..< child_count { for i in 0 ..< child_count {
child := ts.node_named_child(node, u32(i)) child := ts.node_named_child(node, u32(i))
if string(ts.node_type(child)) == "comment" { if string(ts.node_type(child)) == "comment" {
append(comments, Range{ append(
start = ts.node_start_byte(child), comments,
end = ts.node_end_byte(child), Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
}) )
} else { } else {
collect_css_comments(child, comments) collect_css_comments(child, comments)
} }
+1 -1
View File
@@ -58,7 +58,7 @@ Errors (returned as `Data_Error` at render time):
#### `format` #### `format`
Formats an ISO 8601 date string as a display string. Takes a string, returns a string (e.g. `"2026-03-15T08:49:54-04:00"``"15 Mar 2026"`). Invalid input (empty, too-short, non-string, or unparseable) returns a `Data_Error`. Templates that need to skip dateless pages should gate with a section — `{{#date}}<time datetime="{{.}}">{{. | format}}</time>{{/date}}` — so the section's truthiness check catches empty before the filter runs. Commonly used inline as `{{date | format}}` to render a display string while keeping the raw ISO available via `{{date}}` for the `datetime=` attribute. Formats an ISO 8601 date string as a display string. Takes a string, returns a string (e.g. `"2026-03-15T08:49:54-04:00"``"15 Mar 2026"`). Invalid input (empty, too-short, non-string, or unparsable) returns a `Data_Error`. Templates that need to skip dateless pages should gate with a section — `{{#date}}<time datetime="{{.}}">{{. | format}}</time>{{/date}}` — so the section's truthiness check catches empty before the filter runs. Commonly used inline as `{{date | format}}` to render a display string while keeping the raw ISO available via `{{date}}` for the `datetime=` attribute.
Internally: parses the invariant `YYYY-MM-DD` prefix by char offset, stringifies `time.Month(month_num)` and slices `[:3]` for the abbreviation. Accepts any of these ISO 8601 forms (the date prefix is what matters): `2023-10-15T13:18:50-07:00`, `2023-10-15T13:18:50-0700`, `2023-10-15T13:18:50Z`, `2023-10-15T13:18:50`, `2023-10-15`. Internally: parses the invariant `YYYY-MM-DD` prefix by char offset, stringifies `time.Month(month_num)` and slices `[:3]` for the abbreviation. Accepts any of these ISO 8601 forms (the date prefix is what matters): `2023-10-15T13:18:50-07:00`, `2023-10-15T13:18:50-0700`, `2023-10-15T13:18:50Z`, `2023-10-15T13:18:50`, `2023-10-15`.
-1
View File
@@ -302,4 +302,3 @@ write_value :: proc(b: ^strings.Builder, a: any, escape: bool) {
strings.write_string(b, s[start:]) strings.write_string(b, s[start:])
} }
} }
-1
View File
@@ -320,4 +320,3 @@ format_render_error :: proc(err: Error, tmpl: Template, colorize: bool = false)
b := body(err) b := body(err)
return format_error(path, tmpl.source, b.pos, b.msg, colorize = colorize) return format_error(path, tmpl.source, b.pos, b.msg, colorize = colorize)
} }
-1
View File
@@ -557,4 +557,3 @@ test_parse_error_pipe_parse_in_inverted_keeps_double_braces :: proc(t: ^testing.
fmt.tprintf("msg should contain literal '{{^', got %q", b.msg), fmt.tprintf("msg should contain literal '{{^', got %q", b.msg),
) )
} }
-1
View File
@@ -321,4 +321,3 @@ convert_to_tz :: proc(
}, },
true true
} }
+111 -22
View File
@@ -104,38 +104,64 @@ test_parse_offset_skips_fractional_seconds :: proc(t: ^testing.T) {
@(test) @(test)
test_format_date_weekday_full :: proc(t: ^testing.T) { test_format_date_weekday_full :: proc(t: ^testing.T) {
// 2026-01-01 is a Thursday. // 2026-01-01 is a Thursday.
dt := Date_Components{year = 2026, month = 1, day = 1} dt := Date_Components {
year = 2026,
month = 1,
day = 1,
}
result := format_date(dt, "Monday") result := format_date(dt, "Monday")
testing.expect_value(t, result, "Thursday") testing.expect_value(t, result, "Thursday")
} }
@(test) @(test)
test_format_date_weekday_abbr :: proc(t: ^testing.T) { test_format_date_weekday_abbr :: proc(t: ^testing.T) {
dt := Date_Components{year = 2026, month = 1, day = 1} dt := Date_Components {
year = 2026,
month = 1,
day = 1,
}
result := format_date(dt, "Mon") result := format_date(dt, "Mon")
testing.expect_value(t, result, "Thu") testing.expect_value(t, result, "Thu")
} }
@(test) @(test)
test_format_date_month_full_name :: proc(t: ^testing.T) { test_format_date_month_full_name :: proc(t: ^testing.T) {
dt := Date_Components{year = 2026, month = 3, day = 15} dt := Date_Components {
year = 2026,
month = 3,
day = 15,
}
result := format_date(dt, "January") result := format_date(dt, "January")
testing.expect_value(t, result, "March") testing.expect_value(t, result, "March")
} }
@(test) @(test)
test_format_date_two_digit_year :: proc(t: ^testing.T) { test_format_date_two_digit_year :: proc(t: ^testing.T) {
dt := Date_Components{year = 2026, month = 3, day = 15} dt := Date_Components {
year = 2026,
month = 3,
day = 15,
}
result := format_date(dt, "06") result := format_date(dt, "06")
testing.expect_value(t, result, "26") testing.expect_value(t, result, "26")
} }
@(test) @(test)
test_format_date_hour24_padded :: proc(t: ^testing.T) { test_format_date_hour24_padded :: proc(t: ^testing.T) {
midnight := Date_Components{year = 2026, month = 1, day = 1, hour = 0} midnight := Date_Components {
year = 2026,
month = 1,
day = 1,
hour = 0,
}
testing.expect_value(t, format_date(midnight, "15"), "00") testing.expect_value(t, format_date(midnight, "15"), "00")
afternoon := Date_Components{year = 2026, month = 1, day = 1, hour = 13} afternoon := Date_Components {
year = 2026,
month = 1,
day = 1,
hour = 13,
}
testing.expect_value(t, format_date(afternoon, "15"), "13") testing.expect_value(t, format_date(afternoon, "15"), "13")
} }
@@ -147,7 +173,12 @@ test_format_date_hour12_padded_am_pm_boundaries :: proc(t: ^testing.T) {
}{{0, "12 AM"}, {12, "12 PM"}, {13, "01 PM"}, {23, "11 PM"}} }{{0, "12 AM"}, {12, "12 PM"}, {13, "01 PM"}, {23, "11 PM"}}
for &c in cases { for &c in cases {
dt := Date_Components{year = 2026, month = 1, day = 1, hour = c.hour} dt := Date_Components {
year = 2026,
month = 1,
day = 1,
hour = c.hour,
}
result := format_date(dt, "03 PM") result := format_date(dt, "03 PM")
testing.expect_value(t, result, c.expected) testing.expect_value(t, result, c.expected)
} }
@@ -155,32 +186,62 @@ test_format_date_hour12_padded_am_pm_boundaries :: proc(t: ^testing.T) {
@(test) @(test)
test_format_date_hour12_unpadded :: proc(t: ^testing.T) { test_format_date_hour12_unpadded :: proc(t: ^testing.T) {
one_am := Date_Components{year = 2026, month = 1, day = 1, hour = 1} one_am := Date_Components {
year = 2026,
month = 1,
day = 1,
hour = 1,
}
testing.expect_value(t, format_date(one_am, "3"), "1") testing.expect_value(t, format_date(one_am, "3"), "1")
one_pm := Date_Components{year = 2026, month = 1, day = 1, hour = 13} one_pm := Date_Components {
year = 2026,
month = 1,
day = 1,
hour = 13,
}
testing.expect_value(t, format_date(one_pm, "3"), "1") testing.expect_value(t, format_date(one_pm, "3"), "1")
} }
@(test) @(test)
test_format_date_am_pm_lowercase :: proc(t: ^testing.T) { test_format_date_am_pm_lowercase :: proc(t: ^testing.T) {
afternoon := Date_Components{year = 2026, month = 1, day = 1, hour = 13} afternoon := Date_Components {
year = 2026,
month = 1,
day = 1,
hour = 13,
}
testing.expect_value(t, format_date(afternoon, "pm"), "pm") testing.expect_value(t, format_date(afternoon, "pm"), "pm")
morning := Date_Components{year = 2026, month = 1, day = 1, hour = 9} morning := Date_Components {
year = 2026,
month = 1,
day = 1,
hour = 9,
}
testing.expect_value(t, format_date(morning, "pm"), "am") testing.expect_value(t, format_date(morning, "pm"), "am")
} }
@(test) @(test)
test_format_date_minute_second_padding :: proc(t: ^testing.T) { test_format_date_minute_second_padding :: proc(t: ^testing.T) {
dt := Date_Components{year = 2026, month = 1, day = 1, minute = 4, second = 5} dt := Date_Components {
year = 2026,
month = 1,
day = 1,
minute = 4,
second = 5,
}
testing.expect_value(t, format_date(dt, "04:05"), "04:05") testing.expect_value(t, format_date(dt, "04:05"), "04:05")
testing.expect_value(t, format_date(dt, "4:5"), "4:5") testing.expect_value(t, format_date(dt, "4:5"), "4:5")
} }
@(test) @(test)
test_format_date_month_day_numeric_padding :: proc(t: ^testing.T) { test_format_date_month_day_numeric_padding :: proc(t: ^testing.T) {
dt := Date_Components{year = 2026, month = 3, day = 5} dt := Date_Components {
year = 2026,
month = 3,
day = 5,
}
testing.expect_value(t, format_date(dt, "01"), "03") testing.expect_value(t, format_date(dt, "01"), "03")
testing.expect_value(t, format_date(dt, "1"), "3") testing.expect_value(t, format_date(dt, "1"), "3")
testing.expect_value(t, format_date(dt, "02"), "05") testing.expect_value(t, format_date(dt, "02"), "05")
@@ -191,14 +252,23 @@ test_format_date_month_day_numeric_padding :: proc(t: ^testing.T) {
test_format_date_mst_defaults_utc :: proc(t: ^testing.T) { test_format_date_mst_defaults_utc :: proc(t: ^testing.T) {
// Date_Components constructed directly (not via parse_iso_date) // Date_Components constructed directly (not via parse_iso_date)
// defaults to UTC for the MST token. // defaults to UTC for the MST token.
dt := Date_Components{year = 2026, month = 1, day = 1, hour = 12} dt := Date_Components {
year = 2026,
month = 1,
day = 1,
hour = 12,
}
result := format_date(dt, "MST") result := format_date(dt, "MST")
testing.expect_value(t, result, "UTC") testing.expect_value(t, result, "UTC")
} }
@(test) @(test)
test_format_date_literal_passthrough :: proc(t: ^testing.T) { test_format_date_literal_passthrough :: proc(t: ^testing.T) {
dt := Date_Components{year = 2026, month = 1, day = 1} dt := Date_Components {
year = 2026,
month = 1,
day = 1,
}
result := format_date(dt, "Year: 2006!") result := format_date(dt, "Year: 2006!")
testing.expect_value(t, result, "Year: 2026!") testing.expect_value(t, result, "Year: 2026!")
} }
@@ -206,7 +276,14 @@ test_format_date_literal_passthrough :: proc(t: ^testing.T) {
@(test) @(test)
test_format_date_combined_go_reference_layout :: proc(t: ^testing.T) { test_format_date_combined_go_reference_layout :: proc(t: ^testing.T) {
// 2023-10-15 is a Sunday. // 2023-10-15 is a Sunday.
dt := Date_Components{year = 2023, month = 10, day = 15, hour = 13, minute = 18, second = 50} dt := Date_Components {
year = 2023,
month = 10,
day = 15,
hour = 13,
minute = 18,
second = 50,
}
result := format_date(dt, "Mon Jan 2 15:04:05 MST 2006") result := format_date(dt, "Mon Jan 2 15:04:05 MST 2006")
testing.expect_value(t, result, "Sun Oct 15 13:18:50 UTC 2023") testing.expect_value(t, result, "Sun Oct 15 13:18:50 UTC 2023")
} }
@@ -242,8 +319,12 @@ test_convert_to_tz_no_offset_assumes_target :: proc(t: ^testing.T) {
defer timezone.region_destroy(tz, context.temp_allocator) defer timezone.region_destroy(tz, context.temp_allocator)
c := Date_Components { c := Date_Components {
year = 2026, month = 3, day = 15, year = 2026,
hour = 8, minute = 49, second = 54, month = 3,
day = 15,
hour = 8,
minute = 49,
second = 54,
} }
result, ok := convert_to_tz(c, tz) result, ok := convert_to_tz(c, tz)
testing.expect_value(t, ok, true) testing.expect_value(t, ok, true)
@@ -261,8 +342,12 @@ test_convert_to_tz_with_offset_converts :: proc(t: ^testing.T) {
// 2026-03-15T12:49:54Z (UTC) → 08:49:54 EDT (UTC-4) // 2026-03-15T12:49:54Z (UTC) → 08:49:54 EDT (UTC-4)
c := Date_Components { c := Date_Components {
year = 2026, month = 3, day = 15, year = 2026,
hour = 12, minute = 49, second = 54, month = 3,
day = 15,
hour = 12,
minute = 49,
second = 54,
offset_seconds = 0, offset_seconds = 0,
has_offset = true, has_offset = true,
} }
@@ -282,8 +367,12 @@ test_convert_to_tz_with_negative_offset_converts :: proc(t: ^testing.T) {
// 2026-03-15T08:49:54-04:00 → UTC 12:49:54 → EDT 08:49:54 // 2026-03-15T08:49:54-04:00 → UTC 12:49:54 → EDT 08:49:54
c := Date_Components { c := Date_Components {
year = 2026, month = 3, day = 15, year = 2026,
hour = 8, minute = 49, second = 54, month = 3,
day = 15,
hour = 8,
minute = 49,
second = 54,
offset_seconds = -14400, offset_seconds = -14400,
has_offset = true, has_offset = true,
} }
-1
View File
@@ -152,4 +152,3 @@ test_lambda_inverted_section :: proc(t: ^testing.T) {
} }
*/ */
+6 -3
View File
@@ -78,9 +78,11 @@ Node :: struct {
// 1 for leaf nodes, 1 + len(children) for container nodes (whose children // 1 for leaf nodes, 1 + len(children) for container nodes (whose children
// are stored contiguously after them in the array). // are stored contiguously after them in the array).
node_span :: proc(n: Node) -> int { node_span :: proc(n: Node) -> int {
#partial switch n.kind { switch n.kind {
case .Section, .Inverted, .Parent, .Block: case .Section, .Inverted, .Parent, .Block:
return 1 + len(n.children) return 1 + len(n.children)
case .Text, .Variable, .Unescaped, .Partial:
fallthrough
case: case:
return 1 return 1
} }
@@ -374,7 +376,7 @@ parse_section :: proc(
deindent_blocks :: proc(nodes: []Node, allocator := context.allocator) { deindent_blocks :: proc(nodes: []Node, allocator := context.allocator) {
i := 0 i := 0
for i < len(nodes) { for i < len(nodes) {
#partial switch nodes[i].kind { switch nodes[i].kind {
case .Block: case .Block:
if len(nodes[i].children) > 0 { if len(nodes[i].children) > 0 {
children := nodes[i].children children := nodes[i].children
@@ -401,6 +403,8 @@ deindent_blocks :: proc(nodes: []Node, allocator := context.allocator) {
if len(nodes[i].children) > 0 { if len(nodes[i].children) > 0 {
deindent_blocks(nodes[i].children, allocator) deindent_blocks(nodes[i].children, allocator)
} }
case .Text, .Variable, .Unescaped, .Partial:
// Do nothing
} }
i += node_span(nodes[i]) i += node_span(nodes[i])
} }
@@ -981,4 +985,3 @@ warn_context_depth :: proc(current: Template, node: Node) {
diag := format_error(path, current.source, node.pos, msg, "", colorize = should_colorize()) diag := format_error(path, current.source, node.pos, msg, "", colorize = should_colorize())
log.warnf("%s", diag) log.warnf("%s", diag)
} }
-1
View File
@@ -161,4 +161,3 @@ test_context_depth_warns :: proc(t: ^testing.T) {
testing.expect(t, rerr == nil, "depth warning must be non-fatal") testing.expect(t, rerr == nil, "depth warning must be non-fatal")
testing.expect_value(t, result, "found") testing.expect_value(t, result, "found")
} }
+1 -2
View File
@@ -193,7 +193,7 @@ resolve_format_string :: proc(name: string, ctx: []any, pos: int) -> (string, Er
return str, nil return str, nil
} }
// TODO: diagnostics don't show anything relevent // TODO: diagnostics don't show anything relevant
apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) -> (any, Error) { apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) -> (any, Error) {
switch filter.op { switch filter.op {
case "group_by": case "group_by":
@@ -337,4 +337,3 @@ apply_group_by :: proc(value: any, args: []string, pos: int) -> (result: any, er
return groups, nil return groups, nil
} }
+15 -3
View File
@@ -249,7 +249,11 @@ test_interp_pipe_dot_current :: proc(t: ^testing.T) {
items = {"2026-01-06T00:00:00Z", "2026-06-15T00:00:00Z", "2026-10-15T00:00:00Z"}, items = {"2026-01-06T00:00:00Z", "2026-06-15T00:00:00Z", "2026-10-15T00:00:00Z"},
date_format = "2 Jan 2006", date_format = "2 Jan 2006",
} }
tpl, _ := parse("{{#items}}[{{. | format}}]{{/items}}", "<test>", allocator = context.temp_allocator) tpl, _ := parse(
"{{#items}}[{{. | format}}]{{/items}}",
"<test>",
allocator = context.temp_allocator,
)
result, _ := render(tpl, data, {}, context.temp_allocator) result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "[6 Jan 2026][15 Jun 2026][15 Oct 2026]") testing.expect_value(t, result, "[6 Jan 2026][15 Jun 2026][15 Oct 2026]")
} }
@@ -359,7 +363,11 @@ test_format_inside_section_skips_when_empty :: proc(t: ^testing.T) {
date = "", date = "",
date_format = "2 Jan 2006", date_format = "2 Jan 2006",
} }
tpl, _ := parse("[{{#date}}<time>{{. | format}}</time>{{/date}}]", "<test>", allocator = context.temp_allocator) tpl, _ := parse(
"[{{#date}}<time>{{. | format}}</time>{{/date}}]",
"<test>",
allocator = context.temp_allocator,
)
result, _ := render(tpl, data, {}, context.temp_allocator) result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "[]") testing.expect_value(t, result, "[]")
} }
@@ -374,7 +382,11 @@ test_format_quoted_literal_arg :: proc(t: ^testing.T) {
date = "2026-03-15T08:49:54-04:00", date = "2026-03-15T08:49:54-04:00",
date_format = "2 Jan 2006", date_format = "2 Jan 2006",
} }
tpl, _ := parse(`{{date | format "Jan 2, 2006"}}`, "<test>", allocator = context.temp_allocator) tpl, _ := parse(
`{{date | format "Jan 2, 2006"}}`,
"<test>",
allocator = context.temp_allocator,
)
result, _ := render(tpl, data, {}, context.temp_allocator) result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "Mar 15, 2026") testing.expect_value(t, result, "Mar 15, 2026")
} }
-1
View File
@@ -132,4 +132,3 @@ spec_dynamic_names :: proc(t: ^testing.T) {
spec_inheritance :: proc(t: ^testing.T) { spec_inheritance :: proc(t: ^testing.T) {
run_spec_file(t, "spec/specs/~inheritance.json") run_spec_file(t, "spec/specs/~inheritance.json")
} }
+1 -4
View File
@@ -184,10 +184,7 @@ collect_partial_names :: proc(
// collect_block_names enumerates the unique `{{$name}}` block definitions in // collect_block_names enumerates the unique `{{$name}}` block definitions in
// a template's node array. // a template's node array.
collect_block_names :: proc( collect_block_names :: proc(tmpl: Template, allocator := context.temp_allocator) -> []string {
tmpl: Template,
allocator := context.temp_allocator,
) -> []string {
out := make([dynamic]string, 0, 0, allocator) out := make([dynamic]string, 0, 0, allocator)
seen := make(map[string]bool, allocator) seen := make(map[string]bool, allocator)
defer delete(seen) defer delete(seen)
-1
View File
@@ -196,4 +196,3 @@ test_warn_no_false_positive_for_valid_keys :: proc(t: ^testing.T) {
ok, missing, _ := validate_key_path(ctx[:], "name") ok, missing, _ := validate_key_path(ctx[:], "name")
testing.expect_value(t, ok, true) testing.expect_value(t, ok, true)
} }
+2 -12
View File
@@ -104,11 +104,7 @@ tokenize :: proc(
close_idx := strings.index(src[key_start:], "}}") close_idx := strings.index(src[key_start:], "}}")
if close_idx < 0 { if close_idx < 0 {
return tokens, Error_Body { return tokens, Error_Body{msg = "unclosed tag '{{'", pos = tag_pos, kind = .Syntax}
msg = "unclosed tag '{{'",
pos = tag_pos,
kind = .Syntax,
}
} }
close := key_start + close_idx close := key_start + close_idx
@@ -125,12 +121,7 @@ tokenize :: proc(
} }
append( append(
&tokens, &tokens,
Token { Token{kind = .Partial, value = trimmed, is_dynamic = is_dyn, pos = tag_pos},
kind = .Partial,
value = trimmed,
is_dynamic = is_dyn,
pos = tag_pos,
},
) )
} else { } else {
append( append(
@@ -299,4 +290,3 @@ should_trim_whitespace :: proc(kind: Token_Kind) -> bool {
} }
return false return false
} }
-1
View File
@@ -115,4 +115,3 @@ og_for_page :: proc(site_og: Open_Graph, page: Page) -> Open_Graph {
return og return og
} }
+41 -10
View File
@@ -16,6 +16,7 @@ Template_Context :: struct {
timezone: ^datetime.TZ_Region, timezone: ^datetime.TZ_Region,
og: Open_Graph, og: Open_Graph,
site: Site_Context, site: Site_Context,
menus: map[string][]Menu_Entry,
page: Page, page: Page,
// Home data // Home data
@@ -89,14 +90,31 @@ get_template :: proc(
return mustache.Template{} return mustache.Template{}
} }
capitalize :: proc(s: string) -> string { to_title_case :: proc(s: string, allocator := context.allocator) -> string {
if len(s) == 0 { if len(s) == 0 {
return s return s
} }
if s[0] >= 'a' && s[0] <= 'z' {
return fmt.aprintf("%c%s", s[0] - 32, s[1:]) out := transmute([]byte)strings.clone(s, allocator)
capitalize_next := true
for char, i in s {
switch char {
case '-', '_':
out[i] = ' '
fallthrough
case ' ':
capitalize_next = true
case 'a' ..= 'z':
if capitalize_next {
out[i] = u8(char) - 32
} }
return s fallthrough
case:
capitalize_next = false
}
}
return string(out)
} }
render_template :: proc( render_template :: proc(
@@ -118,7 +136,7 @@ render_template :: proc(
render_site :: proc(site: ^Site) { render_site :: proc(site: ^Site) {
allocator := site_allocator(site) allocator := site_allocator(site)
pages := site.pages[:] pages := site.pages[:]
sort_pages_by_date(pages) sort_pages(pages)
// Load shared resources // Load shared resources
partials := load_partials(&site.vfs) partials := load_partials(&site.vfs)
@@ -134,6 +152,7 @@ render_site :: proc(site: ^Site) {
ctx := Template_Context { ctx := Template_Context {
site = site.site_context, site = site.site_context,
menus = site.menus,
now = now, now = now,
og = site.og, og = site.og,
date_format = site.date.format, date_format = site.date.format,
@@ -281,6 +300,7 @@ render_section :: proc(
partials: map[string]mustache.Template, partials: map[string]mustache.Template,
ctx: Template_Context, ctx: Template_Context,
) -> string { ) -> string {
alloc := site_allocator(site)
posts := make([dynamic]Page, 0, len(site.pages) / 2, context.temp_allocator) posts := make([dynamic]Page, 0, len(site.pages) / 2, context.temp_allocator)
for page in site.pages { for page in site.pages {
if page.section != section || page._is_index { if page.section != section || page._is_index {
@@ -295,11 +315,12 @@ render_section :: proc(
ctx.title = fmt.tprintf("%s | %s", section_index.title, site.title) ctx.title = fmt.tprintf("%s | %s", section_index.title, site.title)
ctx.og = og_for_page(site.og, section_index) ctx.og = og_for_page(site.og, section_index)
} else { } else {
title := to_title_case(section, alloc)
ctx.page = Page { ctx.page = Page {
title = capitalize(section), title = title,
} }
ctx.title = fmt.tprintf("%s | %s", ctx.page.title, site.title) ctx.title = fmt.tprintf("%s | %s", ctx.page.title, site.title)
ctx.og.title = capitalize(section) ctx.og.title = title
ctx.og.description = "" ctx.og.description = ""
ctx.og.url = fmt.tprintf("%s/%s/", site.base_url, section) ctx.og.url = fmt.tprintf("%s/%s/", site.base_url, section)
ctx.og.type = "website" ctx.og.type = "website"
@@ -352,11 +373,12 @@ get_year :: proc(iso: string) -> string {
return iso[:4] return iso[:4]
} }
sort_pages_by_date :: proc(pages: #soa[]Page) { // Weight primary (ascending). Date secondary (descending) for equal weights.
sort_pages :: proc(pages: #soa[]Page) {
for i in 1 ..< len(pages) { for i in 1 ..< len(pages) {
key := pages[i] key := pages[i]
j := i - 1 j := i - 1
for j >= 0 && pages.date[j] < key.date { for j >= 0 && compare_pages(pages, j, key) > 0 {
pages[j + 1] = pages[j] pages[j + 1] = pages[j]
j -= 1 j -= 1
} }
@@ -364,6 +386,16 @@ sort_pages_by_date :: proc(pages: #soa[]Page) {
} }
} }
compare_pages :: proc(pages: #soa[]Page, j: int, key: Page) -> int {
wj := pages.weight[j].? or_else DEFAULT_WEIGHT
wk := key.weight.? or_else DEFAULT_WEIGHT
if wj != wk do return wj - wk
// Equal weight → date descending
if pages.date[j] < key.date do return 1
if pages.date[j] > key.date do return -1
return 0
}
write_page :: proc(output_dir: string, permalink: string, html: string) { write_page :: proc(output_dir: string, permalink: string, html: string) {
rel := permalink rel := permalink
if len(rel) > 0 && rel[0] == '/' { if len(rel) > 0 && rel[0] == '/' {
@@ -385,4 +417,3 @@ write_file :: proc(path: string, html: string) {
log.errorf("cannot write %s: %v", path, err) log.errorf("cannot write %s: %v", path, err)
} }
} }
+11 -1
View File
@@ -20,6 +20,7 @@ Site_Context :: struct {
base_url: string, base_url: string,
params: json.Object, params: json.Object,
og: Open_Graph, og: Open_Graph,
menus: map[string][]Menu_Entry,
} }
// Site is the primary workhorse, containing everything needed to build the site, // Site is the primary workhorse, containing everything needed to build the site,
@@ -68,6 +69,7 @@ Config_File :: struct {
markdown_extensions: json.Value, markdown_extensions: json.Value,
params: json.Value, params: json.Value,
modules: json.Value, modules: json.Value,
menus: json.Value,
og: Open_Graph, og: Open_Graph,
date: Date_Preferences, date: Date_Preferences,
grammars: string, grammars: string,
@@ -200,6 +202,15 @@ site_apply_config :: proc(site: ^Site, config: Config_File, config_dir: string)
site.og = config.og site.og = config.og
site.date = config.date site.date = config.date
// Parse config menus if present (nil = absent, non-nil = present)
if config.menus != nil {
site.menus = parse_config_menus(config.menus, site_allocator(site))
if site.menus == nil {
// Present but empty ({}) — explicit opt-out
site.menus = make(map[string][]Menu_Entry, site_allocator(site))
}
}
site.grammars = expand_path(config.grammars, site_allocator(site)) site.grammars = expand_path(config.grammars, site_allocator(site))
site.queries = expand_path(config.queries, site_allocator(site)) site.queries = expand_path(config.queries, site_allocator(site))
} }
@@ -262,4 +273,3 @@ find_config :: proc(filename: string) -> (path: string, ok: bool) {
dir = dir[:idx] dir = dir[:idx]
} }
} }
+5 -3
View File
@@ -181,12 +181,15 @@ test_init_site_md_enable_disable :: proc(t: ^testing.T) {
@(test) @(test)
test_init_site_config_paths :: proc(t: ^testing.T) { test_init_site_config_paths :: proc(t: ^testing.T) {
path := write_temp_config("paths", `{ path := write_temp_config(
"paths",
`{
"content_dir": "/custom/content", "content_dir": "/custom/content",
"assets_dir": "/custom/assets", "assets_dir": "/custom/assets",
"output_dir": "/custom/output", "output_dir": "/custom/output",
"layouts_dir": "/custom/layouts" "layouts_dir": "/custom/layouts"
}`) }`,
)
defer os.remove(path) defer os.remove(path)
site: Site site: Site
@@ -199,4 +202,3 @@ test_init_site_config_paths :: proc(t: ^testing.T) {
testing.expect_value(t, site.output_dir, "/custom/output") testing.expect_value(t, site.output_dir, "/custom/output")
testing.expect_value(t, site.layouts_dir, "/custom/layouts") testing.expect_value(t, site.layouts_dir, "/custom/layouts")
} }
+8 -1
View File
@@ -1 +1,8 @@
{"params":{"social":[{"name":"github","url":"https://github.com/test"},{"name":"rss","url":"/index.xml"}]}} {
"params": {
"social": [
{ "name": "github", "url": "https://github.com/test" },
{ "name": "rss", "url": "/index.xml" }
]
}
}
+10 -3
View File
@@ -249,7 +249,14 @@ ensure_parser :: proc(lang: string) -> ^Grammar_Cache {
return gc return gc
} }
compile_query :: proc(lang: string, language: Language) -> (query: Query, cursor: Query_Cursor, ok: bool) { compile_query :: proc(
lang: string,
language: Language,
) -> (
query: Query,
cursor: Query_Cursor,
ok: bool,
) {
query_src, query_path, qok := load_query(lang) query_src, query_path, qok := load_query(lang)
if !qok { if !qok {
return return
@@ -262,7 +269,7 @@ compile_query :: proc(lang: string, language: Language) -> (query: Query, cursor
if query == nil { if query == nil {
tok := extract_query_token(transmute([]byte)query_src, err_offset) tok := extract_query_token(transmute([]byte)query_src, err_offset)
cause := fmt.tprintf("query error at byte %d (type %v)", err_offset, err_type) cause := fmt.tprintf("query error at byte %d (type %v)", err_offset, err_type)
#partial switch err_type { switch err_type {
case .NodeType: case .NodeType:
if tok != "" { if tok != "" {
cause = fmt.tprintf( cause = fmt.tprintf(
@@ -288,6 +295,7 @@ compile_query :: proc(lang: string, language: Language) -> (query: Query, cursor
cause = fmt.tprintf("query has an illegal pattern structure at byte %d", err_offset) cause = fmt.tprintf("query has an illegal pattern structure at byte %d", err_offset)
case .Language: case .Language:
cause = "grammar language is null (broken grammar .so)" cause = "grammar language is null (broken grammar .so)"
case .None:
} }
log.errorf("treesitter: %s query failed: %s", lang, cause) log.errorf("treesitter: %s query failed: %s", lang, cause)
@@ -451,4 +459,3 @@ helix_version_from_path :: proc(path: string) -> string {
if end <= start do return "" if end <= start do return ""
return path[start:end] return path[start:end]
} }
+2 -3
View File
@@ -49,7 +49,7 @@ mount_recursive :: proc(vfs: ^VFS, current_dir: string, target_prefix: string) {
defer os.file_info_slice_delete(entries, context.allocator) defer os.file_info_slice_delete(entries, context.allocator)
for entry in entries { for entry in entries {
#partial switch entry.type { switch entry.type {
case .Regular: case .Regular:
virtual := fmt.tprintf("%s/%s", target_prefix, entry.name) virtual := fmt.tprintf("%s/%s", target_prefix, entry.name)
vfs.files[virtual] = VFS_Entry { vfs.files[virtual] = VFS_Entry {
@@ -58,7 +58,7 @@ mount_recursive :: proc(vfs: ^VFS, current_dir: string, target_prefix: string) {
case .Directory: case .Directory:
sub_prefix := fmt.tprintf("%s/%s", target_prefix, entry.name) sub_prefix := fmt.tprintf("%s/%s", target_prefix, entry.name)
mount_recursive(vfs, entry.fullpath, sub_prefix) mount_recursive(vfs, entry.fullpath, sub_prefix)
case: case .Undetermined, .Symlink, .Named_Pipe, .Socket, .Block_Device, .Character_Device:
} }
} }
} }
@@ -109,4 +109,3 @@ vfs_entry_data :: proc(entry: VFS_Entry) -> ([]byte, bool) {
} }
return data, true return data, true
} }