Compare commits

..

128 Commits

Author SHA1 Message Date
Spencer Brower 5088f31a58 feat: Added a default stylesheet for extensions. 2026-08-04 18:18:08 -04:00
Spencer Brower 380c324623 perf: Replaced fmt.aprintf/fmt.tprintf calls with fmt.sbprintf. 2026-08-04 15:39:51 -04:00
Spencer Brower 695548bada feat: Added 'footnotes' markdown extension. 2026-08-04 15:30:59 -04:00
Spencer Brower fde54c1f94 refactor: Simplified extension parsers. 2026-08-04 14:24:26 -04:00
Spencer Brower 876f767548 feat: Added deflist markdown extension. 2026-08-04 13:33:32 -04:00
Spencer Brower 0e8d770ac8 chore: Updated AGENTS.md 2026-08-04 12:32:35 -04:00
Spencer Brower cc33447ace feat: Added 'Table of Contents' markdown extension. 2026-08-04 12:12:35 -04:00
Spencer Brower 2ac1d767cf feat: Added {{> scripts}} partial that reads params.scripts. 2026-08-04 11:26:56 -04:00
Spencer Brower 2af8c68339 feat: Added a default {{> styles }} partial. 2026-08-03 18:10:10 -04:00
Spencer Brower b1e40c480b fix(defaults): Removed reference to missing {{> head }} template. 2026-08-03 18:04:43 -04:00
Spencer Brower 8c1f7647a1 refactor: The <title> tag is now configured through a default partial. 2026-08-03 17:29:59 -04:00
Spencer Brower e7bb5126cb refactor: flags are now parsed before creating the consoe_logger. 2026-08-03 16:57:27 -04:00
Spencer Brower f3ea6ab2a9 feat: Added -verbose and -quiet flags to set the log level. 2026-08-03 16:45:17 -04:00
Spencer Brower ed842e4bea chore: Added allocator size logging. 2026-08-03 16:31:48 -04:00
Spencer Brower 8d67108984 docs: Added TODO. 2026-08-03 15:01:39 -04:00
Spencer Brower 58e6da6a2c fix: Default home link now reads "Home" when site title is not set. 2026-08-03 14:43:39 -04:00
Spencer Brower 3ad59c0761 chore: Updated AGENTS.md. 2026-08-03 14:43:39 -04:00
Spencer Brower 6a4debe35a perf(mustache): Removed lambda support.
It really wasn't benefitting us in any way.
2026-08-03 14:43:38 -04:00
Spencer Brower b3c2d4745d docs: Updated docs.md. 2026-08-03 14:43:38 -04:00
Spencer Brower 8404227c22 chore: Minor fixes. 2026-08-03 14:43:38 -04:00
Spencer Brower 55a0d25173 docs: Added docs site. 2026-08-03 14:43:38 -04:00
Spencer Brower 604a80ca42 chore: updated TODOS. 2026-08-03 14:43:38 -04:00
Spencer Brower 806c153979 perf: Fixed leaks in commonmark code. 2026-08-03 14:43:38 -04:00
Spencer Brower 4c41d85385 chore: Cleaned up completed TODOS. 2026-08-03 14:43:38 -04:00
Spencer Brower c92d5e5611 chore: Added diagnostic checks for common template mistakes. 2026-08-03 14:43:37 -04:00
Spencer Brower afb98c3bc4 fix: JSON5 is now supported in frontmatter. 2026-08-03 14:43:37 -04:00
Spencer Brower 7eb116c731 test: Fixed leaks in tests. 2026-08-03 14:43:37 -04:00
Spencer Brower 616880b3da fix: No longer logs warnings for missing params.* keys. (except spellcheck). 2026-08-03 14:43:37 -04:00
Spencer Brower e4f5c63346 feat: Made starred no longer a priviledged value. 2026-08-03 14:43:37 -04:00
Spencer Brower dd77289f5b feat: Errors are now tracked, so duplicate errors don't report more than once. 2026-08-03 14:43:37 -04:00
Spencer Brower dd76a8e83c chore: Updated TODOS.md. 2026-08-03 14:43:37 -04:00
Spencer Brower 10be1b92c4 feat: help for misspelled pipes is now shown to the user. 2026-08-03 14:43:36 -04:00
Spencer Brower 49d08c3129 refactor: Filters are now read as an enum. 2026-08-03 14:43:36 -04:00
Spencer Brower 6eebaa0a6c chore: Removed old plan file. 2026-08-03 14:43:36 -04:00
Spencer Brower e1b2c9d321 fix: pipe Typos diagnostic now highlights only the broken pipe name. 2026-08-03 14:43:36 -04:00
Spencer Brower 86c5428f77 fix: typos in pipe arguments (i.e. format) now show a proper diagnostic. 2026-08-03 14:43:36 -04:00
Spencer Brower 4ed581373b chore: Added diagnostics plan. 2026-08-03 14:43:36 -04:00
Spencer Brower 5e2bee4897 fix: Fixed a bug with the weight checking. 2026-08-03 14:43:36 -04:00
Spencer Brower 54d3bed630 build(nix): Updated prettier config. 2026-08-03 14:43:35 -04:00
Spencer Brower 073695b7c7 chore: Removed completed todos. 2026-08-03 14:43:35 -04:00
Spencer Brower 4d2fdf284d style: Cleaned up #partial switches. 2026-08-03 14:43:35 -04:00
Spencer Brower 25551a4fb6 feat: Added duplicate weights warning. 2026-08-03 14:43:35 -04:00
Spencer Brower b06cf6f8b1 refactor: weights are now wrapped in a Maybe. 2026-08-03 14:43:35 -04:00
Spencer Brower a420803b3e feat: Added weight field to Page. 2026-08-03 14:43:35 -04:00
Spencer Brower 040c462bba feat: Menu items get sorted by weight. 2026-08-03 14:43:35 -04:00
Spencer Brower fa8179c9b9 refactor: Page.menu: string is now `Page.menus: map[string]Menu_Entry 2026-08-03 14:43:34 -04:00
Spencer Brower 8416411c62 nix: Fixed broken package build. 2026-08-03 14:43:34 -04:00
Spencer Brower f54da58698 chore: Added PROBLEMS.md 2026-08-03 14:43:34 -04:00
Spencer Brower f370da6e55 chore: Updated TODOS.md 2026-08-03 14:43:34 -04:00
Spencer Brower ceae8bfa8b style: Ran typos. 2026-08-03 14:43:34 -04:00
Spencer Brower e64cd929e6 style: Ran nixpkgs-fmt. 2026-08-03 14:43:34 -04:00
Spencer Brower de328b3bcb style: ran deadnix. 2026-08-03 14:43:34 -04:00
Spencer Brower 8cb7d1243b style: Ran odinfmt. 2026-08-03 14:43:33 -04:00
Spencer Brower 126548cb29 build(nix): Updated all flake inputs. 2026-08-03 14:43:33 -04:00
Spencer Brower c289c3c7f9 feat: Added menu configuration. 2026-08-03 14:43:33 -04:00
Spencer Brower d1d0af60e7 chore: Updated TODOS.md 2026-08-03 14:43:33 -04:00
Spencer Brower 6d7b88a8d6 feat: Warns the user if they exceed 16 context frames. 2026-08-03 14:43:33 -04:00
Spencer Brower f1c7d02bc1 docs: Updated README.md 2026-08-03 14:43:32 -04:00
Spencer Brower 6ec8fcf2a1 feat: Added site to the template context stack. 2026-08-03 14:43:32 -04:00
Spencer Brower b81aa38c62 feat: page prop now falls through (implicit access). 2026-08-03 14:43:32 -04:00
Spencer Brower 9a35fa2051 fix: Removed content from Template_Context.
now accessible only through page.content.
2026-08-03 14:43:32 -04:00
Spencer Brower 178b02da74 feat: Added page to template context. 2026-08-03 14:43:32 -04:00
Spencer Brower e742832a4b refactor(Page): Renamed body_html to content. 2026-07-28 11:40:28 -04:00
Spencer Brower 01d13ff279 refactor: render_template now accepts only Template_Context objects. 2026-07-28 10:53:32 -04:00
Spencer Brower dea4180031 feat(md): Added "Heading IDs" extension. 2026-07-28 10:33:34 -04:00
Spencer Brower 838c55f73c chore: Removed completed TODOS. 2026-07-25 18:16:40 -04:00
Spencer Brower 98cb51dbc3 perf(site): Stored pages using #soa. 2026-07-25 15:20:47 -04:00
Spencer Brower c913424d41 perf: Parallelized grammar loading. 2026-07-25 15:13:04 -04:00
Spencer Brower 18596bb37d feat: generate_summary and generate_description match hugo better. 2026-07-25 12:53:04 -04:00
Spencer Brower 62d608ef86 perf: Improved performance of highlighter code. 2026-07-25 11:44:00 -04:00
Spencer Brower 71f12e5280 chore: Updated AGENTS.md 2026-07-24 17:08:56 -04:00
Spencer Brower 4f0b5c929d feat: The queries for HTML and CSS are now baked in to the thor binary. 2026-07-24 17:03:36 -04:00
Spencer Brower 1992349520 feat: Users can now choose where to load grammars/queries from. 2026-07-24 16:29:52 -04:00
Spencer Brower 961b3f41b0 chore: Added plan for dynamically loading grammars. 2026-07-24 14:01:29 -04:00
Spencer Brower 91623e8992 chore: Updated TODOS.md 2026-07-24 13:21:13 -04:00
Spencer Brower 033e7f4f79 refactor: $content, and &body renamed to $main and &content. 2026-07-24 13:01:09 -04:00
Spencer Brower 75a7d7a14b refactor: Eliminated global TZ cache
The configured timezone (or local) is now set on Site directly.
2026-07-24 12:47:50 -04:00
Spencer Brower 4f09312daf feat(mustache): apply_format now accepts timezone info. 2026-07-24 11:23:58 -04:00
Spencer Brower 4d847d95d3 feat: Added timezone infrastructure. 2026-07-24 11:03:29 -04:00
spencer 0d1e9ba352 feat(parse_iso_Date): Added timezone parsing. 2026-07-24 10:38:09 -04:00
spencer 213385dd70 feat: Added timezone field to Base_Data. 2026-07-24 10:35:35 -04:00
Spencer Brower 5833b764a4 refactor: Replaced now object with iso string 2026-07-23 16:12:04 -04:00
Spencer Brower 23b0b3fa5f feat: format pipe can now accept custom formats as quoted strings. 2026-07-23 16:12:04 -04:00
Spencer Brower d35da93dcf feat: Added Go style date format configuration. 2026-07-23 16:12:03 -04:00
Spencer Brower 4560014954 feat: Pipes now have access to the full template context. 2026-07-23 11:45:37 -04:00
Spencer Brower b3608fe493 feat: Added date config to site. 2026-07-23 11:45:37 -04:00
Spencer Brower b2ff7038cb fix: Simplified logging. 2026-07-23 11:26:36 -04:00
Spencer Brower c648b600c7 refactor: Site now uses the default opengraph partial. 2026-07-23 11:26:36 -04:00
Spencer Brower 56e434210b feat: Added build time to output. 2026-07-23 11:26:36 -04:00
Spencer Brower 83f2dc18f9 perf(mustache): Improved performance. 2026-07-23 11:26:36 -04:00
Spencer Brower 986038f54c refactor(mustache): Simplified Node parser. 2026-07-23 11:26:36 -04:00
Spencer Brower 5960d3686c feat: Added a stress tester so we can benchmark the template rendering. 2026-07-23 11:26:36 -04:00
Spencer Brower 1856e29763 perf: Fixed leaks. 2026-07-23 11:26:36 -04:00
Spencer Brower cfd01a02d0 refactor: Changed Render_Error from Union to struct.
Also renamed it to `Error`.
2026-07-23 11:26:35 -04:00
Spencer Brower f04532229b feat: Added Rust-style error messages for template errors. 2026-07-23 11:26:32 -04:00
Spencer Brower b239bf2d87 feat: Added format pipe for dates. 2026-07-21 14:31:09 -04:00
Spencer Brower cc42e694fd feat: Demoted author to a param. 2026-07-21 11:10:44 -04:00
Spencer Brower bed7effd4b test(lambda_test.odin): Made some stylistic changes. 2026-07-21 11:10:44 -04:00
Spencer Brower 5b98ad5134 chore: Updated TODOS. 2026-07-21 11:10:44 -04:00
Spencer Brower 0fa08f3cae fix: Replace fmt.eprint calls with logs. 2026-07-21 09:39:53 -04:00
Spencer Brower 4ea44e8a09 fix: Passed description down the context chain. 2026-07-21 09:36:32 -04:00
Spencer Brower 566d6f0768 feat: Improved opengraph defaults. 2026-07-20 15:33:55 -04:00
Spencer Brower 1cb13a6a55 chore: Updated AGENTS.md. 2026-07-20 13:47:15 -04:00
Spencer Brower 5cef29e16f chore(EXTENSIONS.md): Removed future pipe plans. 2026-07-20 13:43:02 -04:00
Spencer Brower 5d8f646b31 build: Updated to latest available Odin package. 2026-07-20 12:53:06 -04:00
Spencer Brower 770a04481d feat(mustache): Added group_by filter to templates 2026-07-20 12:44:40 -04:00
Spencer Brower 29d244c1a6 fix: Fixed issue with double-any wrapping. 2026-07-19 14:55:31 -04:00
Spencer Brower efe0c42e5c chore: Updated TODOS.md 2026-07-19 14:28:11 -04:00
Spencer Brower 5c689638b1 fix: Removed thor: prefixes from log messages. 2026-07-19 12:36:03 -04:00
Spencer Brower 398f0eb1b4 chore: Reformatted TODOS.md 2026-07-19 12:22:26 -04:00
Spencer Brower ac43ab1e5a chore: Url now stored on page. 2026-07-19 12:17:06 -04:00
Spencer Brower 4fc3eae64c refactor: Created Open_Graph struct for og data. 2026-07-19 12:04:27 -04:00
Spencer Brower 90f6466f9c refactor: Moved OpenGraph fields to a separate struct. 2026-07-19 11:48:46 -04:00
Spencer Brower ce0f662c92 fix: Renamed some data keys. 2026-07-19 11:35:19 -04:00
Spencer Brower 6f6c0dddd1 feat(mustache): Added spec-compliant lambda support. 2026-07-17 18:34:15 -04:00
Spencer Brower c0ff59513f fix: Fixed an issue with stars not showing up. 2026-07-17 17:46:41 -04:00
Spencer Brower 6e66709f05 fix: Fixed a bug with template selection. 2026-07-17 17:22:36 -04:00
Spencer Brower a41a7803a4 feat: Simplified templates. 2026-07-17 17:13:49 -04:00
Spencer Brower 66b5c5a934 fix(alerts): Fixed leaks 2026-07-17 16:43:42 -04:00
Spencer Brower ebb4ef691a feat(alerts): Fixed alert class names + css. 2026-07-17 16:30:26 -04:00
Spencer Brower 9d2b2c4395 test: Added tests for sectionate extension. 2026-07-17 15:16:45 -04:00
Spencer Brower d05459af52 test: Added tests for emoji.odin. 2026-07-17 15:03:49 -04:00
Spencer Brower c7a5ae2360 fix(emoji): Replaced raw bytes with human-readable symbols. 2026-07-17 14:57:24 -04:00
Spencer Brower 085811b312 refactor: Moved markdown code to a separate packge. 2026-07-17 14:40:29 -04:00
Spencer Brower 618fabf0e4 refactor: Moved treesitter to its own package. 2026-07-17 14:10:21 -04:00
Spencer Brower dfe863e293 fix: Added -layout flag to set layouts dir. 2026-07-17 13:42:44 -04:00
Spencer Brower 528bef3941 feat: Added Unified file system / default layout mounts. 2026-07-17 13:31:00 -04:00
Spencer Brower fdd9ef4d46 docs: Added README.md. 2026-07-17 12:59:51 -04:00
100 changed files with 12113 additions and 2137 deletions
+360 -97
View File
@@ -2,66 +2,183 @@
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, author, params)
thor.json ← site config (title, base_url, params, modules, og)
content/ ← markdown and HTML content files
layouts/ ← Mustache templates + partials (including icons)
assets/ ← CSS (Tufte-based), JS, fonts, images — copied/processed to public/
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)
```
### Source files
### 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. Sets `context.logger`, calls `init_site`, `walk_content`, `render_site`. Optional Spall profiling via `SPALL` config flag. |
| `site.odin` | `Flags` (CLI args), `Config_File` (from `thor.json`), `Site` (runtime state + arena), `Feature` + `Markdown_Extension` bit_set enums, `DEFAULT_MARKDOWN_EXTENSIONS`, 5-step `init_site` (defaults → flags → config → apply config → apply flags), `load_config_file`, `apply_config`, `apply_cli_flags`, `parse_extension_list`, `find_config` |
| `frontmatter.odin` | JSON frontmatter parser (`{ }` delimited) |
| `content.odin` | `Page` struct, content walker, page loader, cmark integration, full markdown pipeline, `copy_assets_dir` (recursive copy with CSS minification) |
| `footnotes.odin` | Note definition stripping (pre-cmark) + sidenote/marginnote injection (post-cmark) |
| `alerts.odin` | GitHub alert post-processor (`> [!CAUTION]` → styled blockquote) |
| `emoji.odin` | Emoji shortcode expander (`:shrug:``¯\_(ツ)_/¯`), post-cmark |
| `highlight.odin` | Post-cmark Tree-sitter syntax highlighter; statically links HTML/CSS grammars, dlopen for others; caches loaded grammars, reports syntax errors |
| `tree_sitter.odin` | C FFI bindings for Tree-sitter (TSParser, TSQuery, TSQueryCursor, node traversal, dlopen/dlsym). Statically links `tree-sitter-html` and `tree-sitter-css` via Nix. |
| `sectionate.odin` | `wrap_sections` proc — splits HTML at `<h2` into `<section>` wrappers |
| `render.odin` | Template rendering pipeline: pre-parses templates/partials once, struct-based data model (`Base_Data`/`Page_Data`/`Home_Data`/`Posts_Data`), `mustache.render()`, minification gate, RSS, sitemap, robots.txt |
| `minify.odin` | Tree-sitter-based HTML and CSS minification (`minify_html`, `minify_css`). Strips comments, collapses whitespace, removes inter-tag spaces. Preserves `<pre>`/`<code>`/`<script>`/`<style>` content. |
| `feed.odin` | RSS feed + sitemap XML generation. Uses `strings.Builder`. `format_rfc822` uses `core:time` for date parsing. |
| `mustache/` | Mustache template engine (spec-compliant, custom implementation) |
| `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:
1. set defaults (base_url, DEFAULT_MARKDOWN_EXTENSIONS)
2. parse CLI flags (Flags struct)
3. load config file (Config_File from thor.json)
4. apply_config — non-empty config fields override defaults
5. apply_cli_flags — flags override config, -ext/-no-ext adjust extensions
→ Site (config + Dynamic_Arena + features: bit_set + markdown_extensions: bit_set)
content/ → walk_content → []Page (with body_html from pipeline)
layouts/*.html → parse() → mustache.Template (parsed once)
render_site → mustache.render(Page_Data, partials) → optional minify_html → public/
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/
```
### Config system
## 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 by `core:flags`. Includes path overrides, build-mode toggles, 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).
- **`Site`** — runtime state: arena, resolved config values, `features: bit_set[Feature]`, `markdown_extensions: bit_set[Markdown_Extension]`.
- **`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** — build-mode toggles: `Drafts`, `Minify`, `Watch`. Checked with `.Minify in site.features`.
**`Feature` enum** — `Drafts`, `Minify`, `Watch`. Checked with `.Minify in site.features`.
**`Markdown_Extension` enum** — content pipeline toggles: `Emoji`, `Sidenotes`, `Alerts`, `Highlight`, `Sections`. Default is `DEFAULT_MARKDOWN_EXTENSIONS` (currently `.Emoji, .Sidenotes, .Alerts`). Configurable via:
**`markdown.Extension` enum** (in the `markdown` package, not main) — `Emoji`, `Sidenotes`, `Alerts`, `Highlight`, `Sections`, `HeadingIDs`, `DefLists`, `Footnotes`. Default is `md.DEFAULT_EXTENSIONS` (currently `.Emoji, .Sidenotes, .Alerts, .HeadingIDs, .DefLists`). Configurable via:
- `thor.json`: `"markdown_extensions": { "emoji": true, "highlight": false, ... }`
- CLI: `-ext:highlight,sections` (enable) / `-no-ext:emoji` (disable). Comma-separated, case-insensitive.
@@ -73,13 +190,22 @@ Config precedence: `CLI flags > thor.json values > hardcoded defaults`.
{
"title": "...",
"base_url": "...",
"author": "...",
"markdown_extensions": {
"emoji": true,
"sidenotes": true,
"alerts": true,
"highlight": true,
"sections": true
"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": [
@@ -89,68 +215,131 @@ Config precedence: `CLI flags > thor.json values > hardcoded defaults`.
}
```
### Template system
## 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}}{{$content}}{{/content}}{{> footer}}</body>
<body>{{> nav}}{{$main}}{{/main}}{{> footer}}</body>
<!-- post.html -->
<!-- page.html (content layout) -->
{{<base}}
{{$content}}
<main><article><h1>{{page_title}}</h1>{{&body}}</article></main>
{{/content}}
{{$main}}
<main><article><h1>{{page.title}}</h1>{{&content}}</article></main>
{{/main}}
{{/base}}
```
Data is passed as **typed structs** (not `map[string]any`). Mustache resolves struct fields via Odin reflection, including `using`-embedded fields. Date presence is checked via string truthiness (`{{#date_iso}}`) — no separate `has_date` bool needed.
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
Base_Data :: struct {
now: datetime.DateTime,
body: string,
title: string,
// ...
}
Page_Data :: struct {
using base: Base_Data, // fields promoted via struct_get fallback
page_title: string,
// ...
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
}
```
`render_site` pre-parses all templates and partials once (via `mustache.parse`), then reuses them for every page render.
`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).
### Markdown pipeline (in content.odin `load_page`)
`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).
```
raw markdown
→ strip_definitions (pre-cmark: extract [^id]: definitions — only if .Sidenotes enabled)
→ cmark markdown_to_html (Unsafe mode for HTML passthrough)
→ expand_emoji (post-cmark: :shortcode: → unicode — only if .Emoji enabled)
→ inject_notes (post-cmark: [^id] → <label><input><span> markup — only if .Sidenotes enabled)
→ inject_alerts (post-cmark: [!TYPE] blockquotes → styled alerts — only if .Alerts enabled)
→ highlight_code (post-cmark: tree-sitter per code block — only if .Highlight enabled)
→ wrap_sections (post-cmark: wraps content in <section> at <h2> — only if .Sections enabled)
### 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>
```
Each step is gated by `bit_set[Markdown_Extension]`. All `.html` content files skip the pipeline entirely — body is used as-is.
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.
### Syntax highlighting
### 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 Helix's compiled `.so` files.
- Highlight queries (`.scm`) loaded from Helix's runtime directory.
- Paths hardcoded in `tree_sitter.odin` (Nix store paths, Helix-version-dependent).
- **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
## Minification
Optional, enabled with `-minify` flag (`.Minify` in `Feature` bit_set).
@@ -158,17 +347,18 @@ Optional, enabled with `-minify` flag (`.Minify` in `Feature` bit_set).
- **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
## Memory management
- `Site` owns a `mem.Dynamic_Arena`
- `init_site` calls `mem.dynamic_arena_init(&site.arena, alignment = 64)` — the 64-byte alignment is required by Odin's map runtime (`MAP_CACHE_LINE_SIZE`)
- `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
## Spall profiling
Optional, compiled out by default. Enabled with `-define:SPALL=true`:
@@ -203,56 +393,129 @@ nix build # runs thor, outputs to ./result/
```bash
cd thor
odin test . # site tests (config, frontmatter, footnotes)
odin test . -all-packages # includes mustache spec tests
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 Mustache implementation at `mustache/`. See `mustache/SPEC.md` for the implementation specification.
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`), template inheritance (`merge_block_overrides`) |
| `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: `effective` (union/distinct peeling), `lookup_in`, `resolve_name`, `is_truthy`, `any_to_string`, `list_info`, `write_value` |
| `spec_test.odin` | JSON spec test runner — loads `spec/specs/*.json`, runs each test case |
| `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) → tokenize → trim_standalone_whitespace → parse_section → Template
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 `first_child`/`child_count` indices — pre-order layout.
- **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.
- **`effective(a)`** peels Named/Distinct/Union layers (including `json.Value`).
- **`lookup_in`** resolves keys on structs (via `reflect.struct_field_value_by_name` with `allow_using = true`) and maps (via runtime map internals).
- **`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
- Lambdas (`~lambdas.json`)
- 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
- cmark allocates via C malloc, not the arena. HTML output leaks until process exit.
- CSS/JS cache busting uses manual `?v=N` query params instead of content hashing.
- Tree-sitter grammar/query paths for dynamic grammars hardcoded in `tree_sitter.odin` (Nix store hashes, Helix-version-dependent). HTML/CSS are statically linked.
- `map[string]any` not fully supported by mustache `lookup_in` — thor uses structs instead.
- 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/PARTIAL_INDENT.md` for whitespace handling analysis.
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
+61
View File
@@ -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`.
+231
View File
@@ -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>&copy;</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>&copy;</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>&copy;</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>&copy;</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>&copy;</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>&copy;</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>&copy;</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
+317
View File
@@ -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.
+39
View File
@@ -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]
+242 -24
View File
@@ -1,20 +1,208 @@
- README.md
- [ ] "Zero is beautiful"
- [ ] Content-hash fingerprinting for CSS and JS cache busting
- [ ] 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
## 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.
- [ ] mustache data keys for opengraph, etc.
- [ ] 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
- [ ] include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin
- [ ] follow symlinks in `scan_content`?
- [ ] ensure sidenote numbers render in display order and not in declaration order.
- [ ] OpenGraph meta tags — verify all fields match production site
- [ ] Add Opt-in deflist support.
- [ ] We need to be able to do `Year_Section` in a non-magical, unpriviledged way.
- [ ] set opengraph tags / description automatically if unset. (Like hugo does)
- [ ] Table of contents support.
- [ ] Nav items should be active when the current page is selected.
- [ ] Theme selector for syntax highlighting.
- use http://github.com/helix-editor/helix/tree/master/runtime/themes) as a
@@ -32,27 +220,57 @@
- [x] basic poll loop
- [ ] filesystem poll loop
- [ ] event based
- [ ] Free cmark HTML output (`body_html`) — cmark allocates via C malloc, not the arena, so it leaks per iteration in watch mode
- [ ] Rename `{{#is_post}}` to `{{#is_article}}` in templates and data model — currently hardcoded to posts section, should use `is_article` (any page with a section) instead.
- [ ] Split `load_page` into frontmatter-parse + body-process phases so draft pages can skip the markdown pipeline entirely
- [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
- [ ] `init` set up new project
- [ ] Use spall to find ways to reduce run time.
- [ ] `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 alerts.odin
- [ ] Review content.odin
- [ ] Review emoji.odin
- [ ] Review defaults.odin
- [ ] Review feed.odin
- [ ] Review footnotes.odin
- [ ] Review frontmatter.odin
- [ ] Review highlight.odin
- [ ] Review main.odin
- [ ] Review minifiy.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
- [ ] Review sectionate.odin
- [x] Review site.odin
- [ ] Review tree_sitter.odin
- [ ] Review treesitter/treesitter.odin
- [ ] Review vfs.odin
- [ ] Review procs
- [ ] markdown.transform_alert
-105
View File
@@ -1,105 +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 {
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])
strings.write_string(&sb, transform_alert(bq))
remaining = remaining[bq_end:]
}
return strings.to_string(sb)
}
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,
)
}
+18 -40
View File
@@ -5,57 +5,35 @@ import "core:log"
import "core:os"
import "core:strings"
// copy_assets_dir recursively copies files from assets_dir to output_dir.
// .css files are minified when .Minify is enabled; all other files are copied verbatim.
// Silently skips if assets_dir doesn't exist.
copy_assets_dir :: proc(assets_dir: string, output_dir: string, features: bit_set[Feature]) {
if !os.exists(assets_dir) {
return
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
}
copy_assets_recursive(assets_dir, "", output_dir, features)
}
copy_assets_recursive :: proc(
current: string,
rel_prefix: string,
output_dir: string,
features: bit_set[Feature],
) {
entries, err := os.read_all_directory_by_path(current, context.allocator)
if err != nil {
log.warnf("thor: cannot read %s: %v", current, err)
return
}
defer os.file_info_slice_delete(entries, context.allocator)
for entry in entries {
rel := rel_prefix == "" ? entry.name : fmt.tprintf("%s/%s", rel_prefix, entry.name)
switch entry.type {
case .Regular:
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("thor: cannot create %s: %v", dest[:idx], err)
log.warnf("cannot create %s: %v", dest[:idx], err)
continue
}
}
if .Minify in features && strings.has_suffix(entry.name, ".css") {
data, read_err := os.read_entire_file_from_path(entry.fullpath, context.allocator)
if read_err != nil {
log.warnf("thor: cannot read %s: %v", entry.fullpath, read_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)
}
minified := minify_css(string(data))
write_file(dest, minified)
} else {
if err := os.copy_file(dest, entry.fullpath); err != nil {
log.warnf("thor: cannot copy %s: %v", entry.fullpath, err)
if err := os.copy_file(dest, entry.fs_path); err != nil {
log.warnf("cannot copy %s: %v", entry.fs_path, err)
}
}
case .Directory:
copy_assets_recursive(entry.fullpath, rel, output_dir, features)
case .Undetermined, .Symlink, .Named_Pipe, .Socket, .Block_Device, .Character_Device:
}
}
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+224
View File
@@ -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,
}
}
+129
View File
@@ -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
} \
)
}
+43
View File
@@ -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)
}
+59
View File
@@ -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
+46
View File
@@ -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
+125
View File
@@ -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)
}
+132
View File
@@ -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)
}
+127
View File
@@ -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")
}
+43
View File
@@ -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)
}
+126
View File
@@ -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)
}
+13
View File
@@ -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>
+45
View File
@@ -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}}
+5
View File
@@ -0,0 +1,5 @@
<li class="comment">
<strong>{{author}}</strong>
<time datetime="{{date}}">{{date | format}}</time>
<p>{{body}}</p>
</li>
+9
View File
@@ -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>
+176 -58
View File
@@ -1,11 +1,14 @@
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"
// Fields with underscores should never be set by the user.
Page :: struct {
@@ -13,30 +16,73 @@ Page :: struct {
slug: string,
layout: string,
permalink: string,
url: string,
title: string,
description: string,
date: string,
menu: string,
body_html: 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,
toc: string,
_is_index: bool `private`,
}
Pending_File :: struct {
path: string,
section: string,
slug: string,
is_index: bool,
}
// 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([dynamic]Page, 0, 8, site_allocator(site))
scan_content(site, site.content_dir, "")
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)
}
// 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)
}
// scan_content walks the content directory. At the root level (section=""),
// directories are treated as sections. Within a section, directories are
// treated as leaf bundles (directory with an index file).
scan_content :: proc(site: ^Site, dir: string, section: string) {
// 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 {
log.warnf("thor: cannot read %s: %v", dir, err)
log.warnf("cannot read %s: %v", dir, err)
return
}
defer os.file_info_slice_delete(entries, context.allocator)
@@ -52,30 +98,34 @@ scan_content :: proc(site: ^Site, dir: string, section: string) {
is_idx := filename == "index"
slug := is_idx ? "" : filename
page, ok := load_page(entry.fullpath, section, slug, is_idx, site.markdown_extensions)
if ok && (!page.draft || .Drafts in site.features) {
append(&site.pages, page)
}
append(
pending,
Pending_File {
path = strings.clone(entry.fullpath, context.temp_allocator),
section = section,
slug = slug,
is_index = is_idx,
},
)
case .Directory:
if section == "" {
scan_content(site, entry.fullpath, entry.name)
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) {
page, ok := load_page(
index_path,
section,
entry.name,
false,
site.markdown_extensions,
append(
pending,
Pending_File {
path = strings.clone(index_path, context.temp_allocator),
section = section,
slug = entry.name,
is_index = false,
},
)
if ok && (!page.draft || .Drafts in site.features) {
append(&site.pages, page)
}
}
}
case .Undetermined, .Symlink, .Named_Pipe, .Socket, .Block_Device, .Character_Device:
@@ -83,6 +133,67 @@ scan_content :: proc(site: ^Site, dir: string, section: string) {
}
}
// 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"
@@ -91,6 +202,9 @@ infer_layout :: proc(section: string, is_index: bool) -> string {
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"
@@ -101,14 +215,14 @@ load_page :: proc(
section: string,
slug: string,
is_index: bool,
ext: bit_set[Markdown_Extension],
ext: bit_set[md.Extension],
) -> (
page: Page,
ok: bool,
) {
data, err := os.read_entire_file_from_path(file_path, context.allocator)
if err != nil {
log.warnf("thor: cannot read %s: %v", file_path, err)
log.warnf("cannot read %s: %v", file_path, err)
return
}
@@ -124,38 +238,28 @@ load_page :: proc(
page.title = fm.title
page.description = fm.description
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.is_starred = fm.isStarred
page.menu = fm.menu
page.layout = fm.layout if fm.layout != "" else infer_layout(section, is_index)
if strings.has_suffix(file_path, ".html") {
page.body_html = strings.clone(body)
} else {
sn_defs := make(map[string]string)
mn_defs := make(map[string]string)
clean_body := body
if .Sidenotes in ext {
clean_body, sn_defs, mn_defs = strip_definitions(body)
}
html := cm.markdown_to_html_from_string(clean_body, {.Unsafe})
if .Emoji in ext {
html = expand_emoji(html)
}
if .Sidenotes in ext {
html = inject_notes(html, sn_defs, mn_defs)
}
if .Alerts in ext {
html = inject_alerts(html)
}
if .Highlight in ext {
html = highlight_code(html, file_path)
}
if .Sections in ext {
html = wrap_sections(html)
}
page.body_html = html
}
page.params = fm.params
if section == "" && is_index {
page.permalink = "/"
@@ -167,6 +271,20 @@ load_page :: proc(
page.permalink = fmt.aprintf("/%s/%s/", section, slug)
}
page.menus = parse_page_menus(fm.menus, page, context.allocator)
page.layout = fm.layout if fm.layout != "" else infer_layout(section, is_index)
page.og = fm.og
if strings.has_suffix(file_path, ".html") {
page.content = strings.clone(body, context.allocator)
} else {
page.content = md.process(body, ext, file_path, context.allocator)
}
if fm.toc {
page.toc = md.generate_toc(page.content, context.allocator)
}
ok = true
return
}
+5
View File
@@ -0,0 +1,5 @@
package main
import "core:os"
DEFAULTS_PATH :: #directory + os.Path_Separator_String + "defaults"
+18
View File
@@ -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>
+15
View File
@@ -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}}
+16
View File
@@ -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}}
+3
View File
@@ -0,0 +1,3 @@
<footer>
<p>&copy; {{now | format "2006"}} {{params.author.name}}</p>
</footer>
+1
View File
@@ -0,0 +1 @@
{{site.title}}{{^site.title}}Home{{/site.title}}
+10
View File
@@ -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>
+9
View File
@@ -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}}">
+1
View File
@@ -0,0 +1 @@
{{params.scripts}}
+223
View File
@@ -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}}
+1
View File
@@ -0,0 +1 @@
{{#site.title}}{{.}}{{#page.title}} | {{/page.title}}{{/site.title}}{{page.title}}
+19
View File
@@ -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}}
-434
View File
@@ -1,434 +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
}
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)
}
+13 -26
View File
@@ -7,9 +7,8 @@ import "core:time"
generate_rss :: proc(site: ^Site) -> string {
sb := strings.builder_make()
strings.write_string(
fmt.sbprintf(
&sb,
fmt.aprintf(
`<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
@@ -22,7 +21,6 @@ generate_rss :: proc(site: ^Site) -> string {
site.base_url,
xml_escape(site.description),
site.base_url,
),
)
for page in site.pages {
@@ -35,25 +33,21 @@ generate_rss :: proc(site: ^Site) -> string {
pub_date = format_rfc822(page.date)
}
strings.write_string(
fmt.sbprintf(
&sb,
fmt.aprintf(
`<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),
site.base_url,
page.permalink,
page.url,
pub_date,
site.base_url,
page.permalink,
xml_escape(page.body_html),
),
page.url,
xml_escape(page.content),
)
}
@@ -72,14 +66,11 @@ generate_sitemap :: proc(site: ^Site) -> string {
)
for page in site.pages {
lastmod := ""
fmt.sbprintf(&sb, "<url><loc>%s</loc>", page.url)
if page.date != "" {
lastmod = fmt.aprintf("<lastmod>%s</lastmod>", page.date)
fmt.sbprintf(&sb, "<lastmod>%s</lastmod>", page.date)
}
strings.write_string(
&sb,
fmt.aprintf("<url><loc>%s%s</loc>%s</url>\n", site.base_url, page.permalink, lastmod),
)
fmt.sbprintf(&sb, "</url>\n")
}
// Section index pages (for sections without an index in content)
@@ -108,14 +99,11 @@ generate_sitemap :: proc(site: ^Site) -> string {
section_lastmod = page.date
}
}
lm := ""
fmt.sbprintf(&sb, "<url><loc>%s/%s/</loc>", site.base_url, section)
if section_lastmod != "" {
lm = fmt.aprintf("<lastmod>%s</lastmod>", section_lastmod)
fmt.sbprintf(&sb, "<lastmod>%s</lastmod>", section_lastmod)
}
strings.write_string(
&sb,
fmt.aprintf("<url><loc>%s/%s/</loc>%s</url>\n", site.base_url, section, lm),
)
fmt.sbprintf(&sb, "</url>\n")
}
strings.write_string(&sb, "</urlset>")
@@ -154,4 +142,3 @@ xml_escape :: proc(s: string) -> string {
r, _ = strings.replace_all(r, ">", "&gt;")
return r
}
Generated
+3 -3
View File
@@ -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": {
+31 -20
View File
@@ -12,13 +12,10 @@
};
outputs =
inputs@{
self,
flake-parts,
nixpkgs,
nixpkgs-unstable,
inputs@{ flake-parts
, nixpkgs
, ...
# , process-compose-flake
treefmt-nix,
}:
flake-parts.lib.mkFlake { inherit inputs; } {
imports = [
@@ -28,11 +25,10 @@
systems = [ "x86_64-linux" ];
perSystem =
{
pkgs,
system,
inputs',
...
{ pkgs
, system
, inputs'
, ...
}:
let
mkGrammarStaticLib = name: src: pkgs.stdenv.mkDerivation {
@@ -62,6 +58,8 @@
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 {
@@ -69,7 +67,7 @@
config.allowUnfree = true;
overlays = [
(final: prev: { unstable = inputs'.nixpkgs-unstable.legacyPackages; })
(_final: _prev: { unstable = inputs'.nixpkgs-unstable.legacyPackages; })
];
};
@@ -93,11 +91,22 @@
settings.formatter.prettier = {
excludes = [
"public/**"
"resources/js/modernizr.js"
"storage/app/caniuse.json"
"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 = { };
@@ -108,7 +117,7 @@
src = ./.;
nativeBuildInputs = [
pkgs.odin
odin
pkgs.pkg-config
];
@@ -116,6 +125,7 @@
pkgs.git
pkgs.cmark
pkgs.tree-sitter
pkgs.tzdata
html-grammar
css-grammar
];
@@ -131,7 +141,7 @@
buildPhase = ''
runHook preBuild
odin build . -o:speed -out:${pname}-keep
odin build . -o:speed -no-bounds-check -out:${pname}-keep
runHook postBuild
'';
@@ -143,17 +153,18 @@
};
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
odin
ols
buildInputs = [ odin ols ] ++ (with pkgs; [
cmark
tree-sitter
gdb
perf
# IDE
unstable.helix
typescript-language-server
vscode-langservers-extracted
];
]);
shellHook = ''
export LIBRARY_PATH="${html-grammar}/lib:${css-grammar}/lib:$LIBRARY_PATH"
+51 -8
View File
@@ -1,18 +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,
draft: bool,
isStarred: bool,
menu: string,
weight: Maybe(int),
menus: json.Value,
params: json.Value,
layout: string,
og: Open_Graph,
draft: bool,
toc: bool,
}
// parse_frontmatter splits raw file content into a Frontmatter struct and the
@@ -34,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)
@@ -49,11 +53,17 @@ parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok
fm.title = json_get_string(obj, "title")
fm.description = json_get_string(obj, "description")
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
@@ -77,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
}
-476
View File
@@ -1,476 +0,0 @@
package main
import "core:fmt"
import "core:log"
import "core:os"
import "core:strings"
Grammar_Cache :: struct {
language: TSLanguage,
parser: TSParser,
query: TSQuery,
query_failed: bool,
}
Get_Language_Proc :: #type proc() -> TSLanguage
grammar_cache: map[string]^Grammar_Cache
builtin_language :: proc(lang: string) -> (language: TSLanguage, ok: bool) {
switch lang {
case "html":
language = tree_sitter_html()
ok = true
case "css":
language = tree_sitter_css()
ok = true
}
return
}
ensure_parser :: proc(lang: string) -> ^Grammar_Cache {
if grammar_cache == nil {
grammar_cache = make(map[string]^Grammar_Cache)
}
if cached, ok := grammar_cache[lang]; ok {
return cached
}
grammar_cache[lang] = nil
language: TSLanguage
if builtin, ok := builtin_language(lang); ok {
language = builtin
} else {
if GRAPHS_PATH == "" {
log.warnf("highlight: no grammars path set, skipping %s", lang)
return nil
}
so_path := fmt.tprintf("%s/%s.so", GRAPHS_PATH, lang)
so_c := strings.clone_to_cstring(so_path)
defer delete(so_c)
handle := dlopen(so_c, RTLD_LAZY)
if handle == nil {
log.warnf("highlight: cannot load grammar %s (%s)", lang, so_path)
return nil
}
sym_name := fmt.tprintf("tree_sitter_%s", lang)
sym_c := strings.clone_to_cstring(sym_name)
defer delete(sym_c)
sym := dlsym(handle, sym_c)
if sym == nil {
log.errorf("highlight: cannot find symbol %s in %s", sym_name, so_path)
return nil
}
get_language := transmute(Get_Language_Proc)(sym)
language = get_language()
}
parser := ts_parser_new()
if parser == nil {
log.errorf("highlight: cannot create parser for %s", lang)
return nil
}
if !ts_parser_set_language(parser, language) {
log.errorf("highlight: ABI mismatch for %s grammar", lang)
ts_parser_delete(parser)
return nil
}
gc := new(Grammar_Cache)
gc.language = language
gc.parser = parser
grammar_cache[lang] = gc
return gc
}
load_grammar :: proc(lang: string) -> ^Grammar_Cache {
gc := ensure_parser(lang)
if gc == nil {
return nil
}
if gc.query != nil {
return gc
}
if gc.query_failed {
return nil
}
if QUERIES_PATH == "" {
log.warnf("highlight: no queries path set, skipping %s", lang)
gc.query_failed = true
return nil
}
query_path := fmt.tprintf("%s/%s/highlights.scm", QUERIES_PATH, lang)
query_src, err := os.read_entire_file_from_path(query_path, context.allocator)
if err != nil {
log.warnf("highlight: cannot load query %s", query_path)
gc.query_failed = true
return nil
}
query_str := string(query_src)
query_c := strings.clone_to_cstring(query_str)
defer delete(query_c)
err_offset: u32
err_type: TSQueryError
query := ts_query_new(
gc.language,
query_c,
u32(len(query_src)),
&err_offset,
&err_type,
)
if query == nil {
tok := extract_query_token(query_src, err_offset)
cause := fmt.tprintf("query error at byte %d (type %v)", err_offset, err_type)
#partial switch err_type {
case .NodeType:
if tok != "" {
cause = fmt.tprintf("query references unknown node type '%s' (byte %d); the grammar (.so) and query (.scm) are likely from different tree-sitter-%s versions", tok, err_offset, lang)
} else {
cause = fmt.tprintf("query references an unknown node type at byte %d; the grammar (.so) and query (.scm) are likely from different tree-sitter-%s versions", err_offset, lang)
}
case .Field:
cause = fmt.tprintf("query references unknown field '%s' at byte %d", tok, err_offset)
case .Capture:
cause = fmt.tprintf("query uses an invalid capture '%s' at byte %d", tok, err_offset)
case .Syntax:
cause = fmt.tprintf("query has a syntax error at byte %d", err_offset)
case .Structure:
cause = fmt.tprintf("query has an illegal pattern structure at byte %d", err_offset)
case .Language:
cause = "grammar language is null (broken grammar .so)"
}
log.errorf("highlight: %s query failed: %s", lang, cause)
_, is_builtin := builtin_language(lang)
if !is_builtin {
so_path := fmt.tprintf("%s/%s.so", GRAPHS_PATH, lang)
gram_v := helix_version_from_path(so_path)
query_v := helix_version_from_path(query_path)
gram_note := "(version unknown)"
if gram_v != "" do gram_note = fmt.tprintf("helix %s", gram_v)
query_note := "(version unknown)"
if query_v != "" do query_note = fmt.tprintf("helix %s", query_v)
log.errorf(" grammar: %s [%s]", so_path, gram_note)
log.errorf(" query: %s [%s]", query_path, query_note)
if gram_v != "" && query_v != "" && gram_v != query_v {
log.errorf(" >> helix VERSION MISMATCH: grammar %s vs query %s", gram_v, query_v)
}
}
gc.query_failed = true
return nil
}
gc.query = query
return gc
}
extract_query_token :: proc(src: []byte, offset: u32) -> string {
end := offset
for int(end) < len(src) {
c := src[end]
is_ident := (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.'
if !is_ident do break
end += 1
}
if end <= offset do return ""
return string(src[offset:end])
}
helix_version_from_path :: proc(path: string) -> string {
tag := "-helix-"
idx := strings.index(path, tag)
if idx < 0 do return ""
start := idx + len(tag)
end := start
for end < len(path) {
c := path[end]
if !((c >= '0' && c <= '9') || c == '.') do break
end += 1
}
if end <= start do return ""
return path[start:end]
}
Capture :: struct {
start: u32,
end: u32,
name: string,
}
find_first_error_line :: proc(root: TSNode) -> 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
}
capture_name_to_css :: proc(name: string) -> string {
sb := strings.builder_make()
seg := strings.builder_make()
first := true
for i in 0..<len(name) {
if name[i] == '.' {
if !first do strings.write_byte(&sb, ' ')
first = false
strings.write_string(&sb, "hl-")
strings.write_string(&sb, strings.to_string(seg))
strings.write_byte(&seg, '-')
} else {
strings.write_byte(&seg, name[i])
}
}
if !first do strings.write_byte(&sb, ' ')
strings.write_string(&sb, "hl-")
strings.write_string(&sb, strings.to_string(seg))
return strings.to_string(sb)
}
escape_html :: proc(s: string) -> string {
sb := strings.builder_make()
defer strings.builder_destroy(&sb)
start := 0
for i in 0..<len(s) {
switch s[i] {
case '&':
if i > start do strings.write_string(&sb, s[start:i])
strings.write_string(&sb, "&amp;")
start = i + 1
case '<':
if i > start do strings.write_string(&sb, s[start:i])
strings.write_string(&sb, "&lt;")
start = i + 1
case '>':
if i > start do strings.write_string(&sb, s[start:i])
strings.write_string(&sb, "&gt;")
start = i + 1
case '"':
if i > start do strings.write_string(&sb, s[start:i])
strings.write_string(&sb, "&quot;")
start = i + 1
}
}
if start == 0 do return s
if start < len(s) do strings.write_string(&sb, s[start:])
return strings.to_string(sb)
}
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 "&amp;": replacement = "&"
case "&lt;": replacement = "<"
case "&gt;": replacement = ">"
case "&quot;": replacement = "\""
case "&#39;", "&apos;": 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 := 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 := ts_query_cursor_new()
if cursor == nil {
return code
}
defer ts_query_cursor_delete(cursor)
ts_query_cursor_exec(cursor, gc.query, root)
captures: [dynamic]Capture
defer delete(captures)
match: TSQueryMatch
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()
last_pos: u32 = 0
stack: [dynamic]Capture
defer delete(stack)
for cap in captures {
for len(stack) > 0 {
top := stack[len(stack) - 1]
if top.end <= cap.start {
if top.end > last_pos {
strings.write_string(&sb, escape_html(raw_code[last_pos:top.end]))
}
strings.write_string(&sb, "</span>")
last_pos = top.end
pop(&stack)
} else {
break
}
}
if cap.start > last_pos {
strings.write_string(&sb, escape_html(raw_code[last_pos:cap.start]))
last_pos = cap.start
}
css_class := capture_name_to_css(cap.name)
strings.write_string(&sb, fmt.tprintf("<span class=\"%s\">", css_class))
append(&stack, cap)
}
for len(stack) > 0 {
top := pop(&stack)
if top.end > last_pos {
strings.write_string(&sb, escape_html(raw_code[last_pos:top.end]))
}
strings.write_string(&sb, "</span>")
last_pos = top.end
}
if int(last_pos) < len(raw_code) {
strings.write_string(&sb, escape_html(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)
strings.write_string(&sb, fmt.tprintf(`<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)
}
+217
View File
@@ -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 "&amp;":
replacement = "&"
case "&lt;":
replacement = "<"
case "&gt;":
replacement = ">"
case "&quot;":
replacement = "\""
case "&#39;", "&apos;":
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 "&amp;":
replacement = "&"
case "&lt;":
replacement = "<"
case "&gt;":
replacement = ">"
case "&quot;":
replacement = "\""
case "&#39;", "&apos;":
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
View File
@@ -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 &amp; dogs &lt;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")
}
+52 -7
View File
@@ -1,26 +1,38 @@
package main
import "base:runtime"
import "core:fmt"
import "core:flags"
import "core:log"
import "core:mem"
import "core:os"
import "core:prof/spall"
import "core:sync"
import "core:time"
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() {
when SPALL {
ctx, ok := spall.context_create_with_scale("thor.spall", false, 1.0)
if !ok {
fmt.eprintln("Failed to create spall trace file")
log.fatal("Failed to create spall trace file")
os.exit(1)
}
spall_ctx = ctx
@@ -33,24 +45,57 @@ main :: proc() {
defer spall.buffer_destroy(&spall_ctx, &spall_buffer)
}
console_logger := log.create_console_logger()
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)
}
for {
defer free_all(context.temp_allocator)
defer log.debugf(
"max_temp_allocator_size=%M",
(cast(^runtime.Default_Temp_Allocator)context.temp_allocator.data)^.arena.total_used,
)
tick := time.tick_now()
site: Site
init_site(&site, os.args)
init_site(&site, cli_flags)
defer destroy_site(&site)
// TODO: Make it so this isn't necessary
context.allocator = site_allocator(&site)
defer log.debugf(
"current_site_allocator_size=%M",
site.arena.block_size * (len(site.arena.used_blocks) + len(site.arena.unused_blocks)) -
site.arena.bytes_left,
)
build_vfs(&site)
treesitter.grammar_dir = site.grammars
treesitter.query_dir = site.queries
site_load_content(&site)
render_site(&site)
log.infof("Built site in %M", time.tick_since(tick))
if !(.Watch in site.features) {
break
}
(.Watch in site.features) or_break
time.sleep(5 * time.Second)
}
}
-1
View File
@@ -7,4 +7,3 @@ import "core:testing"
test_true :: proc(t: ^testing.T) {
testing.expect(t, true)
}
+97
View File
@@ -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)
}
+103
View File
@@ -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>`,
)
}
+197
View File
@@ -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
}
+104
View File
@@ -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>"))
}
+436
View File
@@ -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)
}
+33
View File
@@ -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 :")
}
+97 -16
View File
@@ -1,6 +1,4 @@
package main
import cm "vendor:commonmark"
package markdown
import "core:fmt"
import "core:strings"
@@ -171,27 +169,27 @@ inject_notes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> strin
}
// Render definition through cmark for markdown support
def_html := cm.markdown_to_html_from_string(def_text, {.Unsafe})
def_html = strip_p_tags(def_html)
def_html := render_inline_md(def_text)
note: string
defer delete(note)
if is_margin {
note = fmt.aprintf(
fmt.sbprintf(
&parts,
`<label for="mn-%s" class="margin-toggle"></label><input type="checkbox" id="mn-%s" class="margin-toggle"><span class="marginnote">%s</span>`,
id,
id,
def_html,
)
} else {
note = fmt.aprintf(
fmt.sbprintf(
&parts,
`<label for="fn-%s" class="margin-toggle sidenote-number"></label><input type="checkbox" id="fn-%s" class="margin-toggle"><span class="sidenote">%s</span>`,
id,
id,
def_html,
)
}
strings.write_string(&parts, note)
remaining = remaining[ref_end:]
}
@@ -199,15 +197,98 @@ inject_notes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> strin
return strings.to_string(parts)
}
// strip_p_tags removes surrounding <p></p> if the HTML is a single paragraph.
strip_p_tags :: proc(html: string) -> string {
s := html
if len(s) > 0 && s[len(s) - 1] == '\n' {
s = s[:len(s) - 1]
// inject_footnotes finds [^id] and [*id] references in rendered HTML, numbers
// them sequentially by order of appearance, and replaces them with <sup> links.
// Appends a <section class="footnotes"><ol> at the end with definitions.
// Both sidenote ([^id]) and marginnote ([*id]) references are treated equally.
inject_footnotes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> string {
if len(sn_defs) == 0 && len(mn_defs) == 0 {
return html
}
if strings.has_prefix(s, "<p>") && strings.has_suffix(s, "</p>") {
return s[3:len(s) - 4]
parts: strings.Builder
strings.builder_init_len(&parts, 0)
defer strings.builder_destroy(&parts)
number_of: map[string]int = make(map[string]int, allocator = context.temp_allocator)
ordered_ids: [dynamic]string = make([dynamic]string, 0, allocator = context.temp_allocator)
next_num := 1
remaining := html
for {
sn_pos := strings.index(remaining, "[^")
mn_pos := strings.index(remaining, "[*")
is_margin := mn_pos >= 0 && (sn_pos < 0 || mn_pos < sn_pos)
pos := sn_pos
if is_margin {
pos = mn_pos
}
return s
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)
}
@@ -1,6 +1,6 @@
#+feature dynamic-literals
#+test
package main
package markdown
import "core:strings"
import "core:testing"
@@ -120,3 +120,101 @@ test_inject_notes_missing_ref :: proc(t: ^testing.T) {
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>`))
}
+195
View File
@@ -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 "&amp;":
replacement = "&"
case "&lt;":
replacement = "<"
case "&gt;":
replacement = ">"
case "&quot;":
replacement = "\""
case "&#39;", "&apos;":
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 ""
}
+102
View File
@@ -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 &amp; Dogs</h2>")
testing.expect_value(t, result, `<h2 id="cats-dogs">Cats &amp; 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>`,
)
}
+307
View File
@@ -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, "&amp;")
start = i + 1
case '<':
if i > start do strings.write_string(b, s[start:i])
strings.write_string(b, "&lt;")
start = i + 1
case '>':
if i > start do strings.write_string(b, s[start:i])
strings.write_string(b, "&gt;")
start = i + 1
case '"':
if i > start do strings.write_string(b, s[start:i])
strings.write_string(b, "&quot;")
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 "&amp;":
replacement = "&"
case "&lt;":
replacement = "<"
case "&gt;":
replacement = ">"
case "&quot;":
replacement = "\""
case "&#39;", "&apos;":
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)
}
+130
View File
@@ -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}
}
}
+4 -3
View File
@@ -1,4 +1,4 @@
package main
package markdown
import "core:strings"
@@ -37,8 +37,9 @@ wrap_sections :: proc(html: string) -> string {
found = true
}
if !found {
if found {
return strings.to_string(sb)
} else {
return html
}
return strings.to_string(sb)
}
+48
View File
@@ -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>",
)
}
+163
View File
@@ -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
View File
@@ -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
View File
@@ -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",
)
}
+50 -48
View File
@@ -1,5 +1,7 @@
package main
import ts "treesitter"
import "core:log"
import "core:strings"
@@ -11,7 +13,7 @@ Range :: struct {
}
minify_html :: proc(source: string) -> string {
gc := ensure_parser("html")
gc := ts.ensure_parser("html")
if gc == nil {
return source
}
@@ -19,15 +21,15 @@ minify_html :: proc(source: string) -> string {
source_c := strings.clone_to_cstring(source)
defer delete(source_c)
tree := ts_parser_parse_string(gc.parser, nil, source_c, u32(len(source)))
tree := ts.parser_parse_string(gc.parser, nil, source_c, u32(len(source)))
if tree == nil {
return source
}
defer ts_tree_delete(tree)
defer ts.tree_delete(tree)
root := ts_tree_root_node(tree)
root := ts.tree_root_node(tree)
if ts_node_has_error(root) {
if ts.node_has_error(root) {
log.warnf("minify: HTML parse errors, skipping minification")
return source
}
@@ -52,7 +54,7 @@ minify_html :: proc(source: string) -> string {
segment := source[i:p.end]
strings.write_string(&sb, segment)
if len(segment) > 0 {
last_written = segment[len(segment)-1]
last_written = segment[len(segment) - 1]
}
i = int(p.end)
pi += 1
@@ -95,33 +97,33 @@ minify_html :: proc(source: string) -> string {
}
collect_html_ranges :: proc(
node: TSNode,
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))
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),
})
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),
})
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),
})
append(
preserves,
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
)
} else {
collect_html_ranges(child, source, comments, preserves)
}
@@ -131,17 +133,17 @@ collect_html_ranges :: proc(
}
}
html_tag_name :: proc(element: TSNode, 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)
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]
}
}
@@ -167,7 +169,7 @@ is_css_delim :: proc(c: u8) -> bool {
}
minify_css :: proc(source: string) -> string {
gc := ensure_parser("css")
gc := ts.ensure_parser("css")
if gc == nil {
return source
}
@@ -175,15 +177,15 @@ minify_css :: proc(source: string) -> string {
source_c := strings.clone_to_cstring(source)
defer delete(source_c)
tree := ts_parser_parse_string(gc.parser, nil, source_c, u32(len(source)))
tree := ts.parser_parse_string(gc.parser, nil, source_c, u32(len(source)))
if tree == nil {
return source
}
defer ts_tree_delete(tree)
defer ts.tree_delete(tree)
root := ts_tree_root_node(tree)
root := ts.tree_root_node(tree)
if ts_node_has_error(root) {
if ts.node_has_error(root) {
log.warnf("minify: CSS parse errors, skipping minification")
return source
}
@@ -237,15 +239,15 @@ minify_css :: proc(source: string) -> string {
return strings.to_string(sb)
}
collect_css_comments :: proc(node: TSNode, 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),
})
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)
}
+96
View File
@@ -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.
+33
View File
@@ -64,7 +64,11 @@ lookup_in :: proc(container: any, key: string) -> (result: any, found: bool) {
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
}
}
@@ -133,6 +137,22 @@ resolve_name :: proc(name: string, ctx: []any) -> any {
return result
}
collect_map_keys :: proc(container: any, allocator := context.temp_allocator) -> []string {
val, info := base_value(container)
if info == nil do return nil
if _, ok := info.variant.(runtime.Type_Info_Map); !ok {
return nil
}
out := make([dynamic]string, 0, 4, allocator)
it := 0
for {
key, _ := reflect.iterate_map(val, &it) or_break
key_str := key.(string) or_continue
append(&out, key_str)
}
return out[:]
}
// is_truthy checks mustache truthiness.
is_truthy :: proc(a: any) -> bool {
if a == nil {
@@ -177,6 +197,19 @@ list_info :: proc(a: any) -> (elem_info: ^runtime.Type_Info, count: int, data: r
}
}
// 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 {
+330
View File
@@ -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)
}
+559
View File
@@ -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),
)
}
+323
View File
@@ -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
}
+383
View File
@@ -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")
}
+541 -163
View File
File diff suppressed because it is too large Load Diff
+51 -9
View File
@@ -1,14 +1,16 @@
#+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 template_free(&tmpl)
defer delete_template(&tmpl)
}
@(test)
@@ -18,9 +20,14 @@ leak_parse_free_tokens :: proc(t: ^testing.T) {
mem.dynamic_arena_init(&arena)
defer mem.dynamic_arena_destroy(&arena)
tmpl, err := parse("Hello {{name}}!", context.allocator, mem.dynamic_arena_allocator(&arena))
tmpl, err := parse(
"Hello {{name}}!",
"<test>",
context.allocator,
mem.dynamic_arena_allocator(&arena),
)
testing.expect(t, err == nil)
defer template_free(&tmpl)
defer delete_template(&tmpl)
}
@(test)
@@ -36,7 +43,7 @@ leak_render :: proc(t: ^testing.T) {
}
tmpl, err := parse("Hello {{name}}!")
testing.expect(t, err == nil)
defer template_free(&tmpl)
defer delete_template(&tmpl)
result, rerr := render(tmpl, Data{name = "World"})
testing.expect(t, rerr == nil)
@@ -48,7 +55,7 @@ leak_render_partials :: proc(t: ^testing.T) {
partials := make_map(map[string]Template)
defer {
for _, &p in partials {
template_free(&p)
delete_template(&p)
}
delete(partials)
}
@@ -59,7 +66,7 @@ leak_render_partials :: proc(t: ^testing.T) {
tmpl, err := parse("Hello {{> name}}!")
testing.expect(t, err == nil)
defer template_free(&tmpl)
defer delete_template(&tmpl)
Data :: struct {
name: string,
@@ -73,7 +80,7 @@ leak_render_partials :: proc(t: ^testing.T) {
leak_render_sections :: proc(t: ^testing.T) {
tmpl, err := parse("{{#items}}{{.}}{{/items}}")
testing.expect(t, err == nil)
defer template_free(&tmpl)
defer delete_template(&tmpl)
Data :: struct {
items: [3]string,
@@ -96,7 +103,7 @@ leak_render_inheritance :: proc(t: ^testing.T) {
tmpl_src := "{{<layout}}{{$title}}custom{{/title}}{{/layout}}"
tmpl, err := parse(tmpl_src)
testing.expect(t, err == nil)
defer template_free(&tmpl)
defer delete_template(&tmpl)
result, rerr := render(tmpl, {}, partials)
testing.expect(t, rerr == nil)
@@ -107,7 +114,7 @@ leak_render_inheritance :: proc(t: ^testing.T) {
leak_repeated_render :: proc(t: ^testing.T) {
tmpl, err := parse("Hello {{name}}!")
testing.expect(t, err == nil)
defer template_free(&tmpl)
defer delete_template(&tmpl)
Data :: struct {
name: string,
@@ -119,3 +126,38 @@ leak_repeated_render :: proc(t: ^testing.T) {
}
}
// A deeply nested map pushes the context stack past MAX_CONTEXT_DEPTH (16).
// The depth warning must be non-fatal: rendering still succeeds.
@(test)
test_context_depth_warns :: proc(t: ^testing.T) {
context.logger = log.nil_logger()
AMT :: MAX_CONTEXT_DEPTH + 2
// Build AMT nested {x: {...}} levels; the innermost holds `leaf`.
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")
}
+411
View File
@@ -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
}
+585
View File
@@ -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'")
}
+7 -3
View File
@@ -3,6 +3,7 @@ package mustache
import "core:encoding/json"
import "core:fmt"
import "core:log"
import "core:os"
import "core:path/filepath"
import "core:testing"
@@ -54,6 +55,10 @@ run_one_test :: proc(
test: json.Object,
name, template_src, expected: string,
) -> bool {
// Spec tests deliberately exercise missing keys, missing partials, etc.
// Silence the warnings to keep test output readable.
context.logger = log.nil_logger()
partials := make_map(map[string]Template, context.temp_allocator)
if "partials" in test {
@@ -63,7 +68,7 @@ run_one_test :: proc(
psrc, ok := pval.(string)
assert(ok)
pt, perr := parse(psrc, context.temp_allocator)
pt, perr := parse(psrc, "<spec>", context.temp_allocator)
if perr != nil {
testing.expectf(t, false, "[%s] partial '%s' parse error", name, pname)
return false
@@ -72,7 +77,7 @@ run_one_test :: proc(
}
}
tmpl, terr := parse(template_src, context.temp_allocator)
tmpl, terr := parse(template_src, "<spec>", context.temp_allocator)
if terr != nil {
testing.expectf(t, false, "[%s] template parse error", name)
return false
@@ -127,4 +132,3 @@ spec_dynamic_names :: proc(t: ^testing.T) {
spec_inheritance :: proc(t: ^testing.T) {
run_spec_file(t, "spec/specs/~inheritance.json")
}
+209
View File
@@ -0,0 +1,209 @@
package mustache
import "base:runtime"
import "core:strings"
// collect_struct_keys enumerates the visible field names of a struct value,
// including fields promoted via `using`-embedded structs.
collect_struct_keys :: proc(val: any, allocator := context.temp_allocator) -> []string {
out := make([dynamic]string, 0, 0, allocator)
collect_struct_keys_into(val, &out, allocator)
return out[:]
}
collect_struct_keys_into :: proc(val: any, out: ^[dynamic]string, allocator := context.allocator) {
v, info := base_value(val)
if info == nil {
return
}
s, ok := info.variant.(runtime.Type_Info_Struct)
if !ok {
return
}
for i in 0 ..< int(s.field_count) {
name := s.names[i]
if len(name) == 0 {
continue
}
if name[0] == '_' {
continue
}
append(out, name)
// Recurse into using-embedded struct fields to surface promoted names.
if s.usings[i] {
field_info := type_info_of(s.types[i].id)
if field_info != nil {
collect_struct_keys_into(any{v.data, s.types[i].id}, out, allocator)
}
}
}
}
// struct_has_field reports whether a struct value has a named field,
// independent of whether that field's value is currently nil. This matters
// for fields like `Maybe(bool)` which can be nil but still exist.
struct_has_field :: proc(val: any, key: string) -> bool {
v, info := base_value(val)
if info == nil {
return false
}
s, ok := info.variant.(runtime.Type_Info_Struct)
if !ok {
return false
}
for i in 0 ..< int(s.field_count) {
if s.names[i] == key {
return true
}
// Recurse into using-embedded fields.
if s.usings[i] {
if struct_has_field(any{v.data, s.types[i].id}, key) {
return true
}
}
}
return false
}
// validate_key_path walks a dotted key path against the context stack and
// reports where (if anywhere) the lookup fails. Returns:
// - ok: true if the entire path resolves, OR if the path crosses a map
// (map keys are user-defined and not validated)
// - missing_segment: the segment that failed (empty when ok)
// - available: keys available at the failing level, for suggestions
validate_key_path :: proc(
ctx: []any,
key: string,
allocator := context.temp_allocator,
) -> (
ok: bool,
missing_segment: string,
available: []string,
) {
parts: [16]string
part_count := 0
start := 0
for i in 0 ..< len(key) {
if key[i] == '.' {
if part_count < len(parts) {
parts[part_count] = key[start:i]
part_count += 1
}
start = i + 1
}
}
if part_count < len(parts) {
parts[part_count] = key[start:]
part_count += 1
}
if part_count == 0 {
return true, "", nil
}
current: any = nil
found := false
for i := len(ctx) - 1; i >= 0; i -= 1 {
current, found = lookup_in(ctx[i], parts[0])
if found {
break
}
}
if !found {
keys := make([dynamic]string, 0, 4, allocator)
for i := len(ctx) - 1; i >= 0; i -= 1 {
collect_struct_keys_into(ctx[i], &keys, allocator)
}
return false, parts[0], keys[:]
}
for i in 1 ..< part_count {
v, info := base_value(current)
if info == nil {
return true, "", nil
}
if _, is_map := info.variant.(runtime.Type_Info_Map); is_map {
val, found := lookup_in(current, parts[i])
if found {
current = val
continue
}
available := collect_map_keys(current, allocator)
if suggest_correction(available, parts[i]) != "" {
return false, parts[i], available
}
return true, "", nil
}
if _, is_struct := info.variant.(runtime.Type_Info_Struct); is_struct {
if !struct_has_field(current, parts[i]) {
return false, parts[i], collect_struct_keys(current, allocator)
}
// Field exists descend into it. If the value is nil, stop here
// (further segments can't be resolved but the current field is
// legitimately present).
next, found := lookup_in(current, parts[i])
if !found {
return true, "", nil
}
current = next
continue
}
return false, parts[i], nil
}
return true, "", nil
}
// suggest_correction returns the closest match from `available` to `missing`
// using Levenshtein distance, or "" if no good match exists. The threshold
// scales with the length of the missing key.
suggest_correction :: proc(available: []string, missing: string) -> string {
if len(available) == 0 || len(missing) == 0 {
return ""
}
threshold := max(2, len(missing) / 3)
best: string
best_dist := threshold + 1
for candidate in available {
if abs(len(candidate) - len(missing)) > threshold {
continue
}
d := strings.levenshtein_distance(missing, candidate)
if d <= threshold && d < best_dist {
best = candidate
best_dist = d
}
}
return best
}
// collect_partial_names enumerates the keys of the partials map.
collect_partial_names :: proc(
partials: map[string]Template,
allocator := context.temp_allocator,
) -> []string {
out := make([dynamic]string, 0, 0, allocator)
for name in partials {
append(&out, name)
}
return out[:]
}
// collect_block_names enumerates the unique `{{$name}}` block definitions in
// a template's node array.
collect_block_names :: proc(tmpl: Template, allocator := context.temp_allocator) -> []string {
out := make([dynamic]string, 0, 0, allocator)
seen := make(map[string]bool, allocator)
defer delete(seen)
for &node in tmpl.nodes {
if node.kind == .Block {
if !seen[node.key] {
seen[node.key] = true
append(&out, node.key)
}
}
}
return out[:]
}
+264
View File
@@ -0,0 +1,264 @@
#+test
#+feature dynamic-literals
package mustache
import "core:encoding/json"
import "core:fmt"
import "core:testing"
Inner :: struct {
foo: string,
bar: int,
}
Page :: struct {
title: string,
}
Outer :: struct {
title: string,
page: Page,
inner: Inner,
numbers: [3]int,
}
@(test)
test_validate_simple_found :: proc(t: ^testing.T) {
data := Outer {
title = "hi",
}
ctx := make([dynamic]any, 0, 1, context.temp_allocator)
append(&ctx, data)
ok, missing, _ := validate_key_path(ctx[:], "title")
testing.expect_value(t, ok, true)
testing.expect(t, missing == "", fmt.tprintf("expected empty missing, got %q", missing))
}
@(test)
test_validate_simple_missing :: proc(t: ^testing.T) {
data := Outer {
title = "hi",
}
ctx := make([dynamic]any, 0, 1, context.temp_allocator)
append(&ctx, data)
ok, missing, available := validate_key_path(ctx[:], "page_titel")
testing.expect_value(t, ok, false)
testing.expect_value(t, missing, "page_titel")
testing.expect(t, len(available) > 0, "should have suggestions")
}
@(test)
test_validate_dotted_found :: proc(t: ^testing.T) {
data := Outer {
inner = Inner{foo = "x"},
}
ctx := make([dynamic]any, 0, 1, context.temp_allocator)
append(&ctx, data)
ok, missing, _ := validate_key_path(ctx[:], "inner.foo")
testing.expect_value(t, ok, true)
testing.expect_value(t, missing, "")
}
@(test)
test_validate_dotted_missing :: proc(t: ^testing.T) {
data := Outer {
inner = Inner{foo = "x"},
}
ctx := make([dynamic]any, 0, 1, context.temp_allocator)
append(&ctx, data)
ok, missing, available := validate_key_path(ctx[:], "inner.fooo")
testing.expect_value(t, ok, false)
testing.expect_value(t, missing, "fooo")
testing.expect(t, len(available) > 0, "should have inner field suggestions")
}
Params_Data :: struct {
params: map[string]string,
}
Maybe_Bool_Data :: struct {
flag: Maybe(bool),
name: string,
}
Inner_For_Using :: struct {
flag: Maybe(bool),
label: string,
}
Outer_With_Using :: struct {
using inner: Inner_For_Using,
other: int,
}
@(test)
test_validate_path_through_using_to_maybe_bool :: proc(t: ^testing.T) {
data := Outer_With_Using {
inner = Inner_For_Using{label = "hi"},
other = 42,
}
ctx := make([dynamic]any, 0, 1, context.temp_allocator)
append(&ctx, data)
// `flag` is promoted via using; field exists even when Maybe is nil.
ok, missing, _ := validate_key_path(ctx[:], "flag")
testing.expect_value(t, ok, true)
testing.expect_value(t, missing, "")
// `label` is also promoted via using.
ok2, missing2, _ := validate_key_path(ctx[:], "label")
testing.expect_value(t, ok2, true)
testing.expect_value(t, missing2, "")
}
@(test)
test_struct_has_field_with_maybe_bool :: proc(t: ^testing.T) {
data := Maybe_Bool_Data {
name = "hi",
} // flag is nil Maybe
testing.expect_value(t, struct_has_field(data, "flag"), true)
testing.expect_value(t, struct_has_field(data, "name"), true)
testing.expect_value(t, struct_has_field(data, "missing"), false)
}
@(test)
test_validate_path_through_maybe_bool :: proc(t: ^testing.T) {
data := Maybe_Bool_Data {
name = "hi",
}
ctx := make([dynamic]any, 0, 1, context.temp_allocator)
append(&ctx, data)
// `flag` exists as a field even when its Maybe value is nil should NOT warn.
ok, missing, _ := validate_key_path(ctx[:], "flag")
testing.expect_value(t, ok, true)
testing.expect_value(t, missing, "")
}
@(test)
test_validate_map_path_silent :: proc(t: ^testing.T) {
data := Params_Data {
params = {"social" = "x"},
}
defer delete(data.params)
ctx := make([dynamic]any, 0, 1, context.temp_allocator)
append(&ctx, data)
// `params` exists and is a map subsequent segments are user-defined.
ok, _, _ := validate_key_path(ctx[:], "params.anything_here")
testing.expect_value(t, ok, true)
}
@(test)
test_suggest_correction_exact :: proc(t: ^testing.T) {
available := []string{"title", "page.title", "body"}
testing.expect_value(t, suggest_correction(available, "page_titel"), "page.title")
}
@(test)
test_suggest_correction_close :: proc(t: ^testing.T) {
available := []string{"title", "body", "now"}
testing.expect_value(t, suggest_correction(available, "titel"), "title")
}
@(test)
test_suggest_correction_no_match :: proc(t: ^testing.T) {
available := []string{"completely_different", "unrelated"}
testing.expect_value(t, suggest_correction(available, "page_titel"), "")
}
@(test)
test_suggest_correction_empty :: proc(t: ^testing.T) {
testing.expect_value(t, suggest_correction([]string{}, "anything"), "")
testing.expect_value(t, suggest_correction([]string{"a"}, ""), "")
}
@(test)
test_warn_no_false_positive_for_valid_keys :: proc(t: ^testing.T) {
Data :: struct {
name: string,
}
src := "Hello {{name}}"
tmpl, err := parse(src, "<test>", context.temp_allocator)
testing.expect(t, err == nil, "should parse")
if err != nil {
return
}
// We can't easily capture log output in tests, but we can verify the
// validation procs agree the key exists.
ctx := make([dynamic]any, 0, 1, context.temp_allocator)
append(&ctx, Data{name = "World"})
ok, missing, _ := validate_key_path(ctx[:], "name")
testing.expect_value(t, ok, true)
}
// --- json.Value map crossing tests ---
JSON_Params_Context :: struct {
params: json.Value,
}
@(test)
test_validate_key_path_crosses_json_value_map :: proc(t: ^testing.T) {
params_map := make(json.Object, context.temp_allocator)
params_map["author"] = json.String("Tester")
data := JSON_Params_Context { params = params_map }
ctx := make([dynamic]any, 0, 1, context.temp_allocator)
append(&ctx, data)
ok, missing, _ := validate_key_path(ctx[:], "params.starred")
testing.expect(t, ok, "path crossing json.Value map should be ok")
testing.expect(t, missing == "", "no missing segment for map crossing")
}
@(test)
test_validate_key_path_json_value_map_existing_key :: proc(t: ^testing.T) {
params_map := make(json.Object, context.temp_allocator)
params_map["author"] = json.String("Tester")
data := JSON_Params_Context { params = params_map }
ctx := make([dynamic]any, 0, 1, context.temp_allocator)
append(&ctx, data)
ok, missing, _ := validate_key_path(ctx[:], "params.author")
testing.expect(t, ok, "existing key in json.Value map should be ok")
}
@(test)
test_validate_key_path_map_typo :: proc(t: ^testing.T) {
params_map := make(json.Object, context.temp_allocator)
params_map["author"] = json.String("Tester")
params_map["social"] = json.String("")
data := JSON_Params_Context { params = params_map }
ctx := make([dynamic]any, 0, 1, context.temp_allocator)
append(&ctx, data)
ok, missing, available := validate_key_path(ctx[:], "params.authr")
testing.expect(t, !ok, "typo of map key should not be ok")
testing.expect_value(t, missing, "authr")
suggestion := suggest_correction(available, "authr")
testing.expect_value(t, suggestion, "author")
}
@(test)
test_validate_key_path_map_no_close_match :: proc(t: ^testing.T) {
params_map := make(json.Object, context.temp_allocator)
params_map["author"] = json.String("Tester")
params_map["social"] = json.String("")
data := JSON_Params_Context { params = params_map }
ctx := make([dynamic]any, 0, 1, context.temp_allocator)
append(&ctx, data)
ok, missing, _ := validate_key_path(ctx[:], "params.xyz")
testing.expect(t, ok, "unknown key with no close match should be ok (suppressed)")
testing.expect(t, missing == "", "no missing segment when suppressed")
}
+19 -14
View File
@@ -28,7 +28,7 @@ tokenize :: proc(
allocator := context.allocator,
) -> (
tokens: [dynamic]Token,
err: Render_Error,
err: Error,
) {
tokens = make([dynamic]Token, 0, 8, allocator)
@@ -36,7 +36,20 @@ tokenize :: proc(
text_start := 0
for i < len(src) {
if src[i] == '{' && i + 1 < len(src) && src[i + 1] == '{' {
// Find next '{' via AVX2/SSE-backed memchr instead of byte-by-byte scan.
rel := strings.index_byte(src[i:], '{')
if rel < 0 {
i = len(src)
break
}
next := i + rel
// Single '{' (not '{{') advance past it and keep scanning.
if next + 1 >= len(src) || src[next + 1] != '{' {
i = next + 1
continue
}
i = next
if i > text_start {
append(&tokens, Token{kind = .Text, value = src[text_start:i], pos = text_start})
}
@@ -47,9 +60,10 @@ tokenize :: proc(
content_start := i + 3
idx := strings.index(src[content_start:], "}}}")
if idx < 0 {
return tokens, Syntax_Error {
return tokens, Error_Body {
msg = "unclosed triple mustache '{{{'",
pos = tag_pos,
kind = .Syntax,
}
}
close := content_start + idx
@@ -90,7 +104,7 @@ tokenize :: proc(
close_idx := strings.index(src[key_start:], "}}")
if close_idx < 0 {
return tokens, Syntax_Error{msg = "unclosed tag '{{'", pos = tag_pos}
return tokens, Error_Body{msg = "unclosed tag '{{'", pos = tag_pos, kind = .Syntax}
}
close := key_start + close_idx
@@ -107,12 +121,7 @@ tokenize :: proc(
}
append(
&tokens,
Token {
kind = .Partial,
value = trimmed,
is_dynamic = is_dyn,
pos = tag_pos,
},
Token{kind = .Partial, value = trimmed, is_dynamic = is_dyn, pos = tag_pos},
)
} else {
append(
@@ -124,9 +133,6 @@ tokenize :: proc(
i = close + 2
text_start = i
}
} else {
i += 1
}
}
if i > text_start {
@@ -284,4 +290,3 @@ should_trim_whitespace :: proc(kind: Token_Kind) -> bool {
}
return false
}
+117
View File
@@ -0,0 +1,117 @@
package main
Open_Graph :: struct {
title: string,
type: string,
image: string,
url: string,
description: string,
locale: string,
site_name: string,
is_article: Maybe(bool),
published_time: string,
modified_time: string,
section: string,
}
og_for_site :: proc(site: ^Site) -> Open_Graph {
og := site.og
if og.site_name == "" {
og.site_name = site.title
}
if og.description == "" {
og.description = site.description
}
if og.locale == "" {
og.locale = "en_US"
}
return og
}
og_for_page :: proc(site_og: Open_Graph, page: Page) -> Open_Graph {
og := site_og
is_article := !page._is_index
if page.url != "" {
og.url = page.url
}
if page.title != "" {
og.title = strip_html_tags(page.title, context.temp_allocator)
} else {
og.title = og.site_name
}
og.type = "article" if is_article else "website"
og.is_article = is_article
if page.section != "" {
og.section = page.section
}
if is_article {
if page.date != "" {
og.published_time = page.date
}
if page.lastmod != "" {
og.modified_time = page.lastmod
} else if page.date != "" {
og.modified_time = page.date
}
}
// Description priority for articles:
// page.og.description > page.description > body summary > inherited
// For non-articles (home, section index), inherited site.og.description
// is the fallback (matches production behavior home inherits site
// description, section index is empty).
description_set := false
if page.og.description != "" {
og.description = page.og.description
description_set = true
}
if !description_set && page.description != "" {
og.description = page.description
description_set = true
}
if !description_set && is_article && page.content != "" {
og.description = generate_description(generate_summary(page.content))
description_set = true
}
if !description_set {
if page._is_index && page.section == "" {
// Home: keep inherited site.og.description.
} else {
og.description = ""
}
}
if page.og.title != "" {
og.title = page.og.title
}
if page.og.type != "" {
og.type = page.og.type
}
if page.og.image != "" {
og.image = page.og.image
}
if page.og.url != "" {
og.url = page.og.url
}
if page.og.locale != "" {
og.locale = page.og.locale
}
if page.og.site_name != "" {
og.site_name = page.og.site_name
}
if page.og.published_time != "" {
og.published_time = page.og.published_time
}
if page.og.modified_time != "" {
og.modified_time = page.og.modified_time
}
if page.og.section != "" {
og.section = page.og.section
}
if page.og.is_article != nil {
og.is_article = page.og.is_article
}
return og
}
+193 -240
View File
@@ -1,4 +1,3 @@
#+feature dynamic-literals
package main
import "mustache"
@@ -11,116 +10,54 @@ import "core:strings"
import "core:time"
import "core:time/datetime"
Page_Context :: struct {
permalink: string,
title: string,
starred: bool,
date_iso: string,
date_display: string,
}
Year_Section :: struct {
year: string,
posts: [dynamic]Page_Context,
}
Base_Data :: struct {
now: datetime.DateTime,
author: string,
Template_Context :: struct {
now: string,
date_format: string,
timezone: ^datetime.TZ_Region,
og: Open_Graph,
params: json.Value,
body: string,
title: string,
og_site_name: string,
og_description: string,
og_image: string,
og_url: string,
og_title: string,
og_type: string,
is_article: bool,
site: Site_Context,
menus: map[string][]Menu_Entry,
page: Page,
// Home data
pages: [dynamic]Page,
// Section Data
// TODO: Remove "posts" from the Odin code
posts: [dynamic]Page,
}
Page_Data :: struct {
using base: Base_Data,
page_title: string,
date_iso: string,
date_display: string,
is_post: bool,
og_section: string,
og_published: string,
}
Home_Data :: struct {
using base: Base_Data,
list_pages: [dynamic]Page_Context,
}
Section_Data :: struct {
using base: Base_Data,
page_title: string,
year_sections: [dynamic]Year_Section,
}
build_page_context :: proc(page: Page) -> Page_Context {
return Page_Context {
permalink = page.permalink,
title = page.title,
starred = page.is_starred,
date_iso = page.date,
date_display = format_date(page.date),
load_template :: proc(vfs: ^VFS, virtual_path: string) -> mustache.Template {
entry, data, ok := vfs_get_entry(vfs, virtual_path)
if !ok {
log.fatalf("template %s not found", virtual_path)
os.exit(1)
}
}
strip_html_tags :: proc(s: string) -> string {
sb := strings.builder_make()
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)
}
og_type :: proc(is_article: bool) -> string {
if is_article {
return "article"
}
return "website"
}
load_template :: proc(layouts_dir: string, name: string) -> mustache.Template {
data, _ := os.read_entire_file_from_path(
fmt.tprintf("%s/%s", layouts_dir, name),
context.allocator,
)
tpl, err := mustache.parse(string(data))
source := string(data)
tpl, err := mustache.parse(source, entry.fs_path)
if err != nil {
log.warnf("thor: failed to parse template %s: %v", name, err)
b := mustache.body(err)
log.errorf(
"%s",
mustache.format_error(
entry.fs_path,
source,
b.pos,
b.msg,
colorize = mustache.should_colorize(),
),
)
os.exit(1)
}
return tpl
}
get_template :: proc(
layouts_dir: string,
vfs: ^VFS,
layout: string,
cache: ^map[string]mustache.Template,
) -> mustache.Template {
// Build fallback chain: <layout> [section_index] page base
chain: [4]string
n := 0
chain[n] = layout; n += 1
@@ -134,71 +71,115 @@ get_template :: proc(
chain[n] = "base"; n += 1
}
for i in 0..<n {
for i in 0 ..< n {
candidate := chain[i]
if cached, ok := cache[candidate]; ok {
return cached
}
filename := fmt.tprintf("%s.html", candidate)
if os.exists(fmt.tprintf("%s/%s", layouts_dir, filename)) {
tpl := load_template(layouts_dir, filename)
virtual := fmt.tprintf("layouts/%s.html", candidate)
if _, ok := vfs_get(vfs, virtual); ok {
tpl := load_template(vfs, virtual)
cache[candidate] = tpl
return tpl
}
if candidate != chain[n - 1] {
log.warnf("thor: template %s not found, falling back", filename)
log.debugf("template %s not found, falling back", virtual)
}
}
log.errorf("thor: base.html not found in %s", layouts_dir)
log.errorf("base.html not found in VFS")
return mustache.Template{}
}
capitalize :: proc(s: string) -> string {
to_title_case :: proc(s: string, allocator := context.allocator) -> string {
if len(s) == 0 {
return s
}
if s[0] >= 'a' && s[0] <= 'z' {
return fmt.aprintf("%c%s", s[0] - 32, s[1:])
out := transmute([]byte)strings.clone(s, allocator)
capitalize_next := true
for char, i in s {
switch char {
case '-', '_':
out[i] = ' '
fallthrough
case ' ':
capitalize_next = true
case 'a' ..= 'z':
if capitalize_next {
out[i] = u8(char) - 32
}
return s
fallthrough
case:
capitalize_next = false
}
}
return string(out)
}
merge_params :: proc(site, page: json.Value) -> json.Value {
if page == nil do return site
if site == nil do return page
site_obj, ok1 := site.(json.Object)
page_obj, ok2 := page.(json.Object)
if !ok1 do return page
if !ok2 do return site
merged := make(json.Object, len(site_obj) + len(page_obj), context.temp_allocator)
for k, v in site_obj {merged[k] = v}
for k, v in page_obj {merged[k] = v}
return merged
}
render_template :: proc(
content_tpl: mustache.Template,
data: any,
ctx: Template_Context,
partials: map[string]mustache.Template,
reported_errors: ^map[string]bool,
) -> string {
result, err := mustache.render(content_tpl, data, partials)
result, err := mustache.render(content_tpl, []any{ctx.site, ctx.page, ctx}, partials)
if err != nil {
fmt.eprintfln("thor: mustache error: %v", err)
formatted := mustache.format_render_error(
err,
content_tpl,
colorize = mustache.should_colorize(),
)
if formatted in reported_errors^ {
return ""
}
reported_errors[formatted] = true
log.errorf("%s", formatted)
return ""
}
return result
}
render_site :: proc(site: ^Site) {
allocator := site_allocator(site)
pages := site.pages[:]
sort_pages_by_date(pages)
sort_pages(pages)
// Load shared resources
partials := load_partials(site.layouts_dir)
partials["base"] = load_template(site.layouts_dir, "base.html")
partials := load_partials(&site.vfs)
partials["base"] = load_template(&site.vfs, "layouts/base.html")
template_cache: map[string]mustache.Template
defer delete(template_cache)
now, ok := time.time_to_datetime(time.now())
assert(ok)
errors := make(map[string]bool, context.temp_allocator)
// Build base data once
base := Base_Data {
offset, ok := mustache.compute_utc_offset(site.tz)
assert(ok)
now, ok2 := time.time_to_rfc3339(time.now(), offset, false, allocator)
assert(ok2)
ctx := Template_Context {
site = site.site_context,
menus = site.menus,
now = now,
author = site.author,
params = site.params,
og_site_name = site.title,
og_description = site.description,
og_image = fmt.tprintf("%s/avatar.jpg", site.base_url),
og = site.og,
date_format = site.date.format,
timezone = site.tz,
}
// Find home page
@@ -211,6 +192,7 @@ render_site :: proc(site: ^Site) {
break
}
}
ctx.page = home
// Collect sections
sections := make(map[string]bool)
@@ -226,8 +208,8 @@ render_site :: proc(site: ^Site) {
if page._is_index {
continue
}
tpl := get_template(site.layouts_dir, page.layout, &template_cache)
html := render_page_html(page, site, tpl, partials, base)
tpl := get_template(&site.vfs, page.layout, &template_cache)
html := render_page_html(page, site, tpl, partials, ctx, &errors)
if .Minify in site.features {
html = minify_html(html)
}
@@ -247,7 +229,7 @@ render_site :: proc(site: ^Site) {
}
layout := fmt.tprintf("%s_index", section)
section_tpl := get_template(site.layouts_dir, layout, &template_cache)
section_tpl := get_template(&site.vfs, layout, &template_cache)
html := render_section(
site,
section,
@@ -255,7 +237,8 @@ render_site :: proc(site: ^Site) {
has_section_index,
section_tpl,
partials,
base,
ctx,
&errors,
)
if .Minify in site.features {
html = minify_html(html)
@@ -265,8 +248,8 @@ render_site :: proc(site: ^Site) {
// Render home page
if has_home {
home_tpl := get_template(site.layouts_dir, "home", &template_cache)
home_html := render_home_html(home, site, home_tpl, partials, base)
home_tpl := get_template(&site.vfs, "home", &template_cache)
home_html := render_home_html(home, site, home_tpl, partials, ctx, &errors)
if .Minify in site.features {
home_html = minify_html(home_html)
}
@@ -282,7 +265,7 @@ render_site :: proc(site: ^Site) {
write_file(fmt.tprintf("%s/sitemap.xml", site.output_dir), sitemap)
// Copy and optionally minify assets directory
copy_assets_dir(site.assets_dir, site.output_dir, site.features)
copy_assets_dir(&site.vfs, site.output_dir, site.features)
// Generate robots.txt
robots := fmt.aprintf("User-agent: *\nAllow: /\nSitemap: %s/sitemap.xml\n", site.base_url)
@@ -292,7 +275,7 @@ render_site :: proc(site: ^Site) {
if !has_home {
total += 1
}
fmt.printfln("Rendered %d pages to %s", total, site.output_dir)
log.infof("Rendered %d pages to %s", total, site.output_dir)
}
render_page_html :: proc(
@@ -300,25 +283,14 @@ render_page_html :: proc(
site: ^Site,
content_tpl: mustache.Template,
partials: map[string]mustache.Template,
base: Base_Data,
ctx: Template_Context,
seen: ^map[string]bool,
) -> string {
is_article := page.section != ""
data := Page_Data {
base = base,
}
data.title = fmt.tprintf("%s | %s", page.title, site.title)
data.page_title = page.title
data.body = page.body_html
data.date_iso = page.date
data.date_display = format_date(page.date)
data.is_post = is_article
data.og_url = fmt.tprintf("%s%s", site.base_url, page.permalink)
data.og_title = strip_html_tags(page.title)
data.og_type = og_type(is_article)
data.is_article = is_article
data.og_section = page.section
data.og_published = page.date
return render_template(content_tpl, data, partials)
ctx := ctx
ctx.page = page
ctx.og = og_for_page(site.og, page)
ctx.params = merge_params(site.params, page.params)
return render_template(content_tpl, ctx, partials, seen)
}
render_home_html :: proc(
@@ -326,28 +298,23 @@ render_home_html :: proc(
site: ^Site,
content_tpl: mustache.Template,
partials: map[string]mustache.Template,
base: Base_Data,
ctx: Template_Context,
seen: ^map[string]bool,
) -> string {
list_pages := make([dynamic]Page_Context)
defer delete(list_pages)
list_pages := make([dynamic]Page, 0, 8, context.temp_allocator)
for page in site.pages {
if page._is_index {
continue
}
append(&list_pages, build_page_context(page))
append(&list_pages, page)
}
data := Home_Data {
base = base,
}
data.title = site.title
data.body = home.body_html
data.list_pages = list_pages
data.og_url = fmt.tprintf("%s/", site.base_url)
data.og_title = site.title
data.og_type = "website"
data.is_article = false
return render_template(content_tpl, data, partials)
ctx := ctx
ctx.pages = list_pages
ctx.og = og_for_page(site.og, home)
ctx.params = merge_params(site.params, home.params)
return render_template(content_tpl, ctx, partials, seen)
}
render_section :: proc(
@@ -357,97 +324,72 @@ render_section :: proc(
has_index: bool,
content_tpl: mustache.Template,
partials: map[string]mustache.Template,
base: Base_Data,
ctx: Template_Context,
seen: ^map[string]bool,
) -> string {
year_sections := make([dynamic]Year_Section)
defer delete(year_sections)
current_year := ""
alloc := site_allocator(site)
posts := make([dynamic]Page, 0, len(site.pages) / 2, context.temp_allocator)
for page in site.pages {
if page.section != section || page._is_index {
continue
}
year := get_year(page.date)
if year != current_year {
append(&year_sections, Year_Section{year = year})
current_year = year
}
append(&year_sections[len(year_sections) - 1].posts, build_page_context(page))
append(&posts, page)
}
data := Section_Data {
base = base,
}
ctx := ctx
if has_index {
data.body = section_index.body_html
data.page_title = section_index.title
data.title = fmt.tprintf("%s | %s", section_index.title, site.title)
data.og_title = section_index.title
ctx.page = section_index
ctx.og = og_for_page(site.og, section_index)
} else {
data.page_title = capitalize(section)
data.title = fmt.tprintf("%s | %s", capitalize(section), site.title)
data.og_title = capitalize(section)
title := to_title_case(section, alloc)
ctx.page = Page {
title = title,
}
data.year_sections = year_sections
data.og_url = fmt.tprintf("%s/%s/", site.base_url, section)
data.og_type = "website"
data.is_article = false
return render_template(content_tpl, data, partials)
ctx.og.title = title
ctx.og.description = ""
ctx.og.url = fmt.tprintf("%s/%s/", site.base_url, section)
ctx.og.type = "website"
ctx.og.is_article = false
}
ctx.posts = posts
ctx.params = merge_params(site.params, ctx.page.params)
return render_template(content_tpl, ctx, partials, seen)
}
load_partials :: proc(layouts_dir: string) -> map[string]mustache.Template {
load_partials :: proc(vfs: ^VFS) -> map[string]mustache.Template {
partials: map[string]mustache.Template
partials_dir := fmt.tprintf("%s/partials", layouts_dir)
load_partials_recursive(&partials, partials_dir, "")
return partials
}
load_partials_recursive :: proc(
partials: ^map[string]mustache.Template,
base_dir: string,
rel_prefix: string,
) {
entries, err := os.read_all_directory_by_path(base_dir, context.allocator)
if err != nil {
return
}
defer os.file_info_slice_delete(entries, context.allocator)
for entry in entries {
#partial switch entry.type {
case .Regular:
name := entry.name
if !strings.has_suffix(name, ".html") {
prefix := "layouts/partials/"
for virtual_path, entry in vfs.files {
if !strings.has_prefix(virtual_path, prefix) {
continue
}
stripped := name[:len(name) - len(".html")]
key := stripped
if rel_prefix != "" {
key = fmt.tprintf("%s/%s", rel_prefix, stripped)
if !strings.has_suffix(virtual_path, ".html") {
continue
}
data, ok := os.read_entire_file_from_path(entry.fullpath, context.allocator)
if ok == nil {
tpl, perr := mustache.parse(string(data))
if perr != nil {
log.warnf("thor: failed to parse partial %s: %v", key, perr)
stripped := virtual_path[len(prefix):]
key := stripped[:len(stripped) - len(".html")]
data := vfs_entry_data(entry) or_continue
source := string(data)
tpl, err := mustache.parse(source, entry.fs_path)
if err != nil {
b := mustache.body(err)
log.errorf(
"%s",
mustache.format_error(
entry.fs_path,
source,
b.pos,
b.msg,
colorize = mustache.should_colorize(),
),
)
os.exit(1)
}
partials[key] = tpl
}
case .Directory:
sub_prefix := entry.name
if rel_prefix != "" {
sub_prefix = fmt.tprintf("%s/%s", rel_prefix, entry.name)
}
load_partials_recursive(partials, entry.fullpath, sub_prefix)
}
}
}
format_date :: proc(iso: string) -> string {
if len(iso) < 10 {
return ""
}
date, _, _, _ := time.iso8601_to_components(iso)
return fmt.aprintf("%s %d, %d", time.Month(date.month), date.day, date.year)
return partials
}
get_year :: proc(iso: string) -> string {
@@ -457,11 +399,12 @@ get_year :: proc(iso: string) -> string {
return iso[:4]
}
sort_pages_by_date :: proc(pages: []Page) {
// Weight primary (ascending). Date secondary (descending) for equal weights.
sort_pages :: proc(pages: #soa[]Page) {
for i in 1 ..< len(pages) {
key := pages[i]
j := i - 1
for j >= 0 && pages[j].date < key.date {
for j >= 0 && compare_pages(pages, j, key) > 0 {
pages[j + 1] = pages[j]
j -= 1
}
@@ -469,6 +412,16 @@ sort_pages_by_date :: proc(pages: []Page) {
}
}
compare_pages :: proc(pages: #soa[]Page, j: int, key: Page) -> int {
wj := pages.weight[j].? or_else DEFAULT_WEIGHT
wk := key.weight.? or_else DEFAULT_WEIGHT
if wj != wk do return wj - wk
// Equal weight date descending
if pages.date[j] < key.date do return 1
if pages.date[j] > key.date do return -1
return 0
}
write_page :: proc(output_dir: string, permalink: string, html: string) {
rel := permalink
if len(rel) > 0 && rel[0] == '/' {
@@ -477,7 +430,7 @@ write_page :: proc(output_dir: string, permalink: string, html: string) {
dir := fmt.tprintf("%s/%s", output_dir, rel)
if err := os.make_directory_all(dir); err != nil && err != .Exist {
fmt.eprintfln("thor: cannot create %s: %v", dir, err)
log.errorf("cannot create %s: %v", dir, err)
return
}
@@ -487,7 +440,7 @@ write_page :: proc(output_dir: string, permalink: string, html: string) {
write_file :: proc(path: string, html: string) {
if err := os.write_entire_file_from_string(path, html); err != nil {
fmt.eprintfln("thor: cannot write %s: %v", path, err)
log.errorf("cannot write %s: %v", path, err)
}
}
+101 -80
View File
@@ -1,30 +1,52 @@
package main
import "core:encoding/json"
import "core:flags"
import "core:fmt"
import "core:log"
import "core:mem"
import "core:os"
import "core:strings"
import "core:time/datetime"
import "core:time/timezone"
import md "markdown"
// Site is the primary workhorse.
Site :: struct {
arena: mem.Dynamic_Arena,
pages: [dynamic]Page,
// Site_Context holds the site date that is accessible in templates.
Site_Context :: struct {
title: string,
description: string,
author: string,
base_url: string,
params: json.Object,
og: Open_Graph,
menus: map[string][]Menu_Entry,
}
// Site is the primary workhorse, containing everything needed to build the site,
// including an arena allocator, the pages, and all the various directories and
// enabled features.
Site :: struct {
using site_context: Site_Context,
arena: mem.Dynamic_Arena,
pages: #soa[dynamic]Page,
modules: [dynamic]string,
vfs: VFS,
config_path: string,
content_dir: string,
assets_dir: string,
output_dir: string,
layouts_dir: string,
params: json.Object,
features: bit_set[Feature],
markdown_extensions: bit_set[Markdown_Extension],
markdown_extensions: bit_set[md.Extension],
date: Date_Preferences,
tz: ^datetime.TZ_Region,
grammars: string,
queries: string,
}
Date_Preferences :: struct {
format: string,
timezone: string,
}
Feature :: enum {
@@ -33,35 +55,24 @@ Feature :: enum {
Watch,
}
Markdown_Extension :: enum {
Emoji,
Sidenotes,
Alerts,
Highlight,
Sections,
}
DEFAULT_MARKDOWN_EXTENSIONS :: bit_set[Markdown_Extension] {
.Emoji,
.Sidenotes,
.Alerts,
// .Highlight,
// .Sections,
}
// Configuration loaded from `thor.json`. Gets folded in to Site before
// Flags
Config_File :: struct {
title: string,
description: string,
base_url: string,
author: string,
content_dir: string,
assets_dir: string,
output_dir: string,
layouts_dir: string,
markdown_extensions: json.Value,
params: json.Value,
modules: json.Value,
menus: json.Value,
og: Open_Graph,
date: Date_Preferences,
grammars: string,
queries: string,
}
// Configuration loaded from command line arguments. Gets folded in to Site
@@ -72,30 +83,30 @@ Flags :: struct {
content_dir: string `args:"name=content" usage:"Path to content directory"`,
assets_dir: string `args:"name=assets" usage:"Path to assets directory (CSS, JS, fonts, images)"`,
output_dir: string `args:"name=output" usage:"Path to output directory (default: public/)"`,
layouts_dir: string `args:"name=layouts" usage:"Path to layouts directory"`,
drafts: bool `args:"name=drafts" usage:"Include draft pages in the build"`,
watch: bool `usage:"Rebuild on file changes (polls every 5 seconds)"`,
minify: bool `args:"name=minify" usage:"Minify HTML output and CSS assets"`,
verbose: bool `usage:"Enable debug logging"`,
quiet: bool `usage:"Suppress info logging (warnings and errors only)"`,
md_enable: string `args:"name=ext" usage:"Enable markdown extensions (comma-separated: emoji,sidenotes,alerts,highlight,sections)"`,
md_disable: string `args:"name=no-ext" usage:"Disable markdown extensions (comma-separated: emoji,sidenotes,alerts,highlight,sections)"`,
}
init_site :: proc(site: ^Site, args: []string) {
mem.dynamic_arena_init(&site.arena, alignment = 64)
init_site :: proc(site: ^Site, flags: Flags) {
mem.dynamic_arena_init(&site.arena)
alloc := site_allocator(site)
// Set defaults
site.base_url = "http://localhost:8080"
site.markdown_extensions = DEFAULT_MARKDOWN_EXTENSIONS
site.markdown_extensions = md.DEFAULT_EXTENSIONS
_flags: Flags
flags.parse_or_exit(&_flags, args, .Odin, alloc)
path := _flags.config_path
path := flags.config_path
if path == "" {
found, ok := find_config("thor.json")
if ok {
path = found
log.debugf("thor: using config %s", path)
log.debugf("using config %s", path)
} else {
path = "./thor.json"
}
@@ -115,8 +126,25 @@ init_site :: proc(site: ^Site, args: []string) {
site_apply_path_defaults(site, config_dir)
}
site_apply_cli_flags(site, _flags)
site_apply_cli_flags(site, flags)
site.config_path = path
site.og = og_for_site(site)
tz_name := site.date.timezone
if tz_name == "" {
log.warnf("no timezone configured, falling back to local system timezone")
tz_name = "local"
}
tz, tz_ok := timezone.region_load(tz_name, alloc)
if tz_ok {
site.tz = tz
if site.date.timezone == "" {
log.debugf("detected local timezone: %s", tz.name)
}
} else if site.date.timezone != "" {
log.warnf("unable to load timezone '%s'", site.date.timezone)
}
}
load_config_file :: proc(
@@ -131,7 +159,7 @@ load_config_file :: proc(
unmarshal_err := json.unmarshal_string(string(data), config, allocator = allocator)
if unmarshal_err != nil {
log.warnf("thor: failed to parse %s: %v", path, unmarshal_err)
log.warnf("failed to parse %s: %v", path, unmarshal_err)
return false
}
@@ -141,7 +169,6 @@ load_config_file :: proc(
site_apply_config :: proc(site: ^Site, config: Config_File, config_dir: string) {
if config.title != "" do site.title = config.title
if config.description != "" do site.description = config.description
if config.author != "" do site.author = config.author
if config.base_url != "" do site.base_url = config.base_url
site.content_dir =
@@ -159,8 +186,41 @@ site_apply_config :: proc(site: ^Site, config: Config_File, config_dir: string)
// Apply markdown extensions from config
if ext_obj, ok := config.markdown_extensions.(json.Object); ok {
apply_extension_config(&site.markdown_extensions, ext_obj)
md.apply_extension_config(&site.markdown_extensions, ext_obj)
}
if modules_arr, ok := config.modules.(json.Array); ok {
for &item in modules_arr {
if s, ok2 := item.(json.String); ok2 {
append(&site.modules, fmt.tprintf("%s/%s", config_dir, s))
}
}
}
site.og = config.og
site.date = config.date
// Parse config menus if present (nil = absent, non-nil = present)
if config.menus != nil {
site.menus = parse_config_menus(config.menus, site_allocator(site))
if site.menus == nil {
// Present but empty ({}) explicit opt-out
site.menus = make(map[string][]Menu_Entry, site_allocator(site))
}
}
site.grammars = expand_path(config.grammars, site_allocator(site))
site.queries = expand_path(config.queries, site_allocator(site))
}
// expand_path replaces a leading ~/ with $HOME/.
expand_path :: proc(path: string, allocator := context.allocator) -> string {
if len(path) >= 2 && path[0] == '~' && path[1] == '/' {
if home := os.get_env_alloc("HOME", allocator); home != "" {
return fmt.aprintf("%s%s", home, path[1:])
}
}
return strings.clone(path, allocator)
}
site_apply_path_defaults :: proc(site: ^Site, config_dir: string) {
@@ -175,53 +235,15 @@ site_apply_cli_flags :: proc(site: ^Site, flags: Flags) {
if flags.content_dir != "" do site.content_dir = flags.content_dir
if flags.assets_dir != "" do site.assets_dir = flags.assets_dir
if flags.output_dir != "" do site.output_dir = flags.output_dir
if flags.layouts_dir != "" do site.layouts_dir = flags.layouts_dir
if flags.drafts {site.features += {.Drafts}}
if flags.watch {site.features += {.Watch}}
if flags.minify {site.features += {.Minify}}
site.markdown_extensions += parse_extension_list(flags.md_enable)
site.markdown_extensions -= parse_extension_list(flags.md_disable)
}
parse_extension_list :: proc(s: string) -> bit_set[Markdown_Extension] {
result: bit_set[Markdown_Extension]
if s == "" do return result
for part in strings.split(s, ",", allocator = context.temp_allocator) {
name := strings.to_lower(strings.trim_space(part), allocator = context.temp_allocator)
switch name {
case "emoji":
result += {.Emoji}
case "sidenotes":
result += {.Sidenotes}
case "alerts":
result += {.Alerts}
case "highlight":
result += {.Highlight}
case "sections":
result += {.Sections}
}
}
return result
}
apply_extension_config :: proc(ext: ^bit_set[Markdown_Extension], config: json.Object) {
for name, val in config {
enabled, is_bool := val.(json.Boolean)
if !is_bool do continue
switch name {
case "emoji":
if enabled {ext^ += {.Emoji}} else {ext^ -= {.Emoji}}
case "sidenotes":
if enabled {ext^ += {.Sidenotes}} else {ext^ -= {.Sidenotes}}
case "alerts":
if enabled {ext^ += {.Alerts}} else {ext^ -= {.Alerts}}
case "highlight":
if enabled {ext^ += {.Highlight}} else {ext^ -= {.Highlight}}
case "sections":
if enabled {ext^ += {.Sections}} else {ext^ -= {.Sections}}
}
}
site.markdown_extensions += md.parse_extension_list(flags.md_enable)
site.markdown_extensions -= md.parse_extension_list(flags.md_disable)
md.resolve_extension_conflicts(&site.markdown_extensions)
}
site_allocator :: proc(site: ^Site) -> mem.Allocator {
@@ -250,4 +272,3 @@ find_config :: proc(filename: string) -> (path: string, ok: bool) {
dir = dir[:idx]
}
}
+1
View File
@@ -0,0 +1 @@
public
+24
View File
@@ -0,0 +1,24 @@
# Docs Site AGENTS.md
## DO NOT EDIT — BY HUMANS, FOR HUMANS
All content under `thor/site/` is handwritten by humans. No AI, agent,
bot, or assistant may edit, rewrite, or generate any file here. AI may
be consulted as a sanity check, but the prose stays human.
## Implicit limits
The mustache engine imposes limits that template authors should be aware of.
These values are defined in `thor/mustache/pipes.odin`, and `thor/mustache/mustache.odin`
and must be kept in sync with the source code if they ever change.
- **Nested partials** — deeply nested partial chains (partial-in-partial)
consume context stack frames during rendering. The limit is defined by
`MAX_CONTEXT_STACK` (16) in the mustache engine. In practice, 34 levels of
nesting is typical and safe.
- **`MAX_PIPES` (8)** — a single tag may chain up to 8 pipe filters:
`{{key | op1 | op2 | ... | op8}}`. Exceeding this is a parse error.
- **`MAX_PIPE_ARGS` (2)** — each pipe filter accepts at most 2 arguments:
`{{key | op arg1 arg2}}`.
+38
View File
@@ -0,0 +1,38 @@
[TOC]
Thor is a simple Static Sire Generator designed for personal blogs and other small websites.
Its core principals are simplicity and minimal configuration, so you can get started as quickly as possible.
It is based on Hugo, and gingerbill's SSG. Templating is done with (extended?) Mustache templates.
## What it does
- Syntax Highlighting server-side (with Tree Sitter) or client-side with highlight.js
- Mustache Templating
- OpenGraph Tags
- Menus (WIP)
- Extended Markdown ([See below](#extended-markdown))
- Basic (whitespace) minification.
## What it doesn't do
- Internationalization
- Pagination (Yet)
- Themes
- Union File System (Yet)
- Image Manipulation
- TailwindCSS integration
## Getting Started
Run `thor`
Check out `public/index.html`
Then follow [The Guide]()
For a more complete setup, run `thor new site`.
## Extended Markdown
- Emoji expansion
- margin style footnotes
- Guthub style alerts
- [and more]
+12
View File
@@ -0,0 +1,12 @@
{
"title": "AI",
"date": "2026-07-28T12:16:00-04:00"
}
## Prose
This site, and all the documentation on it was written for humans, by humans.
AI gets consulted as a sanity check, i.e. "Did I miss anything here?", "Does the documentation match the code's behaviour?", etc. All prose is hand-written by a human being.
## Code
The code is a completely different story. The majority of code is written with GLM-4.2 via OpenCode under programmer direction. I focus on architectural decisions, and I read the code emitted by the AI with varying degrees of scrutiny based on whether it touches a major part of the architecture, is in a hotpath, or holds some other high level of significance.
+384
View File
@@ -0,0 +1,384 @@
{
"title": "Docs",
"date": "2026-07-22T08:54:00-04:00",
"toc": true
}
## Introduction
This guide assumes you have either read [The Guide](../guide), or have built a [Hugo](https://gohugo.io) site before. It also assumes you have a basic knowledge of HTML and CSS.
## Features
Thor has many features and content processors available. In an effort to provide the best out of the box experience, most of them are enabled by default.
### Opt-In Features
TODO: #### deflist syntax
TODO: #### footnotes
#### Minify
If enabled, `minify` will perform simple whitespace removal on all your output `.html` files, and any `.css` files in your `assets` directories. Minifying JavaScript is not supported (yet).
TODO: Do we minify inline css?
### Opt-Out Features
- emoji
- sidenotes/marginnotes
- syntax highlighting
- heading ids
- TODO: Table Of Contents Generation
- [GitHub style alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts)
## Directories
Like Hugo, a Thor project is a collection of specially named directories, plus an optional config file. All directories are optional, but it is recommended to at least have a `content` directory.
content
: `content` holds your pages and page bundles. See [Content](#content). If not found, thor will look for content files in the root of your current working directory.
assets
: `assets` contains any static files for your site (favicon.ico, etc.), as well as files you want to send through the asset pipeline (CSS or JS files).
layouts
: `layouts` holds your templates and partials. See [Templates](#templates).
public
: `public` will contain your completed site.
All of these names can be remapped in `thor.json`.[^remap]
[^remap]: While directories can be remapped at the site level, modules must (currently) adhere to the defaults.
### Assets
Currently, there is only one asset processor, and that is [the minifier](#minify), though more are planned (i.e. Image processing).
TODO: Expand
## Content
### Pages & Page Bundles
Page content can either be defined in a single file (`contact.md`), or in a directory (`contact/index.md` + `contact/our-team.jpg`). Single file pages are preferred to page bundles.[^1]
[^1]: That's not you say you shouldn't use bundles, but if you have no additional resources on your page, there's no benefit to using a bundle.
Thor currently supports 2 formats for page files: MarkDown (`.md`), and HTML (`.html`).
TODO: Content
### Frontmatter
TODO: Frontmatter
## Menus
TODO: Describe
TODO: Don't forget to highlight differences from Hugo.
## Templates
Sites are built using one or more template files written in an extended version of [mustache](https://mustache.github.io) templates. The [mustache manual](https://mustache.github.io/mustache.5.html) has great explainations and a lot of examples if you want to know more, but I'll summarize them for you here.
The beauty of Mustache is that there is very little syntax; there are just 10 symbols you need learn: `{{`, `{{&`, `{{^`, `{{>`, `{{<`, `{{#`, `/}}`, `{{$`, `{{!`, and `|`.
TODO: ^^ Badly worded sentence ^^
TODOS: Gotta describe base templates somewhere. (the same way we describe the partials)
### Tags
#### Variables
In order to display a scalar (not-list) value in your template, simply wrap it in double curly braces. e.g. `{{ page.title }}`.
This content will be HTML escaped (for safety), so if the value you're rendering contains HTML, you'll need to use the raw syntex instead `{{& page.title}}`[^raw] which will output the value without stripping or re-writing content.
[^raw]: Official Triple brace syntax (`{{{ raw }}}`) is also supported, but `{{& raw }}` is preferred, as it's easy to accidentily insert too many braces.
In most[^most] cases, invalid keys will be silently ignored (nothing between the braces will appear), in keeping with the official mustache spec.
[^most]: TODO: in what cases won't it? spelllcheck + strict mode?
Leading and trailing whitespace(s) are ignored by the parser, so the folllowing are all equivilent: `{{& title }}`, `{{&title}}`, `{{& title}}`.
#### Sections
TODO:
#### Inverted Sections
TODO:
#### Partials
TODO:
TODO: Talk about MAX_CONTEXT_DEPTH (currently 16) and how it limits the total number of nested templates to 13. (3 for `[site, page, ctx]`)
##### Dynamic Partials
#### Blocks
TODO:
#### Parents
TODO:
#### Summary
`{{page.title}}` for normal values
`{{&page.title}}` for values that contain HTML.
`<ul>{{#pages}}<li>{{title}}</li>{{/pages}}</ul>` for list values.
`{{#params.is_starred}}<i class="fas fa-star"></i>{{/params.is_starred}}` for conditional content.
`{{^pages}}No pages yet!{{/pages}}` to draw content when the value is empty.
### Section Names
When building your own templates, you are of course free to pick whatever names you choose for your partials and content slots. However, sticking to conventions helps create consistency in the ecosystem, and reduces friction when relying on built-in templates.
`{{$main}}...{{/main}}`
: the `main` section should be used in templates to denote the part of the page that is unique to that page. For the most part, your templates should look like this
```mustache
{{<base}}
{{$main}}
<main>
<h1>Actual page content goes here</h1>
{{&page.content}}
</main>
{{/main}}
{{/base}}
```
### Context
When building your page(s), the following keys are accessible to your template files:
`now`
: The Current `DateTime`. see [DateTime](#datetimes)
`date_format`
: The default format to use for dates. Configured in `thor.json:date.format`.
`timezone`
: The timezone to convert dates to when using `{{ date | format }}`. Configured in `thor.json:date.timezone`.
`site`
: The site parameters that are usable in templates, see [site](#site).
`pages`
: Returns all regular pages, sorted by `?`. Regular pages exclude index pages like home and section roots.
`posts`
: TODO: Section groupings
`og`
: Contains the [Open Graph](https://ogp.me/) metadata for the current page.
`menus`
: The site's constructed menus. See [menus](#menus).
#### Page
`page.content`
: The HTML rendered page content
TODO: write
#### Site
TODO: Write
#### Params
`site.params` and `page.params` are an escape hatch to let users inject arbetrary data into their website. The following are some conventional examples theme developers may want to use to ensure a consistent experience across themes.
`author`
: The author of the current page or site. See [schema.org](https://schema.org/author) for the recommended format.
`stylesheets`
: A list of paths to css files the users wants to include globally. These are rendered by the `{{> styles }}` partial, and can be omitted on sites that use custom templates.
TODO: ^^ Bad sentence? ^^
#### The Context Stack
When building your page(s), each template is fed a Context[^ctx] stack that contains all the data should you need to build your page.
[^ctx]: (for developers) `Template_Context` is not the same as Odin's implicit `context` parameter.
The context stack is initialized[^init] as `[site, page, context]`, meaning if you use a key like `{{title}}` in your template, it will look up `context.title`, `page.title`, and then finally `site.title`, using the first available value it can find.
[^init]: TODO: Don't use programmer speak.
TODO: As you descend into sections/partials, new contexts are placed onto the stack, so they resolve first. (LIFO order)
```mustache
{{<base}}
{{$main}}
<main>
{{&page.content}}
<!-- Is the same as -->
{{&content}}
<!-- Or -->
{{$page}}{{&content}}{{/page}}
<!-- Or -->
{{$page.content}}{{&.}}{{/page.content}}
</main>
{{/main}}
{{/base}}
```
### Filters
Because mustache is a logic-less language, (there are no `for` or `if` tags), Thor extends mustache to include a handful of data filters in the form of pipes. Bash users will feel right at home here.
`group_by <field>`
: Allows you to group pages by the given field. e.g.
```mustache
{{#pages | group_by year }}
<section>
<header>{{$key}} {{! The group's year}}</header>
<ul>
{{#items}}<li>
{{title}} {{! The title of each of that year's pages }}
{{/items}}</li>
</ul>
</section>
{{/pages}}
```
`sort_by <field> <asc|desc>`
: Allows you to sort pages by the given field.
`first <n>`
: Given a list, returns only the first `n` items. `n` defaults to 1. Given a string, returns the first `n` runes of the string. To help catch mistakes, `n` is required for strings.
`last <n>`
: Given a list, returns only the last `n` items. `n` defaults to 1. Given a string, returns the first `n` runes of the string. To help catch mistakes, `n` is required for strings.
`format "<format string>"`
: Used to display a date in a particular format. See [DateTimes](#datetimes). If no format is given, the default will be used.
#### Chaining Pipes
Thor allows you to chain two or more pipes, to allow complex data manipulation. However for performance and stylistic reasons, you are limited to no more than **8 pipes**[^pipes] for any given tag. If you believe you need more than 8 pipes, please [open an issue](../issues) with a **concrete example** of the problem you are facing.
[^pipes]: TODO: This number must be kept in-sync with `MAX_PIPES`.
**Examples:**
```mustache
{{ pages | group_by year | first }} {{! All pages from the current year }}
```
```mustache
{{ pages | sort_by date desc | first }} {{! The latest page }}
```
### Partials
Partials are templates that render a portion of a page. To include a partial, the standard [mustache syntax](https://mustache.github.io/mustache.5.html#Partials) is used. All partials are resolved relative to the root partial's directory, so to include a partial at `layouts/partials/my_partial.html`, you would use `{{> my_partial}}`.
Users can create or override as many partials as they want; several are included for convienience:
`{{> title }}`
: The title of the current page. It should be placed inside the `<title>` tag. By default, it will appear as `{{ page.title }} | {{ site.title }}`.
`{{> nav }}`
: Nav renders the main menu for the site (`menus.main`). It should be placed inside the `<body>` tag, just before the `<main>` block.
`{{> home-link }}`
: This partial is rendered inside the home anchor in `{{> nav }}`. By default, it wil display the name of the site, or `Home` if no name is set.
`{{> styles }}`
: This partial renders any stylesheets specified either by the user (via `params.stylesheets`) or by the theme author (specified directly in the template). `params.stylesheets` is intended as an escape hatch for users that want to add CSS to their site, but don't want to customize any templates. Styles should be placed inside the `<head>` tags.
`{{> scripts }}`
: This partial renders any script tags specified either by the user (via `params.scripts`) or by the theme author (specified directly in the template). `params.scripts` is intended as an escape hatch for users that want to add javascript to their site, but don't want to customize any templates. Scripts should be placed at the end of the `<head>` block.
TODO: Scripts must currently be given in raw form, whereas styles are just paths/urs.
`{{> opengraph }}`
: This partial willl render the Open Graph meta tags for your page. It should be placed inside the `<head>` tag. See the [Open Graph](#open-graph) section for more details.
`{{> footer }}`
: This partial will render content on every page after the main page content. It should be placed at the end of the `<body>` tag. By default, it displays a simple copyright line with the current year and author's name (configured in `params.author.name`).
TODO: `styles`/`css`?
TODO: `comments`?
TODO: `toc`?
TODO: We keep referring to blocks and tags, should probably use consistent language.
### DateTimes
TODO: Should this be a subsection of a "Data Types" section?
Dates are strings in one of the following formats:
| Format | Time zone |
| --------------------------- | ---------------------------- |
| `2023-10-15T13:18:50-07:00` | `America/Los_Angeles` |
| `2023-10-15T13:18:50-0700` | `America/Los_Angeles` |
| `2023-10-15T13:18:50Z` | `Etc/UTC` |
| `2023-10-15T13:18:50` | Default is local system time |
| `2023-10-15` | Default is local system time |
| `15 Oct 2023` | Default is local system time |
If you want to display a date in a different format, you can use the `| format` Filter. With no argument, it will default to formatting the date with `site.date_format`.
## Open Graph
TODO: default Template support
TODO: Template override support
TODO: how to overwrite defaults (in page frontmatter / thor.json)
## Zen
> The guding principals behind Thor.
Simpler is Better
: We are [Grug](https://grugbrain.dev/) developers. We do everything the "dumb" way first, then optimize the **measured** bottlenecks.
Zero is Beautiful
: The less configuration needed, the better. A site can be built from multiple modules or a single `index.md`.
On By Default
: Users shouldn't have to enable features, unless those features significantly impact performance or mangle regular content.
Fault Tolerant
: When possible, mistakes should be recovered with warnings. When fatal errors occur, the user should have all the information they need to fix it quickly.
+28
View File
@@ -0,0 +1,28 @@
{
"title": "Guide",
"date": "2026-07-12T08:55:00-04:00"
}
[TOC]
Thank you for choosing thor for your site building needs. This guide attempts to make it as easy as possible to get started.
> [!NOTE]: This guide assumes you have a basic knowledge of using command line interfaces, file systems, and text editors.
Start by creating a new directory called `my-site`. This will be the home of your first Thor site.
If you haven't already, running `thor` in your new directory should create this guide in `./public/index.html`.
At the heart of any good SSG is the content files. Thor supports markdown (`.md`) and `.html` files.
Let's make a home page - copy the following text and place it into `./index.md`
```md
# Hello, World!
Welcome to your new site.
```
Run `thor` again, and you should see new content in `public/index.html`.
+4
View File
@@ -0,0 +1,4 @@
{
"title": "Home"
}
TODO: Add content here
+10
View File
@@ -0,0 +1,10 @@
{
"markdown_extensions": {
// "footnotes": true
},
"params": {
"author": {
"name": "Spencer Brower"
}
}
}
+158 -15
View File
@@ -3,10 +3,13 @@
package main
import "core:encoding/json"
import "core:flags"
import "core:fmt"
import "core:log"
import "core:os"
import "core:testing"
import "core:time/datetime"
import "core:time/timezone"
write_temp_config :: proc(name: string, content: string) -> string {
path := fmt.tprintf("./test_thor_%s.json", name)
@@ -25,8 +28,8 @@ test_load_config_file :: proc(t: ^testing.T) {
"title":"Test Site",
"description":"Test desc",
"base_url":"https://example.com",
"author":"Tester",
"params":{
"author":"Tester",
"social":[
{"name":"github","url":"https://github.com/test"},
{"name":"rss","url":"/index.xml"}]
@@ -42,11 +45,15 @@ test_load_config_file :: proc(t: ^testing.T) {
testing.expect_value(t, cfg.title, "Test Site")
testing.expect_value(t, cfg.description, "Test desc")
testing.expect_value(t, cfg.base_url, "https://example.com")
testing.expect_value(t, cfg.author, "Tester")
params, has_params := cfg.params.(json.Object)
testing.expect(t, has_params)
author_val := params["author"]
author, has_author := author_val.(json.String)
testing.expect(t, has_author)
testing.expect_value(t, author, "Tester")
social_val := params["social"]
social, has_social := social_val.(json.Array)
testing.expect(t, has_social)
@@ -94,15 +101,17 @@ test_load_config_file_partial :: proc(t: ^testing.T) {
testing.expect(t, ok)
testing.expect_value(t, cfg.title, "Partial")
testing.expect_value(t, cfg.description, "")
testing.expect_value(t, cfg.author, "")
testing.expect(t, cfg.params == nil)
}
@(test)
test_init_site_defaults_no_config :: proc(t: ^testing.T) {
context.logger = log.nil_logger()
site: Site
args := []string{"thor", "-config:./nonexistent.json"}
init_site(&site, args)
_flags: Flags
flags.parse_or_exit(&_flags, []string{"thor", "-config:./nonexistent.json"}, .Odin)
init_site(&site, _flags)
defer destroy_site(&site)
testing.expect_value(t, site.content_dir, "./content")
@@ -117,9 +126,12 @@ test_init_site_defaults_no_config :: proc(t: ^testing.T) {
@(test)
test_init_site_config_dir_relative :: proc(t: ^testing.T) {
context.logger = log.nil_logger()
site: Site
args := []string{"thor", "-config:./sub/nonexistent.json"}
init_site(&site, args)
_flags: Flags
flags.parse_or_exit(&_flags, []string{"thor", "-config:./sub/nonexistent.json"}, .Odin)
init_site(&site, _flags)
defer destroy_site(&site)
testing.expect_value(t, site.content_dir, "./sub/content")
@@ -130,9 +142,12 @@ test_init_site_config_dir_relative :: proc(t: ^testing.T) {
@(test)
test_init_site_flag_overrides_default :: proc(t: ^testing.T) {
context.logger = log.nil_logger()
site: Site
args := []string{"thor", "-config:./nonexistent.json", "-drafts", "-base-url:https://flag.com"}
init_site(&site, args)
_flags: Flags
flags.parse_or_exit(&_flags, []string{"thor", "-config:./nonexistent.json", "-drafts", "-base-url:https://flag.com"}, .Odin)
init_site(&site, _flags)
defer destroy_site(&site)
testing.expect(t, .Drafts in site.features)
@@ -141,29 +156,39 @@ test_init_site_flag_overrides_default :: proc(t: ^testing.T) {
@(test)
test_init_site_full_pipeline :: proc(t: ^testing.T) {
context.logger = log.nil_logger()
path := write_temp_config(
"pipeline",
`{"title":"Pipeline Test","description":"Full","base_url":"https://config.com","author":"Author"}`,
`{"title":"Pipeline Test","description":"Full","base_url":"https://config.com"}`,
)
defer os.remove(path)
site: Site
args := []string{"thor", fmt.tprintf("-config:%s", path), "-drafts"}
init_site(&site, args)
_flags: Flags
flags.parse_or_exit(&_flags, []string{"thor", fmt.tprintf("-config:%s", path), "-drafts"}, .Odin)
init_site(&site, _flags)
defer destroy_site(&site)
testing.expect_value(t, site.title, "Pipeline Test")
testing.expect_value(t, site.description, "Full")
testing.expect_value(t, site.author, "Author")
testing.expect(t, .Drafts in site.features)
testing.expect_value(t, site.base_url, "https://config.com")
}
@(test)
test_init_site_md_enable_disable :: proc(t: ^testing.T) {
context.logger = log.nil_logger()
site: Site
args := []string{"thor", "-config:./nonexistent.json", "-ext:highlight,sections", "-no-ext:emoji"}
init_site(&site, args)
_flags: Flags
flags.parse_or_exit(&_flags, []string {
"thor",
"-config:./nonexistent.json",
"-ext:highlight,sections",
"-no-ext:emoji",
}, .Odin)
init_site(&site, _flags)
defer destroy_site(&site)
testing.expect(t, .Highlight in site.markdown_extensions)
@@ -172,3 +197,121 @@ test_init_site_md_enable_disable :: proc(t: ^testing.T) {
testing.expect(t, .Sidenotes in site.markdown_extensions)
}
@(test)
test_init_site_config_paths :: proc(t: ^testing.T) {
context.logger = log.nil_logger()
path := write_temp_config(
"paths",
`{
"content_dir": "/custom/content",
"assets_dir": "/custom/assets",
"output_dir": "/custom/output",
"layouts_dir": "/custom/layouts"
}`,
)
defer os.remove(path)
site: Site
_flags: Flags
flags.parse_or_exit(&_flags, []string{"thor", fmt.tprintf("-config:%s", path)}, .Odin)
init_site(&site, _flags)
defer destroy_site(&site)
testing.expect_value(t, site.content_dir, "/custom/content")
testing.expect_value(t, site.assets_dir, "/custom/assets")
testing.expect_value(t, site.output_dir, "/custom/output")
testing.expect_value(t, site.layouts_dir, "/custom/layouts")
}
// --- merge_params tests ---
@(test)
test_merge_params_both_present :: proc(t: ^testing.T) {
site_params, _ := json.parse_string(
`{"social": [], "author": "Tester"}`,
spec = .JSON,
allocator = context.temp_allocator,
)
page_params, _ := json.parse_string(
`{"starred": true}`,
spec = .JSON,
allocator = context.temp_allocator,
)
merged_val := merge_params(site_params, page_params)
merged, ok := merged_val.(json.Object)
testing.expect(t, ok, "merged should be a json.Object")
_, has_social := merged["social"]
testing.expect(t, has_social, "site param 'social' should survive merge")
_, has_author := merged["author"]
testing.expect(t, has_author, "site param 'author' should survive merge")
starred, has_starred := merged["starred"]
testing.expect(t, has_starred, "page param 'starred' should be present")
starred_bool, _ := starred.(json.Boolean)
testing.expect(t, bool(starred_bool), "starred should be true")
}
@(test)
test_merge_params_nil_page :: proc(t: ^testing.T) {
site_params, _ := json.parse_string(
`{"author": "Tester"}`,
spec = .JSON,
allocator = context.temp_allocator,
)
merged_val := merge_params(site_params, nil)
merged, ok := merged_val.(json.Object)
testing.expect(t, ok, "should return site params when page is nil")
_, has_author := merged["author"]
testing.expect(t, has_author, "site param should survive")
}
@(test)
test_merge_params_nil_site :: proc(t: ^testing.T) {
page_params, _ := json.parse_string(
`{"starred": true}`,
spec = .JSON,
allocator = context.temp_allocator,
)
merged_val := merge_params(nil, page_params)
merged, ok := merged_val.(json.Object)
testing.expect(t, ok, "should return page params when site is nil")
_, has_starred := merged["starred"]
testing.expect(t, has_starred, "page param should survive")
}
@(test)
test_merge_params_both_nil :: proc(t: ^testing.T) {
merged_val := merge_params(nil, nil)
testing.expect(t, merged_val == nil, "both nil should return nil")
}
@(test)
test_merge_params_page_overrides_site :: proc(t: ^testing.T) {
site_params, _ := json.parse_string(
`{"key": "site_value"}`,
spec = .JSON,
allocator = context.temp_allocator,
)
page_params, _ := json.parse_string(
`{"key": "page_value"}`,
spec = .JSON,
allocator = context.temp_allocator,
)
merged_val := merge_params(site_params, page_params)
merged, ok := merged_val.(json.Object)
testing.expect(t, ok)
val := merged["key"]
str, _ := val.(json.String)
testing.expect_value(t, string(str), "page_value")
}
+8 -1
View File
@@ -1 +1,8 @@
{"params":{"social":[{"name":"github","url":"https://github.com/test"},{"name":"rss","url":"/index.xml"}]}}
{
"params": {
"social": [
{ "name": "github", "url": "https://github.com/test" },
{ "name": "rss", "url": "/index.xml" }
]
}
}
-129
View File
@@ -1,129 +0,0 @@
package main
import "core:c"
GRAPHS_PATH: string = "/home/spencer/.config/helix/runtime/grammars"
QUERIES_PATH: string = "/nix/store/n9da8d007ygbgsx983jr3ar3wb1fsh6q-helix-25.07.1/lib/runtime/queries"
TSLanguage :: distinct rawptr
TSParser :: distinct rawptr
TSTree :: distinct rawptr
TSQuery :: distinct rawptr
TSQueryCursor :: distinct rawptr
TSPoint :: struct {
row: u32,
column: u32,
}
TSNode :: struct {
ctx: [4]u32,
id: rawptr,
tree: rawptr,
}
TSQueryCapture :: struct {
node: TSNode,
index: u32,
_: u32,
}
TSQueryMatch :: struct {
id: u32,
pattern_index: u16,
capture_count: u16,
captures: [^]TSQueryCapture,
}
TSQueryError :: enum c.int {
None = 0,
Syntax,
NodeType,
Field,
Capture,
Structure,
Language,
}
RTLD_LAZY :: c.int(1)
foreign import lib "system:tree-sitter"
foreign import libdl "system:dl"
foreign import html_grammar "system:tree-sitter-html"
foreign import css_grammar "system:tree-sitter-css"
foreign lib {
ts_parser_new :: proc() -> TSParser ---
ts_parser_delete :: proc(self: TSParser) ---
ts_parser_set_language :: proc(self: TSParser, language: TSLanguage) -> bool ---
ts_parser_parse_string :: proc(
self: TSParser,
old_tree: TSTree,
string: cstring,
length: u32,
) -> TSTree ---
}
foreign lib {
ts_tree_root_node :: proc(self: TSTree) -> TSNode ---
ts_tree_delete :: proc(self: TSTree) ---
}
foreign lib {
ts_node_start_byte :: proc(self: TSNode) -> u32 ---
ts_node_end_byte :: proc(self: TSNode) -> u32 ---
ts_node_has_error :: proc(self: TSNode) -> bool ---
ts_node_is_error :: proc(self: TSNode) -> bool ---
ts_node_child_count :: proc(self: TSNode) -> u32 ---
ts_node_child :: proc(self: TSNode, child_index: u32) -> TSNode ---
ts_node_named_child_count :: proc(self: TSNode) -> u32 ---
ts_node_named_child :: proc(self: TSNode, child_index: u32) -> TSNode ---
ts_node_start_point :: proc(self: TSNode) -> TSPoint ---
ts_node_type :: proc(self: TSNode) -> cstring ---
ts_node_parent :: proc(self: TSNode) -> TSNode ---
}
foreign lib {
ts_query_new :: proc(
language: TSLanguage,
source: cstring,
source_len: u32,
error_offset: ^u32,
error_type: ^TSQueryError,
) -> TSQuery ---
ts_query_delete :: proc(self: TSQuery) ---
ts_query_capture_name_for_id :: proc(
self: TSQuery,
index: u32,
length: ^u32,
) -> cstring ---
}
foreign lib {
ts_query_cursor_new :: proc() -> TSQueryCursor ---
ts_query_cursor_delete :: proc(self: TSQueryCursor) ---
ts_query_cursor_exec :: proc(
self: TSQueryCursor,
query: TSQuery,
node: TSNode,
) ---
ts_query_cursor_next_capture :: proc(
self: TSQueryCursor,
match: ^TSQueryMatch,
capture_index: ^u32,
) -> bool ---
}
foreign libdl {
dlopen :: proc(filename: cstring, flags: c.int) -> rawptr ---
dlsym :: proc(handle: rawptr, symbol: cstring) -> rawptr ---
dlclose :: proc(handle: rawptr) -> c.int ---
}
foreign html_grammar {
tree_sitter_html :: proc() -> TSLanguage ---
}
foreign css_grammar {
tree_sitter_css :: proc() -> TSLanguage ---
}
+76
View File
@@ -0,0 +1,76 @@
(comment) @comment
(tag_name) @tag
(nesting_selector) @tag
(universal_selector) @tag
"~" @operator
">" @operator
"+" @operator
"-" @operator
"*" @operator
"/" @operator
"=" @operator
"^=" @operator
"|=" @operator
"~=" @operator
"$=" @operator
"*=" @operator
"and" @operator
"or" @operator
"not" @operator
"only" @operator
(attribute_selector (plain_value) @string)
((property_name) @variable
(#match? @variable "^--"))
((plain_value) @variable
(#match? @variable "^--"))
(class_name) @property
(id_name) @property
(namespace_name) @property
(property_name) @property
(feature_name) @property
(pseudo_element_selector (tag_name) @attribute)
(pseudo_class_selector (class_name) @attribute)
(attribute_name) @attribute
(function_name) @function
"@media" @keyword
"@import" @keyword
"@charset" @keyword
"@namespace" @keyword
"@supports" @keyword
"@keyframes" @keyword
(at_keyword) @keyword
(to) @keyword
(from) @keyword
(important) @keyword
(string_value) @string
(color_value) @string.special
(integer_value) @number
(float_value) @number
(unit) @type
[
"#"
","
"."
":"
"::"
";"
] @punctuation.delimiter
[
"{"
")"
"("
"}"
] @punctuation.bracket
+13
View File
@@ -0,0 +1,13 @@
(tag_name) @tag
(erroneous_end_tag_name) @tag.error
(doctype) @constant
(attribute_name) @attribute
(attribute_value) @string
(comment) @comment
[
"<"
">"
"</"
"/>"
] @punctuation.bracket
+7
View File
@@ -0,0 +1,7 @@
((script_element
(raw_text) @injection.content)
(#set! injection.language "javascript"))
((style_element
(raw_text) @injection.content)
(#set! injection.language "css"))
+461
View File
@@ -0,0 +1,461 @@
package treesitter
import "core:c"
import "core:fmt"
import "core:log"
import "core:mem"
import "core:os"
import "core:strings"
import "core:sync"
import "core:thread"
grammar_dir: string
query_dir: string
HTML_HIGHLIGHTS :: #load(#directory + "queries/html/highlights.scm", string)
CSS_HIGHLIGHTS :: #load(#directory + "queries/css/highlights.scm", string)
Language :: distinct rawptr
Parser :: distinct rawptr
Tree :: distinct rawptr
Query :: distinct rawptr
Query_Cursor :: distinct rawptr
Point :: struct {
row: u32,
column: u32,
}
Node :: struct {
ctx: [4]u32,
id: rawptr,
tree: rawptr,
}
Query_Capture :: struct {
node: Node,
index: u32,
_: u32,
}
Query_Match :: struct {
id: u32,
pattern_index: u16,
capture_count: u16,
captures: [^]Query_Capture,
}
Query_Error :: enum c.int {
None = 0,
Syntax,
NodeType,
Field,
Capture,
Structure,
Language,
}
RTLD_LAZY :: c.int(1)
foreign import lib "system:tree-sitter"
foreign import libdl "system:dl"
foreign import html_grammar "system:tree-sitter-html"
foreign import css_grammar "system:tree-sitter-css"
@(link_prefix = "ts_")
foreign lib {
parser_new :: proc() -> Parser ---
parser_delete :: proc(self: Parser) ---
parser_set_language :: proc(self: Parser, language: Language) -> bool ---
parser_parse_string :: proc(self: Parser, old_tree: Tree, string: cstring, length: u32) -> Tree ---
}
@(link_prefix = "ts_")
foreign lib {
tree_root_node :: proc(self: Tree) -> Node ---
tree_delete :: proc(self: Tree) ---
}
@(link_prefix = "ts_")
foreign lib {
node_start_byte :: proc(self: Node) -> u32 ---
node_end_byte :: proc(self: Node) -> u32 ---
node_has_error :: proc(self: Node) -> bool ---
node_is_error :: proc(self: Node) -> bool ---
node_child_count :: proc(self: Node) -> u32 ---
node_child :: proc(self: Node, child_index: u32) -> Node ---
node_named_child_count :: proc(self: Node) -> u32 ---
node_named_child :: proc(self: Node, child_index: u32) -> Node ---
node_start_point :: proc(self: Node) -> Point ---
node_type :: proc(self: Node) -> cstring ---
node_parent :: proc(self: Node) -> Node ---
}
@(link_prefix = "ts_")
foreign lib {
query_new :: proc(language: Language, source: cstring, source_len: u32, error_offset: ^u32, error_type: ^Query_Error) -> Query ---
query_delete :: proc(self: Query) ---
query_capture_name_for_id :: proc(self: Query, index: u32, length: ^u32) -> cstring ---
}
@(link_prefix = "ts_")
foreign lib {
query_cursor_new :: proc() -> Query_Cursor ---
query_cursor_delete :: proc(self: Query_Cursor) ---
query_cursor_exec :: proc(self: Query_Cursor, query: Query, node: Node) ---
query_cursor_next_capture :: proc(self: Query_Cursor, match: ^Query_Match, capture_index: ^u32) -> bool ---
}
foreign libdl {
dlopen :: proc(filename: cstring, flags: c.int) -> rawptr ---
dlsym :: proc(handle: rawptr, symbol: cstring) -> rawptr ---
dlclose :: proc(handle: rawptr) -> c.int ---
}
foreign html_grammar {
tree_sitter_html :: proc() -> Language ---
}
foreign css_grammar {
tree_sitter_css :: proc() -> Language ---
}
Grammar_Cache :: struct {
language: Language,
parser: Parser,
query: Query,
cursor: Query_Cursor,
query_failed: bool,
}
Get_Language_Proc :: #type proc() -> Language
SPALL :: #config(SPALL, false)
grammar_store: Grammar_Store
cache_mutex: sync.Mutex
Grammar_Store :: struct {
cache: map[string]^Grammar_Cache,
allocator: mem.Allocator,
}
init_persistent :: proc() {
grammar_store.allocator = context.allocator
grammar_store.cache = make(map[string]^Grammar_Cache, grammar_store.allocator)
}
when SPALL {
_thread_init: proc() = nil
_thread_cleanup: proc() = nil
set_thread_callbacks :: proc(init: proc() = nil, cleanup: proc() = nil) {
_thread_init = init
_thread_cleanup = cleanup
}
}
builtin_language :: proc(lang: string) -> (language: Language, ok: bool) {
switch lang {
case "html":
language = tree_sitter_html()
ok = true
case "css":
language = tree_sitter_css()
ok = true
}
return
}
// load_query returns the highlight query source for a language. Builtin
// languages (html/css) are baked into the binary via `#load`; all others are
// read from the runtime `query_dir`. `path` is the on-disk location for
// diagnostics ("(builtin)" for embedded queries). Mirrors `ensure_parser`.
load_query :: proc(lang: string) -> (src: string, path: string, ok: bool) {
switch lang {
case "html":
return HTML_HIGHLIGHTS, "(builtin)", true
case "css":
return CSS_HIGHLIGHTS, "(builtin)", true
}
if query_dir == "" {
log.warnf("treesitter: no query path set, skipping %s", lang)
return "", "", false
}
path = fmt.tprintf("%s/%s/highlights.scm", query_dir, lang)
raw, err := os.read_entire_file_from_path(path, context.allocator)
if err != nil {
log.warnf("treesitter: cannot load query %s", path)
return "", "", false
}
return string(raw), path, true
}
load_language :: proc(lang: string) -> (language: Language, ok: bool) {
if builtin, bok := builtin_language(lang); bok {
language = builtin
ok = true
return
}
if grammar_dir == "" {
log.warnf("treesitter: no grammar path set, skipping %s", lang)
return
}
so_path := fmt.caprintf("%s/%s.so", grammar_dir, lang, allocator = context.temp_allocator)
handle := dlopen(so_path, RTLD_LAZY)
if handle == nil {
log.warnf("treesitter: cannot load grammar %s (%s)", lang, so_path)
return
}
sym_name := fmt.caprintf("tree_sitter_%s", lang, allocator = context.temp_allocator)
sym := dlsym(handle, sym_name)
if sym == nil {
log.errorf("treesitter: cannot find symbol %s in %s", sym_name, so_path)
return
}
get_language := transmute(Get_Language_Proc)(sym)
language = get_language()
ok = true
return
}
ensure_parser :: proc(lang: string) -> ^Grammar_Cache {
if cached, ok := grammar_store.cache[lang]; ok {
return cached
}
grammar_store.cache[lang] = nil
language, ok := load_language(lang)
if !ok {
return nil
}
parser := parser_new()
if parser == nil {
log.errorf("treesitter: cannot create parser for %s", lang)
return nil
}
if !parser_set_language(parser, language) {
log.errorf("treesitter: ABI mismatch for %s grammar", lang)
parser_delete(parser)
return nil
}
gc := new(Grammar_Cache, grammar_store.allocator)
gc.language = language
gc.parser = parser
grammar_store.cache[lang] = gc
return gc
}
compile_query :: proc(
lang: string,
language: Language,
) -> (
query: Query,
cursor: Query_Cursor,
ok: bool,
) {
query_src, query_path, qok := load_query(lang)
if !qok {
return
}
query_c := strings.clone_to_cstring(query_src, context.temp_allocator)
err_offset: u32
err_type: Query_Error
query = query_new(language, query_c, u32(len(query_src)), &err_offset, &err_type)
if query == nil {
tok := extract_query_token(transmute([]byte)query_src, err_offset)
cause := fmt.tprintf("query error at byte %d (type %v)", err_offset, err_type)
switch err_type {
case .NodeType:
if tok != "" {
cause = fmt.tprintf(
"query references unknown node type '%s' (byte %d); the grammar (.so) and query (.scm) are likely from different tree-sitter-%s versions",
tok,
err_offset,
lang,
)
} else {
cause = fmt.tprintf(
"query references an unknown node type at byte %d; the grammar (.so) and query (.scm) are likely from different tree-sitter-%s versions",
err_offset,
lang,
)
}
case .Field:
cause = fmt.tprintf("query references unknown field '%s' at byte %d", tok, err_offset)
case .Capture:
cause = fmt.tprintf("query uses an invalid capture '%s' at byte %d", tok, err_offset)
case .Syntax:
cause = fmt.tprintf("query has a syntax error at byte %d", err_offset)
case .Structure:
cause = fmt.tprintf("query has an illegal pattern structure at byte %d", err_offset)
case .Language:
cause = "grammar language is null (broken grammar .so)"
case .None:
}
log.errorf("treesitter: %s query failed: %s", lang, cause)
_, is_builtin := builtin_language(lang)
if !is_builtin {
so_path := fmt.tprintf("%s/%s.so", grammar_dir, lang)
gram_v := helix_version_from_path(so_path)
query_v := helix_version_from_path(query_path)
gram_note := "(version unknown)"
if gram_v != "" do gram_note = fmt.tprintf("helix %s", gram_v)
query_note := "(version unknown)"
if query_v != "" do query_note = fmt.tprintf("helix %s", query_v)
log.errorf(" grammar: %s [%s]", so_path, gram_note)
log.errorf(" query: %s [%s]", query_path, query_note)
if gram_v != "" && query_v != "" && gram_v != query_v {
log.errorf(" >> helix VERSION MISMATCH: grammar %s vs query %s", gram_v, query_v)
}
}
return
}
cursor = query_cursor_new()
ok = true
return
}
load_grammar :: proc(lang: string) -> ^Grammar_Cache {
gc := ensure_parser(lang)
if gc == nil {
return nil
}
if gc.query != nil {
return gc
}
if gc.query_failed {
return nil
}
query, cursor, ok := compile_query(lang, gc.language)
if !ok {
gc.query_failed = true
return nil
}
gc.query = query
gc.cursor = cursor
return gc
}
preload_grammar :: proc(lang: string) -> ^Grammar_Cache {
language, ok := load_language(lang)
if !ok {
return nil
}
parser := parser_new()
if parser == nil {
log.errorf("treesitter: cannot create parser for %s", lang)
return nil
}
if !parser_set_language(parser, language) {
log.errorf("treesitter: ABI mismatch for %s grammar", lang)
parser_delete(parser)
return nil
}
gc := new(Grammar_Cache, grammar_store.allocator)
gc.language = language
gc.parser = parser
query, cursor, qok := compile_query(lang, language)
if !qok {
gc.query_failed = true
return gc
}
gc.query = query
gc.cursor = cursor
return gc
}
preload_grammars :: proc(languages: []string) {
if len(languages) == 0 {
return
}
// Filter out already-loaded languages (watch mode reuse)
to_load := make([dynamic]string, 0, len(languages), context.temp_allocator)
for lang in languages {
if cached, ok := grammar_store.cache[lang]; ok && cached != nil {
continue
}
if _, bok := builtin_language(lang); bok {
continue
}
append(&to_load, lang)
}
if len(to_load) == 0 {
return
}
threads := make([]^thread.Thread, len(to_load), context.temp_allocator)
for i in 0 ..< len(to_load) {
threads[i] = thread.create_and_start_with_poly_data(to_load[i], grammar_worker)
}
for t in threads {
thread.join(t)
thread.destroy(t)
}
}
grammar_worker :: proc(lang: string) {
when SPALL {
if _thread_init != nil {
_thread_init()
}
defer if _thread_cleanup != nil {
_thread_cleanup()
}
}
gc := preload_grammar(lang)
if gc != nil {
sync.mutex_lock(&cache_mutex)
grammar_store.cache[lang] = gc
sync.mutex_unlock(&cache_mutex)
}
}
extract_query_token :: proc(src: []byte, offset: u32) -> string {
end := offset
for int(end) < len(src) {
c := src[end]
is_ident :=
(c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '_' ||
c == '-' ||
c == '.'
if !is_ident do break
end += 1
}
if end <= offset do return ""
return string(src[offset:end])
}
helix_version_from_path :: proc(path: string) -> string {
tag := "-helix-"
idx := strings.index(path, tag)
if idx < 0 do return ""
start := idx + len(tag)
end := start
for end < len(path) {
c := path[end]
if !((c >= '0' && c <= '9') || c == '.') do break
end += 1
}
if end <= start do return ""
return path[start:end]
}
+111
View File
@@ -0,0 +1,111 @@
package main
import "core:fmt"
import "core:os"
// Virtual File System Entry
VFS_Entry :: struct {
fs_path: string,
data: []byte,
}
// Virtual File System
VFS :: struct {
files: map[string]VFS_Entry,
}
build_vfs :: proc(site: ^Site) {
site.vfs.files = make(map[string]VFS_Entry, site_allocator(site))
mount_dir(&site.vfs, fmt.tprintf("%s/layouts", DEFAULTS_PATH), "layouts")
for i := len(site.modules) - 1; i >= 0; i -= 1 {
module := site.modules[i]
mount_subdir(&site.vfs, module, "layouts")
mount_subdir(&site.vfs, module, "assets")
}
mount_dir(&site.vfs, site.layouts_dir, "layouts")
mount_dir(&site.vfs, site.assets_dir, "assets")
}
mount_subdir :: proc(vfs: ^VFS, module_dir: string, target: string) {
source := fmt.tprintf("%s/%s", module_dir, target)
mount_dir(vfs, source, target)
}
mount_dir :: proc(vfs: ^VFS, source_dir: string, target: string) {
if !os.exists(source_dir) {
return
}
mount_recursive(vfs, source_dir, target)
}
mount_recursive :: proc(vfs: ^VFS, current_dir: string, target_prefix: string) {
entries, err := os.read_all_directory_by_path(current_dir, context.allocator)
if err != nil {
return
}
defer os.file_info_slice_delete(entries, context.allocator)
for entry in entries {
switch entry.type {
case .Regular:
virtual := fmt.tprintf("%s/%s", target_prefix, entry.name)
vfs.files[virtual] = VFS_Entry {
fs_path = entry.fullpath,
}
case .Directory:
sub_prefix := fmt.tprintf("%s/%s", target_prefix, entry.name)
mount_recursive(vfs, entry.fullpath, sub_prefix)
case .Undetermined, .Symlink, .Named_Pipe, .Socket, .Block_Device, .Character_Device:
}
}
}
vfs_get :: proc(vfs: ^VFS, virtual_path: string) -> ([]byte, bool) {
entry, ok := vfs.files[virtual_path]
if !ok {
return nil, false
}
if entry.data != nil {
return entry.data, true
}
data, err := os.read_entire_file_from_path(entry.fs_path, context.allocator)
if err != nil {
return nil, false
}
return data, true
}
// vfs_get_entry returns both the VFS_Entry (for fs_path) and the lazily-loaded
// data. Use this instead of vfs_get when you need the entry's metadata along
// with the contents.
vfs_get_entry :: proc(vfs: ^VFS, virtual_path: string) -> (VFS_Entry, []byte, bool) {
entry, ok := vfs.files[virtual_path]
if !ok {
return {}, nil, false
}
if entry.data != nil {
return entry, entry.data, true
}
data, err := os.read_entire_file_from_path(entry.fs_path, context.allocator)
if err != nil {
return entry, nil, false
}
return entry, data, true
}
// vfs_entry_data returns the data for a VFS_Entry, reading from disk if it
// hasn't been loaded yet. Useful when iterating vfs.files directly (where you
// already have the entry and don't want a redundant map lookup).
vfs_entry_data :: proc(entry: VFS_Entry) -> ([]byte, bool) {
if entry.data != nil {
return entry.data, true
}
data, err := os.read_entire_file_from_path(entry.fs_path, context.allocator)
if err != nil {
return nil, false
}
return data, true
}