mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
refactor: Rewrote the mustache package from scratch.
This commit is contained in:
+1
-8
@@ -1,8 +1 @@
|
||||
*/**/.DS_Store
|
||||
.DS_Store
|
||||
tmp/
|
||||
bin/
|
||||
*/**/odin-mustache.dSYM/
|
||||
*/**/odin-mustache
|
||||
*/**/odin-mustache-test.dSYM/
|
||||
*/**/odin-mustache-test
|
||||
mustache
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
[submodule "spec"]
|
||||
path = spec
|
||||
url = git@github.com:mustache/spec.git
|
||||
branch = v1.4.1
|
||||
@@ -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}}
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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 > 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)
|
||||
@@ -0,0 +1,2 @@
|
||||
- [ ] Initialize string builder size based on filesize?
|
||||
- [ ] Review [whitespace handling code](./tokenizer.odin)
|
||||
@@ -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, "&")
|
||||
case '<':
|
||||
strings.write_string(b, "<")
|
||||
case '>':
|
||||
strings.write_string(b, ">")
|
||||
case '"':
|
||||
strings.write_string(b, """)
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
strings.write_string(b, s[start:])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
Hello, {{name}}!
|
||||
|
||||
Languages you know:
|
||||
{{#languages}}
|
||||
- {{.}}
|
||||
{{/languages}}
|
||||
|
||||
{{#show_footer}}
|
||||
---
|
||||
Rendered with odin-mustache.
|
||||
{{/show_footer}}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
data: {
|
||||
name: "Kilgarvan"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
Begin layout >>
|
||||
{{content}}
|
||||
<< End layout
|
||||
@@ -1 +0,0 @@
|
||||
Hello, this is {{name}}.
|
||||
@@ -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
@@ -10,7 +10,8 @@ test_simple_substitution :: proc(t: ^testing.T) {
|
||||
data := map[string]string {
|
||||
"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, result == "Hello, World!")
|
||||
}
|
||||
@@ -20,7 +21,8 @@ test_bool_section :: proc(t: ^testing.T) {
|
||||
data := map[string]bool {
|
||||
"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, result == "visible")
|
||||
}
|
||||
@@ -30,7 +32,8 @@ test_inverted_section :: proc(t: ^testing.T) {
|
||||
data := map[string]bool {
|
||||
"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, result == "hidden")
|
||||
}
|
||||
@@ -40,7 +43,8 @@ test_unescaped :: proc(t: ^testing.T) {
|
||||
data := map[string]string {
|
||||
"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, result == "<b>bold</b>")
|
||||
}
|
||||
@@ -55,7 +59,8 @@ test_array_iteration :: proc(t: ^testing.T) {
|
||||
data := map[string][dynamic]map[string]string {
|
||||
"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, result == "Alice Bob ")
|
||||
}
|
||||
@@ -65,10 +70,12 @@ test_partial :: proc(t: ^testing.T) {
|
||||
data := map[string]string {
|
||||
"name" = "Test",
|
||||
}
|
||||
partials := map[string]string {
|
||||
"greeting" = "Hello, {{name}}!",
|
||||
greeting_tpl, _ := mustache.parse("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, result == "Hello, Test!")
|
||||
}
|
||||
@@ -81,7 +88,8 @@ test_mixed_types :: proc(t: ^testing.T) {
|
||||
"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, result == "One Idiot Developer: 17 Jul 2025")
|
||||
}
|
||||
@@ -96,19 +104,8 @@ test_nested_context :: proc(t: ^testing.T) {
|
||||
"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, 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
@@ -5,6 +5,7 @@ import "mustache"
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
import "core:time"
|
||||
@@ -103,18 +104,22 @@ og_type :: proc(is_article: bool) -> string {
|
||||
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(
|
||||
fmt.tprintf("%s/%s", layouts_dir, name),
|
||||
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(
|
||||
content_tpl: string,
|
||||
content_tpl: mustache.Template,
|
||||
data: any,
|
||||
partials: map[string]string,
|
||||
partials: map[string]mustache.Template,
|
||||
) -> string {
|
||||
result, err := mustache.render(content_tpl, data, partials)
|
||||
if err != nil {
|
||||
@@ -211,8 +216,8 @@ render_site :: proc(pages: []Page, config: Site) {
|
||||
render_page_html :: proc(
|
||||
page: Page,
|
||||
config: Site,
|
||||
content_tpl: string,
|
||||
partials: map[string]string,
|
||||
content_tpl: mustache.Template,
|
||||
partials: map[string]mustache.Template,
|
||||
base: Base_Data,
|
||||
) -> string {
|
||||
is_article := page.type == .Post
|
||||
@@ -239,8 +244,8 @@ render_home_html :: proc(
|
||||
home: Page,
|
||||
pages: []Page,
|
||||
config: Site,
|
||||
content_tpl: string,
|
||||
partials: map[string]string,
|
||||
content_tpl: mustache.Template,
|
||||
partials: map[string]mustache.Template,
|
||||
base: Base_Data,
|
||||
) -> string {
|
||||
list_pages := make([dynamic]Page_Context)
|
||||
@@ -268,8 +273,8 @@ render_home_html :: proc(
|
||||
render_posts_html :: proc(
|
||||
pages: []Page,
|
||||
config: Site,
|
||||
content_tpl: string,
|
||||
partials: map[string]string,
|
||||
content_tpl: mustache.Template,
|
||||
partials: map[string]mustache.Template,
|
||||
base: Base_Data,
|
||||
) -> string {
|
||||
year_sections := make([dynamic]Year_Section)
|
||||
@@ -299,15 +304,15 @@ render_posts_html :: proc(
|
||||
return render_template(content_tpl, data, partials)
|
||||
}
|
||||
|
||||
load_partials :: proc(layouts_dir: string) -> map[string]string {
|
||||
partials: map[string]string
|
||||
load_partials :: proc(layouts_dir: string) -> map[string]mustache.Template {
|
||||
partials: map[string]mustache.Template
|
||||
partials_dir := fmt.tprintf("%s/partials", layouts_dir)
|
||||
load_partials_recursive(&partials, partials_dir, "")
|
||||
return partials
|
||||
}
|
||||
|
||||
load_partials_recursive :: proc(
|
||||
partials: ^map[string]string,
|
||||
partials: ^map[string]mustache.Template,
|
||||
base_dir: string,
|
||||
rel_prefix: string,
|
||||
) {
|
||||
@@ -331,7 +336,11 @@ load_partials_recursive :: proc(
|
||||
}
|
||||
data, ok := os.read_entire_file_from_path(entry.fullpath, context.allocator)
|
||||
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:
|
||||
sub_prefix := entry.name
|
||||
|
||||
Reference in New Issue
Block a user