sv-router 0.12.0 → 0.14.0
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 +16 -16
- package/src/attachments.svelte.js +47 -0
- package/src/create-router.svelte.js +47 -15
- package/src/helpers/is-active.js +2 -1
- package/src/helpers/match-route.js +3 -3
- package/src/index.d.ts +34 -6
- package/src/index.js +2 -2
- 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.0",
|
|
4
4
|
"description": "Modern Svelte Routing",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|
|
@@ -37,28 +37,28 @@
|
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@changesets/cli": "^2.29.8",
|
|
40
|
-
"@eslint/js": "^
|
|
41
|
-
"@sveltejs/vite-plugin-svelte": "^6.2.
|
|
40
|
+
"@eslint/js": "^10.0.1",
|
|
41
|
+
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
|
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.10.
|
|
46
|
-
"eslint": "^
|
|
45
|
+
"@types/node": "^24.10.13",
|
|
46
|
+
"eslint": "^10.0.0",
|
|
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": "^
|
|
52
|
-
"happy-dom": "^20.0
|
|
53
|
-
"prettier": "^3.
|
|
49
|
+
"eslint-plugin-svelte": "^3.15.0",
|
|
50
|
+
"eslint-plugin-unicorn": "^63.0.0",
|
|
51
|
+
"globals": "^17.3.0",
|
|
52
|
+
"happy-dom": "^20.6.0",
|
|
53
|
+
"prettier": "^3.8.1",
|
|
54
54
|
"prettier-plugin-jsdoc": "^1.8.0",
|
|
55
|
-
"prettier-plugin-svelte": "^3.4.
|
|
56
|
-
"svelte-check": "^4.3.
|
|
55
|
+
"prettier-plugin-svelte": "^3.4.1",
|
|
56
|
+
"svelte-check": "^4.3.6",
|
|
57
57
|
"type-testing": "^0.2.0",
|
|
58
58
|
"typescript": "^5.9.3",
|
|
59
|
-
"typescript-eslint": "^8.
|
|
60
|
-
"vite": "^7.
|
|
61
|
-
"vitest": "^4.0.
|
|
59
|
+
"typescript-eslint": "^8.55.0",
|
|
60
|
+
"vite": "^7.3.1",
|
|
61
|
+
"vitest": "^4.0.18"
|
|
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
|
+
}
|
|
@@ -43,8 +43,15 @@ let params = $state({ value: {} });
|
|
|
43
43
|
|
|
44
44
|
let meta = $state({ value: {} });
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
let
|
|
46
|
+
/** @type {(() => boolean) | null} */
|
|
47
|
+
let navigationBlocker = null;
|
|
48
|
+
|
|
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;
|
|
48
55
|
|
|
49
56
|
/** @param {string | undefined} basename */
|
|
50
57
|
export function init(basename) {
|
|
@@ -126,7 +133,7 @@ export function createRouter(r) {
|
|
|
126
133
|
* search?: import('./index.d.ts').Search;
|
|
127
134
|
* }} options
|
|
128
135
|
*/
|
|
129
|
-
function navigate(path, options = {}) {
|
|
136
|
+
async function navigate(path, options = {}) {
|
|
130
137
|
if (typeof path === 'number') {
|
|
131
138
|
globalThis.history.go(path);
|
|
132
139
|
return new Navigation(`History entry: ${path}`);
|
|
@@ -138,7 +145,9 @@ function navigate(path, options = {}) {
|
|
|
138
145
|
} else if (options.hash && !options.hash.startsWith('#')) {
|
|
139
146
|
options.hash = '#' + options.hash;
|
|
140
147
|
}
|
|
141
|
-
onNavigate(path, options);
|
|
148
|
+
const promise = onNavigate(path, options);
|
|
149
|
+
currentNavigationPromise = promise;
|
|
150
|
+
await promise;
|
|
142
151
|
return new Navigation(`${path}${serializeSearch(options?.search ?? '')}${options?.hash ?? ''}`);
|
|
143
152
|
}
|
|
144
153
|
|
|
@@ -151,8 +160,29 @@ export async function onNavigate(path, options = {}) {
|
|
|
151
160
|
throw new Error('Router not initialized: `createRouter` was not called.');
|
|
152
161
|
}
|
|
153
162
|
|
|
154
|
-
|
|
155
|
-
|
|
163
|
+
if (navigationBlocker) {
|
|
164
|
+
if (!navigationBlocker()) {
|
|
165
|
+
const url = new URL(globalThis.location.toString());
|
|
166
|
+
url.search = location.search;
|
|
167
|
+
url.hash = location.hash;
|
|
168
|
+
if (base.name === '#') {
|
|
169
|
+
url.hash = location.pathname;
|
|
170
|
+
} else {
|
|
171
|
+
url.pathname = location.pathname;
|
|
172
|
+
}
|
|
173
|
+
globalThis.history.replaceState($state.snapshot(location.state) || {}, '', url.toString());
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
navigationBlocker = null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (pendingController && pendingController !== currentNavigationController) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
currentNavigationController?.abort();
|
|
184
|
+
currentNavigationController = new AbortController();
|
|
185
|
+
const { signal } = currentNavigationController;
|
|
156
186
|
|
|
157
187
|
const matchPath = getMatchPath(path);
|
|
158
188
|
const { match, layouts, hooks, meta: newMeta, params: newParams } = matchRoute(matchPath, routes);
|
|
@@ -162,21 +192,23 @@ export async function onNavigate(path, options = {}) {
|
|
|
162
192
|
|
|
163
193
|
let errorHooks = [];
|
|
164
194
|
for (const hook of hooks) {
|
|
195
|
+
if (signal.aborted) return currentNavigationPromise;
|
|
165
196
|
try {
|
|
166
197
|
const { beforeLoad } = hook;
|
|
167
198
|
errorHooks.push(hook);
|
|
168
|
-
|
|
199
|
+
pendingController = currentNavigationController;
|
|
169
200
|
await beforeLoad?.(hooksContext);
|
|
170
201
|
} catch (error) {
|
|
202
|
+
if (signal.aborted) return currentNavigationPromise;
|
|
171
203
|
for (const { onError } of errorHooks) {
|
|
172
204
|
void onError?.(error, hooksContext);
|
|
173
205
|
}
|
|
174
206
|
return;
|
|
207
|
+
} finally {
|
|
208
|
+
pendingController = null;
|
|
175
209
|
}
|
|
176
210
|
}
|
|
177
211
|
|
|
178
|
-
const fromBeforeLoadHook = new Error().stack?.includes('beforeLoad');
|
|
179
|
-
|
|
180
212
|
let routeComponents;
|
|
181
213
|
try {
|
|
182
214
|
routeComponents = await resolveRouteComponents(match ? [...layouts, match] : layouts);
|
|
@@ -186,12 +218,7 @@ export async function onNavigate(path, options = {}) {
|
|
|
186
218
|
}
|
|
187
219
|
throw error;
|
|
188
220
|
}
|
|
189
|
-
if (
|
|
190
|
-
navigationIndex !== currentNavigationIndex ||
|
|
191
|
-
(fromBeforeLoadHook && pendingNavigationIndex + 1 !== currentNavigationIndex)
|
|
192
|
-
) {
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
221
|
+
if (signal.aborted) return currentNavigationPromise;
|
|
195
222
|
|
|
196
223
|
if (path) {
|
|
197
224
|
const search = serializeSearch(options.search);
|
|
@@ -292,3 +319,8 @@ export function onGlobalClick(event) {
|
|
|
292
319
|
viewTransition: viewTransition === '' || viewTransition === 'true',
|
|
293
320
|
});
|
|
294
321
|
}
|
|
322
|
+
|
|
323
|
+
/** @param {() => boolean} callback */
|
|
324
|
+
export function blockNavigation(callback) {
|
|
325
|
+
navigationBlocker = callback;
|
|
326
|
+
}
|
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) {
|
|
@@ -64,14 +64,13 @@ export function matchRoute(pathname, routes) {
|
|
|
64
64
|
|
|
65
65
|
const pathPart = pathParts[index];
|
|
66
66
|
if (routePart.startsWith(':')) {
|
|
67
|
-
params[routePart.slice(1)] = pathPart;
|
|
67
|
+
params[routePart.slice(1)] = decodeURIComponent(pathPart);
|
|
68
68
|
} else if (routePart.startsWith('*')) {
|
|
69
69
|
const param = routePart.slice(1);
|
|
70
70
|
if (param) {
|
|
71
|
-
params[param] = pathParts.slice(index).join('/');
|
|
71
|
+
params[param] = pathParts.slice(index).map(decodeURIComponent).join('/');
|
|
72
72
|
}
|
|
73
73
|
if (breakFromLayouts) {
|
|
74
|
-
routePart = `(${routePart})`;
|
|
75
74
|
layouts = [];
|
|
76
75
|
} else if ('layout' in routes && routes.layout) {
|
|
77
76
|
layouts.push(routes.layout);
|
|
@@ -126,6 +125,7 @@ export function matchRoute(pathname, routes) {
|
|
|
126
125
|
meta = { ...meta, ...result.meta };
|
|
127
126
|
if (result.breakFromLayouts) {
|
|
128
127
|
layouts = [];
|
|
128
|
+
breakFromLayouts = true;
|
|
129
129
|
} else {
|
|
130
130
|
layouts.push(...result.layouts);
|
|
131
131
|
}
|
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;
|
|
@@ -28,6 +42,15 @@ export function serializeSearch(search: Search): string | undefined;
|
|
|
28
42
|
*/
|
|
29
43
|
export function createRouter<T extends Routes>(r: T): RouterApi<T>;
|
|
30
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Block navigation until the callback returns `false`.
|
|
47
|
+
*
|
|
48
|
+
* ```js
|
|
49
|
+
* blockNavigation(() => confirm('Are you sure you want to leave?'));
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function blockNavigation(callback: () => boolean): void;
|
|
53
|
+
|
|
31
54
|
/**
|
|
32
55
|
* The component that will render the current route. You can pass a `base` prop to set the base path
|
|
33
56
|
* that is prepended to every url.
|
|
@@ -83,7 +106,12 @@ export type Routes = {
|
|
|
83
106
|
meta?: RouteMeta;
|
|
84
107
|
};
|
|
85
108
|
|
|
86
|
-
export type IsActiveLink =
|
|
109
|
+
export type IsActiveLink = (options?: {
|
|
110
|
+
className?: string;
|
|
111
|
+
startsWith?: boolean;
|
|
112
|
+
}) => Attachment<HTMLAnchorElement>;
|
|
113
|
+
|
|
114
|
+
export type IsActiveLinkAction = Action<
|
|
87
115
|
HTMLAnchorElement,
|
|
88
116
|
| {
|
|
89
117
|
className?: string;
|
|
@@ -145,7 +173,7 @@ export type RouterApi<T extends Routes> = {
|
|
|
145
173
|
* @param options The navigation options.
|
|
146
174
|
* @returns {@link Navigation} For use with `throw navigate(...)` inside hooks.
|
|
147
175
|
*/
|
|
148
|
-
navigate<U extends Path<T>>(...args: NavigateArgs<U>): Navigation
|
|
176
|
+
navigate<U extends Path<T>>(...args: NavigateArgs<U>): Promise<Navigation>;
|
|
149
177
|
|
|
150
178
|
/**
|
|
151
179
|
* Will return `true` if the given path is active.
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { isActiveLink } from './
|
|
2
|
-
export { createRouter } from './create-router.svelte.js';
|
|
1
|
+
export { isActiveLink, isActiveLinkAction } from './attachments.svelte.js';
|
|
2
|
+
export { blockNavigation, createRouter } from './create-router.svelte.js';
|
|
3
3
|
export { serializeSearch } from './helpers/utils.js';
|
|
4
4
|
export { Navigation } from './navigation.js';
|
|
5
5
|
export { default as Router } from './Router.svelte';
|
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
|
-
}
|