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..ceaf610a6530 100644 --- a/packages/kit/src/runtime/app/paths/client.js +++ b/packages/kit/src/runtime/app/paths/client.js @@ -2,6 +2,7 @@ /** @import { ResolveArgs } from './types.js' */ import { base, assets, hash_routing } from './internal/client.js'; import { resolve_route } from '../../../utils/routing.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. @@ -58,4 +59,35 @@ export function resolve(...args) { ); } +/** + * 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' } } + * + * + * @param {Pathname | URL | (string & {})} url + * @returns {Promise<{ id: RouteId, params: Record } | null>} + */ +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) { + return { + id: /** @type {RouteId} */ (intent.route.id), + params: intent.params + }; + } + + return null; +} + export { base, assets, resolve as resolveRoute }; 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/src/runtime/app/paths/server.js b/packages/kit/src/runtime/app/paths/server.js index abee8466006e..dba1675e876d 100644 --- a/packages/kit/src/runtime/app/paths/server.js +++ b/packages/kit/src/runtime/app/paths/server.js @@ -1,6 +1,9 @@ import { base, assets, relative, initial_base } from './internal/server.js'; -import { resolve_route } from '../../../utils/routing.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'; /** @type {import('./client.js').asset} */ export function asset(file) { @@ -27,4 +30,41 @@ export function resolve(id, params) { return base + resolved; } +/** @type {import('./client.js').match} */ +export async function match(url) { + const store = try_get_request_store(); + + 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: new URL(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(); + const result = find_route(resolved_path, manifest._.routes, matchers); + + if (result) { + return { + id: /** @type {import('$app/types').RouteId} */ (result.route.id), + params: result.params + }; + } + + 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..feacb4e46860 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -189,12 +189,22 @@ 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} */ export let remote_responses = {}; +export { get_navigation_intent }; + /** @type {Array<((url: URL) => boolean)>} */ const invalidated = []; 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'); + }); +}); 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..9ec65ea8cc4d --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/match/const.ts @@ -0,0 +1,6 @@ +export const testPaths = [ + '/match/load/foo', + '/match/slug/test-slug', + '/match/not-a-real-route-that-exists', + '/reroute/basic/a' +]; 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..169e0b279cfe 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -766,6 +766,30 @@ 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 }, + { path: '/reroute/basic/a', expected: { id: '/reroute/basic/b', params: {} } } + ]; + + 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 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 {}; }