Compare commits

...

24 Commits

Author SHA1 Message Date
Spencer Brower 1824bae26c docs: Updated README.md 2026-07-28 13:14:30 -04:00
Spencer Brower 5627215d6f feat: Added site to the template context stack. 2026-07-28 13:07:14 -04:00
Spencer Brower 9d1954d70a feat: page prop now falls through (implicit access). 2026-07-28 12:04:54 -04:00
Spencer Brower fcf349eb51 fix: Removed content from Template_Context.
now accessible only through page.content.
2026-07-28 11:45:55 -04:00
Spencer Brower d51172c836 feat: Added page to template context. 2026-07-28 11:41:21 -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
37 changed files with 1913 additions and 468 deletions
+35 -26
View File
@@ -20,14 +20,14 @@ thor/
├── treesitter/ # FFI types + grammar management (standalone package) ├── treesitter/ # FFI types + grammar management (standalone package)
├── markdown/ # Content transformation pipeline (imports ../treesitter) ├── markdown/ # Content transformation pipeline (imports ../treesitter)
├── mustache/ # Template engine with lambdas + pipe filters + diagnostics ├── mustache/ # Template engine with lambdas + pipe filters + diagnostics
├── content.odin # Page struct, scan_content, load_page ├── content.odin # Page struct, Pending_File, scan_content_files, collect_languages, load_page
├── render.odin # Template rendering, data structs, RSS, sitemap ├── render.odin # Template rendering, data structs, RSS, sitemap
├── site.odin # Config (Flags, Config_File, Site), init_site ├── site.odin # Config (Flags, Config_File, Site), init_site
├── minify.odin # HTML/CSS minification (imports treesitter) ├── minify.odin # HTML/CSS minification (imports treesitter)
├── feed.odin # RSS + sitemap generation ├── feed.odin # RSS + sitemap generation
├── vfs.odin # Union file system (defaults → modules → site) ├── vfs.odin # Union file system (defaults → modules → site)
├── assets.odin # VFS-based asset copying ├── assets.odin # VFS-based asset copying
├── html.odin # HTML helpers: strip_html_tags, unescape_html, generate_summary ├── html.odin # HTML helpers: strip_html_tags, unescape_html, generate_summary (word-count truncation), generate_description (scrub to plain text)
├── opengraph.odin # Open_Graph struct + og_for_site/og_for_page ├── opengraph.odin # Open_Graph struct + og_for_site/og_for_page
├── frontmatter.odin # JSON frontmatter parser (supports nested og + lastmod) ├── frontmatter.odin # JSON frontmatter parser (supports nested og + lastmod)
├── defaults.odin # DEFAULTS_PATH constant (#directory) ├── defaults.odin # DEFAULTS_PATH constant (#directory)
@@ -40,15 +40,15 @@ thor/
| File | Responsibility | | File | Responsibility |
|---|---| |---|---|
| `main.odin` | Entry point. Sets `context.logger`, calls `init_site`, `build_vfs`, `site_load_content`, `render_site`. Optional Spall profiling via `SPALL` config flag. | | `main.odin` | Entry point. Sets `context.logger`, calls `init_site`, `build_vfs`, wires `treesitter.grammar_dir`/`query_dir` from config, `site_load_content`, `render_site`. Optional Spall profiling via `SPALL` config flag. |
| `site.odin` | `Flags` (CLI), `Config_File` (thor.json, includes `og: Open_Graph`), `Site` (runtime state + arena + VFS + pages + modules + `og`). `Feature` enum. 5-step `init_site`. Imports `md "markdown"` for `Extension` enum. | | `site.odin` | `Flags` (CLI), `Config_File` (thor.json, includes `og: Open_Graph`), `Site` (runtime state + arena + VFS + pages + modules + `og`). `Feature` enum. 5-step `init_site`. Imports `md "markdown"` for `Extension` enum. |
| `content.odin` | `Page` struct (includes `lastmod`, `og`), `scan_content` (section-aware walk that handles leaf bundles), `load_page`, `infer_layout`. Calls `md.process()` for the markdown pipeline. | | `content.odin` | `Page` struct (includes `lastmod`, `og`), `Pending_File` struct, `scan_content_files` (section-aware walk that handles leaf bundles), `collect_languages` (pre-scan for code fence languages), `load_page`, `infer_layout`. Calls `md.process()` for the markdown pipeline. |
| `render.odin` | Template rendering: `render_site`, `render_page_html`, `render_home_html`, `render_section`. Data structs (`Base_Data`, `Page_Data`, `Home_Data`, `Section_Data`). VFS-based template loading with fallback chain (`get_template`). | | `render.odin` | Template rendering: `render_site`, `render_page_html`, `render_home_html`, `render_section`. Data structs (`Base_Data`, `Page_Data`, `Home_Data`, `Section_Data`). VFS-based template loading with fallback chain (`get_template`). |
| `minify.odin` | HTML/CSS minification via tree-sitter. Imports `ts "treesitter"`. | | `minify.odin` | HTML/CSS minification via tree-sitter. Imports `ts "treesitter"`. |
| `feed.odin` | RSS feed + sitemap XML. Uses `page.url` for canonical URLs. | | `feed.odin` | RSS feed + sitemap XML. Uses `page.url` for canonical URLs. |
| `vfs.odin` | Union file system: `VFS`, `build_vfs`, `mount_dir`, `mount_subdir`, `mount_recursive`, `vfs_get`, `vfs_get_entry`, `vfs_entry_data`. Layers defaults → modules → site. | | `vfs.odin` | Union file system: `VFS`, `build_vfs`, `mount_dir`, `mount_subdir`, `mount_recursive`, `vfs_get`, `vfs_get_entry`, `vfs_entry_data`. Layers defaults → modules → site. |
| `assets.odin` | `copy_assets_dir` — iterates VFS entries with `assets/` prefix, minifies CSS, copies verbatim or via `os.copy_file`. | | `assets.odin` | `copy_assets_dir` — iterates VFS entries with `assets/` prefix, minifies CSS, copies verbatim or via `os.copy_file`. |
| `html.odin` | `strip_html_tags` (moved from render.odin), `unescape_html`, `generate_summary` (Hugo-style body summary for OG descriptions). | | `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). | | `opengraph.odin` | `Open_Graph` struct (fields ordered per OGP spec, `is_article: Maybe(bool)`). `og_for_site(site)` for site defaults (from config + derived), `og_for_page(site_og, page)` for page-specific (overlay page.og + derive from page data). |
| `frontmatter.odin` | JSON frontmatter parser (`{ }` delimited). Supports `layout`, `lastmod`, and nested `og` object (via `json_get_open_graph`). | | `frontmatter.odin` | JSON frontmatter parser (`{ }` delimited). Supports `layout`, `lastmod`, and nested `og` object (via `json_get_open_graph`). |
| `defaults.odin` | `DEFAULTS_PATH` constant, resolved at compile time via `#directory` so bundled templates ship in the binary. | | `defaults.odin` | `DEFAULTS_PATH` constant, resolved at compile time via `#directory` so bundled templates ship in the binary. |
@@ -57,7 +57,7 @@ thor/
| Package | Files | Responsibility | | Package | Files | Responsibility |
|---|---|---| |---|---|---|
| `treesitter/` | `treesitter.odin` | FFI types (`Parser`, `Node`, `Query`, etc.), `@(link_prefix="ts_")` foreign bindings, grammar management (`ensure_parser`, `load_grammar`, `grammar_cache`), statically-linked HTML/CSS grammars | | `treesitter/` | `treesitter.odin` | FFI types (`Parser`, `Node`, `Query`, etc.), `@(link_prefix="ts_")` foreign bindings, grammar management (`Grammar_Store` with persistent allocator, `load_language`/`compile_query` building blocks, `ensure_parser`/`load_grammar` lazy loading, `preload_grammar`/`preload_grammars` for parallel loading with `sync.Mutex` cache protection), statically-linked HTML/CSS grammars |
| `markdown/` | `markdown.odin` | `Extension` enum, `DEFAULT_EXTENSIONS`, `process(body, ext, file_path)` — full pipeline, `parse_extension_list`, `apply_extension_config` | | `markdown/` | `markdown.odin` | `Extension` enum, `DEFAULT_EXTENSIONS`, `process(body, ext, file_path)` — full pipeline, `parse_extension_list`, `apply_extension_config` |
| | `footnotes.odin` | `strip_definitions` (pre-cmark), `inject_notes` (post-cmark) | | | `footnotes.odin` | `strip_definitions` (pre-cmark), `inject_notes` (post-cmark) |
| | `alerts.odin` | `inject_alerts` — GitHub alert blocks (`> [!NOTE]`) → styled blockquotes with semantic class names (`alert-note` etc.) | | | `alerts.odin` | `inject_alerts` — GitHub alert blocks (`> [!NOTE]`) → styled blockquotes with semantic class names (`alert-note` etc.) |
@@ -74,7 +74,7 @@ Icon SVGs live as HTML partials in `layouts/partials/icons/` (home, github, rss,
``` ```
thor.json → find_config → init_site (5-step) thor.json → find_config → init_site (5-step)
→ build_vfs (defaults/layouts → modules → site/layouts, site/assets) → build_vfs (defaults/layouts → modules → site/layouts, site/assets)
→ site_load_content (scan_content + url computation) → site_load_content (scan_content_files + collect_languages + preload_grammars + load_page + url computation)
→ render_site → render_site
→ load_partials + get_template (VFS + fallback chain) → load_partials + get_template (VFS + fallback chain)
→ render_page_html / render_home_html / render_section → render_page_html / render_home_html / render_section
@@ -123,7 +123,7 @@ Config is split into three structs with a clear 5-step initialization flow:
**`Feature` enum** — `Drafts`, `Minify`, `Watch`. Checked with `.Minify in site.features`. **`Feature` enum** — `Drafts`, `Minify`, `Watch`. Checked with `.Minify in site.features`.
**`markdown.Extension` enum** (in the `markdown` package, not main) — `Emoji`, `Sidenotes`, `Alerts`, `Highlight`, `Sections`. Default is `md.DEFAULT_EXTENSIONS` (currently `.Emoji, .Sidenotes, .Alerts`). Configurable via: **`markdown.Extension` enum** (in the `markdown` package, not main) — `Emoji`, `Sidenotes`, `Alerts`, `Highlight`, `Sections`, `HeadingIDs`. Default is `md.DEFAULT_EXTENSIONS` (currently `.Emoji, .Sidenotes, .Alerts, .HeadingIDs`). Configurable via:
- `thor.json`: `"markdown_extensions": { "emoji": true, "highlight": false, ... }` - `thor.json`: `"markdown_extensions": { "emoji": true, "highlight": false, ... }`
- CLI: `-ext:highlight,sections` (enable) / `-no-ext:emoji` (disable). Comma-separated, case-insensitive. - CLI: `-ext:highlight,sections` (enable) / `-no-ext:emoji` (disable). Comma-separated, case-insensitive.
@@ -139,6 +139,12 @@ Config precedence: `CLI flags > thor.json values > hardcoded defaults`.
"og": { "og": {
"image": "https://example.com/og.png" "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 }, "markdown_extensions": { "emoji": true, "highlight": false },
"params": { "params": {
"social": [ "social": [
@@ -164,7 +170,7 @@ Three access patterns:
- `vfs_get_entry(vfs, path) -> (VFS_Entry, []byte, bool)` — entry + data (for callers that need `fs_path` for diagnostics) - `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`) - `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` still uses direct filesystem reads. (See `TODOS.md`.) Content is **not yet in the VFS**`scan_content_files` still uses direct filesystem reads. (See `TODOS.md`.)
## Open Graph ## Open Graph
@@ -181,7 +187,7 @@ Content is **not yet in the VFS** — `scan_content` still uses direct filesyste
- `is_article ← !page._is_index` - `is_article ← !page._is_index`
- `section ← page.section` - `section ← page.section`
- `published_time / modified_time ← page.date / page.lastmod` - `published_time / modified_time ← page.date / page.lastmod`
- `description ← page.description`, else body summary (via `generate_summary`) - `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. Paths through maps (e.g. `params.*`) are silently allowed — not validated. Templates access via `{{og.url}}`, `{{og.title}}`, `{{#og.is_article}}`, etc.
@@ -197,6 +203,7 @@ raw markdown
→ md.inject_notes (if .Sidenotes — post-cmark) → md.inject_notes (if .Sidenotes — post-cmark)
→ md.inject_alerts (if .Alerts — post-cmark) → md.inject_alerts (if .Alerts — post-cmark)
→ md.highlight_code (if .Highlight — post-cmark) → md.highlight_code (if .Highlight — post-cmark)
→ md.inject_heading_ids (if .HeadingIDs — post-cmark, pre-sections)
→ md.wrap_sections (if .Sections — post-cmark) → md.wrap_sections (if .Sections — post-cmark)
``` ```
@@ -208,13 +215,13 @@ Templates use Mustache with template inheritance (`{{<base}}` / `{{$block}}`):
```html ```html
<!-- base.html --> <!-- base.html -->
<body>{{> nav}}{{$content}}{{/content}}{{> footer}}</body> <body>{{> nav}}{{$main}}{{/main}}{{> footer}}</body>
<!-- page.html (content layout) --> <!-- page.html (content layout) -->
{{<base}} {{<base}}
{{$content}} {{$main}}
<main><article><h1>{{page_title}}</h1>{{&body}}</article></main> <main><article><h1>{{page.title}}</h1>{{&content}}</article></main>
{{/content}} {{/main}}
{{/base}} {{/base}}
``` ```
@@ -222,15 +229,18 @@ Data is passed as **typed structs** (not `map[string]any`). Mustache resolves st
```odin ```odin
Base_Data :: struct { Base_Data :: struct {
now: datetime.DateTime, now: string, // UTC ISO 8601 build timestamp
params: json.Value, params: json.Value,
body: string, content: string,
title: string, title: string,
og: Open_Graph, description: string,
og: Open_Graph,
date_format: string, // from site.date.format (thor.json)
timezone: ^datetime.TZ_Region, // loaded from site.date.timezone or local, owned by Site
} }
Page_Data :: struct { Page_Data :: struct {
using base: Base_Data, // fields promoted via reflection fallback using base: Base_Data, // fields promoted via reflection fallback
page_title: string, page.title: string,
date: string, // raw ISO 8601; formatted via `| format` in templates date: string, // raw ISO 8601; formatted via `| format` in templates
} }
Home_Data :: struct { Home_Data :: struct {
@@ -239,7 +249,7 @@ Home_Data :: struct {
} }
Section_Data :: struct { Section_Data :: struct {
using base: Base_Data, using base: Base_Data,
page_title: string, page.title: string,
posts: [dynamic]Page_Context, // flat list; year grouping done in template via pipe posts: [dynamic]Page_Context, // flat list; year grouping done in template via pipe
} }
``` ```
@@ -259,7 +269,7 @@ Section tags and interpolation tags may transform the resolved value before rend
<time datetime="{{date}}">{{date | format}}</time> <time datetime="{{date}}">{{date | format}}</time>
``` ```
Currently implemented: `group_by <field>` (list → list-of-groups) and `format` (ISO date string → display string like "15 Mar 2026"). 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. Currently implemented: `group_by <field>` (list → list-of-groups) and `format` (ISO date string → display string like "15 Mar 2026"). The `format` pipe resolves `date_format` (string) and `timezone` (`^datetime.TZ_Region`) from the data context. When `timezone` is non-nil, dates are DST-aware converted before formatting. The `MST` token reflects the active timezone abbreviation (e.g. `"EST"`/`"EDT"`) or the source offset (e.g. `"UTC-04:00"`) when no target tz is configured. TZ data is loaded once by `init_site` via `timezone.region_load` using the site arena allocator, stored on `Site.tz`, and freed when the arena is destroyed. Filter results live in `context.temp_allocator` (render-scoped). See `mustache/EXTENSIONS.md` for syntax details, caps (`MAX_PIPES`, `MAX_PIPE_ARGS`), and the `Group` struct shape.
### Comments ### Comments
@@ -270,9 +280,8 @@ Currently implemented: `group_by <field>` (list → list-of-groups) and `format`
Build-time highlighting via Tree-sitter C FFI. No client-side JavaScript. 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`. - **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. - **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.
- Highlight queries (`.scm`) loaded from Helix's runtime directory. - 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.
- Paths hardcoded in `treesitter/treesitter.odin` (`GRAPHS_PATH`, `QUERIES_PATH`) — Nix store paths, Helix-version-dependent. (See `TODOS.md`.)
- Grammar loading split: `ensure_parser` (parser only, used by minify) vs `load_grammar` (parser + query, used by highlight). - 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. - Capture names mapped to CSS classes: `keyword``.hl-keyword`, etc.
- Atom-one-dark color theme in `main.css`. - Atom-one-dark color theme in `main.css`.
@@ -428,7 +437,7 @@ See `mustache/EXTENSIONS.md`.
- cmark allocates via C malloc, not the arena. HTML output leaks until process exit (problematic in watch mode — see `TODOS.md`). - cmark allocates via C malloc, not the arena. HTML output leaks until process exit (problematic in watch mode — see `TODOS.md`).
- CSS/JS cache busting uses manual `?v=N` query params instead of content hashing. - CSS/JS cache busting uses manual `?v=N` query params instead of content hashing.
- Tree-sitter grammar/query paths for dynamic grammars hardcoded in `treesitter/treesitter.odin` (Nix store hashes, Helix-version-dependent). HTML/CSS are statically linked. - Tree-sitter grammar/query paths must be configured manually via `thor.json` (`grammars`, `queries`) — no auto-discovery. HTML/CSS are statically linked.
- `map[string]any` only works through `lookup_in`'s special-case handling; thor otherwise uses structs. - `map[string]any` only works through `lookup_in`'s special-case handling; thor otherwise uses structs.
- `format_f64` in mustache brute-forces shortest float representation. - `format_f64` in mustache brute-forces shortest float representation.
- Content directory not mounted in VFS (modules can ship templates/assets but not content packs yet). - Content directory not mounted in VFS (modules can ship templates/assets but not content packs yet).
+87
View File
@@ -0,0 +1,87 @@
# Timezone Support
## Goal
Add timezone conversion to the `format` pipe so dates display in the configured site timezone, with correct abbreviations for the `MST` token.
## Context
Dates are stored as raw ISO 8601 strings. The `format` pipe formats them for display using Go-style layout tokens. Currently `parse_iso_date` ignores the timezone offset suffix, and `MST` is hardcoded to `"UTC"`.
Pipes now take `ctx: []any`, so timezone config can flow through the data context identically to `date_format` — no new parameters to thread.
## Decisions
- **MST fallback** (no target tz, date has offset): `UTC-04:00` format
- **`now` field**: intentionally UTC (offset=0). The `format` pipe handles timezone display.
- **TZ_Region cache**: lives in the mustache package (`format.odin`)
## Files changed
| File | Changes |
|---|---|
| `mustache/format.odin` | Extend `Date_Components` (+`offset_seconds`, `has_offset`, `tz_abbr`). Extend `parse_iso_date` to parse trailing offset. Add `tz_cache`, `get_cached_tz`, `convert_to_tz`, `format_offset`, `destroy_tz_cache`. Fix MST token. Import `core:time/timezone`. |
| `mustache/pipes.odin` | Resolve `date_timezone` from ctx in `"format"` case (optional — nil is fine). Add `timezone_name` param to `apply_format`. Orchestrates parse → convert → format. |
| `render.odin` | Add `date_timezone: string` to `Base_Data`, populate from `site.date.timezone`. Remove `// TODO: CAlculate offset` (offset=0 is intentional — UTC). |
| `site.odin` | Call `mustache.destroy_tz_cache()` from `destroy_site`. No structural changes — `Date_Preferences.timezone` already exists and flows through config. |
| `mustache/pipes_test.odin` | Add `date_timezone: string` to test data structs. Add timezone conversion tests. |
## Conversion logic (in `apply_format`)
```
1. parse_iso_date(iso) → components (now includes offset_seconds, has_offset)
2. tz_name := resolve "date_timezone" from ctx (optional)
3. target_tz := get_cached_tz(tz_name) // nil if empty/UTC/not configured
4. if target_tz != nil:
components = convert_to_tz(components, target_tz)
// tz_abbr filled by convert_to_tz via timezone.shortname()
5. else if components.has_offset:
components.tz_abbr = format_offset(components.offset_seconds)
// e.g. "UTC-04:00"
6. else:
components.tz_abbr = "UTC"
7. format_date(components, fmt)
```
## `convert_to_tz` flow
```
1. Build DateTime from components (tz=nil=UTC)
2. If has_offset: add offset_seconds to get true UTC
3. timezone.datetime_to_tz(utc_dt, target_tz) → converted DateTime
4. Extract components from converted DateTime
5. tz_abbr = timezone.shortname(converted_dt) // "EST", "EDT", etc.
```
## MST fallback: `format_offset`
```
0 → "UTC"
-14400 → "UTC-04:00"
+19800 → "UTC+05:30"
```
## `now` field
`now` stays UTC (offset=0). Remove the `// TODO: CAlculate offset` comment — it's correct as-is. Templates format it with `{{now | format}}` and the pipe handles timezone display.
## Behavior matrix
| Config TZ | ISO has offset | Conversion | `MST` output |
|---|---|---|---|
| `"America/New_York"` | yes (`-04:00`) | UTC → NY (DST-aware) | `"EST"`/`"EDT"` |
| `"America/New_York"` | no | assume already in NY | `"EST"`/`"EDT"` |
| not set | yes (`-04:00`) | none — display as-is | `"UTC-04:00"` |
| not set | no | none | `"UTC"` |
## Imports added
- `mustache/format.odin`: `import "core:time/timezone"` (for `region_load`, `datetime_to_tz`, `shortname`, `region_destroy`)
## Odin timezone API reference
- `timezone.region_load(name: string) -> (^datetime.TZ_Region, bool)``"local"` reads `$TZ` env, falls back to `/etc/localtime`
- `timezone.region_destroy(region: ^datetime.TZ_Region)`
- `timezone.datetime_to_tz(dt: DateTime, tz: ^TZ_Region) -> (DateTime, bool)` — DST-aware. If `dt.tz == tz`, no-op. If `dt.tz == nil`, treats as UTC.
- `timezone.shortname(dt: DateTime) -> (string, bool)` — abbreviation from TZ_Region records (e.g. `"EST"`, `"EDT"`)
- `datetime.DateTime :: struct { using date: Date, using time: Time, tz: ^TZ_Region }``tz == nil` means UTC
+8 -9
View File
@@ -1,8 +1,6 @@
# Thor # Thor
[TOC] Thor is a simple Static Site Generator designed for personal blogs and other small websites.
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. Its core principals are simplicity and minimal configuration, so you can get started as quickly as possible.
@@ -16,14 +14,14 @@ It is based on Hugo, and gingerbill's SSG. Templating is done with (extended?) M
- Menus (WIP) - Menus (WIP)
- Extended Markdown ([See below](#extended-markdown)) - Extended Markdown ([See below](#extended-markdown))
- Basic (whitespace) minification. - Basic (whitespace) minification.
- Union File System (Modules)
## What it doesn't do ## What it doesn't do
- Internationalization - Internationalization
- Pagination (Yet) - Pagination (Yet)
- Themes - Themes
- Union File System (Yet) - Image Manipulation
- Image Manipulation - TailwindCSS integration
- TailwindCSS integration
## Getting Started ## Getting Started
@@ -34,7 +32,8 @@ Then follow [The Guide]()
For a more complete setup, run `thor new site`. For a more complete setup, run `thor new site`.
## Extended Markdown ## Extended Markdown
- Emoji expansion - Emoji expansion
- margin style footnotes - margin style footnotes
- Guthub style alerts - Github style alerts
- [and more] - [and more]
+61 -12
View File
@@ -1,3 +1,20 @@
## High priority
- Polish existing features before moving on to new ones.
- [ ] Improve diagnostics
- [x] Simplify / unify template context stack. Come up with a name for it.
- [x] `render_template` should accept `Template_Context`, not `any`
- [ ] Load grammars dynamically
- [ ] consider adding a limit to the context stack in mustache.
- [ ] better diagnostics for syntax errors in treesitter.
- [x] Add heading ids as a default on extension.
- [ ] show "stack traces" in template error diagnostics
- [ ] starred must be a param.
- [ ] Add a `#config(MAX_CONTEXT_DEPTH, 16?)` to `mustache`.
- [ ] menu system
- [ ] like Hugo's, but warn(/fail?) if menus are defined in the config *and* pages.
- i.e. force the user to choose one or the other.
## Performance ## Performance
- [ ] See if we can disable bounds checks in `write_indented` and elsewhere. - [ ] See if we can disable bounds checks in `write_indented` and elsewhere.
@@ -6,10 +23,30 @@
- [ ] Only publish referenced assets. - [ ] Only publish referenced assets.
- [ ] Split `load_page` into frontmatter-parse + body-process phases so draft pages can skip the markdown pipeline entirely - [ ] 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. - [ ] Use spall to find ways to reduce run time.
- [ ] Consider using `#soa` for Page lists. - [ ] 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
## 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 ## 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 opengraph.odin.
- [ ] Not sure whether to use temp allocator or site_allocator in `site_load_content`. - [ ] Not sure whether to use temp allocator or site_allocator in `site_load_content`.
- [ ] Might not need to allocate in `strip_html_tags` - [ ] Might not need to allocate in `strip_html_tags`
@@ -25,22 +62,38 @@
## Markdown ## Markdown
- [ ] Add overloads for every extension - accept ^strings.Builder. - [ ] Add overloads for every extension - accept ^strings.Builder.
- [ ] Add conventional (Hugo style) footnotes option. - [ ] Add conventional (Hugo style) footnotes option.
- [ ] Add heading ids as a default on extension.
- [ ] Add opt-in deflist support. - [ ] Add opt-in deflist support.
- [ ] Decide if lambdas actually provide any value. - [ ] Decide if lambdas actually provide any value.
## Dates ## Dates
- [x] Accept "strings"
- [x] Accept keys
- [ ] handle timezones
- [ ] display an error when no part of the date appears in the output. - [ ] display an error when no part of the date appears in the output.
- [x] use `date.format` as the default format.
- [ ] Handle 0 and whitespace padding i.e. "_2" -> " 2" - [ ] Handle 0 and whitespace padding i.e. "_2" -> " 2"
- [ ] Do we *need* mustache.Date_Components, or can we use core:time/datetime.DateTime?
- [ ] show a proper diagnostic for timezones
- currently "unable to load timezone 'America/New_Yorkskie'"
- want rust style diagnostic and better message, maybe "unknown timezone 'America/New_Yorkskie'"
## General ## General
- [ ] get rid of the global variables in the `treesitter` package.
- [ ] Consider using `or_else` when applying default values to structs. i.e.
```odin
package main
X :: struct {
foo: string
}
main :: proc () {
x: X
x.foo = x.foo or_else "bar"
}
```
- [ ] Integrity hash - [ ] Integrity hash
- Allows users to verify their output didn't change after upgrading to a new version - Allows users to verify their output didn't change after upgrading to a new version
- [ ] Content-hash fingerprinting for CSS and JS cache busting - [ ] Content-hash fingerprinting for CSS and JS cache busting
- [ ] merge `render_{section,home_html,page_html}` procs.
- [ ] try to combine render_page_html and render_home_html?
- [ ] Avoid `json.Value` / `json.Object` where possible. - [ ] Avoid `json.Value` / `json.Object` where possible.
- [ ] Create a json schema file for `thor.json`. - [ ] Create a json schema file for `thor.json`.
- [ ] make `parse` an overload of `parse_text/parse_inline` and `parse_file`, or something. - [ ] make `parse` an overload of `parse_text/parse_inline` and `parse_file`, or something.
@@ -55,11 +108,7 @@
(or whatever template is next in the chain) (or whatever template is next in the chain)
- [ ] Add `-production` flag - [ ] Add `-production` flag
- sets `-minify` - sets `-minify`
- [x] Mustache diagnostics - [ ] Mustache diagnostics
- [x] Rust-style error messages: position tracking on Node/Template/Data_Error, `diagnostic.odin` with `format_error`, ANSI colors via `core:terminal/ansi` (Phase 1+2+3)
- [x] Unknown-key detection with Levenshtein suggestions (`core:strings/levenshtein_distance`); warning severity (Phase 4+5)
- [x] Strict-by-default posture: warn on missing keys in `{{k}}`/`{{{k}}}`/`{{#k}}`/`{{^k}}`, missing partials, missing parents, unmatched block overrides
- [x] Block-override source-template tracking: warnings inside overrides point at the override's source file, not the parent template
- [ ] 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. - [ ] Partial invocation stack in diagnostics: when an error fires inside a partial, show "invoked from" chain through `{{> name}}` calls. Currently warnings inside partials point at the partial (correct file) but don't show the invocation site.
- [ ] Could be better error message when missing a closing (or opening) brace - [ ] Could be better error message when missing a closing (or opening) brace
- [ ] Error message doesn't show position of faulty pipe name correctly. - [ ] Error message doesn't show position of faulty pipe name correctly.
@@ -82,7 +131,6 @@
- [ ] include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin - [ ] include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin
- [ ] follow symlinks in `scan_content`? - [ ] follow symlinks in `scan_content`?
- [ ] ensure sidenote numbers render in display order and not in declaration order. - [ ] ensure sidenote numbers render in display order and not in declaration order.
- [x] We need to be able to do `Year_Section` in a non-magical, unprivileged way. Implemented via the pipes extension to mustache — see [mustache/EXTENSIONS.md](mustache/EXTENSIONS.md).
- [ ] Table of contents support. - [ ] Table of contents support.
- [ ] Nav items should be active when the current page is selected. - [ ] Nav items should be active when the current page is selected.
- [ ] Theme selector for syntax highlighting. - [ ] Theme selector for syntax highlighting.
@@ -108,6 +156,7 @@
- [ ] `new site` set up new project - [ ] `new site` set up new project
- [ ] warn/error when unknown key used in mustache. - [ ] warn/error when unknown key used in mustache.
- [ ] Import/export packages. Hugo, jekyll, WordPress, etc. - [ ] Import/export packages. Hugo, jekyll, WordPress, etc.
- [ ] opt-in "strict_keys" mode. in this mode, key lookups may not view parent objects.
## Notes ## Notes
+1 -1
View File
@@ -6,7 +6,7 @@
</head> </head>
<body> <body>
{{$nav}}{{/nav}} {{$nav}}{{/nav}}
{{$content}}{{/content}} {{$main}}{{/main}}
{{$sidebar}}{{/sidebar}} {{$sidebar}}{{/sidebar}}
{{$footer}}{{/footer}} {{$footer}}{{/footer}}
</body> </body>
+2 -2
View File
@@ -12,7 +12,7 @@
</ul> </ul>
</nav> </nav>
{{/nav}} {{/nav}}
{{$content}} {{$main}}
<main> <main>
<h1>Archive</h1> <h1>Archive</h1>
{{#posts | group_by year}} {{#posts | group_by year}}
@@ -26,7 +26,7 @@
</section> </section>
{{/posts}} {{/posts}}
</main> </main>
{{/content}} {{/main}}
{{$sidebar}} {{$sidebar}}
<aside> <aside>
<h3>Recent Comments</h3> <h3>Recent Comments</h3>
View File
+133 -25
View File
@@ -1,11 +1,13 @@
package main package main
import md "markdown" import md "markdown"
import ts "treesitter"
import "core:fmt" import "core:fmt"
import "core:log" import "core:log"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
import "core:time"
// Fields with underscores should never be set by the user. // Fields with underscores should never be set by the user.
Page :: struct { Page :: struct {
@@ -17,30 +19,62 @@ Page :: struct {
title: string, title: string,
description: string, description: string,
date: string, date: string,
year: string,
lastmod: string, lastmod: string,
menu: string, menu: string,
body_html: string, content: string,
og: Open_Graph, og: Open_Graph,
draft: bool, draft: bool,
is_starred: bool, starred: bool,
_is_index: bool `private`, _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. // site_load_content reads the content directory and populates site.pages.
// Drafts are excluded unless .Drafts is enabled. // Drafts are excluded unless .Drafts is enabled.
site_load_content :: proc(site: ^Site) { site_load_content :: proc(site: ^Site) {
site.pages = make([dynamic]Page, 0, 8, site_allocator(site)) site.pages = make(#soa[dynamic]Page, 0, 8, site_allocator(site))
scan_content(site, site.content_dir, "")
// 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 { for &page in site.pages {
page.url = fmt.tprintf("%s%s", site.base_url, page.permalink) page.url = fmt.tprintf("%s%s", site.base_url, page.permalink)
} }
} }
// scan_content walks the content directory. At the root level (section=""), // scan_content_files walks the content directory and collects Pending_File
// directories are treated as sections. Within a section, directories are // entries. At the root level (section=""), directories are treated as
// treated as leaf bundles (directory with an index file). // sections. Within a section, directories are treated as leaf bundles.
scan_content :: proc(site: ^Site, dir: string, section: string) { scan_content_files :: proc(dir: string, section: string, pending: ^[dynamic]Pending_File) {
entries, err := os.read_all_directory_by_path(dir, context.allocator) entries, err := os.read_all_directory_by_path(dir, context.allocator)
if err != nil { if err != nil {
log.warnf("cannot read %s: %v", dir, err) log.warnf("cannot read %s: %v", dir, err)
@@ -59,30 +93,34 @@ scan_content :: proc(site: ^Site, dir: string, section: string) {
is_idx := filename == "index" is_idx := filename == "index"
slug := is_idx ? "" : filename slug := is_idx ? "" : filename
page, ok := load_page(entry.fullpath, section, slug, is_idx, site.markdown_extensions) append(
if ok && (!page.draft || .Drafts in site.features) { pending,
append(&site.pages, page) Pending_File {
} path = strings.clone(entry.fullpath, context.temp_allocator),
section = section,
slug = slug,
is_index = is_idx,
},
)
case .Directory: case .Directory:
if section == "" { if section == "" {
scan_content(site, entry.fullpath, entry.name) scan_content_files(entry.fullpath, entry.name, pending)
} else { } else {
index_path := fmt.tprintf("%s/index.html", entry.fullpath) index_path := fmt.tprintf("%s/index.html", entry.fullpath)
if !os.exists(index_path) { if !os.exists(index_path) {
index_path = fmt.tprintf("%s/index.md", entry.fullpath) index_path = fmt.tprintf("%s/index.md", entry.fullpath)
} }
if os.exists(index_path) { if os.exists(index_path) {
page, ok := load_page( append(
index_path, pending,
section, Pending_File {
entry.name, path = strings.clone(index_path, context.temp_allocator),
false, section = section,
site.markdown_extensions, 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: case .Undetermined, .Symlink, .Named_Pipe, .Socket, .Block_Device, .Character_Device:
@@ -90,6 +128,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 { infer_layout :: proc(section: string, is_index: bool) -> string {
if section == "" && is_index { if section == "" && is_index {
return "home" return "home"
@@ -134,17 +233,26 @@ load_page :: proc(
page.title = fm.title page.title = fm.title
page.description = fm.description page.description = fm.description
page.date = fm.date 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.lastmod = fm.lastmod page.lastmod = fm.lastmod
page.draft = fm.draft page.draft = fm.draft
page.is_starred = fm.isStarred page.starred = fm.isStarred
page.menu = fm.menu page.menu = fm.menu
page.layout = fm.layout if fm.layout != "" else infer_layout(section, is_index) page.layout = fm.layout if fm.layout != "" else infer_layout(section, is_index)
page.og = fm.og page.og = fm.og
if strings.has_suffix(file_path, ".html") { if strings.has_suffix(file_path, ".html") {
page.body_html = strings.clone(body) page.content = strings.clone(body)
} else { } else {
page.body_html = md.process(body, ext, file_path) page.content = md.process(body, ext, file_path)
} }
if section == "" && is_index { if section == "" && is_index {
+1 -1
View File
@@ -10,7 +10,7 @@
</head> </head>
<body> <body>
{{> nav}}{{$content}}{{/content}} {{> nav}}{{$main}}{{/main}}
{{> footer}} {{> footer}}
</body> </body>
+3 -3
View File
@@ -1,8 +1,8 @@
{{<base}} {{<base}}
{{$content}} {{$main}}
<main> <main>
<header> <header>
{{&body}} {{&content}}
</header> </header>
<ul> <ul>
{{#pages}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date_iso}}<time {{#pages}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date_iso}}<time
@@ -11,5 +11,5 @@
{{/pages}} {{/pages}}
</ul> </ul>
</main> </main>
{{/content}} {{/main}}
{{/base}} {{/base}}
+4 -4
View File
@@ -1,11 +1,11 @@
{{<base}} {{<base}}
{{$content}} {{$main}}
<main> <main>
<article> <article>
<h1>{{page_title}}</h1> <h1>{{page.title}}</h1>
{{#date_iso}} <time class="subtitle" datetime="{{date_iso}}">{{date_display}}</time> {{#date_iso}} <time class="subtitle" datetime="{{date_iso}}">{{date_display}}</time>
{{/date_iso}} {{&body}} {{/date_iso}} {{&content}}
</article> </article>
</main> </main>
{{/content}} {{/main}}
{{/base}} {{/base}}
+4 -4
View File
@@ -1,8 +1,8 @@
{{<base}} {{<base}}
{{$content}} {{$main}}
<main> <main>
<h1>{{page_title}}</h1> <h1>{{page.title}}</h1>
{{&body}} {{&content}}
{{#posts | group_by year}} {{#posts | group_by year}}
<section> <section>
<h2>{{key}}</h2> <h2>{{key}}</h2>
@@ -15,5 +15,5 @@
</section> </section>
{{/posts}} {{/posts}}
</main> </main>
{{/content}} {{/main}}
{{/base}} {{/base}}
+2 -5
View File
@@ -50,7 +50,7 @@ generate_rss :: proc(site: ^Site) -> string {
page.url, page.url,
pub_date, pub_date,
page.url, page.url,
xml_escape(page.body_html), xml_escape(page.content),
), ),
) )
} }
@@ -74,10 +74,7 @@ generate_sitemap :: proc(site: ^Site) -> string {
if page.date != "" { if page.date != "" {
lastmod = fmt.aprintf("<lastmod>%s</lastmod>", page.date) lastmod = fmt.aprintf("<lastmod>%s</lastmod>", page.date)
} }
strings.write_string( strings.write_string(&sb, fmt.aprintf("<url><loc>%s</loc>%s</url>\n", page.url, lastmod))
&sb,
fmt.aprintf("<url><loc>%s</loc>%s</url>\n", page.url, lastmod),
)
} }
// Section index pages (for sections without an index in content) // Section index pages (for sections without an index in content)
+1 -1
View File
@@ -133,7 +133,7 @@
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild
odin build . -o:speed -out:${pname}-keep odin build . -o:speed -no-bounds-check -out:${pname}-keep
runHook postBuild runHook postBuild
''; '';
+146 -66
View File
@@ -29,7 +29,7 @@ strip_html_tags :: proc(s: string, allocator := context.allocator) -> string {
} }
unescape_html :: proc(s: string) -> string { unescape_html :: proc(s: string) -> string {
sb := strings.builder_make() sb := strings.builder_make_len_cap(0, len(s))
defer strings.builder_destroy(&sb) defer strings.builder_destroy(&sb)
start := 0 start := 0
@@ -41,15 +41,21 @@ unescape_html :: proc(s: string) -> string {
if semi < 0 { if semi < 0 {
break break
} }
entity := s[i : i + semi + 1] entity := s[i:i + semi + 1]
replacement := "" replacement := ""
switch entity { switch entity {
case "&amp;": replacement = "&" case "&amp;":
case "&lt;": replacement = "<" replacement = "&"
case "&gt;": replacement = ">" case "&lt;":
case "&quot;": replacement = "\"" replacement = "<"
case "&#39;", "&apos;": replacement = "'" case "&gt;":
case: continue replacement = ">"
case "&quot;":
replacement = "\""
case "&#39;", "&apos;":
replacement = "'"
case:
continue
} }
if i > start { if i > start {
strings.write_string(&sb, s[start:i]) strings.write_string(&sb, s[start:i])
@@ -66,72 +72,146 @@ unescape_html :: proc(s: string) -> string {
return strings.to_string(sb) return strings.to_string(sb)
} }
// generate_summary produces a plain-text summary of an HTML fragment. // generate_summary truncates an HTML string to the first max_words words.
// Blocks (paragraphs, headings, list items) are extracted, their tags // Walks forward counting whitespace→text transitions, skipping tag interiors
// stripped, entities decoded, and accumulated word-by-word until the // so spaces inside attributes don't count. Returns a substring of the
// max_words threshold is crossed — at which point the rest of the // original — zero allocation. Mirrors Hugo's default (70 words).
// current block is included before stopping. Mirrors Hugo's default
// summary behavior.
generate_summary :: proc(html: string, max_words: int = 70) -> string { generate_summary :: proc(html: string, max_words: int = 70) -> string {
separated, _ := strings.replace_all(html, "</p>", "\n\n", context.temp_allocator) if max_words <= 0 {
separated, _ = strings.replace_all(separated, "</h1>", "\n\n") return ""
separated, _ = strings.replace_all(separated, "</h2>", "\n\n") }
separated, _ = strings.replace_all(separated, "</h3>", "\n\n")
separated,_ = strings.replace_all(separated, "</h4>", "\n\n")
separated, _ = strings.replace_all(separated, "</h5>", "\n\n")
separated, _ = strings.replace_all(separated, "</h6>", "\n\n")
separated, _ = strings.replace_all(separated, "</li>", "\n\n")
separated, _ = strings.replace_all(separated, "</blockquote>", "\n\n")
stripped := strip_html_tags(separated, context.temp_allocator)
plain := unescape_html(stripped)
blocks := strings.split(plain, "\n\n", allocator = context.temp_allocator)
defer delete(blocks)
sb := strings.builder_make(context.temp_allocator)
defer strings.builder_destroy(&sb)
word_count := 0 word_count := 0
first := true in_word := false
for raw_block in blocks { in_tag := false
block := strings.trim_space(raw_block) for i in 0 ..< len(html) {
if len(block) == 0 { c := html[i]
if in_tag {
if c == '>' {
in_tag = false
}
continue continue
} }
if c == '<' {
// Collapse internal whitespace to single spaces. in_tag = true
block_sb := strings.builder_make(context.temp_allocator) if in_word {
has_content := false word_count += 1
in_space := true if word_count >= max_words {
for c in block { return html[:i]
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
in_space = true
} else {
if in_space && has_content {
strings.write_byte(&block_sb, ' ')
} }
strings.write_rune(&block_sb, c) in_word = false
in_space = false
has_content = true
} }
continue
} }
collapsed := strings.to_string(block_sb) is_space := c == ' ' || c == '\n' || c == '\t' || c == '\r'
words := strings.split(collapsed, " ", allocator = context.temp_allocator) if is_space {
if in_word {
if !first && word_count > 0 { word_count += 1
strings.write_byte(&sb, ' ') if word_count >= max_words {
} return html[:i]
strings.write_string(&sb, collapsed) }
word_count += len(words) in_word = false
first = false }
} else {
delete(words) in_word = true
if word_count >= max_words {
break
} }
} }
return html
}
return strings.to_string(sb) // 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
} }
+107
View File
@@ -0,0 +1,107 @@
#+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")
}
+28
View File
@@ -0,0 +1,28 @@
package main
import "core:fmt"
Ctx :: struct {
title: string,
using page: Page,
site: Site,
}
Page :: struct {
title: string,
}
Site :: struct {
title: string,
}
main :: proc() {
site := Ctx {
site = Site{title = "foo"},
page = Page{title = "bar"},
}
fmt.printfln("%+v", site)
fmt.printf("%+v", site)
}
+20
View File
@@ -7,12 +7,23 @@ import "core:prof/spall"
import "core:sync" import "core:sync"
import "core:time" import "core:time"
import "treesitter"
SPALL :: #config(SPALL, false) SPALL :: #config(SPALL, false)
when SPALL { when SPALL {
spall_ctx: spall.Context spall_ctx: spall.Context
@(thread_local) @(thread_local)
spall_buffer: spall.Buffer 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() { main :: proc() {
@@ -38,6 +49,12 @@ main :: proc() {
context.logger = console_logger context.logger = console_logger
defer log.destroy_console_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 { for {
defer free_all(context.temp_allocator) defer free_all(context.temp_allocator)
tick := time.tick_now() tick := time.tick_now()
@@ -48,6 +65,9 @@ main :: proc() {
context.allocator = site_allocator(&site) context.allocator = site_allocator(&site)
build_vfs(&site) build_vfs(&site)
treesitter.grammar_dir = site.grammars
treesitter.query_dir = site.queries
site_load_content(&site) site_load_content(&site)
render_site(&site) render_site(&site)
log.infof("Built site in %s", time.tick_since(tick)) log.infof("Built site in %s", time.tick_since(tick))
+196
View File
@@ -0,0 +1,196 @@
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 ""
}
+96
View File
@@ -0,0 +1,96 @@
#+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>`,
)
}
+83 -57
View File
@@ -16,7 +16,7 @@ find_first_error_line :: proc(root: ts.Node) -> int {
if ts.node_is_error(root) { if ts.node_is_error(root) {
return int(ts.node_start_point(root).row) + 1 return int(ts.node_start_point(root).row) + 1
} }
for i in 0..<ts.node_child_count(root) { for i in 0 ..< ts.node_child_count(root) {
child := ts.node_child(root, u32(i)) child := ts.node_child(root, u32(i))
if ts.node_has_error(child) { if ts.node_has_error(child) {
line := find_first_error_line(child) line := find_first_error_line(child)
@@ -28,55 +28,66 @@ find_first_error_line :: proc(root: ts.Node) -> int {
return 0 return 0
} }
capture_name_to_css :: proc(name: string) -> string { write_span_open :: proc(b: ^strings.Builder, buf: ^[128]u8, name: string) {
sb := strings.builder_make() pos := 0
seg := strings.builder_make()
prefix := "<span class=\""
for i in 0 ..< len(prefix) {
buf[pos] = prefix[i]
pos += 1
}
first := true first := true
for i in 0..<len(name) { for i in 0 ..< len(name) {
if name[i] == '.' { if name[i] == '.' {
if !first do strings.write_byte(&sb, ' ') if !first {buf[pos] = ' '; pos += 1}
first = false first = false
strings.write_string(&sb, "hl-") buf[pos] = 'h'; buf[pos + 1] = 'l'; buf[pos + 2] = '-'; pos += 3
strings.write_string(&sb, strings.to_string(seg)) for j in 0 ..< i {
strings.write_byte(&seg, '-') buf[pos] = '-' if name[j] == '.' else name[j]
} else { pos += 1
strings.write_byte(&seg, name[i]) }
} }
} }
if !first do strings.write_byte(&sb, ' ') if !first {buf[pos] = ' '; pos += 1}
strings.write_string(&sb, "hl-") buf[pos] = 'h'; buf[pos + 1] = 'l'; buf[pos + 2] = '-'; pos += 3
strings.write_string(&sb, strings.to_string(seg)) for j in 0 ..< len(name) {
return strings.to_string(sb) 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]))
} }
escape_html :: proc(s: string) -> string { write_escaped :: proc(b: ^strings.Builder, s: string) {
sb := strings.builder_make()
defer strings.builder_destroy(&sb)
start := 0 start := 0
for i in 0..<len(s) { for i in 0 ..< len(s) {
switch s[i] { switch s[i] {
case '&': case '&':
if i > start do strings.write_string(&sb, s[start:i]) if i > start do strings.write_string(b, s[start:i])
strings.write_string(&sb, "&amp;") strings.write_string(b, "&amp;")
start = i + 1 start = i + 1
case '<': case '<':
if i > start do strings.write_string(&sb, s[start:i]) if i > start do strings.write_string(b, s[start:i])
strings.write_string(&sb, "&lt;") strings.write_string(b, "&lt;")
start = i + 1 start = i + 1
case '>': case '>':
if i > start do strings.write_string(&sb, s[start:i]) if i > start do strings.write_string(b, s[start:i])
strings.write_string(&sb, "&gt;") strings.write_string(b, "&gt;")
start = i + 1 start = i + 1
case '"': case '"':
if i > start do strings.write_string(&sb, s[start:i]) if i > start do strings.write_string(b, s[start:i])
strings.write_string(&sb, "&quot;") strings.write_string(b, "&quot;")
start = i + 1 start = i + 1
} }
} }
if start == 0 do return s if start == 0 {
if start < len(s) do strings.write_string(&sb, s[start:]) strings.write_string(b, s)
return strings.to_string(sb) } else if start < len(s) {
strings.write_string(b, s[start:])
}
} }
unescape_html :: proc(s: string) -> string { unescape_html :: proc(s: string) -> string {
@@ -84,19 +95,25 @@ unescape_html :: proc(s: string) -> string {
defer strings.builder_destroy(&sb) defer strings.builder_destroy(&sb)
start := 0 start := 0
for i in 0..<len(s) { for i in 0 ..< len(s) {
if s[i] != '&' do continue if s[i] != '&' do continue
semi := strings.index(s[i:], ";") semi := strings.index(s[i:], ";")
if semi < 0 do break if semi < 0 do break
entity := s[i : i + semi + 1] entity := s[i:i + semi + 1]
replacement := "" replacement := ""
switch entity { switch entity {
case "&amp;": replacement = "&" case "&amp;":
case "&lt;": replacement = "<" replacement = "&"
case "&gt;": replacement = ">" case "&lt;":
case "&quot;": replacement = "\"" replacement = "<"
case "&#39;", "&apos;": replacement = "'" case "&gt;":
case: continue replacement = ">"
case "&quot;":
replacement = "\""
case "&#39;", "&apos;":
replacement = "'"
case:
continue
} }
if i > start do strings.write_string(&sb, s[start:i]) if i > start do strings.write_string(&sb, s[start:i])
strings.write_string(&sb, replacement) strings.write_string(&sb, replacement)
@@ -128,21 +145,25 @@ highlight_block :: proc(code: string, lang: string, file_path: string) -> string
if ts.node_has_error(root) { if ts.node_has_error(root) {
line := find_first_error_line(root) line := find_first_error_line(root)
if line > 0 { if line > 0 {
log.warnf("highlight: syntax errors in %s code block at line %d (%s)", lang, line, file_path) log.warnf(
"highlight: syntax errors in %s code block at line %d (%s)",
lang,
line,
file_path,
)
} else { } else {
log.warnf("highlight: syntax errors in %s code block (%s)", lang, file_path) log.warnf("highlight: syntax errors in %s code block (%s)", lang, file_path)
} }
} }
cursor := ts.query_cursor_new() cursor := gc.cursor
if cursor == nil { if cursor == nil {
return code return code
} }
defer ts.query_cursor_delete(cursor)
ts.query_cursor_exec(cursor, gc.query, root) ts.query_cursor_exec(cursor, gc.query, root)
captures: [dynamic]Capture captures := make([dynamic]Capture, 0, 64, context.temp_allocator)
defer delete(captures) defer delete(captures)
match: ts.Query_Match match: ts.Query_Match
@@ -162,29 +183,34 @@ highlight_block :: proc(code: string, lang: string, file_path: string) -> string
if len(name_full) > int(name_len) { if len(name_full) > int(name_len) {
name = name_full[:int(name_len)] name = name_full[:int(name_len)]
} }
append(&captures, Capture{ append(
start = ts.node_start_byte(cap.node), &captures,
end = ts.node_end_byte(cap.node), Capture {
name = name, start = ts.node_start_byte(cap.node),
}) end = ts.node_end_byte(cap.node),
name = name,
},
)
} }
if len(captures) == 0 { if len(captures) == 0 {
return code return code
} }
sb := strings.builder_make() sb := strings.builder_make_len(len(code) * 2)
last_pos: u32 = 0 last_pos: u32 = 0
stack: [dynamic]Capture stack := make([dynamic]Capture, 0, 16, context.temp_allocator)
defer delete(stack) defer delete(stack)
buf: [128]u8
for cap in captures { for cap in captures {
for len(stack) > 0 { for len(stack) > 0 {
top := stack[len(stack) - 1] top := stack[len(stack) - 1]
if top.end <= cap.start { if top.end <= cap.start {
if top.end > last_pos { if top.end > last_pos {
strings.write_string(&sb, escape_html(raw_code[last_pos:top.end])) write_escaped(&sb, raw_code[last_pos:top.end])
} }
strings.write_string(&sb, "</span>") strings.write_string(&sb, "</span>")
last_pos = top.end last_pos = top.end
@@ -195,26 +221,25 @@ highlight_block :: proc(code: string, lang: string, file_path: string) -> string
} }
if cap.start > last_pos { if cap.start > last_pos {
strings.write_string(&sb, escape_html(raw_code[last_pos:cap.start])) write_escaped(&sb, raw_code[last_pos:cap.start])
last_pos = cap.start last_pos = cap.start
} }
css_class := capture_name_to_css(cap.name) write_span_open(&sb, &buf, cap.name)
strings.write_string(&sb, fmt.tprintf("<span class=\"%s\">", css_class))
append(&stack, cap) append(&stack, cap)
} }
for len(stack) > 0 { for len(stack) > 0 {
top := pop(&stack) top := pop(&stack)
if top.end > last_pos { if top.end > last_pos {
strings.write_string(&sb, escape_html(raw_code[last_pos:top.end])) write_escaped(&sb, raw_code[last_pos:top.end])
} }
strings.write_string(&sb, "</span>") strings.write_string(&sb, "</span>")
last_pos = top.end last_pos = top.end
} }
if int(last_pos) < len(raw_code) { if int(last_pos) < len(raw_code) {
strings.write_string(&sb, escape_html(raw_code[last_pos:])) write_escaped(&sb, raw_code[last_pos:])
} }
return strings.to_string(sb) return strings.to_string(sb)
@@ -266,7 +291,7 @@ highlight_code :: proc(html: string, file_path: string) -> string {
code := html[code_start:end_idx] code := html[code_start:end_idx]
highlighted := highlight_block(code, lang, file_path) highlighted := highlight_block(code, lang, file_path)
strings.write_string(&sb, fmt.tprintf(`<pre><code class="language-%s">%s</code></pre>`, lang, highlighted)) fmt.sbprintf(&sb, `<pre><code class="language-%s">%s</code></pre>`, lang, highlighted)
pos = end_idx + len(CODE_END) pos = end_idx + len(CODE_END)
} }
@@ -280,3 +305,4 @@ highlight_code :: proc(html: string, file_path: string) -> string {
} }
return strings.to_string(sb) return strings.to_string(sb)
} }
+9 -1
View File
@@ -11,9 +11,10 @@ Extension :: enum {
Alerts, Alerts,
Highlight, Highlight,
Sections, Sections,
HeadingIDs,
} }
DEFAULT_EXTENSIONS :: bit_set[Extension]{.Emoji, .Sidenotes, .Alerts} DEFAULT_EXTENSIONS :: bit_set[Extension]{.Emoji, .Sidenotes, .Alerts, .HeadingIDs}
process :: proc(body: string, ext: bit_set[Extension], file_path: string) -> string { process :: proc(body: string, ext: bit_set[Extension], file_path: string) -> string {
side_notes := make(map[string]string) side_notes := make(map[string]string)
@@ -35,6 +36,9 @@ process :: proc(body: string, ext: bit_set[Extension], file_path: string) -> str
if .Highlight in ext { if .Highlight in ext {
html = highlight_code(html, file_path) html = highlight_code(html, file_path)
} }
if .HeadingIDs in ext {
html = inject_heading_ids(html)
}
if .Sections in ext { if .Sections in ext {
html = wrap_sections(html) html = wrap_sections(html)
} }
@@ -56,6 +60,8 @@ parse_extension_list :: proc(s: string) -> (result: bit_set[Extension]) {
result += {.Highlight} result += {.Highlight}
case "sections": case "sections":
result += {.Sections} result += {.Sections}
case "heading_ids":
result += {.HeadingIDs}
} }
} }
return result return result
@@ -77,6 +83,8 @@ apply_extension_config :: proc(ext: ^bit_set[Extension], config: json.Object) {
if enabled {ext^ += {.Highlight}} else {ext^ -= {.Highlight}} if enabled {ext^ += {.Highlight}} else {ext^ -= {.Highlight}}
case "sections": case "sections":
if enabled {ext^ += {.Sections}} else {ext^ -= {.Sections}} if enabled {ext^ += {.Sections}} else {ext^ -= {.Sections}}
case "heading_ids":
if enabled {ext^ += {.HeadingIDs}} else {ext^ -= {.HeadingIDs}}
} }
} }
} }
+20
View File
@@ -67,6 +67,26 @@ Takes an optional arg for the Go reference-date layout to use:
- 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. - 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`). - 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 ### 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. - **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.
+162 -7
View File
@@ -5,14 +5,18 @@ import "core:log"
import "core:strings" import "core:strings"
import "core:time" import "core:time"
import "core:time/datetime" import "core:time/datetime"
import "core:time/timezone"
Date_Components :: struct { Date_Components :: struct {
year: int, year: int,
month: int, month: int,
day: int, day: int,
hour: int, hour: int,
minute: int, minute: int,
second: int, second: int,
offset_seconds: int,
has_offset: bool,
tz_abbr: string,
} }
// TODO: Use some kind of scanner interface // TODO: Use some kind of scanner interface
@@ -38,6 +42,9 @@ parse_iso_date :: proc(iso: string) -> (c: Date_Components, ok: bool) {
c.second = parse_2_digits(iso, 17) c.second = parse_2_digits(iso, 17)
} }
parse_offset(iso, &c)
c.tz_abbr = "UTC"
return c, true return c, true
} }
@@ -48,6 +55,54 @@ parse_2_digits :: proc(s: string, offset: int) -> int {
return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30) return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30)
} }
// 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( format_date :: proc(
dt: Date_Components, dt: Date_Components,
fmt: string, fmt: string,
@@ -80,7 +135,12 @@ match_token :: proc(b: ^strings.Builder, dt: Date_Components, s: string) -> int
s, s,
"2006", "2006",
) {strings.write_string(b, fmt.tprintf("%04d", dt.year)); return 4} ) {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, "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, "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, "Mon") {emit_weekday(b, dt, full = false); return 3}
if strings.has_prefix( if strings.has_prefix(
@@ -167,3 +227,98 @@ emit_am_pm_lower :: proc(b: ^strings.Builder, dt: Date_Components) {
strings.write_string(b, "pm" if dt.hour >= 12 else "am") 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
}
+133 -3
View File
@@ -2,6 +2,7 @@
package mustache package mustache
import "core:testing" import "core:testing"
import "core:time/timezone"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// parse_iso_date // parse_iso_date
@@ -17,6 +18,9 @@ test_parse_iso_date_extracts_time :: proc(t: ^testing.T) {
testing.expect_value(t, c.hour, 8) testing.expect_value(t, c.hour, 8)
testing.expect_value(t, c.minute, 49) testing.expect_value(t, c.minute, 49)
testing.expect_value(t, c.second, 54) 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)
@@ -26,6 +30,8 @@ test_parse_iso_date_lowercase_t :: proc(t: ^testing.T) {
testing.expect_value(t, c.hour, 8) testing.expect_value(t, c.hour, 8)
testing.expect_value(t, c.minute, 49) testing.expect_value(t, c.minute, 49)
testing.expect_value(t, c.second, 54) 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)
@@ -35,6 +41,7 @@ test_parse_iso_date_date_only_zero_time :: proc(t: ^testing.T) {
testing.expect_value(t, c.hour, 0) testing.expect_value(t, c.hour, 0)
testing.expect_value(t, c.minute, 0) testing.expect_value(t, c.minute, 0)
testing.expect_value(t, c.second, 0) testing.expect_value(t, c.second, 0)
testing.expect_value(t, c.has_offset, false)
} }
@(test) @(test)
@@ -49,6 +56,47 @@ test_parse_iso_date_too_short_errors :: proc(t: ^testing.T) {
testing.expect(t, !ok, "input shorter than 10 chars should fail") 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 // format_date / match_token
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -140,9 +188,9 @@ test_format_date_month_day_numeric_padding :: proc(t: ^testing.T) {
} }
@(test) @(test)
test_format_date_mst_always_utc :: proc(t: ^testing.T) { test_format_date_mst_defaults_utc :: proc(t: ^testing.T) {
// Date_Components carries no offset yet, so MST is a hardcoded // Date_Components constructed directly (not via parse_iso_date)
// placeholder until real timezone support lands. // defaults to UTC for the MST token.
dt := Date_Components{year = 2026, month = 1, day = 1, hour = 12} dt := Date_Components{year = 2026, month = 1, day = 1, hour = 12}
result := format_date(dt, "MST") result := format_date(dt, "MST")
testing.expect_value(t, result, "UTC") testing.expect_value(t, result, "UTC")
@@ -162,3 +210,85 @@ test_format_date_combined_go_reference_layout :: proc(t: ^testing.T) {
result := format_date(dt, "Mon Jan 2 15:04:05 MST 2006") 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") 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")
}
+16 -1
View File
@@ -1,5 +1,6 @@
package mustache package mustache
import "base:runtime"
import "core:fmt" import "core:fmt"
import "core:log" import "core:log"
import "core:reflect" import "core:reflect"
@@ -147,7 +148,21 @@ render :: proc(
ctx := make([dynamic]any, 0, 4, allocator) ctx := make([dynamic]any, 0, 4, allocator)
defer delete(ctx) defer delete(ctx)
append(&ctx, data)
// If data is a []any, expand into individual context frames.
// Otherwise, push as a single frame.
elem_info, count, slice_data := list_info(data)
if elem_info != nil {
if _, is_any := elem_info.variant.(runtime.Type_Info_Any); is_any {
for j in 0 ..< count {
append(&ctx, extract_list_element(elem_info, slice_data, j))
}
} else {
append(&ctx, data)
}
} else {
append(&ctx, data)
}
all_nodes := tmpl.nodes[:] all_nodes := tmpl.nodes[:]
err = render_nodes(tmpl, all_nodes, &ctx, partials, &builder) err = render_nodes(tmpl, all_nodes, &ctx, partials, &builder)
+10 -2
View File
@@ -5,6 +5,7 @@ import "core:log"
import "core:reflect" import "core:reflect"
import "core:strings" import "core:strings"
import "core:time" import "core:time"
import "core:time/datetime"
// MAX_PIPES was chosen arbitrarily. It holds no performance or logical // MAX_PIPES was chosen arbitrarily. It holds no performance or logical
// significance. // significance.
@@ -228,7 +229,8 @@ apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) ->
date_format = df date_format = df
} }
str2, err := apply_format(str, filter.args[:], pos, date_format) tz := resolve_tz(ctx)
str2, err := apply_format(str, filter.args[:], pos, date_format, tz)
if err != nil { if err != nil {
return value, err return value, err
} else { } else {
@@ -249,6 +251,7 @@ apply_format :: proc(
args: []string, args: []string,
pos: int, pos: int,
date_format: string, date_format: string,
tz: ^datetime.TZ_Region,
) -> ( ) -> (
result: string, result: string,
err: Error, err: Error,
@@ -270,7 +273,12 @@ apply_format :: proc(
} }
} }
log.debugf("date: '%s' format: '%s'", iso, date_format) 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 return format_date(components, fmt_str), nil
} }
+66
View File
@@ -3,6 +3,8 @@ package mustache
import "core:fmt" import "core:fmt"
import "core:testing" import "core:testing"
import "core:time/datetime"
import "core:time/timezone"
Pipe_Post :: struct { Pipe_Post :: struct {
title: string, title: string,
@@ -209,6 +211,7 @@ test_interp_pipe_basic :: proc(t: ^testing.T) {
Scalar_Data :: struct { Scalar_Data :: struct {
name: string, name: string,
date_format: string, date_format: string,
timezone: ^datetime.TZ_Region,
} }
data := Scalar_Data { data := Scalar_Data {
name = "2026-03-15T08:49:54-04:00", name = "2026-03-15T08:49:54-04:00",
@@ -224,6 +227,7 @@ test_interp_pipe_unescaped :: proc(t: ^testing.T) {
Scalar_Data :: struct { Scalar_Data :: struct {
name: string, name: string,
date_format: string, date_format: string,
timezone: ^datetime.TZ_Region,
} }
data := Scalar_Data { data := Scalar_Data {
name = "2025-12-25T00:00:00Z", name = "2025-12-25T00:00:00Z",
@@ -239,6 +243,7 @@ test_interp_pipe_dot_current :: proc(t: ^testing.T) {
List_Data :: struct { List_Data :: struct {
items: [3]string, items: [3]string,
date_format: string, date_format: string,
timezone: ^datetime.TZ_Region,
} }
data := List_Data { data := List_Data {
items = {"2026-01-06T00:00:00Z", "2026-06-15T00:00:00Z", "2026-10-15T00:00:00Z"}, items = {"2026-01-06T00:00:00Z", "2026-06-15T00:00:00Z", "2026-10-15T00:00:00Z"},
@@ -256,6 +261,7 @@ test_interp_pipe_dot_current :: proc(t: ^testing.T) {
Format_Data :: struct { Format_Data :: struct {
date: string, date: string,
date_format: string, date_format: string,
timezone: ^datetime.TZ_Region,
} }
@(test) @(test)
@@ -483,3 +489,63 @@ test_format_bare_numeric_arg_treated_as_key_not_literal :: proc(t: ^testing.T) {
_, err := render(tpl, data, {}, context.temp_allocator) _, err := render(tpl, data, {}, context.temp_allocator)
testing.expect(t, err != nil, "bare numeric-looking arg should error as an unresolved key") 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")
}
+3 -3
View File
@@ -12,7 +12,7 @@ Inner :: struct {
Outer :: struct { Outer :: struct {
title: string, title: string,
page_title: string, page.title: string,
inner: Inner, inner: Inner,
numbers: [3]int, numbers: [3]int,
} }
@@ -150,8 +150,8 @@ test_validate_map_path_silent :: proc(t: ^testing.T) {
@(test) @(test)
test_suggest_correction_exact :: proc(t: ^testing.T) { test_suggest_correction_exact :: proc(t: ^testing.T) {
available := []string{"title", "page_title", "body"} available := []string{"title", "page.title", "body"}
testing.expect_value(t, suggest_correction(available, "page_titel"), "page_title") testing.expect_value(t, suggest_correction(available, "page_titel"), "page.title")
} }
@(test) @(test)
+3 -2
View File
@@ -70,8 +70,8 @@ og_for_page :: proc(site_og: Open_Graph, page: Page) -> Open_Graph {
og.description = page.description og.description = page.description
description_set = true description_set = true
} }
if !description_set && is_article && page.body_html != "" { if !description_set && is_article && page.content != "" {
og.description = generate_summary(page.body_html) og.description = generate_description(generate_summary(page.content))
description_set = true description_set = true
} }
if !description_set { if !description_set {
@@ -115,3 +115,4 @@ og_for_page :: proc(site_og: Open_Graph, page: Page) -> Open_Graph {
return og return og
} }
+57 -96
View File
@@ -2,7 +2,6 @@ package main
import "mustache" import "mustache"
import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:log" import "core:log"
import "core:os" import "core:os"
@@ -10,49 +9,21 @@ import "core:strings"
import "core:time" import "core:time"
import "core:time/datetime" import "core:time/datetime"
Page_Context :: struct { Template_Context :: struct {
permalink: string,
title: string,
starred: bool,
date: string,
year: string,
}
Base_Data :: struct {
now: string, now: string,
params: json.Value,
body: string,
title: string, title: string,
description: string,
og: Open_Graph,
date_format: string, date_format: string,
} timezone: ^datetime.TZ_Region,
og: Open_Graph,
site: Site_Context,
page: Page,
Page_Data :: struct { // Home data
using base: Base_Data, pages: [dynamic]Page,
page_title: string,
date: string,
}
Home_Data :: struct { // Section Data
using base: Base_Data, // TODO: Remove "posts" from the Odin code
pages: [dynamic]Page_Context, posts: [dynamic]Page,
}
Section_Data :: struct {
using base: Base_Data,
page_title: string,
posts: [dynamic]Page_Context,
}
build_page_context :: proc(page: Page) -> Page_Context {
return Page_Context {
permalink = page.permalink,
title = page.title,
starred = page.is_starred,
date = page.date,
year = get_year(page.date),
}
} }
load_template :: proc(vfs: ^VFS, virtual_path: string) -> mustache.Template { load_template :: proc(vfs: ^VFS, virtual_path: string) -> mustache.Template {
@@ -130,10 +101,10 @@ capitalize :: proc(s: string) -> string {
render_template :: proc( render_template :: proc(
content_tpl: mustache.Template, content_tpl: mustache.Template,
data: any, ctx: Template_Context,
partials: map[string]mustache.Template, partials: map[string]mustache.Template,
) -> string { ) -> 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 { if err != nil {
log.errorf( log.errorf(
"%s", "%s",
@@ -156,18 +127,17 @@ render_site :: proc(site: ^Site) {
template_cache: map[string]mustache.Template template_cache: map[string]mustache.Template
defer delete(template_cache) defer delete(template_cache)
// TODO: CAlculate offset offset, ok := mustache.compute_utc_offset(site.tz)
offset := 0
now, ok := time.time_to_rfc3339(time.now(), offset, false, allocator)
assert(ok) assert(ok)
now, ok2 := time.time_to_rfc3339(time.now(), offset, false, allocator)
assert(ok2)
// Build base data once ctx := Template_Context {
base := Base_Data { site = site.site_context,
now = now, now = now,
params = site.params,
description = site.description,
og = site.og, og = site.og,
date_format = site.date.format, date_format = site.date.format,
timezone = site.tz,
} }
// Find home page // Find home page
@@ -180,6 +150,7 @@ render_site :: proc(site: ^Site) {
break break
} }
} }
ctx.page = home
// Collect sections // Collect sections
sections := make(map[string]bool) sections := make(map[string]bool)
@@ -196,7 +167,7 @@ render_site :: proc(site: ^Site) {
continue continue
} }
tpl := get_template(&site.vfs, page.layout, &template_cache) tpl := get_template(&site.vfs, page.layout, &template_cache)
html := render_page_html(page, site, tpl, partials, base) html := render_page_html(page, site, tpl, partials, ctx)
if .Minify in site.features { if .Minify in site.features {
html = minify_html(html) html = minify_html(html)
} }
@@ -224,7 +195,7 @@ render_site :: proc(site: ^Site) {
has_section_index, has_section_index,
section_tpl, section_tpl,
partials, partials,
base, ctx,
) )
if .Minify in site.features { if .Minify in site.features {
html = minify_html(html) html = minify_html(html)
@@ -235,7 +206,7 @@ render_site :: proc(site: ^Site) {
// Render home page // Render home page
if has_home { if has_home {
home_tpl := get_template(&site.vfs, "home", &template_cache) home_tpl := get_template(&site.vfs, "home", &template_cache)
home_html := render_home_html(home, site, home_tpl, partials, base) home_html := render_home_html(home, site, home_tpl, partials, ctx)
if .Minify in site.features { if .Minify in site.features {
home_html = minify_html(home_html) home_html = minify_html(home_html)
} }
@@ -269,18 +240,13 @@ render_page_html :: proc(
site: ^Site, site: ^Site,
content_tpl: mustache.Template, content_tpl: mustache.Template,
partials: map[string]mustache.Template, partials: map[string]mustache.Template,
base: Base_Data, ctx: Template_Context,
) -> string { ) -> string {
is_article := page.section != "" ctx := ctx
data := Page_Data { ctx.title = fmt.tprintf("%s | %s", page.title, site.title)
base = base, ctx.page = page
} ctx.og = og_for_page(site.og, page)
data.title = fmt.tprintf("%s | %s", page.title, site.title) return render_template(content_tpl, ctx, partials)
data.page_title = page.title
data.body = page.body_html
data.date = page.date
data.og = og_for_page(site.og, page)
return render_template(content_tpl, data, partials)
} }
render_home_html :: proc( render_home_html :: proc(
@@ -288,25 +254,22 @@ render_home_html :: proc(
site: ^Site, site: ^Site,
content_tpl: mustache.Template, content_tpl: mustache.Template,
partials: map[string]mustache.Template, partials: map[string]mustache.Template,
base: Base_Data, ctx: Template_Context,
) -> string { ) -> string {
list_pages := make([dynamic]Page_Context) list_pages := make([dynamic]Page, 0, 8, context.temp_allocator)
defer delete(list_pages)
for page in site.pages { for page in site.pages {
if page._is_index { if page._is_index {
continue continue
} }
append(&list_pages, build_page_context(page)) append(&list_pages, page)
} }
data := Home_Data { ctx := ctx
base = base, ctx.title = site.title
} ctx.pages = list_pages
data.title = site.title ctx.og = og_for_page(site.og, home)
data.body = home.body_html
data.pages = list_pages return render_template(content_tpl, ctx, partials)
data.og = og_for_page(site.og, home)
return render_template(content_tpl, data, partials)
} }
render_section :: proc( render_section :: proc(
@@ -316,36 +279,34 @@ render_section :: proc(
has_index: bool, has_index: bool,
content_tpl: mustache.Template, content_tpl: mustache.Template,
partials: map[string]mustache.Template, partials: map[string]mustache.Template,
base: Base_Data, ctx: Template_Context,
) -> string { ) -> string {
posts := make([dynamic]Page_Context) posts := make([dynamic]Page, 0, len(site.pages) / 2, context.temp_allocator)
defer delete(posts)
for page in site.pages { for page in site.pages {
if page.section != section || page._is_index { if page.section != section || page._is_index {
continue continue
} }
append(&posts, build_page_context(page)) append(&posts, page)
} }
data := Section_Data { ctx := ctx
base = base,
}
if has_index { if has_index {
data.body = section_index.body_html ctx.page = section_index
data.page_title = section_index.title ctx.title = fmt.tprintf("%s | %s", section_index.title, site.title)
data.title = fmt.tprintf("%s | %s", section_index.title, site.title) ctx.og = og_for_page(site.og, section_index)
data.og = og_for_page(site.og, section_index)
} else { } else {
data.page_title = capitalize(section) ctx.page = Page {
data.title = fmt.tprintf("%s | %s", capitalize(section), site.title) title = capitalize(section),
data.og.title = capitalize(section) }
data.og.description = "" ctx.title = fmt.tprintf("%s | %s", ctx.page.title, site.title)
data.og.url = fmt.tprintf("%s/%s/", site.base_url, section) ctx.og.title = capitalize(section)
data.og.type = "website" ctx.og.description = ""
data.og.is_article = false ctx.og.url = fmt.tprintf("%s/%s/", site.base_url, section)
ctx.og.type = "website"
ctx.og.is_article = false
} }
data.posts = posts ctx.posts = posts
return render_template(content_tpl, data, partials) return render_template(content_tpl, ctx, partials)
} }
load_partials :: proc(vfs: ^VFS) -> map[string]mustache.Template { load_partials :: proc(vfs: ^VFS) -> map[string]mustache.Template {
@@ -391,11 +352,11 @@ get_year :: proc(iso: string) -> string {
return iso[:4] return iso[:4]
} }
sort_pages_by_date :: proc(pages: []Page) { sort_pages_by_date :: proc(pages: #soa[]Page) {
for i in 1 ..< len(pages) { for i in 1 ..< len(pages) {
key := pages[i] key := pages[i]
j := i - 1 j := i - 1
for j >= 0 && pages[j].date < key.date { for j >= 0 && pages.date[j] < key.date {
pages[j + 1] = pages[j] pages[j + 1] = pages[j]
j -= 1 j -= 1
} }
+49 -8
View File
@@ -7,29 +7,41 @@ import "core:log"
import "core:mem" import "core:mem"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
import "core:time/datetime"
import "core:time/timezone"
import md "markdown" import md "markdown"
// Site is the primary workhorse. // Site_Context holds the site date that is accessible in templates.
Site_Context :: struct {
title: string,
description: string,
base_url: string,
params: json.Object,
og: Open_Graph,
}
// 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 { Site :: struct {
using site_context: Site_Context,
arena: mem.Dynamic_Arena, arena: mem.Dynamic_Arena,
pages: [dynamic]Page, pages: #soa[dynamic]Page,
modules: [dynamic]string, modules: [dynamic]string,
vfs: VFS, vfs: VFS,
title: string,
description: string,
base_url: string,
config_path: string, config_path: string,
content_dir: string, content_dir: string,
assets_dir: string, assets_dir: string,
output_dir: string, output_dir: string,
layouts_dir: string, layouts_dir: string,
params: json.Object,
features: bit_set[Feature], features: bit_set[Feature],
markdown_extensions: bit_set[md.Extension], markdown_extensions: bit_set[md.Extension],
og: Open_Graph,
date: Date_Preferences, date: Date_Preferences,
tz: ^datetime.TZ_Region,
grammars: string,
queries: string,
} }
Date_Preferences :: struct { Date_Preferences :: struct {
@@ -58,6 +70,8 @@ Config_File :: struct {
modules: json.Value, modules: json.Value,
og: Open_Graph, og: Open_Graph,
date: Date_Preferences, date: Date_Preferences,
grammars: string,
queries: string,
} }
// Configuration loaded from command line arguments. Gets folded in to Site // Configuration loaded from command line arguments. Gets folded in to Site
@@ -115,8 +129,22 @@ init_site :: proc(site: ^Site, args: []string) {
site_apply_cli_flags(site, _flags) site_apply_cli_flags(site, _flags)
site.config_path = path site.config_path = path
// Build the resolved site-level OG now that every other field is set.
site.og = og_for_site(site) 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( load_config_file :: proc(
@@ -171,6 +199,19 @@ site_apply_config :: proc(site: ^Site, config: Config_File, config_dir: string)
site.og = config.og site.og = config.og
site.date = config.date site.date = config.date
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) { site_apply_path_defaults :: proc(site: ^Site, config_dir: string) {
+21
View File
@@ -179,3 +179,24 @@ test_init_site_md_enable_disable :: proc(t: ^testing.T) {
testing.expect(t, .Sidenotes in site.markdown_extensions) testing.expect(t, .Sidenotes in site.markdown_extensions)
} }
@(test)
test_init_site_config_paths :: proc(t: ^testing.T) {
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
args := []string{"thor", fmt.tprintf("-config:%s", path)}
init_site(&site, args)
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")
}
+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"))
+250 -129
View File
@@ -3,11 +3,17 @@ package treesitter
import "core:c" import "core:c"
import "core:fmt" import "core:fmt"
import "core:log" import "core:log"
import "core:mem"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
import "core:sync"
import "core:thread"
GRAPHS_PATH: string = "/home/spencer/.config/helix/runtime/grammars" grammar_dir: string
QUERIES_PATH: string = "/nix/store/n9da8d007ygbgsx983jr3ar3wb1fsh6q-helix-25.07.1/lib/runtime/queries" query_dir: string
HTML_HIGHLIGHTS :: #load(#directory + "queries/html/highlights.scm", string)
CSS_HIGHLIGHTS :: #load(#directory + "queries/css/highlights.scm", string)
Language :: distinct rawptr Language :: distinct rawptr
Parser :: distinct rawptr Parser :: distinct rawptr
@@ -21,9 +27,9 @@ Point :: struct {
} }
Node :: struct { Node :: struct {
ctx: [4]u32, ctx: [4]u32,
id: rawptr, id: rawptr,
tree: rawptr, tree: rawptr,
} }
Query_Capture :: struct { Query_Capture :: struct {
@@ -40,7 +46,7 @@ Query_Match :: struct {
} }
Query_Error :: enum c.int { Query_Error :: enum c.int {
None = 0, None = 0,
Syntax, Syntax,
NodeType, NodeType,
Field, Field,
@@ -56,71 +62,48 @@ foreign import libdl "system:dl"
foreign import html_grammar "system:tree-sitter-html" foreign import html_grammar "system:tree-sitter-html"
foreign import css_grammar "system:tree-sitter-css" foreign import css_grammar "system:tree-sitter-css"
@(link_prefix="ts_") @(link_prefix = "ts_")
foreign lib { foreign lib {
parser_new :: proc() -> Parser --- parser_new :: proc() -> Parser ---
parser_delete :: proc(self: Parser) --- parser_delete :: proc(self: Parser) ---
parser_set_language :: proc(self: Parser, language: Language) -> bool --- parser_set_language :: proc(self: Parser, language: Language) -> bool ---
parser_parse_string :: proc( parser_parse_string :: proc(self: Parser, old_tree: Tree, string: cstring, length: u32) -> Tree ---
self: Parser,
old_tree: Tree,
string: cstring,
length: u32,
) -> Tree ---
} }
@(link_prefix="ts_") @(link_prefix = "ts_")
foreign lib { foreign lib {
tree_root_node :: proc(self: Tree) -> Node --- tree_root_node :: proc(self: Tree) -> Node ---
tree_delete :: proc(self: Tree) --- tree_delete :: proc(self: Tree) ---
} }
@(link_prefix="ts_") @(link_prefix = "ts_")
foreign lib { foreign lib {
node_start_byte :: proc(self: Node) -> u32 --- node_start_byte :: proc(self: Node) -> u32 ---
node_end_byte :: proc(self: Node) -> u32 --- node_end_byte :: proc(self: Node) -> u32 ---
node_has_error :: proc(self: Node) -> bool --- node_has_error :: proc(self: Node) -> bool ---
node_is_error :: proc(self: Node) -> bool --- node_is_error :: proc(self: Node) -> bool ---
node_child_count :: proc(self: Node) -> u32 --- node_child_count :: proc(self: Node) -> u32 ---
node_child :: proc(self: Node, child_index: u32) -> Node --- node_child :: proc(self: Node, child_index: u32) -> Node ---
node_named_child_count :: proc(self: Node) -> u32 --- node_named_child_count :: proc(self: Node) -> u32 ---
node_named_child :: proc(self: Node, child_index: u32) -> Node --- node_named_child :: proc(self: Node, child_index: u32) -> Node ---
node_start_point :: proc(self: Node) -> Point --- node_start_point :: proc(self: Node) -> Point ---
node_type :: proc(self: Node) -> cstring --- node_type :: proc(self: Node) -> cstring ---
node_parent :: proc(self: Node) -> Node --- node_parent :: proc(self: Node) -> Node ---
} }
@(link_prefix="ts_") @(link_prefix = "ts_")
foreign lib { foreign lib {
query_new :: proc( query_new :: proc(language: Language, source: cstring, source_len: u32, error_offset: ^u32, error_type: ^Query_Error) -> Query ---
language: Language,
source: cstring,
source_len: u32,
error_offset: ^u32,
error_type: ^Query_Error,
) -> Query ---
query_delete :: proc(self: Query) --- query_delete :: proc(self: Query) ---
query_capture_name_for_id :: proc( query_capture_name_for_id :: proc(self: Query, index: u32, length: ^u32) -> cstring ---
self: Query,
index: u32,
length: ^u32,
) -> cstring ---
} }
@(link_prefix="ts_") @(link_prefix = "ts_")
foreign lib { foreign lib {
query_cursor_new :: proc() -> Query_Cursor --- query_cursor_new :: proc() -> Query_Cursor ---
query_cursor_delete :: proc(self: Query_Cursor) --- query_cursor_delete :: proc(self: Query_Cursor) ---
query_cursor_exec :: proc( query_cursor_exec :: proc(self: Query_Cursor, query: Query, node: Node) ---
self: Query_Cursor, query_cursor_next_capture :: proc(self: Query_Cursor, match: ^Query_Match, capture_index: ^u32) -> bool ---
query: Query,
node: Node,
) ---
query_cursor_next_capture :: proc(
self: Query_Cursor,
match: ^Query_Match,
capture_index: ^u32,
) -> bool ---
} }
foreign libdl { foreign libdl {
@@ -141,12 +124,36 @@ Grammar_Cache :: struct {
language: Language, language: Language,
parser: Parser, parser: Parser,
query: Query, query: Query,
cursor: Query_Cursor,
query_failed: bool, query_failed: bool,
} }
Get_Language_Proc :: #type proc() -> Language Get_Language_Proc :: #type proc() -> Language
grammar_cache: map[string]^Grammar_Cache 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) { builtin_language :: proc(lang: string) -> (language: Language, ok: bool) {
switch lang { switch lang {
@@ -160,45 +167,68 @@ builtin_language :: proc(lang: string) -> (language: Language, ok: bool) {
return return
} }
ensure_parser :: proc(lang: string) -> ^Grammar_Cache { // load_query returns the highlight query source for a language. Builtin
if grammar_cache == nil { // languages (html/css) are baked into the binary via `#load`; all others are
grammar_cache = make(map[string]^Grammar_Cache) // 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 cached, ok := grammar_cache[lang]; ok { 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 return cached
} }
grammar_cache[lang] = nil grammar_store.cache[lang] = nil
language: Language language, ok := load_language(lang)
if !ok {
if builtin, ok := builtin_language(lang); ok { return nil
language = builtin
} else {
if GRAPHS_PATH == "" {
log.warnf("treesitter: 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("treesitter: 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("treesitter: cannot find symbol %s in %s", sym_name, so_path)
return nil
}
get_language := transmute(Get_Language_Proc)(sym)
language = get_language()
} }
parser := parser_new() parser := parser_new()
@@ -212,60 +242,41 @@ ensure_parser :: proc(lang: string) -> ^Grammar_Cache {
return nil return nil
} }
gc := new(Grammar_Cache) gc := new(Grammar_Cache, grammar_store.allocator)
gc.language = language gc.language = language
gc.parser = parser gc.parser = parser
grammar_cache[lang] = gc grammar_store.cache[lang] = gc
return gc return gc
} }
load_grammar :: proc(lang: string) -> ^Grammar_Cache { compile_query :: proc(lang: string, language: Language) -> (query: Query, cursor: Query_Cursor, ok: bool) {
gc := ensure_parser(lang) query_src, query_path, qok := load_query(lang)
if gc == nil { if !qok {
return nil return
} }
if gc.query != nil { query_c := strings.clone_to_cstring(query_src, context.temp_allocator)
return gc
}
if gc.query_failed {
return nil
}
if QUERIES_PATH == "" {
log.warnf("treesitter: 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("treesitter: 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_offset: u32
err_type: Query_Error err_type: Query_Error
query := query_new( query = query_new(language, query_c, u32(len(query_src)), &err_offset, &err_type)
gc.language,
query_c,
u32(len(query_src)),
&err_offset,
&err_type,
)
if query == nil { if query == nil {
tok := extract_query_token(query_src, err_offset) tok := extract_query_token(transmute([]byte)query_src, err_offset)
cause := fmt.tprintf("query error at byte %d (type %v)", err_offset, err_type) cause := fmt.tprintf("query error at byte %d (type %v)", err_offset, err_type)
#partial switch err_type { #partial switch err_type {
case .NodeType: case .NodeType:
if tok != "" { 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) 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 { } 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) 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: case .Field:
cause = fmt.tprintf("query references unknown field '%s' at byte %d", tok, err_offset) cause = fmt.tprintf("query references unknown field '%s' at byte %d", tok, err_offset)
@@ -282,7 +293,7 @@ load_grammar :: proc(lang: string) -> ^Grammar_Cache {
_, is_builtin := builtin_language(lang) _, is_builtin := builtin_language(lang)
if !is_builtin { if !is_builtin {
so_path := fmt.tprintf("%s/%s.so", GRAPHS_PATH, lang) so_path := fmt.tprintf("%s/%s.so", grammar_dir, lang)
gram_v := helix_version_from_path(so_path) gram_v := helix_version_from_path(so_path)
query_v := helix_version_from_path(query_path) query_v := helix_version_from_path(query_path)
gram_note := "(version unknown)" gram_note := "(version unknown)"
@@ -296,20 +307,129 @@ load_grammar :: proc(lang: string) -> ^Grammar_Cache {
} }
} }
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 gc.query_failed = true
return nil return nil
} }
gc.query = query gc.query = query
gc.cursor = cursor
return gc 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 { extract_query_token :: proc(src: []byte, offset: u32) -> string {
end := offset end := offset
for int(end) < len(src) { for int(end) < len(src) {
c := src[end] c := src[end]
is_ident := (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || is_ident :=
(c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.' (c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '_' ||
c == '-' ||
c == '.'
if !is_ident do break if !is_ident do break
end += 1 end += 1
} }
@@ -331,3 +451,4 @@ helix_version_from_path :: proc(path: string) -> string {
if end <= start do return "" if end <= start do return ""
return path[start:end] return path[start:end]
} }