mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
Compare commits
11 Commits
454f90f5cb
...
565115c30c
| Author | SHA1 | Date | |
|---|---|---|---|
| 565115c30c | |||
| 07a262c3a1 | |||
| c78f3b50b6 | |||
| 307af709ff | |||
| 91c90e4408 | |||
| 1de6f58024 | |||
| c3574c6737 | |||
| 87bd2b828b | |||
| 44faebee6c | |||
| 93090afa5a | |||
| 54bc4ee322 |
@@ -0,0 +1,20 @@
|
||||
- [ ] add content-hash fingerprinting for tailwind cache busting.
|
||||
- [ ] create template language
|
||||
- Look at the source for 3 template languages that support mustache syntax,
|
||||
and see how they implement the tempaltes, then pick the best one. One of them
|
||||
should be Hugo / go.
|
||||
- [ ] Deal with the favicon
|
||||
- [ ] Icon support.
|
||||
- [ ] robots.txt?
|
||||
- [ ] Evaluate Tufte CSS — borrow sidenote CSS or replace TailwindCSS entirely
|
||||
- Option B: Steal Tufte's sidenote/margin-note CSS (adapt for dark theme), keep TailwindCSS
|
||||
- Option C: Full Tufte CSS — drop TailwindCSS, no build step, semantic HTML, customize for dark theme + Roboto
|
||||
- Our sidenote HTML pattern already matches Tufte's exactly
|
||||
- [ ] GitHub alerts (`> [!CAUTION]` → styled blockquote) — 2 posts
|
||||
- [ ] Block attributes on code fences (`{ #ex-1 }`) — hello-world.md
|
||||
- [ ] Emoji shortcodes (`:shrug:` etc.) — 2 instances
|
||||
- [ ] include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin
|
||||
- [ ] Nix build integration — main flake runs thor + tailwindcss instead of Hugo
|
||||
- [ ] Copy-to-clipboard button on code blocks (was in Hugo's custom-head.html)
|
||||
- [ ] OpenGraph meta tags
|
||||
- [ ] utteranc.es comments on posts
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
package main
|
||||
|
||||
import cm "vendor:commonmark"
|
||||
|
||||
import "core:fmt"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
|
||||
Page_Type :: enum {
|
||||
Home,
|
||||
Post,
|
||||
Standalone,
|
||||
}
|
||||
|
||||
Page :: struct {
|
||||
type: Page_Type,
|
||||
slug: string,
|
||||
permalink: string,
|
||||
title: string,
|
||||
description: string,
|
||||
date: string,
|
||||
draft: bool,
|
||||
is_starred: bool,
|
||||
menu: string,
|
||||
body: string,
|
||||
body_html: string,
|
||||
bundle_dir: string,
|
||||
}
|
||||
|
||||
// walk_content reads the content directory and returns all non-draft pages
|
||||
// (or all pages if include_drafts is true).
|
||||
walk_content :: proc(content_path: string, include_drafts: bool) -> []Page {
|
||||
pages: [dynamic]Page
|
||||
|
||||
collect_home(&pages, content_path)
|
||||
collect_standalone(&pages, content_path)
|
||||
|
||||
posts_path := fmt.tprintf("%s/posts", content_path)
|
||||
if os.exists(posts_path) {
|
||||
collect_posts(&pages, posts_path)
|
||||
}
|
||||
|
||||
if !include_drafts {
|
||||
filtered: [dynamic]Page
|
||||
for &page in pages {
|
||||
if !page.draft {
|
||||
append(&filtered, page)
|
||||
}
|
||||
}
|
||||
delete(pages)
|
||||
return filtered[:]
|
||||
}
|
||||
|
||||
return pages[:]
|
||||
}
|
||||
|
||||
collect_home :: proc(pages: ^[dynamic]Page, content_path: string) {
|
||||
html_path := fmt.tprintf("%s/index.html", content_path)
|
||||
if os.exists(html_path) {
|
||||
page, ok := load_page(html_path, .Home, "")
|
||||
if ok {
|
||||
page.permalink = "/"
|
||||
append(pages, page)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
md_path := fmt.tprintf("%s/index.md", content_path)
|
||||
if os.exists(md_path) {
|
||||
page, ok := load_page(md_path, .Home, "")
|
||||
if ok {
|
||||
page.permalink = "/"
|
||||
append(pages, page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect_standalone :: proc(pages: ^[dynamic]Page, content_path: string) {
|
||||
entries, err := os.read_all_directory_by_path(content_path, context.allocator)
|
||||
if err != nil {
|
||||
fmt.eprintfln("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, .Standalone, slug)
|
||||
if ok {
|
||||
append(pages, page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect_posts :: proc(pages: ^[dynamic]Page, posts_path: string) {
|
||||
entries, err := os.read_all_directory_by_path(posts_path, context.allocator)
|
||||
if err != nil {
|
||||
fmt.eprintfln("thor: cannot read %s: %v", posts_path, err)
|
||||
return
|
||||
}
|
||||
defer os.file_info_slice_delete(entries, context.allocator)
|
||||
|
||||
for entry in entries {
|
||||
switch entry.type {
|
||||
case .Regular:
|
||||
if !is_content_file(entry.name) {
|
||||
continue
|
||||
}
|
||||
slug := strip_extension(entry.name)
|
||||
page, ok := load_page(entry.fullpath, .Post, slug)
|
||||
if ok {
|
||||
append(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)
|
||||
if ok {
|
||||
page.bundle_dir = entry.fullpath
|
||||
append(pages, page)
|
||||
}
|
||||
case .Undetermined, .Symlink, .Named_Pipe, .Socket, .Block_Device, .Character_Device:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load_page :: proc(
|
||||
file_path: string,
|
||||
page_type: Page_Type,
|
||||
slug: string,
|
||||
) -> (
|
||||
page: Page,
|
||||
ok: bool,
|
||||
) {
|
||||
data, err := os.read_entire_file_from_path(file_path, context.allocator)
|
||||
if err != nil {
|
||||
fmt.eprintfln("thor: cannot read %s: %v", file_path, err)
|
||||
return
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
fm, body, parsed := parse_frontmatter(content)
|
||||
if !parsed {
|
||||
fmt.eprintfln("thor: no frontmatter in %s", file_path)
|
||||
return
|
||||
}
|
||||
|
||||
page.type = page_type
|
||||
page.slug = slug
|
||||
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.body = strings.clone(body)
|
||||
|
||||
if strings.has_suffix(file_path, ".html") {
|
||||
page.body_html = strings.clone(body)
|
||||
} else {
|
||||
clean_body, defs := strip_definitions(body)
|
||||
html := cm.markdown_to_html_from_string(clean_body, {.Unsafe})
|
||||
page.body_html = inject_sidenotes(html, defs)
|
||||
}
|
||||
|
||||
switch page_type {
|
||||
case .Home:
|
||||
page.permalink = "/"
|
||||
case .Post:
|
||||
page.permalink = fmt.aprintf("/posts/%s/", slug)
|
||||
case .Standalone:
|
||||
page.permalink = fmt.aprintf("/%s/", slug)
|
||||
}
|
||||
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
is_content_file :: proc(name: string) -> bool {
|
||||
return strings.has_suffix(name, ".md") || strings.has_suffix(name, ".html")
|
||||
}
|
||||
|
||||
strip_extension :: proc(name: string) -> string {
|
||||
dot := strings.last_index(name, ".")
|
||||
if dot < 0 {
|
||||
return name
|
||||
}
|
||||
return name[:dot]
|
||||
}
|
||||
|
||||
// copy_static_assets copies non-content files from the content root to the output directory.
|
||||
copy_static_assets :: proc(content_path: string, output_dir: string) {
|
||||
entries, err := os.read_all_directory_by_path(content_path, context.allocator)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer os.file_info_slice_delete(entries, context.allocator)
|
||||
|
||||
for entry in entries {
|
||||
if entry.type != .Regular {
|
||||
continue
|
||||
}
|
||||
if is_content_file(entry.name) {
|
||||
continue
|
||||
}
|
||||
|
||||
dest := fmt.tprintf("%s/%s", output_dir, entry.name)
|
||||
if err := os.copy_file(dest, entry.fullpath); err != nil {
|
||||
fmt.eprintfln("thor: cannot copy %s: %v", entry.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import "core:fmt"
|
||||
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 {
|
||||
parts: [dynamic]string
|
||||
defer delete(parts)
|
||||
|
||||
append(&parts, fmt.aprintf(
|
||||
`<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>%s</title>
|
||||
<link>%s/</link>
|
||||
<description>%s</description>
|
||||
<language>en-us</language>
|
||||
<atom:link href="%s/index.xml" rel="self" type="application/rss+xml"/>`,
|
||||
xml_escape(site_title),
|
||||
base_url,
|
||||
xml_escape(site_desc),
|
||||
base_url,
|
||||
))
|
||||
|
||||
for page in pages {
|
||||
if page.type == .Home {
|
||||
continue
|
||||
}
|
||||
|
||||
pub_date := "Mon, 01 Jan 0001 00:00:00 +0000"
|
||||
if page.date != "" {
|
||||
pub_date = format_rfc822(page.date)
|
||||
}
|
||||
|
||||
append(&parts, fmt.aprintf(
|
||||
`<item>
|
||||
<title>%s</title>
|
||||
<link>%s%s</link>
|
||||
<pubDate>%s</pubDate>
|
||||
<guid>%s%s</guid>
|
||||
<description>%s</description>
|
||||
</item>
|
||||
`,
|
||||
xml_escape(page.title),
|
||||
base_url,
|
||||
page.permalink,
|
||||
pub_date,
|
||||
base_url,
|
||||
page.permalink,
|
||||
xml_escape(page.body_html),
|
||||
))
|
||||
}
|
||||
|
||||
append(&parts, "</channel>\n</rss>")
|
||||
|
||||
return strings.join(parts[:], "")
|
||||
}
|
||||
|
||||
generate_sitemap :: proc(pages: []Page, base_url: string) -> string {
|
||||
parts: [dynamic]string
|
||||
defer delete(parts)
|
||||
|
||||
append(
|
||||
&parts,
|
||||
`<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||
`,
|
||||
)
|
||||
|
||||
for page in pages {
|
||||
lastmod := ""
|
||||
if page.date != "" {
|
||||
lastmod = fmt.aprintf("<lastmod>%s</lastmod>", page.date)
|
||||
}
|
||||
append(&parts, fmt.aprintf(
|
||||
"<url><loc>%s%s</loc>%s</url>\n",
|
||||
base_url,
|
||||
page.permalink,
|
||||
lastmod,
|
||||
))
|
||||
}
|
||||
|
||||
// Posts list page
|
||||
posts_lastmod := ""
|
||||
for page in pages {
|
||||
if page.type == .Post && page.date > posts_lastmod {
|
||||
posts_lastmod = page.date
|
||||
}
|
||||
}
|
||||
posts_lm := ""
|
||||
if posts_lastmod != "" {
|
||||
posts_lm = fmt.aprintf("<lastmod>%s</lastmod>", posts_lastmod)
|
||||
}
|
||||
append(&parts, fmt.aprintf("<url><loc>%s/posts/</loc>%s</url>\n", base_url, posts_lm))
|
||||
|
||||
append(&parts, "</urlset>")
|
||||
|
||||
return strings.join(parts[:], "")
|
||||
}
|
||||
|
||||
format_rfc822 :: proc(iso: string) -> string {
|
||||
if len(iso) < 19 {
|
||||
return iso
|
||||
}
|
||||
|
||||
year := (int(iso[0]) - 0x30) * 1000 + (int(iso[1]) - 0x30) * 100 +
|
||||
(int(iso[2]) - 0x30) * 10 + (int(iso[3]) - 0x30)
|
||||
month := (int(iso[5]) - 0x30) * 10 + (int(iso[6]) - 0x30)
|
||||
day := (int(iso[8]) - 0x30) * 10 + (int(iso[9]) - 0x30)
|
||||
time := iso[11:19]
|
||||
|
||||
// Timezone: -04:00 → -0400
|
||||
tz := "+0000"
|
||||
if len(iso) >= 25 && (iso[19] == '+' || iso[19] == '-') {
|
||||
tz = fmt.tprintf("%c%s%s", iso[19], iso[20:22], iso[23:25])
|
||||
}
|
||||
|
||||
// Sakamoto's method for day of week (0=Sunday)
|
||||
t := [12]int{0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4}
|
||||
y := year
|
||||
if month < 3 {
|
||||
y -= 1
|
||||
}
|
||||
dow := (y + y / 4 - y / 100 + y / 400 + t[month - 1] + day) % 7
|
||||
|
||||
return fmt.aprintf(
|
||||
"%s, %d %s %d %s %s",
|
||||
WEEKDAYS[dow],
|
||||
day,
|
||||
MONTHS[month - 1],
|
||||
year,
|
||||
time,
|
||||
tz,
|
||||
)
|
||||
}
|
||||
|
||||
xml_escape :: proc(s: string) -> string {
|
||||
r, _ := strings.replace_all(s, "&", "&")
|
||||
r, _ = strings.replace_all(r, "<", "<")
|
||||
r, _ = strings.replace_all(r, ">", ">")
|
||||
return r
|
||||
}
|
||||
@@ -80,6 +80,7 @@
|
||||
|
||||
buildInputs = [
|
||||
pkgs.git
|
||||
pkgs.cmark
|
||||
];
|
||||
|
||||
doCheck = true;
|
||||
@@ -109,6 +110,7 @@
|
||||
buildInputs = with pkgs; [
|
||||
odin
|
||||
ols
|
||||
cmark
|
||||
|
||||
# IDE
|
||||
unstable.helix
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import cm "vendor:commonmark"
|
||||
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
|
||||
// strip_definitions scans markdown text for footnote definitions ([^id]: text),
|
||||
// removes them, and returns the cleaned text plus a map of id→definition.
|
||||
// Handles multi-line definitions with indented continuation lines.
|
||||
strip_definitions :: proc(body: string) -> (clean_body: string, defs: map[string]string) {
|
||||
defs = make(map[string]string)
|
||||
|
||||
lines := strings.split(body, "\n")
|
||||
output_lines: [dynamic]string
|
||||
defer delete(output_lines)
|
||||
|
||||
i := 0
|
||||
for i < len(lines) {
|
||||
line := lines[i]
|
||||
|
||||
id, def_text, is_def := parse_def_line(line)
|
||||
if !is_def {
|
||||
append(&output_lines, line)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Collect definition text (initial line + multi-line continuations)
|
||||
def_parts: [dynamic]string
|
||||
if def_text != "" {
|
||||
append(&def_parts, def_text)
|
||||
}
|
||||
|
||||
i += 1
|
||||
for i < len(lines) {
|
||||
next := lines[i]
|
||||
// Stop at blank lines
|
||||
if len(next) == 0 {
|
||||
break
|
||||
}
|
||||
// Stop at new footnote definitions
|
||||
_, _, is_new_def := parse_def_line(next)
|
||||
if is_new_def {
|
||||
break
|
||||
}
|
||||
// Include as continuation (trim indented lines)
|
||||
if is_indented(next) {
|
||||
append(&def_parts, strings.trim_left(next, " \t"))
|
||||
} else {
|
||||
append(&def_parts, next)
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
defs[id] = strings.join(def_parts[:], "\n")
|
||||
delete(def_parts)
|
||||
}
|
||||
|
||||
clean_body = strings.join(output_lines[:], "\n")
|
||||
return
|
||||
}
|
||||
|
||||
// parse_def_line checks if a line is a footnote definition: [^id]: text
|
||||
parse_def_line :: proc(line: string) -> (id: string, text: string, ok: bool) {
|
||||
if len(line) < 5 || line[0] != '[' || line[1] != '^' {
|
||||
return
|
||||
}
|
||||
|
||||
close := strings.index(line[2:], "]")
|
||||
if close < 0 {
|
||||
return
|
||||
}
|
||||
close += 2
|
||||
|
||||
if close + 1 >= len(line) || line[close + 1] != ':' {
|
||||
return
|
||||
}
|
||||
|
||||
id = line[2:close]
|
||||
text = strings.trim_left(line[close + 2:], " \t")
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
is_indented :: proc(line: string) -> bool {
|
||||
if len(line) == 0 {
|
||||
return false
|
||||
}
|
||||
return line[0] == ' ' || line[0] == '\t'
|
||||
}
|
||||
|
||||
// inject_sidenotes finds [^id] references in rendered HTML and replaces them
|
||||
// with sidenote markup. Each definition is rendered through cmark separately.
|
||||
inject_sidenotes :: proc(html: string, defs: map[string]string) -> string {
|
||||
if len(defs) == 0 {
|
||||
return html
|
||||
}
|
||||
|
||||
parts: [dynamic]string
|
||||
defer delete(parts)
|
||||
|
||||
remaining := html
|
||||
|
||||
for {
|
||||
pos := strings.index(remaining, "[^")
|
||||
if pos < 0 {
|
||||
append(&parts, remaining)
|
||||
break
|
||||
}
|
||||
|
||||
// Append text before [^
|
||||
append(&parts, remaining[:pos])
|
||||
|
||||
close := strings.index(remaining[pos + 2:], "]")
|
||||
if close < 0 {
|
||||
append(&parts, remaining[pos:])
|
||||
break
|
||||
}
|
||||
|
||||
id := remaining[pos + 2 : pos + 2 + close]
|
||||
ref_end := pos + 2 + close + 1
|
||||
|
||||
def_text, found := defs[id]
|
||||
if !found {
|
||||
// No definition found, leave as literal text
|
||||
append(&parts, remaining[pos:ref_end])
|
||||
remaining = remaining[ref_end:]
|
||||
continue
|
||||
}
|
||||
|
||||
// Render definition through cmark for markdown support
|
||||
def_html := cm.markdown_to_html_from_string(def_text, {.Unsafe})
|
||||
def_html = strip_p_tags(def_html)
|
||||
|
||||
sidenote := fmt.aprintf(
|
||||
`<label for="fn-%s" class="margin-toggle sidenote-number"></label><input type="checkbox" id="fn-%s" class="margin-toggle"><span class="sidenote">%s</span>`,
|
||||
id,
|
||||
id,
|
||||
def_html,
|
||||
)
|
||||
append(&parts, sidenote)
|
||||
|
||||
remaining = remaining[ref_end:]
|
||||
}
|
||||
|
||||
return strings.join(parts[:], "")
|
||||
}
|
||||
|
||||
// strip_p_tags removes surrounding <p></p> if the HTML is a single paragraph.
|
||||
strip_p_tags :: proc(html: string) -> string {
|
||||
s := html
|
||||
if len(s) > 0 && s[len(s) - 1] == '\n' {
|
||||
s = s[:len(s) - 1]
|
||||
}
|
||||
if strings.has_prefix(s, "<p>") && strings.has_suffix(s, "</p>") {
|
||||
return s[3:len(s) - 4]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
|
||||
Frontmatter :: struct {
|
||||
title: string,
|
||||
description: string,
|
||||
date: string,
|
||||
publishDate: string,
|
||||
draft: bool,
|
||||
isStarred: bool,
|
||||
menu: string,
|
||||
}
|
||||
|
||||
// parse_frontmatter splits raw file content into a Frontmatter struct and the
|
||||
// remaining markdown body. The frontmatter is a JSON object delimited by { }
|
||||
// at the start of the file.
|
||||
parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok: bool) {
|
||||
body = content
|
||||
|
||||
if !strings.has_prefix(content, "{\n") {
|
||||
return
|
||||
}
|
||||
end := strings.index(content, "\n}\n")
|
||||
if end < 0 {
|
||||
return
|
||||
} else {
|
||||
end += 2
|
||||
}
|
||||
|
||||
json_str := content[:end + 1]
|
||||
body = strings.trim_left(content[end + 1:], " \t\r\n")
|
||||
|
||||
value, err := json.parse_string(json_str, spec = .JSON)
|
||||
if err != nil {
|
||||
fmt.eprintfln("thor: failed to parse frontmatter JSON: %v", err)
|
||||
return
|
||||
}
|
||||
defer json.destroy_value(value)
|
||||
|
||||
obj, ok2 := value.(json.Object)
|
||||
if !ok2 {
|
||||
return
|
||||
}
|
||||
|
||||
fm.title = json_get_string(obj, "title")
|
||||
fm.description = json_get_string(obj, "description")
|
||||
fm.date = json_get_string(obj, "date")
|
||||
fm.publishDate = json_get_string(obj, "publishDate")
|
||||
fm.draft = json_get_bool(obj, "draft")
|
||||
fm.isStarred = json_get_bool(obj, "isStarred")
|
||||
fm.menu = json_get_string(obj, "menu")
|
||||
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
json_get_string :: proc(obj: json.Object, key: string) -> string {
|
||||
if v, ok := obj[key]; ok {
|
||||
if s, ok2 := v.(json.String); ok2 {
|
||||
return strings.clone(s)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
json_get_bool :: proc(obj: json.Object, key: string) -> bool {
|
||||
if v, ok := obj[key]; ok {
|
||||
if b, ok2 := v.(json.Boolean); ok2 {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,8 +1,72 @@
|
||||
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"`,
|
||||
}
|
||||
|
||||
main :: proc() {
|
||||
fmt.println("Hellope, World!")
|
||||
opt: Options
|
||||
|
||||
flags.parse_or_exit(&opt, os.args, .Odin)
|
||||
|
||||
load_default_options(&opt)
|
||||
|
||||
pages := walk_content(opt.content_dir, opt.drafts)
|
||||
|
||||
render_site(pages, opt.content_dir, opt.output_dir, opt.base_url)
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
package main
|
||||
|
||||
import "core:fmt"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
|
||||
MONTHS: [12]string = {
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
}
|
||||
|
||||
render_site :: proc(pages: []Page, content_path: string, output_dir: string, base_url: string) {
|
||||
sort_pages_by_date(pages)
|
||||
|
||||
// Find home page and extract site title
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Copy static assets (avatar, favicon, etc.)
|
||||
copy_static_assets(content_path, output_dir)
|
||||
|
||||
// Render individual content pages (skip home)
|
||||
for page in pages {
|
||||
if page.type == .Home {
|
||||
continue
|
||||
}
|
||||
html := render_page_html(page, site_title)
|
||||
write_page(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)
|
||||
}
|
||||
|
||||
// Render posts list page
|
||||
posts_html := render_posts_html(pages, site_title)
|
||||
write_page(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)
|
||||
}
|
||||
|
||||
// Generate sitemap
|
||||
sitemap := generate_sitemap(pages, base_url)
|
||||
write_file(fmt.tprintf("%s/sitemap.xml", output_dir), sitemap)
|
||||
|
||||
total := len(pages) + 1
|
||||
if !has_home {
|
||||
total += 1
|
||||
}
|
||||
fmt.printfln("Rendered %d pages to %s", total, output_dir)
|
||||
}
|
||||
|
||||
render_page_html :: proc(page: Page, site_title: string) -> string {
|
||||
body := fmt.aprintf(
|
||||
`<main>
|
||||
<article class="prose">
|
||||
<h1>%s</h1>
|
||||
%s %s
|
||||
</article>
|
||||
</main>
|
||||
`,
|
||||
page.title,
|
||||
render_date(page),
|
||||
page.body_html,
|
||||
)
|
||||
title := fmt.tprintf("%s | %s", page.title, site_title)
|
||||
return render_chrome(title, body)
|
||||
}
|
||||
|
||||
render_home_html :: proc(home: Page, pages: []Page, site_title: string) -> string {
|
||||
items: [dynamic]string
|
||||
defer delete(items)
|
||||
|
||||
for page in pages {
|
||||
if page.type == .Home {
|
||||
continue
|
||||
}
|
||||
append(&items, render_post_item(page))
|
||||
}
|
||||
|
||||
post_list := strings.join(items[:], "\n")
|
||||
|
||||
body := fmt.aprintf(
|
||||
`<main>
|
||||
<header class="text-center mb-28">
|
||||
%s </header>
|
||||
<ul>
|
||||
%s
|
||||
</ul>
|
||||
</main>
|
||||
`,
|
||||
home.body_html,
|
||||
post_list,
|
||||
)
|
||||
|
||||
return render_chrome(site_title, body)
|
||||
}
|
||||
|
||||
render_posts_html :: proc(pages: []Page, site_title: string) -> string {
|
||||
parts: [dynamic]string
|
||||
defer delete(parts)
|
||||
|
||||
append(&parts, "<main>\n")
|
||||
append(&parts, ` <h1 class="text-center">Posts</h1>` + "\n")
|
||||
|
||||
current_year := ""
|
||||
open := false
|
||||
for page in pages {
|
||||
if page.type != .Post {
|
||||
continue
|
||||
}
|
||||
year := get_year(page.date)
|
||||
if year != current_year {
|
||||
if open {
|
||||
append(&parts, " </ul>\n </section>\n")
|
||||
}
|
||||
open = true
|
||||
current_year = year
|
||||
append(
|
||||
&parts,
|
||||
fmt.aprintf(" <section>\n <h2>%s</h2>\n <hr class=\"text-slate-800 mb-1\">\n <ul>\n", year),
|
||||
)
|
||||
}
|
||||
append(&parts, render_post_item(page))
|
||||
append(&parts, "\n")
|
||||
}
|
||||
if open {
|
||||
append(&parts, " </ul>\n </section>\n")
|
||||
}
|
||||
append(&parts, "</main>\n")
|
||||
|
||||
body := strings.join(parts[:], "")
|
||||
title := fmt.tprintf("Posts | %s", site_title)
|
||||
return render_chrome(title, body)
|
||||
}
|
||||
|
||||
render_chrome :: proc(page_title: string, body: string) -> string {
|
||||
return fmt.aprintf(
|
||||
`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>%s</title>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
||||
<script src="/js/main.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
%s%s<footer>
|
||||
<a href="https://github.com/sbrow" target="_blank" rel="noopener noreferrer me" title="Github">GitHub</a>
|
||||
<a href="/index.xml" target="_blank" rel="noopener noreferrer me" title="Rss">RSS</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><small>©</small> 2026</p>
|
||||
</footer>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<script>hljs.highlightAll();</script>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
page_title,
|
||||
HEADER,
|
||||
body,
|
||||
)
|
||||
}
|
||||
|
||||
HEADER :: `
|
||||
<header>
|
||||
<nav>
|
||||
<ul>
|
||||
<li class="mr-auto"><a href="/">Home</a></li>
|
||||
<li><a href="/ideas/">Ideas</a></li>
|
||||
<li><a href="/posts/">Posts</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
`
|
||||
|
||||
render_post_item :: proc(page: Page) -> string {
|
||||
date_html := ""
|
||||
if page.date != "" {
|
||||
date_html = fmt.aprintf(
|
||||
"<time datetime=\"%s\">%s</time>",
|
||||
page.date,
|
||||
format_date(page.date),
|
||||
)
|
||||
}
|
||||
star := ""
|
||||
if page.is_starred {
|
||||
star = `<span class="text-yellow-500 mr-2">★</span>`
|
||||
}
|
||||
return fmt.aprintf(
|
||||
` <li class="flex justify-between"><a href="%s">%s</a><span>%s%s</span></li>`,
|
||||
page.permalink,
|
||||
page.title,
|
||||
star,
|
||||
date_html,
|
||||
)
|
||||
}
|
||||
|
||||
render_date :: proc(page: Page) -> string {
|
||||
if page.date == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.aprintf(` <time datetime="%s">%s</time>` + "\n", page.date, format_date(page.date))
|
||||
}
|
||||
|
||||
format_date :: proc(iso: string) -> string {
|
||||
if len(iso) < 10 {
|
||||
return iso
|
||||
}
|
||||
year := iso[:4]
|
||||
month_num := (int(iso[5]) - 0x30) * 10 + (int(iso[6]) - 0x30)
|
||||
day_num := (int(iso[8]) - 0x30) * 10 + (int(iso[9]) - 0x30)
|
||||
|
||||
if month_num < 1 || month_num > 12 {
|
||||
return iso[:10]
|
||||
}
|
||||
|
||||
return fmt.aprintf("%d %s %s", day_num, MONTHS[month_num - 1], year)
|
||||
}
|
||||
|
||||
get_year :: proc(iso: string) -> string {
|
||||
if len(iso) < 4 {
|
||||
return ""
|
||||
}
|
||||
return iso[:4]
|
||||
}
|
||||
|
||||
sort_pages_by_date :: proc(pages: []Page) {
|
||||
for i in 1..<len(pages) {
|
||||
key := pages[i]
|
||||
j := i - 1
|
||||
for j >= 0 && pages[j].date < key.date {
|
||||
pages[j + 1] = pages[j]
|
||||
j -= 1
|
||||
}
|
||||
pages[j + 1] = key
|
||||
}
|
||||
}
|
||||
|
||||
write_page :: proc(output_dir: string, permalink: string, html: string) {
|
||||
rel := permalink
|
||||
if len(rel) > 0 && rel[0] == '/' {
|
||||
rel = rel[1:]
|
||||
}
|
||||
|
||||
dir := fmt.tprintf("%s/%s", output_dir, rel)
|
||||
if err := os.make_directory_all(dir); err != nil && err != .Exist {
|
||||
fmt.eprintfln("thor: cannot create %s: %v", dir, err)
|
||||
return
|
||||
}
|
||||
|
||||
file_path := fmt.tprintf("%s/index.html", dir)
|
||||
write_file(file_path, html)
|
||||
}
|
||||
|
||||
write_file :: proc(path: string, html: string) {
|
||||
if err := os.write_entire_file_from_string(path, html); err != nil {
|
||||
fmt.eprintfln("thor: cannot write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user