Files
dashcaddy.net/scripts/generate-sitemap.mjs

142 lines
5.5 KiB
JavaScript

#!/usr/bin/env node
/**
* Generate public/sitemap.xml and public/feed.xml from the real route tree
* and the blog manifest.
*
* Runs automatically before `next build` (see the "prebuild" npm script), so
* the deployed sitemap can never drift from the pages that actually exist.
* Fails loudly if the blog manifest cannot be parsed, rather than silently
* emitting a partial sitemap.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const APP_DIR = path.join(root, "src", "app");
const MANIFEST = path.join(root, "src", "content", "blog", "manifest.ts");
const PUBLIC_DIR = path.join(root, "public");
const ORIGIN = "https://dashcaddy.net";
const SITE_DESCRIPTION =
"Self-hosting deep-dives, product updates, and practical guides to running your own infrastructure.";
// Pages that must never be advertised to crawlers.
const EXCLUDE = new Set(["/success/", "/404/", "/500/"]);
function walk(dir) {
const found = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) found.push(...walk(full));
else if (entry.name === "page.tsx") found.push(full);
}
return found;
}
function routeFor(file) {
const rel = path.relative(APP_DIR, path.dirname(file));
const parts = rel
.split(path.sep)
.filter((part) => part && part !== "." && !/^\(.*\)$/.test(part));
// Dynamic segments ([slug]) are expanded from data sources, not enumerated.
if (parts.some((part) => part.startsWith("["))) return null;
return "/" + parts.join("/") + (parts.length ? "/" : "");
}
function meta(route) {
if (route === "/") return { priority: "1.0", changefreq: "weekly" };
if (route === "/blog/" || route === "/docs/" || route === "/features/")
return { priority: "0.9", changefreq: "weekly" };
if (route === "/pricing/" || route === "/docs/catalog/")
return { priority: "0.8", changefreq: "weekly" };
if (route.startsWith("/docs/catalog/")) return { priority: "0.6", changefreq: "monthly" };
if (route.startsWith("/docs/")) return { priority: "0.7", changefreq: "monthly" };
if (route === "/about/") return { priority: "0.6", changefreq: "monthly" };
if (route.startsWith("/policies/")) return { priority: "0.3", changefreq: "yearly" };
return { priority: "0.5", changefreq: "monthly" };
}
function xmlEscape(value) {
return value
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// --- Blog manifest ---------------------------------------------------------
const manifestSrc = fs.readFileSync(MANIFEST, "utf8");
const POSTS = [
...manifestSrc.matchAll(
/\{\s*slug:\s*"([^"]+)",\s*title:\s*"((?:[^"\\]|\\.)*)",\s*description:\s*"((?:[^"\\]|\\.)*)",\s*date:\s*"([^"]+)"/g
),
].map((m) => ({ slug: m[1], title: m[2], description: m[3], date: m[4] }));
const manifestSlugCount = [...manifestSrc.matchAll(/slug:\s*"([^"]+)"/g)].length;
if (POSTS.length === 0 || POSTS.length !== manifestSlugCount) {
throw new Error(
`generate-sitemap: parsed ${POSTS.length} posts but manifest declares ${manifestSlugCount} slugs — refusing to emit a partial sitemap`
);
}
// --- Sitemap ---------------------------------------------------------------
const staticRoutes = [...new Set(walk(APP_DIR).map(routeFor).filter(Boolean))]
.filter((route) => !EXCLUDE.has(route))
.sort();
const urls = [
...staticRoutes.map((route) => ({ loc: ORIGIN + route, ...meta(route) })),
...POSTS.map((post) => ({
loc: `${ORIGIN}/blog/${post.slug}/`,
lastmod: post.date,
changefreq: "monthly",
priority: "0.8",
})),
].sort((a, b) => (a.loc < b.loc ? -1 : 1));
const sitemap =
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n` +
urls
.map(
(u) =>
` <url>\n <loc>${u.loc}</loc>` +
(u.lastmod ? `\n <lastmod>${u.lastmod}</lastmod>` : "") +
`\n <changefreq>${u.changefreq}</changefreq>\n <priority>${u.priority}</priority>\n </url>`
)
.join("\n") +
`\n</urlset>\n`;
// --- RSS feed --------------------------------------------------------------
const sortedPosts = [...POSTS].sort((a, b) => (a.date < b.date ? 1 : -1));
const feed =
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n <channel>\n` +
` <title>DashCaddy Blog</title>\n` +
` <link>${ORIGIN}/blog/</link>\n` +
` <description>${xmlEscape(SITE_DESCRIPTION)}</description>\n` +
` <language>en-us</language>\n` +
` <atom:link href="${ORIGIN}/feed.xml" rel="self" type="application/rss+xml" />\n` +
sortedPosts
.map(
(p) =>
` <item>\n` +
` <title>${xmlEscape(p.title)}</title>\n` +
` <link>${ORIGIN}/blog/${p.slug}/</link>\n` +
` <guid isPermaLink="true">${ORIGIN}/blog/${p.slug}/</guid>\n` +
` <pubDate>${new Date(`${p.date}T00:00:00Z`).toUTCString()}</pubDate>\n` +
` <description>${xmlEscape(p.description)}</description>\n` +
` </item>`
)
.join("\n") +
`\n </channel>\n</rss>\n`;
fs.mkdirSync(PUBLIC_DIR, { recursive: true });
fs.writeFileSync(path.join(PUBLIC_DIR, "sitemap.xml"), sitemap);
fs.writeFileSync(path.join(PUBLIC_DIR, "feed.xml"), feed);
console.log(
`generate-sitemap: ${urls.length} sitemap URLs (${staticRoutes.length} pages + ${POSTS.length} posts), ${sortedPosts.length} feed items`
);