|
import { BROWSER } from 'esm-env'; |
|
|
|
const param_pattern = /^(\[)?(\.\.\.)?(\w+)(?:=(\w+))?(\])?$/; |
|
|
|
|
|
|
|
|
|
|
|
export function parse_route_id(id) { |
|
|
|
const params = []; |
|
|
|
const pattern = |
|
id === '/' |
|
? /^\/$/ |
|
: new RegExp( |
|
`^${get_route_segments(id) |
|
.map((segment) => { |
|
// special case — /[...rest]/ could contain zero segments |
|
const rest_match = /^\[\.\.\.(\w+)(?:=(\w+))?\]$/.exec(segment); |
|
if (rest_match) { |
|
params.push({ |
|
name: rest_match[1], |
|
matcher: rest_match[2], |
|
optional: false, |
|
rest: true, |
|
chained: true |
|
}); |
|
return '(?:/(.*))?'; |
|
} |
|
// special case — /[[optional]]/ could contain zero segments |
|
const optional_match = /^\[\[(\w+)(?:=(\w+))?\]\]$/.exec(segment); |
|
if (optional_match) { |
|
params.push({ |
|
name: optional_match[1], |
|
matcher: optional_match[2], |
|
optional: true, |
|
rest: false, |
|
chained: true |
|
}); |
|
return '(?:/([^/]+))?'; |
|
} |
|
|
|
if (!segment) { |
|
return; |
|
} |
|
|
|
const parts = segment.split(/\[(.+?)\](?!\])/); |
|
const result = parts |
|
.map((content, i) => { |
|
if (i % 2) { |
|
if (content.startsWith('x+')) { |
|
return escape(String.fromCharCode(parseInt(content.slice(2), 16))); |
|
} |
|
|
|
if (content.startsWith('u+')) { |
|
return escape( |
|
String.fromCharCode( |
|
...content |
|
.slice(2) |
|
.split('-') |
|
.map((code) => parseInt(code, 16)) |
|
) |
|
); |
|
} |
|
|
|
// We know the match cannot be null in the browser because manifest generation |
|
// would have invoked this during build and failed if we hit an invalid |
|
// param/matcher name with non-alphanumeric character. |
|
const match = /** @type {RegExpExecArray} */ (param_pattern.exec(content)); |
|
if (!BROWSER && !match) { |
|
throw new Error( |
|
`Invalid param: ${content}. Params and matcher names can only have underscores and alphanumeric characters.` |
|
); |
|
} |
|
|
|
const [, is_optional, is_rest, name, matcher] = match; |
|
// It's assumed that the following invalid route id cases are already checked |
|
// - unbalanced brackets |
|
// - optional param following rest param |
|
|
|
params.push({ |
|
name, |
|
matcher, |
|
optional: !!is_optional, |
|
rest: !!is_rest, |
|
chained: is_rest ? i === 1 && parts[0] === '' : false |
|
}); |
|
return is_rest ? '(.*?)' : is_optional ? '([^/]*)?' : '([^/]+?)'; |
|
} |
|
|
|
return escape(content); |
|
}) |
|
.join(''); |
|
|
|
return '/' + result; |
|
}) |
|
.join('')}/?$` |
|
); |
|
|
|
return { pattern, params }; |
|
} |
|
|
|
const optional_param_regex = /\/\[\[\w+?(?:=\w+)?\]\]/; |
|
|
|
/** |
|
* Removes optional params from a route ID. |
|
* @param {string} id |
|
* @returns The route id with optional params removed |
|
*/ |
|
export function remove_optional_params(id) { |
|
return id.replace(optional_param_regex, ''); |
|
} |
|
|
|
/** |
|
* Returns `false` for `(group)` segments |
|
* @param {string} segment |
|
*/ |
|
function affects_path(segment) { |
|
return !/^\([^)]+\)$/.test(segment); |
|
} |
|
|
|
/** |
|
* Splits a route id into its segments, removing segments that |
|
* don't affect the path (i.e. groups). The root route is represented by `/` |
|
* and will be returned as `['']`. |
|
* @param {string} route |
|
* @returns string[] |
|
*/ |
|
export function get_route_segments(route) { |
|
return route.slice(1).split('/').filter(affects_path); |
|
} |
|
|
|
/** |
|
* @param {RegExpMatchArray} match |
|
* @param {import('types').RouteParam[]} params |
|
* @param {Record<string, import('@sveltejs/kit').ParamMatcher>} matchers |
|
*/ |
|
export function exec(match, params, matchers) { |
|
/** @type {Record<string, string>} */ |
|
const result = {}; |
|
|
|
const values = match.slice(1); |
|
const values_needing_match = values.filter((value) => value !== undefined); |
|
|
|
let buffered = 0; |
|
|
|
for (let i = 0; i < params.length; i += 1) { |
|
const param = params[i]; |
|
let value = values[i - buffered]; |
|
|
|
// in the `[[a=b]]/.../[...rest]` case, if one or more optional parameters |
|
// weren't matched, roll the skipped values into the rest |
|
if (param.chained && param.rest && buffered) { |
|
value = values |
|
.slice(i - buffered, i + 1) |
|
.filter((s) => s) |
|
.join('/'); |
|
|
|
buffered = 0; |
|
} |
|
|
|
// if `value` is undefined, it means this is an optional or rest parameter |
|
if (value === undefined) { |
|
if (param.rest) result[param.name] = ''; |
|
continue; |
|
} |
|
|
|
if (!param.matcher || matchers[param.matcher](value)) { |
|
result[param.name] = value; |
|
|
|
// Now that the params match, reset the buffer if the next param isn't the [...rest] |
|
// and the next value is defined, otherwise the buffer will cause us to skip values |
|
const next_param = params[i + 1]; |
|
const next_value = values[i + 1]; |
|
if (next_param && !next_param.rest && next_param.optional && next_value && param.chained) { |
|
buffered = 0; |
|
} |
|
|
|
// There are no more params and no more values, but all non-empty values have been matched |
|
if ( |
|
!next_param && |
|
!next_value && |
|
Object.keys(result).length === values_needing_match.length |
|
) { |
|
buffered = 0; |
|
} |
|
continue; |
|
} |
|
|
|
// in the `/[[a=b]]/...` case, if the value didn't satisfy the matcher, |
|
// keep track of the number of skipped optional parameters and continue |
|
if (param.optional && param.chained) { |
|
buffered++; |
|
continue; |
|
} |
|
|
|
// otherwise, if the matcher returns `false`, the route did not match |
|
return; |
|
} |
|
|
|
if (buffered) return; |
|
return result; |
|
} |
|
|
|
/** @param {string} str */ |
|
function escape(str) { |
|
return ( |
|
str |
|
.normalize() |
|
// escape [ and ] before escaping other characters, since they are used in the replacements |
|
.replace(/[[\]]/g, '\\$&') |
|
// replace %, /, ? and # with their encoded versions because decode_pathname leaves them untouched |
|
.replace(/%/g, '%25') |
|
.replace(/\//g, '%2[Ff]') |
|
.replace(/\?/g, '%3[Ff]') |
|
.replace(/#/g, '%23') |
|
// escape characters that have special meaning in regex |
|
.replace(/[.*+?^${}()|\\]/g, '\\$&') |
|
); |
|
} |
|
|
|
const basic_param_pattern = /\[(\[)?(\.\.\.)?(\w+?)(?:=(\w+))?\]\]?/g; |
|
|
|
/** |
|
* Populate a route ID with params to resolve a pathname. |
|
* @example |
|
* ```js |
|
* resolveRoute( |
|
* `/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 resolve_route(id, params) { |
|
const segments = get_route_segments(id); |
|
return ( |
|
'/' + |
|
segments |
|
.map((segment) => |
|
segment.replace(basic_param_pattern, (_, optional, rest, name) => { |
|
const param_value = params[name]; |
|
|
|
// This is nested so TS correctly narrows the type |
|
if (!param_value) { |
|
if (optional) return ''; |
|
if (rest && param_value !== undefined) 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('/') |
|
); |
|
} |
|
|