mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
feat(mustache): Added group_by filter to templates
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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`.
|
||||
+16
-3
@@ -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 {
|
||||
|
||||
+80
-18
@@ -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,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user