fix: publish localized API routes on Pages

This commit is contained in:
dadachann
2026-09-04 13:36:11 +00:00
parent fb0b21b36a
commit e6d9bfd21d
3 changed files with 78 additions and 4 deletions
+2 -1
View File
@@ -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",
+56
View File
@@ -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;
});
}
+20 -3
View File
@@ -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}`));