From d9f82c18f1f4a448743504dcaf40ccd45ae191a3 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:05:32 -0400 Subject: [PATCH 1/5] fix: generate valid `Path` types for routes with an optional first segment or several params in one segment --- .changeset/app-types-path-template.md | 5 +++ packages/kit/src/core/sync/write_app_types.js | 40 +++++++++++++------ .../sync/write_types/test/app-types/+page.js | 12 +++++- .../test/app-types/[[a]]/[[b]]/deep/+page.js | 0 .../test/app-types/[[lang]]/about/+page.js | 0 .../multi-param/[foo]-[bar]/+page.js | 0 packages/kit/src/utils/routing.js | 2 +- 7 files changed, 44 insertions(+), 15 deletions(-) create mode 100644 .changeset/app-types-path-template.md create mode 100644 packages/kit/src/core/sync/write_types/test/app-types/[[a]]/[[b]]/deep/+page.js create mode 100644 packages/kit/src/core/sync/write_types/test/app-types/[[lang]]/about/+page.js create mode 100644 packages/kit/src/core/sync/write_types/test/app-types/multi-param/[foo]-[bar]/+page.js diff --git a/.changeset/app-types-path-template.md b/.changeset/app-types-path-template.md new file mode 100644 index 000000000000..a4d93cb620df --- /dev/null +++ b/.changeset/app-types-path-template.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: generate valid `Path` types for routes with an optional first segment or several params in one segment diff --git a/packages/kit/src/core/sync/write_app_types.js b/packages/kit/src/core/sync/write_app_types.js index e8a76fdc25d6..7d57a89d71cd 100644 --- a/packages/kit/src/core/sync/write_app_types.js +++ b/packages/kit/src/core/sync/write_app_types.js @@ -4,15 +4,31 @@ 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'; - -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('/'); +import { basic_param_pattern, get_route_segments } from '../../utils/routing.js'; + +const optional_param_pattern = /^\[\[[\w-]+(?:=[\w-]+)?\]\]$/; + +/** + * Convert a route ID to a pathname (relative to the base path) in which each param + * is replaced with `${string}` + * @param {string} id + */ +const get_pathname_pattern = (id) => { + let pathname = ''; + let separator = ''; + + for (const segment of get_route_segments(id)) { + if (optional_param_pattern.test(segment)) { + // the segment can be absent, so `${string}` absorbs the adjacent `/`. TypeScript does not + // match a literal against two consecutive placeholders, so only add one + if (!pathname.endsWith('${string}')) pathname += '${string}'; + } else { + pathname += separator + segment.replace(basic_param_pattern, '${string}'); + separator = '/'; + } + } + + return pathname; }; /** @@ -192,8 +208,7 @@ function generate_app_types(manifest_data, config, dir) { } for (const route of manifest_data.routes) { - const pathname = remove_group_segments(route.id); - let normalized_pathname = pathname.slice(1); + const pathname = get_pathname_pattern(route.id); /** @type {(path: string) => string} */ let serialise = s; @@ -207,11 +222,10 @@ function generate_app_types(manifest_data, config, dir) { dynamic_routes.push(route_type); - 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)) { + for (const p of get_pathnames_for_trailing_slash(pathname, route)) { pathnames.add(serialise(p)); } 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 ee5b3a316821..12ec641ad53a 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 @@ -106,5 +106,15 @@ 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'; + +// Test multiple params in a single segment +// eslint-disable-next-line @typescript-eslint/no-unused-vars +pathname = 'multi-param/1-2'; 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/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 fd66d3a4b778..9ae7066d03f4 100644 --- a/packages/kit/src/utils/routing.js +++ b/packages/kit/src/utils/routing.js @@ -260,7 +260,7 @@ function escape(str) { ); } -const basic_param_pattern = /\[(\[)?(\.\.\.)?([\w-]+?)(?:=([\w-]+))?\]\]?/g; +export const basic_param_pattern = /\[(\[)?(\.\.\.)?([\w-]+?)(?:=([\w-]+))?\]\]?/g; /** * Populate a route ID with params to resolve a pathname. From 18307d036265dcb4eb2d7d1cf48dbd50b15c8818 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:18:57 -0400 Subject: [PATCH 2/5] fix: don't let an omitted optional param merge into the next path segment --- packages/kit/src/core/sync/write_app_types.js | 35 +++++++++---------- .../sync/write_types/test/app-types/+page.js | 4 +++ 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/kit/src/core/sync/write_app_types.js b/packages/kit/src/core/sync/write_app_types.js index 7d57a89d71cd..f8e0537f206b 100644 --- a/packages/kit/src/core/sync/write_app_types.js +++ b/packages/kit/src/core/sync/write_app_types.js @@ -9,26 +9,25 @@ import { basic_param_pattern, get_route_segments } from '../../utils/routing.js' const optional_param_pattern = /^\[\[[\w-]+(?:=[\w-]+)?\]\]$/; /** - * Convert a route ID to a pathname (relative to the base path) in which each param - * is replaced with `${string}` + * Convert a route ID to the pathnames it can match (relative to the base path), in which each + * param is replaced with `${string}`. 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_pattern = (id) => { - let pathname = ''; - let separator = ''; +const get_pathname_patterns = (id) => { + let pathnames = ['']; for (const segment of get_route_segments(id)) { - if (optional_param_pattern.test(segment)) { - // the segment can be absent, so `${string}` absorbs the adjacent `/`. TypeScript does not - // match a literal against two consecutive placeholders, so only add one - if (!pathname.endsWith('${string}')) pathname += '${string}'; - } else { - pathname += separator + segment.replace(basic_param_pattern, '${string}'); - separator = '/'; - } + const optional = optional_param_pattern.test(segment); + const content = optional ? '${string}' : segment.replace(basic_param_pattern, '${string}'); + + pathnames = pathnames.flatMap((pathname) => { + const joined = pathname === '' ? content : `${pathname}/${content}`; + return optional ? [joined, pathname] : [joined]; + }); } - return pathname; + return [...new Set(pathnames)]; }; /** @@ -208,8 +207,6 @@ function generate_app_types(manifest_data, config, dir) { } for (const route of manifest_data.routes) { - const pathname = get_pathname_pattern(route.id); - /** @type {(path: string) => string} */ let serialise = s; @@ -225,8 +222,10 @@ function generate_app_types(manifest_data, config, dir) { serialise = (p) => `\`${p}\` & {}`; } - for (const p of get_pathnames_for_trailing_slash(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 12ec641ad53a..a159df1f29b5 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 @@ -114,6 +114,10 @@ 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 // eslint-disable-next-line @typescript-eslint/no-unused-vars From c6b0b9fb388fde8c49c70dc56802b6857e667e61 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:29:38 -0400 Subject: [PATCH 3/5] fix: a rest param can match zero segments, so its segment can be absent --- .changeset/app-types-path-template.md | 2 +- packages/kit/src/core/sync/write_app_types.js | 7 ++++--- .../kit/src/core/sync/write_types/test/app-types/+page.js | 6 +++++- .../write_types/test/app-types/files/[...path]/+page.js | 0 4 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 packages/kit/src/core/sync/write_types/test/app-types/files/[...path]/+page.js diff --git a/.changeset/app-types-path-template.md b/.changeset/app-types-path-template.md index a4d93cb620df..31e16212deed 100644 --- a/.changeset/app-types-path-template.md +++ b/.changeset/app-types-path-template.md @@ -2,4 +2,4 @@ '@sveltejs/kit': patch --- -fix: generate valid `Path` types for routes with an optional first segment or several params in one segment +fix: generate valid `Path` types for routes with optional params, rest params, or several params in one segment diff --git a/packages/kit/src/core/sync/write_app_types.js b/packages/kit/src/core/sync/write_app_types.js index f8e0537f206b..1e2f6330bc84 100644 --- a/packages/kit/src/core/sync/write_app_types.js +++ b/packages/kit/src/core/sync/write_app_types.js @@ -7,6 +7,7 @@ import { s } from '../../utils/misc.js'; import { basic_param_pattern, get_route_segments } from '../../utils/routing.js'; 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 @@ -18,12 +19,12 @@ const get_pathname_patterns = (id) => { let pathnames = ['']; for (const segment of get_route_segments(id)) { - const optional = optional_param_pattern.test(segment); - const content = optional ? '${string}' : segment.replace(basic_param_pattern, '${string}'); + const omittable = optional_param_pattern.test(segment) || rest_param_pattern.test(segment); + const content = omittable ? '${string}' : segment.replace(basic_param_pattern, '${string}'); pathnames = pathnames.flatMap((pathname) => { const joined = pathname === '' ? content : `${pathname}/${content}`; - return optional ? [joined, pathname] : [joined]; + return omittable ? [joined, pathname] : [joined]; }); } 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 a159df1f29b5..c05860a27e2d 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 @@ -120,5 +120,9 @@ pathname = 'xyzabout'; pathname = 'xyzdeep'; // Test multiple params in a single segment -// eslint-disable-next-line @typescript-eslint/no-unused-vars pathname = 'multi-param/1-2'; + +// Test rest params, which can match zero segments +pathname = 'files'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +pathname = 'files/a/b'; 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 From dac40be07b3673c9e8e5d896a2033757251fcbc1 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:39:03 -0400 Subject: [PATCH 4/5] fix: expand escape sequences instead of leaking them into the pathname types --- .changeset/app-types-path-template.md | 2 +- packages/kit/src/core/sync/write_app_types.js | 18 ++++++++++++++---- .../sync/write_types/test/app-types/+page.js | 8 +++++++- .../test/app-types/[x+2e]well-known/+page.js | 0 .../test/app-types/[x+40][handle]/+page.js | 0 packages/kit/src/utils/routing.js | 8 ++++---- 6 files changed, 26 insertions(+), 10 deletions(-) create mode 100644 packages/kit/src/core/sync/write_types/test/app-types/[x+2e]well-known/+page.js create mode 100644 packages/kit/src/core/sync/write_types/test/app-types/[x+40][handle]/+page.js diff --git a/.changeset/app-types-path-template.md b/.changeset/app-types-path-template.md index 31e16212deed..99c9c25d49cb 100644 --- a/.changeset/app-types-path-template.md +++ b/.changeset/app-types-path-template.md @@ -2,4 +2,4 @@ '@sveltejs/kit': patch --- -fix: generate valid `Path` types for routes with optional params, rest params, or several params in one segment +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 1e2f6330bc84..b3f5bcd9ea93 100644 --- a/packages/kit/src/core/sync/write_app_types.js +++ b/packages/kit/src/core/sync/write_app_types.js @@ -4,15 +4,21 @@ 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 { basic_param_pattern, get_route_segments } from '../../utils/routing.js'; +import { + decode_escape_sequence, + encode_pathname_chars, + get_route_segments, + segment_pattern +} from '../../utils/routing.js'; 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}`. 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 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) => { @@ -20,7 +26,11 @@ const get_pathname_patterns = (id) => { 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(basic_param_pattern, '${string}'); + 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}`; 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 c05860a27e2d..91d0e01e964b 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 @@ -124,5 +124,11 @@ pathname = 'multi-param/1-2'; // Test rest params, which can match zero segments pathname = 'files'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars 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/[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/utils/routing.js b/packages/kit/src/utils/routing.js index 2c77324d537c..f5b0d6b54d80 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.fromCharCode(...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') @@ -276,11 +276,11 @@ function escape(str) { .join(''); } -export const basic_param_pattern = /\[(\[)?(\.\.\.)?([\w-]+?)(?:=([\w-]+))?\]\]?/g; +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' ); From 15d2da79ecf3bfab3771638325213bbe5f186b61 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 31 Jul 2026 16:42:27 -0400 Subject: [PATCH 5/5] oops, bad merge --- packages/kit/src/core/sync/write_app_types.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/kit/src/core/sync/write_app_types.js b/packages/kit/src/core/sync/write_app_types.js index 7f981794730a..7a6724bf116c 100644 --- a/packages/kit/src/core/sync/write_app_types.js +++ b/packages/kit/src/core/sync/write_app_types.js @@ -226,9 +226,6 @@ function generate_app_types(manifest_data, config, dir) { app_route_ids.push(s(route.id)); } - const pathname = remove_group_segments(route.id); - let normalized_pathname = pathname.slice(1); - /** @type {(path: string) => string} */ let serialise = s;