#!/usr/bin/env node
// Generates public/rss.xml from the blog content directory, so the feed can
// never drift out of sync with what is actually published (one .md file in
// src/pages/blog/content is the whole publishing contract).
// Run via `npm run gen:rss`, or automatically as part of `prebuild`.
import { writeFileSync, readdirSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, '..')
const SITE_URL = 'https://gctrl.tech'
// Same minimal frontmatter contract as src/pages/blog/registry.ts — parsed by
// hand here too rather than pulling in a YAML dependency for four keys.
function parseFrontmatter(raw) {
const meta = {}
const block = raw.match(/^---\n([\s\S]*?)\n---/)?.[1]
if (!block) return meta
for (const line of block.split('\n')) {
const idx = line.indexOf(':')
if (idx === -1) continue
const key = line.slice(0, idx).trim()
let value = line.slice(idx + 1).trim()
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1)
if (key) meta[key] = value
}
return meta
}
function esc(s) {
return s
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
}
// RSS 2.0 requires RFC 822 dates; toUTCString() emits the RFC 1123 form
// ("Wed, 05 Aug 2026 00:00:00 GMT"), which validators accept.
function rfc822(iso) {
const d = new Date(`${iso}T00:00:00Z`)
return Number.isNaN(d.getTime()) ? new Date(0).toUTCString() : d.toUTCString()
}
const blogDir = path.join(root, 'src/pages/blog/content')
let posts = []
try {
posts = readdirSync(blogDir)
.filter((f) => f.endsWith('.md'))
.map((f) => {
const slug = f.replace(/\.md$/, '')
const meta = parseFrontmatter(readFileSync(path.join(blogDir, f), 'utf8'))
return {
slug,
title: meta['title'] ?? slug,
date: meta['date'] ?? '1970-01-01',
description: meta['description'] ?? '',
}
})
// Same draft rule as the registry: no real title or date means unpublished.
.filter((p) => p.title !== p.slug && p.date !== '1970-01-01')
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : a.slug.localeCompare(b.slug)))
} catch {
// no blog content yet - fine
}
const items = posts
.map(
(p) => ` ${esc(p.title)}
${SITE_URL}/blog/${p.slug}
${SITE_URL}/blog/${p.slug}${esc(p.description)}${rfc822(p.date)}`,
)
.join('\n')
const xml = `
GCTRL Blog
${SITE_URL}/blog
Engineering notes on self-hosted knowledge graphs, GraphRAG, sovereign AI memory and compliance.en
${items}
`
const outPath = path.join(root, 'public/rss.xml')
writeFileSync(outPath, xml)
console.log(`[gen-rss] wrote ${outPath} with ${posts.length} items`)