diff --git a/README.md b/README.md index fcb9362..3fa629b 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,11 @@ For full examples, please see the [examples directory](./examples), and if you d | Option | Type | Default | Description | | --------------------------- | -------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `renderTarget` | `string` | `"body"` | Query selector for where to insert prerender result in your HTML template | -| `prerenderScript` | `string` | `undefined` | Absolute path to script containing exported `prerender()` function. If not provided, the plugin will try to find the prerender script in the scripts listed in your HTML entrypoint | -| `additionalPrerenderRoutes` | `string` | `undefined` | While the prerendering process can automatically find new links in your app to prerender, sometimes you will have pages that are not linked to but you still want them prerendered (such as a `/404` page). Use this option to add them to the prerender queue | -| `previewMiddlewareFallback` | `string` | `/index.html` | Fallback path to be used when an HTML document cannot be found via the preview middleware, e.g., `/404` or `/not-found` will be returned when the user requests `/some-path-that-does-not-exist` | +| `renderTarget` | `string` | `"body"` | Query selector for where to insert prerender result in your HTML template | +| `prerenderScript` | `string` | `undefined` | Absolute path to script containing exported `prerender()` function. If not provided, the plugin will try to find the prerender script in the scripts listed in your HTML entrypoint | +| `additionalPrerenderRoutes` | `string` | `undefined` | While the prerendering process can automatically find new links in your app to prerender, sometimes you will have pages that are not linked to but you still want them prerendered (such as a `/404` page). Use this option to add them to the prerender queue | +| `previewMiddlewareFallback` | `string` | `/index.html` | Fallback path to be used when an HTML document cannot be found via the preview middleware, e.g., `/404` or `/not-found` will be returned when the user requests `/some-path-that-does-not-exist` | +| `resolveRoute` | `(PrerenderedRoute) => string` | `(route) => route.url` | A function that resolves a route into a unique route. Should be used to differentiate between several routes with the same url but different data e.g. `{ url: "/" }` and `{ url: "/", data: true }` ## Advanced Prerender Options diff --git a/src/index.d.ts b/src/index.d.ts index bd5f476..340392c 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,9 +1,11 @@ import { Plugin } from 'vite'; +import type { PrerenderedRoute } from './plugins/types.d.ts'; export interface PrerenderOptions { prerenderScript?: string; renderTarget?: string; additionalPrerenderRoutes?: string[]; + resolveRoute?: (route: PrerenderedRoute) => string; } export interface PreviewMiddlewareOptions { diff --git a/src/plugins/prerender-plugin.js b/src/plugins/prerender-plugin.js index 1f01a4e..1368bcf 100644 --- a/src/plugins/prerender-plugin.js +++ b/src/plugins/prerender-plugin.js @@ -1,5 +1,6 @@ import path from 'node:path'; import { promises as fs } from 'node:fs'; +import { isDeepStrictEqual } from 'node:util'; import { createLogger } from 'vite'; import MagicString from 'magic-string'; @@ -71,7 +72,7 @@ function serializeElement(element) { * @param {import('../index.d.ts').PrerenderOptions} options * @returns {import('vite').Plugin} */ -export function prerenderPlugin({ prerenderScript, renderTarget, additionalPrerenderRoutes } = {}) { +export function prerenderPlugin({ prerenderScript, renderTarget, additionalPrerenderRoutes, resolveRoute } = {}) { let viteConfig = {}; let userEnabledSourceMaps; let ssrBuild = false; @@ -82,6 +83,7 @@ export function prerenderPlugin({ prerenderScript, renderTarget, additionalPrere renderTarget ||= 'body'; additionalPrerenderRoutes ||= []; + resolveRoute ||= (route) => route.url const preloadHelperId = 'vite/preload-helper'; const preloadPolyfillId = 'vite/modulepreload-polyfill'; @@ -430,16 +432,38 @@ export function prerenderPlugin({ prerenderScript, renderTarget, additionalPrere this.error('Detected `prerender` export, but it is not a function'); } + /** + * @param route {import('./types.d.ts').Route} + * @returns {import('./types.d.ts').ComplexRoute} + */ + const normalizeRoute = (route) => { + if (typeof route === "string") { + return ({ url: route }) + } else if (typeof route === "object") { + if (typeof route.url === "undefined") { + this.error('Route objects should have a defined url') + } else if (typeof route.url !== "string") { + this.error('Route url should be a string') + } + + return route + } + } + // We start by pre-rendering the home page. // Links discovered during pre-rendering get pushed into the list of routes. - const seen = new Set(['/', ...additionalPrerenderRoutes]); + /** @type {Map} */ + const seen = new Map(['/', ...additionalPrerenderRoutes].map((route) => { + let normalizedRoute = normalizeRoute(route); + return [resolveRoute(normalizedRoute), normalizedRoute]; + })); - routes = [...seen].map((link) => ({ url: link })); + routes = [...seen.values()]; for (const route of routes) { if (!route.url) continue; - const outDir = route.url.replace(/(^\/|\/$)/g, ''); + const outDir = resolveRoute(route).replace(/(^\/|\/$)/g, ''); const assetName = path.join(outDir, outDir.endsWith('.html') ? '' : 'index.html'); // Update `location` to current URL so routers can use things like `location.pathname` @@ -470,13 +494,15 @@ export function prerenderPlugin({ prerenderScript, renderTarget, additionalPrere // Add any discovered links to the list of routes to pre-render: if (result.links) { - for (let url of result.links) { - const parsed = new URL(url, 'http://localhost'); - url = parsed.pathname.replace(/\/$/, '') || '/'; + for (let link of result.links) { + link = normalizeRoute(link); + const parsed = new URL(link.url, 'http://localhost'); + link.url = parsed.pathname.replace(/\/$/, '') || '/'; + let resolved = resolveRoute(link); // ignore external links and ones we've already picked up - if (seen.has(url) || parsed.origin !== 'http://localhost') continue; - seen.add(url); - routes.push({ url, _discoveredBy: route }); + if (seen.has(resolved) || parsed.origin !== 'http://localhost') continue; + seen.set(resolved, link); + routes.push({ ...link, _discoveredBy: route }); } } diff --git a/src/plugins/types.d.ts b/src/plugins/types.d.ts index 7f4dc80..b3b816e 100644 --- a/src/plugins/types.d.ts +++ b/src/plugins/types.d.ts @@ -10,8 +10,14 @@ export interface Head { elements: Set; } -export interface PrerenderedRoute { +export type Route = ComplexRoute | ComplexRoute["url"]; + +export interface ComplexRoute { url: string; + data?: any; +} + +export interface PrerenderedRoute extends ComplexRoute { _discoveredBy?: PrerenderedRoute; } @@ -23,7 +29,7 @@ export interface PrerenderArguments { export type PrerenderResult = { html: string; - links?: Set; + links?: Set; data?: any; head?: Partial; } | string diff --git a/tests/complex-routes.test.js b/tests/complex-routes.test.js new file mode 100644 index 0000000..ff747a4 --- /dev/null +++ b/tests/complex-routes.test.js @@ -0,0 +1,26 @@ +import { test } from 'uvu'; +import * as assert from 'uvu/assert'; + +import { setupTest, teardownTest, loadFixture, viteBuild } from './lib/lifecycle.js'; +import { getOutputFile } from './lib/utils.js'; + +let env; +test.before.each(async () => { + env = await setupTest(); +}); + +test.after.each(async () => { + await teardownTest(env); +}); + +test('Should pass data to prerender script', async () => { + await loadFixture('complex-routes', env); + await viteBuild(env.tmp.path); + + const prerenderedIndexHtml = await getOutputFile(env.tmp.path, 'index.html'); + const prerenderedDataHtml = await getOutputFile(env.tmp.path, 'data/index.html'); + assert.match(prerenderedIndexHtml, '

