Skip to content

Commit

Permalink
feat: add resolvePath export (#9949)
Browse files Browse the repository at this point in the history
* feat: add `resolvePath` export for combining route IDs and params into a relative path

* changeset

* Update packages/kit/src/exports/index.js

Co-authored-by: Rich Harris <[email protected]>

* chore: Move things

* add resolvePath to types/index.d.ts

---------

Co-authored-by: Rich Harris <[email protected]>
Co-authored-by: Rich Harris <[email protected]>
  • Loading branch information
3 people authored May 18, 2023
1 parent 35eafa9 commit 195e9ac
Show file tree
Hide file tree
Showing 7 changed files with 123 additions and 93 deletions.
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) {
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;

1 comment on commit 195e9ac

@brittianwarner
Copy link

Choose a reason for hiding this comment

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

@Rich-Harris Is there any way to include the host in entries? I'm trying to figure out the best way to prerender pages in my "platforms" style application. Long story short, I use the host in hooks.server.js to determine which components to render on each page. I saw this commit and felt like it was a good place to bring this up, though it should probably live in a feature request unless you have a better idea of how to handle this. Thanks!

Please sign in to comment.