Skip to content

Commit e1e2795

Browse files
committed
chore: generate $app/types in a more Typescript-friendly way
This resolves the "ignore me TS, this module will be available later"-hack we did in #13864: Instead of ts-ignoring the `$app/types` import, we now generate that in advance. We didn't do that previously because there's no way to do declaration merging with types - but you can do it with interfaces, so instead we now have an interface whose properties we declaration-merge, and the types just forward those. The wrinkle is that we cannot make the properties be the types directly, else you get a "not the same types" error. Instead we're using functions because they are merged into overloads. For this to properly work across tests (not polluting each other) I had to adjust the test infrastructure a bit, now each test project is run separately. Sadly that means it takes longer, not sure why `tsc` is so slow here.
1 parent bfdb564 commit e1e2795

File tree

29 files changed

+470
-102
lines changed

29 files changed

+470
-102
lines changed

.changeset/good-beds-say.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
chore: generate `$app/types` in a more Typescript-friendly way

packages/kit/src/core/sync/sync.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export function create(config) {
3232
write_server(config, output);
3333
write_root(manifest_data, output);
3434
write_all_types(config, manifest_data);
35+
write_non_ambient(config.kit, manifest_data);
3536

3637
return { manifest_data };
3738
}
@@ -67,6 +68,7 @@ export function all_types(config, mode) {
6768
init(config, mode);
6869
const manifest_data = create_manifest_data({ config });
6970
write_all_types(config, manifest_data);
71+
write_non_ambient(config.kit, manifest_data);
7072
}
7173

7274
/**

packages/kit/src/core/sync/write_non_ambient.js

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
import path from 'node:path';
22
import { GENERATED_COMMENT } from '../../constants.js';
33
import { write_if_changed } from './utils.js';
4+
import { s } from '../../utils/misc.js';
5+
import { get_route_segments } from '../../utils/routing.js';
6+
7+
const replace_optional_params = (/** @type {string} */ id) =>
8+
id.replace(/\/\[\[[^\]]+\]\]/g, '${string}');
9+
const replace_required_params = (/** @type {string} */ id) =>
10+
id.replace(/\/\[[^\]]+\]/g, '/${string}');
11+
/** Convert route ID to pathname by removing layout groups */
12+
const remove_group_segments = (/** @type {string} */ id) => {
13+
return '/' + get_route_segments(id).join('/');
14+
};
415

