From e4e74df27e519cb1050ac739a888692d49424d10 Mon Sep 17 00:00:00 2001 From: Spencer Brower <6729162+sbrow@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:07:44 -0400 Subject: [PATCH] refactor: Rewrote the `mustache` package from scratch. --- mustache/.gitignore | 9 +- mustache/.gitmodules | 4 - mustache/Justfile | 15 - mustache/LICENSE | 19 - mustache/PARTIAL_INDENT.md | 137 ++ mustache/README.md | 162 -- mustache/TODOS.md | 2 + mustache/data.odin | 221 +++ mustache/examples/hello/hello.mustache | 11 - mustache/examples/hello/hello.odin | 28 - mustache/mustache.odin | 2141 ++++-------------------- mustache/mustache_test.odin | 1045 ------------ mustache/spec_test.odin | 127 ++ mustache/test/data.json | 5 - mustache/test/layout.txt | 3 - mustache/test/template.txt | 1 - mustache/tokenizer.odin | 227 +++ mustache_test.odin | 41 +- render.odin | 37 +- 19 files changed, 1061 insertions(+), 3174 deletions(-) delete mode 100644 mustache/.gitmodules delete mode 100644 mustache/Justfile delete mode 100644 mustache/LICENSE create mode 100644 mustache/PARTIAL_INDENT.md delete mode 100644 mustache/README.md create mode 100644 mustache/TODOS.md create mode 100644 mustache/data.odin delete mode 100644 mustache/examples/hello/hello.mustache delete mode 100644 mustache/examples/hello/hello.odin delete mode 100644 mustache/mustache_test.odin create mode 100644 mustache/spec_test.odin delete mode 100644 mustache/test/data.json delete mode 100644 mustache/test/layout.txt delete mode 100644 mustache/test/template.txt create mode 100644 mustache/tokenizer.odin diff --git a/mustache/.gitignore b/mustache/.gitignore index 9a7cb7b..6dd7461 100644 --- a/mustache/.gitignore +++ b/mustache/.gitignore @@ -1,8 +1 @@ -*/**/.DS_Store -.DS_Store -tmp/ -bin/ -*/**/odin-mustache.dSYM/ -*/**/odin-mustache -*/**/odin-mustache-test.dSYM/ -*/**/odin-mustache-test +mustache diff --git a/mustache/.gitmodules b/mustache/.gitmodules deleted file mode 100644 index e032690..0000000 --- a/mustache/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "spec"] - path = spec - url = git@github.com:mustache/spec.git - branch = v1.4.1 diff --git a/mustache/Justfile b/mustache/Justfile deleted file mode 100644 index b045f40..0000000 --- a/mustache/Justfile +++ /dev/null @@ -1,15 +0,0 @@ -flags := "-vet -show-timings -strict-style -vet-cast -vet-tabs -vet-using-param -disallow-do -vet-semicolon" -name := "odin-mustache" - -build: - @mkdir -p bin - odin build . -out:bin/{{name}} -debug {{flags}} - -test: build - odin test . -out:bin/{{name}} - -run: build - bin/{{name}} test/template.txt test/data.json test/layout.txt - -check: - odin check . {{flags}} diff --git a/mustache/LICENSE b/mustache/LICENSE deleted file mode 100644 index 367a29a..0000000 --- a/mustache/LICENSE +++ /dev/null @@ -1,19 +0,0 @@ -Copyright (c) 2025 Benjamin Block - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/mustache/PARTIAL_INDENT.md b/mustache/PARTIAL_INDENT.md new file mode 100644 index 0000000..a6db0dc --- /dev/null +++ b/mustache/PARTIAL_INDENT.md @@ -0,0 +1,137 @@ +# 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 `{{ "Hello, St. Charles!" -``` - -## Escaping -`odin-mustache` follows the official mustache HTML escaping rules. That is, if you enclose a variable with two curly brackets, `{{var}}`, the contents are HTML-escaped. For instance, strings like `5 > 2` are converted to `5 > 2`. To use raw characters, use three curly brackets `{{{var}}}`. - -## Layouts -`odin-mustache` supports rendering templates with layouts. - -A layout is a regular template with a special function. It accepts **a `{{content}}` tag**. This is where the output of a child template will be inserted. Layouts have access to the same data provided to the regular template. - -Layouts can render content in both `{{normal}}` and `{{{literal}}}` tags. - -This is helpful for rendering scenarios like websites where the same elements (``, `