From c67990f571f7769598cb032fd6a8e8cfb9f7eb34 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 23 Jun 2026 22:05:44 +0200 Subject: [PATCH 01/14] feat: add `parse` function to matcher files basically implements https://github.com/sveltejs/kit/issues/10407#issuecomment-1895359560: - new `parse` function can be exported from `params/someMatcher.js` - can be either alongside `match` or standalone - if it throws it counts as invalid and the route will not be matched closes #10407 --- .changeset/ten-lands-send.md | 5 + .../docs/30-advanced/10-advanced-routing.md | 51 ++++++++++ .../kit/src/core/generate_manifest/index.js | 5 +- .../sync/create_manifest_data/index.spec.js | 12 +++ .../src/core/sync/write_client_manifest.js | 4 +- .../kit/src/core/sync/write_non_ambient.js | 4 +- .../kit/src/core/sync/write_types/index.js | 4 +- .../param-type-inference/params/number.js | 6 ++ .../parsed/[id=number]/+page.js | 8 ++ packages/kit/src/exports/public.d.ts | 16 +++- packages/kit/src/exports/vite/dev/index.js | 10 +- packages/kit/src/runtime/client/client.js | 3 +- packages/kit/src/runtime/client/types.d.ts | 6 +- packages/kit/src/utils/routing.js | 73 +++++++++------ packages/kit/src/utils/routing.spec.js | 92 ++++++++++++++++++- packages/kit/types/index.d.ts | 16 +++- 16 files changed, 264 insertions(+), 51 deletions(-) create mode 100644 .changeset/ten-lands-send.md create mode 100644 packages/kit/src/core/sync/write_types/test/param-type-inference/params/number.js create mode 100644 packages/kit/src/core/sync/write_types/test/param-type-inference/parsed/[id=number]/+page.js diff --git a/.changeset/ten-lands-send.md b/.changeset/ten-lands-send.md new file mode 100644 index 000000000000..9981ed05fef2 --- /dev/null +++ b/.changeset/ten-lands-send.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: add `parse` function to matcher files diff --git a/documentation/docs/30-advanced/10-advanced-routing.md b/documentation/docs/30-advanced/10-advanced-routing.md index 7462ed73e692..ca208525bc0c 100644 --- a/documentation/docs/30-advanced/10-advanced-routing.md +++ b/documentation/docs/30-advanced/10-advanced-routing.md @@ -93,6 +93,57 @@ 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. +### Parsing + +In addition to `match`, a param module can export a `parse` function that converts the parameter string into a more useful type. If `parse` is not defined, the parameter value remains a string. If `match` is not defined, all values are considered valid (equivalent to `() => true`). If `parse` throws an error, the parameter is treated as invalid. + +```js +/// file: src/params/number.js +/** @type {import('@sveltejs/kit').ParamParser} */ +export function parse(param) { + const n = Number(param); + if (!Number.isFinite(n)) throw new Error('not a number'); + return n; +} +``` + +```js +/// file: src/routes/items/[id=number]/+page.js +/** @type {import('./$types').PageLoad} */ +export function load({ params }) { + console.log(typeof params.id); // 'number' +} +``` + +You can use both `match` and `parse` in the same module — `match` validates the string, and `parse` converts it: + +```js +/// file: src/params/number.js +/** @type {import('@sveltejs/kit').ParamMatcher} */ +export function match(param) { + return /^\d+$/.test(param); +} + +/** @type {import('@sveltejs/kit').ParamParser} */ +export function parse(param) { + return Number(param); +} +``` + +This also makes it possible to integrate with libraries like Valibot: + +```js +/// file: src/params/number.js +import * as v from 'valibot'; + +const min3 = v.pipe(v.string(), v.minLength(3)); + +/** @type {import('@sveltejs/kit').ParamParser} */ +export function parse(param) { + return v.parse(min3, param); +} +``` + 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. > [!NOTE] Matchers run both on the server and in the browser. diff --git a/packages/kit/src/core/generate_manifest/index.js b/packages/kit/src/core/generate_manifest/index.js index 4bbc78509bdf..769da6a6ce19 100644 --- a/packages/kit/src/core/generate_manifest/index.js +++ b/packages/kit/src/core/generate_manifest/index.js @@ -136,9 +136,10 @@ export function generate_manifest({ matchers: async () => { ${Array.from( matchers, - type => `const { match: ${type} } = await import ('${(join_relative(relative_path, `/entries/matchers/${type}.js`))}')` + type => + `const __matcher_${type} = await import ('${join_relative(relative_path, `/entries/matchers/${type}.js`)}')` ).join('\n')} - return { ${Array.from(matchers).join(', ')} }; + return { ${Array.from(matchers).map((type) => `${type}: { match: __matcher_${type}.match, parse: __matcher_${type}.parse }`).join(', ')} }; }, server_assets: ${s(files)} } 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..f91ea41cb2e4 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 @@ -863,6 +863,18 @@ test('creates param matchers', () => { }); }); +test('creates param matchers with parse-only modules', () => { + const parsed = path.resolve(cwd, 'params', 'parsed.js'); + fs.writeFileSync(parsed, 'export function parse(param) { return Number(param); }'); + try { + const { matchers } = create('samples/basic'); + + expect(matchers.parsed).toEqual(path.join('params', 'parsed.js')); + } finally { + fs.unlinkSync(parsed); + } +}); + test('errors on param matchers with bad names', () => { const boogaloo = path.resolve(cwd, 'params', 'boo-galoo.js'); fs.writeFileSync(boogaloo, ''); diff --git a/packages/kit/src/core/sync/write_client_manifest.js b/packages/kit/src/core/sync/write_client_manifest.js index ba0d1e1b06bc..174d7bde1e29 100644 --- a/packages/kit/src/core/sync/write_client_manifest.js +++ b/packages/kit/src/core/sync/write_client_manifest.js @@ -196,8 +196,8 @@ export function write_client_manifest(kit, manifest_data, output, metadata) { 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); + imports.push(`import * as ${key} from ${s(relative_path(output, src))};`); + matchers.push(`${key}: { match: ${key}.match, parse: ${key}.parse }`); } const module = imports.length diff --git a/packages/kit/src/core/sync/write_non_ambient.js b/packages/kit/src/core/sync/write_non_ambient.js index ec4797f67f6f..d903692e1ec2 100644 --- a/packages/kit/src/core/sync/write_non_ambient.js +++ b/packages/kit/src/core/sync/write_non_ambient.js @@ -98,7 +98,7 @@ function generate_app_types(manifest_data, config) { let type = matcher_types.get(matcher); if (!type) { - type = `MatcherParam`; + type = `MatcherParam`; matcher_types.set(matcher, type); } @@ -239,7 +239,7 @@ 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;', + '\ttype MatcherParam = M extends { parse: (param: string) => infer R } ? R : M extends { match: (param: string) => param is (infer U extends string) } ? U : string;', '', '\texport interface AppTypes {', `\t\tRouteId(): ${manifest_data.routes.map((r) => s(r.id)).join(' | ')};`, diff --git a/packages/kit/src/core/sync/write_types/index.js b/packages/kit/src/core/sync/write_types/index.js index 43eb771de654..73899836ad97 100644 --- a/packages/kit/src/core/sync/write_types/index.js +++ b/packages/kit/src/core/sync/write_types/index.js @@ -200,7 +200,7 @@ function update_types(config, routes, route, root, to_delete = new Set()) { // 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;' + 'type MatcherParam = M extends { parse: (param: string) => infer R } ? R : M extends { match: (param: string) => param is (infer U extends string) } ? U : string;' ); declarations.push( @@ -613,7 +613,7 @@ function generate_params_type(params, outdir, config) { (param) => `${param.name}${param.optional ? '?' : ''}: ${ param.matcher - ? `MatcherParam` + ? `MatcherParam` : 'string' }${param.optional ? ' | undefined' : ''}` ) diff --git a/packages/kit/src/core/sync/write_types/test/param-type-inference/params/number.js b/packages/kit/src/core/sync/write_types/test/param-type-inference/params/number.js new file mode 100644 index 000000000000..bfcca0e4328e --- /dev/null +++ b/packages/kit/src/core/sync/write_types/test/param-type-inference/params/number.js @@ -0,0 +1,6 @@ +/** @type {import('@sveltejs/kit').ParamParser} */ +export function parse(param) { + const n = Number(param); + if (!Number.isFinite(n)) throw new Error('not a number'); + return n; +} 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/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index beae37f64353..a25cf1705ee5 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1431,6 +1431,20 @@ export interface Page< */ export type ParamMatcher = (param: string) => boolean; +/** + * The shape of a param parser. See [matching](https://svelte.dev/docs/kit/advanced-routing#Matching) for more info. + */ +export type ParamParser = (param: string) => T; + +/** + * A param matcher module can export a `match` function, a `parse` function, or both. + * See [matching](https://svelte.dev/docs/kit/advanced-routing#Matching) for more info. + */ +export interface ParamMatcherModule { + match?: ParamMatcher; + parse?: ParamParser; +} + /** * A single entry yielded by [`requested`](https://svelte.dev/docs/kit/$app-server#requested) * when called with a regular `query`. `arg` is the validated argument (the input *after* @@ -1686,7 +1700,7 @@ export interface SSRManifest { remotes: Record Promise>; routes: SSRRoute[]; prerendered_routes: Set; - matchers: () => Promise>; + matchers: () => Promise>; /** A `[file]: size` map of all assets imported by server code. */ server_assets: Record; }; diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index e53a61bb1a82..276d22b8af4f 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -288,7 +288,7 @@ export async function dev(vite, vite_config, svelte_config, get_remotes, root, a }) ), matchers: async () => { - /** @type {Record} */ + /** @type {Record} */ const matchers = {}; for (const key in manifest_data.matchers) { @@ -296,11 +296,11 @@ export async function dev(vite, vite_config, svelte_config, get_remotes, root, a 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 (!module.match && !module.parse) { + throw new Error(`${file} must export a \`match\` and/or \`parse\` function`); } + + matchers[key] = { match: module.match, parse: module.parse }; } return matchers; diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 6f51314f6c1d..29adcaa641f0 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -9,7 +9,6 @@ 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, @@ -1604,7 +1603,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..6396b6f3ee74 100644 --- a/packages/kit/src/runtime/client/types.d.ts +++ b/packages/kit/src/runtime/client/types.d.ts @@ -9,7 +9,7 @@ import { TrailingSlash, Uses } from 'types'; -import { Page, ParamMatcher } from '@sveltejs/kit'; +import { Page, ParamMatcherModule } from '@sveltejs/kit'; export interface SvelteKitApp { /** @@ -39,11 +39,11 @@ export interface SvelteKitApp { dictionary: Record; /** - * A map of `[matcherName: string]: (..) => boolean`, which is used to match route parameters. + * A map of `[matcherName: string]: ParamMatcherModule`, 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. */ - matchers: Record; + matchers: Record; hooks: ClientHooks; diff --git a/packages/kit/src/utils/routing.js b/packages/kit/src/utils/routing.js index 68737505b601..c819973a1a32 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+))?(\])?$/; @@ -137,10 +136,10 @@ export function get_route_segments(route) { /** * @param {RegExpMatchArray} match * @param {import('types').RouteParam[]} params - * @param {Record} matchers + * @param {Record} matchers */ export function exec(match, params, matchers) { - /** @type {Record} */ + /** @type {Record} */ const result = {}; const values = match.slice(1); @@ -173,37 +172,53 @@ 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 { match, parse } = matchers[param.matcher]; + + if (match && !match(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; + } + + // 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; + try { + result[param.name] = parse ? parse(decoded) : decoded; + } catch { + // in the `/[[a=b]]/...` case, if the value didn't satisfy the parser, + // keep track of the number of skipped optional parameters and continue + if (param.optional && param.chained) { + buffered++; + continue; + } + + // otherwise, if the parser threw, the route did not match + return; } - continue; + } 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; @@ -289,8 +304,8 @@ export function has_server_load(node) { * @template {{pattern: RegExp, params: import('types').RouteParam[]}} Route * @param {string} path - The decoded pathname to match * @param {Route[]} routes - * @param {Record} matchers - * @returns {{ route: Route, params: Record } | null} + * @param {Record} matchers + * @returns {{ route: Route, params: Record } | null} */ export function find_route(path, routes, matchers) { for (const route of routes) { @@ -301,7 +316,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..d4be01f76e57 100644 --- a/packages/kit/src/utils/routing.spec.js +++ b/packages/kit/src/utils/routing.spec.js @@ -299,6 +299,60 @@ describe('exec', () => { expect(actual).toEqual(expected); }); } + + test('exec parses params with a parse function', () => { + 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: { + parse: (param) => { + const n = Number(param); + if (!Number.isFinite(n)) throw new Error('not a number'); + return n; + } + } + }); + + expect(actual).toEqual({ id: 42 }); + }); + + test('exec rejects params when parse throws', () => { + 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: { + parse: (param) => { + const n = Number(param); + if (!Number.isFinite(n)) throw new Error('not a number'); + return n; + } + } + }); + + expect(actual).toBeUndefined(); + }); + + test('exec uses match and parse together', () => { + 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: { + match: (param) => /^\d+$/.test(param), + parse: (param) => Number(param) + } + }); + + expect(actual).toEqual({ id: 42 }); + }); }); describe('resolve_route', () => { @@ -421,9 +475,9 @@ describe('find_route', () => { test('respects matchers', () => { const routes = [create_route('/blog/[slug=word]'), create_route('/blog/[slug]')]; - /** @type {Record} */ + /** @type {Record} */ const matchers = { - word: (param) => /^\w+$/.test(param) + word: { match: (param) => /^\w+$/.test(param) } }; // "hello" matches the word matcher @@ -435,6 +489,40 @@ describe('find_route', () => { assert.equal(result2?.route.id, '/blog/[slug]'); }); + test('parses params with a parse function', () => { + const routes = [create_route('/items/[id=number]')]; + /** @type {Record} */ + const matchers = { + number: { + parse: (param) => { + const n = Number(param); + if (!Number.isFinite(n)) throw new Error('not a number'); + return n; + } + } + }; + + const result = find_route('/items/42', routes, matchers); + assert.equal(result?.params.id, 42); + }); + + test('rejects params when parse throws', () => { + const routes = [create_route('/items/[id=number]')]; + /** @type {Record} */ + const matchers = { + number: { + parse: (param) => { + const n = Number(param); + if (!Number.isFinite(n)) throw new Error('not a number'); + return n; + } + } + }; + + 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/types/index.d.ts b/packages/kit/types/index.d.ts index 9e18e24fd52b..acce31b9a025 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1404,6 +1404,20 @@ declare module '@sveltejs/kit' { */ export type ParamMatcher = (param: string) => boolean; + /** + * The shape of a param parser. See [matching](https://svelte.dev/docs/kit/advanced-routing#Matching) for more info. + */ + export type ParamParser = (param: string) => T; + + /** + * A param matcher module can export a `match` function, a `parse` function, or both. + * See [matching](https://svelte.dev/docs/kit/advanced-routing#Matching) for more info. + */ + export interface ParamMatcherModule { + match?: ParamMatcher; + parse?: ParamParser; + } + /** * A single entry yielded by [`requested`](https://svelte.dev/docs/kit/$app-server#requested) * when called with a regular `query`. `arg` is the validated argument (the input *after* @@ -1659,7 +1673,7 @@ declare module '@sveltejs/kit' { remotes: Record Promise>; routes: SSRRoute[]; prerendered_routes: Set; - matchers: () => Promise>; + matchers: () => Promise>; /** A `[file]: size` map of all assets imported by server code. */ server_assets: Record; }; From fcd721d23e0fd147a89cd98ae8c2e852c137143d Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 23 Jun 2026 22:13:34 +0200 Subject: [PATCH 02/14] oops --- packages/kit/src/runtime/client/client.js | 7 +------ packages/kit/src/utils/routing.spec.js | 4 ++-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 29adcaa641f0..c100f5b7031e 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -8,12 +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_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'; diff --git a/packages/kit/src/utils/routing.spec.js b/packages/kit/src/utils/routing.spec.js index d4be01f76e57..d844540f0801 100644 --- a/packages/kit/src/utils/routing.spec.js +++ b/packages/kit/src/utils/routing.spec.js @@ -293,8 +293,8 @@ 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: { match: () => true }, + doesntmatch: { match: () => false } }); expect(actual).toEqual(expected); }); From 834db22075456e85b1c605c0995c3f0e190cb7f6 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 24 Jun 2026 13:48:44 +0200 Subject: [PATCH 03/14] feat: allow param matcher to be a standard schema match can now also be a standard schema. If it parses you get the transformed value. If it throws it counts as invalid and the route will not be matched. closes #10407 --- .changeset/ten-lands-send.md | 2 +- .../docs/30-advanced/10-advanced-routing.md | 44 ++-------- packages/kit/package.json | 1 + .../kit/src/core/generate_manifest/index.js | 4 +- .../sync/create_manifest_data/index.spec.js | 7 +- .../src/core/sync/write_client_manifest.js | 4 +- .../kit/src/core/sync/write_non_ambient.js | 4 +- .../kit/src/core/sync/write_types/index.js | 7 +- .../test/param-type-inference/package.json | 3 +- .../param-type-inference/params/number.js | 9 +-- packages/kit/src/exports/public.d.ts | 22 +++-- packages/kit/src/exports/vite/dev/index.js | 8 +- packages/kit/src/runtime/client/types.d.ts | 6 +- packages/kit/src/utils/routing.js | 45 +++++++---- packages/kit/src/utils/routing.spec.js | 80 +++++-------------- packages/kit/types/index.d.ts | 22 +++-- pnpm-lock.yaml | 6 ++ 17 files changed, 101 insertions(+), 173 deletions(-) diff --git a/.changeset/ten-lands-send.md b/.changeset/ten-lands-send.md index 9981ed05fef2..c67bbcc112d6 100644 --- a/.changeset/ten-lands-send.md +++ b/.changeset/ten-lands-send.md @@ -2,4 +2,4 @@ '@sveltejs/kit': minor --- -feat: add `parse` function to matcher files +feat: allow param matcher to be a standard schema diff --git a/documentation/docs/30-advanced/10-advanced-routing.md b/documentation/docs/30-advanced/10-advanced-routing.md index ca208525bc0c..ebbbf7330f26 100644 --- a/documentation/docs/30-advanced/10-advanced-routing.md +++ b/documentation/docs/30-advanced/10-advanced-routing.md @@ -93,20 +93,17 @@ 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. -### Parsing - -In addition to `match`, a param module can export a `parse` function that converts the parameter string into a more useful type. If `parse` is not defined, the parameter value remains a string. If `match` is not defined, all values are considered valid (equivalent to `() => true`). If `parse` throws an error, the parameter is treated as invalid. +Instead of a function, `match` can also be a [Standard Schema](https://standardschema.dev) — for example with [Valibot](https://valibot.dev): ```js /// file: src/params/number.js -/** @type {import('@sveltejs/kit').ParamParser} */ -export function parse(param) { - const n = Number(param); - if (!Number.isFinite(n)) throw new Error('not a number'); - return n; -} +import * as v from 'valibot'; + +export const match = 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. + ```js /// file: src/routes/items/[id=number]/+page.js /** @type {import('./$types').PageLoad} */ @@ -115,35 +112,6 @@ export function load({ params }) { } ``` -You can use both `match` and `parse` in the same module — `match` validates the string, and `parse` converts it: - -```js -/// file: src/params/number.js -/** @type {import('@sveltejs/kit').ParamMatcher} */ -export function match(param) { - return /^\d+$/.test(param); -} - -/** @type {import('@sveltejs/kit').ParamParser} */ -export function parse(param) { - return Number(param); -} -``` - -This also makes it possible to integrate with libraries like Valibot: - -```js -/// file: src/params/number.js -import * as v from 'valibot'; - -const min3 = v.pipe(v.string(), v.minLength(3)); - -/** @type {import('@sveltejs/kit').ParamParser} */ -export function parse(param) { - return v.parse(min3, param); -} -``` - 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. > [!NOTE] Matchers run both on the server and in the browser. diff --git a/packages/kit/package.json b/packages/kit/package.json index e75a34124c10..3d329058629e 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -40,6 +40,7 @@ "svelte": "catalog:", "svelte-preprocess": "catalog:", "typescript": "~6.0.0", + "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 769da6a6ce19..3bcc2be0690d 100644 --- a/packages/kit/src/core/generate_manifest/index.js +++ b/packages/kit/src/core/generate_manifest/index.js @@ -137,9 +137,9 @@ export function generate_manifest({ ${Array.from( matchers, type => - `const __matcher_${type} = await import ('${join_relative(relative_path, `/entries/matchers/${type}.js`)}')` + `const { match: ${type} } = await import ('${join_relative(relative_path, `/entries/matchers/${type}.js`)}')` ).join('\n')} - return { ${Array.from(matchers).map((type) => `${type}: { match: __matcher_${type}.match, parse: __matcher_${type}.parse }`).join(', ')} }; + return { ${Array.from(matchers).join(', ')} }; }, server_assets: ${s(files)} } 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 f91ea41cb2e4..8cd3a648b802 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 @@ -863,9 +863,12 @@ test('creates param matchers', () => { }); }); -test('creates param matchers with parse-only modules', () => { +test('creates param matchers with schema modules', () => { const parsed = path.resolve(cwd, 'params', 'parsed.js'); - fs.writeFileSync(parsed, 'export function parse(param) { return Number(param); }'); + fs.writeFileSync( + parsed, + `export const match = { '~standard': { validate() { return { value: 0 }; } } };` + ); try { const { matchers } = create('samples/basic'); diff --git a/packages/kit/src/core/sync/write_client_manifest.js b/packages/kit/src/core/sync/write_client_manifest.js index 174d7bde1e29..ba0d1e1b06bc 100644 --- a/packages/kit/src/core/sync/write_client_manifest.js +++ b/packages/kit/src/core/sync/write_client_manifest.js @@ -196,8 +196,8 @@ export function write_client_manifest(kit, manifest_data, output, metadata) { for (const key in manifest_data.matchers) { const src = manifest_data.matchers[key]; - imports.push(`import * as ${key} from ${s(relative_path(output, src))};`); - matchers.push(`${key}: { match: ${key}.match, parse: ${key}.parse }`); + imports.push(`import { match as ${key} } from ${s(relative_path(output, src))};`); + matchers.push(key); } const module = imports.length diff --git a/packages/kit/src/core/sync/write_non_ambient.js b/packages/kit/src/core/sync/write_non_ambient.js index d903692e1ec2..2cdb539c1f03 100644 --- a/packages/kit/src/core/sync/write_non_ambient.js +++ b/packages/kit/src/core/sync/write_non_ambient.js @@ -98,7 +98,7 @@ function generate_app_types(manifest_data, config) { let type = matcher_types.get(matcher); if (!type) { - type = `MatcherParam`; + type = `import('@sveltejs/kit').MatcherParam`; matcher_types.set(matcher, type); } @@ -239,8 +239,6 @@ function generate_app_types(manifest_data, config) { return [ 'declare module "$app/types" {', - '\ttype MatcherParam = M extends { parse: (param: string) => infer R } ? R : M extends { match: (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 73899836ad97..3fa7a35b4985 100644 --- a/packages/kit/src/core/sync/write_types/index.js +++ b/packages/kit/src/core/sync/write_types/index.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 { parse: (param: string) => infer R } ? R : M extends { match: (param: string) => param is (infer U extends string) } ? U : string;' - ); - declarations.push( 'type RouteParams = ' + generate_params_type(route.params, outdir, config) + ';' ); @@ -613,7 +608,7 @@ function generate_params_type(params, outdir, config) { (param) => `${param.name}${param.optional ? '?' : ''}: ${ param.matcher - ? `MatcherParam` + ? `Kit.MatcherParam` : 'string' }${param.optional ? ' | undefined' : ''}` ) 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 29c0de25e9fb..ce519a1be393 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.0" + "typescript": "~6.0.0", + "valibot": "catalog:" }, "scripts": { "testtypes": "tsc" diff --git a/packages/kit/src/core/sync/write_types/test/param-type-inference/params/number.js b/packages/kit/src/core/sync/write_types/test/param-type-inference/params/number.js index bfcca0e4328e..5633eab479d5 100644 --- a/packages/kit/src/core/sync/write_types/test/param-type-inference/params/number.js +++ b/packages/kit/src/core/sync/write_types/test/param-type-inference/params/number.js @@ -1,6 +1,3 @@ -/** @type {import('@sveltejs/kit').ParamParser} */ -export function parse(param) { - const n = Number(param); - if (!Number.isFinite(n)) throw new Error('not a number'); - return n; -} +import * as v from 'valibot'; + +export const match = v.pipe(v.string(), v.toNumber()); diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index a25cf1705ee5..cafcd762ee4d 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1429,21 +1429,17 @@ 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 = ((param: string) => boolean) | StandardSchemaV1; /** - * The shape of a param parser. See [matching](https://svelte.dev/docs/kit/advanced-routing#Matching) for more info. + * Extracts the param type from a matcher — the output type of a Standard Schema, the predicate of a type guard, or `string`. */ -export type ParamParser = (param: string) => T; - -/** - * A param matcher module can export a `match` function, a `parse` function, or both. - * See [matching](https://svelte.dev/docs/kit/advanced-routing#Matching) for more info. - */ -export interface ParamMatcherModule { - match?: ParamMatcher; - parse?: ParamParser; -} +export type MatcherParam = + M extends StandardSchemaV1 + ? StandardSchemaV1.InferOutput + : M extends ((param: string) => param is infer U extends string) + ? U + : string; /** * A single entry yielded by [`requested`](https://svelte.dev/docs/kit/$app-server#requested) @@ -1700,7 +1696,7 @@ export interface SSRManifest { remotes: Record Promise>; routes: SSRRoute[]; prerendered_routes: Set; - matchers: () => Promise>; + matchers: () => Promise>; /** A `[file]: size` map of all assets imported by server code. */ server_assets: Record; }; diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 276d22b8af4f..8fcc8a5ee262 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -288,7 +288,7 @@ export async function dev(vite, vite_config, svelte_config, get_remotes, root, a }) ), matchers: async () => { - /** @type {Record} */ + /** @type {Record} */ const matchers = {}; for (const key in manifest_data.matchers) { @@ -296,11 +296,11 @@ export async function dev(vite, vite_config, svelte_config, get_remotes, root, a const url = path.resolve(root, file); const module = await vite.ssrLoadModule(url, { fixStacktrace: true }); - if (!module.match && !module.parse) { - throw new Error(`${file} must export a \`match\` and/or \`parse\` function`); + if (!module.match) { + throw new Error(`${file} does not export a \`match\` function or schema`); } - matchers[key] = { match: module.match, parse: module.parse }; + matchers[key] = module.match; } return matchers; diff --git a/packages/kit/src/runtime/client/types.d.ts b/packages/kit/src/runtime/client/types.d.ts index 6396b6f3ee74..8a06a987929f 100644 --- a/packages/kit/src/runtime/client/types.d.ts +++ b/packages/kit/src/runtime/client/types.d.ts @@ -9,7 +9,7 @@ import { TrailingSlash, Uses } from 'types'; -import { Page, ParamMatcherModule } from '@sveltejs/kit'; +import { Page, ParamMatcher } from '@sveltejs/kit'; export interface SvelteKitApp { /** @@ -39,11 +39,11 @@ export interface SvelteKitApp { dictionary: Record; /** - * A map of `[matcherName: string]: ParamMatcherModule`, which is used to match and parse 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. */ - matchers: Record; + matchers: Record; hooks: ClientHooks; diff --git a/packages/kit/src/utils/routing.js b/packages/kit/src/utils/routing.js index c819973a1a32..8f55a0479868 100644 --- a/packages/kit/src/utils/routing.js +++ b/packages/kit/src/utils/routing.js @@ -133,10 +133,33 @@ 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) { + if (typeof matcher === 'function') { + return matcher(value) ? { success: true, value } : { success: false }; + } + + if ('~standard' in matcher) { + const result = matcher['~standard'].validate(value); + + if (result instanceof Promise || result.issues) { + return { success: false }; + } + + return { success: true, value: result.value }; + } + + return { success: false }; +} + /** * @param {RegExpMatchArray} match * @param {import('types').RouteParam[]} params - * @param {Record} matchers + * @param {Record} matchers */ export function exec(match, params, matchers) { /** @type {Record} */ @@ -175,9 +198,9 @@ export function exec(match, params, matchers) { const decoded = decodeURIComponent(value); if (param.matcher) { - const { match, parse } = matchers[param.matcher]; + const outcome = run_matcher(matchers[param.matcher], decoded); - if (match && !match(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) { @@ -189,19 +212,7 @@ export function exec(match, params, matchers) { return; } - try { - result[param.name] = parse ? parse(decoded) : decoded; - } catch { - // in the `/[[a=b]]/...` case, if the value didn't satisfy the parser, - // keep track of the number of skipped optional parameters and continue - if (param.optional && param.chained) { - buffered++; - continue; - } - - // otherwise, if the parser threw, the route did not match - return; - } + result[param.name] = outcome.value; } else { result[param.name] = decoded; } @@ -304,7 +315,7 @@ export function has_server_load(node) { * @template {{pattern: RegExp, params: import('types').RouteParam[]}} Route * @param {string} path - The decoded pathname to match * @param {Route[]} routes - * @param {Record} matchers + * @param {Record} matchers * @returns {{ route: Route, params: Record } | null} */ export function find_route(path, routes, matchers) { diff --git a/packages/kit/src/utils/routing.spec.js b/packages/kit/src/utils/routing.spec.js index d844540f0801..7901520a37bb 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,66 +297,34 @@ describe('exec', () => { const match = pattern.exec(path); if (!match) throw new Error(`Failed to match ${path}`); const actual = exec(match, params, { - matches: { match: () => true }, - doesntmatch: { match: () => false } + matches: () => true, + doesntmatch: () => false }); expect(actual).toEqual(expected); }); } - test('exec parses params with a parse function', () => { + 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: { - parse: (param) => { - const n = Number(param); - if (!Number.isFinite(n)) throw new Error('not a number'); - return n; - } - } - }); + const actual = exec(match, params, { number }); expect(actual).toEqual({ id: 42 }); }); - test('exec rejects params when parse throws', () => { + 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: { - parse: (param) => { - const n = Number(param); - if (!Number.isFinite(n)) throw new Error('not a number'); - return n; - } - } - }); + const actual = exec(match, params, { number }); expect(actual).toBeUndefined(); }); - - test('exec uses match and parse together', () => { - 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: { - match: (param) => /^\d+$/.test(param), - parse: (param) => Number(param) - } - }); - - expect(actual).toEqual({ id: 42 }); - }); }); describe('resolve_route', () => { @@ -475,9 +447,9 @@ describe('find_route', () => { test('respects matchers', () => { const routes = [create_route('/blog/[slug=word]'), create_route('/blog/[slug]')]; - /** @type {Record} */ + /** @type {Record} */ const matchers = { - word: { match: (param) => /^\w+$/.test(param) } + word: (param) => /^\w+$/.test(param) }; // "hello" matches the word matcher @@ -489,35 +461,19 @@ describe('find_route', () => { assert.equal(result2?.route.id, '/blog/[slug]'); }); - test('parses params with a parse function', () => { + test('validates and transforms params with a standard schema', () => { const routes = [create_route('/items/[id=number]')]; - /** @type {Record} */ - const matchers = { - number: { - parse: (param) => { - const n = Number(param); - if (!Number.isFinite(n)) throw new Error('not a number'); - return n; - } - } - }; + /** @type {Record} */ + const matchers = { number }; const result = find_route('/items/42', routes, matchers); assert.equal(result?.params.id, 42); }); - test('rejects params when parse throws', () => { + test('rejects params when a standard schema fails validation', () => { const routes = [create_route('/items/[id=number]')]; - /** @type {Record} */ - const matchers = { - number: { - parse: (param) => { - const n = Number(param); - if (!Number.isFinite(n)) throw new Error('not a number'); - return n; - } - } - }; + /** @type {Record} */ + const matchers = { number }; const result = find_route('/items/abc', routes, matchers); assert.equal(result, null); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index acce31b9a025..dd9d950927db 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1402,21 +1402,17 @@ 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 = ((param: string) => boolean) | StandardSchemaV1; /** - * The shape of a param parser. See [matching](https://svelte.dev/docs/kit/advanced-routing#Matching) for more info. + * Extracts the param type from a matcher — the output type of a Standard Schema, the predicate of a type guard, or `string`. */ - export type ParamParser = (param: string) => T; - - /** - * A param matcher module can export a `match` function, a `parse` function, or both. - * See [matching](https://svelte.dev/docs/kit/advanced-routing#Matching) for more info. - */ - export interface ParamMatcherModule { - match?: ParamMatcher; - parse?: ParamParser; - } + export type MatcherParam = + M extends StandardSchemaV1 + ? StandardSchemaV1.InferOutput + : M extends ((param: string) => param is infer U extends string) + ? U + : string; /** * A single entry yielded by [`requested`](https://svelte.dev/docs/kit/$app-server#requested) @@ -1673,7 +1669,7 @@ declare module '@sveltejs/kit' { remotes: Record Promise>; routes: SSRRoute[]; prerendered_routes: Set; - matchers: () => Promise>; + matchers: () => Promise>; /** A `[file]: size` map of all assets imported by server code. */ server_assets: Record; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a02ccb2644a..b090013bbae3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -602,6 +602,9 @@ importers: typescript: specifier: ~6.0.0 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.27.3)(jiti@2.4.2)(yaml@2.8.3) @@ -638,6 +641,9 @@ importers: typescript: specifier: ~6.0.0 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: From 2e0e375ce68e5dbb3c5157bb5e2f317ca3d76171 Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:35:50 +0000 Subject: [PATCH 04/14] Fix: `run_matcher` checks `typeof matcher === 'function'` before the `'~standard'` property, so callable Standard Schemas (e.g. ArkType) are misclassified as plain predicate functions, breaking validation and transformation. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes the issue reported at packages/kit/src/utils/routing.js:139 ## Bug `ParamMatcher` is typed as `((param: string) => boolean) | StandardSchemaV1`. Several headline Standard Schema implementations expose their schema as a **callable function that also carries a `'~standard'` property**. ArkType is the canonical example: ```js const t = type('string.numeric.parse'); t('42'); // → 42 (parsed value, or an ArkErrors object on failure) t['~standard'].validate('42'); // also available ``` In the original `run_matcher`, the `typeof matcher === 'function'` branch comes first: ```js if (typeof matcher === 'function') { return matcher(value) ? { success: true, value } : { success: false }; } if ('~standard' in matcher) { ... } ``` Because an ArkType schema is a function, it never reaches the `'~standard'` branch. Instead `matcher(value)` is called and its return value treated as a boolean: - **Invalid input**: ArkType returns a truthy `ArkErrors` object → treated as a *successful* match, and the raw, untransformed string is stored as the param value. The route matches when it should not. - **Valid input that transforms to a falsy value** (e.g. `0`, empty string): the truthiness check fails → the route is incorrectly rejected. So both validation and transformation are broken for callable Standard Schemas. ## Fix Reorder the checks so `'~standard'` is tested first. `'~standard' in matcher` works on functions too (functions are objects), so a callable Standard Schema is now routed through the proper `validate` path. Plain predicate functions lack the `'~standard'` property and correctly fall through to the function branch. ```js function run_matcher(matcher, value) { if ('~standard' in matcher) { const result = matcher['~standard'].validate(value); if (result instanceof Promise || result.issues) { return { success: false }; } return { success: true, value: result.value }; } if (typeof matcher === 'function') { return matcher(value) ? { success: true, value } : { success: false }; } return { success: false }; } ``` Co-authored-by: Vercel Co-authored-by: dummdidumm --- packages/kit/src/utils/routing.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/kit/src/utils/routing.js b/packages/kit/src/utils/routing.js index 8f55a0479868..0a455b137054 100644 --- a/packages/kit/src/utils/routing.js +++ b/packages/kit/src/utils/routing.js @@ -139,10 +139,6 @@ export function get_route_segments(route) { * @returns {{ success: true, value: any } | { success: false }} */ function run_matcher(matcher, value) { - if (typeof matcher === 'function') { - return matcher(value) ? { success: true, value } : { success: false }; - } - if ('~standard' in matcher) { const result = matcher['~standard'].validate(value); @@ -153,6 +149,10 @@ function run_matcher(matcher, value) { return { success: true, value: result.value }; } + if (typeof matcher === 'function') { + return matcher(value) ? { success: true, value } : { success: false }; + } + return { success: false }; } From 292955c6ceadddcdc645c9df44d7d54e56f1122c Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 25 Jun 2026 00:06:08 +0200 Subject: [PATCH 05/14] oops --- .../sync/write_types/test/param-type-inference/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 cc28445548e1..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,7 @@ "private": true, "type": "module", "devDependencies": { - "typescript": "~6.0.3" + "typescript": "~6.0.3", "valibot": "catalog:" }, "scripts": { From b572065d1155f0312d2081fc7d64f770c71cc85c Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 27 Jun 2026 00:24:33 +0200 Subject: [PATCH 06/14] breaking: remove param files in folder in favor or `params.js/ts` file Instead of having to declare one param matcher per file, you now declare all of them within one file. A `defineParams` function both helps with declaring them in a type-safe way and also normalizes the property values: You can either parse a standard schema object, or a function that either throws or returns the parsed value (if you don't want to bring in a schema library). closes #10407 Kudos to @phi-bre for suggesting this in https://github.com/sveltejs/kit/pull/16148#issuecomment-4787469323 --- .changeset/ten-lands-send.md | 4 +- .../docs/30-advanced/10-advanced-routing.md | 35 +++---- .../kit/src/core/generate_manifest/index.js | 26 +++-- .../core/sync/create_manifest_data/index.js | 48 +--------- .../sync/create_manifest_data/index.spec.js | 49 ++-------- .../sync/create_manifest_data/test/params.js | 6 ++ .../create_manifest_data/test/params/bar.js | 0 .../create_manifest_data/test/params/foo.js | 0 .../src/core/sync/write_client_manifest.js | 21 ++-- .../kit/src/core/sync/write_non_ambient.js | 15 ++- .../kit/src/core/sync/write_types/index.js | 15 ++- .../with-matcher/[[locale=locale]]/+page.js | 6 ++ .../sync/write_types/test/app-types/params.js | 12 +++ .../test/app-types/params/locale.js | 5 - .../test/param-type-inference/params.js | 15 +++ .../param-type-inference/params/narrowed.js | 5 - .../params/not_narrowed.js | 7 -- .../param-type-inference/params/number.js | 3 - .../param-type-inference/required/+layout.js | 2 +- .../[regularParam=not_narrowed]/+page.js | 2 +- packages/kit/src/exports/index.js | 1 + packages/kit/src/exports/params.js | 33 +++++++ packages/kit/src/exports/public.d.ts | 30 +++++- packages/kit/src/exports/vite/dev/index.js | 34 ++++--- packages/kit/src/exports/vite/index.js | 16 +++- packages/kit/src/types/internal.d.ts | 2 +- packages/kit/src/utils/params.js | 96 +++++++++++++++++++ packages/kit/src/utils/params.spec.js | 58 +++++++++++ packages/kit/src/utils/routing.js | 16 +--- packages/kit/src/utils/routing.spec.js | 7 +- packages/kit/test/apps/basics/src/params.js | 17 ++++ .../test/apps/basics/src/params/lowercase.js | 4 - .../test/apps/basics/src/params/numeric.js | 4 - .../test/apps/basics/src/params/uppercase.js | 4 - packages/kit/types/index.d.ts | 30 +++++- 35 files changed, 411 insertions(+), 217 deletions(-) create mode 100644 packages/kit/src/core/sync/create_manifest_data/test/params.js delete mode 100644 packages/kit/src/core/sync/create_manifest_data/test/params/bar.js delete mode 100644 packages/kit/src/core/sync/create_manifest_data/test/params/foo.js create mode 100644 packages/kit/src/core/sync/write_types/test/app-types/params.js delete mode 100644 packages/kit/src/core/sync/write_types/test/app-types/params/locale.js create mode 100644 packages/kit/src/core/sync/write_types/test/param-type-inference/params.js delete mode 100644 packages/kit/src/core/sync/write_types/test/param-type-inference/params/narrowed.js delete mode 100644 packages/kit/src/core/sync/write_types/test/param-type-inference/params/not_narrowed.js delete mode 100644 packages/kit/src/core/sync/write_types/test/param-type-inference/params/number.js create mode 100644 packages/kit/src/exports/params.js create mode 100644 packages/kit/src/utils/params.js create mode 100644 packages/kit/src/utils/params.spec.js create mode 100644 packages/kit/test/apps/basics/src/params.js delete mode 100644 packages/kit/test/apps/basics/src/params/lowercase.js delete mode 100644 packages/kit/test/apps/basics/src/params/numeric.js delete mode 100644 packages/kit/test/apps/basics/src/params/uppercase.js diff --git a/.changeset/ten-lands-send.md b/.changeset/ten-lands-send.md index c67bbcc112d6..4aee5ac6c60e 100644 --- a/.changeset/ten-lands-send.md +++ b/.changeset/ten-lands-send.md @@ -1,5 +1,5 @@ --- -'@sveltejs/kit': minor +'@sveltejs/kit': major --- -feat: allow param matcher to be a standard schema +breaking: remove param files in folder in favor or `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 ebbbf7330f26..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,15 +91,18 @@ 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. -Instead of a function, `match` can also be a [Standard Schema](https://standardschema.dev) — for example with [Valibot](https://valibot.dev): +You can also use a [Standard Schema](https://standardschema.dev) — for example with [Valibot](https://valibot.dev): ```js -/// file: src/params/number.js +/// file: src/params.js +import { defineParams } from '@sveltejs/kit'; import * as v from 'valibot'; -export const match = v.pipe(v.string(), v.toNumber()); +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. @@ -112,10 +115,10 @@ export function load({ params }) { } ``` -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. - > [!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/src/core/generate_manifest/index.js b/packages/kit/src/core/generate_manifest/index.js index 3bcc2be0690d..d203e47042ec 100644 --- a/packages/kit/src/core/generate_manifest/index.js +++ b/packages/kit/src/core/generate_manifest/index.js @@ -69,11 +69,9 @@ 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.client?.nodes + ? build_data.manifest_data.routes.some((route) => route.params.some((param) => param.matcher)) + : false; /** @param {Array} indexes */ function get_nodes(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,12 +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 8cd3a648b802..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,53 +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') - }); -}); - -test('creates param matchers with schema modules', () => { - const parsed = path.resolve(cwd, 'params', 'parsed.js'); - fs.writeFileSync( - parsed, - `export const match = { '~standard': { validate() { return { value: 0 }; } } };` - ); - try { - const { matchers } = create('samples/basic'); - - expect(matchers.parsed).toEqual(path.join('params', 'parsed.js')); - } finally { - fs.unlinkSync(parsed); - } + 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 2cdb539c1f03..e9548d83763a 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 = `import('@sveltejs/kit').MatcherParam`; + const path_to_params = () => { + const params_file = + resolve_entry(config.files.params) ?? + path.join(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); } diff --git a/packages/kit/src/core/sync/write_types/index.js b/packages/kit/src/core/sync/write_types/index.js index 3fa7a35b4985..2f38bd1f74c7 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'; @@ -600,15 +600,22 @@ function replace_ext_with_js(file_path) { */ 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) ?? + path.join(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 - ? `Kit.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/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/params/number.js b/packages/kit/src/core/sync/write_types/test/param-type-inference/params/number.js deleted file mode 100644 index 5633eab479d5..000000000000 --- a/packages/kit/src/core/sync/write_types/test/param-type-inference/params/number.js +++ /dev/null @@ -1,3 +0,0 @@ -import * as v from 'valibot'; - -export const match = v.pipe(v.string(), v.toNumber()); 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..5024e2235efb --- /dev/null +++ b/packages/kit/src/exports/params.js @@ -0,0 +1,33 @@ +import { normalize_param_definition } from '../utils/params.js'; + +/** + * 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); +} diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 2d54a78901ef..ffffbf71f4a6 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1433,17 +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) | StandardSchemaV1; +export type ParamMatcher = StandardSchemaV1; /** - * Extracts the param type from a matcher — the output type of a Standard Schema, the predicate of a type guard, or `string`. + * 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 - : string; + : 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 8fcc8a5ee262..0d94c985c0eb 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,16 @@ export async function dev(vite, vite_config, svelte_config, get_remotes, root, a }) ), matchers: async () => { - /** @type {Record} */ - const matchers = {}; + if (!manifest_data.params) return {}; - 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 }); + const url = path.resolve(root, manifest_data.params); + const module = await vite.ssrLoadModule(url, { fixStacktrace: true }); - if (!module.match) { - throw new Error(`${file} does not export a \`match\` function or schema`); - } - - matchers[key] = module.match; + if (!module.params) { + throw new Error(`${manifest_data.params} does not export \`params\` from \`defineParams\``); } - return matchers; + return module.params; } } }; @@ -320,7 +322,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 +334,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/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..c14032d17b83 --- /dev/null +++ b/packages/kit/src/utils/params.js @@ -0,0 +1,96 @@ +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); +} + +/** + * @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/utils/params.spec.js b/packages/kit/src/utils/params.spec.js new file mode 100644 index 000000000000..2267102ffcca --- /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, + normalize_param_definition, + validate_param_matchers +} from './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: async () => ({ 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 0a455b137054..484e77a873e9 100644 --- a/packages/kit/src/utils/routing.js +++ b/packages/kit/src/utils/routing.js @@ -139,21 +139,13 @@ export function get_route_segments(route) { * @returns {{ success: true, value: any } | { success: false }} */ function run_matcher(matcher, value) { - if ('~standard' in matcher) { - const result = matcher['~standard'].validate(value); + const result = matcher['~standard'].validate(value); - if (result instanceof Promise || result.issues) { - return { success: false }; - } - - return { success: true, value: result.value }; - } - - if (typeof matcher === 'function') { - return matcher(value) ? { success: true, value } : { success: false }; + if (result instanceof Promise || result.issues) { + return { success: false }; } - return { success: false }; + return { success: true, value: result.value }; } /** diff --git a/packages/kit/src/utils/routing.spec.js b/packages/kit/src/utils/routing.spec.js index 7901520a37bb..9aa0c5b49a0f 100644 --- a/packages/kit/src/utils/routing.spec.js +++ b/packages/kit/src/utils/routing.spec.js @@ -1,5 +1,6 @@ import { assert, expect, test, describe } from 'vitest'; import * as v from 'valibot'; +import { normalize_param_definition } from './params.js'; import { exec, parse_route_id, resolve_route, find_route } from './routing.js'; /** @type {import('@sveltejs/kit').ParamMatcher} */ @@ -297,8 +298,8 @@ 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); }); @@ -449,7 +450,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 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 97b0f054586d..b6c16a874f72 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1406,17 +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) | StandardSchemaV1; + export type ParamMatcher = StandardSchemaV1; /** - * Extracts the param type from a matcher — the output type of a Standard Schema, the predicate of a type guard, or `string`. + * 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 - : string; + : 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) @@ -2653,7 +2675,7 @@ declare module '@sveltejs/kit' { }; nodes: PageNode[]; routes: RouteData[]; - matchers: Record; + params: string | null; } interface PageNode { From 0f433d2a8de11ef1c0537152e55f12435fd6f14d Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:50:27 +0000 Subject: [PATCH 07/14] Fix: Fallback params import path uses `path.join(base, '.js')`, producing the invalid path `src/params/.js` instead of `src/params.js`. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes the issue reported at packages/kit/src/core/sync/write_types/index.js:605 ## Bug In `generate_params_type` (`write_types/index.js:606`) and the identical logic in `write_non_ambient.js:101`, the fallback used when `resolve_entry` returns `null` was: ```js path.join(config.kit.files.params.replace(/.(js|ts)$/, ''), '.js'); ``` `config.kit.files.params` defaults to `path.join(files.src, 'params')` → `src/params` (no extension). The `.replace(/.(js|ts)$/, '')` is a no-op there, and `path.join('src/params', '.js')` inserts a path separator, yielding `src/params/.js`. Even when the config value has an extension (e.g. `src/params.ts`), the replace strips it to `src/params` and `path.join` again yields `src/params/.js`. Verified with Node: ``` $ node -e "console.log(require('path').join('src/params', '.js'))" src/params/.js ``` ### Trigger / impact When a route references a matcher but `resolve_entry(config.kit.files.params)` returns `null` (params file not found at resolution time), the generated `$types`/app-types contain an import from the broken path `src/params/.js` instead of `src/params.js`, producing invalid type imports. ## Fix Replaced `path.join(base, '.js')` with string concatenation `base + '.js'` in both locations: ```js config.kit.files.params.replace(/.(js|ts)$/, '') + '.js'; ``` Verified output is now `src/params.js` for both `src/params` and `src/params.ts` inputs. Co-authored-by: Vercel Co-authored-by: dummdidumm --- packages/kit/src/core/sync/write_non_ambient.js | 2 +- packages/kit/src/core/sync/write_types/index.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/core/sync/write_non_ambient.js b/packages/kit/src/core/sync/write_non_ambient.js index e9548d83763a..77ab8f189db6 100644 --- a/packages/kit/src/core/sync/write_non_ambient.js +++ b/packages/kit/src/core/sync/write_non_ambient.js @@ -98,7 +98,7 @@ function generate_app_types(manifest_data, config) { const path_to_params = () => { const params_file = resolve_entry(config.files.params) ?? - path.join(config.files.params.replace(/\.(js|ts)$/, ''), '.js'); + config.files.params.replace(/\.(js|ts)$/, '') + '.js'; return posixify(path.relative(config.outDir, params_file)); }; diff --git a/packages/kit/src/core/sync/write_types/index.js b/packages/kit/src/core/sync/write_types/index.js index 2f38bd1f74c7..7e5ad05eb3f4 100644 --- a/packages/kit/src/core/sync/write_types/index.js +++ b/packages/kit/src/core/sync/write_types/index.js @@ -603,7 +603,7 @@ function generate_params_type(params, outdir, config) { const path_to_params = () => { const params_file = resolve_entry(config.kit.files.params) ?? - path.join(config.kit.files.params.replace(/\.(js|ts)$/, ''), '.js'); + config.kit.files.params.replace(/\.(js|ts)$/, '') + '.js'; return posixify(path.relative(outdir, params_file)); }; From 8e076d91030cebcd35a897f8b03fdc587a71445b Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 27 Jun 2026 00:54:31 +0200 Subject: [PATCH 08/14] move around --- packages/kit/src/exports/params.js | 32 ++++++++++++++++++++++++-- packages/kit/src/utils/params.js | 30 ------------------------ packages/kit/src/utils/params.spec.js | 2 +- packages/kit/src/utils/routing.spec.js | 1 - 4 files changed, 31 insertions(+), 34 deletions(-) diff --git a/packages/kit/src/exports/params.js b/packages/kit/src/exports/params.js index 5024e2235efb..70523c8f8d81 100644 --- a/packages/kit/src/exports/params.js +++ b/packages/kit/src/exports/params.js @@ -1,5 +1,3 @@ -import { normalize_param_definition } from '../utils/params.js'; - /** * Define [parameter matchers](https://svelte.dev/docs/kit/advanced-routing#Matching) for your app. * @@ -31,3 +29,33 @@ export function defineParams(definitions) { 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/utils/params.js b/packages/kit/src/utils/params.js index c14032d17b83..70832b288c83 100644 --- a/packages/kit/src/utils/params.js +++ b/packages/kit/src/utils/params.js @@ -64,33 +64,3 @@ export async function load_and_validate_params({ routes, params_path, root, load return /** @type {Record} */ (module.params); } - -/** - * @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/utils/params.spec.js b/packages/kit/src/utils/params.spec.js index 2267102ffcca..78a9dee8bafe 100644 --- a/packages/kit/src/utils/params.spec.js +++ b/packages/kit/src/utils/params.spec.js @@ -2,9 +2,9 @@ import { assert, expect, test } from 'vitest'; import { collect_matcher_names, load_and_validate_params, - normalize_param_definition, 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([ diff --git a/packages/kit/src/utils/routing.spec.js b/packages/kit/src/utils/routing.spec.js index 9aa0c5b49a0f..7e26be3f870c 100644 --- a/packages/kit/src/utils/routing.spec.js +++ b/packages/kit/src/utils/routing.spec.js @@ -1,6 +1,5 @@ import { assert, expect, test, describe } from 'vitest'; import * as v from 'valibot'; -import { normalize_param_definition } from './params.js'; import { exec, parse_route_id, resolve_route, find_route } from './routing.js'; /** @type {import('@sveltejs/kit').ParamMatcher} */ From 088e9061fb0e84db6d6268c79c6f0958fb745959 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 27 Jun 2026 00:54:48 +0200 Subject: [PATCH 09/14] lint --- packages/kit/src/exports/vite/dev/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 0d94c985c0eb..a2abb6b89037 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -302,7 +302,9 @@ export async function dev(vite, vite_config, svelte_config, get_remotes, root, a const module = await vite.ssrLoadModule(url, { fixStacktrace: true }); if (!module.params) { - throw new Error(`${manifest_data.params} does not export \`params\` from \`defineParams\``); + throw new Error( + `${manifest_data.params} does not export \`params\` from \`defineParams\`` + ); } return module.params; From de6abd86d478d1ac354b7346042ef484efe88200 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 27 Jun 2026 21:46:33 +0200 Subject: [PATCH 10/14] lint --- packages/kit/src/utils/params.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/utils/params.spec.js b/packages/kit/src/utils/params.spec.js index 78a9dee8bafe..0829c838e04a 100644 --- a/packages/kit/src/utils/params.spec.js +++ b/packages/kit/src/utils/params.spec.js @@ -32,7 +32,7 @@ test('load_and_validate_params loads and validates params', async () => { ], params_path: 'params.js', root: import.meta.dirname, - load: async () => ({ params: { number: () => true } }) + load: () => Promise.resolve({ params: { number: () => true } }) }); expect(params).toEqual({ number: expect.any(Function) }); From 7b7782899ead11d2422d53448c18aa628d58ceb4 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 27 Jun 2026 22:18:21 +0200 Subject: [PATCH 11/14] ugh --- packages/kit/src/core/generate_manifest/index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kit/src/core/generate_manifest/index.js b/packages/kit/src/core/generate_manifest/index.js index d203e47042ec..586747b2d294 100644 --- a/packages/kit/src/core/generate_manifest/index.js +++ b/packages/kit/src/core/generate_manifest/index.js @@ -69,9 +69,9 @@ export function generate_manifest({ assets.push(build_data.service_worker); } - const uses_matchers = build_data.client?.nodes - ? build_data.manifest_data.routes.some((route) => route.params.some((param) => param.matcher)) - : false; + const uses_matchers = build_data.manifest_data.routes.some((route) => + route.params.some((param) => param.matcher) + ); /** @param {Array} indexes */ function get_nodes(indexes) { From 65b5b64a8994fb82b102a5fa8f8e19be8cd04788 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 29 Jun 2026 11:13:17 +0200 Subject: [PATCH 12/14] fix --- packages/kit/src/core/sync/write_types/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/kit/src/core/sync/write_types/index.js b/packages/kit/src/core/sync/write_types/index.js index 7e5ad05eb3f4..f80ddf0ba5a8 100644 --- a/packages/kit/src/core/sync/write_types/index.js +++ b/packages/kit/src/core/sync/write_types/index.js @@ -599,7 +599,6 @@ 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_params = () => { const params_file = resolve_entry(config.kit.files.params) ?? From 7c73660d9c0cc6d84e99326d21f9a64ee6bbb70b Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 29 Jun 2026 16:06:00 +0200 Subject: [PATCH 13/14] fix --- packages/kit/test/apps/basics/src/app.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; } From 4f2e270fa52520b6b1a84e149967ebccbcad3ebf Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:22:10 +0200 Subject: [PATCH 14/14] Update .changeset/ten-lands-send.md Co-authored-by: Conduitry --- .changeset/ten-lands-send.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/ten-lands-send.md b/.changeset/ten-lands-send.md index 4aee5ac6c60e..e870ae34ded9 100644 --- a/.changeset/ten-lands-send.md +++ b/.changeset/ten-lands-send.md @@ -2,4 +2,4 @@ '@sveltejs/kit': major --- -breaking: remove param files in folder in favor or `params.js/ts` file +breaking: remove param files in folder in favor of `params.js/ts` file