516
// `declare module "svelte/elements"` needs to happen in a non-ambient module, and dts-buddy generates one big ambient module,
617
// so we can't add it there - therefore generate the typings ourselves here.
@@ -33,10 +44,79 @@ declare module "svelte/elements" {
3344
export {};
3445
`;
3546

47+
/**
48+
* Generate app types interface extension
49+
* @param {import('types').ManifestData} manifest_data
50+
*/
51+
function generate_app_types(manifest_data) {
52+
/** @type {Set<string>} */
53+
const pathnames = new Set();
54+
55+
/** @type {string[]} */
56+
const dynamic_routes = [];
57+
58+
/** @type {string[]} */
59+
const layouts = [];
60+
61+
for (const route of manifest_data.routes) {
62+
if (route.params.length > 0) {
63+
const params = route.params.map((p) => `${p.name}${p.optional ? '?:' : ':'} string`);
64+
const route_type = `${s(route.id)}: { ${params.join('; ')} }`;
65+
66+
dynamic_routes.push(route_type);
67+
68+
const pathname = remove_group_segments(route.id);
69+
pathnames.add(`\`${replace_required_params(replace_optional_params(pathname))}\` & {}`);
70+
} else {
71+
const pathname = remove_group_segments(route.id);
72+
pathnames.add(s(pathname));
73+
}
74+
75+
/** @type {Map<string, boolean>} */
76+
const child_params = new Map(route.params.map((p) => [p.name, p.optional]));
77+
78+
for (const child of manifest_data.routes.filter((r) => r.id.startsWith(route.id))) {
79+
for (const p of child.params) {
80+
if (!child_params.has(p.name)) {
81+
child_params.set(p.name, true); // always optional
82+
}
83+
}
84+
}
85+
86+
const layout_params = Array.from(child_params)
87+
.map(([name, optional]) => `${name}${optional ? '?:' : ':'} string`)
88+
.join('; ');
89+
90+
const layout_type = `${s(route.id)}: ${layout_params.length > 0 ? `{ ${layout_params} }` : 'Record<string, never>'}`;
91+
layouts.push(layout_type);
92+
}
93+
94+
return [
95+
'declare module "$app/types" {',
96+
'\texport interface AppTypes {',
97+
`\t\tRouteId(): ${manifest_data.routes.map((r) => s(r.id)).join(' | ')};`,
98+
`\t\tRouteParams(): {\n\t\t\t${dynamic_routes.join(';\n\t\t\t')}\n\t\t};`,
99+
`\t\tLayoutParams(): {\n\t\t\t${layouts.join(';\n\t\t\t')}\n\t\t};`,
100+
`\t\tPathname(): ${Array.from(pathnames).join(' | ')};`,
101+
'\t\tResolvedPathname(): `${"" | `/${string}`}${ReturnType<AppTypes[\'Pathname\']>}`;',
102+
`\t\tAsset(): ${manifest_data.assets.map((asset) => s('/' + asset.file)).join(' | ') || 'never'};`,
103+
'\t}',
104+
'}'
105+
].join('\n');
106+
}
107+
36108
/**
37109
* Writes non-ambient declarations to the output directory
38110
* @param {import('types').ValidatedKitConfig} config
111+
* @param {import('types').ManifestData} [manifest_data]
39112
*/
40-
export function write_non_ambient(config) {
41-
write_if_changed(path.join(config.outDir, 'non-ambient.d.ts'), template);
113+
export function write_non_ambient(config, manifest_data) {
114+
let content = template;
115+
116+
if (manifest_data) {
117+
const app_types = generate_app_types(manifest_data);
118+
content = [template, app_types].join('\n\n');
119+
}
120+
121+
write_if_changed(path.join(config.outDir, 'non-ambient.d.ts'), content);
42122
}

packages/kit/src/core/sync/write_types/index.js

Lines changed: 0 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,8 @@ import MagicString from 'magic-string';
55
import { posixify, rimraf, walk } from '../../../utils/filesystem.js';
66
import { compact } from '../../../utils/array.js';
77
import { ts } from '../ts.js';
8-
import { s } from '../../../utils/misc.js';
9-
import { get_route_segments } from '../../../utils/routing.js';
10-
118
const remove_relative_parent_traversals = (/** @type {string} */ path) =>
129
path.replace(/\.\.\//g, '');
13-
const replace_optional_params = (/** @type {string} */ id) =>
14-
id.replace(/\/\[\[[^\]]+\]\]/g, '${string}');
15-
const replace_required_params = (/** @type {string} */ id) =>
16-
id.replace(/\/\[[^\]]+\]/g, '/${string}');
17-
/** Convert route ID to pathname by removing layout groups */
18-
const remove_group_segments = (/** @type {string} */ id) => {
19-
return '/' + get_route_segments(id).join('/');
20-
};
2110
const is_whitespace = (/** @type {string} */ char) => /\s/.test(char);
2211

2312
/**
@@ -65,67 +54,6 @@ export function write_all_types(config, manifest_data) {
6554
}
6655
}
6756

68-
/** @type {Set<string>} */
69-
const pathnames = new Set();
70-
71-
/** @type {string[]} */
72-
const dynamic_routes = [];
73-
74-
/** @type {string[]} */
75-
const layouts = [];
76-
77-
for (const route of manifest_data.routes) {
78-
if (route.params.length > 0) {
79-
const params = route.params.map((p) => `${p.name}${p.optional ? '?:' : ':'} string`);
80-
const route_type = `${s(route.id)}: { ${params.join('; ')} }`;
81-
82-
dynamic_routes.push(route_type);
83-
84-
const pathname = remove_group_segments(route.id);
85-
pathnames.add(`\`${replace_required_params(replace_optional_params(pathname))}\` & {}`);
86-
} else {
87-
const pathname = remove_group_segments(route.id);
88-
pathnames.add(s(pathname));
89-
}
90-
91-
/** @type {Map<string, boolean>} */
92-
const child_params = new Map(route.params.map((p) => [p.name, p.optional]));
93-
94-
for (const child of manifest_data.routes.filter((r) => r.id.startsWith(route.id))) {
95-
for (const p of child.params) {
96-
if (!child_params.has(p.name)) {
97-
child_params.set(p.name, true); // always optional
98-
}
99-
}
100-
}
101-
102-
const layout_params = Array.from(child_params)
103-
.map(([name, optional]) => `${name}${optional ? '?:' : ':'} string`)
104-
.join('; ');
105-
106-
const layout_type = `${s(route.id)}: ${layout_params.length > 0 ? `{ ${layout_params} }` : 'undefined'}`;
107-
layouts.push(layout_type);
108-
}
109-
110-
try {
111-
fs.mkdirSync(types_dir, { recursive: true });
112-
} catch {}
113-
114-
fs.writeFileSync(
115-
`${types_dir}/index.d.ts`,
116-
[
117-
`type DynamicRoutes = {\n\t${dynamic_routes.join(';\n\t')}\n};`,
118-
`type Layouts = {\n\t${layouts.join(';\n\t')}\n};`,
119-
// we enumerate these rather than doing `keyof Routes` so that the list is visible on hover
120-
`export type RouteId = ${manifest_data.routes.map((r) => s(r.id)).join(' | ')};`,
121-
'export type RouteParams<T extends RouteId> = T extends keyof DynamicRoutes ? DynamicRoutes[T] : Record<string, never>;',
122-
'export type LayoutParams<T extends RouteId> = Layouts[T] | Record<string, never>;',
123-
`export type Pathname = ${Array.from(pathnames).join(' | ')};`,
124-
'export type ResolvedPathname = `${"" | `/${string}`}${Pathname}`;',
125-
`export type Asset = ${manifest_data.assets.map((asset) => s('/' + asset.file)).join(' | ') || 'never'};`
126-
].join('\n\n')
127-
);
128-
12957
// Read/write meta data on each invocation, not once per node process,
13058
// it could be invoked by another process in the meantime.
13159
const meta_data_file = `${types_dir}/route_meta_data.json`;

