mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
feat: Added Unified file system / default layout mounts.
This commit is contained in:
@@ -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
|
||||
|
||||
+22
-44
@@ -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)
|
||||
|
||||
for entry in entries {
|
||||
rel := rel_prefix == "" ? entry.name : fmt.tprintf("%s/%s", rel_prefix, entry.name)
|
||||
switch entry.type {
|
||||
case .Regular:
|
||||
rel := virtual_path[len("assets/"):]
|
||||
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 .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)
|
||||
}
|
||||
}
|
||||
case .Directory:
|
||||
copy_assets_recursive(entry.fullpath, rel, output_dir, features)
|
||||
case .Undetermined, .Symlink, .Named_Pipe, .Socket, .Block_Device, .Character_Device:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package main
|
||||
|
||||
import "core:os"
|
||||
|
||||
DEFAULTS_PATH :: #directory + os.Path_Separator_String + "defaults"
|
||||
|
||||
@@ -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>
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -0,0 +1,3 @@
|
||||
<footer>
|
||||
<p>© {{now.year}} {{author}}</p>
|
||||
</footer>
|
||||
@@ -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}}">
|
||||
@@ -0,0 +1,7 @@
|
||||
<header>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="/">{{title}}</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -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}}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+38
-58
@@ -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
|
||||
@@ -138,18 +138,18 @@ get_template :: proc(
|
||||
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") {
|
||||
prefix := "layouts/partials/"
|
||||
for virtual_path in vfs.files {
|
||||
if !strings.has_prefix(virtual_path, prefix) {
|
||||
continue
|
||||
}
|
||||
stripped := name[:len(name) - len(".html")]
|
||||
key := stripped
|
||||
if rel_prefix != "" {
|
||||
key = fmt.tprintf("%s/%s", rel_prefix, stripped)
|
||||
if !strings.has_suffix(virtual_path, ".html") {
|
||||
continue
|
||||
}
|
||||
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)
|
||||
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
return partials
|
||||
}
|
||||
|
||||
format_date :: proc(iso: string) -> string {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user