Compare commits

...

14 Commits

Author SHA1 Message Date
Spencer Brower 824101a356 chore: Updated TODOS.md 2026-07-29 11:34:31 -04:00
Spencer Brower 06afc15772 style: Ran typos. 2026-07-29 10:31:55 -04:00
Spencer Brower 730fe6715c style: Ran nixpkgs-fmt. 2026-07-29 10:25:26 -04:00
Spencer Brower ae721b3caa style: ran deadnix. 2026-07-29 10:23:40 -04:00
Spencer Brower 98c0efba44 style: Ran odinfmt. 2026-07-29 10:22:45 -04:00
Spencer Brower 298ae58ca5 build(nix): Updated all flake inputs. 2026-07-29 10:17:30 -04:00
Spencer Brower 27895ea2dc wip(review): feat: Added menu configuration. 2026-07-29 09:43:34 -04:00
Spencer Brower fb32ce90fc chore: Updated TODOS.md 2026-07-28 17:03:59 -04:00
Spencer Brower 0abac89d88 feat: Warns the user if they exceed 16 context frames. 2026-07-28 16:58:46 -04:00
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
60 changed files with 937 additions and 418 deletions
+11 -3
View File
@@ -220,7 +220,7 @@ Templates use Mustache with template inheritance (`{{<base}}` / `{{$block}}`):
<!-- page.html (content layout) -->
{{<base}}
{{$main}}
<main><article><h1>{{page_title}}</h1>{{&content}}</article></main>
<main><article><h1>{{page.title}}</h1>{{&content}}</article></main>
{{/main}}
{{/base}}
```
@@ -240,7 +240,7 @@ Base_Data :: struct {
}
Page_Data :: struct {
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
}
Home_Data :: struct {
@@ -249,7 +249,7 @@ Home_Data :: struct {
}
Section_Data :: struct {
using base: Base_Data,
page_title: string,
page.title: string,
posts: [dynamic]Page_Context, // flat list; year grouping done in template via pipe
}
```
@@ -449,6 +449,14 @@ See `HUGO.md` for analysis of why thor doesn't need Hugo's shortcode context iso
See `mustache/SPEC.md` for the original implementation specification.
See `mustache/EXTENSIONS.md` for non-standard extensions (pipes).
## Odin language facts
These are things that are easy to get wrong:
- **Proc arguments are immutable.** You cannot assign to a parameter directly. To get a mutable copy, shadow it: `x := x`. If you need to modify the source, pass a pointer `^x`.
- **`for` each loops use `item, idx` order**, not `idx, item`. Correct: `for item, idx in arr`. Wrong: `for idx, item in arr`.
- **`make([dynamic]T, n, allocator)` sets capacity, not length.** To get length=0 with capacity=n, use `make([dynamic]T, 0, n, allocator)`. Using `make([dynamic]T, n, allocator)` creates `len=n` with `n` zero-initialized elements.
## TODO
See `TODOS.md` for the full list.
+8 -9
View File
@@ -1,8 +1,6 @@
# Thor
[TOC]
Thor is a simple Static Sire Generator designed for personal blogs and other small websites.
Thor is a simple Static Site Generator designed for personal blogs and other small websites.
Its core principals are simplicity and minimal configuration, so you can get started as quickly as possible.
@@ -16,14 +14,14 @@ It is based on Hugo, and gingerbill's SSG. Templating is done with (extended?) M
- Menus (WIP)
- Extended Markdown ([See below](#extended-markdown))
- Basic (whitespace) minification.
- Union File System (Modules)
## What it doesn't do
- Internationalization
- Pagination (Yet)
- Themes
- Union File System (Yet)
- Image Manipulation
- TailwindCSS integration
- Pagination (Yet)
- Themes
- Image Manipulation
- TailwindCSS integration
## Getting Started
@@ -34,7 +32,8 @@ Then follow [The Guide]()
For a more complete setup, run `thor new site`.
## Extended Markdown
- Emoji expansion
- margin style footnotes
- Guthub style alerts
- Github style alerts
- [and more]
+56 -7
View File
@@ -1,13 +1,42 @@
## High priority
- Polish existing features before moving on to new ones.
- [ ] Add Weights
- [ ] sort by `page.weight` when loading
- [ ] re-sort by `page.menu.weight` when building menus.
- [ ] warn user when 2 pages with explicit weights match.
- [ ] Improve diagnostics
- [ ] Simplify / unify template context stack. Come up with a name for it.
- [ ] `render_template` should accept `Template_Context`, not `any`
- [ ] All Diagnostics should show:
- [ ] *What* went wrong
- [ ] *where* (in the file)
- [ ] *where* (in the stack trace)
- [ ] *how* you can fix it (if applicable)
- [ ] Create a Location struct that somewhat matches Odin's [Source_Code_Location](https://pkg.odin-lang.org/base/runtime/#Source_Code_Location)?
- Note that odin's version doesn't contain the stack trace.
- [ ] show "stack traces" in template error diagnostics
- [ ] better diagnostics for syntax errors in treesitter.
- [ ] Ensure diagnostics for MAX_CONTEXT_DEPTH are good.
- [ ] show a proper diagnostic for timezones
- currently "unable to load timezone 'America/New_Yorkskie'"
- want rust style diagnostic and better message, maybe "unknown timezone 'America/New_Yorkskie'"
- [ ] Test menu diagnostics
- [ ] Honestly, Test **all** diagnostics
- [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] consider adding a limit to the context stack in mustache.
- [x] Add heading ids as a default on extension.
- [ ] starred must be a param.
- [x] Add a `#config(MAX_CONTEXT_DEPTH, 16?)` to `mustache`.
- [ ] Documentation
- [ ] talk about the context stack (and its limit).
- [ ] highlight the differences in the way menus are handled.
- [ ] consider sites with data based urls.
- [ ] 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.
- [ ] Don't show annoying log output in tests.
- [ ] improve home link customization.
## Performance
@@ -31,6 +60,13 @@
- `await` the highlighted code.
- [ ] can markdown extensions run in parallel?
- [ ] enforce MAX_SLUG_LENGTH
- [ ] enforce MAX_CONTEXT_DEPTH
- [ ] ensure struct fields are ordered correctly
## Remove Privileged content
- [ ] `group_by` currently requires a computed `year` field on the page.
- We should replace this with `{{ pages | group_by (date | "2006") }}` or similar
## Memory Management
@@ -58,11 +94,9 @@
- [ ] display an error when no part of the date appears in the output.
- [ ] Handle 0 and whitespace padding i.e. "_2" -> " 2"
- [ ] Do we *need* mustache.Date_Components, or can we use core:time/datetime.DateTime?
- [ ] show a proper diagnostic for timezones
- currently "unable to load timezone 'America/New_Yorkskie'"
- want rust style diagnostic and better message, maybe "unknown timezone 'America/New_Yorkskie'"
## General
- [ ] configure opt-out of automatic sections being added to menu.
- [ ] get rid of the global variables in the `treesitter` package.
- [ ] Consider using `or_else` when applying default values to structs. i.e.
```odin
@@ -81,8 +115,18 @@ main :: proc () {
- [ ] Integrity hash
- Allows users to verify their output didn't change after upgrading to a new version
- [ ] Content-hash fingerprinting for CSS and JS cache busting
- [ ] come up with scrapers / scrape sources to harvest site data
- we'll use this to help us sculpt defaults.
- [ ] merge `render_{section,home_html,page_html}` procs.
- [ ] try to combine render_page_html and render_home_html?
- [ ] Debug log stats. (analytics)
- [ ] final Context_Stack cap
- [ ] highest PIPE args used
- [ ] longest slug length + name that generated it
- [ ] number of pages
- [ ] number of blocks
- [ ] enabled features / extensions
- [ ] etc
- [ ] Avoid `json.Value` / `json.Object` where possible.
- [ ] Create a json schema file for `thor.json`.
- [ ] make `parse` an overload of `parse_text/parse_inline` and `parse_file`, or something.
@@ -93,6 +137,10 @@ main :: proc () {
- [ ] running ./thor/thor still logs the debug message: using config /home/spencer/github.com/sbrow.github.io/thor.json
- wrong cwd?
- [ ] Clean up the default layouts
- [ ] Menus
- [ ] Detailed frontmatter menu form ("menu": {"main": {"weight": 5}})
- [ ] Menu active state (pre-compute is_active based on page.permalink prefix match)
- [ ] Page.weight field for general-purpose page ordering (menus, lists, related posts)
- [ ] if no `html` tag detected in output, re-render output with base template
(or whatever template is next in the chain)
- [ ] Add `-production` flag
@@ -145,6 +193,7 @@ main :: proc () {
- [ ] `new site` set up new project
- [ ] warn/error when unknown key used in mustache.
- [ ] Import/export packages. Hugo, jekyll, WordPress, etc.
- [ ] opt-in "strict_keys" mode. in this mode, key lookups may not view parent objects.
## Notes
+47 -26
View File
@@ -1,11 +1,11 @@
package bench
import "../mustache"
import "core:fmt"
import "core:mem"
import "core:os"
import "core:strconv"
import "core:time"
import "../mustache"
Tag :: struct {
name: string,
@@ -101,13 +101,13 @@ main :: proc() {
return
}
for _ in 0..<3 {
for _ in 0 ..< 3 {
_, _ = mustache.render(page, data, partials, allocator = context.temp_allocator)
mem.dynamic_arena_free_all(&temp_arena)
}
start := time.now()
for _ in 0..<iterations {
for _ in 0 ..< iterations {
_, _ = mustache.render(page, data, partials, allocator = context.temp_allocator)
mem.dynamic_arena_free_all(&temp_arena)
}
@@ -116,8 +116,12 @@ main :: proc() {
seconds := time.duration_seconds(elapsed)
per_render_ms := seconds * 1000 / f64(iterations)
fmt.printfln("iterations=%d total=%.3fs per_render=%.3fms",
iterations, seconds, per_render_ms)
fmt.printfln(
"iterations=%d total=%.3fs per_render=%.3fms",
iterations,
seconds,
per_render_ms,
)
}
parse_file :: proc(name: string) -> mustache.Template {
@@ -138,16 +142,27 @@ parse_file :: proc(name: string) -> mustache.Template {
}
generate_data :: proc() -> Page_Data {
years := []string{
"2025", "2024", "2023", "2022", "2021",
"2020", "2019", "2018", "2017", "2016",
years := []string {
"2025",
"2024",
"2023",
"2022",
"2021",
"2020",
"2019",
"2018",
"2017",
"2016",
}
posts := make([dynamic]Post, 0, 500)
for year in years {
for i in 0..<50 {
for i in 0 ..< 50 {
tags := make([dynamic]Tag, 0, 3)
append(&tags, Tag{name = fmt.aprintf("%s-notes", year), slug = fmt.aprintf("%s-notes", year)})
append(
&tags,
Tag{name = fmt.aprintf("%s-notes", year), slug = fmt.aprintf("%s-notes", year)},
)
append(&tags, Tag{name = "writing", slug = "writing"})
append(&tags, Tag{name = "archive", slug = "archive"})
@@ -159,28 +174,34 @@ generate_data :: proc() -> Page_Data {
author = fmt.aprintf("Author %d", i % 5)
}
append(&posts, Post{
title = fmt.aprintf("Post %d from %s", i, year),
url = fmt.aprintf("/%s/post-%d", year, i),
date = fmt.aprintf("%s-%02d-%02dT10:00:00Z", year, month, day),
year = year,
excerpt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
author = author,
tags = tags,
})
append(
&posts,
Post {
title = fmt.aprintf("Post %d from %s", i, year),
url = fmt.aprintf("/%s/post-%d", year, i),
date = fmt.aprintf("%s-%02d-%02dT10:00:00Z", year, month, day),
year = year,
excerpt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
author = author,
tags = tags,
},
)
}
}
comments := make([dynamic]Comment, 0, 100)
for i in 0..<100 {
for i in 0 ..< 100 {
year := years[i % len(years)]
month := (i % 12) + 1
day := (i % 28) + 1
append(&comments, Comment{
author = fmt.aprintf("Commenter %d", i),
date = fmt.aprintf("%s-%02d-%02dT12:00:00Z", year, month, day),
body = fmt.aprintf("Great post! This is comment number %d.", i),
})
append(
&comments,
Comment {
author = fmt.aprintf("Commenter %d", i),
date = fmt.aprintf("%s-%02d-%02dT12:00:00Z", year, month, day),
body = fmt.aprintf("Great post! This is comment number %d.", i),
},
)
}
nav_items := make([dynamic]Nav_Item, 0, 8)
@@ -193,7 +214,7 @@ generate_data :: proc() -> Page_Data {
append(&nav_items, Nav_Item{url = "https://twitter.com/example", label = "Twitter"})
append(&nav_items, Nav_Item{url = "mailto:nobody@example.com", label = "Email"})
return Page_Data{
return Page_Data {
title = "Post Archive",
now = "2025-07-21T12:00:00Z",
posts = posts,
-1
View File
@@ -127,4 +127,3 @@ to_benchmark :: proc($f: formatter) -> benchmark {
} \
)
}
-1
View File
@@ -41,4 +41,3 @@ parse_2_digits :: proc(s: string, offset: int) -> int {
}
return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30)
}
-1
View File
@@ -123,4 +123,3 @@ emit_hour_12 :: proc(b: ^strings.Builder, dt: common.Date_Components, pad: bool)
fmt.sbprintf(b, format, h12)
}
-1
View File
@@ -130,4 +130,3 @@ emit_hour_12 :: proc(b: ^strings.Builder, dt: common.Date_Components, pad: bool)
fmt.sbprintf(b, format, h12)
}
-1
View File
@@ -125,4 +125,3 @@ emit_am_pm :: proc(b: ^strings.Builder, dt: common.Date_Components) {
emit_am_pm_lower :: proc(b: ^strings.Builder, dt: common.Date_Components) {
strings.write_string(b, "pm" if dt.hour >= 12 else "am")
}
-1
View File
@@ -41,4 +41,3 @@ parse_2_digits :: proc(s: string, offset: int) -> int {
}
return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30)
}
-1
View File
@@ -124,4 +124,3 @@ emit_hour_12 :: proc(b: ^strings.Builder, dt: common.Date_Components, pad: bool)
fmt.sbprintf(b, format, h12)
}
View File
+24 -3
View File
@@ -7,6 +7,7 @@ import "core:fmt"
import "core:log"
import "core:os"
import "core:strings"
import "core:time"
// Fields with underscores should never be set by the user.
Page :: struct {
@@ -18,12 +19,13 @@ Page :: struct {
title: string,
description: string,
date: string,
year: string,
lastmod: string,
menu: string,
content: string,
og: Open_Graph,
draft: bool,
is_starred: bool,
starred: bool,
_is_index: bool `private`,
}
@@ -67,6 +69,8 @@ site_load_content :: proc(site: ^Site) {
for &page in site.pages {
page.url = fmt.tprintf("%s%s", site.base_url, page.permalink)
}
build_menus(site)
}
// scan_content_files walks the content directory and collects Pending_File
@@ -231,9 +235,27 @@ load_page :: proc(
page.title = fm.title
page.description = fm.description
page.date = fm.date
if page.date == "" {
info, stat_err := os.stat(file_path, context.allocator)
if stat_err == nil {
page.date, _ = time.time_to_rfc3339(
info.modification_time,
0,
false,
context.allocator,
)
os.file_info_delete(info, context.allocator)
log.warnf(
"no date in frontmatter for %s, using file modification time: %s",
file_path,
page.date,
)
}
}
page.year = get_year(page.date)
page.lastmod = fm.lastmod
page.draft = fm.draft
page.is_starred = fm.isStarred
page.starred = fm.isStarred
page.menu = fm.menu
page.layout = fm.layout if fm.layout != "" else infer_layout(section, is_index)
page.og = fm.og
@@ -269,4 +291,3 @@ strip_extension :: proc(name: string) -> string {
}
return name[:dot]
}
-1
View File
@@ -3,4 +3,3 @@ package main
import "core:os"
DEFAULTS_PATH :: #directory + os.Path_Separator_String + "defaults"
+1 -1
View File
@@ -2,7 +2,7 @@
{{$main}}
<main>
<article>
<h1>{{page_title}}</h1>
<h1>{{page.title}}</h1>
{{#date_iso}} <time class="subtitle" datetime="{{date_iso}}">{{date_display}}</time>
{{/date_iso}} {{&content}}
</article>
+1
View File
@@ -0,0 +1 @@
{{site.title}}
+4 -1
View File
@@ -1,7 +1,10 @@
<header>
<nav>
<ul>
<li><a href="/">{{title}}</a></li>
<li><a href="/">{{> home-link}}</a></li>
{{#menus.main}}
<li><a href="{{url}}">{{name}}</a></li>
{{/menus.main}}
</ul>
</nav>
</header>
+1 -1
View File
@@ -1,7 +1,7 @@
{{<base}}
{{$main}}
<main>
<h1>{{page_title}}</h1>
<h1>{{page.title}}</h1>
{{&content}}
{{#posts | group_by year}}
<section>
-1
View File
@@ -149,4 +149,3 @@ xml_escape :: proc(s: string) -> string {
r, _ = strings.replace_all(r, ">", "&gt;")
return r
}
+9 -13
View File
@@ -12,13 +12,10 @@
};
outputs =
inputs@{
self,
flake-parts,
nixpkgs,
nixpkgs-unstable,
inputs@{ flake-parts
, nixpkgs
, ...
# , process-compose-flake
treefmt-nix,
}:
flake-parts.lib.mkFlake { inherit inputs; } {
imports = [
@@ -28,11 +25,10 @@
systems = [ "x86_64-linux" ];
perSystem =
{
pkgs,
system,
inputs',
...
{ pkgs
, system
, inputs'
, ...
}:
let
mkGrammarStaticLib = name: src: pkgs.stdenv.mkDerivation {
@@ -71,7 +67,7 @@
config.allowUnfree = true;
overlays = [
(final: prev: { unstable = inputs'.nixpkgs-unstable.legacyPackages; })
(_final: _prev: { unstable = inputs'.nixpkgs-unstable.legacyPackages; })
];
};
@@ -145,7 +141,7 @@
};
devShells.default = pkgs.mkShell {
buildInputs = [odin ols ] ++ (with pkgs; [
buildInputs = [ odin ols ] ++ (with pkgs; [
cmark
tree-sitter
+12 -13
View File
@@ -48,10 +48,10 @@ parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok
return
}
fm.title = json_get_string(obj, "title")
fm.title = json_get_string(obj, "title")
fm.description = json_get_string(obj, "description")
fm.date = json_get_string(obj, "date")
fm.lastmod = json_get_string(obj, "lastmod")
fm.date = json_get_string(obj, "date")
fm.lastmod = json_get_string(obj, "lastmod")
fm.publishDate = json_get_string(obj, "publishDate")
fm.draft = json_get_bool(obj, "draft")
fm.isStarred = json_get_bool(obj, "isStarred")
@@ -85,18 +85,17 @@ json_get_open_graph :: proc(obj: json.Object, key: string) -> Open_Graph {
og: Open_Graph
if v, ok := obj[key]; ok {
if inner, ok2 := v.(json.Object); ok2 {
og.title = json_get_string(inner, "title")
og.type = json_get_string(inner, "type")
og.image = json_get_string(inner, "image")
og.url = json_get_string(inner, "url")
og.description = json_get_string(inner, "description")
og.locale = json_get_string(inner, "locale")
og.site_name = json_get_string(inner, "site_name")
og.title = json_get_string(inner, "title")
og.type = json_get_string(inner, "type")
og.image = json_get_string(inner, "image")
og.url = json_get_string(inner, "url")
og.description = json_get_string(inner, "description")
og.locale = json_get_string(inner, "locale")
og.site_name = json_get_string(inner, "site_name")
og.published_time = json_get_string(inner, "published_time")
og.modified_time = json_get_string(inner, "modified_time")
og.section = json_get_string(inner, "section")
og.modified_time = json_get_string(inner, "modified_time")
og.section = json_get_string(inner, "section")
}
}
return og
}
+1 -3
View File
@@ -82,9 +82,7 @@ test_description_plain_text :: proc(t: ^testing.T) {
@(test)
test_description_highlighted_code :: proc(t: ^testing.T) {
result := generate_description(
`<pre><code><span class="hl-keyword">if</span> x</code></pre>`,
)
result := generate_description(`<pre><code><span class="hl-keyword">if</span> x</code></pre>`)
testing.expect_value(t, result, "if x")
}
+27
View File
@@ -0,0 +1,27 @@
package main
import "core:fmt"
Ctx :: struct {
title: string,
using page: Page,
site: Site,
}
Page :: struct {
title: string,
}
Site :: struct {
title: string,
}
main :: proc() {
site := Ctx {
site = Site{title = "foo"},
page = Page{title = "bar"},
}
fmt.printfln("%+v", site)
fmt.printf("%+v", site)
}
-1
View File
@@ -94,4 +94,3 @@ when SPALL {
spall._buffer_end(&spall_ctx, &spall_buffer)
}
}
-1
View File
@@ -7,4 +7,3 @@ import "core:testing"
test_true :: proc(t: ^testing.T) {
testing.expect(t, true)
}
-1
View File
@@ -95,4 +95,3 @@ transform_alert :: proc(sb: ^strings.Builder, bq: string) {
strings.write_string(sb, " ")
strings.write_string(sb, rest)
}
-1
View File
@@ -101,4 +101,3 @@ test_multiple_alerts_render_together :: proc(t: ^testing.T) {
</blockquote>`,
)
}
-1
View File
@@ -434,4 +434,3 @@ expand_emoji :: proc(text: string) -> string {
return strings.to_string(sb)
}
-1
View File
@@ -30,4 +30,3 @@ test_emoji_skips_invalid_shortcodes :: proc(t: ^testing.T) {
testing.expect_value(t, expand_emoji(":Smile:"), ":Smile:")
testing.expect_value(t, expand_emoji(": not real :"), ": not real :")
}
-1
View File
@@ -210,4 +210,3 @@ strip_p_tags :: proc(html: string) -> string {
}
return s
}
-1
View File
@@ -119,4 +119,3 @@ test_inject_notes_missing_ref :: proc(t: ^testing.T) {
testing.expect(t, strings.contains(out, "[^missing]"))
testing.expect(t, strings.contains(out, "[*missing]"))
}
-1
View File
@@ -193,4 +193,3 @@ make_unique :: proc(slug: string, seen: ^map[string]bool) -> string {
}
return ""
}
+12 -6
View File
@@ -12,7 +12,11 @@ test_heading_simple :: proc(t: ^testing.T) {
@(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>`)
testing.expect_value(
t,
result,
`<h2 id="intro">Intro</h2><p>text</p><h2 id="intro-1">Intro</h2>`,
)
}
@(test)
@@ -36,7 +40,9 @@ test_heading_punctuation :: proc(t: ^testing.T) {
@(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,
testing.expect_value(
t,
result,
`<h1 id="a">A</h1>` +
`<h2 id="b">B</h2>` +
`<h3 id="c">C</h3>` +
@@ -88,9 +94,9 @@ test_heading_numbers :: proc(t: ^testing.T) {
@(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>`,
testing.expect_value(
t,
result,
`<h2 id="foo">Foo</h2>` + `<h2 id="foo-1">Foo</h2>` + `<h2 id="foo-2">Foo</h2>`,
)
}
-1
View File
@@ -305,4 +305,3 @@ highlight_code :: proc(html: string, file_path: string) -> string {
}
return strings.to_string(sb)
}
-1
View File
@@ -88,4 +88,3 @@ apply_extension_config :: proc(ext: ^bit_set[Extension], config: json.Object) {
}
}
}
-1
View File
@@ -43,4 +43,3 @@ wrap_sections :: proc(html: string) -> string {
return html
}
}
-1
View File
@@ -46,4 +46,3 @@ test_wrap_sections_doesnt_split_content :: proc(t: ^testing.T) {
"<section><h1>Big</h1><h3>Small</h3></section>",
)
}
+223
View File
@@ -0,0 +1,223 @@
package main
import "core:encoding/json"
import "core:fmt"
import "core:log"
import "core:os"
import "core:strings"
Menu_Entry :: struct {
name: string,
url: string,
}
// build_menus populates site.menus:
// 1. Config menus (thor.json "menus" key present) — exclusive, preserves array order
// 2. Auto-menus (sections + root-level pages) + page frontmatter menus — merged, sorted
//
// If "menus" is present but empty ({}) it means explicit opt-out: no menus.
// Config menus cannot be mixed with page frontmatter menus (error).
build_menus :: proc(site: ^Site) {
if site.menus != nil {
has_menus := false
for page in site.pages {
if page.menu != "" {
has_menus = true
break
}
}
// Already populated from config in site_apply_config
if len(site.menus) == 0 {
// Explicit opt-out ("menus": {})
if has_menus {
log.warnf(
"menus: config has empty menus but pages have frontmatter menu entries; ignoring page menus",
)
}
return
}
// Config menus active
if has_menus {
log.fatalf("menus: cannot mix config menus with frontmatter menus")
os.exit(1)
}
return
}
// No config menus — auto-generate, then merge page menus on top
collect_auto_menus(site)
merge_page_menus(site)
}
// merge_page_menus collects frontmatter "menu" entries from pages and merges
// them into site.menus (which may already contain auto-generated entries).
// If no pages have "menu" set, this is a no-op.
merge_page_menus :: proc(site: ^Site) {
alloc := site_allocator(site)
// Collect page entries by menu name
page_entries := make(map[string][dynamic]Menu_Entry, 16, alloc)
for page in site.pages {
if page.menu == "" {
continue
}
if _, ok := page_entries[page.menu]; !ok {
page_entries[page.menu] = make([dynamic]Menu_Entry, 0, 4, alloc)
}
append(&page_entries[page.menu], Menu_Entry{name = page.title, url = page.permalink})
}
if len(page_entries) == 0 {
return
}
if site.menus == nil {
site.menus = make(map[string][]Menu_Entry, alloc)
}
for menu_name, entries in page_entries {
sort_menu_entries(entries[:])
if existing, ok := site.menus[menu_name]; ok {
// Merge with existing auto-generated entries
merged := make([dynamic]Menu_Entry, 0, len(existing) + len(entries), alloc)
append(&merged, ..existing)
append(&merged, ..entries[:])
sort_menu_entries(merged[:])
site.menus[menu_name] = merged[:]
} else {
site.menus[menu_name] = entries[:]
}
}
}
collect_auto_menus :: proc(site: ^Site) {
alloc := site_allocator(site)
sections: map[string]bool
for page in site.pages {
if page._is_index {
continue
}
if page.section != "" {
sections[page.section] = true
}
}
entries := make([dynamic]Menu_Entry, 0, 8, alloc)
// Section entries (one per section directory)
for section in sections {
name := to_title_case(section, alloc)
url := fmt.aprintf("/%s/", section, allocator = alloc)
for page in site.pages {
if page.section == section && page._is_index {
url = page.permalink
if page.title != "" {
name = page.title
}
break
}
}
append(&entries, Menu_Entry{name = name, url = url})
}
// Root-level page entries (section = "", not index)
for page in site.pages {
if page._is_index || page.section != "" || page.title == "" {
continue
}
append(&entries, Menu_Entry{name = page.title, url = page.permalink})
}
if len(entries) == 0 {
return
}
sort_menu_entries(entries[:])
site.menus = make(map[string][]Menu_Entry, alloc)
site.menus["main"] = entries[:]
}
sort_menu_entries :: proc(entries: []Menu_Entry) {
for i in 1 ..< len(entries) {
key := entries[i]
j := i - 1
for j >= 0 && strings.compare(entries[j].name, key.name) > 0 {
entries[j + 1] = entries[j]
j -= 1
}
entries[j + 1] = key
}
}
// parse_config_menus converts raw JSON from thor.json into map[string][]Menu_Entry.
// Preserves array order as-declared.
parse_config_menus :: proc(
raw: json.Value,
allocator := context.allocator,
) -> map[string][]Menu_Entry {
obj, ok := raw.(json.Object)
if !ok || len(obj) == 0 {
return nil
}
result := make(map[string][]Menu_Entry, allocator)
for menu_name, menu_val in obj {
arr, ok := menu_val.(json.Array)
if !ok {
log.warnf("menus: '%s' is not an array, skipping", menu_name)
continue
}
entries := make([dynamic]Menu_Entry, 0, len(arr), allocator)
for item, idx in arr {
entry_obj, ok := item.(json.Object)
if !ok {
log.warnf("menus: '%s' entry %d is not an object, skipping", menu_name, idx)
continue
}
name := ""
url := ""
if v, ok := entry_obj["name"]; ok {
if s, ok2 := v.(json.String); ok2 {
name = string(s)
} else {
log.warnf(
"menus: '%s' entry %d: 'name' must be a string, got %v, skipping",
menu_name,
idx,
v,
)
continue
}
}
if v, ok := entry_obj["url"]; ok {
if s, ok2 := v.(json.String); ok2 {
url = string(s)
} else {
log.warnf(
"menus: '%s' entry %d: 'url' must be a string, got %v, skipping",
menu_name,
idx,
v,
)
continue
}
}
if name == "" {
log.warnf("menus: '%s' entry %d missing 'name', skipping", menu_name, idx)
continue
}
append(&entries, Menu_Entry{name = name, url = url})
}
result[menu_name] = entries[:]
}
return result
}
+21 -21
View File
@@ -54,7 +54,7 @@ minify_html :: proc(source: string) -> string {
segment := source[i:p.end]
strings.write_string(&sb, segment)
if len(segment) > 0 {
last_written = segment[len(segment)-1]
last_written = segment[len(segment) - 1]
}
i = int(p.end)
pi += 1
@@ -103,27 +103,27 @@ collect_html_ranges :: proc(
preserves: ^[dynamic]Range,
) {
child_count := ts.node_named_child_count(node)
for i in 0..<child_count {
for i in 0 ..< child_count {
child := ts.node_named_child(node, u32(i))
type_str := string(ts.node_type(child))
if type_str == "comment" {
append(comments, Range{
start = ts.node_start_byte(child),
end = ts.node_end_byte(child),
})
append(
comments,
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
)
} else if type_str == "script_element" || type_str == "style_element" {
append(preserves, Range{
start = ts.node_start_byte(child),
end = ts.node_end_byte(child),
})
append(
preserves,
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
)
} else if type_str == "element" {
tag := html_tag_name(child, source)
if is_preserve_tag(tag) {
append(preserves, Range{
start = ts.node_start_byte(child),
end = ts.node_end_byte(child),
})
append(
preserves,
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
)
} else {
collect_html_ranges(child, source, comments, preserves)
}
@@ -135,11 +135,11 @@ collect_html_ranges :: proc(
html_tag_name :: proc(element: ts.Node, source: string) -> string {
child_count := ts.node_named_child_count(element)
for i in 0..<child_count {
for i in 0 ..< child_count {
child := ts.node_named_child(element, u32(i))
if string(ts.node_type(child)) == "start_tag" {
tag_child_count := ts.node_named_child_count(child)
for j in 0..<tag_child_count {
for j in 0 ..< tag_child_count {
tag_child := ts.node_named_child(child, u32(j))
if string(ts.node_type(tag_child)) == "tag_name" {
start := ts.node_start_byte(tag_child)
@@ -241,13 +241,13 @@ minify_css :: proc(source: string) -> string {
collect_css_comments :: proc(node: ts.Node, comments: ^[dynamic]Range) {
child_count := ts.node_named_child_count(node)
for i in 0..<child_count {
for i in 0 ..< child_count {
child := ts.node_named_child(node, u32(i))
if string(ts.node_type(child)) == "comment" {
append(comments, Range{
start = ts.node_start_byte(child),
end = ts.node_end_byte(child),
})
append(
comments,
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
)
} else {
collect_css_comments(child, comments)
}
+1 -1
View File
@@ -58,7 +58,7 @@ Errors (returned as `Data_Error` at render time):
#### `format`
Formats an ISO 8601 date string as a display string. Takes a string, returns a string (e.g. `"2026-03-15T08:49:54-04:00"``"15 Mar 2026"`). Invalid input (empty, too-short, non-string, or unparseable) returns a `Data_Error`. Templates that need to skip dateless pages should gate with a section — `{{#date}}<time datetime="{{.}}">{{. | format}}</time>{{/date}}` — so the section's truthiness check catches empty before the filter runs. Commonly used inline as `{{date | format}}` to render a display string while keeping the raw ISO available via `{{date}}` for the `datetime=` attribute.
Formats an ISO 8601 date string as a display string. Takes a string, returns a string (e.g. `"2026-03-15T08:49:54-04:00"``"15 Mar 2026"`). Invalid input (empty, too-short, non-string, or unparsable) returns a `Data_Error`. Templates that need to skip dateless pages should gate with a section — `{{#date}}<time datetime="{{.}}">{{. | format}}</time>{{/date}}` — so the section's truthiness check catches empty before the filter runs. Commonly used inline as `{{date | format}}` to render a display string while keeping the raw ISO available via `{{date}}` for the `datetime=` attribute.
Internally: parses the invariant `YYYY-MM-DD` prefix by char offset, stringifies `time.Month(month_num)` and slices `[:3]` for the abbreviation. Accepts any of these ISO 8601 forms (the date prefix is what matters): `2023-10-15T13:18:50-07:00`, `2023-10-15T13:18:50-0700`, `2023-10-15T13:18:50Z`, `2023-10-15T13:18:50`, `2023-10-15`.
-1
View File
@@ -302,4 +302,3 @@ write_value :: proc(b: ^strings.Builder, a: any, escape: bool) {
strings.write_string(b, s[start:])
}
}
-1
View File
@@ -320,4 +320,3 @@ format_render_error :: proc(err: Error, tmpl: Template, colorize: bool = false)
b := body(err)
return format_error(path, tmpl.source, b.pos, b.msg, colorize = colorize)
}
-1
View File
@@ -557,4 +557,3 @@ test_parse_error_pipe_parse_in_inverted_keeps_double_braces :: proc(t: ^testing.
fmt.tprintf("msg should contain literal '{{^', got %q", b.msg),
)
}
-1
View File
@@ -321,4 +321,3 @@ convert_to_tz :: proc(
},
true
}
+116 -27
View File
@@ -104,38 +104,64 @@ test_parse_offset_skips_fractional_seconds :: proc(t: ^testing.T) {
@(test)
test_format_date_weekday_full :: proc(t: ^testing.T) {
// 2026-01-01 is a Thursday.
dt := Date_Components{year = 2026, month = 1, day = 1}
dt := Date_Components {
year = 2026,
month = 1,
day = 1,
}
result := format_date(dt, "Monday")
testing.expect_value(t, result, "Thursday")
}
@(test)
test_format_date_weekday_abbr :: proc(t: ^testing.T) {
dt := Date_Components{year = 2026, month = 1, day = 1}
dt := Date_Components {
year = 2026,
month = 1,
day = 1,
}
result := format_date(dt, "Mon")
testing.expect_value(t, result, "Thu")
}
@(test)
test_format_date_month_full_name :: proc(t: ^testing.T) {
dt := Date_Components{year = 2026, month = 3, day = 15}
dt := Date_Components {
year = 2026,
month = 3,
day = 15,
}
result := format_date(dt, "January")
testing.expect_value(t, result, "March")
}
@(test)
test_format_date_two_digit_year :: proc(t: ^testing.T) {
dt := Date_Components{year = 2026, month = 3, day = 15}
dt := Date_Components {
year = 2026,
month = 3,
day = 15,
}
result := format_date(dt, "06")
testing.expect_value(t, result, "26")
}
@(test)
test_format_date_hour24_padded :: proc(t: ^testing.T) {
midnight := Date_Components{year = 2026, month = 1, day = 1, hour = 0}
midnight := Date_Components {
year = 2026,
month = 1,
day = 1,
hour = 0,
}
testing.expect_value(t, format_date(midnight, "15"), "00")
afternoon := Date_Components{year = 2026, month = 1, day = 1, hour = 13}
afternoon := Date_Components {
year = 2026,
month = 1,
day = 1,
hour = 13,
}
testing.expect_value(t, format_date(afternoon, "15"), "13")
}
@@ -147,7 +173,12 @@ test_format_date_hour12_padded_am_pm_boundaries :: proc(t: ^testing.T) {
}{{0, "12 AM"}, {12, "12 PM"}, {13, "01 PM"}, {23, "11 PM"}}
for &c in cases {
dt := Date_Components{year = 2026, month = 1, day = 1, hour = c.hour}
dt := Date_Components {
year = 2026,
month = 1,
day = 1,
hour = c.hour,
}
result := format_date(dt, "03 PM")
testing.expect_value(t, result, c.expected)
}
@@ -155,32 +186,62 @@ test_format_date_hour12_padded_am_pm_boundaries :: proc(t: ^testing.T) {
@(test)
test_format_date_hour12_unpadded :: proc(t: ^testing.T) {
one_am := Date_Components{year = 2026, month = 1, day = 1, hour = 1}
one_am := Date_Components {
year = 2026,
month = 1,
day = 1,
hour = 1,
}
testing.expect_value(t, format_date(one_am, "3"), "1")
one_pm := Date_Components{year = 2026, month = 1, day = 1, hour = 13}
one_pm := Date_Components {
year = 2026,
month = 1,
day = 1,
hour = 13,
}
testing.expect_value(t, format_date(one_pm, "3"), "1")
}
@(test)
test_format_date_am_pm_lowercase :: proc(t: ^testing.T) {
afternoon := Date_Components{year = 2026, month = 1, day = 1, hour = 13}
afternoon := Date_Components {
year = 2026,
month = 1,
day = 1,
hour = 13,
}
testing.expect_value(t, format_date(afternoon, "pm"), "pm")
morning := Date_Components{year = 2026, month = 1, day = 1, hour = 9}
morning := Date_Components {
year = 2026,
month = 1,
day = 1,
hour = 9,
}
testing.expect_value(t, format_date(morning, "pm"), "am")
}
@(test)
test_format_date_minute_second_padding :: proc(t: ^testing.T) {
dt := Date_Components{year = 2026, month = 1, day = 1, minute = 4, second = 5}
dt := Date_Components {
year = 2026,
month = 1,
day = 1,
minute = 4,
second = 5,
}
testing.expect_value(t, format_date(dt, "04:05"), "04:05")
testing.expect_value(t, format_date(dt, "4:5"), "4:5")
}
@(test)
test_format_date_month_day_numeric_padding :: proc(t: ^testing.T) {
dt := Date_Components{year = 2026, month = 3, day = 5}
dt := Date_Components {
year = 2026,
month = 3,
day = 5,
}
testing.expect_value(t, format_date(dt, "01"), "03")
testing.expect_value(t, format_date(dt, "1"), "3")
testing.expect_value(t, format_date(dt, "02"), "05")
@@ -191,14 +252,23 @@ test_format_date_month_day_numeric_padding :: proc(t: ^testing.T) {
test_format_date_mst_defaults_utc :: proc(t: ^testing.T) {
// Date_Components constructed directly (not via parse_iso_date)
// defaults to UTC for the MST token.
dt := Date_Components{year = 2026, month = 1, day = 1, hour = 12}
dt := Date_Components {
year = 2026,
month = 1,
day = 1,
hour = 12,
}
result := format_date(dt, "MST")
testing.expect_value(t, result, "UTC")
}
@(test)
test_format_date_literal_passthrough :: proc(t: ^testing.T) {
dt := Date_Components{year = 2026, month = 1, day = 1}
dt := Date_Components {
year = 2026,
month = 1,
day = 1,
}
result := format_date(dt, "Year: 2006!")
testing.expect_value(t, result, "Year: 2026!")
}
@@ -206,7 +276,14 @@ test_format_date_literal_passthrough :: proc(t: ^testing.T) {
@(test)
test_format_date_combined_go_reference_layout :: proc(t: ^testing.T) {
// 2023-10-15 is a Sunday.
dt := Date_Components{year = 2023, month = 10, day = 15, hour = 13, minute = 18, second = 50}
dt := Date_Components {
year = 2023,
month = 10,
day = 15,
hour = 13,
minute = 18,
second = 50,
}
result := format_date(dt, "Mon Jan 2 15:04:05 MST 2006")
testing.expect_value(t, result, "Sun Oct 15 13:18:50 UTC 2023")
}
@@ -241,9 +318,13 @@ test_convert_to_tz_no_offset_assumes_target :: proc(t: ^testing.T) {
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,
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)
@@ -260,11 +341,15 @@ test_convert_to_tz_with_offset_converts :: proc(t: ^testing.T) {
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,
c := Date_Components {
year = 2026,
month = 3,
day = 15,
hour = 12,
minute = 49,
second = 54,
offset_seconds = 0,
has_offset = true,
has_offset = true,
}
result, ok := convert_to_tz(c, tz)
testing.expect_value(t, ok, true)
@@ -281,11 +366,15 @@ test_convert_to_tz_with_negative_offset_converts :: proc(t: ^testing.T) {
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,
c := Date_Components {
year = 2026,
month = 3,
day = 15,
hour = 8,
minute = 49,
second = 54,
offset_seconds = -14400,
has_offset = true,
has_offset = true,
}
result, ok := convert_to_tz(c, tz)
testing.expect_value(t, ok, true)
-1
View File
@@ -152,4 +152,3 @@ test_lambda_inverted_section :: proc(t: ^testing.T) {
}
*/
+197 -138
View File
@@ -1,17 +1,28 @@
package mustache
import "base:runtime"
import "core:fmt"
import "core:log"
import "core:reflect"
import "core:strings"
// A hypothetical maximum context depth. Trying to pass more than this many items
// to render(tpl, data), or nesting templates further than this depth would be
// an error.
// May be enforced in a later version (for performance)
MAX_CONTEXT_DEPTH :: #config(MAX_CONTEXT_DEPTH, 16)
// Context_Stack is the growable stack of data frames walked top-to-bottom by
// resolve_name. Frames are pushed on section descent and popped on exit; the
// root data (or each element of a root []any) forms the base frames.
Context_Stack :: [dynamic]any
// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------
Error_Kind :: enum {
Syntax, // parse-time: malformed template
Data, // render-time: template fine, data wrong (e.g. filter misuse)
Data, // render-time: template fine, data wrong (e.g. filter misuse)
}
Error_Body :: struct {
@@ -21,14 +32,18 @@ Error_Body :: struct {
}
// Error is nil when no error occurred.
Error :: union { Error_Body }
Error :: union {
Error_Body,
}
// body unwraps the Error_Body from a non-nil Error.
// Precondition: err != nil.
body :: proc(err: Error) -> Error_Body {
switch e in err {
case Error_Body: return e
case: return {}
case Error_Body:
return e
case:
return {}
}
}
@@ -142,12 +157,24 @@ render :: proc(
err: Error,
) {
builder: strings.Builder
strings.builder_init(&builder, allocator)
defer strings.builder_destroy(&builder)
strings.builder_init(&builder, context.temp_allocator)
ctx := make([dynamic]any, 0, 4, allocator)
defer delete(ctx)
append(&ctx, data)
ctx := make(Context_Stack, 0, 4, context.temp_allocator)
// If data is a []any, expand into individual context frames.
// Otherwise, push as a single frame.
elem_info, count, slice_data := list_info(data)
if elem_info != nil {
if _, is_any := elem_info.variant.(runtime.Type_Info_Any); is_any {
for j in 0 ..< count {
append(&ctx, extract_list_element(elem_info, slice_data, j))
}
} else {
append(&ctx, data)
}
} else {
append(&ctx, data)
}
all_nodes := tmpl.nodes[:]
err = render_nodes(tmpl, all_nodes, &ctx, partials, &builder)
@@ -272,14 +299,14 @@ parse_section :: proc(
case .Section_Close:
if strings.contains(tok.value, "|") {
return Error_Body {
msg = fmt.tprintf(
"pipe expression not allowed in close tag '{{{{/%s}}}}' — use the bare key",
tok.value,
),
pos = tok.pos,
kind = .Syntax,
}
return Error_Body {
msg = fmt.tprintf(
"pipe expression not allowed in close tag '{{{{/%s}}}}' — use the bare key",
tok.value,
),
pos = tok.pos,
kind = .Syntax,
}
}
if end_tag != "" && tok.value == end_tag {
pos^ += 1
@@ -316,12 +343,7 @@ parse_section :: proc(
idx := len(nodes)
append(
nodes,
Node {
kind = .Parent,
key = tok.value,
indent = tok.indent,
pos = tok.pos,
},
Node{kind = .Parent, key = tok.value, indent = tok.indent, pos = tok.pos},
)
parse_section(tokens, pos, nodes, tok.value, source, allocator, tok.pos) or_return
nodes[idx].children = nodes[idx + 1:len(nodes)]
@@ -329,15 +351,7 @@ parse_section :: proc(
case .Block_Open:
pos^ += 1
idx := len(nodes)
append(
nodes,
Node {
kind = .Block,
key = tok.value,
indent = tok.indent,
pos = tok.pos,
},
)
append(nodes, Node{kind = .Block, key = tok.value, indent = tok.indent, pos = tok.pos})
parse_section(tokens, pos, nodes, tok.value, source, allocator, tok.pos) or_return
nodes[idx].children = nodes[idx + 1:len(nodes)]
}
@@ -484,21 +498,29 @@ remove_line_indent :: proc(s: string, indent: string, allocator := context.alloc
render_template :: proc(
pt: Template,
ctx: ^[dynamic]any,
ctx: ^Context_Stack,
partials: map[string]Template,
b: ^strings.Builder,
blocks: map[string]Block_Override,
indent: string,
) -> Error {
if len(indent) > 0 {
state := Indent_State{indent = indent, at_line_start = false}
state := Indent_State {
indent = indent,
at_line_start = false,
}
strings.write_string(b, indent) // first line always gets indent
return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, &state)
}
return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, nil)
}
write_indented :: proc(b: ^strings.Builder, indent: string, content: string, at_line_start: ^bool) {
write_indented :: proc(
b: ^strings.Builder,
indent: string,
content: string,
at_line_start: ^bool,
) {
if len(indent) == 0 || len(content) == 0 {
strings.write_string(b, content)
return
@@ -529,7 +551,7 @@ write_indented :: proc(b: ^strings.Builder, indent: string, content: string, at_
render_nodes :: proc(
current: Template,
nodes: []Node,
ctx: ^[dynamic]any,
ctx: ^Context_Stack,
partials: map[string]Template,
b: ^strings.Builder,
blocks: map[string]Block_Override = nil,
@@ -573,16 +595,16 @@ render_nodes :: proc(
if perr == nil {
temp: strings.Builder
strings.builder_init(&temp, context.temp_allocator)
render_nodes(
sub_tpl,
sub_tpl.nodes[:],
ctx,
partials,
&temp,
blocks,
nil,
) or_return
write_value(b, strings.to_string(temp), escape = true)
render_nodes(
sub_tpl,
sub_tpl.nodes[:],
ctx,
partials,
&temp,
blocks,
nil,
) or_return
write_value(b, strings.to_string(temp), escape = true)
}
} else {
write_value(b, val, escape = true)
@@ -615,16 +637,16 @@ render_nodes :: proc(
if perr == nil {
temp: strings.Builder
strings.builder_init(&temp, context.temp_allocator)
render_nodes(
sub_tpl,
sub_tpl.nodes[:],
ctx,
partials,
&temp,
blocks,
nil,
) or_return
write_value(b, strings.to_string(temp), escape = false)
render_nodes(
sub_tpl,
sub_tpl.nodes[:],
ctx,
partials,
&temp,
blocks,
nil,
) or_return
write_value(b, strings.to_string(temp), escape = false)
}
} else {
write_value(b, val, escape = false)
@@ -651,23 +673,36 @@ render_nodes :: proc(
context.temp_allocator,
)
if perr == nil {
render_nodes(
sub_tpl,
sub_tpl.nodes[:],
ctx,
partials,
b,
blocks,
nil,
) or_return
render_nodes(
sub_tpl,
sub_tpl.nodes[:],
ctx,
partials,
b,
blocks,
nil,
) or_return
}
} else if is_truthy(val) {
children := node.children
elem_info, count, data := list_info(val)
if elem_info != nil {
for j in 0 ..< count {
elem := extract_list_element(elem_info, data, j)
append(ctx, elem)
} else if is_truthy(val) {
children := node.children
elem_info, count, data := list_info(val)
if elem_info != nil {
for j in 0 ..< count {
elem := extract_list_element(elem_info, data, j)
context_push(ctx, elem, current, node)
defer pop(ctx)
render_nodes(
current,
children,
ctx,
partials,
b,
blocks,
indent_state,
) or_return
}
} else {
context_push(ctx, val, current, node)
defer pop(ctx)
render_nodes(
current,
@@ -679,13 +714,8 @@ render_nodes :: proc(
indent_state,
) or_return
}
} else {
append(ctx, val)
defer pop(ctx)
render_nodes(current, children, ctx, partials, b, blocks, indent_state) or_return
}
}
i += 1 + len(node.children)
i += 1 + len(node.children)
case .Inverted:
val := resolve_name(node.key, ctx[:])
@@ -699,10 +729,18 @@ render_nodes :: proc(
}
val = transformed
}
if !is_truthy(val) {
render_nodes(current, node.children, ctx, partials, b, blocks, indent_state) or_return
}
i += 1 + len(node.children)
if !is_truthy(val) {
render_nodes(
current,
node.children,
ctx,
partials,
b,
blocks,
indent_state,
) or_return
}
i += 1 + len(node.children)
case .Partial:
name := node.key
@@ -721,64 +759,64 @@ render_nodes :: proc(
}
i += 1
case .Block:
content_nodes: []Node
content_blocks := blocks
render_current := current
case .Block:
content_nodes: []Node
content_blocks := blocks
render_current := current
found_override := false
if blocks != nil {
if o, ok := blocks[node.key]; ok {
content_nodes = o.nodes
found_override = true
render_current = o.source
found_override := false
if blocks != nil {
if o, ok := blocks[node.key]; ok {
content_nodes = o.nodes
found_override = true
render_current = o.source
}
}
}
if !found_override {
content_nodes = node.children
}
if len(node.indent) > 0 {
temp: strings.Builder
strings.builder_init(&temp, context.temp_allocator)
render_nodes(
render_current,
content_nodes,
ctx,
partials,
&temp,
content_blocks,
nil,
) or_return
at_ls := true
write_indented(b, node.indent, strings.to_string(temp), &at_ls)
} else {
render_nodes(
render_current,
content_nodes,
ctx,
partials,
b,
content_blocks,
indent_state,
) or_return
}
i += 1 + len(node.children)
case .Parent:
parent_children := node.children
merged := merge_block_overrides(parent_children, blocks, current)
pt, found := partials[node.key]
if !found {
warn_missing_partial(current, partials, node, node.key)
} else {
warn_unmatched_block_overrides(current, pt, parent_children)
render_template(pt, ctx, partials, b, merged, node.indent) or_return
if indent_state != nil {
indent_state.at_line_start = false
if !found_override {
content_nodes = node.children
}
}
i += 1 + len(node.children)
if len(node.indent) > 0 {
temp: strings.Builder
strings.builder_init(&temp, context.temp_allocator)
render_nodes(
render_current,
content_nodes,
ctx,
partials,
&temp,
content_blocks,
nil,
) or_return
at_ls := true
write_indented(b, node.indent, strings.to_string(temp), &at_ls)
} else {
render_nodes(
render_current,
content_nodes,
ctx,
partials,
b,
content_blocks,
indent_state,
) or_return
}
i += 1 + len(node.children)
case .Parent:
parent_children := node.children
merged := merge_block_overrides(parent_children, blocks, current)
pt, found := partials[node.key]
if !found {
warn_missing_partial(current, partials, node, node.key)
} else {
warn_unmatched_block_overrides(current, pt, parent_children)
render_template(pt, ctx, partials, b, merged, node.indent) or_return
if indent_state != nil {
indent_state.at_line_start = false
}
}
i += 1 + len(node.children)
}
}
return nil
@@ -922,3 +960,24 @@ warn_unmatched_block_overrides :: proc(
}
}
context_push :: proc(ctx: ^Context_Stack, val: any, current: Template, node: Node) {
append(ctx, val)
if len(ctx^) == MAX_CONTEXT_DEPTH + 1 {
warn_context_depth(current, node)
}
}
// warn_context_depth emits a diagnostic warning pointing at the section tag
// whose push carried the context stack past MAX_CONTEXT_DEPTH.
warn_context_depth :: proc(current: Template, node: Node) {
msg := fmt.tprintf(
"context stack depth exceeded %d (possible recursive section/partial)",
MAX_CONTEXT_DEPTH,
)
path := current.path
if path == "" {
path = "<input>"
}
diag := format_error(path, current.source, node.pos, msg, "", colorize = should_colorize())
log.warnf("%s", diag)
}
+37
View File
@@ -1,7 +1,9 @@
#+test
package mustache
import "core:log"
import "core:mem"
import "core:strings"
import "core:testing"
@(test)
@@ -124,3 +126,38 @@ leak_repeated_render :: proc(t: ^testing.T) {
}
}
// A deeply nested map pushes the context stack past MAX_CONTEXT_DEPTH (16).
// The depth warning must be non-fatal: rendering still succeeds.
@(test)
test_context_depth_warns :: proc(t: ^testing.T) {
context.logger = log.nil_logger()
AMT :: MAX_CONTEXT_DEPTH + 2
// Build AMT nested {x: {...}} levels; the innermost holds `leaf`.
data := make(map[string]any, context.temp_allocator)
data["leaf"] = "found"
for _ in 0 ..< AMT {
outer := make(map[string]any, context.temp_allocator)
outer["x"] = data
data = outer
}
// Template: AMT nested {{#x}} sections around {{leaf}}.
src: strings.Builder
strings.builder_init(&src, context.temp_allocator)
for _ in 0 ..< AMT do strings.write_string(&src, "{{#x}}")
strings.write_string(&src, "{{leaf}}")
for _ in 0 ..< AMT do strings.write_string(&src, "{{/x}}")
template := strings.to_string(src)
tmpl, perr := parse(template, "<depth-test>", context.temp_allocator)
testing.expect(t, perr == nil, "should parse")
if perr != nil {
return
}
result, rerr := render(tmpl, data, allocator = context.temp_allocator)
testing.expect(t, rerr == nil, "depth warning must be non-fatal")
testing.expect_value(t, result, "found")
}
+7 -8
View File
@@ -53,8 +53,8 @@ tokenize_fields :: proc(seg: string, pos: int) -> (tokens: [dynamic]string, err:
}
if j >= len(seg) {
return tokens, Error_Body {
msg = fmt.tprintf("unterminated string literal: %s", seg),
pos = pos,
msg = fmt.tprintf("unterminated string literal: %s", seg),
pos = pos,
kind = .Syntax,
}
}
@@ -177,23 +177,23 @@ resolve_format_string :: proc(name: string, ctx: []any, pos: int) -> (string, Er
raw := resolve_name(name, ctx)
if raw == nil {
return "", Error_Body {
msg = fmt.tprintf("unable to resolve date format key '%s'", name),
pos = pos,
msg = fmt.tprintf("unable to resolve date format key '%s'", name),
pos = pos,
kind = .Data,
}
}
str, ok := reflect.as_string(raw)
if !ok {
return "", Error_Body {
msg = fmt.tprintf("date format key '%s' is not a string", name),
pos = pos,
msg = fmt.tprintf("date format key '%s' is not a string", name),
pos = pos,
kind = .Data,
}
}
return str, nil
}
// TODO: diagnostics don't show anything relevent
// TODO: diagnostics don't show anything relevant
apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) -> (any, Error) {
switch filter.op {
case "group_by":
@@ -337,4 +337,3 @@ apply_group_by :: proc(value: any, args: []string, pos: int) -> (result: any, er
return groups, nil
}
+26 -14
View File
@@ -214,7 +214,7 @@ test_interp_pipe_basic :: proc(t: ^testing.T) {
timezone: ^datetime.TZ_Region,
}
data := Scalar_Data {
name = "2026-03-15T08:49:54-04:00",
name = "2026-03-15T08:49:54-04:00",
date_format = "2 Jan 2006",
}
tpl, _ := parse("[{{name | format}}]", "<test>", allocator = context.temp_allocator)
@@ -230,7 +230,7 @@ test_interp_pipe_unescaped :: proc(t: ^testing.T) {
timezone: ^datetime.TZ_Region,
}
data := Scalar_Data {
name = "2025-12-25T00:00:00Z",
name = "2025-12-25T00:00:00Z",
date_format = "2 Jan 2006",
}
tpl, _ := parse("[{{&name | format}}]", "<test>", allocator = context.temp_allocator)
@@ -246,10 +246,14 @@ test_interp_pipe_dot_current :: proc(t: ^testing.T) {
timezone: ^datetime.TZ_Region,
}
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"},
date_format = "2 Jan 2006",
}
tpl, _ := parse("{{#items}}[{{. | format}}]{{/items}}", "<test>", allocator = context.temp_allocator)
tpl, _ := parse(
"{{#items}}[{{. | format}}]{{/items}}",
"<test>",
allocator = context.temp_allocator,
)
result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "[6 Jan 2026][15 Jun 2026][15 Oct 2026]")
}
@@ -267,7 +271,7 @@ Format_Data :: struct {
@(test)
test_format_typical_iso :: proc(t: ^testing.T) {
data := Format_Data {
date = "2026-03-15T08:49:54-04:00",
date = "2026-03-15T08:49:54-04:00",
date_format = "2 Jan 2006",
}
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
@@ -278,7 +282,7 @@ test_format_typical_iso :: proc(t: ^testing.T) {
@(test)
test_format_short_date_only :: proc(t: ^testing.T) {
data := Format_Data {
date = "2026-06-06",
date = "2026-06-06",
date_format = "2 Jan 2006",
}
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
@@ -289,7 +293,7 @@ test_format_short_date_only :: proc(t: ^testing.T) {
@(test)
test_format_empty_input_errors :: proc(t: ^testing.T) {
data := Format_Data {
date = "",
date = "",
date_format = "2 Jan 2006",
}
tpl, _ := parse("[{{date | format}}]", "<test>", allocator = context.temp_allocator)
@@ -301,7 +305,7 @@ test_format_empty_input_errors :: proc(t: ^testing.T) {
@(test)
test_format_non_date_string_errors :: proc(t: ^testing.T) {
data := Format_Data {
date = "abc",
date = "abc",
date_format = "2 Jan 2006",
}
tpl, _ := parse("[{{date | format}}]", "<test>", allocator = context.temp_allocator)
@@ -327,7 +331,7 @@ test_format_non_string_value_errors :: proc(t: ^testing.T) {
@(test)
test_format_invalid_month_errors :: proc(t: ^testing.T) {
data := Format_Data {
date = "2023-13-15",
date = "2023-13-15",
date_format = "2 Jan 2006",
}
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
@@ -341,7 +345,7 @@ test_format_inside_section_renders :: proc(t: ^testing.T) {
// Mirrors the datetime.html partial pattern: section pushes raw string,
// partial uses {{.}} for ISO attr and {{. | format}} for display.
data := Format_Data {
date = "2025-12-25T00:00:00Z",
date = "2025-12-25T00:00:00Z",
date_format = "2 Jan 2006",
}
tpl, _ := parse(
@@ -356,10 +360,14 @@ test_format_inside_section_renders :: proc(t: ^testing.T) {
@(test)
test_format_inside_section_skips_when_empty :: proc(t: ^testing.T) {
data := Format_Data {
date = "",
date = "",
date_format = "2 Jan 2006",
}
tpl, _ := parse("[{{#date}}<time>{{. | format}}</time>{{/date}}]", "<test>", allocator = context.temp_allocator)
tpl, _ := parse(
"[{{#date}}<time>{{. | format}}</time>{{/date}}]",
"<test>",
allocator = context.temp_allocator,
)
result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "[]")
}
@@ -374,7 +382,11 @@ test_format_quoted_literal_arg :: proc(t: ^testing.T) {
date = "2026-03-15T08:49:54-04:00",
date_format = "2 Jan 2006",
}
tpl, _ := parse(`{{date | format "Jan 2, 2006"}}`, "<test>", allocator = context.temp_allocator)
tpl, _ := parse(
`{{date | format "Jan 2, 2006"}}`,
"<test>",
allocator = context.temp_allocator,
)
result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "Mar 15, 2026")
}
@@ -465,7 +477,7 @@ test_format_handles_all_iso8601_variants :: proc(t: ^testing.T) {
}
for &c in cases {
data := Format_Data {
date = c.input,
date = c.input,
date_format = "2 Jan 2006",
}
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
-1
View File
@@ -132,4 +132,3 @@ spec_dynamic_names :: proc(t: ^testing.T) {
spec_inheritance :: proc(t: ^testing.T) {
run_spec_file(t, "spec/specs/~inheritance.json")
}
+2 -8
View File
@@ -153,10 +153,7 @@ suggest_correction :: proc(available: []string, missing: string) -> string {
if len(available) == 0 || len(missing) == 0 {
return ""
}
threshold := 2
if len(missing) > 8 {
threshold = len(missing) / 4
}
threshold := max(2, len(missing) / 3)
best: string
best_dist := threshold + 1
@@ -187,10 +184,7 @@ collect_partial_names :: proc(
// collect_block_names enumerates the unique `{{$name}}` block definitions in
// a template's node array.
collect_block_names :: proc(
tmpl: Template,
allocator := context.temp_allocator,
) -> []string {
collect_block_names :: proc(tmpl: Template, allocator := context.temp_allocator) -> []string {
out := make([dynamic]string, 0, 0, allocator)
seen := make(map[string]bool, allocator)
defer delete(seen)
+10 -7
View File
@@ -10,11 +10,15 @@ Inner :: struct {
bar: int,
}
Page :: struct {
title: string,
}
Outer :: struct {
title: string,
page_title: string,
inner: Inner,
numbers: [3]int,
title: string,
page: Page,
inner: Inner,
numbers: [3]int,
}
@(test)
@@ -150,8 +154,8 @@ test_validate_map_path_silent :: proc(t: ^testing.T) {
@(test)
test_suggest_correction_exact :: proc(t: ^testing.T) {
available := []string{"title", "page_title", "body"}
testing.expect_value(t, suggest_correction(available, "page_titel"), "page_title")
available := []string{"title", "page.title", "body"}
testing.expect_value(t, suggest_correction(available, "page_titel"), "page.title")
}
@(test)
@@ -192,4 +196,3 @@ test_warn_no_false_positive_for_valid_keys :: proc(t: ^testing.T) {
ok, missing, _ := validate_key_path(ctx[:], "name")
testing.expect_value(t, ok, true)
}
+2 -12
View File
@@ -104,11 +104,7 @@ tokenize :: proc(
close_idx := strings.index(src[key_start:], "}}")
if close_idx < 0 {
return tokens, Error_Body {
msg = "unclosed tag '{{'",
pos = tag_pos,
kind = .Syntax,
}
return tokens, Error_Body{msg = "unclosed tag '{{'", pos = tag_pos, kind = .Syntax}
}
close := key_start + close_idx
@@ -125,12 +121,7 @@ tokenize :: proc(
}
append(
&tokens,
Token {
kind = .Partial,
value = trimmed,
is_dynamic = is_dyn,
pos = tag_pos,
},
Token{kind = .Partial, value = trimmed, is_dynamic = is_dyn, pos = tag_pos},
)
} else {
append(
@@ -299,4 +290,3 @@ should_trim_whitespace :: proc(kind: Token_Kind) -> bool {
}
return false
}
-1
View File
@@ -115,4 +115,3 @@ og_for_page :: proc(site_og: Open_Graph, page: Page) -> Open_Graph {
return og
}
+43 -53
View File
@@ -2,7 +2,6 @@ package main
import "mustache"
import "core:encoding/json"
import "core:fmt"
import "core:log"
import "core:os"
@@ -11,43 +10,20 @@ import "core:time"
import "core:time/datetime"
Template_Context :: struct {
params: json.Value,
now: string,
content: string,
title: string,
description: string,
og: Open_Graph,
date_format: string,
timezone: ^datetime.TZ_Region,
// Page Data
page_title: string,
date: string,
og: Open_Graph,
site: Site_Context,
page: Page,
// Home data
pages: [dynamic]Page_Context,
pages: [dynamic]Page,
// Section Data
// TODO: Remove "posts" from the Odin code
posts: [dynamic]Page_Context,
}
Page_Context :: struct {
permalink: string,
title: string,
starred: bool,
date: string,
year: string,
}
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),
}
posts: [dynamic]Page,
}
load_template :: proc(vfs: ^VFS, virtual_path: string) -> mustache.Template {
@@ -113,22 +89,39 @@ get_template :: proc(
return mustache.Template{}
}
capitalize :: proc(s: string) -> string {
to_title_case :: proc(s: string, allocator := context.allocator) -> string {
if len(s) == 0 {
return s
}
if s[0] >= 'a' && s[0] <= 'z' {
return fmt.aprintf("%c%s", s[0] - 32, s[1:])
out := transmute([]byte)strings.clone(s, allocator)
capitalize_next := true
for char, i in s {
switch char {
case '-', '_':
out[i] = ' '
fallthrough
case ' ':
capitalize_next = true
case 'a' ..= 'z':
if capitalize_next {
out[i] = u8(char) - 32
}
fallthrough
case:
capitalize_next = false
}
}
return s
return string(out)
}
render_template :: proc(
content_tpl: mustache.Template,
data: Template_Context,
ctx: Template_Context,
partials: map[string]mustache.Template,
) -> 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 {
log.errorf(
"%s",
@@ -157,9 +150,8 @@ render_site :: proc(site: ^Site) {
assert(ok2)
ctx := Template_Context {
site = site.site_context,
now = now,
params = site.params,
description = site.description,
og = site.og,
date_format = site.date.format,
timezone = site.tz,
@@ -175,6 +167,7 @@ render_site :: proc(site: ^Site) {
break
}
}
ctx.page = home
// Collect sections
sections := make(map[string]bool)
@@ -268,9 +261,7 @@ render_page_html :: proc(
) -> string {
ctx := ctx
ctx.title = fmt.tprintf("%s | %s", page.title, site.title)
ctx.page_title = page.title
ctx.content = page.content
ctx.date = page.date
ctx.page = page
ctx.og = og_for_page(site.og, page)
return render_template(content_tpl, ctx, partials)
}
@@ -282,18 +273,16 @@ render_home_html :: proc(
partials: map[string]mustache.Template,
ctx: Template_Context,
) -> string {
list_pages := make([dynamic]Page_Context)
defer delete(list_pages)
list_pages := make([dynamic]Page, 0, 8, context.temp_allocator)
for page in site.pages {
if page._is_index {
continue
}
append(&list_pages, build_page_context(page))
append(&list_pages, page)
}
ctx := ctx
ctx.title = site.title
ctx.content = home.content
ctx.pages = list_pages
ctx.og = og_for_page(site.og, home)
@@ -309,25 +298,27 @@ render_section :: proc(
partials: map[string]mustache.Template,
ctx: Template_Context,
) -> string {
posts := make([dynamic]Page_Context)
defer delete(posts)
alloc := site_allocator(site)
posts := make([dynamic]Page, 0, len(site.pages) / 2, context.temp_allocator)
for page in site.pages {
if page.section != section || page._is_index {
continue
}
append(&posts, build_page_context(page))
append(&posts, page)
}
ctx := ctx
if has_index {
ctx.content = section_index.content
ctx.page_title = section_index.title
ctx.page = section_index
ctx.title = fmt.tprintf("%s | %s", section_index.title, site.title)
ctx.og = og_for_page(site.og, section_index)
} else {
ctx.page_title = capitalize(section)
ctx.title = fmt.tprintf("%s | %s", capitalize(section), site.title)
ctx.og.title = capitalize(section)
title := to_title_case(section, alloc)
ctx.page = Page {
title = title,
}
ctx.title = fmt.tprintf("%s | %s", ctx.page.title, site.title)
ctx.og.title = title
ctx.og.description = ""
ctx.og.url = fmt.tprintf("%s/%s/", site.base_url, section)
ctx.og.type = "website"
@@ -413,4 +404,3 @@ write_file :: proc(path: string, html: string) {
log.errorf("cannot write %s: %v", path, err)
}
}
+24 -7
View File
@@ -13,24 +13,32 @@ import "core:time/timezone"
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,
menus: map[string][]Menu_Entry,
}
// Site is the primary workhorse, containing everything needed to build the site,
// including an arena allocator, the pages, and all the various directories and
// enabled features.
Site :: struct {
using site_context: Site_Context,
arena: mem.Dynamic_Arena,
pages: #soa[dynamic]Page,
modules: [dynamic]string,
vfs: VFS,
title: string,
description: string,
base_url: string,
config_path: string,
content_dir: string,
assets_dir: string,
output_dir: string,
layouts_dir: string,
params: json.Object,
features: bit_set[Feature],
markdown_extensions: bit_set[md.Extension],
og: Open_Graph,
date: Date_Preferences,
tz: ^datetime.TZ_Region,
grammars: string,
@@ -61,6 +69,7 @@ Config_File :: struct {
markdown_extensions: json.Value,
params: json.Value,
modules: json.Value,
menus: json.Value,
og: Open_Graph,
date: Date_Preferences,
grammars: string,
@@ -193,6 +202,15 @@ site_apply_config :: proc(site: ^Site, config: Config_File, config_dir: string)
site.og = config.og
site.date = config.date
// Parse config menus if present (nil = absent, non-nil = present)
if config.menus != nil {
site.menus = parse_config_menus(config.menus, site_allocator(site))
if site.menus == nil {
// Present but empty ({}) — explicit opt-out
site.menus = make(map[string][]Menu_Entry, site_allocator(site))
}
}
site.grammars = expand_path(config.grammars, site_allocator(site))
site.queries = expand_path(config.queries, site_allocator(site))
}
@@ -255,4 +273,3 @@ find_config :: proc(filename: string) -> (path: string, ok: bool) {
dir = dir[:idx]
}
}
+5 -3
View File
@@ -181,12 +181,15 @@ test_init_site_md_enable_disable :: proc(t: ^testing.T) {
@(test)
test_init_site_config_paths :: proc(t: ^testing.T) {
path := write_temp_config("paths", `{
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
@@ -199,4 +202,3 @@ test_init_site_config_paths :: proc(t: ^testing.T) {
testing.expect_value(t, site.output_dir, "/custom/output")
testing.expect_value(t, site.layouts_dir, "/custom/layouts")
}
+9 -3
View File
@@ -146,7 +146,7 @@ init_persistent :: proc() {
}
when SPALL {
_thread_init: proc() = nil
_thread_init: proc() = nil
_thread_cleanup: proc() = nil
set_thread_callbacks :: proc(init: proc() = nil, cleanup: proc() = nil) {
@@ -249,7 +249,14 @@ ensure_parser :: proc(lang: string) -> ^Grammar_Cache {
return gc
}
compile_query :: proc(lang: string, language: Language) -> (query: Query, cursor: Query_Cursor, ok: bool) {
compile_query :: proc(
lang: string,
language: Language,
) -> (
query: Query,
cursor: Query_Cursor,
ok: bool,
) {
query_src, query_path, qok := load_query(lang)
if !qok {
return
@@ -451,4 +458,3 @@ helix_version_from_path :: proc(path: string) -> string {
if end <= start do return ""
return path[start:end]
}
-1
View File
@@ -109,4 +109,3 @@ vfs_entry_data :: proc(entry: VFS_Entry) -> ([]byte, bool) {
}
return data, true
}