From cf61978507b09b341aaff67647227cd7445ff0ce Mon Sep 17 00:00:00 2001 From: David Roizenman Date: Wed, 26 Nov 2025 20:38:45 -0800 Subject: [PATCH 1/5] feat: provide match function to allow the opposite of resolve --- .changeset/new-bugs-fail.md | 5 +++ packages/kit/src/runtime/app/paths/client.js | 38 +++++++++++++++++++ packages/kit/src/runtime/app/paths/server.js | 30 ++++++++++++++- packages/kit/src/runtime/client/client.js | 8 ++++ .../basics/src/routes/match/+page.server.js | 12 ++++++ .../apps/basics/src/routes/match/+page.svelte | 34 +++++++++++++++++ .../apps/basics/src/routes/match/const.ts | 5 +++ .../src/routes/match/load/foo/+page.svelte | 1 + .../src/routes/match/slug/[slug]/+page.svelte | 1 + packages/kit/test/apps/basics/test/test.js | 23 +++++++++++ 10 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 .changeset/new-bugs-fail.md create mode 100644 packages/kit/test/apps/basics/src/routes/match/+page.server.js create mode 100644 packages/kit/test/apps/basics/src/routes/match/+page.svelte create mode 100644 packages/kit/test/apps/basics/src/routes/match/const.ts create mode 100644 packages/kit/test/apps/basics/src/routes/match/load/foo/+page.svelte create mode 100644 packages/kit/test/apps/basics/src/routes/match/slug/[slug]/+page.svelte diff --git a/.changeset/new-bugs-fail.md b/.changeset/new-bugs-fail.md new file mode 100644 index 000000000000..d408b4787f99 --- /dev/null +++ b/.changeset/new-bugs-fail.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: `match` function to map a path back to a route id and params diff --git a/packages/kit/src/runtime/app/paths/client.js b/packages/kit/src/runtime/app/paths/client.js index 4de84a1d7c8f..5266ffcef301 100644 --- a/packages/kit/src/runtime/app/paths/client.js +++ b/packages/kit/src/runtime/app/paths/client.js @@ -2,6 +2,8 @@ /** @import { ResolveArgs } from './types.js' */ import { base, assets, hash_routing } from './internal/client.js'; import { resolve_route } from '../../../utils/routing.js'; +import { decode_params } from '../../../utils/url.js'; +import { get_routes } from '../../client/client.js'; /** * Resolve the URL of an asset in your `static` directory, by prefixing it with [`config.kit.paths.assets`](https://svelte.dev/docs/kit/configuration#paths) if configured, or otherwise by prefixing it with the base path. @@ -58,4 +60,40 @@ export function resolve(...args) { ); } +/** + * Match a pathname to a route ID and extracts any parameters. + * + * @example + * ```js + * import { match } from '$app/paths'; + * + * const result = await match('/blog/hello-world'); + * // → { id: '/blog/[slug]', params: { slug: 'hello-world' } } + * + * + * @param {Pathname} pathname + * @returns {Promise<{ id: RouteId, params: Record } | null>} + */ +export async function match(pathname) { + let path = pathname; + + if (base && path.startsWith(base)) { + path = path.slice(base.length) || '/'; + } + + if (hash_routing && path.startsWith('#')) { + path = path.slice(1) || '/'; + } + + const routes = get_routes(); + for (const route of routes) { + const params = route.exec(path); + if (params) { + return { id: /** @type {RouteId} */ (route.id), params: decode_params(params) }; + } + } + + return null; +} + export { base, assets, resolve as resolveRoute }; diff --git a/packages/kit/src/runtime/app/paths/server.js b/packages/kit/src/runtime/app/paths/server.js index abee8466006e..0204d69bdcd4 100644 --- a/packages/kit/src/runtime/app/paths/server.js +++ b/packages/kit/src/runtime/app/paths/server.js @@ -1,6 +1,8 @@ import { base, assets, relative, initial_base } from './internal/server.js'; -import { resolve_route } from '../../../utils/routing.js'; +import { resolve_route, exec } from '../../../utils/routing.js'; +import { decode_params } from '../../../utils/url.js'; import { try_get_request_store } from '@sveltejs/kit/internal/server'; +import { manifest } from '__sveltekit/server'; /** @type {import('./client.js').asset} */ export function asset(file) { @@ -27,4 +29,30 @@ export function resolve(id, params) { return base + resolved; } +/** @type {import('./client.js').match} */ +export async function match(pathname) { + let path = pathname; + + if (base && path.startsWith(base)) { + path = path.slice(base.length) || '/'; + } + + const matchers = await manifest._.matchers(); + + for (const route of manifest._.routes) { + const match = route.pattern.exec(path); + if (!match) continue; + + const matched = exec(match, route.params, matchers); + if (matched) { + return { + id: /** @type {import('$app/types').RouteId} */ (route.id), + params: decode_params(matched) + }; + } + } + + return null; +} + export { base, assets, resolve as resolveRoute }; diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index f8d987693ce2..fd5995730e2f 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -189,6 +189,14 @@ let target; /** @type {import('./types.js').SvelteKitApp} */ export let app; +/** + * Returns the client-side routes array. Used by `$app/paths` for route matching. + * @returns {import('types').CSRRoute[]} + */ +export function get_routes() { + return routes; +} + /** * Data that was serialized during SSR. This is cleared when the user first navigates * @type {Record} diff --git a/packages/kit/test/apps/basics/src/routes/match/+page.server.js b/packages/kit/test/apps/basics/src/routes/match/+page.server.js new file mode 100644 index 000000000000..094b3705d4fe --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/match/+page.server.js @@ -0,0 +1,12 @@ +import { match } from '$app/paths'; +import { testPaths } from './const'; + +export async function load() { + const serverResults = await Promise.all( + testPaths.map(async (path) => ({ path, result: await match(path) })) + ); + + return { + serverResults + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/match/+page.svelte b/packages/kit/test/apps/basics/src/routes/match/+page.svelte new file mode 100644 index 000000000000..3650ff6e619a --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/match/+page.svelte @@ -0,0 +1,34 @@ + + +

Match Test

+ +
+ {#each data.serverResults as { path, result }} +
+ {JSON.stringify(result)} +
+ {/each} +
+ +
+ {#each clientResults as { path, result }} +
+ {JSON.stringify(result)} +
+ {/each} +
diff --git a/packages/kit/test/apps/basics/src/routes/match/const.ts b/packages/kit/test/apps/basics/src/routes/match/const.ts new file mode 100644 index 000000000000..9515185fb8d8 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/match/const.ts @@ -0,0 +1,5 @@ +export const testPaths = [ + '/match/load/foo', + '/match/slug/test-slug', + '/match/not-a-real-route-that-exists' +]; diff --git a/packages/kit/test/apps/basics/src/routes/match/load/foo/+page.svelte b/packages/kit/test/apps/basics/src/routes/match/load/foo/+page.svelte new file mode 100644 index 000000000000..5716ca5987cb --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/match/load/foo/+page.svelte @@ -0,0 +1 @@ +bar diff --git a/packages/kit/test/apps/basics/src/routes/match/slug/[slug]/+page.svelte b/packages/kit/test/apps/basics/src/routes/match/slug/[slug]/+page.svelte new file mode 100644 index 000000000000..9e8d233a62ca --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/match/slug/[slug]/+page.svelte @@ -0,0 +1 @@ +slug diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index 554a4d8ba59d..dc2802c26a20 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -766,6 +766,29 @@ test.describe('$app/paths', () => { javaScriptEnabled ? absolute : '../../../../favicon.png' ); }); + + test('match() returns route id and params for matching routes', async ({ + page, + javaScriptEnabled + }) => { + await page.goto('/match'); + + const samples = [ + { path: '/match/load/foo', expected: { id: '/match/load/foo', params: {} } }, + { + path: '/match/slug/test-slug', + expected: { id: '/match/slug/[slug]', params: { slug: 'test-slug' } } + }, + { path: '/match/not-a-real-route-that-exists', expected: null } + ]; + + for (const { path, expected } of samples) { + const results = javaScriptEnabled + ? page.locator('#client-results') + : page.locator('#server-results'); + await expect(results.locator(`[data-path="${path}"]`)).toHaveText(JSON.stringify(expected)); + } + }); }); // TODO SvelteKit 3: remove these tests From d02f58d11c845c785eefa46f266c274a345c093c Mon Sep 17 00:00:00 2001 From: David Roizenman Date: Mon, 8 Dec 2025 22:50:56 -0800 Subject: [PATCH 2/5] use get_navigation_intent and reroute hook --- packages/kit/src/runtime/app/paths/client.js | 25 ++++++------------- packages/kit/src/runtime/app/paths/server.js | 24 ++++++++++++++---- packages/kit/src/runtime/client/client.js | 2 ++ .../apps/basics/src/routes/match/const.ts | 3 ++- packages/kit/test/apps/basics/test/test.js | 3 ++- 5 files changed, 33 insertions(+), 24 deletions(-) diff --git a/packages/kit/src/runtime/app/paths/client.js b/packages/kit/src/runtime/app/paths/client.js index 5266ffcef301..8523460dc0de 100644 --- a/packages/kit/src/runtime/app/paths/client.js +++ b/packages/kit/src/runtime/app/paths/client.js @@ -2,8 +2,7 @@ /** @import { ResolveArgs } from './types.js' */ import { base, assets, hash_routing } from './internal/client.js'; import { resolve_route } from '../../../utils/routing.js'; -import { decode_params } from '../../../utils/url.js'; -import { get_routes } from '../../client/client.js'; +import { get_navigation_intent } from '../../client/client.js'; /** * Resolve the URL of an asset in your `static` directory, by prefixing it with [`config.kit.paths.assets`](https://svelte.dev/docs/kit/configuration#paths) if configured, or otherwise by prefixing it with the base path. @@ -75,22 +74,14 @@ export function resolve(...args) { * @returns {Promise<{ id: RouteId, params: Record } | null>} */ export async function match(pathname) { - let path = pathname; + const url = new URL(pathname, location.href); + const intent = await get_navigation_intent(url, false); - if (base && path.startsWith(base)) { - path = path.slice(base.length) || '/'; - } - - if (hash_routing && path.startsWith('#')) { - path = path.slice(1) || '/'; - } - - const routes = get_routes(); - for (const route of routes) { - const params = route.exec(path); - if (params) { - return { id: /** @type {RouteId} */ (route.id), params: decode_params(params) }; - } + if (intent) { + return { + id: /** @type {RouteId} */ (intent.route.id), + params: intent.params + }; } return null; diff --git a/packages/kit/src/runtime/app/paths/server.js b/packages/kit/src/runtime/app/paths/server.js index 0204d69bdcd4..c1cc2e7879ca 100644 --- a/packages/kit/src/runtime/app/paths/server.js +++ b/packages/kit/src/runtime/app/paths/server.js @@ -1,8 +1,9 @@ import { base, assets, relative, initial_base } from './internal/server.js'; import { resolve_route, exec } from '../../../utils/routing.js'; -import { decode_params } from '../../../utils/url.js'; +import { decode_params, decode_pathname } from '../../../utils/url.js'; import { try_get_request_store } from '@sveltejs/kit/internal/server'; import { manifest } from '__sveltekit/server'; +import { get_hooks } from '__SERVER__/internal.js'; /** @type {import('./client.js').asset} */ export function asset(file) { @@ -31,16 +32,29 @@ export function resolve(id, params) { /** @type {import('./client.js').match} */ export async function match(pathname) { - let path = pathname; + const store = try_get_request_store(); + const origin = store?.event.url.origin ?? 'http://sveltekit'; + const url = new URL(pathname, origin); - if (base && path.startsWith(base)) { - path = path.slice(base.length) || '/'; + const { reroute } = await get_hooks(); + + let resolved_path = + (await reroute?.({ url, fetch: store?.event.fetch ?? fetch })) ?? url.pathname; + + try { + resolved_path = decode_pathname(resolved_path); + } catch { + return null; + } + + if (base && resolved_path.startsWith(base)) { + resolved_path = resolved_path.slice(base.length) || '/'; } const matchers = await manifest._.matchers(); for (const route of manifest._.routes) { - const match = route.pattern.exec(path); + const match = route.pattern.exec(resolved_path); if (!match) continue; const matched = exec(match, route.params, matchers); diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index fd5995730e2f..feacb4e46860 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -203,6 +203,8 @@ export function get_routes() { */ export let remote_responses = {}; +export { get_navigation_intent }; + /** @type {Array<((url: URL) => boolean)>} */ const invalidated = []; diff --git a/packages/kit/test/apps/basics/src/routes/match/const.ts b/packages/kit/test/apps/basics/src/routes/match/const.ts index 9515185fb8d8..9ec65ea8cc4d 100644 --- a/packages/kit/test/apps/basics/src/routes/match/const.ts +++ b/packages/kit/test/apps/basics/src/routes/match/const.ts @@ -1,5 +1,6 @@ export const testPaths = [ '/match/load/foo', '/match/slug/test-slug', - '/match/not-a-real-route-that-exists' + '/match/not-a-real-route-that-exists', + '/reroute/basic/a' ]; diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index dc2802c26a20..169e0b279cfe 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -779,7 +779,8 @@ test.describe('$app/paths', () => { path: '/match/slug/test-slug', expected: { id: '/match/slug/[slug]', params: { slug: 'test-slug' } } }, - { path: '/match/not-a-real-route-that-exists', expected: null } + { path: '/match/not-a-real-route-that-exists', expected: null }, + { path: '/reroute/basic/a', expected: { id: '/reroute/basic/b', params: {} } } ]; for (const { path, expected } of samples) { From 61870b05f617f8387876527e5ad486a60c48f647 Mon Sep 17 00:00:00 2001 From: David Roizenman Date: Mon, 8 Dec 2025 23:29:20 -0800 Subject: [PATCH 3/5] support passing url to match --- packages/kit/src/runtime/app/paths/client.js | 11 +++++++---- packages/kit/src/runtime/app/paths/server.js | 11 +++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/kit/src/runtime/app/paths/client.js b/packages/kit/src/runtime/app/paths/client.js index 8523460dc0de..ad78b2469b60 100644 --- a/packages/kit/src/runtime/app/paths/client.js +++ b/packages/kit/src/runtime/app/paths/client.js @@ -60,7 +60,7 @@ export function resolve(...args) { } /** - * Match a pathname to a route ID and extracts any parameters. + * Match a path or URL to a route ID and extracts any parameters. * * @example * ```js @@ -70,11 +70,14 @@ export function resolve(...args) { * // → { id: '/blog/[slug]', params: { slug: 'hello-world' } } * * - * @param {Pathname} pathname + * @param {Pathname | string | URL} url * @returns {Promise<{ id: RouteId, params: Record } | null>} */ -export async function match(pathname) { - const url = new URL(pathname, location.href); +export async function match(url) { + if (typeof url === 'string') { + url = new URL(url, location.href); + } + const intent = await get_navigation_intent(url, false); if (intent) { diff --git a/packages/kit/src/runtime/app/paths/server.js b/packages/kit/src/runtime/app/paths/server.js index c1cc2e7879ca..58f887802947 100644 --- a/packages/kit/src/runtime/app/paths/server.js +++ b/packages/kit/src/runtime/app/paths/server.js @@ -31,15 +31,18 @@ export function resolve(id, params) { } /** @type {import('./client.js').match} */ -export async function match(pathname) { +export async function match(url) { const store = try_get_request_store(); - const origin = store?.event.url.origin ?? 'http://sveltekit'; - const url = new URL(pathname, origin); + + if (typeof url === 'string') { + const origin = store?.event.url.origin ?? 'http://sveltekit'; + url = new URL(url, origin); + } const { reroute } = await get_hooks(); let resolved_path = - (await reroute?.({ url, fetch: store?.event.fetch ?? fetch })) ?? url.pathname; + (await reroute?.({ url: new URL(url), fetch: store?.event.fetch ?? fetch })) ?? url.pathname; try { resolved_path = decode_pathname(resolved_path); From f4197f5c0ee4e718297edd7b1e5103656287990a Mon Sep 17 00:00:00 2001 From: David Roizenman Date: Mon, 8 Dec 2025 23:47:53 -0800 Subject: [PATCH 4/5] fix types --- packages/kit/src/runtime/app/paths/client.js | 2 +- packages/kit/src/runtime/app/paths/public.d.ts | 2 +- packages/kit/types/index.d.ts | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/runtime/app/paths/client.js b/packages/kit/src/runtime/app/paths/client.js index ad78b2469b60..ceaf610a6530 100644 --- a/packages/kit/src/runtime/app/paths/client.js +++ b/packages/kit/src/runtime/app/paths/client.js @@ -70,7 +70,7 @@ export function resolve(...args) { * // → { id: '/blog/[slug]', params: { slug: 'hello-world' } } * * - * @param {Pathname | string | URL} url + * @param {Pathname | URL | (string & {})} url * @returns {Promise<{ id: RouteId, params: Record } | null>} */ export async function match(url) { diff --git a/packages/kit/src/runtime/app/paths/public.d.ts b/packages/kit/src/runtime/app/paths/public.d.ts index 61d6fcc07182..ae8b2ed0b0ac 100644 --- a/packages/kit/src/runtime/app/paths/public.d.ts +++ b/packages/kit/src/runtime/app/paths/public.d.ts @@ -1,7 +1,7 @@ import { RouteId, Pathname, ResolvedPathname } from '$app/types'; import { ResolveArgs } from './types.js'; -export { resolve, asset } from './client.js'; +export { resolve, asset, match } from './client.js'; /** * A string that matches [`config.kit.paths.base`](https://svelte.dev/docs/kit/configuration#paths). diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 8ca87b95f7f5..11b8531e5c67 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -3159,6 +3159,22 @@ declare module '$app/paths' { * * */ export function resolve(...args: ResolveArgs): ResolvedPathname; + /** + * Match a path or URL to a route ID and extracts any parameters. + * + * @example + * ```js + * import { match } from '$app/paths'; + * + * const result = await match('/blog/hello-world'); + * // → { id: '/blog/[slug]', params: { slug: 'hello-world' } } + * + * + * */ + export function match(url: Pathname | URL | (string & {})): Promise<{ + id: RouteId; + params: Record; + } | null>; export {}; } From 2534e43bdbb1f453587ecc8ed449b56f00cd967a Mon Sep 17 00:00:00 2001 From: David Roizenman Date: Tue, 9 Dec 2025 00:05:41 -0800 Subject: [PATCH 5/5] extract route matching into find_route --- packages/kit/src/runtime/app/paths/server.js | 21 +++---- .../src/runtime/server/page/server_routing.js | 24 ++------ packages/kit/src/runtime/server/respond.js | 20 +++---- packages/kit/src/utils/routing.js | 25 +++++++++ packages/kit/src/utils/routing.spec.js | 55 ++++++++++++++++++- 5 files changed, 98 insertions(+), 47 deletions(-) diff --git a/packages/kit/src/runtime/app/paths/server.js b/packages/kit/src/runtime/app/paths/server.js index 58f887802947..dba1675e876d 100644 --- a/packages/kit/src/runtime/app/paths/server.js +++ b/packages/kit/src/runtime/app/paths/server.js @@ -1,6 +1,6 @@ import { base, assets, relative, initial_base } from './internal/server.js'; -import { resolve_route, exec } from '../../../utils/routing.js'; -import { decode_params, decode_pathname } from '../../../utils/url.js'; +import { resolve_route, find_route } from '../../../utils/routing.js'; +import { decode_pathname } from '../../../utils/url.js'; import { try_get_request_store } from '@sveltejs/kit/internal/server'; import { manifest } from '__sveltekit/server'; import { get_hooks } from '__SERVER__/internal.js'; @@ -55,18 +55,13 @@ export async function match(url) { } const matchers = await manifest._.matchers(); + const result = find_route(resolved_path, manifest._.routes, matchers); - for (const route of manifest._.routes) { - const match = route.pattern.exec(resolved_path); - if (!match) continue; - - const matched = exec(match, route.params, matchers); - if (matched) { - return { - id: /** @type {import('$app/types').RouteId} */ (route.id), - params: decode_params(matched) - }; - } + if (result) { + return { + id: /** @type {import('$app/types').RouteId} */ (result.route.id), + params: result.params + }; } return null; diff --git a/packages/kit/src/runtime/server/page/server_routing.js b/packages/kit/src/runtime/server/page/server_routing.js index 86496e04bb8c..c01d1ca808b4 100644 --- a/packages/kit/src/runtime/server/page/server_routing.js +++ b/packages/kit/src/runtime/server/page/server_routing.js @@ -1,8 +1,7 @@ import { base, assets, relative } from '$app/paths/internal/server'; import { text } from '@sveltejs/kit'; import { s } from '../../../utils/misc.js'; -import { exec } from '../../../utils/routing.js'; -import { decode_params } from '../../../utils/url.js'; +import { find_route } from '../../../utils/routing.js'; import { get_relative_path } from '../../utils.js'; /** @@ -69,26 +68,11 @@ export async function resolve_route(resolved_path, url, manifest) { return text('Server-side route resolution disabled', { status: 400 }); } - /** @type {import('types').SSRClientRoute | null} */ - let route = null; - /** @type {Record} */ - let params = {}; - const matchers = await manifest._.matchers(); + const result = find_route(resolved_path, manifest._.client.routes, matchers); - for (const candidate of manifest._.client.routes) { - const match = candidate.pattern.exec(resolved_path); - if (!match) continue; - - const matched = exec(match, candidate.params, matchers); - if (matched) { - route = candidate; - params = decode_params(matched); - break; - } - } - - return create_server_routing_response(route, params, url, manifest).response; + return create_server_routing_response(result?.route ?? null, result?.params ?? {}, url, manifest) + .response; } /** diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index a4f126621497..b22101cc5ec8 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -15,8 +15,8 @@ import { method_not_allowed, redirect_response } from './utils.js'; -import { decode_pathname, decode_params, disable_search, normalize_path } from '../../utils/url.js'; -import { exec } from '../../utils/routing.js'; +import { decode_pathname, disable_search, normalize_path } from '../../utils/url.js'; +import { find_route } from '../../utils/routing.js'; import { redirect_json_response, render_data } from './data/index.js'; import { add_cookies_to_headers, get_cookies } from './cookie.js'; import { create_fetch } from './fetch.js'; @@ -303,18 +303,12 @@ export async function internal_respond(request, options, manifest, state) { if (!state.prerendering?.fallback) { // TODO this could theoretically break — should probably be inside a try-catch const matchers = await manifest._.matchers(); + const result = find_route(resolved_path, manifest._.routes, matchers); - for (const candidate of manifest._.routes) { - const match = candidate.pattern.exec(resolved_path); - if (!match) continue; - - const matched = exec(match, candidate.params, matchers); - if (matched) { - route = candidate; - event.route = { id: route.id }; - event.params = decode_params(matched); - break; - } + if (result) { + route = result.route; + event.route = { id: route.id }; + event.params = result.params; } } diff --git a/packages/kit/src/utils/routing.js b/packages/kit/src/utils/routing.js index f36988f370b8..433fbc4bda30 100644 --- a/packages/kit/src/utils/routing.js +++ b/packages/kit/src/utils/routing.js @@ -1,4 +1,5 @@ import { BROWSER } from 'esm-env'; +import { decode_params } from './url.js'; const param_pattern = /^(\[)?(\.\.\.)?(\w+)(?:=(\w+))?(\])?$/; @@ -276,3 +277,27 @@ export function resolve_route(id, params) { export function has_server_load(node) { return node.server?.load !== undefined || node.server?.trailingSlash !== undefined; } + +/** + * Find the first route that matches the given path + * @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} + */ +export function find_route(path, routes, matchers) { + for (const route of routes) { + const match = route.pattern.exec(path); + if (!match) continue; + + const matched = exec(match, route.params, matchers); + if (matched) { + return { + route, + params: decode_params(matched) + }; + } + } + return null; +} diff --git a/packages/kit/src/utils/routing.spec.js b/packages/kit/src/utils/routing.spec.js index 75f5487eb2fa..773f74184e72 100644 --- a/packages/kit/src/utils/routing.spec.js +++ b/packages/kit/src/utils/routing.spec.js @@ -1,5 +1,5 @@ import { assert, expect, test, describe } from 'vitest'; -import { exec, parse_route_id, resolve_route } from './routing.js'; +import { exec, parse_route_id, resolve_route, find_route } from './routing.js'; describe('parse_route_id', () => { const tests = { @@ -359,3 +359,56 @@ describe('resolve_route', () => { ); }); }); + +describe('find_route', () => { + /** @param {string} id */ + function create_route(id) { + const { pattern, params } = parse_route_id(id); + return { id, pattern, params }; + } + + test('finds matching route', () => { + const routes = [create_route('/blog'), create_route('/blog/[slug]'), create_route('/about')]; + + const result = find_route('/blog/hello-world', routes, {}); + assert.equal(result?.route.id, '/blog/[slug]'); + assert.deepEqual(result?.params, { slug: 'hello-world' }); + }); + + test('returns first matching route', () => { + const routes = [create_route('/blog/[slug]'), create_route('/blog/[...rest]')]; + + const result = find_route('/blog/hello', routes, {}); + assert.equal(result?.route.id, '/blog/[slug]'); + }); + + test('returns null for no match', () => { + const routes = [create_route('/blog'), create_route('/about')]; + + const result = find_route('/contact', routes, {}); + assert.equal(result, null); + }); + + test('respects matchers', () => { + const routes = [create_route('/blog/[slug=word]'), create_route('/blog/[slug]')]; + /** @type {Record} */ + const matchers = { + word: (param) => /^\w+$/.test(param) + }; + + // "hello" matches the word matcher + const result1 = find_route('/blog/hello', routes, matchers); + assert.equal(result1?.route.id, '/blog/[slug=word]'); + + // "hello-world" doesn't match word matcher, falls through to [slug] + const result2 = find_route('/blog/hello-world', routes, matchers); + assert.equal(result2?.route.id, '/blog/[slug]'); + }); + + test('decodes params', () => { + const routes = [create_route('/blog/[slug]')]; + + const result = find_route('/blog/hello%20world', routes, {}); + assert.equal(result?.params.slug, 'hello world'); + }); +});