Compare commits

...

33 Commits

Author SHA1 Message Date
Spencer Brower 18733f31d1 feat: Errors are now tracked, so duplicate errors don't report more than once. 2026-07-31 10:15:33 -04:00
Spencer Brower a4db4c2c58 chore: Updated TODOS.md. 2026-07-31 09:33:15 -04:00
Spencer Brower 9e93c012b6 feat: help for misspelled pipes is now shown to the user. 2026-07-31 09:25:38 -04:00
Spencer Brower 0d09db8d26 refactor: Filters are now read as an enum. 2026-07-31 09:24:01 -04:00
Spencer Brower 4948eb05a9 chore: Removed old plan file. 2026-07-31 08:57:36 -04:00
Spencer Brower e71c2a94e5 fix: pipe Typos diagnostic now highlights only the broken pipe name. 2026-07-30 18:34:49 -04:00
Spencer Brower ef7327cc52 fix: typos in pipe arguments (i.e. format) now show a proper diagnostic. 2026-07-30 18:22:53 -04:00
Spencer Brower 7b67201791 chore: Added diagnostics plan. 2026-07-30 14:55:09 -04:00
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
Spencer Brower fb32ce90fc chore: Updated TODOS.md 2026-07-28 17:03:59 -04:00
Spencer Brower 0abac89d88 feat: Warns the user if they exceed 16 context frames. 2026-07-28 16:58:46 -04:00
Spencer Brower 1824bae26c docs: Updated README.md 2026-07-28 13:14:30 -04:00
Spencer Brower 5627215d6f feat: Added site to the template context stack. 2026-07-28 13:07:14 -04:00
Spencer Brower 9d1954d70a feat: page prop now falls through (implicit access). 2026-07-28 12:04:54 -04:00
Spencer Brower fcf349eb51 fix: Removed content from Template_Context.
now accessible only through page.content.
2026-07-28 11:45:55 -04:00
Spencer Brower d51172c836 feat: Added page to template context. 2026-07-28 11:41:21 -04:00
64 changed files with 2345 additions and 614 deletions
+90 -41
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" }
@@ -220,41 +265,31 @@ Templates use Mustache with template inheritance (`{{<base}}` / `{{$block}}`):
<!-- page.html (content layout) --> <!-- page.html (content layout) -->
{{<base}} {{<base}}
{{$main}} {{$main}}
<main><article><h1>{{page_title}}</h1>{{&content}}</article></main> <main><article><h1>{{page.title}}</h1>{{&content}}</article></main>
{{/main}} {{/main}}
{{/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 {
now: string, // UTC ISO 8601 build timestamp site: Site_Context, // site-level data (title, description, base_url, params, og)
params: json.Value, menus: map[string][]Menu_Entry, // generated menu data (copied from site, resolves above Page.menus)
content: string, now: string, // UTC ISO 8601 build timestamp
title: string, title: string, // computed browser title ("Page | Site")
description: string, date_format: string, // from site.date.format (thor.json)
og: Open_Graph, timezone: ^datetime.TZ_Region, // for format pipe
date_format: string, // from site.date.format (thor.json) og: Open_Graph, // computed per-page OG
timezone: ^datetime.TZ_Region, // loaded from site.date.timezone or local, owned by Site page: Page, // current page
} pages: [dynamic]Page, // home page list
Page_Data :: struct { posts: [dynamic]Page, // section post list
using base: Base_Data, // fields promoted via reflection fallback
page_title: string,
date: string, // raw ISO 8601; formatted via `| format` in templates
}
Home_Data :: struct {
using base: Base_Data,
pages: [dynamic]Page_Context,
}
Section_Data :: struct {
using base: Base_Data,
page_title: string,
posts: [dynamic]Page_Context, // flat list; year grouping done in template via pipe
} }
``` ```
`render_site` pre-parses all partials and the base layout once (via `mustache.parse`), then per-layout templates are cached in `get_template`. Year-based grouping on section index pages is done in the template via `{{#posts | group_by year}}` (see Pipes extension below) — there is no `Year_Section` Go-side struct. `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.
+61
View File
@@ -0,0 +1,61 @@
# Diagnostics
Thor has two diagnostic tiers. This document explains why, and when to use each.
## Tier 1: Rust-style rich diagnostics (mustache engine)
`mustache/diagnostic.odin` implements multi-line source context, caret underlines, ANSI colors (gated on TTY detection), and Levenshtein suggestions. Used exclusively by the mustache engine for template errors:
- Unknown keys in `{{k}}`, `{{{k}}}`, `{{#k}}`, `{{^k}}`
- Missing partials (`{{> name}}`)
- Missing parent templates (`{{<name}}`)
- Unmatched block overrides (`{{$name}}`)
- Parse-time syntax errors
These benefit from rich diagnostics because **exact source location matters** — templates have complex syntax, and the user often doesn't know *where* the problem is. The diagnostic system operates on `Template.source` with byte offsets, producing output like:
```
error: unknown key 'titel' in {{page.titel}}
--> layouts/page.html:12:22
|
12 | <h1>{{page.titel}}</h1>
| ^^^^^
|
= hint: did you mean 'title'?
```
## Tier 2: Simple log warnings (content and runtime)
Everything outside the mustache engine uses `log.warnf` — flat one-line messages via `core:log`:
- Missing frontmatter dates (fallback to file mtime)
- Duplicate menu weights
- Non-numeric weight values in frontmatter
- Tree-sitter highlight errors
- Menu system issues (mixing config/frontmatter menus, etc.)
These are simple, actionable, and cross-file. The problem isn't *location* — it's that two files disagree, or a value is missing. A caret pointing at one file doesn't help; the message already communicates what to fix:
```
[WARN] --- [menus.odin:290:warn_duplicate_weights()] menus('main'):'Ideas' and 'Stuff' share the same weight (11).
```
## Why not use rich diagnostics everywhere?
Rust's diagnostic model is built for a single compilation unit with full AST/IR data. Three obstacles prevent reusing it for content warnings:
1. **Source tracking**: The mustache diagnostic system operates on `Template.source` (byte offsets into template strings). Content warnings come from frontmatter in markdown files — different source, different parser, no position tracking. Reusing the system would require building a parallel position-tracking infrastructure for frontmatter.
2. **Cross-file context**: Rust diagnostics point at one location. Weight duplicates are a relationship between two files. Rich diagnostics would need to show *both* file locations, which is more infrastructure for marginal value.
3. **Diminishing returns**: Rust diagnostics shine for syntax/type errors where the user doesn't understand the failure. Content warnings are already self-explanatory — "these two pages share weight 11" doesn't need a caret to be actionable.
## When to upgrade a Tier 2 warning to Tier 1
If a warning's usefulness would significantly improve from showing exact source location (e.g., a frontmatter syntax error where the user needs to see *which line* is malformed), consider extending the diagnostic system to frontmatter. This would require:
1. Position tracking in `frontmatter.odin` (store byte offsets for each parsed field)
2. A `format_frontmatter_error` proc modeled on `format_render_error`
3. File path propagation through the page loading pipeline
This is not currently planned — see `TODOS.md`.
-87
View File
@@ -1,87 +0,0 @@
# Timezone Support
## Goal
Add timezone conversion to the `format` pipe so dates display in the configured site timezone, with correct abbreviations for the `MST` token.
## Context
Dates are stored as raw ISO 8601 strings. The `format` pipe formats them for display using Go-style layout tokens. Currently `parse_iso_date` ignores the timezone offset suffix, and `MST` is hardcoded to `"UTC"`.
Pipes now take `ctx: []any`, so timezone config can flow through the data context identically to `date_format` — no new parameters to thread.
## Decisions
- **MST fallback** (no target tz, date has offset): `UTC-04:00` format
- **`now` field**: intentionally UTC (offset=0). The `format` pipe handles timezone display.
- **TZ_Region cache**: lives in the mustache package (`format.odin`)
## Files changed
| File | Changes |
|---|---|
| `mustache/format.odin` | Extend `Date_Components` (+`offset_seconds`, `has_offset`, `tz_abbr`). Extend `parse_iso_date` to parse trailing offset. Add `tz_cache`, `get_cached_tz`, `convert_to_tz`, `format_offset`, `destroy_tz_cache`. Fix MST token. Import `core:time/timezone`. |
| `mustache/pipes.odin` | Resolve `date_timezone` from ctx in `"format"` case (optional — nil is fine). Add `timezone_name` param to `apply_format`. Orchestrates parse → convert → format. |
| `render.odin` | Add `date_timezone: string` to `Base_Data`, populate from `site.date.timezone`. Remove `// TODO: CAlculate offset` (offset=0 is intentional — UTC). |
| `site.odin` | Call `mustache.destroy_tz_cache()` from `destroy_site`. No structural changes — `Date_Preferences.timezone` already exists and flows through config. |
| `mustache/pipes_test.odin` | Add `date_timezone: string` to test data structs. Add timezone conversion tests. |
## Conversion logic (in `apply_format`)
```
1. parse_iso_date(iso) → components (now includes offset_seconds, has_offset)
2. tz_name := resolve "date_timezone" from ctx (optional)
3. target_tz := get_cached_tz(tz_name) // nil if empty/UTC/not configured
4. if target_tz != nil:
components = convert_to_tz(components, target_tz)
// tz_abbr filled by convert_to_tz via timezone.shortname()
5. else if components.has_offset:
components.tz_abbr = format_offset(components.offset_seconds)
// e.g. "UTC-04:00"
6. else:
components.tz_abbr = "UTC"
7. format_date(components, fmt)
```
## `convert_to_tz` flow
```
1. Build DateTime from components (tz=nil=UTC)
2. If has_offset: add offset_seconds to get true UTC
3. timezone.datetime_to_tz(utc_dt, target_tz) → converted DateTime
4. Extract components from converted DateTime
5. tz_abbr = timezone.shortname(converted_dt) // "EST", "EDT", etc.
```
## MST fallback: `format_offset`
```
0 → "UTC"
-14400 → "UTC-04:00"
+19800 → "UTC+05:30"
```
## `now` field
`now` stays UTC (offset=0). Remove the `// TODO: CAlculate offset` comment — it's correct as-is. Templates format it with `{{now | format}}` and the pipe handles timezone display.
## Behavior matrix
| Config TZ | ISO has offset | Conversion | `MST` output |
|---|---|---|---|
| `"America/New_York"` | yes (`-04:00`) | UTC → NY (DST-aware) | `"EST"`/`"EDT"` |
| `"America/New_York"` | no | assume already in NY | `"EST"`/`"EDT"` |
| not set | yes (`-04:00`) | none — display as-is | `"UTC-04:00"` |
| not set | no | none | `"UTC"` |
## Imports added
- `mustache/format.odin`: `import "core:time/timezone"` (for `region_load`, `datetime_to_tz`, `shortname`, `region_destroy`)
## Odin timezone API reference
- `timezone.region_load(name: string) -> (^datetime.TZ_Region, bool)``"local"` reads `$TZ` env, falls back to `/etc/localtime`
- `timezone.region_destroy(region: ^datetime.TZ_Region)`
- `timezone.datetime_to_tz(dt: DateTime, tz: ^TZ_Region) -> (DateTime, bool)` — DST-aware. If `dt.tz == tz`, no-op. If `dt.tz == nil`, treats as UTC.
- `timezone.shortname(dt: DateTime) -> (string, bool)` — abbreviation from TZ_Region records (e.g. `"EST"`, `"EDT"`)
- `datetime.DateTime :: struct { using date: Date, using time: Time, tz: ^TZ_Region }``tz == nil` means UTC
+317
View File
@@ -0,0 +1,317 @@
# 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.
### G3. Pipe errors lack "did you mean?" suggestions — High
`mustache/pipes.odin:241`
Unknown key errors (`{{tittle}}`), missing partials, and unmatched block
overrides all get Levenshtein "did you mean?" hints via `suggest_correction`.
But unknown pipe operations (`{{date | formats}}`) get only `"unknown pipe op
'formats'"` with no suggestion. The known filter names (`"format"`,
`"group_by"`) are a small fixed set — perfect for suggestions.
Structural gap: `Error_Body` has no `hint` field, and
`format_render_error` doesn't pass `hint` to `format_error` (defaults to
`""`). So even if a suggestion were computed, there's nowhere to put it
without either appending to `msg` or adding `hint` to `Error_Body`.
### G4. Triple-mustache `{{{` mishandled by `tag_content_base` — Medium
`mustache/mustache.odin:230`
`tag_content_base` skips `{{` and sigils (`#^/&><$!`) to find where tag
content begins. But triple-mustache `{{{key}}}` is common — after `{{`,
the next char is `{`, which is not in the sigil list, so `base` points at
`{` instead of the actual key content. Any pipe position calculation for
`{{{key | format}}}` will be off by one byte.
---
## 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.
+8 -9
View File
@@ -1,8 +1,6 @@
# Thor # Thor
[TOC] Thor is a simple Static Site Generator designed for personal blogs and other small websites.
Thor is a simple Static Sire Generator designed for personal blogs and other small websites.
Its core principals are simplicity and minimal configuration, so you can get started as quickly as possible. Its core principals are simplicity and minimal configuration, so you can get started as quickly as possible.
@@ -16,14 +14,14 @@ It is based on Hugo, and gingerbill's SSG. Templating is done with (extended?) M
- Menus (WIP) - Menus (WIP)
- Extended Markdown ([See below](#extended-markdown)) - Extended Markdown ([See below](#extended-markdown))
- Basic (whitespace) minification. - Basic (whitespace) minification.
- Union File System (Modules)
## What it doesn't do ## What it doesn't do
- Internationalization - Internationalization
- Pagination (Yet) - Pagination (Yet)
- Themes - Themes
- Union File System (Yet) - Image Manipulation
- Image Manipulation - TailwindCSS integration
- TailwindCSS integration
## Getting Started ## Getting Started
@@ -34,7 +32,8 @@ Then follow [The Guide]()
For a more complete setup, run `thor new site`. For a more complete setup, run `thor new site`.
## Extended Markdown ## Extended Markdown
- Emoji expansion - Emoji expansion
- margin style footnotes - margin style footnotes
- Guthub style alerts - Github style alerts
- [and more] - [and more]
+67 -10
View File
@@ -2,12 +2,48 @@
- Polish existing features before moving on to new ones. - Polish existing features before moving on to new ones.
- [ ] Improve diagnostics - [ ] Improve diagnostics
- [ ] Simplify / unify template context stack. Come up with a name for it. - [x] keep track of every error and don't report them more than once.
- [ ] `render_template` should accept `Template_Context`, not `any` - [ ] 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
- [ ] better diagnostics for syntax errors in treesitter.
- [ ] Ensure diagnostics for MAX_CONTEXT_DEPTH are good.
- [ ] improve matching weights message.
- [ ] show a proper diagnostic for timezones
- currently "unable to load timezone 'America/New_Yorkskie'"
- want rust style diagnostic and better message, maybe "unknown timezone 'America/New_Yorkskie'"
- [ ] Test menu diagnostics
- [ ] 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
- [ ] Extend `tag_error` to all render-time errors, not just pipe errors.
Currently only pipe errors (4 sites in `render_nodes`) get stamped with
the correct template source/path. Other render errors still use the
content template's source/path, which can point at the wrong file.
- [ ] try to make file paths clickable links.
- [ ] centralize diagnostics to one place.
- [ ] consider logging the number of times an error occurred.
- [ ] Load grammars dynamically - [ ] Load grammars dynamically
- [ ] consider adding a limit to the context stack in mustache. - [ ] starred must be a param.
- [ ] better diagnostics for syntax errors in treesitter. - [ ] Documentation
- [x] Add heading ids as a default on extension. - [ ] talk about the context stack (and its limit).
- [ ] highlight the differences in the way menus are handled.
- [ ] consider sites with date based urls.
- [ ] 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.
- [ ] don't use bullshit "sub-tokens", add filters and pipes as proper tokens.
## Performance ## Performance
@@ -31,6 +67,13 @@
- `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
- [ ] `group_by` currently requires a computed `year` field on the page.
- We should replace this with `{{ pages | group_by (date | "2006") }}` or similar
## Memory Management ## Memory Management
@@ -58,11 +101,11 @@
- [ ] display an error when no part of the date appears in the output. - [ ] display an error when no part of the date appears in the output.
- [ ] Handle 0 and whitespace padding i.e. "_2" -> " 2" - [ ] Handle 0 and whitespace padding i.e. "_2" -> " 2"
- [ ] 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?
- [ ] show a proper diagnostic for timezones
- currently "unable to load timezone 'America/New_Yorkskie'"
- want rust style diagnostic and better message, maybe "unknown timezone 'America/New_Yorkskie'"
## 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
@@ -81,10 +124,19 @@ 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)
- [ ] final Context_Stack cap
- [ ] highest PIPE args used
- [ ] longest slug length + name that generated it
- [ ] number of pages
- [ ] number of blocks
- [ ] enabled features / extensions
- [ ] 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.
@@ -93,6 +145,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
- [x] Detailed frontmatter menu form ("menu": {"main": {"weight": 5}})
- [ ] Menu active state (pre-compute is_active based on page.permalink prefix match)
- [x] 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
@@ -100,7 +156,7 @@ main :: proc () {
- [ ] Mustache diagnostics - [ ] Mustache diagnostics
- [ ] Partial invocation stack in diagnostics: when an error fires inside a partial, show "invoked from" chain through `{{> name}}` calls. Currently warnings inside partials point at the partial (correct file) but don't show the invocation site. - [ ] Partial invocation stack in diagnostics: when an error fires inside a partial, show "invoked from" chain through `{{> name}}` calls. Currently warnings inside partials point at the partial (correct file) but don't show the invocation site.
- [ ] Could be better error message when missing a closing (or opening) brace - [ ] Could be better error message when missing a closing (or opening) brace
- [ ] Error message doesn't show position of faulty pipe name correctly. - [x] Error message doesn't show position of faulty pipe name correctly.
- [ ] `render_template` (`render.odin`) blanks the *entire page* to `""` on any - [ ] `render_template` (`render.odin`) blanks the *entire page* to `""` on any
mustache render error and only `log.errorf`s it — a single bad tag/pipe mustache render error and only `log.errorf`s it — a single bad tag/pipe
anywhere on the page silently kills the whole output with no visible anywhere on the page silently kills the whole output with no visible
@@ -145,6 +201,7 @@ main :: proc () {
- [ ] `new site` set up new project - [ ] `new site` set up new project
- [ ] warn/error when unknown key used in mustache. - [ ] warn/error when unknown key used in mustache.
- [ ] Import/export packages. Hugo, jekyll, WordPress, etc. - [ ] Import/export packages. Hugo, jekyll, WordPress, etc.
- [ ] opt-in "strict_keys" mode. in this mode, key lookups may not view parent objects.
## Notes ## Notes
+47 -26
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,
@@ -101,13 +101,13 @@ main :: proc() {
return return
} }
for _ in 0..<3 { for _ in 0 ..< 3 {
_, _ = mustache.render(page, data, partials, allocator = context.temp_allocator) _, _ = mustache.render(page, data, partials, allocator = context.temp_allocator)
mem.dynamic_arena_free_all(&temp_arena) mem.dynamic_arena_free_all(&temp_arena)
} }
start := time.now() start := time.now()
for _ in 0..<iterations { for _ in 0 ..< iterations {
_, _ = mustache.render(page, data, partials, allocator = context.temp_allocator) _, _ = mustache.render(page, data, partials, allocator = context.temp_allocator)
mem.dynamic_arena_free_all(&temp_arena) mem.dynamic_arena_free_all(&temp_arena)
} }
@@ -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 {
@@ -138,16 +142,27 @@ 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,28 +174,34 @@ generate_data :: proc() -> Page_Data {
author = fmt.aprintf("Author %d", i % 5) author = fmt.aprintf("Author %d", i % 5)
} }
append(&posts, Post{ append(
title = fmt.aprintf("Post %d from %s", i, year), &posts,
url = fmt.aprintf("/%s/post-%d", year, i), Post {
date = fmt.aprintf("%s-%02d-%02dT10:00:00Z", year, month, day), title = fmt.aprintf("Post %d from %s", i, year),
year = year, url = fmt.aprintf("/%s/post-%d", year, i),
excerpt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", date = fmt.aprintf("%s-%02d-%02dT10:00:00Z", year, month, day),
author = author, year = year,
tags = tags, excerpt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
}) author = author,
tags = tags,
},
)
} }
} }
comments := make([dynamic]Comment, 0, 100) comments := make([dynamic]Comment, 0, 100)
for i in 0..<100 { for i in 0 ..< 100 {
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(
author = fmt.aprintf("Commenter %d", i), &comments,
date = fmt.aprintf("%s-%02d-%02dT12:00:00Z", year, month, day), Comment {
body = fmt.aprintf("Great post! This is comment number %d.", i), author = fmt.aprintf("Commenter %d", i),
}) date = fmt.aprintf("%s-%02d-%02dT12:00:00Z", year, month, day),
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)
@@ -193,7 +214,7 @@ generate_data :: proc() -> Page_Data {
append(&nav_items, Nav_Item{url = "https://twitter.com/example", label = "Twitter"}) append(&nav_items, Nav_Item{url = "https://twitter.com/example", label = "Twitter"})
append(&nav_items, Nav_Item{url = "mailto:nobody@example.com", label = "Email"}) append(&nav_items, Nav_Item{url = "mailto:nobody@example.com", label = "Email"})
return Page_Data{ return Page_Data {
title = "Post Archive", title = "Post Archive",
now = "2025-07-21T12:00:00Z", now = "2025-07-21T12:00:00Z",
posts = posts, posts = posts,
-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)
} }
View File
+37 -13
View File
@@ -7,6 +7,7 @@ import "core:fmt"
import "core:log" import "core:log"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
import "core:time"
// Fields with underscores should never be set by the user. // Fields with underscores should never be set by the user.
Page :: struct { Page :: struct {
@@ -18,12 +19,14 @@ Page :: struct {
title: string, title: string,
description: string, description: string,
date: string, date: 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,
is_starred: bool, starred: bool,
_is_index: bool `private`, _is_index: bool `private`,
} }
@@ -67,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
@@ -231,18 +236,28 @@ load_page :: proc(
page.title = fm.title page.title = fm.title
page.description = fm.description page.description = fm.description
page.date = fm.date page.date = fm.date
if page.date == "" {
info, stat_err := os.stat(file_path, context.allocator)
if stat_err == nil {
page.date, _ = time.time_to_rfc3339(
info.modification_time,
0,
false,
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,
)
}
}
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.is_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 = "/"
@@ -254,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
} }
@@ -269,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 -1
View File
@@ -2,7 +2,7 @@
{{$main}} {{$main}}
<main> <main>
<article> <article>
<h1>{{page_title}}</h1> <h1>{{page.title}}</h1>
{{#date_iso}} <time class="subtitle" datetime="{{date_iso}}">{{date_display}}</time> {{#date_iso}} <time class="subtitle" datetime="{{date_iso}}">{{date_display}}</time>
{{/date_iso}} {{&content}} {{/date_iso}} {{&content}}
</article> </article>
+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 -1
View File
@@ -1,7 +1,7 @@
{{<base}} {{<base}}
{{$main}} {{$main}}
<main> <main>
<h1>{{page_title}}</h1> <h1>{{page.title}}</h1>
{{&content}} {{&content}}
{{#posts | group_by year}} {{#posts | group_by year}}
<section> <section>
-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
} }
+23 -15
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
]; ];
@@ -145,7 +153,7 @@
}; };
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
buildInputs = [odin ols ] ++ (with pkgs; [ buildInputs = [ odin ols ] ++ (with pkgs; [
cmark cmark
tree-sitter tree-sitter
+32 -15
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,
@@ -48,14 +49,17 @@ parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok
return return
} }
fm.title = json_get_string(obj, "title") fm.title = json_get_string(obj, "title")
fm.description = json_get_string(obj, "description") fm.description = json_get_string(obj, "description")
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,22 +85,35 @@ 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 {
if inner, ok2 := v.(json.Object); ok2 { if inner, ok2 := v.(json.Object); ok2 {
og.title = json_get_string(inner, "title") og.title = json_get_string(inner, "title")
og.type = json_get_string(inner, "type") og.type = json_get_string(inner, "type")
og.image = json_get_string(inner, "image") og.image = json_get_string(inner, "image")
og.url = json_get_string(inner, "url") og.url = json_get_string(inner, "url")
og.description = json_get_string(inner, "description") og.description = json_get_string(inner, "description")
og.locale = json_get_string(inner, "locale") og.locale = json_get_string(inner, "locale")
og.site_name = json_get_string(inner, "site_name") og.site_name = json_get_string(inner, "site_name")
og.published_time = json_get_string(inner, "published_time") og.published_time = json_get_string(inner, "published_time")
og.modified_time = json_get_string(inner, "modified_time") og.modified_time = json_get_string(inner, "modified_time")
og.section = json_get_string(inner, "section") og.section = json_get_string(inner, "section")
} }
} }
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")
} }
-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>",
) )
} }
+395
View File
@@ -0,0 +1,395 @@
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",
)
}
+21 -21
View File
@@ -54,7 +54,7 @@ minify_html :: proc(source: string) -> string {
segment := source[i:p.end] segment := source[i:p.end]
strings.write_string(&sb, segment) strings.write_string(&sb, segment)
if len(segment) > 0 { if len(segment) > 0 {
last_written = segment[len(segment)-1] last_written = segment[len(segment) - 1]
} }
i = int(p.end) i = int(p.end)
pi += 1 pi += 1
@@ -103,27 +103,27 @@ collect_html_ranges :: proc(
preserves: ^[dynamic]Range, preserves: ^[dynamic]Range,
) { ) {
child_count := ts.node_named_child_count(node) child_count := ts.node_named_child_count(node)
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))
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)
} }
@@ -135,11 +135,11 @@ collect_html_ranges :: proc(
html_tag_name :: proc(element: ts.Node, source: string) -> string { html_tag_name :: proc(element: ts.Node, source: string) -> string {
child_count := ts.node_named_child_count(element) child_count := ts.node_named_child_count(element)
for i in 0..<child_count { for i in 0 ..< child_count {
child := ts.node_named_child(element, u32(i)) child := ts.node_named_child(element, u32(i))
if string(ts.node_type(child)) == "start_tag" { if string(ts.node_type(child)) == "start_tag" {
tag_child_count := ts.node_named_child_count(child) tag_child_count := ts.node_named_child_count(child)
for j in 0..<tag_child_count { for j in 0 ..< tag_child_count {
tag_child := ts.node_named_child(child, u32(j)) tag_child := ts.node_named_child(child, u32(j))
if string(ts.node_type(tag_child)) == "tag_name" { if string(ts.node_type(tag_child)) == "tag_name" {
start := ts.node_start_byte(tag_child) start := ts.node_start_byte(tag_child)
@@ -241,13 +241,13 @@ minify_css :: proc(source: string) -> string {
collect_css_comments :: proc(node: ts.Node, comments: ^[dynamic]Range) { collect_css_comments :: proc(node: ts.Node, comments: ^[dynamic]Range) {
child_count := ts.node_named_child_count(node) child_count := ts.node_named_child_count(node)
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:])
} }
} }
+13 -6
View File
@@ -185,6 +185,7 @@ format_error :: proc(
context_before: int = 2, context_before: int = 2,
context_after: int = 2, context_after: int = 2,
colorize: bool = false, colorize: bool = false,
span: int = 0,
) -> string { ) -> string {
line, col := line_col(source, pos) line, col := line_col(source, pos)
total_lines := count_lines(source) total_lines := count_lines(source)
@@ -232,8 +233,11 @@ format_error :: proc(
write_gutter(&sb, width, faint, reset) write_gutter(&sb, width, faint, reset)
// Caret extent for the error line. // Caret extent for the error line.
_, token_start, token_end := context_extent(source, pos) line_start, token_start, token_end := context_extent(source, pos)
line_start, _, _ := context_extent(source, pos) if span > 0 {
token_start = pos
token_end = pos + span
}
caret_start_col := token_start - line_start + 1 caret_start_col := token_start - line_start + 1
caret_end_col := token_end - line_start + 1 caret_end_col := token_end - line_start + 1
if caret_end_col <= caret_start_col { if caret_end_col <= caret_start_col {
@@ -309,15 +313,18 @@ write_gutter :: proc(sb: ^strings.Builder, width: int, faint: string, reset: str
// format_render_error produces a diagnostic for an Error value using the // format_render_error produces a diagnostic for an Error value using the
// template's path and source for context. Returns "" for nil errors. // template's path and source for context. Returns "" for nil errors.
// If the error carries its own source/path (from tag_error), those are used
// instead of the passed-in template — this ensures errors inside partials
// point at the correct file.
format_render_error :: proc(err: Error, tmpl: Template, colorize: bool = false) -> string { format_render_error :: proc(err: Error, tmpl: Template, colorize: bool = false) -> string {
if err == nil { if err == nil {
return "" return ""
} }
path := tmpl.path b := body(err)
source := b.source != "" ? b.source : tmpl.source
path := b.path != "" ? b.path : tmpl.path
if path == "" { if path == "" {
path = "<input>" path = "<input>"
} }
b := body(err) return format_error(path, source, b.pos, b.msg, hint = b.hint, colorize = colorize, span = b.span)
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
} }
+116 -27
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")
} }
@@ -241,9 +318,13 @@ test_convert_to_tz_no_offset_assumes_target :: proc(t: ^testing.T) {
if !tz_ok do return if !tz_ok do return
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)
@@ -260,11 +341,15 @@ test_convert_to_tz_with_offset_converts :: proc(t: ^testing.T) {
defer timezone.region_destroy(tz, context.temp_allocator) defer timezone.region_destroy(tz, context.temp_allocator)
// 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,
} }
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)
@@ -281,11 +366,15 @@ test_convert_to_tz_with_negative_offset_converts :: proc(t: ^testing.T) {
defer timezone.region_destroy(tz, context.temp_allocator) defer timezone.region_destroy(tz, context.temp_allocator)
// 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,
} }
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)
-1
View File
@@ -152,4 +152,3 @@ test_lambda_inverted_section :: proc(t: ^testing.T) {
} }
*/ */
+250 -151
View File
@@ -1,34 +1,69 @@
package mustache package mustache
import "base:runtime"
import "core:fmt" import "core:fmt"
import "core:log" import "core:log"
import "core:reflect"
import "core:strings" import "core:strings"
// A hypothetical maximum context depth. Trying to pass more than this many items
// to render(tpl, data), or nesting templates further than this depth would be
// an error.
// May be enforced in a later version (for performance)
MAX_CONTEXT_DEPTH :: #config(MAX_CONTEXT_DEPTH, 16)
// Context_Stack is the growable stack of data frames walked top-to-bottom by
// resolve_name. Frames are pushed on section descent and popped on exit; the
// root data (or each element of a root []any) forms the base frames.
Context_Stack :: [dynamic]any
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Error types // Error types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
Error_Kind :: enum { Error_Kind :: enum {
Syntax, // parse-time: malformed template Syntax, // parse-time: malformed template
Data, // render-time: template fine, data wrong (e.g. filter misuse) Data, // render-time: template fine, data wrong (e.g. filter misuse)
} }
Error_Body :: struct { Error_Body :: struct {
msg: string, msg: string,
pos: int, pos: int,
kind: Error_Kind, kind: Error_Kind,
source: string,
path: string,
span: int,
hint: string,
} }
// Error is nil when no error occurred. // Error is nil when no error occurred.
Error :: union { Error_Body } Error :: union {
Error_Body,
}
// body unwraps the Error_Body from a non-nil Error. // body unwraps the Error_Body from a non-nil Error.
// Precondition: err != nil. // Precondition: err != nil.
body :: proc(err: Error) -> Error_Body { body :: proc(err: Error) -> Error_Body {
switch e in err { switch e in err {
case Error_Body: return e case Error_Body:
case: return {} return e
case:
return {}
}
}
// tag_error stamps an Error with the source/path of the template where it
// originated, so diagnostics point at the correct file (e.g. a partial).
tag_error :: proc(err: Error, tmpl: Template) -> Error {
if err == nil do return nil
b := body(err)
return Error_Body {
msg = b.msg,
pos = b.pos,
span = b.span,
hint = b.hint,
kind = b.kind,
source = tmpl.source,
path = tmpl.path,
} }
} }
@@ -63,9 +98,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
} }
@@ -142,12 +179,24 @@ render :: proc(
err: Error, err: Error,
) { ) {
builder: strings.Builder builder: strings.Builder
strings.builder_init(&builder, allocator) strings.builder_init(&builder, context.temp_allocator)
defer strings.builder_destroy(&builder)
ctx := make([dynamic]any, 0, 4, allocator) ctx := make(Context_Stack, 0, 4, context.temp_allocator)
defer delete(ctx)
append(&ctx, data) // If data is a []any, expand into individual context frames.
// Otherwise, push as a single frame.
elem_info, count, slice_data := list_info(data)
if elem_info != nil {
if _, is_any := elem_info.variant.(runtime.Type_Info_Any); is_any {
for j in 0 ..< count {
append(&ctx, extract_list_element(elem_info, slice_data, j))
}
} else {
append(&ctx, data)
}
} else {
append(&ctx, data)
}
all_nodes := tmpl.nodes[:] all_nodes := tmpl.nodes[:]
err = render_nodes(tmpl, all_nodes, &ctx, partials, &builder) err = render_nodes(tmpl, all_nodes, &ctx, partials, &builder)
@@ -180,6 +229,22 @@ parse_tokens :: proc(
return return
} }
// tag_content_base returns the absolute byte offset in source where the
// trimmed tag content begins (after {{, optional sigil, and whitespace).
tag_content_base :: proc(source: string, tag_pos: int) -> int {
base := tag_pos + 2 // skip {{
if base < len(source) {
switch source[base] {
case '#', '^', '/', '&', '>', '<', '$', '!':
base += 1
}
}
for base < len(source) && (source[base] == ' ' || source[base] == '\t') {
base += 1
}
return base
}
parse_section :: proc( parse_section :: proc(
tokens: []Token, tokens: []Token,
pos: ^int, pos: ^int,
@@ -200,7 +265,7 @@ parse_section :: proc(
case .Variable: case .Variable:
idx := len(nodes) idx := len(nodes)
append(nodes, Node{kind = .Variable}) append(nodes, Node{kind = .Variable})
pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos) pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos, tag_content_base(source, tok.pos))
if perr != nil { if perr != nil {
return Error_Body { return Error_Body {
msg = fmt.tprintf("pipe parse error in '{{{{%s}}}}': %v", tok.value, perr), msg = fmt.tprintf("pipe parse error in '{{{{%s}}}}': %v", tok.value, perr),
@@ -214,7 +279,7 @@ parse_section :: proc(
case .Unescaped: case .Unescaped:
idx := len(nodes) idx := len(nodes)
append(nodes, Node{kind = .Unescaped}) append(nodes, Node{kind = .Unescaped})
pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos) pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos, tag_content_base(source, tok.pos))
if perr != nil { if perr != nil {
return Error_Body { return Error_Body {
msg = fmt.tprintf("pipe parse error in '{{{{&%s}}}}': %v", tok.value, perr), msg = fmt.tprintf("pipe parse error in '{{{{&%s}}}}': %v", tok.value, perr),
@@ -234,7 +299,7 @@ parse_section :: proc(
content_start := 0 content_start := 0
if pos^ < len(tokens) {content_start = tokens[pos^].pos} if pos^ < len(tokens) {content_start = tokens[pos^].pos}
append(nodes, Node{kind = .Section, pos = tok.pos}) append(nodes, Node{kind = .Section, pos = tok.pos})
pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos) pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos, tag_content_base(source, tok.pos))
if perr != nil { if perr != nil {
return Error_Body { return Error_Body {
msg = fmt.tprintf("pipe parse error in '{{{{#%s}}}}': %v", tok.value, perr), msg = fmt.tprintf("pipe parse error in '{{{{#%s}}}}': %v", tok.value, perr),
@@ -255,7 +320,7 @@ parse_section :: proc(
content_start := 0 content_start := 0
if pos^ < len(tokens) {content_start = tokens[pos^].pos} if pos^ < len(tokens) {content_start = tokens[pos^].pos}
append(nodes, Node{kind = .Inverted, pos = tok.pos}) append(nodes, Node{kind = .Inverted, pos = tok.pos})
pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos) pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos, tag_content_base(source, tok.pos))
if perr != nil { if perr != nil {
return Error_Body { return Error_Body {
msg = fmt.tprintf("pipe parse error in '{{{{^%s}}}}': %v", tok.value, perr), msg = fmt.tprintf("pipe parse error in '{{{{^%s}}}}': %v", tok.value, perr),
@@ -272,14 +337,14 @@ parse_section :: proc(
case .Section_Close: case .Section_Close:
if strings.contains(tok.value, "|") { if strings.contains(tok.value, "|") {
return Error_Body { return Error_Body {
msg = fmt.tprintf( msg = fmt.tprintf(
"pipe expression not allowed in close tag '{{{{/%s}}}}' — use the bare key", "pipe expression not allowed in close tag '{{{{/%s}}}}' — use the bare key",
tok.value, tok.value,
), ),
pos = tok.pos, pos = tok.pos,
kind = .Syntax, kind = .Syntax,
} }
} }
if end_tag != "" && tok.value == end_tag { if end_tag != "" && tok.value == end_tag {
pos^ += 1 pos^ += 1
@@ -316,12 +381,7 @@ parse_section :: proc(
idx := len(nodes) idx := len(nodes)
append( append(
nodes, nodes,
Node { Node{kind = .Parent, key = tok.value, indent = tok.indent, pos = tok.pos},
kind = .Parent,
key = tok.value,
indent = tok.indent,
pos = tok.pos,
},
) )
parse_section(tokens, pos, nodes, tok.value, source, allocator, tok.pos) or_return parse_section(tokens, pos, nodes, tok.value, source, allocator, tok.pos) or_return
nodes[idx].children = nodes[idx + 1:len(nodes)] nodes[idx].children = nodes[idx + 1:len(nodes)]
@@ -329,15 +389,7 @@ parse_section :: proc(
case .Block_Open: case .Block_Open:
pos^ += 1 pos^ += 1
idx := len(nodes) idx := len(nodes)
append( append(nodes, Node{kind = .Block, key = tok.value, indent = tok.indent, pos = tok.pos})
nodes,
Node {
kind = .Block,
key = tok.value,
indent = tok.indent,
pos = tok.pos,
},
)
parse_section(tokens, pos, nodes, tok.value, source, allocator, tok.pos) or_return parse_section(tokens, pos, nodes, tok.value, source, allocator, tok.pos) or_return
nodes[idx].children = nodes[idx + 1:len(nodes)] nodes[idx].children = nodes[idx + 1:len(nodes)]
} }
@@ -360,7 +412,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
@@ -387,6 +439,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])
} }
@@ -484,21 +538,29 @@ remove_line_indent :: proc(s: string, indent: string, allocator := context.alloc
render_template :: proc( render_template :: proc(
pt: Template, pt: Template,
ctx: ^[dynamic]any, ctx: ^Context_Stack,
partials: map[string]Template, partials: map[string]Template,
b: ^strings.Builder, b: ^strings.Builder,
blocks: map[string]Block_Override, blocks: map[string]Block_Override,
indent: string, indent: string,
) -> Error { ) -> Error {
if len(indent) > 0 { if len(indent) > 0 {
state := Indent_State{indent = indent, at_line_start = false} state := Indent_State {
indent = indent,
at_line_start = false,
}
strings.write_string(b, indent) // first line always gets indent strings.write_string(b, indent) // first line always gets indent
return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, &state) return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, &state)
} }
return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, nil) return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, nil)
} }
write_indented :: proc(b: ^strings.Builder, indent: string, content: string, at_line_start: ^bool) { write_indented :: proc(
b: ^strings.Builder,
indent: string,
content: string,
at_line_start: ^bool,
) {
if len(indent) == 0 || len(content) == 0 { if len(indent) == 0 || len(content) == 0 {
strings.write_string(b, content) strings.write_string(b, content)
return return
@@ -529,7 +591,7 @@ write_indented :: proc(b: ^strings.Builder, indent: string, content: string, at_
render_nodes :: proc( render_nodes :: proc(
current: Template, current: Template,
nodes: []Node, nodes: []Node,
ctx: ^[dynamic]any, ctx: ^Context_Stack,
partials: map[string]Template, partials: map[string]Template,
b: ^strings.Builder, b: ^strings.Builder,
blocks: map[string]Block_Override = nil, blocks: map[string]Block_Override = nil,
@@ -559,7 +621,7 @@ render_nodes :: proc(
if len(node.filters) > 0 { if len(node.filters) > 0 {
transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:]) transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:])
if perr != nil { if perr != nil {
return perr return tag_error(perr, current)
} }
val = transformed val = transformed
} }
@@ -573,16 +635,16 @@ render_nodes :: proc(
if perr == nil { if perr == nil {
temp: strings.Builder temp: strings.Builder
strings.builder_init(&temp, context.temp_allocator) strings.builder_init(&temp, context.temp_allocator)
render_nodes( render_nodes(
sub_tpl, sub_tpl,
sub_tpl.nodes[:], sub_tpl.nodes[:],
ctx, ctx,
partials, partials,
&temp, &temp,
blocks, blocks,
nil, nil,
) or_return ) or_return
write_value(b, strings.to_string(temp), escape = true) write_value(b, strings.to_string(temp), escape = true)
} }
} else { } else {
write_value(b, val, escape = true) write_value(b, val, escape = true)
@@ -601,7 +663,7 @@ render_nodes :: proc(
if len(node.filters) > 0 { if len(node.filters) > 0 {
transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:]) transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:])
if perr != nil { if perr != nil {
return perr return tag_error(perr, current)
} }
val = transformed val = transformed
} }
@@ -615,16 +677,16 @@ render_nodes :: proc(
if perr == nil { if perr == nil {
temp: strings.Builder temp: strings.Builder
strings.builder_init(&temp, context.temp_allocator) strings.builder_init(&temp, context.temp_allocator)
render_nodes( render_nodes(
sub_tpl, sub_tpl,
sub_tpl.nodes[:], sub_tpl.nodes[:],
ctx, ctx,
partials, partials,
&temp, &temp,
blocks, blocks,
nil, nil,
) or_return ) or_return
write_value(b, strings.to_string(temp), escape = false) write_value(b, strings.to_string(temp), escape = false)
} }
} else { } else {
write_value(b, val, escape = false) write_value(b, val, escape = false)
@@ -639,7 +701,7 @@ render_nodes :: proc(
if len(node.filters) > 0 { if len(node.filters) > 0 {
transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:]) transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:])
if perr != nil { if perr != nil {
return perr return tag_error(perr, current)
} }
val = transformed val = transformed
} }
@@ -651,23 +713,36 @@ render_nodes :: proc(
context.temp_allocator, context.temp_allocator,
) )
if perr == nil { if perr == nil {
render_nodes( render_nodes(
sub_tpl, sub_tpl,
sub_tpl.nodes[:], sub_tpl.nodes[:],
ctx, ctx,
partials, partials,
b, b,
blocks, blocks,
nil, nil,
) or_return ) or_return
} }
} else if is_truthy(val) { } else if is_truthy(val) {
children := node.children children := node.children
elem_info, count, data := list_info(val) elem_info, count, data := list_info(val)
if elem_info != nil { if elem_info != nil {
for j in 0 ..< count { for j in 0 ..< count {
elem := extract_list_element(elem_info, data, j) elem := extract_list_element(elem_info, data, j)
append(ctx, elem) context_push(ctx, elem, current, node)
defer pop(ctx)
render_nodes(
current,
children,
ctx,
partials,
b,
blocks,
indent_state,
) or_return
}
} else {
context_push(ctx, val, current, node)
defer pop(ctx) defer pop(ctx)
render_nodes( render_nodes(
current, current,
@@ -679,13 +754,8 @@ render_nodes :: proc(
indent_state, indent_state,
) or_return ) or_return
} }
} else {
append(ctx, val)
defer pop(ctx)
render_nodes(current, children, ctx, partials, b, blocks, indent_state) or_return
} }
} i += 1 + len(node.children)
i += 1 + len(node.children)
case .Inverted: case .Inverted:
val := resolve_name(node.key, ctx[:]) val := resolve_name(node.key, ctx[:])
@@ -695,14 +765,22 @@ render_nodes :: proc(
if len(node.filters) > 0 { if len(node.filters) > 0 {
transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:]) transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:])
if perr != nil { if perr != nil {
return perr return tag_error(perr, current)
} }
val = transformed val = transformed
} }
if !is_truthy(val) { if !is_truthy(val) {
render_nodes(current, node.children, ctx, partials, b, blocks, indent_state) or_return render_nodes(
} current,
i += 1 + len(node.children) node.children,
ctx,
partials,
b,
blocks,
indent_state,
) or_return
}
i += 1 + len(node.children)
case .Partial: case .Partial:
name := node.key name := node.key
@@ -721,64 +799,64 @@ render_nodes :: proc(
} }
i += 1 i += 1
case .Block: case .Block:
content_nodes: []Node content_nodes: []Node
content_blocks := blocks content_blocks := blocks
render_current := current render_current := current
found_override := false found_override := false
if blocks != nil { if blocks != nil {
if o, ok := blocks[node.key]; ok { if o, ok := blocks[node.key]; ok {
content_nodes = o.nodes content_nodes = o.nodes
found_override = true found_override = true
render_current = o.source render_current = o.source
}
} }
} if !found_override {
if !found_override { content_nodes = node.children
content_nodes = node.children
}
if len(node.indent) > 0 {
temp: strings.Builder
strings.builder_init(&temp, context.temp_allocator)
render_nodes(
render_current,
content_nodes,
ctx,
partials,
&temp,
content_blocks,
nil,
) or_return
at_ls := true
write_indented(b, node.indent, strings.to_string(temp), &at_ls)
} else {
render_nodes(
render_current,
content_nodes,
ctx,
partials,
b,
content_blocks,
indent_state,
) or_return
}
i += 1 + len(node.children)
case .Parent:
parent_children := node.children
merged := merge_block_overrides(parent_children, blocks, current)
pt, found := partials[node.key]
if !found {
warn_missing_partial(current, partials, node, node.key)
} else {
warn_unmatched_block_overrides(current, pt, parent_children)
render_template(pt, ctx, partials, b, merged, node.indent) or_return
if indent_state != nil {
indent_state.at_line_start = false
} }
}
i += 1 + len(node.children) if len(node.indent) > 0 {
temp: strings.Builder
strings.builder_init(&temp, context.temp_allocator)
render_nodes(
render_current,
content_nodes,
ctx,
partials,
&temp,
content_blocks,
nil,
) or_return
at_ls := true
write_indented(b, node.indent, strings.to_string(temp), &at_ls)
} else {
render_nodes(
render_current,
content_nodes,
ctx,
partials,
b,
content_blocks,
indent_state,
) or_return
}
i += 1 + len(node.children)
case .Parent:
parent_children := node.children
merged := merge_block_overrides(parent_children, blocks, current)
pt, found := partials[node.key]
if !found {
warn_missing_partial(current, partials, node, node.key)
} else {
warn_unmatched_block_overrides(current, pt, parent_children)
render_template(pt, ctx, partials, b, merged, node.indent) or_return
if indent_state != nil {
indent_state.at_line_start = false
}
}
i += 1 + len(node.children)
} }
} }
return nil return nil
@@ -922,3 +1000,24 @@ warn_unmatched_block_overrides :: proc(
} }
} }
context_push :: proc(ctx: ^Context_Stack, val: any, current: Template, node: Node) {
append(ctx, val)
if len(ctx^) == MAX_CONTEXT_DEPTH + 1 {
warn_context_depth(current, node)
}
}
// warn_context_depth emits a diagnostic warning pointing at the section tag
// whose push carried the context stack past MAX_CONTEXT_DEPTH.
warn_context_depth :: proc(current: Template, node: Node) {
msg := fmt.tprintf(
"context stack depth exceeded %d (possible recursive section/partial)",
MAX_CONTEXT_DEPTH,
)
path := current.path
if path == "" {
path = "<input>"
}
diag := format_error(path, current.source, node.pos, msg, "", colorize = should_colorize())
log.warnf("%s", diag)
}
+37
View File
@@ -1,7 +1,9 @@
#+test #+test
package mustache package mustache
import "core:log"
import "core:mem" import "core:mem"
import "core:strings"
import "core:testing" import "core:testing"
@(test) @(test)
@@ -124,3 +126,38 @@ leak_repeated_render :: proc(t: ^testing.T) {
} }
} }
// A deeply nested map pushes the context stack past MAX_CONTEXT_DEPTH (16).
// The depth warning must be non-fatal: rendering still succeeds.
@(test)
test_context_depth_warns :: proc(t: ^testing.T) {
context.logger = log.nil_logger()
AMT :: MAX_CONTEXT_DEPTH + 2
// Build AMT nested {x: {...}} levels; the innermost holds `leaf`.
data := make(map[string]any, context.temp_allocator)
data["leaf"] = "found"
for _ in 0 ..< AMT {
outer := make(map[string]any, context.temp_allocator)
outer["x"] = data
data = outer
}
// Template: AMT nested {{#x}} sections around {{leaf}}.
src: strings.Builder
strings.builder_init(&src, context.temp_allocator)
for _ in 0 ..< AMT do strings.write_string(&src, "{{#x}}")
strings.write_string(&src, "{{leaf}}")
for _ in 0 ..< AMT do strings.write_string(&src, "{{/x}}")
template := strings.to_string(src)
tmpl, perr := parse(template, "<depth-test>", context.temp_allocator)
testing.expect(t, perr == nil, "should parse")
if perr != nil {
return
}
result, rerr := render(tmpl, data, allocator = context.temp_allocator)
testing.expect(t, rerr == nil, "depth warning must be non-fatal")
testing.expect_value(t, result, "found")
}
+101 -30
View File
@@ -1,5 +1,6 @@
package mustache package mustache
import "base:runtime"
import "core:fmt" import "core:fmt"
import "core:log" import "core:log"
import "core:reflect" import "core:reflect"
@@ -16,9 +17,48 @@ MAX_PIPE_ARGS :: 2
DEFAULT_DATE_FORMAT :: "2 Jan 2006" DEFAULT_DATE_FORMAT :: "2 Jan 2006"
Pipe_Op :: enum {
Format,
Group_By,
}
// Pipe op names are derived from the enum via reflection (lowercased).
// Adding a new op only requires adding it to the enum AND handling it in
// apply_filter's switch — the compiler enforces exhaustive matching.
pipe_op_from_string :: proc(s: string) -> (Pipe_Op, bool) {
ti := type_info_of(typeid_of(Pipe_Op))
base := runtime.type_info_base(ti)
#partial switch &e in base.variant {
case runtime.Type_Info_Enum:
for name, idx in e.names {
lower := strings.to_lower(name, context.temp_allocator) or_else name
if lower == s {
return cast(Pipe_Op)idx, true
}
}
}
return {}, false
}
pipe_op_candidates :: proc(allocator := context.temp_allocator) -> []string {
ti := type_info_of(typeid_of(Pipe_Op))
base := runtime.type_info_base(ti)
out := make([dynamic]string, 0, 2, allocator)
#partial switch &e in base.variant {
case runtime.Type_Info_Enum:
for name in e.names {
lower := strings.to_lower(name, allocator) or_else name
append(&out, lower)
}
}
return out[:]
}
Pipe_Filter :: struct { Pipe_Filter :: struct {
op: string, op: string,
args: [dynamic; MAX_PIPE_ARGS]string, args: [dynamic; MAX_PIPE_ARGS]string,
op_pos: int,
} }
Group :: struct { Group :: struct {
@@ -53,8 +93,8 @@ tokenize_fields :: proc(seg: string, pos: int) -> (tokens: [dynamic]string, err:
} }
if j >= len(seg) { if j >= len(seg) {
return tokens, Error_Body { return tokens, Error_Body {
msg = fmt.tprintf("unterminated string literal: %s", seg), msg = fmt.tprintf("unterminated string literal: %s", seg),
pos = pos, pos = pos,
kind = .Syntax, kind = .Syntax,
} }
} }
@@ -77,6 +117,7 @@ parse_pipeline :: proc(
content: string, content: string,
filters_out: ^[dynamic; MAX_PIPES]Pipe_Filter, filters_out: ^[dynamic; MAX_PIPES]Pipe_Filter,
pos: int, pos: int,
content_base: int,
) -> ( ) -> (
key: string, key: string,
err: Error, err: Error,
@@ -86,14 +127,13 @@ parse_pipeline :: proc(
return key, nil return key, nil
} }
segments := strings.split(content, "|", allocator = context.temp_allocator) // Count pipes to validate against MAX_PIPES.
pipe_count := strings.count(content, "|")
filter_count := len(segments) - 1 if pipe_count > MAX_PIPES {
if filter_count > MAX_PIPES {
return "", Error_Body { return "", Error_Body {
msg = fmt.tprintf( msg = fmt.tprintf(
"pipe expression has %d filters, max is %d", "pipe expression has %d filters, max is %d",
filter_count, pipe_count,
MAX_PIPES, MAX_PIPES,
), ),
pos = pos, pos = pos,
@@ -101,21 +141,38 @@ parse_pipeline :: proc(
} }
} }
key = strings.trim_space(segments[0]) // Key is everything before the first |.
first_pipe := strings.index(content, "|")
key = strings.trim_space(content[:first_pipe])
if len(key) == 0 { if len(key) == 0 {
return "", Error_Body{msg = "pipe expression missing key", pos = pos, kind = .Syntax} return "", Error_Body{msg = "pipe expression missing key", pos = pos, kind = .Syntax}
} }
if filter_count == 0 { if pipe_count == 0 {
return key, nil return key, nil
} }
for i in 0 ..< filter_count { // Walk pipe-delimited segments, tracking byte offsets within content.
seg := strings.trim_space(segments[i + 1]) seg_start := first_pipe + 1 // offset in content, just after |
for seg_start <= len(content) {
next_pipe := strings.index(content[seg_start:], "|")
// Raw segment text (may have leading/trailing whitespace).
seg_end := seg_start + next_pipe if next_pipe >= 0 else len(content)
raw_seg := content[seg_start:seg_end]
// Count leading whitespace to find op offset within content.
ws := 0
for ws < len(raw_seg) && is_pipe_space(raw_seg[ws]) {
ws += 1
}
seg := strings.trim_space(raw_seg)
if len(seg) == 0 { if len(seg) == 0 {
return "", Error_Body{msg = "empty filter", pos = pos, kind = .Syntax} return "", Error_Body{msg = "empty filter", pos = pos, kind = .Syntax}
} }
op_offset_in_content := seg_start + ws
tokens, terr := tokenize_fields(seg, pos) tokens, terr := tokenize_fields(seg, pos)
if terr != nil { if terr != nil {
delete(tokens) delete(tokens)
@@ -140,13 +197,19 @@ parse_pipeline :: proc(
} }
filter := Pipe_Filter { filter := Pipe_Filter {
op = tokens[0], op = tokens[0],
op_pos = content_base + op_offset_in_content,
} }
for j in 1 ..< len(tokens) { for j in 1 ..< len(tokens) {
append(&filter.args, tokens[j]) append(&filter.args, tokens[j])
} }
append(filters_out, filter) append(filters_out, filter)
delete(tokens) delete(tokens)
if next_pipe < 0 {
break
}
seg_start = seg_end + 1
} }
return key, nil return key, nil
@@ -177,28 +240,43 @@ resolve_format_string :: proc(name: string, ctx: []any, pos: int) -> (string, Er
raw := resolve_name(name, ctx) raw := resolve_name(name, ctx)
if raw == nil { if raw == nil {
return "", Error_Body { return "", Error_Body {
msg = fmt.tprintf("unable to resolve date format key '%s'", name), msg = fmt.tprintf("unable to resolve date format key '%s'", name),
pos = pos, pos = pos,
kind = .Data, kind = .Data,
} }
} }
str, ok := reflect.as_string(raw) str, ok := reflect.as_string(raw)
if !ok { if !ok {
return "", Error_Body { return "", Error_Body {
msg = fmt.tprintf("date format key '%s' is not a string", name), msg = fmt.tprintf("date format key '%s' is not a string", name),
pos = pos, pos = pos,
kind = .Data, kind = .Data,
} }
} }
return str, nil return str, nil
} }
// TODO: diagnostics don't show anything relevent
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 { op, ok := pipe_op_from_string(filter.op)
case "group_by": if !ok {
hint := ""
suggestion := suggest_correction(pipe_op_candidates(), filter.op)
if suggestion != "" {
hint = fmt.tprintf("did you mean '%s'?", suggestion)
}
return nil, Error_Body {
msg = fmt.tprintf("unknown pipe op '%s'", filter.op),
pos = filter.op_pos,
span = len(filter.op),
kind = .Data,
hint = hint,
}
}
switch op {
case .Group_By:
return apply_group_by(value, filter.args[:], pos) return apply_group_by(value, filter.args[:], pos)
case "format": case .Format:
str, ok := reflect.as_string(value) str, ok := reflect.as_string(value)
if !ok { if !ok {
return value, Error_Body { return value, Error_Body {
@@ -236,14 +314,8 @@ apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) ->
} else { } else {
return any{new_clone(str2, context.temp_allocator), typeid_of(string)}, nil return any{new_clone(str2, context.temp_allocator), typeid_of(string)}, nil
} }
case:
return nil, Error_Body {
msg = fmt.tprintf("unknown pipe op '%s'", filter.op),
pos = pos,
kind = .Data,
}
} }
return {}, nil
} }
apply_format :: proc( apply_format :: proc(
@@ -337,4 +409,3 @@ apply_group_by :: proc(value: any, args: []string, pos: int) -> (result: any, er
return groups, nil return groups, nil
} }
+48 -14
View File
@@ -2,6 +2,7 @@
package mustache package mustache
import "core:fmt" import "core:fmt"
import "core:strings"
import "core:testing" import "core:testing"
import "core:time/datetime" import "core:time/datetime"
import "core:time/timezone" import "core:time/timezone"
@@ -214,7 +215,7 @@ test_interp_pipe_basic :: proc(t: ^testing.T) {
timezone: ^datetime.TZ_Region, timezone: ^datetime.TZ_Region,
} }
data := Scalar_Data { data := Scalar_Data {
name = "2026-03-15T08:49:54-04:00", name = "2026-03-15T08:49:54-04:00",
date_format = "2 Jan 2006", date_format = "2 Jan 2006",
} }
tpl, _ := parse("[{{name | format}}]", "<test>", allocator = context.temp_allocator) tpl, _ := parse("[{{name | format}}]", "<test>", allocator = context.temp_allocator)
@@ -230,7 +231,7 @@ test_interp_pipe_unescaped :: proc(t: ^testing.T) {
timezone: ^datetime.TZ_Region, timezone: ^datetime.TZ_Region,
} }
data := Scalar_Data { data := Scalar_Data {
name = "2025-12-25T00:00:00Z", name = "2025-12-25T00:00:00Z",
date_format = "2 Jan 2006", date_format = "2 Jan 2006",
} }
tpl, _ := parse("[{{&name | format}}]", "<test>", allocator = context.temp_allocator) tpl, _ := parse("[{{&name | format}}]", "<test>", allocator = context.temp_allocator)
@@ -246,10 +247,14 @@ test_interp_pipe_dot_current :: proc(t: ^testing.T) {
timezone: ^datetime.TZ_Region, timezone: ^datetime.TZ_Region,
} }
data := List_Data { data := List_Data {
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]")
} }
@@ -267,7 +272,7 @@ Format_Data :: struct {
@(test) @(test)
test_format_typical_iso :: proc(t: ^testing.T) { test_format_typical_iso :: proc(t: ^testing.T) {
data := Format_Data { data := Format_Data {
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}}", "<test>", allocator = context.temp_allocator) tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
@@ -278,7 +283,7 @@ test_format_typical_iso :: proc(t: ^testing.T) {
@(test) @(test)
test_format_short_date_only :: proc(t: ^testing.T) { test_format_short_date_only :: proc(t: ^testing.T) {
data := Format_Data { data := Format_Data {
date = "2026-06-06", date = "2026-06-06",
date_format = "2 Jan 2006", date_format = "2 Jan 2006",
} }
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator) tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
@@ -289,7 +294,7 @@ test_format_short_date_only :: proc(t: ^testing.T) {
@(test) @(test)
test_format_empty_input_errors :: proc(t: ^testing.T) { test_format_empty_input_errors :: proc(t: ^testing.T) {
data := Format_Data { data := Format_Data {
date = "", date = "",
date_format = "2 Jan 2006", date_format = "2 Jan 2006",
} }
tpl, _ := parse("[{{date | format}}]", "<test>", allocator = context.temp_allocator) tpl, _ := parse("[{{date | format}}]", "<test>", allocator = context.temp_allocator)
@@ -301,7 +306,7 @@ test_format_empty_input_errors :: proc(t: ^testing.T) {
@(test) @(test)
test_format_non_date_string_errors :: proc(t: ^testing.T) { test_format_non_date_string_errors :: proc(t: ^testing.T) {
data := Format_Data { data := Format_Data {
date = "abc", date = "abc",
date_format = "2 Jan 2006", date_format = "2 Jan 2006",
} }
tpl, _ := parse("[{{date | format}}]", "<test>", allocator = context.temp_allocator) tpl, _ := parse("[{{date | format}}]", "<test>", allocator = context.temp_allocator)
@@ -327,7 +332,7 @@ test_format_non_string_value_errors :: proc(t: ^testing.T) {
@(test) @(test)
test_format_invalid_month_errors :: proc(t: ^testing.T) { test_format_invalid_month_errors :: proc(t: ^testing.T) {
data := Format_Data { data := Format_Data {
date = "2023-13-15", date = "2023-13-15",
date_format = "2 Jan 2006", date_format = "2 Jan 2006",
} }
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator) tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
@@ -341,7 +346,7 @@ test_format_inside_section_renders :: proc(t: ^testing.T) {
// Mirrors the datetime.html partial pattern: section pushes raw string, // Mirrors the datetime.html partial pattern: section pushes raw string,
// partial uses {{.}} for ISO attr and {{. | format}} for display. // partial uses {{.}} for ISO attr and {{. | format}} for display.
data := Format_Data { data := Format_Data {
date = "2025-12-25T00:00:00Z", date = "2025-12-25T00:00:00Z",
date_format = "2 Jan 2006", date_format = "2 Jan 2006",
} }
tpl, _ := parse( tpl, _ := parse(
@@ -356,10 +361,14 @@ test_format_inside_section_renders :: proc(t: ^testing.T) {
@(test) @(test)
test_format_inside_section_skips_when_empty :: proc(t: ^testing.T) { test_format_inside_section_skips_when_empty :: proc(t: ^testing.T) {
data := Format_Data { data := Format_Data {
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 +383,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")
} }
@@ -465,7 +478,7 @@ test_format_handles_all_iso8601_variants :: proc(t: ^testing.T) {
} }
for &c in cases { for &c in cases {
data := Format_Data { data := Format_Data {
date = c.input, date = c.input,
date_format = "2 Jan 2006", date_format = "2 Jan 2006",
} }
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator) tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
@@ -549,3 +562,24 @@ test_format_mst_no_offset_no_timezone :: proc(t: ^testing.T) {
result, _ := render(tpl, data, {}, context.temp_allocator) result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "UTC") testing.expect_value(t, result, "UTC")
} }
// --- pipe op suggestion tests ---
@(test)
test_unknown_pipe_op_suggestion :: proc(t: ^testing.T) {
filter := Pipe_Filter{op = "formats", op_pos = 0}
_, err := apply_filter("2026-01-15", &filter, 0, nil)
testing.expect(t, err != nil, "should error on unknown op")
b := body(err)
testing.expect(t, strings.contains(b.hint, "format"), "hint should suggest 'format'")
testing.expect(t, strings.contains(b.hint, "did you mean"), "hint should be a suggestion")
}
@(test)
test_unknown_pipe_op_no_suggestion :: proc(t: ^testing.T) {
filter := Pipe_Filter{op = "xyz", op_pos = 0}
_, err := apply_filter("2026-01-15", &filter, 0, nil)
testing.expect(t, err != nil, "should error on unknown op")
b := body(err)
testing.expect(t, b.hint == "", "no suggestion expected for 'xyz'")
}
-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")
} }
+2 -8
View File
@@ -153,10 +153,7 @@ suggest_correction :: proc(available: []string, missing: string) -> string {
if len(available) == 0 || len(missing) == 0 { if len(available) == 0 || len(missing) == 0 {
return "" return ""
} }
threshold := 2 threshold := max(2, len(missing) / 3)
if len(missing) > 8 {
threshold = len(missing) / 4
}
best: string best: string
best_dist := threshold + 1 best_dist := threshold + 1
@@ -187,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)
+10 -7
View File
@@ -10,11 +10,15 @@ Inner :: struct {
bar: int, bar: int,
} }
Page :: struct {
title: string,
}
Outer :: struct { Outer :: struct {
title: string, title: string,
page_title: string, page: Page,
inner: Inner, inner: Inner,
numbers: [3]int, numbers: [3]int,
} }
@(test) @(test)
@@ -150,8 +154,8 @@ test_validate_map_path_silent :: proc(t: ^testing.T) {
@(test) @(test)
test_suggest_correction_exact :: proc(t: ^testing.T) { test_suggest_correction_exact :: proc(t: ^testing.T) {
available := []string{"title", "page_title", "body"} available := []string{"title", "page.title", "body"}
testing.expect_value(t, suggest_correction(available, "page_titel"), "page_title") testing.expect_value(t, suggest_correction(available, "page_titel"), "page.title")
} }
@(test) @(test)
@@ -192,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
} }
+80 -63
View File
@@ -2,7 +2,6 @@ package main
import "mustache" import "mustache"
import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:log" import "core:log"
import "core:os" import "core:os"
@@ -11,43 +10,21 @@ import "core:time"
import "core:time/datetime" import "core:time/datetime"
Template_Context :: struct { Template_Context :: struct {
params: json.Value,
now: string, now: string,
content: string,
title: string, title: string,
description: string,
og: Open_Graph,
date_format: string, date_format: string,
timezone: ^datetime.TZ_Region, timezone: ^datetime.TZ_Region,
og: Open_Graph,
// Page Data site: Site_Context,
page_title: string, menus: map[string][]Menu_Entry,
date: string, page: Page,
// Home data // Home data
pages: [dynamic]Page_Context, pages: [dynamic]Page,
// Section Data // Section Data
// TODO: Remove "posts" from the Odin code // TODO: Remove "posts" from the Odin code
posts: [dynamic]Page_Context, posts: [dynamic]Page,
}
Page_Context :: struct {
permalink: string,
title: string,
starred: bool,
date: string,
year: string,
}
build_page_context :: proc(page: Page) -> Page_Context {
return Page_Context {
permalink = page.permalink,
title = page.title,
starred = page.is_starred,
date = page.date,
year = get_year(page.date),
}
} }
load_template :: proc(vfs: ^VFS, virtual_path: string) -> mustache.Template { load_template :: proc(vfs: ^VFS, virtual_path: string) -> mustache.Template {
@@ -113,27 +90,51 @@ 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
}
fallthrough
case:
capitalize_next = false
}
} }
return s return string(out)
} }
render_template :: proc( render_template :: proc(
content_tpl: mustache.Template, content_tpl: mustache.Template,
data: Template_Context, ctx: Template_Context,
partials: map[string]mustache.Template, partials: map[string]mustache.Template,
reported_errors: ^map[string]bool,
) -> string { ) -> string {
result, err := mustache.render(content_tpl, data, partials) result, err := mustache.render(content_tpl, []any{ctx.site, ctx.page, ctx}, partials)
if err != nil { if err != nil {
log.errorf( formatted := mustache.format_render_error(
"%s", err,
mustache.format_render_error(err, content_tpl, colorize = mustache.should_colorize()), content_tpl,
colorize = mustache.should_colorize(),
) )
if formatted in reported_errors^ {
return ""
}
reported_errors[formatted] = true
log.errorf("%s", formatted)
return "" return ""
} }
return result return result
@@ -142,7 +143,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)
@@ -151,15 +152,17 @@ render_site :: proc(site: ^Site) {
template_cache: map[string]mustache.Template template_cache: map[string]mustache.Template
defer delete(template_cache) defer delete(template_cache)
errors := make(map[string]bool, context.temp_allocator)
offset, ok := mustache.compute_utc_offset(site.tz) offset, ok := mustache.compute_utc_offset(site.tz)
assert(ok) assert(ok)
now, ok2 := time.time_to_rfc3339(time.now(), offset, false, allocator) now, ok2 := time.time_to_rfc3339(time.now(), offset, false, allocator)
assert(ok2) assert(ok2)
ctx := Template_Context { ctx := Template_Context {
site = site.site_context,
menus = site.menus,
now = now, now = now,
params = site.params,
description = site.description,
og = site.og, og = site.og,
date_format = site.date.format, date_format = site.date.format,
timezone = site.tz, timezone = site.tz,
@@ -175,6 +178,7 @@ render_site :: proc(site: ^Site) {
break break
} }
} }
ctx.page = home
// Collect sections // Collect sections
sections := make(map[string]bool) sections := make(map[string]bool)
@@ -191,7 +195,7 @@ render_site :: proc(site: ^Site) {
continue continue
} }
tpl := get_template(&site.vfs, page.layout, &template_cache) tpl := get_template(&site.vfs, page.layout, &template_cache)
html := render_page_html(page, site, tpl, partials, ctx) html := render_page_html(page, site, tpl, partials, ctx, &seen)
if .Minify in site.features { if .Minify in site.features {
html = minify_html(html) html = minify_html(html)
} }
@@ -220,6 +224,7 @@ render_site :: proc(site: ^Site) {
section_tpl, section_tpl,
partials, partials,
ctx, ctx,
&seen,
) )
if .Minify in site.features { if .Minify in site.features {
html = minify_html(html) html = minify_html(html)
@@ -230,7 +235,7 @@ render_site :: proc(site: ^Site) {
// Render home page // Render home page
if has_home { if has_home {
home_tpl := get_template(&site.vfs, "home", &template_cache) home_tpl := get_template(&site.vfs, "home", &template_cache)
home_html := render_home_html(home, site, home_tpl, partials, ctx) home_html := render_home_html(home, site, home_tpl, partials, ctx, &seen)
if .Minify in site.features { if .Minify in site.features {
home_html = minify_html(home_html) home_html = minify_html(home_html)
} }
@@ -265,14 +270,13 @@ render_page_html :: proc(
content_tpl: mustache.Template, content_tpl: mustache.Template,
partials: map[string]mustache.Template, partials: map[string]mustache.Template,
ctx: Template_Context, ctx: Template_Context,
seen: ^map[string]bool,
) -> string { ) -> string {
ctx := ctx ctx := ctx
ctx.title = fmt.tprintf("%s | %s", page.title, site.title) ctx.title = fmt.tprintf("%s | %s", page.title, site.title)
ctx.page_title = page.title ctx.page = page
ctx.content = page.content
ctx.date = page.date
ctx.og = og_for_page(site.og, page) ctx.og = og_for_page(site.og, page)
return render_template(content_tpl, ctx, partials) return render_template(content_tpl, ctx, partials, seen)
} }
render_home_html :: proc( render_home_html :: proc(
@@ -281,23 +285,22 @@ render_home_html :: proc(
content_tpl: mustache.Template, content_tpl: mustache.Template,
partials: map[string]mustache.Template, partials: map[string]mustache.Template,
ctx: Template_Context, ctx: Template_Context,
seen: ^map[string]bool,
) -> string { ) -> string {
list_pages := make([dynamic]Page_Context) list_pages := make([dynamic]Page, 0, 8, context.temp_allocator)
defer delete(list_pages)
for page in site.pages { for page in site.pages {
if page._is_index { if page._is_index {
continue continue
} }
append(&list_pages, build_page_context(page)) append(&list_pages, page)
} }
ctx := ctx ctx := ctx
ctx.title = site.title ctx.title = site.title
ctx.content = home.content
ctx.pages = list_pages ctx.pages = list_pages
ctx.og = og_for_page(site.og, home) ctx.og = og_for_page(site.og, home)
return render_template(content_tpl, ctx, partials) return render_template(content_tpl, ctx, partials, seen)
} }
render_section :: proc( render_section :: proc(
@@ -308,33 +311,36 @@ render_section :: proc(
content_tpl: mustache.Template, content_tpl: mustache.Template,
partials: map[string]mustache.Template, partials: map[string]mustache.Template,
ctx: Template_Context, ctx: Template_Context,
seen: ^map[string]bool,
) -> string { ) -> string {
posts := make([dynamic]Page_Context) alloc := site_allocator(site)
defer delete(posts) 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 {
continue continue
} }
append(&posts, build_page_context(page)) append(&posts, page)
} }
ctx := ctx ctx := ctx
if has_index { if has_index {
ctx.content = section_index.content ctx.page = section_index
ctx.page_title = section_index.title
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 {
ctx.page_title = capitalize(section) title := to_title_case(section, alloc)
ctx.title = fmt.tprintf("%s | %s", capitalize(section), site.title) ctx.page = Page {
ctx.og.title = capitalize(section) title = title,
}
ctx.title = fmt.tprintf("%s | %s", ctx.page.title, site.title)
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"
ctx.og.is_article = false ctx.og.is_article = false
} }
ctx.posts = posts ctx.posts = posts
return render_template(content_tpl, ctx, partials) return render_template(content_tpl, ctx, partials, seen)
} }
load_partials :: proc(vfs: ^VFS) -> map[string]mustache.Template { load_partials :: proc(vfs: ^VFS) -> map[string]mustache.Template {
@@ -380,11 +386,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
} }
@@ -392,6 +399,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] == '/' {
+24 -7
View File
@@ -13,24 +13,32 @@ import "core:time/timezone"
import md "markdown" import md "markdown"
// Site is the primary workhorse. // Site_Context holds the site date that is accessible in templates.
Site_Context :: struct {
title: string,
description: string,
base_url: string,
params: json.Object,
og: Open_Graph,
menus: map[string][]Menu_Entry,
}
// Site is the primary workhorse, containing everything needed to build the site,
// including an arena allocator, the pages, and all the various directories and
// enabled features.
Site :: struct { Site :: struct {
using site_context: Site_Context,
arena: mem.Dynamic_Arena, arena: mem.Dynamic_Arena,
pages: #soa[dynamic]Page, pages: #soa[dynamic]Page,
modules: [dynamic]string, modules: [dynamic]string,
vfs: VFS, vfs: VFS,
title: string,
description: string,
base_url: string,
config_path: string, config_path: string,
content_dir: string, content_dir: string,
assets_dir: string, assets_dir: string,
output_dir: string, output_dir: string,
layouts_dir: string, layouts_dir: string,
params: json.Object,
features: bit_set[Feature], features: bit_set[Feature],
markdown_extensions: bit_set[md.Extension], markdown_extensions: bit_set[md.Extension],
og: Open_Graph,
date: Date_Preferences, date: Date_Preferences,
tz: ^datetime.TZ_Region, tz: ^datetime.TZ_Region,
grammars: string, grammars: string,
@@ -61,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,
@@ -193,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))
} }
@@ -255,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" }
]
}
}
+11 -4
View File
@@ -146,7 +146,7 @@ init_persistent :: proc() {
} }
when SPALL { when SPALL {
_thread_init: proc() = nil _thread_init: proc() = nil
_thread_cleanup: proc() = nil _thread_cleanup: proc() = nil
set_thread_callbacks :: proc(init: proc() = nil, cleanup: proc() = nil) { set_thread_callbacks :: proc(init: proc() = nil, cleanup: proc() = nil) {
@@ -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
} }