/**
* Build-time sitemap generator.
*
* Run: npx tsx scripts/generate-sitemap.ts
*
* Reads SITE_URL from .env.local and outputs sitemap.xml to dist/client/.
* Includes static routes and dynamic blog post routes.
*/
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, parseFrontmatter } 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;
// Public routes that should be indexed
const PUBLIC_ROUTES = [
'/',
'/pricing',
'/contact',
'/login',
'/signup',
'/blog',
'/changelog',
'/docs',
'/privacy',
'/terms',
];
function loadSiteUrl(): string {
const value = getEnvValue('VITE_PUBLIC_URL') || getEnvValue('SITE_URL');
return (value || 'http://localhost:5173').replace(/\/$/, '');
}
/** Published (non-draft) slugs in a content directory. */
function getPublishedSlugs(dir: string): string[] {
try {
const contentDir = resolve(__dirname, '..', dir);
return readdirSync(contentDir)
.filter((f) => f.endsWith('.mdx'))
.filter((f) => !isDraft(parseFrontmatter(readFileSync(resolve(contentDir, f), 'utf-8')).fm))
.map((f) => f.replace('.mdx', ''));
} catch {
return [];
}
}
const getBlogSlugs = () => getPublishedSlugs('content/blog');
const getChangelogSlugs = () => getPublishedSlugs('content/changelog');
const getDocsSlugs = () => getPublishedSlugs('content/docs');
function generateSitemap(siteUrl: string): string {
const today = new Date().toISOString().split('T')[0];
const blogSlugs = getBlogSlugs();
const changelogSlugs = getChangelogSlugs();
const docsSlugs = getDocsSlugs();
const allRoutes = [
...PUBLIC_ROUTES,
...blogSlugs.map((s) => `/blog/${s}`),
...changelogSlugs.map((s) => `/changelog/${s}`),
...docsSlugs.map((s) => `/docs/${s}`),
];
const urls = allRoutes
.map(
(route) => `
${siteUrl}${route === '/' ? '' : route}
${today}
`,
)
.join('\n');
return `
${urls}
`;
}
const siteUrl = loadSiteUrl();
const sitemap = generateSitemap(siteUrl);
const outDir = resolve(__dirname, '../dist/client');
mkdirSync(outDir, { recursive: true });
writeFileSync(resolve(outDir, 'sitemap.xml'), sitemap);
// robots.txt is a static public/ file (Vite copies it verbatim, so its
// `Sitemap:` line still carries the create-time {{SITE_URL}} → localhost).
// Rewrite it to the real base URL now that we know it.
try {
const robotsPath = resolve(outDir, 'robots.txt');
const robots = readFileSync(robotsPath, 'utf-8');
const fixed = robots.replace(/^Sitemap:.*$/m, `Sitemap: ${siteUrl}/sitemap.xml`);
if (fixed !== robots) writeFileSync(robotsPath, fixed);
} catch {
// No robots.txt in dist — nothing to fix.
}
const blogCount = getBlogSlugs().length;
const changelogCount = getChangelogSlugs().length;
const docsCount = getDocsSlugs().length;
const totalUrls = PUBLIC_ROUTES.length + blogCount + changelogCount + docsCount;
console.log(`Sitemap generated with ${totalUrls} URLs (${blogCount} blog, ${changelogCount} changelog, ${docsCount} docs) → dist/client/sitemap.xml`);