Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ten-lands-send.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': major
---

breaking: remove param files in folder in favor of `params.js/ts` file
46 changes: 34 additions & 12 deletions documentation/docs/30-advanced/10-advanced-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -91,12 +91,34 @@ export function match(param) {
src/routes/fruits/[page+++=fruit+++]
```

If the pathname doesn't match, SvelteKit will try to match other routes (using the sort order specified below), before eventually returning a 404.
If the pathname doesn't match, SvelteKit will try to match other routes (using the sort order specified below), before eventually returning a 404. If it does match, the returned value is passed as the param value.

You can also use a [Standard Schema](https://standardschema.dev) — for example with [Valibot](https://valibot.dev):

```js
/// file: src/params.js
import { defineParams } from '@sveltejs/kit';
import * as v from 'valibot';

export const params = defineParams({
number: v.pipe(v.string(), v.toNumber())
});
```

When a schema is used, SvelteKit validates the parameter and uses the transformed output as the param value. If validation fails, the route does not match.

Each module in the `params` directory corresponds to a matcher, with the exception of `*.test.js` and `*.spec.js` files which may be used to unit test your matchers.
```js
/// file: src/routes/items/[id=number]/+page.js
/** @type {import('./$types').PageLoad} */
export function load({ params }) {
console.log(typeof params.id); // 'number'
}
```

> [!NOTE] Matchers run both on the server and in the browser.

> [!NOTE] Prior to SvelteKit 3, you had to define each param matcher in a separate file, all listed under a `params` folder (for example `src/params/foo.js` with `export const match = (param) => param === 'foo';`), and matching was determined by whether or not the matcher returns a truthy value (which means no value transformation took place).

## Sorting

It's possible for multiple routes to match a given path. For example each of these routes would match `/foo-abc`:
Expand Down
1 change: 1 addition & 0 deletions packages/kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"svelte": "catalog:",
"svelte-preprocess": "catalog:",
"typescript": "~6.0.3",
"valibot": "catalog:",
"vite": "catalog:",
"vitest": "catalog:"
},
Expand Down
23 changes: 10 additions & 13 deletions packages/kit/src/core/generate_manifest/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,8 @@ export function generate_manifest({
assets.push(build_data.service_worker);
}

// In case of server-side route resolution, we need to include all matchers. Prerendered routes are not part
// of the server manifest, and they could reference matchers that then would not be included.
const matchers = new Set(
build_data.client?.nodes ? Object.keys(build_data.manifest_data.matchers) : undefined
const uses_matchers = build_data.manifest_data.routes.some((route) =>
route.params.some((param) => param.matcher)
);

/** @param {Array<number | undefined>} indexes */
Expand Down Expand Up @@ -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)},
Expand All @@ -134,11 +128,14 @@ export function generate_manifest({
],
prerendered_routes: new Set(${s(prerendered)}),
matchers: async () => {
${Array.from(
matchers,
type => `const { match: ${type} } = await import ('${(join_relative(relative_path, `/entries/matchers/${type}.js`))}')`
).join('\n')}
return { ${Array.from(matchers).join(', ')} };
${
uses_matchers && build_data.manifest_data.params
? dedent`
const { params } = await import('${join_relative(relative_path, '/entries/params.js')}');
return params;
`
: 'return {};'
}
},
server_assets: ${s(files)}
}
Expand Down
48 changes: 5 additions & 43 deletions packages/kit/src/core/sync/create_manifest_data/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand Down Expand Up @@ -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<string, string>} */
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;
}

/**
Expand Down
34 changes: 8 additions & 26 deletions packages/kit/src/core/sync/create_manifest_data/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -854,38 +854,20 @@ test('errors on invalid named layout reference', () => {
);
});

test('creates param matchers', () => {
const { matchers } = create('samples/basic'); // directory doesn't matter for the test
test('creates params file path', () => {
const { params } = create('samples/basic');

expect(matchers).toEqual({
foo: path.join('params', 'foo.js'),
bar: path.join('params', 'bar.js')
});
expect(params).toBe('params.js');
});

test('errors on param matchers with bad names', () => {
const boogaloo = path.resolve(cwd, 'params', 'boo-galoo.js');
fs.writeFileSync(boogaloo, '');
try {
assert.throws(() => create('samples/basic'), /Matcher names can only have/);
} finally {
fs.unlinkSync(boogaloo);
}
});
test('returns null params when file is missing', () => {
const params_file = path.resolve(cwd, 'params.js');

test('errors on duplicate matchers', () => {
const ts_foo = path.resolve(cwd, 'params', 'foo.ts');
fs.writeFileSync(ts_foo, '');
fs.renameSync(params_file, params_file + '.bak');
try {
assert.throws(() => {
create('samples/basic', {
kit: {
moduleExtensions: ['.js', '.ts']
}
});
}, /Duplicate matchers/);
expect(create('samples/basic').params).toBeNull();
} finally {
fs.unlinkSync(ts_foo);
fs.renameSync(params_file + '.bak', params_file);
}
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineParams } from '@sveltejs/kit';

export const params = defineParams({
foo: () => true,
bar: () => true
});
Empty file.
Empty file.
21 changes: 7 additions & 14 deletions packages/kit/src/core/sync/write_client_manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
17 changes: 10 additions & 7 deletions packages/kit/src/core/sync/write_non_ambient.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, string>} */
const matcher_types = new Map();

Expand All @@ -98,7 +95,15 @@ function generate_app_types(manifest_data, config) {

let type = matcher_types.get(matcher);
if (!type) {
type = `MatcherParam<typeof import('${path_to_matcher(matcher)}').match>`;
const path_to_params = () => {
const params_file =
resolve_entry(config.files.params) ??
config.files.params.replace(/\.(js|ts)$/, '') + '.js';

return posixify(path.relative(config.outDir, params_file));
};

type = `(typeof import('${path_to_params()}').params)[${JSON.stringify(matcher)}]`;
matcher_types.set(matcher, type);
}

Expand Down Expand Up @@ -239,8 +244,6 @@ function generate_app_types(manifest_data, config) {

return [
'declare module "$app/types" {',
'\ttype MatcherParam<M> = M extends (param : string) => param is (infer U extends string) ? U : string;',
'',
'\texport interface AppTypes {',
`\t\tRouteId(): ${manifest_data.routes.map((r) => s(r.id)).join(' | ')};`,
`\t\tRouteParams(): {\n\t\t\t${dynamic_routes.join(';\n\t\t\t')}\n\t\t};`,
Expand Down
21 changes: 11 additions & 10 deletions packages/kit/src/core/sync/write_types/index.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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> = 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> = M extends (param : string) => param is (infer U extends string) ? U : string;'
);

declarations.push(
'type RouteParams = ' + generate_params_type(route.params, outdir, config) + ';'
);
Expand Down Expand Up @@ -604,16 +599,22 @@ function replace_ext_with_js(file_path) {
* @param {import('types').ValidatedConfig} config
*/
function generate_params_type(params, outdir, config) {
/** @param {string} matcher */
const path_to_matcher = (matcher) =>
posixify(path.relative(outdir, path.join(config.kit.files.params, matcher + '.js')));
const path_to_params = () => {
const params_file =
resolve_entry(config.kit.files.params) ??
Comment thread
vercel[bot] marked this conversation as resolved.
config.kit.files.params.replace(/\.(js|ts)$/, '') + '.js';

return posixify(path.relative(outdir, params_file));
};

const params_import = path_to_params();

return `{ ${params
.map(
(param) =>
`${param.name}${param.optional ? '?' : ''}: ${
param.matcher
? `MatcherParam<typeof import('${path_to_matcher(param.matcher)}').match>`
? `(typeof import('${params_import}').params)[${JSON.stringify(param.matcher)}]`
: 'string'
}${param.optional ? ' | undefined' : ''}`
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 12 additions & 0 deletions packages/kit/src/core/sync/write_types/test/app-types/params.js
Original file line number Diff line number Diff line change
@@ -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);
}
});

This file was deleted.

Loading
Loading