refactor: Rewrote the mustache package from scratch.

This commit is contained in:
Spencer Brower
2026-07-15 15:07:44 -04:00
parent 28975736cd
commit e4e74df27e
19 changed files with 1061 additions and 3174 deletions
+1 -8
View File
@@ -1,8 +1 @@
*/**/.DS_Store mustache
.DS_Store
tmp/
bin/
*/**/odin-mustache.dSYM/
*/**/odin-mustache
*/**/odin-mustache-test.dSYM/
*/**/odin-mustache-test
-4
View File
@@ -1,4 +0,0 @@
[submodule "spec"]
path = spec
url = git@github.com:mustache/spec.git
branch = v1.4.1
-15
View File
@@ -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}}
-19
View File
@@ -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.
+137
View File
@@ -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 `{{<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).
-162
View File
@@ -1,162 +0,0 @@
# odin-mustache
Native implementation of {{mustache}} templates in [Odin](https://odin-lang.org).
https://github.com/benjamindblock/odin-mustache/assets/1155805/7d794861-e308-4132-aaa0-f800392208a4
All features are implemented, except for the ability to change delimiters.
All in tests in the [official mustache spec](https://github.com/mustache/spec) pass successfully (except for the delimiters spec suite).
## Documentation
For more information about mustache, see the [mustache project page](https://mustache.github.io) or the mustache [man](https://mustache.github.io/mustache.5.html) [pages](https://mustache.github.io/mustache.1.html).
View some [example mustache files](https://github.com/mustache/mustache/tree/master/examples) to get an overview.
## Spec
The [mustache-spec](https://github.com/mustache/spec) repo is added as a git submodule for testing purposes. To run tests, ensure that you run `git clone --recursive` and/or `git submodule update` as needed.
## Usage
### CLI Usage
```
Usage:
odin-mustache [path to template] [path to JSON] [OPTIONAL - layout]
Examples:
$ odin-mustache template.txt data.json
```
### Odin Usage
#### 1. `render(template: string, data: any, partials: any, allocator := context.allocator)`
Renders a template, provided as a `string`. `data` and `partials` should be *either* a `map[string]...` or a `struct`.
All `map` arguments passed **must** be keyed with `string` type data. When parsing a Mustache template, the text inside a tag (eg. `name` inside `{{name}}`) will be parsed as a `string`.
#### 2. `render_from_filename(filename: string, data: any, partials: any, allocator := context.allocator)`
Renders a template stored in a text file using `data` and `partials` provided.
#### 3. `render_with_json(template: string, json_filename: string, allocator := context.allocator)`
Renders a template `string` using data and partials stored inside a JSON file. `odin-mustache` will handle loading the JSON into a usable format for Mustache to work with.
**NOTE**: The JSON file leverages the following top-level keys:
```
"data": [required]
"partials": [optional]
```
#### 4. `render_from_filename_with_json(filename: string, json_filename: string, allocator := context.allocator)`
Renders a template stored in a text file using data and partials stored inside a JSON file. `odin-mustache` will handle loading the JSON into a usable format for Mustache to work with.
#### Example
```odin
input := "Hello, {{name}}!"
data: map[string]string = {
"name" = "St. Charles",
}
output, err := render(input, data, context.temp_allocator)
// => "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 &gt; 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 (`<head>`, `<footer>`, etc.) are shared across all pages, each of which has their own page-specific template.
```
<html>
{{{head}}
<body>
{{{content}}}
</body>
{{{footer}}
</html>
```
### CLI Usage
```
Usage:
odin-mustache [path to template] [path to JSON] [OPTIONAL - layout]
Examples:
$ odin-mustache template.html data.json layout.html
```
### Odin Usage
To render a layout in Odin, all four of the above render procedures have `in_layout` and `in_layout_file` variations. These methods will insert the rendered content of a template within the given layout. This is convenient for rendering HTML views inside a larger layout, amongst other use-cases.
The full list of corresponding methods is:
- `render_in_layout(template: string, data: any, layout: string, partials: any, allocator := context.allocator)`
- `render_in_layout_file(template: string, data: any, layout_filename: string, partials: any, allocator := context.allocator)`
- `render_from_filename_in_layout(filename: string, data: any, layout: string, partials: any, allocator := context.allocator)`
- `render_from_filename_in_layout_file(filename: string, data: any, layout_filename: string, partials: any, allocator := context.allocator)`
- `render_with_json_in_layout(template: string, json_filename: string, layout: string, allocator := context.allocator)`
- `render_with_json_in_layout_file(template: string, json_filename: string, layout_filename: string, allocator := context.allocator)`
- `render_from_filename_with_json_in_layout(filename: string, json_filename: string, layout: string, allocator := context.allocator)`
- `render_from_filename_with_json_in_layout_file(filename: string, json_filename: string, layout_filename: string, allocator := context.allocator)`
#### Example
```odin
template := "Hello, {{name}}."
data := map[string]string{"name" = "Kilgarvan"}
layout := `Above >>
{{content}}
<< Below`
output, _ := render_in_layout(template, data, layout, allocator := context.temp_allocator)
fmt.println(output)
// Above >>
// Hello, Kilgarvan.
// << Below
```
## Precompiled Templates
`odin-mustache` works in two steps:
1. Lexing and parsing
2. Rendering with data
If you are rendering the same template multiple times (ex: sending out personalized emails to subscribers), the lexing+parsing step can be performed once to create a compiled template. This compiled template can then be used multiple times with different data.
### Example
```odin
src := "Hello, {{name}}!"
template: Template
output: string
data: map[string]string
partials: map[string]string
lexer := Lexer{src=src, delim=CORE_DEF}
lexer_parse(&lexer)
data = {"name" = "St. Charles"}
template = Template{lexer=lexer, data=data, partials=partials}
output, _ = template_render(&template)
// => Hello, St. Charles!
data = {"name" = "Edouard"}
template = Template{lexer=lexer, data=data, partials=partials}
output, _ = template_render(&template)
// => Hello, Edouard!
```
## Future Work
- Validate JSON keys
- Better CLI argument parsing
- Improve error handling and reporting
- Add optional logging for debugging and performance work
- Configurable precision for floating point types
- Add support for changing delimiters
- Special loop conditionals (eg., checking for the first iteration or last iteration)
+2
View File
@@ -0,0 +1,2 @@
- [ ] Initialize string builder size based on filesize?
- [ ] Review [whitespace handling code](./tokenizer.odin)
+221
View File
@@ -0,0 +1,221 @@
package mustache
import "base:runtime"
import "core:fmt"
import "core:reflect"
import "core:strconv"
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) {
val = a
info = nil
if val == nil do return
ti := type_info_of(val.id)
if ti == nil do 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)
}
info = base
return
}
// 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
val, info := effective(container)
if info == nil do return nil, false
#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
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
}
return
}
// resolve_name resolves a (possibly dotted) name from the context stack.
// The first segment walks the stack top-to-bottom; remaining segments
// 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
}
parts: [16]string
part_count := 0
start := 0
for i in 0 ..< len(name) {
if name[i] == '.' {
if part_count < len(parts) {
parts[part_count] = name[start:i]
part_count += 1
}
start = i + 1
}
}
if part_count < len(parts) {
parts[part_count] = name[start:]
part_count += 1
}
if part_count == 0 do 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 do return nil
if len(dot_parts) == 1 do return result
for i := 1; i < len(dot_parts); i += 1 {
result, found = lookup_in(result, dot_parts[i])
if !found do return nil
}
return result
}
// is_truthy checks mustache truthiness.
is_truthy :: proc(a: any) -> bool {
if a == nil do return false
val, info := effective(a)
if info == nil do return false
if reflect.is_nil(val) do return false
#partial switch _ in info.variant {
case runtime.Type_Info_Slice, runtime.Type_Info_Dynamic_Array, runtime.Type_Info_Map:
return reflect.length(val) > 0
case:
return true
}
}
// list_info returns element type info, count, and data pointer for a list value.
// 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
#partial switch v in info.variant {
case runtime.Type_Info_Slice:
raw := (^runtime.Raw_Slice)(val.data)^
return v.elem, raw.len, raw.data
case runtime.Type_Info_Dynamic_Array:
raw := (^runtime.Raw_Dynamic_Array)(val.data)^
return v.elem, raw.len, raw.data
case runtime.Type_Info_Array:
return v.elem, v.count, val.data
case:
return nil, 0, nil
}
}
// 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 ""
val, _ := effective(a)
switch v in val {
case string:
return v
case bool:
return "true" if v else "false"
case i64:
return fmt.tprintf("%d", v)
case f64:
// return fmt.tprintf("%.3f", v)
return format_f64(v)
case int:
return fmt.tprintf("%d", v)
case:
return ""
}
}
// format_f64 produces the shortest string that round-trips to the same f64.
// Works around Odin's strconv not implementing shortest representation.
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:]
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:]
return strings.clone(s, context.temp_allocator)
}
// write_value stringifies a value and writes it to the builder,
// 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 !escape {
strings.write_string(b, s)
return
}
start := 0
for i in 0 ..< len(s) {
switch s[i] {
case '&', '<', '>', '"':
if i > start do strings.write_string(b, s[start:i])
switch s[i] {
case '&':
strings.write_string(b, "&amp;")
case '<':
strings.write_string(b, "&lt;")
case '>':
strings.write_string(b, "&gt;")
case '"':
strings.write_string(b, "&quot;")
}
start = i + 1
}
}
if start < len(s) {
strings.write_string(b, s[start:])
}
}
-11
View File
@@ -1,11 +0,0 @@
Hello, {{name}}!
Languages you know:
{{#languages}}
- {{.}}
{{/languages}}
{{#show_footer}}
---
Rendered with odin-mustache.
{{/show_footer}}
-28
View File
@@ -1,28 +0,0 @@
package main
import "core:fmt"
import "core:os"
import mustache "../.."
Person :: struct {
name: string,
languages: []string,
show_footer: bool,
}
main :: proc() {
data := Person{
name = "World",
languages = {"Odin", "C", "Rust"},
show_footer = true,
}
result, err := mustache.render_from_filename("hello.mustache", data)
if err != nil {
fmt.eprintfln("render error: %v", err)
os.exit(1)
}
fmt.print(result)
}
+303 -1836
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+127
View File
@@ -0,0 +1,127 @@
#+test
package mustache
import "core:encoding/json"
import "core:fmt"
import "core:os"
import "core:testing"
run_spec_file :: proc(t: ^testing.T, path: string) {
raw_data, err := os.read_entire_file(path, context.allocator)
if err != nil {
testing.expectf(t, false, "Failed to read %s", path)
return
}
defer delete(raw_data)
value, jerr := json.parse(raw_data)
if jerr != .None {
testing.expectf(t, false, "JSON parse error in %s: %v", path, jerr)
return
}
defer json.destroy_value(value)
root := value.(json.Object)
tests := root["tests"].(json.Array)
passed := 0
failed := 0
for tv in tests {
test := tv.(json.Object)
name := test["name"].(string)
template_src := test["template"].(string)
expected := test["expected"].(string)
if run_one_test(t, test, name, template_src, expected) {
passed += 1
} else {
failed += 1
}
}
if failed > 0 {
testing.fail(t)
}
fmt.printfln(" %s: %d/%d passed", path, passed, passed + failed)
}
run_one_test :: proc(
t: ^testing.T,
test: json.Object,
name, template_src, expected: string,
) -> bool {
partials := make_map(map[string]Template, context.temp_allocator)
if "partials" in test {
p_obj, ok := test["partials"].(json.Object)
assert(ok)
for pname, pval in p_obj {
psrc, ok := pval.(string)
assert(ok)
pt, perr := parse(psrc, context.temp_allocator)
if perr != nil {
testing.expectf(t, false, "[%s] partial '%s' parse error", name, pname)
return false
}
partials[pname] = pt
}
}
tmpl, terr := parse(template_src, context.temp_allocator)
if terr != nil {
testing.expectf(t, false, "[%s] template parse error", name)
return false
}
defer delete(tmpl.nodes)
result, rerr := render(tmpl, test["data"], partials)
if rerr != nil {
testing.expectf(t, false, "[%s] render error", name)
return false
}
defer delete(result)
if result != expected {
testing.expectf(t, false, "[%s]\n got: %q\n expected: %q", name, result, expected)
return false
}
return true
}
@(test)
spec_interpolation :: proc(t: ^testing.T) {
run_spec_file(t, "spec/specs/interpolation.json")
}
@(test)
spec_sections :: proc(t: ^testing.T) {
run_spec_file(t, "spec/specs/sections.json")
}
@(test)
spec_inverted :: proc(t: ^testing.T) {
run_spec_file(t, "spec/specs/inverted.json")
}
@(test)
spec_comments :: proc(t: ^testing.T) {
run_spec_file(t, "spec/specs/comments.json")
}
@(test)
spec_partials :: proc(t: ^testing.T) {
run_spec_file(t, "spec/specs/partials.json")
}
@(test)
spec_dynamic_names :: proc(t: ^testing.T) {
run_spec_file(t, "spec/specs/dynamic-names.json")
}
@(test)
spec_inheritance :: proc(t: ^testing.T) {
run_spec_file(t, "spec/specs/~inheritance.json")
}
-5
View File
@@ -1,5 +0,0 @@
{
data: {
name: "Kilgarvan"
}
}
-3
View File
@@ -1,3 +0,0 @@
Begin layout >>
{{content}}
<< End layout
-1
View File
@@ -1 +0,0 @@
Hello, this is {{name}}.
+227
View File
@@ -0,0 +1,227 @@
package mustache
import "core:strings"
Token_Kind :: enum {
Text,
Variable,
Unescaped,
Section_Open,
Inverted_Open,
Section_Close,
Comment,
Partial,
Parent,
Block_Open,
}
Token :: struct {
kind: Token_Kind,
value: string,
is_dynamic: bool,
pos: int,
}
tokenize :: proc(
src: string,
allocator := context.allocator,
) -> (
tokens: [dynamic]Token,
err: Render_Error,
) {
tokens = make([dynamic]Token, 0, 8, allocator)
i := 0
text_start := 0
for i < len(src) {
if src[i] == '{' && i + 1 < len(src) && src[i + 1] == '{' {
if i > text_start {
append(&tokens, Token{kind = .Text, value = src[text_start:i], pos = text_start})
}
tag_pos := i
if i + 2 < len(src) && src[i + 2] == '{' {
content_start := i + 3
idx := strings.index(src[content_start:], "}}}")
if idx < 0 {
return tokens, Syntax_Error {
msg = "unclosed triple mustache '{{{'",
pos = tag_pos,
}
}
close := content_start + idx
key := strings.trim_space(src[content_start:close])
append(&tokens, Token{kind = .Unescaped, value = key, pos = tag_pos})
i = close + 3
text_start = i
} else {
content_start := i + 2
sigil: byte = 0
if content_start < len(src) {
sigil = src[content_start]
}
kind: Token_Kind
key_start := content_start
switch sigil {
case '&':
kind = .Unescaped; key_start = content_start + 1
case '#':
kind = .Section_Open; key_start = content_start + 1
case '^':
kind = .Inverted_Open; key_start = content_start + 1
case '/':
kind = .Section_Close; key_start = content_start + 1
case '!':
kind = .Comment; key_start = content_start + 1
case '>':
kind = .Partial; key_start = content_start + 1
case '<':
kind = .Parent; key_start = content_start + 1
case '$':
kind = .Block_Open; key_start = content_start + 1
case:
kind = .Variable
}
close_idx := strings.index(src[key_start:], "}}")
if close_idx < 0 {
return tokens, Syntax_Error{msg = "unclosed tag '{{'", pos = tag_pos}
}
close := key_start + close_idx
content := src[key_start:close]
if kind == .Comment {
append(&tokens, Token{kind = .Comment, value = content, pos = tag_pos})
} else if kind == .Partial {
trimmed := strings.trim_space(content)
is_dyn := false
if len(trimmed) > 0 && trimmed[0] == '*' {
is_dyn = true
trimmed = strings.trim_space(trimmed[1:])
}
append(
&tokens,
Token {
kind = .Partial,
value = trimmed,
is_dynamic = is_dyn,
pos = tag_pos,
},
)
} else {
append(
&tokens,
Token{kind = kind, value = strings.trim_space(content), pos = tag_pos},
)
}
i = close + 2
text_start = i
}
} else {
i += 1
}
}
if i > text_start {
append(&tokens, Token{kind = .Text, value = src[text_start:i], pos = text_start})
}
trim_standalone_whitespace(&tokens)
return tokens, nil
}
// ---------------------------------------------------------------------------
// Standalone whitespace handling
// ---------------------------------------------------------------------------
trim_standalone_whitespace :: proc(tokens: ^[dynamic]Token) {
for i := 0; i < len(tokens); i += 1 {
if !should_trim_whitespace(tokens[i].kind) do continue
left_ok, left_idx := check_left(tokens[:], i)
right_ok, right_idx := check_right(tokens[:], i)
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 ""
}
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 ""
}
}
}
check_left :: proc(tokens: []Token, i: int) -> (ok: bool, text_idx: int) {
j := i - 1
for j >= 0 {
if tokens[j].kind == .Text {
text := tokens[j].value
if len(text) == 0 {
j -= 1
continue
}
nl := strings.last_index_byte(text, '\n')
if nl >= 0 {
return strings.trim_space(text[nl+1:]) == "", j
}
if j == 0 {
return strings.trim_space(text) == "", j
}
return false, -1
} else if should_trim_whitespace(tokens[j].kind) {
j -= 1
} else {
return false, -1
}
}
return true, -1
}
check_right :: proc(tokens: []Token, i: int) -> (ok: bool, text_idx: int) {
j := i + 1
for j < len(tokens) {
if tokens[j].kind == .Text {
text := tokens[j].value
if len(text) == 0 {
j += 1
continue
}
nl := strings.index_byte(text, '\n')
if nl >= 0 {
return strings.trim_space(text[:nl]) == "", j
}
if j == len(tokens) - 1 {
return strings.trim_space(text) == "", j
}
return false, -1
} else if should_trim_whitespace(tokens[j].kind) {
j += 1
} else {
return false, -1
}
}
return true, -1
}
should_trim_whitespace :: proc(kind: Token_Kind) -> bool {
switch kind {
case .Section_Open, .Inverted_Open, .Section_Close, .Comment, .Partial, .Parent, .Block_Open:
return true
case .Text, .Variable, .Unescaped:
return false
}
return false
}
+19 -22
View File
@@ -10,7 +10,8 @@ test_simple_substitution :: proc(t: ^testing.T) {
data := map[string]string { data := map[string]string {
"name" = "World", "name" = "World",
} }
result, err := mustache.render("Hello, {{name}}!", data) tpl, _ := mustache.parse("Hello, {{name}}!")
result, err := mustache.render(tpl, data)
testing.expect(t, err == nil) testing.expect(t, err == nil)
testing.expect(t, result == "Hello, World!") testing.expect(t, result == "Hello, World!")
} }
@@ -20,7 +21,8 @@ test_bool_section :: proc(t: ^testing.T) {
data := map[string]bool { data := map[string]bool {
"show" = true, "show" = true,
} }
result, err := mustache.render("{{#show}}visible{{/show}}", data) tpl, _ := mustache.parse("{{#show}}visible{{/show}}")
result, err := mustache.render(tpl, data)
testing.expect(t, err == nil) testing.expect(t, err == nil)
testing.expect(t, result == "visible") testing.expect(t, result == "visible")
} }
@@ -30,7 +32,8 @@ test_inverted_section :: proc(t: ^testing.T) {
data := map[string]bool { data := map[string]bool {
"show" = false, "show" = false,
} }
result, err := mustache.render("{{^show}}hidden{{/show}}", data) tpl, _ := mustache.parse("{{^show}}hidden{{/show}}")
result, err := mustache.render(tpl, data)
testing.expect(t, err == nil) testing.expect(t, err == nil)
testing.expect(t, result == "hidden") testing.expect(t, result == "hidden")
} }
@@ -40,7 +43,8 @@ test_unescaped :: proc(t: ^testing.T) {
data := map[string]string { data := map[string]string {
"html" = "<b>bold</b>", "html" = "<b>bold</b>",
} }
result, err := mustache.render("{{{html}}}", data) tpl, _ := mustache.parse("{{{html}}}")
result, err := mustache.render(tpl, data)
testing.expect(t, err == nil) testing.expect(t, err == nil)
testing.expect(t, result == "<b>bold</b>") testing.expect(t, result == "<b>bold</b>")
} }
@@ -55,7 +59,8 @@ test_array_iteration :: proc(t: ^testing.T) {
data := map[string][dynamic]map[string]string { data := map[string][dynamic]map[string]string {
"items" = items, "items" = items,
} }
result, err := mustache.render("{{#items}}{{name}} {{/items}}", data) tpl, _ := mustache.parse("{{#items}}{{name}} {{/items}}")
result, err := mustache.render(tpl, data)
testing.expect(t, err == nil) testing.expect(t, err == nil)
testing.expect(t, result == "Alice Bob ") testing.expect(t, result == "Alice Bob ")
} }
@@ -65,10 +70,12 @@ test_partial :: proc(t: ^testing.T) {
data := map[string]string { data := map[string]string {
"name" = "Test", "name" = "Test",
} }
partials := map[string]string { greeting_tpl, _ := mustache.parse("Hello, {{name}}!")
"greeting" = "Hello, {{name}}!", partials := map[string]mustache.Template {
"greeting" = greeting_tpl,
} }
result, err := mustache.render("{{>greeting}}", data, partials) main_tpl, _ := mustache.parse("{{>greeting}}")
result, err := mustache.render(main_tpl, data, partials)
testing.expect(t, err == nil) testing.expect(t, err == nil)
testing.expect(t, result == "Hello, Test!") testing.expect(t, result == "Hello, Test!")
} }
@@ -81,7 +88,8 @@ test_mixed_types :: proc(t: ^testing.T) {
"date" = "17 Jul 2025", "date" = "17 Jul 2025",
} }
result, err := mustache.render("{{site_title}}: {{#has_date}}{{date}}{{/has_date}}", data) tpl, _ := mustache.parse("{{site_title}}: {{#has_date}}{{date}}{{/has_date}}")
result, err := mustache.render(tpl, data)
testing.expect(t, err == nil) testing.expect(t, err == nil)
testing.expect(t, result == "One Idiot Developer: 17 Jul 2025") testing.expect(t, result == "One Idiot Developer: 17 Jul 2025")
} }
@@ -96,19 +104,8 @@ test_nested_context :: proc(t: ^testing.T) {
"page" = page, "page" = page,
} }
result, err := mustache.render("{{site_title}}: {{#page}}{{title}}{{/page}}", data) tpl, _ := mustache.parse("{{site_title}}: {{#page}}{{title}}{{/page}}")
result, err := mustache.render(tpl, data)
testing.expect(t, err == nil) testing.expect(t, err == nil)
testing.expect(t, result == "One Idiot Developer: My Post") testing.expect(t, result == "One Idiot Developer: My Post")
} }
@(test)
test_layout :: proc(t: ^testing.T) {
layout := `<html><body>{{{content}}}</body></html>`
template := "<p>Hello!</p>"
data := map[string]string{}
result, err := mustache.render_in_layout(template, data, layout)
testing.expect(t, err == nil)
testing.expect(t, result == "<html><body><p>Hello!</p></body></html>")
}
+23 -14
View File
@@ -5,6 +5,7 @@ import "mustache"
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:log"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
import "core:time" import "core:time"
@@ -103,18 +104,22 @@ og_type :: proc(is_article: bool) -> string {
return "website" return "website"
} }
load_template :: proc(layouts_dir: string, name: string) -> string { load_template :: proc(layouts_dir: string, name: string) -> mustache.Template {
data, _ := os.read_entire_file_from_path( data, _ := os.read_entire_file_from_path(
fmt.tprintf("%s/%s", layouts_dir, name), fmt.tprintf("%s/%s", layouts_dir, name),
context.allocator, context.allocator,
) )
return string(data) tpl, err := mustache.parse(string(data))
if err != nil {
log.warnf("thor: failed to parse template %s: %v", name, err)
}
return tpl
} }
render_template :: proc( render_template :: proc(
content_tpl: string, content_tpl: mustache.Template,
data: any, data: any,
partials: map[string]string, partials: map[string]mustache.Template,
) -> string { ) -> string {
result, err := mustache.render(content_tpl, data, partials) result, err := mustache.render(content_tpl, data, partials)
if err != nil { if err != nil {
@@ -211,8 +216,8 @@ render_site :: proc(pages: []Page, config: Site) {
render_page_html :: proc( render_page_html :: proc(
page: Page, page: Page,
config: Site, config: Site,
content_tpl: string, content_tpl: mustache.Template,
partials: map[string]string, partials: map[string]mustache.Template,
base: Base_Data, base: Base_Data,
) -> string { ) -> string {
is_article := page.type == .Post is_article := page.type == .Post
@@ -239,8 +244,8 @@ render_home_html :: proc(
home: Page, home: Page,
pages: []Page, pages: []Page,
config: Site, config: Site,
content_tpl: string, content_tpl: mustache.Template,
partials: map[string]string, partials: map[string]mustache.Template,
base: Base_Data, base: Base_Data,
) -> string { ) -> string {
list_pages := make([dynamic]Page_Context) list_pages := make([dynamic]Page_Context)
@@ -268,8 +273,8 @@ render_home_html :: proc(
render_posts_html :: proc( render_posts_html :: proc(
pages: []Page, pages: []Page,
config: Site, config: Site,
content_tpl: string, content_tpl: mustache.Template,
partials: map[string]string, partials: map[string]mustache.Template,
base: Base_Data, base: Base_Data,
) -> string { ) -> string {
year_sections := make([dynamic]Year_Section) year_sections := make([dynamic]Year_Section)
@@ -299,15 +304,15 @@ render_posts_html :: proc(
return render_template(content_tpl, data, partials) return render_template(content_tpl, data, partials)
} }
load_partials :: proc(layouts_dir: string) -> map[string]string { load_partials :: proc(layouts_dir: string) -> map[string]mustache.Template {
partials: map[string]string partials: map[string]mustache.Template
partials_dir := fmt.tprintf("%s/partials", layouts_dir) partials_dir := fmt.tprintf("%s/partials", layouts_dir)
load_partials_recursive(&partials, partials_dir, "") load_partials_recursive(&partials, partials_dir, "")
return partials return partials
} }
load_partials_recursive :: proc( load_partials_recursive :: proc(
partials: ^map[string]string, partials: ^map[string]mustache.Template,
base_dir: string, base_dir: string,
rel_prefix: string, rel_prefix: string,
) { ) {
@@ -331,7 +336,11 @@ load_partials_recursive :: proc(
} }
data, ok := os.read_entire_file_from_path(entry.fullpath, context.allocator) data, ok := os.read_entire_file_from_path(entry.fullpath, context.allocator)
if ok == nil { if ok == nil {
partials[key] = string(data) tpl, perr := mustache.parse(string(data))
if perr != nil {
log.warnf("thor: failed to parse partial %s: %v", key, perr)
}
partials[key] = tpl
} }
case .Directory: case .Directory:
sub_prefix := entry.name sub_prefix := entry.name