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
|
mustache
|
||||||
.DS_Store
|
|
||||||
tmp/
|
|
||||||
bin/
|
|
||||||
*/**/odin-mustache.dSYM/
|
|
||||||
*/**/odin-mustache
|
|
||||||
*/**/odin-mustache-test.dSYM/
|
|
||||||
*/**/odin-mustache-test
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
+304
-1837
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 {
|
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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user