diff --git a/TODOS.md b/TODOS.md index 1bec42e..d8c338e 100644 --- a/TODOS.md +++ b/TODOS.md @@ -8,6 +8,7 @@ - [ ] 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'" + - [ ] Test menu diagnostics - [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 @@ -17,10 +18,13 @@ - [x] Add a `#config(MAX_CONTEXT_DEPTH, 16?)` to `mustache`. - [ ] Documentation - [ ] talk about the context stack (and its limit). + - [ ] highlight the differences in the way menus are handled. +- [ ] consider sites with data based urls. - [ ] menu system - [ ] like Hugo's, but warn(/fail?) if menus are defined in the config *and* pages. - i.e. force the user to choose one or the other. - [ ] Don't show annoying log output in tests. +- [ ] improve home link customization. ## Performance @@ -44,6 +48,8 @@ - `await` the highlighted code. - [ ] can markdown extensions run in parallel? - [ ] enforce MAX_SLUG_LENGTH +- [ ] enforce MAX_CONTEXT_DEPTH +- [ ] ensure struct fields are ordered correctly ## Remove Privileged content @@ -78,6 +84,7 @@ - [ ] Do we *need* mustache.Date_Components, or can we use core:time/datetime.DateTime? ## General +- [ ] configure opt-out of automatic sections being added to menu. - [ ] get rid of the global variables in the `treesitter` package. - [ ] Consider using `or_else` when applying default values to structs. i.e. ```odin @@ -96,6 +103,8 @@ main :: proc () { - [ ] Integrity hash - Allows users to verify their output didn't change after upgrading to a new version - [ ] Content-hash fingerprinting for CSS and JS cache busting +- [ ] come up with scrapers / scrape sources to harvest site data + - we'll use this to help us sculpt defaults. - [ ] merge `render_{section,home_html,page_html}` procs. - [ ] try to combine render_page_html and render_home_html? - [ ] Debug log stats. (analytics) @@ -116,6 +125,10 @@ main :: proc () { - [ ] running ./thor/thor still logs the debug message: using config /home/spencer/github.com/sbrow.github.io/thor.json - wrong cwd? - [ ] Clean up the default layouts +- [ ] Menus + - [ ] Detailed frontmatter menu form ("menu": {"main": {"weight": 5}}) + - [ ] Menu active state (pre-compute is_active based on page.permalink prefix match) + - [ ] Page.weight field for general-purpose page ordering (menus, lists, related posts) - [ ] if no `html` tag detected in output, re-render output with base template (or whatever template is next in the chain) - [ ] Add `-production` flag diff --git a/content.odin b/content.odin index 220c6f9..635ce18 100644 --- a/content.odin +++ b/content.odin @@ -69,6 +69,8 @@ site_load_content :: proc(site: ^Site) { for &page in site.pages { page.url = fmt.tprintf("%s%s", site.base_url, page.permalink) } + + build_menus(site) } // scan_content_files walks the content directory and collects Pending_File diff --git a/defaults/layouts/partials/home-link.html b/defaults/layouts/partials/home-link.html new file mode 100644 index 0000000..ac718fd --- /dev/null +++ b/defaults/layouts/partials/home-link.html @@ -0,0 +1 @@ +{{site.title}} diff --git a/defaults/layouts/partials/nav.html b/defaults/layouts/partials/nav.html index 51e9475..625642a 100644 --- a/defaults/layouts/partials/nav.html +++ b/defaults/layouts/partials/nav.html @@ -1,7 +1,10 @@
diff --git a/menus.odin b/menus.odin new file mode 100644 index 0000000..6356797 --- /dev/null +++ b/menus.odin @@ -0,0 +1,224 @@ +package main + +import "core:encoding/json" +import "core:fmt" +import "core:log" +import "core:os" +import "core:strings" + +Menu_Entry :: struct { + name: string, + url: string, +} + +// build_menus populates site.menus: +// 1. Config menus (thor.json "menus" key present) — exclusive, preserves array order +// 2. Auto-menus (sections + root-level pages) + page frontmatter menus — merged, sorted +// +// If "menus" is present but empty ({}) it means explicit opt-out: no menus. +// Config menus cannot be mixed with page frontmatter menus (error). +build_menus :: proc(site: ^Site) { + if site.menus != nil { + has_menus := false + for page in site.pages { + if page.menu != "" { + has_menus = true + break + } + } + + // Already populated from config in site_apply_config + if len(site.menus) == 0 { + // Explicit opt-out ("menus": {}) + if has_menus { + log.warnf( + "menus: config has empty menus but pages have frontmatter menu entries; ignoring page menus", + ) + } + return + } + // Config menus active + if has_menus { + log.fatalf("menus: cannot mix config menus with frontmatter menus") + os.exit(1) + } + return + } + + // No config menus — auto-generate, then merge page menus on top + collect_auto_menus(site) + merge_page_menus(site) +} + +// merge_page_menus collects frontmatter "menu" entries from pages and merges +// them into site.menus (which may already contain auto-generated entries). +// If no pages have "menu" set, this is a no-op. +merge_page_menus :: proc(site: ^Site) { + alloc := site_allocator(site) + + // Collect page entries by menu name + page_entries := make(map[string][dynamic]Menu_Entry, 16, alloc) + for page in site.pages { + if page.menu == "" { + continue + } + if _, ok := page_entries[page.menu]; !ok { + page_entries[page.menu] = make([dynamic]Menu_Entry, 0, 4, alloc) + } + append(&page_entries[page.menu], Menu_Entry{name = page.title, url = page.permalink}) + } + + if len(page_entries) == 0 { + return + } + + if site.menus == nil { + site.menus = make(map[string][]Menu_Entry, alloc) + } + + for menu_name, entries in page_entries { + sort_menu_entries(entries[:]) + if existing, ok := site.menus[menu_name]; ok { + // Merge with existing auto-generated entries + merged := make([dynamic]Menu_Entry, 0, len(existing) + len(entries), alloc) + append(&merged, ..existing) + append(&merged, ..entries[:]) + sort_menu_entries(merged[:]) + site.menus[menu_name] = merged[:] + } else { + site.menus[menu_name] = entries[:] + } + } +} + +collect_auto_menus :: proc(site: ^Site) { + alloc := site_allocator(site) + sections: map[string]bool + + for page in site.pages { + if page._is_index { + continue + } + if page.section != "" { + sections[page.section] = true + } + } + + entries := make([dynamic]Menu_Entry, 0, 8, alloc) + + // Section entries (one per section directory) + for section in sections { + name := to_title_case(section, alloc) + url := fmt.aprintf("/%s/", section, allocator = alloc) + for page in site.pages { + if page.section == section && page._is_index { + url = page.permalink + if page.title != "" { + name = page.title + } + break + } + } + append(&entries, Menu_Entry{name = name, url = url}) + } + + // Root-level page entries (section = "", not index) + for page in site.pages { + if page._is_index || page.section != "" || page.title == "" { + continue + } + append(&entries, Menu_Entry{name = page.title, url = page.permalink}) + } + + if len(entries) == 0 { + return + } + + sort_menu_entries(entries[:]) + site.menus = make(map[string][]Menu_Entry, alloc) + site.menus["main"] = entries[:] +} + +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 { + entries[j + 1] = entries[j] + j -= 1 + } + entries[j + 1] = key + } +} + +// parse_config_menus converts raw JSON from thor.json into map[string][]Menu_Entry. +// Preserves array order as-declared. +parse_config_menus :: proc( + raw: json.Value, + allocator := context.allocator, +) -> map[string][]Menu_Entry { + obj, ok := raw.(json.Object) + if !ok || len(obj) == 0 { + return nil + } + + result := make(map[string][]Menu_Entry, allocator) + for menu_name, menu_val in obj { + arr, ok := menu_val.(json.Array) + if !ok { + log.warnf("menus: '%s' is not an array, skipping", menu_name) + continue + } + + entries := make([dynamic]Menu_Entry, 0, len(arr), allocator) + for item, idx in arr { + entry_obj, ok := item.(json.Object) + if !ok { + log.warnf("menus: '%s' entry %d is not an object, skipping", menu_name, idx) + continue + } + + name := "" + url := "" + + if v, ok := entry_obj["name"]; ok { + if s, ok2 := v.(json.String); ok2 { + name = string(s) + } else { + log.warnf( + "menus: '%s' entry %d: 'name' must be a string, got %v, skipping", + menu_name, + idx, + v, + ) + continue + } + } + + if v, ok := entry_obj["url"]; ok { + if s, ok2 := v.(json.String); ok2 { + url = string(s) + } else { + log.warnf( + "menus: '%s' entry %d: 'url' must be a string, got %v, skipping", + menu_name, + idx, + v, + ) + continue + } + } + + if name == "" { + log.warnf("menus: '%s' entry %d missing 'name', skipping", menu_name, idx) + continue + } + + append(&entries, Menu_Entry{name = name, url = url}) + } + result[menu_name] = entries[:] + } + + return result +} + diff --git a/render.odin b/render.odin index 65d86c2..55ab5df 100644 --- a/render.odin +++ b/render.odin @@ -89,14 +89,31 @@ get_template :: proc( return mustache.Template{} } -capitalize :: proc(s: string) -> string { +to_title_case :: proc(s: string, allocator := context.allocator) -> string { if len(s) == 0 { return s } - if s[0] >= 'a' && s[0] <= 'z' { - return fmt.aprintf("%c%s", s[0] - 32, s[1:]) + + out := transmute([]byte)strings.clone(s, allocator) + + capitalize_next := true + for char, i in s { + switch char { + case '-', '_': + out[i] = ' ' + fallthrough + case ' ': + capitalize_next = true + case 'a' ..= 'z': + if capitalize_next { + out[i] = u8(char) - 32 + } + fallthrough + case: + capitalize_next = false + } } - return s + return string(out) } render_template :: proc( @@ -281,6 +298,7 @@ render_section :: proc( partials: map[string]mustache.Template, ctx: Template_Context, ) -> string { + alloc := site_allocator(site) posts := make([dynamic]Page, 0, len(site.pages) / 2, context.temp_allocator) for page in site.pages { if page.section != section || page._is_index { @@ -295,11 +313,12 @@ render_section :: proc( ctx.title = fmt.tprintf("%s | %s", section_index.title, site.title) ctx.og = og_for_page(site.og, section_index) } else { + title := to_title_case(section, alloc) ctx.page = Page { - title = capitalize(section), + title = title, } ctx.title = fmt.tprintf("%s | %s", ctx.page.title, site.title) - ctx.og.title = capitalize(section) + ctx.og.title = title ctx.og.description = "" ctx.og.url = fmt.tprintf("%s/%s/", site.base_url, section) ctx.og.type = "website" diff --git a/site.odin b/site.odin index 2f8ba3d..7055232 100644 --- a/site.odin +++ b/site.odin @@ -20,6 +20,7 @@ Site_Context :: struct { base_url: string, params: json.Object, og: Open_Graph, + menus: map[string][]Menu_Entry, } // Site is the primary workhorse, containing everything needed to build the site, @@ -68,6 +69,7 @@ Config_File :: struct { markdown_extensions: json.Value, params: json.Value, modules: json.Value, + menus: json.Value, og: Open_Graph, date: Date_Preferences, grammars: string, @@ -200,6 +202,15 @@ site_apply_config :: proc(site: ^Site, config: Config_File, config_dir: string) site.og = config.og site.date = config.date + // Parse config menus if present (nil = absent, non-nil = present) + if config.menus != nil { + site.menus = parse_config_menus(config.menus, site_allocator(site)) + if site.menus == nil { + // Present but empty ({}) — explicit opt-out + site.menus = make(map[string][]Menu_Entry, site_allocator(site)) + } + } + site.grammars = expand_path(config.grammars, site_allocator(site)) site.queries = expand_path(config.queries, site_allocator(site)) }