Skip to content

Commit 3685993

Browse files
os-zhuangclaude
andauthored
docs(site): keep the agent-reader copies of every page out of the search index (#12303)
`/docs/<slug>.mdx`, its rewrite destination `/llms.mdx/docs/<slug>`, `/llms.txt` and `/llms-full.txt` each serve the full text of documentation pages at a crawlable URL with no robots directive of any kind, so every page exists twice (three times, counting the aggregates) as far as a search engine is concerned. Measured on production before this change: `/docs/data-modeling/objects.mdx` → 200 `text/markdown`, 28080 bytes, no `X-Robots-Tag` and no `Link` header. Add `X-Robots-Tag: noindex` to all four from `next.config.mjs`'s `headers()`, which matches the incoming request path and so covers `/docs/**.mdx` before the rewrite rewrites it. The endpoints keep answering 200 with their full body — they are how AI agents read these docs and nothing here gates, redirects or content-negotiates them. `noindex` over `Link: rel="canonical"` because only the per-page markdown has an HTML twin to canonicalise to: `/llms.txt` and the 8 MB `/llms-full.txt` are whole-site aggregates that are a duplicate of no single page, so a canonical header could say nothing honest about the two largest copies on the site. It is also a directive rather than a hint. `robots.txt` keeps allowing all four paths — a `Disallow` would prevent the fetch that reveals the header — and now names `llms.txt` and `llms-full.txt` explicitly so agent crawlers find them deliberately rather than by convention. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 1524927 commit 3685993

2 files changed

Lines changed: 67 additions & 1 deletion

File tree

apps/docs/app/robots.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,27 @@ import { absoluteUrl } from '@/lib/site';
1313
* not fixed here.
1414
*
1515
* Static: the content depends on nothing per-request.
16+
*
17+
* ## No `Disallow` lines, deliberately
18+
*
19+
* This file shipped without any and the question was left to the card that owns
20+
* the duplicate-copy problem. The answer is that it stays without any, because
21+
* the fix for that problem is an `X-Robots-Tag: noindex` header on the
22+
* agent-reader endpoints (see the `headers()` block in `next.config.mjs`) and a
23+
* `Disallow` would defeat it: a crawler that is told not to fetch a URL never
24+
* sees the header on it, and a disallowed URL can still be indexed URL-only from
25+
* an inbound link. Allow the crawl, refuse the index. Anything added here later
26+
* must not cover `/docs/**.mdx`, `/llms.mdx/**`, `/llms.txt` or `/llms-full.txt`.
27+
*
28+
* ## Why `llms.txt` is named under `Allow:`
29+
*
30+
* `Allow: /` already permits both aggregate endpoints, so these two lines change
31+
* no crawler's behaviour -- they are declarative, and that is the whole job. The
32+
* robots.txt grammar has exactly one discovery directive, `Sitemap:`, and these
33+
* are not sitemaps; naming the paths in the file agents already fetch first is
34+
* the available way to make them findable on purpose rather than by guessing at
35+
* a convention. It also records, at the point of the crawl rules, that the
36+
* `noindex` on those paths is about indexing and not about access.
1637
*/
1738
export const dynamic = 'force-static';
1839
export const revalidate = false;
@@ -22,7 +43,7 @@ export default function robots(): MetadataRoute.Robots {
2243
rules: [
2344
{
2445
userAgent: '*',
25-
allow: '/',
46+
allow: ['/', '/llms.txt', '/llms-full.txt'],
2647
},
2748
],
2849
sitemap: absoluteUrl('/sitemap.xml'),

apps/docs/next.config.mjs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,51 @@ const config = {
6262
'lucide-react': './node_modules/lucide-react',
6363
},
6464
},
65+
async headers() {
66+
// The agent-reader surfaces below each serve the full text of documentation
67+
// pages as `text/markdown` / `text/plain`, at URLs a crawler can reach. They
68+
// are a deliberate feature -- this is how AI agents read these docs -- so
69+
// they keep answering 200 with their full body to anyone who asks. The only
70+
// thing added here is a directive telling *search engines* which copy is the
71+
// one worth indexing: the HTML page.
72+
//
73+
// `noindex` rather than `Link: <...>; rel="canonical"`, and the reasons are
74+
// not interchangeable:
75+
//
76+
// 1. Coverage. Only the per-page markdown has an HTML twin to point a
77+
// canonical at. `/llms.txt` is an index of every page and
78+
// `/llms-full.txt` is all 400+ of them concatenated (8 MB today); there
79+
// is no single HTML URL either one is a duplicate *of*, so a canonical
80+
// header cannot say anything honest about them and would leave the two
81+
// largest parallel copies on the site undirected. `noindex` states the
82+
// same intent for all four sources.
83+
// 2. A canonical link is a hint a search engine weighs against other
84+
// signals and may overrule; `noindex` is a directive. What this card
85+
// wants is the strong form -- keep the copy fetchable, keep it out of
86+
// results.
87+
//
88+
// The two are also not additive: pairing `noindex` with a canonical pointing
89+
// elsewhere is contradictory (the target is asked to absorb the signal of a
90+
// page that has asked to be dropped), so exactly one of them belongs here.
91+
//
92+
// ⚠️ This works only while `robots.txt` still allows these paths to be
93+
// crawled -- a `Disallow` would stop the fetch that reveals the header and
94+
// leave the URLs eligible for URL-only indexing instead. `app/robots.ts`
95+
// names them under `Allow:` on purpose; the two files are one mechanism.
96+
//
97+
// Matching is on the *incoming* request path, before rewrites, which is why
98+
// `/docs/:path*.mdx` is spelled here as the client asks for it. Its rewrite
99+
// destination `/llms.mdx/docs/:path*` is a real route and answers directly
100+
// too (measured: `/llms.mdx/docs/data-modeling/objects` -> 200
101+
// `text/markdown`), so each page actually has two markdown URLs and both are
102+
// listed.
103+
const noindex = ['/docs/:path*.mdx', '/llms.mdx/:path*', '/llms.txt', '/llms-full.txt'];
104+
105+
return noindex.map((source) => ({
106+
source,
107+
headers: [{ key: 'X-Robots-Tag', value: 'noindex' }],
108+
}));
109+
},
65110
async redirects() {
66111
return toNextRedirects();
67112
},

0 commit comments

Comments
 (0)