From abe0127442f3e4d490e8bf53fc357ca27b02b174 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 04:41:54 +0000 Subject: [PATCH] Serve SPA routes as HTTP 200 on Cloudflare Pages Add a _redirects 200 rewrite so crawlers and curl see live pages instead of the 404.html fallback. Browsers already recovered. Fixes #20 Co-authored-by: Christoffer Hallas --- www/README.md | 9 ++++--- www/package.json | 3 ++- www/public/_redirects | 4 +++ www/scripts/verify-spa-redirects.mjs | 39 ++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 www/public/_redirects create mode 100644 www/scripts/verify-spa-redirects.mjs diff --git a/www/README.md b/www/README.md index f9be3cdb..63e79ec5 100644 --- a/www/README.md +++ b/www/README.md @@ -46,9 +46,12 @@ playground without a Rust toolchain. Content routes (Documents, Packages, Spec, Install, First program, Book, and the rest of the docs tree) are written as `dist//index.html` so those -URLs are real pages. Unknown paths still use the `public/404.html` → `/?/path` -bounce and `index.html` restore script. Custom domain: `public/CNAME` → -`xo.run`. Wasm bindings stay in `public/echo-wasm/`. +URLs are real pages. Unknown paths use `public/_redirects` +(`/* /index.html 200`) so they are a rewrite, not HTTP 404. Crawlers and +`curl` see 200. Existing static files still win. `public/404.html` remains +the older browser bounce for hosts that still fall back to a 404 page; +`index.html` restores `?/` paths from that bounce. Custom domain: +`public/CNAME` → `xo.run`. Wasm bindings stay in `public/echo-wasm/`. `/robots.txt` is a static file. `/sitemap.xml` is emitted at build from the public catalog (`staticPages` and site chrome). Both use `https://xo.run`. diff --git a/www/package.json b/www/package.json index 543b0296..9ebfb262 100644 --- a/www/package.json +++ b/www/package.json @@ -10,12 +10,13 @@ "format": "oxfmt --check .", "lint": "oxlint . --ignore-pattern public/echo-wasm", "preview": "vite preview", - "test": "npm run test:docs && npm run test:prose && npm run test:std-ref && npm run test:try && npm run test:sitemap", + "test": "npm run test:docs && npm run test:prose && npm run test:std-ref && npm run test:try && npm run test:sitemap && npm run test:spa-redirects", "test:docs": "node scripts/verify-docs-pages.mjs", "test:std-ref": "node scripts/verify-std-reference.mjs", "test:prose": "node scripts/verify-prose.mjs", "test:try": "node scripts/verify-try.mjs", "test:sitemap": "node scripts/verify-sitemap.mjs", + "test:spa-redirects": "node scripts/verify-spa-redirects.mjs", "sync:tree-sitter": "node scripts/sync-tree-sitter.mjs", "postinstall": "npm run sync:tree-sitter" }, diff --git a/www/public/_redirects b/www/public/_redirects new file mode 100644 index 00000000..87e279d8 --- /dev/null +++ b/www/public/_redirects @@ -0,0 +1,4 @@ +# SPA routes: rewrite unknown paths to the app shell with HTTP 200. +# Existing static files still win. Without this, Cloudflare Pages serves +# 404.html (browsers recover; crawlers and curl see HTTP 404). +/* /index.html 200 diff --git a/www/scripts/verify-spa-redirects.mjs b/www/scripts/verify-spa-redirects.mjs new file mode 100644 index 00000000..40e78fb1 --- /dev/null +++ b/www/scripts/verify-spa-redirects.mjs @@ -0,0 +1,39 @@ +/** + * Verifies Cloudflare Pages serves SPA routes as HTTP 200: + * - public/_redirects rewrites /* to /index.html with status 200 + */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const redirectsPath = path.join(root, "public", "_redirects"); +const source = readFileSync(redirectsPath, "utf8"); + +const failures = []; + +function fail(message) { + failures.push(message); +} + +const rewrite = source + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")) + .find((line) => { + const parts = line.split(/\s+/); + return parts[0] === "/*" && parts[1] === "/index.html" && parts[2] === "200"; + }); + +if (!rewrite) { + fail("public/_redirects must rewrite /* to /index.html with status 200"); +} + +if (failures.length > 0) { + for (const failure of failures) { + console.error(failure); + } + process.exit(1); +} + +console.log("verify-spa-redirects: ok");