mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-28 19:35:27 +08:00
fix: publish localized API routes on Pages
This commit is contained in:
+2
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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}`));
|
||||
|
||||
Reference in New Issue
Block a user