diff --git a/TODOS.md b/TODOS.md index 21b34b8..567f5f3 100644 --- a/TODOS.md +++ b/TODOS.md @@ -22,7 +22,7 @@ - [ ] Test menu diagnostics - [ ] Honestly, Test **all** diagnostics - [ ] Need to be careful about diagnostics across module boundaries. - - we don't necessarilly want to warn users about theme designers mistakes. (though perhaps we do) + - we don't necessarily want to warn users about theme designers mistakes. (though perhaps we do) - [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 @@ -39,7 +39,7 @@ - i.e. force the user to choose one or the other. - [ ] Don't show annoying log output in tests. - [ ] improve home link customization. - - [ ] currently an accessability issue. + - [ ] currently an accessibility issue. - [ ] support JSON5 in in frontmatter - [ ] Create a json schema file for `thor.json`. - [ ] cleanup `#partial switch`es. diff --git a/menus.odin b/menus.odin index fbf625e..38b90c6 100644 --- a/menus.odin +++ b/menus.odin @@ -7,6 +7,8 @@ import "core:mem" import "core:os" import "core:strings" +DEFAULT_WEIGHT :: 10 + Menu_Entry :: struct { name: string, url: string, @@ -33,8 +35,9 @@ 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, + weight = DEFAULT_WEIGHT, } case json.Array: @@ -42,8 +45,9 @@ parse_page_menus :: proc( for item in v { if s, ok := item.(json.String); ok { result[string(s)] = Menu_Entry { - name = page.title, - url = page.permalink, + name = page.title, + url = page.permalink, + weight = DEFAULT_WEIGHT, } } else { log.warnf("menus: ignoring non-string item in menus array: %v", item) @@ -53,7 +57,7 @@ parse_page_menus :: proc( case json.Object: result = make(map[string]Menu_Entry, allocator) for menu_name, entry_val in v { - weight := 0 + weight := DEFAULT_WEIGHT if entry_obj, ok := entry_val.(json.Object); ok { if w, ok := entry_obj["weight"]; ok { #partial switch wval in w { @@ -217,7 +221,7 @@ collect_auto_menus :: proc(site: ^Site) { break } } - append(&entries, Menu_Entry{name = name, url = url}) + append(&entries, Menu_Entry{name = name, url = url, weight = DEFAULT_WEIGHT}) } // Root-level page entries (section = "", not index) @@ -225,7 +229,10 @@ collect_auto_menus :: proc(site: ^Site) { if page._is_index || page.section != "" || page.title == "" { continue } - append(&entries, Menu_Entry{name = page.title, url = page.permalink}) + append( + &entries, + Menu_Entry{name = page.title, url = page.permalink, weight = DEFAULT_WEIGHT}, + ) } if len(entries) == 0 { @@ -237,11 +244,16 @@ collect_auto_menus :: proc(site: ^Site) { site.menus["main"] = entries[:] } +compare_menu_entries :: proc(a, b: Menu_Entry) -> int { + if a.weight != b.weight do return a.weight - b.weight + return strings.compare(a.name, b.name) +} + 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 { + for j >= 0 && compare_menu_entries(entries[j], key) > 0 { entries[j + 1] = entries[j] j -= 1 } @@ -250,7 +262,7 @@ sort_menu_entries :: proc(entries: []Menu_Entry) { } // parse_config_menus converts raw JSON from thor.json into map[string][]Menu_Entry. -// Preserves array order as-declared. +// Entries are sorted by weight, then name. parse_config_menus :: proc( raw: json.Value, allocator := context.allocator, @@ -278,6 +290,7 @@ parse_config_menus :: proc( name := "" url := "" + weight := DEFAULT_WEIGHT if v, ok := entry_obj["name"]; ok { if s, ok2 := v.(json.String); ok2 { @@ -307,16 +320,32 @@ parse_config_menus :: proc( } } + if v, ok := entry_obj["weight"]; ok { + switch wval in v { + case json.Integer: + weight = int(wval) + case json.Float: + weight = int(wval) + case json.Null, json.Boolean, json.String, json.Array, json.Object: + log.warnf( + "menus: '%s' entry %d: 'weight' must be a number, got %v", + menu_name, + idx, + v, + ) + } + } + if name == "" { log.warnf("menus: '%s' entry %d missing 'name', skipping", menu_name, idx) continue } - append(&entries, Menu_Entry{name = name, url = url}) + append(&entries, Menu_Entry{name = name, url = url, weight = weight}) } + sort_menu_entries(entries[:]) result[menu_name] = entries[:] } return result } - diff --git a/menus_test.odin b/menus_test.odin index 6c077c6..b08428b 100644 --- a/menus_test.odin +++ b/menus_test.odin @@ -16,7 +16,6 @@ parse_raw :: proc(s: string) -> json.Value { } - @(test) test_menus_string_form :: proc(t: ^testing.T) { arena: mem.Dynamic_Arena @@ -31,7 +30,7 @@ test_menus_string_form :: proc(t: ^testing.T) { testing.expect(t, ok, "expected 'main' menu") testing.expect_value(t, entry.name, "About") testing.expect_value(t, entry.url, "/about/") - testing.expect_value(t, entry.weight, 0) + testing.expect_value(t, entry.weight, DEFAULT_WEIGHT) } @(test) @@ -82,7 +81,7 @@ test_menus_object_no_weight :: proc(t: ^testing.T) { testing.expect(t, len(menus) == 1) entry, ok := menus["main"] testing.expect(t, ok) - testing.expect_value(t, entry.weight, 0) + testing.expect_value(t, entry.weight, DEFAULT_WEIGHT) } @(test) @@ -140,7 +139,7 @@ test_menus_object_non_object_value :: proc(t: ^testing.T) { testing.expect(t, len(menus) == 1, "entry created with defaults") entry, ok := menus["main"] testing.expect(t, ok) - testing.expect_value(t, entry.weight, 0) + testing.expect_value(t, entry.weight, DEFAULT_WEIGHT) } @(test) @@ -155,7 +154,7 @@ test_menus_non_numeric_weight :: proc(t: ^testing.T) { menus := parse_page_menus(parse_raw(`{"main": {"weight": "30"}}`), page, context.allocator) entry, ok := menus["main"] testing.expect(t, ok) - testing.expect_value(t, entry.weight, 0) + testing.expect_value(t, entry.weight, DEFAULT_WEIGHT) } @(test) @@ -199,3 +198,77 @@ test_menus_null_json :: proc(t: ^testing.T) { menus := parse_page_menus(parse_raw(`null`), page, context.allocator) testing.expect(t, menus == nil, "null JSON should return nil") } + +// --- sort_menu_entries tests --- + +@(test) +test_sort_weight_orders_correctly :: proc(t: ^testing.T) { + entries := []Menu_Entry { + {name = "Zeta", url = "/z/", weight = DEFAULT_WEIGHT}, + {name = "Alpha", url = "/a/", weight = 5}, + {name = "Beta", url = "/b/", weight = 1}, + } + sort_menu_entries(entries) + // weight 1 first, then weight 5, then default weight 10 + testing.expect_value(t, entries[0].name, "Beta") + testing.expect_value(t, entries[1].name, "Alpha") + testing.expect_value(t, entries[2].name, "Zeta") +} + +@(test) +test_sort_equal_weights_alphabetical :: proc(t: ^testing.T) { + entries := []Menu_Entry { + {name = "Zebra", url = "/z/", weight = DEFAULT_WEIGHT}, + {name = "Apple", url = "/a/", weight = DEFAULT_WEIGHT}, + {name = "Mango", url = "/m/", weight = DEFAULT_WEIGHT}, + } + sort_menu_entries(entries) + testing.expect_value(t, entries[0].name, "Apple") + testing.expect_value(t, entries[1].name, "Mango") + testing.expect_value(t, entries[2].name, "Zebra") +} + +@(test) +test_sort_mixed_weights :: proc(t: ^testing.T) { + entries := []Menu_Entry { + {name = "Charlie", url = "/c/", weight = DEFAULT_WEIGHT}, + {name = "Alpha", url = "/a/", weight = DEFAULT_WEIGHT}, + {name = "Bravo", url = "/b/", weight = 3}, + {name = "Delta", url = "/d/", weight = 1}, + } + sort_menu_entries(entries) + // weight 1 (Delta), weight 3 (Bravo), then default weight alphabetical (Alpha, Charlie) + testing.expect_value(t, entries[0].name, "Delta") + testing.expect_value(t, entries[1].name, "Bravo") + testing.expect_value(t, entries[2].name, "Alpha") + testing.expect_value(t, entries[3].name, "Charlie") +} + +@(test) +test_config_weight_parsing_and_sort :: proc(t: ^testing.T) { + arena: mem.Dynamic_Arena + mem.dynamic_arena_init(&arena) + defer mem.dynamic_arena_destroy(&arena) + context.allocator = mem.dynamic_arena_allocator(&arena) + + raw := parse_raw( + `{ + "main": [ + {"name": "Heavy", "url": "/h/", "weight": 20}, + {"name": "Light", "url": "/l/", "weight": 1}, + {"name": "Default", "url": "/d/"} + ] + }`, + ) + + menus := parse_config_menus(raw, context.allocator) + main, ok := menus["main"] + testing.expect(t, ok) + testing.expect(t, len(main) == 3) + testing.expect_value(t, main[0].name, "Light") + testing.expect_value(t, main[0].weight, 1) + testing.expect_value(t, main[1].name, "Default") + testing.expect_value(t, main[1].weight, DEFAULT_WEIGHT) + testing.expect_value(t, main[2].name, "Heavy") + testing.expect_value(t, main[2].weight, 20) +}