Skip to content

Commit 816c171

Browse files
committed
Generate sitemap.xml at build time and advertise it in robots.txt
The live site serves a 404 for /sitemap.xml, so crawlers have to discover several hundred documentation and reference pages by link walking alone. A dependency-free Node script runs as the final build step, after the Next.js export and the Jekyll blogs build have both written into out/. It emits one sitemap entry per exported page (any directory containing an index.html, the shape produced by trailingSlash and Jekyll's pretty permalinks), skips non-page outputs (_next, deb, rpm, packages, images, 404), and appends the Sitemap directive to out/robots.txt, creating a minimal robots.txt when public/ does not provide one. It refuses to write an empty sitemap so a broken export fails the build instead of shipping silently.
1 parent dd4e786 commit 816c171

2 files changed

Lines changed: 115 additions & 1 deletion

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
"private": true,
55
"scripts": {
66
"dev": "npm run compile && next dev --turbopack",
7-
"build": "npm run build:next && npm run build:blogs",
7+
"build": "npm run build:next && npm run build:blogs && npm run build:sitemap",
88
"build:next": "npm run compile && next build --turbopack",
99
"build:blogs": "bundle exec jekyll build --config blogs/_config.yml --source blogs --destination out/blogs --baseurl \"${JEKYLL_BASE_PATH:-/blogs}\"",
10+
"build:sitemap": "node scripts/generate-sitemap.mjs",
1011
"compile": "npm run compile:clean && npm run compile:content && npm run compile:samples",
1112
"compile:content": "tsx scripts/compile-content.tsx",
1213
"compile:samples": "tsx scripts/compile-samples.tsx",

scripts/generate-sitemap.mjs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Generates out/sitemap.xml from the exported site and ensures out/robots.txt
2+
// advertises it. Runs as the last build step, after the Next.js export and the
3+
// Jekyll blogs build have both written into out/.
4+
//
5+
// Deliberately dependency-free (plain node:fs) so it runs in every
6+
// environment the build runs in, including ones without dev dependencies.
7+
8+
import fs from 'node:fs';
9+
import path from 'node:path';
10+
11+
const siteUrl = 'https://documentdb.io';
12+
const outDir = path.join(process.cwd(), 'out');
13+
14+
// Top-level build outputs that are not HTML pages: Next.js assets, the APT/RPM
15+
// package repositories, release metadata, and images. The packages workflow
16+
// adds deb/, rpm/, and packages/ after this script runs in the deploy job, but
17+
// they are excluded here too so local full builds behave identically.
18+
const excludedTopLevelDirectories = new Set([
19+
'_next',
20+
'deb',
21+
'rpm',
22+
'packages',
23+
'images',
24+
]);
25+
26+
function xmlEscape(value) {
27+
return value
28+
.replace(/&/g, '&')
29+
.replace(/</g, '&lt;')
30+
.replace(/>/g, '&gt;')
31+
.replace(/"/g, '&quot;')
32+
.replace(/'/g, '&apos;');
33+
}
34+
35+
/**
36+
* Walks the export directory and returns one entry per page, where a page is
37+
* any directory containing an index.html (the shape `trailingSlash: true` and
38+
* Jekyll's `permalink: pretty` both produce).
39+
*/
40+
function collectPages(directory, relativePath = '') {
41+
const pages = [];
42+
const indexFile = path.join(directory, 'index.html');
43+
44+
if (fs.existsSync(indexFile)) {
45+
pages.push({
46+
url: relativePath === '' ? '/' : `/${relativePath}/`,
47+
lastModified: fs.statSync(indexFile).mtime,
48+
});
49+
}
50+
51+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
52+
if (!entry.isDirectory()) continue;
53+
if (relativePath === '' && excludedTopLevelDirectories.has(entry.name)) continue;
54+
55+
pages.push(
56+
...collectPages(
57+
path.join(directory, entry.name),
58+
relativePath === '' ? entry.name : `${relativePath}/${entry.name}`,
59+
),
60+
);
61+
}
62+
63+
return pages;
64+
}
65+
66+
if (!fs.existsSync(outDir)) {
67+
console.error('out/ does not exist - run the site build before the sitemap step.');
68+
process.exit(1);
69+
}
70+
71+
const pages = collectPages(outDir).sort((a, b) => a.url.localeCompare(b.url));
72+
73+
if (pages.length === 0) {
74+
console.error('No pages found in out/ - refusing to write an empty sitemap.');
75+
process.exit(1);
76+
}
77+
78+
const sitemap = [
79+
'<?xml version="1.0" encoding="UTF-8"?>',
80+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
81+
...pages.map((page) =>
82+
[
83+
' <url>',
84+
` <loc>${xmlEscape(siteUrl + page.url)}</loc>`,
85+
` <lastmod>${page.lastModified.toISOString()}</lastmod>`,
86+
' </url>',
87+
].join('\n'),
88+
),
89+
'</urlset>',
90+
'',
91+
].join('\n');
92+
93+
fs.writeFileSync(path.join(outDir, 'sitemap.xml'), sitemap);
94+
95+
// Advertise the sitemap from robots.txt. public/robots.txt (when present) has
96+
// already been copied into out/ by the Next.js build; otherwise create a
97+
// minimal one.
98+
const robotsPath = path.join(outDir, 'robots.txt');
99+
const sitemapLine = `Sitemap: ${siteUrl}/sitemap.xml`;
100+
101+
if (fs.existsSync(robotsPath)) {
102+
const robots = fs.readFileSync(robotsPath, 'utf8');
103+
if (!robots.includes(sitemapLine)) {
104+
fs.writeFileSync(
105+
robotsPath,
106+
`${robots.trimEnd()}\n\n${sitemapLine}\n`,
107+
);
108+
}
109+
} else {
110+
fs.writeFileSync(robotsPath, `User-agent: *\nAllow: /\n\n${sitemapLine}\n`);
111+
}
112+
113+
console.log(`Wrote out/sitemap.xml with ${pages.length} URLs and advertised it in out/robots.txt.`);

0 commit comments

Comments
 (0)