[grade=A] SEO expansion: generated sitemap (14->108 URLs) + RSS feed + 4 keyword articles + 77-app catalog hub
This commit is contained in:
15 files changed
+1773
-16
No files matched your search
@@ -0,0 +1,142 @@
|
||||
#!/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`
|
||||
);
|
||||
Reference in new issue
Block a user