diff --git a/src/main/PathnameAdapter.ts b/src/main/PathnameAdapter.ts index 9c31edd..938cff3 100644 --- a/src/main/PathnameAdapter.ts +++ b/src/main/PathnameAdapter.ts @@ -2,12 +2,12 @@ import { Dict } from './types'; export interface PathnameMatch { /** - * A pathname that was matched. + * A pathname that was matched, beginning with a "/". */ pathname: string; /** - * A pathname that should be matched by a nested route. + * A pathname that should be matched by a nested route, beginning with a "/". */ nestedPathname: string; @@ -26,105 +26,38 @@ export class PathnameAdapter { */ readonly paramNames: ReadonlySet; - /** - * An array with an odd number of strings, where even items are param names. - */ - protected _template; - - /** - * The {@link !RegExp} that matches the template at the start of the pathname. - */ + protected _parts; + protected _flags; protected _regExp; /** * Creates a new {@link PathnameAdapter} instance. * - * Pattern can include params that conform `\$[_A-Za-z][_A-Za-z0-9]+`. For example: "$userId". - * - * By default, params match a non-empty pathname substring not-including "/". Follow a param with a "*" to make param - * match any character. For example: "$slug*". - * - * By default, params expect at least one character to be matched. To make param optional (so it can match zero - * characters) follow it by a "?". For example: "$userId?" or "$slug*?". - * - * To use "$" as a character in a pathname pattern, replace it with an {@link !encodeURIComponent encoded} - * representation: "%24". - * * @param pathname A pathname pattern. + * @param isCaseSensitive If `true` then pathname is matched in a case-sensitive manner. */ - constructor(pathname: string) { - const template = []; + constructor(pathname: string, isCaseSensitive = false) { + const template = parsePathname(pathname); const paramNames = new Set(); - let i = 0; - let j = 0; - let charCode; - - if (pathname.charAt(0) === '/') { - pathname = pathname.substring(1); - } - if (pathname.endsWith('/')) { - pathname = pathname.slice(0, -1); - } - - let segment; - let pattern = ''; - - while ((i = pathname.indexOf('$', i)) !== -1) { - segment = pathname.substring(j, i); - pattern += escapeRegExp(segment); - template.push(segment); - - j = ++i; - - while ( - ((charCode = pathname.charCodeAt(i)), - (i > j && charCode >= 48 && charCode <= 57) /* 0-9 */ || - (charCode >= 65 && charCode <= 90) /* A-Z */ || - (charCode >= 97 && charCode <= 122) /* a-z */ || - charCode === 95) /* _ */ - ) { - ++i; + for (let i = 0; i < template.parts.length; ++i) { + if ((template.flags[i] & FLAG_PARAM) === FLAG_PARAM) { + paramNames.add(template.parts[i]); } - - if (i === j) { - throw new Error('Pathname param must have a name: ' + i); - } - - segment = pathname.substring(j, i); - - if (charCode === 42 /* * */) { - pattern += (charCode = pathname.charCodeAt(++i)) === 63 /* ? */ ? (++i, '(.*)') : '(.+)'; - } else { - pattern += charCode === 63 /* ? */ ? (++i, '([^\\\\/]*)') : '([^\\\\/]+)'; - } - - paramNames.add(segment); - template.push(segment); - - j = i; - } - - if (j === 0) { - template.push(pathname); - pattern = escapeRegExp(pathname); - } else { - segment = pathname.substring(j); - pattern += escapeRegExp(segment); - template.push(segment); } this.paramNames = paramNames; - this._template = template; - this._regExp = pattern === '' ? /^/ : new RegExp('^\\/?' + pattern + '(?=\\/|$)', 'i'); + this._parts = template.parts; + this._flags = template.flags; + this._regExp = createPathnameRegExp(template, isCaseSensitive); } /** - * Matches a pathname against a template and returns a match if pathname conforms. + * Matches a pathname against a pathname pattern. */ match(pathname: string): PathnameMatch | null { - const { _template } = this; + const { _parts, _flags } = this; const match = this._regExp.exec(pathname); @@ -134,50 +67,216 @@ export class PathnameAdapter { let params: Dict | undefined; - if (_template.length !== 1) { + if (_parts.length !== 1) { params = {}; - for (let i = 1; i < _template.length; i += 2) { - params[_template[i]] = decodeURIComponent(match[(i + 1) >> 1]); + for (let i = 0, j = 1, value; i < _parts.length; ++i) { + if ((_flags[i] & FLAG_PARAM) !== FLAG_PARAM) { + continue; + } + value = match[j++]; + params[_parts[i]] = value && decodeURIComponent(value); } } const m = match[0]; + const nestedPathname = pathname.substring(m.length); return { - pathname: m, - nestedPathname: pathname.length > m.length + 1 ? pathname.substring(m.length) : '', + pathname: m === '' ? '/' : m, + nestedPathname: + nestedPathname.length === 0 || nestedPathname.charCodeAt(0) !== 47 ? '/' + nestedPathname : nestedPathname, params, }; } /** - * Creates a pathname from a template by substituting params. - * - * The returned pathname never contains leading or trailing "/". + * Creates a pathname from a template by substituting params, beginning with a "/". */ toPathname(params: Dict | undefined): string { - const { _template } = this; + const { _parts, _flags } = this; + + let pathname = ''; + + for (let i = 0, part, flag, value; i < _parts.length; i++) { + part = _parts[i]; + flag = _flags[i]; - if (_template.length !== 1 && params === undefined) { - throw new Error('Pathname params are required: ' + Array.from(this.paramNames).join(', ')); + if ((flag & FLAG_PARAM) !== FLAG_PARAM) { + pathname += '/' + part; + continue; + } + if ( + (params === undefined || (value = params[part]) === undefined || value === null || value === '') && + (flag & FLAG_OPTIONAL) === FLAG_OPTIONAL + ) { + continue; + } + if (typeof value !== 'string') { + throw new Error('Param must be a string: ' + part); + } + + pathname += + '/' + ((flag & FLAG_WILDCARD) === FLAG_WILDCARD ? encodePathname(value) : encodePathnameComponent(value)); } - let pathname = _template[0]; + return pathname === '' ? '/' : pathname; + } +} - for (let i = 1, paramValue; i < _template.length; i += 2) { - paramValue = params![_template[i]]; +const FLAG_PARAM = 1; +const FLAG_WILDCARD = 1 << 1; +const FLAG_OPTIONAL = 1 << 2; - if (typeof paramValue !== 'string') { - throw new Error('Pathname param must be a string: ' + _template[i]); - } - pathname += encodeURIComponent(paramValue) + _template[i + 1]; +const STAGE_SEPARATOR = 0; +const STAGE_SEGMENT = 1; +const STAGE_PARAM = 2; +const STAGE_WILDCARD = 3; +const STAGE_OPTIONAL = 4; + +/** + * A result of a pathname pattern parsing. + */ +interface Template { + /** + * A non-empty array of segments and param names extracted from a pathname pattern. + */ + parts: string[]; + + /** + * An array of bitmasks that holds {@link parts} metadata. + */ + flags: number[]; +} + +/** + * Parses pathname pattern as a template. + */ +export function parsePathname(pathname: string): Template { + const parts = []; + const flags = []; + + let stage = STAGE_SEPARATOR; + let segmentIndex = 0; + + for (let i = 0, charCode, paramIndex; i < pathname.length; ) { + switch (pathname.charCodeAt(i)) { + case 58 /* : */: + if (stage !== STAGE_SEPARATOR) { + throw new SyntaxError('Unexpected param at ' + i); + } + + paramIndex = ++i; + + while ( + ((charCode = pathname.charCodeAt(i)), + (i > paramIndex && charCode >= 48 && charCode <= 57) /* 0-9 */ || + (charCode >= 65 && charCode <= 90) /* A-Z */ || + (charCode >= 97 && charCode <= 122) /* a-z */ || + charCode === 95 /* _ */ || + charCode === 36) /* $ */ + ) { + ++i; + } + + if (paramIndex === i) { + throw new SyntaxError('Param must have a name at ' + i); + } + + parts.push(pathname.substring(paramIndex, i)); + flags.push(FLAG_PARAM); + stage = STAGE_PARAM; + break; + + case 42 /* * */: + if (stage !== STAGE_PARAM) { + throw new SyntaxError('Unexpected wildcard flag at ' + i); + } + flags[flags.length - 1] |= FLAG_WILDCARD; + stage = STAGE_WILDCARD; + ++i; + break; + + case 63 /* ? */: + if (stage === STAGE_SEPARATOR || stage === STAGE_SEGMENT) { + parts.push(pathname.substring(segmentIndex, i)); + flags.push(FLAG_OPTIONAL); + stage = STAGE_OPTIONAL; + ++i; + break; + } + if (stage === STAGE_PARAM || stage === STAGE_WILDCARD) { + flags[flags.length - 1] |= FLAG_OPTIONAL; + stage = STAGE_OPTIONAL; + ++i; + break; + } + throw new SyntaxError('Unexpected optional flag at ' + i); + + case 47 /* / */: + if (i !== 0 && (stage === STAGE_SEPARATOR || stage === STAGE_SEGMENT)) { + parts.push(pathname.substring(segmentIndex, i)); + flags.push(0); + } + stage = STAGE_SEPARATOR; + segmentIndex = ++i; + break; + + default: + if (stage !== STAGE_SEPARATOR && stage !== STAGE_SEGMENT) { + throw new SyntaxError('Unexpected character at ' + i); + } + stage = STAGE_SEGMENT; + ++i; + break; + } + } + + if (stage === STAGE_SEPARATOR || stage === STAGE_SEGMENT) { + parts.push(pathname.substring(segmentIndex)); + flags.push(0); + } + + return { parts, flags }; +} + +/** + * Creates a {@link !RegExp} that matches a pathname template. + */ +export function createPathnameRegExp(template: Template, isCaseSensitive = false): RegExp { + const { parts, flags } = template; + + let pattern = '^'; + + for (let i = 0, part, flag, segmentPattern; i < parts.length; ++i) { + part = parts[i]; + flag = flags[i]; + + if ((flag & FLAG_PARAM) !== FLAG_PARAM) { + segmentPattern = part.length === 0 ? '/' : '/' + escapeRegExp(part); + + pattern += (flag & FLAG_OPTIONAL) === FLAG_OPTIONAL ? '(?:' + segmentPattern + ')?' : segmentPattern; + continue; } - return pathname; + if ((flag & FLAG_WILDCARD) === FLAG_WILDCARD) { + pattern += (flag & FLAG_OPTIONAL) === FLAG_OPTIONAL ? '(?:/(.+))?' : '/(.+)'; + } else { + pattern += (flag & FLAG_OPTIONAL) === FLAG_OPTIONAL ? '(?:/([^/]+))?' : '/([^/]+)'; + } } + + return new RegExp(pattern.endsWith('/') ? pattern : pattern + '(?=/|$)', isCaseSensitive ? '' : 'i'); } function escapeRegExp(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + +function encodePathname(str: string): string { + return str.replace(/[:*?]/g, encodeURIComponent); +} + +function encodePathnameComponent(str: string): string { + return str.replace(/[:*?/]/g, encodeURIComponent); +} diff --git a/src/main/Route.ts b/src/main/Route.ts index 96d7915..b79c724 100644 --- a/src/main/Route.ts +++ b/src/main/Route.ts @@ -1,8 +1,8 @@ import { ReactNode } from 'react'; -import { memoizeNode } from './utils'; import { Outlet } from './Outlet'; import { PathnameAdapter } from './PathnameAdapter'; import { Dict, Location, LocationOptions, RouteContent, RouteOptions } from './types'; +import { memoizeNode } from './utils'; type Squash = { [K in keyof T]: T[K] } & {}; @@ -63,7 +63,7 @@ export class Route< this.parent = parent; - this._pathnameAdapter = new PathnameAdapter(options.pathname); + this._pathnameAdapter = new PathnameAdapter(options.pathname, options.isCaseSensitive); this._paramsAdapter = typeof paramsAdapter === 'function' ? { parse: paramsAdapter } : paramsAdapter; this._pendingNode = memoizeNode(options.pendingFallback); this._errorNode = memoizeNode(options.errorFallback); @@ -92,9 +92,9 @@ export class Route< } else { if (_paramsAdapter === undefined || _paramsAdapter.toSearchParams === undefined) { // Search params = params omit pathname params - for (const paramName in params) { - if (params.hasOwnProperty(paramName) && !_pathnameAdapter.paramNames.has(paramName)) { - searchParams[paramName] = params[paramName]; + for (const name in params) { + if (params.hasOwnProperty(name) && !_pathnameAdapter.paramNames.has(name)) { + searchParams[name] = params[name]; } } } else { @@ -109,7 +109,7 @@ export class Route< if (parent !== null) { const location = parent.getLocation(params, options); - location.pathname += location.pathname.endsWith('/') ? pathname : '/' + pathname; + location.pathname += location.pathname.endsWith('/') ? pathname.substring(1) : pathname; // Merge search params Object.assign(location.searchParams, searchParams); @@ -127,7 +127,7 @@ export class Route< : '#' + encodeURIComponent(hash); return { - pathname: '/' + pathname, + pathname, searchParams, hash, state: options?.state, diff --git a/src/main/index.ts b/src/main/index.ts index 0d47b11..62d7d81 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -22,6 +22,7 @@ export type { BrowserHistoryOptions } from './createBrowserHistory'; export type { MemoryHistoryOptions } from './createMemoryHistory'; export type { LinkProps } from './Link'; export type { OutletProps } from './Outlet'; +export type { RouterProps, NoContextRouterProps } from './Router'; export type { Dict, To, diff --git a/src/main/matchRoutes.ts b/src/main/matchRoutes.ts index cc87c8b..0fdb12f 100644 --- a/src/main/matchRoutes.ts +++ b/src/main/matchRoutes.ts @@ -28,7 +28,7 @@ export function matchRoutes(pathname: string, searchParams: Dict, routes: Route[ for (const route of routes) { const match = matchPathname(pathname, route, cache); - if (match === null || match.nestedPathname !== '') { + if (match === null || match.nestedPathname !== '/') { // No match or pathname cannot be consumed by a route continue; } diff --git a/src/main/types.ts b/src/main/types.ts index ef57492..c54dfa7 100644 --- a/src/main/types.ts +++ b/src/main/types.ts @@ -108,12 +108,39 @@ export interface ParamsAdapter { */ export interface RouteOptions { /** - * A URL pathname segment. Leading and trailing slashes are ignored during route matching. + * A URL pathname pattern. * - * @example "/foo/$bar" + * Pattern can include params that conform `:[A-Za-z$_][A-Za-z0-9$_]+`, for example `:teamId`. + * + * Params match the whole segment and cannot be partial: + * + * - 🚫`"/teams-:teamId"` + * - ✅`"/teams/:teamId"` + * - 🚫`"/:category--:productId"` + * - ✅`"/:productSlug"` + * + * By default, a param matches a non-empty pathname substring. To make a param optional (so it can match zero + * characters) follow it by a `?` flag. For example: `":userId?"`. + * + * You can make a static pathname segment optional as well: `"/project/task?/:taskId"`. + * + * By default, a param matches a pathname segment: all characters except a `/`. Follow a param with a `*` flag to make + * it match multiple segments. For example: `":slug*"`. Such params are called wildcard params. + * + * To make param both wildcard and optional, combine `*` and `?` flags: `":slug*?"` + * + * To use `:` as a character in a pathname pattern, replace it with an {@link !encodeURIComponent encoded} + * representation: `%3A`. */ pathname: string; + /** + * If `true` then {@link pathname} is matched in a case-sensitive manner. + * + * @default false + */ + isCaseSensitive?: boolean; + /** * A content rendered by a route. If `undefined` then route implicitly renders {@link Outlet}. */ diff --git a/src/test/PathnameAdapter.test.ts b/src/test/PathnameAdapter.test.ts index 1bce03e..b6e4a1f 100644 --- a/src/test/PathnameAdapter.test.ts +++ b/src/test/PathnameAdapter.test.ts @@ -1,129 +1,171 @@ -import { PathnameAdapter } from '../main/PathnameAdapter'; - -describe('PathnameAdapter', () => { - test('creates a template without params', () => { - expect(new PathnameAdapter('')['_template']).toEqual(['']); - expect(new PathnameAdapter('/')['_template']).toEqual(['']); - expect(new PathnameAdapter('aaa')['_template']).toEqual(['aaa']); - expect(new PathnameAdapter('aaa/')['_template']).toEqual(['aaa']); - expect(new PathnameAdapter('/aaa')['_template']).toEqual(['aaa']); - expect(new PathnameAdapter('/aaa/')['_template']).toEqual(['aaa']); - expect(new PathnameAdapter('/aaa/bbb')['_template']).toEqual(['aaa/bbb']); +import { createPathnameRegExp, parsePathname, PathnameAdapter } from '../main/PathnameAdapter'; + +describe('parsePathname', () => { + const FLAG_PARAM = 1; + const FLAG_WILDCARD = 1 << 1; + const FLAG_OPTIONAL = 1 << 2; + + test('parses pathname as a template', () => { + expect(parsePathname('')).toEqual({ parts: [''], flags: [0] }); + expect(parsePathname('/')).toEqual({ parts: [''], flags: [0] }); + expect(parsePathname('//')).toEqual({ parts: ['', ''], flags: [0, 0] }); + expect(parsePathname('///')).toEqual({ parts: ['', '', ''], flags: [0, 0, 0] }); + expect(parsePathname('aaa')).toEqual({ parts: ['aaa'], flags: [0] }); + expect(parsePathname('/aaa')).toEqual({ parts: ['aaa'], flags: [0] }); + expect(parsePathname('/aaa/bbb')).toEqual({ parts: ['aaa', 'bbb'], flags: [0, 0] }); + expect(parsePathname('/aaa?')).toEqual({ parts: ['aaa'], flags: [FLAG_OPTIONAL] }); + expect(parsePathname('/aaa?/')).toEqual({ parts: ['aaa', ''], flags: [FLAG_OPTIONAL, 0] }); + expect(parsePathname('/aaa?/bbb?')).toEqual({ parts: ['aaa', 'bbb'], flags: [FLAG_OPTIONAL, FLAG_OPTIONAL] }); + expect(parsePathname(':xxx')).toEqual({ parts: ['xxx'], flags: [FLAG_PARAM] }); + expect(parsePathname('/:xxx')).toEqual({ parts: ['xxx'], flags: [FLAG_PARAM] }); + expect(parsePathname('/:xxx')).toEqual({ parts: ['xxx'], flags: [FLAG_PARAM] }); + expect(parsePathname('/:xxx?')).toEqual({ parts: ['xxx'], flags: [FLAG_PARAM | FLAG_OPTIONAL] }); + expect(parsePathname('/:xxx*')).toEqual({ parts: ['xxx'], flags: [FLAG_PARAM | FLAG_WILDCARD] }); + expect(parsePathname('/:xxx*?')).toEqual({ + parts: ['xxx'], + flags: [FLAG_PARAM | FLAG_WILDCARD | FLAG_OPTIONAL], + }); + expect(parsePathname('/:xxx*?/:yyy?')).toEqual({ + parts: ['xxx', 'yyy'], + flags: [FLAG_PARAM | FLAG_WILDCARD | FLAG_OPTIONAL, FLAG_PARAM | FLAG_OPTIONAL], + }); }); - test('creates a template with params', () => { - expect(new PathnameAdapter('$aaa')['_template']).toEqual(['', 'aaa', '']); - expect(new PathnameAdapter('$AAA')['_template']).toEqual(['', 'AAA', '']); - expect(new PathnameAdapter('$aaa111')['_template']).toEqual(['', 'aaa111', '']); - expect(new PathnameAdapter('/$aaa_bbb')['_template']).toEqual(['', 'aaa_bbb', '']); - expect(new PathnameAdapter('/$aaa+bbb')['_template']).toEqual(['', 'aaa', '+bbb']); - expect(new PathnameAdapter('/$aaa$bbb')['_template']).toEqual(['', 'aaa', '', 'bbb', '']); - expect(new PathnameAdapter('/bbb$aaa/ccc')['_template']).toEqual(['bbb', 'aaa', '/ccc']); - expect(new PathnameAdapter('/bbb$aaa*/ccc')['_template']).toEqual(['bbb', 'aaa', '/ccc']); - expect(new PathnameAdapter('/bbb$aaa*?/ccc')['_template']).toEqual(['bbb', 'aaa', '/ccc']); - expect(new PathnameAdapter('/bbb$aaa?/ccc')['_template']).toEqual(['bbb', 'aaa', '/ccc']); + test('throws an error if syntax is invalid', () => { + expect(() => parsePathname('aaa:xxx')).toThrow(new SyntaxError('Unexpected param at 3')); + expect(() => parsePathname('/:/')).toThrow(new SyntaxError('Param must have a name at 2')); + expect(() => parsePathname('/*/')).toThrow(new SyntaxError('Unexpected wildcard flag at 1')); + expect(() => parsePathname('/aaa*/')).toThrow(new SyntaxError('Unexpected wildcard flag at 4')); + expect(() => parsePathname('/aaa*/')).toThrow(new SyntaxError('Unexpected wildcard flag at 4')); + expect(() => parsePathname('/aaa??/')).toThrow(new SyntaxError('Unexpected optional flag at 5')); + expect(() => parsePathname('/:xxx??/')).toThrow(new SyntaxError('Unexpected optional flag at 6')); + expect(() => parsePathname('/:xxx?*/')).toThrow(new SyntaxError('Unexpected wildcard flag at 6')); + expect(() => parsePathname('/:xxx**/')).toThrow(new SyntaxError('Unexpected wildcard flag at 6')); + expect(() => parsePathname('/:xxxЯ/')).toThrow(new SyntaxError('Unexpected character at 5')); + expect(() => parsePathname('/:xxx?xxx/')).toThrow(new SyntaxError('Unexpected character at 6')); }); +}); - test('throws if param does not have a name', () => { - expect(() => new PathnameAdapter('/aaa$111')).toThrow(new Error('Pathname param must have a name: 4')); - expect(() => new PathnameAdapter('/$/')).toThrow(); - expect(() => new PathnameAdapter('/' + encodeURIComponent('$') + '')).not.toThrow(); +describe('createPathnameRegExp', () => { + test('creates a RegExp from a pathname template', () => { + expect(createPathnameRegExp(parsePathname(''))).toEqual(/^\//i); + expect(createPathnameRegExp(parsePathname('/'))).toEqual(/^\//i); + expect(createPathnameRegExp(parsePathname('//'))).toEqual(/^\/\//i); + expect(createPathnameRegExp(parsePathname('aaa'))).toEqual(/^\/aaa(?=\/|$)/i); + expect(createPathnameRegExp(parsePathname('aaa?'))).toEqual(/^(?:\/aaa)?(?=\/|$)/i); + expect(createPathnameRegExp(parsePathname('aaa?/bbb'))).toEqual(/^(?:\/aaa)?\/bbb(?=\/|$)/i); + expect(createPathnameRegExp(parsePathname('aaa?/bbb?'))).toEqual(/^(?:\/aaa)?(?:\/bbb)?(?=\/|$)/i); + expect(createPathnameRegExp(parsePathname(':xxx'))).toEqual(/^\/([^/]+)(?=\/|$)/i); + expect(createPathnameRegExp(parsePathname(':xxx?'))).toEqual(/^(?:\/([^/]+))?(?=\/|$)/i); + expect(createPathnameRegExp(parsePathname(':xxx*'))).toEqual(/^\/(.+)(?=\/|$)/i); + expect(createPathnameRegExp(parsePathname(':xxx*?'))).toEqual(/^(?:\/(.+))?(?=\/|$)/i); + expect(createPathnameRegExp(parsePathname('aaa/:xxx'))).toEqual(/^\/aaa\/([^/]+)(?=\/|$)/i); + expect(createPathnameRegExp(parsePathname('aaa/:xxx?'))).toEqual(/^\/aaa(?:\/([^/]+))?(?=\/|$)/i); + expect(createPathnameRegExp(parsePathname('aaa/:xxx*'))).toEqual(/^\/aaa\/(.+)(?=\/|$)/i); + expect(createPathnameRegExp(parsePathname('aaa/:xxx*?'))).toEqual(/^\/aaa(?:\/(.+))?(?=\/|$)/i); + expect(createPathnameRegExp(parsePathname('aaa/:xxx?/bbb'))).toEqual(/^\/aaa(?:\/([^/]+))?\/bbb(?=\/|$)/i); }); +}); +describe('PathnameAdapter', () => { test('matches a pathname without params', () => { - expect(new PathnameAdapter('').match('')).toEqual({ pathname: '', nestedPathname: '' }); - expect(new PathnameAdapter('').match('/')).toEqual({ pathname: '', nestedPathname: '' }); - expect(new PathnameAdapter('').match('/aaa')).toEqual({ pathname: '', nestedPathname: '/aaa' }); - expect(new PathnameAdapter('/aaa').match('aaa')).toEqual({ pathname: 'aaa', nestedPathname: '' }); - expect(new PathnameAdapter('/aaa').match('/aaa')).toEqual({ pathname: '/aaa', nestedPathname: '' }); - expect(new PathnameAdapter('/aaa').match('/aaa/')).toEqual({ pathname: '/aaa', nestedPathname: '' }); - expect(new PathnameAdapter('/AAA').match('/aaa')).toEqual({ pathname: '/aaa', nestedPathname: '' }); - expect(new PathnameAdapter('/aaa').match('/AAA')).toEqual({ pathname: '/AAA', nestedPathname: '' }); + expect(new PathnameAdapter('').match('/')).toEqual({ pathname: '/', nestedPathname: '/' }); + expect(new PathnameAdapter('').match('/aaa')).toEqual({ pathname: '/', nestedPathname: '/aaa' }); + expect(new PathnameAdapter('/aaa').match('/aaa')).toEqual({ pathname: '/aaa', nestedPathname: '/' }); + expect(new PathnameAdapter('/aaa').match('/aaa/')).toEqual({ pathname: '/aaa', nestedPathname: '/' }); + expect(new PathnameAdapter('/AAA').match('/aaa')).toEqual({ pathname: '/aaa', nestedPathname: '/' }); + expect(new PathnameAdapter('/aaa').match('/AAA')).toEqual({ pathname: '/AAA', nestedPathname: '/' }); expect(new PathnameAdapter('/aaa').match('/aaa/bbb')).toEqual({ pathname: '/aaa', nestedPathname: '/bbb' }); - expect(new PathnameAdapter('/aaa').match('/aaa/')).toEqual({ pathname: '/aaa', nestedPathname: '' }); + expect(new PathnameAdapter('/aaa').match('/aaa/')).toEqual({ pathname: '/aaa', nestedPathname: '/' }); }); test('does not match a pathname without params', () => { + expect(new PathnameAdapter('').match('')).toBeNull(); + expect(new PathnameAdapter('/aaa').match('aaa')).toBeNull(); expect(new PathnameAdapter('/aa').match('/aaa')).toBeNull(); expect(new PathnameAdapter('/aaaa').match('/aaa')).toBeNull(); expect(new PathnameAdapter('/aaabbb').match('/aaa')).toBeNull(); }); test('matches a pathname with params', () => { - expect(new PathnameAdapter('/aaa$xxx').match('/aaayyy')).toEqual({ - pathname: '/aaayyy', - nestedPathname: '', + expect(new PathnameAdapter('/aaa/:xxx').match('/aaa/yyy')).toEqual({ + pathname: '/aaa/yyy', + nestedPathname: '/', params: { xxx: 'yyy' }, }); - expect(new PathnameAdapter('/aaa$xxx/bbb$yyy').match('/aaappp/bbbqqq/ccc')).toEqual({ - pathname: '/aaappp/bbbqqq', - nestedPathname: '/ccc', - params: { xxx: 'ppp', yyy: 'qqq' }, + expect(new PathnameAdapter('/aaa/:xxx*').match('/aaa/bbb/ccc')).toEqual({ + pathname: '/aaa/bbb/ccc', + nestedPathname: '/', + params: { xxx: 'bbb/ccc' }, }); }); test('creates a pathname without params', () => { - expect(new PathnameAdapter('/aaa').toPathname({})).toBe('aaa'); + expect(new PathnameAdapter('/aaa').toPathname(undefined)).toBe('/aaa'); }); test('creates a pathname with params', () => { - expect(new PathnameAdapter('/aaa$xxx').toPathname({ xxx: 'yyy' })).toBe('aaayyy'); + expect(new PathnameAdapter('/aaa/:xxx').toPathname({ xxx: 'yyy' })).toBe('/aaa/yyy'); }); - test('throws if params are not provided', () => { - expect(() => new PathnameAdapter('/$xxx/$yyy').toPathname(undefined)).toThrow( - new Error('Pathname params are required: xxx, yyy') - ); + test("throws if non-optional param isn't provided", () => { + expect(() => new PathnameAdapter('/:xxx').toPathname(undefined)).toThrow(new Error('Param must be a string: xxx')); + + expect(() => new PathnameAdapter('/:xxx').toPathname({})).toThrow(new Error('Param must be a string: xxx')); }); - test('matches a wildcard param', () => { - expect(new PathnameAdapter('/aaa$xxx*').match('/aaa')).toBeNull(); + test("does not throw if optional param isn't provided", () => { + expect(new PathnameAdapter('/:xxx?').toPathname(undefined)).toBe('/'); + expect(new PathnameAdapter('/aaa/:xxx?').toPathname(undefined)).toBe('/aaa'); + expect(new PathnameAdapter('/aaa/:xxx?/bbb').toPathname(undefined)).toBe('/aaa/bbb'); + }); - expect(new PathnameAdapter('/aaa$xxx*').match('/aaa/bbb/ccc')).toEqual({ - nestedPathname: '', - params: { xxx: '/bbb/ccc' }, + test('matches a wildcard param', () => { + expect(new PathnameAdapter('/aaa/:xxx*').match('/aaa/bbb/ccc')).toEqual({ pathname: '/aaa/bbb/ccc', + nestedPathname: '/', + params: { xxx: 'bbb/ccc' }, }); - expect(new PathnameAdapter('/aaa$xxx*/ccc').match('/aaa/bbb/ccc')).toEqual({ - nestedPathname: '', - params: { xxx: '/bbb' }, + expect(new PathnameAdapter('/aaa/:xxx*/ccc').match('/aaa/bbb/ccc')).toEqual({ pathname: '/aaa/bbb/ccc', + nestedPathname: '/', + params: { xxx: 'bbb' }, }); - }); - test('matches an optional wildcard param', () => { - expect(new PathnameAdapter('/aaa$xxx*?').match('/aaa')).toEqual({ - nestedPathname: '', - params: { xxx: '' }, - pathname: '/aaa', + expect(new PathnameAdapter('/aaa/:xxx*/ddd').match('/aaa/bbb/ccc/ddd')).toEqual({ + pathname: '/aaa/bbb/ccc/ddd', + nestedPathname: '/', + params: { xxx: 'bbb/ccc' }, }); + }); - expect(new PathnameAdapter('/aaa$xxx*?').match('/aaa/bbb/ccc')).toEqual({ - nestedPathname: '', - params: { xxx: '/bbb/ccc' }, + test('matches an optional wildcard param', () => { + expect(new PathnameAdapter('/aaa/:xxx*?').match('/aaa/bbb/ccc')).toEqual({ pathname: '/aaa/bbb/ccc', + nestedPathname: '/', + params: { xxx: 'bbb/ccc' }, }); }); test('matches an optional param', () => { - expect(new PathnameAdapter('/aaa$xxx?').match('/aaa')).toEqual({ - nestedPathname: '', - params: { xxx: '' }, + expect(new PathnameAdapter('/aaa/:xxx?').match('/aaa')).toEqual({ pathname: '/aaa', + nestedPathname: '/', + params: { xxx: undefined }, }); - expect(new PathnameAdapter('/aaa$xxx?').match('/aaa/')).toEqual({ - nestedPathname: '', - params: { xxx: '' }, - pathname: '/aaa', + expect(new PathnameAdapter('/aaa/:xxx?/ccc').match('/aaa/ccc')).toEqual({ + pathname: '/aaa/ccc', + nestedPathname: '/', + params: { xxx: undefined }, }); - expect(new PathnameAdapter('/aaa$xxx?').match('/aaa/bbb')).toEqual({ - nestedPathname: '/bbb', - params: { xxx: '' }, - pathname: '/aaa', + expect(new PathnameAdapter('/aaa/:xxx?/ccc').match('/aaa/bbb/ccc')).toEqual({ + pathname: '/aaa/bbb/ccc', + nestedPathname: '/', + params: { xxx: 'bbb' }, }); }); }); diff --git a/src/test/createRoute.test.ts b/src/test/createRoute.test.ts index 091dea6..abd3897 100644 --- a/src/test/createRoute.test.ts +++ b/src/test/createRoute.test.ts @@ -13,7 +13,11 @@ describe('Route', () => { expect(cccRoute.getLocation()).toEqual({ pathname: '/aaa/bbb/ccc', searchParams: {}, hash: '' }); expect(createRoute({ pathname: 'aaa' }).getLocation()).toEqual({ pathname: '/aaa', searchParams: {}, hash: '' }); - expect(createRoute({ pathname: 'aaa/' }).getLocation()).toEqual({ pathname: '/aaa', searchParams: {}, hash: '' }); + expect(createRoute({ pathname: 'aaa/' }).getLocation()).toEqual({ + pathname: '/aaa/', + searchParams: {}, + hash: '', + }); expect(createRoute(createRoute({ pathname: '/' }), { pathname: '/' }).getLocation()).toEqual({ pathname: '/', @@ -49,7 +53,7 @@ describe('Route', () => { }); test('interpolates pathname params', () => { - expect(createRoute<{ bbb: string }>({ pathname: 'aaa/$bbb' }).getLocation({ bbb: 'xxx' })).toEqual({ + expect(createRoute<{ bbb: string }>({ pathname: 'aaa/:bbb' }).getLocation({ bbb: 'xxx' })).toEqual({ pathname: '/aaa/xxx', searchParams: {}, hash: '', @@ -57,7 +61,7 @@ describe('Route', () => { }); test('ignores unexpected search params', () => { - expect(createRoute({ pathname: 'aaa/$bbb' }).getLocation({ bbb: 'xxx', ccc: 'yyy' })).toEqual({ + expect(createRoute({ pathname: 'aaa/:bbb' }).getLocation({ bbb: 'xxx', ccc: 'yyy' })).toEqual({ pathname: '/aaa/xxx', searchParams: { ccc: 'yyy' }, hash: '', @@ -66,7 +70,7 @@ describe('Route', () => { test('adds search params by omitting pathname params', () => { expect( - createRoute({ pathname: 'aaa/$bbb', paramsAdapter: params => params }).getLocation({ + createRoute({ pathname: 'aaa/:bbb', paramsAdapter: params => params }).getLocation({ bbb: 'xxx', ccc: 'yyy', }) @@ -84,7 +88,7 @@ describe('Route', () => { }; expect( - createRoute({ pathname: 'aaa/$bbb', paramsAdapter }).getLocation({ + createRoute({ pathname: 'aaa/:bbb', paramsAdapter }).getLocation({ bbb: 'xxx', }) ).toEqual({