/**
* Build-time RSS feed generator.
*
* Run: npx tsx scripts/generate-rss.ts
*
* Reads blog post frontmatter from content/blog/*.mdx and generates an RSS feed.
*/
import { readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readEnvFiles } from './lib/dotenv.js';
import { isDraft } from './lib/seo-content';
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = resolve(__dirname, '..');
// Shell env wins (a blank export falls through), then .env.local over .env
// (readEnvFiles layers those two).
// Production builds receive VITE_PUBLIC_URL as a build arg (no .env.local in
// the image); locally this falls back to whatever the project's .env files hold.
const fileEnv = readEnvFiles(rootDir);
const getEnvValue = (key: string): string | null => process.env[key] || fileEnv[key] || null;
interface PostMeta {
slug: string;
title: string;
description: string;
date: string;
author?: string;
}
function loadSiteUrl(): string {
const value = getEnvValue('VITE_PUBLIC_URL') || getEnvValue('SITE_URL');
return (value || 'http://localhost:5173').replace(/\/$/, '');
}
/**
* Human-facing brand name for the RSS channel — package.json `name` is the
* npm slug, never the brand. Same resolution chain as generate-seo.ts:
* environment, .env.local, then the
in index.html (which survives
* into the Docker build, where .env.local is absent by design).
*/
function loadProjectName(): string {
const fromEnv = getEnvValue('VITE_PROJECT_DISPLAY_NAME') || getEnvValue('PROJECT_DISPLAY_NAME');
if (fromEnv) return fromEnv;
try {
const indexHtml = readFileSync(resolve(__dirname, '../src/client/index.html'), 'utf-8');
const title = indexHtml.match(/([^<]*)<\/title>/);
if (title?.[1]?.trim()) {
return title[1]
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/&/g, '&')
.trim();
}
} catch {
// index.html missing — fall through
}
return 'My SaaS';
}
function parseFrontmatter(content: string): Record {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return {};
const fm: Record = {};
for (const line of match[1].split('\n')) {
const [key, ...rest] = line.split(':');
if (key && rest.length) {
fm[key.trim()] = rest.join(':').trim().replace(/^["']|["']$/g, '');
}
}
return fm;
}
function loadPosts(): PostMeta[] {
const blogDir = resolve(__dirname, '../content/blog');
const files = readdirSync(blogDir).filter((f) => f.endsWith('.mdx'));
return files
.map((file): PostMeta | null => {
const content = readFileSync(resolve(blogDir, file), 'utf-8');
const fm = parseFrontmatter(content);
if (isDraft(fm)) return null;
return {
slug: file.replace('.mdx', ''),
title: fm.title || file.replace('.mdx', ''),
description: fm.description || '',
date: fm.date || new Date().toISOString(),
author: fm.author,
};
})
.filter((post): post is PostMeta => post !== null)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
function generateRss(posts: PostMeta[], siteUrl: string, projectName: string): string {
const items = posts
.map(
(post) => ` -
${escapeXml(post.title)}
${siteUrl}/blog/${post.slug}
${escapeXml(post.description)}
${new Date(post.date).toUTCString()}
${siteUrl}/blog/${post.slug}
`,
)
.join('\n');
return `
${escapeXml(projectName)} Blog
${siteUrl}/blog
Updates, guides, and insights from ${escapeXml(projectName)}.
en-us
${items}
`;
}
function escapeXml(str: string): string {
return str.replace(/&/g, '&').replace(//g, '>');
}
const siteUrl = loadSiteUrl();
const projectName = loadProjectName();
const posts = loadPosts();
const rss = generateRss(posts, siteUrl, projectName);
const outDir = resolve(__dirname, '../dist/client');
mkdirSync(outDir, { recursive: true });
writeFileSync(resolve(outDir, 'rss.xml'), rss);
console.log(`RSS feed generated with ${posts.length} posts → dist/client/rss.xml`);