#!/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`)