142 lines
5.5 KiB
JavaScript
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, "<")
|
|
.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 =
|
|
`<?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`
|
|
); |