Skip to content

Commit 071ec4e

Browse files
os-zhuangclaude
andauthored
feat(docs): serve a real /robots.txt and /sitemap.xml (#12253)
Both paths had no route, so `app/[lang]/page.tsx` matched them as `lang = "robots.txt"` / `lang = "sitemap.xml"` and answered 200 text/html with the homepage: a crawler asking for crawl rules got a web page, and a sitemap submitted to Search Console would have failed to parse. - `app/robots.ts` — text/plain, allows crawling, declares the sitemap. - `app/sitemap.ts` — every indexable URL derived from `source` / `blog`, never a hand-maintained list. `lastModified` is the git committer date of each source .mdx (one `git log` pass, ~1s over 11k commits) rather than build time, so an untouched page does not look edited on every deploy. A page whose date cannot be known ships without `lastmod` and the build says so — build time is never substituted. - `lib/site.ts` — the canonical origin, declared once. `metadataBase`, canonical links and JSON-LD import it next. Directives for `/api`, `/og`, `/docs/**.mdx` and `llms*.txt` are deliberately absent: crawl hygiene for those surfaces is a separate card and belongs in one place rather than in two PRs editing the same lines. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 48c7c34 commit 071ec4e

3 files changed

Lines changed: 217 additions & 0 deletions

File tree

apps/docs/app/robots.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { MetadataRoute } from 'next';
2+
3+
import { absoluteUrl } from '@/lib/site';
4+
5+
/**
6+
* `/robots.txt`.
7+
*
8+
* Before this file existed the path had no route at all, so `app/[lang]/page.tsx`
9+
* matched it as `lang = "robots.txt"` and answered `200 text/html` with the
10+
* homepage — a crawler asking for crawl rules got a web page. A literal segment
11+
* outranks a dynamic one in the app router, so this file takes the path back; the
12+
* `[lang]` catch-all swallowing *other* dotted paths is a separate defect and is
13+
* not fixed here.
14+
*
15+
* Static: the content depends on nothing per-request.
16+
*/
17+
export const dynamic = 'force-static';
18+
export const revalidate = false;
19+
20+
export default function robots(): MetadataRoute.Robots {
21+
return {
22+
rules: [
23+
{
24+
userAgent: '*',
25+
allow: '/',
26+
},
27+
],
28+
sitemap: absoluteUrl('/sitemap.xml'),
29+
};
30+
}

apps/docs/app/sitemap.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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+
}

apps/docs/lib/site.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Canonical identity of the documentation site.
3+
*
4+
* The origin is a maintainer ruling, not configuration. Every absolute URL this
5+
* site emits — sitemap entries, the `Sitemap:` line in `robots.txt`, and (as the
6+
* remaining indexability work lands) `metadataBase`, canonical links and JSON-LD
7+
* identifiers — must name this host and no other. Hard-coding it twice is how the
8+
* two halves drift; hence one constant, imported.
9+
*
10+
* Deliberately NOT read from an environment variable. A preview deployment that
11+
* derived its own origin would emit canonical links and a sitemap pointing at the
12+
* preview host — precisely the duplicate-content signal a canonical link exists to
13+
* suppress. One host, declared once, here.
14+
*/
15+
export const SITE_ORIGIN = 'https://objectstack.ai';
16+
17+
/**
18+
* Absolute URL for a **site-relative** path.
19+
*
20+
* `path` must start with `/`. Anything else throws at build time rather than
21+
* quietly emitting a URL on the wrong host: `new URL(path, SITE_ORIGIN)` on its
22+
* own would hand an already-absolute `https://elsewhere/...` straight back, and a
23+
* sitemap listing another host is discarded wholesale by search engines rather
24+
* than reported.
25+
*
26+
* Next's `metadataBase` wants a `URL` rather than a string — write
27+
* `new URL(SITE_ORIGIN)` there, so this file stays the only place the origin is
28+
* spelled out.
29+
*/
30+
export function absoluteUrl(path: string): string {
31+
if (!path.startsWith('/')) {
32+
throw new Error(
33+
`absoluteUrl() expects a site-relative path starting with "/", received ${JSON.stringify(path)}`,
34+
);
35+
}
36+
37+
return new URL(path, SITE_ORIGIN).toString();
38+
}

0 commit comments

Comments
 (0)