mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
Compare commits
62 Commits
e71c2a94e5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5088f31a58 | |||
| 380c324623 | |||
| 695548bada | |||
| fde54c1f94 | |||
| 876f767548 | |||
| 0e8d770ac8 | |||
| cc33447ace | |||
| 2ac1d767cf | |||
| 2af8c68339 | |||
| b1e40c480b | |||
| 8c1f7647a1 | |||
| e7bb5126cb | |||
| f3ea6ab2a9 | |||
| ed842e4bea | |||
| 8d67108984 | |||
| 58e6da6a2c | |||
| 3ad59c0761 | |||
| 6a4debe35a | |||
| b3c2d4745d | |||
| 8404227c22 | |||
| 55a0d25173 | |||
| 604a80ca42 | |||
| 806c153979 | |||
| 4c41d85385 | |||
| c92d5e5611 | |||
| afb98c3bc4 | |||
| 7eb116c731 | |||
| 616880b3da | |||
| e4f5c63346 | |||
| dd77289f5b | |||
| dd76a8e83c | |||
| 10be1b92c4 | |||
| 49d08c3129 | |||
| 6eebaa0a6c | |||
| e1b2c9d321 | |||
| 86c5428f77 | |||
| 4ed581373b | |||
| 5e2bee4897 | |||
| 54d3bed630 | |||
| 073695b7c7 | |||
| 4d2fdf284d | |||
| 25551a4fb6 | |||
| b06cf6f8b1 | |||
| a420803b3e | |||
| 040c462bba | |||
| fa8179c9b9 | |||
| 8416411c62 | |||
| f54da58698 | |||
| f370da6e55 | |||
| ceae8bfa8b | |||
| e64cd929e6 | |||
| de328b3bcb | |||
| 8cb7d1243b | |||
| 126548cb29 | |||
| c289c3c7f9 | |||
| d1d0af60e7 | |||
| 6d7b88a8d6 | |||
| f1c7d02bc1 | |||
| 6ec8fcf2a1 | |||
| b81aa38c62 | |||
| 9a35fa2051 | |||
| 178b02da74 |
@@ -2,6 +2,18 @@
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
@@ -41,10 +53,10 @@ thor/
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `main.odin` | Entry point. Sets `context.logger`, calls `init_site`, `build_vfs`, wires `treesitter.grammar_dir`/`query_dir` from config, `site_load_content`, `render_site`. Optional Spall profiling via `SPALL` config flag. |
|
||||
| `site.odin` | `Flags` (CLI), `Config_File` (thor.json), `Site_Context` (template-facing: `title`, `description`, `base_url`, `params`, `og`, `menus`), `Site` (runtime state + arena + VFS + pages + `og`). `Feature` enum. 5-step `init_site`. Config menu parsing in `site_apply_config`. |
|
||||
| `content.odin` | `Page` struct (includes `weight`, `menus: map[string]Menu_Entry`, `og`), `Pending_File` struct, `scan_content_files` (section-aware walk that handles leaf bundles), `collect_languages` (pre-scan for code fence languages), `load_page` (falls back to file mtime when no frontmatter date), `infer_layout`. Calls `md.process()` for the markdown pipeline. |
|
||||
| `render.odin` | Template rendering: `render_site`, `render_page_html`, `render_home_html`, `render_section`. `Template_Context` (unified render struct with `site: Site_Context`, `page: Page`, `menus`, `posts`, `pages`). 3-frame context stack via `[]any{ctx.site, ctx.page, ctx}`. `sort_pages` (weight primary, date secondary). `to_title_case` for section display names. VFS-based template loading with fallback chain (`get_template`). |
|
||||
| `main.odin` | Entry point. Parses CLI flags via `core:flags`, sets logger level from `-verbose`/`-quiet`, calls `init_site`, `build_vfs`, wires `treesitter.grammar_dir`/`query_dir` from config, `site_load_content`, `render_site`. Optional Spall profiling via `SPALL` config flag. |
|
||||
| `site.odin` | `Flags` (CLI, includes `-verbose`/`-quiet`), `Config_File` (thor.json), `Site_Context` (template-facing: `title`, `description`, `base_url`, `params`, `og`, `menus`), `Site` (runtime state + arena + VFS + pages + `og`). `Feature` enum. `init_site(site, flags)` — takes pre-parsed `Flags`. Config menu parsing in `site_apply_config`. |
|
||||
| `content.odin` | `Page` struct (includes `weight`, `menus`, `params: json.Value`, `toc: string`, `og`), `Pending_File` struct, `scan_content_files` (section-aware walk that handles leaf bundles), `collect_languages` (pre-scan for code fence languages), `load_page` (falls back to file mtime, generates TOC via `md.generate_toc` when frontmatter `"toc": true`), `infer_layout`. Calls `md.process()` for the markdown pipeline. |
|
||||
| `render.odin` | Template rendering: `render_site`, `render_page_html`, `render_home_html`, `render_section`. `Template_Context` (unified render struct with `site: Site_Context`, `page: Page`, `menus`, `params`, `posts`, `pages`). 3-frame context stack via `[]any{ctx.site, ctx.page, ctx}`. `merge_params(site, page)` — shallow merge of site + page params. Error deduplication via `seen: ^map[string]bool` passed through render chain. `sort_pages` (weight primary, date secondary). `to_title_case` for section display names. VFS-based template loading with fallback chain (`get_template`). |
|
||||
| `menus.odin` | Menu system: `Menu_Entry {name, url, weight: Maybe(int)}`, `DEFAULT_WEIGHT = 10`. `build_menus` (priority chain: config → auto + page frontmatter, then `warn_all_duplicate_weights`). `collect_auto_menus` (sections + root-level pages, skips pages with explicit `"menus": "main"` frontmatter). `merge_page_menus` (frontmatter entries with effective weight fallback via nil check). `parse_page_menus` (string/array/object forms). `parse_config_menus` (from thor.json). `sort_menu_entries` / `compare_menu_entries` (weight primary via `.? or_else DEFAULT_WEIGHT`, name secondary). `warn_duplicate_weights` / `warn_all_duplicate_weights` (log when two entries in same menu have same explicitly-set weight). |
|
||||
| `minify.odin` | HTML/CSS minification via tree-sitter. Imports `ts "treesitter"`. |
|
||||
| `feed.odin` | RSS feed + sitemap XML. Uses `page.url` for canonical URLs. |
|
||||
@@ -52,7 +64,7 @@ thor/
|
||||
| `assets.odin` | `copy_assets_dir` — iterates VFS entries with `assets/` prefix, minifies CSS, copies verbatim or via `os.copy_file`. |
|
||||
| `html.odin` | `strip_html_tags`, `unescape_html`, `generate_summary` (word-count truncation, zero-alloc), `generate_description` (HTML→plain text: strip tags, decode entities, collapse whitespace). |
|
||||
| `opengraph.odin` | `Open_Graph` struct (fields ordered per OGP spec, `is_article: Maybe(bool)`). `og_for_site(site)` for site defaults (from config + derived), `og_for_page(site_og, page)` for page-specific (overlay page.og + derive from page data). Description falls back to `generate_description(generate_summary(body_html))`. |
|
||||
| `frontmatter.odin` | JSON frontmatter parser (`{ }` delimited). Supports `layout`, `lastmod`, `weight: Maybe(int)`, `menus`, and nested `og` object (via `json_get_open_graph`). Helpers: `json_get_string`, `json_get_bool`, `json_get_int` (returns `Maybe(int)`, nil for absent/invalid). |
|
||||
| `frontmatter.odin` | JSON frontmatter parser (`{ }` delimited). Supports `layout`, `lastmod`, `weight: Maybe(int)`, `menus`, `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). |
|
||||
| `defaults.odin` | `DEFAULTS_PATH` constant, resolved at compile time via `#directory` so bundled templates ship in the binary. |
|
||||
|
||||
### Subpackages
|
||||
@@ -60,13 +72,15 @@ thor/
|
||||
| Package | Files | Responsibility |
|
||||
|---|---|---|
|
||||
| `treesitter/` | `treesitter.odin` | FFI types (`Parser`, `Node`, `Query`, etc.), `@(link_prefix="ts_")` foreign bindings, grammar management (`Grammar_Store` with persistent allocator, `load_language`/`compile_query` building blocks, `ensure_parser`/`load_grammar` lazy loading, `preload_grammar`/`preload_grammars` for parallel loading with `sync.Mutex` cache protection), statically-linked HTML/CSS grammars |
|
||||
| `markdown/` | `markdown.odin` | `Extension` enum, `DEFAULT_EXTENSIONS`, `process(body, ext, file_path)` — full pipeline, `parse_extension_list`, `apply_extension_config` |
|
||||
| | `footnotes.odin` | `strip_definitions` (pre-cmark), `inject_notes` (post-cmark) |
|
||||
| `markdown/` | `markdown.odin` | `Extension` enum, `DEFAULT_EXTENSIONS`, `process(body, ext, file_path, allocator)` — full pipeline (clones cmark output, frees original), `parse_extension_list`, `apply_extension_config` |
|
||||
| | `footnotes.odin` | `strip_definitions` (pre-cmark, shared by `.Sidenotes` + `.Footnotes`), `inject_notes` (post-cmark sidenote rendering), `inject_footnotes` (post-cmark standard footnote rendering — numbered `<sup>` links + `<section class="footnotes"><ol>` at bottom). `.Sidenotes` and `.Footnotes` are mutually exclusive; `resolve_extension_conflicts` in `markdown.odin` picks `.Footnotes` if both are set. |
|
||||
| | `alerts.odin` | `inject_alerts` — GitHub alert blocks (`> [!NOTE]`) → styled blockquotes with semantic class names (`alert-note` etc.) |
|
||||
| | `emoji.odin` | `expand_emoji` — `:shortcode:` → unicode emoji |
|
||||
| | `sectionate.odin` | `wrap_sections` — splits HTML at `<h2>` into `<section>` wrappers |
|
||||
| | `highlight.odin` | Syntax highlighting via tree-sitter. Imports `../treesitter`. |
|
||||
| | `heading_ids.odin` | `inject_heading_ids` — adds `id` attributes to `<h1>`-`<h6>` from heading text. Slug-based, deduplicated. |
|
||||
| | `deflists.odin` | `convert_deflists` — pre-cmark pass. Scans for definition list patterns (`term\n\n: definition`) and converts to `<dl><dt><dd>` HTML blocks. Terms and definitions rendered through cmark individually for inline markdown. Consecutive pairs grouped into single `<dl>`. |
|
||||
| | `toc.odin` | `generate_toc(html, allocator)` — page-level feature (not a pipeline extension). Scans `<h1>`-`<h6>` for IDs (after `inject_heading_ids`), builds nested `<ul>` with `<a href="#id">` links. Called from `load_page` when frontmatter `"toc": true`. Depends on `.HeadingIDs` being enabled. |
|
||||
| `mustache/` | See [Mustache engine](#mustache-engine) below | Template engine |
|
||||
| `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). |
|
||||
|
||||
@@ -101,13 +115,15 @@ Page :: struct {
|
||||
weight: Maybe(int), // page ordering (nil = unset, defaults to DEFAULT_WEIGHT at comparison time)
|
||||
lastmod: string,
|
||||
menus: map[string]Menu_Entry, // frontmatter menu assignments
|
||||
params: json.Value, // per-page params (merged with site params at render time)
|
||||
content: string, // rendered HTML body
|
||||
og: Open_Graph,
|
||||
draft: bool,
|
||||
starred: bool,
|
||||
toc: string, // generated table of contents HTML (empty if not requested)
|
||||
_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)`:
|
||||
|
||||
@@ -156,13 +172,13 @@ All weight fields use `Maybe(int)` — nil means "unset," `some(v)` means explic
|
||||
|
||||
Config is split into three structs with a clear 5-step initialization flow:
|
||||
|
||||
- **`Flags`** — CLI args only. Parsed by `core:flags`. Includes path overrides (`--content`, `--assets`, `--output`, `--layouts`), build-mode toggles (`-drafts`, `-watch`, `-minify`), and `-ext`/`-no-ext` for markdown extension overrides.
|
||||
- **`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.
|
||||
- **`Config_File`** — parsed from `thor.json` via `json.unmarshal_string`. Holds title, paths, `markdown_extensions` (JSON), `params` (JSON), `modules` (JSON array of relative paths), `og` (`Open_Graph` struct for site-level OG defaults).
|
||||
- **`Site`** — runtime state: arena, pages, modules, VFS, `features: bit_set[Feature]`, `markdown_extensions: bit_set[md.Extension]`, `og: Open_Graph` (resolved site-level OG).
|
||||
|
||||
**`Feature` enum** — `Drafts`, `Minify`, `Watch`. Checked with `.Minify in site.features`.
|
||||
|
||||
**`markdown.Extension` enum** (in the `markdown` package, not main) — `Emoji`, `Sidenotes`, `Alerts`, `Highlight`, `Sections`, `HeadingIDs`. Default is `md.DEFAULT_EXTENSIONS` (currently `.Emoji, .Sidenotes, .Alerts, .HeadingIDs`). Configurable via:
|
||||
**`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:
|
||||
- `thor.json`: `"markdown_extensions": { "emoji": true, "highlight": false, ... }`
|
||||
- CLI: `-ext:highlight,sections` (enable) / `-no-ext:emoji` (disable). Comma-separated, case-insensitive.
|
||||
|
||||
@@ -242,10 +258,12 @@ Lives in the `markdown` package. Entry point: `md.process(body, ext, file_path)`
|
||||
|
||||
```
|
||||
raw markdown
|
||||
→ md.strip_definitions (if .Sidenotes — pre-cmark)
|
||||
→ md.strip_definitions (if .Sidenotes || .Footnotes — pre-cmark)
|
||||
→ md.convert_deflists (if .DefLists — pre-cmark)
|
||||
→ cmark markdown_to_html (Unsafe mode for HTML passthrough)
|
||||
→ md.expand_emoji (if .Emoji — post-cmark)
|
||||
→ md.inject_notes (if .Sidenotes — post-cmark)
|
||||
→ md.inject_notes (if .Sidenotes — post-cmark, sidenote rendering)
|
||||
→ md.inject_footnotes (if .Footnotes — post-cmark, standard footnote rendering)
|
||||
→ md.inject_alerts (if .Alerts — post-cmark)
|
||||
→ md.highlight_code (if .Highlight — post-cmark)
|
||||
→ md.inject_heading_ids (if .HeadingIDs — post-cmark, pre-sections)
|
||||
@@ -277,17 +295,17 @@ Template_Context :: struct {
|
||||
site: Site_Context, // site-level data (title, description, base_url, params, og)
|
||||
menus: map[string][]Menu_Entry, // generated menu data (copied from site, resolves above Page.menus)
|
||||
now: string, // UTC ISO 8601 build timestamp
|
||||
title: string, // computed browser title ("Page | Site")
|
||||
date_format: string, // from site.date.format (thor.json)
|
||||
timezone: ^datetime.TZ_Region, // for format pipe
|
||||
og: Open_Graph, // computed per-page OG
|
||||
params: json.Value, // merged site + page params (resolves above Page.params)
|
||||
page: Page, // current page
|
||||
pages: [dynamic]Page, // home page list
|
||||
posts: [dynamic]Page, // section post list
|
||||
}
|
||||
```
|
||||
|
||||
`Site_Context` is embedded in `Site` via `using site_context`. Fields like `site.title`, `site.menus`, `site.params` are accessed directly on `Site` through promotion. `Template_Context.menus` is copied from `site.menus` to resolve above `Page.menus` (frontmatter assignments) on the context stack.
|
||||
`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).
|
||||
|
||||
@@ -399,12 +417,11 @@ Spec-compliant implementation at `mustache/`. See `mustache/SPEC.md` for the imp
|
||||
|---|---|
|
||||
| `mustache.odin` | Public API (`parse`, `render`, `Template`), parser (`parse_section`), renderer (`render_nodes` with `Indent_State` for partial indentation), template inheritance (`merge_block_overrides`), `delete_template`/`delete_partials`. Pipe support in Variable/Unescaped/Section/Inverted tags. |
|
||||
| `tokenizer.odin` | Tokenizer (template string → `[]Token`), standalone whitespace detection |
|
||||
| `data.odin` | Reflection-based data model: `base_value` (peels union/any/nested-any layers), `lookup_in` (structs + maps, handles `Type_Info_Any` value kind in maps), `resolve_name`, `is_truthy`, `any_to_string`, `list_info`, `extract_list_element`, `call_interp_lambda`/`call_section_lambda` |
|
||||
| `pipes.odin` | Pipes extension: `Pipe_Filter` AST, `parse_pipeline` (takes `pos`), `apply_pipeline`, `apply_filter` (switch dispatch: `group_by` + `format`), `apply_group_by`, `apply_format`. Stored on `Node.filters`; render-scoped results in temp allocator. |
|
||||
| `data.odin` | Reflection-based data model: `base_value` (peels union/any/nested-any layers), `lookup_in` (structs + maps, handles `Type_Info_Any` value kind in maps), `resolve_name`, `is_truthy`, `any_to_string`, `write_value`, `list_info`, `extract_list_element`, `collect_map_keys` |
|
||||
| `pipes.odin` | Pipes extension: `Pipe_Op` enum (`.Format`, `.Group_By`), `pipe_op_from_string`/`pipe_op_candidates` (reflection-based enum name lookup), `Pipe_Filter` AST (with `op_pos` for diagnostics), `parse_pipeline` (tracks byte offsets via `strings.index`), `apply_pipeline`, `apply_filter` (exhaustive enum switch), `apply_group_by`, `apply_format`. Misspelled pipe op suggestions via `suggest_correction`. Stored on `Node.filters`; render-scoped results in temp allocator. |
|
||||
| `diagnostic.odin` | Rust-style error formatter: `format_error` (multi-line context, ANSI colors via `core:terminal/ansi`, `colorize` param), `format_render_error` (formats `Error`), `line_col`, `line_text`, `context_extent`, `count_lines`, `digit_count`, `should_colorize`. |
|
||||
| `suggest.odin` | Strict-warning helpers: `validate_key_path` (walks dotted path, crosses maps silently), `suggest_correction` (Levenshtein via `core:strings/levenshtein_distance`), `collect_struct_keys` (via reflection, recurses into `using`), `struct_has_field` (distinguishes missing field from nil value — needed for `Maybe(bool)`), `collect_partial_names`, `collect_block_names`. |
|
||||
| `spec_test.odin` | JSON spec test runner — loads `spec/specs/*.json`, runs each test case. Uses `log.nil_logger()` to suppress expected warnings. |
|
||||
| `lambda_test.odin` | Spec lambda tests |
|
||||
| `pipes_test.odin` | Pipe filter tests (`group_by` + `format`) |
|
||||
| `diagnostic_test.odin` | Golden-output tests for `format_error` (multi-line context, edge cases, alignment, caret position, hint) + parser error message brace-escaping |
|
||||
| `suggest_test.odin` | Tests for `validate_key_path`, `suggest_correction`, `struct_has_field` with `Maybe(bool)` and `using`-promoted fields |
|
||||
@@ -432,7 +449,11 @@ render(tmpl, data, partials) → render_nodes (walks flat node array against con
|
||||
|
||||
Rust-style error messages with multi-line source context, caret underlines, and Levenshtein suggestions. ANSI colors via `core:terminal/ansi`, gated on `should_colorize()` (TTY detection on stderr).
|
||||
|
||||
**Error types**: `Error_Body{msg, pos, kind}` 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 types**: `Error_Body{msg, pos, kind, source, path, span, hint}` where `kind` is `Error_Kind.Syntax` (parse-time) or `Error_Kind.Data` (render-time). `source`/`path` carry the template the error originated in (set by `tag_error` — enables correct file/line for errors inside partials). `span` controls caret underline width (used by pipe op diagnostics). `hint` carries "did you mean?" suggestions. `Error` is a single-variant union wrapping `Error_Body` (nilable for `!= nil` / `or_return`).
|
||||
|
||||
**Error deduplication**: `render_template` takes `seen: ^map[string]bool`. Duplicate errors (same formatted diagnostic) are suppressed across page renders within a single build.
|
||||
|
||||
**Partial source tracking**: `tag_error(err, current)` stamps render-time errors with `current.source`/`current.path` at the 4 `apply_pipeline` call sites in `render_nodes`. Ensures errors inside partials point at the partial file, not the top-level template.
|
||||
|
||||
**Strict-by-default warnings** — `render_nodes` emits `log.warnf` diagnostics for:
|
||||
- Unknown keys in `{{k}}`, `{{{k}}}`, `{{#k}}`, `{{^k}}` (via `validate_key_path` + `suggest_correction`)
|
||||
@@ -442,18 +463,12 @@ Rust-style error messages with multi-line source context, caret underlines, and
|
||||
|
||||
**Exceptions** (no warning):
|
||||
- `{{.}}` and dot-prefixed names (current context)
|
||||
- Paths that cross a map (e.g., `params.*` — user-defined namespace)
|
||||
- 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
|
||||
- `Maybe(bool)` fields with nil value (field exists, value is nil — distinguished via `struct_has_field`)
|
||||
- Found fields whose value is nil/empty (e.g., nil `json.Value` union) — the field exists, looking up sub-keys is valid "not found" behavior
|
||||
|
||||
**Block override source tracking**: `Block_Override.source: Template` ensures warnings inside block overrides point at the override's source file (e.g., `page.html`), not the parent template (`base.html`).
|
||||
|
||||
### Lambdas
|
||||
|
||||
Spec-compliant. Stored as `any` values in the data context.
|
||||
|
||||
- **Interpolation lambdas**: `proc() -> string`, `proc() -> int`, `proc() -> bool` — called via `call_interp_lambda`, result stringified and escaped.
|
||||
- **Section lambdas**: `proc(string) -> string`, `proc(string) -> int`, `proc(string) -> bool` — called via `call_section_lambda` with the raw section text (`node.content`). String result is re-parsed as mustache and rendered against the current context stack.
|
||||
|
||||
### Pipes
|
||||
|
||||
`{{key | op args…}}` for interpolation, `{{#key | op args…}}…{{/key}}` for sections. Stored as `[dynamic; MAX_PIPES]Pipe_Filter` on each `Node`. Applied in the renderer via `apply_pipeline` before truthiness/interpolation. Implemented filters:
|
||||
@@ -470,12 +485,13 @@ See `mustache/EXTENSIONS.md`.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- cmark allocates via C malloc, not the arena. HTML output leaks until process exit (problematic in watch mode — see `TODOS.md`).
|
||||
- CSS/JS cache busting uses manual `?v=N` query params instead of content hashing.
|
||||
- Tree-sitter grammar/query paths must be configured manually via `thor.json` (`grammars`, `queries`) — no auto-discovery. HTML/CSS are statically linked.
|
||||
- `map[string]any` only works through `lookup_in`'s special-case handling; thor otherwise uses structs.
|
||||
- `format_f64` in mustache brute-forces shortest float representation.
|
||||
- Content directory not mounted in VFS (modules can ship templates/assets but not content packs yet).
|
||||
- Per-page params rendering is incomplete — `merge_params` produces correct data, but `base_value` may return nil for `json.Value` fields accessed through the `[]any` context stack via reflection in some cases. Warning suppression masks this; actual rendering may not work for all param values.
|
||||
- Lambda support removed. Mustache lambdas (`proc() -> string` in data context) are not supported. Pipes cover data transformation.
|
||||
|
||||
## Design decisions
|
||||
|
||||
@@ -483,6 +499,8 @@ You may never, *ever* remove `TODO:` or `FIXME:` comments. Those are for humans,
|
||||
See `HUGO.md` for analysis of why thor doesn't need Hugo's shortcode context isolation.
|
||||
See `mustache/SPEC.md` for the original implementation specification.
|
||||
See `mustache/EXTENSIONS.md` for non-standard extensions (pipes).
|
||||
See `DIAGNOSTICS.md` for the two-tier diagnostic system design.
|
||||
See `mustache/TOKENIZERS.md` for tokenizer architecture comparison (Go, Liquid, Thor).
|
||||
|
||||
## Odin language facts
|
||||
|
||||
@@ -497,6 +515,7 @@ These are things that are easy to get wrong:
|
||||
- **`Maybe(T)` unwrap syntax:** `value.? or_else default`. Not `value or_else default` — `or_else` works on the `?T` returned by `.?`, not on `Maybe(T)` directly.
|
||||
- **`Maybe(T)` equality:** `a == b` works directly between two `Maybe(T)` values (nil == nil → true, some(5) == some(5) → true, nil == some(5) → false). Also `a == 5` works (int coerces to `Maybe(int)`).
|
||||
- **File logger in tests:** `log.create_file_logger(&f)` + `context.logger = logger` captures log output. Must be set inline in the test proc (not via a helper proc) for context propagation. Clean up with `log.destroy_file_logger(logger)` then `os.read_entire_file_from_path` to verify output.
|
||||
- **`fmt.sbprintf` writes directly to a `strings.Builder`.** Prefer `fmt.sbprintf(&sb, fmt, args...)` over `fmt.aprintf(fmt, args...)` + `defer delete` + `strings.write_string`. The `aprintf` pattern allocates an intermediate string, requires manual cleanup, and queues a `defer delete` per loop iteration. `sbprintf` avoids all of this.
|
||||
|
||||
## TODO
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
name: Missing 2nd closing brace
|
||||
input: |
|
||||
<menu>
|
||||
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}</a>
|
||||
{{#params.social}}
|
||||
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
{{/params.social}}
|
||||
</menu>
|
||||
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
|
||||
expected: Somthing that points to the opening and lack of close.
|
||||
actual: |
|
||||
[ERROR] --- [383:load_partials()] unexpected {{/params.social}}
|
||||
--> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:6:5
|
||||
|
|
||||
4 | {{#params.social}}
|
||||
5 | <a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
6 | {{/params.social}}
|
||||
| ^^^^^^^^^^^^^^^^^^
|
||||
7 | </menu>
|
||||
8 | <p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
---
|
||||
name: Missing 2nd opening brace
|
||||
input: |
|
||||
<menu>
|
||||
<a class="goto-top opacity-0" href="#">{> icons/chevron_up}}</a>
|
||||
{{#params.social}}
|
||||
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
{{/params.social}}
|
||||
</menu>
|
||||
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
expected: Somthing that shows the whole block, and points out the missing opening brace
|
||||
actual: Nothing. The page compiles with no warnings or errors
|
||||
---
|
||||
name: Invalid timezone
|
||||
input: |
|
||||
{
|
||||
"date": {
|
||||
"timezone": "America/New_Yorkside"
|
||||
}
|
||||
}
|
||||
expected: "Did you mean 'America/New_York'? pointing to line+col no in the config"
|
||||
actual: |
|
||||
[WARN ] --- [148:init_site()] unable to load timezone 'America/New_Yorkside'
|
||||
---
|
||||
name: Mismatched section tags
|
||||
input: |
|
||||
<footer>
|
||||
<menu>
|
||||
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
{{#params.ocial}}
|
||||
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
{{/params.social}}
|
||||
</menu>
|
||||
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
|
||||
</p>
|
||||
<p><small>©</small> {{now | format "2006" }} {{params.author.name}}</p>
|
||||
</footer>
|
||||
expected: Mostly the same, but with rust style markers at the opening and close
|
||||
actual: |
|
||||
[ERROR] --- [383:load_partials()] expected {{/params.ocial}}, got {{/params.social}}
|
||||
--> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:6:5
|
||||
|
|
||||
4 | {{#params.ocial}}
|
||||
5 | <a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
6 | {{/params.social}}
|
||||
| ^^^^^^^^^^^^^^^^^^
|
||||
7 | </menu>
|
||||
8 | <p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
---
|
||||
name: Missing closing section
|
||||
input: |
|
||||
<footer>
|
||||
<menu>
|
||||
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
{{#params.social}}
|
||||
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
<!-- {{/params.social}} -->
|
||||
</menu>
|
||||
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
|
||||
</p>
|
||||
<p><small>©</small> {{now | format "2006" }} {{params.author.name}}</p>
|
||||
</footer>
|
||||
expected: "Missing closing tag '{{/params.social}}' ...opened <here> ... expected close <here>"
|
||||
actual: Nothing. The page compiles with no warnings or errors
|
||||
---
|
||||
name: empty tag
|
||||
input: |
|
||||
<footer>
|
||||
<menu>
|
||||
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
{{#params.social}}
|
||||
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
{{/params.social}}
|
||||
</menu>
|
||||
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
|
||||
</p>
|
||||
<p><small>©</small> {{}} {{params.author.name}}</p>
|
||||
</footer>
|
||||
expected: "Found an empty tag at <line:col>"
|
||||
actual: |
|
||||
[WARN ] --- [920:warn_unknown_key()] unknown key ''
|
||||
--> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:1:1
|
||||
|
|
||||
1 | <footer>
|
||||
| ^
|
||||
2 | <menu>
|
||||
3 | <a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
|
|
||||
---
|
||||
name: Missing pipe arguement
|
||||
input: |
|
||||
<footer>
|
||||
<menu>
|
||||
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
{{#params.social}}
|
||||
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
{{/params.social}}
|
||||
</menu>
|
||||
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
|
||||
</p>
|
||||
<p><small>©</small> {{now | group_by }} {{params.author.name}}</p>
|
||||
</footer>
|
||||
expected: "Missing filter argument <pointing to exact location>"
|
||||
actual: |
|
||||
[ERROR] --- [152:render_template()] group_by expects 1 argument, got 0
|
||||
--> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:1:1
|
||||
|
|
||||
1 | <footer>
|
||||
| ^
|
||||
2 | <menu>
|
||||
3 | <a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
|
|
||||
---
|
||||
name: Double dot access
|
||||
input: |
|
||||
<footer>
|
||||
<menu>
|
||||
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
{{#params.social}}
|
||||
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
{{/params.social}}
|
||||
</menu>
|
||||
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
|
||||
</p>
|
||||
<p><small>©</small> {{now | format "2006" }} {{params..name}}</p>
|
||||
</footer>
|
||||
expected: Error "Invalid access key"
|
||||
actual: Nothing. The page compiles with no warnings or errors
|
||||
---
|
||||
name: Too many opening braces
|
||||
input: |
|
||||
<footer>
|
||||
<menu>
|
||||
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
{{#params.social}}
|
||||
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
{{/params.social}}
|
||||
</menu>
|
||||
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
|
||||
</p>
|
||||
<p><small>©</small> {{{{now | format "2006" }}} {{params.author.name}}</p>
|
||||
</footer>
|
||||
expected: "Too many opening braces <point to location>"
|
||||
actual: |
|
||||
[WARN ] --- [920:warn_unknown_key()] unknown key '{now'
|
||||
--> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:1:1
|
||||
|
|
||||
1 | <footer>
|
||||
| ^ did you mean 'now'?
|
||||
2 | <menu>
|
||||
3 | <a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
|
|
||||
---
|
||||
name: Too many closing braces
|
||||
input: |
|
||||
<footer>
|
||||
<menu>
|
||||
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
{{#params.social}}
|
||||
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
{{/params.social}}
|
||||
</menu>
|
||||
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
|
||||
</p>
|
||||
<p><small>©</small> {{now | format "2006" }}}} {{params.author.name}}</p>
|
||||
</footer>
|
||||
expected: "Too many closing braces <Point to location>"
|
||||
actual: Nothing. The page compiles with no warnings or errors
|
||||
---
|
||||
name: Frontmatter missing closing quote on key
|
||||
input: |
|
||||
{
|
||||
"title: Docs"
|
||||
}
|
||||
|
||||
[TOC]
|
||||
|
||||
## Introduction
|
||||
expected: 'TODO:'
|
||||
actual: |
|
||||
[ERROR] --- [42:parse_frontmatter()] failed to parse frontmatter JSON: Expected_Colon_After_Key
|
||||
---
|
||||
name: Fronmatter missing opening quote on value
|
||||
input: |
|
||||
{
|
||||
"title": Docs"
|
||||
}
|
||||
|
||||
[TOC]
|
||||
|
||||
## Introduction
|
||||
expected: 'detailed diagnostic pointing to the point of failure and suggesting the addition of a quote'
|
||||
actual: Nothing. The page compiles with no warnings or errors
|
||||
---
|
||||
name: No error when missing index page.
|
||||
expected: 'Your site has no index page! Please create an index.md or index.html file in ./content/'
|
||||
actual: Nothing. The site compiles with no warnings or errors
|
||||
---
|
||||
name: format pipe with no date format set
|
||||
expected: |
|
||||
Show where the error was triggered. Should also show the format that will be used
|
||||
actual: |
|
||||
[ERROR] --- [333:apply_format()] format pipe used but no date format configured (set date.format in thor.json) Default will be used
|
||||
@@ -1,87 +0,0 @@
|
||||
# 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
|
||||
@@ -1,8 +1,24 @@
|
||||
## High priority
|
||||
|
||||
- Polish existing features before moving on to new ones.
|
||||
- [ ] Do mustache's whitespace rules actually suit us, or should we make our own?
|
||||
- [x] `{{>title}}` default partial? `{{site.title}} | {{ page.title }}`
|
||||
- [ ] implicit titles (Set when missing?)
|
||||
- [ ] create a default `head.html`.
|
||||
- [ ] html comments are rendered, except in minify mode.
|
||||
- [ ] does it make sense for partials to be inside layouts?
|
||||
- current: `layouts/partials`
|
||||
- alt1: `layouts`, `partials`
|
||||
- alt2: `templates/layouts`, `templates/partials`
|
||||
- [ ] [aliases](https://gohugo.io/methods/page/aliases/#redirects)?
|
||||
- [ ] Improve diagnostics
|
||||
- [ ] keep track of every error and don't report them more than once.
|
||||
- [x] keep track of every error and don't report them more than once.
|
||||
- [ ] show parsed arg when -extension is unrecognized
|
||||
- [ ] also do typo detection?
|
||||
- [ ] `*` make sure the frontmatter parser has good diagnostics.
|
||||
- [ ] fix the diagnostics in [DIAGNOSTIC TODOS](./DIAGNOSTIC_TODOS.yaml)
|
||||
- [ ] only report format errors once.
|
||||
- [ ] only report missing partiall errors once.
|
||||
- [ ] All Diagnostics should show:
|
||||
- [ ] *What* went wrong
|
||||
- [ ] *where* (in the file)
|
||||
@@ -14,9 +30,6 @@
|
||||
- [ ] better diagnostics for syntax errors in treesitter.
|
||||
- [ ] Ensure diagnostics for MAX_CONTEXT_DEPTH are good.
|
||||
- [ ] improve matching weights message.
|
||||
- [ ] show a proper diagnostic for timezones
|
||||
- currently "unable to load timezone 'America/New_Yorkskie'"
|
||||
- want rust style diagnostic and better message, maybe "unknown timezone 'America/New_Yorkskie'"
|
||||
- [ ] Test menu diagnostics
|
||||
- [ ] Honestly, Test **all** diagnostics
|
||||
- [ ] Need to be careful about diagnostics across module boundaries.
|
||||
@@ -27,21 +40,25 @@
|
||||
the correct template source/path. Other render errors still use the
|
||||
content template's source/path, which can point at the wrong file.
|
||||
- [ ] try to make file paths clickable links.
|
||||
- [ ] centralize diagnostics to one place.
|
||||
- [ ] consider logging the number of times an error occurred.
|
||||
- [ ] Load grammars dynamically
|
||||
- [ ] starred must be a param.
|
||||
- [x] starred must be a param.
|
||||
- [ ] Documentation
|
||||
- [ ] talk about the context stack (and its limit).
|
||||
- [ ] highlight the differences in the way menus are handled.
|
||||
- [ ] consider sites with data based urls.
|
||||
- [ ] Don't show annoying log output in tests.
|
||||
- [ ] consider sites with date based urls.
|
||||
- [x] Don't show annoying log output in tests.
|
||||
- [ ] improve home link customization.
|
||||
- [ ] currently an accessibility issue.
|
||||
- [ ] support JSON5 in in frontmatter
|
||||
- [x] support JSON5 in in frontmatter
|
||||
- [ ] Create a json schema file for `thor.json`.
|
||||
- [ ] cleanup `#partial switch`es.
|
||||
- [x] cleanup `#partial switch`es.
|
||||
- [ ] improve json diagnostics.
|
||||
- i.e. "Missing quotes around string", etc.
|
||||
- [ ] don't use bullshit "sub-tokens", add filters and pipes as proper tokens.
|
||||
- [ ] Ideas is now in the wrong spot. Date is wrong, and it is showing date
|
||||
when it shouldn't be.
|
||||
|
||||
## Performance
|
||||
|
||||
@@ -91,9 +108,19 @@
|
||||
|
||||
## 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.
|
||||
- [x] Add conventional (Hugo style) footnotes option.
|
||||
- [x] Add opt-in deflist support.
|
||||
- [x] Decide if lambdas actually provide any value.
|
||||
- [ ] add tables extension
|
||||
- [x] Table of contents support.
|
||||
- [ ] enable template level rendering of TOCs
|
||||
- [ ] Write css for toc sidebar and figure out where to put it.
|
||||
- [ ] Add [hugo style configuration](https://gohugo.io/configuration/markup/#table-of-contents)
|
||||
- [ ] Link checker?
|
||||
- Checks all links on each page to make sure they are valid.
|
||||
- [ ] Peruse [GitHub's](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts)
|
||||
docs for any juicy nuggets we may have missed.
|
||||
- [ ] Avoid using `render_inline_md` if possible.
|
||||
|
||||
## Dates
|
||||
- [ ] display an error when no part of the date appears in the output.
|
||||
@@ -105,6 +132,9 @@
|
||||
- [ ] configure opt-out of automatic sections being added to menu.
|
||||
- [ ] nested menus (i.e. `parent` support)
|
||||
- [ ] get rid of the global variables in the `treesitter` package.
|
||||
- [ ] enforce heading structure.
|
||||
- [ ] Either frontmatter.title set, or 1 h1 tag at top, not both
|
||||
- [ ] No skipping.
|
||||
- [ ] Consider using `or_else` when applying default values to structs. i.e.
|
||||
```odin
|
||||
package main
|
||||
@@ -136,7 +166,7 @@ main :: proc () {
|
||||
- [ ] etc
|
||||
- [ ] Avoid `json.Value` / `json.Object` where possible.
|
||||
- [ ] make `parse` an overload of `parse_text/parse_inline` and `parse_file`, or something.
|
||||
- [ ] Add page params
|
||||
- [x] Add page params
|
||||
- [ ] We must remove all mention of `posts` from the odin code.
|
||||
At present, "posts" are a user-level construct defined as pages in a
|
||||
particular collection.
|
||||
@@ -144,17 +174,16 @@ main :: proc () {
|
||||
- wrong cwd?
|
||||
- [ ] Clean up the default layouts
|
||||
- [ ] Menus
|
||||
- [ ] Detailed frontmatter menu form ("menu": {"main": {"weight": 5}})
|
||||
- [x] Detailed frontmatter menu form ("menu": {"main": {"weight": 5}})
|
||||
- [ ] Menu active state (pre-compute is_active based on page.permalink prefix match)
|
||||
- [ ] Page.weight field for general-purpose page ordering (menus, lists, related posts)
|
||||
- [x] Page.weight field for general-purpose page ordering (menus, lists, related posts)
|
||||
- [ ] if no `html` tag detected in output, re-render output with base template
|
||||
(or whatever template is next in the chain)
|
||||
- [ ] Add `-production` flag
|
||||
- sets `-minify`
|
||||
- [ ] Mustache diagnostics
|
||||
- [ ] Partial invocation stack in diagnostics: when an error fires inside a partial, show "invoked from" chain through `{{> name}}` calls. Currently warnings inside partials point at the partial (correct file) but don't show the invocation site.
|
||||
- [ ] Could be better error message when missing a closing (or opening) brace
|
||||
- [ ] Error message doesn't show position of faulty pipe name correctly.
|
||||
- [x] Error message doesn't show position of faulty pipe name correctly.
|
||||
- [ ] `render_template` (`render.odin`) blanks the *entire page* to `""` on any
|
||||
mustache render error and only `log.errorf`s it — a single bad tag/pipe
|
||||
anywhere on the page silently kills the whole output with no visible
|
||||
@@ -174,7 +203,6 @@ main :: proc () {
|
||||
- [ ] 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
|
||||
@@ -192,7 +220,8 @@ main :: proc () {
|
||||
- [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
|
||||
- [x] Free cmark HTML output (`body_html`) — cmark allocates via C malloc, not the arena, so it leaks per iteration in watch mode
|
||||
- [ ] manually pass `site_allocator` to `load_page`
|
||||
- [ ] Mount content in VFS
|
||||
- [ ] commands
|
||||
- [ ] `build` alias of default
|
||||
|
||||
+11
-4
@@ -3,6 +3,7 @@ package main
|
||||
import md "markdown"
|
||||
import ts "treesitter"
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:os"
|
||||
@@ -23,10 +24,11 @@ Page :: struct {
|
||||
weight: Maybe(int),
|
||||
lastmod: string,
|
||||
menus: map[string]Menu_Entry,
|
||||
params: json.Value,
|
||||
content: string,
|
||||
og: Open_Graph,
|
||||
draft: bool,
|
||||
starred: bool,
|
||||
toc: string,
|
||||
_is_index: bool `private`,
|
||||
}
|
||||
|
||||
@@ -257,7 +259,7 @@ load_page :: proc(
|
||||
page.weight = fm.weight
|
||||
page.lastmod = fm.lastmod
|
||||
page.draft = fm.draft
|
||||
page.starred = fm.isStarred
|
||||
page.params = fm.params
|
||||
|
||||
if section == "" && is_index {
|
||||
page.permalink = "/"
|
||||
@@ -274,9 +276,13 @@ load_page :: proc(
|
||||
page.og = fm.og
|
||||
|
||||
if strings.has_suffix(file_path, ".html") {
|
||||
page.content = strings.clone(body)
|
||||
page.content = strings.clone(body, context.allocator)
|
||||
} else {
|
||||
page.content = md.process(body, ext, file_path)
|
||||
page.content = md.process(body, ext, file_path, context.allocator)
|
||||
}
|
||||
|
||||
if fm.toc {
|
||||
page.toc = md.generate_toc(page.content, context.allocator)
|
||||
}
|
||||
|
||||
ok = true
|
||||
@@ -294,3 +300,4 @@ strip_extension :: proc(name: string) -> string {
|
||||
}
|
||||
return name[:dot]
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{title}}</title>
|
||||
{{> head}}
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<title>{{> title}}</title>
|
||||
{{> opengraph}}
|
||||
{{> styles}}
|
||||
{{> scripts}}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
{{&content}}
|
||||
</header>
|
||||
<ul>
|
||||
{{#pages}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date_iso}}<time
|
||||
datetime="{{date_iso}}">{{date_display}}</time>{{/date_iso}}</span>
|
||||
{{#pages}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date}}<time
|
||||
datetime="{{date}}">{{date | format}}</time>{{/date}}</span>
|
||||
</li>
|
||||
{{/pages}}
|
||||
</ul>
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
<main>
|
||||
<article>
|
||||
<h1>{{page.title}}</h1>
|
||||
{{#date_iso}} <time class="subtitle" datetime="{{date_iso}}">{{date_display}}</time>
|
||||
{{/date_iso}} {{&content}}
|
||||
{{#date}}<time class="subtitle" datetime="{{date}}">{{ date | format}}</time>{{/date}}
|
||||
{{#page.toc}}
|
||||
<nav class="toc">
|
||||
{{&page.toc}}
|
||||
</nav>
|
||||
{{/page.toc}}
|
||||
{{&content}}
|
||||
</article>
|
||||
</main>
|
||||
{{/main}}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<footer>
|
||||
<p>© {{now.year}} {{params.author.name}}</p>
|
||||
</footer>
|
||||
<p>© {{now | format "2006"}} {{params.author.name}}</p>
|
||||
</footer>
|
||||
|
||||
@@ -1 +1 @@
|
||||
{{site.title}}
|
||||
{{site.title}}{{^site.title}}Home{{/site.title}}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{{params.scripts}}
|
||||
@@ -0,0 +1,223 @@
|
||||
<style>
|
||||
/* 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}}<link rel="stylesheet" href="{{.}}">{{/params.stylesheets}}
|
||||
@@ -0,0 +1 @@
|
||||
{{#site.title}}{{.}}{{#page.title}} | {{/page.title}}{{/site.title}}{{page.title}}
|
||||
@@ -7,8 +7,8 @@
|
||||
<section>
|
||||
<h2>{{key}}</h2>
|
||||
<ul>
|
||||
{{#items}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date_iso}}<time
|
||||
datetime="{{date_iso}}">{{date_display}}</time>{{/date_iso}}</span>
|
||||
{{#items}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date}}<time
|
||||
datetime="{{date}}">{{date | format}}</time>{{/date}}</span>
|
||||
</li>
|
||||
{{/items}}
|
||||
</ul>
|
||||
|
||||
@@ -7,10 +7,9 @@ import "core:time"
|
||||
generate_rss :: proc(site: ^Site) -> string {
|
||||
sb := strings.builder_make()
|
||||
|
||||
strings.write_string(
|
||||
fmt.sbprintf(
|
||||
&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">
|
||||
<channel>
|
||||
<title>%s</title>
|
||||
@@ -18,11 +17,10 @@ generate_rss :: proc(site: ^Site) -> string {
|
||||
<description>%s</description>
|
||||
<language>en-us</language>
|
||||
<atom:link href="%s/index.xml" rel="self" type="application/rss+xml"/>`,
|
||||
xml_escape(site.title),
|
||||
site.base_url,
|
||||
xml_escape(site.description),
|
||||
site.base_url,
|
||||
),
|
||||
xml_escape(site.title),
|
||||
site.base_url,
|
||||
xml_escape(site.description),
|
||||
site.base_url,
|
||||
)
|
||||
|
||||
for page in site.pages {
|
||||
@@ -35,10 +33,9 @@ generate_rss :: proc(site: ^Site) -> string {
|
||||
pub_date = format_rfc822(page.date)
|
||||
}
|
||||
|
||||
strings.write_string(
|
||||
fmt.sbprintf(
|
||||
&sb,
|
||||
fmt.aprintf(
|
||||
`<item>
|
||||
`<item>
|
||||
<title>%s</title>
|
||||
<link>%s</link>
|
||||
<pubDate>%s</pubDate>
|
||||
@@ -46,12 +43,11 @@ generate_rss :: proc(site: ^Site) -> string {
|
||||
<description>%s</description>
|
||||
</item>
|
||||
`,
|
||||
xml_escape(page.title),
|
||||
page.url,
|
||||
pub_date,
|
||||
page.url,
|
||||
xml_escape(page.content),
|
||||
),
|
||||
xml_escape(page.title),
|
||||
page.url,
|
||||
pub_date,
|
||||
page.url,
|
||||
xml_escape(page.content),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,11 +66,11 @@ generate_sitemap :: proc(site: ^Site) -> string {
|
||||
)
|
||||
|
||||
for page in site.pages {
|
||||
lastmod := ""
|
||||
fmt.sbprintf(&sb, "<url><loc>%s</loc>", page.url)
|
||||
if page.date != "" {
|
||||
lastmod = fmt.aprintf("<lastmod>%s</lastmod>", page.date)
|
||||
fmt.sbprintf(&sb, "<lastmod>%s</lastmod>", page.date)
|
||||
}
|
||||
strings.write_string(&sb, fmt.aprintf("<url><loc>%s</loc>%s</url>\n", page.url, lastmod))
|
||||
fmt.sbprintf(&sb, "</url>\n")
|
||||
}
|
||||
|
||||
// Section index pages (for sections without an index in content)
|
||||
@@ -103,14 +99,11 @@ generate_sitemap :: proc(site: ^Site) -> string {
|
||||
section_lastmod = page.date
|
||||
}
|
||||
}
|
||||
lm := ""
|
||||
fmt.sbprintf(&sb, "<url><loc>%s/%s/</loc>", site.base_url, section)
|
||||
if section_lastmod != "" {
|
||||
lm = fmt.aprintf("<lastmod>%s</lastmod>", section_lastmod)
|
||||
fmt.sbprintf(&sb, "<lastmod>%s</lastmod>", section_lastmod)
|
||||
}
|
||||
strings.write_string(
|
||||
&sb,
|
||||
fmt.aprintf("<url><loc>%s/%s/</loc>%s</url>\n", site.base_url, section, lm),
|
||||
)
|
||||
fmt.sbprintf(&sb, "</url>\n")
|
||||
}
|
||||
|
||||
strings.write_string(&sb, "</urlset>")
|
||||
|
||||
+6
-3
@@ -12,10 +12,11 @@ Frontmatter :: struct {
|
||||
publishDate: string,
|
||||
weight: Maybe(int),
|
||||
menus: json.Value,
|
||||
params: json.Value,
|
||||
layout: string,
|
||||
og: Open_Graph,
|
||||
draft: bool,
|
||||
isStarred: bool,
|
||||
toc: bool,
|
||||
}
|
||||
|
||||
// parse_frontmatter splits raw file content into a Frontmatter struct and the
|
||||
@@ -37,7 +38,7 @@ parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok
|
||||
json_str := content[:end + 1]
|
||||
body = strings.trim_left(content[end + 1:], " \t\r\n")
|
||||
|
||||
value, err := json.parse_string(json_str, spec = .JSON)
|
||||
value, err := json.parse_string(json_str, spec = .JSON5)
|
||||
if err != nil {
|
||||
log.errorf("failed to parse frontmatter JSON: %v", err)
|
||||
return
|
||||
@@ -56,12 +57,13 @@ parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok
|
||||
fm.publishDate = json_get_string(obj, "publishDate")
|
||||
fm.weight = json_get_int(obj, "weight")
|
||||
fm.draft = json_get_bool(obj, "draft")
|
||||
fm.isStarred = json_get_bool(obj, "isStarred")
|
||||
fm.toc = json_get_bool(obj, "toc")
|
||||
if v, ok := obj["menus"]; ok {
|
||||
fm.menus = v
|
||||
}
|
||||
fm.layout = json_get_string(obj, "layout")
|
||||
fm.og = json_get_open_graph(obj, "og")
|
||||
if v, ok := obj["params"]; ok {fm.params = v}
|
||||
|
||||
ok = true
|
||||
return
|
||||
@@ -117,3 +119,4 @@ json_get_open_graph :: proc(obj: json.Object, key: string) -> Open_Graph {
|
||||
}
|
||||
return og
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import "base:runtime"
|
||||
import "core:flags"
|
||||
import "core:log"
|
||||
import "core:mem"
|
||||
import "core:os"
|
||||
import "core:prof/spall"
|
||||
import "core:sync"
|
||||
@@ -43,9 +45,21 @@ main :: proc() {
|
||||
defer spall.buffer_destroy(&spall_ctx, &spall_buffer)
|
||||
}
|
||||
|
||||
cli_flags: Flags
|
||||
flags.parse_or_exit(&cli_flags, os.args, .Odin)
|
||||
|
||||
level: log.Level
|
||||
switch {
|
||||
case cli_flags.quiet:
|
||||
level = .Warning
|
||||
case cli_flags.verbose:
|
||||
level = .Debug
|
||||
case:
|
||||
level = .Info
|
||||
}
|
||||
logger_opts: log.Options =
|
||||
(log.Default_Console_Logger_Opts - log.Full_Timestamp_Opts - {.Short_File_Path})
|
||||
console_logger := log.create_console_logger(.Info, logger_opts)
|
||||
console_logger := log.create_console_logger(level, logger_opts)
|
||||
context.logger = console_logger
|
||||
defer log.destroy_console_logger(console_logger)
|
||||
|
||||
@@ -57,12 +71,21 @@ main :: proc() {
|
||||
|
||||
for {
|
||||
defer free_all(context.temp_allocator)
|
||||
defer log.debugf(
|
||||
"max_temp_allocator_size=%M",
|
||||
(cast(^runtime.Default_Temp_Allocator)context.temp_allocator.data)^.arena.total_used,
|
||||
)
|
||||
tick := time.tick_now()
|
||||
site: Site
|
||||
init_site(&site, os.args)
|
||||
init_site(&site, cli_flags)
|
||||
defer destroy_site(&site)
|
||||
// TODO: Make it so this isn't necessary
|
||||
context.allocator = site_allocator(&site)
|
||||
defer log.debugf(
|
||||
"current_site_allocator_size=%M",
|
||||
site.arena.block_size * (len(site.arena.used_blocks) + len(site.arena.unused_blocks)) -
|
||||
site.arena.bytes_left,
|
||||
)
|
||||
build_vfs(&site)
|
||||
|
||||
treesitter.grammar_dir = site.grammars
|
||||
@@ -70,7 +93,7 @@ main :: proc() {
|
||||
|
||||
site_load_content(&site)
|
||||
render_site(&site)
|
||||
log.infof("Built site in %s", time.tick_since(tick))
|
||||
log.infof("Built site in %M", time.tick_since(tick))
|
||||
|
||||
(.Watch in site.features) or_break
|
||||
time.sleep(5 * time.Second)
|
||||
@@ -94,3 +117,4 @@ when SPALL {
|
||||
spall._buffer_end(&spall_ctx, &spall_buffer)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package markdown
|
||||
|
||||
import cm "vendor:commonmark"
|
||||
|
||||
import "core:strings"
|
||||
|
||||
// DefList_Entry represents a single term-definition pair in a definition list.
|
||||
DefList_Entry :: struct {
|
||||
term: string,
|
||||
definition: string,
|
||||
}
|
||||
|
||||
// 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 optional whitespace followed by a colon and
|
||||
// a space. 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.
|
||||
//
|
||||
// Terms and definitions are rendered through cmark individually so that
|
||||
// inline markdown (code, links, emphasis) is processed.
|
||||
convert_deflists :: proc(body: string, allocator := context.allocator) -> string {
|
||||
lines := strings.split(body, "\n", allocator = context.temp_allocator)
|
||||
|
||||
sb := strings.builder_make(context.temp_allocator)
|
||||
|
||||
first := true
|
||||
need_blank := false
|
||||
|
||||
i := 0
|
||||
for i < len(lines) {
|
||||
entries, matched, next := try_match_deflist(lines, i)
|
||||
if matched {
|
||||
html := render_deflist(entries)
|
||||
if !first {
|
||||
strings.write_string(&sb, "\n\n")
|
||||
}
|
||||
strings.write_string(&sb, html)
|
||||
first = false
|
||||
need_blank = true
|
||||
i = next
|
||||
continue
|
||||
}
|
||||
|
||||
if need_blank {
|
||||
strings.write_string(&sb, "\n\n")
|
||||
need_blank = false
|
||||
} else if !first {
|
||||
strings.write_string(&sb, "\n")
|
||||
}
|
||||
strings.write_string(&sb, lines[i])
|
||||
first = false
|
||||
i += 1
|
||||
}
|
||||
|
||||
return strings.clone(strings.to_string(sb), allocator)
|
||||
}
|
||||
|
||||
// try_match_deflist attempts to match a definition list group starting at
|
||||
// lines[start]. A group is one or more term+definition pairs. Returns the
|
||||
// matched entries, whether a match was found, and the index past the group.
|
||||
try_match_deflist :: proc(
|
||||
lines: []string,
|
||||
start: int,
|
||||
) -> (
|
||||
entries: [dynamic]DefList_Entry,
|
||||
ok: bool,
|
||||
end: int,
|
||||
) {
|
||||
entries = make([dynamic]DefList_Entry, 0, allocator = context.temp_allocator)
|
||||
end = start
|
||||
|
||||
i := start
|
||||
for i < len(lines) {
|
||||
// A term must be non-blank and not itself a def line
|
||||
if is_blank_line(lines[i]) || is_def_line(lines[i]) {
|
||||
break
|
||||
}
|
||||
|
||||
// Look for a def line: immediately after or with one blank line
|
||||
def_idx := i + 1
|
||||
if def_idx < len(lines) && is_blank_line(lines[def_idx]) {
|
||||
def_idx += 1
|
||||
}
|
||||
|
||||
if def_idx >= len(lines) || !is_def_line(lines[def_idx]) {
|
||||
break
|
||||
}
|
||||
|
||||
// Found a term + def pair
|
||||
append(
|
||||
&entries,
|
||||
DefList_Entry {
|
||||
term = strings.trim_space(lines[i]),
|
||||
definition = def_content(lines[def_idx]),
|
||||
},
|
||||
)
|
||||
i = def_idx + 1
|
||||
|
||||
// After a pair, check if another pair follows (optionally
|
||||
// separated by one blank line). If so, continue the group.
|
||||
// If not, break without consuming the blank line.
|
||||
if i < len(lines) && is_blank_line(lines[i]) {
|
||||
after_blank := i + 1
|
||||
if after_blank < len(lines) &&
|
||||
!is_blank_line(lines[after_blank]) &&
|
||||
!is_def_line(lines[after_blank]) {
|
||||
// Check whether a def follows this potential term
|
||||
check_def := after_blank + 1
|
||||
if check_def < len(lines) && is_blank_line(lines[check_def]) {
|
||||
check_def += 1
|
||||
}
|
||||
if check_def < len(lines) && is_def_line(lines[check_def]) {
|
||||
i = after_blank
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(entries) > 0 {
|
||||
ok = true
|
||||
end = i
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// is_def_line returns true if the line is a definition line:
|
||||
// optional leading whitespace, a colon, then whitespace or end-of-line.
|
||||
is_def_line :: proc(line: string) -> bool {
|
||||
trimmed := strings.trim_left(line, " \t")
|
||||
if len(trimmed) < 1 || trimmed[0] != ':' {
|
||||
return false
|
||||
}
|
||||
if len(trimmed) == 1 {
|
||||
return true
|
||||
}
|
||||
return trimmed[1] == ' ' || trimmed[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_deflist builds the <dl> HTML block from a list of entries.
|
||||
// Each term and definition is rendered through cmark to process inline
|
||||
// markdown. Result lives in context.temp_allocator.
|
||||
render_deflist :: proc(entries: [dynamic]DefList_Entry) -> string {
|
||||
sb := strings.builder_make(context.temp_allocator)
|
||||
|
||||
strings.write_string(&sb, "<dl>")
|
||||
for entry in entries {
|
||||
term_html := render_inline_md(entry.term)
|
||||
def_html := render_inline_md(entry.definition)
|
||||
strings.write_string(&sb, "<dt>")
|
||||
strings.write_string(&sb, term_html)
|
||||
strings.write_string(&sb, "</dt><dd>")
|
||||
strings.write_string(&sb, def_html)
|
||||
strings.write_string(&sb, "</dd>")
|
||||
}
|
||||
strings.write_string(&sb, "</dl>")
|
||||
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
#+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, "")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_docs_md_pattern :: proc(t: ^testing.T) {
|
||||
input := "content\n\n: `content` holds your pages.\n\n assets\n : `assets` contains files."
|
||||
result := convert_deflists(input, context.temp_allocator)
|
||||
testing.expect(t, strings.contains(result, "<dl>"))
|
||||
testing.expect(t, strings.contains(result, "<dt>content</dt>"))
|
||||
testing.expect(t, strings.contains(result, "<dt>assets</dt>"))
|
||||
testing.expect(t, strings.contains(result, "<code>content</code>"))
|
||||
testing.expect(t, strings.contains(result, "<code>assets</code>"))
|
||||
testing.expect(t, strings.contains(result, "</dl>"))
|
||||
}
|
||||
|
||||
@@ -30,3 +30,4 @@ test_emoji_skips_invalid_shortcodes :: proc(t: ^testing.T) {
|
||||
testing.expect_value(t, expand_emoji(":Smile:"), ":Smile:")
|
||||
testing.expect_value(t, expand_emoji(": not real :"), ": not real :")
|
||||
}
|
||||
|
||||
|
||||
+97
-15
@@ -1,7 +1,5 @@
|
||||
package markdown
|
||||
|
||||
import cm "vendor:commonmark"
|
||||
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
|
||||
@@ -171,27 +169,27 @@ inject_notes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> strin
|
||||
}
|
||||
|
||||
// Render definition through cmark for markdown support
|
||||
def_html := cm.markdown_to_html_from_string(def_text, {.Unsafe})
|
||||
def_html = strip_p_tags(def_html)
|
||||
def_html := render_inline_md(def_text)
|
||||
|
||||
note: string
|
||||
defer delete(note)
|
||||
if is_margin {
|
||||
note = fmt.aprintf(
|
||||
fmt.sbprintf(
|
||||
&parts,
|
||||
`<label for="mn-%s" class="margin-toggle"></label><input type="checkbox" id="mn-%s" class="margin-toggle"><span class="marginnote">%s</span>`,
|
||||
id,
|
||||
id,
|
||||
def_html,
|
||||
)
|
||||
} else {
|
||||
note = fmt.aprintf(
|
||||
fmt.sbprintf(
|
||||
&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>`,
|
||||
id,
|
||||
id,
|
||||
def_html,
|
||||
)
|
||||
}
|
||||
strings.write_string(&parts, note)
|
||||
|
||||
remaining = remaining[ref_end:]
|
||||
}
|
||||
@@ -199,14 +197,98 @@ inject_notes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> strin
|
||||
return strings.to_string(parts)
|
||||
}
|
||||
|
||||
// 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]
|
||||
// inject_footnotes finds [^id] and [*id] references in rendered HTML, numbers
|
||||
// them sequentially by order of appearance, and replaces them with <sup> links.
|
||||
// Appends a <section class="footnotes"><ol> at the end with definitions.
|
||||
// Both sidenote ([^id]) and marginnote ([*id]) references are treated equally.
|
||||
inject_footnotes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> string {
|
||||
if len(sn_defs) == 0 && len(mn_defs) == 0 {
|
||||
return html
|
||||
}
|
||||
if strings.has_prefix(s, "<p>") && strings.has_suffix(s, "</p>") {
|
||||
return s[3:len(s) - 4]
|
||||
|
||||
parts: strings.Builder
|
||||
strings.builder_init_len(&parts, 0)
|
||||
defer strings.builder_destroy(&parts)
|
||||
|
||||
number_of: map[string]int = make(map[string]int, allocator = context.temp_allocator)
|
||||
ordered_ids: [dynamic]string = make([dynamic]string, 0, allocator = context.temp_allocator)
|
||||
next_num := 1
|
||||
|
||||
remaining := html
|
||||
|
||||
for {
|
||||
sn_pos := strings.index(remaining, "[^")
|
||||
mn_pos := strings.index(remaining, "[*")
|
||||
|
||||
is_margin := mn_pos >= 0 && (sn_pos < 0 || mn_pos < sn_pos)
|
||||
pos := sn_pos
|
||||
if is_margin {
|
||||
pos = mn_pos
|
||||
}
|
||||
if pos < 0 {
|
||||
strings.write_string(&parts, remaining)
|
||||
break
|
||||
}
|
||||
|
||||
strings.write_string(&parts, remaining[:pos])
|
||||
|
||||
close := strings.index(remaining[pos + 2:], "]")
|
||||
if close < 0 {
|
||||
strings.write_string(&parts, remaining[pos:])
|
||||
break
|
||||
}
|
||||
|
||||
id := remaining[pos + 2:pos + 2 + close]
|
||||
ref_end := pos + 2 + close + 1
|
||||
|
||||
defs := sn_defs
|
||||
if is_margin {
|
||||
defs = mn_defs
|
||||
}
|
||||
def_text, found := defs[id]
|
||||
if !found {
|
||||
strings.write_string(&parts, remaining[pos:ref_end])
|
||||
remaining = remaining[ref_end:]
|
||||
continue
|
||||
}
|
||||
|
||||
num, seen := number_of[id]
|
||||
if !seen {
|
||||
num = next_num
|
||||
number_of[id] = num
|
||||
next_num += 1
|
||||
append(&ordered_ids, id)
|
||||
}
|
||||
|
||||
fmt.sbprintf(&parts, `<sup><a href="#fn-%d" id="fnref-%d">%d</a></sup>`, num, num, num)
|
||||
|
||||
remaining = remaining[ref_end:]
|
||||
}
|
||||
return s
|
||||
|
||||
if len(ordered_ids) > 0 {
|
||||
strings.write_string(&parts, "\n<section class=\"footnotes\">\n<hr>\n<ol>\n")
|
||||
for id in ordered_ids {
|
||||
def_text, ok := sn_defs[id]
|
||||
if !ok {
|
||||
def_text, ok = mn_defs[id]
|
||||
}
|
||||
if !ok do continue
|
||||
|
||||
def_html := render_inline_md(def_text)
|
||||
num := number_of[id]
|
||||
|
||||
fmt.sbprintf(
|
||||
&parts,
|
||||
`<li id="fn-%d">%s <a href="#fnref-%d" class="footnote-backref">↩︎</a></li>` +
|
||||
"\n",
|
||||
num,
|
||||
def_html,
|
||||
num,
|
||||
)
|
||||
}
|
||||
strings.write_string(&parts, "</ol>\n</section>")
|
||||
}
|
||||
|
||||
return strings.to_string(parts)
|
||||
}
|
||||
|
||||
|
||||
@@ -119,3 +119,102 @@ 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>`))
|
||||
}
|
||||
|
||||
+71
-31
@@ -3,6 +3,7 @@ package markdown
|
||||
import cm "vendor:commonmark"
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:log"
|
||||
import "core:strings"
|
||||
|
||||
Extension :: enum {
|
||||
@@ -12,22 +13,38 @@ Extension :: enum {
|
||||
Highlight,
|
||||
Sections,
|
||||
HeadingIDs,
|
||||
DefLists,
|
||||
Footnotes,
|
||||
}
|
||||
|
||||
DEFAULT_EXTENSIONS :: bit_set[Extension]{.Emoji, .Sidenotes, .Alerts, .HeadingIDs}
|
||||
DEFAULT_EXTENSIONS :: bit_set[Extension]{.Emoji, .Sidenotes, .Alerts, .HeadingIDs, .DefLists}
|
||||
|
||||
process :: proc(body: string, ext: bit_set[Extension], file_path: string) -> string {
|
||||
// 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)
|
||||
margin_notes := make(map[string]string)
|
||||
clean_body := body
|
||||
if .Sidenotes in ext {
|
||||
if .Sidenotes in ext || .Footnotes in ext {
|
||||
clean_body, side_notes, margin_notes = strip_definitions(body)
|
||||
}
|
||||
html := cm.markdown_to_html_from_string(clean_body, {.Unsafe})
|
||||
if .DefLists in ext {
|
||||
clean_body = convert_deflists(clean_body, allocator)
|
||||
}
|
||||
original_html := cm.markdown_to_html_from_string(clean_body, {.Unsafe})
|
||||
html := strings.clone(original_html, allocator)
|
||||
cm.free_string(original_html)
|
||||
|
||||
if .Emoji in ext {
|
||||
html = expand_emoji(html)
|
||||
}
|
||||
if .Sidenotes in ext {
|
||||
if .Footnotes in ext {
|
||||
html = inject_footnotes(html, side_notes, margin_notes)
|
||||
} else if .Sidenotes in ext {
|
||||
html = inject_notes(html, side_notes, margin_notes)
|
||||
}
|
||||
if .Alerts in ext {
|
||||
@@ -49,20 +66,14 @@ process :: proc(body: string, ext: bit_set[Extension], file_path: string) -> str
|
||||
parse_extension_list :: proc(s: string) -> (result: bit_set[Extension]) {
|
||||
for part in strings.split(s, ",", allocator = context.temp_allocator) {
|
||||
name := strings.to_lower(strings.trim_space(part), allocator = context.temp_allocator)
|
||||
switch name {
|
||||
case "emoji":
|
||||
result += {.Emoji}
|
||||
case "sidenotes":
|
||||
result += {.Sidenotes}
|
||||
case "alerts":
|
||||
result += {.Alerts}
|
||||
case "highlight":
|
||||
result += {.Highlight}
|
||||
case "sections":
|
||||
result += {.Sections}
|
||||
case "heading_ids":
|
||||
result += {.HeadingIDs}
|
||||
e, ok := extension_from_name(name)
|
||||
if !ok {
|
||||
if name != "" {
|
||||
log.warnf("unknown extension '%s'", name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
result += {e}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -72,19 +83,48 @@ apply_extension_config :: proc(ext: ^bit_set[Extension], config: json.Object) {
|
||||
for name, val in config {
|
||||
// TODO: Silently discards invalid values.
|
||||
enabled := val.(json.Boolean) or_continue
|
||||
switch name {
|
||||
case "emoji":
|
||||
if enabled {ext^ += {.Emoji}} else {ext^ -= {.Emoji}}
|
||||
case "sidenotes":
|
||||
if enabled {ext^ += {.Sidenotes}} else {ext^ -= {.Sidenotes}}
|
||||
case "alerts":
|
||||
if enabled {ext^ += {.Alerts}} else {ext^ -= {.Alerts}}
|
||||
case "highlight":
|
||||
if enabled {ext^ += {.Highlight}} else {ext^ -= {.Highlight}}
|
||||
case "sections":
|
||||
if enabled {ext^ += {.Sections}} else {ext^ -= {.Sections}}
|
||||
case "heading_ids":
|
||||
if enabled {ext^ += {.HeadingIDs}} else {ext^ -= {.HeadingIDs}}
|
||||
e := extension_from_name(name) or_continue
|
||||
|
||||
if enabled {
|
||||
ext^ += {e}
|
||||
} else {
|
||||
ext^ -= {e}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension_from_name :: proc(name: string) -> (e: Extension, ok: bool) {
|
||||
switch name {
|
||||
case "emoji":
|
||||
e = .Emoji
|
||||
ok = true
|
||||
case "sidenotes":
|
||||
e = .Sidenotes
|
||||
case "alerts":
|
||||
e = .Alerts
|
||||
case "highlight":
|
||||
e = .Highlight
|
||||
case "sections":
|
||||
e = .Sections
|
||||
case "heading_ids":
|
||||
e = .HeadingIDs
|
||||
case "deflists":
|
||||
e = .DefLists
|
||||
case "footnotes":
|
||||
e = .Footnotes
|
||||
case:
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
return e, ok || e != .Emoji
|
||||
}
|
||||
|
||||
// resolve_extension_conflicts resolves mutually exclusive extensions.
|
||||
// Footnotes and Sidenotes share the same [^id] syntax but render differently;
|
||||
// if both are enabled (e.g. from defaults + CLI), Footnotes wins.
|
||||
resolve_extension_conflicts :: proc(ext: ^bit_set[Extension]) {
|
||||
if .Footnotes in ext^ && .Sidenotes in ext^ {
|
||||
ext^ -= {.Sidenotes}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package markdown
|
||||
|
||||
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 {
|
||||
for current_level < level {
|
||||
strings.write_string(&b, "<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)
|
||||
}
|
||||
|
||||
+17
-26
@@ -137,6 +137,22 @@ resolve_name :: proc(name: string, ctx: []any) -> any {
|
||||
return result
|
||||
}
|
||||
|
||||
collect_map_keys :: proc(container: any, allocator := context.temp_allocator) -> []string {
|
||||
val, info := base_value(container)
|
||||
if info == nil do return nil
|
||||
if _, ok := info.variant.(runtime.Type_Info_Map); !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([dynamic]string, 0, 4, allocator)
|
||||
it := 0
|
||||
for {
|
||||
key, _ := reflect.iterate_map(val, &it) or_break
|
||||
key_str := key.(string) or_continue
|
||||
append(&out, key_str)
|
||||
}
|
||||
return out[:]
|
||||
}
|
||||
|
||||
// is_truthy checks mustache truthiness.
|
||||
is_truthy :: proc(a: any) -> bool {
|
||||
if a == nil {
|
||||
@@ -159,32 +175,6 @@ is_truthy :: proc(a: any) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
call_interp_lambda :: proc(val: any) -> (result: string, ok: bool) {
|
||||
switch v in val {
|
||||
case proc() -> string:
|
||||
return v(), true
|
||||
case proc() -> int:
|
||||
return fmt.tprintf("%d", v()), true
|
||||
case proc() -> bool:
|
||||
return "true" if v() else "false", true
|
||||
case:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
call_section_lambda :: proc(val: any, text: string) -> (result: string, ok: bool) {
|
||||
switch v in val {
|
||||
case proc(_: string) -> string:
|
||||
return v(text), true
|
||||
case proc(_: string) -> int:
|
||||
return fmt.tprintf("%d", v(text)), true
|
||||
case proc(_: string) -> bool:
|
||||
return "true" if v(text) else "false", true
|
||||
case:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// list_info returns element type info, count, and data pointer for a list value.
|
||||
// Returns elem_info=nil if the value is not a list.
|
||||
list_info :: proc(a: any) -> (elem_info: ^runtime.Type_Info, count: int, data: rawptr) {
|
||||
@@ -302,3 +292,4 @@ write_value :: proc(b: ^strings.Builder, a: any, escape: bool) {
|
||||
strings.write_string(b, s[start:])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ format_error :: proc(
|
||||
}
|
||||
strings.write_string(&sb, "--> ")
|
||||
strings.write_string(&sb, reset)
|
||||
strings.write_string(&sb, fmt.tprintf("%s:%d:%d\n", path, line, col))
|
||||
fmt.sbprintf(&sb, "%s:%d:%d\n", path, line, col)
|
||||
|
||||
// Top gutter line.
|
||||
write_gutter(&sb, width, faint, reset)
|
||||
@@ -326,5 +326,5 @@ format_render_error :: proc(err: Error, tmpl: Template, colorize: bool = false)
|
||||
if path == "" {
|
||||
path = "<input>"
|
||||
}
|
||||
return format_error(path, source, b.pos, b.msg, colorize = colorize, span = b.span)
|
||||
return format_error(path, source, b.pos, b.msg, hint = b.hint, colorize = colorize, span = b.span)
|
||||
}
|
||||
|
||||
+12
-12
@@ -129,12 +129,12 @@ match_token :: proc(b: ^strings.Builder, dt: Date_Components, s: string) -> int
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"January",
|
||||
) {strings.write_string(b, fmt.tprintf("%s", time.Month(dt.month))); return 7}
|
||||
) {fmt.sbprintf(b, "%s", time.Month(dt.month)); return 7}
|
||||
if strings.has_prefix(s, "Monday") {emit_weekday(b, dt, full = true); return 6}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"2006",
|
||||
) {strings.write_string(b, fmt.tprintf("%04d", dt.year)); return 4}
|
||||
) {fmt.sbprintf(b, "%04d", dt.year); return 4}
|
||||
if strings.has_prefix(s, "MST") {
|
||||
abbr := dt.tz_abbr
|
||||
if len(abbr) == 0 do abbr = "UTC"
|
||||
@@ -146,37 +146,37 @@ match_token :: proc(b: ^strings.Builder, dt: Date_Components, s: string) -> int
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"06",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.year % 100)); return 2}
|
||||
if strings.has_prefix(s, "02") {strings.write_string(b, fmt.tprintf("%02d", dt.day)); return 2}
|
||||
) {fmt.sbprintf(b, "%02d", dt.year % 100); return 2}
|
||||
if strings.has_prefix(s, "02") {fmt.sbprintf(b, "%02d", dt.day); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"15",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.hour)); return 2}
|
||||
) {fmt.sbprintf(b, "%02d", dt.hour); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"04",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.minute)); return 2}
|
||||
) {fmt.sbprintf(b, "%02d", dt.minute); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"05",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.second)); return 2}
|
||||
) {fmt.sbprintf(b, "%02d", dt.second); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"01",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.month)); return 2}
|
||||
) {fmt.sbprintf(b, "%02d", dt.month); return 2}
|
||||
if strings.has_prefix(s, "03") {emit_hour_12(b, dt, pad = true); return 2}
|
||||
if strings.has_prefix(s, "PM") {emit_am_pm(b, dt); return 2}
|
||||
if strings.has_prefix(s, "pm") {emit_am_pm_lower(b, dt); return 2}
|
||||
if len(s) >= 1 {
|
||||
switch s[0] {
|
||||
case '2':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.day)); return 1
|
||||
fmt.sbprintf(b, "%d", dt.day); return 1
|
||||
case '1':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.month)); return 1
|
||||
fmt.sbprintf(b, "%d", dt.month); return 1
|
||||
case '4':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.minute)); return 1
|
||||
fmt.sbprintf(b, "%d", dt.minute); return 1
|
||||
case '5':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.second)); return 1
|
||||
fmt.sbprintf(b, "%d", dt.second); return 1
|
||||
case '3':
|
||||
emit_hour_12(b, dt, pad = false); return 1
|
||||
case:
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
#+test
|
||||
package mustache
|
||||
|
||||
import "core:fmt"
|
||||
import "core:testing"
|
||||
|
||||
/*
|
||||
|
||||
// --- Spec test 1: Interpolation ---
|
||||
// A lambda's return value should be interpolated.
|
||||
|
||||
Interp_Data :: struct {
|
||||
lambda: proc() -> string,
|
||||
planet: string,
|
||||
}
|
||||
|
||||
Interp_Data_Int :: struct {
|
||||
lambda: proc() -> int,
|
||||
planet: string,
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_lambda_interpolation :: proc(t: ^testing.T) {
|
||||
data := Interp_Data {
|
||||
lambda = proc() -> string {return "world"},
|
||||
}
|
||||
tpl, _ := parse("Hello, {{lambda}}!", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "Hello, world!")
|
||||
}
|
||||
|
||||
// --- Spec test 2: Interpolation - Expansion ---
|
||||
// A lambda's return value should be parsed.
|
||||
|
||||
@(test)
|
||||
test_lambda_interpolation_expansion :: proc(t: ^testing.T) {
|
||||
data := Interp_Data {
|
||||
lambda = proc() -> string {return "{{planet}}"},
|
||||
planet = "world",
|
||||
}
|
||||
tpl, _ := parse("Hello, {{lambda}}!", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "Hello, world!")
|
||||
}
|
||||
|
||||
// --- Spec test 4: Interpolation - Multiple Calls ---
|
||||
// Interpolated lambdas should not be cached.
|
||||
|
||||
counter_lambda :: proc() -> int {
|
||||
@(static) call_count := 0
|
||||
call_count += 1
|
||||
return call_count
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_lambda_interpolation_multiple_calls :: proc(t: ^testing.T) {
|
||||
data := Interp_Data_Int {
|
||||
lambda = counter_lambda,
|
||||
}
|
||||
tpl, _ := parse("{{lambda}} == {{{lambda}}} == {{lambda}}", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "1 == 2 == 3")
|
||||
}
|
||||
|
||||
// --- Spec test 5: Escaping ---
|
||||
// Lambda results should be appropriately escaped.
|
||||
|
||||
@(test)
|
||||
test_lambda_escaping :: proc(t: ^testing.T) {
|
||||
data := Interp_Data {
|
||||
lambda = proc() -> string {return ">"},
|
||||
}
|
||||
tpl, _ := parse("<{{lambda}}{{{lambda}}}", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "<>>")
|
||||
}
|
||||
|
||||
// --- Spec test 6: Section ---
|
||||
// Lambdas used for sections should receive the raw section string.
|
||||
|
||||
Section_Data :: struct {
|
||||
lambda: proc(_: string) -> string,
|
||||
x: string,
|
||||
planet: string,
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_lambda_section :: proc(t: ^testing.T) {
|
||||
data := Section_Data {
|
||||
lambda = proc(text: string) -> string {
|
||||
if text == "{{x}}" {return "yes"} else {return "no"}
|
||||
},
|
||||
x = "Error!",
|
||||
}
|
||||
tpl, _ := parse("<{{#lambda}}{{x}}{{/lambda}}>", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "<yes>")
|
||||
}
|
||||
|
||||
// --- Spec test 7: Section - Expansion ---
|
||||
// Lambdas used for sections should have their results parsed.
|
||||
|
||||
@(test)
|
||||
test_lambda_section_expansion :: proc(t: ^testing.T) {
|
||||
data := Section_Data {
|
||||
lambda = proc(text: string) -> string {
|
||||
return fmt.tprintf("%s{{{{planet}}}}%s", text, text)
|
||||
},
|
||||
planet = "Earth",
|
||||
}
|
||||
tpl, _ := parse("<{{#lambda}}-{{/lambda}}>", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "<-Earth->")
|
||||
}
|
||||
|
||||
// --- Spec test 9: Section - Multiple Calls ---
|
||||
// Lambdas used for sections should not be cached.
|
||||
|
||||
@(test)
|
||||
test_lambda_section_multiple_calls :: proc(t: ^testing.T) {
|
||||
data := Section_Data {
|
||||
lambda = proc(text: string) -> string {
|
||||
return fmt.tprintf("__%s__", text)
|
||||
},
|
||||
}
|
||||
tpl, _ := parse(
|
||||
"{{#lambda}}FILE{{/lambda}} != {{#lambda}}LINE{{/lambda}}",
|
||||
"<test>",
|
||||
context.temp_allocator,
|
||||
)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "__FILE__ != __LINE__")
|
||||
}
|
||||
|
||||
// --- Spec test 10: Inverted Section ---
|
||||
// Lambdas used for inverted sections should be considered truthy.
|
||||
|
||||
Inverted_Data :: struct {
|
||||
lambda: proc(_: string) -> bool,
|
||||
static: string,
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_lambda_inverted_section :: proc(t: ^testing.T) {
|
||||
data := Inverted_Data {
|
||||
lambda = proc(text: string) -> bool {return false},
|
||||
static = "static",
|
||||
}
|
||||
tpl, _ := parse("<{{^lambda}}{{static}}{{/lambda}}>", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "<>")
|
||||
}
|
||||
|
||||
*/
|
||||
+5
-67
@@ -32,6 +32,7 @@ Error_Body :: struct {
|
||||
source: string,
|
||||
path: string,
|
||||
span: int,
|
||||
hint: string,
|
||||
}
|
||||
|
||||
// Error is nil when no error occurred.
|
||||
@@ -59,6 +60,7 @@ tag_error :: proc(err: Error, tmpl: Template) -> Error {
|
||||
msg = b.msg,
|
||||
pos = b.pos,
|
||||
span = b.span,
|
||||
hint = b.hint,
|
||||
kind = b.kind,
|
||||
source = tmpl.source,
|
||||
path = tmpl.path,
|
||||
@@ -623,30 +625,7 @@ render_nodes :: proc(
|
||||
}
|
||||
val = transformed
|
||||
}
|
||||
if result_str, ok := call_interp_lambda(val); ok {
|
||||
sub_tpl, perr := parse(
|
||||
result_str,
|
||||
fmt.tprintf("<lambda output from '%s'>", node.key),
|
||||
context.temp_allocator,
|
||||
context.temp_allocator,
|
||||
)
|
||||
if perr == nil {
|
||||
temp: strings.Builder
|
||||
strings.builder_init(&temp, context.temp_allocator)
|
||||
render_nodes(
|
||||
sub_tpl,
|
||||
sub_tpl.nodes[:],
|
||||
ctx,
|
||||
partials,
|
||||
&temp,
|
||||
blocks,
|
||||
nil,
|
||||
) or_return
|
||||
write_value(b, strings.to_string(temp), escape = true)
|
||||
}
|
||||
} else {
|
||||
write_value(b, val, escape = true)
|
||||
}
|
||||
write_value(b, val, escape = true)
|
||||
i += 1
|
||||
|
||||
case .Unescaped:
|
||||
@@ -665,30 +644,7 @@ render_nodes :: proc(
|
||||
}
|
||||
val = transformed
|
||||
}
|
||||
if result_str, ok := call_interp_lambda(val); ok {
|
||||
sub_tpl, perr := parse(
|
||||
result_str,
|
||||
fmt.tprintf("<lambda output from '%s'>", node.key),
|
||||
context.temp_allocator,
|
||||
context.temp_allocator,
|
||||
)
|
||||
if perr == nil {
|
||||
temp: strings.Builder
|
||||
strings.builder_init(&temp, context.temp_allocator)
|
||||
render_nodes(
|
||||
sub_tpl,
|
||||
sub_tpl.nodes[:],
|
||||
ctx,
|
||||
partials,
|
||||
&temp,
|
||||
blocks,
|
||||
nil,
|
||||
) or_return
|
||||
write_value(b, strings.to_string(temp), escape = false)
|
||||
}
|
||||
} else {
|
||||
write_value(b, val, escape = false)
|
||||
}
|
||||
write_value(b, val, escape = false)
|
||||
i += 1
|
||||
|
||||
case .Section:
|
||||
@@ -703,25 +659,7 @@ render_nodes :: proc(
|
||||
}
|
||||
val = transformed
|
||||
}
|
||||
if result_str, ok := call_section_lambda(val, node.content); ok {
|
||||
sub_tpl, perr := parse(
|
||||
result_str,
|
||||
fmt.tprintf("<lambda output from '%s'>", node.key),
|
||||
context.temp_allocator,
|
||||
context.temp_allocator,
|
||||
)
|
||||
if perr == nil {
|
||||
render_nodes(
|
||||
sub_tpl,
|
||||
sub_tpl.nodes[:],
|
||||
ctx,
|
||||
partials,
|
||||
b,
|
||||
blocks,
|
||||
nil,
|
||||
) or_return
|
||||
}
|
||||
} else if is_truthy(val) {
|
||||
if is_truthy(val) {
|
||||
children := node.children
|
||||
elem_info, count, data := list_info(val)
|
||||
if elem_info != nil {
|
||||
|
||||
+59
-12
@@ -1,5 +1,6 @@
|
||||
package mustache
|
||||
|
||||
import "base:runtime"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:reflect"
|
||||
@@ -16,6 +17,44 @@ MAX_PIPE_ARGS :: 2
|
||||
|
||||
DEFAULT_DATE_FORMAT :: "2 Jan 2006"
|
||||
|
||||
Pipe_Op :: enum {
|
||||
Format,
|
||||
Group_By,
|
||||
}
|
||||
|
||||
// Pipe op names are derived from the enum via reflection (lowercased).
|
||||
// Adding a new op only requires adding it to the enum AND handling it in
|
||||
// apply_filter's switch — the compiler enforces exhaustive matching.
|
||||
|
||||
pipe_op_from_string :: proc(s: string) -> (Pipe_Op, bool) {
|
||||
ti := type_info_of(typeid_of(Pipe_Op))
|
||||
base := runtime.type_info_base(ti)
|
||||
#partial switch &e in base.variant {
|
||||
case runtime.Type_Info_Enum:
|
||||
for name, idx in e.names {
|
||||
lower := strings.to_lower(name, context.temp_allocator) or_else name
|
||||
if lower == s {
|
||||
return cast(Pipe_Op)idx, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return {}, false
|
||||
}
|
||||
|
||||
pipe_op_candidates :: proc(allocator := context.temp_allocator) -> []string {
|
||||
ti := type_info_of(typeid_of(Pipe_Op))
|
||||
base := runtime.type_info_base(ti)
|
||||
out := make([dynamic]string, 0, 2, allocator)
|
||||
#partial switch &e in base.variant {
|
||||
case runtime.Type_Info_Enum:
|
||||
for name in e.names {
|
||||
lower := strings.to_lower(name, allocator) or_else name
|
||||
append(&out, lower)
|
||||
}
|
||||
}
|
||||
return out[:]
|
||||
}
|
||||
|
||||
Pipe_Filter :: struct {
|
||||
op: string,
|
||||
args: [dynamic; MAX_PIPE_ARGS]string,
|
||||
@@ -217,12 +256,27 @@ resolve_format_string :: proc(name: string, ctx: []any, pos: int) -> (string, Er
|
||||
return str, nil
|
||||
}
|
||||
|
||||
// TODO: diagnostics don't show anything relevant
|
||||
apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) -> (any, Error) {
|
||||
switch filter.op {
|
||||
case "group_by":
|
||||
op, ok := pipe_op_from_string(filter.op)
|
||||
if !ok {
|
||||
hint := ""
|
||||
suggestion := suggest_correction(pipe_op_candidates(), filter.op)
|
||||
if suggestion != "" {
|
||||
hint = fmt.tprintf("did you mean '%s'?", suggestion)
|
||||
}
|
||||
return nil, Error_Body {
|
||||
msg = fmt.tprintf("unknown pipe op '%s'", filter.op),
|
||||
pos = filter.op_pos,
|
||||
span = len(filter.op),
|
||||
kind = .Data,
|
||||
hint = hint,
|
||||
}
|
||||
}
|
||||
|
||||
switch op {
|
||||
case .Group_By:
|
||||
return apply_group_by(value, filter.args[:], pos)
|
||||
case "format":
|
||||
case .Format:
|
||||
str, ok := reflect.as_string(value)
|
||||
if !ok {
|
||||
return value, Error_Body {
|
||||
@@ -260,15 +314,8 @@ apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) ->
|
||||
} else {
|
||||
return any{new_clone(str2, context.temp_allocator), typeid_of(string)}, nil
|
||||
}
|
||||
|
||||
case:
|
||||
return nil, Error_Body {
|
||||
msg = fmt.tprintf("unknown pipe op '%s'", filter.op),
|
||||
pos = filter.op_pos,
|
||||
span = len(filter.op),
|
||||
kind = .Data,
|
||||
}
|
||||
}
|
||||
return {}, nil
|
||||
}
|
||||
|
||||
apply_format :: proc(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package mustache
|
||||
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
import "core:testing"
|
||||
import "core:time/datetime"
|
||||
import "core:time/timezone"
|
||||
@@ -561,3 +562,24 @@ test_format_mst_no_offset_no_timezone :: proc(t: ^testing.T) {
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "UTC")
|
||||
}
|
||||
|
||||
// --- pipe op suggestion tests ---
|
||||
|
||||
@(test)
|
||||
test_unknown_pipe_op_suggestion :: proc(t: ^testing.T) {
|
||||
filter := Pipe_Filter{op = "formats", op_pos = 0}
|
||||
_, err := apply_filter("2026-01-15", &filter, 0, nil)
|
||||
testing.expect(t, err != nil, "should error on unknown op")
|
||||
b := body(err)
|
||||
testing.expect(t, strings.contains(b.hint, "format"), "hint should suggest 'format'")
|
||||
testing.expect(t, strings.contains(b.hint, "did you mean"), "hint should be a suggestion")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_unknown_pipe_op_no_suggestion :: proc(t: ^testing.T) {
|
||||
filter := Pipe_Filter{op = "xyz", op_pos = 0}
|
||||
_, err := apply_filter("2026-01-15", &filter, 0, nil)
|
||||
testing.expect(t, err != nil, "should error on unknown op")
|
||||
b := body(err)
|
||||
testing.expect(t, b.hint == "", "no suggestion expected for 'xyz'")
|
||||
}
|
||||
|
||||
+10
-1
@@ -121,9 +121,18 @@ validate_key_path :: proc(
|
||||
for i in 1 ..< part_count {
|
||||
v, info := base_value(current)
|
||||
if info == nil {
|
||||
return false, parts[i], nil
|
||||
return true, "", nil
|
||||
}
|
||||
if _, is_map := info.variant.(runtime.Type_Info_Map); is_map {
|
||||
val, found := lookup_in(current, parts[i])
|
||||
if found {
|
||||
current = val
|
||||
continue
|
||||
}
|
||||
available := collect_map_keys(current, allocator)
|
||||
if suggest_correction(available, parts[i]) != "" {
|
||||
return false, parts[i], available
|
||||
}
|
||||
return true, "", nil
|
||||
}
|
||||
if _, is_struct := info.variant.(runtime.Type_Info_Struct); is_struct {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#+feature dynamic-literals
|
||||
package mustache
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:testing"
|
||||
|
||||
@@ -196,3 +197,68 @@ test_warn_no_false_positive_for_valid_keys :: proc(t: ^testing.T) {
|
||||
ok, missing, _ := validate_key_path(ctx[:], "name")
|
||||
testing.expect_value(t, ok, true)
|
||||
}
|
||||
|
||||
// --- json.Value map crossing tests ---
|
||||
|
||||
JSON_Params_Context :: struct {
|
||||
params: json.Value,
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_validate_key_path_crosses_json_value_map :: proc(t: ^testing.T) {
|
||||
params_map := make(json.Object, context.temp_allocator)
|
||||
params_map["author"] = json.String("Tester")
|
||||
|
||||
data := JSON_Params_Context { params = params_map }
|
||||
ctx := make([dynamic]any, 0, 1, context.temp_allocator)
|
||||
append(&ctx, data)
|
||||
|
||||
ok, missing, _ := validate_key_path(ctx[:], "params.starred")
|
||||
testing.expect(t, ok, "path crossing json.Value map should be ok")
|
||||
testing.expect(t, missing == "", "no missing segment for map crossing")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_validate_key_path_json_value_map_existing_key :: proc(t: ^testing.T) {
|
||||
params_map := make(json.Object, context.temp_allocator)
|
||||
params_map["author"] = json.String("Tester")
|
||||
|
||||
data := JSON_Params_Context { params = params_map }
|
||||
ctx := make([dynamic]any, 0, 1, context.temp_allocator)
|
||||
append(&ctx, data)
|
||||
|
||||
ok, missing, _ := validate_key_path(ctx[:], "params.author")
|
||||
testing.expect(t, ok, "existing key in json.Value map should be ok")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_validate_key_path_map_typo :: proc(t: ^testing.T) {
|
||||
params_map := make(json.Object, context.temp_allocator)
|
||||
params_map["author"] = json.String("Tester")
|
||||
params_map["social"] = json.String("")
|
||||
|
||||
data := JSON_Params_Context { params = params_map }
|
||||
ctx := make([dynamic]any, 0, 1, context.temp_allocator)
|
||||
append(&ctx, data)
|
||||
|
||||
ok, missing, available := validate_key_path(ctx[:], "params.authr")
|
||||
testing.expect(t, !ok, "typo of map key should not be ok")
|
||||
testing.expect_value(t, missing, "authr")
|
||||
suggestion := suggest_correction(available, "authr")
|
||||
testing.expect_value(t, suggestion, "author")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_validate_key_path_map_no_close_match :: proc(t: ^testing.T) {
|
||||
params_map := make(json.Object, context.temp_allocator)
|
||||
params_map["author"] = json.String("Tester")
|
||||
params_map["social"] = json.String("")
|
||||
|
||||
data := JSON_Params_Context { params = params_map }
|
||||
ctx := make([dynamic]any, 0, 1, context.temp_allocator)
|
||||
append(&ctx, data)
|
||||
|
||||
ok, missing, _ := validate_key_path(ctx[:], "params.xyz")
|
||||
testing.expect(t, ok, "unknown key with no close match should be ok (suppressed)")
|
||||
testing.expect(t, missing == "", "no missing segment when suppressed")
|
||||
}
|
||||
|
||||
+40
-13
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import "mustache"
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:os"
|
||||
@@ -11,10 +12,10 @@ import "core:time/datetime"
|
||||
|
||||
Template_Context :: struct {
|
||||
now: string,
|
||||
title: string,
|
||||
date_format: string,
|
||||
timezone: ^datetime.TZ_Region,
|
||||
og: Open_Graph,
|
||||
params: json.Value,
|
||||
site: Site_Context,
|
||||
menus: map[string][]Menu_Entry,
|
||||
page: Page,
|
||||
@@ -117,17 +118,37 @@ to_title_case :: proc(s: string, allocator := context.allocator) -> string {
|
||||
return string(out)
|
||||
}
|
||||
|
||||
merge_params :: proc(site, page: json.Value) -> json.Value {
|
||||
if page == nil do return site
|
||||
if site == nil do return page
|
||||
site_obj, ok1 := site.(json.Object)
|
||||
page_obj, ok2 := page.(json.Object)
|
||||
if !ok1 do return page
|
||||
if !ok2 do return site
|
||||
merged := make(json.Object, len(site_obj) + len(page_obj), context.temp_allocator)
|
||||
for k, v in site_obj {merged[k] = v}
|
||||
for k, v in page_obj {merged[k] = v}
|
||||
return merged
|
||||
}
|
||||
|
||||
render_template :: proc(
|
||||
content_tpl: mustache.Template,
|
||||
ctx: Template_Context,
|
||||
partials: map[string]mustache.Template,
|
||||
reported_errors: ^map[string]bool,
|
||||
) -> string {
|
||||
result, err := mustache.render(content_tpl, []any{ctx.site, ctx.page, ctx}, partials)
|
||||
if err != nil {
|
||||
log.errorf(
|
||||
"%s",
|
||||
mustache.format_render_error(err, content_tpl, colorize = mustache.should_colorize()),
|
||||
formatted := mustache.format_render_error(
|
||||
err,
|
||||
content_tpl,
|
||||
colorize = mustache.should_colorize(),
|
||||
)
|
||||
if formatted in reported_errors^ {
|
||||
return ""
|
||||
}
|
||||
reported_errors[formatted] = true
|
||||
log.errorf("%s", formatted)
|
||||
return ""
|
||||
}
|
||||
return result
|
||||
@@ -145,6 +166,8 @@ render_site :: proc(site: ^Site) {
|
||||
template_cache: map[string]mustache.Template
|
||||
defer delete(template_cache)
|
||||
|
||||
errors := make(map[string]bool, context.temp_allocator)
|
||||
|
||||
offset, ok := mustache.compute_utc_offset(site.tz)
|
||||
assert(ok)
|
||||
now, ok2 := time.time_to_rfc3339(time.now(), offset, false, allocator)
|
||||
@@ -186,7 +209,7 @@ render_site :: proc(site: ^Site) {
|
||||
continue
|
||||
}
|
||||
tpl := get_template(&site.vfs, page.layout, &template_cache)
|
||||
html := render_page_html(page, site, tpl, partials, ctx)
|
||||
html := render_page_html(page, site, tpl, partials, ctx, &errors)
|
||||
if .Minify in site.features {
|
||||
html = minify_html(html)
|
||||
}
|
||||
@@ -215,6 +238,7 @@ render_site :: proc(site: ^Site) {
|
||||
section_tpl,
|
||||
partials,
|
||||
ctx,
|
||||
&errors,
|
||||
)
|
||||
if .Minify in site.features {
|
||||
html = minify_html(html)
|
||||
@@ -225,7 +249,7 @@ render_site :: proc(site: ^Site) {
|
||||
// Render home page
|
||||
if has_home {
|
||||
home_tpl := get_template(&site.vfs, "home", &template_cache)
|
||||
home_html := render_home_html(home, site, home_tpl, partials, ctx)
|
||||
home_html := render_home_html(home, site, home_tpl, partials, ctx, &errors)
|
||||
if .Minify in site.features {
|
||||
home_html = minify_html(home_html)
|
||||
}
|
||||
@@ -260,12 +284,13 @@ render_page_html :: proc(
|
||||
content_tpl: mustache.Template,
|
||||
partials: map[string]mustache.Template,
|
||||
ctx: Template_Context,
|
||||
seen: ^map[string]bool,
|
||||
) -> string {
|
||||
ctx := ctx
|
||||
ctx.title = fmt.tprintf("%s | %s", page.title, site.title)
|
||||
ctx.page = page
|
||||
ctx.og = og_for_page(site.og, page)
|
||||
return render_template(content_tpl, ctx, partials)
|
||||
ctx.params = merge_params(site.params, page.params)
|
||||
return render_template(content_tpl, ctx, partials, seen)
|
||||
}
|
||||
|
||||
render_home_html :: proc(
|
||||
@@ -274,6 +299,7 @@ render_home_html :: proc(
|
||||
content_tpl: mustache.Template,
|
||||
partials: map[string]mustache.Template,
|
||||
ctx: Template_Context,
|
||||
seen: ^map[string]bool,
|
||||
) -> string {
|
||||
list_pages := make([dynamic]Page, 0, 8, context.temp_allocator)
|
||||
for page in site.pages {
|
||||
@@ -284,11 +310,11 @@ render_home_html :: proc(
|
||||
}
|
||||
|
||||
ctx := ctx
|
||||
ctx.title = site.title
|
||||
ctx.pages = list_pages
|
||||
ctx.og = og_for_page(site.og, home)
|
||||
ctx.params = merge_params(site.params, home.params)
|
||||
|
||||
return render_template(content_tpl, ctx, partials)
|
||||
return render_template(content_tpl, ctx, partials, seen)
|
||||
}
|
||||
|
||||
render_section :: proc(
|
||||
@@ -299,6 +325,7 @@ render_section :: proc(
|
||||
content_tpl: mustache.Template,
|
||||
partials: map[string]mustache.Template,
|
||||
ctx: Template_Context,
|
||||
seen: ^map[string]bool,
|
||||
) -> string {
|
||||
alloc := site_allocator(site)
|
||||
posts := make([dynamic]Page, 0, len(site.pages) / 2, context.temp_allocator)
|
||||
@@ -312,14 +339,12 @@ render_section :: proc(
|
||||
ctx := ctx
|
||||
if has_index {
|
||||
ctx.page = section_index
|
||||
ctx.title = fmt.tprintf("%s | %s", section_index.title, site.title)
|
||||
ctx.og = og_for_page(site.og, section_index)
|
||||
} else {
|
||||
title := to_title_case(section, alloc)
|
||||
ctx.page = Page {
|
||||
title = title,
|
||||
}
|
||||
ctx.title = fmt.tprintf("%s | %s", ctx.page.title, site.title)
|
||||
ctx.og.title = title
|
||||
ctx.og.description = ""
|
||||
ctx.og.url = fmt.tprintf("%s/%s/", site.base_url, section)
|
||||
@@ -327,7 +352,8 @@ render_section :: proc(
|
||||
ctx.og.is_article = false
|
||||
}
|
||||
ctx.posts = posts
|
||||
return render_template(content_tpl, ctx, partials)
|
||||
ctx.params = merge_params(site.params, ctx.page.params)
|
||||
return render_template(content_tpl, ctx, partials, seen)
|
||||
}
|
||||
|
||||
load_partials :: proc(vfs: ^VFS) -> map[string]mustache.Template {
|
||||
@@ -417,3 +443,4 @@ write_file :: proc(path: string, html: string) {
|
||||
log.errorf("cannot write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:flags"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:mem"
|
||||
@@ -88,11 +87,13 @@ Flags :: struct {
|
||||
drafts: bool `args:"name=drafts" usage:"Include draft pages in the build"`,
|
||||
watch: bool `usage:"Rebuild on file changes (polls every 5 seconds)"`,
|
||||
minify: bool `args:"name=minify" usage:"Minify HTML output and CSS assets"`,
|
||||
verbose: bool `usage:"Enable debug logging"`,
|
||||
quiet: bool `usage:"Suppress info logging (warnings and errors only)"`,
|
||||
md_enable: string `args:"name=ext" usage:"Enable markdown extensions (comma-separated: emoji,sidenotes,alerts,highlight,sections)"`,
|
||||
md_disable: string `args:"name=no-ext" usage:"Disable markdown extensions (comma-separated: emoji,sidenotes,alerts,highlight,sections)"`,
|
||||
}
|
||||
|
||||
init_site :: proc(site: ^Site, args: []string) {
|
||||
init_site :: proc(site: ^Site, flags: Flags) {
|
||||
mem.dynamic_arena_init(&site.arena)
|
||||
alloc := site_allocator(site)
|
||||
|
||||
@@ -100,10 +101,7 @@ init_site :: proc(site: ^Site, args: []string) {
|
||||
site.base_url = "http://localhost:8080"
|
||||
site.markdown_extensions = md.DEFAULT_EXTENSIONS
|
||||
|
||||
_flags: Flags
|
||||
flags.parse_or_exit(&_flags, args, .Odin, alloc)
|
||||
|
||||
path := _flags.config_path
|
||||
path := flags.config_path
|
||||
if path == "" {
|
||||
found, ok := find_config("thor.json")
|
||||
if ok {
|
||||
@@ -128,7 +126,7 @@ init_site :: proc(site: ^Site, args: []string) {
|
||||
site_apply_path_defaults(site, config_dir)
|
||||
}
|
||||
|
||||
site_apply_cli_flags(site, _flags)
|
||||
site_apply_cli_flags(site, flags)
|
||||
site.config_path = path
|
||||
|
||||
site.og = og_for_site(site)
|
||||
@@ -245,6 +243,7 @@ site_apply_cli_flags :: proc(site: ^Site, flags: Flags) {
|
||||
|
||||
site.markdown_extensions += md.parse_extension_list(flags.md_enable)
|
||||
site.markdown_extensions -= md.parse_extension_list(flags.md_disable)
|
||||
md.resolve_extension_conflicts(&site.markdown_extensions)
|
||||
}
|
||||
|
||||
site_allocator :: proc(site: ^Site) -> mem.Allocator {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
public
|
||||
@@ -0,0 +1,24 @@
|
||||
# Docs Site AGENTS.md
|
||||
|
||||
## DO NOT EDIT — BY HUMANS, FOR HUMANS
|
||||
|
||||
All content under `thor/site/` is handwritten by humans. No AI, agent,
|
||||
bot, or assistant may edit, rewrite, or generate any file here. AI may
|
||||
be consulted as a sanity check, but the prose stays human.
|
||||
|
||||
## Implicit limits
|
||||
|
||||
The mustache engine imposes limits that template authors should be aware of.
|
||||
These values are defined in `thor/mustache/pipes.odin`, and `thor/mustache/mustache.odin`
|
||||
and must be kept in sync with the source code if they ever change.
|
||||
|
||||
- **Nested partials** — deeply nested partial chains (partial-in-partial)
|
||||
consume context stack frames during rendering. The limit is defined by
|
||||
`MAX_CONTEXT_STACK` (16) in the mustache engine. In practice, 3–4 levels of
|
||||
nesting is typical and safe.
|
||||
|
||||
- **`MAX_PIPES` (8)** — a single tag may chain up to 8 pipe filters:
|
||||
`{{key | op1 | op2 | ... | op8}}`. Exceeding this is a parse error.
|
||||
|
||||
- **`MAX_PIPE_ARGS` (2)** — each pipe filter accepts at most 2 arguments:
|
||||
`{{key | op arg1 arg2}}`.
|
||||
@@ -0,0 +1,38 @@
|
||||
[TOC]
|
||||
|
||||
Thor is a simple Static Sire Generator designed for personal blogs and other small websites.
|
||||
|
||||
Its core principals are simplicity and minimal configuration, so you can get started as quickly as possible.
|
||||
|
||||
It is based on Hugo, and gingerbill's SSG. Templating is done with (extended?) Mustache templates.
|
||||
|
||||
## What it does
|
||||
|
||||
- Syntax Highlighting server-side (with Tree Sitter) or client-side with highlight.js
|
||||
- Mustache Templating
|
||||
- OpenGraph Tags
|
||||
- Menus (WIP)
|
||||
- Extended Markdown ([See below](#extended-markdown))
|
||||
- Basic (whitespace) minification.
|
||||
|
||||
## What it doesn't do
|
||||
- Internationalization
|
||||
- Pagination (Yet)
|
||||
- Themes
|
||||
- Union File System (Yet)
|
||||
- Image Manipulation
|
||||
- TailwindCSS integration
|
||||
|
||||
## Getting Started
|
||||
|
||||
Run `thor`
|
||||
Check out `public/index.html`
|
||||
|
||||
Then follow [The Guide]()
|
||||
For a more complete setup, run `thor new site`.
|
||||
|
||||
## Extended Markdown
|
||||
- Emoji expansion
|
||||
- margin style footnotes
|
||||
- Guthub style alerts
|
||||
- [and more]
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"title": "AI",
|
||||
"date": "2026-07-28T12:16:00-04:00"
|
||||
}
|
||||
## Prose
|
||||
|
||||
This site, and all the documentation on it was written for humans, by humans.
|
||||
AI gets consulted as a sanity check, i.e. "Did I miss anything here?", "Does the documentation match the code's behaviour?", etc. All prose is hand-written by a human being.
|
||||
|
||||
## Code
|
||||
|
||||
The code is a completely different story. The majority of code is written with GLM-4.2 via OpenCode under programmer direction. I focus on architectural decisions, and I read the code emitted by the AI with varying degrees of scrutiny based on whether it touches a major part of the architecture, is in a hotpath, or holds some other high level of significance.
|
||||
@@ -0,0 +1,384 @@
|
||||
{
|
||||
"title": "Docs",
|
||||
"date": "2026-07-22T08:54:00-04:00",
|
||||
"toc": true
|
||||
}
|
||||
|
||||
## Introduction
|
||||
|
||||
This guide assumes you have either read [The Guide](../guide), or have built a [Hugo](https://gohugo.io) site before. It also assumes you have a basic knowledge of HTML and CSS.
|
||||
|
||||
## Features
|
||||
|
||||
Thor has many features and content processors available. In an effort to provide the best out of the box experience, most of them are enabled by default.
|
||||
|
||||
### Opt-In Features
|
||||
|
||||
TODO: #### deflist syntax
|
||||
|
||||
TODO: #### footnotes
|
||||
|
||||
#### Minify
|
||||
|
||||
If enabled, `minify` will perform simple whitespace removal on all your output `.html` files, and any `.css` files in your `assets` directories. Minifying JavaScript is not supported (yet).
|
||||
|
||||
TODO: Do we minify inline css?
|
||||
|
||||
### Opt-Out Features
|
||||
|
||||
- emoji
|
||||
- sidenotes/marginnotes
|
||||
- syntax highlighting
|
||||
- heading ids
|
||||
- TODO: Table Of Contents Generation
|
||||
- [GitHub style alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts)
|
||||
|
||||
|
||||
## Directories
|
||||
Like Hugo, a Thor project is a collection of specially named directories, plus an optional config file. All directories are optional, but it is recommended to at least have a `content` directory.
|
||||
|
||||
content
|
||||
|
||||
: `content` holds your pages and page bundles. See [Content](#content). If not found, thor will look for content files in the root of your current working directory.
|
||||
|
||||
assets
|
||||
: `assets` contains any static files for your site (favicon.ico, etc.), as well as files you want to send through the asset pipeline (CSS or JS files).
|
||||
|
||||
layouts
|
||||
|
||||
: `layouts` holds your templates and partials. See [Templates](#templates).
|
||||
|
||||
public
|
||||
: `public` will contain your completed site.
|
||||
|
||||
All of these names can be remapped in `thor.json`.[^remap]
|
||||
|
||||
[^remap]: While directories can be remapped at the site level, modules must (currently) adhere to the defaults.
|
||||
|
||||
### Assets
|
||||
|
||||
Currently, there is only one asset processor, and that is [the minifier](#minify), though more are planned (i.e. Image processing).
|
||||
|
||||
TODO: Expand
|
||||
|
||||
## Content
|
||||
### Pages & Page Bundles
|
||||
|
||||
Page content can either be defined in a single file (`contact.md`), or in a directory (`contact/index.md` + `contact/our-team.jpg`). Single file pages are preferred to page bundles.[^1]
|
||||
|
||||
[^1]: That's not you say you shouldn't use bundles, but if you have no additional resources on your page, there's no benefit to using a bundle.
|
||||
|
||||
Thor currently supports 2 formats for page files: MarkDown (`.md`), and HTML (`.html`).
|
||||
|
||||
TODO: Content
|
||||
|
||||
### Frontmatter
|
||||
|
||||
TODO: Frontmatter
|
||||
|
||||
## Menus
|
||||
|
||||
TODO: Describe
|
||||
|
||||
TODO: Don't forget to highlight differences from Hugo.
|
||||
|
||||
## Templates
|
||||
|
||||
Sites are built using one or more template files written in an extended version of [mustache](https://mustache.github.io) templates. The [mustache manual](https://mustache.github.io/mustache.5.html) has great explainations and a lot of examples if you want to know more, but I'll summarize them for you here.
|
||||
|
||||
The beauty of Mustache is that there is very little syntax; there are just 10 symbols you need learn: `{{`, `{{&`, `{{^`, `{{>`, `{{<`, `{{#`, `/}}`, `{{$`, `{{!`, and `|`.
|
||||
|
||||
TODO: ^^ Badly worded sentence ^^
|
||||
|
||||
TODOS: Gotta describe base templates somewhere. (the same way we describe the partials)
|
||||
|
||||
### Tags
|
||||
|
||||
#### Variables
|
||||
|
||||
In order to display a scalar (not-list) value in your template, simply wrap it in double curly braces. e.g. `{{ page.title }}`.
|
||||
|
||||
This content will be HTML escaped (for safety), so if the value you're rendering contains HTML, you'll need to use the raw syntex instead `{{& page.title}}`[^raw] which will output the value without stripping or re-writing content.
|
||||
|
||||
[^raw]: Official Triple brace syntax (`{{{ raw }}}`) is also supported, but `{{& raw }}` is preferred, as it's easy to accidentily insert too many braces.
|
||||
|
||||
In most[^most] cases, invalid keys will be silently ignored (nothing between the braces will appear), in keeping with the official mustache spec.
|
||||
|
||||
[^most]: TODO: in what cases won't it? spelllcheck + strict mode?
|
||||
|
||||
Leading and trailing whitespace(s) are ignored by the parser, so the folllowing are all equivilent: `{{& title }}`, `{{&title}}`, `{{& title}}`.
|
||||
|
||||
#### Sections
|
||||
TODO:
|
||||
|
||||
#### Inverted Sections
|
||||
TODO:
|
||||
|
||||
#### Partials
|
||||
TODO:
|
||||
|
||||
TODO: Talk about MAX_CONTEXT_DEPTH (currently 16) and how it limits the total number of nested templates to 13. (3 for `[site, page, ctx]`)
|
||||
|
||||
##### Dynamic Partials
|
||||
|
||||
#### Blocks
|
||||
TODO:
|
||||
|
||||
#### Parents
|
||||
TODO:
|
||||
|
||||
#### Summary
|
||||
|
||||
`{{page.title}}` for normal values
|
||||
`{{&page.title}}` for values that contain HTML.
|
||||
`<ul>{{#pages}}<li>{{title}}</li>{{/pages}}</ul>` for list values.
|
||||
`{{#params.is_starred}}<i class="fas fa-star"></i>{{/params.is_starred}}` for conditional content.
|
||||
`{{^pages}}No pages yet!{{/pages}}` to draw content when the value is empty.
|
||||
|
||||
### Section Names
|
||||
|
||||
When building your own templates, you are of course free to pick whatever names you choose for your partials and content slots. However, sticking to conventions helps create consistency in the ecosystem, and reduces friction when relying on built-in templates.
|
||||
|
||||
`{{$main}}...{{/main}}`
|
||||
|
||||
: the `main` section should be used in templates to denote the part of the page that is unique to that page. For the most part, your templates should look like this
|
||||
|
||||
```mustache
|
||||
{{<base}}
|
||||
{{$main}}
|
||||
<main>
|
||||
<h1>Actual page content goes here</h1>
|
||||
{{&page.content}}
|
||||
</main>
|
||||
{{/main}}
|
||||
{{/base}}
|
||||
|
||||
```
|
||||
|
||||
### Context
|
||||
|
||||
When building your page(s), the following keys are accessible to your template files:
|
||||
|
||||
`now`
|
||||
|
||||
: The Current `DateTime`. see [DateTime](#datetimes)
|
||||
|
||||
`date_format`
|
||||
|
||||
: The default format to use for dates. Configured in `thor.json:date.format`.
|
||||
|
||||
`timezone`
|
||||
|
||||
: The timezone to convert dates to when using `{{ date | format }}`. Configured in `thor.json:date.timezone`.
|
||||
|
||||
`site`
|
||||
|
||||
: The site parameters that are usable in templates, see [site](#site).
|
||||
|
||||
`pages`
|
||||
|
||||
: Returns all regular pages, sorted by `?`. Regular pages exclude index pages like home and section roots.
|
||||
|
||||
`posts`
|
||||
: TODO: Section groupings
|
||||
|
||||
`og`
|
||||
|
||||
: Contains the [Open Graph](https://ogp.me/) metadata for the current page.
|
||||
|
||||
`menus`
|
||||
|
||||
: The site's constructed menus. See [menus](#menus).
|
||||
#### Page
|
||||
|
||||
`page.content`
|
||||
|
||||
: The HTML rendered page content
|
||||
|
||||
TODO: write
|
||||
|
||||
#### Site
|
||||
|
||||
TODO: Write
|
||||
|
||||
#### Params
|
||||
|
||||
`site.params` and `page.params` are an escape hatch to let users inject arbetrary data into their website. The following are some conventional examples theme developers may want to use to ensure a consistent experience across themes.
|
||||
|
||||
`author`
|
||||
|
||||
: The author of the current page or site. See [schema.org](https://schema.org/author) for the recommended format.
|
||||
|
||||
`stylesheets`
|
||||
|
||||
: A list of paths to css files the users wants to include globally. These are rendered by the `{{> styles }}` partial, and can be omitted on sites that use custom templates.
|
||||
|
||||
TODO: ^^ Bad sentence? ^^
|
||||
|
||||
#### The Context Stack
|
||||
When building your page(s), each template is fed a Context[^ctx] stack that contains all the data should you need to build your page.
|
||||
|
||||
[^ctx]: (for developers) `Template_Context` is not the same as Odin's implicit `context` parameter.
|
||||
|
||||
The context stack is initialized[^init] as `[site, page, context]`, meaning if you use a key like `{{title}}` in your template, it will look up `context.title`, `page.title`, and then finally `site.title`, using the first available value it can find.
|
||||
|
||||
[^init]: TODO: Don't use programmer speak.
|
||||
|
||||
TODO: As you descend into sections/partials, new contexts are placed onto the stack, so they resolve first. (LIFO order)
|
||||
|
||||
```mustache
|
||||
{{<base}}
|
||||
{{$main}}
|
||||
<main>
|
||||
{{&page.content}}
|
||||
<!-- Is the same as -->
|
||||
{{&content}}
|
||||
<!-- Or -->
|
||||
{{$page}}{{&content}}{{/page}}
|
||||
<!-- Or -->
|
||||
{{$page.content}}{{&.}}{{/page.content}}
|
||||
</main>
|
||||
{{/main}}
|
||||
{{/base}}
|
||||
```
|
||||
|
||||
|
||||
### Filters
|
||||
|
||||
Because mustache is a logic-less language, (there are no `for` or `if` tags), Thor extends mustache to include a handful of data filters in the form of pipes. Bash users will feel right at home here.
|
||||
|
||||
`group_by <field>`
|
||||
|
||||
: Allows you to group pages by the given field. e.g.
|
||||
```mustache
|
||||
{{#pages | group_by year }}
|
||||
<section>
|
||||
<header>{{$key}} {{! The group's year}}</header>
|
||||
<ul>
|
||||
{{#items}}<li>
|
||||
{{title}} {{! The title of each of that year's pages }}
|
||||
{{/items}}</li>
|
||||
</ul>
|
||||
</section>
|
||||
{{/pages}}
|
||||
```
|
||||
|
||||
`sort_by <field> <asc|desc>`
|
||||
|
||||
: Allows you to sort pages by the given field.
|
||||
|
||||
`first <n>`
|
||||
|
||||
: Given a list, returns only the first `n` items. `n` defaults to 1. Given a string, returns the first `n` runes of the string. To help catch mistakes, `n` is required for strings.
|
||||
|
||||
`last <n>`
|
||||
|
||||
: Given a list, returns only the last `n` items. `n` defaults to 1. Given a string, returns the first `n` runes of the string. To help catch mistakes, `n` is required for strings.
|
||||
|
||||
`format "<format string>"`
|
||||
|
||||
: Used to display a date in a particular format. See [DateTimes](#datetimes). If no format is given, the default will be used.
|
||||
|
||||
#### Chaining Pipes
|
||||
|
||||
Thor allows you to chain two or more pipes, to allow complex data manipulation. However for performance and stylistic reasons, you are limited to no more than **8 pipes**[^pipes] for any given tag. If you believe you need more than 8 pipes, please [open an issue](../issues) with a **concrete example** of the problem you are facing.
|
||||
|
||||
[^pipes]: TODO: This number must be kept in-sync with `MAX_PIPES`.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```mustache
|
||||
{{ pages | group_by year | first }} {{! All pages from the current year }}
|
||||
```
|
||||
|
||||
```mustache
|
||||
{{ pages | sort_by date desc | first }} {{! The latest page }}
|
||||
```
|
||||
|
||||
|
||||
### Partials
|
||||
|
||||
Partials are templates that render a portion of a page. To include a partial, the standard [mustache syntax](https://mustache.github.io/mustache.5.html#Partials) is used. All partials are resolved relative to the root partial's directory, so to include a partial at `layouts/partials/my_partial.html`, you would use `{{> my_partial}}`.
|
||||
|
||||
Users can create or override as many partials as they want; several are included for convienience:
|
||||
|
||||
`{{> title }}`
|
||||
|
||||
: The title of the current page. It should be placed inside the `<title>` tag. By default, it will appear as `{{ page.title }} | {{ site.title }}`.
|
||||
|
||||
`{{> nav }}`
|
||||
|
||||
: Nav renders the main menu for the site (`menus.main`). It should be placed inside the `<body>` tag, just before the `<main>` block.
|
||||
|
||||
`{{> home-link }}`
|
||||
|
||||
: This partial is rendered inside the home anchor in `{{> nav }}`. By default, it wil display the name of the site, or `Home` if no name is set.
|
||||
|
||||
`{{> styles }}`
|
||||
|
||||
: This partial renders any stylesheets specified either by the user (via `params.stylesheets`) or by the theme author (specified directly in the template). `params.stylesheets` is intended as an escape hatch for users that want to add CSS to their site, but don't want to customize any templates. Styles should be placed inside the `<head>` tags.
|
||||
|
||||
`{{> scripts }}`
|
||||
|
||||
: This partial renders any script tags specified either by the user (via `params.scripts`) or by the theme author (specified directly in the template). `params.scripts` is intended as an escape hatch for users that want to add javascript to their site, but don't want to customize any templates. Scripts should be placed at the end of the `<head>` block.
|
||||
|
||||
TODO: Scripts must currently be given in raw form, whereas styles are just paths/urs.
|
||||
|
||||
`{{> opengraph }}`
|
||||
|
||||
: This partial willl render the Open Graph meta tags for your page. It should be placed inside the `<head>` tag. See the [Open Graph](#open-graph) section for more details.
|
||||
|
||||
`{{> footer }}`
|
||||
|
||||
: This partial will render content on every page after the main page content. It should be placed at the end of the `<body>` tag. By default, it displays a simple copyright line with the current year and author's name (configured in `params.author.name`).
|
||||
|
||||
TODO: `styles`/`css`?
|
||||
|
||||
TODO: `comments`?
|
||||
|
||||
TODO: `toc`?
|
||||
|
||||
TODO: We keep referring to blocks and tags, should probably use consistent language.
|
||||
|
||||
### DateTimes
|
||||
|
||||
TODO: Should this be a subsection of a "Data Types" section?
|
||||
|
||||
Dates are strings in one of the following formats:
|
||||
|
||||
| Format | Time zone |
|
||||
| --------------------------- | ---------------------------- |
|
||||
| `2023-10-15T13:18:50-07:00` | `America/Los_Angeles` |
|
||||
| `2023-10-15T13:18:50-0700` | `America/Los_Angeles` |
|
||||
| `2023-10-15T13:18:50Z` | `Etc/UTC` |
|
||||
| `2023-10-15T13:18:50` | Default is local system time |
|
||||
| `2023-10-15` | Default is local system time |
|
||||
| `15 Oct 2023` | Default is local system time |
|
||||
|
||||
If you want to display a date in a different format, you can use the `| format` Filter. With no argument, it will default to formatting the date with `site.date_format`.
|
||||
|
||||
## Open Graph
|
||||
|
||||
TODO: default Template support
|
||||
TODO: Template override support
|
||||
TODO: how to overwrite defaults (in page frontmatter / thor.json)
|
||||
|
||||
## Zen
|
||||
|
||||
> The guding principals behind Thor.
|
||||
|
||||
Simpler is Better
|
||||
|
||||
: We are [Grug](https://grugbrain.dev/) developers. We do everything the "dumb" way first, then optimize the **measured** bottlenecks.
|
||||
|
||||
Zero is Beautiful
|
||||
|
||||
: The less configuration needed, the better. A site can be built from multiple modules or a single `index.md`.
|
||||
|
||||
On By Default
|
||||
|
||||
: Users shouldn't have to enable features, unless those features significantly impact performance or mangle regular content.
|
||||
|
||||
Fault Tolerant
|
||||
|
||||
: When possible, mistakes should be recovered with warnings. When fatal errors occur, the user should have all the information they need to fix it quickly.
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"title": "Guide",
|
||||
"date": "2026-07-12T08:55:00-04:00"
|
||||
}
|
||||
[TOC]
|
||||
|
||||
Thank you for choosing thor for your site building needs. This guide attempts to make it as easy as possible to get started.
|
||||
|
||||
> [!NOTE]: This guide assumes you have a basic knowledge of using command line interfaces, file systems, and text editors.
|
||||
|
||||
|
||||
Start by creating a new directory called `my-site`. This will be the home of your first Thor site.
|
||||
|
||||
If you haven't already, running `thor` in your new directory should create this guide in `./public/index.html`.
|
||||
|
||||
At the heart of any good SSG is the content files. Thor supports markdown (`.md`) and `.html` files.
|
||||
|
||||
Let's make a home page - copy the following text and place it into `./index.md`
|
||||
|
||||
```md
|
||||
# Hello, World!
|
||||
|
||||
Welcome to your new site.
|
||||
```
|
||||
|
||||
Run `thor` again, and you should see new content in `public/index.html`.
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Home"
|
||||
}
|
||||
TODO: Add content here
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"markdown_extensions": {
|
||||
// "footnotes": true
|
||||
},
|
||||
"params": {
|
||||
"author": {
|
||||
"name": "Spencer Brower"
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
-13
@@ -3,10 +3,13 @@
|
||||
package main
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:flags"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:os"
|
||||
import "core:testing"
|
||||
import "core:time/datetime"
|
||||
import "core:time/timezone"
|
||||
|
||||
write_temp_config :: proc(name: string, content: string) -> string {
|
||||
path := fmt.tprintf("./test_thor_%s.json", name)
|
||||
@@ -103,9 +106,12 @@ test_load_config_file_partial :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_init_site_defaults_no_config :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
|
||||
site: Site
|
||||
args := []string{"thor", "-config:./nonexistent.json"}
|
||||
init_site(&site, args)
|
||||
_flags: Flags
|
||||
flags.parse_or_exit(&_flags, []string{"thor", "-config:./nonexistent.json"}, .Odin)
|
||||
init_site(&site, _flags)
|
||||
defer destroy_site(&site)
|
||||
|
||||
testing.expect_value(t, site.content_dir, "./content")
|
||||
@@ -120,9 +126,12 @@ test_init_site_defaults_no_config :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_init_site_config_dir_relative :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
|
||||
site: Site
|
||||
args := []string{"thor", "-config:./sub/nonexistent.json"}
|
||||
init_site(&site, args)
|
||||
_flags: Flags
|
||||
flags.parse_or_exit(&_flags, []string{"thor", "-config:./sub/nonexistent.json"}, .Odin)
|
||||
init_site(&site, _flags)
|
||||
defer destroy_site(&site)
|
||||
|
||||
testing.expect_value(t, site.content_dir, "./sub/content")
|
||||
@@ -133,9 +142,12 @@ test_init_site_config_dir_relative :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_init_site_flag_overrides_default :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
|
||||
site: Site
|
||||
args := []string{"thor", "-config:./nonexistent.json", "-drafts", "-base-url:https://flag.com"}
|
||||
init_site(&site, args)
|
||||
_flags: Flags
|
||||
flags.parse_or_exit(&_flags, []string{"thor", "-config:./nonexistent.json", "-drafts", "-base-url:https://flag.com"}, .Odin)
|
||||
init_site(&site, _flags)
|
||||
defer destroy_site(&site)
|
||||
|
||||
testing.expect(t, .Drafts in site.features)
|
||||
@@ -144,6 +156,8 @@ test_init_site_flag_overrides_default :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_init_site_full_pipeline :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
|
||||
path := write_temp_config(
|
||||
"pipeline",
|
||||
`{"title":"Pipeline Test","description":"Full","base_url":"https://config.com"}`,
|
||||
@@ -151,8 +165,9 @@ test_init_site_full_pipeline :: proc(t: ^testing.T) {
|
||||
defer os.remove(path)
|
||||
|
||||
site: Site
|
||||
args := []string{"thor", fmt.tprintf("-config:%s", path), "-drafts"}
|
||||
init_site(&site, args)
|
||||
_flags: Flags
|
||||
flags.parse_or_exit(&_flags, []string{"thor", fmt.tprintf("-config:%s", path), "-drafts"}, .Odin)
|
||||
init_site(&site, _flags)
|
||||
defer destroy_site(&site)
|
||||
|
||||
testing.expect_value(t, site.title, "Pipeline Test")
|
||||
@@ -163,14 +178,17 @@ test_init_site_full_pipeline :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_init_site_md_enable_disable :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
|
||||
site: Site
|
||||
args := []string {
|
||||
_flags: Flags
|
||||
flags.parse_or_exit(&_flags, []string {
|
||||
"thor",
|
||||
"-config:./nonexistent.json",
|
||||
"-ext:highlight,sections",
|
||||
"-no-ext:emoji",
|
||||
}
|
||||
init_site(&site, args)
|
||||
}, .Odin)
|
||||
init_site(&site, _flags)
|
||||
defer destroy_site(&site)
|
||||
|
||||
testing.expect(t, .Highlight in site.markdown_extensions)
|
||||
@@ -181,6 +199,8 @@ test_init_site_md_enable_disable :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_init_site_config_paths :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
|
||||
path := write_temp_config(
|
||||
"paths",
|
||||
`{
|
||||
@@ -193,8 +213,9 @@ test_init_site_config_paths :: proc(t: ^testing.T) {
|
||||
defer os.remove(path)
|
||||
|
||||
site: Site
|
||||
args := []string{"thor", fmt.tprintf("-config:%s", path)}
|
||||
init_site(&site, args)
|
||||
_flags: Flags
|
||||
flags.parse_or_exit(&_flags, []string{"thor", fmt.tprintf("-config:%s", path)}, .Odin)
|
||||
init_site(&site, _flags)
|
||||
defer destroy_site(&site)
|
||||
|
||||
testing.expect_value(t, site.content_dir, "/custom/content")
|
||||
@@ -202,3 +223,95 @@ test_init_site_config_paths :: proc(t: ^testing.T) {
|
||||
testing.expect_value(t, site.output_dir, "/custom/output")
|
||||
testing.expect_value(t, site.layouts_dir, "/custom/layouts")
|
||||
}
|
||||
|
||||
// --- merge_params tests ---
|
||||
|
||||
@(test)
|
||||
test_merge_params_both_present :: proc(t: ^testing.T) {
|
||||
site_params, _ := json.parse_string(
|
||||
`{"social": [], "author": "Tester"}`,
|
||||
spec = .JSON,
|
||||
allocator = context.temp_allocator,
|
||||
)
|
||||
page_params, _ := json.parse_string(
|
||||
`{"starred": true}`,
|
||||
spec = .JSON,
|
||||
allocator = context.temp_allocator,
|
||||
)
|
||||
|
||||
merged_val := merge_params(site_params, page_params)
|
||||
merged, ok := merged_val.(json.Object)
|
||||
testing.expect(t, ok, "merged should be a json.Object")
|
||||
|
||||
_, has_social := merged["social"]
|
||||
testing.expect(t, has_social, "site param 'social' should survive merge")
|
||||
|
||||
_, has_author := merged["author"]
|
||||
testing.expect(t, has_author, "site param 'author' should survive merge")
|
||||
|
||||
starred, has_starred := merged["starred"]
|
||||
testing.expect(t, has_starred, "page param 'starred' should be present")
|
||||
starred_bool, _ := starred.(json.Boolean)
|
||||
testing.expect(t, bool(starred_bool), "starred should be true")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_merge_params_nil_page :: proc(t: ^testing.T) {
|
||||
site_params, _ := json.parse_string(
|
||||
`{"author": "Tester"}`,
|
||||
spec = .JSON,
|
||||
allocator = context.temp_allocator,
|
||||
)
|
||||
|
||||
merged_val := merge_params(site_params, nil)
|
||||
merged, ok := merged_val.(json.Object)
|
||||
testing.expect(t, ok, "should return site params when page is nil")
|
||||
|
||||
_, has_author := merged["author"]
|
||||
testing.expect(t, has_author, "site param should survive")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_merge_params_nil_site :: proc(t: ^testing.T) {
|
||||
page_params, _ := json.parse_string(
|
||||
`{"starred": true}`,
|
||||
spec = .JSON,
|
||||
allocator = context.temp_allocator,
|
||||
)
|
||||
|
||||
merged_val := merge_params(nil, page_params)
|
||||
merged, ok := merged_val.(json.Object)
|
||||
testing.expect(t, ok, "should return page params when site is nil")
|
||||
|
||||
_, has_starred := merged["starred"]
|
||||
testing.expect(t, has_starred, "page param should survive")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_merge_params_both_nil :: proc(t: ^testing.T) {
|
||||
merged_val := merge_params(nil, nil)
|
||||
testing.expect(t, merged_val == nil, "both nil should return nil")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_merge_params_page_overrides_site :: proc(t: ^testing.T) {
|
||||
site_params, _ := json.parse_string(
|
||||
`{"key": "site_value"}`,
|
||||
spec = .JSON,
|
||||
allocator = context.temp_allocator,
|
||||
)
|
||||
page_params, _ := json.parse_string(
|
||||
`{"key": "page_value"}`,
|
||||
spec = .JSON,
|
||||
allocator = context.temp_allocator,
|
||||
)
|
||||
|
||||
merged_val := merge_params(site_params, page_params)
|
||||
merged, ok := merged_val.(json.Object)
|
||||
testing.expect(t, ok)
|
||||
|
||||
val := merged["key"]
|
||||
str, _ := val.(json.String)
|
||||
testing.expect_value(t, string(str), "page_value")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user