fix: make Markdown alternates resolve and page moves permanent - #17
Merged
Merged
Conversation
Every docs page advertises a text/markdown alternate at <slug>.mdx, and
six of the seven returned 404. The proxy built its rewrite patterns by
concatenating docsRoute ("/") with the optional `{/*path}` group, which
supplies its own slash, so the patterns only ever matched the root and
double-slash paths. Strip the trailing slash from the prefix, the way
upstream's Fumadocs CLI does for any base URL.
Fixing the pattern also turns the negotiated rewrite into a catch-all,
so the mapping now lives behind one exported helper that only negotiates
on paths that look like docs pages. The root page advertises /index.mdx
instead of /.mdx, and both aliases resolve.
A new test walks every page and ties the advertised alternate, the proxy
rewrite, and the /llms.mdx handler URL together, and the standalone smoke
run now fetches the alternates over HTTP.
A page's HTML URL now answers Markdown when the client asks for it with Accept: text/markdown or text/plain, and the rewrite carries Vary: Accept so a cache never hands a browser the Markdown body. The .mdx alternates are not negotiated and do not get the header. The tests enumerate every non-page route on the site (llms.txt, robots, sitemap, the /llms.mdx and /og trees, /_next, /.well-known) and pin that a Markdown-preferring client still gets that route rather than a rewrite into a path the build never generated. A second cross-layer loop ties the negotiated URL of every page to its /llms.mdx handler URL. The standalone smoke server now binds to 0.0.0.0 like the Dockerfile. With a loopback HOSTNAME, Next normalises request.nextUrl to localhost, mismatches its own origin, treats every rewrite as external, and proxies the request back to itself, which dropped the Vary header and doubled the work. Production never takes that path, so the smoke run must not.
The /start and /openprose pages moved to the root for good, but their redirects answered 307. A temporary redirect tells a crawler the old URL may come back, so search engines keep listing the old URLs as separate pages and pass none of their signals to the new ones. The entry for /start/what-is-openprose was permanent from the day it was added and only became temporary when the harness routes were introduced alongside it. That change justified temporary redirects for the harness routes alone, which may host docs again, so those four stay 307. The standalone smoke run can now observe a redirect at all: it records the Location header and asserts a 308 page move, a 307 harness route, and that an old /openprose/<slug>.mdx alternate lands on a working one.
The docs-page gate treated any extensionless path outside /_next as a page, and the search API has no extension. Its clients commonly send `Accept: application/json, text/plain, */*`, and text/plain alone counts as a Markdown preference, so /api/search and its OpenAPI document were rewritten into /llms.mdx paths the build never generates and answered 404. The gate now excludes /api, and the unit tests and smoke run both send that Accept header to prove the API still answers JSON. The gate is exported as isNegotiablePage so the proxy makes one decision about which rewrites are content-negotiated and therefore carry Vary: Accept. The HTML side of a page cannot carry it: Next overwrites Vary on App Router page responses before rendering, and neither a proxy header nor a headers() rule in next.config survives that. The proxy comment records the constraint so a future cache in front of the site is configured to key page URLs on Accept itself.
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 free
to 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.
Why the change
Every docs page advertises a Markdown copy at
<slug>.mdx, but six of the seven answered 404 andAccept: text/markdownonly worked on/, so agents that followed the advertised link got an HTML error page; now every page serves Markdown both ways, and the old/startand/openproseURLs redirect permanently so search engines can retire them.Special things to note
isMarkdownPreferredis true forAccept: text/plainas well astext/markdown. So negotiation only applies to paths with no dot that are outside/_next./llms.txt,/robots.txt,/sitemap.xml,/llms.mdx/**,/og/**, and/.well-known/**pass through unchanged for anyAccept. If a future page slug contains a dot,markdown-alternates.test.tsfails, so the page can't quietly fall back to HTML./reactor,/sdk,/cli,/reactor-devtools) stay 307 because those routes may host docs again. Browsers cache a 308 for a long time, which is fine because the pages are staying at the root.HOSTNAME=0.0.0.0, the same value the Dockerfile uses. With127.0.0.1, Next treated every proxy rewrite as external and proxied it back to itself, which dropped theVaryheader. Production never takes that path.Change outline
What a client sees:
The cause:
proxy.tsputdocsRoute("/") in front of Fumadocs's optional{/*path}group, which already starts with a slash, so both patterns only matched/and double-slash paths. Removing the prefix's trailing slash fixes both patterns and still works if the docs ever move back under a prefix like/docs.The rewrite decision moves into one exported pure helper, like
resolveDocsHostRedirectandhasEncodedPathname. The encoded-path guard from #15 still runs first.A rewrite can only land on a file the build prerendered, or on a 404, because
/llms.mdxisfallback: false(#16). A bad guess never serves the wrong body or writes to disk.proxy.ts ~ prefix fix, resolveMarkdownRewrite(), Vary on negotiated rewrites lib/canonical.ts ~ root page advertises /index.mdx lib/shared.ts ~ comment: docsRoute is "/" or a prefix without a trailing slash next.config.mjs ~ three page-move redirects → permanent: true __tests__/proxy.test.ts + rewrite, negotiation, and non-page pass-through cases __tests__/canonical.test.ts ~ fixtures move to root-mounted paths, root case added +__tests__/markdown-alternates.test.ts for every page: alternate and HTML URL → proxy → /llms.mdx handler URL scripts/smoke-standalone.ts + .mdx, negotiation, and redirect probes; records Vary and Location