#!/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, ">") .replace(/"/g, """); } // --- 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 = `\n` + `\n` + urls .map( (u) => ` \n ${u.loc}` + (u.lastmod ? `\n ${u.lastmod}` : "") + `\n ${u.changefreq}\n ${u.priority}\n ` ) .join("\n") + `\n\n`; // --- RSS feed -------------------------------------------------------------- const sortedPosts = [...POSTS].sort((a, b) => (a.date < b.date ? 1 : -1)); const feed = `\n` + `\n \n` + ` DashCaddy Blog\n` + ` ${ORIGIN}/blog/\n` + ` ${xmlEscape(SITE_DESCRIPTION)}\n` + ` en-us\n` + ` \n` + sortedPosts .map( (p) => ` \n` + ` ${xmlEscape(p.title)}\n` + ` ${ORIGIN}/blog/${p.slug}/\n` + ` ${ORIGIN}/blog/${p.slug}/\n` + ` ${new Date(`${p.date}T00:00:00Z`).toUTCString()}\n` + ` ${xmlEscape(p.description)}\n` + ` ` ) .join("\n") + `\n \n\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` );