mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4e74df27e | |||
| 28975736cd | |||
| a00f7d8ef5 | |||
| 618e08a718 | |||
| 5a1ceb617c | |||
| 93cf74ee0b | |||
| 7aa411490a |
@@ -1,3 +1,3 @@
|
|||||||
use flake
|
use flake
|
||||||
|
|
||||||
export PATH="$PATH:./vendor/bin"
|
export PATH="$PATH:./"
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,13 @@ 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,
|
`<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||||
fmt.aprintf(
|
|
||||||
`<?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>
|
||||||
<title>%s</title>
|
<title>%s</title>
|
||||||
@@ -20,12 +16,11 @@ generate_rss :: proc(pages: []Page, config: Site) -> string {
|
|||||||
<description>%s</description>
|
<description>%s</description>
|
||||||
<language>en-us</language>
|
<language>en-us</language>
|
||||||
<atom:link href="%s/index.xml" rel="self" type="application/rss+xml"/>`,
|
<atom:link href="%s/index.xml" rel="self" type="application/rss+xml"/>`,
|
||||||
xml_escape(config.title),
|
xml_escape(config.title),
|
||||||
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,10 +32,8 @@ 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,
|
`<item>
|
||||||
fmt.aprintf(
|
|
||||||
`<item>
|
|
||||||
<title>%s</title>
|
<title>%s</title>
|
||||||
<link>%s%s</link>
|
<link>%s%s</link>
|
||||||
<pubDate>%s</pubDate>
|
<pubDate>%s</pubDate>
|
||||||
@@ -48,42 +41,35 @@ generate_rss :: proc(pages: []Page, config: Site) -> string {
|
|||||||
<description>%s</description>
|
<description>%s</description>
|
||||||
</item>
|
</item>
|
||||||
`,
|
`,
|
||||||
xml_escape(page.title),
|
xml_escape(page.title),
|
||||||
config.base_url,
|
config.base_url,
|
||||||
page.permalink,
|
page.permalink,
|
||||||
pub_date,
|
pub_date,
|
||||||
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
@@ -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)
|
|
||||||
}
|
|
||||||
+314
-1705
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>")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
+189
-181
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user