diff --git a/TODOS.md b/TODOS.md
index 99966eb..e15f6ec 100644
--- a/TODOS.md
+++ b/TODOS.md
@@ -6,10 +6,9 @@
- [ ] Instead of loading the site fresh each time in watch mode, create a
`reload_site` proc, that just updates changed resources.
- [ ] mustache data keys for opengraph, etc.
-- [ ] Infer page types / collections rather than having dedicated procs for
- everything
- [ ] Block attributes on code fences (`{ #ex-1 }`) — hello-world.md
- [ ] include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin
+- [ ] follow symlinks in `scan_content`?
- [ ] ensure sidenote numbers render in display order and not in declaration order.
- [ ] OpenGraph meta tags — verify all fields match production site
- [ ] Add Opt-in deflist support.
@@ -34,13 +33,16 @@
- [ ] filesystem poll loop
- [ ] event based
- [ ] 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
- [ ] commands
- [ ] `build` alias of default
- [ ] `init` set up new project
- [ ] Use spall to find ways to reduce run time.
- [ ] warn/error when unknown key used in mustache.
+- [ ] Import/export packages. Hugo, jekyll, WordPress, etc.
- [ ] Review every file in thor
+ - [ ] Review assets.odin
- [ ] Review alerts.odin
- [ ] Review content.odin
- [ ] Review emoji.odin
diff --git a/assets.odin b/assets.odin
new file mode 100644
index 0000000..66465dc
--- /dev/null
+++ b/assets.odin
@@ -0,0 +1,61 @@
+package main
+
+import "core:fmt"
+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_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:
+ 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:
+ }
+ }
+}
+
diff --git a/content.odin b/content.odin
index 0fa1ef4..e4f748f 100644
--- a/content.odin
+++ b/content.odin
@@ -7,100 +7,36 @@ import "core:log"
import "core:os"
import "core:strings"
-Page_Type :: enum {
- Page,
- Post,
- Home,
-}
-
+// Fields with underscores should never be set by the user.
Page :: struct {
- type: Page_Type,
+ section: string,
slug: string,
+ layout: string,
permalink: string,
title: string,
description: string,
date: string,
- draft: bool,
- is_starred: bool,
menu: string,
body_html: string,
- bundle_dir: string,
+ draft: bool,
+ is_starred: bool,
+ _is_index: bool `private`,
}
// site_load_content reads the content directory and populates site.pages.
// Drafts are excluded unless .Drafts is enabled.
site_load_content :: proc(site: ^Site) {
site.pages = make([dynamic]Page, 0, 8, site_allocator(site))
-
- load_homepage(site)
- load_pages(site)
- load_posts(site)
+ scan_content(site, site.content_dir, "")
}
-load_homepage :: proc(site: ^Site) {
- content_path := site.content_dir
- ext := site.markdown_extensions
-
- html_path := fmt.tprintf("%s/index.html", content_path)
- if os.exists(html_path) {
- page, ok := load_page(html_path, .Home, "", ext)
- if ok && (!page.draft || .Drafts in site.features) {
- page.permalink = "/"
- append(&site.pages, page)
- }
- return
- }
-
- md_path := fmt.tprintf("%s/index.md", content_path)
- if os.exists(md_path) {
- page, ok := load_page(md_path, .Home, "", ext)
- if ok && (!page.draft || .Drafts in site.features) {
- page.permalink = "/"
- append(&site.pages, page)
- }
- }
-}
-
-load_pages :: proc(site: ^Site) {
- content_path := site.content_dir
- ext := site.markdown_extensions
-
- entries, err := os.read_all_directory_by_path(content_path, context.allocator)
+// scan_content walks the content directory. At the root level (section=""),
+// directories are treated as sections. Within a section, directories are
+// treated as leaf bundles (directory with an index file).
+scan_content :: proc(site: ^Site, dir: string, section: string) {
+ entries, err := os.read_all_directory_by_path(dir, context.allocator)
if err != nil {
- log.warnf("thor: cannot read %s: %v", content_path, err)
- return
- }
- defer os.file_info_slice_delete(entries, context.allocator)
-
- for entry in entries {
- if !is_content_file(entry.name) {
- continue
- }
- if entry.name == "index.md" || entry.name == "index.html" {
- continue
- }
- if entry.type != .Regular {
- continue
- }
-
- slug := strip_extension(entry.name)
- page, ok := load_page(entry.fullpath, .Page, slug, ext)
- if ok && (!page.draft || .Drafts in site.features) {
- append(&site.pages, page)
- }
- }
-}
-
-load_posts :: proc(site: ^Site) {
- posts_path := fmt.tprintf("%s/posts", site.content_dir)
- if !os.exists(posts_path) {
- return
- }
-
- ext := site.markdown_extensions
- entries, err := os.read_all_directory_by_path(posts_path, context.allocator)
- if err != nil {
- log.warnf("thor: cannot read %s: %v", posts_path, err)
+ log.warnf("thor: cannot read %s: %v", dir, err)
return
}
defer os.file_info_slice_delete(entries, context.allocator)
@@ -111,33 +47,60 @@ load_posts :: proc(site: ^Site) {
if !is_content_file(entry.name) {
continue
}
- slug := strip_extension(entry.name)
- page, ok := load_page(entry.fullpath, .Post, slug, ext)
+
+ filename := strip_extension(entry.name)
+ is_idx := filename == "index"
+ slug := is_idx ? "" : filename
+
+ page, ok := load_page(entry.fullpath, section, slug, is_idx, site.markdown_extensions)
if ok && (!page.draft || .Drafts in site.features) {
append(&site.pages, page)
}
+
case .Directory:
- index_path := fmt.tprintf("%s/index.html", entry.fullpath)
- if !os.exists(index_path) {
- index_path = fmt.tprintf("%s/index.md", entry.fullpath)
- }
- if !os.exists(index_path) {
- continue
- }
- page, ok := load_page(index_path, .Post, entry.name, ext)
- if ok && (!page.draft || .Drafts in site.features) {
- page.bundle_dir = entry.fullpath
- append(&site.pages, page)
+ if section == "" {
+ scan_content(site, entry.fullpath, entry.name)
+ } else {
+ index_path := fmt.tprintf("%s/index.html", entry.fullpath)
+ if !os.exists(index_path) {
+ index_path = fmt.tprintf("%s/index.md", entry.fullpath)
+ }
+ if os.exists(index_path) {
+ page, ok := load_page(
+ index_path,
+ section,
+ entry.name,
+ false,
+ site.markdown_extensions,
+ )
+ if ok && (!page.draft || .Drafts in site.features) {
+ append(&site.pages, page)
+ }
+ }
}
case .Undetermined, .Symlink, .Named_Pipe, .Socket, .Block_Device, .Character_Device:
}
}
}
+infer_layout :: proc(section: string, is_index: bool) -> string {
+ if section == "" && is_index {
+ return "home"
+ }
+ if is_index {
+ return fmt.tprintf("%s_index", section)
+ }
+ if section != "" {
+ return section
+ }
+ return "page"
+}
+
load_page :: proc(
file_path: string,
- page_type: Page_Type,
+ section: string,
slug: string,
+ is_index: bool,
ext: bit_set[Markdown_Extension],
) -> (
page: Page,
@@ -155,14 +118,16 @@ load_page :: proc(
body = strings.trim_left(content, " \t\r\n")
}
- page.type = page_type
+ page.section = section
page.slug = slug
+ page._is_index = is_index
page.title = fm.title
page.description = fm.description
page.date = fm.date
page.draft = fm.draft
page.is_starred = fm.isStarred
page.menu = fm.menu
+ page.layout = fm.layout if fm.layout != "" else infer_layout(section, is_index)
if strings.has_suffix(file_path, ".html") {
page.body_html = strings.clone(body)
@@ -192,13 +157,14 @@ load_page :: proc(
page.body_html = html
}
- switch page_type {
- case .Home:
+ if section == "" && is_index {
page.permalink = "/"
- case .Post:
- page.permalink = fmt.aprintf("/posts/%s/", slug)
- case .Page:
+ } else if is_index {
+ page.permalink = fmt.aprintf("/%s/", section)
+ } else if section == "" {
page.permalink = fmt.aprintf("/%s/", slug)
+ } else {
+ page.permalink = fmt.aprintf("/%s/%s/", section, slug)
}
ok = true
@@ -217,57 +183,3 @@ strip_extension :: proc(name: string) -> string {
return name[:dot]
}
-// 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_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:
- 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:
- }
- }
-}
-
diff --git a/feed.odin b/feed.odin
index 64fb01a..d17e483 100644
--- a/feed.odin
+++ b/feed.odin
@@ -7,8 +7,10 @@ import "core:time"
generate_rss :: proc(site: ^Site) -> string {
sb := strings.builder_make()
- strings.write_string(&sb, fmt.aprintf(
- `
+ strings.write_string(
+ &sb,
+ fmt.aprintf(
+ `
%s
@@ -16,14 +18,15 @@ generate_rss :: proc(site: ^Site) -> string {
%s
en-us
`,
- xml_escape(site.title),
- site.base_url,
- xml_escape(site.description),
- site.base_url,
- ))
+ xml_escape(site.title),
+ site.base_url,
+ xml_escape(site.description),
+ site.base_url,
+ ),
+ )
for page in site.pages {
- if page.type == .Home {
+ if page.section == "" && page._is_index {
continue
}
@@ -32,8 +35,10 @@ generate_rss :: proc(site: ^Site) -> string {
pub_date = format_rfc822(page.date)
}
- strings.write_string(&sb, fmt.aprintf(
- `-
+ strings.write_string(
+ &sb,
+ fmt.aprintf(
+ `
-
%s
%s%s
%s
@@ -41,14 +46,15 @@ generate_rss :: proc(site: ^Site) -> string {
%s
`,
- xml_escape(page.title),
- site.base_url,
- page.permalink,
- pub_date,
- site.base_url,
- page.permalink,
- xml_escape(page.body_html),
- ))
+ xml_escape(page.title),
+ site.base_url,
+ page.permalink,
+ pub_date,
+ site.base_url,
+ page.permalink,
+ xml_escape(page.body_html),
+ ),
+ )
}
strings.write_string(&sb, " \n")
@@ -58,34 +64,59 @@ generate_rss :: proc(site: ^Site) -> string {
generate_sitemap :: proc(site: ^Site) -> string {
sb := strings.builder_make()
- strings.write_string(&sb, `
+ strings.write_string(
+ &sb,
+ `
-`)
+`,
+ )
for page in site.pages {
lastmod := ""
if page.date != "" {
lastmod = fmt.aprintf("%s", page.date)
}
- strings.write_string(&sb, fmt.aprintf(
- "%s%s%s\n", site.base_url, page.permalink, lastmod,
- ))
+ strings.write_string(
+ &sb,
+ fmt.aprintf("%s%s%s\n", site.base_url, page.permalink, lastmod),
+ )
}
- // Posts list page
- posts_lastmod := ""
+ // Section index pages (for sections without an index in content)
+ sections := make(map[string]bool)
+ defer delete(sections)
for page in site.pages {
- if page.type == .Post && page.date > posts_lastmod {
- posts_lastmod = page.date
+ if page.section != "" && !page._is_index {
+ sections[page.section] = true
}
}
- posts_lm := ""
- if posts_lastmod != "" {
- posts_lm = fmt.aprintf("%s", posts_lastmod)
+ for section in sections {
+ has_index := false
+ for page in site.pages {
+ if page.section == section && page._is_index {
+ has_index = true
+ break
+ }
+ }
+ if has_index {
+ continue
+ }
+
+ section_lastmod := ""
+ for page in site.pages {
+ if page.section == section && !page._is_index && page.date > section_lastmod {
+ section_lastmod = page.date
+ }
+ }
+ lm := ""
+ if section_lastmod != "" {
+ lm = fmt.aprintf("%s", section_lastmod)
+ }
+ strings.write_string(
+ &sb,
+ fmt.aprintf("%s/%s/%s\n", site.base_url, section, lm),
+ )
}
- strings.write_string(&sb, fmt.aprintf(
- "%s/posts/%s\n", site.base_url, posts_lm,
- ))
strings.write_string(&sb, "")
return strings.to_string(sb)
diff --git a/frontmatter.odin b/frontmatter.odin
index cb7e26f..d299480 100644
--- a/frontmatter.odin
+++ b/frontmatter.odin
@@ -12,6 +12,7 @@ Frontmatter :: struct {
draft: bool,
isStarred: bool,
menu: string,
+ layout: string,
}
// parse_frontmatter splits raw file content into a Frontmatter struct and the
@@ -52,6 +53,7 @@ parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok
fm.draft = json_get_bool(obj, "draft")
fm.isStarred = json_get_bool(obj, "isStarred")
fm.menu = json_get_string(obj, "menu")
+ fm.layout = json_get_string(obj, "layout")
ok = true
return
diff --git a/render.odin b/render.odin
index b1eaef6..9d54200 100644
--- a/render.odin
+++ b/render.odin
@@ -54,12 +54,13 @@ Home_Data :: struct {
list_pages: [dynamic]Page_Context,
}
-Posts_Data :: struct {
+Section_Data :: struct {
using base: Base_Data,
+ page_title: string,
year_sections: [dynamic]Year_Section,
}
- build_page_context :: proc(page: Page) -> Page_Context {
+build_page_context :: proc(page: Page) -> Page_Context {
return Page_Context {
permalink = page.permalink,
title = page.title,
@@ -114,6 +115,41 @@ load_template :: proc(layouts_dir: string, name: string) -> mustache.Template {
return tpl
}
+get_template :: proc(
+ layouts_dir: string,
+ layout: string,
+ cache: ^map[string]mustache.Template,
+) -> mustache.Template {
+ if cached, ok := cache[layout]; ok {
+ return cached
+ }
+
+ filename := fmt.tprintf("%s.html", layout)
+ if os.exists(fmt.tprintf("%s/%s", layouts_dir, filename)) {
+ tpl := load_template(layouts_dir, filename)
+ cache[layout] = tpl
+ return tpl
+ }
+
+ if layout != "page" {
+ log.warnf("thor: template %s not found, falling back to page.html", filename)
+ return get_template(layouts_dir, "page", cache)
+ }
+
+ log.errorf("thor: default template page.html not found in %s", layouts_dir)
+ return load_template(layouts_dir, "page.html")
+}
+
+capitalize :: proc(s: string) -> string {
+ if len(s) == 0 {
+ return s
+ }
+ if s[0] >= 'a' && s[0] <= 'z' {
+ return fmt.aprintf("%c%s", s[0] - 32, s[1:])
+ }
+ return s
+}
+
render_template :: proc(
content_tpl: mustache.Template,
data: any,
@@ -131,10 +167,12 @@ render_site :: proc(site: ^Site) {
pages := site.pages[:]
sort_pages_by_date(pages)
- // Load shared resources once
+ // Load shared resources
partials := load_partials(site.layouts_dir)
partials["base"] = load_template(site.layouts_dir, "base.html")
- post_tpl := load_template(site.layouts_dir, "post.html")
+
+ template_cache: map[string]mustache.Template
+ defer delete(template_cache)
now, ok := time.time_to_datetime(time.now())
assert(ok)
@@ -153,28 +191,67 @@ render_site :: proc(site: ^Site) {
home: Page
has_home := false
for page in pages {
- if page.type == .Home {
+ if page.section == "" && page._is_index {
home = page
has_home = true
break
}
}
- // Render individual content pages (skip home)
+ // Collect sections
+ sections := make(map[string]bool)
+ defer delete(sections)
for page in pages {
- if page.type == .Home {
+ if page.section != "" {
+ sections[page.section] = true
+ }
+ }
+
+ // Render individual content pages (skip all index pages)
+ for page in pages {
+ if page._is_index {
continue
}
- html := render_page_html(page, site, post_tpl, partials, base)
+ tpl := get_template(site.layouts_dir, page.layout, &template_cache)
+ html := render_page_html(page, site, tpl, partials, base)
if .Minify in site.features {
html = minify_html(html)
}
write_page(site.output_dir, page.permalink, html)
}
+ // Render section index pages
+ for section in sections {
+ section_index: Page
+ has_section_index := false
+ for page in pages {
+ if page.section == section && page._is_index {
+ section_index = page
+ has_section_index = true
+ break
+ }
+ }
+
+ layout := fmt.tprintf("%s_index", section)
+ section_tpl := get_template(site.layouts_dir, layout, &template_cache)
+ html := render_section(
+ site,
+ section,
+ section_index,
+ has_section_index,
+ section_tpl,
+ partials,
+ base,
+ )
+ if .Minify in site.features {
+ html = minify_html(html)
+ }
+ write_page(site.output_dir, fmt.aprintf("/%s/", section), html)
+ }
+
// Render home page
if has_home {
- home_tpl := load_template(site.layouts_dir, "home.html")
+ home_tpl := get_template(site.layouts_dir, "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)
@@ -182,14 +259,6 @@ render_site :: proc(site: ^Site) {
write_file(fmt.tprintf("%s/index.html", site.output_dir), home_html)
}
- // Render posts list page
- posts_tpl := load_template(site.layouts_dir, "posts_list.html")
- posts_html := render_posts_html(site, posts_tpl, partials, base)
- if .Minify in site.features {
- posts_html = minify_html(posts_html)
- }
- write_page(site.output_dir, "/posts/", posts_html)
-
// Generate RSS feed
rss := generate_rss(site)
write_file(fmt.tprintf("%s/index.xml", site.output_dir), rss)
@@ -205,7 +274,7 @@ render_site :: proc(site: ^Site) {
robots := fmt.aprintf("User-agent: *\nAllow: /\nSitemap: %s/sitemap.xml\n", site.base_url)
write_file(fmt.tprintf("%s/robots.txt", site.output_dir), robots)
- total := len(pages) + 1
+ total := len(pages) + len(sections)
if !has_home {
total += 1
}
@@ -219,7 +288,7 @@ render_page_html :: proc(
partials: map[string]mustache.Template,
base: Base_Data,
) -> string {
- is_article := page.type == .Post
+ is_article := page.section != ""
data := Page_Data {
base = base,
}
@@ -233,7 +302,7 @@ render_page_html :: proc(
data.og_title = strip_html_tags(page.title)
data.og_type = og_type(is_article)
data.is_article = is_article
- data.og_section = "posts"
+ data.og_section = page.section
data.og_published = page.date
return render_template(content_tpl, data, partials)
}
@@ -248,7 +317,7 @@ render_home_html :: proc(
list_pages := make([dynamic]Page_Context)
defer delete(list_pages)
for page in site.pages {
- if page.type == .Home {
+ if page._is_index {
continue
}
append(&list_pages, build_page_context(page))
@@ -267,8 +336,11 @@ render_home_html :: proc(
return render_template(content_tpl, data, partials)
}
-render_posts_html :: proc(
+render_section :: proc(
site: ^Site,
+ section: string,
+ section_index: Page,
+ has_index: bool,
content_tpl: mustache.Template,
partials: map[string]mustache.Template,
base: Base_Data,
@@ -277,7 +349,7 @@ render_posts_html :: proc(
defer delete(year_sections)
current_year := ""
for page in site.pages {
- if page.type != .Post {
+ if page.section != section || page._is_index {
continue
}
year := get_year(page.date)
@@ -288,13 +360,21 @@ render_posts_html :: proc(
append(&year_sections[len(year_sections) - 1].posts, build_page_context(page))
}
- data := Posts_Data {
+ data := Section_Data {
base = base,
}
- data.title = fmt.tprintf("Posts | %s", site.title)
+ if has_index {
+ data.body = section_index.body_html
+ data.page_title = section_index.title
+ data.title = fmt.tprintf("%s | %s", section_index.title, site.title)
+ data.og_title = section_index.title
+ } else {
+ data.page_title = capitalize(section)
+ data.title = fmt.tprintf("%s | %s", capitalize(section), site.title)
+ data.og_title = capitalize(section)
+ }
data.year_sections = year_sections
- data.og_url = fmt.tprintf("%s/posts/", site.base_url)
- data.og_title = "Posts"
+ data.og_url = fmt.tprintf("%s/%s/", site.base_url, section)
data.og_type = "website"
data.is_article = false
return render_template(content_tpl, data, partials)