|
| 1 | +import { execFileSync } from 'node:child_process'; |
| 2 | + |
| 3 | +import type { MetadataRoute } from 'next'; |
| 4 | + |
| 5 | +import { absoluteUrl } from '@/lib/site'; |
| 6 | +import { blog, source } from '@/lib/source'; |
| 7 | + |
| 8 | +/** |
| 9 | + * `/sitemap.xml`. |
| 10 | + * |
| 11 | + * Same story as `app/robots.ts`: with no route here the path fell through to |
| 12 | + * `app/[lang]/page.tsx` and answered `200 text/html`, so a sitemap submitted to |
| 13 | + * Search Console would have failed to parse. Every indexable URL is derived from |
| 14 | + * `source` / `blog` — never a hand-maintained list, which is guaranteed to rot the |
| 15 | + * first time a page is added. |
| 16 | + * |
| 17 | + * Static: generated once at build, so the `git log` below runs in the build |
| 18 | + * process and never in a request. |
| 19 | + */ |
| 20 | +export const dynamic = 'force-static'; |
| 21 | +export const revalidate = false; |
| 22 | + |
| 23 | +/** Repo-relative roots of the two MDX collections, matching `source.config.ts`. */ |
| 24 | +const DOCS_CONTENT_ROOT = 'content/docs'; |
| 25 | +const BLOG_CONTENT_ROOT = 'content/blog'; |
| 26 | + |
| 27 | +/** |
| 28 | + * `lastModified` comes from the git committer date of each source `.mdx`, not from |
| 29 | + * build time. Build time would restamp all 400+ pages on every deploy, which tells |
| 30 | + * a crawler that the whole site changed whenever anything did — a signal that gets |
| 31 | + * discounted precisely because it is never false. |
| 32 | + * |
| 33 | + * One `git log` pass over both collections covers every file (~1s over 11k commits |
| 34 | + * locally, measured), rather than one `git log` per page. |
| 35 | + * |
| 36 | + * When the date cannot be known — no git directory, or a clone shallow enough that |
| 37 | + * no commit in the window touched the file — the entry ships **without** |
| 38 | + * `lastModified`. `lastmod` is optional in the sitemap protocol, and omitting it is |
| 39 | + * the honest answer; substituting build time would reintroduce the exact lie this |
| 40 | + * function exists to avoid. Degrading silently is not on the table either: the |
| 41 | + * build prints a counted warning naming the remedy. |
| 42 | + */ |
| 43 | +let cachedGitDates: Map<string, Date> | undefined; |
| 44 | + |
| 45 | +function loadGitDates(): Map<string, Date> { |
| 46 | + if (cachedGitDates) return cachedGitDates; |
| 47 | + |
| 48 | + const dates = new Map<string, Date>(); |
| 49 | + const git = (args: string[], cwd: string) => |
| 50 | + execFileSync('git', args, { |
| 51 | + cwd, |
| 52 | + encoding: 'utf8', |
| 53 | + maxBuffer: 64 * 1024 * 1024, |
| 54 | + stdio: ['ignore', 'pipe', 'pipe'], |
| 55 | + }); |
| 56 | + |
| 57 | + try { |
| 58 | + const repoRoot = git(['rev-parse', '--show-toplevel'], process.cwd()).trim(); |
| 59 | + const shallow = git(['rev-parse', '--is-shallow-repository'], repoRoot).trim() === 'true'; |
| 60 | + if (shallow) { |
| 61 | + console.warn( |
| 62 | + '[sitemap] the checkout is a shallow clone; pages whose last commit predates the ' + |
| 63 | + 'clone depth will ship without <lastmod>. Deepen the clone to restore the dates.', |
| 64 | + ); |
| 65 | + } |
| 66 | + |
| 67 | + // `--format` marker cannot collide with a path: no file under content/ starts |
| 68 | + // with "commit-date:". `diff.relative=false` pins the printed paths to |
| 69 | + // repo-root-relative regardless of local git config. |
| 70 | + const log = git( |
| 71 | + [ |
| 72 | + '-c', |
| 73 | + 'diff.relative=false', |
| 74 | + 'log', |
| 75 | + '--format=commit-date:%cI', |
| 76 | + '--name-only', |
| 77 | + '--no-renames', |
| 78 | + '--', |
| 79 | + DOCS_CONTENT_ROOT, |
| 80 | + BLOG_CONTENT_ROOT, |
| 81 | + ], |
| 82 | + repoRoot, |
| 83 | + ); |
| 84 | + |
| 85 | + // `git log` is newest-first, so the first date seen for a path is its latest. |
| 86 | + let current: Date | undefined; |
| 87 | + for (const line of log.split('\n')) { |
| 88 | + if (line.startsWith('commit-date:')) { |
| 89 | + current = new Date(line.slice('commit-date:'.length)); |
| 90 | + continue; |
| 91 | + } |
| 92 | + if (!line || !current || dates.has(line)) continue; |
| 93 | + dates.set(line, current); |
| 94 | + } |
| 95 | + } catch (error) { |
| 96 | + console.warn( |
| 97 | + `[sitemap] could not read commit dates from git (${ |
| 98 | + error instanceof Error ? error.message : String(error) |
| 99 | + }); every entry will ship without <lastmod>.`, |
| 100 | + ); |
| 101 | + } |
| 102 | + |
| 103 | + cachedGitDates = dates; |
| 104 | + return dates; |
| 105 | +} |
| 106 | + |
| 107 | +type SitemapEntry = MetadataRoute.Sitemap[number]; |
| 108 | + |
| 109 | +export default function sitemap(): MetadataRoute.Sitemap { |
| 110 | + const dates = loadGitDates(); |
| 111 | + const undated: string[] = []; |
| 112 | + |
| 113 | + /** |
| 114 | + * `sourcePath` is repo-relative; `undefined` for routes with no MDX file behind |
| 115 | + * them (the homepage, the blog index), which are not counted as missing dates. |
| 116 | + */ |
| 117 | + const entry = (url: string, sourcePath?: string): SitemapEntry => { |
| 118 | + const lastModified = sourcePath ? dates.get(sourcePath) : undefined; |
| 119 | + if (sourcePath && !lastModified) undated.push(sourcePath); |
| 120 | + return lastModified ? { url: absoluteUrl(url), lastModified } : { url: absoluteUrl(url) }; |
| 121 | + }; |
| 122 | + |
| 123 | + const byUrl = (a: SitemapEntry, b: SitemapEntry) => a.url.localeCompare(b.url); |
| 124 | + |
| 125 | + // `getPages()` with no argument lists every language. English is the only one |
| 126 | + // today, and a future locale belongs in the sitemap under its own prefixed URL, |
| 127 | + // so leaving it unfiltered is the forward-correct spelling. |
| 128 | + const docs = source |
| 129 | + .getPages() |
| 130 | + .map((page) => entry(page.url, `${DOCS_CONTENT_ROOT}/${page.path}`)) |
| 131 | + .sort(byUrl); |
| 132 | + |
| 133 | + const posts = blog |
| 134 | + .getPages() |
| 135 | + .map((page) => entry(page.url, `${BLOG_CONTENT_ROOT}/${page.path}`)) |
| 136 | + .sort(byUrl); |
| 137 | + |
| 138 | + if (undated.length > 0) { |
| 139 | + console.warn( |
| 140 | + `[sitemap] ${undated.length} of ${docs.length + posts.length} content pages have no git ` + |
| 141 | + `commit date and ship without <lastmod>; first: ${undated.slice(0, 3).join(', ')}`, |
| 142 | + ); |
| 143 | + } |
| 144 | + |
| 145 | + // No `priority` or `changeFrequency`: Google ignores both, and inventing values |
| 146 | + // for 400+ pages would put numbers into a machine-readable surface that nothing |
| 147 | + // measured. |
| 148 | + return [entry('/'), ...docs, entry('/blog'), ...posts]; |
| 149 | +} |
0 commit comments