feat: Menu items get sorted by weight.

This commit is contained in:
Spencer Brower
2026-07-29 12:38:12 -04:00
parent fa8179c9b9
commit 040c462bba
3 changed files with 120 additions and 18 deletions
+2 -2
View File
@@ -22,7 +22,7 @@
- [ ] Test menu diagnostics - [ ] Test menu diagnostics
- [ ] Honestly, Test **all** diagnostics - [ ] Honestly, Test **all** diagnostics
- [ ] Need to be careful about diagnostics across module boundaries. - [ ] 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] Simplify / unify template context stack. Come up with a name for it.
- [x] `render_template` should accept `Template_Context`, not `any` - [x] `render_template` should accept `Template_Context`, not `any`
- [ ] Load grammars dynamically - [ ] Load grammars dynamically
@@ -39,7 +39,7 @@
- i.e. force the user to choose one or the other. - i.e. force the user to choose one or the other.
- [ ] Don't show annoying log output in tests. - [ ] Don't show annoying log output in tests.
- [ ] improve home link customization. - [ ] improve home link customization.
- [ ] currently an accessability issue. - [ ] currently an accessibility issue.
- [ ] support JSON5 in in frontmatter - [ ] support JSON5 in in frontmatter
- [ ] Create a json schema file for `thor.json`. - [ ] Create a json schema file for `thor.json`.
- [ ] cleanup `#partial switch`es. - [ ] cleanup `#partial switch`es.
+40 -11
View File
@@ -7,6 +7,8 @@ import "core:mem"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
DEFAULT_WEIGHT :: 10
Menu_Entry :: struct { Menu_Entry :: struct {
name: string, name: string,
url: string, url: string,
@@ -33,8 +35,9 @@ parse_page_menus :: proc(
case json.String: case json.String:
result = make(map[string]Menu_Entry, allocator) result = make(map[string]Menu_Entry, allocator)
result[string(v)] = Menu_Entry { result[string(v)] = Menu_Entry {
name = page.title, name = page.title,
url = page.permalink, url = page.permalink,
weight = DEFAULT_WEIGHT,
} }
case json.Array: case json.Array:
@@ -42,8 +45,9 @@ parse_page_menus :: proc(
for item in v { for item in v {
if s, ok := item.(json.String); ok { if s, ok := item.(json.String); ok {
result[string(s)] = Menu_Entry { result[string(s)] = Menu_Entry {
name = page.title, name = page.title,
url = page.permalink, url = page.permalink,
weight = DEFAULT_WEIGHT,
} }
} else { } else {
log.warnf("menus: ignoring non-string item in menus array: %v", item) log.warnf("menus: ignoring non-string item in menus array: %v", item)
@@ -53,7 +57,7 @@ parse_page_menus :: proc(
case json.Object: case json.Object:
result = make(map[string]Menu_Entry, allocator) result = make(map[string]Menu_Entry, allocator)
for menu_name, entry_val in v { for menu_name, entry_val in v {
weight := 0 weight := DEFAULT_WEIGHT
if entry_obj, ok := entry_val.(json.Object); ok { if entry_obj, ok := entry_val.(json.Object); ok {
if w, ok := entry_obj["weight"]; ok { if w, ok := entry_obj["weight"]; ok {
#partial switch wval in w { #partial switch wval in w {
@@ -217,7 +221,7 @@ collect_auto_menus :: proc(site: ^Site) {
break 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) // 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 == "" { if page._is_index || page.section != "" || page.title == "" {
continue 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 { if len(entries) == 0 {
@@ -237,11 +244,16 @@ collect_auto_menus :: proc(site: ^Site) {
site.menus["main"] = entries[:] 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) { sort_menu_entries :: proc(entries: []Menu_Entry) {
for i in 1 ..< len(entries) { for i in 1 ..< len(entries) {
key := entries[i] key := entries[i]
j := i - 1 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] entries[j + 1] = entries[j]
j -= 1 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. // 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( parse_config_menus :: proc(
raw: json.Value, raw: json.Value,
allocator := context.allocator, allocator := context.allocator,
@@ -278,6 +290,7 @@ parse_config_menus :: proc(
name := "" name := ""
url := "" url := ""
weight := DEFAULT_WEIGHT
if v, ok := entry_obj["name"]; ok { if v, ok := entry_obj["name"]; ok {
if s, ok2 := v.(json.String); ok2 { 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 == "" { if name == "" {
log.warnf("menus: '%s' entry %d missing 'name', skipping", menu_name, idx) log.warnf("menus: '%s' entry %d missing 'name', skipping", menu_name, idx)
continue 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[:] result[menu_name] = entries[:]
} }
return result return result
} }
+78 -5
View File
@@ -16,7 +16,6 @@ parse_raw :: proc(s: string) -> json.Value {
} }
@(test) @(test)
test_menus_string_form :: proc(t: ^testing.T) { test_menus_string_form :: proc(t: ^testing.T) {
arena: mem.Dynamic_Arena 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(t, ok, "expected 'main' menu")
testing.expect_value(t, entry.name, "About") testing.expect_value(t, entry.name, "About")
testing.expect_value(t, entry.url, "/about/") testing.expect_value(t, entry.url, "/about/")
testing.expect_value(t, entry.weight, 0) testing.expect_value(t, entry.weight, DEFAULT_WEIGHT)
} }
@(test) @(test)
@@ -82,7 +81,7 @@ test_menus_object_no_weight :: proc(t: ^testing.T) {
testing.expect(t, len(menus) == 1) testing.expect(t, len(menus) == 1)
entry, ok := menus["main"] entry, ok := menus["main"]
testing.expect(t, ok) testing.expect(t, ok)
testing.expect_value(t, entry.weight, 0) testing.expect_value(t, entry.weight, DEFAULT_WEIGHT)
} }
@(test) @(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") testing.expect(t, len(menus) == 1, "entry created with defaults")
entry, ok := menus["main"] entry, ok := menus["main"]
testing.expect(t, ok) testing.expect(t, ok)
testing.expect_value(t, entry.weight, 0) testing.expect_value(t, entry.weight, DEFAULT_WEIGHT)
} }
@(test) @(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) menus := parse_page_menus(parse_raw(`{"main": {"weight": "30"}}`), page, context.allocator)
entry, ok := menus["main"] entry, ok := menus["main"]
testing.expect(t, ok) testing.expect(t, ok)
testing.expect_value(t, entry.weight, 0) testing.expect_value(t, entry.weight, DEFAULT_WEIGHT)
} }
@(test) @(test)
@@ -199,3 +198,77 @@ test_menus_null_json :: proc(t: ^testing.T) {
menus := parse_page_menus(parse_raw(`null`), page, context.allocator) menus := parse_page_menus(parse_raw(`null`), page, context.allocator)
testing.expect(t, menus == nil, "null JSON should return nil") 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)
}