Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 45 additions & 5 deletions src/ssg/output-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -101,7 +103,11 @@ export async function removeSsgOutputReport(outputDir: string): Promise<void> {
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("\\", "/");
Expand All @@ -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) {
Expand All @@ -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 ?? {})) {
Expand All @@ -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[] {
Expand Down Expand Up @@ -242,7 +282,7 @@ export async function writeSsgOutputReport(
inspections: ReadonlyMap<string, SsgDocumentInspection>,
config: SsgOutputReportConfig = {},
): Promise<string> {
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)));
Expand All @@ -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) {
Expand Down
103 changes: 103 additions & 0 deletions tests/cli-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
'<script type="module" src="/website/assets/app.js"></script>',
);
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"),
'<script type="module" src="/website/assets/missing.js"></script>',
);
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");
Expand Down
63 changes: 63 additions & 0 deletions tests/output-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
'<link rel="stylesheet" href="/website/assets/app.css">' +
'<script type="module" src="/website/assets/app.js"></script>',
);
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"),
'<link rel="stylesheet" href="../assets/app.css">' +
'<script type="module" src="/assets/app.js"></script>',
);
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"),
'<script type="module" src="/website-other/assets/app.js"></script>',
);
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(
Expand Down