From 07a262c3a1cd653cb2a12e17af0f6209aa61e1c5 Mon Sep 17 00:00:00 2001
From: Spencer Brower <6729162+sbrow@users.noreply.github.com>
Date: Fri, 10 Jul 2026 20:36:06 -0400
Subject: [PATCH] feat: Added sitemap and RSS feed.
---
TODOS.md | 2 -
feed.odin | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++++
main.odin | 6 ++-
render.odin | 12 ++++-
4 files changed, 165 insertions(+), 4 deletions(-)
create mode 100644 feed.odin
diff --git a/TODOS.md b/TODOS.md
index 5ac13b2..91fee30 100644
--- a/TODOS.md
+++ b/TODOS.md
@@ -5,8 +5,6 @@
should be Hugo / go.
- [ ] Deal with the favicon
- [ ] Icon support.
-- [ ] RSS
-- [ ] sitemap
- [ ] 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
diff --git a/feed.odin b/feed.odin
new file mode 100644
index 0000000..f1db857
--- /dev/null
+++ b/feed.odin
@@ -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(
+ `
+
+
+%s
+%s/
+%s
+en-us
+`,
+ 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(
+ `-
+%s
+%s%s
+%s
+%s%s
+%s
+
+`,
+ xml_escape(page.title),
+ base_url,
+ page.permalink,
+ pub_date,
+ base_url,
+ page.permalink,
+ xml_escape(page.body_html),
+ ))
+ }
+
+ append(&parts, "\n")
+
+ return strings.join(parts[:], "")
+}
+
+generate_sitemap :: proc(pages: []Page, base_url: string) -> string {
+ parts: [dynamic]string
+ defer delete(parts)
+
+ append(
+ &parts,
+ `
+
+`,
+ )
+
+ for page in pages {
+ lastmod := ""
+ if page.date != "" {
+ lastmod = fmt.aprintf("%s", page.date)
+ }
+ append(&parts, fmt.aprintf(
+ "%s%s%s\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("%s", posts_lastmod)
+ }
+ append(&parts, fmt.aprintf("%s/posts/%s\n", base_url, posts_lm))
+
+ append(&parts, "")
+
+ 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
+}
diff --git a/main.odin b/main.odin
index 1508938..23a4b56 100644
--- a/main.odin
+++ b/main.odin
@@ -20,7 +20,7 @@ main :: proc() {
pages := walk_content(opt.content_dir, opt.drafts)
- render_site(pages, opt.content_dir, opt.output_dir)
+ render_site(pages, opt.content_dir, opt.output_dir, opt.base_url)
}
load_default_options :: proc(opt: ^Options) {
@@ -31,6 +31,10 @@ load_default_options :: proc(opt: ^Options) {
if opt.output_dir == "" {
opt.output_dir = "./public"
}
+
+ if opt.base_url == "" {
+ opt.base_url = "http://localhost:8080"
+ }
}
print_summary :: proc(pages: []Page) {
diff --git a/render.odin b/render.odin
index ceb1100..2551c9e 100644
--- a/render.odin
+++ b/render.odin
@@ -9,7 +9,7 @@ MONTHS: [12]string = {
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
}
-render_site :: proc(pages: []Page, content_path: string, output_dir: string) {
+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
@@ -48,6 +48,16 @@ render_site :: proc(pages: []Page, content_path: string, output_dir: string) {
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