Compare commits

...

3 Commits

Author SHA1 Message Date
Spencer Brower 770a04481d feat(mustache): Added group_by filter to templates 2026-07-20 12:44:40 -04:00
Spencer Brower 29d244c1a6 fix: Fixed issue with double-any wrapping. 2026-07-19 14:55:31 -04:00
Spencer Brower efe0c42e5c chore: Updated TODOS.md 2026-07-19 14:28:11 -04:00
11 changed files with 565 additions and 195 deletions
+4 -1
View File
@@ -123,6 +123,8 @@ Page_Data :: struct {
`render_site` pre-parses all templates and partials once (via `mustache.parse`), then reuses them for every page render.
**Pipes extension**: section tags may transform the resolved value before iteration via `{{#key | op args…}}`. Pipe filters are parsed into `Node.filters` (allocator-owned by the template) and applied at render time via `apply_pipeline`, with filter results (e.g. `[dynamic]Group` from `group_by`) living in `context.temp_allocator`. Currently only `group_by <field>` is implemented; see `mustache/EXTENSIONS.md`. Used by `posts_index.html` to group posts by year without privileged Go-side data shaping.
### Markdown pipeline (in content.odin `load_page`)
```
@@ -209,7 +211,7 @@ odin test . -all-packages # includes mustache spec tests
## Mustache engine
Spec-compliant Mustache implementation at `mustache/`. See `mustache/SPEC.md` for the implementation specification.
Spec-compliant Mustache implementation at `mustache/`. See `mustache/EXTENSIONS.md` for the non-standard extensions (pipes).
### Files
@@ -218,6 +220,7 @@ Spec-compliant Mustache implementation at `mustache/`. See `mustache/SPEC.md` fo
| `mustache.odin` | Public API (`parse`, `render`, `Template`), parser (`parse_section`), renderer (`render_nodes`), template inheritance (`merge_block_overrides`) |
| `tokenizer.odin` | Tokenizer (template string → `[]Token`), standalone whitespace detection |
| `data.odin` | Reflection-based data model: `effective` (union/distinct peeling), `lookup_in`, `resolve_name`, `is_truthy`, `any_to_string`, `list_info`, `write_value` |
| `pipes.odin` | Pipes extension: `Pipe_Filter` AST, `parse_pipeline`, `apply_pipeline`, `apply_group_by`. Stored on `Node.filters`; render-scoped results in temp allocator. |
| `spec_test.odin` | JSON spec test runner — loads `spec/specs/*.json`, runs each test case |
### Architecture
-141
View File
@@ -1,141 +0,0 @@
# Plan: Computed Properties in Mustache
## Problem
`Year_Section` is a privileged, hardcoded struct in render.odin. Every section listing gets pages grouped by year — no choice. The grouping dimension (year) is baked into the code. Templates can't request different views of the same data.
## Solution: Computed Properties
Struct fields that are procs get called during mustache data resolution. When `lookup_in` resolves a field via reflection and finds a proc, it calls it and uses the return value. This is computed properties (Vue/Ember pattern), not spec lambdas (text transformers).
No mustache syntax changes. `{{#posts.by_year}}` is vanilla mustache — the magic is in data resolution, not template syntax.
## Template Usage
```handlebars
{{#posts.by_year}}
<h2>{{year}}</h2>
{{#posts}}
<li>{{title}}</li>
{{/posts}}
{{/posts.by_year}}
{{#posts.all}}
<li>{{title}}</li>
{{/posts.all}}
```
Context resolution handles naming naturally — `posts` inside a Year_Group (the `.posts` field) shadows the outer `posts` view object.
## Data Model
```odin
Year_Group :: struct {
year: string,
posts: [dynamic]Page_Context,
}
Pages_View :: struct {
all: [dynamic]Page_Context,
by_year: proc() -> [dynamic]Year_Group,
}
Section_Data :: struct {
using base: Base_Data,
page_title: string,
posts: Pages_View,
}
```
`by_year` is a closure that captures `all` and groups lazily. Grouping only happens when the template requests it.
## Implementation Steps
### 1. Mustache data layer (`mustache/data.odin`)
In `lookup_in` (or `resolve_name`), after resolving a field value via reflection:
```odin
// If the resolved value is a proc, call it
if value, ok := resolved.(proc() -> any); ok {
return value()
}
```
Need to handle proc detection generically — Odin has many proc types (different signatures, calling conventions). The computed properties we use are all zero-argument procs returning `any`.
Alternative: check via `reflect.Type_Info` if the value is a proc type, then call it through `reflect.CallProcedure` or transmute.
### 2. Odin closure challenge
Contextless procs can't capture variables. Regular closure procs capture context by reference. We need the proc to access the page list when called later by mustache.
Options:
- Store the data alongside the proc in the struct (e.g., `all` field on `Pages_View`). The proc accesses it via the struct instance.
- Use `context.allocator` to store captured data that the closure references.
- Pass the struct as an implicit `self` parameter (method-style).
The simplest approach: the closure captures a pointer to the data, which is arena-allocated and alive during rendering.
### 3. Build `Pages_View` in `render_section`
```odin
all_pages := make([dynamic]Page_Context)
for page in site.pages {
if page.section != section || page._is_index { continue }
append(&all_pages, build_page_context(page))
}
view := Pages_View{
all = all_pages,
by_year = group_by_year_closure, // captures all_pages
}
```
### 4. Remove `Year_Section` and `year_sections`
- Delete `Year_Section` struct
- Remove `year_sections` from `Section_Data`
- Remove year grouping logic from `render_section`
- Delete `get_year` helper (or move it into the closure)
### 5. Update templates
`posts_index.html`:
```handlebars
{{#posts.by_year}}
<section>
<h2>{{year}}</h2>
<ul class="post-list">
{{#posts}} <li>...</li> {{/posts}}
</ul>
</section>
{{/posts.by_year}}
```
Home page could use the same pattern:
```handlebars
{{#pages.all}}
<li>...</li>
{{/pages.all}}
```
### 6. Tests
- Verify `by_year` proc is called lazily (only when template requests it)
- Verify year groups contain correct pages
- Verify flat list (`all`) works independently
## Prerequisite
**Step 1 (mustache data layer) must be done first.** This is the foundation — without proc-calling in data resolution, none of the rest works.
## Future Extensions
Once computed properties work, they're available everywhere:
- `posts.featured` — filter by starred
- `posts.recent(n)` — most recent N posts (if we support params)
- `site.tags` — all unique tags across posts
- `pages.by_section("posts")` — filter by section
These are all zero-argument procs on view structs. No mustache changes needed beyond step 1.
+5 -2
View File
@@ -32,14 +32,17 @@
- [x] mustache data keys for opengraph, etc.
- [ ] OpenGraph meta tags — verify all fields match production site
- [ ] set opengraph tags / description automatically if unset. (Like hugo does)
- [ ] can't set avatar.jpg directly in `og_init`.
- [ ] We can't use avatar.jpg as the default site image, that's unique to sbrow.github.io. We need to set that in the frontmatter of content/index.html. or possibly in the config
- [ ] Add `og Open_Graph` to `Config_File` and if `Some`, use it as the base
site og instead of `og_init()`?
- If we go this route, `og_init` might not be the best name.
- [ ] Author should be a struct adhering to https://schema.org/author
- [ ] Block attributes on code fences (`{ #ex-1 }`) — hello-world.md
- [ ] include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin
- [ ] follow symlinks in `scan_content`?
- [ ] ensure sidenote numbers render in display order and not in declaration order.
- [ ] Add Opt-in deflist support.
- [ ] We need to be able to do `Year_Section` in a non-magical, unprivileged way. See [PLAN.md](PLAN.md) for computed properties approach.
- [x] We need to be able to do `Year_Section` in a non-magical, unprivileged way. Implemented via the pipes extension to mustache — see [mustache/EXTENSIONS.md](mustache/EXTENSIONS.md).
- [ ] Table of contents support.
- [ ] Nav items should be active when the current page is selected.
- [ ] Theme selector for syntax highlighting.
+5 -5
View File
@@ -3,17 +3,17 @@
<main>
<h1>{{page_title}}</h1>
{{&body}}
{{#by_year}}
{{#posts | group_by year}}
<section>
<h2>{{year}}</h2>
<h2>{{key}}</h2>
<ul>
{{#posts}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date_iso}}<time
{{#items}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date_iso}}<time
datetime="{{date_iso}}">{{date_display}}</time>{{/date_iso}}</span>
</li>
{{/posts}}
{{/items}}
</ul>
</section>
{{/by_year}}
{{/posts}}
</main>
{{/content}}
{{/base}}
+73
View File
@@ -0,0 +1,73 @@
# Mustache Extensions
Thor's mustache engine implements the [Mustache spec](https://github.com/mustache/spec) plus the following non-standard extensions. Extensions are opt-in via template syntax — vanilla mustache templates render identically to the spec.
## Pipes
A pipe expression appears inside a section tag and transforms the resolved value before iteration. Pipes let templates request different views of the same data without privileged Go-side support.
### Syntax
```
{{#<key> | <op> <arg> <arg>... | <op> <arg>... }} … {{/<key>}}
```
- The first whitespace-separated token after `|` is the op name; remaining tokens are its arguments.
- Whitespace around `|` is optional: `posts|group_by year` and `posts | group_by year` are equivalent.
- Multiple filters compose left-to-right.
- **At most 8 filters** may appear in a single tag (compile-time constant `MAX_PIPES` in `pipes.odin`). Exceeding it is a parse error.
- **At most 2 args** per filter (compile-time constant `MAX_PIPE_ARGS`). Exceeding it is a parse error.
- The close tag is the **bare key only**. Pipe expressions are not allowed in close tags — `{{/posts | group_by year}}` is a parse error.
- Pipes are only supported in section tags (`{{#…}}` and `{{^…}}`) today. Variable interpolation (`{{x | op}}`) is a parse error.
### Example
```handlebars
{{#posts | group_by year}}
<section>
<h2>{{key}}</h2>
<ul>{{#items}}<li>{{title}}</li>{{/items}}</ul>
</section>
{{/posts}}
```
### Available ops
#### `group_by <field>`
Buckets each element of a list by the value of `<field>`. Returns a list of `Group` values, each shaped as:
```
Group :: struct {
key: string, // the distinct field value
items: [dynamic]any, // elements sharing that value
}
```
Group order preserves first-appearance order in the input list (not key-sorted).
Errors (returned as `Data_Error` at render time):
- Argument count is not 1.
- Input is not a list.
- Any element is missing the named field.
- Any element has an empty value for the named field.
### Memory ownership
- **Parsed pipe filters** (`Pipe_Filter` values) are stored inline on each `Node` via `[dynamic; MAX_PIPES]Pipe_Filter`, and `args` is inline on each `Pipe_Filter` via `[dynamic; MAX_PIPE_ARGS]string`. Both use Odin's fixed-capacity dynamic array type, so no per-tag heap allocations occur at parse time. The storage dies with the `Template` when `delete_template` is called.
- **Filter results** (e.g. the `[dynamic]Group` returned by `group_by`) are render-scoped allocations in `context.temp_allocator`. They die with the render call. No caller-side cleanup is needed.
- The string data inside `Pipe_Filter` (op names, args) is borrowed from the template source — no cloning.
### Future ops (not yet implemented)
The pipe framework is general; additional ops are straightforward to add to `apply_filter` in `pipes.odin`:
- `sort`, `sort_by <field>` — ordering
- `filter <field> <value>`, `where <field>` — selection
- `take <n>`, `take_last <n>`, `skip <n>` — slicing
- `reverse` — order flip
To add a new op:
1. Implement `apply_<op>(value: any, args: []string) -> (any, Render_Error)` in `pipes.odin`.
2. Add a `case` to `apply_filter`.
3. Add tests to `pipes_test.odin`.
+20 -3
View File
@@ -64,7 +64,11 @@ lookup_in :: proc(container: any, key: string) -> (result: any, found: bool) {
h := mi.map_info.key_hasher(&k, seed)
value_ptr := runtime.__dynamic_map_get(rm_ptr, mi.map_info, h, &k)
if value_ptr != nil {
if _, ok := mi.value.variant.(runtime.Type_Info_Any); ok {
result = (^any)(value_ptr)^
} else {
result = any{value_ptr, mi.value.id}
}
found = true
}
}
@@ -170,11 +174,11 @@ call_interp_lambda :: proc(val: any) -> (result: string, ok: bool) {
call_section_lambda :: proc(val: any, text: string) -> (result: string, ok: bool) {
switch v in val {
case proc(string) -> string:
case proc(_: string) -> string:
return v(text), true
case proc(string) -> int:
case proc(_: string) -> int:
return fmt.tprintf("%d", v(text)), true
case proc(string) -> bool:
case proc(_: string) -> bool:
return "true" if v(text) else "false", true
case:
return "", false
@@ -203,6 +207,19 @@ list_info :: proc(a: any) -> (elem_info: ^runtime.Type_Info, count: int, data: r
}
}
// extract_list_element returns the n-th element of a list (described by
// elem_info/data from list_info) as a ready-to-use any.
//
// Unwraps [dynamic]any element types so downstream lookup_in sees the inner
// value's real type, not a double-wrapped any-of-any.
extract_list_element :: proc(elem_info: ^runtime.Type_Info, data: rawptr, n: int) -> any {
elem_ptr := rawptr(uintptr(data) + uintptr(n) * uintptr(elem_info.size))
if _, is_any := elem_info.variant.(runtime.Type_Info_Any); is_any {
return (^any)(elem_ptr)^
}
return any{elem_ptr, elem_info.id}
}
// any_to_string converts a scalar value to a string using the temp allocator.
any_to_string :: proc(a: any) -> string {
if a == nil {
+80 -18
View File
@@ -44,6 +44,7 @@ Node :: struct {
kind: Node_Kind,
text: string,
key: string,
filters: [dynamic; MAX_PIPES]Pipe_Filter,
is_dynamic: bool,
indent: string,
first_child: int,
@@ -74,7 +75,7 @@ Block_Override :: struct {
count: int,
}
template_free :: proc(tmpl: ^Template) {
delete_template :: proc(tmpl: ^Template) {
if tmpl != nil && len(tmpl.nodes) > 0 {
delete(tmpl.nodes)
}
@@ -82,7 +83,7 @@ template_free :: proc(tmpl: ^Template) {
delete_partials :: proc(partials: map[string]Template) {
for _, &p in partials {
template_free(&p)
delete_template(&p)
}
delete(partials)
}
@@ -157,7 +158,7 @@ parse_tokens :: proc(
) {
nodes = make([dynamic]Node, 0, len(tokens), allocator)
pos := 0
err = parse_section(tokens, &pos, &nodes, "", source)
err = parse_section(tokens, &pos, &nodes, "", source, allocator)
return
}
@@ -167,6 +168,7 @@ parse_section :: proc(
nodes: ^[dynamic]Node,
end_tag: string,
source: string,
allocator := context.allocator,
) -> Render_Error {
for pos^ < len(tokens) {
tok := tokens[pos^]
@@ -191,11 +193,19 @@ parse_section :: proc(
pos^ += 1
idx := len(nodes)
content_start := 0
if pos^ < len(tokens) { content_start = tokens[pos^].pos }
append(nodes, Node{kind = .Section, key = tok.value, first_child = -1})
parse_section(tokens, pos, nodes, tok.value, source) or_return
if pos^ < len(tokens) {content_start = tokens[pos^].pos}
append(nodes, Node{kind = .Section, first_child = -1})
pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters)
if perr != nil {
return Syntax_Error {
msg = fmt.tprintf("pipe parse error in '{{#%s}}': %v", tok.value, perr),
pos = tok.pos,
}
}
nodes[idx].key = pipe_key
parse_section(tokens, pos, nodes, pipe_key, source, allocator) or_return
close_pos := 0
if pos^ - 1 >= 0 && pos^ - 1 < len(tokens) { close_pos = tokens[pos^ - 1].pos }
if pos^ - 1 >= 0 && pos^ - 1 < len(tokens) {close_pos = tokens[pos^ - 1].pos}
nodes[idx].first_child = idx + 1
nodes[idx].child_count = len(nodes) - idx - 1
nodes[idx].content = source[content_start:close_pos]
@@ -204,16 +214,33 @@ parse_section :: proc(
pos^ += 1
idx := len(nodes)
content_start := 0
if pos^ < len(tokens) { content_start = tokens[pos^].pos }
append(nodes, Node{kind = .Inverted, key = tok.value, first_child = -1})
parse_section(tokens, pos, nodes, tok.value, source) or_return
if pos^ < len(tokens) {content_start = tokens[pos^].pos}
append(nodes, Node{kind = .Inverted, first_child = -1})
pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters)
if perr != nil {
return Syntax_Error {
msg = fmt.tprintf("pipe parse error in '{{^%s}}': %v", tok.value, perr),
pos = tok.pos,
}
}
nodes[idx].key = pipe_key
parse_section(tokens, pos, nodes, pipe_key, source, allocator) or_return
close_pos := 0
if pos^ - 1 >= 0 && pos^ - 1 < len(tokens) { close_pos = tokens[pos^ - 1].pos }
if pos^ - 1 >= 0 && pos^ - 1 < len(tokens) {close_pos = tokens[pos^ - 1].pos}
nodes[idx].first_child = idx + 1
nodes[idx].child_count = len(nodes) - idx - 1
nodes[idx].content = source[content_start:close_pos]
case .Section_Close:
if strings.contains(tok.value, "|") {
return Syntax_Error {
msg = fmt.tprintf(
"pipe expression not allowed in close tag '{{/%s}}' — use the bare key",
tok.value,
),
pos = tok.pos,
}
}
if end_tag != "" && tok.value == end_tag {
pos^ += 1
return nil
@@ -249,7 +276,7 @@ parse_section :: proc(
nodes,
Node{kind = .Parent, key = tok.value, indent = tok.indent, first_child = -1},
)
parse_section(tokens, pos, nodes, tok.value, source) or_return
parse_section(tokens, pos, nodes, tok.value, source, allocator) or_return
nodes[idx].first_child = idx + 1
nodes[idx].child_count = len(nodes) - idx - 1
@@ -260,7 +287,7 @@ parse_section :: proc(
nodes,
Node{kind = .Block, key = tok.value, indent = tok.indent, first_child = -1},
)
parse_section(tokens, pos, nodes, tok.value, source) or_return
parse_section(tokens, pos, nodes, tok.value, source, allocator) or_return
nodes[idx].first_child = idx + 1
nodes[idx].child_count = len(nodes) - idx - 1
}
@@ -465,7 +492,14 @@ render_nodes :: proc(
if perr == nil {
temp: strings.Builder
strings.builder_init(&temp, context.temp_allocator)
render_nodes(sub_tpl.nodes[:], sub_tpl.nodes[:], ctx, partials, &temp, blocks) or_return
render_nodes(
sub_tpl.nodes[:],
sub_tpl.nodes[:],
ctx,
partials,
&temp,
blocks,
) or_return
write_value(b, strings.to_string(temp), escape = true)
}
} else {
@@ -480,7 +514,14 @@ render_nodes :: proc(
if perr == nil {
temp: strings.Builder
strings.builder_init(&temp, context.temp_allocator)
render_nodes(sub_tpl.nodes[:], sub_tpl.nodes[:], ctx, partials, &temp, blocks) or_return
render_nodes(
sub_tpl.nodes[:],
sub_tpl.nodes[:],
ctx,
partials,
&temp,
blocks,
) or_return
write_value(b, strings.to_string(temp), escape = false)
}
} else {
@@ -490,18 +531,32 @@ render_nodes :: proc(
case .Section:
val := resolve_name(node.key, ctx[:])
if len(node.filters) > 0 {
transformed, perr := apply_pipeline(val, node.filters[:])
if perr != nil {
return perr
}
val = transformed
}
if result_str, ok := call_section_lambda(val, node.content); ok {
sub_tpl, perr := parse(result_str, context.temp_allocator, context.temp_allocator)
if perr == nil {
render_nodes(sub_tpl.nodes[:], sub_tpl.nodes[:], ctx, partials, b, blocks) or_return
render_nodes(
sub_tpl.nodes[:],
sub_tpl.nodes[:],
ctx,
partials,
b,
blocks,
) or_return
}
} else if is_truthy(val) {
children := all_nodes[node.first_child:node.first_child + node.child_count]
elem_info, count, data := list_info(val)
if elem_info != nil {
for j in 0 ..< count {
elem_ptr := rawptr(uintptr(data) + uintptr(j) * uintptr(elem_info.size))
append(ctx, any{elem_ptr, elem_info.id})
elem := extract_list_element(elem_info, data, j)
append(ctx, elem)
defer pop(ctx)
render_nodes(all_nodes, children, ctx, partials, b, blocks) or_return
}
@@ -515,6 +570,13 @@ render_nodes :: proc(
case .Inverted:
val := resolve_name(node.key, ctx[:])
if len(node.filters) > 0 {
transformed, perr := apply_pipeline(val, node.filters[:])
if perr != nil {
return perr
}
val = transformed
}
if !is_truthy(val) {
children := all_nodes[node.first_child:node.first_child + node.child_count]
render_nodes(all_nodes, children, ctx, partials, b, blocks) or_return
+8 -8
View File
@@ -8,7 +8,7 @@ import "core:testing"
leak_parse_free :: proc(t: ^testing.T) {
tmpl, err := parse("Hello {{name}}!")
testing.expect(t, err == nil)
defer template_free(&tmpl)
defer delete_template(&tmpl)
}
@(test)
@@ -20,7 +20,7 @@ leak_parse_free_tokens :: proc(t: ^testing.T) {
tmpl, err := parse("Hello {{name}}!", context.allocator, mem.dynamic_arena_allocator(&arena))
testing.expect(t, err == nil)
defer template_free(&tmpl)
defer delete_template(&tmpl)
}
@(test)
@@ -36,7 +36,7 @@ leak_render :: proc(t: ^testing.T) {
}
tmpl, err := parse("Hello {{name}}!")
testing.expect(t, err == nil)
defer template_free(&tmpl)
defer delete_template(&tmpl)
result, rerr := render(tmpl, Data{name = "World"})
testing.expect(t, rerr == nil)
@@ -48,7 +48,7 @@ leak_render_partials :: proc(t: ^testing.T) {
partials := make_map(map[string]Template)
defer {
for _, &p in partials {
template_free(&p)
delete_template(&p)
}
delete(partials)
}
@@ -59,7 +59,7 @@ leak_render_partials :: proc(t: ^testing.T) {
tmpl, err := parse("Hello {{> name}}!")
testing.expect(t, err == nil)
defer template_free(&tmpl)
defer delete_template(&tmpl)
Data :: struct {
name: string,
@@ -73,7 +73,7 @@ leak_render_partials :: proc(t: ^testing.T) {
leak_render_sections :: proc(t: ^testing.T) {
tmpl, err := parse("{{#items}}{{.}}{{/items}}")
testing.expect(t, err == nil)
defer template_free(&tmpl)
defer delete_template(&tmpl)
Data :: struct {
items: [3]string,
@@ -96,7 +96,7 @@ leak_render_inheritance :: proc(t: ^testing.T) {
tmpl_src := "{{<layout}}{{$title}}custom{{/title}}{{/layout}}"
tmpl, err := parse(tmpl_src)
testing.expect(t, err == nil)
defer template_free(&tmpl)
defer delete_template(&tmpl)
result, rerr := render(tmpl, {}, partials)
testing.expect(t, rerr == nil)
@@ -107,7 +107,7 @@ leak_render_inheritance :: proc(t: ^testing.T) {
leak_repeated_render :: proc(t: ^testing.T) {
tmpl, err := parse("Hello {{name}}!")
testing.expect(t, err == nil)
defer template_free(&tmpl)
defer delete_template(&tmpl)
Data :: struct {
name: string,
+156
View File
@@ -0,0 +1,156 @@
package mustache
import "core:fmt"
import "core:strings"
// MAX_PIPES was chosen arbitrarily. It holds no performance or logical
// significance.
MAX_PIPES :: 8
// No filter accepts more than 2 args.
MAX_PIPE_ARGS :: 2
Pipe_Filter :: struct {
op: string,
args: [dynamic; MAX_PIPE_ARGS]string,
}
Group :: struct {
key: string,
items: [dynamic]any,
}
// Returned strings are slices into content — no cloning, lifetime bound to
// the caller's source.
parse_pipeline :: proc(
content: string,
filters_out: ^[dynamic; MAX_PIPES]Pipe_Filter,
) -> (
key: string,
err: Render_Error,
) {
if !strings.contains(content, "|") {
key = strings.trim_space(content)
return key, nil
}
segments := strings.split(content, "|", allocator = context.temp_allocator)
filter_count := len(segments) - 1
if filter_count > MAX_PIPES {
return "", Syntax_Error {
msg = fmt.tprintf("pipe expression has %d filters, max is %d", filter_count, MAX_PIPES),
}
}
key = strings.trim_space(segments[0])
if len(key) == 0 {
return "", Syntax_Error{msg = "pipe expression missing key"}
}
if filter_count == 0 {
return key, nil
}
for i in 0 ..< filter_count {
seg := strings.trim_space(segments[i + 1])
if len(seg) == 0 {
return "", Syntax_Error{msg = "empty filter"}
}
tokens := strings.fields(seg)
if len(tokens) == 0 {
return "", Syntax_Error{msg = "filter missing op name"}
}
arg_count := len(tokens) - 1
if arg_count > MAX_PIPE_ARGS {
return "", Syntax_Error {
msg = fmt.tprintf(
"filter '%s' has %d args, max is %d",
tokens[0],
arg_count,
MAX_PIPE_ARGS,
),
}
}
filter := Pipe_Filter {
op = tokens[0],
}
for j in 1 ..< len(tokens) {
append(&filter.args, tokens[j])
}
append(filters_out, filter)
delete(tokens)
}
return key, nil
}
apply_pipeline :: proc(value: any, filters: []Pipe_Filter) -> (any, Render_Error) {
current := value
for &filter in filters {
result, err := apply_filter(current, &filter)
if err != nil {
return nil, err
}
current = result
}
return current, nil
}
apply_filter :: proc(value: any, filter: ^Pipe_Filter) -> (any, Render_Error) {
switch filter.op {
case "group_by":
return apply_group_by(value, filter.args[:])
case:
return nil, Data_Error{msg = fmt.tprintf("unknown pipe op '%s'", filter.op)}
}
}
// Groups preserve first-appearance order from the input list.
apply_group_by :: proc(value: any, args: []string) -> (result: any, err: Render_Error) {
if len(args) != 1 {
return nil, Data_Error{msg = fmt.tprintf("group_by expects 1 argument, got %d", len(args))}
}
field := args[0]
elem_info, count, data := list_info(value)
if elem_info == nil {
return nil, Data_Error{msg = "group_by expects a list"}
}
groups := make([dynamic]Group, 0, 8, context.temp_allocator)
key_to_idx := make(map[string]int, context.temp_allocator)
defer delete(key_to_idx)
for j in 0 ..< count {
elem_ptr := rawptr(uintptr(data) + uintptr(j) * uintptr(elem_info.size))
elem := any{elem_ptr, elem_info.id}
key_val, found := lookup_in(elem, field)
if !found {
return nil, Data_Error {
msg = fmt.tprintf("group_by: element missing field '%s'", field),
}
}
key_str := any_to_string(key_val)
if len(key_str) == 0 {
return nil, Data_Error{msg = fmt.tprintf("group_by: field '%s' is empty", field)}
}
idx, exists := key_to_idx[key_str]
if !exists {
idx = len(groups)
key_to_idx[key_str] = idx
append(
&groups,
Group{key = key_str, items = make([dynamic]any, 0, 4, context.temp_allocator)},
)
}
append(&groups[idx].items, elem)
}
return groups, nil
}
+206
View File
@@ -0,0 +1,206 @@
#+test
package mustache
import "core:fmt"
import "core:testing"
Pipe_Post :: struct {
title: string,
year: string,
}
Pipe_Data :: struct {
posts: []Pipe_Post,
}
make_data :: proc(posts: ..Pipe_Post) -> Pipe_Data {
d: Pipe_Data
d.posts = posts
return d
}
@(test)
test_pipe_group_by_basic :: proc(t: ^testing.T) {
data := make_data(
{title = "A", year = "2024"},
{title = "B", year = "2024"},
{title = "C", year = "2023"},
)
tpl, _ := parse("{{#posts | group_by year}}{{key}}:{{#items}}{{title}},{{/items}};{{/posts}}")
defer delete_template(&tpl)
result, _ := render(tpl, data)
defer delete(result)
testing.expect_value(t, result, "2024:A,B,;2023:C,;")
}
@(test)
test_pipe_group_by_doesnt_sort :: proc(t: ^testing.T) {
data := make_data(
{title = "old", year = "2020"},
{title = "new", year = "2024"},
{title = "mid", year = "2022"},
)
tpl, _ := parse("{{#posts | group_by year}}{{key}} {{/posts}}")
defer delete_template(&tpl)
result, _ := render(tpl, data)
defer delete(result)
testing.expect_value(t, result, "2020 2024 2022 ")
}
@(test)
test_pipe_group_by_empty_list_skips :: proc(t: ^testing.T) {
data := Pipe_Data {
posts = nil,
}
tpl, _ := parse("[{{#posts | group_by year}}{{key}}{{/posts}}]")
defer delete_template(&tpl)
result, _ := render(tpl, data)
defer delete(result)
testing.expect_value(t, result, "[]")
}
@(test)
test_pipe_group_by_missing_field_fails :: proc(t: ^testing.T) {
data := make_data({title = "A", year = "2024"})
tpl, _ := parse("{{#posts | group_by missing}}x{{/posts}}")
defer delete_template(&tpl)
_, err := render(tpl, data)
testing.expect(t, err != nil, "missing field should error")
is_data_err := false
#partial switch e in err {
case Data_Error:
is_data_err = len(e.msg) > 0
}
testing.expect(t, is_data_err, "error should be Data_Error")
}
@(test)
test_pipe_group_by_requires_grouped_field :: proc(t: ^testing.T) {
data := make_data({title = "A", year = ""})
tpl, _ := parse("{{#posts | group_by year}}x{{/posts}}")
defer delete_template(&tpl)
_, err := render(tpl, data)
testing.expect(t, err != nil, "empty field value should error")
}
@(test)
test_group_by_requires_arg :: proc(t: ^testing.T) {
data := make_data({title = "A", year = "2024"})
tpl, _ := parse("{{#posts | group_by}}x{{/posts}}")
defer delete_template(&tpl)
_, err := render(tpl, data)
testing.expect(t, err != nil, "group_by with no args should error")
}
@(test)
test_pipe_group_by_non_list_returns_error :: proc(t: ^testing.T) {
Scalar_Data :: struct {
name: string,
}
data := Scalar_Data {
name = "hello",
}
tpl, _ := parse("{{#name | group_by x}}y{{/name}}")
defer delete_template(&tpl)
_, err := render(tpl, data)
testing.expect(t, err != nil, "group_by on a scalar should error")
}
@(test)
test_pipe_accepts_8_filters :: proc(t: ^testing.T) {
src := "{{#posts | group_by year | group_by year | group_by year | group_by year | group_by year | group_by year | group_by year | group_by year}}x{{/posts}}"
tpl, err := parse(src)
testing.expect(t, err == nil, "8 filters should parse OK")
if err == nil {
defer delete_template(&tpl)
} else {
fmt.printfln("parse error: %v", err)
}
}
@(test)
test_pipe_rejects_nine_filters :: proc(t: ^testing.T) {
src := "{{#posts | group_by y | group_by y | group_by y | group_by y | group_by y | group_by y | group_by y | group_by y | group_by y}}x{{/posts}}"
_, err := parse(src)
testing.expect(t, err != nil, "9 filters should fail to parse")
}
@(test)
test_pipe_forbidden_in_close_tag :: proc(t: ^testing.T) {
src := "{{#posts | group_by year}}x{{/posts | group_by year}}"
_, err := parse(src)
testing.expect(t, err != nil, "pipe in close tag should fail to parse")
}
@(test)
test_pipe_empty_middle_filter_required :: proc(t: ^testing.T) {
src := "{{#posts | | group_by year}}x{{/posts}}"
_, err := parse(src)
testing.expect(t, err != nil, "empty pipe filter should fail to parse")
}
@(test)
test_pipe_filter_required :: proc(t: ^testing.T) {
src := "{{#posts |}}x{{/posts}}"
_, err := parse(src)
testing.expect(t, err != nil, "trailing pipe with no filter should fail")
}
@(test)
test_pipe_data_required :: proc(t: ^testing.T) {
src := "{{#| group_by year}}x{{/}}"
_, err := parse(src)
testing.expect(t, err != nil, "missing key should fail to parse")
}
@(test)
test_extra_whitespace_allowed :: proc(t: ^testing.T) {
data := make_data({title = "A", year = "2024"}, {title = "B", year = "2023"})
tpl, _ := parse("{{#posts|group_by year}}{{key}};{{/posts}}")
defer delete_template(&tpl)
result, _ := render(tpl, data)
defer delete(result)
testing.expect_value(t, result, "2024;2023;")
}
@(test)
test_pipe_no_pipe_still_works :: proc(t: ^testing.T) {
data := make_data({title = "A", year = "2024"}, {title = "B", year = "2023"})
tpl, _ := parse("{{#posts}}{{title}};{{/posts}}")
defer delete_template(&tpl)
result, _ := render(tpl, data)
defer delete(result)
testing.expect_value(t, result, "A;B;")
}
@(test)
test_pipe_inverted_section_empty :: proc(t: ^testing.T) {
data := Pipe_Data {
posts = nil,
}
tpl, _ := parse("{{^posts | group_by year}}none{{/posts}}")
defer delete_template(&tpl)
result, _ := render(tpl, data)
defer delete(result)
testing.expect_value(t, result, "none")
}
@(test)
test_pipe_single_group_works :: proc(t: ^testing.T) {
data := make_data({title = "A", year = "2024"}, {title = "B", year = "2024"})
tpl, _ := parse("{{#posts | group_by year}}{{key}}({{#items}}{{title}}{{/items}}){{/posts}}")
defer delete_template(&tpl)
result, _ := render(tpl, data)
defer delete(result)
testing.expect_value(t, result, "2024(AB)")
}
@(test)
test_delete_template_doesnt_leak :: proc(t: ^testing.T) {
tmpl, err := parse("{{#posts | group_by year}}x{{/posts}}")
testing.expect(t, err == nil)
defer delete_template(&tmpl)
}
+6 -15
View File
@@ -16,11 +16,7 @@ Page_Context :: struct {
starred: bool,
date_iso: string,
date_display: string,
}
Year_Section :: struct {
year: string,
posts: [dynamic]Page_Context,
}
Base_Data :: struct {
@@ -47,7 +43,7 @@ Home_Data :: struct {
Section_Data :: struct {
using base: Base_Data,
page_title: string,
by_year: [dynamic]Year_Section,
posts: [dynamic]Page_Context,
}
build_page_context :: proc(page: Page) -> Page_Context {
@@ -57,6 +53,7 @@ build_page_context :: proc(page: Page) -> Page_Context {
starred = page.is_starred,
date_iso = page.date,
date_display = format_date(page.date),
year = get_year(page.date),
}
}
@@ -334,19 +331,13 @@ render_section :: proc(
partials: map[string]mustache.Template,
base: Base_Data,
) -> string {
by_year := make([dynamic]Year_Section)
defer delete(by_year)
current_year := ""
posts := make([dynamic]Page_Context)
defer delete(posts)
for page in site.pages {
if page.section != section || page._is_index {
continue
}
year := get_year(page.date)
if year != current_year {
append(&by_year, Year_Section{year = year})
current_year = year
}
append(&by_year[len(by_year) - 1].posts, build_page_context(page))
append(&posts, build_page_context(page))
}
data := Section_Data {
@@ -362,7 +353,7 @@ render_section :: proc(
data.title = fmt.tprintf("%s | %s", capitalize(section), site.title)
data.og.title = capitalize(section)
}
data.by_year = by_year
data.posts = posts
data.og.url = fmt.tprintf("%s/%s/", site.base_url, section)
data.og.type = "website"
data.og.is_article = false