mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
Compare commits
3 Commits
4521960824
...
918100bf5d
| Author | SHA1 | Date | |
|---|---|---|---|
| 918100bf5d | |||
| cdb4ad9329 | |||
| 9671a90726 |
@@ -0,0 +1,135 @@
|
|||||||
|
# Thor — Odin Static Site Generator
|
||||||
|
|
||||||
|
Thor is a static site generator written in [Odin](https://odin-lang.org), replacing Hugo for the `sbrow.github.io` blog. It lives at `./thor/` as a git subtree with its own `flake.nix`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
thor.json ← site config (title, base_url, author, params)
|
||||||
|
content/ ← markdown and HTML content files
|
||||||
|
layouts/ ← Mustache templates + partials
|
||||||
|
assets/ ← CSS (TailwindCSS source) and JS
|
||||||
|
public/ ← build output (generated)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source files
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `main.odin` | Entry point. Calls `init_site`, `walk_content`, `render_site` |
|
||||||
|
| `site.odin` | `Site` struct (config + arena), `init_site`, `load_site_config`, `site_merge`, `site_allocator`, `destroy_site` |
|
||||||
|
| `frontmatter.odin` | JSON frontmatter parser (`{ }` delimited) |
|
||||||
|
| `content.odin` | `Page` struct, content walker, page loader, cmark integration, footnote/alert/emoji pipeline |
|
||||||
|
| `footnotes.odin` | Footnote definition stripping (pre-cmark) + sidenote injection (post-cmark) |
|
||||||
|
| `alerts.odin` | GitHub alert post-processor (`> [!CAUTION]` → styled blockquote) |
|
||||||
|
| `emoji.odin` | Emoji shortcode expander (`:shrug:` → `¯\_(ツ)_/¯`) |
|
||||||
|
| `render.odin` | Mustache template rendering, all page types, RSS, sitemap, robots.txt, `load_partials` recursive scan |
|
||||||
|
| `feed.odin` | RSS feed + sitemap XML generation |
|
||||||
|
| `mustache/` | Vendored [odin-mustache](https://github.com/benjamindblock/odin-mustache) library |
|
||||||
|
|
||||||
|
Icon SVGs live as HTML partials in `layouts/partials/icons/` (home, github, rss, chevron_up, star).
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
thor.json → init_site → Site (config + Dynamic_Arena)
|
||||||
|
↓
|
||||||
|
content/ → walk_content → []Page (with body_html from cmark pipeline)
|
||||||
|
↓
|
||||||
|
layouts/*.html → render_site (Mustache render_in_layout) → public/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Config system
|
||||||
|
|
||||||
|
`Site` has `params: json.Value` for arbitrary user-defined data from `thor.json`. Social links and other template-only data live under `"params"`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "...",
|
||||||
|
"base_url": "...",
|
||||||
|
"author": "...",
|
||||||
|
"params": {
|
||||||
|
"social": [
|
||||||
|
{ "name": "github", "url": "...", "icon": "icons/github" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Templates access params via dotted keys: `{{#params.social}}`, `{{>* icon}}`.
|
||||||
|
|
||||||
|
Config precedence: `CLI flags > thor.json values > hardcoded defaults`.
|
||||||
|
|
||||||
|
### Markdown pipeline (in content.odin `load_page`)
|
||||||
|
|
||||||
|
```
|
||||||
|
raw markdown
|
||||||
|
→ expand_emoji (pre-cmark: :shortcode: → unicode)
|
||||||
|
→ strip_definitions (pre-cmark: extract [^id]: definitions)
|
||||||
|
→ cmark markdown_to_html (Unsafe mode for HTML passthrough)
|
||||||
|
→ inject_sidenotes (post-cmark: [^id] → <label><input><span> markup)
|
||||||
|
→ inject_alerts (post-cmark: [!TYPE] blockquotes → styled alerts)
|
||||||
|
```
|
||||||
|
|
||||||
|
`.html` content files skip cmark entirely — body is used as-is.
|
||||||
|
|
||||||
|
### Memory management
|
||||||
|
|
||||||
|
- `Site` owns a `mem.Dynamic_Arena`
|
||||||
|
- `init_site` calls `mem.dynamic_arena_init(&site.arena, alignment = 64)` — the 64-byte alignment is required by Odin's map runtime (`MAP_CACHE_LINE_SIZE`)
|
||||||
|
- Config loading (flags + JSON) uses the arena allocator explicitly
|
||||||
|
- `site_allocator(site)` returns the arena allocator for callers
|
||||||
|
- `destroy_site` frees the arena
|
||||||
|
- **Not yet wired:** `context.allocator` is not set to the arena in `main.odin`, so rendering and content processing still use the heap allocator
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
### Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix develop
|
||||||
|
# From blog root:
|
||||||
|
odin run ./thor -- -drafts
|
||||||
|
tailwindcss --input assets/css/main.css --output public/css/main.css --minify
|
||||||
|
cp assets/js/main.js public/js/main.js
|
||||||
|
caddy run # serves public/ on blog.localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix build # runs thor + tailwindcss + cp js, outputs to ./result/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd thor
|
||||||
|
odin test . # site + mustache smoke tests
|
||||||
|
odin test mustache # mustache spec + targeted tests (37 total)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vendored mustache patches
|
||||||
|
|
||||||
|
Four modifications to `mustache/mustache.odin`:
|
||||||
|
|
||||||
|
1. **`any` unwrapping** in `map_get` and `data_type` — when values come from `map[string]any`, the inner `any` wrapper is unwrapped so type detection works correctly for nested maps and lists.
|
||||||
|
|
||||||
|
2. **Layout partials** — `layout_template.partials = tmpl.partials` added so partials (`{{> nav}}`, `{{> footer}}`) work inside the base layout template.
|
||||||
|
|
||||||
|
3. **Inline partial rendering** — replaced `template_insert_partial` (which injected tokens into the main token list, breaking section iteration) with `template_render_partial` (which lexes the partial, temporarily swaps `tmpl.lexer`, and recursively processes via `template_process_tokens`). Fixes partials inside sections.
|
||||||
|
|
||||||
|
4. **Dynamic Names** — `{{>*key}}` support. When a partial token value starts with `*`, the remaining key is resolved from the data context stack, and the resolved string is used as the partial name. Enables per-item partial selection inside sections (e.g., `{{>* icon}}` resolves `icon` from each social link).
|
||||||
|
|
||||||
|
Extracted `template_process_tokens` from `template_eat_tokens` to separate ROOT initialization + skip pass from the core token loop, allowing partials to reuse the loop.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- cmark allocates via C malloc, not the arena. HTML output leaks until process exit.
|
||||||
|
- CSS/JS cache busting uses manual `?v=N` query params instead of content hashing.
|
||||||
|
- The `shrug` emoji has a backslash that may not display correctly.
|
||||||
|
- `json.Value` params require 64-byte aligned arena (workaround for `dynamic_arena_allocator_proc` ignoring per-allocation alignment).
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
See `TODOS.md` for the full list.
|
||||||
@@ -10,21 +10,19 @@
|
|||||||
- [x] Nix build integration — main flake runs thor + tailwindcss instead of Hugo
|
- [x] Nix build integration — main flake runs thor + tailwindcss instead of Hugo
|
||||||
- [ ] Fix copy-to-clipboard button x-axis positioning inside code blocks
|
- [ ] Fix copy-to-clipboard button x-axis positioning inside code blocks
|
||||||
- [ ] Content-hash fingerprinting for CSS and JS cache busting
|
- [ ] Content-hash fingerprinting for CSS and JS cache busting
|
||||||
- [ ] OpenGraph meta tags
|
- [x] OpenGraph meta tags
|
||||||
- [ ] Search up for `thor.json` files.
|
- [ ] Search up for `thor.json` files.
|
||||||
- [ ] Partials inside sections still produce duplicate items — a fundamental issue with the mustache library's token handling.
|
|
||||||
- [ ] OpenGraph meta tags — verify all fields match production site
|
- [ ] OpenGraph meta tags — verify all fields match production site
|
||||||
|
- [ ] Nav items should be active when the current page is selected.
|
||||||
- [ ] Review every file in thor
|
- [ ] Review every file in thor
|
||||||
- [ ] Review alerts.odin
|
- [ ] Review alerts.odin
|
||||||
- [ ] Review config.odin
|
|
||||||
- [ ] Review content.odin
|
- [ ] Review content.odin
|
||||||
- [ ] Review emoji.odin
|
- [ ] Review emoji.odin
|
||||||
- [ ] Review feed.odin
|
- [ ] Review feed.odin
|
||||||
- [ ] Review footnotes.odin
|
- [ ] Review footnotes.odin
|
||||||
- [ ] Review frontmatter.odin
|
- [ ] Review frontmatter.odin
|
||||||
- [ ] Review icons.odin
|
|
||||||
- [ ] Review main.odin
|
- [ ] Review main.odin
|
||||||
- [ ] Review main_test.odin
|
- [ ] Review main_test.odin
|
||||||
- [ ] Review mustache_test.odin
|
- [ ] Review mustache_test.odin
|
||||||
- [ ] Review render.odin
|
- [ ] Review render.odin
|
||||||
- [ ] Review site.odin
|
- [x] Review site.odin
|
||||||
|
|||||||
-68
@@ -1,68 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import "core:encoding/json"
|
|
||||||
import "core:fmt"
|
|
||||||
import "core:os"
|
|
||||||
import "core:strings"
|
|
||||||
|
|
||||||
Social_Link :: struct {
|
|
||||||
name: string,
|
|
||||||
url: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
Site_Config :: struct {
|
|
||||||
config_path: string `args:"name=config"`,
|
|
||||||
title: string,
|
|
||||||
description: string,
|
|
||||||
base_url: string `args:"name=base-url"`,
|
|
||||||
content_dir: string `args:"name=content"`,
|
|
||||||
output_dir: string `args:"name=output"`,
|
|
||||||
layouts_dir: string,
|
|
||||||
author: string,
|
|
||||||
social: []Social_Link,
|
|
||||||
drafts: bool `args:"name=drafts"`,
|
|
||||||
}
|
|
||||||
|
|
||||||
load_config :: proc(path: string) -> (config: Site_Config, ok: bool) {
|
|
||||||
data, err := os.read_entire_file_from_path(path, context.allocator)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
value, parse_err := json.parse_string(string(data), spec = .JSON)
|
|
||||||
if parse_err != nil {
|
|
||||||
fmt.eprintfln("thor: failed to parse %s: %v", path, parse_err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer json.destroy_value(value)
|
|
||||||
|
|
||||||
obj, obj_ok := value.(json.Object)
|
|
||||||
if !obj_ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
config.title = json_get_string(obj, "title")
|
|
||||||
config.description = json_get_string(obj, "description")
|
|
||||||
config.base_url = json_get_string(obj, "base_url")
|
|
||||||
config.content_dir = json_get_string(obj, "content_dir")
|
|
||||||
config.output_dir = json_get_string(obj, "output_dir")
|
|
||||||
config.author = json_get_string(obj, "author")
|
|
||||||
|
|
||||||
if v, found := obj["social"]; found {
|
|
||||||
if arr, arr_ok := v.(json.Array); arr_ok {
|
|
||||||
social: [dynamic]Social_Link
|
|
||||||
for elem in arr {
|
|
||||||
if elem_obj, eo_ok := elem.(json.Object); eo_ok {
|
|
||||||
link := Social_Link{}
|
|
||||||
link.name = json_get_string(elem_obj, "name")
|
|
||||||
link.url = json_get_string(elem_obj, "url")
|
|
||||||
append(&social, link)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
config.social = social[:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ok = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
+19
-15
@@ -3,6 +3,7 @@ package main
|
|||||||
import cm "vendor:commonmark"
|
import cm "vendor:commonmark"
|
||||||
|
|
||||||
import "core:fmt"
|
import "core:fmt"
|
||||||
|
import "core:log"
|
||||||
import "core:os"
|
import "core:os"
|
||||||
import "core:strings"
|
import "core:strings"
|
||||||
|
|
||||||
@@ -29,6 +30,8 @@ Page :: struct {
|
|||||||
|
|
||||||
// walk_content reads the content directory and returns all non-draft pages
|
// walk_content reads the content directory and returns all non-draft pages
|
||||||
// (or all pages if include_drafts is true).
|
// (or all pages if include_drafts is true).
|
||||||
|
//
|
||||||
|
// TODO: What is the lifetime of pages?
|
||||||
walk_content :: proc(content_path: string, include_drafts: bool) -> []Page {
|
walk_content :: proc(content_path: string, include_drafts: bool) -> []Page {
|
||||||
pages: [dynamic]Page
|
pages: [dynamic]Page
|
||||||
|
|
||||||
@@ -40,7 +43,9 @@ walk_content :: proc(content_path: string, include_drafts: bool) -> []Page {
|
|||||||
collect_posts(&pages, posts_path)
|
collect_posts(&pages, posts_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !include_drafts {
|
if include_drafts {
|
||||||
|
return pages[:]
|
||||||
|
} else {
|
||||||
filtered: [dynamic]Page
|
filtered: [dynamic]Page
|
||||||
for &page in pages {
|
for &page in pages {
|
||||||
if !page.draft {
|
if !page.draft {
|
||||||
@@ -50,8 +55,6 @@ walk_content :: proc(content_path: string, include_drafts: bool) -> []Page {
|
|||||||
delete(pages)
|
delete(pages)
|
||||||
return filtered[:]
|
return filtered[:]
|
||||||
}
|
}
|
||||||
|
|
||||||
return pages[:]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
collect_home :: proc(pages: ^[dynamic]Page, content_path: string) {
|
collect_home :: proc(pages: ^[dynamic]Page, content_path: string) {
|
||||||
@@ -78,7 +81,7 @@ collect_home :: proc(pages: ^[dynamic]Page, content_path: string) {
|
|||||||
collect_standalone :: proc(pages: ^[dynamic]Page, content_path: string) {
|
collect_standalone :: proc(pages: ^[dynamic]Page, content_path: string) {
|
||||||
entries, err := os.read_all_directory_by_path(content_path, context.allocator)
|
entries, err := os.read_all_directory_by_path(content_path, context.allocator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.eprintfln("thor: cannot read %s: %v", content_path, err)
|
log.warnf("thor: cannot read %s: %v", content_path, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer os.file_info_slice_delete(entries, context.allocator)
|
defer os.file_info_slice_delete(entries, context.allocator)
|
||||||
@@ -105,7 +108,7 @@ collect_standalone :: proc(pages: ^[dynamic]Page, content_path: string) {
|
|||||||
collect_posts :: proc(pages: ^[dynamic]Page, posts_path: string) {
|
collect_posts :: proc(pages: ^[dynamic]Page, posts_path: string) {
|
||||||
entries, err := os.read_all_directory_by_path(posts_path, context.allocator)
|
entries, err := os.read_all_directory_by_path(posts_path, context.allocator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.eprintfln("thor: cannot read %s: %v", posts_path, err)
|
log.warnf("thor: cannot read %s: %v", posts_path, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer os.file_info_slice_delete(entries, context.allocator)
|
defer os.file_info_slice_delete(entries, context.allocator)
|
||||||
@@ -149,7 +152,7 @@ load_page :: proc(
|
|||||||
) {
|
) {
|
||||||
data, err := os.read_entire_file_from_path(file_path, context.allocator)
|
data, err := os.read_entire_file_from_path(file_path, context.allocator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.eprintfln("thor: cannot read %s: %v", file_path, err)
|
log.warnf("thor: cannot read %s: %v", file_path, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,15 +162,15 @@ load_page :: proc(
|
|||||||
body = strings.trim_left(content, " \t\r\n")
|
body = strings.trim_left(content, " \t\r\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
page.type = page_type
|
page.type = page_type
|
||||||
page.slug = slug
|
page.slug = slug
|
||||||
page.title = fm.title
|
page.title = fm.title
|
||||||
page.description = fm.description
|
page.description = fm.description
|
||||||
page.date = fm.date
|
page.date = fm.date
|
||||||
page.draft = fm.draft
|
page.draft = fm.draft
|
||||||
page.is_starred = fm.isStarred
|
page.is_starred = fm.isStarred
|
||||||
page.menu = fm.menu
|
page.menu = fm.menu
|
||||||
page.body = strings.clone(body)
|
page.body = strings.clone(body)
|
||||||
|
|
||||||
if strings.has_suffix(file_path, ".html") {
|
if strings.has_suffix(file_path, ".html") {
|
||||||
page.body_html = strings.clone(body)
|
page.body_html = strings.clone(body)
|
||||||
@@ -221,7 +224,8 @@ copy_static_assets :: proc(content_path: string, output_dir: string) {
|
|||||||
|
|
||||||
dest := fmt.tprintf("%s/%s", output_dir, entry.name)
|
dest := fmt.tprintf("%s/%s", output_dir, entry.name)
|
||||||
if err := os.copy_file(dest, entry.fullpath); err != nil {
|
if err := os.copy_file(dest, entry.fullpath); err != nil {
|
||||||
fmt.eprintfln("thor: cannot copy %s: %v", entry.name, err)
|
log.warnf("thor: cannot copy %s: %v", entry.name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import "core:strings"
|
|||||||
|
|
||||||
WEEKDAYS: [7]string = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
|
WEEKDAYS: [7]string = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
|
||||||
|
|
||||||
generate_rss :: proc(pages: []Page, config: Site_Config) -> string {
|
generate_rss :: proc(pages: []Page, config: Site) -> string {
|
||||||
parts: [dynamic]string
|
parts: [dynamic]string
|
||||||
defer delete(parts)
|
defer delete(parts)
|
||||||
|
|
||||||
append(&parts, fmt.aprintf(
|
append(
|
||||||
`<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
&parts,
|
||||||
|
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>
|
||||||
@@ -18,11 +20,12 @@ generate_rss :: proc(pages: []Page, config: Site_Config) -> 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 {
|
||||||
@@ -34,8 +37,10 @@ generate_rss :: proc(pages: []Page, config: Site_Config) -> string {
|
|||||||
pub_date = format_rfc822(page.date)
|
pub_date = format_rfc822(page.date)
|
||||||
}
|
}
|
||||||
|
|
||||||
append(&parts, fmt.aprintf(
|
append(
|
||||||
`<item>
|
&parts,
|
||||||
|
fmt.aprintf(
|
||||||
|
`<item>
|
||||||
<title>%s</title>
|
<title>%s</title>
|
||||||
<link>%s%s</link>
|
<link>%s%s</link>
|
||||||
<pubDate>%s</pubDate>
|
<pubDate>%s</pubDate>
|
||||||
@@ -43,14 +48,15 @@ generate_rss :: proc(pages: []Page, config: Site_Config) -> 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>")
|
append(&parts, "</channel>\n</rss>")
|
||||||
@@ -74,12 +80,10 @@ generate_sitemap :: proc(pages: []Page, base_url: string) -> string {
|
|||||||
if page.date != "" {
|
if page.date != "" {
|
||||||
lastmod = fmt.aprintf("<lastmod>%s</lastmod>", page.date)
|
lastmod = fmt.aprintf("<lastmod>%s</lastmod>", page.date)
|
||||||
}
|
}
|
||||||
append(&parts, fmt.aprintf(
|
append(
|
||||||
"<url><loc>%s%s</loc>%s</url>\n",
|
&parts,
|
||||||
base_url,
|
fmt.aprintf("<url><loc>%s%s</loc>%s</url>\n", base_url, page.permalink, lastmod),
|
||||||
page.permalink,
|
)
|
||||||
lastmod,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Posts list page
|
// Posts list page
|
||||||
@@ -105,8 +109,11 @@ format_rfc822 :: proc(iso: string) -> string {
|
|||||||
return iso
|
return iso
|
||||||
}
|
}
|
||||||
|
|
||||||
year := (int(iso[0]) - 0x30) * 1000 + (int(iso[1]) - 0x30) * 100 +
|
year :=
|
||||||
(int(iso[2]) - 0x30) * 10 + (int(iso[3]) - 0x30)
|
(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)
|
month := (int(iso[5]) - 0x30) * 10 + (int(iso[6]) - 0x30)
|
||||||
day := (int(iso[8]) - 0x30) * 10 + (int(iso[9]) - 0x30)
|
day := (int(iso[8]) - 0x30) * 10 + (int(iso[9]) - 0x30)
|
||||||
time := iso[11:19]
|
time := iso[11:19]
|
||||||
@@ -125,15 +132,7 @@ format_rfc822 :: proc(iso: string) -> string {
|
|||||||
}
|
}
|
||||||
dow := (y + y / 4 - y / 100 + y / 400 + t[month - 1] + day) % 7
|
dow := (y + y / 4 - y / 100 + y / 400 + t[month - 1] + day) % 7
|
||||||
|
|
||||||
return fmt.aprintf(
|
return fmt.aprintf("%s, %d %s %d %s %s", WEEKDAYS[dow], day, MONTHS[month - 1], year, time, tz)
|
||||||
"%s, %d %s %d %s %s",
|
|
||||||
WEEKDAYS[dow],
|
|
||||||
day,
|
|
||||||
MONTHS[month - 1],
|
|
||||||
year,
|
|
||||||
time,
|
|
||||||
tz,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
xml_escape :: proc(s: string) -> string {
|
xml_escape :: proc(s: string) -> string {
|
||||||
@@ -142,3 +141,4 @@ xml_escape :: proc(s: string) -> string {
|
|||||||
r, _ = strings.replace_all(r, ">", ">")
|
r, _ = strings.replace_all(r, ">", ">")
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-11
@@ -1,11 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
ICON_HOME :: `<svg aria-hidden="true" class="hi-svg-inline h-6 w-6" fill="none" height="1em" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="1em"><path d="m3 9 9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>`
|
|
||||||
|
|
||||||
ICON_GITHUB :: `<svg aria-hidden="true" class="hi-svg-inline h-7 w-7" fill="none" height="1em" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="1em"><path d="M15 22v-4a4.8 4.8.0 00-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35.0-3.5.0.0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35.0 3.5A5.403 5.403.0 004 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></svg>`
|
|
||||||
|
|
||||||
ICON_RSS :: `<svg aria-hidden="true" class="hi-svg-inline h-7 w-7" fill="none" height="1em" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="1em"><path d="M4 11a9 9 0 019 9"/><path d="M4 4a16 16 0 0116 16"/><circle cx="5" cy="19" r="1"/></svg>`
|
|
||||||
|
|
||||||
ICON_CHEVRON_UP :: `<svg aria-hidden="true" class="hi-svg-inline h-10 w-10" fill="none" height="1em" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="1em"><path d="m18 15-6-6-6 6"/></svg>`
|
|
||||||
|
|
||||||
ICON_STAR :: `<svg aria-hidden="true" class="hi-svg-inline h-3.5 w-3.5 text-yellow-500 align-baseline mr-2" fill="currentColor" height="1em" viewBox="0 0 16 16" width="1em"><path d="M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z"/></svg>`
|
|
||||||
@@ -1,46 +1,18 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "core:flags"
|
import "core:log"
|
||||||
import "core:fmt"
|
|
||||||
import "core:os"
|
import "core:os"
|
||||||
import "core:strings"
|
|
||||||
|
|
||||||
main :: proc() {
|
main :: proc() {
|
||||||
// Phase 1: parse flags on empty config to get config_path
|
console_logger := log.create_console_logger()
|
||||||
phase1: Site_Config
|
defer log.destroy_console_logger(console_logger)
|
||||||
flags.parse_or_exit(&phase1, os.args, .Odin)
|
|
||||||
|
|
||||||
path := phase1.config_path
|
site: Site
|
||||||
if path == "" {
|
init_site(&site, os.args)
|
||||||
path = "./thor.json"
|
defer destroy_site(&site)
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: load config file
|
pages := walk_content(site.content_dir, site.drafts)
|
||||||
config, _ := load_config(path)
|
|
||||||
|
|
||||||
// Phase 3: re-parse flags on loaded config (CLI overrides file values)
|
render_site(pages, site)
|
||||||
flags.parse_or_exit(&config, os.args, .Odin)
|
|
||||||
|
|
||||||
// Defaults relative to config file's directory
|
|
||||||
config_dir := "./"
|
|
||||||
if idx := strings.last_index(path, "/"); idx >= 0 {
|
|
||||||
config_dir = path[:idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.content_dir == "" {
|
|
||||||
config.content_dir = fmt.tprintf("%s/content", config_dir)
|
|
||||||
}
|
|
||||||
if config.output_dir == "" {
|
|
||||||
config.output_dir = fmt.tprintf("%s/public", config_dir)
|
|
||||||
}
|
|
||||||
if config.layouts_dir == "" {
|
|
||||||
config.layouts_dir = fmt.tprintf("%s/layouts", config_dir)
|
|
||||||
}
|
|
||||||
if config.base_url == "" {
|
|
||||||
config.base_url = "http://localhost:8080"
|
|
||||||
}
|
|
||||||
|
|
||||||
pages := walk_content(config.content_dir, config.drafts)
|
|
||||||
|
|
||||||
render_site(pages, config)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+79
-40
@@ -964,56 +964,90 @@ token_is_tag :: proc(t: Token) -> bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// When a .Partial token is encountered, we need to inject the contents
|
template_render_partial :: proc(
|
||||||
// of the partial into the current list of tokens.
|
|
||||||
template_insert_partial :: proc(
|
|
||||||
tmpl: ^Template,
|
tmpl: ^Template,
|
||||||
token: Token,
|
token: Token,
|
||||||
offset: int,
|
offset: int,
|
||||||
|
sb: ^strings.Builder,
|
||||||
allocator := context.allocator,
|
allocator := context.allocator,
|
||||||
) -> (err: Lexer_Error) {
|
) {
|
||||||
partial_name := token.value
|
partial_name := strings.trim_space(token.value)
|
||||||
|
|
||||||
|
// Dynamic Names: {{>*key}} — resolve key from context to get partial name.
|
||||||
|
if len(partial_name) > 0 && partial_name[0] == '*' {
|
||||||
|
dynamic_key := strings.trim_space(partial_name[1:])
|
||||||
|
resolved := template_get_data_for_stack(tmpl, dynamic_key, allocator)
|
||||||
|
if resolved == nil || reflect.is_nil(resolved) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolved_str, _ := any_to_string(resolved)
|
||||||
|
if resolved_str == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
partial_name = strings.trim_space(resolved_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up partial content.
|
||||||
partial_content := dig(tmpl.partials, []string{partial_name})
|
partial_content := dig(tmpl.partials, []string{partial_name})
|
||||||
partial_str, _ := any_to_string(partial_content)
|
partial_str, _ := any_to_string(partial_content)
|
||||||
|
if partial_str == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
lexer := lexer_make(allocator)
|
// Handle standalone indentation — indent each line of the partial source
|
||||||
lexer.src = partial_str
|
// (except the first line, which follows the preceding whitespace, and
|
||||||
lexer.line = token.pos.line
|
// trailing empty lines).
|
||||||
lexer.delim = CORE_DEF
|
|
||||||
lexer_parse(lexer, allocator = allocator) or_return
|
|
||||||
|
|
||||||
// Performs any indentation on the .Partial that we are inserting.
|
|
||||||
//
|
|
||||||
// Example: use the first Token as the indentation for the .Partial Token.
|
|
||||||
// [Token{type=.Text, value=" "}, Token{type=.Partial, value="to_add"}]
|
|
||||||
//
|
|
||||||
standalone := lexer_token_is_standalone_partial(tmpl.lexer, token)
|
standalone := lexer_token_is_standalone_partial(tmpl.lexer, token)
|
||||||
if offset > 0 && standalone {
|
indent := ""
|
||||||
prev_token := tmpl.lexer.tokens[offset-1]
|
if standalone && offset > 0 {
|
||||||
|
prev_token := tmpl.lexer.tokens[offset - 1]
|
||||||
if prev_token.type == .Text && is_text_blank(prev_token.value) {
|
if prev_token.type == .Text && is_text_blank(prev_token.value) {
|
||||||
cur_line := lexer.tokens[len(lexer.tokens)-1].pos.line
|
indent = prev_token.value
|
||||||
#reverse for t, i in lexer.tokens {
|
|
||||||
// Do not indent the top line.
|
|
||||||
if cur_line == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// When moving back up a line, insert the indentation.
|
|
||||||
if cur_line != t.pos.line {
|
|
||||||
inject_at(&lexer.tokens, i+1, prev_token)
|
|
||||||
}
|
|
||||||
|
|
||||||
cur_line = t.pos.line
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject tokens from the partial into the primary template.
|
if indent != "" {
|
||||||
#reverse for t in lexer.tokens {
|
lines := strings.split(partial_str, "\n", allocator)
|
||||||
inject_at(&tmpl.lexer.tokens, offset+1, t)
|
defer delete(lines)
|
||||||
|
|
||||||
|
indented := strings.builder_make(allocator)
|
||||||
|
for line, i in lines {
|
||||||
|
if i > 0 {
|
||||||
|
strings.write_string(&indented, "\n")
|
||||||
|
if i < len(lines) - 1 || line != "" {
|
||||||
|
strings.write_string(&indented, indent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
strings.write_string(&indented, line)
|
||||||
|
}
|
||||||
|
partial_str = strings.to_string(indented)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
// Lex the partial (after applying indentation).
|
||||||
|
partial_lexer := lexer_make(allocator)
|
||||||
|
partial_lexer.src = partial_str
|
||||||
|
partial_lexer.line = token.pos.line
|
||||||
|
partial_lexer.delim = CORE_DEF
|
||||||
|
err := lexer_parse(partial_lexer, allocator = allocator)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply skip rules to partial tokens.
|
||||||
|
for &pt in partial_lexer.tokens {
|
||||||
|
if lexer_token_should_skip(partial_lexer, pt) {
|
||||||
|
pt.type = .Skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap lexer to process partial tokens with shared context stack.
|
||||||
|
saved_lexer := tmpl.lexer
|
||||||
|
tmpl.lexer = partial_lexer
|
||||||
|
|
||||||
|
template_process_tokens(tmpl, sb, allocator)
|
||||||
|
|
||||||
|
// Restore original lexer.
|
||||||
|
tmpl.lexer = saved_lexer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject a chunk of text into the token list of the larger layout template.
|
// Inject a chunk of text into the token list of the larger layout template.
|
||||||
@@ -1070,15 +1104,20 @@ template_eat_tokens :: proc(
|
|||||||
inject_at(&tmpl.context_stack, 0, root)
|
inject_at(&tmpl.context_stack, 0, root)
|
||||||
|
|
||||||
// First pass to find all the whitespace/newline elements that should be skipped.
|
// First pass to find all the whitespace/newline elements that should be skipped.
|
||||||
// This is performed up-front due to partial templates -- we cannot check for the
|
|
||||||
// whitespace logic *after* the partials have been injected into the template.
|
|
||||||
for &t in tmpl.lexer.tokens {
|
for &t in tmpl.lexer.tokens {
|
||||||
if lexer_token_should_skip(tmpl.lexer, t) {
|
if lexer_token_should_skip(tmpl.lexer, t) {
|
||||||
t.type = .Skip
|
t.type = .Skip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second pass to render the template.
|
template_process_tokens(tmpl, sb, allocator)
|
||||||
|
}
|
||||||
|
|
||||||
|
template_process_tokens :: proc(
|
||||||
|
tmpl: ^Template,
|
||||||
|
sb: ^strings.Builder,
|
||||||
|
allocator := context.allocator,
|
||||||
|
) {
|
||||||
i := 0
|
i := 0
|
||||||
for i < len(tmpl.lexer.tokens) {
|
for i < len(tmpl.lexer.tokens) {
|
||||||
defer { i += 1 }
|
defer { i += 1 }
|
||||||
@@ -1101,7 +1140,7 @@ template_eat_tokens :: proc(
|
|||||||
i = t.start_i
|
i = t.start_i
|
||||||
}
|
}
|
||||||
case .Partial:
|
case .Partial:
|
||||||
template_insert_partial(tmpl, t, i, allocator)
|
template_render_partial(tmpl, t, i, sb, allocator)
|
||||||
// Do nothing for these tags.
|
// Do nothing for these tags.
|
||||||
case .Comment, .Skip, .EOF:
|
case .Comment, .Skip, .EOF:
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import "core:testing"
|
|||||||
COMMENTS_SPEC :: "mustache/spec/specs/comments.json"
|
COMMENTS_SPEC :: "mustache/spec/specs/comments.json"
|
||||||
DELIMITERS_SPEC :: "mustache/spec/specs/delimiters.json"
|
DELIMITERS_SPEC :: "mustache/spec/specs/delimiters.json"
|
||||||
DYNAMIC_NAMES_SPEC :: "mustache/spec/specs/dynamic-names.json"
|
DYNAMIC_NAMES_SPEC :: "mustache/spec/specs/dynamic-names.json"
|
||||||
|
INTERPOLATION_SPEC :: "mustache/spec/specs/interpolation.json"
|
||||||
INVERTED_SPEC :: "mustache/spec/specs/inverted.json"
|
INVERTED_SPEC :: "mustache/spec/specs/inverted.json"
|
||||||
PARTIALS_SPEC :: "mustache/spec/specs/partials.json"
|
PARTIALS_SPEC :: "mustache/spec/specs/partials.json"
|
||||||
SECTIONS_SPEC :: "mustache/spec/specs/sections.json"
|
SECTIONS_SPEC :: "mustache/spec/specs/sections.json"
|
||||||
@@ -498,6 +499,29 @@ test_partials_spec :: proc(t: ^testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
|
test_dynamic_names_spec :: proc(t: ^testing.T) {
|
||||||
|
context.allocator = context.temp_allocator
|
||||||
|
|
||||||
|
spec := load_spec(DYNAMIC_NAMES_SPEC)
|
||||||
|
defer json.destroy_value(spec)
|
||||||
|
|
||||||
|
root := spec.(json.Object)
|
||||||
|
tests := root["tests"].(json.Array)
|
||||||
|
|
||||||
|
for test in tests {
|
||||||
|
test_obj := test.(json.Object)
|
||||||
|
template := test_obj["template"].(string)
|
||||||
|
exp_output := test_obj["expected"].(string)
|
||||||
|
data := test_obj["data"]
|
||||||
|
input := load_json(data)
|
||||||
|
partials := test_obj["partials"]
|
||||||
|
partials_input := load_json(partials).(JSON_Map)
|
||||||
|
|
||||||
|
assert_mustache(t, template, input, exp_output, partials_input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Someday.
|
||||||
// @(test)
|
// @(test)
|
||||||
// test_delimiters_spec :: proc(t: ^testing.T) {
|
// test_delimiters_spec :: proc(t: ^testing.T) {
|
||||||
// spec := load_spec(DELIMITERS_SPEC)
|
// spec := load_spec(DELIMITERS_SPEC)
|
||||||
@@ -958,3 +982,64 @@ test_dig :: proc(t: ^testing.T) {
|
|||||||
delete(keys)
|
delete(keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
test_partial_in_section :: proc(t: ^testing.T) {
|
||||||
|
context.allocator = context.temp_allocator
|
||||||
|
|
||||||
|
template := "{{#items}}{{> item }}{{/items}}"
|
||||||
|
|
||||||
|
data := make(map[string][dynamic]string, 1, context.temp_allocator)
|
||||||
|
items := make([dynamic]string, 0, context.temp_allocator)
|
||||||
|
append(&items, "A", "B", "C")
|
||||||
|
data["items"] = items
|
||||||
|
|
||||||
|
partials := map[string]string{
|
||||||
|
"item" = "[{{.}}]",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_mustache(t, template, data, "[A][B][C]", partials)
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
test_dynamic_partial_in_section :: proc(t: ^testing.T) {
|
||||||
|
context.allocator = context.temp_allocator
|
||||||
|
|
||||||
|
template := "{{#items}}{{>*tpl}}{{/items}}"
|
||||||
|
|
||||||
|
items := make([dynamic]Test_Map, 0, context.temp_allocator)
|
||||||
|
item1 := make(Test_Map)
|
||||||
|
item1["tpl"] = "a"
|
||||||
|
item1["v"] = "1"
|
||||||
|
append(&items, item1)
|
||||||
|
item2 := make(Test_Map)
|
||||||
|
item2["tpl"] = "b"
|
||||||
|
item2["v"] = "2"
|
||||||
|
append(&items, item2)
|
||||||
|
|
||||||
|
data := make(map[string][dynamic]Test_Map, 1, context.temp_allocator)
|
||||||
|
data["items"] = items
|
||||||
|
|
||||||
|
partials := map[string]string{
|
||||||
|
"a" = "A:{{v}}",
|
||||||
|
"b" = "B:{{v}}",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_mustache(t, template, data, "A:1B:2", partials)
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
test_nested_partials :: proc(t: ^testing.T) {
|
||||||
|
context.allocator = context.temp_allocator
|
||||||
|
|
||||||
|
template := "{{> outer }}"
|
||||||
|
data := make(Test_Map)
|
||||||
|
data["x"] = "hello"
|
||||||
|
|
||||||
|
partials := map[string]string{
|
||||||
|
"outer" = "[{{> inner }}]",
|
||||||
|
"inner" = "{{x}}",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_mustache(t, template, data, "[hello]", partials)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+28
-20
@@ -2,13 +2,14 @@
|
|||||||
#+test
|
#+test
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "core:fmt"
|
|
||||||
import "core:testing"
|
import "core:testing"
|
||||||
import mustache "mustache"
|
import "mustache"
|
||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
test_simple_substitution :: proc(t: ^testing.T) {
|
test_simple_substitution :: proc(t: ^testing.T) {
|
||||||
data := map[string]string{"name" = "World"}
|
data := map[string]string {
|
||||||
|
"name" = "World",
|
||||||
|
}
|
||||||
result, err := mustache.render("Hello, {{name}}!", data)
|
result, err := mustache.render("Hello, {{name}}!", data)
|
||||||
testing.expect(t, err == nil)
|
testing.expect(t, err == nil)
|
||||||
testing.expect(t, result == "Hello, World!")
|
testing.expect(t, result == "Hello, World!")
|
||||||
@@ -16,7 +17,9 @@ test_simple_substitution :: proc(t: ^testing.T) {
|
|||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
test_bool_section :: proc(t: ^testing.T) {
|
test_bool_section :: proc(t: ^testing.T) {
|
||||||
data := map[string]bool{"show" = true}
|
data := map[string]bool {
|
||||||
|
"show" = true,
|
||||||
|
}
|
||||||
result, err := mustache.render("{{#show}}visible{{/show}}", data)
|
result, err := mustache.render("{{#show}}visible{{/show}}", data)
|
||||||
testing.expect(t, err == nil)
|
testing.expect(t, err == nil)
|
||||||
testing.expect(t, result == "visible")
|
testing.expect(t, result == "visible")
|
||||||
@@ -24,7 +27,9 @@ test_bool_section :: proc(t: ^testing.T) {
|
|||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
test_inverted_section :: proc(t: ^testing.T) {
|
test_inverted_section :: proc(t: ^testing.T) {
|
||||||
data := map[string]bool{"show" = false}
|
data := map[string]bool {
|
||||||
|
"show" = false,
|
||||||
|
}
|
||||||
result, err := mustache.render("{{^show}}hidden{{/show}}", data)
|
result, err := mustache.render("{{^show}}hidden{{/show}}", data)
|
||||||
testing.expect(t, err == nil)
|
testing.expect(t, err == nil)
|
||||||
testing.expect(t, result == "hidden")
|
testing.expect(t, result == "hidden")
|
||||||
@@ -32,7 +37,9 @@ test_inverted_section :: proc(t: ^testing.T) {
|
|||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
test_unescaped :: proc(t: ^testing.T) {
|
test_unescaped :: proc(t: ^testing.T) {
|
||||||
data := map[string]string{"html" = "<b>bold</b>"}
|
data := map[string]string {
|
||||||
|
"html" = "<b>bold</b>",
|
||||||
|
}
|
||||||
result, err := mustache.render("{{{html}}}", data)
|
result, err := mustache.render("{{{html}}}", 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>")
|
||||||
@@ -45,7 +52,9 @@ test_array_iteration :: proc(t: ^testing.T) {
|
|||||||
append(&items, map[string]string{"name" = "Alice"})
|
append(&items, map[string]string{"name" = "Alice"})
|
||||||
append(&items, map[string]string{"name" = "Bob"})
|
append(&items, map[string]string{"name" = "Bob"})
|
||||||
|
|
||||||
data := map[string][dynamic]map[string]string{"items" = items}
|
data := map[string][dynamic]map[string]string {
|
||||||
|
"items" = items,
|
||||||
|
}
|
||||||
result, err := mustache.render("{{#items}}{{name}} {{/items}}", data)
|
result, err := mustache.render("{{#items}}{{name}} {{/items}}", data)
|
||||||
testing.expect(t, err == nil)
|
testing.expect(t, err == nil)
|
||||||
testing.expect(t, result == "Alice Bob ")
|
testing.expect(t, result == "Alice Bob ")
|
||||||
@@ -53,8 +62,12 @@ test_array_iteration :: proc(t: ^testing.T) {
|
|||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
test_partial :: proc(t: ^testing.T) {
|
test_partial :: proc(t: ^testing.T) {
|
||||||
data := map[string]string{"name" = "Test"}
|
data := map[string]string {
|
||||||
partials := map[string]string{"greeting" = "Hello, {{name}}!"}
|
"name" = "Test",
|
||||||
|
}
|
||||||
|
partials := map[string]string {
|
||||||
|
"greeting" = "Hello, {{name}}!",
|
||||||
|
}
|
||||||
result, err := mustache.render("{{>greeting}}", data, partials)
|
result, err := mustache.render("{{>greeting}}", data, partials)
|
||||||
testing.expect(t, err == nil)
|
testing.expect(t, err == nil)
|
||||||
testing.expect(t, result == "Hello, Test!")
|
testing.expect(t, result == "Hello, Test!")
|
||||||
@@ -62,34 +75,28 @@ test_partial :: proc(t: ^testing.T) {
|
|||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
test_mixed_types :: proc(t: ^testing.T) {
|
test_mixed_types :: proc(t: ^testing.T) {
|
||||||
data := map[string]any{
|
data := map[string]any {
|
||||||
"site_title" = "One Idiot Developer",
|
"site_title" = "One Idiot Developer",
|
||||||
"has_date" = true,
|
"has_date" = true,
|
||||||
"date" = "17 Jul 2025",
|
"date" = "17 Jul 2025",
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := mustache.render(
|
result, err := mustache.render("{{site_title}}: {{#has_date}}{{date}}{{/has_date}}", data)
|
||||||
"{{site_title}}: {{#has_date}}{{date}}{{/has_date}}",
|
|
||||||
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
@(test)
|
@(test)
|
||||||
test_nested_context :: proc(t: ^testing.T) {
|
test_nested_context :: proc(t: ^testing.T) {
|
||||||
page := map[string]string{
|
page := map[string]string {
|
||||||
"title" = "My Post",
|
"title" = "My Post",
|
||||||
}
|
}
|
||||||
data := map[string]any{
|
data := map[string]any {
|
||||||
"site_title" = "One Idiot Developer",
|
"site_title" = "One Idiot Developer",
|
||||||
"page" = page,
|
"page" = page,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := mustache.render(
|
result, err := mustache.render("{{site_title}}: {{#page}}{{title}}{{/page}}", data)
|
||||||
"{{site_title}}: {{#page}}{{title}}{{/page}}",
|
|
||||||
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")
|
||||||
}
|
}
|
||||||
@@ -104,3 +111,4 @@ test_layout :: proc(t: ^testing.T) {
|
|||||||
testing.expect(t, err == nil)
|
testing.expect(t, err == nil)
|
||||||
testing.expect(t, result == "<html><body><p>Hello!</p></body></html>")
|
testing.expect(t, result == "<html><body><p>Hello!</p></body></html>")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+107
-112
@@ -8,14 +8,24 @@ import "core:os"
|
|||||||
import "core:strings"
|
import "core:strings"
|
||||||
|
|
||||||
MONTHS: [12]string = {
|
MONTHS: [12]string = {
|
||||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
"Jan",
|
||||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
"Feb",
|
||||||
|
"Mar",
|
||||||
|
"Apr",
|
||||||
|
"May",
|
||||||
|
"Jun",
|
||||||
|
"Jul",
|
||||||
|
"Aug",
|
||||||
|
"Sep",
|
||||||
|
"Oct",
|
||||||
|
"Nov",
|
||||||
|
"Dec",
|
||||||
}
|
}
|
||||||
|
|
||||||
Page_Context :: struct {
|
Page_Context :: struct {
|
||||||
permalink: string,
|
permalink: string,
|
||||||
title: string,
|
title: string,
|
||||||
star: string,
|
starred: bool,
|
||||||
has_date: bool,
|
has_date: bool,
|
||||||
date_iso: string,
|
date_iso: string,
|
||||||
date_display: string,
|
date_display: string,
|
||||||
@@ -32,41 +42,22 @@ Year_Slice :: struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
build_page_context :: proc(page: Page) -> Page_Context {
|
build_page_context :: proc(page: Page) -> Page_Context {
|
||||||
star := ""
|
return Page_Context {
|
||||||
if page.is_starred {
|
|
||||||
star = ICON_STAR
|
|
||||||
}
|
|
||||||
return Page_Context{
|
|
||||||
permalink = page.permalink,
|
permalink = page.permalink,
|
||||||
title = page.title,
|
title = page.title,
|
||||||
star = star,
|
starred = page.is_starred,
|
||||||
has_date = page.date != "",
|
has_date = page.date != "",
|
||||||
date_iso = page.date,
|
date_iso = page.date,
|
||||||
date_display = format_date(page.date),
|
date_display = format_date(page.date),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
build_social_context :: proc(config: Site_Config) -> [dynamic]map[string]string {
|
|
||||||
social_ctx := make([dynamic]map[string]string)
|
|
||||||
for link in config.social {
|
|
||||||
append(
|
|
||||||
&social_ctx,
|
|
||||||
map[string]string{
|
|
||||||
"name" = link.name,
|
|
||||||
"url" = link.url,
|
|
||||||
"icon" = social_icon(link.name),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return social_ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
strip_html_tags :: proc(s: string) -> string {
|
strip_html_tags :: proc(s: string) -> string {
|
||||||
parts: [dynamic]string
|
parts: [dynamic]string
|
||||||
defer delete(parts)
|
defer delete(parts)
|
||||||
in_tag := false
|
in_tag := false
|
||||||
start := 0
|
start := 0
|
||||||
for i in 0..<len(s) {
|
for i in 0 ..< len(s) {
|
||||||
if s[i] == '<' && !in_tag {
|
if s[i] == '<' && !in_tag {
|
||||||
if i > start {
|
if i > start {
|
||||||
append(&parts, s[start:i])
|
append(&parts, s[start:i])
|
||||||
@@ -93,7 +84,7 @@ og_type :: proc(is_article: bool) -> string {
|
|||||||
return "website"
|
return "website"
|
||||||
}
|
}
|
||||||
|
|
||||||
render_site :: proc(pages: []Page, config: Site_Config) {
|
render_site :: proc(pages: []Page, config: Site) {
|
||||||
sort_pages_by_date(pages)
|
sort_pages_by_date(pages)
|
||||||
|
|
||||||
// Find home page
|
// Find home page
|
||||||
@@ -148,47 +139,43 @@ render_site :: proc(pages: []Page, config: Site_Config) {
|
|||||||
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_Config) -> string {
|
render_page_html :: proc(page: Page, config: Site) -> string {
|
||||||
social_ctx := build_social_context(config)
|
|
||||||
defer delete(social_ctx)
|
|
||||||
|
|
||||||
is_article := page.type == .Post
|
is_article := page.type == .Post
|
||||||
|
|
||||||
data := map[string]any{
|
data := map[string]any {
|
||||||
"title" = fmt.tprintf("%s | %s", page.title, config.title),
|
"title" = fmt.tprintf("%s | %s", page.title, config.title),
|
||||||
"page_title" = page.title,
|
"page_title" = page.title,
|
||||||
"body_html" = page.body_html,
|
"body_html" = page.body_html,
|
||||||
"has_date" = page.date != "",
|
"has_date" = page.date != "",
|
||||||
"date_iso" = page.date,
|
"date_iso" = page.date,
|
||||||
"date_display" = format_date(page.date),
|
"date_display" = format_date(page.date),
|
||||||
"is_post" = is_article,
|
"is_post" = is_article,
|
||||||
"home_icon" = ICON_HOME,
|
"year" = "2026",
|
||||||
"chevron_up" = ICON_CHEVRON_UP,
|
"author" = config.author,
|
||||||
"year" = "2026",
|
"params" = config.params,
|
||||||
"author" = config.author,
|
"og_url" = fmt.tprintf("%s%s", config.base_url, page.permalink),
|
||||||
"social" = social_ctx[:],
|
"og_site_name" = config.title,
|
||||||
"og_url" = fmt.tprintf("%s%s", config.base_url, page.permalink),
|
"og_title" = strip_html_tags(page.title),
|
||||||
"og_site_name" = config.title,
|
|
||||||
"og_title" = strip_html_tags(page.title),
|
|
||||||
"og_description" = config.description,
|
"og_description" = config.description,
|
||||||
"og_type" = og_type(is_article),
|
"og_type" = og_type(is_article),
|
||||||
"is_article" = is_article,
|
"is_article" = is_article,
|
||||||
"og_section" = "posts",
|
"og_section" = "posts",
|
||||||
"og_published" = page.date,
|
"og_published" = page.date,
|
||||||
"og_image" = fmt.tprintf("%s/avatar.jpg", config.base_url),
|
"og_image" = fmt.tprintf("%s/avatar.jpg", config.base_url),
|
||||||
}
|
}
|
||||||
|
|
||||||
partials := load_partials(config.layouts_dir)
|
partials := load_partials(config.layouts_dir)
|
||||||
|
|
||||||
post_tpl, _ := os.read_entire_file_from_path( fmt.tprintf("%s/post.html", config.layouts_dir), context.allocator)
|
post_tpl, _ := os.read_entire_file_from_path(
|
||||||
base_tpl, _ := os.read_entire_file_from_path( fmt.tprintf("%s/base.html", config.layouts_dir), context.allocator)
|
fmt.tprintf("%s/post.html", config.layouts_dir),
|
||||||
|
context.allocator,
|
||||||
result, err := mustache.render_in_layout(
|
|
||||||
string(post_tpl),
|
|
||||||
data,
|
|
||||||
string(base_tpl),
|
|
||||||
partials,
|
|
||||||
)
|
)
|
||||||
|
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 {
|
if err != nil {
|
||||||
fmt.eprintfln("thor: mustache error rendering page: %v", err)
|
fmt.eprintfln("thor: mustache error rendering page: %v", err)
|
||||||
return ""
|
return ""
|
||||||
@@ -198,20 +185,45 @@ render_page_html :: proc(page: Page, config: Site_Config) -> string {
|
|||||||
|
|
||||||
load_partials :: proc(layouts_dir: string) -> map[string]string {
|
load_partials :: proc(layouts_dir: string) -> map[string]string {
|
||||||
partials: map[string]string
|
partials: map[string]string
|
||||||
|
partials_dir := fmt.tprintf("%s/partials", layouts_dir)
|
||||||
nav, _ := os.read_entire_file_from_path(fmt.tprintf("%s/partials/nav.html", layouts_dir), context.allocator)
|
load_partials_recursive(&partials, partials_dir, "")
|
||||||
partials["nav"] = string(nav)
|
|
||||||
|
|
||||||
footer, _ := os.read_entire_file_from_path(fmt.tprintf("%s/partials/footer.html", layouts_dir), context.allocator)
|
|
||||||
partials["footer"] = string(footer)
|
|
||||||
|
|
||||||
head, _ := os.read_entire_file_from_path(fmt.tprintf("%s/partials/head.html", layouts_dir), context.allocator)
|
|
||||||
partials["head"] = string(head)
|
|
||||||
|
|
||||||
return partials
|
return partials
|
||||||
}
|
}
|
||||||
|
|
||||||
render_home_html :: proc(home: Page, pages: []Page, config: Site_Config) -> string {
|
load_partials_recursive :: proc(partials: ^map[string]string, base_dir: string, rel_prefix: string) {
|
||||||
|
entries, err := os.read_all_directory_by_path(base_dir, context.allocator)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.file_info_slice_delete(entries, context.allocator)
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
#partial switch entry.type {
|
||||||
|
case .Regular:
|
||||||
|
name := entry.name
|
||||||
|
if !strings.has_suffix(name, ".html") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stripped := name[:len(name) - len(".html")]
|
||||||
|
key := stripped
|
||||||
|
if rel_prefix != "" {
|
||||||
|
key = fmt.tprintf("%s/%s", rel_prefix, stripped)
|
||||||
|
}
|
||||||
|
data, ok := os.read_entire_file_from_path(entry.fullpath, context.allocator)
|
||||||
|
if ok == nil {
|
||||||
|
partials[key] = string(data)
|
||||||
|
}
|
||||||
|
case .Directory:
|
||||||
|
sub_prefix := entry.name
|
||||||
|
if rel_prefix != "" {
|
||||||
|
sub_prefix = fmt.tprintf("%s/%s", rel_prefix, entry.name)
|
||||||
|
}
|
||||||
|
load_partials_recursive(partials, entry.fullpath, sub_prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render_home_html :: proc(home: Page, pages: []Page, config: Site) -> string {
|
||||||
list_pages := make([dynamic]Page_Context)
|
list_pages := make([dynamic]Page_Context)
|
||||||
defer delete(list_pages)
|
defer delete(list_pages)
|
||||||
for page in pages {
|
for page in pages {
|
||||||
@@ -221,18 +233,13 @@ render_home_html :: proc(home: Page, pages: []Page, config: Site_Config) -> stri
|
|||||||
append(&list_pages, build_page_context(page))
|
append(&list_pages, build_page_context(page))
|
||||||
}
|
}
|
||||||
|
|
||||||
social_ctx := build_social_context(config)
|
data := map[string]any {
|
||||||
defer delete(social_ctx)
|
|
||||||
|
|
||||||
data := map[string]any{
|
|
||||||
"title" = config.title,
|
"title" = config.title,
|
||||||
"home_body" = home.body_html,
|
"home_body" = home.body_html,
|
||||||
"list_pages" = list_pages[:],
|
"list_pages" = list_pages[:],
|
||||||
"home_icon" = ICON_HOME,
|
|
||||||
"chevron_up" = ICON_CHEVRON_UP,
|
|
||||||
"year" = "2026",
|
"year" = "2026",
|
||||||
"author" = config.author,
|
"author" = config.author,
|
||||||
"social" = social_ctx[:],
|
"params" = config.params,
|
||||||
"og_url" = fmt.tprintf("%s/", config.base_url),
|
"og_url" = fmt.tprintf("%s/", config.base_url),
|
||||||
"og_site_name" = config.title,
|
"og_site_name" = config.title,
|
||||||
"og_title" = config.title,
|
"og_title" = config.title,
|
||||||
@@ -244,15 +251,16 @@ render_home_html :: proc(home: Page, pages: []Page, config: Site_Config) -> stri
|
|||||||
|
|
||||||
partials := load_partials(config.layouts_dir)
|
partials := load_partials(config.layouts_dir)
|
||||||
|
|
||||||
home_tpl, _ := os.read_entire_file_from_path( fmt.tprintf("%s/home.html", config.layouts_dir), context.allocator)
|
home_tpl, _ := os.read_entire_file_from_path(
|
||||||
base_tpl, _ := os.read_entire_file_from_path( fmt.tprintf("%s/base.html", config.layouts_dir), context.allocator)
|
fmt.tprintf("%s/home.html", config.layouts_dir),
|
||||||
|
context.allocator,
|
||||||
result, err := mustache.render_in_layout(
|
|
||||||
string(home_tpl),
|
|
||||||
data,
|
|
||||||
string(base_tpl),
|
|
||||||
partials,
|
|
||||||
)
|
)
|
||||||
|
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 {
|
if err != nil {
|
||||||
fmt.eprintfln("thor: mustache error: %v", err)
|
fmt.eprintfln("thor: mustache error: %v", err)
|
||||||
return ""
|
return ""
|
||||||
@@ -260,7 +268,7 @@ render_home_html :: proc(home: Page, pages: []Page, config: Site_Config) -> stri
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
render_posts_html :: proc(pages: []Page, config: Site_Config) -> string {
|
render_posts_html :: proc(pages: []Page, config: Site) -> string {
|
||||||
// Group posts by year
|
// Group posts by year
|
||||||
year_sections := make([dynamic]Year_Section)
|
year_sections := make([dynamic]Year_Section)
|
||||||
defer delete(year_sections)
|
defer delete(year_sections)
|
||||||
@@ -283,17 +291,12 @@ render_posts_html :: proc(pages: []Page, config: Site_Config) -> string {
|
|||||||
append(&year_slices, Year_Slice{year = section.year, posts = section.posts[:]})
|
append(&year_slices, Year_Slice{year = section.year, posts = section.posts[:]})
|
||||||
}
|
}
|
||||||
|
|
||||||
social_ctx := build_social_context(config)
|
data := map[string]any {
|
||||||
defer delete(social_ctx)
|
|
||||||
|
|
||||||
data := map[string]any{
|
|
||||||
"title" = fmt.tprintf("Posts | %s", config.title),
|
"title" = fmt.tprintf("Posts | %s", config.title),
|
||||||
"year_sections" = year_slices[:],
|
"year_sections" = year_slices[:],
|
||||||
"home_icon" = ICON_HOME,
|
|
||||||
"chevron_up" = ICON_CHEVRON_UP,
|
|
||||||
"year" = "2026",
|
"year" = "2026",
|
||||||
"author" = config.author,
|
"author" = config.author,
|
||||||
"social" = social_ctx[:],
|
"params" = config.params,
|
||||||
"og_url" = fmt.tprintf("%s/posts/", config.base_url),
|
"og_url" = fmt.tprintf("%s/posts/", config.base_url),
|
||||||
"og_site_name" = config.title,
|
"og_site_name" = config.title,
|
||||||
"og_title" = "Posts",
|
"og_title" = "Posts",
|
||||||
@@ -305,15 +308,16 @@ render_posts_html :: proc(pages: []Page, config: Site_Config) -> string {
|
|||||||
|
|
||||||
partials := load_partials(config.layouts_dir)
|
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)
|
posts_tpl, _ := os.read_entire_file_from_path(
|
||||||
base_tpl, _ := os.read_entire_file_from_path( fmt.tprintf("%s/base.html", config.layouts_dir), context.allocator)
|
fmt.tprintf("%s/posts_list.html", config.layouts_dir),
|
||||||
|
context.allocator,
|
||||||
result, err := mustache.render_in_layout(
|
|
||||||
string(posts_tpl),
|
|
||||||
data,
|
|
||||||
string(base_tpl),
|
|
||||||
partials,
|
|
||||||
)
|
)
|
||||||
|
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 {
|
if err != nil {
|
||||||
fmt.eprintfln("thor: mustache error: %v", err)
|
fmt.eprintfln("thor: mustache error: %v", err)
|
||||||
return ""
|
return ""
|
||||||
@@ -321,16 +325,6 @@ render_posts_html :: proc(pages: []Page, config: Site_Config) -> string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
social_icon :: proc(name: string) -> string {
|
|
||||||
switch strings.to_lower(name) {
|
|
||||||
case "github":
|
|
||||||
return ICON_GITHUB
|
|
||||||
case "rss":
|
|
||||||
return ICON_RSS
|
|
||||||
}
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
format_date :: proc(iso: string) -> string {
|
format_date :: proc(iso: string) -> string {
|
||||||
if len(iso) < 10 {
|
if len(iso) < 10 {
|
||||||
return iso
|
return iso
|
||||||
@@ -354,7 +348,7 @@ get_year :: proc(iso: string) -> string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sort_pages_by_date :: proc(pages: []Page) {
|
sort_pages_by_date :: proc(pages: []Page) {
|
||||||
for i in 1..<len(pages) {
|
for i in 1 ..< len(pages) {
|
||||||
key := pages[i]
|
key := pages[i]
|
||||||
j := i - 1
|
j := i - 1
|
||||||
for j >= 0 && pages[j].date < key.date {
|
for j >= 0 && pages[j].date < key.date {
|
||||||
@@ -386,3 +380,4 @@ write_file :: proc(path: string, html: string) {
|
|||||||
fmt.eprintfln("thor: cannot write %s: %v", path, err)
|
fmt.eprintfln("thor: cannot write %s: %v", path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,110 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
|
import "core:encoding/json"
|
||||||
|
import "core:flags"
|
||||||
|
import "core:fmt"
|
||||||
|
import "core:log"
|
||||||
|
import "core:mem"
|
||||||
|
import "core:os"
|
||||||
|
import "core:strings"
|
||||||
|
|
||||||
Site :: struct {
|
Site :: struct {
|
||||||
base_url: string,
|
arena: mem.Dynamic_Arena,
|
||||||
title: string,
|
config_path: string `args:"name=config"`,
|
||||||
socials: []Social_Icon,
|
title: string,
|
||||||
|
description: string,
|
||||||
|
base_url: string `args:"name=base-url"`,
|
||||||
|
content_dir: string `args:"name=content"`,
|
||||||
|
output_dir: string `args:"name=output"`,
|
||||||
|
layouts_dir: string,
|
||||||
|
author: string,
|
||||||
|
params: json.Value,
|
||||||
|
drafts: bool `args:"name=drafts"`,
|
||||||
}
|
}
|
||||||
|
|
||||||
Social_Icon :: struct {
|
init_site :: proc(site: ^Site, args: []string) {
|
||||||
name: string,
|
_flags: Site
|
||||||
url: string,
|
mem.dynamic_arena_init(&site.arena, alignment = 64) // FIXME: This is a hack
|
||||||
|
alloc := site_allocator(site)
|
||||||
|
flags.parse_or_exit(&_flags, args, .Odin, alloc)
|
||||||
|
|
||||||
|
path := _flags.config_path
|
||||||
|
if path == "" {
|
||||||
|
path = "./thor.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
if load_site_config(site, path, alloc) {
|
||||||
|
site_merge(site, _flags)
|
||||||
|
} else {
|
||||||
|
_flags.arena = site.arena
|
||||||
|
site^ = _flags
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine config file's directory for relative defaults
|
||||||
|
config_dir := "./"
|
||||||
|
if idx := strings.last_index(path, "/"); idx >= 0 {
|
||||||
|
config_dir = path[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hardcoded defaults (lowest precedence)
|
||||||
|
// TODO: Probably shouldn't use temp allocator here?
|
||||||
|
if site.content_dir == "" {
|
||||||
|
site.content_dir = fmt.tprintf("%s/content", config_dir)
|
||||||
|
}
|
||||||
|
if site.output_dir == "" {
|
||||||
|
site.output_dir = fmt.tprintf("%s/public", config_dir)
|
||||||
|
}
|
||||||
|
if site.layouts_dir == "" {
|
||||||
|
site.layouts_dir = fmt.tprintf("%s/layouts", config_dir)
|
||||||
|
}
|
||||||
|
if site.base_url == "" {
|
||||||
|
site.base_url = "http://localhost:8080"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load_site_config :: proc(
|
||||||
|
config: ^Site,
|
||||||
|
path: string,
|
||||||
|
allocator := context.allocator,
|
||||||
|
) -> (
|
||||||
|
ok: bool,
|
||||||
|
) {
|
||||||
|
data, err := os.read_entire_file_from_path(path, allocator)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
unmarshal_err := json.unmarshal_string(string(data), config, allocator = allocator)
|
||||||
|
if unmarshal_err != nil {
|
||||||
|
log.warnf("thor: failed to parse %s: %v", path, unmarshal_err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ok = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
site_merge :: proc(config: ^Site, flags: Site) {
|
||||||
|
if flags.base_url != "" {
|
||||||
|
config.base_url = flags.base_url
|
||||||
|
}
|
||||||
|
if flags.content_dir != "" {
|
||||||
|
config.content_dir = flags.content_dir
|
||||||
|
}
|
||||||
|
if flags.output_dir != "" {
|
||||||
|
config.output_dir = flags.output_dir
|
||||||
|
}
|
||||||
|
if flags.drafts {
|
||||||
|
config.drafts = true
|
||||||
|
}
|
||||||
|
config.config_path = flags.config_path
|
||||||
|
}
|
||||||
|
|
||||||
|
site_allocator :: proc(site: ^Site) -> mem.Allocator {
|
||||||
|
return mem.dynamic_arena_allocator(&site.arena)
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy_site :: proc(site: ^Site) {
|
||||||
|
mem.dynamic_arena_destroy(&site.arena)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+218
@@ -0,0 +1,218 @@
|
|||||||
|
#+feature dynamic-literals
|
||||||
|
#+test
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "core:encoding/json"
|
||||||
|
import "core:fmt"
|
||||||
|
import "core:os"
|
||||||
|
import "core:testing"
|
||||||
|
|
||||||
|
write_temp_config :: proc(name: string, content: string) -> string {
|
||||||
|
path := fmt.tprintf("./test_thor_%s.json", name)
|
||||||
|
write_err := os.write_entire_file_from_string(path, content)
|
||||||
|
if write_err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
test_load_site_config :: proc(t: ^testing.T) {
|
||||||
|
path := write_temp_config(
|
||||||
|
"valid",
|
||||||
|
`{
|
||||||
|
"title":"Test Site",
|
||||||
|
"description":"Test desc",
|
||||||
|
"base_url":"https://example.com",
|
||||||
|
"author":"Tester",
|
||||||
|
"params":{
|
||||||
|
"social":[
|
||||||
|
{"name":"github","url":"https://github.com/test"},
|
||||||
|
{"name":"rss","url":"/index.xml"}]
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
)
|
||||||
|
defer os.remove(path)
|
||||||
|
|
||||||
|
site: Site
|
||||||
|
ok := load_site_config(&site, path, context.temp_allocator)
|
||||||
|
|
||||||
|
testing.expect(t, ok)
|
||||||
|
testing.expect_value(t, site.title, "Test Site")
|
||||||
|
testing.expect_value(t, site.description, "Test desc")
|
||||||
|
testing.expect_value(t, site.base_url, "https://example.com")
|
||||||
|
testing.expect_value(t, site.author, "Tester")
|
||||||
|
|
||||||
|
params, has_params := site.params.(json.Object)
|
||||||
|
testing.expect(t, has_params)
|
||||||
|
|
||||||
|
social_val := params["social"]
|
||||||
|
social, has_social := social_val.(json.Array)
|
||||||
|
testing.expect(t, has_social)
|
||||||
|
testing.expect_value(t, len(social), 2)
|
||||||
|
|
||||||
|
link0, has_link0 := social[0].(json.Object)
|
||||||
|
testing.expect(t, has_link0)
|
||||||
|
testing.expect_value(t, link0["name"].(string), "github")
|
||||||
|
testing.expect_value(t, link0["url"].(string), "https://github.com/test")
|
||||||
|
|
||||||
|
link1, _ := social[1].(json.Object)
|
||||||
|
testing.expect_value(t, link1["name"].(string), "rss")
|
||||||
|
testing.expect_value(t, link1["url"].(string), "/index.xml")
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
test_load_site_config_missing_file :: proc(t: ^testing.T) {
|
||||||
|
site: Site
|
||||||
|
ok := load_site_config(&site, "./nonexistent_thor_test.json", context.temp_allocator)
|
||||||
|
testing.expect(t, !ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
test_load_site_config_invalid_json :: proc(t: ^testing.T) {
|
||||||
|
path := write_temp_config("invalid", `{not valid json}`)
|
||||||
|
defer os.remove(path)
|
||||||
|
|
||||||
|
site: Site
|
||||||
|
ok := load_site_config(&site, path, context.temp_allocator)
|
||||||
|
testing.expect(t, !ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
test_load_site_config_partial :: proc(t: ^testing.T) {
|
||||||
|
path := write_temp_config("partial", `{"title":"Partial"}`)
|
||||||
|
defer os.remove(path)
|
||||||
|
|
||||||
|
site: Site
|
||||||
|
ok := load_site_config(&site, path, context.temp_allocator)
|
||||||
|
|
||||||
|
testing.expect(t, ok)
|
||||||
|
testing.expect_value(t, site.title, "Partial")
|
||||||
|
testing.expect_value(t, site.description, "")
|
||||||
|
testing.expect_value(t, site.author, "")
|
||||||
|
testing.expect(t, site.params == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
test_site_merge_overrides :: proc(t: ^testing.T) {
|
||||||
|
config := Site {
|
||||||
|
base_url = "https://original.com",
|
||||||
|
content_dir = "./content",
|
||||||
|
}
|
||||||
|
flags := Site {
|
||||||
|
base_url = "https://override.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
site_merge(&config, flags)
|
||||||
|
|
||||||
|
testing.expect_value(t, config.base_url, "https://override.com")
|
||||||
|
testing.expect_value(t, config.content_dir, "./content")
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
test_site_merge_empty_flags_keep_config :: proc(t: ^testing.T) {
|
||||||
|
config := Site {
|
||||||
|
base_url = "https://keep.com",
|
||||||
|
content_dir = "./keep",
|
||||||
|
}
|
||||||
|
flags := Site{}
|
||||||
|
|
||||||
|
site_merge(&config, flags)
|
||||||
|
|
||||||
|
testing.expect_value(t, config.base_url, "https://keep.com")
|
||||||
|
testing.expect_value(t, config.content_dir, "./keep")
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
test_site_merge_drafts_true :: proc(t: ^testing.T) {
|
||||||
|
config := Site {
|
||||||
|
drafts = false,
|
||||||
|
}
|
||||||
|
flags := Site {
|
||||||
|
drafts = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
site_merge(&config, flags)
|
||||||
|
testing.expect(t, config.drafts)
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
test_site_merge_drafts_false_preserves :: proc(t: ^testing.T) {
|
||||||
|
config := Site {
|
||||||
|
drafts = false,
|
||||||
|
}
|
||||||
|
flags := Site {
|
||||||
|
drafts = false,
|
||||||
|
}
|
||||||
|
|
||||||
|
site_merge(&config, flags)
|
||||||
|
testing.expect(t, !config.drafts)
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
test_site_merge_config_path :: proc(t: ^testing.T) {
|
||||||
|
config := Site{}
|
||||||
|
flags := Site {
|
||||||
|
config_path = "./custom/thor.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
site_merge(&config, flags)
|
||||||
|
testing.expect_value(t, config.config_path, "./custom/thor.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
test_init_site_defaults_no_config :: proc(t: ^testing.T) {
|
||||||
|
site: Site
|
||||||
|
args := []string{"thor", "-config:./nonexistent.json"}
|
||||||
|
init_site(&site, args)
|
||||||
|
defer destroy_site(&site)
|
||||||
|
|
||||||
|
testing.expect_value(t, site.content_dir, "./content")
|
||||||
|
testing.expect_value(t, site.output_dir, "./public")
|
||||||
|
testing.expect_value(t, site.layouts_dir, "./layouts")
|
||||||
|
testing.expect_value(t, site.base_url, "http://localhost:8080")
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
test_init_site_config_dir_relative :: proc(t: ^testing.T) {
|
||||||
|
site: Site
|
||||||
|
args := []string{"thor", "-config:./sub/nonexistent.json"}
|
||||||
|
init_site(&site, args)
|
||||||
|
defer destroy_site(&site)
|
||||||
|
|
||||||
|
testing.expect_value(t, site.content_dir, "./sub/content")
|
||||||
|
testing.expect_value(t, site.output_dir, "./sub/public")
|
||||||
|
testing.expect_value(t, site.layouts_dir, "./sub/layouts")
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
test_init_site_flag_overrides_default :: proc(t: ^testing.T) {
|
||||||
|
site: Site
|
||||||
|
args := []string{"thor", "-config:./nonexistent.json", "-drafts", "-base-url:https://flag.com"}
|
||||||
|
init_site(&site, args)
|
||||||
|
defer destroy_site(&site)
|
||||||
|
|
||||||
|
testing.expect(t, site.drafts)
|
||||||
|
testing.expect_value(t, site.base_url, "https://flag.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
test_init_site_full_pipeline :: proc(t: ^testing.T) {
|
||||||
|
path := write_temp_config(
|
||||||
|
"pipeline",
|
||||||
|
`{"title":"Pipeline Test","description":"Full","base_url":"https://config.com","author":"Author"}`,
|
||||||
|
)
|
||||||
|
defer os.remove(path)
|
||||||
|
|
||||||
|
site: Site
|
||||||
|
args := []string{"thor", fmt.tprintf("-config:%s", path), "-drafts"}
|
||||||
|
init_site(&site, args)
|
||||||
|
defer destroy_site(&site)
|
||||||
|
|
||||||
|
testing.expect_value(t, site.title, "Pipeline Test")
|
||||||
|
testing.expect_value(t, site.description, "Full")
|
||||||
|
testing.expect_value(t, site.author, "Author")
|
||||||
|
testing.expect(t, site.drafts)
|
||||||
|
testing.expect_value(t, site.base_url, "https://config.com")
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"params":{"social":[{"name":"github","url":"https://github.com/test"},{"name":"rss","url":"/index.xml"}]}}
|
||||||
Reference in New Issue
Block a user