diff --git a/.changeset/ten-lands-send.md b/.changeset/ten-lands-send.md new file mode 100644 index 000000000000..e870ae34ded9 --- /dev/null +++ b/.changeset/ten-lands-send.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': major +--- + +breaking: remove param files in folder in favor of `params.js/ts` file diff --git a/documentation/docs/30-advanced/10-advanced-routing.md b/documentation/docs/30-advanced/10-advanced-routing.md index 7462ed73e692..5ce31ced237e 100644 --- a/documentation/docs/30-advanced/10-advanced-routing.md +++ b/documentation/docs/30-advanced/10-advanced-routing.md @@ -71,18 +71,18 @@ Note that an optional route parameter cannot follow a rest parameter (`[...rest] ## Matching -A route like `src/routes/fruits/[page]` would match `/fruits/apple`, but it would also match `/fruits/rocketship`. We don't want that. You can ensure that route parameters are well-formed by adding a _matcher_ — which takes the parameter string (`"apple"` or `"rocketship"`) and returns `true` if it is valid — to your `src/params` directory... +A route like `src/routes/fruits/[page]` would match `/fruits/apple`, but it would also match `/fruits/rocketship`. We don't want that. You can ensure that route parameters are well-formed by adding a _matcher_ to your `src/params.js` file (or `src/params.ts`)... ```js -/// file: src/params/fruit.js -/** - * @param {string} param - * @return {param is ('apple' | 'orange')} - * @satisfies {import('@sveltejs/kit').ParamMatcher} - */ -export function match(param) { - return param === 'apple' || param === 'orange'; -} +/// file: src/params.js +import { defineParams } from '@sveltejs/kit'; + +export const params = defineParams({ + fruit: (param) => { + if (param !== 'apple' && param !== 'orange') throw new Error('Invalid fruit'); + return param; + } +}); ``` ...and augmenting your routes: @@ -91,12 +91,34 @@ export function match(param) { src/routes/fruits/[page+++=fruit+++] ``` -If the pathname doesn't match, SvelteKit will try to match other routes (using the sort order specified below), before eventually returning a 404. +If the pathname doesn't match, SvelteKit will try to match other routes (using the sort order specified below), before eventually returning a 404. If it does match, the returned value is passed as the param value. + +You can also use a [Standard Schema](https://standardschema.dev) — for example with [Valibot](https://valibot.dev): + +```js +/// file: src/params.js +import { defineParams } from '@sveltejs/kit'; +import * as v from 'valibot'; + +export const params = defineParams({ + number: v.pipe(v.string(), v.toNumber()) +}); +``` + +When a schema is used, SvelteKit validates the parameter and uses the transformed output as the param value. If validation fails, the route does not match. -Each module in the `params` directory corresponds to a matcher, with the exception of `*.test.js` and `*.spec.js` files which may be used to unit test your matchers. +```js +/// file: src/routes/items/[id=number]/+page.js +/** @type {import('./$types').PageLoad} */ +export function load({ params }) { + console.log(typeof params.id); // 'number' +} +``` > [!NOTE] Matchers run both on the server and in the browser. +> [!NOTE] Prior to SvelteKit 3, you had to define each param matcher in a separate file, all listed under a `params` folder (for example `src/params/foo.js` with `export const match = (param) => param === 'foo';`), and matching was determined by whether or not the matcher returns a truthy value (which means no value transformation took place). + ## Sorting It's possible for multiple routes to match a given path. For example each of these routes would match `/foo-abc`: diff --git a/packages/kit/package.json b/packages/kit/package.json index 2d7f780f5c23..5e83f7cf1ce2 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -40,6 +40,7 @@ "svelte": "catalog:", "svelte-preprocess": "catalog:", "typescript": "~6.0.3", + "valibot": "catalog:", "vite": "catalog:", "vitest": "catalog:" }, diff --git a/packages/kit/src/core/generate_manifest/index.js b/packages/kit/src/core/generate_manifest/index.js index 4bbc78509bdf..586747b2d294 100644 --- a/packages/kit/src/core/generate_manifest/index.js +++ b/packages/kit/src/core/generate_manifest/index.js @@ -69,10 +69,8 @@ export function generate_manifest({ assets.push(build_data.service_worker); } - // In case of server-side route resolution, we need to include all matchers. Prerendered routes are not part - // of the server manifest, and they could reference matchers that then would not be included. - const matchers = new Set( - build_data.client?.nodes ? Object.keys(build_data.manifest_data.matchers) : undefined + const uses_matchers = build_data.manifest_data.routes.some((route) => + route.params.some((param) => param.matcher) ); /** @param {Array} indexes */ @@ -117,10 +115,6 @@ export function generate_manifest({ ${routes.map(route => { if (!route.page && !route.endpoint) return; - route.params.forEach(param => { - if (param.matcher) matchers.add(param.matcher); - }); - return dedent` { id: ${s(route.id)}, @@ -134,11 +128,14 @@ export function generate_manifest({ ], prerendered_routes: new Set(${s(prerendered)}), matchers: async () => { - ${Array.from( - matchers, - type => `const { match: ${type} } = await import ('${(join_relative(relative_path, `/entries/matchers/${type}.js`))}')` - ).join('\n')} - return { ${Array.from(matchers).join(', ')} }; + ${ + uses_matchers && build_data.manifest_data.params + ? dedent` + const { params } = await import('${join_relative(relative_path, '/entries/params.js')}'); + return params; + ` + : 'return {};' + } }, server_assets: ${s(files)} } diff --git a/packages/kit/src/core/sync/create_manifest_data/index.js b/packages/kit/src/core/sync/create_manifest_data/index.js index e23cdebf17d7..62f9f5d12c69 100644 --- a/packages/kit/src/core/sync/create_manifest_data/index.js +++ b/packages/kit/src/core/sync/create_manifest_data/index.js @@ -29,22 +29,13 @@ export default function create_manifest_data({ }) { const assets = create_assets(config); const hooks = create_hooks(config, cwd); - const matchers = create_matchers(config, cwd); + const params = resolve_params(config, cwd); const { nodes, routes } = create_routes_and_nodes(cwd, config, fallback); - // validate matcher names used in parameterised routes - for (const route of routes) { - for (const param of route.params) { - if (param.matcher && !matchers[param.matcher]) { - throw new Error(`No matcher found for parameter '${param.matcher}' in route ${route.id}`); - } - } - } - return { assets, hooks, - matchers, + params, nodes, routes }; @@ -82,38 +73,9 @@ function create_hooks(config, cwd) { * @param {import('types').ValidatedConfig} config * @param {string} cwd */ -function create_matchers(config, cwd) { - const params_base = path.relative(cwd, config.kit.files.params); - - /** @type {Record} */ - const matchers = {}; - if (fs.existsSync(config.kit.files.params)) { - for (const file of fs.readdirSync(config.kit.files.params)) { - const ext = path.extname(file); - if (!config.kit.moduleExtensions.includes(ext)) continue; - const type = file.slice(0, -ext.length); - - if (/^\w+$/.test(type)) { - const matcher_file = path.join(params_base, file); - - // Disallow same matcher with different extensions - if (matchers[type]) { - throw new Error(`Duplicate matchers: ${matcher_file} and ${matchers[type]}`); - } else { - matchers[type] = matcher_file; - } - } else { - // Allow for matcher test collocation - if (type.endsWith('.test') || type.endsWith('.spec')) continue; - - throw new Error( - `Matcher names can only have underscores and alphanumeric characters — "${file}" is invalid` - ); - } - } - } - - return matchers; +function resolve_params(config, cwd) { + const params_file = resolve_entry(config.kit.files.params); + return params_file ? posixify(path.relative(cwd, params_file)) : null; } /** diff --git a/packages/kit/src/core/sync/create_manifest_data/index.spec.js b/packages/kit/src/core/sync/create_manifest_data/index.spec.js index 848ba6c6974a..e6c1c1013b94 100644 --- a/packages/kit/src/core/sync/create_manifest_data/index.spec.js +++ b/packages/kit/src/core/sync/create_manifest_data/index.spec.js @@ -854,38 +854,20 @@ test('errors on invalid named layout reference', () => { ); }); -test('creates param matchers', () => { - const { matchers } = create('samples/basic'); // directory doesn't matter for the test +test('creates params file path', () => { + const { params } = create('samples/basic'); - expect(matchers).toEqual({ - foo: path.join('params', 'foo.js'), - bar: path.join('params', 'bar.js') - }); + expect(params).toBe('params.js'); }); -test('errors on param matchers with bad names', () => { - const boogaloo = path.resolve(cwd, 'params', 'boo-galoo.js'); - fs.writeFileSync(boogaloo, ''); - try { - assert.throws(() => create('samples/basic'), /Matcher names can only have/); - } finally { - fs.unlinkSync(boogaloo); - } -}); +test('returns null params when file is missing', () => { + const params_file = path.resolve(cwd, 'params.js'); -test('errors on duplicate matchers', () => { - const ts_foo = path.resolve(cwd, 'params', 'foo.ts'); - fs.writeFileSync(ts_foo, ''); + fs.renameSync(params_file, params_file + '.bak'); try { - assert.throws(() => { - create('samples/basic', { - kit: { - moduleExtensions: ['.js', '.ts'] - } - }); - }, /Duplicate matchers/); + expect(create('samples/basic').params).toBeNull(); } finally { - fs.unlinkSync(ts_foo); + fs.renameSync(params_file + '.bak', params_file); } }); diff --git a/packages/kit/src/core/sync/create_manifest_data/test/params.js b/packages/kit/src/core/sync/create_manifest_data/test/params.js new file mode 100644 index 000000000000..7e09842fb6c3 --- /dev/null +++ b/packages/kit/src/core/sync/create_manifest_data/test/params.js @@ -0,0 +1,6 @@ +import { defineParams } from '@sveltejs/kit'; + +export const params = defineParams({ + foo: () => true, + bar: () => true +}); diff --git a/packages/kit/src/core/sync/create_manifest_data/test/params/bar.js b/packages/kit/src/core/sync/create_manifest_data/test/params/bar.js deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/kit/src/core/sync/create_manifest_data/test/params/foo.js b/packages/kit/src/core/sync/create_manifest_data/test/params/foo.js deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/kit/src/core/sync/write_client_manifest.js b/packages/kit/src/core/sync/write_client_manifest.js index ba0d1e1b06bc..696f72c72306 100644 --- a/packages/kit/src/core/sync/write_client_manifest.js +++ b/packages/kit/src/core/sync/write_client_manifest.js @@ -188,21 +188,14 @@ export function write_client_manifest(kit, manifest_data, output, metadata) { ); if (client_routing) { - // write matchers to a separate module so that we don't - // need to worry about name conflicts - const imports = []; - const matchers = []; - - for (const key in manifest_data.matchers) { - const src = manifest_data.matchers[key]; - - imports.push(`import { match as ${key} } from ${s(relative_path(output, src))};`); - matchers.push(key); - } + const uses_matchers = manifest_data.routes.some((route) => + route.params.some((param) => param.matcher) + ); - const module = imports.length - ? `${imports.join('\n')}\n\nexport const matchers = { ${matchers.join(', ')} };` - : 'export const matchers = {};'; + const module = + !manifest_data.params || !uses_matchers + ? 'export const matchers = {};' + : `import { params as matchers } from ${s(relative_path(output, manifest_data.params))};\n\nexport { matchers };`; write_if_changed(`${output}/matchers.js`, module); } diff --git a/packages/kit/src/core/sync/write_non_ambient.js b/packages/kit/src/core/sync/write_non_ambient.js index ec4797f67f6f..77ab8f189db6 100644 --- a/packages/kit/src/core/sync/write_non_ambient.js +++ b/packages/kit/src/core/sync/write_non_ambient.js @@ -1,5 +1,6 @@ import path from 'node:path'; import { GENERATED_COMMENT } from '../../constants.js'; +import { resolve_entry } from '../../utils/filesystem.js'; import { posixify } from '../../utils/os.js'; import { write_if_changed } from './utils.js'; import { s } from '../../utils/misc.js'; @@ -85,10 +86,6 @@ export {}; * @param {import('types').ValidatedKitConfig} config */ function generate_app_types(manifest_data, config) { - /** @param {string} matcher */ - const path_to_matcher = (matcher) => - posixify(path.relative(config.outDir, path.join(config.files.params, matcher + '.js'))); - /** @type {Map} */ const matcher_types = new Map(); @@ -98,7 +95,15 @@ function generate_app_types(manifest_data, config) { let type = matcher_types.get(matcher); if (!type) { - type = `MatcherParam`; + const path_to_params = () => { + const params_file = + resolve_entry(config.files.params) ?? + config.files.params.replace(/\.(js|ts)$/, '') + '.js'; + + return posixify(path.relative(config.outDir, params_file)); + }; + + type = `(typeof import('${path_to_params()}').params)[${JSON.stringify(matcher)}]`; matcher_types.set(matcher, type); } @@ -239,8 +244,6 @@ function generate_app_types(manifest_data, config) { return [ 'declare module "$app/types" {', - '\ttype MatcherParam = M extends (param : string) => param is (infer U extends string) ? U : string;', - '', '\texport interface AppTypes {', `\t\tRouteId(): ${manifest_data.routes.map((r) => s(r.id)).join(' | ')};`, `\t\tRouteParams(): {\n\t\t\t${dynamic_routes.join(';\n\t\t\t')}\n\t\t};`, diff --git a/packages/kit/src/core/sync/write_types/index.js b/packages/kit/src/core/sync/write_types/index.js index 43eb771de654..f80ddf0ba5a8 100644 --- a/packages/kit/src/core/sync/write_types/index.js +++ b/packages/kit/src/core/sync/write_types/index.js @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import MagicString from 'magic-string'; -import { rimraf, walk } from '../../../utils/filesystem.js'; +import { rimraf, walk, resolve_entry } from '../../../utils/filesystem.js'; import { compact } from '../../../utils/array.js'; import { posixify } from '../../../utils/os.js'; import { ts } from '../ts.js'; @@ -198,11 +198,6 @@ function update_types(config, routes, route, root, to_delete = new Set()) { // Makes sure a type is "repackaged" and therefore more readable declarations.push('type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never;'); - // returns the predicate of a matcher's type guard - or string if there is no type guard - declarations.push( - 'type MatcherParam = M extends (param : string) => param is (infer U extends string) ? U : string;' - ); - declarations.push( 'type RouteParams = ' + generate_params_type(route.params, outdir, config) + ';' ); @@ -604,16 +599,22 @@ function replace_ext_with_js(file_path) { * @param {import('types').ValidatedConfig} config */ function generate_params_type(params, outdir, config) { - /** @param {string} matcher */ - const path_to_matcher = (matcher) => - posixify(path.relative(outdir, path.join(config.kit.files.params, matcher + '.js'))); + const path_to_params = () => { + const params_file = + resolve_entry(config.kit.files.params) ?? + config.kit.files.params.replace(/\.(js|ts)$/, '') + '.js'; + + return posixify(path.relative(outdir, params_file)); + }; + + const params_import = path_to_params(); return `{ ${params .map( (param) => `${param.name}${param.optional ? '?' : ''}: ${ param.matcher - ? `MatcherParam` + ? `(typeof import('${params_import}').params)[${JSON.stringify(param.matcher)}]` : 'string' }${param.optional ? ' | undefined' : ''}` ) diff --git a/packages/kit/src/core/sync/write_types/test/app-types/matcher-test/with-matcher/[[locale=locale]]/+page.js b/packages/kit/src/core/sync/write_types/test/app-types/matcher-test/with-matcher/[[locale=locale]]/+page.js index e69de29bb2d1..4e9dfb3820f0 100644 --- a/packages/kit/src/core/sync/write_types/test/app-types/matcher-test/with-matcher/[[locale=locale]]/+page.js +++ b/packages/kit/src/core/sync/write_types/test/app-types/matcher-test/with-matcher/[[locale=locale]]/+page.js @@ -0,0 +1,6 @@ +/** @type {import('../../../.svelte-kit/types/matcher-test/with-matcher/[[locale=locale]]/$types').PageLoad} */ +export function load({ params }) { + params.locale === 'en'; // okay + // @ts-expect-error + params.locale === 'fr'; // not okay +} diff --git a/packages/kit/src/core/sync/write_types/test/app-types/params.js b/packages/kit/src/core/sync/write_types/test/app-types/params.js new file mode 100644 index 000000000000..60cded2db842 --- /dev/null +++ b/packages/kit/src/core/sync/write_types/test/app-types/params.js @@ -0,0 +1,12 @@ +import { defineParams } from '@sveltejs/kit'; + +export const params = defineParams({ + /** + * @param {string} param + * @returns {'en' | 'nb'} + */ + locale: (param) => { + if (!['en', 'nb'].includes(param)) throw new Error('Invalid locale'); + return /** @type {'en' | 'nb'} */ (param); + } +}); diff --git a/packages/kit/src/core/sync/write_types/test/app-types/params/locale.js b/packages/kit/src/core/sync/write_types/test/app-types/params/locale.js deleted file mode 100644 index d7dcc841d3c2..000000000000 --- a/packages/kit/src/core/sync/write_types/test/app-types/params/locale.js +++ /dev/null @@ -1,5 +0,0 @@ -/** - * @param {string} param - * @returns {param is "en" | "nb"} - */ -export const match = (param) => ['en', 'nb'].includes(param); diff --git a/packages/kit/src/core/sync/write_types/test/param-type-inference/package.json b/packages/kit/src/core/sync/write_types/test/param-type-inference/package.json index 86ea41944ec7..e49075df5014 100644 --- a/packages/kit/src/core/sync/write_types/test/param-type-inference/package.json +++ b/packages/kit/src/core/sync/write_types/test/param-type-inference/package.json @@ -3,7 +3,8 @@ "private": true, "type": "module", "devDependencies": { - "typescript": "~6.0.3" + "typescript": "~6.0.3", + "valibot": "catalog:" }, "scripts": { "testtypes": "tsc" diff --git a/packages/kit/src/core/sync/write_types/test/param-type-inference/params.js b/packages/kit/src/core/sync/write_types/test/param-type-inference/params.js new file mode 100644 index 000000000000..a88468749575 --- /dev/null +++ b/packages/kit/src/core/sync/write_types/test/param-type-inference/params.js @@ -0,0 +1,15 @@ +import { defineParams } from '@sveltejs/kit'; +import * as v from 'valibot'; + +export const params = defineParams({ + /** + * @param {string} param + * @returns {'a' | 'b'} + */ + narrowed: (param) => { + if (!['a', 'b'].includes(param)) throw new Error('Invalid param'); + return /** @type {'a' | 'b'} */ (param); + }, + not_narrowed: () => true, + number: v.pipe(v.string(), v.toNumber()) +}); diff --git a/packages/kit/src/core/sync/write_types/test/param-type-inference/params/narrowed.js b/packages/kit/src/core/sync/write_types/test/param-type-inference/params/narrowed.js deleted file mode 100644 index e8d48d1ca344..000000000000 --- a/packages/kit/src/core/sync/write_types/test/param-type-inference/params/narrowed.js +++ /dev/null @@ -1,5 +0,0 @@ -/** - * @param {string} param - * @returns {param is "a" | "b"} - */ -export const match = (param) => ['a', 'b'].includes(param); diff --git a/packages/kit/src/core/sync/write_types/test/param-type-inference/params/not_narrowed.js b/packages/kit/src/core/sync/write_types/test/param-type-inference/params/not_narrowed.js deleted file mode 100644 index 0e2f951afadd..000000000000 --- a/packages/kit/src/core/sync/write_types/test/param-type-inference/params/not_narrowed.js +++ /dev/null @@ -1,7 +0,0 @@ -/* eslint-disable */ - -/** - * @param {string} param - * @returns {boolean} - */ -export const match = (param) => true; diff --git a/packages/kit/src/core/sync/write_types/test/param-type-inference/parsed/[id=number]/+page.js b/packages/kit/src/core/sync/write_types/test/param-type-inference/parsed/[id=number]/+page.js new file mode 100644 index 000000000000..9acb53bec049 --- /dev/null +++ b/packages/kit/src/core/sync/write_types/test/param-type-inference/parsed/[id=number]/+page.js @@ -0,0 +1,8 @@ +/* eslint-disable */ + +/** @type {import('../../.svelte-kit/types/parsed/[id=number]/$types').PageLoad} */ +export function load({ params }) { + /** @type {number} */ + let id; + id = params.id; +} diff --git a/packages/kit/src/core/sync/write_types/test/param-type-inference/required/+layout.js b/packages/kit/src/core/sync/write_types/test/param-type-inference/required/+layout.js index 3c27fddc8492..24041c463b7e 100644 --- a/packages/kit/src/core/sync/write_types/test/param-type-inference/required/+layout.js +++ b/packages/kit/src/core/sync/write_types/test/param-type-inference/required/+layout.js @@ -14,7 +14,7 @@ export function load({ params }) { //@ts-expect-error a = params.regularParam; - /** @type {string} b*/ + /** @type {boolean} b */ const b = params.regularParam; } } diff --git a/packages/kit/src/core/sync/write_types/test/param-type-inference/required/[regularParam=not_narrowed]/+page.js b/packages/kit/src/core/sync/write_types/test/param-type-inference/required/[regularParam=not_narrowed]/+page.js index bd803fe94597..10ec64271c38 100644 --- a/packages/kit/src/core/sync/write_types/test/param-type-inference/required/[regularParam=not_narrowed]/+page.js +++ b/packages/kit/src/core/sync/write_types/test/param-type-inference/required/[regularParam=not_narrowed]/+page.js @@ -2,7 +2,7 @@ /** @type {import('../../.svelte-kit/types/required/[regularParam=not_narrowed]/$types').PageLoad} */ export function load({ params }) { - /** @type {string} a*/ + /** @type {boolean} a */ const a = params.regularParam; /** @type {"a" | "b"} b*/ diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js index ff1a02d834c5..334f4bdf4dd9 100644 --- a/packages/kit/src/exports/index.js +++ b/packages/kit/src/exports/index.js @@ -13,6 +13,7 @@ import { import { text_encoder } from '../runtime/utils.js'; export { VERSION } from '../version.js'; +export { defineParams } from './params.js'; // Keep the status codes as `number` because restricting to certain numbers makes it unnecessarily hard to use compared to the benefits // (we have runtime errors already to check for invalid codes). Also see https://github.com/sveltejs/kit/issues/11780 diff --git a/packages/kit/src/exports/params.js b/packages/kit/src/exports/params.js new file mode 100644 index 000000000000..70523c8f8d81 --- /dev/null +++ b/packages/kit/src/exports/params.js @@ -0,0 +1,61 @@ +/** + * Define [parameter matchers](https://svelte.dev/docs/kit/advanced-routing#Matching) for your app. + * + * @example + * ```js + * import { defineParams } from '@sveltejs/kit'; + * import * as v from 'valibot'; + * + * export const params = defineParams({ + * foo: (param) => { + * if (param !== 'bar') throw new Error('Invalid param'); + * return param; + * }, + * bar: v.string() + * }); + * ``` + * + * @template {Record} T + * @param {T} definitions + * @returns {import('./public.js').DefinedParams} + */ +export function defineParams(definitions) { + /** @type {Record} */ + const matchers = {}; + + for (const [key, definition] of Object.entries(definitions)) { + matchers[key] = normalize_param_definition(definition); + } + + return /** @type {import('./public.js').DefinedParams} */ (matchers); +} + +/** + * @param {import('@sveltejs/kit').ParamDefinition} definition + * @returns {import('@sveltejs/kit').ParamMatcher} + */ +export function normalize_param_definition(definition) { + if (typeof definition === 'function') { + return /** @type {import('@sveltejs/kit').ParamMatcher} */ ( + /** @type {unknown} */ ({ + '~standard': { + validate(/** @type {unknown} */ value) { + try { + return { value: definition(/** @type {string} */ (value)) }; + } catch (error) { + return { + issues: [{ message: error instanceof Error ? error.message : 'Invalid param' }] + }; + } + } + } + }) + ); + } + + if (definition && typeof definition === 'object' && '~standard' in definition) { + return definition; + } + + throw new Error('Invalid param definition'); +} diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 976483de50e9..ffffbf71f4a6 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1433,7 +1433,41 @@ export interface Page< /** * The shape of a param matcher. See [matching](https://svelte.dev/docs/kit/advanced-routing#Matching) for more info. */ -export type ParamMatcher = (param: string) => boolean; +export type ParamMatcher = StandardSchemaV1; + +/** + * A param matcher definition passed to [`defineParams`](https://svelte.dev/docs/kit/@sveltejs-kit#defineParams). + */ +export type ParamDefinition = ((param: string) => any) | StandardSchemaV1; + +/** + * The return type of [`defineParams`](https://svelte.dev/docs/kit/@sveltejs-kit#defineParams). + */ +export type DefinedParams> = { + readonly [K in keyof T]: MatcherParam; +}; + +/** + * Extracts the param type from a matcher — the output type of a Standard Schema, the predicate of a type guard, the return type of a transform function, or `string`. + */ +export type MatcherParam = + M extends StandardSchemaV1 + ? StandardSchemaV1.InferOutput + : M extends ((param: string) => param is infer U extends string) + ? U + : M extends (param: string) => infer R + ? R + : string; + +/** + * Define [parameter matchers](https://svelte.dev/docs/kit/advanced-routing#Matching) for your app. + * + * @template T + * @param definitions + */ +export function defineParams>( + definitions: T +): DefinedParams; /** * A single entry yielded by [`requested`](https://svelte.dev/docs/kit/$app-server#requested) diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index e53a61bb1a82..a2abb6b89037 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -10,6 +10,7 @@ import { isCSSRequest, loadEnv, buildErrorMessage } from 'vite'; import { createReadableStream, getRequest, setResponse } from '../../../exports/node/index.js'; import { coalesce_to_error } from '../../../utils/error.js'; import { resolve_entry } from '../../../utils/filesystem.js'; +import { load_and_validate_params } from '../../../utils/params.js'; import { from_fs, to_fs } from '../../../utils/vite.js'; import { posixify } from '../../../utils/os.js'; import { load_error_page } from '../../../core/config/index.js'; @@ -106,10 +107,17 @@ export async function dev(vite, vite_config, svelte_config, get_remotes, root, a return { module, module_node, url }; } - function update_manifest() { + async function update_manifest() { try { ({ manifest_data } = sync.create(svelte_config, root)); + await load_and_validate_params({ + routes: manifest_data.routes, + params_path: manifest_data.params, + root, + load: (file) => loud_ssr_load_module(file) + }); + if (manifest_error) { manifest_error = null; vite.ws.send({ type: 'full-reload' }); @@ -288,22 +296,18 @@ export async function dev(vite, vite_config, svelte_config, get_remotes, root, a }) ), matchers: async () => { - /** @type {Record} */ - const matchers = {}; - - for (const key in manifest_data.matchers) { - const file = manifest_data.matchers[key]; - const url = path.resolve(root, file); - const module = await vite.ssrLoadModule(url, { fixStacktrace: true }); - - if (module.match) { - matchers[key] = module.match; - } else { - throw new Error(`${file} does not export a \`match\` function`); - } + if (!manifest_data.params) return {}; + + const url = path.resolve(root, manifest_data.params); + const module = await vite.ssrLoadModule(url, { fixStacktrace: true }); + + if (!module.params) { + throw new Error( + `${manifest_data.params} does not export \`params\` from \`defineParams\`` + ); } - return matchers; + return module.params; } } }; @@ -320,7 +324,9 @@ export async function dev(vite, vite_config, svelte_config, get_remotes, root, a return error.stack?.replaceAll('\0', ''); // remove null bytes from e.g. virtual module IDs, or the response will fail } - update_manifest(); + await update_manifest(); + + const params_file = resolve_entry(svelte_config.kit.files.params); /** * @param {string} event @@ -330,7 +336,7 @@ export async function dev(vite, vite_config, svelte_config, get_remotes, root, a vite.watcher.on(event, (file) => { if ( file.startsWith(svelte_config.kit.files.routes + path.sep) || - file.startsWith(svelte_config.kit.files.params + path.sep) || + (params_file && file === params_file) || svelte_config.kit.moduleExtensions.some((ext) => file.endsWith(`.remote${ext}`)) || // in contrast to server hooks, client hooks are written to the client manifest // and therefore need rebuilding when they are added/removed diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 78dd4f0cfa1f..d1f53f26543f 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -21,6 +21,7 @@ import { } from '../../core/env.js'; import * as sync from '../../core/sync/sync.js'; import { create_assets } from '../../core/sync/create_manifest_data/index.js'; +import { load_and_validate_params } from '../../utils/params.js'; import { runtime_directory, logger } from '../../core/utils.js'; import { generate_manifest } from '../../core/generate_manifest/index.js'; import { build_server_nodes } from './build/build_server.js'; @@ -1228,11 +1229,10 @@ function kit({ svelte_config, adapter }) { } }); - // ...and every matcher - Object.entries(manifest_data.matchers).forEach(([key, file]) => { - const name = posixify(path.join('entries/matchers', key)); - server_input[name] = path.resolve(root, file); - }); + // ...and the params file + if (manifest_data.params) { + server_input['entries/params'] = path.resolve(root, manifest_data.params); + } // ...and the hooks files if (manifest_data.hooks.server) { @@ -1478,6 +1478,12 @@ function kit({ svelte_config, adapter }) { } mkdirp(out); + await load_and_validate_params({ + routes: manifest_data.routes, + params_path: manifest_data.params, + root + }); + const { output: server_chunks } = /** @type {Rolldown.RolldownOutput} */ ( await builder.build(builder.environments.ssr) ); diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 532ad57f9a09..30ee39141a8b 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -8,13 +8,7 @@ import { HttpError, Redirect, SvelteKitError } from '@sveltejs/kit/internal'; const { onMount, tick } = svelte; // Svelte 4 and under don't have `untrack`, so we have to fallback if `untrack` is not exported const untrack = svelte.untrack ?? ((value) => value()); -import { - decode_params, - decode_pathname, - strip_hash, - make_trackable, - normalize_path -} from '../../utils/url.js'; +import { decode_pathname, strip_hash, make_trackable, normalize_path } from '../../utils/url.js'; import { dev_fetch, initial_fetch, lock_fetch, subsequent_fetch, unlock_fetch } from './fetcher.js'; import { parse, parse_server_route } from './parse.js'; import * as storage from './session-storage.js'; @@ -1608,7 +1602,7 @@ export async function get_navigation_intent(url, invalidating) { id: get_page_key(url), invalidating, route, - params: decode_params(params), + params, url }; } diff --git a/packages/kit/src/runtime/client/types.d.ts b/packages/kit/src/runtime/client/types.d.ts index a84ceb4d57f4..8a06a987929f 100644 --- a/packages/kit/src/runtime/client/types.d.ts +++ b/packages/kit/src/runtime/client/types.d.ts @@ -39,7 +39,7 @@ export interface SvelteKitApp { dictionary: Record; /** - * A map of `[matcherName: string]: (..) => boolean`, which is used to match route parameters. + * A map of `[matcherName: string]: ParamMatcher`, which is used to match and parse route parameters. * * In case of router.resolution=server, this object is empty, as resolution happens on the server. */ diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 95dc2954ac21..c260ef5f78a1 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -205,7 +205,7 @@ export interface ManifestData { }; nodes: PageNode[]; routes: RouteData[]; - matchers: Record; + params: string | null; } export interface RemoteChunk { diff --git a/packages/kit/src/utils/params.js b/packages/kit/src/utils/params.js new file mode 100644 index 000000000000..70832b288c83 --- /dev/null +++ b/packages/kit/src/utils/params.js @@ -0,0 +1,66 @@ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +/** + * @param {import('types').RouteData[]} routes + * @returns {Set} + */ +export function collect_matcher_names(routes) { + /** @type {Set} */ + const names = new Set(); + + for (const route of routes) { + for (const param of route.params) { + if (param.matcher) names.add(param.matcher); + } + } + + return names; +} + +/** + * @param {Record} params + * @param {Set} names + * @param {string} [file] + */ +export function validate_param_matchers(params, names, file) { + for (const name of names) { + if (!(name in params)) { + throw new Error(`No matcher found for parameter '${name}'${file ? ` in ${file}` : ''}`); + } + } +} + +/** + * @param {{ + * routes: import('types').RouteData[]; + * params_path: string | null; + * root: string; + * load?: (file: string) => Promise>; + * }} opts + * @returns {Promise | null>} + */ +export async function load_and_validate_params({ routes, params_path, root, load }) { + const names = collect_matcher_names(routes); + + if (names.size === 0) return null; + + if (!params_path) { + throw new Error(`No matcher found for parameter '${names.values().next().value}'`); + } + + const file = path.resolve(root, params_path); + const module = load ? await load(file) : await import(pathToFileURL(file).href); + + if (!module.params || typeof module.params !== 'object') { + throw new Error(`${params_path} does not export \`params\` from \`defineParams\``); + } + + validate_param_matchers( + /** @type {Record} */ (module.params), + names, + params_path + ); + + return /** @type {Record} */ (module.params); +} diff --git a/packages/kit/src/utils/params.spec.js b/packages/kit/src/utils/params.spec.js new file mode 100644 index 000000000000..0829c838e04a --- /dev/null +++ b/packages/kit/src/utils/params.spec.js @@ -0,0 +1,58 @@ +import { assert, expect, test } from 'vitest'; +import { + collect_matcher_names, + load_and_validate_params, + validate_param_matchers +} from './params.js'; +import { normalize_param_definition } from '../exports/params.js'; + +test('collect_matcher_names collects matcher names from routes', () => { + const names = collect_matcher_names([ + /** @type {import('types').RouteData} */ ({ + params: [{ name: 'id', matcher: 'number' }] + }) + ]); + + expect(names).toEqual(new Set(['number'])); +}); + +test('validate_param_matchers throws for unknown matchers', () => { + assert.throws( + () => validate_param_matchers({ foo: true }, new Set(['bar']), 'params.js'), + /No matcher found for parameter 'bar'/ + ); +}); + +test('load_and_validate_params loads and validates params', async () => { + const params = await load_and_validate_params({ + routes: [ + /** @type {import('types').RouteData} */ ({ + params: [{ name: 'id', matcher: 'number' }] + }) + ], + params_path: 'params.js', + root: import.meta.dirname, + load: () => Promise.resolve({ params: { number: () => true } }) + }); + + expect(params).toEqual({ number: expect.any(Function) }); +}); + +test('normalize_param_definition uses the returned value as the parsed param', () => { + const matcher = normalize_param_definition(() => true); + + assert.deepEqual(matcher['~standard'].validate('x'), { value: true }); +}); + +test('normalize_param_definition supports transform functions', () => { + const matcher = normalize_param_definition((param) => { + if (param !== '42') throw new Error('nope'); + return 42; + }); + + assert.deepEqual(matcher['~standard'].validate('42'), { value: 42 }); + + const result = matcher['~standard'].validate('nope'); + if (result instanceof Promise) assert.fail('Expected synchronous validation'); + assert.ok(result.issues); +}); diff --git a/packages/kit/src/utils/routing.js b/packages/kit/src/utils/routing.js index 68737505b601..484e77a873e9 100644 --- a/packages/kit/src/utils/routing.js +++ b/packages/kit/src/utils/routing.js @@ -1,5 +1,4 @@ import { BROWSER } from 'esm-env'; -import { decode_params } from './url.js'; const param_pattern = /^(\[)?(\.\.\.)?(\w+)(?:=(\w+))?(\])?$/; @@ -134,13 +133,28 @@ export function get_route_segments(route) { return route.slice(1).split('/').filter(affects_path); } +/** + * @param {import('@sveltejs/kit').ParamMatcher} matcher + * @param {string} value + * @returns {{ success: true, value: any } | { success: false }} + */ +function run_matcher(matcher, value) { + const result = matcher['~standard'].validate(value); + + if (result instanceof Promise || result.issues) { + return { success: false }; + } + + return { success: true, value: result.value }; +} + /** * @param {RegExpMatchArray} match * @param {import('types').RouteParam[]} params * @param {Record} matchers */ export function exec(match, params, matchers) { - /** @type {Record} */ + /** @type {Record} */ const result = {}; const values = match.slice(1); @@ -173,37 +187,41 @@ export function exec(match, params, matchers) { } } - if (!param.matcher || matchers[param.matcher](value)) { - result[param.name] = value; + const decoded = decodeURIComponent(value); - // Now that the params match, reset the buffer if the next param isn't the [...rest] - // and the next value is defined, otherwise the buffer will cause us to skip values - const next_param = params[i + 1]; - const next_value = values[i + 1]; - if (next_param && !next_param.rest && next_param.optional && next_value && param.chained) { - buffered = 0; - } + if (param.matcher) { + const outcome = run_matcher(matchers[param.matcher], decoded); + + if (!outcome.success) { + // in the `/[[a=b]]/...` case, if the value didn't satisfy the matcher, + // keep track of the number of skipped optional parameters and continue + if (param.optional && param.chained) { + buffered++; + continue; + } - // There are no more params and no more values, but all non-empty values have been matched - if ( - !next_param && - !next_value && - Object.keys(result).length === values_needing_match.length - ) { - buffered = 0; + // otherwise, if the matcher returns `false`, the route did not match + return; } - continue; + + result[param.name] = outcome.value; + } else { + result[param.name] = decoded; } - // in the `/[[a=b]]/...` case, if the value didn't satisfy the matcher, - // keep track of the number of skipped optional parameters and continue - if (param.optional && param.chained) { - buffered++; - continue; + // Now that the params match, reset the buffer if the next param isn't the [...rest] + // and the next value is defined, otherwise the buffer will cause us to skip values + const next_param = params[i + 1]; + const next_value = values[i + 1]; + if (next_param && !next_param.rest && next_param.optional && next_value && param.chained) { + buffered = 0; } - // otherwise, if the matcher returns `false`, the route did not match - return; + // There are no more params and no more values, but all non-empty values have been matched + if (!next_param && !next_value && Object.keys(result).length === values_needing_match.length) { + buffered = 0; + } + continue; } if (buffered) return; @@ -290,7 +308,7 @@ export function has_server_load(node) { * @param {string} path - The decoded pathname to match * @param {Route[]} routes * @param {Record} matchers - * @returns {{ route: Route, params: Record } | null} + * @returns {{ route: Route, params: Record } | null} */ export function find_route(path, routes, matchers) { for (const route of routes) { @@ -301,7 +319,7 @@ export function find_route(path, routes, matchers) { if (matched) { return { route, - params: decode_params(matched) + params: matched }; } } diff --git a/packages/kit/src/utils/routing.spec.js b/packages/kit/src/utils/routing.spec.js index 2325bdcb4e12..7e26be3f870c 100644 --- a/packages/kit/src/utils/routing.spec.js +++ b/packages/kit/src/utils/routing.spec.js @@ -1,6 +1,10 @@ import { assert, expect, test, describe } from 'vitest'; +import * as v from 'valibot'; import { exec, parse_route_id, resolve_route, find_route } from './routing.js'; +/** @type {import('@sveltejs/kit').ParamMatcher} */ +const number = v.pipe(v.string(), v.toNumber()); + describe('parse_route_id', () => { const tests = { '/': { @@ -293,12 +297,34 @@ describe('exec', () => { const match = pattern.exec(path); if (!match) throw new Error(`Failed to match ${path}`); const actual = exec(match, params, { - matches: () => true, - doesntmatch: () => false + matches: v.string(), + doesntmatch: v.never() }); expect(actual).toEqual(expected); }); } + + test('exec validates and transforms params with a standard schema', () => { + const route = '/items/[id=number]'; + const { pattern, params } = parse_route_id(route); + const match = pattern.exec('/items/42'); + if (!match) throw new Error('Failed to match'); + + const actual = exec(match, params, { number }); + + expect(actual).toEqual({ id: 42 }); + }); + + test('exec rejects params when a standard schema fails validation', () => { + const route = '/items/[id=number]'; + const { pattern, params } = parse_route_id(route); + const match = pattern.exec('/items/abc'); + if (!match) throw new Error('Failed to match'); + + const actual = exec(match, params, { number }); + + expect(actual).toBeUndefined(); + }); }); describe('resolve_route', () => { @@ -423,7 +449,7 @@ describe('find_route', () => { const routes = [create_route('/blog/[slug=word]'), create_route('/blog/[slug]')]; /** @type {Record} */ const matchers = { - word: (param) => /^\w+$/.test(param) + word: v.pipe(v.string(), v.regex(/^\w+$/)) }; // "hello" matches the word matcher @@ -435,6 +461,24 @@ describe('find_route', () => { assert.equal(result2?.route.id, '/blog/[slug]'); }); + test('validates and transforms params with a standard schema', () => { + const routes = [create_route('/items/[id=number]')]; + /** @type {Record} */ + const matchers = { number }; + + const result = find_route('/items/42', routes, matchers); + assert.equal(result?.params.id, 42); + }); + + test('rejects params when a standard schema fails validation', () => { + const routes = [create_route('/items/[id=number]')]; + /** @type {Record} */ + const matchers = { number }; + + const result = find_route('/items/abc', routes, matchers); + assert.equal(result, null); + }); + test('decodes params', () => { const routes = [create_route('/blog/[slug]')]; diff --git a/packages/kit/test/apps/basics/src/app.d.ts b/packages/kit/test/apps/basics/src/app.d.ts index 7a9aa4d6fa9b..e3198c7bd1c7 100644 --- a/packages/kit/test/apps/basics/src/app.d.ts +++ b/packages/kit/test/apps/basics/src/app.d.ts @@ -4,7 +4,7 @@ declare global { answer: number; name?: string; key: string | null; - params: Record; + params: Record; url?: URL; message?: string; } diff --git a/packages/kit/test/apps/basics/src/params.js b/packages/kit/test/apps/basics/src/params.js new file mode 100644 index 000000000000..25a5c82b7549 --- /dev/null +++ b/packages/kit/test/apps/basics/src/params.js @@ -0,0 +1,17 @@ +import { defineParams } from '@sveltejs/kit'; + +export const params = defineParams({ + lowercase: (param) => { + if (!/^[a-z]+$/.test(param)) throw new Error('Invalid param'); + return param; + }, + uppercase: (param) => { + if (!/^[A-Z]+$/.test(param)) throw new Error('Invalid param'); + return param; + }, + numeric: (param) => { + const value = parseInt(param); + if (isNaN(value)) throw new Error('Invalid param'); + return value; + } +}); diff --git a/packages/kit/test/apps/basics/src/params/lowercase.js b/packages/kit/test/apps/basics/src/params/lowercase.js deleted file mode 100644 index 91fb093b4432..000000000000 --- a/packages/kit/test/apps/basics/src/params/lowercase.js +++ /dev/null @@ -1,4 +0,0 @@ -/** @type {import('@sveltejs/kit').ParamMatcher} */ -export function match(param) { - return /^[a-z]+$/.test(param); -} diff --git a/packages/kit/test/apps/basics/src/params/numeric.js b/packages/kit/test/apps/basics/src/params/numeric.js deleted file mode 100644 index e338b33ceeb0..000000000000 --- a/packages/kit/test/apps/basics/src/params/numeric.js +++ /dev/null @@ -1,4 +0,0 @@ -/** @type {import('@sveltejs/kit').ParamMatcher} */ -export function match(param) { - return !isNaN(parseInt(param)); -} diff --git a/packages/kit/test/apps/basics/src/params/uppercase.js b/packages/kit/test/apps/basics/src/params/uppercase.js deleted file mode 100644 index 1af40886e050..000000000000 --- a/packages/kit/test/apps/basics/src/params/uppercase.js +++ /dev/null @@ -1,4 +0,0 @@ -/** @type {import('@sveltejs/kit').ParamMatcher} */ -export function match(param) { - return /^[A-Z]+$/.test(param); -} diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 54548342513d..b6c16a874f72 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1406,7 +1406,39 @@ declare module '@sveltejs/kit' { /** * The shape of a param matcher. See [matching](https://svelte.dev/docs/kit/advanced-routing#Matching) for more info. */ - export type ParamMatcher = (param: string) => boolean; + export type ParamMatcher = StandardSchemaV1; + + /** + * A param matcher definition passed to [`defineParams`](https://svelte.dev/docs/kit/@sveltejs-kit#defineParams). + */ + export type ParamDefinition = ((param: string) => any) | StandardSchemaV1; + + /** + * The return type of [`defineParams`](https://svelte.dev/docs/kit/@sveltejs-kit#defineParams). + */ + export type DefinedParams> = { + readonly [K in keyof T]: MatcherParam; + }; + + /** + * Extracts the param type from a matcher — the output type of a Standard Schema, the predicate of a type guard, the return type of a transform function, or `string`. + */ + export type MatcherParam = + M extends StandardSchemaV1 + ? StandardSchemaV1.InferOutput + : M extends ((param: string) => param is infer U extends string) + ? U + : M extends (param: string) => infer R + ? R + : string; + + /** + * Define [parameter matchers](https://svelte.dev/docs/kit/advanced-routing#Matching) for your app. + * + * */ + export function defineParams>( + definitions: T + ): DefinedParams; /** * A single entry yielded by [`requested`](https://svelte.dev/docs/kit/$app-server#requested) @@ -2643,7 +2675,7 @@ declare module '@sveltejs/kit' { }; nodes: PageNode[]; routes: RouteData[]; - matchers: Record; + params: string | null; } interface PageNode { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef516ea8847b..a704207eb050 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -602,6 +602,9 @@ importers: typescript: specifier: ~6.0.3 version: 6.0.3 + valibot: + specifier: 'catalog:' + version: 1.2.0(typescript@6.0.3) vite: specifier: 'catalog:' version: 8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.4.2)(yaml@2.8.3) @@ -638,6 +641,9 @@ importers: typescript: specifier: ~6.0.3 version: 6.0.3 + valibot: + specifier: 'catalog:' + version: 1.2.0(typescript@6.0.3) packages/kit/src/core/sync/write_types/test/simple-page-server-and-shared: devDependencies: