mirror of
https://github.com/sbrow/thor.git
synced 2026-10-25 10:11:55 -04:00
Compare commits
5
Commits
main
..
1824bae26c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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`.
|
Thor is a static site generator written in [Odin](https://odin-lang.org), replacing Hugo for the `sbrow.github.io` blog. It lives at `./thor/` as a git subtree with its own `flake.nix`.
|
||||||
|
|
||||||
## 🚫 DO NOT EDIT THE DOCS — BY HUMANS, FOR HUMANS
|
|
||||||
|
|
||||||
> **THE DOCUMENTATION UNDER `thor/site/` IS HANDWRITTEN, BY HUMANS, FOR HUMANS.**
|
|
||||||
>
|
|
||||||
> **NO AI, AGENT, BOT, ASSISTANT, OR OTHER NON-HUMAN MAY EDIT, REWRITE,
|
|
||||||
> REPHRASE, REFORMAT, "IMPROVE," SUMMARIZE, OR GENERATE ANY FILE UNDER
|
|
||||||
> `thor/site/` — EVER.**
|
|
||||||
>
|
|
||||||
> These are not machine artifacts. A human wrote every word. AI may be
|
|
||||||
> consulted as a sanity check, but the prose stays human. If you are not a
|
|
||||||
> human, do not touch these files. See `thor/site/content/ai.md`.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -32,18 +20,16 @@ thor/
|
|||||||
├── treesitter/ # FFI types + grammar management (standalone package)
|
├── treesitter/ # FFI types + grammar management (standalone package)
|
||||||
├── markdown/ # Content transformation pipeline (imports ../treesitter)
|
├── markdown/ # Content transformation pipeline (imports ../treesitter)
|
||||||
├── mustache/ # Template engine with lambdas + pipe filters + diagnostics
|
├── mustache/ # Template engine with lambdas + pipe filters + diagnostics
|
||||||
├── diagnostics/ # Shared diagnostic utilities (suggest_correction)
|
|
||||||
├── content.odin # Page struct, Pending_File, scan_content_files, collect_languages, load_page
|
├── content.odin # Page struct, Pending_File, scan_content_files, collect_languages, load_page
|
||||||
├── render.odin # Template rendering, Template_Context, sort_pages, RSS, sitemap
|
├── render.odin # Template rendering, data structs, RSS, sitemap
|
||||||
├── menus.odin # Menu_Entry, DEFAULT_WEIGHT, build_menus, collect_auto_menus, merge_page_menus, parse_page_menus, parse_config_menus
|
├── site.odin # Config (Flags, Config_File, Site), init_site
|
||||||
├── site.odin # Config (Flags, Config_File, Site, Site_Context), init_site
|
|
||||||
├── minify.odin # HTML/CSS minification (imports treesitter)
|
├── minify.odin # HTML/CSS minification (imports treesitter)
|
||||||
├── feed.odin # RSS + sitemap generation
|
├── feed.odin # RSS + sitemap generation
|
||||||
├── vfs.odin # Union file system (defaults → modules → site)
|
├── vfs.odin # Union file system (defaults → modules → site)
|
||||||
├── assets.odin # VFS-based asset copying
|
├── assets.odin # VFS-based asset copying
|
||||||
├── html.odin # HTML helpers: strip_html_tags, unescape_html, generate_summary, generate_description
|
├── html.odin # HTML helpers: strip_html_tags, unescape_html, generate_summary (word-count truncation), generate_description (scrub to plain text)
|
||||||
├── opengraph.odin # Open_Graph struct + og_for_site/og_for_page
|
├── opengraph.odin # Open_Graph struct + og_for_site/og_for_page
|
||||||
├── frontmatter.odin # JSON frontmatter parser (supports nested og + lastmod + weight + menus)
|
├── frontmatter.odin # JSON frontmatter parser (supports nested og + lastmod)
|
||||||
├── defaults.odin # DEFAULTS_PATH constant (#directory)
|
├── defaults.odin # DEFAULTS_PATH constant (#directory)
|
||||||
├── main.odin # Entry point
|
├── main.odin # Entry point
|
||||||
├── bench/ # Template rendering benchmark
|
├── bench/ # Template rendering benchmark
|
||||||
@@ -54,18 +40,17 @@ thor/
|
|||||||
|
|
||||||
| File | Responsibility |
|
| File | Responsibility |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `main.odin` | Entry point. Parses CLI flags via `core:flags`, sets logger level from `-verbose`/`-quiet`, calls `init_site`, `build_vfs`, wires `treesitter.grammar_dir`/`query_dir` from config, `site_load_content`, `render_site`. Optional Spall profiling via `SPALL` config flag. |
|
| `main.odin` | Entry point. Sets `context.logger`, calls `init_site`, `build_vfs`, wires `treesitter.grammar_dir`/`query_dir` from config, `site_load_content`, `render_site`. Optional Spall profiling via `SPALL` config flag. |
|
||||||
| `site.odin` | `Flags` (CLI, includes `-verbose`/`-quiet`), `Config_File` (thor.json), `Site_Context` (template-facing: `title`, `description`, `base_url`, `params`, `og`, `menus`), `Site` (runtime state + arena + VFS + pages + `og`). `Feature` enum. `init_site(site, flags)` — takes pre-parsed `Flags`. Config menu parsing in `site_apply_config`. |
|
| `site.odin` | `Flags` (CLI), `Config_File` (thor.json, includes `og: Open_Graph`), `Site` (runtime state + arena + VFS + pages + modules + `og`). `Feature` enum. 5-step `init_site`. Imports `md "markdown"` for `Extension` enum. |
|
||||||
| `content.odin` | `Page` struct (includes `weight`, `menus`, `params: json.Value`, `toc: string`, `og`), `Pending_File` struct, `scan_content_files` (section-aware walk that handles leaf bundles), `collect_languages` (pre-scan for code fence languages), `load_page` (falls back to file mtime, generates TOC via `md.generate_toc` when frontmatter `"toc": true`), `infer_layout`. Calls `md.process()` for the markdown pipeline. |
|
| `content.odin` | `Page` struct (includes `lastmod`, `og`), `Pending_File` struct, `scan_content_files` (section-aware walk that handles leaf bundles), `collect_languages` (pre-scan for code fence languages), `load_page`, `infer_layout`. Calls `md.process()` for the markdown pipeline. |
|
||||||
| `render.odin` | Template rendering: `render_site`, `render_page_html`, `render_home_html`, `render_section`. `Template_Context` (unified render struct with `site: Site_Context`, `page: Page`, `menus`, `params`, `posts`, `pages`). 3-frame context stack via `[]any{ctx.site, ctx.page, ctx}`. `merge_params(site, page)` — shallow merge of site + page params. Error deduplication via `seen: ^map[string]bool` passed through render chain. `sort_pages` (weight primary, date secondary). `to_title_case` for section display names. VFS-based template loading with fallback chain (`get_template`). |
|
| `render.odin` | Template rendering: `render_site`, `render_page_html`, `render_home_html`, `render_section`. Data structs (`Base_Data`, `Page_Data`, `Home_Data`, `Section_Data`). VFS-based template loading with fallback chain (`get_template`). |
|
||||||
| `menus.odin` | Menu system: `Menu_Entry {name, url, weight: Maybe(int)}`, `DEFAULT_WEIGHT = 10`. `build_menus` (priority chain: config → auto + page frontmatter, then `warn_all_duplicate_weights`). `collect_auto_menus` (sections + root-level pages, skips pages with explicit `"menus": "main"` frontmatter). `merge_page_menus` (frontmatter entries with effective weight fallback via nil check). `parse_page_menus` (string/array/object forms). `parse_config_menus` (from thor.json). `sort_menu_entries` / `compare_menu_entries` (weight primary via `.? or_else DEFAULT_WEIGHT`, name secondary). `warn_duplicate_weights` / `warn_all_duplicate_weights` (log when two entries in same menu have same explicitly-set weight). |
|
|
||||||
| `minify.odin` | HTML/CSS minification via tree-sitter. Imports `ts "treesitter"`. |
|
| `minify.odin` | HTML/CSS minification via tree-sitter. Imports `ts "treesitter"`. |
|
||||||
| `feed.odin` | RSS feed + sitemap XML. Uses `page.url` for canonical URLs. |
|
| `feed.odin` | RSS feed + sitemap XML. Uses `page.url` for canonical URLs. |
|
||||||
| `vfs.odin` | Union file system: `VFS`, `build_vfs`, `mount_dir`, `mount_subdir`, `mount_recursive`, `vfs_get`, `vfs_get_entry`, `vfs_entry_data`. Layers defaults → modules → site. |
|
| `vfs.odin` | Union file system: `VFS`, `build_vfs`, `mount_dir`, `mount_subdir`, `mount_recursive`, `vfs_get`, `vfs_get_entry`, `vfs_entry_data`. Layers defaults → modules → site. |
|
||||||
| `assets.odin` | `copy_assets_dir` — iterates VFS entries with `assets/` prefix, minifies CSS, copies verbatim or via `os.copy_file`. |
|
| `assets.odin` | `copy_assets_dir` — iterates VFS entries with `assets/` prefix, minifies CSS, copies verbatim or via `os.copy_file`. |
|
||||||
| `html.odin` | `strip_html_tags`, `unescape_html`, `generate_summary` (word-count truncation, zero-alloc), `generate_description` (HTML→plain text: strip tags, decode entities, collapse whitespace). |
|
| `html.odin` | `strip_html_tags`, `unescape_html`, `generate_summary` (word-count truncation, zero-alloc), `generate_description` (HTML→plain text: strip tags, decode entities, collapse whitespace). |
|
||||||
| `opengraph.odin` | `Open_Graph` struct (fields ordered per OGP spec, `is_article: Maybe(bool)`). `og_for_site(site)` for site defaults (from config + derived), `og_for_page(site_og, page)` for page-specific (overlay page.og + derive from page data). Description falls back to `generate_description(generate_summary(body_html))`. |
|
| `opengraph.odin` | `Open_Graph` struct (fields ordered per OGP spec, `is_article: Maybe(bool)`). `og_for_site(site)` for site defaults (from config + derived), `og_for_page(site_og, page)` for page-specific (overlay page.og + derive from page data). |
|
||||||
| `frontmatter.odin` | JSON frontmatter parser (`{ }` delimited). Supports `layout`, `lastmod`, `weight: Maybe(int)`, `menus`, `params: json.Value`, `toc: bool`, and nested `og` object (via `json_get_open_graph`). Helpers: `json_get_string`, `json_get_bool`, `json_get_int` (returns `Maybe(int)`, nil for absent/invalid). |
|
| `frontmatter.odin` | JSON frontmatter parser (`{ }` delimited). Supports `layout`, `lastmod`, and nested `og` object (via `json_get_open_graph`). |
|
||||||
| `defaults.odin` | `DEFAULTS_PATH` constant, resolved at compile time via `#directory` so bundled templates ship in the binary. |
|
| `defaults.odin` | `DEFAULTS_PATH` constant, resolved at compile time via `#directory` so bundled templates ship in the binary. |
|
||||||
|
|
||||||
### Subpackages
|
### Subpackages
|
||||||
@@ -73,17 +58,13 @@ thor/
|
|||||||
| Package | Files | Responsibility |
|
| Package | Files | Responsibility |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `treesitter/` | `treesitter.odin` | FFI types (`Parser`, `Node`, `Query`, etc.), `@(link_prefix="ts_")` foreign bindings, grammar management (`Grammar_Store` with persistent allocator, `load_language`/`compile_query` building blocks, `ensure_parser`/`load_grammar` lazy loading, `preload_grammar`/`preload_grammars` for parallel loading with `sync.Mutex` cache protection), statically-linked HTML/CSS grammars |
|
| `treesitter/` | `treesitter.odin` | FFI types (`Parser`, `Node`, `Query`, etc.), `@(link_prefix="ts_")` foreign bindings, grammar management (`Grammar_Store` with persistent allocator, `load_language`/`compile_query` building blocks, `ensure_parser`/`load_grammar` lazy loading, `preload_grammar`/`preload_grammars` for parallel loading with `sync.Mutex` cache protection), statically-linked HTML/CSS grammars |
|
||||||
| `markdown/` | `markdown.odin` | `Extension` enum, `DEFAULT_EXTENSIONS`, `EXTENSION_NAMES`, `process(body, ext, file_path, allocator)` — full pipeline (clones cmark output, frees original), `parse_extension_list` (with "did you mean?" hints via `diagnostics.suggest_correction`), `apply_extension_config` (same), `resolve_extension_conflicts` (Footnotes wins over Sidenotes) |
|
| `markdown/` | `markdown.odin` | `Extension` enum, `DEFAULT_EXTENSIONS`, `process(body, ext, file_path)` — full pipeline, `parse_extension_list`, `apply_extension_config` |
|
||||||
| | `footnotes.odin` | `strip_definitions` (pre-cmark, shared by `.Sidenotes` + `.Footnotes`), `inject_notes` (post-cmark sidenote rendering), `inject_footnotes` (post-cmark standard footnote rendering — numbered `<sup>` links + `<section class="footnotes"><ol>` at bottom). `.Sidenotes` and `.Footnotes` are mutually exclusive; `resolve_extension_conflicts` in `markdown.odin` picks `.Footnotes` if both are set. |
|
| | `footnotes.odin` | `strip_definitions` (pre-cmark), `inject_notes` (post-cmark) |
|
||||||
| | `alerts.odin` | `inject_alerts` — GitHub alert blocks (`> [!NOTE]`) → styled blockquotes with semantic class names (`alert-note` etc.) |
|
| | `alerts.odin` | `inject_alerts` — GitHub alert blocks (`> [!NOTE]`) → styled blockquotes with semantic class names (`alert-note` etc.) |
|
||||||
| | `emoji.odin` | `expand_emoji` — `:shortcode:` → unicode emoji |
|
| | `emoji.odin` | `expand_emoji` — `:shortcode:` → unicode emoji |
|
||||||
| | `sectionate.odin` | `wrap_sections` — splits HTML at `<h2>` into `<section>` wrappers |
|
| | `sectionate.odin` | `wrap_sections` — splits HTML at `<h2>` into `<section>` wrappers |
|
||||||
| | `highlight.odin` | Syntax highlighting via tree-sitter. Imports `../treesitter`. |
|
| | `highlight.odin` | Syntax highlighting via tree-sitter. Imports `../treesitter`. |
|
||||||
| | `heading_ids.odin` | `inject_heading_ids` — adds `id` attributes to `<h1>`-`<h6>` from heading text. Slug-based, deduplicated. |
|
|
||||||
| | `deflists.odin` | `convert_deflists` — pre-cmark pass. Scans for definition list patterns (`term\n\n: definition`) and converts to `<dl><dt><dd>` HTML blocks. Terms and definitions rendered through cmark individually for inline markdown. Consecutive pairs grouped into single `<dl>`. |
|
|
||||||
| | `toc.odin` | `generate_toc(html, allocator)` — page-level feature (not a pipeline extension). Scans `<h1>`-`<h6>` for IDs (after `inject_heading_ids`), builds nested `<ul>` with `<a href="#id">` links. Called from `load_page` when frontmatter `"toc": true`. Depends on `.HeadingIDs` being enabled. |
|
|
||||||
| `mustache/` | See [Mustache engine](#mustache-engine) below | Template engine |
|
| `mustache/` | See [Mustache engine](#mustache-engine) below | Template engine |
|
||||||
| `diagnostics/` | `diagnostics.odin` | Shared diagnostic utilities: `suggest_correction` (Levenshtein "did you mean?"), `format_error` (rust-style multi-line error formatter with `caret_start`/`caret_end` params), `line_col`, `line_text`, `count_lines`, `digit_count`, `display_width`, `should_colorize`, `write_gutter`. Used by `mustache` (tag-aware errors via `format_tag_error` wrapper), `markdown` (extension name suggestions), and `render.odin` (parse/render error display). |
|
|
||||||
| `bench/` | `bench.odin` + `templates/` | Standalone template rendering benchmark. Generates 500 posts + 100 comments, renders with indented partials + inheritance + pipes. `--dump <path>` for output validation, positional arg for iteration count (default 250). |
|
| `bench/` | `bench.odin` + `templates/` | Standalone template rendering benchmark. Generates 500 posts + 100 comments, renders with indented partials + inheritance + pipes. `--dump <path>` for output validation, positional arg for iteration count (default 250). |
|
||||||
|
|
||||||
Icon SVGs live as HTML partials in `layouts/partials/icons/` (home, github, rss, chevron_up, star).
|
Icon SVGs live as HTML partials in `layouts/partials/icons/` (home, github, rss, chevron_up, star).
|
||||||
@@ -93,10 +74,10 @@ Icon SVGs live as HTML partials in `layouts/partials/icons/` (home, github, rss,
|
|||||||
```
|
```
|
||||||
thor.json → find_config → init_site (5-step)
|
thor.json → find_config → init_site (5-step)
|
||||||
→ build_vfs (defaults/layouts → modules → site/layouts, site/assets)
|
→ build_vfs (defaults/layouts → modules → site/layouts, site/assets)
|
||||||
→ site_load_content (scan_content_files + collect_languages + preload_grammars + load_page + url computation + build_menus + warn_all_duplicate_weights)
|
→ site_load_content (scan_content_files + collect_languages + preload_grammars + load_page + url computation)
|
||||||
→ render_site
|
→ render_site
|
||||||
→ load_partials + get_template (VFS + fallback chain)
|
→ load_partials + get_template (VFS + fallback chain)
|
||||||
→ render_page_html / render_home_html / render_section (3-frame context stack: site, page, ctx)
|
→ render_page_html / render_home_html / render_section
|
||||||
→ optional minify_html
|
→ optional minify_html
|
||||||
→ public/
|
→ public/
|
||||||
```
|
```
|
||||||
@@ -113,19 +94,15 @@ Page :: struct {
|
|||||||
title: string,
|
title: string,
|
||||||
description: string,
|
description: string,
|
||||||
date: string,
|
date: string,
|
||||||
year: string,
|
|
||||||
weight: Maybe(int), // page ordering (nil = unset, defaults to DEFAULT_WEIGHT at comparison time)
|
|
||||||
lastmod: string,
|
lastmod: string,
|
||||||
menus: map[string]Menu_Entry, // frontmatter menu assignments
|
menu: string,
|
||||||
params: json.Value, // per-page params (merged with site params at render time)
|
body_html: string,
|
||||||
content: string, // rendered HTML body
|
|
||||||
og: Open_Graph,
|
|
||||||
draft: bool,
|
draft: bool,
|
||||||
toc: string, // generated table of contents HTML (empty if not requested)
|
is_starred: bool,
|
||||||
|
og: Open_Graph, // per-page OG overrides from frontmatter
|
||||||
_is_index: bool `private`,
|
_is_index: bool `private`,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
```
|
|
||||||
|
|
||||||
No `Page_Type` enum — page type is inferred from section + `_is_index`. Layout is inferred via `infer_layout(section, is_index)`:
|
No `Page_Type` enum — page type is inferred from section + `_is_index`. Layout is inferred via `infer_layout(section, is_index)`:
|
||||||
|
|
||||||
@@ -136,51 +113,17 @@ No `Page_Type` enum — page type is inferred from section + `_is_index`. Layout
|
|||||||
|
|
||||||
**Template fallback chain** (in `get_template`): for content pages, `post → page → base`; for section indexes, `posts_index → section_index → page → base`. Fallbacks logged at debug level. Frontmatter `layout` field overrides the inferred value.
|
**Template fallback chain** (in `get_template`): for content pages, `post → page → base`; for section indexes, `posts_index → section_index → page → base`. Fallbacks logged at debug level. Frontmatter `layout` field overrides the inferred value.
|
||||||
|
|
||||||
## Menus
|
|
||||||
|
|
||||||
Menu system in `menus.odin`. `Menu_Entry :: struct {name: string, url: string, weight: int}`. `DEFAULT_WEIGHT = 10`.
|
|
||||||
|
|
||||||
### Sources (priority chain, no mixing)
|
|
||||||
|
|
||||||
1. **Config menus** (`"menus"` key in `thor.json`) — exclusive. `"menus": {}` = explicit opt-out (no menus). Config entries sorted by weight.
|
|
||||||
2. **Auto-menus + page frontmatter** — always run together when no config menus:
|
|
||||||
- Auto: one entry per section directory + one per root-level non-index page. Alphabetical.
|
|
||||||
- Page frontmatter: `"menus": "main"` (string), `["main", "footer"]` (array), or `{"main": {"weight": 30}}` (object with per-menu weight). Merged with auto entries, sorted by weight.
|
|
||||||
|
|
||||||
### Weight
|
|
||||||
|
|
||||||
All weight fields use `Maybe(int)` — nil means "unset," `some(v)` means explicitly set. This distinguishes `"weight": 10` (explicit) from no weight key (defaults to `DEFAULT_WEIGHT` at comparison time via `.? or_else DEFAULT_WEIGHT`). Eliminates the old `0`-as-sentinel pattern from `json_get_int`.
|
|
||||||
|
|
||||||
- `Page.weight: Maybe(int)` — page-level ordering. nil = unset. Affects `sort_pages` (weight primary, date secondary).
|
|
||||||
- `Menu_Entry.weight: Maybe(int)` — per-menu ordering. nil for auto-generated entries and string/array frontmatter forms. Explicit value from object frontmatter form `{"weight": N}`.
|
|
||||||
- Effective weight in `merge_page_menus`: per-menu weight if set, else falls back to `page.weight`. Both `Maybe(int)`, so nil propagates naturally — no value-based sentinel check.
|
|
||||||
- Sorted ascending via `.? or_else DEFAULT_WEIGHT`, name alphabetical for ties.
|
|
||||||
|
|
||||||
### Templates
|
|
||||||
|
|
||||||
```html
|
|
||||||
{{#menus.main}}
|
|
||||||
<li><a href="{{url}}">{{name}}</a></li>
|
|
||||||
{{/menus.main}}
|
|
||||||
```
|
|
||||||
|
|
||||||
`Template_Context.menus` resolves above `Page.menus` (frontmatter assignments) on the 3-frame context stack. Accessible as `{{#menus.main}}` or `{{#site.menus.main}}`.
|
|
||||||
|
|
||||||
### Duplicate weight warnings
|
|
||||||
|
|
||||||
`warn_duplicate_weights` (called from `build_menus` after all menus are sorted) logs a warning when two entries in the same menu have the same explicitly-set weight. Only non-nil weights are checked — nil (unset/default) entries are never flagged, so auto-generated entries don't produce noise. The warning includes the menu name, weight value, and both entry names.
|
|
||||||
|
|
||||||
## Config system
|
## Config system
|
||||||
|
|
||||||
Config is split into three structs with a clear 5-step initialization flow:
|
Config is split into three structs with a clear 5-step initialization flow:
|
||||||
|
|
||||||
- **`Flags`** — CLI args only. Parsed once in `main.odin` via `core:flags`, passed to `init_site`. Includes path overrides (`--content`, `--assets`, `--output`, `--layouts`), build-mode toggles (`-drafts`, `-watch`, `-minify`), log level (`-verbose` → Debug, `-quiet` → Warning), and `-ext`/`-no-ext` for markdown extension overrides.
|
- **`Flags`** — CLI args only. Parsed by `core:flags`. Includes path overrides (`--content`, `--assets`, `--output`, `--layouts`), build-mode toggles (`-drafts`, `-watch`, `-minify`), and `-ext`/`-no-ext` for markdown extension overrides.
|
||||||
- **`Config_File`** — parsed from `thor.json` via `json.unmarshal_string`. Holds title, paths, `markdown_extensions` (JSON), `params` (JSON), `modules` (JSON array of relative paths), `og` (`Open_Graph` struct for site-level OG defaults).
|
- **`Config_File`** — parsed from `thor.json` via `json.unmarshal_string`. Holds title, paths, `markdown_extensions` (JSON), `params` (JSON), `modules` (JSON array of relative paths), `og` (`Open_Graph` struct for site-level OG defaults).
|
||||||
- **`Site`** — runtime state: arena, pages, modules, VFS, `features: bit_set[Feature]`, `markdown_extensions: bit_set[md.Extension]`, `og: Open_Graph` (resolved site-level OG).
|
- **`Site`** — runtime state: arena, pages, modules, VFS, `features: bit_set[Feature]`, `markdown_extensions: bit_set[md.Extension]`, `og: Open_Graph` (resolved site-level OG).
|
||||||
|
|
||||||
**`Feature` enum** — `Drafts`, `Minify`, `Watch`. Checked with `.Minify in site.features`.
|
**`Feature` enum** — `Drafts`, `Minify`, `Watch`. Checked with `.Minify in site.features`.
|
||||||
|
|
||||||
**`markdown.Extension` enum** (in the `markdown` package, not main) — `Emoji`, `Sidenotes`, `Alerts`, `Highlight`, `Sections`, `HeadingIDs`, `DefLists`, `Footnotes`. Default is `md.DEFAULT_EXTENSIONS` (currently `.Emoji, .Sidenotes, .Alerts, .HeadingIDs, .DefLists`). Configurable via:
|
**`markdown.Extension` enum** (in the `markdown` package, not main) — `Emoji`, `Sidenotes`, `Alerts`, `Highlight`, `Sections`, `HeadingIDs`. Default is `md.DEFAULT_EXTENSIONS` (currently `.Emoji, .Sidenotes, .Alerts, .HeadingIDs`). Configurable via:
|
||||||
- `thor.json`: `"markdown_extensions": { "emoji": true, "highlight": false, ... }`
|
- `thor.json`: `"markdown_extensions": { "emoji": true, "highlight": false, ... }`
|
||||||
- CLI: `-ext:highlight,sections` (enable) / `-no-ext:emoji` (disable). Comma-separated, case-insensitive.
|
- CLI: `-ext:highlight,sections` (enable) / `-no-ext:emoji` (disable). Comma-separated, case-insensitive.
|
||||||
|
|
||||||
@@ -203,12 +146,6 @@ Config precedence: `CLI flags > thor.json values > hardcoded defaults`.
|
|||||||
"grammars": "~/.config/helix/runtime/grammars/",
|
"grammars": "~/.config/helix/runtime/grammars/",
|
||||||
"queries": "/path/to/tree-sitter/queries",
|
"queries": "/path/to/tree-sitter/queries",
|
||||||
"markdown_extensions": { "emoji": true, "highlight": false },
|
"markdown_extensions": { "emoji": true, "highlight": false },
|
||||||
"menus": {
|
|
||||||
"main": [
|
|
||||||
{"name": "Home", "url": "/", "weight": 1},
|
|
||||||
{"name": "About", "url": "/about/"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"params": {
|
"params": {
|
||||||
"social": [
|
"social": [
|
||||||
{ "name": "github", "url": "...", "icon": "icons/github" }
|
{ "name": "github", "url": "...", "icon": "icons/github" }
|
||||||
@@ -260,12 +197,10 @@ Lives in the `markdown` package. Entry point: `md.process(body, ext, file_path)`
|
|||||||
|
|
||||||
```
|
```
|
||||||
raw markdown
|
raw markdown
|
||||||
→ md.strip_definitions (if .Sidenotes || .Footnotes — pre-cmark)
|
→ md.strip_definitions (if .Sidenotes — pre-cmark)
|
||||||
→ md.convert_deflists (if .DefLists — pre-cmark)
|
|
||||||
→ cmark markdown_to_html (Unsafe mode for HTML passthrough)
|
→ cmark markdown_to_html (Unsafe mode for HTML passthrough)
|
||||||
→ md.expand_emoji (if .Emoji — post-cmark)
|
→ md.expand_emoji (if .Emoji — post-cmark)
|
||||||
→ md.inject_notes (if .Sidenotes — post-cmark, sidenote rendering)
|
→ md.inject_notes (if .Sidenotes — post-cmark)
|
||||||
→ md.inject_footnotes (if .Footnotes — post-cmark, standard footnote rendering)
|
|
||||||
→ md.inject_alerts (if .Alerts — post-cmark)
|
→ md.inject_alerts (if .Alerts — post-cmark)
|
||||||
→ md.highlight_code (if .Highlight — post-cmark)
|
→ md.highlight_code (if .Highlight — post-cmark)
|
||||||
→ md.inject_heading_ids (if .HeadingIDs — post-cmark, pre-sections)
|
→ md.inject_heading_ids (if .HeadingIDs — post-cmark, pre-sections)
|
||||||
@@ -290,26 +225,36 @@ Templates use Mustache with template inheritance (`{{<base}}` / `{{$block}}`):
|
|||||||
{{/base}}
|
{{/base}}
|
||||||
```
|
```
|
||||||
|
|
||||||
Data is passed as a single `Template_Context` struct. `render_template` passes a 3-frame context stack `[]any{ctx.site, ctx.page, ctx}` to `mustache.render`, which auto-detects `[]any` and expands each element into a stack frame. Name resolution walks top-to-bottom: `Template_Context` → `Page` → `Site_Context`. Fields not found on the top frame fall through to lower frames.
|
Data is passed as **typed structs** (not `map[string]any`). Mustache resolves struct fields via Odin reflection, including `using`-embedded fields. Date presence is checked via string truthiness (`{{#date}}`) — no separate `has_date` bool needed. Dates are stored as raw ISO strings; presentation formatting happens in the template via the `format` pipe (see Pipes extension below).
|
||||||
|
|
||||||
```odin
|
```odin
|
||||||
Template_Context :: struct {
|
Base_Data :: struct {
|
||||||
site: Site_Context, // site-level data (title, description, base_url, params, og)
|
|
||||||
menus: map[string][]Menu_Entry, // generated menu data (copied from site, resolves above Page.menus)
|
|
||||||
now: string, // UTC ISO 8601 build timestamp
|
now: string, // UTC ISO 8601 build timestamp
|
||||||
|
params: json.Value,
|
||||||
|
content: string,
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
og: Open_Graph,
|
||||||
date_format: string, // from site.date.format (thor.json)
|
date_format: string, // from site.date.format (thor.json)
|
||||||
timezone: ^datetime.TZ_Region, // for format pipe
|
timezone: ^datetime.TZ_Region, // loaded from site.date.timezone or local, owned by Site
|
||||||
og: Open_Graph, // computed per-page OG
|
}
|
||||||
params: json.Value, // merged site + page params (resolves above Page.params)
|
Page_Data :: struct {
|
||||||
page: Page, // current page
|
using base: Base_Data, // fields promoted via reflection fallback
|
||||||
pages: [dynamic]Page, // home page list
|
page.title: string,
|
||||||
posts: [dynamic]Page, // section post list
|
date: string, // raw ISO 8601; formatted via `| format` in templates
|
||||||
|
}
|
||||||
|
Home_Data :: struct {
|
||||||
|
using base: Base_Data,
|
||||||
|
pages: [dynamic]Page_Context,
|
||||||
|
}
|
||||||
|
Section_Data :: struct {
|
||||||
|
using base: Base_Data,
|
||||||
|
page.title: string,
|
||||||
|
posts: [dynamic]Page_Context, // flat list; year grouping done in template via pipe
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`Site_Context` is embedded in `Site` via `using site_context`. Fields like `site.title`, `site.menus`, `site.params` are accessed directly on `Site` through promotion. `Template_Context.menus` is copied from `site.menus` to resolve above `Page.menus` (frontmatter assignments) on the context stack. `Template_Context.params` is set per-page via `merge_params(site.params, page.params)` — site params overlaid with page params. Browser title is handled by the `{{> title}}` partial (not a computed field).
|
`render_site` pre-parses all partials and the base layout once (via `mustache.parse`), then per-layout templates are cached in `get_template`. Year-based grouping on section index pages is done in the template via `{{#posts | group_by year}}` (see Pipes extension below) — there is no `Year_Section` Go-side struct.
|
||||||
|
|
||||||
`render_site` pre-parses all partials and the base layout once (via `mustache.parse`), then per-layout templates are cached in `get_template`. Year-based grouping on section index pages is done in the template via `{{#posts | group_by year}}` (see Pipes extension below).
|
|
||||||
|
|
||||||
### Pipes extension
|
### Pipes extension
|
||||||
|
|
||||||
@@ -419,11 +364,12 @@ Spec-compliant implementation at `mustache/`. See `mustache/SPEC.md` for the imp
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `mustache.odin` | Public API (`parse`, `render`, `Template`), parser (`parse_section`), renderer (`render_nodes` with `Indent_State` for partial indentation), template inheritance (`merge_block_overrides`), `delete_template`/`delete_partials`. Pipe support in Variable/Unescaped/Section/Inverted tags. |
|
| `mustache.odin` | Public API (`parse`, `render`, `Template`), parser (`parse_section`), renderer (`render_nodes` with `Indent_State` for partial indentation), template inheritance (`merge_block_overrides`), `delete_template`/`delete_partials`. Pipe support in Variable/Unescaped/Section/Inverted tags. |
|
||||||
| `tokenizer.odin` | Tokenizer (template string → `[]Token`), standalone whitespace detection |
|
| `tokenizer.odin` | Tokenizer (template string → `[]Token`), standalone whitespace detection |
|
||||||
| `data.odin` | Reflection-based data model: `base_value` (peels union/any/nested-any layers), `lookup_in` (structs + maps, handles `Type_Info_Any` value kind in maps), `resolve_name`, `is_truthy`, `any_to_string`, `write_value`, `list_info`, `extract_list_element`, `collect_map_keys` |
|
| `data.odin` | Reflection-based data model: `base_value` (peels union/any/nested-any layers), `lookup_in` (structs + maps, handles `Type_Info_Any` value kind in maps), `resolve_name`, `is_truthy`, `any_to_string`, `list_info`, `extract_list_element`, `call_interp_lambda`/`call_section_lambda` |
|
||||||
| `pipes.odin` | Pipes extension: `Pipe_Op` enum (`.Format`, `.Group_By`), `pipe_op_from_string`/`pipe_op_candidates` (reflection-based enum name lookup), `Pipe_Filter` AST (with `op_pos` for diagnostics), `parse_pipeline` (tracks byte offsets via `strings.index`), `apply_pipeline`, `apply_filter` (exhaustive enum switch), `apply_group_by`, `apply_format`. Misspelled pipe op suggestions via `suggest_correction`. Stored on `Node.filters`; render-scoped results in temp allocator. |
|
| `pipes.odin` | Pipes extension: `Pipe_Filter` AST, `parse_pipeline` (takes `pos`), `apply_pipeline`, `apply_filter` (switch dispatch: `group_by` + `format`), `apply_group_by`, `apply_format`. Stored on `Node.filters`; render-scoped results in temp allocator. |
|
||||||
| `diagnostic.odin` | Mustache-specific diagnostics: `context_extent` (scans `{{ }}` tags for caret underline), `format_tag_error` (wraps `diags.format_error` + `context_extent`), `format_render_error` (formats `Error` using `Template` source/path). Generic error formatting lives in `diagnostics/` package. |
|
| `diagnostic.odin` | Rust-style error formatter: `format_error` (multi-line context, ANSI colors via `core:terminal/ansi`, `colorize` param), `format_render_error` (formats `Error`), `line_col`, `line_text`, `context_extent`, `count_lines`, `digit_count`, `should_colorize`. |
|
||||||
| `suggest.odin` | Strict-warning helpers: `validate_key_path` (walks dotted path, crosses maps silently), `suggest_correction` (Levenshtein via `core:strings/levenshtein_distance`), `collect_struct_keys` (via reflection, recurses into `using`), `struct_has_field` (distinguishes missing field from nil value — needed for `Maybe(bool)`), `collect_partial_names`, `collect_block_names`. |
|
| `suggest.odin` | Strict-warning helpers: `validate_key_path` (walks dotted path, crosses maps silently), `suggest_correction` (Levenshtein via `core:strings/levenshtein_distance`), `collect_struct_keys` (via reflection, recurses into `using`), `struct_has_field` (distinguishes missing field from nil value — needed for `Maybe(bool)`), `collect_partial_names`, `collect_block_names`. |
|
||||||
| `spec_test.odin` | JSON spec test runner — loads `spec/specs/*.json`, runs each test case. Uses `log.nil_logger()` to suppress expected warnings. |
|
| `spec_test.odin` | JSON spec test runner — loads `spec/specs/*.json`, runs each test case. Uses `log.nil_logger()` to suppress expected warnings. |
|
||||||
|
| `lambda_test.odin` | Spec lambda tests |
|
||||||
| `pipes_test.odin` | Pipe filter tests (`group_by` + `format`) |
|
| `pipes_test.odin` | Pipe filter tests (`group_by` + `format`) |
|
||||||
| `diagnostic_test.odin` | Golden-output tests for `format_error` (multi-line context, edge cases, alignment, caret position, hint) + parser error message brace-escaping |
|
| `diagnostic_test.odin` | Golden-output tests for `format_error` (multi-line context, edge cases, alignment, caret position, hint) + parser error message brace-escaping |
|
||||||
| `suggest_test.odin` | Tests for `validate_key_path`, `suggest_correction`, `struct_has_field` with `Maybe(bool)` and `using`-promoted fields |
|
| `suggest_test.odin` | Tests for `validate_key_path`, `suggest_correction`, `struct_has_field` with `Maybe(bool)` and `using`-promoted fields |
|
||||||
@@ -451,11 +397,7 @@ render(tmpl, data, partials) → render_nodes (walks flat node array against con
|
|||||||
|
|
||||||
Rust-style error messages with multi-line source context, caret underlines, and Levenshtein suggestions. ANSI colors via `core:terminal/ansi`, gated on `should_colorize()` (TTY detection on stderr).
|
Rust-style error messages with multi-line source context, caret underlines, and Levenshtein suggestions. ANSI colors via `core:terminal/ansi`, gated on `should_colorize()` (TTY detection on stderr).
|
||||||
|
|
||||||
**Error types**: `Error_Body{msg, pos, kind, source, path, span, hint}` where `kind` is `Error_Kind.Syntax` (parse-time) or `Error_Kind.Data` (render-time). `source`/`path` carry the template the error originated in (set by `tag_error` — enables correct file/line for errors inside partials). `span` controls caret underline width (used by pipe op diagnostics). `hint` carries "did you mean?" suggestions. `Error` is a single-variant union wrapping `Error_Body` (nilable for `!= nil` / `or_return`).
|
**Error types**: `Error_Body{msg, pos, kind}` where `kind` is `Error_Kind.Syntax` (parse-time) or `Error_Kind.Data` (render-time). `Error` is a single-variant union wrapping `Error_Body` (nilable for `!= nil` / `or_return`).
|
||||||
|
|
||||||
**Error deduplication**: `render_template` takes `seen: ^map[string]bool`. Duplicate errors (same formatted diagnostic) are suppressed across page renders within a single build.
|
|
||||||
|
|
||||||
**Partial source tracking**: `tag_error(err, current)` stamps render-time errors with `current.source`/`current.path` at the 4 `apply_pipeline` call sites in `render_nodes`. Ensures errors inside partials point at the partial file, not the top-level template.
|
|
||||||
|
|
||||||
**Strict-by-default warnings** — `render_nodes` emits `log.warnf` diagnostics for:
|
**Strict-by-default warnings** — `render_nodes` emits `log.warnf` diagnostics for:
|
||||||
- Unknown keys in `{{k}}`, `{{{k}}}`, `{{#k}}`, `{{^k}}` (via `validate_key_path` + `suggest_correction`)
|
- Unknown keys in `{{k}}`, `{{{k}}}`, `{{#k}}`, `{{^k}}` (via `validate_key_path` + `suggest_correction`)
|
||||||
@@ -465,12 +407,18 @@ Rust-style error messages with multi-line source context, caret underlines, and
|
|||||||
|
|
||||||
**Exceptions** (no warning):
|
**Exceptions** (no warning):
|
||||||
- `{{.}}` and dot-prefixed names (current context)
|
- `{{.}}` and dot-prefixed names (current context)
|
||||||
- Paths that cross a map (e.g., `params.*`) — validated for typos via Levenshtein: close matches warn with a suggestion, genuinely absent keys are suppressed silently
|
- Paths that cross a map (e.g., `params.*` — user-defined namespace)
|
||||||
- `Maybe(bool)` fields with nil value (field exists, value is nil — distinguished via `struct_has_field`)
|
- `Maybe(bool)` fields with nil value (field exists, value is nil — distinguished via `struct_has_field`)
|
||||||
- Found fields whose value is nil/empty (e.g., nil `json.Value` union) — the field exists, looking up sub-keys is valid "not found" behavior
|
|
||||||
|
|
||||||
**Block override source tracking**: `Block_Override.source: Template` ensures warnings inside block overrides point at the override's source file (e.g., `page.html`), not the parent template (`base.html`).
|
**Block override source tracking**: `Block_Override.source: Template` ensures warnings inside block overrides point at the override's source file (e.g., `page.html`), not the parent template (`base.html`).
|
||||||
|
|
||||||
|
### Lambdas
|
||||||
|
|
||||||
|
Spec-compliant. Stored as `any` values in the data context.
|
||||||
|
|
||||||
|
- **Interpolation lambdas**: `proc() -> string`, `proc() -> int`, `proc() -> bool` — called via `call_interp_lambda`, result stringified and escaped.
|
||||||
|
- **Section lambdas**: `proc(string) -> string`, `proc(string) -> int`, `proc(string) -> bool` — called via `call_section_lambda` with the raw section text (`node.content`). String result is re-parsed as mustache and rendered against the current context stack.
|
||||||
|
|
||||||
### Pipes
|
### Pipes
|
||||||
|
|
||||||
`{{key | op args…}}` for interpolation, `{{#key | op args…}}…{{/key}}` for sections. Stored as `[dynamic; MAX_PIPES]Pipe_Filter` on each `Node`. Applied in the renderer via `apply_pipeline` before truthiness/interpolation. Implemented filters:
|
`{{key | op args…}}` for interpolation, `{{#key | op args…}}…{{/key}}` for sections. Stored as `[dynamic; MAX_PIPES]Pipe_Filter` on each `Node`. Applied in the renderer via `apply_pipeline` before truthiness/interpolation. Implemented filters:
|
||||||
@@ -487,13 +435,12 @@ See `mustache/EXTENSIONS.md`.
|
|||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
|
- cmark allocates via C malloc, not the arena. HTML output leaks until process exit (problematic in watch mode — see `TODOS.md`).
|
||||||
- CSS/JS cache busting uses manual `?v=N` query params instead of content hashing.
|
- CSS/JS cache busting uses manual `?v=N` query params instead of content hashing.
|
||||||
- Tree-sitter grammar/query paths must be configured manually via `thor.json` (`grammars`, `queries`) — no auto-discovery. HTML/CSS are statically linked.
|
- Tree-sitter grammar/query paths must be configured manually via `thor.json` (`grammars`, `queries`) — no auto-discovery. HTML/CSS are statically linked.
|
||||||
- `map[string]any` only works through `lookup_in`'s special-case handling; thor otherwise uses structs.
|
- `map[string]any` only works through `lookup_in`'s special-case handling; thor otherwise uses structs.
|
||||||
- `format_f64` in mustache brute-forces shortest float representation.
|
- `format_f64` in mustache brute-forces shortest float representation.
|
||||||
- Content directory not mounted in VFS (modules can ship templates/assets but not content packs yet).
|
- Content directory not mounted in VFS (modules can ship templates/assets but not content packs yet).
|
||||||
- Per-page params rendering is incomplete — `merge_params` produces correct data, but `base_value` may return nil for `json.Value` fields accessed through the `[]any` context stack via reflection in some cases. Warning suppression masks this; actual rendering may not work for all param values.
|
|
||||||
- Lambda support removed. Mustache lambdas (`proc() -> string` in data context) are not supported. Pipes cover data transformation.
|
|
||||||
|
|
||||||
## Design decisions
|
## Design decisions
|
||||||
|
|
||||||
@@ -501,23 +448,6 @@ You may never, *ever* remove `TODO:` or `FIXME:` comments. Those are for humans,
|
|||||||
See `HUGO.md` for analysis of why thor doesn't need Hugo's shortcode context isolation.
|
See `HUGO.md` for analysis of why thor doesn't need Hugo's shortcode context isolation.
|
||||||
See `mustache/SPEC.md` for the original implementation specification.
|
See `mustache/SPEC.md` for the original implementation specification.
|
||||||
See `mustache/EXTENSIONS.md` for non-standard extensions (pipes).
|
See `mustache/EXTENSIONS.md` for non-standard extensions (pipes).
|
||||||
See `DIAGNOSTICS.md` for the two-tier diagnostic system design.
|
|
||||||
See `mustache/TOKENIZERS.md` for tokenizer architecture comparison (Go, Liquid, Thor).
|
|
||||||
|
|
||||||
## Odin language facts
|
|
||||||
|
|
||||||
These are things that are easy to get wrong:
|
|
||||||
|
|
||||||
- **Proc arguments are immutable.** You cannot assign to a parameter directly. To get a mutable copy, shadow it: `x := x`. If you need to modify the source, pass a pointer `^x`.
|
|
||||||
- **`for` each loops use `item, idx` order**, not `idx, item`. Correct: `for item, idx in arr`. Wrong: `for idx, item in arr`.
|
|
||||||
- **`make([dynamic]T, n, allocator)` sets capacity, not length.** To get length=0 with capacity=n, use `make([dynamic]T, 0, n, allocator)`. Using `make([dynamic]T, n, allocator)` creates `len=n` with `n` zero-initialized elements.
|
|
||||||
- `#partial switch` is usually a code smell. prefer a `case all, extra, types:` branch.
|
|
||||||
- you don't usually need to create arena allocators in tests, instead use context.temp_allocator if you want to simplify cleanup.
|
|
||||||
- you don't need to manually set up a tracking allocator in tests. the context.allocator will warn you about leaks.
|
|
||||||
- **`Maybe(T)` unwrap syntax:** `value.? or_else default`. Not `value or_else default` — `or_else` works on the `?T` returned by `.?`, not on `Maybe(T)` directly.
|
|
||||||
- **`Maybe(T)` equality:** `a == b` works directly between two `Maybe(T)` values (nil == nil → true, some(5) == some(5) → true, nil == some(5) → false). Also `a == 5` works (int coerces to `Maybe(int)`).
|
|
||||||
- **File logger in tests:** `log.create_file_logger(&f)` + `context.logger = logger` captures log output. Must be set inline in the test proc (not via a helper proc) for context propagation. Clean up with `log.destroy_file_logger(logger)` then `os.read_entire_file_from_path` to verify output.
|
|
||||||
- **`fmt.sbprintf` writes directly to a `strings.Builder`.** Prefer `fmt.sbprintf(&sb, fmt, args...)` over `fmt.aprintf(fmt, args...)` + `defer delete` + `strings.write_string`. The `aprintf` pattern allocates an intermediate string, requires manual cleanup, and queues a `defer delete` per loop iteration. `sbprintf` avoids all of this.
|
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
|
|||||||
@@ -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,61 +0,0 @@
|
|||||||
# Diagnostics
|
|
||||||
|
|
||||||
Thor has two diagnostic tiers. This document explains why, and when to use each.
|
|
||||||
|
|
||||||
## Tier 1: Rust-style rich diagnostics (mustache engine)
|
|
||||||
|
|
||||||
`mustache/diagnostic.odin` implements multi-line source context, caret underlines, ANSI colors (gated on TTY detection), and Levenshtein suggestions. Used exclusively by the mustache engine for template errors:
|
|
||||||
|
|
||||||
- Unknown keys in `{{k}}`, `{{{k}}}`, `{{#k}}`, `{{^k}}`
|
|
||||||
- Missing partials (`{{> name}}`)
|
|
||||||
- Missing parent templates (`{{<name}}`)
|
|
||||||
- Unmatched block overrides (`{{$name}}`)
|
|
||||||
- Parse-time syntax errors
|
|
||||||
|
|
||||||
These benefit from rich diagnostics because **exact source location matters** — templates have complex syntax, and the user often doesn't know *where* the problem is. The diagnostic system operates on `Template.source` with byte offsets, producing output like:
|
|
||||||
|
|
||||||
```
|
|
||||||
error: unknown key 'titel' in {{page.titel}}
|
|
||||||
--> layouts/page.html:12:22
|
|
||||||
|
|
|
||||||
12 | <h1>{{page.titel}}</h1>
|
|
||||||
| ^^^^^
|
|
||||||
|
|
|
||||||
= hint: did you mean 'title'?
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tier 2: Simple log warnings (content and runtime)
|
|
||||||
|
|
||||||
Everything outside the mustache engine uses `log.warnf` — flat one-line messages via `core:log`:
|
|
||||||
|
|
||||||
- Missing frontmatter dates (fallback to file mtime)
|
|
||||||
- Duplicate menu weights
|
|
||||||
- Non-numeric weight values in frontmatter
|
|
||||||
- Tree-sitter highlight errors
|
|
||||||
- Menu system issues (mixing config/frontmatter menus, etc.)
|
|
||||||
|
|
||||||
These are simple, actionable, and cross-file. The problem isn't *location* — it's that two files disagree, or a value is missing. A caret pointing at one file doesn't help; the message already communicates what to fix:
|
|
||||||
|
|
||||||
```
|
|
||||||
[WARN] --- [menus.odin:290:warn_duplicate_weights()] menus('main'):'Ideas' and 'Stuff' share the same weight (11).
|
|
||||||
```
|
|
||||||
|
|
||||||
## Why not use rich diagnostics everywhere?
|
|
||||||
|
|
||||||
Rust's diagnostic model is built for a single compilation unit with full AST/IR data. Three obstacles prevent reusing it for content warnings:
|
|
||||||
|
|
||||||
1. **Source tracking**: The mustache diagnostic system operates on `Template.source` (byte offsets into template strings). Content warnings come from frontmatter in markdown files — different source, different parser, no position tracking. Reusing the system would require building a parallel position-tracking infrastructure for frontmatter.
|
|
||||||
|
|
||||||
2. **Cross-file context**: Rust diagnostics point at one location. Weight duplicates are a relationship between two files. Rich diagnostics would need to show *both* file locations, which is more infrastructure for marginal value.
|
|
||||||
|
|
||||||
3. **Diminishing returns**: Rust diagnostics shine for syntax/type errors where the user doesn't understand the failure. Content warnings are already self-explanatory — "these two pages share weight 11" doesn't need a caret to be actionable.
|
|
||||||
|
|
||||||
## When to upgrade a Tier 2 warning to Tier 1
|
|
||||||
|
|
||||||
If a warning's usefulness would significantly improve from showing exact source location (e.g., a frontmatter syntax error where the user needs to see *which line* is malformed), consider extending the diagnostic system to frontmatter. This would require:
|
|
||||||
|
|
||||||
1. Position tracking in `frontmatter.odin` (store byte offsets for each parsed field)
|
|
||||||
2. A `format_frontmatter_error` proc modeled on `format_render_error`
|
|
||||||
3. File path propagation through the page loading pipeline
|
|
||||||
|
|
||||||
This is not currently planned — see `TODOS.md`.
|
|
||||||
@@ -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:
|
### Why Hugo does this
|
||||||
1. weight (ascending)
|
|
||||||
2. date (descending)
|
1. **Circular dependencies** — Hugo shortcodes output into `.Content`. Layouts read `.Content` and compute derived properties (`.TableOfContents`, `.WordCount`, `.ReadingTime`). If shortcodes could access these computed properties, you'd get circular dependencies (shortcode output → computed property → shortcode reads it).
|
||||||
3. linkTitle falling back to title (ascending)
|
|
||||||
4. logical path (ascending)
|
2. **Multiple layouts/themes** — Hugo supports user-selectable themes and multiple layouts per section type. A shortcode must produce identical output regardless of which layout renders it. Isolating context guarantees portability.
|
||||||
|
|
||||||
|
3. **Shared mutable page object** — Hugo builds a rich `Page` object that both shortcodes and layouts access. Context isolation prevents shortcodes from mutating layout-visible state.
|
||||||
|
|
||||||
|
4. **Security** — Shortcodes live in content files (potentially user-authored). Full template access would let content authors inject arbitrary logic, access sensitive config, or break site structure.
|
||||||
|
|
||||||
|
5. **Caching** — Hugo caches shortcode output independently. Predictable context = predictable cache behavior.
|
||||||
|
|
||||||
|
## Why this doesn't apply to thor
|
||||||
|
|
||||||
|
- **No computed page properties** — The content Mustache pass runs on raw markdown (before cmark). The layout Mustache pass runs on finished HTML (after cmark). There's no shared `.Content` object or derived properties. The two passes are at different pipeline stages with no shared mutable state.
|
||||||
|
|
||||||
|
- **Single layout set** — Thor has one set of templates (`base.html` + page templates). No theme switching, no multiple layout variants per section. Portability across layouts isn't a concern.
|
||||||
|
|
||||||
|
- **Explicit data passing** — Each `mustache.render` call receives its own data struct. Content pass gets frontmatter + params. Layout pass gets page data + site config. No shared mutable object between them.
|
||||||
|
|
||||||
|
- **Single author** — Content is authored by the site owner. No untrusted user-generated content.
|
||||||
|
|
||||||
|
- **No caching** — Thor rebuilds from scratch every time. No cache invalidation concerns.
|
||||||
|
|
||||||
|
## Thor's approach
|
||||||
|
|
||||||
|
Thor runs Mustache on content **before** cmark as a pre-processing step, then runs Mustache on layout templates **after** cmark as a post-processing step. These are two independent `mustache.render` calls:
|
||||||
|
|
||||||
|
```
|
||||||
|
content (markdown)
|
||||||
|
→ mustache.render(content, content_data, partials) ← content pass
|
||||||
|
→ cmark markdown_to_html
|
||||||
|
→ inject_sidenotes / inject_alerts / highlight_code
|
||||||
|
→ mustache.render_in_layout(template, page_data, layout, partials) ← layout pass
|
||||||
|
→ final HTML
|
||||||
|
```
|
||||||
|
|
||||||
|
The content pass can use Mustache variables, partials (`{{> ./file}}`), sections, and conditionals freely. The layout pass wraps the rendered content in the page chrome. They share nothing except the data we explicitly choose to pass.
|
||||||
|
|
||||||
|
## When this WOULD matter for thor
|
||||||
|
|
||||||
|
If thor ever adds:
|
||||||
|
- Computed page properties (table of contents, reading time, word count)
|
||||||
|
- Multiple selectable themes/layouts
|
||||||
|
- User-generated content / multi-author support
|
||||||
|
- Partial template caching
|
||||||
|
|
||||||
|
...then context isolation between the content and layout passes would become relevant. Until then, it's unnecessary complexity.
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Timezone Support
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add timezone conversion to the `format` pipe so dates display in the configured site timezone, with correct abbreviations for the `MST` token.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Dates are stored as raw ISO 8601 strings. The `format` pipe formats them for display using Go-style layout tokens. Currently `parse_iso_date` ignores the timezone offset suffix, and `MST` is hardcoded to `"UTC"`.
|
||||||
|
|
||||||
|
Pipes now take `ctx: []any`, so timezone config can flow through the data context identically to `date_format` — no new parameters to thread.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- **MST fallback** (no target tz, date has offset): `UTC-04:00` format
|
||||||
|
- **`now` field**: intentionally UTC (offset=0). The `format` pipe handles timezone display.
|
||||||
|
- **TZ_Region cache**: lives in the mustache package (`format.odin`)
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|---|---|
|
||||||
|
| `mustache/format.odin` | Extend `Date_Components` (+`offset_seconds`, `has_offset`, `tz_abbr`). Extend `parse_iso_date` to parse trailing offset. Add `tz_cache`, `get_cached_tz`, `convert_to_tz`, `format_offset`, `destroy_tz_cache`. Fix MST token. Import `core:time/timezone`. |
|
||||||
|
| `mustache/pipes.odin` | Resolve `date_timezone` from ctx in `"format"` case (optional — nil is fine). Add `timezone_name` param to `apply_format`. Orchestrates parse → convert → format. |
|
||||||
|
| `render.odin` | Add `date_timezone: string` to `Base_Data`, populate from `site.date.timezone`. Remove `// TODO: CAlculate offset` (offset=0 is intentional — UTC). |
|
||||||
|
| `site.odin` | Call `mustache.destroy_tz_cache()` from `destroy_site`. No structural changes — `Date_Preferences.timezone` already exists and flows through config. |
|
||||||
|
| `mustache/pipes_test.odin` | Add `date_timezone: string` to test data structs. Add timezone conversion tests. |
|
||||||
|
|
||||||
|
## Conversion logic (in `apply_format`)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. parse_iso_date(iso) → components (now includes offset_seconds, has_offset)
|
||||||
|
2. tz_name := resolve "date_timezone" from ctx (optional)
|
||||||
|
3. target_tz := get_cached_tz(tz_name) // nil if empty/UTC/not configured
|
||||||
|
4. if target_tz != nil:
|
||||||
|
components = convert_to_tz(components, target_tz)
|
||||||
|
// tz_abbr filled by convert_to_tz via timezone.shortname()
|
||||||
|
5. else if components.has_offset:
|
||||||
|
components.tz_abbr = format_offset(components.offset_seconds)
|
||||||
|
// e.g. "UTC-04:00"
|
||||||
|
6. else:
|
||||||
|
components.tz_abbr = "UTC"
|
||||||
|
7. format_date(components, fmt)
|
||||||
|
```
|
||||||
|
|
||||||
|
## `convert_to_tz` flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Build DateTime from components (tz=nil=UTC)
|
||||||
|
2. If has_offset: add offset_seconds to get true UTC
|
||||||
|
3. timezone.datetime_to_tz(utc_dt, target_tz) → converted DateTime
|
||||||
|
4. Extract components from converted DateTime
|
||||||
|
5. tz_abbr = timezone.shortname(converted_dt) // "EST", "EDT", etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
## MST fallback: `format_offset`
|
||||||
|
|
||||||
|
```
|
||||||
|
0 → "UTC"
|
||||||
|
-14400 → "UTC-04:00"
|
||||||
|
+19800 → "UTC+05:30"
|
||||||
|
```
|
||||||
|
|
||||||
|
## `now` field
|
||||||
|
|
||||||
|
`now` stays UTC (offset=0). Remove the `// TODO: CAlculate offset` comment — it's correct as-is. Templates format it with `{{now | format}}` and the pipe handles timezone display.
|
||||||
|
|
||||||
|
## Behavior matrix
|
||||||
|
|
||||||
|
| Config TZ | ISO has offset | Conversion | `MST` output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `"America/New_York"` | yes (`-04:00`) | UTC → NY (DST-aware) | `"EST"`/`"EDT"` |
|
||||||
|
| `"America/New_York"` | no | assume already in NY | `"EST"`/`"EDT"` |
|
||||||
|
| not set | yes (`-04:00`) | none — display as-is | `"UTC-04:00"` |
|
||||||
|
| not set | no | none | `"UTC"` |
|
||||||
|
|
||||||
|
## Imports added
|
||||||
|
|
||||||
|
- `mustache/format.odin`: `import "core:time/timezone"` (for `region_load`, `datetime_to_tz`, `shortname`, `region_destroy`)
|
||||||
|
|
||||||
|
## Odin timezone API reference
|
||||||
|
|
||||||
|
- `timezone.region_load(name: string) -> (^datetime.TZ_Region, bool)` — `"local"` reads `$TZ` env, falls back to `/etc/localtime`
|
||||||
|
- `timezone.region_destroy(region: ^datetime.TZ_Region)`
|
||||||
|
- `timezone.datetime_to_tz(dt: DateTime, tz: ^TZ_Region) -> (DateTime, bool)` — DST-aware. If `dt.tz == tz`, no-op. If `dt.tz == nil`, treats as UTC.
|
||||||
|
- `timezone.shortname(dt: DateTime) -> (string, bool)` — abbreviation from TZ_Region records (e.g. `"EST"`, `"EDT"`)
|
||||||
|
- `datetime.DateTime :: struct { using date: Date, using time: Time, tz: ^TZ_Region }` — `tz == nil` means UTC
|
||||||
-317
@@ -1,317 +0,0 @@
|
|||||||
# Thor — UX Problems Catalog
|
|
||||||
|
|
||||||
Adversarial review of error messages, behavioral inconsistencies, and user
|
|
||||||
frustration points. Established as a baseline on commit `314cab2`.
|
|
||||||
|
|
||||||
Each entry cites the source location so it can be tracked to a fix.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Severity legend
|
|
||||||
|
|
||||||
- **Critical** — user mistake produces silent wrong output or an unhelpful
|
|
||||||
fatal error with no path forward.
|
|
||||||
- **High** — error or warning is emitted but missing "where" or "how to fix."
|
|
||||||
- **Medium** — inconsistency or gotcha that causes confusion or rework.
|
|
||||||
- **Low** — polish / minor frustration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. Silent wrong output (no error, wrong result)
|
|
||||||
|
|
||||||
These are the most dangerous — the user gets *no signal* that something is wrong.
|
|
||||||
|
|
||||||
### A1. Non-JSON frontmatter silently treated as body content — Critical
|
|
||||||
`frontmatter.odin:26`
|
|
||||||
|
|
||||||
Thor expects JSON frontmatter delimited by bare `{` / `}` lines. A user
|
|
||||||
coming from Hugo/Jekyll writes YAML (`---`) or TOML (`+++`) frontmatter. It is
|
|
||||||
silently swallowed into the markdown body. No title, no date, no draft flag —
|
|
||||||
and no error. Likely the #1 onboarding trap.
|
|
||||||
|
|
||||||
### A2. Unknown `thor.json` keys silently ignored — Critical
|
|
||||||
`site.odin:162`
|
|
||||||
|
|
||||||
`json.unmarshal_string` skips unknown fields. A typo like `"tittle"` instead
|
|
||||||
of `"title"` produces a silently-empty title. No warning. (`TODOS.md` already
|
|
||||||
wants a JSON schema.)
|
|
||||||
|
|
||||||
### A3. Draft pages silently excluded — High
|
|
||||||
`content.odin:64`
|
|
||||||
|
|
||||||
When `-drafts` isn't passed, draft pages vanish with no log. User adds a
|
|
||||||
page, forgets the flag, page doesn't appear — zero feedback.
|
|
||||||
|
|
||||||
### A4. Naive singularization for layout inference — High
|
|
||||||
`content.odin:202`
|
|
||||||
|
|
||||||
`posts` → `post` (correct), but `series` → `serie`, `news` → `new`. The
|
|
||||||
layout silently falls through the fallback chain to `page`/`base`. No
|
|
||||||
"layout 'serie' not found for section 'series'" message — only a debug log
|
|
||||||
that's off by default.
|
|
||||||
|
|
||||||
### A5. `base_url` defaults to `localhost:8080` — Critical
|
|
||||||
`site.odin:100`
|
|
||||||
|
|
||||||
Forgetting to set it means every canonical URL, OG tag, and RSS link points
|
|
||||||
to localhost. No warning. Devastating in production builds.
|
|
||||||
|
|
||||||
### A6. Missing `content/` produces empty build — High
|
|
||||||
`content.odin:82`
|
|
||||||
|
|
||||||
`scan_content_files` logs a `warnf`, the build proceeds with zero pages, then
|
|
||||||
`log.infof("Rendered 0 pages")`. No fatal error, no "did you create
|
|
||||||
content/?" guidance.
|
|
||||||
|
|
||||||
### A7. RSS emits sentinel epoch date silently — Medium
|
|
||||||
`feed.odin:33`
|
|
||||||
|
|
||||||
Pages without a date get `"Mon, 01 Jan 0001 00:00:00 +0000"` in `<pubDate>`.
|
|
||||||
No warning that a page is dateless in the feed.
|
|
||||||
|
|
||||||
### A8. `format_rfc822` returns raw ISO on parse failure — Medium
|
|
||||||
`feed.odin:122-125`
|
|
||||||
|
|
||||||
`// TODO: should indicate error somehow` — short/malformed dates get embedded
|
|
||||||
verbatim in `<pubDate>`, producing invalid RSS with no warning.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. Error messages missing "where" or "how to fix"
|
|
||||||
|
|
||||||
### B1. `render_template` blanks the entire page on error — Critical
|
|
||||||
`render.odin:119-133`
|
|
||||||
|
|
||||||
A single bad tag/pipe anywhere produces `log.errorf` + `return ""`. The
|
|
||||||
output file is silently written empty. In a `nix build` (no visible
|
|
||||||
terminal), the user sees a blank page with zero clue why. Already noted in
|
|
||||||
`TODOS.md`.
|
|
||||||
|
|
||||||
### B2. Malformed `thor.json` degrades to defaults — Critical
|
|
||||||
`site.odin:162-168`
|
|
||||||
|
|
||||||
A JSON syntax error is a `warnf`, then `site_apply_path_defaults` kicks in.
|
|
||||||
The site builds with wrong paths and produces a confusing empty result — the
|
|
||||||
cause is two hops removed from the symptom.
|
|
||||||
|
|
||||||
### B3. Frontmatter parse error has no file location — Critical
|
|
||||||
`frontmatter.odin:41`
|
|
||||||
|
|
||||||
`"failed to parse frontmatter JSON: %v"` — no filename. On a 100-post site
|
|
||||||
the user can't find the bad file. Worse: `ok=false` silently drops the page
|
|
||||||
entirely.
|
|
||||||
|
|
||||||
### B4. `get_template` returns empty `Template{}` on missing base — High
|
|
||||||
`render.odin:88-89`
|
|
||||||
|
|
||||||
`"base.html not found in VFS"` — no guidance on how to fix (create the file,
|
|
||||||
check modules, etc.).
|
|
||||||
|
|
||||||
### B5. `dlopen` failures lack the OS reason and fix guidance — High
|
|
||||||
`treesitter/treesitter.odin:200-219`
|
|
||||||
|
|
||||||
"cannot load grammar %s (%s)" shows the path but not *why* (no `dlerror()`).
|
|
||||||
No guidance: "set the 'grammars' key in thor.json" or "this .so may be for a
|
|
||||||
different tree-sitter ABI."
|
|
||||||
|
|
||||||
### B6. Menu-mix fatal lacks location — High
|
|
||||||
`menus.odin:42`
|
|
||||||
|
|
||||||
`"cannot mix config menus with frontmatter menus"` — doesn't name which pages
|
|
||||||
have frontmatter menus.
|
|
||||||
|
|
||||||
### B7. Minify error doesn't name the page — Medium
|
|
||||||
`minify.odin:33`
|
|
||||||
|
|
||||||
"minify: HTML parse errors, skipping minification" — across 50 pages, which
|
|
||||||
one?
|
|
||||||
|
|
||||||
### B8. Timezone load failure is a warning with no guidance — Medium
|
|
||||||
`site.odin:148`
|
|
||||||
|
|
||||||
Doesn't state impact (dates render in UTC) or suggest valid names. Already
|
|
||||||
in `TODOS.md`.
|
|
||||||
|
|
||||||
### B9. No "config not found" message — Medium
|
|
||||||
`site.odin:113`
|
|
||||||
|
|
||||||
Silently falls back to `./thor.json`. Wrong-directory runs produce a
|
|
||||||
confusing default build.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. Silent skip of invalid user input
|
|
||||||
|
|
||||||
### C1. Unknown markdown extensions silently ignored (CLI) — High
|
|
||||||
`markdown/markdown.odin:49-68`
|
|
||||||
|
|
||||||
`parse_extension_list` has a switch with no default case. `-ext:higlight`
|
|
||||||
(typo for `highlight`) is silently a no-op.
|
|
||||||
|
|
||||||
### C2. Unknown markdown extensions silently ignored (config) — High
|
|
||||||
`markdown/markdown.odin:71-90`
|
|
||||||
|
|
||||||
`apply_extension_config` has a `// TODO: Silently discards invalid values.`
|
|
||||||
Unknown keys in `thor.json`'s `markdown_extensions` are silently dropped.
|
|
||||||
Non-boolean values are `or_continue`d.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. Naming inconsistencies
|
|
||||||
|
|
||||||
### D1. Markdown extensions have 3+ names — Medium
|
|
||||||
|
|
||||||
| Context | Name |
|
|
||||||
|---|---|
|
|
||||||
| `thor.json` key | `markdown_extensions` |
|
|
||||||
| CLI flag | `-ext` / `-no-ext` |
|
|
||||||
| Struct fields | `md_enable` / `md_disable` |
|
|
||||||
| JSON/CLI values | `emoji`, `sidenotes` (lowercase) |
|
|
||||||
| Enum members | `.Emoji`, `.Sidenotes` (PascalCase) |
|
|
||||||
|
|
||||||
### D2. `-ext` usage string omits `heading_ids` — Medium
|
|
||||||
`site.odin:91`
|
|
||||||
|
|
||||||
The help text lists `emoji,sidenotes,alerts,highlight,sections` but the enum
|
|
||||||
also has `HeadingIDs`. Users can't discover it from `--help`.
|
|
||||||
|
|
||||||
### D3. Starred field has three names — Low
|
|
||||||
- `Page.starred` (`content.odin:28`)
|
|
||||||
- `Frontmatter.isStarred` (`frontmatter.odin:17`) — so the JSON key is `isStarred`
|
|
||||||
- `AGENTS.md:101` says `is_starred` (stale)
|
|
||||||
|
|
||||||
### D4. Inconsistent error severity for similar failures — Medium
|
|
||||||
- Template **parse** error → `log.errorf` + `os.exit(1)` (fatal) — `render.odin:37-49`
|
|
||||||
- Template **render** error → `log.errorf` + return `""` (non-fatal, blank page) — `render.odin:126-131`
|
|
||||||
- Config parse error → `warnf` + fallback to defaults — `site.odin:163-165`
|
|
||||||
|
|
||||||
Same category of failure (user wrote something wrong) with wildly different
|
|
||||||
consequences.
|
|
||||||
|
|
||||||
### D5. Dead `os.exit(1)` after `log.fatalf` — Low
|
|
||||||
`render.odin:33`, `menus.odin:43`
|
|
||||||
|
|
||||||
`fatalf` already exits; the following line is dead code.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. Configuration gotchas
|
|
||||||
|
|
||||||
### E1. `"menus": {}` is a stealth opt-out — Medium
|
|
||||||
`menus.odin:31-38`
|
|
||||||
|
|
||||||
An empty object silently disables *all* auto-menus. A user who adds the key
|
|
||||||
intending to configure later quietly loses their nav. The semantics (absent
|
|
||||||
≠ empty) are undocumented outside code comments.
|
|
||||||
|
|
||||||
### E2. Config precedence is invisible — Medium
|
|
||||||
`site.odin`
|
|
||||||
|
|
||||||
CLI > JSON > defaults, but there's no "resolved config" log. Debugging "why
|
|
||||||
is my base_url wrong?" requires reading source.
|
|
||||||
|
|
||||||
### E3. `format` pipe logs ERROR but still renders — Medium
|
|
||||||
`pipes.odin:261-265`
|
|
||||||
|
|
||||||
Missing `date.format` produces `log.errorf` but falls back to
|
|
||||||
`DEFAULT_DATE_FORMAT`. The severity says "error" but the behavior says
|
|
||||||
"warning."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. File/directory behavior surprises
|
|
||||||
|
|
||||||
### F1. Root dirs = sections, nested dirs = leaf bundles — Medium
|
|
||||||
`content.odin:108-127`
|
|
||||||
|
|
||||||
This meaningful semantic distinction is entirely implicit.
|
|
||||||
`content/about/team.md` is a leaf bundle (page "about" with body from
|
|
||||||
team.md), not a section "about" with page "team". No error or guidance when
|
|
||||||
the user's mental model differs.
|
|
||||||
|
|
||||||
### F2. Missing `layouts/` silently uses defaults — High
|
|
||||||
`vfs.odin:38-40`
|
|
||||||
|
|
||||||
`mount_dir` returns silently if the directory doesn't exist. Wrong path →
|
|
||||||
all user templates missing → defaults used. No "layouts directory X not
|
|
||||||
found" message.
|
|
||||||
|
|
||||||
### F3. Missing section index silently synthesized — Medium
|
|
||||||
`render.odin:316-326`
|
|
||||||
|
|
||||||
A section with pages but no `index.md` gets a synthetic `Page` with only a
|
|
||||||
title. No warning. User expecting an error gets a mostly-blank page.
|
|
||||||
|
|
||||||
### F4. Windows line endings silently break frontmatter — Medium
|
|
||||||
`frontmatter.odin:26`
|
|
||||||
|
|
||||||
`has_prefix(content, "{\n")` fails on `\r\n`; the JSON is treated as body.
|
|
||||||
Zero feedback.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## G. Template authoring frustrations
|
|
||||||
|
|
||||||
### G1. Template fallback chain is silent at Info level — Medium
|
|
||||||
`render.odin:84`
|
|
||||||
|
|
||||||
Only `log.debugf`, which is off by default (`main.odin:48` sets `.Info`).
|
|
||||||
User's custom layout silently ignored, defaults used.
|
|
||||||
|
|
||||||
### G2. Render error blanks entire page — Critical
|
|
||||||
`render.odin:126-131`
|
|
||||||
|
|
||||||
(Same as B1 — restated here for the template-authoring perspective.) One bad
|
|
||||||
tag → whole page `""`. The most impactful silent failure in the system.
|
|
||||||
|
|
||||||
### G3. Pipe errors lack "did you mean?" suggestions — High
|
|
||||||
`mustache/pipes.odin:241`
|
|
||||||
|
|
||||||
Unknown key errors (`{{tittle}}`), missing partials, and unmatched block
|
|
||||||
overrides all get Levenshtein "did you mean?" hints via `suggest_correction`.
|
|
||||||
But unknown pipe operations (`{{date | formats}}`) get only `"unknown pipe op
|
|
||||||
'formats'"` with no suggestion. The known filter names (`"format"`,
|
|
||||||
`"group_by"`) are a small fixed set — perfect for suggestions.
|
|
||||||
|
|
||||||
Structural gap: `Error_Body` has no `hint` field, and
|
|
||||||
`format_render_error` doesn't pass `hint` to `format_error` (defaults to
|
|
||||||
`""`). So even if a suggestion were computed, there's nowhere to put it
|
|
||||||
without either appending to `msg` or adding `hint` to `Error_Body`.
|
|
||||||
|
|
||||||
### G4. Triple-mustache `{{{` mishandled by `tag_content_base` — Medium
|
|
||||||
`mustache/mustache.odin:230`
|
|
||||||
|
|
||||||
`tag_content_base` skips `{{` and sigils (`#^/&><$!`) to find where tag
|
|
||||||
content begins. But triple-mustache `{{{key}}}` is common — after `{{`,
|
|
||||||
the next char is `{`, which is not in the sigil list, so `base` points at
|
|
||||||
`{` instead of the actual key content. Any pipe position calculation for
|
|
||||||
`{{{key | format}}}` will be off by one byte.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cross-cutting themes
|
|
||||||
|
|
||||||
1. **Debug-level logging masks important fallbacks.** Layout fallbacks,
|
|
||||||
template misses, and grammar skips are all `debugf` — invisible at the
|
|
||||||
default Info level. Users never learn their customizations were ignored.
|
|
||||||
|
|
||||||
2. **The system fails open, not closed.** Missing files, missing
|
|
||||||
directories, missing config — all silently fall back to defaults rather
|
|
||||||
than surfacing the problem. Friendly until it isn't.
|
|
||||||
|
|
||||||
3. **No "resolved state" visibility.** There's no way for a user to see what
|
|
||||||
thor actually loaded: which layouts, which config values, which pages
|
|
||||||
were skipped as drafts. The build is a black box.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Gold-standard examples to emulate
|
|
||||||
|
|
||||||
These are the parts of the codebase that already do it right:
|
|
||||||
|
|
||||||
- **Mustache diagnostics** (`mustache/diagnostic.odin` + `suggest.odin`):
|
|
||||||
rust-style multi-line context, caret underlines, Levenshtein "did you
|
|
||||||
mean?" hints, file:line:col.
|
|
||||||
- **Treesitter query version-mismatch** (`treesitter/treesitter.odin:299-315`):
|
|
||||||
explains the likely cause, shows both grammar/query versions, flags
|
|
||||||
mismatches explicitly.
|
|
||||||
-131
@@ -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,204 @@
|
|||||||
# TODOs
|
## High priority
|
||||||
|
|
||||||
## Priority Unclear (Evaluate Later)
|
- Polish existing features before moving on to new ones.
|
||||||
|
- [ ] Improve diagnostics
|
||||||
|
- [x] Simplify / unify template context stack. Come up with a name for it.
|
||||||
|
- [x] `render_template` should accept `Template_Context`, not `any`
|
||||||
|
- [ ] Load grammars dynamically
|
||||||
|
- [ ] consider adding a limit to the context stack in mustache.
|
||||||
|
- [ ] better diagnostics for syntax errors in treesitter.
|
||||||
|
- [x] Add heading ids as a default on extension.
|
||||||
|
- [ ] show "stack traces" in template error diagnostics
|
||||||
|
- [ ] starred must be a param.
|
||||||
|
- [ ] Add a `#config(MAX_CONTEXT_DEPTH, 16?)` to `mustache`.
|
||||||
|
- [ ] menu system
|
||||||
|
- [ ] like Hugo's, but warn(/fail?) if menus are defined in the config *and* pages.
|
||||||
|
- i.e. force the user to choose one or the other.
|
||||||
|
|
||||||
- [ ] Decide the parallel-parsing strategy for the grammar cache (pool vs
|
## Performance
|
||||||
thread-local parsers) — deferred until the parallel usage shape is clear.
|
|
||||||
- Resolved already: the registry (`grammar()`) is thread-safe and self-
|
- [ ] See if we can disable bounds checks in `write_indented` and elsewhere.
|
||||||
initializing, its allocator is pinned to the heap, and parsers/cursors are now
|
- [ ] Instead of loading the site fresh each time in watch mode, create a
|
||||||
caller-owned (`open_parser`) instead of cached — so the library holds no
|
`reload_site` proc, that just updates changed resources.
|
||||||
single-thread-use state and the multi-threaded tests pass without locks.
|
- [ ] Only publish referenced assets.
|
||||||
- Remaining choice only matters once we actually parse in parallel: reuse
|
- [ ] Split `load_page` into frontmatter-parse + body-process phases so draft pages can skip the markdown pipeline entirely
|
||||||
parsers via a pool or per-thread storage, plugged in behind `open_parser`
|
- [ ] Use spall to find ways to reduce run time.
|
||||||
without touching call sites. A `TSParser`/`TSQueryCursor` is one-per-thread.
|
- [ ] too many `write_string` calls in `highlight_block`
|
||||||
- Full write-up: `GRAMMAR_CACHE_THREADING.md`.
|
- [ ] return `src: cstring` from `load_query`.
|
||||||
- [x] Restore parallel grammar preload (serialized in `edb2e64`); `preload_grammars`
|
- [ ] Improve `unescape_html` with simd.
|
||||||
now fans `build_grammar` across a bounded `core:thread.Pool`.
|
- [ ] generate summary before syntax highlighting.
|
||||||
- [ ] Add tests: pool fan-out correctness + `-sanitize:thread` for race-freedom.
|
- [ ] generate summary before markdown to html conversion.
|
||||||
- [ ] Concurrent-read path: an `RW_Mutex` on `grammar()` only helps a future
|
- [ ] mount_recursive is pretty significant
|
||||||
multi-threaded render, not the load side. Revisit if render goes parallel.
|
- [ ] thread pool for grammar loading is unbounded.
|
||||||
- [ ] instead of `warnings: [dynamic]Error` we should use `warnings: [dynamic; 8]Error`
|
- [ ] load grammars async.
|
||||||
- when limit reached, the template fails and stops rendering.
|
- [ ] during `load_page`:
|
||||||
- [ ] Brainstorm plugin/tool/(postccs/less/tailwind/sass) support.
|
- pass each code block to the treesitter queue
|
||||||
- [ ] `fingerprint` pipe + contents-based composition. The `tool` pipe currently
|
- continue working on the page,
|
||||||
carries a URL string and each stage publishes a file to the output dir, so
|
- `await` the highlighted code.
|
||||||
`| tool sass | fingerprint` would leave a dead unhashed intermediate. When
|
- [ ] can markdown extensions run in parallel?
|
||||||
fingerprint lands, move the pipe value from a URL string to a contents/
|
- [ ] enforce MAX_SLUG_LENGTH
|
||||||
Resource (bytes + logical name) where only the terminal stage publishes;
|
|
||||||
a Resource must stringify to its URL so existing bare-`tool` templates keep
|
## Remove Privileged content
|
||||||
working. stdin/stdout tools compose best there (bytes stay in memory), which
|
|
||||||
is why stdin/stdout is the documented preferred form over `{{in}}`/`{{out}}`.
|
- [ ] `group_by` currently requires a computed `year` field on the page.
|
||||||
- [ ] Reconsider `"menus": {}` opt-out: it currently discards all page frontmatter
|
- We should replace this with `{{ pages | group_by (date | "2006") }}` or similar
|
||||||
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.
|
## Memory Management
|
||||||
- should we at least warn about them?
|
|
||||||
- [ ] Review the whole `copy_assets_dir` pipeline. It copies every file under
|
- [ ] Leaks in highlighter code.
|
||||||
`assets/` to the output dir blindly, whether or not anything references it,
|
- [ ] Not sure whether to use temp allocator or site_allocator in opengraph.odin.
|
||||||
and now also skips dests that already exist (a blunt collision guard so the
|
- [ ] Not sure whether to use temp allocator or site_allocator in `site_load_content`.
|
||||||
raw copy doesn't clobber `tool`-pipe output written earlier in the build).
|
- [ ] Might not need to allocate in `strip_html_tags`
|
||||||
Reconsider tracking which assets are actually used/published instead.
|
- [ ] Fix `apply_filter`'s `format` case (`mustache/pipes.odin`) boxing `apply_format`'s
|
||||||
- [ ] warn if stylesheet url starts with /assets (and is therefore invalid).
|
`string` result into `any` via bare `return`, which materializes a hidden
|
||||||
- [ ] warn when heading levels are skipped in a toc.
|
header temp in `apply_filter`'s own stack frame. Dangling once the frame
|
||||||
- [ ] extract `[TOC]` from `.md` body (or at least from the start.)
|
returns; caused the `-o:speed` segfault in `write_value`. Fix: box explicitly
|
||||||
- [ ] add `--production` flag
|
with `any{new_clone(formatted, context.temp_allocator), typeid_of(string)}`.
|
||||||
- set `minify` to true
|
- [ ] Same pattern in `apply_group_by` (`mustache/pipes.odin`): `return groups, nil`
|
||||||
- warn when `base_url` unset
|
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?
|
||||||
|
- [ ] show a proper diagnostic for timezones
|
||||||
|
- currently "unable to load timezone 'America/New_Yorkskie'"
|
||||||
|
- want rust style diagnostic and better message, maybe "unknown timezone 'America/New_Yorkskie'"
|
||||||
|
|
||||||
## General
|
## General
|
||||||
|
- [ ] 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
|
main :: proc () {
|
||||||
`#load_directory` so the binary is self-contained and doesn't read
|
x: X
|
||||||
`DEFAULTS_PATH` from disk at runtime.
|
|
||||||
- `#load_directory` is NOT recursive and its `name` is the base filename
|
x.foo = x.foo or_else "bar"
|
||||||
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
|
- [ ] Integrity hash
|
||||||
the need to ship `defaults/` alongside the binary (see `flake.nix`).
|
- Allows users to verify their output didn't change after upgrading to a new version
|
||||||
- [ ] `build` command alias of default
|
- [ ] Content-hash fingerprinting for CSS and JS cache busting
|
||||||
- [ ] `new site` command to set up new project
|
- [ ] merge `render_{section,home_html,page_html}` procs.
|
||||||
- [ ] Some render errors blank the entire page instead of scoping to the failing tag.
|
- [ ] try to combine render_page_html and render_home_html?
|
||||||
|
- [ ] Avoid `json.Value` / `json.Object` where possible.
|
||||||
|
- [ ] Create a json schema file for `thor.json`.
|
||||||
|
- [ ] make `parse` an overload of `parse_text/parse_inline` and `parse_file`, or something.
|
||||||
|
- [ ] Add page params
|
||||||
- [ ] We must remove all mention of `posts` from the odin code.
|
- [ ] We must remove all mention of `posts` from the odin code.
|
||||||
At present, "posts" are a user-level construct defined as pages in a
|
At present, "posts" are a user-level construct defined as pages in a
|
||||||
particular collection.
|
particular collection.
|
||||||
- [ ] ? Clean up the default layouts (no idea what this means)
|
|
||||||
- [ ] `group_by` currently requires a computed `year` field on the page.
|
|
||||||
- We should replace this with `{{ pages | group_by (date | "2006") }}` or similar
|
|
||||||
- [ ] does it make sense for partials to be inside layouts?
|
|
||||||
- current: `layouts/partials`
|
|
||||||
- alt1: `layouts`, `partials`
|
|
||||||
- alt2: `templates/layouts`, `templates/partials`
|
|
||||||
- [ ] add `sort_by` pipe
|
|
||||||
- [ ] pipes
|
|
||||||
- [x] first/last
|
|
||||||
- [ ] write test for error when n == 0
|
|
||||||
- [x] warn when using negative numbers
|
|
||||||
- [x] warn when using default arg on string
|
|
||||||
- [x] add the above warnings to DIAGNOSTIC_TESTS.md
|
|
||||||
- [ ] Instead of "clearer form", be exact: "last 5"
|
|
||||||
- [ ] write tests for `first 0` & `last 0`
|
|
||||||
- [ ] update tests to assert diagnostics when appropriate
|
|
||||||
- do we want to do this or just use DIAGNOSTIC_TESTS.md as the tests?
|
|
||||||
- [ ] also update to use file logger so they don't clog up the output
|
|
||||||
- [ ] narrow highlight in diagnostic to relevent part of line.
|
|
||||||
- [ ] page.url and page.permalink are confusing names. Switch to url + page_url
|
|
||||||
- There is confusion over how to do this and whethor or not `| rel_url` should be needed to render these.
|
|
||||||
Requires **careful thought**.
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- [ ] talk about the context stack (and its limit).
|
|
||||||
- [ ] highlight the differences in the way menus are handled.
|
|
||||||
- [ ] explain that html/css/js comments are rendered, except in minify mode.
|
|
||||||
|
|
||||||
## Investigate
|
|
||||||
|
|
||||||
- [ ] running ./thor/thor still logs the debug message: using config /home/spencer/github.com/sbrow.github.io/thor.json
|
- [ ] running ./thor/thor still logs the debug message: using config /home/spencer/github.com/sbrow.github.io/thor.json
|
||||||
- wrong cwd?
|
- wrong cwd?
|
||||||
|
- [ ] Clean up the default layouts
|
||||||
|
- [ ] if no `html` tag detected in output, re-render output with base template
|
||||||
|
(or whatever template is next in the chain)
|
||||||
|
- [ ] Add `-production` flag
|
||||||
|
- sets `-minify`
|
||||||
|
- [ ] Mustache diagnostics
|
||||||
|
- [ ] Partial invocation stack in diagnostics: when an error fires inside a partial, show "invoked from" chain through `{{> name}}` calls. Currently warnings inside partials point at the partial (correct file) but don't show the invocation site.
|
||||||
|
- [ ] Could be better error message when missing a closing (or opening) brace
|
||||||
|
- [ ] Error message doesn't show position of faulty pipe name correctly.
|
||||||
|
- [ ] `render_template` (`render.odin`) blanks the *entire page* to `""` on any
|
||||||
|
mustache render error and only `log.errorf`s it — a single bad tag/pipe
|
||||||
|
anywhere on the page silently kills the whole output with no visible
|
||||||
|
signal outside the terminal log. Should at least be scoped to the
|
||||||
|
failing tag/section, or surfaced somewhere the person building the
|
||||||
|
site will actually see it.
|
||||||
|
```bash
|
||||||
|
[ERROR] --- [138:render_template()] unknown pipe op 'formats'
|
||||||
|
--> /home/spencer/github.com/sbrow.github.io/layouts/home.html:1:1
|
||||||
|
|
|
||||||
|
1 | {{<base}}
|
||||||
|
| ^^^^^^^^^
|
||||||
|
2 | {{$content}}
|
||||||
|
3 | <main>
|
||||||
|
```
|
||||||
|
- [ ] Block attributes on code fences (`{ #ex-1 }`) — hello-world.md
|
||||||
|
- [ ] include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin
|
||||||
|
- [ ] follow symlinks in `scan_content`?
|
||||||
|
- [ ] ensure sidenote numbers render in display order and not in declaration order.
|
||||||
|
- [ ] Table of contents support.
|
||||||
|
- [ ] Nav items should be active when the current page is selected.
|
||||||
|
- [ ] Theme selector for syntax highlighting.
|
||||||
|
- use http://github.com/helix-editor/helix/tree/master/runtime/themes) as a
|
||||||
|
guide
|
||||||
|
- [ ] grammars
|
||||||
|
- [ ] Search in multiple places
|
||||||
|
- [ ] Download missing grammars.
|
||||||
|
- [ ] Durable highlight paths: read `GRAPHS_PATH`/`QUERIES_PATH` from env vars set by the flake instead of hardcoded nix store hashes, so they survive `nix flake update` and let the grammar/query version-mismatch detector fire automatically.
|
||||||
|
- [ ] CI
|
||||||
|
- [ ] Syntax highlighting in production: CI (`nix build` on ubuntu-latest) has no grammar `.so`s and a machine-specific `QUERIES_PATH` nix store hash, so the deployed site renders unhighlighted. Provide grammars + queries as nix build inputs and pass paths to thor at runtime (env vars/flags).
|
||||||
|
- [ ] Unit tests for highlighting helpers: `capture_name_to_css`, `escape_html`, `unescape_html`, `extract_query_token`, `helix_version_from_path`.
|
||||||
|
- [ ] `<pre><code>` blocks need to set background to theme background,
|
||||||
|
regardless of prefers-dark. (or use a different theme)
|
||||||
|
- [ ] `-watch` flag
|
||||||
|
- [x] basic poll loop
|
||||||
|
- [ ] filesystem poll loop
|
||||||
|
- [ ] event based
|
||||||
|
- [ ] Free cmark HTML output (`body_html`) — cmark allocates via C malloc, not the arena, so it leaks per iteration in watch mode
|
||||||
|
- [ ] Mount content in VFS
|
||||||
|
- [ ] commands
|
||||||
|
- [ ] `build` alias of default
|
||||||
|
- [ ] `new site` set up new project
|
||||||
|
- [ ] warn/error when unknown key used in mustache.
|
||||||
|
- [ ] Import/export packages. Hugo, jekyll, WordPress, etc.
|
||||||
|
- [ ] opt-in "strict_keys" mode. in this mode, key lookups may not view parent objects.
|
||||||
|
|
||||||
## Diagnostics
|
## Notes
|
||||||
|
|
||||||
All diagnostics (errors) should show
|
from the [Hugo docs](https://gohugo.io/quick-reference/glossary/#default-sort-order)
|
||||||
|
|
||||||
- *What* went wrong
|
default sort order
|
||||||
- *where* (in the file)
|
: The default sort order for page collections, used when no other criteria are set, follows this priority:
|
||||||
- *where* (in the stack trace)
|
1. weight (ascending)
|
||||||
- *how* you can fix it (if applicable)
|
2. date (descending)
|
||||||
|
3. linkTitle falling back to title (ascending)
|
||||||
|
4. logical path (ascending)
|
||||||
|
|
||||||
- [ ] try to make file paths clickable links.
|
## Code Review
|
||||||
- [ ] make sure the frontmatter parser has good diagnostics.
|
|
||||||
- [ ] add all known diagnostics to [DIAGNOSTIC TODOS](./DIAGNOSTIC_TESTS.yaml).
|
|
||||||
- [ ] fix the broken diagnostics in [DIAGNOSTIC TODOS](./DIAGNOSTIC_TESTS.yaml)
|
|
||||||
- [ ] show *stack trace* for template partials?
|
|
||||||
- [ ] show where the typo occurred (config file path for `apply_extension_config`, full `-ext:value` for `parse_extension_list`)
|
|
||||||
- [ ] **all** diagnostics in [DIAGNOSTIC TODOS](./DIAGNOSTIC_TESTS.yaml) must be tested.
|
|
||||||
- [ ] only report format errors once.
|
|
||||||
- [ ] only report missing partial errors once.
|
|
||||||
- [ ] better diagnostics for syntax errors in treesitter.
|
|
||||||
- [ ] Ensure diagnostics for MAX_CONTEXT_DEPTH are good.
|
|
||||||
- [ ] improve matching weights message.
|
|
||||||
- [ ] Test menu diagnostics
|
|
||||||
- [ ] Extend `tag_error` to all render-time errors, not just pipe errors.
|
|
||||||
Currently only pipe errors (4 sites in `render_nodes`) get stamped with
|
|
||||||
the correct template source/path. Other render errors still use the
|
|
||||||
content template's source/path, which can point at the wrong file.
|
|
||||||
|
|
||||||
## Analytics
|
A human should manually review every file in the project. AI cannot complete
|
||||||
|
these tasks.
|
||||||
|
|
||||||
Debug log stats:
|
- [ ] Review every file in thor
|
||||||
|
- [ ] Review assets.odin
|
||||||
- [ ] final Context_Stack cap
|
- [ ] Review content.odin
|
||||||
- [ ] highest PIPE args used
|
- [ ] Review defaults.odin
|
||||||
- [ ] longest slug length + name that generated it
|
- [ ] Review feed.odin
|
||||||
- [ ] number of pages
|
- [ ] Review frontmatter.odin
|
||||||
- [ ] number of blocks
|
- [ ] Review main.odin
|
||||||
- [ ] enabled features / extensions
|
- [ ] Review minify.odin
|
||||||
- [ ] etc
|
- [ ] Review `markdown/`
|
||||||
|
- [x] Review alerts.odin
|
||||||
## sbrow.github.io
|
- [x] Review alerts_test.odin
|
||||||
- [ ] The home file was accidentally deleted in commit 9a9f731
|
- [x] Review emoji.odin
|
||||||
- [ ] Ideas is now in the wrong spot. Date is wrong, and it is showing date
|
- [x] Review emoji_test.odin
|
||||||
when it shouldn't be.
|
- [ ] Review footnotes.odin
|
||||||
|
- [ ] Review footnotes_test.odin
|
||||||
|
- [ ] Review highlight.odin
|
||||||
|
- [ ] Review markdown.odin
|
||||||
|
- [x] Review sectionate.odin
|
||||||
|
- [x] Review sectionate_test.odin
|
||||||
|
- [ ] Review suggest.odin
|
||||||
|
- [ ] Review suggest_test.odin
|
||||||
|
- [x] Review opengraph.odin
|
||||||
|
- [ ] Review render.odin
|
||||||
|
- [x] Review site.odin
|
||||||
|
- [ ] Review treesitter/treesitter.odin
|
||||||
|
- [ ] Review vfs.odin
|
||||||
|
- [ ] Review procs
|
||||||
|
- [ ] markdown.transform_alert
|
||||||
|
|||||||
@@ -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:os"
|
||||||
import "core:strings"
|
import "core:strings"
|
||||||
|
|
||||||
import ts "treesitter"
|
|
||||||
|
|
||||||
copy_assets_dir :: proc(vfs: ^VFS, output_dir: string, features: bit_set[Feature]) {
|
copy_assets_dir :: proc(vfs: ^VFS, output_dir: string, features: bit_set[Feature]) {
|
||||||
// One CSS parser, reused for every .css asset (single-threaded). Owned here.
|
|
||||||
css_parser: ts.Parser
|
|
||||||
if .Minify in features {
|
|
||||||
if g, ok := ts.grammar("css"); ok {
|
|
||||||
css_parser = ts.open_parser(g)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer if css_parser != nil {
|
|
||||||
ts.parser_delete(css_parser)
|
|
||||||
}
|
|
||||||
|
|
||||||
for virtual_path, entry in vfs.files {
|
for virtual_path, entry in vfs.files {
|
||||||
if !strings.has_prefix(virtual_path, "assets/") {
|
if !strings.has_prefix(virtual_path, "assets/") {
|
||||||
continue
|
continue
|
||||||
@@ -27,12 +14,6 @@ copy_assets_dir :: proc(vfs: ^VFS, output_dir: string, features: bit_set[Feature
|
|||||||
rel := virtual_path[len("assets/"):]
|
rel := virtual_path[len("assets/"):]
|
||||||
dest := fmt.tprintf("%s/%s", output_dir, rel)
|
dest := fmt.tprintf("%s/%s", output_dir, rel)
|
||||||
|
|
||||||
// Don't clobber a file already written this build (e.g. the output of a
|
|
||||||
// `tool` pipe, which publishes to the same dest path as its source).
|
|
||||||
if os.exists(dest) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if idx := strings.last_index(dest, "/"); idx >= 0 {
|
if idx := strings.last_index(dest, "/"); idx >= 0 {
|
||||||
if err := os.make_directory_all(dest[:idx]); err != nil && err != .Exist {
|
if err := os.make_directory_all(dest[:idx]); err != nil && err != .Exist {
|
||||||
log.warnf("cannot create %s: %v", dest[:idx], err)
|
log.warnf("cannot create %s: %v", dest[:idx], err)
|
||||||
@@ -43,11 +24,7 @@ copy_assets_dir :: proc(vfs: ^VFS, output_dir: string, features: bit_set[Feature
|
|||||||
if .Minify in features && strings.has_suffix(rel, ".css") {
|
if .Minify in features && strings.has_suffix(rel, ".css") {
|
||||||
data, ok := vfs_get(vfs, virtual_path)
|
data, ok := vfs_get(vfs, virtual_path)
|
||||||
if ok {
|
if ok {
|
||||||
if m, mok := minify_css(css_parser, string(data)); mok {
|
write_file(dest, minify_css(string(data)))
|
||||||
write_file(dest, m)
|
|
||||||
} else {
|
|
||||||
write_file(dest, string(data))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if entry.data != nil {
|
} else if entry.data != nil {
|
||||||
if err := os.write_entire_file(dest, entry.data); err != nil {
|
if err := os.write_entire_file(dest, entry.data); err != nil {
|
||||||
|
|||||||
+16
-37
@@ -1,11 +1,11 @@
|
|||||||
package bench
|
package bench
|
||||||
|
|
||||||
import "../mustache"
|
|
||||||
import "core:fmt"
|
import "core:fmt"
|
||||||
import "core:mem"
|
import "core:mem"
|
||||||
import "core:os"
|
import "core:os"
|
||||||
import "core:strconv"
|
import "core:strconv"
|
||||||
import "core:time"
|
import "core:time"
|
||||||
|
import "../mustache"
|
||||||
|
|
||||||
Tag :: struct {
|
Tag :: struct {
|
||||||
name: string,
|
name: string,
|
||||||
@@ -101,13 +101,13 @@ main :: proc() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for _ in 0 ..< 3 {
|
for _ in 0..<3 {
|
||||||
_, _ = mustache.render(page, data, partials, allocator = context.temp_allocator)
|
_, _ = mustache.render(page, data, partials, allocator = context.temp_allocator)
|
||||||
mem.dynamic_arena_free_all(&temp_arena)
|
mem.dynamic_arena_free_all(&temp_arena)
|
||||||
}
|
}
|
||||||
|
|
||||||
start := time.now()
|
start := time.now()
|
||||||
for _ in 0 ..< iterations {
|
for _ in 0..<iterations {
|
||||||
_, _ = mustache.render(page, data, partials, allocator = context.temp_allocator)
|
_, _ = mustache.render(page, data, partials, allocator = context.temp_allocator)
|
||||||
mem.dynamic_arena_free_all(&temp_arena)
|
mem.dynamic_arena_free_all(&temp_arena)
|
||||||
}
|
}
|
||||||
@@ -116,12 +116,8 @@ main :: proc() {
|
|||||||
seconds := time.duration_seconds(elapsed)
|
seconds := time.duration_seconds(elapsed)
|
||||||
per_render_ms := seconds * 1000 / f64(iterations)
|
per_render_ms := seconds * 1000 / f64(iterations)
|
||||||
|
|
||||||
fmt.printfln(
|
fmt.printfln("iterations=%d total=%.3fs per_render=%.3fms",
|
||||||
"iterations=%d total=%.3fs per_render=%.3fms",
|
iterations, seconds, per_render_ms)
|
||||||
iterations,
|
|
||||||
seconds,
|
|
||||||
per_render_ms,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parse_file :: proc(name: string) -> mustache.Template {
|
parse_file :: proc(name: string) -> mustache.Template {
|
||||||
@@ -142,27 +138,16 @@ parse_file :: proc(name: string) -> mustache.Template {
|
|||||||
}
|
}
|
||||||
|
|
||||||
generate_data :: proc() -> Page_Data {
|
generate_data :: proc() -> Page_Data {
|
||||||
years := []string {
|
years := []string{
|
||||||
"2025",
|
"2025", "2024", "2023", "2022", "2021",
|
||||||
"2024",
|
"2020", "2019", "2018", "2017", "2016",
|
||||||
"2023",
|
|
||||||
"2022",
|
|
||||||
"2021",
|
|
||||||
"2020",
|
|
||||||
"2019",
|
|
||||||
"2018",
|
|
||||||
"2017",
|
|
||||||
"2016",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
posts := make([dynamic]Post, 0, 500)
|
posts := make([dynamic]Post, 0, 500)
|
||||||
for year in years {
|
for year in years {
|
||||||
for i in 0 ..< 50 {
|
for i in 0..<50 {
|
||||||
tags := make([dynamic]Tag, 0, 3)
|
tags := make([dynamic]Tag, 0, 3)
|
||||||
append(
|
append(&tags, Tag{name = fmt.aprintf("%s-notes", year), slug = fmt.aprintf("%s-notes", year)})
|
||||||
&tags,
|
|
||||||
Tag{name = fmt.aprintf("%s-notes", year), slug = fmt.aprintf("%s-notes", year)},
|
|
||||||
)
|
|
||||||
append(&tags, Tag{name = "writing", slug = "writing"})
|
append(&tags, Tag{name = "writing", slug = "writing"})
|
||||||
append(&tags, Tag{name = "archive", slug = "archive"})
|
append(&tags, Tag{name = "archive", slug = "archive"})
|
||||||
|
|
||||||
@@ -174,9 +159,7 @@ generate_data :: proc() -> Page_Data {
|
|||||||
author = fmt.aprintf("Author %d", i % 5)
|
author = fmt.aprintf("Author %d", i % 5)
|
||||||
}
|
}
|
||||||
|
|
||||||
append(
|
append(&posts, Post{
|
||||||
&posts,
|
|
||||||
Post {
|
|
||||||
title = fmt.aprintf("Post %d from %s", i, year),
|
title = fmt.aprintf("Post %d from %s", i, year),
|
||||||
url = fmt.aprintf("/%s/post-%d", year, i),
|
url = fmt.aprintf("/%s/post-%d", year, i),
|
||||||
date = fmt.aprintf("%s-%02d-%02dT10:00:00Z", year, month, day),
|
date = fmt.aprintf("%s-%02d-%02dT10:00:00Z", year, month, day),
|
||||||
@@ -184,24 +167,20 @@ generate_data :: proc() -> Page_Data {
|
|||||||
excerpt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
excerpt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
||||||
author = author,
|
author = author,
|
||||||
tags = tags,
|
tags = tags,
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
comments := make([dynamic]Comment, 0, 100)
|
comments := make([dynamic]Comment, 0, 100)
|
||||||
for i in 0 ..< 100 {
|
for i in 0..<100 {
|
||||||
year := years[i % len(years)]
|
year := years[i % len(years)]
|
||||||
month := (i % 12) + 1
|
month := (i % 12) + 1
|
||||||
day := (i % 28) + 1
|
day := (i % 28) + 1
|
||||||
append(
|
append(&comments, Comment{
|
||||||
&comments,
|
|
||||||
Comment {
|
|
||||||
author = fmt.aprintf("Commenter %d", i),
|
author = fmt.aprintf("Commenter %d", i),
|
||||||
date = fmt.aprintf("%s-%02d-%02dT12:00:00Z", year, month, day),
|
date = fmt.aprintf("%s-%02d-%02dT12:00:00Z", year, month, day),
|
||||||
body = fmt.aprintf("Great post! This is comment number %d.", i),
|
body = fmt.aprintf("Great post! This is comment number %d.", i),
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nav_items := make([dynamic]Nav_Item, 0, 8)
|
nav_items := make([dynamic]Nav_Item, 0, 8)
|
||||||
@@ -214,7 +193,7 @@ generate_data :: proc() -> Page_Data {
|
|||||||
append(&nav_items, Nav_Item{url = "https://twitter.com/example", label = "Twitter"})
|
append(&nav_items, Nav_Item{url = "https://twitter.com/example", label = "Twitter"})
|
||||||
append(&nav_items, Nav_Item{url = "mailto:nobody@example.com", label = "Email"})
|
append(&nav_items, Nav_Item{url = "mailto:nobody@example.com", label = "Email"})
|
||||||
|
|
||||||
return Page_Data {
|
return Page_Data{
|
||||||
title = "Post Archive",
|
title = "Post Archive",
|
||||||
now = "2025-07-21T12:00:00Z",
|
now = "2025-07-21T12:00:00Z",
|
||||||
posts = posts,
|
posts = posts,
|
||||||
|
|||||||
@@ -127,3 +127,4 @@ to_benchmark :: proc($f: formatter) -> benchmark {
|
|||||||
} \
|
} \
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,3 +41,4 @@ parse_2_digits :: proc(s: string, offset: int) -> int {
|
|||||||
}
|
}
|
||||||
return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30)
|
return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,3 +123,4 @@ emit_hour_12 :: proc(b: ^strings.Builder, dt: common.Date_Components, pad: bool)
|
|||||||
|
|
||||||
fmt.sbprintf(b, format, h12)
|
fmt.sbprintf(b, format, h12)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,3 +130,4 @@ emit_hour_12 :: proc(b: ^strings.Builder, dt: common.Date_Components, pad: bool)
|
|||||||
|
|
||||||
fmt.sbprintf(b, format, h12)
|
fmt.sbprintf(b, format, h12)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,3 +125,4 @@ emit_am_pm :: proc(b: ^strings.Builder, dt: common.Date_Components) {
|
|||||||
emit_am_pm_lower :: proc(b: ^strings.Builder, dt: common.Date_Components) {
|
emit_am_pm_lower :: proc(b: ^strings.Builder, dt: common.Date_Components) {
|
||||||
strings.write_string(b, "pm" if dt.hour >= 12 else "am")
|
strings.write_string(b, "pm" if dt.hour >= 12 else "am")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,3 +41,4 @@ parse_2_digits :: proc(s: string, offset: int) -> int {
|
|||||||
}
|
}
|
||||||
return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30)
|
return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,3 +124,4 @@ emit_hour_12 :: proc(b: ^strings.Builder, dt: common.Date_Components, pad: bool)
|
|||||||
|
|
||||||
fmt.sbprintf(b, format, h12)
|
fmt.sbprintf(b, format, h12)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-88
@@ -3,7 +3,6 @@ package main
|
|||||||
import md "markdown"
|
import md "markdown"
|
||||||
import ts "treesitter"
|
import ts "treesitter"
|
||||||
|
|
||||||
import "core:encoding/json"
|
|
||||||
import "core:fmt"
|
import "core:fmt"
|
||||||
import "core:log"
|
import "core:log"
|
||||||
import "core:os"
|
import "core:os"
|
||||||
@@ -21,14 +20,12 @@ Page :: struct {
|
|||||||
description: string,
|
description: string,
|
||||||
date: string,
|
date: string,
|
||||||
year: string,
|
year: string,
|
||||||
weight: Maybe(int),
|
|
||||||
lastmod: string,
|
lastmod: string,
|
||||||
menus: map[string]Menu_Entry,
|
menu: string,
|
||||||
params: json.Value,
|
|
||||||
content: string,
|
content: string,
|
||||||
og: Open_Graph,
|
og: Open_Graph,
|
||||||
draft: bool,
|
draft: bool,
|
||||||
toc: string,
|
starred: bool,
|
||||||
_is_index: bool `private`,
|
_is_index: bool `private`,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,12 +68,7 @@ site_load_content :: proc(site: ^Site) {
|
|||||||
|
|
||||||
for &page in site.pages {
|
for &page in site.pages {
|
||||||
page.url = fmt.tprintf("%s%s", site.base_url, page.permalink)
|
page.url = fmt.tprintf("%s%s", site.base_url, page.permalink)
|
||||||
if site.base_path != "" {
|
|
||||||
page.content = rewrite_root_urls(page.content, site.base_path, site_allocator(site))
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
build_menus(site)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// scan_content_files walks the content directory and collects Pending_File
|
// scan_content_files walks the content directory and collects Pending_File
|
||||||
@@ -244,25 +236,24 @@ load_page :: proc(
|
|||||||
if page.date == "" {
|
if page.date == "" {
|
||||||
info, stat_err := os.stat(file_path, context.allocator)
|
info, stat_err := os.stat(file_path, context.allocator)
|
||||||
if stat_err == nil {
|
if stat_err == nil {
|
||||||
page.date, _ = time.time_to_rfc3339(
|
page.date, _ = time.time_to_rfc3339(info.modification_time, 0, false, context.allocator)
|
||||||
info.modification_time,
|
|
||||||
0,
|
|
||||||
false,
|
|
||||||
context.allocator,
|
|
||||||
)
|
|
||||||
os.file_info_delete(info, context.allocator)
|
os.file_info_delete(info, context.allocator)
|
||||||
log.warnf(
|
log.warnf("no date in frontmatter for %s, using file modification time: %s", file_path, page.date)
|
||||||
"no date in frontmatter for %s, using file modification time: %s",
|
|
||||||
file_path,
|
|
||||||
page.date,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
page.year = get_year(page.date)
|
page.year = get_year(page.date)
|
||||||
page.weight = fm.weight
|
|
||||||
page.lastmod = fm.lastmod
|
page.lastmod = fm.lastmod
|
||||||
page.draft = fm.draft
|
page.draft = fm.draft
|
||||||
page.params = fm.params
|
page.starred = fm.isStarred
|
||||||
|
page.menu = fm.menu
|
||||||
|
page.layout = fm.layout if fm.layout != "" else infer_layout(section, is_index)
|
||||||
|
page.og = fm.og
|
||||||
|
|
||||||
|
if strings.has_suffix(file_path, ".html") {
|
||||||
|
page.content = strings.clone(body)
|
||||||
|
} else {
|
||||||
|
page.content = md.process(body, ext, file_path)
|
||||||
|
}
|
||||||
|
|
||||||
if section == "" && is_index {
|
if section == "" && is_index {
|
||||||
page.permalink = "/"
|
page.permalink = "/"
|
||||||
@@ -274,75 +265,10 @@ load_page :: proc(
|
|||||||
page.permalink = fmt.aprintf("/%s/%s/", section, slug)
|
page.permalink = fmt.aprintf("/%s/%s/", section, slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
page.menus = parse_page_menus(fm.menus, page, context.allocator)
|
|
||||||
page.layout = fm.layout if fm.layout != "" else infer_layout(section, is_index)
|
|
||||||
page.og = fm.og
|
|
||||||
|
|
||||||
if strings.has_suffix(file_path, ".html") {
|
|
||||||
page.content = strings.clone(body, context.allocator)
|
|
||||||
} else {
|
|
||||||
page.content = md.process(body, ext, file_path, context.allocator)
|
|
||||||
}
|
|
||||||
|
|
||||||
if fm.toc {
|
|
||||||
page.toc = md.generate_toc(page.content, context.allocator)
|
|
||||||
}
|
|
||||||
|
|
||||||
ok = true
|
ok = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// rewrite_root_urls prefixes root-relative href/src attribute values with
|
|
||||||
// base_path so links authored in content (e.g. [x](/ideas)) resolve under a
|
|
||||||
// subpath deployment. Only values beginning with a single '/' are rewritten;
|
|
||||||
// absolute ("http://…"), protocol-relative ("//cdn…"), fragment ("#x"),
|
|
||||||
// mailto:, and document-relative ("foo/bar", "./foo") values all fail that
|
|
||||||
// test and pass through untouched. Assumes cmark's double-quoted output.
|
|
||||||
rewrite_root_urls :: proc(html, base_path: string, allocator := context.allocator) -> string {
|
|
||||||
b := strings.builder_make(allocator)
|
|
||||||
strings.builder_grow(&b, len(html))
|
|
||||||
|
|
||||||
i := 0
|
|
||||||
for i < len(html) {
|
|
||||||
n := attr_opener_at(html, i) // ` href="` or ` src="`
|
|
||||||
if n == 0 {
|
|
||||||
strings.write_byte(&b, html[i])
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
strings.write_string(&b, html[i:i + n]) // copy the opener verbatim
|
|
||||||
i += n
|
|
||||||
|
|
||||||
end := strings.index_byte(html[i:], '"')
|
|
||||||
if end < 0 { // malformed; bail cleanly
|
|
||||||
strings.write_string(&b, html[i:])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
value := html[i:i + end]
|
|
||||||
if url_is_root_relative(value) {
|
|
||||||
strings.write_string(&b, base_path)
|
|
||||||
}
|
|
||||||
strings.write_string(&b, value) // closing quote copied next loop
|
|
||||||
i += end
|
|
||||||
}
|
|
||||||
return strings.to_string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// url_is_root_relative reports whether value is a site-root path ("/faq/") as
|
|
||||||
// opposed to protocol-relative ("//cdn") or anything not starting with '/'.
|
|
||||||
url_is_root_relative :: proc(value: string) -> bool {
|
|
||||||
return len(value) >= 1 && value[0] == '/' && !strings.has_prefix(value, "//")
|
|
||||||
}
|
|
||||||
|
|
||||||
// attr_opener_at returns the length of a ` href="` / ` src="` attribute opener
|
|
||||||
// at s[i:], or 0. The leading space anchors the match to a real HTML attribute
|
|
||||||
// so we don't rewrite the strings inside link text or code blocks.
|
|
||||||
attr_opener_at :: proc(s: string, i: int) -> int {
|
|
||||||
if strings.has_prefix(s[i:], " href=\"") do return 7
|
|
||||||
if strings.has_prefix(s[i:], " src=\"") do return 6
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
is_content_file :: proc(name: string) -> bool {
|
is_content_file :: proc(name: string) -> bool {
|
||||||
return strings.has_suffix(name, ".md") || strings.has_suffix(name, ".html")
|
return strings.has_suffix(name, ".md") || strings.has_suffix(name, ".html")
|
||||||
}
|
}
|
||||||
@@ -354,3 +280,4 @@ strip_extension :: proc(name: string) -> string {
|
|||||||
}
|
}
|
||||||
return name[:dot]
|
return name[:dot]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-14
@@ -2,17 +2,5 @@ package main
|
|||||||
|
|
||||||
import "core:os"
|
import "core:os"
|
||||||
|
|
||||||
// Path to the built-in default layouts, resolved at runtime.
|
DEFAULTS_PATH :: #directory + os.Path_Separator_String + "defaults"
|
||||||
//
|
|
||||||
// The default points at the `defaults` directory next to the source tree (via
|
|
||||||
// `#directory`), which works for local `odin run`/`odin build` from the repo.
|
|
||||||
// Packaged builds install `defaults/` to a fixed location and override this
|
|
||||||
// with `-define:THOR_DEFAULTS_PATH=<path>` (see flake.nix).
|
|
||||||
//
|
|
||||||
// TODO: bake these into the binary at compile time via `#load_directory` so the
|
|
||||||
// binary is self-contained and this runtime path goes away entirely. See
|
|
||||||
// TODOS.md.
|
|
||||||
DEFAULTS_PATH :: #config(
|
|
||||||
THOR_DEFAULTS_PATH,
|
|
||||||
#directory + os.Path_Separator_String + "defaults",
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -4,10 +4,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{> title}}</title>
|
<title>{{title}}</title>
|
||||||
{{> opengraph}}
|
{{> head}}
|
||||||
{{> styles}}
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
{{> scripts}}
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
{{&content}}
|
{{&content}}
|
||||||
</header>
|
</header>
|
||||||
<ul>
|
<ul>
|
||||||
{{#pages}} <li><a href="{{permalink | rel_url}}">{{&title}}</a><span>{{#date}}<time
|
{{#pages}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date_iso}}<time
|
||||||
datetime="{{date}}">{{date | format}}</time>{{/date}}</span>
|
datetime="{{date_iso}}">{{date_display}}</time>{{/date_iso}}</span>
|
||||||
</li>
|
</li>
|
||||||
{{/pages}}
|
{{/pages}}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -3,13 +3,8 @@
|
|||||||
<main>
|
<main>
|
||||||
<article>
|
<article>
|
||||||
<h1>{{page.title}}</h1>
|
<h1>{{page.title}}</h1>
|
||||||
{{#date}}<time class="subtitle" datetime="{{date}}">{{ date | format}}</time>{{/date}}
|
{{#date_iso}} <time class="subtitle" datetime="{{date_iso}}">{{date_display}}</time>
|
||||||
{{#page.toc}}
|
{{/date_iso}} {{&content}}
|
||||||
<nav class="toc">
|
|
||||||
{{&page.toc}}
|
|
||||||
</nav>
|
|
||||||
{{/page.toc}}
|
|
||||||
{{&content}}
|
|
||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
{{/main}}
|
{{/main}}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
<footer>
|
<footer>
|
||||||
<p>© {{now | format "2006"}} {{params.author.name}}</p>
|
<p>© {{now.year}} {{params.author.name}}</p>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -1 +0,0 @@
|
|||||||
{{site.title}}{{^site.title}}Home{{/site.title}}
|
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
<header>
|
<header>
|
||||||
<nav>
|
<nav>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="{{site.base_path}}/">{{> home-link}}</a></li>
|
<li><a href="/">{{title}}</a></li>
|
||||||
{{#menus.main}}
|
|
||||||
<li><a href="{{url | rel_url}}">{{name}}</a></li>
|
|
||||||
{{/menus.main}}
|
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -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>
|
<section>
|
||||||
<h2>{{key}}</h2>
|
<h2>{{key}}</h2>
|
||||||
<ul>
|
<ul>
|
||||||
{{#items}} <li><a href="{{permalink | rel_url}}">{{&title}}</a><span>{{#date}}<time
|
{{#items}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date_iso}}<time
|
||||||
datetime="{{date}}">{{date | format}}</time>{{/date}}</span>
|
datetime="{{date_iso}}">{{date_display}}</time>{{/date_iso}}</span>
|
||||||
</li>
|
</li>
|
||||||
{{/items}}
|
{{/items}}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -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
|
package main
|
||||||
|
|
||||||
import "base:runtime"
|
|
||||||
import "core:fmt"
|
import "core:fmt"
|
||||||
import "core:strings"
|
import "core:strings"
|
||||||
import "core:time"
|
import "core:time"
|
||||||
@@ -8,8 +7,9 @@ import "core:time"
|
|||||||
generate_rss :: proc(site: ^Site) -> string {
|
generate_rss :: proc(site: ^Site) -> string {
|
||||||
sb := strings.builder_make()
|
sb := strings.builder_make()
|
||||||
|
|
||||||
fmt.sbprintf(
|
strings.write_string(
|
||||||
&sb,
|
&sb,
|
||||||
|
fmt.aprintf(
|
||||||
`<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
`<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||||
<channel>
|
<channel>
|
||||||
@@ -22,6 +22,7 @@ generate_rss :: proc(site: ^Site) -> string {
|
|||||||
site.base_url,
|
site.base_url,
|
||||||
xml_escape(site.description),
|
xml_escape(site.description),
|
||||||
site.base_url,
|
site.base_url,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
for page in site.pages {
|
for page in site.pages {
|
||||||
@@ -34,8 +35,9 @@ generate_rss :: proc(site: ^Site) -> string {
|
|||||||
pub_date = format_rfc822(page.date)
|
pub_date = format_rfc822(page.date)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.sbprintf(
|
strings.write_string(
|
||||||
&sb,
|
&sb,
|
||||||
|
fmt.aprintf(
|
||||||
`<item>
|
`<item>
|
||||||
<title>%s</title>
|
<title>%s</title>
|
||||||
<link>%s</link>
|
<link>%s</link>
|
||||||
@@ -49,6 +51,7 @@ generate_rss :: proc(site: ^Site) -> string {
|
|||||||
pub_date,
|
pub_date,
|
||||||
page.url,
|
page.url,
|
||||||
xml_escape(page.content),
|
xml_escape(page.content),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,11 +70,11 @@ generate_sitemap :: proc(site: ^Site) -> string {
|
|||||||
)
|
)
|
||||||
|
|
||||||
for page in site.pages {
|
for page in site.pages {
|
||||||
fmt.sbprintf(&sb, "<url><loc>%s</loc>", page.url)
|
lastmod := ""
|
||||||
if page.date != "" {
|
if page.date != "" {
|
||||||
fmt.sbprintf(&sb, "<lastmod>%s</lastmod>", page.date)
|
lastmod = fmt.aprintf("<lastmod>%s</lastmod>", page.date)
|
||||||
}
|
}
|
||||||
fmt.sbprintf(&sb, "</url>\n")
|
strings.write_string(&sb, fmt.aprintf("<url><loc>%s</loc>%s</url>\n", page.url, lastmod))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Section index pages (for sections without an index in content)
|
// Section index pages (for sections without an index in content)
|
||||||
@@ -100,18 +103,22 @@ generate_sitemap :: proc(site: ^Site) -> string {
|
|||||||
section_lastmod = page.date
|
section_lastmod = page.date
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.sbprintf(&sb, "<url><loc>%s/%s/</loc>", site.base_url, section)
|
lm := ""
|
||||||
if section_lastmod != "" {
|
if section_lastmod != "" {
|
||||||
fmt.sbprintf(&sb, "<lastmod>%s</lastmod>", section_lastmod)
|
lm = fmt.aprintf("<lastmod>%s</lastmod>", section_lastmod)
|
||||||
}
|
}
|
||||||
fmt.sbprintf(&sb, "</url>\n")
|
strings.write_string(
|
||||||
|
&sb,
|
||||||
|
fmt.aprintf("<url><loc>%s/%s/</loc>%s</url>\n", site.base_url, section, lm),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
strings.write_string(&sb, "</urlset>")
|
strings.write_string(&sb, "</urlset>")
|
||||||
return strings.to_string(sb)
|
return strings.to_string(sb)
|
||||||
}
|
}
|
||||||
|
|
||||||
format_rfc822 :: proc(iso: string, allocator := context.temp_allocator) -> string {
|
// TODO: Leaks
|
||||||
|
format_rfc822 :: proc(iso: string) -> string {
|
||||||
if len(iso) < 19 {
|
if len(iso) < 19 {
|
||||||
// TODO: should indicate error somehow
|
// TODO: should indicate error somehow
|
||||||
return iso
|
return iso
|
||||||
@@ -124,66 +131,22 @@ format_rfc822 :: proc(iso: string, allocator := context.temp_allocator) -> strin
|
|||||||
buf: [8]byte
|
buf: [8]byte
|
||||||
t := time.to_string_hms(date, buf[:])
|
t := time.to_string_hms(date, buf[:])
|
||||||
|
|
||||||
// RFC-822 zone: sign followed by two-digit hours and minutes (e.g. +0000,
|
|
||||||
// +0530, -0800). offset is in minutes.
|
|
||||||
sign := '+'
|
|
||||||
off := offset
|
|
||||||
if off < 0 {
|
|
||||||
sign = '-'
|
|
||||||
off = -off
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.aprintf(
|
return fmt.aprintf(
|
||||||
"%s, %02d %s %d %s %c%02d%02d",
|
"%s, %02d %s %d %s %3d%2d",
|
||||||
weekday[:3],
|
weekday[:3],
|
||||||
time.day(date),
|
time.day(date),
|
||||||
month[:3],
|
month[:3],
|
||||||
time.year(date),
|
time.year(date),
|
||||||
t,
|
t,
|
||||||
sign,
|
offset / 60,
|
||||||
off / 60,
|
offset % 60,
|
||||||
off % 60,
|
|
||||||
allocator = allocator,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// xml_escape escapes the XML metacharacters '&', '<' and '>' in s. Only these
|
xml_escape :: proc(s: string) -> string {
|
||||||
// ASCII characters are touched; all other bytes (including UTF-8 multibyte
|
r, _ := strings.replace_all(s, "&", "&")
|
||||||
// sequences) pass through verbatim, so s must already be valid UTF-8.
|
r, _ = strings.replace_all(r, "<", "<")
|
||||||
//
|
r, _ = strings.replace_all(r, ">", ">")
|
||||||
// The result is allocated in `allocator`, which defaults to
|
return r
|
||||||
// context.temp_allocator — the returned string is only valid until the next
|
|
||||||
// temp allocator reset, so callers must not retain it across frames.
|
|
||||||
xml_escape :: proc(
|
|
||||||
s: string,
|
|
||||||
allocator := context.temp_allocator,
|
|
||||||
) -> (
|
|
||||||
escaped: string,
|
|
||||||
err: runtime.Allocator_Error,
|
|
||||||
) #optional_allocator_error {
|
|
||||||
sb := strings.builder_make_len_cap(0, len(s), allocator) or_return
|
|
||||||
|
|
||||||
// Single pass over the bytes, copying runs of ordinary text in bulk and
|
|
||||||
// emitting an entity for each metacharacter. The switch is both the
|
|
||||||
// membership test and the replacement lookup, so no per-match scan.
|
|
||||||
start := 0
|
|
||||||
for i := 0; i < len(s); i += 1 {
|
|
||||||
esc: string
|
|
||||||
switch s[i] {
|
|
||||||
case '&':
|
|
||||||
esc = "&"
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-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)
|
|
||||||
}
|
|
||||||
@@ -12,10 +12,13 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
inputs@{ flake-parts
|
inputs@{
|
||||||
, nixpkgs
|
self,
|
||||||
, ...
|
flake-parts,
|
||||||
|
nixpkgs,
|
||||||
|
nixpkgs-unstable,
|
||||||
# , process-compose-flake
|
# , process-compose-flake
|
||||||
|
treefmt-nix,
|
||||||
}:
|
}:
|
||||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
imports = [
|
imports = [
|
||||||
@@ -25,10 +28,11 @@
|
|||||||
systems = [ "x86_64-linux" ];
|
systems = [ "x86_64-linux" ];
|
||||||
|
|
||||||
perSystem =
|
perSystem =
|
||||||
{ pkgs
|
{
|
||||||
, system
|
pkgs,
|
||||||
, inputs'
|
system,
|
||||||
, ...
|
inputs',
|
||||||
|
...
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
mkGrammarStaticLib = name: src: pkgs.stdenv.mkDerivation {
|
mkGrammarStaticLib = name: src: pkgs.stdenv.mkDerivation {
|
||||||
@@ -67,7 +71,7 @@
|
|||||||
config.allowUnfree = true;
|
config.allowUnfree = true;
|
||||||
|
|
||||||
overlays = [
|
overlays = [
|
||||||
(_final: _prev: { unstable = inputs'.nixpkgs-unstable.legacyPackages; })
|
(final: prev: { unstable = inputs'.nixpkgs-unstable.legacyPackages; })
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,22 +95,11 @@
|
|||||||
settings.formatter.prettier = {
|
settings.formatter.prettier = {
|
||||||
excludes = [
|
excludes = [
|
||||||
"public/**"
|
"public/**"
|
||||||
"mustache/spec/specs/**"
|
"resources/js/modernizr.js"
|
||||||
"*.html"
|
"storage/app/caniuse.json"
|
||||||
"*.md"
|
"*.md"
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
settings.formatter.ols = {
|
|
||||||
command = "${pkgs.bash}/bin/bash";
|
|
||||||
options = [
|
|
||||||
"-euc"
|
|
||||||
''
|
|
||||||
${pkgs.ols}/bin/odinfmt -w .
|
|
||||||
''
|
|
||||||
];
|
|
||||||
includes = [ "*.odin" ];
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#process-compose.default.settings.processes = { };
|
#process-compose.default.settings.processes = { };
|
||||||
@@ -125,7 +118,6 @@
|
|||||||
pkgs.git
|
pkgs.git
|
||||||
pkgs.cmark
|
pkgs.cmark
|
||||||
pkgs.tree-sitter
|
pkgs.tree-sitter
|
||||||
pkgs.tzdata
|
|
||||||
html-grammar
|
html-grammar
|
||||||
css-grammar
|
css-grammar
|
||||||
];
|
];
|
||||||
@@ -141,41 +133,26 @@
|
|||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
runHook preBuild
|
runHook preBuild
|
||||||
odin build . -o:speed -no-bounds-check \
|
odin build . -o:speed -no-bounds-check -out:${pname}-keep
|
||||||
-define:THOR_DEFAULTS_PATH=$out/share/thor/defaults \
|
|
||||||
-out:${pname}-keep
|
|
||||||
runHook postBuild
|
runHook postBuild
|
||||||
'';
|
'';
|
||||||
|
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
runHook preInstall
|
||||||
install -Dm755 ${pname}-keep $out/bin/${pname}
|
install -Dm755 ${pname}-keep $out/bin/${pname}
|
||||||
# Ship the built-in default layouts alongside the binary. The
|
|
||||||
# binary reads these at runtime from THOR_DEFAULTS_PATH (baked in
|
|
||||||
# above). Keep in sync with defaults.odin.
|
|
||||||
mkdir -p $out/share/thor
|
|
||||||
cp -r defaults $out/share/thor/defaults
|
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
buildInputs = [ odin ols ] ++ (with pkgs; [
|
buildInputs = [odin ols ] ++ (with pkgs; [
|
||||||
cmark
|
cmark
|
||||||
tree-sitter
|
tree-sitter
|
||||||
|
|
||||||
gdb
|
gdb
|
||||||
perf
|
|
||||||
|
|
||||||
bloaty # du for binaries?
|
|
||||||
|
|
||||||
# temp
|
|
||||||
hugo
|
|
||||||
pandoc
|
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
unstable.helix
|
unstable.helix
|
||||||
claude-code
|
|
||||||
typescript-language-server
|
typescript-language-server
|
||||||
vscode-langservers-extracted
|
vscode-langservers-extracted
|
||||||
]);
|
]);
|
||||||
|
|||||||
+5
-25
@@ -10,13 +10,11 @@ Frontmatter :: struct {
|
|||||||
date: string,
|
date: string,
|
||||||
lastmod: string,
|
lastmod: string,
|
||||||
publishDate: string,
|
publishDate: string,
|
||||||
weight: Maybe(int),
|
menu: string,
|
||||||
menus: json.Value,
|
|
||||||
params: json.Value,
|
|
||||||
layout: string,
|
layout: string,
|
||||||
og: Open_Graph,
|
og: Open_Graph,
|
||||||
draft: bool,
|
draft: bool,
|
||||||
toc: bool,
|
isStarred: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse_frontmatter splits raw file content into a Frontmatter struct and the
|
// parse_frontmatter splits raw file content into a Frontmatter struct and the
|
||||||
@@ -38,7 +36,7 @@ parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok
|
|||||||
json_str := content[:end + 1]
|
json_str := content[:end + 1]
|
||||||
body = strings.trim_left(content[end + 1:], " \t\r\n")
|
body = strings.trim_left(content[end + 1:], " \t\r\n")
|
||||||
|
|
||||||
value, err := json.parse_string(json_str, spec = .JSON5)
|
value, err := json.parse_string(json_str, spec = .JSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.errorf("failed to parse frontmatter JSON: %v", err)
|
log.errorf("failed to parse frontmatter JSON: %v", err)
|
||||||
return
|
return
|
||||||
@@ -55,15 +53,11 @@ parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok
|
|||||||
fm.date = json_get_string(obj, "date")
|
fm.date = json_get_string(obj, "date")
|
||||||
fm.lastmod = json_get_string(obj, "lastmod")
|
fm.lastmod = json_get_string(obj, "lastmod")
|
||||||
fm.publishDate = json_get_string(obj, "publishDate")
|
fm.publishDate = json_get_string(obj, "publishDate")
|
||||||
fm.weight = json_get_int(obj, "weight")
|
|
||||||
fm.draft = json_get_bool(obj, "draft")
|
fm.draft = json_get_bool(obj, "draft")
|
||||||
fm.toc = json_get_bool(obj, "toc")
|
fm.isStarred = json_get_bool(obj, "isStarred")
|
||||||
if v, ok := obj["menus"]; ok {
|
fm.menu = json_get_string(obj, "menu")
|
||||||
fm.menus = v
|
|
||||||
}
|
|
||||||
fm.layout = json_get_string(obj, "layout")
|
fm.layout = json_get_string(obj, "layout")
|
||||||
fm.og = json_get_open_graph(obj, "og")
|
fm.og = json_get_open_graph(obj, "og")
|
||||||
if v, ok := obj["params"]; ok {fm.params = v}
|
|
||||||
|
|
||||||
ok = true
|
ok = true
|
||||||
return
|
return
|
||||||
@@ -87,20 +81,6 @@ json_get_bool :: proc(obj: json.Object, key: string) -> bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
json_get_int :: proc(obj: json.Object, key: string) -> Maybe(int) {
|
|
||||||
if v, ok := obj[key]; ok {
|
|
||||||
switch val in v {
|
|
||||||
case json.Integer:
|
|
||||||
return int(val)
|
|
||||||
case json.Float:
|
|
||||||
return int(val)
|
|
||||||
case json.Boolean, json.String, json.Array, json.Object, json.Null:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
json_get_open_graph :: proc(obj: json.Object, key: string) -> Open_Graph {
|
json_get_open_graph :: proc(obj: json.Object, key: string) -> Open_Graph {
|
||||||
og: Open_Graph
|
og: Open_Graph
|
||||||
if v, ok := obj[key]; ok {
|
if v, ok := obj[key]; ok {
|
||||||
|
|||||||
+3
-1
@@ -82,7 +82,9 @@ test_description_plain_text :: proc(t: ^testing.T) {
|
|||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
test_description_highlighted_code :: proc(t: ^testing.T) {
|
test_description_highlighted_code :: proc(t: ^testing.T) {
|
||||||
result := generate_description(`<pre><code><span class="hl-keyword">if</span> x</code></pre>`)
|
result := generate_description(
|
||||||
|
`<pre><code><span class="hl-keyword">if</span> x</code></pre>`,
|
||||||
|
)
|
||||||
testing.expect_value(t, result, "if x")
|
testing.expect_value(t, result, "if x")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "core:fmt"
|
||||||
|
|
||||||
|
Ctx :: struct {
|
||||||
|
title: string,
|
||||||
|
using page: Page,
|
||||||
|
site: Site,
|
||||||
|
}
|
||||||
|
|
||||||
|
Page :: struct {
|
||||||
|
title: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
Site :: struct {
|
||||||
|
title: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
main :: proc() {
|
||||||
|
site := Ctx {
|
||||||
|
site = Site{title = "foo"},
|
||||||
|
page = Page{title = "bar"},
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.printfln("%+v", site)
|
||||||
|
fmt.printf("%+v", site)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "base:runtime"
|
import "base:runtime"
|
||||||
import "core:flags"
|
|
||||||
import "core:log"
|
import "core:log"
|
||||||
import "core:mem"
|
|
||||||
import "core:os"
|
import "core:os"
|
||||||
import "core:prof/spall"
|
import "core:prof/spall"
|
||||||
import "core:sync"
|
import "core:sync"
|
||||||
@@ -45,21 +43,9 @@ main :: proc() {
|
|||||||
defer spall.buffer_destroy(&spall_ctx, &spall_buffer)
|
defer spall.buffer_destroy(&spall_ctx, &spall_buffer)
|
||||||
}
|
}
|
||||||
|
|
||||||
cli_flags: Flags
|
|
||||||
flags.parse_or_exit(&cli_flags, os.args, .Odin)
|
|
||||||
|
|
||||||
level: log.Level
|
|
||||||
switch {
|
|
||||||
case cli_flags.quiet:
|
|
||||||
level = .Warning
|
|
||||||
case cli_flags.verbose:
|
|
||||||
level = .Debug
|
|
||||||
case:
|
|
||||||
level = .Info
|
|
||||||
}
|
|
||||||
logger_opts: log.Options =
|
logger_opts: log.Options =
|
||||||
(log.Default_Console_Logger_Opts - log.Full_Timestamp_Opts - {.Short_File_Path})
|
(log.Default_Console_Logger_Opts - log.Full_Timestamp_Opts - {.Short_File_Path})
|
||||||
console_logger := log.create_console_logger(level, logger_opts)
|
console_logger := log.create_console_logger(.Info, logger_opts)
|
||||||
context.logger = console_logger
|
context.logger = console_logger
|
||||||
defer log.destroy_console_logger(console_logger)
|
defer log.destroy_console_logger(console_logger)
|
||||||
|
|
||||||
@@ -71,21 +57,12 @@ main :: proc() {
|
|||||||
|
|
||||||
for {
|
for {
|
||||||
defer free_all(context.temp_allocator)
|
defer free_all(context.temp_allocator)
|
||||||
defer log.debugf(
|
|
||||||
"max_temp_allocator_size=%M",
|
|
||||||
(cast(^runtime.Default_Temp_Allocator)context.temp_allocator.data)^.arena.total_used,
|
|
||||||
)
|
|
||||||
tick := time.tick_now()
|
tick := time.tick_now()
|
||||||
site: Site
|
site: Site
|
||||||
init_site(&site, cli_flags)
|
init_site(&site, os.args)
|
||||||
defer destroy_site(&site)
|
defer destroy_site(&site)
|
||||||
// TODO: Make it so this isn't necessary
|
// TODO: Make it so this isn't necessary
|
||||||
context.allocator = site_allocator(&site)
|
context.allocator = site_allocator(&site)
|
||||||
defer log.debugf(
|
|
||||||
"current_site_allocator_size=%M",
|
|
||||||
site.arena.block_size * (len(site.arena.used_blocks) + len(site.arena.unused_blocks)) -
|
|
||||||
site.arena.bytes_left,
|
|
||||||
)
|
|
||||||
build_vfs(&site)
|
build_vfs(&site)
|
||||||
|
|
||||||
treesitter.grammar_dir = site.grammars
|
treesitter.grammar_dir = site.grammars
|
||||||
@@ -93,7 +70,7 @@ main :: proc() {
|
|||||||
|
|
||||||
site_load_content(&site)
|
site_load_content(&site)
|
||||||
render_site(&site)
|
render_site(&site)
|
||||||
log.infof("Built site in %M", time.tick_since(tick))
|
log.infof("Built site in %s", time.tick_since(tick))
|
||||||
|
|
||||||
(.Watch in site.features) or_break
|
(.Watch in site.features) or_break
|
||||||
time.sleep(5 * time.Second)
|
time.sleep(5 * time.Second)
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ import "core:testing"
|
|||||||
test_true :: proc(t: ^testing.T) {
|
test_true :: proc(t: ^testing.T) {
|
||||||
testing.expect(t, true)
|
testing.expect(t, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,3 +95,4 @@ transform_alert :: proc(sb: ^strings.Builder, bq: string) {
|
|||||||
strings.write_string(sb, " ")
|
strings.write_string(sb, " ")
|
||||||
strings.write_string(sb, rest)
|
strings.write_string(sb, rest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,3 +101,4 @@ test_multiple_alerts_render_together :: proc(t: ^testing.T) {
|
|||||||
</blockquote>`,
|
</blockquote>`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -434,3 +434,4 @@ expand_emoji :: proc(text: string) -> string {
|
|||||||
|
|
||||||
return strings.to_string(sb)
|
return strings.to_string(sb)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-96
@@ -1,5 +1,7 @@
|
|||||||
package markdown
|
package markdown
|
||||||
|
|
||||||
|
import cm "vendor:commonmark"
|
||||||
|
|
||||||
import "core:fmt"
|
import "core:fmt"
|
||||||
import "core:strings"
|
import "core:strings"
|
||||||
|
|
||||||
@@ -169,27 +171,27 @@ inject_notes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render definition through cmark for markdown support
|
// Render definition through cmark for markdown support
|
||||||
def_html := render_inline_md(def_text)
|
def_html := cm.markdown_to_html_from_string(def_text, {.Unsafe})
|
||||||
|
def_html = strip_p_tags(def_html)
|
||||||
|
|
||||||
note: string
|
note: string
|
||||||
defer delete(note)
|
defer delete(note)
|
||||||
if is_margin {
|
if is_margin {
|
||||||
fmt.sbprintf(
|
note = fmt.aprintf(
|
||||||
&parts,
|
|
||||||
`<label for="mn-%s" class="margin-toggle"></label><input type="checkbox" id="mn-%s" class="margin-toggle"><span class="marginnote">%s</span>`,
|
`<label for="mn-%s" class="margin-toggle"></label><input type="checkbox" id="mn-%s" class="margin-toggle"><span class="marginnote">%s</span>`,
|
||||||
id,
|
id,
|
||||||
id,
|
id,
|
||||||
def_html,
|
def_html,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
fmt.sbprintf(
|
note = fmt.aprintf(
|
||||||
&parts,
|
|
||||||
`<label for="fn-%s" class="margin-toggle sidenote-number"></label><input type="checkbox" id="fn-%s" class="margin-toggle"><span class="sidenote">%s</span>`,
|
`<label for="fn-%s" class="margin-toggle sidenote-number"></label><input type="checkbox" id="fn-%s" class="margin-toggle"><span class="sidenote">%s</span>`,
|
||||||
id,
|
id,
|
||||||
id,
|
id,
|
||||||
def_html,
|
def_html,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
strings.write_string(&parts, note)
|
||||||
|
|
||||||
remaining = remaining[ref_end:]
|
remaining = remaining[ref_end:]
|
||||||
}
|
}
|
||||||
@@ -197,98 +199,15 @@ inject_notes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> strin
|
|||||||
return strings.to_string(parts)
|
return strings.to_string(parts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// inject_footnotes finds [^id] and [*id] references in rendered HTML, numbers
|
// strip_p_tags removes surrounding <p></p> if the HTML is a single paragraph.
|
||||||
// them sequentially by order of appearance, and replaces them with <sup> links.
|
strip_p_tags :: proc(html: string) -> string {
|
||||||
// Appends a <section class="footnotes"><ol> at the end with definitions.
|
s := html
|
||||||
// Both sidenote ([^id]) and marginnote ([*id]) references are treated equally.
|
if len(s) > 0 && s[len(s) - 1] == '\n' {
|
||||||
inject_footnotes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> string {
|
s = s[:len(s) - 1]
|
||||||
if len(sn_defs) == 0 && len(mn_defs) == 0 {
|
|
||||||
return html
|
|
||||||
}
|
}
|
||||||
|
if strings.has_prefix(s, "<p>") && strings.has_suffix(s, "</p>") {
|
||||||
parts: strings.Builder
|
return s[3:len(s) - 4]
|
||||||
strings.builder_init_len(&parts, 0)
|
|
||||||
defer strings.builder_destroy(&parts)
|
|
||||||
|
|
||||||
number_of: map[string]int = make(map[string]int, allocator = context.temp_allocator)
|
|
||||||
ordered_ids: [dynamic]string = make([dynamic]string, 0, allocator = context.temp_allocator)
|
|
||||||
next_num := 1
|
|
||||||
|
|
||||||
remaining := html
|
|
||||||
|
|
||||||
for {
|
|
||||||
sn_pos := strings.index(remaining, "[^")
|
|
||||||
mn_pos := strings.index(remaining, "[*")
|
|
||||||
|
|
||||||
is_margin := mn_pos >= 0 && (sn_pos < 0 || mn_pos < sn_pos)
|
|
||||||
pos := sn_pos
|
|
||||||
if is_margin {
|
|
||||||
pos = mn_pos
|
|
||||||
}
|
}
|
||||||
if pos < 0 {
|
return s
|
||||||
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 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,101 +120,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>`))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -193,3 +193,4 @@ make_unique :: proc(slug: string, seen: ^map[string]bool) -> string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,7 @@ test_heading_simple :: proc(t: ^testing.T) {
|
|||||||
@(test)
|
@(test)
|
||||||
test_heading_dedup :: proc(t: ^testing.T) {
|
test_heading_dedup :: proc(t: ^testing.T) {
|
||||||
result := inject_heading_ids("<h2>Intro</h2><p>text</p><h2>Intro</h2>")
|
result := inject_heading_ids("<h2>Intro</h2><p>text</p><h2>Intro</h2>")
|
||||||
testing.expect_value(
|
testing.expect_value(t, result, `<h2 id="intro">Intro</h2><p>text</p><h2 id="intro-1">Intro</h2>`)
|
||||||
t,
|
|
||||||
result,
|
|
||||||
`<h2 id="intro">Intro</h2><p>text</p><h2 id="intro-1">Intro</h2>`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
@@ -40,9 +36,7 @@ test_heading_punctuation :: proc(t: ^testing.T) {
|
|||||||
@(test)
|
@(test)
|
||||||
test_heading_all_levels :: proc(t: ^testing.T) {
|
test_heading_all_levels :: proc(t: ^testing.T) {
|
||||||
result := inject_heading_ids("<h1>A</h1><h2>B</h2><h3>C</h3><h4>D</h4><h5>E</h5><h6>F</h6>")
|
result := inject_heading_ids("<h1>A</h1><h2>B</h2><h3>C</h3><h4>D</h4><h5>E</h5><h6>F</h6>")
|
||||||
testing.expect_value(
|
testing.expect_value(t, result,
|
||||||
t,
|
|
||||||
result,
|
|
||||||
`<h1 id="a">A</h1>` +
|
`<h1 id="a">A</h1>` +
|
||||||
`<h2 id="b">B</h2>` +
|
`<h2 id="b">B</h2>` +
|
||||||
`<h3 id="c">C</h3>` +
|
`<h3 id="c">C</h3>` +
|
||||||
@@ -94,9 +88,9 @@ test_heading_numbers :: proc(t: ^testing.T) {
|
|||||||
@(test)
|
@(test)
|
||||||
test_heading_triple_dedup :: proc(t: ^testing.T) {
|
test_heading_triple_dedup :: proc(t: ^testing.T) {
|
||||||
result := inject_heading_ids("<h2>Foo</h2><h2>Foo</h2><h2>Foo</h2>")
|
result := inject_heading_ids("<h2>Foo</h2><h2>Foo</h2><h2>Foo</h2>")
|
||||||
testing.expect_value(
|
testing.expect_value(t, result,
|
||||||
t,
|
`<h2 id="foo">Foo</h2>` +
|
||||||
result,
|
`<h2 id="foo-1">Foo</h2>` +
|
||||||
`<h2 id="foo">Foo</h2>` + `<h2 id="foo-1">Foo</h2>` + `<h2 id="foo-2">Foo</h2>`,
|
`<h2 id="foo-2">Foo</h2>`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-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 {
|
unescape_html :: proc(s: string) -> string {
|
||||||
sb := strings.builder_make()
|
sb := strings.builder_make()
|
||||||
|
defer strings.builder_destroy(&sb)
|
||||||
|
|
||||||
start := 0
|
start := 0
|
||||||
for i in 0 ..< len(s) {
|
for i in 0 ..< len(s) {
|
||||||
@@ -122,28 +119,22 @@ unescape_html :: proc(s: string) -> string {
|
|||||||
strings.write_string(&sb, replacement)
|
strings.write_string(&sb, replacement)
|
||||||
start = i + semi + 1
|
start = i + semi + 1
|
||||||
}
|
}
|
||||||
if start == 0 do return strings.clone(s)
|
if start == 0 do return s
|
||||||
if start < len(s) do strings.write_string(&sb, s[start:])
|
if start < len(s) do strings.write_string(&sb, s[start:])
|
||||||
return strings.to_string(sb)
|
return strings.to_string(sb)
|
||||||
}
|
}
|
||||||
|
|
||||||
highlight_block :: proc(code: string, lang: string, file_path: string) -> string {
|
highlight_block :: proc(code: string, lang: string, file_path: string) -> string {
|
||||||
g, ok := ts.grammar(lang)
|
gc := ts.load_grammar(lang)
|
||||||
if !ok {
|
if gc == nil {
|
||||||
return code
|
return code
|
||||||
}
|
}
|
||||||
parser := ts.open_parser(g)
|
|
||||||
if parser == nil {
|
|
||||||
return code
|
|
||||||
}
|
|
||||||
defer ts.parser_delete(parser)
|
|
||||||
|
|
||||||
raw_code := unescape_html(code)
|
raw_code := unescape_html(code)
|
||||||
defer delete(raw_code)
|
|
||||||
raw_c := strings.clone_to_cstring(raw_code)
|
raw_c := strings.clone_to_cstring(raw_code)
|
||||||
defer delete(raw_c)
|
defer delete(raw_c)
|
||||||
|
|
||||||
tree := ts.parser_parse_string(parser, nil, raw_c, u32(len(raw_code)))
|
tree := ts.parser_parse_string(gc.parser, nil, raw_c, u32(len(raw_code)))
|
||||||
if tree == nil {
|
if tree == nil {
|
||||||
return code
|
return code
|
||||||
}
|
}
|
||||||
@@ -165,13 +156,12 @@ highlight_block :: proc(code: string, lang: string, file_path: string) -> string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if g.query == nil {
|
cursor := gc.cursor
|
||||||
|
if cursor == nil {
|
||||||
return code
|
return code
|
||||||
}
|
}
|
||||||
cursor := ts.query_cursor_new()
|
|
||||||
defer ts.query_cursor_delete(cursor)
|
|
||||||
|
|
||||||
ts.query_cursor_exec(cursor, g.query, root)
|
ts.query_cursor_exec(cursor, gc.query, root)
|
||||||
|
|
||||||
captures := make([dynamic]Capture, 0, 64, context.temp_allocator)
|
captures := make([dynamic]Capture, 0, 64, context.temp_allocator)
|
||||||
defer delete(captures)
|
defer delete(captures)
|
||||||
@@ -184,7 +174,7 @@ highlight_block :: proc(code: string, lang: string, file_path: string) -> string
|
|||||||
}
|
}
|
||||||
cap := match.captures[capture_idx]
|
cap := match.captures[capture_idx]
|
||||||
name_len: u32
|
name_len: u32
|
||||||
name_c := ts.query_capture_name_for_id(g.query, cap.index, &name_len)
|
name_c := ts.query_capture_name_for_id(gc.query, cap.index, &name_len)
|
||||||
if name_c == nil {
|
if name_c == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -315,3 +305,4 @@ highlight_code :: proc(html: string, file_path: string) -> string {
|
|||||||
}
|
}
|
||||||
return strings.to_string(sb)
|
return strings.to_string(sb)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
+25
-86
@@ -3,11 +3,8 @@ package markdown
|
|||||||
import cm "vendor:commonmark"
|
import cm "vendor:commonmark"
|
||||||
|
|
||||||
import "core:encoding/json"
|
import "core:encoding/json"
|
||||||
import "core:log"
|
|
||||||
import "core:strings"
|
import "core:strings"
|
||||||
|
|
||||||
import diags "../diagnostics"
|
|
||||||
|
|
||||||
Extension :: enum {
|
Extension :: enum {
|
||||||
Emoji,
|
Emoji,
|
||||||
Sidenotes,
|
Sidenotes,
|
||||||
@@ -15,43 +12,22 @@ Extension :: enum {
|
|||||||
Highlight,
|
Highlight,
|
||||||
Sections,
|
Sections,
|
||||||
HeadingIDs,
|
HeadingIDs,
|
||||||
DefLists,
|
|
||||||
Footnotes,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_EXTENSIONS :: bit_set[Extension]{.Emoji, .Sidenotes, .Alerts, .HeadingIDs, .DefLists}
|
DEFAULT_EXTENSIONS :: bit_set[Extension]{.Emoji, .Sidenotes, .Alerts, .HeadingIDs}
|
||||||
|
|
||||||
EXTENSION_NAMES :: []string{
|
process :: proc(body: string, ext: bit_set[Extension], file_path: string) -> string {
|
||||||
"emoji", "sidenotes", "alerts", "highlight", "sections",
|
|
||||||
"heading_ids", "deflists", "footnotes",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Caller is responsible for freeing string
|
|
||||||
process :: proc(
|
|
||||||
body: string,
|
|
||||||
ext: bit_set[Extension],
|
|
||||||
file_path: string,
|
|
||||||
allocator := context.allocator,
|
|
||||||
) -> string {
|
|
||||||
side_notes := make(map[string]string)
|
side_notes := make(map[string]string)
|
||||||
margin_notes := make(map[string]string)
|
margin_notes := make(map[string]string)
|
||||||
clean_body := body
|
clean_body := body
|
||||||
if .Sidenotes in ext || .Footnotes in ext {
|
if .Sidenotes in ext {
|
||||||
clean_body, side_notes, margin_notes = strip_definitions(body)
|
clean_body, side_notes, margin_notes = strip_definitions(body)
|
||||||
}
|
}
|
||||||
if .DefLists in ext {
|
html := cm.markdown_to_html_from_string(clean_body, {.Unsafe})
|
||||||
clean_body = convert_deflists(clean_body, allocator)
|
|
||||||
}
|
|
||||||
original_html := cm.markdown_to_html_from_string(clean_body, {.Unsafe})
|
|
||||||
html := strings.clone(original_html, allocator)
|
|
||||||
cm.free_string(original_html)
|
|
||||||
|
|
||||||
if .Emoji in ext {
|
if .Emoji in ext {
|
||||||
html = expand_emoji(html)
|
html = expand_emoji(html)
|
||||||
}
|
}
|
||||||
if .Footnotes in ext {
|
if .Sidenotes in ext {
|
||||||
html = inject_footnotes(html, side_notes, margin_notes)
|
|
||||||
} else if .Sidenotes in ext {
|
|
||||||
html = inject_notes(html, side_notes, margin_notes)
|
html = inject_notes(html, side_notes, margin_notes)
|
||||||
}
|
}
|
||||||
if .Alerts in ext {
|
if .Alerts in ext {
|
||||||
@@ -73,80 +49,43 @@ process :: proc(
|
|||||||
parse_extension_list :: proc(s: string) -> (result: bit_set[Extension]) {
|
parse_extension_list :: proc(s: string) -> (result: bit_set[Extension]) {
|
||||||
for part in strings.split(s, ",", allocator = context.temp_allocator) {
|
for part in strings.split(s, ",", allocator = context.temp_allocator) {
|
||||||
name := strings.to_lower(strings.trim_space(part), allocator = context.temp_allocator)
|
name := strings.to_lower(strings.trim_space(part), allocator = context.temp_allocator)
|
||||||
e, ok := extension_from_name(name)
|
switch name {
|
||||||
if !ok {
|
case "emoji":
|
||||||
if name != "" {
|
result += {.Emoji}
|
||||||
suggestion := diags.suggest_correction(EXTENSION_NAMES, name)
|
case "sidenotes":
|
||||||
if suggestion != "" {
|
result += {.Sidenotes}
|
||||||
log.warnf("unknown extension '%s' (did you mean '%s'?)", name, suggestion)
|
case "alerts":
|
||||||
} else {
|
result += {.Alerts}
|
||||||
log.warnf("unknown extension '%s'", name)
|
case "highlight":
|
||||||
|
result += {.Highlight}
|
||||||
|
case "sections":
|
||||||
|
result += {.Sections}
|
||||||
|
case "heading_ids":
|
||||||
|
result += {.HeadingIDs}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue
|
|
||||||
}
|
|
||||||
result += {e}
|
|
||||||
}
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Given a map[Extension]bool, apply it to ext.
|
// Given a map[Extension]bool, apply it to ext.
|
||||||
apply_extension_config :: proc(ext: ^bit_set[Extension], config: json.Object) {
|
apply_extension_config :: proc(ext: ^bit_set[Extension], config: json.Object) {
|
||||||
for name, val in config {
|
for name, val in config {
|
||||||
|
// TODO: Silently discards invalid values.
|
||||||
enabled := val.(json.Boolean) or_continue
|
enabled := val.(json.Boolean) or_continue
|
||||||
|
|
||||||
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}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension_from_name :: proc(name: string) -> (e: Extension, ok: bool) {
|
|
||||||
switch name {
|
switch name {
|
||||||
case "emoji":
|
case "emoji":
|
||||||
e = .Emoji
|
if enabled {ext^ += {.Emoji}} else {ext^ -= {.Emoji}}
|
||||||
ok = true
|
|
||||||
case "sidenotes":
|
case "sidenotes":
|
||||||
e = .Sidenotes
|
if enabled {ext^ += {.Sidenotes}} else {ext^ -= {.Sidenotes}}
|
||||||
case "alerts":
|
case "alerts":
|
||||||
e = .Alerts
|
if enabled {ext^ += {.Alerts}} else {ext^ -= {.Alerts}}
|
||||||
case "highlight":
|
case "highlight":
|
||||||
e = .Highlight
|
if enabled {ext^ += {.Highlight}} else {ext^ -= {.Highlight}}
|
||||||
case "sections":
|
case "sections":
|
||||||
e = .Sections
|
if enabled {ext^ += {.Sections}} else {ext^ -= {.Sections}}
|
||||||
case "heading_ids":
|
case "heading_ids":
|
||||||
e = .HeadingIDs
|
if enabled {ext^ += {.HeadingIDs}} else {ext^ -= {.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}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,3 +43,4 @@ wrap_sections :: proc(html: string) -> string {
|
|||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,3 +46,4 @@ test_wrap_sections_doesnt_split_content :: proc(t: ^testing.T) {
|
|||||||
"<section><h1>Big</h1><h3>Small</h3></section>",
|
"<section><h1>Big</h1><h3>Small</h3></section>",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-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)
|
|
||||||
}
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user