diff --git a/.changeset/fuzzy-insects-shake.md b/.changeset/fuzzy-insects-shake.md new file mode 100644 index 000000000000..282d7ba94443 --- /dev/null +++ b/.changeset/fuzzy-insects-shake.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: route-level entry generators via `export const entries` diff --git a/documentation/docs/20-core-concepts/40-page-options.md b/documentation/docs/20-core-concepts/40-page-options.md index d0444baab408..5804fea5df44 100644 --- a/documentation/docs/20-core-concepts/40-page-options.md +++ b/documentation/docs/20-core-concepts/40-page-options.md @@ -33,7 +33,7 @@ export const prerender = 'auto'; > If your entire app is suitable for prerendering, you can use [`adapter-static`](https://github.com/sveltejs/kit/tree/master/packages/adapter-static), which will output files suitable for use with any static webserver. -The prerenderer will start at the root of your app and generate files for any prerenderable pages or `+server.js` routes it finds. Each page is scanned for `` elements that point to other pages that are candidates for prerendering — because of this, you generally don't need to specify which pages should be accessed. If you _do_ need to specify which pages should be accessed by the prerenderer, you can do so with the `entries` option in the [prerender configuration](configuration#prerender). +The prerenderer will start at the root of your app and generate files for any prerenderable pages or `+server.js` routes it finds. Each page is scanned for `` elements that point to other pages that are candidates for prerendering — because of this, you generally don't need to specify which pages should be accessed. If you _do_ need to specify which pages should be accessed by the prerenderer, you can do so with [`config.kit.prerender.entries`](configuration#prerender), or by exporting an [`entries`](#entries) function from your dynamic route. While prerendering, the value of `building` imported from [`$app/environment`](modules#$app-environment) will be `true`. @@ -84,9 +84,40 @@ If you encounter an error like 'The following routes were marked as prerenderabl Since these routes cannot be dynamically server-rendered, this will cause errors when people try to access the route in question. There are two ways to fix it: -* Ensure that SvelteKit can find the route by following links from [`config.kit.prerender.entries`](configuration#prerender). Add links to dynamic routes (i.e. pages with `[parameters]` ) to this option if they are not found through crawling the other entry points, else they are not prerendered because SvelteKit doesn't know what value the parameters should have. Pages not marked as prerenderable will be ignored and their links to other pages will not be crawled, even if some of them would be prerenderable. +* Ensure that SvelteKit can find the route by following links from [`config.kit.prerender.entries`](configuration#prerender) or the [`entries`](#entries) page option. Add links to dynamic routes (i.e. pages with `[parameters]` ) to this option if they are not found through crawling the other entry points, else they are not prerendered because SvelteKit doesn't know what value the parameters should have. Pages not marked as prerenderable will be ignored and their links to other pages will not be crawled, even if some of them would be prerenderable. * Change `export const prerender = true` to `export const prerender = 'auto'`. Routes with `'auto'` can be dynamically server rendered +## entries + +SvelteKit will discover pages to prerender automatically, by starting at _entry points_ and crawling them. By default, all your non-dynamic routes are considered entry points — for example, if you have these routes... + +```bash +/ # non-dynamic +/blog # non-dynamic +/blog/[slug] # dynamic, because of `[slug]` +``` + +...SvelteKit will prerender `/` and `/blog`, and in the process discover links like `` which give it new pages to prerender. + +Most of the time, that's enough. In some situations, links to pages like `/blog/hello-world` might not exist (or might not exist on prerendered pages), in which case we need to tell SvelteKit about their existence. + +This can be done with [`config.kit.prerender.entries`](configuration#prerender), or by exporting an `entries` function from a `+page.js` or `+page.server.js` belonging to a dynamic route: + +```js +/// file: src/routes/blog/[slug]/+page.server.js +/** @type {import('./$types').EntryGenerator} */ +export function entries() { + return [ + { slug: 'hello-world' }, + { slug: 'another-blog-post' } + ]; +} + +export const prerender = true; +``` + +`entries` can be an `async` function, allowing you to (for example) retrieve a list of posts from a CMS or database, in the example above. + ## ssr Normally, SvelteKit renders your page on the server first and sends that HTML to the client where it's [hydrated](glossary#hydration). If you set `ssr` to `false`, it renders an empty 'shell' page instead. This is useful if your page is unable to be rendered on the server (because you use browser-only globals like `document` for example), but in most situations it's not recommended ([see appendix](glossary#ssr)). diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index b1b63d3a3154..e039060cc17c 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -229,6 +229,20 @@ const options = object( } ), + handleEntryGeneratorMismatch: validate( + (/** @type {any} */ { message }) => { + throw new Error( + message + + `\nTo suppress or handle this error, implement \`handleEntryGeneratorMismatch\` in https://kit.svelte.dev/docs/configuration#prerender` + ); + }, + (input, keypath) => { + if (typeof input === 'function') return input; + if (['fail', 'warn', 'ignore'].includes(input)) return input; + throw new Error(`${keypath} should be "fail", "warn", "ignore" or a custom function`); + } + ), + origin: validate('http://sveltekit-prerender', (input, keypath) => { assert_string(input, keypath); diff --git a/packages/kit/src/core/postbuild/analyse.js b/packages/kit/src/core/postbuild/analyse.js index a9b1a1d1fb51..01ad617f5249 100644 --- a/packages/kit/src/core/postbuild/analyse.js +++ b/packages/kit/src/core/postbuild/analyse.js @@ -2,7 +2,9 @@ import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { get_option } from '../../utils/options.js'; import { - validate_common_exports, + validate_layout_exports, + validate_layout_server_exports, + validate_page_exports, validate_page_server_exports, validate_server_exports } from '../../utils/exports.js'; @@ -10,6 +12,7 @@ import { load_config } from '../config/index.js'; import { forked } from '../../utils/fork.js'; import { should_polyfill } from '../../utils/platform.js'; import { installPolyfills } from '../../exports/node/polyfills.js'; +import { resolve_entry } from '../../utils/routing.js'; export default forked(import.meta.url, analyse); @@ -72,6 +75,8 @@ async function analyse({ manifest_path, env }) { let prerender = undefined; /** @type {any} */ let config = undefined; + /** @type {import('types').PrerenderEntryGenerator | undefined} */ + let entries = undefined; if (route.endpoint) { const mod = await route.endpoint(); @@ -95,6 +100,7 @@ async function analyse({ manifest_path, env }) { if (mod.OPTIONS) api_methods.push('OPTIONS'); config = mod.config; + entries = mod.entries; } if (route.page) { @@ -109,8 +115,8 @@ async function analyse({ manifest_path, env }) { for (const layout of layouts) { if (layout) { - validate_common_exports(layout.server, layout.server_id); - validate_common_exports(layout.universal, layout.universal_id); + validate_layout_server_exports(layout.server, layout.server_id); + validate_layout_exports(layout.universal, layout.universal_id); } } @@ -119,12 +125,13 @@ async function analyse({ manifest_path, env }) { if (page.server?.actions) page_methods.push('POST'); validate_page_server_exports(page.server, page.server_id); - validate_common_exports(page.universal, page.universal_id); + validate_page_exports(page.universal, page.universal_id); } prerender = get_option(nodes, 'prerender') ?? false; config = get_config(nodes); + entries ??= get_option(nodes, 'entries'); } metadata.routes.set(route.id, { @@ -136,7 +143,9 @@ async function analyse({ manifest_path, env }) { api: { methods: api_methods }, - prerender + prerender, + entries: + entries && (await entries()).map((entry_object) => resolve_entry(route.id, entry_object)) }); } diff --git a/packages/kit/src/core/postbuild/prerender.js b/packages/kit/src/core/postbuild/prerender.js index 379ba3da2895..f4cbb7415066 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -127,6 +127,14 @@ async function prerender({ out, manifest_path, metadata, verbose, env }) { } ); + const handle_entry_generator_mismatch = normalise_error_handler( + log, + config.prerender.handleEntryGeneratorMismatch, + ({ generatedFromId, entry, matchedId }) => { + return `The entries export from ${generatedFromId} generated entry ${entry}, which was matched by ${matchedId} - see the \`handleEntryGeneratorMismatch\` option in https://kit.svelte.dev/docs/configuration#prerender for more info.`; + } + ); + const q = queue(config.prerender.concurrency); /** @@ -164,23 +172,25 @@ async function prerender({ out, manifest_path, metadata, verbose, env }) { * @param {string | null} referrer * @param {string} decoded * @param {string} [encoded] + * @param {string} [generated_from_id] */ - function enqueue(referrer, decoded, encoded) { + function enqueue(referrer, decoded, encoded, generated_from_id) { if (seen.has(decoded)) return; seen.add(decoded); const file = decoded.slice(config.paths.base.length + 1); if (files.has(file)) return; - return q.add(() => visit(decoded, encoded || encodeURI(decoded), referrer)); + return q.add(() => visit(decoded, encoded || encodeURI(decoded), referrer, generated_from_id)); } /** * @param {string} decoded * @param {string} encoded * @param {string?} referrer + * @param {string} [generated_from_id] */ - async function visit(decoded, encoded, referrer) { + async function visit(decoded, encoded, referrer, generated_from_id) { if (!decoded.startsWith(config.paths.base)) { handle_http_error({ status: 404, path: decoded, referrer, referenceType: 'linked' }); return; @@ -206,6 +216,20 @@ async function prerender({ out, manifest_path, metadata, verbose, env }) { } }); + const encoded_id = response.headers.get('x-sveltekit-routeid'); + const decoded_id = encoded_id && decode_uri(encoded_id); + if ( + decoded_id !== null && + generated_from_id !== undefined && + decoded_id !== generated_from_id + ) { + handle_entry_generator_mismatch({ + generatedFromId: generated_from_id, + entry: decoded, + matchedId: decoded_id + }); + } + const body = Buffer.from(await response.arrayBuffer()); save('pages', response, body, decoded, encoded, referrer, 'linked'); @@ -378,9 +402,18 @@ async function prerender({ out, manifest_path, metadata, verbose, env }) { saved.set(file, dest); } + /** @type {Array<{ id: string, entries: Array}>} */ + const route_level_entries = []; + for (const [id, { entries }] of metadata.routes.entries()) { + if (entries) { + route_level_entries.push({ id, entries }); + } + } + if ( config.prerender.entries.length > 1 || config.prerender.entries[0] !== '*' || + route_level_entries.length > 0 || prerender_map.size > 0 ) { // Only log if we're actually going to do something to not confuse users @@ -401,6 +434,12 @@ async function prerender({ out, manifest_path, metadata, verbose, env }) { } } + for (const { id, entries } of route_level_entries) { + for (const entry of entries) { + enqueue(null, config.paths.base + entry, undefined, id); + } + } + await q.done(); // handle invalid fragment links diff --git a/packages/kit/src/core/sync/write_types/index.js b/packages/kit/src/core/sync/write_types/index.js index c1e0c827c825..e7ff358fe5bd 100644 --- a/packages/kit/src/core/sync/write_types/index.js +++ b/packages/kit/src/core/sync/write_types/index.js @@ -194,6 +194,12 @@ function update_types(config, routes, route, to_delete = new Set()) { .join('; ')} }` ); + if (route.params.length > 0) { + exports.push( + `export type EntryGenerator = () => Promise> | Array;` + ); + } + declarations.push(`type RouteId = '${route.id}';`); // These could also be placed in our public types, but it would bloat them unnecessarily and we may want to change these in the future diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 9d8997d34ef4..2c5c912f0bcc 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -31,7 +31,7 @@ import { stores } from './singletons.js'; import { unwrap_promises } from '../../utils/promises.js'; import * as devalue from 'devalue'; import { INDEX_KEY, PRELOAD_PRIORITIES, SCROLL_KEY, SNAPSHOT_KEY } from './constants.js'; -import { validate_common_exports } from '../../utils/exports.js'; +import { validate_page_exports } from '../../utils/exports.js'; import { compact } from '../../utils/array.js'; import { INVALIDATED_PARAM, validate_depends } from '../shared.js'; @@ -428,7 +428,7 @@ export function create_client(app, target) { const node = await loader(); if (DEV) { - validate_common_exports(node.universal); + validate_page_exports(node.universal); } if (node.universal?.load) { diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 26fec78a1cba..b29e2c6484ed 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -20,7 +20,9 @@ import { add_cookies_to_headers, get_cookies } from './cookie.js'; import { create_fetch } from './fetch.js'; import { Redirect } from '../control.js'; import { - validate_common_exports, + validate_layout_exports, + validate_layout_server_exports, + validate_page_exports, validate_page_server_exports, validate_server_exports } from '../../utils/exports.js'; @@ -197,8 +199,11 @@ export async function respond(request, options, manifest, state) { for (const layout of layouts) { if (layout) { - validate_common_exports(layout.server, /** @type {string} */ (layout.server_id)); - validate_common_exports( + validate_layout_server_exports( + layout.server, + /** @type {string} */ (layout.server_id) + ); + validate_layout_exports( layout.universal, /** @type {string} */ (layout.universal_id) ); @@ -207,7 +212,7 @@ export async function respond(request, options, manifest, state) { if (page) { validate_page_server_exports(page.server, /** @type {string} */ (page.server_id)); - validate_common_exports(page.universal, /** @type {string} */ (page.universal_id)); + validate_page_exports(page.universal, /** @type {string} */ (page.universal_id)); } } diff --git a/packages/kit/src/utils/exports.js b/packages/kit/src/utils/exports.js index 7f78ec0381a7..bcf830926028 100644 --- a/packages/kit/src/utils/exports.js +++ b/packages/kit/src/utils/exports.js @@ -1,9 +1,7 @@ /** - * @param {string[]} expected + * @param {Set} expected */ function validator(expected) { - const set = new Set(expected); - /** * @param {any} module * @param {string} [file] @@ -12,11 +10,13 @@ function validator(expected) { if (!module) return; for (const key in module) { - if (key[0] === '_' || set.has(key)) continue; // key is valid in this module + if (key[0] === '_' || expected.has(key)) continue; // key is valid in this module + + const values = [...expected.values()]; const hint = hint_for_supported_files(key, file?.slice(file.lastIndexOf('.'))) ?? - `valid exports are ${expected.join(', ')}, or anything with a '_' prefix`; + `valid exports are ${values.join(', ')}, or anything with a '_' prefix`; throw new Error(`Invalid export '${key}'${file ? ` in ${file}` : ''} (${hint})`); } @@ -33,34 +33,45 @@ function validator(expected) { function hint_for_supported_files(key, ext = '.js') { let supported_files = []; - if (valid_common_exports.includes(key)) { + if (valid_layout_exports.has(key)) { + supported_files.push(`+layout${ext}`); + } + + if (valid_page_exports.has(key)) { supported_files.push(`+page${ext}`); } - if (valid_page_server_exports.includes(key)) { + if (valid_layout_server_exports.has(key)) { + supported_files.push(`+layout.server${ext}`); + } + + if (valid_page_server_exports.has(key)) { supported_files.push(`+page.server${ext}`); } - if (valid_server_exports.includes(key)) { + if (valid_server_exports.has(key)) { supported_files.push(`+server${ext}`); } if (supported_files.length > 0) { - return `'${key}' is a valid export in ${supported_files.join(` or `)}`; + return `'${key}' is a valid export in ${supported_files.slice(0, -1).join(`, `)}${ + supported_files.length > 1 ? ' or ' : '' + }${supported_files.at(-1)}`; } } -const valid_common_exports = ['load', 'prerender', 'csr', 'ssr', 'trailingSlash', 'config']; -const valid_page_server_exports = [ +const valid_layout_exports = new Set([ 'load', 'prerender', 'csr', 'ssr', - 'actions', 'trailingSlash', 'config' -]; -const valid_server_exports = [ +]); +const valid_page_exports = new Set([...valid_layout_exports, 'entries']); +const valid_layout_server_exports = new Set([...valid_layout_exports, 'actions']); +const valid_page_server_exports = new Set([...valid_layout_server_exports, 'entries']); +const valid_server_exports = new Set([ 'GET', 'POST', 'PATCH', @@ -69,9 +80,12 @@ const valid_server_exports = [ 'OPTIONS', 'prerender', 'trailingSlash', - 'config' -]; + 'config', + 'entries' +]); -export const validate_common_exports = validator(valid_common_exports); +export const validate_layout_exports = validator(valid_layout_exports); +export const validate_page_exports = validator(valid_page_exports); +export const validate_layout_server_exports = validator(valid_layout_server_exports); export const validate_page_server_exports = validator(valid_page_server_exports); export const validate_server_exports = validator(valid_server_exports); diff --git a/packages/kit/src/utils/exports.spec.js b/packages/kit/src/utils/exports.spec.js index bbca70d0e7a1..fbbdf0be46a5 100644 --- a/packages/kit/src/utils/exports.spec.js +++ b/packages/kit/src/utils/exports.spec.js @@ -1,7 +1,9 @@ import { test } from 'uvu'; import * as assert from 'uvu/assert'; import { - validate_common_exports, + validate_layout_exports, + validate_layout_server_exports, + validate_page_exports, validate_page_server_exports, validate_server_exports } from './exports.js'; @@ -22,41 +24,117 @@ function check_error(fn, message) { assert.equal(error?.message, message); } -test('validates +layout.server.js, +layout.js, +page.js', () => { - validate_common_exports({ - load: () => {} +test('validates +layout.js', () => { + validate_layout_exports({ + load: () => {}, + prerender: false, + csr: false, + ssr: false, + trailingSlash: false, + config: {} }); - validate_common_exports({ + validate_layout_exports({ _unknown: () => {} }); check_error(() => { - validate_common_exports({ + validate_layout_exports({ answer: 42 }); }, `Invalid export 'answer' (valid exports are load, prerender, csr, ssr, trailingSlash, config, or anything with a '_' prefix)`); check_error(() => { - validate_common_exports( + validate_layout_exports( + { + actions: {} + }, + 'src/routes/foo/+page.ts' + ); + }, `Invalid export 'actions' in src/routes/foo/+page.ts ('actions' is a valid export in +layout.server.ts or +page.server.ts)`); + + check_error(() => { + validate_layout_exports({ + GET: {} + }); + }, `Invalid export 'GET' ('GET' is a valid export in +server.js)`); +}); + +test('validates +page.js', () => { + validate_page_exports({ + load: () => {}, + prerender: false, + csr: false, + ssr: false, + trailingSlash: false, + config: {}, + entries: () => {} + }); + + validate_page_exports({ + _unknown: () => {} + }); + + check_error(() => { + validate_page_exports({ + answer: 42 + }); + }, `Invalid export 'answer' (valid exports are load, prerender, csr, ssr, trailingSlash, config, entries, or anything with a '_' prefix)`); + + check_error(() => { + validate_page_exports( { actions: {} }, 'src/routes/foo/+page.ts' ); - }, `Invalid export 'actions' in src/routes/foo/+page.ts ('actions' is a valid export in +page.server.ts)`); + }, `Invalid export 'actions' in src/routes/foo/+page.ts ('actions' is a valid export in +layout.server.ts or +page.server.ts)`); check_error(() => { - validate_common_exports({ + validate_page_exports({ GET: {} }); }, `Invalid export 'GET' ('GET' is a valid export in +server.js)`); }); +test('validates +layout.server.js', () => { + validate_layout_server_exports({ + load: () => {}, + prerender: false, + csr: false, + ssr: false, + trailingSlash: false, + config: {}, + actions: {} + }); + + validate_layout_server_exports({ + _unknown: () => {} + }); + + check_error(() => { + validate_layout_server_exports({ + answer: 42 + }); + }, `Invalid export 'answer' (valid exports are load, prerender, csr, ssr, trailingSlash, config, actions, or anything with a '_' prefix)`); + + check_error(() => { + validate_layout_server_exports({ + POST: {} + }); + }, `Invalid export 'POST' ('POST' is a valid export in +server.js)`); +}); + test('validates +page.server.js', () => { validate_page_server_exports({ load: () => {}, - actions: {} + prerender: false, + csr: false, + ssr: false, + trailingSlash: false, + config: {}, + actions: {}, + entries: () => {} }); validate_page_server_exports({ @@ -67,7 +145,7 @@ test('validates +page.server.js', () => { validate_page_server_exports({ answer: 42 }); - }, `Invalid export 'answer' (valid exports are load, prerender, csr, ssr, actions, trailingSlash, config, or anything with a '_' prefix)`); + }, `Invalid export 'answer' (valid exports are load, prerender, csr, ssr, trailingSlash, config, actions, entries, or anything with a '_' prefix)`); check_error(() => { validate_page_server_exports({ @@ -89,13 +167,13 @@ test('validates +server.js', () => { validate_server_exports({ answer: 42 }); - }, `Invalid export 'answer' (valid exports are GET, POST, PATCH, PUT, DELETE, OPTIONS, prerender, trailingSlash, config, or anything with a '_' prefix)`); + }, `Invalid export 'answer' (valid exports are GET, POST, PATCH, PUT, DELETE, OPTIONS, prerender, trailingSlash, config, entries, or anything with a '_' prefix)`); check_error(() => { validate_server_exports({ csr: false }); - }, `Invalid export 'csr' ('csr' is a valid export in +page.js or +page.server.js)`); + }, `Invalid export 'csr' ('csr' is a valid export in +layout.js, +page.js, +layout.server.js or +page.server.js)`); }); test.run(); diff --git a/packages/kit/src/utils/options.js b/packages/kit/src/utils/options.js index a1c9088a4c74..46a32535128c 100644 --- a/packages/kit/src/utils/options.js +++ b/packages/kit/src/utils/options.js @@ -1,6 +1,6 @@ /** - * @template {'prerender' | 'ssr' | 'csr' | 'trailingSlash'} Option - * @template {Option extends 'prerender' ? import('types').PrerenderOption : Option extends 'trailingSlash' ? import('types').TrailingSlash : boolean} Value + * @template {'prerender' | 'ssr' | 'csr' | 'trailingSlash' | 'entries'} Option + * @template {(import('types').SSRNode['universal'] | import('types').SSRNode['server'])[Option]} Value * * @param {Array} nodes * @param {Option} option @@ -9,7 +9,7 @@ */ export function get_option(nodes, option) { return nodes.reduce((value, node) => { - return /** @type {any} TypeScript's too dumb to understand this */ ( + return /** @type {Value} TypeScript's too dumb to understand this */ ( node?.universal?.[option] ?? node?.server?.[option] ?? value ); }, /** @type {Value | undefined} */ (undefined)); diff --git a/packages/kit/src/utils/routing.js b/packages/kit/src/utils/routing.js index a5a9fdb938af..4318fe08dbc5 100644 --- a/packages/kit/src/utils/routing.js +++ b/packages/kit/src/utils/routing.js @@ -96,6 +96,61 @@ export function parse_route_id(id) { return { pattern, params }; } +const basic_param_pattern = /\[(\[)?(?:\.\.\.)?(\w+?)(?:=(\w+))?\]\]?/; + +/** + * Parses a route ID, then resolves it to a path by replacing parameters with actual values from `entry`. + * @param {string} id The route id + * @param {Record} entry The entry meant to populate the route. For example, if the route is `/blog/[slug]`, the entry would be `{ slug: 'hello-world' }` + * @example + * ```js + * resolve_entry(`/blog/[slug]/[...somethingElse]`, { slug: 'hello-world', somethingElse: 'something/else' }); // `/blog/hello-world/something/else` + * ``` + */ +export function resolve_entry(id, entry) { + const segments = get_route_segments(id); + return ( + '/' + + segments + .map((segment) => { + const match = basic_param_pattern.exec(segment); + + // static content -- i.e. not a param + if (!match) return segment; + + const optional = !!match[1]; + const name = match[2]; + const param_value = entry[name]; + + // This is nested so TS correctly narrows the type + if (!param_value) { + if (optional) return ''; + throw new Error(`Missing parameter '${name}' in route ${id}`); + } + + if (param_value.startsWith('/') || param_value.endsWith('/')) + throw new Error( + `Parameter '${name}' in route ${id} cannot start or end with a slash -- this would cause an invalid route like foo//bar` + ); + + return param_value; + }) + .filter(Boolean) + .join('/') + ); +} + +const optional_param_regex = /\/\[\[\w+?(?:=\w+)?\]\]/; + +/** + * Removes optional params from a route ID. + * @param {string} id + * @returns The route id with optional params removed + */ +export function remove_optional_params(id) { + return id.replace(optional_param_regex, ''); +} + /** * Returns `false` for `(group)` segments * @param {string} segment diff --git a/packages/kit/src/utils/routing.spec.js b/packages/kit/src/utils/routing.spec.js index 4ba8737fe479..d20ce871558b 100644 --- a/packages/kit/src/utils/routing.spec.js +++ b/packages/kit/src/utils/routing.spec.js @@ -1,6 +1,6 @@ import { test } from 'uvu'; import * as assert from 'uvu/assert'; -import { exec, parse_route_id } from './routing.js'; +import { exec, parse_route_id, resolve_entry } from './routing.js'; const tests = { '/': { @@ -218,9 +218,52 @@ for (const { path, route, expected } of exec_tests) { }); } -test('errors on bad param name', () => { +test('parse_route_id errors on bad param name', () => { assert.throws(() => parse_route_id('abc/[b-c]'), /Invalid param: b-c/); assert.throws(() => parse_route_id('abc/[bc=d-e]'), /Invalid param: bc=d-e/); }); +const from_entry_tests = [ + { + route: '/blog/[one]/[two]', + entry: { one: 'one', two: 'two' }, + expected: '/blog/one/two' + }, + { + route: '/blog/[one=matcher]/[...two]', + entry: { one: 'one', two: 'two/three' }, + expected: '/blog/one/two/three' + }, + { + route: '/blog/[one=matcher]/[[two]]', + entry: { one: 'one' }, + expected: '/blog/one' + } +]; + +for (const { route, entry, expected } of from_entry_tests) { + test(`resolve_entry generates correct path for ${route}`, () => { + const result = resolve_entry(route, entry); + assert.equal(result, expected); + }); +} + +test('resolve_entry errors on missing entry for required param', () => { + assert.throws( + () => resolve_entry('/blog/[one]/[two]', { one: 'one' }), + "Missing param 'two' in route /blog/[one]/[two]" + ); +}); + +test('resolve_entry errors on entry values starting or ending with slashes', () => { + assert.throws( + () => resolve_entry('/blog/[one]/[two]', { one: 'one', two: '/two' }), + "Parameter 'two' in route /blog/[one]/[two] cannot start or end with a slash -- this would cause an invalid route like foo//bar" + ); + assert.throws( + () => resolve_entry('/blog/[one]/[two]', { one: 'one', two: 'two/' }), + "Parameter 'two' in route /blog/[one]/[two] cannot start or end with a slash -- this would cause an invalid route like foo//bar" + ); +}); + test.run(); diff --git a/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/package.json b/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/package.json new file mode 100644 index 000000000000..a1176e486f96 --- /dev/null +++ b/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/package.json @@ -0,0 +1,20 @@ +{ + "name": "prerenderable-incorrect-fragment", + "private": true, + "version": "0.0.1", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && tsc && svelte-check" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "workspace:^", + "@sveltejs/kit": "workspace:^", + "svelte": "^3.56.0", + "svelte-check": "^3.0.2", + "typescript": "^4.9.4", + "vite": "^4.2.0" + }, + "type": "module" +} diff --git a/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/src/app.html b/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/src/app.html new file mode 100644 index 000000000000..5b53ef7e3ae7 --- /dev/null +++ b/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/src/routes/+layout.js b/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/src/routes/+layout.js new file mode 100644 index 000000000000..189f71e2e1b3 --- /dev/null +++ b/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/src/routes/+layout.js @@ -0,0 +1 @@ +export const prerender = true; diff --git a/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/src/routes/[slug]/[notSpecific]/+page.ts b/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/src/routes/[slug]/[notSpecific]/+page.ts new file mode 100644 index 000000000000..9e91d7e49886 --- /dev/null +++ b/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/src/routes/[slug]/[notSpecific]/+page.ts @@ -0,0 +1,4 @@ +/** @type {import('./$types').EntryGenerator} */ +export const entries = () => { + return [{ slug: 'whatever', notSpecific: 'specific' }]; +}; diff --git a/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/src/routes/[slug]/specific/+page.svelte b/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/src/routes/[slug]/specific/+page.svelte new file mode 100644 index 000000000000..2a38aa473510 --- /dev/null +++ b/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/src/routes/[slug]/specific/+page.svelte @@ -0,0 +1 @@ +
This will be matched
diff --git a/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/static/favicon.png b/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/static/favicon.png new file mode 100644 index 000000000000..825b9e65af7c Binary files /dev/null and b/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/static/favicon.png differ diff --git a/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/svelte.config.js b/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/svelte.config.js new file mode 100644 index 000000000000..6e60dbbbdd9c --- /dev/null +++ b/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/svelte.config.js @@ -0,0 +1,10 @@ +import adapter from '../../../../../adapter-auto/index.js'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter() + } +}; + +export default config; diff --git a/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/tsconfig.json b/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/tsconfig.json new file mode 100644 index 000000000000..95d9c037df6c --- /dev/null +++ b/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "noEmit": true, + "module": "esnext", + "moduleResolution": "node", + "paths": { + "@sveltejs/kit": ["../../../../types"], + "$lib": ["./src/lib"], + "$lib/*": ["./src/lib/*"] + } + }, + "extends": "./.svelte-kit/tsconfig.json" +} diff --git a/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/vite.config.js b/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/vite.config.js new file mode 100644 index 000000000000..3f12d3677ea6 --- /dev/null +++ b/packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch/vite.config.js @@ -0,0 +1,23 @@ +import * as path from 'node:path'; +import { sveltekit } from '@sveltejs/kit/vite'; + +/** @type {import('vite').UserConfig} */ +const config = { + build: { + minify: false + }, + + clearScreen: false, + + logLevel: 'silent', + + plugins: [sveltekit()], + + server: { + fs: { + allow: [path.resolve('../../../../src')] + } + } +}; + +export default config; diff --git a/packages/kit/test/build-errors/prerender.spec.js b/packages/kit/test/build-errors/prerender.spec.js index e71ad1226da3..2413610a88bd 100644 --- a/packages/kit/test/build-errors/prerender.spec.js +++ b/packages/kit/test/build-errors/prerender.spec.js @@ -15,4 +15,16 @@ test('prerenderable routes must be prerendered', () => { ); }); +test('entry generators should match their own route', () => { + assert.throws( + () => + execSync('pnpm build', { + cwd: path.join(process.cwd(), 'apps/entry-generator-mismatch'), + stdio: 'pipe', + timeout: 60000 + }), + `The entries export from /[slug]/[notSpecific] generated entry /whatever/specific, which was matched by /[slug]/specific - see the \`handleEntryGeneratorMismatch\` option in https://kit.svelte.dev/docs/configuration#prerender for more info.` + ); +}); + test.run(); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index c24174f45862..07aff646b6d0 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -11,6 +11,7 @@ import { Logger, MaybePromise, Prerendered, + PrerenderEntryGeneratorMismatchHandlerValue, PrerenderHttpErrorHandlerValue, PrerenderMissingIdHandlerValue, PrerenderOption, @@ -515,7 +516,7 @@ export interface KitConfig { */ handleHttpError?: PrerenderHttpErrorHandlerValue; /** - * How to respond to hash links from one prerendered page to another that don't correspond to an `id` on the destination page + * How to respond when hash links from one prerendered page to another don't correspond to an `id` on the destination page. * * - `'fail'` — fail the build * - `'ignore'` - silently ignore the failure and continue @@ -525,6 +526,17 @@ export interface KitConfig { * @default "fail" */ handleMissingId?: PrerenderMissingIdHandlerValue; + /** + * How to respond when an entry generated by the `entries` export doesn't match the route it was generated from. + * + * - `'fail'` — fail the build + * - `'ignore'` - silently ignore the failure and continue + * - `'warn'` — continue, but print a warning + * - `(details) => void` — a custom error handler that takes a `details` object with `generatedFromId`, `entry`, `matchedId` and `message` properties. If you `throw` from this function, the build will fail + * + * @default "fail" + */ + handleEntryGeneratorMismatch?: PrerenderEntryGeneratorMismatchHandlerValue; /** * The value of `url.origin` during prerendering; useful if it is included in rendered content. * @default "http://sveltekit-prerender" diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index d85545cb58f0..c2dce68abcd4 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -266,6 +266,7 @@ export interface ServerMetadataRoute { }; methods: HttpMethod[]; prerender: PrerenderOption | undefined; + entries: Array | undefined; } export interface ServerMetadata { @@ -308,6 +309,7 @@ export interface SSRNode { csr?: boolean; trailingSlash?: TrailingSlash; config?: any; + entries?: PrerenderEntryGenerator; }; server: { @@ -318,6 +320,7 @@ export interface SSRNode { trailingSlash?: TrailingSlash; actions?: Actions; config?: any; + entries?: PrerenderEntryGenerator; }; universal_id: string; @@ -355,10 +358,13 @@ export interface PageNodeIndexes { leaf: number; } +export type PrerenderEntryGenerator = () => MaybePromise>>; + export type SSREndpoint = Partial> & { prerender?: PrerenderOption; trailingSlash?: TrailingSlash; config?: any; + entries?: PrerenderEntryGenerator; }; export interface SSRRoute { diff --git a/packages/kit/types/private.d.ts b/packages/kit/types/private.d.ts index 4ca5b9752279..ad02da171242 100644 --- a/packages/kit/types/private.d.ts +++ b/packages/kit/types/private.d.ts @@ -205,8 +205,17 @@ export interface PrerenderMissingIdHandler { (details: { path: string; id: string; referrers: string[]; message: string }): void; } +export interface PrerenderEntryGeneratorMismatchHandler { + (details: { generatedFromId: string; entry: string; matchedId: string; message: string }): void; +} + export type PrerenderHttpErrorHandlerValue = 'fail' | 'warn' | 'ignore' | PrerenderHttpErrorHandler; export type PrerenderMissingIdHandlerValue = 'fail' | 'warn' | 'ignore' | PrerenderMissingIdHandler; +export type PrerenderEntryGeneratorMismatchHandlerValue = + | 'fail' + | 'warn' + | 'ignore' + | PrerenderEntryGeneratorMismatchHandler; export type PrerenderOption = boolean | 'auto'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba3e8ed59c28..958c2645c6bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -611,6 +611,27 @@ importers: specifier: ^0.5.6 version: 0.5.6 + packages/kit/test/build-errors/apps/prerender-entry-generator-mismatch: + devDependencies: + '@sveltejs/adapter-auto': + specifier: workspace:^ + version: link:../../../../../adapter-auto + '@sveltejs/kit': + specifier: workspace:^ + version: link:../../../.. + svelte: + specifier: ^3.56.0 + version: 3.56.0 + svelte-check: + specifier: ^3.0.2 + version: 3.0.2(svelte@3.56.0) + typescript: + specifier: ^4.9.4 + version: 4.9.4 + vite: + specifier: ^4.2.0 + version: 4.3.0(@types/node@16.18.6) + packages/kit/test/build-errors/apps/prerenderable-incorrect-fragment: devDependencies: '@sveltejs/adapter-auto': diff --git a/sites/kit.svelte.dev/src/lib/docs/server/index.js b/sites/kit.svelte.dev/src/lib/docs/server/index.js index a3f69404a173..26d49e6671bc 100644 --- a/sites/kit.svelte.dev/src/lib/docs/server/index.js +++ b/sites/kit.svelte.dev/src/lib/docs/server/index.js @@ -179,7 +179,8 @@ export async function read_file(file) { `export type LayoutServerLoad = Kit.ServerLoad<{${params}}>;`, `export type RequestHandler = Kit.RequestHandler<{${params}}>;`, `export type Action = Kit.Action<{${params}}>;`, - `export type Actions = Kit.Actions<{${params}}>;` + `export type Actions = Kit.Actions<{${params}}>;`, + `export type EntryGenerator = () => Promise> | Array<{${params}}>;` ); }