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 :: `