From a420803b3e7ae55693d58f653c234b74b7ede24e Mon Sep 17 00:00:00 2001 From: Spencer Brower <6729162+sbrow@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:52:15 -0400 Subject: [PATCH] feat: Added `weight` field to Page. --- AGENTS.md | 2 + content.odin | 2 + frontmatter.odin | 2 + menus.odin | 24 +++++++-- menus_test.odin | 135 +++++++++++++++++++++++++++++++++++++++++++++++ render.odin | 17 ++++-- 6 files changed, 176 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a3ed401..1486432 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -485,6 +485,8 @@ These are things that are easy to get wrong: - **`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. - `#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. ## TODO diff --git a/content.odin b/content.odin index dff0628..3550d5f 100644 --- a/content.odin +++ b/content.odin @@ -20,6 +20,7 @@ Page :: struct { description: string, date: string, year: string, + weight: int, lastmod: string, menus: map[string]Menu_Entry, content: string, @@ -253,6 +254,7 @@ load_page :: proc( } } page.year = get_year(page.date) + page.weight = fm.weight if fm.weight != 0 else DEFAULT_WEIGHT page.lastmod = fm.lastmod page.draft = fm.draft page.starred = fm.isStarred diff --git a/frontmatter.odin b/frontmatter.odin index f78b84f..25eef77 100644 --- a/frontmatter.odin +++ b/frontmatter.odin @@ -10,6 +10,7 @@ Frontmatter :: struct { date: string, lastmod: string, publishDate: string, + weight: int, menus: json.Value, layout: string, og: Open_Graph, @@ -53,6 +54,7 @@ parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok fm.date = json_get_string(obj, "date") fm.lastmod = json_get_string(obj, "lastmod") fm.publishDate = json_get_string(obj, "publishDate") + fm.weight = json_get_int(obj, "weight") fm.draft = json_get_bool(obj, "draft") fm.isStarred = json_get_bool(obj, "isStarred") if v, ok := obj["menus"]; ok { diff --git a/menus.odin b/menus.odin index 38b90c6..812194b 100644 --- a/menus.odin +++ b/menus.odin @@ -166,7 +166,16 @@ merge_page_menus :: proc(site: ^Site) { if _, ok := page_entries[menu_name]; !ok { page_entries[menu_name] = make([dynamic]Menu_Entry, 0, 4, alloc) } - append(&page_entries[menu_name], entry) + // Effective weight: per-menu weight if explicit, else page.weight + effective := entry.weight + if effective == DEFAULT_WEIGHT { + effective = page.weight + } + append(&page_entries[menu_name], Menu_Entry{ + name = entry.name, + url = entry.url, + weight = effective, + }) } } @@ -212,16 +221,22 @@ collect_auto_menus :: proc(site: ^Site) { for section in sections { name := to_title_case(section, alloc) url := fmt.aprintf("/%s/", section, allocator = alloc) + skip := false for page in site.pages { if page.section == section && page._is_index { url = page.permalink if page.title != "" { name = page.title } + if _, has_main := page.menus["main"]; has_main { + skip = true + } break } } - append(&entries, Menu_Entry{name = name, url = url, weight = DEFAULT_WEIGHT}) + if !skip { + append(&entries, Menu_Entry{name = name, url = url, weight = DEFAULT_WEIGHT}) + } } // Root-level page entries (section = "", not index) @@ -229,9 +244,12 @@ collect_auto_menus :: proc(site: ^Site) { if page._is_index || page.section != "" || page.title == "" { continue } + if _, has_main := page.menus["main"]; has_main { + continue + } append( &entries, - Menu_Entry{name = page.title, url = page.permalink, weight = DEFAULT_WEIGHT}, + Menu_Entry{name = page.title, url = page.permalink, weight = page.weight}, ) } diff --git a/menus_test.odin b/menus_test.odin index b08428b..fbd7937 100644 --- a/menus_test.odin +++ b/menus_test.odin @@ -272,3 +272,138 @@ test_config_weight_parsing_and_sort :: proc(t: ^testing.T) { testing.expect_value(t, main[2].name, "Heavy") testing.expect_value(t, main[2].weight, 20) } + +// --- json_get_int tests --- + +@(test) +test_json_get_int_integer :: proc(t: ^testing.T) { + obj, _ := json.parse_string(`{"weight": 5}`, spec = .JSON) + defer json.destroy_value(obj) + o, _ := obj.(json.Object) + testing.expect_value(t, json_get_int(o, "weight"), 5) +} + +@(test) +test_json_get_int_float :: proc(t: ^testing.T) { + obj, _ := json.parse_string(`{"weight": 5.0}`, spec = .JSON) + defer json.destroy_value(obj) + o, _ := obj.(json.Object) + testing.expect_value(t, json_get_int(o, "weight"), 5) +} + +@(test) +test_json_get_int_missing :: proc(t: ^testing.T) { + obj, _ := json.parse_string(`{}`, spec = .JSON) + defer json.destroy_value(obj) + o, _ := obj.(json.Object) + testing.expect_value(t, json_get_int(o, "weight"), 0) +} + +@(test) +test_json_get_int_non_numeric :: proc(t: ^testing.T) { + obj, _ := json.parse_string(`{"weight": "5"}`, spec = .JSON) + defer json.destroy_value(obj) + o, _ := obj.(json.Object) + testing.expect_value(t, json_get_int(o, "weight"), 0) +} + +// --- sort_pages tests --- + +@(test) +test_sort_pages_weight_primary :: proc(t: ^testing.T) { + pages := make(#soa[dynamic]Page, 0, 3) + defer delete(pages) + append(&pages, Page{title = "Gamma", date = "2025-01-03", weight = DEFAULT_WEIGHT}) + append(&pages, Page{title = "Alpha", date = "2025-01-01", weight = 5}) + append(&pages, Page{title = "Beta", date = "2025-01-02", weight = 1}) + + sort_pages(pages[:]) + + // weight 1, weight 5, then default weight 10 + testing.expect_value(t, pages.title[0], "Beta") + testing.expect_value(t, pages.title[1], "Alpha") + testing.expect_value(t, pages.title[2], "Gamma") +} + +@(test) +test_sort_pages_equal_weights_by_date :: proc(t: ^testing.T) { + pages := make(#soa[dynamic]Page, 0, 3) + defer delete(pages) + append(&pages, Page{title = "Old", date = "2025-01-01", weight = DEFAULT_WEIGHT}) + append(&pages, Page{title = "New", date = "2025-06-01", weight = DEFAULT_WEIGHT}) + append(&pages, Page{title = "Mid", date = "2025-03-01", weight = DEFAULT_WEIGHT}) + + sort_pages(pages[:]) + + // All same weight → date descending + testing.expect_value(t, pages.title[0], "New") + testing.expect_value(t, pages.title[1], "Mid") + testing.expect_value(t, pages.title[2], "Old") +} + +@(test) +test_sort_pages_mixed :: proc(t: ^testing.T) { + pages := make(#soa[dynamic]Page, 0, 4) + defer delete(pages) + append(&pages, Page{title = "DefaultOld", date = "2025-01-01", weight = DEFAULT_WEIGHT}) + append(&pages, Page{title = "DefaultNew", date = "2025-06-01", weight = DEFAULT_WEIGHT}) + append(&pages, Page{title = "Heavy", date = "2025-03-01", weight = 20}) + append(&pages, Page{title = "Light", date = "2025-02-01", weight = 1}) + + sort_pages(pages[:]) + + // weight 1, weight 10 (DefaultNew by date), weight 10 (DefaultOld by date), weight 20 + testing.expect_value(t, pages.title[0], "Light") + testing.expect_value(t, pages.title[1], "DefaultNew") + testing.expect_value(t, pages.title[2], "DefaultOld") + testing.expect_value(t, pages.title[3], "Heavy") +} + +// --- merge_page_menus effective weight tests --- + +@(test) +test_merge_page_menus_weight_fallback :: proc(t: ^testing.T) { + site: Site + mem.dynamic_arena_init(&site.arena) + defer mem.dynamic_arena_destroy(&site.arena) + context.allocator = site_allocator(&site) + + page := make_page("Test", "/test/") + page.weight = 3 + page.menus = parse_page_menus(parse_raw(`"main"`), page, site_allocator(&site)) + + site.pages = make(#soa[dynamic]Page, 0, 1, site_allocator(&site)) + append(&site.pages, page) + + // Don't call collect_auto_menus — test merge_page_menus in isolation + merge_page_menus(&site) + + main, ok := site.menus["main"] + testing.expect(t, ok) + testing.expect(t, len(main) == 1, "expected exactly 1 entry") + testing.expect_value(t, main[0].name, "Test") + testing.expect_value(t, main[0].weight, 3) +} + +@(test) +test_auto_menus_no_duplicate_with_frontmatter :: proc(t: ^testing.T) { + site: Site + mem.dynamic_arena_init(&site.arena) + defer mem.dynamic_arena_destroy(&site.arena) + context.allocator = site_allocator(&site) + + // Root-level page with explicit "menus": "main" + page := make_page("Ideas", "/ideas/") + page.menus = parse_page_menus(parse_raw(`"main"`), page, site_allocator(&site)) + + site.pages = make(#soa[dynamic]Page, 0, 1, site_allocator(&site)) + append(&site.pages, page) + + collect_auto_menus(&site) + merge_page_menus(&site) + + main, ok := site.menus["main"] + testing.expect(t, ok) + testing.expect(t, len(main) == 1, "expected exactly 1 entry (no duplicate)") + testing.expect_value(t, main[0].name, "Ideas") +} diff --git a/render.odin b/render.odin index b87c125..295672e 100644 --- a/render.odin +++ b/render.odin @@ -136,7 +136,7 @@ render_template :: proc( render_site :: proc(site: ^Site) { allocator := site_allocator(site) pages := site.pages[:] - sort_pages_by_date(pages) + sort_pages(pages) // Load shared resources partials := load_partials(&site.vfs) @@ -373,11 +373,12 @@ get_year :: proc(iso: string) -> string { return iso[:4] } -sort_pages_by_date :: proc(pages: #soa[]Page) { +// Weight primary (ascending). Date secondary (descending) for equal weights. +sort_pages :: proc(pages: #soa[]Page) { for i in 1 ..< len(pages) { key := pages[i] j := i - 1 - for j >= 0 && pages.date[j] < key.date { + for j >= 0 && compare_pages(pages, j, key) > 0 { pages[j + 1] = pages[j] j -= 1 } @@ -385,6 +386,16 @@ sort_pages_by_date :: proc(pages: #soa[]Page) { } } +compare_pages :: proc(pages: #soa[]Page, j: int, key: Page) -> int { + wj := pages.weight[j] + wk := key.weight + if wj != wk do return wj - wk + // Equal weight → date descending + if pages.date[j] < key.date do return 1 + if pages.date[j] > key.date do return -1 + return 0 +} + write_page :: proc(output_dir: string, permalink: string, html: string) { rel := permalink if len(rel) > 0 && rel[0] == '/' {