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 @@ +{{ +
+ {{&body}} +
+ + +{{/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 @@ +
+

© {{now.year}} {{author}}

+
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 +} +