mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
Compare commits
7 Commits
0307ab647f
...
824101a356
| Author | SHA1 | Date | |
|---|---|---|---|
| 824101a356 | |||
| 06afc15772 | |||
| 730fe6715c | |||
| ae721b3caa | |||
| 98c0efba44 | |||
| 298ae58ca5 | |||
| 27895ea2dc |
@@ -449,6 +449,14 @@ See `HUGO.md` for analysis of why thor doesn't need Hugo's shortcode context iso
|
|||||||
See `mustache/SPEC.md` for the original implementation specification.
|
See `mustache/SPEC.md` for the original implementation specification.
|
||||||
See `mustache/EXTENSIONS.md` for non-standard extensions (pipes).
|
See `mustache/EXTENSIONS.md` for non-standard extensions (pipes).
|
||||||
|
|
||||||
|
## Odin language facts
|
||||||
|
|
||||||
|
These are things that are easy to get wrong:
|
||||||
|
|
||||||
|
- **Proc arguments are immutable.** You cannot assign to a parameter directly. To get a mutable copy, shadow it: `x := x`. If you need to modify the source, pass a pointer `^x`.
|
||||||
|
- **`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.
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
See `TODOS.md` for the full list.
|
See `TODOS.md` for the full list.
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
## High priority
|
## High priority
|
||||||
|
|
||||||
- Polish existing features before moving on to new ones.
|
- Polish existing features before moving on to new ones.
|
||||||
|
- [ ] Add Weights
|
||||||
|
- [ ] sort by `page.weight` when loading
|
||||||
|
- [ ] re-sort by `page.menu.weight` when building menus.
|
||||||
|
- [ ] warn user when 2 pages with explicit weights match.
|
||||||
- [ ] Improve diagnostics
|
- [ ] Improve diagnostics
|
||||||
|
- [ ] All Diagnostics should show:
|
||||||
|
- [ ] *What* went wrong
|
||||||
|
- [ ] *where* (in the file)
|
||||||
|
- [ ] *where* (in the stack trace)
|
||||||
|
- [ ] *how* you can fix it (if applicable)
|
||||||
|
- [ ] Create a Location struct that somewhat matches Odin's [Source_Code_Location](https://pkg.odin-lang.org/base/runtime/#Source_Code_Location)?
|
||||||
|
- Note that odin's version doesn't contain the stack trace.
|
||||||
- [ ] show "stack traces" in template error diagnostics
|
- [ ] show "stack traces" in template error diagnostics
|
||||||
- [ ] better diagnostics for syntax errors in treesitter.
|
- [ ] better diagnostics for syntax errors in treesitter.
|
||||||
- [ ] Ensure diagnostics for MAX_CONTEXT_DEPTH are good.
|
- [ ] Ensure diagnostics for MAX_CONTEXT_DEPTH are good.
|
||||||
- [ ] 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
|
||||||
|
- [ ] Honestly, Test **all** 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 +30,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 +60,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 +96,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 +115,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 +137,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
|
||||||
|
|||||||
+37
-16
@@ -1,11 +1,11 @@
|
|||||||
package bench
|
package bench
|
||||||
|
|
||||||
|
import "../mustache"
|
||||||
import "core:fmt"
|
import "core:fmt"
|
||||||
import "core:mem"
|
import "core:mem"
|
||||||
import "core:os"
|
import "core:os"
|
||||||
import "core:strconv"
|
import "core:strconv"
|
||||||
import "core:time"
|
import "core:time"
|
||||||
import "../mustache"
|
|
||||||
|
|
||||||
Tag :: struct {
|
Tag :: struct {
|
||||||
name: string,
|
name: string,
|
||||||
@@ -101,13 +101,13 @@ main :: proc() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for _ in 0..<3 {
|
for _ in 0 ..< 3 {
|
||||||
_, _ = mustache.render(page, data, partials, allocator = context.temp_allocator)
|
_, _ = mustache.render(page, data, partials, allocator = context.temp_allocator)
|
||||||
mem.dynamic_arena_free_all(&temp_arena)
|
mem.dynamic_arena_free_all(&temp_arena)
|
||||||
}
|
}
|
||||||
|
|
||||||
start := time.now()
|
start := time.now()
|
||||||
for _ in 0..<iterations {
|
for _ in 0 ..< iterations {
|
||||||
_, _ = mustache.render(page, data, partials, allocator = context.temp_allocator)
|
_, _ = mustache.render(page, data, partials, allocator = context.temp_allocator)
|
||||||
mem.dynamic_arena_free_all(&temp_arena)
|
mem.dynamic_arena_free_all(&temp_arena)
|
||||||
}
|
}
|
||||||
@@ -116,8 +116,12 @@ main :: proc() {
|
|||||||
seconds := time.duration_seconds(elapsed)
|
seconds := time.duration_seconds(elapsed)
|
||||||
per_render_ms := seconds * 1000 / f64(iterations)
|
per_render_ms := seconds * 1000 / f64(iterations)
|
||||||
|
|
||||||
fmt.printfln("iterations=%d total=%.3fs per_render=%.3fms",
|
fmt.printfln(
|
||||||
iterations, seconds, per_render_ms)
|
"iterations=%d total=%.3fs per_render=%.3fms",
|
||||||
|
iterations,
|
||||||
|
seconds,
|
||||||
|
per_render_ms,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
parse_file :: proc(name: string) -> mustache.Template {
|
parse_file :: proc(name: string) -> mustache.Template {
|
||||||
@@ -138,16 +142,27 @@ parse_file :: proc(name: string) -> mustache.Template {
|
|||||||
}
|
}
|
||||||
|
|
||||||
generate_data :: proc() -> Page_Data {
|
generate_data :: proc() -> Page_Data {
|
||||||
years := []string{
|
years := []string {
|
||||||
"2025", "2024", "2023", "2022", "2021",
|
"2025",
|
||||||
"2020", "2019", "2018", "2017", "2016",
|
"2024",
|
||||||
|
"2023",
|
||||||
|
"2022",
|
||||||
|
"2021",
|
||||||
|
"2020",
|
||||||
|
"2019",
|
||||||
|
"2018",
|
||||||
|
"2017",
|
||||||
|
"2016",
|
||||||
}
|
}
|
||||||
|
|
||||||
posts := make([dynamic]Post, 0, 500)
|
posts := make([dynamic]Post, 0, 500)
|
||||||
for year in years {
|
for year in years {
|
||||||
for i in 0..<50 {
|
for i in 0 ..< 50 {
|
||||||
tags := make([dynamic]Tag, 0, 3)
|
tags := make([dynamic]Tag, 0, 3)
|
||||||
append(&tags, Tag{name = fmt.aprintf("%s-notes", year), slug = fmt.aprintf("%s-notes", year)})
|
append(
|
||||||
|
&tags,
|
||||||
|
Tag{name = fmt.aprintf("%s-notes", year), slug = fmt.aprintf("%s-notes", year)},
|
||||||
|
)
|
||||||
append(&tags, Tag{name = "writing", slug = "writing"})
|
append(&tags, Tag{name = "writing", slug = "writing"})
|
||||||
append(&tags, Tag{name = "archive", slug = "archive"})
|
append(&tags, Tag{name = "archive", slug = "archive"})
|
||||||
|
|
||||||
@@ -159,7 +174,9 @@ generate_data :: proc() -> Page_Data {
|
|||||||
author = fmt.aprintf("Author %d", i % 5)
|
author = fmt.aprintf("Author %d", i % 5)
|
||||||
}
|
}
|
||||||
|
|
||||||
append(&posts, Post{
|
append(
|
||||||
|
&posts,
|
||||||
|
Post {
|
||||||
title = fmt.aprintf("Post %d from %s", i, year),
|
title = fmt.aprintf("Post %d from %s", i, year),
|
||||||
url = fmt.aprintf("/%s/post-%d", year, i),
|
url = fmt.aprintf("/%s/post-%d", year, i),
|
||||||
date = fmt.aprintf("%s-%02d-%02dT10:00:00Z", year, month, day),
|
date = fmt.aprintf("%s-%02d-%02dT10:00:00Z", year, month, day),
|
||||||
@@ -167,20 +184,24 @@ generate_data :: proc() -> Page_Data {
|
|||||||
excerpt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
excerpt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
||||||
author = author,
|
author = author,
|
||||||
tags = tags,
|
tags = tags,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
comments := make([dynamic]Comment, 0, 100)
|
comments := make([dynamic]Comment, 0, 100)
|
||||||
for i in 0..<100 {
|
for i in 0 ..< 100 {
|
||||||
year := years[i % len(years)]
|
year := years[i % len(years)]
|
||||||
month := (i % 12) + 1
|
month := (i % 12) + 1
|
||||||
day := (i % 28) + 1
|
day := (i % 28) + 1
|
||||||
append(&comments, Comment{
|
append(
|
||||||
|
&comments,
|
||||||
|
Comment {
|
||||||
author = fmt.aprintf("Commenter %d", i),
|
author = fmt.aprintf("Commenter %d", i),
|
||||||
date = fmt.aprintf("%s-%02d-%02dT12:00:00Z", year, month, day),
|
date = fmt.aprintf("%s-%02d-%02dT12:00:00Z", year, month, day),
|
||||||
body = fmt.aprintf("Great post! This is comment number %d.", i),
|
body = fmt.aprintf("Great post! This is comment number %d.", i),
|
||||||
})
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
nav_items := make([dynamic]Nav_Item, 0, 8)
|
nav_items := make([dynamic]Nav_Item, 0, 8)
|
||||||
@@ -193,7 +214,7 @@ generate_data :: proc() -> Page_Data {
|
|||||||
append(&nav_items, Nav_Item{url = "https://twitter.com/example", label = "Twitter"})
|
append(&nav_items, Nav_Item{url = "https://twitter.com/example", label = "Twitter"})
|
||||||
append(&nav_items, Nav_Item{url = "mailto:nobody@example.com", label = "Email"})
|
append(&nav_items, Nav_Item{url = "mailto:nobody@example.com", label = "Email"})
|
||||||
|
|
||||||
return Page_Data{
|
return Page_Data {
|
||||||
title = "Post Archive",
|
title = "Post Archive",
|
||||||
now = "2025-07-21T12:00:00Z",
|
now = "2025-07-21T12:00:00Z",
|
||||||
posts = posts,
|
posts = posts,
|
||||||
|
|||||||
@@ -127,4 +127,3 @@ to_benchmark :: proc($f: formatter) -> benchmark {
|
|||||||
} \
|
} \
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,4 +41,3 @@ parse_2_digits :: proc(s: string, offset: int) -> int {
|
|||||||
}
|
}
|
||||||
return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30)
|
return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,4 +123,3 @@ emit_hour_12 :: proc(b: ^strings.Builder, dt: common.Date_Components, pad: bool)
|
|||||||
|
|
||||||
fmt.sbprintf(b, format, h12)
|
fmt.sbprintf(b, format, h12)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,4 +130,3 @@ emit_hour_12 :: proc(b: ^strings.Builder, dt: common.Date_Components, pad: bool)
|
|||||||
|
|
||||||
fmt.sbprintf(b, format, h12)
|
fmt.sbprintf(b, format, h12)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,4 +125,3 @@ emit_am_pm :: proc(b: ^strings.Builder, dt: common.Date_Components) {
|
|||||||
emit_am_pm_lower :: proc(b: ^strings.Builder, dt: common.Date_Components) {
|
emit_am_pm_lower :: proc(b: ^strings.Builder, dt: common.Date_Components) {
|
||||||
strings.write_string(b, "pm" if dt.hour >= 12 else "am")
|
strings.write_string(b, "pm" if dt.hour >= 12 else "am")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,4 +41,3 @@ parse_2_digits :: proc(s: string, offset: int) -> int {
|
|||||||
}
|
}
|
||||||
return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30)
|
return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,4 +124,3 @@ emit_hour_12 :: proc(b: ^strings.Builder, dt: common.Date_Components, pad: bool)
|
|||||||
|
|
||||||
fmt.sbprintf(b, format, h12)
|
fmt.sbprintf(b, format, h12)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-3
@@ -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
|
||||||
@@ -236,9 +238,18 @@ load_page :: proc(
|
|||||||
if page.date == "" {
|
if page.date == "" {
|
||||||
info, stat_err := os.stat(file_path, context.allocator)
|
info, stat_err := os.stat(file_path, context.allocator)
|
||||||
if stat_err == nil {
|
if stat_err == nil {
|
||||||
page.date, _ = time.time_to_rfc3339(info.modification_time, 0, false, context.allocator)
|
page.date, _ = time.time_to_rfc3339(
|
||||||
|
info.modification_time,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
context.allocator,
|
||||||
|
)
|
||||||
os.file_info_delete(info, context.allocator)
|
os.file_info_delete(info, context.allocator)
|
||||||
log.warnf("no date in frontmatter for %s, using file modification time: %s", file_path, page.date)
|
log.warnf(
|
||||||
|
"no date in frontmatter for %s, using file modification time: %s",
|
||||||
|
file_path,
|
||||||
|
page.date,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
page.year = get_year(page.date)
|
page.year = get_year(page.date)
|
||||||
@@ -280,4 +291,3 @@ strip_extension :: proc(name: string) -> string {
|
|||||||
}
|
}
|
||||||
return name[:dot]
|
return name[:dot]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,4 +3,3 @@ package main
|
|||||||
import "core:os"
|
import "core:os"
|
||||||
|
|
||||||
DEFAULTS_PATH :: #directory + os.Path_Separator_String + "defaults"
|
DEFAULTS_PATH :: #directory + os.Path_Separator_String + "defaults"
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -149,4 +149,3 @@ xml_escape :: proc(s: string) -> string {
|
|||||||
r, _ = strings.replace_all(r, ">", ">")
|
r, _ = strings.replace_all(r, ">", ">")
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
inputs@{
|
inputs@{ flake-parts
|
||||||
self,
|
, nixpkgs
|
||||||
flake-parts,
|
, ...
|
||||||
nixpkgs,
|
|
||||||
nixpkgs-unstable,
|
|
||||||
# , process-compose-flake
|
# , process-compose-flake
|
||||||
treefmt-nix,
|
|
||||||
}:
|
}:
|
||||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
imports = [
|
imports = [
|
||||||
@@ -28,11 +25,10 @@
|
|||||||
systems = [ "x86_64-linux" ];
|
systems = [ "x86_64-linux" ];
|
||||||
|
|
||||||
perSystem =
|
perSystem =
|
||||||
{
|
{ pkgs
|
||||||
pkgs,
|
, system
|
||||||
system,
|
, inputs'
|
||||||
inputs',
|
, ...
|
||||||
...
|
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
mkGrammarStaticLib = name: src: pkgs.stdenv.mkDerivation {
|
mkGrammarStaticLib = name: src: pkgs.stdenv.mkDerivation {
|
||||||
@@ -71,7 +67,7 @@
|
|||||||
config.allowUnfree = true;
|
config.allowUnfree = true;
|
||||||
|
|
||||||
overlays = [
|
overlays = [
|
||||||
(final: prev: { unstable = inputs'.nixpkgs-unstable.legacyPackages; })
|
(_final: _prev: { unstable = inputs'.nixpkgs-unstable.legacyPackages; })
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -145,7 +141,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
buildInputs = [odin ols ] ++ (with pkgs; [
|
buildInputs = [ odin ols ] ++ (with pkgs; [
|
||||||
cmark
|
cmark
|
||||||
tree-sitter
|
tree-sitter
|
||||||
|
|
||||||
|
|||||||
@@ -99,4 +99,3 @@ json_get_open_graph :: proc(obj: json.Object, key: string) -> Open_Graph {
|
|||||||
}
|
}
|
||||||
return og
|
return og
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -82,9 +82,7 @@ test_description_plain_text :: proc(t: ^testing.T) {
|
|||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
test_description_highlighted_code :: proc(t: ^testing.T) {
|
test_description_highlighted_code :: proc(t: ^testing.T) {
|
||||||
result := generate_description(
|
result := generate_description(`<pre><code><span class="hl-keyword">if</span> x</code></pre>`)
|
||||||
`<pre><code><span class="hl-keyword">if</span> x</code></pre>`,
|
|
||||||
)
|
|
||||||
testing.expect_value(t, result, "if x")
|
testing.expect_value(t, result, "if x")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,4 +25,3 @@ main :: proc() {
|
|||||||
fmt.printfln("%+v", site)
|
fmt.printfln("%+v", site)
|
||||||
fmt.printf("%+v", site)
|
fmt.printf("%+v", site)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,4 +94,3 @@ when SPALL {
|
|||||||
spall._buffer_end(&spall_ctx, &spall_buffer)
|
spall._buffer_end(&spall_ctx, &spall_buffer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,4 +7,3 @@ import "core:testing"
|
|||||||
test_true :: proc(t: ^testing.T) {
|
test_true :: proc(t: ^testing.T) {
|
||||||
testing.expect(t, true)
|
testing.expect(t, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,4 +95,3 @@ transform_alert :: proc(sb: ^strings.Builder, bq: string) {
|
|||||||
strings.write_string(sb, " ")
|
strings.write_string(sb, " ")
|
||||||
strings.write_string(sb, rest)
|
strings.write_string(sb, rest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,4 +101,3 @@ test_multiple_alerts_render_together :: proc(t: ^testing.T) {
|
|||||||
</blockquote>`,
|
</blockquote>`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -434,4 +434,3 @@ expand_emoji :: proc(text: string) -> string {
|
|||||||
|
|
||||||
return strings.to_string(sb)
|
return strings.to_string(sb)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,4 +30,3 @@ test_emoji_skips_invalid_shortcodes :: proc(t: ^testing.T) {
|
|||||||
testing.expect_value(t, expand_emoji(":Smile:"), ":Smile:")
|
testing.expect_value(t, expand_emoji(":Smile:"), ":Smile:")
|
||||||
testing.expect_value(t, expand_emoji(": not real :"), ": not real :")
|
testing.expect_value(t, expand_emoji(": not real :"), ": not real :")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -210,4 +210,3 @@ strip_p_tags :: proc(html: string) -> string {
|
|||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,4 +119,3 @@ test_inject_notes_missing_ref :: proc(t: ^testing.T) {
|
|||||||
testing.expect(t, strings.contains(out, "[^missing]"))
|
testing.expect(t, strings.contains(out, "[^missing]"))
|
||||||
testing.expect(t, strings.contains(out, "[*missing]"))
|
testing.expect(t, strings.contains(out, "[*missing]"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -193,4 +193,3 @@ make_unique :: proc(slug: string, seen: ^map[string]bool) -> string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ test_heading_simple :: proc(t: ^testing.T) {
|
|||||||
@(test)
|
@(test)
|
||||||
test_heading_dedup :: proc(t: ^testing.T) {
|
test_heading_dedup :: proc(t: ^testing.T) {
|
||||||
result := inject_heading_ids("<h2>Intro</h2><p>text</p><h2>Intro</h2>")
|
result := inject_heading_ids("<h2>Intro</h2><p>text</p><h2>Intro</h2>")
|
||||||
testing.expect_value(t, result, `<h2 id="intro">Intro</h2><p>text</p><h2 id="intro-1">Intro</h2>`)
|
testing.expect_value(
|
||||||
|
t,
|
||||||
|
result,
|
||||||
|
`<h2 id="intro">Intro</h2><p>text</p><h2 id="intro-1">Intro</h2>`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
@@ -36,7 +40,9 @@ test_heading_punctuation :: proc(t: ^testing.T) {
|
|||||||
@(test)
|
@(test)
|
||||||
test_heading_all_levels :: proc(t: ^testing.T) {
|
test_heading_all_levels :: proc(t: ^testing.T) {
|
||||||
result := inject_heading_ids("<h1>A</h1><h2>B</h2><h3>C</h3><h4>D</h4><h5>E</h5><h6>F</h6>")
|
result := inject_heading_ids("<h1>A</h1><h2>B</h2><h3>C</h3><h4>D</h4><h5>E</h5><h6>F</h6>")
|
||||||
testing.expect_value(t, result,
|
testing.expect_value(
|
||||||
|
t,
|
||||||
|
result,
|
||||||
`<h1 id="a">A</h1>` +
|
`<h1 id="a">A</h1>` +
|
||||||
`<h2 id="b">B</h2>` +
|
`<h2 id="b">B</h2>` +
|
||||||
`<h3 id="c">C</h3>` +
|
`<h3 id="c">C</h3>` +
|
||||||
@@ -88,9 +94,9 @@ test_heading_numbers :: proc(t: ^testing.T) {
|
|||||||
@(test)
|
@(test)
|
||||||
test_heading_triple_dedup :: proc(t: ^testing.T) {
|
test_heading_triple_dedup :: proc(t: ^testing.T) {
|
||||||
result := inject_heading_ids("<h2>Foo</h2><h2>Foo</h2><h2>Foo</h2>")
|
result := inject_heading_ids("<h2>Foo</h2><h2>Foo</h2><h2>Foo</h2>")
|
||||||
testing.expect_value(t, result,
|
testing.expect_value(
|
||||||
`<h2 id="foo">Foo</h2>` +
|
t,
|
||||||
`<h2 id="foo-1">Foo</h2>` +
|
result,
|
||||||
`<h2 id="foo-2">Foo</h2>`,
|
`<h2 id="foo">Foo</h2>` + `<h2 id="foo-1">Foo</h2>` + `<h2 id="foo-2">Foo</h2>`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,4 +305,3 @@ highlight_code :: proc(html: string, file_path: string) -> string {
|
|||||||
}
|
}
|
||||||
return strings.to_string(sb)
|
return strings.to_string(sb)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,4 +88,3 @@ apply_extension_config :: proc(ext: ^bit_set[Extension], config: json.Object) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,4 +43,3 @@ wrap_sections :: proc(html: string) -> string {
|
|||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,4 +46,3 @@ test_wrap_sections_doesnt_split_content :: proc(t: ^testing.T) {
|
|||||||
"<section><h1>Big</h1><h3>Small</h3></section>",
|
"<section><h1>Big</h1><h3>Small</h3></section>",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+223
@@ -0,0 +1,223 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
+21
-21
@@ -54,7 +54,7 @@ minify_html :: proc(source: string) -> string {
|
|||||||
segment := source[i:p.end]
|
segment := source[i:p.end]
|
||||||
strings.write_string(&sb, segment)
|
strings.write_string(&sb, segment)
|
||||||
if len(segment) > 0 {
|
if len(segment) > 0 {
|
||||||
last_written = segment[len(segment)-1]
|
last_written = segment[len(segment) - 1]
|
||||||
}
|
}
|
||||||
i = int(p.end)
|
i = int(p.end)
|
||||||
pi += 1
|
pi += 1
|
||||||
@@ -103,27 +103,27 @@ collect_html_ranges :: proc(
|
|||||||
preserves: ^[dynamic]Range,
|
preserves: ^[dynamic]Range,
|
||||||
) {
|
) {
|
||||||
child_count := ts.node_named_child_count(node)
|
child_count := ts.node_named_child_count(node)
|
||||||
for i in 0..<child_count {
|
for i in 0 ..< child_count {
|
||||||
child := ts.node_named_child(node, u32(i))
|
child := ts.node_named_child(node, u32(i))
|
||||||
type_str := string(ts.node_type(child))
|
type_str := string(ts.node_type(child))
|
||||||
|
|
||||||
if type_str == "comment" {
|
if type_str == "comment" {
|
||||||
append(comments, Range{
|
append(
|
||||||
start = ts.node_start_byte(child),
|
comments,
|
||||||
end = ts.node_end_byte(child),
|
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
|
||||||
})
|
)
|
||||||
} else if type_str == "script_element" || type_str == "style_element" {
|
} else if type_str == "script_element" || type_str == "style_element" {
|
||||||
append(preserves, Range{
|
append(
|
||||||
start = ts.node_start_byte(child),
|
preserves,
|
||||||
end = ts.node_end_byte(child),
|
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
|
||||||
})
|
)
|
||||||
} else if type_str == "element" {
|
} else if type_str == "element" {
|
||||||
tag := html_tag_name(child, source)
|
tag := html_tag_name(child, source)
|
||||||
if is_preserve_tag(tag) {
|
if is_preserve_tag(tag) {
|
||||||
append(preserves, Range{
|
append(
|
||||||
start = ts.node_start_byte(child),
|
preserves,
|
||||||
end = ts.node_end_byte(child),
|
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
|
||||||
})
|
)
|
||||||
} else {
|
} else {
|
||||||
collect_html_ranges(child, source, comments, preserves)
|
collect_html_ranges(child, source, comments, preserves)
|
||||||
}
|
}
|
||||||
@@ -135,11 +135,11 @@ collect_html_ranges :: proc(
|
|||||||
|
|
||||||
html_tag_name :: proc(element: ts.Node, source: string) -> string {
|
html_tag_name :: proc(element: ts.Node, source: string) -> string {
|
||||||
child_count := ts.node_named_child_count(element)
|
child_count := ts.node_named_child_count(element)
|
||||||
for i in 0..<child_count {
|
for i in 0 ..< child_count {
|
||||||
child := ts.node_named_child(element, u32(i))
|
child := ts.node_named_child(element, u32(i))
|
||||||
if string(ts.node_type(child)) == "start_tag" {
|
if string(ts.node_type(child)) == "start_tag" {
|
||||||
tag_child_count := ts.node_named_child_count(child)
|
tag_child_count := ts.node_named_child_count(child)
|
||||||
for j in 0..<tag_child_count {
|
for j in 0 ..< tag_child_count {
|
||||||
tag_child := ts.node_named_child(child, u32(j))
|
tag_child := ts.node_named_child(child, u32(j))
|
||||||
if string(ts.node_type(tag_child)) == "tag_name" {
|
if string(ts.node_type(tag_child)) == "tag_name" {
|
||||||
start := ts.node_start_byte(tag_child)
|
start := ts.node_start_byte(tag_child)
|
||||||
@@ -241,13 +241,13 @@ minify_css :: proc(source: string) -> string {
|
|||||||
|
|
||||||
collect_css_comments :: proc(node: ts.Node, comments: ^[dynamic]Range) {
|
collect_css_comments :: proc(node: ts.Node, comments: ^[dynamic]Range) {
|
||||||
child_count := ts.node_named_child_count(node)
|
child_count := ts.node_named_child_count(node)
|
||||||
for i in 0..<child_count {
|
for i in 0 ..< child_count {
|
||||||
child := ts.node_named_child(node, u32(i))
|
child := ts.node_named_child(node, u32(i))
|
||||||
if string(ts.node_type(child)) == "comment" {
|
if string(ts.node_type(child)) == "comment" {
|
||||||
append(comments, Range{
|
append(
|
||||||
start = ts.node_start_byte(child),
|
comments,
|
||||||
end = ts.node_end_byte(child),
|
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
|
||||||
})
|
)
|
||||||
} else {
|
} else {
|
||||||
collect_css_comments(child, comments)
|
collect_css_comments(child, comments)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ Errors (returned as `Data_Error` at render time):
|
|||||||
|
|
||||||
#### `format`
|
#### `format`
|
||||||
|
|
||||||
Formats an ISO 8601 date string as a display string. Takes a string, returns a string (e.g. `"2026-03-15T08:49:54-04:00"` → `"15 Mar 2026"`). Invalid input (empty, too-short, non-string, or unparseable) returns a `Data_Error`. Templates that need to skip dateless pages should gate with a section — `{{#date}}<time datetime="{{.}}">{{. | format}}</time>{{/date}}` — so the section's truthiness check catches empty before the filter runs. Commonly used inline as `{{date | format}}` to render a display string while keeping the raw ISO available via `{{date}}` for the `datetime=` attribute.
|
Formats an ISO 8601 date string as a display string. Takes a string, returns a string (e.g. `"2026-03-15T08:49:54-04:00"` → `"15 Mar 2026"`). Invalid input (empty, too-short, non-string, or unparsable) returns a `Data_Error`. Templates that need to skip dateless pages should gate with a section — `{{#date}}<time datetime="{{.}}">{{. | format}}</time>{{/date}}` — so the section's truthiness check catches empty before the filter runs. Commonly used inline as `{{date | format}}` to render a display string while keeping the raw ISO available via `{{date}}` for the `datetime=` attribute.
|
||||||
|
|
||||||
Internally: parses the invariant `YYYY-MM-DD` prefix by char offset, stringifies `time.Month(month_num)` and slices `[:3]` for the abbreviation. Accepts any of these ISO 8601 forms (the date prefix is what matters): `2023-10-15T13:18:50-07:00`, `2023-10-15T13:18:50-0700`, `2023-10-15T13:18:50Z`, `2023-10-15T13:18:50`, `2023-10-15`.
|
Internally: parses the invariant `YYYY-MM-DD` prefix by char offset, stringifies `time.Month(month_num)` and slices `[:3]` for the abbreviation. Accepts any of these ISO 8601 forms (the date prefix is what matters): `2023-10-15T13:18:50-07:00`, `2023-10-15T13:18:50-0700`, `2023-10-15T13:18:50Z`, `2023-10-15T13:18:50`, `2023-10-15`.
|
||||||
|
|
||||||
|
|||||||
@@ -302,4 +302,3 @@ write_value :: proc(b: ^strings.Builder, a: any, escape: bool) {
|
|||||||
strings.write_string(b, s[start:])
|
strings.write_string(b, s[start:])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -320,4 +320,3 @@ format_render_error :: proc(err: Error, tmpl: Template, colorize: bool = false)
|
|||||||
b := body(err)
|
b := body(err)
|
||||||
return format_error(path, tmpl.source, b.pos, b.msg, colorize = colorize)
|
return format_error(path, tmpl.source, b.pos, b.msg, colorize = colorize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -557,4 +557,3 @@ test_parse_error_pipe_parse_in_inverted_keeps_double_braces :: proc(t: ^testing.
|
|||||||
fmt.tprintf("msg should contain literal '{{^', got %q", b.msg),
|
fmt.tprintf("msg should contain literal '{{^', got %q", b.msg),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -321,4 +321,3 @@ convert_to_tz :: proc(
|
|||||||
},
|
},
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+114
-25
@@ -104,38 +104,64 @@ test_parse_offset_skips_fractional_seconds :: proc(t: ^testing.T) {
|
|||||||
@(test)
|
@(test)
|
||||||
test_format_date_weekday_full :: proc(t: ^testing.T) {
|
test_format_date_weekday_full :: proc(t: ^testing.T) {
|
||||||
// 2026-01-01 is a Thursday.
|
// 2026-01-01 is a Thursday.
|
||||||
dt := Date_Components{year = 2026, month = 1, day = 1}
|
dt := Date_Components {
|
||||||
|
year = 2026,
|
||||||
|
month = 1,
|
||||||
|
day = 1,
|
||||||
|
}
|
||||||
result := format_date(dt, "Monday")
|
result := format_date(dt, "Monday")
|
||||||
testing.expect_value(t, result, "Thursday")
|
testing.expect_value(t, result, "Thursday")
|
||||||
}
|
}
|
||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
test_format_date_weekday_abbr :: proc(t: ^testing.T) {
|
test_format_date_weekday_abbr :: proc(t: ^testing.T) {
|
||||||
dt := Date_Components{year = 2026, month = 1, day = 1}
|
dt := Date_Components {
|
||||||
|
year = 2026,
|
||||||
|
month = 1,
|
||||||
|
day = 1,
|
||||||
|
}
|
||||||
result := format_date(dt, "Mon")
|
result := format_date(dt, "Mon")
|
||||||
testing.expect_value(t, result, "Thu")
|
testing.expect_value(t, result, "Thu")
|
||||||
}
|
}
|
||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
test_format_date_month_full_name :: proc(t: ^testing.T) {
|
test_format_date_month_full_name :: proc(t: ^testing.T) {
|
||||||
dt := Date_Components{year = 2026, month = 3, day = 15}
|
dt := Date_Components {
|
||||||
|
year = 2026,
|
||||||
|
month = 3,
|
||||||
|
day = 15,
|
||||||
|
}
|
||||||
result := format_date(dt, "January")
|
result := format_date(dt, "January")
|
||||||
testing.expect_value(t, result, "March")
|
testing.expect_value(t, result, "March")
|
||||||
}
|
}
|
||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
test_format_date_two_digit_year :: proc(t: ^testing.T) {
|
test_format_date_two_digit_year :: proc(t: ^testing.T) {
|
||||||
dt := Date_Components{year = 2026, month = 3, day = 15}
|
dt := Date_Components {
|
||||||
|
year = 2026,
|
||||||
|
month = 3,
|
||||||
|
day = 15,
|
||||||
|
}
|
||||||
result := format_date(dt, "06")
|
result := format_date(dt, "06")
|
||||||
testing.expect_value(t, result, "26")
|
testing.expect_value(t, result, "26")
|
||||||
}
|
}
|
||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
test_format_date_hour24_padded :: proc(t: ^testing.T) {
|
test_format_date_hour24_padded :: proc(t: ^testing.T) {
|
||||||
midnight := Date_Components{year = 2026, month = 1, day = 1, hour = 0}
|
midnight := Date_Components {
|
||||||
|
year = 2026,
|
||||||
|
month = 1,
|
||||||
|
day = 1,
|
||||||
|
hour = 0,
|
||||||
|
}
|
||||||
testing.expect_value(t, format_date(midnight, "15"), "00")
|
testing.expect_value(t, format_date(midnight, "15"), "00")
|
||||||
|
|
||||||
afternoon := Date_Components{year = 2026, month = 1, day = 1, hour = 13}
|
afternoon := Date_Components {
|
||||||
|
year = 2026,
|
||||||
|
month = 1,
|
||||||
|
day = 1,
|
||||||
|
hour = 13,
|
||||||
|
}
|
||||||
testing.expect_value(t, format_date(afternoon, "15"), "13")
|
testing.expect_value(t, format_date(afternoon, "15"), "13")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +173,12 @@ test_format_date_hour12_padded_am_pm_boundaries :: proc(t: ^testing.T) {
|
|||||||
}{{0, "12 AM"}, {12, "12 PM"}, {13, "01 PM"}, {23, "11 PM"}}
|
}{{0, "12 AM"}, {12, "12 PM"}, {13, "01 PM"}, {23, "11 PM"}}
|
||||||
|
|
||||||
for &c in cases {
|
for &c in cases {
|
||||||
dt := Date_Components{year = 2026, month = 1, day = 1, hour = c.hour}
|
dt := Date_Components {
|
||||||
|
year = 2026,
|
||||||
|
month = 1,
|
||||||
|
day = 1,
|
||||||
|
hour = c.hour,
|
||||||
|
}
|
||||||
result := format_date(dt, "03 PM")
|
result := format_date(dt, "03 PM")
|
||||||
testing.expect_value(t, result, c.expected)
|
testing.expect_value(t, result, c.expected)
|
||||||
}
|
}
|
||||||
@@ -155,32 +186,62 @@ test_format_date_hour12_padded_am_pm_boundaries :: proc(t: ^testing.T) {
|
|||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
test_format_date_hour12_unpadded :: proc(t: ^testing.T) {
|
test_format_date_hour12_unpadded :: proc(t: ^testing.T) {
|
||||||
one_am := Date_Components{year = 2026, month = 1, day = 1, hour = 1}
|
one_am := Date_Components {
|
||||||
|
year = 2026,
|
||||||
|
month = 1,
|
||||||
|
day = 1,
|
||||||
|
hour = 1,
|
||||||
|
}
|
||||||
testing.expect_value(t, format_date(one_am, "3"), "1")
|
testing.expect_value(t, format_date(one_am, "3"), "1")
|
||||||
|
|
||||||
one_pm := Date_Components{year = 2026, month = 1, day = 1, hour = 13}
|
one_pm := Date_Components {
|
||||||
|
year = 2026,
|
||||||
|
month = 1,
|
||||||
|
day = 1,
|
||||||
|
hour = 13,
|
||||||
|
}
|
||||||
testing.expect_value(t, format_date(one_pm, "3"), "1")
|
testing.expect_value(t, format_date(one_pm, "3"), "1")
|
||||||
}
|
}
|
||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
test_format_date_am_pm_lowercase :: proc(t: ^testing.T) {
|
test_format_date_am_pm_lowercase :: proc(t: ^testing.T) {
|
||||||
afternoon := Date_Components{year = 2026, month = 1, day = 1, hour = 13}
|
afternoon := Date_Components {
|
||||||
|
year = 2026,
|
||||||
|
month = 1,
|
||||||
|
day = 1,
|
||||||
|
hour = 13,
|
||||||
|
}
|
||||||
testing.expect_value(t, format_date(afternoon, "pm"), "pm")
|
testing.expect_value(t, format_date(afternoon, "pm"), "pm")
|
||||||
|
|
||||||
morning := Date_Components{year = 2026, month = 1, day = 1, hour = 9}
|
morning := Date_Components {
|
||||||
|
year = 2026,
|
||||||
|
month = 1,
|
||||||
|
day = 1,
|
||||||
|
hour = 9,
|
||||||
|
}
|
||||||
testing.expect_value(t, format_date(morning, "pm"), "am")
|
testing.expect_value(t, format_date(morning, "pm"), "am")
|
||||||
}
|
}
|
||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
test_format_date_minute_second_padding :: proc(t: ^testing.T) {
|
test_format_date_minute_second_padding :: proc(t: ^testing.T) {
|
||||||
dt := Date_Components{year = 2026, month = 1, day = 1, minute = 4, second = 5}
|
dt := Date_Components {
|
||||||
|
year = 2026,
|
||||||
|
month = 1,
|
||||||
|
day = 1,
|
||||||
|
minute = 4,
|
||||||
|
second = 5,
|
||||||
|
}
|
||||||
testing.expect_value(t, format_date(dt, "04:05"), "04:05")
|
testing.expect_value(t, format_date(dt, "04:05"), "04:05")
|
||||||
testing.expect_value(t, format_date(dt, "4:5"), "4:5")
|
testing.expect_value(t, format_date(dt, "4:5"), "4:5")
|
||||||
}
|
}
|
||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
test_format_date_month_day_numeric_padding :: proc(t: ^testing.T) {
|
test_format_date_month_day_numeric_padding :: proc(t: ^testing.T) {
|
||||||
dt := Date_Components{year = 2026, month = 3, day = 5}
|
dt := Date_Components {
|
||||||
|
year = 2026,
|
||||||
|
month = 3,
|
||||||
|
day = 5,
|
||||||
|
}
|
||||||
testing.expect_value(t, format_date(dt, "01"), "03")
|
testing.expect_value(t, format_date(dt, "01"), "03")
|
||||||
testing.expect_value(t, format_date(dt, "1"), "3")
|
testing.expect_value(t, format_date(dt, "1"), "3")
|
||||||
testing.expect_value(t, format_date(dt, "02"), "05")
|
testing.expect_value(t, format_date(dt, "02"), "05")
|
||||||
@@ -191,14 +252,23 @@ test_format_date_month_day_numeric_padding :: proc(t: ^testing.T) {
|
|||||||
test_format_date_mst_defaults_utc :: proc(t: ^testing.T) {
|
test_format_date_mst_defaults_utc :: proc(t: ^testing.T) {
|
||||||
// Date_Components constructed directly (not via parse_iso_date)
|
// Date_Components constructed directly (not via parse_iso_date)
|
||||||
// defaults to UTC for the MST token.
|
// defaults to UTC for the MST token.
|
||||||
dt := Date_Components{year = 2026, month = 1, day = 1, hour = 12}
|
dt := Date_Components {
|
||||||
|
year = 2026,
|
||||||
|
month = 1,
|
||||||
|
day = 1,
|
||||||
|
hour = 12,
|
||||||
|
}
|
||||||
result := format_date(dt, "MST")
|
result := format_date(dt, "MST")
|
||||||
testing.expect_value(t, result, "UTC")
|
testing.expect_value(t, result, "UTC")
|
||||||
}
|
}
|
||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
test_format_date_literal_passthrough :: proc(t: ^testing.T) {
|
test_format_date_literal_passthrough :: proc(t: ^testing.T) {
|
||||||
dt := Date_Components{year = 2026, month = 1, day = 1}
|
dt := Date_Components {
|
||||||
|
year = 2026,
|
||||||
|
month = 1,
|
||||||
|
day = 1,
|
||||||
|
}
|
||||||
result := format_date(dt, "Year: 2006!")
|
result := format_date(dt, "Year: 2006!")
|
||||||
testing.expect_value(t, result, "Year: 2026!")
|
testing.expect_value(t, result, "Year: 2026!")
|
||||||
}
|
}
|
||||||
@@ -206,7 +276,14 @@ test_format_date_literal_passthrough :: proc(t: ^testing.T) {
|
|||||||
@(test)
|
@(test)
|
||||||
test_format_date_combined_go_reference_layout :: proc(t: ^testing.T) {
|
test_format_date_combined_go_reference_layout :: proc(t: ^testing.T) {
|
||||||
// 2023-10-15 is a Sunday.
|
// 2023-10-15 is a Sunday.
|
||||||
dt := Date_Components{year = 2023, month = 10, day = 15, hour = 13, minute = 18, second = 50}
|
dt := Date_Components {
|
||||||
|
year = 2023,
|
||||||
|
month = 10,
|
||||||
|
day = 15,
|
||||||
|
hour = 13,
|
||||||
|
minute = 18,
|
||||||
|
second = 50,
|
||||||
|
}
|
||||||
result := format_date(dt, "Mon Jan 2 15:04:05 MST 2006")
|
result := format_date(dt, "Mon Jan 2 15:04:05 MST 2006")
|
||||||
testing.expect_value(t, result, "Sun Oct 15 13:18:50 UTC 2023")
|
testing.expect_value(t, result, "Sun Oct 15 13:18:50 UTC 2023")
|
||||||
}
|
}
|
||||||
@@ -241,9 +318,13 @@ test_convert_to_tz_no_offset_assumes_target :: proc(t: ^testing.T) {
|
|||||||
if !tz_ok do return
|
if !tz_ok do return
|
||||||
defer timezone.region_destroy(tz, context.temp_allocator)
|
defer timezone.region_destroy(tz, context.temp_allocator)
|
||||||
|
|
||||||
c := Date_Components{
|
c := Date_Components {
|
||||||
year = 2026, month = 3, day = 15,
|
year = 2026,
|
||||||
hour = 8, minute = 49, second = 54,
|
month = 3,
|
||||||
|
day = 15,
|
||||||
|
hour = 8,
|
||||||
|
minute = 49,
|
||||||
|
second = 54,
|
||||||
}
|
}
|
||||||
result, ok := convert_to_tz(c, tz)
|
result, ok := convert_to_tz(c, tz)
|
||||||
testing.expect_value(t, ok, true)
|
testing.expect_value(t, ok, true)
|
||||||
@@ -260,9 +341,13 @@ test_convert_to_tz_with_offset_converts :: proc(t: ^testing.T) {
|
|||||||
defer timezone.region_destroy(tz, context.temp_allocator)
|
defer timezone.region_destroy(tz, context.temp_allocator)
|
||||||
|
|
||||||
// 2026-03-15T12:49:54Z (UTC) → 08:49:54 EDT (UTC-4)
|
// 2026-03-15T12:49:54Z (UTC) → 08:49:54 EDT (UTC-4)
|
||||||
c := Date_Components{
|
c := Date_Components {
|
||||||
year = 2026, month = 3, day = 15,
|
year = 2026,
|
||||||
hour = 12, minute = 49, second = 54,
|
month = 3,
|
||||||
|
day = 15,
|
||||||
|
hour = 12,
|
||||||
|
minute = 49,
|
||||||
|
second = 54,
|
||||||
offset_seconds = 0,
|
offset_seconds = 0,
|
||||||
has_offset = true,
|
has_offset = true,
|
||||||
}
|
}
|
||||||
@@ -281,9 +366,13 @@ test_convert_to_tz_with_negative_offset_converts :: proc(t: ^testing.T) {
|
|||||||
defer timezone.region_destroy(tz, context.temp_allocator)
|
defer timezone.region_destroy(tz, context.temp_allocator)
|
||||||
|
|
||||||
// 2026-03-15T08:49:54-04:00 → UTC 12:49:54 → EDT 08:49:54
|
// 2026-03-15T08:49:54-04:00 → UTC 12:49:54 → EDT 08:49:54
|
||||||
c := Date_Components{
|
c := Date_Components {
|
||||||
year = 2026, month = 3, day = 15,
|
year = 2026,
|
||||||
hour = 8, minute = 49, second = 54,
|
month = 3,
|
||||||
|
day = 15,
|
||||||
|
hour = 8,
|
||||||
|
minute = 49,
|
||||||
|
second = 54,
|
||||||
offset_seconds = -14400,
|
offset_seconds = -14400,
|
||||||
has_offset = true,
|
has_offset = true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,4 +152,3 @@ test_lambda_inverted_section :: proc(t: ^testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -981,4 +981,3 @@ warn_context_depth :: proc(current: Template, node: Node) {
|
|||||||
diag := format_error(path, current.source, node.pos, msg, "", colorize = should_colorize())
|
diag := format_error(path, current.source, node.pos, msg, "", colorize = should_colorize())
|
||||||
log.warnf("%s", diag)
|
log.warnf("%s", diag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,4 +161,3 @@ test_context_depth_warns :: proc(t: ^testing.T) {
|
|||||||
testing.expect(t, rerr == nil, "depth warning must be non-fatal")
|
testing.expect(t, rerr == nil, "depth warning must be non-fatal")
|
||||||
testing.expect_value(t, result, "found")
|
testing.expect_value(t, result, "found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -193,7 +193,7 @@ resolve_format_string :: proc(name: string, ctx: []any, pos: int) -> (string, Er
|
|||||||
return str, nil
|
return str, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: diagnostics don't show anything relevent
|
// TODO: diagnostics don't show anything relevant
|
||||||
apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) -> (any, Error) {
|
apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) -> (any, Error) {
|
||||||
switch filter.op {
|
switch filter.op {
|
||||||
case "group_by":
|
case "group_by":
|
||||||
@@ -337,4 +337,3 @@ apply_group_by :: proc(value: any, args: []string, pos: int) -> (result: any, er
|
|||||||
|
|
||||||
return groups, nil
|
return groups, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -249,7 +249,11 @@ test_interp_pipe_dot_current :: proc(t: ^testing.T) {
|
|||||||
items = {"2026-01-06T00:00:00Z", "2026-06-15T00:00:00Z", "2026-10-15T00:00:00Z"},
|
items = {"2026-01-06T00:00:00Z", "2026-06-15T00:00:00Z", "2026-10-15T00:00:00Z"},
|
||||||
date_format = "2 Jan 2006",
|
date_format = "2 Jan 2006",
|
||||||
}
|
}
|
||||||
tpl, _ := parse("{{#items}}[{{. | format}}]{{/items}}", "<test>", allocator = context.temp_allocator)
|
tpl, _ := parse(
|
||||||
|
"{{#items}}[{{. | format}}]{{/items}}",
|
||||||
|
"<test>",
|
||||||
|
allocator = context.temp_allocator,
|
||||||
|
)
|
||||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||||
testing.expect_value(t, result, "[6 Jan 2026][15 Jun 2026][15 Oct 2026]")
|
testing.expect_value(t, result, "[6 Jan 2026][15 Jun 2026][15 Oct 2026]")
|
||||||
}
|
}
|
||||||
@@ -359,7 +363,11 @@ test_format_inside_section_skips_when_empty :: proc(t: ^testing.T) {
|
|||||||
date = "",
|
date = "",
|
||||||
date_format = "2 Jan 2006",
|
date_format = "2 Jan 2006",
|
||||||
}
|
}
|
||||||
tpl, _ := parse("[{{#date}}<time>{{. | format}}</time>{{/date}}]", "<test>", allocator = context.temp_allocator)
|
tpl, _ := parse(
|
||||||
|
"[{{#date}}<time>{{. | format}}</time>{{/date}}]",
|
||||||
|
"<test>",
|
||||||
|
allocator = context.temp_allocator,
|
||||||
|
)
|
||||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||||
testing.expect_value(t, result, "[]")
|
testing.expect_value(t, result, "[]")
|
||||||
}
|
}
|
||||||
@@ -374,7 +382,11 @@ test_format_quoted_literal_arg :: proc(t: ^testing.T) {
|
|||||||
date = "2026-03-15T08:49:54-04:00",
|
date = "2026-03-15T08:49:54-04:00",
|
||||||
date_format = "2 Jan 2006",
|
date_format = "2 Jan 2006",
|
||||||
}
|
}
|
||||||
tpl, _ := parse(`{{date | format "Jan 2, 2006"}}`, "<test>", allocator = context.temp_allocator)
|
tpl, _ := parse(
|
||||||
|
`{{date | format "Jan 2, 2006"}}`,
|
||||||
|
"<test>",
|
||||||
|
allocator = context.temp_allocator,
|
||||||
|
)
|
||||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||||
testing.expect_value(t, result, "Mar 15, 2026")
|
testing.expect_value(t, result, "Mar 15, 2026")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,4 +132,3 @@ spec_dynamic_names :: proc(t: ^testing.T) {
|
|||||||
spec_inheritance :: proc(t: ^testing.T) {
|
spec_inheritance :: proc(t: ^testing.T) {
|
||||||
run_spec_file(t, "spec/specs/~inheritance.json")
|
run_spec_file(t, "spec/specs/~inheritance.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -184,10 +184,7 @@ collect_partial_names :: proc(
|
|||||||
|
|
||||||
// collect_block_names enumerates the unique `{{$name}}` block definitions in
|
// collect_block_names enumerates the unique `{{$name}}` block definitions in
|
||||||
// a template's node array.
|
// a template's node array.
|
||||||
collect_block_names :: proc(
|
collect_block_names :: proc(tmpl: Template, allocator := context.temp_allocator) -> []string {
|
||||||
tmpl: Template,
|
|
||||||
allocator := context.temp_allocator,
|
|
||||||
) -> []string {
|
|
||||||
out := make([dynamic]string, 0, 0, allocator)
|
out := make([dynamic]string, 0, 0, allocator)
|
||||||
seen := make(map[string]bool, allocator)
|
seen := make(map[string]bool, allocator)
|
||||||
defer delete(seen)
|
defer delete(seen)
|
||||||
|
|||||||
@@ -196,4 +196,3 @@ test_warn_no_false_positive_for_valid_keys :: proc(t: ^testing.T) {
|
|||||||
ok, missing, _ := validate_key_path(ctx[:], "name")
|
ok, missing, _ := validate_key_path(ctx[:], "name")
|
||||||
testing.expect_value(t, ok, true)
|
testing.expect_value(t, ok, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-12
@@ -104,11 +104,7 @@ tokenize :: proc(
|
|||||||
|
|
||||||
close_idx := strings.index(src[key_start:], "}}")
|
close_idx := strings.index(src[key_start:], "}}")
|
||||||
if close_idx < 0 {
|
if close_idx < 0 {
|
||||||
return tokens, Error_Body {
|
return tokens, Error_Body{msg = "unclosed tag '{{'", pos = tag_pos, kind = .Syntax}
|
||||||
msg = "unclosed tag '{{'",
|
|
||||||
pos = tag_pos,
|
|
||||||
kind = .Syntax,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
close := key_start + close_idx
|
close := key_start + close_idx
|
||||||
|
|
||||||
@@ -125,12 +121,7 @@ tokenize :: proc(
|
|||||||
}
|
}
|
||||||
append(
|
append(
|
||||||
&tokens,
|
&tokens,
|
||||||
Token {
|
Token{kind = .Partial, value = trimmed, is_dynamic = is_dyn, pos = tag_pos},
|
||||||
kind = .Partial,
|
|
||||||
value = trimmed,
|
|
||||||
is_dynamic = is_dyn,
|
|
||||||
pos = tag_pos,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
append(
|
append(
|
||||||
@@ -299,4 +290,3 @@ should_trim_whitespace :: proc(kind: Token_Kind) -> bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,4 +115,3 @@ og_for_page :: proc(site_og: Open_Graph, page: Page) -> Open_Graph {
|
|||||||
|
|
||||||
return og
|
return og
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-7
@@ -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
|
||||||
}
|
}
|
||||||
return s
|
fallthrough
|
||||||
|
case:
|
||||||
|
capitalize_next = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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"
|
||||||
@@ -385,4 +404,3 @@ write_file :: proc(path: string, html: string) {
|
|||||||
log.errorf("cannot write %s: %v", path, err)
|
log.errorf("cannot write %s: %v", path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
@@ -262,4 +273,3 @@ find_config :: proc(filename: string) -> (path: string, ok: bool) {
|
|||||||
dir = dir[:idx]
|
dir = dir[:idx]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -181,12 +181,15 @@ test_init_site_md_enable_disable :: proc(t: ^testing.T) {
|
|||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
test_init_site_config_paths :: proc(t: ^testing.T) {
|
test_init_site_config_paths :: proc(t: ^testing.T) {
|
||||||
path := write_temp_config("paths", `{
|
path := write_temp_config(
|
||||||
|
"paths",
|
||||||
|
`{
|
||||||
"content_dir": "/custom/content",
|
"content_dir": "/custom/content",
|
||||||
"assets_dir": "/custom/assets",
|
"assets_dir": "/custom/assets",
|
||||||
"output_dir": "/custom/output",
|
"output_dir": "/custom/output",
|
||||||
"layouts_dir": "/custom/layouts"
|
"layouts_dir": "/custom/layouts"
|
||||||
}`)
|
}`,
|
||||||
|
)
|
||||||
defer os.remove(path)
|
defer os.remove(path)
|
||||||
|
|
||||||
site: Site
|
site: Site
|
||||||
@@ -199,4 +202,3 @@ test_init_site_config_paths :: proc(t: ^testing.T) {
|
|||||||
testing.expect_value(t, site.output_dir, "/custom/output")
|
testing.expect_value(t, site.output_dir, "/custom/output")
|
||||||
testing.expect_value(t, site.layouts_dir, "/custom/layouts")
|
testing.expect_value(t, site.layouts_dir, "/custom/layouts")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -249,7 +249,14 @@ ensure_parser :: proc(lang: string) -> ^Grammar_Cache {
|
|||||||
return gc
|
return gc
|
||||||
}
|
}
|
||||||
|
|
||||||
compile_query :: proc(lang: string, language: Language) -> (query: Query, cursor: Query_Cursor, ok: bool) {
|
compile_query :: proc(
|
||||||
|
lang: string,
|
||||||
|
language: Language,
|
||||||
|
) -> (
|
||||||
|
query: Query,
|
||||||
|
cursor: Query_Cursor,
|
||||||
|
ok: bool,
|
||||||
|
) {
|
||||||
query_src, query_path, qok := load_query(lang)
|
query_src, query_path, qok := load_query(lang)
|
||||||
if !qok {
|
if !qok {
|
||||||
return
|
return
|
||||||
@@ -451,4 +458,3 @@ helix_version_from_path :: proc(path: string) -> string {
|
|||||||
if end <= start do return ""
|
if end <= start do return ""
|
||||||
return path[start:end]
|
return path[start:end]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user