mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
Compare commits
170 Commits
4fa7227b94
...
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 | |||
| e742832a4b | |||
| 01d13ff279 | |||
| dea4180031 | |||
| 838c55f73c | |||
| 98cb51dbc3 | |||
| c913424d41 | |||
| 18596bb37d | |||
| 62d608ef86 | |||
| 71f12e5280 | |||
| 4f0b5c929d | |||
| 1992349520 | |||
| 961b3f41b0 | |||
| 91623e8992 | |||
| 033e7f4f79 | |||
| 75a7d7a14b | |||
| 4f09312daf | |||
| 4d847d95d3 | |||
| 0d1e9ba352 | |||
| 213385dd70 | |||
| 5833b764a4 | |||
| 23b0b3fa5f | |||
| d35da93dcf | |||
| 4560014954 | |||
| b3608fe493 | |||
| b2ff7038cb | |||
| c648b600c7 | |||
| 56e434210b | |||
| 83f2dc18f9 | |||
| 986038f54c | |||
| 5960d3686c | |||
| 1856e29763 | |||
| cfd01a02d0 | |||
| f04532229b | |||
| b239bf2d87 | |||
| cc42e694fd | |||
| bed7effd4b | |||
| 5b98ad5134 | |||
| 0fa08f3cae | |||
| 4ea44e8a09 | |||
| 566d6f0768 | |||
| 1cb13a6a55 | |||
| 5cef29e16f | |||
| 5d8f646b31 | |||
| 770a04481d | |||
| 29d244c1a6 | |||
| efe0c42e5c | |||
| 5c689638b1 | |||
| 398f0eb1b4 | |||
| ac43ab1e5a | |||
| 4fc3eae64c | |||
| 90f6466f9c | |||
| ce0f662c92 | |||
| 6f6c0dddd1 | |||
| c0ff59513f | |||
| 6e66709f05 | |||
| a41a7803a4 | |||
| 66b5c5a934 | |||
| ebb4ef691a | |||
| 9d2b2c4395 | |||
| d05459af52 | |||
| c7a5ae2360 | |||
| 085811b312 | |||
| 618fabf0e4 | |||
| dfe863e293 | |||
| 528bef3941 | |||
| fdd9ef4d46 | |||
| cfb41ef930 | |||
| fba62fe156 | |||
| 6694c0cf67 | |||
| c7b42ce7aa | |||
| a4b16e1b0a | |||
| f70205876f | |||
| f78e3811e4 | |||
| 3898925228 | |||
| 323a1318cf | |||
| 06a3c4d82d | |||
| eb452b25fc | |||
| 7e93c3468f | |||
| 1f44adcf33 | |||
| e4e74df27e | |||
| 28975736cd | |||
| a00f7d8ef5 | |||
| 618e08a718 | |||
| 5a1ceb617c | |||
| 93cf74ee0b | |||
| 7aa411490a | |||
| 84e28f8a1d | |||
| 9b732c4879 | |||
| 00b73c04ba | |||
| 3c149246be | |||
| d1ca36bbd2 | |||
| fea7144a4d | |||
| 7aa3cc7c74 | |||
| 91606a2223 | |||
| f48cc242a4 | |||
| 0ba29e43ed | |||
| b90496b5a2 | |||
| d23eec448b | |||
| 8de51ead39 | |||
| 3cbf765345 | |||
| 8a9f12d532 | |||
| cdb4ad9329 | |||
| 9671a90726 | |||
| 4521960824 | |||
| ed17ac8633 | |||
| 8b2dceb679 | |||
| 42d58394ef | |||
| 462c434334 |
@@ -1,3 +1,3 @@
|
||||
use flake
|
||||
|
||||
export PATH="$PATH:./vendor/bin"
|
||||
export PATH="$PATH:./"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
.direnv
|
||||
result
|
||||
thor
|
||||
*.spall
|
||||
thor-prof
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
# Thor — Odin Static Site Generator
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
thor.json ← site config (title, base_url, params, modules, og)
|
||||
content/ ← markdown and HTML content files
|
||||
layouts/ ← Mustache templates + partials (user overrides)
|
||||
assets/ ← CSS (Tufte-based), JS, fonts, images
|
||||
thor/defaults/ ← bundled default templates (embedded via #directory)
|
||||
public/ ← build output (generated)
|
||||
```
|
||||
|
||||
### Package structure
|
||||
|
||||
```
|
||||
thor/
|
||||
├── treesitter/ # FFI types + grammar management (standalone package)
|
||||
├── markdown/ # Content transformation pipeline (imports ../treesitter)
|
||||
├── mustache/ # Template engine with lambdas + pipe filters + diagnostics
|
||||
├── content.odin # Page struct, Pending_File, scan_content_files, collect_languages, load_page
|
||||
├── render.odin # Template rendering, Template_Context, sort_pages, RSS, sitemap
|
||||
├── menus.odin # Menu_Entry, DEFAULT_WEIGHT, build_menus, collect_auto_menus, merge_page_menus, parse_page_menus, parse_config_menus
|
||||
├── site.odin # Config (Flags, Config_File, Site, Site_Context), init_site
|
||||
├── minify.odin # HTML/CSS minification (imports treesitter)
|
||||
├── feed.odin # RSS + sitemap generation
|
||||
├── vfs.odin # Union file system (defaults → modules → site)
|
||||
├── assets.odin # VFS-based asset copying
|
||||
├── html.odin # HTML helpers: strip_html_tags, unescape_html, generate_summary, generate_description
|
||||
├── opengraph.odin # Open_Graph struct + og_for_site/og_for_page
|
||||
├── frontmatter.odin # JSON frontmatter parser (supports nested og + lastmod + weight + menus)
|
||||
├── defaults.odin # DEFAULTS_PATH constant (#directory)
|
||||
├── main.odin # Entry point
|
||||
├── bench/ # Template rendering benchmark
|
||||
└── defaults/layouts/ # Bundled default templates
|
||||
```
|
||||
|
||||
### Source files (main package)
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `main.odin` | Entry point. Parses CLI flags via `core:flags`, sets logger level from `-verbose`/`-quiet`, calls `init_site`, `build_vfs`, wires `treesitter.grammar_dir`/`query_dir` from config, `site_load_content`, `render_site`. Optional Spall profiling via `SPALL` config flag. |
|
||||
| `site.odin` | `Flags` (CLI, includes `-verbose`/`-quiet`), `Config_File` (thor.json), `Site_Context` (template-facing: `title`, `description`, `base_url`, `params`, `og`, `menus`), `Site` (runtime state + arena + VFS + pages + `og`). `Feature` enum. `init_site(site, flags)` — takes pre-parsed `Flags`. Config menu parsing in `site_apply_config`. |
|
||||
| `content.odin` | `Page` struct (includes `weight`, `menus`, `params: json.Value`, `toc: string`, `og`), `Pending_File` struct, `scan_content_files` (section-aware walk that handles leaf bundles), `collect_languages` (pre-scan for code fence languages), `load_page` (falls back to file mtime, generates TOC via `md.generate_toc` when frontmatter `"toc": true`), `infer_layout`. Calls `md.process()` for the markdown pipeline. |
|
||||
| `render.odin` | Template rendering: `render_site`, `render_page_html`, `render_home_html`, `render_section`. `Template_Context` (unified render struct with `site: Site_Context`, `page: Page`, `menus`, `params`, `posts`, `pages`). 3-frame context stack via `[]any{ctx.site, ctx.page, ctx}`. `merge_params(site, page)` — shallow merge of site + page params. Error deduplication via `seen: ^map[string]bool` passed through render chain. `sort_pages` (weight primary, date secondary). `to_title_case` for section display names. VFS-based template loading with fallback chain (`get_template`). |
|
||||
| `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. |
|
||||
| `vfs.odin` | Union file system: `VFS`, `build_vfs`, `mount_dir`, `mount_subdir`, `mount_recursive`, `vfs_get`, `vfs_get_entry`, `vfs_entry_data`. Layers defaults → modules → site. |
|
||||
| `assets.odin` | `copy_assets_dir` — iterates VFS entries with `assets/` prefix, minifies CSS, copies verbatim or via `os.copy_file`. |
|
||||
| `html.odin` | `strip_html_tags`, `unescape_html`, `generate_summary` (word-count truncation, zero-alloc), `generate_description` (HTML→plain text: strip tags, decode entities, collapse whitespace). |
|
||||
| `opengraph.odin` | `Open_Graph` struct (fields ordered per OGP spec, `is_article: Maybe(bool)`). `og_for_site(site)` for site defaults (from config + derived), `og_for_page(site_og, page)` for page-specific (overlay page.og + derive from page data). Description falls back to `generate_description(generate_summary(body_html))`. |
|
||||
| `frontmatter.odin` | JSON frontmatter parser (`{ }` delimited). Supports `layout`, `lastmod`, `weight: Maybe(int)`, `menus`, `params: json.Value`, `toc: bool`, and nested `og` object (via `json_get_open_graph`). Helpers: `json_get_string`, `json_get_bool`, `json_get_int` (returns `Maybe(int)`, nil for absent/invalid). |
|
||||
| `defaults.odin` | `DEFAULTS_PATH` constant, resolved at compile time via `#directory` so bundled templates ship in the binary. |
|
||||
|
||||
### Subpackages
|
||||
|
||||
| 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, 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). |
|
||||
|
||||
Icon SVGs live as HTML partials in `layouts/partials/icons/` (home, github, rss, chevron_up, star).
|
||||
|
||||
### Data flow
|
||||
|
||||
```
|
||||
thor.json → find_config → init_site (5-step)
|
||||
→ build_vfs (defaults/layouts → modules → site/layouts, site/assets)
|
||||
→ site_load_content (scan_content_files + collect_languages + preload_grammars + load_page + url computation + build_menus + warn_all_duplicate_weights)
|
||||
→ render_site
|
||||
→ load_partials + get_template (VFS + fallback chain)
|
||||
→ render_page_html / render_home_html / render_section (3-frame context stack: site, page, ctx)
|
||||
→ optional minify_html
|
||||
→ public/
|
||||
```
|
||||
|
||||
## Page struct
|
||||
|
||||
```odin
|
||||
Page :: struct {
|
||||
section: string, // "" for root, "posts", etc.
|
||||
slug: string,
|
||||
layout: string, // inferred or frontmatter override
|
||||
permalink: string, // relative URL path
|
||||
url: string, // full canonical URL (base_url + permalink)
|
||||
title: string,
|
||||
description: string,
|
||||
date: string,
|
||||
year: string,
|
||||
weight: Maybe(int), // page ordering (nil = unset, defaults to DEFAULT_WEIGHT at comparison time)
|
||||
lastmod: string,
|
||||
menus: map[string]Menu_Entry, // frontmatter menu assignments
|
||||
params: json.Value, // per-page params (merged with site params at render time)
|
||||
content: string, // rendered HTML body
|
||||
og: Open_Graph,
|
||||
draft: bool,
|
||||
toc: string, // generated table of contents HTML (empty if not requested)
|
||||
_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)`:
|
||||
|
||||
- Home (root index): `"home"`
|
||||
- Section index: `"<section>_index"` (e.g. `"posts_index"`)
|
||||
- Section page: singularized section (e.g. `"post"`)
|
||||
- Root page: `"page"`
|
||||
|
||||
**Template fallback chain** (in `get_template`): for content pages, `post → page → base`; for section indexes, `posts_index → section_index → page → base`. Fallbacks logged at debug level. Frontmatter `layout` field overrides the inferred value.
|
||||
|
||||
## Menus
|
||||
|
||||
Menu system in `menus.odin`. `Menu_Entry :: struct {name: string, url: string, weight: int}`. `DEFAULT_WEIGHT = 10`.
|
||||
|
||||
### Sources (priority chain, no mixing)
|
||||
|
||||
1. **Config menus** (`"menus"` key in `thor.json`) — exclusive. `"menus": {}` = explicit opt-out (no menus). Config entries sorted by weight.
|
||||
2. **Auto-menus + page frontmatter** — always run together when no config menus:
|
||||
- Auto: one entry per section directory + one per root-level non-index page. Alphabetical.
|
||||
- Page frontmatter: `"menus": "main"` (string), `["main", "footer"]` (array), or `{"main": {"weight": 30}}` (object with per-menu weight). Merged with auto entries, sorted by weight.
|
||||
|
||||
### Weight
|
||||
|
||||
All weight fields use `Maybe(int)` — nil means "unset," `some(v)` means explicitly set. This distinguishes `"weight": 10` (explicit) from no weight key (defaults to `DEFAULT_WEIGHT` at comparison time via `.? or_else DEFAULT_WEIGHT`). Eliminates the old `0`-as-sentinel pattern from `json_get_int`.
|
||||
|
||||
- `Page.weight: Maybe(int)` — page-level ordering. nil = unset. Affects `sort_pages` (weight primary, date secondary).
|
||||
- `Menu_Entry.weight: Maybe(int)` — per-menu ordering. nil for auto-generated entries and string/array frontmatter forms. Explicit value from object frontmatter form `{"weight": N}`.
|
||||
- Effective weight in `merge_page_menus`: per-menu weight if set, else falls back to `page.weight`. Both `Maybe(int)`, so nil propagates naturally — no value-based sentinel check.
|
||||
- Sorted ascending via `.? or_else DEFAULT_WEIGHT`, name alphabetical for ties.
|
||||
|
||||
### Templates
|
||||
|
||||
```html
|
||||
{{#menus.main}}
|
||||
<li><a href="{{url}}">{{name}}</a></li>
|
||||
{{/menus.main}}
|
||||
```
|
||||
|
||||
`Template_Context.menus` resolves above `Page.menus` (frontmatter assignments) on the 3-frame context stack. Accessible as `{{#menus.main}}` or `{{#site.menus.main}}`.
|
||||
|
||||
### Duplicate weight warnings
|
||||
|
||||
`warn_duplicate_weights` (called from `build_menus` after all menus are sorted) logs a warning when two entries in the same menu have the same explicitly-set weight. Only non-nil weights are checked — nil (unset/default) entries are never flagged, so auto-generated entries don't produce noise. The warning includes the menu name, weight value, and both entry names.
|
||||
|
||||
## Config system
|
||||
|
||||
Config is split into three structs with a clear 5-step initialization flow:
|
||||
|
||||
- **`Flags`** — CLI args only. Parsed once in `main.odin` via `core:flags`, passed to `init_site`. Includes path overrides (`--content`, `--assets`, `--output`, `--layouts`), build-mode toggles (`-drafts`, `-watch`, `-minify`), log level (`-verbose` → Debug, `-quiet` → Warning), and `-ext`/`-no-ext` for markdown extension overrides.
|
||||
- **`Config_File`** — parsed from `thor.json` via `json.unmarshal_string`. Holds title, paths, `markdown_extensions` (JSON), `params` (JSON), `modules` (JSON array of relative paths), `og` (`Open_Graph` struct for site-level OG defaults).
|
||||
- **`Site`** — runtime state: arena, pages, modules, VFS, `features: bit_set[Feature]`, `markdown_extensions: bit_set[md.Extension]`, `og: Open_Graph` (resolved site-level OG).
|
||||
|
||||
**`Feature` enum** — `Drafts`, `Minify`, `Watch`. Checked with `.Minify in site.features`.
|
||||
|
||||
**`markdown.Extension` enum** (in the `markdown` package, not main) — `Emoji`, `Sidenotes`, `Alerts`, `Highlight`, `Sections`, `HeadingIDs`, `DefLists`, `Footnotes`. Default is `md.DEFAULT_EXTENSIONS` (currently `.Emoji, .Sidenotes, .Alerts, .HeadingIDs, .DefLists`). Configurable via:
|
||||
- `thor.json`: `"markdown_extensions": { "emoji": true, "highlight": false, ... }`
|
||||
- CLI: `-ext:highlight,sections` (enable) / `-no-ext:emoji` (disable). Comma-separated, case-insensitive.
|
||||
|
||||
**`find_config`** — walks up from CWD looking for `thor.json`. Falls back to `./thor.json`.
|
||||
|
||||
Config precedence: `CLI flags > thor.json values > hardcoded defaults`.
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "...",
|
||||
"base_url": "...",
|
||||
"modules": ["../path/to/module"],
|
||||
"og": {
|
||||
"image": "https://example.com/og.png"
|
||||
},
|
||||
"date": {
|
||||
"format": "2 Jan 2006",
|
||||
"timezone": "America/New_York"
|
||||
},
|
||||
"grammars": "~/.config/helix/runtime/grammars/",
|
||||
"queries": "/path/to/tree-sitter/queries",
|
||||
"markdown_extensions": { "emoji": true, "highlight": false },
|
||||
"menus": {
|
||||
"main": [
|
||||
{"name": "Home", "url": "/", "weight": 1},
|
||||
{"name": "About", "url": "/about/"}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"social": [
|
||||
{ "name": "github", "url": "...", "icon": "icons/github" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## VFS (Union File System)
|
||||
|
||||
Layered directory resolution for templates and assets: `site layouts/ → module layouts/ → defaults/layouts/`.
|
||||
|
||||
```odin
|
||||
VFS :: struct { files: map[string]VFS_Entry }
|
||||
VFS_Entry :: struct { fs_path: string, data: []byte }
|
||||
```
|
||||
|
||||
`build_vfs` mounts in reverse precedence (defaults first, site last overwrites). `DEFAULTS_PATH` resolved at compile time via `#directory`, so bundled templates ship inside the binary. Modules configured via `"modules": ["../path"]` in `thor.json` — each module contributes `layouts/` and `assets/` subdirectories.
|
||||
|
||||
Three access patterns:
|
||||
- `vfs_get(vfs, path) -> ([]byte, bool)` — data only (lazy-loaded from disk)
|
||||
- `vfs_get_entry(vfs, path) -> (VFS_Entry, []byte, bool)` — entry + data (for callers that need `fs_path` for diagnostics)
|
||||
- `vfs_entry_data(entry) -> ([]byte, bool)` — data from an entry already in hand (avoids redundant map lookup when iterating `vfs.files`)
|
||||
|
||||
Content is **not yet in the VFS** — `scan_content_files` still uses direct filesystem reads. (See `TODOS.md`.)
|
||||
|
||||
## Open Graph
|
||||
|
||||
`Open_Graph` struct in `opengraph.odin` with fields ordered per [ogp.me](https://ogp.me/) spec. `is_article` is `Maybe(bool)` — nil means "unset" (distinguished from explicitly `false`).
|
||||
|
||||
**Site-level** (`og_for_site`): starts from `Config_File.og` (user-supplied defaults from `thor.json`), then fills empty fields derivable from `Site`:
|
||||
- `site_name ← site.title`
|
||||
- `locale ← "en_US"` (default if unset)
|
||||
|
||||
**Page-level** (`og_for_page`): copies site OG, derives page-specific fields, then overlays `Page.og` (from frontmatter):
|
||||
- `url ← page.url`
|
||||
- `title ← page.title` (falls back to `site_name` if empty)
|
||||
- `type ← "article" if !page._is_index else "website"`
|
||||
- `is_article ← !page._is_index`
|
||||
- `section ← page.section`
|
||||
- `published_time / modified_time ← page.date / page.lastmod`
|
||||
- `description ← page.description`, else `generate_description(generate_summary(body_html))` (scrubbed plain text)
|
||||
|
||||
Paths through maps (e.g. `params.*`) are silently allowed — not validated. Templates access via `{{og.url}}`, `{{og.title}}`, `{{#og.is_article}}`, etc.
|
||||
|
||||
## Markdown pipeline
|
||||
|
||||
Lives in the `markdown` package. Entry point: `md.process(body, ext, file_path)`. All `.html` content files skip the pipeline entirely — body is used as-is.
|
||||
|
||||
```
|
||||
raw markdown
|
||||
→ 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, 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)
|
||||
→ md.wrap_sections (if .Sections — post-cmark)
|
||||
```
|
||||
|
||||
Each step is gated by `bit_set[md.Extension]`.
|
||||
|
||||
## Template system
|
||||
|
||||
Templates use Mustache with template inheritance (`{{<base}}` / `{{$block}}`):
|
||||
|
||||
```html
|
||||
<!-- base.html -->
|
||||
<body>{{> nav}}{{$main}}{{/main}}{{> footer}}</body>
|
||||
|
||||
<!-- page.html (content layout) -->
|
||||
{{<base}}
|
||||
{{$main}}
|
||||
<main><article><h1>{{page.title}}</h1>{{&content}}</article></main>
|
||||
{{/main}}
|
||||
{{/base}}
|
||||
```
|
||||
|
||||
Data is passed as a single `Template_Context` struct. `render_template` passes a 3-frame context stack `[]any{ctx.site, ctx.page, ctx}` to `mustache.render`, which auto-detects `[]any` and expands each element into a stack frame. Name resolution walks top-to-bottom: `Template_Context` → `Page` → `Site_Context`. Fields not found on the top frame fall through to lower frames.
|
||||
|
||||
```odin
|
||||
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
|
||||
date_format: string, // from site.date.format (thor.json)
|
||||
timezone: ^datetime.TZ_Region, // for format pipe
|
||||
og: Open_Graph, // computed per-page OG
|
||||
params: json.Value, // merged site + page params (resolves above Page.params)
|
||||
page: Page, // current page
|
||||
pages: [dynamic]Page, // home page list
|
||||
posts: [dynamic]Page, // section post list
|
||||
}
|
||||
```
|
||||
|
||||
`Site_Context` is embedded in `Site` via `using site_context`. Fields like `site.title`, `site.menus`, `site.params` are accessed directly on `Site` through promotion. `Template_Context.menus` is copied from `site.menus` to resolve above `Page.menus` (frontmatter assignments) on the context stack. `Template_Context.params` is set per-page via `merge_params(site.params, page.params)` — site params overlaid with page params. Browser title is handled by the `{{> title}}` partial (not a computed field).
|
||||
|
||||
`render_site` pre-parses all partials and the base layout once (via `mustache.parse`), then per-layout templates are cached in `get_template`. Year-based grouping on section index pages is done in the template via `{{#posts | group_by year}}` (see Pipes extension below).
|
||||
|
||||
### Pipes extension
|
||||
|
||||
Section tags and interpolation tags may transform the resolved value before rendering:
|
||||
|
||||
```handlebars
|
||||
{{#posts | group_by year}}
|
||||
{{key}}: {{#items}}{{title}}, {{/items}}
|
||||
{{/posts}}
|
||||
|
||||
<!-- Interpolation pipe: format a date for display -->
|
||||
<time datetime="{{date}}">{{date | format}}</time>
|
||||
```
|
||||
|
||||
Currently implemented: `group_by <field>` (list → list-of-groups) and `format` (ISO date string → display string like "15 Mar 2026"). The `format` pipe resolves `date_format` (string) and `timezone` (`^datetime.TZ_Region`) from the data context. When `timezone` is non-nil, dates are DST-aware converted before formatting. The `MST` token reflects the active timezone abbreviation (e.g. `"EST"`/`"EDT"`) or the source offset (e.g. `"UTC-04:00"`) when no target tz is configured. TZ data is loaded once by `init_site` via `timezone.region_load` using the site arena allocator, stored on `Site.tz`, and freed when the arena is destroyed. Filter results live in `context.temp_allocator` (render-scoped). See `mustache/EXTENSIONS.md` for syntax details, caps (`MAX_PIPES`, `MAX_PIPE_ARGS`), and the `Group` struct shape.
|
||||
|
||||
### Comments
|
||||
|
||||
`page.html` includes `{{> comments}}`. The `comments.html` partial self-guards with `{{#og.is_article}}` so it only renders on article pages — no separate `is_post` flag.
|
||||
|
||||
## Syntax highlighting
|
||||
|
||||
Build-time highlighting via Tree-sitter C FFI. No client-side JavaScript.
|
||||
|
||||
- **HTML and CSS grammars** statically linked via Nix (`mkGrammarStaticLib` in `thor/flake.nix`). Always available, no `dlopen`.
|
||||
- **Other grammars** (bash, odin, nu, etc.) loaded via `dlopen` from `.so` files. Pre-scanned from content code fences and loaded in parallel via `preload_grammars` (one thread per language, `sync.Mutex` on `Grammar_Store.cache`). `Grammar_Store.allocator` is the OS heap (set by `init_persistent` before arena override) so grammars persist across watch-mode rebuilds.
|
||||
- Grammar and query paths configured via `thor.json` (`grammars`, `queries`). Flow: `thor.json` → `Config_File` → `Site` → `main.odin` sets `treesitter.grammar_dir`/`treesitter.query_dir`. Tilde (`~/`) expanded by `expand_path` in `site.odin`. Paths logged at startup.
|
||||
- Grammar loading split: `ensure_parser` (parser only, used by minify) vs `load_grammar` (parser + query, used by highlight).
|
||||
- Capture names mapped to CSS classes: `keyword` → `.hl-keyword`, etc.
|
||||
- Atom-one-dark color theme in `main.css`.
|
||||
|
||||
## Minification
|
||||
|
||||
Optional, enabled with `-minify` flag (`.Minify` in `Feature` bit_set).
|
||||
|
||||
- **HTML** — tree-sitter parses output, strips comments, removes inter-tag whitespace, preserves `<pre>`/`<code>`/`<textarea>`/`<script>`/`<style>` content. Applied after template rendering.
|
||||
- **CSS** — tree-sitter parses `.css` files in `assets/`, strips comments, collapses whitespace, trims around `{};:,`. Applied during `copy_assets_dir`.
|
||||
- Non-CSS files in `assets/` copied verbatim.
|
||||
|
||||
## Memory management
|
||||
|
||||
- `Site` owns a `mem.Dynamic_Arena`
|
||||
- `init_site` calls `mem.dynamic_arena_init(&site.arena)` (Odin's default alignment suffices)
|
||||
- Config loading (flags + JSON) uses the arena allocator explicitly
|
||||
- `site_allocator(site)` returns the arena allocator for callers
|
||||
- `destroy_site` frees the arena
|
||||
- `main.odin` sets `context.logger = log.create_console_logger()` — without this, all `log.*` calls are silently dropped
|
||||
- `context.allocator` is set to `site_allocator(&site)` in the main loop
|
||||
- `context.temp_allocator` freed per watch-loop iteration via `defer free_all`
|
||||
|
||||
## Spall profiling
|
||||
|
||||
Optional, compiled out by default. Enabled with `-define:SPALL=true`:
|
||||
|
||||
```bash
|
||||
odin build . -define:SPALL=true -o:speed -out:thor-prof
|
||||
./thor-prof -drafts # generates thor.spall
|
||||
```
|
||||
|
||||
Uses `core:prof/spall` with `@(instrumentation_enter)`/`@(instrumentation_exit)` hooks — every function auto-instrumented, no manual annotation needed.
|
||||
|
||||
## Building
|
||||
|
||||
### Local development
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
# From blog root:
|
||||
odin run ./thor -- -drafts
|
||||
# Assets (CSS/JS/fonts) are copied/minified automatically by thor
|
||||
caddy run # serves public/ on blog.localhost
|
||||
```
|
||||
|
||||
No CSS build step — `main.css` is static Tufte-based CSS, no preprocessor or compiler needed.
|
||||
|
||||
### Production build
|
||||
|
||||
```bash
|
||||
nix build # runs thor, outputs to ./result/
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
cd thor
|
||||
odin test . # main package tests (site, frontmatter)
|
||||
odin test . -all-packages # includes mustache specs, lambdas, pipes, diagnostics, markdown tests
|
||||
```
|
||||
|
||||
### Benchmark
|
||||
|
||||
```bash
|
||||
cd thor
|
||||
odin build bench -o:speed
|
||||
./bench.bin # 250 iterations, prints timing
|
||||
./bench.bin --dump output.html # render once, write to file for diff validation
|
||||
./bench.bin 1000 # custom iteration count
|
||||
```
|
||||
|
||||
## Mustache engine
|
||||
|
||||
Spec-compliant implementation at `mustache/`. See `mustache/SPEC.md` for the implementation specification, `mustache/EXTENSIONS.md` for non-standard extensions (pipes), and `mustache/diagnostic.odin` for the rust-style error formatter.
|
||||
|
||||
### Files
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `mustache.odin` | Public API (`parse`, `render`, `Template`), parser (`parse_section`), renderer (`render_nodes` with `Indent_State` for partial indentation), template inheritance (`merge_block_overrides`), `delete_template`/`delete_partials`. Pipe support in Variable/Unescaped/Section/Inverted tags. |
|
||||
| `tokenizer.odin` | Tokenizer (template string → `[]Token`), standalone whitespace detection |
|
||||
| `data.odin` | Reflection-based data model: `base_value` (peels union/any/nested-any layers), `lookup_in` (structs + maps, handles `Type_Info_Any` value kind in maps), `resolve_name`, `is_truthy`, `any_to_string`, `write_value`, `list_info`, `extract_list_element`, `collect_map_keys` |
|
||||
| `pipes.odin` | Pipes extension: `Pipe_Op` enum (`.Format`, `.Group_By`), `pipe_op_from_string`/`pipe_op_candidates` (reflection-based enum name lookup), `Pipe_Filter` AST (with `op_pos` for diagnostics), `parse_pipeline` (tracks byte offsets via `strings.index`), `apply_pipeline`, `apply_filter` (exhaustive enum switch), `apply_group_by`, `apply_format`. Misspelled pipe op suggestions via `suggest_correction`. Stored on `Node.filters`; render-scoped results in temp allocator. |
|
||||
| `diagnostic.odin` | Rust-style error formatter: `format_error` (multi-line context, ANSI colors via `core:terminal/ansi`, `colorize` param), `format_render_error` (formats `Error`), `line_col`, `line_text`, `context_extent`, `count_lines`, `digit_count`, `should_colorize`. |
|
||||
| `suggest.odin` | Strict-warning helpers: `validate_key_path` (walks dotted path, crosses maps silently), `suggest_correction` (Levenshtein via `core:strings/levenshtein_distance`), `collect_struct_keys` (via reflection, recurses into `using`), `struct_has_field` (distinguishes missing field from nil value — needed for `Maybe(bool)`), `collect_partial_names`, `collect_block_names`. |
|
||||
| `spec_test.odin` | JSON spec test runner — loads `spec/specs/*.json`, runs each test case. Uses `log.nil_logger()` to suppress expected warnings. |
|
||||
| `pipes_test.odin` | Pipe filter tests (`group_by` + `format`) |
|
||||
| `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 |
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
parse(source, path) → tokenize → trim_standalone_whitespace → parse_section → Template
|
||||
render(tmpl, data, partials) → render_nodes (walks flat node array against context stack) → string
|
||||
```
|
||||
|
||||
- **Two-phase API**: `parse()` produces a reusable `Template`, `render()` walks it against data. Templates parsed once, rendered many times.
|
||||
- **Flat `[dynamic]Node` array** with `children: []Node` slices (pre-order layout; slices point into the backing array). Each `Node` carries `pos: int` (byte offset into source) for diagnostics.
|
||||
- **`Template`** carries `source` and `path` — used by diagnostics to show file location and source context.
|
||||
- **Context stack**: `^[dynamic]any` with `append`/`pop` for section push/pop.
|
||||
- **`render_nodes` takes `Template` by value** (not `^Template`) — Odin's calling convention promotes to pointer when efficient. Eliminates "local copy" patterns at call sites.
|
||||
- **`Block_Override.source: Template`** — carries the template that defined the override, so warnings inside block overrides point at the correct file.
|
||||
- **`base_value`** peels Named/Distinct/Union layers (including `json.Value`). Also unwraps nested `any`-of-`any` (which occurs when `map[string]any` values are read via runtime map internals).
|
||||
- **`lookup_in`** resolves keys on structs (via `reflect.struct_field_value_by_name` with `allow_using = true`) and maps. Detects `Type_Info_Any` value kind in maps and reads the inner any directly to avoid double-wrap.
|
||||
- **Template inheritance**: `{{<parent}}` loads parent from partials, `{{$block}}` defines overridable sections. `merge_block_overrides` propagates overrides through multi-level chains.
|
||||
- **Dynamic partial names**: `{{>*key}}` resolves partial name from data context at render time.
|
||||
- **Render-time partial indentation**: `Indent_State` threads `at_line_start` through `render_nodes` so partial indent is applied at render time (via `write_indented` on Text nodes) instead of reparsing the partial's source. `render_template` writes initial indent, creates state, calls `render_nodes`. Data-injected newlines don't pick up indent (Variables don't update `at_line_start`).
|
||||
|
||||
### Diagnostics
|
||||
|
||||
Rust-style error messages with multi-line source context, caret underlines, and Levenshtein suggestions. ANSI colors via `core:terminal/ansi`, gated on `should_colorize()` (TTY detection on stderr).
|
||||
|
||||
**Error types**: `Error_Body{msg, pos, kind, source, path, span, hint}` where `kind` is `Error_Kind.Syntax` (parse-time) or `Error_Kind.Data` (render-time). `source`/`path` carry the template the error originated in (set by `tag_error` — enables correct file/line for errors inside partials). `span` controls caret underline width (used by pipe op diagnostics). `hint` carries "did you mean?" suggestions. `Error` is a single-variant union wrapping `Error_Body` (nilable for `!= nil` / `or_return`).
|
||||
|
||||
**Error deduplication**: `render_template` takes `seen: ^map[string]bool`. Duplicate errors (same formatted diagnostic) are suppressed across page renders within a single build.
|
||||
|
||||
**Partial source tracking**: `tag_error(err, current)` stamps render-time errors with `current.source`/`current.path` at the 4 `apply_pipeline` call sites in `render_nodes`. Ensures errors inside partials point at the partial file, not the top-level template.
|
||||
|
||||
**Strict-by-default warnings** — `render_nodes` emits `log.warnf` diagnostics for:
|
||||
- Unknown keys in `{{k}}`, `{{{k}}}`, `{{#k}}`, `{{^k}}` (via `validate_key_path` + `suggest_correction`)
|
||||
- Missing partials (`{{> name}}` not in partials map)
|
||||
- Missing parent templates (`{{<name}}` not in partials map)
|
||||
- Unmatched block overrides (`{{$name}}` doesn't match any block in parent template)
|
||||
|
||||
**Exceptions** (no warning):
|
||||
- `{{.}}` and dot-prefixed names (current context)
|
||||
- Paths that cross a map (e.g., `params.*`) — validated for typos via Levenshtein: close matches warn with a suggestion, genuinely absent keys are suppressed silently
|
||||
- `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`).
|
||||
|
||||
### 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:
|
||||
|
||||
- `group_by <field>` — list → `[dynamic]Group` where `Group{key, items}`
|
||||
- `format` — ISO 8601 date string → display string (e.g., "15 Mar 2026")
|
||||
|
||||
See `mustache/EXTENSIONS.md`.
|
||||
|
||||
### Not implemented
|
||||
|
||||
- Set delimiters (`{{= =}}`, `delimiters.json`)
|
||||
- Partial invocation stack in diagnostics (warnings inside partials point at the partial file but don't show the `{{> name}}` invocation site — see TODOS.md)
|
||||
|
||||
## Known limitations
|
||||
|
||||
- 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
|
||||
|
||||
You may never, *ever* remove `TODO:` or `FIXME:` comments. Those are for humans, not machines.
|
||||
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
|
||||
|
||||
These are things that are easy to get wrong:
|
||||
|
||||
- **Proc arguments are immutable.** You cannot assign to a parameter directly. To get a mutable copy, shadow it: `x := x`. If you need to modify the source, pass a pointer `^x`.
|
||||
- **`for` each loops use `item, idx` order**, not `idx, item`. Correct: `for item, idx in arr`. Wrong: `for idx, item in arr`.
|
||||
- **`make([dynamic]T, n, allocator)` sets capacity, not length.** To get length=0 with capacity=n, use `make([dynamic]T, 0, n, allocator)`. Using `make([dynamic]T, n, allocator)` creates `len=n` with `n` zero-initialized elements.
|
||||
- `#partial switch` is usually a code smell. prefer a `case all, extra, types:` branch.
|
||||
- you don't usually need to create arena allocators in tests, instead use context.temp_allocator if you want to simplify cleanup.
|
||||
- you don't need to manually set up a tracking allocator in tests. the context.allocator will warn you about leaks.
|
||||
- **`Maybe(T)` unwrap syntax:** `value.? or_else default`. Not `value or_else default` — `or_else` works on the `?T` returned by `.?`, not on `Maybe(T)` directly.
|
||||
- **`Maybe(T)` equality:** `a == b` works directly between two `Maybe(T)` values (nil == nil → true, some(5) == some(5) → true, nil == some(5) → false). Also `a == 5` works (int coerces to `Maybe(int)`).
|
||||
- **File logger in tests:** `log.create_file_logger(&f)` + `context.logger = logger` captures log output. Must be set inline in the test proc (not via a helper proc) for context propagation. Clean up with `log.destroy_file_logger(logger)` then `os.read_entire_file_from_path` to verify output.
|
||||
- **`fmt.sbprintf` writes directly to a `strings.Builder`.** Prefer `fmt.sbprintf(&sb, fmt, args...)` over `fmt.aprintf(fmt, args...)` + `defer delete` + `strings.write_string`. The `aprintf` pattern allocates an intermediate string, requires manual cleanup, and queues a `defer delete` per loop iteration. `sbprintf` avoids all of this.
|
||||
|
||||
## TODO
|
||||
|
||||
See `TODOS.md` for the full list.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Diagnostics
|
||||
|
||||
Thor has two diagnostic tiers. This document explains why, and when to use each.
|
||||
|
||||
## Tier 1: Rust-style rich diagnostics (mustache engine)
|
||||
|
||||
`mustache/diagnostic.odin` implements multi-line source context, caret underlines, ANSI colors (gated on TTY detection), and Levenshtein suggestions. Used exclusively by the mustache engine for template errors:
|
||||
|
||||
- Unknown keys in `{{k}}`, `{{{k}}}`, `{{#k}}`, `{{^k}}`
|
||||
- Missing partials (`{{> name}}`)
|
||||
- Missing parent templates (`{{<name}}`)
|
||||
- Unmatched block overrides (`{{$name}}`)
|
||||
- Parse-time syntax errors
|
||||
|
||||
These benefit from rich diagnostics because **exact source location matters** — templates have complex syntax, and the user often doesn't know *where* the problem is. The diagnostic system operates on `Template.source` with byte offsets, producing output like:
|
||||
|
||||
```
|
||||
error: unknown key 'titel' in {{page.titel}}
|
||||
--> layouts/page.html:12:22
|
||||
|
|
||||
12 | <h1>{{page.titel}}</h1>
|
||||
| ^^^^^
|
||||
|
|
||||
= hint: did you mean 'title'?
|
||||
```
|
||||
|
||||
## Tier 2: Simple log warnings (content and runtime)
|
||||
|
||||
Everything outside the mustache engine uses `log.warnf` — flat one-line messages via `core:log`:
|
||||
|
||||
- Missing frontmatter dates (fallback to file mtime)
|
||||
- Duplicate menu weights
|
||||
- Non-numeric weight values in frontmatter
|
||||
- Tree-sitter highlight errors
|
||||
- Menu system issues (mixing config/frontmatter menus, etc.)
|
||||
|
||||
These are simple, actionable, and cross-file. The problem isn't *location* — it's that two files disagree, or a value is missing. A caret pointing at one file doesn't help; the message already communicates what to fix:
|
||||
|
||||
```
|
||||
[WARN] --- [menus.odin:290:warn_duplicate_weights()] menus('main'):'Ideas' and 'Stuff' share the same weight (11).
|
||||
```
|
||||
|
||||
## Why not use rich diagnostics everywhere?
|
||||
|
||||
Rust's diagnostic model is built for a single compilation unit with full AST/IR data. Three obstacles prevent reusing it for content warnings:
|
||||
|
||||
1. **Source tracking**: The mustache diagnostic system operates on `Template.source` (byte offsets into template strings). Content warnings come from frontmatter in markdown files — different source, different parser, no position tracking. Reusing the system would require building a parallel position-tracking infrastructure for frontmatter.
|
||||
|
||||
2. **Cross-file context**: Rust diagnostics point at one location. Weight duplicates are a relationship between two files. Rich diagnostics would need to show *both* file locations, which is more infrastructure for marginal value.
|
||||
|
||||
3. **Diminishing returns**: Rust diagnostics shine for syntax/type errors where the user doesn't understand the failure. Content warnings are already self-explanatory — "these two pages share weight 11" doesn't need a caret to be actionable.
|
||||
|
||||
## When to upgrade a Tier 2 warning to Tier 1
|
||||
|
||||
If a warning's usefulness would significantly improve from showing exact source location (e.g., a frontmatter syntax error where the user needs to see *which line* is malformed), consider extending the diagnostic system to frontmatter. This would require:
|
||||
|
||||
1. Position tracking in `frontmatter.odin` (store byte offsets for each parsed field)
|
||||
2. A `format_frontmatter_error` proc modeled on `format_render_error`
|
||||
3. File path propagation through the page loading pipeline
|
||||
|
||||
This is not currently planned — see `TODOS.md`.
|
||||
@@ -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
|
||||
@@ -0,0 +1,54 @@
|
||||
# Hugo vs Thor: Why Thor Doesn't Need Shortcode Context Isolation
|
||||
|
||||
## Hugo's Shortcode Isolation
|
||||
|
||||
Hugo strictly separates shortcode context from layout template context. Shortcodes run during markdown rendering, before layouts. They get a limited context (`.Page`, `.Site`, `.Params`), not the full layout rendering state.
|
||||
|
||||
### Why Hugo does this
|
||||
|
||||
1. **Circular dependencies** — Hugo shortcodes output into `.Content`. Layouts read `.Content` and compute derived properties (`.TableOfContents`, `.WordCount`, `.ReadingTime`). If shortcodes could access these computed properties, you'd get circular dependencies (shortcode output → computed property → shortcode reads it).
|
||||
|
||||
2. **Multiple layouts/themes** — Hugo supports user-selectable themes and multiple layouts per section type. A shortcode must produce identical output regardless of which layout renders it. Isolating context guarantees portability.
|
||||
|
||||
3. **Shared mutable page object** — Hugo builds a rich `Page` object that both shortcodes and layouts access. Context isolation prevents shortcodes from mutating layout-visible state.
|
||||
|
||||
4. **Security** — Shortcodes live in content files (potentially user-authored). Full template access would let content authors inject arbitrary logic, access sensitive config, or break site structure.
|
||||
|
||||
5. **Caching** — Hugo caches shortcode output independently. Predictable context = predictable cache behavior.
|
||||
|
||||
## Why this doesn't apply to thor
|
||||
|
||||
- **No computed page properties** — The content Mustache pass runs on raw markdown (before cmark). The layout Mustache pass runs on finished HTML (after cmark). There's no shared `.Content` object or derived properties. The two passes are at different pipeline stages with no shared mutable state.
|
||||
|
||||
- **Single layout set** — Thor has one set of templates (`base.html` + page templates). No theme switching, no multiple layout variants per section. Portability across layouts isn't a concern.
|
||||
|
||||
- **Explicit data passing** — Each `mustache.render` call receives its own data struct. Content pass gets frontmatter + params. Layout pass gets page data + site config. No shared mutable object between them.
|
||||
|
||||
- **Single author** — Content is authored by the site owner. No untrusted user-generated content.
|
||||
|
||||
- **No caching** — Thor rebuilds from scratch every time. No cache invalidation concerns.
|
||||
|
||||
## Thor's approach
|
||||
|
||||
Thor runs Mustache on content **before** cmark as a pre-processing step, then runs Mustache on layout templates **after** cmark as a post-processing step. These are two independent `mustache.render` calls:
|
||||
|
||||
```
|
||||
content (markdown)
|
||||
→ mustache.render(content, content_data, partials) ← content pass
|
||||
→ cmark markdown_to_html
|
||||
→ inject_sidenotes / inject_alerts / highlight_code
|
||||
→ mustache.render_in_layout(template, page_data, layout, partials) ← layout pass
|
||||
→ final HTML
|
||||
```
|
||||
|
||||
The content pass can use Mustache variables, partials (`{{> ./file}}`), sections, and conditionals freely. The layout pass wraps the rendered content in the page chrome. They share nothing except the data we explicitly choose to pass.
|
||||
|
||||
## When this WOULD matter for thor
|
||||
|
||||
If thor ever adds:
|
||||
- Computed page properties (table of contents, reading time, word count)
|
||||
- Multiple selectable themes/layouts
|
||||
- User-generated content / multi-author support
|
||||
- Partial template caching
|
||||
|
||||
...then context isolation between the content and layout passes would become relevant. Until then, it's unnecessary complexity.
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
# Thor — UX Problems Catalog
|
||||
|
||||
Adversarial review of error messages, behavioral inconsistencies, and user
|
||||
frustration points. Established as a baseline on commit `314cab2`.
|
||||
|
||||
Each entry cites the source location so it can be tracked to a fix.
|
||||
|
||||
---
|
||||
|
||||
## Severity legend
|
||||
|
||||
- **Critical** — user mistake produces silent wrong output or an unhelpful
|
||||
fatal error with no path forward.
|
||||
- **High** — error or warning is emitted but missing "where" or "how to fix."
|
||||
- **Medium** — inconsistency or gotcha that causes confusion or rework.
|
||||
- **Low** — polish / minor frustration.
|
||||
|
||||
---
|
||||
|
||||
## A. Silent wrong output (no error, wrong result)
|
||||
|
||||
These are the most dangerous — the user gets *no signal* that something is wrong.
|
||||
|
||||
### A1. Non-JSON frontmatter silently treated as body content — Critical
|
||||
`frontmatter.odin:26`
|
||||
|
||||
Thor expects JSON frontmatter delimited by bare `{` / `}` lines. A user
|
||||
coming from Hugo/Jekyll writes YAML (`---`) or TOML (`+++`) frontmatter. It is
|
||||
silently swallowed into the markdown body. No title, no date, no draft flag —
|
||||
and no error. Likely the #1 onboarding trap.
|
||||
|
||||
### A2. Unknown `thor.json` keys silently ignored — Critical
|
||||
`site.odin:162`
|
||||
|
||||
`json.unmarshal_string` skips unknown fields. A typo like `"tittle"` instead
|
||||
of `"title"` produces a silently-empty title. No warning. (`TODOS.md` already
|
||||
wants a JSON schema.)
|
||||
|
||||
### A3. Draft pages silently excluded — High
|
||||
`content.odin:64`
|
||||
|
||||
When `-drafts` isn't passed, draft pages vanish with no log. User adds a
|
||||
page, forgets the flag, page doesn't appear — zero feedback.
|
||||
|
||||
### A4. Naive singularization for layout inference — High
|
||||
`content.odin:202`
|
||||
|
||||
`posts` → `post` (correct), but `series` → `serie`, `news` → `new`. The
|
||||
layout silently falls through the fallback chain to `page`/`base`. No
|
||||
"layout 'serie' not found for section 'series'" message — only a debug log
|
||||
that's off by default.
|
||||
|
||||
### A5. `base_url` defaults to `localhost:8080` — Critical
|
||||
`site.odin:100`
|
||||
|
||||
Forgetting to set it means every canonical URL, OG tag, and RSS link points
|
||||
to localhost. No warning. Devastating in production builds.
|
||||
|
||||
### A6. Missing `content/` produces empty build — High
|
||||
`content.odin:82`
|
||||
|
||||
`scan_content_files` logs a `warnf`, the build proceeds with zero pages, then
|
||||
`log.infof("Rendered 0 pages")`. No fatal error, no "did you create
|
||||
content/?" guidance.
|
||||
|
||||
### A7. RSS emits sentinel epoch date silently — Medium
|
||||
`feed.odin:33`
|
||||
|
||||
Pages without a date get `"Mon, 01 Jan 0001 00:00:00 +0000"` in `<pubDate>`.
|
||||
No warning that a page is dateless in the feed.
|
||||
|
||||
### A8. `format_rfc822` returns raw ISO on parse failure — Medium
|
||||
`feed.odin:122-125`
|
||||
|
||||
`// TODO: should indicate error somehow` — short/malformed dates get embedded
|
||||
verbatim in `<pubDate>`, producing invalid RSS with no warning.
|
||||
|
||||
---
|
||||
|
||||
## B. Error messages missing "where" or "how to fix"
|
||||
|
||||
### B1. `render_template` blanks the entire page on error — Critical
|
||||
`render.odin:119-133`
|
||||
|
||||
A single bad tag/pipe anywhere produces `log.errorf` + `return ""`. The
|
||||
output file is silently written empty. In a `nix build` (no visible
|
||||
terminal), the user sees a blank page with zero clue why. Already noted in
|
||||
`TODOS.md`.
|
||||
|
||||
### B2. Malformed `thor.json` degrades to defaults — Critical
|
||||
`site.odin:162-168`
|
||||
|
||||
A JSON syntax error is a `warnf`, then `site_apply_path_defaults` kicks in.
|
||||
The site builds with wrong paths and produces a confusing empty result — the
|
||||
cause is two hops removed from the symptom.
|
||||
|
||||
### B3. Frontmatter parse error has no file location — Critical
|
||||
`frontmatter.odin:41`
|
||||
|
||||
`"failed to parse frontmatter JSON: %v"` — no filename. On a 100-post site
|
||||
the user can't find the bad file. Worse: `ok=false` silently drops the page
|
||||
entirely.
|
||||
|
||||
### B4. `get_template` returns empty `Template{}` on missing base — High
|
||||
`render.odin:88-89`
|
||||
|
||||
`"base.html not found in VFS"` — no guidance on how to fix (create the file,
|
||||
check modules, etc.).
|
||||
|
||||
### B5. `dlopen` failures lack the OS reason and fix guidance — High
|
||||
`treesitter/treesitter.odin:200-219`
|
||||
|
||||
"cannot load grammar %s (%s)" shows the path but not *why* (no `dlerror()`).
|
||||
No guidance: "set the 'grammars' key in thor.json" or "this .so may be for a
|
||||
different tree-sitter ABI."
|
||||
|
||||
### B6. Menu-mix fatal lacks location — High
|
||||
`menus.odin:42`
|
||||
|
||||
`"cannot mix config menus with frontmatter menus"` — doesn't name which pages
|
||||
have frontmatter menus.
|
||||
|
||||
### B7. Minify error doesn't name the page — Medium
|
||||
`minify.odin:33`
|
||||
|
||||
"minify: HTML parse errors, skipping minification" — across 50 pages, which
|
||||
one?
|
||||
|
||||
### B8. Timezone load failure is a warning with no guidance — Medium
|
||||
`site.odin:148`
|
||||
|
||||
Doesn't state impact (dates render in UTC) or suggest valid names. Already
|
||||
in `TODOS.md`.
|
||||
|
||||
### B9. No "config not found" message — Medium
|
||||
`site.odin:113`
|
||||
|
||||
Silently falls back to `./thor.json`. Wrong-directory runs produce a
|
||||
confusing default build.
|
||||
|
||||
---
|
||||
|
||||
## C. Silent skip of invalid user input
|
||||
|
||||
### C1. Unknown markdown extensions silently ignored (CLI) — High
|
||||
`markdown/markdown.odin:49-68`
|
||||
|
||||
`parse_extension_list` has a switch with no default case. `-ext:higlight`
|
||||
(typo for `highlight`) is silently a no-op.
|
||||
|
||||
### C2. Unknown markdown extensions silently ignored (config) — High
|
||||
`markdown/markdown.odin:71-90`
|
||||
|
||||
`apply_extension_config` has a `// TODO: Silently discards invalid values.`
|
||||
Unknown keys in `thor.json`'s `markdown_extensions` are silently dropped.
|
||||
Non-boolean values are `or_continue`d.
|
||||
|
||||
---
|
||||
|
||||
## D. Naming inconsistencies
|
||||
|
||||
### D1. Markdown extensions have 3+ names — Medium
|
||||
|
||||
| Context | Name |
|
||||
|---|---|
|
||||
| `thor.json` key | `markdown_extensions` |
|
||||
| CLI flag | `-ext` / `-no-ext` |
|
||||
| Struct fields | `md_enable` / `md_disable` |
|
||||
| JSON/CLI values | `emoji`, `sidenotes` (lowercase) |
|
||||
| Enum members | `.Emoji`, `.Sidenotes` (PascalCase) |
|
||||
|
||||
### D2. `-ext` usage string omits `heading_ids` — Medium
|
||||
`site.odin:91`
|
||||
|
||||
The help text lists `emoji,sidenotes,alerts,highlight,sections` but the enum
|
||||
also has `HeadingIDs`. Users can't discover it from `--help`.
|
||||
|
||||
### D3. Starred field has three names — Low
|
||||
- `Page.starred` (`content.odin:28`)
|
||||
- `Frontmatter.isStarred` (`frontmatter.odin:17`) — so the JSON key is `isStarred`
|
||||
- `AGENTS.md:101` says `is_starred` (stale)
|
||||
|
||||
### D4. Inconsistent error severity for similar failures — Medium
|
||||
- Template **parse** error → `log.errorf` + `os.exit(1)` (fatal) — `render.odin:37-49`
|
||||
- Template **render** error → `log.errorf` + return `""` (non-fatal, blank page) — `render.odin:126-131`
|
||||
- Config parse error → `warnf` + fallback to defaults — `site.odin:163-165`
|
||||
|
||||
Same category of failure (user wrote something wrong) with wildly different
|
||||
consequences.
|
||||
|
||||
### D5. Dead `os.exit(1)` after `log.fatalf` — Low
|
||||
`render.odin:33`, `menus.odin:43`
|
||||
|
||||
`fatalf` already exits; the following line is dead code.
|
||||
|
||||
---
|
||||
|
||||
## E. Configuration gotchas
|
||||
|
||||
### E1. `"menus": {}` is a stealth opt-out — Medium
|
||||
`menus.odin:31-38`
|
||||
|
||||
An empty object silently disables *all* auto-menus. A user who adds the key
|
||||
intending to configure later quietly loses their nav. The semantics (absent
|
||||
≠ empty) are undocumented outside code comments.
|
||||
|
||||
### E2. Config precedence is invisible — Medium
|
||||
`site.odin`
|
||||
|
||||
CLI > JSON > defaults, but there's no "resolved config" log. Debugging "why
|
||||
is my base_url wrong?" requires reading source.
|
||||
|
||||
### E3. `format` pipe logs ERROR but still renders — Medium
|
||||
`pipes.odin:261-265`
|
||||
|
||||
Missing `date.format` produces `log.errorf` but falls back to
|
||||
`DEFAULT_DATE_FORMAT`. The severity says "error" but the behavior says
|
||||
"warning."
|
||||
|
||||
---
|
||||
|
||||
## F. File/directory behavior surprises
|
||||
|
||||
### F1. Root dirs = sections, nested dirs = leaf bundles — Medium
|
||||
`content.odin:108-127`
|
||||
|
||||
This meaningful semantic distinction is entirely implicit.
|
||||
`content/about/team.md` is a leaf bundle (page "about" with body from
|
||||
team.md), not a section "about" with page "team". No error or guidance when
|
||||
the user's mental model differs.
|
||||
|
||||
### F2. Missing `layouts/` silently uses defaults — High
|
||||
`vfs.odin:38-40`
|
||||
|
||||
`mount_dir` returns silently if the directory doesn't exist. Wrong path →
|
||||
all user templates missing → defaults used. No "layouts directory X not
|
||||
found" message.
|
||||
|
||||
### F3. Missing section index silently synthesized — Medium
|
||||
`render.odin:316-326`
|
||||
|
||||
A section with pages but no `index.md` gets a synthetic `Page` with only a
|
||||
title. No warning. User expecting an error gets a mostly-blank page.
|
||||
|
||||
### F4. Windows line endings silently break frontmatter — Medium
|
||||
`frontmatter.odin:26`
|
||||
|
||||
`has_prefix(content, "{\n")` fails on `\r\n`; the JSON is treated as body.
|
||||
Zero feedback.
|
||||
|
||||
---
|
||||
|
||||
## G. Template authoring frustrations
|
||||
|
||||
### G1. Template fallback chain is silent at Info level — Medium
|
||||
`render.odin:84`
|
||||
|
||||
Only `log.debugf`, which is off by default (`main.odin:48` sets `.Info`).
|
||||
User's custom layout silently ignored, defaults used.
|
||||
|
||||
### G2. Render error blanks entire page — Critical
|
||||
`render.odin:126-131`
|
||||
|
||||
(Same as B1 — restated here for the template-authoring perspective.) One bad
|
||||
tag → whole page `""`. The most impactful silent failure in the system.
|
||||
|
||||
### G3. Pipe errors lack "did you mean?" suggestions — High
|
||||
`mustache/pipes.odin:241`
|
||||
|
||||
Unknown key errors (`{{tittle}}`), missing partials, and unmatched block
|
||||
overrides all get Levenshtein "did you mean?" hints via `suggest_correction`.
|
||||
But unknown pipe operations (`{{date | formats}}`) get only `"unknown pipe op
|
||||
'formats'"` with no suggestion. The known filter names (`"format"`,
|
||||
`"group_by"`) are a small fixed set — perfect for suggestions.
|
||||
|
||||
Structural gap: `Error_Body` has no `hint` field, and
|
||||
`format_render_error` doesn't pass `hint` to `format_error` (defaults to
|
||||
`""`). So even if a suggestion were computed, there's nowhere to put it
|
||||
without either appending to `msg` or adding `hint` to `Error_Body`.
|
||||
|
||||
### G4. Triple-mustache `{{{` mishandled by `tag_content_base` — Medium
|
||||
`mustache/mustache.odin:230`
|
||||
|
||||
`tag_content_base` skips `{{` and sigils (`#^/&><$!`) to find where tag
|
||||
content begins. But triple-mustache `{{{key}}}` is common — after `{{`,
|
||||
the next char is `{`, which is not in the sigil list, so `base` points at
|
||||
`{` instead of the actual key content. Any pipe position calculation for
|
||||
`{{{key | format}}}` will be off by one byte.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting themes
|
||||
|
||||
1. **Debug-level logging masks important fallbacks.** Layout fallbacks,
|
||||
template misses, and grammar skips are all `debugf` — invisible at the
|
||||
default Info level. Users never learn their customizations were ignored.
|
||||
|
||||
2. **The system fails open, not closed.** Missing files, missing
|
||||
directories, missing config — all silently fall back to defaults rather
|
||||
than surfacing the problem. Friendly until it isn't.
|
||||
|
||||
3. **No "resolved state" visibility.** There's no way for a user to see what
|
||||
thor actually loaded: which layouts, which config values, which pages
|
||||
were skipped as drafts. The build is a black box.
|
||||
|
||||
---
|
||||
|
||||
## Gold-standard examples to emulate
|
||||
|
||||
These are the parts of the codebase that already do it right:
|
||||
|
||||
- **Mustache diagnostics** (`mustache/diagnostic.odin` + `suggest.odin`):
|
||||
rust-style multi-line context, caret underlines, Levenshtein "did you
|
||||
mean?" hints, file:line:col.
|
||||
- **Treesitter query version-mismatch** (`treesitter/treesitter.odin:299-315`):
|
||||
explains the likely cause, shows both grammar/query versions, flags
|
||||
mismatches explicitly.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Thor
|
||||
|
||||
Thor is a simple Static Site 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.
|
||||
- Union File System (Modules)
|
||||
|
||||
## What it doesn't do
|
||||
- Internationalization
|
||||
- Pagination (Yet)
|
||||
- Themes
|
||||
- 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
|
||||
- Github style alerts
|
||||
- [and more]
|
||||
@@ -1,16 +1,276 @@
|
||||
- [ ] add content-hash fingerprinting for tailwind cache busting.
|
||||
- [ ] create template language
|
||||
- Look at the source for 3 template languages that support mustache syntax,
|
||||
and see how they implement the tempaltes, then pick the best one. One of them
|
||||
should be Hugo / go.
|
||||
- [ ] Evaluate Tufte CSS — borrow sidenote CSS or replace TailwindCSS entirely
|
||||
- Option B: Steal Tufte's sidenote/margin-note CSS (adapt for dark theme), keep TailwindCSS
|
||||
- Option C: Full Tufte CSS — drop TailwindCSS, no build step, semantic HTML, customize for dark theme + Roboto
|
||||
- Our sidenote HTML pattern already matches Tufte's exactly
|
||||
## 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
|
||||
- [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)
|
||||
- [ ] *where* (in the stack trace)
|
||||
- [ ] *how* you can fix it (if applicable)
|
||||
- [ ] Create a Location struct that somewhat matches Odin's [Source_Code_Location](https://pkg.odin-lang.org/base/runtime/#Source_Code_Location)?
|
||||
- Note that odin's version doesn't contain the stack trace.
|
||||
- [ ] show "stack traces" in template error diagnostics
|
||||
- [ ] better diagnostics for syntax errors in treesitter.
|
||||
- [ ] Ensure diagnostics for MAX_CONTEXT_DEPTH are good.
|
||||
- [ ] improve matching weights message.
|
||||
- [ ] Test menu diagnostics
|
||||
- [ ] Honestly, Test **all** diagnostics
|
||||
- [ ] Need to be careful about diagnostics across module boundaries.
|
||||
- we don't necessarily want to warn users about theme designers mistakes. (though perhaps we do)
|
||||
- [ ] consider reporting duplicate weights outside of menus
|
||||
- [ ] Extend `tag_error` to all render-time errors, not just pipe errors.
|
||||
Currently only pipe errors (4 sites in `render_nodes`) get stamped with
|
||||
the correct template source/path. Other render errors still use the
|
||||
content template's source/path, which can point at the wrong file.
|
||||
- [ ] try to make file paths clickable links.
|
||||
- [ ] centralize diagnostics to one place.
|
||||
- [ ] consider logging the number of times an error occurred.
|
||||
- [ ] Load grammars dynamically
|
||||
- [x] starred must be a param.
|
||||
- [ ] Documentation
|
||||
- [ ] talk about the context stack (and its limit).
|
||||
- [ ] highlight the differences in the way menus are handled.
|
||||
- [ ] consider sites with date based urls.
|
||||
- [x] Don't show annoying log output in tests.
|
||||
- [ ] improve home link customization.
|
||||
- [ ] currently an accessibility issue.
|
||||
- [x] support JSON5 in in frontmatter
|
||||
- [ ] Create a json schema file for `thor.json`.
|
||||
- [x] cleanup `#partial switch`es.
|
||||
- [ ] improve json diagnostics.
|
||||
- i.e. "Missing quotes around string", etc.
|
||||
- [ ] don't use bullshit "sub-tokens", add filters and pipes as proper tokens.
|
||||
- [ ] Ideas is now in the wrong spot. Date is wrong, and it is showing date
|
||||
when it shouldn't be.
|
||||
|
||||
## Performance
|
||||
|
||||
- [ ] See if we can disable bounds checks in `write_indented` and elsewhere.
|
||||
- [ ] Instead of loading the site fresh each time in watch mode, create a
|
||||
`reload_site` proc, that just updates changed resources.
|
||||
- [ ] Only publish referenced assets.
|
||||
- [ ] Split `load_page` into frontmatter-parse + body-process phases so draft pages can skip the markdown pipeline entirely
|
||||
- [ ] Use spall to find ways to reduce run time.
|
||||
- [ ] too many `write_string` calls in `highlight_block`
|
||||
- [ ] return `src: cstring` from `load_query`.
|
||||
- [ ] Improve `unescape_html` with simd.
|
||||
- [ ] generate summary before syntax highlighting.
|
||||
- [ ] generate summary before markdown to html conversion.
|
||||
- [ ] mount_recursive is pretty significant
|
||||
- [ ] thread pool for grammar loading is unbounded.
|
||||
- [ ] load grammars async.
|
||||
- [ ] during `load_page`:
|
||||
- pass each code block to the treesitter queue
|
||||
- continue working on the page,
|
||||
- `await` the highlighted code.
|
||||
- [ ] can markdown extensions run in parallel?
|
||||
- [ ] enforce MAX_SLUG_LENGTH
|
||||
- [ ] enforce MAX_CONTEXT_DEPTH
|
||||
- [ ] ensure struct fields are ordered correctly
|
||||
|
||||
## Remove Privileged content
|
||||
|
||||
- [ ] `group_by` currently requires a computed `year` field on the page.
|
||||
- We should replace this with `{{ pages | group_by (date | "2006") }}` or similar
|
||||
|
||||
|
||||
## Memory Management
|
||||
|
||||
- [ ] Leaks in highlighter code.
|
||||
- [ ] Not sure whether to use temp allocator or site_allocator in opengraph.odin.
|
||||
- [ ] Not sure whether to use temp allocator or site_allocator in `site_load_content`.
|
||||
- [ ] Might not need to allocate in `strip_html_tags`
|
||||
- [ ] Fix `apply_filter`'s `format` case (`mustache/pipes.odin`) boxing `apply_format`'s
|
||||
`string` result into `any` via bare `return`, which materializes a hidden
|
||||
header temp in `apply_filter`'s own stack frame. Dangling once the frame
|
||||
returns; caused the `-o:speed` segfault in `write_value`. Fix: box explicitly
|
||||
with `any{new_clone(formatted, context.temp_allocator), typeid_of(string)}`.
|
||||
- [ ] Same pattern in `apply_group_by` (`mustache/pipes.odin`): `return groups, nil`
|
||||
boxes a freshly-built `[dynamic]Group` as bare `any` — same latent
|
||||
stack-temp UB, hasn't crashed yet but should get the same treatment.
|
||||
|
||||
## Markdown
|
||||
- [ ] Add overloads for every extension - accept ^strings.Builder.
|
||||
- [x] Add conventional (Hugo style) footnotes option.
|
||||
- [x] Add opt-in deflist support.
|
||||
- [x] Decide if lambdas actually provide any value.
|
||||
- [ ] add tables extension
|
||||
- [x] Table of contents support.
|
||||
- [ ] enable template level rendering of TOCs
|
||||
- [ ] Write css for toc sidebar and figure out where to put it.
|
||||
- [ ] Add [hugo style configuration](https://gohugo.io/configuration/markup/#table-of-contents)
|
||||
- [ ] Link checker?
|
||||
- Checks all links on each page to make sure they are valid.
|
||||
- [ ] Peruse [GitHub's](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts)
|
||||
docs for any juicy nuggets we may have missed.
|
||||
- [ ] Avoid using `render_inline_md` if possible.
|
||||
|
||||
## Dates
|
||||
- [ ] display an error when no part of the date appears in the output.
|
||||
- [ ] Handle 0 and whitespace padding i.e. "_2" -> " 2"
|
||||
- [ ] Do we *need* mustache.Date_Components, or can we use core:time/datetime.DateTime?
|
||||
|
||||
## General
|
||||
- [ ] Menus
|
||||
- [ ] configure opt-out of automatic sections being added to menu.
|
||||
- [ ] nested menus (i.e. `parent` support)
|
||||
- [ ] get rid of the global variables in the `treesitter` package.
|
||||
- [ ] enforce heading structure.
|
||||
- [ ] Either frontmatter.title set, or 1 h1 tag at top, not both
|
||||
- [ ] No skipping.
|
||||
- [ ] Consider using `or_else` when applying default values to structs. i.e.
|
||||
```odin
|
||||
package main
|
||||
|
||||
X :: struct {
|
||||
foo: string
|
||||
}
|
||||
|
||||
main :: proc () {
|
||||
x: X
|
||||
|
||||
x.foo = x.foo or_else "bar"
|
||||
}
|
||||
```
|
||||
- [ ] Integrity hash
|
||||
- Allows users to verify their output didn't change after upgrading to a new version
|
||||
- [ ] Content-hash fingerprinting for CSS and JS cache busting
|
||||
- [ ] come up with scrapers / scrape sources to harvest site data
|
||||
- we'll use this to help us sculpt defaults.
|
||||
- [ ] merge `render_{section,home_html,page_html}` procs.
|
||||
- [ ] try to combine render_page_html and render_home_html?
|
||||
- [ ] Debug log stats. (analytics)
|
||||
- [ ] final Context_Stack cap
|
||||
- [ ] highest PIPE args used
|
||||
- [ ] longest slug length + name that generated it
|
||||
- [ ] number of pages
|
||||
- [ ] number of blocks
|
||||
- [ ] enabled features / extensions
|
||||
- [ ] etc
|
||||
- [ ] Avoid `json.Value` / `json.Object` where possible.
|
||||
- [ ] make `parse` an overload of `parse_text/parse_inline` and `parse_file`, or something.
|
||||
- [x] Add page params
|
||||
- [ ] We must remove all mention of `posts` from the odin code.
|
||||
At present, "posts" are a user-level construct defined as pages in a
|
||||
particular collection.
|
||||
- [ ] running ./thor/thor still logs the debug message: using config /home/spencer/github.com/sbrow.github.io/thor.json
|
||||
- wrong cwd?
|
||||
- [ ] Clean up the default layouts
|
||||
- [ ] Menus
|
||||
- [x] Detailed frontmatter menu form ("menu": {"main": {"weight": 5}})
|
||||
- [ ] Menu active state (pre-compute is_active based on page.permalink prefix match)
|
||||
- [x] Page.weight field for general-purpose page ordering (menus, lists, related posts)
|
||||
- [ ] if no `html` tag detected in output, re-render output with base template
|
||||
(or whatever template is next in the chain)
|
||||
- [ ] Add `-production` flag
|
||||
- sets `-minify`
|
||||
- [ ] Mustache diagnostics
|
||||
- [ ] Partial invocation stack in diagnostics: when an error fires inside a partial, show "invoked from" chain through `{{> name}}` calls. Currently warnings inside partials point at the partial (correct file) but don't show the invocation site.
|
||||
- [x] Error message doesn't show position of faulty pipe name correctly.
|
||||
- [ ] `render_template` (`render.odin`) blanks the *entire page* to `""` on any
|
||||
mustache render error and only `log.errorf`s it — a single bad tag/pipe
|
||||
anywhere on the page silently kills the whole output with no visible
|
||||
signal outside the terminal log. Should at least be scoped to the
|
||||
failing tag/section, or surfaced somewhere the person building the
|
||||
site will actually see it.
|
||||
```bash
|
||||
[ERROR] --- [138:render_template()] unknown pipe op 'formats'
|
||||
--> /home/spencer/github.com/sbrow.github.io/layouts/home.html:1:1
|
||||
|
|
||||
1 | {{<base}}
|
||||
| ^^^^^^^^^
|
||||
2 | {{$content}}
|
||||
3 | <main>
|
||||
```
|
||||
- [ ] Block attributes on code fences (`{ #ex-1 }`) — hello-world.md
|
||||
- [x] Emoji shortcodes (`:shrug:` etc.) — 2 instances
|
||||
- [ ] Backslash in shrug not visible.
|
||||
- [ ] include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin
|
||||
- [x] Nix build integration — main flake runs thor + tailwindcss instead of Hugo
|
||||
- [ ] Copy-to-clipboard button on code blocks (was in Hugo's custom-head.html)
|
||||
- [ ] OpenGraph meta tags
|
||||
- [ ] follow symlinks in `scan_content`?
|
||||
- [ ] ensure sidenote numbers render in display order and not in declaration order.
|
||||
- [ ] Nav items should be active when the current page is selected.
|
||||
- [ ] Theme selector for syntax highlighting.
|
||||
- use http://github.com/helix-editor/helix/tree/master/runtime/themes) as a
|
||||
guide
|
||||
- [ ] grammars
|
||||
- [ ] Search in multiple places
|
||||
- [ ] Download missing grammars.
|
||||
- [ ] Durable highlight paths: read `GRAPHS_PATH`/`QUERIES_PATH` from env vars set by the flake instead of hardcoded nix store hashes, so they survive `nix flake update` and let the grammar/query version-mismatch detector fire automatically.
|
||||
- [ ] CI
|
||||
- [ ] Syntax highlighting in production: CI (`nix build` on ubuntu-latest) has no grammar `.so`s and a machine-specific `QUERIES_PATH` nix store hash, so the deployed site renders unhighlighted. Provide grammars + queries as nix build inputs and pass paths to thor at runtime (env vars/flags).
|
||||
- [ ] Unit tests for highlighting helpers: `capture_name_to_css`, `escape_html`, `unescape_html`, `extract_query_token`, `helix_version_from_path`.
|
||||
- [ ] `<pre><code>` blocks need to set background to theme background,
|
||||
regardless of prefers-dark. (or use a different theme)
|
||||
- [ ] `-watch` flag
|
||||
- [x] basic poll loop
|
||||
- [ ] filesystem poll loop
|
||||
- [ ] event based
|
||||
- [x] Free cmark HTML output (`body_html`) — cmark allocates via C malloc, not the arena, so it leaks per iteration in watch mode
|
||||
- [ ] manually pass `site_allocator` to `load_page`
|
||||
- [ ] Mount content in VFS
|
||||
- [ ] commands
|
||||
- [ ] `build` alias of default
|
||||
- [ ] `new site` set up new project
|
||||
- [ ] warn/error when unknown key used in mustache.
|
||||
- [ ] Import/export packages. Hugo, jekyll, WordPress, etc.
|
||||
- [ ] opt-in "strict_keys" mode. in this mode, key lookups may not view parent objects.
|
||||
|
||||
## Notes
|
||||
|
||||
from the [Hugo docs](https://gohugo.io/quick-reference/glossary/#default-sort-order)
|
||||
|
||||
default sort order
|
||||
: The default sort order for page collections, used when no other criteria are set, follows this priority:
|
||||
1. weight (ascending)
|
||||
2. date (descending)
|
||||
3. linkTitle falling back to title (ascending)
|
||||
4. logical path (ascending)
|
||||
|
||||
## Code Review
|
||||
|
||||
A human should manually review every file in the project. AI cannot complete
|
||||
these tasks.
|
||||
|
||||
- [ ] Review every file in thor
|
||||
- [ ] Review assets.odin
|
||||
- [ ] Review content.odin
|
||||
- [ ] Review defaults.odin
|
||||
- [ ] Review feed.odin
|
||||
- [ ] Review frontmatter.odin
|
||||
- [ ] Review main.odin
|
||||
- [ ] Review minify.odin
|
||||
- [ ] Review `markdown/`
|
||||
- [x] Review alerts.odin
|
||||
- [x] Review alerts_test.odin
|
||||
- [x] Review emoji.odin
|
||||
- [x] Review emoji_test.odin
|
||||
- [ ] Review footnotes.odin
|
||||
- [ ] Review footnotes_test.odin
|
||||
- [ ] Review highlight.odin
|
||||
- [ ] Review markdown.odin
|
||||
- [x] Review sectionate.odin
|
||||
- [x] Review sectionate_test.odin
|
||||
- [ ] Review suggest.odin
|
||||
- [ ] Review suggest_test.odin
|
||||
- [x] Review opengraph.odin
|
||||
- [ ] Review render.odin
|
||||
- [x] Review site.odin
|
||||
- [ ] Review treesitter/treesitter.odin
|
||||
- [ ] Review vfs.odin
|
||||
- [ ] Review procs
|
||||
- [ ] markdown.transform_alert
|
||||
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
#+feature dynamic-literals
|
||||
package main
|
||||
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
|
||||
ALERT_STYLES: map[string]string = {
|
||||
"note" = "border-l-blue-500 bg-blue-950/30 text-slate-300",
|
||||
"tip" = "border-l-green-500 bg-green-950/30 text-slate-300",
|
||||
"important" = "border-l-purple-500 bg-purple-950/30 text-slate-300",
|
||||
"warning" = "border-l-yellow-500 bg-yellow-950/30 text-slate-300",
|
||||
"caution" = "border-l-red-500 bg-red-950/30 text-slate-300",
|
||||
}
|
||||
|
||||
ALERT_EMOJIS: map[string]string = {
|
||||
"note" = "\xe2\x84\xb9\xef\xb8\x8f",
|
||||
"tip" = "\xf0\x9f\x92\xa1",
|
||||
"important" = "\xe2\x9d\x97",
|
||||
"warning" = "\xe2\x9a\xa0\xef\xb8\x8f",
|
||||
"caution" = "\xe2\x9d\x97",
|
||||
}
|
||||
|
||||
inject_alerts :: proc(html: string) -> string {
|
||||
parts: [dynamic]string
|
||||
defer delete(parts)
|
||||
|
||||
remaining := html
|
||||
|
||||
for {
|
||||
bq_start := strings.index(remaining, "<blockquote>")
|
||||
if bq_start < 0 {
|
||||
append(&parts, remaining)
|
||||
break
|
||||
}
|
||||
|
||||
bq_close := strings.index(remaining, "</blockquote>")
|
||||
if bq_close < 0 {
|
||||
append(&parts, remaining)
|
||||
break
|
||||
}
|
||||
|
||||
bq_end := bq_close + len("</blockquote>")
|
||||
bq := remaining[bq_start:bq_end]
|
||||
|
||||
// Append text before this blockquote
|
||||
append(&parts, remaining[:bq_start])
|
||||
|
||||
// Transform if it's an alert, otherwise keep as-is
|
||||
append(&parts, transform_alert(bq))
|
||||
|
||||
remaining = remaining[bq_end:]
|
||||
}
|
||||
|
||||
return strings.join(parts[:], "")
|
||||
}
|
||||
|
||||
transform_alert :: proc(bq: string) -> string {
|
||||
// Skip <blockquote> tag and whitespace
|
||||
pos := len("<blockquote>")
|
||||
for pos < len(bq) && (bq[pos] == '\n' || bq[pos] == '\r' || bq[pos] == ' ' || bq[pos] == '\t') {
|
||||
pos += 1
|
||||
}
|
||||
|
||||
// Check for <p>[!
|
||||
if pos + 4 >= len(bq) || bq[pos] != '<' || bq[pos + 1] != 'p' || bq[pos + 2] != '>' ||
|
||||
bq[pos + 3] != '[' || bq[pos + 4] != '!' {
|
||||
return bq
|
||||
}
|
||||
|
||||
// Extract alert type until ]
|
||||
close := strings.index(bq[pos + 5:], "]")
|
||||
if close < 0 {
|
||||
return bq
|
||||
}
|
||||
|
||||
type_raw := bq[pos + 5 : pos + 5 + close]
|
||||
type_lower := strings.to_lower(type_raw)
|
||||
|
||||
style, has_style := ALERT_STYLES[type_lower]
|
||||
emoji, has_emoji := ALERT_EMOJIS[type_lower]
|
||||
if !has_style || !has_emoji {
|
||||
return bq
|
||||
}
|
||||
|
||||
// Position after ]
|
||||
after_type := pos + 5 + close + 1
|
||||
|
||||
// Skip optional + or -
|
||||
content_start := after_type
|
||||
if content_start < len(bq) && (bq[content_start] == '+' || bq[content_start] == '-') {
|
||||
content_start += 1
|
||||
}
|
||||
// Skip space after marker
|
||||
if content_start < len(bq) && bq[content_start] == ' ' {
|
||||
content_start += 1
|
||||
}
|
||||
|
||||
// Rebuild: styled blockquote + bold title paragraph + rest
|
||||
rest := bq[content_start:]
|
||||
|
||||
return fmt.aprintf(
|
||||
`<blockquote class="alert %s rounded-r py-2">
|
||||
<p class="font-bold mb-1">%s %s`,
|
||||
style,
|
||||
emoji,
|
||||
rest,
|
||||
)
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
|
||||
copy_assets_dir :: proc(vfs: ^VFS, output_dir: string, features: bit_set[Feature]) {
|
||||
for virtual_path, entry in vfs.files {
|
||||
if !strings.has_prefix(virtual_path, "assets/") {
|
||||
continue
|
||||
}
|
||||
|
||||
rel := virtual_path[len("assets/"):]
|
||||
dest := fmt.tprintf("%s/%s", output_dir, rel)
|
||||
|
||||
if idx := strings.last_index(dest, "/"); idx >= 0 {
|
||||
if err := os.make_directory_all(dest[:idx]); err != nil && err != .Exist {
|
||||
log.warnf("cannot create %s: %v", dest[:idx], err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if .Minify in features && strings.has_suffix(rel, ".css") {
|
||||
data, ok := vfs_get(vfs, virtual_path)
|
||||
if ok {
|
||||
write_file(dest, minify_css(string(data)))
|
||||
}
|
||||
} else if entry.data != nil {
|
||||
if err := os.write_entire_file(dest, entry.data); err != nil {
|
||||
log.warnf("cannot write %s: %v", dest, err)
|
||||
}
|
||||
} else {
|
||||
if err := os.copy_file(dest, entry.fs_path); err != nil {
|
||||
log.warnf("cannot copy %s: %v", entry.fs_path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -0,0 +1,224 @@
|
||||
package bench
|
||||
|
||||
import "../mustache"
|
||||
import "core:fmt"
|
||||
import "core:mem"
|
||||
import "core:os"
|
||||
import "core:strconv"
|
||||
import "core:time"
|
||||
|
||||
Tag :: struct {
|
||||
name: string,
|
||||
slug: string,
|
||||
}
|
||||
|
||||
Nav_Item :: struct {
|
||||
url: string,
|
||||
label: string,
|
||||
}
|
||||
|
||||
Post :: struct {
|
||||
title: string,
|
||||
url: string,
|
||||
date: string,
|
||||
year: string,
|
||||
excerpt: string,
|
||||
author: string,
|
||||
tags: [dynamic]Tag,
|
||||
}
|
||||
|
||||
Comment :: struct {
|
||||
author: string,
|
||||
date: string,
|
||||
body: string,
|
||||
}
|
||||
|
||||
Page_Data :: struct {
|
||||
title: string,
|
||||
now: string,
|
||||
posts: [dynamic]Post,
|
||||
comments: [dynamic]Comment,
|
||||
nav_items: [dynamic]Nav_Item,
|
||||
}
|
||||
|
||||
TEMPLATE_DIR :: #directory
|
||||
|
||||
main :: proc() {
|
||||
iterations := 250
|
||||
dump_path := ""
|
||||
|
||||
i := 1
|
||||
for i < len(os.args) {
|
||||
if os.args[i] == "--dump" && i + 1 < len(os.args) {
|
||||
dump_path = os.args[i + 1]
|
||||
i += 2
|
||||
} else {
|
||||
n, ok := strconv.parse_int(os.args[i])
|
||||
if ok && n > 0 {
|
||||
iterations = n
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
data_arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&data_arena)
|
||||
defer mem.dynamic_arena_destroy(&data_arena)
|
||||
|
||||
temp_arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&temp_arena)
|
||||
defer mem.dynamic_arena_destroy(&temp_arena)
|
||||
|
||||
context.allocator = mem.dynamic_arena_allocator(&data_arena)
|
||||
context.temp_allocator = mem.dynamic_arena_allocator(&temp_arena)
|
||||
|
||||
base := parse_file("base.html")
|
||||
page := parse_file("page.html")
|
||||
defer mustache.delete_template(&base)
|
||||
defer mustache.delete_template(&page)
|
||||
|
||||
partials := make(map[string]mustache.Template)
|
||||
partials["base"] = base
|
||||
partials["post"] = parse_file("partials/post.html")
|
||||
partials["comment"] = parse_file("partials/comment.html")
|
||||
defer mustache.delete_partials(partials)
|
||||
|
||||
data := generate_data()
|
||||
|
||||
if dump_path != "" {
|
||||
result, err := mustache.render(page, data, partials, allocator = context.temp_allocator)
|
||||
if err != nil {
|
||||
b := mustache.body(err)
|
||||
fmt.eprintln("render error:", b.msg)
|
||||
os.exit(1)
|
||||
}
|
||||
werr := os.write_entire_file_from_string(dump_path, result)
|
||||
if werr != nil {
|
||||
fmt.eprintln("failed to write", dump_path, ":", werr)
|
||||
os.exit(1)
|
||||
}
|
||||
fmt.println("wrote", len(result), "bytes to", dump_path)
|
||||
return
|
||||
}
|
||||
|
||||
for _ in 0 ..< 3 {
|
||||
_, _ = mustache.render(page, data, partials, allocator = context.temp_allocator)
|
||||
mem.dynamic_arena_free_all(&temp_arena)
|
||||
}
|
||||
|
||||
start := time.now()
|
||||
for _ in 0 ..< iterations {
|
||||
_, _ = mustache.render(page, data, partials, allocator = context.temp_allocator)
|
||||
mem.dynamic_arena_free_all(&temp_arena)
|
||||
}
|
||||
elapsed := time.since(start)
|
||||
|
||||
seconds := time.duration_seconds(elapsed)
|
||||
per_render_ms := seconds * 1000 / f64(iterations)
|
||||
|
||||
fmt.printfln(
|
||||
"iterations=%d total=%.3fs per_render=%.3fms",
|
||||
iterations,
|
||||
seconds,
|
||||
per_render_ms,
|
||||
)
|
||||
}
|
||||
|
||||
parse_file :: proc(name: string) -> mustache.Template {
|
||||
path := fmt.aprintf("%s/templates/%s", TEMPLATE_DIR, name)
|
||||
data, err := os.read_entire_file_from_path(path, context.allocator)
|
||||
if err != nil {
|
||||
fmt.eprintln("failed to read", path, ":", err)
|
||||
os.exit(1)
|
||||
}
|
||||
source := string(data)
|
||||
tmpl, perr := mustache.parse(source, path)
|
||||
if perr != nil {
|
||||
b := mustache.body(perr)
|
||||
fmt.eprintln("parse error in", path, ":", b.msg)
|
||||
os.exit(1)
|
||||
}
|
||||
return tmpl
|
||||
}
|
||||
|
||||
generate_data :: proc() -> Page_Data {
|
||||
years := []string {
|
||||
"2025",
|
||||
"2024",
|
||||
"2023",
|
||||
"2022",
|
||||
"2021",
|
||||
"2020",
|
||||
"2019",
|
||||
"2018",
|
||||
"2017",
|
||||
"2016",
|
||||
}
|
||||
|
||||
posts := make([dynamic]Post, 0, 500)
|
||||
for year in years {
|
||||
for i in 0 ..< 50 {
|
||||
tags := make([dynamic]Tag, 0, 3)
|
||||
append(
|
||||
&tags,
|
||||
Tag{name = fmt.aprintf("%s-notes", year), slug = fmt.aprintf("%s-notes", year)},
|
||||
)
|
||||
append(&tags, Tag{name = "writing", slug = "writing"})
|
||||
append(&tags, Tag{name = "archive", slug = "archive"})
|
||||
|
||||
month := (i % 12) + 1
|
||||
day := (i % 28) + 1
|
||||
|
||||
author := ""
|
||||
if i % 3 != 0 {
|
||||
author = fmt.aprintf("Author %d", i % 5)
|
||||
}
|
||||
|
||||
append(
|
||||
&posts,
|
||||
Post {
|
||||
title = fmt.aprintf("Post %d from %s", i, year),
|
||||
url = fmt.aprintf("/%s/post-%d", year, i),
|
||||
date = fmt.aprintf("%s-%02d-%02dT10:00:00Z", year, month, day),
|
||||
year = year,
|
||||
excerpt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
||||
author = author,
|
||||
tags = tags,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
comments := make([dynamic]Comment, 0, 100)
|
||||
for i in 0 ..< 100 {
|
||||
year := years[i % len(years)]
|
||||
month := (i % 12) + 1
|
||||
day := (i % 28) + 1
|
||||
append(
|
||||
&comments,
|
||||
Comment {
|
||||
author = fmt.aprintf("Commenter %d", i),
|
||||
date = fmt.aprintf("%s-%02d-%02dT12:00:00Z", year, month, day),
|
||||
body = fmt.aprintf("Great post! This is comment number %d.", i),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
nav_items := make([dynamic]Nav_Item, 0, 8)
|
||||
append(&nav_items, Nav_Item{url = "/", label = "Home"})
|
||||
append(&nav_items, Nav_Item{url = "/archive", label = "Archive"})
|
||||
append(&nav_items, Nav_Item{url = "/about", label = "About"})
|
||||
append(&nav_items, Nav_Item{url = "/tags", label = "Tags"})
|
||||
append(&nav_items, Nav_Item{url = "/feed.xml", label = "RSS"})
|
||||
append(&nav_items, Nav_Item{url = "https://github.com/example", label = "GitHub"})
|
||||
append(&nav_items, Nav_Item{url = "https://twitter.com/example", label = "Twitter"})
|
||||
append(&nav_items, Nav_Item{url = "mailto:nobody@example.com", label = "Email"})
|
||||
|
||||
return Page_Data {
|
||||
title = "Post Archive",
|
||||
now = "2025-07-21T12:00:00Z",
|
||||
posts = posts,
|
||||
comments = comments,
|
||||
nav_items = nav_items,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import "base:runtime"
|
||||
import "common"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
import "core:time"
|
||||
import "inlined"
|
||||
import "original"
|
||||
|
||||
DATES :: #load(#directory + os.Path_Separator_String + "dates.txt")
|
||||
FORMATS :: #load(#directory + os.Path_Separator_String + "formats.txt")
|
||||
|
||||
ITERATIONS :: 1_000
|
||||
|
||||
dates: []common.Date_Components
|
||||
formats: []string
|
||||
|
||||
formatter :: #type proc(
|
||||
date: common.Date_Components,
|
||||
format: string,
|
||||
allocator: runtime.Allocator,
|
||||
) -> string
|
||||
|
||||
benchmark :: #type proc(
|
||||
options: ^time.Benchmark_Options,
|
||||
allocator: runtime.Allocator,
|
||||
) -> (
|
||||
err: time.Benchmark_Error,
|
||||
)
|
||||
|
||||
Version :: struct {
|
||||
name: string,
|
||||
bench: benchmark,
|
||||
}
|
||||
|
||||
init :: proc() {
|
||||
raw_dates := strings.split_lines(string(DATES))
|
||||
raw_dates = raw_dates[:len(raw_dates) - 1]
|
||||
formats = strings.split_lines(string(FORMATS))
|
||||
|
||||
dates = make([]common.Date_Components, len(raw_dates))
|
||||
|
||||
assert(to_parsed(raw_dates, &dates))
|
||||
}
|
||||
|
||||
to_parsed :: proc(raw_dates: []string, dates: ^[]common.Date_Components) -> bool {
|
||||
for date, i in raw_dates {
|
||||
// log.debugf("parsing '%s'", date)
|
||||
dates[i] = common.parse_iso_date(date) or_return
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
main :: proc() {
|
||||
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)
|
||||
context.logger = console_logger
|
||||
defer log.destroy_console_logger(console_logger)
|
||||
|
||||
init()
|
||||
|
||||
versions := [?]Version {
|
||||
{"original", to_benchmark(original.format_date)},
|
||||
{"inlined", to_benchmark(inlined.format_date)},
|
||||
}
|
||||
|
||||
fmt.printfln(
|
||||
"%-10s %14s %10s %14s %12s %8s",
|
||||
"version",
|
||||
"total",
|
||||
"calls",
|
||||
"calls/s",
|
||||
"time/call",
|
||||
"MB/s",
|
||||
)
|
||||
|
||||
for version in versions {
|
||||
defer free_all(context.temp_allocator)
|
||||
|
||||
b: time.Benchmark_Options
|
||||
b.bench = version.bench
|
||||
|
||||
if err := time.benchmark(&b, context.temp_allocator); err != nil {
|
||||
fmt.panicf("%v", err)
|
||||
}
|
||||
|
||||
// fmt.printfln("%v", b)
|
||||
per_call := time.Duration(i64(b.duration) / i64(b.count))
|
||||
fmt.printfln(
|
||||
"%-10s %14v % 10d % 14.0f %12v % 8.2f",
|
||||
version.name,
|
||||
b.duration,
|
||||
b.count,
|
||||
b.rounds_per_second,
|
||||
per_call,
|
||||
b.megabytes_per_second,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
to_benchmark :: proc($f: formatter) -> benchmark {
|
||||
return(
|
||||
proc(
|
||||
opts: ^time.Benchmark_Options,
|
||||
allocator: runtime.Allocator,
|
||||
) -> (
|
||||
err: time.Benchmark_Error,
|
||||
) {
|
||||
for _ in 0 ..< ITERATIONS {
|
||||
for date in dates {
|
||||
for format in formats {
|
||||
f(date, format, allocator)
|
||||
opts.count += 1
|
||||
opts.processed += size_of(date) + size_of(format)
|
||||
}
|
||||
}
|
||||
|
||||
opts.rounds += 1
|
||||
}
|
||||
|
||||
return err
|
||||
} \
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package common
|
||||
|
||||
Date_Components :: struct {
|
||||
year: int,
|
||||
month: int,
|
||||
day: int,
|
||||
hour: int,
|
||||
minute: int,
|
||||
second: int,
|
||||
}
|
||||
|
||||
// TODO: Use some kind of scanner interface
|
||||
parse_iso_date :: proc(iso: string) -> (c: Date_Components, ok: bool) {
|
||||
if len(iso) < 10 {
|
||||
return {}, false
|
||||
}
|
||||
|
||||
c.year = parse_2_digits(iso, 0) * 100 + parse_2_digits(iso, 2)
|
||||
c.month = parse_2_digits(iso, 5)
|
||||
c.day = parse_2_digits(iso, 8)
|
||||
|
||||
if c.month < 1 || c.month > 12 {
|
||||
return {}, false
|
||||
}
|
||||
if c.day < 1 || c.day > 31 {
|
||||
return {}, false
|
||||
}
|
||||
|
||||
if len(iso) >= 19 && (iso[10] == 'T' || iso[10] == 't') {
|
||||
c.hour = parse_2_digits(iso, 11)
|
||||
c.minute = parse_2_digits(iso, 14)
|
||||
c.second = parse_2_digits(iso, 17)
|
||||
}
|
||||
|
||||
return c, true
|
||||
}
|
||||
|
||||
parse_2_digits :: proc(s: string, offset: int) -> int {
|
||||
if offset + 1 >= len(s) {
|
||||
return 0
|
||||
}
|
||||
return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
2024-01-05
|
||||
2024-02-14
|
||||
2024-03-08
|
||||
2024-04-22
|
||||
2024-05-01
|
||||
2024-06-19
|
||||
2024-07-04
|
||||
2024-08-30
|
||||
2024-09-11
|
||||
2024-10-31
|
||||
2024-11-27
|
||||
2024-12-25
|
||||
2023-01-15T00:00:00-05:00
|
||||
2023-02-14T09:30:15+01:00
|
||||
2023-03-08T14:45:22-08:00
|
||||
2023-04-22T23:59:59+05:30
|
||||
2023-05-01T06:05:09-04:00
|
||||
2023-06-19T18:20:33+09:00
|
||||
2023-07-04T03:04:05-07:00
|
||||
2023-08-30T11:11:11+00:00
|
||||
2023-09-11T15:04:05-04:00
|
||||
2023-10-31T20:15:30+02:00
|
||||
2023-11-27T07:07:07-06:00
|
||||
2023-12-25T12:30:45+03:00
|
||||
2025-01-15T00:15:00-0500
|
||||
2025-02-14T09:30:15+0100
|
||||
2025-03-08T14:45:22-0800
|
||||
2025-04-22T23:59:59+0530
|
||||
2025-05-01T06:05:09-0400
|
||||
2025-06-19T18:20:33+0900
|
||||
2025-07-04T03:04:05-0700
|
||||
2025-08-30T11:11:11+0000
|
||||
2025-09-11T15:04:05-0400
|
||||
2025-10-31T20:15:30+0200
|
||||
2025-11-27T07:07:07-0600
|
||||
2025-12-25T12:30:45+0300
|
||||
2022-01-01T00:00:00Z
|
||||
2022-02-14T01:30:00Z
|
||||
2022-03-08T11:59:59Z
|
||||
2022-04-22T12:00:00Z
|
||||
2022-05-01T12:00:01Z
|
||||
2022-06-19T13:15:00Z
|
||||
2022-07-04T15:04:05Z
|
||||
2022-08-30T18:45:30Z
|
||||
2022-09-11T20:20:20Z
|
||||
2022-10-31T22:10:10Z
|
||||
2022-11-27T23:59:59Z
|
||||
2022-12-25T09:09:09Z
|
||||
2021-03-14t09:26:53
|
||||
2021-07-20t23:00:00
|
||||
2021-11-05t00:00:00
|
||||
2020-06-15T15:04:05
|
||||
2020-12-31T23:59:59
|
||||
2020-01-01T00:00:00
|
||||
2024-02-29T12:00:00Z
|
||||
2000-02-29T00:00:00Z
|
||||
1999-12-31T23:59:59Z
|
||||
2026-01-01T00:00:00Z
|
||||
2026-07-23T14:56:07-04:00
|
||||
@@ -0,0 +1,46 @@
|
||||
2006
|
||||
06
|
||||
January
|
||||
Jan
|
||||
Monday
|
||||
Mon
|
||||
01
|
||||
02
|
||||
03
|
||||
04
|
||||
05
|
||||
15
|
||||
1
|
||||
2
|
||||
3
|
||||
4
|
||||
5
|
||||
PM
|
||||
pm
|
||||
MST
|
||||
2006-01-02
|
||||
2006-01-02 15:04:05
|
||||
2 Jan 2006
|
||||
Jan 2, 2006
|
||||
January 2, 2006
|
||||
Monday, January 2, 2006
|
||||
Mon Jan 2 2006
|
||||
Mon Jan 02 2006
|
||||
Mon, 02 Jan 2006 15:04:05 MST
|
||||
01/02/2006
|
||||
02/01/2006
|
||||
2006/01/02
|
||||
1/2/06
|
||||
1/2/06 3:04PM
|
||||
1/2/2006
|
||||
3:04:05 PM
|
||||
3:04 pm
|
||||
15:04:05
|
||||
15:04
|
||||
15:04:05 MST
|
||||
January 2006
|
||||
Jan 06
|
||||
2 January 2006
|
||||
Monday 2 Jan 2006 at 15:04
|
||||
06-01-02
|
||||
3:4:5
|
||||
@@ -0,0 +1,125 @@
|
||||
package inlined
|
||||
|
||||
import "../common"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:strings"
|
||||
import "core:time"
|
||||
import "core:time/datetime"
|
||||
|
||||
format_date :: proc(
|
||||
dt: common.Date_Components,
|
||||
fmt: string,
|
||||
allocator := context.temp_allocator,
|
||||
) -> string {
|
||||
b: strings.Builder
|
||||
strings.builder_init_len(&b, len(fmt), allocator)
|
||||
|
||||
for i := 0; i < len(fmt); {
|
||||
matched := match_token(&b, dt, fmt[i:])
|
||||
if matched > 0 {
|
||||
i += matched
|
||||
} else {
|
||||
strings.write_byte(&b, fmt[i])
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
log.debugf("formatted date: '%s'", b.buf)
|
||||
return strings.to_string(b)
|
||||
}
|
||||
|
||||
match_token :: proc(b: ^strings.Builder, dt: common.Date_Components, s: string) -> int {
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"January",
|
||||
) {strings.write_string(b, fmt.tprintf("%s", time.Month(dt.month))); return 7}
|
||||
if strings.has_prefix(s, "Monday") {emit_weekday(b, dt, full = true); return 6}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"2006",
|
||||
) {strings.write_string(b, fmt.tprintf("%04d", dt.year)); return 4}
|
||||
if strings.has_prefix(s, "MST") {strings.write_string(b, "UTC"); return 3}
|
||||
if strings.has_prefix(s, "Jan") {emit_month_abbr(b, dt); return 3}
|
||||
if strings.has_prefix(s, "Mon") {emit_weekday(b, dt, full = false); return 3}
|
||||
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}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"15",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.hour)); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"04",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.minute)); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"05",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.second)); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"01",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.month)); return 2}
|
||||
if strings.has_prefix(s, "03") {emit_hour_12(b, dt, pad = true); return 2}
|
||||
if strings.has_prefix(s, "PM") {
|
||||
strings.write_string(b, "PM" if dt.hour >= 12 else "AM")
|
||||
return 2
|
||||
}
|
||||
if strings.has_prefix(s, "pm") {
|
||||
strings.write_string(b, "pm" if dt.hour >= 12 else "am")
|
||||
return 2
|
||||
}
|
||||
if len(s) >= 1 {
|
||||
switch s[0] {
|
||||
case '2':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.day)); return 1
|
||||
case '1':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.month)); return 1
|
||||
case '4':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.minute)); return 1
|
||||
case '5':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.second)); return 1
|
||||
case '3':
|
||||
emit_hour_12(b, dt, pad = false); return 1
|
||||
case:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
emit_month_abbr :: proc(b: ^strings.Builder, dt: common.Date_Components) {
|
||||
name := fmt.tprintf("%s", time.Month(dt.month))
|
||||
strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
|
||||
}
|
||||
|
||||
emit_weekday :: proc(b: ^strings.Builder, dt: common.Date_Components, full: bool) {
|
||||
date := datetime.Date {
|
||||
year = i64(dt.year),
|
||||
month = i8(dt.month),
|
||||
day = i8(dt.day),
|
||||
}
|
||||
ordinal, err := datetime.date_to_ordinal(date)
|
||||
if err != .None {
|
||||
strings.write_string(b, "???")
|
||||
return
|
||||
}
|
||||
weekday := datetime.day_of_week(ordinal)
|
||||
name := fmt.tprintf("%s", weekday)
|
||||
if full {
|
||||
strings.write_string(b, name)
|
||||
} else {
|
||||
strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
|
||||
}
|
||||
}
|
||||
|
||||
emit_hour_12 :: proc(b: ^strings.Builder, dt: common.Date_Components, pad: bool) {
|
||||
h12 := dt.hour % 12
|
||||
if h12 == 0 {h12 = 12}
|
||||
format := "%02d" if pad else "%d"
|
||||
|
||||
fmt.sbprintf(b, format, h12)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package inplace
|
||||
|
||||
import "../common"
|
||||
import "core:bytes"
|
||||
import "core:fmt"
|
||||
import "core:mem"
|
||||
import "core:strings"
|
||||
import "core:time"
|
||||
import "core:time/datetime"
|
||||
|
||||
format_date :: proc(
|
||||
dt: common.Date_Components,
|
||||
fmt: string,
|
||||
allocator := context.temp_allocator,
|
||||
) -> string {
|
||||
b := make([]byte, len(fmt), context.temp_allocator)
|
||||
x := transmute([]byte)(fmt)
|
||||
mem.copy_non_overlapping(&b[0], &x, len(fmt))
|
||||
|
||||
|
||||
return string(b)
|
||||
// for i := 0; i < len(fmt); {
|
||||
// matched := match_token(&b, dt, fmt[i:])
|
||||
// if matched > 0 {
|
||||
// i += matched
|
||||
// } else {
|
||||
// strings.write_byte(&b, fmt[i])
|
||||
// i += 1
|
||||
// }
|
||||
// }
|
||||
|
||||
// log.debugf("formatted date: '%s'", b.buf)
|
||||
// return strings.to_string(b)
|
||||
}
|
||||
|
||||
FULL_MONTH: string : "January"
|
||||
|
||||
match_token :: proc(s: []byte, dt: common.Date_Components) {
|
||||
if bytes.equal(s[:len(FULL_MONTH)], transmute([]u8)FULL_MONTH) {
|
||||
mo := fmt.tprintf("%s", time.Month(dt.month))
|
||||
mem.copy_non_overlapping(&s[0], &(transmute([]u8)mo)[0], len(mo))
|
||||
}
|
||||
/*
|
||||
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}
|
||||
if strings.has_prefix(s, "MST") {strings.write_string(b, "UTC"); return 3}
|
||||
if strings.has_prefix(s, "Jan") {emit_month_abbr(b, dt); return 3}
|
||||
if strings.has_prefix(s, "Mon") {emit_weekday(b, dt, full = false); return 3}
|
||||
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}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"15",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.hour)); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"04",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.minute)); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"05",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.second)); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"01",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.month)); return 2}
|
||||
if strings.has_prefix(s, "03") {emit_hour_12(b, dt, pad = true); return 2}
|
||||
if strings.has_prefix(s, "PM") {
|
||||
strings.write_string(b, "PM" if dt.hour >= 12 else "AM")
|
||||
return 2
|
||||
}
|
||||
if strings.has_prefix(s, "pm") {
|
||||
strings.write_string(b, "pm" if dt.hour >= 12 else "am")
|
||||
return 2
|
||||
}
|
||||
if len(s) >= 1 {
|
||||
switch s[0] {
|
||||
case '2':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.day)); return 1
|
||||
case '1':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.month)); return 1
|
||||
case '4':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.minute)); return 1
|
||||
case '5':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.second)); return 1
|
||||
case '3':
|
||||
emit_hour_12(b, dt, pad = false); return 1
|
||||
case:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
emit_month_abbr :: proc(b: ^strings.Builder, dt: common.Date_Components) {
|
||||
name := fmt.tprintf("%s", time.Month(dt.month))
|
||||
strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
|
||||
}
|
||||
|
||||
emit_weekday :: proc(b: ^strings.Builder, dt: common.Date_Components, full: bool) {
|
||||
date := datetime.Date {
|
||||
year = i64(dt.year),
|
||||
month = i8(dt.month),
|
||||
day = i8(dt.day),
|
||||
}
|
||||
ordinal, err := datetime.date_to_ordinal(date)
|
||||
if err != .None {
|
||||
strings.write_string(b, "???")
|
||||
return
|
||||
}
|
||||
weekday := datetime.day_of_week(ordinal)
|
||||
name := fmt.tprintf("%s", weekday)
|
||||
if full {
|
||||
strings.write_string(b, name)
|
||||
} else {
|
||||
strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
|
||||
}
|
||||
}
|
||||
|
||||
emit_hour_12 :: proc(b: ^strings.Builder, dt: common.Date_Components, pad: bool) {
|
||||
h12 := dt.hour % 12
|
||||
if h12 == 0 {h12 = 12}
|
||||
format := "%02d" if pad else "%d"
|
||||
|
||||
fmt.sbprintf(b, format, h12)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package original
|
||||
|
||||
import "../common"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:strings"
|
||||
import "core:time"
|
||||
import "core:time/datetime"
|
||||
|
||||
format_date :: proc(
|
||||
dt: common.Date_Components,
|
||||
fmt: string,
|
||||
allocator := context.temp_allocator,
|
||||
) -> string {
|
||||
b: strings.Builder
|
||||
strings.builder_init(&b, allocator)
|
||||
|
||||
for i := 0; i < len(fmt); {
|
||||
matched := match_token(&b, dt, fmt[i:])
|
||||
if matched > 0 {
|
||||
i += matched
|
||||
} else {
|
||||
strings.write_byte(&b, fmt[i])
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
log.debugf("formatted date: '%s'", b.buf)
|
||||
return strings.to_string(b)
|
||||
}
|
||||
|
||||
match_token :: proc(b: ^strings.Builder, dt: common.Date_Components, s: string) -> int {
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"January",
|
||||
) {strings.write_string(b, fmt.tprintf("%s", time.Month(dt.month))); return 7}
|
||||
if strings.has_prefix(s, "Monday") {emit_weekday(b, dt, full = true); return 6}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"2006",
|
||||
) {strings.write_string(b, fmt.tprintf("%04d", dt.year)); return 4}
|
||||
if strings.has_prefix(s, "MST") {strings.write_string(b, "UTC"); return 3}
|
||||
if strings.has_prefix(s, "Jan") {emit_month_abbr(b, dt); return 3}
|
||||
if strings.has_prefix(s, "Mon") {emit_weekday(b, dt, full = false); return 3}
|
||||
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}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"15",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.hour)); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"04",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.minute)); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"05",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.second)); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"01",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.month)); return 2}
|
||||
if strings.has_prefix(s, "03") {emit_hour_12(b, dt, pad = true); return 2}
|
||||
if strings.has_prefix(s, "PM") {emit_am_pm(b, dt); return 2}
|
||||
if strings.has_prefix(s, "pm") {emit_am_pm_lower(b, dt); return 2}
|
||||
if len(s) >= 1 {
|
||||
switch s[0] {
|
||||
case '2':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.day)); return 1
|
||||
case '1':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.month)); return 1
|
||||
case '4':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.minute)); return 1
|
||||
case '5':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.second)); return 1
|
||||
case '3':
|
||||
emit_hour_12(b, dt, pad = false); return 1
|
||||
case:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
emit_month_abbr :: proc(b: ^strings.Builder, dt: common.Date_Components) {
|
||||
name := fmt.tprintf("%s", time.Month(dt.month))
|
||||
strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
|
||||
}
|
||||
|
||||
emit_weekday :: proc(b: ^strings.Builder, dt: common.Date_Components, full: bool) {
|
||||
date := datetime.Date {
|
||||
year = i64(dt.year),
|
||||
month = i8(dt.month),
|
||||
day = i8(dt.day),
|
||||
}
|
||||
ordinal, err := datetime.date_to_ordinal(date)
|
||||
if err != .None {
|
||||
strings.write_string(b, "???")
|
||||
return
|
||||
}
|
||||
weekday := datetime.day_of_week(ordinal)
|
||||
name := fmt.tprintf("%s", weekday)
|
||||
if full {
|
||||
strings.write_string(b, name)
|
||||
} else {
|
||||
strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
|
||||
}
|
||||
}
|
||||
|
||||
emit_hour_12 :: proc(b: ^strings.Builder, dt: common.Date_Components, pad: bool) {
|
||||
h12 := dt.hour % 12
|
||||
if h12 == 0 {h12 = 12}
|
||||
format := "%02d" if pad else "%d"
|
||||
|
||||
fmt.sbprintf(b, format, h12)
|
||||
}
|
||||
|
||||
emit_am_pm :: proc(b: ^strings.Builder, dt: common.Date_Components) {
|
||||
strings.write_string(b, "PM" if dt.hour >= 12 else "AM")
|
||||
}
|
||||
|
||||
emit_am_pm_lower :: proc(b: ^strings.Builder, dt: common.Date_Components) {
|
||||
strings.write_string(b, "pm" if dt.hour >= 12 else "am")
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
Date_Components :: struct {
|
||||
year: int,
|
||||
month: int,
|
||||
day: int,
|
||||
hour: int,
|
||||
minute: int,
|
||||
second: int,
|
||||
}
|
||||
|
||||
// TODO: Use some kind of scanner interface
|
||||
parse_iso_date :: proc(iso: string) -> (c: Date_Components, ok: bool) {
|
||||
if len(iso) < 10 {
|
||||
return {}, false
|
||||
}
|
||||
|
||||
c.year = parse_2_digits(iso, 0) * 100 + parse_2_digits(iso, 2)
|
||||
c.month = parse_2_digits(iso, 5)
|
||||
c.day = parse_2_digits(iso, 8)
|
||||
|
||||
if c.month < 1 || c.month > 12 {
|
||||
return {}, false
|
||||
}
|
||||
if c.day < 1 || c.day > 31 {
|
||||
return {}, false
|
||||
}
|
||||
|
||||
if len(iso) >= 19 && (iso[10] == 'T' || iso[10] == 't') {
|
||||
c.hour = parse_2_digits(iso, 11)
|
||||
c.minute = parse_2_digits(iso, 14)
|
||||
c.second = parse_2_digits(iso, 17)
|
||||
}
|
||||
|
||||
return c, true
|
||||
}
|
||||
|
||||
parse_2_digits :: proc(s: string, offset: int) -> int {
|
||||
if offset + 1 >= len(s) {
|
||||
return 0
|
||||
}
|
||||
return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package returned
|
||||
|
||||
import "../common"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:strings"
|
||||
import "core:time"
|
||||
import "core:time/datetime"
|
||||
|
||||
format_date :: proc(
|
||||
dt: common.Date_Components,
|
||||
fmt: string,
|
||||
allocator := context.temp_allocator,
|
||||
) -> string {
|
||||
b: strings.Builder
|
||||
// strings.builder_init_len(&b, len(fmt), allocator)
|
||||
strings.builder_init(&b, allocator)
|
||||
|
||||
for i := 0; i < len(fmt); {
|
||||
matched := match_token(&b, dt, fmt[i:])
|
||||
if matched > 0 {
|
||||
i += matched
|
||||
} else {
|
||||
strings.write_byte(&b, fmt[i])
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
log.debugf("formatted date: '%s'", b.buf)
|
||||
return strings.to_string(b)
|
||||
}
|
||||
|
||||
match_token :: proc(b: ^strings.Builder, dt: common.Date_Components, s: string) -> int {
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"January",
|
||||
) {strings.write_string(b, fmt.tprintf("%s", time.Month(dt.month))); return 7}
|
||||
if strings.has_prefix(s, "Monday") {emit_weekday(b, dt, full = true); return 6}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"2006",
|
||||
) {strings.write_string(b, fmt.tprintf("%04d", dt.year)); return 4}
|
||||
if strings.has_prefix(s, "MST") {strings.write_string(b, "UTC"); return 3}
|
||||
if strings.has_prefix(s, "Jan") {emit_month_abbr(b, dt); return 3}
|
||||
if strings.has_prefix(s, "Mon") {emit_weekday(b, dt, full = false); return 3}
|
||||
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}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"15",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.hour)); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"04",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.minute)); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"05",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.second)); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"01",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.month)); return 2}
|
||||
if strings.has_prefix(s, "03") {emit_hour_12(b, dt, pad = true); return 2}
|
||||
if strings.has_prefix(s, "PM") {
|
||||
strings.write_string(b, "PM" if dt.hour >= 12 else "AM")
|
||||
return 2
|
||||
}
|
||||
if strings.has_prefix(s, "pm") {
|
||||
strings.write_string(b, "pm" if dt.hour >= 12 else "am")
|
||||
return 2
|
||||
}
|
||||
if len(s) >= 1 {
|
||||
switch s[0] {
|
||||
case '2':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.day)); return 1
|
||||
case '1':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.month)); return 1
|
||||
case '4':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.minute)); return 1
|
||||
case '5':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.second)); return 1
|
||||
case '3':
|
||||
emit_hour_12(b, dt, pad = false); return 1
|
||||
case:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
emit_month_abbr :: proc(b: ^strings.Builder, dt: common.Date_Components) {
|
||||
name := fmt.tprintf("%s", time.Month(dt.month))
|
||||
strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
|
||||
}
|
||||
|
||||
emit_weekday :: proc(b: ^strings.Builder, dt: common.Date_Components, full: bool) {
|
||||
date := datetime.Date {
|
||||
year = i64(dt.year),
|
||||
month = i8(dt.month),
|
||||
day = i8(dt.day),
|
||||
}
|
||||
ordinal, err := datetime.date_to_ordinal(date)
|
||||
if err != .None {
|
||||
strings.write_string(b, "???")
|
||||
return
|
||||
}
|
||||
weekday := datetime.day_of_week(ordinal)
|
||||
name := fmt.tprintf("%s", weekday)
|
||||
if full {
|
||||
strings.write_string(b, name)
|
||||
} else {
|
||||
strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
|
||||
}
|
||||
}
|
||||
|
||||
emit_hour_12 :: proc(b: ^strings.Builder, dt: common.Date_Components, pad: bool) {
|
||||
h12 := dt.hour % 12
|
||||
if h12 == 0 {h12 = 12}
|
||||
format := "%02d" if pad else "%d"
|
||||
|
||||
fmt.sbprintf(b, format, h12)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{$title}}{{/title}}</title>
|
||||
{{$head}}{{/head}}
|
||||
</head>
|
||||
<body>
|
||||
{{$nav}}{{/nav}}
|
||||
{{$main}}{{/main}}
|
||||
{{$sidebar}}{{/sidebar}}
|
||||
{{$footer}}{{/footer}}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
{{<base}}
|
||||
{{$title}}Archive{{/title}}
|
||||
{{$head}}
|
||||
<meta name="description" content="Post archive">
|
||||
{{/head}}
|
||||
{{$nav}}
|
||||
<nav>
|
||||
<ul>
|
||||
{{#nav_items}}
|
||||
<li><a href="{{url}}">{{label}}</a></li>
|
||||
{{/nav_items}}
|
||||
</ul>
|
||||
</nav>
|
||||
{{/nav}}
|
||||
{{$main}}
|
||||
<main>
|
||||
<h1>Archive</h1>
|
||||
{{#posts | group_by year}}
|
||||
<section class="year">
|
||||
<h2>{{key}}</h2>
|
||||
<ul>
|
||||
{{#items}}
|
||||
{{> post}}
|
||||
{{/items}}
|
||||
</ul>
|
||||
</section>
|
||||
{{/posts}}
|
||||
</main>
|
||||
{{/main}}
|
||||
{{$sidebar}}
|
||||
<aside>
|
||||
<h3>Recent Comments</h3>
|
||||
<ul>
|
||||
{{#comments}}
|
||||
{{> comment}}
|
||||
{{/comments}}
|
||||
</ul>
|
||||
</aside>
|
||||
{{/sidebar}}
|
||||
{{$footer}}
|
||||
<footer>
|
||||
<p>Generated {{now}}</p>
|
||||
</footer>
|
||||
{{/footer}}
|
||||
{{/base}}
|
||||
@@ -0,0 +1,5 @@
|
||||
<li class="comment">
|
||||
<strong>{{author}}</strong>
|
||||
<time datetime="{{date}}">{{date | format}}</time>
|
||||
<p>{{body}}</p>
|
||||
</li>
|
||||
@@ -0,0 +1,9 @@
|
||||
<li class="post">
|
||||
<article>
|
||||
<h3><a href="{{url}}">{{title}}</a></h3>
|
||||
<time datetime="{{date}}">{{date | format}}</time>
|
||||
<p class="excerpt">{{excerpt}}</p>
|
||||
{{#author}}<address>{{author}}</address>{{/author}}
|
||||
{{#tags}}<a href="/tag/{{slug}}" class="tag">{{name}}</a>{{/tags}}
|
||||
</article>
|
||||
</li>
|
||||
+220
-145
@@ -1,111 +1,88 @@
|
||||
package main
|
||||
|
||||
import cm "vendor:commonmark"
|
||||
import md "markdown"
|
||||
import ts "treesitter"
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
import "core:time"
|
||||
|
||||
Page_Type :: enum {
|
||||
Home,
|
||||
Post,
|
||||
Standalone,
|
||||
}
|
||||
|
||||
// Fields with underscores should never be set by the user.
|
||||
Page :: struct {
|
||||
type: Page_Type,
|
||||
section: string,
|
||||
slug: string,
|
||||
layout: string,
|
||||
permalink: string,
|
||||
url: string,
|
||||
title: string,
|
||||
description: string,
|
||||
date: string,
|
||||
year: string,
|
||||
weight: Maybe(int),
|
||||
lastmod: string,
|
||||
menus: map[string]Menu_Entry,
|
||||
params: json.Value,
|
||||
content: string,
|
||||
og: Open_Graph,
|
||||
draft: bool,
|
||||
is_starred: bool,
|
||||
menu: string,
|
||||
body: string,
|
||||
body_html: string,
|
||||
bundle_dir: string,
|
||||
toc: string,
|
||||
_is_index: bool `private`,
|
||||
}
|
||||
|
||||
// walk_content reads the content directory and returns all non-draft pages
|
||||
// (or all pages if include_drafts is true).
|
||||
walk_content :: proc(content_path: string, include_drafts: bool) -> []Page {
|
||||
pages: [dynamic]Page
|
||||
|
||||
collect_home(&pages, content_path)
|
||||
collect_standalone(&pages, content_path)
|
||||
|
||||
posts_path := fmt.tprintf("%s/posts", content_path)
|
||||
if os.exists(posts_path) {
|
||||
collect_posts(&pages, posts_path)
|
||||
}
|
||||
|
||||
if !include_drafts {
|
||||
filtered: [dynamic]Page
|
||||
for &page in pages {
|
||||
if !page.draft {
|
||||
append(&filtered, page)
|
||||
}
|
||||
}
|
||||
delete(pages)
|
||||
return filtered[:]
|
||||
}
|
||||
|
||||
return pages[:]
|
||||
Pending_File :: struct {
|
||||
path: string,
|
||||
section: string,
|
||||
slug: string,
|
||||
is_index: bool,
|
||||
}
|
||||
|
||||
collect_home :: proc(pages: ^[dynamic]Page, content_path: string) {
|
||||
html_path := fmt.tprintf("%s/index.html", content_path)
|
||||
if os.exists(html_path) {
|
||||
page, ok := load_page(html_path, .Home, "")
|
||||
if ok {
|
||||
page.permalink = "/"
|
||||
append(pages, page)
|
||||
}
|
||||
return
|
||||
// site_load_content reads the content directory and populates site.pages.
|
||||
// Drafts are excluded unless .Drafts is enabled.
|
||||
site_load_content :: proc(site: ^Site) {
|
||||
site.pages = make(#soa[dynamic]Page, 0, 8, site_allocator(site))
|
||||
|
||||
// Phase 0: Enumerate content files
|
||||
pending := make([dynamic]Pending_File, 0, 16, context.temp_allocator)
|
||||
scan_content_files(site.content_dir, "", &pending)
|
||||
|
||||
// Phase 1: Pre-scan for code fence languages
|
||||
// Phase 2: Parallel grammar preload
|
||||
if .Highlight in site.markdown_extensions {
|
||||
languages := collect_languages(pending[:])
|
||||
ts.preload_grammars(languages)
|
||||
}
|
||||
|
||||
md_path := fmt.tprintf("%s/index.md", content_path)
|
||||
if os.exists(md_path) {
|
||||
page, ok := load_page(md_path, .Home, "")
|
||||
if ok {
|
||||
page.permalink = "/"
|
||||
append(pages, page)
|
||||
// Phase 3: Load pages (grammars already cached)
|
||||
for file in pending {
|
||||
page, ok := load_page(
|
||||
file.path,
|
||||
file.section,
|
||||
file.slug,
|
||||
file.is_index,
|
||||
site.markdown_extensions,
|
||||
)
|
||||
if ok && (!page.draft || .Drafts in site.features) {
|
||||
append(&site.pages, page)
|
||||
}
|
||||
}
|
||||
|
||||
for &page in site.pages {
|
||||
page.url = fmt.tprintf("%s%s", site.base_url, page.permalink)
|
||||
}
|
||||
|
||||
build_menus(site)
|
||||
}
|
||||
|
||||
collect_standalone :: proc(pages: ^[dynamic]Page, content_path: string) {
|
||||
entries, err := os.read_all_directory_by_path(content_path, context.allocator)
|
||||
// scan_content_files walks the content directory and collects Pending_File
|
||||
// entries. At the root level (section=""), directories are treated as
|
||||
// sections. Within a section, directories are treated as leaf bundles.
|
||||
scan_content_files :: proc(dir: string, section: string, pending: ^[dynamic]Pending_File) {
|
||||
entries, err := os.read_all_directory_by_path(dir, context.allocator)
|
||||
if err != nil {
|
||||
fmt.eprintfln("thor: cannot read %s: %v", content_path, err)
|
||||
return
|
||||
}
|
||||
defer os.file_info_slice_delete(entries, context.allocator)
|
||||
|
||||
for entry in entries {
|
||||
if !is_content_file(entry.name) {
|
||||
continue
|
||||
}
|
||||
if entry.name == "index.md" || entry.name == "index.html" {
|
||||
continue
|
||||
}
|
||||
if entry.type != .Regular {
|
||||
continue
|
||||
}
|
||||
|
||||
slug := strip_extension(entry.name)
|
||||
page, ok := load_page(entry.fullpath, .Standalone, slug)
|
||||
if ok {
|
||||
append(pages, page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect_posts :: proc(pages: ^[dynamic]Page, posts_path: string) {
|
||||
entries, err := os.read_all_directory_by_path(posts_path, context.allocator)
|
||||
if err != nil {
|
||||
fmt.eprintfln("thor: cannot read %s: %v", posts_path, err)
|
||||
log.warnf("cannot read %s: %v", dir, err)
|
||||
return
|
||||
}
|
||||
defer os.file_info_slice_delete(entries, context.allocator)
|
||||
@@ -116,76 +93,196 @@ collect_posts :: proc(pages: ^[dynamic]Page, posts_path: string) {
|
||||
if !is_content_file(entry.name) {
|
||||
continue
|
||||
}
|
||||
slug := strip_extension(entry.name)
|
||||
page, ok := load_page(entry.fullpath, .Post, slug)
|
||||
if ok {
|
||||
append(pages, page)
|
||||
}
|
||||
|
||||
filename := strip_extension(entry.name)
|
||||
is_idx := filename == "index"
|
||||
slug := is_idx ? "" : filename
|
||||
|
||||
append(
|
||||
pending,
|
||||
Pending_File {
|
||||
path = strings.clone(entry.fullpath, context.temp_allocator),
|
||||
section = section,
|
||||
slug = slug,
|
||||
is_index = is_idx,
|
||||
},
|
||||
)
|
||||
|
||||
case .Directory:
|
||||
index_path := fmt.tprintf("%s/index.html", entry.fullpath)
|
||||
if !os.exists(index_path) {
|
||||
index_path = fmt.tprintf("%s/index.md", entry.fullpath)
|
||||
}
|
||||
if !os.exists(index_path) {
|
||||
continue
|
||||
}
|
||||
page, ok := load_page(index_path, .Post, entry.name)
|
||||
if ok {
|
||||
page.bundle_dir = entry.fullpath
|
||||
append(pages, page)
|
||||
if section == "" {
|
||||
scan_content_files(entry.fullpath, entry.name, pending)
|
||||
} else {
|
||||
index_path := fmt.tprintf("%s/index.html", entry.fullpath)
|
||||
if !os.exists(index_path) {
|
||||
index_path = fmt.tprintf("%s/index.md", entry.fullpath)
|
||||
}
|
||||
if os.exists(index_path) {
|
||||
append(
|
||||
pending,
|
||||
Pending_File {
|
||||
path = strings.clone(index_path, context.temp_allocator),
|
||||
section = section,
|
||||
slug = entry.name,
|
||||
is_index = false,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
case .Undetermined, .Symlink, .Named_Pipe, .Socket, .Block_Device, .Character_Device:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// collect_languages scans .md files for code fence language identifiers
|
||||
// (```lang or ~~~lang) and returns the unique set.
|
||||
collect_languages :: proc(files: []Pending_File) -> []string {
|
||||
set := make(map[string]bool, context.temp_allocator)
|
||||
|
||||
for file in files {
|
||||
if !strings.has_suffix(file.path, ".md") {
|
||||
continue
|
||||
}
|
||||
data, err := os.read_entire_file_from_path(file.path, context.temp_allocator)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
content := string(data)
|
||||
|
||||
pos := 0
|
||||
for pos < len(content) {
|
||||
newline := strings.index_byte(content[pos:], '\n')
|
||||
line_end := pos + newline if newline >= 0 else len(content)
|
||||
line := content[pos:line_end]
|
||||
|
||||
i := 0
|
||||
for i < len(line) && (line[i] == ' ' || line[i] == '\t') {
|
||||
i += 1
|
||||
}
|
||||
|
||||
if i + 3 <= len(line) &&
|
||||
(line[i] == '`' && line[i + 1] == '`' && line[i + 2] == '`') ||
|
||||
(i + 3 <= len(line) && line[i] == '~' && line[i + 1] == '~' && line[i + 2] == '~') {
|
||||
fence_char := line[i]
|
||||
j := i + 3
|
||||
for j < len(line) && line[j] == fence_char {
|
||||
j += 1
|
||||
}
|
||||
for j < len(line) && (line[j] == ' ' || line[j] == '\t') {
|
||||
j += 1
|
||||
}
|
||||
lang_start := j
|
||||
for j < len(line) {
|
||||
c := line[j]
|
||||
if c == ' ' || c == '\t' || c == '\r' || c == '\n' {
|
||||
break
|
||||
}
|
||||
j += 1
|
||||
}
|
||||
if j > lang_start {
|
||||
set[line[lang_start:j]] = true
|
||||
}
|
||||
}
|
||||
|
||||
pos = line_end + 1
|
||||
}
|
||||
}
|
||||
|
||||
result := make([dynamic]string, 0, len(set), context.temp_allocator)
|
||||
for lang in set {
|
||||
append(&result, lang)
|
||||
}
|
||||
return result[:]
|
||||
}
|
||||
|
||||
infer_layout :: proc(section: string, is_index: bool) -> string {
|
||||
if section == "" && is_index {
|
||||
return "home"
|
||||
}
|
||||
if is_index {
|
||||
return fmt.tprintf("%s_index", section)
|
||||
}
|
||||
if section != "" {
|
||||
if len(section) > 1 && section[len(section) - 1] == 's' {
|
||||
return section[:len(section) - 1]
|
||||
}
|
||||
return section
|
||||
}
|
||||
return "page"
|
||||
}
|
||||
|
||||
load_page :: proc(
|
||||
file_path: string,
|
||||
page_type: Page_Type,
|
||||
section: string,
|
||||
slug: string,
|
||||
is_index: bool,
|
||||
ext: bit_set[md.Extension],
|
||||
) -> (
|
||||
page: Page,
|
||||
ok: bool,
|
||||
) {
|
||||
data, err := os.read_entire_file_from_path(file_path, context.allocator)
|
||||
if err != nil {
|
||||
fmt.eprintfln("thor: cannot read %s: %v", file_path, err)
|
||||
log.warnf("cannot read %s: %v", file_path, err)
|
||||
return
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
fm, body, parsed := parse_frontmatter(content)
|
||||
if !parsed {
|
||||
fmt.eprintfln("thor: no frontmatter in %s", file_path)
|
||||
return
|
||||
body = strings.trim_left(content, " \t\r\n")
|
||||
}
|
||||
|
||||
page.type = page_type
|
||||
page.slug = slug
|
||||
page.title = fm.title
|
||||
page.section = section
|
||||
page.slug = slug
|
||||
page._is_index = is_index
|
||||
page.title = fm.title
|
||||
page.description = fm.description
|
||||
page.date = fm.date
|
||||
page.draft = fm.draft
|
||||
page.is_starred = fm.isStarred
|
||||
page.menu = fm.menu
|
||||
page.body = strings.clone(body)
|
||||
page.date = fm.date
|
||||
if page.date == "" {
|
||||
info, stat_err := os.stat(file_path, context.allocator)
|
||||
if stat_err == nil {
|
||||
page.date, _ = time.time_to_rfc3339(
|
||||
info.modification_time,
|
||||
0,
|
||||
false,
|
||||
context.allocator,
|
||||
)
|
||||
os.file_info_delete(info, context.allocator)
|
||||
log.warnf(
|
||||
"no date in frontmatter for %s, using file modification time: %s",
|
||||
file_path,
|
||||
page.date,
|
||||
)
|
||||
}
|
||||
}
|
||||
page.year = get_year(page.date)
|
||||
page.weight = fm.weight
|
||||
page.lastmod = fm.lastmod
|
||||
page.draft = fm.draft
|
||||
page.params = fm.params
|
||||
|
||||
if section == "" && is_index {
|
||||
page.permalink = "/"
|
||||
} else if is_index {
|
||||
page.permalink = fmt.aprintf("/%s/", section)
|
||||
} else if section == "" {
|
||||
page.permalink = fmt.aprintf("/%s/", slug)
|
||||
} else {
|
||||
page.permalink = fmt.aprintf("/%s/%s/", section, slug)
|
||||
}
|
||||
|
||||
page.menus = parse_page_menus(fm.menus, page, context.allocator)
|
||||
page.layout = fm.layout if fm.layout != "" else infer_layout(section, is_index)
|
||||
page.og = fm.og
|
||||
|
||||
if strings.has_suffix(file_path, ".html") {
|
||||
page.body_html = strings.clone(body)
|
||||
page.content = strings.clone(body, context.allocator)
|
||||
} else {
|
||||
expanded := expand_emoji(body)
|
||||
clean_body, defs := strip_definitions(expanded)
|
||||
html := cm.markdown_to_html_from_string(clean_body, {.Unsafe})
|
||||
page.body_html = inject_alerts(inject_sidenotes(html, defs))
|
||||
page.content = md.process(body, ext, file_path, context.allocator)
|
||||
}
|
||||
|
||||
switch page_type {
|
||||
case .Home:
|
||||
page.permalink = "/"
|
||||
case .Post:
|
||||
page.permalink = fmt.aprintf("/posts/%s/", slug)
|
||||
case .Standalone:
|
||||
page.permalink = fmt.aprintf("/%s/", slug)
|
||||
if fm.toc {
|
||||
page.toc = md.generate_toc(page.content, context.allocator)
|
||||
}
|
||||
|
||||
ok = true
|
||||
@@ -204,25 +301,3 @@ strip_extension :: proc(name: string) -> string {
|
||||
return name[:dot]
|
||||
}
|
||||
|
||||
// copy_static_assets copies non-content files from the content root to the output directory.
|
||||
copy_static_assets :: proc(content_path: string, output_dir: string) {
|
||||
entries, err := os.read_all_directory_by_path(content_path, context.allocator)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer os.file_info_slice_delete(entries, context.allocator)
|
||||
|
||||
for entry in entries {
|
||||
if entry.type != .Regular {
|
||||
continue
|
||||
}
|
||||
if is_content_file(entry.name) {
|
||||
continue
|
||||
}
|
||||
|
||||
dest := fmt.tprintf("%s/%s", output_dir, entry.name)
|
||||
if err := os.copy_file(dest, entry.fullpath); err != nil {
|
||||
fmt.eprintfln("thor: cannot copy %s: %v", entry.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package main
|
||||
|
||||
import "core:os"
|
||||
|
||||
DEFAULTS_PATH :: #directory + os.Path_Separator_String + "defaults"
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{> title}}</title>
|
||||
{{> opengraph}}
|
||||
{{> styles}}
|
||||
{{> scripts}}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{{> nav}}{{$main}}{{/main}}
|
||||
{{> footer}}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
{{<base}}
|
||||
{{$main}}
|
||||
<main>
|
||||
<header>
|
||||
{{&content}}
|
||||
</header>
|
||||
<ul>
|
||||
{{#pages}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date}}<time
|
||||
datetime="{{date}}">{{date | format}}</time>{{/date}}</span>
|
||||
</li>
|
||||
{{/pages}}
|
||||
</ul>
|
||||
</main>
|
||||
{{/main}}
|
||||
{{/base}}
|
||||
@@ -0,0 +1,16 @@
|
||||
{{<base}}
|
||||
{{$main}}
|
||||
<main>
|
||||
<article>
|
||||
<h1>{{page.title}}</h1>
|
||||
{{#date}}<time class="subtitle" datetime="{{date}}">{{ date | format}}</time>{{/date}}
|
||||
{{#page.toc}}
|
||||
<nav class="toc">
|
||||
{{&page.toc}}
|
||||
</nav>
|
||||
{{/page.toc}}
|
||||
{{&content}}
|
||||
</article>
|
||||
</main>
|
||||
{{/main}}
|
||||
{{/base}}
|
||||
@@ -0,0 +1,3 @@
|
||||
<footer>
|
||||
<p>© {{now | format "2006"}} {{params.author.name}}</p>
|
||||
</footer>
|
||||
@@ -0,0 +1 @@
|
||||
{{site.title}}{{^site.title}}Home{{/site.title}}
|
||||
@@ -0,0 +1,10 @@
|
||||
<header>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="/">{{> home-link}}</a></li>
|
||||
{{#menus.main}}
|
||||
<li><a href="{{url}}">{{name}}</a></li>
|
||||
{{/menus.main}}
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -0,0 +1,9 @@
|
||||
<meta property="og:url" content="{{og.url}}">
|
||||
<meta property="og:site_name" content="{{og.site_name}}">
|
||||
<meta property="og:title" content="{{og.title}}">
|
||||
<meta property="og:description" content="{{og.description}}">
|
||||
<meta property="og:locale" content="{{og.locale}}">
|
||||
<meta property="og:type" content="{{og.type}}">
|
||||
{{#og.is_article}}<meta property="article:section" content="{{og.section}}">
|
||||
<meta property="article:published_time" content="{{og.published_time}}">
|
||||
{{/og.is_article}}<meta property="og:image" content="{{og.image}}">
|
||||
@@ -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}}
|
||||
@@ -0,0 +1,19 @@
|
||||
{{<base}}
|
||||
{{$main}}
|
||||
<main>
|
||||
<h1>{{page.title}}</h1>
|
||||
{{&content}}
|
||||
{{#posts | group_by year}}
|
||||
<section>
|
||||
<h2>{{key}}</h2>
|
||||
<ul>
|
||||
{{#items}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date}}<time
|
||||
datetime="{{date}}">{{date | format}}</time>{{/date}}</span>
|
||||
</li>
|
||||
{{/items}}
|
||||
</ul>
|
||||
</section>
|
||||
{{/posts}}
|
||||
</main>
|
||||
{{/main}}
|
||||
{{/base}}
|
||||
-438
@@ -1,438 +0,0 @@
|
||||
#+feature dynamic-literals
|
||||
package main
|
||||
|
||||
import "core:strings"
|
||||
|
||||
EMOJIS: map[string]string = {
|
||||
// People
|
||||
"smile" = "\xf0\x9f\x98\x84",
|
||||
"laughing" = "\xf0\x9f\x98\x86",
|
||||
"blush" = "\xf0\x9f\x98\x8a",
|
||||
"smiley" = "\xf0\x9f\x98\x83",
|
||||
"wink" = "\xf0\x9f\x98\x89",
|
||||
"joy" = "\xf0\x9f\xa4\xa3",
|
||||
"rofl" = "\xf0\x9f\xa4\xa3",
|
||||
"relaxed" = "\xe2\x98\xba\xef\xb8\x8f",
|
||||
"thinking" = "\xf0\x9f\xa4\x94",
|
||||
"neutral_face" = "\xf0\x9f\x98\x90",
|
||||
"expressionless" = "\xf0\x9f\x98\x91",
|
||||
"no_mouth" = "\xf0\x9f\x98\xb6",
|
||||
"rolling_eyes" = "\xf0\x9f\x99\x84",
|
||||
"smirk" = "\xf0\x9f\x98\x8f",
|
||||
"persevere" = "\xf0\x9f\x98\xa3",
|
||||
"disappointed_relieved" = "\xf0\x9f\x98\xa5",
|
||||
"open_mouth" = "\xf0\x9f\x98\xae",
|
||||
"zipper_mouth_face" = "\xf0\x9f\xa4\x90",
|
||||
"hushed" = "\xf0\x9f\x98\xaf",
|
||||
"sleepy" = "\xf0\x9f\x98\xaa",
|
||||
"tired_face" = "\xf0\x9f\x98\xab",
|
||||
"sleeping" = "\xf0\x9f\x98\xb4",
|
||||
"relieved" = "\xf0\x9f\x98\x8c",
|
||||
"stuck_out_tongue" = "\xf0\x9f\x98\x9b",
|
||||
"stuck_out_tongue_winking_eye" = "\xf0\x9f\x98\x9c",
|
||||
"stuck_out_tongue_closed_eyes" = "\xf0\x9f\x98\x9d",
|
||||
"drooling_face" = "\xf0\x9f\xa4\xa4",
|
||||
"unamused" = "\xf0\x9f\x98\x92",
|
||||
"sweat" = "\xf0\x9f\x98\x85",
|
||||
"pensive" = "\xf0\x9f\x98\x94",
|
||||
"confused" = "\xf0\x9f\x98\x95",
|
||||
"upside_down_face" = "\xf0\x9f\x99\x83",
|
||||
"money_mouth_face" = "\xf0\x9f\xa4\x91",
|
||||
"astonished" = "\xf0\x9f\x98\xb2",
|
||||
"white_frowning_face" = "\xe2\x98\xb9\xef\xb8\x8f",
|
||||
"slightly_sad_face" = "\xe2\x98\xb9\xef\xb8\x8f",
|
||||
"confounded" = "\xf0\x9f\x98\x96",
|
||||
"disappointed" = "\xf0\x9f\x98\x9e",
|
||||
"worried" = "\xf0\x9f\x98\x9f",
|
||||
"triumph" = "\xf0\x9f\x98\xa4",
|
||||
"cry" = "\xf0\x9f\x98\xa2",
|
||||
"sob" = "\xf0\x9f\x98\xad",
|
||||
"frowning" = "\xf0\x9f\x98\xa6",
|
||||
"frowning_face" = "\xf0\x9f\x99\x81",
|
||||
"anguished" = "\xf0\x9f\x98\xa7",
|
||||
"fearful" = "\xf0\x9f\x98\xa8",
|
||||
"weary" = "\xf0\x9f\x98\xa9",
|
||||
"grimacing" = "\xf0\x9f\x98\xac",
|
||||
"cold_sweat" = "\xf0\x9f\x98\xb0",
|
||||
"scream" = "\xf0\x9f\x98\xb1",
|
||||
"flushed" = "\xf0\x9f\x98\xb3",
|
||||
"dizzy_face" = "\xf0\x9f\x98\xb5",
|
||||
"rage" = "\xf0\x9f\x98\xa1",
|
||||
"angry" = "\xf0\x9f\x98\xa0",
|
||||
"innocent" = "\xf0\x9f\x98\x87",
|
||||
"cowboy_hat_face" = "\xf0\x9f\xa4\xa0",
|
||||
"clown_face" = "\xf0\x9f\xa4\xa1",
|
||||
"mask" = "\xf0\x9f\x98\xb7",
|
||||
"thermometer_face" = "\xf0\x9f\xa4\x92",
|
||||
"head_bandage" = "\xf0\x9f\xa4\x95",
|
||||
"nauseated_face" = "\xf0\x9f\xa4\xa2",
|
||||
"sneezing_face" = "\xf0\x9f\xa4\xa7",
|
||||
"smiling_imp" = "\xf0\x9f\x98\x88",
|
||||
"imp" = "\xf0\x9f\x91\xbf",
|
||||
"shrug" = "\xc2\xaf\\_(\xe3\x83\x84)_/\xc2\xaf",
|
||||
"facepalm" = "\xf0\x9f\xa4\xa6",
|
||||
"facepunch" = "\xf0\x9f\x91\x8a",
|
||||
"wave" = "\xf0\x9f\x91\x8b",
|
||||
"ok_hand" = "\xf0\x9f\x91\x8c",
|
||||
"thumbsup" = "\xf0\x9f\x91\x8d",
|
||||
"thumbsdown" = "\xf0\x9f\x91\x8e",
|
||||
"clap" = "\xf0\x9f\x91\x8f",
|
||||
"pray" = "\xf0\x9f\x99\x8f",
|
||||
"point_up" = "\xe2\x98\x9d\xef\xb8\x8f",
|
||||
"point_down" = "\xf0\x9f\x91\x87",
|
||||
"point_left" = "\xf0\x9f\x91\x88",
|
||||
"point_right" = "\xf0\x9f\x89\x89",
|
||||
"v" = "\xe2\x9c\x8c\xef\xb8\x8f",
|
||||
"raised_hands" = "\xf0\x9f\x99\x8c",
|
||||
"muscle" = "\xf0\x9f\x92\xaa",
|
||||
"fist" = "\xe2\x9c\x8a",
|
||||
"hand" = "\xe2\x9c\x8b",
|
||||
|
||||
// Hearts & symbols
|
||||
"heart" = "\xe2\x9d\xa4\xef\xb8\x8f",
|
||||
"orange_heart" = "\xf0\x9f\xa7\xa1",
|
||||
"yellow_heart" = "\xf0\x9f\x92\x9b",
|
||||
"green_heart" = "\xf0\x9f\x92\x9a",
|
||||
"blue_heart" = "\xf0\x9f\x92\x99",
|
||||
"purple_heart" = "\xf0\x9f\x92\x9c",
|
||||
"broken_heart" = "\xf0\x9f\x92\x94",
|
||||
"sparkling_heart" = "\xf0\x9f\x92\x96",
|
||||
"100" = "\xf0\x9f\x92\xaf",
|
||||
"anger" = "\xf0\x9f\x92\xa2",
|
||||
"checkered_flag" = "\xf0\x9f\x8f\x81",
|
||||
"crossed_flags" = "\xf0\x9f\x9a\xa9",
|
||||
"rocket" = "\xf0\x9f\x9a\x80",
|
||||
"star" = "\xe2\xad\x90",
|
||||
"star2" = "\xf0\x9f\x8c\x9f",
|
||||
"sparkles" = "\xe2\x9c\xa8",
|
||||
"boom" = "\xf0\x9f\x92\xa5",
|
||||
"exclamation" = "\xe2\x9d\x97",
|
||||
"question" = "\xe2\x9d\x93",
|
||||
"grey_exclamation" = "\xe2\x9d\x95",
|
||||
"grey_question" = "\xe2\x9d\x94",
|
||||
"zzz" = "\xf0\x9f\x92\xa4",
|
||||
"warning" = "\xe2\x9a\xa0\xef\xb8\x8f",
|
||||
"no_entry_sign" = "\xf0\x9f\x9a\xab",
|
||||
"no_entry" = "\xe2\x9b\x94",
|
||||
"white_check_mark" = "\xe2\x9c\x85",
|
||||
"negative_squared_cross_mark" = "\xe2\x9d\x8e",
|
||||
"x" = "\xe2\x9d\x8c",
|
||||
"o" = "\xe2\xad\x95",
|
||||
"heavy_plus_sign" = "\xe2\x9e\x95",
|
||||
"heavy_minus_sign" = "\xe2\x9e\x96",
|
||||
"heavy_division_sign" = "\xe2\x9e\x97",
|
||||
"copyright" = "\xc2\xa9\xef\xb8\x8f",
|
||||
"registered" = "\xc2\xae\xef\xb8\x8f",
|
||||
"tm" = "\xe2\x84\xa2\xef\xb8\x8f",
|
||||
|
||||
// Nature & weather
|
||||
"fire" = "\xf0\x9f\x94\xa5",
|
||||
"zap" = "\xe2\x9a\xa1",
|
||||
"sunny" = "\xe2\x98\x80\xef\xb8\x8f",
|
||||
"cloud" = "\xe2\x98\x81\xef\xb8\x8f",
|
||||
"rainbow" = "\xf0\x9f\x8c\x88",
|
||||
"snowflake" = "\xe2\x9d\x84\xef\xb8\x8f",
|
||||
"dash" = "\xf0\x9f\x92\xa8",
|
||||
"tornado" = "\xf0\x9f\x8c\xaa\xef\xb8\x8f",
|
||||
"deciduous_tree" = "\xf0\x9f\x8c\xb3",
|
||||
"evergreen_tree" = "\xf0\x9f\x8c\xb2",
|
||||
"palm_tree" = "\xf0\x9f\x8c\xb4",
|
||||
"cactus" = "\xf0\x9f\x8c\xb5",
|
||||
"tulip" = "\xf0\x9f\x8c\xb7",
|
||||
"rose" = "\xf0\x9f\x8c\xb9",
|
||||
"sunflower" = "\xf0\x9f\x8c\xbb",
|
||||
"hibiscus" = "\xf0\x9f\x8c\xba",
|
||||
"earth_africa" = "\xf0\x9f\x8c\x8d",
|
||||
"earth_americas" = "\xf0\x9f\x8c\x8e",
|
||||
"earth_asia" = "\xf0\x9f\x8c\x8f",
|
||||
"new_moon" = "\xf0\x9f\x8c\x9a",
|
||||
"full_moon" = "\xf0\x9f\x8c\x95",
|
||||
|
||||
// Food & drink
|
||||
"coffee" = "\xe2\x98\x95",
|
||||
"tea" = "\xf0\x9f\x8d\xb5",
|
||||
"beer" = "\xf0\x9f\x8d\xba",
|
||||
"beers" = "\xf0\x9f\x8d\xbb",
|
||||
"wine_glass" = "\xf0\x9f\x8d\xb7",
|
||||
"tropical_drink" = "\xf0\x9f\x8d\xb9",
|
||||
"apple" = "\xf0\x9f\x8d\x8e",
|
||||
"green_apple" = "\xf0\x9f\x8d\x8f",
|
||||
"orange" = "\xf0\x9f\x8d\x8a",
|
||||
"lemon" = "\xf0\x9f\x8d\x8b",
|
||||
"banana" = "\xf0\x9f\x8d\x8c",
|
||||
"watermelon" = "\xf0\x9f\x8d\x89",
|
||||
"grapes" = "\xf0\x9f\x8d\x87",
|
||||
"strawberry" = "\xf0\x9f\x8d\x93",
|
||||
"melon" = "\xf0\x9f\x8d\x88",
|
||||
"cherries" = "\xf0\x9f\x8d\x92",
|
||||
"peach" = "\xf0\x9f\x8d\x91",
|
||||
"pineapple" = "\xf0\x9f\x8d\x8d",
|
||||
"pizza" = "\xf0\x9f\x8d\x95",
|
||||
"hamburger" = "\xf0\x9f\x8d\x94",
|
||||
"hotdog" = "\xf0\x9f\x8c\xad",
|
||||
"taco" = "\xf0\x9f\x8c\xae",
|
||||
"burrito" = "\xf0\x9f\x8c\xaf",
|
||||
"cake" = "\xf0\x9f\x8d\xb0",
|
||||
"cookie" = "\xf0\x9f\x8d\xaa",
|
||||
"chocolate_bar" = "\xf0\x9f\x8d\xab",
|
||||
"candy" = "\xf0\x9f\x8d\xac",
|
||||
"popcorn" = "\xf0\x9f\x8d\xbf",
|
||||
|
||||
// Animals
|
||||
"dog" = "\xf0\x9f\x90\xb6",
|
||||
"cat" = "\xf0\x9f\x90\xb1",
|
||||
"mouse" = "\xf0\x9f\x90\xad",
|
||||
"hamster" = "\xf0\x9f\x90\xb9",
|
||||
"rabbit" = "\xf0\x9f\x90\xb0",
|
||||
"bear" = "\xf0\x9f\x90\xbb",
|
||||
"panda_face" = "\xf0\x9f\x90\xbc",
|
||||
"koala" = "\xf0\x9f\x90\xa8",
|
||||
"tiger" = "\xf0\x9f\x90\xaf",
|
||||
"lion_face" = "\xf0\x9f\xa6\x81",
|
||||
"cow" = "\xf0\x9f\x90\xae",
|
||||
"pig" = "\xf0\x9f\x90\xb7",
|
||||
"frog" = "\xf0\x9f\x90\xb8",
|
||||
"monkey_face" = "\xf0\x9f\x90\xb5",
|
||||
"see_no_evil" = "\xf0\x9f\x99\x88",
|
||||
"hear_no_evil" = "\xf0\x9f\x99\x89",
|
||||
"speak_no_evil" = "\xf0\x9f\x99\x8a",
|
||||
"owl" = "\xf0\x9f\xa6\x89",
|
||||
"bat" = "\xf0\x9f\xa6\x87",
|
||||
"wolf" = "\xf0\x9f\x90\xba",
|
||||
"boar" = "\xf0\x9f\x90\x97",
|
||||
"horse" = "\xf0\x9f\x90\x8e",
|
||||
"unicorn" = "\xf0\x9f\xa6\x84",
|
||||
"honeybee" = "\xf0\x9f\x90\x9d",
|
||||
"bug" = "\xf0\x9f\x90\x9b",
|
||||
"snail" = "\xf0\x9f\x90\x8c",
|
||||
"beetle" = "\xf0\x9f\x90\x9e",
|
||||
"ant" = "\xf0\x9f\x90\x9c",
|
||||
"spider" = "\xf0\x9f\x95\xb7",
|
||||
"scorpion" = "\xf0\x9f\xa6\x82",
|
||||
"turtle" = "\xf0\x9f\x90\xa2",
|
||||
"snake" = "\xf0\x9f\x90\x8d",
|
||||
"octopus" = "\xf0\x9f\x90\x99",
|
||||
"shell" = "\xf0\x9f\x90\x9a",
|
||||
"whale" = "\xf0\x9f\x90\xb3",
|
||||
"dolphin" = "\xf0\x9f\x90\xac",
|
||||
"fish" = "\xf0\x9f\x90\x9f",
|
||||
"tropical_fish" = "\xf0\x9f\x90\xa0",
|
||||
"blowfish" = "\xf0\x9f\x90\xa1",
|
||||
"penguin" = "\xf0\x9f\x90\xa7",
|
||||
"chicken" = "\xf0\x9f\x90\x94",
|
||||
"bird" = "\xf0\x9f\x90\xa6",
|
||||
"eagle" = "\xf0\x9f\xa6\x85",
|
||||
|
||||
// Objects
|
||||
"computer" = "\xf0\x9f\x92\xbb",
|
||||
"iphone" = "\xf0\x9f\x93\xb1",
|
||||
"keyboard" = "\xe2\x8c\xa8\xef\xb8\x8f",
|
||||
"desktop_computer" = "\xf0\x9f\x96\xa5",
|
||||
"printer" = "\xf0\x9f\x96\xa8",
|
||||
"mouse_three_button" = "\xf0\x9f\x96\xb1",
|
||||
"joystick" = "\xf0\x9f\x95\xb9",
|
||||
"stash" = "\xf0\x9f\x92\xbe",
|
||||
"floppy_disk" = "\xf0\x9f\x92\xbe",
|
||||
"cd" = "\xf0\x9f\x92\xbf",
|
||||
"dvd" = "\xf0\x9f\x93\x80",
|
||||
"vhs" = "\xf0\x9f\x93\xbc",
|
||||
"camera" = "\xf0\x9f\x93\xb7",
|
||||
"video_camera" = "\xf0\x9f\x93\xb9",
|
||||
"tv" = "\xf0\x9f\x93\xba",
|
||||
"radio" = "\xf0\x9f\x93\xbb",
|
||||
"pager" = "\xf0\x9f\x93\x9f",
|
||||
"telephone" = "\xe2\x98\x8e\xef\xb8\x8f",
|
||||
"fax" = "\xf0\x9f\x93\xa0",
|
||||
"bulb" = "\xf0\x9f\x92\xa1",
|
||||
"candle" = "\xf0\x9f\x95\xaf",
|
||||
"bathtub" = "\xf0\x9f\x9b\x81",
|
||||
"shower" = "\xf0\x9f\x9a\xbf",
|
||||
"toilet" = "\xf0\x9f\x9a\xbd",
|
||||
"wrench" = "\xf0\x9f\x94\xa7",
|
||||
"hammer" = "\xf0\x9f\x94\xa8",
|
||||
"nut_and_bolt" = "\xf0\x9f\x94\xa9",
|
||||
"gear" = "\xe2\x9a\x99\xef\xb8\x8f",
|
||||
"link" = "\xf0\x9f\x94\x97",
|
||||
"lock" = "\xf0\x9f\x94\x92",
|
||||
"unlock" = "\xf0\x9f\x94\x93",
|
||||
"key" = "\xf0\x9f\x94\x91",
|
||||
"bell" = "\xf0\x9f\x94\x94",
|
||||
"bookmark" = "\xf0\x9f\x94\x96",
|
||||
"pushpin" = "\xf0\x9f\x93\x8c",
|
||||
"paperclip" = "\xf0\x9f\x93\x8e",
|
||||
"memo" = "\xf0\x9f\x93\x9d",
|
||||
"pencil2" = "\xe2\x9c\x8f\xef\xb8\x8f",
|
||||
"black_nib" = "\xe2\x9c\x92\xef\xb8\x8f",
|
||||
"pen" = "\xf0\x9f\x96\x8a",
|
||||
"paintbrush" = "\xf0\x9f\x96\x8c",
|
||||
"crayon" = "\xf0\x9f\x96\x8d",
|
||||
"book" = "\xf0\x9f\x93\x97",
|
||||
"books" = "\xf0\x9f\x93\x9a",
|
||||
"ledger" = "\xf0\x9f\x93\x92",
|
||||
"notebook" = "\xf0\x9f\x93\x93",
|
||||
"scroll" = "\xf0\x9f\x93\x9c",
|
||||
"page_with_curl" = "\xf0\x9f\x93\x84",
|
||||
"newspaper" = "\xf0\x9f\x93\xb0",
|
||||
"chart_with_upwards_trend" = "\xf0\x9f\x93\x88",
|
||||
"chart_with_downwards_trend" = "\xf0\x9f\x93\x89",
|
||||
"bar_chart" = "\xf0\x9f\x93\x8a",
|
||||
"calendar" = "\xf0\x9f\x93\x85",
|
||||
"date" = "\xf0\x9f\x93\x85",
|
||||
"hourglass" = "\xe2\x8f\xb3",
|
||||
"hourglass_flowing_sand" = "\xe2\x8f\xb3",
|
||||
"clock" = "\xf0\x9f\x95\x90",
|
||||
"alarm_clock" = "\xe2\x8f\xb0",
|
||||
"stopwatch" = "\xe2\x8f\xb1",
|
||||
"watch" = "\xe2\x8c\x9a",
|
||||
"moneybag" = "\xf0\x9f\x92\xb0",
|
||||
"yen" = "\xf0\x9f\x92\xb4",
|
||||
"dollar" = "\xf0\x9f\x92\xb5",
|
||||
"euro" = "\xf0\x9f\x92\xb6",
|
||||
"pound" = "\xf0\x9f\x92\xb7",
|
||||
"money_with_wings" = "\xf0\x9f\x92\xb8",
|
||||
"credit_card" = "\xf0\x9f\x92\xb3",
|
||||
"gem" = "\xf0\x9f\x92\x8e",
|
||||
"bomb" = "\xf0\x9f\x92\xa3",
|
||||
"gift" = "\xf0\x9f\x8e\x81",
|
||||
"balloon" = "\xf0\x9f\x8e\x88",
|
||||
"tada" = "\xf0\x9f\x8e\x89",
|
||||
"confetti_ball" = "\xf0\x9f\x8e\x8a",
|
||||
"package" = "\xf0\x9f\x93\xa6",
|
||||
"mailbox" = "\xf0\x9f\x93\xab",
|
||||
"inbox_tray" = "\xf0\x9f\x93\xa5",
|
||||
"outbox_tray" = "\xf0\x9f\x93\xa4",
|
||||
"email" = "\xe2\x9c\x89\xef\xb8\x8f",
|
||||
"envelope" = "\xe2\x9c\x89\xef\xb8\x8f",
|
||||
"incoming_envelope" = "\xf0\x9f\x93\xa8",
|
||||
|
||||
// Activities
|
||||
"soccer" = "\xe2\x9a\xbd",
|
||||
"basketball" = "\xf0\x9f\x8f\x80",
|
||||
"football" = "\xf0\x9f\x8f\x88",
|
||||
"baseball" = "\xe2\x9a\xbe",
|
||||
"tennis" = "\xf0\x9f\x8e\xbe",
|
||||
"8ball" = "\xf0\x9f\x8e\xb1",
|
||||
"bowling" = "\xf0\x9f\x8e\xb3",
|
||||
"video_game" = "\xf0\x9f\x8e\xae",
|
||||
"dart" = "\xf0\x9f\x8e\xaf",
|
||||
"game_die" = "\xf0\x9f\x8e\xb2",
|
||||
"slot_machine" = "\xf0\x9f\x8e\xb0",
|
||||
"cards" = "\xf0\x9f\x83\x8f",
|
||||
"black_joker" = "\xf0\x9f\x83\x9f",
|
||||
"mahjong" = "\xf0\x9f\x80\x84",
|
||||
"musical_note" = "\xf0\x9f\x8e\xb5",
|
||||
"notes" = "\xf0\x9f\x8e\xb6",
|
||||
"saxophone" = "\xf0\x9f\x8e\xb7",
|
||||
"guitar" = "\xf0\x9f\x8e\xb8",
|
||||
"musical_keyboard" = "\xf0\x9f\x8e\xb9",
|
||||
"trumpet" = "\xf0\x9f\x8e\xba",
|
||||
"violin" = "\xf0\x9f\x8e\xbb",
|
||||
"drum" = "\xf0\x9f\xa5\x81",
|
||||
"headphones" = "\xf0\x9f\x8e\xa7",
|
||||
"microphone" = "\xf0\x9f\x8e\xa4",
|
||||
"level_slider" = "\xf0\x9f\x8e\x9a",
|
||||
"control_knobs" = "\xf0\x9f\x8e\x9b",
|
||||
"ticket" = "\xf0\x9f\x8e\xab",
|
||||
"art" = "\xf0\x9f\x8e\xa8",
|
||||
"circus_tent" = "\xf0\x9f\x8e\xaa",
|
||||
"theater_masks" = "\xf0\x9f\x8e\xad",
|
||||
"clapper" = "\xf0\x9f\x8e\xac",
|
||||
|
||||
// Travel
|
||||
"car" = "\xf0\x9f\x9a\x97",
|
||||
"taxi" = "\xf0\x9f\x9a\x95",
|
||||
"bus" = "\xf0\x9f\x9a\x8d",
|
||||
"train" = "\xf0\x9f\x9a\x86",
|
||||
"metro" = "\xf0\x9f\x9a\x87",
|
||||
"light_rail" = "\xf0\x9f\x9a\x88",
|
||||
"tram" = "\xf0\x9f\x9a\x8a",
|
||||
"bike" = "\xf0\x9f\x9a\xb2",
|
||||
"motorcycle" = "\xf0\x9f\x8f\x8d",
|
||||
"airplane" = "\xe2\x9c\x88\xef\xb8\x8f",
|
||||
"helicopter" = "\xf0\x9f\x9a\x81",
|
||||
"boat" = "\xe2\x9b\xb5",
|
||||
"sailboat" = "\xe2\x9b\xb5",
|
||||
"ship" = "\xf0\x9f\x9a\xa2",
|
||||
"fuelpump" = "\xe2\x9b\xbd",
|
||||
"construction" = "\xf0\x9f\x9a\xa7",
|
||||
"house" = "\xf0\x9f\x8f\xa0",
|
||||
"house_with_garden" = "\xf0\x9f\x8f\xa1",
|
||||
"office" = "\xf0\x9f\x8f\xa2",
|
||||
"post_office" = "\xf0\x9f\x8f\xa3",
|
||||
"hospital" = "\xf0\x9f\x8f\xa5",
|
||||
"bank" = "\xf0\x9f\x8f\xa6",
|
||||
"hotel" = "\xf0\x9f\x8f\xa8",
|
||||
"school" = "\xf0\x9f\x8f\xab",
|
||||
"department_store" = "\xf0\x9f\x8f\xac",
|
||||
"church" = "\xe2\x9b\xaa",
|
||||
"castle" = "\xf0\x9f\x8f\xb0",
|
||||
"factory" = "\xf0\x9f\x8f\xad",
|
||||
"tokyo_tower" = "\xf0\x9f\x97\xbc",
|
||||
"statue_of_liberty" = "\xf0\x9f\x97\xbd",
|
||||
"fountain" = "\xe2\x9b\xb2",
|
||||
"tent" = "\xe2\x9b\xba",
|
||||
"mountain" = "\xf0\x9f\x8f\x94",
|
||||
"snow_capped_mountain" = "\xf0\x9f\x8f\x94",
|
||||
"beach" = "\xf0\x9f\x8f\x96",
|
||||
"camping" = "\xf0\x9f\x8f\x95",
|
||||
"world_map" = "\xf0\x9f\x97\xba",
|
||||
"japan" = "\xf0\x9f\x97\xbe",
|
||||
};
|
||||
|
||||
expand_emoji :: proc(text: string) -> string {
|
||||
if len(EMOJIS) == 0 {
|
||||
return text
|
||||
}
|
||||
|
||||
parts: [dynamic]string
|
||||
defer delete(parts)
|
||||
|
||||
remaining := text
|
||||
|
||||
for {
|
||||
colon := strings.index(remaining, ":")
|
||||
if colon < 0 {
|
||||
append(&parts, remaining)
|
||||
break
|
||||
}
|
||||
|
||||
// Find the closing colon
|
||||
after := remaining[colon + 1:]
|
||||
end := strings.index(after, ":")
|
||||
if end < 0 {
|
||||
append(&parts, remaining)
|
||||
break
|
||||
}
|
||||
|
||||
shortcode := remaining[colon + 1 : colon + 1 + end]
|
||||
|
||||
// Validate: shortcode must be all lowercase letters, digits, or underscores
|
||||
// and must not contain whitespace
|
||||
valid := true
|
||||
for c in shortcode {
|
||||
if !(c >= 'a' && c <= 'z') && !(c >= '0' && c <= '9') && c != '_' && c != '+' && c != '-' {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid || len(shortcode) == 0 {
|
||||
append(&parts, remaining[:colon + 1])
|
||||
remaining = remaining[colon + 1:]
|
||||
continue
|
||||
}
|
||||
|
||||
emoji, found := EMOJIS[shortcode]
|
||||
if !found {
|
||||
append(&parts, remaining[:colon + 1])
|
||||
remaining = remaining[colon + 1:]
|
||||
continue
|
||||
}
|
||||
|
||||
// Replace :shortcode: with emoji
|
||||
append(&parts, remaining[:colon])
|
||||
append(&parts, emoji)
|
||||
remaining = remaining[colon + 1 + end + 1:]
|
||||
}
|
||||
|
||||
return strings.join(parts[:], "")
|
||||
}
|
||||
@@ -2,19 +2,13 @@ package main
|
||||
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
import "core:time"
|
||||
|
||||
WEEKDAYS: [7]string = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
|
||||
generate_rss :: proc(site: ^Site) -> string {
|
||||
sb := strings.builder_make()
|
||||
|
||||
generate_rss :: proc(
|
||||
pages: []Page,
|
||||
site_title: string,
|
||||
site_desc: string,
|
||||
base_url: string,
|
||||
) -> string {
|
||||
parts: [dynamic]string
|
||||
defer delete(parts)
|
||||
|
||||
append(&parts, fmt.aprintf(
|
||||
fmt.sbprintf(
|
||||
&sb,
|
||||
`<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
@@ -23,14 +17,14 @@ generate_rss :: proc(
|
||||
<description>%s</description>
|
||||
<language>en-us</language>
|
||||
<atom:link href="%s/index.xml" rel="self" type="application/rss+xml"/>`,
|
||||
xml_escape(site_title),
|
||||
base_url,
|
||||
xml_escape(site_desc),
|
||||
base_url,
|
||||
))
|
||||
xml_escape(site.title),
|
||||
site.base_url,
|
||||
xml_escape(site.description),
|
||||
site.base_url,
|
||||
)
|
||||
|
||||
for page in pages {
|
||||
if page.type == .Home {
|
||||
for page in site.pages {
|
||||
if page.section == "" && page._is_index {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -39,105 +33,106 @@ generate_rss :: proc(
|
||||
pub_date = format_rfc822(page.date)
|
||||
}
|
||||
|
||||
append(&parts, fmt.aprintf(
|
||||
fmt.sbprintf(
|
||||
&sb,
|
||||
`<item>
|
||||
<title>%s</title>
|
||||
<link>%s%s</link>
|
||||
<link>%s</link>
|
||||
<pubDate>%s</pubDate>
|
||||
<guid>%s%s</guid>
|
||||
<guid>%s</guid>
|
||||
<description>%s</description>
|
||||
</item>
|
||||
`,
|
||||
xml_escape(page.title),
|
||||
base_url,
|
||||
page.permalink,
|
||||
page.url,
|
||||
pub_date,
|
||||
base_url,
|
||||
page.permalink,
|
||||
xml_escape(page.body_html),
|
||||
))
|
||||
page.url,
|
||||
xml_escape(page.content),
|
||||
)
|
||||
}
|
||||
|
||||
append(&parts, "</channel>\n</rss>")
|
||||
|
||||
return strings.join(parts[:], "")
|
||||
strings.write_string(&sb, "</channel>\n</rss>")
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
generate_sitemap :: proc(pages: []Page, base_url: string) -> string {
|
||||
parts: [dynamic]string
|
||||
defer delete(parts)
|
||||
generate_sitemap :: proc(site: ^Site) -> string {
|
||||
sb := strings.builder_make()
|
||||
|
||||
append(
|
||||
&parts,
|
||||
strings.write_string(
|
||||
&sb,
|
||||
`<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||
`,
|
||||
)
|
||||
|
||||
for page in pages {
|
||||
lastmod := ""
|
||||
for page in site.pages {
|
||||
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)
|
||||
}
|
||||
append(&parts, fmt.aprintf(
|
||||
"<url><loc>%s%s</loc>%s</url>\n",
|
||||
base_url,
|
||||
page.permalink,
|
||||
lastmod,
|
||||
))
|
||||
fmt.sbprintf(&sb, "</url>\n")
|
||||
}
|
||||
|
||||
// Posts list page
|
||||
posts_lastmod := ""
|
||||
for page in pages {
|
||||
if page.type == .Post && page.date > posts_lastmod {
|
||||
posts_lastmod = page.date
|
||||
// Section index pages (for sections without an index in content)
|
||||
sections := make(map[string]bool)
|
||||
defer delete(sections)
|
||||
for page in site.pages {
|
||||
if page.section != "" && !page._is_index {
|
||||
sections[page.section] = true
|
||||
}
|
||||
}
|
||||
posts_lm := ""
|
||||
if posts_lastmod != "" {
|
||||
posts_lm = fmt.aprintf("<lastmod>%s</lastmod>", posts_lastmod)
|
||||
for section in sections {
|
||||
has_index := false
|
||||
for page in site.pages {
|
||||
if page.section == section && page._is_index {
|
||||
has_index = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if has_index {
|
||||
continue
|
||||
}
|
||||
|
||||
section_lastmod := ""
|
||||
for page in site.pages {
|
||||
if page.section == section && !page._is_index && page.date > section_lastmod {
|
||||
section_lastmod = page.date
|
||||
}
|
||||
}
|
||||
fmt.sbprintf(&sb, "<url><loc>%s/%s/</loc>", site.base_url, section)
|
||||
if section_lastmod != "" {
|
||||
fmt.sbprintf(&sb, "<lastmod>%s</lastmod>", section_lastmod)
|
||||
}
|
||||
fmt.sbprintf(&sb, "</url>\n")
|
||||
}
|
||||
append(&parts, fmt.aprintf("<url><loc>%s/posts/</loc>%s</url>\n", base_url, posts_lm))
|
||||
|
||||
append(&parts, "</urlset>")
|
||||
|
||||
return strings.join(parts[:], "")
|
||||
strings.write_string(&sb, "</urlset>")
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
// TODO: Leaks
|
||||
format_rfc822 :: proc(iso: string) -> string {
|
||||
if len(iso) < 19 {
|
||||
// TODO: should indicate error somehow
|
||||
return iso
|
||||
}
|
||||
|
||||
year := (int(iso[0]) - 0x30) * 1000 + (int(iso[1]) - 0x30) * 100 +
|
||||
(int(iso[2]) - 0x30) * 10 + (int(iso[3]) - 0x30)
|
||||
month := (int(iso[5]) - 0x30) * 10 + (int(iso[6]) - 0x30)
|
||||
day := (int(iso[8]) - 0x30) * 10 + (int(iso[9]) - 0x30)
|
||||
time := iso[11:19]
|
||||
date, offset, _ := time.iso8601_to_time_and_offset(iso)
|
||||
|
||||
// Timezone: -04:00 → -0400
|
||||
tz := "+0000"
|
||||
if len(iso) >= 25 && (iso[19] == '+' || iso[19] == '-') {
|
||||
tz = fmt.tprintf("%c%s%s", iso[19], iso[20:22], iso[23:25])
|
||||
}
|
||||
|
||||
// Sakamoto's method for day of week (0=Sunday)
|
||||
t := [12]int{0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4}
|
||||
y := year
|
||||
if month < 3 {
|
||||
y -= 1
|
||||
}
|
||||
dow := (y + y / 4 - y / 100 + y / 400 + t[month - 1] + day) % 7
|
||||
weekday := fmt.tprintf("%s", time.weekday(date))
|
||||
month := fmt.tprintf("%s", time.month(date))
|
||||
buf: [8]byte
|
||||
t := time.to_string_hms(date, buf[:])
|
||||
|
||||
return fmt.aprintf(
|
||||
"%s, %d %s %d %s %s",
|
||||
WEEKDAYS[dow],
|
||||
day,
|
||||
MONTHS[month - 1],
|
||||
year,
|
||||
time,
|
||||
tz,
|
||||
"%s, %02d %s %d %s %3d%2d",
|
||||
weekday[:3],
|
||||
time.day(date),
|
||||
month[:3],
|
||||
time.year(date),
|
||||
t,
|
||||
offset / 60,
|
||||
offset % 60,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -51,11 +51,11 @@
|
||||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1783475452,
|
||||
"narHash": "sha256-3S92fSuv32mjJz2Qb2F4405c21J55F4NIphz5K6hxco=",
|
||||
"lastModified": 1784525419,
|
||||
"narHash": "sha256-yocJ4I4Kd4as0UPMFs9P7laPvvA2x/Bj8EW46iW3VVM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "05988b07fb05cbcb50be6bce197b4b5f75b5e61b",
|
||||
"rev": "a16c3fde2ffeab7f6326f50f460aaffde7ae066d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -12,12 +12,10 @@
|
||||
};
|
||||
|
||||
outputs =
|
||||
inputs@{ self
|
||||
, flake-parts
|
||||
inputs@{ flake-parts
|
||||
, nixpkgs
|
||||
, nixpkgs-unstable
|
||||
# , process-compose-flake
|
||||
, treefmt-nix
|
||||
, ...
|
||||
# , process-compose-flake
|
||||
}:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
imports = [
|
||||
@@ -27,13 +25,49 @@
|
||||
systems = [ "x86_64-linux" ];
|
||||
|
||||
perSystem =
|
||||
{ pkgs, system, inputs', ... }: {
|
||||
{ pkgs
|
||||
, system
|
||||
, inputs'
|
||||
, ...
|
||||
}:
|
||||
let
|
||||
mkGrammarStaticLib = name: src: pkgs.stdenv.mkDerivation {
|
||||
inherit name src;
|
||||
dontConfigure = true;
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
if [ -f src/scanner.cc ]; then
|
||||
$CXX -fPIC -c src/scanner.cc -o scanner.o
|
||||
elif [ -f src/scanner.c ]; then
|
||||
$CC -fPIC -c src/scanner.c -o scanner.o
|
||||
fi
|
||||
$CC -fPIC -c src/parser.c -o parser.o
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out/lib
|
||||
ar rcs $out/lib/lib${name}.a *.o
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
|
||||
html-grammar = mkGrammarStaticLib "tree-sitter-html"
|
||||
pkgs.tree-sitter-grammars.tree-sitter-html.src;
|
||||
css-grammar = mkGrammarStaticLib "tree-sitter-css"
|
||||
pkgs.tree-sitter-grammars.tree-sitter-css.src;
|
||||
odin = pkgs.unstable.odin;
|
||||
ols = pkgs.unstable.ols;
|
||||
in
|
||||
{
|
||||
_module.args.pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
config.allowUnfree = true;
|
||||
|
||||
overlays = [
|
||||
(final: prev: { unstable = inputs'.nixpkgs-unstable.legacyPackages; })
|
||||
(_final: _prev: { unstable = inputs'.nixpkgs-unstable.legacyPackages; })
|
||||
];
|
||||
};
|
||||
|
||||
@@ -48,22 +82,31 @@
|
||||
".env.local"
|
||||
];
|
||||
|
||||
|
||||
# Format nix files
|
||||
programs.nixpkgs-fmt.enable = true;
|
||||
programs.deadnix.enable = true;
|
||||
|
||||
# Format js, json, and yaml files
|
||||
programs.prettier.enable = true;
|
||||
settings.formatter.prettier =
|
||||
{
|
||||
excludes = [
|
||||
"public/**"
|
||||
"resources/js/modernizr.js"
|
||||
"storage/app/caniuse.json"
|
||||
"*.md"
|
||||
];
|
||||
};
|
||||
settings.formatter.prettier = {
|
||||
excludes = [
|
||||
"public/**"
|
||||
"mustache/spec/specs/**"
|
||||
"*.html"
|
||||
"*.md"
|
||||
];
|
||||
};
|
||||
|
||||
settings.formatter.ols = {
|
||||
command = "${pkgs.bash}/bin/bash";
|
||||
options = [
|
||||
"-euc"
|
||||
''
|
||||
${pkgs.ols}/bin/odinfmt -w .
|
||||
''
|
||||
];
|
||||
includes = [ "*.odin" ];
|
||||
};
|
||||
};
|
||||
|
||||
#process-compose.default.settings.processes = { };
|
||||
@@ -74,15 +117,21 @@
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkgs.odin
|
||||
odin
|
||||
pkgs.pkg-config
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
pkgs.git
|
||||
pkgs.cmark
|
||||
pkgs.tree-sitter
|
||||
pkgs.tzdata
|
||||
html-grammar
|
||||
css-grammar
|
||||
];
|
||||
|
||||
LIBRARY_PATH = "${html-grammar}/lib:${css-grammar}/lib";
|
||||
|
||||
doCheck = true;
|
||||
checkPhase = ''
|
||||
runHook preCheck
|
||||
@@ -92,9 +141,7 @@
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
odin build . -o:speed -out:${pname}-keep
|
||||
echo "Listing filles..."
|
||||
ls .
|
||||
odin build . -o:speed -no-bounds-check -out:${pname}-keep
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
@@ -105,19 +152,24 @@
|
||||
'';
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell
|
||||
{
|
||||
buildInputs = with pkgs; [
|
||||
odin
|
||||
ols
|
||||
cmark
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = [ odin ols ] ++ (with pkgs; [
|
||||
cmark
|
||||
tree-sitter
|
||||
|
||||
# IDE
|
||||
unstable.helix
|
||||
typescript-language-server
|
||||
vscode-langservers-extracted
|
||||
];
|
||||
};
|
||||
gdb
|
||||
perf
|
||||
|
||||
# IDE
|
||||
unstable.helix
|
||||
typescript-language-server
|
||||
vscode-langservers-extracted
|
||||
]);
|
||||
|
||||
shellHook = ''
|
||||
export LIBRARY_PATH="${html-grammar}/lib:${css-grammar}/lib:$LIBRARY_PATH"
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
-160
@@ -1,160 +0,0 @@
|
||||
package main
|
||||
|
||||
import cm "vendor:commonmark"
|
||||
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
|
||||
// strip_definitions scans markdown text for footnote definitions ([^id]: text),
|
||||
// removes them, and returns the cleaned text plus a map of id→definition.
|
||||
// Handles multi-line definitions with indented continuation lines.
|
||||
strip_definitions :: proc(body: string) -> (clean_body: string, defs: map[string]string) {
|
||||
defs = make(map[string]string)
|
||||
|
||||
lines := strings.split(body, "\n")
|
||||
output_lines: [dynamic]string
|
||||
defer delete(output_lines)
|
||||
|
||||
i := 0
|
||||
for i < len(lines) {
|
||||
line := lines[i]
|
||||
|
||||
id, def_text, is_def := parse_def_line(line)
|
||||
if !is_def {
|
||||
append(&output_lines, line)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Collect definition text (initial line + multi-line continuations)
|
||||
def_parts: [dynamic]string
|
||||
if def_text != "" {
|
||||
append(&def_parts, def_text)
|
||||
}
|
||||
|
||||
i += 1
|
||||
for i < len(lines) {
|
||||
next := lines[i]
|
||||
// Stop at blank lines
|
||||
if len(next) == 0 {
|
||||
break
|
||||
}
|
||||
// Stop at new footnote definitions
|
||||
_, _, is_new_def := parse_def_line(next)
|
||||
if is_new_def {
|
||||
break
|
||||
}
|
||||
// Include as continuation (trim indented lines)
|
||||
if is_indented(next) {
|
||||
append(&def_parts, strings.trim_left(next, " \t"))
|
||||
} else {
|
||||
append(&def_parts, next)
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
defs[id] = strings.join(def_parts[:], "\n")
|
||||
delete(def_parts)
|
||||
}
|
||||
|
||||
clean_body = strings.join(output_lines[:], "\n")
|
||||
return
|
||||
}
|
||||
|
||||
// parse_def_line checks if a line is a footnote definition: [^id]: text
|
||||
parse_def_line :: proc(line: string) -> (id: string, text: string, ok: bool) {
|
||||
if len(line) < 5 || line[0] != '[' || line[1] != '^' {
|
||||
return
|
||||
}
|
||||
|
||||
close := strings.index(line[2:], "]")
|
||||
if close < 0 {
|
||||
return
|
||||
}
|
||||
close += 2
|
||||
|
||||
if close + 1 >= len(line) || line[close + 1] != ':' {
|
||||
return
|
||||
}
|
||||
|
||||
id = line[2:close]
|
||||
text = strings.trim_left(line[close + 2:], " \t")
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
is_indented :: proc(line: string) -> bool {
|
||||
if len(line) == 0 {
|
||||
return false
|
||||
}
|
||||
return line[0] == ' ' || line[0] == '\t'
|
||||
}
|
||||
|
||||
// inject_sidenotes finds [^id] references in rendered HTML and replaces them
|
||||
// with sidenote markup. Each definition is rendered through cmark separately.
|
||||
inject_sidenotes :: proc(html: string, defs: map[string]string) -> string {
|
||||
if len(defs) == 0 {
|
||||
return html
|
||||
}
|
||||
|
||||
parts: [dynamic]string
|
||||
defer delete(parts)
|
||||
|
||||
remaining := html
|
||||
|
||||
for {
|
||||
pos := strings.index(remaining, "[^")
|
||||
if pos < 0 {
|
||||
append(&parts, remaining)
|
||||
break
|
||||
}
|
||||
|
||||
// Append text before [^
|
||||
append(&parts, remaining[:pos])
|
||||
|
||||
close := strings.index(remaining[pos + 2:], "]")
|
||||
if close < 0 {
|
||||
append(&parts, remaining[pos:])
|
||||
break
|
||||
}
|
||||
|
||||
id := remaining[pos + 2 : pos + 2 + close]
|
||||
ref_end := pos + 2 + close + 1
|
||||
|
||||
def_text, found := defs[id]
|
||||
if !found {
|
||||
// No definition found, leave as literal text
|
||||
append(&parts, remaining[pos:ref_end])
|
||||
remaining = remaining[ref_end:]
|
||||
continue
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
sidenote := fmt.aprintf(
|
||||
`<label for="fn-%s" class="margin-toggle sidenote-number"></label><input type="checkbox" id="fn-%s" class="margin-toggle"><span class="sidenote">%s</span>`,
|
||||
id,
|
||||
id,
|
||||
def_html,
|
||||
)
|
||||
append(&parts, sidenote)
|
||||
|
||||
remaining = remaining[ref_end:]
|
||||
}
|
||||
|
||||
return strings.join(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]
|
||||
}
|
||||
if strings.has_prefix(s, "<p>") && strings.has_suffix(s, "</p>") {
|
||||
return s[3:len(s) - 4]
|
||||
}
|
||||
return s
|
||||
}
|
||||
+54
-9
@@ -1,17 +1,22 @@
|
||||
package main
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:strings"
|
||||
|
||||
Frontmatter :: struct {
|
||||
title: string,
|
||||
description: string,
|
||||
date: string,
|
||||
lastmod: string,
|
||||
publishDate: string,
|
||||
weight: Maybe(int),
|
||||
menus: json.Value,
|
||||
params: json.Value,
|
||||
layout: string,
|
||||
og: Open_Graph,
|
||||
draft: bool,
|
||||
isStarred: bool,
|
||||
menu: string,
|
||||
toc: bool,
|
||||
}
|
||||
|
||||
// parse_frontmatter splits raw file content into a Frontmatter struct and the
|
||||
@@ -33,9 +38,9 @@ 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 {
|
||||
fmt.eprintfln("thor: failed to parse frontmatter JSON: %v", err)
|
||||
log.errorf("failed to parse frontmatter JSON: %v", err)
|
||||
return
|
||||
}
|
||||
defer json.destroy_value(value)
|
||||
@@ -45,13 +50,20 @@ parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok
|
||||
return
|
||||
}
|
||||
|
||||
fm.title = json_get_string(obj, "title")
|
||||
fm.title = json_get_string(obj, "title")
|
||||
fm.description = json_get_string(obj, "description")
|
||||
fm.date = json_get_string(obj, "date")
|
||||
fm.date = json_get_string(obj, "date")
|
||||
fm.lastmod = json_get_string(obj, "lastmod")
|
||||
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.menu = json_get_string(obj, "menu")
|
||||
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
|
||||
@@ -75,3 +87,36 @@ json_get_bool :: proc(obj: json.Object, key: string) -> bool {
|
||||
return false
|
||||
}
|
||||
|
||||
json_get_int :: proc(obj: json.Object, key: string) -> Maybe(int) {
|
||||
if v, ok := obj[key]; ok {
|
||||
switch val in v {
|
||||
case json.Integer:
|
||||
return int(val)
|
||||
case json.Float:
|
||||
return int(val)
|
||||
case json.Boolean, json.String, json.Array, json.Object, json.Null:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
json_get_open_graph :: proc(obj: json.Object, key: string) -> Open_Graph {
|
||||
og: Open_Graph
|
||||
if v, ok := obj[key]; ok {
|
||||
if inner, ok2 := v.(json.Object); ok2 {
|
||||
og.title = json_get_string(inner, "title")
|
||||
og.type = json_get_string(inner, "type")
|
||||
og.image = json_get_string(inner, "image")
|
||||
og.url = json_get_string(inner, "url")
|
||||
og.description = json_get_string(inner, "description")
|
||||
og.locale = json_get_string(inner, "locale")
|
||||
og.site_name = json_get_string(inner, "site_name")
|
||||
og.published_time = json_get_string(inner, "published_time")
|
||||
og.modified_time = json_get_string(inner, "modified_time")
|
||||
og.section = json_get_string(inner, "section")
|
||||
}
|
||||
}
|
||||
return og
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package main
|
||||
|
||||
import "core:strings"
|
||||
|
||||
strip_html_tags :: proc(s: string, allocator := context.allocator) -> string {
|
||||
sb := strings.builder_make(allocator)
|
||||
defer strings.builder_destroy(&sb)
|
||||
|
||||
in_tag := false
|
||||
start := 0
|
||||
for i in 0 ..< len(s) {
|
||||
if s[i] == '<' && !in_tag {
|
||||
if i > start {
|
||||
strings.write_string(&sb, s[start:i])
|
||||
}
|
||||
in_tag = true
|
||||
} else if s[i] == '>' && in_tag {
|
||||
in_tag = false
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start == 0 {
|
||||
return s
|
||||
}
|
||||
if !in_tag && start < len(s) {
|
||||
strings.write_string(&sb, s[start:])
|
||||
}
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
unescape_html :: proc(s: string) -> string {
|
||||
sb := strings.builder_make_len_cap(0, len(s))
|
||||
defer strings.builder_destroy(&sb)
|
||||
|
||||
start := 0
|
||||
for i in 0 ..< len(s) {
|
||||
if s[i] != '&' {
|
||||
continue
|
||||
}
|
||||
semi := strings.index(s[i:], ";")
|
||||
if semi < 0 {
|
||||
break
|
||||
}
|
||||
entity := s[i:i + semi + 1]
|
||||
replacement := ""
|
||||
switch entity {
|
||||
case "&":
|
||||
replacement = "&"
|
||||
case "<":
|
||||
replacement = "<"
|
||||
case ">":
|
||||
replacement = ">"
|
||||
case """:
|
||||
replacement = "\""
|
||||
case "'", "'":
|
||||
replacement = "'"
|
||||
case:
|
||||
continue
|
||||
}
|
||||
if i > start {
|
||||
strings.write_string(&sb, s[start:i])
|
||||
}
|
||||
strings.write_string(&sb, replacement)
|
||||
start = i + semi + 1
|
||||
}
|
||||
if start == 0 {
|
||||
return s
|
||||
}
|
||||
if start < len(s) {
|
||||
strings.write_string(&sb, s[start:])
|
||||
}
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
// generate_summary truncates an HTML string to the first max_words words.
|
||||
// Walks forward counting whitespace→text transitions, skipping tag interiors
|
||||
// so spaces inside attributes don't count. Returns a substring of the
|
||||
// original — zero allocation. Mirrors Hugo's default (70 words).
|
||||
generate_summary :: proc(html: string, max_words: int = 70) -> string {
|
||||
if max_words <= 0 {
|
||||
return ""
|
||||
}
|
||||
word_count := 0
|
||||
in_word := false
|
||||
in_tag := false
|
||||
for i in 0 ..< len(html) {
|
||||
c := html[i]
|
||||
if in_tag {
|
||||
if c == '>' {
|
||||
in_tag = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if c == '<' {
|
||||
in_tag = true
|
||||
if in_word {
|
||||
word_count += 1
|
||||
if word_count >= max_words {
|
||||
return html[:i]
|
||||
}
|
||||
in_word = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
is_space := c == ' ' || c == '\n' || c == '\t' || c == '\r'
|
||||
if is_space {
|
||||
if in_word {
|
||||
word_count += 1
|
||||
if word_count >= max_words {
|
||||
return html[:i]
|
||||
}
|
||||
in_word = false
|
||||
}
|
||||
} else {
|
||||
in_word = true
|
||||
}
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
// generate_description converts an HTML fragment to plain text by stripping
|
||||
// tags, decoding entities, and collapsing whitespace. Emits a space when
|
||||
// exiting any tag so block-level boundaries aren't lost. Intended for OG
|
||||
// descriptions — operate on the output of generate_summary for bounded input.
|
||||
generate_description :: proc(html: string, allocator := context.temp_allocator) -> string {
|
||||
sb := strings.builder_make_len_cap(0, len(html), allocator)
|
||||
defer strings.builder_destroy(&sb)
|
||||
|
||||
in_tag := false
|
||||
prev_was_space := true
|
||||
run_start := 0
|
||||
i := 0
|
||||
for i < len(html) {
|
||||
c := html[i]
|
||||
if in_tag {
|
||||
if c == '>' {
|
||||
in_tag = false
|
||||
if !prev_was_space {
|
||||
strings.write_byte(&sb, ' ')
|
||||
prev_was_space = true
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
run_start = i
|
||||
continue
|
||||
}
|
||||
if c == '<' {
|
||||
if i > run_start {
|
||||
strings.write_string(&sb, html[run_start:i])
|
||||
prev_was_space = false
|
||||
}
|
||||
in_tag = true
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if c == '&' {
|
||||
if i > run_start {
|
||||
strings.write_string(&sb, html[run_start:i])
|
||||
prev_was_space = false
|
||||
}
|
||||
semi := strings.index(html[i:], ";")
|
||||
if semi > 0 && semi <= 5 {
|
||||
entity := html[i:i + semi + 1]
|
||||
replacement := ""
|
||||
switch entity {
|
||||
case "&":
|
||||
replacement = "&"
|
||||
case "<":
|
||||
replacement = "<"
|
||||
case ">":
|
||||
replacement = ">"
|
||||
case """:
|
||||
replacement = "\""
|
||||
case "'", "'":
|
||||
replacement = "'"
|
||||
case:
|
||||
replacement = ""
|
||||
}
|
||||
if replacement != "" {
|
||||
strings.write_string(&sb, replacement)
|
||||
prev_was_space = false
|
||||
i += semi + 1
|
||||
run_start = i
|
||||
continue
|
||||
}
|
||||
}
|
||||
strings.write_byte(&sb, '&')
|
||||
prev_was_space = false
|
||||
i += 1
|
||||
run_start = i
|
||||
continue
|
||||
}
|
||||
if c == ' ' || c == '\n' || c == '\t' || c == '\r' {
|
||||
if i > run_start {
|
||||
strings.write_string(&sb, html[run_start:i])
|
||||
prev_was_space = false
|
||||
}
|
||||
if !prev_was_space {
|
||||
strings.write_byte(&sb, ' ')
|
||||
prev_was_space = true
|
||||
}
|
||||
i += 1
|
||||
run_start = i
|
||||
continue
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
if i > run_start && !in_tag {
|
||||
strings.write_string(&sb, html[run_start:i])
|
||||
}
|
||||
|
||||
result := strings.to_string(sb)
|
||||
if len(result) > 0 && result[len(result) - 1] == ' ' {
|
||||
result = result[:len(result) - 1]
|
||||
}
|
||||
return result
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
#+test
|
||||
package main
|
||||
|
||||
import "core:testing"
|
||||
|
||||
// --- generate_summary ---
|
||||
|
||||
@(test)
|
||||
test_summary_short :: proc(t: ^testing.T) {
|
||||
result := generate_summary("<p>Hello world</p>")
|
||||
testing.expect_value(t, result, "<p>Hello world</p>")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_summary_word_limit :: proc(t: ^testing.T) {
|
||||
result := generate_summary("<p>one two three four five</p>", max_words = 3)
|
||||
testing.expect_value(t, result, "<p>one two three")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_summary_empty :: proc(t: ^testing.T) {
|
||||
result := generate_summary("")
|
||||
testing.expect_value(t, result, "")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_summary_no_words :: proc(t: ^testing.T) {
|
||||
result := generate_summary("<p></p>")
|
||||
testing.expect_value(t, result, "<p></p>")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_summary_tags_not_counted :: proc(t: ^testing.T) {
|
||||
html := `<pre><code><span class="hl-keyword">if</span> x</code></pre>`
|
||||
result := generate_summary(html, max_words = 1)
|
||||
testing.expect_value(t, result, `<pre><code><span class="hl-keyword">if`)
|
||||
}
|
||||
|
||||
// --- generate_description ---
|
||||
|
||||
@(test)
|
||||
test_description_simple :: proc(t: ^testing.T) {
|
||||
result := generate_description("<p>Hello world</p>")
|
||||
testing.expect_value(t, result, "Hello world")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_description_entities :: proc(t: ^testing.T) {
|
||||
result := generate_description("<p>Cats & dogs <3</p>")
|
||||
testing.expect_value(t, result, "Cats & dogs <3")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_description_nested_tags :: proc(t: ^testing.T) {
|
||||
result := generate_description("<p><strong>Bold</strong> text</p>")
|
||||
testing.expect_value(t, result, "Bold text")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_description_block_boundary :: proc(t: ^testing.T) {
|
||||
result := generate_description("<p>First</p><p>Second</p>")
|
||||
testing.expect_value(t, result, "First Second")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_description_whitespace_collapse :: proc(t: ^testing.T) {
|
||||
result := generate_description("<p> Multiple spaces </p>")
|
||||
testing.expect_value(t, result, "Multiple spaces")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_description_empty :: proc(t: ^testing.T) {
|
||||
result := generate_description("")
|
||||
testing.expect_value(t, result, "")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_description_plain_text :: proc(t: ^testing.T) {
|
||||
result := generate_description("Just plain text")
|
||||
testing.expect_value(t, result, "Just plain text")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_description_highlighted_code :: proc(t: ^testing.T) {
|
||||
result := generate_description(`<pre><code><span class="hl-keyword">if</span> x</code></pre>`)
|
||||
testing.expect_value(t, result, "if x")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_description_list_items :: proc(t: ^testing.T) {
|
||||
result := generate_description("<ul><li>One</li><li>Two</li></ul>")
|
||||
testing.expect_value(t, result, "One Two")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_description_headings :: proc(t: ^testing.T) {
|
||||
result := generate_description("<h1>Title</h1><p>Body</p>")
|
||||
testing.expect_value(t, result, "Title Body")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_description_blockquote :: proc(t: ^testing.T) {
|
||||
result := generate_description("<blockquote>Quote</blockquote>")
|
||||
testing.expect_value(t, result, "Quote")
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
package main
|
||||
|
||||
ICON_HOME :: `<svg aria-hidden="true" class="hi-svg-inline h-6 w-6" fill="none" height="1em" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="1em"><path d="m3 9 9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>`
|
||||
|
||||
ICON_GITHUB :: `<svg aria-hidden="true" class="hi-svg-inline h-7 w-7" fill="none" height="1em" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="1em"><path d="M15 22v-4a4.8 4.8.0 00-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35.0-3.5.0.0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35.0 3.5A5.403 5.403.0 004 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></svg>`
|
||||
|
||||
ICON_RSS :: `<svg aria-hidden="true" class="hi-svg-inline h-7 w-7" fill="none" height="1em" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="1em"><path d="M4 11a9 9 0 019 9"/><path d="M4 4a16 16 0 0116 16"/><circle cx="5" cy="19" r="1"/></svg>`
|
||||
|
||||
ICON_CHEVRON_UP :: `<svg aria-hidden="true" class="hi-svg-inline h-10 w-10" fill="none" height="1em" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="1em"><path d="m18 15-6-6-6 6"/></svg>`
|
||||
|
||||
ICON_STAR :: `<svg aria-hidden="true" class="hi-svg-inline h-3.5 w-3.5 text-yellow-500 align-baseline mr-2" fill="currentColor" height="1em" viewBox="0 0 16 16" width="1em"><path d="M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z"/></svg>`
|
||||
@@ -1,72 +1,120 @@
|
||||
package main
|
||||
|
||||
import "base:runtime"
|
||||
import "core:flags"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:mem"
|
||||
import "core:os"
|
||||
import "core:prof/spall"
|
||||
import "core:sync"
|
||||
import "core:time"
|
||||
|
||||
Options :: struct {
|
||||
base_url: string `args:"name=base-url" usage:"hostname (and path) to the root, e.g. https://example.com/"`,
|
||||
content_dir: string `args:"name=content" usage:"where to look for content files"`,
|
||||
output_dir: string `args:"name=output" usage:"where to export the completed site"`,
|
||||
drafts: bool `args:"name=drafts" usage:"Whether to render draft posts"`,
|
||||
import "treesitter"
|
||||
|
||||
SPALL :: #config(SPALL, false)
|
||||
|
||||
when SPALL {
|
||||
spall_ctx: spall.Context
|
||||
@(thread_local)
|
||||
spall_buffer: spall.Buffer
|
||||
|
||||
init_spall_for_thread :: proc() {
|
||||
backing := make([]u8, spall.BUFFER_DEFAULT_SIZE, context.temp_allocator)
|
||||
spall_buffer = spall.buffer_create(backing, u32(sync.current_thread_id()))
|
||||
}
|
||||
|
||||
cleanup_spall_for_thread :: proc() {
|
||||
spall.buffer_destroy(&spall_ctx, &spall_buffer)
|
||||
}
|
||||
}
|
||||
|
||||
main :: proc() {
|
||||
opt: Options
|
||||
when SPALL {
|
||||
ctx, ok := spall.context_create_with_scale("thor.spall", false, 1.0)
|
||||
if !ok {
|
||||
log.fatal("Failed to create spall trace file")
|
||||
os.exit(1)
|
||||
}
|
||||
spall_ctx = ctx
|
||||
defer spall.context_destroy(&spall_ctx)
|
||||
|
||||
flags.parse_or_exit(&opt, os.args, .Odin)
|
||||
spall_backing := make([]u8, spall.BUFFER_DEFAULT_SIZE)
|
||||
defer delete(spall_backing)
|
||||
|
||||
load_default_options(&opt)
|
||||
|
||||
pages := walk_content(opt.content_dir, opt.drafts)
|
||||
|
||||
render_site(pages, opt.content_dir, opt.output_dir, opt.base_url)
|
||||
}
|
||||
|
||||
load_default_options :: proc(opt: ^Options) {
|
||||
if opt.content_dir == "" {
|
||||
opt.content_dir = "./content"
|
||||
spall_buffer = spall.buffer_create(spall_backing, u32(sync.current_thread_id()))
|
||||
defer spall.buffer_destroy(&spall_ctx, &spall_buffer)
|
||||
}
|
||||
|
||||
if opt.output_dir == "" {
|
||||
opt.output_dir = "./public"
|
||||
cli_flags: Flags
|
||||
flags.parse_or_exit(&cli_flags, os.args, .Odin)
|
||||
|
||||
level: log.Level
|
||||
switch {
|
||||
case cli_flags.quiet:
|
||||
level = .Warning
|
||||
case cli_flags.verbose:
|
||||
level = .Debug
|
||||
case:
|
||||
level = .Info
|
||||
}
|
||||
logger_opts: log.Options =
|
||||
(log.Default_Console_Logger_Opts - log.Full_Timestamp_Opts - {.Short_File_Path})
|
||||
console_logger := log.create_console_logger(level, logger_opts)
|
||||
context.logger = console_logger
|
||||
defer log.destroy_console_logger(console_logger)
|
||||
|
||||
treesitter.init_persistent()
|
||||
|
||||
when SPALL {
|
||||
treesitter.set_thread_callbacks(init_spall_for_thread, cleanup_spall_for_thread)
|
||||
}
|
||||
|
||||
if opt.base_url == "" {
|
||||
opt.base_url = "http://localhost:8080"
|
||||
}
|
||||
}
|
||||
|
||||
print_summary :: proc(pages: []Page) {
|
||||
fmt.printfln("Pages: %d\n", len(pages))
|
||||
|
||||
for page in pages {
|
||||
type_label := "post"
|
||||
if page.type == .Standalone {
|
||||
type_label = "standalone"
|
||||
}
|
||||
|
||||
date_short := page.date
|
||||
if len(date_short) > 10 {
|
||||
date_short = date_short[:10]
|
||||
}
|
||||
|
||||
badge := ""
|
||||
if page.draft {
|
||||
badge = " (draft)"
|
||||
} else if page.is_starred {
|
||||
badge = " *"
|
||||
}
|
||||
|
||||
fmt.printfln(
|
||||
" [%-11s] %-30s %s %s%s (%d bytes html)",
|
||||
type_label,
|
||||
page.title,
|
||||
date_short,
|
||||
page.permalink,
|
||||
badge,
|
||||
len(page.body_html),
|
||||
for {
|
||||
defer free_all(context.temp_allocator)
|
||||
defer log.debugf(
|
||||
"max_temp_allocator_size=%M",
|
||||
(cast(^runtime.Default_Temp_Allocator)context.temp_allocator.data)^.arena.total_used,
|
||||
)
|
||||
tick := time.tick_now()
|
||||
site: Site
|
||||
init_site(&site, cli_flags)
|
||||
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
|
||||
treesitter.query_dir = site.queries
|
||||
|
||||
site_load_content(&site)
|
||||
render_site(&site)
|
||||
log.infof("Built site in %M", time.tick_since(tick))
|
||||
|
||||
(.Watch in site.features) or_break
|
||||
time.sleep(5 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
when SPALL {
|
||||
@(instrumentation_enter)
|
||||
spall_enter :: proc "contextless" (
|
||||
proc_address, call_site_return_address: rawptr,
|
||||
loc: runtime.Source_Code_Location,
|
||||
) {
|
||||
spall._buffer_begin(&spall_ctx, &spall_buffer, "", "", loc)
|
||||
}
|
||||
|
||||
@(instrumentation_exit)
|
||||
spall_exit :: proc "contextless" (
|
||||
proc_address, call_site_return_address: rawptr,
|
||||
loc: runtime.Source_Code_Location,
|
||||
) {
|
||||
spall._buffer_end(&spall_ctx, &spall_buffer)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,4 +7,3 @@ import "core:testing"
|
||||
test_true :: proc(t: ^testing.T) {
|
||||
testing.expect(t, true)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#+feature dynamic-literals
|
||||
package markdown
|
||||
|
||||
import "core:strings"
|
||||
|
||||
ALERT_EMOJIS: map[string]string = {
|
||||
"note" = "ℹ️",
|
||||
"tip" = "💡",
|
||||
"important" = "❗",
|
||||
"warning" = "⚠️",
|
||||
"caution" = "❗",
|
||||
}
|
||||
|
||||
inject_alerts :: proc(html: string) -> string {
|
||||
sb := strings.builder_make()
|
||||
defer strings.builder_destroy(&sb)
|
||||
|
||||
remaining := html
|
||||
|
||||
for {
|
||||
bq_start := strings.index(remaining, "<blockquote>")
|
||||
if bq_start < 0 {
|
||||
strings.write_string(&sb, remaining)
|
||||
break
|
||||
}
|
||||
|
||||
bq_close := strings.index(remaining, "</blockquote>")
|
||||
if bq_close < 0 {
|
||||
strings.write_string(&sb, remaining)
|
||||
break
|
||||
}
|
||||
|
||||
bq_end := bq_close + len("</blockquote>")
|
||||
bq := remaining[bq_start:bq_end]
|
||||
|
||||
strings.write_string(&sb, remaining[:bq_start])
|
||||
transform_alert(&sb, bq)
|
||||
|
||||
remaining = remaining[bq_end:]
|
||||
}
|
||||
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
transform_alert :: proc(sb: ^strings.Builder, bq: string) {
|
||||
pos := len("<blockquote>")
|
||||
for pos < len(bq) &&
|
||||
(bq[pos] == '\n' || bq[pos] == '\r' || bq[pos] == ' ' || bq[pos] == '\t') {
|
||||
pos += 1
|
||||
}
|
||||
|
||||
if pos + 4 >= len(bq) ||
|
||||
bq[pos] != '<' ||
|
||||
bq[pos + 1] != 'p' ||
|
||||
bq[pos + 2] != '>' ||
|
||||
bq[pos + 3] != '[' ||
|
||||
bq[pos + 4] != '!' {
|
||||
strings.write_string(sb, bq)
|
||||
return
|
||||
}
|
||||
|
||||
close := strings.index(bq[pos + 5:], "]")
|
||||
if close < 0 {
|
||||
strings.write_string(sb, bq)
|
||||
return
|
||||
}
|
||||
|
||||
type_raw := bq[pos + 5:pos + 5 + close]
|
||||
type_lower := strings.to_lower(type_raw, context.temp_allocator)
|
||||
|
||||
emoji, found := ALERT_EMOJIS[type_lower]
|
||||
if !found {
|
||||
strings.write_string(sb, bq)
|
||||
return
|
||||
}
|
||||
|
||||
after_type := pos + 5 + close + 1
|
||||
|
||||
content_start := after_type
|
||||
if content_start < len(bq) && (bq[content_start] == '+' || bq[content_start] == '-') {
|
||||
content_start += 1
|
||||
}
|
||||
if content_start < len(bq) && bq[content_start] == ' ' {
|
||||
content_start += 1
|
||||
}
|
||||
|
||||
rest := bq[content_start:]
|
||||
|
||||
strings.write_string(sb, `<blockquote class="alert alert-`)
|
||||
strings.write_string(sb, type_lower)
|
||||
strings.write_string(sb, `">`)
|
||||
strings.write_string(sb, "\n")
|
||||
strings.write_string(sb, `<p class="alert-title">`)
|
||||
strings.write_string(sb, emoji)
|
||||
strings.write_string(sb, " ")
|
||||
strings.write_string(sb, rest)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
#+feature dynamic-literals
|
||||
#+test
|
||||
package markdown
|
||||
|
||||
import "core:testing"
|
||||
|
||||
@(test)
|
||||
test_alerts_render_properly :: proc(t: ^testing.T) {
|
||||
testing.expect_value(
|
||||
t,
|
||||
inject_alerts("<blockquote>\n<p>[!NOTE] Hello.</p>\n</blockquote>"),
|
||||
`<blockquote class="alert alert-note">
|
||||
<p class="alert-title">ℹ️ Hello.</p>
|
||||
</blockquote>`,
|
||||
)
|
||||
|
||||
testing.expect_value(
|
||||
t,
|
||||
inject_alerts("<blockquote>\n<p>[!TIP] Be smart.</p>\n</blockquote>"),
|
||||
`<blockquote class="alert alert-tip">
|
||||
<p class="alert-title">💡 Be smart.</p>
|
||||
</blockquote>`,
|
||||
)
|
||||
|
||||
testing.expect_value(
|
||||
t,
|
||||
inject_alerts("<blockquote>\n<p>[!CAUTION] Danger!</p>\n</blockquote>"),
|
||||
`<blockquote class="alert alert-caution">
|
||||
<p class="alert-title">❗ Danger!</p>
|
||||
</blockquote>`,
|
||||
)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_non_alerts_pass_through_inject_alerts :: proc(t: ^testing.T) {
|
||||
testing.expect_value(
|
||||
t,
|
||||
inject_alerts("<blockquote>\n<p>Just a quote.</p>\n</blockquote>"),
|
||||
"<blockquote>\n<p>Just a quote.</p>\n</blockquote>",
|
||||
)
|
||||
|
||||
testing.expect_value(
|
||||
t,
|
||||
inject_alerts("<blockquote>\n<p>[!UNKNOWN] Nope.</p>\n</blockquote>"),
|
||||
"<blockquote>\n<p>[!UNKNOWN] Nope.</p>\n</blockquote>",
|
||||
)
|
||||
|
||||
testing.expect_value(
|
||||
t,
|
||||
inject_alerts("<p>No blockquote here.</p>"),
|
||||
"<p>No blockquote here.</p>",
|
||||
)
|
||||
|
||||
testing.expect_value(t, inject_alerts(""), "")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_case_insensitive_alerts_render :: proc(t: ^testing.T) {
|
||||
testing.expect_value(
|
||||
t,
|
||||
inject_alerts("<blockquote>\n<p>[!Note] Mixed case.</p>\n</blockquote>"),
|
||||
`<blockquote class="alert alert-note">
|
||||
<p class="alert-title">ℹ️ Mixed case.</p>
|
||||
</blockquote>`,
|
||||
)
|
||||
|
||||
testing.expect_value(
|
||||
t,
|
||||
inject_alerts("<blockquote>\n<p>[!NOTE] Upper case.</p>\n</blockquote>"),
|
||||
`<blockquote class="alert alert-note">
|
||||
<p class="alert-title">ℹ️ Upper case.</p>
|
||||
</blockquote>`,
|
||||
)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_multiple_alerts_render_together :: proc(t: ^testing.T) {
|
||||
testing.expect_value(
|
||||
t,
|
||||
inject_alerts(
|
||||
"<blockquote>\n<p>[!NOTE] First.</p>\n</blockquote>\n<blockquote>\n<p>[!TIP] Second.</p>\n</blockquote>",
|
||||
),
|
||||
`<blockquote class="alert alert-note">
|
||||
<p class="alert-title">ℹ️ First.</p>
|
||||
</blockquote>
|
||||
<blockquote class="alert alert-tip">
|
||||
<p class="alert-title">💡 Second.</p>
|
||||
</blockquote>`,
|
||||
)
|
||||
|
||||
testing.expect_value(
|
||||
t,
|
||||
inject_alerts(
|
||||
"<blockquote>\n<p>Regular.</p>\n</blockquote>\n<blockquote>\n<p>[!WARNING] Alert!</p>\n</blockquote>",
|
||||
),
|
||||
`<blockquote>
|
||||
<p>Regular.</p>
|
||||
</blockquote>
|
||||
<blockquote class="alert alert-warning">
|
||||
<p class="alert-title">⚠️ Alert!</p>
|
||||
</blockquote>`,
|
||||
)
|
||||
}
|
||||
@@ -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>"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
#+feature dynamic-literals
|
||||
package markdown
|
||||
|
||||
import "core:strings"
|
||||
|
||||
EMOJIS: map[string]string = {
|
||||
// People
|
||||
"smile" = "😄",
|
||||
"laughing" = "😆",
|
||||
"blush" = "😊",
|
||||
"smiley" = "😃",
|
||||
"wink" = "😉",
|
||||
"joy" = "😂",
|
||||
"rofl" = "🤣",
|
||||
"relaxed" = "☺️",
|
||||
"thinking" = "🤔",
|
||||
"neutral_face" = "😐",
|
||||
"expressionless" = "😑",
|
||||
"no_mouth" = "😶",
|
||||
"rolling_eyes" = "🙄",
|
||||
"smirk" = "😏",
|
||||
"persevere" = "😣",
|
||||
"disappointed_relieved" = "😥",
|
||||
"open_mouth" = "😮",
|
||||
"zipper_mouth_face" = "🤐",
|
||||
"hushed" = "😯",
|
||||
"sleepy" = "😪",
|
||||
"tired_face" = "😫",
|
||||
"sleeping" = "😴",
|
||||
"relieved" = "😌",
|
||||
"stuck_out_tongue" = "😛",
|
||||
"stuck_out_tongue_winking_eye" = "😜",
|
||||
"stuck_out_tongue_closed_eyes" = "😝",
|
||||
"drooling_face" = "🤤",
|
||||
"unamused" = "😒",
|
||||
"sweat" = "😅",
|
||||
"pensive" = "😔",
|
||||
"confused" = "😕",
|
||||
"upside_down_face" = "🙃",
|
||||
"money_mouth_face" = "🤑",
|
||||
"astonished" = "😲",
|
||||
"white_frowning_face" = "☹️",
|
||||
"slightly_sad_face" = "☹️",
|
||||
"confounded" = "😖",
|
||||
"disappointed" = "😞",
|
||||
"worried" = "😟",
|
||||
"triumph" = "😤",
|
||||
"cry" = "😢",
|
||||
"sob" = "😭",
|
||||
"frowning" = "😦",
|
||||
"frowning_face" = "🙁",
|
||||
"anguished" = "😧",
|
||||
"fearful" = "😨",
|
||||
"weary" = "😩",
|
||||
"grimacing" = "😬",
|
||||
"cold_sweat" = "😰",
|
||||
"scream" = "😱",
|
||||
"flushed" = "😳",
|
||||
"dizzy_face" = "😵",
|
||||
"rage" = "😡",
|
||||
"angry" = "😠",
|
||||
"innocent" = "😇",
|
||||
"cowboy_hat_face" = "🤠",
|
||||
"clown_face" = "🤡",
|
||||
"mask" = "😷",
|
||||
"thermometer_face" = "🤒",
|
||||
"head_bandage" = "🤕",
|
||||
"nauseated_face" = "🤢",
|
||||
"sneezing_face" = "🤧",
|
||||
"smiling_imp" = "😈",
|
||||
"imp" = "👿",
|
||||
"shrug" = "¯\\_(ツ)_/¯",
|
||||
"facepalm" = "🤦",
|
||||
"facepunch" = "👊",
|
||||
"wave" = "👋",
|
||||
"ok_hand" = "👌",
|
||||
"thumbsup" = "👍",
|
||||
"thumbsdown" = "👎",
|
||||
"clap" = "👏",
|
||||
"pray" = "🙏",
|
||||
"point_up" = "☝️",
|
||||
"point_down" = "👇",
|
||||
"point_left" = "👈",
|
||||
"point_right" = "👉",
|
||||
"v" = "✌️",
|
||||
"raised_hands" = "🙌",
|
||||
"muscle" = "💪",
|
||||
"fist" = "✊",
|
||||
"hand" = "✋",
|
||||
|
||||
// Hearts & symbols
|
||||
"heart" = "❤️",
|
||||
"orange_heart" = "🧡",
|
||||
"yellow_heart" = "💛",
|
||||
"green_heart" = "💚",
|
||||
"blue_heart" = "💙",
|
||||
"purple_heart" = "💜",
|
||||
"broken_heart" = "💔",
|
||||
"sparkling_heart" = "💖",
|
||||
"100" = "💯",
|
||||
"anger" = "💢",
|
||||
"checkered_flag" = "🏁",
|
||||
"crossed_flags" = "🚩",
|
||||
"rocket" = "🚀",
|
||||
"star" = "⭐",
|
||||
"star2" = "🌟",
|
||||
"sparkles" = "✨",
|
||||
"boom" = "💥",
|
||||
"exclamation" = "❗",
|
||||
"question" = "❓",
|
||||
"grey_exclamation" = "❕",
|
||||
"grey_question" = "❔",
|
||||
"zzz" = "💤",
|
||||
"warning" = "⚠️",
|
||||
"no_entry_sign" = "🚫",
|
||||
"no_entry" = "⛔",
|
||||
"white_check_mark" = "✅",
|
||||
"negative_squared_cross_mark" = "❎",
|
||||
"x" = "❌",
|
||||
"o" = "⭕",
|
||||
"heavy_plus_sign" = "➕",
|
||||
"heavy_minus_sign" = "➖",
|
||||
"heavy_division_sign" = "➗",
|
||||
"copyright" = "©️",
|
||||
"registered" = "®️",
|
||||
"tm" = "™️",
|
||||
|
||||
// Nature & weather
|
||||
"fire" = "🔥",
|
||||
"zap" = "⚡",
|
||||
"sunny" = "☀️",
|
||||
"cloud" = "☁️",
|
||||
"rainbow" = "🌈",
|
||||
"snowflake" = "❄️",
|
||||
"dash" = "💨",
|
||||
"tornado" = "🌪️",
|
||||
"deciduous_tree" = "🌳",
|
||||
"evergreen_tree" = "🌲",
|
||||
"palm_tree" = "🌴",
|
||||
"cactus" = "🌵",
|
||||
"tulip" = "🌷",
|
||||
"rose" = "🌹",
|
||||
"sunflower" = "🌻",
|
||||
"hibiscus" = "🌺",
|
||||
"earth_africa" = "🌍",
|
||||
"earth_americas" = "🌎",
|
||||
"earth_asia" = "🌏",
|
||||
"new_moon" = "🌑",
|
||||
"full_moon" = "🌕",
|
||||
|
||||
// Food & drink
|
||||
"coffee" = "☕",
|
||||
"tea" = "🍵",
|
||||
"beer" = "🍺",
|
||||
"beers" = "🍻",
|
||||
"wine_glass" = "🍷",
|
||||
"tropical_drink" = "🍹",
|
||||
"apple" = "🍎",
|
||||
"green_apple" = "🍏",
|
||||
"orange" = "🍊",
|
||||
"lemon" = "🍋",
|
||||
"banana" = "🍌",
|
||||
"watermelon" = "🍉",
|
||||
"grapes" = "🍇",
|
||||
"strawberry" = "🍓",
|
||||
"melon" = "🍈",
|
||||
"cherries" = "🍒",
|
||||
"peach" = "🍑",
|
||||
"pineapple" = "🍍",
|
||||
"pizza" = "🍕",
|
||||
"hamburger" = "🍔",
|
||||
"hotdog" = "🌭",
|
||||
"taco" = "🌮",
|
||||
"burrito" = "🌯",
|
||||
"cake" = "🍰",
|
||||
"cookie" = "🍪",
|
||||
"chocolate_bar" = "🍫",
|
||||
"candy" = "🍬",
|
||||
"popcorn" = "🍿",
|
||||
|
||||
// Animals
|
||||
"dog" = "🐶",
|
||||
"cat" = "🐱",
|
||||
"mouse" = "🐭",
|
||||
"hamster" = "🐹",
|
||||
"rabbit" = "🐰",
|
||||
"bear" = "🐻",
|
||||
"panda_face" = "🐼",
|
||||
"koala" = "🐨",
|
||||
"tiger" = "🐯",
|
||||
"lion_face" = "🦁",
|
||||
"cow" = "🐮",
|
||||
"pig" = "🐷",
|
||||
"frog" = "🐸",
|
||||
"monkey_face" = "🐵",
|
||||
"see_no_evil" = "🙈",
|
||||
"hear_no_evil" = "🙉",
|
||||
"speak_no_evil" = "🙊",
|
||||
"owl" = "🦉",
|
||||
"bat" = "🦇",
|
||||
"wolf" = "🐺",
|
||||
"boar" = "🐗",
|
||||
"horse" = "🐴",
|
||||
"unicorn" = "🦄",
|
||||
"honeybee" = "🐝",
|
||||
"bug" = "🐛",
|
||||
"snail" = "🐌",
|
||||
"beetle" = "🐞",
|
||||
"ant" = "🐜",
|
||||
"spider" = "🕷",
|
||||
"scorpion" = "🦂",
|
||||
"turtle" = "🐢",
|
||||
"snake" = "🐍",
|
||||
"octopus" = "🐙",
|
||||
"shell" = "🐚",
|
||||
"whale" = "🐳",
|
||||
"dolphin" = "🐬",
|
||||
"fish" = "🐟",
|
||||
"tropical_fish" = "🐠",
|
||||
"blowfish" = "🐡",
|
||||
"penguin" = "🐧",
|
||||
"chicken" = "🐔",
|
||||
"bird" = "🐦",
|
||||
"eagle" = "🦅",
|
||||
|
||||
// Objects
|
||||
"computer" = "💻",
|
||||
"iphone" = "📱",
|
||||
"keyboard" = "⌨️",
|
||||
"desktop_computer" = "🖥",
|
||||
"printer" = "🖨",
|
||||
"mouse_three_button" = "🖱",
|
||||
"joystick" = "🕹",
|
||||
"stash" = "💾",
|
||||
"floppy_disk" = "💾",
|
||||
"cd" = "💿",
|
||||
"dvd" = "📀",
|
||||
"vhs" = "📼",
|
||||
"camera" = "📷",
|
||||
"video_camera" = "📹",
|
||||
"tv" = "📺",
|
||||
"radio" = "📻",
|
||||
"pager" = "📟",
|
||||
"telephone" = "☎️",
|
||||
"fax" = "📠",
|
||||
"bulb" = "💡",
|
||||
"candle" = "🕯",
|
||||
"bathtub" = "🛁",
|
||||
"shower" = "🚿",
|
||||
"toilet" = "🚽",
|
||||
"wrench" = "🔧",
|
||||
"hammer" = "🔨",
|
||||
"nut_and_bolt" = "🔩",
|
||||
"gear" = "⚙️",
|
||||
"link" = "🔗",
|
||||
"lock" = "🔒",
|
||||
"unlock" = "🔓",
|
||||
"key" = "🔑",
|
||||
"bell" = "🔔",
|
||||
"bookmark" = "🔖",
|
||||
"pushpin" = "📌",
|
||||
"paperclip" = "📎",
|
||||
"memo" = "📝",
|
||||
"pencil2" = "✏️",
|
||||
"black_nib" = "✒️",
|
||||
"pen" = "🖊",
|
||||
"paintbrush" = "🖌",
|
||||
"crayon" = "🖍",
|
||||
"book" = "📖",
|
||||
"books" = "📚",
|
||||
"ledger" = "📒",
|
||||
"notebook" = "📓",
|
||||
"scroll" = "📜",
|
||||
"page_with_curl" = "📄",
|
||||
"newspaper" = "📰",
|
||||
"chart_with_upwards_trend" = "📈",
|
||||
"chart_with_downwards_trend" = "📉",
|
||||
"bar_chart" = "📊",
|
||||
"calendar" = "📅",
|
||||
"date" = "📅",
|
||||
"hourglass" = "⌛",
|
||||
"hourglass_flowing_sand" = "⏳",
|
||||
"clock" = "🕐",
|
||||
"alarm_clock" = "⏰",
|
||||
"stopwatch" = "⏱",
|
||||
"watch" = "⌚",
|
||||
"moneybag" = "💰",
|
||||
"yen" = "💴",
|
||||
"dollar" = "💵",
|
||||
"euro" = "💶",
|
||||
"pound" = "💷",
|
||||
"money_with_wings" = "💸",
|
||||
"credit_card" = "💳",
|
||||
"gem" = "💎",
|
||||
"bomb" = "💣",
|
||||
"gift" = "🎁",
|
||||
"balloon" = "🎈",
|
||||
"tada" = "🎉",
|
||||
"confetti_ball" = "🎊",
|
||||
"package" = "📦",
|
||||
"mailbox" = "📫",
|
||||
"inbox_tray" = "📥",
|
||||
"outbox_tray" = "📤",
|
||||
"email" = "✉️",
|
||||
"envelope" = "✉️",
|
||||
"incoming_envelope" = "📨",
|
||||
|
||||
// Activities
|
||||
"soccer" = "⚽",
|
||||
"basketball" = "🏀",
|
||||
"football" = "🏈",
|
||||
"baseball" = "⚾",
|
||||
"tennis" = "🎾",
|
||||
"8ball" = "🎱",
|
||||
"bowling" = "🎳",
|
||||
"video_game" = "🎮",
|
||||
"dart" = "🎯",
|
||||
"game_die" = "🎲",
|
||||
"slot_machine" = "🎰",
|
||||
"cards" = "🃏",
|
||||
"black_joker" = "🃟",
|
||||
"mahjong" = "🀄",
|
||||
"musical_note" = "🎵",
|
||||
"notes" = "🎶",
|
||||
"saxophone" = "🎷",
|
||||
"guitar" = "🎸",
|
||||
"musical_keyboard" = "🎹",
|
||||
"trumpet" = "🎺",
|
||||
"violin" = "🎻",
|
||||
"drum" = "🥁",
|
||||
"headphones" = "🎧",
|
||||
"microphone" = "🎤",
|
||||
"level_slider" = "🎚",
|
||||
"control_knobs" = "🎛",
|
||||
"ticket" = "🎫",
|
||||
"art" = "🎨",
|
||||
"circus_tent" = "🎪",
|
||||
"theater_masks" = "🎭",
|
||||
"clapper" = "🎬",
|
||||
|
||||
// Travel
|
||||
"car" = "🚗",
|
||||
"taxi" = "🚕",
|
||||
"bus" = "🚍",
|
||||
"train" = "🚆",
|
||||
"metro" = "🚇",
|
||||
"light_rail" = "🚈",
|
||||
"tram" = "🚊",
|
||||
"bike" = "🚲",
|
||||
"motorcycle" = "🏍",
|
||||
"airplane" = "✈️",
|
||||
"helicopter" = "🚁",
|
||||
"boat" = "⛵",
|
||||
"sailboat" = "⛵",
|
||||
"ship" = "🚢",
|
||||
"fuelpump" = "⛽",
|
||||
"construction" = "🚧",
|
||||
"house" = "🏠",
|
||||
"house_with_garden" = "🏡",
|
||||
"office" = "🏢",
|
||||
"post_office" = "🏣",
|
||||
"hospital" = "🏥",
|
||||
"bank" = "🏦",
|
||||
"hotel" = "🏨",
|
||||
"school" = "🏫",
|
||||
"department_store" = "🏬",
|
||||
"church" = "⛪",
|
||||
"castle" = "🏰",
|
||||
"factory" = "🏭",
|
||||
"tokyo_tower" = "🗼",
|
||||
"statue_of_liberty" = "🗽",
|
||||
"fountain" = "⛲",
|
||||
"tent" = "⛺",
|
||||
"mountain" = "⛰️",
|
||||
"snow_capped_mountain" = "🏔",
|
||||
"beach" = "🏖",
|
||||
"camping" = "🏕",
|
||||
"world_map" = "🗺",
|
||||
"japan" = "🗾",
|
||||
}
|
||||
|
||||
expand_emoji :: proc(text: string) -> string {
|
||||
assert(len(EMOJIS) > 0)
|
||||
|
||||
sb := strings.builder_make()
|
||||
defer strings.builder_destroy(&sb)
|
||||
|
||||
remaining := text
|
||||
|
||||
for {
|
||||
colon := strings.index(remaining, ":")
|
||||
if colon < 0 {
|
||||
strings.write_string(&sb, remaining)
|
||||
break
|
||||
}
|
||||
|
||||
after := remaining[colon + 1:]
|
||||
end := strings.index(after, ":")
|
||||
if end < 0 {
|
||||
strings.write_string(&sb, remaining)
|
||||
break
|
||||
}
|
||||
|
||||
shortcode := remaining[colon + 1:colon + 1 + end]
|
||||
|
||||
valid := true
|
||||
for c in shortcode {
|
||||
if !(c >= 'a' && c <= 'z') &&
|
||||
!(c >= '0' && c <= '9') &&
|
||||
c != '_' &&
|
||||
c != '+' &&
|
||||
c != '-' {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid || len(shortcode) == 0 {
|
||||
strings.write_string(&sb, remaining[:colon + 1])
|
||||
remaining = remaining[colon + 1:]
|
||||
continue
|
||||
}
|
||||
|
||||
emoji, found := EMOJIS[shortcode]
|
||||
if !found {
|
||||
strings.write_string(&sb, remaining[:colon + 1])
|
||||
remaining = remaining[colon + 1:]
|
||||
continue
|
||||
}
|
||||
|
||||
strings.write_string(&sb, remaining[:colon])
|
||||
strings.write_string(&sb, emoji)
|
||||
remaining = remaining[colon + 1 + end + 1:]
|
||||
}
|
||||
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#+test
|
||||
package markdown
|
||||
|
||||
import "core:testing"
|
||||
|
||||
@(test)
|
||||
test_basic_emoji_expansion_works :: proc(t: ^testing.T) {
|
||||
testing.expect_value(t, expand_emoji(":smile:"), "😄")
|
||||
testing.expect_value(t, expand_emoji(":smile: :heart:"), "😄 ❤️")
|
||||
testing.expect_value(t, expand_emoji(":smile::heart:"), "😄❤️")
|
||||
testing.expect_value(t, expand_emoji("Hello :wave:!"), "Hello 👋!")
|
||||
testing.expect_value(t, expand_emoji(":smile: rest"), "😄 rest")
|
||||
testing.expect_value(t, expand_emoji("rest :smile:"), "rest 😄")
|
||||
testing.expect_value(t, expand_emoji(":100:"), "💯")
|
||||
testing.expect_value(t, expand_emoji(":stuck_out_tongue:"), "😛")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_emoji_skips_non_matches :: proc(t: ^testing.T) {
|
||||
testing.expect_value(t, expand_emoji("Hello world"), "Hello world")
|
||||
testing.expect_value(t, expand_emoji("Hello:"), "Hello:")
|
||||
testing.expect_value(t, expand_emoji(":notreal:"), ":notreal:")
|
||||
testing.expect_value(t, expand_emoji("http://example.com"), "http://example.com")
|
||||
testing.expect_value(t, expand_emoji(""), "")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_emoji_skips_invalid_shortcodes :: proc(t: ^testing.T) {
|
||||
testing.expect_value(t, expand_emoji("::"), "::")
|
||||
testing.expect_value(t, expand_emoji(":Smile:"), ":Smile:")
|
||||
testing.expect_value(t, expand_emoji(": not real :"), ": not real :")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package markdown
|
||||
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
|
||||
Note_Kind :: enum {
|
||||
Sidenote,
|
||||
Marginnote,
|
||||
}
|
||||
|
||||
// strip_definitions scans markdown text for note definitions ([^id]: text for
|
||||
// sidenotes, [*id]: text for marginnotes), removes them, and returns the cleaned
|
||||
// text plus separate maps of id->definition for each kind.
|
||||
// Handles multi-line definitions with indented continuation lines.
|
||||
strip_definitions :: proc(
|
||||
body: string,
|
||||
) -> (
|
||||
clean_body: string,
|
||||
sn_defs, mn_defs: map[string]string,
|
||||
) {
|
||||
lines := strings.split(body, "\n")
|
||||
defer delete(lines)
|
||||
|
||||
out_sb := strings.builder_make()
|
||||
defer strings.builder_destroy(&out_sb)
|
||||
|
||||
i := 0
|
||||
for i < len(lines) {
|
||||
line := lines[i]
|
||||
|
||||
id, def_text, kind, is_def := parse_def_line(line)
|
||||
if !is_def {
|
||||
if strings.builder_len(out_sb) > 0 {
|
||||
strings.write_string(&out_sb, "\n")
|
||||
}
|
||||
strings.write_string(&out_sb, line)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
def_sb := strings.builder_make()
|
||||
if def_text != "" {
|
||||
strings.write_string(&def_sb, def_text)
|
||||
}
|
||||
|
||||
i += 1
|
||||
for i < len(lines) {
|
||||
next := lines[i]
|
||||
if len(next) == 0 {
|
||||
break
|
||||
}
|
||||
_, _, _, is_new_def := parse_def_line(next)
|
||||
if is_new_def {
|
||||
break
|
||||
}
|
||||
if strings.builder_len(def_sb) > 0 {
|
||||
strings.write_string(&def_sb, "\n")
|
||||
}
|
||||
if is_indented(next) {
|
||||
strings.write_string(&def_sb, strings.trim_left(next, " \t"))
|
||||
} else {
|
||||
strings.write_string(&def_sb, next)
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
joined := strings.clone(strings.to_string(def_sb))
|
||||
strings.builder_destroy(&def_sb)
|
||||
|
||||
if kind == .Marginnote {
|
||||
mn_defs[id] = joined
|
||||
} else {
|
||||
sn_defs[id] = joined
|
||||
}
|
||||
}
|
||||
|
||||
clean_body = strings.clone(strings.to_string(out_sb))
|
||||
return
|
||||
}
|
||||
|
||||
// parse_def_line checks if a line is a note definition: [^id]: text (sidenote)
|
||||
// or [*id]: text (marginnote).
|
||||
parse_def_line :: proc(line: string) -> (id: string, text: string, kind: Note_Kind, ok: bool) {
|
||||
if len(line) < 5 || line[0] != '[' {
|
||||
return
|
||||
}
|
||||
|
||||
if line[1] == '^' {
|
||||
kind = .Sidenote
|
||||
} else if line[1] == '*' {
|
||||
kind = .Marginnote
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
close := strings.index(line[2:], "]")
|
||||
if close < 0 {
|
||||
return
|
||||
}
|
||||
close += 2
|
||||
|
||||
if close + 1 >= len(line) || line[close + 1] != ':' {
|
||||
return
|
||||
}
|
||||
|
||||
id = line[2:close]
|
||||
text = strings.trim_left(line[close + 2:], " \t")
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
is_indented :: proc(line: string) -> bool {
|
||||
if len(line) == 0 {
|
||||
return false
|
||||
}
|
||||
return line[0] == ' ' || line[0] == '\t'
|
||||
}
|
||||
|
||||
// inject_notes finds [^id] (sidenote) and [*id] (marginnote) references in
|
||||
// rendered HTML and replaces them with the appropriate margin markup. Each
|
||||
// definition is rendered through cmark separately.
|
||||
inject_notes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> string {
|
||||
if len(sn_defs) == 0 && len(mn_defs) == 0 {
|
||||
return html
|
||||
}
|
||||
|
||||
parts: strings.Builder
|
||||
strings.builder_init_len(&parts, 0) // TODO: Set a reasonable default
|
||||
defer strings.builder_destroy(&parts)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Append text before the reference
|
||||
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 {
|
||||
// No definition found, leave as literal text
|
||||
strings.write_string(&parts, remaining[pos:ref_end])
|
||||
remaining = remaining[ref_end:]
|
||||
continue
|
||||
}
|
||||
|
||||
// Render definition through cmark for markdown support
|
||||
def_html := render_inline_md(def_text)
|
||||
|
||||
note: string
|
||||
defer delete(note)
|
||||
if is_margin {
|
||||
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 {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
remaining = remaining[ref_end:]
|
||||
}
|
||||
|
||||
return strings.to_string(parts)
|
||||
}
|
||||
|
||||
// inject_footnotes finds [^id] and [*id] references in rendered HTML, numbers
|
||||
// them sequentially by order of appearance, and replaces them with <sup> links.
|
||||
// Appends a <section class="footnotes"><ol> at the end with definitions.
|
||||
// Both sidenote ([^id]) and marginnote ([*id]) references are treated equally.
|
||||
inject_footnotes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> string {
|
||||
if len(sn_defs) == 0 && len(mn_defs) == 0 {
|
||||
return html
|
||||
}
|
||||
|
||||
parts: strings.Builder
|
||||
strings.builder_init_len(&parts, 0)
|
||||
defer strings.builder_destroy(&parts)
|
||||
|
||||
number_of: map[string]int = make(map[string]int, allocator = context.temp_allocator)
|
||||
ordered_ids: [dynamic]string = make([dynamic]string, 0, allocator = context.temp_allocator)
|
||||
next_num := 1
|
||||
|
||||
remaining := html
|
||||
|
||||
for {
|
||||
sn_pos := strings.index(remaining, "[^")
|
||||
mn_pos := strings.index(remaining, "[*")
|
||||
|
||||
is_margin := mn_pos >= 0 && (sn_pos < 0 || mn_pos < sn_pos)
|
||||
pos := sn_pos
|
||||
if is_margin {
|
||||
pos = mn_pos
|
||||
}
|
||||
if pos < 0 {
|
||||
strings.write_string(&parts, remaining)
|
||||
break
|
||||
}
|
||||
|
||||
strings.write_string(&parts, remaining[:pos])
|
||||
|
||||
close := strings.index(remaining[pos + 2:], "]")
|
||||
if close < 0 {
|
||||
strings.write_string(&parts, remaining[pos:])
|
||||
break
|
||||
}
|
||||
|
||||
id := remaining[pos + 2:pos + 2 + close]
|
||||
ref_end := pos + 2 + close + 1
|
||||
|
||||
defs := sn_defs
|
||||
if is_margin {
|
||||
defs = mn_defs
|
||||
}
|
||||
def_text, found := defs[id]
|
||||
if !found {
|
||||
strings.write_string(&parts, remaining[pos:ref_end])
|
||||
remaining = remaining[ref_end:]
|
||||
continue
|
||||
}
|
||||
|
||||
num, seen := number_of[id]
|
||||
if !seen {
|
||||
num = next_num
|
||||
number_of[id] = num
|
||||
next_num += 1
|
||||
append(&ordered_ids, id)
|
||||
}
|
||||
|
||||
fmt.sbprintf(&parts, `<sup><a href="#fn-%d" id="fnref-%d">%d</a></sup>`, num, num, num)
|
||||
|
||||
remaining = remaining[ref_end:]
|
||||
}
|
||||
|
||||
if 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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
#+feature dynamic-literals
|
||||
#+test
|
||||
package markdown
|
||||
|
||||
import "core:strings"
|
||||
import "core:testing"
|
||||
|
||||
@(test)
|
||||
test_parse_def_line :: proc(t: ^testing.T) {
|
||||
// sidenote definition
|
||||
id, text, kind, ok := parse_def_line("[^foo]: bar baz")
|
||||
testing.expect(t, ok)
|
||||
testing.expect(t, id == "foo")
|
||||
testing.expect(t, text == "bar baz")
|
||||
testing.expect(t, kind == .Sidenote)
|
||||
|
||||
// marginnote definition
|
||||
id, text, kind, ok = parse_def_line("[*baz]: qux")
|
||||
testing.expect(t, ok)
|
||||
testing.expect(t, id == "baz")
|
||||
testing.expect(t, text == "qux")
|
||||
testing.expect(t, kind == .Marginnote)
|
||||
|
||||
// plain text is not a definition
|
||||
_, _, _, ok = parse_def_line("regular text")
|
||||
testing.expect(t, !ok)
|
||||
|
||||
// a bracket that is not a note (e.g. reference-link style) is ignored
|
||||
_, _, _, ok = parse_def_line("[link]: url")
|
||||
testing.expect(t, !ok)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_strip_definitions :: proc(t: ^testing.T) {
|
||||
body := "Intro[^a] and [*b].\n\n[^a]: a side\n\n[*b]: a margin\n"
|
||||
clean, sn_defs, mn_defs := strip_definitions(body)
|
||||
defer {
|
||||
delete(clean)
|
||||
delete(sn_defs["a"])
|
||||
delete(sn_defs)
|
||||
delete(mn_defs["b"])
|
||||
delete(mn_defs)
|
||||
}
|
||||
|
||||
// references stay; definitions are removed
|
||||
testing.expect(t, strings.contains(clean, "Intro[^a]"))
|
||||
testing.expect(t, strings.contains(clean, "[*b]"))
|
||||
testing.expect(t, !strings.contains(clean, "[^a]:"))
|
||||
testing.expect(t, !strings.contains(clean, "[*b]:"))
|
||||
|
||||
// routed to the correct map by sigil
|
||||
sa, sa_ok := sn_defs["a"]
|
||||
testing.expect(t, sa_ok)
|
||||
testing.expect(t, sa == "a side")
|
||||
|
||||
mb, mb_ok := mn_defs["b"]
|
||||
testing.expect(t, mb_ok)
|
||||
testing.expect(t, mb == "a margin")
|
||||
|
||||
// ids do not leak across maps
|
||||
_, leaked := sn_defs["b"]
|
||||
testing.expect(t, !leaked)
|
||||
_, leaked2 := mn_defs["a"]
|
||||
testing.expect(t, !leaked2)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inject_notes :: proc(t: ^testing.T) {
|
||||
html := "Text[^a] more [*b] end."
|
||||
sn_defs := map[string]string {
|
||||
"a" = "side note",
|
||||
}
|
||||
defer delete(sn_defs)
|
||||
mn_defs := map[string]string {
|
||||
"b" = "margin note",
|
||||
}
|
||||
defer delete(mn_defs)
|
||||
|
||||
out := inject_notes(html, sn_defs, mn_defs)
|
||||
|
||||
// sidenote: numbered, fn- prefix, .sidenote span, rendered text
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(out, `for="fn-a" class="margin-toggle sidenote-number"></label>`),
|
||||
)
|
||||
testing.expect(t, strings.contains(out, `class="sidenote"`))
|
||||
testing.expect(t, strings.contains(out, "side note"))
|
||||
|
||||
// marginnote: unnumbered, mn- prefix, .marginnote span, rendered text
|
||||
testing.expect(t, strings.contains(out, `for="mn-b" class="margin-toggle"></label>`))
|
||||
testing.expect(t, strings.contains(out, `class="marginnote"`))
|
||||
testing.expect(t, strings.contains(out, "margin note"))
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inject_notes_no_defs :: proc(t: ^testing.T) {
|
||||
html := "No notes here."
|
||||
sn := make(map[string]string)
|
||||
mn := make(map[string]string)
|
||||
testing.expect(t, inject_notes(html, sn, mn) == html)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inject_notes_missing_ref :: proc(t: ^testing.T) {
|
||||
html := "Ref[^missing] and [*missing] end."
|
||||
sn := map[string]string {
|
||||
"other" = "x",
|
||||
}
|
||||
defer delete_map(sn)
|
||||
mn := map[string]string {
|
||||
"other2" = "y",
|
||||
}
|
||||
defer delete_map(mn)
|
||||
|
||||
out := inject_notes(html, sn, mn)
|
||||
|
||||
// references with no matching definition are left as literal text
|
||||
testing.expect(t, len(out) > 2)
|
||||
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>`))
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package markdown
|
||||
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:strings"
|
||||
|
||||
// A hypothetical maximum slug length.
|
||||
// May be enforced in a later version (for performance)
|
||||
MAX_SLUG_LENGTH :: #config(MAX_SLUG_LENGTH, 255)
|
||||
|
||||
inject_heading_ids :: proc(html: string, allocator := context.allocator) -> string {
|
||||
sb := strings.builder_make_len_cap(0, len(html) + 256, allocator)
|
||||
defer strings.builder_destroy(&sb)
|
||||
|
||||
seen := make(map[string]bool, 8, context.temp_allocator)
|
||||
empty_count := 0
|
||||
|
||||
pos := 0
|
||||
for {
|
||||
h_start := find_heading_open(html, pos)
|
||||
if h_start < 0 {
|
||||
strings.write_string(&sb, html[pos:])
|
||||
break
|
||||
}
|
||||
|
||||
if h_start > pos {
|
||||
strings.write_string(&sb, html[pos:h_start])
|
||||
}
|
||||
|
||||
level := int(html[h_start + 2] - '0')
|
||||
|
||||
close_buf: [5]u8
|
||||
close_buf[0] = '<'; close_buf[1] = '/'; close_buf[2] = 'h'
|
||||
close_buf[3] = html[h_start + 2]
|
||||
close_buf[4] = '>'
|
||||
close_tag := string(close_buf[:])
|
||||
|
||||
close_rel := strings.index(html[h_start:], close_tag)
|
||||
if close_rel < 0 {
|
||||
strings.write_string(&sb, html[h_start:])
|
||||
break
|
||||
}
|
||||
|
||||
open_tag_end := h_start + 4
|
||||
close_start := h_start + close_rel
|
||||
close_end := close_start + 5
|
||||
|
||||
inner_html := html[open_tag_end:close_start]
|
||||
|
||||
text := extract_plain_text(inner_html, context.temp_allocator)
|
||||
slug := slugify(text)
|
||||
if len(slug) == 0 {
|
||||
empty_count += 1
|
||||
slug = fmt.tprintf("section-%d", empty_count)
|
||||
}
|
||||
slug = make_unique(slug, &seen)
|
||||
|
||||
fmt.sbprintf(&sb, `<h%d id="%s">`, level, slug)
|
||||
strings.write_string(&sb, inner_html)
|
||||
strings.write_string(&sb, close_tag)
|
||||
|
||||
pos = close_end
|
||||
}
|
||||
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
find_heading_open :: proc(html: string, start: int) -> int {
|
||||
pos := start
|
||||
for pos < len(html) - 3 {
|
||||
if html[pos] == '<' &&
|
||||
html[pos + 1] == 'h' &&
|
||||
html[pos + 2] >= '1' &&
|
||||
html[pos + 2] <= '6' &&
|
||||
html[pos + 3] == '>' {
|
||||
return pos
|
||||
}
|
||||
pos += 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
extract_plain_text :: proc(html: string, allocator := context.temp_allocator) -> string {
|
||||
sb := strings.builder_make(allocator)
|
||||
defer strings.builder_destroy(&sb)
|
||||
|
||||
in_tag := false
|
||||
i := 0
|
||||
for i < len(html) {
|
||||
c := html[i]
|
||||
if in_tag {
|
||||
if c == '>' {
|
||||
in_tag = false
|
||||
}
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if c == '<' {
|
||||
in_tag = true
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if c == '&' {
|
||||
semi := strings.index(html[i:], ";")
|
||||
if semi > 0 && semi <= 5 {
|
||||
entity := html[i:i + semi + 1]
|
||||
replacement := ""
|
||||
switch entity {
|
||||
case "&":
|
||||
replacement = "&"
|
||||
case "<":
|
||||
replacement = "<"
|
||||
case ">":
|
||||
replacement = ">"
|
||||
case """:
|
||||
replacement = "\""
|
||||
case "'", "'":
|
||||
replacement = "'"
|
||||
case:
|
||||
replacement = ""
|
||||
}
|
||||
if replacement != "" {
|
||||
strings.write_string(&sb, replacement)
|
||||
i += semi + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
strings.write_byte(&sb, '&')
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
strings.write_byte(&sb, c)
|
||||
i += 1
|
||||
}
|
||||
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
slugify :: proc(text: string, allocator := context.temp_allocator) -> string {
|
||||
sb := strings.builder_make_len_cap(0, 255, allocator)
|
||||
defer strings.builder_destroy(&sb)
|
||||
|
||||
has_hyphen := false
|
||||
|
||||
for i in 0 ..< len(text) {
|
||||
c := text[i]
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
strings.write_byte(&sb, c + 32)
|
||||
has_hyphen = false
|
||||
} else if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
|
||||
strings.write_byte(&sb, c)
|
||||
has_hyphen = false
|
||||
} else {
|
||||
if !has_hyphen {
|
||||
strings.write_byte(&sb, '-')
|
||||
has_hyphen = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := strings.to_string(sb)
|
||||
if len(result) > 0 && result[len(result) - 1] == '-' {
|
||||
result = result[:len(result) - 1]
|
||||
}
|
||||
|
||||
if len(result) > MAX_SLUG_LENGTH {
|
||||
log.warnf(
|
||||
"Long slug detected (%d > %d). " +
|
||||
"This may break in later versions of thor. " +
|
||||
"slug=%s input=\"%s\"",
|
||||
len(result),
|
||||
MAX_SLUG_LENGTH,
|
||||
result,
|
||||
text,
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
make_unique :: proc(slug: string, seen: ^map[string]bool) -> string {
|
||||
if _, ok := seen^[slug]; !ok {
|
||||
seen^[slug] = true
|
||||
return slug
|
||||
}
|
||||
n := 1
|
||||
for {
|
||||
candidate := fmt.tprintf("%s-%d", slug, n)
|
||||
if _, ok := seen^[candidate]; !ok {
|
||||
seen^[candidate] = true
|
||||
return candidate
|
||||
}
|
||||
n += 1
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
#+test
|
||||
package markdown
|
||||
|
||||
import "core:testing"
|
||||
|
||||
@(test)
|
||||
test_heading_simple :: proc(t: ^testing.T) {
|
||||
result := inject_heading_ids("<h2>Hello World</h2>")
|
||||
testing.expect_value(t, result, `<h2 id="hello-world">Hello World</h2>`)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_heading_dedup :: proc(t: ^testing.T) {
|
||||
result := inject_heading_ids("<h2>Intro</h2><p>text</p><h2>Intro</h2>")
|
||||
testing.expect_value(
|
||||
t,
|
||||
result,
|
||||
`<h2 id="intro">Intro</h2><p>text</p><h2 id="intro-1">Intro</h2>`,
|
||||
)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_heading_nested_html :: proc(t: ^testing.T) {
|
||||
result := inject_heading_ids("<h3>With <code>code</code></h3>")
|
||||
testing.expect_value(t, result, `<h3 id="with-code">With <code>code</code></h3>`)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_heading_entities :: proc(t: ^testing.T) {
|
||||
result := inject_heading_ids("<h2>Cats & Dogs</h2>")
|
||||
testing.expect_value(t, result, `<h2 id="cats-dogs">Cats & Dogs</h2>`)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_heading_punctuation :: proc(t: ^testing.T) {
|
||||
result := inject_heading_ids("<h2>Hello, World!</h2>")
|
||||
testing.expect_value(t, result, `<h2 id="hello-world">Hello, World!</h2>`)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_heading_all_levels :: proc(t: ^testing.T) {
|
||||
result := inject_heading_ids("<h1>A</h1><h2>B</h2><h3>C</h3><h4>D</h4><h5>E</h5><h6>F</h6>")
|
||||
testing.expect_value(
|
||||
t,
|
||||
result,
|
||||
`<h1 id="a">A</h1>` +
|
||||
`<h2 id="b">B</h2>` +
|
||||
`<h3 id="c">C</h3>` +
|
||||
`<h4 id="d">D</h4>` +
|
||||
`<h5 id="e">E</h5>` +
|
||||
`<h6 id="f">F</h6>`,
|
||||
)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_heading_preserves_text :: proc(t: ^testing.T) {
|
||||
result := inject_heading_ids("<h2>It is <em>bold</em></h2>")
|
||||
testing.expect_value(t, result, `<h2 id="it-is-bold">It is <em>bold</em></h2>`)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_heading_non_heading_tags :: proc(t: ^testing.T) {
|
||||
input := "<header>Nav</header><h2>Title</h2><hr>"
|
||||
result := inject_heading_ids(input)
|
||||
testing.expect_value(t, result, `<header>Nav</header><h2 id="title">Title</h2><hr>`)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_heading_existing_attrs_skipped :: proc(t: ^testing.T) {
|
||||
input := `<h2 class="foo">Title</h2>`
|
||||
result := inject_heading_ids(input)
|
||||
testing.expect_value(t, result, input)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_heading_empty :: proc(t: ^testing.T) {
|
||||
result := inject_heading_ids("<h2></h2><h3></h3>")
|
||||
testing.expect_value(t, result, `<h2 id="section-1"></h2><h3 id="section-2"></h3>`)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_heading_with_surrounding_content :: proc(t: ^testing.T) {
|
||||
input := "<p>Before</p><h2>Title</h2><p>After</p>"
|
||||
result := inject_heading_ids(input)
|
||||
testing.expect_value(t, result, `<p>Before</p><h2 id="title">Title</h2><p>After</p>`)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_heading_numbers :: proc(t: ^testing.T) {
|
||||
result := inject_heading_ids("<h2>Chapter 12</h2>")
|
||||
testing.expect_value(t, result, `<h2 id="chapter-12">Chapter 12</h2>`)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_heading_triple_dedup :: proc(t: ^testing.T) {
|
||||
result := inject_heading_ids("<h2>Foo</h2><h2>Foo</h2><h2>Foo</h2>")
|
||||
testing.expect_value(
|
||||
t,
|
||||
result,
|
||||
`<h2 id="foo">Foo</h2>` + `<h2 id="foo-1">Foo</h2>` + `<h2 id="foo-2">Foo</h2>`,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package markdown
|
||||
|
||||
import ts "../treesitter"
|
||||
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:strings"
|
||||
|
||||
Capture :: struct {
|
||||
start: u32,
|
||||
end: u32,
|
||||
name: string,
|
||||
}
|
||||
|
||||
find_first_error_line :: proc(root: ts.Node) -> int {
|
||||
if ts.node_is_error(root) {
|
||||
return int(ts.node_start_point(root).row) + 1
|
||||
}
|
||||
for i in 0 ..< ts.node_child_count(root) {
|
||||
child := ts.node_child(root, u32(i))
|
||||
if ts.node_has_error(child) {
|
||||
line := find_first_error_line(child)
|
||||
if line > 0 {
|
||||
return line
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
write_span_open :: proc(b: ^strings.Builder, buf: ^[128]u8, name: string) {
|
||||
pos := 0
|
||||
|
||||
prefix := "<span class=\""
|
||||
for i in 0 ..< len(prefix) {
|
||||
buf[pos] = prefix[i]
|
||||
pos += 1
|
||||
}
|
||||
|
||||
first := true
|
||||
for i in 0 ..< len(name) {
|
||||
if name[i] == '.' {
|
||||
if !first {buf[pos] = ' '; pos += 1}
|
||||
first = false
|
||||
buf[pos] = 'h'; buf[pos + 1] = 'l'; buf[pos + 2] = '-'; pos += 3
|
||||
for j in 0 ..< i {
|
||||
buf[pos] = '-' if name[j] == '.' else name[j]
|
||||
pos += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if !first {buf[pos] = ' '; pos += 1}
|
||||
buf[pos] = 'h'; buf[pos + 1] = 'l'; buf[pos + 2] = '-'; pos += 3
|
||||
for j in 0 ..< len(name) {
|
||||
buf[pos] = '-' if name[j] == '.' else name[j]
|
||||
pos += 1
|
||||
}
|
||||
buf[pos] = '"'; pos += 1
|
||||
buf[pos] = '>'; pos += 1
|
||||
|
||||
strings.write_string(b, string(buf[:pos]))
|
||||
}
|
||||
|
||||
write_escaped :: proc(b: ^strings.Builder, s: string) {
|
||||
start := 0
|
||||
for i in 0 ..< len(s) {
|
||||
switch s[i] {
|
||||
case '&':
|
||||
if i > start do strings.write_string(b, s[start:i])
|
||||
strings.write_string(b, "&")
|
||||
start = i + 1
|
||||
case '<':
|
||||
if i > start do strings.write_string(b, s[start:i])
|
||||
strings.write_string(b, "<")
|
||||
start = i + 1
|
||||
case '>':
|
||||
if i > start do strings.write_string(b, s[start:i])
|
||||
strings.write_string(b, ">")
|
||||
start = i + 1
|
||||
case '"':
|
||||
if i > start do strings.write_string(b, s[start:i])
|
||||
strings.write_string(b, """)
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start == 0 {
|
||||
strings.write_string(b, s)
|
||||
} else if start < len(s) {
|
||||
strings.write_string(b, s[start:])
|
||||
}
|
||||
}
|
||||
|
||||
unescape_html :: proc(s: string) -> string {
|
||||
sb := strings.builder_make()
|
||||
defer strings.builder_destroy(&sb)
|
||||
|
||||
start := 0
|
||||
for i in 0 ..< len(s) {
|
||||
if s[i] != '&' do continue
|
||||
semi := strings.index(s[i:], ";")
|
||||
if semi < 0 do break
|
||||
entity := s[i:i + semi + 1]
|
||||
replacement := ""
|
||||
switch entity {
|
||||
case "&":
|
||||
replacement = "&"
|
||||
case "<":
|
||||
replacement = "<"
|
||||
case ">":
|
||||
replacement = ">"
|
||||
case """:
|
||||
replacement = "\""
|
||||
case "'", "'":
|
||||
replacement = "'"
|
||||
case:
|
||||
continue
|
||||
}
|
||||
if i > start do strings.write_string(&sb, s[start:i])
|
||||
strings.write_string(&sb, replacement)
|
||||
start = i + semi + 1
|
||||
}
|
||||
if start == 0 do return s
|
||||
if start < len(s) do strings.write_string(&sb, s[start:])
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
highlight_block :: proc(code: string, lang: string, file_path: string) -> string {
|
||||
gc := ts.load_grammar(lang)
|
||||
if gc == nil {
|
||||
return code
|
||||
}
|
||||
|
||||
raw_code := unescape_html(code)
|
||||
raw_c := strings.clone_to_cstring(raw_code)
|
||||
defer delete(raw_c)
|
||||
|
||||
tree := ts.parser_parse_string(gc.parser, nil, raw_c, u32(len(raw_code)))
|
||||
if tree == nil {
|
||||
return code
|
||||
}
|
||||
defer ts.tree_delete(tree)
|
||||
|
||||
root := ts.tree_root_node(tree)
|
||||
|
||||
if ts.node_has_error(root) {
|
||||
line := find_first_error_line(root)
|
||||
if line > 0 {
|
||||
log.warnf(
|
||||
"highlight: syntax errors in %s code block at line %d (%s)",
|
||||
lang,
|
||||
line,
|
||||
file_path,
|
||||
)
|
||||
} else {
|
||||
log.warnf("highlight: syntax errors in %s code block (%s)", lang, file_path)
|
||||
}
|
||||
}
|
||||
|
||||
cursor := gc.cursor
|
||||
if cursor == nil {
|
||||
return code
|
||||
}
|
||||
|
||||
ts.query_cursor_exec(cursor, gc.query, root)
|
||||
|
||||
captures := make([dynamic]Capture, 0, 64, context.temp_allocator)
|
||||
defer delete(captures)
|
||||
|
||||
match: ts.Query_Match
|
||||
capture_idx: u32
|
||||
for ts.query_cursor_next_capture(cursor, &match, &capture_idx) {
|
||||
if capture_idx >= u32(match.capture_count) {
|
||||
continue
|
||||
}
|
||||
cap := match.captures[capture_idx]
|
||||
name_len: u32
|
||||
name_c := ts.query_capture_name_for_id(gc.query, cap.index, &name_len)
|
||||
if name_c == nil {
|
||||
continue
|
||||
}
|
||||
name_full := string(name_c)
|
||||
name := name_full
|
||||
if len(name_full) > int(name_len) {
|
||||
name = name_full[:int(name_len)]
|
||||
}
|
||||
append(
|
||||
&captures,
|
||||
Capture {
|
||||
start = ts.node_start_byte(cap.node),
|
||||
end = ts.node_end_byte(cap.node),
|
||||
name = name,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if len(captures) == 0 {
|
||||
return code
|
||||
}
|
||||
|
||||
sb := strings.builder_make_len(len(code) * 2)
|
||||
|
||||
last_pos: u32 = 0
|
||||
stack := make([dynamic]Capture, 0, 16, context.temp_allocator)
|
||||
defer delete(stack)
|
||||
|
||||
buf: [128]u8
|
||||
|
||||
for cap in captures {
|
||||
for len(stack) > 0 {
|
||||
top := stack[len(stack) - 1]
|
||||
if top.end <= cap.start {
|
||||
if top.end > last_pos {
|
||||
write_escaped(&sb, raw_code[last_pos:top.end])
|
||||
}
|
||||
strings.write_string(&sb, "</span>")
|
||||
last_pos = top.end
|
||||
pop(&stack)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if cap.start > last_pos {
|
||||
write_escaped(&sb, raw_code[last_pos:cap.start])
|
||||
last_pos = cap.start
|
||||
}
|
||||
|
||||
write_span_open(&sb, &buf, cap.name)
|
||||
append(&stack, cap)
|
||||
}
|
||||
|
||||
for len(stack) > 0 {
|
||||
top := pop(&stack)
|
||||
if top.end > last_pos {
|
||||
write_escaped(&sb, raw_code[last_pos:top.end])
|
||||
}
|
||||
strings.write_string(&sb, "</span>")
|
||||
last_pos = top.end
|
||||
}
|
||||
|
||||
if int(last_pos) < len(raw_code) {
|
||||
write_escaped(&sb, raw_code[last_pos:])
|
||||
}
|
||||
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
highlight_code :: proc(html: string, file_path: string) -> string {
|
||||
PREFIX :: `<pre><code class="language-`
|
||||
CODE_END :: `</code></pre>`
|
||||
|
||||
sb := strings.builder_make()
|
||||
defer strings.builder_destroy(&sb)
|
||||
|
||||
pos := 0
|
||||
found := false
|
||||
|
||||
for {
|
||||
rel := strings.index(html[pos:], PREFIX)
|
||||
if rel < 0 {
|
||||
break
|
||||
}
|
||||
found = true
|
||||
idx := pos + rel
|
||||
|
||||
if idx > pos {
|
||||
strings.write_string(&sb, html[pos:idx])
|
||||
}
|
||||
|
||||
lang_start := idx + len(PREFIX)
|
||||
lang_end_rel := strings.index(html[lang_start:], `"`)
|
||||
if lang_end_rel < 0 {
|
||||
break
|
||||
}
|
||||
lang_end := lang_start + lang_end_rel
|
||||
lang := html[lang_start:lang_end]
|
||||
|
||||
code_start := lang_end + 1
|
||||
if code_start < len(html) && html[code_start] == '>' {
|
||||
code_start += 1
|
||||
} else {
|
||||
pos = lang_end
|
||||
continue
|
||||
}
|
||||
|
||||
end_rel := strings.index(html[code_start:], CODE_END)
|
||||
if end_rel < 0 {
|
||||
break
|
||||
}
|
||||
end_idx := code_start + end_rel
|
||||
|
||||
code := html[code_start:end_idx]
|
||||
highlighted := highlight_block(code, lang, file_path)
|
||||
fmt.sbprintf(&sb, `<pre><code class="language-%s">%s</code></pre>`, lang, highlighted)
|
||||
|
||||
pos = end_idx + len(CODE_END)
|
||||
}
|
||||
|
||||
if pos < len(html) && found {
|
||||
strings.write_string(&sb, html[pos:])
|
||||
}
|
||||
|
||||
if !found {
|
||||
return html
|
||||
}
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package markdown
|
||||
|
||||
import cm "vendor:commonmark"
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:log"
|
||||
import "core:strings"
|
||||
|
||||
Extension :: enum {
|
||||
Emoji,
|
||||
Sidenotes,
|
||||
Alerts,
|
||||
Highlight,
|
||||
Sections,
|
||||
HeadingIDs,
|
||||
DefLists,
|
||||
Footnotes,
|
||||
}
|
||||
|
||||
DEFAULT_EXTENSIONS :: bit_set[Extension]{.Emoji, .Sidenotes, .Alerts, .HeadingIDs, .DefLists}
|
||||
|
||||
// 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 || .Footnotes in ext {
|
||||
clean_body, side_notes, margin_notes = strip_definitions(body)
|
||||
}
|
||||
if .DefLists in ext {
|
||||
clean_body = convert_deflists(clean_body, allocator)
|
||||
}
|
||||
original_html := cm.markdown_to_html_from_string(clean_body, {.Unsafe})
|
||||
html := strings.clone(original_html, allocator)
|
||||
cm.free_string(original_html)
|
||||
|
||||
if .Emoji in ext {
|
||||
html = expand_emoji(html)
|
||||
}
|
||||
if .Footnotes in ext {
|
||||
html = inject_footnotes(html, side_notes, margin_notes)
|
||||
} else if .Sidenotes in ext {
|
||||
html = inject_notes(html, side_notes, margin_notes)
|
||||
}
|
||||
if .Alerts in ext {
|
||||
html = inject_alerts(html)
|
||||
}
|
||||
if .Highlight in ext {
|
||||
html = highlight_code(html, file_path)
|
||||
}
|
||||
if .HeadingIDs in ext {
|
||||
html = inject_heading_ids(html)
|
||||
}
|
||||
if .Sections in ext {
|
||||
html = wrap_sections(html)
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
// Convert a ',' separated list of case-insensitive extension names to a bit set.
|
||||
parse_extension_list :: proc(s: string) -> (result: bit_set[Extension]) {
|
||||
for part in strings.split(s, ",", allocator = context.temp_allocator) {
|
||||
name := strings.to_lower(strings.trim_space(part), allocator = context.temp_allocator)
|
||||
e, ok := extension_from_name(name)
|
||||
if !ok {
|
||||
if name != "" {
|
||||
log.warnf("unknown extension '%s'", name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
result += {e}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Given a map[Extension]bool, apply it to ext.
|
||||
apply_extension_config :: proc(ext: ^bit_set[Extension], config: json.Object) {
|
||||
for name, val in config {
|
||||
// TODO: Silently discards invalid values.
|
||||
enabled := val.(json.Boolean) or_continue
|
||||
e := 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,45 @@
|
||||
package markdown
|
||||
|
||||
import "core:strings"
|
||||
|
||||
wrap_sections :: proc(html: string) -> string {
|
||||
H2 :: "<h2"
|
||||
|
||||
sb := strings.builder_make()
|
||||
defer strings.builder_destroy(&sb)
|
||||
|
||||
pos := 0
|
||||
search_pos := 0
|
||||
found := false
|
||||
|
||||
for {
|
||||
rel := strings.index(html[search_pos:], H2)
|
||||
if rel < 0 {
|
||||
break
|
||||
}
|
||||
idx := search_pos + rel
|
||||
found = true
|
||||
|
||||
if idx > pos {
|
||||
strings.write_string(&sb, "<section>")
|
||||
strings.write_string(&sb, html[pos:idx])
|
||||
strings.write_string(&sb, "</section>")
|
||||
}
|
||||
|
||||
pos = idx
|
||||
search_pos = idx + len(H2)
|
||||
}
|
||||
|
||||
if pos < len(html) {
|
||||
strings.write_string(&sb, "<section>")
|
||||
strings.write_string(&sb, html[pos:])
|
||||
strings.write_string(&sb, "</section>")
|
||||
found = true
|
||||
}
|
||||
|
||||
if found {
|
||||
return strings.to_string(sb)
|
||||
} else {
|
||||
return html
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#+test
|
||||
package markdown
|
||||
|
||||
import "core:testing"
|
||||
|
||||
@(test)
|
||||
test_wrap_sections_works :: proc(t: ^testing.T) {
|
||||
testing.expect_value(
|
||||
t,
|
||||
wrap_sections("<p>intro</p><h2>Title</h2><p>body</p>"),
|
||||
"<section><p>intro</p></section><section><h2>Title</h2><p>body</p></section>",
|
||||
)
|
||||
|
||||
testing.expect_value(
|
||||
t,
|
||||
wrap_sections("<p>a</p><h2>A</h2><p>b</p><h2>B</h2><p>c</p>"),
|
||||
"<section><p>a</p></section><section><h2>A</h2><p>b</p></section><section><h2>B</h2><p>c</p></section>",
|
||||
)
|
||||
|
||||
testing.expect_value(
|
||||
t,
|
||||
wrap_sections("<h2>Title</h2><p>body</p>"),
|
||||
"<section><h2>Title</h2><p>body</p></section>",
|
||||
)
|
||||
|
||||
testing.expect_value(
|
||||
t,
|
||||
wrap_sections("<p>intro</p><h2>Title</h2>"),
|
||||
"<section><p>intro</p></section><section><h2>Title</h2></section>",
|
||||
)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_wrap_sections_doesnt_split_content :: proc(t: ^testing.T) {
|
||||
testing.expect_value(
|
||||
t,
|
||||
wrap_sections("<p>just text</p>"),
|
||||
"<section><p>just text</p></section>",
|
||||
)
|
||||
|
||||
testing.expect_value(t, wrap_sections(""), "")
|
||||
|
||||
testing.expect_value(
|
||||
t,
|
||||
wrap_sections("<h1>Big</h1><h3>Small</h3>"),
|
||||
"<section><h1>Big</h1><h3>Small</h3></section>",
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
package main
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:mem"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
|
||||
DEFAULT_WEIGHT :: 10
|
||||
|
||||
Menu_Entry :: struct {
|
||||
name: string,
|
||||
url: string,
|
||||
weight: Maybe(int),
|
||||
}
|
||||
|
||||
// parse_page_menus converts raw frontmatter JSON into map[string]Menu_Entry.
|
||||
// Supports three forms:
|
||||
// "menus": "main" → {main: {name=title, url=permalink, weight=nil}}
|
||||
// "menus": ["main", "footer"] → {main: {...}, footer: {...}}
|
||||
// "menus": {"main": {"weight": 30}} → {main: {name=title, url=permalink, weight=30}}
|
||||
parse_page_menus :: proc(
|
||||
raw: json.Value,
|
||||
page: Page,
|
||||
allocator: mem.Allocator,
|
||||
) -> map[string]Menu_Entry {
|
||||
result: map[string]Menu_Entry
|
||||
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v in raw {
|
||||
case json.String:
|
||||
result = make(map[string]Menu_Entry, allocator)
|
||||
result[string(v)] = Menu_Entry {
|
||||
name = page.title,
|
||||
url = page.permalink,
|
||||
}
|
||||
|
||||
case json.Array:
|
||||
result = make(map[string]Menu_Entry, allocator)
|
||||
for item in v {
|
||||
if s, ok := item.(json.String); ok {
|
||||
result[string(s)] = Menu_Entry {
|
||||
name = page.title,
|
||||
url = page.permalink,
|
||||
}
|
||||
} else {
|
||||
log.warnf("menus: ignoring non-string item in menus array: %v", item)
|
||||
}
|
||||
}
|
||||
|
||||
case json.Object:
|
||||
result = make(map[string]Menu_Entry, allocator)
|
||||
for menu_name, entry_val in v {
|
||||
weight: Maybe(int) = nil
|
||||
if entry_obj, ok := entry_val.(json.Object); ok {
|
||||
if w, ok := entry_obj["weight"]; ok {
|
||||
switch wval in w {
|
||||
case json.Integer:
|
||||
weight = int(wval)
|
||||
case json.Float:
|
||||
weight = int(wval)
|
||||
case json.Boolean, json.String, json.Array, json.Object, json.Null:
|
||||
log.warnf(
|
||||
"menus: '%s' entry 'weight' must be a number, got %v",
|
||||
menu_name,
|
||||
w,
|
||||
)
|
||||
}
|
||||
}
|
||||
if _, has_name := entry_obj["name"]; has_name {
|
||||
log.warnf(
|
||||
"menus: '%s' entry 'name' override not yet supported, ignoring",
|
||||
menu_name,
|
||||
)
|
||||
}
|
||||
if _, has_url := entry_obj["url"]; has_url {
|
||||
log.warnf(
|
||||
"menus: '%s' entry 'url' override not yet supported, ignoring",
|
||||
menu_name,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
log.warnf(
|
||||
"menus: '%s' entry must be an object, got %v, using defaults",
|
||||
menu_name,
|
||||
entry_val,
|
||||
)
|
||||
}
|
||||
result[menu_name] = Menu_Entry {
|
||||
name = page.title,
|
||||
url = page.permalink,
|
||||
weight = weight,
|
||||
}
|
||||
}
|
||||
|
||||
case json.Null:
|
||||
return nil
|
||||
|
||||
case json.Integer, json.Float, json.Boolean:
|
||||
log.warnf("menus: expected string, array, or object, got %v", raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
if page.title == "" {
|
||||
log.warnf("menus: page '%s' has no title, menu entry will be blank", page.permalink)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// build_menus populates site.menus:
|
||||
// 1. Config menus (thor.json "menus" key present) — exclusive, preserves array order
|
||||
// 2. Auto-menus (sections + root-level pages) + page frontmatter menus — merged, sorted
|
||||
//
|
||||
// If "menus" is present but empty ({}) it means explicit opt-out: no menus.
|
||||
// Config menus cannot be mixed with page frontmatter menus (error).
|
||||
build_menus :: proc(site: ^Site) {
|
||||
if site.menus != nil {
|
||||
has_menus := false
|
||||
for page in site.pages {
|
||||
if len(page.menus) > 0 {
|
||||
has_menus = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Already populated from config in site_apply_config
|
||||
if len(site.menus) == 0 {
|
||||
// Explicit opt-out ("menus": {})
|
||||
if has_menus {
|
||||
log.warnf(
|
||||
"menus: config has empty menus but pages have frontmatter menu entries; ignoring page menus",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Config menus active
|
||||
if has_menus {
|
||||
log.fatalf("menus: cannot mix config menus with frontmatter menus")
|
||||
os.exit(1)
|
||||
}
|
||||
warn_all_duplicate_weights(site)
|
||||
return
|
||||
}
|
||||
|
||||
// No config menus — auto-generate, then merge page menus on top
|
||||
collect_auto_menus(site)
|
||||
merge_page_menus(site)
|
||||
warn_all_duplicate_weights(site)
|
||||
}
|
||||
|
||||
// merge_page_menus collects frontmatter menu entries from pages and merges
|
||||
// them into site.menus (which may already contain auto-generated entries).
|
||||
// If no pages have menus set, this is a no-op.
|
||||
merge_page_menus :: proc(site: ^Site) {
|
||||
alloc := site_allocator(site)
|
||||
|
||||
// Collect page entries by menu name
|
||||
page_entries := make(map[string][dynamic]Menu_Entry, 16, alloc)
|
||||
for page in site.pages {
|
||||
for menu_name, entry in page.menus {
|
||||
if _, ok := page_entries[menu_name]; !ok {
|
||||
page_entries[menu_name] = make([dynamic]Menu_Entry, 0, 4, alloc)
|
||||
}
|
||||
// Effective weight: per-menu weight if set, else page.weight
|
||||
effective := entry.weight
|
||||
if effective == nil {
|
||||
effective = page.weight
|
||||
}
|
||||
append(
|
||||
&page_entries[menu_name],
|
||||
Menu_Entry{name = entry.name, url = entry.url, weight = effective},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if len(page_entries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if site.menus == nil {
|
||||
site.menus = make(map[string][]Menu_Entry, alloc)
|
||||
}
|
||||
|
||||
for menu_name, entries in page_entries {
|
||||
sort_menu_entries(entries[:])
|
||||
if existing, ok := site.menus[menu_name]; ok {
|
||||
// Merge with existing auto-generated entries
|
||||
merged := make([dynamic]Menu_Entry, 0, len(existing) + len(entries), alloc)
|
||||
append(&merged, ..existing)
|
||||
append(&merged, ..entries[:])
|
||||
sort_menu_entries(merged[:])
|
||||
site.menus[menu_name] = merged[:]
|
||||
} else {
|
||||
site.menus[menu_name] = entries[:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect_auto_menus :: proc(site: ^Site) {
|
||||
alloc := site_allocator(site)
|
||||
sections: map[string]bool
|
||||
|
||||
for page in site.pages {
|
||||
if page._is_index {
|
||||
continue
|
||||
}
|
||||
if page.section != "" {
|
||||
sections[page.section] = true
|
||||
}
|
||||
}
|
||||
|
||||
entries := make([dynamic]Menu_Entry, 0, 8, alloc)
|
||||
|
||||
// Section entries (one per section directory)
|
||||
for section in sections {
|
||||
name := to_title_case(section, alloc)
|
||||
url := fmt.aprintf("/%s/", section, allocator = alloc)
|
||||
skip := false
|
||||
for page in site.pages {
|
||||
if page.section == section && page._is_index {
|
||||
url = page.permalink
|
||||
if page.title != "" {
|
||||
name = page.title
|
||||
}
|
||||
if _, has_main := page.menus["main"]; has_main {
|
||||
skip = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !skip {
|
||||
append(&entries, Menu_Entry{name = name, url = url})
|
||||
}
|
||||
}
|
||||
|
||||
// Root-level page entries (section = "", not index)
|
||||
for page in site.pages {
|
||||
if page._is_index || page.section != "" || page.title == "" {
|
||||
continue
|
||||
}
|
||||
if _, has_main := page.menus["main"]; has_main {
|
||||
continue
|
||||
}
|
||||
append(&entries, Menu_Entry{name = page.title, url = page.permalink, weight = page.weight})
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sort_menu_entries(entries[:])
|
||||
site.menus = make(map[string][]Menu_Entry, alloc)
|
||||
site.menus["main"] = entries[:]
|
||||
}
|
||||
|
||||
compare_menu_entries :: proc(a, b: Menu_Entry) -> int {
|
||||
aw := a.weight.? or_else DEFAULT_WEIGHT
|
||||
bw := b.weight.? or_else DEFAULT_WEIGHT
|
||||
if aw != bw do return aw - bw
|
||||
a_set := a.weight != nil
|
||||
b_set := b.weight != nil
|
||||
if a_set != b_set {
|
||||
return a_set ? -1 : 1
|
||||
}
|
||||
return strings.compare(a.name, b.name)
|
||||
}
|
||||
|
||||
sort_menu_entries :: proc(entries: []Menu_Entry) {
|
||||
for i in 1 ..< len(entries) {
|
||||
key := entries[i]
|
||||
j := i - 1
|
||||
for j >= 0 && compare_menu_entries(entries[j], key) > 0 {
|
||||
entries[j + 1] = entries[j]
|
||||
j -= 1
|
||||
}
|
||||
entries[j + 1] = key
|
||||
}
|
||||
}
|
||||
|
||||
// warn_duplicate_weights logs a warning for each pair of adjacent entries
|
||||
// (pre-sorted) that have the same explicitly-set weight. Entries with nil
|
||||
// weight (unset/default) are never flagged.
|
||||
warn_duplicate_weights :: proc(menu_name: string, entries: []Menu_Entry) {
|
||||
for i in 0 ..< len(entries) - 1 {
|
||||
if entries[i].weight != nil && entries[i].weight == entries[i + 1].weight {
|
||||
log.warnf(
|
||||
"menus('%s'): '%s' and '%s' share the same menu weight (%d).",
|
||||
menu_name,
|
||||
entries[i].name,
|
||||
entries[i + 1].name,
|
||||
entries[i].weight,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warn_all_duplicate_weights :: proc(site: ^Site) {
|
||||
for menu_name, entries in site.menus {
|
||||
warn_duplicate_weights(menu_name, entries)
|
||||
}
|
||||
}
|
||||
|
||||
// parse_config_menus converts raw JSON from thor.json into map[string][]Menu_Entry.
|
||||
// Entries are sorted by weight, then name.
|
||||
parse_config_menus :: proc(
|
||||
raw: json.Value,
|
||||
allocator := context.allocator,
|
||||
) -> map[string][]Menu_Entry {
|
||||
obj, ok := raw.(json.Object)
|
||||
if !ok || len(obj) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(map[string][]Menu_Entry, allocator)
|
||||
for menu_name, menu_val in obj {
|
||||
arr, ok := menu_val.(json.Array)
|
||||
if !ok {
|
||||
log.warnf("menus: '%s' is not an array, skipping", menu_name)
|
||||
continue
|
||||
}
|
||||
|
||||
entries := make([dynamic]Menu_Entry, 0, len(arr), allocator)
|
||||
for item, idx in arr {
|
||||
entry_obj, ok := item.(json.Object)
|
||||
if !ok {
|
||||
log.warnf("menus: '%s' entry %d is not an object, skipping", menu_name, idx)
|
||||
continue
|
||||
}
|
||||
|
||||
name := ""
|
||||
url := ""
|
||||
weight: Maybe(int) = nil
|
||||
|
||||
if v, ok := entry_obj["name"]; ok {
|
||||
if s, ok2 := v.(json.String); ok2 {
|
||||
name = string(s)
|
||||
} else {
|
||||
log.warnf(
|
||||
"menus: '%s' entry %d: 'name' must be a string, got %v, skipping",
|
||||
menu_name,
|
||||
idx,
|
||||
v,
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := entry_obj["url"]; ok {
|
||||
if s, ok2 := v.(json.String); ok2 {
|
||||
url = string(s)
|
||||
} else {
|
||||
log.warnf(
|
||||
"menus: '%s' entry %d: 'url' must be a string, got %v, skipping",
|
||||
menu_name,
|
||||
idx,
|
||||
v,
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := entry_obj["weight"]; ok {
|
||||
switch wval in v {
|
||||
case json.Integer:
|
||||
weight = int(wval)
|
||||
case json.Float:
|
||||
weight = int(wval)
|
||||
case json.Null, json.Boolean, json.String, json.Array, json.Object:
|
||||
log.warnf(
|
||||
"menus: '%s' entry %d: 'weight' must be a number, got %v",
|
||||
menu_name,
|
||||
idx,
|
||||
v,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
log.warnf("menus: '%s' entry %d missing 'name', skipping", menu_name, idx)
|
||||
continue
|
||||
}
|
||||
|
||||
append(&entries, Menu_Entry{name = name, url = url, weight = weight})
|
||||
}
|
||||
sort_menu_entries(entries[:])
|
||||
result[menu_name] = entries[:]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
+517
@@ -0,0 +1,517 @@
|
||||
#+test
|
||||
package main
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:log"
|
||||
import "core:mem"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
import "core:testing"
|
||||
|
||||
make_page :: proc(title: string, permalink: string) -> Page {
|
||||
return Page{title = title, permalink = permalink}
|
||||
}
|
||||
|
||||
parse_raw :: proc(s: string) -> json.Value {
|
||||
v, _ := json.parse_string(s, spec = .JSON)
|
||||
return v
|
||||
}
|
||||
|
||||
|
||||
@(test)
|
||||
test_menus_string_form :: proc(t: ^testing.T) {
|
||||
arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
context.allocator = mem.dynamic_arena_allocator(&arena)
|
||||
|
||||
page := make_page("About", "/about/")
|
||||
menus := parse_page_menus(parse_raw(`"main"`), page, context.allocator)
|
||||
testing.expect(t, len(menus) == 1, "expected 1 menu")
|
||||
entry, ok := menus["main"]
|
||||
testing.expect(t, ok, "expected 'main' menu")
|
||||
testing.expect_value(t, entry.name, "About")
|
||||
testing.expect_value(t, entry.url, "/about/")
|
||||
testing.expect(t, entry.weight == nil, "string form should have nil weight")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_menus_array_form :: proc(t: ^testing.T) {
|
||||
arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
context.allocator = mem.dynamic_arena_allocator(&arena)
|
||||
|
||||
page := make_page("Contact", "/contact/")
|
||||
menus := parse_page_menus(parse_raw(`["main", "footer"]`), page, context.allocator)
|
||||
testing.expect(t, len(menus) == 2, "expected 2 menus")
|
||||
|
||||
main, ok1 := menus["main"]
|
||||
testing.expect(t, ok1)
|
||||
testing.expect_value(t, main.name, "Contact")
|
||||
|
||||
footer, ok2 := menus["footer"]
|
||||
testing.expect(t, ok2)
|
||||
testing.expect_value(t, footer.name, "Contact")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_menus_object_with_weight :: proc(t: ^testing.T) {
|
||||
arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
context.allocator = mem.dynamic_arena_allocator(&arena)
|
||||
|
||||
page := make_page("Posts", "/posts/")
|
||||
menus := parse_page_menus(parse_raw(`{"main": {"weight": 30}}`), page, context.allocator)
|
||||
testing.expect(t, len(menus) == 1)
|
||||
entry, ok := menus["main"]
|
||||
testing.expect(t, ok)
|
||||
testing.expect_value(t, entry.weight, 30)
|
||||
testing.expect_value(t, entry.name, "Posts")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_menus_object_no_weight :: proc(t: ^testing.T) {
|
||||
arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
context.allocator = mem.dynamic_arena_allocator(&arena)
|
||||
|
||||
page := make_page("About", "/about/")
|
||||
menus := parse_page_menus(parse_raw(`{"main": {}}`), page, context.allocator)
|
||||
testing.expect(t, len(menus) == 1)
|
||||
entry, ok := menus["main"]
|
||||
testing.expect(t, ok)
|
||||
testing.expect(t, entry.weight == nil, "object without weight key should have nil weight")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_menus_nil_input :: proc(t: ^testing.T) {
|
||||
arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
context.allocator = mem.dynamic_arena_allocator(&arena)
|
||||
|
||||
page := make_page("Test", "/test/")
|
||||
menus := parse_page_menus(nil, page, context.allocator)
|
||||
testing.expect(t, menus == nil, "nil input should return nil")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_menus_invalid_type :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
context.allocator = mem.dynamic_arena_allocator(&arena)
|
||||
|
||||
page := make_page("Test", "/test/")
|
||||
menus := parse_page_menus(parse_raw(`42`), page, context.allocator)
|
||||
testing.expect(t, menus == nil, "integer should return nil")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_menus_array_non_string :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
context.allocator = mem.dynamic_arena_allocator(&arena)
|
||||
|
||||
page := make_page("Test", "/test/")
|
||||
menus := parse_page_menus(parse_raw(`["main", 42, "footer"]`), page, context.allocator)
|
||||
testing.expect(t, len(menus) == 2, "42 should be dropped")
|
||||
_, ok1 := menus["main"]
|
||||
testing.expect(t, ok1)
|
||||
_, ok2 := menus["footer"]
|
||||
testing.expect(t, ok2)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_menus_object_non_object_value :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
context.allocator = mem.dynamic_arena_allocator(&arena)
|
||||
|
||||
page := make_page("Test", "/test/")
|
||||
menus := parse_page_menus(parse_raw(`{"main": "oops"}`), page, context.allocator)
|
||||
testing.expect(t, len(menus) == 1, "entry created with defaults")
|
||||
entry, ok := menus["main"]
|
||||
testing.expect(t, ok)
|
||||
testing.expect(t, entry.weight == nil, "non-object value should have nil weight")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_menus_non_numeric_weight :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
context.allocator = mem.dynamic_arena_allocator(&arena)
|
||||
|
||||
page := make_page("Test", "/test/")
|
||||
menus := parse_page_menus(parse_raw(`{"main": {"weight": "30"}}`), page, context.allocator)
|
||||
entry, ok := menus["main"]
|
||||
testing.expect(t, ok)
|
||||
testing.expect(t, entry.weight == nil, "non-numeric weight should have nil weight")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_menus_empty_title :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
context.allocator = mem.dynamic_arena_allocator(&arena)
|
||||
|
||||
page := make_page("", "/test/")
|
||||
menus := parse_page_menus(parse_raw(`"main"`), page, context.allocator)
|
||||
testing.expect(t, len(menus) == 1)
|
||||
entry, ok := menus["main"]
|
||||
testing.expect(t, ok)
|
||||
testing.expect_value(t, entry.name, "")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_menus_object_with_float_weight :: proc(t: ^testing.T) {
|
||||
arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
context.allocator = mem.dynamic_arena_allocator(&arena)
|
||||
|
||||
page := make_page("Test", "/test/")
|
||||
menus := parse_page_menus(parse_raw(`{"main": {"weight": 15.0}}`), page, context.allocator)
|
||||
entry, ok := menus["main"]
|
||||
testing.expect(t, ok)
|
||||
testing.expect_value(t, entry.weight, 15)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_menus_null_json :: proc(t: ^testing.T) {
|
||||
arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
context.allocator = mem.dynamic_arena_allocator(&arena)
|
||||
|
||||
page := make_page("Test", "/test/")
|
||||
menus := parse_page_menus(parse_raw(`null`), page, context.allocator)
|
||||
testing.expect(t, menus == nil, "null JSON should return nil")
|
||||
}
|
||||
|
||||
// --- sort_menu_entries tests ---
|
||||
|
||||
@(test)
|
||||
test_sort_weight_orders_correctly :: proc(t: ^testing.T) {
|
||||
entries := []Menu_Entry {
|
||||
{name = "Zeta", url = "/z/"},
|
||||
{name = "Alpha", url = "/a/", weight = 5},
|
||||
{name = "Beta", url = "/b/", weight = 1},
|
||||
}
|
||||
sort_menu_entries(entries)
|
||||
// weight 1 first, then weight 5, then nil (DEFAULT_WEIGHT)
|
||||
testing.expect_value(t, entries[0].name, "Beta")
|
||||
testing.expect_value(t, entries[1].name, "Alpha")
|
||||
testing.expect_value(t, entries[2].name, "Zeta")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_sort_equal_weights_alphabetical :: proc(t: ^testing.T) {
|
||||
entries := []Menu_Entry {
|
||||
{name = "Zebra", url = "/z/"},
|
||||
{name = "Apple", url = "/a/"},
|
||||
{name = "Mango", url = "/m/"},
|
||||
}
|
||||
sort_menu_entries(entries)
|
||||
testing.expect_value(t, entries[0].name, "Apple")
|
||||
testing.expect_value(t, entries[1].name, "Mango")
|
||||
testing.expect_value(t, entries[2].name, "Zebra")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_sort_mixed_weights :: proc(t: ^testing.T) {
|
||||
entries := []Menu_Entry {
|
||||
{name = "Charlie", url = "/c/"},
|
||||
{name = "Alpha", url = "/a/"},
|
||||
{name = "Bravo", url = "/b/", weight = 3},
|
||||
{name = "Delta", url = "/d/", weight = 1},
|
||||
}
|
||||
sort_menu_entries(entries)
|
||||
// weight 1 (Delta), weight 3 (Bravo), then nil weight alphabetical (Alpha, Charlie)
|
||||
testing.expect_value(t, entries[0].name, "Delta")
|
||||
testing.expect_value(t, entries[1].name, "Bravo")
|
||||
testing.expect_value(t, entries[2].name, "Alpha")
|
||||
testing.expect_value(t, entries[3].name, "Charlie")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_sort_explicit_zero_before_nil :: proc(t: ^testing.T) {
|
||||
// Explicit weight 0 is distinguishable from unset (nil → DEFAULT_WEIGHT).
|
||||
// This is the key behavioral improvement of Maybe(int).
|
||||
entries := []Menu_Entry {
|
||||
{name = "Unset", url = "/u/"},
|
||||
{name = "ExplicitZero", url = "/0/", weight = 0},
|
||||
{name = "ExplicitFive", url = "/5/", weight = 5},
|
||||
}
|
||||
sort_menu_entries(entries)
|
||||
// weight 0 first, then weight 5, then nil (DEFAULT_WEIGHT = 10)
|
||||
testing.expect_value(t, entries[0].name, "ExplicitZero")
|
||||
testing.expect_value(t, entries[1].name, "ExplicitFive")
|
||||
testing.expect_value(t, entries[2].name, "Unset")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_config_weight_parsing_and_sort :: proc(t: ^testing.T) {
|
||||
arena: mem.Dynamic_Arena
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
context.allocator = mem.dynamic_arena_allocator(&arena)
|
||||
|
||||
raw := parse_raw(
|
||||
`{
|
||||
"main": [
|
||||
{"name": "Heavy", "url": "/h/", "weight": 20},
|
||||
{"name": "Light", "url": "/l/", "weight": 1},
|
||||
{"name": "Default", "url": "/d/"}
|
||||
]
|
||||
}`,
|
||||
)
|
||||
|
||||
menus := parse_config_menus(raw, context.allocator)
|
||||
main, ok := menus["main"]
|
||||
testing.expect(t, ok)
|
||||
testing.expect(t, len(main) == 3)
|
||||
testing.expect_value(t, main[0].name, "Light")
|
||||
testing.expect_value(t, main[0].weight, 1)
|
||||
testing.expect_value(t, main[1].name, "Default")
|
||||
testing.expect(t, main[1].weight == nil, "entry without weight should be nil")
|
||||
testing.expect_value(t, main[2].name, "Heavy")
|
||||
testing.expect_value(t, main[2].weight, 20)
|
||||
}
|
||||
|
||||
// --- json_get_int tests ---
|
||||
|
||||
@(test)
|
||||
test_json_get_int_integer :: proc(t: ^testing.T) {
|
||||
obj, _ := json.parse_string(`{"weight": 5}`, spec = .JSON)
|
||||
defer json.destroy_value(obj)
|
||||
o, _ := obj.(json.Object)
|
||||
testing.expect_value(t, json_get_int(o, "weight"), 5)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_json_get_int_float :: proc(t: ^testing.T) {
|
||||
obj, _ := json.parse_string(`{"weight": 5.0}`, spec = .JSON)
|
||||
defer json.destroy_value(obj)
|
||||
o, _ := obj.(json.Object)
|
||||
testing.expect_value(t, json_get_int(o, "weight"), 5)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_json_get_int_missing :: proc(t: ^testing.T) {
|
||||
obj, _ := json.parse_string(`{}`, spec = .JSON)
|
||||
defer json.destroy_value(obj)
|
||||
o, _ := obj.(json.Object)
|
||||
testing.expect(t, json_get_int(o, "weight") == nil, "missing key should return nil")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_json_get_int_non_numeric :: proc(t: ^testing.T) {
|
||||
obj, _ := json.parse_string(`{"weight": "5"}`, spec = .JSON)
|
||||
defer json.destroy_value(obj)
|
||||
o, _ := obj.(json.Object)
|
||||
testing.expect(t, json_get_int(o, "weight") == nil, "non-numeric should return nil")
|
||||
}
|
||||
|
||||
// --- sort_pages tests ---
|
||||
|
||||
@(test)
|
||||
test_sort_pages_weight_primary :: proc(t: ^testing.T) {
|
||||
pages := make(#soa[dynamic]Page, 0, 3)
|
||||
defer delete(pages)
|
||||
append(&pages, Page{title = "Gamma", date = "2025-01-03"})
|
||||
append(&pages, Page{title = "Alpha", date = "2025-01-01", weight = 5})
|
||||
append(&pages, Page{title = "Beta", date = "2025-01-02", weight = 1})
|
||||
|
||||
sort_pages(pages[:])
|
||||
|
||||
// weight 1, weight 5, then nil weight (DEFAULT_WEIGHT)
|
||||
testing.expect_value(t, pages.title[0], "Beta")
|
||||
testing.expect_value(t, pages.title[1], "Alpha")
|
||||
testing.expect_value(t, pages.title[2], "Gamma")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_sort_pages_equal_weights_by_date :: proc(t: ^testing.T) {
|
||||
pages := make(#soa[dynamic]Page, 0, 3)
|
||||
defer delete(pages)
|
||||
append(&pages, Page{title = "Old", date = "2025-01-01"})
|
||||
append(&pages, Page{title = "New", date = "2025-06-01"})
|
||||
append(&pages, Page{title = "Mid", date = "2025-03-01"})
|
||||
|
||||
sort_pages(pages[:])
|
||||
|
||||
// All nil weight → date descending
|
||||
testing.expect_value(t, pages.title[0], "New")
|
||||
testing.expect_value(t, pages.title[1], "Mid")
|
||||
testing.expect_value(t, pages.title[2], "Old")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_sort_pages_mixed :: proc(t: ^testing.T) {
|
||||
pages := make(#soa[dynamic]Page, 0, 4)
|
||||
defer delete(pages)
|
||||
append(&pages, Page{title = "DefaultOld", date = "2025-01-01"})
|
||||
append(&pages, Page{title = "DefaultNew", date = "2025-06-01"})
|
||||
append(&pages, Page{title = "Heavy", date = "2025-03-01", weight = 20})
|
||||
append(&pages, Page{title = "Light", date = "2025-02-01", weight = 1})
|
||||
|
||||
sort_pages(pages[:])
|
||||
|
||||
// weight 1, nil weight (DefaultNew by date), nil weight (DefaultOld by date), weight 20
|
||||
testing.expect_value(t, pages.title[0], "Light")
|
||||
testing.expect_value(t, pages.title[1], "DefaultNew")
|
||||
testing.expect_value(t, pages.title[2], "DefaultOld")
|
||||
testing.expect_value(t, pages.title[3], "Heavy")
|
||||
}
|
||||
|
||||
// --- merge_page_menus effective weight tests ---
|
||||
|
||||
@(test)
|
||||
test_merge_page_menus_weight_fallback :: proc(t: ^testing.T) {
|
||||
site: Site
|
||||
mem.dynamic_arena_init(&site.arena)
|
||||
defer mem.dynamic_arena_destroy(&site.arena)
|
||||
context.allocator = site_allocator(&site)
|
||||
|
||||
page := make_page("Test", "/test/")
|
||||
page.weight = 3
|
||||
page.menus = parse_page_menus(parse_raw(`"main"`), page, site_allocator(&site))
|
||||
|
||||
site.pages = make(#soa[dynamic]Page, 0, 1, site_allocator(&site))
|
||||
append(&site.pages, page)
|
||||
|
||||
// Don't call collect_auto_menus — test merge_page_menus in isolation
|
||||
merge_page_menus(&site)
|
||||
|
||||
main, ok := site.menus["main"]
|
||||
testing.expect(t, ok)
|
||||
testing.expect(t, len(main) == 1, "expected exactly 1 entry")
|
||||
testing.expect_value(t, main[0].name, "Test")
|
||||
testing.expect_value(t, main[0].weight, 3)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_auto_menus_no_duplicate_with_frontmatter :: proc(t: ^testing.T) {
|
||||
site: Site
|
||||
mem.dynamic_arena_init(&site.arena)
|
||||
defer mem.dynamic_arena_destroy(&site.arena)
|
||||
context.allocator = site_allocator(&site)
|
||||
|
||||
// Root-level page with explicit "menus": "main"
|
||||
page := make_page("Ideas", "/ideas/")
|
||||
page.menus = parse_page_menus(parse_raw(`"main"`), page, site_allocator(&site))
|
||||
|
||||
site.pages = make(#soa[dynamic]Page, 0, 1, site_allocator(&site))
|
||||
append(&site.pages, page)
|
||||
|
||||
collect_auto_menus(&site)
|
||||
merge_page_menus(&site)
|
||||
|
||||
main, ok := site.menus["main"]
|
||||
testing.expect(t, ok)
|
||||
testing.expect(t, len(main) == 1, "expected exactly 1 entry (no duplicate)")
|
||||
testing.expect_value(t, main[0].name, "Ideas")
|
||||
}
|
||||
|
||||
// --- warn_duplicate_weights tests ---
|
||||
|
||||
@(test)
|
||||
test_warn_duplicate_weights_explicit :: proc(t: ^testing.T) {
|
||||
path := "/tmp/thor_test_warn_explicit.log"
|
||||
os.remove(path)
|
||||
f, err := os.open(path, os.O_RDWR | os.O_CREATE | os.O_TRUNC)
|
||||
if err != nil {
|
||||
testing.expect(t, false, "failed to open temp log file")
|
||||
return
|
||||
}
|
||||
|
||||
logger := log.create_file_logger(f)
|
||||
context.logger = logger
|
||||
|
||||
entries := []Menu_Entry {
|
||||
{name = "Alpha", url = "/a/", weight = 5},
|
||||
{name = "Beta", url = "/b/", weight = 5},
|
||||
}
|
||||
warn_duplicate_weights("main", entries)
|
||||
|
||||
log.destroy_file_logger(logger)
|
||||
|
||||
data, _ := os.read_entire_file_from_path(path, context.temp_allocator)
|
||||
output := string(data)
|
||||
os.remove(path)
|
||||
|
||||
testing.expect(t, strings.contains(output, "duplicate weight 5"), "expected weight in warning")
|
||||
testing.expect(t, strings.contains(output, "Alpha"), "expected first entry name")
|
||||
testing.expect(t, strings.contains(output, "Beta"), "expected second entry name")
|
||||
testing.expect(t, strings.contains(output, "'main'"), "expected menu name in warning")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_warn_duplicate_weights_nil_not_flagged :: proc(t: ^testing.T) {
|
||||
path := "/tmp/thor_test_warn_nil.log"
|
||||
os.remove(path)
|
||||
f, err := os.open(path, os.O_RDWR | os.O_CREATE | os.O_TRUNC)
|
||||
if err != nil {
|
||||
testing.expect(t, false, "failed to open temp log file")
|
||||
return
|
||||
}
|
||||
|
||||
logger := log.create_file_logger(f)
|
||||
context.logger = logger
|
||||
|
||||
entries := []Menu_Entry{{name = "Alpha", url = "/a/"}, {name = "Beta", url = "/b/"}}
|
||||
warn_duplicate_weights("main", entries)
|
||||
|
||||
log.destroy_file_logger(logger)
|
||||
|
||||
data, _ := os.read_entire_file_from_path(path, context.temp_allocator)
|
||||
output := string(data)
|
||||
os.remove(path)
|
||||
|
||||
testing.expect(t, output == "", "nil-weight entries should not produce warnings")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_warn_duplicate_weights_explicit_default :: proc(t: ^testing.T) {
|
||||
path := "/tmp/thor_test_warn_default.log"
|
||||
os.remove(path)
|
||||
f, err := os.open(path, os.O_RDWR | os.O_CREATE | os.O_TRUNC)
|
||||
if err != nil {
|
||||
testing.expect(t, false, "failed to open temp log file")
|
||||
return
|
||||
}
|
||||
|
||||
logger := log.create_file_logger(f)
|
||||
context.logger = logger
|
||||
|
||||
entries := []Menu_Entry {
|
||||
{name = "Alpha", url = "/a/", weight = 10},
|
||||
{name = "Beta", url = "/b/", weight = 10},
|
||||
}
|
||||
warn_duplicate_weights("main", entries)
|
||||
|
||||
log.destroy_file_logger(logger)
|
||||
|
||||
data, _ := os.read_entire_file_from_path(path, context.temp_allocator)
|
||||
output := string(data)
|
||||
os.remove(path)
|
||||
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(output, "duplicate weight 10"),
|
||||
"explicit weight 10 (== DEFAULT_WEIGHT) should warn — this is the Maybe(int) win",
|
||||
)
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
package main
|
||||
|
||||
import ts "treesitter"
|
||||
|
||||
import "core:log"
|
||||
import "core:strings"
|
||||
|
||||
PRESERVE_TAGS :: [?]string{"pre", "code", "textarea"}
|
||||
|
||||
Range :: struct {
|
||||
start: u32,
|
||||
end: u32,
|
||||
}
|
||||
|
||||
minify_html :: proc(source: string) -> string {
|
||||
gc := ts.ensure_parser("html")
|
||||
if gc == nil {
|
||||
return source
|
||||
}
|
||||
|
||||
source_c := strings.clone_to_cstring(source)
|
||||
defer delete(source_c)
|
||||
|
||||
tree := ts.parser_parse_string(gc.parser, nil, source_c, u32(len(source)))
|
||||
if tree == nil {
|
||||
return source
|
||||
}
|
||||
defer ts.tree_delete(tree)
|
||||
|
||||
root := ts.tree_root_node(tree)
|
||||
|
||||
if ts.node_has_error(root) {
|
||||
log.warnf("minify: HTML parse errors, skipping minification")
|
||||
return source
|
||||
}
|
||||
|
||||
comments: [dynamic]Range
|
||||
defer delete(comments)
|
||||
preserves: [dynamic]Range
|
||||
defer delete(preserves)
|
||||
|
||||
collect_html_ranges(root, source, &comments, &preserves)
|
||||
|
||||
sb := strings.builder_make()
|
||||
|
||||
ci := 0
|
||||
pi := 0
|
||||
i := 0
|
||||
last_written: u8 = 0
|
||||
|
||||
for i < len(source) {
|
||||
if pi < len(preserves) && u32(i) >= preserves[pi].start {
|
||||
p := preserves[pi]
|
||||
segment := source[i:p.end]
|
||||
strings.write_string(&sb, segment)
|
||||
if len(segment) > 0 {
|
||||
last_written = segment[len(segment) - 1]
|
||||
}
|
||||
i = int(p.end)
|
||||
pi += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if ci < len(comments) && u32(i) >= comments[ci].start {
|
||||
i = int(comments[ci].end)
|
||||
ci += 1
|
||||
continue
|
||||
}
|
||||
|
||||
c := source[i]
|
||||
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
|
||||
j := i + 1
|
||||
for j < len(source) {
|
||||
c2 := source[j]
|
||||
if c2 != ' ' && c2 != '\t' && c2 != '\n' && c2 != '\r' {
|
||||
break
|
||||
}
|
||||
j += 1
|
||||
}
|
||||
next: u8 = 0
|
||||
if j < len(source) {
|
||||
next = source[j]
|
||||
}
|
||||
if last_written != '>' || next != '<' {
|
||||
strings.write_byte(&sb, ' ')
|
||||
last_written = ' '
|
||||
}
|
||||
i = j
|
||||
} else {
|
||||
strings.write_byte(&sb, c)
|
||||
last_written = c
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
collect_html_ranges :: proc(
|
||||
node: ts.Node,
|
||||
source: string,
|
||||
comments: ^[dynamic]Range,
|
||||
preserves: ^[dynamic]Range,
|
||||
) {
|
||||
child_count := ts.node_named_child_count(node)
|
||||
for i in 0 ..< child_count {
|
||||
child := ts.node_named_child(node, u32(i))
|
||||
type_str := string(ts.node_type(child))
|
||||
|
||||
if type_str == "comment" {
|
||||
append(
|
||||
comments,
|
||||
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
|
||||
)
|
||||
} else if type_str == "script_element" || type_str == "style_element" {
|
||||
append(
|
||||
preserves,
|
||||
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
|
||||
)
|
||||
} else if type_str == "element" {
|
||||
tag := html_tag_name(child, source)
|
||||
if is_preserve_tag(tag) {
|
||||
append(
|
||||
preserves,
|
||||
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
|
||||
)
|
||||
} else {
|
||||
collect_html_ranges(child, source, comments, preserves)
|
||||
}
|
||||
} else {
|
||||
collect_html_ranges(child, source, comments, preserves)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html_tag_name :: proc(element: ts.Node, source: string) -> string {
|
||||
child_count := ts.node_named_child_count(element)
|
||||
for i in 0 ..< child_count {
|
||||
child := ts.node_named_child(element, u32(i))
|
||||
if string(ts.node_type(child)) == "start_tag" {
|
||||
tag_child_count := ts.node_named_child_count(child)
|
||||
for j in 0 ..< tag_child_count {
|
||||
tag_child := ts.node_named_child(child, u32(j))
|
||||
if string(ts.node_type(tag_child)) == "tag_name" {
|
||||
start := ts.node_start_byte(tag_child)
|
||||
end := ts.node_end_byte(tag_child)
|
||||
return source[start:end]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
is_preserve_tag :: proc(tag: string) -> bool {
|
||||
for t in PRESERVE_TAGS {
|
||||
if tag == t do return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
CSS_DELIMS :: [?]u8{'{', '}', ':', ';', ','}
|
||||
|
||||
is_css_delim :: proc(c: u8) -> bool {
|
||||
for d in CSS_DELIMS {
|
||||
if c == d do return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
minify_css :: proc(source: string) -> string {
|
||||
gc := ts.ensure_parser("css")
|
||||
if gc == nil {
|
||||
return source
|
||||
}
|
||||
|
||||
source_c := strings.clone_to_cstring(source)
|
||||
defer delete(source_c)
|
||||
|
||||
tree := ts.parser_parse_string(gc.parser, nil, source_c, u32(len(source)))
|
||||
if tree == nil {
|
||||
return source
|
||||
}
|
||||
defer ts.tree_delete(tree)
|
||||
|
||||
root := ts.tree_root_node(tree)
|
||||
|
||||
if ts.node_has_error(root) {
|
||||
log.warnf("minify: CSS parse errors, skipping minification")
|
||||
return source
|
||||
}
|
||||
|
||||
comments: [dynamic]Range
|
||||
defer delete(comments)
|
||||
|
||||
collect_css_comments(root, &comments)
|
||||
|
||||
sb := strings.builder_make()
|
||||
|
||||
ci := 0
|
||||
i := 0
|
||||
last_written: u8 = 0
|
||||
|
||||
for i < len(source) {
|
||||
if ci < len(comments) && u32(i) >= comments[ci].start {
|
||||
i = int(comments[ci].end)
|
||||
ci += 1
|
||||
continue
|
||||
}
|
||||
|
||||
c := source[i]
|
||||
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
|
||||
j := i + 1
|
||||
for j < len(source) {
|
||||
c2 := source[j]
|
||||
if c2 != ' ' && c2 != '\t' && c2 != '\n' && c2 != '\r' {
|
||||
break
|
||||
}
|
||||
j += 1
|
||||
}
|
||||
|
||||
next: u8 = 0
|
||||
if j < len(source) {
|
||||
next = source[j]
|
||||
}
|
||||
|
||||
if !is_css_delim(last_written) && !is_css_delim(next) {
|
||||
strings.write_byte(&sb, ' ')
|
||||
last_written = ' '
|
||||
}
|
||||
i = j
|
||||
} else {
|
||||
strings.write_byte(&sb, c)
|
||||
last_written = c
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
collect_css_comments :: proc(node: ts.Node, comments: ^[dynamic]Range) {
|
||||
child_count := ts.node_named_child_count(node)
|
||||
for i in 0 ..< child_count {
|
||||
child := ts.node_named_child(node, u32(i))
|
||||
if string(ts.node_type(child)) == "comment" {
|
||||
append(
|
||||
comments,
|
||||
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
|
||||
)
|
||||
} else {
|
||||
collect_css_comments(child, comments)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
mustache
|
||||
@@ -0,0 +1,96 @@
|
||||
# Mustache Extensions
|
||||
|
||||
Thor's mustache engine implements the [Mustache spec](https://github.com/mustache/spec) plus the following non-standard extensions. Extensions are opt-in via template syntax — vanilla mustache templates render identically to the spec.
|
||||
|
||||
## Pipes
|
||||
|
||||
A pipe expression appears inside a section tag or an interpolation tag and transforms the resolved value before rendering. Pipes let templates request different views of the same data without privileged Go-side support.
|
||||
|
||||
### Syntax
|
||||
|
||||
```
|
||||
{{#<key> | <op> <arg> <arg>... | <op> <arg>... }} … {{/<key>}}
|
||||
{{<key> | <op> <arg> <arg>... | <op> <arg>...}}
|
||||
```
|
||||
|
||||
- The first whitespace-separated token after `|` is the op name; remaining tokens are its arguments.
|
||||
- Whitespace around `|` is optional: `posts|group_by year` and `posts | group_by year` are equivalent.
|
||||
- Multiple filters compose left-to-right.
|
||||
- **At most 8 filters** may appear in a single tag (compile-time constant `MAX_PIPES` in `pipes.odin`). Exceeding it is a parse error.
|
||||
- **At most 2 args** per filter (compile-time constant `MAX_PIPE_ARGS`). Exceeding it is a parse error.
|
||||
- The close tag is the **bare key only**. Pipe expressions are not allowed in close tags — `{{/posts | group_by year}}` is a parse error.
|
||||
- Pipes work in both section tags (`{{#…}}` and `{{^…}}`) and interpolation tags (`{{…}}` and `{{&…}}`). In sections, the transformed value becomes the section's context. In interpolations, the transformed value is what gets rendered.
|
||||
|
||||
### Example
|
||||
|
||||
```handlebars
|
||||
{{#posts | group_by year}}
|
||||
<section>
|
||||
<h2>{{key}}</h2>
|
||||
<ul>{{#items}}<li>{{title}}</li>{{/items}}</ul>
|
||||
</section>
|
||||
{{/posts}}
|
||||
|
||||
<!-- Interpolation pipe: format a date string for display -->
|
||||
<time datetime="{{date}}">{{date | format}}</time>
|
||||
```
|
||||
|
||||
### Available ops
|
||||
|
||||
#### `group_by <field>`
|
||||
|
||||
Buckets each element of a list by the value of `<field>`. Returns a list of `Group` values, each shaped as:
|
||||
|
||||
```
|
||||
Group :: struct {
|
||||
key: string, // the distinct field value
|
||||
items: [dynamic]any, // elements sharing that value
|
||||
}
|
||||
```
|
||||
|
||||
Group order preserves first-appearance order in the input list (not key-sorted).
|
||||
|
||||
Errors (returned as `Data_Error` at render time):
|
||||
- Argument count is not 1.
|
||||
- Input is not a list.
|
||||
- Any element is missing the named field.
|
||||
- Any element has an empty value for the named field.
|
||||
|
||||
#### `format`
|
||||
|
||||
Formats an ISO 8601 date string as a display string. Takes a string, returns a string (e.g. `"2026-03-15T08:49:54-04:00"` → `"15 Mar 2026"`). Invalid input (empty, too-short, non-string, or unparsable) returns a `Data_Error`. Templates that need to skip dateless pages should gate with a section — `{{#date}}<time datetime="{{.}}">{{. | format}}</time>{{/date}}` — so the section's truthiness check catches empty before the filter runs. Commonly used inline as `{{date | format}}` to render a display string while keeping the raw ISO available via `{{date}}` for the `datetime=` attribute.
|
||||
|
||||
Internally: parses the invariant `YYYY-MM-DD` prefix by char offset, stringifies `time.Month(month_num)` and slices `[:3]` for the abbreviation. Accepts any of these ISO 8601 forms (the date prefix is what matters): `2023-10-15T13:18:50-07:00`, `2023-10-15T13:18:50-0700`, `2023-10-15T13:18:50Z`, `2023-10-15T13:18:50`, `2023-10-15`.
|
||||
|
||||
Takes an optional arg for the Go reference-date layout to use:
|
||||
- A double-quoted literal, spaces allowed: `{{date | format "Mon Jan 2 2006"}}`.
|
||||
- A bare key, resolved from context like any other field: `{{date | format long}}` uses the value of `long` (e.g. a site-config field) as the layout.
|
||||
- No arg: falls back to the `date_format` context key (typically `date.format` from `thor.json`).
|
||||
|
||||
#### Timezone conversion
|
||||
|
||||
When the `timezone` context key is set (typically `date.timezone` from `thor.json`, an IANA name like `"America/New_York"`), the `format` pipe converts dates to that timezone before formatting:
|
||||
|
||||
- **Date has offset + target tz**: adjusts to true UTC, then converts to the target timezone (DST-aware).
|
||||
- **Date has no offset + target tz**: assumes the date is already in the target timezone — no conversion, only resolves the abbreviation.
|
||||
- **Date has offset + no target tz**: displays in the source offset.
|
||||
- **Date has no offset + no target tz**: displays as-is, assumes UTC.
|
||||
|
||||
The `MST` token reflects the active timezone:
|
||||
|
||||
| Config tz | ISO has offset | `MST` output |
|
||||
|---|---|---|
|
||||
| `"America/New_York"` | yes | `"EST"` or `"EDT"` (DST-aware) |
|
||||
| `"America/New_York"` | no | `"EST"` or `"EDT"` |
|
||||
| not set | yes | `"UTC-04:00"` (from source offset) |
|
||||
| not set | no | `"UTC"` |
|
||||
|
||||
Timezone data is loaded once by `init_site` via `core:time/timezone.region_load`, using the site arena allocator. The resolved `^datetime.TZ_Region` pointer is stored on `Site.tz` and passed to templates through `Base_Data.timezone`. The arena frees it automatically on `destroy_site`.
|
||||
|
||||
### Memory ownership
|
||||
|
||||
- **Parsed pipe filters** (`Pipe_Filter` values) are stored inline on each `Node` via `[dynamic; MAX_PIPES]Pipe_Filter`, and `args` is inline on each `Pipe_Filter` via `[dynamic; MAX_PIPE_ARGS]string`. Both use Odin's fixed-capacity dynamic array type, so no per-tag heap allocations occur at parse time. The storage dies with the `Template` when `delete_template` is called.
|
||||
- **Filter results** (e.g. the `[dynamic]Group` returned by `group_by`, or the display string returned by `format`) are render-scoped allocations in `context.temp_allocator`. They die with the render call. No caller-side cleanup is needed.
|
||||
- The string data inside `Pipe_Filter` (op names, args) is borrowed from the template source — no cloning.
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
- [ ] Initialize string builder size based on filesize?
|
||||
- [x] Review [whitespace handling code](./tokenizer.odin)
|
||||
- [x] Add leak checks for public procs.
|
||||
- [ ] Simplify `trim_standalone_whitespace` — four temp arrays (`li_buf`, `ri_buf`, `left_done`, `right_done`) could potentially be reduced to two by merging the done-tracking into the index buffers using sentinel values (e.g. `-2` = already trimmed).
|
||||
- [ ] Adjacent standalone tags don't capture indent for the second tag (e.g. ` {{>a}}\n {{>b}}` — `{{>b}}` gets `indent=""` because its left text was consumed by `{{>a}}`'s right-trim). Blocks recover via `deindent_blocks`, but partials/parents have no fallback.
|
||||
- [ ] lazy load partials ? (with mutex)
|
||||
@@ -0,0 +1,295 @@
|
||||
package mustache
|
||||
|
||||
import "base:runtime"
|
||||
import "core:fmt"
|
||||
import "core:reflect"
|
||||
import "core:strconv"
|
||||
import "core:strings"
|
||||
|
||||
// base_value unwraps union variants and strips Named/Distinct layers,
|
||||
// returning the "peeled" any value and its base type info.
|
||||
base_value :: proc(a: any) -> (val: any, info: ^runtime.Type_Info) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
|
||||
val = a
|
||||
info = nil
|
||||
|
||||
ti := type_info_of(val.id)
|
||||
if ti == nil {
|
||||
return
|
||||
}
|
||||
|
||||
base := runtime.type_info_base(ti)
|
||||
|
||||
if _, ok := base.variant.(runtime.Type_Info_Union); ok {
|
||||
variant := reflect.get_union_variant(val)
|
||||
if variant == nil {
|
||||
return {}, nil
|
||||
} else {
|
||||
return base_value(variant)
|
||||
}
|
||||
}
|
||||
|
||||
info = base
|
||||
return
|
||||
}
|
||||
|
||||
// lookup_in resolves a single key in a container (struct or map).
|
||||
// Returns found=false if the key doesn't exist or the container is not a struct/map.
|
||||
lookup_in :: proc(container: any, key: string) -> (result: any, found: bool) {
|
||||
if container == nil {
|
||||
return
|
||||
}
|
||||
|
||||
val, info := base_value(container)
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
|
||||
#partial switch v in info.variant {
|
||||
case runtime.Type_Info_Struct:
|
||||
result = reflect.struct_field_value_by_name(val, key, allow_using = true)
|
||||
found = result != nil
|
||||
case runtime.Type_Info_Map:
|
||||
mi := v
|
||||
rm_ptr := (^runtime.Raw_Map)(val.data)
|
||||
if rm_ptr.len == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
k := key
|
||||
seed := runtime.map_seed(rm_ptr^)
|
||||
h := mi.map_info.key_hasher(&k, seed)
|
||||
value_ptr := runtime.__dynamic_map_get(rm_ptr, mi.map_info, h, &k)
|
||||
if value_ptr != nil {
|
||||
if _, ok := mi.value.variant.(runtime.Type_Info_Any); ok {
|
||||
result = (^any)(value_ptr)^
|
||||
} else {
|
||||
result = any{value_ptr, mi.value.id}
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// resolve_name resolves a (possibly dotted) name from the context stack.
|
||||
// The first segment walks the stack top-to-bottom; remaining segments
|
||||
// resolve against the prior result only.
|
||||
resolve_name :: proc(name: string, ctx: []any) -> any {
|
||||
if name == "." {
|
||||
if len(ctx) > 0 {
|
||||
return ctx[len(ctx) - 1]
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
parts: [16]string
|
||||
part_count := 0
|
||||
start := 0
|
||||
for i in 0 ..< len(name) {
|
||||
if name[i] == '.' {
|
||||
if part_count < len(parts) {
|
||||
parts[part_count] = name[start:i]
|
||||
part_count += 1
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if part_count < len(parts) {
|
||||
parts[part_count] = name[start:]
|
||||
part_count += 1
|
||||
}
|
||||
|
||||
if part_count == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
dot_parts := parts[:part_count]
|
||||
|
||||
result: any = nil
|
||||
found := false
|
||||
for i := len(ctx) - 1; i >= 0; i -= 1 {
|
||||
result, found = lookup_in(ctx[i], dot_parts[0])
|
||||
if found {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
if len(dot_parts) == 1 {
|
||||
return result
|
||||
}
|
||||
|
||||
for i := 1; i < len(dot_parts); i += 1 {
|
||||
result, found = lookup_in(result, dot_parts[i])
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return false
|
||||
}
|
||||
|
||||
val, info := base_value(a)
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
if reflect.is_nil(val) {
|
||||
return false
|
||||
}
|
||||
|
||||
#partial switch _ in info.variant {
|
||||
case runtime.Type_Info_Slice, runtime.Type_Info_Dynamic_Array, runtime.Type_Info_Map:
|
||||
return reflect.length(val) > 0
|
||||
case:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
val, info := base_value(a)
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
|
||||
#partial switch v in info.variant {
|
||||
case runtime.Type_Info_Slice:
|
||||
raw := (^runtime.Raw_Slice)(val.data)^
|
||||
return v.elem, raw.len, raw.data
|
||||
case runtime.Type_Info_Dynamic_Array:
|
||||
raw := (^runtime.Raw_Dynamic_Array)(val.data)^
|
||||
return v.elem, raw.len, raw.data
|
||||
case runtime.Type_Info_Array:
|
||||
return v.elem, v.count, val.data
|
||||
case:
|
||||
return nil, 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
// extract_list_element returns the n-th element of a list (described by
|
||||
// elem_info/data from list_info) as a ready-to-use any.
|
||||
//
|
||||
// Unwraps [dynamic]any element types so downstream lookup_in sees the inner
|
||||
// value's real type, not a double-wrapped any-of-any.
|
||||
extract_list_element :: proc(elem_info: ^runtime.Type_Info, data: rawptr, n: int) -> any {
|
||||
elem_ptr := rawptr(uintptr(data) + uintptr(n) * uintptr(elem_info.size))
|
||||
if _, is_any := elem_info.variant.(runtime.Type_Info_Any); is_any {
|
||||
return (^any)(elem_ptr)^
|
||||
}
|
||||
return any{elem_ptr, elem_info.id}
|
||||
}
|
||||
|
||||
// any_to_string converts a scalar value to a string using the temp allocator.
|
||||
any_to_string :: proc(a: any) -> string {
|
||||
if a == nil {
|
||||
return ""
|
||||
}
|
||||
val, _ := base_value(a)
|
||||
|
||||
switch v in val {
|
||||
case string:
|
||||
return v
|
||||
case bool:
|
||||
return "true" if v else "false"
|
||||
case i64:
|
||||
return fmt.tprintf("%d", v)
|
||||
case f64:
|
||||
// return fmt.tprintf("%.3f", v)
|
||||
return format_f64(v)
|
||||
case int:
|
||||
return fmt.tprintf("%d", v)
|
||||
case:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// format_f64 produces the shortest string that round-trips to the same f64.
|
||||
// Works around Odin's strconv not implementing shortest representation.
|
||||
format_f64 :: proc(v: f64) -> string {
|
||||
buf: [64]byte
|
||||
for prec in 1 ..= 17 {
|
||||
s := strconv.write_float(buf[:], v, 'g', prec, 64)
|
||||
if len(s) > 0 && s[0] == '+' {
|
||||
s = s[1:]
|
||||
}
|
||||
parsed, ok := strconv.parse_f64(s)
|
||||
if ok && parsed == v {
|
||||
return strings.clone(s, context.temp_allocator)
|
||||
}
|
||||
}
|
||||
s := strconv.write_float(buf[:], v, 'g', -1, 64)
|
||||
if len(s) > 0 && s[0] == '+' {
|
||||
s = s[1:]
|
||||
}
|
||||
return strings.clone(s, context.temp_allocator)
|
||||
}
|
||||
|
||||
// write_value stringifies a value and writes it to the builder,
|
||||
// optionally HTML-escaped.
|
||||
write_value :: proc(b: ^strings.Builder, a: any, escape: bool) {
|
||||
s := any_to_string(a)
|
||||
if len(s) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if !escape {
|
||||
strings.write_string(b, s)
|
||||
return
|
||||
}
|
||||
|
||||
start := 0
|
||||
for i in 0 ..< len(s) {
|
||||
switch s[i] {
|
||||
case '&', '<', '>', '"':
|
||||
if i > start {
|
||||
strings.write_string(b, s[start:i])
|
||||
}
|
||||
switch s[i] {
|
||||
case '&':
|
||||
strings.write_string(b, "&")
|
||||
case '<':
|
||||
strings.write_string(b, "<")
|
||||
case '>':
|
||||
strings.write_string(b, ">")
|
||||
case '"':
|
||||
strings.write_string(b, """)
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
strings.write_string(b, s[start:])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package mustache
|
||||
|
||||
import "core:fmt"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
import "core:terminal/ansi"
|
||||
import "core:unicode/utf8"
|
||||
|
||||
// line_col returns the 1-indexed line and column for a byte offset in source.
|
||||
// Newlines ('\n') separate lines; '\r' is treated as part of '\r\n'. Column is
|
||||
// counted in bytes from the start of the line.
|
||||
line_col :: proc(source: string, pos_in: int) -> (line: int, col: int) {
|
||||
pos := pos_in
|
||||
if pos < 0 {
|
||||
return 1, 1
|
||||
}
|
||||
if pos > len(source) {
|
||||
pos = len(source)
|
||||
}
|
||||
line = 1
|
||||
col = 1
|
||||
for i := 0; i < pos; i += 1 {
|
||||
if source[i] == '\n' {
|
||||
line += 1
|
||||
col = 1
|
||||
} else {
|
||||
col += 1
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// line_text returns the Nth (1-indexed) line of source, without the trailing
|
||||
// newline. Returns "" if line is out of range.
|
||||
line_text :: proc(source: string, line: int) -> string {
|
||||
if line < 1 {
|
||||
return ""
|
||||
}
|
||||
current := 1
|
||||
start := 0
|
||||
for i := 0; i < len(source); i += 1 {
|
||||
if current == line {
|
||||
end := i
|
||||
for end < len(source) && source[end] != '\n' {
|
||||
end += 1
|
||||
}
|
||||
return source[start:end]
|
||||
}
|
||||
if source[i] == '\n' {
|
||||
current += 1
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if current == line {
|
||||
return source[start:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// context_extent returns the byte offset of the start of the line containing
|
||||
// pos, plus the byte offsets of the start and end of the mustache tag at pos.
|
||||
// Used to underline the offending tag. If pos is not inside a tag, the
|
||||
// returned [token_start, token_end) is a single rune at pos.
|
||||
context_extent :: proc(
|
||||
source: string,
|
||||
pos_in: int,
|
||||
) -> (
|
||||
line_start: int,
|
||||
token_start: int,
|
||||
token_end: int,
|
||||
) {
|
||||
pos := pos_in
|
||||
if pos < 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
if pos >= len(source) {
|
||||
pos = len(source) - 1
|
||||
}
|
||||
line_start = pos
|
||||
for line_start > 0 && source[line_start - 1] != '\n' {
|
||||
line_start -= 1
|
||||
}
|
||||
|
||||
// Scan forward from line_start for `{{ ... }}` tags. If pos falls inside
|
||||
// any tag's byte range, return that tag's extent.
|
||||
i := line_start
|
||||
for i + 1 < len(source) {
|
||||
if source[i] == '{' && source[i + 1] == '{' {
|
||||
tag_start := i
|
||||
// Find closing }}
|
||||
j := i + 2
|
||||
depth := 1
|
||||
for j + 1 < len(source) && depth > 0 {
|
||||
if source[j] == '{' && source[j + 1] == '{' {
|
||||
depth += 1
|
||||
j += 2
|
||||
} else if source[j] == '}' && source[j + 1] == '}' {
|
||||
depth -= 1
|
||||
j += 2
|
||||
} else {
|
||||
j += 1
|
||||
}
|
||||
}
|
||||
tag_end := j
|
||||
if pos >= tag_start && pos < tag_end {
|
||||
return line_start, tag_start, tag_end
|
||||
}
|
||||
i = tag_end
|
||||
} else {
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Not inside a tag — underline a single rune at pos.
|
||||
return line_start, pos, pos + 1
|
||||
}
|
||||
|
||||
// should_colorize returns true if stderr is a TTY and color output is wanted.
|
||||
should_colorize :: proc() -> bool {
|
||||
return os.is_tty(os.stderr)
|
||||
}
|
||||
|
||||
// count_lines returns the number of '\n'-separated lines in source.
|
||||
// A trailing newline does not add an extra line.
|
||||
count_lines :: proc(source: string) -> int {
|
||||
if len(source) == 0 {
|
||||
return 1
|
||||
}
|
||||
n := 1
|
||||
for c in source {
|
||||
if c == '\n' {
|
||||
n += 1
|
||||
}
|
||||
}
|
||||
// Drop phantom last line if source ends with '\n'.
|
||||
if len(source) > 0 && source[len(source) - 1] == '\n' {
|
||||
n -= 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// digit_count returns the number of decimal digits in n (min 1).
|
||||
digit_count :: proc(n: int) -> int {
|
||||
if n <= 0 {
|
||||
return 1
|
||||
}
|
||||
c := 0
|
||||
x := n
|
||||
for x > 0 {
|
||||
c += 1
|
||||
x /= 10
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// display_width returns the number of terminal cells `s` occupies.
|
||||
// For ASCII this is byte length; for UTF-8 we count runes (combining
|
||||
// marks and wide CJK chars are still approximate).
|
||||
display_width :: proc(s: string) -> int {
|
||||
return utf8.rune_count_in_string(s)
|
||||
}
|
||||
|
||||
// format_error produces a rust-style multi-line diagnostic string.
|
||||
//
|
||||
// <msg>
|
||||
// --> <path>:<line>:<col>
|
||||
// |
|
||||
// N | <source line N-2>
|
||||
// N | <source line N-1>
|
||||
// N | <source line N — the error line>
|
||||
// | ^^^^^^^^^^^ <hint>
|
||||
// N | <source line N+1>
|
||||
// N | <source line N+2>
|
||||
// |
|
||||
//
|
||||
// `context_before`/`context_after` lines of context are shown around the
|
||||
// error line. Line numbers are right-aligned to the width of the largest
|
||||
// line number shown.
|
||||
format_error :: proc(
|
||||
path: string,
|
||||
source: string,
|
||||
pos: int,
|
||||
msg: string,
|
||||
hint: string = "",
|
||||
context_before: int = 2,
|
||||
context_after: int = 2,
|
||||
colorize: bool = false,
|
||||
span: int = 0,
|
||||
) -> string {
|
||||
line, col := line_col(source, pos)
|
||||
total_lines := count_lines(source)
|
||||
|
||||
start_line := line - context_before
|
||||
if start_line < 1 {
|
||||
start_line = 1
|
||||
}
|
||||
end_line := line + context_after
|
||||
if end_line > total_lines {
|
||||
end_line = total_lines
|
||||
}
|
||||
|
||||
// Width of the line-number column (right-align).
|
||||
width := digit_count(end_line)
|
||||
if width < 1 {
|
||||
width = 1
|
||||
}
|
||||
|
||||
sb := strings.builder_make(context.temp_allocator)
|
||||
defer strings.builder_destroy(&sb)
|
||||
|
||||
color := colorize
|
||||
red, faint, reset := "", "", ""
|
||||
if color {
|
||||
red = ansi.CSI + ansi.FG_RED + ansi.SGR
|
||||
faint = ansi.CSI + ansi.FAINT + ansi.SGR
|
||||
reset = ansi.CSI + ansi.RESET + ansi.SGR
|
||||
}
|
||||
|
||||
// Header line: message.
|
||||
strings.write_string(&sb, msg)
|
||||
strings.write_byte(&sb, '\n')
|
||||
|
||||
// Location line: " --> path:line:col" (width spaces + arrow).
|
||||
strings.write_string(&sb, faint)
|
||||
for _ in 0 ..< width {
|
||||
strings.write_byte(&sb, ' ')
|
||||
}
|
||||
strings.write_string(&sb, "--> ")
|
||||
strings.write_string(&sb, reset)
|
||||
fmt.sbprintf(&sb, "%s:%d:%d\n", path, line, col)
|
||||
|
||||
// Top gutter line.
|
||||
write_gutter(&sb, width, faint, reset)
|
||||
|
||||
// Caret extent for the error line.
|
||||
line_start, token_start, token_end := context_extent(source, pos)
|
||||
if span > 0 {
|
||||
token_start = pos
|
||||
token_end = pos + span
|
||||
}
|
||||
caret_start_col := token_start - line_start + 1
|
||||
caret_end_col := token_end - line_start + 1
|
||||
if caret_end_col <= caret_start_col {
|
||||
caret_end_col = caret_start_col + 1
|
||||
}
|
||||
|
||||
// Context lines.
|
||||
for n in start_line ..= end_line {
|
||||
// Line number (right-aligned, faint).
|
||||
num_str := fmt.tprintf("%d", n)
|
||||
strings.write_string(&sb, faint)
|
||||
for _ in 0 ..< width - len(num_str) {
|
||||
strings.write_byte(&sb, ' ')
|
||||
}
|
||||
strings.write_string(&sb, num_str)
|
||||
strings.write_string(&sb, " | ")
|
||||
strings.write_string(&sb, reset)
|
||||
|
||||
strings.write_string(&sb, line_text(source, n))
|
||||
strings.write_byte(&sb, '\n')
|
||||
|
||||
// After the error line, emit the caret row.
|
||||
if n == line {
|
||||
strings.write_string(&sb, faint)
|
||||
for _ in 0 ..< width + 1 {
|
||||
strings.write_byte(&sb, ' ')
|
||||
}
|
||||
strings.write_string(&sb, "| ")
|
||||
strings.write_string(&sb, reset)
|
||||
|
||||
for _ in 1 ..< caret_start_col {
|
||||
strings.write_byte(&sb, ' ')
|
||||
}
|
||||
if color {
|
||||
strings.write_string(&sb, red)
|
||||
}
|
||||
for _ in 0 ..< caret_end_col - caret_start_col {
|
||||
strings.write_byte(&sb, '^')
|
||||
}
|
||||
if color {
|
||||
strings.write_string(&sb, reset)
|
||||
}
|
||||
if hint != "" {
|
||||
strings.write_byte(&sb, ' ')
|
||||
if color {
|
||||
strings.write_string(&sb, faint)
|
||||
}
|
||||
strings.write_string(&sb, hint)
|
||||
if color {
|
||||
strings.write_string(&sb, reset)
|
||||
}
|
||||
}
|
||||
strings.write_byte(&sb, '\n')
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing gutter line for visual closure.
|
||||
write_gutter(&sb, width, faint, reset)
|
||||
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
// write_gutter emits a faint pipe-only gutter line: `<width+1 spaces> |`.
|
||||
write_gutter :: proc(sb: ^strings.Builder, width: int, faint: string, reset: string) {
|
||||
strings.write_string(sb, faint)
|
||||
for _ in 0 ..< width + 1 {
|
||||
strings.write_byte(sb, ' ')
|
||||
}
|
||||
strings.write_string(sb, "|")
|
||||
strings.write_string(sb, reset)
|
||||
strings.write_byte(sb, '\n')
|
||||
}
|
||||
|
||||
// format_render_error produces a diagnostic for an Error value using the
|
||||
// template's path and source for context. Returns "" for nil errors.
|
||||
// If the error carries its own source/path (from tag_error), those are used
|
||||
// instead of the passed-in template — this ensures errors inside partials
|
||||
// point at the correct file.
|
||||
format_render_error :: proc(err: Error, tmpl: Template, colorize: bool = false) -> string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
b := body(err)
|
||||
source := b.source != "" ? b.source : tmpl.source
|
||||
path := b.path != "" ? b.path : tmpl.path
|
||||
if path == "" {
|
||||
path = "<input>"
|
||||
}
|
||||
return format_error(path, source, b.pos, b.msg, hint = b.hint, colorize = colorize, span = b.span)
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
#+test
|
||||
package mustache
|
||||
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
import "core:testing"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// line_col / line_text / count_lines / digit_count — primitive helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(test)
|
||||
test_line_col_basic :: proc(t: ^testing.T) {
|
||||
src := "abc\ndef\nghi"
|
||||
cases := [?]struct {
|
||||
pos: int,
|
||||
line: int,
|
||||
col: int,
|
||||
}{{0, 1, 1}, {2, 1, 3}, {3, 1, 4}, {4, 2, 1}, {6, 2, 3}, {7, 2, 4}, {8, 3, 1}}
|
||||
for c in cases {
|
||||
l, col := line_col(src, c.pos)
|
||||
testing.expect(t, l == c.line, fmt.tprintf("pos %d: line %d, want %d", c.pos, l, c.line))
|
||||
testing.expect(t, col == c.col, fmt.tprintf("pos %d: col %d, want %d", c.pos, col, c.col))
|
||||
}
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_line_col_empty :: proc(t: ^testing.T) {
|
||||
l, col := line_col("", 0)
|
||||
testing.expect_value(t, l, 1)
|
||||
testing.expect_value(t, col, 1)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_line_col_negative :: proc(t: ^testing.T) {
|
||||
l, col := line_col("abc", -1)
|
||||
testing.expect_value(t, l, 1)
|
||||
testing.expect_value(t, col, 1)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_line_col_past_end :: proc(t: ^testing.T) {
|
||||
l, col := line_col("abc", 100)
|
||||
testing.expect_value(t, l, 1)
|
||||
testing.expect_value(t, col, 4)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_line_text_first :: proc(t: ^testing.T) {
|
||||
src := "first\nsecond\nthird"
|
||||
testing.expect_value(t, line_text(src, 1), "first")
|
||||
testing.expect_value(t, line_text(src, 2), "second")
|
||||
testing.expect_value(t, line_text(src, 3), "third")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_line_text_trailing_newline :: proc(t: ^testing.T) {
|
||||
src := "first\nsecond\n"
|
||||
testing.expect_value(t, line_text(src, 1), "first")
|
||||
testing.expect_value(t, line_text(src, 2), "second")
|
||||
testing.expect_value(t, line_text(src, 3), "")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_line_text_out_of_range :: proc(t: ^testing.T) {
|
||||
testing.expect_value(t, line_text("abc", 5), "")
|
||||
testing.expect_value(t, line_text("abc", 0), "")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_count_lines :: proc(t: ^testing.T) {
|
||||
testing.expect_value(t, count_lines(""), 1)
|
||||
testing.expect_value(t, count_lines("abc"), 1)
|
||||
testing.expect_value(t, count_lines("a\nb"), 2)
|
||||
testing.expect_value(t, count_lines("a\nb\n"), 2)
|
||||
testing.expect_value(t, count_lines("a\nb\nc"), 3)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_digit_count :: proc(t: ^testing.T) {
|
||||
testing.expect_value(t, digit_count(0), 1)
|
||||
testing.expect_value(t, digit_count(1), 1)
|
||||
testing.expect_value(t, digit_count(9), 1)
|
||||
testing.expect_value(t, digit_count(10), 2)
|
||||
testing.expect_value(t, digit_count(99), 2)
|
||||
testing.expect_value(t, digit_count(100), 3)
|
||||
testing.expect_value(t, digit_count(-5), 1)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// format_error — golden output tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(test)
|
||||
test_format_error_basic :: proc(t: ^testing.T) {
|
||||
src := "line 1\nline 2\n{{bad}}\nline 4\nline 5"
|
||||
out := format_error("p.html", src, 14, "unknown key 'bad'", "", colorize = false)
|
||||
expected := `unknown key 'bad'
|
||||
--> p.html:3:1
|
||||
|
|
||||
1 | line 1
|
||||
2 | line 2
|
||||
3 | {{bad}}
|
||||
| ^^^^^^^
|
||||
4 | line 4
|
||||
5 | line 5
|
||||
|
|
||||
`
|
||||
testing.expect_value(t, out, expected)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_error_with_hint :: proc(t: ^testing.T) {
|
||||
src := "line 1\nline 2\n{{titel}}\nline 4\nline 5"
|
||||
out := format_error(
|
||||
"post.html",
|
||||
src,
|
||||
14,
|
||||
"unknown key 'titel'",
|
||||
"did you mean 'title'?",
|
||||
colorize = false,
|
||||
)
|
||||
expected := `unknown key 'titel'
|
||||
--> post.html:3:1
|
||||
|
|
||||
1 | line 1
|
||||
2 | line 2
|
||||
3 | {{titel}}
|
||||
| ^^^^^^^^^ did you mean 'title'?
|
||||
4 | line 4
|
||||
5 | line 5
|
||||
|
|
||||
`
|
||||
testing.expect_value(t, out, expected)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_error_no_hint_omits_trailing_space :: proc(t: ^testing.T) {
|
||||
src := "{{bad}}"
|
||||
out := format_error("p.html", src, 0, "msg", "", colorize = false)
|
||||
// Caret line ends immediately after the carets — no trailing space.
|
||||
testing.expect(t, strings.contains(out, "^^^^^^^\n"), out)
|
||||
testing.expect(t, !strings.contains(out, "^^^^^^^ \n"), out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases — context window clamping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(test)
|
||||
test_format_error_first_line_only_after_context :: proc(t: ^testing.T) {
|
||||
src := "{{bad}}\nline 2\nline 3\nline 4\nline 5"
|
||||
out := format_error("p.html", src, 0, "msg", "", colorize = false)
|
||||
expected := `msg
|
||||
--> p.html:1:1
|
||||
|
|
||||
1 | {{bad}}
|
||||
| ^^^^^^^
|
||||
2 | line 2
|
||||
3 | line 3
|
||||
|
|
||||
`
|
||||
testing.expect_value(t, out, expected)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_error_last_line_only_before_context :: proc(t: ^testing.T) {
|
||||
src := "line 1\nline 2\nline 3\nline 4\n{{bad}}"
|
||||
out := format_error("p.html", src, 28, "msg", "", colorize = false)
|
||||
expected := `msg
|
||||
--> p.html:5:1
|
||||
|
|
||||
3 | line 3
|
||||
4 | line 4
|
||||
5 | {{bad}}
|
||||
| ^^^^^^^
|
||||
|
|
||||
`
|
||||
testing.expect_value(t, out, expected)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_error_short_source_clamped :: proc(t: ^testing.T) {
|
||||
src := "x\n{{bad}}\ny"
|
||||
out := format_error("p.html", src, 2, "msg", "", colorize = false)
|
||||
expected := `msg
|
||||
--> p.html:2:1
|
||||
|
|
||||
1 | x
|
||||
2 | {{bad}}
|
||||
| ^^^^^^^
|
||||
3 | y
|
||||
|
|
||||
`
|
||||
testing.expect_value(t, out, expected)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_error_single_line_source :: proc(t: ^testing.T) {
|
||||
src := "{{bad}}"
|
||||
out := format_error("p.html", src, 0, "msg", "", colorize = false)
|
||||
expected := `msg
|
||||
--> p.html:1:1
|
||||
|
|
||||
1 | {{bad}}
|
||||
| ^^^^^^^
|
||||
|
|
||||
`
|
||||
testing.expect_value(t, out, expected)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_error_two_digit_line_numbers :: proc(t: ^testing.T) {
|
||||
// 12-line source; error on line 9. end_line=11 → width=2.
|
||||
src := "l01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\n{{bad}}\nl10\nl11\nl12"
|
||||
// Position of `{{bad}}`: 8 lines of "l0N\n" = 8*4 = 32 bytes.
|
||||
out := format_error("p.html", src, 32, "msg", "", colorize = false)
|
||||
testing.expect(t, strings.contains(out, " --> p.html:9:1\n"), out)
|
||||
testing.expect(t, strings.contains(out, " |\n"), out)
|
||||
testing.expect(t, strings.contains(out, " 7 | l07\n"), out)
|
||||
testing.expect(t, strings.contains(out, " 9 | {{bad}}\n"), out)
|
||||
testing.expect(t, strings.contains(out, "11 | l11\n"), out)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_error_three_digit_line_numbers :: proc(t: ^testing.T) {
|
||||
// 102-line source; error on line 100. Width=3 because end_line=102 has 3 digits.
|
||||
parts: [dynamic]string
|
||||
defer delete(parts)
|
||||
for i in 1 ..= 99 {
|
||||
append(&parts, fmt.tprintf("l%03d", i))
|
||||
}
|
||||
append(&parts, "{{bad}}")
|
||||
append(&parts, "l101")
|
||||
append(&parts, "l102")
|
||||
src := strings.join(parts[:], "\n", context.temp_allocator)
|
||||
|
||||
// Find byte position of "{{bad}}": after 99 lines.
|
||||
pos := 0
|
||||
for i in 1 ..= 99 {
|
||||
pos += len(parts[i - 1]) + 1
|
||||
}
|
||||
|
||||
out := format_error("p.html", src, pos, "msg", "", colorize = false)
|
||||
testing.expect(t, strings.contains(out, " --> p.html:100:1\n"), out)
|
||||
testing.expect(t, strings.contains(out, " |\n"), out)
|
||||
testing.expect(t, strings.contains(out, " 98 | l098\n"), out)
|
||||
testing.expect(t, strings.contains(out, "100 | {{bad}}\n"), out)
|
||||
testing.expect(t, strings.contains(out, "102 | l102\n"), out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Caret position
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(test)
|
||||
test_caret_at_column_1 :: proc(t: ^testing.T) {
|
||||
src := "{{bad}} at start"
|
||||
out := format_error("p.html", src, 0, "msg", "", colorize = false)
|
||||
// Caret line should start with "^" right after "| " (no leading spaces).
|
||||
testing.expect(t, strings.contains(out, " | ^^^^^^^\n"), out)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_caret_at_column_N :: proc(t: ^testing.T) {
|
||||
src := " {{bad}}"
|
||||
// pos=4 is the first '{'. Line 1, col 5.
|
||||
out := format_error("p.html", src, 4, "msg", "", colorize = false)
|
||||
// 4 leading spaces, then 7 carets.
|
||||
testing.expect(t, strings.contains(out, " | ^^^^^^^\n"), out)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_caret_width_matches_token :: proc(t: ^testing.T) {
|
||||
src := "{{x}}"
|
||||
out := format_error("p.html", src, 0, "msg", "", colorize = false)
|
||||
// {{x}} is 5 chars wide.
|
||||
testing.expect(t, strings.contains(out, " | ^^^^^\n"), out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context count
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(test)
|
||||
test_context_before_zero :: proc(t: ^testing.T) {
|
||||
src := "l1\nl2\nl3\n{{bad}}\nl5\nl6"
|
||||
out := format_error(
|
||||
"p.html",
|
||||
src,
|
||||
9,
|
||||
"msg",
|
||||
"",
|
||||
context_before = 0,
|
||||
context_after = 1,
|
||||
colorize = false,
|
||||
)
|
||||
expected := `msg
|
||||
--> p.html:4:1
|
||||
|
|
||||
4 | {{bad}}
|
||||
| ^^^^^^^
|
||||
5 | l5
|
||||
|
|
||||
`
|
||||
testing.expect_value(t, out, expected)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_context_after_zero :: proc(t: ^testing.T) {
|
||||
src := "l1\nl2\nl3\n{{bad}}\nl5\nl6"
|
||||
out := format_error(
|
||||
"p.html",
|
||||
src,
|
||||
9,
|
||||
"msg",
|
||||
"",
|
||||
context_before = 1,
|
||||
context_after = 0,
|
||||
colorize = false,
|
||||
)
|
||||
expected := `msg
|
||||
--> p.html:4:1
|
||||
|
|
||||
3 | l3
|
||||
4 | {{bad}}
|
||||
| ^^^^^^^
|
||||
|
|
||||
`
|
||||
testing.expect_value(t, out, expected)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_context_both_zero :: proc(t: ^testing.T) {
|
||||
src := "l1\nl2\nl3\n{{bad}}\nl5\nl6"
|
||||
out := format_error(
|
||||
"p.html",
|
||||
src,
|
||||
9,
|
||||
"msg",
|
||||
"",
|
||||
context_before = 0,
|
||||
context_after = 0,
|
||||
colorize = false,
|
||||
)
|
||||
expected := `msg
|
||||
--> p.html:4:1
|
||||
|
|
||||
4 | {{bad}}
|
||||
| ^^^^^^^
|
||||
|
|
||||
`
|
||||
testing.expect_value(t, out, expected)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gutter/alignment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(test)
|
||||
test_gutter_pipes_align_with_source_pipe :: proc(t: ^testing.T) {
|
||||
src := "l1\n{{bad}}\nl3"
|
||||
out := format_error("p.html", src, 3, "msg", "", colorize = false)
|
||||
// All "|" characters should appear at the same column.
|
||||
// For width=1: source line is "N | ...", so "|" at col 2.
|
||||
// Empty gutter is " |" (width+1 spaces + "|"), so "|" at col 2.
|
||||
lines := strings.split(out, "\n", context.temp_allocator)
|
||||
pipe_col := -1
|
||||
for line in lines {
|
||||
idx := strings.index(line, "|")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
if pipe_col < 0 {
|
||||
pipe_col = idx
|
||||
} else {
|
||||
testing.expect_value(t, idx, pipe_col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_arrow_points_at_pipe :: proc(t: ^testing.T) {
|
||||
src := "{{bad}}"
|
||||
out := format_error("p.html", src, 0, "msg", "", colorize = false)
|
||||
// For width=1: arrow line is " --> ..." so ">" at col 3.
|
||||
// Pipe lines are " |" so "|" at col 2.
|
||||
lines := strings.split(out, "\n", context.temp_allocator)
|
||||
|
||||
pipe_col := -1
|
||||
for line in lines {
|
||||
idx := strings.index(line, "|")
|
||||
if idx >= 0 {
|
||||
pipe_col = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
testing.expect(t, pipe_col >= 0, "expected pipe in output")
|
||||
|
||||
// Find the arrow line specifically and verify its ">" column.
|
||||
arrow_col := -1
|
||||
for line in lines {
|
||||
idx := strings.index(line, "-->")
|
||||
if idx >= 0 {
|
||||
arrow_col = idx + 2 // ">" is the last char of "-->"
|
||||
break
|
||||
}
|
||||
}
|
||||
testing.expect(t, arrow_col >= 0, "expected --> in output")
|
||||
testing.expect_value(t, arrow_col, pipe_col + 1)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// format_render_error — dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(test)
|
||||
test_format_render_error_dispatch :: proc(t: ^testing.T) {
|
||||
src := "{{#unclosed}}\ncontent"
|
||||
tmpl, parse_err := parse(src, "test.html")
|
||||
testing.expect(t, parse_err != nil, "should fail to parse unclosed section")
|
||||
if parse_err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
b := body(parse_err)
|
||||
out := format_error("test.html", src, b.pos, b.msg, colorize = false)
|
||||
testing.expect(t, strings.contains(out, "unclosed section"), out)
|
||||
testing.expect(t, strings.contains(out, "test.html:"), out)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_diagnostic_for_pipe_error :: proc(t: ^testing.T) {
|
||||
src := "{{#name | group_by year}}x{{/name}}"
|
||||
tmpl, perr := parse(src, "test.html")
|
||||
testing.expect(t, perr == nil, "should parse")
|
||||
if perr != nil {
|
||||
return
|
||||
}
|
||||
defer delete_template(&tmpl)
|
||||
|
||||
Data :: struct {
|
||||
name: string,
|
||||
}
|
||||
_, rerr := render(tmpl, Data{name = "hello"})
|
||||
testing.expect(t, rerr != nil, "should fail to render")
|
||||
if rerr == nil {
|
||||
return
|
||||
}
|
||||
|
||||
out := format_render_error(rerr, tmpl, colorize = false)
|
||||
testing.expect(t, strings.contains(out, "group_by expects a list"), out)
|
||||
testing.expect(t, strings.contains(out, "test.html:"), out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parser error messages preserve double braces in tag syntax
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(test)
|
||||
test_parse_error_expected_got_keeps_double_braces :: proc(t: ^testing.T) {
|
||||
src := "{{#content}}body{{/cotent}}"
|
||||
_, err := parse(src, "test.html")
|
||||
testing.expect(t, err != nil, "should fail to parse")
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
b := body(err)
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(b.msg, "{{/content}}"),
|
||||
fmt.tprintf("msg should contain literal {{/content}}, got %q", b.msg),
|
||||
)
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(b.msg, "{{/cotent}}"),
|
||||
fmt.tprintf("msg should contain literal {{/cotent}}, got %q", b.msg),
|
||||
)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_error_unclosed_section_keeps_double_braces :: proc(t: ^testing.T) {
|
||||
src := "{{#content}}body"
|
||||
_, err := parse(src, "test.html")
|
||||
testing.expect(t, err != nil, "should fail to parse")
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
b := body(err)
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(b.msg, "{{#content}}"),
|
||||
fmt.tprintf("msg should contain literal {{#content}}, got %q", b.msg),
|
||||
)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_error_unexpected_close_keeps_double_braces :: proc(t: ^testing.T) {
|
||||
src := "text{{/content}}"
|
||||
_, err := parse(src, "test.html")
|
||||
testing.expect(t, err != nil, "should fail to parse")
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
b := body(err)
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(b.msg, "{{/content}}"),
|
||||
fmt.tprintf("msg should contain literal {{/content}}, got %q", b.msg),
|
||||
)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_error_pipe_in_close_tag_keeps_double_braces :: proc(t: ^testing.T) {
|
||||
src := "{{#posts | group_by year}}x{{/posts | group_by year}}"
|
||||
_, err := parse(src, "test.html")
|
||||
testing.expect(t, err != nil, "should fail to parse")
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
b := body(err)
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(b.msg, "{{/"),
|
||||
fmt.tprintf("msg should contain literal '{{/', got %q", b.msg),
|
||||
)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_error_pipe_parse_in_section_keeps_double_braces :: proc(t: ^testing.T) {
|
||||
src := "{{#posts |}}x{{/posts}}"
|
||||
_, err := parse(src, "test.html")
|
||||
testing.expect(t, err != nil, "should fail to parse")
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
b := body(err)
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(b.msg, "{{#"),
|
||||
fmt.tprintf("msg should contain literal '{{#', got %q", b.msg),
|
||||
)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_error_pipe_parse_in_inverted_keeps_double_braces :: proc(t: ^testing.T) {
|
||||
src := "{{^posts |}}x{{/posts}}"
|
||||
_, err := parse(src, "test.html")
|
||||
testing.expect(t, err != nil, "should fail to parse")
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
b := body(err)
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(b.msg, "{{^"),
|
||||
fmt.tprintf("msg should contain literal '{{^', got %q", b.msg),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package mustache
|
||||
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:strings"
|
||||
import "core:time"
|
||||
import "core:time/datetime"
|
||||
import "core:time/timezone"
|
||||
|
||||
Date_Components :: struct {
|
||||
year: int,
|
||||
month: int,
|
||||
day: int,
|
||||
hour: int,
|
||||
minute: int,
|
||||
second: int,
|
||||
offset_seconds: int,
|
||||
has_offset: bool,
|
||||
tz_abbr: string,
|
||||
}
|
||||
|
||||
// TODO: Use some kind of scanner interface
|
||||
parse_iso_date :: proc(iso: string) -> (c: Date_Components, ok: bool) {
|
||||
if len(iso) < 10 {
|
||||
return {}, false
|
||||
}
|
||||
|
||||
c.year = parse_2_digits(iso, 0) * 100 + parse_2_digits(iso, 2)
|
||||
c.month = parse_2_digits(iso, 5)
|
||||
c.day = parse_2_digits(iso, 8)
|
||||
|
||||
if c.month < 1 || c.month > 12 {
|
||||
return {}, false
|
||||
}
|
||||
if c.day < 1 || c.day > 31 {
|
||||
return {}, false
|
||||
}
|
||||
|
||||
if len(iso) >= 19 && (iso[10] == 'T' || iso[10] == 't') {
|
||||
c.hour = parse_2_digits(iso, 11)
|
||||
c.minute = parse_2_digits(iso, 14)
|
||||
c.second = parse_2_digits(iso, 17)
|
||||
}
|
||||
|
||||
parse_offset(iso, &c)
|
||||
|
||||
c.tz_abbr = "UTC"
|
||||
return c, true
|
||||
}
|
||||
|
||||
parse_2_digits :: proc(s: string, offset: int) -> int {
|
||||
if offset + 1 >= len(s) {
|
||||
return 0
|
||||
}
|
||||
return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30)
|
||||
}
|
||||
|
||||
// parse_offset parses the timezone suffix of an ISO 8601 string (Z,
|
||||
// +HH:MM, +HHMM, -HH:MM, -HHMM). Skips fractional seconds if present.
|
||||
// Does nothing if no recognizable offset is found.
|
||||
parse_offset :: proc(iso: string, c: ^Date_Components) {
|
||||
pos := 19
|
||||
if pos >= len(iso) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip fractional seconds (e.g., .123)
|
||||
if iso[pos] == '.' {
|
||||
pos += 1
|
||||
for pos < len(iso) && iso[pos] >= '0' && iso[pos] <= '9' {
|
||||
pos += 1
|
||||
}
|
||||
}
|
||||
|
||||
if pos >= len(iso) {
|
||||
return
|
||||
}
|
||||
|
||||
switch iso[pos] {
|
||||
case 'Z', 'z':
|
||||
c.has_offset = true
|
||||
case '+', '-':
|
||||
sign := 1 if iso[pos] == '+' else -1
|
||||
pos += 1
|
||||
if pos + 1 >= len(iso) {
|
||||
return
|
||||
}
|
||||
hours := parse_2_digits(iso, pos)
|
||||
pos += 2
|
||||
|
||||
minutes := 0
|
||||
if pos < len(iso) && iso[pos] == ':' {
|
||||
pos += 1
|
||||
}
|
||||
if pos + 1 < len(iso) {
|
||||
minutes = parse_2_digits(iso, pos)
|
||||
}
|
||||
|
||||
c.offset_seconds = sign * (hours * 3600 + minutes * 60)
|
||||
c.has_offset = true
|
||||
case:
|
||||
// no recognizable offset
|
||||
}
|
||||
}
|
||||
|
||||
format_date :: proc(
|
||||
dt: Date_Components,
|
||||
fmt: string,
|
||||
allocator := context.temp_allocator,
|
||||
) -> string {
|
||||
b: strings.Builder
|
||||
strings.builder_init(&b, allocator)
|
||||
|
||||
for i := 0; i < len(fmt); {
|
||||
matched := match_token(&b, dt, fmt[i:])
|
||||
if matched > 0 {
|
||||
i += matched
|
||||
} else {
|
||||
strings.write_byte(&b, fmt[i])
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
log.debugf("formatted date: '%s'", b.buf)
|
||||
return strings.to_string(b)
|
||||
}
|
||||
|
||||
match_token :: proc(b: ^strings.Builder, dt: Date_Components, s: string) -> int {
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"January",
|
||||
) {fmt.sbprintf(b, "%s", time.Month(dt.month)); return 7}
|
||||
if strings.has_prefix(s, "Monday") {emit_weekday(b, dt, full = true); return 6}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"2006",
|
||||
) {fmt.sbprintf(b, "%04d", dt.year); return 4}
|
||||
if strings.has_prefix(s, "MST") {
|
||||
abbr := dt.tz_abbr
|
||||
if len(abbr) == 0 do abbr = "UTC"
|
||||
strings.write_string(b, abbr)
|
||||
return 3
|
||||
}
|
||||
if strings.has_prefix(s, "Jan") {emit_month_abbr(b, dt); return 3}
|
||||
if strings.has_prefix(s, "Mon") {emit_weekday(b, dt, full = false); return 3}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"06",
|
||||
) {fmt.sbprintf(b, "%02d", dt.year % 100); return 2}
|
||||
if strings.has_prefix(s, "02") {fmt.sbprintf(b, "%02d", dt.day); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"15",
|
||||
) {fmt.sbprintf(b, "%02d", dt.hour); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"04",
|
||||
) {fmt.sbprintf(b, "%02d", dt.minute); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"05",
|
||||
) {fmt.sbprintf(b, "%02d", dt.second); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"01",
|
||||
) {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':
|
||||
fmt.sbprintf(b, "%d", dt.day); return 1
|
||||
case '1':
|
||||
fmt.sbprintf(b, "%d", dt.month); return 1
|
||||
case '4':
|
||||
fmt.sbprintf(b, "%d", dt.minute); return 1
|
||||
case '5':
|
||||
fmt.sbprintf(b, "%d", dt.second); return 1
|
||||
case '3':
|
||||
emit_hour_12(b, dt, pad = false); return 1
|
||||
case:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
emit_month_abbr :: proc(b: ^strings.Builder, dt: Date_Components) {
|
||||
name := fmt.tprintf("%s", time.Month(dt.month))
|
||||
strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
|
||||
}
|
||||
|
||||
emit_weekday :: proc(b: ^strings.Builder, dt: Date_Components, full: bool) {
|
||||
date := datetime.Date {
|
||||
year = i64(dt.year),
|
||||
month = i8(dt.month),
|
||||
day = i8(dt.day),
|
||||
}
|
||||
ordinal, err := datetime.date_to_ordinal(date)
|
||||
if err != .None {
|
||||
strings.write_string(b, "???")
|
||||
return
|
||||
}
|
||||
weekday := datetime.day_of_week(ordinal)
|
||||
name := fmt.tprintf("%s", weekday)
|
||||
if full {
|
||||
strings.write_string(b, name)
|
||||
} else {
|
||||
strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
|
||||
}
|
||||
}
|
||||
|
||||
emit_hour_12 :: proc(b: ^strings.Builder, dt: Date_Components, pad: bool) {
|
||||
h12 := dt.hour % 12
|
||||
if h12 == 0 {h12 = 12}
|
||||
format := "%02d" if pad else "%d"
|
||||
|
||||
fmt.sbprintf(b, format, h12)
|
||||
}
|
||||
|
||||
emit_am_pm :: proc(b: ^strings.Builder, dt: Date_Components) {
|
||||
strings.write_string(b, "PM" if dt.hour >= 12 else "AM")
|
||||
}
|
||||
|
||||
emit_am_pm_lower :: proc(b: ^strings.Builder, dt: Date_Components) {
|
||||
strings.write_string(b, "pm" if dt.hour >= 12 else "am")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timezone conversion infrastructure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
format_offset :: proc(offset_seconds: int) -> string {
|
||||
if offset_seconds == 0 do return "UTC"
|
||||
sign := "+" if offset_seconds > 0 else "-"
|
||||
abs_val := abs(offset_seconds)
|
||||
hours := abs_val / 3600
|
||||
minutes := (abs_val % 3600) / 60
|
||||
return fmt.tprintf("UTC%s%02d:%02d", sign, hours, minutes)
|
||||
}
|
||||
|
||||
// resolve_tz looks up the `tz` field from the context stack and returns
|
||||
// the ^TZ_Region pointer, or nil if not set.
|
||||
resolve_tz :: proc(ctx: []any) -> ^datetime.TZ_Region {
|
||||
raw := resolve_name("timezone", ctx)
|
||||
if raw == nil do return nil
|
||||
switch v in raw {
|
||||
case ^datetime.TZ_Region:
|
||||
return v
|
||||
case:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// compute_utc_offset returns the UTC offset (in seconds) for the given
|
||||
// timezone at the current time. Returns (0, true) if tz is nil.
|
||||
compute_utc_offset :: proc(tz: ^datetime.TZ_Region) -> (offset: int, ok: bool) {
|
||||
if tz == nil do return 0, true
|
||||
tm := time.now()
|
||||
dt_utc := time.time_to_datetime(tm) or_return
|
||||
dt_local := timezone.datetime_to_tz(dt_utc, tz) or_return
|
||||
tm_utc := time.datetime_to_time(dt_utc) or_return
|
||||
tm_local := time.datetime_to_time(dt_local) or_return
|
||||
offset = int(time.time_to_unix(tm_local) - time.time_to_unix(tm_utc))
|
||||
return offset, true
|
||||
}
|
||||
|
||||
// convert_to_tz converts date components from their source timezone to a
|
||||
// target timezone.
|
||||
//
|
||||
// If the source has no offset (has_offset=false), the components are
|
||||
// assumed to be in the target timezone — only the abbreviation is resolved.
|
||||
//
|
||||
// If the source has an offset, the components are first adjusted to true
|
||||
// UTC, then converted to the target timezone.
|
||||
//
|
||||
// Precondition: target_tz != nil.
|
||||
convert_to_tz :: proc(
|
||||
c: Date_Components,
|
||||
target_tz: ^datetime.TZ_Region,
|
||||
) -> (
|
||||
result: Date_Components,
|
||||
ok: bool,
|
||||
) {
|
||||
result = c
|
||||
dt := datetime.DateTime {
|
||||
year = i64(c.year),
|
||||
month = i8(c.month),
|
||||
day = i8(c.day),
|
||||
hour = i8(c.hour),
|
||||
minute = i8(c.minute),
|
||||
second = i8(c.second),
|
||||
}
|
||||
|
||||
if !c.has_offset {
|
||||
dt.tz = target_tz
|
||||
abbr, _ := timezone.shortname(dt)
|
||||
result.tz_abbr = abbr
|
||||
return result, true
|
||||
}
|
||||
|
||||
tm := time.datetime_to_time(dt) or_return
|
||||
|
||||
secs := time.time_to_unix(tm) - i64(c.offset_seconds)
|
||||
tm = time.unix(secs, 0)
|
||||
|
||||
dt_utc := time.time_to_datetime(tm) or_return
|
||||
dt_out := timezone.datetime_to_tz(dt_utc, target_tz) or_return
|
||||
|
||||
abbr, _ := timezone.shortname(dt_out)
|
||||
|
||||
return {
|
||||
year = int(dt_out.year),
|
||||
month = int(dt_out.month),
|
||||
day = int(dt_out.day),
|
||||
hour = int(dt_out.hour),
|
||||
minute = int(dt_out.minute),
|
||||
second = int(dt_out.second),
|
||||
tz_abbr = abbr,
|
||||
},
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
#+test
|
||||
package mustache
|
||||
|
||||
import "core:testing"
|
||||
import "core:time/timezone"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parse_iso_date
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(test)
|
||||
test_parse_iso_date_extracts_time :: proc(t: ^testing.T) {
|
||||
c, ok := parse_iso_date("2026-03-15T08:49:54-04:00")
|
||||
testing.expect(t, ok, "should parse")
|
||||
testing.expect_value(t, c.year, 2026)
|
||||
testing.expect_value(t, c.month, 3)
|
||||
testing.expect_value(t, c.day, 15)
|
||||
testing.expect_value(t, c.hour, 8)
|
||||
testing.expect_value(t, c.minute, 49)
|
||||
testing.expect_value(t, c.second, 54)
|
||||
testing.expect_value(t, c.has_offset, true)
|
||||
testing.expect_value(t, c.offset_seconds, -14400)
|
||||
testing.expect_value(t, c.tz_abbr, "UTC")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_iso_date_lowercase_t :: proc(t: ^testing.T) {
|
||||
c, ok := parse_iso_date("2026-03-15t08:49:54Z")
|
||||
testing.expect(t, ok, "should parse lowercase t separator")
|
||||
testing.expect_value(t, c.hour, 8)
|
||||
testing.expect_value(t, c.minute, 49)
|
||||
testing.expect_value(t, c.second, 54)
|
||||
testing.expect_value(t, c.has_offset, true)
|
||||
testing.expect_value(t, c.offset_seconds, 0)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_iso_date_date_only_zero_time :: proc(t: ^testing.T) {
|
||||
c, ok := parse_iso_date("2026-03-15")
|
||||
testing.expect(t, ok, "should parse date-only")
|
||||
testing.expect_value(t, c.hour, 0)
|
||||
testing.expect_value(t, c.minute, 0)
|
||||
testing.expect_value(t, c.second, 0)
|
||||
testing.expect_value(t, c.has_offset, false)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_iso_date_invalid_day_errors :: proc(t: ^testing.T) {
|
||||
_, ok := parse_iso_date("2026-03-32")
|
||||
testing.expect(t, !ok, "day > 31 should fail")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_iso_date_too_short_errors :: proc(t: ^testing.T) {
|
||||
_, ok := parse_iso_date("2026-03")
|
||||
testing.expect(t, !ok, "input shorter than 10 chars should fail")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_offset_positive_colon :: proc(t: ^testing.T) {
|
||||
c, ok := parse_iso_date("2026-03-15T08:49:54+07:00")
|
||||
testing.expect(t, ok, "should parse")
|
||||
testing.expect_value(t, c.has_offset, true)
|
||||
testing.expect_value(t, c.offset_seconds, 25200)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_offset_positive_no_colon :: proc(t: ^testing.T) {
|
||||
c, ok := parse_iso_date("2026-03-15T08:49:54+0530")
|
||||
testing.expect(t, ok, "should parse")
|
||||
testing.expect_value(t, c.has_offset, true)
|
||||
testing.expect_value(t, c.offset_seconds, 19800)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_offset_hours_only :: proc(t: ^testing.T) {
|
||||
c, ok := parse_iso_date("2026-03-15T08:49:54+05")
|
||||
testing.expect(t, ok, "should parse")
|
||||
testing.expect_value(t, c.has_offset, true)
|
||||
testing.expect_value(t, c.offset_seconds, 18000)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_offset_none_with_time :: proc(t: ^testing.T) {
|
||||
c, ok := parse_iso_date("2026-03-15T08:49:54")
|
||||
testing.expect(t, ok, "should parse")
|
||||
testing.expect_value(t, c.has_offset, false)
|
||||
testing.expect_value(t, c.offset_seconds, 0)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_offset_skips_fractional_seconds :: proc(t: ^testing.T) {
|
||||
c, ok := parse_iso_date("2026-03-15T08:49:54.123Z")
|
||||
testing.expect(t, ok, "should parse")
|
||||
testing.expect_value(t, c.has_offset, true)
|
||||
testing.expect_value(t, c.offset_seconds, 0)
|
||||
testing.expect_value(t, c.second, 54)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// format_date / match_token
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(test)
|
||||
test_format_date_weekday_full :: proc(t: ^testing.T) {
|
||||
// 2026-01-01 is a Thursday.
|
||||
dt := Date_Components {
|
||||
year = 2026,
|
||||
month = 1,
|
||||
day = 1,
|
||||
}
|
||||
result := format_date(dt, "Monday")
|
||||
testing.expect_value(t, result, "Thursday")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_weekday_abbr :: proc(t: ^testing.T) {
|
||||
dt := Date_Components {
|
||||
year = 2026,
|
||||
month = 1,
|
||||
day = 1,
|
||||
}
|
||||
result := format_date(dt, "Mon")
|
||||
testing.expect_value(t, result, "Thu")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_month_full_name :: proc(t: ^testing.T) {
|
||||
dt := Date_Components {
|
||||
year = 2026,
|
||||
month = 3,
|
||||
day = 15,
|
||||
}
|
||||
result := format_date(dt, "January")
|
||||
testing.expect_value(t, result, "March")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_two_digit_year :: proc(t: ^testing.T) {
|
||||
dt := Date_Components {
|
||||
year = 2026,
|
||||
month = 3,
|
||||
day = 15,
|
||||
}
|
||||
result := format_date(dt, "06")
|
||||
testing.expect_value(t, result, "26")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_hour24_padded :: proc(t: ^testing.T) {
|
||||
midnight := Date_Components {
|
||||
year = 2026,
|
||||
month = 1,
|
||||
day = 1,
|
||||
hour = 0,
|
||||
}
|
||||
testing.expect_value(t, format_date(midnight, "15"), "00")
|
||||
|
||||
afternoon := Date_Components {
|
||||
year = 2026,
|
||||
month = 1,
|
||||
day = 1,
|
||||
hour = 13,
|
||||
}
|
||||
testing.expect_value(t, format_date(afternoon, "15"), "13")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_hour12_padded_am_pm_boundaries :: proc(t: ^testing.T) {
|
||||
cases := [4]struct {
|
||||
hour: int,
|
||||
expected: string,
|
||||
}{{0, "12 AM"}, {12, "12 PM"}, {13, "01 PM"}, {23, "11 PM"}}
|
||||
|
||||
for &c in cases {
|
||||
dt := Date_Components {
|
||||
year = 2026,
|
||||
month = 1,
|
||||
day = 1,
|
||||
hour = c.hour,
|
||||
}
|
||||
result := format_date(dt, "03 PM")
|
||||
testing.expect_value(t, result, c.expected)
|
||||
}
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_hour12_unpadded :: proc(t: ^testing.T) {
|
||||
one_am := Date_Components {
|
||||
year = 2026,
|
||||
month = 1,
|
||||
day = 1,
|
||||
hour = 1,
|
||||
}
|
||||
testing.expect_value(t, format_date(one_am, "3"), "1")
|
||||
|
||||
one_pm := Date_Components {
|
||||
year = 2026,
|
||||
month = 1,
|
||||
day = 1,
|
||||
hour = 13,
|
||||
}
|
||||
testing.expect_value(t, format_date(one_pm, "3"), "1")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_am_pm_lowercase :: proc(t: ^testing.T) {
|
||||
afternoon := Date_Components {
|
||||
year = 2026,
|
||||
month = 1,
|
||||
day = 1,
|
||||
hour = 13,
|
||||
}
|
||||
testing.expect_value(t, format_date(afternoon, "pm"), "pm")
|
||||
|
||||
morning := Date_Components {
|
||||
year = 2026,
|
||||
month = 1,
|
||||
day = 1,
|
||||
hour = 9,
|
||||
}
|
||||
testing.expect_value(t, format_date(morning, "pm"), "am")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_minute_second_padding :: proc(t: ^testing.T) {
|
||||
dt := Date_Components {
|
||||
year = 2026,
|
||||
month = 1,
|
||||
day = 1,
|
||||
minute = 4,
|
||||
second = 5,
|
||||
}
|
||||
testing.expect_value(t, format_date(dt, "04:05"), "04:05")
|
||||
testing.expect_value(t, format_date(dt, "4:5"), "4:5")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_month_day_numeric_padding :: proc(t: ^testing.T) {
|
||||
dt := Date_Components {
|
||||
year = 2026,
|
||||
month = 3,
|
||||
day = 5,
|
||||
}
|
||||
testing.expect_value(t, format_date(dt, "01"), "03")
|
||||
testing.expect_value(t, format_date(dt, "1"), "3")
|
||||
testing.expect_value(t, format_date(dt, "02"), "05")
|
||||
testing.expect_value(t, format_date(dt, "2"), "5")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_mst_defaults_utc :: proc(t: ^testing.T) {
|
||||
// Date_Components constructed directly (not via parse_iso_date)
|
||||
// defaults to UTC for the MST token.
|
||||
dt := Date_Components {
|
||||
year = 2026,
|
||||
month = 1,
|
||||
day = 1,
|
||||
hour = 12,
|
||||
}
|
||||
result := format_date(dt, "MST")
|
||||
testing.expect_value(t, result, "UTC")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_literal_passthrough :: proc(t: ^testing.T) {
|
||||
dt := Date_Components {
|
||||
year = 2026,
|
||||
month = 1,
|
||||
day = 1,
|
||||
}
|
||||
result := format_date(dt, "Year: 2006!")
|
||||
testing.expect_value(t, result, "Year: 2026!")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_combined_go_reference_layout :: proc(t: ^testing.T) {
|
||||
// 2023-10-15 is a Sunday.
|
||||
dt := Date_Components {
|
||||
year = 2023,
|
||||
month = 10,
|
||||
day = 15,
|
||||
hour = 13,
|
||||
minute = 18,
|
||||
second = 50,
|
||||
}
|
||||
result := format_date(dt, "Mon Jan 2 15:04:05 MST 2006")
|
||||
testing.expect_value(t, result, "Sun Oct 15 13:18:50 UTC 2023")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// format_offset
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(test)
|
||||
test_format_offset_zero :: proc(t: ^testing.T) {
|
||||
testing.expect_value(t, format_offset(0), "UTC")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_offset_negative :: proc(t: ^testing.T) {
|
||||
testing.expect_value(t, format_offset(-14400), "UTC-04:00")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_offset_positive :: proc(t: ^testing.T) {
|
||||
testing.expect_value(t, format_offset(19800), "UTC+05:30")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// convert_to_tz (require system zoneinfo)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(test)
|
||||
test_convert_to_tz_no_offset_assumes_target :: proc(t: ^testing.T) {
|
||||
tz, tz_ok := timezone.region_load("America/New_York", context.temp_allocator)
|
||||
testing.expect(t, tz_ok, "should load timezone")
|
||||
if !tz_ok do return
|
||||
defer timezone.region_destroy(tz, context.temp_allocator)
|
||||
|
||||
c := Date_Components {
|
||||
year = 2026,
|
||||
month = 3,
|
||||
day = 15,
|
||||
hour = 8,
|
||||
minute = 49,
|
||||
second = 54,
|
||||
}
|
||||
result, ok := convert_to_tz(c, tz)
|
||||
testing.expect_value(t, ok, true)
|
||||
testing.expect_value(t, result.hour, 8)
|
||||
testing.expect_value(t, result.minute, 49)
|
||||
testing.expect(t, len(result.tz_abbr) > 0, "should resolve abbreviation")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_convert_to_tz_with_offset_converts :: proc(t: ^testing.T) {
|
||||
tz, tz_ok := timezone.region_load("America/New_York", context.temp_allocator)
|
||||
testing.expect(t, tz_ok, "should load timezone")
|
||||
if !tz_ok do return
|
||||
defer timezone.region_destroy(tz, context.temp_allocator)
|
||||
|
||||
// 2026-03-15T12:49:54Z (UTC) → 08:49:54 EDT (UTC-4)
|
||||
c := Date_Components {
|
||||
year = 2026,
|
||||
month = 3,
|
||||
day = 15,
|
||||
hour = 12,
|
||||
minute = 49,
|
||||
second = 54,
|
||||
offset_seconds = 0,
|
||||
has_offset = true,
|
||||
}
|
||||
result, ok := convert_to_tz(c, tz)
|
||||
testing.expect_value(t, ok, true)
|
||||
testing.expect_value(t, result.hour, 8)
|
||||
testing.expect_value(t, result.minute, 49)
|
||||
testing.expect_value(t, result.tz_abbr, "EDT")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_convert_to_tz_with_negative_offset_converts :: proc(t: ^testing.T) {
|
||||
tz, tz_ok := timezone.region_load("America/New_York", context.temp_allocator)
|
||||
testing.expect(t, tz_ok, "should load timezone")
|
||||
if !tz_ok do return
|
||||
defer timezone.region_destroy(tz, context.temp_allocator)
|
||||
|
||||
// 2026-03-15T08:49:54-04:00 → UTC 12:49:54 → EDT 08:49:54
|
||||
c := Date_Components {
|
||||
year = 2026,
|
||||
month = 3,
|
||||
day = 15,
|
||||
hour = 8,
|
||||
minute = 49,
|
||||
second = 54,
|
||||
offset_seconds = -14400,
|
||||
has_offset = true,
|
||||
}
|
||||
result, ok := convert_to_tz(c, tz)
|
||||
testing.expect_value(t, ok, true)
|
||||
testing.expect_value(t, result.hour, 8)
|
||||
testing.expect_value(t, result.tz_abbr, "EDT")
|
||||
}
|
||||
@@ -0,0 +1,959 @@
|
||||
package mustache
|
||||
|
||||
import "base:runtime"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:strings"
|
||||
|
||||
// A hypothetical maximum context depth. Trying to pass more than this many items
|
||||
// to render(tpl, data), or nesting templates further than this depth would be
|
||||
// an error.
|
||||
// May be enforced in a later version (for performance)
|
||||
MAX_CONTEXT_DEPTH :: #config(MAX_CONTEXT_DEPTH, 16)
|
||||
|
||||
// Context_Stack is the growable stack of data frames walked top-to-bottom by
|
||||
// resolve_name. Frames are pushed on section descent and popped on exit; the
|
||||
// root data (or each element of a root []any) forms the base frames.
|
||||
Context_Stack :: [dynamic]any
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Error_Kind :: enum {
|
||||
Syntax, // parse-time: malformed template
|
||||
Data, // render-time: template fine, data wrong (e.g. filter misuse)
|
||||
}
|
||||
|
||||
Error_Body :: struct {
|
||||
msg: string,
|
||||
pos: int,
|
||||
kind: Error_Kind,
|
||||
source: string,
|
||||
path: string,
|
||||
span: int,
|
||||
hint: string,
|
||||
}
|
||||
|
||||
// Error is nil when no error occurred.
|
||||
Error :: union {
|
||||
Error_Body,
|
||||
}
|
||||
|
||||
// body unwraps the Error_Body from a non-nil Error.
|
||||
// Precondition: err != nil.
|
||||
body :: proc(err: Error) -> Error_Body {
|
||||
switch e in err {
|
||||
case Error_Body:
|
||||
return e
|
||||
case:
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// tag_error stamps an Error with the source/path of the template where it
|
||||
// originated, so diagnostics point at the correct file (e.g. a partial).
|
||||
tag_error :: proc(err: Error, tmpl: Template) -> Error {
|
||||
if err == nil do return nil
|
||||
b := body(err)
|
||||
return Error_Body {
|
||||
msg = b.msg,
|
||||
pos = b.pos,
|
||||
span = b.span,
|
||||
hint = b.hint,
|
||||
kind = b.kind,
|
||||
source = tmpl.source,
|
||||
path = tmpl.path,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node tree
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Node_Kind :: enum {
|
||||
Text,
|
||||
Variable,
|
||||
Unescaped,
|
||||
Section,
|
||||
Inverted,
|
||||
Partial,
|
||||
Parent,
|
||||
Block,
|
||||
}
|
||||
|
||||
Node :: struct {
|
||||
kind: Node_Kind,
|
||||
text: string,
|
||||
key: string,
|
||||
filters: [dynamic; MAX_PIPES]Pipe_Filter,
|
||||
is_dynamic: bool,
|
||||
indent: string,
|
||||
children: []Node,
|
||||
content: string,
|
||||
pos: int,
|
||||
}
|
||||
|
||||
// node_span returns the number of flat-array entries a node occupies:
|
||||
// 1 for leaf nodes, 1 + len(children) for container nodes (whose children
|
||||
// are stored contiguously after them in the array).
|
||||
node_span :: proc(n: Node) -> int {
|
||||
switch n.kind {
|
||||
case .Section, .Inverted, .Parent, .Block:
|
||||
return 1 + len(n.children)
|
||||
case .Text, .Variable, .Unescaped, .Partial:
|
||||
fallthrough
|
||||
case:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
Template :: struct {
|
||||
nodes: [dynamic]Node,
|
||||
source: string,
|
||||
path: string,
|
||||
}
|
||||
|
||||
Block_Override :: struct {
|
||||
nodes: []Node,
|
||||
source: Template,
|
||||
}
|
||||
|
||||
// Indent_State threads partial-indent tracking through render_nodes so
|
||||
// the renderer can apply indentation at render time instead of reparsing
|
||||
// the partial's source with indentation baked in.
|
||||
Indent_State :: struct {
|
||||
indent: string,
|
||||
at_line_start: bool,
|
||||
}
|
||||
|
||||
delete_template :: proc(tmpl: ^Template) {
|
||||
if tmpl != nil && len(tmpl.nodes) > 0 {
|
||||
delete(tmpl.nodes)
|
||||
}
|
||||
}
|
||||
|
||||
delete_partials :: proc(partials: map[string]Template) {
|
||||
for _, &p in partials {
|
||||
delete_template(&p)
|
||||
}
|
||||
delete(partials)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
parse :: proc(
|
||||
source: string,
|
||||
path := "",
|
||||
allocator := context.allocator,
|
||||
tokens_allocator := context.temp_allocator,
|
||||
) -> (
|
||||
tmpl: Template,
|
||||
err: Error,
|
||||
) {
|
||||
tokens, terr := tokenize(source, tokens_allocator)
|
||||
if terr != nil {
|
||||
return {}, terr
|
||||
}
|
||||
|
||||
tmpl.nodes, err = parse_tokens(tokens[:], source, allocator)
|
||||
if err != nil {
|
||||
delete(tmpl.nodes)
|
||||
return {}, err
|
||||
}
|
||||
tmpl.source = source
|
||||
tmpl.path = path
|
||||
deindent_blocks(tmpl.nodes[:], allocator)
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
render :: proc(
|
||||
tmpl: Template,
|
||||
data: any,
|
||||
partials: map[string]Template = nil,
|
||||
allocator := context.allocator,
|
||||
) -> (
|
||||
result: string,
|
||||
err: Error,
|
||||
) {
|
||||
builder: strings.Builder
|
||||
strings.builder_init(&builder, context.temp_allocator)
|
||||
|
||||
ctx := make(Context_Stack, 0, 4, context.temp_allocator)
|
||||
|
||||
// If data is a []any, expand into individual context frames.
|
||||
// Otherwise, push as a single frame.
|
||||
elem_info, count, slice_data := list_info(data)
|
||||
if elem_info != nil {
|
||||
if _, is_any := elem_info.variant.(runtime.Type_Info_Any); is_any {
|
||||
for j in 0 ..< count {
|
||||
append(&ctx, extract_list_element(elem_info, slice_data, j))
|
||||
}
|
||||
} else {
|
||||
append(&ctx, data)
|
||||
}
|
||||
} else {
|
||||
append(&ctx, data)
|
||||
}
|
||||
|
||||
all_nodes := tmpl.nodes[:]
|
||||
err = render_nodes(tmpl, all_nodes, &ctx, partials, &builder)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
temp := strings.to_string(builder)
|
||||
result = strings.clone(temp, allocator)
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parser — flat token list → flat node array with child indices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
parse_tokens :: proc(
|
||||
tokens: []Token,
|
||||
source: string,
|
||||
allocator := context.allocator,
|
||||
) -> (
|
||||
nodes: [dynamic]Node,
|
||||
err: Error,
|
||||
) {
|
||||
nodes = make([dynamic]Node, 0, len(tokens), allocator)
|
||||
pos := 0
|
||||
err = parse_section(tokens, &pos, &nodes, "", source, allocator, 0)
|
||||
assert(len(nodes) <= cap(nodes))
|
||||
return
|
||||
}
|
||||
|
||||
// tag_content_base returns the absolute byte offset in source where the
|
||||
// trimmed tag content begins (after {{, optional sigil, and whitespace).
|
||||
tag_content_base :: proc(source: string, tag_pos: int) -> int {
|
||||
base := tag_pos + 2 // skip {{
|
||||
if base < len(source) {
|
||||
switch source[base] {
|
||||
case '#', '^', '/', '&', '>', '<', '$', '!':
|
||||
base += 1
|
||||
}
|
||||
}
|
||||
for base < len(source) && (source[base] == ' ' || source[base] == '\t') {
|
||||
base += 1
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
parse_section :: proc(
|
||||
tokens: []Token,
|
||||
pos: ^int,
|
||||
nodes: ^[dynamic]Node,
|
||||
end_tag: string,
|
||||
source: string,
|
||||
allocator := context.allocator,
|
||||
open_pos: int = 0,
|
||||
) -> Error {
|
||||
for pos^ < len(tokens) {
|
||||
tok := tokens[pos^]
|
||||
|
||||
switch tok.kind {
|
||||
case .Text:
|
||||
append(nodes, Node{kind = .Text, text = tok.value, pos = tok.pos})
|
||||
pos^ += 1
|
||||
|
||||
case .Variable:
|
||||
idx := len(nodes)
|
||||
append(nodes, Node{kind = .Variable})
|
||||
pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos, tag_content_base(source, tok.pos))
|
||||
if perr != nil {
|
||||
return Error_Body {
|
||||
msg = fmt.tprintf("pipe parse error in '{{{{%s}}}}': %v", tok.value, perr),
|
||||
pos = tok.pos,
|
||||
kind = .Syntax,
|
||||
}
|
||||
}
|
||||
nodes[idx].key = pipe_key
|
||||
pos^ += 1
|
||||
|
||||
case .Unescaped:
|
||||
idx := len(nodes)
|
||||
append(nodes, Node{kind = .Unescaped})
|
||||
pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos, tag_content_base(source, tok.pos))
|
||||
if perr != nil {
|
||||
return Error_Body {
|
||||
msg = fmt.tprintf("pipe parse error in '{{{{&%s}}}}': %v", tok.value, perr),
|
||||
pos = tok.pos,
|
||||
kind = .Syntax,
|
||||
}
|
||||
}
|
||||
nodes[idx].key = pipe_key
|
||||
pos^ += 1
|
||||
|
||||
case .Comment:
|
||||
pos^ += 1
|
||||
|
||||
case .Section_Open:
|
||||
pos^ += 1
|
||||
idx := len(nodes)
|
||||
content_start := 0
|
||||
if pos^ < len(tokens) {content_start = tokens[pos^].pos}
|
||||
append(nodes, Node{kind = .Section, pos = tok.pos})
|
||||
pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos, tag_content_base(source, tok.pos))
|
||||
if perr != nil {
|
||||
return Error_Body {
|
||||
msg = fmt.tprintf("pipe parse error in '{{{{#%s}}}}': %v", tok.value, perr),
|
||||
pos = tok.pos,
|
||||
kind = .Syntax,
|
||||
}
|
||||
}
|
||||
nodes[idx].key = pipe_key
|
||||
parse_section(tokens, pos, nodes, pipe_key, source, allocator, tok.pos) or_return
|
||||
close_pos := 0
|
||||
if pos^ - 1 >= 0 && pos^ - 1 < len(tokens) {close_pos = tokens[pos^ - 1].pos}
|
||||
nodes[idx].children = nodes[idx + 1:len(nodes)]
|
||||
nodes[idx].content = source[content_start:close_pos]
|
||||
|
||||
case .Inverted_Open:
|
||||
pos^ += 1
|
||||
idx := len(nodes)
|
||||
content_start := 0
|
||||
if pos^ < len(tokens) {content_start = tokens[pos^].pos}
|
||||
append(nodes, Node{kind = .Inverted, pos = tok.pos})
|
||||
pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos, tag_content_base(source, tok.pos))
|
||||
if perr != nil {
|
||||
return Error_Body {
|
||||
msg = fmt.tprintf("pipe parse error in '{{{{^%s}}}}': %v", tok.value, perr),
|
||||
pos = tok.pos,
|
||||
kind = .Syntax,
|
||||
}
|
||||
}
|
||||
nodes[idx].key = pipe_key
|
||||
parse_section(tokens, pos, nodes, pipe_key, source, allocator, tok.pos) or_return
|
||||
close_pos := 0
|
||||
if pos^ - 1 >= 0 && pos^ - 1 < len(tokens) {close_pos = tokens[pos^ - 1].pos}
|
||||
nodes[idx].children = nodes[idx + 1:len(nodes)]
|
||||
nodes[idx].content = source[content_start:close_pos]
|
||||
|
||||
case .Section_Close:
|
||||
if strings.contains(tok.value, "|") {
|
||||
return Error_Body {
|
||||
msg = fmt.tprintf(
|
||||
"pipe expression not allowed in close tag '{{{{/%s}}}}' — use the bare key",
|
||||
tok.value,
|
||||
),
|
||||
pos = tok.pos,
|
||||
kind = .Syntax,
|
||||
}
|
||||
}
|
||||
if end_tag != "" && tok.value == end_tag {
|
||||
pos^ += 1
|
||||
return nil
|
||||
}
|
||||
if end_tag == "" {
|
||||
return Error_Body {
|
||||
msg = fmt.tprintf("unexpected {{{{/%s}}}}", tok.value),
|
||||
pos = tok.pos,
|
||||
kind = .Syntax,
|
||||
}
|
||||
}
|
||||
return Error_Body {
|
||||
msg = fmt.tprintf("expected {{{{/%s}}}}, got {{{{/%s}}}}", end_tag, tok.value),
|
||||
pos = tok.pos,
|
||||
kind = .Syntax,
|
||||
}
|
||||
|
||||
case .Partial:
|
||||
append(
|
||||
nodes,
|
||||
Node {
|
||||
kind = .Partial,
|
||||
key = tok.value,
|
||||
is_dynamic = tok.is_dynamic,
|
||||
indent = tok.indent,
|
||||
pos = tok.pos,
|
||||
},
|
||||
)
|
||||
pos^ += 1
|
||||
|
||||
case .Parent:
|
||||
pos^ += 1
|
||||
idx := len(nodes)
|
||||
append(
|
||||
nodes,
|
||||
Node{kind = .Parent, key = tok.value, indent = tok.indent, pos = tok.pos},
|
||||
)
|
||||
parse_section(tokens, pos, nodes, tok.value, source, allocator, tok.pos) or_return
|
||||
nodes[idx].children = nodes[idx + 1:len(nodes)]
|
||||
|
||||
case .Block_Open:
|
||||
pos^ += 1
|
||||
idx := len(nodes)
|
||||
append(nodes, Node{kind = .Block, key = tok.value, indent = tok.indent, pos = tok.pos})
|
||||
parse_section(tokens, pos, nodes, tok.value, source, allocator, tok.pos) or_return
|
||||
nodes[idx].children = nodes[idx + 1:len(nodes)]
|
||||
}
|
||||
}
|
||||
|
||||
if end_tag != "" {
|
||||
return Error_Body {
|
||||
msg = fmt.tprintf("unclosed section '{{{{#%s}}}}'", end_tag),
|
||||
pos = open_pos,
|
||||
kind = .Syntax,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-parse: de-indent block content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
deindent_blocks :: proc(nodes: []Node, allocator := context.allocator) {
|
||||
i := 0
|
||||
for i < len(nodes) {
|
||||
switch nodes[i].kind {
|
||||
case .Block:
|
||||
if len(nodes[i].children) > 0 {
|
||||
children := nodes[i].children
|
||||
deindent_blocks(children, allocator)
|
||||
common := find_common_indent(children)
|
||||
if len(common) > 0 {
|
||||
if len(nodes[i].indent) == 0 {
|
||||
nodes[i].indent = common
|
||||
}
|
||||
for j := 0; j < len(children); {
|
||||
if children[j].kind == .Text && len(children[j].text) > 0 {
|
||||
children[j].text = remove_line_indent(
|
||||
children[j].text,
|
||||
common,
|
||||
allocator,
|
||||
)
|
||||
}
|
||||
j += node_span(children[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .Section, .Inverted, .Parent:
|
||||
if len(nodes[i].children) > 0 {
|
||||
deindent_blocks(nodes[i].children, allocator)
|
||||
}
|
||||
case .Text, .Variable, .Unescaped, .Partial:
|
||||
// Do nothing
|
||||
}
|
||||
i += node_span(nodes[i])
|
||||
}
|
||||
}
|
||||
|
||||
find_common_indent :: proc(children: []Node) -> string {
|
||||
common: string
|
||||
found := false
|
||||
|
||||
i := 0
|
||||
for i < len(children) {
|
||||
if children[i].kind == .Text {
|
||||
text := children[i].text
|
||||
if len(text) > 0 {
|
||||
line_start := 0
|
||||
for {
|
||||
// Bulk-scan to next newline (AVX2-backed) instead of byte-by-byte.
|
||||
rel := strings.index_byte(text[line_start:], '\n')
|
||||
line_end: int
|
||||
if rel < 0 {
|
||||
line_end = len(text)
|
||||
} else {
|
||||
line_end = line_start + rel
|
||||
}
|
||||
line := text[line_start:line_end]
|
||||
if len(strings.trim_space(line)) > 0 {
|
||||
ws := leading_whitespace(line)
|
||||
if !found {
|
||||
common = ws
|
||||
found = true
|
||||
} else if len(ws) < len(common) {
|
||||
common = ws
|
||||
}
|
||||
}
|
||||
if rel < 0 {
|
||||
break
|
||||
}
|
||||
line_start = line_end + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
i += node_span(children[i])
|
||||
}
|
||||
|
||||
if found {
|
||||
return common
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
leading_whitespace :: proc(s: string) -> string {
|
||||
for i in 0 ..< len(s) {
|
||||
if s[i] != ' ' && s[i] != '\t' {
|
||||
return s[:i]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
remove_line_indent :: proc(s: string, indent: string, allocator := context.allocator) -> string {
|
||||
if len(indent) == 0 {
|
||||
return s
|
||||
}
|
||||
|
||||
buf := make([dynamic]u8, 0, len(s), allocator)
|
||||
i := 0
|
||||
at_line_start := true
|
||||
|
||||
for i < len(s) {
|
||||
if at_line_start {
|
||||
if i + len(indent) <= len(s) && s[i:i + len(indent)] == indent {
|
||||
i += len(indent)
|
||||
}
|
||||
at_line_start = false
|
||||
}
|
||||
// Bulk-append up to and including the next newline (AVX2-backed).
|
||||
rel := strings.index_byte(s[i:], '\n')
|
||||
if rel < 0 {
|
||||
append(&buf, s[i:])
|
||||
break
|
||||
}
|
||||
next := i + rel
|
||||
append(&buf, s[i:next + 1])
|
||||
i = next + 1
|
||||
at_line_start = true
|
||||
}
|
||||
|
||||
return string(buf[:])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Indentation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
render_template :: proc(
|
||||
pt: Template,
|
||||
ctx: ^Context_Stack,
|
||||
partials: map[string]Template,
|
||||
b: ^strings.Builder,
|
||||
blocks: map[string]Block_Override,
|
||||
indent: string,
|
||||
) -> Error {
|
||||
if len(indent) > 0 {
|
||||
state := Indent_State {
|
||||
indent = indent,
|
||||
at_line_start = false,
|
||||
}
|
||||
strings.write_string(b, indent) // first line always gets indent
|
||||
return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, &state)
|
||||
}
|
||||
return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, nil)
|
||||
}
|
||||
|
||||
write_indented :: proc(
|
||||
b: ^strings.Builder,
|
||||
indent: string,
|
||||
content: string,
|
||||
at_line_start: ^bool,
|
||||
) {
|
||||
if len(indent) == 0 || len(content) == 0 {
|
||||
strings.write_string(b, content)
|
||||
return
|
||||
}
|
||||
i := 0
|
||||
for i < len(content) {
|
||||
if at_line_start^ {
|
||||
strings.write_string(b, indent)
|
||||
at_line_start^ = false
|
||||
}
|
||||
// Bulk-write up to and including the next newline (AVX2-backed).
|
||||
rel := strings.index_byte(content[i:], '\n')
|
||||
if rel < 0 {
|
||||
strings.write_string(b, content[i:])
|
||||
return
|
||||
}
|
||||
next := i + rel
|
||||
strings.write_string(b, content[i:next + 1])
|
||||
i = next + 1
|
||||
at_line_start^ = true
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Renderer — walk node array against context stack, write to builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
render_nodes :: proc(
|
||||
current: Template,
|
||||
nodes: []Node,
|
||||
ctx: ^Context_Stack,
|
||||
partials: map[string]Template,
|
||||
b: ^strings.Builder,
|
||||
blocks: map[string]Block_Override = nil,
|
||||
indent_state: ^Indent_State = nil,
|
||||
) -> Error {
|
||||
i := 0
|
||||
for i < len(nodes) {
|
||||
node := nodes[i]
|
||||
switch node.kind {
|
||||
case .Text:
|
||||
if indent_state != nil {
|
||||
write_indented(b, indent_state.indent, node.text, &indent_state.at_line_start)
|
||||
} else {
|
||||
strings.write_string(b, node.text)
|
||||
}
|
||||
i += 1
|
||||
|
||||
case .Variable:
|
||||
if indent_state != nil && indent_state.at_line_start {
|
||||
strings.write_string(b, indent_state.indent)
|
||||
indent_state.at_line_start = false
|
||||
}
|
||||
val := resolve_name(node.key, ctx[:])
|
||||
if val == nil {
|
||||
warn_unknown_key(current, ctx[:], node)
|
||||
}
|
||||
if len(node.filters) > 0 {
|
||||
transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:])
|
||||
if perr != nil {
|
||||
return tag_error(perr, current)
|
||||
}
|
||||
val = transformed
|
||||
}
|
||||
write_value(b, val, escape = true)
|
||||
i += 1
|
||||
|
||||
case .Unescaped:
|
||||
if indent_state != nil && indent_state.at_line_start {
|
||||
strings.write_string(b, indent_state.indent)
|
||||
indent_state.at_line_start = false
|
||||
}
|
||||
val := resolve_name(node.key, ctx[:])
|
||||
if val == nil {
|
||||
warn_unknown_key(current, ctx[:], node)
|
||||
}
|
||||
if len(node.filters) > 0 {
|
||||
transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:])
|
||||
if perr != nil {
|
||||
return tag_error(perr, current)
|
||||
}
|
||||
val = transformed
|
||||
}
|
||||
write_value(b, val, escape = false)
|
||||
i += 1
|
||||
|
||||
case .Section:
|
||||
val := resolve_name(node.key, ctx[:])
|
||||
if val == nil {
|
||||
warn_unknown_key(current, ctx[:], node)
|
||||
}
|
||||
if len(node.filters) > 0 {
|
||||
transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:])
|
||||
if perr != nil {
|
||||
return tag_error(perr, current)
|
||||
}
|
||||
val = transformed
|
||||
}
|
||||
if is_truthy(val) {
|
||||
children := node.children
|
||||
elem_info, count, data := list_info(val)
|
||||
if elem_info != nil {
|
||||
for j in 0 ..< count {
|
||||
elem := extract_list_element(elem_info, data, j)
|
||||
context_push(ctx, elem, current, node)
|
||||
defer pop(ctx)
|
||||
render_nodes(
|
||||
current,
|
||||
children,
|
||||
ctx,
|
||||
partials,
|
||||
b,
|
||||
blocks,
|
||||
indent_state,
|
||||
) or_return
|
||||
}
|
||||
} else {
|
||||
context_push(ctx, val, current, node)
|
||||
defer pop(ctx)
|
||||
render_nodes(
|
||||
current,
|
||||
children,
|
||||
ctx,
|
||||
partials,
|
||||
b,
|
||||
blocks,
|
||||
indent_state,
|
||||
) or_return
|
||||
}
|
||||
}
|
||||
i += 1 + len(node.children)
|
||||
|
||||
case .Inverted:
|
||||
val := resolve_name(node.key, ctx[:])
|
||||
if val == nil {
|
||||
warn_unknown_key(current, ctx[:], node)
|
||||
}
|
||||
if len(node.filters) > 0 {
|
||||
transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:])
|
||||
if perr != nil {
|
||||
return tag_error(perr, current)
|
||||
}
|
||||
val = transformed
|
||||
}
|
||||
if !is_truthy(val) {
|
||||
render_nodes(
|
||||
current,
|
||||
node.children,
|
||||
ctx,
|
||||
partials,
|
||||
b,
|
||||
blocks,
|
||||
indent_state,
|
||||
) or_return
|
||||
}
|
||||
i += 1 + len(node.children)
|
||||
|
||||
case .Partial:
|
||||
name := node.key
|
||||
if node.is_dynamic {
|
||||
val := resolve_name(node.key, ctx[:])
|
||||
name = any_to_string(val)
|
||||
}
|
||||
pt, found := partials[name]
|
||||
if !found {
|
||||
warn_missing_partial(current, partials, node, name)
|
||||
} else {
|
||||
render_template(pt, ctx, partials, b, nil, node.indent) or_return
|
||||
if indent_state != nil {
|
||||
indent_state.at_line_start = false
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
|
||||
case .Block:
|
||||
content_nodes: []Node
|
||||
content_blocks := blocks
|
||||
render_current := current
|
||||
|
||||
found_override := false
|
||||
if blocks != nil {
|
||||
if o, ok := blocks[node.key]; ok {
|
||||
content_nodes = o.nodes
|
||||
found_override = true
|
||||
render_current = o.source
|
||||
}
|
||||
}
|
||||
if !found_override {
|
||||
content_nodes = node.children
|
||||
}
|
||||
|
||||
if len(node.indent) > 0 {
|
||||
temp: strings.Builder
|
||||
strings.builder_init(&temp, context.temp_allocator)
|
||||
render_nodes(
|
||||
render_current,
|
||||
content_nodes,
|
||||
ctx,
|
||||
partials,
|
||||
&temp,
|
||||
content_blocks,
|
||||
nil,
|
||||
) or_return
|
||||
at_ls := true
|
||||
write_indented(b, node.indent, strings.to_string(temp), &at_ls)
|
||||
} else {
|
||||
render_nodes(
|
||||
render_current,
|
||||
content_nodes,
|
||||
ctx,
|
||||
partials,
|
||||
b,
|
||||
content_blocks,
|
||||
indent_state,
|
||||
) or_return
|
||||
}
|
||||
i += 1 + len(node.children)
|
||||
|
||||
case .Parent:
|
||||
parent_children := node.children
|
||||
merged := merge_block_overrides(parent_children, blocks, current)
|
||||
pt, found := partials[node.key]
|
||||
if !found {
|
||||
warn_missing_partial(current, partials, node, node.key)
|
||||
} else {
|
||||
warn_unmatched_block_overrides(current, pt, parent_children)
|
||||
render_template(pt, ctx, partials, b, merged, node.indent) or_return
|
||||
if indent_state != nil {
|
||||
indent_state.at_line_start = false
|
||||
}
|
||||
}
|
||||
i += 1 + len(node.children)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
merge_block_overrides :: proc(
|
||||
children: []Node,
|
||||
existing: map[string]Block_Override,
|
||||
source: Template,
|
||||
) -> map[string]Block_Override {
|
||||
result := make(map[string]Block_Override, context.temp_allocator)
|
||||
|
||||
for name, override in existing {
|
||||
result[name] = override
|
||||
}
|
||||
|
||||
i := 0
|
||||
for i < len(children) {
|
||||
child := children[i]
|
||||
if child.kind == .Block {
|
||||
if _, exists := result[child.key]; !exists {
|
||||
result[child.key] = Block_Override {
|
||||
nodes = child.children,
|
||||
source = source,
|
||||
}
|
||||
}
|
||||
}
|
||||
i += node_span(child)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// warn_unknown_key checks whether the missing key is a genuine typo (vs. a
|
||||
// legitimate path through a user-defined map) and, if so, emits a diagnostic
|
||||
// warning with the closest field-name suggestion via Levenshtein.
|
||||
warn_unknown_key :: proc(current: Template, ctx: []any, node: Node) {
|
||||
// `{{.}}` and dot-prefixed names refer to the current context — always valid.
|
||||
if node.key == "." || (len(node.key) > 0 && node.key[0] == '.') {
|
||||
return
|
||||
}
|
||||
|
||||
path_ok, missing, available := validate_key_path(ctx, node.key)
|
||||
if path_ok {
|
||||
return
|
||||
}
|
||||
|
||||
hint := ""
|
||||
if len(available) > 0 {
|
||||
suggestion := suggest_correction(available, missing)
|
||||
if suggestion != "" {
|
||||
hint = fmt.tprintf("did you mean '%s'?", suggestion)
|
||||
}
|
||||
}
|
||||
msg := fmt.tprintf("unknown key '%s'", node.key)
|
||||
path := current.path
|
||||
if path == "" {
|
||||
path = "<input>"
|
||||
}
|
||||
diag := format_error(path, current.source, node.pos, msg, hint, colorize = should_colorize())
|
||||
log.warnf("%s", diag)
|
||||
}
|
||||
|
||||
// warn_missing_partial emits a warning when a `{{> name}}` or `{{<name}}` tag
|
||||
// references a partial that isn't in the partials map.
|
||||
warn_missing_partial :: proc(
|
||||
current: Template,
|
||||
partials: map[string]Template,
|
||||
node: Node,
|
||||
name: string,
|
||||
) {
|
||||
hint := ""
|
||||
available := collect_partial_names(partials)
|
||||
suggestion := suggest_correction(available, name)
|
||||
if suggestion != "" {
|
||||
hint = fmt.tprintf("did you mean '%s'?", suggestion)
|
||||
}
|
||||
msg := fmt.tprintf("partial '%s' not found", name)
|
||||
path := current.path
|
||||
if path == "" {
|
||||
path = "<input>"
|
||||
}
|
||||
diag := format_error(path, current.source, node.pos, msg, hint, colorize = should_colorize())
|
||||
log.warnf("%s", diag)
|
||||
}
|
||||
|
||||
// warn_unmatched_block_overrides checks each `{{$name}}...{{/name}}` block
|
||||
// defined inside a `{{<parent}}` tag and warns when the name doesn't match
|
||||
// any block in the parent template.
|
||||
warn_unmatched_block_overrides :: proc(
|
||||
current: Template,
|
||||
parent: Template,
|
||||
parent_children: []Node,
|
||||
) {
|
||||
if len(parent_children) == 0 {
|
||||
return
|
||||
}
|
||||
available := collect_block_names(parent)
|
||||
parent_path := parent.path
|
||||
if parent_path == "" {
|
||||
parent_path = "<input>"
|
||||
}
|
||||
for child in parent_children {
|
||||
if child.kind != .Block {
|
||||
continue
|
||||
}
|
||||
matched := false
|
||||
for name in available {
|
||||
if name == child.key {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
continue
|
||||
}
|
||||
|
||||
hint := ""
|
||||
suggestion := suggest_correction(available, child.key)
|
||||
if suggestion != "" {
|
||||
hint = fmt.tprintf("did you mean '%s'?", suggestion)
|
||||
}
|
||||
msg := fmt.tprintf(
|
||||
"block override '%s' has no match in parent template '%s'",
|
||||
child.key,
|
||||
parent_path,
|
||||
)
|
||||
path := current.path
|
||||
if path == "" {
|
||||
path = "<input>"
|
||||
}
|
||||
diag := format_error(
|
||||
path,
|
||||
current.source,
|
||||
child.pos,
|
||||
msg,
|
||||
hint,
|
||||
colorize = should_colorize(),
|
||||
)
|
||||
log.warnf("%s", diag)
|
||||
}
|
||||
}
|
||||
|
||||
context_push :: proc(ctx: ^Context_Stack, val: any, current: Template, node: Node) {
|
||||
append(ctx, val)
|
||||
if len(ctx^) == MAX_CONTEXT_DEPTH + 1 {
|
||||
warn_context_depth(current, node)
|
||||
}
|
||||
}
|
||||
|
||||
// warn_context_depth emits a diagnostic warning pointing at the section tag
|
||||
// whose push carried the context stack past MAX_CONTEXT_DEPTH.
|
||||
warn_context_depth :: proc(current: Template, node: Node) {
|
||||
msg := fmt.tprintf(
|
||||
"context stack depth exceeded %d (possible recursive section/partial)",
|
||||
MAX_CONTEXT_DEPTH,
|
||||
)
|
||||
path := current.path
|
||||
if path == "" {
|
||||
path = "<input>"
|
||||
}
|
||||
diag := format_error(path, current.source, node.pos, msg, "", colorize = should_colorize())
|
||||
log.warnf("%s", diag)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
#+test
|
||||
package mustache
|
||||
|
||||
import "core:log"
|
||||
import "core:mem"
|
||||
import "core:strings"
|
||||
import "core:testing"
|
||||
|
||||
@(test)
|
||||
leak_parse_free :: proc(t: ^testing.T) {
|
||||
tmpl, err := parse("Hello {{name}}!")
|
||||
testing.expect(t, err == nil)
|
||||
defer delete_template(&tmpl)
|
||||
}
|
||||
|
||||
@(test)
|
||||
leak_parse_free_tokens :: proc(t: ^testing.T) {
|
||||
arena: mem.Dynamic_Arena
|
||||
backing: [256]byte
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
|
||||
tmpl, err := parse(
|
||||
"Hello {{name}}!",
|
||||
"<test>",
|
||||
context.allocator,
|
||||
mem.dynamic_arena_allocator(&arena),
|
||||
)
|
||||
testing.expect(t, err == nil)
|
||||
defer delete_template(&tmpl)
|
||||
}
|
||||
|
||||
@(test)
|
||||
leak_parse_error :: proc(t: ^testing.T) {
|
||||
tmpl, err := parse("Hello {{name")
|
||||
testing.expect(t, err != nil)
|
||||
}
|
||||
|
||||
@(test)
|
||||
leak_render :: proc(t: ^testing.T) {
|
||||
Data :: struct {
|
||||
name: string,
|
||||
}
|
||||
tmpl, err := parse("Hello {{name}}!")
|
||||
testing.expect(t, err == nil)
|
||||
defer delete_template(&tmpl)
|
||||
|
||||
result, rerr := render(tmpl, Data{name = "World"})
|
||||
testing.expect(t, rerr == nil)
|
||||
defer delete(result)
|
||||
}
|
||||
|
||||
@(test)
|
||||
leak_render_partials :: proc(t: ^testing.T) {
|
||||
partials := make_map(map[string]Template)
|
||||
defer {
|
||||
for _, &p in partials {
|
||||
delete_template(&p)
|
||||
}
|
||||
delete(partials)
|
||||
}
|
||||
|
||||
pt, perr := parse("world")
|
||||
testing.expect(t, perr == nil)
|
||||
partials["name"] = pt
|
||||
|
||||
tmpl, err := parse("Hello {{> name}}!")
|
||||
testing.expect(t, err == nil)
|
||||
defer delete_template(&tmpl)
|
||||
|
||||
Data :: struct {
|
||||
name: string,
|
||||
}
|
||||
result, rerr := render(tmpl, Data{name = "World"}, partials)
|
||||
testing.expect(t, rerr == nil)
|
||||
defer delete(result)
|
||||
}
|
||||
|
||||
@(test)
|
||||
leak_render_sections :: proc(t: ^testing.T) {
|
||||
tmpl, err := parse("{{#items}}{{.}}{{/items}}")
|
||||
testing.expect(t, err == nil)
|
||||
defer delete_template(&tmpl)
|
||||
|
||||
Data :: struct {
|
||||
items: [3]string,
|
||||
}
|
||||
result, rerr := render(tmpl, Data{items = {"a", "b", "c"}})
|
||||
testing.expect(t, rerr == nil)
|
||||
defer delete(result)
|
||||
}
|
||||
|
||||
@(test)
|
||||
leak_render_inheritance :: proc(t: ^testing.T) {
|
||||
partials := make_map(map[string]Template)
|
||||
defer delete_partials(partials)
|
||||
|
||||
layout_src := "{{$title}}default{{/title}}"
|
||||
layout, lerr := parse(layout_src)
|
||||
testing.expect(t, lerr == nil)
|
||||
partials["layout"] = layout
|
||||
|
||||
tmpl_src := "{{<layout}}{{$title}}custom{{/title}}{{/layout}}"
|
||||
tmpl, err := parse(tmpl_src)
|
||||
testing.expect(t, err == nil)
|
||||
defer delete_template(&tmpl)
|
||||
|
||||
result, rerr := render(tmpl, {}, partials)
|
||||
testing.expect(t, rerr == nil)
|
||||
defer delete(result)
|
||||
}
|
||||
|
||||
@(test)
|
||||
leak_repeated_render :: proc(t: ^testing.T) {
|
||||
tmpl, err := parse("Hello {{name}}!")
|
||||
testing.expect(t, err == nil)
|
||||
defer delete_template(&tmpl)
|
||||
|
||||
Data :: struct {
|
||||
name: string,
|
||||
}
|
||||
for _ in 0 ..< 3 {
|
||||
result, rerr := render(tmpl, Data{name = "World"})
|
||||
testing.expect(t, rerr == nil)
|
||||
defer delete(result)
|
||||
}
|
||||
}
|
||||
|
||||
// A deeply nested map pushes the context stack past MAX_CONTEXT_DEPTH (16).
|
||||
// The depth warning must be non-fatal: rendering still succeeds.
|
||||
@(test)
|
||||
test_context_depth_warns :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
|
||||
AMT :: MAX_CONTEXT_DEPTH + 2
|
||||
|
||||
// Build AMT nested {x: {...}} levels; the innermost holds `leaf`.
|
||||
data := make(map[string]any, context.temp_allocator)
|
||||
data["leaf"] = "found"
|
||||
for _ in 0 ..< AMT {
|
||||
outer := make(map[string]any, context.temp_allocator)
|
||||
outer["x"] = data
|
||||
data = outer
|
||||
}
|
||||
|
||||
// Template: AMT nested {{#x}} sections around {{leaf}}.
|
||||
src: strings.Builder
|
||||
strings.builder_init(&src, context.temp_allocator)
|
||||
for _ in 0 ..< AMT do strings.write_string(&src, "{{#x}}")
|
||||
strings.write_string(&src, "{{leaf}}")
|
||||
for _ in 0 ..< AMT do strings.write_string(&src, "{{/x}}")
|
||||
template := strings.to_string(src)
|
||||
|
||||
tmpl, perr := parse(template, "<depth-test>", context.temp_allocator)
|
||||
testing.expect(t, perr == nil, "should parse")
|
||||
if perr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
result, rerr := render(tmpl, data, allocator = context.temp_allocator)
|
||||
testing.expect(t, rerr == nil, "depth warning must be non-fatal")
|
||||
testing.expect_value(t, result, "found")
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package mustache
|
||||
|
||||
import "base:runtime"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:reflect"
|
||||
import "core:strings"
|
||||
import "core:time"
|
||||
import "core:time/datetime"
|
||||
|
||||
// MAX_PIPES was chosen arbitrarily. It holds no performance or logical
|
||||
// significance.
|
||||
MAX_PIPES :: 8
|
||||
|
||||
// No filter accepts more than 2 args.
|
||||
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,
|
||||
op_pos: int,
|
||||
}
|
||||
|
||||
Group :: struct {
|
||||
key: string,
|
||||
items: [dynamic]any,
|
||||
}
|
||||
|
||||
// is_pipe_space reports whether c is whitespace for the purposes of
|
||||
// tokenizing a filter segment.
|
||||
is_pipe_space :: proc(c: u8) -> bool {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r'
|
||||
}
|
||||
|
||||
// tokenize_fields splits seg on whitespace like strings.fields, but a
|
||||
// double-quoted span (spaces allowed inside) becomes a single token. The
|
||||
// quote characters are kept in the token (not stripped) so callers can
|
||||
// distinguish a quoted literal from a bare key name. No escape sequences.
|
||||
tokenize_fields :: proc(seg: string, pos: int) -> (tokens: [dynamic]string, err: Error) {
|
||||
i := 0
|
||||
for i < len(seg) {
|
||||
for i < len(seg) && is_pipe_space(seg[i]) {
|
||||
i += 1
|
||||
}
|
||||
if i >= len(seg) {
|
||||
break
|
||||
}
|
||||
if seg[i] == '"' {
|
||||
start := i
|
||||
j := i + 1
|
||||
for j < len(seg) && seg[j] != '"' {
|
||||
j += 1
|
||||
}
|
||||
if j >= len(seg) {
|
||||
return tokens, Error_Body {
|
||||
msg = fmt.tprintf("unterminated string literal: %s", seg),
|
||||
pos = pos,
|
||||
kind = .Syntax,
|
||||
}
|
||||
}
|
||||
append(&tokens, seg[start:j + 1])
|
||||
i = j + 1
|
||||
} else {
|
||||
start := i
|
||||
for i < len(seg) && !is_pipe_space(seg[i]) {
|
||||
i += 1
|
||||
}
|
||||
append(&tokens, seg[start:i])
|
||||
}
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// Returned strings are slices into content — no cloning, lifetime bound to
|
||||
// the caller's source.
|
||||
parse_pipeline :: proc(
|
||||
content: string,
|
||||
filters_out: ^[dynamic; MAX_PIPES]Pipe_Filter,
|
||||
pos: int,
|
||||
content_base: int,
|
||||
) -> (
|
||||
key: string,
|
||||
err: Error,
|
||||
) {
|
||||
if !strings.contains(content, "|") {
|
||||
key = strings.trim_space(content)
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Count pipes to validate against MAX_PIPES.
|
||||
pipe_count := strings.count(content, "|")
|
||||
if pipe_count > MAX_PIPES {
|
||||
return "", Error_Body {
|
||||
msg = fmt.tprintf(
|
||||
"pipe expression has %d filters, max is %d",
|
||||
pipe_count,
|
||||
MAX_PIPES,
|
||||
),
|
||||
pos = pos,
|
||||
kind = .Syntax,
|
||||
}
|
||||
}
|
||||
|
||||
// Key is everything before the first |.
|
||||
first_pipe := strings.index(content, "|")
|
||||
key = strings.trim_space(content[:first_pipe])
|
||||
if len(key) == 0 {
|
||||
return "", Error_Body{msg = "pipe expression missing key", pos = pos, kind = .Syntax}
|
||||
}
|
||||
|
||||
if pipe_count == 0 {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Walk pipe-delimited segments, tracking byte offsets within content.
|
||||
seg_start := first_pipe + 1 // offset in content, just after |
|
||||
for seg_start <= len(content) {
|
||||
next_pipe := strings.index(content[seg_start:], "|")
|
||||
|
||||
// Raw segment text (may have leading/trailing whitespace).
|
||||
seg_end := seg_start + next_pipe if next_pipe >= 0 else len(content)
|
||||
raw_seg := content[seg_start:seg_end]
|
||||
|
||||
// Count leading whitespace to find op offset within content.
|
||||
ws := 0
|
||||
for ws < len(raw_seg) && is_pipe_space(raw_seg[ws]) {
|
||||
ws += 1
|
||||
}
|
||||
seg := strings.trim_space(raw_seg)
|
||||
if len(seg) == 0 {
|
||||
return "", Error_Body{msg = "empty filter", pos = pos, kind = .Syntax}
|
||||
}
|
||||
|
||||
op_offset_in_content := seg_start + ws
|
||||
|
||||
tokens, terr := tokenize_fields(seg, pos)
|
||||
if terr != nil {
|
||||
delete(tokens)
|
||||
return "", terr
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
return "", Error_Body{msg = "filter missing op name", pos = pos, kind = .Syntax}
|
||||
}
|
||||
|
||||
arg_count := len(tokens) - 1
|
||||
if arg_count > MAX_PIPE_ARGS {
|
||||
return "", Error_Body {
|
||||
msg = fmt.tprintf(
|
||||
"filter '%s' has %d args, max is %d",
|
||||
tokens[0],
|
||||
arg_count,
|
||||
MAX_PIPE_ARGS,
|
||||
),
|
||||
pos = pos,
|
||||
kind = .Syntax,
|
||||
}
|
||||
}
|
||||
|
||||
filter := Pipe_Filter {
|
||||
op = tokens[0],
|
||||
op_pos = content_base + op_offset_in_content,
|
||||
}
|
||||
for j in 1 ..< len(tokens) {
|
||||
append(&filter.args, tokens[j])
|
||||
}
|
||||
append(filters_out, filter)
|
||||
delete(tokens)
|
||||
|
||||
if next_pipe < 0 {
|
||||
break
|
||||
}
|
||||
seg_start = seg_end + 1
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
apply_pipeline :: proc(
|
||||
value: any,
|
||||
filters: []Pipe_Filter,
|
||||
pos: int,
|
||||
ctx: []any,
|
||||
) -> (
|
||||
current: any,
|
||||
err: Error,
|
||||
) {
|
||||
current = value
|
||||
for &filter in filters {
|
||||
current = apply_filter(current, &filter, pos, ctx) or_return
|
||||
log.debugf("applied: filter=%v before=%s after=%s pos=%d", filter, value, current, pos)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// resolve_format_string looks up name as a context key and returns its
|
||||
// string value. Used both for an explicit bare-key filter arg (e.g.
|
||||
// `format long`) and for the implicit "date_format" fallback when no arg
|
||||
// is given.
|
||||
resolve_format_string :: proc(name: string, ctx: []any, pos: int) -> (string, Error) {
|
||||
raw := resolve_name(name, ctx)
|
||||
if raw == nil {
|
||||
return "", Error_Body {
|
||||
msg = fmt.tprintf("unable to resolve date format key '%s'", name),
|
||||
pos = pos,
|
||||
kind = .Data,
|
||||
}
|
||||
}
|
||||
str, ok := reflect.as_string(raw)
|
||||
if !ok {
|
||||
return "", Error_Body {
|
||||
msg = fmt.tprintf("date format key '%s' is not a string", name),
|
||||
pos = pos,
|
||||
kind = .Data,
|
||||
}
|
||||
}
|
||||
return str, nil
|
||||
}
|
||||
|
||||
apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) -> (any, Error) {
|
||||
op, ok := pipe_op_from_string(filter.op)
|
||||
if !ok {
|
||||
hint := ""
|
||||
suggestion := 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:
|
||||
str, ok := reflect.as_string(value)
|
||||
if !ok {
|
||||
return value, Error_Body {
|
||||
msg = "format may only be used on dates",
|
||||
pos = pos,
|
||||
kind = .Data,
|
||||
}
|
||||
}
|
||||
|
||||
date_format: string
|
||||
|
||||
if len(filter.args) > 0 {
|
||||
arg := filter.args[0]
|
||||
if len(arg) >= 2 && arg[0] == '"' && arg[len(arg) - 1] == '"' {
|
||||
date_format = arg[1:len(arg) - 1]
|
||||
} else {
|
||||
df, ferr := resolve_format_string(arg, ctx, pos)
|
||||
if ferr != nil {
|
||||
return value, ferr
|
||||
}
|
||||
date_format = df
|
||||
}
|
||||
} else {
|
||||
df, ferr := resolve_format_string("date_format", ctx, pos)
|
||||
if ferr != nil {
|
||||
return value, ferr
|
||||
}
|
||||
date_format = df
|
||||
}
|
||||
|
||||
tz := resolve_tz(ctx)
|
||||
str2, err := apply_format(str, filter.args[:], pos, date_format, tz)
|
||||
if err != nil {
|
||||
return value, err
|
||||
} else {
|
||||
return any{new_clone(str2, context.temp_allocator), typeid_of(string)}, nil
|
||||
}
|
||||
}
|
||||
return {}, nil
|
||||
}
|
||||
|
||||
apply_format :: proc(
|
||||
iso: string,
|
||||
args: []string,
|
||||
pos: int,
|
||||
date_format: string,
|
||||
tz: ^datetime.TZ_Region,
|
||||
) -> (
|
||||
result: string,
|
||||
err: Error,
|
||||
) {
|
||||
fmt_str := date_format
|
||||
if fmt_str == "" {
|
||||
log.errorf(
|
||||
"format pipe used but no date format configured (set date.format in thor.json) Default will be used",
|
||||
)
|
||||
fmt_str = DEFAULT_DATE_FORMAT
|
||||
}
|
||||
|
||||
components, ok := parse_iso_date(iso)
|
||||
if !ok {
|
||||
return "", Error_Body {
|
||||
msg = fmt.tprintf("invalid date: \"%s\"", iso),
|
||||
pos = pos,
|
||||
kind = .Data,
|
||||
}
|
||||
}
|
||||
|
||||
if tz != nil {
|
||||
components, _ = convert_to_tz(components, tz)
|
||||
} else if components.has_offset {
|
||||
components.tz_abbr = format_offset(components.offset_seconds)
|
||||
}
|
||||
|
||||
return format_date(components, fmt_str), nil
|
||||
}
|
||||
|
||||
// Groups preserve first-appearance order from the input list.
|
||||
apply_group_by :: proc(value: any, args: []string, pos: int) -> (result: any, err: Error) {
|
||||
if len(args) != 1 {
|
||||
return nil, Error_Body {
|
||||
msg = fmt.tprintf("group_by expects 1 argument, got %d", len(args)),
|
||||
pos = pos,
|
||||
kind = .Data,
|
||||
}
|
||||
}
|
||||
field := args[0]
|
||||
|
||||
elem_info, count, data := list_info(value)
|
||||
if elem_info == nil {
|
||||
return nil, Error_Body{msg = "group_by expects a list", pos = pos, kind = .Data}
|
||||
}
|
||||
|
||||
groups := make([dynamic]Group, 0, 8, context.temp_allocator)
|
||||
key_to_idx := make(map[string]int, context.temp_allocator)
|
||||
defer delete(key_to_idx)
|
||||
|
||||
for j in 0 ..< count {
|
||||
elem_ptr := rawptr(uintptr(data) + uintptr(j) * uintptr(elem_info.size))
|
||||
elem := any{elem_ptr, elem_info.id}
|
||||
|
||||
key_val, found := lookup_in(elem, field)
|
||||
if !found {
|
||||
return nil, Error_Body {
|
||||
msg = fmt.tprintf("group_by: element missing field '%s'", field),
|
||||
pos = pos,
|
||||
kind = .Data,
|
||||
}
|
||||
}
|
||||
key_str := any_to_string(key_val)
|
||||
if len(key_str) == 0 {
|
||||
return nil, Error_Body {
|
||||
msg = fmt.tprintf("group_by: field '%s' is empty", field),
|
||||
pos = pos,
|
||||
kind = .Data,
|
||||
}
|
||||
}
|
||||
|
||||
idx, exists := key_to_idx[key_str]
|
||||
if !exists {
|
||||
idx = len(groups)
|
||||
key_to_idx[key_str] = idx
|
||||
append(
|
||||
&groups,
|
||||
Group{key = key_str, items = make([dynamic]any, 0, 4, context.temp_allocator)},
|
||||
)
|
||||
}
|
||||
append(&groups[idx].items, elem)
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
#+test
|
||||
package mustache
|
||||
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
import "core:testing"
|
||||
import "core:time/datetime"
|
||||
import "core:time/timezone"
|
||||
|
||||
Pipe_Post :: struct {
|
||||
title: string,
|
||||
year: string,
|
||||
}
|
||||
|
||||
Pipe_Data :: struct {
|
||||
posts: []Pipe_Post,
|
||||
}
|
||||
|
||||
make_data :: proc(posts: ..Pipe_Post) -> Pipe_Data {
|
||||
d: Pipe_Data
|
||||
d.posts = posts
|
||||
return d
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_pipe_group_by_basic :: proc(t: ^testing.T) {
|
||||
data := make_data(
|
||||
{title = "A", year = "2024"},
|
||||
{title = "B", year = "2024"},
|
||||
{title = "C", year = "2023"},
|
||||
)
|
||||
tpl, _ := parse("{{#posts | group_by year}}{{key}}:{{#items}}{{title}},{{/items}};{{/posts}}")
|
||||
defer delete_template(&tpl)
|
||||
result, _ := render(tpl, data)
|
||||
defer delete(result)
|
||||
testing.expect_value(t, result, "2024:A,B,;2023:C,;")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_pipe_group_by_doesnt_sort :: proc(t: ^testing.T) {
|
||||
data := make_data(
|
||||
{title = "old", year = "2020"},
|
||||
{title = "new", year = "2024"},
|
||||
{title = "mid", year = "2022"},
|
||||
)
|
||||
tpl, _ := parse("{{#posts | group_by year}}{{key}} {{/posts}}")
|
||||
defer delete_template(&tpl)
|
||||
result, _ := render(tpl, data)
|
||||
defer delete(result)
|
||||
testing.expect_value(t, result, "2020 2024 2022 ")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_pipe_group_by_empty_list_skips :: proc(t: ^testing.T) {
|
||||
data := Pipe_Data {
|
||||
posts = nil,
|
||||
}
|
||||
tpl, _ := parse("[{{#posts | group_by year}}{{key}}{{/posts}}]")
|
||||
defer delete_template(&tpl)
|
||||
result, _ := render(tpl, data)
|
||||
defer delete(result)
|
||||
testing.expect_value(t, result, "[]")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_pipe_group_by_missing_field_fails :: proc(t: ^testing.T) {
|
||||
data := make_data({title = "A", year = "2024"})
|
||||
tpl, _ := parse("{{#posts | group_by missing}}x{{/posts}}")
|
||||
defer delete_template(&tpl)
|
||||
_, err := render(tpl, data)
|
||||
testing.expect(t, err != nil, "missing field should error")
|
||||
b := body(err)
|
||||
testing.expect(t, b.kind == .Data, "error should be Data kind")
|
||||
testing.expect(t, len(b.msg) > 0, "error should have non-empty msg")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_pipe_group_by_requires_grouped_field :: proc(t: ^testing.T) {
|
||||
data := make_data({title = "A", year = ""})
|
||||
tpl, _ := parse("{{#posts | group_by year}}x{{/posts}}")
|
||||
defer delete_template(&tpl)
|
||||
_, err := render(tpl, data)
|
||||
testing.expect(t, err != nil, "empty field value should error")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_group_by_requires_arg :: proc(t: ^testing.T) {
|
||||
data := make_data({title = "A", year = "2024"})
|
||||
tpl, _ := parse("{{#posts | group_by}}x{{/posts}}")
|
||||
defer delete_template(&tpl)
|
||||
_, err := render(tpl, data)
|
||||
testing.expect(t, err != nil, "group_by with no args should error")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_pipe_group_by_non_list_returns_error :: proc(t: ^testing.T) {
|
||||
Scalar_Data :: struct {
|
||||
name: string,
|
||||
}
|
||||
data := Scalar_Data {
|
||||
name = "hello",
|
||||
}
|
||||
tpl, _ := parse("{{#name | group_by x}}y{{/name}}")
|
||||
defer delete_template(&tpl)
|
||||
_, err := render(tpl, data)
|
||||
testing.expect(t, err != nil, "group_by on a scalar should error")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_pipe_accepts_8_filters :: proc(t: ^testing.T) {
|
||||
src := "{{#posts | group_by year | group_by year | group_by year | group_by year | group_by year | group_by year | group_by year | group_by year}}x{{/posts}}"
|
||||
tpl, err := parse(src)
|
||||
testing.expect(t, err == nil, "8 filters should parse OK")
|
||||
if err == nil {
|
||||
defer delete_template(&tpl)
|
||||
} else {
|
||||
fmt.printfln("parse error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_pipe_rejects_nine_filters :: proc(t: ^testing.T) {
|
||||
src := "{{#posts | group_by y | group_by y | group_by y | group_by y | group_by y | group_by y | group_by y | group_by y | group_by y}}x{{/posts}}"
|
||||
_, err := parse(src)
|
||||
testing.expect(t, err != nil, "9 filters should fail to parse")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_pipe_forbidden_in_close_tag :: proc(t: ^testing.T) {
|
||||
src := "{{#posts | group_by year}}x{{/posts | group_by year}}"
|
||||
_, err := parse(src)
|
||||
testing.expect(t, err != nil, "pipe in close tag should fail to parse")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_pipe_empty_middle_filter_required :: proc(t: ^testing.T) {
|
||||
src := "{{#posts | | group_by year}}x{{/posts}}"
|
||||
_, err := parse(src)
|
||||
testing.expect(t, err != nil, "empty pipe filter should fail to parse")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_pipe_filter_required :: proc(t: ^testing.T) {
|
||||
src := "{{#posts |}}x{{/posts}}"
|
||||
_, err := parse(src)
|
||||
testing.expect(t, err != nil, "trailing pipe with no filter should fail")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_pipe_data_required :: proc(t: ^testing.T) {
|
||||
src := "{{#| group_by year}}x{{/}}"
|
||||
_, err := parse(src)
|
||||
testing.expect(t, err != nil, "missing key should fail to parse")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_extra_whitespace_allowed :: proc(t: ^testing.T) {
|
||||
data := make_data({title = "A", year = "2024"}, {title = "B", year = "2023"})
|
||||
tpl, _ := parse("{{#posts|group_by year}}{{key}};{{/posts}}")
|
||||
defer delete_template(&tpl)
|
||||
result, _ := render(tpl, data)
|
||||
defer delete(result)
|
||||
testing.expect_value(t, result, "2024;2023;")
|
||||
}
|
||||
|
||||
|
||||
@(test)
|
||||
test_pipe_no_pipe_still_works :: proc(t: ^testing.T) {
|
||||
data := make_data({title = "A", year = "2024"}, {title = "B", year = "2023"})
|
||||
tpl, _ := parse("{{#posts}}{{title}};{{/posts}}")
|
||||
defer delete_template(&tpl)
|
||||
result, _ := render(tpl, data)
|
||||
defer delete(result)
|
||||
testing.expect_value(t, result, "A;B;")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_pipe_inverted_section_empty :: proc(t: ^testing.T) {
|
||||
data := Pipe_Data {
|
||||
posts = nil,
|
||||
}
|
||||
tpl, _ := parse("{{^posts | group_by year}}none{{/posts}}")
|
||||
defer delete_template(&tpl)
|
||||
result, _ := render(tpl, data)
|
||||
defer delete(result)
|
||||
testing.expect_value(t, result, "none")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_pipe_single_group_works :: proc(t: ^testing.T) {
|
||||
data := make_data({title = "A", year = "2024"}, {title = "B", year = "2024"})
|
||||
tpl, _ := parse("{{#posts | group_by year}}{{key}}({{#items}}{{title}}{{/items}}){{/posts}}")
|
||||
defer delete_template(&tpl)
|
||||
result, _ := render(tpl, data)
|
||||
defer delete(result)
|
||||
testing.expect_value(t, result, "2024(AB)")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_delete_template_doesnt_leak :: proc(t: ^testing.T) {
|
||||
tmpl, err := parse("{{#posts | group_by year}}x{{/posts}}")
|
||||
testing.expect(t, err == nil)
|
||||
defer delete_template(&tmpl)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interpolation pipes ({{x | op}})
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(test)
|
||||
test_interp_pipe_basic :: proc(t: ^testing.T) {
|
||||
Scalar_Data :: struct {
|
||||
name: string,
|
||||
date_format: string,
|
||||
timezone: ^datetime.TZ_Region,
|
||||
}
|
||||
data := Scalar_Data {
|
||||
name = "2026-03-15T08:49:54-04:00",
|
||||
date_format = "2 Jan 2006",
|
||||
}
|
||||
tpl, _ := parse("[{{name | format}}]", "<test>", allocator = context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "[15 Mar 2026]")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_interp_pipe_unescaped :: proc(t: ^testing.T) {
|
||||
Scalar_Data :: struct {
|
||||
name: string,
|
||||
date_format: string,
|
||||
timezone: ^datetime.TZ_Region,
|
||||
}
|
||||
data := Scalar_Data {
|
||||
name = "2025-12-25T00:00:00Z",
|
||||
date_format = "2 Jan 2006",
|
||||
}
|
||||
tpl, _ := parse("[{{&name | format}}]", "<test>", allocator = context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "[25 Dec 2025]")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_interp_pipe_dot_current :: proc(t: ^testing.T) {
|
||||
List_Data :: struct {
|
||||
items: [3]string,
|
||||
date_format: string,
|
||||
timezone: ^datetime.TZ_Region,
|
||||
}
|
||||
data := List_Data {
|
||||
items = {"2026-01-06T00:00:00Z", "2026-06-15T00:00:00Z", "2026-10-15T00:00:00Z"},
|
||||
date_format = "2 Jan 2006",
|
||||
}
|
||||
tpl, _ := parse(
|
||||
"{{#items}}[{{. | format}}]{{/items}}",
|
||||
"<test>",
|
||||
allocator = context.temp_allocator,
|
||||
)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "[6 Jan 2026][15 Jun 2026][15 Oct 2026]")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// format filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Format_Data :: struct {
|
||||
date: string,
|
||||
date_format: string,
|
||||
timezone: ^datetime.TZ_Region,
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_typical_iso :: proc(t: ^testing.T) {
|
||||
data := Format_Data {
|
||||
date = "2026-03-15T08:49:54-04:00",
|
||||
date_format = "2 Jan 2006",
|
||||
}
|
||||
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "15 Mar 2026")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_short_date_only :: proc(t: ^testing.T) {
|
||||
data := Format_Data {
|
||||
date = "2026-06-06",
|
||||
date_format = "2 Jan 2006",
|
||||
}
|
||||
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "6 Jun 2026")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_empty_input_errors :: proc(t: ^testing.T) {
|
||||
data := Format_Data {
|
||||
date = "",
|
||||
date_format = "2 Jan 2006",
|
||||
}
|
||||
tpl, _ := parse("[{{date | format}}]", "<test>", allocator = context.temp_allocator)
|
||||
defer delete_template(&tpl)
|
||||
_, err := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect(t, err != nil, "empty date should error")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_non_date_string_errors :: proc(t: ^testing.T) {
|
||||
data := Format_Data {
|
||||
date = "abc",
|
||||
date_format = "2 Jan 2006",
|
||||
}
|
||||
tpl, _ := parse("[{{date | format}}]", "<test>", allocator = context.temp_allocator)
|
||||
defer delete_template(&tpl)
|
||||
_, err := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect(t, err != nil, "non-date string should error")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_non_string_value_errors :: proc(t: ^testing.T) {
|
||||
Int_Data :: struct {
|
||||
count: int,
|
||||
}
|
||||
data := Int_Data {
|
||||
count = 42,
|
||||
}
|
||||
tpl, _ := parse("{{count | format}}", "<test>", allocator = context.temp_allocator)
|
||||
defer delete_template(&tpl)
|
||||
_, err := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect(t, err != nil, "non-string value should error")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_invalid_month_errors :: proc(t: ^testing.T) {
|
||||
data := Format_Data {
|
||||
date = "2023-13-15",
|
||||
date_format = "2 Jan 2006",
|
||||
}
|
||||
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
|
||||
defer delete_template(&tpl)
|
||||
_, err := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect(t, err != nil, "invalid month should error")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_inside_section_renders :: proc(t: ^testing.T) {
|
||||
// Mirrors the datetime.html partial pattern: section pushes raw string,
|
||||
// partial uses {{.}} for ISO attr and {{. | format}} for display.
|
||||
data := Format_Data {
|
||||
date = "2025-12-25T00:00:00Z",
|
||||
date_format = "2 Jan 2006",
|
||||
}
|
||||
tpl, _ := parse(
|
||||
"{{#date}}<time datetime=\"{{.}}\">{{. | format}}</time>{{/date}}",
|
||||
"<test>",
|
||||
allocator = context.temp_allocator,
|
||||
)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "<time datetime=\"2025-12-25T00:00:00Z\">25 Dec 2025</time>")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_inside_section_skips_when_empty :: proc(t: ^testing.T) {
|
||||
data := Format_Data {
|
||||
date = "",
|
||||
date_format = "2 Jan 2006",
|
||||
}
|
||||
tpl, _ := parse(
|
||||
"[{{#date}}<time>{{. | format}}</time>{{/date}}]",
|
||||
"<test>",
|
||||
allocator = context.temp_allocator,
|
||||
)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "[]")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// format filter args: quoted literal strings and bare context keys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(test)
|
||||
test_format_quoted_literal_arg :: proc(t: ^testing.T) {
|
||||
data := Format_Data {
|
||||
date = "2026-03-15T08:49:54-04:00",
|
||||
date_format = "2 Jan 2006",
|
||||
}
|
||||
tpl, _ := parse(
|
||||
`{{date | format "Jan 2, 2006"}}`,
|
||||
"<test>",
|
||||
allocator = context.temp_allocator,
|
||||
)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "Mar 15, 2026")
|
||||
}
|
||||
|
||||
Key_Format_Data :: struct {
|
||||
date: string,
|
||||
long: string,
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_bare_key_arg_resolves_from_context :: proc(t: ^testing.T) {
|
||||
data := Key_Format_Data {
|
||||
date = "2026-03-15T08:49:54-04:00",
|
||||
long = "2 January 2006",
|
||||
}
|
||||
tpl, _ := parse("{{date | format long}}", "<test>", allocator = context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "15 March 2026")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_bare_key_arg_missing_errors :: proc(t: ^testing.T) {
|
||||
data := Key_Format_Data {
|
||||
date = "2026-03-15T08:49:54-04:00",
|
||||
long = "2 January 2006",
|
||||
}
|
||||
tpl, _ := parse("{{date | format missing}}", "<test>", allocator = context.temp_allocator)
|
||||
defer delete_template(&tpl)
|
||||
_, err := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect(t, err != nil, "unresolved format key should error")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_bare_key_arg_non_string_errors :: proc(t: ^testing.T) {
|
||||
Int_Key_Data :: struct {
|
||||
date: string,
|
||||
count: int,
|
||||
}
|
||||
data := Int_Key_Data {
|
||||
date = "2026-03-15T08:49:54-04:00",
|
||||
count = 42,
|
||||
}
|
||||
tpl, _ := parse("{{date | format count}}", "<test>", allocator = context.temp_allocator)
|
||||
defer delete_template(&tpl)
|
||||
_, err := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect(t, err != nil, "non-string format key should error")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_unterminated_quote_arg_is_parse_error :: proc(t: ^testing.T) {
|
||||
src := `{{date | format "Jan 2, 2006}}`
|
||||
_, err := parse(src)
|
||||
testing.expect(t, err != nil, "unterminated string literal should fail to parse")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_context_date_format_weekday :: proc(t: ^testing.T) {
|
||||
data := Format_Data {
|
||||
date = "2026-01-01T00:00:00Z",
|
||||
date_format = "Monday, January 2, 2006",
|
||||
}
|
||||
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "Thursday, January 1, 2026")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_context_date_format_time_of_day :: proc(t: ^testing.T) {
|
||||
data := Format_Data {
|
||||
date = "2026-01-01T15:30:00Z",
|
||||
date_format = "3:04 PM",
|
||||
}
|
||||
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "3:30 PM")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_handles_all_iso8601_variants :: proc(t: ^testing.T) {
|
||||
cases := [5]struct {
|
||||
input, expected: string,
|
||||
} {
|
||||
{"2023-10-15T13:18:50-07:00", "15 Oct 2023"},
|
||||
{"2023-10-15T13:18:50-0700", "15 Oct 2023"},
|
||||
{"2023-10-15T13:18:50Z", "15 Oct 2023"},
|
||||
{"2023-10-15T13:18:50", "15 Oct 2023"},
|
||||
{"2023-10-15", "15 Oct 2023"},
|
||||
}
|
||||
for &c in cases {
|
||||
data := Format_Data {
|
||||
date = c.input,
|
||||
date_format = "2 Jan 2006",
|
||||
}
|
||||
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, c.expected)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@(test)
|
||||
test_format_bare_numeric_arg_treated_as_key_not_literal :: proc(t: ^testing.T) {
|
||||
// "2006" happens to also be a valid Go layout token — make sure an
|
||||
// unquoted arg is still resolved as a context key (and fails, since
|
||||
// no field is named "2006"), not silently used as the literal layout.
|
||||
data := Format_Data {
|
||||
date = "2026-03-15T08:49:54-04:00",
|
||||
date_format = "2 Jan 2006",
|
||||
}
|
||||
tpl, _ := parse("{{date | format 2006}}", "<test>", allocator = context.temp_allocator)
|
||||
defer delete_template(&tpl)
|
||||
_, err := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect(t, err != nil, "bare numeric-looking arg should error as an unresolved key")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// format pipe with timezone conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(test)
|
||||
test_format_with_timezone_summer :: proc(t: ^testing.T) {
|
||||
tz, ok := timezone.region_load("America/New_York", context.temp_allocator)
|
||||
testing.expect(t, ok, "should load timezone")
|
||||
if !ok do return
|
||||
defer timezone.region_destroy(tz, context.temp_allocator)
|
||||
|
||||
data := Format_Data {
|
||||
date = "2026-03-15T12:49:54Z",
|
||||
date_format = "15:04 MST",
|
||||
timezone = tz,
|
||||
}
|
||||
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "08:49 EDT")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_with_timezone_winter :: proc(t: ^testing.T) {
|
||||
tz, ok := timezone.region_load("America/New_York", context.temp_allocator)
|
||||
testing.expect(t, ok, "should load timezone")
|
||||
if !ok do return
|
||||
defer timezone.region_destroy(tz, context.temp_allocator)
|
||||
|
||||
data := Format_Data {
|
||||
date = "2026-01-15T12:49:54Z",
|
||||
date_format = "15:04 MST",
|
||||
timezone = tz,
|
||||
}
|
||||
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "07:49 EST")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_mst_offset_no_timezone :: proc(t: ^testing.T) {
|
||||
data := Format_Data {
|
||||
date = "2026-03-15T08:49:54-04:00",
|
||||
date_format = "MST",
|
||||
}
|
||||
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "UTC-04:00")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_mst_no_offset_no_timezone :: proc(t: ^testing.T) {
|
||||
data := Format_Data {
|
||||
date = "2026-03-15",
|
||||
date_format = "MST",
|
||||
}
|
||||
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
|
||||
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'")
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
2011-03-20: v1.1.2
|
||||
Added tests for standalone tags at string boundaries.
|
||||
Added tests for rendering lambda returns after delimiter changes.
|
||||
|
||||
2011-03-20: v1.0.3
|
||||
Added tests for standalone tags at string boundaries.
|
||||
Added tests for rendering lambda returns after delimiter changes.
|
||||
|
||||
2011-03-05: v1.1.1
|
||||
Added tests for indented inline sections.
|
||||
Added tests for Windows-style newlines.
|
||||
|
||||
2011-03-05: v1.0.2
|
||||
Added tests for indented inline sections.
|
||||
Added tests for Windows-style newlines.
|
||||
|
||||
2011-03-04: v1.1.0
|
||||
Implicit iterators.
|
||||
A single period (`.`) may now be used as a name in Interpolation tags,
|
||||
which represents the top of stack (cast as a String).
|
||||
Dotted names.
|
||||
Names containing one or more periods should be resolved as chained
|
||||
properties; naïvely, this is like nesting section tags, but with some
|
||||
built-in scoping protections.
|
||||
|
||||
2011-03-02: v1.0.1
|
||||
Clarifying a point in the README about version compliance.
|
||||
Adding high-level documentation to each spec file.
|
||||
|
||||
2011-02-28: v1.0.0
|
||||
Initial Release
|
||||
@@ -0,0 +1,20 @@
|
||||
Copyright (c) 2010-2022 Mustache Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,63 @@
|
||||
The repository at https://github.com/mustache/spec is the formal standard for
|
||||
Mustache. It defines both normal usage and edge-case behavior for libraries
|
||||
parsing the Mustache templating language (or a superset thereof).
|
||||
|
||||
The specification is developed as a series of YAML files, under the `specs`
|
||||
directory.
|
||||
|
||||
Versioning
|
||||
----------
|
||||
This specification is being [semantically versioned](http://semver.org).
|
||||
Roughly described, major version changes will always represent backwards
|
||||
incompatible changes, minor version changes will always represent new language
|
||||
features and will be backwards compatible, and patch ('tiny') version changes
|
||||
will always be bug fixes. For the purposes of semantic versioning, the public
|
||||
API is the contents of the `specs` directory and the algorithm for testing
|
||||
against it.
|
||||
|
||||
Mustache implementations SHOULD report the most recent version of the spec
|
||||
(major and minor version numbers). If an implementation has support for any
|
||||
optional modules, they SHOULD indicate so with a remark attached to the
|
||||
version number (e.g. "vX.Y, including lambdas" or "v.X.Y+λ"). It is
|
||||
RECOMMENDED that implementations not supporting at least v1.0.0 of this spec
|
||||
refer to themselves as "Mustache-like", or "Mustache-inspired".
|
||||
|
||||
Alternate Formats
|
||||
-----------------
|
||||
|
||||
Since YAML is a reasonably complex format that not every language has good
|
||||
tools for working with, we also provide JSON versions of the specs on a
|
||||
best-effort basis.
|
||||
|
||||
These should be identical to the YAML specifications, but if you find the need
|
||||
to regenerate them, they can be trivially rebuilt by invoking `rake build`.
|
||||
|
||||
It is also worth noting that some specifications (notably, the lambda module)
|
||||
rely on YAML "tags" to denote special types of data (e.g. source code). Since
|
||||
JSON offers no way to denote this, a special key ("`__tag__`") is injected
|
||||
with the name of the tag as its value. See `TESTING.md` for more information
|
||||
about handling tagged data.
|
||||
|
||||
Optional Modules
|
||||
----------------
|
||||
|
||||
Specification files beginning with a tilde (`~`) describe optional modules.
|
||||
As a guideline, a module may be a candidate for optionality when:
|
||||
|
||||
* It does not affect the core syntax of the language.
|
||||
* It does not significantly affect the output of rendered templates.
|
||||
* It concerns implementation language features or data types that are not
|
||||
common to or core in every targeted language.
|
||||
* The lack of support by an implementation does not diminish the usage of
|
||||
Mustache in the target language.
|
||||
|
||||
As an example, the lambda module is primarily concerned with the handling of a
|
||||
particular data type (code). This is a type of data that may be difficult to
|
||||
support in some languages, and users of those languages will not see the lack
|
||||
as an 'inconsistency' between implementations.
|
||||
|
||||
Support for specific pragmas or syntax extensions, however, are best managed
|
||||
outside this core specification, as adjunct specifications.
|
||||
|
||||
Implementors are strongly encouraged to support any and all modules they are
|
||||
reasonably capable of supporting.
|
||||
@@ -0,0 +1,36 @@
|
||||
require 'json'
|
||||
require 'yaml'
|
||||
|
||||
# Our custom YAML tags must retain their magic.
|
||||
class TaggedMap < Hash
|
||||
yaml_tag '!code'
|
||||
def init_with(psych_coder)
|
||||
self.replace({:__tag__ => 'code'}.merge(psych_coder.map))
|
||||
end
|
||||
end
|
||||
|
||||
YAML::add_tag('code', TaggedMap)
|
||||
|
||||
desc 'Build all alternate versions of the specs.'
|
||||
multitask :build => [ 'build:json' ]
|
||||
|
||||
namespace :build do
|
||||
note = 'Do not edit this file; changes belong in the appropriate YAML file.'
|
||||
|
||||
desc 'Build JSON versions of the specs.'
|
||||
task :json do
|
||||
rm(Dir['specs/*.json'], :verbose => false)
|
||||
Dir.glob('specs/*.yml').each do |filename|
|
||||
json_file = filename.gsub('.yml', '.json')
|
||||
|
||||
File.open(json_file, 'w') do |file|
|
||||
warning = {:__ATTN__ => note}
|
||||
doc = YAML.load_file(
|
||||
filename,
|
||||
permitted_classes: [TaggedMap]
|
||||
)
|
||||
file << JSON.pretty_generate(warning.merge(doc)) << "\n"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
Testing your Mustache implementation against this specification should be
|
||||
relatively simple. If you have a readily available testing framework on your
|
||||
platform, your task may be even simpler.
|
||||
|
||||
In general, the process for each `.yml` file is as follows:
|
||||
|
||||
1. Use a YAML parser to load the file.
|
||||
|
||||
2. For each test in the `tests` array:
|
||||
|
||||
1. Ensure that each element of the `partials` hash (if it exists) is
|
||||
stored in a place where the interpreter will look for it.
|
||||
|
||||
2. If your implementation will not support lambdas, feel free to skip
|
||||
over the optional `~lambdas.yml` file.
|
||||
|
||||
Otherwise, ensure that each member of `data` tagged with `!code` is
|
||||
properly processed into a language-specific lambda reference.
|
||||
|
||||
* e.g. Given this YAML data hash:
|
||||
|
||||
`{ x: !code { ruby: 'proc { "x" }', perl: 'sub { "x" }' } }`
|
||||
|
||||
a Ruby-based Mustache implementation would process it such that it
|
||||
was equivalent to this Ruby hash:
|
||||
|
||||
`{ 'x' => proc { "x" } }`
|
||||
|
||||
* If your implementation language does not currently have lambda
|
||||
examples in the spec, feel free to implement them and send a pull
|
||||
request.
|
||||
|
||||
* The JSON version of the spec represents these tagged values as a
|
||||
hash with a `__tag__` key of `code`.
|
||||
|
||||
3. Render the template (stored in the `template` key) with the given
|
||||
`data` hash.
|
||||
|
||||
4. Compare the results of your rendering against the `expected` value;
|
||||
any differences should be reported, along with any useful debugging
|
||||
information.
|
||||
|
||||
* Of note, the `desc` key contains a rough one-line description of
|
||||
the behavior being tested – this is most useful in conjunction with
|
||||
the file name and test `name`.
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.",
|
||||
"overview": "Comment tags represent content that should never appear in the resulting\noutput.\n\nThe tag's content may contain any substring (including newlines) EXCEPT the\nclosing delimiter.\n\nComment tags SHOULD be treated as standalone when appropriate.\n",
|
||||
"tests": [
|
||||
{
|
||||
"name": "Inline",
|
||||
"desc": "Comment blocks should be removed from the template.",
|
||||
"data": {
|
||||
},
|
||||
"template": "12345{{! Comment Block! }}67890",
|
||||
"expected": "1234567890"
|
||||
},
|
||||
{
|
||||
"name": "Multiline",
|
||||
"desc": "Multiline comments should be permitted.",
|
||||
"data": {
|
||||
},
|
||||
"template": "12345{{!\n This is a\n multi-line comment...\n}}67890\n",
|
||||
"expected": "1234567890\n"
|
||||
},
|
||||
{
|
||||
"name": "Standalone",
|
||||
"desc": "All standalone comment lines should be removed.",
|
||||
"data": {
|
||||
},
|
||||
"template": "Begin.\n{{! Comment Block! }}\nEnd.\n",
|
||||
"expected": "Begin.\nEnd.\n"
|
||||
},
|
||||
{
|
||||
"name": "Indented Standalone",
|
||||
"desc": "All standalone comment lines should be removed.",
|
||||
"data": {
|
||||
},
|
||||
"template": "Begin.\n {{! Indented Comment Block! }}\nEnd.\n",
|
||||
"expected": "Begin.\nEnd.\n"
|
||||
},
|
||||
{
|
||||
"name": "Standalone Line Endings",
|
||||
"desc": "\"\\r\\n\" should be considered a newline for standalone tags.",
|
||||
"data": {
|
||||
},
|
||||
"template": "|\r\n{{! Standalone Comment }}\r\n|",
|
||||
"expected": "|\r\n|"
|
||||
},
|
||||
{
|
||||
"name": "Standalone Without Previous Line",
|
||||
"desc": "Standalone tags should not require a newline to precede them.",
|
||||
"data": {
|
||||
},
|
||||
"template": " {{! I'm Still Standalone }}\n!",
|
||||
"expected": "!"
|
||||
},
|
||||
{
|
||||
"name": "Standalone Without Newline",
|
||||
"desc": "Standalone tags should not require a newline to follow them.",
|
||||
"data": {
|
||||
},
|
||||
"template": "!\n {{! I'm Still Standalone }}",
|
||||
"expected": "!\n"
|
||||
},
|
||||
{
|
||||
"name": "Multiline Standalone",
|
||||
"desc": "All standalone comment lines should be removed.",
|
||||
"data": {
|
||||
},
|
||||
"template": "Begin.\n{{!\nSomething's going on here...\n}}\nEnd.\n",
|
||||
"expected": "Begin.\nEnd.\n"
|
||||
},
|
||||
{
|
||||
"name": "Indented Multiline Standalone",
|
||||
"desc": "All standalone comment lines should be removed.",
|
||||
"data": {
|
||||
},
|
||||
"template": "Begin.\n {{!\n Something's going on here...\n }}\nEnd.\n",
|
||||
"expected": "Begin.\nEnd.\n"
|
||||
},
|
||||
{
|
||||
"name": "Indented Inline",
|
||||
"desc": "Inline comments should not strip whitespace",
|
||||
"data": {
|
||||
},
|
||||
"template": " 12 {{! 34 }}\n",
|
||||
"expected": " 12 \n"
|
||||
},
|
||||
{
|
||||
"name": "Surrounding Whitespace",
|
||||
"desc": "Comment removal should preserve surrounding whitespace.",
|
||||
"data": {
|
||||
},
|
||||
"template": "12345 {{! Comment Block! }} 67890",
|
||||
"expected": "12345 67890"
|
||||
},
|
||||
{
|
||||
"name": "Variable Name Collision",
|
||||
"desc": "Comments must never render, even if variable with same name exists.",
|
||||
"data": {
|
||||
"! comment": 1,
|
||||
"! comment ": 2,
|
||||
"!comment": 3,
|
||||
"comment": 4
|
||||
},
|
||||
"template": "comments never show: >{{! comment }}<",
|
||||
"expected": "comments never show: ><"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
overview: |
|
||||
Comment tags represent content that should never appear in the resulting
|
||||
output.
|
||||
|
||||
The tag's content may contain any substring (including newlines) EXCEPT the
|
||||
closing delimiter.
|
||||
|
||||
Comment tags SHOULD be treated as standalone when appropriate.
|
||||
tests:
|
||||
- name: Inline
|
||||
desc: Comment blocks should be removed from the template.
|
||||
data: { }
|
||||
template: '12345{{! Comment Block! }}67890'
|
||||
expected: '1234567890'
|
||||
|
||||
- name: Multiline
|
||||
desc: Multiline comments should be permitted.
|
||||
data: { }
|
||||
template: |
|
||||
12345{{!
|
||||
This is a
|
||||
multi-line comment...
|
||||
}}67890
|
||||
expected: |
|
||||
1234567890
|
||||
|
||||
- name: Standalone
|
||||
desc: All standalone comment lines should be removed.
|
||||
data: { }
|
||||
template: |
|
||||
Begin.
|
||||
{{! Comment Block! }}
|
||||
End.
|
||||
expected: |
|
||||
Begin.
|
||||
End.
|
||||
|
||||
- name: Indented Standalone
|
||||
desc: All standalone comment lines should be removed.
|
||||
data: { }
|
||||
template: |
|
||||
Begin.
|
||||
{{! Indented Comment Block! }}
|
||||
End.
|
||||
expected: |
|
||||
Begin.
|
||||
End.
|
||||
|
||||
- name: Standalone Line Endings
|
||||
desc: '"\r\n" should be considered a newline for standalone tags.'
|
||||
data: { }
|
||||
template: "|\r\n{{! Standalone Comment }}\r\n|"
|
||||
expected: "|\r\n|"
|
||||
|
||||
- name: Standalone Without Previous Line
|
||||
desc: Standalone tags should not require a newline to precede them.
|
||||
data: { }
|
||||
template: " {{! I'm Still Standalone }}\n!"
|
||||
expected: "!"
|
||||
|
||||
- name: Standalone Without Newline
|
||||
desc: Standalone tags should not require a newline to follow them.
|
||||
data: { }
|
||||
template: "!\n {{! I'm Still Standalone }}"
|
||||
expected: "!\n"
|
||||
|
||||
- name: Multiline Standalone
|
||||
desc: All standalone comment lines should be removed.
|
||||
data: { }
|
||||
template: |
|
||||
Begin.
|
||||
{{!
|
||||
Something's going on here...
|
||||
}}
|
||||
End.
|
||||
expected: |
|
||||
Begin.
|
||||
End.
|
||||
|
||||
- name: Indented Multiline Standalone
|
||||
desc: All standalone comment lines should be removed.
|
||||
data: { }
|
||||
template: |
|
||||
Begin.
|
||||
{{!
|
||||
Something's going on here...
|
||||
}}
|
||||
End.
|
||||
expected: |
|
||||
Begin.
|
||||
End.
|
||||
|
||||
- name: Indented Inline
|
||||
desc: Inline comments should not strip whitespace
|
||||
data: { }
|
||||
template: " 12 {{! 34 }}\n"
|
||||
expected: " 12 \n"
|
||||
|
||||
- name: Surrounding Whitespace
|
||||
desc: Comment removal should preserve surrounding whitespace.
|
||||
data: { }
|
||||
template: '12345 {{! Comment Block! }} 67890'
|
||||
expected: '12345 67890'
|
||||
|
||||
- name: Variable Name Collision
|
||||
desc: Comments must never render, even if variable with same name exists.
|
||||
data: { '! comment': 1, '! comment ': 2, '!comment': 3, 'comment': 4}
|
||||
template: 'comments never show: >{{! comment }}<'
|
||||
expected: 'comments never show: ><'
|
||||
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.",
|
||||
"overview": "Set Delimiter tags are used to change the tag delimiters for all content\nfollowing the tag in the current compilation unit.\n\nThe tag's content MUST be any two non-whitespace sequences (separated by\nwhitespace) EXCEPT an equals sign ('=') followed by the current closing\ndelimiter.\n\nSet Delimiter tags SHOULD be treated as standalone when appropriate.\n",
|
||||
"tests": [
|
||||
{
|
||||
"name": "Pair Behavior",
|
||||
"desc": "The equals sign (used on both sides) should permit delimiter changes.",
|
||||
"data": {
|
||||
"text": "Hey!"
|
||||
},
|
||||
"template": "{{=<% %>=}}(<%text%>)",
|
||||
"expected": "(Hey!)"
|
||||
},
|
||||
{
|
||||
"name": "Special Characters",
|
||||
"desc": "Characters with special meaning regexen should be valid delimiters.",
|
||||
"data": {
|
||||
"text": "It worked!"
|
||||
},
|
||||
"template": "({{=[ ]=}}[text])",
|
||||
"expected": "(It worked!)"
|
||||
},
|
||||
{
|
||||
"name": "Sections",
|
||||
"desc": "Delimiters set outside sections should persist.",
|
||||
"data": {
|
||||
"section": true,
|
||||
"data": "I got interpolated."
|
||||
},
|
||||
"template": "[\n{{#section}}\n {{data}}\n |data|\n{{/section}}\n\n{{= | | =}}\n|#section|\n {{data}}\n |data|\n|/section|\n]\n",
|
||||
"expected": "[\n I got interpolated.\n |data|\n\n {{data}}\n I got interpolated.\n]\n"
|
||||
},
|
||||
{
|
||||
"name": "Inverted Sections",
|
||||
"desc": "Delimiters set outside inverted sections should persist.",
|
||||
"data": {
|
||||
"section": false,
|
||||
"data": "I got interpolated."
|
||||
},
|
||||
"template": "[\n{{^section}}\n {{data}}\n |data|\n{{/section}}\n\n{{= | | =}}\n|^section|\n {{data}}\n |data|\n|/section|\n]\n",
|
||||
"expected": "[\n I got interpolated.\n |data|\n\n {{data}}\n I got interpolated.\n]\n"
|
||||
},
|
||||
{
|
||||
"name": "Partial Inheritence",
|
||||
"desc": "Delimiters set in a parent template should not affect a partial.",
|
||||
"data": {
|
||||
"value": "yes"
|
||||
},
|
||||
"partials": {
|
||||
"include": ".{{value}}."
|
||||
},
|
||||
"template": "[ {{>include}} ]\n{{= | | =}}\n[ |>include| ]\n",
|
||||
"expected": "[ .yes. ]\n[ .yes. ]\n"
|
||||
},
|
||||
{
|
||||
"name": "Post-Partial Behavior",
|
||||
"desc": "Delimiters set in a partial should not affect the parent template.",
|
||||
"data": {
|
||||
"value": "yes"
|
||||
},
|
||||
"partials": {
|
||||
"include": ".{{value}}. {{= | | =}} .|value|."
|
||||
},
|
||||
"template": "[ {{>include}} ]\n[ .{{value}}. .|value|. ]\n",
|
||||
"expected": "[ .yes. .yes. ]\n[ .yes. .|value|. ]\n"
|
||||
},
|
||||
{
|
||||
"name": "Surrounding Whitespace",
|
||||
"desc": "Surrounding whitespace should be left untouched.",
|
||||
"data": {
|
||||
},
|
||||
"template": "| {{=@ @=}} |",
|
||||
"expected": "| |"
|
||||
},
|
||||
{
|
||||
"name": "Outlying Whitespace (Inline)",
|
||||
"desc": "Whitespace should be left untouched.",
|
||||
"data": {
|
||||
},
|
||||
"template": " | {{=@ @=}}\n",
|
||||
"expected": " | \n"
|
||||
},
|
||||
{
|
||||
"name": "Standalone Tag",
|
||||
"desc": "Standalone lines should be removed from the template.",
|
||||
"data": {
|
||||
},
|
||||
"template": "Begin.\n{{=@ @=}}\nEnd.\n",
|
||||
"expected": "Begin.\nEnd.\n"
|
||||
},
|
||||
{
|
||||
"name": "Indented Standalone Tag",
|
||||
"desc": "Indented standalone lines should be removed from the template.",
|
||||
"data": {
|
||||
},
|
||||
"template": "Begin.\n {{=@ @=}}\nEnd.\n",
|
||||
"expected": "Begin.\nEnd.\n"
|
||||
},
|
||||
{
|
||||
"name": "Standalone Line Endings",
|
||||
"desc": "\"\\r\\n\" should be considered a newline for standalone tags.",
|
||||
"data": {
|
||||
},
|
||||
"template": "|\r\n{{= @ @ =}}\r\n|",
|
||||
"expected": "|\r\n|"
|
||||
},
|
||||
{
|
||||
"name": "Standalone Without Previous Line",
|
||||
"desc": "Standalone tags should not require a newline to precede them.",
|
||||
"data": {
|
||||
},
|
||||
"template": " {{=@ @=}}\n=",
|
||||
"expected": "="
|
||||
},
|
||||
{
|
||||
"name": "Standalone Without Newline",
|
||||
"desc": "Standalone tags should not require a newline to follow them.",
|
||||
"data": {
|
||||
},
|
||||
"template": "=\n {{=@ @=}}",
|
||||
"expected": "=\n"
|
||||
},
|
||||
{
|
||||
"name": "Pair with Padding",
|
||||
"desc": "Superfluous in-tag whitespace should be ignored.",
|
||||
"data": {
|
||||
},
|
||||
"template": "|{{= @ @ =}}|",
|
||||
"expected": "||"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
overview: |
|
||||
Set Delimiter tags are used to change the tag delimiters for all content
|
||||
following the tag in the current compilation unit.
|
||||
|
||||
The tag's content MUST be any two non-whitespace sequences (separated by
|
||||
whitespace) EXCEPT an equals sign ('=') followed by the current closing
|
||||
delimiter.
|
||||
|
||||
Set Delimiter tags SHOULD be treated as standalone when appropriate.
|
||||
tests:
|
||||
- name: Pair Behavior
|
||||
desc: The equals sign (used on both sides) should permit delimiter changes.
|
||||
data: { text: 'Hey!' }
|
||||
template: '{{=<% %>=}}(<%text%>)'
|
||||
expected: '(Hey!)'
|
||||
|
||||
- name: Special Characters
|
||||
desc: Characters with special meaning regexen should be valid delimiters.
|
||||
data: { text: 'It worked!' }
|
||||
template: '({{=[ ]=}}[text])'
|
||||
expected: '(It worked!)'
|
||||
|
||||
- name: Sections
|
||||
desc: Delimiters set outside sections should persist.
|
||||
data: { section: true, data: 'I got interpolated.' }
|
||||
template: |
|
||||
[
|
||||
{{#section}}
|
||||
{{data}}
|
||||
|data|
|
||||
{{/section}}
|
||||
|
||||
{{= | | =}}
|
||||
|#section|
|
||||
{{data}}
|
||||
|data|
|
||||
|/section|
|
||||
]
|
||||
expected: |
|
||||
[
|
||||
I got interpolated.
|
||||
|data|
|
||||
|
||||
{{data}}
|
||||
I got interpolated.
|
||||
]
|
||||
|
||||
- name: Inverted Sections
|
||||
desc: Delimiters set outside inverted sections should persist.
|
||||
data: { section: false, data: 'I got interpolated.' }
|
||||
template: |
|
||||
[
|
||||
{{^section}}
|
||||
{{data}}
|
||||
|data|
|
||||
{{/section}}
|
||||
|
||||
{{= | | =}}
|
||||
|^section|
|
||||
{{data}}
|
||||
|data|
|
||||
|/section|
|
||||
]
|
||||
expected: |
|
||||
[
|
||||
I got interpolated.
|
||||
|data|
|
||||
|
||||
{{data}}
|
||||
I got interpolated.
|
||||
]
|
||||
|
||||
- name: Partial Inheritence
|
||||
desc: Delimiters set in a parent template should not affect a partial.
|
||||
data: { value: 'yes' }
|
||||
partials:
|
||||
include: '.{{value}}.'
|
||||
template: |
|
||||
[ {{>include}} ]
|
||||
{{= | | =}}
|
||||
[ |>include| ]
|
||||
expected: |
|
||||
[ .yes. ]
|
||||
[ .yes. ]
|
||||
|
||||
- name: Post-Partial Behavior
|
||||
desc: Delimiters set in a partial should not affect the parent template.
|
||||
data: { value: 'yes' }
|
||||
partials:
|
||||
include: '.{{value}}. {{= | | =}} .|value|.'
|
||||
template: |
|
||||
[ {{>include}} ]
|
||||
[ .{{value}}. .|value|. ]
|
||||
expected: |
|
||||
[ .yes. .yes. ]
|
||||
[ .yes. .|value|. ]
|
||||
|
||||
# Whitespace Sensitivity
|
||||
|
||||
- name: Surrounding Whitespace
|
||||
desc: Surrounding whitespace should be left untouched.
|
||||
data: { }
|
||||
template: '| {{=@ @=}} |'
|
||||
expected: '| |'
|
||||
|
||||
- name: Outlying Whitespace (Inline)
|
||||
desc: Whitespace should be left untouched.
|
||||
data: { }
|
||||
template: " | {{=@ @=}}\n"
|
||||
expected: " | \n"
|
||||
|
||||
- name: Standalone Tag
|
||||
desc: Standalone lines should be removed from the template.
|
||||
data: { }
|
||||
template: |
|
||||
Begin.
|
||||
{{=@ @=}}
|
||||
End.
|
||||
expected: |
|
||||
Begin.
|
||||
End.
|
||||
|
||||
- name: Indented Standalone Tag
|
||||
desc: Indented standalone lines should be removed from the template.
|
||||
data: { }
|
||||
template: |
|
||||
Begin.
|
||||
{{=@ @=}}
|
||||
End.
|
||||
expected: |
|
||||
Begin.
|
||||
End.
|
||||
|
||||
- name: Standalone Line Endings
|
||||
desc: '"\r\n" should be considered a newline for standalone tags.'
|
||||
data: { }
|
||||
template: "|\r\n{{= @ @ =}}\r\n|"
|
||||
expected: "|\r\n|"
|
||||
|
||||
- name: Standalone Without Previous Line
|
||||
desc: Standalone tags should not require a newline to precede them.
|
||||
data: { }
|
||||
template: " {{=@ @=}}\n="
|
||||
expected: "="
|
||||
|
||||
- name: Standalone Without Newline
|
||||
desc: Standalone tags should not require a newline to follow them.
|
||||
data: { }
|
||||
template: "=\n {{=@ @=}}"
|
||||
expected: "=\n"
|
||||
|
||||
# Whitespace Insensitivity
|
||||
|
||||
- name: Pair with Padding
|
||||
desc: Superfluous in-tag whitespace should be ignored.
|
||||
data: { }
|
||||
template: '|{{= @ @ =}}|'
|
||||
expected: '||'
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,422 @@
|
||||
{
|
||||
"__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.",
|
||||
"overview": "Interpolation tags are used to integrate dynamic content into the template.\n\nThe tag's content MUST be a non-whitespace character sequence NOT containing\nthe current closing delimiter.\n\nThis tag's content names the data to replace the tag. A single period (`.`)\nindicates that the item currently sitting atop the context stack should be\nused; otherwise, name resolution is as follows:\n 1) Split the name on periods; the first part is the name to resolve, any\n remaining parts should be retained.\n 2) Walk the context stack from top to bottom, finding the first context\n that is a) a hash containing the name as a key OR b) an object responding\n to a method with the given name.\n 3) If the context is a hash, the data is the value associated with the\n name.\n 4) If the context is an object, the data is the value returned by the\n method with the given name.\n 5) If any name parts were retained in step 1, each should be resolved\n against a context stack containing only the result from the former\n resolution. If any part fails resolution, the result should be considered\n falsey, and should interpolate as the empty string.\nData should be coerced into a string (and escaped, if appropriate) before\ninterpolation.\n\nThe Interpolation tags MUST NOT be treated as standalone.\n",
|
||||
"tests": [
|
||||
{
|
||||
"name": "No Interpolation",
|
||||
"desc": "Mustache-free templates should render as-is.",
|
||||
"data": {
|
||||
},
|
||||
"template": "Hello from {Mustache}!\n",
|
||||
"expected": "Hello from {Mustache}!\n"
|
||||
},
|
||||
{
|
||||
"name": "Basic Interpolation",
|
||||
"desc": "Unadorned tags should interpolate content into the template.",
|
||||
"data": {
|
||||
"subject": "world"
|
||||
},
|
||||
"template": "Hello, {{subject}}!\n",
|
||||
"expected": "Hello, world!\n"
|
||||
},
|
||||
{
|
||||
"name": "No Re-interpolation",
|
||||
"desc": "Interpolated tag output should not be re-interpolated.",
|
||||
"data": {
|
||||
"template": "{{planet}}",
|
||||
"planet": "Earth"
|
||||
},
|
||||
"template": "{{template}}: {{planet}}",
|
||||
"expected": "{{planet}}: Earth"
|
||||
},
|
||||
{
|
||||
"name": "HTML Escaping",
|
||||
"desc": "Basic interpolation should be HTML escaped.",
|
||||
"data": {
|
||||
"forbidden": "& \" < >"
|
||||
},
|
||||
"template": "These characters should be HTML escaped: {{forbidden}}\n",
|
||||
"expected": "These characters should be HTML escaped: & " < >\n"
|
||||
},
|
||||
{
|
||||
"name": "Triple Mustache",
|
||||
"desc": "Triple mustaches should interpolate without HTML escaping.",
|
||||
"data": {
|
||||
"forbidden": "& \" < >"
|
||||
},
|
||||
"template": "These characters should not be HTML escaped: {{{forbidden}}}\n",
|
||||
"expected": "These characters should not be HTML escaped: & \" < >\n"
|
||||
},
|
||||
{
|
||||
"name": "Ampersand",
|
||||
"desc": "Ampersand should interpolate without HTML escaping.",
|
||||
"data": {
|
||||
"forbidden": "& \" < >"
|
||||
},
|
||||
"template": "These characters should not be HTML escaped: {{&forbidden}}\n",
|
||||
"expected": "These characters should not be HTML escaped: & \" < >\n"
|
||||
},
|
||||
{
|
||||
"name": "Basic Integer Interpolation",
|
||||
"desc": "Integers should interpolate seamlessly.",
|
||||
"data": {
|
||||
"mph": 85
|
||||
},
|
||||
"template": "\"{{mph}} miles an hour!\"",
|
||||
"expected": "\"85 miles an hour!\""
|
||||
},
|
||||
{
|
||||
"name": "Triple Mustache Integer Interpolation",
|
||||
"desc": "Integers should interpolate seamlessly.",
|
||||
"data": {
|
||||
"mph": 85
|
||||
},
|
||||
"template": "\"{{{mph}}} miles an hour!\"",
|
||||
"expected": "\"85 miles an hour!\""
|
||||
},
|
||||
{
|
||||
"name": "Ampersand Integer Interpolation",
|
||||
"desc": "Integers should interpolate seamlessly.",
|
||||
"data": {
|
||||
"mph": 85
|
||||
},
|
||||
"template": "\"{{&mph}} miles an hour!\"",
|
||||
"expected": "\"85 miles an hour!\""
|
||||
},
|
||||
{
|
||||
"name": "Basic Decimal Interpolation",
|
||||
"desc": "Decimals should interpolate seamlessly with proper significance.",
|
||||
"data": {
|
||||
"power": 1.21
|
||||
},
|
||||
"template": "\"{{power}} jiggawatts!\"",
|
||||
"expected": "\"1.21 jiggawatts!\""
|
||||
},
|
||||
{
|
||||
"name": "Triple Mustache Decimal Interpolation",
|
||||
"desc": "Decimals should interpolate seamlessly with proper significance.",
|
||||
"data": {
|
||||
"power": 1.21
|
||||
},
|
||||
"template": "\"{{{power}}} jiggawatts!\"",
|
||||
"expected": "\"1.21 jiggawatts!\""
|
||||
},
|
||||
{
|
||||
"name": "Ampersand Decimal Interpolation",
|
||||
"desc": "Decimals should interpolate seamlessly with proper significance.",
|
||||
"data": {
|
||||
"power": 1.21
|
||||
},
|
||||
"template": "\"{{&power}} jiggawatts!\"",
|
||||
"expected": "\"1.21 jiggawatts!\""
|
||||
},
|
||||
{
|
||||
"name": "Basic Null Interpolation",
|
||||
"desc": "Nulls should interpolate as the empty string.",
|
||||
"data": {
|
||||
"cannot": null
|
||||
},
|
||||
"template": "I ({{cannot}}) be seen!",
|
||||
"expected": "I () be seen!"
|
||||
},
|
||||
{
|
||||
"name": "Triple Mustache Null Interpolation",
|
||||
"desc": "Nulls should interpolate as the empty string.",
|
||||
"data": {
|
||||
"cannot": null
|
||||
},
|
||||
"template": "I ({{{cannot}}}) be seen!",
|
||||
"expected": "I () be seen!"
|
||||
},
|
||||
{
|
||||
"name": "Ampersand Null Interpolation",
|
||||
"desc": "Nulls should interpolate as the empty string.",
|
||||
"data": {
|
||||
"cannot": null
|
||||
},
|
||||
"template": "I ({{&cannot}}) be seen!",
|
||||
"expected": "I () be seen!"
|
||||
},
|
||||
{
|
||||
"name": "Basic Context Miss Interpolation",
|
||||
"desc": "Failed context lookups should default to empty strings.",
|
||||
"data": {
|
||||
},
|
||||
"template": "I ({{cannot}}) be seen!",
|
||||
"expected": "I () be seen!"
|
||||
},
|
||||
{
|
||||
"name": "Triple Mustache Context Miss Interpolation",
|
||||
"desc": "Failed context lookups should default to empty strings.",
|
||||
"data": {
|
||||
},
|
||||
"template": "I ({{{cannot}}}) be seen!",
|
||||
"expected": "I () be seen!"
|
||||
},
|
||||
{
|
||||
"name": "Ampersand Context Miss Interpolation",
|
||||
"desc": "Failed context lookups should default to empty strings.",
|
||||
"data": {
|
||||
},
|
||||
"template": "I ({{&cannot}}) be seen!",
|
||||
"expected": "I () be seen!"
|
||||
},
|
||||
{
|
||||
"name": "Dotted Names - Basic Interpolation",
|
||||
"desc": "Dotted names should be considered a form of shorthand for sections.",
|
||||
"data": {
|
||||
"person": {
|
||||
"name": "Joe"
|
||||
}
|
||||
},
|
||||
"template": "\"{{person.name}}\" == \"{{#person}}{{name}}{{/person}}\"",
|
||||
"expected": "\"Joe\" == \"Joe\""
|
||||
},
|
||||
{
|
||||
"name": "Dotted Names - Triple Mustache Interpolation",
|
||||
"desc": "Dotted names should be considered a form of shorthand for sections.",
|
||||
"data": {
|
||||
"person": {
|
||||
"name": "Joe"
|
||||
}
|
||||
},
|
||||
"template": "\"{{{person.name}}}\" == \"{{#person}}{{{name}}}{{/person}}\"",
|
||||
"expected": "\"Joe\" == \"Joe\""
|
||||
},
|
||||
{
|
||||
"name": "Dotted Names - Ampersand Interpolation",
|
||||
"desc": "Dotted names should be considered a form of shorthand for sections.",
|
||||
"data": {
|
||||
"person": {
|
||||
"name": "Joe"
|
||||
}
|
||||
},
|
||||
"template": "\"{{&person.name}}\" == \"{{#person}}{{&name}}{{/person}}\"",
|
||||
"expected": "\"Joe\" == \"Joe\""
|
||||
},
|
||||
{
|
||||
"name": "Dotted Names - Arbitrary Depth",
|
||||
"desc": "Dotted names should be functional to any level of nesting.",
|
||||
"data": {
|
||||
"a": {
|
||||
"b": {
|
||||
"c": {
|
||||
"d": {
|
||||
"e": {
|
||||
"name": "Phil"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"template": "\"{{a.b.c.d.e.name}}\" == \"Phil\"",
|
||||
"expected": "\"Phil\" == \"Phil\""
|
||||
},
|
||||
{
|
||||
"name": "Dotted Names - Broken Chains",
|
||||
"desc": "Any falsey value prior to the last part of the name should yield ''.",
|
||||
"data": {
|
||||
"a": {
|
||||
}
|
||||
},
|
||||
"template": "\"{{a.b.c}}\" == \"\"",
|
||||
"expected": "\"\" == \"\""
|
||||
},
|
||||
{
|
||||
"name": "Dotted Names - Broken Chain Resolution",
|
||||
"desc": "Each part of a dotted name should resolve only against its parent.",
|
||||
"data": {
|
||||
"a": {
|
||||
"b": {
|
||||
}
|
||||
},
|
||||
"c": {
|
||||
"name": "Jim"
|
||||
}
|
||||
},
|
||||
"template": "\"{{a.b.c.name}}\" == \"\"",
|
||||
"expected": "\"\" == \"\""
|
||||
},
|
||||
{
|
||||
"name": "Dotted Names - Initial Resolution",
|
||||
"desc": "The first part of a dotted name should resolve as any other name.",
|
||||
"data": {
|
||||
"a": {
|
||||
"b": {
|
||||
"c": {
|
||||
"d": {
|
||||
"e": {
|
||||
"name": "Phil"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"b": {
|
||||
"c": {
|
||||
"d": {
|
||||
"e": {
|
||||
"name": "Wrong"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"template": "\"{{#a}}{{b.c.d.e.name}}{{/a}}\" == \"Phil\"",
|
||||
"expected": "\"Phil\" == \"Phil\""
|
||||
},
|
||||
{
|
||||
"name": "Dotted Names - Context Precedence",
|
||||
"desc": "Dotted names should be resolved against former resolutions.",
|
||||
"data": {
|
||||
"a": {
|
||||
"b": {
|
||||
}
|
||||
},
|
||||
"b": {
|
||||
"c": "ERROR"
|
||||
}
|
||||
},
|
||||
"template": "{{#a}}{{b.c}}{{/a}}",
|
||||
"expected": ""
|
||||
},
|
||||
{
|
||||
"name": "Dotted Names are never single keys",
|
||||
"desc": "Dotted names shall not be parsed as single, atomic keys",
|
||||
"data": {
|
||||
"a.b": "c"
|
||||
},
|
||||
"template": "{{a.b}}",
|
||||
"expected": ""
|
||||
},
|
||||
{
|
||||
"name": "Dotted Names - No Masking",
|
||||
"desc": "Dotted Names in a given context are unvavailable due to dot splitting",
|
||||
"data": {
|
||||
"a.b": "c",
|
||||
"a": {
|
||||
"b": "d"
|
||||
}
|
||||
},
|
||||
"template": "{{a.b}}",
|
||||
"expected": "d"
|
||||
},
|
||||
{
|
||||
"name": "Implicit Iterators - Basic Interpolation",
|
||||
"desc": "Unadorned tags should interpolate content into the template.",
|
||||
"data": "world",
|
||||
"template": "Hello, {{.}}!\n",
|
||||
"expected": "Hello, world!\n"
|
||||
},
|
||||
{
|
||||
"name": "Implicit Iterators - HTML Escaping",
|
||||
"desc": "Basic interpolation should be HTML escaped.",
|
||||
"data": "& \" < >",
|
||||
"template": "These characters should be HTML escaped: {{.}}\n",
|
||||
"expected": "These characters should be HTML escaped: & " < >\n"
|
||||
},
|
||||
{
|
||||
"name": "Implicit Iterators - Triple Mustache",
|
||||
"desc": "Triple mustaches should interpolate without HTML escaping.",
|
||||
"data": "& \" < >",
|
||||
"template": "These characters should not be HTML escaped: {{{.}}}\n",
|
||||
"expected": "These characters should not be HTML escaped: & \" < >\n"
|
||||
},
|
||||
{
|
||||
"name": "Implicit Iterators - Ampersand",
|
||||
"desc": "Ampersand should interpolate without HTML escaping.",
|
||||
"data": "& \" < >",
|
||||
"template": "These characters should not be HTML escaped: {{&.}}\n",
|
||||
"expected": "These characters should not be HTML escaped: & \" < >\n"
|
||||
},
|
||||
{
|
||||
"name": "Implicit Iterators - Basic Integer Interpolation",
|
||||
"desc": "Integers should interpolate seamlessly.",
|
||||
"data": 85,
|
||||
"template": "\"{{.}} miles an hour!\"",
|
||||
"expected": "\"85 miles an hour!\""
|
||||
},
|
||||
{
|
||||
"name": "Interpolation - Surrounding Whitespace",
|
||||
"desc": "Interpolation should not alter surrounding whitespace.",
|
||||
"data": {
|
||||
"string": "---"
|
||||
},
|
||||
"template": "| {{string}} |",
|
||||
"expected": "| --- |"
|
||||
},
|
||||
{
|
||||
"name": "Triple Mustache - Surrounding Whitespace",
|
||||
"desc": "Interpolation should not alter surrounding whitespace.",
|
||||
"data": {
|
||||
"string": "---"
|
||||
},
|
||||
"template": "| {{{string}}} |",
|
||||
"expected": "| --- |"
|
||||
},
|
||||
{
|
||||
"name": "Ampersand - Surrounding Whitespace",
|
||||
"desc": "Interpolation should not alter surrounding whitespace.",
|
||||
"data": {
|
||||
"string": "---"
|
||||
},
|
||||
"template": "| {{&string}} |",
|
||||
"expected": "| --- |"
|
||||
},
|
||||
{
|
||||
"name": "Interpolation - Standalone",
|
||||
"desc": "Standalone interpolation should not alter surrounding whitespace.",
|
||||
"data": {
|
||||
"string": "---"
|
||||
},
|
||||
"template": " {{string}}\n",
|
||||
"expected": " ---\n"
|
||||
},
|
||||
{
|
||||
"name": "Triple Mustache - Standalone",
|
||||
"desc": "Standalone interpolation should not alter surrounding whitespace.",
|
||||
"data": {
|
||||
"string": "---"
|
||||
},
|
||||
"template": " {{{string}}}\n",
|
||||
"expected": " ---\n"
|
||||
},
|
||||
{
|
||||
"name": "Ampersand - Standalone",
|
||||
"desc": "Standalone interpolation should not alter surrounding whitespace.",
|
||||
"data": {
|
||||
"string": "---"
|
||||
},
|
||||
"template": " {{&string}}\n",
|
||||
"expected": " ---\n"
|
||||
},
|
||||
{
|
||||
"name": "Interpolation With Padding",
|
||||
"desc": "Superfluous in-tag whitespace should be ignored.",
|
||||
"data": {
|
||||
"string": "---"
|
||||
},
|
||||
"template": "|{{ string }}|",
|
||||
"expected": "|---|"
|
||||
},
|
||||
{
|
||||
"name": "Triple Mustache With Padding",
|
||||
"desc": "Superfluous in-tag whitespace should be ignored.",
|
||||
"data": {
|
||||
"string": "---"
|
||||
},
|
||||
"template": "|{{{ string }}}|",
|
||||
"expected": "|---|"
|
||||
},
|
||||
{
|
||||
"name": "Ampersand With Padding",
|
||||
"desc": "Superfluous in-tag whitespace should be ignored.",
|
||||
"data": {
|
||||
"string": "---"
|
||||
},
|
||||
"template": "|{{& string }}|",
|
||||
"expected": "|---|"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
overview: |
|
||||
Interpolation tags are used to integrate dynamic content into the template.
|
||||
|
||||
The tag's content MUST be a non-whitespace character sequence NOT containing
|
||||
the current closing delimiter.
|
||||
|
||||
This tag's content names the data to replace the tag. A single period (`.`)
|
||||
indicates that the item currently sitting atop the context stack should be
|
||||
used; otherwise, name resolution is as follows:
|
||||
1) Split the name on periods; the first part is the name to resolve, any
|
||||
remaining parts should be retained.
|
||||
2) Walk the context stack from top to bottom, finding the first context
|
||||
that is a) a hash containing the name as a key OR b) an object responding
|
||||
to a method with the given name.
|
||||
3) If the context is a hash, the data is the value associated with the
|
||||
name.
|
||||
4) If the context is an object, the data is the value returned by the
|
||||
method with the given name.
|
||||
5) If any name parts were retained in step 1, each should be resolved
|
||||
against a context stack containing only the result from the former
|
||||
resolution. If any part fails resolution, the result should be considered
|
||||
falsey, and should interpolate as the empty string.
|
||||
Data should be coerced into a string (and escaped, if appropriate) before
|
||||
interpolation.
|
||||
|
||||
The Interpolation tags MUST NOT be treated as standalone.
|
||||
tests:
|
||||
- name: No Interpolation
|
||||
desc: Mustache-free templates should render as-is.
|
||||
data: { }
|
||||
template: |
|
||||
Hello from {Mustache}!
|
||||
expected: |
|
||||
Hello from {Mustache}!
|
||||
|
||||
- name: Basic Interpolation
|
||||
desc: Unadorned tags should interpolate content into the template.
|
||||
data: { subject: "world" }
|
||||
template: |
|
||||
Hello, {{subject}}!
|
||||
expected: |
|
||||
Hello, world!
|
||||
|
||||
- name: No Re-interpolation
|
||||
desc: Interpolated tag output should not be re-interpolated.
|
||||
data: { template: '{{planet}}', planet: 'Earth' }
|
||||
template: '{{template}}: {{planet}}'
|
||||
expected: '{{planet}}: Earth'
|
||||
|
||||
- name: HTML Escaping
|
||||
desc: Basic interpolation should be HTML escaped.
|
||||
data: { forbidden: '& " < >' }
|
||||
template: |
|
||||
These characters should be HTML escaped: {{forbidden}}
|
||||
expected: |
|
||||
These characters should be HTML escaped: & " < >
|
||||
|
||||
- name: Triple Mustache
|
||||
desc: Triple mustaches should interpolate without HTML escaping.
|
||||
data: { forbidden: '& " < >' }
|
||||
template: |
|
||||
These characters should not be HTML escaped: {{{forbidden}}}
|
||||
expected: |
|
||||
These characters should not be HTML escaped: & " < >
|
||||
|
||||
- name: Ampersand
|
||||
desc: Ampersand should interpolate without HTML escaping.
|
||||
data: { forbidden: '& " < >' }
|
||||
template: |
|
||||
These characters should not be HTML escaped: {{&forbidden}}
|
||||
expected: |
|
||||
These characters should not be HTML escaped: & " < >
|
||||
|
||||
- name: Basic Integer Interpolation
|
||||
desc: Integers should interpolate seamlessly.
|
||||
data: { mph: 85 }
|
||||
template: '"{{mph}} miles an hour!"'
|
||||
expected: '"85 miles an hour!"'
|
||||
|
||||
- name: Triple Mustache Integer Interpolation
|
||||
desc: Integers should interpolate seamlessly.
|
||||
data: { mph: 85 }
|
||||
template: '"{{{mph}}} miles an hour!"'
|
||||
expected: '"85 miles an hour!"'
|
||||
|
||||
- name: Ampersand Integer Interpolation
|
||||
desc: Integers should interpolate seamlessly.
|
||||
data: { mph: 85 }
|
||||
template: '"{{&mph}} miles an hour!"'
|
||||
expected: '"85 miles an hour!"'
|
||||
|
||||
- name: Basic Decimal Interpolation
|
||||
desc: Decimals should interpolate seamlessly with proper significance.
|
||||
data: { power: 1.210 }
|
||||
template: '"{{power}} jiggawatts!"'
|
||||
expected: '"1.21 jiggawatts!"'
|
||||
|
||||
- name: Triple Mustache Decimal Interpolation
|
||||
desc: Decimals should interpolate seamlessly with proper significance.
|
||||
data: { power: 1.210 }
|
||||
template: '"{{{power}}} jiggawatts!"'
|
||||
expected: '"1.21 jiggawatts!"'
|
||||
|
||||
- name: Ampersand Decimal Interpolation
|
||||
desc: Decimals should interpolate seamlessly with proper significance.
|
||||
data: { power: 1.210 }
|
||||
template: '"{{&power}} jiggawatts!"'
|
||||
expected: '"1.21 jiggawatts!"'
|
||||
|
||||
- name: Basic Null Interpolation
|
||||
desc: Nulls should interpolate as the empty string.
|
||||
data: { cannot: null }
|
||||
template: "I ({{cannot}}) be seen!"
|
||||
expected: "I () be seen!"
|
||||
|
||||
- name: Triple Mustache Null Interpolation
|
||||
desc: Nulls should interpolate as the empty string.
|
||||
data: { cannot: null }
|
||||
template: "I ({{{cannot}}}) be seen!"
|
||||
expected: "I () be seen!"
|
||||
|
||||
- name: Ampersand Null Interpolation
|
||||
desc: Nulls should interpolate as the empty string.
|
||||
data: { cannot: null }
|
||||
template: "I ({{&cannot}}) be seen!"
|
||||
expected: "I () be seen!"
|
||||
|
||||
# Context Misses
|
||||
|
||||
- name: Basic Context Miss Interpolation
|
||||
desc: Failed context lookups should default to empty strings.
|
||||
data: { }
|
||||
template: "I ({{cannot}}) be seen!"
|
||||
expected: "I () be seen!"
|
||||
|
||||
- name: Triple Mustache Context Miss Interpolation
|
||||
desc: Failed context lookups should default to empty strings.
|
||||
data: { }
|
||||
template: "I ({{{cannot}}}) be seen!"
|
||||
expected: "I () be seen!"
|
||||
|
||||
- name: Ampersand Context Miss Interpolation
|
||||
desc: Failed context lookups should default to empty strings.
|
||||
data: { }
|
||||
template: "I ({{&cannot}}) be seen!"
|
||||
expected: "I () be seen!"
|
||||
|
||||
# Dotted Names
|
||||
|
||||
- name: Dotted Names - Basic Interpolation
|
||||
desc: Dotted names should be considered a form of shorthand for sections.
|
||||
data: { person: { name: 'Joe' } }
|
||||
template: '"{{person.name}}" == "{{#person}}{{name}}{{/person}}"'
|
||||
expected: '"Joe" == "Joe"'
|
||||
|
||||
- name: Dotted Names - Triple Mustache Interpolation
|
||||
desc: Dotted names should be considered a form of shorthand for sections.
|
||||
data: { person: { name: 'Joe' } }
|
||||
template: '"{{{person.name}}}" == "{{#person}}{{{name}}}{{/person}}"'
|
||||
expected: '"Joe" == "Joe"'
|
||||
|
||||
- name: Dotted Names - Ampersand Interpolation
|
||||
desc: Dotted names should be considered a form of shorthand for sections.
|
||||
data: { person: { name: 'Joe' } }
|
||||
template: '"{{&person.name}}" == "{{#person}}{{&name}}{{/person}}"'
|
||||
expected: '"Joe" == "Joe"'
|
||||
|
||||
- name: Dotted Names - Arbitrary Depth
|
||||
desc: Dotted names should be functional to any level of nesting.
|
||||
data:
|
||||
a: { b: { c: { d: { e: { name: 'Phil' } } } } }
|
||||
template: '"{{a.b.c.d.e.name}}" == "Phil"'
|
||||
expected: '"Phil" == "Phil"'
|
||||
|
||||
- name: Dotted Names - Broken Chains
|
||||
desc: Any falsey value prior to the last part of the name should yield ''.
|
||||
data:
|
||||
a: { }
|
||||
template: '"{{a.b.c}}" == ""'
|
||||
expected: '"" == ""'
|
||||
|
||||
- name: Dotted Names - Broken Chain Resolution
|
||||
desc: Each part of a dotted name should resolve only against its parent.
|
||||
data:
|
||||
a: { b: { } }
|
||||
c: { name: 'Jim' }
|
||||
template: '"{{a.b.c.name}}" == ""'
|
||||
expected: '"" == ""'
|
||||
|
||||
- name: Dotted Names - Initial Resolution
|
||||
desc: The first part of a dotted name should resolve as any other name.
|
||||
data:
|
||||
a: { b: { c: { d: { e: { name: 'Phil' } } } } }
|
||||
b: { c: { d: { e: { name: 'Wrong' } } } }
|
||||
template: '"{{#a}}{{b.c.d.e.name}}{{/a}}" == "Phil"'
|
||||
expected: '"Phil" == "Phil"'
|
||||
|
||||
- name: Dotted Names - Context Precedence
|
||||
desc: Dotted names should be resolved against former resolutions.
|
||||
data:
|
||||
a: { b: { } }
|
||||
b: { c: 'ERROR' }
|
||||
template: '{{#a}}{{b.c}}{{/a}}'
|
||||
expected: ''
|
||||
|
||||
- name: Dotted Names are never single keys
|
||||
desc: Dotted names shall not be parsed as single, atomic keys
|
||||
data:
|
||||
a.b: c
|
||||
template: '{{a.b}}'
|
||||
expected: ''
|
||||
|
||||
- name: Dotted Names - No Masking
|
||||
desc: Dotted Names in a given context are unvavailable due to dot splitting
|
||||
data:
|
||||
a.b: c
|
||||
a: { b: d }
|
||||
template: '{{a.b}}'
|
||||
expected: 'd'
|
||||
|
||||
# Implicit Iterators
|
||||
|
||||
- name: Implicit Iterators - Basic Interpolation
|
||||
desc: Unadorned tags should interpolate content into the template.
|
||||
data: "world"
|
||||
template: |
|
||||
Hello, {{.}}!
|
||||
expected: |
|
||||
Hello, world!
|
||||
|
||||
- name: Implicit Iterators - HTML Escaping
|
||||
desc: Basic interpolation should be HTML escaped.
|
||||
data: '& " < >'
|
||||
template: |
|
||||
These characters should be HTML escaped: {{.}}
|
||||
expected: |
|
||||
These characters should be HTML escaped: & " < >
|
||||
|
||||
- name: Implicit Iterators - Triple Mustache
|
||||
desc: Triple mustaches should interpolate without HTML escaping.
|
||||
data: '& " < >'
|
||||
template: |
|
||||
These characters should not be HTML escaped: {{{.}}}
|
||||
expected: |
|
||||
These characters should not be HTML escaped: & " < >
|
||||
|
||||
- name: Implicit Iterators - Ampersand
|
||||
desc: Ampersand should interpolate without HTML escaping.
|
||||
data: '& " < >'
|
||||
template: |
|
||||
These characters should not be HTML escaped: {{&.}}
|
||||
expected: |
|
||||
These characters should not be HTML escaped: & " < >
|
||||
|
||||
- name: Implicit Iterators - Basic Integer Interpolation
|
||||
desc: Integers should interpolate seamlessly.
|
||||
data: 85
|
||||
template: '"{{.}} miles an hour!"'
|
||||
expected: '"85 miles an hour!"'
|
||||
|
||||
# Whitespace Sensitivity
|
||||
|
||||
- name: Interpolation - Surrounding Whitespace
|
||||
desc: Interpolation should not alter surrounding whitespace.
|
||||
data: { string: '---' }
|
||||
template: '| {{string}} |'
|
||||
expected: '| --- |'
|
||||
|
||||
- name: Triple Mustache - Surrounding Whitespace
|
||||
desc: Interpolation should not alter surrounding whitespace.
|
||||
data: { string: '---' }
|
||||
template: '| {{{string}}} |'
|
||||
expected: '| --- |'
|
||||
|
||||
- name: Ampersand - Surrounding Whitespace
|
||||
desc: Interpolation should not alter surrounding whitespace.
|
||||
data: { string: '---' }
|
||||
template: '| {{&string}} |'
|
||||
expected: '| --- |'
|
||||
|
||||
- name: Interpolation - Standalone
|
||||
desc: Standalone interpolation should not alter surrounding whitespace.
|
||||
data: { string: '---' }
|
||||
template: " {{string}}\n"
|
||||
expected: " ---\n"
|
||||
|
||||
- name: Triple Mustache - Standalone
|
||||
desc: Standalone interpolation should not alter surrounding whitespace.
|
||||
data: { string: '---' }
|
||||
template: " {{{string}}}\n"
|
||||
expected: " ---\n"
|
||||
|
||||
- name: Ampersand - Standalone
|
||||
desc: Standalone interpolation should not alter surrounding whitespace.
|
||||
data: { string: '---' }
|
||||
template: " {{&string}}\n"
|
||||
expected: " ---\n"
|
||||
|
||||
# Whitespace Insensitivity
|
||||
|
||||
- name: Interpolation With Padding
|
||||
desc: Superfluous in-tag whitespace should be ignored.
|
||||
data: { string: "---" }
|
||||
template: '|{{ string }}|'
|
||||
expected: '|---|'
|
||||
|
||||
- name: Triple Mustache With Padding
|
||||
desc: Superfluous in-tag whitespace should be ignored.
|
||||
data: { string: "---" }
|
||||
template: '|{{{ string }}}|'
|
||||
expected: '|---|'
|
||||
|
||||
- name: Ampersand With Padding
|
||||
desc: Superfluous in-tag whitespace should be ignored.
|
||||
data: { string: "---" }
|
||||
template: '|{{& string }}|'
|
||||
expected: '|---|'
|
||||
@@ -0,0 +1,227 @@
|
||||
{
|
||||
"__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.",
|
||||
"overview": "Inverted Section tags and End Section tags are used in combination to wrap a\nsection of the template.\n\nThese tags' content MUST be a non-whitespace character sequence NOT\ncontaining the current closing delimiter; each Inverted Section tag MUST be\nfollowed by an End Section tag with the same content within the same\nsection.\n\nThis tag's content names the data to replace the tag. Name resolution is as\nfollows:\n 1) Split the name on periods; the first part is the name to resolve, any\n remaining parts should be retained.\n 2) Walk the context stack from top to bottom, finding the first context\n that is a) a hash containing the name as a key OR b) an object responding\n to a method with the given name.\n 3) If the context is a hash, the data is the value associated with the\n name.\n 4) If the context is an object and the method with the given name has an\n arity of 1, the method SHOULD be called with a String containing the\n unprocessed contents of the sections; the data is the value returned.\n 5) Otherwise, the data is the value returned by calling the method with\n the given name.\n 6) If any name parts were retained in step 1, each should be resolved\n against a context stack containing only the result from the former\n resolution. If any part fails resolution, the result should be considered\n falsey, and should interpolate as the empty string.\nIf the data is not of a list type, it is coerced into a list as follows: if\nthe data is truthy (e.g. `!!data == true`), use a single-element list\ncontaining the data, otherwise use an empty list.\n\nThis section MUST NOT be rendered unless the data list is empty.\n\nInverted Section and End Section tags SHOULD be treated as standalone when\nappropriate.\n",
|
||||
"tests": [
|
||||
{
|
||||
"name": "Falsey",
|
||||
"desc": "Falsey sections should have their contents rendered.",
|
||||
"data": {
|
||||
"boolean": false
|
||||
},
|
||||
"template": "\"{{^boolean}}This should be rendered.{{/boolean}}\"",
|
||||
"expected": "\"This should be rendered.\""
|
||||
},
|
||||
{
|
||||
"name": "Truthy",
|
||||
"desc": "Truthy sections should have their contents omitted.",
|
||||
"data": {
|
||||
"boolean": true
|
||||
},
|
||||
"template": "\"{{^boolean}}This should not be rendered.{{/boolean}}\"",
|
||||
"expected": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "Null is falsey",
|
||||
"desc": "Null is falsey.",
|
||||
"data": {
|
||||
"null": null
|
||||
},
|
||||
"template": "\"{{^null}}This should be rendered.{{/null}}\"",
|
||||
"expected": "\"This should be rendered.\""
|
||||
},
|
||||
{
|
||||
"name": "Context",
|
||||
"desc": "Objects and hashes should behave like truthy values.",
|
||||
"data": {
|
||||
"context": {
|
||||
"name": "Joe"
|
||||
}
|
||||
},
|
||||
"template": "\"{{^context}}Hi {{name}}.{{/context}}\"",
|
||||
"expected": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "List",
|
||||
"desc": "Lists should behave like truthy values.",
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"n": 1
|
||||
},
|
||||
{
|
||||
"n": 2
|
||||
},
|
||||
{
|
||||
"n": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
"template": "\"{{^list}}{{n}}{{/list}}\"",
|
||||
"expected": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "Empty List",
|
||||
"desc": "Empty lists should behave like falsey values.",
|
||||
"data": {
|
||||
"list": [
|
||||
|
||||
]
|
||||
},
|
||||
"template": "\"{{^list}}Yay lists!{{/list}}\"",
|
||||
"expected": "\"Yay lists!\""
|
||||
},
|
||||
{
|
||||
"name": "Doubled",
|
||||
"desc": "Multiple inverted sections per template should be permitted.",
|
||||
"data": {
|
||||
"bool": false,
|
||||
"two": "second"
|
||||
},
|
||||
"template": "{{^bool}}\n* first\n{{/bool}}\n* {{two}}\n{{^bool}}\n* third\n{{/bool}}\n",
|
||||
"expected": "* first\n* second\n* third\n"
|
||||
},
|
||||
{
|
||||
"name": "Nested (Falsey)",
|
||||
"desc": "Nested falsey sections should have their contents rendered.",
|
||||
"data": {
|
||||
"bool": false
|
||||
},
|
||||
"template": "| A {{^bool}}B {{^bool}}C{{/bool}} D{{/bool}} E |",
|
||||
"expected": "| A B C D E |"
|
||||
},
|
||||
{
|
||||
"name": "Nested (Truthy)",
|
||||
"desc": "Nested truthy sections should be omitted.",
|
||||
"data": {
|
||||
"bool": true
|
||||
},
|
||||
"template": "| A {{^bool}}B {{^bool}}C{{/bool}} D{{/bool}} E |",
|
||||
"expected": "| A E |"
|
||||
},
|
||||
{
|
||||
"name": "Context Misses",
|
||||
"desc": "Failed context lookups should be considered falsey.",
|
||||
"data": {
|
||||
},
|
||||
"template": "[{{^missing}}Cannot find key 'missing'!{{/missing}}]",
|
||||
"expected": "[Cannot find key 'missing'!]"
|
||||
},
|
||||
{
|
||||
"name": "Dotted Names - Truthy",
|
||||
"desc": "Dotted names should be valid for Inverted Section tags.",
|
||||
"data": {
|
||||
"a": {
|
||||
"b": {
|
||||
"c": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"template": "\"{{^a.b.c}}Not Here{{/a.b.c}}\" == \"\"",
|
||||
"expected": "\"\" == \"\""
|
||||
},
|
||||
{
|
||||
"name": "Dotted Names - Falsey",
|
||||
"desc": "Dotted names should be valid for Inverted Section tags.",
|
||||
"data": {
|
||||
"a": {
|
||||
"b": {
|
||||
"c": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"template": "\"{{^a.b.c}}Not Here{{/a.b.c}}\" == \"Not Here\"",
|
||||
"expected": "\"Not Here\" == \"Not Here\""
|
||||
},
|
||||
{
|
||||
"name": "Dotted Names - Broken Chains",
|
||||
"desc": "Dotted names that cannot be resolved should be considered falsey.",
|
||||
"data": {
|
||||
"a": {
|
||||
}
|
||||
},
|
||||
"template": "\"{{^a.b.c}}Not Here{{/a.b.c}}\" == \"Not Here\"",
|
||||
"expected": "\"Not Here\" == \"Not Here\""
|
||||
},
|
||||
{
|
||||
"name": "Surrounding Whitespace",
|
||||
"desc": "Inverted sections should not alter surrounding whitespace.",
|
||||
"data": {
|
||||
"boolean": false
|
||||
},
|
||||
"template": " | {{^boolean}}\t|\t{{/boolean}} | \n",
|
||||
"expected": " | \t|\t | \n"
|
||||
},
|
||||
{
|
||||
"name": "Internal Whitespace",
|
||||
"desc": "Inverted should not alter internal whitespace.",
|
||||
"data": {
|
||||
"boolean": false
|
||||
},
|
||||
"template": " | {{^boolean}} {{! Important Whitespace }}\n {{/boolean}} | \n",
|
||||
"expected": " | \n | \n"
|
||||
},
|
||||
{
|
||||
"name": "Indented Inline Sections",
|
||||
"desc": "Single-line sections should not alter surrounding whitespace.",
|
||||
"data": {
|
||||
"boolean": false
|
||||
},
|
||||
"template": " {{^boolean}}NO{{/boolean}}\n {{^boolean}}WAY{{/boolean}}\n",
|
||||
"expected": " NO\n WAY\n"
|
||||
},
|
||||
{
|
||||
"name": "Standalone Lines",
|
||||
"desc": "Standalone lines should be removed from the template.",
|
||||
"data": {
|
||||
"boolean": false
|
||||
},
|
||||
"template": "| This Is\n{{^boolean}}\n|\n{{/boolean}}\n| A Line\n",
|
||||
"expected": "| This Is\n|\n| A Line\n"
|
||||
},
|
||||
{
|
||||
"name": "Standalone Indented Lines",
|
||||
"desc": "Standalone indented lines should be removed from the template.",
|
||||
"data": {
|
||||
"boolean": false
|
||||
},
|
||||
"template": "| This Is\n {{^boolean}}\n|\n {{/boolean}}\n| A Line\n",
|
||||
"expected": "| This Is\n|\n| A Line\n"
|
||||
},
|
||||
{
|
||||
"name": "Standalone Line Endings",
|
||||
"desc": "\"\\r\\n\" should be considered a newline for standalone tags.",
|
||||
"data": {
|
||||
"boolean": false
|
||||
},
|
||||
"template": "|\r\n{{^boolean}}\r\n{{/boolean}}\r\n|",
|
||||
"expected": "|\r\n|"
|
||||
},
|
||||
{
|
||||
"name": "Standalone Without Previous Line",
|
||||
"desc": "Standalone tags should not require a newline to precede them.",
|
||||
"data": {
|
||||
"boolean": false
|
||||
},
|
||||
"template": " {{^boolean}}\n^{{/boolean}}\n/",
|
||||
"expected": "^\n/"
|
||||
},
|
||||
{
|
||||
"name": "Standalone Without Newline",
|
||||
"desc": "Standalone tags should not require a newline to follow them.",
|
||||
"data": {
|
||||
"boolean": false
|
||||
},
|
||||
"template": "^{{^boolean}}\n/\n {{/boolean}}",
|
||||
"expected": "^\n/\n"
|
||||
},
|
||||
{
|
||||
"name": "Padding",
|
||||
"desc": "Superfluous in-tag whitespace should be ignored.",
|
||||
"data": {
|
||||
"boolean": false
|
||||
},
|
||||
"template": "|{{^ boolean }}={{/ boolean }}|",
|
||||
"expected": "|=|"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
overview: |
|
||||
Inverted Section tags and End Section tags are used in combination to wrap a
|
||||
section of the template.
|
||||
|
||||
These tags' content MUST be a non-whitespace character sequence NOT
|
||||
containing the current closing delimiter; each Inverted Section tag MUST be
|
||||
followed by an End Section tag with the same content within the same
|
||||
section.
|
||||
|
||||
This tag's content names the data to replace the tag. Name resolution is as
|
||||
follows:
|
||||
1) Split the name on periods; the first part is the name to resolve, any
|
||||
remaining parts should be retained.
|
||||
2) Walk the context stack from top to bottom, finding the first context
|
||||
that is a) a hash containing the name as a key OR b) an object responding
|
||||
to a method with the given name.
|
||||
3) If the context is a hash, the data is the value associated with the
|
||||
name.
|
||||
4) If the context is an object and the method with the given name has an
|
||||
arity of 1, the method SHOULD be called with a String containing the
|
||||
unprocessed contents of the sections; the data is the value returned.
|
||||
5) Otherwise, the data is the value returned by calling the method with
|
||||
the given name.
|
||||
6) If any name parts were retained in step 1, each should be resolved
|
||||
against a context stack containing only the result from the former
|
||||
resolution. If any part fails resolution, the result should be considered
|
||||
falsey, and should interpolate as the empty string.
|
||||
If the data is not of a list type, it is coerced into a list as follows: if
|
||||
the data is truthy (e.g. `!!data == true`), use a single-element list
|
||||
containing the data, otherwise use an empty list.
|
||||
|
||||
This section MUST NOT be rendered unless the data list is empty.
|
||||
|
||||
Inverted Section and End Section tags SHOULD be treated as standalone when
|
||||
appropriate.
|
||||
tests:
|
||||
- name: Falsey
|
||||
desc: Falsey sections should have their contents rendered.
|
||||
data: { boolean: false }
|
||||
template: '"{{^boolean}}This should be rendered.{{/boolean}}"'
|
||||
expected: '"This should be rendered."'
|
||||
|
||||
- name: Truthy
|
||||
desc: Truthy sections should have their contents omitted.
|
||||
data: { boolean: true }
|
||||
template: '"{{^boolean}}This should not be rendered.{{/boolean}}"'
|
||||
expected: '""'
|
||||
|
||||
- name: Null is falsey
|
||||
desc: Null is falsey.
|
||||
data: { "null": null }
|
||||
template: '"{{^null}}This should be rendered.{{/null}}"'
|
||||
expected: '"This should be rendered."'
|
||||
|
||||
- name: Context
|
||||
desc: Objects and hashes should behave like truthy values.
|
||||
data: { context: { name: 'Joe' } }
|
||||
template: '"{{^context}}Hi {{name}}.{{/context}}"'
|
||||
expected: '""'
|
||||
|
||||
- name: List
|
||||
desc: Lists should behave like truthy values.
|
||||
data: { list: [ { n: 1 }, { n: 2 }, { n: 3 } ] }
|
||||
template: '"{{^list}}{{n}}{{/list}}"'
|
||||
expected: '""'
|
||||
|
||||
- name: Empty List
|
||||
desc: Empty lists should behave like falsey values.
|
||||
data: { list: [ ] }
|
||||
template: '"{{^list}}Yay lists!{{/list}}"'
|
||||
expected: '"Yay lists!"'
|
||||
|
||||
- name: Doubled
|
||||
desc: Multiple inverted sections per template should be permitted.
|
||||
data: { bool: false, two: 'second' }
|
||||
template: |
|
||||
{{^bool}}
|
||||
* first
|
||||
{{/bool}}
|
||||
* {{two}}
|
||||
{{^bool}}
|
||||
* third
|
||||
{{/bool}}
|
||||
expected: |
|
||||
* first
|
||||
* second
|
||||
* third
|
||||
|
||||
- name: Nested (Falsey)
|
||||
desc: Nested falsey sections should have their contents rendered.
|
||||
data: { bool: false }
|
||||
template: "| A {{^bool}}B {{^bool}}C{{/bool}} D{{/bool}} E |"
|
||||
expected: "| A B C D E |"
|
||||
|
||||
- name: Nested (Truthy)
|
||||
desc: Nested truthy sections should be omitted.
|
||||
data: { bool: true }
|
||||
template: "| A {{^bool}}B {{^bool}}C{{/bool}} D{{/bool}} E |"
|
||||
expected: "| A E |"
|
||||
|
||||
- name: Context Misses
|
||||
desc: Failed context lookups should be considered falsey.
|
||||
data: { }
|
||||
template: "[{{^missing}}Cannot find key 'missing'!{{/missing}}]"
|
||||
expected: "[Cannot find key 'missing'!]"
|
||||
|
||||
# Dotted Names
|
||||
|
||||
- name: Dotted Names - Truthy
|
||||
desc: Dotted names should be valid for Inverted Section tags.
|
||||
data: { a: { b: { c: true } } }
|
||||
template: '"{{^a.b.c}}Not Here{{/a.b.c}}" == ""'
|
||||
expected: '"" == ""'
|
||||
|
||||
- name: Dotted Names - Falsey
|
||||
desc: Dotted names should be valid for Inverted Section tags.
|
||||
data: { a: { b: { c: false } } }
|
||||
template: '"{{^a.b.c}}Not Here{{/a.b.c}}" == "Not Here"'
|
||||
expected: '"Not Here" == "Not Here"'
|
||||
|
||||
- name: Dotted Names - Broken Chains
|
||||
desc: Dotted names that cannot be resolved should be considered falsey.
|
||||
data: { a: { } }
|
||||
template: '"{{^a.b.c}}Not Here{{/a.b.c}}" == "Not Here"'
|
||||
expected: '"Not Here" == "Not Here"'
|
||||
|
||||
# Whitespace Sensitivity
|
||||
|
||||
- name: Surrounding Whitespace
|
||||
desc: Inverted sections should not alter surrounding whitespace.
|
||||
data: { boolean: false }
|
||||
template: " | {{^boolean}}\t|\t{{/boolean}} | \n"
|
||||
expected: " | \t|\t | \n"
|
||||
|
||||
- name: Internal Whitespace
|
||||
desc: Inverted should not alter internal whitespace.
|
||||
data: { boolean: false }
|
||||
template: " | {{^boolean}} {{! Important Whitespace }}\n {{/boolean}} | \n"
|
||||
expected: " | \n | \n"
|
||||
|
||||
- name: Indented Inline Sections
|
||||
desc: Single-line sections should not alter surrounding whitespace.
|
||||
data: { boolean: false }
|
||||
template: " {{^boolean}}NO{{/boolean}}\n {{^boolean}}WAY{{/boolean}}\n"
|
||||
expected: " NO\n WAY\n"
|
||||
|
||||
- name: Standalone Lines
|
||||
desc: Standalone lines should be removed from the template.
|
||||
data: { boolean: false }
|
||||
template: |
|
||||
| This Is
|
||||
{{^boolean}}
|
||||
|
|
||||
{{/boolean}}
|
||||
| A Line
|
||||
expected: |
|
||||
| This Is
|
||||
|
|
||||
| A Line
|
||||
|
||||
- name: Standalone Indented Lines
|
||||
desc: Standalone indented lines should be removed from the template.
|
||||
data: { boolean: false }
|
||||
template: |
|
||||
| This Is
|
||||
{{^boolean}}
|
||||
|
|
||||
{{/boolean}}
|
||||
| A Line
|
||||
expected: |
|
||||
| This Is
|
||||
|
|
||||
| A Line
|
||||
|
||||
- name: Standalone Line Endings
|
||||
desc: '"\r\n" should be considered a newline for standalone tags.'
|
||||
data: { boolean: false }
|
||||
template: "|\r\n{{^boolean}}\r\n{{/boolean}}\r\n|"
|
||||
expected: "|\r\n|"
|
||||
|
||||
- name: Standalone Without Previous Line
|
||||
desc: Standalone tags should not require a newline to precede them.
|
||||
data: { boolean: false }
|
||||
template: " {{^boolean}}\n^{{/boolean}}\n/"
|
||||
expected: "^\n/"
|
||||
|
||||
- name: Standalone Without Newline
|
||||
desc: Standalone tags should not require a newline to follow them.
|
||||
data: { boolean: false }
|
||||
template: "^{{^boolean}}\n/\n {{/boolean}}"
|
||||
expected: "^\n/\n"
|
||||
|
||||
# Whitespace Insensitivity
|
||||
|
||||
- name: Padding
|
||||
desc: Superfluous in-tag whitespace should be ignored.
|
||||
data: { boolean: false }
|
||||
template: '|{{^ boolean }}={{/ boolean }}|'
|
||||
expected: '|=|'
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.",
|
||||
"overview": "Partial tags are used to expand an external template into the current\ntemplate.\n\nThe tag's content MUST be a non-whitespace character sequence NOT containing\nthe current closing delimiter.\n\nThis tag's content names the partial to inject. Set Delimiter tags MUST NOT\naffect the parsing of a partial. The partial MUST be rendered against the\ncontext stack local to the tag. If the named partial cannot be found, the\nempty string SHOULD be used instead, as in interpolations.\n\nPartial tags SHOULD be treated as standalone when appropriate. If this tag\nis used standalone, any whitespace preceding the tag should treated as\nindentation, and prepended to each line of the partial before rendering.\n",
|
||||
"tests": [
|
||||
{
|
||||
"name": "Basic Behavior",
|
||||
"desc": "The greater-than operator should expand to the named partial.",
|
||||
"data": {
|
||||
},
|
||||
"template": "\"{{>text}}\"",
|
||||
"partials": {
|
||||
"text": "from partial"
|
||||
},
|
||||
"expected": "\"from partial\""
|
||||
},
|
||||
{
|
||||
"name": "Failed Lookup",
|
||||
"desc": "The empty string should be used when the named partial is not found.",
|
||||
"data": {
|
||||
},
|
||||
"template": "\"{{>text}}\"",
|
||||
"partials": {
|
||||
},
|
||||
"expected": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "Context",
|
||||
"desc": "The greater-than operator should operate within the current context.",
|
||||
"data": {
|
||||
"text": "content"
|
||||
},
|
||||
"template": "\"{{>partial}}\"",
|
||||
"partials": {
|
||||
"partial": "*{{text}}*"
|
||||
},
|
||||
"expected": "\"*content*\""
|
||||
},
|
||||
{
|
||||
"name": "Recursion",
|
||||
"desc": "The greater-than operator should properly recurse.",
|
||||
"data": {
|
||||
"content": "X",
|
||||
"nodes": [
|
||||
{
|
||||
"content": "Y",
|
||||
"nodes": [
|
||||
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"template": "{{>node}}",
|
||||
"partials": {
|
||||
"node": "{{content}}<{{#nodes}}{{>node}}{{/nodes}}>"
|
||||
},
|
||||
"expected": "X<Y<>>"
|
||||
},
|
||||
{
|
||||
"name": "Nested",
|
||||
"desc": "The greater-than operator should work from within partials.",
|
||||
"data": {
|
||||
"a": "hello",
|
||||
"b": "world"
|
||||
},
|
||||
"template": "{{>outer}}",
|
||||
"partials": {
|
||||
"outer": "*{{a}} {{>inner}}*",
|
||||
"inner": "{{b}}!"
|
||||
},
|
||||
"expected": "*hello world!*"
|
||||
},
|
||||
{
|
||||
"name": "Surrounding Whitespace",
|
||||
"desc": "The greater-than operator should not alter surrounding whitespace.",
|
||||
"data": {
|
||||
},
|
||||
"template": "| {{>partial}} |",
|
||||
"partials": {
|
||||
"partial": "\t|\t"
|
||||
},
|
||||
"expected": "| \t|\t |"
|
||||
},
|
||||
{
|
||||
"name": "Inline Indentation",
|
||||
"desc": "Whitespace should be left untouched.",
|
||||
"data": {
|
||||
"data": "|"
|
||||
},
|
||||
"template": " {{data}} {{> partial}}\n",
|
||||
"partials": {
|
||||
"partial": ">\n>"
|
||||
},
|
||||
"expected": " | >\n>\n"
|
||||
},
|
||||
{
|
||||
"name": "Standalone Line Endings",
|
||||
"desc": "\"\\r\\n\" should be considered a newline for standalone tags.",
|
||||
"data": {
|
||||
},
|
||||
"template": "|\r\n{{>partial}}\r\n|",
|
||||
"partials": {
|
||||
"partial": ">"
|
||||
},
|
||||
"expected": "|\r\n>|"
|
||||
},
|
||||
{
|
||||
"name": "Standalone Without Previous Line",
|
||||
"desc": "Standalone tags should not require a newline to precede them.",
|
||||
"data": {
|
||||
},
|
||||
"template": " {{>partial}}\n>",
|
||||
"partials": {
|
||||
"partial": ">\n>"
|
||||
},
|
||||
"expected": " >\n >>"
|
||||
},
|
||||
{
|
||||
"name": "Standalone Without Newline",
|
||||
"desc": "Standalone tags should not require a newline to follow them.",
|
||||
"data": {
|
||||
},
|
||||
"template": ">\n {{>partial}}",
|
||||
"partials": {
|
||||
"partial": ">\n>"
|
||||
},
|
||||
"expected": ">\n >\n >"
|
||||
},
|
||||
{
|
||||
"name": "Standalone Indentation",
|
||||
"desc": "Each line of the partial should be indented before rendering.",
|
||||
"data": {
|
||||
"content": "<\n->"
|
||||
},
|
||||
"template": "\\\n {{>partial}}\n/\n",
|
||||
"partials": {
|
||||
"partial": "|\n{{{content}}}\n|\n"
|
||||
},
|
||||
"expected": "\\\n |\n <\n->\n |\n/\n"
|
||||
},
|
||||
{
|
||||
"name": "Padding Whitespace",
|
||||
"desc": "Superfluous in-tag whitespace should be ignored.",
|
||||
"data": {
|
||||
"boolean": true
|
||||
},
|
||||
"template": "|{{> partial }}|",
|
||||
"partials": {
|
||||
"partial": "[]"
|
||||
},
|
||||
"expected": "|[]|"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
overview: |
|
||||
Partial tags are used to expand an external template into the current
|
||||
template.
|
||||
|
||||
The tag's content MUST be a non-whitespace character sequence NOT containing
|
||||
the current closing delimiter.
|
||||
|
||||
This tag's content names the partial to inject. Set Delimiter tags MUST NOT
|
||||
affect the parsing of a partial. The partial MUST be rendered against the
|
||||
context stack local to the tag. If the named partial cannot be found, the
|
||||
empty string SHOULD be used instead, as in interpolations.
|
||||
|
||||
Partial tags SHOULD be treated as standalone when appropriate. If this tag
|
||||
is used standalone, any whitespace preceding the tag should treated as
|
||||
indentation, and prepended to each line of the partial before rendering.
|
||||
tests:
|
||||
- name: Basic Behavior
|
||||
desc: The greater-than operator should expand to the named partial.
|
||||
data: { }
|
||||
template: '"{{>text}}"'
|
||||
partials: { text: 'from partial' }
|
||||
expected: '"from partial"'
|
||||
|
||||
- name: Failed Lookup
|
||||
desc: The empty string should be used when the named partial is not found.
|
||||
data: { }
|
||||
template: '"{{>text}}"'
|
||||
partials: { }
|
||||
expected: '""'
|
||||
|
||||
- name: Context
|
||||
desc: The greater-than operator should operate within the current context.
|
||||
data: { text: 'content' }
|
||||
template: '"{{>partial}}"'
|
||||
partials: { partial: '*{{text}}*' }
|
||||
expected: '"*content*"'
|
||||
|
||||
- name: Recursion
|
||||
desc: The greater-than operator should properly recurse.
|
||||
data: { content: "X", nodes: [ { content: "Y", nodes: [] } ] }
|
||||
template: '{{>node}}'
|
||||
partials: { node: '{{content}}<{{#nodes}}{{>node}}{{/nodes}}>' }
|
||||
expected: 'X<Y<>>'
|
||||
|
||||
- name: Nested
|
||||
desc: The greater-than operator should work from within partials.
|
||||
data: { a: "hello", b: "world" }
|
||||
template: '{{>outer}}'
|
||||
partials: { outer: '*{{a}} {{>inner}}*', inner: '{{b}}!' }
|
||||
expected: '*hello world!*'
|
||||
|
||||
# Whitespace Sensitivity
|
||||
|
||||
- name: Surrounding Whitespace
|
||||
desc: The greater-than operator should not alter surrounding whitespace.
|
||||
data: { }
|
||||
template: '| {{>partial}} |'
|
||||
partials: { partial: "\t|\t" }
|
||||
expected: "| \t|\t |"
|
||||
|
||||
- name: Inline Indentation
|
||||
desc: Whitespace should be left untouched.
|
||||
data: { data: '|' }
|
||||
template: " {{data}} {{> partial}}\n"
|
||||
partials: { partial: ">\n>" }
|
||||
expected: " | >\n>\n"
|
||||
|
||||
- name: Standalone Line Endings
|
||||
desc: '"\r\n" should be considered a newline for standalone tags.'
|
||||
data: { }
|
||||
template: "|\r\n{{>partial}}\r\n|"
|
||||
partials: { partial: ">" }
|
||||
expected: "|\r\n>|"
|
||||
|
||||
- name: Standalone Without Previous Line
|
||||
desc: Standalone tags should not require a newline to precede them.
|
||||
data: { }
|
||||
template: " {{>partial}}\n>"
|
||||
partials: { partial: ">\n>"}
|
||||
expected: " >\n >>"
|
||||
|
||||
- name: Standalone Without Newline
|
||||
desc: Standalone tags should not require a newline to follow them.
|
||||
data: { }
|
||||
template: ">\n {{>partial}}"
|
||||
partials: { partial: ">\n>" }
|
||||
expected: ">\n >\n >"
|
||||
|
||||
- name: Standalone Indentation
|
||||
desc: Each line of the partial should be indented before rendering.
|
||||
data: { content: "<\n->" }
|
||||
template: |
|
||||
\
|
||||
{{>partial}}
|
||||
/
|
||||
partials:
|
||||
partial: |
|
||||
|
|
||||
{{{content}}}
|
||||
|
|
||||
expected: |
|
||||
\
|
||||
|
|
||||
<
|
||||
->
|
||||
|
|
||||
/
|
||||
|
||||
# Whitespace Insensitivity
|
||||
|
||||
- name: Padding Whitespace
|
||||
desc: Superfluous in-tag whitespace should be ignored.
|
||||
data: { boolean: true }
|
||||
template: "|{{> partial }}|"
|
||||
partials: { partial: "[]" }
|
||||
expected: '|[]|'
|
||||
@@ -0,0 +1,423 @@
|
||||
{
|
||||
"__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.",
|
||||
"overview": "Section tags and End Section tags are used in combination to wrap a section\nof the template for iteration.\n\nThese tags' content MUST be a non-whitespace character sequence NOT\ncontaining the current closing delimiter; each Section tag MUST be followed\nby an End Section tag with the same content within the same section.\n\nThis tag's content names the data to replace the tag. Name resolution is as\nfollows:\n 1) If the name is a single period (.), the data is the item currently\n sitting atop the context stack. Skip the rest of these steps.\n 2) Split the name on periods; the first part is the name to resolve, any\n remaining parts should be retained.\n 3) Walk the context stack from top to bottom, finding the first context\n that is a) a hash containing the name as a key OR b) an object responding\n to a method with the given name.\n 4) If the context is a hash, the data is the value associated with the\n name.\n 5) If the context is an object and the method with the given name has an\n arity of 1, the method SHOULD be called with a String containing the\n unprocessed contents of the sections; the data is the value returned.\n 6) Otherwise, the data is the value returned by calling the method with\n the given name.\n 7) If any name parts were retained in step 1, each should be resolved\n against a context stack containing only the result from the former\n resolution. If any part fails resolution, the result should be considered\n falsey, and should interpolate as the empty string.\n\nIf the data is not of a list type, it is coerced into a list as follows: if\nthe data is truthy (e.g. `!!data == true`), use a single-element list\ncontaining the data, otherwise use an empty list.\n\nFor each element in the data list, the element MUST be pushed onto the\ncontext stack, the section MUST be rendered, and the element MUST be popped\noff the context stack.\n\nSection and End Section tags SHOULD be treated as standalone when\nappropriate.\n",
|
||||
"tests": [
|
||||
{
|
||||
"name": "Truthy",
|
||||
"desc": "Truthy sections should have their contents rendered.",
|
||||
"data": {
|
||||
"boolean": true
|
||||
},
|
||||
"template": "\"{{#boolean}}This should be rendered.{{/boolean}}\"",
|
||||
"expected": "\"This should be rendered.\""
|
||||
},
|
||||
{
|
||||
"name": "Falsey",
|
||||
"desc": "Falsey sections should have their contents omitted.",
|
||||
"data": {
|
||||
"boolean": false
|
||||
},
|
||||
"template": "\"{{#boolean}}This should not be rendered.{{/boolean}}\"",
|
||||
"expected": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "Null is falsey",
|
||||
"desc": "Null is falsey.",
|
||||
"data": {
|
||||
"null": null
|
||||
},
|
||||
"template": "\"{{#null}}This should not be rendered.{{/null}}\"",
|
||||
"expected": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "Context",
|
||||
"desc": "Objects and hashes should be pushed onto the context stack.",
|
||||
"data": {
|
||||
"context": {
|
||||
"name": "Joe"
|
||||
}
|
||||
},
|
||||
"template": "\"{{#context}}Hi {{name}}.{{/context}}\"",
|
||||
"expected": "\"Hi Joe.\""
|
||||
},
|
||||
{
|
||||
"name": "Parent contexts",
|
||||
"desc": "Names missing in the current context are looked up in the stack.",
|
||||
"data": {
|
||||
"a": "foo",
|
||||
"b": "wrong",
|
||||
"sec": {
|
||||
"b": "bar"
|
||||
},
|
||||
"c": {
|
||||
"d": "baz"
|
||||
}
|
||||
},
|
||||
"template": "\"{{#sec}}{{a}}, {{b}}, {{c.d}}{{/sec}}\"",
|
||||
"expected": "\"foo, bar, baz\""
|
||||
},
|
||||
{
|
||||
"name": "Variable test",
|
||||
"desc": "Non-false sections have their value at the top of context,\naccessible as {{.}} or through the parent context. This gives\na simple way to display content conditionally if a variable exists.\n",
|
||||
"data": {
|
||||
"foo": "bar"
|
||||
},
|
||||
"template": "\"{{#foo}}{{.}} is {{foo}}{{/foo}}\"",
|
||||
"expected": "\"bar is bar\""
|
||||
},
|
||||
{
|
||||
"name": "List Contexts",
|
||||
"desc": "All elements on the context stack should be accessible within lists.",
|
||||
"data": {
|
||||
"tops": [
|
||||
{
|
||||
"tname": {
|
||||
"upper": "A",
|
||||
"lower": "a"
|
||||
},
|
||||
"middles": [
|
||||
{
|
||||
"mname": "1",
|
||||
"bottoms": [
|
||||
{
|
||||
"bname": "x"
|
||||
},
|
||||
{
|
||||
"bname": "y"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"template": "{{#tops}}{{#middles}}{{tname.lower}}{{mname}}.{{#bottoms}}{{tname.upper}}{{mname}}{{bname}}.{{/bottoms}}{{/middles}}{{/tops}}",
|
||||
"expected": "a1.A1x.A1y."
|
||||
},
|
||||
{
|
||||
"name": "Deeply Nested Contexts",
|
||||
"desc": "All elements on the context stack should be accessible.",
|
||||
"data": {
|
||||
"a": {
|
||||
"one": 1
|
||||
},
|
||||
"b": {
|
||||
"two": 2
|
||||
},
|
||||
"c": {
|
||||
"three": 3,
|
||||
"d": {
|
||||
"four": 4,
|
||||
"five": 5
|
||||
}
|
||||
}
|
||||
},
|
||||
"template": "{{#a}}\n{{one}}\n{{#b}}\n{{one}}{{two}}{{one}}\n{{#c}}\n{{one}}{{two}}{{three}}{{two}}{{one}}\n{{#d}}\n{{one}}{{two}}{{three}}{{four}}{{three}}{{two}}{{one}}\n{{#five}}\n{{one}}{{two}}{{three}}{{four}}{{five}}{{four}}{{three}}{{two}}{{one}}\n{{one}}{{two}}{{three}}{{four}}{{.}}6{{.}}{{four}}{{three}}{{two}}{{one}}\n{{one}}{{two}}{{three}}{{four}}{{five}}{{four}}{{three}}{{two}}{{one}}\n{{/five}}\n{{one}}{{two}}{{three}}{{four}}{{three}}{{two}}{{one}}\n{{/d}}\n{{one}}{{two}}{{three}}{{two}}{{one}}\n{{/c}}\n{{one}}{{two}}{{one}}\n{{/b}}\n{{one}}\n{{/a}}\n",
|
||||
"expected": "1\n121\n12321\n1234321\n123454321\n12345654321\n123454321\n1234321\n12321\n121\n1\n"
|
||||
},
|
||||
{
|
||||
"name": "List",
|
||||
"desc": "Lists should be iterated; list items should visit the context stack.",
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"item": 1
|
||||
},
|
||||
{
|
||||
"item": 2
|
||||
},
|
||||
{
|
||||
"item": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
"template": "\"{{#list}}{{item}}{{/list}}\"",
|
||||
"expected": "\"123\""
|
||||
},
|
||||
{
|
||||
"name": "Empty List",
|
||||
"desc": "Empty lists should behave like falsey values.",
|
||||
"data": {
|
||||
"list": [
|
||||
|
||||
]
|
||||
},
|
||||
"template": "\"{{#list}}Yay lists!{{/list}}\"",
|
||||
"expected": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "Doubled",
|
||||
"desc": "Multiple sections per template should be permitted.",
|
||||
"data": {
|
||||
"bool": true,
|
||||
"two": "second"
|
||||
},
|
||||
"template": "{{#bool}}\n* first\n{{/bool}}\n* {{two}}\n{{#bool}}\n* third\n{{/bool}}\n",
|
||||
"expected": "* first\n* second\n* third\n"
|
||||
},
|
||||
{
|
||||
"name": "Nested (Truthy)",
|
||||
"desc": "Nested truthy sections should have their contents rendered.",
|
||||
"data": {
|
||||
"bool": true
|
||||
},
|
||||
"template": "| A {{#bool}}B {{#bool}}C{{/bool}} D{{/bool}} E |",
|
||||
"expected": "| A B C D E |"
|
||||
},
|
||||
{
|
||||
"name": "Nested (Falsey)",
|
||||
"desc": "Nested falsey sections should be omitted.",
|
||||
"data": {
|
||||
"bool": false
|
||||
},
|
||||
"template": "| A {{#bool}}B {{#bool}}C{{/bool}} D{{/bool}} E |",
|
||||
"expected": "| A E |"
|
||||
},
|
||||
{
|
||||
"name": "Context Misses",
|
||||
"desc": "Failed context lookups should be considered falsey.",
|
||||
"data": {
|
||||
},
|
||||
"template": "[{{#missing}}Found key 'missing'!{{/missing}}]",
|
||||
"expected": "[]"
|
||||
},
|
||||
{
|
||||
"name": "Implicit Iterator - String",
|
||||
"desc": "Implicit iterators should directly interpolate strings.",
|
||||
"data": {
|
||||
"list": [
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d",
|
||||
"e"
|
||||
]
|
||||
},
|
||||
"template": "\"{{#list}}({{.}}){{/list}}\"",
|
||||
"expected": "\"(a)(b)(c)(d)(e)\""
|
||||
},
|
||||
{
|
||||
"name": "Implicit Iterator - Integer",
|
||||
"desc": "Implicit iterators should cast integers to strings and interpolate.",
|
||||
"data": {
|
||||
"list": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5
|
||||
]
|
||||
},
|
||||
"template": "\"{{#list}}({{.}}){{/list}}\"",
|
||||
"expected": "\"(1)(2)(3)(4)(5)\""
|
||||
},
|
||||
{
|
||||
"name": "Implicit Iterator - Decimal",
|
||||
"desc": "Implicit iterators should cast decimals to strings and interpolate.",
|
||||
"data": {
|
||||
"list": [
|
||||
1.1,
|
||||
2.2,
|
||||
3.3,
|
||||
4.4,
|
||||
5.5
|
||||
]
|
||||
},
|
||||
"template": "\"{{#list}}({{.}}){{/list}}\"",
|
||||
"expected": "\"(1.1)(2.2)(3.3)(4.4)(5.5)\""
|
||||
},
|
||||
{
|
||||
"name": "Implicit Iterator - Array",
|
||||
"desc": "Implicit iterators should allow iterating over nested arrays.",
|
||||
"data": {
|
||||
"list": [
|
||||
[
|
||||
1,
|
||||
2,
|
||||
3
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"b",
|
||||
"c"
|
||||
]
|
||||
]
|
||||
},
|
||||
"template": "\"{{#list}}({{#.}}{{.}}{{/.}}){{/list}}\"",
|
||||
"expected": "\"(123)(abc)\""
|
||||
},
|
||||
{
|
||||
"name": "Implicit Iterator - HTML Escaping",
|
||||
"desc": "Implicit iterators with basic interpolation should be HTML escaped.",
|
||||
"data": {
|
||||
"list": [
|
||||
"&",
|
||||
"\"",
|
||||
"<",
|
||||
">"
|
||||
]
|
||||
},
|
||||
"template": "\"{{#list}}({{.}}){{/list}}\"",
|
||||
"expected": "\"(&)(")(<)(>)\""
|
||||
},
|
||||
{
|
||||
"name": "Implicit Iterator - Triple mustache",
|
||||
"desc": "Implicit iterators in triple mustache should interpolate without HTML escaping.",
|
||||
"data": {
|
||||
"list": [
|
||||
"&",
|
||||
"\"",
|
||||
"<",
|
||||
">"
|
||||
]
|
||||
},
|
||||
"template": "\"{{#list}}({{{.}}}){{/list}}\"",
|
||||
"expected": "\"(&)(\")(<)(>)\""
|
||||
},
|
||||
{
|
||||
"name": "Implicit Iterator - Ampersand",
|
||||
"desc": "Implicit iterators in an Ampersand tag should interpolate without HTML escaping.",
|
||||
"data": {
|
||||
"list": [
|
||||
"&",
|
||||
"\"",
|
||||
"<",
|
||||
">"
|
||||
]
|
||||
},
|
||||
"template": "\"{{#list}}({{&.}}){{/list}}\"",
|
||||
"expected": "\"(&)(\")(<)(>)\""
|
||||
},
|
||||
{
|
||||
"name": "Implicit Iterator - Root-level",
|
||||
"desc": "Implicit iterators should work on root-level lists.",
|
||||
"data": [
|
||||
{
|
||||
"value": "a"
|
||||
},
|
||||
{
|
||||
"value": "b"
|
||||
}
|
||||
],
|
||||
"template": "\"{{#.}}({{value}}){{/.}}\"",
|
||||
"expected": "\"(a)(b)\""
|
||||
},
|
||||
{
|
||||
"name": "Dotted Names - Truthy",
|
||||
"desc": "Dotted names should be valid for Section tags.",
|
||||
"data": {
|
||||
"a": {
|
||||
"b": {
|
||||
"c": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"template": "\"{{#a.b.c}}Here{{/a.b.c}}\" == \"Here\"",
|
||||
"expected": "\"Here\" == \"Here\""
|
||||
},
|
||||
{
|
||||
"name": "Dotted Names - Falsey",
|
||||
"desc": "Dotted names should be valid for Section tags.",
|
||||
"data": {
|
||||
"a": {
|
||||
"b": {
|
||||
"c": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"template": "\"{{#a.b.c}}Here{{/a.b.c}}\" == \"\"",
|
||||
"expected": "\"\" == \"\""
|
||||
},
|
||||
{
|
||||
"name": "Dotted Names - Broken Chains",
|
||||
"desc": "Dotted names that cannot be resolved should be considered falsey.",
|
||||
"data": {
|
||||
"a": {
|
||||
}
|
||||
},
|
||||
"template": "\"{{#a.b.c}}Here{{/a.b.c}}\" == \"\"",
|
||||
"expected": "\"\" == \"\""
|
||||
},
|
||||
{
|
||||
"name": "Surrounding Whitespace",
|
||||
"desc": "Sections should not alter surrounding whitespace.",
|
||||
"data": {
|
||||
"boolean": true
|
||||
},
|
||||
"template": " | {{#boolean}}\t|\t{{/boolean}} | \n",
|
||||
"expected": " | \t|\t | \n"
|
||||
},
|
||||
{
|
||||
"name": "Internal Whitespace",
|
||||
"desc": "Sections should not alter internal whitespace.",
|
||||
"data": {
|
||||
"boolean": true
|
||||
},
|
||||
"template": " | {{#boolean}} {{! Important Whitespace }}\n {{/boolean}} | \n",
|
||||
"expected": " | \n | \n"
|
||||
},
|
||||
{
|
||||
"name": "Indented Inline Sections",
|
||||
"desc": "Single-line sections should not alter surrounding whitespace.",
|
||||
"data": {
|
||||
"boolean": true
|
||||
},
|
||||
"template": " {{#boolean}}YES{{/boolean}}\n {{#boolean}}GOOD{{/boolean}}\n",
|
||||
"expected": " YES\n GOOD\n"
|
||||
},
|
||||
{
|
||||
"name": "Standalone Lines",
|
||||
"desc": "Standalone lines should be removed from the template.",
|
||||
"data": {
|
||||
"boolean": true
|
||||
},
|
||||
"template": "| This Is\n{{#boolean}}\n|\n{{/boolean}}\n| A Line\n",
|
||||
"expected": "| This Is\n|\n| A Line\n"
|
||||
},
|
||||
{
|
||||
"name": "Indented Standalone Lines",
|
||||
"desc": "Indented standalone lines should be removed from the template.",
|
||||
"data": {
|
||||
"boolean": true
|
||||
},
|
||||
"template": "| This Is\n {{#boolean}}\n|\n {{/boolean}}\n| A Line\n",
|
||||
"expected": "| This Is\n|\n| A Line\n"
|
||||
},
|
||||
{
|
||||
"name": "Standalone Line Endings",
|
||||
"desc": "\"\\r\\n\" should be considered a newline for standalone tags.",
|
||||
"data": {
|
||||
"boolean": true
|
||||
},
|
||||
"template": "|\r\n{{#boolean}}\r\n{{/boolean}}\r\n|",
|
||||
"expected": "|\r\n|"
|
||||
},
|
||||
{
|
||||
"name": "Standalone Without Previous Line",
|
||||
"desc": "Standalone tags should not require a newline to precede them.",
|
||||
"data": {
|
||||
"boolean": true
|
||||
},
|
||||
"template": " {{#boolean}}\n#{{/boolean}}\n/",
|
||||
"expected": "#\n/"
|
||||
},
|
||||
{
|
||||
"name": "Standalone Without Newline",
|
||||
"desc": "Standalone tags should not require a newline to follow them.",
|
||||
"data": {
|
||||
"boolean": true
|
||||
},
|
||||
"template": "#{{#boolean}}\n/\n {{/boolean}}",
|
||||
"expected": "#\n/\n"
|
||||
},
|
||||
{
|
||||
"name": "Padding",
|
||||
"desc": "Superfluous in-tag whitespace should be ignored.",
|
||||
"data": {
|
||||
"boolean": true
|
||||
},
|
||||
"template": "|{{# boolean }}={{/ boolean }}|",
|
||||
"expected": "|=|"
|
||||
}
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user