Uh oh!
There was an error while loading. Please reload this page.
docs(site): keep the agent-reader copies of every page out of the search index - #12303
Merged
Conversation
…rch index `/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: Claude Opus 5 <noreply@anthropic.com>
os-zhuang
marked this pull request as ready for review
August 25, 2026 17:27
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#12241
What was wrong
Every documentation page is reachable as machine-readable text at a crawlable URL with no robots directive of any kind. Measured against production before this change (2026-08-25, after the epic's P0 PRs deployed):
The card understated it by one. The rewrite's destination is itself a real app-router route, so it answers directly too — each page has two markdown URLs, not one:
After
Same commands against the app with this change (dev server,
next dev, port 3987):Body still served in full:
/docs/data-modeling/objects.mdxreturns 200 with 28042 bytes, first line# Object Metadata. Nothing is gated, redirected, renamed or content-negotiated — the endpoints are a deliberate feature and they behave exactly as before to anyone who fetches them.Which directive, and why —
noindex, not a canonicalLinkheaderI built and measured both candidates before choosing, because "which one actually reaches the response" was the stated tiebreak. Both do; Next interpolates route params into header values, so the canonical variant produced a correct per-page target (
Linkheader carryinghttps://objectstack.ai/docs/data-modeling/objectswithrel="canonical"on the.mdxresponse). Mechanics did not decide it — substance did, on two counts. Coverage: only the per-page markdown has an HTML twin to canonicalise to./llms.txtis an index of all 400+ pages and/llms-full.txtis all of them concatenated; neither is a duplicate of any single HTML URL, so a canonical header can say nothing honest about them and a canonical-based fix would leave the two largest parallel copies on the site — including an 8 MB one — with no directive at all, which is the exact gap this card exists to close. Strength: a canonical link is a hint a search engine weighs against other signals and may overrule, whereasnoindexis a directive; what is wanted here is the strong form — keep the copy fetchable, keep it out of results. The two are also not additive: pairingnoindexwith a canonical pointing elsewhere is self-contradictory, so exactly one belongs. One claim I expected to make and had to drop after testing it: I assumednext.config.mjs(ESM JavaScript) could not importSITE_ORIGINfromlib/site.ts, which would have forced a second spelling of the origin. It imports fine under Next 16's config loader — verified by printing the value at config load. So that was not a reason, andnoindexwins on coverage and strength alone.The two files are one mechanism, and they point in opposite directions
robots.tsshipped in #12253 with noDisallowlines and left the question here. The answer is that it stays that way: aDisallowwould defeat the header. A crawler told not to fetch a URL never sees theX-Robots-Tagon it, and a disallowed URL remains eligible for URL-only indexing from an inbound link — strictly worse than today. Allow the crawl, refuse the index. Both docblocks say so at the point where someone would otherwise add the wrong thing.Allow: /llms.txt/Allow: /llms-full.txtare redundant underAllow: /and change no crawler's behaviour — that is deliberate and it is the whole job. robots.txt has exactly one discovery directive,Sitemap:, and these are not sitemaps; naming the paths in the file agents fetch first is the available way to make them findable on purpose rather than by guessing at a convention.On
/llms-full.txtbeing 8 MB (note, not scope)Measured at 8,052,587 bytes today and growing linearly with the page count. My read is that it should eventually be split, but the trigger should be a measured consumer failure rather than the number itself: the file's whole value is being one fetch, and splitting it trades that for an index plus N requests, which is what
/llms.txtplus the per-page.mdxURLs already provide. The thing to watch is that many agent HTTP clients cap response bodies well under 8 MB and truncate silently mid-document — if that starts happening the split is forced, and the natural seam is the existing top-level docs sections. Not touched here.Verification
Gate union re-run at the final commit
6f8244cef(after mergingorigin/mainat5ce5f8c12), each verdict quoted from the gate's own output:check:docs-locale-catch-all✓ 1 top-level dynamic segment(s), 1 guarded; dotted paths bypass proxy.ts: truecheck:page-declaration-shapeOK — 34 page entries across 2185 sources … all reach the kernel through a discoverable declarationcheck:published-files✓ 69 publishable package(s) of 78 workspace member(s) declare a files whitelist …check:test-source-aliasOK — 72 packages with tests scannedcheck:type-source-resolutionOK — 93 tsc program(s) across 77 packages scannedcheck:nul-bytesOK (scanned 6815 text file(s) … no raw ASCII control bytes)pnpm --filter @objectstack/docs typechecktsc --noEmit, silent)Families derived by
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, re-derived at the final commit; the list did not change.Two honest limits on the above:
next.config.mjsis not in the typecheck program.tsc --noEmit --listFilesinapps/docsenumerates 1239 files;app/robots.tsappears in it,next.config.mjsdoes not (it is.mjs). So typecheck says nothing about the file carrying the headers — the evidence for that file is the running server above, which loaded the config (✓ Running next.config.mjs took 135ms) and served the headers.pnpm lintwas narrowed, deliberately, and the narrowing is declared. ESLint was run on the two edited files only:--format jsonreports exactly 2 results, botherrorCount=0 warningCount=0, neither ignored. The narrowing is safe to a specific measured fact rather than an assumption: this repo runs oneeslint.config.mjswhich never enables type-aware linting for any file —eslint.config.mjs's own docblock records that (noparserOptions.project, no typed@typescript-eslintrules, with a planted-positive-control measurement behind it). With no cross-file type program, a two-file diff cannot move any untouched file's verdict. CI runs the full farm regardless.scripts/pm/os-verify-lock.shreportedUNLOCKED (declared) · no usable flock on this host, so the shared verify lock was NEVER taken and NOTHING was serializedon both runs, and instructs that this be declared here. Every command above still went through the wrapper.Not done here
No changeset: docs-site only, publishes nothing —
skip-changesetapplied.The card's second acceptance box reads "the HTML page is unaffected and still canonicalises to itself". The first half holds and is measured above. The second half is not true on
mainyet and is not this card's to fix: the HTML page emits nolink rel=canonicaltoday, in production or locally — that is #12234, in flight in the same round. This PR adds nothing to the HTML page, so whatever #12234 lands is unaffected by it.Generated by Claude Code