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.
|
`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`)
|
### Markdown pipeline (in content.odin `load_page`)
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -209,7 +211,7 @@ odin test . -all-packages # includes mustache spec tests
|
|||||||
|
|
||||||
## Mustache engine
|
## 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
|
### 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`) |
|
| `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 |
|
| `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` |
|
| `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 |
|
| `spec_test.odin` | JSON spec test runner — loads `spec/specs/*.json`, runs each test case |
|
||||||
|
|
||||||
### Architecture
|
### 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`?
|
- [ ] follow symlinks in `scan_content`?
|
||||||
- [ ] ensure sidenote numbers render in display order and not in declaration order.
|
- [ ] ensure sidenote numbers render in display order and not in declaration order.
|
||||||
- [ ] Add Opt-in deflist support.
|
- [ ] 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.
|
- [ ] Table of contents support.
|
||||||
- [ ] Nav items should be active when the current page is selected.
|
- [ ] Nav items should be active when the current page is selected.
|
||||||
- [ ] Theme selector for syntax highlighting.
|
- [ ] Theme selector for syntax highlighting.
|
||||||
|
|||||||
@@ -3,17 +3,17 @@
|
|||||||
<main>
|
<main>
|
||||||
<h1>{{page_title}}</h1>
|
<h1>{{page_title}}</h1>
|
||||||
{{&body}}
|
{{&body}}
|
||||||
{{#by_year}}
|
{{#posts | group_by year}}
|
||||||
<section>
|
<section>
|
||||||
<h2>{{year}}</h2>
|
<h2>{{key}}</h2>
|
||||||
<ul>
|
<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>
|
datetime="{{date_iso}}">{{date_display}}</time>{{/date_iso}}</span>
|
||||||
</li>
|
</li>
|
||||||
{{/posts}}
|
{{/items}}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
{{/by_year}}
|
{{/posts}}
|
||||||
</main>
|
</main>
|
||||||
{{/content}}
|
{{/content}}
|
||||||
{{/base}}
|
{{/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) {
|
call_section_lambda :: proc(val: any, text: string) -> (result: string, ok: bool) {
|
||||||
switch v in val {
|
switch v in val {
|
||||||
case proc(string) -> string:
|
case proc(_: string) -> string:
|
||||||
return v(text), true
|
return v(text), true
|
||||||
case proc(string) -> int:
|
case proc(_: string) -> int:
|
||||||
return fmt.tprintf("%d", v(text)), true
|
return fmt.tprintf("%d", v(text)), true
|
||||||
case proc(string) -> bool:
|
case proc(_: string) -> bool:
|
||||||
return "true" if v(text) else "false", true
|
return "true" if v(text) else "false", true
|
||||||
case:
|
case:
|
||||||
return "", false
|
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 converts a scalar value to a string using the temp allocator.
|
||||||
any_to_string :: proc(a: any) -> string {
|
any_to_string :: proc(a: any) -> string {
|
||||||
if a == nil {
|
if a == nil {
|
||||||
|
|||||||
+80
-18
@@ -44,6 +44,7 @@ Node :: struct {
|
|||||||
kind: Node_Kind,
|
kind: Node_Kind,
|
||||||
text: string,
|
text: string,
|
||||||
key: string,
|
key: string,
|
||||||
|
filters: [dynamic; MAX_PIPES]Pipe_Filter,
|
||||||
is_dynamic: bool,
|
is_dynamic: bool,
|
||||||
indent: string,
|
indent: string,
|
||||||
first_child: int,
|
first_child: int,
|
||||||
@@ -74,7 +75,7 @@ Block_Override :: struct {
|
|||||||
count: int,
|
count: int,
|
||||||
}
|
}
|
||||||
|
|
||||||
template_free :: proc(tmpl: ^Template) {
|
delete_template :: proc(tmpl: ^Template) {
|
||||||
if tmpl != nil && len(tmpl.nodes) > 0 {
|
if tmpl != nil && len(tmpl.nodes) > 0 {
|
||||||
delete(tmpl.nodes)
|
delete(tmpl.nodes)
|
||||||
}
|
}
|
||||||
@@ -82,7 +83,7 @@ template_free :: proc(tmpl: ^Template) {
|
|||||||
|
|
||||||
delete_partials :: proc(partials: map[string]Template) {
|
delete_partials :: proc(partials: map[string]Template) {
|
||||||
for _, &p in partials {
|
for _, &p in partials {
|
||||||
template_free(&p)
|
delete_template(&p)
|
||||||
}
|
}
|
||||||
delete(partials)
|
delete(partials)
|
||||||
}
|
}
|
||||||
@@ -157,7 +158,7 @@ parse_tokens :: proc(
|
|||||||
) {
|
) {
|
||||||
nodes = make([dynamic]Node, 0, len(tokens), allocator)
|
nodes = make([dynamic]Node, 0, len(tokens), allocator)
|
||||||
pos := 0
|
pos := 0
|
||||||
err = parse_section(tokens, &pos, &nodes, "", source)
|
err = parse_section(tokens, &pos, &nodes, "", source, allocator)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +168,7 @@ parse_section :: proc(
|
|||||||
nodes: ^[dynamic]Node,
|
nodes: ^[dynamic]Node,
|
||||||
end_tag: string,
|
end_tag: string,
|
||||||
source: string,
|
source: string,
|
||||||
|
allocator := context.allocator,
|
||||||
) -> Render_Error {
|
) -> Render_Error {
|
||||||
for pos^ < len(tokens) {
|
for pos^ < len(tokens) {
|
||||||
tok := tokens[pos^]
|
tok := tokens[pos^]
|
||||||
@@ -191,11 +193,19 @@ parse_section :: proc(
|
|||||||
pos^ += 1
|
pos^ += 1
|
||||||
idx := len(nodes)
|
idx := len(nodes)
|
||||||
content_start := 0
|
content_start := 0
|
||||||
if pos^ < len(tokens) { content_start = tokens[pos^].pos }
|
if pos^ < len(tokens) {content_start = tokens[pos^].pos}
|
||||||
append(nodes, Node{kind = .Section, key = tok.value, first_child = -1})
|
append(nodes, Node{kind = .Section, first_child = -1})
|
||||||
parse_section(tokens, pos, nodes, tok.value, source) or_return
|
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
|
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].first_child = idx + 1
|
||||||
nodes[idx].child_count = len(nodes) - idx - 1
|
nodes[idx].child_count = len(nodes) - idx - 1
|
||||||
nodes[idx].content = source[content_start:close_pos]
|
nodes[idx].content = source[content_start:close_pos]
|
||||||
@@ -204,16 +214,33 @@ parse_section :: proc(
|
|||||||
pos^ += 1
|
pos^ += 1
|
||||||
idx := len(nodes)
|
idx := len(nodes)
|
||||||
content_start := 0
|
content_start := 0
|
||||||
if pos^ < len(tokens) { content_start = tokens[pos^].pos }
|
if pos^ < len(tokens) {content_start = tokens[pos^].pos}
|
||||||
append(nodes, Node{kind = .Inverted, key = tok.value, first_child = -1})
|
append(nodes, Node{kind = .Inverted, first_child = -1})
|
||||||
parse_section(tokens, pos, nodes, tok.value, source) or_return
|
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
|
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].first_child = idx + 1
|
||||||
nodes[idx].child_count = len(nodes) - idx - 1
|
nodes[idx].child_count = len(nodes) - idx - 1
|
||||||
nodes[idx].content = source[content_start:close_pos]
|
nodes[idx].content = source[content_start:close_pos]
|
||||||
|
|
||||||
case .Section_Close:
|
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 {
|
if end_tag != "" && tok.value == end_tag {
|
||||||
pos^ += 1
|
pos^ += 1
|
||||||
return nil
|
return nil
|
||||||
@@ -249,7 +276,7 @@ parse_section :: proc(
|
|||||||
nodes,
|
nodes,
|
||||||
Node{kind = .Parent, key = tok.value, indent = tok.indent, first_child = -1},
|
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].first_child = idx + 1
|
||||||
nodes[idx].child_count = len(nodes) - idx - 1
|
nodes[idx].child_count = len(nodes) - idx - 1
|
||||||
|
|
||||||
@@ -260,7 +287,7 @@ parse_section :: proc(
|
|||||||
nodes,
|
nodes,
|
||||||
Node{kind = .Block, key = tok.value, indent = tok.indent, first_child = -1},
|
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].first_child = idx + 1
|
||||||
nodes[idx].child_count = len(nodes) - idx - 1
|
nodes[idx].child_count = len(nodes) - idx - 1
|
||||||
}
|
}
|
||||||
@@ -465,7 +492,14 @@ render_nodes :: proc(
|
|||||||
if perr == nil {
|
if perr == nil {
|
||||||
temp: strings.Builder
|
temp: strings.Builder
|
||||||
strings.builder_init(&temp, context.temp_allocator)
|
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)
|
write_value(b, strings.to_string(temp), escape = true)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -480,7 +514,14 @@ render_nodes :: proc(
|
|||||||
if perr == nil {
|
if perr == nil {
|
||||||
temp: strings.Builder
|
temp: strings.Builder
|
||||||
strings.builder_init(&temp, context.temp_allocator)
|
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)
|
write_value(b, strings.to_string(temp), escape = false)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -490,18 +531,32 @@ render_nodes :: proc(
|
|||||||
|
|
||||||
case .Section:
|
case .Section:
|
||||||
val := resolve_name(node.key, ctx[:])
|
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 {
|
if result_str, ok := call_section_lambda(val, node.content); ok {
|
||||||
sub_tpl, perr := parse(result_str, context.temp_allocator, context.temp_allocator)
|
sub_tpl, perr := parse(result_str, context.temp_allocator, context.temp_allocator)
|
||||||
if perr == nil {
|
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) {
|
} else if is_truthy(val) {
|
||||||
children := all_nodes[node.first_child:node.first_child + node.child_count]
|
children := all_nodes[node.first_child:node.first_child + node.child_count]
|
||||||
elem_info, count, data := list_info(val)
|
elem_info, count, data := list_info(val)
|
||||||
if elem_info != nil {
|
if elem_info != nil {
|
||||||
for j in 0 ..< count {
|
for j in 0 ..< count {
|
||||||
elem_ptr := rawptr(uintptr(data) + uintptr(j) * uintptr(elem_info.size))
|
elem := extract_list_element(elem_info, data, j)
|
||||||
append(ctx, any{elem_ptr, elem_info.id})
|
append(ctx, elem)
|
||||||
defer pop(ctx)
|
defer pop(ctx)
|
||||||
render_nodes(all_nodes, children, ctx, partials, b, blocks) or_return
|
render_nodes(all_nodes, children, ctx, partials, b, blocks) or_return
|
||||||
}
|
}
|
||||||
@@ -515,6 +570,13 @@ render_nodes :: proc(
|
|||||||
|
|
||||||
case .Inverted:
|
case .Inverted:
|
||||||
val := resolve_name(node.key, ctx[:])
|
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) {
|
if !is_truthy(val) {
|
||||||
children := all_nodes[node.first_child:node.first_child + node.child_count]
|
children := all_nodes[node.first_child:node.first_child + node.child_count]
|
||||||
render_nodes(all_nodes, children, ctx, partials, b, blocks) or_return
|
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) {
|
leak_parse_free :: proc(t: ^testing.T) {
|
||||||
tmpl, err := parse("Hello {{name}}!")
|
tmpl, err := parse("Hello {{name}}!")
|
||||||
testing.expect(t, err == nil)
|
testing.expect(t, err == nil)
|
||||||
defer template_free(&tmpl)
|
defer delete_template(&tmpl)
|
||||||
}
|
}
|
||||||
|
|
||||||
@(test)
|
@(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))
|
tmpl, err := parse("Hello {{name}}!", context.allocator, mem.dynamic_arena_allocator(&arena))
|
||||||
testing.expect(t, err == nil)
|
testing.expect(t, err == nil)
|
||||||
defer template_free(&tmpl)
|
defer delete_template(&tmpl)
|
||||||
}
|
}
|
||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
@@ -36,7 +36,7 @@ leak_render :: proc(t: ^testing.T) {
|
|||||||
}
|
}
|
||||||
tmpl, err := parse("Hello {{name}}!")
|
tmpl, err := parse("Hello {{name}}!")
|
||||||
testing.expect(t, err == nil)
|
testing.expect(t, err == nil)
|
||||||
defer template_free(&tmpl)
|
defer delete_template(&tmpl)
|
||||||
|
|
||||||
result, rerr := render(tmpl, Data{name = "World"})
|
result, rerr := render(tmpl, Data{name = "World"})
|
||||||
testing.expect(t, rerr == nil)
|
testing.expect(t, rerr == nil)
|
||||||
@@ -48,7 +48,7 @@ leak_render_partials :: proc(t: ^testing.T) {
|
|||||||
partials := make_map(map[string]Template)
|
partials := make_map(map[string]Template)
|
||||||
defer {
|
defer {
|
||||||
for _, &p in partials {
|
for _, &p in partials {
|
||||||
template_free(&p)
|
delete_template(&p)
|
||||||
}
|
}
|
||||||
delete(partials)
|
delete(partials)
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ leak_render_partials :: proc(t: ^testing.T) {
|
|||||||
|
|
||||||
tmpl, err := parse("Hello {{> name}}!")
|
tmpl, err := parse("Hello {{> name}}!")
|
||||||
testing.expect(t, err == nil)
|
testing.expect(t, err == nil)
|
||||||
defer template_free(&tmpl)
|
defer delete_template(&tmpl)
|
||||||
|
|
||||||
Data :: struct {
|
Data :: struct {
|
||||||
name: string,
|
name: string,
|
||||||
@@ -73,7 +73,7 @@ leak_render_partials :: proc(t: ^testing.T) {
|
|||||||
leak_render_sections :: proc(t: ^testing.T) {
|
leak_render_sections :: proc(t: ^testing.T) {
|
||||||
tmpl, err := parse("{{#items}}{{.}}{{/items}}")
|
tmpl, err := parse("{{#items}}{{.}}{{/items}}")
|
||||||
testing.expect(t, err == nil)
|
testing.expect(t, err == nil)
|
||||||
defer template_free(&tmpl)
|
defer delete_template(&tmpl)
|
||||||
|
|
||||||
Data :: struct {
|
Data :: struct {
|
||||||
items: [3]string,
|
items: [3]string,
|
||||||
@@ -96,7 +96,7 @@ leak_render_inheritance :: proc(t: ^testing.T) {
|
|||||||
tmpl_src := "{{<layout}}{{$title}}custom{{/title}}{{/layout}}"
|
tmpl_src := "{{<layout}}{{$title}}custom{{/title}}{{/layout}}"
|
||||||
tmpl, err := parse(tmpl_src)
|
tmpl, err := parse(tmpl_src)
|
||||||
testing.expect(t, err == nil)
|
testing.expect(t, err == nil)
|
||||||
defer template_free(&tmpl)
|
defer delete_template(&tmpl)
|
||||||
|
|
||||||
result, rerr := render(tmpl, {}, partials)
|
result, rerr := render(tmpl, {}, partials)
|
||||||
testing.expect(t, rerr == nil)
|
testing.expect(t, rerr == nil)
|
||||||
@@ -107,7 +107,7 @@ leak_render_inheritance :: proc(t: ^testing.T) {
|
|||||||
leak_repeated_render :: proc(t: ^testing.T) {
|
leak_repeated_render :: proc(t: ^testing.T) {
|
||||||
tmpl, err := parse("Hello {{name}}!")
|
tmpl, err := parse("Hello {{name}}!")
|
||||||
testing.expect(t, err == nil)
|
testing.expect(t, err == nil)
|
||||||
defer template_free(&tmpl)
|
defer delete_template(&tmpl)
|
||||||
|
|
||||||
Data :: struct {
|
Data :: struct {
|
||||||
name: string,
|
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,
|
starred: bool,
|
||||||
date_iso: string,
|
date_iso: string,
|
||||||
date_display: string,
|
date_display: string,
|
||||||
}
|
|
||||||
|
|
||||||
Year_Section :: struct {
|
|
||||||
year: string,
|
year: string,
|
||||||
posts: [dynamic]Page_Context,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Base_Data :: struct {
|
Base_Data :: struct {
|
||||||
@@ -47,7 +43,7 @@ Home_Data :: struct {
|
|||||||
Section_Data :: struct {
|
Section_Data :: struct {
|
||||||
using base: Base_Data,
|
using base: Base_Data,
|
||||||
page_title: string,
|
page_title: string,
|
||||||
by_year: [dynamic]Year_Section,
|
posts: [dynamic]Page_Context,
|
||||||
}
|
}
|
||||||
|
|
||||||
build_page_context :: proc(page: Page) -> 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,
|
starred = page.is_starred,
|
||||||
date_iso = page.date,
|
date_iso = page.date,
|
||||||
date_display = format_date(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,
|
partials: map[string]mustache.Template,
|
||||||
base: Base_Data,
|
base: Base_Data,
|
||||||
) -> string {
|
) -> string {
|
||||||
by_year := make([dynamic]Year_Section)
|
posts := make([dynamic]Page_Context)
|
||||||
defer delete(by_year)
|
defer delete(posts)
|
||||||
current_year := ""
|
|
||||||
for page in site.pages {
|
for page in site.pages {
|
||||||
if page.section != section || page._is_index {
|
if page.section != section || page._is_index {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
year := get_year(page.date)
|
append(&posts, build_page_context(page))
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data := Section_Data {
|
data := Section_Data {
|
||||||
@@ -362,7 +353,7 @@ render_section :: proc(
|
|||||||
data.title = fmt.tprintf("%s | %s", capitalize(section), site.title)
|
data.title = fmt.tprintf("%s | %s", capitalize(section), site.title)
|
||||||
data.og.title = capitalize(section)
|
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.url = fmt.tprintf("%s/%s/", site.base_url, section)
|
||||||
data.og.type = "website"
|
data.og.type = "website"
|
||||||
data.og.is_article = false
|
data.og.is_article = false
|
||||||
|
|||||||
Reference in New Issue
Block a user