data: no

'); + assert.match(prerenderedDataHtml, '

data: yes

'); +}); + +test.run(); diff --git a/tests/config.test.js b/tests/config.test.js index 9829e89..0cb68e6 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -143,4 +143,20 @@ test('Should support the `additionalPrerenderRoutes` plugin option', async () => assert.ok(await outputFileExists(env.tmp.path, 'not-found/index.html')); }); +test('Should support the `resolveRoute` plugin optionm', async () => { + await loadFixture('complex-routes', env); + await writeConfig(env.tmp.path, ` + import { defineConfig } from 'vite'; + import { vitePrerenderPlugin } from 'vite-prerender-plugin'; + + export default defineConfig({ + plugins: [vitePrerenderPlugin({ resolveRoute: (route) => { console.log(route); return \`\${route.data ? "/data" : ""}\${route.url}\` } })], + }); + `); + await viteBuild(env.tmp.path); + + assert.ok(await outputFileExists(env.tmp.path, 'index.html')); + assert.ok(await outputFileExists(env.tmp.path, 'data/data/index.html')); +}) + test.run(); diff --git a/tests/fixtures/complex-routes/index.html b/tests/fixtures/complex-routes/index.html new file mode 100644 index 0000000..3d387f8 --- /dev/null +++ b/tests/fixtures/complex-routes/index.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tests/fixtures/complex-routes/src/index.js b/tests/fixtures/complex-routes/src/index.js new file mode 100644 index 0000000..e7e19d8 --- /dev/null +++ b/tests/fixtures/complex-routes/src/index.js @@ -0,0 +1,11 @@ +export async function prerender({ route }) { + return { + html: `

data: ${route.data ? "yes" : "no"}

`, + links: new Set([ + { + url: "/data", + data: true + } + ]) + } +} diff --git a/tests/fixtures/complex-routes/vite.config.js b/tests/fixtures/complex-routes/vite.config.js new file mode 100644 index 0000000..4f19296 --- /dev/null +++ b/tests/fixtures/complex-routes/vite.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite'; +import { vitePrerenderPlugin } from 'vite-prerender-plugin'; + +export default defineConfig({ + plugins: [vitePrerenderPlugin()], +});