From 500b719102bf43bb868c6ef13da860501385d8fc Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:36:35 -0700 Subject: [PATCH 1/2] fix(prerender): strip baseURL from crawled links Crawled links include baseURL while prerender routes are relative to it, so each crawled page rendered a second time and bypassed prerender.ignore and route rules. --- src/prerender/prerender.ts | 6 ++- .../prerender-baseurl-fixture/nitro.config.ts | 7 ++++ test/vite/prerender-baseurl-fixture/server.ts | 12 ++++++ .../prerender-baseurl-fixture/vite.config.ts | 6 +++ test/vite/prerender-baseurl.test.ts | 41 +++++++++++++++++++ 5 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 test/vite/prerender-baseurl-fixture/nitro.config.ts create mode 100644 test/vite/prerender-baseurl-fixture/server.ts create mode 100644 test/vite/prerender-baseurl-fixture/vite.config.ts create mode 100644 test/vite/prerender-baseurl.test.ts diff --git a/src/prerender/prerender.ts b/src/prerender/prerender.ts index 43e6c351af..7daa7071d8 100644 --- a/src/prerender/prerender.ts +++ b/src/prerender/prerender.ts @@ -311,8 +311,10 @@ export async function prerender(nitro: Nitro) { nitro.options.prerender.crawlLinks ?? false ); for (const _link of extractedLinks) { - if (canPrerender(_link)) { - routes.add(_link); + // Crawled links include `baseURL`, while routes are relative to it + const link = withoutBase(_link, nitro.options.baseURL); + if (canPrerender(link)) { + routes.add(link); } } } diff --git a/test/vite/prerender-baseurl-fixture/nitro.config.ts b/test/vite/prerender-baseurl-fixture/nitro.config.ts new file mode 100644 index 0000000000..64ea97ea70 --- /dev/null +++ b/test/vite/prerender-baseurl-fixture/nitro.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "nitro"; + +export default defineConfig({ + preset: "static", + baseURL: "/base/", + prerender: { routes: ["/"], crawlLinks: true, ignore: ["/admin"] }, +}); diff --git a/test/vite/prerender-baseurl-fixture/server.ts b/test/vite/prerender-baseurl-fixture/server.ts new file mode 100644 index 0000000000..c3a32687d0 --- /dev/null +++ b/test/vite/prerender-baseurl-fixture/server.ts @@ -0,0 +1,12 @@ +export default { + fetch(req: Request) { + const { pathname } = new URL(req.url); + // Links rendered by an app with `baseURL` include the base + const links = ["/base/", "/base/about", "/base/admin"] + .map((href) => `${href}`) + .join(""); + return new Response(`

${pathname}

${links}`, { + headers: { "content-type": "text/html" }, + }); + }, +}; diff --git a/test/vite/prerender-baseurl-fixture/vite.config.ts b/test/vite/prerender-baseurl-fixture/vite.config.ts new file mode 100644 index 0000000000..dc884054e6 --- /dev/null +++ b/test/vite/prerender-baseurl-fixture/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import { nitro } from "nitro/vite"; + +export default defineConfig({ + plugins: [nitro()], +}); diff --git a/test/vite/prerender-baseurl.test.ts b/test/vite/prerender-baseurl.test.ts new file mode 100644 index 0000000000..b8015f602e --- /dev/null +++ b/test/vite/prerender-baseurl.test.ts @@ -0,0 +1,41 @@ +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { rm, mkdir } from "node:fs/promises"; +import { beforeAll, describe, expect, it } from "vitest"; +import { createNitro, build, prepare } from "nitro/builder"; + +const fixtureDir = fileURLToPath(new URL("./prerender-baseurl-fixture", import.meta.url)); +const outDir = join(fixtureDir, ".tmp"); + +// https://github.com/nitrojs/nitro/issues/4441 +describe("prerender with baseURL", () => { + const routes: string[] = []; + + beforeAll(async () => { + await rm(outDir, { recursive: true, force: true }); + await mkdir(outDir, { recursive: true }); + const nitro = await createNitro({ + rootDir: fixtureDir, + output: { dir: outDir }, + builder: "vite", + }); + nitro.hooks.hook("prerender:route", (route) => { + routes.push(route.route); + }); + try { + await prepare(nitro); + await build(nitro); + } finally { + await nitro.close(); + } + }, 30_000); + + it("renders crawled links once, without the baseURL prefix", () => { + expect(routes.sort()).toEqual(["/", "/about"]); + }); + + it("applies `prerender.ignore` to crawled links", () => { + expect(routes).not.toContain("/admin"); + expect(routes).not.toContain("/base/admin"); + }); +}); From 1d42d634f20204d0ccddc57aea661ead4101f46b Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:50:28 -0700 Subject: [PATCH 2/2] fix(prerender): fetch and write crawled links by their original path Use the baseURL-relative route only as the dedupe and ignore key, so a route that itself starts with the base segment (/base/base/about) is still fetched and written as before. --- src/prerender/prerender.ts | 27 ++++++++++++------- test/vite/prerender-baseurl-fixture/server.ts | 2 +- test/vite/prerender-baseurl.test.ts | 12 +++++++-- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/prerender/prerender.ts b/src/prerender/prerender.ts index 7daa7071d8..4eb1289605 100644 --- a/src/prerender/prerender.ts +++ b/src/prerender/prerender.ts @@ -108,6 +108,8 @@ export async function prerender(nitro: Nitro) { // Start prerendering const generatedRoutes = new Set(); + // Crawled links, keyed by route (relative to `baseURL`), with their original path + const crawledLinkPaths = new Map(); const failedRoutes = new Set(); const skippedRoutes = new Set(); const displayedLengthWarns = new Set(); @@ -192,8 +194,12 @@ export async function prerender(nitro: Nitro) { const generateRoute = async (route: string) => { const start = Date.now(); + // Crawled links are fetched, resolved and written by their original path + const crawledPath = crawledLinkPaths.get(route); + // Ensure route is decoded to start with route = decodeURI(route); + const routePath = crawledPath ? decodeURI(crawledPath) : route; // Check if we should render route if (!canPrerender(route)) { @@ -206,7 +212,7 @@ export async function prerender(nitro: Nitro) { const _route: PrerenderRoute = { route }; // Fetch the route - const encodedRoute = encodeURI(route); + const encodedRoute = encodeURI(routePath); const req = toRequest(withBase(encodedRoute, nitro.options.baseURL), { headers: [["x-nitro-prerender", encodedRoute]], @@ -257,14 +263,14 @@ export async function prerender(nitro: Nitro) { // Guess route type and populate fileName const contentType = res.headers.get("content-type") || ""; const isImplicitHTML = - !route.endsWith(".html") && + !routePath.endsWith(".html") && contentType.includes("html") && !JsonSigRx.test(dataBuff!.subarray(0, 32).toString("utf8")); - const routeWithIndex = route.endsWith("/") ? route + "index" : route; + const routeWithIndex = routePath.endsWith("/") ? routePath + "index" : routePath; const htmlPath = - route.endsWith("/") || nitro.options.prerender.autoSubfolderIndex - ? joinURL(route, "index.html") - : route + ".html"; + routePath.endsWith("/") || nitro.options.prerender.autoSubfolderIndex + ? joinURL(routePath, "index.html") + : routePath + ".html"; _route.fileName = withoutBase( isImplicitHTML ? htmlPath : routeWithIndex, nitro.options.baseURL @@ -303,17 +309,18 @@ export async function prerender(nitro: Nitro) { } // Crawl route links - if (!_route.error && (isImplicitHTML || route.endsWith(".html"))) { + if (!_route.error && (isImplicitHTML || routePath.endsWith(".html"))) { const extractedLinks = await extractLinks( dataBuff!.toString("utf8"), - route, + routePath, res, nitro.options.prerender.crawlLinks ?? false ); for (const _link of extractedLinks) { - // Crawled links include `baseURL`, while routes are relative to it + // Crawled links include `baseURL`, while routes (and `ignore` patterns) are relative to it const link = withoutBase(_link, nitro.options.baseURL); - if (canPrerender(link)) { + if (canPrerender(link) && !routes.has(link)) { + crawledLinkPaths.set(link, _link); routes.add(link); } } diff --git a/test/vite/prerender-baseurl-fixture/server.ts b/test/vite/prerender-baseurl-fixture/server.ts index c3a32687d0..a6e6911f45 100644 --- a/test/vite/prerender-baseurl-fixture/server.ts +++ b/test/vite/prerender-baseurl-fixture/server.ts @@ -2,7 +2,7 @@ export default { fetch(req: Request) { const { pathname } = new URL(req.url); // Links rendered by an app with `baseURL` include the base - const links = ["/base/", "/base/about", "/base/admin"] + const links = ["/base/", "/base/about", "/base/admin", "/base/base/about"] .map((href) => `${href}`) .join(""); return new Response(`

${pathname}

${links}`, { diff --git a/test/vite/prerender-baseurl.test.ts b/test/vite/prerender-baseurl.test.ts index b8015f602e..3527bb67b6 100644 --- a/test/vite/prerender-baseurl.test.ts +++ b/test/vite/prerender-baseurl.test.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from "node:url"; import { join } from "node:path"; -import { rm, mkdir } from "node:fs/promises"; +import { readFile, rm, mkdir } from "node:fs/promises"; import { beforeAll, describe, expect, it } from "vitest"; import { createNitro, build, prepare } from "nitro/builder"; @@ -31,7 +31,15 @@ describe("prerender with baseURL", () => { }, 30_000); it("renders crawled links once, without the baseURL prefix", () => { - expect(routes.sort()).toEqual(["/", "/about"]); + expect(routes.sort()).toEqual(["/", "/about", "/base/about"]); + }); + + it("fetches and writes crawled links by their original path", async () => { + const read = (file: string) => readFile(join(outDir, "public", file), "utf8"); + expect(await read("index.html")).toContain("

/base

"); + expect(await read("about/index.html")).toContain("

/base/about

"); + // `/base/base/about` is the `/base/about` route, not a duplicate of `/about` + expect(await read("base/about/index.html")).toContain("

/base/base/about

"); }); it("applies `prerender.ignore` to crawled links", () => {