Compare commits

..
14 Commits
124 changed files with 1544 additions and 8792 deletions
-78
View File
@@ -1,78 +0,0 @@
# Custom Pages build: compiles Thor from source via the Nix flake, uses it to
# generate thor/site/, and publishes the result. Replaces the default Jekyll build.
name: Deploy site to GitHub Pages
on:
push:
branches: [main]
# Only rebuild when something that affects the output changes.
paths:
- "site/**"
- "**/*.odin"
- "defaults/**"
- "flake.nix"
- "flake.lock"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
# Allow one concurrent deployment; don't cancel an in-progress publish.
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@v30
with:
extra_nix_config: |
experimental-features = nix-command flakes
# Free cache: snapshots /nix/store into GitHub's Actions cache. The key is
# hashed over the inputs that change Thor's output, so a site-content-only
# change is a full hit (Thor not recompiled), while an Odin source change
# restores the nearest store and rebuilds just the delta.
- name: Cache /nix/store
uses: nix-community/cache-nix-action@v6
with:
primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.lock', '**/*.nix', '**/*.odin') }}
restore-prefixes-first-match: nix-${{ runner.os }}-
gc-max-store-size-linux: 2G
purge: true
purge-prefixes: nix-${{ runner.os }}-
purge-created: 604800 # drop entries older than 7 days
# First run compiles Thor + the tree-sitter grammar libs; later runs
# restore them from the cache above and skip the compile.
- name: Build Thor
run: nix build .#default --print-build-logs
- name: Generate site
working-directory: site
run: ../result/bin/thor -base-url=https://oneidiotdeveloper.com/thor -minify
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: site/public
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+61 -123
View File
@@ -2,18 +2,6 @@
Thor is a static site generator written in [Odin](https://odin-lang.org), replacing Hugo for the `sbrow.github.io` blog. It lives at `./thor/` as a git subtree with its own `flake.nix`. Thor is a static site generator written in [Odin](https://odin-lang.org), replacing Hugo for the `sbrow.github.io` blog. It lives at `./thor/` as a git subtree with its own `flake.nix`.
## 🚫 DO NOT EDIT THE DOCS — BY HUMANS, FOR HUMANS
> **THE DOCUMENTATION UNDER `thor/site/` IS HANDWRITTEN, BY HUMANS, FOR HUMANS.**
>
> **NO AI, AGENT, BOT, ASSISTANT, OR OTHER NON-HUMAN MAY EDIT, REWRITE,
> REPHRASE, REFORMAT, "IMPROVE," SUMMARIZE, OR GENERATE ANY FILE UNDER
> `thor/site/` — EVER.**
>
> These are not machine artifacts. A human wrote every word. AI may be
> consulted as a sanity check, but the prose stays human. If you are not a
> human, do not touch these files. See `thor/site/content/ai.md`.
## Architecture ## Architecture
``` ```
@@ -32,18 +20,16 @@ thor/
├── treesitter/ # FFI types + grammar management (standalone package) ├── treesitter/ # FFI types + grammar management (standalone package)
├── 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
├── diagnostics/ # Shared diagnostic utilities (suggest_correction)
├── 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
@@ -54,18 +40,17 @@ thor/
| File | Responsibility | | File | Responsibility |
|---|---| |---|---|
| `main.odin` | Entry point. Parses CLI flags via `core:flags`, sets logger level from `-verbose`/`-quiet`, 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, includes `-verbose`/`-quiet`), `Config_File` (thor.json), `Site_Context` (template-facing: `title`, `description`, `base_url`, `params`, `og`, `menus`), `Site` (runtime state + arena + VFS + pages + `og`). `Feature` enum. `init_site(site, flags)` — takes pre-parsed `Flags`. 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`, `params: json.Value`, `toc: string`, `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, generates TOC via `md.generate_toc` when frontmatter `"toc": true`), `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`, `params`, `posts`, `pages`). 3-frame context stack via `[]any{ctx.site, ctx.page, ctx}`. `merge_params(site, page)` — shallow merge of site + page params. Error deduplication via `seen: ^map[string]bool` passed through render chain. `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`, `params: json.Value`, `toc: bool`, 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
@@ -73,17 +58,13 @@ thor/
| Package | Files | Responsibility | | Package | Files | Responsibility |
|---|---|---| |---|---|---|
| `treesitter/` | `treesitter.odin` | FFI types (`Parser`, `Node`, `Query`, etc.), `@(link_prefix="ts_")` foreign bindings, grammar management (`Grammar_Store` with persistent allocator, `load_language`/`compile_query` building blocks, `ensure_parser`/`load_grammar` lazy loading, `preload_grammar`/`preload_grammars` for parallel loading with `sync.Mutex` cache protection), statically-linked HTML/CSS grammars | | `treesitter/` | `treesitter.odin` | FFI types (`Parser`, `Node`, `Query`, etc.), `@(link_prefix="ts_")` foreign bindings, grammar management (`Grammar_Store` with persistent allocator, `load_language`/`compile_query` building blocks, `ensure_parser`/`load_grammar` lazy loading, `preload_grammar`/`preload_grammars` for parallel loading with `sync.Mutex` cache protection), statically-linked HTML/CSS grammars |
| `markdown/` | `markdown.odin` | `Extension` enum, `DEFAULT_EXTENSIONS`, `EXTENSION_NAMES`, `process(body, ext, file_path, allocator)` — full pipeline (clones cmark output, frees original), `parse_extension_list` (with "did you mean?" hints via `diagnostics.suggest_correction`), `apply_extension_config` (same), `resolve_extension_conflicts` (Footnotes wins over Sidenotes) | | `markdown/` | `markdown.odin` | `Extension` enum, `DEFAULT_EXTENSIONS`, `process(body, ext, file_path)` — full pipeline, `parse_extension_list`, `apply_extension_config` |
| | `footnotes.odin` | `strip_definitions` (pre-cmark, shared by `.Sidenotes` + `.Footnotes`), `inject_notes` (post-cmark sidenote rendering), `inject_footnotes` (post-cmark standard footnote rendering — numbered `<sup>` links + `<section class="footnotes"><ol>` at bottom). `.Sidenotes` and `.Footnotes` are mutually exclusive; `resolve_extension_conflicts` in `markdown.odin` picks `.Footnotes` if both are set. | | | `footnotes.odin` | `strip_definitions` (pre-cmark), `inject_notes` (post-cmark) |
| | `alerts.odin` | `inject_alerts` — GitHub alert blocks (`> [!NOTE]`) → styled blockquotes with semantic class names (`alert-note` etc.) | | | `alerts.odin` | `inject_alerts` — GitHub alert blocks (`> [!NOTE]`) → styled blockquotes with semantic class names (`alert-note` etc.) |
| | `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. |
| | `deflists.odin` | `convert_deflists` — pre-cmark pass. Scans for definition list patterns (`term\n\n: definition`) and converts to `<dl><dt><dd>` HTML blocks. Terms and definitions rendered through cmark individually for inline markdown. Consecutive pairs grouped into single `<dl>`. |
| | `toc.odin` | `generate_toc(html, allocator)` — page-level feature (not a pipeline extension). Scans `<h1>`-`<h6>` for IDs (after `inject_heading_ids`), builds nested `<ul>` with `<a href="#id">` links. Called from `load_page` when frontmatter `"toc": true`. Depends on `.HeadingIDs` being enabled. |
| `mustache/` | See [Mustache engine](#mustache-engine) below | Template engine | | `mustache/` | See [Mustache engine](#mustache-engine) below | Template engine |
| `diagnostics/` | `diagnostics.odin` | Shared diagnostic utilities: `suggest_correction` (Levenshtein "did you mean?"), `format_error` (rust-style multi-line error formatter with `caret_start`/`caret_end` params), `line_col`, `line_text`, `count_lines`, `digit_count`, `display_width`, `should_colorize`, `write_gutter`. Used by `mustache` (tag-aware errors via `format_tag_error` wrapper), `markdown` (extension name suggestions), and `render.odin` (parse/render error display). |
| `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). |
Icon SVGs live as HTML partials in `layouts/partials/icons/` (home, github, rss, chevron_up, star). Icon SVGs live as HTML partials in `layouts/partials/icons/` (home, github, rss, chevron_up, star).
@@ -93,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/
``` ```
@@ -113,19 +94,15 @@ 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,
params: json.Value, // per-page params (merged with site params at render time) body_html: string,
content: string, // rendered HTML body
og: Open_Graph,
draft: bool, draft: bool,
toc: string, // generated table of contents HTML (empty if not requested) is_starred: bool,
og: Open_Graph, // per-page OG overrides from frontmatter
_is_index: bool `private`, _is_index: bool `private`,
} }
``` ```
```
No `Page_Type` enum — page type is inferred from section + `_is_index`. Layout is inferred via `infer_layout(section, is_index)`: No `Page_Type` enum — page type is inferred from section + `_is_index`. Layout is inferred via `infer_layout(section, is_index)`:
@@ -136,51 +113,17 @@ 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:
- **`Flags`** — CLI args only. Parsed once in `main.odin` via `core:flags`, passed to `init_site`. Includes path overrides (`--content`, `--assets`, `--output`, `--layouts`), build-mode toggles (`-drafts`, `-watch`, `-minify`), log level (`-verbose` → Debug, `-quiet` → Warning), and `-ext`/`-no-ext` for markdown extension overrides. - **`Flags`** — CLI args only. Parsed by `core:flags`. Includes path overrides (`--content`, `--assets`, `--output`, `--layouts`), build-mode toggles (`-drafts`, `-watch`, `-minify`), and `-ext`/`-no-ext` for markdown extension overrides.
- **`Config_File`** — parsed from `thor.json` via `json.unmarshal_string`. Holds title, paths, `markdown_extensions` (JSON), `params` (JSON), `modules` (JSON array of relative paths), `og` (`Open_Graph` struct for site-level OG defaults). - **`Config_File`** — parsed from `thor.json` via `json.unmarshal_string`. Holds title, paths, `markdown_extensions` (JSON), `params` (JSON), `modules` (JSON array of relative paths), `og` (`Open_Graph` struct for site-level OG defaults).
- **`Site`** — runtime state: arena, pages, modules, VFS, `features: bit_set[Feature]`, `markdown_extensions: bit_set[md.Extension]`, `og: Open_Graph` (resolved site-level OG). - **`Site`** — runtime state: arena, pages, modules, VFS, `features: bit_set[Feature]`, `markdown_extensions: bit_set[md.Extension]`, `og: Open_Graph` (resolved site-level OG).
**`Feature` enum** — `Drafts`, `Minify`, `Watch`. Checked with `.Minify in site.features`. **`Feature` enum** — `Drafts`, `Minify`, `Watch`. Checked with `.Minify in site.features`.
**`markdown.Extension` enum** (in the `markdown` package, not main) — `Emoji`, `Sidenotes`, `Alerts`, `Highlight`, `Sections`, `HeadingIDs`, `DefLists`, `Footnotes`. Default is `md.DEFAULT_EXTENSIONS` (currently `.Emoji, .Sidenotes, .Alerts, .HeadingIDs, .DefLists`). Configurable via: **`markdown.Extension` enum** (in the `markdown` package, not main) — `Emoji`, `Sidenotes`, `Alerts`, `Highlight`, `Sections`, `HeadingIDs`. Default is `md.DEFAULT_EXTENSIONS` (currently `.Emoji, .Sidenotes, .Alerts, .HeadingIDs`). Configurable via:
- `thor.json`: `"markdown_extensions": { "emoji": true, "highlight": false, ... }` - `thor.json`: `"markdown_extensions": { "emoji": true, "highlight": false, ... }`
- CLI: `-ext:highlight,sections` (enable) / `-no-ext:emoji` (disable). Comma-separated, case-insensitive. - CLI: `-ext:highlight,sections` (enable) / `-no-ext:emoji` (disable). Comma-separated, case-insensitive.
@@ -203,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" }
@@ -260,12 +197,10 @@ Lives in the `markdown` package. Entry point: `md.process(body, ext, file_path)`
``` ```
raw markdown raw markdown
→ md.strip_definitions (if .Sidenotes || .Footnotes — pre-cmark) → md.strip_definitions (if .Sidenotes — pre-cmark)
→ md.convert_deflists (if .DefLists — pre-cmark)
→ cmark markdown_to_html (Unsafe mode for HTML passthrough) → cmark markdown_to_html (Unsafe mode for HTML passthrough)
→ md.expand_emoji (if .Emoji — post-cmark) → md.expand_emoji (if .Emoji — post-cmark)
→ md.inject_notes (if .Sidenotes — post-cmark, sidenote rendering) → md.inject_notes (if .Sidenotes — post-cmark)
→ md.inject_footnotes (if .Footnotes — post-cmark, standard footnote rendering)
→ md.inject_alerts (if .Alerts — post-cmark) → md.inject_alerts (if .Alerts — post-cmark)
→ md.highlight_code (if .Highlight — post-cmark) → md.highlight_code (if .Highlight — post-cmark)
→ md.inject_heading_ids (if .HeadingIDs — post-cmark, pre-sections) → md.inject_heading_ids (if .HeadingIDs — post-cmark, pre-sections)
@@ -290,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,
date_format: string, // from site.date.format (thor.json) title: string,
timezone: ^datetime.TZ_Region, // for format pipe description: string,
og: Open_Graph, // computed per-page OG og: Open_Graph,
params: json.Value, // merged site + page params (resolves above Page.params) 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. `Template_Context.params` is set per-page via `merge_params(site.params, page.params)` — site params overlaid with page params. Browser title is handled by the `{{> title}}` partial (not a computed field). `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
@@ -419,11 +364,12 @@ Spec-compliant implementation at `mustache/`. See `mustache/SPEC.md` for the imp
|---|---| |---|---|
| `mustache.odin` | Public API (`parse`, `render`, `Template`), parser (`parse_section`), renderer (`render_nodes` with `Indent_State` for partial indentation), template inheritance (`merge_block_overrides`), `delete_template`/`delete_partials`. Pipe support in Variable/Unescaped/Section/Inverted tags. | | `mustache.odin` | Public API (`parse`, `render`, `Template`), parser (`parse_section`), renderer (`render_nodes` with `Indent_State` for partial indentation), template inheritance (`merge_block_overrides`), `delete_template`/`delete_partials`. Pipe support in Variable/Unescaped/Section/Inverted tags. |
| `tokenizer.odin` | Tokenizer (template string → `[]Token`), standalone whitespace detection | | `tokenizer.odin` | Tokenizer (template string → `[]Token`), standalone whitespace detection |
| `data.odin` | Reflection-based data model: `base_value` (peels union/any/nested-any layers), `lookup_in` (structs + maps, handles `Type_Info_Any` value kind in maps), `resolve_name`, `is_truthy`, `any_to_string`, `write_value`, `list_info`, `extract_list_element`, `collect_map_keys` | | `data.odin` | Reflection-based data model: `base_value` (peels union/any/nested-any layers), `lookup_in` (structs + maps, handles `Type_Info_Any` value kind in maps), `resolve_name`, `is_truthy`, `any_to_string`, `list_info`, `extract_list_element`, `call_interp_lambda`/`call_section_lambda` |
| `pipes.odin` | Pipes extension: `Pipe_Op` enum (`.Format`, `.Group_By`), `pipe_op_from_string`/`pipe_op_candidates` (reflection-based enum name lookup), `Pipe_Filter` AST (with `op_pos` for diagnostics), `parse_pipeline` (tracks byte offsets via `strings.index`), `apply_pipeline`, `apply_filter` (exhaustive enum switch), `apply_group_by`, `apply_format`. Misspelled pipe op suggestions via `suggest_correction`. Stored on `Node.filters`; render-scoped results in temp allocator. | | `pipes.odin` | Pipes extension: `Pipe_Filter` AST, `parse_pipeline` (takes `pos`), `apply_pipeline`, `apply_filter` (switch dispatch: `group_by` + `format`), `apply_group_by`, `apply_format`. Stored on `Node.filters`; render-scoped results in temp allocator. |
| `diagnostic.odin` | Mustache-specific diagnostics: `context_extent` (scans `{{ }}` tags for caret underline), `format_tag_error` (wraps `diags.format_error` + `context_extent`), `format_render_error` (formats `Error` using `Template` source/path). Generic error formatting lives in `diagnostics/` package. | | `diagnostic.odin` | Rust-style error formatter: `format_error` (multi-line context, ANSI colors via `core:terminal/ansi`, `colorize` param), `format_render_error` (formats `Error`), `line_col`, `line_text`, `context_extent`, `count_lines`, `digit_count`, `should_colorize`. |
| `suggest.odin` | Strict-warning helpers: `validate_key_path` (walks dotted path, crosses maps silently), `suggest_correction` (Levenshtein via `core:strings/levenshtein_distance`), `collect_struct_keys` (via reflection, recurses into `using`), `struct_has_field` (distinguishes missing field from nil value — needed for `Maybe(bool)`), `collect_partial_names`, `collect_block_names`. | | `suggest.odin` | Strict-warning helpers: `validate_key_path` (walks dotted path, crosses maps silently), `suggest_correction` (Levenshtein via `core:strings/levenshtein_distance`), `collect_struct_keys` (via reflection, recurses into `using`), `struct_has_field` (distinguishes missing field from nil value — needed for `Maybe(bool)`), `collect_partial_names`, `collect_block_names`. |
| `spec_test.odin` | JSON spec test runner — loads `spec/specs/*.json`, runs each test case. Uses `log.nil_logger()` to suppress expected warnings. | | `spec_test.odin` | JSON spec test runner — loads `spec/specs/*.json`, runs each test case. Uses `log.nil_logger()` to suppress expected warnings. |
| `lambda_test.odin` | Spec lambda tests |
| `pipes_test.odin` | Pipe filter tests (`group_by` + `format`) | | `pipes_test.odin` | Pipe filter tests (`group_by` + `format`) |
| `diagnostic_test.odin` | Golden-output tests for `format_error` (multi-line context, edge cases, alignment, caret position, hint) + parser error message brace-escaping | | `diagnostic_test.odin` | Golden-output tests for `format_error` (multi-line context, edge cases, alignment, caret position, hint) + parser error message brace-escaping |
| `suggest_test.odin` | Tests for `validate_key_path`, `suggest_correction`, `struct_has_field` with `Maybe(bool)` and `using`-promoted fields | | `suggest_test.odin` | Tests for `validate_key_path`, `suggest_correction`, `struct_has_field` with `Maybe(bool)` and `using`-promoted fields |
@@ -451,11 +397,7 @@ render(tmpl, data, partials) → render_nodes (walks flat node array against con
Rust-style error messages with multi-line source context, caret underlines, and Levenshtein suggestions. ANSI colors via `core:terminal/ansi`, gated on `should_colorize()` (TTY detection on stderr). Rust-style error messages with multi-line source context, caret underlines, and Levenshtein suggestions. ANSI colors via `core:terminal/ansi`, gated on `should_colorize()` (TTY detection on stderr).
**Error types**: `Error_Body{msg, pos, kind, source, path, span, hint}` where `kind` is `Error_Kind.Syntax` (parse-time) or `Error_Kind.Data` (render-time). `source`/`path` carry the template the error originated in (set by `tag_error` — enables correct file/line for errors inside partials). `span` controls caret underline width (used by pipe op diagnostics). `hint` carries "did you mean?" suggestions. `Error` is a single-variant union wrapping `Error_Body` (nilable for `!= nil` / `or_return`). **Error types**: `Error_Body{msg, pos, kind}` where `kind` is `Error_Kind.Syntax` (parse-time) or `Error_Kind.Data` (render-time). `Error` is a single-variant union wrapping `Error_Body` (nilable for `!= nil` / `or_return`).
**Error deduplication**: `render_template` takes `seen: ^map[string]bool`. Duplicate errors (same formatted diagnostic) are suppressed across page renders within a single build.
**Partial source tracking**: `tag_error(err, current)` stamps render-time errors with `current.source`/`current.path` at the 4 `apply_pipeline` call sites in `render_nodes`. Ensures errors inside partials point at the partial file, not the top-level template.
**Strict-by-default warnings** — `render_nodes` emits `log.warnf` diagnostics for: **Strict-by-default warnings** — `render_nodes` emits `log.warnf` diagnostics for:
- Unknown keys in `{{k}}`, `{{{k}}}`, `{{#k}}`, `{{^k}}` (via `validate_key_path` + `suggest_correction`) - Unknown keys in `{{k}}`, `{{{k}}}`, `{{#k}}`, `{{^k}}` (via `validate_key_path` + `suggest_correction`)
@@ -465,12 +407,18 @@ Rust-style error messages with multi-line source context, caret underlines, and
**Exceptions** (no warning): **Exceptions** (no warning):
- `{{.}}` and dot-prefixed names (current context) - `{{.}}` and dot-prefixed names (current context)
- Paths that cross a map (e.g., `params.*`) — validated for typos via Levenshtein: close matches warn with a suggestion, genuinely absent keys are suppressed silently - Paths that cross a map (e.g., `params.*` — user-defined namespace)
- `Maybe(bool)` fields with nil value (field exists, value is nil — distinguished via `struct_has_field`) - `Maybe(bool)` fields with nil value (field exists, value is nil — distinguished via `struct_has_field`)
- Found fields whose value is nil/empty (e.g., nil `json.Value` union) — the field exists, looking up sub-keys is valid "not found" behavior
**Block override source tracking**: `Block_Override.source: Template` ensures warnings inside block overrides point at the override's source file (e.g., `page.html`), not the parent template (`base.html`). **Block override source tracking**: `Block_Override.source: Template` ensures warnings inside block overrides point at the override's source file (e.g., `page.html`), not the parent template (`base.html`).
### Lambdas
Spec-compliant. Stored as `any` values in the data context.
- **Interpolation lambdas**: `proc() -> string`, `proc() -> int`, `proc() -> bool` — called via `call_interp_lambda`, result stringified and escaped.
- **Section lambdas**: `proc(string) -> string`, `proc(string) -> int`, `proc(string) -> bool` — called via `call_section_lambda` with the raw section text (`node.content`). String result is re-parsed as mustache and rendered against the current context stack.
### Pipes ### Pipes
`{{key | op args…}}` for interpolation, `{{#key | op args…}}…{{/key}}` for sections. Stored as `[dynamic; MAX_PIPES]Pipe_Filter` on each `Node`. Applied in the renderer via `apply_pipeline` before truthiness/interpolation. Implemented filters: `{{key | op args…}}` for interpolation, `{{#key | op args…}}…{{/key}}` for sections. Stored as `[dynamic; MAX_PIPES]Pipe_Filter` on each `Node`. Applied in the renderer via `apply_pipeline` before truthiness/interpolation. Implemented filters:
@@ -487,13 +435,12 @@ See `mustache/EXTENSIONS.md`.
## Known limitations ## Known limitations
- cmark allocates via C malloc, not the arena. HTML output leaks until process exit (problematic in watch mode — see `TODOS.md`).
- CSS/JS cache busting uses manual `?v=N` query params instead of content hashing. - CSS/JS cache busting uses manual `?v=N` query params instead of content hashing.
- Tree-sitter grammar/query paths must be configured manually via `thor.json` (`grammars`, `queries`) — no auto-discovery. HTML/CSS are statically linked. - Tree-sitter grammar/query paths must be configured manually via `thor.json` (`grammars`, `queries`) — no auto-discovery. HTML/CSS are statically linked.
- `map[string]any` only works through `lookup_in`'s special-case handling; thor otherwise uses structs. - `map[string]any` only works through `lookup_in`'s special-case handling; thor otherwise uses structs.
- `format_f64` in mustache brute-forces shortest float representation. - `format_f64` in mustache brute-forces shortest float representation.
- Content directory not mounted in VFS (modules can ship templates/assets but not content packs yet). - Content directory not mounted in VFS (modules can ship templates/assets but not content packs yet).
- Per-page params rendering is incomplete — `merge_params` produces correct data, but `base_value` may return nil for `json.Value` fields accessed through the `[]any` context stack via reflection in some cases. Warning suppression masks this; actual rendering may not work for all param values.
- Lambda support removed. Mustache lambdas (`proc() -> string` in data context) are not supported. Pipes cover data transformation.
## Design decisions ## Design decisions
@@ -501,8 +448,6 @@ You may never, *ever* remove `TODO:` or `FIXME:` comments. Those are for humans,
See `HUGO.md` for analysis of why thor doesn't need Hugo's shortcode context isolation. See `HUGO.md` for analysis of why thor doesn't need Hugo's shortcode context isolation.
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).
See `DIAGNOSTICS.md` for the two-tier diagnostic system design.
See `mustache/TOKENIZERS.md` for tokenizer architecture comparison (Go, Liquid, Thor).
## Odin language facts ## Odin language facts
@@ -511,13 +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.
- **`fmt.sbprintf` writes directly to a `strings.Builder`.** Prefer `fmt.sbprintf(&sb, fmt, args...)` over `fmt.aprintf(fmt, args...)` + `defer delete` + `strings.write_string`. The `aprintf` pattern allocates an intermediate string, requires manual cleanup, and queues a `defer delete` per loop iteration. `sbprintf` avoids all of this.
## TODO ## TODO
-135
View File
@@ -1,135 +0,0 @@
# Asset Pipeline Plan
Design for letting thor users bring their own CSS toolchain (Sass, PostCSS,
Lightning CSS, Tailwind, …).
## Thesis
Let thor users bring their own CSS toolchain by **orchestrating the tool they
already installed** — never bundling or reimplementing one. This is expressed
through thor's existing mustache pipe grammar with two new ops: `tool` and
`fingerprint`.
The earlier research design (content-addressed registry + deferred token
resolution + a separate post-render path for "whole-output scanners") is
**discarded**. It was overbuilt. Every tool — Tailwind included — scans
*source*, which exists at render time, so every transform runs **eagerly,
synchronously, and memoized**. No placeholder tokens, no post-render
string-replace pass, no second integration surface.
## The two ops
```html
<link href="{{ "css/main.scss" | tool sass | fingerprint | rel_url }}">
```
```json5
// thor.json
{
"tools": {
"sass": "sass $in $out",
"postcss": "postcss $in -o $out",
"lightningcss": "lightningcss --minify $in -o $out",
"tailwind": "tailwindcss -i $in -o $out"
}
}
```
## Core model
The value flowing through the pipe is **bytes held by thor**, with a logical
name attached (`css/main.css`). Each stage transforms the bytes; the **final
stage publishes** one file to the output dir and returns its URL.
- A literal (`"css/main.scss"`) or context key names the **source** file on disk.
- `| tool <name>` runs a subprocess, replacing the carried bytes with its output.
- `| fingerprint` writes the content-addressed file and returns its hashed URL.
- The chain composes with the existing `rel_url` for base_url subpaths.
**Memoization:** results are cached per build, keyed by (op-chain, source). The
first page to reference an asset runs the tool; every other page hits the cache.
This is what stops N pages from shelling out N times.
## `| tool <name>`
Looks up `<name>` in the `thor.json` `tools` table and runs its command
template. `$in` and `$out` are **optional per-tool I/O adapters**:
| Template has | Behavior |
|---|---|
| `$in` | thor writes current bytes to a scratch path, substitutes it |
| no `$in` | thor pipes current bytes to the tool's **stdin** |
| `$out` | thor substitutes a scratch path, reads it back after |
| no `$out` | thor captures the tool's **stdout** |
Defaulting to stdin/stdout avoids scratch-file overhead when the tool supports
it. Tools that can't stream (Lightning CSS wants a path; Tailwind v3 needs an
input file) just get `$in`/`$out` in their template. Either way the bytes are
thor's, so chaining works regardless of each tool's preference:
`| tool sass | tool postcss` composes even if sass streams and postcss wants
files.
- **`$out` is always thor-controlled scratch** — the tool never picks the
published filename.
- **Extension derivation:** thor owns the output name; `main.scss` through
`tool sass` becomes `main.css`.
- **Errors:** thor gates on exit code and surfaces the tool's stderr through one
code path. If the tool is missing from PATH, fail with its stderr — never
bundle, never assume a runtime.
- **A bare `tool` (no `fingerprint`) still publishes** — to the logical unhashed
path (`output/css/main.css`), returning `/css/main.css`.
## `| fingerprint`
Content-addresses whatever bytes it currently holds. It has **two outputs**:
- **Side effect:** writes `css/main.<hash>.css` to the output dir.
- **Return value:** the hashed URL string `/css/main.<hash>.css` (feeds
`rel_url`).
Properties:
- **Works standalone**, no `tool` needed: `{{ "css/main.css" | fingerprint }}`
is plain cache-busting of a hand-written file — probably the most common use.
- **Position decides what's hashed** — it hashes current bytes, so it belongs
**last** in the chain. No terminal-only special-casing; the rule is just "put
it last."
- **Hash format:** `name.<hash>.ext` (CDN-friendly), 8 hex chars of SHA-256.
## Tailwind is not special
It scans your **source** (`layouts/`, `content/`, partials) — which exists at
render time — so it runs synchronously like sass. Its `thor.json` entry just
needs content globs pointing at the source tree (or v4 auto-detection). The
**one** asymmetry is watch invalidation: sass re-runs when `main.scss` changes;
Tailwind must re-run when *any* scanned source changes. That's a cache-key
breadth difference, not an architectural one. The cost of scanning source vs.
output is losing dynamically-assembled class tokens (`col-{{n}}`) — a Tailwind
anti-pattern whose sanctioned fix is a safelist, not warping the build.
## Interaction: `copy_assets_dir`
Today thor copies everything under `assets/` to the output dir. Any source
consumed by a pipe must be **excluded from that plain copy** — otherwise you'd
ship the raw `main.scss` (or a dead unhashed `main.css` alongside the hashed
one). The pipe owns the write for assets it touches.
## Implementation steps (separable)
1. **`| fingerprint`** — no subprocess, no config. Hash + write + return URL,
plus the `copy_assets_dir` exclusion. Smallest blast radius, independently
useful, ships first.
2. **`| tool <name>`** — `thor.json` tools table, the `$in`/`$out`/stdin/stdout
adapter, subprocess + exit-code/stderr handling, extension derivation,
memoization. Composes with step 1.
## Open questions for implementation
- **Watch invalidation:** simplest correct v1 recomputes the memo each build
(cheap for the common single-asset case). A cross-build skip keyed on source
content/mtime is a later optimization — and Tailwind's broad content-glob key
is where it matters most.
- **Source-map sidecars:** sass/lightning emit `.map` files next to `$out`;
disable in the template or ignore the sidecar. Minor.
- **Error policy default:** hard-fail the build vs. warn-and-skip on tool
failure/absence. Lean fail; possibly configurable later.
-96
View File
@@ -1,96 +0,0 @@
# Visualizing & Managing `thor` Binary Size
How to inspect what makes up the compiled `thor` binary, and how to keep that
inspection useful once we bundle layout/asset files into the binary.
## Quick inspection (tools already available)
The binary is a not-stripped ELF, so it ships symbols we can break down.
```bash
# Section totals (text / data / bss)
size ./thor
# Top symbols by size
nm --print-size --size-sort --radix=d ./thor \
| awk '{sz=$2+0; sub(/^[0-9a-fA-F]+ +[0-9]+ +[A-Za-z] +/,""); printf "%8d %s\n", sz, $0}' \
| sort -rn | head -25
# Aggregate symbol sizes by Odin module namespace (foo::bar -> "foo")
nm --print-size --size-sort --radix=d ./thor \
| awk '{sz=$2+0; rest=$0; sub(/^[0-9a-fA-F]+ +[0-9]+ +[A-Za-z] +/,"",rest);
ns=rest; if (rest ~ /::/){sub(/::.*/,"",ns)}
else if (rest ~ /^ts_/){ns="treesitter (ts_*)"} else {ns="<other/runtime>"};
s[ns]+=sz} END{for (k in s) printf "%9d %s\n", s[k], k}' \
| sort -rn | head -25
```
As of this writing the top contributors were: tree-sitter tables (~118 KB),
`main` (~96 KB), runtime/other (~93 KB), `mustache` (~67 KB), then `fmt` /
`encoding_json` from the stdlib.
## Better visualization: `bloaty`
`bloaty` (Google's binary-size profiler) is the right tool. Not installed, but
runnable via Nix:
```bash
bloaty ./thor # by section
bloaty ./thor -d symbols -n 30 # by symbol, hierarchical
bloaty ./thor -d sections,symbols # section then symbols within each
# via nix (no install):
nix run nixpkgs#bloaty -- ./thor -d symbols -n 30
```
For a stripped-size estimate:
```bash
readelf -S ./thor | grep -E 'debug|symtab|strtab' # strippable overhead
strip -s ./thor -o thor.stripped && size thor.stripped
```
Odin also supports smaller builds: `odin build . -o:size -no-bounds-check`.
## IMPORTANT: keeping bloaty useful once we bundle layouts into the binary
Today the VFS (`vfs.odin`) reads layout/asset files lazily from disk at runtime
(`VFS_Entry.fs_path` + `os.read_entire_file_from_path`); `VFS_Entry.data` is
never populated at build time. So embedded content is **not** in `./thor` yet.
When we switch to embedding via Odin `#load(...)`, the file bytes land in the
binary's `.rodata` section. Whether bloaty can show *per-file* sizes depends on
how we emit the embed:
- **Section total — always visible.** `.rodata` grows by ~the total bundled
size. `bloaty ./thor -d sections` (or `size`) shows the aggregate, and we can
diff before/after. This always works.
- **Per-file breakdown — only if each file is a NAMED symbol.**
- A generated `map[string]VFS_Entry` literal full of inline `#load(...)`
expressions makes each blob an **anonymous** `.rodata` constant. Bloaty
lumps them together — no per-file breakdown.
- Assigning each file to its own **package-level variable** makes each a named
global symbol, so `bloaty ./thor -d symbols` (and the `nm` recipe above)
show each file individually:
```odin
layout_base_html := #load("defaults/layouts/base.html")
layout_single_html := #load("defaults/layouts/single.html")
// ...then reference these in the VFS map instead of inlining #load.
```
### Decision to bake into the bundling codegen
When we build the codegen that replaces the runtime `mount_dir` disk walk, have
it emit **one named package-level variable per embedded file** (not an anonymous
map literal). That single choice is what makes bloaty / `nm` per-file analysis
possible — cheap to do up front, painful to retrofit.
Additional options that help regardless of symbolization:
1. Build with Odin `-debug` (DWARF) and use `bloaty ./thor -d compileunits` to
attribute embedded bytes to the generated source file.
2. Since the generator already knows every file and its size, emit a size
manifest at bundle time (`path -> bytes`, sorted / treemap). This is the most
reliable per-file view and can't drift from reality; bloaty then just
confirms the `.rodata` total matches.
-38
View File
@@ -1,38 +0,0 @@
## Code Review
A human should manually review every file in the project. AI cannot complete
these tasks.
- [ ] Review every file in thor
- [ ] Review assets.odin
- [ ] Review content.odin
- [ ] Review defaults.odin
- [ ] Review feed.odin
- [ ] Review frontmatter.odin
- [ ] Review main.odin
- [ ] Review minify.odin
- [ ] Review `markdown/`
- [x] Review alerts.odin
- [x] Review alerts_test.odin
- [x] Review emoji.odin
- [x] Review emoji_test.odin
- [ ] Review footnotes.odin
- [ ] Review footnotes_test.odin
- [ ] Review deflists.odin
- [ ] Review deflists_test.odin
- [ ] Review highlight.odin
- [ ] Review markdown.odin
- [x] Review sectionate.odin
- [x] Review sectionate_test.odin
- [ ] Review suggest.odin
- [ ] Review suggest_test.odin
- [ ] Review `diagnostics/`
- [ ] Review diagnostics.odin
- [ ] Review diagnostics_test.odin
- [x] Review opengraph.odin
- [ ] Review render.odin
- [x] Review site.odin
- [ ] Review treesitter/treesitter.odin
- [ ] Review vfs.odin
- [ ] Review procs
- [ ] markdown.transform_alert
-61
View File
@@ -1,61 +0,0 @@
# 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`.
-379
View File
@@ -1,379 +0,0 @@
# This file contains tests for all known mustache syntax errors and their
# desired error messages. Each one is converted into an Odin test.
name: Missing 2nd closing brace
enable: false # TODO: Re-enable when passing
should: error
input: |
<menu>
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}</a>
{{#params.social}}
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
{{/params.social}}
</menu>
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
expected: |
Missing closing brace
--> <test>:1:1
1 | <menu>
2 | <a class="goto-top opacity-0" href="#">{{> icons/chevron_up}</a>
| ^^ opened ^ never closed
3 | {{#params.social}}
4 | <a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
5 | {{/params.social}}
|
---
name: Missing 2nd opening brace
enable: false # TODO: Re-enable when passing
should: error
input: |
<menu>
<a class="goto-top opacity-0" href="#">{> icons/chevron_up}}</a>
{{#params.social}}
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
{{/params.social}}
</menu>
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
expected: |
Missing opening brace
--> <test>:1:1
1 | <menu>
2 | <a class="goto-top opacity-0" href="#">{> icons/chevron_up}}</a>
| ^ opened ^^ closed
3 | {{#params.social}}
4 | <a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
5 | {{/params.social}}
|
---
# TODO: make it work
name: Invalid timezone
enable: false
should: warn
input: |
{
"date": {
"timezone": "America/New_Yorkside"
}
}
expected: "Did you mean 'America/New_York'? pointing to line+col no in the config"
actual: |
[WARN ] --- [148:init_site()] unable to load timezone 'America/New_Yorkside'
---
name: Mismatched section tags
enable: false # TODO: Re-enable when passing
should: error
input: |
<footer>
<menu>
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
{{#params.ocial}}
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
{{/params.social}}
</menu>
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
</p>
<p><small>&copy;</small> {{now | format "2006" }} {{params.author.name}}</p>
</footer>
expected: |
Mismatched section: 'ocial' opened, 'social' closed
--> <test>:4:5
|
4 | {{#params.ocial}}
| ^^^^^ opened — did you mean 'social'?
5 | <a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
6 | {{/params.social}}
| ^^^^^^ expected {{/params.ocial}}, got {{/params.social}}
|
---
name: Missing closing section
enable: false # TODO: Re-enable when passing
should: error
input: |
<footer>
<menu>
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
{{#params.social}}
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
<!-- {{/params.social}} -->
</menu>
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
</p>
<p><small>&copy;</small> {{now | format "2006" }} {{params.author.name}}</p>
</footer>
expected: |
Missing closing tag '{{/params.social}}'
--> <test>:4:5
|
4 | {{#params.social}}
| ^^^^^^^^^^^^^^^^^ opened
5 | <a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
6 | <!-- {{/params.social}} -->
| ^^^^^^^^^^^^^^^^^^ closing tag is commented out
|
---
name: empty tag
enable: true
should: error
input: |
<footer>
<menu>
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
{{#params.social}}
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
{{/params.social}}
</menu>
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
</p>
<p><small>&copy;</small> {{}} {{params.author.name}}</p>
</footer>
expected: |
Empty tag
--> <test>:11:28
|
9 | href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
10 | </p>
11 | <p><small>&copy;</small> {{}} {{params.author.name}}</p>
| ^^^^ tag must contain a key
12 | </footer>
|
---
name: empty raw tag
enable: true
should: error
input: |
Hello, {{&}}
expected: |
Empty tag
--> <test>:1:8
|
1 | Hello, {{&}}
| ^^^^^ tag must contain a key
|
---
name: Missing pipe arguement
enable: true
should: error
input: |
<footer>
<menu>
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
{{#params.social}}
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
{{/params.social}}
</menu>
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
</p>
<p><small>&copy;</small> {{now | group_by }} {{params.author.name}}</p>
</footer>
expected: |
group_by expects 1 argument, got 0
--> <test>:11:36
|
9 | href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
10 | </p>
11 | <p><small>&copy;</small> {{now | group_by }} {{params.author.name}}</p>
| ^^^^^^^^ missing argument
12 | </footer>
|
---
name: Double dot access
enable: false # TODO: Re-enable when passing
should: error
input: |
<footer>
<menu>
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
{{#params.social}}
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
{{/params.social}}
</menu>
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
</p>
<p><small>&copy;</small> {{now | format "2006" }} {{params..name}}</p>
</footer>
expected: |
Invalid key path: consecutive dots
--> <test>:11:60
|
9 | href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
10 | </p>
11 | <p><small>&copy;</small> {{now | format "2006" }} {{params..name}}</p>
| ^^ missing path name
12 | </footer>
|
---
name: Too many opening braces
enable: false # TODO: Re-enable when passing
should: error
input: |
<footer>
<menu>
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
{{#params.social}}
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
{{/params.social}}
</menu>
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
</p>
<p><small>&copy;</small> {{{{now | format "2006" }}} {{params.author.name}}</p>
</footer>
expected: |
Too many opening braces
--> <test>:11:28
|
9 | href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
10 | </p>
11 | <p><small>&copy;</small> {{{{now | format "2006" }}} {{params.author.name}}</p>
| ^^^^ expected {{{, got {{{{
12 | </footer>
|
---
name: Too many closing braces
enable: false # TODO: Re-enable when passing
should: error
input: |
<footer>
<menu>
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
{{#params.social}}
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
{{/params.social}}
</menu>
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
</p>
<p><small>&copy;</small> {{now | format "2006" }}}} {{params.author.name}}</p>
</footer>
expected: |
Too many closing braces
--> <test>:11:50
|
9 | href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
10 | </p>
11 | <p><small>&copy;</small> {{now | format "2006" }}}} {{params.author.name}}</p>
| ^^^^ expected }}, got }}}
12 | </footer>
|
---
# TODO: Implement
name: Frontmatter missing closing quote on key
enable: false
should: warn
input: |
{
"title: Docs"
}
[TOC]
## Introduction
expected: 'TODO:'
actual: |
[ERROR] --- [42:parse_frontmatter()] failed to parse frontmatter JSON: Expected_Colon_After_Key
---
# TODO: Implement
name: Fronmatter missing opening quote on value
should: warn
enable: false
input: |
{
"title": Docs"
}
[TOC]
## Introduction
expected: 'detailed diagnostic pointing to the point of failure and suggesting the addition of a quote'
actual: Nothing. The page compiles with no warnings or errors
---
name: No error when missing index page
enable: false # TODO: Re-enable when passing
should: error
expected: 'Your site has no index page! Please create an index.md or index.html file in ./content/'
---
# FIXME: Doesn't work properly
name: format pipe with no date format set
enable: false # TODO: Re-enable when passing
should: warn
input: '{{ now | format }}'
expected: |
no date format configured — set date.format in thor.json or specify a format, e.g. {{key | format "2 Jan 2006"}}
--> <test>:1:10
|
1 | {{now | format}}
| ^^^^^^ defaulting to "2 Jan 2006"
---
# TODO: called First/last, but only tests first
name: first/last with zero argument
enable: true
should: error
input: |
<ul>{{#items | first 0}}<li>{{.}}</li>{{/items}}</ul>
expected: |
argument must not be zero
--> <test>:1:16
|
1 | <ul>{{#items | first 0}}<li>{{.}}</li>{{/items}}</ul>
| ^^^^^^^
|
---
# TODO: called First/last, but only tests first
name: first/last with negative argument
enable: true
should: warn
input: |
<ul>{{#items | first -3}}<li>{{.}}</li>{{/items}}</ul>
expected: |
first -3 is equivalent to last 3
--> <test>:1:16
|
1 | <ul>{{#items | first -3}}<li>{{.}}</li>{{/items}}</ul>
| ^^^^^^^^ consider using the clearer form
|
---
# TODO: called First/last, but only tests first
name: first/last on string with no argument
# enable: true
input: '{{word | first}}'
should: warn
expected: |
defaulting to n=1 for string
--> <test>:1:1
|
1 | {{word | first}}
| ^^^^^ consider specifying n explicitly
|
---
name: "first succeeds on params.author.name"
enable: true
input: '{{ params.author.name | first 3 }}'
should: ok
expected: "foo"
---
# TODO: called First/last, but only tests first
# TODO: Include | last 3 }} in the suggestion.
name: "first/last in partial"
enable: true
input: "{{> footer }}"
should: warn
expected: |
first -3 is equivalent to last 3
--> fixtures/layouts/partials/footer.html:11:78
|
9 | href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
10 | </p>
11 | <p><small>&copy;</small> {{! now | format "2006" }} {{params.author.name | first -3 }}</p>
| ^^^^^^^^ consider using the clearer form
12 | </footer>
|
---
name: "Pipes are ignored in comments"
enable: true
should: ok
input: 'Hello, {{! now | format "2006" }}'
expected: 'Hello, '
-171
View File
@@ -1,171 +0,0 @@
# Grammar cache thread-safety & allocator lifetime
Status: **resolved.** All three issues below are fixed. The cache was redesigned
so the library caches only the immutable, shareable objects and callers own the
per-thread ones. The rest of this document is kept as the rationale.
## Resolution (what shipped)
The `Grammar_Cache` (which mixed shareable `language`/`query` with per-thread
`parser`/`cursor`) was split:
- **`Grammar`** holds only the immutable `language` + `query`. It lives in a
process-lifetime **`registry`** (`map[string]^Grammar`) guarded by one mutex.
- **`grammar(lang)`** is the thread-safe, self-initializing, lazy loader — fixes
**#1a** (all map access under the lock) and lets tests run with no setup.
- The registry allocator is pinned with `os.heap_allocator()` — fixes **#2**; the
cache no longer captures a transient `context.allocator`.
- Parsers/cursors are **caller-owned**: `open_parser(g)` mints one, the caller
`defer`s `parser_delete` / `query_cursor_delete`. No parser is shared, so
**#1b** is gone. `open_parser` is the seam where a future pool / thread-local
strategy plugs in without touching call sites.
- Deleted: `ensure_parser`, `load_grammar`, `preload_grammar`, `grammar_worker`,
`cache_mutex`; `preload_grammars` is now a serial registry warm-up. The minify
tests dropped their `parse_mu` serialization and preload scaffolding entirely.
The **later** decision — pool vs per-thread parser reuse for genuine parallel
parsing — is still open, but intentionally so: it only matters once we parse in
parallel, and it now changes only `open_parser`, not the rest of the library.
The original investigation follows.
## How we got here
While adding a feature to minify inline `<style>` blocks (route the CSS body
through `minify_css`), the new unit tests exercised `minify_html` / `minify_css`
for the first time. Those are the first tests to touch the tree-sitter parser
cache, and under the multi-threaded test runner (default 16 threads) they failed
**intermittently** with a rotating cast of symptoms:
- `panic: unable to insert into a map`
- tree-sitter internal aborts: `ts_subtree_retain: Assertion 'self.ptr->ref_count > 0' failed`, `_array__erase: Assertion 'index < *size' failed`
- `malloc(): unaligned tcache chunk detected`
- assertions passing on the wrong data (e.g. minified output missing `<style>`)
The flakiness comes from **three distinct problems**, two thread-safety and one
allocator-lifetime. They are orthogonal — fixing one does not fix the others.
## The three issues
### #1a — concurrent cache **writes** (data race)
`ensure_parser` (`treesitter.odin`) reads and writes `grammar_store.cache`
with **no lock**. `cache_mutex` exists but is only taken on the parallel preload
path (`grammar_worker`), never in the lazy `ensure_parser` / `load_grammar`
paths. Two threads first-touching `"html"`/`"css"` at once corrupt the map.
### #1b — concurrent parser **use** (data race)
There is exactly **one cached `Grammar_Cache` (and one `parser`) per language**.
When two threads both call `minify_html`, they fetch the *same* `gc.parser` and
call `parser_parse_string` on it simultaneously. A tree-sitter `TSParser`
mutates its internal subtree pool during a parse, so concurrent parses corrupt
it — that's the `ts_subtree_retain` / `_array__erase` aborts.
This is **not fixed by preloading/"loading html+css statically."** Loading gives
you *one shared* parser; the race is about *using* that one parser from two
threads. It is fixed only by serializing the parse or giving each thread its own
parser.
### #2 — allocator lifetime (not a race)
`init_persistent` captures `context.allocator` into the process-lifetime
`grammar_store`. In `main` that happens to be the heap (it runs before the site
arena is installed at `main.odin`), so production is fine. But whatever
allocator is live at that instant becomes the cache's owner forever:
- Under the **test runner**, `context.allocator` is a **per-test tracking
allocator** that is torn down when that test ends → the global cache is left
pointing at dead memory (→ `unable to insert into a map`, garbage reads).
- A **lazy** first call during rendering would capture the **site arena**
(`context.allocator` after `main.odin` swaps it in), which is destroyed every
watch-mode rebuild → dangling parsers across builds.
This would break even single-threaded; threading just decides *which* test wins
the init race.
## tree-sitter's threading model (the root fact)
tree-sitter objects have specific, documented threading rules:
| Object | Shareable across threads? | Rule |
|---|---|---|
| `TSLanguage` (`tree_sitter_html()` etc.) | Yes | Immutable; share freely. |
| `TSParser` (`gc.parser`) | **No** | One per thread; never parse on one parser from two threads. |
| `TSTree` | Only via `ts_tree_copy` | Copy before using on another thread. |
| `TSQuery` (`gc.query`) | Yes | Immutable after compile. |
| `TSQueryCursor` (`gc.cursor`) | **No** | One per thread, like the parser. |
This explains the existing design:
- The preload fan-out (`preload_grammar` / `grammar_worker`) creates a parser
**per worker** and only publishes the finished `Grammar_Cache` under
`cache_mutex` — because parsers can't be shared during construction/use.
- `ensure_parser` + the shared cached parser is fine in production **only
because rendering is single-threaded**.
A cached `Grammar_Cache` mixes shareable (`language`, `query`) and per-thread
(`parser`, `cursor`) objects in one shared struct, which makes it
single-threaded-use by construction — a fact that was previously undocumented.
## Where this actually bites
**Only in tests.** Confirmed by grep:
- Production parses on one thread: every real caller — `minify.odin` (html/css)
and `markdown/highlight.odin` — runs inside `main`'s single-threaded render
loop. No concurrent `ensure_parser` / `parser_parse_string`.
- The one threaded path in production, `preload_grammars`, is already safe (own
parser per worker + `cache_mutex` on the write) and only handles non-builtin
grammars.
- Only `minify_test.odin` touches tree-sitter from tests, and the test runner
runs those on many threads.
So production is correct **by convention** (single-threaded-from-heap usage),
not **by construction** — nothing stops a future parallel render from breaking
it, and until now nothing documented the constraint.
## Chosen direction
**For now:** keep the single global grammar cache and make it safe by
**serializing the non-thread-safe behavior with a mutex** — only one thread uses
a parser at a time. This is simple, matches how production already behaves, and
unblocks the tests.
**Later (TODO):** decide whether true multi-threaded parsing is worth it for
performance. If yes, give each thread its own parser + cursor (as the preload
workers already do) rather than sharing one behind a lock.
## Implementation options (deferred — pick when we resume)
All assume the allocator fix (#2) below, which is independent and unambiguous.
- **#2 allocator (do regardless):** pin the heap allocator explicitly —
`grammar_store.allocator = runtime.heap_allocator()` (or `os.heap_allocator()`)
instead of `context.allocator`. Makes the cache persistent from any context;
behavior-identical in production. Optionally make `init_persistent` idempotent
and/or an `@(init)` so callers need no ordering.
Then, for the races:
- **Option A — library mutex (sustainable):** take `cache_mutex` in
`ensure_parser` (fixes #1a) and add a locking `parse` helper that wraps
`parser_parse_string` (fixes #1b), wired into the three call sites
(`minify.odin` ×2, `markdown/highlight.odin` ×1). Tests then need no
scaffolding. Serializes all parsing.
- **Option B — test-side only (minimal):** leave the library single-threaded by
contract; make the tests not violate it. Either fold the minify tests into one
`@(test)` (no concurrency at all → no locks), or wrap the `minify_*` calls in a
test-local mutex. Production stays lock-free.
Recommendation captured during the discussion: the allocator one-liner is
worth doing immediately; for the races, Option A is the "correct" long-term
answer but only necessary if/when we want concurrent parsing — otherwise
Option B is enough.
## Pointers
- `treesitter/treesitter.odin`: doc comments on `Grammar_Cache`, `cache_mutex`,
`init_persistent`, `ensure_parser` reference this file.
- Original feature that surfaced this: minify inline `<style>` bodies via
`minify_css` (reverted; to be re-landed alongside whichever fix we choose).
+51 -8
View File
@@ -1,11 +1,54 @@
# Hugo Notes # Hugo vs Thor: Why Thor Doesn't Need Shortcode Context Isolation
from the [Hugo docs](https://gohugo.io/quick-reference/glossary/#default-sort-order) ## Hugo's Shortcode Isolation
default sort order Hugo strictly separates shortcode context from layout template context. Shortcodes run during markdown rendering, before layouts. They get a limited context (`.Page`, `.Site`, `.Params`), not the full layout rendering state.
: The default sort order for page collections, used when no other criteria are set, follows this priority: ### Why Hugo does this
1. weight (ascending)
2. date (descending) 1. **Circular dependencies** — Hugo shortcodes output into `.Content`. Layouts read `.Content` and compute derived properties (`.TableOfContents`, `.WordCount`, `.ReadingTime`). If shortcodes could access these computed properties, you'd get circular dependencies (shortcode output → computed property → shortcode reads it).
3. linkTitle falling back to title (ascending)
4. logical path (ascending) 2. **Multiple layouts/themes** — Hugo supports user-selectable themes and multiple layouts per section type. A shortcode must produce identical output regardless of which layout renders it. Isolating context guarantees portability.
3. **Shared mutable page object** — Hugo builds a rich `Page` object that both shortcodes and layouts access. Context isolation prevents shortcodes from mutating layout-visible state.
4. **Security** — Shortcodes live in content files (potentially user-authored). Full template access would let content authors inject arbitrary logic, access sensitive config, or break site structure.
5. **Caching** — Hugo caches shortcode output independently. Predictable context = predictable cache behavior.
## Why this doesn't apply to thor
- **No computed page properties** — The content Mustache pass runs on raw markdown (before cmark). The layout Mustache pass runs on finished HTML (after cmark). There's no shared `.Content` object or derived properties. The two passes are at different pipeline stages with no shared mutable state.
- **Single layout set** — Thor has one set of templates (`base.html` + page templates). No theme switching, no multiple layout variants per section. Portability across layouts isn't a concern.
- **Explicit data passing** — Each `mustache.render` call receives its own data struct. Content pass gets frontmatter + params. Layout pass gets page data + site config. No shared mutable object between them.
- **Single author** — Content is authored by the site owner. No untrusted user-generated content.
- **No caching** — Thor rebuilds from scratch every time. No cache invalidation concerns.
## Thor's approach
Thor runs Mustache on content **before** cmark as a pre-processing step, then runs Mustache on layout templates **after** cmark as a post-processing step. These are two independent `mustache.render` calls:
```
content (markdown)
→ mustache.render(content, content_data, partials) ← content pass
→ cmark markdown_to_html
→ inject_sidenotes / inject_alerts / highlight_code
→ mustache.render_in_layout(template, page_data, layout, partials) ← layout pass
→ final HTML
```
The content pass can use Mustache variables, partials (`{{> ./file}}`), sections, and conditionals freely. The layout pass wraps the rendered content in the page chrome. They share nothing except the data we explicitly choose to pass.
## When this WOULD matter for thor
If thor ever adds:
- Computed page properties (table of contents, reading time, word count)
- Multiple selectable themes/layouts
- User-generated content / multi-author support
- Partial template caching
...then context isolation between the content and layout passes would become relevant. Until then, it's unnecessary complexity.
+87
View File
@@ -0,0 +1,87 @@
# Timezone Support
## Goal
Add timezone conversion to the `format` pipe so dates display in the configured site timezone, with correct abbreviations for the `MST` token.
## Context
Dates are stored as raw ISO 8601 strings. The `format` pipe formats them for display using Go-style layout tokens. Currently `parse_iso_date` ignores the timezone offset suffix, and `MST` is hardcoded to `"UTC"`.
Pipes now take `ctx: []any`, so timezone config can flow through the data context identically to `date_format` — no new parameters to thread.
## Decisions
- **MST fallback** (no target tz, date has offset): `UTC-04:00` format
- **`now` field**: intentionally UTC (offset=0). The `format` pipe handles timezone display.
- **TZ_Region cache**: lives in the mustache package (`format.odin`)
## Files changed
| File | Changes |
|---|---|
| `mustache/format.odin` | Extend `Date_Components` (+`offset_seconds`, `has_offset`, `tz_abbr`). Extend `parse_iso_date` to parse trailing offset. Add `tz_cache`, `get_cached_tz`, `convert_to_tz`, `format_offset`, `destroy_tz_cache`. Fix MST token. Import `core:time/timezone`. |
| `mustache/pipes.odin` | Resolve `date_timezone` from ctx in `"format"` case (optional — nil is fine). Add `timezone_name` param to `apply_format`. Orchestrates parse → convert → format. |
| `render.odin` | Add `date_timezone: string` to `Base_Data`, populate from `site.date.timezone`. Remove `// TODO: CAlculate offset` (offset=0 is intentional — UTC). |
| `site.odin` | Call `mustache.destroy_tz_cache()` from `destroy_site`. No structural changes — `Date_Preferences.timezone` already exists and flows through config. |
| `mustache/pipes_test.odin` | Add `date_timezone: string` to test data structs. Add timezone conversion tests. |
## Conversion logic (in `apply_format`)
```
1. parse_iso_date(iso) → components (now includes offset_seconds, has_offset)
2. tz_name := resolve "date_timezone" from ctx (optional)
3. target_tz := get_cached_tz(tz_name) // nil if empty/UTC/not configured
4. if target_tz != nil:
components = convert_to_tz(components, target_tz)
// tz_abbr filled by convert_to_tz via timezone.shortname()
5. else if components.has_offset:
components.tz_abbr = format_offset(components.offset_seconds)
// e.g. "UTC-04:00"
6. else:
components.tz_abbr = "UTC"
7. format_date(components, fmt)
```
## `convert_to_tz` flow
```
1. Build DateTime from components (tz=nil=UTC)
2. If has_offset: add offset_seconds to get true UTC
3. timezone.datetime_to_tz(utc_dt, target_tz) → converted DateTime
4. Extract components from converted DateTime
5. tz_abbr = timezone.shortname(converted_dt) // "EST", "EDT", etc.
```
## MST fallback: `format_offset`
```
0 → "UTC"
-14400 → "UTC-04:00"
+19800 → "UTC+05:30"
```
## `now` field
`now` stays UTC (offset=0). Remove the `// TODO: CAlculate offset` comment — it's correct as-is. Templates format it with `{{now | format}}` and the pipe handles timezone display.
## Behavior matrix
| Config TZ | ISO has offset | Conversion | `MST` output |
|---|---|---|---|
| `"America/New_York"` | yes (`-04:00`) | UTC → NY (DST-aware) | `"EST"`/`"EDT"` |
| `"America/New_York"` | no | assume already in NY | `"EST"`/`"EDT"` |
| not set | yes (`-04:00`) | none — display as-is | `"UTC-04:00"` |
| not set | no | none | `"UTC"` |
## Imports added
- `mustache/format.odin`: `import "core:time/timezone"` (for `region_load`, `datetime_to_tz`, `shortname`, `region_destroy`)
## Odin timezone API reference
- `timezone.region_load(name: string) -> (^datetime.TZ_Region, bool)` — `"local"` reads `$TZ` env, falls back to `/etc/localtime`
- `timezone.region_destroy(region: ^datetime.TZ_Region)`
- `timezone.datetime_to_tz(dt: DateTime, tz: ^TZ_Region) -> (DateTime, bool)` — DST-aware. If `dt.tz == tz`, no-op. If `dt.tz == nil`, treats as UTC.
- `timezone.shortname(dt: DateTime) -> (string, bool)` — abbreviation from TZ_Region records (e.g. `"EST"`, `"EDT"`)
- `datetime.DateTime :: struct { using date: Date, using time: Time, tz: ^TZ_Region }` — `tz == nil` means UTC
-317
View File
@@ -1,317 +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.
### 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.
-131
View File
@@ -1,131 +0,0 @@
## v0.2.0
- [ ] [aliases](https://gohugo.io/methods/page/aliases/#redirects)?
- [ ] consider sites with date based urls.
- [ ] Create a json schema file for `thor.json`.
- [ ] Content-hash fingerprinting for CSS and JS cache busting
- [ ] Mount content in VFS
- [ ] `-watch` flag
- [x] basic poll loop
- [ ] filesystem poll loop
- [ ] event based
- [ ] if no `html` tag detected in output, re-render output with base template
(or whatever template is next in the chain). (Hugo seems to do this)
- [ ] Markdown
- [ ] Block attributes extension — hello-world.md
- [ ] add tables extension
- [ ] enable template level rendering of TOCs
- [ ] Write css for toc sidebar and figure out where to put it.
- [ ] Add [hugo style configuration](https://gohugo.io/configuration/markup/#table-of-contents)
- [ ] implicit page titles (Set when missing?)
- guess from h1 tag or first h2 tag?
- [ ] Syntax highlighting
- [ ] grammars
- [ ] load grammars dynamically
- [ ] Search in multiple places
- [ ] Download missing grammars.
- [ ] Durable highlight paths: read `GRAPHS_PATH`/`QUERIES_PATH` from env vars set by the flake instead of hardcoded nix store hashes, so they survive `nix flake update` and let the grammar/query version-mismatch detector fire automatically.
- [ ] Theme selector
- use http://github.com/helix-editor/helix/tree/master/runtime/themes) as a
guide
- [ ] Unit tests for highlighting helpers: `capture_name_to_css`, `escape_html`, `unescape_html`, `extract_query_token`, `helix_version_from_path`.
- [ ] `<pre><code>` blocks need to set background to theme background,
regardless of prefers-dark. (or use a different theme)
- [ ] Avoid `json.Value` / `json.Object` where possible.
- [ ] 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.
- [ ] try to combine render_page_html and render_home_html?
- [ ] make `parse` an overload of `parse_text/parse_inline` and `parse_file`, or something.
- [ ] refactor `mustache.load_partials` to accept an iterator so we don't copy `main.load_partials`
### Menus
- [ ] Nav items should be active when the current page is selected.
- [ ] configure opt-out of automatic sections being added to menu.
- [ ] nested menus (i.e. `parent` support)
### Strict Mode
- [ ] opt-in "strict_keys" mode. in this mode, key lookups may not view parent objects.
- [ ] warn/error when unknown key used in mustache.
- [ ] enforce heading structure.
- [ ] Either frontmatter.title set, or 1 h1 tag at top, not both
- [ ] No skipping.
### Dates
- [ ] display an error when no part of the date appears in the output.
- [ ] Handle 0 and whitespace padding i.e. "_2" -> " 2"
- [ ] Do we *need* mustache.Date_Components, or can we use core:time/datetime.DateTime?
## Beyond
- [ ] consider logging the number of times an error occurred. (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 (diagnostic)
- [ ] Do mustache's whitespace rules actually suit us, or should we make our own?
- [ ] create a default `head.html`?
- [ ] improve home link customization.
- [ ] currently an accessibility issue.
- [ ] get rid of the global variables in the `treesitter` package.
- [ ] site-wide Integrity hash
- Allows users to verify their output didn't change after upgrading to a new version
- [ ] Add `-production` flag
- sets `-minify`
- [ ] manually pass `site_allocator` to `load_page`
- [ ] Import/export packages. Hugo, jekyll, WordPress, etc.
- [ ] shortcodes?
- include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin
- [ ] follow symlinks in `scan_content`?
- [ ] Add overloads for every md extension? i.e. accept ^strings.Builder.
- [ ] content link checker?
- Checks all links on each page to make sure they are valid.
- [ ] Peruse [GitHub's](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts)
docs for any juicy nuggets we may have missed.
- [ ] Avoid using `render_inline_md` if possible.
- [ ] don't use bullshit "sub-tokens", add filters and pipes as proper tokens.
- [ ] 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.
- [ ] Link checker / linter that warns if links don't resolve. ("please use {{whatever | rel_url }}").
### Memory Management
- [ ] Leaks in highlighter code.
- [ ] Not sure whether to use temp allocator or site_allocator in opengraph.odin.
- [ ] Not sure whether to use temp allocator or site_allocator in `site_load_content`.
- [ ] Might not need to allocate in `strip_html_tags`
- [ ] Fix `apply_filter`'s `format` case (`mustache/pipes.odin`) boxing `apply_format`'s
`string` result into `any` via bare `return`, which materializes a hidden
header temp in `apply_filter`'s own stack frame. Dangling once the frame
returns; caused the `-o:speed` segfault in `write_value`. Fix: box explicitly
with `any{new_clone(formatted, context.temp_allocator), typeid_of(string)}`.
- [ ] Same pattern in `apply_group_by` (`mustache/pipes.odin`): `return groups, nil`
boxes a freshly-built `[dynamic]Group` as bare `any` — same latent
stack-temp UB, hasn't crashed yet but should get the same treatment.
### Performance
- [ ] See if we can disable bounds checks in `write_indented` and elsewhere.
- [ ] Instead of loading the site fresh each time in watch mode, create a
`reload_site` proc, that just updates changed resources.
- [ ] Only publish referenced assets.
- [ ] Split `load_page` into frontmatter-parse + body-process phases so draft pages can skip the markdown pipeline entirely
- [ ] Use spall to find ways to reduce run time.
- [ ] too many `write_string` calls in `highlight_block`
- [ ] return `src: cstring` from `load_query`.
- [ ] Improve `unescape_html` with simd.
- [ ] generate summary before syntax highlighting.
- [ ] generate summary before markdown to html conversion.
- [ ] mount_recursive is pretty significant
- [ ] thread pool for grammar loading is unbounded.
- [ ] load grammars async.
- [ ] during `load_page`:
- pass each code block to the treesitter queue
- continue working on the page,
- `await` the highlighted code.
- [ ] can markdown extensions run in parallel?
- [ ] enforce MAX_SLUG_LENGTH
- [ ] enforce MAX_CONTEXT_DEPTH
- [ ] ensure struct fields are ordered correctly
+225 -127
View File
@@ -1,143 +1,241 @@
# TODOs ## High priority
## Priority Unclear (Evaluate Later) - 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
- [ ] 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.
- [ ] 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
- [x] Simplify / unify template context stack. Come up with a name for it.
- [x] `render_template` should accept `Template_Context`, not `any`
- [ ] 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.
- [x] Add a `#config(MAX_CONTEXT_DEPTH, 16?)` to `mustache`.
- [ ] Documentation
- [ ] talk about the context stack (and its limit).
- [ ] highlight the differences in the way menus are handled.
- [ ] 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.
- [ ] improve home link customization.
- [ ] Decide the parallel-parsing strategy for the grammar cache (pool vs ## Performance
thread-local parsers) — deferred until the parallel usage shape is clear.
- Resolved already: the registry (`grammar()`) is thread-safe and self-
initializing, its allocator is pinned to the heap, and parsers/cursors are now
caller-owned (`open_parser`) instead of cached — so the library holds no
single-thread-use state and the multi-threaded tests pass without locks.
- Remaining choice only matters once we actually parse in parallel: reuse
parsers via a pool or per-thread storage, plugged in behind `open_parser`
without touching call sites. A `TSParser`/`TSQueryCursor` is one-per-thread.
- Full write-up: `GRAMMAR_CACHE_THREADING.md`.
- [x] Restore parallel grammar preload (serialized in `edb2e64`); `preload_grammars`
now fans `build_grammar` across a bounded `core:thread.Pool`.
- [ ] Add tests: pool fan-out correctness + `-sanitize:thread` for race-freedom.
- [ ] Concurrent-read path: an `RW_Mutex` on `grammar()` only helps a future
multi-threaded render, not the load side. Revisit if render goes parallel.
- [ ] instead of `warnings: [dynamic]Error` we should use `warnings: [dynamic; 8]Error`
- when limit reached, the template fails and stops rendering.
- [ ] Brainstorm plugin/tool/(postccs/less/tailwind/sass) support.
- [ ] `fingerprint` pipe + contents-based composition. The `tool` pipe currently
carries a URL string and each stage publishes a file to the output dir, so
`| tool sass | fingerprint` would leave a dead unhashed intermediate. When
fingerprint lands, move the pipe value from a URL string to a contents/
Resource (bytes + logical name) where only the terminal stage publishes;
a Resource must stringify to its URL so existing bare-`tool` templates keep
working. stdin/stdout tools compose best there (bytes stay in memory), which
is why stdin/stdout is the documented preferred form over `{{in}}`/`{{out}}`.
- [ ] Reconsider `"menus": {}` opt-out: it currently discards all page frontmatter
menus (with only a warning). Decide whether empty config should instead just
suppress the auto-generated menu while still honoring frontmatter.
- [ ] asset css files not automatically included in site.
- should we at least warn about them?
- [ ] Review the whole `copy_assets_dir` pipeline. It copies every file under
`assets/` to the output dir blindly, whether or not anything references it,
and now also skips dests that already exist (a blunt collision guard so the
raw copy doesn't clobber `tool`-pipe output written earlier in the build).
Reconsider tracking which assets are actually used/published instead.
- [ ] warn if stylesheet url starts with /assets (and is therefore invalid).
- [ ] warn when heading levels are skipped in a toc.
- [ ] extract `[TOC]` from `.md` body (or at least from the start.)
- [ ] add `--production` flag
- set `minify` to true
- warn when `base_url` unset
## General - [ ] See if we can disable bounds checks in `write_indented` and elsewhere.
- [ ] Instead of loading the site fresh each time in watch mode, create a
`reload_site` proc, that just updates changed resources.
- [ ] Only publish referenced assets.
- [ ] Split `load_page` into frontmatter-parse + body-process phases so draft pages can skip the markdown pipeline entirely
- [ ] Use spall to find ways to reduce run time.
- [ ] too many `write_string` calls in `highlight_block`
- [ ] return `src: cstring` from `load_query`.
- [ ] Improve `unescape_html` with simd.
- [ ] generate summary before syntax highlighting.
- [ ] generate summary before markdown to html conversion.
- [ ] mount_recursive is pretty significant
- [ ] thread pool for grammar loading is unbounded.
- [ ] load grammars async.
- [ ] during `load_page`:
- pass each code block to the treesitter queue
- continue working on the page,
- `await` the highlighted code.
- [ ] can markdown extensions run in parallel?
- [ ] enforce MAX_SLUG_LENGTH
- [ ] enforce MAX_CONTEXT_DEPTH
- [ ] ensure struct fields are ordered correctly
Remember to polish existing features before moving on to new ones. ## Remove Privileged content
- [ ] Bake the default `layouts` into the binary at compile time via - [ ] `group_by` currently requires a computed `year` field on the page.
`#load_directory` so the binary is self-contained and doesn't read - We should replace this with `{{ pages | group_by (date | "2006") }}` or similar
`DEFAULTS_PATH` from disk at runtime.
- `#load_directory` is NOT recursive and its `name` is the base filename
only, so `defaults/layouts` and `defaults/layouts/partials` must be loaded ## Memory Management
separately and mounted into the VFS with `data` set (not `fs_path`).
- Removes the runtime dependency on `THOR_DEFAULTS_PATH` / `#directory` and - [ ] Leaks in highlighter code.
the need to ship `defaults/` alongside the binary (see `flake.nix`). - [ ] Not sure whether to use temp allocator or site_allocator in opengraph.odin.
- [ ] `build` command alias of default - [ ] Not sure whether to use temp allocator or site_allocator in `site_load_content`.
- [ ] `new site` command to set up new project - [ ] Might not need to allocate in `strip_html_tags`
- [ ] Some render errors blank the entire page instead of scoping to the failing tag. - [ ] Fix `apply_filter`'s `format` case (`mustache/pipes.odin`) boxing `apply_format`'s
`string` result into `any` via bare `return`, which materializes a hidden
header temp in `apply_filter`'s own stack frame. Dangling once the frame
returns; caused the `-o:speed` segfault in `write_value`. Fix: box explicitly
with `any{new_clone(formatted, context.temp_allocator), typeid_of(string)}`.
- [ ] Same pattern in `apply_group_by` (`mustache/pipes.odin`): `return groups, nil`
boxes a freshly-built `[dynamic]Group` as bare `any` — same latent
stack-temp UB, hasn't crashed yet but should get the same treatment.
## Markdown
- [ ] Add overloads for every extension - accept ^strings.Builder.
- [ ] Add conventional (Hugo style) footnotes option.
- [ ] Add opt-in deflist support.
- [ ] Decide if lambdas actually provide any value.
## Dates
- [ ] display an error when no part of the date appears in the output.
- [ ] Handle 0 and whitespace padding i.e. "_2" -> " 2"
- [ ] Do we *need* mustache.Date_Components, or can we use core:time/datetime.DateTime?
## General
- [ ] configure opt-out of automatic sections being added to menu.
- [ ] get rid of the global variables in the `treesitter` package.
- [ ] Consider using `or_else` when applying default values to structs. i.e.
```odin
package main
X :: struct {
foo: string
}
main :: proc () {
x: X
x.foo = x.foo or_else "bar"
}
```
- [ ] Integrity hash
- Allows users to verify their output didn't change after upgrading to a new version
- [ ] 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.
- [ ] 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.
- [ ] Create a json schema file for `thor.json`.
- [ ] make `parse` an overload of `parse_text/parse_inline` and `parse_file`, or something.
- [ ] 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.
At present, "posts" are a user-level construct defined as pages in a At present, "posts" are a user-level construct defined as pages in a
particular collection. particular collection.
- [ ] ? Clean up the default layouts (no idea what this means)
- [ ] `group_by` currently requires a computed `year` field on the page.
- We should replace this with `{{ pages | group_by (date | "2006") }}` or similar
- [ ] does it make sense for partials to be inside layouts?
- current: `layouts/partials`
- alt1: `layouts`, `partials`
- alt2: `templates/layouts`, `templates/partials`
- [ ] add `sort_by` pipe
- [ ] pipes
- [x] first/last
- [ ] write test for error when n == 0
- [x] warn when using negative numbers
- [x] warn when using default arg on string
- [x] add the above warnings to DIAGNOSTIC_TESTS.md
- [ ] Instead of "clearer form", be exact: "last 5"
- [ ] write tests for `first 0` & `last 0`
- [ ] update tests to assert diagnostics when appropriate
- do we want to do this or just use DIAGNOSTIC_TESTS.md as the tests?
- [ ] also update to use file logger so they don't clog up the output
- [ ] narrow highlight in diagnostic to relevent part of line.
- [ ] page.url and page.permalink are confusing names. Switch to url + page_url
- There is confusion over how to do this and whethor or not `| rel_url` should be needed to render these.
Requires **careful thought**.
## Documentation
- [ ] talk about the context stack (and its limit).
- [ ] highlight the differences in the way menus are handled.
- [ ] explain that html/css/js comments are rendered, except in minify mode.
## Investigate
- [ ] 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
- [ ] 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
(or whatever template is next in the chain)
- [ ] Add `-production` flag
- sets `-minify`
- [ ] Mustache diagnostics
- [ ] Partial invocation stack in diagnostics: when an error fires inside a partial, show "invoked from" chain through `{{> name}}` calls. Currently warnings inside partials point at the partial (correct file) but don't show the invocation site.
- [ ] Could be better error message when missing a closing (or opening) brace
- [ ] Error message doesn't show position of faulty pipe name correctly.
- [ ] `render_template` (`render.odin`) blanks the *entire page* to `""` on any
mustache render error and only `log.errorf`s it — a single bad tag/pipe
anywhere on the page silently kills the whole output with no visible
signal outside the terminal log. Should at least be scoped to the
failing tag/section, or surfaced somewhere the person building the
site will actually see it.
```bash
[ERROR] --- [138:render_template()] unknown pipe op 'formats'
--> /home/spencer/github.com/sbrow.github.io/layouts/home.html:1:1
|
1 | {{<base}}
| ^^^^^^^^^
2 | {{$content}}
3 | <main>
```
- [ ] Block attributes on code fences (`{ #ex-1 }`) — hello-world.md
- [ ] include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin
- [ ] follow symlinks in `scan_content`?
- [ ] ensure sidenote numbers render in display order and not in declaration order.
- [ ] Table of contents support.
- [ ] Nav items should be active when the current page is selected.
- [ ] Theme selector for syntax highlighting.
- use http://github.com/helix-editor/helix/tree/master/runtime/themes) as a
guide
- [ ] grammars
- [ ] Search in multiple places
- [ ] Download missing grammars.
- [ ] Durable highlight paths: read `GRAPHS_PATH`/`QUERIES_PATH` from env vars set by the flake instead of hardcoded nix store hashes, so they survive `nix flake update` and let the grammar/query version-mismatch detector fire automatically.
- [ ] CI
- [ ] Syntax highlighting in production: CI (`nix build` on ubuntu-latest) has no grammar `.so`s and a machine-specific `QUERIES_PATH` nix store hash, so the deployed site renders unhighlighted. Provide grammars + queries as nix build inputs and pass paths to thor at runtime (env vars/flags).
- [ ] Unit tests for highlighting helpers: `capture_name_to_css`, `escape_html`, `unescape_html`, `extract_query_token`, `helix_version_from_path`.
- [ ] `<pre><code>` blocks need to set background to theme background,
regardless of prefers-dark. (or use a different theme)
- [ ] `-watch` flag
- [x] basic poll loop
- [ ] filesystem poll loop
- [ ] event based
- [ ] Free cmark HTML output (`body_html`) — cmark allocates via C malloc, not the arena, so it leaks per iteration in watch mode
- [ ] Mount content in VFS
- [ ] commands
- [ ] `build` alias of default
- [ ] `new site` set up new project
- [ ] warn/error when unknown key used in mustache.
- [ ] Import/export packages. Hugo, jekyll, WordPress, etc.
- [ ] opt-in "strict_keys" mode. in this mode, key lookups may not view parent objects.
## Diagnostics ## Notes
All diagnostics (errors) should show from the [Hugo docs](https://gohugo.io/quick-reference/glossary/#default-sort-order)
- *What* went wrong default sort order
- *where* (in the file) : The default sort order for page collections, used when no other criteria are set, follows this priority:
- *where* (in the stack trace) 1. weight (ascending)
- *how* you can fix it (if applicable) 2. date (descending)
3. linkTitle falling back to title (ascending)
4. logical path (ascending)
- [ ] try to make file paths clickable links. ## Code Review
- [ ] make sure the frontmatter parser has good diagnostics.
- [ ] add all known diagnostics to [DIAGNOSTIC TODOS](./DIAGNOSTIC_TESTS.yaml).
- [ ] fix the broken diagnostics in [DIAGNOSTIC TODOS](./DIAGNOSTIC_TESTS.yaml)
- [ ] show *stack trace* for template partials?
- [ ] show where the typo occurred (config file path for `apply_extension_config`, full `-ext:value` for `parse_extension_list`)
- [ ] **all** diagnostics in [DIAGNOSTIC TODOS](./DIAGNOSTIC_TESTS.yaml) must be tested.
- [ ] only report format errors once.
- [ ] only report missing partial errors once.
- [ ] better diagnostics for syntax errors in treesitter.
- [ ] Ensure diagnostics for MAX_CONTEXT_DEPTH are good.
- [ ] improve matching weights message.
- [ ] Test menu diagnostics
- [ ] 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.
## Analytics A human should manually review every file in the project. AI cannot complete
these tasks.
Debug log stats: - [ ] Review every file in thor
- [ ] Review assets.odin
- [ ] final Context_Stack cap - [ ] Review content.odin
- [ ] highest PIPE args used - [ ] Review defaults.odin
- [ ] longest slug length + name that generated it - [ ] Review feed.odin
- [ ] number of pages - [ ] Review frontmatter.odin
- [ ] number of blocks - [ ] Review main.odin
- [ ] enabled features / extensions - [ ] Review minify.odin
- [ ] etc - [ ] Review `markdown/`
- [x] Review alerts.odin
## sbrow.github.io - [x] Review alerts_test.odin
- [ ] The home file was accidentally deleted in commit 9a9f731 - [x] Review emoji.odin
- [ ] Ideas is now in the wrong spot. Date is wrong, and it is showing date - [x] Review emoji_test.odin
when it shouldn't be. - [ ] Review footnotes.odin
- [ ] Review footnotes_test.odin
- [ ] Review highlight.odin
- [ ] Review markdown.odin
- [x] Review sectionate.odin
- [x] Review sectionate_test.odin
- [ ] Review suggest.odin
- [ ] Review suggest_test.odin
- [x] Review opengraph.odin
- [ ] Review render.odin
- [x] Review site.odin
- [ ] Review treesitter/treesitter.odin
- [ ] Review vfs.odin
- [ ] Review procs
- [ ] markdown.transform_alert
-109
View File
@@ -1,109 +0,0 @@
# Warning Counter Plan
## Goal
When the same warning fires multiple times across a single page render, show it once with a `[×N]` count prefix instead of N identical diagnostic blocks.
```
[×5] first -3 is equivalent to last 3 — consider using the clearer form
--> layouts/home.html:12:1
|
12 | <ul>{{#items | first -3}}<li>{{.}}</li>{{/items}}</ul>
| ^^^^^^^^^^^^^^^^^^^^^ consider using the clearer form
|
```
## Approach
Module-level buffer in `pipes.odin`. Warnings are collected during render, deduplicated + counted + logged after the page completes. No parameter threading through `render_nodes`.
## Changes
### `mustache/pipes.odin`
**New module-level state:**
```odin
_pending_warnings: [dynamic]Error
```
**New procs:**
```odin
reset_warnings :: proc() {
clear(&_pending_warnings)
}
flush_warnings :: proc(colorize: bool = false) {
if len(_pending_warnings) == 0 do return
counts := make(map[string]int, context.temp_allocator)
first: map[string]Error = make(map[string]Error, context.temp_allocator)
defer delete(counts)
defer delete(first)
for w in _pending_warnings {
b := body(w)
if _, seen := first[b.msg]; !seen {
first[b.msg] = w
}
counts[b.msg] += 1
}
for msg, w in first {
c := counts[msg]
b := body(w)
path := b.path != "" ? b.path : "<input>"
prefix := c > 1 ? fmt.tprintf("[×%d] ", c) : ""
diag := diags.format_error(
path, b.source, b.pos, b.msg,
hint = b.hint,
colorize = colorize,
)
log.warnf("%s%s", prefix, diag)
}
clear(&_pending_warnings)
}
```
**`apply_pipeline` change:**
Replace the current warning handling (which logs immediately):
```odin
if warnings != nil {
append(warnings, ferr)
} else {
log.warnf("%s", format_render_error(ferr, tmpl))
}
```
With:
```odin
append(&_pending_warnings, ferr)
```
Drop the `warnings: ^[dynamic]Error` parameter — no longer needed.
### `render.odin`
In `render_template`, wrap the render call:
```odin
mustache.reset_warnings()
result, err := mustache.render(content_tpl, []any{ctx.site, ctx.page, ctx}, partials)
mustache.flush_warnings(colorize = diags.should_colorize())
```
### `mustache/pipes.odin` cleanup
- Remove `warnings` parameter from `apply_pipeline` signature
- Remove `format_render_error` import/call from `apply_pipeline` (formatting moves to `flush_warnings`)
- Remove the `log` import if no longer used directly in `apply_pipeline`
## Scope
~25 lines new code (`reset_warnings` + `flush_warnings`), ~3 lines changed in `apply_pipeline`, ~2 lines added in `render.odin`. Total ~30 lines.
## Limitations
- Module-level state means warnings don't survive across page renders (by design — `reset_warnings` is called per page)
- Not thread-safe (fine for sequential rendering)
- Counting is by exact `msg` string — warnings with different messages but same root cause are counted separately
+1 -24
View File
@@ -5,20 +5,7 @@ import "core:log"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
import ts "treesitter"
copy_assets_dir :: proc(vfs: ^VFS, output_dir: string, features: bit_set[Feature]) { copy_assets_dir :: proc(vfs: ^VFS, output_dir: string, features: bit_set[Feature]) {
// One CSS parser, reused for every .css asset (single-threaded). Owned here.
css_parser: ts.Parser
if .Minify in features {
if g, ok := ts.grammar("css"); ok {
css_parser = ts.open_parser(g)
}
}
defer if css_parser != nil {
ts.parser_delete(css_parser)
}
for virtual_path, entry in vfs.files { for virtual_path, entry in vfs.files {
if !strings.has_prefix(virtual_path, "assets/") { if !strings.has_prefix(virtual_path, "assets/") {
continue continue
@@ -27,12 +14,6 @@ copy_assets_dir :: proc(vfs: ^VFS, output_dir: string, features: bit_set[Feature
rel := virtual_path[len("assets/"):] rel := virtual_path[len("assets/"):]
dest := fmt.tprintf("%s/%s", output_dir, rel) dest := fmt.tprintf("%s/%s", output_dir, rel)
// Don't clobber a file already written this build (e.g. the output of a
// `tool` pipe, which publishes to the same dest path as its source).
if os.exists(dest) {
continue
}
if idx := strings.last_index(dest, "/"); idx >= 0 { if idx := strings.last_index(dest, "/"); idx >= 0 {
if err := os.make_directory_all(dest[:idx]); err != nil && err != .Exist { if err := os.make_directory_all(dest[:idx]); err != nil && err != .Exist {
log.warnf("cannot create %s: %v", dest[:idx], err) log.warnf("cannot create %s: %v", dest[:idx], err)
@@ -43,11 +24,7 @@ copy_assets_dir :: proc(vfs: ^VFS, output_dir: string, features: bit_set[Feature
if .Minify in features && strings.has_suffix(rel, ".css") { if .Minify in features && strings.has_suffix(rel, ".css") {
data, ok := vfs_get(vfs, virtual_path) data, ok := vfs_get(vfs, virtual_path)
if ok { if ok {
if m, mok := minify_css(css_parser, string(data)); mok { write_file(dest, minify_css(string(data)))
write_file(dest, m)
} else {
write_file(dest, string(data))
}
} }
} else if entry.data != nil { } else if entry.data != nil {
if err := os.write_entire_file(dest, entry.data); err != nil { if err := os.write_entire_file(dest, entry.data); err != nil {
View File
+12 -75
View File
@@ -3,7 +3,6 @@ package main
import md "markdown" import md "markdown"
import ts "treesitter" import ts "treesitter"
import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:log" import "core:log"
import "core:os" import "core:os"
@@ -21,14 +20,12 @@ 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,
params: json.Value,
content: string, content: string,
og: Open_Graph, og: Open_Graph,
draft: bool, draft: bool,
toc: string, starred: bool,
_is_index: bool `private`, _is_index: bool `private`,
} }
@@ -71,9 +68,6 @@ 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)
if site.base_path != "" {
page.content = rewrite_root_urls(page.content, site.base_path, site_allocator(site))
}
} }
build_menus(site) build_menus(site)
@@ -259,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.params = fm.params 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 = "/"
@@ -274,75 +276,10 @@ 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, context.allocator)
} else {
page.content = md.process(body, ext, file_path, context.allocator)
}
if fm.toc {
page.toc = md.generate_toc(page.content, context.allocator)
}
ok = true ok = true
return return
} }
// rewrite_root_urls prefixes root-relative href/src attribute values with
// base_path so links authored in content (e.g. [x](/ideas)) resolve under a
// subpath deployment. Only values beginning with a single '/' are rewritten;
// absolute ("http://…"), protocol-relative ("//cdn…"), fragment ("#x"),
// mailto:, and document-relative ("foo/bar", "./foo") values all fail that
// test and pass through untouched. Assumes cmark's double-quoted output.
rewrite_root_urls :: proc(html, base_path: string, allocator := context.allocator) -> string {
b := strings.builder_make(allocator)
strings.builder_grow(&b, len(html))
i := 0
for i < len(html) {
n := attr_opener_at(html, i) // ` href="` or ` src="`
if n == 0 {
strings.write_byte(&b, html[i])
i += 1
continue
}
strings.write_string(&b, html[i:i + n]) // copy the opener verbatim
i += n
end := strings.index_byte(html[i:], '"')
if end < 0 { // malformed; bail cleanly
strings.write_string(&b, html[i:])
break
}
value := html[i:i + end]
if url_is_root_relative(value) {
strings.write_string(&b, base_path)
}
strings.write_string(&b, value) // closing quote copied next loop
i += end
}
return strings.to_string(b)
}
// url_is_root_relative reports whether value is a site-root path ("/faq/") as
// opposed to protocol-relative ("//cdn") or anything not starting with '/'.
url_is_root_relative :: proc(value: string) -> bool {
return len(value) >= 1 && value[0] == '/' && !strings.has_prefix(value, "//")
}
// attr_opener_at returns the length of a ` href="` / ` src="` attribute opener
// at s[i:], or 0. The leading space anchors the match to a real HTML attribute
// so we don't rewrite the strings inside link text or code blocks.
attr_opener_at :: proc(s: string, i: int) -> int {
if strings.has_prefix(s[i:], " href=\"") do return 7
if strings.has_prefix(s[i:], " src=\"") do return 6
return 0
}
is_content_file :: proc(name: string) -> bool { is_content_file :: proc(name: string) -> bool {
return strings.has_suffix(name, ".md") || strings.has_suffix(name, ".html") return strings.has_suffix(name, ".md") || strings.has_suffix(name, ".html")
} }
+1 -14
View File
@@ -2,17 +2,4 @@ package main
import "core:os" import "core:os"
// Path to the built-in default layouts, resolved at runtime. DEFAULTS_PATH :: #directory + os.Path_Separator_String + "defaults"
//
// The default points at the `defaults` directory next to the source tree (via
// `#directory`), which works for local `odin run`/`odin build` from the repo.
// Packaged builds install `defaults/` to a fixed location and override this
// with `-define:THOR_DEFAULTS_PATH=<path>` (see flake.nix).
//
// TODO: bake these into the binary at compile time via `#load_directory` so the
// binary is self-contained and this runtime path goes away entirely. See
// TODOS.md.
DEFAULTS_PATH :: #config(
THOR_DEFAULTS_PATH,
#directory + os.Path_Separator_String + "defaults",
)
+3 -4
View File
@@ -4,10 +4,9 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{> title}}</title> <title>{{title}}</title>
{{> opengraph}} {{> head}}
{{> styles}} <link rel="stylesheet" href="/css/main.css">
{{> scripts}}
</head> </head>
<body> <body>
+2 -2
View File
@@ -5,8 +5,8 @@
{{&content}} {{&content}}
</header> </header>
<ul> <ul>
{{#pages}} <li><a href="{{permalink | rel_url}}">{{&title}}</a><span>{{#date}}<time {{#pages}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date_iso}}<time
datetime="{{date}}">{{date | format}}</time>{{/date}}</span> datetime="{{date_iso}}">{{date_display}}</time>{{/date_iso}}</span>
</li> </li>
{{/pages}} {{/pages}}
</ul> </ul>
+2 -7
View File
@@ -3,13 +3,8 @@
<main> <main>
<article> <article>
<h1>{{page.title}}</h1> <h1>{{page.title}}</h1>
{{#date}}<time class="subtitle" datetime="{{date}}">{{ date | format}}</time>{{/date}} {{#date_iso}} <time class="subtitle" datetime="{{date_iso}}">{{date_display}}</time>
{{#page.toc}} {{/date_iso}} {{&content}}
<nav class="toc">
{{&page.toc}}
</nav>
{{/page.toc}}
{{&content}}
</article> </article>
</main> </main>
{{/main}} {{/main}}
+2 -2
View File
@@ -1,3 +1,3 @@
<footer> <footer>
<p>&copy; {{now | format "2006"}} {{params.author.name}}</p> <p>&copy; {{now.year}} {{params.author.name}}</p>
</footer> </footer>
+1 -1
View File
@@ -1 +1 @@
{{site.title}}{{^site.title}}Home{{/site.title}} {{site.title}}
+2 -2
View File
@@ -1,9 +1,9 @@
<header> <header>
<nav> <nav>
<ul> <ul>
<li><a href="{{site.base_path}}/">{{> home-link}}</a></li> <li><a href="/">{{> home-link}}</a></li>
{{#menus.main}} {{#menus.main}}
<li><a href="{{url | rel_url}}">{{name}}</a></li> <li><a href="{{url}}">{{name}}</a></li>
{{/menus.main}} {{/menus.main}}
</ul> </ul>
</nav> </nav>
-1
View File
@@ -1 +0,0 @@
{{params.scripts}}
-294
View File
@@ -1,294 +0,0 @@
{{^params.stylesheets}}
<style>
/* Nav */
header nav ul {
list-style: none;
display: flex;
gap: 1rem;
padding: 0;
}
/* Base layout */
body {
margin-left: auto;
margin-right: auto;
}
main {
max-width: 760px;
margin: 0 auto;
}
/* Sidenote layout — activates only when sidenote elements are present */
body:has(.sidenote, .marginnote) {
counter-reset: sidenote-counter;
padding-left: 12.5%;
width: 87.5%;
}
body:has(.sidenote, .marginnote) main {
max-width: none;
width: 60%;
margin: 0;
}
/* Images */
img {
max-width: 100%;
height: auto;
}
/* Superscript (footnote refs) */
sup {
line-height: 0;
}
/* Code blocks */
code,
pre>code {
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 1.0rem;
line-height: 1.42;
-webkit-text-size-adjust: 100%;
}
pre>code {
font-size: 0.9rem;
overflow-x: auto;
display: block;
}
/* Alerts (GitHub-style) */
.alert {
border-left: 4px solid;
border-radius: 0 4px 4px 0;
padding: 0.5rem 1rem;
margin-inline-start: 0;
}
.alert-title {
font-weight: bold;
margin-bottom: 0.25rem;
margin-top: 0.25rem;
}
.alert-note {
border-left-color: #3b82f6;
}
.alert-tip {
border-left-color: #22c55e;
}
.alert-important {
border-left-color: #a855f7;
}
.alert-warning {
border-left-color: #eab308;
}
.alert-caution {
border-left-color: #ef4444;
}
/* Definition lists */
dl,
ol,
ul {
font-size: 1.4rem;
line-height: 2rem;
}
dt:not(:first-child) {
margin-top: 0.25rem;
}
dd {
margin-left: 0;
}
/* Sidenotes, margin notes */
.sidenote,
.marginnote {
float: right;
clear: right;
margin-right: -45%;
width: 40%;
margin-top: 0.3rem;
margin-bottom: 0;
font-size: 0.85em;
line-height: 1.5;
vertical-align: baseline;
position: relative;
}
.sidenote-number {
counter-increment: sidenote-counter;
}
.sidenote-number:after,
.sidenote:before {
position: relative;
vertical-align: baseline;
color: var(--color-accent, currentColor);
}
.sidenote-number:after {
content: counter(sidenote-counter);
font-size: 0.8rem;
top: -0.5rem;
left: 0.1rem;
}
.sidenote:before {
content: counter(sidenote-counter) " ";
font-size: 0.8rem;
top: -0.5rem;
}
blockquote .sidenote,
blockquote .marginnote {
margin-right: -82%;
min-width: 59%;
text-align: left;
}
.marginnote>code,
.sidenote>code {
font-size: 1rem;
}
input.margin-toggle {
display: none;
}
label.sidenote-number {
display: inline-block;
max-height: 2rem;
}
label.margin-toggle:not(.sidenote-number) {
display: none;
}
/* Responsive */
@media (max-width: 1200px) {
body {
padding-left: 1rem;
padding-right: 1rem;
}
body:has(.sidenote, .marginnote) {
padding-left: 8%;
padding-right: 8%;
width: 84%;
}
body:has(.sidenote, .marginnote) main {
width: 100%;
max-width: 760px;
margin: 0 auto;
}
pre>code {
width: 97%;
}
img {
width: 100%;
}
label.margin-toggle:not(.sidenote-number) {
display: inline;
}
label.margin-toggle:not(.sidenote-number)::after {
content: "\2295";
}
.sidenote,
.marginnote {
display: none;
}
.margin-toggle:checked+.sidenote,
.margin-toggle:checked+.marginnote {
display: block;
float: left;
left: 1rem;
clear: both;
width: 95%;
margin: 1rem 2.5%;
vertical-align: baseline;
position: relative;
}
label {
cursor: pointer;
}
}
</style>
{{/params.stylesheets}}
<style>
/* Syntax highlighting. Build-time <span class="hl-*"> wrappers only appear
inside fenced code blocks. The base class (e.g. hl-keyword) is always
emitted alongside any dotted variant (hl-keyword-control), so styling the
base names covers every capture. Re-theme by overriding the --hl-* vars. */
:root {
--hl-bg: #282c34;
--hl-fg: #abb2bf;
--hl-comment: #5c6370;
--hl-keyword: #c678dd;
--hl-string: #98c379;
--hl-escape: #56b6c2;
--hl-number: #d19a66;
--hl-function: #61afef;
--hl-type: #e5c07b;
--hl-variable: #e06c75;
--hl-attribute: #d19a66;
}
pre:has(> code [class^="hl-"]) {
background: var(--hl-bg);
color: var(--hl-fg);
padding: 0.75rem 1rem;
border-radius: 4px;
}
.hl-comment { color: var(--hl-comment); font-style: italic; }
.hl-keyword,
.hl-conditional,
.hl-repeat,
.hl-exception,
.hl-include,
.hl-storageclass { color: var(--hl-keyword); }
.hl-operator,
.hl-punctuation { color: var(--hl-fg); }
.hl-string,
.hl-character { color: var(--hl-string); }
.hl-escape,
.hl-string-escape,
.hl-string-special { color: var(--hl-escape); }
.hl-number,
.hl-float,
.hl-boolean,
.hl-constant { color: var(--hl-number); }
.hl-function,
.hl-method,
.hl-constructor { color: var(--hl-function); }
.hl-type,
.hl-namespace,
.hl-module { color: var(--hl-type); }
.hl-variable,
.hl-property,
.hl-field,
.hl-parameter,
.hl-tag,
.hl-symbol { color: var(--hl-variable); }
.hl-attribute,
.hl-label,
.hl-preproc { color: var(--hl-attribute); }
</style>
{{#params.stylesheets}}<link rel="stylesheet" href="{{.|rel_url}}">
{{/params.stylesheets}}
-1
View File
@@ -1 +0,0 @@
{{#site.title}}{{.}}{{#page.title}} | {{/page.title}}{{/site.title}}{{page.title}}
+2 -2
View File
@@ -7,8 +7,8 @@
<section> <section>
<h2>{{key}}</h2> <h2>{{key}}</h2>
<ul> <ul>
{{#items}} <li><a href="{{permalink | rel_url}}">{{&title}}</a><span>{{#date}}<time {{#items}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date_iso}}<time
datetime="{{date}}">{{date | format}}</time>{{/date}}</span> datetime="{{date_iso}}">{{date_display}}</time>{{/date_iso}}</span>
</li> </li>
{{/items}} {{/items}}
</ul> </ul>
-279
View File
@@ -1,279 +0,0 @@
package diagnostics
import "core:fmt"
import "core:os"
import "core:strings"
import "core:terminal/ansi"
import "core:unicode/utf8"
// suggest_correction returns the closest match from `available` to `missing`
// using Levenshtein distance, or "" if no good match exists. The threshold
// scales with the length of the missing key.
suggest_correction :: proc(available: []string, missing: string) -> string {
if len(available) == 0 || len(missing) == 0 {
return ""
}
threshold := max(2, len(missing) / 3)
best: string
best_dist := threshold + 1
for candidate in available {
if abs(len(candidate) - len(missing)) > threshold {
continue
}
d := strings.levenshtein_distance(missing, candidate)
if d <= threshold && d < best_dist {
best = candidate
best_dist = d
}
}
return best
}
// line_col returns the 1-indexed line and column for a byte offset in source.
line_col :: proc(source: string, pos_in: int) -> (line: int, col: int) {
pos := pos_in
if pos < 0 {
return 1, 1
}
if pos > len(source) {
pos = len(source)
}
line = 1
col = 1
for i := 0; i < pos; i += 1 {
if source[i] == '\n' {
line += 1
col = 1
} else {
col += 1
}
}
return
}
// line_text returns the Nth (1-indexed) line of source, without the trailing
// newline. Returns "" if line is out of range.
line_text :: proc(source: string, line: int) -> string {
if line < 1 {
return ""
}
current := 1
start := 0
for i := 0; i < len(source); i += 1 {
if current == line {
end := i
for end < len(source) && source[end] != '\n' {
end += 1
}
return source[start:end]
}
if source[i] == '\n' {
current += 1
start = i + 1
}
}
if current == line {
return source[start:]
}
return ""
}
// should_colorize returns true if stderr is a TTY and color output is wanted.
should_colorize :: proc() -> bool {
return os.is_tty(os.stderr)
}
// count_lines returns the number of '\n'-separated lines in source.
count_lines :: proc(source: string) -> int {
if len(source) == 0 {
return 1
}
n := 1
for c in source {
if c == '\n' {
n += 1
}
}
if len(source) > 0 && source[len(source) - 1] == '\n' {
n -= 1
}
return n
}
// digit_count returns the number of decimal digits in n (min 1).
digit_count :: proc(n: int) -> int {
if n <= 0 {
return 1
}
c := 0
x := n
for x > 0 {
c += 1
x /= 10
}
return c
}
// display_width returns the number of terminal cells `s` occupies.
display_width :: proc(s: string) -> int {
return utf8.rune_count_in_string(s)
}
// line_start_at returns the byte offset of the start of the line containing pos.
line_start_at :: proc(source: string, pos_in: int) -> int {
pos := pos_in
if pos < 0 {
return 0
}
if pos >= len(source) {
pos = len(source) - 1
}
for pos > 0 && source[pos - 1] != '\n' {
pos -= 1
}
return pos
}
// format_error produces a rust-style multi-line diagnostic string.
//
// <msg>
// --> <path>:<line>:<col>
// |
// N | <source line N-2>
// N | <source line N-1>
// N | <source line N — the error line>
// | ^^^^^^^^^^^ <hint>
// N | <source line N+1>
// N | <source line N+2>
// |
//
// `caret_start`/`caret_end` control the byte range of the caret underline.
// If not provided (or negative), defaults to a single-rune underline at `pos`.
format_error :: proc(
path: string,
source: string,
pos: int,
msg: string,
hint: string = "",
context_before: int = 2,
context_after: int = 2,
colorize: bool = false,
caret_start: int = -1,
caret_end: int = -1,
) -> string {
line, col := line_col(source, pos)
total_lines := count_lines(source)
start_line := line - context_before
if start_line < 1 {
start_line = 1
}
end_line := line + context_after
if end_line > total_lines {
end_line = total_lines
}
width := digit_count(end_line)
if width < 1 {
width = 1
}
cs := caret_start >= 0 ? caret_start : pos
ce := caret_end > cs ? caret_end : cs + 1
ls := line_start_at(source, pos)
caret_start_col := cs - ls + 1
caret_end_col := ce - ls + 1
sb := strings.builder_make(context.temp_allocator)
defer strings.builder_destroy(&sb)
red, faint, reset := "", "", ""
if colorize {
red = ansi.CSI + ansi.FG_RED + ansi.SGR
faint = ansi.CSI + ansi.FAINT + ansi.SGR
reset = ansi.CSI + ansi.RESET + ansi.SGR
}
// Header
strings.write_string(&sb, msg)
strings.write_byte(&sb, '\n')
// Location line
strings.write_string(&sb, faint)
for _ in 0 ..< width {
strings.write_byte(&sb, ' ')
}
strings.write_string(&sb, "--> ")
strings.write_string(&sb, reset)
fmt.sbprintf(&sb, "%s:%d:%d\n", path, line, col)
// Top gutter
write_gutter(&sb, width, faint, reset)
// Context lines
for n in start_line ..= end_line {
num_str := fmt.tprintf("%d", n)
strings.write_string(&sb, faint)
for _ in 0 ..< width - len(num_str) {
strings.write_byte(&sb, ' ')
}
strings.write_string(&sb, num_str)
strings.write_string(&sb, " | ")
strings.write_string(&sb, reset)
strings.write_string(&sb, line_text(source, n))
strings.write_byte(&sb, '\n')
// Caret row after error line
if n == line {
strings.write_string(&sb, faint)
for _ in 0 ..< width + 1 {
strings.write_byte(&sb, ' ')
}
strings.write_string(&sb, "| ")
strings.write_string(&sb, reset)
for _ in 1 ..< caret_start_col {
strings.write_byte(&sb, ' ')
}
if colorize {
strings.write_string(&sb, red)
}
for _ in 0 ..< caret_end_col - caret_start_col {
strings.write_byte(&sb, '^')
}
if colorize {
strings.write_string(&sb, reset)
}
if hint != "" {
strings.write_byte(&sb, ' ')
if colorize {
strings.write_string(&sb, faint)
}
strings.write_string(&sb, hint)
if colorize {
strings.write_string(&sb, reset)
}
}
strings.write_byte(&sb, '\n')
}
}
// Trailing gutter
write_gutter(&sb, width, faint, reset)
return strings.to_string(sb)
}
// write_gutter emits a faint pipe-only gutter line: `<width+1 spaces> |`.
write_gutter :: proc(sb: ^strings.Builder, width: int, faint: string, reset: string) {
strings.write_string(sb, faint)
for _ in 0 ..< width + 1 {
strings.write_byte(sb, ' ')
}
strings.write_string(sb, "|")
strings.write_string(sb, reset)
strings.write_byte(sb, '\n')
}
-111
View File
@@ -1,111 +0,0 @@
#+test
package diagnostics
import "core:fmt"
import "core:testing"
@(test)
test_suggest_correction_exact :: proc(t: ^testing.T) {
available := []string{"title", "page.title", "body"}
testing.expect_value(t, suggest_correction(available, "page_titel"), "page.title")
}
@(test)
test_suggest_correction_close :: proc(t: ^testing.T) {
available := []string{"title", "body", "now"}
testing.expect_value(t, suggest_correction(available, "titel"), "title")
}
@(test)
test_suggest_correction_no_match :: proc(t: ^testing.T) {
available := []string{"completely_different", "unrelated"}
testing.expect_value(t, suggest_correction(available, "page_titel"), "")
}
@(test)
test_suggest_correction_empty :: proc(t: ^testing.T) {
testing.expect_value(t, suggest_correction([]string{}, "anything"), "")
testing.expect_value(t, suggest_correction([]string{"a"}, ""), "")
}
// ---------------------------------------------------------------------------
// line_col / line_text / count_lines / digit_count — primitive helpers
// ---------------------------------------------------------------------------
@(test)
test_line_col_basic :: proc(t: ^testing.T) {
src := "abc\ndef\nghi"
cases := [?]struct {
pos: int,
line: int,
col: int,
}{{0, 1, 1}, {2, 1, 3}, {3, 1, 4}, {4, 2, 1}, {6, 2, 3}, {7, 2, 4}, {8, 3, 1}}
for c in cases {
l, col := line_col(src, c.pos)
testing.expect(t, l == c.line, fmt.tprintf("pos %d: line %d, want %d", c.pos, l, c.line))
testing.expect(t, col == c.col, fmt.tprintf("pos %d: col %d, want %d", c.pos, col, c.col))
}
}
@(test)
test_line_col_empty :: proc(t: ^testing.T) {
l, col := line_col("", 0)
testing.expect_value(t, l, 1)
testing.expect_value(t, col, 1)
}
@(test)
test_line_col_negative :: proc(t: ^testing.T) {
l, col := line_col("abc", -1)
testing.expect_value(t, l, 1)
testing.expect_value(t, col, 1)
}
@(test)
test_line_col_past_end :: proc(t: ^testing.T) {
l, col := line_col("abc", 100)
testing.expect_value(t, l, 1)
testing.expect_value(t, col, 4)
}
@(test)
test_line_text_first :: proc(t: ^testing.T) {
src := "first\nsecond\nthird"
testing.expect_value(t, line_text(src, 1), "first")
testing.expect_value(t, line_text(src, 2), "second")
testing.expect_value(t, line_text(src, 3), "third")
}
@(test)
test_line_text_trailing_newline :: proc(t: ^testing.T) {
src := "first\nsecond\n"
testing.expect_value(t, line_text(src, 1), "first")
testing.expect_value(t, line_text(src, 2), "second")
testing.expect_value(t, line_text(src, 3), "")
}
@(test)
test_line_text_out_of_range :: proc(t: ^testing.T) {
testing.expect_value(t, line_text("abc", 5), "")
testing.expect_value(t, line_text("abc", 0), "")
}
@(test)
test_count_lines :: proc(t: ^testing.T) {
testing.expect_value(t, count_lines(""), 1)
testing.expect_value(t, count_lines("abc"), 1)
testing.expect_value(t, count_lines("a\nb"), 2)
testing.expect_value(t, count_lines("a\nb\n"), 2)
testing.expect_value(t, count_lines("a\nb\nc"), 3)
}
@(test)
test_digit_count :: proc(t: ^testing.T) {
testing.expect_value(t, digit_count(0), 1)
testing.expect_value(t, digit_count(1), 1)
testing.expect_value(t, digit_count(9), 1)
testing.expect_value(t, digit_count(10), 2)
testing.expect_value(t, digit_count(99), 2)
testing.expect_value(t, digit_count(100), 3)
testing.expect_value(t, digit_count(-5), 1)
}
+36 -74
View File
@@ -1,6 +1,5 @@
package main package main
import "base:runtime"
import "core:fmt" import "core:fmt"
import "core:strings" import "core:strings"
import "core:time" import "core:time"
@@ -8,9 +7,10 @@ import "core:time"
generate_rss :: proc(site: ^Site) -> string { generate_rss :: proc(site: ^Site) -> string {
sb := strings.builder_make() sb := strings.builder_make()
fmt.sbprintf( strings.write_string(
&sb, &sb,
`<?xml version="1.0" encoding="utf-8" standalone="yes"?> fmt.aprintf(
`<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel> <channel>
<title>%s</title> <title>%s</title>
@@ -18,10 +18,11 @@ generate_rss :: proc(site: ^Site) -> string {
<description>%s</description> <description>%s</description>
<language>en-us</language> <language>en-us</language>
<atom:link href="%s/index.xml" rel="self" type="application/rss+xml"/>`, <atom:link href="%s/index.xml" rel="self" type="application/rss+xml"/>`,
xml_escape(site.title), xml_escape(site.title),
site.base_url, site.base_url,
xml_escape(site.description), xml_escape(site.description),
site.base_url, site.base_url,
),
) )
for page in site.pages { for page in site.pages {
@@ -34,9 +35,10 @@ generate_rss :: proc(site: ^Site) -> string {
pub_date = format_rfc822(page.date) pub_date = format_rfc822(page.date)
} }
fmt.sbprintf( strings.write_string(
&sb, &sb,
`<item> fmt.aprintf(
`<item>
<title>%s</title> <title>%s</title>
<link>%s</link> <link>%s</link>
<pubDate>%s</pubDate> <pubDate>%s</pubDate>
@@ -44,11 +46,12 @@ generate_rss :: proc(site: ^Site) -> string {
<description>%s</description> <description>%s</description>
</item> </item>
`, `,
xml_escape(page.title), xml_escape(page.title),
page.url, page.url,
pub_date, pub_date,
page.url, page.url,
xml_escape(page.content), xml_escape(page.content),
),
) )
} }
@@ -67,11 +70,11 @@ generate_sitemap :: proc(site: ^Site) -> string {
) )
for page in site.pages { for page in site.pages {
fmt.sbprintf(&sb, "<url><loc>%s</loc>", page.url) lastmod := ""
if page.date != "" { if page.date != "" {
fmt.sbprintf(&sb, "<lastmod>%s</lastmod>", page.date) lastmod = fmt.aprintf("<lastmod>%s</lastmod>", page.date)
} }
fmt.sbprintf(&sb, "</url>\n") strings.write_string(&sb, fmt.aprintf("<url><loc>%s</loc>%s</url>\n", page.url, lastmod))
} }
// Section index pages (for sections without an index in content) // Section index pages (for sections without an index in content)
@@ -100,18 +103,22 @@ generate_sitemap :: proc(site: ^Site) -> string {
section_lastmod = page.date section_lastmod = page.date
} }
} }
fmt.sbprintf(&sb, "<url><loc>%s/%s/</loc>", site.base_url, section) lm := ""
if section_lastmod != "" { if section_lastmod != "" {
fmt.sbprintf(&sb, "<lastmod>%s</lastmod>", section_lastmod) lm = fmt.aprintf("<lastmod>%s</lastmod>", section_lastmod)
} }
fmt.sbprintf(&sb, "</url>\n") strings.write_string(
&sb,
fmt.aprintf("<url><loc>%s/%s/</loc>%s</url>\n", site.base_url, section, lm),
)
} }
strings.write_string(&sb, "</urlset>") strings.write_string(&sb, "</urlset>")
return strings.to_string(sb) return strings.to_string(sb)
} }
format_rfc822 :: proc(iso: string, allocator := context.temp_allocator) -> string { // TODO: Leaks
format_rfc822 :: proc(iso: string) -> string {
if len(iso) < 19 { if len(iso) < 19 {
// TODO: should indicate error somehow // TODO: should indicate error somehow
return iso return iso
@@ -124,66 +131,21 @@ format_rfc822 :: proc(iso: string, allocator := context.temp_allocator) -> strin
buf: [8]byte buf: [8]byte
t := time.to_string_hms(date, buf[:]) t := time.to_string_hms(date, buf[:])
// RFC-822 zone: sign followed by two-digit hours and minutes (e.g. +0000,
// +0530, -0800). offset is in minutes.
sign := '+'
off := offset
if off < 0 {
sign = '-'
off = -off
}
return fmt.aprintf( return fmt.aprintf(
"%s, %02d %s %d %s %c%02d%02d", "%s, %02d %s %d %s %3d%2d",
weekday[:3], weekday[:3],
time.day(date), time.day(date),
month[:3], month[:3],
time.year(date), time.year(date),
t, t,
sign, offset / 60,
off / 60, offset % 60,
off % 60,
allocator = allocator,
) )
} }
// xml_escape escapes the XML metacharacters '&', '<' and '>' in s. Only these xml_escape :: proc(s: string) -> string {
// ASCII characters are touched; all other bytes (including UTF-8 multibyte r, _ := strings.replace_all(s, "&", "&amp;")
// sequences) pass through verbatim, so s must already be valid UTF-8. r, _ = strings.replace_all(r, "<", "&lt;")
// r, _ = strings.replace_all(r, ">", "&gt;")
// The result is allocated in `allocator`, which defaults to return r
// context.temp_allocator — the returned string is only valid until the next
// temp allocator reset, so callers must not retain it across frames.
xml_escape :: proc(
s: string,
allocator := context.temp_allocator,
) -> (
escaped: string,
err: runtime.Allocator_Error,
) #optional_allocator_error {
sb := strings.builder_make_len_cap(0, len(s), allocator) or_return
// Single pass over the bytes, copying runs of ordinary text in bulk and
// emitting an entity for each metacharacter. The switch is both the
// membership test and the replacement lookup, so no per-match scan.
start := 0
for i := 0; i < len(s); i += 1 {
esc: string
switch s[i] {
case '&':
esc = "&amp;"
case '<':
esc = "&lt;"
case '>':
esc = "&gt;"
case:
continue // ordinary byte: copied as part of the next run
}
strings.write_string(&sb, s[start:i]) // run before the metacharacter
strings.write_string(&sb, esc)
start = i + 1
}
strings.write_string(&sb, s[start:]) // tail
return strings.to_string(sb), nil
} }
-166
View File
@@ -1,166 +0,0 @@
#+test
package main
import "core:strings"
import "core:testing"
// ---------------------------------------------------------------------------
// xml_escape
// ---------------------------------------------------------------------------
@(test)
test_xml_escape_passthrough :: proc(t: ^testing.T) {
testing.expect_value(t, xml_escape(""), "")
testing.expect_value(t, xml_escape("hello world"), "hello world")
// UTF-8 multibyte bytes are not metacharacters and pass through untouched.
testing.expect_value(t, xml_escape("héllo — 世界"), "héllo — 世界")
}
@(test)
test_xml_escape_entities :: proc(t: ^testing.T) {
testing.expect_value(t, xml_escape("&"), "&amp;")
testing.expect_value(t, xml_escape("<"), "&lt;")
testing.expect_value(t, xml_escape(">"), "&gt;")
// Adjacent specials, and specials at the very start/end of the string.
testing.expect_value(t, xml_escape("&<>"), "&amp;&lt;&gt;")
testing.expect_value(t, xml_escape("<a>"), "&lt;a&gt;")
}
@(test)
test_xml_escape_mixed :: proc(t: ^testing.T) {
testing.expect_value(t, xml_escape("a<b>c&d"), "a&lt;b&gt;c&amp;d")
testing.expect_value(
t,
xml_escape("<p>Tom & Jerry</p>"),
"&lt;p&gt;Tom &amp; Jerry&lt;/p&gt;",
)
}
// ---------------------------------------------------------------------------
// format_rfc822
// ---------------------------------------------------------------------------
@(test)
test_format_rfc822_short_passthrough :: proc(t: ^testing.T) {
// Inputs shorter than a full ISO-8601 timestamp are returned unchanged
// (and are the input slice itself, so they must not be deleted).
testing.expect_value(t, format_rfc822("2024"), "2024")
testing.expect_value(t, format_rfc822(""), "")
}
@(test)
test_format_rfc822_full_date :: proc(t: ^testing.T) {
// Results are temp-allocated (reset by the test runner), so no delete.
// 2024-01-15 is a Monday; wall-clock time is preserved and the zone is
// rendered as an RFC-822 ±HHMM offset.
testing.expect_value(
t,
format_rfc822("2024-01-15T10:30:00Z"),
"Mon, 15 Jan 2024 10:30:00 +0000",
)
testing.expect_value(
t,
format_rfc822("2024-01-15T10:30:00+05:30"),
"Mon, 15 Jan 2024 10:30:00 +0530",
)
testing.expect_value(
t,
format_rfc822("2024-01-15T10:30:00-08:00"),
"Mon, 15 Jan 2024 10:30:00 -0800",
)
}
// ---------------------------------------------------------------------------
// generate_rss
// ---------------------------------------------------------------------------
@(test)
test_generate_rss_structure :: proc(t: ^testing.T) {
site: Site
site.title = "My & Site"
site.description = "Desc <here>"
site.base_url = "https://example.com"
site.pages = make(#soa[dynamic]Page)
defer delete_soa(site.pages)
// A real content page.
append(
&site.pages,
Page {
section = "posts",
title = "First <Post>",
url = "https://example.com/posts/first/",
content = "<p>Hello & welcome</p>",
},
)
// The home index page (section == "" && _is_index) must be skipped.
append(
&site.pages,
Page {
section = "",
_is_index = true,
title = "HOME_SHOULD_BE_SKIPPED",
url = "https://example.com/",
content = "home",
},
)
// A second content page.
append(
&site.pages,
Page {
section = "posts",
title = "Second Post",
url = "https://example.com/posts/second/",
content = "plain body",
},
)
rss := generate_rss(&site)
defer delete(rss)
// Channel header: site title/description are XML-escaped, link gets a
// trailing slash, and the atom self-link points at index.xml.
testing.expect(t, strings.contains(rss, "<title>My &amp; Site</title>"), rss)
testing.expect(
t,
strings.contains(rss, "<description>Desc &lt;here&gt;</description>"),
rss,
)
testing.expect(t, strings.contains(rss, "<link>https://example.com/</link>"), rss)
testing.expect(
t,
strings.contains(rss, `<atom:link href="https://example.com/index.xml"`),
rss,
)
// Exactly two <item>s — the home index page is skipped.
testing.expect_value(t, strings.count(rss, "<item>"), 2)
testing.expect(t, !strings.contains(rss, "HOME_SHOULD_BE_SKIPPED"), rss)
// First item: title and body are XML-escaped, link is verbatim.
testing.expect(t, strings.contains(rss, "<title>First &lt;Post&gt;</title>"), rss)
testing.expect(
t,
strings.contains(
rss,
"<description>&lt;p&gt;Hello &amp; welcome&lt;/p&gt;</description>",
),
rss,
)
testing.expect(
t,
strings.contains(rss, "<link>https://example.com/posts/first/</link>"),
rss,
)
// Undated pages get the sentinel pubDate (no date formatting/allocation).
testing.expect(
t,
strings.contains(rss, "<pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate>"),
rss,
)
// Well-formed at the boundaries.
testing.expect(t, strings.has_prefix(rss, `<?xml version="1.0"`), rss)
testing.expect(t, strings.has_suffix(rss, "</channel>\n</rss>"), rss)
}
+3 -30
View File
@@ -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
]; ];
@@ -141,20 +129,13 @@
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild
odin build . -o:speed -no-bounds-check \ odin build . -o:speed -no-bounds-check -out:${pname}-keep
-define:THOR_DEFAULTS_PATH=$out/share/thor/defaults \
-out:${pname}-keep
runHook postBuild runHook postBuild
''; '';
installPhase = '' installPhase = ''
runHook preInstall runHook preInstall
install -Dm755 ${pname}-keep $out/bin/${pname} install -Dm755 ${pname}-keep $out/bin/${pname}
# Ship the built-in default layouts alongside the binary. The
# binary reads these at runtime from THOR_DEFAULTS_PATH (baked in
# above). Keep in sync with defaults.odin.
mkdir -p $out/share/thor
cp -r defaults $out/share/thor/defaults
runHook postInstall runHook postInstall
''; '';
}; };
@@ -165,17 +146,9 @@
tree-sitter tree-sitter
gdb gdb
perf
bloaty # du for binaries?
# temp
hugo
pandoc
# IDE # IDE
unstable.helix unstable.helix
claude-code
typescript-language-server typescript-language-server
vscode-langservers-extracted vscode-langservers-extracted
]); ]);
+5 -26
View File
@@ -10,13 +10,11 @@ Frontmatter :: struct {
date: string, date: string,
lastmod: string, lastmod: string,
publishDate: string, publishDate: string,
weight: Maybe(int), menu: string,
menus: json.Value,
params: json.Value,
layout: string, layout: string,
og: Open_Graph, og: Open_Graph,
draft: bool, draft: bool,
toc: bool, isStarred: bool,
} }
// parse_frontmatter splits raw file content into a Frontmatter struct and the // parse_frontmatter splits raw file content into a Frontmatter struct and the
@@ -38,7 +36,7 @@ parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok
json_str := content[:end + 1] json_str := content[:end + 1]
body = strings.trim_left(content[end + 1:], " \t\r\n") body = strings.trim_left(content[end + 1:], " \t\r\n")
value, err := json.parse_string(json_str, spec = .JSON5) value, err := json.parse_string(json_str, spec = .JSON)
if err != nil { if err != nil {
log.errorf("failed to parse frontmatter JSON: %v", err) log.errorf("failed to parse frontmatter JSON: %v", err)
return return
@@ -55,15 +53,11 @@ 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.toc = json_get_bool(obj, "toc") 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")
if v, ok := obj["params"]; ok {fm.params = v}
ok = true ok = true
return return
@@ -87,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 {
@@ -119,4 +99,3 @@ json_get_open_graph :: proc(obj: json.Object, key: string) -> Open_Graph {
} }
return og return og
} }
+27
View File
@@ -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)
}
+3 -27
View File
@@ -1,9 +1,7 @@
package main package main
import "base:runtime" import "base:runtime"
import "core:flags"
import "core:log" import "core:log"
import "core:mem"
import "core:os" import "core:os"
import "core:prof/spall" import "core:prof/spall"
import "core:sync" import "core:sync"
@@ -45,21 +43,9 @@ main :: proc() {
defer spall.buffer_destroy(&spall_ctx, &spall_buffer) defer spall.buffer_destroy(&spall_ctx, &spall_buffer)
} }
cli_flags: Flags
flags.parse_or_exit(&cli_flags, os.args, .Odin)
level: log.Level
switch {
case cli_flags.quiet:
level = .Warning
case cli_flags.verbose:
level = .Debug
case:
level = .Info
}
logger_opts: log.Options = logger_opts: log.Options =
(log.Default_Console_Logger_Opts - log.Full_Timestamp_Opts - {.Short_File_Path}) (log.Default_Console_Logger_Opts - log.Full_Timestamp_Opts - {.Short_File_Path})
console_logger := log.create_console_logger(level, logger_opts) console_logger := log.create_console_logger(.Info, logger_opts)
context.logger = console_logger context.logger = console_logger
defer log.destroy_console_logger(console_logger) defer log.destroy_console_logger(console_logger)
@@ -71,21 +57,12 @@ main :: proc() {
for { for {
defer free_all(context.temp_allocator) defer free_all(context.temp_allocator)
defer log.debugf(
"max_temp_allocator_size=%M",
(cast(^runtime.Default_Temp_Allocator)context.temp_allocator.data)^.arena.total_used,
)
tick := time.tick_now() tick := time.tick_now()
site: Site site: Site
init_site(&site, cli_flags) init_site(&site, os.args)
defer destroy_site(&site) defer destroy_site(&site)
// TODO: Make it so this isn't necessary // TODO: Make it so this isn't necessary
context.allocator = site_allocator(&site) context.allocator = site_allocator(&site)
defer log.debugf(
"current_site_allocator_size=%M",
site.arena.block_size * (len(site.arena.used_blocks) + len(site.arena.unused_blocks)) -
site.arena.bytes_left,
)
build_vfs(&site) build_vfs(&site)
treesitter.grammar_dir = site.grammars treesitter.grammar_dir = site.grammars
@@ -93,7 +70,7 @@ main :: proc() {
site_load_content(&site) site_load_content(&site)
render_site(&site) render_site(&site)
log.infof("Built site in %M", time.tick_since(tick)) log.infof("Built site in %s", time.tick_since(tick))
(.Watch in site.features) or_break (.Watch in site.features) or_break
time.sleep(5 * time.Second) time.sleep(5 * time.Second)
@@ -117,4 +94,3 @@ when SPALL {
spall._buffer_end(&spall_ctx, &spall_buffer) spall._buffer_end(&spall_ctx, &spall_buffer)
} }
} }
-324
View File
@@ -1,324 +0,0 @@
package markdown
import cm "vendor:commonmark"
import "core:strings"
// convert_deflists scans markdown text for definition list patterns and
// converts them to <dl><dt><dd> HTML blocks before cmark processing.
//
// A definition line starts with fewer than four columns of leading whitespace
// followed by a colon and a space (or end-of-line). The term is the nearest
// preceding non-blank line (immediately or within one blank line). Consecutive
// term+definition pairs are grouped into a single <dl> block, and consecutive
// definition lines under one term become multiple <dd> elements.
//
// The scanner walks the body one line at a time using byte offsets: it never
// splits the input, allocates nothing per line, and copies non-deflist text
// through verbatim. Matching is disabled inside fenced code blocks. Terms and
// definitions are rendered through cmark individually so that inline markdown
// (code, links, emphasis) is processed.
// Line_Kind classifies a line for convert_deflists's group-finding scan. Other
// covers fenced-code delimiters and their contents, where definition syntax is
// inert.
Line_Kind :: enum {
None,
Blank,
Term,
Def,
Other,
}
convert_deflists :: proc(body: string, allocator := context.allocator) -> string {
// Fast path: a body with no definition line at all cannot contain a
// definition list, so return it untouched — skipping the builder allocation
// and the classifying scan below. This is the common case (most pages have
// no definition lists), and the guard scan is leaner than the full loop.
if !has_def_line(body) {
return body
}
sb := strings.builder_make(context.temp_allocator)
// emitted_up_to is the byte offset up to which `body` has been copied into
// `sb`. It stays at 0 until the first deflist is emitted; if it is still 0
// at the end, nothing matched and we can return the body unchanged.
emitted_up_to := 0
in_fence := false
fence_marker: u8 = 0
// The scan is definition-driven: it walks line by line, remembering the two
// previous lines' kinds and offsets, and only does group-finding work when it
// reaches a (rare) definition line — whose term is the immediately preceding
// line, or the one before a single blank line. This avoids a per-line forward
// lookahead: each line is classified once, with no rescanning.
prev1_kind, prev2_kind := Line_Kind.None, Line_Kind.None
prev1_pos, prev2_pos := 0, 0
pos := 0
for {
line, next, ok := line_at(body, pos)
if !ok {
break
}
// Classify the line. Definition-list syntax is inert inside fenced code
// blocks, so fence delimiters and their contents are classified Other.
kind: Line_Kind
if in_fence {
if is_fence_line(line, fence_marker) {
in_fence = false
}
kind = .Other
} else if marker, opened := fence_open(line); opened {
in_fence = true
fence_marker = marker
kind = .Other
} else if is_def_line(line) {
kind = .Def
} else if is_blank_line(line) {
kind = .Blank
} else {
kind = .Term
}
// A definition-list group begins at the term preceding this def line
// (immediately, or across a single blank line). Flush pending passthrough
// text verbatim, then render the whole group directly into the builder.
if kind == .Def {
term_pos := -1
if prev1_kind == .Term {
term_pos = prev1_pos
} else if prev1_kind == .Blank && prev2_kind == .Term {
term_pos = prev2_pos
}
if term_pos >= 0 {
strings.write_string(&sb, body[emitted_up_to:term_pos])
group_end := write_deflist_group(&sb, body, term_pos)
// write_deflist_group emits a raw <dl> HTML block with no
// trailing newline, so the blank line the author left after the
// list is consumed as the </dl> line's terminator rather than a
// separating blank line. CommonMark (type-6 HTML block) then
// swallows the following block — e.g. a fenced code example —
// as raw, unescaped HTML, leaking its markup (a template's
// <h1>) into the document. Re-emit a newline so </dl> stands on
// its own line and the author's blank line still separates the
// block. Only when a blank line actually follows: fused
// non-blank fall-through content is left as-is.
if line, _, ok := line_at(body, group_end); ok && is_blank_line(line) {
strings.write_byte(&sb, '\n')
}
emitted_up_to = group_end
pos = group_end
prev1_kind, prev2_kind = .None, .None
in_fence = false
continue
}
}
prev2_kind, prev2_pos = prev1_kind, prev1_pos
prev1_kind, prev1_pos = kind, pos
pos = next
}
if emitted_up_to == 0 {
// No definition list anywhere; the body passes through unchanged.
return body
}
strings.write_string(&sb, body[emitted_up_to:])
return strings.clone(strings.to_string(sb), allocator)
}
// line_at returns the line beginning at byte offset `pos` (excluding its
// trailing newline) along with the offset of the following line. `pos` must be
// 0 or one past a '\n'. ok is false once `pos` reaches the end of `body`.
line_at :: proc(body: string, pos: int) -> (line: string, next: int, ok: bool) {
if pos >= len(body) {
return "", pos, false
}
nl := strings.index_byte(body[pos:], '\n')
if nl < 0 {
return body[pos:], len(body), true
}
return body[pos:pos + nl], pos + nl + 1, true
}
// has_def_line reports whether any line in `body` is a definition line (see
// is_def_line). It is a cheap guard for convert_deflists: a body with no
// definition line cannot contain a definition list. There are no false
// negatives (every definition list requires a definition line); a false
// positive — e.g. a colon line that only appears inside a code fence — simply
// falls through to the full scan, which then finds no match and returns `body`.
has_def_line :: proc(body: string) -> bool {
pos := 0
for {
line, next := line_at(body, pos) or_break
if is_def_line(line) {
return true
}
pos = next
}
return false
}
// write_deflist_group renders the definition-list group beginning at `start`
// directly into `sb`, wrapped in <dl>...</dl>, and returns the byte offset one
// past the group (the start of the first line not consumed). The caller must
// have confirmed via term_has_def that a group starts at `start`, so the group
// always contains at least one term+definition pair. A trailing blank line that
// separates the group from following content is left unconsumed.
write_deflist_group :: proc(sb: ^strings.Builder, body: string, start: int) -> (end: int) {
strings.write_string(sb, "<dl>")
pos := start
for {
term_line, term_next, term_ok := line_at(body, pos)
if !term_ok || is_blank_line(term_line) || is_def_line(term_line) {
break
}
// The definition follows immediately or across a single blank line.
def_pos := term_next
def_line, def_next, def_ok := line_at(body, def_pos)
if def_ok && is_blank_line(def_line) {
def_pos = def_next
def_line, def_next, def_ok = line_at(body, def_pos)
}
if !def_ok || !is_def_line(def_line) {
break
}
// Emit the term with its first definition, then any consecutive
// definition lines as additional <dd>s under the same term.
strings.write_string(sb, "<dt>")
strings.write_string(sb, render_inline_md(strings.trim_space(term_line)))
strings.write_string(sb, "</dt><dd>")
strings.write_string(sb, render_inline_md(def_content(def_line)))
strings.write_string(sb, "</dd>")
pos = def_next
for {
cont, cont_next, cont_ok := line_at(body, pos)
if !cont_ok || !is_def_line(cont) {
break
}
strings.write_string(sb, "<dd>")
strings.write_string(sb, render_inline_md(def_content(cont)))
strings.write_string(sb, "</dd>")
pos = cont_next
}
// Continue the group if another term+definition pair follows,
// optionally separated by one blank line. Otherwise stop here without
// consuming a trailing blank line.
blank_line, after_blank, blank_ok := line_at(body, pos)
if blank_ok && is_blank_line(blank_line) {
if term_has_def(body, after_blank) {
pos = after_blank
continue
}
break
}
// No blank line: loop and let the top re-test this line as a term.
}
strings.write_string(sb, "</dl>")
return pos
}
// term_has_def reports whether the line at `pos` is a term that is immediately
// (or across one blank line) followed by a definition line.
term_has_def :: proc(body: string, pos: int) -> bool {
line, next, ok := line_at(body, pos)
if !ok || is_blank_line(line) || is_def_line(line) {
return false
}
def_pos := next
def_line, def_next, def_ok := line_at(body, def_pos)
if def_ok && is_blank_line(def_line) {
def_pos = def_next
def_line, def_next, def_ok = line_at(body, def_pos)
}
return def_ok && is_def_line(def_line)
}
// fence_open reports whether `line` opens a fenced code block and, if so, which
// marker character it uses. Leading spaces are ignored.
fence_open :: proc(line: string) -> (marker: u8, ok: bool) {
trimmed := strings.trim_left(line, " ")
if strings.has_prefix(trimmed, "```") {
return '`', true
}
if strings.has_prefix(trimmed, "~~~") {
return '~', true
}
return 0, false
}
// is_fence_line reports whether `line` is a fence using the given marker
// character (used to detect the closing fence of an open block).
is_fence_line :: proc(line: string, marker: u8) -> bool {
trimmed := strings.trim_left(line, " ")
if marker == '~' {
return strings.has_prefix(trimmed, "~~~")
}
return strings.has_prefix(trimmed, "```")
}
// is_def_line returns true if the line is a definition line: fewer than four
// columns of leading whitespace, a colon, then whitespace or end-of-line. Four
// or more columns of indentation is a code block, not a definition.
is_def_line :: proc(line: string) -> bool {
indent := 0
n := 0
for n < len(line) && (line[n] == ' ' || line[n] == '\t') {
indent += line[n] == '\t' ? 4 : 1
n += 1
}
if indent >= 4 {
return false
}
rest := line[n:]
if len(rest) < 1 || rest[0] != ':' {
return false
}
if len(rest) == 1 {
return true
}
return rest[1] == ' ' || rest[1] == '\t'
}
// def_content extracts the definition text from a definition line,
// stripping the leading colon and surrounding whitespace.
def_content :: proc(line: string) -> string {
trimmed := strings.trim_left(line, " \t")
content := trimmed[1:]
content = strings.trim_left(content, " \t")
return content
}
// is_blank_line returns true for empty or whitespace-only lines.
is_blank_line :: proc(line: string) -> bool {
return strings.trim_space(line) == ""
}
// render_inline_md renders a snippet of markdown through cmark and strips
// the surrounding <p> tags. Result lives in context.temp_allocator.
render_inline_md :: proc(text: string) -> string {
raw := cm.markdown_to_html_from_string(text, {.Unsafe})
defer cm.free_string(raw)
return strings.clone(strip_p_tags(raw), context.temp_allocator)
}
// strip_p_tags removes surrounding <p></p> if the HTML is a single paragraph.
strip_p_tags :: proc(html: string) -> string {
s := html
if len(s) > 0 && s[len(s) - 1] == '\n' {
s = s[:len(s) - 1]
}
if strings.has_prefix(s, "<p>") && strings.has_suffix(s, "</p>") {
return s[3:len(s) - 4]
}
return s
}
-92
View File
@@ -1,92 +0,0 @@
#+test
package markdown
import "core:testing"
// Fixture-driven tests for convert_deflists. Every fixture is exact-matched
// against its oracle in testdata/deflists/expected/. See
// testdata/deflists/reference/README.md for how each expected file was decided
// (cross-checked against Pandoc and Hugo/Goldmark).
//
// The fixture inputs are the definition-list examples from
// https://www.markdownlang.com/extended/definition-lists.html
//
// Provenance of each expected file:
// 01,02,08,09 Pandoc and Hugo agree; oracle = that agreed HTML.
// 06 Both agree it is NOT a deflist; convert_deflists passes the
// markdown through unchanged, so expected = the raw input.
// 07 CommonMark's <4-space marker rule: we accept the 2-space colon
// (Pandoc does; Hugo does not).
// 05 Block nesting is unsupported; our flatten happens to match
// Pandoc's output exactly.
// 03,04 Block content in definitions is unsupported; expected captures
// our current degraded behavior (a <dl> for the term/first line,
// the rest falling through as markdown) as a regression guard.
Deflist_Fixture :: struct {
name: string,
input: string,
expected: string,
}
@(test)
test_deflist_fixtures :: proc(t: ^testing.T) {
cases := []Deflist_Fixture {
{
"01-basic",
#load(#directory + "testdata/deflists/01-basic.md", string),
#load(#directory + "testdata/deflists/expected/01-basic.html", string),
},
{
"02-multiple-defs",
#load(#directory + "testdata/deflists/02-multiple-defs.md", string),
#load(#directory + "testdata/deflists/expected/02-multiple-defs.html", string),
},
{
"03-multiline-def",
#load(#directory + "testdata/deflists/03-multiline-def.md", string),
#load(#directory + "testdata/deflists/expected/03-multiline-def.html", string),
},
{
"04-markdown-in-def",
#load(#directory + "testdata/deflists/04-markdown-in-def.md", string),
#load(#directory + "testdata/deflists/expected/04-markdown-in-def.html", string),
},
{
"05-nested",
#load(#directory + "testdata/deflists/05-nested.md", string),
#load(#directory + "testdata/deflists/expected/05-nested.html", string),
},
{
"06-compact",
#load(#directory + "testdata/deflists/06-compact.md", string),
#load(#directory + "testdata/deflists/expected/06-compact.html", string),
},
{
"07-extra-space",
#load(#directory + "testdata/deflists/07-extra-space.md", string),
#load(#directory + "testdata/deflists/expected/07-extra-space.html", string),
},
{
"08-glossary",
#load(#directory + "testdata/deflists/08-glossary.md", string),
#load(#directory + "testdata/deflists/expected/08-glossary.html", string),
},
{
"09-api-params",
#load(#directory + "testdata/deflists/09-api-params.md", string),
#load(#directory + "testdata/deflists/expected/09-api-params.html", string),
},
}
for c in cases {
result := convert_deflists(c.input, context.temp_allocator)
testing.expectf(
t,
result == c.expected,
"[%s]\n expected: %q\n got: %q",
c.name,
c.expected,
result,
)
}
}
-115
View File
@@ -1,115 +0,0 @@
#+test
package markdown
import "core:strings"
import "core:testing"
@(test)
test_single_entry :: proc(t: ^testing.T) {
input := "term\n\n: definition"
result := convert_deflists(input, context.temp_allocator)
expected := "<dl><dt>term</dt><dd>definition</dd></dl>"
testing.expect_value(t, result, expected)
}
@(test)
test_single_entry_no_blank :: proc(t: ^testing.T) {
input := "term\n: definition"
result := convert_deflists(input, context.temp_allocator)
expected := "<dl><dt>term</dt><dd>definition</dd></dl>"
testing.expect_value(t, result, expected)
}
@(test)
test_indented_variant :: proc(t: ^testing.T) {
input := " term\n : definition"
result := convert_deflists(input, context.temp_allocator)
expected := "<dl><dt>term</dt><dd>definition</dd></dl>"
testing.expect_value(t, result, expected)
}
@(test)
test_multiple_entries :: proc(t: ^testing.T) {
input := "t1\n\n: d1\n\nt2\n\n: d2"
result := convert_deflists(input, context.temp_allocator)
expected := "<dl><dt>t1</dt><dd>d1</dd><dt>t2</dt><dd>d2</dd></dl>"
testing.expect_value(t, result, expected)
}
@(test)
test_mixed_indented_and_non_indented :: proc(t: ^testing.T) {
input := "t1\n\n: d1\n\n t2\n : d2\n\nt3\n\n: d3"
result := convert_deflists(input, context.temp_allocator)
expected := "<dl><dt>t1</dt><dd>d1</dd><dt>t2</dt><dd>d2</dd><dt>t3</dt><dd>d3</dd></dl>"
testing.expect_value(t, result, expected)
}
@(test)
test_inline_markdown_in_term :: proc(t: ^testing.T) {
input := "`code`\n\n: def"
result := convert_deflists(input, context.temp_allocator)
expected := "<dl><dt><code>code</code></dt><dd>def</dd></dl>"
testing.expect_value(t, result, expected)
}
@(test)
test_inline_markdown_in_definition :: proc(t: ^testing.T) {
input := "term\n\n: see [Content](#content) here"
result := convert_deflists(input, context.temp_allocator)
testing.expect(t, strings.contains(result, `<a href="#content">Content</a>`))
testing.expect(t, strings.contains(result, "<dd>see "))
testing.expect(t, strings.contains(result, "</dd>"))
}
@(test)
test_regular_text_passes_through :: proc(t: ^testing.T) {
input := "This is: not a deflist"
result := convert_deflists(input, context.temp_allocator)
testing.expect_value(t, result, input)
}
@(test)
test_colon_inside_paragraph_no_false_positive :: proc(t: ^testing.T) {
input := "First paragraph.\n\nSecond paragraph."
result := convert_deflists(input, context.temp_allocator)
testing.expect_value(t, result, input)
}
@(test)
test_deflist_between_paragraphs :: proc(t: ^testing.T) {
input := "Before.\n\nterm\n\n: def\n\nAfter."
result := convert_deflists(input, context.temp_allocator)
testing.expect(t, strings.has_prefix(result, "Before."))
testing.expect(t, strings.contains(result, "<dl><dt>term</dt><dd>def</dd></dl>"))
testing.expect(t, strings.has_suffix(result, "After."))
}
@(test)
test_empty_body :: proc(t: ^testing.T) {
result := convert_deflists("", context.temp_allocator)
testing.expect_value(t, result, "")
}
// Regression: a fenced code block following a definition list must stay a
// separate block. Without a blank line after the emitted </dl>, CommonMark
// treats the fence as raw HTML continuation of the <dl> block and passes its
// contents through unescaped (leaking e.g. an example <h1> into the document).
@(test)
test_deflist_before_code_fence :: proc(t: ^testing.T) {
input := "term\n\n: def\n\n```\n<h1>example</h1>\n```"
result := convert_deflists(input, context.temp_allocator)
testing.expect(t, strings.contains(result, "</dl>\n\n```"))
}
@(test)
test_docs_md_pattern :: proc(t: ^testing.T) {
input := "content\n\n: `content` holds your pages.\n\n assets\n : `assets` contains files."
result := convert_deflists(input, context.temp_allocator)
testing.expect(t, strings.contains(result, "<dl>"))
testing.expect(t, strings.contains(result, "<dt>content</dt>"))
testing.expect(t, strings.contains(result, "<dt>assets</dt>"))
testing.expect(t, strings.contains(result, "<code>content</code>"))
testing.expect(t, strings.contains(result, "<code>assets</code>"))
testing.expect(t, strings.contains(result, "</dl>"))
}
-1
View File
@@ -30,4 +30,3 @@ test_emoji_skips_invalid_shortcodes :: proc(t: ^testing.T) {
testing.expect_value(t, expand_emoji(":Smile:"), ":Smile:") testing.expect_value(t, expand_emoji(":Smile:"), ":Smile:")
testing.expect_value(t, expand_emoji(": not real :"), ": not real :") testing.expect_value(t, expand_emoji(": not real :"), ": not real :")
} }
+15 -97
View File
@@ -1,5 +1,7 @@
package markdown package markdown
import cm "vendor:commonmark"
import "core:fmt" import "core:fmt"
import "core:strings" import "core:strings"
@@ -169,27 +171,27 @@ inject_notes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> strin
} }
// Render definition through cmark for markdown support // Render definition through cmark for markdown support
def_html := render_inline_md(def_text) def_html := cm.markdown_to_html_from_string(def_text, {.Unsafe})
def_html = strip_p_tags(def_html)
note: string note: string
defer delete(note) defer delete(note)
if is_margin { if is_margin {
fmt.sbprintf( note = fmt.aprintf(
&parts,
`<label for="mn-%s" class="margin-toggle"></label><input type="checkbox" id="mn-%s" class="margin-toggle"><span class="marginnote">%s</span>`, `<label for="mn-%s" class="margin-toggle"></label><input type="checkbox" id="mn-%s" class="margin-toggle"><span class="marginnote">%s</span>`,
id, id,
id, id,
def_html, def_html,
) )
} else { } else {
fmt.sbprintf( note = fmt.aprintf(
&parts,
`<label for="fn-%s" class="margin-toggle sidenote-number"></label><input type="checkbox" id="fn-%s" class="margin-toggle"><span class="sidenote">%s</span>`, `<label for="fn-%s" class="margin-toggle sidenote-number"></label><input type="checkbox" id="fn-%s" class="margin-toggle"><span class="sidenote">%s</span>`,
id, id,
id, id,
def_html, def_html,
) )
} }
strings.write_string(&parts, note)
remaining = remaining[ref_end:] remaining = remaining[ref_end:]
} }
@@ -197,98 +199,14 @@ inject_notes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> strin
return strings.to_string(parts) return strings.to_string(parts)
} }
// inject_footnotes finds [^id] and [*id] references in rendered HTML, numbers // strip_p_tags removes surrounding <p></p> if the HTML is a single paragraph.
// them sequentially by order of appearance, and replaces them with <sup> links. strip_p_tags :: proc(html: string) -> string {
// Appends a <section class="footnotes"><ol> at the end with definitions. s := html
// Both sidenote ([^id]) and marginnote ([*id]) references are treated equally. if len(s) > 0 && s[len(s) - 1] == '\n' {
inject_footnotes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> string { s = s[:len(s) - 1]
if len(sn_defs) == 0 && len(mn_defs) == 0 {
return html
} }
if strings.has_prefix(s, "<p>") && strings.has_suffix(s, "</p>") {
parts: strings.Builder return s[3:len(s) - 4]
strings.builder_init_len(&parts, 0)
defer strings.builder_destroy(&parts)
number_of: map[string]int = make(map[string]int, allocator = context.temp_allocator)
ordered_ids: [dynamic]string = make([dynamic]string, 0, allocator = context.temp_allocator)
next_num := 1
remaining := html
for {
sn_pos := strings.index(remaining, "[^")
mn_pos := strings.index(remaining, "[*")
is_margin := mn_pos >= 0 && (sn_pos < 0 || mn_pos < sn_pos)
pos := sn_pos
if is_margin {
pos = mn_pos
}
if pos < 0 {
strings.write_string(&parts, remaining)
break
}
strings.write_string(&parts, remaining[:pos])
close := strings.index(remaining[pos + 2:], "]")
if close < 0 {
strings.write_string(&parts, remaining[pos:])
break
}
id := remaining[pos + 2:pos + 2 + close]
ref_end := pos + 2 + close + 1
defs := sn_defs
if is_margin {
defs = mn_defs
}
def_text, found := defs[id]
if !found {
strings.write_string(&parts, remaining[pos:ref_end])
remaining = remaining[ref_end:]
continue
}
num, seen := number_of[id]
if !seen {
num = next_num
number_of[id] = num
next_num += 1
append(&ordered_ids, id)
}
fmt.sbprintf(&parts, `<sup><a href="#fn-%d" id="fnref-%d">%d</a></sup>`, num, num, num)
remaining = remaining[ref_end:]
} }
return s
if len(ordered_ids) > 0 {
strings.write_string(&parts, "\n<section class=\"footnotes\">\n<hr>\n<ol>\n")
for id in ordered_ids {
def_text, ok := sn_defs[id]
if !ok {
def_text, ok = mn_defs[id]
}
if !ok do continue
def_html := render_inline_md(def_text)
num := number_of[id]
fmt.sbprintf(
&parts,
`<li id="fn-%d">%s <a href="#fnref-%d" class="footnote-backref">↩︎</a></li>` +
"\n",
num,
def_html,
num,
)
}
strings.write_string(&parts, "</ol>\n</section>")
}
return strings.to_string(parts)
} }
-99
View File
@@ -119,102 +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]"))
} }
@(test)
test_inject_footnotes_basic :: proc(t: ^testing.T) {
html := "Text[^a] end."
sn := map[string]string {
"a" = "a footnote",
}
defer delete_map(sn)
mn := make(map[string]string)
defer delete_map(mn)
out := inject_footnotes(html, sn, mn)
testing.expect(t, strings.contains(out, `<sup><a href="#fn-1" id="fnref-1">1</a></sup>`))
testing.expect(t, strings.contains(out, `<li id="fn-1">a footnote`))
testing.expect(t, strings.contains(out, `class="footnote-backref"`))
testing.expect(t, strings.contains(out, `<section class="footnotes">`))
testing.expect(t, strings.contains(out, "</section>"))
}
@(test)
test_inject_footnotes_numbered_by_appearance :: proc(t: ^testing.T) {
html := "Second[^b] then first[^a]."
sn := map[string]string {
"a" = "def a",
"b" = "def b",
}
defer delete_map(sn)
mn := make(map[string]string)
defer delete_map(mn)
out := inject_footnotes(html, sn, mn)
// b appears first in the text → 1, a → 2
testing.expect(t, strings.contains(out, `id="fnref-1">1</a></sup>`))
testing.expect(t, strings.contains(out, `id="fnref-2">2</a></sup>`))
testing.expect(t, strings.contains(out, `<li id="fn-1">def b`))
testing.expect(t, strings.contains(out, `<li id="fn-2">def a`))
}
@(test)
test_inject_footnotes_marginnote_treated_same :: proc(t: ^testing.T) {
html := "Sidenote[^a] and marginnote[*b]."
sn := map[string]string {
"a" = "sn def",
}
defer delete_map(sn)
mn := map[string]string {
"b" = "mn def",
}
defer delete_map(mn)
out := inject_footnotes(html, sn, mn)
// Both get numbered as regular footnotes
testing.expect(t, strings.contains(out, `id="fnref-1">1</a></sup>`))
testing.expect(t, strings.contains(out, `id="fnref-2">2</a></sup>`))
testing.expect(t, strings.contains(out, `<li id="fn-1">sn def`))
testing.expect(t, strings.contains(out, `<li id="fn-2">mn def`))
}
@(test)
test_inject_footnotes_no_defs :: proc(t: ^testing.T) {
html := "No notes here."
sn := make(map[string]string)
mn := make(map[string]string)
testing.expect(t, inject_footnotes(html, sn, mn) == html)
}
@(test)
test_inject_footnotes_missing_def :: proc(t: ^testing.T) {
html := "Ref[^missing] end."
sn := map[string]string {
"other" = "x",
}
defer delete_map(sn)
mn := make(map[string]string)
defer delete_map(mn)
out := inject_footnotes(html, sn, mn)
testing.expect(t, strings.contains(out, "[^missing]"))
testing.expect(t, !strings.contains(out, "<section"))
}
@(test)
test_inject_footnotes_inline_markdown :: proc(t: ^testing.T) {
html := "Text[^a] end."
sn := map[string]string {
"a" = "see [link](http://example.com) here",
}
defer delete_map(sn)
mn := make(map[string]string)
defer delete_map(mn)
out := inject_footnotes(html, sn, mn)
testing.expect(t, strings.contains(out, `<a href="http://example.com">link</a>`))
}
+9 -19
View File
@@ -90,12 +90,9 @@ write_escaped :: proc(b: ^strings.Builder, s: string) {
} }
} }
// unescape_html returns a caller-owned string (free it with delete). When the
// input has no entities it returns a clone; otherwise it returns the builder's
// buffer. It must NOT destroy the builder — to_string aliases that buffer, so the
// returned string owns it.
unescape_html :: proc(s: string) -> string { unescape_html :: proc(s: string) -> string {
sb := strings.builder_make() sb := strings.builder_make()
defer strings.builder_destroy(&sb)
start := 0 start := 0
for i in 0 ..< len(s) { for i in 0 ..< len(s) {
@@ -122,28 +119,22 @@ unescape_html :: proc(s: string) -> string {
strings.write_string(&sb, replacement) strings.write_string(&sb, replacement)
start = i + semi + 1 start = i + semi + 1
} }
if start == 0 do return strings.clone(s) if start == 0 do return s
if start < len(s) do strings.write_string(&sb, s[start:]) if start < len(s) do strings.write_string(&sb, s[start:])
return strings.to_string(sb) return strings.to_string(sb)
} }
highlight_block :: proc(code: string, lang: string, file_path: string) -> string { highlight_block :: proc(code: string, lang: string, file_path: string) -> string {
g, ok := ts.grammar(lang) gc := ts.load_grammar(lang)
if !ok { if gc == nil {
return code return code
} }
parser := ts.open_parser(g)
if parser == nil {
return code
}
defer ts.parser_delete(parser)
raw_code := unescape_html(code) raw_code := unescape_html(code)
defer delete(raw_code)
raw_c := strings.clone_to_cstring(raw_code) raw_c := strings.clone_to_cstring(raw_code)
defer delete(raw_c) defer delete(raw_c)
tree := ts.parser_parse_string(parser, nil, raw_c, u32(len(raw_code))) tree := ts.parser_parse_string(gc.parser, nil, raw_c, u32(len(raw_code)))
if tree == nil { if tree == nil {
return code return code
} }
@@ -165,13 +156,12 @@ highlight_block :: proc(code: string, lang: string, file_path: string) -> string
} }
} }
if g.query == nil { cursor := gc.cursor
if cursor == nil {
return code return code
} }
cursor := ts.query_cursor_new()
defer ts.query_cursor_delete(cursor)
ts.query_cursor_exec(cursor, g.query, root) ts.query_cursor_exec(cursor, gc.query, root)
captures := make([dynamic]Capture, 0, 64, context.temp_allocator) captures := make([dynamic]Capture, 0, 64, context.temp_allocator)
defer delete(captures) defer delete(captures)
@@ -184,7 +174,7 @@ highlight_block :: proc(code: string, lang: string, file_path: string) -> string
} }
cap := match.captures[capture_idx] cap := match.captures[capture_idx]
name_len: u32 name_len: u32
name_c := ts.query_capture_name_for_id(g.query, cap.index, &name_len) name_c := ts.query_capture_name_for_id(gc.query, cap.index, &name_len)
if name_c == nil { if name_c == nil {
continue continue
} }
-103
View File
@@ -1,103 +0,0 @@
#+test
package markdown
import ts "../treesitter"
import "core:mem"
import "core:strings"
import "core:testing"
// highlight_block returns the input string unchanged (borrowed) when it can't
// highlight — no grammar, no query, or zero captures — and a freshly-allocated
// string when it does. Free only the latter; identity (raw_data) tells them
// apart regardless of content.
@(private = "file")
free_highlight :: proc(out, code: string) {
if raw_data(out) != raw_data(code) {
delete(out)
}
}
// A builtin grammar (css) highlights into hl- spans, preserving the source text.
@(test)
test_highlight_block_css :: proc(t: ^testing.T) {
code := "a{color:red}"
out := highlight_block(code, "css", "test")
defer free_highlight(out, code)
testing.expect(t, strings.contains(out, "<span class=\"hl-"), "css should be highlighted")
testing.expect(t, strings.contains(out, "</span>"))
testing.expect(t, strings.contains(out, "color"))
testing.expect(t, strings.contains(out, "red"))
}
// The other builtin grammar (html) highlights, and the source's own '<'/'>' are
// HTML-escaped in the output (so they can't be confused with the wrapping spans).
@(test)
test_highlight_block_html_escapes_source :: proc(t: ^testing.T) {
code := "<p>hi</p>"
out := highlight_block(code, "html", "test")
defer free_highlight(out, code)
testing.expect(t, strings.contains(out, "<span class=\"hl-"), "html should be highlighted")
testing.expect(t, strings.contains(out, "hi"))
// The literal angle brackets of the source are escaped in the output.
testing.expect(t, strings.contains(out, "&lt;"))
testing.expect(t, strings.contains(out, "&gt;"))
}
// A language with no available grammar returns the input verbatim (best-effort:
// highlighting is optional). The returned string must be the same buffer, so
// free_highlight leaves it alone.
@(test)
test_highlight_block_unknown_lang_passthrough :: proc(t: ^testing.T) {
code := "fn main() {}"
out := highlight_block(code, "no-such-language", "test")
defer free_highlight(out, code)
testing.expect_value(t, out, code)
testing.expect(t, raw_data(out) == raw_data(code), "passthrough must return the input buffer")
}
// End-to-end: highlight_code finds a fenced code block in rendered HTML and
// rewrites its body with highlight spans while preserving the <pre><code> shell.
// Run under a scratch arena (as production does) so the intermediate allocations
// highlight_code doesn't individually free are reclaimed in one shot.
@(test)
test_highlight_code_wraps_css_block :: 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)
html := `<pre><code class="language-css">a{color:red}</code></pre>`
out := highlight_code(html, "test")
testing.expect(t, strings.contains(out, `<pre><code class="language-css">`))
testing.expect(t, strings.contains(out, "</code></pre>"))
testing.expect(
t,
strings.contains(out, "<span class=\"hl-"),
"block body should be highlighted",
)
}
// unescape_html must return a caller-owned allocation the caller frees exactly
// once. The buggy version frees its builder internally (defer builder_destroy)
// and returns the freed alias, so the caller's delete becomes a double-free that
// the test runner's tracking allocator flags.
@(test)
test_unescape_html_entities_owned :: proc(t: ^testing.T) {
got := unescape_html("&lt;a&gt;")
defer delete(got)
testing.expect_value(t, got, "<a>")
}
// Same contract on the no-entity path: the buggy version returns the borrowed
// input, so freeing it is a bad free (pointer not from the allocator).
@(test)
test_unescape_html_passthrough_owned :: proc(t: ^testing.T) {
got := unescape_html("plain text")
defer delete(got)
testing.expect_value(t, got, "plain text")
}
+32 -94
View File
@@ -3,11 +3,8 @@ package markdown
import cm "vendor:commonmark" import cm "vendor:commonmark"
import "core:encoding/json" import "core:encoding/json"
import "core:log"
import "core:strings" import "core:strings"
import diags "../diagnostics"
Extension :: enum { Extension :: enum {
Emoji, Emoji,
Sidenotes, Sidenotes,
@@ -15,43 +12,22 @@ Extension :: enum {
Highlight, Highlight,
Sections, Sections,
HeadingIDs, HeadingIDs,
DefLists,
Footnotes,
} }
DEFAULT_EXTENSIONS :: bit_set[Extension]{.Emoji, .Sidenotes, .Alerts, .HeadingIDs, .DefLists} DEFAULT_EXTENSIONS :: bit_set[Extension]{.Emoji, .Sidenotes, .Alerts, .HeadingIDs}
EXTENSION_NAMES :: []string{ process :: proc(body: string, ext: bit_set[Extension], file_path: string) -> string {
"emoji", "sidenotes", "alerts", "highlight", "sections",
"heading_ids", "deflists", "footnotes",
}
// Caller is responsible for freeing string
process :: proc(
body: string,
ext: bit_set[Extension],
file_path: string,
allocator := context.allocator,
) -> string {
side_notes := make(map[string]string) side_notes := make(map[string]string)
margin_notes := make(map[string]string) margin_notes := make(map[string]string)
clean_body := body clean_body := body
if .Sidenotes in ext || .Footnotes in ext { if .Sidenotes in ext {
clean_body, side_notes, margin_notes = strip_definitions(body) clean_body, side_notes, margin_notes = strip_definitions(body)
} }
if .DefLists in ext { html := cm.markdown_to_html_from_string(clean_body, {.Unsafe})
clean_body = convert_deflists(clean_body, allocator)
}
original_html := cm.markdown_to_html_from_string(clean_body, {.Unsafe})
html := strings.clone(original_html, allocator)
cm.free_string(original_html)
if .Emoji in ext { if .Emoji in ext {
html = expand_emoji(html) html = expand_emoji(html)
} }
if .Footnotes in ext { if .Sidenotes in ext {
html = inject_footnotes(html, side_notes, margin_notes)
} else if .Sidenotes in ext {
html = inject_notes(html, side_notes, margin_notes) html = inject_notes(html, side_notes, margin_notes)
} }
if .Alerts in ext { if .Alerts in ext {
@@ -73,19 +49,20 @@ process :: proc(
parse_extension_list :: proc(s: string) -> (result: bit_set[Extension]) { parse_extension_list :: proc(s: string) -> (result: bit_set[Extension]) {
for part in strings.split(s, ",", allocator = context.temp_allocator) { for part in strings.split(s, ",", allocator = context.temp_allocator) {
name := strings.to_lower(strings.trim_space(part), allocator = context.temp_allocator) name := strings.to_lower(strings.trim_space(part), allocator = context.temp_allocator)
e, ok := extension_from_name(name) switch name {
if !ok { case "emoji":
if name != "" { result += {.Emoji}
suggestion := diags.suggest_correction(EXTENSION_NAMES, name) case "sidenotes":
if suggestion != "" { result += {.Sidenotes}
log.warnf("unknown extension '%s' (did you mean '%s'?)", name, suggestion) case "alerts":
} else { result += {.Alerts}
log.warnf("unknown extension '%s'", name) case "highlight":
} result += {.Highlight}
} case "sections":
continue result += {.Sections}
case "heading_ids":
result += {.HeadingIDs}
} }
result += {e}
} }
return result return result
} }
@@ -93,60 +70,21 @@ parse_extension_list :: proc(s: string) -> (result: bit_set[Extension]) {
// Given a map[Extension]bool, apply it to ext. // Given a map[Extension]bool, apply it to ext.
apply_extension_config :: proc(ext: ^bit_set[Extension], config: json.Object) { apply_extension_config :: proc(ext: ^bit_set[Extension], config: json.Object) {
for name, val in config { for name, val in config {
// TODO: Silently discards invalid values.
enabled := val.(json.Boolean) or_continue enabled := val.(json.Boolean) or_continue
switch name {
e, ok := extension_from_name(name) case "emoji":
if !ok { if enabled {ext^ += {.Emoji}} else {ext^ -= {.Emoji}}
lower := strings.to_lower(name, context.temp_allocator) case "sidenotes":
suggestion := diags.suggest_correction(EXTENSION_NAMES, lower) if enabled {ext^ += {.Sidenotes}} else {ext^ -= {.Sidenotes}}
if suggestion != "" { case "alerts":
log.warnf("unknown extension '%s' (did you mean '%s'?)", name, suggestion) if enabled {ext^ += {.Alerts}} else {ext^ -= {.Alerts}}
} else { case "highlight":
log.warnf("unknown extension '%s'", name) if enabled {ext^ += {.Highlight}} else {ext^ -= {.Highlight}}
} case "sections":
continue if enabled {ext^ += {.Sections}} else {ext^ -= {.Sections}}
} case "heading_ids":
if enabled {ext^ += {.HeadingIDs}} else {ext^ -= {.HeadingIDs}}
if enabled {
ext^ += {e}
} else {
ext^ -= {e}
} }
} }
} }
extension_from_name :: proc(name: string) -> (e: Extension, ok: bool) {
switch name {
case "emoji":
e = .Emoji
ok = true
case "sidenotes":
e = .Sidenotes
case "alerts":
e = .Alerts
case "highlight":
e = .Highlight
case "sections":
e = .Sections
case "heading_ids":
e = .HeadingIDs
case "deflists":
e = .DefLists
case "footnotes":
e = .Footnotes
case:
// Do nothing
}
return e, ok || e != .Emoji
}
// resolve_extension_conflicts resolves mutually exclusive extensions.
// Footnotes and Sidenotes share the same [^id] syntax but render differently;
// if both are enabled (e.g. from defaults + CLI), Footnotes wins.
resolve_extension_conflicts :: proc(ext: ^bit_set[Extension]) {
if .Footnotes in ext^ && .Sidenotes in ext^ {
ext^ -= {.Sidenotes}
}
}
-2
View File
@@ -1,2 +0,0 @@
Term
: Definition content
-7
View File
@@ -1,7 +0,0 @@
Markdown
: A lightweight markup language
: Created by John Gruber in 2004
HTML
: A standard markup language used to create web pages
: Used to create web pages
-5
View File
@@ -1,5 +0,0 @@
Term
: This is the first line of definition content
This is the second line, needs at least one space indentation
This is a new paragraph, needs at least one space indentation and a preceding blank line
-7
View File
@@ -1,7 +0,0 @@
Markdown Syntax
: **Markdown** supports multiple text formatting:
- *Italic* and **Bold**
- [Link](https://www.markdownlang.com)
- `Inline code`
- > Block quote
- Lists and other elements
-6
View File
@@ -1,6 +0,0 @@
Outer Term
: Outer Definition
Inner Term
: Inner Definition
: Another Inner Definition
-1
View File
@@ -1 +0,0 @@
Term: Definition
-2
View File
@@ -1,2 +0,0 @@
Term
: Definition
-11
View File
@@ -1,11 +0,0 @@
API
: An application programming interface, a set of rules that allow different software applications to communicate with each other.
Framework
: A software library that provides a standardized structure for application development.
Git
: A distributed version control system used to track changes in a project development process.
IDE
: An integrated development environment, a software application that includes a code editor and various development tools.
-14
View File
@@ -1,14 +0,0 @@
user_id
: **Required** - The unique identifier for the user.
: Type: `integer`
name
: **Optional** - The display name for the user.
: Type: `string`
: Default: `null`
status
: **Optional** - The user status.
: Type: `string`
: Allowed values: `active`, `inactive`, `suspended`
: Default: `active`
-1
View File
@@ -1 +0,0 @@
<dl><dt>Term</dt><dd>Definition content</dd></dl>
@@ -1 +0,0 @@
<dl><dt>Markdown</dt><dd>A lightweight markup language</dd><dd>Created by John Gruber in 2004</dd><dt>HTML</dt><dd>A standard markup language used to create web pages</dd><dd>Used to create web pages</dd></dl>
@@ -1,3 +0,0 @@
<dl><dt>Term</dt><dd>This is the first line of definition content</dd></dl> This is the second line, needs at least one space indentation
This is a new paragraph, needs at least one space indentation and a preceding blank line
@@ -1,5 +0,0 @@
<dl><dt>Markdown Syntax</dt><dd><strong>Markdown</strong> supports multiple text formatting:</dd></dl> - *Italic* and **Bold**
- [Link](https://www.markdownlang.com)
- `Inline code`
- > Block quote
- Lists and other elements
-1
View File
@@ -1 +0,0 @@
<dl><dt>Outer Term</dt><dd>Outer Definition</dd><dt>Inner Term</dt><dd>Inner Definition</dd><dd>Another Inner Definition</dd></dl>
-1
View File
@@ -1 +0,0 @@
Term: Definition
@@ -1 +0,0 @@
<dl><dt>Term</dt><dd>Definition</dd></dl>
-1
View File
@@ -1 +0,0 @@
<dl><dt>API</dt><dd>An application programming interface, a set of rules that allow different software applications to communicate with each other.</dd><dt>Framework</dt><dd>A software library that provides a standardized structure for application development.</dd><dt>Git</dt><dd>A distributed version control system used to track changes in a project development process.</dd><dt>IDE</dt><dd>An integrated development environment, a software application that includes a code editor and various development tools.</dd></dl>
@@ -1 +0,0 @@
<dl><dt>user_id</dt><dd><strong>Required</strong> - The unique identifier for the user.</dd><dd>Type: <code>integer</code></dd><dt>name</dt><dd><strong>Optional</strong> - The display name for the user.</dd><dd>Type: <code>string</code></dd><dd>Default: <code>null</code></dd><dt>status</dt><dd><strong>Optional</strong> - The user status.</dd><dd>Type: <code>string</code></dd><dd>Allowed values: <code>active</code>, <code>inactive</code>, <code>suspended</code></dd><dd>Default: <code>active</code></dd></dl>
-60
View File
@@ -1,60 +0,0 @@
# 3-way behavior comparison: ours vs Pandoc vs Hugo
Captured for later review (e.g. if we ever pursue "Option B" — full CommonMark
block semantics inside definitions). Compares `convert_deflists` output (Option A,
inline/flat) against Pandoc (`-f markdown -t html5`) and Hugo/Goldmark for the
four fixtures where behavior is interesting. Raw renderings live in `pandoc/` and
`hugo/`; ours is in `../expected/`. Whitespace/formatting differences ignored
below — only structural differences are described.
## 03-multiline-def — definition with a continuation line + a second paragraph
*How much multi-line content belongs to the `<dd>`?*
- **Ours:** only the **first line** goes in the `<dd>`. The continuation line and
the second paragraph fall out of the `<dl>` as **raw, unrendered markdown**.
- **Pandoc:** folds the continuation line **into** the `<dd>` (lazy continuation),
then **ejects** the second paragraph as a sibling `<p>` after `</dl>`.
- **Hugo:** keeps **everything inside** the `<dd>` — continuation line as text,
second paragraph as a nested `<p>`.
- All three differ. We capture the least, Pandoc the middle, Hugo the most.
## 04-markdown-in-def — definition containing a bullet list (with a blockquote)
*What happens to the nested list?*
- **Ours:** intro line becomes the `<dd>`; the **entire bullet list falls out as
raw markdown** (unrendered `- …` lines after `</dl>`).
- **Pandoc:** bullets **flattened into the `<dd>`** as inline text — no `<ul>`, the
`-` become literal dashes.
- **Hugo:** bullets become a **real nested `<ul>`/`<li>`** (blockquote and all)
inside the `<dd>`.
- All three differ; same shape as 03. We drop it, Pandoc absorbs-but-flattens,
Hugo builds true structure.
## 05-nested — a definition list nested under another definition
*Nest or flatten?*
- **Ours:** **flatten** — one flat `<dl>`; "Inner Term" is just another `<dt>`.
- **Pandoc:** **flatten** — structurally **identical to ours**.
- **Hugo:** **nests** a real inner `<dl>` inside the outer `<dd>`.
- Ours == Pandoc; Hugo is the odd one out.
## 07-extra-space — term then a 2-space-indented `: Definition`
*Is a 2-space colon a definition marker?*
- **Ours:** **yes** → clean `<dl>`.
- **Pandoc:** **yes** → structurally **identical to ours**.
- **Hugo:** **no** → rejects it, emits a plain `<p>`.
- Ours == Pandoc; Hugo is the odd one out.
## Pattern
Two clean groups:
- **Marker / flattening decisions (05, 07):** we land **exactly on Pandoc**. These
are the calls our CommonMark-marker + flat-inline design makes cleanly; Pandoc
agrees, Hugo diverges (nests / stricter marker).
- **Block-content-in-a-definition (03, 04):** we diverge from **both** tools, doing
**less** — we emit the `<dl>` for the term/first line and let the rest fall
through as raw markdown, rather than absorbing it into the `<dd>` (flattened like
Pandoc, or structured like Hugo).
In short: our behavior is **"Pandoc, minus the ability to pull block content into
a `<dd>`."** Single inline-line definitions (01, 02, 05, 07, 08, 09) match a real
engine; genuinely multi-block definitions (03, 04) stop at the first line and pass
the remainder through. Closing that gap is what Option B would entail.
-44
View File
@@ -1,44 +0,0 @@
# Definition-list test fixtures
Inputs (`../NN-name.md`) are the definition-list examples from
<https://www.markdownlang.com/extended/definition-lists.html>.
`pandoc/` and `hugo/` hold each input rendered by Pandoc (`-f markdown -t html5`)
and Hugo (Goldmark with `markup.goldmark.extensions.definitionList = true`).
They are retained as reference for a possible future "Option B" (full CommonMark
block semantics inside definitions). They are NOT the test oracle for the current
implementation.
`../expected/NN.html` is the oracle for `convert_deflists`: our inline fragment
format (no inter-tag newlines, no trailing newline), derived from the trusted
tool per the decisions below.
## Decisions (Option A — simple / inline)
Marker whitespace follows CommonMark: `:` with 0–3 spaces of indent is a
definition; 4+ (tab = 4) is a code block. Definition bodies are inline/flat.
Every fixture has a concrete exact-match oracle in `../expected/NN.html`.
| Fixture | Pandoc | Hugo | Decision | expected/ source |
|---------|--------|------|----------|------------------|
| 01-basic | `<dl>` | `<dl>` | support | hugo (agreed) |
| 02-multiple-defs | multi `<dd>` | multi `<dd>` | support | hugo (agreed) |
| 06-compact (`Term: Definition`) | `<p>` | `<p>` | NOT a deflist → passthrough | raw input |
| 07-extra-space (2-space `:`) | `<dl>` | `<p>` | support (accept, CommonMark ≤3-space rule) | hand-written |
| 08-glossary | `<dl>` | `<dl>` | support | hugo (agreed) |
| 09-api-params | multi `<dd>` | multi `<dd>` | support | hugo (agreed) |
| 05-nested | flattens | nested `<dl>` | flatten (matches Pandoc) | snapshot (== pandoc) |
| 03-multiline-def | ejects 2nd para | nests 2nd para in `<dd>` | degrade | snapshot (current output) |
| 04-markdown-in-def | flattens list | nested `<ul>`/`<blockquote>` | degrade | snapshot (current output) |
A detailed 3-way behavior comparison (ours vs Pandoc vs Hugo) for the diverging
fixtures is in `COMPARISON.md`, kept for Option-B review.
03/04/05 require block-level content inside `<dd>` (multi-paragraph, nested
lists/blockquotes, nested `<dl>`), which the inline preprocessor does not
produce. 05 flattens exactly as Pandoc does, so its snapshot equals Pandoc's
output. 03/04 emit a `<dl>` for the term and first line, then let the remaining
block content fall through as markdown; their `expected/` files snapshot that
current degraded behavior as a regression guard, not an endorsed rendering.
Revisit via Option B (full CommonMark block semantics) if real content needs it.
@@ -1,4 +0,0 @@
<dl>
<dt>Term</dt>
<dd>Definition content</dd>
</dl>
@@ -1,8 +0,0 @@
<dl>
<dt>Markdown</dt>
<dd>A lightweight markup language</dd>
<dd>Created by John Gruber in 2004</dd>
<dt>HTML</dt>
<dd>A standard markup language used to create web pages</dd>
<dd>Used to create web pages</dd>
</dl>
@@ -1,7 +0,0 @@
<dl>
<dt>Term</dt>
<dd>This is the first line of definition content
This is the second line, needs at least one space indentation
<p>This is a new paragraph, needs at least one space indentation and a preceding blank line</p>
</dd>
</dl>
@@ -1,16 +0,0 @@
<dl>
<dt>Markdown Syntax</dt>
<dd><strong>Markdown</strong> supports multiple text formatting:
<ul>
<li><em>Italic</em> and <strong>Bold</strong></li>
<li><a href="https://www.markdownlang.com">Link</a></li>
<li><code>Inline code</code></li>
<li>
<blockquote>
<p>Block quote</p>
</blockquote>
</li>
<li>Lists and other elements</li>
</ul>
</dd>
</dl>
@@ -1,10 +0,0 @@
<dl>
<dt>Outer Term</dt>
<dd>Outer Definition
<dl>
<dt>Inner Term</dt>
<dd>Inner Definition</dd>
<dd>Another Inner Definition</dd>
</dl>
</dd>
</dl>
@@ -1 +0,0 @@
<p>Term: Definition</p>
@@ -1,2 +0,0 @@
<p>Term
: Definition</p>
@@ -1,10 +0,0 @@
<dl>
<dt>API</dt>
<dd>An application programming interface, a set of rules that allow different software applications to communicate with each other.</dd>
<dt>Framework</dt>
<dd>A software library that provides a standardized structure for application development.</dd>
<dt>Git</dt>
<dd>A distributed version control system used to track changes in a project development process.</dd>
<dt>IDE</dt>
<dd>An integrated development environment, a software application that includes a code editor and various development tools.</dd>
</dl>
@@ -1,14 +0,0 @@
<dl>
<dt>user_id</dt>
<dd><strong>Required</strong> - The unique identifier for the user.</dd>
<dd>Type: <code>integer</code></dd>
<dt>name</dt>
<dd><strong>Optional</strong> - The display name for the user.</dd>
<dd>Type: <code>string</code></dd>
<dd>Default: <code>null</code></dd>
<dt>status</dt>
<dd><strong>Optional</strong> - The user status.</dd>
<dd>Type: <code>string</code></dd>
<dd>Allowed values: <code>active</code>, <code>inactive</code>, <code>suspended</code></dd>
<dd>Default: <code>active</code></dd>
</dl>
@@ -1,6 +0,0 @@
<dl>
<dt>Term</dt>
<dd>
Definition content
</dd>
</dl>
@@ -1,16 +0,0 @@
<dl>
<dt>Markdown</dt>
<dd>
A lightweight markup language
</dd>
<dd>
Created by John Gruber in 2004
</dd>
<dt>HTML</dt>
<dd>
A standard markup language used to create web pages
</dd>
<dd>
Used to create web pages
</dd>
</dl>
@@ -1,9 +0,0 @@
<dl>
<dt>Term</dt>
<dd>
This is the first line of definition content This is the second line,
needs at least one space indentation
</dd>
</dl>
<p>This is a new paragraph, needs at least one space indentation and a
preceding blank line</p>
@@ -1,9 +0,0 @@
<dl>
<dt>Markdown Syntax</dt>
<dd>
<strong>Markdown</strong> supports multiple text formatting: -
<em>Italic</em> and <strong>Bold</strong> - <a
href="https://www.markdownlang.com">Link</a> - <code>Inline code</code>
- &gt; Block quote - Lists and other elements
</dd>
</dl>
@@ -1,13 +0,0 @@
<dl>
<dt>Outer Term</dt>
<dd>
Outer Definition
</dd>
<dt>Inner Term</dt>
<dd>
Inner Definition
</dd>
<dd>
Another Inner Definition
</dd>
</dl>
@@ -1 +0,0 @@
<p>Term: Definition</p>
@@ -1,6 +0,0 @@
<dl>
<dt>Term</dt>
<dd>
Definition
</dd>
</dl>
@@ -1,22 +0,0 @@
<dl>
<dt>API</dt>
<dd>
An application programming interface, a set of rules that allow
different software applications to communicate with each other.
</dd>
<dt>Framework</dt>
<dd>
A software library that provides a standardized structure for
application development.
</dd>
<dt>Git</dt>
<dd>
A distributed version control system used to track changes in a project
development process.
</dd>
<dt>IDE</dt>
<dd>
An integrated development environment, a software application that
includes a code editor and various development tools.
</dd>
</dl>
@@ -1,33 +0,0 @@
<dl>
<dt>user_id</dt>
<dd>
<strong>Required</strong> - The unique identifier for the user.
</dd>
<dd>
Type: <code>integer</code>
</dd>
<dt>name</dt>
<dd>
<strong>Optional</strong> - The display name for the user.
</dd>
<dd>
Type: <code>string</code>
</dd>
<dd>
Default: <code>null</code>
</dd>
<dt>status</dt>
<dd>
<strong>Optional</strong> - The user status.
</dd>
<dd>
Type: <code>string</code>
</dd>
<dd>
Allowed values: <code>active</code>, <code>inactive</code>,
<code>suspended</code>
</dd>
<dd>
Default: <code>active</code>
</dd>
</dl>
-179
View File
@@ -1,179 +0,0 @@
package markdown
import "core:log"
import "core:strings"
// generate_toc scans rendered HTML for <h1>-<h6> tags with id attributes and
// builds a nested <ul> table of contents. Returns "" if no headings with IDs
// are found. Must be called after inject_heading_ids.
generate_toc :: proc(html: string, allocator := context.allocator) -> string {
b: strings.Builder
strings.builder_init(&b, allocator)
current_level := 0
min_level := 7
pos := 0
for {
idx, level, id, text, next_pos := next_heading(html, pos)
if level == 0 {
break
}
pos = next_pos
if level < min_level {
min_level = level
}
if current_level == 0 {
current_level = level
strings.write_string(&b, "<ul>\n")
} else if level > current_level {
if level - current_level > 1 {
log.warnf(
"heading level skips from h%d to h%d (\"%s\"); " +
"table of contents inserts empty levels to keep valid nesting",
current_level,
level,
text,
)
}
// The first step down nests inside the current, still-open <li>.
// Each further skipped level needs its own <li> to host the deeper
// <ul>, since a <ul> may only contain <li> — otherwise a jump of
// more than one level emits stacked, parentless <ul> tags.
strings.write_string(&b, "<ul>\n")
current_level += 1
for current_level < level {
strings.write_string(&b, "<li>\n<ul>\n")
current_level += 1
}
} else if level < current_level {
strings.write_string(&b, "</li>\n")
for current_level > level {
strings.write_string(&b, "</ul>\n</li>\n")
current_level -= 1
}
} else {
strings.write_string(&b, "</li>\n")
}
strings.write_string(&b, `<li><a href="#`)
strings.write_string(&b, id)
strings.write_string(&b, `">`)
strings.write_string(&b, text)
strings.write_string(&b, `</a>`)
}
if current_level == 0 {
return ""
}
strings.write_string(&b, "</li>\n")
for current_level > min_level {
strings.write_string(&b, "</ul>\n</li>\n")
current_level -= 1
}
strings.write_string(&b, "</ul>\n")
return strings.to_string(b)
}
// next_heading finds the next <hN> tag with an id attribute starting from pos.
// Returns level=0 if none found.
next_heading :: proc(
html: string,
start: int,
) -> (
idx: int,
level: int,
id: string,
text: string,
next_pos: int,
) {
i := start
for i + 3 < len(html) {
if html[i] == '<' && html[i + 1] == 'h' {
d := html[i + 2]
if d >= '1' && d <= '6' {
level = int(d - '0')
idx = i
break
}
}
i += 1
}
if level == 0 {
return 0, 0, "", "", len(html)
}
// Find end of opening tag
tag_end := strings.index_byte(html[idx:], '>')
if tag_end < 0 {
return 0, 0, "", "", len(html)
}
tag_end += idx
// Find id="..." within the tag
tag := html[idx:tag_end + 1]
id_pos := strings.index(tag, `id="`)
if id_pos < 0 {
// No id — skip this heading, continue searching
return next_heading(html, tag_end + 1)
}
id_start := idx + id_pos + 4
id_end_rel := strings.index_byte(html[id_start:], '"')
if id_end_rel < 0 {
return 0, 0, "", "", len(html)
}
id = html[id_start:id_start + id_end_rel]
// Text between > and </hN>
text_start := tag_end + 1
close_idx := strings.index(html[text_start:], "</h")
if close_idx < 0 {
return 0, 0, "", "", len(html)
}
text_end := text_start + close_idx
text = strip_tags(html[text_start:text_end])
// Find end of closing tag
next_pos = text_end + close_tag_len(html, text_end)
return idx, level, id, text, next_pos
}
// close_tag_len returns the length of the </hN> tag at pos.
close_tag_len :: proc(html: string, pos: int) -> int {
if pos + 4 > len(html) {
return 4
}
end := strings.index_byte(html[pos:], '>')
if end < 0 {
return 4
}
return end + 1
}
// strip_tags removes HTML tags from a string, leaving only text content.
strip_tags :: proc(s: string) -> string {
b: strings.Builder
strings.builder_init(&b, context.temp_allocator)
i := 0
for i < len(s) {
if s[i] == '<' {
end := strings.index_byte(s[i:], '>')
if end >= 0 {
i += end + 1
continue
}
}
strings.write_byte(&b, s[i])
i += 1
}
return strings.to_string(b)
}
-35
View File
@@ -1,35 +0,0 @@
#+test
package markdown
import "core:strings"
import "core:testing"
@(test)
test_toc_sequential_nesting :: proc(t: ^testing.T) {
html := `<h1 id="a">A</h1><h2 id="b">B</h2><h1 id="c">C</h1>`
result := generate_toc(html, context.temp_allocator)
testing.expect(t, strings.contains(result, `<a href="#a">A</a>`))
testing.expect(t, strings.contains(result, `<a href="#b">B</a>`))
testing.expect(t, strings.contains(result, `<a href="#c">C</a>`))
// A well-formed list never places a <ul> directly inside a <ul>.
testing.expect(t, !strings.contains(result, "<ul>\n<ul>"))
}
// Regression: a heading level that jumps by more than one (h1 -> h3) must not
// emit stacked, parentless <ul> tags. Each skipped level is wrapped in an <li>
// so the list stays valid.
@(test)
test_toc_level_jump_stays_valid :: proc(t: ^testing.T) {
html := `<h1 id="a">A</h1><h3 id="b">B</h3>`
result := generate_toc(html, context.temp_allocator)
testing.expect(t, !strings.contains(result, "<ul>\n<ul>"))
// The intermediate level is hosted by an <li>.
testing.expect(t, strings.contains(result, "<li>\n<ul>"))
testing.expect(t, strings.contains(result, `<a href="#b">B</a>`))
}
@(test)
test_toc_empty_when_no_headings :: proc(t: ^testing.T) {
result := generate_toc("<p>no headings here</p>", context.temp_allocator)
testing.expect_value(t, result, "")
}
+41 -211
View File
@@ -3,168 +3,69 @@ 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: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. // build_menus populates site.menus:
// Supports three forms: // 1. Config menus (thor.json "menus" key present) — exclusive, preserves array order
// "menus": "main" → {main: {name=title, url=permalink, weight=nil}} // 2. Auto-menus (sections + root-level pages) + page frontmatter menus — merged, sorted
// "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 by merging every source into one menu set:
// 1. Auto-menus (sections + root-level pages)
// 2. Config menus (thor.json "menus" key), already parsed into site.menus
// 3. Page frontmatter menus
// All three are combined per menu name and sorted by weight, then name.
// //
// If "menus" is present but empty ({}) it means explicit opt-out: no menus at // If "menus" is present but empty ({}) it means explicit opt-out: no menus.
// all, and page frontmatter menus are ignored. // Config menus cannot be mixed with page frontmatter menus (error).
build_menus :: proc(site: ^Site) { build_menus :: proc(site: ^Site) {
// Explicit opt-out: "menus": {} present but empty → no menus; page if site.menus != nil {
// frontmatter menus are ignored (with a warning). has_menus := false
// TODO(TODOS.md): silently discarding frontmatter here is harsh; revisit.
if site.menus != nil && len(site.menus) == 0 {
for page in site.pages { for page in site.pages {
if len(page.menus) > 0 { if page.menu != "" {
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( log.warnf(
"menus: config has empty menus but pages have frontmatter menu entries; ignoring page menus", "menus: config has empty menus but pages have frontmatter menu entries; ignoring page menus",
) )
break
} }
return
}
// Config menus active
if has_menus {
log.fatalf("menus: cannot mix config menus with frontmatter menus")
os.exit(1)
} }
return return
} }
// Merge all sources: auto section/root menus, config menus (already in // No config menus — auto-generate, then merge page menus on top
// site.menus if present), then page frontmatter menus.
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 {
@@ -209,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)
@@ -232,47 +127,23 @@ 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 {
return return
} }
if site.menus == nil {
site.menus = make(map[string][]Menu_Entry, alloc)
}
// Merge into any existing "main" (e.g. config menus) rather than clobbering.
if existing, ok := site.menus["main"]; ok {
merged := make([dynamic]Menu_Entry, 0, len(existing) + len(entries), alloc)
append(&merged, ..existing)
append(&merged, ..entries[:])
entries = merged
}
sort_menu_entries(entries[:]) sort_menu_entries(entries[:])
site.menus = make(map[string][]Menu_Entry, alloc)
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
} }
@@ -280,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,
@@ -332,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 {
@@ -362,30 +209,13 @@ 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[:]
} }
-577
View File
@@ -1,577 +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) {
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) {
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) {
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) {
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) {
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")
}
// --- config + frontmatter + auto merge tests ---
@(test)
test_build_menus_config_merges_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)
// Simulate config menus as site_apply_config would populate them.
site.menus = make(map[string][]Menu_Entry, site_allocator(&site))
cfg := make([dynamic]Menu_Entry, 0, 1, site_allocator(&site))
append(&cfg, Menu_Entry{name = "GitHub", url = "https://example.com", weight = 20})
site.menus["main"] = cfg[:]
// A root-level page opting into "main" via frontmatter.
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)
build_menus(&site)
main, ok := site.menus["main"]
testing.expect(t, ok)
// GitHub (config, weight 20) + Ideas (frontmatter, nil → DEFAULT_WEIGHT 10).
testing.expect(t, len(main) == 2, "expected config + frontmatter merged")
testing.expect_value(t, main[0].name, "Ideas")
testing.expect_value(t, main[1].name, "GitHub")
}
@(test)
test_build_menus_config_merges_auto :: proc(t: ^testing.T) {
site: Site
mem.dynamic_arena_init(&site.arena)
defer mem.dynamic_arena_destroy(&site.arena)
context.allocator = site_allocator(&site)
// Config menu in a different bucket ("footer"); auto-gen owns "main".
site.menus = make(map[string][]Menu_Entry, site_allocator(&site))
cfg := make([dynamic]Menu_Entry, 0, 1, site_allocator(&site))
append(&cfg, Menu_Entry{name = "GitHub", url = "https://example.com", weight = 20})
site.menus["footer"] = cfg[:]
// A section (non-index) page triggers an auto "main" section entry.
page := make_page("Post", "/blog/post/")
page.section = "blog"
site.pages = make(#soa[dynamic]Page, 0, 1, site_allocator(&site))
append(&site.pages, page)
build_menus(&site)
footer, ok_f := site.menus["footer"]
testing.expect(t, ok_f, "config footer menu should be preserved")
testing.expect(t, len(footer) == 1)
testing.expect_value(t, footer[0].name, "GitHub")
main, ok_m := site.menus["main"]
testing.expect(t, ok_m, "auto-generated main menu should exist alongside config")
testing.expect(t, len(main) == 1)
testing.expect_value(t, main[0].name, "Blog")
}
// --- 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 -97
View File
@@ -12,31 +12,18 @@ Range :: struct {
end: u32, end: u32,
} }
// minify_html collapses insignificant whitespace, strips comments, and minifies minify_html :: proc(source: string) -> string {
// inline <style> bodies (via minify_css). The caller owns html_parser and gc := ts.ensure_parser("html")
// css_parser and passes them in; both may be reused across calls. On success it if gc == nil {
// returns (owned string, true) and the caller must free the string. On failure return source
// (nil html_parser, parse failure, or syntax errors) it returns ("", false) and
// the caller should keep the original source unchanged.
minify_html :: proc(
html_parser: ts.Parser,
css_parser: ts.Parser,
source: string,
) -> (
minified: string,
ok: bool,
) {
if html_parser == nil {
return minified, ok
} }
source_c := strings.clone_to_cstring(source) source_c := strings.clone_to_cstring(source)
defer delete(source_c) defer delete(source_c)
tree := ts.parser_parse_string(html_parser, nil, source_c, u32(len(source))) tree := ts.parser_parse_string(gc.parser, nil, source_c, u32(len(source)))
if tree == nil { if tree == nil {
ok = false return source
return minified, ok
} }
defer ts.tree_delete(tree) defer ts.tree_delete(tree)
@@ -44,24 +31,20 @@ minify_html :: proc(
if ts.node_has_error(root) { if ts.node_has_error(root) {
log.warnf("minify: HTML parse errors, skipping minification") log.warnf("minify: HTML parse errors, skipping minification")
ok = false return source
return minified, ok
} }
comments: [dynamic]Range comments: [dynamic]Range
defer delete(comments) defer delete(comments)
preserves: [dynamic]Range preserves: [dynamic]Range
defer delete(preserves) defer delete(preserves)
styles: [dynamic]Range
defer delete(styles)
collect_html_ranges(root, source, &comments, &preserves, &styles) collect_html_ranges(root, source, &comments, &preserves)
sb := strings.builder_make() sb := strings.builder_make()
ci := 0 ci := 0
pi := 0 pi := 0
si := 0
i := 0 i := 0
last_written: u8 = 0 last_written: u8 = 0
@@ -78,27 +61,6 @@ minify_html :: proc(
continue continue
} }
if si < len(styles) && u32(i) >= styles[si].start {
s := styles[si]
seg := source[i:s.end]
if minified, mok := minify_css(css_parser, seg); mok {
strings.write_string(&sb, minified)
if len(minified) > 0 {
last_written = minified[len(minified) - 1]
}
delete(minified)
} else {
// CSS minify skipped/failed: keep the style body verbatim.
strings.write_string(&sb, seg)
if len(seg) > 0 {
last_written = seg[len(seg) - 1]
}
}
i = int(s.end)
si += 1
continue
}
if ci < len(comments) && u32(i) >= comments[ci].start { if ci < len(comments) && u32(i) >= comments[ci].start {
i = int(comments[ci].end) i = int(comments[ci].end)
ci += 1 ci += 1
@@ -131,10 +93,7 @@ minify_html :: proc(
} }
} }
return strings.to_string(sb)
minified, ok = strings.to_string(sb), true
return minified, ok
} }
collect_html_ranges :: proc( collect_html_ranges :: proc(
@@ -142,7 +101,6 @@ collect_html_ranges :: proc(
source: string, source: string,
comments: ^[dynamic]Range, comments: ^[dynamic]Range,
preserves: ^[dynamic]Range, preserves: ^[dynamic]Range,
styles: ^[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 {
@@ -154,14 +112,7 @@ collect_html_ranges :: proc(
comments, comments,
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)}, Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
) )
} else if type_str == "style_element" { } else if type_str == "script_element" || type_str == "style_element" {
// Preserve the <style> tags but minify the CSS body (the raw_text
// child) via the CSS-aware pass.
raw, ok := style_raw_text(child)
if ok {
append(styles, Range{start = ts.node_start_byte(raw), end = ts.node_end_byte(raw)})
}
} else if type_str == "script_element" {
append( append(
preserves, preserves,
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)}, Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
@@ -174,27 +125,14 @@ collect_html_ranges :: proc(
Range{start = ts.node_start_byte(child), 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, styles) collect_html_ranges(child, source, comments, preserves)
} }
} else { } else {
collect_html_ranges(child, source, comments, preserves, styles) collect_html_ranges(child, source, comments, preserves)
} }
} }
} }
// style_raw_text returns the raw_text (CSS body) child of a style_element.
// ok is false for an empty <style></style>, which has no raw_text child.
style_raw_text :: proc(style_element: ts.Node) -> (raw: ts.Node, ok: bool) {
child_count := ts.node_named_child_count(style_element)
for i in 0 ..< child_count {
child := ts.node_named_child(style_element, u32(i))
if string(ts.node_type(child)) == "raw_text" {
return child, true
}
}
return {}, false
}
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 {
@@ -230,23 +168,18 @@ is_css_delim :: proc(c: u8) -> bool {
return false return false
} }
// minify_css collapses insignificant whitespace and strips comments. The caller minify_css :: proc(source: string) -> string {
// owns `parser` and passes it in; it may be reused across calls. On success it gc := ts.ensure_parser("css")
// returns (owned string, true) and the caller must free the string. On failure if gc == nil {
// (nil parser, parse failure, or syntax errors) it returns ("", false) and the return source
// caller should keep the original source unchanged.
minify_css :: proc(parser: ts.Parser, source: string) -> (minified: string, ok: bool) {
if parser == nil {
return minified, ok
} }
source_c := strings.clone_to_cstring(source) source_c := strings.clone_to_cstring(source)
defer delete(source_c) defer delete(source_c)
tree := ts.parser_parse_string(parser, nil, source_c, u32(len(source))) tree := ts.parser_parse_string(gc.parser, nil, source_c, u32(len(source)))
if tree == nil { if tree == nil {
ok = false return source
return minified, ok
} }
defer ts.tree_delete(tree) defer ts.tree_delete(tree)
@@ -254,8 +187,7 @@ minify_css :: proc(parser: ts.Parser, source: string) -> (minified: string, ok:
if ts.node_has_error(root) { if ts.node_has_error(root) {
log.warnf("minify: CSS parse errors, skipping minification") log.warnf("minify: CSS parse errors, skipping minification")
ok = false return source
return minified, ok
} }
comments: [dynamic]Range comments: [dynamic]Range
@@ -292,14 +224,7 @@ minify_css :: proc(parser: ts.Parser, source: string) -> (minified: string, ok:
next = source[j] next = source[j]
} }
// Emit a single separating space, but never a leading space if !is_css_delim(last_written) && !is_css_delim(next) {
// (last_written == 0), a doubled space (last_written == ' ',
// which happens when a stripped comment sits between two
// whitespace runs), or one adjacent to a delimiter.
if last_written != 0 &&
last_written != ' ' &&
!is_css_delim(last_written) &&
!is_css_delim(next) {
strings.write_byte(&sb, ' ') strings.write_byte(&sb, ' ')
last_written = ' ' last_written = ' '
} }
@@ -311,8 +236,7 @@ minify_css :: proc(parser: ts.Parser, source: string) -> (minified: string, ok:
} }
} }
minified, ok = strings.to_string(sb), true return strings.to_string(sb)
return minified, ok
} }
collect_css_comments :: proc(node: ts.Node, comments: ^[dynamic]Range) { collect_css_comments :: proc(node: ts.Node, comments: ^[dynamic]Range) {
-98
View File
@@ -1,98 +0,0 @@
#+test
package main
import ts "treesitter"
import "core:strings"
import "core:testing"
// The treesitter registry is thread-safe and self-initializing (grammar() lazily
// binds it to the heap), and parsers are caller-owned. Each test opens its own
// parser(s) via ts.open_parser, passes them to minify_*, and frees the returned
// string — so the multi-threaded test runner needs no shared state or locking.
@(private = "file")
open_parsers :: proc(t: ^testing.T) -> (html_parser, css_parser: ts.Parser) {
hg, hok := ts.grammar("html")
cg, cok := ts.grammar("css")
testing.expect(t, hok, "html grammar should load")
testing.expect(t, cok, "css grammar should load")
return ts.open_parser(hg), ts.open_parser(cg)
}
@(private = "file")
expect_css :: proc(t: ^testing.T, parser: ts.Parser, input, want: string) {
got, ok := minify_css(parser, input)
testing.expect(t, ok)
defer delete(got)
testing.expect_value(t, got, want)
}
// --- minify_html: inline <style> bodies get CSS-minified ---
@(test)
test_minify_html_inline_style :: proc(t: ^testing.T) {
hp, cp := open_parsers(t)
defer ts.parser_delete(hp)
defer ts.parser_delete(cp)
input := `<html><head><style>
body {
color: red;
}
</style></head><body><p>hi</p></body></html>`
result, ok := minify_html(hp, cp, input)
testing.expect(t, ok)
defer delete(result)
// The CSS body is collapsed by minify_css (delimiters lose surrounding
// whitespace)...
testing.expect(t, strings.contains(result, "body{color:red;}"))
// ...while the <style> tags themselves survive.
testing.expect(t, strings.contains(result, "<style>"))
testing.expect(t, strings.contains(result, "</style>"))
}
@(test)
test_minify_html_empty_style :: proc(t: ^testing.T) {
hp, cp := open_parsers(t)
defer ts.parser_delete(hp)
defer ts.parser_delete(cp)
// An empty <style></style> has no raw_text child; must not crash.
input := `<html><head><style></style></head><body></body></html>`
result, ok := minify_html(hp, cp, input)
testing.expect(t, ok)
defer delete(result)
testing.expect(t, strings.contains(result, "<style></style>"))
}
// --- minify_css: whitespace around stripped comments / leading edge ---
@(test)
test_minify_css_leading_and_comment :: proc(t: ^testing.T) {
cg, cok := ts.grammar("css")
testing.expect(t, cok, "css grammar should load")
cp := ts.open_parser(cg)
defer ts.parser_delete(cp)
// Leading whitespace and a comment between two whitespace runs must not
// leave a leading space or a doubled space.
expect_css(t, cp, "\n/* c */\na {}", "a{}")
expect_css(t, cp, " a {}", "a{}")
expect_css(t, cp, "a {}\n/* trailing */", "a{}")
}
@(test)
test_minify_html_script_preserved :: proc(t: ^testing.T) {
hp, cp := open_parsers(t)
defer ts.parser_delete(hp)
defer ts.parser_delete(cp)
// <script> stays byte-for-byte (whitespace-significant JS).
input := "<html><body><script>\nconst s = \"a b\";\n</script></body></html>"
result, ok := minify_html(hp, cp, input)
testing.expect(t, ok)
defer delete(result)
testing.expect(t, strings.contains(result, "const s = \"a b\";"))
}
-1
View File
@@ -4,4 +4,3 @@
- [ ] Simplify `trim_standalone_whitespace` — four temp arrays (`li_buf`, `ri_buf`, `left_done`, `right_done`) could potentially be reduced to two by merging the done-tracking into the index buffers using sentinel values (e.g. `-2` = already trimmed). - [ ] Simplify `trim_standalone_whitespace` — four temp arrays (`li_buf`, `ri_buf`, `left_done`, `right_done`) could potentially be reduced to two by merging the done-tracking into the index buffers using sentinel values (e.g. `-2` = already trimmed).
- [ ] Adjacent standalone tags don't capture indent for the second tag (e.g. ` {{>a}}\n {{>b}}` — `{{>b}}` gets `indent=""` because its left text was consumed by `{{>a}}`'s right-trim). Blocks recover via `deindent_blocks`, but partials/parents have no fallback. - [ ] Adjacent standalone tags don't capture indent for the second tag (e.g. ` {{>a}}\n {{>b}}` — `{{>b}}` gets `indent=""` because its left text was consumed by `{{>a}}`'s right-trim). Blocks recover via `deindent_blocks`, but partials/parents have no fallback.
- [ ] lazy load partials ? (with mutex) - [ ] lazy load partials ? (with mutex)
- [ ] Support changing delimiters (Set Delimiter tag `{{=<% %>=}}`). Delimiters `{{`/`}}`/`{{{`/`}}}` are hardcoded in [tokenizer.odin](./tokenizer.odin); needs an `=` sigil case, mutable open/close delimiters threaded through `tokenize`, and wiring up `spec/specs/delimiters.json` in `spec_test.odin` (fixture present but not run).
+26 -17
View File
@@ -137,22 +137,6 @@ resolve_name :: proc(name: string, ctx: []any) -> any {
return result return result
} }
collect_map_keys :: proc(container: any, allocator := context.temp_allocator) -> []string {
val, info := base_value(container)
if info == nil do return nil
if _, ok := info.variant.(runtime.Type_Info_Map); !ok {
return nil
}
out := make([dynamic]string, 0, 4, allocator)
it := 0
for {
key, _ := reflect.iterate_map(val, &it) or_break
key_str := key.(string) or_continue
append(&out, key_str)
}
return out[:]
}
// is_truthy checks mustache truthiness. // is_truthy checks mustache truthiness.
is_truthy :: proc(a: any) -> bool { is_truthy :: proc(a: any) -> bool {
if a == nil { if a == nil {
@@ -175,6 +159,32 @@ is_truthy :: proc(a: any) -> bool {
} }
} }
call_interp_lambda :: proc(val: any) -> (result: string, ok: bool) {
switch v in val {
case proc() -> string:
return v(), true
case proc() -> int:
return fmt.tprintf("%d", v()), true
case proc() -> bool:
return "true" if v() else "false", true
case:
return "", false
}
}
call_section_lambda :: proc(val: any, text: string) -> (result: string, ok: bool) {
switch v in val {
case proc(_: string) -> string:
return v(text), true
case proc(_: string) -> int:
return fmt.tprintf("%d", v(text)), true
case proc(_: string) -> bool:
return "true" if v(text) else "false", true
case:
return "", false
}
}
// list_info returns element type info, count, and data pointer for a list value. // list_info returns element type info, count, and data pointer for a list value.
// Returns elem_info=nil if the value is not a list. // Returns elem_info=nil if the value is not a list.
list_info :: proc(a: any) -> (elem_info: ^runtime.Type_Info, count: int, data: rawptr) { list_info :: proc(a: any) -> (elem_info: ^runtime.Type_Info, count: int, data: rawptr) {
@@ -292,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:])
} }
} }
-149
View File
@@ -1,149 +0,0 @@
package mustache
import "core:os"
import "core:path/filepath"
import "core:strings"
Diag_Case :: struct {
name: string,
enable: bool,
should: string, // "error" | "warn" | "pass"
expected: string, // exact formatted diagnostic output
input: string,
}
load_diag_cases :: proc(path: string, allocator := context.temp_allocator) -> []Diag_Case {
dirs := [?]string{#directory, path}
full_path, _ := filepath.join(dirs[:], allocator)
raw_data, err := os.read_entire_file(full_path, allocator)
if err != nil {
return {}
}
content := string(raw_data)
lines := strings.split(content, "\n", allocator)
cases := make([dynamic]Diag_Case, 0, 16, allocator)
current: Diag_Case
has_current := false
// Multiline block state
in_block := false
block_key: string
block_indent := -1
block_sb: strings.Builder
for line in lines {
raw := strings.trim_right(line, "\r")
if in_block {
// Check if this line belongs to the multiline block
is_indented := len(raw) == 0 || raw[0] == ' ' || raw[0] == '\t'
if is_indented {
if block_indent < 0 && len(raw) > 0 {
// Determine indentation from first content line
block_indent = 0
for block_indent < len(raw) && raw[block_indent] == ' ' {
block_indent += 1
}
}
// Strip block indentation
stripped := raw
if block_indent > 0 && len(stripped) >= block_indent {
stripped = stripped[block_indent:]
}
if strings.builder_len(block_sb) > 0 {
strings.write_string(&block_sb, "\n")
}
strings.write_string(&block_sb, stripped)
continue
} else {
// End of multiline block
in_block = false
strings.write_string(&block_sb, "\n") // YAML | trailing newline
value := strings.clone(strings.to_string(block_sb), allocator)
strings.builder_destroy(&block_sb)
set_field(&current, block_key, value)
block_indent = -1
// Fall through to process this line
}
}
trimmed := strings.trim_space(raw)
// Entry separator
if trimmed == "---" {
if has_current {
append(&cases, current)
current = {}
has_current = false
}
continue
}
// Skip blank lines outside blocks
if trimmed == "" {
continue
}
// Multiline block start: "key: |"
if strings.has_suffix(trimmed, ": |") {
colon := strings.last_index(trimmed, ":")
if colon > 0 {
block_key = strings.trim_space(trimmed[:colon])
strings.builder_init(&block_sb, allocator)
in_block = true
has_current = true
continue
}
}
// Simple key: value
colon := strings.index(trimmed, ":")
if colon > 0 {
key := strings.trim_space(trimmed[:colon])
value := strings.trim_space(trimmed[colon + 1:])
// Strip matching outer quotes
if len(value) >= 2 {
if (value[0] == '"' && value[len(value) - 1] == '"') ||
(value[0] == '\'' && value[len(value) - 1] == '\'') {
value = value[1:len(value) - 1]
}
}
set_field(&current, key, value)
has_current = true
}
}
// Flush trailing multiline block
if in_block {
strings.write_string(&block_sb, "\n") // YAML | trailing newline
value := strings.clone(strings.to_string(block_sb), allocator)
strings.builder_destroy(&block_sb)
set_field(&current, block_key, value)
}
// Flush last entry
if has_current {
append(&cases, current)
}
return cases[:]
}
set_field :: proc(c: ^Diag_Case, key, value: string) {
switch key {
case "name":
c.name = value
case "enable":
c.enable = value == "true"
case "should":
c.should = value
case "expected":
c.expected = value
case "input":
c.input = value
}
}
+239 -42
View File
@@ -1,6 +1,61 @@
package mustache package mustache
import diags "../diagnostics" import "core:fmt"
import "core:os"
import "core:strings"
import "core:terminal/ansi"
import "core:unicode/utf8"
// line_col returns the 1-indexed line and column for a byte offset in source.
// Newlines ('\n') separate lines; '\r' is treated as part of '\r\n'. Column is
// counted in bytes from the start of the line.
line_col :: proc(source: string, pos_in: int) -> (line: int, col: int) {
pos := pos_in
if pos < 0 {
return 1, 1
}
if pos > len(source) {
pos = len(source)
}
line = 1
col = 1
for i := 0; i < pos; i += 1 {
if source[i] == '\n' {
line += 1
col = 1
} else {
col += 1
}
}
return
}
// line_text returns the Nth (1-indexed) line of source, without the trailing
// newline. Returns "" if line is out of range.
line_text :: proc(source: string, line: int) -> string {
if line < 1 {
return ""
}
current := 1
start := 0
for i := 0; i < len(source); i += 1 {
if current == line {
end := i
for end < len(source) && source[end] != '\n' {
end += 1
}
return source[start:end]
}
if source[i] == '\n' {
current += 1
start = i + 1
}
}
if current == line {
return source[start:]
}
return ""
}
// context_extent returns the byte offset of the start of the line containing // context_extent returns the byte offset of the start of the line containing
// pos, plus the byte offsets of the start and end of the mustache tag at pos. // pos, plus the byte offsets of the start and end of the mustache tag at pos.
@@ -60,11 +115,68 @@ context_extent :: proc(
return line_start, pos, pos + 1 return line_start, pos, pos + 1
} }
// format_tag_error is a mustache-aware wrapper around diags.format_error. // should_colorize returns true if stderr is a TTY and color output is wanted.
// It uses context_extent to compute the caret underline for `{{ }}` tags, should_colorize :: proc() -> bool {
// falling back to a single-rune underline when pos is not inside a tag. return os.is_tty(os.stderr)
// When `span > 0`, the caret extends from pos to pos + span. }
format_tag_error :: proc(
// count_lines returns the number of '\n'-separated lines in source.
// A trailing newline does not add an extra line.
count_lines :: proc(source: string) -> int {
if len(source) == 0 {
return 1
}
n := 1
for c in source {
if c == '\n' {
n += 1
}
}
// Drop phantom last line if source ends with '\n'.
if len(source) > 0 && source[len(source) - 1] == '\n' {
n -= 1
}
return n
}
// digit_count returns the number of decimal digits in n (min 1).
digit_count :: proc(n: int) -> int {
if n <= 0 {
return 1
}
c := 0
x := n
for x > 0 {
c += 1
x /= 10
}
return c
}
// display_width returns the number of terminal cells `s` occupies.
// For ASCII this is byte length; for UTF-8 we count runes (combining
// marks and wide CJK chars are still approximate).
display_width :: proc(s: string) -> int {
return utf8.rune_count_in_string(s)
}
// format_error produces a rust-style multi-line diagnostic string.
//
// <msg>
// --> <path>:<line>:<col>
// |
// N | <source line N-2>
// N | <source line N-1>
// N | <source line N — the error line>
// | ^^^^^^^^^^^ <hint>
// N | <source line N+1>
// N | <source line N+2>
// |
//
// `context_before`/`context_after` lines of context are shown around the
// error line. Line numbers are right-aligned to the width of the largest
// line number shown.
format_error :: proc(
path: string, path: string,
source: string, source: string,
pos: int, pos: int,
@@ -73,23 +185,126 @@ format_tag_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 {
caret_start := pos line, col := line_col(source, pos)
caret_end := pos + 1 total_lines := count_lines(source)
if span > 0 {
caret_end = pos + span start_line := line - context_before
} else { if start_line < 1 {
_, ts, te := context_extent(source, pos) start_line = 1
caret_start = ts
caret_end = te
} }
return diags.format_error( end_line := line + context_after
path, source, pos, msg, hint, if end_line > total_lines {
context_before, context_after, end_line = total_lines
colorize, }
caret_start, caret_end,
) // Width of the line-number column (right-align).
width := digit_count(end_line)
if width < 1 {
width = 1
}
sb := strings.builder_make(context.temp_allocator)
defer strings.builder_destroy(&sb)
color := colorize
red, faint, reset := "", "", ""
if color {
red = ansi.CSI + ansi.FG_RED + ansi.SGR
faint = ansi.CSI + ansi.FAINT + ansi.SGR
reset = ansi.CSI + ansi.RESET + ansi.SGR
}
// Header line: message.
strings.write_string(&sb, msg)
strings.write_byte(&sb, '\n')
// Location line: " --> path:line:col" (width spaces + arrow).
strings.write_string(&sb, faint)
for _ in 0 ..< width {
strings.write_byte(&sb, ' ')
}
strings.write_string(&sb, "--> ")
strings.write_string(&sb, reset)
strings.write_string(&sb, fmt.tprintf("%s:%d:%d\n", path, line, col))
// Top gutter line.
write_gutter(&sb, width, faint, reset)
// Caret extent for the error line.
_, token_start, token_end := context_extent(source, pos)
line_start, _, _ := context_extent(source, pos)
caret_start_col := token_start - line_start + 1
caret_end_col := token_end - line_start + 1
if caret_end_col <= caret_start_col {
caret_end_col = caret_start_col + 1
}
// Context lines.
for n in start_line ..= end_line {
// Line number (right-aligned, faint).
num_str := fmt.tprintf("%d", n)
strings.write_string(&sb, faint)
for _ in 0 ..< width - len(num_str) {
strings.write_byte(&sb, ' ')
}
strings.write_string(&sb, num_str)
strings.write_string(&sb, " | ")
strings.write_string(&sb, reset)
strings.write_string(&sb, line_text(source, n))
strings.write_byte(&sb, '\n')
// After the error line, emit the caret row.
if n == line {
strings.write_string(&sb, faint)
for _ in 0 ..< width + 1 {
strings.write_byte(&sb, ' ')
}
strings.write_string(&sb, "| ")
strings.write_string(&sb, reset)
for _ in 1 ..< caret_start_col {
strings.write_byte(&sb, ' ')
}
if color {
strings.write_string(&sb, red)
}
for _ in 0 ..< caret_end_col - caret_start_col {
strings.write_byte(&sb, '^')
}
if color {
strings.write_string(&sb, reset)
}
if hint != "" {
strings.write_byte(&sb, ' ')
if color {
strings.write_string(&sb, faint)
}
strings.write_string(&sb, hint)
if color {
strings.write_string(&sb, reset)
}
}
strings.write_byte(&sb, '\n')
}
}
// Trailing gutter line for visual closure.
write_gutter(&sb, width, faint, reset)
return strings.to_string(sb)
}
// write_gutter emits a faint pipe-only gutter line: `<width+1 spaces> |`.
write_gutter :: proc(sb: ^strings.Builder, width: int, faint: string, reset: string) {
strings.write_string(sb, faint)
for _ in 0 ..< width + 1 {
strings.write_byte(sb, ' ')
}
strings.write_string(sb, "|")
strings.write_string(sb, reset)
strings.write_byte(sb, '\n')
} }
// format_render_error produces a diagnostic for an Error value using the // format_render_error produces a diagnostic for an Error value using the
@@ -98,28 +313,10 @@ format_render_error :: proc(err: Error, tmpl: Template, colorize: bool = false)
if err == nil { if err == nil {
return "" return ""
} }
b := body(err) path := tmpl.path
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)
caret_start := b.pos return format_error(path, tmpl.source, b.pos, b.msg, colorize = colorize)
caret_end := b.pos + 1
if b.span > 0 {
caret_end = b.pos + b.span
} else {
_, ts, te := context_extent(source, b.pos)
caret_start = ts
caret_end = te
}
return diags.format_error(
path, source, b.pos, b.msg,
hint = b.hint,
colorize = colorize,
caret_start = caret_start,
caret_end = caret_end,
)
} }
+101 -288
View File
@@ -1,22 +1,100 @@
#+test #+test
package mustache package mustache
import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:log"
import "core:os"
import "core:path/filepath"
import "core:strings" import "core:strings"
import "core:testing" import "core:testing"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// format_tag_error — golden output tests // line_col / line_text / count_lines / digit_count — primitive helpers
// ---------------------------------------------------------------------------
@(test)
test_line_col_basic :: proc(t: ^testing.T) {
src := "abc\ndef\nghi"
cases := [?]struct {
pos: int,
line: int,
col: int,
}{{0, 1, 1}, {2, 1, 3}, {3, 1, 4}, {4, 2, 1}, {6, 2, 3}, {7, 2, 4}, {8, 3, 1}}
for c in cases {
l, col := line_col(src, c.pos)
testing.expect(t, l == c.line, fmt.tprintf("pos %d: line %d, want %d", c.pos, l, c.line))
testing.expect(t, col == c.col, fmt.tprintf("pos %d: col %d, want %d", c.pos, col, c.col))
}
}
@(test)
test_line_col_empty :: proc(t: ^testing.T) {
l, col := line_col("", 0)
testing.expect_value(t, l, 1)
testing.expect_value(t, col, 1)
}
@(test)
test_line_col_negative :: proc(t: ^testing.T) {
l, col := line_col("abc", -1)
testing.expect_value(t, l, 1)
testing.expect_value(t, col, 1)
}
@(test)
test_line_col_past_end :: proc(t: ^testing.T) {
l, col := line_col("abc", 100)
testing.expect_value(t, l, 1)
testing.expect_value(t, col, 4)
}
@(test)
test_line_text_first :: proc(t: ^testing.T) {
src := "first\nsecond\nthird"
testing.expect_value(t, line_text(src, 1), "first")
testing.expect_value(t, line_text(src, 2), "second")
testing.expect_value(t, line_text(src, 3), "third")
}
@(test)
test_line_text_trailing_newline :: proc(t: ^testing.T) {
src := "first\nsecond\n"
testing.expect_value(t, line_text(src, 1), "first")
testing.expect_value(t, line_text(src, 2), "second")
testing.expect_value(t, line_text(src, 3), "")
}
@(test)
test_line_text_out_of_range :: proc(t: ^testing.T) {
testing.expect_value(t, line_text("abc", 5), "")
testing.expect_value(t, line_text("abc", 0), "")
}
@(test)
test_count_lines :: proc(t: ^testing.T) {
testing.expect_value(t, count_lines(""), 1)
testing.expect_value(t, count_lines("abc"), 1)
testing.expect_value(t, count_lines("a\nb"), 2)
testing.expect_value(t, count_lines("a\nb\n"), 2)
testing.expect_value(t, count_lines("a\nb\nc"), 3)
}
@(test)
test_digit_count :: proc(t: ^testing.T) {
testing.expect_value(t, digit_count(0), 1)
testing.expect_value(t, digit_count(1), 1)
testing.expect_value(t, digit_count(9), 1)
testing.expect_value(t, digit_count(10), 2)
testing.expect_value(t, digit_count(99), 2)
testing.expect_value(t, digit_count(100), 3)
testing.expect_value(t, digit_count(-5), 1)
}
// ---------------------------------------------------------------------------
// format_error — golden output tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@(test) @(test)
test_format_error_basic :: proc(t: ^testing.T) { test_format_error_basic :: proc(t: ^testing.T) {
src := "line 1\nline 2\n{{bad}}\nline 4\nline 5" src := "line 1\nline 2\n{{bad}}\nline 4\nline 5"
out := format_tag_error("p.html", src, 14, "unknown key 'bad'", "", colorize = false) out := format_error("p.html", src, 14, "unknown key 'bad'", "", colorize = false)
expected := `unknown key 'bad' expected := `unknown key 'bad'
--> p.html:3:1 --> p.html:3:1
| |
@@ -34,7 +112,7 @@ test_format_error_basic :: proc(t: ^testing.T) {
@(test) @(test)
test_format_error_with_hint :: proc(t: ^testing.T) { test_format_error_with_hint :: proc(t: ^testing.T) {
src := "line 1\nline 2\n{{titel}}\nline 4\nline 5" src := "line 1\nline 2\n{{titel}}\nline 4\nline 5"
out := format_tag_error( out := format_error(
"post.html", "post.html",
src, src,
14, 14,
@@ -59,7 +137,7 @@ test_format_error_with_hint :: proc(t: ^testing.T) {
@(test) @(test)
test_format_error_no_hint_omits_trailing_space :: proc(t: ^testing.T) { test_format_error_no_hint_omits_trailing_space :: proc(t: ^testing.T) {
src := "{{bad}}" src := "{{bad}}"
out := format_tag_error("p.html", src, 0, "msg", "", colorize = false) out := format_error("p.html", src, 0, "msg", "", colorize = false)
// Caret line ends immediately after the carets — no trailing space. // Caret line ends immediately after the carets — no trailing space.
testing.expect(t, strings.contains(out, "^^^^^^^\n"), out) testing.expect(t, strings.contains(out, "^^^^^^^\n"), out)
testing.expect(t, !strings.contains(out, "^^^^^^^ \n"), out) testing.expect(t, !strings.contains(out, "^^^^^^^ \n"), out)
@@ -72,7 +150,7 @@ test_format_error_no_hint_omits_trailing_space :: proc(t: ^testing.T) {
@(test) @(test)
test_format_error_first_line_only_after_context :: proc(t: ^testing.T) { test_format_error_first_line_only_after_context :: proc(t: ^testing.T) {
src := "{{bad}}\nline 2\nline 3\nline 4\nline 5" src := "{{bad}}\nline 2\nline 3\nline 4\nline 5"
out := format_tag_error("p.html", src, 0, "msg", "", colorize = false) out := format_error("p.html", src, 0, "msg", "", colorize = false)
expected := `msg expected := `msg
--> p.html:1:1 --> p.html:1:1
| |
@@ -88,7 +166,7 @@ test_format_error_first_line_only_after_context :: proc(t: ^testing.T) {
@(test) @(test)
test_format_error_last_line_only_before_context :: proc(t: ^testing.T) { test_format_error_last_line_only_before_context :: proc(t: ^testing.T) {
src := "line 1\nline 2\nline 3\nline 4\n{{bad}}" src := "line 1\nline 2\nline 3\nline 4\n{{bad}}"
out := format_tag_error("p.html", src, 28, "msg", "", colorize = false) out := format_error("p.html", src, 28, "msg", "", colorize = false)
expected := `msg expected := `msg
--> p.html:5:1 --> p.html:5:1
| |
@@ -104,7 +182,7 @@ test_format_error_last_line_only_before_context :: proc(t: ^testing.T) {
@(test) @(test)
test_format_error_short_source_clamped :: proc(t: ^testing.T) { test_format_error_short_source_clamped :: proc(t: ^testing.T) {
src := "x\n{{bad}}\ny" src := "x\n{{bad}}\ny"
out := format_tag_error("p.html", src, 2, "msg", "", colorize = false) out := format_error("p.html", src, 2, "msg", "", colorize = false)
expected := `msg expected := `msg
--> p.html:2:1 --> p.html:2:1
| |
@@ -120,7 +198,7 @@ test_format_error_short_source_clamped :: proc(t: ^testing.T) {
@(test) @(test)
test_format_error_single_line_source :: proc(t: ^testing.T) { test_format_error_single_line_source :: proc(t: ^testing.T) {
src := "{{bad}}" src := "{{bad}}"
out := format_tag_error("p.html", src, 0, "msg", "", colorize = false) out := format_error("p.html", src, 0, "msg", "", colorize = false)
expected := `msg expected := `msg
--> p.html:1:1 --> p.html:1:1
| |
@@ -136,7 +214,7 @@ test_format_error_two_digit_line_numbers :: proc(t: ^testing.T) {
// 12-line source; error on line 9. end_line=11 → width=2. // 12-line source; error on line 9. end_line=11 → width=2.
src := "l01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\n{{bad}}\nl10\nl11\nl12" src := "l01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\n{{bad}}\nl10\nl11\nl12"
// Position of `{{bad}}`: 8 lines of "l0N\n" = 8*4 = 32 bytes. // Position of `{{bad}}`: 8 lines of "l0N\n" = 8*4 = 32 bytes.
out := format_tag_error("p.html", src, 32, "msg", "", colorize = false) out := format_error("p.html", src, 32, "msg", "", colorize = false)
testing.expect(t, strings.contains(out, " --> p.html:9:1\n"), out) testing.expect(t, strings.contains(out, " --> p.html:9:1\n"), out)
testing.expect(t, strings.contains(out, " |\n"), out) testing.expect(t, strings.contains(out, " |\n"), out)
testing.expect(t, strings.contains(out, " 7 | l07\n"), out) testing.expect(t, strings.contains(out, " 7 | l07\n"), out)
@@ -163,7 +241,7 @@ test_format_error_three_digit_line_numbers :: proc(t: ^testing.T) {
pos += len(parts[i - 1]) + 1 pos += len(parts[i - 1]) + 1
} }
out := format_tag_error("p.html", src, pos, "msg", "", colorize = false) out := format_error("p.html", src, pos, "msg", "", colorize = false)
testing.expect(t, strings.contains(out, " --> p.html:100:1\n"), out) testing.expect(t, strings.contains(out, " --> p.html:100:1\n"), out)
testing.expect(t, strings.contains(out, " |\n"), out) testing.expect(t, strings.contains(out, " |\n"), out)
testing.expect(t, strings.contains(out, " 98 | l098\n"), out) testing.expect(t, strings.contains(out, " 98 | l098\n"), out)
@@ -178,7 +256,7 @@ test_format_error_three_digit_line_numbers :: proc(t: ^testing.T) {
@(test) @(test)
test_caret_at_column_1 :: proc(t: ^testing.T) { test_caret_at_column_1 :: proc(t: ^testing.T) {
src := "{{bad}} at start" src := "{{bad}} at start"
out := format_tag_error("p.html", src, 0, "msg", "", colorize = false) out := format_error("p.html", src, 0, "msg", "", colorize = false)
// Caret line should start with "^" right after "| " (no leading spaces). // Caret line should start with "^" right after "| " (no leading spaces).
testing.expect(t, strings.contains(out, " | ^^^^^^^\n"), out) testing.expect(t, strings.contains(out, " | ^^^^^^^\n"), out)
} }
@@ -187,7 +265,7 @@ test_caret_at_column_1 :: proc(t: ^testing.T) {
test_caret_at_column_N :: proc(t: ^testing.T) { test_caret_at_column_N :: proc(t: ^testing.T) {
src := " {{bad}}" src := " {{bad}}"
// pos=4 is the first '{'. Line 1, col 5. // pos=4 is the first '{'. Line 1, col 5.
out := format_tag_error("p.html", src, 4, "msg", "", colorize = false) out := format_error("p.html", src, 4, "msg", "", colorize = false)
// 4 leading spaces, then 7 carets. // 4 leading spaces, then 7 carets.
testing.expect(t, strings.contains(out, " | ^^^^^^^\n"), out) testing.expect(t, strings.contains(out, " | ^^^^^^^\n"), out)
} }
@@ -195,7 +273,7 @@ test_caret_at_column_N :: proc(t: ^testing.T) {
@(test) @(test)
test_caret_width_matches_token :: proc(t: ^testing.T) { test_caret_width_matches_token :: proc(t: ^testing.T) {
src := "{{x}}" src := "{{x}}"
out := format_tag_error("p.html", src, 0, "msg", "", colorize = false) out := format_error("p.html", src, 0, "msg", "", colorize = false)
// {{x}} is 5 chars wide. // {{x}} is 5 chars wide.
testing.expect(t, strings.contains(out, " | ^^^^^\n"), out) testing.expect(t, strings.contains(out, " | ^^^^^\n"), out)
} }
@@ -207,7 +285,7 @@ test_caret_width_matches_token :: proc(t: ^testing.T) {
@(test) @(test)
test_context_before_zero :: proc(t: ^testing.T) { test_context_before_zero :: proc(t: ^testing.T) {
src := "l1\nl2\nl3\n{{bad}}\nl5\nl6" src := "l1\nl2\nl3\n{{bad}}\nl5\nl6"
out := format_tag_error( out := format_error(
"p.html", "p.html",
src, src,
9, 9,
@@ -231,7 +309,7 @@ test_context_before_zero :: proc(t: ^testing.T) {
@(test) @(test)
test_context_after_zero :: proc(t: ^testing.T) { test_context_after_zero :: proc(t: ^testing.T) {
src := "l1\nl2\nl3\n{{bad}}\nl5\nl6" src := "l1\nl2\nl3\n{{bad}}\nl5\nl6"
out := format_tag_error( out := format_error(
"p.html", "p.html",
src, src,
9, 9,
@@ -255,7 +333,7 @@ test_context_after_zero :: proc(t: ^testing.T) {
@(test) @(test)
test_context_both_zero :: proc(t: ^testing.T) { test_context_both_zero :: proc(t: ^testing.T) {
src := "l1\nl2\nl3\n{{bad}}\nl5\nl6" src := "l1\nl2\nl3\n{{bad}}\nl5\nl6"
out := format_tag_error( out := format_error(
"p.html", "p.html",
src, src,
9, 9,
@@ -282,7 +360,7 @@ test_context_both_zero :: proc(t: ^testing.T) {
@(test) @(test)
test_gutter_pipes_align_with_source_pipe :: proc(t: ^testing.T) { test_gutter_pipes_align_with_source_pipe :: proc(t: ^testing.T) {
src := "l1\n{{bad}}\nl3" src := "l1\n{{bad}}\nl3"
out := format_tag_error("p.html", src, 3, "msg", "", colorize = false) out := format_error("p.html", src, 3, "msg", "", colorize = false)
// All "|" characters should appear at the same column. // All "|" characters should appear at the same column.
// For width=1: source line is "N | ...", so "|" at col 2. // For width=1: source line is "N | ...", so "|" at col 2.
// Empty gutter is " |" (width+1 spaces + "|"), so "|" at col 2. // Empty gutter is " |" (width+1 spaces + "|"), so "|" at col 2.
@@ -304,7 +382,7 @@ test_gutter_pipes_align_with_source_pipe :: proc(t: ^testing.T) {
@(test) @(test)
test_arrow_points_at_pipe :: proc(t: ^testing.T) { test_arrow_points_at_pipe :: proc(t: ^testing.T) {
src := "{{bad}}" src := "{{bad}}"
out := format_tag_error("p.html", src, 0, "msg", "", colorize = false) out := format_error("p.html", src, 0, "msg", "", colorize = false)
// For width=1: arrow line is " --> ..." so ">" at col 3. // For width=1: arrow line is " --> ..." so ">" at col 3.
// Pipe lines are " |" so "|" at col 2. // Pipe lines are " |" so "|" at col 2.
lines := strings.split(out, "\n", context.temp_allocator) lines := strings.split(out, "\n", context.temp_allocator)
@@ -346,7 +424,7 @@ test_format_render_error_dispatch :: proc(t: ^testing.T) {
} }
b := body(parse_err) b := body(parse_err)
out := format_tag_error("test.html", src, b.pos, b.msg, colorize = false) out := format_error("test.html", src, b.pos, b.msg, colorize = false)
testing.expect(t, strings.contains(out, "unclosed section"), out) testing.expect(t, strings.contains(out, "unclosed section"), out)
testing.expect(t, strings.contains(out, "test.html:"), out) testing.expect(t, strings.contains(out, "test.html:"), out)
} }
@@ -479,268 +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),
) )
} }
// ---------------------------------------------------------------------------
// Data-driven diagnostic cases (from DIAGNOSTIC_TESTS.yaml)
//
// write_diagnostic_tests generates diagnostic_test_cases_test.odin from the
// enabled cases in DIAGNOSTIC_TESTS.yaml. On the first run (or when the YAML
// changes), it regenerates the file and fails. On the second run, each case
// runs as an individual test via run_diag_case.
// ---------------------------------------------------------------------------
Diag_Context :: struct {
items: [5]string,
word: string,
params: json.Object,
now: string,
}
diag_ctx :: proc() -> Diag_Context {
params_raw := `{ "author": { "name": "foo bar"}}`
params_val, err := json.parse(params_raw, allocator = context.temp_allocator)
assert(err == nil)
params, ok := params_val.(json.Object)
assert(ok)
return Diag_Context {
items = {"a", "b", "c", "d", "e"},
word = "Hello, World!",
params = params,
now = "2026-08-10T16:21:55-04:00",
}
}
diag_partials :: proc() -> map[string]Template {
dirs := [?]string{#directory, "fixtures/layouts/partials"}
partials_dir, _ := filepath.join(dirs[:], context.temp_allocator)
partials := load_partials(partials_dir, allocator = context.temp_allocator)
// #directory expands to an absolute, build-environment-specific path (e.g.
// /build/... inside the nix sandbox vs a local checkout). Strip it so
// partial diagnostics reference a stable path across environments, keeping
// the golden expected output deterministic.
for key, tmpl in partials {
t := tmpl
rel := strings.trim_prefix(t.path, #directory)
t.path = strings.trim_left(rel, "/")
partials[key] = t
}
return partials
}
diag_slug :: proc(name: string) -> string {
sb: strings.Builder
strings.builder_init(&sb, context.temp_allocator)
prev_underscore := false
for r in name {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
strings.write_rune(&sb, r)
prev_underscore = false
} else if r >= 'A' && r <= 'Z' {
strings.write_rune(&sb, r + 32)
prev_underscore = false
} else {
if !prev_underscore && strings.builder_len(sb) > 0 {
strings.write_rune(&sb, '_')
prev_underscore = true
}
}
}
s := strings.to_string(sb)
if len(s) > 0 && s[len(s) - 1] == '_' {
s = s[:len(s) - 1]
}
return s
}
render_silenced :: proc(
tmpl: Template,
data: any,
partials: map[string]Template = nil,
allocator := context.allocator,
warnings: ^[dynamic]Error = nil,
) -> (string, Error) {
saved := context.logger
context.logger = log.nil_logger()
result, err := render(tmpl, data, partials, allocator, warnings)
context.logger = saved
return result, err
}
run_diag_case :: proc(t: ^testing.T, name: string) {
cases := load_diag_cases("../DIAGNOSTIC_TESTS.yaml")
ctx := diag_ctx()
partials := diag_partials()
for c in cases {
if c.name != name do continue
switch c.should {
case "error":
tmpl, perr := parse(c.input, "<test>", allocator = context.temp_allocator)
if perr != nil {
formatted := format_render_error(perr, tmpl, colorize = false)
testing.expect_value(t, formatted, c.expected)
return
}
_, rerr := render_silenced(tmpl, ctx, partials, context.temp_allocator)
testing.expect(
t,
rerr != nil,
fmt.tprintf("[%s] should error but render succeeded", c.name),
)
if rerr == nil do return
formatted := format_render_error(rerr, tmpl, colorize = false)
testing.expect_value(t, formatted, c.expected)
case "pass":
tmpl, perr := parse(c.input, "<test>", allocator = context.temp_allocator)
testing.expect(
t,
perr == nil,
fmt.tprintf(
"[%s] should parse, got: %s",
c.name,
perr != nil ? format_render_error(perr, tmpl, colorize = false) : "",
),
)
if perr != nil do return
_, rerr := render_silenced(tmpl, ctx, partials, context.temp_allocator)
testing.expect(
t,
rerr == nil,
fmt.tprintf(
"[%s] should not error, got: %s",
c.name,
rerr != nil ? format_render_error(rerr, tmpl, colorize = false) : "",
),
)
case "ok":
tmpl, perr := parse(c.input, "<test>", allocator = context.temp_allocator)
testing.expect(
t,
perr == nil,
fmt.tprintf(
"[%s] should parse, got: %s",
c.name,
perr != nil ? format_render_error(perr, tmpl, colorize = false) : "",
),
)
if perr != nil do return
result, rerr := render_silenced(tmpl, ctx, partials, context.temp_allocator)
testing.expect(
t,
rerr == nil,
fmt.tprintf(
"[%s] should not error, got: %s",
c.name,
rerr != nil ? format_render_error(rerr, tmpl, colorize = false) : "",
),
)
if rerr != nil do return
testing.expect_value(t, result, c.expected)
case "warn":
tmpl, perr := parse(c.input, "<test>", allocator = context.temp_allocator)
testing.expect(
t,
perr == nil,
fmt.tprintf(
"[%s] should parse, got: %s",
c.name,
perr != nil ? format_render_error(perr, tmpl, colorize = false) : "",
),
)
if perr != nil do return
warnings := make([dynamic]Error, 0, 4, context.temp_allocator)
_, rerr := render_silenced(tmpl, ctx, partials, context.temp_allocator, &warnings)
testing.expect(
t,
rerr == nil,
fmt.tprintf(
"[%s] should not error, got: %s",
c.name,
rerr != nil ? format_render_error(rerr, tmpl, colorize = false) : "",
),
)
if rerr != nil do return
testing.expect(
t,
len(warnings) > 0,
fmt.tprintf("[%s] should produce a warning", c.name),
)
if len(warnings) == 0 do return
formatted := format_render_error(warnings[0], tmpl, colorize = false)
testing.expect_value(t, formatted, c.expected)
case:
testing.expectf(t, false, "[%s] unknown should value: '%s'", c.name, c.should)
}
return
}
testing.expectf(t, false, "case '%s' not found in DIAGNOSTIC_TESTS.yaml", name)
}
@(test)
write_diagnostic_tests :: proc(t: ^testing.T) {
cases := load_diag_cases("../DIAGNOSTIC_TESTS.yaml")
testing.expect(t, len(cases) > 0, "should load diagnostic cases")
sb: strings.Builder
strings.builder_init(&sb, context.temp_allocator)
strings.write_string(&sb, "#+test\n")
strings.write_string(&sb, "package mustache\n\n")
strings.write_string(&sb, "import \"core:testing\"\n\n")
count := 0
for c in cases {
if !c.enable do continue
if c.should == "" {
testing.expectf(t, false, "[%s] has enable: true but no should value", c.name)
continue
}
count += 1
slug := diag_slug(c.name)
strings.write_string(&sb, "@(test)\n")
strings.write_string(&sb, "test_diag_")
strings.write_string(&sb, slug)
strings.write_string(&sb, " :: proc(t: ^testing.T) {\n")
strings.write_string(&sb, "\trun_diag_case(t, \"")
strings.write_string(&sb, c.name)
strings.write_string(&sb, "\")\n}\n\n")
}
testing.expect(t, count > 0, "at least one case should be enabled")
generated := strings.to_string(sb)
dirs := [?]string{#directory, "diagnostic_test_cases_test.odin"}
gen_path, _ := filepath.join(dirs[:], context.temp_allocator)
existing_data, _ := os.read_entire_file_from_path(gen_path, context.temp_allocator)
existing := string(existing_data)
if existing == generated do return
werr := os.write_entire_file(gen_path, transmute([]byte)generated)
if werr != nil {
testing.expectf(t, false, "failed to write %s: %v", gen_path, werr)
return
}
testing.expectf(
t,
false,
"diagnostic_test_cases_test.odin regenerated (%d cases), re-run tests to see results",
count,
)
}
-45
View File
@@ -1,45 +0,0 @@
#+test
package mustache
import "core:testing"
@(test)
test_diag_empty_tag :: proc(t: ^testing.T) {
run_diag_case(t, "empty tag")
}
@(test)
test_diag_empty_raw_tag :: proc(t: ^testing.T) {
run_diag_case(t, "empty raw tag")
}
@(test)
test_diag_missing_pipe_arguement :: proc(t: ^testing.T) {
run_diag_case(t, "Missing pipe arguement")
}
@(test)
test_diag_first_last_with_zero_argument :: proc(t: ^testing.T) {
run_diag_case(t, "first/last with zero argument")
}
@(test)
test_diag_first_last_with_negative_argument :: proc(t: ^testing.T) {
run_diag_case(t, "first/last with negative argument")
}
@(test)
test_diag_first_succeeds_on_params_author_name :: proc(t: ^testing.T) {
run_diag_case(t, "first succeeds on params.author.name")
}
@(test)
test_diag_first_last_in_partial :: proc(t: ^testing.T) {
run_diag_case(t, "first/last in partial")
}
@(test)
test_diag_pipes_are_ignored_in_comments :: proc(t: ^testing.T) {
run_diag_case(t, "Pipes are ignored in comments")
}
@@ -1,12 +0,0 @@
<footer>
<menu>
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
{{#params.social}}
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
{{/params.social}}
</menu>
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
</p>
<p><small>&copy;</small> {{! now | format "2006" }} {{params.author.name | first -3 }}</p>
</footer>
+12 -12
View File
@@ -129,12 +129,12 @@ match_token :: proc(b: ^strings.Builder, dt: Date_Components, s: string) -> int
if strings.has_prefix( if strings.has_prefix(
s, s,
"January", "January",
) {fmt.sbprintf(b, "%s", time.Month(dt.month)); return 7} ) {strings.write_string(b, fmt.tprintf("%s", time.Month(dt.month))); return 7}
if strings.has_prefix(s, "Monday") {emit_weekday(b, dt, full = true); return 6} if strings.has_prefix(s, "Monday") {emit_weekday(b, dt, full = true); return 6}
if strings.has_prefix( if strings.has_prefix(
s, s,
"2006", "2006",
) {fmt.sbprintf(b, "%04d", dt.year); return 4} ) {strings.write_string(b, fmt.tprintf("%04d", dt.year)); return 4}
if strings.has_prefix(s, "MST") { if strings.has_prefix(s, "MST") {
abbr := dt.tz_abbr abbr := dt.tz_abbr
if len(abbr) == 0 do abbr = "UTC" if len(abbr) == 0 do abbr = "UTC"
@@ -146,37 +146,37 @@ match_token :: proc(b: ^strings.Builder, dt: Date_Components, s: string) -> int
if strings.has_prefix( if strings.has_prefix(
s, s,
"06", "06",
) {fmt.sbprintf(b, "%02d", dt.year % 100); return 2} ) {strings.write_string(b, fmt.tprintf("%02d", dt.year % 100)); return 2}
if strings.has_prefix(s, "02") {fmt.sbprintf(b, "%02d", dt.day); return 2} if strings.has_prefix(s, "02") {strings.write_string(b, fmt.tprintf("%02d", dt.day)); return 2}
if strings.has_prefix( if strings.has_prefix(
s, s,
"15", "15",
) {fmt.sbprintf(b, "%02d", dt.hour); return 2} ) {strings.write_string(b, fmt.tprintf("%02d", dt.hour)); return 2}
if strings.has_prefix( if strings.has_prefix(
s, s,
"04", "04",
) {fmt.sbprintf(b, "%02d", dt.minute); return 2} ) {strings.write_string(b, fmt.tprintf("%02d", dt.minute)); return 2}
if strings.has_prefix( if strings.has_prefix(
s, s,
"05", "05",
) {fmt.sbprintf(b, "%02d", dt.second); return 2} ) {strings.write_string(b, fmt.tprintf("%02d", dt.second)); return 2}
if strings.has_prefix( if strings.has_prefix(
s, s,
"01", "01",
) {fmt.sbprintf(b, "%02d", dt.month); return 2} ) {strings.write_string(b, fmt.tprintf("%02d", dt.month)); return 2}
if strings.has_prefix(s, "03") {emit_hour_12(b, dt, pad = true); return 2} if strings.has_prefix(s, "03") {emit_hour_12(b, dt, pad = true); return 2}
if strings.has_prefix(s, "PM") {emit_am_pm(b, dt); return 2} if strings.has_prefix(s, "PM") {emit_am_pm(b, dt); return 2}
if strings.has_prefix(s, "pm") {emit_am_pm_lower(b, dt); return 2} if strings.has_prefix(s, "pm") {emit_am_pm_lower(b, dt); return 2}
if len(s) >= 1 { if len(s) >= 1 {
switch s[0] { switch s[0] {
case '2': case '2':
fmt.sbprintf(b, "%d", dt.day); return 1 strings.write_string(b, fmt.tprintf("%d", dt.day)); return 1
case '1': case '1':
fmt.sbprintf(b, "%d", dt.month); return 1 strings.write_string(b, fmt.tprintf("%d", dt.month)); return 1
case '4': case '4':
fmt.sbprintf(b, "%d", dt.minute); return 1 strings.write_string(b, fmt.tprintf("%d", dt.minute)); return 1
case '5': case '5':
fmt.sbprintf(b, "%d", dt.second); return 1 strings.write_string(b, fmt.tprintf("%d", dt.second)); return 1
case '3': case '3':
emit_hour_12(b, dt, pad = false); return 1 emit_hour_12(b, dt, pad = false); return 1
case: case:
+154
View File
@@ -0,0 +1,154 @@
#+test
package mustache
import "core:fmt"
import "core:testing"
/*
// --- Spec test 1: Interpolation ---
// A lambda's return value should be interpolated.
Interp_Data :: struct {
lambda: proc() -> string,
planet: string,
}
Interp_Data_Int :: struct {
lambda: proc() -> int,
planet: string,
}
@(test)
test_lambda_interpolation :: proc(t: ^testing.T) {
data := Interp_Data {
lambda = proc() -> string {return "world"},
}
tpl, _ := parse("Hello, {{lambda}}!", "<test>", context.temp_allocator)
result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "Hello, world!")
}
// --- Spec test 2: Interpolation - Expansion ---
// A lambda's return value should be parsed.
@(test)
test_lambda_interpolation_expansion :: proc(t: ^testing.T) {
data := Interp_Data {
lambda = proc() -> string {return "{{planet}}"},
planet = "world",
}
tpl, _ := parse("Hello, {{lambda}}!", "<test>", context.temp_allocator)
result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "Hello, world!")
}
// --- Spec test 4: Interpolation - Multiple Calls ---
// Interpolated lambdas should not be cached.
counter_lambda :: proc() -> int {
@(static) call_count := 0
call_count += 1
return call_count
}
@(test)
test_lambda_interpolation_multiple_calls :: proc(t: ^testing.T) {
data := Interp_Data_Int {
lambda = counter_lambda,
}
tpl, _ := parse("{{lambda}} == {{{lambda}}} == {{lambda}}", "<test>", context.temp_allocator)
result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "1 == 2 == 3")
}
// --- Spec test 5: Escaping ---
// Lambda results should be appropriately escaped.
@(test)
test_lambda_escaping :: proc(t: ^testing.T) {
data := Interp_Data {
lambda = proc() -> string {return ">"},
}
tpl, _ := parse("<{{lambda}}{{{lambda}}}", "<test>", context.temp_allocator)
result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "<&gt;>")
}
// --- Spec test 6: Section ---
// Lambdas used for sections should receive the raw section string.
Section_Data :: struct {
lambda: proc(_: string) -> string,
x: string,
planet: string,
}
@(test)
test_lambda_section :: proc(t: ^testing.T) {
data := Section_Data {
lambda = proc(text: string) -> string {
if text == "{{x}}" {return "yes"} else {return "no"}
},
x = "Error!",
}
tpl, _ := parse("<{{#lambda}}{{x}}{{/lambda}}>", "<test>", context.temp_allocator)
result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "<yes>")
}
// --- Spec test 7: Section - Expansion ---
// Lambdas used for sections should have their results parsed.
@(test)
test_lambda_section_expansion :: proc(t: ^testing.T) {
data := Section_Data {
lambda = proc(text: string) -> string {
return fmt.tprintf("%s{{{{planet}}}}%s", text, text)
},
planet = "Earth",
}
tpl, _ := parse("<{{#lambda}}-{{/lambda}}>", "<test>", context.temp_allocator)
result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "<-Earth->")
}
// --- Spec test 9: Section - Multiple Calls ---
// Lambdas used for sections should not be cached.
@(test)
test_lambda_section_multiple_calls :: proc(t: ^testing.T) {
data := Section_Data {
lambda = proc(text: string) -> string {
return fmt.tprintf("__%s__", text)
},
}
tpl, _ := parse(
"{{#lambda}}FILE{{/lambda}} != {{#lambda}}LINE{{/lambda}}",
"<test>",
context.temp_allocator,
)
result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "__FILE__ != __LINE__")
}
// --- Spec test 10: Inverted Section ---
// Lambdas used for inverted sections should be considered truthy.
Inverted_Data :: struct {
lambda: proc(_: string) -> bool,
static: string,
}
@(test)
test_lambda_inverted_section :: proc(t: ^testing.T) {
data := Inverted_Data {
lambda = proc(text: string) -> bool {return false},
static = "static",
}
tpl, _ := parse("<{{^lambda}}{{static}}{{/lambda}}>", "<test>", context.temp_allocator)
result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "<>")
}
*/
+101 -205
View File
@@ -5,8 +5,6 @@ import "core:fmt"
import "core:log" import "core:log"
import "core:strings" import "core:strings"
import diags "../diagnostics"
// A hypothetical maximum context depth. Trying to pass more than this many items // 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 // to render(tpl, data), or nesting templates further than this depth would be
// an error. // an error.
@@ -25,17 +23,12 @@ Context_Stack :: [dynamic]any
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)
Warning, // non-fatal: operation succeeded but user should know
} }
Error_Body :: struct { Error_Body :: struct {
msg: string, msg: string,
pos: int, pos: int,
kind: Error_Kind, kind: Error_Kind,
source: string,
path: string,
span: int,
hint: string,
} }
// Error is nil when no error occurred. // Error is nil when no error occurred.
@@ -54,22 +47,6 @@ body :: proc(err: Error) -> Error_Body {
} }
} }
// tag_error stamps an Error with the source/path of the template where it
// originated, so diagnostics point at the correct file (e.g. a partial).
tag_error :: proc(err: Error, tmpl: Template) -> Error {
if err == nil do return nil
b := body(err)
return Error_Body {
msg = b.msg,
pos = b.pos,
span = b.span,
hint = b.hint,
kind = b.kind,
source = tmpl.source,
path = tmpl.path,
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Node tree // Node tree
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -101,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
} }
@@ -156,22 +131,18 @@ parse :: proc(
tmpl: Template, tmpl: Template,
err: Error, err: Error,
) { ) {
tmpl.source = source
tmpl.path = path
tokens, terr := tokenize(source, tokens_allocator) tokens, terr := tokenize(source, tokens_allocator)
if terr != nil { if terr != nil {
return tmpl, terr return {}, terr
} }
tmpl.nodes, err = parse_tokens(tokens[:], source, allocator) tmpl.nodes, err = parse_tokens(tokens[:], source, allocator)
if err != nil { if err != nil {
delete(tmpl.nodes) delete(tmpl.nodes)
e := body(err) return {}, err
e.source = source
e.path = path
return {}, e
} }
tmpl.source = source
tmpl.path = path
deindent_blocks(tmpl.nodes[:], allocator) deindent_blocks(tmpl.nodes[:], allocator)
return tmpl, nil return tmpl, nil
} }
@@ -181,8 +152,6 @@ render :: proc(
data: any, data: any,
partials: map[string]Template = nil, partials: map[string]Template = nil,
allocator := context.allocator, allocator := context.allocator,
warnings: ^[dynamic]Error = nil,
tools: ^Tool_Registry = nil,
) -> ( ) -> (
result: string, result: string,
err: Error, err: Error,
@@ -208,15 +177,7 @@ render :: proc(
} }
all_nodes := tmpl.nodes[:] all_nodes := tmpl.nodes[:]
err = render_nodes( err = render_nodes(tmpl, all_nodes, &ctx, partials, &builder)
tmpl,
all_nodes,
&ctx,
partials,
&builder,
warnings = warnings,
tools = tools,
)
if err != nil { if err != nil {
return result, err return result, err
} }
@@ -246,22 +207,6 @@ 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,
@@ -280,22 +225,9 @@ parse_section :: proc(
pos^ += 1 pos^ += 1
case .Variable: case .Variable:
if len(strings.trim_space(tok.value)) == 0 {
return Error_Body {
msg = "Empty tag",
pos = tok.pos,
kind = .Syntax,
hint = "tag must contain a key",
}
}
idx := len(nodes) idx := len(nodes)
append(nodes, Node{kind = .Variable}) append(nodes, Node{kind = .Variable})
pipe_key, perr := parse_pipeline( pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos)
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),
@@ -307,22 +239,9 @@ parse_section :: proc(
pos^ += 1 pos^ += 1
case .Unescaped: case .Unescaped:
if len(strings.trim_space(tok.value)) == 0 {
return Error_Body {
msg = "Empty tag",
pos = tok.pos,
kind = .Syntax,
hint = "tag must contain a key",
}
}
idx := len(nodes) idx := len(nodes)
append(nodes, Node{kind = .Unescaped}) append(nodes, Node{kind = .Unescaped})
pipe_key, perr := parse_pipeline( pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos)
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),
@@ -342,12 +261,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( pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos)
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),
@@ -368,12 +282,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( pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos)
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),
@@ -465,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
@@ -492,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])
} }
@@ -596,8 +503,6 @@ render_template :: proc(
b: ^strings.Builder, b: ^strings.Builder,
blocks: map[string]Block_Override, blocks: map[string]Block_Override,
indent: string, indent: string,
warnings: ^[dynamic]Error = nil,
tools: ^Tool_Registry = nil,
) -> Error { ) -> Error {
if len(indent) > 0 { if len(indent) > 0 {
state := Indent_State { state := Indent_State {
@@ -605,9 +510,9 @@ render_template :: proc(
at_line_start = false, 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, warnings, tools) return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, &state)
} }
return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, nil, warnings, tools) return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, nil)
} }
write_indented :: proc( write_indented :: proc(
@@ -651,8 +556,6 @@ render_nodes :: proc(
b: ^strings.Builder, b: ^strings.Builder,
blocks: map[string]Block_Override = nil, blocks: map[string]Block_Override = nil,
indent_state: ^Indent_State = nil, indent_state: ^Indent_State = nil,
warnings: ^[dynamic]Error = nil,
tools: ^Tool_Registry = nil,
) -> Error { ) -> Error {
i := 0 i := 0
for i < len(nodes) { for i < len(nodes) {
@@ -676,21 +579,36 @@ render_nodes :: proc(
warn_unknown_key(current, ctx[:], node) warn_unknown_key(current, ctx[:], node)
} }
if len(node.filters) > 0 { if len(node.filters) > 0 {
transformed, perr := apply_pipeline( transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:])
val,
node.filters[:],
node.pos,
ctx[:],
current,
warnings,
tools,
)
if perr != nil { if perr != nil {
return tag_error(perr, current) return perr
} }
val = transformed val = transformed
} }
write_value(b, val, escape = true) if result_str, ok := call_interp_lambda(val); ok {
sub_tpl, perr := parse(
result_str,
fmt.tprintf("<lambda output from '%s'>", node.key),
context.temp_allocator,
context.temp_allocator,
)
if perr == nil {
temp: strings.Builder
strings.builder_init(&temp, context.temp_allocator)
render_nodes(
sub_tpl,
sub_tpl.nodes[:],
ctx,
partials,
&temp,
blocks,
nil,
) or_return
write_value(b, strings.to_string(temp), escape = true)
}
} else {
write_value(b, val, escape = true)
}
i += 1 i += 1
case .Unescaped: case .Unescaped:
@@ -703,21 +621,36 @@ render_nodes :: proc(
warn_unknown_key(current, ctx[:], node) warn_unknown_key(current, ctx[:], node)
} }
if len(node.filters) > 0 { if len(node.filters) > 0 {
transformed, perr := apply_pipeline( transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:])
val,
node.filters[:],
node.pos,
ctx[:],
current,
warnings,
tools,
)
if perr != nil { if perr != nil {
return tag_error(perr, current) return perr
} }
val = transformed val = transformed
} }
write_value(b, val, escape = false) if result_str, ok := call_interp_lambda(val); ok {
sub_tpl, perr := parse(
result_str,
fmt.tprintf("<lambda output from '%s'>", node.key),
context.temp_allocator,
context.temp_allocator,
)
if perr == nil {
temp: strings.Builder
strings.builder_init(&temp, context.temp_allocator)
render_nodes(
sub_tpl,
sub_tpl.nodes[:],
ctx,
partials,
&temp,
blocks,
nil,
) or_return
write_value(b, strings.to_string(temp), escape = false)
}
} else {
write_value(b, val, escape = false)
}
i += 1 i += 1
case .Section: case .Section:
@@ -726,21 +659,31 @@ render_nodes :: proc(
warn_unknown_key(current, ctx[:], node) warn_unknown_key(current, ctx[:], node)
} }
if len(node.filters) > 0 { if len(node.filters) > 0 {
transformed, perr := apply_pipeline( transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:])
val,
node.filters[:],
node.pos,
ctx[:],
current,
warnings,
tools,
)
if perr != nil { if perr != nil {
return tag_error(perr, current) return perr
} }
val = transformed val = transformed
} }
if is_truthy(val) { if result_str, ok := call_section_lambda(val, node.content); ok {
sub_tpl, perr := parse(
result_str,
fmt.tprintf("<lambda output from '%s'>", node.key),
context.temp_allocator,
context.temp_allocator,
)
if perr == nil {
render_nodes(
sub_tpl,
sub_tpl.nodes[:],
ctx,
partials,
b,
blocks,
nil,
) or_return
}
} 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 {
@@ -756,8 +699,6 @@ render_nodes :: proc(
b, b,
blocks, blocks,
indent_state, indent_state,
warnings,
tools,
) or_return ) or_return
} }
} else { } else {
@@ -771,7 +712,6 @@ render_nodes :: proc(
b, b,
blocks, blocks,
indent_state, indent_state,
warnings,
) or_return ) or_return
} }
} }
@@ -783,17 +723,9 @@ render_nodes :: proc(
warn_unknown_key(current, ctx[:], node) warn_unknown_key(current, ctx[:], node)
} }
if len(node.filters) > 0 { if len(node.filters) > 0 {
transformed, perr := apply_pipeline( transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:])
val,
node.filters[:],
node.pos,
ctx[:],
current,
warnings,
tools,
)
if perr != nil { if perr != nil {
return tag_error(perr, current) return perr
} }
val = transformed val = transformed
} }
@@ -806,8 +738,6 @@ render_nodes :: proc(
b, b,
blocks, blocks,
indent_state, indent_state,
warnings,
tools,
) or_return ) or_return
} }
i += 1 + len(node.children) i += 1 + len(node.children)
@@ -822,7 +752,7 @@ render_nodes :: proc(
if !found { if !found {
warn_missing_partial(current, partials, node, name) warn_missing_partial(current, partials, node, name)
} else { } else {
render_template(pt, ctx, partials, b, nil, node.indent, warnings, tools) or_return render_template(pt, ctx, partials, b, nil, node.indent) or_return
if indent_state != nil { if indent_state != nil {
indent_state.at_line_start = false indent_state.at_line_start = false
} }
@@ -857,8 +787,6 @@ render_nodes :: proc(
&temp, &temp,
content_blocks, content_blocks,
nil, nil,
warnings,
tools,
) or_return ) or_return
at_ls := true at_ls := true
write_indented(b, node.indent, strings.to_string(temp), &at_ls) write_indented(b, node.indent, strings.to_string(temp), &at_ls)
@@ -871,8 +799,6 @@ render_nodes :: proc(
b, b,
content_blocks, content_blocks,
indent_state, indent_state,
warnings,
tools,
) or_return ) or_return
} }
i += 1 + len(node.children) i += 1 + len(node.children)
@@ -885,16 +811,7 @@ render_nodes :: proc(
warn_missing_partial(current, partials, node, node.key) warn_missing_partial(current, partials, node, node.key)
} else { } else {
warn_unmatched_block_overrides(current, pt, parent_children) warn_unmatched_block_overrides(current, pt, parent_children)
render_template( render_template(pt, ctx, partials, b, merged, node.indent) or_return
pt,
ctx,
partials,
b,
merged,
node.indent,
warnings,
tools,
) or_return
if indent_state != nil { if indent_state != nil {
indent_state.at_line_start = false indent_state.at_line_start = false
} }
@@ -949,7 +866,7 @@ warn_unknown_key :: proc(current: Template, ctx: []any, node: Node) {
hint := "" hint := ""
if len(available) > 0 { if len(available) > 0 {
suggestion := diags.suggest_correction(available, missing) suggestion := suggest_correction(available, missing)
if suggestion != "" { if suggestion != "" {
hint = fmt.tprintf("did you mean '%s'?", suggestion) hint = fmt.tprintf("did you mean '%s'?", suggestion)
} }
@@ -959,14 +876,7 @@ warn_unknown_key :: proc(current: Template, ctx: []any, node: Node) {
if path == "" { if path == "" {
path = "<input>" path = "<input>"
} }
diag := format_tag_error( diag := format_error(path, current.source, node.pos, msg, hint, colorize = should_colorize())
path,
current.source,
node.pos,
msg,
hint,
colorize = diags.should_colorize(),
)
log.warnf("%s", diag) log.warnf("%s", diag)
} }
@@ -980,7 +890,7 @@ warn_missing_partial :: proc(
) { ) {
hint := "" hint := ""
available := collect_partial_names(partials) available := collect_partial_names(partials)
suggestion := diags.suggest_correction(available, name) suggestion := suggest_correction(available, name)
if suggestion != "" { if suggestion != "" {
hint = fmt.tprintf("did you mean '%s'?", suggestion) hint = fmt.tprintf("did you mean '%s'?", suggestion)
} }
@@ -989,14 +899,7 @@ warn_missing_partial :: proc(
if path == "" { if path == "" {
path = "<input>" path = "<input>"
} }
diag := format_tag_error( diag := format_error(path, current.source, node.pos, msg, hint, colorize = should_colorize())
path,
current.source,
node.pos,
msg,
hint,
colorize = diags.should_colorize(),
)
log.warnf("%s", diag) log.warnf("%s", diag)
} }
@@ -1032,7 +935,7 @@ warn_unmatched_block_overrides :: proc(
} }
hint := "" hint := ""
suggestion := diags.suggest_correction(available, child.key) suggestion := suggest_correction(available, child.key)
if suggestion != "" { if suggestion != "" {
hint = fmt.tprintf("did you mean '%s'?", suggestion) hint = fmt.tprintf("did you mean '%s'?", suggestion)
} }
@@ -1045,13 +948,13 @@ warn_unmatched_block_overrides :: proc(
if path == "" { if path == "" {
path = "<input>" path = "<input>"
} }
diag := format_tag_error( diag := format_error(
path, path,
current.source, current.source,
child.pos, child.pos,
msg, msg,
hint, hint,
colorize = diags.should_colorize(), colorize = should_colorize(),
) )
log.warnf("%s", diag) log.warnf("%s", diag)
} }
@@ -1075,13 +978,6 @@ warn_context_depth :: proc(current: Template, node: Node) {
if path == "" { if path == "" {
path = "<input>" path = "<input>"
} }
diag := format_tag_error( diag := format_error(path, current.source, node.pos, msg, "", colorize = should_colorize())
path,
current.source,
node.pos,
msg,
"",
colorize = diags.should_colorize(),
)
log.warnf("%s", diag) log.warnf("%s", diag)
} }
+4 -4
View File
@@ -1,6 +1,7 @@
#+test #+test
package mustache package mustache
import "core:log"
import "core:mem" import "core:mem"
import "core:strings" import "core:strings"
import "core:testing" import "core:testing"
@@ -127,9 +128,10 @@ leak_repeated_render :: proc(t: ^testing.T) {
// A deeply nested map pushes the context stack past MAX_CONTEXT_DEPTH (16). // A deeply nested map pushes the context stack past MAX_CONTEXT_DEPTH (16).
// The depth warning must be non-fatal: rendering still succeeds. // The depth warning must be non-fatal: rendering still succeeds.
/*
@(test) @(test)
test_context_depth_warns :: proc(t: ^testing.T) { test_context_depth_warns :: proc(t: ^testing.T) {
context.logger = log.nil_logger()
AMT :: MAX_CONTEXT_DEPTH + 2 AMT :: MAX_CONTEXT_DEPTH + 2
// Build AMT nested {x: {...}} levels; the innermost holds `leaf`. // Build AMT nested {x: {...}} levels; the innermost holds `leaf`.
@@ -155,9 +157,7 @@ test_context_depth_warns :: proc(t: ^testing.T) {
return return
} }
result, rerr := render_silenced(tmpl, data, allocator = context.temp_allocator) result, rerr := render(tmpl, data, allocator = context.temp_allocator)
testing.expect(t, rerr == nil, "depth warning must be non-fatal") testing.expect(t, rerr == nil, "depth warning must be non-fatal")
testing.expect_value(t, result, "found") testing.expect_value(t, result, "found")
} }
*/

Some files were not shown because too many files have changed in this diff Show More