diff --git a/AGENTS.md b/AGENTS.md index 1486432..0296e44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,14 +45,14 @@ thor/ | `site.odin` | `Flags` (CLI), `Config_File` (thor.json), `Site_Context` (template-facing: `title`, `description`, `base_url`, `params`, `og`, `menus`), `Site` (runtime state + arena + VFS + pages + `og`). `Feature` enum. 5-step `init_site`. Config menu parsing in `site_apply_config`. | | `content.odin` | `Page` struct (includes `weight`, `menus: map[string]Menu_Entry`, `og`), `Pending_File` struct, `scan_content_files` (section-aware walk that handles leaf bundles), `collect_languages` (pre-scan for code fence languages), `load_page` (falls back to file mtime when no frontmatter date), `infer_layout`. Calls `md.process()` for the markdown pipeline. | | `render.odin` | Template rendering: `render_site`, `render_page_html`, `render_home_html`, `render_section`. `Template_Context` (unified render struct with `site: Site_Context`, `page: Page`, `menus`, `posts`, `pages`). 3-frame context stack via `[]any{ctx.site, ctx.page, ctx}`. `sort_pages` (weight primary, date secondary). `to_title_case` for section display names. VFS-based template loading with fallback chain (`get_template`). | -| `menus.odin` | Menu system: `Menu_Entry {name, url, weight}`, `DEFAULT_WEIGHT = 10`. `build_menus` (priority chain: config → auto + page frontmatter). `collect_auto_menus` (sections + root-level pages). `merge_page_menus` (frontmatter entries with effective weight fallback). `parse_page_menus` (string/array/object forms). `parse_config_menus` (from thor.json). `sort_menu_entries` / `compare_menu_entries` (weight primary, name secondary). | +| `menus.odin` | Menu system: `Menu_Entry {name, url, weight: Maybe(int)}`, `DEFAULT_WEIGHT = 10`. `build_menus` (priority chain: config → auto + page frontmatter, then `warn_all_duplicate_weights`). `collect_auto_menus` (sections + root-level pages, skips pages with explicit `"menus": "main"` frontmatter). `merge_page_menus` (frontmatter entries with effective weight fallback via nil check). `parse_page_menus` (string/array/object forms). `parse_config_menus` (from thor.json). `sort_menu_entries` / `compare_menu_entries` (weight primary via `.? or_else DEFAULT_WEIGHT`, name secondary). `warn_duplicate_weights` / `warn_all_duplicate_weights` (log when two entries in same menu have same explicitly-set weight). | | `minify.odin` | HTML/CSS minification via tree-sitter. Imports `ts "treesitter"`. | | `feed.odin` | RSS feed + sitemap XML. Uses `page.url` for canonical URLs. | | `vfs.odin` | Union file system: `VFS`, `build_vfs`, `mount_dir`, `mount_subdir`, `mount_recursive`, `vfs_get`, `vfs_get_entry`, `vfs_entry_data`. Layers defaults → modules → site. | | `assets.odin` | `copy_assets_dir` — iterates VFS entries with `assets/` prefix, minifies CSS, copies verbatim or via `os.copy_file`. | | `html.odin` | `strip_html_tags`, `unescape_html`, `generate_summary` (word-count truncation, zero-alloc), `generate_description` (HTML→plain text: strip tags, decode entities, collapse whitespace). | | `opengraph.odin` | `Open_Graph` struct (fields ordered per OGP spec, `is_article: Maybe(bool)`). `og_for_site(site)` for site defaults (from config + derived), `og_for_page(site_og, page)` for page-specific (overlay page.og + derive from page data). Description falls back to `generate_description(generate_summary(body_html))`. | -| `frontmatter.odin` | JSON frontmatter parser (`{ }` delimited). Supports `layout`, `lastmod`, `weight`, `menus`, and nested `og` object (via `json_get_open_graph`). Helpers: `json_get_string`, `json_get_bool`, `json_get_int`. | +| `frontmatter.odin` | JSON frontmatter parser (`{ }` delimited). Supports `layout`, `lastmod`, `weight: Maybe(int)`, `menus`, and nested `og` object (via `json_get_open_graph`). Helpers: `json_get_string`, `json_get_bool`, `json_get_int` (returns `Maybe(int)`, nil for absent/invalid). | | `defaults.odin` | `DEFAULTS_PATH` constant, resolved at compile time via `#directory` so bundled templates ship in the binary. | ### Subpackages @@ -77,7 +77,7 @@ Icon SVGs live as HTML partials in `layouts/partials/icons/` (home, github, rss, ``` thor.json → find_config → init_site (5-step) → build_vfs (defaults/layouts → modules → site/layouts, site/assets) - → site_load_content (scan_content_files + collect_languages + preload_grammars + load_page + url computation + build_menus) + → site_load_content (scan_content_files + collect_languages + preload_grammars + load_page + url computation + build_menus + warn_all_duplicate_weights) → render_site → load_partials + get_template (VFS + fallback chain) → render_page_html / render_home_html / render_section (3-frame context stack: site, page, ctx) @@ -98,7 +98,7 @@ Page :: struct { description: string, date: string, year: string, - weight: int, // page ordering (default DEFAULT_WEIGHT = 10) + weight: Maybe(int), // page ordering (nil = unset, defaults to DEFAULT_WEIGHT at comparison time) lastmod: string, menus: map[string]Menu_Entry, // frontmatter menu assignments content: string, // rendered HTML body @@ -131,9 +131,12 @@ Menu system in `menus.odin`. `Menu_Entry :: struct {name: string, url: string, w ### Weight -- `Page.weight` — general page ordering (default `DEFAULT_WEIGHT`). Affects `sort_pages` (weight primary, date secondary). -- Per-menu weight — from object frontmatter form. Falls back to `Page.weight` when `DEFAULT_WEIGHT`. -- `Menu_Entry.weight` — effective weight after fallback. Sorted ascending, name alphabetical for ties. +All weight fields use `Maybe(int)` — nil means "unset," `some(v)` means explicitly set. This distinguishes `"weight": 10` (explicit) from no weight key (defaults to `DEFAULT_WEIGHT` at comparison time via `.? or_else DEFAULT_WEIGHT`). Eliminates the old `0`-as-sentinel pattern from `json_get_int`. + +- `Page.weight: Maybe(int)` — page-level ordering. nil = unset. Affects `sort_pages` (weight primary, date secondary). +- `Menu_Entry.weight: Maybe(int)` — per-menu ordering. nil for auto-generated entries and string/array frontmatter forms. Explicit value from object frontmatter form `{"weight": N}`. +- Effective weight in `merge_page_menus`: per-menu weight if set, else falls back to `page.weight`. Both `Maybe(int)`, so nil propagates naturally — no value-based sentinel check. +- Sorted ascending via `.? or_else DEFAULT_WEIGHT`, name alphabetical for ties. ### Templates @@ -145,6 +148,10 @@ Menu system in `menus.odin`. `Menu_Entry :: struct {name: string, url: string, w `Template_Context.menus` resolves above `Page.menus` (frontmatter assignments) on the 3-frame context stack. Accessible as `{{#menus.main}}` or `{{#site.menus.main}}`. +### Duplicate weight warnings + +`warn_duplicate_weights` (called from `build_menus` after all menus are sorted) logs a warning when two entries in the same menu have the same explicitly-set weight. Only non-nil weights are checked — nil (unset/default) entries are never flagged, so auto-generated entries don't produce noise. The warning includes the menu name, weight value, and both entry names. + ## Config system Config is split into three structs with a clear 5-step initialization flow: @@ -487,6 +494,9 @@ These are things that are easy to get wrong: - `#partial switch` is usually a code smell. prefer a `case all, extra, types:` branch. - you don't usually need to create arena allocators in tests, instead use context.temp_allocator if you want to simplify cleanup. - you don't need to manually set up a tracking allocator in tests. the context.allocator will warn you about leaks. +- **`Maybe(T)` unwrap syntax:** `value.? or_else default`. Not `value or_else default` — `or_else` works on the `?T` returned by `.?`, not on `Maybe(T)` directly. +- **`Maybe(T)` equality:** `a == b` works directly between two `Maybe(T)` values (nil == nil → true, some(5) == some(5) → true, nil == some(5) → false). Also `a == 5` works (int coerces to `Maybe(int)`). +- **File logger in tests:** `log.create_file_logger(&f)` + `context.logger = logger` captures log output. Must be set inline in the test proc (not via a helper proc) for context propagation. Clean up with `log.destroy_file_logger(logger)` then `os.read_entire_file_from_path` to verify output. ## TODO diff --git a/TODOS.md b/TODOS.md index 567f5f3..98b3898 100644 --- a/TODOS.md +++ b/TODOS.md @@ -1,10 +1,10 @@ ## 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. +- [x] Add Weights + - [x] sort by `page.weight` when loading + - [x] re-sort by `page.menu.weight` when building menus. + - [x] warn user when 2 pages with explicit weights match. - [ ] Improve diagnostics - [ ] All Diagnostics should show: - [ ] *What* went wrong @@ -16,6 +16,7 @@ - [ ] show "stack traces" in template error diagnostics - [ ] better diagnostics for syntax errors in treesitter. - [ ] Ensure diagnostics for MAX_CONTEXT_DEPTH are good. + - [ ] improve matching weights message. - [ ] 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'" diff --git a/menus.odin b/menus.odin index 33ba23e..26d9d62 100644 --- a/menus.odin +++ b/menus.odin @@ -35,8 +35,8 @@ parse_page_menus :: proc( case json.String: result = make(map[string]Menu_Entry, allocator) result[string(v)] = Menu_Entry { - name = page.title, - url = page.permalink, + name = page.title, + url = page.permalink, } case json.Array: @@ -143,12 +143,14 @@ build_menus :: proc(site: ^Site) { log.fatalf("menus: cannot mix config menus with frontmatter menus") os.exit(1) } + warn_all_duplicate_weights(site) return } // No config menus — auto-generate, then merge page menus on top collect_auto_menus(site) merge_page_menus(site) + warn_all_duplicate_weights(site) } // merge_page_menus collects frontmatter menu entries from pages and merges @@ -169,11 +171,10 @@ merge_page_menus :: proc(site: ^Site) { if effective == nil { effective = page.weight } - append(&page_entries[menu_name], Menu_Entry{ - name = entry.name, - url = entry.url, - weight = effective, - }) + append( + &page_entries[menu_name], + Menu_Entry{name = entry.name, url = entry.url, weight = effective}, + ) } } @@ -245,10 +246,7 @@ collect_auto_menus :: proc(site: ^Site) { if _, has_main := page.menus["main"]; has_main { continue } - append( - &entries, - Menu_Entry{name = page.title, url = page.permalink, weight = page.weight}, - ) + append(&entries, Menu_Entry{name = page.title, url = page.permalink, weight = page.weight}) } if len(entries) == 0 { @@ -279,6 +277,29 @@ sort_menu_entries :: proc(entries: []Menu_Entry) { } } +// warn_duplicate_weights logs a warning for each pair of adjacent entries +// (pre-sorted) that have the same explicitly-set weight. Entries with nil +// weight (unset/default) are never flagged. +warn_duplicate_weights :: proc(menu_name: string, entries: []Menu_Entry) { + for i in 0 ..< len(entries) - 1 { + if entries[i].weight != nil && entries[i].weight == entries[i + 1].weight { + log.warnf( + "menus('%s'):'%s' and '%s' share the same weight (%d).", + menu_name, + entries[i].name, + entries[i + 1].name, + entries[i].weight, + ) + } + } +} + +warn_all_duplicate_weights :: proc(site: ^Site) { + for menu_name, entries in site.menus { + warn_duplicate_weights(menu_name, entries) + } +} + // parse_config_menus converts raw JSON from thor.json into map[string][]Menu_Entry. // Entries are sorted by weight, then name. parse_config_menus :: proc( @@ -367,3 +388,4 @@ parse_config_menus :: proc( return result } + diff --git a/menus_test.odin b/menus_test.odin index afe620a..1ac1558 100644 --- a/menus_test.odin +++ b/menus_test.odin @@ -4,6 +4,8 @@ package main import "core:encoding/json" import "core:log" import "core:mem" +import "core:os" +import "core:strings" import "core:testing" make_page :: proc(title: string, permalink: string) -> Page { @@ -423,3 +425,96 @@ test_auto_menus_no_duplicate_with_frontmatter :: proc(t: ^testing.T) { testing.expect(t, len(main) == 1, "expected exactly 1 entry (no duplicate)") testing.expect_value(t, main[0].name, "Ideas") } + +// --- warn_duplicate_weights tests --- + +@(test) +test_warn_duplicate_weights_explicit :: proc(t: ^testing.T) { + path := "/tmp/thor_test_warn_explicit.log" + os.remove(path) + f, err := os.open(path, os.O_RDWR | os.O_CREATE | os.O_TRUNC) + if err != nil { + testing.expect(t, false, "failed to open temp log file") + return + } + + logger := log.create_file_logger(f) + context.logger = logger + + entries := []Menu_Entry { + {name = "Alpha", url = "/a/", weight = 5}, + {name = "Beta", url = "/b/", weight = 5}, + } + warn_duplicate_weights("main", entries) + + log.destroy_file_logger(logger) + + data, _ := os.read_entire_file_from_path(path, context.temp_allocator) + output := string(data) + os.remove(path) + + testing.expect(t, strings.contains(output, "duplicate weight 5"), "expected weight in warning") + testing.expect(t, strings.contains(output, "Alpha"), "expected first entry name") + testing.expect(t, strings.contains(output, "Beta"), "expected second entry name") + testing.expect(t, strings.contains(output, "'main'"), "expected menu name in warning") +} + +@(test) +test_warn_duplicate_weights_nil_not_flagged :: proc(t: ^testing.T) { + path := "/tmp/thor_test_warn_nil.log" + os.remove(path) + f, err := os.open(path, os.O_RDWR | os.O_CREATE | os.O_TRUNC) + if err != nil { + testing.expect(t, false, "failed to open temp log file") + return + } + + logger := log.create_file_logger(f) + context.logger = logger + + entries := []Menu_Entry { + {name = "Alpha", url = "/a/"}, + {name = "Beta", url = "/b/"}, + } + warn_duplicate_weights("main", entries) + + log.destroy_file_logger(logger) + + data, _ := os.read_entire_file_from_path(path, context.temp_allocator) + output := string(data) + os.remove(path) + + testing.expect(t, output == "", "nil-weight entries should not produce warnings") +} + +@(test) +test_warn_duplicate_weights_explicit_default :: proc(t: ^testing.T) { + path := "/tmp/thor_test_warn_default.log" + os.remove(path) + f, err := os.open(path, os.O_RDWR | os.O_CREATE | os.O_TRUNC) + if err != nil { + testing.expect(t, false, "failed to open temp log file") + return + } + + logger := log.create_file_logger(f) + context.logger = logger + + entries := []Menu_Entry { + {name = "Alpha", url = "/a/", weight = 10}, + {name = "Beta", url = "/b/", weight = 10}, + } + warn_duplicate_weights("main", entries) + + log.destroy_file_logger(logger) + + data, _ := os.read_entire_file_from_path(path, context.temp_allocator) + output := string(data) + os.remove(path) + + testing.expect( + t, + strings.contains(output, "duplicate weight 10"), + "explicit weight 10 (== DEFAULT_WEIGHT) should warn — this is the Maybe(int) win", + ) +}