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
6 changes: 6 additions & 0 deletions .changeset/famous-bars-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@sveltejs/kit': major
---

breaking: rename `Pathname` type to `Path` and `Asset` to `AssetPath`
breaking: remove leading `/` from `Path` and `AssetPath`
14 changes: 7 additions & 7 deletions documentation/docs/98-reference/20-$app-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ This module contains generated types for the routes in your app.
import type { RouteId, RouteParams, LayoutParams } from '$app/types';
```

## Asset
## AssetPath

A union of all the filenames of assets contained in your `static` directory, plus a `string` wildcard for asset paths generated from `import` declarations.
A union of all the filenames of assets contained in your `static` directory, relative to the `base` path.

<div class="ts-block">

```dts
type Asset = '/favicon.png' | '/robots.txt' | (string & {});
type AssetPath = 'favicon.png' | 'robots.txt' | (string & {});
```

</div>
Expand All @@ -37,21 +37,21 @@ type RouteId = '/' | '/my-route' | '/my-other-route/[param]';

</div>

## Pathname
## Path

A union of all valid pathnames in your app.
A union of all valid paths in your app, relative to the `base` path.

<div class="ts-block">

```dts
type Pathname = '/' | '/my-route' | `/my-other-route/${string}` & {};
type Path = '' | 'my-route' | `my-other-route/${string}` & {};
```

</div>

## ResolvedPathname

Similar to `Pathname`, but possibly prefixed with a [base path](configuration#paths). Used for `page.url.pathname`.
Similar to `Path`, but prefixed with a [base path](configuration#paths). Used for `page.url.pathname`.

<div class="ts-block">

Expand Down
16 changes: 6 additions & 10 deletions packages/kit/src/core/sync/write_non_ambient.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,6 @@ const remove_group_segments = (/** @type {string} */ id) => {
* @returns {string[]}
*/
function get_pathnames_for_trailing_slash(pathname, route) {
if (pathname === '/') {
return [pathname];
}
Comment on lines -25 to -27

@teemingc teemingc Jul 20, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we keep this check but return early on pathname === ''? Because the trailing slash handling below it doesn't make sense for the root page

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's already correct — previously the root would become '/', but now it becomes '' for trailingSlash = 'never', '/' for always, and '' | '/' for ignore, which I think is what we want. if we reinstated this and returned early it would always be ''

@teemingc teemingc Jul 20, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mm you're right about that. But this also causes ResolvedPathname to accept '//' right now. At

'\t\tResolvedPathname(): `${"/" | `/${string}/`}${ReturnType<AppTypes[\'Path\']>}`;',
it's formed by '/' plus the values of Path so we need to adjust that logic instead

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's probably unavoidable, sadly, because of /${string}/. It's already like this today

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(but if there's a fix I'm missing we can always patch later)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah right. I'll just open an issue


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

Expand Down Expand Up @@ -201,7 +197,7 @@ function generate_app_types(manifest_data, config) {

for (const route of manifest_data.routes) {
const pathname = remove_group_segments(route.id);
let normalized_pathname = pathname;
let normalized_pathname = pathname.slice(1);

/** @type {(path: string) => string} */
let serialise = s;
Expand All @@ -215,7 +211,7 @@ function generate_app_types(manifest_data, config) {

dynamic_routes.push(route_type);

normalized_pathname = replace_required_params(replace_optional_params(pathname));
normalized_pathname = replace_required_params(replace_optional_params(pathname)).slice(1);
serialise = (p) => `\`${p}\` & {}`;
}

Expand All @@ -240,17 +236,17 @@ function generate_app_types(manifest_data, config) {
layouts.push(`${s(route.id)}: ${layout_type}`);
}

const assets = manifest_data.assets.map((asset) => s('/' + asset.file));
const assets = manifest_data.assets.map((asset) => s(asset.file));

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(): ${assets.concat('string & {}').join(' | ')};`,
`\t\tPath(): ${Array.from(pathnames).join(' | ')};`,
'\t\tResolvedPathname(): `${"/" | `/${string}/`}${ReturnType<AppTypes[\'Path\']>}`;',
`\t\tAssetPath(): ${assets.join(' | ') || 'never'};`,
'\t}',
'}'
].join('\n');
Expand Down
46 changes: 23 additions & 23 deletions packages/kit/src/core/sync/write_types/test/app-types/+page.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,44 +67,44 @@ const matcherParentLayoutParams = {};

matcherParentLayoutParams.locale = 'fr'; // any string

/** @type {import('$app/types').Pathname} */
/** @type {import('$app/types').Path} */
let pathname;

// @ts-expect-error route doesn't exist
pathname = '/nope';
pathname = 'nope';
// @ts-expect-error route doesn't exist
pathname = '/foo';
pathname = 'foo';
// @ts-expect-error route doesn't exist
pathname = '/foo/';
pathname = '/foo/1/2'; // okay
pathname = '/foo/1/2/'; // okay
pathname = 'foo/';
pathname = 'foo/1/2'; // okay
pathname = 'foo/1/2/'; // okay

// Test layout groups
pathname = '/path-a';
pathname = 'path-a';
// @ts-expect-error default trailing slash is never, so we should not have it here
pathname = '/path-a/';
pathname = 'path-a/';
// @ts-expect-error layout group names are NOT part of the pathname type
pathname = '/(group)/path-a';
pathname = '(group)/path-a';

// Test trailing-slash - always
pathname = '/path-a/trailing-slash/always/';
pathname = '/path-a/trailing-slash/always/endpoint/';
pathname = '/path-a/trailing-slash/always/layout/inside/';
pathname = 'path-a/trailing-slash/always/';
pathname = 'path-a/trailing-slash/always/endpoint/';
pathname = 'path-a/trailing-slash/always/layout/inside/';

// Test trailing-slash - ignore
pathname = '/path-a/trailing-slash/ignore';
pathname = '/path-a/trailing-slash/ignore/';
pathname = '/path-a/trailing-slash/ignore/endpoint';
pathname = '/path-a/trailing-slash/ignore/endpoint/';
pathname = '/path-a/trailing-slash/ignore/layout/inside';
pathname = '/path-a/trailing-slash/ignore/layout/inside/';
pathname = 'path-a/trailing-slash/ignore';
pathname = 'path-a/trailing-slash/ignore/';
pathname = 'path-a/trailing-slash/ignore/endpoint';
pathname = 'path-a/trailing-slash/ignore/endpoint/';
pathname = 'path-a/trailing-slash/ignore/layout/inside';
pathname = 'path-a/trailing-slash/ignore/layout/inside/';

// Test trailing-slash - never (default)
pathname = '/path-a/trailing-slash/never';
pathname = '/path-a/trailing-slash/never/endpoint';
pathname = '/path-a/trailing-slash/never/layout/inside';
pathname = 'path-a/trailing-slash/never';
pathname = 'path-a/trailing-slash/never/endpoint';
pathname = 'path-a/trailing-slash/never/layout/inside';

// Test trailing-slash - always (endpoint) and never (page)
pathname = '/path-a/trailing-slash/mixed';
pathname = 'path-a/trailing-slash/mixed';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
pathname = '/path-a/trailing-slash/mixed/';
pathname = 'path-a/trailing-slash/mixed/';
40 changes: 27 additions & 13 deletions packages/kit/src/runtime/app/paths/client.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
/** @import { Asset, RouteId, RouteIdWithSearchOrHash, Pathname, PathnameWithSearchOrHash, ResolvedPathname, RouteParams } from '$app/types' */
/** @import { AssetPath, RouteId, RouteIdWithSearchOrHash, Path, PathnameWithSearchOrHash, ResolvedPathname, RouteParams } from '$app/types' */
/** @import { ResolveArgs } from './types.js' */
import { base, assets, hash_routing } from './internal/client.js';
import { resolve_route } from '../../../utils/routing.js';
import { get_navigation_intent } from '../../client/client.js';
import { DEV } from 'esm-env';

/**
* Resolve the URL of an asset in your `static` directory, by prefixing it with [`config.paths.assets`](https://svelte.dev/docs/kit/configuration#paths) if configured, or otherwise by prefixing it with the base path.
Expand All @@ -15,15 +16,24 @@ import { get_navigation_intent } from '../../client/client.js';
* import { asset } from '$app/paths';
* </script>
*
* <img alt="a potato" src={asset('/potato.jpg')} />
* <img alt="a potato" src={asset('potato.jpg')} />
* ```
* @since 2.26
*
* @param {Asset} file
* @param {AssetPath} file
* @returns {string}
*/
export function asset(file) {
return (assets || base) + file;
// TODO 4.0 remove this
if (file[0] === '/') {
if (DEV) {
console.warn(`\`asset('${file}')\` should now be \`asset('${file.slice(1)}')\``);
}

file = file.slice(1);
}

return (assets || base) + '/' + file;
}

const pathname_prefix = hash_routing ? '#' : '';
Expand All @@ -38,7 +48,7 @@ const pathname_prefix = hash_routing ? '#' : '';
* import { resolve } from '$app/paths';
*
* // using a pathname
* const resolved = resolve(`/blog/hello-world`);
* const resolved = resolve(`blog/hello-world`);
*
* // using a route ID plus parameters
* const resolved = resolve('/blog/[slug]', {
Expand All @@ -52,14 +62,18 @@ const pathname_prefix = hash_routing ? '#' : '';
* @returns {ResolvedPathname}
*/
export function resolve(...args) {
if (!args[0].startsWith('/')) {
throw new Error(
`Cannot use \`resolve(...)\` with a non-absolute pathname or route ID (got "${args[0]}"). ` +
'`resolve` is only for internal pathnames and route IDs; external URLs should be used directly.'
);
if (args[0][0] === '/') {
const [id, params] = args;

// route ID
if (id.includes('[') && !params) {
throw new Error(`Missing params for dynamic route ID ${id}`);
}

return base + pathname_prefix + resolve_route(args[0], args[1] ?? {});
}

return base + pathname_prefix + resolve_route(args[0], args[1] ?? {});
return base + pathname_prefix + '/' + args[0];
}

/**
Expand All @@ -69,7 +83,7 @@ export function resolve(...args) {
* ```js
* import { match } from '$app/paths';
*
* const route = await match('/blog/hello-world');
* const route = await match('blog/hello-world');
*
* if (route?.id === '/blog/[slug]') {
* const slug = route.params.slug;
Expand All @@ -79,7 +93,7 @@ export function resolve(...args) {
* ```
* @since 2.52.0
*
* @param {Pathname | URL | (string & {})} url
* @param {Path | URL | (string & {})} url
* @returns {Promise<{ [K in RouteId]: { id: K; params: RouteParams<K>; } }[RouteId] | null>}
*/
export async function match(url) {
Expand Down
31 changes: 22 additions & 9 deletions packages/kit/src/runtime/app/paths/server.js

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there no good way to share the logic between client and server here? Weird to have this specific pathname handling duplicated

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not really, no. one uses shared global state, one needs AsyncLocalStorage. you could maybe try and tease bits of them apart to share implementations but it would end up looking worse I think

Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,36 @@ import { add_data_suffix } from '../../pathname.js';
import { try_get_request_store } from '@sveltejs/kit/internal/server';
import { manifest } from '../../server/internal.js';
import { get_hooks } from '__SERVER__/internal.js';
import { DEV } from 'esm-env';

/** @type {import('./client.js').asset} */
export function asset(file) {
// @ts-expect-error we use the `resolve` mechanism, but with the 'wrong' input
return assets && assets !== base ? assets + file : resolve(file);
// TODO 4.0 remove this
if (file[0] === '/') {
if (DEV) {
console.warn(`\`asset('${file}')\` should now be \`asset('${file.slice(1)}')\``);
}

file = file.slice(1);
}

return assets !== base ? `${assets}/${file}` : resolve(file);
}

/** @type {import('./client.js').resolve} */
export function resolve(id, params) {
if (!id.startsWith('/')) {
throw new Error(
`Cannot use \`resolve(...)\` with a non-absolute pathname or route ID (got "${id}"). ` +
'`resolve` is only for internal pathnames and route IDs; external URLs should be used directly.'
);
}
let resolved;

if (id[0] === '/') {
Comment thread
vercel[bot] marked this conversation as resolved.
// route ID
if (id.includes('[') && !params) {
throw new Error(`Missing params for dynamic route ID ${id}`);
}

const resolved = resolve_route(id, params ?? {});
resolved = resolve_route(id, params ?? {});
} else {
resolved = '/' + id;
}

if (relative) {
const store = try_get_request_store();
Expand Down
30 changes: 11 additions & 19 deletions packages/kit/src/runtime/app/paths/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,15 @@
import {
PathnameWithSearchOrHash,
RouteId,
RouteIdWithSearchOrHash,
RouteParams
} from '$app/types';
import { RouteId, RouteParams } from '$app/types';

type StripSearchOrHash<T extends string> = T extends `${infer Pathname}?${string}`
? Pathname
: T extends `${infer Pathname}#${string}`
? Pathname
type StripSearchOrHash<T extends string> = T extends `${infer U}?${string}`
? U
: T extends `${infer U}#${string}`
? U
: T;

export type ResolveArgs<T extends RouteIdWithSearchOrHash | PathnameWithSearchOrHash> =
T extends RouteId
? RouteParams<T> extends Record<string, never>
export type ResolveArgs<T> = T extends `/${string}`
? StripSearchOrHash<T> extends infer U extends RouteId
? RouteParams<U> extends Record<string, never>
? [route: T]
: [route: T, params: RouteParams<T>]
: StripSearchOrHash<T> extends infer U extends RouteId
? RouteParams<U> extends Record<string, never>
? [route: T]
: [route: T, params: RouteParams<U>]
: [route: T];
: [route: T, params: RouteParams<U>]
: [never]
: [pathname: T];
21 changes: 9 additions & 12 deletions packages/kit/src/types/ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,9 @@ declare module '$app/types' {
RouteId(): string;
RouteParams(): Record<string, Record<string, string>>;
LayoutParams(): Record<string, Record<string, string>>;
Pathname(): string;
Path(): string;
ResolvedPathname(): string;
Asset(): string;
AssetPath(): string;
}

/**
Expand Down Expand Up @@ -129,25 +129,22 @@ declare module '$app/types' {
: Record<string, never>;

/**
* A union of all valid pathnames in your app.
* A union of all valid paths in your app, relative to the `base` path.
*/
export type Pathname = ReturnType<AppTypes['Pathname']>;
export type Path = ReturnType<AppTypes['Path']>;

/**
* `Pathname`, but possibly suffixed with a search string and/or hash.
* `Path`, but possibly suffixed with a search string and/or hash.
*/
export type PathnameWithSearchOrHash =
| Pathname
| `${Pathname}?${string}`
| `${Pathname}#${string}`;
export type PathnameWithSearchOrHash = Path | `${Path}?${string}` | `${Path}#${string}`;

/**
* `Pathname`, but possibly prefixed with a base path. Used for `page.url.pathname`.
* `Path`, but prefixed with a base path. Used for `page.url.pathname`.
*/
export type ResolvedPathname = ReturnType<AppTypes['ResolvedPathname']>;

/**
* A union of all the filenames of assets contained in your `static` directory.
* A union of all the filenames of assets contained in your `static` directory, relative to the `base` path.
*/
export type Asset = ReturnType<AppTypes['Asset']>;
export type AssetPath = ReturnType<AppTypes['AssetPath']>;
}
4 changes: 2 additions & 2 deletions packages/kit/test/apps/options-2/src/routes/+error.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@

<h1 data-testid="error-status">{page.status}</h1>

<p data-testid="base">base: {resolve('/')}</p>
<p data-testid="assets">assets: {asset('/')}</p>
<p data-testid="base">base: {resolve('')}</p>
<p data-testid="assets">assets: {asset('answer.txt').replace('answer.txt', '')}</p>
Loading
Loading