mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 824101a356 | |||
| 06afc15772 | |||
| 730fe6715c | |||
| ae721b3caa | |||
| 98c0efba44 | |||
| 298ae58ca5 | |||
| 27895ea2dc |
@@ -21,16 +21,15 @@ 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, Template_Context, sort_pages, RSS, sitemap
|
├── render.odin # Template rendering, data structs, RSS, sitemap
|
||||||
├── 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), init_site
|
||||||
├── 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, generate_description
|
├── html.odin # HTML helpers: strip_html_tags, unescape_html, generate_summary (word-count truncation), generate_description (scrub to plain text)
|
||||||
├── opengraph.odin # Open_Graph struct + og_for_site/og_for_page
|
├── opengraph.odin # Open_Graph struct + og_for_site/og_for_page
|
||||||
├── frontmatter.odin # JSON frontmatter parser (supports nested og + lastmod + weight + menus)
|
├── frontmatter.odin # JSON frontmatter parser (supports nested og + lastmod)
|
||||||
├── 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
|
||||||
@@ -42,17 +41,16 @@ 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), `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`. |
|
| `site.odin` | `Flags` (CLI), `Config_File` (thor.json, includes `og: Open_Graph`), `Site` (runtime state + arena + VFS + pages + modules + `og`). `Feature` enum. 5-step `init_site`. Imports `md "markdown"` for `Extension` enum. |
|
||||||
| `content.odin` | `Page` struct (includes `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. |
|
| `content.odin` | `Page` struct (includes `lastmod`, `og`), `Pending_File` struct, `scan_content_files` (section-aware walk that handles leaf bundles), `collect_languages` (pre-scan for code fence languages), `load_page`, `infer_layout`. Calls `md.process()` for the markdown pipeline. |
|
||||||
| `render.odin` | Template rendering: `render_site`, `render_page_html`, `render_home_html`, `render_section`. `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`). |
|
| `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`). |
|
||||||
| `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). Description falls back to `generate_description(generate_summary(body_html))`. |
|
| `opengraph.odin` | `Open_Graph` struct (fields ordered per OGP spec, `is_article: Maybe(bool)`). `og_for_site(site)` for site defaults (from config + derived), `og_for_page(site_og, page)` for page-specific (overlay page.og + derive from page data). |
|
||||||
| `frontmatter.odin` | JSON frontmatter parser (`{ }` delimited). Supports `layout`, `lastmod`, `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). |
|
| `frontmatter.odin` | JSON frontmatter parser (`{ }` delimited). Supports `layout`, `lastmod`, and nested `og` object (via `json_get_open_graph`). |
|
||||||
| `defaults.odin` | `DEFAULTS_PATH` constant, resolved at compile time via `#directory` so bundled templates ship in the binary. |
|
| `defaults.odin` | `DEFAULTS_PATH` constant, resolved at compile time via `#directory` so bundled templates ship in the binary. |
|
||||||
|
|
||||||
### Subpackages
|
### Subpackages
|
||||||
@@ -66,7 +64,6 @@ 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). |
|
||||||
|
|
||||||
@@ -77,10 +74,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 + build_menus + warn_all_duplicate_weights)
|
→ site_load_content (scan_content_files + collect_languages + preload_grammars + load_page + url computation)
|
||||||
→ 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 (3-frame context stack: site, page, ctx)
|
→ render_page_html / render_home_html / render_section
|
||||||
→ optional minify_html
|
→ optional minify_html
|
||||||
→ public/
|
→ public/
|
||||||
```
|
```
|
||||||
@@ -97,14 +94,12 @@ 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,
|
||||||
menus: map[string]Menu_Entry, // frontmatter menu assignments
|
menu: string,
|
||||||
content: string, // rendered HTML body
|
body_html: string,
|
||||||
og: Open_Graph,
|
|
||||||
draft: bool,
|
draft: bool,
|
||||||
starred: bool,
|
is_starred: bool,
|
||||||
|
og: Open_Graph, // per-page OG overrides from frontmatter
|
||||||
_is_index: bool `private`,
|
_is_index: bool `private`,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -118,40 +113,6 @@ 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:
|
||||||
@@ -185,12 +146,6 @@ 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" }
|
||||||
@@ -270,26 +225,36 @@ Templates use Mustache with template inheritance (`{{<base}}` / `{{$block}}`):
|
|||||||
{{/base}}
|
{{/base}}
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
Data is passed as **typed structs** (not `map[string]any`). Mustache resolves struct fields via Odin reflection, including `using`-embedded fields. Date presence is checked via string truthiness (`{{#date}}`) — no separate `has_date` bool needed. Dates are stored as raw ISO strings; presentation formatting happens in the template via the `format` pipe (see Pipes extension below).
|
||||||
|
|
||||||
```odin
|
```odin
|
||||||
Template_Context :: struct {
|
Base_Data :: struct {
|
||||||
site: Site_Context, // site-level data (title, description, base_url, params, og)
|
now: string, // UTC ISO 8601 build timestamp
|
||||||
menus: map[string][]Menu_Entry, // generated menu data (copied from site, resolves above Page.menus)
|
params: json.Value,
|
||||||
now: string, // UTC ISO 8601 build timestamp
|
content: string,
|
||||||
title: string, // computed browser title ("Page | Site")
|
title: string,
|
||||||
date_format: string, // from site.date.format (thor.json)
|
description: string,
|
||||||
timezone: ^datetime.TZ_Region, // for format pipe
|
og: Open_Graph,
|
||||||
og: Open_Graph, // computed per-page OG
|
date_format: string, // from site.date.format (thor.json)
|
||||||
page: Page, // current page
|
timezone: ^datetime.TZ_Region, // loaded from site.date.timezone or local, owned by Site
|
||||||
pages: [dynamic]Page, // home page list
|
}
|
||||||
posts: [dynamic]Page, // section post list
|
Page_Data :: struct {
|
||||||
|
using base: Base_Data, // fields promoted via reflection fallback
|
||||||
|
page.title: string,
|
||||||
|
date: string, // raw ISO 8601; formatted via `| format` in templates
|
||||||
|
}
|
||||||
|
Home_Data :: struct {
|
||||||
|
using base: Base_Data,
|
||||||
|
pages: [dynamic]Page_Context,
|
||||||
|
}
|
||||||
|
Section_Data :: struct {
|
||||||
|
using base: Base_Data,
|
||||||
|
page.title: string,
|
||||||
|
posts: [dynamic]Page_Context, // flat list; year grouping done in template via pipe
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`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) — there is no `Year_Section` Go-side struct.
|
||||||
|
|
||||||
`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
|
||||||
|
|
||||||
@@ -491,12 +456,6 @@ 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`.
|
- **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`.
|
- **`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.
|
- **`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
|
||||||
|
|
||||||
|
|||||||
-294
@@ -1,294 +0,0 @@
|
|||||||
# Thor — UX Problems Catalog
|
|
||||||
|
|
||||||
Adversarial review of error messages, behavioral inconsistencies, and user
|
|
||||||
frustration points. Established as a baseline on commit `314cab2`.
|
|
||||||
|
|
||||||
Each entry cites the source location so it can be tracked to a fix.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Severity legend
|
|
||||||
|
|
||||||
- **Critical** — user mistake produces silent wrong output or an unhelpful
|
|
||||||
fatal error with no path forward.
|
|
||||||
- **High** — error or warning is emitted but missing "where" or "how to fix."
|
|
||||||
- **Medium** — inconsistency or gotcha that causes confusion or rework.
|
|
||||||
- **Low** — polish / minor frustration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. Silent wrong output (no error, wrong result)
|
|
||||||
|
|
||||||
These are the most dangerous — the user gets *no signal* that something is wrong.
|
|
||||||
|
|
||||||
### A1. Non-JSON frontmatter silently treated as body content — Critical
|
|
||||||
`frontmatter.odin:26`
|
|
||||||
|
|
||||||
Thor expects JSON frontmatter delimited by bare `{` / `}` lines. A user
|
|
||||||
coming from Hugo/Jekyll writes YAML (`---`) or TOML (`+++`) frontmatter. It is
|
|
||||||
silently swallowed into the markdown body. No title, no date, no draft flag —
|
|
||||||
and no error. Likely the #1 onboarding trap.
|
|
||||||
|
|
||||||
### A2. Unknown `thor.json` keys silently ignored — Critical
|
|
||||||
`site.odin:162`
|
|
||||||
|
|
||||||
`json.unmarshal_string` skips unknown fields. A typo like `"tittle"` instead
|
|
||||||
of `"title"` produces a silently-empty title. No warning. (`TODOS.md` already
|
|
||||||
wants a JSON schema.)
|
|
||||||
|
|
||||||
### A3. Draft pages silently excluded — High
|
|
||||||
`content.odin:64`
|
|
||||||
|
|
||||||
When `-drafts` isn't passed, draft pages vanish with no log. User adds a
|
|
||||||
page, forgets the flag, page doesn't appear — zero feedback.
|
|
||||||
|
|
||||||
### A4. Naive singularization for layout inference — High
|
|
||||||
`content.odin:202`
|
|
||||||
|
|
||||||
`posts` → `post` (correct), but `series` → `serie`, `news` → `new`. The
|
|
||||||
layout silently falls through the fallback chain to `page`/`base`. No
|
|
||||||
"layout 'serie' not found for section 'series'" message — only a debug log
|
|
||||||
that's off by default.
|
|
||||||
|
|
||||||
### A5. `base_url` defaults to `localhost:8080` — Critical
|
|
||||||
`site.odin:100`
|
|
||||||
|
|
||||||
Forgetting to set it means every canonical URL, OG tag, and RSS link points
|
|
||||||
to localhost. No warning. Devastating in production builds.
|
|
||||||
|
|
||||||
### A6. Missing `content/` produces empty build — High
|
|
||||||
`content.odin:82`
|
|
||||||
|
|
||||||
`scan_content_files` logs a `warnf`, the build proceeds with zero pages, then
|
|
||||||
`log.infof("Rendered 0 pages")`. No fatal error, no "did you create
|
|
||||||
content/?" guidance.
|
|
||||||
|
|
||||||
### A7. RSS emits sentinel epoch date silently — Medium
|
|
||||||
`feed.odin:33`
|
|
||||||
|
|
||||||
Pages without a date get `"Mon, 01 Jan 0001 00:00:00 +0000"` in `<pubDate>`.
|
|
||||||
No warning that a page is dateless in the feed.
|
|
||||||
|
|
||||||
### A8. `format_rfc822` returns raw ISO on parse failure — Medium
|
|
||||||
`feed.odin:122-125`
|
|
||||||
|
|
||||||
`// TODO: should indicate error somehow` — short/malformed dates get embedded
|
|
||||||
verbatim in `<pubDate>`, producing invalid RSS with no warning.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. Error messages missing "where" or "how to fix"
|
|
||||||
|
|
||||||
### B1. `render_template` blanks the entire page on error — Critical
|
|
||||||
`render.odin:119-133`
|
|
||||||
|
|
||||||
A single bad tag/pipe anywhere produces `log.errorf` + `return ""`. The
|
|
||||||
output file is silently written empty. In a `nix build` (no visible
|
|
||||||
terminal), the user sees a blank page with zero clue why. Already noted in
|
|
||||||
`TODOS.md`.
|
|
||||||
|
|
||||||
### B2. Malformed `thor.json` degrades to defaults — Critical
|
|
||||||
`site.odin:162-168`
|
|
||||||
|
|
||||||
A JSON syntax error is a `warnf`, then `site_apply_path_defaults` kicks in.
|
|
||||||
The site builds with wrong paths and produces a confusing empty result — the
|
|
||||||
cause is two hops removed from the symptom.
|
|
||||||
|
|
||||||
### B3. Frontmatter parse error has no file location — Critical
|
|
||||||
`frontmatter.odin:41`
|
|
||||||
|
|
||||||
`"failed to parse frontmatter JSON: %v"` — no filename. On a 100-post site
|
|
||||||
the user can't find the bad file. Worse: `ok=false` silently drops the page
|
|
||||||
entirely.
|
|
||||||
|
|
||||||
### B4. `get_template` returns empty `Template{}` on missing base — High
|
|
||||||
`render.odin:88-89`
|
|
||||||
|
|
||||||
`"base.html not found in VFS"` — no guidance on how to fix (create the file,
|
|
||||||
check modules, etc.).
|
|
||||||
|
|
||||||
### B5. `dlopen` failures lack the OS reason and fix guidance — High
|
|
||||||
`treesitter/treesitter.odin:200-219`
|
|
||||||
|
|
||||||
"cannot load grammar %s (%s)" shows the path but not *why* (no `dlerror()`).
|
|
||||||
No guidance: "set the 'grammars' key in thor.json" or "this .so may be for a
|
|
||||||
different tree-sitter ABI."
|
|
||||||
|
|
||||||
### B6. Menu-mix fatal lacks location — High
|
|
||||||
`menus.odin:42`
|
|
||||||
|
|
||||||
`"cannot mix config menus with frontmatter menus"` — doesn't name which pages
|
|
||||||
have frontmatter menus.
|
|
||||||
|
|
||||||
### B7. Minify error doesn't name the page — Medium
|
|
||||||
`minify.odin:33`
|
|
||||||
|
|
||||||
"minify: HTML parse errors, skipping minification" — across 50 pages, which
|
|
||||||
one?
|
|
||||||
|
|
||||||
### B8. Timezone load failure is a warning with no guidance — Medium
|
|
||||||
`site.odin:148`
|
|
||||||
|
|
||||||
Doesn't state impact (dates render in UTC) or suggest valid names. Already
|
|
||||||
in `TODOS.md`.
|
|
||||||
|
|
||||||
### B9. No "config not found" message — Medium
|
|
||||||
`site.odin:113`
|
|
||||||
|
|
||||||
Silently falls back to `./thor.json`. Wrong-directory runs produce a
|
|
||||||
confusing default build.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. Silent skip of invalid user input
|
|
||||||
|
|
||||||
### C1. Unknown markdown extensions silently ignored (CLI) — High
|
|
||||||
`markdown/markdown.odin:49-68`
|
|
||||||
|
|
||||||
`parse_extension_list` has a switch with no default case. `-ext:higlight`
|
|
||||||
(typo for `highlight`) is silently a no-op.
|
|
||||||
|
|
||||||
### C2. Unknown markdown extensions silently ignored (config) — High
|
|
||||||
`markdown/markdown.odin:71-90`
|
|
||||||
|
|
||||||
`apply_extension_config` has a `// TODO: Silently discards invalid values.`
|
|
||||||
Unknown keys in `thor.json`'s `markdown_extensions` are silently dropped.
|
|
||||||
Non-boolean values are `or_continue`d.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. Naming inconsistencies
|
|
||||||
|
|
||||||
### D1. Markdown extensions have 3+ names — Medium
|
|
||||||
|
|
||||||
| Context | Name |
|
|
||||||
|---|---|
|
|
||||||
| `thor.json` key | `markdown_extensions` |
|
|
||||||
| CLI flag | `-ext` / `-no-ext` |
|
|
||||||
| Struct fields | `md_enable` / `md_disable` |
|
|
||||||
| JSON/CLI values | `emoji`, `sidenotes` (lowercase) |
|
|
||||||
| Enum members | `.Emoji`, `.Sidenotes` (PascalCase) |
|
|
||||||
|
|
||||||
### D2. `-ext` usage string omits `heading_ids` — Medium
|
|
||||||
`site.odin:91`
|
|
||||||
|
|
||||||
The help text lists `emoji,sidenotes,alerts,highlight,sections` but the enum
|
|
||||||
also has `HeadingIDs`. Users can't discover it from `--help`.
|
|
||||||
|
|
||||||
### D3. Starred field has three names — Low
|
|
||||||
- `Page.starred` (`content.odin:28`)
|
|
||||||
- `Frontmatter.isStarred` (`frontmatter.odin:17`) — so the JSON key is `isStarred`
|
|
||||||
- `AGENTS.md:101` says `is_starred` (stale)
|
|
||||||
|
|
||||||
### D4. Inconsistent error severity for similar failures — Medium
|
|
||||||
- Template **parse** error → `log.errorf` + `os.exit(1)` (fatal) — `render.odin:37-49`
|
|
||||||
- Template **render** error → `log.errorf` + return `""` (non-fatal, blank page) — `render.odin:126-131`
|
|
||||||
- Config parse error → `warnf` + fallback to defaults — `site.odin:163-165`
|
|
||||||
|
|
||||||
Same category of failure (user wrote something wrong) with wildly different
|
|
||||||
consequences.
|
|
||||||
|
|
||||||
### D5. Dead `os.exit(1)` after `log.fatalf` — Low
|
|
||||||
`render.odin:33`, `menus.odin:43`
|
|
||||||
|
|
||||||
`fatalf` already exits; the following line is dead code.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. Configuration gotchas
|
|
||||||
|
|
||||||
### E1. `"menus": {}` is a stealth opt-out — Medium
|
|
||||||
`menus.odin:31-38`
|
|
||||||
|
|
||||||
An empty object silently disables *all* auto-menus. A user who adds the key
|
|
||||||
intending to configure later quietly loses their nav. The semantics (absent
|
|
||||||
≠ empty) are undocumented outside code comments.
|
|
||||||
|
|
||||||
### E2. Config precedence is invisible — Medium
|
|
||||||
`site.odin`
|
|
||||||
|
|
||||||
CLI > JSON > defaults, but there's no "resolved config" log. Debugging "why
|
|
||||||
is my base_url wrong?" requires reading source.
|
|
||||||
|
|
||||||
### E3. `format` pipe logs ERROR but still renders — Medium
|
|
||||||
`pipes.odin:261-265`
|
|
||||||
|
|
||||||
Missing `date.format` produces `log.errorf` but falls back to
|
|
||||||
`DEFAULT_DATE_FORMAT`. The severity says "error" but the behavior says
|
|
||||||
"warning."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. File/directory behavior surprises
|
|
||||||
|
|
||||||
### F1. Root dirs = sections, nested dirs = leaf bundles — Medium
|
|
||||||
`content.odin:108-127`
|
|
||||||
|
|
||||||
This meaningful semantic distinction is entirely implicit.
|
|
||||||
`content/about/team.md` is a leaf bundle (page "about" with body from
|
|
||||||
team.md), not a section "about" with page "team". No error or guidance when
|
|
||||||
the user's mental model differs.
|
|
||||||
|
|
||||||
### F2. Missing `layouts/` silently uses defaults — High
|
|
||||||
`vfs.odin:38-40`
|
|
||||||
|
|
||||||
`mount_dir` returns silently if the directory doesn't exist. Wrong path →
|
|
||||||
all user templates missing → defaults used. No "layouts directory X not
|
|
||||||
found" message.
|
|
||||||
|
|
||||||
### F3. Missing section index silently synthesized — Medium
|
|
||||||
`render.odin:316-326`
|
|
||||||
|
|
||||||
A section with pages but no `index.md` gets a synthetic `Page` with only a
|
|
||||||
title. No warning. User expecting an error gets a mostly-blank page.
|
|
||||||
|
|
||||||
### F4. Windows line endings silently break frontmatter — Medium
|
|
||||||
`frontmatter.odin:26`
|
|
||||||
|
|
||||||
`has_prefix(content, "{\n")` fails on `\r\n`; the JSON is treated as body.
|
|
||||||
Zero feedback.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## G. Template authoring frustrations
|
|
||||||
|
|
||||||
### G1. Template fallback chain is silent at Info level — Medium
|
|
||||||
`render.odin:84`
|
|
||||||
|
|
||||||
Only `log.debugf`, which is off by default (`main.odin:48` sets `.Info`).
|
|
||||||
User's custom layout silently ignored, defaults used.
|
|
||||||
|
|
||||||
### G2. Render error blanks entire page — Critical
|
|
||||||
`render.odin:126-131`
|
|
||||||
|
|
||||||
(Same as B1 — restated here for the template-authoring perspective.) One bad
|
|
||||||
tag → whole page `""`. The most impactful silent failure in the system.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cross-cutting themes
|
|
||||||
|
|
||||||
1. **Debug-level logging masks important fallbacks.** Layout fallbacks,
|
|
||||||
template misses, and grammar skips are all `debugf` — invisible at the
|
|
||||||
default Info level. Users never learn their customizations were ignored.
|
|
||||||
|
|
||||||
2. **The system fails open, not closed.** Missing files, missing
|
|
||||||
directories, missing config — all silently fall back to defaults rather
|
|
||||||
than surfacing the problem. Friendly until it isn't.
|
|
||||||
|
|
||||||
3. **No "resolved state" visibility.** There's no way for a user to see what
|
|
||||||
thor actually loaded: which layouts, which config values, which pages
|
|
||||||
were skipped as drafts. The build is a black box.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Gold-standard examples to emulate
|
|
||||||
|
|
||||||
These are the parts of the codebase that already do it right:
|
|
||||||
|
|
||||||
- **Mustache diagnostics** (`mustache/diagnostic.odin` + `suggest.odin`):
|
|
||||||
rust-style multi-line context, caret underlines, Levenshtein "did you
|
|
||||||
mean?" hints, file:line:col.
|
|
||||||
- **Treesitter query version-mismatch** (`treesitter/treesitter.odin:299-315`):
|
|
||||||
explains the likely cause, shows both grammar/query versions, flags
|
|
||||||
mismatches explicitly.
|
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
## High priority
|
## High priority
|
||||||
|
|
||||||
- Polish existing features before moving on to new ones.
|
- Polish existing features before moving on to new ones.
|
||||||
|
- [ ] Add Weights
|
||||||
|
- [ ] sort by `page.weight` when loading
|
||||||
|
- [ ] re-sort by `page.menu.weight` when building menus.
|
||||||
|
- [ ] warn user when 2 pages with explicit weights match.
|
||||||
- [ ] Improve diagnostics
|
- [ ] Improve diagnostics
|
||||||
- [ ] All Diagnostics should show:
|
- [ ] All Diagnostics should show:
|
||||||
- [ ] *What* went wrong
|
- [ ] *What* went wrong
|
||||||
@@ -12,29 +16,27 @@
|
|||||||
- [ ] show "stack traces" in template error diagnostics
|
- [ ] show "stack traces" in template error diagnostics
|
||||||
- [ ] better diagnostics for syntax errors in treesitter.
|
- [ ] better diagnostics for syntax errors in treesitter.
|
||||||
- [ ] Ensure diagnostics for MAX_CONTEXT_DEPTH are good.
|
- [ ] Ensure diagnostics for MAX_CONTEXT_DEPTH are good.
|
||||||
- [ ] improve matching weights message.
|
|
||||||
- [ ] show a proper diagnostic for timezones
|
- [ ] show a proper diagnostic for timezones
|
||||||
- currently "unable to load timezone 'America/New_Yorkskie'"
|
- currently "unable to load timezone 'America/New_Yorkskie'"
|
||||||
- want rust style diagnostic and better message, maybe "unknown timezone 'America/New_Yorkskie'"
|
- want rust style diagnostic and better message, maybe "unknown timezone 'America/New_Yorkskie'"
|
||||||
- [ ] Test menu diagnostics
|
- [ ] Test menu diagnostics
|
||||||
- [ ] Honestly, Test **all** diagnostics
|
- [ ] Honestly, Test **all** diagnostics
|
||||||
- [ ] Need to be careful about diagnostics across module boundaries.
|
- [x] Simplify / unify template context stack. Come up with a name for it.
|
||||||
- we don't necessarily want to warn users about theme designers mistakes. (though perhaps we do)
|
- [x] `render_template` should accept `Template_Context`, not `any`
|
||||||
- [ ] consider reporting duplicate weights outside of menus
|
|
||||||
- [ ] Load grammars dynamically
|
- [ ] Load grammars dynamically
|
||||||
|
- [x] consider adding a limit to the context stack in mustache.
|
||||||
|
- [x] Add heading ids as a default on extension.
|
||||||
- [ ] starred must be a param.
|
- [ ] starred must be a param.
|
||||||
|
- [x] Add a `#config(MAX_CONTEXT_DEPTH, 16?)` to `mustache`.
|
||||||
- [ ] Documentation
|
- [ ] Documentation
|
||||||
- [ ] talk about the context stack (and its limit).
|
- [ ] talk about the context stack (and its limit).
|
||||||
- [ ] highlight the differences in the way menus are handled.
|
- [ ] highlight the differences in the way menus are handled.
|
||||||
- [ ] consider sites with data based urls.
|
- [ ] consider sites with data based urls.
|
||||||
|
- [ ] menu system
|
||||||
|
- [ ] like Hugo's, but warn(/fail?) if menus are defined in the config *and* pages.
|
||||||
|
- i.e. force the user to choose one or the other.
|
||||||
- [ ] Don't show annoying log output in tests.
|
- [ ] Don't show annoying log output in tests.
|
||||||
- [ ] improve home link customization.
|
- [ ] improve home link customization.
|
||||||
- [ ] currently an accessibility issue.
|
|
||||||
- [ ] support JSON5 in in frontmatter
|
|
||||||
- [ ] Create a json schema file for `thor.json`.
|
|
||||||
- [ ] cleanup `#partial switch`es.
|
|
||||||
- [ ] improve json diagnostics.
|
|
||||||
- i.e. "Missing quotes around string", etc.
|
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|
||||||
@@ -94,9 +96,7 @@
|
|||||||
- [ ] Do we *need* mustache.Date_Components, or can we use core:time/datetime.DateTime?
|
- [ ] Do we *need* mustache.Date_Components, or can we use core:time/datetime.DateTime?
|
||||||
|
|
||||||
## General
|
## General
|
||||||
- [ ] Menus
|
- [ ] configure opt-out of automatic sections being added to menu.
|
||||||
- [ ] 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
|
||||||
@@ -128,6 +128,7 @@ main :: proc () {
|
|||||||
- [ ] enabled features / extensions
|
- [ ] enabled features / extensions
|
||||||
- [ ] etc
|
- [ ] etc
|
||||||
- [ ] Avoid `json.Value` / `json.Object` where possible.
|
- [ ] Avoid `json.Value` / `json.Object` where possible.
|
||||||
|
- [ ] Create a json schema file for `thor.json`.
|
||||||
- [ ] make `parse` an overload of `parse_text/parse_inline` and `parse_file`, or something.
|
- [ ] make `parse` an overload of `parse_text/parse_inline` and `parse_file`, or something.
|
||||||
- [ ] Add page params
|
- [ ] Add page params
|
||||||
- [ ] We must remove all mention of `posts` from the odin code.
|
- [ ] We must remove all mention of `posts` from the odin code.
|
||||||
|
|||||||
+10
-13
@@ -20,9 +20,8 @@ Page :: struct {
|
|||||||
description: string,
|
description: string,
|
||||||
date: string,
|
date: string,
|
||||||
year: string,
|
year: string,
|
||||||
weight: Maybe(int),
|
|
||||||
lastmod: string,
|
lastmod: string,
|
||||||
menus: map[string]Menu_Entry,
|
menu: string,
|
||||||
content: string,
|
content: string,
|
||||||
og: Open_Graph,
|
og: Open_Graph,
|
||||||
draft: bool,
|
draft: bool,
|
||||||
@@ -254,10 +253,18 @@ load_page :: proc(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
page.year = get_year(page.date)
|
page.year = get_year(page.date)
|
||||||
page.weight = fm.weight
|
|
||||||
page.lastmod = fm.lastmod
|
page.lastmod = fm.lastmod
|
||||||
page.draft = fm.draft
|
page.draft = fm.draft
|
||||||
page.starred = fm.isStarred
|
page.starred = fm.isStarred
|
||||||
|
page.menu = fm.menu
|
||||||
|
page.layout = fm.layout if fm.layout != "" else infer_layout(section, is_index)
|
||||||
|
page.og = fm.og
|
||||||
|
|
||||||
|
if strings.has_suffix(file_path, ".html") {
|
||||||
|
page.content = strings.clone(body)
|
||||||
|
} else {
|
||||||
|
page.content = md.process(body, ext, file_path)
|
||||||
|
}
|
||||||
|
|
||||||
if section == "" && is_index {
|
if section == "" && is_index {
|
||||||
page.permalink = "/"
|
page.permalink = "/"
|
||||||
@@ -269,16 +276,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,22 +91,11 @@
|
|||||||
settings.formatter.prettier = {
|
settings.formatter.prettier = {
|
||||||
excludes = [
|
excludes = [
|
||||||
"public/**"
|
"public/**"
|
||||||
"mustache/spec/specs/**"
|
"resources/js/modernizr.js"
|
||||||
"*.html"
|
"storage/app/caniuse.json"
|
||||||
"*.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 = { };
|
||||||
@@ -125,7 +114,6 @@
|
|||||||
pkgs.git
|
pkgs.git
|
||||||
pkgs.cmark
|
pkgs.cmark
|
||||||
pkgs.tree-sitter
|
pkgs.tree-sitter
|
||||||
pkgs.tzdata
|
|
||||||
html-grammar
|
html-grammar
|
||||||
css-grammar
|
css-grammar
|
||||||
];
|
];
|
||||||
|
|||||||
+2
-20
@@ -10,8 +10,7 @@ Frontmatter :: struct {
|
|||||||
date: string,
|
date: string,
|
||||||
lastmod: string,
|
lastmod: string,
|
||||||
publishDate: string,
|
publishDate: string,
|
||||||
weight: Maybe(int),
|
menu: string,
|
||||||
menus: json.Value,
|
|
||||||
layout: string,
|
layout: string,
|
||||||
og: Open_Graph,
|
og: Open_Graph,
|
||||||
draft: bool,
|
draft: bool,
|
||||||
@@ -54,12 +53,9 @@ parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok
|
|||||||
fm.date = json_get_string(obj, "date")
|
fm.date = json_get_string(obj, "date")
|
||||||
fm.lastmod = json_get_string(obj, "lastmod")
|
fm.lastmod = json_get_string(obj, "lastmod")
|
||||||
fm.publishDate = json_get_string(obj, "publishDate")
|
fm.publishDate = json_get_string(obj, "publishDate")
|
||||||
fm.weight = json_get_int(obj, "weight")
|
|
||||||
fm.draft = json_get_bool(obj, "draft")
|
fm.draft = json_get_bool(obj, "draft")
|
||||||
fm.isStarred = json_get_bool(obj, "isStarred")
|
fm.isStarred = json_get_bool(obj, "isStarred")
|
||||||
if v, ok := obj["menus"]; ok {
|
fm.menu = json_get_string(obj, "menu")
|
||||||
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")
|
||||||
|
|
||||||
@@ -85,20 +81,6 @@ 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 {
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "core:fmt"
|
||||||
|
|
||||||
|
Ctx :: struct {
|
||||||
|
title: string,
|
||||||
|
using page: Page,
|
||||||
|
site: Site,
|
||||||
|
}
|
||||||
|
|
||||||
|
Page :: struct {
|
||||||
|
title: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
Site :: struct {
|
||||||
|
title: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
main :: proc() {
|
||||||
|
site := Ctx {
|
||||||
|
site = Site{title = "foo"},
|
||||||
|
page = Page{title = "bar"},
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.printfln("%+v", site)
|
||||||
|
fmt.printf("%+v", site)
|
||||||
|
}
|
||||||
+16
-189
@@ -3,113 +3,12 @@ package main
|
|||||||
import "core:encoding/json"
|
import "core:encoding/json"
|
||||||
import "core:fmt"
|
import "core:fmt"
|
||||||
import "core:log"
|
import "core:log"
|
||||||
import "core:mem"
|
|
||||||
import "core:os"
|
import "core:os"
|
||||||
import "core:strings"
|
import "core:strings"
|
||||||
|
|
||||||
DEFAULT_WEIGHT :: 10
|
|
||||||
|
|
||||||
Menu_Entry :: struct {
|
Menu_Entry :: struct {
|
||||||
name: string,
|
name: string,
|
||||||
url: 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:
|
// build_menus populates site.menus:
|
||||||
@@ -122,7 +21,7 @@ build_menus :: proc(site: ^Site) {
|
|||||||
if site.menus != nil {
|
if site.menus != nil {
|
||||||
has_menus := false
|
has_menus := false
|
||||||
for page in site.pages {
|
for page in site.pages {
|
||||||
if len(page.menus) > 0 {
|
if page.menu != "" {
|
||||||
has_menus = true
|
has_menus = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -143,39 +42,30 @@ build_menus :: proc(site: ^Site) {
|
|||||||
log.fatalf("menus: cannot mix config menus with frontmatter menus")
|
log.fatalf("menus: cannot mix config menus with frontmatter menus")
|
||||||
os.exit(1)
|
os.exit(1)
|
||||||
}
|
}
|
||||||
warn_all_duplicate_weights(site)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// No config menus — auto-generate, then merge page menus on top
|
// No config menus — auto-generate, then merge page menus on top
|
||||||
collect_auto_menus(site)
|
collect_auto_menus(site)
|
||||||
merge_page_menus(site)
|
merge_page_menus(site)
|
||||||
warn_all_duplicate_weights(site)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// merge_page_menus collects frontmatter menu entries from pages and merges
|
// merge_page_menus collects frontmatter "menu" entries from pages and merges
|
||||||
// them into site.menus (which may already contain auto-generated entries).
|
// them into site.menus (which may already contain auto-generated entries).
|
||||||
// If no pages have menus set, this is a no-op.
|
// If no pages have "menu" set, this is a no-op.
|
||||||
merge_page_menus :: proc(site: ^Site) {
|
merge_page_menus :: proc(site: ^Site) {
|
||||||
alloc := site_allocator(site)
|
alloc := site_allocator(site)
|
||||||
|
|
||||||
// Collect page entries by menu name
|
// Collect page entries by menu name
|
||||||
page_entries := make(map[string][dynamic]Menu_Entry, 16, alloc)
|
page_entries := make(map[string][dynamic]Menu_Entry, 16, alloc)
|
||||||
for page in site.pages {
|
for page in site.pages {
|
||||||
for menu_name, entry in page.menus {
|
if page.menu == "" {
|
||||||
if _, ok := page_entries[menu_name]; !ok {
|
continue
|
||||||
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 _, ok := page_entries[page.menu]; !ok {
|
||||||
|
page_entries[page.menu] = make([dynamic]Menu_Entry, 0, 4, alloc)
|
||||||
|
}
|
||||||
|
append(&page_entries[page.menu], Menu_Entry{name = page.title, url = page.permalink})
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(page_entries) == 0 {
|
if len(page_entries) == 0 {
|
||||||
@@ -220,22 +110,16 @@ collect_auto_menus :: proc(site: ^Site) {
|
|||||||
for section in sections {
|
for section in sections {
|
||||||
name := to_title_case(section, alloc)
|
name := to_title_case(section, alloc)
|
||||||
url := fmt.aprintf("/%s/", section, allocator = alloc)
|
url := fmt.aprintf("/%s/", section, allocator = alloc)
|
||||||
skip := false
|
|
||||||
for page in site.pages {
|
for page in site.pages {
|
||||||
if page.section == section && page._is_index {
|
if page.section == section && page._is_index {
|
||||||
url = page.permalink
|
url = page.permalink
|
||||||
if page.title != "" {
|
if page.title != "" {
|
||||||
name = page.title
|
name = page.title
|
||||||
}
|
}
|
||||||
if _, has_main := page.menus["main"]; has_main {
|
|
||||||
skip = true
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !skip {
|
append(&entries, Menu_Entry{name = name, url = url})
|
||||||
append(&entries, Menu_Entry{name = name, url = url})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Root-level page entries (section = "", not index)
|
// Root-level page entries (section = "", not index)
|
||||||
@@ -243,10 +127,7 @@ collect_auto_menus :: proc(site: ^Site) {
|
|||||||
if page._is_index || page.section != "" || page.title == "" {
|
if page._is_index || page.section != "" || page.title == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, has_main := page.menus["main"]; has_main {
|
append(&entries, Menu_Entry{name = page.title, url = page.permalink})
|
||||||
continue
|
|
||||||
}
|
|
||||||
append(&entries, Menu_Entry{name = page.title, url = page.permalink, weight = page.weight})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
@@ -258,23 +139,11 @@ collect_auto_menus :: proc(site: ^Site) {
|
|||||||
site.menus["main"] = entries[:]
|
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) {
|
sort_menu_entries :: proc(entries: []Menu_Entry) {
|
||||||
for i in 1 ..< len(entries) {
|
for i in 1 ..< len(entries) {
|
||||||
key := entries[i]
|
key := entries[i]
|
||||||
j := i - 1
|
j := i - 1
|
||||||
for j >= 0 && compare_menu_entries(entries[j], key) > 0 {
|
for j >= 0 && strings.compare(entries[j].name, key.name) > 0 {
|
||||||
entries[j + 1] = entries[j]
|
entries[j + 1] = entries[j]
|
||||||
j -= 1
|
j -= 1
|
||||||
}
|
}
|
||||||
@@ -282,31 +151,8 @@ sort_menu_entries :: proc(entries: []Menu_Entry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
// parse_config_menus converts raw JSON from thor.json into map[string][]Menu_Entry.
|
||||||
// Entries are sorted by weight, then name.
|
// Preserves array order as-declared.
|
||||||
parse_config_menus :: proc(
|
parse_config_menus :: proc(
|
||||||
raw: json.Value,
|
raw: json.Value,
|
||||||
allocator := context.allocator,
|
allocator := context.allocator,
|
||||||
@@ -334,7 +180,6 @@ parse_config_menus :: proc(
|
|||||||
|
|
||||||
name := ""
|
name := ""
|
||||||
url := ""
|
url := ""
|
||||||
weight: Maybe(int) = nil
|
|
||||||
|
|
||||||
if v, ok := entry_obj["name"]; ok {
|
if v, ok := entry_obj["name"]; ok {
|
||||||
if s, ok2 := v.(json.String); ok2 {
|
if s, ok2 := v.(json.String); ok2 {
|
||||||
@@ -364,33 +209,15 @@ parse_config_menus :: proc(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 == "" {
|
if name == "" {
|
||||||
log.warnf("menus: '%s' entry %d missing 'name', skipping", menu_name, idx)
|
log.warnf("menus: '%s' entry %d missing 'name', skipping", menu_name, idx)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
append(&entries, Menu_Entry{name = name, url = url, weight = weight})
|
append(&entries, Menu_Entry{name = name, url = url})
|
||||||
}
|
}
|
||||||
sort_menu_entries(entries[:])
|
|
||||||
result[menu_name] = entries[:]
|
result[menu_name] = entries[:]
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-517
@@ -1,517 +0,0 @@
|
|||||||
#+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",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -78,11 +78,9 @@ 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 {
|
||||||
switch n.kind {
|
#partial 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
|
||||||
}
|
}
|
||||||
@@ -376,7 +374,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) {
|
||||||
switch nodes[i].kind {
|
#partial 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
|
||||||
@@ -403,8 +401,6 @@ 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])
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-16
@@ -16,7 +16,6 @@ Template_Context :: struct {
|
|||||||
timezone: ^datetime.TZ_Region,
|
timezone: ^datetime.TZ_Region,
|
||||||
og: Open_Graph,
|
og: Open_Graph,
|
||||||
site: Site_Context,
|
site: Site_Context,
|
||||||
menus: map[string][]Menu_Entry,
|
|
||||||
page: Page,
|
page: Page,
|
||||||
|
|
||||||
// Home data
|
// Home data
|
||||||
@@ -136,7 +135,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(pages)
|
sort_pages_by_date(pages)
|
||||||
|
|
||||||
// Load shared resources
|
// Load shared resources
|
||||||
partials := load_partials(&site.vfs)
|
partials := load_partials(&site.vfs)
|
||||||
@@ -152,7 +151,6 @@ render_site :: proc(site: ^Site) {
|
|||||||
|
|
||||||
ctx := Template_Context {
|
ctx := Template_Context {
|
||||||
site = site.site_context,
|
site = site.site_context,
|
||||||
menus = site.menus,
|
|
||||||
now = now,
|
now = now,
|
||||||
og = site.og,
|
og = site.og,
|
||||||
date_format = site.date.format,
|
date_format = site.date.format,
|
||||||
@@ -373,12 +371,11 @@ get_year :: proc(iso: string) -> string {
|
|||||||
return iso[:4]
|
return iso[:4]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Weight primary (ascending). Date secondary (descending) for equal weights.
|
sort_pages_by_date :: proc(pages: #soa[]Page) {
|
||||||
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 && compare_pages(pages, j, key) > 0 {
|
for j >= 0 && pages.date[j] < key.date {
|
||||||
pages[j + 1] = pages[j]
|
pages[j + 1] = pages[j]
|
||||||
j -= 1
|
j -= 1
|
||||||
}
|
}
|
||||||
@@ -386,16 +383,6 @@ sort_pages :: 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] == '/' {
|
||||||
|
|||||||
@@ -1,8 +1 @@
|
|||||||
{
|
{"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" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -269,7 +269,7 @@ compile_query :: proc(
|
|||||||
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)
|
||||||
switch err_type {
|
#partial switch err_type {
|
||||||
case .NodeType:
|
case .NodeType:
|
||||||
if tok != "" {
|
if tok != "" {
|
||||||
cause = fmt.tprintf(
|
cause = fmt.tprintf(
|
||||||
@@ -295,7 +295,6 @@ compile_query :: proc(
|
|||||||
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
switch entry.type {
|
#partial 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 .Undetermined, .Symlink, .Named_Pipe, .Socket, .Block_Device, .Character_Device:
|
case:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user