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
|
||||
|
||||
export PATH="$PATH:./vendor/bin"
|
||||
export PATH="$PATH:./"
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
- [ ] ensure sidenote numbers render in display order and not in declaration order.
|
||||
- [x] Search up for `thor.json` files.
|
||||
- [ ] OpenGraph meta tags — verify all fields match production site
|
||||
- [ ] Add Opt-in deflist support.
|
||||
- [ ] set opengraph tags / description automatically if unset. (Like hugo does)
|
||||
- [ ] Table of contents support.
|
||||
- [ ] Nav items should be active when the current page is selected.
|
||||
@@ -23,6 +24,7 @@
|
||||
guide
|
||||
- [ ] Search for grammars in multiple places
|
||||
- [ ] 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).
|
||||
- [ ] 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`.
|
||||
@@ -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
|
||||
- [ ] commands
|
||||
- [ ] 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 alerts.odin
|
||||
- [ ] Review content.odin
|
||||
|
||||
+1
-43
@@ -217,47 +217,8 @@ strip_extension :: proc(name: string) -> string {
|
||||
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.
|
||||
// .css files are minified when .Minify is enabled; all other files are copied
|
||||
// verbatim with a warning suggesting they belong in static/.
|
||||
// .css files are minified when .Minify is enabled; all other files are copied verbatim.
|
||||
// Silently skips if assets_dir doesn't exist.
|
||||
copy_assets_dir :: proc(assets_dir: string, output_dir: string, features: bit_set[Feature]) {
|
||||
if !os.exists(assets_dir) {
|
||||
@@ -299,9 +260,6 @@ copy_assets_recursive :: proc(
|
||||
minified := minify_css(string(data))
|
||||
write_file(dest, minified)
|
||||
} 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 {
|
||||
log.warnf("thor: cannot copy %s: %v", entry.fullpath, err)
|
||||
}
|
||||
|
||||
@@ -2,17 +2,13 @@ package main
|
||||
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
|
||||
WEEKDAYS: [7]string = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
|
||||
import "core:time"
|
||||
|
||||
generate_rss :: proc(pages: []Page, config: Site) -> string {
|
||||
parts: [dynamic]string
|
||||
defer delete(parts)
|
||||
sb := strings.builder_make()
|
||||
|
||||
append(
|
||||
&parts,
|
||||
fmt.aprintf(
|
||||
`<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
strings.write_string(&sb, fmt.aprintf(
|
||||
`<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>%s</title>
|
||||
@@ -20,12 +16,11 @@ generate_rss :: proc(pages: []Page, config: Site) -> string {
|
||||
<description>%s</description>
|
||||
<language>en-us</language>
|
||||
<atom:link href="%s/index.xml" rel="self" type="application/rss+xml"/>`,
|
||||
xml_escape(config.title),
|
||||
config.base_url,
|
||||
xml_escape(config.description),
|
||||
config.base_url,
|
||||
),
|
||||
)
|
||||
xml_escape(config.title),
|
||||
config.base_url,
|
||||
xml_escape(config.description),
|
||||
config.base_url,
|
||||
))
|
||||
|
||||
for page in pages {
|
||||
if page.type == .Home {
|
||||
@@ -37,10 +32,8 @@ generate_rss :: proc(pages: []Page, config: Site) -> string {
|
||||
pub_date = format_rfc822(page.date)
|
||||
}
|
||||
|
||||
append(
|
||||
&parts,
|
||||
fmt.aprintf(
|
||||
`<item>
|
||||
strings.write_string(&sb, fmt.aprintf(
|
||||
`<item>
|
||||
<title>%s</title>
|
||||
<link>%s%s</link>
|
||||
<pubDate>%s</pubDate>
|
||||
@@ -48,42 +41,35 @@ generate_rss :: proc(pages: []Page, config: Site) -> string {
|
||||
<description>%s</description>
|
||||
</item>
|
||||
`,
|
||||
xml_escape(page.title),
|
||||
config.base_url,
|
||||
page.permalink,
|
||||
pub_date,
|
||||
config.base_url,
|
||||
page.permalink,
|
||||
xml_escape(page.body_html),
|
||||
),
|
||||
)
|
||||
xml_escape(page.title),
|
||||
config.base_url,
|
||||
page.permalink,
|
||||
pub_date,
|
||||
config.base_url,
|
||||
page.permalink,
|
||||
xml_escape(page.body_html),
|
||||
))
|
||||
}
|
||||
|
||||
append(&parts, "</channel>\n</rss>")
|
||||
|
||||
return strings.join(parts[:], "")
|
||||
strings.write_string(&sb, "</channel>\n</rss>")
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
generate_sitemap :: proc(pages: []Page, base_url: string) -> string {
|
||||
parts: [dynamic]string
|
||||
defer delete(parts)
|
||||
sb := strings.builder_make()
|
||||
|
||||
append(
|
||||
&parts,
|
||||
`<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
strings.write_string(&sb, `<?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">
|
||||
`,
|
||||
)
|
||||
`)
|
||||
|
||||
for page in pages {
|
||||
lastmod := ""
|
||||
if page.date != "" {
|
||||
lastmod = fmt.aprintf("<lastmod>%s</lastmod>", page.date)
|
||||
}
|
||||
append(
|
||||
&parts,
|
||||
fmt.aprintf("<url><loc>%s%s</loc>%s</url>\n", base_url, page.permalink, lastmod),
|
||||
)
|
||||
strings.write_string(&sb, fmt.aprintf(
|
||||
"<url><loc>%s%s</loc>%s</url>\n", base_url, page.permalink, lastmod,
|
||||
))
|
||||
}
|
||||
|
||||
// Posts list page
|
||||
@@ -97,42 +83,38 @@ generate_sitemap :: proc(pages: []Page, base_url: string) -> string {
|
||||
if 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>")
|
||||
|
||||
return strings.join(parts[:], "")
|
||||
strings.write_string(&sb, "</urlset>")
|
||||
return strings.to_string(sb)
|
||||
}
|
||||
|
||||
// TODO: Leaks
|
||||
format_rfc822 :: proc(iso: string) -> string {
|
||||
if len(iso) < 19 {
|
||||
// TODO: should indicate error somehow
|
||||
return iso
|
||||
}
|
||||
|
||||
year :=
|
||||
(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]
|
||||
date, offset, _ := time.iso8601_to_time_and_offset(iso)
|
||||
|
||||
// Timezone: -04:00 → -0400
|
||||
tz := "+0000"
|
||||
if len(iso) >= 25 && (iso[19] == '+' || iso[19] == '-') {
|
||||
tz = fmt.tprintf("%c%s%s", iso[19], iso[20:22], iso[23:25])
|
||||
}
|
||||
weekday := fmt.tprintf("%s", time.weekday(date))
|
||||
month := fmt.tprintf("%s", time.month(date))
|
||||
buf: [8]byte
|
||||
t := time.to_string_hms(date, buf[:])
|
||||
|
||||
// Sakamoto's method for day of week (0=Sunday)
|
||||
t := [12]int{0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4}
|
||||
y := year
|
||||
if month < 3 {
|
||||
y -= 1
|
||||
}
|
||||
dow := (y + y / 4 - y / 100 + y / 400 + t[month - 1] + day) % 7
|
||||
|
||||
return fmt.aprintf("%s, %d %s %d %s %s", WEEKDAYS[dow], day, MONTHS[month - 1], year, time, tz)
|
||||
return fmt.aprintf(
|
||||
"%s, %02d %s %d %s %3d%2d",
|
||||
weekday[:3],
|
||||
time.day(date),
|
||||
month[:3],
|
||||
time.year(date),
|
||||
t,
|
||||
offset / 60,
|
||||
offset % 60,
|
||||
)
|
||||
}
|
||||
|
||||
xml_escape :: proc(s: string) -> string {
|
||||
|
||||
+1
-8
@@ -1,8 +1 @@
|
||||
*/**/.DS_Store
|
||||
.DS_Store
|
||||
tmp/
|
||||
bin/
|
||||
*/**/odin-mustache.dSYM/
|
||||
*/**/odin-mustache
|
||||
*/**/odin-mustache-test.dSYM/
|
||||
*/**/odin-mustache-test
|
||||
mustache
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
[submodule "spec"]
|
||||
path = spec
|
||||
url = git@github.com:mustache/spec.git
|
||||
branch = v1.4.1
|
||||
@@ -1,15 +0,0 @@
|
||||
flags := "-vet -show-timings -strict-style -vet-cast -vet-tabs -vet-using-param -disallow-do -vet-semicolon"
|
||||
name := "odin-mustache"
|
||||
|
||||
build:
|
||||
@mkdir -p bin
|
||||
odin build . -out:bin/{{name}} -debug {{flags}}
|
||||
|
||||
test: build
|
||||
odin test . -out:bin/{{name}}
|
||||
|
||||
run: build
|
||||
bin/{{name}} test/template.txt test/data.json test/layout.txt
|
||||
|
||||
check:
|
||||
odin check . {{flags}}
|
||||
@@ -1,19 +0,0 @@
|
||||
Copyright (c) 2025 Benjamin Block
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Partial Indentation — Problem & Solutions
|
||||
|
||||
## Problem
|
||||
|
||||
When a partial tag `{{> name}}` is standalone (only non-whitespace on its line), the mustache spec requires that its leading whitespace be treated as indentation and **prepended to each line of the partial source before rendering**.
|
||||
|
||||
This is a source-level transformation, not output post-processing. The distinction matters when interpolated content contains newlines:
|
||||
|
||||
```
|
||||
partial source: "|\n{{{content}}}\n|\n"
|
||||
content value: "<\n->"
|
||||
indent: " "
|
||||
|
||||
Expected output: " |\n <\n->\n |\n"
|
||||
```
|
||||
|
||||
The line `->` gets NO indent — it comes from expanded content (`<\n->`), not from a partial source line. Post-processing the output would incorrectly indent it.
|
||||
|
||||
### Spec tests that require this
|
||||
|
||||
- **Standalone Without Previous Line** — indent at start of template
|
||||
- **Standalone Without Newline** — indent at end of template
|
||||
- **Standalone Indentation** — indent with multi-line interpolated content
|
||||
|
||||
3 of 14 tests in `partials.json`. The other 11 (basic lookup, context, recursion, nesting, inline usage, failed lookup, padding) work without indentation handling.
|
||||
|
||||
### thor's real usage
|
||||
|
||||
Thor's partials (`{{> nav}}`, `{{> footer}}`, `{{>* icon}}`) are typically standalone with indentation. Without indentation handling, HTML output has wrong indentation — ugly but functional since HTML ignores whitespace.
|
||||
|
||||
## Solution 1: Store source, re-parse with indent (Recommended)
|
||||
|
||||
Add `source: string` to `Template`. When rendering a standalone partial with non-empty indent:
|
||||
|
||||
1. Prepend indent to each line of `partial.source`
|
||||
2. Re-tokenize + re-parse with `context.temp_allocator`
|
||||
3. Render the re-parsed nodes
|
||||
|
||||
When indent is empty, render the pre-parsed nodes directly (no re-parse).
|
||||
|
||||
### Pros
|
||||
- Correct by construction — exactly matches spec ("prepended to each line before rendering")
|
||||
- ~20 lines of code
|
||||
- Nested partials accumulate indentation naturally
|
||||
- No line-start state tracking
|
||||
|
||||
### Cons
|
||||
- Template gains a `source: string` field
|
||||
- Standalone partials with indent get re-parsed at render time (negligible for small fragments)
|
||||
|
||||
### Implementation sketch
|
||||
|
||||
```odin
|
||||
// tokenizer: capture indent during trim_standalone_whitespace
|
||||
// for Partial tokens, before stripping left whitespace:
|
||||
tokens[i].indent = text[nl+1:] // capture indentation
|
||||
|
||||
// mustache.odin: Template gains source field
|
||||
Template :: struct {
|
||||
nodes: [dynamic]Node,
|
||||
source: string,
|
||||
}
|
||||
|
||||
// parse: store source
|
||||
tmpl.source = source
|
||||
|
||||
// renderer: re-parse if indent
|
||||
case .Partial:
|
||||
pt, found := partials[name]
|
||||
if !found do break
|
||||
if len(node.indent) > 0 {
|
||||
indented := indent_lines(pt.source, node.indent)
|
||||
reparse, err := parse(indented, context.temp_allocator, context.temp_allocator)
|
||||
if err == nil {
|
||||
defer delete(reparse.nodes)
|
||||
render_nodes(reparse.nodes[:], reparse.nodes[:], ctx, partials, b)
|
||||
}
|
||||
} else {
|
||||
render_nodes(pt.nodes[:], pt.nodes[:], ctx, partials, b)
|
||||
}
|
||||
```
|
||||
|
||||
## Solution 2: Modify text nodes during rendering
|
||||
|
||||
Track "at line start" state while rendering the partial's nodes. Insert indent before text-node content that begins a new line. Variables/sections render normally — their output is NOT indented.
|
||||
|
||||
### Pros
|
||||
- No source storage
|
||||
- No re-parsing
|
||||
|
||||
### Cons
|
||||
- Complex: must track line-start state across nodes
|
||||
- Variables producing multi-line output need careful handling (their newlines don't create indented lines)
|
||||
- Trailing newline edge case (partial ending with `\n` shouldn't leave trailing indent)
|
||||
- Nested partials with accumulated indentation need extra logic
|
||||
- ~60+ lines of fiddly code
|
||||
|
||||
### Implementation sketch
|
||||
|
||||
```odin
|
||||
render_partial :: proc(nodes, ctx, partials, b, indent) {
|
||||
at_line_start := true
|
||||
for node in nodes {
|
||||
switch node.kind {
|
||||
case .Text:
|
||||
// Walk text, prepend indent at line starts
|
||||
// Insert indent after each \n
|
||||
// Don't indent after final \n of last text node
|
||||
...
|
||||
at_line_start = (text ends with \n)
|
||||
case .Variable, .Unescaped:
|
||||
// Render normally — no indent applied to output
|
||||
write_value(b, val, ...)
|
||||
at_line_start = false // can't know if output ends with \n
|
||||
case .Section, .Inverted:
|
||||
// Complex: nested text nodes need indent too?
|
||||
...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The "can't know if variable output ends with \n" problem makes `at_line_start` unreliable, requiring heuristics or output buffering.
|
||||
|
||||
## Adjacent Tag Standalone Detection
|
||||
|
||||
**Problem:** When a block-type tag and its close tag are adjacent (no text between them), like `{{<include}}{{/include}}\n`, neither tag is individually detected as standalone. The current check looks at the immediately adjacent token — if it's another tag (not text), the check fails. So the trailing `\n` isn't consumed.
|
||||
|
||||
**Affected spec tests:**
|
||||
- `~inheritance.json`: "Inherit", "Override parent with newlines"
|
||||
- Potentially any `{{<name}}{{/name}}` or `{{#name}}{{/name}}` on its own line
|
||||
|
||||
**Solution:** When scanning left/right for the line boundary, skip over adjacent standalone-eligible tags (they're transparent). Two helper procs replace the current inline checks:
|
||||
- `check_left(tokens, i)` — scans backwards through adjacent eligible tags until finding text or start of template
|
||||
- `check_right(tokens, i)` — scans forwards through adjacent eligible tags until finding text or end of template
|
||||
|
||||
Both return `(ok: bool, text_idx: int)` where `text_idx` is the text token to trim (or -1 if none).
|
||||
@@ -1,162 +0,0 @@
|
||||
# odin-mustache
|
||||
Native implementation of {{mustache}} templates in [Odin](https://odin-lang.org).
|
||||
|
||||
https://github.com/benjamindblock/odin-mustache/assets/1155805/7d794861-e308-4132-aaa0-f800392208a4
|
||||
|
||||
All features are implemented, except for the ability to change delimiters.
|
||||
|
||||
All in tests in the [official mustache spec](https://github.com/mustache/spec) pass successfully (except for the delimiters spec suite).
|
||||
|
||||
## Documentation
|
||||
|
||||
For more information about mustache, see the [mustache project page](https://mustache.github.io) or the mustache [man](https://mustache.github.io/mustache.5.html) [pages](https://mustache.github.io/mustache.1.html).
|
||||
|
||||
View some [example mustache files](https://github.com/mustache/mustache/tree/master/examples) to get an overview.
|
||||
|
||||
## Spec
|
||||
|
||||
The [mustache-spec](https://github.com/mustache/spec) repo is added as a git submodule for testing purposes. To run tests, ensure that you run `git clone --recursive` and/or `git submodule update` as needed.
|
||||
|
||||
## Usage
|
||||
|
||||
### CLI Usage
|
||||
```
|
||||
Usage:
|
||||
odin-mustache [path to template] [path to JSON] [OPTIONAL - layout]
|
||||
|
||||
Examples:
|
||||
$ odin-mustache template.txt data.json
|
||||
```
|
||||
|
||||
### Odin Usage
|
||||
#### 1. `render(template: string, data: any, partials: any, allocator := context.allocator)`
|
||||
|
||||
Renders a template, provided as a `string`. `data` and `partials` should be *either* a `map[string]...` or a `struct`.
|
||||
|
||||
All `map` arguments passed **must** be keyed with `string` type data. When parsing a Mustache template, the text inside a tag (eg. `name` inside `{{name}}`) will be parsed as a `string`.
|
||||
|
||||
#### 2. `render_from_filename(filename: string, data: any, partials: any, allocator := context.allocator)`
|
||||
|
||||
Renders a template stored in a text file using `data` and `partials` provided.
|
||||
|
||||
#### 3. `render_with_json(template: string, json_filename: string, allocator := context.allocator)`
|
||||
|
||||
Renders a template `string` using data and partials stored inside a JSON file. `odin-mustache` will handle loading the JSON into a usable format for Mustache to work with.
|
||||
|
||||
**NOTE**: The JSON file leverages the following top-level keys:
|
||||
```
|
||||
"data": [required]
|
||||
"partials": [optional]
|
||||
```
|
||||
|
||||
#### 4. `render_from_filename_with_json(filename: string, json_filename: string, allocator := context.allocator)`
|
||||
|
||||
Renders a template stored in a text file using data and partials stored inside a JSON file. `odin-mustache` will handle loading the JSON into a usable format for Mustache to work with.
|
||||
|
||||
#### Example
|
||||
```odin
|
||||
input := "Hello, {{name}}!"
|
||||
data: map[string]string = {
|
||||
"name" = "St. Charles",
|
||||
}
|
||||
output, err := render(input, data, context.temp_allocator)
|
||||
// => "Hello, St. Charles!"
|
||||
```
|
||||
|
||||
## Escaping
|
||||
`odin-mustache` follows the official mustache HTML escaping rules. That is, if you enclose a variable with two curly brackets, `{{var}}`, the contents are HTML-escaped. For instance, strings like `5 > 2` are converted to `5 > 2`. To use raw characters, use three curly brackets `{{{var}}}`.
|
||||
|
||||
## Layouts
|
||||
`odin-mustache` supports rendering templates with layouts.
|
||||
|
||||
A layout is a regular template with a special function. It accepts **a `{{content}}` tag**. This is where the output of a child template will be inserted. Layouts have access to the same data provided to the regular template.
|
||||
|
||||
Layouts can render content in both `{{normal}}` and `{{{literal}}}` tags.
|
||||
|
||||
This is helpful for rendering scenarios like websites where the same elements (`<head>`, `<footer>`, etc.) are shared across all pages, each of which has their own page-specific template.
|
||||
|
||||
```
|
||||
<html>
|
||||
{{{head}}
|
||||
<body>
|
||||
{{{content}}}
|
||||
</body>
|
||||
{{{footer}}
|
||||
</html>
|
||||
```
|
||||
|
||||
### CLI Usage
|
||||
```
|
||||
Usage:
|
||||
odin-mustache [path to template] [path to JSON] [OPTIONAL - layout]
|
||||
|
||||
Examples:
|
||||
$ odin-mustache template.html data.json layout.html
|
||||
```
|
||||
|
||||
### Odin Usage
|
||||
To render a layout in Odin, all four of the above render procedures have `in_layout` and `in_layout_file` variations. These methods will insert the rendered content of a template within the given layout. This is convenient for rendering HTML views inside a larger layout, amongst other use-cases.
|
||||
|
||||
The full list of corresponding methods is:
|
||||
- `render_in_layout(template: string, data: any, layout: string, partials: any, allocator := context.allocator)`
|
||||
- `render_in_layout_file(template: string, data: any, layout_filename: string, partials: any, allocator := context.allocator)`
|
||||
- `render_from_filename_in_layout(filename: string, data: any, layout: string, partials: any, allocator := context.allocator)`
|
||||
- `render_from_filename_in_layout_file(filename: string, data: any, layout_filename: string, partials: any, allocator := context.allocator)`
|
||||
- `render_with_json_in_layout(template: string, json_filename: string, layout: string, allocator := context.allocator)`
|
||||
- `render_with_json_in_layout_file(template: string, json_filename: string, layout_filename: string, allocator := context.allocator)`
|
||||
- `render_from_filename_with_json_in_layout(filename: string, json_filename: string, layout: string, allocator := context.allocator)`
|
||||
- `render_from_filename_with_json_in_layout_file(filename: string, json_filename: string, layout_filename: string, allocator := context.allocator)`
|
||||
|
||||
#### Example
|
||||
```odin
|
||||
template := "Hello, {{name}}."
|
||||
data := map[string]string{"name" = "Kilgarvan"}
|
||||
layout := `Above >>
|
||||
{{content}}
|
||||
<< Below`
|
||||
|
||||
output, _ := render_in_layout(template, data, layout, allocator := context.temp_allocator)
|
||||
fmt.println(output)
|
||||
|
||||
// Above >>
|
||||
// Hello, Kilgarvan.
|
||||
// << Below
|
||||
```
|
||||
|
||||
## Precompiled Templates
|
||||
`odin-mustache` works in two steps:
|
||||
1. Lexing and parsing
|
||||
2. Rendering with data
|
||||
|
||||
If you are rendering the same template multiple times (ex: sending out personalized emails to subscribers), the lexing+parsing step can be performed once to create a compiled template. This compiled template can then be used multiple times with different data.
|
||||
|
||||
### Example
|
||||
```odin
|
||||
src := "Hello, {{name}}!"
|
||||
template: Template
|
||||
output: string
|
||||
data: map[string]string
|
||||
partials: map[string]string
|
||||
|
||||
lexer := Lexer{src=src, delim=CORE_DEF}
|
||||
lexer_parse(&lexer)
|
||||
|
||||
data = {"name" = "St. Charles"}
|
||||
template = Template{lexer=lexer, data=data, partials=partials}
|
||||
output, _ = template_render(&template)
|
||||
// => Hello, St. Charles!
|
||||
|
||||
data = {"name" = "Edouard"}
|
||||
template = Template{lexer=lexer, data=data, partials=partials}
|
||||
output, _ = template_render(&template)
|
||||
// => Hello, Edouard!
|
||||
```
|
||||
|
||||
## Future Work
|
||||
- Validate JSON keys
|
||||
- Better CLI argument parsing
|
||||
- Improve error handling and reporting
|
||||
- Add optional logging for debugging and performance work
|
||||
- Configurable precision for floating point types
|
||||
- Add support for changing delimiters
|
||||
- Special loop conditionals (eg., checking for the first iteration or last iteration)
|
||||
@@ -0,0 +1,2 @@
|
||||
- [ ] Initialize string builder size based on filesize?
|
||||
- [ ] Review [whitespace handling code](./tokenizer.odin)
|
||||
@@ -0,0 +1,221 @@
|
||||
package mustache
|
||||
|
||||
import "base:runtime"
|
||||
import "core:fmt"
|
||||
import "core:reflect"
|
||||
import "core:strconv"
|
||||
import "core:strings"
|
||||
|
||||
// effective unwraps union variants and strips Named/Distinct layers,
|
||||
// returning the "peeled" any value and its base type info.
|
||||
effective :: proc(a: any) -> (val: any, info: ^runtime.Type_Info) {
|
||||
val = a
|
||||
info = nil
|
||||
if val == nil do return
|
||||
|
||||
ti := type_info_of(val.id)
|
||||
if ti == nil do return
|
||||
|
||||
base := runtime.type_info_base(ti)
|
||||
|
||||
if _, ok := base.variant.(runtime.Type_Info_Union); ok {
|
||||
variant := reflect.get_union_variant(val)
|
||||
if variant == nil do return {}, nil
|
||||
return effective(variant)
|
||||
}
|
||||
|
||||
info = base
|
||||
return
|
||||
}
|
||||
|
||||
// lookup_in resolves a single key in a container (struct or map).
|
||||
// Returns found=false if the key doesn't exist or the container is not a struct/map.
|
||||
lookup_in :: proc(container: any, key: string) -> (result: any, found: bool) {
|
||||
if container == nil do return nil, false
|
||||
|
||||
val, info := effective(container)
|
||||
if info == nil do return nil, false
|
||||
|
||||
#partial switch v in info.variant {
|
||||
case runtime.Type_Info_Struct:
|
||||
result = reflect.struct_field_value_by_name(val, key, allow_using = true)
|
||||
found = result != nil
|
||||
return
|
||||
|
||||
case runtime.Type_Info_Map:
|
||||
mi := v
|
||||
rm_ptr := (^runtime.Raw_Map)(val.data)
|
||||
if rm_ptr.len == 0 do return nil, false
|
||||
|
||||
k := key
|
||||
seed := runtime.map_seed(rm_ptr^)
|
||||
h := mi.map_info.key_hasher(&k, seed)
|
||||
value_ptr := runtime.__dynamic_map_get(rm_ptr, mi.map_info, h, &k)
|
||||
if value_ptr == nil do return nil, false
|
||||
|
||||
result = any{value_ptr, mi.value.id}
|
||||
found = true
|
||||
return
|
||||
|
||||
case:
|
||||
return nil, false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// resolve_name resolves a (possibly dotted) name from the context stack.
|
||||
// The first segment walks the stack top-to-bottom; remaining segments
|
||||
// resolve against the prior result only.
|
||||
resolve_name :: proc(name: string, ctx: []any) -> any {
|
||||
if name == "." {
|
||||
if len(ctx) > 0 do return ctx[len(ctx) - 1]
|
||||
return nil
|
||||
}
|
||||
|
||||
parts: [16]string
|
||||
part_count := 0
|
||||
start := 0
|
||||
for i in 0 ..< len(name) {
|
||||
if name[i] == '.' {
|
||||
if part_count < len(parts) {
|
||||
parts[part_count] = name[start:i]
|
||||
part_count += 1
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if part_count < len(parts) {
|
||||
parts[part_count] = name[start:]
|
||||
part_count += 1
|
||||
}
|
||||
|
||||
if part_count == 0 do return nil
|
||||
dot_parts := parts[:part_count]
|
||||
|
||||
result: any = nil
|
||||
found := false
|
||||
for i := len(ctx) - 1; i >= 0; i -= 1 {
|
||||
result, found = lookup_in(ctx[i], dot_parts[0])
|
||||
if found do break
|
||||
}
|
||||
|
||||
if !found do return nil
|
||||
if len(dot_parts) == 1 do return result
|
||||
|
||||
for i := 1; i < len(dot_parts); i += 1 {
|
||||
result, found = lookup_in(result, dot_parts[i])
|
||||
if !found do return nil
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// is_truthy checks mustache truthiness.
|
||||
is_truthy :: proc(a: any) -> bool {
|
||||
if a == nil do return false
|
||||
|
||||
val, info := effective(a)
|
||||
if info == nil do return false
|
||||
if reflect.is_nil(val) do return false
|
||||
|
||||
#partial switch _ in info.variant {
|
||||
case runtime.Type_Info_Slice, runtime.Type_Info_Dynamic_Array, runtime.Type_Info_Map:
|
||||
return reflect.length(val) > 0
|
||||
case:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// list_info returns element type info, count, and data pointer for a list value.
|
||||
// Returns elem_info=nil if the value is not a list.
|
||||
list_info :: proc(a: any) -> (elem_info: ^runtime.Type_Info, count: int, data: rawptr) {
|
||||
val, info := effective(a)
|
||||
if info == nil do return nil, 0, nil
|
||||
|
||||
#partial switch v in info.variant {
|
||||
case runtime.Type_Info_Slice:
|
||||
raw := (^runtime.Raw_Slice)(val.data)^
|
||||
return v.elem, raw.len, raw.data
|
||||
case runtime.Type_Info_Dynamic_Array:
|
||||
raw := (^runtime.Raw_Dynamic_Array)(val.data)^
|
||||
return v.elem, raw.len, raw.data
|
||||
case runtime.Type_Info_Array:
|
||||
return v.elem, v.count, val.data
|
||||
case:
|
||||
return nil, 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
// any_to_string converts a scalar value to a string using the temp allocator.
|
||||
any_to_string :: proc(a: any) -> string {
|
||||
if a == nil do return ""
|
||||
val, _ := effective(a)
|
||||
|
||||
switch v in val {
|
||||
case string:
|
||||
return v
|
||||
case bool:
|
||||
return "true" if v else "false"
|
||||
case i64:
|
||||
return fmt.tprintf("%d", v)
|
||||
case f64:
|
||||
// return fmt.tprintf("%.3f", v)
|
||||
return format_f64(v)
|
||||
case int:
|
||||
return fmt.tprintf("%d", v)
|
||||
case:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// format_f64 produces the shortest string that round-trips to the same f64.
|
||||
// Works around Odin's strconv not implementing shortest representation.
|
||||
format_f64 :: proc(v: f64) -> string {
|
||||
buf: [64]byte
|
||||
for prec in 1 ..= 17 {
|
||||
s := strconv.write_float(buf[:], v, 'g', prec, 64)
|
||||
if len(s) > 0 && s[0] == '+' do s = s[1:]
|
||||
parsed, ok := strconv.parse_f64(s)
|
||||
if ok && parsed == v {
|
||||
return strings.clone(s, context.temp_allocator)
|
||||
}
|
||||
}
|
||||
s := strconv.write_float(buf[:], v, 'g', -1, 64)
|
||||
if len(s) > 0 && s[0] == '+' do s = s[1:]
|
||||
return strings.clone(s, context.temp_allocator)
|
||||
}
|
||||
|
||||
// write_value stringifies a value and writes it to the builder,
|
||||
// optionally HTML-escaped.
|
||||
write_value :: proc(b: ^strings.Builder, a: any, escape: bool) {
|
||||
s := any_to_string(a)
|
||||
if len(s) == 0 do return
|
||||
|
||||
if !escape {
|
||||
strings.write_string(b, s)
|
||||
return
|
||||
}
|
||||
|
||||
start := 0
|
||||
for i in 0 ..< len(s) {
|
||||
switch s[i] {
|
||||
case '&', '<', '>', '"':
|
||||
if i > start do strings.write_string(b, s[start:i])
|
||||
switch s[i] {
|
||||
case '&':
|
||||
strings.write_string(b, "&")
|
||||
case '<':
|
||||
strings.write_string(b, "<")
|
||||
case '>':
|
||||
strings.write_string(b, ">")
|
||||
case '"':
|
||||
strings.write_string(b, """)
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
strings.write_string(b, s[start:])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
Hello, {{name}}!
|
||||
|
||||
Languages you know:
|
||||
{{#languages}}
|
||||
- {{.}}
|
||||
{{/languages}}
|
||||
|
||||
{{#show_footer}}
|
||||
---
|
||||
Rendered with odin-mustache.
|
||||
{{/show_footer}}
|
||||
@@ -1,28 +0,0 @@
|
||||
package main
|
||||
|
||||
import "core:fmt"
|
||||
import "core:os"
|
||||
|
||||
import mustache "../.."
|
||||
|
||||
Person :: struct {
|
||||
name: string,
|
||||
languages: []string,
|
||||
show_footer: bool,
|
||||
}
|
||||
|
||||
main :: proc() {
|
||||
data := Person{
|
||||
name = "World",
|
||||
languages = {"Odin", "C", "Rust"},
|
||||
show_footer = true,
|
||||
}
|
||||
|
||||
result, err := mustache.render_from_filename("hello.mustache", data)
|
||||
if err != nil {
|
||||
fmt.eprintfln("render error: %v", err)
|
||||
os.exit(1)
|
||||
}
|
||||
|
||||
fmt.print(result)
|
||||
}
|
||||
+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 {
|
||||
"name" = "World",
|
||||
}
|
||||
result, err := mustache.render("Hello, {{name}}!", data)
|
||||
tpl, _ := mustache.parse("Hello, {{name}}!")
|
||||
result, err := mustache.render(tpl, data)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "Hello, World!")
|
||||
}
|
||||
@@ -20,7 +21,8 @@ test_bool_section :: proc(t: ^testing.T) {
|
||||
data := map[string]bool {
|
||||
"show" = true,
|
||||
}
|
||||
result, err := mustache.render("{{#show}}visible{{/show}}", data)
|
||||
tpl, _ := mustache.parse("{{#show}}visible{{/show}}")
|
||||
result, err := mustache.render(tpl, data)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "visible")
|
||||
}
|
||||
@@ -30,7 +32,8 @@ test_inverted_section :: proc(t: ^testing.T) {
|
||||
data := map[string]bool {
|
||||
"show" = false,
|
||||
}
|
||||
result, err := mustache.render("{{^show}}hidden{{/show}}", data)
|
||||
tpl, _ := mustache.parse("{{^show}}hidden{{/show}}")
|
||||
result, err := mustache.render(tpl, data)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "hidden")
|
||||
}
|
||||
@@ -40,7 +43,8 @@ test_unescaped :: proc(t: ^testing.T) {
|
||||
data := map[string]string {
|
||||
"html" = "<b>bold</b>",
|
||||
}
|
||||
result, err := mustache.render("{{{html}}}", data)
|
||||
tpl, _ := mustache.parse("{{{html}}}")
|
||||
result, err := mustache.render(tpl, data)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "<b>bold</b>")
|
||||
}
|
||||
@@ -55,7 +59,8 @@ test_array_iteration :: proc(t: ^testing.T) {
|
||||
data := map[string][dynamic]map[string]string {
|
||||
"items" = items,
|
||||
}
|
||||
result, err := mustache.render("{{#items}}{{name}} {{/items}}", data)
|
||||
tpl, _ := mustache.parse("{{#items}}{{name}} {{/items}}")
|
||||
result, err := mustache.render(tpl, data)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "Alice Bob ")
|
||||
}
|
||||
@@ -65,10 +70,12 @@ test_partial :: proc(t: ^testing.T) {
|
||||
data := map[string]string {
|
||||
"name" = "Test",
|
||||
}
|
||||
partials := map[string]string {
|
||||
"greeting" = "Hello, {{name}}!",
|
||||
greeting_tpl, _ := mustache.parse("Hello, {{name}}!")
|
||||
partials := map[string]mustache.Template {
|
||||
"greeting" = greeting_tpl,
|
||||
}
|
||||
result, err := mustache.render("{{>greeting}}", data, partials)
|
||||
main_tpl, _ := mustache.parse("{{>greeting}}")
|
||||
result, err := mustache.render(main_tpl, data, partials)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "Hello, Test!")
|
||||
}
|
||||
@@ -81,7 +88,8 @@ test_mixed_types :: proc(t: ^testing.T) {
|
||||
"date" = "17 Jul 2025",
|
||||
}
|
||||
|
||||
result, err := mustache.render("{{site_title}}: {{#has_date}}{{date}}{{/has_date}}", data)
|
||||
tpl, _ := mustache.parse("{{site_title}}: {{#has_date}}{{date}}{{/has_date}}")
|
||||
result, err := mustache.render(tpl, data)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "One Idiot Developer: 17 Jul 2025")
|
||||
}
|
||||
@@ -96,19 +104,8 @@ test_nested_context :: proc(t: ^testing.T) {
|
||||
"page" = page,
|
||||
}
|
||||
|
||||
result, err := mustache.render("{{site_title}}: {{#page}}{{title}}{{/page}}", data)
|
||||
tpl, _ := mustache.parse("{{site_title}}: {{#page}}{{title}}{{/page}}")
|
||||
result, err := mustache.render(tpl, data)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "One Idiot Developer: My Post")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_layout :: proc(t: ^testing.T) {
|
||||
layout := `<html><body>{{{content}}}</body></html>`
|
||||
template := "<p>Hello!</p>"
|
||||
data := map[string]string{}
|
||||
|
||||
result, err := mustache.render_in_layout(template, data, layout)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "<html><body><p>Hello!</p></body></html>")
|
||||
}
|
||||
|
||||
|
||||
+189
-181
@@ -3,24 +3,13 @@ package main
|
||||
|
||||
import "mustache"
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
|
||||
MONTHS: [12]string = {
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
}
|
||||
import "core:time"
|
||||
import "core:time/datetime"
|
||||
|
||||
Page_Context :: struct {
|
||||
permalink: string,
|
||||
@@ -36,9 +25,40 @@ Year_Section :: struct {
|
||||
posts: [dynamic]Page_Context,
|
||||
}
|
||||
|
||||
Year_Slice :: struct {
|
||||
year: string,
|
||||
posts: []Page_Context,
|
||||
Base_Data :: struct {
|
||||
now: datetime.DateTime,
|
||||
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 {
|
||||
@@ -84,9 +104,52 @@ og_type :: proc(is_article: bool) -> string {
|
||||
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) {
|
||||
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
|
||||
home: Page
|
||||
has_home := false
|
||||
@@ -103,7 +166,7 @@ render_site :: proc(pages: []Page, config: Site) {
|
||||
if page.type == .Home {
|
||||
continue
|
||||
}
|
||||
html := render_page_html(page, config)
|
||||
html := render_page_html(page, config, post_tpl, partials, base)
|
||||
if .Minify in config.features {
|
||||
html = minify_html(html)
|
||||
}
|
||||
@@ -112,7 +175,8 @@ render_site :: proc(pages: []Page, config: Site) {
|
||||
|
||||
// Render home page
|
||||
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 {
|
||||
home_html = minify_html(home_html)
|
||||
}
|
||||
@@ -120,7 +184,8 @@ render_site :: proc(pages: []Page, config: Site) {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
posts_html = minify_html(posts_html)
|
||||
}
|
||||
@@ -134,10 +199,7 @@ render_site :: proc(pages: []Page, config: Site) {
|
||||
sitemap := generate_sitemap(pages, config.base_url)
|
||||
write_file(fmt.tprintf("%s/sitemap.xml", config.output_dir), sitemap)
|
||||
|
||||
// Copy static directory (favicon, CSS, images, etc.)
|
||||
copy_static_dir(config.static_dir, config.output_dir)
|
||||
|
||||
// Copy and maybe minify assets directory
|
||||
// Copy and optionally minify assets directory
|
||||
copy_assets_dir(config.assets_dir, config.output_dir, config.features)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
data := map[string]any {
|
||||
"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 := Page_Data {
|
||||
base = base,
|
||||
}
|
||||
|
||||
partials := load_partials(config.layouts_dir)
|
||||
|
||||
post_tpl, _ := os.read_entire_file_from_path(
|
||||
fmt.tprintf("%s/post.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(post_tpl), data, string(base_tpl), partials)
|
||||
if err != nil {
|
||||
fmt.eprintfln("thor: mustache error rendering page: %v", err)
|
||||
return ""
|
||||
}
|
||||
return result
|
||||
data.title = fmt.tprintf("%s | %s", page.title, config.title)
|
||||
data.page_title = page.title
|
||||
data.body = page.body_html
|
||||
data.has_date = page.date != ""
|
||||
data.date_iso = page.date
|
||||
data.date_display = format_date(page.date)
|
||||
data.is_post = is_article
|
||||
data.og_url = fmt.tprintf("%s%s", config.base_url, page.permalink)
|
||||
data.og_title = strip_html_tags(page.title)
|
||||
data.og_type = og_type(is_article)
|
||||
data.is_article = is_article
|
||||
data.og_section = "posts"
|
||||
data.og_published = page.date
|
||||
return render_template(content_tpl, data, partials)
|
||||
}
|
||||
|
||||
load_partials :: proc(layouts_dir: string) -> map[string]string {
|
||||
partials: map[string]string
|
||||
render_home_html :: proc(
|
||||
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)
|
||||
load_partials_recursive(&partials, partials_dir, "")
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
if ok == nil {
|
||||
partials[key] = string(data)
|
||||
tpl, perr := mustache.parse(string(data))
|
||||
if perr != nil {
|
||||
log.warnf("thor: failed to parse partial %s: %v", key, perr)
|
||||
}
|
||||
partials[key] = tpl
|
||||
}
|
||||
case .Directory:
|
||||
sub_prefix := entry.name
|
||||
@@ -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 {
|
||||
if len(iso) < 10 {
|
||||
return iso
|
||||
return ""
|
||||
}
|
||||
year := iso[:4]
|
||||
month_num := (int(iso[5]) - 0x30) * 10 + (int(iso[6]) - 0x30)
|
||||
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)
|
||||
date, _, _, _ := time.iso8601_to_components(iso)
|
||||
return fmt.aprintf("%s %d, %d", time.Month(date.month), date.day, date.year)
|
||||
}
|
||||
|
||||
get_year :: proc(iso: string) -> string {
|
||||
|
||||
@@ -17,7 +17,6 @@ Site :: struct {
|
||||
base_url: string,
|
||||
config_path: string,
|
||||
content_dir: string,
|
||||
static_dir: string,
|
||||
assets_dir: string,
|
||||
output_dir: string,
|
||||
layouts_dir: string,
|
||||
@@ -38,7 +37,6 @@ Flags :: struct {
|
||||
description: string,
|
||||
base_url: string `args:"name=base-url"`,
|
||||
content_dir: string `args:"name=content"`,
|
||||
static_dir: string `args:"name=static"`,
|
||||
assets_dir: string `args:"name=assets"`,
|
||||
output_dir: string `args:"name=output"`,
|
||||
layouts_dir: string,
|
||||
@@ -87,9 +85,6 @@ init_site :: proc(site: ^Site, args: []string) {
|
||||
if site.content_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 == "" {
|
||||
site.assets_dir = fmt.tprintf("%s/assets", config_dir)
|
||||
}
|
||||
@@ -133,9 +128,6 @@ merge_flags :: proc(config: ^Flags, flags: Flags) {
|
||||
if flags.content_dir != "" {
|
||||
config.content_dir = flags.content_dir
|
||||
}
|
||||
if flags.static_dir != "" {
|
||||
config.static_dir = flags.static_dir
|
||||
}
|
||||
if 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.config_path = flags.config_path
|
||||
site.content_dir = flags.content_dir
|
||||
site.static_dir = flags.static_dir
|
||||
site.assets_dir = flags.assets_dir
|
||||
site.output_dir = flags.output_dir
|
||||
site.layouts_dir = flags.layouts_dir
|
||||
|
||||
Reference in New Issue
Block a user