Skip to content

fix: make Markdown alternates resolve and page moves permanent - #17

Merged
josemontesdeoca merged 4 commits into
mainfrom
fix-markdown-alternate-links
Sep 30, 2026
Merged

josemontesdeoca merged 4 commits into
mainfrom
fix-markdown-alternate-links

Conversation

@josemontesdeoca

Copy link
Copy Markdown
Member

Why the change

Every docs page advertises a Markdown copy at <slug>.mdx, but six of the seven answered 404 and Accept: text/markdown only worked on /, so agents that followed the advertised link got an HTML error page; now every page serves Markdown both ways, and the old /start and /openprose URLs redirect permanently so search engines can retire them.

Special things to note

  • Once the pattern is fixed, the negotiated rewrite matches every path, and Fumadocs's isMarkdownPreferred is true for Accept: text/plain as well as text/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 any Accept. If a future page slug contains a dot, markdown-alternates.test.ts fails, so the page can't quietly fall back to HTML.
  • Only the three page-move redirects become 308. The four harness redirects (/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.
  • The smoke server now starts with HOSTNAME=0.0.0.0, the same value the Dockerfile uses. With 127.0.0.1, Next treated every proxy rewrite as external and proxied it back to itself, which dropped the Vary header. Production never takes that path.

Change outline

What a client sees:

 GET /setup.mdx                               (every page's advertised alternate)
-  404 text/html
+  200 text/markdown
 GET /setup   Accept: text/markdown | text/plain
-  200 text/html
+  200 text/markdown, Vary: Accept
 <link rel="alternate" type="text/markdown"> on /
-  /.mdx
+  /index.mdx                                 (/.mdx still resolves)
 GET /openprose/contracts
-  307 → /contracts
+  308 → /contracts
 GET /openprose/contracts.mdx
-  307 → /contracts.mdx → 404
+  308 → /contracts.mdx → 200 text/markdown

The cause: proxy.ts put docsRoute ("/") 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.

+const docsPrefix = docsRoute.replace(/\/$/, '');
-`${docsRoute}{/*path}.mdx`     // "/{/*path}.mdx" matched "/.mdx" and "//setup.mdx"
+`${docsPrefix}{/*path}.mdx`    // "{/*path}.mdx"  matches "/setup.mdx"

The rewrite decision moves into one exported pure helper, like resolveDocsHostRedirect and hasEncodedPathname. The encoded-path guard from #15 still runs first.

 proxy(request)
   if hasEncodedPathname(pathname)        → 404
   if host is docs.openprose.ai           → 301 docs.prose.md
-  if rewriteSuffix(pathname)             → rewrite
-  if isMarkdownPreferred(request)
-     and rewriteDocs(pathname)           → rewrite
+  target = resolveMarkdownRewrite(pathname, isMarkdownPreferred(request))
+  if target                              → rewrite, with Vary: Accept when negotiated
   otherwise                              → next()
resolveMarkdownRewrite(pathname, prefersMarkdown)
  pathname contains "//"                        → null
  "/index.mdx" or "/.mdx"                       → /llms.mdx/content.md
  "/<slug>.mdx"                                 → /llms.mdx/<slug>/content.md
  prefersMarkdown, no ".", not under /_next     → /llms.mdx/<slug>/content.md
  otherwise                                     → null

A rewrite can only land on a file the build prerendered, or on a 404, because /llms.mdx is fallback: 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

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.
@josemontesdeoca josemontesdeoca self-assigned this Sep 30, 2026
@josemontesdeoca
josemontesdeoca marked this pull request as ready for review September 30, 2026 00:38
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.
@josemontesdeoca
josemontesdeoca merged commit 6b0f7dc into main Sep 30, 2026
4 checks passed
@josemontesdeoca
josemontesdeoca deleted the fix-markdown-alternate-links branch September 30, 2026 01:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant