diff --git a/scripts/rss.ts b/scripts/rss.ts new file mode 100644 index 0000000..fd93476 --- /dev/null +++ b/scripts/rss.ts @@ -0,0 +1,120 @@ +import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +export interface RssOptions { + siteUrl: string; + title: string; + description: string; + blogDir?: string; + outFile?: string; +} + +interface Meta { + slug: string; + title: string; + date: string; + description?: string; + tags?: string[]; + draft?: boolean; + content: string; +} + +function parseValue(value: string): string | string[] | boolean { + const t = value.trim(); + if (t === 'true') return true; + if (t === 'false') return false; + if (t.startsWith('[') && t.endsWith(']')) { + return t.slice(1, -1).split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean); + } + return t.replace(/^['"]|['"]$/g, ''); +} + +function parseFrontmatter(raw: string): { data: Record; content: string } { + if (!raw.startsWith('---')) return { data: {}, content: raw }; + const end = raw.indexOf('\n---', 3); + if (end === -1) return { data: {}, content: raw }; + const fm = raw.slice(3, end).trim(); + const content = raw.slice(end + 4).replace(/^\s*\n/, ''); + const data: Record = {}; + for (const line of fm.split('\n')) { + const sep = line.indexOf(':'); + if (sep === -1) continue; + data[line.slice(0, sep).trim()] = parseValue(line.slice(sep + 1)); + } + return { data, content }; +} + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +export function loadPosts(blogDir: string): Meta[] { + const dir = resolve(blogDir); + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter((f) => f.endsWith('.md')) + .map((file) => { + const raw = readFileSync(join(dir, file), 'utf8'); + const { data, content } = parseFrontmatter(raw); + const slug = file.replace(/\.md$/, ''); + return { + slug, + title: data.title ?? slug, + date: data.date ? new Date(data.date).toISOString() : new Date().toISOString(), + description: data.description, + tags: data.tags ?? [], + draft: data.draft ?? false, + content, + } as Meta; + }) + .filter((p) => !p.draft) + .sort((a, b) => b.date.localeCompare(a.date)); +} + +export function buildRss(opts: RssOptions): string { + const blogDir = opts.blogDir ?? 'src/content/blog'; + const posts = loadPosts(blogDir); + const site = opts.siteUrl.replace(/\/$/, ''); + const items = posts + .map((p) => { + const url = `${site}/blog/${p.slug}`; + return ` + ${esc(p.title)} + ${esc(url)} + ${esc(url)} + ${new Date(p.date).toUTCString()}${p.description ? `\n ${esc(p.description)}` : ''} + `; + }) + .join('\n'); + + return ` + + + ${esc(opts.title)} + ${esc(site)} + ${esc(opts.description)} + + en-us + ${new Date().toUTCString()} +${items} + +`; +} + +export function rssPlugin(opts: RssOptions) { + const generate = () => buildRss(opts); + return { + name: 'rss-feed', + configureServer(server: any) { + server.middlewares.use('/rss.xml', (_req: any, res: any) => { + res.setHeader('Content-Type', 'application/rss+xml; charset=utf-8'); + res.end(generate()); + }); + }, + closeBundle() { + const outDir = 'dist'; + if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }); + writeFileSync(join(outDir, 'rss.xml'), generate()); + }, + }; +} diff --git a/vite.config.ts b/vite.config.ts index a9b992f..b6cb8fa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react-swc"; import path from "path"; import { componentTagger } from "lovable-tagger"; +import { rssPlugin } from "./scripts/rss"; // https://vitejs.dev/config/ export default defineConfig(({ mode }) => ({ @@ -12,7 +13,15 @@ export default defineConfig(({ mode }) => ({ overlay: false, }, }, - plugins: [react(), mode === "development" && componentTagger()].filter(Boolean), + plugins: [ + react(), + mode === "development" && componentTagger(), + rssPlugin({ + siteUrl: "https://damaj.tech", + title: "Damaj — Blog", + description: "Writing on engineering, systems, and craft.", + }), + ].filter(Boolean), resolve: { alias: { "@": path.resolve(__dirname, "./src"),