Uh oh!
There was an error while loading. Please reload this page.
feat(docs): serve a real /robots.txt and /sitemap.xml - #12253
Merged
Conversation
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: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 25, 2026
os-zhuang
marked this pull request as ready for review
August 25, 2026 15:45
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Aug 25, 2026
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#12232
Part of the docs-site indexability epic #12243 (P0 lane).
/robots.txtand/sitemap.xmlhad no route, soapp/[lang]/page.tsxmatched themas
lang = "robots.txt"/lang = "sitemap.xml"and answered200 text/htmlwiththe homepage. A crawler asking for crawl rules got a web page; a sitemap submitted to
Search Console would have failed to parse.
What changed — 3 new files, 217 lines, nothing edited
apps/docs/lib/site.tsapps/docs/app/robots.tstext/plainrobots response naming the sitemapapps/docs/app/sitemap.tssource/blogThe shared origin constant — exactly what is exported
This card owns creating it.
apps/docs/lib/site.tsexports two names and nothingelse:
SITE_ORIGIN— the origin only, no trailing slash. Maintainer ruling, recorded infix(docs): converge the docs host on the canonical origin, gate included #10659, verbatim and untranslated:
absoluteUrl(path)— joins a site-relative path onto that origin.pathmust start with a slash; anything else throws at build time rather thanquietly emitting a URL on the wrong host.
new URL(path, SITE_ORIGIN)alone wouldhand an already-absolute
https://elsewhere/...straight back, and a sitemaplisting a foreign host is discarded wholesale rather than reported.
Notes for the two cards that import this next round:
metadataBaseand no canonical link on any page #12234 (metadataBase) — Next wants aURLobject, not a string. WritemetadataBase: new URL(SITE_ORIGIN). That is the intended spelling, and it isdocumented in the file so a second origin literal never appears. A module-level
shared
URLinstance is deliberately not exported:URLis mutable, so oneconsumer assigning
.pathnamewould silently move the origin for every other.absoluteUrl()for every@id/url.own origin would emit canonical links and a sitemap pointing at the preview host,
which is exactly the duplicate-content signal a canonical link exists to suppress.
SITE_NAME/ title / description constants: nothing in this card needs them,and inventing surface for a consumer that has not landed is how a shared module
starts collecting things nobody reads.
lastModified: git committer date, not build timeOne
git logpass over both content collections, run once at build:Cost, measured, not assumed: 1.0s over 11,231 commits for all 406 content files.
The suggested alternative in the card — one
git logper file — would be 406 gitprocesses; this is one. So the cheap honest signal and the good one are the same
signal here, and there was no trade to make.
Why not build time: it restamps 400+ pages on every deploy, which tells a crawler the
whole site changed whenever anything did — a signal discounted precisely because it is
never false.
When the date cannot be known, the entry ships with no
lastmodelement at all(no git directory; or a clone shallow enough that no commit in the window touched the
file).
lastmodis optional in the sitemap protocol, so omitting it is the honestanswer, and substituting build time would reintroduce the exact lie the git date
exists to avoid. It does not degrade silently, either: the build prints a counted
warning naming the remedy — a shallow-clone note, or a per-page count with the first
three paths. On this build that warning count is 0.
PM dispatch assumptions — all four verified, none falsified
robots.ts/sitemap.tsinapps/docs/app/— confirmed onorigin/main.200 text/html— re-measured today againsthttps://objectstack.ai, unchanged:source.getPages()is the right source — confirmed; no list is hand-maintained.Note
getPages()with no argument lists every language (fumadocsLoaderOutput);English is the only one today, and a future locale belongs in the sitemap under its
own prefixed URL, so it is left unfiltered on purpose.
app/robots.tsbeats the single-segment[lang]dynamic route —confirmed, in both dev and a production build. Measurements below. This is the
assumption the PM asked to have tested; it holds, so docs site: any single-segment path containing a dot renders the homepage with 200 (soft-404 class) #12233 is not a blocker for
this card. The converse also holds and is worth recording for docs site: any single-segment path containing a dot renders the homepage with 200 (soft-404 class) #12233: an unrelated
dotted path still renders the homepage after this change, so this PR fixes exactly
two paths and does not touch that defect.
Measurements
Dev server (
next dev -p 38412)Production build (
next build, exit 0)Route table — both are
○ (Static) prerendered as static content, so thegit logruns in the build process and never in a request:
Production server (
next start -p 38413)The sitemap's first line is an XML declaration, not HTML.
Acceptance criteria
robots.txtis200 text/plainand the body names the sitemapsitemap.xmlstarts with an XML declaration, not HTMLcontent-type: application/xmlfind content/docs -name '*.mdx' | wc -l+ non-docs routes/,/blog, and 3 blog posts)objectstack.aigrep -oover everylocelement yields exactly one hostlastmodelement count is 406 — every one of the 403 docs pages and 3 blog posts.The 2 URLs without one are
/and/blog, which have no MDX file behind them. Threedates spot-checked against
git log -1 --format=%cIon the source file: all three matchto the second.
Neighbouring routes, unchanged
/en/docs307to/docs— the epic asks that this not break; it does not/llms.txt200 text/plain/docs/upgrading.mdx200 text/markdown200 text/html— #12233's defect, untouchedScope: what robots.txt deliberately does NOT contain
User-agent: */Allow: /and theSitemap:line, and nothing else. NoDisallowfor
/api,/og,/docs/**.mdxorllms*.txt:.mdxparallel copy is docs site:/docs/**.mdxserves an indexable parallel copy of every page with no robots directive #12241, and two PRs editing thesame handful of lines in the same file is a conflict for no gain — that card can add
its directive on top of this file;
/ogin particular must stay crawlable, or the OG cards docs site: Open Graph images are generated for all 403 pages but no page references them #12235 is about wouldstop rendering for the crawlers that fetch them.
No
priorityorchangeFrequencyon any entry: Google ignores both, and inventingvalues for 408 URLs would put numbers into a machine-readable surface that nothing
measured.
Verification
All at
e02a77cc7, the final commit on this branch.pnpm --filter '@objectstack/docs^...' buildVERDICT command-exit 0pnpm --filter @objectstack/docs typecheckVERDICT command-exit 0next build(apps/docs)pnpm lint(repo-wideeslint . --no-inline-config)VERDICT command-exit 0, 28s, no findings — the full farm scan, not a narrowed onepnpm check:published-files✓ ... 69 publishable package(s) ...pnpm check:test-source-aliascheck-test-source-alias OK — 72 packages with tests scannedpnpm check:type-source-resolutioncheck-type-source-resolution OK — 93 tsc program(s) across 77 packages scannedpnpm check:nul-bytescheck-nul-bytes: OK (scanned 6772 text file(s) ... no raw ASCII control bytes)The first three gate families are the ones
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackderives forthese three paths, re-derived from a tree at
origin/mainafter the branch wasfast-forwarded (the first derivation ran against a stale tree and said so).
check:nul-bytesis added because the diff is an edit.Declared narrowing — verification ran UNLOCKED.
scripts/pm/os-verify-lock.shcould not take the shared verify lock on this host: no usable
flock(the lock isdeclared Linux-only; a stock macOS does not ship util-linux). Every wrapped command
above therefore ran directly, without the lock — a declared narrowing, not a silent
one. No serialization guarantee held for those runs.
Landing
Docs-site only, no published package changes, so no changeset — the
skip-changesetlabel carries that, applied to this PR.Generated by Claude Code