diff --git a/.changeset/app-types-path-template.md b/.changeset/app-types-path-template.md new file mode 100644 index 000000000000..99c9c25d49cb --- /dev/null +++ b/.changeset/app-types-path-template.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: generate valid `Path` types for routes with optional or rest params, several params in one segment, or escape sequences diff --git a/packages/kit/src/core/sync/write_app_types.js b/packages/kit/src/core/sync/write_app_types.js index ada2fd4b2bff..4fa04fb0f698 100644 --- a/packages/kit/src/core/sync/write_app_types.js +++ b/packages/kit/src/core/sync/write_app_types.js @@ -4,16 +4,42 @@ import { resolve_entry } from '../../utils/filesystem.js'; import { posixify } from '../../utils/os.js'; import { write_if_changed } from './utils.js'; import { s } from '../../utils/misc.js'; -import { get_route_segments } from '../../utils/routing.js'; +import { + decode_escape_sequence, + encode_pathname_chars, + get_route_segments, + segment_pattern +} from '../../utils/routing.js'; import { is_app_route, is_endpoint_route, is_page_route } from './create_manifest_data/index.js'; -const replace_optional_params = (/** @type {string} */ id) => - id.replace(/\/\[\[[^\]]+\]\]/g, '${string}'); -const replace_required_params = (/** @type {string} */ id) => - id.replace(/\/\[[^\]]+\]/g, '/${string}'); -/** Convert route ID to pathname by removing layout groups */ -const remove_group_segments = (/** @type {string} */ id) => { - return '/' + get_route_segments(id).join('/'); +const optional_param_pattern = /^\[\[[\w-]+(?:=[\w-]+)?\]\]$/; +const rest_param_pattern = /^\[\.\.\.[\w-]+(?:=[\w-]+)?\]$/; + +/** + * Convert a route ID to the pathnames it can match (relative to the base path), in which each + * param is replaced with `${string}` and each escape sequence is expanded. A param that fills an + * entire segment can be absent, so it contributes a pathname with the segment and one without, + * rather than one that absorbs the `/` + * @param {string} id + */ +const get_pathname_patterns = (id) => { + let pathnames = ['']; + + for (const segment of get_route_segments(id)) { + const omittable = optional_param_pattern.test(segment) || rest_param_pattern.test(segment); + const content = omittable + ? '${string}' + : segment.replace(segment_pattern, (_, escape_type, escape_code) => + escape_type ? encode_pathname_chars(decode_escape_sequence(escape_code)) : '${string}' + ); + + pathnames = pathnames.flatMap((pathname) => { + const joined = pathname === '' ? content : `${pathname}/${content}`; + return omittable ? [joined, pathname] : [joined]; + }); + } + + return [...new Set(pathnames)]; }; /** @@ -210,9 +236,6 @@ function generate_app_types(manifest_data, config, dir) { if (is_endpoint_route(route)) endpoint_route_ids.push(id); if (is_app_route(route)) app_route_ids.push(id); - const pathname = remove_group_segments(route.id); - let normalized_pathname = pathname.slice(1); - /** @type {(path: string) => string} */ let serialise = s; @@ -226,12 +249,13 @@ function generate_app_types(manifest_data, config, dir) { dynamic_routes.push(`${s(route.id)}: { ${params.join('; ')} }`); } - normalized_pathname = replace_required_params(replace_optional_params(pathname)).slice(1); serialise = (p) => `\`${p}\` & {}`; } - for (const p of get_pathnames_for_trailing_slash(normalized_pathname, route)) { - pathnames.add(serialise(p)); + for (const pathname of get_pathname_patterns(route.id)) { + for (const p of get_pathnames_for_trailing_slash(pathname, route)) { + pathnames.add(serialise(p)); + } } let layout_type = 'Record'; diff --git a/packages/kit/src/core/sync/write_types/test/app-types/+page.js b/packages/kit/src/core/sync/write_types/test/app-types/+page.js index c369c6e680c5..9bd80c0e52f3 100644 --- a/packages/kit/src/core/sync/write_types/test/app-types/+page.js +++ b/packages/kit/src/core/sync/write_types/test/app-types/+page.js @@ -209,5 +209,29 @@ pathname = 'path-a/trailing-slash/never/layout/inside'; // Test trailing-slash - always (endpoint) and never (page) pathname = 'path-a/trailing-slash/mixed'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars pathname = 'path-a/trailing-slash/mixed/'; + +// Test optional params, which can be omitted +pathname = 'about'; +pathname = 'en/about'; +pathname = 'deep'; +pathname = 'en/deep'; +pathname = 'en/gb/deep'; +// @ts-expect-error an omitted optional param does not merge into the next segment +pathname = 'xyzabout'; +// @ts-expect-error +pathname = 'xyzdeep'; + +// Test multiple params in a single segment +pathname = 'multi-param/1-2'; + +// Test rest params, which can match zero segments +pathname = 'files'; +pathname = 'files/a/b'; + +// Test escape sequences, which are expanded +pathname = '.well-known'; +// @ts-expect-error the route id's escape sequence is not part of the pathname +pathname = '[x+2e]well-known'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +pathname = '@someone'; diff --git a/packages/kit/src/core/sync/write_types/test/app-types/[[a]]/[[b]]/deep/+page.js b/packages/kit/src/core/sync/write_types/test/app-types/[[a]]/[[b]]/deep/+page.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/kit/src/core/sync/write_types/test/app-types/[[lang]]/about/+page.js b/packages/kit/src/core/sync/write_types/test/app-types/[[lang]]/about/+page.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/kit/src/core/sync/write_types/test/app-types/[x+2e]well-known/+page.js b/packages/kit/src/core/sync/write_types/test/app-types/[x+2e]well-known/+page.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/kit/src/core/sync/write_types/test/app-types/[x+40][handle]/+page.js b/packages/kit/src/core/sync/write_types/test/app-types/[x+40][handle]/+page.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/kit/src/core/sync/write_types/test/app-types/files/[...path]/+page.js b/packages/kit/src/core/sync/write_types/test/app-types/files/[...path]/+page.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/kit/src/core/sync/write_types/test/app-types/multi-param/[foo]-[bar]/+page.js b/packages/kit/src/core/sync/write_types/test/app-types/multi-param/[foo]-[bar]/+page.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/kit/src/utils/routing.js b/packages/kit/src/utils/routing.js index 79caa6ed1c3a..fc085dfdd360 100644 --- a/packages/kit/src/utils/routing.js +++ b/packages/kit/src/utils/routing.js @@ -11,7 +11,7 @@ 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 */ -function decode_escape_sequence(code) { +export function decode_escape_sequence(code) { return String.fromCodePoint(...code.split('-').map((codepoint) => parseInt(codepoint, 16))); } @@ -20,7 +20,7 @@ function decode_escape_sequence(code) { * escape sequence still matches the pattern `parse_route_id` builds for it * @param {string} str */ -function encode_pathname_chars(str) { +export function encode_pathname_chars(str) { return str.replace( /[%/?#]/g, (char) => '%' + char.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0') @@ -280,7 +280,7 @@ 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 -const segment_pattern = new RegExp( +export const segment_pattern = new RegExp( `${escape_sequence_pattern.source}|${basic_param_pattern.source}`, 'g' );