mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
fix(mustache): Got whitespace management to pass the tests.
This commit is contained in:
@@ -28,7 +28,7 @@ public/ ← build output (generated)
|
||||
| `sectionate.odin` | `wrap_sections` proc — splits HTML at `<h2` into `<section>` wrappers |
|
||||
| `render.odin` | Mustache template rendering, all page types, RSS, sitemap, robots.txt, `load_partials` recursive scan |
|
||||
| `feed.odin` | RSS feed + sitemap XML generation |
|
||||
| `mustache/` | Vendored [odin-mustache](https://github.com/benjamindblock/odin-mustache) library |
|
||||
| `mustache/` | Mustache template engine (spec-compliant, replaces vendored odin-mustache) |
|
||||
|
||||
Icon SVGs live as HTML partials in `layouts/partials/icons/` (home, github, rss, chevron_up, star).
|
||||
|
||||
@@ -131,19 +131,39 @@ odin test . # site + mustache smoke tests
|
||||
odin test mustache # mustache spec + targeted tests (37 total)
|
||||
```
|
||||
|
||||
## Vendored mustache patches
|
||||
## Mustache engine
|
||||
|
||||
Four modifications to `mustache/mustache.odin`:
|
||||
Spec-compliant Mustache implementation at `mustache/`. Passes all 170 tests across 7 official spec files (interpolation, sections, inverted, comments, partials, dynamic-names, inheritance).
|
||||
|
||||
1. **`any` unwrapping** in `map_get` and `data_type` — when values come from `map[string]any`, the inner `any` wrapper is unwrapped so type detection works correctly for nested maps and lists.
|
||||
### Files
|
||||
|
||||
2. **Layout partials** — `layout_template.partials = tmpl.partials` added so partials (`{{> nav}}`, `{{> footer}}`) work inside the base layout template.
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `mustache.odin` | Public API (`parse`, `render`, `Template`), parser (`parse_section`), post-parse de-indent (`deindent_blocks`), renderer (`render_nodes`), indent helpers |
|
||||
| `tokenizer.odin` | Tokenizer (template string → `[]Token`), two-pass standalone whitespace detection (`trim_standalone_whitespace`) |
|
||||
| `data.odin` | Reflection-based data model: `effective` (union/distinct peeling), `lookup_in`, `resolve_name`, `is_truthy`, `any_to_string`, `list_info`, `write_value`, `format_f64` |
|
||||
| `spec_test.odin` | JSON spec test runner — loads `spec/specs/*.json`, runs each test case |
|
||||
|
||||
3. **Inline partial rendering** — replaced `template_insert_partial` (which injected tokens into the main token list, breaking section iteration) with `template_render_partial` (which lexes the partial, temporarily swaps `tmpl.lexer`, and recursively processes via `template_process_tokens`). Fixes partials inside sections. Standalone indentation applied to partial source before lexing.
|
||||
### Architecture
|
||||
|
||||
4. **Dynamic Names** — `{{>*key}}` support. When a partial token value starts with `*`, the remaining key is resolved from the data context stack, and the resolved string is used as the partial name. Enables per-item partial selection inside sections (e.g., `{{>* icon}}` resolves `icon` from each social link).
|
||||
```
|
||||
parse(source) → tokenize → trim_standalone_whitespace → parse_section → deindent_blocks → Template
|
||||
render(tmpl, data, partials) → render_nodes (walks flat node array against context stack) → string
|
||||
```
|
||||
|
||||
Extracted `template_process_tokens` from `template_eat_tokens` to separate ROOT initialization + skip pass from the core token loop, allowing partials to reuse the loop.
|
||||
- **Flat `[dynamic]Node` array** with `first_child`/`child_count` indices — one allocation, one `delete`. Pre-order layout: children stored contiguously after their parent.
|
||||
- **`render_nodes`** takes `all_nodes` (full array, for absolute child access) + `nodes` (current slice). Index-based loop, skips children after sections/blocks via `i += 1 + child_count`.
|
||||
- **Context stack**: `^[dynamic]any` with `append`/`pop` for section push/pop.
|
||||
- **`effective(a)`** peels Named/Distinct/Union layers (including `json.Value`) so all downstream operations can switch on base `Type_Info` variant directly.
|
||||
- **Two-pass standalone detection**: detect using original token values, then trim left-to-right with `left_done`/`right_done` tracking to prevent double-trims. Cascading `check_left`/`check_right` skip adjacent standalone-eligible tags.
|
||||
- **Block indent**: `deindent_blocks` runs post-parse — finds common indent of direct text children, sets block's intrinsic indent if empty, removes common indent. Renderer applies block indent at output level via `write_indented`.
|
||||
- **Partial/parent indent**: `Template` stores `source` for indent re-parse. `render_template` calls `indent_lines(source, indent)` then re-parses with temp allocator when indent is non-empty.
|
||||
- **Block overrides** (`Block_Override` struct): carries `all_nodes` + child range so override content from different templates renders correctly. `merge_block_overrides` propagates overrides through multi-level inheritance chains.
|
||||
|
||||
### Not implemented
|
||||
|
||||
- Lambdas (`~lambdas.json`)
|
||||
- Set delimiters (`{{= =}}`, `delimiters.json`)
|
||||
|
||||
## Known limitations
|
||||
|
||||
@@ -152,12 +172,15 @@ Extracted `template_process_tokens` from `template_eat_tokens` to separate ROOT
|
||||
- `json.Value` params require 64-byte aligned arena (workaround for `dynamic_arena_allocator_proc` ignoring per-allocation alignment).
|
||||
- Tree-sitter grammar/query paths hardcoded in `tree_sitter.odin` (Nix store hashes, Helix-version-dependent).
|
||||
- `TSQueryCapture` needs explicit `_padding: u32` field for C ABI compatibility (40-byte sizeof).
|
||||
- `{{&content}}` in layout template must have no leading whitespace — `template_insert_content_into_layout` indents all lines by preceding whitespace, which corrupts `<pre>` blocks.
|
||||
- highlight.js removed; syntax highlighting is build-time only (no fallback if Tree-sitter fails).
|
||||
- `format_f64` in mustache brute-forces shortest float representation (Odin's `strconv` doesn't produce shortest round-trip for all values like `3.3`).
|
||||
- Block indent uses output-level indentation (`write_indented`), not source-level re-parse. Multi-line interpolated content inside a standalone block would get incorrectly indented. No spec test exercises this.
|
||||
|
||||
## Design decisions
|
||||
|
||||
See `HUGO.md` for analysis of why thor doesn't need Hugo's shortcode context isolation.
|
||||
See `mustache/PARTIAL_INDENT.md` for whitespace handling analysis.
|
||||
See `mustache/SPEC.md` for the original implementation specification.
|
||||
|
||||
## TODO
|
||||
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
# Partial Indentation — Problem & Solutions
|
||||
|
||||
## Problem
|
||||
|
||||
When a partial tag `{{> name}}` is standalone (only non-whitespace on its line), the mustache spec requires that its leading whitespace be treated as indentation and **prepended to each line of the partial source before rendering**.
|
||||
|
||||
This is a source-level transformation, not output post-processing. The distinction matters when interpolated content contains newlines:
|
||||
|
||||
```
|
||||
partial source: "|\n{{{content}}}\n|\n"
|
||||
content value: "<\n->"
|
||||
indent: " "
|
||||
|
||||
Expected output: " |\n <\n->\n |\n"
|
||||
```
|
||||
|
||||
The line `->` gets NO indent — it comes from expanded content (`<\n->`), not from a partial source line. Post-processing the output would incorrectly indent it.
|
||||
|
||||
### Spec tests that require this
|
||||
|
||||
- **Standalone Without Previous Line** — indent at start of template
|
||||
- **Standalone Without Newline** — indent at end of template
|
||||
- **Standalone Indentation** — indent with multi-line interpolated content
|
||||
|
||||
3 of 14 tests in `partials.json`. The other 11 (basic lookup, context, recursion, nesting, inline usage, failed lookup, padding) work without indentation handling.
|
||||
|
||||
### thor's real usage
|
||||
|
||||
Thor's partials (`{{> nav}}`, `{{> footer}}`, `{{>* icon}}`) are typically standalone with indentation. Without indentation handling, HTML output has wrong indentation — ugly but functional since HTML ignores whitespace.
|
||||
|
||||
## Solution 1: Store source, re-parse with indent (Recommended)
|
||||
|
||||
Add `source: string` to `Template`. When rendering a standalone partial with non-empty indent:
|
||||
|
||||
1. Prepend indent to each line of `partial.source`
|
||||
2. Re-tokenize + re-parse with `context.temp_allocator`
|
||||
3. Render the re-parsed nodes
|
||||
|
||||
When indent is empty, render the pre-parsed nodes directly (no re-parse).
|
||||
|
||||
### Pros
|
||||
- Correct by construction — exactly matches spec ("prepended to each line before rendering")
|
||||
- ~20 lines of code
|
||||
- Nested partials accumulate indentation naturally
|
||||
- No line-start state tracking
|
||||
|
||||
### Cons
|
||||
- Template gains a `source: string` field
|
||||
- Standalone partials with indent get re-parsed at render time (negligible for small fragments)
|
||||
|
||||
### Implementation sketch
|
||||
|
||||
```odin
|
||||
// tokenizer: capture indent during trim_standalone_whitespace
|
||||
// for Partial tokens, before stripping left whitespace:
|
||||
tokens[i].indent = text[nl+1:] // capture indentation
|
||||
|
||||
// mustache.odin: Template gains source field
|
||||
Template :: struct {
|
||||
nodes: [dynamic]Node,
|
||||
source: string,
|
||||
}
|
||||
|
||||
// parse: store source
|
||||
tmpl.source = source
|
||||
|
||||
// renderer: re-parse if indent
|
||||
case .Partial:
|
||||
pt, found := partials[name]
|
||||
if !found do break
|
||||
if len(node.indent) > 0 {
|
||||
indented := indent_lines(pt.source, node.indent)
|
||||
reparse, err := parse(indented, context.temp_allocator, context.temp_allocator)
|
||||
if err == nil {
|
||||
defer delete(reparse.nodes)
|
||||
render_nodes(reparse.nodes[:], reparse.nodes[:], ctx, partials, b)
|
||||
}
|
||||
} else {
|
||||
render_nodes(pt.nodes[:], pt.nodes[:], ctx, partials, b)
|
||||
}
|
||||
```
|
||||
|
||||
## Solution 2: Modify text nodes during rendering
|
||||
|
||||
Track "at line start" state while rendering the partial's nodes. Insert indent before text-node content that begins a new line. Variables/sections render normally — their output is NOT indented.
|
||||
|
||||
### Pros
|
||||
- No source storage
|
||||
- No re-parsing
|
||||
|
||||
### Cons
|
||||
- Complex: must track line-start state across nodes
|
||||
- Variables producing multi-line output need careful handling (their newlines don't create indented lines)
|
||||
- Trailing newline edge case (partial ending with `\n` shouldn't leave trailing indent)
|
||||
- Nested partials with accumulated indentation need extra logic
|
||||
- ~60+ lines of fiddly code
|
||||
|
||||
### Implementation sketch
|
||||
|
||||
```odin
|
||||
render_partial :: proc(nodes, ctx, partials, b, indent) {
|
||||
at_line_start := true
|
||||
for node in nodes {
|
||||
switch node.kind {
|
||||
case .Text:
|
||||
// Walk text, prepend indent at line starts
|
||||
// Insert indent after each \n
|
||||
// Don't indent after final \n of last text node
|
||||
...
|
||||
at_line_start = (text ends with \n)
|
||||
case .Variable, .Unescaped:
|
||||
// Render normally — no indent applied to output
|
||||
write_value(b, val, ...)
|
||||
at_line_start = false // can't know if output ends with \n
|
||||
case .Section, .Inverted:
|
||||
// Complex: nested text nodes need indent too?
|
||||
...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The "can't know if variable output ends with \n" problem makes `at_line_start` unreliable, requiring heuristics or output buffering.
|
||||
|
||||
## Adjacent Tag Standalone Detection
|
||||
|
||||
**Problem:** When a block-type tag and its close tag are adjacent (no text between them), like `{{<include}}{{/include}}\n`, neither tag is individually detected as standalone. The current check looks at the immediately adjacent token — if it's another tag (not text), the check fails. So the trailing `\n` isn't consumed.
|
||||
|
||||
**Affected spec tests:**
|
||||
- `~inheritance.json`: "Inherit", "Override parent with newlines"
|
||||
- Potentially any `{{<name}}{{/name}}` or `{{#name}}{{/name}}` on its own line
|
||||
|
||||
**Solution:** When scanning left/right for the line boundary, skip over adjacent standalone-eligible tags (they're transparent). Two helper procs replace the current inline checks:
|
||||
- `check_left(tokens, i)` — scans backwards through adjacent eligible tags until finding text or start of template
|
||||
- `check_right(tokens, i)` — scans forwards through adjacent eligible tags until finding text or end of template
|
||||
|
||||
Both return `(ok: bool, text_idx: int)` where `text_idx` is the text token to trim (or -1 if none).
|
||||
+5
-2
@@ -1,3 +1,6 @@
|
||||
- [ ] Initialize string builder size based on filesize?
|
||||
- [ ] Review [whitespace handling code](./tokenizer.odin)
|
||||
- [ ] Add leak checks for public procs.
|
||||
- [x] Review [whitespace handling code](./tokenizer.odin)
|
||||
- [x] Add leak checks for public procs.
|
||||
- [ ] Simplify `trim_standalone_whitespace` — four temp arrays (`li_buf`, `ri_buf`, `left_done`, `right_done`) could potentially be reduced to two by merging the done-tracking into the index buffers using sentinel values (e.g. `-2` = already trimmed).
|
||||
- [ ] Adjacent standalone tags don't capture indent for the second tag (e.g. ` {{>a}}\n {{>b}}` — `{{>b}}` gets `indent=""` because its left text was consumed by `{{>a}}`'s right-trim). Blocks recover via `deindent_blocks`, but partials/parents have no fallback.
|
||||
- [ ] lazy load partials ? (with mutex)
|
||||
|
||||
+70
-29
@@ -9,20 +9,28 @@ import "core:strings"
|
||||
// effective unwraps union variants and strips Named/Distinct layers,
|
||||
// returning the "peeled" any value and its base type info.
|
||||
effective :: proc(a: any) -> (val: any, info: ^runtime.Type_Info) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
|
||||
val = a
|
||||
info = nil
|
||||
if val == nil do return
|
||||
|
||||
ti := type_info_of(val.id)
|
||||
if ti == nil do return
|
||||
if ti == nil {
|
||||
return
|
||||
}
|
||||
|
||||
base := runtime.type_info_base(ti)
|
||||
|
||||
if _, ok := base.variant.(runtime.Type_Info_Union); ok {
|
||||
variant := reflect.get_union_variant(val)
|
||||
if variant == nil do return {}, nil
|
||||
if variant == nil {
|
||||
return {}, nil
|
||||
} else {
|
||||
return effective(variant)
|
||||
}
|
||||
}
|
||||
|
||||
info = base
|
||||
return
|
||||
@@ -31,35 +39,36 @@ effective :: proc(a: any) -> (val: any, info: ^runtime.Type_Info) {
|
||||
// lookup_in resolves a single key in a container (struct or map).
|
||||
// Returns found=false if the key doesn't exist or the container is not a struct/map.
|
||||
lookup_in :: proc(container: any, key: string) -> (result: any, found: bool) {
|
||||
if container == nil do return nil, false
|
||||
if container == nil {
|
||||
return
|
||||
}
|
||||
|
||||
val, info := effective(container)
|
||||
if info == nil do return nil, false
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
|
||||
#partial switch v in info.variant {
|
||||
case runtime.Type_Info_Struct:
|
||||
result = reflect.struct_field_value_by_name(val, key, allow_using = true)
|
||||
found = result != nil
|
||||
return
|
||||
|
||||
case runtime.Type_Info_Map:
|
||||
mi := v
|
||||
rm_ptr := (^runtime.Raw_Map)(val.data)
|
||||
if rm_ptr.len == 0 do return nil, false
|
||||
if rm_ptr.len == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
k := key
|
||||
seed := runtime.map_seed(rm_ptr^)
|
||||
h := mi.map_info.key_hasher(&k, seed)
|
||||
value_ptr := runtime.__dynamic_map_get(rm_ptr, mi.map_info, h, &k)
|
||||
if value_ptr == nil do return nil, false
|
||||
|
||||
if value_ptr != nil {
|
||||
result = any{value_ptr, mi.value.id}
|
||||
found = true
|
||||
return
|
||||
|
||||
case:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -68,9 +77,12 @@ lookup_in :: proc(container: any, key: string) -> (result: any, found: bool) {
|
||||
// resolve against the prior result only.
|
||||
resolve_name :: proc(name: string, ctx: []any) -> any {
|
||||
if name == "." {
|
||||
if len(ctx) > 0 do return ctx[len(ctx) - 1]
|
||||
if len(ctx) > 0 {
|
||||
return ctx[len(ctx) - 1]
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
parts: [16]string
|
||||
part_count := 0
|
||||
@@ -89,22 +101,33 @@ resolve_name :: proc(name: string, ctx: []any) -> any {
|
||||
part_count += 1
|
||||
}
|
||||
|
||||
if part_count == 0 do return nil
|
||||
if part_count == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
dot_parts := parts[:part_count]
|
||||
|
||||
result: any = nil
|
||||
found := false
|
||||
for i := len(ctx) - 1; i >= 0; i -= 1 {
|
||||
result, found = lookup_in(ctx[i], dot_parts[0])
|
||||
if found do break
|
||||
if found {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found do return nil
|
||||
if len(dot_parts) == 1 do return result
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
if len(dot_parts) == 1 {
|
||||
return result
|
||||
}
|
||||
|
||||
for i := 1; i < len(dot_parts); i += 1 {
|
||||
result, found = lookup_in(result, dot_parts[i])
|
||||
if !found do return nil
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -112,11 +135,17 @@ resolve_name :: proc(name: string, ctx: []any) -> any {
|
||||
|
||||
// is_truthy checks mustache truthiness.
|
||||
is_truthy :: proc(a: any) -> bool {
|
||||
if a == nil do return false
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
val, info := effective(a)
|
||||
if info == nil do return false
|
||||
if reflect.is_nil(val) do return false
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
if reflect.is_nil(val) {
|
||||
return false
|
||||
}
|
||||
|
||||
#partial switch _ in info.variant {
|
||||
case runtime.Type_Info_Slice, runtime.Type_Info_Dynamic_Array, runtime.Type_Info_Map:
|
||||
@@ -130,7 +159,9 @@ is_truthy :: proc(a: any) -> bool {
|
||||
// Returns elem_info=nil if the value is not a list.
|
||||
list_info :: proc(a: any) -> (elem_info: ^runtime.Type_Info, count: int, data: rawptr) {
|
||||
val, info := effective(a)
|
||||
if info == nil do return nil, 0, nil
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
|
||||
#partial switch v in info.variant {
|
||||
case runtime.Type_Info_Slice:
|
||||
@@ -148,7 +179,9 @@ list_info :: proc(a: any) -> (elem_info: ^runtime.Type_Info, count: int, data: r
|
||||
|
||||
// any_to_string converts a scalar value to a string using the temp allocator.
|
||||
any_to_string :: proc(a: any) -> string {
|
||||
if a == nil do return ""
|
||||
if a == nil {
|
||||
return ""
|
||||
}
|
||||
val, _ := effective(a)
|
||||
|
||||
switch v in val {
|
||||
@@ -174,14 +207,18 @@ format_f64 :: proc(v: f64) -> string {
|
||||
buf: [64]byte
|
||||
for prec in 1 ..= 17 {
|
||||
s := strconv.write_float(buf[:], v, 'g', prec, 64)
|
||||
if len(s) > 0 && s[0] == '+' do s = s[1:]
|
||||
if len(s) > 0 && s[0] == '+' {
|
||||
s = s[1:]
|
||||
}
|
||||
parsed, ok := strconv.parse_f64(s)
|
||||
if ok && parsed == v {
|
||||
return strings.clone(s, context.temp_allocator)
|
||||
}
|
||||
}
|
||||
s := strconv.write_float(buf[:], v, 'g', -1, 64)
|
||||
if len(s) > 0 && s[0] == '+' do s = s[1:]
|
||||
if len(s) > 0 && s[0] == '+' {
|
||||
s = s[1:]
|
||||
}
|
||||
return strings.clone(s, context.temp_allocator)
|
||||
}
|
||||
|
||||
@@ -189,7 +226,9 @@ format_f64 :: proc(v: f64) -> string {
|
||||
// optionally HTML-escaped.
|
||||
write_value :: proc(b: ^strings.Builder, a: any, escape: bool) {
|
||||
s := any_to_string(a)
|
||||
if len(s) == 0 do return
|
||||
if len(s) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if !escape {
|
||||
strings.write_string(b, s)
|
||||
@@ -200,7 +239,9 @@ write_value :: proc(b: ^strings.Builder, a: any, escape: bool) {
|
||||
for i in 0 ..< len(s) {
|
||||
switch s[i] {
|
||||
case '&', '<', '>', '"':
|
||||
if i > start do strings.write_string(b, s[start:i])
|
||||
if i > start {
|
||||
strings.write_string(b, s[start:i])
|
||||
}
|
||||
switch s[i] {
|
||||
case '&':
|
||||
strings.write_string(b, "&")
|
||||
|
||||
+247
-37
@@ -50,8 +50,21 @@ Node :: struct {
|
||||
child_count: int,
|
||||
}
|
||||
|
||||
// node_span returns the number of flat-array entries a node occupies:
|
||||
// 1 for leaf nodes, 1 + child_count for container nodes (whose children
|
||||
// are stored contiguously after them in the array).
|
||||
node_span :: proc(n: Node) -> int {
|
||||
#partial switch n.kind {
|
||||
case .Section, .Inverted, .Parent, .Block:
|
||||
return 1 + n.child_count
|
||||
case:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
Template :: struct {
|
||||
nodes: [dynamic]Node,
|
||||
source: string,
|
||||
}
|
||||
|
||||
Block_Override :: struct {
|
||||
@@ -66,6 +79,13 @@ template_free :: proc(tmpl: ^Template) {
|
||||
}
|
||||
}
|
||||
|
||||
delete_partials :: proc(partials: map[string]Template) {
|
||||
for _, &p in partials {
|
||||
template_free(&p)
|
||||
}
|
||||
delete(partials)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -86,8 +106,11 @@ parse :: proc(
|
||||
tmpl.nodes, err = parse_tokens(tokens[:], allocator)
|
||||
if err != nil {
|
||||
delete(tmpl.nodes)
|
||||
return {}, err
|
||||
}
|
||||
return tmpl, err
|
||||
tmpl.source = source
|
||||
deindent_blocks(tmpl.nodes[:], 0, len(tmpl.nodes), allocator)
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
render :: proc(
|
||||
@@ -165,8 +188,7 @@ parse_section :: proc(
|
||||
pos^ += 1
|
||||
idx := len(nodes)
|
||||
append(nodes, Node{kind = .Section, key = tok.value, first_child = -1})
|
||||
err := parse_section(tokens, pos, nodes, tok.value)
|
||||
if err != nil do return err
|
||||
parse_section(tokens, pos, nodes, tok.value) or_return
|
||||
nodes[idx].first_child = idx + 1
|
||||
nodes[idx].child_count = len(nodes) - idx - 1
|
||||
|
||||
@@ -174,8 +196,7 @@ parse_section :: proc(
|
||||
pos^ += 1
|
||||
idx := len(nodes)
|
||||
append(nodes, Node{kind = .Inverted, key = tok.value, first_child = -1})
|
||||
err := parse_section(tokens, pos, nodes, tok.value)
|
||||
if err != nil do return err
|
||||
parse_section(tokens, pos, nodes, tok.value) or_return
|
||||
nodes[idx].first_child = idx + 1
|
||||
nodes[idx].child_count = len(nodes) - idx - 1
|
||||
|
||||
@@ -202,6 +223,7 @@ parse_section :: proc(
|
||||
kind = .Partial,
|
||||
key = tok.value,
|
||||
is_dynamic = tok.is_dynamic,
|
||||
indent = tok.indent,
|
||||
first_child = -1,
|
||||
},
|
||||
)
|
||||
@@ -210,18 +232,22 @@ parse_section :: proc(
|
||||
case .Parent:
|
||||
pos^ += 1
|
||||
idx := len(nodes)
|
||||
append(nodes, Node{kind = .Parent, key = tok.value, first_child = -1})
|
||||
err := parse_section(tokens, pos, nodes, tok.value)
|
||||
if err != nil do return err
|
||||
append(
|
||||
nodes,
|
||||
Node{kind = .Parent, key = tok.value, indent = tok.indent, first_child = -1},
|
||||
)
|
||||
parse_section(tokens, pos, nodes, tok.value) or_return
|
||||
nodes[idx].first_child = idx + 1
|
||||
nodes[idx].child_count = len(nodes) - idx - 1
|
||||
|
||||
case .Block_Open:
|
||||
pos^ += 1
|
||||
idx := len(nodes)
|
||||
append(nodes, Node{kind = .Block, key = tok.value, first_child = -1})
|
||||
err := parse_section(tokens, pos, nodes, tok.value)
|
||||
if err != nil do return err
|
||||
append(
|
||||
nodes,
|
||||
Node{kind = .Block, key = tok.value, indent = tok.indent, first_child = -1},
|
||||
)
|
||||
parse_section(tokens, pos, nodes, tok.value) or_return
|
||||
nodes[idx].first_child = idx + 1
|
||||
nodes[idx].child_count = len(nodes) - idx - 1
|
||||
}
|
||||
@@ -233,6 +259,172 @@ parse_section :: proc(
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-parse: de-indent block content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
deindent_blocks :: proc(all_nodes: []Node, start: int, end: int, allocator := context.allocator) {
|
||||
i := start
|
||||
for i < end {
|
||||
#partial switch all_nodes[i].kind {
|
||||
case .Block:
|
||||
if all_nodes[i].child_count > 0 {
|
||||
cs := all_nodes[i].first_child
|
||||
ce := cs + all_nodes[i].child_count
|
||||
deindent_blocks(all_nodes, cs, ce, allocator)
|
||||
|
||||
children := all_nodes[cs:ce]
|
||||
common := find_common_indent(children)
|
||||
if len(common) > 0 {
|
||||
if len(all_nodes[i].indent) == 0 {
|
||||
all_nodes[i].indent = common
|
||||
}
|
||||
for j := cs; j < ce; {
|
||||
if all_nodes[j].kind == .Text && len(all_nodes[j].text) > 0 {
|
||||
all_nodes[j].text = remove_line_indent(
|
||||
all_nodes[j].text,
|
||||
common,
|
||||
allocator,
|
||||
)
|
||||
}
|
||||
j += node_span(all_nodes[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .Section, .Inverted, .Parent:
|
||||
if all_nodes[i].child_count > 0 {
|
||||
cs := all_nodes[i].first_child
|
||||
ce := cs + all_nodes[i].child_count
|
||||
deindent_blocks(all_nodes, cs, ce, allocator)
|
||||
}
|
||||
}
|
||||
i += node_span(all_nodes[i])
|
||||
}
|
||||
}
|
||||
|
||||
find_common_indent :: proc(children: []Node) -> string {
|
||||
common: string
|
||||
found := false
|
||||
|
||||
i := 0
|
||||
for i < len(children) {
|
||||
if children[i].kind == .Text {
|
||||
text := children[i].text
|
||||
if len(text) > 0 {
|
||||
line_start := 0
|
||||
for j in 0 ..= len(text) {
|
||||
if j == len(text) || text[j] == '\n' {
|
||||
line := text[line_start:j]
|
||||
if len(strings.trim_space(line)) > 0 {
|
||||
ws := leading_whitespace(line)
|
||||
if !found {
|
||||
common = ws
|
||||
found = true
|
||||
} else if len(ws) < len(common) {
|
||||
common = ws
|
||||
}
|
||||
}
|
||||
line_start = j + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i += node_span(children[i])
|
||||
}
|
||||
|
||||
if found {
|
||||
return common
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
leading_whitespace :: proc(s: string) -> string {
|
||||
for i in 0 ..< len(s) {
|
||||
if s[i] != ' ' && s[i] != '\t' {
|
||||
return s[:i]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
remove_line_indent :: proc(s: string, indent: string, allocator := context.allocator) -> string {
|
||||
if len(indent) == 0 {
|
||||
return s
|
||||
}
|
||||
|
||||
buf := make([dynamic]u8, 0, len(s), allocator)
|
||||
i := 0
|
||||
at_line_start := true
|
||||
|
||||
for i < len(s) {
|
||||
if at_line_start {
|
||||
if i + len(indent) <= len(s) && s[i:i + len(indent)] == indent {
|
||||
i += len(indent)
|
||||
at_line_start = false
|
||||
continue
|
||||
}
|
||||
at_line_start = false
|
||||
}
|
||||
append(&buf, s[i])
|
||||
if s[i] == '\n' {
|
||||
at_line_start = true
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
return string(buf[:])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Indentation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
indent_lines :: proc(source: string, indent: string) -> string {
|
||||
if len(indent) == 0 || len(source) == 0 {
|
||||
return source
|
||||
}
|
||||
b: strings.Builder
|
||||
strings.builder_init(&b, context.temp_allocator)
|
||||
write_indented(&b, indent, source)
|
||||
return strings.to_string(b)
|
||||
}
|
||||
|
||||
write_indented :: proc(b: ^strings.Builder, indent: string, content: string) {
|
||||
if len(indent) == 0 || len(content) == 0 {
|
||||
strings.write_string(b, content)
|
||||
return
|
||||
}
|
||||
at_line_start := true
|
||||
for i in 0 ..< len(content) {
|
||||
if at_line_start {
|
||||
strings.write_string(b, indent)
|
||||
at_line_start = false
|
||||
}
|
||||
strings.write_byte(b, content[i])
|
||||
if content[i] == '\n' {
|
||||
at_line_start = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render_template :: proc(
|
||||
pt: Template,
|
||||
ctx: ^[dynamic]any,
|
||||
partials: map[string]Template,
|
||||
b: ^strings.Builder,
|
||||
blocks: map[string]Block_Override,
|
||||
indent: string,
|
||||
) -> Render_Error {
|
||||
if len(indent) > 0 && len(pt.source) > 0 {
|
||||
indented := indent_lines(pt.source, indent)
|
||||
reparse := parse(indented, context.temp_allocator, context.temp_allocator) or_return
|
||||
return render_nodes(reparse.nodes[:], reparse.nodes[:], ctx, partials, b, blocks)
|
||||
}
|
||||
return render_nodes(pt.nodes[:], pt.nodes[:], ctx, partials, b, blocks)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Renderer — walk node array against context stack, write to builder
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -269,18 +461,16 @@ render_nodes :: proc(
|
||||
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 {
|
||||
for j in 0 ..< count {
|
||||
elem_ptr := rawptr(uintptr(data) + uintptr(j) * uintptr(elem_info.size))
|
||||
append(ctx, any{elem_ptr, elem_info.id})
|
||||
err := render_nodes(all_nodes, children, ctx, partials, b, blocks)
|
||||
pop(ctx)
|
||||
if err != nil do return err
|
||||
defer pop(ctx)
|
||||
render_nodes(all_nodes, children, ctx, partials, b, blocks) or_return
|
||||
}
|
||||
} else {
|
||||
append(ctx, val)
|
||||
err := render_nodes(all_nodes, children, ctx, partials, b, blocks)
|
||||
pop(ctx)
|
||||
if err != nil do return err
|
||||
defer pop(ctx)
|
||||
render_nodes(all_nodes, children, ctx, partials, b, blocks) or_return
|
||||
}
|
||||
}
|
||||
i += 1 + node.child_count
|
||||
@@ -289,8 +479,7 @@ render_nodes :: proc(
|
||||
val := resolve_name(node.key, ctx[:])
|
||||
if !is_truthy(val) {
|
||||
children := all_nodes[node.first_child:node.first_child + node.child_count]
|
||||
err := render_nodes(all_nodes, children, ctx, partials, b, blocks)
|
||||
if err != nil do return err
|
||||
render_nodes(all_nodes, children, ctx, partials, b, blocks) or_return
|
||||
}
|
||||
i += 1 + node.child_count
|
||||
|
||||
@@ -302,25 +491,49 @@ render_nodes :: proc(
|
||||
}
|
||||
pt, found := partials[name]
|
||||
if found {
|
||||
err := render_nodes(pt.nodes[:], pt.nodes[:], ctx, partials, b)
|
||||
if err != nil do return err
|
||||
render_template(pt, ctx, partials, b, nil, node.indent) or_return
|
||||
}
|
||||
i += 1
|
||||
|
||||
case .Block:
|
||||
rendered_override := false
|
||||
content_nodes: []Node
|
||||
content_pool: []Node
|
||||
content_blocks := blocks
|
||||
|
||||
found_override := false
|
||||
if blocks != nil {
|
||||
if o, ok := blocks[node.key]; ok {
|
||||
children := o.all_nodes[o.first:o.first + o.count]
|
||||
err := render_nodes(o.all_nodes, children, ctx, partials, b)
|
||||
if err != nil do return err
|
||||
rendered_override = true
|
||||
content_nodes = o.all_nodes[o.first:o.first + o.count]
|
||||
content_pool = o.all_nodes
|
||||
found_override = true
|
||||
}
|
||||
}
|
||||
if !rendered_override {
|
||||
children := all_nodes[node.first_child:node.first_child + node.child_count]
|
||||
err := render_nodes(all_nodes, children, ctx, partials, b, blocks)
|
||||
if err != nil do return err
|
||||
if !found_override {
|
||||
content_nodes = all_nodes[node.first_child:node.first_child + node.child_count]
|
||||
content_pool = all_nodes
|
||||
}
|
||||
|
||||
if len(node.indent) > 0 {
|
||||
temp: strings.Builder
|
||||
strings.builder_init(&temp, context.temp_allocator)
|
||||
render_nodes(
|
||||
content_pool,
|
||||
content_nodes,
|
||||
ctx,
|
||||
partials,
|
||||
&temp,
|
||||
content_blocks,
|
||||
) or_return
|
||||
write_indented(b, node.indent, strings.to_string(temp))
|
||||
} else {
|
||||
render_nodes(
|
||||
content_pool,
|
||||
content_nodes,
|
||||
ctx,
|
||||
partials,
|
||||
b,
|
||||
content_blocks,
|
||||
) or_return
|
||||
}
|
||||
i += 1 + node.child_count
|
||||
|
||||
@@ -329,8 +542,7 @@ render_nodes :: proc(
|
||||
merged := merge_block_overrides(parent_children, all_nodes, blocks)
|
||||
pt, found := partials[node.key]
|
||||
if found {
|
||||
err := render_nodes(pt.nodes[:], pt.nodes[:], ctx, partials, b, merged)
|
||||
if err != nil do return err
|
||||
render_template(pt, ctx, partials, b, merged, node.indent) or_return
|
||||
}
|
||||
i += 1 + node.child_count
|
||||
}
|
||||
@@ -354,16 +566,14 @@ merge_block_overrides :: proc(
|
||||
child := children[i]
|
||||
if child.kind == .Block {
|
||||
if _, exists := result[child.key]; !exists {
|
||||
result[child.key] = Block_Override{
|
||||
result[child.key] = Block_Override {
|
||||
all_nodes = all_nodes,
|
||||
first = child.first_child,
|
||||
count = child.child_count,
|
||||
}
|
||||
}
|
||||
i += 1 + child.child_count
|
||||
} else {
|
||||
i += 1
|
||||
}
|
||||
i += node_span(child)
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
#+test
|
||||
package mustache
|
||||
|
||||
import "core:mem"
|
||||
import "core:testing"
|
||||
|
||||
@(test)
|
||||
leak_parse_free :: proc(t: ^testing.T) {
|
||||
tmpl, err := parse("Hello {{name}}!")
|
||||
testing.expect(t, err == nil)
|
||||
defer template_free(&tmpl)
|
||||
}
|
||||
|
||||
@(test)
|
||||
leak_parse_free_tokens :: proc(t: ^testing.T) {
|
||||
arena: mem.Dynamic_Arena
|
||||
backing: [256]byte
|
||||
mem.dynamic_arena_init(&arena)
|
||||
defer mem.dynamic_arena_destroy(&arena)
|
||||
|
||||
tmpl, err := parse("Hello {{name}}!", context.allocator, mem.dynamic_arena_allocator(&arena))
|
||||
testing.expect(t, err == nil)
|
||||
defer template_free(&tmpl)
|
||||
}
|
||||
|
||||
@(test)
|
||||
leak_parse_error :: proc(t: ^testing.T) {
|
||||
tmpl, err := parse("Hello {{name")
|
||||
testing.expect(t, err != nil)
|
||||
}
|
||||
|
||||
@(test)
|
||||
leak_render :: proc(t: ^testing.T) {
|
||||
Data :: struct {
|
||||
name: string,
|
||||
}
|
||||
tmpl, err := parse("Hello {{name}}!")
|
||||
testing.expect(t, err == nil)
|
||||
defer template_free(&tmpl)
|
||||
|
||||
result, rerr := render(tmpl, Data{name = "World"})
|
||||
testing.expect(t, rerr == nil)
|
||||
defer delete(result)
|
||||
}
|
||||
|
||||
@(test)
|
||||
leak_render_partials :: proc(t: ^testing.T) {
|
||||
partials := make_map(map[string]Template)
|
||||
defer {
|
||||
for _, &p in partials {
|
||||
template_free(&p)
|
||||
}
|
||||
delete(partials)
|
||||
}
|
||||
|
||||
pt, perr := parse("world")
|
||||
testing.expect(t, perr == nil)
|
||||
partials["name"] = pt
|
||||
|
||||
tmpl, err := parse("Hello {{> name}}!")
|
||||
testing.expect(t, err == nil)
|
||||
defer template_free(&tmpl)
|
||||
|
||||
Data :: struct {
|
||||
name: string,
|
||||
}
|
||||
result, rerr := render(tmpl, Data{name = "World"}, partials)
|
||||
testing.expect(t, rerr == nil)
|
||||
defer delete(result)
|
||||
}
|
||||
|
||||
@(test)
|
||||
leak_render_sections :: proc(t: ^testing.T) {
|
||||
tmpl, err := parse("{{#items}}{{.}}{{/items}}")
|
||||
testing.expect(t, err == nil)
|
||||
defer template_free(&tmpl)
|
||||
|
||||
Data :: struct {
|
||||
items: [3]string,
|
||||
}
|
||||
result, rerr := render(tmpl, Data{items = {"a", "b", "c"}})
|
||||
testing.expect(t, rerr == nil)
|
||||
defer delete(result)
|
||||
}
|
||||
|
||||
@(test)
|
||||
leak_render_inheritance :: proc(t: ^testing.T) {
|
||||
partials := make_map(map[string]Template)
|
||||
defer delete_partials(partials)
|
||||
|
||||
layout_src := "{{$title}}default{{/title}}"
|
||||
layout, lerr := parse(layout_src)
|
||||
testing.expect(t, lerr == nil)
|
||||
partials["layout"] = layout
|
||||
|
||||
tmpl_src := "{{<layout}}{{$title}}custom{{/title}}{{/layout}}"
|
||||
tmpl, err := parse(tmpl_src)
|
||||
testing.expect(t, err == nil)
|
||||
defer template_free(&tmpl)
|
||||
|
||||
result, rerr := render(tmpl, {}, partials)
|
||||
testing.expect(t, rerr == nil)
|
||||
defer delete(result)
|
||||
}
|
||||
|
||||
@(test)
|
||||
leak_repeated_render :: proc(t: ^testing.T) {
|
||||
tmpl, err := parse("Hello {{name}}!")
|
||||
testing.expect(t, err == nil)
|
||||
defer template_free(&tmpl)
|
||||
|
||||
Data :: struct {
|
||||
name: string,
|
||||
}
|
||||
for _ in 0 ..< 3 {
|
||||
result, rerr := render(tmpl, Data{name = "World"})
|
||||
testing.expect(t, rerr == nil)
|
||||
defer delete(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@ package mustache
|
||||
import "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:os"
|
||||
import "core:path/filepath"
|
||||
import "core:testing"
|
||||
|
||||
run_spec_file :: proc(t: ^testing.T, path: string) {
|
||||
dirs := [?]string{#directory, path}
|
||||
path, _ := filepath.join(dirs[:], context.temp_allocator)
|
||||
raw_data, err := os.read_entire_file(path, context.allocator)
|
||||
if err != nil {
|
||||
testing.expectf(t, false, "Failed to read %s", path)
|
||||
|
||||
+74
-14
@@ -19,6 +19,7 @@ Token :: struct {
|
||||
kind: Token_Kind,
|
||||
value: string,
|
||||
is_dynamic: bool,
|
||||
indent: string,
|
||||
pos: int,
|
||||
}
|
||||
|
||||
@@ -141,24 +142,83 @@ tokenize :: proc(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
trim_standalone_whitespace :: proc(tokens: ^[dynamic]Token) {
|
||||
for i := 0; i < len(tokens); i += 1 {
|
||||
if !should_trim_whitespace(tokens[i].kind) do continue
|
||||
n := len(tokens)
|
||||
|
||||
left_ok, left_idx := check_left(tokens[:], i)
|
||||
right_ok, right_idx := check_right(tokens[:], i)
|
||||
// First pass: detect standalone status using original token values
|
||||
li_buf := make([dynamic]int, n, context.temp_allocator)
|
||||
defer delete(li_buf)
|
||||
ri_buf := make([dynamic]int, n, context.temp_allocator)
|
||||
defer delete(ri_buf)
|
||||
|
||||
if !left_ok || !right_ok do continue
|
||||
|
||||
if left_idx >= 0 {
|
||||
text := tokens[left_idx].value
|
||||
nl := strings.last_index_byte(text, '\n')
|
||||
tokens[left_idx].value = text[:nl+1] if nl >= 0 else ""
|
||||
for i := 0; i < n; i += 1 {
|
||||
li_buf[i] = -1
|
||||
ri_buf[i] = -1
|
||||
}
|
||||
|
||||
if right_idx >= 0 {
|
||||
text := tokens[right_idx].value
|
||||
for i := 0; i < n; i += 1 {
|
||||
should_trim_whitespace(tokens[i].kind) or_continue
|
||||
|
||||
left_ok, li := check_left(tokens[:], i)
|
||||
right_ok, ri := check_right(tokens[:], i)
|
||||
|
||||
if left_ok && right_ok {
|
||||
li_buf[i] = li
|
||||
ri_buf[i] = ri
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: apply trims left-to-right
|
||||
// Track which text tokens have been trimmed to avoid double-trimming
|
||||
left_done := make([dynamic]bool, n, context.temp_allocator)
|
||||
defer delete(left_done)
|
||||
right_done := make([dynamic]bool, n, context.temp_allocator)
|
||||
defer delete(right_done)
|
||||
|
||||
for i := 0; i < n; i += 1 {
|
||||
should_trim_whitespace(tokens[i].kind) or_continue
|
||||
li := li_buf[i]
|
||||
ri := ri_buf[i]
|
||||
if li < 0 && ri < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if li >= 0 && !left_done[li] {
|
||||
left_done[li] = true
|
||||
text := tokens[li].value
|
||||
nl := strings.last_index_byte(text, '\n')
|
||||
if tokens[i].kind == .Partial ||
|
||||
tokens[i].kind == .Parent ||
|
||||
tokens[i].kind == .Block_Open {
|
||||
tokens[i].indent = text[nl + 1:] if nl >= 0 else text
|
||||
}
|
||||
tokens[li].value = text[:nl + 1] if nl >= 0 else ""
|
||||
}
|
||||
|
||||
if ri >= 0 && !right_done[ri] {
|
||||
right_done[ri] = true
|
||||
// When a Block_Open and its Section_Close are adjacent (e.g.
|
||||
// {{$block}}{{/block}}\n), the close tag is the one that
|
||||
// should consume the trailing newline — not the block open.
|
||||
// Skip the right-trim if a Section_Close sits between this
|
||||
// tag and its right text, or if this IS the Section_Close
|
||||
// immediately following a Block_Open (the close handles it).
|
||||
skip := false
|
||||
if tokens[i].kind == .Block_Open {
|
||||
for k := i + 1; k < ri; k += 1 {
|
||||
if tokens[k].kind == .Section_Close {
|
||||
skip = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if tokens[i].kind == .Section_Close && i > 0 && tokens[i - 1].kind == .Block_Open {
|
||||
skip = true
|
||||
}
|
||||
if !skip {
|
||||
text := tokens[ri].value
|
||||
nl := strings.index_byte(text, '\n')
|
||||
tokens[right_idx].value = text[nl+1:] if nl >= 0 else ""
|
||||
tokens[ri].value = text[nl + 1:] if nl >= 0 else ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,7 +234,7 @@ check_left :: proc(tokens: []Token, i: int) -> (ok: bool, text_idx: int) {
|
||||
}
|
||||
nl := strings.last_index_byte(text, '\n')
|
||||
if nl >= 0 {
|
||||
return strings.trim_space(text[nl+1:]) == "", j
|
||||
return strings.trim_space(text[nl + 1:]) == "", j
|
||||
}
|
||||
if j == 0 {
|
||||
return strings.trim_space(text) == "", j
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
#+feature dynamic-literals
|
||||
#+test
|
||||
package main
|
||||
|
||||
import "core:testing"
|
||||
import "mustache"
|
||||
|
||||
@(test)
|
||||
test_simple_substitution :: proc(t: ^testing.T) {
|
||||
data := map[string]string {
|
||||
"name" = "World",
|
||||
}
|
||||
tpl, _ := mustache.parse("Hello, {{name}}!")
|
||||
result, err := mustache.render(tpl, data)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "Hello, World!")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_bool_section :: proc(t: ^testing.T) {
|
||||
data := map[string]bool {
|
||||
"show" = true,
|
||||
}
|
||||
tpl, _ := mustache.parse("{{#show}}visible{{/show}}")
|
||||
result, err := mustache.render(tpl, data)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "visible")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_inverted_section :: proc(t: ^testing.T) {
|
||||
data := map[string]bool {
|
||||
"show" = false,
|
||||
}
|
||||
tpl, _ := mustache.parse("{{^show}}hidden{{/show}}")
|
||||
result, err := mustache.render(tpl, data)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "hidden")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_unescaped :: proc(t: ^testing.T) {
|
||||
data := map[string]string {
|
||||
"html" = "<b>bold</b>",
|
||||
}
|
||||
tpl, _ := mustache.parse("{{{html}}}")
|
||||
result, err := mustache.render(tpl, data)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "<b>bold</b>")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_array_iteration :: proc(t: ^testing.T) {
|
||||
items := make([dynamic]map[string]string)
|
||||
defer delete(items)
|
||||
append(&items, map[string]string{"name" = "Alice"})
|
||||
append(&items, map[string]string{"name" = "Bob"})
|
||||
|
||||
data := map[string][dynamic]map[string]string {
|
||||
"items" = items,
|
||||
}
|
||||
tpl, _ := mustache.parse("{{#items}}{{name}} {{/items}}")
|
||||
result, err := mustache.render(tpl, data)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "Alice Bob ")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_partial :: proc(t: ^testing.T) {
|
||||
data := map[string]string {
|
||||
"name" = "Test",
|
||||
}
|
||||
greeting_tpl, _ := mustache.parse("Hello, {{name}}!")
|
||||
partials := map[string]mustache.Template {
|
||||
"greeting" = greeting_tpl,
|
||||
}
|
||||
main_tpl, _ := mustache.parse("{{>greeting}}")
|
||||
result, err := mustache.render(main_tpl, data, partials)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "Hello, Test!")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_mixed_types :: proc(t: ^testing.T) {
|
||||
data := map[string]any {
|
||||
"site_title" = "One Idiot Developer",
|
||||
"has_date" = true,
|
||||
"date" = "17 Jul 2025",
|
||||
}
|
||||
|
||||
tpl, _ := mustache.parse("{{site_title}}: {{#has_date}}{{date}}{{/has_date}}")
|
||||
result, err := mustache.render(tpl, data)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "One Idiot Developer: 17 Jul 2025")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_nested_context :: proc(t: ^testing.T) {
|
||||
page := map[string]string {
|
||||
"title" = "My Post",
|
||||
}
|
||||
data := map[string]any {
|
||||
"site_title" = "One Idiot Developer",
|
||||
"page" = page,
|
||||
}
|
||||
|
||||
tpl, _ := mustache.parse("{{site_title}}: {{#page}}{{title}}{{/page}}")
|
||||
result, err := mustache.render(tpl, data)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "One Idiot Developer: My Post")
|
||||
}
|
||||
Reference in New Issue
Block a user