From 195e9ac2233776ecf1aff7dadbadd7226f68e8dd Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" Date: Thu, 18 May 2023 08:14:59 -0600 Subject: [PATCH] feat: add `resolvePath` export (#9949) * 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 * chore: Move things * add resolvePath to types/index.d.ts --------- Co-authored-by: Rich Harris Co-authored-by: Rich Harris --- .changeset/unlucky-guests-appear.md | 5 ++ packages/kit/src/core/postbuild/analyse.js | 4 +- packages/kit/src/exports/index.js | 46 ++++++++++++++++++ packages/kit/src/exports/index.spec.js | 54 ++++++++++++++++++++++ packages/kit/src/utils/routing.js | 38 --------------- packages/kit/src/utils/routing.spec.js | 54 +--------------------- packages/kit/types/index.d.ts | 15 ++++++ 7 files changed, 123 insertions(+), 93 deletions(-) create mode 100644 .changeset/unlucky-guests-appear.md create mode 100644 packages/kit/src/exports/index.spec.js diff --git a/.changeset/unlucky-guests-appear.md b/.changeset/unlucky-guests-appear.md new file mode 100644 index 000000000000..8dcb7fba5931 --- /dev/null +++ b/.changeset/unlucky-guests-appear.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: add `resolvePath` export for building relative paths from route IDs and parameters diff --git a/packages/kit/src/core/postbuild/analyse.js b/packages/kit/src/core/postbuild/analyse.js index 01ad617f5249..1b82177fba66 100644 --- a/packages/kit/src/core/postbuild/analyse.js +++ b/packages/kit/src/core/postbuild/analyse.js @@ -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); @@ -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)) }); } diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js index b14a6e9613b9..3050bdb91d30 100644 --- a/packages/kit/src/exports/index.js +++ b/packages/kit/src/exports/index.js @@ -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 @@ -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} 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('/') + ); +} diff --git a/packages/kit/src/exports/index.spec.js b/packages/kit/src/exports/index.spec.js new file mode 100644 index 000000000000..a7ea3053b2a9 --- /dev/null +++ b/packages/kit/src/exports/index.spec.js @@ -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" + ); +}); diff --git a/packages/kit/src/utils/routing.js b/packages/kit/src/utils/routing.js index fe7f77d6cc2f..9803ac7b6acd 100644 --- a/packages/kit/src/utils/routing.js +++ b/packages/kit/src/utils/routing.js @@ -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} 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+)?\]\]/; /** diff --git a/packages/kit/src/utils/routing.spec.js b/packages/kit/src/utils/routing.spec.js index 027dd37c8ce5..ec791fc459ba 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 } from 'vitest'; -import { exec, parse_route_id, resolve_entry } from './routing.js'; +import { exec, parse_route_id } from './routing.js'; const tests = { '/': { @@ -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" - ); -}); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 10c5c580d0e4..7ef60082de58 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1310,3 +1310,18 @@ export interface Snapshot { 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;