Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add resolvePath export #9949

Merged
merged 7 commits into from
May 18, 2023
5 changes: 5 additions & 0 deletions .changeset/unlucky-guests-appear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: add `resolvePath` export for building relative paths from route IDs and parameters
4 changes: 2 additions & 2 deletions packages/kit/src/core/postbuild/analyse.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { load_config } from '../config/index.js';
import { forked } from '../../utils/fork.js';
import { should_polyfill } from '../../utils/platform.js';
import { installPolyfills } from '../../exports/node/polyfills.js';
import { resolve_entry } from '../../utils/routing.js';
import { resolvePath } from '../../exports/index.js';

export default forked(import.meta.url, analyse);

Expand Down Expand Up @@ -145,7 +145,7 @@ async function analyse({ manifest_path, env }) {
},
prerender,
entries:
entries && (await entries()).map((entry_object) => resolve_entry(route.id, entry_object))
entries && (await entries()).map((entry_object) => resolvePath(route.id, entry_object))
});
}

Expand Down
46 changes: 46 additions & 0 deletions packages/kit/src/exports/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { HttpError, Redirect, ActionFailure } from '../runtime/control.js';
import { BROWSER, DEV } from 'esm-env';
import { get_route_segments } from '../utils/routing.js';

// For some reason we need to type the params as well here,
// JSdoc doesn't seem to like @type with function overloads
Expand Down Expand Up @@ -72,3 +73,48 @@ export function text(body, init) {
export function fail(status, data) {
return new ActionFailure(status, data);
}

const basic_param_pattern = /\[(\[)?(?:\.\.\.)?(\w+?)(?:=(\w+))?\]\]?/g;

/**
* Populate a route ID with params to resolve a pathname.
* @example
* ```js
* resolvePath(
* `/blog/[slug]/[...somethingElse]`,
* {
* slug: 'hello-world',
* somethingElse: 'something/else'
* }
* ); // `/blog/hello-world/something/else`
* ```
* @param {string} id
* @param {Record<string, string | undefined>} params
* @returns {string}
*/
export function resolvePath(id, params) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we call it route id in other places I think it would make sense to reuse that nomenclature here

Suggested change
export function resolvePath(id, params) {
export function resolveRoute(id, params) {

Alternatively even resolveRouteId.

Thoughts?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think resolvePath is correct — we're resolving a path from a route ID + some parameters.

That said, resolvePath sounds like path.resolve, i.e. you might expect to see resolvePath(base, relative). I don't know if there's a good name for this that doesn't sound clunky and un-SvelteKit-like.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that was my reasoning as well, Rich -- I think the current name is a good mix of what it does and brevity, and the example is pretty darn clear

const segments = get_route_segments(id);
return (
'/' +
segments
.map((segment) =>
segment.replace(basic_param_pattern, (_, optional, name) => {
const param_value = params[name];

// This is nested so TS correctly narrows the type
if (!param_value) {
if (optional) return '';
throw new Error(`Missing parameter '${name}' in route ${id}`);
}

if (param_value.startsWith('/') || param_value.endsWith('/'))
throw new Error(
`Parameter '${name}' in route ${id} cannot start or end with a slash -- this would cause an invalid route like foo//bar`
);
return param_value;
})
)
.filter(Boolean)
.join('/')
);
}
54 changes: 54 additions & 0 deletions packages/kit/src/exports/index.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { assert, expect, test } from 'vitest';
import { resolvePath } from './index.js';

const from_params_tests = [
{
route: '/blog/[one]/[two]',
params: { one: 'one', two: 'two' },
expected: '/blog/one/two'
},
{
route: '/blog/[one=matcher]/[...two]',
params: { one: 'one', two: 'two/three' },
expected: '/blog/one/two/three'
},
{
route: '/blog/[one=matcher]/[[two]]',
params: { one: 'one' },
expected: '/blog/one'
},
{
route: '/blog/[one]/[two]-and-[three]',
params: { one: 'one', two: '2', three: '3' },
expected: '/blog/one/2-and-3'
},
{
route: '/blog/[one]/[...two]-not-three',
params: { one: 'one', two: 'two/2' },
expected: '/blog/one/two/2-not-three'
}
];

for (const { route, params, expected } of from_params_tests) {
test(`resolvePath generates correct path for ${route}`, () => {
const result = resolvePath(route, params);
assert.equal(result, expected);
});
}

test('resolvePath errors on missing params for required param', () => {
expect(() => resolvePath('/blog/[one]/[two]', { one: 'one' })).toThrow(
"Missing parameter 'two' in route /blog/[one]/[two]"
);
});

test('resolvePath errors on params values starting or ending with slashes', () => {
assert.throws(
() => resolvePath('/blog/[one]/[two]', { one: 'one', two: '/two' }),
"Parameter 'two' in route /blog/[one]/[two] cannot start or end with a slash -- this would cause an invalid route like foo//bar"
);
assert.throws(
() => resolvePath('/blog/[one]/[two]', { one: 'one', two: 'two/' }),
"Parameter 'two' in route /blog/[one]/[two] cannot start or end with a slash -- this would cause an invalid route like foo//bar"
);
});
38 changes: 0 additions & 38 deletions packages/kit/src/utils/routing.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,44 +96,6 @@ export function parse_route_id(id) {
return { pattern, params };
}

const basic_param_pattern = /\[(\[)?(?:\.\.\.)?(\w+?)(?:=(\w+))?\]\]?/g;

/**
* Parses a route ID, then resolves it to a path by replacing parameters with actual values from `entry`.
* @param {string} id The route id
* @param {Record<string, string | undefined>} entry The entry meant to populate the route. For example, if the route is `/blog/[slug]`, the entry would be `{ slug: 'hello-world' }`
* @example
* ```js
* resolve_entry(`/blog/[slug]/[...somethingElse]`, { slug: 'hello-world', somethingElse: 'something/else' }); // `/blog/hello-world/something/else`
* ```
*/
export function resolve_entry(id, entry) {
const segments = get_route_segments(id);
return (
'/' +
segments
.map((segment) =>
segment.replace(basic_param_pattern, (_, optional, name) => {
const param_value = entry[name];

// This is nested so TS correctly narrows the type
if (!param_value) {
if (optional) return '';
throw new Error(`Missing parameter '${name}' in route ${id}`);
}

if (param_value.startsWith('/') || param_value.endsWith('/'))
throw new Error(
`Parameter '${name}' in route ${id} cannot start or end with a slash -- this would cause an invalid route like foo//bar`
);
return param_value;
})
)
.filter(Boolean)
.join('/')
);
}

const optional_param_regex = /\/\[\[\w+?(?:=\w+)?\]\]/;

/**
Expand Down
54 changes: 1 addition & 53 deletions 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 } from 'vitest';
import { exec, parse_route_id, resolve_entry } from './routing.js';
import { exec, parse_route_id } from './routing.js';

const tests = {
'/': {
Expand Down Expand Up @@ -221,55 +221,3 @@ test('parse_route_id errors on bad param name', () => {
assert.throws(() => parse_route_id('abc/[b-c]'), /Invalid param: b-c/);
assert.throws(() => parse_route_id('abc/[bc=d-e]'), /Invalid param: bc=d-e/);
});

const from_entry_tests = [
{
route: '/blog/[one]/[two]',
entry: { one: 'one', two: 'two' },
expected: '/blog/one/two'
},
{
route: '/blog/[one=matcher]/[...two]',
entry: { one: 'one', two: 'two/three' },
expected: '/blog/one/two/three'
},
{
route: '/blog/[one=matcher]/[[two]]',
entry: { one: 'one' },
expected: '/blog/one'
},
{
route: '/blog/[one]/[two]-and-[three]',
entry: { one: 'one', two: '2', three: '3' },
expected: '/blog/one/2-and-3'
},
{
route: '/blog/[one]/[...two]-not-three',
entry: { one: 'one', two: 'two/2' },
expected: '/blog/one/two/2-not-three'
}
];

for (const { route, entry, expected } of from_entry_tests) {
test(`resolve_entry generates correct path for ${route}`, () => {
const result = resolve_entry(route, entry);
assert.equal(result, expected);
});
}

test('resolve_entry errors on missing entry for required param', () => {
expect(() => resolve_entry('/blog/[one]/[two]', { one: 'one' })).toThrow(
"Missing parameter 'two' in route /blog/[one]/[two]"
);
});

test('resolve_entry errors on entry values starting or ending with slashes', () => {
assert.throws(
() => resolve_entry('/blog/[one]/[two]', { one: 'one', two: '/two' }),
"Parameter 'two' in route /blog/[one]/[two] cannot start or end with a slash -- this would cause an invalid route like foo//bar"
);
assert.throws(
() => resolve_entry('/blog/[one]/[two]', { one: 'one', two: 'two/' }),
"Parameter 'two' in route /blog/[one]/[two] cannot start or end with a slash -- this would cause an invalid route like foo//bar"
);
});
15 changes: 15 additions & 0 deletions packages/kit/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1310,3 +1310,18 @@ export interface Snapshot<T = any> {
capture: () => T;
restore: (snapshot: T) => void;
}

/**
* Populate a route ID with params to resolve a pathname.
* @example
* ```js
* resolvePath(
* `/blog/[slug]/[...somethingElse]`,
* {
* slug: 'hello-world',
* somethingElse: 'something/else'
* }
* ); // `/blog/hello-world/something/else`
* ```
*/
export function resolvePath(id: string, params: Record<string, string | undefined>): string;