mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
feat: Added config file support.
This commit is contained in:
@@ -15,3 +15,4 @@
|
|||||||
- [ ] Fix copy-to-clipboard button x-axis positioning inside code blocks
|
- [ ] Fix copy-to-clipboard button x-axis positioning inside code blocks
|
||||||
- [ ] Content-hash fingerprinting for CSS and JS cache busting
|
- [ ] Content-hash fingerprinting for CSS and JS cache busting
|
||||||
- [ ] OpenGraph meta tags
|
- [ ] OpenGraph meta tags
|
||||||
|
- [ ] Search up for `thor.json` files.
|
||||||
|
|||||||
+67
@@ -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
|
||||||
|
}
|
||||||
+1
-2
@@ -156,8 +156,7 @@ load_page :: proc(
|
|||||||
content := string(data)
|
content := string(data)
|
||||||
fm, body, parsed := parse_frontmatter(content)
|
fm, body, parsed := parse_frontmatter(content)
|
||||||
if !parsed {
|
if !parsed {
|
||||||
fmt.eprintfln("thor: no frontmatter in %s", file_path)
|
body = strings.trim_left(content, " \t\r\n")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
page.type = page_type
|
page.type = page_type
|
||||||
|
|||||||
@@ -5,12 +5,7 @@ import "core:strings"
|
|||||||
|
|
||||||
WEEKDAYS: [7]string = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
|
WEEKDAYS: [7]string = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
|
||||||
|
|
||||||
generate_rss :: proc(
|
generate_rss :: proc(pages: []Page, config: Site_Config) -> string {
|
||||||
pages: []Page,
|
|
||||||
site_title: string,
|
|
||||||
site_desc: string,
|
|
||||||
base_url: string,
|
|
||||||
) -> string {
|
|
||||||
parts: [dynamic]string
|
parts: [dynamic]string
|
||||||
defer delete(parts)
|
defer delete(parts)
|
||||||
|
|
||||||
@@ -23,10 +18,10 @@ generate_rss :: proc(
|
|||||||
<description>%s</description>
|
<description>%s</description>
|
||||||
<language>en-us</language>
|
<language>en-us</language>
|
||||||
<atom:link href="%s/index.xml" rel="self" type="application/rss+xml"/>`,
|
<atom:link href="%s/index.xml" rel="self" type="application/rss+xml"/>`,
|
||||||
xml_escape(site_title),
|
xml_escape(config.title),
|
||||||
base_url,
|
config.base_url,
|
||||||
xml_escape(site_desc),
|
xml_escape(config.description),
|
||||||
base_url,
|
config.base_url,
|
||||||
))
|
))
|
||||||
|
|
||||||
for page in pages {
|
for page in pages {
|
||||||
@@ -49,10 +44,10 @@ generate_rss :: proc(
|
|||||||
</item>
|
</item>
|
||||||
`,
|
`,
|
||||||
xml_escape(page.title),
|
xml_escape(page.title),
|
||||||
base_url,
|
config.base_url,
|
||||||
page.permalink,
|
page.permalink,
|
||||||
pub_date,
|
pub_date,
|
||||||
base_url,
|
config.base_url,
|
||||||
page.permalink,
|
page.permalink,
|
||||||
xml_escape(page.body_html),
|
xml_escape(page.body_html),
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -3,70 +3,41 @@ package main
|
|||||||
import "core:flags"
|
import "core:flags"
|
||||||
import "core:fmt"
|
import "core:fmt"
|
||||||
import "core:os"
|
import "core:os"
|
||||||
|
import "core:strings"
|
||||||
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"`,
|
|
||||||
}
|
|
||||||
|
|
||||||
main :: proc() {
|
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
+61
-37
@@ -9,19 +9,16 @@ MONTHS: [12]string = {
|
|||||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
"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)
|
sort_pages_by_date(pages)
|
||||||
|
|
||||||
// Find home page and extract site title
|
// Find home page
|
||||||
home: Page
|
home: Page
|
||||||
has_home := false
|
has_home := false
|
||||||
site_title := "Site"
|
|
||||||
|
|
||||||
for page in pages {
|
for page in pages {
|
||||||
if page.type == .Home {
|
if page.type == .Home {
|
||||||
home = page
|
home = page
|
||||||
has_home = true
|
has_home = true
|
||||||
site_title = page.title
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,45 +28,43 @@ render_site :: proc(pages: []Page, content_path: string, output_dir: string, bas
|
|||||||
if page.type == .Home {
|
if page.type == .Home {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
html := render_page_html(page, site_title)
|
html := render_page_html(page, config)
|
||||||
write_page(output_dir, page.permalink, html)
|
write_page(config.output_dir, page.permalink, html)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render home page
|
// Render home page
|
||||||
if has_home {
|
if has_home {
|
||||||
home_html := render_home_html(home, pages, site_title)
|
home_html := render_home_html(home, pages, config)
|
||||||
write_file(fmt.tprintf("%s/index.html", output_dir), home_html)
|
write_file(fmt.tprintf("%s/index.html", config.output_dir), home_html)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render posts list page
|
// Render posts list page
|
||||||
posts_html := render_posts_html(pages, site_title)
|
posts_html := render_posts_html(pages, config)
|
||||||
write_page(output_dir, "/posts/", posts_html)
|
write_page(config.output_dir, "/posts/", posts_html)
|
||||||
|
|
||||||
// Generate RSS feed
|
// Generate RSS feed
|
||||||
if has_home {
|
rss := generate_rss(pages, config)
|
||||||
rss := generate_rss(pages, site_title, home.description, base_url)
|
write_file(fmt.tprintf("%s/index.xml", config.output_dir), rss)
|
||||||
write_file(fmt.tprintf("%s/index.xml", output_dir), rss)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate sitemap
|
// Generate sitemap
|
||||||
sitemap := generate_sitemap(pages, base_url)
|
sitemap := generate_sitemap(pages, config.base_url)
|
||||||
write_file(fmt.tprintf("%s/sitemap.xml", output_dir), sitemap)
|
write_file(fmt.tprintf("%s/sitemap.xml", config.output_dir), sitemap)
|
||||||
|
|
||||||
// Copy static assets (avatar, favicon, etc.)
|
// 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
|
// Generate robots.txt
|
||||||
robots := fmt.aprintf("User-agent: *\nAllow: /\nSitemap: %s/sitemap.xml\n", base_url)
|
robots := fmt.aprintf("User-agent: *\nAllow: /\nSitemap: %s/sitemap.xml\n", config.base_url)
|
||||||
write_file(fmt.tprintf("%s/robots.txt", output_dir), robots)
|
write_file(fmt.tprintf("%s/robots.txt", config.output_dir), robots)
|
||||||
|
|
||||||
total := len(pages) + 1
|
total := len(pages) + 1
|
||||||
if !has_home {
|
if !has_home {
|
||||||
total += 1
|
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 := ""
|
comments := ""
|
||||||
if page.type == .Post {
|
if page.type == .Post {
|
||||||
comments = `
|
comments = `
|
||||||
@@ -95,11 +90,11 @@ render_page_html :: proc(page: Page, site_title: string) -> string {
|
|||||||
page.body_html,
|
page.body_html,
|
||||||
comments,
|
comments,
|
||||||
)
|
)
|
||||||
title := fmt.tprintf("%s | %s", page.title, site_title)
|
title := fmt.tprintf("%s | %s", page.title, config.title)
|
||||||
return render_chrome(title, body)
|
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
|
items: [dynamic]string
|
||||||
defer delete(items)
|
defer delete(items)
|
||||||
|
|
||||||
@@ -125,10 +120,10 @@ render_home_html :: proc(home: Page, pages: []Page, site_title: string) -> strin
|
|||||||
post_list,
|
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
|
parts: [dynamic]string
|
||||||
defer delete(parts)
|
defer delete(parts)
|
||||||
|
|
||||||
@@ -162,12 +157,34 @@ render_posts_html :: proc(pages: []Page, site_title: string) -> string {
|
|||||||
append(&parts, "</main>\n")
|
append(&parts, "</main>\n")
|
||||||
|
|
||||||
body := strings.join(parts[:], "")
|
body := strings.join(parts[:], "")
|
||||||
title := fmt.tprintf("Posts | %s", site_title)
|
title := fmt.tprintf("Posts | %s", config.title)
|
||||||
return render_chrome(title, body)
|
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)
|
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 <a href="%s" target="_blank" rel="noopener noreferrer me" title="%s">%s</a>`,
|
||||||
|
link.url,
|
||||||
|
link.name,
|
||||||
|
social_icon(link.name),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
social_html := strings.join(social_parts[:], "")
|
||||||
|
|
||||||
|
copyright := "<p><small>©</small> 2026</p>"
|
||||||
|
if config.author != "" {
|
||||||
|
copyright = fmt.aprintf("<p><small>©</small> 2026 %s</p>", config.author)
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.aprintf(
|
return fmt.aprintf(
|
||||||
`<!DOCTYPE html>
|
`<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -181,12 +198,9 @@ render_chrome :: proc(page_title: string, body: string) -> string {
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
%s%s<footer>
|
%s%s<footer>
|
||||||
<a class="goto-top opacity-0" href="#">%s</a>
|
<a class="goto-top opacity-0" href="#">%s</a>%s
|
||||||
<a href="https://github.com/sbrow" target="_blank" rel="noopener noreferrer me" title="Github">%s</a>
|
|
||||||
<a href="/index.xml" target="_blank" rel="noopener noreferrer me" title="Rss">%s</a>
|
|
||||||
<p class="pt-5 prose">Proudly built with <a href="https://odin-lang.org/">Odin</a> and <a href="https://tailwindcss.com/">Tailwindcss</a></p>
|
<p class="pt-5 prose">Proudly built with <a href="https://odin-lang.org/">Odin</a> and <a href="https://tailwindcss.com/">Tailwindcss</a></p>
|
||||||
<p><small>©</small> 2026</p>
|
%s</footer>
|
||||||
</footer>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||||
<script>hljs.highlightAll();</script>
|
<script>hljs.highlightAll();</script>
|
||||||
</body>
|
</body>
|
||||||
@@ -196,11 +210,21 @@ render_chrome :: proc(page_title: string, body: string) -> string {
|
|||||||
header,
|
header,
|
||||||
body,
|
body,
|
||||||
ICON_CHEVRON_UP,
|
ICON_CHEVRON_UP,
|
||||||
ICON_GITHUB,
|
social_html,
|
||||||
ICON_RSS,
|
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 :: `
|
HEADER :: `
|
||||||
<header>
|
<header>
|
||||||
<nav>
|
<nav>
|
||||||
|
|||||||
Reference in New Issue
Block a user