sv-router 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +17 -17
- package/src/attachments.svelte.js +47 -0
- package/src/create-router.svelte.js +23 -15
- package/src/helpers/is-active.js +2 -1
- package/src/helpers/match-route.js +5 -3
- package/src/helpers/preload.js +1 -0
- package/src/index.d.ts +31 -8
- package/src/index.js +1 -1
- package/src/actions.svelte.js +0 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sv-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "Modern Svelte Routing",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|
|
@@ -36,29 +36,29 @@
|
|
|
36
36
|
"esm-env": "^1.2.2"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@changesets/cli": "^2.
|
|
40
|
-
"@eslint/js": "^
|
|
41
|
-
"@sveltejs/vite-plugin-svelte": "^
|
|
39
|
+
"@changesets/cli": "^2.30.0",
|
|
40
|
+
"@eslint/js": "^10.0.1",
|
|
41
|
+
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
42
42
|
"@testing-library/jest-dom": "^6.9.1",
|
|
43
|
-
"@testing-library/svelte": "^5.
|
|
43
|
+
"@testing-library/svelte": "^5.3.1",
|
|
44
44
|
"@testing-library/user-event": "^14.6.1",
|
|
45
|
-
"@types/node": "^24.
|
|
46
|
-
"eslint": "^
|
|
45
|
+
"@types/node": "^24.12.0",
|
|
46
|
+
"eslint": "^10.0.3",
|
|
47
47
|
"eslint-config-prettier": "^10.1.8",
|
|
48
48
|
"eslint-plugin-simple-import-sort": "^12.1.1",
|
|
49
|
-
"eslint-plugin-svelte": "^3.
|
|
50
|
-
"eslint-plugin-unicorn": "^
|
|
51
|
-
"globals": "^17.
|
|
52
|
-
"happy-dom": "^20.
|
|
53
|
-
"prettier": "^3.
|
|
49
|
+
"eslint-plugin-svelte": "^3.15.2",
|
|
50
|
+
"eslint-plugin-unicorn": "^63.0.0",
|
|
51
|
+
"globals": "^17.4.0",
|
|
52
|
+
"happy-dom": "^20.8.4",
|
|
53
|
+
"prettier": "^3.8.1",
|
|
54
54
|
"prettier-plugin-jsdoc": "^1.8.0",
|
|
55
|
-
"prettier-plugin-svelte": "^3.
|
|
56
|
-
"svelte-check": "^4.
|
|
55
|
+
"prettier-plugin-svelte": "^3.5.1",
|
|
56
|
+
"svelte-check": "^4.4.5",
|
|
57
57
|
"type-testing": "^0.2.0",
|
|
58
58
|
"typescript": "^5.9.3",
|
|
59
|
-
"typescript-eslint": "^8.
|
|
60
|
-
"vite": "^
|
|
61
|
-
"vitest": "^4.0
|
|
59
|
+
"typescript-eslint": "^8.57.1",
|
|
60
|
+
"vite": "^8.0.0",
|
|
61
|
+
"vitest": "^4.1.0"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
64
|
"svelte": "^5"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { base, location } from './create-router.svelte.js';
|
|
2
|
+
|
|
3
|
+
/** @type {import('./index.d.ts').IsActiveLink} */
|
|
4
|
+
export function isActiveLink({ className = 'is-active', startsWith = false } = {}) {
|
|
5
|
+
return (node) => {
|
|
6
|
+
if (node.tagName !== 'A') {
|
|
7
|
+
throw new Error('isActiveLink can only be used on <a> elements');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
$effect(() => {
|
|
11
|
+
let pathname;
|
|
12
|
+
if (base.name === '#') {
|
|
13
|
+
pathname = new URL(node.href).hash.slice(1);
|
|
14
|
+
} else {
|
|
15
|
+
pathname = new URL(node.href).pathname;
|
|
16
|
+
}
|
|
17
|
+
const tokens = className.split(' ').filter(Boolean) ?? [];
|
|
18
|
+
if (startsWith ? location.pathname.startsWith(pathname) : location.pathname === pathname) {
|
|
19
|
+
node.classList.add(...tokens);
|
|
20
|
+
} else {
|
|
21
|
+
node.classList.remove(...tokens);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @type {import('./index.d.ts').IsActiveLinkAction} */
|
|
28
|
+
export function isActiveLinkAction(node, { className = 'is-active', startsWith = false } = {}) {
|
|
29
|
+
if (node.tagName !== 'A') {
|
|
30
|
+
throw new Error('isActiveLink can only be used on <a> elements');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
$effect(() => {
|
|
34
|
+
let pathname;
|
|
35
|
+
if (base.name === '#') {
|
|
36
|
+
pathname = new URL(node.href).hash.slice(1);
|
|
37
|
+
} else {
|
|
38
|
+
pathname = new URL(node.href).pathname;
|
|
39
|
+
}
|
|
40
|
+
const tokens = className.split(' ').filter(Boolean) ?? [];
|
|
41
|
+
if (startsWith ? location.pathname.startsWith(pathname) : location.pathname === pathname) {
|
|
42
|
+
node.classList.add(...tokens);
|
|
43
|
+
} else {
|
|
44
|
+
node.classList.remove(...tokens);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -46,8 +46,12 @@ let meta = $state({ value: {} });
|
|
|
46
46
|
/** @type {(() => boolean) | null} */
|
|
47
47
|
let navigationBlocker = null;
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
let
|
|
49
|
+
/** @type {AbortController | null} */
|
|
50
|
+
let currentNavigationController = null;
|
|
51
|
+
let pendingController = /** @type {AbortController | null} */ (null);
|
|
52
|
+
|
|
53
|
+
/** @type {Promise<void | null> | null} */
|
|
54
|
+
let currentNavigationPromise = null;
|
|
51
55
|
|
|
52
56
|
/** @param {string | undefined} basename */
|
|
53
57
|
export function init(basename) {
|
|
@@ -129,7 +133,7 @@ export function createRouter(r) {
|
|
|
129
133
|
* search?: import('./index.d.ts').Search;
|
|
130
134
|
* }} options
|
|
131
135
|
*/
|
|
132
|
-
function navigate(path, options = {}) {
|
|
136
|
+
async function navigate(path, options = {}) {
|
|
133
137
|
if (typeof path === 'number') {
|
|
134
138
|
globalThis.history.go(path);
|
|
135
139
|
return new Navigation(`History entry: ${path}`);
|
|
@@ -141,7 +145,9 @@ function navigate(path, options = {}) {
|
|
|
141
145
|
} else if (options.hash && !options.hash.startsWith('#')) {
|
|
142
146
|
options.hash = '#' + options.hash;
|
|
143
147
|
}
|
|
144
|
-
onNavigate(path, options);
|
|
148
|
+
const promise = onNavigate(path, options);
|
|
149
|
+
currentNavigationPromise = promise;
|
|
150
|
+
await promise;
|
|
145
151
|
return new Navigation(`${path}${serializeSearch(options?.search ?? '')}${options?.hash ?? ''}`);
|
|
146
152
|
}
|
|
147
153
|
|
|
@@ -170,8 +176,13 @@ export async function onNavigate(path, options = {}) {
|
|
|
170
176
|
navigationBlocker = null;
|
|
171
177
|
}
|
|
172
178
|
|
|
173
|
-
|
|
174
|
-
|
|
179
|
+
if (pendingController && pendingController !== currentNavigationController) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
currentNavigationController?.abort();
|
|
184
|
+
currentNavigationController = new AbortController();
|
|
185
|
+
const { signal } = currentNavigationController;
|
|
175
186
|
|
|
176
187
|
const matchPath = getMatchPath(path);
|
|
177
188
|
const { match, layouts, hooks, meta: newMeta, params: newParams } = matchRoute(matchPath, routes);
|
|
@@ -181,21 +192,23 @@ export async function onNavigate(path, options = {}) {
|
|
|
181
192
|
|
|
182
193
|
let errorHooks = [];
|
|
183
194
|
for (const hook of hooks) {
|
|
195
|
+
if (signal.aborted) return currentNavigationPromise;
|
|
184
196
|
try {
|
|
185
197
|
const { beforeLoad } = hook;
|
|
186
198
|
errorHooks.push(hook);
|
|
187
|
-
|
|
199
|
+
pendingController = currentNavigationController;
|
|
188
200
|
await beforeLoad?.(hooksContext);
|
|
189
201
|
} catch (error) {
|
|
202
|
+
if (signal.aborted) return currentNavigationPromise;
|
|
190
203
|
for (const { onError } of errorHooks) {
|
|
191
204
|
void onError?.(error, hooksContext);
|
|
192
205
|
}
|
|
193
206
|
return;
|
|
207
|
+
} finally {
|
|
208
|
+
pendingController = null;
|
|
194
209
|
}
|
|
195
210
|
}
|
|
196
211
|
|
|
197
|
-
const fromBeforeLoadHook = new Error().stack?.includes('beforeLoad');
|
|
198
|
-
|
|
199
212
|
let routeComponents;
|
|
200
213
|
try {
|
|
201
214
|
routeComponents = await resolveRouteComponents(match ? [...layouts, match] : layouts);
|
|
@@ -205,12 +218,7 @@ export async function onNavigate(path, options = {}) {
|
|
|
205
218
|
}
|
|
206
219
|
throw error;
|
|
207
220
|
}
|
|
208
|
-
if (
|
|
209
|
-
navigationIndex !== currentNavigationIndex ||
|
|
210
|
-
(fromBeforeLoadHook && pendingNavigationIndex + 1 !== currentNavigationIndex)
|
|
211
|
-
) {
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
221
|
+
if (signal.aborted) return currentNavigationPromise;
|
|
214
222
|
|
|
215
223
|
if (path) {
|
|
216
224
|
const search = serializeSearch(options.search);
|
package/src/helpers/is-active.js
CHANGED
|
@@ -27,7 +27,8 @@ isActive.startsWith = (pathname, params) => {
|
|
|
27
27
|
*/
|
|
28
28
|
function compare(compareFn, pathname, params) {
|
|
29
29
|
if (!pathname.includes(':')) {
|
|
30
|
-
|
|
30
|
+
const target = base.name && base.name !== '#' ? constructPath(pathname) : pathname;
|
|
31
|
+
return compareFn(location.pathname, target);
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
if (params) {
|
|
@@ -63,6 +63,8 @@ export function matchRoute(pathname, routes) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
const pathPart = pathParts[index];
|
|
66
|
+
const isLayoutGroup = routePart === '' && typeof routes['/'] !== 'function';
|
|
67
|
+
|
|
66
68
|
if (routePart.startsWith(':')) {
|
|
67
69
|
params[routePart.slice(1)] = decodeURIComponent(pathPart);
|
|
68
70
|
} else if (routePart.startsWith('*')) {
|
|
@@ -71,7 +73,6 @@ export function matchRoute(pathname, routes) {
|
|
|
71
73
|
params[param] = pathParts.slice(index).map(decodeURIComponent).join('/');
|
|
72
74
|
}
|
|
73
75
|
if (breakFromLayouts) {
|
|
74
|
-
routePart = `(${routePart})`;
|
|
75
76
|
layouts = [];
|
|
76
77
|
} else if ('layout' in routes && routes.layout) {
|
|
77
78
|
layouts.push(routes.layout);
|
|
@@ -81,7 +82,7 @@ export function matchRoute(pathname, routes) {
|
|
|
81
82
|
);
|
|
82
83
|
match = /** @type {RouteComponent} */ (routes[resolvedPath]);
|
|
83
84
|
break outer;
|
|
84
|
-
} else if (routePart.toLowerCase() !== pathPart?.toLowerCase()) {
|
|
85
|
+
} else if (routePart.toLowerCase() !== pathPart?.toLowerCase() && !isLayoutGroup) {
|
|
85
86
|
break;
|
|
86
87
|
}
|
|
87
88
|
|
|
@@ -117,7 +118,7 @@ export function matchRoute(pathname, routes) {
|
|
|
117
118
|
continue;
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
const nestedPathname = '/' + pathParts.slice(index + 1).join('/');
|
|
121
|
+
const nestedPathname = isLayoutGroup ? pathname : '/' + pathParts.slice(index + 1).join('/');
|
|
121
122
|
const result = matchRoute(nestedPathname, routeMatch);
|
|
122
123
|
if (result.match) {
|
|
123
124
|
match = result.match;
|
|
@@ -126,6 +127,7 @@ export function matchRoute(pathname, routes) {
|
|
|
126
127
|
meta = { ...meta, ...result.meta };
|
|
127
128
|
if (result.breakFromLayouts) {
|
|
128
129
|
layouts = [];
|
|
130
|
+
breakFromLayouts = true;
|
|
129
131
|
} else {
|
|
130
132
|
layouts.push(...result.layouts);
|
|
131
133
|
}
|
package/src/helpers/preload.js
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
import type { Component, Snippet } from 'svelte';
|
|
2
2
|
import type { Action } from 'svelte/action';
|
|
3
|
+
import type { Attachment } from 'svelte/attachments';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* to `
|
|
6
|
+
* @deprecated Use the `isActiveLink` [attachment](https://svelte.dev/docs/svelte/@attach) instead.
|
|
7
|
+
*
|
|
8
|
+
* A Svelte action that will add a class to the anchor if its `href` matches the current route. It
|
|
9
|
+
* can have an optional `className` parameter to specify the class to add, otherwise it will
|
|
10
|
+
* default to `is-active`, and an optional `startsWith` parameter.
|
|
11
|
+
*
|
|
12
|
+
* ```svelte
|
|
13
|
+
* <a href={p('/about')} use:isActiveLink={{ className: 'active-link' }}>
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export const isActiveLinkAction: IsActiveLinkAction;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A Svelte attachment that will add a class to the anchor if its `href` matches the current route.
|
|
20
|
+
* It can have an optional `className` parameter to specify the class to add, otherwise it will
|
|
21
|
+
* default to `is-active`, and an optional `startsWith` parameter.
|
|
8
22
|
*
|
|
9
23
|
* ```svelte
|
|
10
|
-
* <a href={p('/about')}
|
|
24
|
+
* <a href={p('/about')} {@attach isActiveLink({ className: 'active-link' })}>
|
|
11
25
|
* ```
|
|
12
26
|
*/
|
|
13
27
|
export const isActiveLink: IsActiveLink;
|
|
@@ -92,7 +106,12 @@ export type Routes = {
|
|
|
92
106
|
meta?: RouteMeta;
|
|
93
107
|
};
|
|
94
108
|
|
|
95
|
-
export type IsActiveLink =
|
|
109
|
+
export type IsActiveLink = (options?: {
|
|
110
|
+
className?: string;
|
|
111
|
+
startsWith?: boolean;
|
|
112
|
+
}) => Attachment<HTMLAnchorElement>;
|
|
113
|
+
|
|
114
|
+
export type IsActiveLinkAction = Action<
|
|
96
115
|
HTMLAnchorElement,
|
|
97
116
|
| {
|
|
98
117
|
className?: string;
|
|
@@ -154,7 +173,7 @@ export type RouterApi<T extends Routes> = {
|
|
|
154
173
|
* @param options The navigation options.
|
|
155
174
|
* @returns {@link Navigation} For use with `throw navigate(...)` inside hooks.
|
|
156
175
|
*/
|
|
157
|
-
navigate<U extends Path<T>>(...args: NavigateArgs<U>): Navigation
|
|
176
|
+
navigate<U extends Path<T>>(...args: NavigateArgs<U>): Promise<Navigation>;
|
|
158
177
|
|
|
159
178
|
/**
|
|
160
179
|
* Will return `true` if the given path is active.
|
|
@@ -305,6 +324,10 @@ type StripNonRoutes<T extends Routes> = {
|
|
|
305
324
|
: K]: T[K] extends Routes ? StripNonRoutes<T[K]> : T[K];
|
|
306
325
|
};
|
|
307
326
|
|
|
327
|
+
type NormalizeSlashes<T extends string> = T extends `${infer A}//${infer B}`
|
|
328
|
+
? NormalizeSlashes<`${A}/${B}`>
|
|
329
|
+
: T;
|
|
330
|
+
|
|
308
331
|
type RecursiveKeys<
|
|
309
332
|
T extends Routes,
|
|
310
333
|
Prefix extends string = '',
|
|
@@ -314,10 +337,10 @@ type RecursiveKeys<
|
|
|
314
337
|
? T[K] extends Routes
|
|
315
338
|
? RecursiveKeys<
|
|
316
339
|
T[K],
|
|
317
|
-
|
|
340
|
+
NormalizeSlashes<`${Prefix}${AnyParam extends true ? ReplaceParamWithString<K> : K}`>,
|
|
318
341
|
AnyParam
|
|
319
342
|
>
|
|
320
|
-
:
|
|
343
|
+
: NormalizeSlashes<`${Prefix}${AnyParam extends true ? ReplaceParamWithString<K> : K}`>
|
|
321
344
|
: never;
|
|
322
345
|
}[keyof T];
|
|
323
346
|
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { isActiveLink } from './
|
|
1
|
+
export { isActiveLink, isActiveLinkAction } from './attachments.svelte.js';
|
|
2
2
|
export { blockNavigation, createRouter } from './create-router.svelte.js';
|
|
3
3
|
export { serializeSearch } from './helpers/utils.js';
|
|
4
4
|
export { Navigation } from './navigation.js';
|
package/src/actions.svelte.js
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { base, location } from './create-router.svelte.js';
|
|
2
|
-
import { join } from './helpers/utils.js';
|
|
3
|
-
|
|
4
|
-
/** @type {import('./index.d.ts').IsActiveLink} */
|
|
5
|
-
export function isActiveLink(node, { className = 'is-active', startsWith = false } = {}) {
|
|
6
|
-
if (node.tagName !== 'A') {
|
|
7
|
-
throw new Error('isActiveLink can only be used on <a> elements');
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
$effect(() => {
|
|
11
|
-
let pathname;
|
|
12
|
-
if (base.name === '#') {
|
|
13
|
-
pathname = new URL(node.href).hash.slice(1);
|
|
14
|
-
} else {
|
|
15
|
-
pathname = new URL(node.href).pathname;
|
|
16
|
-
if (base.name) {
|
|
17
|
-
pathname = join(base.name, pathname);
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
const tokens = className.split(' ').filter(Boolean) ?? [];
|
|
21
|
-
if (startsWith ? location.pathname.startsWith(pathname) : location.pathname === pathname) {
|
|
22
|
-
node.classList.add(...tokens);
|
|
23
|
-
} else {
|
|
24
|
-
node.classList.remove(...tokens);
|
|
25
|
-
}
|
|
26
|
-
});
|
|
27
|
-
}
|