mirror of
https://github.com/sbrow/thor.git
synced 2026-10-25 18:21:54 -04:00
Compare commits
33
Commits
main
..
18733f31d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18733f31d1 | ||
|
|
a4db4c2c58 | ||
|
|
9e93c012b6 | ||
|
|
0d09db8d26 | ||
|
|
4948eb05a9 | ||
|
|
e71c2a94e5 | ||
|
|
ef7327cc52 | ||
|
|
7b67201791 | ||
|
|
0307ab647f | ||
|
|
9e19b0dc2c | ||
|
|
6d97a23568 | ||
|
|
89c2ffbf78 | ||
|
|
4659aa8dc2 | ||
|
|
d03e3469b3 | ||
|
|
df14d76a85 | ||
|
|
c48afafd8a | ||
|
|
92d7b7525a | ||
|
|
b54eb76495 | ||
|
|
137b691a71 | ||
|
|
06e411a329 | ||
|
|
2299dcd652 | ||
|
|
40bd2ad810 | ||
|
|
eabb8a1430 | ||
|
|
c634e2e718 | ||
|
|
f839ec0f04 | ||
|
|
df0ed5a8f1 | ||
|
|
fb32ce90fc | ||
|
|
0abac89d88 | ||
|
|
1824bae26c | ||
|
|
5627215d6f | ||
|
|
9d1954d70a | ||
|
|
fcf349eb51 | ||
|
|
d51172c836 |
@@ -1,78 +0,0 @@
|
||||
# Custom Pages build: compiles Thor from source via the Nix flake, uses it to
|
||||
# generate thor/site/, and publishes the result. Replaces the default Jekyll build.
|
||||
name: Deploy site to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Only rebuild when something that affects the output changes.
|
||||
paths:
|
||||
- "site/**"
|
||||
- "**/*.odin"
|
||||
- "defaults/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- ".github/workflows/pages.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Allow one concurrent deployment; don't cancel an in-progress publish.
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v30
|
||||
with:
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
|
||||
# Free cache: snapshots /nix/store into GitHub's Actions cache. The key is
|
||||
# hashed over the inputs that change Thor's output, so a site-content-only
|
||||
# change is a full hit (Thor not recompiled), while an Odin source change
|
||||
# restores the nearest store and rebuilds just the delta.
|
||||
- name: Cache /nix/store
|
||||
uses: nix-community/cache-nix-action@v6
|
||||
with:
|
||||
primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.lock', '**/*.nix', '**/*.odin') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-
|
||||
gc-max-store-size-linux: 2G
|
||||
purge: true
|
||||
purge-prefixes: nix-${{ runner.os }}-
|
||||
purge-created: 604800 # drop entries older than 7 days
|
||||
|
||||
# First run compiles Thor + the tree-sitter grammar libs; later runs
|
||||
# restore them from the cache above and skip the compile.
|
||||
- name: Build Thor
|
||||
run: nix build .#default --print-build-logs
|
||||
|
||||
- name: Generate site
|
||||
working-directory: site
|
||||
run: ../result/bin/thor -base-url=https://oneidiotdeveloper.com/thor -minify
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: site/public
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -2,18 +2,6 @@
|
||||
|
||||
Thor is a static site generator written in [Odin](https://odin-lang.org), replacing Hugo for the `sbrow.github.io` blog. It lives at `./thor/` as a git subtree with its own `flake.nix`.
|
||||
|
||||
## 🚫 DO NOT EDIT THE DOCS — BY HUMANS, FOR HUMANS
|
||||
|
||||
> **THE DOCUMENTATION UNDER `thor/site/` IS HANDWRITTEN, BY HUMANS, FOR HUMANS.**
|
||||
>
|
||||
> **NO AI, AGENT, BOT, ASSISTANT, OR OTHER NON-HUMAN MAY EDIT, REWRITE,
|
||||
> REPHRASE, REFORMAT, "IMPROVE," SUMMARIZE, OR GENERATE ANY FILE UNDER
|
||||
> `thor/site/` — EVER.**
|
||||
>
|
||||
> These are not machine artifacts. A human wrote every word. AI may be
|
||||
> consulted as a sanity check, but the prose stays human. If you are not a
|
||||
> human, do not touch these files. See `thor/site/content/ai.md`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
@@ -32,7 +20,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
|
||||
@@ -54,10 +41,10 @@ thor/
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `main.odin` | Entry point. Parses CLI flags via `core:flags`, sets logger level from `-verbose`/`-quiet`, calls `init_site`, `build_vfs`, wires `treesitter.grammar_dir`/`query_dir` from config, `site_load_content`, `render_site`. Optional Spall profiling via `SPALL` config flag. |
|
||||
| `site.odin` | `Flags` (CLI, includes `-verbose`/`-quiet`), `Config_File` (thor.json), `Site_Context` (template-facing: `title`, `description`, `base_url`, `params`, `og`, `menus`), `Site` (runtime state + arena + VFS + pages + `og`). `Feature` enum. `init_site(site, flags)` — takes pre-parsed `Flags`. Config menu parsing in `site_apply_config`. |
|
||||
| `content.odin` | `Page` struct (includes `weight`, `menus`, `params: json.Value`, `toc: string`, `og`), `Pending_File` struct, `scan_content_files` (section-aware walk that handles leaf bundles), `collect_languages` (pre-scan for code fence languages), `load_page` (falls back to file mtime, generates TOC via `md.generate_toc` when frontmatter `"toc": true`), `infer_layout`. Calls `md.process()` for the markdown pipeline. |
|
||||
| `render.odin` | Template rendering: `render_site`, `render_page_html`, `render_home_html`, `render_section`. `Template_Context` (unified render struct with `site: Site_Context`, `page: Page`, `menus`, `params`, `posts`, `pages`). 3-frame context stack via `[]any{ctx.site, ctx.page, ctx}`. `merge_params(site, page)` — shallow merge of site + page params. Error deduplication via `seen: ^map[string]bool` passed through render chain. `sort_pages` (weight primary, date secondary). `to_title_case` for section display names. VFS-based template loading with fallback chain (`get_template`). |
|
||||
| `main.odin` | Entry point. Sets `context.logger`, calls `init_site`, `build_vfs`, wires `treesitter.grammar_dir`/`query_dir` from config, `site_load_content`, `render_site`. Optional Spall profiling via `SPALL` config flag. |
|
||||
| `site.odin` | `Flags` (CLI), `Config_File` (thor.json), `Site_Context` (template-facing: `title`, `description`, `base_url`, `params`, `og`, `menus`), `Site` (runtime state + arena + VFS + pages + `og`). `Feature` enum. 5-step `init_site`. Config menu parsing in `site_apply_config`. |
|
||||
| `content.odin` | `Page` struct (includes `weight`, `menus: map[string]Menu_Entry`, `og`), `Pending_File` struct, `scan_content_files` (section-aware walk that handles leaf bundles), `collect_languages` (pre-scan for code fence languages), `load_page` (falls back to file mtime when no frontmatter date), `infer_layout`. Calls `md.process()` for the markdown pipeline. |
|
||||
| `render.odin` | Template rendering: `render_site`, `render_page_html`, `render_home_html`, `render_section`. `Template_Context` (unified render struct with `site: Site_Context`, `page: Page`, `menus`, `posts`, `pages`). 3-frame context stack via `[]any{ctx.site, ctx.page, ctx}`. `sort_pages` (weight primary, date secondary). `to_title_case` for section display names. VFS-based template loading with fallback chain (`get_template`). |
|
||||
| `menus.odin` | Menu system: `Menu_Entry {name, url, weight: Maybe(int)}`, `DEFAULT_WEIGHT = 10`. `build_menus` (priority chain: config → auto + page frontmatter, then `warn_all_duplicate_weights`). `collect_auto_menus` (sections + root-level pages, skips pages with explicit `"menus": "main"` frontmatter). `merge_page_menus` (frontmatter entries with effective weight fallback via nil check). `parse_page_menus` (string/array/object forms). `parse_config_menus` (from thor.json). `sort_menu_entries` / `compare_menu_entries` (weight primary via `.? or_else DEFAULT_WEIGHT`, name secondary). `warn_duplicate_weights` / `warn_all_duplicate_weights` (log when two entries in same menu have same explicitly-set weight). |
|
||||
| `minify.odin` | HTML/CSS minification via tree-sitter. Imports `ts "treesitter"`. |
|
||||
| `feed.odin` | RSS feed + sitemap XML. Uses `page.url` for canonical URLs. |
|
||||
@@ -65,7 +52,7 @@ thor/
|
||||
| `assets.odin` | `copy_assets_dir` — iterates VFS entries with `assets/` prefix, minifies CSS, copies verbatim or via `os.copy_file`. |
|
||||
| `html.odin` | `strip_html_tags`, `unescape_html`, `generate_summary` (word-count truncation, zero-alloc), `generate_description` (HTML→plain text: strip tags, decode entities, collapse whitespace). |
|
||||
| `opengraph.odin` | `Open_Graph` struct (fields ordered per OGP spec, `is_article: Maybe(bool)`). `og_for_site(site)` for site defaults (from config + derived), `og_for_page(site_og, page)` for page-specific (overlay page.og + derive from page data). Description falls back to `generate_description(generate_summary(body_html))`. |
|
||||
| `frontmatter.odin` | JSON frontmatter parser (`{ }` delimited). Supports `layout`, `lastmod`, `weight: Maybe(int)`, `menus`, `params: json.Value`, `toc: bool`, and nested `og` object (via `json_get_open_graph`). Helpers: `json_get_string`, `json_get_bool`, `json_get_int` (returns `Maybe(int)`, nil for absent/invalid). |
|
||||
| `frontmatter.odin` | JSON frontmatter parser (`{ }` delimited). Supports `layout`, `lastmod`, `weight: Maybe(int)`, `menus`, and nested `og` object (via `json_get_open_graph`). Helpers: `json_get_string`, `json_get_bool`, `json_get_int` (returns `Maybe(int)`, nil for absent/invalid). |
|
||||
| `defaults.odin` | `DEFAULTS_PATH` constant, resolved at compile time via `#directory` so bundled templates ship in the binary. |
|
||||
|
||||
### Subpackages
|
||||
@@ -73,17 +60,14 @@ 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) |
|
||||
| | `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. |
|
||||
| `markdown/` | `markdown.odin` | `Extension` enum, `DEFAULT_EXTENSIONS`, `process(body, ext, file_path)` — full pipeline, `parse_extension_list`, `apply_extension_config` |
|
||||
| | `footnotes.odin` | `strip_definitions` (pre-cmark), `inject_notes` (post-cmark) |
|
||||
| | `alerts.odin` | `inject_alerts` — GitHub alert blocks (`> [!NOTE]`) → styled blockquotes with semantic class names (`alert-note` etc.) |
|
||||
| | `emoji.odin` | `expand_emoji` — `:shortcode:` → unicode emoji |
|
||||
| | `sectionate.odin` | `wrap_sections` — splits HTML at `<h2>` into `<section>` wrappers |
|
||||
| | `highlight.odin` | Syntax highlighting via tree-sitter. Imports `../treesitter`. |
|
||||
| | `heading_ids.odin` | `inject_heading_ids` — adds `id` attributes to `<h1>`-`<h6>` from heading text. Slug-based, deduplicated. |
|
||||
| | `deflists.odin` | `convert_deflists` — pre-cmark pass. Scans for definition list patterns (`term\n\n: definition`) and converts to `<dl><dt><dd>` HTML blocks. Terms and definitions rendered through cmark individually for inline markdown. Consecutive pairs grouped into single `<dl>`. |
|
||||
| | `toc.odin` | `generate_toc(html, allocator)` — page-level feature (not a pipeline extension). Scans `<h1>`-`<h6>` for IDs (after `inject_heading_ids`), builds nested `<ul>` with `<a href="#id">` links. Called from `load_page` when frontmatter `"toc": true`. Depends on `.HeadingIDs` being enabled. |
|
||||
| `mustache/` | See [Mustache engine](#mustache-engine) below | Template engine |
|
||||
| `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).
|
||||
@@ -117,15 +101,13 @@ Page :: struct {
|
||||
weight: Maybe(int), // page ordering (nil = unset, defaults to DEFAULT_WEIGHT at comparison time)
|
||||
lastmod: string,
|
||||
menus: map[string]Menu_Entry, // frontmatter menu assignments
|
||||
params: json.Value, // per-page params (merged with site params at render time)
|
||||
content: string, // rendered HTML body
|
||||
og: Open_Graph,
|
||||
draft: bool,
|
||||
toc: string, // generated table of contents HTML (empty if not requested)
|
||||
starred: bool,
|
||||
_is_index: bool `private`,
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
No `Page_Type` enum — page type is inferred from section + `_is_index`. Layout is inferred via `infer_layout(section, is_index)`:
|
||||
|
||||
@@ -174,13 +156,13 @@ All weight fields use `Maybe(int)` — nil means "unset," `some(v)` means explic
|
||||
|
||||
Config is split into three structs with a clear 5-step initialization flow:
|
||||
|
||||
- **`Flags`** — CLI args only. Parsed once in `main.odin` via `core:flags`, passed to `init_site`. Includes path overrides (`--content`, `--assets`, `--output`, `--layouts`), build-mode toggles (`-drafts`, `-watch`, `-minify`), log level (`-verbose` → Debug, `-quiet` → Warning), and `-ext`/`-no-ext` for markdown extension overrides.
|
||||
- **`Flags`** — CLI args only. Parsed by `core:flags`. Includes path overrides (`--content`, `--assets`, `--output`, `--layouts`), build-mode toggles (`-drafts`, `-watch`, `-minify`), and `-ext`/`-no-ext` for markdown extension overrides.
|
||||
- **`Config_File`** — parsed from `thor.json` via `json.unmarshal_string`. Holds title, paths, `markdown_extensions` (JSON), `params` (JSON), `modules` (JSON array of relative paths), `og` (`Open_Graph` struct for site-level OG defaults).
|
||||
- **`Site`** — runtime state: arena, pages, modules, VFS, `features: bit_set[Feature]`, `markdown_extensions: bit_set[md.Extension]`, `og: Open_Graph` (resolved site-level OG).
|
||||
|
||||
**`Feature` enum** — `Drafts`, `Minify`, `Watch`. Checked with `.Minify in site.features`.
|
||||
|
||||
**`markdown.Extension` enum** (in the `markdown` package, not main) — `Emoji`, `Sidenotes`, `Alerts`, `Highlight`, `Sections`, `HeadingIDs`, `DefLists`, `Footnotes`. Default is `md.DEFAULT_EXTENSIONS` (currently `.Emoji, .Sidenotes, .Alerts, .HeadingIDs, .DefLists`). Configurable via:
|
||||
**`markdown.Extension` enum** (in the `markdown` package, not main) — `Emoji`, `Sidenotes`, `Alerts`, `Highlight`, `Sections`, `HeadingIDs`. Default is `md.DEFAULT_EXTENSIONS` (currently `.Emoji, .Sidenotes, .Alerts, .HeadingIDs`). Configurable via:
|
||||
- `thor.json`: `"markdown_extensions": { "emoji": true, "highlight": false, ... }`
|
||||
- CLI: `-ext:highlight,sections` (enable) / `-no-ext:emoji` (disable). Comma-separated, case-insensitive.
|
||||
|
||||
@@ -260,12 +242,10 @@ Lives in the `markdown` package. Entry point: `md.process(body, ext, file_path)`
|
||||
|
||||
```
|
||||
raw markdown
|
||||
→ md.strip_definitions (if .Sidenotes || .Footnotes — pre-cmark)
|
||||
→ md.convert_deflists (if .DefLists — pre-cmark)
|
||||
→ md.strip_definitions (if .Sidenotes — pre-cmark)
|
||||
→ cmark markdown_to_html (Unsafe mode for HTML passthrough)
|
||||
→ md.expand_emoji (if .Emoji — post-cmark)
|
||||
→ md.inject_notes (if .Sidenotes — post-cmark, sidenote rendering)
|
||||
→ md.inject_footnotes (if .Footnotes — post-cmark, standard footnote rendering)
|
||||
→ md.inject_notes (if .Sidenotes — post-cmark)
|
||||
→ md.inject_alerts (if .Alerts — post-cmark)
|
||||
→ md.highlight_code (if .Highlight — post-cmark)
|
||||
→ md.inject_heading_ids (if .HeadingIDs — post-cmark, pre-sections)
|
||||
@@ -297,17 +277,17 @@ Template_Context :: struct {
|
||||
site: Site_Context, // site-level data (title, description, base_url, params, og)
|
||||
menus: map[string][]Menu_Entry, // generated menu data (copied from site, resolves above Page.menus)
|
||||
now: string, // UTC ISO 8601 build timestamp
|
||||
title: string, // computed browser title ("Page | Site")
|
||||
date_format: string, // from site.date.format (thor.json)
|
||||
timezone: ^datetime.TZ_Region, // for format pipe
|
||||
og: Open_Graph, // computed per-page OG
|
||||
params: json.Value, // merged site + page params (resolves above Page.params)
|
||||
page: Page, // current page
|
||||
pages: [dynamic]Page, // home page list
|
||||
posts: [dynamic]Page, // section post list
|
||||
}
|
||||
```
|
||||
|
||||
`Site_Context` is embedded in `Site` via `using site_context`. Fields like `site.title`, `site.menus`, `site.params` are accessed directly on `Site` through promotion. `Template_Context.menus` is copied from `site.menus` to resolve above `Page.menus` (frontmatter assignments) on the context stack. `Template_Context.params` is set per-page via `merge_params(site.params, page.params)` — site params overlaid with page params. Browser title is handled by the `{{> title}}` partial (not a computed field).
|
||||
`Site_Context` is embedded in `Site` via `using site_context`. Fields like `site.title`, `site.menus`, `site.params` are accessed directly on `Site` through promotion. `Template_Context.menus` is copied from `site.menus` to resolve above `Page.menus` (frontmatter assignments) on the context stack.
|
||||
|
||||
`render_site` pre-parses all partials and the base layout once (via `mustache.parse`), then per-layout templates are cached in `get_template`. Year-based grouping on section index pages is done in the template via `{{#posts | group_by year}}` (see Pipes extension below).
|
||||
|
||||
@@ -419,11 +399,12 @@ Spec-compliant implementation at `mustache/`. See `mustache/SPEC.md` for the imp
|
||||
|---|---|
|
||||
| `mustache.odin` | Public API (`parse`, `render`, `Template`), parser (`parse_section`), renderer (`render_nodes` with `Indent_State` for partial indentation), template inheritance (`merge_block_overrides`), `delete_template`/`delete_partials`. Pipe support in Variable/Unescaped/Section/Inverted tags. |
|
||||
| `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. |
|
||||
| `data.odin` | Reflection-based data model: `base_value` (peels union/any/nested-any layers), `lookup_in` (structs + maps, handles `Type_Info_Any` value kind in maps), `resolve_name`, `is_truthy`, `any_to_string`, `list_info`, `extract_list_element`, `call_interp_lambda`/`call_section_lambda` |
|
||||
| `pipes.odin` | Pipes extension: `Pipe_Filter` AST, `parse_pipeline` (takes `pos`), `apply_pipeline`, `apply_filter` (switch dispatch: `group_by` + `format`), `apply_group_by`, `apply_format`. Stored on `Node.filters`; render-scoped results in temp allocator. |
|
||||
| `diagnostic.odin` | 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. |
|
||||
| `lambda_test.odin` | Spec lambda tests |
|
||||
| `pipes_test.odin` | Pipe filter tests (`group_by` + `format`) |
|
||||
| `diagnostic_test.odin` | Golden-output tests for `format_error` (multi-line context, edge cases, alignment, caret position, hint) + parser error message brace-escaping |
|
||||
| `suggest_test.odin` | Tests for `validate_key_path`, `suggest_correction`, `struct_has_field` with `Maybe(bool)` and `using`-promoted fields |
|
||||
@@ -451,11 +432,7 @@ render(tmpl, data, partials) → render_nodes (walks flat node array against con
|
||||
|
||||
Rust-style error messages with multi-line source context, caret underlines, and Levenshtein suggestions. ANSI colors via `core:terminal/ansi`, gated on `should_colorize()` (TTY detection on stderr).
|
||||
|
||||
**Error types**: `Error_Body{msg, pos, kind, source, path, span, hint}` where `kind` is `Error_Kind.Syntax` (parse-time) or `Error_Kind.Data` (render-time). `source`/`path` carry the template the error originated in (set by `tag_error` — enables correct file/line for errors inside partials). `span` controls caret underline width (used by pipe op diagnostics). `hint` carries "did you mean?" suggestions. `Error` is a single-variant union wrapping `Error_Body` (nilable for `!= nil` / `or_return`).
|
||||
|
||||
**Error deduplication**: `render_template` takes `seen: ^map[string]bool`. Duplicate errors (same formatted diagnostic) are suppressed across page renders within a single build.
|
||||
|
||||
**Partial source tracking**: `tag_error(err, current)` stamps render-time errors with `current.source`/`current.path` at the 4 `apply_pipeline` call sites in `render_nodes`. Ensures errors inside partials point at the partial file, not the top-level template.
|
||||
**Error types**: `Error_Body{msg, pos, kind}` where `kind` is `Error_Kind.Syntax` (parse-time) or `Error_Kind.Data` (render-time). `Error` is a single-variant union wrapping `Error_Body` (nilable for `!= nil` / `or_return`).
|
||||
|
||||
**Strict-by-default warnings** — `render_nodes` emits `log.warnf` diagnostics for:
|
||||
- Unknown keys in `{{k}}`, `{{{k}}}`, `{{#k}}`, `{{^k}}` (via `validate_key_path` + `suggest_correction`)
|
||||
@@ -465,12 +442,18 @@ Rust-style error messages with multi-line source context, caret underlines, and
|
||||
|
||||
**Exceptions** (no warning):
|
||||
- `{{.}}` and dot-prefixed names (current context)
|
||||
- Paths that cross a map (e.g., `params.*`) — validated for typos via Levenshtein: close matches warn with a suggestion, genuinely absent keys are suppressed silently
|
||||
- Paths that cross a map (e.g., `params.*` — user-defined namespace)
|
||||
- `Maybe(bool)` fields with nil value (field exists, value is nil — distinguished via `struct_has_field`)
|
||||
- Found fields whose value is nil/empty (e.g., nil `json.Value` union) — the field exists, looking up sub-keys is valid "not found" behavior
|
||||
|
||||
**Block override source tracking**: `Block_Override.source: Template` ensures warnings inside block overrides point at the override's source file (e.g., `page.html`), not the parent template (`base.html`).
|
||||
|
||||
### Lambdas
|
||||
|
||||
Spec-compliant. Stored as `any` values in the data context.
|
||||
|
||||
- **Interpolation lambdas**: `proc() -> string`, `proc() -> int`, `proc() -> bool` — called via `call_interp_lambda`, result stringified and escaped.
|
||||
- **Section lambdas**: `proc(string) -> string`, `proc(string) -> int`, `proc(string) -> bool` — called via `call_section_lambda` with the raw section text (`node.content`). String result is re-parsed as mustache and rendered against the current context stack.
|
||||
|
||||
### Pipes
|
||||
|
||||
`{{key | op args…}}` for interpolation, `{{#key | op args…}}…{{/key}}` for sections. Stored as `[dynamic; MAX_PIPES]Pipe_Filter` on each `Node`. Applied in the renderer via `apply_pipeline` before truthiness/interpolation. Implemented filters:
|
||||
@@ -487,13 +470,12 @@ See `mustache/EXTENSIONS.md`.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- cmark allocates via C malloc, not the arena. HTML output leaks until process exit (problematic in watch mode — see `TODOS.md`).
|
||||
- CSS/JS cache busting uses manual `?v=N` query params instead of content hashing.
|
||||
- Tree-sitter grammar/query paths must be configured manually via `thor.json` (`grammars`, `queries`) — no auto-discovery. HTML/CSS are statically linked.
|
||||
- `map[string]any` only works through `lookup_in`'s special-case handling; thor otherwise uses structs.
|
||||
- `format_f64` in mustache brute-forces shortest float representation.
|
||||
- Content directory not mounted in VFS (modules can ship templates/assets but not content packs yet).
|
||||
- Per-page params rendering is incomplete — `merge_params` produces correct data, but `base_value` may return nil for `json.Value` fields accessed through the `[]any` context stack via reflection in some cases. Warning suppression masks this; actual rendering may not work for all param values.
|
||||
- Lambda support removed. Mustache lambdas (`proc() -> string` in data context) are not supported. Pipes cover data transformation.
|
||||
|
||||
## Design decisions
|
||||
|
||||
@@ -501,8 +483,6 @@ You may never, *ever* remove `TODO:` or `FIXME:` comments. Those are for humans,
|
||||
See `HUGO.md` for analysis of why thor doesn't need Hugo's shortcode context isolation.
|
||||
See `mustache/SPEC.md` for the original implementation specification.
|
||||
See `mustache/EXTENSIONS.md` for non-standard extensions (pipes).
|
||||
See `DIAGNOSTICS.md` for the two-tier diagnostic system design.
|
||||
See `mustache/TOKENIZERS.md` for tokenizer architecture comparison (Go, Liquid, Thor).
|
||||
|
||||
## Odin language facts
|
||||
|
||||
@@ -517,7 +497,6 @@ These are things that are easy to get wrong:
|
||||
- **`Maybe(T)` unwrap syntax:** `value.? or_else default`. Not `value or_else default` — `or_else` works on the `?T` returned by `.?`, not on `Maybe(T)` directly.
|
||||
- **`Maybe(T)` equality:** `a == b` works directly between two `Maybe(T)` values (nil == nil → true, some(5) == some(5) → true, nil == some(5) → false). Also `a == 5` works (int coerces to `Maybe(int)`).
|
||||
- **File logger in tests:** `log.create_file_logger(&f)` + `context.logger = logger` captures log output. Must be set inline in the test proc (not via a helper proc) for context propagation. Clean up with `log.destroy_file_logger(logger)` then `os.read_entire_file_from_path` to verify output.
|
||||
- **`fmt.sbprintf` writes directly to a `strings.Builder`.** Prefer `fmt.sbprintf(&sb, fmt, args...)` over `fmt.aprintf(fmt, args...)` + `defer delete` + `strings.write_string`. The `aprintf` pattern allocates an intermediate string, requires manual cleanup, and queues a `defer delete` per loop iteration. `sbprintf` avoids all of this.
|
||||
|
||||
## TODO
|
||||
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
# Asset Pipeline Plan
|
||||
|
||||
Design for letting thor users bring their own CSS toolchain (Sass, PostCSS,
|
||||
Lightning CSS, Tailwind, …).
|
||||
|
||||
## Thesis
|
||||
|
||||
Let thor users bring their own CSS toolchain by **orchestrating the tool they
|
||||
already installed** — never bundling or reimplementing one. This is expressed
|
||||
through thor's existing mustache pipe grammar with two new ops: `tool` and
|
||||
`fingerprint`.
|
||||
|
||||
The earlier research design (content-addressed registry + deferred token
|
||||
resolution + a separate post-render path for "whole-output scanners") is
|
||||
**discarded**. It was overbuilt. Every tool — Tailwind included — scans
|
||||
*source*, which exists at render time, so every transform runs **eagerly,
|
||||
synchronously, and memoized**. No placeholder tokens, no post-render
|
||||
string-replace pass, no second integration surface.
|
||||
|
||||
## The two ops
|
||||
|
||||
```html
|
||||
<link href="{{ "css/main.scss" | tool sass | fingerprint | rel_url }}">
|
||||
```
|
||||
|
||||
```json5
|
||||
// thor.json
|
||||
{
|
||||
"tools": {
|
||||
"sass": "sass $in $out",
|
||||
"postcss": "postcss $in -o $out",
|
||||
"lightningcss": "lightningcss --minify $in -o $out",
|
||||
"tailwind": "tailwindcss -i $in -o $out"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Core model
|
||||
|
||||
The value flowing through the pipe is **bytes held by thor**, with a logical
|
||||
name attached (`css/main.css`). Each stage transforms the bytes; the **final
|
||||
stage publishes** one file to the output dir and returns its URL.
|
||||
|
||||
- A literal (`"css/main.scss"`) or context key names the **source** file on disk.
|
||||
- `| tool <name>` runs a subprocess, replacing the carried bytes with its output.
|
||||
- `| fingerprint` writes the content-addressed file and returns its hashed URL.
|
||||
- The chain composes with the existing `rel_url` for base_url subpaths.
|
||||
|
||||
**Memoization:** results are cached per build, keyed by (op-chain, source). The
|
||||
first page to reference an asset runs the tool; every other page hits the cache.
|
||||
This is what stops N pages from shelling out N times.
|
||||
|
||||
## `| tool <name>`
|
||||
|
||||
Looks up `<name>` in the `thor.json` `tools` table and runs its command
|
||||
template. `$in` and `$out` are **optional per-tool I/O adapters**:
|
||||
|
||||
| Template has | Behavior |
|
||||
|---|---|
|
||||
| `$in` | thor writes current bytes to a scratch path, substitutes it |
|
||||
| no `$in` | thor pipes current bytes to the tool's **stdin** |
|
||||
| `$out` | thor substitutes a scratch path, reads it back after |
|
||||
| no `$out` | thor captures the tool's **stdout** |
|
||||
|
||||
Defaulting to stdin/stdout avoids scratch-file overhead when the tool supports
|
||||
it. Tools that can't stream (Lightning CSS wants a path; Tailwind v3 needs an
|
||||
input file) just get `$in`/`$out` in their template. Either way the bytes are
|
||||
thor's, so chaining works regardless of each tool's preference:
|
||||
`| tool sass | tool postcss` composes even if sass streams and postcss wants
|
||||
files.
|
||||
|
||||
- **`$out` is always thor-controlled scratch** — the tool never picks the
|
||||
published filename.
|
||||
- **Extension derivation:** thor owns the output name; `main.scss` through
|
||||
`tool sass` becomes `main.css`.
|
||||
- **Errors:** thor gates on exit code and surfaces the tool's stderr through one
|
||||
code path. If the tool is missing from PATH, fail with its stderr — never
|
||||
bundle, never assume a runtime.
|
||||
- **A bare `tool` (no `fingerprint`) still publishes** — to the logical unhashed
|
||||
path (`output/css/main.css`), returning `/css/main.css`.
|
||||
|
||||
## `| fingerprint`
|
||||
|
||||
Content-addresses whatever bytes it currently holds. It has **two outputs**:
|
||||
|
||||
- **Side effect:** writes `css/main.<hash>.css` to the output dir.
|
||||
- **Return value:** the hashed URL string `/css/main.<hash>.css` (feeds
|
||||
`rel_url`).
|
||||
|
||||
Properties:
|
||||
|
||||
- **Works standalone**, no `tool` needed: `{{ "css/main.css" | fingerprint }}`
|
||||
is plain cache-busting of a hand-written file — probably the most common use.
|
||||
- **Position decides what's hashed** — it hashes current bytes, so it belongs
|
||||
**last** in the chain. No terminal-only special-casing; the rule is just "put
|
||||
it last."
|
||||
- **Hash format:** `name.<hash>.ext` (CDN-friendly), 8 hex chars of SHA-256.
|
||||
|
||||
## Tailwind is not special
|
||||
|
||||
It scans your **source** (`layouts/`, `content/`, partials) — which exists at
|
||||
render time — so it runs synchronously like sass. Its `thor.json` entry just
|
||||
needs content globs pointing at the source tree (or v4 auto-detection). The
|
||||
**one** asymmetry is watch invalidation: sass re-runs when `main.scss` changes;
|
||||
Tailwind must re-run when *any* scanned source changes. That's a cache-key
|
||||
breadth difference, not an architectural one. The cost of scanning source vs.
|
||||
output is losing dynamically-assembled class tokens (`col-{{n}}`) — a Tailwind
|
||||
anti-pattern whose sanctioned fix is a safelist, not warping the build.
|
||||
|
||||
## Interaction: `copy_assets_dir`
|
||||
|
||||
Today thor copies everything under `assets/` to the output dir. Any source
|
||||
consumed by a pipe must be **excluded from that plain copy** — otherwise you'd
|
||||
ship the raw `main.scss` (or a dead unhashed `main.css` alongside the hashed
|
||||
one). The pipe owns the write for assets it touches.
|
||||
|
||||
## Implementation steps (separable)
|
||||
|
||||
1. **`| fingerprint`** — no subprocess, no config. Hash + write + return URL,
|
||||
plus the `copy_assets_dir` exclusion. Smallest blast radius, independently
|
||||
useful, ships first.
|
||||
2. **`| tool <name>`** — `thor.json` tools table, the `$in`/`$out`/stdin/stdout
|
||||
adapter, subprocess + exit-code/stderr handling, extension derivation,
|
||||
memoization. Composes with step 1.
|
||||
|
||||
## Open questions for implementation
|
||||
|
||||
- **Watch invalidation:** simplest correct v1 recomputes the memo each build
|
||||
(cheap for the common single-asset case). A cross-build skip keyed on source
|
||||
content/mtime is a later optimization — and Tailwind's broad content-glob key
|
||||
is where it matters most.
|
||||
- **Source-map sidecars:** sass/lightning emit `.map` files next to `$out`;
|
||||
disable in the template or ignore the sidecar. Minor.
|
||||
- **Error policy default:** hard-fail the build vs. warn-and-skip on tool
|
||||
failure/absence. Lean fail; possibly configurable later.
|
||||
@@ -1,96 +0,0 @@
|
||||
# Visualizing & Managing `thor` Binary Size
|
||||
|
||||
How to inspect what makes up the compiled `thor` binary, and how to keep that
|
||||
inspection useful once we bundle layout/asset files into the binary.
|
||||
|
||||
## Quick inspection (tools already available)
|
||||
|
||||
The binary is a not-stripped ELF, so it ships symbols we can break down.
|
||||
|
||||
```bash
|
||||
# Section totals (text / data / bss)
|
||||
size ./thor
|
||||
|
||||
# Top symbols by size
|
||||
nm --print-size --size-sort --radix=d ./thor \
|
||||
| awk '{sz=$2+0; sub(/^[0-9a-fA-F]+ +[0-9]+ +[A-Za-z] +/,""); printf "%8d %s\n", sz, $0}' \
|
||||
| sort -rn | head -25
|
||||
|
||||
# Aggregate symbol sizes by Odin module namespace (foo::bar -> "foo")
|
||||
nm --print-size --size-sort --radix=d ./thor \
|
||||
| awk '{sz=$2+0; rest=$0; sub(/^[0-9a-fA-F]+ +[0-9]+ +[A-Za-z] +/,"",rest);
|
||||
ns=rest; if (rest ~ /::/){sub(/::.*/,"",ns)}
|
||||
else if (rest ~ /^ts_/){ns="treesitter (ts_*)"} else {ns="<other/runtime>"};
|
||||
s[ns]+=sz} END{for (k in s) printf "%9d %s\n", s[k], k}' \
|
||||
| sort -rn | head -25
|
||||
```
|
||||
|
||||
As of this writing the top contributors were: tree-sitter tables (~118 KB),
|
||||
`main` (~96 KB), runtime/other (~93 KB), `mustache` (~67 KB), then `fmt` /
|
||||
`encoding_json` from the stdlib.
|
||||
|
||||
## Better visualization: `bloaty`
|
||||
|
||||
`bloaty` (Google's binary-size profiler) is the right tool. Not installed, but
|
||||
runnable via Nix:
|
||||
|
||||
```bash
|
||||
bloaty ./thor # by section
|
||||
bloaty ./thor -d symbols -n 30 # by symbol, hierarchical
|
||||
bloaty ./thor -d sections,symbols # section then symbols within each
|
||||
# via nix (no install):
|
||||
nix run nixpkgs#bloaty -- ./thor -d symbols -n 30
|
||||
```
|
||||
|
||||
For a stripped-size estimate:
|
||||
|
||||
```bash
|
||||
readelf -S ./thor | grep -E 'debug|symtab|strtab' # strippable overhead
|
||||
strip -s ./thor -o thor.stripped && size thor.stripped
|
||||
```
|
||||
|
||||
Odin also supports smaller builds: `odin build . -o:size -no-bounds-check`.
|
||||
|
||||
## IMPORTANT: keeping bloaty useful once we bundle layouts into the binary
|
||||
|
||||
Today the VFS (`vfs.odin`) reads layout/asset files lazily from disk at runtime
|
||||
(`VFS_Entry.fs_path` + `os.read_entire_file_from_path`); `VFS_Entry.data` is
|
||||
never populated at build time. So embedded content is **not** in `./thor` yet.
|
||||
|
||||
When we switch to embedding via Odin `#load(...)`, the file bytes land in the
|
||||
binary's `.rodata` section. Whether bloaty can show *per-file* sizes depends on
|
||||
how we emit the embed:
|
||||
|
||||
- **Section total — always visible.** `.rodata` grows by ~the total bundled
|
||||
size. `bloaty ./thor -d sections` (or `size`) shows the aggregate, and we can
|
||||
diff before/after. This always works.
|
||||
|
||||
- **Per-file breakdown — only if each file is a NAMED symbol.**
|
||||
- A generated `map[string]VFS_Entry` literal full of inline `#load(...)`
|
||||
expressions makes each blob an **anonymous** `.rodata` constant. Bloaty
|
||||
lumps them together — no per-file breakdown.
|
||||
- Assigning each file to its own **package-level variable** makes each a named
|
||||
global symbol, so `bloaty ./thor -d symbols` (and the `nm` recipe above)
|
||||
show each file individually:
|
||||
|
||||
```odin
|
||||
layout_base_html := #load("defaults/layouts/base.html")
|
||||
layout_single_html := #load("defaults/layouts/single.html")
|
||||
// ...then reference these in the VFS map instead of inlining #load.
|
||||
```
|
||||
|
||||
### Decision to bake into the bundling codegen
|
||||
|
||||
When we build the codegen that replaces the runtime `mount_dir` disk walk, have
|
||||
it emit **one named package-level variable per embedded file** (not an anonymous
|
||||
map literal). That single choice is what makes bloaty / `nm` per-file analysis
|
||||
possible — cheap to do up front, painful to retrofit.
|
||||
|
||||
Additional options that help regardless of symbolization:
|
||||
|
||||
1. Build with Odin `-debug` (DWARF) and use `bloaty ./thor -d compileunits` to
|
||||
attribute embedded bytes to the generated source file.
|
||||
2. Since the generator already knows every file and its size, emit a size
|
||||
manifest at bundle time (`path -> bytes`, sorted / treemap). This is the most
|
||||
reliable per-file view and can't drift from reality; bloaty then just
|
||||
confirms the `.rodata` total matches.
|
||||
@@ -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, '
|
||||
@@ -1,171 +0,0 @@
|
||||
# Grammar cache thread-safety & allocator lifetime
|
||||
|
||||
Status: **resolved.** All three issues below are fixed. The cache was redesigned
|
||||
so the library caches only the immutable, shareable objects and callers own the
|
||||
per-thread ones. The rest of this document is kept as the rationale.
|
||||
|
||||
## Resolution (what shipped)
|
||||
|
||||
The `Grammar_Cache` (which mixed shareable `language`/`query` with per-thread
|
||||
`parser`/`cursor`) was split:
|
||||
|
||||
- **`Grammar`** holds only the immutable `language` + `query`. It lives in a
|
||||
process-lifetime **`registry`** (`map[string]^Grammar`) guarded by one mutex.
|
||||
- **`grammar(lang)`** is the thread-safe, self-initializing, lazy loader — fixes
|
||||
**#1a** (all map access under the lock) and lets tests run with no setup.
|
||||
- The registry allocator is pinned with `os.heap_allocator()` — fixes **#2**; the
|
||||
cache no longer captures a transient `context.allocator`.
|
||||
- Parsers/cursors are **caller-owned**: `open_parser(g)` mints one, the caller
|
||||
`defer`s `parser_delete` / `query_cursor_delete`. No parser is shared, so
|
||||
**#1b** is gone. `open_parser` is the seam where a future pool / thread-local
|
||||
strategy plugs in without touching call sites.
|
||||
- Deleted: `ensure_parser`, `load_grammar`, `preload_grammar`, `grammar_worker`,
|
||||
`cache_mutex`; `preload_grammars` is now a serial registry warm-up. The minify
|
||||
tests dropped their `parse_mu` serialization and preload scaffolding entirely.
|
||||
|
||||
The **later** decision — pool vs per-thread parser reuse for genuine parallel
|
||||
parsing — is still open, but intentionally so: it only matters once we parse in
|
||||
parallel, and it now changes only `open_parser`, not the rest of the library.
|
||||
The original investigation follows.
|
||||
|
||||
## How we got here
|
||||
|
||||
While adding a feature to minify inline `<style>` blocks (route the CSS body
|
||||
through `minify_css`), the new unit tests exercised `minify_html` / `minify_css`
|
||||
for the first time. Those are the first tests to touch the tree-sitter parser
|
||||
cache, and under the multi-threaded test runner (default 16 threads) they failed
|
||||
**intermittently** with a rotating cast of symptoms:
|
||||
|
||||
- `panic: unable to insert into a map`
|
||||
- tree-sitter internal aborts: `ts_subtree_retain: Assertion 'self.ptr->ref_count > 0' failed`, `_array__erase: Assertion 'index < *size' failed`
|
||||
- `malloc(): unaligned tcache chunk detected`
|
||||
- assertions passing on the wrong data (e.g. minified output missing `<style>`)
|
||||
|
||||
The flakiness comes from **three distinct problems**, two thread-safety and one
|
||||
allocator-lifetime. They are orthogonal — fixing one does not fix the others.
|
||||
|
||||
## The three issues
|
||||
|
||||
### #1a — concurrent cache **writes** (data race)
|
||||
|
||||
`ensure_parser` (`treesitter.odin`) reads and writes `grammar_store.cache`
|
||||
with **no lock**. `cache_mutex` exists but is only taken on the parallel preload
|
||||
path (`grammar_worker`), never in the lazy `ensure_parser` / `load_grammar`
|
||||
paths. Two threads first-touching `"html"`/`"css"` at once corrupt the map.
|
||||
|
||||
### #1b — concurrent parser **use** (data race)
|
||||
|
||||
There is exactly **one cached `Grammar_Cache` (and one `parser`) per language**.
|
||||
When two threads both call `minify_html`, they fetch the *same* `gc.parser` and
|
||||
call `parser_parse_string` on it simultaneously. A tree-sitter `TSParser`
|
||||
mutates its internal subtree pool during a parse, so concurrent parses corrupt
|
||||
it — that's the `ts_subtree_retain` / `_array__erase` aborts.
|
||||
|
||||
This is **not fixed by preloading/"loading html+css statically."** Loading gives
|
||||
you *one shared* parser; the race is about *using* that one parser from two
|
||||
threads. It is fixed only by serializing the parse or giving each thread its own
|
||||
parser.
|
||||
|
||||
### #2 — allocator lifetime (not a race)
|
||||
|
||||
`init_persistent` captures `context.allocator` into the process-lifetime
|
||||
`grammar_store`. In `main` that happens to be the heap (it runs before the site
|
||||
arena is installed at `main.odin`), so production is fine. But whatever
|
||||
allocator is live at that instant becomes the cache's owner forever:
|
||||
|
||||
- Under the **test runner**, `context.allocator` is a **per-test tracking
|
||||
allocator** that is torn down when that test ends → the global cache is left
|
||||
pointing at dead memory (→ `unable to insert into a map`, garbage reads).
|
||||
- A **lazy** first call during rendering would capture the **site arena**
|
||||
(`context.allocator` after `main.odin` swaps it in), which is destroyed every
|
||||
watch-mode rebuild → dangling parsers across builds.
|
||||
|
||||
This would break even single-threaded; threading just decides *which* test wins
|
||||
the init race.
|
||||
|
||||
## tree-sitter's threading model (the root fact)
|
||||
|
||||
tree-sitter objects have specific, documented threading rules:
|
||||
|
||||
| Object | Shareable across threads? | Rule |
|
||||
|---|---|---|
|
||||
| `TSLanguage` (`tree_sitter_html()` etc.) | Yes | Immutable; share freely. |
|
||||
| `TSParser` (`gc.parser`) | **No** | One per thread; never parse on one parser from two threads. |
|
||||
| `TSTree` | Only via `ts_tree_copy` | Copy before using on another thread. |
|
||||
| `TSQuery` (`gc.query`) | Yes | Immutable after compile. |
|
||||
| `TSQueryCursor` (`gc.cursor`) | **No** | One per thread, like the parser. |
|
||||
|
||||
This explains the existing design:
|
||||
|
||||
- The preload fan-out (`preload_grammar` / `grammar_worker`) creates a parser
|
||||
**per worker** and only publishes the finished `Grammar_Cache` under
|
||||
`cache_mutex` — because parsers can't be shared during construction/use.
|
||||
- `ensure_parser` + the shared cached parser is fine in production **only
|
||||
because rendering is single-threaded**.
|
||||
|
||||
A cached `Grammar_Cache` mixes shareable (`language`, `query`) and per-thread
|
||||
(`parser`, `cursor`) objects in one shared struct, which makes it
|
||||
single-threaded-use by construction — a fact that was previously undocumented.
|
||||
|
||||
## Where this actually bites
|
||||
|
||||
**Only in tests.** Confirmed by grep:
|
||||
|
||||
- Production parses on one thread: every real caller — `minify.odin` (html/css)
|
||||
and `markdown/highlight.odin` — runs inside `main`'s single-threaded render
|
||||
loop. No concurrent `ensure_parser` / `parser_parse_string`.
|
||||
- The one threaded path in production, `preload_grammars`, is already safe (own
|
||||
parser per worker + `cache_mutex` on the write) and only handles non-builtin
|
||||
grammars.
|
||||
- Only `minify_test.odin` touches tree-sitter from tests, and the test runner
|
||||
runs those on many threads.
|
||||
|
||||
So production is correct **by convention** (single-threaded-from-heap usage),
|
||||
not **by construction** — nothing stops a future parallel render from breaking
|
||||
it, and until now nothing documented the constraint.
|
||||
|
||||
## Chosen direction
|
||||
|
||||
**For now:** keep the single global grammar cache and make it safe by
|
||||
**serializing the non-thread-safe behavior with a mutex** — only one thread uses
|
||||
a parser at a time. This is simple, matches how production already behaves, and
|
||||
unblocks the tests.
|
||||
|
||||
**Later (TODO):** decide whether true multi-threaded parsing is worth it for
|
||||
performance. If yes, give each thread its own parser + cursor (as the preload
|
||||
workers already do) rather than sharing one behind a lock.
|
||||
|
||||
## Implementation options (deferred — pick when we resume)
|
||||
|
||||
All assume the allocator fix (#2) below, which is independent and unambiguous.
|
||||
|
||||
- **#2 allocator (do regardless):** pin the heap allocator explicitly —
|
||||
`grammar_store.allocator = runtime.heap_allocator()` (or `os.heap_allocator()`)
|
||||
instead of `context.allocator`. Makes the cache persistent from any context;
|
||||
behavior-identical in production. Optionally make `init_persistent` idempotent
|
||||
and/or an `@(init)` so callers need no ordering.
|
||||
|
||||
Then, for the races:
|
||||
|
||||
- **Option A — library mutex (sustainable):** take `cache_mutex` in
|
||||
`ensure_parser` (fixes #1a) and add a locking `parse` helper that wraps
|
||||
`parser_parse_string` (fixes #1b), wired into the three call sites
|
||||
(`minify.odin` ×2, `markdown/highlight.odin` ×1). Tests then need no
|
||||
scaffolding. Serializes all parsing.
|
||||
|
||||
- **Option B — test-side only (minimal):** leave the library single-threaded by
|
||||
contract; make the tests not violate it. Either fold the minify tests into one
|
||||
`@(test)` (no concurrency at all → no locks), or wrap the `minify_*` calls in a
|
||||
test-local mutex. Production stays lock-free.
|
||||
|
||||
Recommendation captured during the discussion: the allocator one-liner is
|
||||
worth doing immediately; for the races, Option A is the "correct" long-term
|
||||
answer but only necessary if/when we want concurrent parsing — otherwise
|
||||
Option B is enough.
|
||||
|
||||
## Pointers
|
||||
|
||||
- `treesitter/treesitter.odin`: doc comments on `Grammar_Cache`, `cache_mutex`,
|
||||
`init_persistent`, `ensure_parser` reference this file.
|
||||
- Original feature that surfaced this: minify inline `<style>` bodies via
|
||||
`minify_css` (reverted; to be re-landed alongside whichever fix we choose).
|
||||
@@ -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.
|
||||
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
## v0.2.0
|
||||
|
||||
- [ ] [aliases](https://gohugo.io/methods/page/aliases/#redirects)?
|
||||
- [ ] consider sites with date based urls.
|
||||
- [ ] Create a json schema file for `thor.json`.
|
||||
- [ ] Content-hash fingerprinting for CSS and JS cache busting
|
||||
- [ ] Mount content in VFS
|
||||
- [ ] `-watch` flag
|
||||
- [x] basic poll loop
|
||||
- [ ] filesystem poll loop
|
||||
- [ ] event based
|
||||
- [ ] if no `html` tag detected in output, re-render output with base template
|
||||
(or whatever template is next in the chain). (Hugo seems to do this)
|
||||
- [ ] Markdown
|
||||
- [ ] Block attributes extension — hello-world.md
|
||||
- [ ] add tables extension
|
||||
- [ ] enable template level rendering of TOCs
|
||||
- [ ] Write css for toc sidebar and figure out where to put it.
|
||||
- [ ] Add [hugo style configuration](https://gohugo.io/configuration/markup/#table-of-contents)
|
||||
- [ ] implicit page titles (Set when missing?)
|
||||
- guess from h1 tag or first h2 tag?
|
||||
- [ ] Syntax highlighting
|
||||
- [ ] grammars
|
||||
- [ ] load grammars dynamically
|
||||
- [ ] Search in multiple places
|
||||
- [ ] Download missing grammars.
|
||||
- [ ] Durable highlight paths: read `GRAPHS_PATH`/`QUERIES_PATH` from env vars set by the flake instead of hardcoded nix store hashes, so they survive `nix flake update` and let the grammar/query version-mismatch detector fire automatically.
|
||||
- [ ] Theme selector
|
||||
- use http://github.com/helix-editor/helix/tree/master/runtime/themes) as a
|
||||
guide
|
||||
- [ ] Unit tests for highlighting helpers: `capture_name_to_css`, `escape_html`, `unescape_html`, `extract_query_token`, `helix_version_from_path`.
|
||||
- [ ] `<pre><code>` blocks need to set background to theme background,
|
||||
regardless of prefers-dark. (or use a different theme)
|
||||
- [ ] Avoid `json.Value` / `json.Object` where possible.
|
||||
- [ ] come up with scrapers / scrape sources to harvest site data
|
||||
- we'll use this to help us sculpt defaults.
|
||||
- [ ] merge `render_{section,home_html,page_html}` procs.
|
||||
- [ ] try to combine render_page_html and render_home_html?
|
||||
- [ ] make `parse` an overload of `parse_text/parse_inline` and `parse_file`, or something.
|
||||
- [ ] refactor `mustache.load_partials` to accept an iterator so we don't copy `main.load_partials`
|
||||
|
||||
### Menus
|
||||
|
||||
- [ ] Nav items should be active when the current page is selected.
|
||||
- [ ] configure opt-out of automatic sections being added to menu.
|
||||
- [ ] nested menus (i.e. `parent` support)
|
||||
|
||||
### Strict Mode
|
||||
|
||||
- [ ] opt-in "strict_keys" mode. in this mode, key lookups may not view parent objects.
|
||||
- [ ] warn/error when unknown key used in mustache.
|
||||
- [ ] enforce heading structure.
|
||||
- [ ] Either frontmatter.title set, or 1 h1 tag at top, not both
|
||||
- [ ] No skipping.
|
||||
|
||||
### Dates
|
||||
- [ ] display an error when no part of the date appears in the output.
|
||||
- [ ] Handle 0 and whitespace padding i.e. "_2" -> " 2"
|
||||
- [ ] Do we *need* mustache.Date_Components, or can we use core:time/datetime.DateTime?
|
||||
|
||||
## Beyond
|
||||
|
||||
- [ ] consider logging the number of times an error occurred. (diagnostics)
|
||||
- [ ] Need to be careful about diagnostics across module boundaries.
|
||||
- we don't necessarily want to warn users about theme designers mistakes. (though perhaps we do)
|
||||
- [ ] consider reporting duplicate weights outside of menus (diagnostic)
|
||||
- [ ] Do mustache's whitespace rules actually suit us, or should we make our own?
|
||||
- [ ] create a default `head.html`?
|
||||
- [ ] improve home link customization.
|
||||
- [ ] currently an accessibility issue.
|
||||
- [ ] get rid of the global variables in the `treesitter` package.
|
||||
- [ ] site-wide Integrity hash
|
||||
- Allows users to verify their output didn't change after upgrading to a new version
|
||||
- [ ] Add `-production` flag
|
||||
- sets `-minify`
|
||||
- [ ] manually pass `site_allocator` to `load_page`
|
||||
- [ ] Import/export packages. Hugo, jekyll, WordPress, etc.
|
||||
- [ ] shortcodes?
|
||||
- include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin
|
||||
- [ ] follow symlinks in `scan_content`?
|
||||
- [ ] Add overloads for every md extension? i.e. accept ^strings.Builder.
|
||||
- [ ] content link checker?
|
||||
- Checks all links on each page to make sure they are valid.
|
||||
- [ ] Peruse [GitHub's](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts)
|
||||
docs for any juicy nuggets we may have missed.
|
||||
- [ ] Avoid using `render_inline_md` if possible.
|
||||
- [ ] don't use bullshit "sub-tokens", add filters and pipes as proper tokens.
|
||||
- [ ] Create a Location struct that somewhat matches Odin's [Source_Code_Location](https://pkg.odin-lang.org/base/runtime/#Source_Code_Location)?
|
||||
- Note that odin's version doesn't contain the stack trace.
|
||||
- [ ] Link checker / linter that warns if links don't resolve. ("please use {{whatever | rel_url }}").
|
||||
|
||||
### Memory Management
|
||||
|
||||
- [ ] Leaks in highlighter code.
|
||||
- [ ] Not sure whether to use temp allocator or site_allocator in opengraph.odin.
|
||||
- [ ] Not sure whether to use temp allocator or site_allocator in `site_load_content`.
|
||||
- [ ] Might not need to allocate in `strip_html_tags`
|
||||
- [ ] Fix `apply_filter`'s `format` case (`mustache/pipes.odin`) boxing `apply_format`'s
|
||||
`string` result into `any` via bare `return`, which materializes a hidden
|
||||
header temp in `apply_filter`'s own stack frame. Dangling once the frame
|
||||
returns; caused the `-o:speed` segfault in `write_value`. Fix: box explicitly
|
||||
with `any{new_clone(formatted, context.temp_allocator), typeid_of(string)}`.
|
||||
- [ ] Same pattern in `apply_group_by` (`mustache/pipes.odin`): `return groups, nil`
|
||||
boxes a freshly-built `[dynamic]Group` as bare `any` — same latent
|
||||
stack-temp UB, hasn't crashed yet but should get the same treatment.
|
||||
|
||||
### Performance
|
||||
|
||||
- [ ] See if we can disable bounds checks in `write_indented` and elsewhere.
|
||||
- [ ] Instead of loading the site fresh each time in watch mode, create a
|
||||
`reload_site` proc, that just updates changed resources.
|
||||
- [ ] Only publish referenced assets.
|
||||
- [ ] Split `load_page` into frontmatter-parse + body-process phases so draft pages can skip the markdown pipeline entirely
|
||||
- [ ] Use spall to find ways to reduce run time.
|
||||
- [ ] too many `write_string` calls in `highlight_block`
|
||||
- [ ] return `src: cstring` from `load_query`.
|
||||
- [ ] Improve `unescape_html` with simd.
|
||||
- [ ] generate summary before syntax highlighting.
|
||||
- [ ] generate summary before markdown to html conversion.
|
||||
- [ ] mount_recursive is pretty significant
|
||||
- [ ] thread pool for grammar loading is unbounded.
|
||||
- [ ] load grammars async.
|
||||
- [ ] during `load_page`:
|
||||
- pass each code block to the treesitter queue
|
||||
- continue working on the page,
|
||||
- `await` the highlighted code.
|
||||
- [ ] can markdown extensions run in parallel?
|
||||
- [ ] enforce MAX_SLUG_LENGTH
|
||||
- [ ] enforce MAX_CONTEXT_DEPTH
|
||||
- [ ] ensure struct fields are ordered correctly
|
||||
|
||||
@@ -1,143 +1,249 @@
|
||||
# TODOs
|
||||
## High priority
|
||||
|
||||
## Priority Unclear (Evaluate Later)
|
||||
- Polish existing features before moving on to new ones.
|
||||
- [ ] Improve diagnostics
|
||||
- [x] keep track of every error and don't report them more than 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.
|
||||
- [ ] show a proper diagnostic for timezones
|
||||
- currently "unable to load timezone 'America/New_Yorkskie'"
|
||||
- want rust style diagnostic and better message, maybe "unknown timezone 'America/New_Yorkskie'"
|
||||
- [ ] Test menu diagnostics
|
||||
- [ ] Honestly, Test **all** diagnostics
|
||||
- [ ] Need to be careful about diagnostics across module boundaries.
|
||||
- we don't necessarily want to warn users about theme designers mistakes. (though perhaps we do)
|
||||
- [ ] consider reporting duplicate weights outside of menus
|
||||
- [ ] Extend `tag_error` to all render-time errors, not just pipe errors.
|
||||
Currently only pipe errors (4 sites in `render_nodes`) get stamped with
|
||||
the correct template source/path. Other render errors still use the
|
||||
content template's source/path, which can point at the wrong file.
|
||||
- [ ] try to make file paths clickable links.
|
||||
- [ ] centralize diagnostics to one place.
|
||||
- [ ] consider logging the number of times an error occurred.
|
||||
- [ ] Load grammars dynamically
|
||||
- [ ] 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.
|
||||
- [ ] Don't show annoying log output in tests.
|
||||
- [ ] improve home link customization.
|
||||
- [ ] currently an accessibility issue.
|
||||
- [ ] support JSON5 in in frontmatter
|
||||
- [ ] Create a json schema file for `thor.json`.
|
||||
- [ ] cleanup `#partial switch`es.
|
||||
- [ ] improve json diagnostics.
|
||||
- i.e. "Missing quotes around string", etc.
|
||||
- [ ] don't use bullshit "sub-tokens", add filters and pipes as proper tokens.
|
||||
|
||||
- [ ] Decide the parallel-parsing strategy for the grammar cache (pool vs
|
||||
thread-local parsers) — deferred until the parallel usage shape is clear.
|
||||
- Resolved already: the registry (`grammar()`) is thread-safe and self-
|
||||
initializing, its allocator is pinned to the heap, and parsers/cursors are now
|
||||
caller-owned (`open_parser`) instead of cached — so the library holds no
|
||||
single-thread-use state and the multi-threaded tests pass without locks.
|
||||
- Remaining choice only matters once we actually parse in parallel: reuse
|
||||
parsers via a pool or per-thread storage, plugged in behind `open_parser`
|
||||
without touching call sites. A `TSParser`/`TSQueryCursor` is one-per-thread.
|
||||
- Full write-up: `GRAMMAR_CACHE_THREADING.md`.
|
||||
- [x] Restore parallel grammar preload (serialized in `edb2e64`); `preload_grammars`
|
||||
now fans `build_grammar` across a bounded `core:thread.Pool`.
|
||||
- [ ] Add tests: pool fan-out correctness + `-sanitize:thread` for race-freedom.
|
||||
- [ ] Concurrent-read path: an `RW_Mutex` on `grammar()` only helps a future
|
||||
multi-threaded render, not the load side. Revisit if render goes parallel.
|
||||
- [ ] instead of `warnings: [dynamic]Error` we should use `warnings: [dynamic; 8]Error`
|
||||
- when limit reached, the template fails and stops rendering.
|
||||
- [ ] Brainstorm plugin/tool/(postccs/less/tailwind/sass) support.
|
||||
- [ ] `fingerprint` pipe + contents-based composition. The `tool` pipe currently
|
||||
carries a URL string and each stage publishes a file to the output dir, so
|
||||
`| tool sass | fingerprint` would leave a dead unhashed intermediate. When
|
||||
fingerprint lands, move the pipe value from a URL string to a contents/
|
||||
Resource (bytes + logical name) where only the terminal stage publishes;
|
||||
a Resource must stringify to its URL so existing bare-`tool` templates keep
|
||||
working. stdin/stdout tools compose best there (bytes stay in memory), which
|
||||
is why stdin/stdout is the documented preferred form over `{{in}}`/`{{out}}`.
|
||||
- [ ] Reconsider `"menus": {}` opt-out: it currently discards all page frontmatter
|
||||
menus (with only a warning). Decide whether empty config should instead just
|
||||
suppress the auto-generated menu while still honoring frontmatter.
|
||||
- [ ] asset css files not automatically included in site.
|
||||
- should we at least warn about them?
|
||||
- [ ] Review the whole `copy_assets_dir` pipeline. It copies every file under
|
||||
`assets/` to the output dir blindly, whether or not anything references it,
|
||||
and now also skips dests that already exist (a blunt collision guard so the
|
||||
raw copy doesn't clobber `tool`-pipe output written earlier in the build).
|
||||
Reconsider tracking which assets are actually used/published instead.
|
||||
- [ ] warn if stylesheet url starts with /assets (and is therefore invalid).
|
||||
- [ ] warn when heading levels are skipped in a toc.
|
||||
- [ ] extract `[TOC]` from `.md` body (or at least from the start.)
|
||||
- [ ] add `--production` flag
|
||||
- set `minify` to true
|
||||
- warn when `base_url` unset
|
||||
## 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
|
||||
|
||||
## 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.
|
||||
- [ ] Add conventional (Hugo style) footnotes option.
|
||||
- [ ] Add opt-in deflist support.
|
||||
- [ ] Decide if lambdas actually provide any value.
|
||||
|
||||
## Dates
|
||||
- [ ] display an error when no part of the date appears in the output.
|
||||
- [ ] Handle 0 and whitespace padding i.e. "_2" -> " 2"
|
||||
- [ ] Do we *need* mustache.Date_Components, or can we use core:time/datetime.DateTime?
|
||||
|
||||
## General
|
||||
- [ ] 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.
|
||||
- [ ] Consider using `or_else` when applying default values to structs. i.e.
|
||||
```odin
|
||||
package main
|
||||
|
||||
Remember to polish existing features before moving on to new ones.
|
||||
X :: struct {
|
||||
foo: string
|
||||
}
|
||||
|
||||
- [ ] 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.
|
||||
main :: proc () {
|
||||
x: X
|
||||
|
||||
x.foo = x.foo or_else "bar"
|
||||
}
|
||||
```
|
||||
- [ ] Integrity hash
|
||||
- Allows users to verify their output didn't change after upgrading to a new version
|
||||
- [ ] Content-hash fingerprinting for CSS and JS cache busting
|
||||
- [ ] come up with scrapers / scrape sources to harvest site data
|
||||
- we'll use this to help us sculpt defaults.
|
||||
- [ ] merge `render_{section,home_html,page_html}` procs.
|
||||
- [ ] try to combine render_page_html and render_home_html?
|
||||
- [ ] Debug log stats. (analytics)
|
||||
- [ ] final Context_Stack cap
|
||||
- [ ] highest PIPE args used
|
||||
- [ ] longest slug length + name that generated it
|
||||
- [ ] number of pages
|
||||
- [ ] number of blocks
|
||||
- [ ] enabled features / extensions
|
||||
- [ ] etc
|
||||
- [ ] Avoid `json.Value` / `json.Object` where possible.
|
||||
- [ ] make `parse` an overload of `parse_text/parse_inline` and `parse_file`, or something.
|
||||
- [ ] Add page params
|
||||
- [ ] We must remove all mention of `posts` from the odin code.
|
||||
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
|
||||
- [ ] does it make sense for partials to be inside layouts?
|
||||
- current: `layouts/partials`
|
||||
- alt1: `layouts`, `partials`
|
||||
- alt2: `templates/layouts`, `templates/partials`
|
||||
- [ ] add `sort_by` pipe
|
||||
- [ ] pipes
|
||||
- [x] first/last
|
||||
- [ ] write test for error when n == 0
|
||||
- [x] warn when using negative numbers
|
||||
- [x] warn when using default arg on string
|
||||
- [x] add the above warnings to DIAGNOSTIC_TESTS.md
|
||||
- [ ] Instead of "clearer form", be exact: "last 5"
|
||||
- [ ] write tests for `first 0` & `last 0`
|
||||
- [ ] update tests to assert diagnostics when appropriate
|
||||
- do we want to do this or just use DIAGNOSTIC_TESTS.md as the tests?
|
||||
- [ ] also update to use file logger so they don't clog up the output
|
||||
- [ ] narrow highlight in diagnostic to relevent part of line.
|
||||
- [ ] page.url and page.permalink are confusing names. Switch to url + page_url
|
||||
- There is confusion over how to do this and whethor or not `| rel_url` should be needed to render these.
|
||||
Requires **careful thought**.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [ ] talk about the context stack (and its limit).
|
||||
- [ ] highlight the differences in the way menus are handled.
|
||||
- [ ] explain that html/css/js comments are rendered, except in minify mode.
|
||||
|
||||
## Investigate
|
||||
|
||||
- [ ] running ./thor/thor still logs the debug message: using config /home/spencer/github.com/sbrow.github.io/thor.json
|
||||
- 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.
|
||||
- [ ] Could be better error message when missing a closing (or opening) brace
|
||||
- [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.
|
||||
- [ ] Table of contents support.
|
||||
- [ ] Nav items should be active when the current page is selected.
|
||||
- [ ] Theme selector for syntax highlighting.
|
||||
- use http://github.com/helix-editor/helix/tree/master/runtime/themes) as a
|
||||
guide
|
||||
- [ ] grammars
|
||||
- [ ] Search in multiple places
|
||||
- [ ] Download missing grammars.
|
||||
- [ ] Durable highlight paths: read `GRAPHS_PATH`/`QUERIES_PATH` from env vars set by the flake instead of hardcoded nix store hashes, so they survive `nix flake update` and let the grammar/query version-mismatch detector fire automatically.
|
||||
- [ ] CI
|
||||
- [ ] Syntax highlighting in production: CI (`nix build` on ubuntu-latest) has no grammar `.so`s and a machine-specific `QUERIES_PATH` nix store hash, so the deployed site renders unhighlighted. Provide grammars + queries as nix build inputs and pass paths to thor at runtime (env vars/flags).
|
||||
- [ ] Unit tests for highlighting helpers: `capture_name_to_css`, `escape_html`, `unescape_html`, `extract_query_token`, `helix_version_from_path`.
|
||||
- [ ] `<pre><code>` blocks need to set background to theme background,
|
||||
regardless of prefers-dark. (or use a different theme)
|
||||
- [ ] `-watch` flag
|
||||
- [x] basic poll loop
|
||||
- [ ] filesystem poll loop
|
||||
- [ ] event based
|
||||
- [ ] Free cmark HTML output (`body_html`) — cmark allocates via C malloc, not the arena, so it leaks per iteration in watch mode
|
||||
- [ ] Mount content in VFS
|
||||
- [ ] commands
|
||||
- [ ] `build` alias of default
|
||||
- [ ] `new site` set up new project
|
||||
- [ ] warn/error when unknown key used in mustache.
|
||||
- [ ] Import/export packages. Hugo, jekyll, WordPress, etc.
|
||||
- [ ] opt-in "strict_keys" mode. in this mode, key lookups may not view parent objects.
|
||||
|
||||
## Diagnostics
|
||||
## Notes
|
||||
|
||||
All diagnostics (errors) should show
|
||||
from the [Hugo docs](https://gohugo.io/quick-reference/glossary/#default-sort-order)
|
||||
|
||||
- *What* went wrong
|
||||
- *where* (in the file)
|
||||
- *where* (in the stack trace)
|
||||
- *how* you can fix it (if applicable)
|
||||
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)
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] only report format errors once.
|
||||
- [ ] only report missing partial errors once.
|
||||
- [ ] better diagnostics for syntax errors in treesitter.
|
||||
- [ ] Ensure diagnostics for MAX_CONTEXT_DEPTH are good.
|
||||
- [ ] improve matching weights message.
|
||||
- [ ] Test menu diagnostics
|
||||
- [ ] Extend `tag_error` to all render-time errors, not just pipe errors.
|
||||
Currently only pipe errors (4 sites in `render_nodes`) get stamped with
|
||||
the correct template source/path. Other render errors still use the
|
||||
content template's source/path, which can point at the wrong file.
|
||||
## Code Review
|
||||
|
||||
## Analytics
|
||||
A human should manually review every file in the project. AI cannot complete
|
||||
these tasks.
|
||||
|
||||
Debug log stats:
|
||||
|
||||
- [ ] final Context_Stack cap
|
||||
- [ ] highest PIPE args used
|
||||
- [ ] longest slug length + name that generated it
|
||||
- [ ] number of pages
|
||||
- [ ] number of blocks
|
||||
- [ ] enabled features / extensions
|
||||
- [ ] etc
|
||||
|
||||
## 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.
|
||||
- [ ] 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
-24
@@ -5,20 +5,7 @@ import "core:log"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
|
||||
import ts "treesitter"
|
||||
|
||||
copy_assets_dir :: proc(vfs: ^VFS, output_dir: string, features: bit_set[Feature]) {
|
||||
// One CSS parser, reused for every .css asset (single-threaded). Owned here.
|
||||
css_parser: ts.Parser
|
||||
if .Minify in features {
|
||||
if g, ok := ts.grammar("css"); ok {
|
||||
css_parser = ts.open_parser(g)
|
||||
}
|
||||
}
|
||||
defer if css_parser != nil {
|
||||
ts.parser_delete(css_parser)
|
||||
}
|
||||
|
||||
for virtual_path, entry in vfs.files {
|
||||
if !strings.has_prefix(virtual_path, "assets/") {
|
||||
continue
|
||||
@@ -27,12 +14,6 @@ copy_assets_dir :: proc(vfs: ^VFS, output_dir: string, features: bit_set[Feature
|
||||
rel := virtual_path[len("assets/"):]
|
||||
dest := fmt.tprintf("%s/%s", output_dir, rel)
|
||||
|
||||
// Don't clobber a file already written this build (e.g. the output of a
|
||||
// `tool` pipe, which publishes to the same dest path as its source).
|
||||
if os.exists(dest) {
|
||||
continue
|
||||
}
|
||||
|
||||
if idx := strings.last_index(dest, "/"); idx >= 0 {
|
||||
if err := os.make_directory_all(dest[:idx]); err != nil && err != .Exist {
|
||||
log.warnf("cannot create %s: %v", dest[:idx], err)
|
||||
@@ -43,11 +24,7 @@ copy_assets_dir :: proc(vfs: ^VFS, output_dir: string, features: bit_set[Feature
|
||||
if .Minify in features && strings.has_suffix(rel, ".css") {
|
||||
data, ok := vfs_get(vfs, virtual_path)
|
||||
if ok {
|
||||
if m, mok := minify_css(css_parser, string(data)); mok {
|
||||
write_file(dest, m)
|
||||
} else {
|
||||
write_file(dest, string(data))
|
||||
}
|
||||
write_file(dest, minify_css(string(data)))
|
||||
}
|
||||
} else if entry.data != nil {
|
||||
if err := os.write_entire_file(dest, entry.data); err != nil {
|
||||
|
||||
+4
-64
@@ -3,7 +3,6 @@ package main
|
||||
import md "markdown"
|
||||
import ts "treesitter"
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:os"
|
||||
@@ -24,11 +23,10 @@ Page :: struct {
|
||||
weight: Maybe(int),
|
||||
lastmod: string,
|
||||
menus: map[string]Menu_Entry,
|
||||
params: json.Value,
|
||||
content: string,
|
||||
og: Open_Graph,
|
||||
draft: bool,
|
||||
toc: string,
|
||||
starred: bool,
|
||||
_is_index: bool `private`,
|
||||
}
|
||||
|
||||
@@ -71,9 +69,6 @@ site_load_content :: proc(site: ^Site) {
|
||||
|
||||
for &page in site.pages {
|
||||
page.url = fmt.tprintf("%s%s", site.base_url, page.permalink)
|
||||
if site.base_path != "" {
|
||||
page.content = rewrite_root_urls(page.content, site.base_path, site_allocator(site))
|
||||
}
|
||||
}
|
||||
|
||||
build_menus(site)
|
||||
@@ -262,7 +257,7 @@ load_page :: proc(
|
||||
page.weight = fm.weight
|
||||
page.lastmod = fm.lastmod
|
||||
page.draft = fm.draft
|
||||
page.params = fm.params
|
||||
page.starred = fm.isStarred
|
||||
|
||||
if section == "" && is_index {
|
||||
page.permalink = "/"
|
||||
@@ -279,70 +274,15 @@ load_page :: proc(
|
||||
page.og = fm.og
|
||||
|
||||
if strings.has_suffix(file_path, ".html") {
|
||||
page.content = strings.clone(body, context.allocator)
|
||||
page.content = strings.clone(body)
|
||||
} else {
|
||||
page.content = md.process(body, ext, file_path, context.allocator)
|
||||
}
|
||||
|
||||
if fm.toc {
|
||||
page.toc = md.generate_toc(page.content, context.allocator)
|
||||
page.content = md.process(body, ext, file_path)
|
||||
}
|
||||
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
// rewrite_root_urls prefixes root-relative href/src attribute values with
|
||||
// base_path so links authored in content (e.g. [x](/ideas)) resolve under a
|
||||
// subpath deployment. Only values beginning with a single '/' are rewritten;
|
||||
// absolute ("http://…"), protocol-relative ("//cdn…"), fragment ("#x"),
|
||||
// mailto:, and document-relative ("foo/bar", "./foo") values all fail that
|
||||
// test and pass through untouched. Assumes cmark's double-quoted output.
|
||||
rewrite_root_urls :: proc(html, base_path: string, allocator := context.allocator) -> string {
|
||||
b := strings.builder_make(allocator)
|
||||
strings.builder_grow(&b, len(html))
|
||||
|
||||
i := 0
|
||||
for i < len(html) {
|
||||
n := attr_opener_at(html, i) // ` href="` or ` src="`
|
||||
if n == 0 {
|
||||
strings.write_byte(&b, html[i])
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
strings.write_string(&b, html[i:i + n]) // copy the opener verbatim
|
||||
i += n
|
||||
|
||||
end := strings.index_byte(html[i:], '"')
|
||||
if end < 0 { // malformed; bail cleanly
|
||||
strings.write_string(&b, html[i:])
|
||||
break
|
||||
}
|
||||
value := html[i:i + end]
|
||||
if url_is_root_relative(value) {
|
||||
strings.write_string(&b, base_path)
|
||||
}
|
||||
strings.write_string(&b, value) // closing quote copied next loop
|
||||
i += end
|
||||
}
|
||||
return strings.to_string(b)
|
||||
}
|
||||
|
||||
// url_is_root_relative reports whether value is a site-root path ("/faq/") as
|
||||
// opposed to protocol-relative ("//cdn") or anything not starting with '/'.
|
||||
url_is_root_relative :: proc(value: string) -> bool {
|
||||
return len(value) >= 1 && value[0] == '/' && !strings.has_prefix(value, "//")
|
||||
}
|
||||
|
||||
// attr_opener_at returns the length of a ` href="` / ` src="` attribute opener
|
||||
// at s[i:], or 0. The leading space anchors the match to a real HTML attribute
|
||||
// so we don't rewrite the strings inside link text or code blocks.
|
||||
attr_opener_at :: proc(s: string, i: int) -> int {
|
||||
if strings.has_prefix(s[i:], " href=\"") do return 7
|
||||
if strings.has_prefix(s[i:], " src=\"") do return 6
|
||||
return 0
|
||||
}
|
||||
|
||||
is_content_file :: proc(name: string) -> bool {
|
||||
return strings.has_suffix(name, ".md") || strings.has_suffix(name, ".html")
|
||||
}
|
||||
|
||||
+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"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{> title}}</title>
|
||||
{{> opengraph}}
|
||||
{{> styles}}
|
||||
{{> scripts}}
|
||||
<title>{{title}}</title>
|
||||
{{> head}}
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
{{&content}}
|
||||
</header>
|
||||
<ul>
|
||||
{{#pages}} <li><a href="{{permalink | rel_url}}">{{&title}}</a><span>{{#date}}<time
|
||||
datetime="{{date}}">{{date | format}}</time>{{/date}}</span>
|
||||
{{#pages}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date_iso}}<time
|
||||
datetime="{{date_iso}}">{{date_display}}</time>{{/date_iso}}</span>
|
||||
</li>
|
||||
{{/pages}}
|
||||
</ul>
|
||||
|
||||
@@ -3,13 +3,8 @@
|
||||
<main>
|
||||
<article>
|
||||
<h1>{{page.title}}</h1>
|
||||
{{#date}}<time class="subtitle" datetime="{{date}}">{{ date | format}}</time>{{/date}}
|
||||
{{#page.toc}}
|
||||
<nav class="toc">
|
||||
{{&page.toc}}
|
||||
</nav>
|
||||
{{/page.toc}}
|
||||
{{&content}}
|
||||
{{#date_iso}} <time class="subtitle" datetime="{{date_iso}}">{{date_display}}</time>
|
||||
{{/date_iso}} {{&content}}
|
||||
</article>
|
||||
</main>
|
||||
{{/main}}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<footer>
|
||||
<p>© {{now | format "2006"}} {{params.author.name}}</p>
|
||||
<p>© {{now.year}} {{params.author.name}}</p>
|
||||
</footer>
|
||||
@@ -1 +1 @@
|
||||
{{site.title}}{{^site.title}}Home{{/site.title}}
|
||||
{{site.title}}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<header>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="{{site.base_path}}/">{{> home-link}}</a></li>
|
||||
<li><a href="/">{{> home-link}}</a></li>
|
||||
{{#menus.main}}
|
||||
<li><a href="{{url | rel_url}}">{{name}}</a></li>
|
||||
<li><a href="{{url}}">{{name}}</a></li>
|
||||
{{/menus.main}}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{{params.scripts}}
|
||||
@@ -1,294 +0,0 @@
|
||||
{{^params.stylesheets}}
|
||||
<style>
|
||||
/* Nav */
|
||||
header nav ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Base layout */
|
||||
body {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Sidenote layout — activates only when sidenote elements are present */
|
||||
body:has(.sidenote, .marginnote) {
|
||||
counter-reset: sidenote-counter;
|
||||
padding-left: 12.5%;
|
||||
width: 87.5%;
|
||||
}
|
||||
|
||||
body:has(.sidenote, .marginnote) main {
|
||||
max-width: none;
|
||||
width: 60%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Images */
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Superscript (footnote refs) */
|
||||
sup {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
code,
|
||||
pre>code {
|
||||
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
font-size: 1.0rem;
|
||||
line-height: 1.42;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
pre>code {
|
||||
font-size: 0.9rem;
|
||||
overflow-x: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Alerts (GitHub-style) */
|
||||
.alert {
|
||||
border-left: 4px solid;
|
||||
border-radius: 0 4px 4px 0;
|
||||
padding: 0.5rem 1rem;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
.alert-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.alert-note {
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.alert-tip {
|
||||
border-left-color: #22c55e;
|
||||
}
|
||||
|
||||
.alert-important {
|
||||
border-left-color: #a855f7;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
border-left-color: #eab308;
|
||||
}
|
||||
|
||||
.alert-caution {
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
/* Definition lists */
|
||||
dl,
|
||||
ol,
|
||||
ul {
|
||||
font-size: 1.4rem;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
dt:not(:first-child) {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Sidenotes, margin notes */
|
||||
.sidenote,
|
||||
.marginnote {
|
||||
float: right;
|
||||
clear: right;
|
||||
margin-right: -45%;
|
||||
width: 40%;
|
||||
margin-top: 0.3rem;
|
||||
margin-bottom: 0;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.5;
|
||||
vertical-align: baseline;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidenote-number {
|
||||
counter-increment: sidenote-counter;
|
||||
}
|
||||
|
||||
.sidenote-number:after,
|
||||
.sidenote:before {
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
color: var(--color-accent, currentColor);
|
||||
}
|
||||
|
||||
.sidenote-number:after {
|
||||
content: counter(sidenote-counter);
|
||||
font-size: 0.8rem;
|
||||
top: -0.5rem;
|
||||
left: 0.1rem;
|
||||
}
|
||||
|
||||
.sidenote:before {
|
||||
content: counter(sidenote-counter) " ";
|
||||
font-size: 0.8rem;
|
||||
top: -0.5rem;
|
||||
}
|
||||
|
||||
blockquote .sidenote,
|
||||
blockquote .marginnote {
|
||||
margin-right: -82%;
|
||||
min-width: 59%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.marginnote>code,
|
||||
.sidenote>code {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input.margin-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
label.sidenote-number {
|
||||
display: inline-block;
|
||||
max-height: 2rem;
|
||||
}
|
||||
|
||||
label.margin-toggle:not(.sidenote-number) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1200px) {
|
||||
body {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
body:has(.sidenote, .marginnote) {
|
||||
padding-left: 8%;
|
||||
padding-right: 8%;
|
||||
width: 84%;
|
||||
}
|
||||
|
||||
body:has(.sidenote, .marginnote) main {
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
pre>code {
|
||||
width: 97%;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
label.margin-toggle:not(.sidenote-number) {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
label.margin-toggle:not(.sidenote-number)::after {
|
||||
content: "\2295";
|
||||
}
|
||||
|
||||
.sidenote,
|
||||
.marginnote {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.margin-toggle:checked+.sidenote,
|
||||
.margin-toggle:checked+.marginnote {
|
||||
display: block;
|
||||
float: left;
|
||||
left: 1rem;
|
||||
clear: both;
|
||||
width: 95%;
|
||||
margin: 1rem 2.5%;
|
||||
vertical-align: baseline;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
label {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{{/params.stylesheets}}
|
||||
<style>
|
||||
/* Syntax highlighting. Build-time <span class="hl-*"> wrappers only appear
|
||||
inside fenced code blocks. The base class (e.g. hl-keyword) is always
|
||||
emitted alongside any dotted variant (hl-keyword-control), so styling the
|
||||
base names covers every capture. Re-theme by overriding the --hl-* vars. */
|
||||
:root {
|
||||
--hl-bg: #282c34;
|
||||
--hl-fg: #abb2bf;
|
||||
--hl-comment: #5c6370;
|
||||
--hl-keyword: #c678dd;
|
||||
--hl-string: #98c379;
|
||||
--hl-escape: #56b6c2;
|
||||
--hl-number: #d19a66;
|
||||
--hl-function: #61afef;
|
||||
--hl-type: #e5c07b;
|
||||
--hl-variable: #e06c75;
|
||||
--hl-attribute: #d19a66;
|
||||
}
|
||||
|
||||
pre:has(> code [class^="hl-"]) {
|
||||
background: var(--hl-bg);
|
||||
color: var(--hl-fg);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.hl-comment { color: var(--hl-comment); font-style: italic; }
|
||||
.hl-keyword,
|
||||
.hl-conditional,
|
||||
.hl-repeat,
|
||||
.hl-exception,
|
||||
.hl-include,
|
||||
.hl-storageclass { color: var(--hl-keyword); }
|
||||
.hl-operator,
|
||||
.hl-punctuation { color: var(--hl-fg); }
|
||||
.hl-string,
|
||||
.hl-character { color: var(--hl-string); }
|
||||
.hl-escape,
|
||||
.hl-string-escape,
|
||||
.hl-string-special { color: var(--hl-escape); }
|
||||
.hl-number,
|
||||
.hl-float,
|
||||
.hl-boolean,
|
||||
.hl-constant { color: var(--hl-number); }
|
||||
.hl-function,
|
||||
.hl-method,
|
||||
.hl-constructor { color: var(--hl-function); }
|
||||
.hl-type,
|
||||
.hl-namespace,
|
||||
.hl-module { color: var(--hl-type); }
|
||||
.hl-variable,
|
||||
.hl-property,
|
||||
.hl-field,
|
||||
.hl-parameter,
|
||||
.hl-tag,
|
||||
.hl-symbol { color: var(--hl-variable); }
|
||||
.hl-attribute,
|
||||
.hl-label,
|
||||
.hl-preproc { color: var(--hl-attribute); }
|
||||
</style>
|
||||
{{#params.stylesheets}}<link rel="stylesheet" href="{{.|rel_url}}">
|
||||
{{/params.stylesheets}}
|
||||
@@ -1 +0,0 @@
|
||||
{{#site.title}}{{.}}{{#page.title}} | {{/page.title}}{{/site.title}}{{page.title}}
|
||||
@@ -7,8 +7,8 @@
|
||||
<section>
|
||||
<h2>{{key}}</h2>
|
||||
<ul>
|
||||
{{#items}} <li><a href="{{permalink | rel_url}}">{{&title}}</a><span>{{#date}}<time
|
||||
datetime="{{date}}">{{date | format}}</time>{{/date}}</span>
|
||||
{{#items}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date_iso}}<time
|
||||
datetime="{{date_iso}}">{{date_display}}</time>{{/date_iso}}</span>
|
||||
</li>
|
||||
{{/items}}
|
||||
</ul>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package main
|
||||
|
||||
import "base:runtime"
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
import "core:time"
|
||||
@@ -8,9 +7,10 @@ import "core:time"
|
||||
generate_rss :: proc(site: ^Site) -> string {
|
||||
sb := strings.builder_make()
|
||||
|
||||
fmt.sbprintf(
|
||||
strings.write_string(
|
||||
&sb,
|
||||
`<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
fmt.aprintf(
|
||||
`<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>%s</title>
|
||||
@@ -18,10 +18,11 @@ generate_rss :: proc(site: ^Site) -> string {
|
||||
<description>%s</description>
|
||||
<language>en-us</language>
|
||||
<atom:link href="%s/index.xml" rel="self" type="application/rss+xml"/>`,
|
||||
xml_escape(site.title),
|
||||
site.base_url,
|
||||
xml_escape(site.description),
|
||||
site.base_url,
|
||||
xml_escape(site.title),
|
||||
site.base_url,
|
||||
xml_escape(site.description),
|
||||
site.base_url,
|
||||
),
|
||||
)
|
||||
|
||||
for page in site.pages {
|
||||
@@ -34,9 +35,10 @@ generate_rss :: proc(site: ^Site) -> string {
|
||||
pub_date = format_rfc822(page.date)
|
||||
}
|
||||
|
||||
fmt.sbprintf(
|
||||
strings.write_string(
|
||||
&sb,
|
||||
`<item>
|
||||
fmt.aprintf(
|
||||
`<item>
|
||||
<title>%s</title>
|
||||
<link>%s</link>
|
||||
<pubDate>%s</pubDate>
|
||||
@@ -44,11 +46,12 @@ generate_rss :: proc(site: ^Site) -> string {
|
||||
<description>%s</description>
|
||||
</item>
|
||||
`,
|
||||
xml_escape(page.title),
|
||||
page.url,
|
||||
pub_date,
|
||||
page.url,
|
||||
xml_escape(page.content),
|
||||
xml_escape(page.title),
|
||||
page.url,
|
||||
pub_date,
|
||||
page.url,
|
||||
xml_escape(page.content),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,11 +70,11 @@ generate_sitemap :: proc(site: ^Site) -> string {
|
||||
)
|
||||
|
||||
for page in site.pages {
|
||||
fmt.sbprintf(&sb, "<url><loc>%s</loc>", page.url)
|
||||
lastmod := ""
|
||||
if page.date != "" {
|
||||
fmt.sbprintf(&sb, "<lastmod>%s</lastmod>", page.date)
|
||||
lastmod = fmt.aprintf("<lastmod>%s</lastmod>", page.date)
|
||||
}
|
||||
fmt.sbprintf(&sb, "</url>\n")
|
||||
strings.write_string(&sb, fmt.aprintf("<url><loc>%s</loc>%s</url>\n", page.url, lastmod))
|
||||
}
|
||||
|
||||
// Section index pages (for sections without an index in content)
|
||||
@@ -100,18 +103,22 @@ generate_sitemap :: proc(site: ^Site) -> string {
|
||||
section_lastmod = page.date
|
||||
}
|
||||
}
|
||||
fmt.sbprintf(&sb, "<url><loc>%s/%s/</loc>", site.base_url, section)
|
||||
lm := ""
|
||||
if section_lastmod != "" {
|
||||
fmt.sbprintf(&sb, "<lastmod>%s</lastmod>", section_lastmod)
|
||||
lm = fmt.aprintf("<lastmod>%s</lastmod>", section_lastmod)
|
||||
}
|
||||
fmt.sbprintf(&sb, "</url>\n")
|
||||
strings.write_string(
|
||||
&sb,
|
||||
fmt.aprintf("<url><loc>%s/%s/</loc>%s</url>\n", site.base_url, section, lm),
|
||||
)
|
||||
}
|
||||
|
||||
strings.write_string(&sb, "</urlset>")
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
format_rfc822 :: proc(iso: string, allocator := context.temp_allocator) -> string {
|
||||
// TODO: Leaks
|
||||
format_rfc822 :: proc(iso: string) -> string {
|
||||
if len(iso) < 19 {
|
||||
// TODO: should indicate error somehow
|
||||
return iso
|
||||
@@ -124,66 +131,21 @@ format_rfc822 :: proc(iso: string, allocator := context.temp_allocator) -> strin
|
||||
buf: [8]byte
|
||||
t := time.to_string_hms(date, buf[:])
|
||||
|
||||
// RFC-822 zone: sign followed by two-digit hours and minutes (e.g. +0000,
|
||||
// +0530, -0800). offset is in minutes.
|
||||
sign := '+'
|
||||
off := offset
|
||||
if off < 0 {
|
||||
sign = '-'
|
||||
off = -off
|
||||
}
|
||||
|
||||
return fmt.aprintf(
|
||||
"%s, %02d %s %d %s %c%02d%02d",
|
||||
"%s, %02d %s %d %s %3d%2d",
|
||||
weekday[:3],
|
||||
time.day(date),
|
||||
month[:3],
|
||||
time.year(date),
|
||||
t,
|
||||
sign,
|
||||
off / 60,
|
||||
off % 60,
|
||||
allocator = allocator,
|
||||
offset / 60,
|
||||
offset % 60,
|
||||
)
|
||||
}
|
||||
|
||||
// xml_escape escapes the XML metacharacters '&', '<' and '>' in s. Only these
|
||||
// ASCII characters are touched; all other bytes (including UTF-8 multibyte
|
||||
// sequences) pass through verbatim, so s must already be valid UTF-8.
|
||||
//
|
||||
// The result is allocated in `allocator`, which defaults to
|
||||
// context.temp_allocator — the returned string is only valid until the next
|
||||
// temp allocator reset, so callers must not retain it across frames.
|
||||
xml_escape :: proc(
|
||||
s: string,
|
||||
allocator := context.temp_allocator,
|
||||
) -> (
|
||||
escaped: string,
|
||||
err: runtime.Allocator_Error,
|
||||
) #optional_allocator_error {
|
||||
sb := strings.builder_make_len_cap(0, len(s), allocator) or_return
|
||||
|
||||
// Single pass over the bytes, copying runs of ordinary text in bulk and
|
||||
// emitting an entity for each metacharacter. The switch is both the
|
||||
// membership test and the replacement lookup, so no per-match scan.
|
||||
start := 0
|
||||
for i := 0; i < len(s); i += 1 {
|
||||
esc: string
|
||||
switch s[i] {
|
||||
case '&':
|
||||
esc = "&"
|
||||
case '<':
|
||||
esc = "<"
|
||||
case '>':
|
||||
esc = ">"
|
||||
case:
|
||||
continue // ordinary byte: copied as part of the next run
|
||||
}
|
||||
strings.write_string(&sb, s[start:i]) // run before the metacharacter
|
||||
strings.write_string(&sb, esc)
|
||||
start = i + 1
|
||||
}
|
||||
strings.write_string(&sb, s[start:]) // tail
|
||||
return strings.to_string(sb), nil
|
||||
xml_escape :: proc(s: string) -> string {
|
||||
r, _ := strings.replace_all(s, "&", "&")
|
||||
r, _ = strings.replace_all(r, "<", "<")
|
||||
r, _ = strings.replace_all(r, ">", ">")
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
-166
@@ -1,166 +0,0 @@
|
||||
#+test
|
||||
package main
|
||||
|
||||
import "core:strings"
|
||||
import "core:testing"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// xml_escape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(test)
|
||||
test_xml_escape_passthrough :: proc(t: ^testing.T) {
|
||||
testing.expect_value(t, xml_escape(""), "")
|
||||
testing.expect_value(t, xml_escape("hello world"), "hello world")
|
||||
// UTF-8 multibyte bytes are not metacharacters and pass through untouched.
|
||||
testing.expect_value(t, xml_escape("héllo — 世界"), "héllo — 世界")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_xml_escape_entities :: proc(t: ^testing.T) {
|
||||
testing.expect_value(t, xml_escape("&"), "&")
|
||||
testing.expect_value(t, xml_escape("<"), "<")
|
||||
testing.expect_value(t, xml_escape(">"), ">")
|
||||
// Adjacent specials, and specials at the very start/end of the string.
|
||||
testing.expect_value(t, xml_escape("&<>"), "&<>")
|
||||
testing.expect_value(t, xml_escape("<a>"), "<a>")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_xml_escape_mixed :: proc(t: ^testing.T) {
|
||||
testing.expect_value(t, xml_escape("a<b>c&d"), "a<b>c&d")
|
||||
testing.expect_value(
|
||||
t,
|
||||
xml_escape("<p>Tom & Jerry</p>"),
|
||||
"<p>Tom & Jerry</p>",
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// format_rfc822
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(test)
|
||||
test_format_rfc822_short_passthrough :: proc(t: ^testing.T) {
|
||||
// Inputs shorter than a full ISO-8601 timestamp are returned unchanged
|
||||
// (and are the input slice itself, so they must not be deleted).
|
||||
testing.expect_value(t, format_rfc822("2024"), "2024")
|
||||
testing.expect_value(t, format_rfc822(""), "")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_rfc822_full_date :: proc(t: ^testing.T) {
|
||||
// Results are temp-allocated (reset by the test runner), so no delete.
|
||||
// 2024-01-15 is a Monday; wall-clock time is preserved and the zone is
|
||||
// rendered as an RFC-822 ±HHMM offset.
|
||||
testing.expect_value(
|
||||
t,
|
||||
format_rfc822("2024-01-15T10:30:00Z"),
|
||||
"Mon, 15 Jan 2024 10:30:00 +0000",
|
||||
)
|
||||
testing.expect_value(
|
||||
t,
|
||||
format_rfc822("2024-01-15T10:30:00+05:30"),
|
||||
"Mon, 15 Jan 2024 10:30:00 +0530",
|
||||
)
|
||||
testing.expect_value(
|
||||
t,
|
||||
format_rfc822("2024-01-15T10:30:00-08:00"),
|
||||
"Mon, 15 Jan 2024 10:30:00 -0800",
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// generate_rss
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(test)
|
||||
test_generate_rss_structure :: proc(t: ^testing.T) {
|
||||
site: Site
|
||||
site.title = "My & Site"
|
||||
site.description = "Desc <here>"
|
||||
site.base_url = "https://example.com"
|
||||
site.pages = make(#soa[dynamic]Page)
|
||||
defer delete_soa(site.pages)
|
||||
|
||||
// A real content page.
|
||||
append(
|
||||
&site.pages,
|
||||
Page {
|
||||
section = "posts",
|
||||
title = "First <Post>",
|
||||
url = "https://example.com/posts/first/",
|
||||
content = "<p>Hello & welcome</p>",
|
||||
},
|
||||
)
|
||||
// The home index page (section == "" && _is_index) must be skipped.
|
||||
append(
|
||||
&site.pages,
|
||||
Page {
|
||||
section = "",
|
||||
_is_index = true,
|
||||
title = "HOME_SHOULD_BE_SKIPPED",
|
||||
url = "https://example.com/",
|
||||
content = "home",
|
||||
},
|
||||
)
|
||||
// A second content page.
|
||||
append(
|
||||
&site.pages,
|
||||
Page {
|
||||
section = "posts",
|
||||
title = "Second Post",
|
||||
url = "https://example.com/posts/second/",
|
||||
content = "plain body",
|
||||
},
|
||||
)
|
||||
|
||||
rss := generate_rss(&site)
|
||||
defer delete(rss)
|
||||
|
||||
// Channel header: site title/description are XML-escaped, link gets a
|
||||
// trailing slash, and the atom self-link points at index.xml.
|
||||
testing.expect(t, strings.contains(rss, "<title>My & Site</title>"), rss)
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(rss, "<description>Desc <here></description>"),
|
||||
rss,
|
||||
)
|
||||
testing.expect(t, strings.contains(rss, "<link>https://example.com/</link>"), rss)
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(rss, `<atom:link href="https://example.com/index.xml"`),
|
||||
rss,
|
||||
)
|
||||
|
||||
// Exactly two <item>s — the home index page is skipped.
|
||||
testing.expect_value(t, strings.count(rss, "<item>"), 2)
|
||||
testing.expect(t, !strings.contains(rss, "HOME_SHOULD_BE_SKIPPED"), rss)
|
||||
|
||||
// First item: title and body are XML-escaped, link is verbatim.
|
||||
testing.expect(t, strings.contains(rss, "<title>First <Post></title>"), rss)
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(
|
||||
rss,
|
||||
"<description><p>Hello & welcome</p></description>",
|
||||
),
|
||||
rss,
|
||||
)
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(rss, "<link>https://example.com/posts/first/</link>"),
|
||||
rss,
|
||||
)
|
||||
|
||||
// Undated pages get the sentinel pubDate (no date formatting/allocation).
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(rss, "<pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate>"),
|
||||
rss,
|
||||
)
|
||||
|
||||
// Well-formed at the boundaries.
|
||||
testing.expect(t, strings.has_prefix(rss, `<?xml version="1.0"`), rss)
|
||||
testing.expect(t, strings.has_suffix(rss, "</channel>\n</rss>"), rss)
|
||||
}
|
||||
@@ -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
|
||||
'';
|
||||
};
|
||||
@@ -165,17 +158,9 @@
|
||||
tree-sitter
|
||||
|
||||
gdb
|
||||
perf
|
||||
|
||||
bloaty # du for binaries?
|
||||
|
||||
# temp
|
||||
hugo
|
||||
pandoc
|
||||
|
||||
# IDE
|
||||
unstable.helix
|
||||
claude-code
|
||||
typescript-language-server
|
||||
vscode-langservers-extracted
|
||||
]);
|
||||
|
||||
+3
-6
@@ -12,11 +12,10 @@ Frontmatter :: struct {
|
||||
publishDate: string,
|
||||
weight: Maybe(int),
|
||||
menus: json.Value,
|
||||
params: json.Value,
|
||||
layout: string,
|
||||
og: Open_Graph,
|
||||
draft: bool,
|
||||
toc: bool,
|
||||
isStarred: bool,
|
||||
}
|
||||
|
||||
// parse_frontmatter splits raw file content into a Frontmatter struct and the
|
||||
@@ -38,7 +37,7 @@ parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok
|
||||
json_str := content[:end + 1]
|
||||
body = strings.trim_left(content[end + 1:], " \t\r\n")
|
||||
|
||||
value, err := json.parse_string(json_str, spec = .JSON5)
|
||||
value, err := json.parse_string(json_str, spec = .JSON)
|
||||
if err != nil {
|
||||
log.errorf("failed to parse frontmatter JSON: %v", err)
|
||||
return
|
||||
@@ -57,13 +56,12 @@ parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok
|
||||
fm.publishDate = json_get_string(obj, "publishDate")
|
||||
fm.weight = json_get_int(obj, "weight")
|
||||
fm.draft = json_get_bool(obj, "draft")
|
||||
fm.toc = json_get_bool(obj, "toc")
|
||||
fm.isStarred = json_get_bool(obj, "isStarred")
|
||||
if v, ok := obj["menus"]; ok {
|
||||
fm.menus = v
|
||||
}
|
||||
fm.layout = json_get_string(obj, "layout")
|
||||
fm.og = json_get_open_graph(obj, "og")
|
||||
if v, ok := obj["params"]; ok {fm.params = v}
|
||||
|
||||
ok = true
|
||||
return
|
||||
@@ -119,4 +117,3 @@ json_get_open_graph :: proc(obj: json.Object, key: string) -> Open_Graph {
|
||||
}
|
||||
return og
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package main
|
||||
|
||||
import "base:runtime"
|
||||
import "core:flags"
|
||||
import "core:log"
|
||||
import "core:mem"
|
||||
import "core:os"
|
||||
import "core:prof/spall"
|
||||
import "core:sync"
|
||||
@@ -45,21 +43,9 @@ main :: proc() {
|
||||
defer spall.buffer_destroy(&spall_ctx, &spall_buffer)
|
||||
}
|
||||
|
||||
cli_flags: Flags
|
||||
flags.parse_or_exit(&cli_flags, os.args, .Odin)
|
||||
|
||||
level: log.Level
|
||||
switch {
|
||||
case cli_flags.quiet:
|
||||
level = .Warning
|
||||
case cli_flags.verbose:
|
||||
level = .Debug
|
||||
case:
|
||||
level = .Info
|
||||
}
|
||||
logger_opts: log.Options =
|
||||
(log.Default_Console_Logger_Opts - log.Full_Timestamp_Opts - {.Short_File_Path})
|
||||
console_logger := log.create_console_logger(level, logger_opts)
|
||||
console_logger := log.create_console_logger(.Info, logger_opts)
|
||||
context.logger = console_logger
|
||||
defer log.destroy_console_logger(console_logger)
|
||||
|
||||
@@ -71,21 +57,12 @@ main :: proc() {
|
||||
|
||||
for {
|
||||
defer free_all(context.temp_allocator)
|
||||
defer log.debugf(
|
||||
"max_temp_allocator_size=%M",
|
||||
(cast(^runtime.Default_Temp_Allocator)context.temp_allocator.data)^.arena.total_used,
|
||||
)
|
||||
tick := time.tick_now()
|
||||
site: Site
|
||||
init_site(&site, cli_flags)
|
||||
init_site(&site, os.args)
|
||||
defer destroy_site(&site)
|
||||
// TODO: Make it so this isn't necessary
|
||||
context.allocator = site_allocator(&site)
|
||||
defer log.debugf(
|
||||
"current_site_allocator_size=%M",
|
||||
site.arena.block_size * (len(site.arena.used_blocks) + len(site.arena.unused_blocks)) -
|
||||
site.arena.bytes_left,
|
||||
)
|
||||
build_vfs(&site)
|
||||
|
||||
treesitter.grammar_dir = site.grammars
|
||||
@@ -93,7 +70,7 @@ main :: proc() {
|
||||
|
||||
site_load_content(&site)
|
||||
render_site(&site)
|
||||
log.infof("Built site in %M", time.tick_since(tick))
|
||||
log.infof("Built site in %s", time.tick_since(tick))
|
||||
|
||||
(.Watch in site.features) or_break
|
||||
time.sleep(5 * time.Second)
|
||||
@@ -117,4 +94,3 @@ when SPALL {
|
||||
spall._buffer_end(&spall_ctx, &spall_buffer)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
package markdown
|
||||
|
||||
import cm "vendor:commonmark"
|
||||
|
||||
import "core:strings"
|
||||
|
||||
// convert_deflists scans markdown text for definition list patterns and
|
||||
// converts them to <dl><dt><dd> HTML blocks before cmark processing.
|
||||
//
|
||||
// A definition line starts with fewer than four columns of leading whitespace
|
||||
// followed by a colon and a space (or end-of-line). The term is the nearest
|
||||
// preceding non-blank line (immediately or within one blank line). Consecutive
|
||||
// term+definition pairs are grouped into a single <dl> block, and consecutive
|
||||
// definition lines under one term become multiple <dd> elements.
|
||||
//
|
||||
// The scanner walks the body one line at a time using byte offsets: it never
|
||||
// splits the input, allocates nothing per line, and copies non-deflist text
|
||||
// through verbatim. Matching is disabled inside fenced code blocks. Terms and
|
||||
// definitions are rendered through cmark individually so that inline markdown
|
||||
// (code, links, emphasis) is processed.
|
||||
// Line_Kind classifies a line for convert_deflists's group-finding scan. Other
|
||||
// covers fenced-code delimiters and their contents, where definition syntax is
|
||||
// inert.
|
||||
Line_Kind :: enum {
|
||||
None,
|
||||
Blank,
|
||||
Term,
|
||||
Def,
|
||||
Other,
|
||||
}
|
||||
|
||||
convert_deflists :: proc(body: string, allocator := context.allocator) -> string {
|
||||
// Fast path: a body with no definition line at all cannot contain a
|
||||
// definition list, so return it untouched — skipping the builder allocation
|
||||
// and the classifying scan below. This is the common case (most pages have
|
||||
// no definition lists), and the guard scan is leaner than the full loop.
|
||||
if !has_def_line(body) {
|
||||
return body
|
||||
}
|
||||
|
||||
sb := strings.builder_make(context.temp_allocator)
|
||||
|
||||
// emitted_up_to is the byte offset up to which `body` has been copied into
|
||||
// `sb`. It stays at 0 until the first deflist is emitted; if it is still 0
|
||||
// at the end, nothing matched and we can return the body unchanged.
|
||||
emitted_up_to := 0
|
||||
|
||||
in_fence := false
|
||||
fence_marker: u8 = 0
|
||||
|
||||
// The scan is definition-driven: it walks line by line, remembering the two
|
||||
// previous lines' kinds and offsets, and only does group-finding work when it
|
||||
// reaches a (rare) definition line — whose term is the immediately preceding
|
||||
// line, or the one before a single blank line. This avoids a per-line forward
|
||||
// lookahead: each line is classified once, with no rescanning.
|
||||
prev1_kind, prev2_kind := Line_Kind.None, Line_Kind.None
|
||||
prev1_pos, prev2_pos := 0, 0
|
||||
|
||||
pos := 0
|
||||
for {
|
||||
line, next, ok := line_at(body, pos)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
|
||||
// Classify the line. Definition-list syntax is inert inside fenced code
|
||||
// blocks, so fence delimiters and their contents are classified Other.
|
||||
kind: Line_Kind
|
||||
if in_fence {
|
||||
if is_fence_line(line, fence_marker) {
|
||||
in_fence = false
|
||||
}
|
||||
kind = .Other
|
||||
} else if marker, opened := fence_open(line); opened {
|
||||
in_fence = true
|
||||
fence_marker = marker
|
||||
kind = .Other
|
||||
} else if is_def_line(line) {
|
||||
kind = .Def
|
||||
} else if is_blank_line(line) {
|
||||
kind = .Blank
|
||||
} else {
|
||||
kind = .Term
|
||||
}
|
||||
|
||||
// A definition-list group begins at the term preceding this def line
|
||||
// (immediately, or across a single blank line). Flush pending passthrough
|
||||
// text verbatim, then render the whole group directly into the builder.
|
||||
if kind == .Def {
|
||||
term_pos := -1
|
||||
if prev1_kind == .Term {
|
||||
term_pos = prev1_pos
|
||||
} else if prev1_kind == .Blank && prev2_kind == .Term {
|
||||
term_pos = prev2_pos
|
||||
}
|
||||
if term_pos >= 0 {
|
||||
strings.write_string(&sb, body[emitted_up_to:term_pos])
|
||||
group_end := write_deflist_group(&sb, body, term_pos)
|
||||
// write_deflist_group emits a raw <dl> HTML block with no
|
||||
// trailing newline, so the blank line the author left after the
|
||||
// list is consumed as the </dl> line's terminator rather than a
|
||||
// separating blank line. CommonMark (type-6 HTML block) then
|
||||
// swallows the following block — e.g. a fenced code example —
|
||||
// as raw, unescaped HTML, leaking its markup (a template's
|
||||
// <h1>) into the document. Re-emit a newline so </dl> stands on
|
||||
// its own line and the author's blank line still separates the
|
||||
// block. Only when a blank line actually follows: fused
|
||||
// non-blank fall-through content is left as-is.
|
||||
if line, _, ok := line_at(body, group_end); ok && is_blank_line(line) {
|
||||
strings.write_byte(&sb, '\n')
|
||||
}
|
||||
emitted_up_to = group_end
|
||||
pos = group_end
|
||||
prev1_kind, prev2_kind = .None, .None
|
||||
in_fence = false
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
prev2_kind, prev2_pos = prev1_kind, prev1_pos
|
||||
prev1_kind, prev1_pos = kind, pos
|
||||
pos = next
|
||||
}
|
||||
|
||||
if emitted_up_to == 0 {
|
||||
// No definition list anywhere; the body passes through unchanged.
|
||||
return body
|
||||
}
|
||||
strings.write_string(&sb, body[emitted_up_to:])
|
||||
return strings.clone(strings.to_string(sb), allocator)
|
||||
}
|
||||
|
||||
// line_at returns the line beginning at byte offset `pos` (excluding its
|
||||
// trailing newline) along with the offset of the following line. `pos` must be
|
||||
// 0 or one past a '\n'. ok is false once `pos` reaches the end of `body`.
|
||||
line_at :: proc(body: string, pos: int) -> (line: string, next: int, ok: bool) {
|
||||
if pos >= len(body) {
|
||||
return "", pos, false
|
||||
}
|
||||
nl := strings.index_byte(body[pos:], '\n')
|
||||
if nl < 0 {
|
||||
return body[pos:], len(body), true
|
||||
}
|
||||
return body[pos:pos + nl], pos + nl + 1, true
|
||||
}
|
||||
|
||||
// has_def_line reports whether any line in `body` is a definition line (see
|
||||
// is_def_line). It is a cheap guard for convert_deflists: a body with no
|
||||
// definition line cannot contain a definition list. There are no false
|
||||
// negatives (every definition list requires a definition line); a false
|
||||
// positive — e.g. a colon line that only appears inside a code fence — simply
|
||||
// falls through to the full scan, which then finds no match and returns `body`.
|
||||
has_def_line :: proc(body: string) -> bool {
|
||||
pos := 0
|
||||
for {
|
||||
line, next := line_at(body, pos) or_break
|
||||
if is_def_line(line) {
|
||||
return true
|
||||
}
|
||||
pos = next
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// write_deflist_group renders the definition-list group beginning at `start`
|
||||
// directly into `sb`, wrapped in <dl>...</dl>, and returns the byte offset one
|
||||
// past the group (the start of the first line not consumed). The caller must
|
||||
// have confirmed via term_has_def that a group starts at `start`, so the group
|
||||
// always contains at least one term+definition pair. A trailing blank line that
|
||||
// separates the group from following content is left unconsumed.
|
||||
write_deflist_group :: proc(sb: ^strings.Builder, body: string, start: int) -> (end: int) {
|
||||
strings.write_string(sb, "<dl>")
|
||||
|
||||
pos := start
|
||||
for {
|
||||
term_line, term_next, term_ok := line_at(body, pos)
|
||||
if !term_ok || is_blank_line(term_line) || is_def_line(term_line) {
|
||||
break
|
||||
}
|
||||
|
||||
// The definition follows immediately or across a single blank line.
|
||||
def_pos := term_next
|
||||
def_line, def_next, def_ok := line_at(body, def_pos)
|
||||
if def_ok && is_blank_line(def_line) {
|
||||
def_pos = def_next
|
||||
def_line, def_next, def_ok = line_at(body, def_pos)
|
||||
}
|
||||
if !def_ok || !is_def_line(def_line) {
|
||||
break
|
||||
}
|
||||
|
||||
// Emit the term with its first definition, then any consecutive
|
||||
// definition lines as additional <dd>s under the same term.
|
||||
strings.write_string(sb, "<dt>")
|
||||
strings.write_string(sb, render_inline_md(strings.trim_space(term_line)))
|
||||
strings.write_string(sb, "</dt><dd>")
|
||||
strings.write_string(sb, render_inline_md(def_content(def_line)))
|
||||
strings.write_string(sb, "</dd>")
|
||||
pos = def_next
|
||||
for {
|
||||
cont, cont_next, cont_ok := line_at(body, pos)
|
||||
if !cont_ok || !is_def_line(cont) {
|
||||
break
|
||||
}
|
||||
strings.write_string(sb, "<dd>")
|
||||
strings.write_string(sb, render_inline_md(def_content(cont)))
|
||||
strings.write_string(sb, "</dd>")
|
||||
pos = cont_next
|
||||
}
|
||||
|
||||
// Continue the group if another term+definition pair follows,
|
||||
// optionally separated by one blank line. Otherwise stop here without
|
||||
// consuming a trailing blank line.
|
||||
blank_line, after_blank, blank_ok := line_at(body, pos)
|
||||
if blank_ok && is_blank_line(blank_line) {
|
||||
if term_has_def(body, after_blank) {
|
||||
pos = after_blank
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
// No blank line: loop and let the top re-test this line as a term.
|
||||
}
|
||||
|
||||
strings.write_string(sb, "</dl>")
|
||||
return pos
|
||||
}
|
||||
|
||||
// term_has_def reports whether the line at `pos` is a term that is immediately
|
||||
// (or across one blank line) followed by a definition line.
|
||||
term_has_def :: proc(body: string, pos: int) -> bool {
|
||||
line, next, ok := line_at(body, pos)
|
||||
if !ok || is_blank_line(line) || is_def_line(line) {
|
||||
return false
|
||||
}
|
||||
def_pos := next
|
||||
def_line, def_next, def_ok := line_at(body, def_pos)
|
||||
if def_ok && is_blank_line(def_line) {
|
||||
def_pos = def_next
|
||||
def_line, def_next, def_ok = line_at(body, def_pos)
|
||||
}
|
||||
return def_ok && is_def_line(def_line)
|
||||
}
|
||||
|
||||
// fence_open reports whether `line` opens a fenced code block and, if so, which
|
||||
// marker character it uses. Leading spaces are ignored.
|
||||
fence_open :: proc(line: string) -> (marker: u8, ok: bool) {
|
||||
trimmed := strings.trim_left(line, " ")
|
||||
if strings.has_prefix(trimmed, "```") {
|
||||
return '`', true
|
||||
}
|
||||
if strings.has_prefix(trimmed, "~~~") {
|
||||
return '~', true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// is_fence_line reports whether `line` is a fence using the given marker
|
||||
// character (used to detect the closing fence of an open block).
|
||||
is_fence_line :: proc(line: string, marker: u8) -> bool {
|
||||
trimmed := strings.trim_left(line, " ")
|
||||
if marker == '~' {
|
||||
return strings.has_prefix(trimmed, "~~~")
|
||||
}
|
||||
return strings.has_prefix(trimmed, "```")
|
||||
}
|
||||
|
||||
// is_def_line returns true if the line is a definition line: fewer than four
|
||||
// columns of leading whitespace, a colon, then whitespace or end-of-line. Four
|
||||
// or more columns of indentation is a code block, not a definition.
|
||||
is_def_line :: proc(line: string) -> bool {
|
||||
indent := 0
|
||||
n := 0
|
||||
for n < len(line) && (line[n] == ' ' || line[n] == '\t') {
|
||||
indent += line[n] == '\t' ? 4 : 1
|
||||
n += 1
|
||||
}
|
||||
if indent >= 4 {
|
||||
return false
|
||||
}
|
||||
rest := line[n:]
|
||||
if len(rest) < 1 || rest[0] != ':' {
|
||||
return false
|
||||
}
|
||||
if len(rest) == 1 {
|
||||
return true
|
||||
}
|
||||
return rest[1] == ' ' || rest[1] == '\t'
|
||||
}
|
||||
|
||||
// def_content extracts the definition text from a definition line,
|
||||
// stripping the leading colon and surrounding whitespace.
|
||||
def_content :: proc(line: string) -> string {
|
||||
trimmed := strings.trim_left(line, " \t")
|
||||
content := trimmed[1:]
|
||||
content = strings.trim_left(content, " \t")
|
||||
return content
|
||||
}
|
||||
|
||||
// is_blank_line returns true for empty or whitespace-only lines.
|
||||
is_blank_line :: proc(line: string) -> bool {
|
||||
return strings.trim_space(line) == ""
|
||||
}
|
||||
|
||||
// render_inline_md renders a snippet of markdown through cmark and strips
|
||||
// the surrounding <p> tags. Result lives in context.temp_allocator.
|
||||
render_inline_md :: proc(text: string) -> string {
|
||||
raw := cm.markdown_to_html_from_string(text, {.Unsafe})
|
||||
defer cm.free_string(raw)
|
||||
return strings.clone(strip_p_tags(raw), context.temp_allocator)
|
||||
}
|
||||
|
||||
// strip_p_tags removes surrounding <p></p> if the HTML is a single paragraph.
|
||||
strip_p_tags :: proc(html: string) -> string {
|
||||
s := html
|
||||
if len(s) > 0 && s[len(s) - 1] == '\n' {
|
||||
s = s[:len(s) - 1]
|
||||
}
|
||||
if strings.has_prefix(s, "<p>") && strings.has_suffix(s, "</p>") {
|
||||
return s[3:len(s) - 4]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
#+test
|
||||
package markdown
|
||||
|
||||
import "core:testing"
|
||||
|
||||
// Fixture-driven tests for convert_deflists. Every fixture is exact-matched
|
||||
// against its oracle in testdata/deflists/expected/. See
|
||||
// testdata/deflists/reference/README.md for how each expected file was decided
|
||||
// (cross-checked against Pandoc and Hugo/Goldmark).
|
||||
//
|
||||
// The fixture inputs are the definition-list examples from
|
||||
// https://www.markdownlang.com/extended/definition-lists.html
|
||||
//
|
||||
// Provenance of each expected file:
|
||||
// 01,02,08,09 Pandoc and Hugo agree; oracle = that agreed HTML.
|
||||
// 06 Both agree it is NOT a deflist; convert_deflists passes the
|
||||
// markdown through unchanged, so expected = the raw input.
|
||||
// 07 CommonMark's <4-space marker rule: we accept the 2-space colon
|
||||
// (Pandoc does; Hugo does not).
|
||||
// 05 Block nesting is unsupported; our flatten happens to match
|
||||
// Pandoc's output exactly.
|
||||
// 03,04 Block content in definitions is unsupported; expected captures
|
||||
// our current degraded behavior (a <dl> for the term/first line,
|
||||
// the rest falling through as markdown) as a regression guard.
|
||||
Deflist_Fixture :: struct {
|
||||
name: string,
|
||||
input: string,
|
||||
expected: string,
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_deflist_fixtures :: proc(t: ^testing.T) {
|
||||
cases := []Deflist_Fixture {
|
||||
{
|
||||
"01-basic",
|
||||
#load(#directory + "testdata/deflists/01-basic.md", string),
|
||||
#load(#directory + "testdata/deflists/expected/01-basic.html", string),
|
||||
},
|
||||
{
|
||||
"02-multiple-defs",
|
||||
#load(#directory + "testdata/deflists/02-multiple-defs.md", string),
|
||||
#load(#directory + "testdata/deflists/expected/02-multiple-defs.html", string),
|
||||
},
|
||||
{
|
||||
"03-multiline-def",
|
||||
#load(#directory + "testdata/deflists/03-multiline-def.md", string),
|
||||
#load(#directory + "testdata/deflists/expected/03-multiline-def.html", string),
|
||||
},
|
||||
{
|
||||
"04-markdown-in-def",
|
||||
#load(#directory + "testdata/deflists/04-markdown-in-def.md", string),
|
||||
#load(#directory + "testdata/deflists/expected/04-markdown-in-def.html", string),
|
||||
},
|
||||
{
|
||||
"05-nested",
|
||||
#load(#directory + "testdata/deflists/05-nested.md", string),
|
||||
#load(#directory + "testdata/deflists/expected/05-nested.html", string),
|
||||
},
|
||||
{
|
||||
"06-compact",
|
||||
#load(#directory + "testdata/deflists/06-compact.md", string),
|
||||
#load(#directory + "testdata/deflists/expected/06-compact.html", string),
|
||||
},
|
||||
{
|
||||
"07-extra-space",
|
||||
#load(#directory + "testdata/deflists/07-extra-space.md", string),
|
||||
#load(#directory + "testdata/deflists/expected/07-extra-space.html", string),
|
||||
},
|
||||
{
|
||||
"08-glossary",
|
||||
#load(#directory + "testdata/deflists/08-glossary.md", string),
|
||||
#load(#directory + "testdata/deflists/expected/08-glossary.html", string),
|
||||
},
|
||||
{
|
||||
"09-api-params",
|
||||
#load(#directory + "testdata/deflists/09-api-params.md", string),
|
||||
#load(#directory + "testdata/deflists/expected/09-api-params.html", string),
|
||||
},
|
||||
}
|
||||
|
||||
for c in cases {
|
||||
result := convert_deflists(c.input, context.temp_allocator)
|
||||
testing.expectf(
|
||||
t,
|
||||
result == c.expected,
|
||||
"[%s]\n expected: %q\n got: %q",
|
||||
c.name,
|
||||
c.expected,
|
||||
result,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
#+test
|
||||
package markdown
|
||||
|
||||
import "core:strings"
|
||||
import "core:testing"
|
||||
|
||||
@(test)
|
||||
test_single_entry :: proc(t: ^testing.T) {
|
||||
input := "term\n\n: definition"
|
||||
result := convert_deflists(input, context.temp_allocator)
|
||||
expected := "<dl><dt>term</dt><dd>definition</dd></dl>"
|
||||
testing.expect_value(t, result, expected)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_single_entry_no_blank :: proc(t: ^testing.T) {
|
||||
input := "term\n: definition"
|
||||
result := convert_deflists(input, context.temp_allocator)
|
||||
expected := "<dl><dt>term</dt><dd>definition</dd></dl>"
|
||||
testing.expect_value(t, result, expected)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_indented_variant :: proc(t: ^testing.T) {
|
||||
input := " term\n : definition"
|
||||
result := convert_deflists(input, context.temp_allocator)
|
||||
expected := "<dl><dt>term</dt><dd>definition</dd></dl>"
|
||||
testing.expect_value(t, result, expected)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_multiple_entries :: proc(t: ^testing.T) {
|
||||
input := "t1\n\n: d1\n\nt2\n\n: d2"
|
||||
result := convert_deflists(input, context.temp_allocator)
|
||||
expected := "<dl><dt>t1</dt><dd>d1</dd><dt>t2</dt><dd>d2</dd></dl>"
|
||||
testing.expect_value(t, result, expected)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_mixed_indented_and_non_indented :: proc(t: ^testing.T) {
|
||||
input := "t1\n\n: d1\n\n t2\n : d2\n\nt3\n\n: d3"
|
||||
result := convert_deflists(input, context.temp_allocator)
|
||||
expected := "<dl><dt>t1</dt><dd>d1</dd><dt>t2</dt><dd>d2</dd><dt>t3</dt><dd>d3</dd></dl>"
|
||||
testing.expect_value(t, result, expected)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inline_markdown_in_term :: proc(t: ^testing.T) {
|
||||
input := "`code`\n\n: def"
|
||||
result := convert_deflists(input, context.temp_allocator)
|
||||
expected := "<dl><dt><code>code</code></dt><dd>def</dd></dl>"
|
||||
testing.expect_value(t, result, expected)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inline_markdown_in_definition :: proc(t: ^testing.T) {
|
||||
input := "term\n\n: see [Content](#content) here"
|
||||
result := convert_deflists(input, context.temp_allocator)
|
||||
testing.expect(t, strings.contains(result, `<a href="#content">Content</a>`))
|
||||
testing.expect(t, strings.contains(result, "<dd>see "))
|
||||
testing.expect(t, strings.contains(result, "</dd>"))
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_regular_text_passes_through :: proc(t: ^testing.T) {
|
||||
input := "This is: not a deflist"
|
||||
result := convert_deflists(input, context.temp_allocator)
|
||||
testing.expect_value(t, result, input)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_colon_inside_paragraph_no_false_positive :: proc(t: ^testing.T) {
|
||||
input := "First paragraph.\n\nSecond paragraph."
|
||||
result := convert_deflists(input, context.temp_allocator)
|
||||
testing.expect_value(t, result, input)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_deflist_between_paragraphs :: proc(t: ^testing.T) {
|
||||
input := "Before.\n\nterm\n\n: def\n\nAfter."
|
||||
result := convert_deflists(input, context.temp_allocator)
|
||||
testing.expect(t, strings.has_prefix(result, "Before."))
|
||||
testing.expect(t, strings.contains(result, "<dl><dt>term</dt><dd>def</dd></dl>"))
|
||||
testing.expect(t, strings.has_suffix(result, "After."))
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_empty_body :: proc(t: ^testing.T) {
|
||||
result := convert_deflists("", context.temp_allocator)
|
||||
testing.expect_value(t, result, "")
|
||||
}
|
||||
|
||||
// Regression: a fenced code block following a definition list must stay a
|
||||
// separate block. Without a blank line after the emitted </dl>, CommonMark
|
||||
// treats the fence as raw HTML continuation of the <dl> block and passes its
|
||||
// contents through unescaped (leaking e.g. an example <h1> into the document).
|
||||
@(test)
|
||||
test_deflist_before_code_fence :: proc(t: ^testing.T) {
|
||||
input := "term\n\n: def\n\n```\n<h1>example</h1>\n```"
|
||||
result := convert_deflists(input, context.temp_allocator)
|
||||
testing.expect(t, strings.contains(result, "</dl>\n\n```"))
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_docs_md_pattern :: proc(t: ^testing.T) {
|
||||
input := "content\n\n: `content` holds your pages.\n\n assets\n : `assets` contains files."
|
||||
result := convert_deflists(input, context.temp_allocator)
|
||||
testing.expect(t, strings.contains(result, "<dl>"))
|
||||
testing.expect(t, strings.contains(result, "<dt>content</dt>"))
|
||||
testing.expect(t, strings.contains(result, "<dt>assets</dt>"))
|
||||
testing.expect(t, strings.contains(result, "<code>content</code>"))
|
||||
testing.expect(t, strings.contains(result, "<code>assets</code>"))
|
||||
testing.expect(t, strings.contains(result, "</dl>"))
|
||||
}
|
||||
|
||||
@@ -30,4 +30,3 @@ test_emoji_skips_invalid_shortcodes :: proc(t: ^testing.T) {
|
||||
testing.expect_value(t, expand_emoji(":Smile:"), ":Smile:")
|
||||
testing.expect_value(t, expand_emoji(": not real :"), ": not real :")
|
||||
}
|
||||
|
||||
|
||||
+15
-97
@@ -1,5 +1,7 @@
|
||||
package markdown
|
||||
|
||||
import cm "vendor:commonmark"
|
||||
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
|
||||
@@ -169,27 +171,27 @@ inject_notes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> strin
|
||||
}
|
||||
|
||||
// Render definition through cmark for markdown support
|
||||
def_html := render_inline_md(def_text)
|
||||
def_html := cm.markdown_to_html_from_string(def_text, {.Unsafe})
|
||||
def_html = strip_p_tags(def_html)
|
||||
|
||||
note: string
|
||||
defer delete(note)
|
||||
if is_margin {
|
||||
fmt.sbprintf(
|
||||
&parts,
|
||||
note = fmt.aprintf(
|
||||
`<label for="mn-%s" class="margin-toggle"></label><input type="checkbox" id="mn-%s" class="margin-toggle"><span class="marginnote">%s</span>`,
|
||||
id,
|
||||
id,
|
||||
def_html,
|
||||
)
|
||||
} else {
|
||||
fmt.sbprintf(
|
||||
&parts,
|
||||
note = fmt.aprintf(
|
||||
`<label for="fn-%s" class="margin-toggle sidenote-number"></label><input type="checkbox" id="fn-%s" class="margin-toggle"><span class="sidenote">%s</span>`,
|
||||
id,
|
||||
id,
|
||||
def_html,
|
||||
)
|
||||
}
|
||||
strings.write_string(&parts, note)
|
||||
|
||||
remaining = remaining[ref_end:]
|
||||
}
|
||||
@@ -197,98 +199,14 @@ inject_notes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> strin
|
||||
return strings.to_string(parts)
|
||||
}
|
||||
|
||||
// inject_footnotes finds [^id] and [*id] references in rendered HTML, numbers
|
||||
// them sequentially by order of appearance, and replaces them with <sup> links.
|
||||
// Appends a <section class="footnotes"><ol> at the end with definitions.
|
||||
// Both sidenote ([^id]) and marginnote ([*id]) references are treated equally.
|
||||
inject_footnotes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> string {
|
||||
if len(sn_defs) == 0 && len(mn_defs) == 0 {
|
||||
return html
|
||||
// strip_p_tags removes surrounding <p></p> if the HTML is a single paragraph.
|
||||
strip_p_tags :: proc(html: string) -> string {
|
||||
s := html
|
||||
if len(s) > 0 && s[len(s) - 1] == '\n' {
|
||||
s = s[:len(s) - 1]
|
||||
}
|
||||
|
||||
parts: strings.Builder
|
||||
strings.builder_init_len(&parts, 0)
|
||||
defer strings.builder_destroy(&parts)
|
||||
|
||||
number_of: map[string]int = make(map[string]int, allocator = context.temp_allocator)
|
||||
ordered_ids: [dynamic]string = make([dynamic]string, 0, allocator = context.temp_allocator)
|
||||
next_num := 1
|
||||
|
||||
remaining := html
|
||||
|
||||
for {
|
||||
sn_pos := strings.index(remaining, "[^")
|
||||
mn_pos := strings.index(remaining, "[*")
|
||||
|
||||
is_margin := mn_pos >= 0 && (sn_pos < 0 || mn_pos < sn_pos)
|
||||
pos := sn_pos
|
||||
if is_margin {
|
||||
pos = mn_pos
|
||||
}
|
||||
if pos < 0 {
|
||||
strings.write_string(&parts, remaining)
|
||||
break
|
||||
}
|
||||
|
||||
strings.write_string(&parts, remaining[:pos])
|
||||
|
||||
close := strings.index(remaining[pos + 2:], "]")
|
||||
if close < 0 {
|
||||
strings.write_string(&parts, remaining[pos:])
|
||||
break
|
||||
}
|
||||
|
||||
id := remaining[pos + 2:pos + 2 + close]
|
||||
ref_end := pos + 2 + close + 1
|
||||
|
||||
defs := sn_defs
|
||||
if is_margin {
|
||||
defs = mn_defs
|
||||
}
|
||||
def_text, found := defs[id]
|
||||
if !found {
|
||||
strings.write_string(&parts, remaining[pos:ref_end])
|
||||
remaining = remaining[ref_end:]
|
||||
continue
|
||||
}
|
||||
|
||||
num, seen := number_of[id]
|
||||
if !seen {
|
||||
num = next_num
|
||||
number_of[id] = num
|
||||
next_num += 1
|
||||
append(&ordered_ids, id)
|
||||
}
|
||||
|
||||
fmt.sbprintf(&parts, `<sup><a href="#fn-%d" id="fnref-%d">%d</a></sup>`, num, num, num)
|
||||
|
||||
remaining = remaining[ref_end:]
|
||||
if strings.has_prefix(s, "<p>") && strings.has_suffix(s, "</p>") {
|
||||
return s[3:len(s) - 4]
|
||||
}
|
||||
|
||||
if len(ordered_ids) > 0 {
|
||||
strings.write_string(&parts, "\n<section class=\"footnotes\">\n<hr>\n<ol>\n")
|
||||
for id in ordered_ids {
|
||||
def_text, ok := sn_defs[id]
|
||||
if !ok {
|
||||
def_text, ok = mn_defs[id]
|
||||
}
|
||||
if !ok do continue
|
||||
|
||||
def_html := render_inline_md(def_text)
|
||||
num := number_of[id]
|
||||
|
||||
fmt.sbprintf(
|
||||
&parts,
|
||||
`<li id="fn-%d">%s <a href="#fnref-%d" class="footnote-backref">↩︎</a></li>` +
|
||||
"\n",
|
||||
num,
|
||||
def_html,
|
||||
num,
|
||||
)
|
||||
}
|
||||
strings.write_string(&parts, "</ol>\n</section>")
|
||||
}
|
||||
|
||||
return strings.to_string(parts)
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
@@ -119,102 +119,3 @@ test_inject_notes_missing_ref :: proc(t: ^testing.T) {
|
||||
testing.expect(t, strings.contains(out, "[^missing]"))
|
||||
testing.expect(t, strings.contains(out, "[*missing]"))
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inject_footnotes_basic :: proc(t: ^testing.T) {
|
||||
html := "Text[^a] end."
|
||||
sn := map[string]string {
|
||||
"a" = "a footnote",
|
||||
}
|
||||
defer delete_map(sn)
|
||||
mn := make(map[string]string)
|
||||
defer delete_map(mn)
|
||||
|
||||
out := inject_footnotes(html, sn, mn)
|
||||
|
||||
testing.expect(t, strings.contains(out, `<sup><a href="#fn-1" id="fnref-1">1</a></sup>`))
|
||||
testing.expect(t, strings.contains(out, `<li id="fn-1">a footnote`))
|
||||
testing.expect(t, strings.contains(out, `class="footnote-backref"`))
|
||||
testing.expect(t, strings.contains(out, `<section class="footnotes">`))
|
||||
testing.expect(t, strings.contains(out, "</section>"))
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inject_footnotes_numbered_by_appearance :: proc(t: ^testing.T) {
|
||||
html := "Second[^b] then first[^a]."
|
||||
sn := map[string]string {
|
||||
"a" = "def a",
|
||||
"b" = "def b",
|
||||
}
|
||||
defer delete_map(sn)
|
||||
mn := make(map[string]string)
|
||||
defer delete_map(mn)
|
||||
|
||||
out := inject_footnotes(html, sn, mn)
|
||||
|
||||
// b appears first in the text → 1, a → 2
|
||||
testing.expect(t, strings.contains(out, `id="fnref-1">1</a></sup>`))
|
||||
testing.expect(t, strings.contains(out, `id="fnref-2">2</a></sup>`))
|
||||
testing.expect(t, strings.contains(out, `<li id="fn-1">def b`))
|
||||
testing.expect(t, strings.contains(out, `<li id="fn-2">def a`))
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inject_footnotes_marginnote_treated_same :: proc(t: ^testing.T) {
|
||||
html := "Sidenote[^a] and marginnote[*b]."
|
||||
sn := map[string]string {
|
||||
"a" = "sn def",
|
||||
}
|
||||
defer delete_map(sn)
|
||||
mn := map[string]string {
|
||||
"b" = "mn def",
|
||||
}
|
||||
defer delete_map(mn)
|
||||
|
||||
out := inject_footnotes(html, sn, mn)
|
||||
|
||||
// Both get numbered as regular footnotes
|
||||
testing.expect(t, strings.contains(out, `id="fnref-1">1</a></sup>`))
|
||||
testing.expect(t, strings.contains(out, `id="fnref-2">2</a></sup>`))
|
||||
testing.expect(t, strings.contains(out, `<li id="fn-1">sn def`))
|
||||
testing.expect(t, strings.contains(out, `<li id="fn-2">mn def`))
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inject_footnotes_no_defs :: proc(t: ^testing.T) {
|
||||
html := "No notes here."
|
||||
sn := make(map[string]string)
|
||||
mn := make(map[string]string)
|
||||
testing.expect(t, inject_footnotes(html, sn, mn) == html)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inject_footnotes_missing_def :: proc(t: ^testing.T) {
|
||||
html := "Ref[^missing] end."
|
||||
sn := map[string]string {
|
||||
"other" = "x",
|
||||
}
|
||||
defer delete_map(sn)
|
||||
mn := make(map[string]string)
|
||||
defer delete_map(mn)
|
||||
|
||||
out := inject_footnotes(html, sn, mn)
|
||||
|
||||
testing.expect(t, strings.contains(out, "[^missing]"))
|
||||
testing.expect(t, !strings.contains(out, "<section"))
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inject_footnotes_inline_markdown :: proc(t: ^testing.T) {
|
||||
html := "Text[^a] end."
|
||||
sn := map[string]string {
|
||||
"a" = "see [link](http://example.com) here",
|
||||
}
|
||||
defer delete_map(sn)
|
||||
mn := make(map[string]string)
|
||||
defer delete_map(mn)
|
||||
|
||||
out := inject_footnotes(html, sn, mn)
|
||||
|
||||
testing.expect(t, strings.contains(out, `<a href="http://example.com">link</a>`))
|
||||
}
|
||||
|
||||
+9
-19
@@ -90,12 +90,9 @@ write_escaped :: proc(b: ^strings.Builder, s: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// unescape_html returns a caller-owned string (free it with delete). When the
|
||||
// input has no entities it returns a clone; otherwise it returns the builder's
|
||||
// buffer. It must NOT destroy the builder — to_string aliases that buffer, so the
|
||||
// returned string owns it.
|
||||
unescape_html :: proc(s: string) -> string {
|
||||
sb := strings.builder_make()
|
||||
defer strings.builder_destroy(&sb)
|
||||
|
||||
start := 0
|
||||
for i in 0 ..< len(s) {
|
||||
@@ -122,28 +119,22 @@ unescape_html :: proc(s: string) -> string {
|
||||
strings.write_string(&sb, replacement)
|
||||
start = i + semi + 1
|
||||
}
|
||||
if start == 0 do return strings.clone(s)
|
||||
if start == 0 do return s
|
||||
if start < len(s) do strings.write_string(&sb, s[start:])
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
highlight_block :: proc(code: string, lang: string, file_path: string) -> string {
|
||||
g, ok := ts.grammar(lang)
|
||||
if !ok {
|
||||
gc := ts.load_grammar(lang)
|
||||
if gc == nil {
|
||||
return code
|
||||
}
|
||||
parser := ts.open_parser(g)
|
||||
if parser == nil {
|
||||
return code
|
||||
}
|
||||
defer ts.parser_delete(parser)
|
||||
|
||||
raw_code := unescape_html(code)
|
||||
defer delete(raw_code)
|
||||
raw_c := strings.clone_to_cstring(raw_code)
|
||||
defer delete(raw_c)
|
||||
|
||||
tree := ts.parser_parse_string(parser, nil, raw_c, u32(len(raw_code)))
|
||||
tree := ts.parser_parse_string(gc.parser, nil, raw_c, u32(len(raw_code)))
|
||||
if tree == nil {
|
||||
return code
|
||||
}
|
||||
@@ -165,13 +156,12 @@ highlight_block :: proc(code: string, lang: string, file_path: string) -> string
|
||||
}
|
||||
}
|
||||
|
||||
if g.query == nil {
|
||||
cursor := gc.cursor
|
||||
if cursor == nil {
|
||||
return code
|
||||
}
|
||||
cursor := ts.query_cursor_new()
|
||||
defer ts.query_cursor_delete(cursor)
|
||||
|
||||
ts.query_cursor_exec(cursor, g.query, root)
|
||||
ts.query_cursor_exec(cursor, gc.query, root)
|
||||
|
||||
captures := make([dynamic]Capture, 0, 64, context.temp_allocator)
|
||||
defer delete(captures)
|
||||
@@ -184,7 +174,7 @@ highlight_block :: proc(code: string, lang: string, file_path: string) -> string
|
||||
}
|
||||
cap := match.captures[capture_idx]
|
||||
name_len: u32
|
||||
name_c := ts.query_capture_name_for_id(g.query, cap.index, &name_len)
|
||||
name_c := ts.query_capture_name_for_id(gc.query, cap.index, &name_len)
|
||||
if name_c == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
#+test
|
||||
package markdown
|
||||
|
||||
import ts "../treesitter"
|
||||
|
||||
import "core:mem"
|
||||
import "core:strings"
|
||||
import "core:testing"
|
||||
|
||||
// highlight_block returns the input string unchanged (borrowed) when it can't
|
||||
// highlight — no grammar, no query, or zero captures — and a freshly-allocated
|
||||
// string when it does. Free only the latter; identity (raw_data) tells them
|
||||
// apart regardless of content.
|
||||
@(private = "file")
|
||||
free_highlight :: proc(out, code: string) {
|
||||
if raw_data(out) != raw_data(code) {
|
||||
delete(out)
|
||||
}
|
||||
}
|
||||
|
||||
// A builtin grammar (css) highlights into hl- spans, preserving the source text.
|
||||
@(test)
|
||||
test_highlight_block_css :: proc(t: ^testing.T) {
|
||||
code := "a{color:red}"
|
||||
out := highlight_block(code, "css", "test")
|
||||
defer free_highlight(out, code)
|
||||
|
||||
testing.expect(t, strings.contains(out, "<span class=\"hl-"), "css should be highlighted")
|
||||
testing.expect(t, strings.contains(out, "</span>"))
|
||||
testing.expect(t, strings.contains(out, "color"))
|
||||
testing.expect(t, strings.contains(out, "red"))
|
||||
}
|
||||
|
||||
// The other builtin grammar (html) highlights, and the source's own '<'/'>' are
|
||||
// HTML-escaped in the output (so they can't be confused with the wrapping spans).
|
||||
@(test)
|
||||
test_highlight_block_html_escapes_source :: proc(t: ^testing.T) {
|
||||
code := "<p>hi</p>"
|
||||
out := highlight_block(code, "html", "test")
|
||||
defer free_highlight(out, code)
|
||||
|
||||
testing.expect(t, strings.contains(out, "<span class=\"hl-"), "html should be highlighted")
|
||||
testing.expect(t, strings.contains(out, "hi"))
|
||||
// The literal angle brackets of the source are escaped in the output.
|
||||
testing.expect(t, strings.contains(out, "<"))
|
||||
testing.expect(t, strings.contains(out, ">"))
|
||||
}
|
||||
|
||||
// A language with no available grammar returns the input verbatim (best-effort:
|
||||
// highlighting is optional). The returned string must be the same buffer, so
|
||||
// free_highlight leaves it alone.
|
||||
@(test)
|
||||
test_highlight_block_unknown_lang_passthrough :: proc(t: ^testing.T) {
|
||||
code := "fn main() {}"
|
||||
out := highlight_block(code, "no-such-language", "test")
|
||||
defer free_highlight(out, code)
|
||||
|
||||
testing.expect_value(t, out, code)
|
||||
testing.expect(t, raw_data(out) == raw_data(code), "passthrough must return the input buffer")
|
||||
}
|
||||
|
||||
// End-to-end: highlight_code finds a fenced code block in rendered HTML and
|
||||
// rewrites its body with highlight spans while preserving the <pre><code> shell.
|
||||
// Run under a scratch arena (as production does) so the intermediate allocations
|
||||
// highlight_code doesn't individually free are reclaimed in one shot.
|
||||
@(test)
|
||||
test_highlight_code_wraps_css_block :: proc(t: ^testing.T) {
|
||||
arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
context.allocator = mem.dynamic_arena_allocator(&arena)
|
||||
|
||||
html := `<pre><code class="language-css">a{color:red}</code></pre>`
|
||||
out := highlight_code(html, "test")
|
||||
|
||||
testing.expect(t, strings.contains(out, `<pre><code class="language-css">`))
|
||||
testing.expect(t, strings.contains(out, "</code></pre>"))
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(out, "<span class=\"hl-"),
|
||||
"block body should be highlighted",
|
||||
)
|
||||
}
|
||||
|
||||
// unescape_html must return a caller-owned allocation the caller frees exactly
|
||||
// once. The buggy version frees its builder internally (defer builder_destroy)
|
||||
// and returns the freed alias, so the caller's delete becomes a double-free that
|
||||
// the test runner's tracking allocator flags.
|
||||
@(test)
|
||||
test_unescape_html_entities_owned :: proc(t: ^testing.T) {
|
||||
got := unescape_html("<a>")
|
||||
defer delete(got)
|
||||
testing.expect_value(t, got, "<a>")
|
||||
}
|
||||
|
||||
// Same contract on the no-entity path: the buggy version returns the borrowed
|
||||
// input, so freeing it is a bad free (pointer not from the allocator).
|
||||
@(test)
|
||||
test_unescape_html_passthrough_owned :: proc(t: ^testing.T) {
|
||||
got := unescape_html("plain text")
|
||||
defer delete(got)
|
||||
testing.expect_value(t, got, "plain text")
|
||||
}
|
||||
+32
-94
@@ -3,11 +3,8 @@ package markdown
|
||||
import cm "vendor:commonmark"
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:log"
|
||||
import "core:strings"
|
||||
|
||||
import diags "../diagnostics"
|
||||
|
||||
Extension :: enum {
|
||||
Emoji,
|
||||
Sidenotes,
|
||||
@@ -15,43 +12,22 @@ Extension :: enum {
|
||||
Highlight,
|
||||
Sections,
|
||||
HeadingIDs,
|
||||
DefLists,
|
||||
Footnotes,
|
||||
}
|
||||
|
||||
DEFAULT_EXTENSIONS :: bit_set[Extension]{.Emoji, .Sidenotes, .Alerts, .HeadingIDs, .DefLists}
|
||||
DEFAULT_EXTENSIONS :: bit_set[Extension]{.Emoji, .Sidenotes, .Alerts, .HeadingIDs}
|
||||
|
||||
EXTENSION_NAMES :: []string{
|
||||
"emoji", "sidenotes", "alerts", "highlight", "sections",
|
||||
"heading_ids", "deflists", "footnotes",
|
||||
}
|
||||
|
||||
// Caller is responsible for freeing string
|
||||
process :: proc(
|
||||
body: string,
|
||||
ext: bit_set[Extension],
|
||||
file_path: string,
|
||||
allocator := context.allocator,
|
||||
) -> string {
|
||||
process :: proc(body: string, ext: bit_set[Extension], file_path: string) -> string {
|
||||
side_notes := make(map[string]string)
|
||||
margin_notes := make(map[string]string)
|
||||
clean_body := body
|
||||
if .Sidenotes in ext || .Footnotes in ext {
|
||||
if .Sidenotes in ext {
|
||||
clean_body, side_notes, margin_notes = strip_definitions(body)
|
||||
}
|
||||
if .DefLists in ext {
|
||||
clean_body = convert_deflists(clean_body, allocator)
|
||||
}
|
||||
original_html := cm.markdown_to_html_from_string(clean_body, {.Unsafe})
|
||||
html := strings.clone(original_html, allocator)
|
||||
cm.free_string(original_html)
|
||||
|
||||
html := cm.markdown_to_html_from_string(clean_body, {.Unsafe})
|
||||
if .Emoji in ext {
|
||||
html = expand_emoji(html)
|
||||
}
|
||||
if .Footnotes in ext {
|
||||
html = inject_footnotes(html, side_notes, margin_notes)
|
||||
} else if .Sidenotes in ext {
|
||||
if .Sidenotes in ext {
|
||||
html = inject_notes(html, side_notes, margin_notes)
|
||||
}
|
||||
if .Alerts in ext {
|
||||
@@ -73,19 +49,20 @@ process :: proc(
|
||||
parse_extension_list :: proc(s: string) -> (result: bit_set[Extension]) {
|
||||
for part in strings.split(s, ",", allocator = context.temp_allocator) {
|
||||
name := strings.to_lower(strings.trim_space(part), allocator = context.temp_allocator)
|
||||
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
|
||||
switch name {
|
||||
case "emoji":
|
||||
result += {.Emoji}
|
||||
case "sidenotes":
|
||||
result += {.Sidenotes}
|
||||
case "alerts":
|
||||
result += {.Alerts}
|
||||
case "highlight":
|
||||
result += {.Highlight}
|
||||
case "sections":
|
||||
result += {.Sections}
|
||||
case "heading_ids":
|
||||
result += {.HeadingIDs}
|
||||
}
|
||||
result += {e}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -93,60 +70,21 @@ parse_extension_list :: proc(s: string) -> (result: bit_set[Extension]) {
|
||||
// Given a map[Extension]bool, apply it to ext.
|
||||
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
|
||||
}
|
||||
|
||||
if enabled {
|
||||
ext^ += {e}
|
||||
} else {
|
||||
ext^ -= {e}
|
||||
switch name {
|
||||
case "emoji":
|
||||
if enabled {ext^ += {.Emoji}} else {ext^ -= {.Emoji}}
|
||||
case "sidenotes":
|
||||
if enabled {ext^ += {.Sidenotes}} else {ext^ -= {.Sidenotes}}
|
||||
case "alerts":
|
||||
if enabled {ext^ += {.Alerts}} else {ext^ -= {.Alerts}}
|
||||
case "highlight":
|
||||
if enabled {ext^ += {.Highlight}} else {ext^ -= {.Highlight}}
|
||||
case "sections":
|
||||
if enabled {ext^ += {.Sections}} else {ext^ -= {.Sections}}
|
||||
case "heading_ids":
|
||||
if enabled {ext^ += {.HeadingIDs}} else {ext^ -= {.HeadingIDs}}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension_from_name :: proc(name: string) -> (e: Extension, ok: bool) {
|
||||
switch name {
|
||||
case "emoji":
|
||||
e = .Emoji
|
||||
ok = true
|
||||
case "sidenotes":
|
||||
e = .Sidenotes
|
||||
case "alerts":
|
||||
e = .Alerts
|
||||
case "highlight":
|
||||
e = .Highlight
|
||||
case "sections":
|
||||
e = .Sections
|
||||
case "heading_ids":
|
||||
e = .HeadingIDs
|
||||
case "deflists":
|
||||
e = .DefLists
|
||||
case "footnotes":
|
||||
e = .Footnotes
|
||||
case:
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
return e, ok || e != .Emoji
|
||||
}
|
||||
|
||||
// resolve_extension_conflicts resolves mutually exclusive extensions.
|
||||
// Footnotes and Sidenotes share the same [^id] syntax but render differently;
|
||||
// if both are enabled (e.g. from defaults + CLI), Footnotes wins.
|
||||
resolve_extension_conflicts :: proc(ext: ^bit_set[Extension]) {
|
||||
if .Footnotes in ext^ && .Sidenotes in ext^ {
|
||||
ext^ -= {.Sidenotes}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
Term
|
||||
: Definition content
|
||||
@@ -1,7 +0,0 @@
|
||||
Markdown
|
||||
: A lightweight markup language
|
||||
: Created by John Gruber in 2004
|
||||
|
||||
HTML
|
||||
: A standard markup language used to create web pages
|
||||
: Used to create web pages
|
||||
@@ -1,5 +0,0 @@
|
||||
Term
|
||||
: This is the first line of definition content
|
||||
This is the second line, needs at least one space indentation
|
||||
|
||||
This is a new paragraph, needs at least one space indentation and a preceding blank line
|
||||
@@ -1,7 +0,0 @@
|
||||
Markdown Syntax
|
||||
: **Markdown** supports multiple text formatting:
|
||||
- *Italic* and **Bold**
|
||||
- [Link](https://www.markdownlang.com)
|
||||
- `Inline code`
|
||||
- > Block quote
|
||||
- Lists and other elements
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
Outer Term
|
||||
: Outer Definition
|
||||
|
||||
Inner Term
|
||||
: Inner Definition
|
||||
: Another Inner Definition
|
||||
-1
@@ -1 +0,0 @@
|
||||
Term: Definition
|
||||
@@ -1,2 +0,0 @@
|
||||
Term
|
||||
: Definition
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
API
|
||||
: An application programming interface, a set of rules that allow different software applications to communicate with each other.
|
||||
|
||||
Framework
|
||||
: A software library that provides a standardized structure for application development.
|
||||
|
||||
Git
|
||||
: A distributed version control system used to track changes in a project development process.
|
||||
|
||||
IDE
|
||||
: An integrated development environment, a software application that includes a code editor and various development tools.
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
user_id
|
||||
: **Required** - The unique identifier for the user.
|
||||
: Type: `integer`
|
||||
|
||||
name
|
||||
: **Optional** - The display name for the user.
|
||||
: Type: `string`
|
||||
: Default: `null`
|
||||
|
||||
status
|
||||
: **Optional** - The user status.
|
||||
: Type: `string`
|
||||
: Allowed values: `active`, `inactive`, `suspended`
|
||||
: Default: `active`
|
||||
@@ -1 +0,0 @@
|
||||
<dl><dt>Term</dt><dd>Definition content</dd></dl>
|
||||
@@ -1 +0,0 @@
|
||||
<dl><dt>Markdown</dt><dd>A lightweight markup language</dd><dd>Created by John Gruber in 2004</dd><dt>HTML</dt><dd>A standard markup language used to create web pages</dd><dd>Used to create web pages</dd></dl>
|
||||
@@ -1,3 +0,0 @@
|
||||
<dl><dt>Term</dt><dd>This is the first line of definition content</dd></dl> This is the second line, needs at least one space indentation
|
||||
|
||||
This is a new paragraph, needs at least one space indentation and a preceding blank line
|
||||
@@ -1,5 +0,0 @@
|
||||
<dl><dt>Markdown Syntax</dt><dd><strong>Markdown</strong> supports multiple text formatting:</dd></dl> - *Italic* and **Bold**
|
||||
- [Link](https://www.markdownlang.com)
|
||||
- `Inline code`
|
||||
- > Block quote
|
||||
- Lists and other elements
|
||||
@@ -1 +0,0 @@
|
||||
<dl><dt>Outer Term</dt><dd>Outer Definition</dd><dt>Inner Term</dt><dd>Inner Definition</dd><dd>Another Inner Definition</dd></dl>
|
||||
@@ -1 +0,0 @@
|
||||
Term: Definition
|
||||
@@ -1 +0,0 @@
|
||||
<dl><dt>Term</dt><dd>Definition</dd></dl>
|
||||
@@ -1 +0,0 @@
|
||||
<dl><dt>API</dt><dd>An application programming interface, a set of rules that allow different software applications to communicate with each other.</dd><dt>Framework</dt><dd>A software library that provides a standardized structure for application development.</dd><dt>Git</dt><dd>A distributed version control system used to track changes in a project development process.</dd><dt>IDE</dt><dd>An integrated development environment, a software application that includes a code editor and various development tools.</dd></dl>
|
||||
@@ -1 +0,0 @@
|
||||
<dl><dt>user_id</dt><dd><strong>Required</strong> - The unique identifier for the user.</dd><dd>Type: <code>integer</code></dd><dt>name</dt><dd><strong>Optional</strong> - The display name for the user.</dd><dd>Type: <code>string</code></dd><dd>Default: <code>null</code></dd><dt>status</dt><dd><strong>Optional</strong> - The user status.</dd><dd>Type: <code>string</code></dd><dd>Allowed values: <code>active</code>, <code>inactive</code>, <code>suspended</code></dd><dd>Default: <code>active</code></dd></dl>
|
||||
@@ -1,60 +0,0 @@
|
||||
# 3-way behavior comparison: ours vs Pandoc vs Hugo
|
||||
|
||||
Captured for later review (e.g. if we ever pursue "Option B" — full CommonMark
|
||||
block semantics inside definitions). Compares `convert_deflists` output (Option A,
|
||||
inline/flat) against Pandoc (`-f markdown -t html5`) and Hugo/Goldmark for the
|
||||
four fixtures where behavior is interesting. Raw renderings live in `pandoc/` and
|
||||
`hugo/`; ours is in `../expected/`. Whitespace/formatting differences ignored
|
||||
below — only structural differences are described.
|
||||
|
||||
## 03-multiline-def — definition with a continuation line + a second paragraph
|
||||
*How much multi-line content belongs to the `<dd>`?*
|
||||
- **Ours:** only the **first line** goes in the `<dd>`. The continuation line and
|
||||
the second paragraph fall out of the `<dl>` as **raw, unrendered markdown**.
|
||||
- **Pandoc:** folds the continuation line **into** the `<dd>` (lazy continuation),
|
||||
then **ejects** the second paragraph as a sibling `<p>` after `</dl>`.
|
||||
- **Hugo:** keeps **everything inside** the `<dd>` — continuation line as text,
|
||||
second paragraph as a nested `<p>`.
|
||||
- All three differ. We capture the least, Pandoc the middle, Hugo the most.
|
||||
|
||||
## 04-markdown-in-def — definition containing a bullet list (with a blockquote)
|
||||
*What happens to the nested list?*
|
||||
- **Ours:** intro line becomes the `<dd>`; the **entire bullet list falls out as
|
||||
raw markdown** (unrendered `- …` lines after `</dl>`).
|
||||
- **Pandoc:** bullets **flattened into the `<dd>`** as inline text — no `<ul>`, the
|
||||
`-` become literal dashes.
|
||||
- **Hugo:** bullets become a **real nested `<ul>`/`<li>`** (blockquote and all)
|
||||
inside the `<dd>`.
|
||||
- All three differ; same shape as 03. We drop it, Pandoc absorbs-but-flattens,
|
||||
Hugo builds true structure.
|
||||
|
||||
## 05-nested — a definition list nested under another definition
|
||||
*Nest or flatten?*
|
||||
- **Ours:** **flatten** — one flat `<dl>`; "Inner Term" is just another `<dt>`.
|
||||
- **Pandoc:** **flatten** — structurally **identical to ours**.
|
||||
- **Hugo:** **nests** a real inner `<dl>` inside the outer `<dd>`.
|
||||
- Ours == Pandoc; Hugo is the odd one out.
|
||||
|
||||
## 07-extra-space — term then a 2-space-indented `: Definition`
|
||||
*Is a 2-space colon a definition marker?*
|
||||
- **Ours:** **yes** → clean `<dl>`.
|
||||
- **Pandoc:** **yes** → structurally **identical to ours**.
|
||||
- **Hugo:** **no** → rejects it, emits a plain `<p>`.
|
||||
- Ours == Pandoc; Hugo is the odd one out.
|
||||
|
||||
## Pattern
|
||||
|
||||
Two clean groups:
|
||||
|
||||
- **Marker / flattening decisions (05, 07):** we land **exactly on Pandoc**. These
|
||||
are the calls our CommonMark-marker + flat-inline design makes cleanly; Pandoc
|
||||
agrees, Hugo diverges (nests / stricter marker).
|
||||
- **Block-content-in-a-definition (03, 04):** we diverge from **both** tools, doing
|
||||
**less** — we emit the `<dl>` for the term/first line and let the rest fall
|
||||
through as raw markdown, rather than absorbing it into the `<dd>` (flattened like
|
||||
Pandoc, or structured like Hugo).
|
||||
|
||||
In short: our behavior is **"Pandoc, minus the ability to pull block content into
|
||||
a `<dd>`."** Single inline-line definitions (01, 02, 05, 07, 08, 09) match a real
|
||||
engine; genuinely multi-block definitions (03, 04) stop at the first line and pass
|
||||
the remainder through. Closing that gap is what Option B would entail.
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
# Definition-list test fixtures
|
||||
|
||||
Inputs (`../NN-name.md`) are the definition-list examples from
|
||||
<https://www.markdownlang.com/extended/definition-lists.html>.
|
||||
|
||||
`pandoc/` and `hugo/` hold each input rendered by Pandoc (`-f markdown -t html5`)
|
||||
and Hugo (Goldmark with `markup.goldmark.extensions.definitionList = true`).
|
||||
They are retained as reference for a possible future "Option B" (full CommonMark
|
||||
block semantics inside definitions). They are NOT the test oracle for the current
|
||||
implementation.
|
||||
|
||||
`../expected/NN.html` is the oracle for `convert_deflists`: our inline fragment
|
||||
format (no inter-tag newlines, no trailing newline), derived from the trusted
|
||||
tool per the decisions below.
|
||||
|
||||
## Decisions (Option A — simple / inline)
|
||||
|
||||
Marker whitespace follows CommonMark: `:` with 0–3 spaces of indent is a
|
||||
definition; 4+ (tab = 4) is a code block. Definition bodies are inline/flat.
|
||||
|
||||
Every fixture has a concrete exact-match oracle in `../expected/NN.html`.
|
||||
|
||||
| Fixture | Pandoc | Hugo | Decision | expected/ source |
|
||||
|---------|--------|------|----------|------------------|
|
||||
| 01-basic | `<dl>` | `<dl>` | support | hugo (agreed) |
|
||||
| 02-multiple-defs | multi `<dd>` | multi `<dd>` | support | hugo (agreed) |
|
||||
| 06-compact (`Term: Definition`) | `<p>` | `<p>` | NOT a deflist → passthrough | raw input |
|
||||
| 07-extra-space (2-space `:`) | `<dl>` | `<p>` | support (accept, CommonMark ≤3-space rule) | hand-written |
|
||||
| 08-glossary | `<dl>` | `<dl>` | support | hugo (agreed) |
|
||||
| 09-api-params | multi `<dd>` | multi `<dd>` | support | hugo (agreed) |
|
||||
| 05-nested | flattens | nested `<dl>` | flatten (matches Pandoc) | snapshot (== pandoc) |
|
||||
| 03-multiline-def | ejects 2nd para | nests 2nd para in `<dd>` | degrade | snapshot (current output) |
|
||||
| 04-markdown-in-def | flattens list | nested `<ul>`/`<blockquote>` | degrade | snapshot (current output) |
|
||||
|
||||
A detailed 3-way behavior comparison (ours vs Pandoc vs Hugo) for the diverging
|
||||
fixtures is in `COMPARISON.md`, kept for Option-B review.
|
||||
|
||||
03/04/05 require block-level content inside `<dd>` (multi-paragraph, nested
|
||||
lists/blockquotes, nested `<dl>`), which the inline preprocessor does not
|
||||
produce. 05 flattens exactly as Pandoc does, so its snapshot equals Pandoc's
|
||||
output. 03/04 emit a `<dl>` for the term and first line, then let the remaining
|
||||
block content fall through as markdown; their `expected/` files snapshot that
|
||||
current degraded behavior as a regression guard, not an endorsed rendering.
|
||||
Revisit via Option B (full CommonMark block semantics) if real content needs it.
|
||||
@@ -1,4 +0,0 @@
|
||||
<dl>
|
||||
<dt>Term</dt>
|
||||
<dd>Definition content</dd>
|
||||
</dl>
|
||||
@@ -1,8 +0,0 @@
|
||||
<dl>
|
||||
<dt>Markdown</dt>
|
||||
<dd>A lightweight markup language</dd>
|
||||
<dd>Created by John Gruber in 2004</dd>
|
||||
<dt>HTML</dt>
|
||||
<dd>A standard markup language used to create web pages</dd>
|
||||
<dd>Used to create web pages</dd>
|
||||
</dl>
|
||||
@@ -1,7 +0,0 @@
|
||||
<dl>
|
||||
<dt>Term</dt>
|
||||
<dd>This is the first line of definition content
|
||||
This is the second line, needs at least one space indentation
|
||||
<p>This is a new paragraph, needs at least one space indentation and a preceding blank line</p>
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -1,16 +0,0 @@
|
||||
<dl>
|
||||
<dt>Markdown Syntax</dt>
|
||||
<dd><strong>Markdown</strong> supports multiple text formatting:
|
||||
<ul>
|
||||
<li><em>Italic</em> and <strong>Bold</strong></li>
|
||||
<li><a href="https://www.markdownlang.com">Link</a></li>
|
||||
<li><code>Inline code</code></li>
|
||||
<li>
|
||||
<blockquote>
|
||||
<p>Block quote</p>
|
||||
</blockquote>
|
||||
</li>
|
||||
<li>Lists and other elements</li>
|
||||
</ul>
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -1,10 +0,0 @@
|
||||
<dl>
|
||||
<dt>Outer Term</dt>
|
||||
<dd>Outer Definition
|
||||
<dl>
|
||||
<dt>Inner Term</dt>
|
||||
<dd>Inner Definition</dd>
|
||||
<dd>Another Inner Definition</dd>
|
||||
</dl>
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -1 +0,0 @@
|
||||
<p>Term: Definition</p>
|
||||
@@ -1,2 +0,0 @@
|
||||
<p>Term
|
||||
: Definition</p>
|
||||
@@ -1,10 +0,0 @@
|
||||
<dl>
|
||||
<dt>API</dt>
|
||||
<dd>An application programming interface, a set of rules that allow different software applications to communicate with each other.</dd>
|
||||
<dt>Framework</dt>
|
||||
<dd>A software library that provides a standardized structure for application development.</dd>
|
||||
<dt>Git</dt>
|
||||
<dd>A distributed version control system used to track changes in a project development process.</dd>
|
||||
<dt>IDE</dt>
|
||||
<dd>An integrated development environment, a software application that includes a code editor and various development tools.</dd>
|
||||
</dl>
|
||||
@@ -1,14 +0,0 @@
|
||||
<dl>
|
||||
<dt>user_id</dt>
|
||||
<dd><strong>Required</strong> - The unique identifier for the user.</dd>
|
||||
<dd>Type: <code>integer</code></dd>
|
||||
<dt>name</dt>
|
||||
<dd><strong>Optional</strong> - The display name for the user.</dd>
|
||||
<dd>Type: <code>string</code></dd>
|
||||
<dd>Default: <code>null</code></dd>
|
||||
<dt>status</dt>
|
||||
<dd><strong>Optional</strong> - The user status.</dd>
|
||||
<dd>Type: <code>string</code></dd>
|
||||
<dd>Allowed values: <code>active</code>, <code>inactive</code>, <code>suspended</code></dd>
|
||||
<dd>Default: <code>active</code></dd>
|
||||
</dl>
|
||||
@@ -1,6 +0,0 @@
|
||||
<dl>
|
||||
<dt>Term</dt>
|
||||
<dd>
|
||||
Definition content
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -1,16 +0,0 @@
|
||||
<dl>
|
||||
<dt>Markdown</dt>
|
||||
<dd>
|
||||
A lightweight markup language
|
||||
</dd>
|
||||
<dd>
|
||||
Created by John Gruber in 2004
|
||||
</dd>
|
||||
<dt>HTML</dt>
|
||||
<dd>
|
||||
A standard markup language used to create web pages
|
||||
</dd>
|
||||
<dd>
|
||||
Used to create web pages
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -1,9 +0,0 @@
|
||||
<dl>
|
||||
<dt>Term</dt>
|
||||
<dd>
|
||||
This is the first line of definition content This is the second line,
|
||||
needs at least one space indentation
|
||||
</dd>
|
||||
</dl>
|
||||
<p>This is a new paragraph, needs at least one space indentation and a
|
||||
preceding blank line</p>
|
||||
@@ -1,9 +0,0 @@
|
||||
<dl>
|
||||
<dt>Markdown Syntax</dt>
|
||||
<dd>
|
||||
<strong>Markdown</strong> supports multiple text formatting: -
|
||||
<em>Italic</em> and <strong>Bold</strong> - <a
|
||||
href="https://www.markdownlang.com">Link</a> - <code>Inline code</code>
|
||||
- > Block quote - Lists and other elements
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -1,13 +0,0 @@
|
||||
<dl>
|
||||
<dt>Outer Term</dt>
|
||||
<dd>
|
||||
Outer Definition
|
||||
</dd>
|
||||
<dt>Inner Term</dt>
|
||||
<dd>
|
||||
Inner Definition
|
||||
</dd>
|
||||
<dd>
|
||||
Another Inner Definition
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -1 +0,0 @@
|
||||
<p>Term: Definition</p>
|
||||
@@ -1,6 +0,0 @@
|
||||
<dl>
|
||||
<dt>Term</dt>
|
||||
<dd>
|
||||
Definition
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -1,22 +0,0 @@
|
||||
<dl>
|
||||
<dt>API</dt>
|
||||
<dd>
|
||||
An application programming interface, a set of rules that allow
|
||||
different software applications to communicate with each other.
|
||||
</dd>
|
||||
<dt>Framework</dt>
|
||||
<dd>
|
||||
A software library that provides a standardized structure for
|
||||
application development.
|
||||
</dd>
|
||||
<dt>Git</dt>
|
||||
<dd>
|
||||
A distributed version control system used to track changes in a project
|
||||
development process.
|
||||
</dd>
|
||||
<dt>IDE</dt>
|
||||
<dd>
|
||||
An integrated development environment, a software application that
|
||||
includes a code editor and various development tools.
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -1,33 +0,0 @@
|
||||
<dl>
|
||||
<dt>user_id</dt>
|
||||
<dd>
|
||||
<strong>Required</strong> - The unique identifier for the user.
|
||||
</dd>
|
||||
<dd>
|
||||
Type: <code>integer</code>
|
||||
</dd>
|
||||
<dt>name</dt>
|
||||
<dd>
|
||||
<strong>Optional</strong> - The display name for the user.
|
||||
</dd>
|
||||
<dd>
|
||||
Type: <code>string</code>
|
||||
</dd>
|
||||
<dd>
|
||||
Default: <code>null</code>
|
||||
</dd>
|
||||
<dt>status</dt>
|
||||
<dd>
|
||||
<strong>Optional</strong> - The user status.
|
||||
</dd>
|
||||
<dd>
|
||||
Type: <code>string</code>
|
||||
</dd>
|
||||
<dd>
|
||||
Allowed values: <code>active</code>, <code>inactive</code>,
|
||||
<code>suspended</code>
|
||||
</dd>
|
||||
<dd>
|
||||
Default: <code>active</code>
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -1,179 +0,0 @@
|
||||
package markdown
|
||||
|
||||
import "core:log"
|
||||
import "core:strings"
|
||||
|
||||
// generate_toc scans rendered HTML for <h1>-<h6> tags with id attributes and
|
||||
// builds a nested <ul> table of contents. Returns "" if no headings with IDs
|
||||
// are found. Must be called after inject_heading_ids.
|
||||
generate_toc :: proc(html: string, allocator := context.allocator) -> string {
|
||||
b: strings.Builder
|
||||
strings.builder_init(&b, allocator)
|
||||
|
||||
current_level := 0
|
||||
min_level := 7
|
||||
pos := 0
|
||||
|
||||
for {
|
||||
idx, level, id, text, next_pos := next_heading(html, pos)
|
||||
if level == 0 {
|
||||
break
|
||||
}
|
||||
pos = next_pos
|
||||
|
||||
if level < min_level {
|
||||
min_level = level
|
||||
}
|
||||
|
||||
if current_level == 0 {
|
||||
current_level = level
|
||||
strings.write_string(&b, "<ul>\n")
|
||||
} else if level > current_level {
|
||||
if level - current_level > 1 {
|
||||
log.warnf(
|
||||
"heading level skips from h%d to h%d (\"%s\"); " +
|
||||
"table of contents inserts empty levels to keep valid nesting",
|
||||
current_level,
|
||||
level,
|
||||
text,
|
||||
)
|
||||
}
|
||||
// The first step down nests inside the current, still-open <li>.
|
||||
// Each further skipped level needs its own <li> to host the deeper
|
||||
// <ul>, since a <ul> may only contain <li> — otherwise a jump of
|
||||
// more than one level emits stacked, parentless <ul> tags.
|
||||
strings.write_string(&b, "<ul>\n")
|
||||
current_level += 1
|
||||
for current_level < level {
|
||||
strings.write_string(&b, "<li>\n<ul>\n")
|
||||
current_level += 1
|
||||
}
|
||||
} else if level < current_level {
|
||||
strings.write_string(&b, "</li>\n")
|
||||
for current_level > level {
|
||||
strings.write_string(&b, "</ul>\n</li>\n")
|
||||
current_level -= 1
|
||||
}
|
||||
} else {
|
||||
strings.write_string(&b, "</li>\n")
|
||||
}
|
||||
|
||||
strings.write_string(&b, `<li><a href="#`)
|
||||
strings.write_string(&b, id)
|
||||
strings.write_string(&b, `">`)
|
||||
strings.write_string(&b, text)
|
||||
strings.write_string(&b, `</a>`)
|
||||
}
|
||||
|
||||
if current_level == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
strings.write_string(&b, "</li>\n")
|
||||
for current_level > min_level {
|
||||
strings.write_string(&b, "</ul>\n</li>\n")
|
||||
current_level -= 1
|
||||
}
|
||||
strings.write_string(&b, "</ul>\n")
|
||||
|
||||
return strings.to_string(b)
|
||||
}
|
||||
|
||||
// next_heading finds the next <hN> tag with an id attribute starting from pos.
|
||||
// Returns level=0 if none found.
|
||||
next_heading :: proc(
|
||||
html: string,
|
||||
start: int,
|
||||
) -> (
|
||||
idx: int,
|
||||
level: int,
|
||||
id: string,
|
||||
text: string,
|
||||
next_pos: int,
|
||||
) {
|
||||
i := start
|
||||
for i + 3 < len(html) {
|
||||
if html[i] == '<' && html[i + 1] == 'h' {
|
||||
d := html[i + 2]
|
||||
if d >= '1' && d <= '6' {
|
||||
level = int(d - '0')
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
if level == 0 {
|
||||
return 0, 0, "", "", len(html)
|
||||
}
|
||||
|
||||
// Find end of opening tag
|
||||
tag_end := strings.index_byte(html[idx:], '>')
|
||||
if tag_end < 0 {
|
||||
return 0, 0, "", "", len(html)
|
||||
}
|
||||
tag_end += idx
|
||||
|
||||
// Find id="..." within the tag
|
||||
tag := html[idx:tag_end + 1]
|
||||
id_pos := strings.index(tag, `id="`)
|
||||
if id_pos < 0 {
|
||||
// No id — skip this heading, continue searching
|
||||
return next_heading(html, tag_end + 1)
|
||||
}
|
||||
|
||||
id_start := idx + id_pos + 4
|
||||
id_end_rel := strings.index_byte(html[id_start:], '"')
|
||||
if id_end_rel < 0 {
|
||||
return 0, 0, "", "", len(html)
|
||||
}
|
||||
id = html[id_start:id_start + id_end_rel]
|
||||
|
||||
// Text between > and </hN>
|
||||
text_start := tag_end + 1
|
||||
close_idx := strings.index(html[text_start:], "</h")
|
||||
if close_idx < 0 {
|
||||
return 0, 0, "", "", len(html)
|
||||
}
|
||||
text_end := text_start + close_idx
|
||||
text = strip_tags(html[text_start:text_end])
|
||||
|
||||
// Find end of closing tag
|
||||
next_pos = text_end + close_tag_len(html, text_end)
|
||||
|
||||
return idx, level, id, text, next_pos
|
||||
}
|
||||
|
||||
// close_tag_len returns the length of the </hN> tag at pos.
|
||||
close_tag_len :: proc(html: string, pos: int) -> int {
|
||||
if pos + 4 > len(html) {
|
||||
return 4
|
||||
}
|
||||
end := strings.index_byte(html[pos:], '>')
|
||||
if end < 0 {
|
||||
return 4
|
||||
}
|
||||
return end + 1
|
||||
}
|
||||
|
||||
// strip_tags removes HTML tags from a string, leaving only text content.
|
||||
strip_tags :: proc(s: string) -> string {
|
||||
b: strings.Builder
|
||||
strings.builder_init(&b, context.temp_allocator)
|
||||
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
if s[i] == '<' {
|
||||
end := strings.index_byte(s[i:], '>')
|
||||
if end >= 0 {
|
||||
i += end + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
strings.write_byte(&b, s[i])
|
||||
i += 1
|
||||
}
|
||||
return strings.to_string(b)
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
#+test
|
||||
package markdown
|
||||
|
||||
import "core:strings"
|
||||
import "core:testing"
|
||||
|
||||
@(test)
|
||||
test_toc_sequential_nesting :: proc(t: ^testing.T) {
|
||||
html := `<h1 id="a">A</h1><h2 id="b">B</h2><h1 id="c">C</h1>`
|
||||
result := generate_toc(html, context.temp_allocator)
|
||||
testing.expect(t, strings.contains(result, `<a href="#a">A</a>`))
|
||||
testing.expect(t, strings.contains(result, `<a href="#b">B</a>`))
|
||||
testing.expect(t, strings.contains(result, `<a href="#c">C</a>`))
|
||||
// A well-formed list never places a <ul> directly inside a <ul>.
|
||||
testing.expect(t, !strings.contains(result, "<ul>\n<ul>"))
|
||||
}
|
||||
|
||||
// Regression: a heading level that jumps by more than one (h1 -> h3) must not
|
||||
// emit stacked, parentless <ul> tags. Each skipped level is wrapped in an <li>
|
||||
// so the list stays valid.
|
||||
@(test)
|
||||
test_toc_level_jump_stays_valid :: proc(t: ^testing.T) {
|
||||
html := `<h1 id="a">A</h1><h3 id="b">B</h3>`
|
||||
result := generate_toc(html, context.temp_allocator)
|
||||
testing.expect(t, !strings.contains(result, "<ul>\n<ul>"))
|
||||
// The intermediate level is hosted by an <li>.
|
||||
testing.expect(t, strings.contains(result, "<li>\n<ul>"))
|
||||
testing.expect(t, strings.contains(result, `<a href="#b">B</a>`))
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_toc_empty_when_no_headings :: proc(t: ^testing.T) {
|
||||
result := generate_toc("<p>no headings here</p>", context.temp_allocator)
|
||||
testing.expect_value(t, result, "")
|
||||
}
|
||||
+28
-26
@@ -4,6 +4,7 @@ import "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:mem"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
|
||||
DEFAULT_WEIGHT :: 10
|
||||
@@ -111,32 +112,42 @@ parse_page_menus :: proc(
|
||||
return result
|
||||
}
|
||||
|
||||
// build_menus populates site.menus by merging every source into one menu set:
|
||||
// 1. Auto-menus (sections + root-level pages)
|
||||
// 2. Config menus (thor.json "menus" key), already parsed into site.menus
|
||||
// 3. Page frontmatter menus
|
||||
// All three are combined per menu name and sorted by weight, then name.
|
||||
// build_menus populates site.menus:
|
||||
// 1. Config menus (thor.json "menus" key present) — exclusive, preserves array order
|
||||
// 2. Auto-menus (sections + root-level pages) + page frontmatter menus — merged, sorted
|
||||
//
|
||||
// If "menus" is present but empty ({}) it means explicit opt-out: no menus at
|
||||
// all, and page frontmatter menus are ignored.
|
||||
// If "menus" is present but empty ({}) it means explicit opt-out: no menus.
|
||||
// Config menus cannot be mixed with page frontmatter menus (error).
|
||||
build_menus :: proc(site: ^Site) {
|
||||
// Explicit opt-out: "menus": {} present but empty → no menus; page
|
||||
// frontmatter menus are ignored (with a warning).
|
||||
// TODO(TODOS.md): silently discarding frontmatter here is harsh; revisit.
|
||||
if site.menus != nil && len(site.menus) == 0 {
|
||||
if site.menus != nil {
|
||||
has_menus := false
|
||||
for page in site.pages {
|
||||
if len(page.menus) > 0 {
|
||||
log.warnf(
|
||||
"menus: config has empty menus but pages have frontmatter menu entries; ignoring page menus",
|
||||
)
|
||||
has_menus = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Already populated from config in site_apply_config
|
||||
if len(site.menus) == 0 {
|
||||
// Explicit opt-out ("menus": {})
|
||||
if has_menus {
|
||||
log.warnf(
|
||||
"menus: config has empty menus but pages have frontmatter menu entries; ignoring page menus",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Config menus active
|
||||
if has_menus {
|
||||
log.fatalf("menus: cannot mix config menus with frontmatter menus")
|
||||
os.exit(1)
|
||||
}
|
||||
warn_all_duplicate_weights(site)
|
||||
return
|
||||
}
|
||||
|
||||
// Merge all sources: auto section/root menus, config menus (already in
|
||||
// site.menus if present), then page frontmatter menus.
|
||||
// No config menus — auto-generate, then merge page menus on top
|
||||
collect_auto_menus(site)
|
||||
merge_page_menus(site)
|
||||
warn_all_duplicate_weights(site)
|
||||
@@ -242,17 +253,8 @@ collect_auto_menus :: proc(site: ^Site) {
|
||||
return
|
||||
}
|
||||
|
||||
if site.menus == nil {
|
||||
site.menus = make(map[string][]Menu_Entry, alloc)
|
||||
}
|
||||
// Merge into any existing "main" (e.g. config menus) rather than clobbering.
|
||||
if existing, ok := site.menus["main"]; ok {
|
||||
merged := make([dynamic]Menu_Entry, 0, len(existing) + len(entries), alloc)
|
||||
append(&merged, ..existing)
|
||||
append(&merged, ..entries[:])
|
||||
entries = merged
|
||||
}
|
||||
sort_menu_entries(entries[:])
|
||||
site.menus = make(map[string][]Menu_Entry, alloc)
|
||||
site.menus["main"] = entries[:]
|
||||
}
|
||||
|
||||
|
||||
+5
-65
@@ -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)
|
||||
@@ -421,71 +426,6 @@ test_auto_menus_no_duplicate_with_frontmatter :: proc(t: ^testing.T) {
|
||||
testing.expect_value(t, main[0].name, "Ideas")
|
||||
}
|
||||
|
||||
// --- config + frontmatter + auto merge tests ---
|
||||
|
||||
@(test)
|
||||
test_build_menus_config_merges_frontmatter :: proc(t: ^testing.T) {
|
||||
site: Site
|
||||
mem.dynamic_arena_init(&site.arena)
|
||||
defer mem.dynamic_arena_destroy(&site.arena)
|
||||
context.allocator = site_allocator(&site)
|
||||
|
||||
// Simulate config menus as site_apply_config would populate them.
|
||||
site.menus = make(map[string][]Menu_Entry, site_allocator(&site))
|
||||
cfg := make([dynamic]Menu_Entry, 0, 1, site_allocator(&site))
|
||||
append(&cfg, Menu_Entry{name = "GitHub", url = "https://example.com", weight = 20})
|
||||
site.menus["main"] = cfg[:]
|
||||
|
||||
// A root-level page opting into "main" via frontmatter.
|
||||
page := make_page("Ideas", "/ideas/")
|
||||
page.menus = parse_page_menus(parse_raw(`"main"`), page, site_allocator(&site))
|
||||
|
||||
site.pages = make(#soa[dynamic]Page, 0, 1, site_allocator(&site))
|
||||
append(&site.pages, page)
|
||||
|
||||
build_menus(&site)
|
||||
|
||||
main, ok := site.menus["main"]
|
||||
testing.expect(t, ok)
|
||||
// GitHub (config, weight 20) + Ideas (frontmatter, nil → DEFAULT_WEIGHT 10).
|
||||
testing.expect(t, len(main) == 2, "expected config + frontmatter merged")
|
||||
testing.expect_value(t, main[0].name, "Ideas")
|
||||
testing.expect_value(t, main[1].name, "GitHub")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_build_menus_config_merges_auto :: proc(t: ^testing.T) {
|
||||
site: Site
|
||||
mem.dynamic_arena_init(&site.arena)
|
||||
defer mem.dynamic_arena_destroy(&site.arena)
|
||||
context.allocator = site_allocator(&site)
|
||||
|
||||
// Config menu in a different bucket ("footer"); auto-gen owns "main".
|
||||
site.menus = make(map[string][]Menu_Entry, site_allocator(&site))
|
||||
cfg := make([dynamic]Menu_Entry, 0, 1, site_allocator(&site))
|
||||
append(&cfg, Menu_Entry{name = "GitHub", url = "https://example.com", weight = 20})
|
||||
site.menus["footer"] = cfg[:]
|
||||
|
||||
// A section (non-index) page triggers an auto "main" section entry.
|
||||
page := make_page("Post", "/blog/post/")
|
||||
page.section = "blog"
|
||||
|
||||
site.pages = make(#soa[dynamic]Page, 0, 1, site_allocator(&site))
|
||||
append(&site.pages, page)
|
||||
|
||||
build_menus(&site)
|
||||
|
||||
footer, ok_f := site.menus["footer"]
|
||||
testing.expect(t, ok_f, "config footer menu should be preserved")
|
||||
testing.expect(t, len(footer) == 1)
|
||||
testing.expect_value(t, footer[0].name, "GitHub")
|
||||
|
||||
main, ok_m := site.menus["main"]
|
||||
testing.expect(t, ok_m, "auto-generated main menu should exist alongside config")
|
||||
testing.expect(t, len(main) == 1)
|
||||
testing.expect_value(t, main[0].name, "Blog")
|
||||
}
|
||||
|
||||
// --- warn_duplicate_weights tests ---
|
||||
|
||||
@(test)
|
||||
|
||||
+21
-97
@@ -12,31 +12,18 @@ Range :: struct {
|
||||
end: u32,
|
||||
}
|
||||
|
||||
// minify_html collapses insignificant whitespace, strips comments, and minifies
|
||||
// inline <style> bodies (via minify_css). The caller owns html_parser and
|
||||
// css_parser and passes them in; both may be reused across calls. On success it
|
||||
// returns (owned string, true) and the caller must free the string. On failure
|
||||
// (nil html_parser, parse failure, or syntax errors) it returns ("", false) and
|
||||
// the caller should keep the original source unchanged.
|
||||
minify_html :: proc(
|
||||
html_parser: ts.Parser,
|
||||
css_parser: ts.Parser,
|
||||
source: string,
|
||||
) -> (
|
||||
minified: string,
|
||||
ok: bool,
|
||||
) {
|
||||
if html_parser == nil {
|
||||
return minified, ok
|
||||
minify_html :: proc(source: string) -> string {
|
||||
gc := ts.ensure_parser("html")
|
||||
if gc == nil {
|
||||
return source
|
||||
}
|
||||
|
||||
source_c := strings.clone_to_cstring(source)
|
||||
defer delete(source_c)
|
||||
|
||||
tree := ts.parser_parse_string(html_parser, nil, source_c, u32(len(source)))
|
||||
tree := ts.parser_parse_string(gc.parser, nil, source_c, u32(len(source)))
|
||||
if tree == nil {
|
||||
ok = false
|
||||
return minified, ok
|
||||
return source
|
||||
}
|
||||
defer ts.tree_delete(tree)
|
||||
|
||||
@@ -44,24 +31,20 @@ minify_html :: proc(
|
||||
|
||||
if ts.node_has_error(root) {
|
||||
log.warnf("minify: HTML parse errors, skipping minification")
|
||||
ok = false
|
||||
return minified, ok
|
||||
return source
|
||||
}
|
||||
|
||||
comments: [dynamic]Range
|
||||
defer delete(comments)
|
||||
preserves: [dynamic]Range
|
||||
defer delete(preserves)
|
||||
styles: [dynamic]Range
|
||||
defer delete(styles)
|
||||
|
||||
collect_html_ranges(root, source, &comments, &preserves, &styles)
|
||||
collect_html_ranges(root, source, &comments, &preserves)
|
||||
|
||||
sb := strings.builder_make()
|
||||
|
||||
ci := 0
|
||||
pi := 0
|
||||
si := 0
|
||||
i := 0
|
||||
last_written: u8 = 0
|
||||
|
||||
@@ -78,27 +61,6 @@ minify_html :: proc(
|
||||
continue
|
||||
}
|
||||
|
||||
if si < len(styles) && u32(i) >= styles[si].start {
|
||||
s := styles[si]
|
||||
seg := source[i:s.end]
|
||||
if minified, mok := minify_css(css_parser, seg); mok {
|
||||
strings.write_string(&sb, minified)
|
||||
if len(minified) > 0 {
|
||||
last_written = minified[len(minified) - 1]
|
||||
}
|
||||
delete(minified)
|
||||
} else {
|
||||
// CSS minify skipped/failed: keep the style body verbatim.
|
||||
strings.write_string(&sb, seg)
|
||||
if len(seg) > 0 {
|
||||
last_written = seg[len(seg) - 1]
|
||||
}
|
||||
}
|
||||
i = int(s.end)
|
||||
si += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if ci < len(comments) && u32(i) >= comments[ci].start {
|
||||
i = int(comments[ci].end)
|
||||
ci += 1
|
||||
@@ -131,10 +93,7 @@ minify_html :: proc(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
minified, ok = strings.to_string(sb), true
|
||||
|
||||
return minified, ok
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
collect_html_ranges :: proc(
|
||||
@@ -142,7 +101,6 @@ collect_html_ranges :: proc(
|
||||
source: string,
|
||||
comments: ^[dynamic]Range,
|
||||
preserves: ^[dynamic]Range,
|
||||
styles: ^[dynamic]Range,
|
||||
) {
|
||||
child_count := ts.node_named_child_count(node)
|
||||
for i in 0 ..< child_count {
|
||||
@@ -154,14 +112,7 @@ collect_html_ranges :: proc(
|
||||
comments,
|
||||
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
|
||||
)
|
||||
} else if type_str == "style_element" {
|
||||
// Preserve the <style> tags but minify the CSS body (the raw_text
|
||||
// child) via the CSS-aware pass.
|
||||
raw, ok := style_raw_text(child)
|
||||
if ok {
|
||||
append(styles, Range{start = ts.node_start_byte(raw), end = ts.node_end_byte(raw)})
|
||||
}
|
||||
} else if type_str == "script_element" {
|
||||
} else if type_str == "script_element" || type_str == "style_element" {
|
||||
append(
|
||||
preserves,
|
||||
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
|
||||
@@ -174,27 +125,14 @@ collect_html_ranges :: proc(
|
||||
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
|
||||
)
|
||||
} else {
|
||||
collect_html_ranges(child, source, comments, preserves, styles)
|
||||
collect_html_ranges(child, source, comments, preserves)
|
||||
}
|
||||
} else {
|
||||
collect_html_ranges(child, source, comments, preserves, styles)
|
||||
collect_html_ranges(child, source, comments, preserves)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// style_raw_text returns the raw_text (CSS body) child of a style_element.
|
||||
// ok is false for an empty <style></style>, which has no raw_text child.
|
||||
style_raw_text :: proc(style_element: ts.Node) -> (raw: ts.Node, ok: bool) {
|
||||
child_count := ts.node_named_child_count(style_element)
|
||||
for i in 0 ..< child_count {
|
||||
child := ts.node_named_child(style_element, u32(i))
|
||||
if string(ts.node_type(child)) == "raw_text" {
|
||||
return child, true
|
||||
}
|
||||
}
|
||||
return {}, false
|
||||
}
|
||||
|
||||
html_tag_name :: proc(element: ts.Node, source: string) -> string {
|
||||
child_count := ts.node_named_child_count(element)
|
||||
for i in 0 ..< child_count {
|
||||
@@ -230,23 +168,18 @@ is_css_delim :: proc(c: u8) -> bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// minify_css collapses insignificant whitespace and strips comments. The caller
|
||||
// owns `parser` and passes it in; it may be reused across calls. On success it
|
||||
// returns (owned string, true) and the caller must free the string. On failure
|
||||
// (nil parser, parse failure, or syntax errors) it returns ("", false) and the
|
||||
// caller should keep the original source unchanged.
|
||||
minify_css :: proc(parser: ts.Parser, source: string) -> (minified: string, ok: bool) {
|
||||
if parser == nil {
|
||||
return minified, ok
|
||||
minify_css :: proc(source: string) -> string {
|
||||
gc := ts.ensure_parser("css")
|
||||
if gc == nil {
|
||||
return source
|
||||
}
|
||||
|
||||
source_c := strings.clone_to_cstring(source)
|
||||
defer delete(source_c)
|
||||
|
||||
tree := ts.parser_parse_string(parser, nil, source_c, u32(len(source)))
|
||||
tree := ts.parser_parse_string(gc.parser, nil, source_c, u32(len(source)))
|
||||
if tree == nil {
|
||||
ok = false
|
||||
return minified, ok
|
||||
return source
|
||||
}
|
||||
defer ts.tree_delete(tree)
|
||||
|
||||
@@ -254,8 +187,7 @@ minify_css :: proc(parser: ts.Parser, source: string) -> (minified: string, ok:
|
||||
|
||||
if ts.node_has_error(root) {
|
||||
log.warnf("minify: CSS parse errors, skipping minification")
|
||||
ok = false
|
||||
return minified, ok
|
||||
return source
|
||||
}
|
||||
|
||||
comments: [dynamic]Range
|
||||
@@ -292,14 +224,7 @@ minify_css :: proc(parser: ts.Parser, source: string) -> (minified: string, ok:
|
||||
next = source[j]
|
||||
}
|
||||
|
||||
// Emit a single separating space, but never a leading space
|
||||
// (last_written == 0), a doubled space (last_written == ' ',
|
||||
// which happens when a stripped comment sits between two
|
||||
// whitespace runs), or one adjacent to a delimiter.
|
||||
if last_written != 0 &&
|
||||
last_written != ' ' &&
|
||||
!is_css_delim(last_written) &&
|
||||
!is_css_delim(next) {
|
||||
if !is_css_delim(last_written) && !is_css_delim(next) {
|
||||
strings.write_byte(&sb, ' ')
|
||||
last_written = ' '
|
||||
}
|
||||
@@ -311,8 +236,7 @@ minify_css :: proc(parser: ts.Parser, source: string) -> (minified: string, ok:
|
||||
}
|
||||
}
|
||||
|
||||
minified, ok = strings.to_string(sb), true
|
||||
return minified, ok
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
collect_css_comments :: proc(node: ts.Node, comments: ^[dynamic]Range) {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
#+test
|
||||
package main
|
||||
|
||||
import ts "treesitter"
|
||||
|
||||
import "core:strings"
|
||||
import "core:testing"
|
||||
|
||||
// The treesitter registry is thread-safe and self-initializing (grammar() lazily
|
||||
// binds it to the heap), and parsers are caller-owned. Each test opens its own
|
||||
// parser(s) via ts.open_parser, passes them to minify_*, and frees the returned
|
||||
// string — so the multi-threaded test runner needs no shared state or locking.
|
||||
|
||||
@(private = "file")
|
||||
open_parsers :: proc(t: ^testing.T) -> (html_parser, css_parser: ts.Parser) {
|
||||
hg, hok := ts.grammar("html")
|
||||
cg, cok := ts.grammar("css")
|
||||
testing.expect(t, hok, "html grammar should load")
|
||||
testing.expect(t, cok, "css grammar should load")
|
||||
return ts.open_parser(hg), ts.open_parser(cg)
|
||||
}
|
||||
|
||||
@(private = "file")
|
||||
expect_css :: proc(t: ^testing.T, parser: ts.Parser, input, want: string) {
|
||||
got, ok := minify_css(parser, input)
|
||||
testing.expect(t, ok)
|
||||
defer delete(got)
|
||||
testing.expect_value(t, got, want)
|
||||
}
|
||||
|
||||
// --- minify_html: inline <style> bodies get CSS-minified ---
|
||||
|
||||
@(test)
|
||||
test_minify_html_inline_style :: proc(t: ^testing.T) {
|
||||
hp, cp := open_parsers(t)
|
||||
defer ts.parser_delete(hp)
|
||||
defer ts.parser_delete(cp)
|
||||
|
||||
input := `<html><head><style>
|
||||
body {
|
||||
color: red;
|
||||
}
|
||||
</style></head><body><p>hi</p></body></html>`
|
||||
result, ok := minify_html(hp, cp, input)
|
||||
testing.expect(t, ok)
|
||||
defer delete(result)
|
||||
|
||||
// The CSS body is collapsed by minify_css (delimiters lose surrounding
|
||||
// whitespace)...
|
||||
testing.expect(t, strings.contains(result, "body{color:red;}"))
|
||||
// ...while the <style> tags themselves survive.
|
||||
testing.expect(t, strings.contains(result, "<style>"))
|
||||
testing.expect(t, strings.contains(result, "</style>"))
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_minify_html_empty_style :: proc(t: ^testing.T) {
|
||||
hp, cp := open_parsers(t)
|
||||
defer ts.parser_delete(hp)
|
||||
defer ts.parser_delete(cp)
|
||||
|
||||
// An empty <style></style> has no raw_text child; must not crash.
|
||||
input := `<html><head><style></style></head><body></body></html>`
|
||||
result, ok := minify_html(hp, cp, input)
|
||||
testing.expect(t, ok)
|
||||
defer delete(result)
|
||||
testing.expect(t, strings.contains(result, "<style></style>"))
|
||||
}
|
||||
|
||||
// --- minify_css: whitespace around stripped comments / leading edge ---
|
||||
|
||||
@(test)
|
||||
test_minify_css_leading_and_comment :: proc(t: ^testing.T) {
|
||||
cg, cok := ts.grammar("css")
|
||||
testing.expect(t, cok, "css grammar should load")
|
||||
cp := ts.open_parser(cg)
|
||||
defer ts.parser_delete(cp)
|
||||
|
||||
// Leading whitespace and a comment between two whitespace runs must not
|
||||
// leave a leading space or a doubled space.
|
||||
expect_css(t, cp, "\n/* c */\na {}", "a{}")
|
||||
expect_css(t, cp, " a {}", "a{}")
|
||||
expect_css(t, cp, "a {}\n/* trailing */", "a{}")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_minify_html_script_preserved :: proc(t: ^testing.T) {
|
||||
hp, cp := open_parsers(t)
|
||||
defer ts.parser_delete(hp)
|
||||
defer ts.parser_delete(cp)
|
||||
|
||||
// <script> stays byte-for-byte (whitespace-significant JS).
|
||||
input := "<html><body><script>\nconst s = \"a b\";\n</script></body></html>"
|
||||
result, ok := minify_html(hp, cp, input)
|
||||
testing.expect(t, ok)
|
||||
defer delete(result)
|
||||
testing.expect(t, strings.contains(result, "const s = \"a b\";"))
|
||||
}
|
||||
@@ -4,4 +4,3 @@
|
||||
- [ ] Simplify `trim_standalone_whitespace` — four temp arrays (`li_buf`, `ri_buf`, `left_done`, `right_done`) could potentially be reduced to two by merging the done-tracking into the index buffers using sentinel values (e.g. `-2` = already trimmed).
|
||||
- [ ] Adjacent standalone tags don't capture indent for the second tag (e.g. ` {{>a}}\n {{>b}}` — `{{>b}}` gets `indent=""` because its left text was consumed by `{{>a}}`'s right-trim). Blocks recover via `deindent_blocks`, but partials/parents have no fallback.
|
||||
- [ ] lazy load partials ? (with mutex)
|
||||
- [ ] Support changing delimiters (Set Delimiter tag `{{=<% %>=}}`). Delimiters `{{`/`}}`/`{{{`/`}}}` are hardcoded in [tokenizer.odin](./tokenizer.odin); needs an `=` sigil case, mutable open/close delimiters threaded through `tokenize`, and wiring up `spec/specs/delimiters.json` in `spec_test.odin` (fixture present but not run).
|
||||
|
||||
+26
-17
@@ -137,22 +137,6 @@ resolve_name :: proc(name: string, ctx: []any) -> any {
|
||||
return result
|
||||
}
|
||||
|
||||
collect_map_keys :: proc(container: any, allocator := context.temp_allocator) -> []string {
|
||||
val, info := base_value(container)
|
||||
if info == nil do return nil
|
||||
if _, ok := info.variant.(runtime.Type_Info_Map); !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([dynamic]string, 0, 4, allocator)
|
||||
it := 0
|
||||
for {
|
||||
key, _ := reflect.iterate_map(val, &it) or_break
|
||||
key_str := key.(string) or_continue
|
||||
append(&out, key_str)
|
||||
}
|
||||
return out[:]
|
||||
}
|
||||
|
||||
// is_truthy checks mustache truthiness.
|
||||
is_truthy :: proc(a: any) -> bool {
|
||||
if a == nil {
|
||||
@@ -175,6 +159,32 @@ is_truthy :: proc(a: any) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
call_interp_lambda :: proc(val: any) -> (result: string, ok: bool) {
|
||||
switch v in val {
|
||||
case proc() -> string:
|
||||
return v(), true
|
||||
case proc() -> int:
|
||||
return fmt.tprintf("%d", v()), true
|
||||
case proc() -> bool:
|
||||
return "true" if v() else "false", true
|
||||
case:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
call_section_lambda :: proc(val: any, text: string) -> (result: string, ok: bool) {
|
||||
switch v in val {
|
||||
case proc(_: string) -> string:
|
||||
return v(text), true
|
||||
case proc(_: string) -> int:
|
||||
return fmt.tprintf("%d", v(text)), true
|
||||
case proc(_: string) -> bool:
|
||||
return "true" if v(text) else "false", true
|
||||
case:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// list_info returns element type info, count, and data pointer for a list value.
|
||||
// Returns elem_info=nil if the value is not a list.
|
||||
list_info :: proc(a: any) -> (elem_info: ^runtime.Type_Info, count: int, data: rawptr) {
|
||||
@@ -292,4 +302,3 @@ write_value :: proc(b: ^strings.Builder, a: any, escape: bool) {
|
||||
strings.write_string(b, s[start:])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
strings.write_string(&sb, fmt.tprintf("%s:%d:%d\n", path, line, col))
|
||||
|
||||
// Top gutter line.
|
||||
write_gutter(&sb, width, faint, reset)
|
||||
|
||||
// Caret extent for the error line.
|
||||
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>
|
||||
+12
-12
@@ -129,12 +129,12 @@ match_token :: proc(b: ^strings.Builder, dt: Date_Components, s: string) -> int
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"January",
|
||||
) {fmt.sbprintf(b, "%s", time.Month(dt.month)); return 7}
|
||||
) {strings.write_string(b, fmt.tprintf("%s", time.Month(dt.month))); return 7}
|
||||
if strings.has_prefix(s, "Monday") {emit_weekday(b, dt, full = true); return 6}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"2006",
|
||||
) {fmt.sbprintf(b, "%04d", dt.year); return 4}
|
||||
) {strings.write_string(b, fmt.tprintf("%04d", dt.year)); return 4}
|
||||
if strings.has_prefix(s, "MST") {
|
||||
abbr := dt.tz_abbr
|
||||
if len(abbr) == 0 do abbr = "UTC"
|
||||
@@ -146,37 +146,37 @@ match_token :: proc(b: ^strings.Builder, dt: Date_Components, s: string) -> int
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"06",
|
||||
) {fmt.sbprintf(b, "%02d", dt.year % 100); return 2}
|
||||
if strings.has_prefix(s, "02") {fmt.sbprintf(b, "%02d", dt.day); return 2}
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.year % 100)); return 2}
|
||||
if strings.has_prefix(s, "02") {strings.write_string(b, fmt.tprintf("%02d", dt.day)); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"15",
|
||||
) {fmt.sbprintf(b, "%02d", dt.hour); return 2}
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.hour)); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"04",
|
||||
) {fmt.sbprintf(b, "%02d", dt.minute); return 2}
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.minute)); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"05",
|
||||
) {fmt.sbprintf(b, "%02d", dt.second); return 2}
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.second)); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"01",
|
||||
) {fmt.sbprintf(b, "%02d", dt.month); return 2}
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.month)); return 2}
|
||||
if strings.has_prefix(s, "03") {emit_hour_12(b, dt, pad = true); return 2}
|
||||
if strings.has_prefix(s, "PM") {emit_am_pm(b, dt); return 2}
|
||||
if strings.has_prefix(s, "pm") {emit_am_pm_lower(b, dt); return 2}
|
||||
if len(s) >= 1 {
|
||||
switch s[0] {
|
||||
case '2':
|
||||
fmt.sbprintf(b, "%d", dt.day); return 1
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.day)); return 1
|
||||
case '1':
|
||||
fmt.sbprintf(b, "%d", dt.month); return 1
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.month)); return 1
|
||||
case '4':
|
||||
fmt.sbprintf(b, "%d", dt.minute); return 1
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.minute)); return 1
|
||||
case '5':
|
||||
fmt.sbprintf(b, "%d", dt.second); return 1
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.second)); return 1
|
||||
case '3':
|
||||
emit_hour_12(b, dt, pad = false); return 1
|
||||
case:
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
#+test
|
||||
package mustache
|
||||
|
||||
import "core:fmt"
|
||||
import "core:testing"
|
||||
|
||||
/*
|
||||
|
||||
// --- Spec test 1: Interpolation ---
|
||||
// A lambda's return value should be interpolated.
|
||||
|
||||
Interp_Data :: struct {
|
||||
lambda: proc() -> string,
|
||||
planet: string,
|
||||
}
|
||||
|
||||
Interp_Data_Int :: struct {
|
||||
lambda: proc() -> int,
|
||||
planet: string,
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_lambda_interpolation :: proc(t: ^testing.T) {
|
||||
data := Interp_Data {
|
||||
lambda = proc() -> string {return "world"},
|
||||
}
|
||||
tpl, _ := parse("Hello, {{lambda}}!", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "Hello, world!")
|
||||
}
|
||||
|
||||
// --- Spec test 2: Interpolation - Expansion ---
|
||||
// A lambda's return value should be parsed.
|
||||
|
||||
@(test)
|
||||
test_lambda_interpolation_expansion :: proc(t: ^testing.T) {
|
||||
data := Interp_Data {
|
||||
lambda = proc() -> string {return "{{planet}}"},
|
||||
planet = "world",
|
||||
}
|
||||
tpl, _ := parse("Hello, {{lambda}}!", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "Hello, world!")
|
||||
}
|
||||
|
||||
// --- Spec test 4: Interpolation - Multiple Calls ---
|
||||
// Interpolated lambdas should not be cached.
|
||||
|
||||
counter_lambda :: proc() -> int {
|
||||
@(static) call_count := 0
|
||||
call_count += 1
|
||||
return call_count
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_lambda_interpolation_multiple_calls :: proc(t: ^testing.T) {
|
||||
data := Interp_Data_Int {
|
||||
lambda = counter_lambda,
|
||||
}
|
||||
tpl, _ := parse("{{lambda}} == {{{lambda}}} == {{lambda}}", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "1 == 2 == 3")
|
||||
}
|
||||
|
||||
// --- Spec test 5: Escaping ---
|
||||
// Lambda results should be appropriately escaped.
|
||||
|
||||
@(test)
|
||||
test_lambda_escaping :: proc(t: ^testing.T) {
|
||||
data := Interp_Data {
|
||||
lambda = proc() -> string {return ">"},
|
||||
}
|
||||
tpl, _ := parse("<{{lambda}}{{{lambda}}}", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "<>>")
|
||||
}
|
||||
|
||||
// --- Spec test 6: Section ---
|
||||
// Lambdas used for sections should receive the raw section string.
|
||||
|
||||
Section_Data :: struct {
|
||||
lambda: proc(_: string) -> string,
|
||||
x: string,
|
||||
planet: string,
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_lambda_section :: proc(t: ^testing.T) {
|
||||
data := Section_Data {
|
||||
lambda = proc(text: string) -> string {
|
||||
if text == "{{x}}" {return "yes"} else {return "no"}
|
||||
},
|
||||
x = "Error!",
|
||||
}
|
||||
tpl, _ := parse("<{{#lambda}}{{x}}{{/lambda}}>", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "<yes>")
|
||||
}
|
||||
|
||||
// --- Spec test 7: Section - Expansion ---
|
||||
// Lambdas used for sections should have their results parsed.
|
||||
|
||||
@(test)
|
||||
test_lambda_section_expansion :: proc(t: ^testing.T) {
|
||||
data := Section_Data {
|
||||
lambda = proc(text: string) -> string {
|
||||
return fmt.tprintf("%s{{{{planet}}}}%s", text, text)
|
||||
},
|
||||
planet = "Earth",
|
||||
}
|
||||
tpl, _ := parse("<{{#lambda}}-{{/lambda}}>", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "<-Earth->")
|
||||
}
|
||||
|
||||
// --- Spec test 9: Section - Multiple Calls ---
|
||||
// Lambdas used for sections should not be cached.
|
||||
|
||||
@(test)
|
||||
test_lambda_section_multiple_calls :: proc(t: ^testing.T) {
|
||||
data := Section_Data {
|
||||
lambda = proc(text: string) -> string {
|
||||
return fmt.tprintf("__%s__", text)
|
||||
},
|
||||
}
|
||||
tpl, _ := parse(
|
||||
"{{#lambda}}FILE{{/lambda}} != {{#lambda}}LINE{{/lambda}}",
|
||||
"<test>",
|
||||
context.temp_allocator,
|
||||
)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "__FILE__ != __LINE__")
|
||||
}
|
||||
|
||||
// --- Spec test 10: Inverted Section ---
|
||||
// Lambdas used for inverted sections should be considered truthy.
|
||||
|
||||
Inverted_Data :: struct {
|
||||
lambda: proc(_: string) -> bool,
|
||||
static: string,
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_lambda_inverted_section :: proc(t: ^testing.T) {
|
||||
data := Inverted_Data {
|
||||
lambda = proc(text: string) -> bool {return false},
|
||||
static = "static",
|
||||
}
|
||||
tpl, _ := parse("<{{^lambda}}{{static}}{{/lambda}}>", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "<>")
|
||||
}
|
||||
|
||||
*/
|
||||
+98
-162
@@ -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 {
|
||||
@@ -60,13 +57,13 @@ tag_error :: proc(err: Error, tmpl: Template) -> Error {
|
||||
if err == nil do return nil
|
||||
b := body(err)
|
||||
return Error_Body {
|
||||
msg = b.msg,
|
||||
pos = b.pos,
|
||||
span = b.span,
|
||||
hint = b.hint,
|
||||
kind = b.kind,
|
||||
msg = b.msg,
|
||||
pos = b.pos,
|
||||
span = b.span,
|
||||
hint = b.hint,
|
||||
kind = b.kind,
|
||||
source = tmpl.source,
|
||||
path = tmpl.path,
|
||||
path = tmpl.path,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 +174,6 @@ render :: proc(
|
||||
data: any,
|
||||
partials: map[string]Template = nil,
|
||||
allocator := context.allocator,
|
||||
warnings: ^[dynamic]Error = nil,
|
||||
tools: ^Tool_Registry = nil,
|
||||
) -> (
|
||||
result: string,
|
||||
err: Error,
|
||||
@@ -208,15 +199,7 @@ render :: proc(
|
||||
}
|
||||
|
||||
all_nodes := tmpl.nodes[:]
|
||||
err = render_nodes(
|
||||
tmpl,
|
||||
all_nodes,
|
||||
&ctx,
|
||||
partials,
|
||||
&builder,
|
||||
warnings = warnings,
|
||||
tools = tools,
|
||||
)
|
||||
err = render_nodes(tmpl, all_nodes, &ctx, partials, &builder)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
@@ -280,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),
|
||||
@@ -307,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),
|
||||
@@ -342,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),
|
||||
@@ -368,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),
|
||||
@@ -596,8 +543,6 @@ render_template :: proc(
|
||||
b: ^strings.Builder,
|
||||
blocks: map[string]Block_Override,
|
||||
indent: string,
|
||||
warnings: ^[dynamic]Error = nil,
|
||||
tools: ^Tool_Registry = nil,
|
||||
) -> Error {
|
||||
if len(indent) > 0 {
|
||||
state := Indent_State {
|
||||
@@ -605,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, tools)
|
||||
return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, &state)
|
||||
}
|
||||
return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, nil, warnings, tools)
|
||||
return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, nil)
|
||||
}
|
||||
|
||||
write_indented :: proc(
|
||||
@@ -651,8 +596,6 @@ render_nodes :: proc(
|
||||
b: ^strings.Builder,
|
||||
blocks: map[string]Block_Override = nil,
|
||||
indent_state: ^Indent_State = nil,
|
||||
warnings: ^[dynamic]Error = nil,
|
||||
tools: ^Tool_Registry = nil,
|
||||
) -> Error {
|
||||
i := 0
|
||||
for i < len(nodes) {
|
||||
@@ -676,21 +619,36 @@ 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,
|
||||
tools,
|
||||
)
|
||||
transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:])
|
||||
if perr != nil {
|
||||
return tag_error(perr, current)
|
||||
}
|
||||
val = transformed
|
||||
}
|
||||
write_value(b, val, escape = true)
|
||||
if result_str, ok := call_interp_lambda(val); ok {
|
||||
sub_tpl, perr := parse(
|
||||
result_str,
|
||||
fmt.tprintf("<lambda output from '%s'>", node.key),
|
||||
context.temp_allocator,
|
||||
context.temp_allocator,
|
||||
)
|
||||
if perr == nil {
|
||||
temp: strings.Builder
|
||||
strings.builder_init(&temp, context.temp_allocator)
|
||||
render_nodes(
|
||||
sub_tpl,
|
||||
sub_tpl.nodes[:],
|
||||
ctx,
|
||||
partials,
|
||||
&temp,
|
||||
blocks,
|
||||
nil,
|
||||
) or_return
|
||||
write_value(b, strings.to_string(temp), escape = true)
|
||||
}
|
||||
} else {
|
||||
write_value(b, val, escape = true)
|
||||
}
|
||||
i += 1
|
||||
|
||||
case .Unescaped:
|
||||
@@ -703,21 +661,36 @@ 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,
|
||||
tools,
|
||||
)
|
||||
transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:])
|
||||
if perr != nil {
|
||||
return tag_error(perr, current)
|
||||
}
|
||||
val = transformed
|
||||
}
|
||||
write_value(b, val, escape = false)
|
||||
if result_str, ok := call_interp_lambda(val); ok {
|
||||
sub_tpl, perr := parse(
|
||||
result_str,
|
||||
fmt.tprintf("<lambda output from '%s'>", node.key),
|
||||
context.temp_allocator,
|
||||
context.temp_allocator,
|
||||
)
|
||||
if perr == nil {
|
||||
temp: strings.Builder
|
||||
strings.builder_init(&temp, context.temp_allocator)
|
||||
render_nodes(
|
||||
sub_tpl,
|
||||
sub_tpl.nodes[:],
|
||||
ctx,
|
||||
partials,
|
||||
&temp,
|
||||
blocks,
|
||||
nil,
|
||||
) or_return
|
||||
write_value(b, strings.to_string(temp), escape = false)
|
||||
}
|
||||
} else {
|
||||
write_value(b, val, escape = false)
|
||||
}
|
||||
i += 1
|
||||
|
||||
case .Section:
|
||||
@@ -726,21 +699,31 @@ 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,
|
||||
tools,
|
||||
)
|
||||
transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:])
|
||||
if perr != nil {
|
||||
return tag_error(perr, current)
|
||||
}
|
||||
val = transformed
|
||||
}
|
||||
if is_truthy(val) {
|
||||
if result_str, ok := call_section_lambda(val, node.content); ok {
|
||||
sub_tpl, perr := parse(
|
||||
result_str,
|
||||
fmt.tprintf("<lambda output from '%s'>", node.key),
|
||||
context.temp_allocator,
|
||||
context.temp_allocator,
|
||||
)
|
||||
if perr == nil {
|
||||
render_nodes(
|
||||
sub_tpl,
|
||||
sub_tpl.nodes[:],
|
||||
ctx,
|
||||
partials,
|
||||
b,
|
||||
blocks,
|
||||
nil,
|
||||
) or_return
|
||||
}
|
||||
} else if is_truthy(val) {
|
||||
children := node.children
|
||||
elem_info, count, data := list_info(val)
|
||||
if elem_info != nil {
|
||||
@@ -756,8 +739,6 @@ render_nodes :: proc(
|
||||
b,
|
||||
blocks,
|
||||
indent_state,
|
||||
warnings,
|
||||
tools,
|
||||
) or_return
|
||||
}
|
||||
} else {
|
||||
@@ -771,7 +752,6 @@ render_nodes :: proc(
|
||||
b,
|
||||
blocks,
|
||||
indent_state,
|
||||
warnings,
|
||||
) or_return
|
||||
}
|
||||
}
|
||||
@@ -783,15 +763,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,
|
||||
tools,
|
||||
)
|
||||
transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:])
|
||||
if perr != nil {
|
||||
return tag_error(perr, current)
|
||||
}
|
||||
@@ -806,8 +778,6 @@ render_nodes :: proc(
|
||||
b,
|
||||
blocks,
|
||||
indent_state,
|
||||
warnings,
|
||||
tools,
|
||||
) or_return
|
||||
}
|
||||
i += 1 + len(node.children)
|
||||
@@ -822,7 +792,7 @@ render_nodes :: proc(
|
||||
if !found {
|
||||
warn_missing_partial(current, partials, node, name)
|
||||
} else {
|
||||
render_template(pt, ctx, partials, b, nil, node.indent, warnings, tools) or_return
|
||||
render_template(pt, ctx, partials, b, nil, node.indent) or_return
|
||||
if indent_state != nil {
|
||||
indent_state.at_line_start = false
|
||||
}
|
||||
@@ -857,8 +827,6 @@ render_nodes :: proc(
|
||||
&temp,
|
||||
content_blocks,
|
||||
nil,
|
||||
warnings,
|
||||
tools,
|
||||
) or_return
|
||||
at_ls := true
|
||||
write_indented(b, node.indent, strings.to_string(temp), &at_ls)
|
||||
@@ -871,8 +839,6 @@ render_nodes :: proc(
|
||||
b,
|
||||
content_blocks,
|
||||
indent_state,
|
||||
warnings,
|
||||
tools,
|
||||
) or_return
|
||||
}
|
||||
i += 1 + len(node.children)
|
||||
@@ -885,16 +851,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,
|
||||
tools,
|
||||
) or_return
|
||||
render_template(pt, ctx, partials, b, merged, node.indent) or_return
|
||||
if indent_state != nil {
|
||||
indent_state.at_line_start = false
|
||||
}
|
||||
@@ -949,7 +906,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)
|
||||
}
|
||||
@@ -959,14 +916,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)
|
||||
}
|
||||
|
||||
@@ -980,7 +930,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)
|
||||
}
|
||||
@@ -989,14 +939,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)
|
||||
}
|
||||
|
||||
@@ -1032,7 +975,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)
|
||||
}
|
||||
@@ -1045,13 +988,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)
|
||||
}
|
||||
@@ -1075,13 +1018,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
|
||||
}
|
||||
+20
-258
@@ -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,10 +20,6 @@ DEFAULT_DATE_FORMAT :: "2 Jan 2006"
|
||||
Pipe_Op :: enum {
|
||||
Format,
|
||||
Group_By,
|
||||
First,
|
||||
Last,
|
||||
Rel_Url,
|
||||
Tool,
|
||||
}
|
||||
|
||||
// Pipe op names are derived from the enum via reflection (lowercased).
|
||||
@@ -62,10 +56,9 @@ pipe_op_candidates :: proc(allocator := context.temp_allocator) -> []string {
|
||||
}
|
||||
|
||||
Pipe_Filter :: struct {
|
||||
op: string,
|
||||
args: [dynamic; MAX_PIPE_ARGS]string,
|
||||
op_pos: int,
|
||||
end_pos: int,
|
||||
op: string,
|
||||
args: [dynamic; MAX_PIPE_ARGS]string,
|
||||
op_pos: int,
|
||||
}
|
||||
|
||||
Group :: struct {
|
||||
@@ -138,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,9 +197,8 @@ 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),
|
||||
op = tokens[0],
|
||||
op_pos = content_base + op_offset_in_content,
|
||||
}
|
||||
for j in 1 ..< len(tokens) {
|
||||
append(&filter.args, tokens[j])
|
||||
@@ -224,28 +220,14 @@ apply_pipeline :: proc(
|
||||
filters: []Pipe_Filter,
|
||||
pos: int,
|
||||
ctx: []any,
|
||||
tmpl: Template,
|
||||
warnings: ^[dynamic]Error = nil,
|
||||
tools: ^Tool_Registry = nil,
|
||||
) -> (
|
||||
current: any,
|
||||
err: Error,
|
||||
) {
|
||||
current = value
|
||||
for &filter in filters {
|
||||
result, ferr := apply_filter(current, &filter, pos, ctx, tmpl, tools)
|
||||
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
|
||||
}
|
||||
@@ -274,27 +256,17 @@ 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,
|
||||
tools: ^Tool_Registry = nil,
|
||||
) -> (
|
||||
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)
|
||||
}
|
||||
return nil, Error_Body {
|
||||
msg = fmt.tprintf("unknown pipe op '%s'", filter.op),
|
||||
pos = filter.op_pos,
|
||||
msg = fmt.tprintf("unknown pipe op '%s'", filter.op),
|
||||
pos = filter.op_pos,
|
||||
span = len(filter.op),
|
||||
kind = .Data,
|
||||
hint = hint,
|
||||
@@ -303,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 {
|
||||
@@ -343,89 +314,10 @@ 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)
|
||||
case .Rel_Url:
|
||||
bv, _ := base_value(value)
|
||||
str, ok := reflect.as_string(bv)
|
||||
if !ok {
|
||||
return value, Error_Body {
|
||||
msg = "rel_url may only be used on strings",
|
||||
pos = pos,
|
||||
kind = .Data,
|
||||
}
|
||||
}
|
||||
result := rel_url(str, ctx)
|
||||
return any{new_clone(result, context.temp_allocator), typeid_of(string)}, nil
|
||||
case .Tool:
|
||||
if len(filter.args) != 1 {
|
||||
return value, Error_Body {
|
||||
msg = fmt.tprintf(
|
||||
"tool expects 1 argument (the tool name), got %d",
|
||||
len(filter.args),
|
||||
),
|
||||
pos = filter.op_pos,
|
||||
span = filter.end_pos - filter.op_pos,
|
||||
kind = .Data,
|
||||
}
|
||||
}
|
||||
bv, _ := base_value(value)
|
||||
str, ok := reflect.as_string(bv)
|
||||
if !ok {
|
||||
return value, Error_Body {
|
||||
msg = "tool may only be used on strings",
|
||||
pos = pos,
|
||||
kind = .Data,
|
||||
}
|
||||
}
|
||||
result, terr := run_tool(tools, filter.args[0], str, filter.op_pos)
|
||||
if terr != nil {
|
||||
return value, terr
|
||||
}
|
||||
return any{new_clone(result, context.temp_allocator), typeid_of(string)}, nil
|
||||
}
|
||||
return {}, nil
|
||||
}
|
||||
|
||||
// rel_url prefixes a root-relative path (e.g. "/faq/") with the site's
|
||||
// base_path so links resolve correctly under a subpath deployment. Values that
|
||||
// are already absolute — a scheme ("http://"), protocol-relative ("//"), an
|
||||
// in-page fragment ("#"), or a "mailto:" — are returned unchanged, which keeps
|
||||
// external menu and footer links intact. base_path is read from the context
|
||||
// stack the same way `format` reads date_format/timezone.
|
||||
rel_url :: proc(path: string, ctx: []any) -> string {
|
||||
if path == "" {
|
||||
return path
|
||||
}
|
||||
if strings.has_prefix(path, "http://") ||
|
||||
strings.has_prefix(path, "https://") ||
|
||||
strings.has_prefix(path, "//") ||
|
||||
strings.has_prefix(path, "#") ||
|
||||
strings.has_prefix(path, "mailto:") {
|
||||
return path
|
||||
}
|
||||
|
||||
base := ""
|
||||
if raw := resolve_name("base_path", ctx); raw != nil {
|
||||
if s, ok := reflect.as_string(raw); ok {
|
||||
base = s
|
||||
}
|
||||
}
|
||||
if base == "" {
|
||||
return path
|
||||
}
|
||||
|
||||
// Only prefix root-relative paths; leave document-relative ones alone.
|
||||
if path[0] == '/' {
|
||||
return fmt.tprintf("%s%s", base, path)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
apply_format :: proc(
|
||||
iso: string,
|
||||
args: []string,
|
||||
@@ -463,40 +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)
|
||||
@@ -538,112 +409,3 @@ apply_group_by :: proc(
|
||||
|
||||
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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user