From e6d9bfd21ded9cfd23299be9f4d69b2b57ef6e2d Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:36:11 +0000 Subject: [PATCH] fix: publish localized API routes on Pages --- package.json | 3 +- scripts/normalize-static-paths.mjs | 56 +++++++++++++++++++++++++++ tests/fumapress-static-build.test.mjs | 23 +++++++++-- 3 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 scripts/normalize-static-paths.mjs diff --git a/package.json b/package.json index c7cefb09c..81e2d22ed 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,9 @@ "prepare:fumapress": "node scripts/prepare-fumapress.mjs", "build": "fumapress build", "prebuild": "npm run prepare:fumapress", - "postbuild": "npm run generate:sitemap-alternates && npm run test:static", + "postbuild": "npm run generate:sitemap-alternates && npm run normalize:static-paths && npm run test:static", "typecheck": "tsc --noEmit", + "normalize:static-paths": "node scripts/normalize-static-paths.mjs", "test:static": "node --test tests/fumapress-static-build.test.mjs", "generate:sitemap-alternates": "node scripts/generate-alternate-sitemap.mjs", "sync:articles": "node scripts/sync-blog-articles.mjs", diff --git a/scripts/normalize-static-paths.mjs b/scripts/normalize-static-paths.mjs new file mode 100644 index 000000000..b427a4122 --- /dev/null +++ b/scripts/normalize-static-paths.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +import { access, readdir, rename } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function decodePathSegment(segment) { + const decoded = decodeURIComponent(segment); + if (decoded.includes("/") || decoded.includes("\\") || decoded.includes("\0")) { + throw new Error(`Refusing to decode unsafe output path segment: ${segment}`); + } + return decoded; +} + +async function exists(target) { + try { + await access(target); + return true; + } catch { + return false; + } +} + +export async function normalizeStaticPaths(directory) { + let renamed = 0; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const source = path.join(directory, entry.name); + if (entry.isDirectory()) renamed += await normalizeStaticPaths(source); + + const decodedName = decodePathSegment(entry.name); + if (decodedName === entry.name) continue; + + const destination = path.join(directory, decodedName); + if (await exists(destination)) { + throw new Error(`Static output path collision: ${source} -> ${destination}`); + } + await rename(source, destination); + renamed += 1; + } + return renamed; +} + +async function main() { + const publicRoot = path.join(ROOT, "dist/public"); + const renamed = await normalizeStaticPaths(publicRoot); + console.log(`Normalized ${renamed} percent-encoded static path segments`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error.stack || error.message); + process.exitCode = 1; + }); +} diff --git a/tests/fumapress-static-build.test.mjs b/tests/fumapress-static-build.test.mjs index 52b13653f..f41a4e829 100644 --- a/tests/fumapress-static-build.test.mjs +++ b/tests/fumapress-static-build.test.mjs @@ -31,6 +31,12 @@ function staticHtmlForCanonicalDocument(document) { return path.join(publicRoot, ...parts, "index.html"); } +function staticHtmlForUrlPath(urlPath) { + const parts = urlPath.replace(/^\//, "").replace(/\/$/, "").split("/") + .map((segment) => decodeURIComponent(segment)); + return path.join(publicRoot, ...parts, "index.html"); +} + test("all 302 canonical localized documents have static HTML", async () => { const documents = await collectMdxDocuments(root); assert.equal(documents.length, 302); @@ -50,11 +56,22 @@ test("every fixed redirect destination resolves to generated output", async () = .trim().split("\n").map((line) => line.split(/\s+/)); for (const [source, destination] of redirects) { if (destination.includes(":splat")) continue; - const target = destination.replace(/^\//, "").replace(/\/$/, ""); - await access(path.join(publicRoot, target, "index.html"), undefined, `${source} -> ${destination}`); + await access(staticHtmlForUrlPath(destination), undefined, `${source} -> ${destination}`); } }); +test("static output uses decoded filesystem paths for edge hosting", async () => { + const encodedComponents = []; + async function walk(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (/%[0-9A-Fa-f]{2}/.test(entry.name)) encodedComponents.push(path.join(directory, entry.name)); + if (entry.isDirectory()) await walk(path.join(directory, entry.name)); + } + } + await walk(publicRoot); + assert.deepEqual(encodedComponents, []); +}); + test("static routes never repeat their locale prefix", async () => { for (const locale of ["en", "zh", "ja"]) { await assert.rejects(access(path.join(publicRoot, locale, locale)), { code: "ENOENT" }); @@ -87,7 +104,7 @@ test("all locales emit the OpenAPI surface with endpoint semantics", async () => assert.ok(routes.length >= 50, `${locale} emitted only ${routes.length} API routes`); const encodedRepresentativePath = representativePath.split("/").map((segment) => encodeURIComponent(segment)).join("/"); - const representative = path.join(apiRoot, encodedRepresentativePath, "index.html"); + const representative = path.join(apiRoot, representativePath, "index.html"); const html = await readFile(representative, "utf8"); for (const semantic of semantics) assert.ok(html.includes(semantic), `${locale} page is missing ${semantic}`); assert.ok(sitemap.includes(`https://docs.langbot.dev/${locale}/api-reference/${encodedRepresentativePath}`));