Skip to content
Open
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/new-bugs-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: `match` function to map a path back to a route id and params
32 changes: 32 additions & 0 deletions packages/kit/src/runtime/app/paths/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, string> } | 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 };
2 changes: 1 addition & 1 deletion packages/kit/src/runtime/app/paths/public.d.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
42 changes: 41 additions & 1 deletion packages/kit/src/runtime/app/paths/server.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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 };
10 changes: 10 additions & 0 deletions packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>}
*/
export let remote_responses = {};

export { get_navigation_intent };

/** @type {Array<((url: URL) => boolean)>} */
const invalidated = [];

Expand Down
24 changes: 4 additions & 20 deletions packages/kit/src/runtime/server/page/server_routing.js
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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<string, string>} */
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;
}

/**
Expand Down
20 changes: 7 additions & 13 deletions packages/kit/src/runtime/server/respond.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
}

Expand Down
25 changes: 25 additions & 0 deletions packages/kit/src/utils/routing.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BROWSER } from 'esm-env';
import { decode_params } from './url.js';

const param_pattern = /^(\[)?(\.\.\.)?(\w+)(?:=(\w+))?(\])?$/;

Expand Down Expand Up @@ -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<string, import('@sveltejs/kit').ParamMatcher>} matchers
* @returns {{ route: Route, params: Record<string, string> } | 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;
}
55 changes: 54 additions & 1 deletion packages/kit/src/utils/routing.spec.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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<string, import('@sveltejs/kit').ParamMatcher>} */
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');
});
});
12 changes: 12 additions & 0 deletions packages/kit/test/apps/basics/src/routes/match/+page.server.js
Original file line number Diff line number Diff line change
@@ -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
};
}
34 changes: 34 additions & 0 deletions packages/kit/test/apps/basics/src/routes/match/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script>
import { match } from '$app/paths';
import { onMount } from 'svelte';
import { testPaths } from './const';

let { data } = $props();

const clientResults = $state([]);

onMount(async () => {
for (const path of testPaths) {
const result = await match(path);
clientResults.push({ path, result });
}
});
</script>

<h1>Match Test</h1>

<div id="server-results">
{#each data.serverResults as { path, result }}
<div class="result" data-path={path}>
{JSON.stringify(result)}
</div>
{/each}
</div>

<div id="client-results">
{#each clientResults as { path, result }}
<div class="result" data-path={path}>
{JSON.stringify(result)}
</div>
{/each}
</div>
6 changes: 6 additions & 0 deletions packages/kit/test/apps/basics/src/routes/match/const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const testPaths = [
'/match/load/foo',
'/match/slug/test-slug',
'/match/not-a-real-route-that-exists',
'/reroute/basic/a'
];
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
bar
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
slug
24 changes: 24 additions & 0 deletions packages/kit/test/apps/basics/test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading