mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
refactor: Improved the way sites are loaded.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
# 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, social, author)
|
||||
content/ ← markdown and HTML content files
|
||||
layouts/ ← Mustache templates
|
||||
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 |
|
||||
| `feed.odin` | RSS feed + sitemap XML generation |
|
||||
| `icons.odin` | Inline SVG icon constants (home, github, rss, chevron-up, star) |
|
||||
| `mustache/` | Vendored [odin-mustache](https://github.com/benjamindblock/odin-mustache) library |
|
||||
|
||||
### 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/
|
||||
```
|
||||
|
||||
### 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` before any allocation
|
||||
- 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
|
||||
|
||||
### Config precedence
|
||||
|
||||
```
|
||||
CLI flags > thor.json values > hardcoded defaults
|
||||
```
|
||||
|
||||
`init_site` handles this flow:
|
||||
1. Parse flags into a temp `Site` struct
|
||||
2. Load `thor.json` (if exists)
|
||||
3. `site_merge` — CLI overrides config values
|
||||
4. Hardcoded defaults fill remaining gaps (relative to config file's directory)
|
||||
|
||||
## 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 . -all-packages # also runs mustache spec tests
|
||||
```
|
||||
|
||||
## Vendored mustache patches
|
||||
|
||||
Two 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.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Partials inside Mustache sections (`{{#list}}...{{> partial}}...{{/list}}`) produce duplicate items — library token insertion bug. Workaround: inline the markup.
|
||||
- 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.
|
||||
|
||||
## TODO
|
||||
|
||||
See `TODOS.md` for the full list.
|
||||
@@ -16,7 +16,6 @@
|
||||
- [ ] OpenGraph meta tags — verify all fields match production site
|
||||
- [ ] Review every file in thor
|
||||
- [ ] Review alerts.odin
|
||||
- [ ] Review config.odin
|
||||
- [ ] Review content.odin
|
||||
- [ ] Review emoji.odin
|
||||
- [ ] Review feed.odin
|
||||
@@ -27,4 +26,4 @@
|
||||
- [ ] Review main_test.odin
|
||||
- [ ] Review mustache_test.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
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import "core:strings"
|
||||
|
||||
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
|
||||
defer delete(parts)
|
||||
|
||||
append(&parts, fmt.aprintf(
|
||||
append(
|
||||
&parts,
|
||||
fmt.aprintf(
|
||||
`<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
@@ -22,7 +24,8 @@ generate_rss :: proc(pages: []Page, config: Site_Config) -> string {
|
||||
config.base_url,
|
||||
xml_escape(config.description),
|
||||
config.base_url,
|
||||
))
|
||||
),
|
||||
)
|
||||
|
||||
for page in pages {
|
||||
if page.type == .Home {
|
||||
@@ -34,7 +37,9 @@ generate_rss :: proc(pages: []Page, config: Site_Config) -> string {
|
||||
pub_date = format_rfc822(page.date)
|
||||
}
|
||||
|
||||
append(&parts, fmt.aprintf(
|
||||
append(
|
||||
&parts,
|
||||
fmt.aprintf(
|
||||
`<item>
|
||||
<title>%s</title>
|
||||
<link>%s%s</link>
|
||||
@@ -50,7 +55,8 @@ generate_rss :: proc(pages: []Page, config: Site_Config) -> string {
|
||||
config.base_url,
|
||||
page.permalink,
|
||||
xml_escape(page.body_html),
|
||||
))
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
append(&parts, "</channel>\n</rss>")
|
||||
@@ -74,12 +80,10 @@ generate_sitemap :: proc(pages: []Page, base_url: string) -> string {
|
||||
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,
|
||||
))
|
||||
append(
|
||||
&parts,
|
||||
fmt.aprintf("<url><loc>%s%s</loc>%s</url>\n", base_url, page.permalink, lastmod),
|
||||
)
|
||||
}
|
||||
|
||||
// Posts list page
|
||||
@@ -105,8 +109,11 @@ format_rfc822 :: proc(iso: string) -> string {
|
||||
return iso
|
||||
}
|
||||
|
||||
year := (int(iso[0]) - 0x30) * 1000 + (int(iso[1]) - 0x30) * 100 +
|
||||
(int(iso[2]) - 0x30) * 10 + (int(iso[3]) - 0x30)
|
||||
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]
|
||||
@@ -125,15 +132,7 @@ format_rfc822 :: proc(iso: string) -> string {
|
||||
}
|
||||
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, %d %s %d %s %s", WEEKDAYS[dow], day, MONTHS[month - 1], year, time, tz)
|
||||
}
|
||||
|
||||
xml_escape :: proc(s: string) -> string {
|
||||
@@ -142,3 +141,4 @@ xml_escape :: proc(s: string) -> string {
|
||||
r, _ = strings.replace_all(r, ">", ">")
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
@@ -1,46 +1,13 @@
|
||||
package main
|
||||
|
||||
import "core:flags"
|
||||
import "core:fmt"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
|
||||
main :: proc() {
|
||||
// Phase 1: parse flags on empty config to get config_path
|
||||
phase1: Site_Config
|
||||
flags.parse_or_exit(&phase1, os.args, .Odin)
|
||||
site: Site
|
||||
init_site(&site, os.args)
|
||||
|
||||
path := phase1.config_path
|
||||
if path == "" {
|
||||
path = "./thor.json"
|
||||
}
|
||||
pages := walk_content(site.content_dir, site.drafts)
|
||||
|
||||
// Phase 2: load config file
|
||||
config, _ := load_config(path)
|
||||
|
||||
// Phase 3: re-parse flags on loaded config (CLI overrides file values)
|
||||
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)
|
||||
render_site(pages, site)
|
||||
}
|
||||
|
||||
|
||||
+28
-20
@@ -2,13 +2,14 @@
|
||||
#+test
|
||||
package main
|
||||
|
||||
import "core:fmt"
|
||||
import "core:testing"
|
||||
import mustache "mustache"
|
||||
import "mustache"
|
||||
|
||||
@(test)
|
||||
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)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "Hello, World!")
|
||||
@@ -16,7 +17,9 @@ test_simple_substitution :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
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)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "visible")
|
||||
@@ -24,7 +27,9 @@ test_bool_section :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
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)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "hidden")
|
||||
@@ -32,7 +37,9 @@ test_inverted_section :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
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)
|
||||
testing.expect(t, err == nil)
|
||||
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" = "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)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "Alice Bob ")
|
||||
@@ -53,8 +62,12 @@ test_array_iteration :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_partial :: proc(t: ^testing.T) {
|
||||
data := map[string]string{"name" = "Test"}
|
||||
partials := map[string]string{"greeting" = "Hello, {{name}}!"}
|
||||
data := map[string]string {
|
||||
"name" = "Test",
|
||||
}
|
||||
partials := map[string]string {
|
||||
"greeting" = "Hello, {{name}}!",
|
||||
}
|
||||
result, err := mustache.render("{{>greeting}}", data, partials)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "Hello, Test!")
|
||||
@@ -62,34 +75,28 @@ test_partial :: proc(t: ^testing.T) {
|
||||
|
||||
@(test)
|
||||
test_mixed_types :: proc(t: ^testing.T) {
|
||||
data := map[string]any{
|
||||
data := map[string]any {
|
||||
"site_title" = "One Idiot Developer",
|
||||
"has_date" = true,
|
||||
"date" = "17 Jul 2025",
|
||||
}
|
||||
|
||||
result, err := mustache.render(
|
||||
"{{site_title}}: {{#has_date}}{{date}}{{/has_date}}",
|
||||
data,
|
||||
)
|
||||
result, err := mustache.render("{{site_title}}: {{#has_date}}{{date}}{{/has_date}}", data)
|
||||
testing.expect(t, err == nil)
|
||||
testing.expect(t, result == "One Idiot Developer: 17 Jul 2025")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_nested_context :: proc(t: ^testing.T) {
|
||||
page := map[string]string{
|
||||
page := map[string]string {
|
||||
"title" = "My Post",
|
||||
}
|
||||
data := map[string]any{
|
||||
data := map[string]any {
|
||||
"site_title" = "One Idiot Developer",
|
||||
"page" = page,
|
||||
}
|
||||
|
||||
result, err := mustache.render(
|
||||
"{{site_title}}: {{#page}}{{title}}{{/page}}",
|
||||
data,
|
||||
)
|
||||
result, err := mustache.render("{{site_title}}: {{#page}}{{title}}{{/page}}", data)
|
||||
testing.expect(t, err == nil)
|
||||
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, result == "<html><body><p>Hello!</p></body></html>")
|
||||
}
|
||||
|
||||
|
||||
+64
-41
@@ -8,8 +8,18 @@ import "core:os"
|
||||
import "core:strings"
|
||||
|
||||
MONTHS: [12]string = {
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
}
|
||||
|
||||
Page_Context :: struct {
|
||||
@@ -36,7 +46,7 @@ build_page_context :: proc(page: Page) -> Page_Context {
|
||||
if page.is_starred {
|
||||
star = ICON_STAR
|
||||
}
|
||||
return Page_Context{
|
||||
return Page_Context {
|
||||
permalink = page.permalink,
|
||||
title = page.title,
|
||||
star = star,
|
||||
@@ -46,12 +56,12 @@ build_page_context :: proc(page: Page) -> Page_Context {
|
||||
}
|
||||
}
|
||||
|
||||
build_social_context :: proc(config: Site_Config) -> [dynamic]map[string]string {
|
||||
build_social_context :: proc(config: Site) -> [dynamic]map[string]string {
|
||||
social_ctx := make([dynamic]map[string]string)
|
||||
for link in config.social {
|
||||
append(
|
||||
&social_ctx,
|
||||
map[string]string{
|
||||
map[string]string {
|
||||
"name" = link.name,
|
||||
"url" = link.url,
|
||||
"icon" = social_icon(link.name),
|
||||
@@ -66,7 +76,7 @@ strip_html_tags :: proc(s: string) -> string {
|
||||
defer delete(parts)
|
||||
in_tag := false
|
||||
start := 0
|
||||
for i in 0..<len(s) {
|
||||
for i in 0 ..< len(s) {
|
||||
if s[i] == '<' && !in_tag {
|
||||
if i > start {
|
||||
append(&parts, s[start:i])
|
||||
@@ -93,7 +103,7 @@ og_type :: proc(is_article: bool) -> string {
|
||||
return "website"
|
||||
}
|
||||
|
||||
render_site :: proc(pages: []Page, config: Site_Config) {
|
||||
render_site :: proc(pages: []Page, config: Site) {
|
||||
sort_pages_by_date(pages)
|
||||
|
||||
// Find home page
|
||||
@@ -148,13 +158,13 @@ render_site :: proc(pages: []Page, config: Site_Config) {
|
||||
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
|
||||
|
||||
data := map[string]any{
|
||||
data := map[string]any {
|
||||
"title" = fmt.tprintf("%s | %s", page.title, config.title),
|
||||
"page_title" = page.title,
|
||||
"body_html" = page.body_html,
|
||||
@@ -180,15 +190,16 @@ render_page_html :: proc(page: Page, config: Site_Config) -> string {
|
||||
|
||||
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,
|
||||
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 ""
|
||||
@@ -199,19 +210,28 @@ render_page_html :: proc(page: Page, config: Site_Config) -> string {
|
||||
load_partials :: proc(layouts_dir: string) -> map[string]string {
|
||||
partials: map[string]string
|
||||
|
||||
nav, _ := os.read_entire_file_from_path(fmt.tprintf("%s/partials/nav.html", layouts_dir), context.allocator)
|
||||
nav, _ := os.read_entire_file_from_path(
|
||||
fmt.tprintf("%s/partials/nav.html", layouts_dir),
|
||||
context.allocator,
|
||||
)
|
||||
partials["nav"] = string(nav)
|
||||
|
||||
footer, _ := os.read_entire_file_from_path(fmt.tprintf("%s/partials/footer.html", layouts_dir), context.allocator)
|
||||
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)
|
||||
head, _ := os.read_entire_file_from_path(
|
||||
fmt.tprintf("%s/partials/head.html", layouts_dir),
|
||||
context.allocator,
|
||||
)
|
||||
partials["head"] = string(head)
|
||||
|
||||
return partials
|
||||
}
|
||||
|
||||
render_home_html :: proc(home: Page, pages: []Page, config: Site_Config) -> 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 {
|
||||
@@ -224,7 +244,7 @@ render_home_html :: proc(home: Page, pages: []Page, config: Site_Config) -> stri
|
||||
social_ctx := build_social_context(config)
|
||||
defer delete(social_ctx)
|
||||
|
||||
data := map[string]any{
|
||||
data := map[string]any {
|
||||
"title" = config.title,
|
||||
"home_body" = home.body_html,
|
||||
"list_pages" = list_pages[:],
|
||||
@@ -244,15 +264,16 @@ render_home_html :: proc(home: Page, pages: []Page, config: Site_Config) -> stri
|
||||
|
||||
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,
|
||||
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 ""
|
||||
@@ -260,7 +281,7 @@ render_home_html :: proc(home: Page, pages: []Page, config: Site_Config) -> stri
|
||||
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
|
||||
year_sections := make([dynamic]Year_Section)
|
||||
defer delete(year_sections)
|
||||
@@ -286,7 +307,7 @@ render_posts_html :: proc(pages: []Page, config: Site_Config) -> string {
|
||||
social_ctx := build_social_context(config)
|
||||
defer delete(social_ctx)
|
||||
|
||||
data := map[string]any{
|
||||
data := map[string]any {
|
||||
"title" = fmt.tprintf("Posts | %s", config.title),
|
||||
"year_sections" = year_slices[:],
|
||||
"home_icon" = ICON_HOME,
|
||||
@@ -305,15 +326,16 @@ render_posts_html :: proc(pages: []Page, config: Site_Config) -> string {
|
||||
|
||||
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,
|
||||
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 ""
|
||||
@@ -354,7 +376,7 @@ get_year :: proc(iso: string) -> string {
|
||||
}
|
||||
|
||||
sort_pages_by_date :: proc(pages: []Page) {
|
||||
for i in 1..<len(pages) {
|
||||
for i in 1 ..< len(pages) {
|
||||
key := pages[i]
|
||||
j := i - 1
|
||||
for j >= 0 && pages[j].date < key.date {
|
||||
@@ -386,3 +408,4 @@ write_file :: proc(path: string, html: string) {
|
||||
fmt.eprintfln("thor: cannot write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,113 @@
|
||||
package main
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:flags"
|
||||
import "core:fmt"
|
||||
import "core:mem"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
|
||||
Site :: struct {
|
||||
base_url: string,
|
||||
arena: mem.Dynamic_Arena,
|
||||
config_path: string `args:"name=config"`,
|
||||
title: string,
|
||||
socials: []Social_Icon,
|
||||
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"`,
|
||||
}
|
||||
|
||||
Social_Icon :: struct {
|
||||
Social_Link :: struct {
|
||||
name: string,
|
||||
url: string,
|
||||
}
|
||||
|
||||
init_site :: proc(site: ^Site, args: []string) {
|
||||
_flags: Site
|
||||
mem.dynamic_arena_init(&site.arena)
|
||||
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)
|
||||
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 {
|
||||
fmt.eprintfln("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)
|
||||
}
|
||||
|
||||
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
#+feature dynamic-literals
|
||||
#+test
|
||||
package main
|
||||
|
||||
import "core:fmt"
|
||||
import "core:mem"
|
||||
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","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")
|
||||
testing.expect_value(t, len(site.social), 2)
|
||||
testing.expect_value(t, site.social[0].name, "github")
|
||||
testing.expect_value(t, site.social[0].url, "https://github.com/test")
|
||||
testing.expect_value(t, site.social[1].name, "rss")
|
||||
testing.expect_value(t, site.social[1].url, "/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_value(t, len(site.social), 0)
|
||||
}
|
||||
|
||||
@(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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user