mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
feat: Added menu configuration.
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
- [ ] show a proper diagnostic for timezones
|
- [ ] show a proper diagnostic for timezones
|
||||||
- currently "unable to load timezone 'America/New_Yorkskie'"
|
- currently "unable to load timezone 'America/New_Yorkskie'"
|
||||||
- want rust style diagnostic and better message, maybe "unknown 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] 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
|
||||||
@@ -17,10 +18,13 @@
|
|||||||
- [x] Add a `#config(MAX_CONTEXT_DEPTH, 16?)` to `mustache`.
|
- [x] Add a `#config(MAX_CONTEXT_DEPTH, 16?)` to `mustache`.
|
||||||
- [ ] Documentation
|
- [ ] Documentation
|
||||||
- [ ] talk about the context stack (and its limit).
|
- [ ] 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
|
- [ ] menu system
|
||||||
- [ ] like Hugo's, but warn(/fail?) if menus are defined in the config *and* pages.
|
- [ ] 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.
|
- 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.
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|
||||||
@@ -44,6 +48,8 @@
|
|||||||
- `await` the highlighted code.
|
- `await` the highlighted code.
|
||||||
- [ ] can markdown extensions run in parallel?
|
- [ ] can markdown extensions run in parallel?
|
||||||
- [ ] enforce MAX_SLUG_LENGTH
|
- [ ] enforce MAX_SLUG_LENGTH
|
||||||
|
- [ ] enforce MAX_CONTEXT_DEPTH
|
||||||
|
- [ ] ensure struct fields are ordered correctly
|
||||||
|
|
||||||
## Remove Privileged content
|
## Remove Privileged content
|
||||||
|
|
||||||
@@ -78,6 +84,7 @@
|
|||||||
- [ ] Do we *need* mustache.Date_Components, or can we use core:time/datetime.DateTime?
|
- [ ] Do we *need* mustache.Date_Components, or can we use core:time/datetime.DateTime?
|
||||||
|
|
||||||
## General
|
## General
|
||||||
|
- [ ] configure opt-out of automatic sections being added to menu.
|
||||||
- [ ] get rid of the global variables in the `treesitter` package.
|
- [ ] get rid of the global variables in the `treesitter` package.
|
||||||
- [ ] Consider using `or_else` when applying default values to structs. i.e.
|
- [ ] Consider using `or_else` when applying default values to structs. i.e.
|
||||||
```odin
|
```odin
|
||||||
@@ -96,6 +103,8 @@ main :: proc () {
|
|||||||
- [ ] Integrity hash
|
- [ ] Integrity hash
|
||||||
- Allows users to verify their output didn't change after upgrading to a new version
|
- Allows users to verify their output didn't change after upgrading to a new version
|
||||||
- [ ] Content-hash fingerprinting for CSS and JS cache busting
|
- [ ] 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.
|
- [ ] merge `render_{section,home_html,page_html}` procs.
|
||||||
- [ ] try to combine render_page_html and render_home_html?
|
- [ ] try to combine render_page_html and render_home_html?
|
||||||
- [ ] Debug log stats. (analytics)
|
- [ ] 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
|
- [ ] running ./thor/thor still logs the debug message: using config /home/spencer/github.com/sbrow.github.io/thor.json
|
||||||
- wrong cwd?
|
- wrong cwd?
|
||||||
- [ ] Clean up the default layouts
|
- [ ] 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
|
- [ ] if no `html` tag detected in output, re-render output with base template
|
||||||
(or whatever template is next in the chain)
|
(or whatever template is next in the chain)
|
||||||
- [ ] Add `-production` flag
|
- [ ] Add `-production` flag
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ site_load_content :: proc(site: ^Site) {
|
|||||||
for &page in site.pages {
|
for &page in site.pages {
|
||||||
page.url = fmt.tprintf("%s%s", site.base_url, page.permalink)
|
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
|
// scan_content_files walks the content directory and collects Pending_File
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{{site.title}}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
<header>
|
<header>
|
||||||
<nav>
|
<nav>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/">{{title}}</a></li>
|
<li><a href="/">{{> home-link}}</a></li>
|
||||||
|
{{#menus.main}}
|
||||||
|
<li><a href="{{url}}">{{name}}</a></li>
|
||||||
|
{{/menus.main}}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
+224
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
+25
-6
@@ -89,14 +89,31 @@ get_template :: proc(
|
|||||||
return mustache.Template{}
|
return mustache.Template{}
|
||||||
}
|
}
|
||||||
|
|
||||||
capitalize :: proc(s: string) -> string {
|
to_title_case :: proc(s: string, allocator := context.allocator) -> string {
|
||||||
if len(s) == 0 {
|
if len(s) == 0 {
|
||||||
return s
|
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(
|
render_template :: proc(
|
||||||
@@ -281,6 +298,7 @@ render_section :: proc(
|
|||||||
partials: map[string]mustache.Template,
|
partials: map[string]mustache.Template,
|
||||||
ctx: Template_Context,
|
ctx: Template_Context,
|
||||||
) -> string {
|
) -> string {
|
||||||
|
alloc := site_allocator(site)
|
||||||
posts := make([dynamic]Page, 0, len(site.pages) / 2, context.temp_allocator)
|
posts := make([dynamic]Page, 0, len(site.pages) / 2, context.temp_allocator)
|
||||||
for page in site.pages {
|
for page in site.pages {
|
||||||
if page.section != section || page._is_index {
|
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.title = fmt.tprintf("%s | %s", section_index.title, site.title)
|
||||||
ctx.og = og_for_page(site.og, section_index)
|
ctx.og = og_for_page(site.og, section_index)
|
||||||
} else {
|
} else {
|
||||||
|
title := to_title_case(section, alloc)
|
||||||
ctx.page = Page {
|
ctx.page = Page {
|
||||||
title = capitalize(section),
|
title = title,
|
||||||
}
|
}
|
||||||
ctx.title = fmt.tprintf("%s | %s", ctx.page.title, site.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.description = ""
|
||||||
ctx.og.url = fmt.tprintf("%s/%s/", site.base_url, section)
|
ctx.og.url = fmt.tprintf("%s/%s/", site.base_url, section)
|
||||||
ctx.og.type = "website"
|
ctx.og.type = "website"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Site_Context :: struct {
|
|||||||
base_url: string,
|
base_url: string,
|
||||||
params: json.Object,
|
params: json.Object,
|
||||||
og: Open_Graph,
|
og: Open_Graph,
|
||||||
|
menus: map[string][]Menu_Entry,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Site is the primary workhorse, containing everything needed to build the site,
|
// Site is the primary workhorse, containing everything needed to build the site,
|
||||||
@@ -68,6 +69,7 @@ Config_File :: struct {
|
|||||||
markdown_extensions: json.Value,
|
markdown_extensions: json.Value,
|
||||||
params: json.Value,
|
params: json.Value,
|
||||||
modules: json.Value,
|
modules: json.Value,
|
||||||
|
menus: json.Value,
|
||||||
og: Open_Graph,
|
og: Open_Graph,
|
||||||
date: Date_Preferences,
|
date: Date_Preferences,
|
||||||
grammars: string,
|
grammars: string,
|
||||||
@@ -200,6 +202,15 @@ site_apply_config :: proc(site: ^Site, config: Config_File, config_dir: string)
|
|||||||
site.og = config.og
|
site.og = config.og
|
||||||
site.date = config.date
|
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.grammars = expand_path(config.grammars, site_allocator(site))
|
||||||
site.queries = expand_path(config.queries, site_allocator(site))
|
site.queries = expand_path(config.queries, site_allocator(site))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user