From 770a04481d342b962aaece7ff8c944473da87621 Mon Sep 17 00:00:00 2001 From: Spencer Brower <6729162+sbrow@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:05:48 -0400 Subject: [PATCH] feat(mustache): Added `group_by` filter to templates --- AGENTS.md | 5 +- PLAN.md | 141 ------------------- TODOS.md | 2 +- defaults/layouts/section_index.html | 10 +- mustache/EXTENSIONS.md | 73 ++++++++++ mustache/data.odin | 19 ++- mustache/mustache.odin | 98 ++++++++++--- mustache/mustache_test.odin | 16 +-- mustache/pipes.odin | 156 +++++++++++++++++++++ mustache/pipes_test.odin | 206 ++++++++++++++++++++++++++++ render.odin | 23 +--- 11 files changed, 556 insertions(+), 193 deletions(-) delete mode 100644 PLAN.md create mode 100644 mustache/EXTENSIONS.md create mode 100644 mustache/pipes.odin create mode 100644 mustache/pipes_test.odin diff --git a/AGENTS.md b/AGENTS.md index 24d2c51..e604af9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` 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 diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index d1657ee..0000000 --- a/PLAN.md +++ /dev/null @@ -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}} -

{{year}}

- {{#posts}} -
  • {{title}}
  • - {{/posts}} -{{/posts.by_year}} - -{{#posts.all}} -
  • {{title}}
  • -{{/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}} -
    -

    {{year}}

    - -
    -{{/posts.by_year}} -``` - -Home page could use the same pattern: -```handlebars -{{#pages.all}} -
  • ...
  • -{{/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. diff --git a/TODOS.md b/TODOS.md index 77c9c34..628983c 100644 --- a/TODOS.md +++ b/TODOS.md @@ -42,7 +42,7 @@ - [ ] 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. diff --git a/defaults/layouts/section_index.html b/defaults/layouts/section_index.html index fc02128..397d8d8 100644 --- a/defaults/layouts/section_index.html +++ b/defaults/layouts/section_index.html @@ -3,17 +3,17 @@

    {{page_title}}

    {{&body}} - {{#by_year}} + {{#posts | group_by year}}
    -

    {{year}}

    +

    {{key}}

      - {{#posts}}
    • {{&title}}{{#date_iso}}
    • - {{/posts}} + {{/items}}
    - {{/by_year}} + {{/posts}}
    {{/content}} {{/base}} diff --git a/mustache/EXTENSIONS.md b/mustache/EXTENSIONS.md new file mode 100644 index 0000000..e7e7ae6 --- /dev/null +++ b/mustache/EXTENSIONS.md @@ -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 + +``` +{{# | ... | ... }} … {{/}} +``` + +- 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}} +
    +

    {{key}}

    +
      {{#items}}
    • {{title}}
    • {{/items}}
    +
    +{{/posts}} +``` + +### Available ops + +#### `group_by ` + +Buckets each element of a list by the value of ``. 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 ` — ordering +- `filter `, `where ` — selection +- `take `, `take_last `, `skip ` — slicing +- `reverse` — order flip + +To add a new op: +1. Implement `apply_(value: any, args: []string) -> (any, Render_Error)` in `pipes.odin`. +2. Add a `case` to `apply_filter`. +3. Add tests to `pipes_test.odin`. diff --git a/mustache/data.odin b/mustache/data.odin index ca2ca3e..e0dd657 100644 --- a/mustache/data.odin +++ b/mustache/data.odin @@ -174,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 @@ -207,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 { diff --git a/mustache/mustache.odin b/mustache/mustache.odin index 7c050d2..abf920b 100644 --- a/mustache/mustache.odin +++ b/mustache/mustache.odin @@ -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 diff --git a/mustache/mustache_test.odin b/mustache/mustache_test.odin index b174495..e512406 100644 --- a/mustache/mustache_test.odin +++ b/mustache/mustache_test.odin @@ -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 := "{{ ( + 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 +} diff --git a/mustache/pipes_test.odin b/mustache/pipes_test.odin new file mode 100644 index 0000000..abbf846 --- /dev/null +++ b/mustache/pipes_test.odin @@ -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) +} + diff --git a/render.odin b/render.odin index d9a152b..d346a13 100644 --- a/render.odin +++ b/render.odin @@ -16,11 +16,7 @@ Page_Context :: struct { starred: bool, date_iso: string, date_display: string, -} - -Year_Section :: struct { - year: string, - posts: [dynamic]Page_Context, + year: string, } 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