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 - README.md
- [ ] "Zero is beautiful" - [ ] "Zero is beautiful"
- [ ] Content-hash fingerprinting for CSS and JS cache busting - [ ] Content-hash fingerprinting for CSS and JS cache busting
- [ ] Clean up the default layouts
- [ ] Performance - [ ] Performance
- [ ] See if we can disable bounds checks in `write_indented` and elsewhere. - [ ] 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 - [ ] Instead of loading the site fresh each time in watch mode, create a
`reload_site` proc, that just updates changed resources. `reload_site` proc, that just updates changed resources.
- [ ] Only publish referenced assets.
- [ ] mustache data keys for opengraph, etc. - [ ] mustache data keys for opengraph, etc.
- [ ] Block attributes on code fences (`{ #ex-1 }`) — hello-world.md - [ ] Block attributes on code fences (`{ #ex-1 }`) — hello-world.md
- [ ] include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin - [ ] 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 - [ ] 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. - [ ] 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 - [ ] Split `load_page` into frontmatter-parse + body-process phases so draft pages can skip the markdown pipeline entirely
- [ ] Mount content in VFS
- [ ] commands - [ ] commands
- [ ] `build` alias of default - [ ] `build` alias of default
- [ ] `init` set up new project - [ ] `init` set up new project
+17 -39
View File
@@ -5,57 +5,35 @@ import "core:log"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
// copy_assets_dir recursively copies files from assets_dir to output_dir. copy_assets_dir :: proc(vfs: ^VFS, output_dir: string, features: bit_set[Feature]) {
// .css files are minified when .Minify is enabled; all other files are copied verbatim. for virtual_path, entry in vfs.files {
// Silently skips if assets_dir doesn't exist. if !strings.has_prefix(virtual_path, "assets/") {
copy_assets_dir :: proc(assets_dir: string, output_dir: string, features: bit_set[Feature]) { continue
if !os.exists(assets_dir) {
return
} }
copy_assets_recursive(assets_dir, "", output_dir, features)
}
copy_assets_recursive :: proc( rel := virtual_path[len("assets/"):]
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)
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) dest := fmt.tprintf("%s/%s", output_dir, rel)
if idx := strings.last_index(dest, "/"); idx >= 0 { if idx := strings.last_index(dest, "/"); idx >= 0 {
if err := os.make_directory_all(dest[:idx]); err != nil && err != .Exist { if err := os.make_directory_all(dest[:idx]); err != nil && err != .Exist {
log.warnf("thor: cannot create %s: %v", dest[:idx], err) log.warnf("thor: cannot create %s: %v", dest[:idx], err)
continue 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 .Minify in features && strings.has_suffix(rel, ".css") {
if read_err != nil { data, ok := vfs_get(vfs, virtual_path)
log.warnf("thor: cannot read %s: %v", entry.fullpath, read_err) if ok {
continue 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)
} }
minified := minify_css(string(data))
write_file(dest, minified)
} else { } else {
if err := os.copy_file(dest, entry.fullpath); err != nil { if err := os.copy_file(dest, entry.fs_path); err != nil {
log.warnf("thor: cannot copy %s: %v", entry.fullpath, err) 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) defer destroy_site(&site)
// TODO: Make it so this isn't necessary // TODO: Make it so this isn't necessary
context.allocator = site_allocator(&site) context.allocator = site_allocator(&site)
build_vfs(&site)
site_load_content(&site) site_load_content(&site)
render_site(&site) render_site(&site)
if !(.Watch in site.features) { (.Watch in site.features) or_break
break
}
time.sleep(5 * time.Second) time.sleep(5 * time.Second)
} }
} }
+39 -59
View File
@@ -102,24 +102,24 @@ og_type :: proc(is_article: bool) -> string {
return "website" return "website"
} }
load_template :: proc(layouts_dir: string, name: string) -> mustache.Template { load_template :: proc(vfs: ^VFS, virtual_path: string) -> mustache.Template {
data, _ := os.read_entire_file_from_path( data, ok := vfs_get(vfs, virtual_path)
fmt.tprintf("%s/%s", layouts_dir, name), if !ok {
context.allocator, log.warnf("thor: template %s not found", virtual_path)
) return mustache.Template{}
}
tpl, err := mustache.parse(string(data)) tpl, err := mustache.parse(string(data))
if err != nil { 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 return tpl
} }
get_template :: proc( get_template :: proc(
layouts_dir: string, vfs: ^VFS,
layout: string, layout: string,
cache: ^map[string]mustache.Template, cache: ^map[string]mustache.Template,
) -> mustache.Template { ) -> mustache.Template {
// Build fallback chain: <layout> → [section_index] → page → base
chain: [4]string chain: [4]string
n := 0 n := 0
chain[n] = layout; n += 1 chain[n] = layout; n += 1
@@ -133,23 +133,23 @@ get_template :: proc(
chain[n] = "base"; n += 1 chain[n] = "base"; n += 1
} }
for i in 0 ..< n { for i in 0..<n {
candidate := chain[i] candidate := chain[i]
if cached, ok := cache[candidate]; ok { if cached, ok := cache[candidate]; ok {
return cached return cached
} }
filename := fmt.tprintf("%s.html", candidate) virtual := fmt.tprintf("layouts/%s.html", candidate)
if os.exists(fmt.tprintf("%s/%s", layouts_dir, filename)) { if _, ok := vfs_get(vfs, virtual); ok {
tpl := load_template(layouts_dir, filename) tpl := load_template(vfs, virtual)
cache[candidate] = tpl cache[candidate] = tpl
return tpl return tpl
} }
if candidate != chain[n - 1] { 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{} return mustache.Template{}
} }
@@ -181,8 +181,8 @@ render_site :: proc(site: ^Site) {
sort_pages_by_date(pages) sort_pages_by_date(pages)
// Load shared resources // Load shared resources
partials := load_partials(site.layouts_dir) partials := load_partials(&site.vfs)
partials["base"] = load_template(site.layouts_dir, "base.html") partials["base"] = load_template(&site.vfs, "layouts/base.html")
template_cache: map[string]mustache.Template template_cache: map[string]mustache.Template
defer delete(template_cache) defer delete(template_cache)
@@ -225,7 +225,7 @@ render_site :: proc(site: ^Site) {
if page._is_index { if page._is_index {
continue 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) html := render_page_html(page, site, tpl, partials, base)
if .Minify in site.features { if .Minify in site.features {
html = minify_html(html) html = minify_html(html)
@@ -246,7 +246,7 @@ render_site :: proc(site: ^Site) {
} }
layout := fmt.tprintf("%s_index", section) 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( html := render_section(
site, site,
section, section,
@@ -264,7 +264,7 @@ render_site :: proc(site: ^Site) {
// Render home page // Render home page
if has_home { 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) home_html := render_home_html(home, site, home_tpl, partials, base)
if .Minify in site.features { if .Minify in site.features {
home_html = minify_html(home_html) 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) write_file(fmt.tprintf("%s/sitemap.xml", site.output_dir), sitemap)
// Copy and optionally minify assets directory // 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 // Generate robots.txt
robots := fmt.aprintf("User-agent: *\nAllow: /\nSitemap: %s/sitemap.xml\n", site.base_url) 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) 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: map[string]mustache.Template
partials_dir := fmt.tprintf("%s/partials", layouts_dir) prefix := "layouts/partials/"
load_partials_recursive(&partials, partials_dir, "") for virtual_path in vfs.files {
return partials if !strings.has_prefix(virtual_path, prefix) {
}
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 continue
} }
stripped := name[:len(name) - len(".html")] if !strings.has_suffix(virtual_path, ".html") {
key := stripped continue
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 { stripped := virtual_path[len(prefix):]
tpl, perr := mustache.parse(string(data)) key := stripped[:len(stripped) - len(".html")]
if perr != nil {
log.warnf("thor: failed to parse partial %s: %v", key, perr) 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 partials[key] = tpl
} }
case .Directory: return partials
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)
}
}
} }
format_date :: proc(iso: string) -> string { format_date :: proc(iso: string) -> string {
+11
View File
@@ -13,6 +13,8 @@ import "core:strings"
Site :: struct { Site :: struct {
arena: mem.Dynamic_Arena, arena: mem.Dynamic_Arena,
pages: [dynamic]Page, pages: [dynamic]Page,
modules: [dynamic]string,
vfs: VFS,
title: string, title: string,
description: string, description: string,
author: string, author: string,
@@ -62,6 +64,7 @@ Config_File :: struct {
layouts_dir: string, layouts_dir: string,
markdown_extensions: json.Value, markdown_extensions: json.Value,
params: json.Value, params: json.Value,
modules: json.Value,
} }
// Configuration loaded from command line arguments. Gets folded in to Site // 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 { if ext_obj, ok := config.markdown_extensions.(json.Object); ok {
apply_extension_config(&site.markdown_extensions, ext_obj) 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) { 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
}