packages/kit/src/core/sync/write_types/index.spec.js

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { assert, expect, test } from 'vitest';
66
import { rimraf } from '../../../utils/filesystem.js';
77
import create_manifest_data from '../create_manifest_data/index.js';
88
import { tweak_types, write_all_types } from './index.js';
9+
import { write_non_ambient } from '../write_non_ambient.js';
910
import { validate_config } from '../../config/index.js';
1011

1112
const cwd = fileURLToPath(new URL('./test', import.meta.url));
@@ -28,9 +29,10 @@ function run_test(dir) {
2829
});
2930

3031
write_all_types(initial, manifest);
32+
write_non_ambient(initial.kit, manifest);
3133
}
3234

33-
test('Creates correct $types', { timeout: 10000 }, () => {
35+
test('Creates correct $types', { timeout: 60000 }, () => {
3436
// To save us from creating a real SvelteKit project for each of the tests,
3537
// we first run the type generation directly for each test case, and then
3638
// call `tsc` to check that the generated types are valid.
@@ -40,13 +42,12 @@ test('Creates correct $types', { timeout: 10000 }, () => {
4042

4143
for (const dir of directories) {
4244
run_test(dir);
43-
}
44-
45-
try {
46-
execSync('pnpm testtypes', { cwd });
47-
} catch (e) {
48-
console.error(/** @type {any} */ (e).stdout.toString());
49-
throw new Error('Type tests failed');
45+
try {
46+
execSync('pnpm testtypes', { cwd: path.join(cwd, dir) });
47+
} catch (e) {
48+
console.error(/** @type {any} */ (e).stdout.toString());
49+
throw new Error('Type tests failed');
50+
}
5051
}
5152
});
5253

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"compilerOptions": {
3+
"allowJs": true,
4+
"checkJs": true,
5+
"noEmit": true,
6+
"strict": true,
7+
"target": "es2022",
8+
"module": "es2022",
9+
"moduleResolution": "bundler",
10+
"allowSyntheticDefaultImports": true,
11+
"baseUrl": ".",
12+
"paths": {
13+
"@sveltejs/kit": ["../../../../../exports/public"],
14+
"types": ["../../../../../types/internal"],
15+
"$app/types": ["../../../../../types/ambient.d.ts"]
16+
}
17+
},
18+
"include": ["./**/*.js", "./**/*.ts", ".svelte-kit/non-ambient.d.ts"],
19+
"exclude": ["..svelte-kit/**"]
20+
}

packages/kit/src/core/sync/write_types/test/app-types/+page.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { RouteId, RouteParams, Pathname } from './.svelte-kit/types/index.d.ts';
1+
import type { RouteId, RouteParams, Pathname } from '$app/types';
22

33
declare let id: RouteId;
44

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"private": true,
3+
"type": "module",
4+
"scripts": {
5+
"testtypes": "tsc"
6+
}
7+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"compilerOptions": {
3+
"allowJs": true,
4+
"checkJs": true,
5+
"noEmit": true,
6+
"strict": true,
7+
"target": "es2022",
8+
"module": "es2022",
9+
"moduleResolution": "bundler",
10+
"allowSyntheticDefaultImports": true,
11+
"baseUrl": ".",
12+
"paths": {
13+
"@sveltejs/kit": ["../../../../../exports/public"],
14+
"types": ["../../../../../types/internal"],
15+
"$app/types": ["../../../../../types/ambient.d.ts"]
16+
}
17+
},
18+
"include": ["./**/*.js", "./**/*.ts", ".svelte-kit/non-ambient.d.ts"],
19+
"exclude": ["..svelte-kit/**"]
20+
}

0 commit comments

Comments
 (0)