UNPKG

@sveltejs/kit

Version:

SvelteKit is the fastest way to build Svelte apps

386 lines (329 loc) • 10.8 kB
/** @import { ParamMatcher, ParamValue } from '@sveltejs/kit/params' */ import * as e from '../messages/shared-errors.js'; import { escape_for_regexp } from './regex.js'; const param_pattern = /^(\[)?(\.\.\.)?([\w-]+)(?:=([\w-]+))?(\])?$/; const root_group_pattern = /^\/\((?:[^)]+)\)$/; const escape_sequence_pattern = /\[([ux])\+([^\]]+)\]/; /** * Decodes the codepoints of an `[x+nn]` or `[u+nnnn]` escape sequence * @param {string} code the sequence without its `[x+`/`[u+` prefix or `]` suffix */ export function decode_escape_sequence(code) { return String.fromCodePoint(...code.split('-').map((codepoint) => parseInt(codepoint, 16))); } /** * Encodes the characters that `decode_pathname` leaves untouched, so that a decoded * escape sequence still matches the pattern `parse_route_id` builds for it * @param {string} str */ export function encode_pathname_chars(str) { return str.replace( /[%/?#]/g, (char) => '%' + char.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0') ); } /** * Creates the regex pattern, extracts parameter names, and generates types for a route * @param {string} id */ export function parse_route_id(id) { /** @type {import('types').RouteParam[]} */ const params = []; const pattern = id === '/' || root_group_pattern.test(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+') || content.startsWith('u+')) { return escape(decode_escape_sequence(content.slice(2))); } // We know the match cannot be null because manifest generation checks // each route ID with `validate_route_id_params` first const match = /** @type {RegExpExecArray} */ (param_pattern.exec(content)); 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 }; } /** * Returns the first param in a route ID whose name or matcher contains characters other than * underscores, hyphens and alphanumeric characters, mirroring the segments `parse_route_id` parses * @param {string} id * @returns {string | undefined} */ export function validate_route_id_params(id) { if (id === '/' || root_group_pattern.test(id)) return; for (const segment of get_route_segments(id)) { if (/^\[\.\.\.([\w-]+)(?:=([\w-]+))?\]$/.test(segment)) continue; if (/^\[\[([\w-]+)(?:=([\w-]+))?\]\]$/.test(segment)) continue; if (!segment) continue; const parts = segment.split(/\[(.+?)\](?!\])/); for (let i = 1; i < parts.length; i += 2) { const content = parts[i]; if (content.startsWith('x+') || content.startsWith('u+')) continue; if (!param_pattern.test(content)) return content; } } } /** * Returns `false` for `(group)` segments * @param {string} segment */ function affects_path(segment) { return segment !== '' && !/^\([^)]+\)$/.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 {ParamMatcher} matcher * @param {string} value * @returns {{ success: true, value: any } | { success: false }} */ function run_matcher(matcher, value) { const result = matcher['~standard'].validate(value); if (result instanceof Promise) { e.param_matcher_async(); } if (result.issues) { return { success: false }; } const parsed = result.value; if ( typeof parsed !== 'string' && typeof parsed !== 'number' && typeof parsed !== 'boolean' && typeof parsed !== 'bigint' ) { e.param_matcher_result_invalid(); } return { success: true, value: parsed }; } /** * @param {RegExpMatchArray} match * @param {import('types').RouteParam[]} params * @param {Record<string, ParamMatcher>} matchers */ export function exec(match, params, matchers) { /** @type {Record<string, any>} */ 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) { // We need to allow the matcher to run so that it can decide if this optional rest param should be allowed to match value = ''; } else { continue; } } const decoded = decodeURIComponent(value); if (param.matcher) { const outcome = run_matcher(matchers[param.matcher], decoded); if (!outcome.success) { // 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; } result[param.name] = outcome.value; } else { result[param.name] = decoded; } // 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; } if (buffered) return; return result; } /** * `decode_pathname` leaves these characters untouched, so routes have to match their encoded forms * @type {Record<string, string>} */ const encoded = { '%': '%25', '/': '%2[Ff]', '?': '%3[Ff]', '#': '%23' }; /** @param {string} str */ function escape(str) { // the replacements in `encoded` are regex source themselves, so they must not be escaped again return str .normalize() .split(/([%/?#])/) .map((part, i) => (i % 2 ? encoded[part] : escape_for_regexp(part))) .join(''); } const basic_param_pattern = /\[(\[)?(\.\.\.)?([\w-]+?)(?:=([\w-]+))?\]\]?/g; // escape sequences are expanded in the same pass as the params, so that a param // value containing `[x+2f]` is not itself expanded export const segment_pattern = new RegExp( `${escape_sequence_pattern.source}|${basic_param_pattern.source}`, '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, ParamValue | undefined>} params * @returns {string} */ export function resolve_route(id, params) { const segments = get_route_segments(id); const has_id_trailing_slash = id != '/' && id.endsWith('/'); return ( '/' + segments .map((segment) => segment.replace(segment_pattern, (_, escape_type, escape_code, optional, rest, name) => { if (escape_type) return encode_pathname_chars(decode_escape_sequence(escape_code)); const value = params[name]; if (value === undefined || value === '') { if (optional) return ''; if (rest && value !== undefined) return ''; e.route_param_missing({ name, id }); } if (typeof value === 'string') { if (value.startsWith('/') || value.endsWith('/')) { e.route_param_slash({ name, id }); } return value; } if ( typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint' ) { return String(value); } e.route_param_value_invalid({ name, id }); }) ) .filter(Boolean) .join('/') + (has_id_trailing_slash ? '/' : '') ); } /** * @param {import('types').SSRNode} node * @returns {boolean} */ export function has_server_load(node) { return node.server?.load !== undefined || node.server?.trailingSlash !== undefined; } /** * Find the first route that matches the given path * @template {{pattern: RegExp, params: import('types').RouteParam[]}} Route * @param {string} path - The decoded pathname to match * @param {Route[]} routes * @param {Record<string, ParamMatcher>} matchers * @returns {{ route: Route, params: Record<string, any> } | null} */ export function find_route(path, routes, matchers) { for (const route of routes) { const match = route.pattern.exec(path); if (!match) continue; const matched = exec(match, route.params, matchers); if (matched) { return { route, params: matched }; } } return null; }