From 528bef3941493fc5cf4e53a16d29c10f07460108 Mon Sep 17 00:00:00 2001
From: Spencer Brower <6729162+sbrow@users.noreply.github.com>
Date: Fri, 17 Jul 2026 13:24:51 -0400
Subject: [PATCH] feat: Added Unified file system / default layout mounts.
---
TODOS.md | 3 +
assets.odin | 72 ++++++-----------
defaults.odin | 6 ++
defaults/layouts/base.html | 17 +++++
defaults/layouts/home.html | 15 ++++
defaults/layouts/page.html | 11 +++
defaults/layouts/partials/footer.html | 3 +
defaults/layouts/partials/head.html | 9 +++
defaults/layouts/partials/nav.html | 7 ++
defaults/layouts/section_index.html | 19 +++++
main.odin | 5 +-
render.odin | 106 +++++++++++---------------
site.odin | 11 +++
vfs.odin | 80 +++++++++++++++++++
14 files changed, 251 insertions(+), 113 deletions(-)
create mode 100644 defaults.odin
create mode 100644 defaults/layouts/base.html
create mode 100644 defaults/layouts/home.html
create mode 100644 defaults/layouts/page.html
create mode 100644 defaults/layouts/partials/footer.html
create mode 100644 defaults/layouts/partials/head.html
create mode 100644 defaults/layouts/partials/nav.html
create mode 100644 defaults/layouts/section_index.html
create mode 100644 vfs.odin
diff --git a/TODOS.md b/TODOS.md
index e15f6ec..e29c79e 100644
--- a/TODOS.md
+++ b/TODOS.md
@@ -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
diff --git a/assets.odin b/assets.odin
index 66465dc..7eec12a 100644
--- a/assets.odin
+++ b/assets.odin
@@ -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:
}
}
}
-
diff --git a/defaults.odin b/defaults.odin
new file mode 100644
index 0000000..a88ff3b
--- /dev/null
+++ b/defaults.odin
@@ -0,0 +1,6 @@
+package main
+
+import "core:os"
+
+DEFAULTS_PATH :: #directory + os.Path_Separator_String + "defaults"
+
diff --git a/defaults/layouts/base.html b/defaults/layouts/base.html
new file mode 100644
index 0000000..8b66e4e
--- /dev/null
+++ b/defaults/layouts/base.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+ {{title}}
+ {{> head}}
+
+
+
+
+ {{> nav}}{{$content}}{{/content}}
+ {{> footer}}
+
+
+
diff --git a/defaults/layouts/home.html b/defaults/layouts/home.html
new file mode 100644
index 0000000..5b5f26b
--- /dev/null
+++ b/defaults/layouts/home.html
@@ -0,0 +1,15 @@
+{{
+
+
+ {{#list_pages}} - {{&title}}{{#date_iso}}{{/date_iso}}
+
+ {{/list_pages}}
+
+
+{{/content}}
+{{/base}}
diff --git a/defaults/layouts/page.html b/defaults/layouts/page.html
new file mode 100644
index 0000000..de22599
--- /dev/null
+++ b/defaults/layouts/page.html
@@ -0,0 +1,11 @@
+{{
+
+ {{page_title}}
+ {{#date_iso}}
+ {{/date_iso}} {{&body}}
+
+
+{{/content}}
+{{/base}}
diff --git a/defaults/layouts/partials/footer.html b/defaults/layouts/partials/footer.html
new file mode 100644
index 0000000..87c313a
--- /dev/null
+++ b/defaults/layouts/partials/footer.html
@@ -0,0 +1,3 @@
+
diff --git a/defaults/layouts/partials/head.html b/defaults/layouts/partials/head.html
new file mode 100644
index 0000000..7f0c126
--- /dev/null
+++ b/defaults/layouts/partials/head.html
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+{{#is_article}}
+
+{{/is_article}}
diff --git a/defaults/layouts/partials/nav.html b/defaults/layouts/partials/nav.html
new file mode 100644
index 0000000..51e9475
--- /dev/null
+++ b/defaults/layouts/partials/nav.html
@@ -0,0 +1,7 @@
+
diff --git a/defaults/layouts/section_index.html b/defaults/layouts/section_index.html
new file mode 100644
index 0000000..b7b5dae
--- /dev/null
+++ b/defaults/layouts/section_index.html
@@ -0,0 +1,19 @@
+{{
+ {{page_title}}
+ {{&body}}
+ {{#year_sections}}
+
+ {{year}}
+
+ {{#posts}} - {{&title}}{{#date_iso}}{{/date_iso}}
+
+ {{/posts}}
+
+
+ {{/year_sections}}
+
+{{/content}}
+{{/base}}
diff --git a/main.odin b/main.odin
index b47a3c0..b39dc9c 100644
--- a/main.odin
+++ b/main.odin
@@ -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)
}
}
diff --git a/render.odin b/render.odin
index 2ce487f..b13e490 100644
--- a/render.odin
+++ b/render.odin
@@ -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: → [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.. 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 {
diff --git a/site.odin b/site.odin
index f8f9ea6..ddae25d 100644
--- a/site.odin
+++ b/site.odin
@@ -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) {
diff --git a/vfs.odin b/vfs.odin
new file mode 100644
index 0000000..51ce374
--- /dev/null
+++ b/vfs.odin
@@ -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
+}
+