From 7e93c3468f34dfc918621d7fd06cec3d0eaa9e46 Mon Sep 17 00:00:00 2001 From: Spencer Brower <6729162+sbrow@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:06:17 -0400 Subject: [PATCH] fix(mustache): Got whitespace management to pass the tests. --- AGENTS.md | 41 +++-- mustache/PARTIAL_INDENT.md | 137 ----------------- mustache/TODOS.md | 7 +- mustache/data.odin | 107 +++++++++---- mustache/mustache.odin | 290 +++++++++++++++++++++++++++++++----- mustache/mustache_test.odin | 121 +++++++++++++++ mustache/spec_test.odin | 3 + mustache/tokenizer.odin | 88 +++++++++-- mustache_test.odin | 111 -------------- 9 files changed, 559 insertions(+), 346 deletions(-) delete mode 100644 mustache/PARTIAL_INDENT.md create mode 100644 mustache/mustache_test.odin delete mode 100644 mustache_test.odin diff --git a/AGENTS.md b/AGENTS.md index df50195..07c1c9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ public/ ← build output (generated) | `sectionate.odin` | `wrap_sections` proc — splits HTML at `` 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 `
` 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
 
diff --git a/mustache/PARTIAL_INDENT.md b/mustache/PARTIAL_INDENT.md
deleted file mode 100644
index a6db0dc..0000000
--- a/mustache/PARTIAL_INDENT.md
+++ /dev/null
@@ -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 `{{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)
diff --git a/mustache/data.odin b/mustache/data.odin
index 6314480..6f2dfa3 100644
--- a/mustache/data.odin
+++ b/mustache/data.odin
@@ -9,19 +9,27 @@ 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
-		return effective(variant)
+		if variant == nil {
+			return {}, nil
+		} else {
+			return effective(variant)
+		}
 	}
 
 	info = base
@@ -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
-
-		result = any{value_ptr, mi.value.id}
-		found = true
-		return
-
-	case:
-		return nil, false
+		if value_ptr != nil {
+			result = any{value_ptr, mi.value.id}
+			found = true
+		}
 	}
+
 	return
 }
 
@@ -68,8 +77,11 @@ 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]
-		return nil
+		if len(ctx) > 0 {
+			return ctx[len(ctx) - 1]
+		} else {
+			return nil
+		}
 	}
 
 	parts: [16]string
@@ -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, "&")
diff --git a/mustache/mustache.odin b/mustache/mustache.odin
index a065847..9f4a956 100644
--- a/mustache/mustache.odin
+++ b/mustache/mustache.odin
@@ -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,
+	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.. 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,
+					first     = child.first_child,
+					count     = child.child_count,
 				}
 			}
-			i += 1 + child.child_count
-		} else {
-			i += 1
 		}
+		i += node_span(child)
 	}
 
 	return result
diff --git a/mustache/mustache_test.odin b/mustache/mustache_test.odin
new file mode 100644
index 0000000..b174495
--- /dev/null
+++ b/mustache/mustache_test.odin
@@ -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 := "{{= 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 {
+		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 right_idx >= 0 {
-			text := tokens[right_idx].value
-			nl := strings.index_byte(text, '\n')
-			tokens[right_idx].value = text[nl+1:] if nl >= 0 else ""
+		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[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
diff --git a/mustache_test.odin b/mustache_test.odin
deleted file mode 100644
index 49f3453..0000000
--- a/mustache_test.odin
+++ /dev/null
@@ -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" = "bold",
-	}
-	tpl, _ := mustache.parse("{{{html}}}")
-	result, err := mustache.render(tpl, data)
-	testing.expect(t, err == nil)
-	testing.expect(t, result == "bold")
-}
-
-@(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")
-}