diff --git a/AGENTS.md b/AGENTS.md index 8318c25..2646b6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,9 +5,9 @@ Thor is a static site generator written in [Odin](https://odin-lang.org), replac ## Architecture ``` -thor.json ← site config (title, base_url, social, author) +thor.json ← site config (title, base_url, author, params) content/ ← markdown and HTML content files -layouts/ ← Mustache templates +layouts/ ← Mustache templates + partials assets/ ← CSS (TailwindCSS source) and JS public/ ← build output (generated) ``` @@ -23,11 +23,12 @@ public/ ← build output (generated) | `footnotes.odin` | Footnote definition stripping (pre-cmark) + sidenote injection (post-cmark) | | `alerts.odin` | GitHub alert post-processor (`> [!CAUTION]` → styled blockquote) | | `emoji.odin` | Emoji shortcode expander (`:shrug:` → `¯\_(ツ)_/¯`) | -| `render.odin` | Mustache template rendering, all page types, RSS, sitemap, robots.txt | +| `render.odin` | Mustache template rendering, all page types, RSS, sitemap, robots.txt, `load_partials` recursive scan | | `feed.odin` | RSS feed + sitemap XML generation | -| `icons.odin` | Inline SVG icon constants (home, github, rss, chevron-up, star) | | `mustache/` | Vendored [odin-mustache](https://github.com/benjamindblock/odin-mustache) library | +Icon SVGs live as HTML partials in `layouts/partials/icons/` (home, github, rss, chevron_up, star). + ### Data flow ``` @@ -38,6 +39,27 @@ content/ → walk_content → []Page (with body_html from cmark pipeline) layouts/*.html → render_site (Mustache render_in_layout) → public/ ``` +### Config system + +`Site` has `params: json.Value` for arbitrary user-defined data from `thor.json`. Social links and other template-only data live under `"params"`: + +```json +{ + "title": "...", + "base_url": "...", + "author": "...", + "params": { + "social": [ + { "name": "github", "url": "...", "icon": "icons/github" } + ] + } +} +``` + +Templates access params via dotted keys: `{{#params.social}}`, `{{>* icon}}`. + +Config precedence: `CLI flags > thor.json values > hardcoded defaults`. + ### Markdown pipeline (in content.odin `load_page`) ``` @@ -54,24 +76,12 @@ raw markdown ### Memory management - `Site` owns a `mem.Dynamic_Arena` -- `init_site` calls `mem.dynamic_arena_init` before any allocation +- `init_site` calls `mem.dynamic_arena_init(&site.arena, alignment = 64)` — the 64-byte alignment is required by Odin's map runtime (`MAP_CACHE_LINE_SIZE`) - Config loading (flags + JSON) uses the arena allocator explicitly - `site_allocator(site)` returns the arena allocator for callers - `destroy_site` frees the arena - **Not yet wired:** `context.allocator` is not set to the arena in `main.odin`, so rendering and content processing still use the heap allocator -### Config precedence - -``` -CLI flags > thor.json values > hardcoded defaults -``` - -`init_site` handles this flow: -1. Parse flags into a temp `Site` struct -2. Load `thor.json` (if exists) -3. `site_merge` — CLI overrides config values -4. Hardcoded defaults fill remaining gaps (relative to config file's directory) - ## Building ### Local development @@ -96,23 +106,29 @@ nix build # runs thor + tailwindcss + cp js, outputs to ./result/ ```bash cd thor odin test . # site + mustache smoke tests -odin test . -all-packages # also runs mustache spec tests +odin test mustache # mustache spec + targeted tests (37 total) ``` ## Vendored mustache patches -Two modifications to `mustache/mustache.odin`: +Four modifications to `mustache/mustache.odin`: 1. **`any` unwrapping** in `map_get` and `data_type` — when values come from `map[string]any`, the inner `any` wrapper is unwrapped so type detection works correctly for nested maps and lists. 2. **Layout partials** — `layout_template.partials = tmpl.partials` added so partials (`{{> nav}}`, `{{> footer}}`) work inside the base layout template. +3. **Inline partial rendering** — replaced `template_insert_partial` (which injected tokens into the main token list, breaking section iteration) with `template_render_partial` (which lexes the partial, temporarily swaps `tmpl.lexer`, and recursively processes via `template_process_tokens`). Fixes partials inside sections. + +4. **Dynamic Names** — `{{>*key}}` support. When a partial token value starts with `*`, the remaining key is resolved from the data context stack, and the resolved string is used as the partial name. Enables per-item partial selection inside sections (e.g., `{{>* icon}}` resolves `icon` from each social link). + +Extracted `template_process_tokens` from `template_eat_tokens` to separate ROOT initialization + skip pass from the core token loop, allowing partials to reuse the loop. + ## Known limitations -- Partials inside Mustache sections (`{{#list}}...{{> partial}}...{{/list}}`) produce duplicate items — library token insertion bug. Workaround: inline the markup. - cmark allocates via C malloc, not the arena. HTML output leaks until process exit. - CSS/JS cache busting uses manual `?v=N` query params instead of content hashing. - The `shrug` emoji has a backslash that may not display correctly. +- `json.Value` params require 64-byte aligned arena (workaround for `dynamic_arena_allocator_proc` ignoring per-allocation alignment). ## TODO diff --git a/TODOS.md b/TODOS.md index 1cfea04..13f1a96 100644 --- a/TODOS.md +++ b/TODOS.md @@ -10,10 +10,10 @@ - [x] Nix build integration — main flake runs thor + tailwindcss instead of Hugo - [ ] Fix copy-to-clipboard button x-axis positioning inside code blocks - [ ] Content-hash fingerprinting for CSS and JS cache busting -- [ ] OpenGraph meta tags +- [x] OpenGraph meta tags - [ ] Search up for `thor.json` files. -- [ ] Partials inside sections still produce duplicate items — a fundamental issue with the mustache library's token handling. - [ ] OpenGraph meta tags — verify all fields match production site +- [ ] Nav items should be active when the current page is selected. - [ ] Review every file in thor - [ ] Review alerts.odin - [ ] Review content.odin @@ -21,7 +21,6 @@ - [ ] Review feed.odin - [ ] Review footnotes.odin - [ ] Review frontmatter.odin - - [ ] Review icons.odin - [ ] Review main.odin - [ ] Review main_test.odin - [ ] Review mustache_test.odin diff --git a/icons.odin b/icons.odin deleted file mode 100644 index 0f801c1..0000000 --- a/icons.odin +++ /dev/null @@ -1,11 +0,0 @@ -package main - -ICON_HOME :: `` - -ICON_GITHUB :: `` - -ICON_RSS :: `` - -ICON_CHEVRON_UP :: `` - -ICON_STAR :: `` diff --git a/main.odin b/main.odin index cbfa3c8..069ba5f 100644 --- a/main.odin +++ b/main.odin @@ -5,6 +5,7 @@ import "core:os" main :: proc() { site: Site init_site(&site, os.args) + defer destroy_site(&site) pages := walk_content(site.content_dir, site.drafts) diff --git a/mustache/mustache.odin b/mustache/mustache.odin index 8377596..0e86ccd 100644 --- a/mustache/mustache.odin +++ b/mustache/mustache.odin @@ -964,56 +964,90 @@ token_is_tag :: proc(t: Token) -> bool { return false } -// When a .Partial token is encountered, we need to inject the contents -// of the partial into the current list of tokens. -template_insert_partial :: proc( +template_render_partial :: proc( tmpl: ^Template, token: Token, offset: int, + sb: ^strings.Builder, allocator := context.allocator, -) -> (err: Lexer_Error) { - partial_name := token.value +) { + partial_name := strings.trim_space(token.value) + + // Dynamic Names: {{>*key}} — resolve key from context to get partial name. + if len(partial_name) > 0 && partial_name[0] == '*' { + dynamic_key := strings.trim_space(partial_name[1:]) + resolved := template_get_data_for_stack(tmpl, dynamic_key, allocator) + if resolved == nil || reflect.is_nil(resolved) { + return + } + resolved_str, _ := any_to_string(resolved) + if resolved_str == "" { + return + } + partial_name = strings.trim_space(resolved_str) + } + + // Look up partial content. partial_content := dig(tmpl.partials, []string{partial_name}) partial_str, _ := any_to_string(partial_content) + if partial_str == "" { + return + } - lexer := lexer_make(allocator) - lexer.src = partial_str - lexer.line = token.pos.line - lexer.delim = CORE_DEF - lexer_parse(lexer, allocator = allocator) or_return - - // Performs any indentation on the .Partial that we are inserting. - // - // Example: use the first Token as the indentation for the .Partial Token. - // [Token{type=.Text, value=" "}, Token{type=.Partial, value="to_add"}] - // + // Handle standalone indentation — indent each line of the partial source + // (except the first line, which follows the preceding whitespace, and + // trailing empty lines). standalone := lexer_token_is_standalone_partial(tmpl.lexer, token) - if offset > 0 && standalone { - prev_token := tmpl.lexer.tokens[offset-1] + indent := "" + if standalone && offset > 0 { + prev_token := tmpl.lexer.tokens[offset - 1] if prev_token.type == .Text && is_text_blank(prev_token.value) { - cur_line := lexer.tokens[len(lexer.tokens)-1].pos.line - #reverse for t, i in lexer.tokens { - // Do not indent the top line. - if cur_line == 0 { - break - } - - // When moving back up a line, insert the indentation. - if cur_line != t.pos.line { - inject_at(&lexer.tokens, i+1, prev_token) - } - - cur_line = t.pos.line - } + indent = prev_token.value } } - // Inject tokens from the partial into the primary template. - #reverse for t in lexer.tokens { - inject_at(&tmpl.lexer.tokens, offset+1, t) + if indent != "" { + lines := strings.split(partial_str, "\n", allocator) + defer delete(lines) + + indented := strings.builder_make(allocator) + for line, i in lines { + if i > 0 { + strings.write_string(&indented, "\n") + if i < len(lines) - 1 || line != "" { + strings.write_string(&indented, indent) + } + } + strings.write_string(&indented, line) + } + partial_str = strings.to_string(indented) } - return nil + // Lex the partial (after applying indentation). + partial_lexer := lexer_make(allocator) + partial_lexer.src = partial_str + partial_lexer.line = token.pos.line + partial_lexer.delim = CORE_DEF + err := lexer_parse(partial_lexer, allocator = allocator) + if err != nil { + return + } + + // Apply skip rules to partial tokens. + for &pt in partial_lexer.tokens { + if lexer_token_should_skip(partial_lexer, pt) { + pt.type = .Skip + } + } + + // Swap lexer to process partial tokens with shared context stack. + saved_lexer := tmpl.lexer + tmpl.lexer = partial_lexer + + template_process_tokens(tmpl, sb, allocator) + + // Restore original lexer. + tmpl.lexer = saved_lexer } // Inject a chunk of text into the token list of the larger layout template. @@ -1070,15 +1104,20 @@ template_eat_tokens :: proc( inject_at(&tmpl.context_stack, 0, root) // First pass to find all the whitespace/newline elements that should be skipped. - // This is performed up-front due to partial templates -- we cannot check for the - // whitespace logic *after* the partials have been injected into the template. for &t in tmpl.lexer.tokens { if lexer_token_should_skip(tmpl.lexer, t) { t.type = .Skip } } - // Second pass to render the template. + template_process_tokens(tmpl, sb, allocator) +} + +template_process_tokens :: proc( + tmpl: ^Template, + sb: ^strings.Builder, + allocator := context.allocator, +) { i := 0 for i < len(tmpl.lexer.tokens) { defer { i += 1 } @@ -1101,7 +1140,7 @@ template_eat_tokens :: proc( i = t.start_i } case .Partial: - template_insert_partial(tmpl, t, i, allocator) + template_render_partial(tmpl, t, i, sb, allocator) // Do nothing for these tags. case .Comment, .Skip, .EOF: } diff --git a/mustache/mustache_test.odin b/mustache/mustache_test.odin index 3a8befb..50ae53d 100644 --- a/mustache/mustache_test.odin +++ b/mustache/mustache_test.odin @@ -11,6 +11,7 @@ import "core:testing" COMMENTS_SPEC :: "mustache/spec/specs/comments.json" DELIMITERS_SPEC :: "mustache/spec/specs/delimiters.json" +DYNAMIC_NAMES_SPEC :: "mustache/spec/specs/dynamic-names.json" INTERPOLATION_SPEC :: "mustache/spec/specs/interpolation.json" INVERTED_SPEC :: "mustache/spec/specs/inverted.json" PARTIALS_SPEC :: "mustache/spec/specs/partials.json" @@ -498,6 +499,29 @@ test_partials_spec :: proc(t: ^testing.T) { } } +@(test) +test_dynamic_names_spec :: proc(t: ^testing.T) { + context.allocator = context.temp_allocator + + spec := load_spec(DYNAMIC_NAMES_SPEC) + defer json.destroy_value(spec) + + root := spec.(json.Object) + tests := root["tests"].(json.Array) + + for test in tests { + test_obj := test.(json.Object) + template := test_obj["template"].(string) + exp_output := test_obj["expected"].(string) + data := test_obj["data"] + input := load_json(data) + partials := test_obj["partials"] + partials_input := load_json(partials).(JSON_Map) + + assert_mustache(t, template, input, exp_output, partials_input) + } +} + // TODO: Someday. // @(test) // test_delimiters_spec :: proc(t: ^testing.T) { @@ -958,3 +982,64 @@ test_dig :: proc(t: ^testing.T) { assert(t, reflect.is_nil(output), "Map without a matching field should be nil") delete(keys) } + +@(test) +test_partial_in_section :: proc(t: ^testing.T) { + context.allocator = context.temp_allocator + + template := "{{#items}}{{> item }}{{/items}}" + + data := make(map[string][dynamic]string, 1, context.temp_allocator) + items := make([dynamic]string, 0, context.temp_allocator) + append(&items, "A", "B", "C") + data["items"] = items + + partials := map[string]string{ + "item" = "[{{.}}]", + } + + assert_mustache(t, template, data, "[A][B][C]", partials) +} + +@(test) +test_dynamic_partial_in_section :: proc(t: ^testing.T) { + context.allocator = context.temp_allocator + + template := "{{#items}}{{>*tpl}}{{/items}}" + + items := make([dynamic]Test_Map, 0, context.temp_allocator) + item1 := make(Test_Map) + item1["tpl"] = "a" + item1["v"] = "1" + append(&items, item1) + item2 := make(Test_Map) + item2["tpl"] = "b" + item2["v"] = "2" + append(&items, item2) + + data := make(map[string][dynamic]Test_Map, 1, context.temp_allocator) + data["items"] = items + + partials := map[string]string{ + "a" = "A:{{v}}", + "b" = "B:{{v}}", + } + + assert_mustache(t, template, data, "A:1B:2", partials) +} + +@(test) +test_nested_partials :: proc(t: ^testing.T) { + context.allocator = context.temp_allocator + + template := "{{> outer }}" + data := make(Test_Map) + data["x"] = "hello" + + partials := map[string]string{ + "outer" = "[{{> inner }}]", + "inner" = "{{x}}", + } + + assert_mustache(t, template, data, "[hello]", partials) +} diff --git a/mustache/spec/specs/~dynamic-names.json b/mustache/spec/specs/dynamic-names.json similarity index 100% rename from mustache/spec/specs/~dynamic-names.json rename to mustache/spec/specs/dynamic-names.json diff --git a/render.odin b/render.odin index 6d95836..20691b1 100644 --- a/render.odin +++ b/render.odin @@ -25,7 +25,7 @@ MONTHS: [12]string = { Page_Context :: struct { permalink: string, title: string, - star: string, + starred: bool, has_date: bool, date_iso: string, date_display: string, @@ -42,35 +42,16 @@ Year_Slice :: struct { } build_page_context :: proc(page: Page) -> Page_Context { - star := "" - if page.is_starred { - star = ICON_STAR - } return Page_Context { permalink = page.permalink, title = page.title, - star = star, + starred = page.is_starred, has_date = page.date != "", date_iso = page.date, date_display = format_date(page.date), } } -build_social_context :: proc(config: Site) -> [dynamic]map[string]string { - social_ctx := make([dynamic]map[string]string) - for link in config.social { - append( - &social_ctx, - map[string]string { - "name" = link.name, - "url" = link.url, - "icon" = social_icon(link.name), - }, - ) - } - return social_ctx -} - strip_html_tags :: proc(s: string) -> string { parts: [dynamic]string defer delete(parts) @@ -159,9 +140,6 @@ render_site :: proc(pages: []Page, config: Site) { } render_page_html :: proc(page: Page, config: Site) -> string { - social_ctx := build_social_context(config) - defer delete(social_ctx) - is_article := page.type == .Post data := map[string]any { @@ -172,11 +150,9 @@ render_page_html :: proc(page: Page, config: Site) -> string { "date_iso" = page.date, "date_display" = format_date(page.date), "is_post" = is_article, - "home_icon" = ICON_HOME, - "chevron_up" = ICON_CHEVRON_UP, "year" = "2026", "author" = config.author, - "social" = social_ctx[:], + "params" = config.params, "og_url" = fmt.tprintf("%s%s", config.base_url, page.permalink), "og_site_name" = config.title, "og_title" = strip_html_tags(page.title), @@ -209,28 +185,44 @@ render_page_html :: proc(page: Page, config: Site) -> string { load_partials :: proc(layouts_dir: string) -> map[string]string { partials: map[string]string - - nav, _ := os.read_entire_file_from_path( - fmt.tprintf("%s/partials/nav.html", layouts_dir), - context.allocator, - ) - partials["nav"] = string(nav) - - footer, _ := os.read_entire_file_from_path( - fmt.tprintf("%s/partials/footer.html", layouts_dir), - context.allocator, - ) - partials["footer"] = string(footer) - - head, _ := os.read_entire_file_from_path( - fmt.tprintf("%s/partials/head.html", layouts_dir), - context.allocator, - ) - partials["head"] = string(head) - + partials_dir := fmt.tprintf("%s/partials", layouts_dir) + load_partials_recursive(&partials, partials_dir, "") return partials } +load_partials_recursive :: proc(partials: ^map[string]string, base_dir: string, rel_prefix: string) { + entries, err := os.read_all_directory_by_path(base_dir, context.allocator) + if err != nil { + return + } + defer os.file_info_slice_delete(entries, context.allocator) + + for entry in entries { + #partial switch entry.type { + case .Regular: + name := entry.name + if !strings.has_suffix(name, ".html") { + continue + } + stripped := name[:len(name) - len(".html")] + key := stripped + if rel_prefix != "" { + key = fmt.tprintf("%s/%s", rel_prefix, stripped) + } + data, ok := os.read_entire_file_from_path(entry.fullpath, context.allocator) + if ok == nil { + partials[key] = string(data) + } + case .Directory: + sub_prefix := entry.name + if rel_prefix != "" { + sub_prefix = fmt.tprintf("%s/%s", rel_prefix, entry.name) + } + load_partials_recursive(partials, entry.fullpath, sub_prefix) + } + } +} + render_home_html :: proc(home: Page, pages: []Page, config: Site) -> string { list_pages := make([dynamic]Page_Context) defer delete(list_pages) @@ -241,18 +233,13 @@ render_home_html :: proc(home: Page, pages: []Page, config: Site) -> string { append(&list_pages, build_page_context(page)) } - social_ctx := build_social_context(config) - defer delete(social_ctx) - data := map[string]any { "title" = config.title, "home_body" = home.body_html, "list_pages" = list_pages[:], - "home_icon" = ICON_HOME, - "chevron_up" = ICON_CHEVRON_UP, "year" = "2026", "author" = config.author, - "social" = social_ctx[:], + "params" = config.params, "og_url" = fmt.tprintf("%s/", config.base_url), "og_site_name" = config.title, "og_title" = config.title, @@ -304,17 +291,12 @@ render_posts_html :: proc(pages: []Page, config: Site) -> string { append(&year_slices, Year_Slice{year = section.year, posts = section.posts[:]}) } - social_ctx := build_social_context(config) - defer delete(social_ctx) - data := map[string]any { "title" = fmt.tprintf("Posts | %s", config.title), "year_sections" = year_slices[:], - "home_icon" = ICON_HOME, - "chevron_up" = ICON_CHEVRON_UP, "year" = "2026", "author" = config.author, - "social" = social_ctx[:], + "params" = config.params, "og_url" = fmt.tprintf("%s/posts/", config.base_url), "og_site_name" = config.title, "og_title" = "Posts", @@ -343,16 +325,6 @@ render_posts_html :: proc(pages: []Page, config: Site) -> string { return result } -social_icon :: proc(name: string) -> string { - switch strings.to_lower(name) { - case "github": - return ICON_GITHUB - case "rss": - return ICON_RSS - } - return name -} - format_date :: proc(iso: string) -> string { if len(iso) < 10 { return iso diff --git a/site.odin b/site.odin index 4024ae1..a416761 100644 --- a/site.odin +++ b/site.odin @@ -17,18 +17,13 @@ Site :: struct { output_dir: string `args:"name=output"`, layouts_dir: string, author: string, - social: []Social_Link, + params: json.Value, drafts: bool `args:"name=drafts"`, } -Social_Link :: struct { - name: string, - url: string, -} - init_site :: proc(site: ^Site, args: []string) { _flags: Site - mem.dynamic_arena_init(&site.arena) + mem.dynamic_arena_init(&site.arena, alignment = 64) // FIXME: This is a hack alloc := site_allocator(site) flags.parse_or_exit(&_flags, args, .Odin, alloc) diff --git a/site_test.odin b/site_test.odin index 61c0791..8f4ac20 100644 --- a/site_test.odin +++ b/site_test.odin @@ -2,8 +2,8 @@ #+test package main +import "core:encoding/json" import "core:fmt" -import "core:mem" import "core:os" import "core:testing" @@ -20,7 +20,17 @@ write_temp_config :: proc(name: string, content: string) -> string { test_load_site_config :: proc(t: ^testing.T) { path := write_temp_config( "valid", - `{"title":"Test Site","description":"Test desc","base_url":"https://example.com","author":"Tester","social":[{"name":"github","url":"https://github.com/test"},{"name":"rss","url":"/index.xml"}]}`, + `{ + "title":"Test Site", + "description":"Test desc", + "base_url":"https://example.com", + "author":"Tester", + "params":{ + "social":[ + {"name":"github","url":"https://github.com/test"}, + {"name":"rss","url":"/index.xml"}] + } + }`, ) defer os.remove(path) @@ -32,11 +42,23 @@ test_load_site_config :: proc(t: ^testing.T) { testing.expect_value(t, site.description, "Test desc") testing.expect_value(t, site.base_url, "https://example.com") testing.expect_value(t, site.author, "Tester") - testing.expect_value(t, len(site.social), 2) - testing.expect_value(t, site.social[0].name, "github") - testing.expect_value(t, site.social[0].url, "https://github.com/test") - testing.expect_value(t, site.social[1].name, "rss") - testing.expect_value(t, site.social[1].url, "/index.xml") + + params, has_params := site.params.(json.Object) + testing.expect(t, has_params) + + social_val := params["social"] + social, has_social := social_val.(json.Array) + testing.expect(t, has_social) + testing.expect_value(t, len(social), 2) + + link0, has_link0 := social[0].(json.Object) + testing.expect(t, has_link0) + testing.expect_value(t, link0["name"].(string), "github") + testing.expect_value(t, link0["url"].(string), "https://github.com/test") + + link1, _ := social[1].(json.Object) + testing.expect_value(t, link1["name"].(string), "rss") + testing.expect_value(t, link1["url"].(string), "/index.xml") } @(test) @@ -68,7 +90,7 @@ test_load_site_config_partial :: proc(t: ^testing.T) { testing.expect_value(t, site.title, "Partial") testing.expect_value(t, site.description, "") testing.expect_value(t, site.author, "") - testing.expect_value(t, len(site.social), 0) + testing.expect(t, site.params == nil) } @(test) diff --git a/test_thor_icons.json b/test_thor_icons.json new file mode 100644 index 0000000..14bc0f3 --- /dev/null +++ b/test_thor_icons.json @@ -0,0 +1 @@ +{"params":{"social":[{"name":"github","url":"https://github.com/test"},{"name":"rss","url":"/index.xml"}]}} \ No newline at end of file