From feeb0a31e445ab0fa24b66d3adb3384e9ad3fd2f Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Sat, 8 Aug 2026 08:52:05 -0400 Subject: [PATCH] fix: support SSG output deployment base paths --- CHANGELOG.md | 8 +++ package-lock.json | 6 +-- src/ssg/output-report.ts | 50 +++++++++++++++-- tests/cli-smoke.test.ts | 103 ++++++++++++++++++++++++++++++++++++ tests/output-report.test.ts | 63 ++++++++++++++++++++++ 5 files changed, 222 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c283d6b..041bdca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Support an explicit `outputReport.basePath` deployment prefix when validating root-absolute SSG asset references. + +### Security + +- Refresh the transitive Nano ID lockfile resolution to address the current audit advisory. + ## [0.0.22] - 2026-08-07 ### Changed diff --git a/package-lock.json b/package-lock.json index f264e62..922f7b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4408,9 +4408,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { diff --git a/src/ssg/output-report.ts b/src/ssg/output-report.ts index 9f32560..2262416 100644 --- a/src/ssg/output-report.ts +++ b/src/ssg/output-report.ts @@ -28,6 +28,8 @@ export interface SsgOutputBudgets { } export interface SsgOutputReportConfig { + /** Deployment pathname prefix stripped from root-absolute asset references. */ + basePath?: string; budgets?: SsgOutputBudgets; /** Number of largest pages retained in the summary. Defaults to 20. */ largestPages?: number; @@ -101,7 +103,11 @@ export async function removeSsgOutputReport(outputDir: string): Promise { await fs.rm(path.join(outputDir, REPORT_PATH), { force: true }); } -function localReference(reference: string, documentPath: string): string | undefined { +function localReference( + reference: string, + documentPath: string, + basePath: string, +): string | undefined { let resolved: URL; try { const portableDocumentPath = documentPath.replaceAll("\\", "/"); @@ -116,12 +122,44 @@ function localReference(reference: string, documentPath: string): string | undef } if (resolved.origin !== "https://askr.invalid") return undefined; try { - return decodeURIComponent(resolved.pathname).replace(/^\/+/, ""); + let pathname = decodeURIComponent(resolved.pathname); + if ( + basePath && + reference.startsWith("/") && + (pathname === basePath || pathname.startsWith(`${basePath}/`)) + ) { + pathname = pathname.slice(basePath.length); + } + return pathname.replace(/^\/+/, ""); } catch { return undefined; } } +function deploymentBasePath(value: string | undefined): string { + if (value === undefined || value === "" || value === "/") return ""; + if ( + typeof value !== "string" || + !value.startsWith("/") || + value.startsWith("//") || + value.includes("?") || + value.includes("#") || + value.includes("\\") + ) { + throw new Error("outputReport.basePath must be an absolute URL pathname"); + } + let decoded: string; + try { + decoded = decodeURIComponent(value); + } catch { + throw new Error("outputReport.basePath must use valid URL encoding"); + } + if (decoded.split("/").some((segment) => segment === "." || segment === "..")) { + throw new Error("outputReport.basePath must not contain dot-segment traversal"); + } + return decoded.replace(/\/+$/, ""); +} + function positiveCount(value: number | undefined, fallback: number, label: string): number { const resolved = value ?? fallback; if (!Number.isSafeInteger(resolved) || resolved < 1) { @@ -141,7 +179,8 @@ function validateByteBudget(budget: SsgByteBudget | undefined, label: string): v } } -function validateConfig(config: SsgOutputReportConfig): void { +function validateConfig(config: SsgOutputReportConfig): string { + const basePath = deploymentBasePath(config.basePath); const budgets = config.budgets; validateByteBudget(budgets?.routes, "outputReport.budgets.routes"); for (const [route, budget] of Object.entries(budgets?.routeOverrides ?? {})) { @@ -167,6 +206,7 @@ function validateConfig(config: SsgOutputReportConfig): void { } positiveCount(config.largestPages, 20, "outputReport.largestPages"); positiveCount(config.largestAssets, 20, "outputReport.largestAssets"); + return basePath; } function budgetViolations(report: SsgOutputReport, budgets: SsgOutputBudgets = {}): string[] { @@ -242,7 +282,7 @@ export async function writeSsgOutputReport( inspections: ReadonlyMap, config: SsgOutputReportConfig = {}, ): Promise { - validateConfig(config); + const basePath = validateConfig(config); const assets: SsgOutputAsset[] = []; for (const filePath of (await emittedFiles(outputDir)).filter(isReportableAsset)) { const measured = size(await fs.readFile(path.join(outputDir, filePath))); @@ -260,7 +300,7 @@ export async function writeSsgOutputReport( const referenced = (references: readonly string[], type: SsgOutputAsset["type"]) => { const resolved: SsgOutputAsset[] = []; for (const reference of references) { - const localPath = localReference(reference, inspection.filePath); + const localPath = localReference(reference, inspection.filePath, basePath); if (!localPath) continue; const asset = assetMap.get(localPath); if (!asset) { diff --git a/tests/cli-smoke.test.ts b/tests/cli-smoke.test.ts index b5bec02..71922ac 100644 --- a/tests/cli-smoke.test.ts +++ b/tests/cli-smoke.test.ts @@ -1217,6 +1217,109 @@ test("should ensure runSsgCli writes the default report before publishing staged } }); +test("should ensure runSsgCli applies the output report deployment base path", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "askr-cli-output-base-path-")); + const outputDir = path.join(tempRoot, "dist"); + const { io, errors } = createIo(); + + try { + const code = await runSsgCli( + ["--config", "ssg.config.ts", "--output", outputDir], + { + cwd: () => tempRoot, + existsSync: () => true, + importConfig: async () => ({ + routes: [{ path: "/" }], + sitemap: false, + outputReport: { basePath: "/website" }, + }), + createStaticGen: (options) => ({ + generate: async () => { + await fs.mkdir(path.join(options.outputDir, "assets"), { recursive: true }); + await fs.writeFile( + path.join(options.outputDir, "index.html"), + '', + ); + await fs.writeFile(path.join(options.outputDir, "assets/app.js"), "export {};"); + return { + mode: "full", + successful: 1, + totalRoutes: 1, + failed: 0, + rebuilt: 1, + skipped: 0, + removed: 0, + cacheHits: 0, + routes: [{ path: "/", filePath: "index.html", status: "success" }], + }; + }, + }), + }, + io, + ); + + expect(code).toBe(0); + expect(errors).toHaveLength(0); + const report = JSON.parse( + await fs.readFile(path.join(outputDir, ".askr/ssg-output.json"), "utf8"), + ); + expect(report.routes[0].initial.javascript[0].path).toBe("assets/app.js"); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); + +test("should ensure runSsgCli preserves live output when a prefixed asset is missing", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "askr-cli-output-missing-asset-")); + const outputDir = path.join(tempRoot, "dist"); + await fs.mkdir(outputDir); + await fs.writeFile(path.join(outputDir, "index.html"), "previous"); + const { io, errors } = createIo(); + + try { + const code = await runSsgCli( + ["--config", "ssg.config.ts", "--output", outputDir], + { + cwd: () => tempRoot, + existsSync: () => true, + importConfig: async () => ({ + routes: [{ path: "/" }], + sitemap: false, + outputReport: { basePath: "/website" }, + }), + createStaticGen: (options) => ({ + generate: async () => { + await fs.mkdir(options.outputDir, { recursive: true }); + await fs.writeFile( + path.join(options.outputDir, "index.html"), + '', + ); + return { + mode: "full", + successful: 1, + totalRoutes: 1, + failed: 0, + rebuilt: 1, + skipped: 0, + removed: 0, + cacheHits: 0, + routes: [{ path: "/", filePath: "index.html", status: "success" }], + }; + }, + }), + }, + io, + ); + + expect(code).toBe(1); + expect(errors.join("\n")).toContain("missing javascript asset assets/missing.js"); + await expect(fs.readFile(path.join(outputDir, "index.html"), "utf8")).resolves.toBe("previous"); + await expect(fs.access(path.join(outputDir, ".askr/ssg-output.json"))).rejects.toThrow(); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); + test("should ensure runSsgCli preserves live output when an output budget fails", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "askr-cli-output-budget-")); const outputDir = path.join(tempRoot, "dist"); diff --git a/tests/output-report.test.ts b/tests/output-report.test.ts index 5c5d1c3..bc90487 100644 --- a/tests/output-report.test.ts +++ b/tests/output-report.test.ts @@ -119,6 +119,69 @@ describe("SSG output report", () => { expect(report.routes[1].initial.css[0].path).toBe("assets/app.css"); }); + it("should strip a configured deployment base path from root-absolute assets", async () => { + const { outputDir, routes } = await fixture(); + await fs.writeFile( + path.join(outputDir, "index.html"), + '' + + '', + ); + const inspections = await inspectSsgDocuments(outputDir, routes); + + const destination = await writeSsgOutputReport(outputDir, routes, inspections, { + basePath: "/website", + }); + const report = JSON.parse(await fs.readFile(destination, "utf8")); + + expect(report.routes[0].initial.javascript[0].path).toBe("assets/app.js"); + expect(report.routes[0].initial.css[0].path).toBe("assets/app.css"); + }); + + it("should preserve root and relative asset resolution with an empty base path", async () => { + const { outputDir, routes } = await fixture(); + await fs.writeFile( + path.join(outputDir, "guide/index.html"), + '' + + '', + ); + const inspections = await inspectSsgDocuments(outputDir, routes); + + await expect( + writeSsgOutputReport(outputDir, routes, inspections, { basePath: "/" }), + ).resolves.toBeTruthy(); + }); + + it("should not rewrite root-absolute assets outside the configured base path", async () => { + const { outputDir, routes } = await fixture(); + await fs.writeFile( + path.join(outputDir, "index.html"), + '', + ); + const inspections = await inspectSsgDocuments(outputDir, routes); + + await expect( + writeSsgOutputReport(outputDir, routes, inspections, { basePath: "/website" }), + ).rejects.toThrow(/missing javascript asset website-other\/assets\/app\.js/); + }); + + it.each([ + "website", + "https://example.com/website", + "/website?x=1", + "/website#x", + "/web\\site", + "/website/../private", + "/website/%2e%2e/private", + "/website/%zz", + ])("should reject malformed deployment base path %s", async (basePath) => { + const { outputDir, routes } = await fixture(); + const inspections = await inspectSsgDocuments(outputDir, routes); + + await expect( + writeSsgOutputReport(outputDir, routes, inspections, { basePath }), + ).rejects.toThrow(/outputReport\.basePath/); + }); + it("should reject missing local initial assets instead of dropping the reference", async () => { const { outputDir, routes } = await fixture(); await fs.writeFile(