mirror of
https://github.com/sbrow/thor.git
synced 2026-10-25 10:11:55 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5088f31a58 |
@@ -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://sbrow.github.io/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
|
||||
@@ -32,7 +32,6 @@ thor/
|
||||
├── treesitter/ # FFI types + grammar management (standalone package)
|
||||
├── markdown/ # Content transformation pipeline (imports ../treesitter)
|
||||
├── 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
|
||||
├── render.odin # Template rendering, Template_Context, sort_pages, RSS, sitemap
|
||||
├── menus.odin # Menu_Entry, DEFAULT_WEIGHT, build_menus, collect_auto_menus, merge_page_menus, parse_page_menus, parse_config_menus
|
||||
@@ -73,7 +72,7 @@ thor/
|
||||
| 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 |
|
||||
| `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, allocator)` — full pipeline (clones cmark output, frees original), `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. |
|
||||
| | `alerts.odin` | `inject_alerts` — GitHub alert blocks (`> [!NOTE]`) → styled blockquotes with semantic class names (`alert-note` etc.) |
|
||||
| | `emoji.odin` | `expand_emoji` — `:shortcode:` → unicode emoji |
|
||||
@@ -83,7 +82,6 @@ thor/
|
||||
| | `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 |
|
||||
| `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). |
|
||||
|
||||
Icon SVGs live as HTML partials in `layouts/partials/icons/` (home, github, rss, chevron_up, star).
|
||||
@@ -421,7 +419,7 @@ Spec-compliant implementation at `mustache/`. See `mustache/SPEC.md` for the imp
|
||||
| `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` |
|
||||
| `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. |
|
||||
| `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`. |
|
||||
| `spec_test.odin` | JSON spec test runner — loads `spec/specs/*.json`, runs each test case. Uses `log.nil_logger()` to suppress expected warnings. |
|
||||
| `pipes_test.odin` | Pipe filter tests (`group_by` + `format`) |
|
||||
|
||||
@@ -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
|
||||
@@ -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>©</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>©</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>©</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>©</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>©</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>©</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>©</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>©</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>©</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>©</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>©</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>©</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>©</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, '
|
||||
@@ -0,0 +1,231 @@
|
||||
name: Missing 2nd closing brace
|
||||
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: Somthing that points to the opening and lack of close.
|
||||
actual: |
|
||||
[ERROR] --- [383:load_partials()] unexpected {{/params.social}}
|
||||
--> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:6:5
|
||||
|
|
||||
4 | {{#params.social}}
|
||||
5 | <a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
6 | {{/params.social}}
|
||||
| ^^^^^^^^^^^^^^^^^^
|
||||
7 | </menu>
|
||||
8 | <p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
---
|
||||
name: Missing 2nd opening brace
|
||||
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: Somthing that shows the whole block, and points out the missing opening brace
|
||||
actual: Nothing. The page compiles with no warnings or errors
|
||||
---
|
||||
name: Invalid timezone
|
||||
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
|
||||
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>©</small> {{now | format "2006" }} {{params.author.name}}</p>
|
||||
</footer>
|
||||
expected: Mostly the same, but with rust style markers at the opening and close
|
||||
actual: |
|
||||
[ERROR] --- [383:load_partials()] expected {{/params.ocial}}, got {{/params.social}}
|
||||
--> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:6:5
|
||||
|
|
||||
4 | {{#params.ocial}}
|
||||
5 | <a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
6 | {{/params.social}}
|
||||
| ^^^^^^^^^^^^^^^^^^
|
||||
7 | </menu>
|
||||
8 | <p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
---
|
||||
name: Missing closing section
|
||||
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>©</small> {{now | format "2006" }} {{params.author.name}}</p>
|
||||
</footer>
|
||||
expected: "Missing closing tag '{{/params.social}}' ...opened <here> ... expected close <here>"
|
||||
actual: Nothing. The page compiles with no warnings or errors
|
||||
---
|
||||
name: empty tag
|
||||
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>©</small> {{}} {{params.author.name}}</p>
|
||||
</footer>
|
||||
expected: "Found an empty tag at <line:col>"
|
||||
actual: |
|
||||
[WARN ] --- [920:warn_unknown_key()] unknown key ''
|
||||
--> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:1:1
|
||||
|
|
||||
1 | <footer>
|
||||
| ^
|
||||
2 | <menu>
|
||||
3 | <a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
|
|
||||
---
|
||||
name: Missing pipe arguement
|
||||
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>©</small> {{now | group_by }} {{params.author.name}}</p>
|
||||
</footer>
|
||||
expected: "Missing filter argument <pointing to exact location>"
|
||||
actual: |
|
||||
[ERROR] --- [152:render_template()] group_by expects 1 argument, got 0
|
||||
--> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:1:1
|
||||
|
|
||||
1 | <footer>
|
||||
| ^
|
||||
2 | <menu>
|
||||
3 | <a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
|
|
||||
---
|
||||
name: Double dot access
|
||||
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>©</small> {{now | format "2006" }} {{params..name}}</p>
|
||||
</footer>
|
||||
expected: Error "Invalid access key"
|
||||
actual: Nothing. The page compiles with no warnings or errors
|
||||
---
|
||||
name: Too many opening braces
|
||||
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>©</small> {{{{now | format "2006" }}} {{params.author.name}}</p>
|
||||
</footer>
|
||||
expected: "Too many opening braces <point to location>"
|
||||
actual: |
|
||||
[WARN ] --- [920:warn_unknown_key()] unknown key '{now'
|
||||
--> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:1:1
|
||||
|
|
||||
1 | <footer>
|
||||
| ^ did you mean 'now'?
|
||||
2 | <menu>
|
||||
3 | <a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
|
|
||||
---
|
||||
name: Too many closing braces
|
||||
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>©</small> {{now | format "2006" }}}} {{params.author.name}}</p>
|
||||
</footer>
|
||||
expected: "Too many closing braces <Point to location>"
|
||||
actual: Nothing. The page compiles with no warnings or errors
|
||||
---
|
||||
name: Frontmatter missing closing quote on key
|
||||
input: |
|
||||
{
|
||||
"title: Docs"
|
||||
}
|
||||
|
||||
[TOC]
|
||||
|
||||
## Introduction
|
||||
expected: 'TODO:'
|
||||
actual: |
|
||||
[ERROR] --- [42:parse_frontmatter()] failed to parse frontmatter JSON: Expected_Colon_After_Key
|
||||
---
|
||||
name: Fronmatter missing opening quote on value
|
||||
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.
|
||||
expected: 'Your site has no index page! Please create an index.md or index.html file in ./content/'
|
||||
actual: Nothing. The site compiles with no warnings or errors
|
||||
---
|
||||
name: format pipe with no date format set
|
||||
expected: |
|
||||
Show where the error was triggered. Should also show the format that will be used
|
||||
actual: |
|
||||
[ERROR] --- [333:apply_format()] format pipe used but no date format configured (set date.format in thor.json) Default will be used
|
||||
@@ -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:
|
||||
1. weight (ascending)
|
||||
2. date (descending)
|
||||
3. linkTitle falling back to title (ascending)
|
||||
4. logical path (ascending)
|
||||
### Why Hugo does this
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
-130
@@ -1,130 +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.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -1,91 +1,162 @@
|
||||
# TODOs
|
||||
## High priority
|
||||
|
||||
## Priority Unclear (Evaluate Later)
|
||||
|
||||
- [ ] instead of `warnings: [dynamic]Error` we should use `warnings: [dynamic; 8]Error`
|
||||
- when limit reached, the template fails and stops rendering.
|
||||
|
||||
## General
|
||||
|
||||
Remember to polish existing features before moving on to new ones.
|
||||
|
||||
- [ ] Bake the default `layouts` into the binary at compile time via
|
||||
`#load_directory` so the binary is self-contained and doesn't read
|
||||
`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
|
||||
separately and mounted into the VFS with `data` set (not `fs_path`).
|
||||
- Removes the runtime dependency on `THOR_DEFAULTS_PATH` / `#directory` and
|
||||
the need to ship `defaults/` alongside the binary (see `flake.nix`).
|
||||
- [ ] `build` command alias of default
|
||||
- [ ] `new site` command to set up new project
|
||||
- [ ] Some render errors blank the entire page instead of scoping to the failing tag.
|
||||
- [ ] We must remove all mention of `posts` from the odin code.
|
||||
At present, "posts" are a user-level construct defined as pages in a
|
||||
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
|
||||
- Polish existing features before moving on to new ones.
|
||||
- [ ] Do mustache's whitespace rules actually suit us, or should we make our own?
|
||||
- [x] `{{>title}}` default partial? `{{site.title}} | {{ page.title }}`
|
||||
- [ ] implicit titles (Set when missing?)
|
||||
- [ ] create a default `head.html`.
|
||||
- [ ] html comments are rendered, except in minify mode.
|
||||
- [ ] 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.
|
||||
|
||||
## 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
|
||||
- wrong cwd?
|
||||
|
||||
## Diagnostics
|
||||
|
||||
All diagnostics (errors) should show
|
||||
|
||||
- *What* went wrong
|
||||
- *where* (in the file)
|
||||
- *where* (in the stack trace)
|
||||
- *how* you can fix it (if applicable)
|
||||
|
||||
- [ ] try to make file paths clickable links.
|
||||
- [ ] 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.
|
||||
- [ ] [aliases](https://gohugo.io/methods/page/aliases/#redirects)?
|
||||
- [ ] Improve diagnostics
|
||||
- [x] keep track of every error and don't report them more than once.
|
||||
- [ ] show parsed arg when -extension is unrecognized
|
||||
- [ ] also do typo detection?
|
||||
- [ ] `*` make sure the frontmatter parser has good diagnostics.
|
||||
- [ ] fix the diagnostics in [DIAGNOSTIC TODOS](./DIAGNOSTIC_TODOS.yaml)
|
||||
- [ ] only report format errors once.
|
||||
- [ ] only report missing partial errors once.
|
||||
- [ ] only report missing partiall errors once.
|
||||
- [ ] All Diagnostics should show:
|
||||
- [ ] *What* went wrong
|
||||
- [ ] *where* (in the file)
|
||||
- [ ] *where* (in the stack trace)
|
||||
- [ ] *how* you can fix it (if applicable)
|
||||
- [ ] Create a Location struct that somewhat matches Odin's [Source_Code_Location](https://pkg.odin-lang.org/base/runtime/#Source_Code_Location)?
|
||||
- Note that odin's version doesn't contain the stack trace.
|
||||
- [ ] show "stack traces" in template error diagnostics
|
||||
- [ ] better diagnostics for syntax errors in treesitter.
|
||||
- [ ] Ensure diagnostics for MAX_CONTEXT_DEPTH are good.
|
||||
- [ ] improve matching weights message.
|
||||
- [ ] Test menu diagnostics
|
||||
- [ ] Honestly, Test **all** diagnostics
|
||||
- [ ] Need to be careful about diagnostics across module boundaries.
|
||||
- we don't necessarily want to warn users about theme designers mistakes. (though perhaps we do)
|
||||
- [ ] consider reporting duplicate weights outside of menus
|
||||
- [ ] Extend `tag_error` to all render-time errors, not just pipe errors.
|
||||
Currently only pipe errors (4 sites in `render_nodes`) get stamped with
|
||||
the correct template source/path. Other render errors still use the
|
||||
content template's source/path, which can point at the wrong file.
|
||||
- [ ] try to make file paths clickable links.
|
||||
- [ ] centralize diagnostics to one place.
|
||||
- [ ] consider logging the number of times an error occurred.
|
||||
- [ ] Load grammars dynamically
|
||||
- [x] starred must be a param.
|
||||
- [ ] Documentation
|
||||
- [ ] talk about the context stack (and its limit).
|
||||
- [ ] highlight the differences in the way menus are handled.
|
||||
- [ ] consider sites with date based urls.
|
||||
- [x] Don't show annoying log output in tests.
|
||||
- [ ] improve home link customization.
|
||||
- [ ] currently an accessibility issue.
|
||||
- [x] support JSON5 in in frontmatter
|
||||
- [ ] Create a json schema file for `thor.json`.
|
||||
- [x] cleanup `#partial switch`es.
|
||||
- [ ] improve json diagnostics.
|
||||
- i.e. "Missing quotes around string", etc.
|
||||
- [ ] don't use bullshit "sub-tokens", add filters and pipes as proper tokens.
|
||||
- [ ] Ideas is now in the wrong spot. Date is wrong, and it is showing date
|
||||
when it shouldn't be.
|
||||
|
||||
## Analytics
|
||||
## Performance
|
||||
|
||||
Debug log stats:
|
||||
- [ ] 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
|
||||
|
||||
## Remove Privileged content
|
||||
|
||||
- [ ] `group_by` currently requires a computed `year` field on the page.
|
||||
- We should replace this with `{{ pages | group_by (date | "2006") }}` or similar
|
||||
|
||||
|
||||
## Memory Management
|
||||
|
||||
- [ ] 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.
|
||||
|
||||
## Markdown
|
||||
- [ ] Add overloads for every extension - accept ^strings.Builder.
|
||||
- [x] Add conventional (Hugo style) footnotes option.
|
||||
- [x] Add opt-in deflist support.
|
||||
- [x] Decide if lambdas actually provide any value.
|
||||
- [ ] add tables extension
|
||||
- [x] Table of contents support.
|
||||
- [ ] 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)
|
||||
- [ ] 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.
|
||||
|
||||
## 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
|
||||
- [ ] Menus
|
||||
- [ ] configure opt-out of automatic sections being added to menu.
|
||||
- [ ] nested menus (i.e. `parent` support)
|
||||
- [ ] get rid of the global variables in the `treesitter` package.
|
||||
- [ ] enforce heading structure.
|
||||
- [ ] Either frontmatter.title set, or 1 h1 tag at top, not both
|
||||
- [ ] No skipping.
|
||||
- [ ] 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
|
||||
@@ -93,8 +164,113 @@ Debug log stats:
|
||||
- [ ] number of blocks
|
||||
- [ ] enabled features / extensions
|
||||
- [ ] etc
|
||||
- [ ] Avoid `json.Value` / `json.Object` where possible.
|
||||
- [ ] make `parse` an overload of `parse_text/parse_inline` and `parse_file`, or something.
|
||||
- [x] Add page params
|
||||
- [ ] We must remove all mention of `posts` from the odin code.
|
||||
At present, "posts" are a user-level construct defined as pages in a
|
||||
particular collection.
|
||||
- [ ] running ./thor/thor still logs the debug message: using config /home/spencer/github.com/sbrow.github.io/thor.json
|
||||
- wrong cwd?
|
||||
- [ ] Clean up the default layouts
|
||||
- [ ] Menus
|
||||
- [x] Detailed frontmatter menu form ("menu": {"main": {"weight": 5}})
|
||||
- [ ] Menu active state (pre-compute is_active based on page.permalink prefix match)
|
||||
- [x] Page.weight field for general-purpose page ordering (menus, lists, related posts)
|
||||
- [ ] if no `html` tag detected in output, re-render output with base template
|
||||
(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.
|
||||
- [x] 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.
|
||||
- [ ] 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
|
||||
- [x] Free cmark HTML output (`body_html`) — cmark allocates via C malloc, not the arena, so it leaks per iteration in watch mode
|
||||
- [ ] manually pass `site_allocator` to `load_page`
|
||||
- [ ] 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.
|
||||
|
||||
## sbrow.github.io
|
||||
- [ ] The home file was accidentally deleted in commit 9a9f731
|
||||
- [ ] Ideas is now in the wrong spot. Date is wrong, and it is showing date
|
||||
when it shouldn't be.
|
||||
## Notes
|
||||
|
||||
from the [Hugo docs](https://gohugo.io/quick-reference/glossary/#default-sort-order)
|
||||
|
||||
default sort order
|
||||
: The default sort order for page collections, used when no other criteria are set, follows this priority:
|
||||
1. weight (ascending)
|
||||
2. date (descending)
|
||||
3. linkTitle falling back to title (ascending)
|
||||
4. logical path (ascending)
|
||||
|
||||
## 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 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
|
||||
|
||||
@@ -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
-14
@@ -2,17 +2,4 @@ package main
|
||||
|
||||
import "core:os"
|
||||
|
||||
// Path to the built-in default layouts, resolved at runtime.
|
||||
//
|
||||
// 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",
|
||||
)
|
||||
DEFAULTS_PATH :: #directory + os.Path_Separator_String + "defaults"
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -141,20 +141,13 @@
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
odin build . -o:speed -no-bounds-check \
|
||||
-define:THOR_DEFAULTS_PATH=$out/share/thor/defaults \
|
||||
-out:${pname}-keep
|
||||
odin build . -o:speed -no-bounds-check -out:${pname}-keep
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
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
|
||||
'';
|
||||
};
|
||||
@@ -169,7 +162,6 @@
|
||||
|
||||
# IDE
|
||||
unstable.helix
|
||||
claude-code
|
||||
typescript-language-server
|
||||
vscode-langservers-extracted
|
||||
]);
|
||||
|
||||
+2
-24
@@ -6,8 +6,6 @@ import "core:encoding/json"
|
||||
import "core:log"
|
||||
import "core:strings"
|
||||
|
||||
import diags "../diagnostics"
|
||||
|
||||
Extension :: enum {
|
||||
Emoji,
|
||||
Sidenotes,
|
||||
@@ -21,11 +19,6 @@ Extension :: enum {
|
||||
|
||||
DEFAULT_EXTENSIONS :: bit_set[Extension]{.Emoji, .Sidenotes, .Alerts, .HeadingIDs, .DefLists}
|
||||
|
||||
EXTENSION_NAMES :: []string{
|
||||
"emoji", "sidenotes", "alerts", "highlight", "sections",
|
||||
"heading_ids", "deflists", "footnotes",
|
||||
}
|
||||
|
||||
// Caller is responsible for freeing string
|
||||
process :: proc(
|
||||
body: string,
|
||||
@@ -76,13 +69,8 @@ parse_extension_list :: proc(s: string) -> (result: bit_set[Extension]) {
|
||||
e, ok := extension_from_name(name)
|
||||
if !ok {
|
||||
if name != "" {
|
||||
suggestion := diags.suggest_correction(EXTENSION_NAMES, name)
|
||||
if suggestion != "" {
|
||||
log.warnf("unknown extension '%s' (did you mean '%s'?)", name, suggestion)
|
||||
} else {
|
||||
log.warnf("unknown extension '%s'", name)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
result += {e}
|
||||
@@ -93,19 +81,9 @@ parse_extension_list :: proc(s: string) -> (result: bit_set[Extension]) {
|
||||
// Given a map[Extension]bool, apply it to ext.
|
||||
apply_extension_config :: proc(ext: ^bit_set[Extension], config: json.Object) {
|
||||
for name, val in config {
|
||||
// TODO: Silently discards invalid values.
|
||||
enabled := val.(json.Boolean) or_continue
|
||||
|
||||
e, ok := extension_from_name(name)
|
||||
if !ok {
|
||||
lower := strings.to_lower(name, context.temp_allocator)
|
||||
suggestion := diags.suggest_correction(EXTENSION_NAMES, lower)
|
||||
if suggestion != "" {
|
||||
log.warnf("unknown extension '%s' (did you mean '%s'?)", name, suggestion)
|
||||
} else {
|
||||
log.warnf("unknown extension '%s'", name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
e := extension_from_name(name) or_continue
|
||||
|
||||
if enabled {
|
||||
ext^ += {e}
|
||||
|
||||
@@ -100,6 +100,7 @@ test_menus_nil_input :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_menus_invalid_type :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
@@ -112,6 +113,7 @@ test_menus_invalid_type :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_menus_array_non_string :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
@@ -128,6 +130,7 @@ test_menus_array_non_string :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_menus_object_non_object_value :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
@@ -143,6 +146,7 @@ test_menus_object_non_object_value :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_menus_non_numeric_weight :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
@@ -157,6 +161,7 @@ test_menus_non_numeric_weight :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_menus_empty_title :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
|
||||
@@ -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(¤t, 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(¤t, 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(¤t, 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
|
||||
}
|
||||
}
|
||||
|
||||
+243
-38
@@ -1,6 +1,61 @@
|
||||
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
|
||||
// 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
|
||||
}
|
||||
|
||||
// format_tag_error is a mustache-aware wrapper around diags.format_error.
|
||||
// It uses context_extent to compute the caret underline for `{{ }}` tags,
|
||||
// falling back to a single-rune underline when pos is not inside a tag.
|
||||
// When `span > 0`, the caret extends from pos to pos + span.
|
||||
format_tag_error :: proc(
|
||||
// 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.
|
||||
// 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,
|
||||
source: string,
|
||||
pos: int,
|
||||
@@ -75,25 +187,135 @@ format_tag_error :: proc(
|
||||
colorize: bool = false,
|
||||
span: int = 0,
|
||||
) -> string {
|
||||
caret_start := pos
|
||||
caret_end := pos + 1
|
||||
if span > 0 {
|
||||
caret_end = pos + span
|
||||
} else {
|
||||
_, ts, te := context_extent(source, pos)
|
||||
caret_start = ts
|
||||
caret_end = te
|
||||
line, col := line_col(source, pos)
|
||||
total_lines := count_lines(source)
|
||||
|
||||
start_line := line - context_before
|
||||
if start_line < 1 {
|
||||
start_line = 1
|
||||
}
|
||||
return diags.format_error(
|
||||
path, source, pos, msg, hint,
|
||||
context_before, context_after,
|
||||
colorize,
|
||||
caret_start, caret_end,
|
||||
)
|
||||
end_line := line + context_after
|
||||
if end_line > total_lines {
|
||||
end_line = total_lines
|
||||
}
|
||||
|
||||
// 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)
|
||||
fmt.sbprintf(&sb, "%s:%d:%d\n", path, line, col)
|
||||
|
||||
// Top gutter line.
|
||||
write_gutter(&sb, width, faint, reset)
|
||||
|
||||
// Caret extent for the error line.
|
||||
line_start, token_start, token_end := context_extent(source, pos)
|
||||
if span > 0 {
|
||||
token_start = pos
|
||||
token_end = pos + span
|
||||
}
|
||||
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
|
||||
// template's path and source for context. Returns "" for nil errors.
|
||||
// If the error carries its own source/path (from tag_error), those are used
|
||||
// instead of the passed-in template — this ensures errors inside partials
|
||||
// point at the correct file.
|
||||
format_render_error :: proc(err: Error, tmpl: Template, colorize: bool = false) -> string {
|
||||
if err == nil {
|
||||
return ""
|
||||
@@ -104,22 +326,5 @@ format_render_error :: proc(err: Error, tmpl: Template, colorize: bool = false)
|
||||
if path == "" {
|
||||
path = "<input>"
|
||||
}
|
||||
|
||||
caret_start := b.pos
|
||||
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,
|
||||
)
|
||||
return format_error(path, source, b.pos, b.msg, hint = b.hint, colorize = colorize, span = b.span)
|
||||
}
|
||||
|
||||
+101
-288
@@ -1,22 +1,100 @@
|
||||
#+test
|
||||
package mustache
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:os"
|
||||
import "core:path/filepath"
|
||||
import "core:strings"
|
||||
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_format_error_basic :: proc(t: ^testing.T) {
|
||||
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'
|
||||
--> p.html:3:1
|
||||
|
|
||||
@@ -34,7 +112,7 @@ test_format_error_basic :: proc(t: ^testing.T) {
|
||||
@(test)
|
||||
test_format_error_with_hint :: proc(t: ^testing.T) {
|
||||
src := "line 1\nline 2\n{{titel}}\nline 4\nline 5"
|
||||
out := format_tag_error(
|
||||
out := format_error(
|
||||
"post.html",
|
||||
src,
|
||||
14,
|
||||
@@ -59,7 +137,7 @@ test_format_error_with_hint :: proc(t: ^testing.T) {
|
||||
@(test)
|
||||
test_format_error_no_hint_omits_trailing_space :: proc(t: ^testing.T) {
|
||||
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.
|
||||
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_format_error_first_line_only_after_context :: proc(t: ^testing.T) {
|
||||
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
|
||||
--> p.html:1:1
|
||||
|
|
||||
@@ -88,7 +166,7 @@ test_format_error_first_line_only_after_context :: proc(t: ^testing.T) {
|
||||
@(test)
|
||||
test_format_error_last_line_only_before_context :: proc(t: ^testing.T) {
|
||||
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
|
||||
--> p.html:5:1
|
||||
|
|
||||
@@ -104,7 +182,7 @@ test_format_error_last_line_only_before_context :: proc(t: ^testing.T) {
|
||||
@(test)
|
||||
test_format_error_short_source_clamped :: proc(t: ^testing.T) {
|
||||
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
|
||||
--> p.html:2:1
|
||||
|
|
||||
@@ -120,7 +198,7 @@ test_format_error_short_source_clamped :: proc(t: ^testing.T) {
|
||||
@(test)
|
||||
test_format_error_single_line_source :: proc(t: ^testing.T) {
|
||||
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
|
||||
--> 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.
|
||||
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.
|
||||
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, " |\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
|
||||
}
|
||||
|
||||
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, " |\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_caret_at_column_1 :: proc(t: ^testing.T) {
|
||||
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).
|
||||
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) {
|
||||
src := " {{bad}}"
|
||||
// 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.
|
||||
testing.expect(t, strings.contains(out, " | ^^^^^^^\n"), out)
|
||||
}
|
||||
@@ -195,7 +273,7 @@ test_caret_at_column_N :: proc(t: ^testing.T) {
|
||||
@(test)
|
||||
test_caret_width_matches_token :: proc(t: ^testing.T) {
|
||||
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.
|
||||
testing.expect(t, strings.contains(out, " | ^^^^^\n"), out)
|
||||
}
|
||||
@@ -207,7 +285,7 @@ test_caret_width_matches_token :: proc(t: ^testing.T) {
|
||||
@(test)
|
||||
test_context_before_zero :: proc(t: ^testing.T) {
|
||||
src := "l1\nl2\nl3\n{{bad}}\nl5\nl6"
|
||||
out := format_tag_error(
|
||||
out := format_error(
|
||||
"p.html",
|
||||
src,
|
||||
9,
|
||||
@@ -231,7 +309,7 @@ test_context_before_zero :: proc(t: ^testing.T) {
|
||||
@(test)
|
||||
test_context_after_zero :: proc(t: ^testing.T) {
|
||||
src := "l1\nl2\nl3\n{{bad}}\nl5\nl6"
|
||||
out := format_tag_error(
|
||||
out := format_error(
|
||||
"p.html",
|
||||
src,
|
||||
9,
|
||||
@@ -255,7 +333,7 @@ test_context_after_zero :: proc(t: ^testing.T) {
|
||||
@(test)
|
||||
test_context_both_zero :: proc(t: ^testing.T) {
|
||||
src := "l1\nl2\nl3\n{{bad}}\nl5\nl6"
|
||||
out := format_tag_error(
|
||||
out := format_error(
|
||||
"p.html",
|
||||
src,
|
||||
9,
|
||||
@@ -282,7 +360,7 @@ test_context_both_zero :: proc(t: ^testing.T) {
|
||||
@(test)
|
||||
test_gutter_pipes_align_with_source_pipe :: proc(t: ^testing.T) {
|
||||
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.
|
||||
// For width=1: source line is "N | ...", 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_arrow_points_at_pipe :: proc(t: ^testing.T) {
|
||||
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.
|
||||
// Pipe lines are " |" so "|" at col 2.
|
||||
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)
|
||||
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, "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),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>©</small> {{! now | format "2006" }} {{params.author.name | first -3 }}</p>
|
||||
</footer>
|
||||
+25
-126
@@ -5,8 +5,6 @@ import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:strings"
|
||||
|
||||
import diags "../diagnostics"
|
||||
|
||||
// A hypothetical maximum context depth. Trying to pass more than this many items
|
||||
// to render(tpl, data), or nesting templates further than this depth would be
|
||||
// an error.
|
||||
@@ -25,7 +23,6 @@ Context_Stack :: [dynamic]any
|
||||
Error_Kind :: enum {
|
||||
Syntax, // parse-time: malformed template
|
||||
Data, // render-time: template fine, data wrong (e.g. filter misuse)
|
||||
Warning, // non-fatal: operation succeeded but user should know
|
||||
}
|
||||
|
||||
Error_Body :: struct {
|
||||
@@ -156,22 +153,18 @@ parse :: proc(
|
||||
tmpl: Template,
|
||||
err: Error,
|
||||
) {
|
||||
tmpl.source = source
|
||||
tmpl.path = path
|
||||
|
||||
tokens, terr := tokenize(source, tokens_allocator)
|
||||
if terr != nil {
|
||||
return tmpl, terr
|
||||
return {}, terr
|
||||
}
|
||||
|
||||
tmpl.nodes, err = parse_tokens(tokens[:], source, allocator)
|
||||
if err != nil {
|
||||
delete(tmpl.nodes)
|
||||
e := body(err)
|
||||
e.source = source
|
||||
e.path = path
|
||||
return {}, e
|
||||
return {}, err
|
||||
}
|
||||
tmpl.source = source
|
||||
tmpl.path = path
|
||||
deindent_blocks(tmpl.nodes[:], allocator)
|
||||
return tmpl, nil
|
||||
}
|
||||
@@ -181,7 +174,6 @@ render :: proc(
|
||||
data: any,
|
||||
partials: map[string]Template = nil,
|
||||
allocator := context.allocator,
|
||||
warnings: ^[dynamic]Error = nil,
|
||||
) -> (
|
||||
result: string,
|
||||
err: Error,
|
||||
@@ -207,7 +199,7 @@ render :: proc(
|
||||
}
|
||||
|
||||
all_nodes := tmpl.nodes[:]
|
||||
err = render_nodes(tmpl, all_nodes, &ctx, partials, &builder, warnings = warnings)
|
||||
err = render_nodes(tmpl, all_nodes, &ctx, partials, &builder)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
@@ -271,22 +263,9 @@ parse_section :: proc(
|
||||
pos^ += 1
|
||||
|
||||
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)
|
||||
append(nodes, Node{kind = .Variable})
|
||||
pipe_key, perr := parse_pipeline(
|
||||
tok.value,
|
||||
&nodes[idx].filters,
|
||||
tok.pos,
|
||||
tag_content_base(source, tok.pos),
|
||||
)
|
||||
pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos, tag_content_base(source, tok.pos))
|
||||
if perr != nil {
|
||||
return Error_Body {
|
||||
msg = fmt.tprintf("pipe parse error in '{{{{%s}}}}': %v", tok.value, perr),
|
||||
@@ -298,22 +277,9 @@ parse_section :: proc(
|
||||
pos^ += 1
|
||||
|
||||
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)
|
||||
append(nodes, Node{kind = .Unescaped})
|
||||
pipe_key, perr := parse_pipeline(
|
||||
tok.value,
|
||||
&nodes[idx].filters,
|
||||
tok.pos,
|
||||
tag_content_base(source, tok.pos),
|
||||
)
|
||||
pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos, tag_content_base(source, tok.pos))
|
||||
if perr != nil {
|
||||
return Error_Body {
|
||||
msg = fmt.tprintf("pipe parse error in '{{{{&%s}}}}': %v", tok.value, perr),
|
||||
@@ -333,12 +299,7 @@ parse_section :: proc(
|
||||
content_start := 0
|
||||
if pos^ < len(tokens) {content_start = tokens[pos^].pos}
|
||||
append(nodes, Node{kind = .Section, pos = tok.pos})
|
||||
pipe_key, perr := parse_pipeline(
|
||||
tok.value,
|
||||
&nodes[idx].filters,
|
||||
tok.pos,
|
||||
tag_content_base(source, tok.pos),
|
||||
)
|
||||
pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos, tag_content_base(source, tok.pos))
|
||||
if perr != nil {
|
||||
return Error_Body {
|
||||
msg = fmt.tprintf("pipe parse error in '{{{{#%s}}}}': %v", tok.value, perr),
|
||||
@@ -359,12 +320,7 @@ parse_section :: proc(
|
||||
content_start := 0
|
||||
if pos^ < len(tokens) {content_start = tokens[pos^].pos}
|
||||
append(nodes, Node{kind = .Inverted, pos = tok.pos})
|
||||
pipe_key, perr := parse_pipeline(
|
||||
tok.value,
|
||||
&nodes[idx].filters,
|
||||
tok.pos,
|
||||
tag_content_base(source, tok.pos),
|
||||
)
|
||||
pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos, tag_content_base(source, tok.pos))
|
||||
if perr != nil {
|
||||
return Error_Body {
|
||||
msg = fmt.tprintf("pipe parse error in '{{{{^%s}}}}': %v", tok.value, perr),
|
||||
@@ -587,7 +543,6 @@ render_template :: proc(
|
||||
b: ^strings.Builder,
|
||||
blocks: map[string]Block_Override,
|
||||
indent: string,
|
||||
warnings: ^[dynamic]Error = nil,
|
||||
) -> Error {
|
||||
if len(indent) > 0 {
|
||||
state := Indent_State {
|
||||
@@ -595,9 +550,9 @@ render_template :: proc(
|
||||
at_line_start = false,
|
||||
}
|
||||
strings.write_string(b, indent) // first line always gets indent
|
||||
return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, &state, warnings)
|
||||
return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, &state)
|
||||
}
|
||||
return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, nil, warnings)
|
||||
return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, nil)
|
||||
}
|
||||
|
||||
write_indented :: proc(
|
||||
@@ -641,7 +596,6 @@ render_nodes :: proc(
|
||||
b: ^strings.Builder,
|
||||
blocks: map[string]Block_Override = nil,
|
||||
indent_state: ^Indent_State = nil,
|
||||
warnings: ^[dynamic]Error = nil,
|
||||
) -> Error {
|
||||
i := 0
|
||||
for i < len(nodes) {
|
||||
@@ -665,14 +619,7 @@ render_nodes :: proc(
|
||||
warn_unknown_key(current, ctx[:], node)
|
||||
}
|
||||
if len(node.filters) > 0 {
|
||||
transformed, perr := apply_pipeline(
|
||||
val,
|
||||
node.filters[:],
|
||||
node.pos,
|
||||
ctx[:],
|
||||
current,
|
||||
warnings,
|
||||
)
|
||||
transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:])
|
||||
if perr != nil {
|
||||
return tag_error(perr, current)
|
||||
}
|
||||
@@ -691,14 +638,7 @@ render_nodes :: proc(
|
||||
warn_unknown_key(current, ctx[:], node)
|
||||
}
|
||||
if len(node.filters) > 0 {
|
||||
transformed, perr := apply_pipeline(
|
||||
val,
|
||||
node.filters[:],
|
||||
node.pos,
|
||||
ctx[:],
|
||||
current,
|
||||
warnings,
|
||||
)
|
||||
transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:])
|
||||
if perr != nil {
|
||||
return tag_error(perr, current)
|
||||
}
|
||||
@@ -713,14 +653,7 @@ render_nodes :: proc(
|
||||
warn_unknown_key(current, ctx[:], node)
|
||||
}
|
||||
if len(node.filters) > 0 {
|
||||
transformed, perr := apply_pipeline(
|
||||
val,
|
||||
node.filters[:],
|
||||
node.pos,
|
||||
ctx[:],
|
||||
current,
|
||||
warnings,
|
||||
)
|
||||
transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:])
|
||||
if perr != nil {
|
||||
return tag_error(perr, current)
|
||||
}
|
||||
@@ -742,7 +675,6 @@ render_nodes :: proc(
|
||||
b,
|
||||
blocks,
|
||||
indent_state,
|
||||
warnings,
|
||||
) or_return
|
||||
}
|
||||
} else {
|
||||
@@ -756,7 +688,6 @@ render_nodes :: proc(
|
||||
b,
|
||||
blocks,
|
||||
indent_state,
|
||||
warnings,
|
||||
) or_return
|
||||
}
|
||||
}
|
||||
@@ -768,14 +699,7 @@ render_nodes :: proc(
|
||||
warn_unknown_key(current, ctx[:], node)
|
||||
}
|
||||
if len(node.filters) > 0 {
|
||||
transformed, perr := apply_pipeline(
|
||||
val,
|
||||
node.filters[:],
|
||||
node.pos,
|
||||
ctx[:],
|
||||
current,
|
||||
warnings,
|
||||
)
|
||||
transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:])
|
||||
if perr != nil {
|
||||
return tag_error(perr, current)
|
||||
}
|
||||
@@ -790,7 +714,6 @@ render_nodes :: proc(
|
||||
b,
|
||||
blocks,
|
||||
indent_state,
|
||||
warnings,
|
||||
) or_return
|
||||
}
|
||||
i += 1 + len(node.children)
|
||||
@@ -805,7 +728,7 @@ render_nodes :: proc(
|
||||
if !found {
|
||||
warn_missing_partial(current, partials, node, name)
|
||||
} else {
|
||||
render_template(pt, ctx, partials, b, nil, node.indent, warnings) or_return
|
||||
render_template(pt, ctx, partials, b, nil, node.indent) or_return
|
||||
if indent_state != nil {
|
||||
indent_state.at_line_start = false
|
||||
}
|
||||
@@ -840,7 +763,6 @@ render_nodes :: proc(
|
||||
&temp,
|
||||
content_blocks,
|
||||
nil,
|
||||
warnings,
|
||||
) or_return
|
||||
at_ls := true
|
||||
write_indented(b, node.indent, strings.to_string(temp), &at_ls)
|
||||
@@ -853,7 +775,6 @@ render_nodes :: proc(
|
||||
b,
|
||||
content_blocks,
|
||||
indent_state,
|
||||
warnings,
|
||||
) or_return
|
||||
}
|
||||
i += 1 + len(node.children)
|
||||
@@ -866,7 +787,7 @@ render_nodes :: proc(
|
||||
warn_missing_partial(current, partials, node, node.key)
|
||||
} else {
|
||||
warn_unmatched_block_overrides(current, pt, parent_children)
|
||||
render_template(pt, ctx, partials, b, merged, node.indent, warnings) or_return
|
||||
render_template(pt, ctx, partials, b, merged, node.indent) or_return
|
||||
if indent_state != nil {
|
||||
indent_state.at_line_start = false
|
||||
}
|
||||
@@ -921,7 +842,7 @@ warn_unknown_key :: proc(current: Template, ctx: []any, node: Node) {
|
||||
|
||||
hint := ""
|
||||
if len(available) > 0 {
|
||||
suggestion := diags.suggest_correction(available, missing)
|
||||
suggestion := suggest_correction(available, missing)
|
||||
if suggestion != "" {
|
||||
hint = fmt.tprintf("did you mean '%s'?", suggestion)
|
||||
}
|
||||
@@ -931,14 +852,7 @@ warn_unknown_key :: proc(current: Template, ctx: []any, node: Node) {
|
||||
if path == "" {
|
||||
path = "<input>"
|
||||
}
|
||||
diag := format_tag_error(
|
||||
path,
|
||||
current.source,
|
||||
node.pos,
|
||||
msg,
|
||||
hint,
|
||||
colorize = diags.should_colorize(),
|
||||
)
|
||||
diag := format_error(path, current.source, node.pos, msg, hint, colorize = should_colorize())
|
||||
log.warnf("%s", diag)
|
||||
}
|
||||
|
||||
@@ -952,7 +866,7 @@ warn_missing_partial :: proc(
|
||||
) {
|
||||
hint := ""
|
||||
available := collect_partial_names(partials)
|
||||
suggestion := diags.suggest_correction(available, name)
|
||||
suggestion := suggest_correction(available, name)
|
||||
if suggestion != "" {
|
||||
hint = fmt.tprintf("did you mean '%s'?", suggestion)
|
||||
}
|
||||
@@ -961,14 +875,7 @@ warn_missing_partial :: proc(
|
||||
if path == "" {
|
||||
path = "<input>"
|
||||
}
|
||||
diag := format_tag_error(
|
||||
path,
|
||||
current.source,
|
||||
node.pos,
|
||||
msg,
|
||||
hint,
|
||||
colorize = diags.should_colorize(),
|
||||
)
|
||||
diag := format_error(path, current.source, node.pos, msg, hint, colorize = should_colorize())
|
||||
log.warnf("%s", diag)
|
||||
}
|
||||
|
||||
@@ -1004,7 +911,7 @@ warn_unmatched_block_overrides :: proc(
|
||||
}
|
||||
|
||||
hint := ""
|
||||
suggestion := diags.suggest_correction(available, child.key)
|
||||
suggestion := suggest_correction(available, child.key)
|
||||
if suggestion != "" {
|
||||
hint = fmt.tprintf("did you mean '%s'?", suggestion)
|
||||
}
|
||||
@@ -1017,13 +924,13 @@ warn_unmatched_block_overrides :: proc(
|
||||
if path == "" {
|
||||
path = "<input>"
|
||||
}
|
||||
diag := format_tag_error(
|
||||
diag := format_error(
|
||||
path,
|
||||
current.source,
|
||||
child.pos,
|
||||
msg,
|
||||
hint,
|
||||
colorize = diags.should_colorize(),
|
||||
colorize = should_colorize(),
|
||||
)
|
||||
log.warnf("%s", diag)
|
||||
}
|
||||
@@ -1047,14 +954,6 @@ warn_context_depth :: proc(current: Template, node: Node) {
|
||||
if path == "" {
|
||||
path = "<input>"
|
||||
}
|
||||
diag := format_tag_error(
|
||||
path,
|
||||
current.source,
|
||||
node.pos,
|
||||
msg,
|
||||
"",
|
||||
colorize = diags.should_colorize(),
|
||||
)
|
||||
diag := format_error(path, current.source, node.pos, msg, "", colorize = should_colorize())
|
||||
log.warnf("%s", diag)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#+test
|
||||
package mustache
|
||||
|
||||
import "core:log"
|
||||
import "core:mem"
|
||||
import "core:strings"
|
||||
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).
|
||||
// The depth warning must be non-fatal: rendering still succeeds.
|
||||
/*
|
||||
@(test)
|
||||
test_context_depth_warns :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
|
||||
AMT :: MAX_CONTEXT_DEPTH + 2
|
||||
|
||||
// Build AMT nested {x: {...}} levels; the innermost holds `leaf`.
|
||||
@@ -155,9 +157,7 @@ test_context_depth_warns :: proc(t: ^testing.T) {
|
||||
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_value(t, result, "found")
|
||||
}
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package mustache
|
||||
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
|
||||
load_partials :: proc(
|
||||
dir: string,
|
||||
base: string = "",
|
||||
allocator := context.allocator,
|
||||
) -> map[string]Template {
|
||||
partials := make(map[string]Template, allocator)
|
||||
|
||||
entries, err := os.read_all_directory_by_path(dir, context.temp_allocator)
|
||||
if err != nil {
|
||||
log.warnf("cannot read partials dir %s: %v", dir, err)
|
||||
return partials
|
||||
}
|
||||
defer os.file_info_slice_delete(entries, context.temp_allocator)
|
||||
|
||||
for entry in entries {
|
||||
if entry.type == .Regular {
|
||||
if !strings.has_suffix(entry.name, ".html") do continue
|
||||
stripped := entry.name[:len(entry.name) - len(".html")]
|
||||
key := fmt.tprintf("%s%s", base, stripped)
|
||||
data, ferr := os.read_entire_file_from_path(entry.fullpath, allocator)
|
||||
if ferr != nil do continue
|
||||
source := string(data)
|
||||
path := strings.clone(entry.fullpath, allocator)
|
||||
tmpl, perr := parse(source, path, allocator = allocator)
|
||||
if perr != nil {
|
||||
b := body(perr)
|
||||
log.warnf("failed to parse partial %s: %s", entry.fullpath, b.msg)
|
||||
continue
|
||||
}
|
||||
partials[key] = tmpl
|
||||
} else if entry.type == .Directory {
|
||||
sub_base := fmt.tprintf("%s%s/", base, entry.name)
|
||||
sub := load_partials(entry.fullpath, sub_base, allocator)
|
||||
for k, v in sub {
|
||||
partials[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return partials
|
||||
}
|
||||
+13
-162
@@ -4,10 +4,8 @@ import "base:runtime"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:reflect"
|
||||
import "core:strconv"
|
||||
import "core:strings"
|
||||
|
||||
import diags "../diagnostics"
|
||||
import "core:time"
|
||||
import "core:time/datetime"
|
||||
|
||||
// MAX_PIPES was chosen arbitrarily. It holds no performance or logical
|
||||
@@ -22,8 +20,6 @@ DEFAULT_DATE_FORMAT :: "2 Jan 2006"
|
||||
Pipe_Op :: enum {
|
||||
Format,
|
||||
Group_By,
|
||||
First,
|
||||
Last,
|
||||
}
|
||||
|
||||
// Pipe op names are derived from the enum via reflection (lowercased).
|
||||
@@ -63,7 +59,6 @@ Pipe_Filter :: struct {
|
||||
op: string,
|
||||
args: [dynamic; MAX_PIPE_ARGS]string,
|
||||
op_pos: int,
|
||||
end_pos: int,
|
||||
}
|
||||
|
||||
Group :: struct {
|
||||
@@ -136,7 +131,11 @@ parse_pipeline :: proc(
|
||||
pipe_count := strings.count(content, "|")
|
||||
if pipe_count > MAX_PIPES {
|
||||
return "", Error_Body {
|
||||
msg = fmt.tprintf("pipe expression has %d filters, max is %d", pipe_count, MAX_PIPES),
|
||||
msg = fmt.tprintf(
|
||||
"pipe expression has %d filters, max is %d",
|
||||
pipe_count,
|
||||
MAX_PIPES,
|
||||
),
|
||||
pos = pos,
|
||||
kind = .Syntax,
|
||||
}
|
||||
@@ -200,7 +199,6 @@ parse_pipeline :: proc(
|
||||
filter := Pipe_Filter {
|
||||
op = tokens[0],
|
||||
op_pos = content_base + op_offset_in_content,
|
||||
end_pos = content_base + op_offset_in_content + len(seg),
|
||||
}
|
||||
for j in 1 ..< len(tokens) {
|
||||
append(&filter.args, tokens[j])
|
||||
@@ -222,27 +220,14 @@ apply_pipeline :: proc(
|
||||
filters: []Pipe_Filter,
|
||||
pos: int,
|
||||
ctx: []any,
|
||||
tmpl: Template,
|
||||
warnings: ^[dynamic]Error = nil,
|
||||
) -> (
|
||||
current: any,
|
||||
err: Error,
|
||||
) {
|
||||
current = value
|
||||
for &filter in filters {
|
||||
result, ferr := apply_filter(current, &filter, pos, ctx, tmpl)
|
||||
if ferr != nil {
|
||||
b := body(ferr)
|
||||
if b.kind != .Warning {
|
||||
return result, ferr
|
||||
}
|
||||
if warnings != nil {
|
||||
append(warnings, ferr)
|
||||
} else {
|
||||
log.warnf("%s", format_render_error(ferr, tmpl))
|
||||
}
|
||||
}
|
||||
current = result
|
||||
current = apply_filter(current, &filter, pos, ctx) or_return
|
||||
log.debugf("applied: filter=%v before=%s after=%s pos=%d", filter, value, current, pos)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -271,20 +256,11 @@ resolve_format_string :: proc(name: string, ctx: []any, pos: int) -> (string, Er
|
||||
return str, nil
|
||||
}
|
||||
|
||||
apply_filter :: proc(
|
||||
value: any,
|
||||
filter: ^Pipe_Filter,
|
||||
pos: int,
|
||||
ctx: []any,
|
||||
tmpl: Template,
|
||||
) -> (
|
||||
any,
|
||||
Error,
|
||||
) {
|
||||
apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) -> (any, Error) {
|
||||
op, ok := pipe_op_from_string(filter.op)
|
||||
if !ok {
|
||||
hint := ""
|
||||
suggestion := diags.suggest_correction(pipe_op_candidates(), filter.op)
|
||||
suggestion := suggest_correction(pipe_op_candidates(), filter.op)
|
||||
if suggestion != "" {
|
||||
hint = fmt.tprintf("did you mean '%s'?", suggestion)
|
||||
}
|
||||
@@ -299,8 +275,7 @@ apply_filter :: proc(
|
||||
|
||||
switch op {
|
||||
case .Group_By:
|
||||
span := filter.end_pos - filter.op_pos
|
||||
return apply_group_by(value, filter.args[:], filter.op_pos, span)
|
||||
return apply_group_by(value, filter.args[:], pos)
|
||||
case .Format:
|
||||
str, ok := reflect.as_string(value)
|
||||
if !ok {
|
||||
@@ -339,12 +314,6 @@ apply_filter :: proc(
|
||||
} else {
|
||||
return any{new_clone(str2, context.temp_allocator), typeid_of(string)}, nil
|
||||
}
|
||||
case .First:
|
||||
span := filter.end_pos - filter.op_pos
|
||||
return take(value, filter.args[:], true, tmpl, filter.op_pos, span)
|
||||
case .Last:
|
||||
span := filter.end_pos - filter.op_pos
|
||||
return take(value, filter.args[:], false, tmpl, filter.op_pos, span)
|
||||
}
|
||||
return {}, nil
|
||||
}
|
||||
@@ -386,27 +355,19 @@ apply_format :: proc(
|
||||
}
|
||||
|
||||
// Groups preserve first-appearance order from the input list.
|
||||
apply_group_by :: proc(value: any, args: []string, pos: int, span: int) -> (result: any, err: Error) {
|
||||
apply_group_by :: proc(value: any, args: []string, pos: int) -> (result: any, err: Error) {
|
||||
if len(args) != 1 {
|
||||
hint := ""
|
||||
if len(args) == 0 {
|
||||
hint = "missing argument"
|
||||
} else {
|
||||
hint = "too many arguments"
|
||||
}
|
||||
return nil, Error_Body {
|
||||
msg = fmt.tprintf("group_by expects 1 argument, got %d", len(args)),
|
||||
pos = pos,
|
||||
span = span,
|
||||
kind = .Data,
|
||||
hint = hint,
|
||||
}
|
||||
}
|
||||
field := args[0]
|
||||
|
||||
elem_info, count, data := list_info(value)
|
||||
if elem_info == nil {
|
||||
return nil, Error_Body{msg = "group_by expects a list", pos = pos, span = span, kind = .Data}
|
||||
return nil, Error_Body{msg = "group_by expects a list", pos = pos, kind = .Data}
|
||||
}
|
||||
|
||||
groups := make([dynamic]Group, 0, 8, context.temp_allocator)
|
||||
@@ -448,113 +409,3 @@ apply_group_by :: proc(value: any, args: []string, pos: int, span: int) -> (resu
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
take :: proc(
|
||||
value: any,
|
||||
args: []string,
|
||||
from_start: bool,
|
||||
tmpl: Template,
|
||||
op_pos: int,
|
||||
span: int,
|
||||
) -> (
|
||||
any,
|
||||
Error,
|
||||
) {
|
||||
unwrapped, _ := base_value(value)
|
||||
elem_info, count, data := list_info(unwrapped)
|
||||
is_list := elem_info != nil
|
||||
from_start := from_start
|
||||
warning: Error = nil
|
||||
|
||||
str: string
|
||||
if !is_list {
|
||||
s, is_str := reflect.as_string(unwrapped)
|
||||
if !is_str {
|
||||
return nil, Error_Body {
|
||||
msg = "requires a list or string",
|
||||
pos = op_pos,
|
||||
span = span,
|
||||
kind = .Data,
|
||||
source = tmpl.source,
|
||||
path = tmpl.path,
|
||||
}
|
||||
}
|
||||
str = s
|
||||
count = len(str)
|
||||
}
|
||||
|
||||
n := 1
|
||||
if !is_list && len(args) == 0 {
|
||||
warning = Error_Body {
|
||||
msg = "defaulting to n=1 for string",
|
||||
pos = op_pos,
|
||||
span = span,
|
||||
kind = .Warning,
|
||||
source = tmpl.source,
|
||||
path = tmpl.path,
|
||||
hint = "consider specifying n explicitly",
|
||||
}
|
||||
}
|
||||
if len(args) > 0 {
|
||||
parsed, ok := strconv.parse_int(args[0])
|
||||
if !ok {
|
||||
return nil, Error_Body {
|
||||
msg = fmt.tprintf("invalid argument '%s'", args[0]),
|
||||
pos = op_pos,
|
||||
span = span,
|
||||
kind = .Data,
|
||||
source = tmpl.source,
|
||||
path = tmpl.path,
|
||||
}
|
||||
}
|
||||
n = parsed
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
return nil, Error_Body {
|
||||
msg = "argument must not be zero",
|
||||
pos = op_pos,
|
||||
span = span,
|
||||
kind = .Data,
|
||||
source = tmpl.source,
|
||||
path = tmpl.path,
|
||||
}
|
||||
}
|
||||
|
||||
if n < 0 {
|
||||
op_name := from_start ? "first" : "last"
|
||||
alt_name := from_start ? "last" : "first"
|
||||
warning = Error_Body {
|
||||
msg = fmt.tprintf("%s %d is equivalent to %s %d", op_name, n, alt_name, 0 - n),
|
||||
pos = op_pos,
|
||||
span = span,
|
||||
kind = .Warning,
|
||||
source = tmpl.source,
|
||||
path = tmpl.path,
|
||||
hint = "consider using the clearer form",
|
||||
}
|
||||
from_start = !from_start
|
||||
n = 0 - n
|
||||
}
|
||||
|
||||
start: int
|
||||
end: int
|
||||
if from_start {
|
||||
start = 0
|
||||
end = min(count, n)
|
||||
} else {
|
||||
start = max(0, count - n)
|
||||
end = count
|
||||
}
|
||||
|
||||
if is_list {
|
||||
result := make([dynamic]any, 0, max(0, end - start), context.temp_allocator)
|
||||
for j in start ..< end {
|
||||
append(&result, extract_list_element(elem_info, data, j))
|
||||
}
|
||||
return any{new_clone(result, context.temp_allocator), typeid_of([dynamic]any)}, warning
|
||||
} else {
|
||||
return any{new_clone(str[start:end], context.temp_allocator), typeid_of(string)}, warning
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-145
@@ -1,4 +1,3 @@
|
||||
#+feature dynamic-literals
|
||||
#+test
|
||||
package mustache
|
||||
|
||||
@@ -568,11 +567,8 @@ test_format_mst_no_offset_no_timezone :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_unknown_pipe_op_suggestion :: proc(t: ^testing.T) {
|
||||
filter := Pipe_Filter {
|
||||
op = "formats",
|
||||
op_pos = 0,
|
||||
}
|
||||
_, err := apply_filter("2026-01-15", &filter, 0, nil, {})
|
||||
filter := Pipe_Filter{op = "formats", op_pos = 0}
|
||||
_, err := apply_filter("2026-01-15", &filter, 0, nil)
|
||||
testing.expect(t, err != nil, "should error on unknown op")
|
||||
b := body(err)
|
||||
testing.expect(t, strings.contains(b.hint, "format"), "hint should suggest 'format'")
|
||||
@@ -581,146 +577,9 @@ test_unknown_pipe_op_suggestion :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_unknown_pipe_op_no_suggestion :: proc(t: ^testing.T) {
|
||||
filter := Pipe_Filter {
|
||||
op = "xyz",
|
||||
op_pos = 0,
|
||||
}
|
||||
_, err := apply_filter("2026-01-15", &filter, 0, nil, {})
|
||||
filter := Pipe_Filter{op = "xyz", op_pos = 0}
|
||||
_, err := apply_filter("2026-01-15", &filter, 0, nil)
|
||||
testing.expect(t, err != nil, "should error on unknown op")
|
||||
b := body(err)
|
||||
testing.expect(t, b.hint == "", "no suggestion expected for 'xyz'")
|
||||
}
|
||||
|
||||
// Group 1: Lists
|
||||
|
||||
First_Last_List_Data: map[string][5]string = {
|
||||
"items" = [5]string{"foo", "bar", "bat", "qux", "quz"},
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_first_list_no_args_equals_first_one :: proc(t: ^testing.T) {
|
||||
tpl_a, _ := parse(
|
||||
"{{#items | first}}<li>{{.}}</li>{{/items}}",
|
||||
"<test>",
|
||||
allocator = context.temp_allocator,
|
||||
)
|
||||
result_a, _ := render(tpl_a, First_Last_List_Data, {}, context.temp_allocator)
|
||||
|
||||
tpl_b, _ := parse(
|
||||
"{{#items | first 1}}<li>{{.}}</li>{{/items}}",
|
||||
"<test>",
|
||||
allocator = context.temp_allocator,
|
||||
)
|
||||
result_b, _ := render(tpl_b, First_Last_List_Data, {}, context.temp_allocator)
|
||||
|
||||
testing.expect_value(t, result_a, "<li>foo</li>")
|
||||
testing.expect_value(t, result_a, result_b)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_first_list_n :: proc(t: ^testing.T) {
|
||||
data := First_Last_List_Data
|
||||
tpl, _ := parse(
|
||||
"{{#items | first 3}}<li>{{.}}</li>{{/items}}",
|
||||
"<test>",
|
||||
allocator = context.temp_allocator,
|
||||
)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "<li>foo</li><li>bar</li><li>bat</li>")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_first_list_negative_equals_last :: proc(t: ^testing.T) {
|
||||
data := First_Last_List_Data
|
||||
tpl, _ := parse(
|
||||
"{{#items | first -2}}<li>{{.}}</li>{{/items}}",
|
||||
"<test>",
|
||||
allocator = context.temp_allocator,
|
||||
)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "<li>qux</li><li>quz</li>")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_last_list_no_args_equals_last_one :: proc(t: ^testing.T) {
|
||||
data := First_Last_List_Data
|
||||
tpl_a, _ := parse(
|
||||
"{{#items | last}}<li>{{.}}</li>{{/items}}",
|
||||
"<test>",
|
||||
allocator = context.temp_allocator,
|
||||
)
|
||||
result_a, _ := render(tpl_a, data, {}, context.temp_allocator)
|
||||
|
||||
tpl_b, _ := parse(
|
||||
"{{#items | last 1}}<li>{{.}}</li>{{/items}}",
|
||||
"<test>",
|
||||
allocator = context.temp_allocator,
|
||||
)
|
||||
result_b, _ := render(tpl_b, data, {}, context.temp_allocator)
|
||||
|
||||
testing.expect_value(t, result_a, "<li>quz</li>")
|
||||
testing.expect_value(t, result_b, "<li>quz</li>")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_last_list_n :: proc(t: ^testing.T) {
|
||||
data := First_Last_List_Data
|
||||
tpl, _ := parse(
|
||||
"{{#items | last 3}}<li>{{.}}</li>{{/items}}",
|
||||
"<test>",
|
||||
allocator = context.temp_allocator,
|
||||
)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "<li>bat</li><li>qux</li><li>quz</li>")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_last_list_negative_equals_first :: proc(t: ^testing.T) {
|
||||
data := First_Last_List_Data
|
||||
tpl, _ := parse(
|
||||
"{{#items | last -2}}<li>{{.}}</li>{{/items}}",
|
||||
"<test>",
|
||||
allocator = context.temp_allocator,
|
||||
)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "<li>foo</li><li>bar</li>")
|
||||
}
|
||||
|
||||
// Group 2: Strings
|
||||
|
||||
First_Last_String_Data := map[string]string {
|
||||
"word" = "Hello, World!",
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_first_string_n :: proc(t: ^testing.T) {
|
||||
data := First_Last_String_Data
|
||||
tpl, _ := parse("{{word | first 5}}", "<test>", allocator = context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "Hello")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_last_string_n :: proc(t: ^testing.T) {
|
||||
data := First_Last_String_Data
|
||||
tpl, _ := parse("{{word | last 6}}", "<test>", allocator = context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "World!")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_first_string_negative_equals_last :: proc(t: ^testing.T) {
|
||||
data := First_Last_String_Data
|
||||
tpl, _ := parse("{{word | first -6}}", "<test>", allocator = context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "World!")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_last_string_negative_equals_first :: proc(t: ^testing.T) {
|
||||
data := First_Last_String_Data
|
||||
tpl, _ := parse("{{word | last -5}}", "<test>", allocator = context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "Hello")
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package mustache
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:os"
|
||||
import "core:path/filepath"
|
||||
import "core:testing"
|
||||
@@ -54,6 +55,10 @@ run_one_test :: proc(
|
||||
test: json.Object,
|
||||
name, template_src, expected: string,
|
||||
) -> bool {
|
||||
// Spec tests deliberately exercise missing keys, missing partials, etc.
|
||||
// Silence the warnings to keep test output readable.
|
||||
context.logger = log.nil_logger()
|
||||
|
||||
partials := make_map(map[string]Template, context.temp_allocator)
|
||||
|
||||
if "partials" in test {
|
||||
@@ -79,7 +84,7 @@ run_one_test :: proc(
|
||||
}
|
||||
defer delete(tmpl.nodes)
|
||||
|
||||
result, rerr := render_silenced(tmpl, test["data"], partials)
|
||||
result, rerr := render(tmpl, test["data"], partials)
|
||||
if rerr != nil {
|
||||
testing.expectf(t, false, "[%s] render error", name)
|
||||
return false
|
||||
|
||||
+25
-3
@@ -3,8 +3,6 @@ package mustache
|
||||
import "base:runtime"
|
||||
import "core:strings"
|
||||
|
||||
import diags "../diagnostics"
|
||||
|
||||
// collect_struct_keys enumerates the visible field names of a struct value,
|
||||
// including fields promoted via `using`-embedded structs.
|
||||
collect_struct_keys :: proc(val: any, allocator := context.temp_allocator) -> []string {
|
||||
@@ -132,7 +130,7 @@ validate_key_path :: proc(
|
||||
continue
|
||||
}
|
||||
available := collect_map_keys(current, allocator)
|
||||
if diags.suggest_correction(available, parts[i]) != "" {
|
||||
if suggest_correction(available, parts[i]) != "" {
|
||||
return false, parts[i], available
|
||||
}
|
||||
return true, "", nil
|
||||
@@ -157,6 +155,30 @@ validate_key_path :: proc(
|
||||
return true, "", nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// collect_partial_names enumerates the keys of the partials map.
|
||||
collect_partial_names :: proc(
|
||||
partials: map[string]Template,
|
||||
|
||||
@@ -6,8 +6,6 @@ import "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:testing"
|
||||
|
||||
import diags "../diagnostics"
|
||||
|
||||
Inner :: struct {
|
||||
foo: string,
|
||||
bar: int,
|
||||
@@ -155,6 +153,30 @@ test_validate_map_path_silent :: proc(t: ^testing.T) {
|
||||
testing.expect_value(t, ok, true)
|
||||
}
|
||||
|
||||
@(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"}, ""), "")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_warn_no_false_positive_for_valid_keys :: proc(t: ^testing.T) {
|
||||
Data :: struct {
|
||||
@@ -222,7 +244,7 @@ test_validate_key_path_map_typo :: proc(t: ^testing.T) {
|
||||
ok, missing, available := validate_key_path(ctx[:], "params.authr")
|
||||
testing.expect(t, !ok, "typo of map key should not be ok")
|
||||
testing.expect_value(t, missing, "authr")
|
||||
suggestion := diags.suggest_correction(available, "authr")
|
||||
suggestion := suggest_correction(available, "authr")
|
||||
testing.expect_value(t, suggestion, "author")
|
||||
}
|
||||
|
||||
|
||||
+5
-7
@@ -2,8 +2,6 @@ package main
|
||||
|
||||
import "mustache"
|
||||
|
||||
import diags "diagnostics"
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
@@ -42,12 +40,12 @@ load_template :: proc(vfs: ^VFS, virtual_path: string) -> mustache.Template {
|
||||
b := mustache.body(err)
|
||||
log.errorf(
|
||||
"%s",
|
||||
mustache.format_tag_error(
|
||||
mustache.format_error(
|
||||
entry.fs_path,
|
||||
source,
|
||||
b.pos,
|
||||
b.msg,
|
||||
colorize = diags.should_colorize(),
|
||||
colorize = mustache.should_colorize(),
|
||||
),
|
||||
)
|
||||
os.exit(1)
|
||||
@@ -144,7 +142,7 @@ render_template :: proc(
|
||||
formatted := mustache.format_render_error(
|
||||
err,
|
||||
content_tpl,
|
||||
colorize = diags.should_colorize(),
|
||||
colorize = mustache.should_colorize(),
|
||||
)
|
||||
if formatted in reported_errors^ {
|
||||
return ""
|
||||
@@ -379,12 +377,12 @@ load_partials :: proc(vfs: ^VFS) -> map[string]mustache.Template {
|
||||
b := mustache.body(err)
|
||||
log.errorf(
|
||||
"%s",
|
||||
mustache.format_tag_error(
|
||||
mustache.format_error(
|
||||
entry.fs_path,
|
||||
source,
|
||||
b.pos,
|
||||
b.msg,
|
||||
colorize = diags.should_colorize(),
|
||||
colorize = mustache.should_colorize(),
|
||||
),
|
||||
)
|
||||
os.exit(1)
|
||||
|
||||
+34
-262
@@ -3,19 +3,6 @@
|
||||
"date": "2026-07-22T08:54:00-04:00",
|
||||
"toc": true
|
||||
}
|
||||
<!--
|
||||
This document serves as the primary reference material for building Thor sites.
|
||||
|
||||
It should include reference and description of all
|
||||
|
||||
- markdown extensions
|
||||
- site configuration options
|
||||
- commandline flags
|
||||
- frontmatter options
|
||||
etc.
|
||||
|
||||
TODO: Use consistent spacing with mustache tags
|
||||
-->
|
||||
|
||||
## Introduction
|
||||
|
||||
@@ -23,26 +10,13 @@ This guide assumes you have either read [The Guide](../guide), or have built a [
|
||||
|
||||
## Features
|
||||
|
||||
Thor has many features and content processors available. In an effort to provide the best out of the box experience, most of them are enabled by default. Those that aren't are chosen because either:
|
||||
|
||||
1. Significant Performance loses.
|
||||
2. Unexpected behaviour (content mangling)
|
||||
|
||||
If you feel that an extension is in the wrong category, let is know! [^issues]
|
||||
|
||||
[^issues]: TODO: Add a link to issues from here.
|
||||
Thor has many features and content processors available. In an effort to provide the best out of the box experience, most of them are enabled by default.
|
||||
|
||||
### Opt-In Features
|
||||
|
||||
#### Footnotes
|
||||
TODO: #### deflist syntax
|
||||
|
||||
By default, Thor uses Tufte style margin notes instead of footnotes. However, if you prefer Hugo style footnotes, you can enable them in the [config](#configuration) file.
|
||||
|
||||
TODO: Explain what tufte style is.
|
||||
|
||||
#### Syntax Highlighting
|
||||
|
||||
The highlight extension enables server-side syntax highlighting, powered by TreeSitter. This feature requires downloading a grammar file for each language you want to highlight, and will raise your build times from several milliseconds to over 250
|
||||
TODO: #### footnotes
|
||||
|
||||
#### Minify
|
||||
|
||||
@@ -50,28 +24,22 @@ If enabled, `minify` will perform simple whitespace removal on all your output `
|
||||
|
||||
TODO: Do we minify inline css?
|
||||
|
||||
#### Sections
|
||||
|
||||
The `sections` extension wraps each of your content sections in a `<section>` block. Sections are opened just before each heading, and closed before the next heading, or the end the document, whichever comes first.
|
||||
|
||||
### Opt-Out Features
|
||||
|
||||
- emoji
|
||||
- sidenotes/marginnotes
|
||||
- syntax highlighting
|
||||
- heading ids
|
||||
- Table Of Contents Generation
|
||||
- TODO: Table Of Contents Generation
|
||||
- [GitHub style alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts)
|
||||
- deflist syntax[^deflist]
|
||||
|
||||
[^deflist]: This may become opt-in in the future.
|
||||
|
||||
## Directories
|
||||
Like Hugo, a Thor project is a collection of specially named directories, plus an optional config file. All directories are optional, but it is recommended to at least have a `content` directory.
|
||||
|
||||
content
|
||||
|
||||
: `content` holds your pages and page bundles. If not found, thor will look for content files in the root of your current working directory. See [Content](#content) for more details.
|
||||
: `content` holds your pages and page bundles. See [Content](#content). If not found, thor will look for content files in the root of your current working directory.
|
||||
|
||||
assets
|
||||
: `assets` contains any static files for your site (favicon.ico, etc.), as well as files you want to send through the asset pipeline (CSS or JS files).
|
||||
@@ -85,7 +53,7 @@ layouts
|
||||
|
||||
All of these names can be remapped in `thor.json`.[^remap]
|
||||
|
||||
[^remap]: While directories can be remapped at the site level, [modules](#modules) must (currently) adhere to the defaults.
|
||||
[^remap]: While directories can be remapped at the site level, modules must (currently) adhere to the defaults.
|
||||
|
||||
### Assets
|
||||
|
||||
@@ -116,83 +84,40 @@ TODO: Don't forget to highlight differences from Hugo.
|
||||
|
||||
## Templates
|
||||
|
||||
Sites are built using one or more template files written with an extended version of [mustache](https://mustache.github.io). The [mustache manual](https://mustache.github.io/mustache.5.html) has great explainations and a lot of examples, but this section should tell you everything you need to know.
|
||||
Sites are built using one or more template files written in an extended version of [mustache](https://mustache.github.io) templates. The [mustache manual](https://mustache.github.io/mustache.5.html) has great explainations and a lot of examples if you want to know more, but I'll summarize them for you here.
|
||||
|
||||
The beauty of Mustache is that there is very little syntax; there are just 10 symbols you need learn: `{{`, `{{&`, `{{^`, `{{>`, `{{<`, `{{#`, `/}}`, `{{$`, `{{!`, and `|`.
|
||||
|
||||
TODO: ^^ Badly worded sentence ^^
|
||||
|
||||
TODOS: Gotta describe base templates somewhere. (the same way we describe the partials)
|
||||
|
||||
### Tags
|
||||
|
||||
The beauty of Mustache is that there is very little syntax; there are just 10 symbols you need learn: `{{`, `{{&`, `{{^`, `{{>`, `{{<`, `{{#`, `/}}`, `{{$`, `{{!`, and `|`.
|
||||
|
||||
#### Variables
|
||||
|
||||
In order to display a scalar (non-list) value in your template, simply wrap it in double curly braces. e.g. `{{ page.title }}`.
|
||||
In order to display a scalar (not-list) value in your template, simply wrap it in double curly braces. e.g. `{{ page.title }}`.
|
||||
|
||||
This content will be HTML escaped (for safety), so if the value you're rendering contains HTML, you'll need to use the raw syntax instead `{{& page.title}}`[^raw] which will output the value without escaping your HTML. Do so at your own risk.[^risk]
|
||||
This content will be HTML escaped (for safety), so if the value you're rendering contains HTML, you'll need to use the raw syntex instead `{{& page.title}}`[^raw] which will output the value without stripping or re-writing content.
|
||||
|
||||
[^risk]: Or don't, if you don't know why this is dangerous.
|
||||
|
||||
[^raw]: Mustache also supports raw output through triple brace syntax (`{{{ raw }}}`), but `{{& raw }}` is preferred, as it's easy to accidentily insert too many braces.
|
||||
|
||||
Leading and trailing whitespace(s) are ignored by the parser, so the folllowing are all equivilent: `{{& title }}`, `{{&title}}`, `{{& title}}`, `{{&title }}`.
|
||||
[^raw]: Official Triple brace syntax (`{{{ raw }}}`) is also supported, but `{{& raw }}` is preferred, as it's easy to accidentily insert too many braces.
|
||||
|
||||
In most[^most] cases, invalid keys will be silently ignored (nothing between the braces will appear), in keeping with the official mustache spec.
|
||||
|
||||
[^most]: TODO: in what cases won't it? spelllcheck + strict mode? Also, people don't care about the spec, they care about how the app works. Also maybe mention strict mode here.
|
||||
[^most]: TODO: in what cases won't it? spelllcheck + strict mode?
|
||||
|
||||
Leading and trailing whitespace(s) are ignored by the parser, so the folllowing are all equivilent: `{{& title }}`, `{{&title}}`, `{{& title}}`.
|
||||
|
||||
#### Sections
|
||||
> Sections render blocks of text zero or more times, depending on the value of the key in the current context.
|
||||
|
||||
Sections allow you to render text conditionally, and also allow you to render lists[^iffor]. They are opened with `{{#key}}` and closed with `{{/key}}`.
|
||||
|
||||
[^iffor]: These would be your `if` and `for` blocks in a logic-ful language.
|
||||
|
||||
```mustache
|
||||
{{og.title}}
|
||||
Shown if OpenGraph title is set!
|
||||
{{/og.title}}
|
||||
|
||||
<ul>
|
||||
{{#pages}}
|
||||
<li>{{name}}</li>
|
||||
{{/pages}}
|
||||
</ul>
|
||||
```
|
||||
TODO:
|
||||
|
||||
#### Inverted Sections
|
||||
All of the syntax we've described so far renders text when its value is *not* empty. Inverted sections render text when the value given to them *is* empty.[^unless] This allows you to display special conent when no values exist, for example.
|
||||
|
||||
> [!NOTE]
|
||||
> Empty values include `""` (an empty string), `[]` (an empty list), `false`
|
||||
|
||||
Inverted sections are opened with `{{^key}}` and closed with `{{/key}}`.
|
||||
|
||||
```mustache
|
||||
<ul>
|
||||
{{#pages}}<li>{{name}}</li>{{/pages}}
|
||||
</ul>
|
||||
{{^pages}}
|
||||
We don't have anything to show you right now, come back later!
|
||||
{{/pages}}
|
||||
```
|
||||
|
||||
[^unless]: This would be your `if not` or `unless` keyword in a logic-ful template.
|
||||
TODO:
|
||||
|
||||
#### Partials
|
||||
Single file templates have limited functionality. Fortunately, mustache lets you break your documents up into multiple files through the use of partials.
|
||||
TODO:
|
||||
|
||||
To include a partial, you use the `{{>name}}` syntax, where `name` is the path to the partial, relative to the `{{layouts_dir}}/partials` of the module. For example, with the default theme, the `footer` template exists in `{{theme_dir}}/layouts/partials/footer.html`, so you'd include it by adding `{{>footer}}` to you template.
|
||||
|
||||
See [Partials](#partials-2) for more information.
|
||||
|
||||
<!--
|
||||
If you want to, your partials can include partials, creating a tree of files. But don't go crazy with it.
|
||||
|
||||
> [!WARNING]
|
||||
> You are limited in the number of partials you can nest, to around 13.
|
||||
> Please see [context depth](#depth-limit) to learn more.
|
||||
-->
|
||||
TODO: Talk about MAX_CONTEXT_DEPTH (currently 16) and how it limits the total number of nested templates to 13. (3 for `[site, page, ctx]`)
|
||||
|
||||
##### Dynamic Partials
|
||||
|
||||
@@ -202,8 +127,6 @@ TODO:
|
||||
#### Parents
|
||||
TODO:
|
||||
|
||||
Parents can be used by theme developers to create complex templates with multiple content slots.
|
||||
|
||||
#### Summary
|
||||
|
||||
`{{page.title}}` for normal values
|
||||
@@ -212,7 +135,7 @@ Parents can be used by theme developers to create complex templates with multipl
|
||||
`{{#params.is_starred}}<i class="fas fa-star"></i>{{/params.is_starred}}` for conditional content.
|
||||
`{{^pages}}No pages yet!{{/pages}}` to draw content when the value is empty.
|
||||
|
||||
### Block Names
|
||||
### Section Names
|
||||
|
||||
When building your own templates, you are of course free to pick whatever names you choose for your partials and content slots. However, sticking to conventions helps create consistency in the ecosystem, and reduces friction when relying on built-in templates.
|
||||
|
||||
@@ -288,24 +211,20 @@ TODO: Write
|
||||
|
||||
`stylesheets`
|
||||
|
||||
: A list of paths to css files the users wants to include globally. These are rendered by the `{{> styles }}` partial.
|
||||
: A list of paths to css files the users wants to include globally. These are rendered by the `{{> styles }}` partial, and can be omitted on sites that use custom templates.
|
||||
|
||||
TODO: ^^ Bad sentence? ^^
|
||||
|
||||
`scripts`
|
||||
|
||||
: A list of `<script>` tags the users wants to include globally. These are rendered by the `{{> scripts }}` partial.
|
||||
|
||||
#### The Context Stack
|
||||
When building your page(s), each template is fed a Context[^ctx] stack that contains all the data you should need to build your page.
|
||||
When building your page(s), each template is fed a Context[^ctx] stack that contains all the data should you need to build your page.
|
||||
|
||||
[^ctx]: (for developers) `Template_Context` is distinct from Odin's implicit `context` parameter.
|
||||
[^ctx]: (for developers) `Template_Context` is not the same as Odin's implicit `context` parameter.
|
||||
|
||||
The context stack is initialized[^init] as `[site, page, context]`, meaning if you use a key like `{{title}}` in your template, it will first attempt to look up `context.title`, then `page.title`, and finally `site.title`, using the first available value it can find.
|
||||
The context stack is initialized[^init] as `[site, page, context]`, meaning if you use a key like `{{title}}` in your template, it will look up `context.title`, `page.title`, and then finally `site.title`, using the first available value it can find.
|
||||
|
||||
[^init]: TODO: Don't use programmer speak.
|
||||
|
||||
As you descend into sections/partials, new contexts are placed onto the stack, so they resolve first. (LIFO order).
|
||||
TODO: As you descend into sections/partials, new contexts are placed onto the stack, so they resolve first. (LIFO order)
|
||||
|
||||
```mustache
|
||||
{{<base}}
|
||||
@@ -323,16 +242,10 @@ As you descend into sections/partials, new contexts are placed onto the stack, s
|
||||
{{/base}}
|
||||
```
|
||||
|
||||
##### Depth Limit
|
||||
|
||||
For technical reasons, the context depth is limited to 16. This shouldn't matter to most users, but if you're running into errors, please see the [FAQ](./FAQ) for more information.
|
||||
|
||||
---
|
||||
### Filters
|
||||
|
||||
Because mustache is a logic-less language, (there are no `for` or `if` keywords), things like grouping, sorting, and filtering, are not possible using standard syntax.
|
||||
|
||||
To get around this, Thor extends mustache to include a handful of data filters in the form of pipes. Bash users will feel right at home here.
|
||||
Because mustache is a logic-less language, (there are no `for` or `if` tags), Thor extends mustache to include a handful of data filters in the form of pipes. Bash users will feel right at home here.
|
||||
|
||||
`group_by <field>`
|
||||
|
||||
@@ -350,25 +263,25 @@ To get around this, Thor extends mustache to include a handful of data filters i
|
||||
{{/pages}}
|
||||
```
|
||||
|
||||
`sort_by <field> [asc|desc]`
|
||||
`sort_by <field> <asc|desc>`
|
||||
|
||||
: Allows you to sort pages by the given field.
|
||||
|
||||
`first [n]`
|
||||
`first <n>`
|
||||
|
||||
: Given a list, returns only the first `n` items. `n` defaults to 1. Given a string, returns the first `n` runes of the string. To help catch mistakes, `n` is required for strings.
|
||||
|
||||
`last [n]`
|
||||
`last <n>`
|
||||
|
||||
: Given a list, returns only the last `n` items. `n` defaults to 1. Given a string, returns the first `n` runes of the string. To help catch mistakes, `n` is required for strings.
|
||||
|
||||
`format ["format string"]`
|
||||
`format "<format string>"`
|
||||
|
||||
: Used to display a date in a particular format. See [DateTimes](#datetimes). If no format is given, the default will be used.
|
||||
|
||||
#### Chaining Pipes
|
||||
|
||||
Thor allows you to chain two or more pipes, to allow complex data manipulation. However you are limited to no more than **8 pipes**[^pipes] for any given tag. If you believe you need more than 8 pipes, please [open an issue](../issues) with a **clear** and **concrete** example of the problem you are facing.
|
||||
Thor allows you to chain two or more pipes, to allow complex data manipulation. However for performance and stylistic reasons, you are limited to no more than **8 pipes**[^pipes] for any given tag. If you believe you need more than 8 pipes, please [open an issue](../issues) with a **concrete example** of the problem you are facing.
|
||||
|
||||
[^pipes]: TODO: This number must be kept in-sync with `MAX_PIPES`.
|
||||
|
||||
@@ -432,7 +345,7 @@ TODO: We keep referring to blocks and tags, should probably use consistent langu
|
||||
TODO: Should this be a subsection of a "Data Types" section?
|
||||
|
||||
Dates are strings in one of the following formats:
|
||||
<!--
|
||||
|
||||
| Format | Time zone |
|
||||
| --------------------------- | ---------------------------- |
|
||||
| `2023-10-15T13:18:50-07:00` | `America/Los_Angeles` |
|
||||
@@ -441,156 +354,15 @@ Dates are strings in one of the following formats:
|
||||
| `2023-10-15T13:18:50` | Default is local system time |
|
||||
| `2023-10-15` | Default is local system time |
|
||||
| `15 Oct 2023` | Default is local system time |
|
||||
-->
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Format</th>
|
||||
<th>Time zone</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code class="inline-code">2023-10-15T13:18:50-07:00</code></td>
|
||||
<td><code class="inline-code">America/Los_Angeles</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code class="inline-code">2023-10-15T13:18:50-0700</code></td>
|
||||
<td><code class="inline-code">America/Los_Angeles</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code class="inline-code">2023-10-15T13:18:50Z</code></td>
|
||||
<td><code class="inline-code">Etc/UTC</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code class="inline-code">2023-10-15T13:18:50</code></td>
|
||||
<td>Default is local system time</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code class="inline-code">2023-10-15</code></td>
|
||||
<td>Default is local system time</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code class="inline-code">15 Oct 2023</code></td>
|
||||
<td>Default is local system time</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
If you want to display a date in a different format, you can use the `| format` Filter. With no argument, it will default to formatting the date with `site.date_format`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Thor can be configured through commandline flags, content [frontmatter](#frontmatter), and the configuration file. Settings take precedence in that order, so command line flags will always override configured values.
|
||||
|
||||
You can run `thor -help` to list all the available flags.
|
||||
|
||||
### Config File
|
||||
|
||||
The `thor.json` file at the root of your project is the heart of your Thor site. Hopefully, you'll write your site specific metadata here once, and you'll never have to touch it again.
|
||||
|
||||
`title`
|
||||
|
||||
: The title of your site. Will be rendered inside the `{{> title}}` partial.
|
||||
|
||||
`description`
|
||||
|
||||
: A brief description of your site. Can be used by theme authors, or as the default [OpenGraph](#open-graph) description.
|
||||
|
||||
`base_url`
|
||||
|
||||
: The root URL prepended to all links on your site.
|
||||
|
||||
`date.format`
|
||||
|
||||
: The default format to display dates in. Write the date you'd like to see formatted as [the go format string]. TODO: Explain
|
||||
|
||||
`date.timezone`
|
||||
|
||||
: The time zone to localize all formatted dates to. Will attempt to use the local system's zone if left unset.
|
||||
|
||||
TODO: validate the default
|
||||
|
||||
TODO: Explain format
|
||||
|
||||
`markdown_extensions`
|
||||
|
||||
: This setting lets you control which markdown extensions are enabled on your site. It should be a mapping of extension names to either `true` or `false`.
|
||||
The following keys are available:
|
||||
- `emoji`
|
||||
- `sidenotes`
|
||||
- `alerts`
|
||||
- `highlight`
|
||||
- `sections`
|
||||
- `headingids`
|
||||
- `deflists`
|
||||
- `footnotes`
|
||||
|
||||
|
||||
`content_dir`, `assets_dir`, `output_dir`, `layouts_dir`
|
||||
|
||||
: These allow you to change the directory structure of your app. See [Directories](#directories) for more info.
|
||||
|
||||
> [!Note]
|
||||
> This will not affect the directory structure of any of your modules.
|
||||
|
||||
`modules`
|
||||
|
||||
: This setting lets you layer modules onto the Union File System. See [modules](#modules) for more info.
|
||||
|
||||
`params`
|
||||
|
||||
: Site-wide user customizable data. See [params](#params) for more info.
|
||||
|
||||
`og`
|
||||
|
||||
: Site-wide Open Graph metadata. See [Open Graph](#open-graph) for more info.
|
||||
|
||||
`menus`
|
||||
|
||||
Menu configuration for the site. Note that (unlike Hugo) if your configuration contains this key, frontmatter `menus` will be ignored. See [menus](#menus) for more info.
|
||||
|
||||
|
||||
|
||||
## Open Graph
|
||||
|
||||
Thor supports [Open Graph](https://ogp.me/) tags, allowing you to customize how your site looks when linked to on social media or in apps like Discord. It is accessible through the `{{>opengraph}}` template, which should be automatically included in your header by your theme.
|
||||
|
||||
```html
|
||||
TODO: {{>opengraph shorcode here}}
|
||||
```
|
||||
|
||||
The options are sourced [using the context stack], first from your page frontmatter, and then from your site config. Thor will attempt to infer as much information as possible, to keep you from needing to set them explictly.
|
||||
|
||||
The following keys are available:
|
||||
- `og.title`
|
||||
- `og.type`
|
||||
- `og.image`
|
||||
- `og.url`
|
||||
- `og.description`
|
||||
- `og.locale`
|
||||
- `og.site_name`
|
||||
- `og.is_article`*
|
||||
- `og.published_time`*
|
||||
- `og.modified_time`*
|
||||
- `og.section`
|
||||
|
||||
Please read [the protocol docs](https://ogp.me/#metadata) for the meaning and use of these keys.
|
||||
|
||||
TODO: * keys are not a 1:1 mapping
|
||||
TODO: default Template support
|
||||
TODO: Template override support
|
||||
TODO: how to overwrite defaults (in page frontmatter / thor.json)
|
||||
|
||||
## Modules
|
||||
|
||||
TODO: Modules
|
||||
|
||||
## Themes
|
||||
|
||||
TODO: Themes
|
||||
|
||||
## Zen
|
||||
|
||||
> The guding principals behind Thor.
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"title": "FAQ",
|
||||
"date": "2026-08-30T10:17:00-04:00",
|
||||
"toc": true
|
||||
}
|
||||
<!--
|
||||
TODO: Make sure issue link is legit
|
||||
-->
|
||||
## context stack depth exceeded (possible recursive section/partial)
|
||||
|
||||
To maintain high performance and encourage maintainable sites, the context stack can never be more than 16 items deep. What does this mean?
|
||||
|
||||
Every time you enter a section or a partial, the closest referenced item gets pushed onto the stack. if you then enter another partial, or reference a nested object inside of that, you'll add another item.
|
||||
|
||||
This is easier to see with examples:
|
||||
|
||||
```mustache
|
||||
{{! base.html }}
|
||||
<html>
|
||||
<head>
|
||||
<title>
|
||||
{{ title }}
|
||||
{{! stack size = 3 throughout the file }}
|
||||
</title>
|
||||
</head>
|
||||
<body>
|
||||
{{> now}}
|
||||
<nav>
|
||||
<ul>
|
||||
{{#pages}}
|
||||
<li>{{name}} {{! stack size = 4 within #pages}}</li>
|
||||
{{/pages}}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{{> footer}}
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
```mustache
|
||||
{{! partials/footer.html }}
|
||||
|
||||
{{! stack size = 4 throughout the file (when called from base.html) }}
|
||||
<footer>
|
||||
{{#params}}
|
||||
<p>© {{author.name}} {{> now}}</p>
|
||||
{{! stack size = 5 inside #params }}
|
||||
{{/params}}
|
||||
{{/site}}
|
||||
</footer>
|
||||
```
|
||||
|
||||
```mustache
|
||||
{{! partials/now.html }}
|
||||
|
||||
{{! stack size =
|
||||
4 when called from base.html
|
||||
6 when called from #params inside of footer.html
|
||||
}}
|
||||
|
||||
{{ now | format }}
|
||||
```
|
||||
|
||||
The stack size starts at 3 because the stack is preloaded with `[site, page, context]`, allowing you to reference values like `title` or `params` from `page` or `site` without having to use the fully qualified name `{{site.title}}`.
|
||||
|
||||
> [!NOTE]
|
||||
> If your sites needs a context depth higher than 16, please reach out and open an issue[^issue] with a **clear** and **concrete** example of what you're trying to do.
|
||||
+1
-4
@@ -1,9 +1,6 @@
|
||||
{
|
||||
"date": {
|
||||
"timezone": "America/New_York"
|
||||
},
|
||||
"markdown_extensions": {
|
||||
"footnotes": true
|
||||
// "footnotes": true
|
||||
},
|
||||
"params": {
|
||||
"author": {
|
||||
|
||||
@@ -5,6 +5,7 @@ package main
|
||||
import "core:encoding/json"
|
||||
import "core:flags"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:os"
|
||||
import "core:testing"
|
||||
import "core:time/datetime"
|
||||
@@ -83,6 +84,7 @@ test_load_config_file_invalid_json :: proc(t: ^testing.T) {
|
||||
cfg: Config_File
|
||||
ok := false
|
||||
{
|
||||
context.logger = log.nil_logger()
|
||||
ok = load_config_file(&cfg, path, context.temp_allocator)
|
||||
}
|
||||
testing.expect(t, !ok)
|
||||
@@ -104,6 +106,7 @@ test_load_config_file_partial :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_init_site_defaults_no_config :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
|
||||
site: Site
|
||||
_flags: Flags
|
||||
@@ -123,6 +126,7 @@ test_init_site_defaults_no_config :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_init_site_config_dir_relative :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
|
||||
site: Site
|
||||
_flags: Flags
|
||||
@@ -138,6 +142,7 @@ test_init_site_config_dir_relative :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_init_site_flag_overrides_default :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
|
||||
site: Site
|
||||
_flags: Flags
|
||||
@@ -151,6 +156,7 @@ test_init_site_flag_overrides_default :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_init_site_full_pipeline :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
|
||||
path := write_temp_config(
|
||||
"pipeline",
|
||||
@@ -172,6 +178,7 @@ test_init_site_full_pipeline :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_init_site_md_enable_disable :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
|
||||
site: Site
|
||||
_flags: Flags
|
||||
@@ -192,6 +199,7 @@ test_init_site_md_enable_disable :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_init_site_config_paths :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
|
||||
path := write_temp_config(
|
||||
"paths",
|
||||
|
||||
Reference in New Issue
Block a user