Compare commits

...

7 Commits

Author SHA1 Message Date
Spencer Brower e4e74df27e refactor: Rewrote the mustache package from scratch. 2026-07-15 18:03:08 -04:00
Spencer Brower 28975736cd refactor: Added parent support to mustache so templates can work right. 2026-07-15 15:02:35 -04:00
Spencer Brower a00f7d8ef5 refactor: Simplified render context. 2026-07-15 14:10:37 -04:00
Spencer Brower 618e08a718 refactor!: replaced year with now.year in template data. 2026-07-15 13:28:15 -04:00
Spencer Brower 5a1ceb617c refactor: generate_rss and generate_sitemap now use string builders. 2026-07-15 12:32:46 -04:00
Spencer Brower 93cf74ee0b feat: refactored code to use core:time package. 2026-07-15 12:30:46 -04:00
Spencer Brower 7aa411490a feat: Removed static dir from sites. 2026-07-15 11:34:16 -04:00
24 changed files with 1291 additions and 3329 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
use flake use flake
export PATH="$PATH:./vendor/bin" export PATH="$PATH:./"
+3
View File
@@ -15,6 +15,7 @@
- [ ] ensure sidenote numbers render in display order and not in declaration order. - [ ] ensure sidenote numbers render in display order and not in declaration order.
- [x] Search up for `thor.json` files. - [x] Search up for `thor.json` files.
- [ ] OpenGraph meta tags — verify all fields match production site - [ ] OpenGraph meta tags — verify all fields match production site
- [ ] Add Opt-in deflist support.
- [ ] set opengraph tags / description automatically if unset. (Like hugo does) - [ ] set opengraph tags / description automatically if unset. (Like hugo does)
- [ ] Table of contents support. - [ ] Table of contents support.
- [ ] Nav items should be active when the current page is selected. - [ ] Nav items should be active when the current page is selected.
@@ -23,6 +24,7 @@
guide guide
- [ ] Search for grammars in multiple places - [ ] Search for grammars in multiple places
- [ ] Download missing grammars. - [ ] Download missing grammars.
- [ ] Add reflection support to mustache / consider rewriting from scratch.
- [ ] Syntax highlighting in production: CI (`nix build` on ubuntu-latest) has no grammar `.so`s and a machine-specific `QUERIES_PATH` nix store hash, so the deployed site renders unhighlighted. Provide grammars + queries as nix build inputs and pass paths to thor at runtime (env vars/flags). - [ ] Syntax highlighting in production: CI (`nix build` on ubuntu-latest) has no grammar `.so`s and a machine-specific `QUERIES_PATH` nix store hash, so the deployed site renders unhighlighted. Provide grammars + queries as nix build inputs and pass paths to thor at runtime (env vars/flags).
- [ ] Durable highlight paths: read `GRAPHS_PATH`/`QUERIES_PATH` from env vars set by the flake instead of hardcoded nix store hashes, so they survive `nix flake update` and let the grammar/query version-mismatch detector fire automatically. - [ ] Durable highlight paths: read `GRAPHS_PATH`/`QUERIES_PATH` from env vars set by the flake instead of hardcoded nix store hashes, so they survive `nix flake update` and let the grammar/query version-mismatch detector fire automatically.
- [ ] Unit tests for highlighting helpers: `capture_name_to_css`, `escape_html`, `unescape_html`, `extract_query_token`, `helix_version_from_path`. - [ ] Unit tests for highlighting helpers: `capture_name_to_css`, `escape_html`, `unescape_html`, `extract_query_token`, `helix_version_from_path`.
@@ -35,6 +37,7 @@
- [ ] Free cmark HTML output (`body_html`) — cmark allocates via C malloc, not the arena, so it leaks per iteration in watch mode - [ ] Free cmark HTML output (`body_html`) — cmark allocates via C malloc, not the arena, so it leaks per iteration in watch mode
- [ ] commands - [ ] commands
- [ ] Add spall and find ways to reduce run time. (Getting close to 1/2sec here.) - [ ] Add spall and find ways to reduce run time. (Getting close to 1/2sec here.)
- [ ] warn/error when unknown key used in mustache.
- [ ] Review every file in thor - [ ] Review every file in thor
- [ ] Review alerts.odin - [ ] Review alerts.odin
- [ ] Review content.odin - [ ] Review content.odin
+1 -43
View File
@@ -217,47 +217,8 @@ strip_extension :: proc(name: string) -> string {
return name[:dot] return name[:dot]
} }
// copy_static_dir recursively copies all files from static_dir to output_dir.
// Silently skips if static_dir doesn't exist.
copy_static_dir :: proc(static_dir: string, output_dir: string) {
if !os.exists(static_dir) {
return
}
copy_static_recursive(static_dir, "", output_dir)
}
copy_static_recursive :: proc(current: string, rel_prefix: string, output_dir: string) {
entries, err := os.read_all_directory_by_path(current, context.allocator)
if err != nil {
log.warnf("thor: cannot read %s: %v", current, err)
return
}
defer os.file_info_slice_delete(entries, context.allocator)
for entry in entries {
rel := rel_prefix == "" ? entry.name : fmt.tprintf("%s/%s", rel_prefix, entry.name)
switch entry.type {
case .Regular:
dest := fmt.tprintf("%s/%s", output_dir, rel)
if idx := strings.last_index(dest, "/"); idx >= 0 {
if err := os.make_directory_all(dest[:idx]); err != nil && err != .Exist {
log.warnf("thor: cannot create %s: %v", dest[:idx], err)
continue
}
}
if err := os.copy_file(dest, entry.fullpath); err != nil {
log.warnf("thor: cannot copy %s: %v", entry.fullpath, err)
}
case .Directory:
copy_static_recursive(entry.fullpath, rel, output_dir)
case .Undetermined, .Symlink, .Named_Pipe, .Socket, .Block_Device, .Character_Device:
}
}
}
// copy_assets_dir recursively copies files from assets_dir to output_dir. // copy_assets_dir recursively copies files from assets_dir to output_dir.
// .css files are minified when .Minify is enabled; all other files are copied // .css files are minified when .Minify is enabled; all other files are copied verbatim.
// verbatim with a warning suggesting they belong in static/.
// Silently skips if assets_dir doesn't exist. // Silently skips if assets_dir doesn't exist.
copy_assets_dir :: proc(assets_dir: string, output_dir: string, features: bit_set[Feature]) { copy_assets_dir :: proc(assets_dir: string, output_dir: string, features: bit_set[Feature]) {
if !os.exists(assets_dir) { if !os.exists(assets_dir) {
@@ -299,9 +260,6 @@ copy_assets_recursive :: proc(
minified := minify_css(string(data)) minified := minify_css(string(data))
write_file(dest, minified) write_file(dest, minified)
} else { } else {
if !strings.has_suffix(entry.name, ".css") {
log.warnf("thor: %s is not a CSS file; consider moving it to static/", entry.fullpath)
}
if err := os.copy_file(dest, entry.fullpath); err != nil { if err := os.copy_file(dest, entry.fullpath); err != nil {
log.warnf("thor: cannot copy %s: %v", entry.fullpath, err) log.warnf("thor: cannot copy %s: %v", entry.fullpath, err)
} }
+36 -54
View File
@@ -2,16 +2,12 @@ package main
import "core:fmt" import "core:fmt"
import "core:strings" import "core:strings"
import "core:time"
WEEKDAYS: [7]string = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
generate_rss :: proc(pages: []Page, config: Site) -> string { generate_rss :: proc(pages: []Page, config: Site) -> string {
parts: [dynamic]string sb := strings.builder_make()
defer delete(parts)
append( strings.write_string(&sb, fmt.aprintf(
&parts,
fmt.aprintf(
`<?xml version="1.0" encoding="utf-8" standalone="yes"?> `<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel> <channel>
@@ -24,8 +20,7 @@ generate_rss :: proc(pages: []Page, config: Site) -> string {
config.base_url, config.base_url,
xml_escape(config.description), xml_escape(config.description),
config.base_url, config.base_url,
), ))
)
for page in pages { for page in pages {
if page.type == .Home { if page.type == .Home {
@@ -37,9 +32,7 @@ generate_rss :: proc(pages: []Page, config: Site) -> string {
pub_date = format_rfc822(page.date) pub_date = format_rfc822(page.date)
} }
append( strings.write_string(&sb, fmt.aprintf(
&parts,
fmt.aprintf(
`<item> `<item>
<title>%s</title> <title>%s</title>
<link>%s%s</link> <link>%s%s</link>
@@ -55,35 +48,28 @@ generate_rss :: proc(pages: []Page, config: Site) -> string {
config.base_url, config.base_url,
page.permalink, page.permalink,
xml_escape(page.body_html), xml_escape(page.body_html),
), ))
)
} }
append(&parts, "</channel>\n</rss>") strings.write_string(&sb, "</channel>\n</rss>")
return strings.to_string(sb)
return strings.join(parts[:], "")
} }
generate_sitemap :: proc(pages: []Page, base_url: string) -> string { generate_sitemap :: proc(pages: []Page, base_url: string) -> string {
parts: [dynamic]string sb := strings.builder_make()
defer delete(parts)
append( strings.write_string(&sb, `<?xml version="1.0" encoding="utf-8" standalone="yes"?>
&parts,
`<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
`, `)
)
for page in pages { for page in pages {
lastmod := "" lastmod := ""
if page.date != "" { if page.date != "" {
lastmod = fmt.aprintf("<lastmod>%s</lastmod>", page.date) lastmod = fmt.aprintf("<lastmod>%s</lastmod>", page.date)
} }
append( strings.write_string(&sb, fmt.aprintf(
&parts, "<url><loc>%s%s</loc>%s</url>\n", base_url, page.permalink, lastmod,
fmt.aprintf("<url><loc>%s%s</loc>%s</url>\n", base_url, page.permalink, lastmod), ))
)
} }
// Posts list page // Posts list page
@@ -97,42 +83,38 @@ generate_sitemap :: proc(pages: []Page, base_url: string) -> string {
if posts_lastmod != "" { if posts_lastmod != "" {
posts_lm = fmt.aprintf("<lastmod>%s</lastmod>", posts_lastmod) posts_lm = fmt.aprintf("<lastmod>%s</lastmod>", posts_lastmod)
} }
append(&parts, fmt.aprintf("<url><loc>%s/posts/</loc>%s</url>\n", base_url, posts_lm)) strings.write_string(&sb, fmt.aprintf(
"<url><loc>%s/posts/</loc>%s</url>\n", base_url, posts_lm,
))
append(&parts, "</urlset>") strings.write_string(&sb, "</urlset>")
return strings.to_string(sb)
return strings.join(parts[:], "")
} }
// TODO: Leaks
format_rfc822 :: proc(iso: string) -> string { format_rfc822 :: proc(iso: string) -> string {
if len(iso) < 19 { if len(iso) < 19 {
// TODO: should indicate error somehow
return iso return iso
} }
year := date, offset, _ := time.iso8601_to_time_and_offset(iso)
(int(iso[0]) - 0x30) * 1000 +
(int(iso[1]) - 0x30) * 100 +
(int(iso[2]) - 0x30) * 10 +
(int(iso[3]) - 0x30)
month := (int(iso[5]) - 0x30) * 10 + (int(iso[6]) - 0x30)
day := (int(iso[8]) - 0x30) * 10 + (int(iso[9]) - 0x30)
time := iso[11:19]
// Timezone: -04:00 → -0400 weekday := fmt.tprintf("%s", time.weekday(date))
tz := "+0000" month := fmt.tprintf("%s", time.month(date))
if len(iso) >= 25 && (iso[19] == '+' || iso[19] == '-') { buf: [8]byte
tz = fmt.tprintf("%c%s%s", iso[19], iso[20:22], iso[23:25]) t := time.to_string_hms(date, buf[:])
}
// Sakamoto's method for day of week (0=Sunday) return fmt.aprintf(
t := [12]int{0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4} "%s, %02d %s %d %s %3d%2d",
y := year weekday[:3],
if month < 3 { time.day(date),
y -= 1 month[:3],
} time.year(date),
dow := (y + y / 4 - y / 100 + y / 400 + t[month - 1] + day) % 7 t,
offset / 60,
return fmt.aprintf("%s, %d %s %d %s %s", WEEKDAYS[dow], day, MONTHS[month - 1], year, time, tz) offset % 60,
)
} }
xml_escape :: proc(s: string) -> string { xml_escape :: proc(s: string) -> string {
+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)
}
+310 -1701
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>")
}
+189 -181
View File
@@ -3,24 +3,13 @@ package main
import "mustache" import "mustache"
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"
MONTHS: [12]string = { import "core:time/datetime"
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
}
Page_Context :: struct { Page_Context :: struct {
permalink: string, permalink: string,
@@ -36,9 +25,40 @@ Year_Section :: struct {
posts: [dynamic]Page_Context, posts: [dynamic]Page_Context,
} }
Year_Slice :: struct { Base_Data :: struct {
year: string, now: datetime.DateTime,
posts: []Page_Context, author: string,
params: json.Value,
body: string,
title: string,
og_site_name: string,
og_description: string,
og_image: string,
og_url: string,
og_title: string,
og_type: string,
is_article: bool,
}
Page_Data :: struct {
using base: Base_Data,
page_title: string,
has_date: bool,
date_iso: string,
date_display: string,
is_post: bool,
og_section: string,
og_published: string,
}
Home_Data :: struct {
using base: Base_Data,
list_pages: [dynamic]Page_Context,
}
Posts_Data :: struct {
using base: Base_Data,
year_sections: [dynamic]Year_Section,
} }
build_page_context :: proc(page: Page) -> Page_Context { build_page_context :: proc(page: Page) -> Page_Context {
@@ -84,9 +104,52 @@ og_type :: proc(is_article: bool) -> string {
return "website" return "website"
} }
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,
)
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: mustache.Template,
data: any,
partials: map[string]mustache.Template,
) -> string {
result, err := mustache.render(content_tpl, data, partials)
if err != nil {
fmt.eprintfln("thor: mustache error: %v", err)
return ""
}
return result
}
render_site :: proc(pages: []Page, config: Site) { render_site :: proc(pages: []Page, config: Site) {
sort_pages_by_date(pages) sort_pages_by_date(pages)
// Load shared resources once
partials := load_partials(config.layouts_dir)
partials["base"] = load_template(config.layouts_dir, "base.html")
post_tpl := load_template(config.layouts_dir, "post.html")
now, ok := time.time_to_datetime(time.now())
assert(ok)
// Build base data once
base := Base_Data {
now = now,
author = config.author,
params = config.params,
og_site_name = config.title,
og_description = config.description,
og_image = fmt.tprintf("%s/avatar.jpg", config.base_url),
}
// Find home page // Find home page
home: Page home: Page
has_home := false has_home := false
@@ -103,7 +166,7 @@ render_site :: proc(pages: []Page, config: Site) {
if page.type == .Home { if page.type == .Home {
continue continue
} }
html := render_page_html(page, config) html := render_page_html(page, config, post_tpl, partials, base)
if .Minify in config.features { if .Minify in config.features {
html = minify_html(html) html = minify_html(html)
} }
@@ -112,7 +175,8 @@ render_site :: proc(pages: []Page, config: Site) {
// Render home page // Render home page
if has_home { if has_home {
home_html := render_home_html(home, pages, config) home_tpl := load_template(config.layouts_dir, "home.html")
home_html := render_home_html(home, pages, config, home_tpl, partials, base)
if .Minify in config.features { if .Minify in config.features {
home_html = minify_html(home_html) home_html = minify_html(home_html)
} }
@@ -120,7 +184,8 @@ render_site :: proc(pages: []Page, config: Site) {
} }
// Render posts list page // Render posts list page
posts_html := render_posts_html(pages, config) posts_tpl := load_template(config.layouts_dir, "posts_list.html")
posts_html := render_posts_html(pages, config, posts_tpl, partials, base)
if .Minify in config.features { if .Minify in config.features {
posts_html = minify_html(posts_html) posts_html = minify_html(posts_html)
} }
@@ -134,10 +199,7 @@ render_site :: proc(pages: []Page, config: Site) {
sitemap := generate_sitemap(pages, config.base_url) sitemap := generate_sitemap(pages, config.base_url)
write_file(fmt.tprintf("%s/sitemap.xml", config.output_dir), sitemap) write_file(fmt.tprintf("%s/sitemap.xml", config.output_dir), sitemap)
// Copy static directory (favicon, CSS, images, etc.) // Copy and optionally minify assets directory
copy_static_dir(config.static_dir, config.output_dir)
// Copy and maybe minify assets directory
copy_assets_dir(config.assets_dir, config.output_dir, config.features) copy_assets_dir(config.assets_dir, config.output_dir, config.features)
// Generate robots.txt // Generate robots.txt
@@ -151,58 +213,109 @@ render_site :: proc(pages: []Page, config: Site) {
fmt.printfln("Rendered %d pages to %s", total, config.output_dir) fmt.printfln("Rendered %d pages to %s", total, config.output_dir)
} }
render_page_html :: proc(page: Page, config: Site) -> string { render_page_html :: proc(
page: Page,
config: Site,
content_tpl: mustache.Template,
partials: map[string]mustache.Template,
base: Base_Data,
) -> string {
is_article := page.type == .Post is_article := page.type == .Post
data := Page_Data {
data := map[string]any { base = base,
"title" = fmt.tprintf("%s | %s", page.title, config.title),
"page_title" = page.title,
"body_html" = page.body_html,
"has_date" = page.date != "",
"date_iso" = page.date,
"date_display" = format_date(page.date),
"is_post" = is_article,
"year" = "2026",
"author" = config.author,
"params" = config.params,
"og_url" = fmt.tprintf("%s%s", config.base_url, page.permalink),
"og_site_name" = config.title,
"og_title" = strip_html_tags(page.title),
"og_description" = config.description,
"og_type" = og_type(is_article),
"is_article" = is_article,
"og_section" = "posts",
"og_published" = page.date,
"og_image" = fmt.tprintf("%s/avatar.jpg", config.base_url),
} }
data.title = fmt.tprintf("%s | %s", page.title, config.title)
partials := load_partials(config.layouts_dir) data.page_title = page.title
data.body = page.body_html
post_tpl, _ := os.read_entire_file_from_path( data.has_date = page.date != ""
fmt.tprintf("%s/post.html", config.layouts_dir), data.date_iso = page.date
context.allocator, data.date_display = format_date(page.date)
) data.is_post = is_article
base_tpl, _ := os.read_entire_file_from_path( data.og_url = fmt.tprintf("%s%s", config.base_url, page.permalink)
fmt.tprintf("%s/base.html", config.layouts_dir), data.og_title = strip_html_tags(page.title)
context.allocator, data.og_type = og_type(is_article)
) data.is_article = is_article
data.og_section = "posts"
result, err := mustache.render_in_layout(string(post_tpl), data, string(base_tpl), partials) data.og_published = page.date
if err != nil { return render_template(content_tpl, data, partials)
fmt.eprintfln("thor: mustache error rendering page: %v", err)
return ""
}
return result
} }
load_partials :: proc(layouts_dir: string) -> map[string]string { render_home_html :: proc(
partials: map[string]string home: Page,
pages: []Page,
config: Site,
content_tpl: mustache.Template,
partials: map[string]mustache.Template,
base: Base_Data,
) -> string {
list_pages := make([dynamic]Page_Context)
defer delete(list_pages)
for page in pages {
if page.type == .Home {
continue
}
append(&list_pages, build_page_context(page))
}
data := Home_Data {
base = base,
}
data.title = config.title
data.body = home.body_html
data.list_pages = list_pages
data.og_url = fmt.tprintf("%s/", config.base_url)
data.og_title = config.title
data.og_type = "website"
data.is_article = false
return render_template(content_tpl, data, partials)
}
render_posts_html :: proc(
pages: []Page,
config: Site,
content_tpl: mustache.Template,
partials: map[string]mustache.Template,
base: Base_Data,
) -> string {
year_sections := make([dynamic]Year_Section)
defer delete(year_sections)
current_year := ""
for page in pages {
if page.type != .Post {
continue
}
year := get_year(page.date)
if year != current_year {
append(&year_sections, Year_Section{year = year})
current_year = year
}
append(&year_sections[len(year_sections) - 1].posts, build_page_context(page))
}
data := Posts_Data {
base = base,
}
data.title = fmt.tprintf("Posts | %s", config.title)
data.year_sections = year_sections
data.og_url = fmt.tprintf("%s/posts/", config.base_url)
data.og_title = "Posts"
data.og_type = "website"
data.is_article = false
return render_template(content_tpl, data, partials)
}
load_partials :: proc(layouts_dir: string) -> map[string]mustache.Template {
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(partials: ^map[string]string, base_dir: string, rel_prefix: string) { load_partials_recursive :: proc(
partials: ^map[string]mustache.Template,
base_dir: string,
rel_prefix: string,
) {
entries, err := os.read_all_directory_by_path(base_dir, context.allocator) entries, err := os.read_all_directory_by_path(base_dir, context.allocator)
if err != nil { if err != nil {
return return
@@ -223,7 +336,11 @@ load_partials_recursive :: proc(partials: ^map[string]string, base_dir: string,
} }
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
@@ -235,121 +352,12 @@ load_partials_recursive :: proc(partials: ^map[string]string, base_dir: string,
} }
} }
render_home_html :: proc(home: Page, pages: []Page, config: Site) -> string {
list_pages := make([dynamic]Page_Context)
defer delete(list_pages)
for page in pages {
if page.type == .Home {
continue
}
append(&list_pages, build_page_context(page))
}
data := map[string]any {
"title" = config.title,
"home_body" = home.body_html,
"list_pages" = list_pages[:],
"year" = "2026",
"author" = config.author,
"params" = config.params,
"og_url" = fmt.tprintf("%s/", config.base_url),
"og_site_name" = config.title,
"og_title" = config.title,
"og_description" = config.description,
"og_type" = "website",
"is_article" = false,
"og_image" = fmt.tprintf("%s/avatar.jpg", config.base_url),
}
partials := load_partials(config.layouts_dir)
home_tpl, _ := os.read_entire_file_from_path(
fmt.tprintf("%s/home.html", config.layouts_dir),
context.allocator,
)
base_tpl, _ := os.read_entire_file_from_path(
fmt.tprintf("%s/base.html", config.layouts_dir),
context.allocator,
)
result, err := mustache.render_in_layout(string(home_tpl), data, string(base_tpl), partials)
if err != nil {
fmt.eprintfln("thor: mustache error: %v", err)
return ""
}
return result
}
render_posts_html :: proc(pages: []Page, config: Site) -> string {
// Group posts by year
year_sections := make([dynamic]Year_Section)
defer delete(year_sections)
current_year := ""
for page in pages {
if page.type != .Post {
continue
}
year := get_year(page.date)
if year != current_year {
append(&year_sections, Year_Section{year = year})
current_year = year
}
append(&year_sections[len(year_sections) - 1].posts, build_page_context(page))
}
// Convert [dynamic]Page_Context slices to []Page_Context for mustache
year_slices := make([dynamic]Year_Slice)
defer delete(year_slices)
for section in year_sections {
append(&year_slices, Year_Slice{year = section.year, posts = section.posts[:]})
}
data := map[string]any {
"title" = fmt.tprintf("Posts | %s", config.title),
"year_sections" = year_slices[:],
"year" = "2026",
"author" = config.author,
"params" = config.params,
"og_url" = fmt.tprintf("%s/posts/", config.base_url),
"og_site_name" = config.title,
"og_title" = "Posts",
"og_description" = config.description,
"og_type" = "website",
"is_article" = false,
"og_image" = fmt.tprintf("%s/avatar.jpg", config.base_url),
}
partials := load_partials(config.layouts_dir)
posts_tpl, _ := os.read_entire_file_from_path(
fmt.tprintf("%s/posts_list.html", config.layouts_dir),
context.allocator,
)
base_tpl, _ := os.read_entire_file_from_path(
fmt.tprintf("%s/base.html", config.layouts_dir),
context.allocator,
)
result, err := mustache.render_in_layout(string(posts_tpl), data, string(base_tpl), partials)
if err != nil {
fmt.eprintfln("thor: mustache error: %v", err)
return ""
}
return result
}
format_date :: proc(iso: string) -> string { format_date :: proc(iso: string) -> string {
if len(iso) < 10 { if len(iso) < 10 {
return iso return ""
} }
year := iso[:4] date, _, _, _ := time.iso8601_to_components(iso)
month_num := (int(iso[5]) - 0x30) * 10 + (int(iso[6]) - 0x30) return fmt.aprintf("%s %d, %d", time.Month(date.month), date.day, date.year)
day_num := (int(iso[8]) - 0x30) * 10 + (int(iso[9]) - 0x30)
if month_num < 1 || month_num > 12 {
return iso[:10]
}
return fmt.aprintf("%d %s %s", day_num, MONTHS[month_num - 1], year)
} }
get_year :: proc(iso: string) -> string { get_year :: proc(iso: string) -> string {
-9
View File
@@ -17,7 +17,6 @@ Site :: struct {
base_url: string, base_url: string,
config_path: string, config_path: string,
content_dir: string, content_dir: string,
static_dir: string,
assets_dir: string, assets_dir: string,
output_dir: string, output_dir: string,
layouts_dir: string, layouts_dir: string,
@@ -38,7 +37,6 @@ Flags :: struct {
description: string, description: string,
base_url: string `args:"name=base-url"`, base_url: string `args:"name=base-url"`,
content_dir: string `args:"name=content"`, content_dir: string `args:"name=content"`,
static_dir: string `args:"name=static"`,
assets_dir: string `args:"name=assets"`, assets_dir: string `args:"name=assets"`,
output_dir: string `args:"name=output"`, output_dir: string `args:"name=output"`,
layouts_dir: string, layouts_dir: string,
@@ -87,9 +85,6 @@ init_site :: proc(site: ^Site, args: []string) {
if site.content_dir == "" { if site.content_dir == "" {
site.content_dir = fmt.tprintf("%s/content", config_dir) site.content_dir = fmt.tprintf("%s/content", config_dir)
} }
if site.static_dir == "" {
site.static_dir = fmt.tprintf("%s/static", config_dir)
}
if site.assets_dir == "" { if site.assets_dir == "" {
site.assets_dir = fmt.tprintf("%s/assets", config_dir) site.assets_dir = fmt.tprintf("%s/assets", config_dir)
} }
@@ -133,9 +128,6 @@ merge_flags :: proc(config: ^Flags, flags: Flags) {
if flags.content_dir != "" { if flags.content_dir != "" {
config.content_dir = flags.content_dir config.content_dir = flags.content_dir
} }
if flags.static_dir != "" {
config.static_dir = flags.static_dir
}
if flags.assets_dir != "" { if flags.assets_dir != "" {
config.assets_dir = flags.assets_dir config.assets_dir = flags.assets_dir
} }
@@ -164,7 +156,6 @@ site_apply_flags :: proc(site: ^Site, flags: Flags) {
site.base_url = flags.base_url site.base_url = flags.base_url
site.config_path = flags.config_path site.config_path = flags.config_path
site.content_dir = flags.content_dir site.content_dir = flags.content_dir
site.static_dir = flags.static_dir
site.assets_dir = flags.assets_dir site.assets_dir = flags.assets_dir
site.output_dir = flags.output_dir site.output_dir = flags.output_dir
site.layouts_dir = flags.layouts_dir site.layouts_dir = flags.layouts_dir