Compare commits

...

5 Commits

Author SHA1 Message Date
Spencer Brower 1824bae26c docs: Updated README.md 2026-07-28 13:14:30 -04:00
Spencer Brower 5627215d6f feat: Added site to the template context stack. 2026-07-28 13:07:14 -04:00
Spencer Brower 9d1954d70a feat: page prop now falls through (implicit access). 2026-07-28 12:04:54 -04:00
Spencer Brower fcf349eb51 fix: Removed content from Template_Context.
now accessible only through page.content.
2026-07-28 11:45:55 -04:00
Spencer Brower d51172c836 feat: Added page to template context. 2026-07-28 11:41:21 -04:00
12 changed files with 119 additions and 75 deletions
+3 -3
View File
@@ -220,7 +220,7 @@ Templates use Mustache with template inheritance (`{{<base}}` / `{{$block}}`):
<!-- page.html (content layout) --> <!-- page.html (content layout) -->
{{<base}} {{<base}}
{{$main}} {{$main}}
<main><article><h1>{{page_title}}</h1>{{&content}}</article></main> <main><article><h1>{{page.title}}</h1>{{&content}}</article></main>
{{/main}} {{/main}}
{{/base}} {{/base}}
``` ```
@@ -240,7 +240,7 @@ Base_Data :: struct {
} }
Page_Data :: struct { Page_Data :: struct {
using base: Base_Data, // fields promoted via reflection fallback using base: Base_Data, // fields promoted via reflection fallback
page_title: string, page.title: string,
date: string, // raw ISO 8601; formatted via `| format` in templates date: string, // raw ISO 8601; formatted via `| format` in templates
} }
Home_Data :: struct { Home_Data :: struct {
@@ -249,7 +249,7 @@ Home_Data :: struct {
} }
Section_Data :: struct { Section_Data :: struct {
using base: Base_Data, using base: Base_Data,
page_title: string, page.title: string,
posts: [dynamic]Page_Context, // flat list; year grouping done in template via pipe posts: [dynamic]Page_Context, // flat list; year grouping done in template via pipe
} }
``` ```
+4 -5
View File
@@ -1,8 +1,6 @@
# Thor # Thor
[TOC] Thor is a simple Static Site Generator designed for personal blogs and other small websites.
Thor is a simple Static Sire Generator designed for personal blogs and other small websites.
Its core principals are simplicity and minimal configuration, so you can get started as quickly as possible. Its core principals are simplicity and minimal configuration, so you can get started as quickly as possible.
@@ -16,12 +14,12 @@ It is based on Hugo, and gingerbill's SSG. Templating is done with (extended?) M
- Menus (WIP) - Menus (WIP)
- Extended Markdown ([See below](#extended-markdown)) - Extended Markdown ([See below](#extended-markdown))
- Basic (whitespace) minification. - Basic (whitespace) minification.
- Union File System (Modules)
## What it doesn't do ## What it doesn't do
- Internationalization - Internationalization
- Pagination (Yet) - Pagination (Yet)
- Themes - Themes
- Union File System (Yet)
- Image Manipulation - Image Manipulation
- TailwindCSS integration - TailwindCSS integration
@@ -34,7 +32,8 @@ Then follow [The Guide]()
For a more complete setup, run `thor new site`. For a more complete setup, run `thor new site`.
## Extended Markdown ## Extended Markdown
- Emoji expansion - Emoji expansion
- margin style footnotes - margin style footnotes
- Guthub style alerts - Github style alerts
- [and more] - [and more]
+14 -2
View File
@@ -2,12 +2,18 @@
- Polish existing features before moving on to new ones. - Polish existing features before moving on to new ones.
- [ ] Improve diagnostics - [ ] Improve diagnostics
- [ ] Simplify / unify template context stack. Come up with a name for it. - [x] Simplify / unify template context stack. Come up with a name for it.
- [ ] `render_template` should accept `Template_Context`, not `any` - [x] `render_template` should accept `Template_Context`, not `any`
- [ ] Load grammars dynamically - [ ] Load grammars dynamically
- [ ] consider adding a limit to the context stack in mustache. - [ ] consider adding a limit to the context stack in mustache.
- [ ] better diagnostics for syntax errors in treesitter. - [ ] better diagnostics for syntax errors in treesitter.
- [x] Add heading ids as a default on extension. - [x] Add heading ids as a default on extension.
- [ ] show "stack traces" in template error diagnostics
- [ ] starred must be a param.
- [ ] Add a `#config(MAX_CONTEXT_DEPTH, 16?)` to `mustache`.
- [ ] menu system
- [ ] like Hugo's, but warn(/fail?) if menus are defined in the config *and* pages.
- i.e. force the user to choose one or the other.
## Performance ## Performance
@@ -32,6 +38,11 @@
- [ ] can markdown extensions run in parallel? - [ ] can markdown extensions run in parallel?
- [ ] enforce MAX_SLUG_LENGTH - [ ] enforce MAX_SLUG_LENGTH
## Remove Privileged content
- [ ] `group_by` currently requires a computed `year` field on the page.
- We should replace this with `{{ pages | group_by (date | "2006") }}` or similar
## Memory Management ## Memory Management
@@ -145,6 +156,7 @@ main :: proc () {
- [ ] `new site` set up new project - [ ] `new site` set up new project
- [ ] warn/error when unknown key used in mustache. - [ ] warn/error when unknown key used in mustache.
- [ ] Import/export packages. Hugo, jekyll, WordPress, etc. - [ ] Import/export packages. Hugo, jekyll, WordPress, etc.
- [ ] opt-in "strict_keys" mode. in this mode, key lookups may not view parent objects.
## Notes ## Notes
View File
+13 -2
View File
@@ -7,6 +7,7 @@ import "core:fmt"
import "core:log" import "core:log"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
import "core:time"
// Fields with underscores should never be set by the user. // Fields with underscores should never be set by the user.
Page :: struct { Page :: struct {
@@ -18,12 +19,13 @@ Page :: struct {
title: string, title: string,
description: string, description: string,
date: string, date: string,
year: string,
lastmod: string, lastmod: string,
menu: string, menu: string,
content: string, content: string,
og: Open_Graph, og: Open_Graph,
draft: bool, draft: bool,
is_starred: bool, starred: bool,
_is_index: bool `private`, _is_index: bool `private`,
} }
@@ -231,9 +233,18 @@ load_page :: proc(
page.title = fm.title page.title = fm.title
page.description = fm.description page.description = fm.description
page.date = fm.date page.date = fm.date
if page.date == "" {
info, stat_err := os.stat(file_path, context.allocator)
if stat_err == nil {
page.date, _ = time.time_to_rfc3339(info.modification_time, 0, false, context.allocator)
os.file_info_delete(info, context.allocator)
log.warnf("no date in frontmatter for %s, using file modification time: %s", file_path, page.date)
}
}
page.year = get_year(page.date)
page.lastmod = fm.lastmod page.lastmod = fm.lastmod
page.draft = fm.draft page.draft = fm.draft
page.is_starred = fm.isStarred page.starred = fm.isStarred
page.menu = fm.menu page.menu = fm.menu
page.layout = fm.layout if fm.layout != "" else infer_layout(section, is_index) page.layout = fm.layout if fm.layout != "" else infer_layout(section, is_index)
page.og = fm.og page.og = fm.og
+1 -1
View File
@@ -2,7 +2,7 @@
{{$main}} {{$main}}
<main> <main>
<article> <article>
<h1>{{page_title}}</h1> <h1>{{page.title}}</h1>
{{#date_iso}} <time class="subtitle" datetime="{{date_iso}}">{{date_display}}</time> {{#date_iso}} <time class="subtitle" datetime="{{date_iso}}">{{date_display}}</time>
{{/date_iso}} {{&content}} {{/date_iso}} {{&content}}
</article> </article>
+1 -1
View File
@@ -1,7 +1,7 @@
{{<base}} {{<base}}
{{$main}} {{$main}}
<main> <main>
<h1>{{page_title}}</h1> <h1>{{page.title}}</h1>
{{&content}} {{&content}}
{{#posts | group_by year}} {{#posts | group_by year}}
<section> <section>
+28
View File
@@ -0,0 +1,28 @@
package main
import "core:fmt"
Ctx :: struct {
title: string,
using page: Page,
site: Site,
}
Page :: struct {
title: string,
}
Site :: struct {
title: string,
}
main :: proc() {
site := Ctx {
site = Site{title = "foo"},
page = Page{title = "bar"},
}
fmt.printfln("%+v", site)
fmt.printf("%+v", site)
}
+15
View File
@@ -1,5 +1,6 @@
package mustache package mustache
import "base:runtime"
import "core:fmt" import "core:fmt"
import "core:log" import "core:log"
import "core:reflect" import "core:reflect"
@@ -147,7 +148,21 @@ render :: proc(
ctx := make([dynamic]any, 0, 4, allocator) ctx := make([dynamic]any, 0, 4, allocator)
defer delete(ctx) defer delete(ctx)
// If data is a []any, expand into individual context frames.
// Otherwise, push as a single frame.
elem_info, count, slice_data := list_info(data)
if elem_info != nil {
if _, is_any := elem_info.variant.(runtime.Type_Info_Any); is_any {
for j in 0 ..< count {
append(&ctx, extract_list_element(elem_info, slice_data, j))
}
} else {
append(&ctx, data) append(&ctx, data)
}
} else {
append(&ctx, data)
}
all_nodes := tmpl.nodes[:] all_nodes := tmpl.nodes[:]
err = render_nodes(tmpl, all_nodes, &ctx, partials, &builder) err = render_nodes(tmpl, all_nodes, &ctx, partials, &builder)
+3 -3
View File
@@ -12,7 +12,7 @@ Inner :: struct {
Outer :: struct { Outer :: struct {
title: string, title: string,
page_title: string, page.title: string,
inner: Inner, inner: Inner,
numbers: [3]int, numbers: [3]int,
} }
@@ -150,8 +150,8 @@ test_validate_map_path_silent :: proc(t: ^testing.T) {
@(test) @(test)
test_suggest_correction_exact :: proc(t: ^testing.T) { test_suggest_correction_exact :: proc(t: ^testing.T) {
available := []string{"title", "page_title", "body"} available := []string{"title", "page.title", "body"}
testing.expect_value(t, suggest_correction(available, "page_titel"), "page_title") testing.expect_value(t, suggest_correction(available, "page_titel"), "page.title")
} }
@(test) @(test)
+19 -47
View File
@@ -2,7 +2,6 @@ package main
import "mustache" import "mustache"
import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:log" import "core:log"
import "core:os" import "core:os"
@@ -11,43 +10,20 @@ import "core:time"
import "core:time/datetime" import "core:time/datetime"
Template_Context :: struct { Template_Context :: struct {
params: json.Value,
now: string, now: string,
content: string,
title: string, title: string,
description: string,
og: Open_Graph,
date_format: string, date_format: string,
timezone: ^datetime.TZ_Region, timezone: ^datetime.TZ_Region,
og: Open_Graph,
// Page Data site: Site_Context,
page_title: string, page: Page,
date: string,
// Home data // Home data
pages: [dynamic]Page_Context, pages: [dynamic]Page,
// Section Data // Section Data
// TODO: Remove "posts" from the Odin code // TODO: Remove "posts" from the Odin code
posts: [dynamic]Page_Context, posts: [dynamic]Page,
}
Page_Context :: struct {
permalink: string,
title: string,
starred: bool,
date: string,
year: string,
}
build_page_context :: proc(page: Page) -> Page_Context {
return Page_Context {
permalink = page.permalink,
title = page.title,
starred = page.is_starred,
date = page.date,
year = get_year(page.date),
}
} }
load_template :: proc(vfs: ^VFS, virtual_path: string) -> mustache.Template { load_template :: proc(vfs: ^VFS, virtual_path: string) -> mustache.Template {
@@ -125,10 +101,10 @@ capitalize :: proc(s: string) -> string {
render_template :: proc( render_template :: proc(
content_tpl: mustache.Template, content_tpl: mustache.Template,
data: Template_Context, ctx: Template_Context,
partials: map[string]mustache.Template, partials: map[string]mustache.Template,
) -> string { ) -> string {
result, err := mustache.render(content_tpl, data, partials) result, err := mustache.render(content_tpl, []any{ctx.site, ctx.page, ctx}, partials)
if err != nil { if err != nil {
log.errorf( log.errorf(
"%s", "%s",
@@ -157,9 +133,8 @@ render_site :: proc(site: ^Site) {
assert(ok2) assert(ok2)
ctx := Template_Context { ctx := Template_Context {
site = site.site_context,
now = now, now = now,
params = site.params,
description = site.description,
og = site.og, og = site.og,
date_format = site.date.format, date_format = site.date.format,
timezone = site.tz, timezone = site.tz,
@@ -175,6 +150,7 @@ render_site :: proc(site: ^Site) {
break break
} }
} }
ctx.page = home
// Collect sections // Collect sections
sections := make(map[string]bool) sections := make(map[string]bool)
@@ -268,9 +244,7 @@ render_page_html :: proc(
) -> string { ) -> string {
ctx := ctx ctx := ctx
ctx.title = fmt.tprintf("%s | %s", page.title, site.title) ctx.title = fmt.tprintf("%s | %s", page.title, site.title)
ctx.page_title = page.title ctx.page = page
ctx.content = page.content
ctx.date = page.date
ctx.og = og_for_page(site.og, page) ctx.og = og_for_page(site.og, page)
return render_template(content_tpl, ctx, partials) return render_template(content_tpl, ctx, partials)
} }
@@ -282,18 +256,16 @@ render_home_html :: proc(
partials: map[string]mustache.Template, partials: map[string]mustache.Template,
ctx: Template_Context, ctx: Template_Context,
) -> string { ) -> string {
list_pages := make([dynamic]Page_Context) list_pages := make([dynamic]Page, 0, 8, context.temp_allocator)
defer delete(list_pages)
for page in site.pages { for page in site.pages {
if page._is_index { if page._is_index {
continue continue
} }
append(&list_pages, build_page_context(page)) append(&list_pages, page)
} }
ctx := ctx ctx := ctx
ctx.title = site.title ctx.title = site.title
ctx.content = home.content
ctx.pages = list_pages ctx.pages = list_pages
ctx.og = og_for_page(site.og, home) ctx.og = og_for_page(site.og, home)
@@ -309,24 +281,24 @@ render_section :: proc(
partials: map[string]mustache.Template, partials: map[string]mustache.Template,
ctx: Template_Context, ctx: Template_Context,
) -> string { ) -> string {
posts := make([dynamic]Page_Context) posts := make([dynamic]Page, 0, len(site.pages) / 2, context.temp_allocator)
defer delete(posts)
for page in site.pages { for page in site.pages {
if page.section != section || page._is_index { if page.section != section || page._is_index {
continue continue
} }
append(&posts, build_page_context(page)) append(&posts, page)
} }
ctx := ctx ctx := ctx
if has_index { if has_index {
ctx.content = section_index.content ctx.page = section_index
ctx.page_title = section_index.title
ctx.title = fmt.tprintf("%s | %s", section_index.title, site.title) ctx.title = fmt.tprintf("%s | %s", section_index.title, site.title)
ctx.og = og_for_page(site.og, section_index) ctx.og = og_for_page(site.og, section_index)
} else { } else {
ctx.page_title = capitalize(section) ctx.page = Page {
ctx.title = fmt.tprintf("%s | %s", capitalize(section), site.title) title = capitalize(section),
}
ctx.title = fmt.tprintf("%s | %s", ctx.page.title, site.title)
ctx.og.title = capitalize(section) ctx.og.title = capitalize(section)
ctx.og.description = "" ctx.og.description = ""
ctx.og.url = fmt.tprintf("%s/%s/", site.base_url, section) ctx.og.url = fmt.tprintf("%s/%s/", site.base_url, section)
+13 -6
View File
@@ -13,24 +13,31 @@ import "core:time/timezone"
import md "markdown" import md "markdown"
// Site is the primary workhorse. // Site_Context holds the site date that is accessible in templates.
Site_Context :: struct {
title: string,
description: string,
base_url: string,
params: json.Object,
og: Open_Graph,
}
// Site is the primary workhorse, containing everything needed to build the site,
// including an arena allocator, the pages, and all the various directories and
// enabled features.
Site :: struct { Site :: struct {
using site_context: Site_Context,
arena: mem.Dynamic_Arena, arena: mem.Dynamic_Arena,
pages: #soa[dynamic]Page, pages: #soa[dynamic]Page,
modules: [dynamic]string, modules: [dynamic]string,
vfs: VFS, vfs: VFS,
title: string,
description: string,
base_url: string,
config_path: string, config_path: string,
content_dir: string, content_dir: string,
assets_dir: string, assets_dir: string,
output_dir: string, output_dir: string,
layouts_dir: string, layouts_dir: string,
params: json.Object,
features: bit_set[Feature], features: bit_set[Feature],
markdown_extensions: bit_set[md.Extension], markdown_extensions: bit_set[md.Extension],
og: Open_Graph,
date: Date_Preferences, date: Date_Preferences,
tz: ^datetime.TZ_Region, tz: ^datetime.TZ_Region,
grammars: string, grammars: string,