Skip to content
5 changes: 5 additions & 0 deletions .changeset/app-types-path-template.md
Original file line number Diff line number Diff line change
@@ -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
52 changes: 38 additions & 14 deletions packages/kit/src/core/sync/write_app_types.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } 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)];
};

/**
Expand Down Expand Up @@ -200,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;

Expand All @@ -215,12 +238,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<string, never>';
Expand Down
26 changes: 25 additions & 1 deletion packages/kit/src/core/sync/write_types/test/app-types/+page.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,5 +112,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';
6 changes: 3 additions & 3 deletions packages/kit/src/utils/routing.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}

Expand All @@ -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')
Expand Down Expand Up @@ -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'
);
Expand Down
Loading