diff --git a/src/prerender/prerender.ts b/src/prerender/prerender.ts index f04d578a58..221dcec20d 100644 --- a/src/prerender/prerender.ts +++ b/src/prerender/prerender.ts @@ -97,6 +97,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(); @@ -181,8 +183,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)) { @@ -195,7 +201,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]], @@ -246,14 +252,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 @@ -292,16 +298,19 @@ 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) { - if (canPrerender(_link)) { - routes.add(_link); + // Crawled links include `baseURL`, while routes (and `ignore` patterns) are relative to it + const link = withoutBase(_link, nitro.options.baseURL); + if (canPrerender(link) && !routes.has(link)) { + crawledLinkPaths.set(link, _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..a6e6911f45 --- /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", "/base/base/about"] + .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..3527bb67b6 --- /dev/null +++ b/test/vite/prerender-baseurl.test.ts @@ -0,0 +1,49 @@ +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { readFile, 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", "/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", () => { + expect(routes).not.toContain("/admin"); + expect(routes).not.toContain("/base/admin"); + }); +});