sv-router 0.14.1 → 0.16.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/README.md +1 -0
- package/package.json +6 -4
- package/src/Router.svelte +16 -5
- package/src/attachments.svelte.js +0 -22
- package/src/create-router.svelte.js +75 -19
- package/src/gen/generate-router-code.js +8 -2
- package/src/gen/write-router-code.js +20 -4
- package/src/helpers/match-route.js +167 -98
- package/src/helpers/utils.js +9 -1
- package/src/index.d.ts +31 -16
- package/src/index.js +1 -1
- package/src/search-params.svelte.js +2 -1
package/README.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/sv-router)
|
|
8
8
|
[](https://packagephobia.com/result?p=sv-router)
|
|
9
|
+
[](https://codecov.io/github/colinlienard/sv-router)
|
|
9
10
|
|
|
10
11
|
A feature-rich yet intuitive routing library for Svelte single-page apps.
|
|
11
12
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sv-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Modern Svelte Routing",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|
|
@@ -43,11 +43,12 @@
|
|
|
43
43
|
"@testing-library/svelte": "^5.3.1",
|
|
44
44
|
"@testing-library/user-event": "^14.6.1",
|
|
45
45
|
"@types/node": "^24.12.0",
|
|
46
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
46
47
|
"eslint": "^10.0.3",
|
|
47
48
|
"eslint-config-prettier": "^10.1.8",
|
|
48
|
-
"eslint-plugin-simple-import-sort": "^
|
|
49
|
+
"eslint-plugin-simple-import-sort": "^13.0.0",
|
|
49
50
|
"eslint-plugin-svelte": "^3.15.2",
|
|
50
|
-
"eslint-plugin-unicorn": "^
|
|
51
|
+
"eslint-plugin-unicorn": "^64.0.0",
|
|
51
52
|
"globals": "^17.4.0",
|
|
52
53
|
"happy-dom": "^20.8.4",
|
|
53
54
|
"prettier": "^3.8.1",
|
|
@@ -55,7 +56,7 @@
|
|
|
55
56
|
"prettier-plugin-svelte": "^3.5.1",
|
|
56
57
|
"svelte-check": "^4.4.5",
|
|
57
58
|
"type-testing": "^0.2.0",
|
|
58
|
-
"typescript": "^
|
|
59
|
+
"typescript": "^6.0.2",
|
|
59
60
|
"typescript-eslint": "^8.57.1",
|
|
60
61
|
"vite": "^8.0.0",
|
|
61
62
|
"vitest": "^4.1.0"
|
|
@@ -70,6 +71,7 @@
|
|
|
70
71
|
"docs:build": "pnpm --filter docs build",
|
|
71
72
|
"docs:preview": "pnpm --filter docs preview",
|
|
72
73
|
"test": "vitest",
|
|
74
|
+
"test:coverage": "vitest run --coverage",
|
|
73
75
|
"check": "svelte-check && pnpm -r check",
|
|
74
76
|
"lint": "eslint . --max-warnings 0",
|
|
75
77
|
"lint:fix": "eslint . --fix",
|
package/src/Router.svelte
CHANGED
|
@@ -1,22 +1,33 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
import { on } from 'svelte/events';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
componentTree,
|
|
5
|
+
init,
|
|
6
|
+
onBeforeUnload,
|
|
7
|
+
onGlobalClick,
|
|
8
|
+
onNavigate,
|
|
9
|
+
} from './create-router.svelte.js';
|
|
4
10
|
import RecursiveComponentTree from './RecursiveComponentTree.svelte';
|
|
5
11
|
|
|
6
12
|
/** @type {{ base?: string }} */
|
|
7
13
|
let { base: basename } = $props();
|
|
8
14
|
|
|
15
|
+
// svelte-ignore state_referenced_locally
|
|
9
16
|
init(basename);
|
|
10
17
|
|
|
11
18
|
onNavigate();
|
|
12
19
|
|
|
13
20
|
$effect(() => {
|
|
14
|
-
const
|
|
15
|
-
|
|
21
|
+
const cleanup = [
|
|
22
|
+
on(globalThis, 'popstate', () => onNavigate()),
|
|
23
|
+
on(globalThis, 'beforeunload', onBeforeUnload),
|
|
24
|
+
on(globalThis, 'click', onGlobalClick),
|
|
25
|
+
];
|
|
16
26
|
|
|
17
27
|
return () => {
|
|
18
|
-
|
|
19
|
-
|
|
28
|
+
for (const clean of cleanup) {
|
|
29
|
+
clean();
|
|
30
|
+
}
|
|
20
31
|
};
|
|
21
32
|
});
|
|
22
33
|
</script>
|
|
@@ -23,25 +23,3 @@ export function isActiveLink({ className = 'is-active', startsWith = false } = {
|
|
|
23
23
|
});
|
|
24
24
|
};
|
|
25
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,17 @@ let params = $state({ value: {} });
|
|
|
43
43
|
|
|
44
44
|
let meta = $state({ value: {} });
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
46
|
+
/**
|
|
47
|
+
* @type {Map<
|
|
48
|
+
* number,
|
|
49
|
+
* | (() => boolean | Promise<boolean>)
|
|
50
|
+
* | { beforeUnload?(): boolean; onNavigate(): boolean | Promise<boolean> }
|
|
51
|
+
* >}
|
|
52
|
+
*/
|
|
53
|
+
const navigationBlockers = new Map();
|
|
54
|
+
|
|
55
|
+
let historyIndex = 0;
|
|
56
|
+
let skipNextPopstate = false;
|
|
48
57
|
|
|
49
58
|
/** @type {AbortController | null} */
|
|
50
59
|
let currentNavigationController = null;
|
|
@@ -61,16 +70,33 @@ export function init(basename) {
|
|
|
61
70
|
base.name = '#';
|
|
62
71
|
if (!globalThis.location.href.includes('#')) {
|
|
63
72
|
url.hash = '/';
|
|
64
|
-
history.replaceState(
|
|
73
|
+
history.replaceState(
|
|
74
|
+
{ _routerIndex: historyIndex, _userState: history.state ?? null },
|
|
75
|
+
'',
|
|
76
|
+
url.toString(),
|
|
77
|
+
);
|
|
65
78
|
}
|
|
66
79
|
} else {
|
|
67
80
|
base.name = (basename.startsWith('/') ? '' : '/') + basename;
|
|
68
81
|
if (!url.pathname.startsWith(base.name)) {
|
|
69
82
|
url.pathname = join(base.name, url.pathname);
|
|
70
|
-
history.replaceState(
|
|
83
|
+
history.replaceState(
|
|
84
|
+
{ _routerIndex: historyIndex, _userState: history.state ?? null },
|
|
85
|
+
'',
|
|
86
|
+
url.toString(),
|
|
87
|
+
);
|
|
71
88
|
}
|
|
72
89
|
}
|
|
73
90
|
}
|
|
91
|
+
if (history.state?._routerIndex === undefined) {
|
|
92
|
+
history.replaceState(
|
|
93
|
+
{ _routerIndex: historyIndex, _userState: history.state ?? null },
|
|
94
|
+
'',
|
|
95
|
+
globalThis.location.href,
|
|
96
|
+
);
|
|
97
|
+
} else {
|
|
98
|
+
historyIndex = history.state._routerIndex;
|
|
99
|
+
}
|
|
74
100
|
Object.assign(location, updatedLocation());
|
|
75
101
|
}
|
|
76
102
|
|
|
@@ -151,6 +177,16 @@ async function navigate(path, options = {}) {
|
|
|
151
177
|
return new Navigation(`${path}${serializeSearch(options?.search ?? '')}${options?.hash ?? ''}`);
|
|
152
178
|
}
|
|
153
179
|
|
|
180
|
+
/** @param {BeforeUnloadEvent} event */
|
|
181
|
+
export function onBeforeUnload(event) {
|
|
182
|
+
for (const blocker of navigationBlockers.values()) {
|
|
183
|
+
if (typeof blocker !== 'object' || !blocker.beforeUnload) continue;
|
|
184
|
+
if (!blocker.beforeUnload()) {
|
|
185
|
+
event.preventDefault();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
154
190
|
/**
|
|
155
191
|
* @param {string} [path]
|
|
156
192
|
* @param {import('./index.d.ts').NavigateOptions} options
|
|
@@ -160,20 +196,26 @@ export async function onNavigate(path, options = {}) {
|
|
|
160
196
|
throw new Error('Router not initialized: `createRouter` was not called.');
|
|
161
197
|
}
|
|
162
198
|
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
199
|
+
if (!path && skipNextPopstate) {
|
|
200
|
+
skipNextPopstate = false;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (navigationBlockers.size > 0) {
|
|
205
|
+
const popstateDelta = path ? 0 : historyIndex - (history.state?._routerIndex ?? 0);
|
|
206
|
+
for (const blocker of navigationBlockers.values()) {
|
|
207
|
+
const shouldNavigate = typeof blocker === 'object' ? blocker.onNavigate : blocker;
|
|
208
|
+
if (!(await shouldNavigate())) {
|
|
209
|
+
if (!path && popstateDelta !== 0) {
|
|
210
|
+
skipNextPopstate = true;
|
|
211
|
+
history.go(popstateDelta);
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
172
214
|
}
|
|
173
|
-
globalThis.history.replaceState($state.snapshot(location.state) || {}, '', url.toString());
|
|
174
|
-
return;
|
|
175
215
|
}
|
|
176
|
-
|
|
216
|
+
if (!path) {
|
|
217
|
+
historyIndex = history.state?._routerIndex ?? historyIndex;
|
|
218
|
+
}
|
|
177
219
|
}
|
|
178
220
|
|
|
179
221
|
if (pendingController && pendingController !== currentNavigationController) {
|
|
@@ -233,7 +275,12 @@ export async function onNavigate(path, options = {}) {
|
|
|
233
275
|
url.pathname = path;
|
|
234
276
|
}
|
|
235
277
|
const historyMethod = options.replace ? 'replaceState' : 'pushState';
|
|
236
|
-
|
|
278
|
+
if (historyMethod === 'pushState') historyIndex++;
|
|
279
|
+
globalThis.history[historyMethod](
|
|
280
|
+
{ _routerIndex: historyIndex, _userState: options.state ?? null },
|
|
281
|
+
'',
|
|
282
|
+
url.toString(),
|
|
283
|
+
);
|
|
237
284
|
syncSearchParams(search);
|
|
238
285
|
} else {
|
|
239
286
|
syncSearchParams(globalThis.location.search);
|
|
@@ -320,7 +367,16 @@ export function onGlobalClick(event) {
|
|
|
320
367
|
});
|
|
321
368
|
}
|
|
322
369
|
|
|
323
|
-
|
|
370
|
+
let navigationBlockId = 0;
|
|
371
|
+
/**
|
|
372
|
+
* @param {(() => boolean | Promise<boolean>)
|
|
373
|
+
* | { beforeUnload?(): boolean; onNavigate(): boolean | Promise<boolean> }} callback
|
|
374
|
+
* @returns {() => void}
|
|
375
|
+
*/
|
|
324
376
|
export function blockNavigation(callback) {
|
|
325
|
-
|
|
377
|
+
const id = navigationBlockId++;
|
|
378
|
+
navigationBlockers.set(id, callback);
|
|
379
|
+
return () => {
|
|
380
|
+
navigationBlockers.delete(id);
|
|
381
|
+
};
|
|
326
382
|
}
|
|
@@ -165,8 +165,14 @@ function mergeRouteGroup(result, childMap) {
|
|
|
165
165
|
} else if (!Array.isArray(val)) {
|
|
166
166
|
routeWithGroupFiles = { ...val };
|
|
167
167
|
}
|
|
168
|
-
if (layout)
|
|
169
|
-
|
|
168
|
+
if (layout) {
|
|
169
|
+
if (routeWithGroupFiles.layout) {
|
|
170
|
+
routeWithGroupFiles = { '/': routeWithGroupFiles, layout: layout };
|
|
171
|
+
} else {
|
|
172
|
+
routeWithGroupFiles.layout = layout;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (hooks) routeWithGroupFiles.hooks = hooks;
|
|
170
176
|
if (mergedMeta) routeWithGroupFiles.meta = /** @type {string | string[]} */ (mergedMeta);
|
|
171
177
|
if (result[key]) {
|
|
172
178
|
throw new Error(`Route conflict at \`${key}\``);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* eslint-disable no-console */
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
3
4
|
import { genConfig } from './config.js';
|
|
4
5
|
import { generateRouterCode } from './generate-router-code.js';
|
|
5
6
|
|
|
@@ -10,14 +11,18 @@ export function writeRouterCode() {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
// Write `.router/tsconfig.json` file
|
|
14
|
+
const tsMajor = getTypeScriptMajorVersion();
|
|
15
|
+
const useBaseUrl = tsMajor === undefined || tsMajor < 6;
|
|
16
|
+
let alias = genConfig.routerPath;
|
|
17
|
+
if (!useBaseUrl) {
|
|
18
|
+
alias = alias.replace(genConfig.genCodeDirPath, '.');
|
|
19
|
+
}
|
|
13
20
|
const tsConfig = {
|
|
14
21
|
compilerOptions: {
|
|
15
22
|
module: 'preserve',
|
|
16
23
|
moduleResolution: 'bundler',
|
|
17
|
-
baseUrl: '..',
|
|
18
|
-
paths: {
|
|
19
|
-
[genConfig.genCodeAlias]: [genConfig.routerPath],
|
|
20
|
-
},
|
|
24
|
+
...(useBaseUrl ? { baseUrl: '..' } : {}),
|
|
25
|
+
paths: { [genConfig.genCodeAlias]: [alias] },
|
|
21
26
|
},
|
|
22
27
|
include: [
|
|
23
28
|
'../src/**/*.js',
|
|
@@ -62,3 +67,14 @@ function writeFileIfDifferent(filePath, content) {
|
|
|
62
67
|
return true;
|
|
63
68
|
}
|
|
64
69
|
}
|
|
70
|
+
|
|
71
|
+
function getTypeScriptMajorVersion() {
|
|
72
|
+
try {
|
|
73
|
+
const require = createRequire(process.cwd() + '/package.json');
|
|
74
|
+
const tsPackagePath = require.resolve('typescript/package.json');
|
|
75
|
+
const tsPackage = JSON.parse(fs.readFileSync(tsPackagePath, 'utf8'));
|
|
76
|
+
return Number(tsPackage.version.split('.')[0]);
|
|
77
|
+
} catch {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -8,137 +8,206 @@
|
|
|
8
8
|
* @typedef {import('../index.d.ts').Routes} Routes
|
|
9
9
|
*
|
|
10
10
|
* @typedef {import('../index.d.ts').RouteMeta} RouteMeta
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* @param {string} pathname
|
|
15
|
-
* @param {Routes} routes
|
|
16
|
-
* @returns {{
|
|
11
|
+
*
|
|
12
|
+
* @typedef {{
|
|
17
13
|
* match: RouteComponent | undefined;
|
|
18
14
|
* layouts: LayoutComponent[];
|
|
19
15
|
* hooks: Hooks[];
|
|
20
16
|
* meta: RouteMeta;
|
|
21
17
|
* params: Record<string, string>;
|
|
22
18
|
* breakFromLayouts: boolean;
|
|
23
|
-
*
|
|
19
|
+
* isCatchAll: boolean;
|
|
20
|
+
* }} MatchResult
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} pathname
|
|
25
|
+
* @param {Routes} routes
|
|
26
|
+
* @returns {MatchResult}
|
|
24
27
|
*/
|
|
25
28
|
export function matchRoute(pathname, routes) {
|
|
26
|
-
// Remove trailing slash
|
|
27
29
|
if (pathname.length > 1 && pathname.endsWith('/')) {
|
|
28
30
|
pathname = pathname.slice(0, -1);
|
|
29
31
|
}
|
|
30
|
-
const pathParts = pathname.split('/').slice(1);
|
|
31
|
-
const allRoutes = sortRoutes(Object.keys(routes));
|
|
32
|
-
|
|
33
|
-
/** @type {RouteComponent | undefined} */
|
|
34
|
-
let match;
|
|
35
|
-
|
|
36
|
-
/** @type {LayoutComponent[]} */
|
|
37
|
-
let layouts = [];
|
|
38
|
-
|
|
39
|
-
/** @type {Hooks[]} */
|
|
40
|
-
let hooks = [];
|
|
41
32
|
|
|
42
|
-
|
|
43
|
-
|
|
33
|
+
const pathParts = pathname.split('/').slice(1);
|
|
34
|
+
const sortedRoutes = sortRoutes(Object.keys(routes));
|
|
44
35
|
|
|
45
36
|
/** @type {RouteMeta} */
|
|
46
|
-
let
|
|
47
|
-
|
|
37
|
+
let baseMeta = {};
|
|
48
38
|
const rootRoute = routes['/'];
|
|
49
39
|
if (rootRoute && typeof rootRoute === 'object' && 'meta' in rootRoute && rootRoute.meta) {
|
|
50
|
-
|
|
40
|
+
baseMeta = { ...rootRoute.meta };
|
|
51
41
|
}
|
|
52
42
|
|
|
53
|
-
|
|
43
|
+
/** @type {MatchResult | undefined} */
|
|
44
|
+
let catchAllFallback;
|
|
54
45
|
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
46
|
+
for (const route of sortedRoutes) {
|
|
47
|
+
const attempt = tryMatch(route, pathParts, pathname, routes, baseMeta);
|
|
48
|
+
if (!attempt) continue;
|
|
58
49
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
50
|
+
if (attempt.fallback) {
|
|
51
|
+
if (!catchAllFallback) catchAllFallback = attempt.result;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
64
54
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (routePart.startsWith(':')) {
|
|
69
|
-
params[routePart.slice(1)] = decodeURIComponent(pathPart);
|
|
70
|
-
} else if (routePart.startsWith('*')) {
|
|
71
|
-
const param = routePart.slice(1);
|
|
72
|
-
if (param) {
|
|
73
|
-
params[param] = pathParts.slice(index).map(decodeURIComponent).join('/');
|
|
74
|
-
}
|
|
75
|
-
if (breakFromLayouts) {
|
|
76
|
-
layouts = [];
|
|
77
|
-
} else if ('layout' in routes && routes.layout) {
|
|
78
|
-
layouts.push(routes.layout);
|
|
79
|
-
}
|
|
80
|
-
const resolvedPath = /** @type {keyof Routes} */ (
|
|
81
|
-
(index ? '/' : '') + routeParts.join('/')
|
|
82
|
-
);
|
|
83
|
-
match = /** @type {RouteComponent} */ (routes[resolvedPath]);
|
|
84
|
-
break outer;
|
|
85
|
-
} else if (routePart.toLowerCase() !== pathPart?.toLowerCase() && !isLayoutGroup) {
|
|
86
|
-
break;
|
|
87
|
-
}
|
|
55
|
+
return attempt.result;
|
|
56
|
+
}
|
|
88
57
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
58
|
+
return (
|
|
59
|
+
catchAllFallback || {
|
|
60
|
+
match: undefined,
|
|
61
|
+
layouts: [],
|
|
62
|
+
hooks: [],
|
|
63
|
+
params: {},
|
|
64
|
+
meta: baseMeta,
|
|
65
|
+
breakFromLayouts: false,
|
|
66
|
+
isCatchAll: false,
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Try to match a single route key against the path. Returns null if the route doesn't match.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} route
|
|
75
|
+
* @param {string[]} pathParts
|
|
76
|
+
* @param {string} pathname
|
|
77
|
+
* @param {Routes} routes
|
|
78
|
+
* @param {RouteMeta} baseMeta
|
|
79
|
+
* @returns {{ result: MatchResult; fallback: boolean } | null}
|
|
80
|
+
*/
|
|
81
|
+
function tryMatch(route, pathParts, pathname, routes, baseMeta) {
|
|
82
|
+
const routeParts = route.split('/');
|
|
83
|
+
if (routeParts[0] === '') routeParts.shift();
|
|
92
84
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
85
|
+
/** @type {Record<string, string>} */
|
|
86
|
+
const params = {};
|
|
87
|
+
/** @type {boolean} */
|
|
88
|
+
let breakFromLayouts;
|
|
89
|
+
|
|
90
|
+
for (let [index, routePart] of routeParts.entries()) {
|
|
91
|
+
breakFromLayouts = routePart.startsWith('(') && routePart.endsWith(')');
|
|
92
|
+
if (breakFromLayouts) {
|
|
93
|
+
routePart = routePart.slice(1, -1);
|
|
94
|
+
}
|
|
96
95
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
96
|
+
const pathPart = pathParts[index];
|
|
97
|
+
const isLayoutGroup = routePart === '' && typeof routes['/'] !== 'function';
|
|
100
98
|
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
// Dynamic segment
|
|
100
|
+
if (routePart.startsWith(':')) {
|
|
101
|
+
params[routePart.slice(1)] = decodeURIComponent(pathPart);
|
|
102
|
+
}
|
|
103
|
+
// Catch-all segment
|
|
104
|
+
else if (routePart.startsWith('*')) {
|
|
105
|
+
const param = routePart.slice(1);
|
|
106
|
+
if (param) {
|
|
107
|
+
params[param] = pathParts.slice(index).map(decodeURIComponent).join('/');
|
|
103
108
|
}
|
|
109
|
+
const context = collectContext(routes, breakFromLayouts, baseMeta);
|
|
110
|
+
const resolvedPath = /** @type {keyof Routes} */ ((index ? '/' : '') + routeParts.join('/'));
|
|
111
|
+
return {
|
|
112
|
+
result: {
|
|
113
|
+
match: /** @type {RouteComponent} */ (routes[resolvedPath]),
|
|
114
|
+
...context,
|
|
115
|
+
params,
|
|
116
|
+
breakFromLayouts,
|
|
117
|
+
isCatchAll: true,
|
|
118
|
+
},
|
|
119
|
+
fallback: false,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// Static segment mismatch
|
|
123
|
+
else if (routePart.toLowerCase() !== pathPart?.toLowerCase() && !isLayoutGroup) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
104
126
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
127
|
+
// Continue matching next segment
|
|
128
|
+
if (index !== routeParts.length - 1) continue;
|
|
108
129
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
130
|
+
// Last segment — resolve the route value
|
|
131
|
+
const routeKey = /** @type {keyof Routes} */ ('/' + routeParts.join('/'));
|
|
132
|
+
const routeMatch = /** @type {RouteComponent} */ (routes[routeKey]);
|
|
112
133
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
break outer;
|
|
117
|
-
}
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
134
|
+
if (typeof routeMatch === 'function' && routeParts.length !== pathParts.length) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
120
137
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
breakFromLayouts = true;
|
|
131
|
-
} else {
|
|
132
|
-
layouts.push(...result.layouts);
|
|
133
|
-
}
|
|
134
|
-
} else {
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
break outer;
|
|
138
|
+
const context = collectContext(routes, breakFromLayouts, baseMeta);
|
|
139
|
+
|
|
140
|
+
// Leaf route (component function)
|
|
141
|
+
if (typeof routeMatch === 'function') {
|
|
142
|
+
if (routeParts.length !== pathParts.length) return null;
|
|
143
|
+
return {
|
|
144
|
+
result: { match: routeMatch, ...context, params, breakFromLayouts, isCatchAll: false },
|
|
145
|
+
fallback: false,
|
|
146
|
+
};
|
|
138
147
|
}
|
|
148
|
+
|
|
149
|
+
// Nested routes — recurse
|
|
150
|
+
const nestedPathname = isLayoutGroup ? pathname : '/' + pathParts.slice(index + 1).join('/');
|
|
151
|
+
const nested = matchRoute(nestedPathname, routeMatch);
|
|
152
|
+
if (!nested.match) return null;
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
result: mergeWithNested(context, nested, params, breakFromLayouts),
|
|
156
|
+
fallback: isLayoutGroup && nested.isCatchAll,
|
|
157
|
+
};
|
|
139
158
|
}
|
|
140
159
|
|
|
141
|
-
return
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Collect layouts, hooks, and meta from the current route level.
|
|
165
|
+
*
|
|
166
|
+
* @param {Routes} routes
|
|
167
|
+
* @param {boolean} breakFromLayouts
|
|
168
|
+
* @param {RouteMeta} baseMeta
|
|
169
|
+
* @returns {{ layouts: LayoutComponent[]; hooks: Hooks[]; meta: RouteMeta }}
|
|
170
|
+
*/
|
|
171
|
+
function collectContext(routes, breakFromLayouts, baseMeta) {
|
|
172
|
+
/** @type {LayoutComponent[]} */
|
|
173
|
+
const layouts = [];
|
|
174
|
+
/** @type {Hooks[]} */
|
|
175
|
+
const hooks = [];
|
|
176
|
+
let meta = { ...baseMeta };
|
|
177
|
+
|
|
178
|
+
if (!breakFromLayouts && 'layout' in routes && routes.layout) {
|
|
179
|
+
layouts.push(routes.layout);
|
|
180
|
+
}
|
|
181
|
+
if ('hooks' in routes && routes.hooks) {
|
|
182
|
+
hooks.push(routes.hooks);
|
|
183
|
+
}
|
|
184
|
+
if ('meta' in routes && routes.meta) {
|
|
185
|
+
meta = { ...meta, ...routes.meta };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { layouts, hooks, meta };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Merge current level context with a nested match result.
|
|
193
|
+
*
|
|
194
|
+
* @param {{ layouts: LayoutComponent[]; hooks: Hooks[]; meta: RouteMeta }} context
|
|
195
|
+
* @param {MatchResult} nested
|
|
196
|
+
* @param {Record<string, string>} params
|
|
197
|
+
* @param {boolean} breakFromLayouts
|
|
198
|
+
* @returns {MatchResult}
|
|
199
|
+
*/
|
|
200
|
+
function mergeWithNested(context, nested, params, breakFromLayouts) {
|
|
201
|
+
const shouldBreak = nested.breakFromLayouts;
|
|
202
|
+
return {
|
|
203
|
+
match: nested.match,
|
|
204
|
+
layouts: shouldBreak ? [] : [...context.layouts, ...nested.layouts],
|
|
205
|
+
hooks: [...context.hooks, ...nested.hooks],
|
|
206
|
+
params: { ...params, ...nested.params },
|
|
207
|
+
meta: { ...context.meta, ...nested.meta },
|
|
208
|
+
breakFromLayouts: shouldBreak || breakFromLayouts,
|
|
209
|
+
isCatchAll: nested.isCatchAll,
|
|
210
|
+
};
|
|
142
211
|
}
|
|
143
212
|
|
|
144
213
|
/**
|
package/src/helpers/utils.js
CHANGED
|
@@ -103,6 +103,14 @@ export function stripBase(pathname) {
|
|
|
103
103
|
return pathname;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/** @param {any} state */
|
|
107
|
+
function getUserState(state) {
|
|
108
|
+
if (state && '_userState' in state) {
|
|
109
|
+
return state._userState;
|
|
110
|
+
}
|
|
111
|
+
return state;
|
|
112
|
+
}
|
|
113
|
+
|
|
106
114
|
export function updatedLocation() {
|
|
107
115
|
const pathname =
|
|
108
116
|
base.name === '#' ? globalThis.location.hash.slice(1) : globalThis.location.pathname;
|
|
@@ -110,7 +118,7 @@ export function updatedLocation() {
|
|
|
110
118
|
return {
|
|
111
119
|
pathname,
|
|
112
120
|
search: globalThis.location.search,
|
|
113
|
-
state: history.state,
|
|
121
|
+
state: getUserState(history.state),
|
|
114
122
|
hash,
|
|
115
123
|
};
|
|
116
124
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -2,19 +2,6 @@ import type { Component, Snippet } from 'svelte';
|
|
|
2
2
|
import type { Action } from 'svelte/action';
|
|
3
3
|
import type { Attachment } from 'svelte/attachments';
|
|
4
4
|
|
|
5
|
-
/**
|
|
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
5
|
/**
|
|
19
6
|
* A Svelte attachment that will add a class to the anchor if its `href` matches the current route.
|
|
20
7
|
* It can have an optional `className` parameter to specify the class to add, otherwise it will
|
|
@@ -43,13 +30,41 @@ export function serializeSearch(search: Search): string | undefined;
|
|
|
43
30
|
export function createRouter<T extends Routes>(r: T): RouterApi<T>;
|
|
44
31
|
|
|
45
32
|
/**
|
|
46
|
-
*
|
|
33
|
+
* Blocks navigation as long as the callback returns `false`.
|
|
34
|
+
*
|
|
35
|
+
* Returns a function that clears the navigation block.
|
|
36
|
+
*
|
|
37
|
+
* ```js
|
|
38
|
+
* $effect(() => blockNavigation(() => confirm('Are you sure you want to leave?')));
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* The callback can also be async:
|
|
42
|
+
*
|
|
43
|
+
* ```js
|
|
44
|
+
* $effect(() => blockNavigation(async () => await showConfirmModal()));
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* If you also need to block tab close, use the object form to handle blocking for navigation and
|
|
48
|
+
* site unloading separately (site unloading cannot be blocked asynchronously):
|
|
47
49
|
*
|
|
48
50
|
* ```js
|
|
49
|
-
*
|
|
51
|
+
* $effect(() =>
|
|
52
|
+
* blockNavigation({
|
|
53
|
+
* beforeUnload() {
|
|
54
|
+
* return false;
|
|
55
|
+
* },
|
|
56
|
+
* async onNavigate() {
|
|
57
|
+
* return await askInModal();
|
|
58
|
+
* },
|
|
59
|
+
* }),
|
|
60
|
+
* );
|
|
50
61
|
* ```
|
|
51
62
|
*/
|
|
52
|
-
export function blockNavigation(
|
|
63
|
+
export function blockNavigation(
|
|
64
|
+
callback:
|
|
65
|
+
| (() => boolean | Promise<boolean>)
|
|
66
|
+
| { beforeUnload?(): boolean; onNavigate(): boolean | Promise<boolean> },
|
|
67
|
+
): () => void;
|
|
53
68
|
|
|
54
69
|
/**
|
|
55
70
|
* The component that will render the current route. You can pass a `base` prop to set the base path
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { isActiveLink
|
|
1
|
+
export { isActiveLink } 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';
|
|
@@ -66,7 +66,8 @@ export function syncSearchParams(search) {
|
|
|
66
66
|
for (const [key, value] of newSearch) {
|
|
67
67
|
searchParams.set(key, value);
|
|
68
68
|
}
|
|
69
|
-
|
|
69
|
+
// eslint-disable-next-line unicorn/no-useless-spread
|
|
70
|
+
for (const key of [...searchParams.keys()]) {
|
|
70
71
|
if (!newSearch.has(key)) {
|
|
71
72
|
searchParams.delete(key);
|
|
72
73
|
}
|