diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts index 3050860..05680dc 100644 --- a/apps/web/app/sitemap.ts +++ b/apps/web/app/sitemap.ts @@ -1,37 +1,59 @@ +import { execFile } from 'node:child_process' import { stat } from 'node:fs/promises' import { join } from 'node:path' +import { promisify } from 'node:util' import type { MetadataRoute } from 'next' import { listDocs } from '@/lib/docs' import { SITE } from '@/lib/site' +const run = promisify(execFile) + /** * Every URL used to carry `new Date()`, so all eighteen shared one build - * timestamp. A lastmod that changes on every deploy and is identical across the - * site says nothing about what actually changed, and crawlers discount it — - * which is worse than sending none, because it costs a field and buys nothing. + * timestamp. A lastmod that is identical site-wide and changes on every deploy + * says nothing about what actually changed, so crawlers discount it — which is + * worse than sending none, because it costs a field and buys nothing. * - * The real date is on disk. Doc pages are rendered from the repository's own - * `docs/*.md`, so that file's mtime *is* when the page last changed; the rest - * are their own source files. Falls back to the build time only when a stat - * fails, which should not happen but must not break the sitemap if it does. + * The date has to come from git, not from the filesystem. An earlier version of + * this read mtimes, which is correct on a working tree and useless in CI: a + * clone stamps every file with the checkout time, so in production all eighteen + * collapsed back to a single date and the fix silently did nothing. mtime is + * kept only as a fallback for a build with no git history, where it is no worse + * than the build clock it replaces. */ const ROOT = join(process.cwd(), '..', '..') const buildTime = new Date() -async function modified(...relative: string[]): Promise { - const times = await Promise.all( - relative.map(async (path) => { - try { - return (await stat(join(ROOT, path))).mtime - } catch { - return null - } - }), +/** When git last changed this path, or null if git cannot say. */ +async function committed(path: string): Promise { + try { + const { stdout } = await run('git', ['log', '-1', '--format=%cI', '--', path], { cwd: ROOT }) + const iso = stdout.trim() + if (!iso) return null + const date = new Date(iso) + return Number.isNaN(date.getTime()) ? null : date + } catch { + // No git, no history, or a shallow clone that does not reach this commit. + return null + } +} + +async function touched(path: string): Promise { + try { + return (await stat(join(ROOT, path))).mtime + } catch { + return null + } +} + +/** The newest real date across the sources a page is built from. */ +async function modified(...paths: string[]): Promise { + const dates = await Promise.all( + paths.map(async (path) => (await committed(path)) ?? (await touched(path))), ) - const known = times.filter((time): time is Date => time !== null) + const known = dates.filter((date): date is Date => date !== null) if (known.length === 0) return buildTime - // The newest of the sources a page is built from. - return new Date(Math.max(...known.map((time) => time.getTime()))) + return new Date(Math.max(...known.map((date) => date.getTime()))) } export default async function sitemap(): Promise { @@ -40,9 +62,9 @@ export default async function sitemap(): Promise { const [home, download, docsIndex, security, privacy] = await Promise.all([ modified(`${web}/app/page.tsx`, `${web}/app/layout.tsx`), - modified(`${web}/app/download/page.tsx`), + modified(`${web}/app/download/page.tsx`, `${web}/lib/releases.ts`), modified(`${web}/app/docs/page.tsx`), - modified(`${web}/app/security/page.tsx`), + modified(`${web}/app/security/page.tsx`, 'docs/security.md'), modified(`${web}/app/privacy/page.tsx`), ])