diff --git a/TODOS.md b/TODOS.md
index 4911969..5fc85f3 100644
--- a/TODOS.md
+++ b/TODOS.md
@@ -15,3 +15,4 @@
- [ ] Fix copy-to-clipboard button x-axis positioning inside code blocks
- [ ] Content-hash fingerprinting for CSS and JS cache busting
- [ ] OpenGraph meta tags
+- [ ] Search up for `thor.json` files.
diff --git a/config.odin b/config.odin
new file mode 100644
index 0000000..07e5597
--- /dev/null
+++ b/config.odin
@@ -0,0 +1,67 @@
+package main
+
+import "core:encoding/json"
+import "core:fmt"
+import "core:os"
+import "core:strings"
+
+Social_Link :: struct {
+ name: string,
+ url: string,
+}
+
+Site_Config :: struct {
+ config_path: string `args:"name=config"`,
+ title: string,
+ description: string,
+ base_url: string `args:"name=base-url"`,
+ content_dir: string `args:"name=content"`,
+ output_dir: string `args:"name=output"`,
+ author: string,
+ social: []Social_Link,
+ drafts: bool `args:"name=drafts"`,
+}
+
+load_config :: proc(path: string) -> (config: Site_Config, ok: bool) {
+ data, err := os.read_entire_file_from_path(path, context.allocator)
+ if err != nil {
+ return
+ }
+
+ value, parse_err := json.parse_string(string(data), spec = .JSON)
+ if parse_err != nil {
+ fmt.eprintfln("thor: failed to parse %s: %v", path, parse_err)
+ return
+ }
+ defer json.destroy_value(value)
+
+ obj, obj_ok := value.(json.Object)
+ if !obj_ok {
+ return
+ }
+
+ config.title = json_get_string(obj, "title")
+ config.description = json_get_string(obj, "description")
+ config.base_url = json_get_string(obj, "base_url")
+ config.content_dir = json_get_string(obj, "content_dir")
+ config.output_dir = json_get_string(obj, "output_dir")
+ config.author = json_get_string(obj, "author")
+
+ if v, found := obj["social"]; found {
+ if arr, arr_ok := v.(json.Array); arr_ok {
+ social: [dynamic]Social_Link
+ for elem in arr {
+ if elem_obj, eo_ok := elem.(json.Object); eo_ok {
+ link := Social_Link{}
+ link.name = json_get_string(elem_obj, "name")
+ link.url = json_get_string(elem_obj, "url")
+ append(&social, link)
+ }
+ }
+ config.social = social[:]
+ }
+ }
+
+ ok = true
+ return
+}
diff --git a/content.odin b/content.odin
index f30dd0c..7d36194 100644
--- a/content.odin
+++ b/content.odin
@@ -156,8 +156,7 @@ load_page :: proc(
content := string(data)
fm, body, parsed := parse_frontmatter(content)
if !parsed {
- fmt.eprintfln("thor: no frontmatter in %s", file_path)
- return
+ body = strings.trim_left(content, " \t\r\n")
}
page.type = page_type
diff --git a/feed.odin b/feed.odin
index f1db857..1d02c62 100644
--- a/feed.odin
+++ b/feed.odin
@@ -5,12 +5,7 @@ import "core:strings"
WEEKDAYS: [7]string = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
-generate_rss :: proc(
- pages: []Page,
- site_title: string,
- site_desc: string,
- base_url: string,
-) -> string {
+generate_rss :: proc(pages: []Page, config: Site_Config) -> string {
parts: [dynamic]string
defer delete(parts)
@@ -23,10 +18,10 @@ generate_rss :: proc(
%s
en-us
`,
- xml_escape(site_title),
- base_url,
- xml_escape(site_desc),
- base_url,
+ xml_escape(config.title),
+ config.base_url,
+ xml_escape(config.description),
+ config.base_url,
))
for page in pages {
@@ -49,10 +44,10 @@ generate_rss :: proc(
`,
xml_escape(page.title),
- base_url,
+ config.base_url,
page.permalink,
pub_date,
- base_url,
+ config.base_url,
page.permalink,
xml_escape(page.body_html),
))
diff --git a/main.odin b/main.odin
index 23a4b56..baa3665 100644
--- a/main.odin
+++ b/main.odin
@@ -3,70 +3,41 @@ package main
import "core:flags"
import "core:fmt"
import "core:os"
-
-Options :: struct {
- base_url: string `args:"name=base-url" usage:"hostname (and path) to the root, e.g. https://example.com/"`,
- content_dir: string `args:"name=content" usage:"where to look for content files"`,
- output_dir: string `args:"name=output" usage:"where to export the completed site"`,
- drafts: bool `args:"name=drafts" usage:"Whether to render draft posts"`,
-}
+import "core:strings"
main :: proc() {
- opt: Options
+ // Phase 1: parse flags on empty config to get config_path
+ phase1: Site_Config
+ flags.parse_or_exit(&phase1, os.args, .Odin)
- flags.parse_or_exit(&opt, os.args, .Odin)
+ path := phase1.config_path
+ if path == "" {
+ path = "./thor.json"
+ }
- load_default_options(&opt)
+ // Phase 2: load config file
+ config, _ := load_config(path)
- pages := walk_content(opt.content_dir, opt.drafts)
+ // Phase 3: re-parse flags on loaded config (CLI overrides file values)
+ flags.parse_or_exit(&config, os.args, .Odin)
- render_site(pages, opt.content_dir, opt.output_dir, opt.base_url)
+ // Defaults relative to config file's directory
+ config_dir := "./"
+ if idx := strings.last_index(path, "/"); idx >= 0 {
+ config_dir = path[:idx]
+ }
+
+ if config.content_dir == "" {
+ config.content_dir = fmt.tprintf("%s/content", config_dir)
+ }
+ if config.output_dir == "" {
+ config.output_dir = fmt.tprintf("%s/public", config_dir)
+ }
+ if config.base_url == "" {
+ config.base_url = "http://localhost:8080"
+ }
+
+ pages := walk_content(config.content_dir, config.drafts)
+
+ render_site(pages, config)
}
-
-load_default_options :: proc(opt: ^Options) {
- if opt.content_dir == "" {
- opt.content_dir = "./content"
- }
-
- if opt.output_dir == "" {
- opt.output_dir = "./public"
- }
-
- if opt.base_url == "" {
- opt.base_url = "http://localhost:8080"
- }
-}
-
-print_summary :: proc(pages: []Page) {
- fmt.printfln("Pages: %d\n", len(pages))
-
- for page in pages {
- type_label := "post"
- if page.type == .Standalone {
- type_label = "standalone"
- }
-
- date_short := page.date
- if len(date_short) > 10 {
- date_short = date_short[:10]
- }
-
- badge := ""
- if page.draft {
- badge = " (draft)"
- } else if page.is_starred {
- badge = " *"
- }
-
- fmt.printfln(
- " [%-11s] %-30s %s %s%s (%d bytes html)",
- type_label,
- page.title,
- date_short,
- page.permalink,
- badge,
- len(page.body_html),
- )
- }
-}
-
diff --git a/render.odin b/render.odin
index c7d7cca..cc1bb08 100644
--- a/render.odin
+++ b/render.odin
@@ -9,19 +9,16 @@ MONTHS: [12]string = {
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
}
-render_site :: proc(pages: []Page, content_path: string, output_dir: string, base_url: string) {
+render_site :: proc(pages: []Page, config: Site_Config) {
sort_pages_by_date(pages)
- // Find home page and extract site title
+ // Find home page
home: Page
has_home := false
- site_title := "Site"
-
for page in pages {
if page.type == .Home {
home = page
has_home = true
- site_title = page.title
break
}
}
@@ -31,45 +28,43 @@ render_site :: proc(pages: []Page, content_path: string, output_dir: string, bas
if page.type == .Home {
continue
}
- html := render_page_html(page, site_title)
- write_page(output_dir, page.permalink, html)
+ html := render_page_html(page, config)
+ write_page(config.output_dir, page.permalink, html)
}
// Render home page
if has_home {
- home_html := render_home_html(home, pages, site_title)
- write_file(fmt.tprintf("%s/index.html", output_dir), home_html)
+ home_html := render_home_html(home, pages, config)
+ write_file(fmt.tprintf("%s/index.html", config.output_dir), home_html)
}
// Render posts list page
- posts_html := render_posts_html(pages, site_title)
- write_page(output_dir, "/posts/", posts_html)
+ posts_html := render_posts_html(pages, config)
+ write_page(config.output_dir, "/posts/", posts_html)
// Generate RSS feed
- if has_home {
- rss := generate_rss(pages, site_title, home.description, base_url)
- write_file(fmt.tprintf("%s/index.xml", output_dir), rss)
- }
+ rss := generate_rss(pages, config)
+ write_file(fmt.tprintf("%s/index.xml", config.output_dir), rss)
// Generate sitemap
- sitemap := generate_sitemap(pages, base_url)
- write_file(fmt.tprintf("%s/sitemap.xml", output_dir), sitemap)
+ sitemap := generate_sitemap(pages, config.base_url)
+ write_file(fmt.tprintf("%s/sitemap.xml", config.output_dir), sitemap)
// Copy static assets (avatar, favicon, etc.)
- copy_static_assets(content_path, output_dir)
+ copy_static_assets(config.content_dir, config.output_dir)
// Generate robots.txt
- robots := fmt.aprintf("User-agent: *\nAllow: /\nSitemap: %s/sitemap.xml\n", base_url)
- write_file(fmt.tprintf("%s/robots.txt", output_dir), robots)
+ robots := fmt.aprintf("User-agent: *\nAllow: /\nSitemap: %s/sitemap.xml\n", config.base_url)
+ write_file(fmt.tprintf("%s/robots.txt", config.output_dir), robots)
total := len(pages) + 1
if !has_home {
total += 1
}
- fmt.printfln("Rendered %d pages to %s", total, output_dir)
+ fmt.printfln("Rendered %d pages to %s", total, config.output_dir)
}
-render_page_html :: proc(page: Page, site_title: string) -> string {
+render_page_html :: proc(page: Page, config: Site_Config) -> string {
comments := ""
if page.type == .Post {
comments = `
@@ -95,11 +90,11 @@ render_page_html :: proc(page: Page, site_title: string) -> string {
page.body_html,
comments,
)
- title := fmt.tprintf("%s | %s", page.title, site_title)
- return render_chrome(title, body)
+ title := fmt.tprintf("%s | %s", page.title, config.title)
+ return render_chrome(title, body, config)
}
-render_home_html :: proc(home: Page, pages: []Page, site_title: string) -> string {
+render_home_html :: proc(home: Page, pages: []Page, config: Site_Config) -> string {
items: [dynamic]string
defer delete(items)
@@ -125,10 +120,10 @@ render_home_html :: proc(home: Page, pages: []Page, site_title: string) -> strin
post_list,
)
- return render_chrome(site_title, body)
+ return render_chrome(config.title, body, config)
}
-render_posts_html :: proc(pages: []Page, site_title: string) -> string {
+render_posts_html :: proc(pages: []Page, config: Site_Config) -> string {
parts: [dynamic]string
defer delete(parts)
@@ -162,12 +157,34 @@ render_posts_html :: proc(pages: []Page, site_title: string) -> string {
append(&parts, "\n")
body := strings.join(parts[:], "")
- title := fmt.tprintf("Posts | %s", site_title)
- return render_chrome(title, body)
+ title := fmt.tprintf("Posts | %s", config.title)
+ return render_chrome(title, body, config)
}
-render_chrome :: proc(page_title: string, body: string) -> string {
+render_chrome :: proc(page_title: string, body: string, config: Site_Config) -> string {
header := fmt.aprintf(HEADER, ICON_HOME)
+
+ // Build social links from config
+ social_parts: [dynamic]string
+ defer delete(social_parts)
+ for link in config.social {
+ append(
+ &social_parts,
+ fmt.aprintf(
+ `\n %s`,
+ link.url,
+ link.name,
+ social_icon(link.name),
+ ),
+ )
+ }
+ social_html := strings.join(social_parts[:], "")
+
+ copyright := "
© 2026
"
+ if config.author != "" {
+ copyright = fmt.aprintf("© 2026 %s
", config.author)
+ }
+
return fmt.aprintf(
`
@@ -181,12 +198,9 @@ render_chrome :: proc(page_title: string, body: string) -> string {
%s%s
+%s
@@ -196,11 +210,21 @@ render_chrome :: proc(page_title: string, body: string) -> string {
header,
body,
ICON_CHEVRON_UP,
- ICON_GITHUB,
- ICON_RSS,
+ social_html,
+ copyright,
)
}
+social_icon :: proc(name: string) -> string {
+ switch strings.to_lower(name) {
+ case "github":
+ return ICON_GITHUB
+ case "rss":
+ return ICON_RSS
+ }
+ return name
+}
+
HEADER :: `