mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
Compare commits
28 Commits
main
...
e71c2a94e5
| Author | SHA1 | Date | |
|---|---|---|---|
| e71c2a94e5 | |||
| ef7327cc52 | |||
| 7b67201791 | |||
| 0307ab647f | |||
| 9e19b0dc2c | |||
| 6d97a23568 | |||
| 89c2ffbf78 | |||
| 4659aa8dc2 | |||
| d03e3469b3 | |||
| df14d76a85 | |||
| c48afafd8a | |||
| 92d7b7525a | |||
| b54eb76495 | |||
| 137b691a71 | |||
| 06e411a329 | |||
| 2299dcd652 | |||
| 40bd2ad810 | |||
| eabb8a1430 | |||
| c634e2e718 | |||
| f839ec0f04 | |||
| df0ed5a8f1 | |||
| fb32ce90fc | |||
| 0abac89d88 | |||
| 1824bae26c | |||
| 5627215d6f | |||
| 9d1954d70a | |||
| fcf349eb51 | |||
| d51172c836 |
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
+317
@@ -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.
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -2,12 +2,46 @@
|
|||||||
|
|
||||||
- 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.
|
- [ ] 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.
|
||||||
- [ ] 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 data 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 +65,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 +99,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 +122,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 +143,10 @@ main :: proc () {
|
|||||||
- [ ] running ./thor/thor still logs the debug message: using config /home/spencer/github.com/sbrow.github.io/thor.json
|
- [ ] running ./thor/thor still logs the debug message: using config /home/spencer/github.com/sbrow.github.io/thor.json
|
||||||
- wrong cwd?
|
- wrong cwd?
|
||||||
- [ ] Clean up the default layouts
|
- [ ] Clean up the default layouts
|
||||||
|
- [ ] Menus
|
||||||
|
- [ ] Detailed frontmatter menu form ("menu": {"main": {"weight": 5}})
|
||||||
|
- [ ] Menu active state (pre-compute is_active based on page.permalink prefix match)
|
||||||
|
- [ ] Page.weight field for general-purpose page ordering (menus, lists, related posts)
|
||||||
- [ ] if no `html` tag detected in output, re-render output with base template
|
- [ ] if no `html` tag detected in output, re-render output with base template
|
||||||
(or whatever template is next in the chain)
|
(or whatever template is next in the chain)
|
||||||
- [ ] Add `-production` flag
|
- [ ] Add `-production` flag
|
||||||
@@ -145,6 +199,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
@@ -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,
|
||||||
|
|||||||
@@ -127,4 +127,3 @@ to_benchmark :: proc($f: formatter) -> benchmark {
|
|||||||
} \
|
} \
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+37
-13
@@ -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]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{{site.title}}
|
||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -149,4 +149,3 @@ xml_escape :: proc(s: string) -> string {
|
|||||||
r, _ = strings.replace_all(r, ">", ">")
|
r, _ = strings.replace_all(r, ">", ">")
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,4 +94,3 @@ when SPALL {
|
|||||||
spall._buffer_end(&spall_ctx, &spall_buffer)
|
spall._buffer_end(&spall_ctx, &spall_buffer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,4 +101,3 @@ test_multiple_alerts_render_together :: proc(t: ^testing.T) {
|
|||||||
</blockquote>`,
|
</blockquote>`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -434,4 +434,3 @@ expand_emoji :: proc(text: string) -> string {
|
|||||||
|
|
||||||
return strings.to_string(sb)
|
return strings.to_string(sb)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 :")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -210,4 +210,3 @@ strip_p_tags :: proc(html: string) -> string {
|
|||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -193,4 +193,3 @@ make_unique :: proc(slug: string, seen: ^map[string]bool) -> string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,4 +305,3 @@ highlight_code :: proc(html: string, file_path: string) -> string {
|
|||||||
}
|
}
|
||||||
return strings.to_string(sb)
|
return strings.to_string(sb)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,4 +88,3 @@ apply_extension_config :: proc(ext: ^bit_set[Extension], config: json.Object) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,4 +43,3 @@ wrap_sections :: proc(html: string) -> string {
|
|||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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:])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, colorize = colorize, span = b.span)
|
||||||
return format_error(path, tmpl.source, b.pos, b.msg, colorize = colorize)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -321,4 +321,3 @@ convert_to_tz :: proc(
|
|||||||
},
|
},
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+116
-27
@@ -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)
|
||||||
|
|||||||
@@ -152,4 +152,3 @@ test_lambda_inverted_section :: proc(t: ^testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
+248
-151
@@ -1,34 +1,67 @@
|
|||||||
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
|
kind = b.kind,
|
||||||
|
source = tmpl.source,
|
||||||
|
path = tmpl.path,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,9 +96,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 +177,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 +227,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 +263,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 +277,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 +297,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 +318,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 +335,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 +379,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 +387,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 +410,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 +437,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 +536,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 +589,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 +619,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 +633,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 +661,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 +675,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 +699,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 +711,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 +752,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 +763,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 +797,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 +998,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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
+46
-22
@@ -17,8 +17,9 @@ MAX_PIPE_ARGS :: 2
|
|||||||
DEFAULT_DATE_FORMAT :: "2 Jan 2006"
|
DEFAULT_DATE_FORMAT :: "2 Jan 2006"
|
||||||
|
|
||||||
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 +54,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 +78,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 +88,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 +102,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 +158,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,23 +201,23 @@ 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
|
// TODO: diagnostics don't show anything relevant
|
||||||
apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) -> (any, Error) {
|
apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) -> (any, Error) {
|
||||||
switch filter.op {
|
switch filter.op {
|
||||||
case "group_by":
|
case "group_by":
|
||||||
@@ -239,8 +263,9 @@ apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) ->
|
|||||||
|
|
||||||
case:
|
case:
|
||||||
return nil, Error_Body {
|
return nil, Error_Body {
|
||||||
msg = fmt.tprintf("unknown pipe op '%s'", filter.op),
|
msg = fmt.tprintf("unknown pipe op '%s'", filter.op),
|
||||||
pos = pos,
|
pos = filter.op_pos,
|
||||||
|
span = len(filter.op),
|
||||||
kind = .Data,
|
kind = .Data,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,4 +362,3 @@ apply_group_by :: proc(value: any, args: []string, pos: int) -> (result: any, er
|
|||||||
|
|
||||||
return groups, nil
|
return groups, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-14
@@ -214,7 +214,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 +230,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 +246,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 +271,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 +282,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 +293,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 +305,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 +331,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 +345,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 +360,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 +382,11 @@ test_format_quoted_literal_arg :: proc(t: ^testing.T) {
|
|||||||
date = "2026-03-15T08:49:54-04:00",
|
date = "2026-03-15T08:49:54-04:00",
|
||||||
date_format = "2 Jan 2006",
|
date_format = "2 Jan 2006",
|
||||||
}
|
}
|
||||||
tpl, _ := parse(`{{date | format "Jan 2, 2006"}}`, "<test>", allocator = context.temp_allocator)
|
tpl, _ := parse(
|
||||||
|
`{{date | format "Jan 2, 2006"}}`,
|
||||||
|
"<test>",
|
||||||
|
allocator = context.temp_allocator,
|
||||||
|
)
|
||||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||||
testing.expect_value(t, result, "Mar 15, 2026")
|
testing.expect_value(t, result, "Mar 15, 2026")
|
||||||
}
|
}
|
||||||
@@ -465,7 +477,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)
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,4 +115,3 @@ og_for_page :: proc(site_og: Open_Graph, page: Page) -> Open_Graph {
|
|||||||
|
|
||||||
return og
|
return og
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+59
-56
@@ -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,22 +90,39 @@ 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,
|
||||||
) -> 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(
|
log.errorf(
|
||||||
"%s",
|
"%s",
|
||||||
@@ -142,7 +136,7 @@ render_template :: proc(
|
|||||||
render_site :: proc(site: ^Site) {
|
render_site :: proc(site: ^Site) {
|
||||||
allocator := site_allocator(site)
|
allocator := site_allocator(site)
|
||||||
pages := site.pages[:]
|
pages := site.pages[:]
|
||||||
sort_pages_by_date(pages)
|
sort_pages(pages)
|
||||||
|
|
||||||
// Load shared resources
|
// Load shared resources
|
||||||
partials := load_partials(&site.vfs)
|
partials := load_partials(&site.vfs)
|
||||||
@@ -157,9 +151,9 @@ render_site :: proc(site: ^Site) {
|
|||||||
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 +169,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)
|
||||||
@@ -268,9 +263,7 @@ render_page_html :: proc(
|
|||||||
) -> 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)
|
||||||
}
|
}
|
||||||
@@ -282,18 +275,16 @@ render_home_html :: proc(
|
|||||||
partials: map[string]mustache.Template,
|
partials: map[string]mustache.Template,
|
||||||
ctx: Template_Context,
|
ctx: Template_Context,
|
||||||
) -> 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)
|
||||||
|
|
||||||
@@ -309,25 +300,27 @@ render_section :: proc(
|
|||||||
partials: map[string]mustache.Template,
|
partials: map[string]mustache.Template,
|
||||||
ctx: Template_Context,
|
ctx: Template_Context,
|
||||||
) -> 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"
|
||||||
@@ -380,11 +373,12 @@ get_year :: proc(iso: string) -> string {
|
|||||||
return iso[:4]
|
return iso[:4]
|
||||||
}
|
}
|
||||||
|
|
||||||
sort_pages_by_date :: proc(pages: #soa[]Page) {
|
// Weight primary (ascending). Date secondary (descending) for equal weights.
|
||||||
|
sort_pages :: proc(pages: #soa[]Page) {
|
||||||
for i in 1 ..< len(pages) {
|
for i in 1 ..< len(pages) {
|
||||||
key := pages[i]
|
key := pages[i]
|
||||||
j := i - 1
|
j := i - 1
|
||||||
for j >= 0 && pages.date[j] < key.date {
|
for j >= 0 && compare_pages(pages, j, key) > 0 {
|
||||||
pages[j + 1] = pages[j]
|
pages[j + 1] = pages[j]
|
||||||
j -= 1
|
j -= 1
|
||||||
}
|
}
|
||||||
@@ -392,6 +386,16 @@ sort_pages_by_date :: proc(pages: #soa[]Page) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
compare_pages :: proc(pages: #soa[]Page, j: int, key: Page) -> int {
|
||||||
|
wj := pages.weight[j].? or_else DEFAULT_WEIGHT
|
||||||
|
wk := key.weight.? or_else DEFAULT_WEIGHT
|
||||||
|
if wj != wk do return wj - wk
|
||||||
|
// Equal weight → date descending
|
||||||
|
if pages.date[j] < key.date do return 1
|
||||||
|
if pages.date[j] > key.date do return -1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
write_page :: proc(output_dir: string, permalink: string, html: string) {
|
write_page :: proc(output_dir: string, permalink: string, html: string) {
|
||||||
rel := permalink
|
rel := permalink
|
||||||
if len(rel) > 0 && rel[0] == '/' {
|
if len(rel) > 0 && rel[0] == '/' {
|
||||||
@@ -413,4 +417,3 @@ write_file :: proc(path: string, html: string) {
|
|||||||
log.errorf("cannot write %s: %v", path, err)
|
log.errorf("cannot write %s: %v", path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user