From cdb4ad9329c6dfc32f410e7e71f2cfd70db7311c Mon Sep 17 00:00:00 2001
From: Spencer Brower <6729162+sbrow@users.noreply.github.com>
Date: Mon, 13 Jul 2026 10:37:03 -0400
Subject: [PATCH] feat: Icons are now loaded from templates (no longer
baked-in).
---
AGENTS.md | 56 ++++++---
TODOS.md | 5 +-
icons.odin | 11 --
main.odin | 1 +
mustache/mustache.odin | 119 ++++++++++++------
mustache/mustache_test.odin | 85 +++++++++++++
...~dynamic-names.json => dynamic-names.json} | 0
render.odin | 108 ++++++----------
site.odin | 9 +-
site_test.odin | 38 ++++--
test_thor_icons.json | 1 +
11 files changed, 276 insertions(+), 157 deletions(-)
delete mode 100644 icons.odin
rename mustache/spec/specs/{~dynamic-names.json => dynamic-names.json} (100%)
create mode 100644 test_thor_icons.json
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