Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/good-beds-say.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

chore: generate `$app/types` in a more Typescript-friendly way
5 changes: 3 additions & 2 deletions packages/kit/src/core/sync/sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,13 @@ import { write_non_ambient } from './write_non_ambient.js';
import { write_server } from './write_server.js';

/**
* Initialize SvelteKit's generated files.
* Initialize SvelteKit's generated files that only depend on the config and mode.
* @param {import('types').ValidatedConfig} config
* @param {string} mode
*/
export function init(config, mode) {
write_tsconfig(config.kit);
write_ambient(config.kit, mode);
write_non_ambient(config.kit);
}

/**
Expand All @@ -32,6 +31,7 @@ export function create(config) {
write_server(config, output);
write_root(manifest_data, output);
write_all_types(config, manifest_data);
write_non_ambient(config.kit, manifest_data);

return { manifest_data };
}
Expand Down Expand Up @@ -67,6 +67,7 @@ export function all_types(config, mode) {
init(config, mode);
const manifest_data = create_manifest_data({ config });
write_all_types(config, manifest_data);
write_non_ambient(config.kit, manifest_data);
}

/**
Expand Down
80 changes: 78 additions & 2 deletions packages/kit/src/core/sync/write_non_ambient.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import path from 'node:path';
import { GENERATED_COMMENT } from '../../constants.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('/');
};

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

/**
* Generate app types interface extension
* @param {import('types').ManifestData} manifest_data
*/
function generate_app_types(manifest_data) {
/** @type {Set<string>} */
const pathnames = new Set();

/** @type {string[]} */
const dynamic_routes = [];

/** @type {string[]} */
const layouts = [];

for (const route of manifest_data.routes) {
if (route.params.length > 0) {
const params = route.params.map((p) => `${p.name}${p.optional ? '?:' : ':'} string`);
const route_type = `${s(route.id)}: { ${params.join('; ')} }`;

dynamic_routes.push(route_type);

const pathname = remove_group_segments(route.id);
pathnames.add(`\`${replace_required_params(replace_optional_params(pathname))}\` & {}`);
} else {
const pathname = remove_group_segments(route.id);
pathnames.add(s(pathname));
}

/** @type {Map<string, boolean>} */
const child_params = new Map(route.params.map((p) => [p.name, p.optional]));

for (const child of manifest_data.routes.filter((r) => r.id.startsWith(route.id))) {
for (const p of child.params) {
if (!child_params.has(p.name)) {
child_params.set(p.name, true); // always optional
}
}
}

const layout_params = Array.from(child_params)
.map(([name, optional]) => `${name}${optional ? '?:' : ':'} string`)
.join('; ');

const layout_type = `${s(route.id)}: ${layout_params.length > 0 ? `{ ${layout_params} }` : 'Record<string, never>'}`;
layouts.push(layout_type);
}

return [
'declare module "$app/types" {',
'\texport interface AppTypes {',
`\t\tRouteId(): ${manifest_data.routes.map((r) => s(r.id)).join(' | ')};`,
`\t\tRouteParams(): {\n\t\t\t${dynamic_routes.join(';\n\t\t\t')}\n\t\t};`,
`\t\tLayoutParams(): {\n\t\t\t${layouts.join(';\n\t\t\t')}\n\t\t};`,
`\t\tPathname(): ${Array.from(pathnames).join(' | ')};`,
'\t\tResolvedPathname(): `${"" | `/${string}`}${ReturnType<AppTypes[\'Pathname\']>}`;',
`\t\tAsset(): ${manifest_data.assets.map((asset) => s('/' + asset.file)).join(' | ') || 'never'};`,
'\t}',
'}'
].join('\n');
}

