feat: Added Unified file system / default layout mounts.

This commit is contained in:
Spencer Brower
2026-07-17 13:24:51 -04:00
parent fdd9ef4d46
commit 528bef3941
14 changed files with 251 additions and 113 deletions
+3
View File
@@ -1,10 +1,12 @@
- README.md
- [ ] "Zero is beautiful"
- [ ] Content-hash fingerprinting for CSS and JS cache busting
- [ ] Clean up the default layouts
- [ ] Performance
- [ ] See if we can disable bounds checks in `write_indented` and elsewhere.
- [ ] Instead of loading the site fresh each time in watch mode, create a
`reload_site` proc, that just updates changed resources.
- [ ] Only publish referenced assets.
- [ ] mustache data keys for opengraph, etc.
- [ ] Block attributes on code fences (`{ #ex-1 }`) — hello-world.md
- [ ] include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin
@@ -35,6 +37,7 @@
- [ ] Free cmark HTML output (`body_html`) — cmark allocates via C malloc, not the arena, so it leaks per iteration in watch mode
- [ ] Rename `{{#is_post}}` to `{{#is_article}}` in templates and data model — currently hardcoded to posts section, should use `is_article` (any page with a section) instead.
- [ ] Split `load_page` into frontmatter-parse + body-process phases so draft pages can skip the markdown pipeline entirely
- [ ] Mount content in VFS
- [ ] commands
- [ ] `build` alias of default
- [ ] `init` set up new project
+25 -47
View File
@@ -5,57 +5,35 @@ import "core:log"
import "core:os"
import "core:strings"
// copy_assets_dir recursively copies files from assets_dir to output_dir.
// .css files are minified when .Minify is enabled; all other files are copied verbatim.
// Silently skips if assets_dir doesn't exist.
copy_assets_dir :: proc(assets_dir: string, output_dir: string, features: bit_set[Feature]) {
if !os.exists(assets_dir) {
return
}
copy_assets_recursive(assets_dir, "", output_dir, features)
}
copy_assets_dir :: proc(vfs: ^VFS, output_dir: string, features: bit_set[Feature]) {
for virtual_path, entry in vfs.files {
if !strings.has_prefix(virtual_path, "assets/") {
continue
}
copy_assets_recursive :: proc(
current: string,
rel_prefix: string,
output_dir: string,
features: bit_set[Feature],
) {
entries, err := os.read_all_directory_by_path(current, context.allocator)
if err != nil {
log.warnf("thor: cannot read %s: %v", current, err)
return
}
defer os.file_info_slice_delete(entries, context.allocator)
rel := virtual_path[len("assets/"):]
dest := fmt.tprintf("%s/%s", output_dir, rel)
for entry in entries {
rel := rel_prefix == "" ? entry.name : fmt.tprintf("%s/%s", rel_prefix, entry.name)
switch entry.type {
case .Regular:
dest := fmt.tprintf("%s/%s", output_dir, rel)
if idx := strings.last_index(dest, "/"); idx >= 0 {
if err := os.make_directory_all(dest[:idx]); err != nil && err != .Exist {
log.warnf("thor: cannot create %s: %v", dest[:idx], err)
continue
}
if idx := strings.last_index(dest, "/"); idx >= 0 {
if err := os.make_directory_all(dest[:idx]); err != nil && err != .Exist {
log.warnf("thor: cannot create %s: %v", dest[:idx], err)
continue
}
if .Minify in features && strings.has_suffix(entry.name, ".css") {
data, read_err := os.read_entire_file_from_path(entry.fullpath, context.allocator)
if read_err != nil {
log.warnf("thor: cannot read %s: %v", entry.fullpath, read_err)
continue
}
minified := minify_css(string(data))
write_file(dest, minified)
} else {
if err := os.copy_file(dest, entry.fullpath); err != nil {
log.warnf("thor: cannot copy %s: %v", entry.fullpath, err)
}
}
if .Minify in features && strings.has_suffix(rel, ".css") {
data, ok := vfs_get(vfs, virtual_path)
if ok {
write_file(dest, minify_css(string(data)))
}
} else if entry.data != nil {
if err := os.write_entire_file(dest, entry.data); err != nil {
log.warnf("thor: cannot write %s: %v", dest, err)
}
} else {
if err := os.copy_file(dest, entry.fs_path); err != nil {
log.warnf("thor: cannot copy %s: %v", entry.fs_path, err)
}
case .Directory:
copy_assets_recursive(entry.fullpath, rel, output_dir, features)
case .Undetermined, .Symlink, .Named_Pipe, .Socket, .Block_Device, .Character_Device:
}
}
}
+6
View File
@@ -0,0 +1,6 @@
package main
import "core:os"
DEFAULTS_PATH :: #directory + os.Path_Separator_String + "defaults"
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{title}}</title>
{{> head}}
<link rel="stylesheet" href="/css/main.css">
</head>
<body>
{{> nav}}{{$content}}{{/content}}
{{> footer}}
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
{{<base}}
{{$content}}
<main>
<header>
{{&body}}
</header>
<ul>
{{#list_pages}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date_iso}}<time
datetime="{{date_iso}}">{{date_display}}</time>{{/date_iso}}</span>
</li>
{{/list_pages}}
</ul>
</main>
{{/content}}
{{/base}}
+11
View File
@@ -0,0 +1,11 @@
{{<base}}
{{$content}}
<main>
<article>
<h1>{{page_title}}</h1>
{{#date_iso}} <time class="subtitle" datetime="{{date_iso}}">{{date_display}}</time>
{{/date_iso}} {{&body}}
</article>
</main>
{{/content}}
{{/base}}
+3
View File
@@ -0,0 +1,3 @@
<footer>
<p>&copy; {{now.year}} {{author}}</p>
</footer>
+9
View File
@@ -0,0 +1,9 @@
<meta property="og:url" content="{{og_url}}">
<meta property="og:site_name" content="{{og_site_name}}">
<meta property="og:title" content="{{og_title}}">
<meta property="og:description" content="{{og_description}}">
<meta property="og:locale" content="en_us">
<meta property="og:type" content="{{og_type}}">
{{#is_article}}<meta property="article:section" content="{{og_section}}">
<meta property="article:published_time" content="{{og_published}}">
{{/is_article}}<meta property="og:image" content="{{og_image}}">
+7
View File
@@ -0,0 +1,7 @@
<header>
<nav>
<ul>
<li><a href="/">{{title}}</a></li>
</ul>
</nav>
</header>
+19
View File
@@ -0,0 +1,19 @@
{{<base}}
{{$content}}
<main>
<h1>{{page_title}}</h1>
{{&body}}
{{#year_sections}}
<section>
<h2>{{year}}</h2>
<ul>
{{#posts}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date_iso}}<time
datetime="{{date_iso}}">{{date_display}}</time>{{/date_iso}}</span>
</li>
{{/posts}}
</ul>
</section>
{{/year_sections}}
</main>
{{/content}}
{{/base}}
+2 -3
View File
@@ -44,13 +44,12 @@ main :: proc() {
defer destroy_site(&site)
// TODO: Make it so this isn't necessary
context.allocator = site_allocator(&site)
build_vfs(&site)
site_load_content(&site)
render_site(&site)
if !(.Watch in site.features) {
break
}
(.Watch in site.features) or_break
time.sleep(5 * time.Second)
}
}
+43 -63
View File
@@ -102,24 +102,24 @@ og_type :: proc(is_article: bool) -> string {
return "website"
}
load_template :: proc(layouts_dir: string, name: string) -> mustache.Template {
data, _ := os.read_entire_file_from_path(
fmt.tprintf("%s/%s", layouts_dir, name),
context.allocator,
)
load_template :: proc(vfs: ^VFS, virtual_path: string) -> mustache.Template {
data, ok := vfs_get(vfs, virtual_path)
if !ok {
log.warnf("thor: template %s not found", virtual_path)
return mustache.Template{}
}
tpl, err := mustache.parse(string(data))
if err != nil {
log.warnf("thor: failed to parse template %s: %v", name, err)
log.warnf("thor: failed to parse template %s: %v", virtual_path, err)
}
return tpl
}
get_template :: proc(
layouts_dir: string,
vfs: ^VFS,
layout: string,
cache: ^map[string]mustache.Template,
) -> mustache.Template {
// Build fallback chain: <layout> → [section_index] → page → base
chain: [4]string
n := 0
chain[n] = layout; n += 1
@@ -133,23 +133,23 @@ get_template :: proc(
chain[n] = "base"; n += 1
}
for i in 0 ..< n {
for i in 0..<n {
candidate := chain[i]
if cached, ok := cache[candidate]; ok {
return cached
}
filename := fmt.tprintf("%s.html", candidate)
if os.exists(fmt.tprintf("%s/%s", layouts_dir, filename)) {
tpl := load_template(layouts_dir, filename)
virtual := fmt.tprintf("layouts/%s.html", candidate)
if _, ok := vfs_get(vfs, virtual); ok {
tpl := load_template(vfs, virtual)
cache[candidate] = tpl
return tpl
}
if candidate != chain[n - 1] {
log.warnf("thor: template %s not found, falling back", filename)
log.warnf("thor: template %s not found, falling back", virtual)
}
}
log.errorf("thor: base.html not found in %s", layouts_dir)
log.errorf("thor: base.html not found in VFS")
return mustache.Template{}
}
@@ -181,8 +181,8 @@ render_site :: proc(site: ^Site) {
sort_pages_by_date(pages)
// Load shared resources
partials := load_partials(site.layouts_dir)
partials["base"] = load_template(site.layouts_dir, "base.html")
partials := load_partials(&site.vfs)
partials["base"] = load_template(&site.vfs, "layouts/base.html")
template_cache: map[string]mustache.Template
defer delete(template_cache)
@@ -225,7 +225,7 @@ render_site :: proc(site: ^Site) {
if page._is_index {
continue
}
tpl := get_template(site.layouts_dir, page.layout, &template_cache)
tpl := get_template(&site.vfs, page.layout, &template_cache)
html := render_page_html(page, site, tpl, partials, base)
if .Minify in site.features {
html = minify_html(html)
@@ -246,7 +246,7 @@ render_site :: proc(site: ^Site) {
}
layout := fmt.tprintf("%s_index", section)
section_tpl := get_template(site.layouts_dir, layout, &template_cache)
section_tpl := get_template(&site.vfs, layout, &template_cache)
html := render_section(
site,
section,
@@ -264,7 +264,7 @@ render_site :: proc(site: ^Site) {
// Render home page
if has_home {
home_tpl := get_template(site.layouts_dir, "home", &template_cache)
home_tpl := get_template(&site.vfs, "home", &template_cache)
home_html := render_home_html(home, site, home_tpl, partials, base)
if .Minify in site.features {
home_html = minify_html(home_html)
@@ -281,7 +281,7 @@ render_site :: proc(site: ^Site) {
write_file(fmt.tprintf("%s/sitemap.xml", site.output_dir), sitemap)
// Copy and optionally minify assets directory
copy_assets_dir(site.assets_dir, site.output_dir, site.features)
copy_assets_dir(&site.vfs, site.output_dir, site.features)
// Generate robots.txt
robots := fmt.aprintf("User-agent: *\nAllow: /\nSitemap: %s/sitemap.xml\n", site.base_url)
@@ -393,52 +393,32 @@ render_section :: proc(
return render_template(content_tpl, data, partials)
}
load_partials :: proc(layouts_dir: string) -> map[string]mustache.Template {
load_partials :: proc(vfs: ^VFS) -> map[string]mustache.Template {
partials: map[string]mustache.Template
partials_dir := fmt.tprintf("%s/partials", layouts_dir)
load_partials_recursive(&partials, partials_dir, "")
return partials
}
load_partials_recursive :: proc(
partials: ^map[string]mustache.Template,
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 {
tpl, perr := mustache.parse(string(data))
if perr != nil {
log.warnf("thor: failed to parse partial %s: %v", key, perr)
}
partials[key] = tpl
}
case .Directory:
sub_prefix := entry.name
if rel_prefix != "" {
sub_prefix = fmt.tprintf("%s/%s", rel_prefix, entry.name)
}
load_partials_recursive(partials, entry.fullpath, sub_prefix)
prefix := "layouts/partials/"
for virtual_path in vfs.files {
if !strings.has_prefix(virtual_path, prefix) {
continue
}
if !strings.has_suffix(virtual_path, ".html") {
continue
}
stripped := virtual_path[len(prefix):]
key := stripped[:len(stripped) - len(".html")]
data, ok := vfs_get(vfs, virtual_path)
if !ok {
continue
}
tpl, err := mustache.parse(string(data))
if err != nil {
log.warnf("thor: failed to parse partial %s: %v", key, err)
continue
}
partials[key] = tpl
}
return partials
}
format_date :: proc(iso: string) -> string {
+11
View File
@@ -13,6 +13,8 @@ import "core:strings"
Site :: struct {
arena: mem.Dynamic_Arena,
pages: [dynamic]Page,
modules: [dynamic]string,
vfs: VFS,
title: string,
description: string,
author: string,
@@ -62,6 +64,7 @@ Config_File :: struct {
layouts_dir: string,
markdown_extensions: json.Value,
params: json.Value,
modules: json.Value,
}
// Configuration loaded from command line arguments. Gets folded in to Site
@@ -161,6 +164,14 @@ site_apply_config :: proc(site: ^Site, config: Config_File, config_dir: string)
if ext_obj, ok := config.markdown_extensions.(json.Object); ok {
apply_extension_config(&site.markdown_extensions, ext_obj)
}
if modules_arr, ok := config.modules.(json.Array); ok {
for &item in modules_arr {
if s, ok2 := item.(json.String); ok2 {
append(&site.modules, fmt.tprintf("%s/%s", config_dir, s))
}
}
}
}
site_apply_path_defaults :: proc(site: ^Site, config_dir: string) {
+80
View File
@@ -0,0 +1,80 @@
package main
import "core:fmt"
import "core:os"
// Virtual File System Entry
VFS_Entry :: struct {
fs_path: string,
data: []byte,
}
// Virtual File System
VFS :: struct {
files: map[string]VFS_Entry,
}
build_vfs :: proc(site: ^Site) {
site.vfs.files = make(map[string]VFS_Entry, site_allocator(site))
mount_dir(&site.vfs, fmt.tprintf("%s/layouts", DEFAULTS_PATH), "layouts")
for i := len(site.modules) - 1; i >= 0; i -= 1 {
module := site.modules[i]
mount_subdir(&site.vfs, module, "layouts")
mount_subdir(&site.vfs, module, "assets")
}
mount_dir(&site.vfs, site.layouts_dir, "layouts")
mount_dir(&site.vfs, site.assets_dir, "assets")
}
mount_subdir :: proc(vfs: ^VFS, module_dir: string, target: string) {
source := fmt.tprintf("%s/%s", module_dir, target)
mount_dir(vfs, source, target)
}
mount_dir :: proc(vfs: ^VFS, source_dir: string, target: string) {
if !os.exists(source_dir) {
return
}
mount_recursive(vfs, source_dir, target)
}
mount_recursive :: proc(vfs: ^VFS, current_dir: string, target_prefix: string) {
entries, err := os.read_all_directory_by_path(current_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:
virtual := fmt.tprintf("%s/%s", target_prefix, entry.name)
vfs.files[virtual] = VFS_Entry {
fs_path = entry.fullpath,
}
case .Directory:
sub_prefix := fmt.tprintf("%s/%s", target_prefix, entry.name)
mount_recursive(vfs, entry.fullpath, sub_prefix)
case:
}
}
}
vfs_get :: proc(vfs: ^VFS, virtual_path: string) -> ([]byte, bool) {
entry, ok := vfs.files[virtual_path]
if !ok {
return nil, false
}
if entry.data != nil {
return entry.data, true
}
data, err := os.read_entire_file_from_path(entry.fs_path, context.allocator)
if err != nil {
return nil, false
}
return data, true
}