/**
* Writes non-ambient declarations to the output directory
* @param {import('types').ValidatedKitConfig} config
* @param {import('types').ManifestData} manifest_data
*/
export function write_non_ambient(config) {
write_if_changed(path.join(config.outDir, 'non-ambient.d.ts'), template);
export function write_non_ambient(config, manifest_data) {
const app_types = generate_app_types(manifest_data);
const content = [template, app_types].join('\n\n');

write_if_changed(path.join(config.outDir, 'non-ambient.d.ts'), content);
}
72 changes: 0 additions & 72 deletions packages/kit/src/core/sync/write_types/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,8 @@ import MagicString from 'magic-string';
import { posixify, rimraf, walk } from '../../../utils/filesystem.js';
import { compact } from '../../../utils/array.js';
import { ts } from '../ts.js';
import { s } from '../../../utils/misc.js';
import { get_route_segments } from '../../../utils/routing.js';

const remove_relative_parent_traversals = (/** @type {string} */ path) =>
path.replace(/\.\.\//g, '');
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 is_whitespace = (/** @type {string} */ char) => /\s/.test(char);

/**
Expand Down Expand Up @@ -65,67 +54,6 @@ export function write_all_types(config, manifest_data) {
}
}

/** @type {Set<string>} */
const pathnames = new Set();

/** @type {string[]} */
const dynamic_routes = [];

/** @type {string[]} */
const layouts = [];

for (const route of manifest_data.routes) {
if (route.params.length > 0) {
const params = route.params.map((p) => `${p.name}${p.optional ? '?:' : ':'} string`);
const route_type = `${s(route.id)}: { ${params.join('; ')} }`;

dynamic_routes.push(route_type);

const pathname = remove_group_segments(route.id);
pathnames.add(`\`${replace_required_params(replace_optional_params(pathname))}\` & {}`);
} else {
const pathname = remove_group_segments(route.id);
pathnames.add(s(pathname));
}

/** @type {Map<string, boolean>} */
const child_params = new Map(route.params.map((p) => [p.name, p.optional]));

for (const child of manifest_data.routes.filter((r) => r.id.startsWith(route.id))) {
for (const p of child.params) {
if (!child_params.has(p.name)) {
child_params.set(p.name, true); // always optional
}
}
}

const layout_params = Array.from(child_params)
.map(([name, optional]) => `${name}${optional ? '?:' : ':'} string`)
.join('; ');

const layout_type = `${s(route.id)}: ${layout_params.length > 0 ? `{ ${layout_params} }` : 'undefined'}`;
layouts.push(layout_type);
}

try {
fs.mkdirSync(types_dir, { recursive: true });
} catch {}

fs.writeFileSync(
`${types_dir}/index.d.ts`,
[
`type DynamicRoutes = {\n\t${dynamic_routes.join(';\n\t')}\n};`,
`type Layouts = {\n\t${layouts.join(';\n\t')}\n};`,
// we enumerate these rather than doing `keyof Routes` so that the list is visible on hover
`export type RouteId = ${manifest_data.routes.map((r) => s(r.id)).join(' | ')};`,
'export type RouteParams<T extends RouteId> = T extends keyof DynamicRoutes ? DynamicRoutes[T] : Record<string, never>;',
'export type LayoutParams<T extends RouteId> = Layouts[T] | Record<string, never>;',
`export type Pathname = ${Array.from(pathnames).join(' | ')};`,
'export type ResolvedPathname = `${"" | `/${string}`}${Pathname}`;',
`export type Asset = ${manifest_data.assets.map((asset) => s('/' + asset.file)).join(' | ') || 'never'};`
].join('\n\n')
);

// Read/write meta data on each invocation, not once per node process,
// it could be invoked by another process in the meantime.
const meta_data_file = `${types_dir}/route_meta_data.json`;
Expand Down
17 changes: 9 additions & 8 deletions packages/kit/src/core/sync/write_types/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { assert, expect, test } from 'vitest';
import { rimraf } from '../../../utils/filesystem.js';
import create_manifest_data from '../create_manifest_data/index.js';
import { tweak_types, write_all_types } from './index.js';
import { write_non_ambient } from '../write_non_ambient.js';
import { validate_config } from '../../config/index.js';

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

write_all_types(initial, manifest);
write_non_ambient(initial.kit, manifest);
}

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

for (const dir of directories) {
run_test(dir);
}

try {
execSync('pnpm testtypes', { cwd });
} catch (e) {
console.error(/** @type {any} */ (e).stdout.toString());
throw new Error('Type tests failed');
try {
execSync('pnpm testtypes', { cwd: path.join(cwd, dir) });
} catch (e) {
console.error(/** @type {any} */ (e).stdout.toString());
throw new Error('Type tests failed');
}
}
});

Expand Down
20 changes: 20 additions & 0 deletions packages/kit/src/core/sync/write_types/test/actions/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "es2022",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"@sveltejs/kit": ["../../../../../exports/public"],
"types": ["../../../../../types/internal"],
"$app/types": ["../../../../../types/ambient.d.ts"]
}
},
"include": ["./**/*.js", "./**/*.ts", ".svelte-kit/non-ambient.d.ts"],
"exclude": ["..svelte-kit/**"]
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { RouteId, RouteParams, Pathname } from './.svelte-kit/types/index.d.ts';
import type { RouteId, RouteParams, Pathname } from '$app/types';

declare let id: RouteId;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"private": true,
"type": "module",
"scripts": {
"testtypes": "tsc"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "es2022",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"@sveltejs/kit": ["../../../../../exports/public"],
"types": ["../../../../../types/internal"],
"$app/types": ["../../../../../types/ambient.d.ts"]
}
},
"include": ["./**/*.js", "./**/*.ts", ".svelte-kit/non-ambient.d.ts"],
"exclude": ["..svelte-kit/**"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"private": true,
"type": "module",
"scripts": {
"testtypes": "tsc"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "es2022",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"@sveltejs/kit": ["../../../../../exports/public"],
"types": ["../../../../../types/internal"],
"$app/types": ["../../../../../types/ambient.d.ts"]
}
},
"include": ["./**/*.js", "./**/*.ts", ".svelte-kit/non-ambient.d.ts"],
"exclude": ["..svelte-kit/**"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"private": true,
"type": "module",
"scripts": {
"testtypes": "tsc"
}
}
20 changes: 20 additions & 0 deletions packages/kit/src/core/sync/write_types/test/layout/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "es2022",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"@sveltejs/kit": ["../../../../../exports/public"],
"types": ["../../../../../types/internal"],
"$app/types": ["../../../../../types/ambient.d.ts"]
}
},
"include": ["./**/*.js", "./**/*.ts", ".svelte-kit/non-ambient.d.ts"],
"exclude": ["..svelte-kit/**"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"private": true,
"type": "module",
"scripts": {
"testtypes": "tsc"
}
}
Loading
Loading