sv-router 0.0.6 → 0.0.8
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 +35 -1
- package/package.json +20 -17
- package/src/Router.svelte +1 -1
- package/src/actions.svelte.js +2 -2
- package/src/create-router.svelte.js +48 -20
- package/src/gen/generate-router-code.js +132 -59
- package/src/gen/write-router-code.js +4 -1
- package/src/helpers/is-active.js +22 -6
- package/src/helpers/match-route.js +15 -1
- package/src/helpers/utils.js +1 -1
- package/src/index.d.ts +54 -23
package/README.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<img src="./docs/public/logo.svg" alt="" height="128px">
|
|
4
|
+
|
|
1
5
|
# sv-router
|
|
2
6
|
|
|
3
|
-
https://www.npmjs.com/package/sv-router
|
|
7
|
+
[](https://www.npmjs.com/package/sv-router)
|
|
8
|
+
[](https://packagephobia.com/result?p=sv-router)
|
|
9
|
+
|
|
10
|
+
A feature-rich yet intuitive routing library for Svelte single-page apps.
|
|
11
|
+
|
|
12
|
+
[Documentation](https://sv-router.vercel.app/) • [Getting Started](https://sv-router.vercel.app/guide/getting-started) • [Reference](https://sv-router.vercel.app/reference)
|
|
13
|
+
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- 🔒 **Typesafe navigation**: Get autocomplete and type checking for your routes.
|
|
21
|
+
- 🗂️ **File-based routing (optional)**: Enjoy the DX of a meta-framework-like approach.
|
|
22
|
+
- 🌿 **Nested routes**: Create complex layouts with ease.
|
|
23
|
+
- ⚡ **Performance**: Optimized for speed with built-in code splitting and preloading.
|
|
24
|
+
- 🧩 **Familiar API**: Follows established conventions from popular meta frameworks
|
|
25
|
+
- 🚀 **Made for Svelte 5**: Benefit from faster performance and smaller bundle size.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
Add it to an existing Svelte project:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install sv-router
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
[MIT](./LICENSE) © Colin Lienard
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sv-router",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Modern Svelte
|
|
3
|
+
"version": "0.0.8",
|
|
4
|
+
"description": "Modern Svelte Routing",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|
|
7
7
|
"router",
|
|
@@ -36,31 +36,34 @@
|
|
|
36
36
|
"esm-env": "^1.2.2"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@changesets/cli": "^2.
|
|
40
|
-
"@eslint/js": "^9.
|
|
41
|
-
"@types/node": "^22.
|
|
42
|
-
"eslint
|
|
39
|
+
"@changesets/cli": "^2.28.1",
|
|
40
|
+
"@eslint/js": "^9.21.0",
|
|
41
|
+
"@types/node": "^22.13.8",
|
|
42
|
+
"eslint": "^9.21.0",
|
|
43
|
+
"eslint-config-prettier": "^10.0.2",
|
|
43
44
|
"eslint-plugin-simple-import-sort": "^12.1.1",
|
|
44
|
-
"eslint-plugin-svelte": "^
|
|
45
|
-
"eslint-plugin-unicorn": "^
|
|
46
|
-
"globals": "^
|
|
47
|
-
"prettier": "^3.
|
|
45
|
+
"eslint-plugin-svelte": "^3.0.2",
|
|
46
|
+
"eslint-plugin-unicorn": "^57.0.0",
|
|
47
|
+
"globals": "^16.0.0",
|
|
48
|
+
"prettier": "^3.5.2",
|
|
48
49
|
"prettier-plugin-jsdoc": "^1.3.2",
|
|
49
50
|
"prettier-plugin-svelte": "^3.3.3",
|
|
51
|
+
"svelte-check": "^4.1.4",
|
|
50
52
|
"type-testing": "^0.2.0",
|
|
51
|
-
"typescript": "^5.
|
|
52
|
-
"typescript-eslint": "^8.
|
|
53
|
-
"vite": "^6.0
|
|
54
|
-
"vitest": "^3.0.
|
|
53
|
+
"typescript": "^5.8.2",
|
|
54
|
+
"typescript-eslint": "^8.25.0",
|
|
55
|
+
"vite": "^6.2.0",
|
|
56
|
+
"vitest": "^3.0.7"
|
|
55
57
|
},
|
|
56
58
|
"peerDependencies": {
|
|
57
59
|
"svelte": "^5"
|
|
58
60
|
},
|
|
59
61
|
"scripts": {
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
+
"docs:dev": "pnpm --filter docs dev",
|
|
63
|
+
"docs:build": "pnpm --filter docs build",
|
|
64
|
+
"docs:preview": "pnpm --filter docs preview",
|
|
62
65
|
"test": "vitest",
|
|
63
|
-
"check": "
|
|
66
|
+
"check": "svelte-check && pnpm -r check",
|
|
64
67
|
"lint": "eslint .",
|
|
65
68
|
"lint:fix": "eslint . --fix",
|
|
66
69
|
"format": "prettier . --check",
|
package/src/Router.svelte
CHANGED
package/src/actions.svelte.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { location } from './create-router.svelte.js';
|
|
2
2
|
|
|
3
3
|
/** @type {import('./index.d.ts').IsActiveLink} */
|
|
4
|
-
export function isActiveLink(node, { className = 'is-active' } = {}) {
|
|
4
|
+
export function isActiveLink(node, { className = 'is-active', startsWith = false } = {}) {
|
|
5
5
|
if (node.tagName !== 'A') {
|
|
6
6
|
throw new Error('isActiveLink can only be used on <a> elements');
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
$effect(() => {
|
|
10
10
|
const pathname = new URL(node.href).pathname;
|
|
11
|
-
if (pathname
|
|
11
|
+
if (startsWith ? location.pathname.startsWith(pathname) : location.pathname === pathname) {
|
|
12
12
|
node.classList.add(className);
|
|
13
13
|
} else {
|
|
14
14
|
node.classList.remove(className);
|
|
@@ -57,40 +57,65 @@ export function createRouter(r) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
* @param {string} path
|
|
60
|
+
* @param {string | number} path
|
|
61
61
|
* @param {import('./index.d.ts').NavigateOptions & { params?: Record<string, string> }} options
|
|
62
62
|
*/
|
|
63
63
|
function navigate(path, options = {}) {
|
|
64
|
+
if (typeof path === 'number') {
|
|
65
|
+
globalThis.history.go(path);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
64
68
|
if (options.params) {
|
|
65
69
|
path = constructPath(path, options.params);
|
|
66
70
|
}
|
|
67
|
-
if (options.search) {
|
|
68
|
-
|
|
71
|
+
if (options.search && !options.search.startsWith('?')) {
|
|
72
|
+
options.search = '?' + options.search;
|
|
69
73
|
}
|
|
70
|
-
if (options.hash) {
|
|
71
|
-
|
|
74
|
+
if (options.hash && !options.hash.startsWith('#')) {
|
|
75
|
+
options.hash = '#' + options.hash;
|
|
72
76
|
}
|
|
73
|
-
|
|
74
|
-
globalThis.history[historyMethod](options.state || {}, '', path);
|
|
75
|
-
onNavigate();
|
|
77
|
+
onNavigate(path, options);
|
|
76
78
|
}
|
|
77
|
-
navigate.back = () => globalThis.history.back();
|
|
78
|
-
navigate.forward = () => globalThis.history.forward();
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
/**
|
|
81
|
+
* @param {string} [path]
|
|
82
|
+
* @param {import('./index.d.ts').NavigateOptions} options
|
|
83
|
+
*/
|
|
84
|
+
export async function onNavigate(path, options = {}) {
|
|
81
85
|
if (!routes) {
|
|
82
86
|
throw new Error('Router not initialized: `createRouter` was not called.');
|
|
83
87
|
}
|
|
88
|
+
const {
|
|
89
|
+
match,
|
|
90
|
+
layouts,
|
|
91
|
+
hooks,
|
|
92
|
+
params: newParams,
|
|
93
|
+
} = matchRoute(path || globalThis.location.pathname, routes);
|
|
94
|
+
|
|
95
|
+
for (const { beforeLoad } of hooks) {
|
|
96
|
+
try {
|
|
97
|
+
await beforeLoad?.();
|
|
98
|
+
} catch {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
84
102
|
|
|
85
|
-
|
|
103
|
+
componentTree.value = await resolveRouteComponents(match ? [...layouts, match] : layouts);
|
|
104
|
+
params.value = newParams || {};
|
|
86
105
|
|
|
106
|
+
if (path) {
|
|
107
|
+
if (options.search) path += options.search;
|
|
108
|
+
if (options.hash) path += options.hash;
|
|
109
|
+
const historyMethod = options.replace ? 'replaceState' : 'pushState';
|
|
110
|
+
globalThis.history[historyMethod](options.state || {}, '', path);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
syncSearchParams();
|
|
87
114
|
Object.assign(location, updatedLocation());
|
|
88
115
|
|
|
89
|
-
const {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
componentTree.value = components;
|
|
93
|
-
});
|
|
116
|
+
for (const { afterLoad } of hooks) {
|
|
117
|
+
afterLoad?.();
|
|
118
|
+
}
|
|
94
119
|
}
|
|
95
120
|
|
|
96
121
|
/** @param {Event} event */
|
|
@@ -106,9 +131,12 @@ export function onGlobalClick(event) {
|
|
|
106
131
|
|
|
107
132
|
event.preventDefault();
|
|
108
133
|
const { replace, state } = anchor.dataset;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
134
|
+
onNavigate(url.pathname, {
|
|
135
|
+
replace: replace === '' || replace === 'true',
|
|
136
|
+
search: url.search,
|
|
137
|
+
state,
|
|
138
|
+
hash: url.hash,
|
|
139
|
+
});
|
|
112
140
|
}
|
|
113
141
|
|
|
114
142
|
function updatedLocation() {
|
|
@@ -9,16 +9,24 @@ import path from 'node:path';
|
|
|
9
9
|
* }} GeneratedRoutes
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
const
|
|
12
|
+
const FILENAME_REGEX = /\(?([\w-]+)\)?(\.lazy)?\.svelte$/; // any.svelte, any.lazy.svelte, (any).svelte
|
|
13
|
+
const PARAM_FILENAME_REGEX = /\(?\[(.*)\]\)?(\.lazy)?\.svelte$/; // [any].svelte, [any].lazy.svelte, ([any]).svelte
|
|
14
|
+
const CATCH_ALL_FILENAME_REGEX = /\(?\[\.\.\.(.*)\]\)?(\.lazy)?\.svelte$/; // [...any].svelte, [...any].lazy.svelte, ([...any]).svelte
|
|
15
|
+
const OUT_OF_LAYOUT_FILENAME_REGEX = /\(\[\.?\.?\.?(.*)\]\)(\.lazy)?\.svelte$/; // ([any]).svelte, ([...any]).lazy.svelte
|
|
16
|
+
const HOOKS_FILENAME_REGEX = /(hooks)(\.svelte)?\.(js|ts)$/; // hooks.js, hooks.svelte.js, hooks.ts, hooks.svelte.ts
|
|
13
17
|
|
|
14
18
|
/**
|
|
15
19
|
* @param {string} routesPath
|
|
16
20
|
* @returns {string}
|
|
17
21
|
*/
|
|
18
22
|
export function generateRouterCode(routesPath) {
|
|
19
|
-
const
|
|
23
|
+
const absoluteRoutesPath = path.join(process.cwd(), routesPath);
|
|
24
|
+
if (!fs.existsSync(absoluteRoutesPath)) {
|
|
25
|
+
throw new Error(`Routes directory not found at \`${routesPath}\``);
|
|
26
|
+
}
|
|
27
|
+
const fileTree = buildFileTree(absoluteRoutesPath);
|
|
20
28
|
const routeMap = createRouteMap(fileTree);
|
|
21
|
-
return createRouterCode(routeMap, path.join('..', routesPath));
|
|
29
|
+
return createRouterCode(routeMap, path.posix.join('..', routesPath));
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
/**
|
|
@@ -35,34 +43,12 @@ export function buildFileTree(routesPath) {
|
|
|
35
43
|
tree.push({ name: entry, tree: buildFileTree(path.join(routesPath, entry)) });
|
|
36
44
|
continue;
|
|
37
45
|
}
|
|
38
|
-
if (!entry.endsWith('.svelte'))
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
return tree;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* @param {FileTree} tree
|
|
46
|
-
* @param {string} path
|
|
47
|
-
*/
|
|
48
|
-
function handleFlatFilename(tree, path) {
|
|
49
|
-
// Split the path by the first dot that is not preceded or followed by another dot
|
|
50
|
-
const splited = path.split(/(?<!\.)\.(?!\.)/);
|
|
51
|
-
if (splited.length === 2) {
|
|
52
|
-
tree.push(path);
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
const first = /** @type {string} */ (splited.shift());
|
|
56
|
-
for (const item of tree) {
|
|
57
|
-
if (typeof item === 'object' && item.name === first) {
|
|
58
|
-
handleFlatFilename(item.tree, splited.join('.'));
|
|
59
|
-
return;
|
|
46
|
+
if (!entry.endsWith('.svelte') && !HOOKS_FILENAME_REGEX.test(entry)) {
|
|
47
|
+
continue;
|
|
60
48
|
}
|
|
49
|
+
tree.push(entry);
|
|
61
50
|
}
|
|
62
|
-
|
|
63
|
-
const branch = [];
|
|
64
|
-
handleFlatFilename(branch, splited.join('.'));
|
|
65
|
-
tree.push({ name: first, tree: branch });
|
|
51
|
+
return tree;
|
|
66
52
|
}
|
|
67
53
|
|
|
68
54
|
/**
|
|
@@ -75,36 +61,61 @@ export function createRouteMap(fileTree, prefix = '') {
|
|
|
75
61
|
const result = {};
|
|
76
62
|
for (const entry of fileTree) {
|
|
77
63
|
if (typeof entry === 'string') {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
break;
|
|
83
|
-
}
|
|
84
|
-
case entry === 'layout.svelte': {
|
|
85
|
-
result['layout'] = prefix + entry;
|
|
86
|
-
break;
|
|
64
|
+
if (!entry.endsWith('.svelte')) {
|
|
65
|
+
if (HOOKS_FILENAME_REGEX.test(entry)) {
|
|
66
|
+
result['hooks'] = prefix + entry;
|
|
67
|
+
continue;
|
|
87
68
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (entry.endsWith('index.svelte') || entry.endsWith('index.lazy.svelte')) {
|
|
73
|
+
const indexEntry = entry.replace(/\.?index(\.lazy)?\.svelte/, '');
|
|
74
|
+
result['/' + (indexEntry ? filePathToRoute(indexEntry) : '')] = prefix + entry;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (entry === 'layout.svelte' || entry === 'layout.lazy.svelte') {
|
|
79
|
+
result['layout'] = prefix + entry;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (CATCH_ALL_FILENAME_REGEX.test(entry)) {
|
|
84
|
+
const replacement = OUT_OF_LAYOUT_FILENAME_REGEX.test(entry) ? '(*$1)' : '*$1';
|
|
85
|
+
let key = filePathToRoute(entry.replace(CATCH_ALL_FILENAME_REGEX, replacement));
|
|
86
|
+
if (!key.startsWith('*') && !key.startsWith('(*')) {
|
|
87
|
+
key = '/' + key;
|
|
99
88
|
}
|
|
89
|
+
result[key] = prefix + entry;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (PARAM_FILENAME_REGEX.test(entry)) {
|
|
94
|
+
const replacement = OUT_OF_LAYOUT_FILENAME_REGEX.test(entry) ? '(:$1)' : ':$1';
|
|
95
|
+
const key = '/' + filePathToRoute(entry.replace(PARAM_FILENAME_REGEX, replacement));
|
|
96
|
+
result[key] = prefix + entry;
|
|
97
|
+
continue;
|
|
100
98
|
}
|
|
99
|
+
|
|
100
|
+
result['/' + filePathToRoute(entry.replace('.svelte', ''))] = prefix + entry;
|
|
101
101
|
} else {
|
|
102
|
-
|
|
102
|
+
const entryName = filePathToRoute(entry.name);
|
|
103
|
+
result['/' + entryName] = createRouteMap(entry.tree, prefix + entryName + '/');
|
|
103
104
|
}
|
|
104
105
|
}
|
|
105
106
|
return result;
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Replace `.` with `/`, but not `...`
|
|
111
|
+
*
|
|
112
|
+
* @param {string} filename
|
|
113
|
+
* @returns {string}
|
|
114
|
+
*/
|
|
115
|
+
function filePathToRoute(filename) {
|
|
116
|
+
return filename.replaceAll(/\.(?!\.\.)/g, '/');
|
|
117
|
+
}
|
|
118
|
+
|
|
108
119
|
/**
|
|
109
120
|
* @param {GeneratedRoutes} routes
|
|
110
121
|
* @param {string} routesPath
|
|
@@ -115,14 +126,76 @@ export function createRouterCode(routes, routesPath) {
|
|
|
115
126
|
routesPath += '/';
|
|
116
127
|
}
|
|
117
128
|
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
129
|
+
/** @type {Map<string, string>} */
|
|
130
|
+
const importsMap = new Map();
|
|
131
|
+
|
|
132
|
+
const withImports = (function handleImports(routes, routesPath) {
|
|
133
|
+
/** @type {GeneratedRoutes} */
|
|
134
|
+
const result = {};
|
|
135
|
+
for (const [key, value] of Object.entries(routes)) {
|
|
136
|
+
if (typeof value === 'object') {
|
|
137
|
+
result[key] = handleImports(value, routesPath);
|
|
138
|
+
} else if (key === 'hooks' || !value.endsWith('.lazy.svelte')) {
|
|
139
|
+
const variableName = pathToCorrectCasing(value);
|
|
140
|
+
importsMap.set(variableName, routesPath + value);
|
|
141
|
+
result[key] = variableName;
|
|
142
|
+
} else {
|
|
143
|
+
result[key] = `() => import('${routesPath}${value}')`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
})(routes, routesPath);
|
|
148
|
+
|
|
149
|
+
const imports = [...importsMap.entries()].map(([key, value]) => {
|
|
150
|
+
if (value.endsWith('.ts')) {
|
|
151
|
+
value = value.replace('.ts', '');
|
|
152
|
+
}
|
|
153
|
+
return `import ${key} from '${value}';`;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const stringifiedRoutes = JSON.stringify(withImports, undefined, 2)
|
|
157
|
+
.replaceAll(/"(.*)": /g, `'$1': `)
|
|
158
|
+
.replaceAll(/: "(.*)"/g, ': $1');
|
|
159
|
+
|
|
123
160
|
return [
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
161
|
+
`import { createRouter } from 'sv-router';`,
|
|
162
|
+
...imports,
|
|
163
|
+
'',
|
|
164
|
+
`export const { p, navigate, isActive, route } = createRouter(${stringifiedRoutes});`,
|
|
165
|
+
].join('\n');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @param {string} value
|
|
170
|
+
* @returns {string}
|
|
171
|
+
*/
|
|
172
|
+
export function pathToCorrectCasing(value) {
|
|
173
|
+
const parts = /** @type {string[]} */ ([]);
|
|
174
|
+
|
|
175
|
+
/** @param {RegExp} regex */
|
|
176
|
+
function extractLastPart(regex) {
|
|
177
|
+
if (!regex.test(value)) return;
|
|
178
|
+
const exec = /** @type {RegExpExecArray} */ (regex.exec(value));
|
|
179
|
+
if (exec.index > 0) {
|
|
180
|
+
const before = value.slice(0, exec.index - 1);
|
|
181
|
+
parts.push(...before.split(/\/|-|\./));
|
|
182
|
+
}
|
|
183
|
+
return exec[1];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const lastPart =
|
|
187
|
+
extractLastPart(CATCH_ALL_FILENAME_REGEX) ||
|
|
188
|
+
extractLastPart(PARAM_FILENAME_REGEX) ||
|
|
189
|
+
extractLastPart(HOOKS_FILENAME_REGEX) ||
|
|
190
|
+
extractLastPart(FILENAME_REGEX);
|
|
191
|
+
if (!lastPart) {
|
|
192
|
+
throw new Error(`Invalid filename: ${value}`);
|
|
193
|
+
}
|
|
194
|
+
parts.push(...lastPart.split('-'));
|
|
195
|
+
|
|
196
|
+
const uppercased = parts.map((part, index) => {
|
|
197
|
+
if (index === 0 && lastPart === 'hooks') return part;
|
|
198
|
+
return part.charAt(0).toUpperCase() + part.slice(1);
|
|
199
|
+
});
|
|
200
|
+
return uppercased.join('');
|
|
128
201
|
}
|
|
@@ -37,7 +37,10 @@ export function writeRouterCode() {
|
|
|
37
37
|
|
|
38
38
|
console.log('✅️ Routes generated');
|
|
39
39
|
} catch (error) {
|
|
40
|
-
console.error(
|
|
40
|
+
console.error(
|
|
41
|
+
'Error during routes generation:',
|
|
42
|
+
error instanceof Error ? error.message : String(error),
|
|
43
|
+
);
|
|
41
44
|
}
|
|
42
45
|
}
|
|
43
46
|
|
package/src/helpers/is-active.js
CHANGED
|
@@ -7,25 +7,41 @@ import { constructPath } from './utils.js';
|
|
|
7
7
|
* @returns {boolean}
|
|
8
8
|
*/
|
|
9
9
|
export function isActive(pathname, params) {
|
|
10
|
+
return compare((a, b) => a === b, pathname, params);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} pathname
|
|
15
|
+
* @param {Record<string, string>} [params]
|
|
16
|
+
* @returns {boolean}
|
|
17
|
+
*/
|
|
18
|
+
isActive.startsWith = (pathname, params) => {
|
|
19
|
+
return compare((a, b) => a.startsWith(b), pathname, params);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {function(string, string): boolean} compareFn
|
|
24
|
+
* @param {string} pathname
|
|
25
|
+
* @param {Record<string, string>} [params]
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
function compare(compareFn, pathname, params) {
|
|
10
29
|
if (!pathname.includes(':')) {
|
|
11
|
-
return
|
|
30
|
+
return compareFn(location.pathname, pathname);
|
|
12
31
|
}
|
|
13
32
|
|
|
14
33
|
if (params) {
|
|
15
|
-
return constructPath(pathname, params)
|
|
34
|
+
return compareFn(location.pathname, constructPath(pathname, params));
|
|
16
35
|
}
|
|
17
36
|
|
|
18
37
|
const pathParts = pathname.split('/').slice(1);
|
|
19
38
|
const routeParts = location.pathname.split('/').slice(1);
|
|
20
|
-
if (pathParts.length !== routeParts.length) {
|
|
21
|
-
return false;
|
|
22
|
-
}
|
|
23
39
|
for (const [index, pathPart] of pathParts.entries()) {
|
|
24
40
|
const routePart = routeParts[index];
|
|
25
41
|
if (routePart.startsWith(':')) {
|
|
26
42
|
continue;
|
|
27
43
|
}
|
|
28
|
-
return pathPart
|
|
44
|
+
return compareFn(pathPart, routePart);
|
|
29
45
|
}
|
|
30
46
|
return false;
|
|
31
47
|
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @typedef {import('../index.d.ts').RouteComponent} RouteComponent
|
|
5
5
|
*
|
|
6
|
+
* @typedef {import('../index.d.ts').Hooks} Hooks
|
|
7
|
+
*
|
|
6
8
|
* @typedef {import('../index.d.ts').Routes} Routes
|
|
7
9
|
*/
|
|
8
10
|
|
|
@@ -12,6 +14,7 @@
|
|
|
12
14
|
* @returns {{
|
|
13
15
|
* match: RouteComponent | undefined;
|
|
14
16
|
* layouts: LayoutComponent[];
|
|
17
|
+
* hooks: Hooks[];
|
|
15
18
|
* params: Record<string, string>;
|
|
16
19
|
* breakFromLayouts: boolean;
|
|
17
20
|
* }}
|
|
@@ -30,6 +33,9 @@ export function matchRoute(pathname, routes) {
|
|
|
30
33
|
/** @type {LayoutComponent[]} */
|
|
31
34
|
let layouts = [];
|
|
32
35
|
|
|
36
|
+
/** @type {Hooks[]} */
|
|
37
|
+
let hooks = [];
|
|
38
|
+
|
|
33
39
|
/** @type {Record<string, string>} */
|
|
34
40
|
let params = {};
|
|
35
41
|
|
|
@@ -53,6 +59,9 @@ export function matchRoute(pathname, routes) {
|
|
|
53
59
|
if (param) {
|
|
54
60
|
params[param] = pathParts.slice(index).join('/');
|
|
55
61
|
}
|
|
62
|
+
if (breakFromLayouts) {
|
|
63
|
+
routePart = `(${routePart})`;
|
|
64
|
+
}
|
|
56
65
|
const resolvedPath = /** @type {keyof Routes} */ (
|
|
57
66
|
(index ? '/' : '') + routeParts.join('/')
|
|
58
67
|
);
|
|
@@ -74,6 +83,10 @@ export function matchRoute(pathname, routes) {
|
|
|
74
83
|
layouts.push(routes.layout);
|
|
75
84
|
}
|
|
76
85
|
|
|
86
|
+
if ('hooks' in routes && routes.hooks) {
|
|
87
|
+
hooks.push(routes.hooks);
|
|
88
|
+
}
|
|
89
|
+
|
|
77
90
|
if (typeof routeMatch === 'function') {
|
|
78
91
|
if (routeParts.length === pathParts.length) {
|
|
79
92
|
match = routeMatch;
|
|
@@ -87,6 +100,7 @@ export function matchRoute(pathname, routes) {
|
|
|
87
100
|
if (result) {
|
|
88
101
|
match = result.match;
|
|
89
102
|
params = { ...params, ...result.params };
|
|
103
|
+
hooks.push(...result.hooks);
|
|
90
104
|
if (result.breakFromLayouts) {
|
|
91
105
|
layouts = [];
|
|
92
106
|
} else {
|
|
@@ -97,7 +111,7 @@ export function matchRoute(pathname, routes) {
|
|
|
97
111
|
}
|
|
98
112
|
}
|
|
99
113
|
|
|
100
|
-
return { match, layouts, params, breakFromLayouts };
|
|
114
|
+
return { match, layouts, hooks, params, breakFromLayouts };
|
|
101
115
|
}
|
|
102
116
|
|
|
103
117
|
/**
|
package/src/helpers/utils.js
CHANGED
|
@@ -42,5 +42,5 @@ export function resolveRouteComponent(input) {
|
|
|
42
42
|
* @returns {input is import('../index.d.ts').LazyRouteComponent}
|
|
43
43
|
*/
|
|
44
44
|
export function isLazyImport(input) {
|
|
45
|
-
return typeof input === 'function' && !!/\(\)\s?=>\s?import\(.*\)
|
|
45
|
+
return typeof input === 'function' && !!/\(\)\s?=>\s?import\(.*\)/.test(String(input));
|
|
46
46
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export const isActiveLink: IsActiveLink;
|
|
|
15
15
|
* Setup a new router instance with the given routes.
|
|
16
16
|
*
|
|
17
17
|
* ```js
|
|
18
|
-
* export const { p, navigate, route } = createRouter({
|
|
18
|
+
* export const { p, navigate, isActive, route } = createRouter({
|
|
19
19
|
* '/': Home,
|
|
20
20
|
* '/about': About,
|
|
21
21
|
* ...
|
|
@@ -42,14 +42,36 @@ export type RouteComponent<Props extends BaseProps = any> =
|
|
|
42
42
|
| Component<Props>
|
|
43
43
|
| LazyRouteComponent<Props>;
|
|
44
44
|
export type LayoutComponent = RouteComponent<{ children: Snippet }>;
|
|
45
|
+
export type Hooks = {
|
|
46
|
+
/**
|
|
47
|
+
* A function that will be called before the route is loaded. If it returns a promise, the route
|
|
48
|
+
* will wait for it to resolve before loading.
|
|
49
|
+
*
|
|
50
|
+
* You can throw a `navigate` call to redirect to another route.
|
|
51
|
+
*
|
|
52
|
+
* ```js
|
|
53
|
+
* async beforeLoad() {
|
|
54
|
+
* await ...
|
|
55
|
+
* throw navigate('/home');
|
|
56
|
+
* }
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
beforeLoad?(): void | Promise<void>;
|
|
60
|
+
/** A function that will be called after the route is loaded. */
|
|
61
|
+
afterLoad?(): void;
|
|
62
|
+
};
|
|
45
63
|
|
|
46
64
|
export type Routes = {
|
|
47
65
|
[_: `/${string}`]: RouteComponent | Routes;
|
|
48
|
-
[_: `*${string}`]: RouteComponent | undefined;
|
|
66
|
+
[_: `*${string}` | `(*${string})`]: RouteComponent | undefined;
|
|
49
67
|
layout?: LayoutComponent;
|
|
68
|
+
hooks?: Hooks;
|
|
50
69
|
};
|
|
51
70
|
|
|
52
|
-
export type IsActiveLink = Action<
|
|
71
|
+
export type IsActiveLink = Action<
|
|
72
|
+
HTMLAnchorElement,
|
|
73
|
+
{ className?: string; startsWith?: boolean } | undefined
|
|
74
|
+
>;
|
|
53
75
|
|
|
54
76
|
export type RouterApi<T extends Routes> = {
|
|
55
77
|
/**
|
|
@@ -77,18 +99,15 @@ export type RouterApi<T extends Routes> = {
|
|
|
77
99
|
* },
|
|
78
100
|
* });
|
|
79
101
|
* // Back and forward
|
|
80
|
-
* navigate
|
|
81
|
-
* navigate
|
|
102
|
+
* navigate(-1);
|
|
103
|
+
* navigate(2);
|
|
82
104
|
* ```
|
|
83
105
|
*
|
|
84
106
|
* @param route The route to navigate to.
|
|
85
107
|
* @param options The navigation options.
|
|
86
108
|
*/
|
|
87
|
-
navigate:
|
|
88
|
-
|
|
89
|
-
back: () => void;
|
|
90
|
-
forward: () => void;
|
|
91
|
-
};
|
|
109
|
+
navigate<U extends Path<T>>(...args: NavigateArgs<U>): void;
|
|
110
|
+
|
|
92
111
|
/**
|
|
93
112
|
* Will return `true` if the given path is active.
|
|
94
113
|
*
|
|
@@ -98,7 +117,10 @@ export type RouterApi<T extends Routes> = {
|
|
|
98
117
|
* @param path The route to check.
|
|
99
118
|
* @param params The optional parameters to replace in the route.
|
|
100
119
|
*/
|
|
101
|
-
isActive
|
|
120
|
+
isActive: {
|
|
121
|
+
<U extends Path<T>>(...args: IsActiveArgs<U>): boolean;
|
|
122
|
+
startsWith<U extends Path<T>>(...args: IsActiveArgs<U>): boolean;
|
|
123
|
+
};
|
|
102
124
|
route: {
|
|
103
125
|
/**
|
|
104
126
|
* An object containing the parameters of the current route.
|
|
@@ -139,21 +161,26 @@ export type NavigateOptions =
|
|
|
139
161
|
replace?: boolean;
|
|
140
162
|
search?: string;
|
|
141
163
|
state?: string;
|
|
142
|
-
hash?:
|
|
164
|
+
hash?: string;
|
|
143
165
|
}
|
|
144
166
|
| undefined;
|
|
145
167
|
|
|
146
168
|
type NavigateArgs<T extends string> =
|
|
147
|
-
PathParams<T> extends never
|
|
148
|
-
|
|
149
|
-
|
|
169
|
+
| (PathParams<T> extends never
|
|
170
|
+
? [T] | [T, NavigateOptions]
|
|
171
|
+
: [T, NavigateOptions & { params: PathParams<T> }])
|
|
172
|
+
| [number];
|
|
150
173
|
|
|
151
174
|
type StripNonRoutes<T extends Routes> = {
|
|
152
175
|
[K in keyof T as K extends `*${string}`
|
|
153
176
|
? never
|
|
154
|
-
: K extends
|
|
177
|
+
: K extends `(*${string})`
|
|
155
178
|
? never
|
|
156
|
-
: K
|
|
179
|
+
: K extends 'layout'
|
|
180
|
+
? never
|
|
181
|
+
: K extends 'hooks'
|
|
182
|
+
? never
|
|
183
|
+
: K]: T[K] extends Routes ? StripNonRoutes<T[K]> : T[K];
|
|
157
184
|
};
|
|
158
185
|
|
|
159
186
|
type RecursiveKeys<T extends Routes, Prefix extends string = ''> = {
|
|
@@ -172,10 +199,14 @@ type RemoveParenthesis<T extends string> = T extends `${infer A}(${infer B})${in
|
|
|
172
199
|
|
|
173
200
|
type ExtractParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}`
|
|
174
201
|
? Param | ExtractParams<`/${Rest}`>
|
|
175
|
-
: T extends `${string}:${infer Param}`
|
|
202
|
+
: T extends `${string}(:${infer Param})`
|
|
176
203
|
? Param
|
|
177
|
-
: T extends `${string}
|
|
178
|
-
? Param
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
204
|
+
: T extends `${string}:${infer Param}`
|
|
205
|
+
? Param
|
|
206
|
+
: T extends `${string}(*${infer Param})`
|
|
207
|
+
? Param
|
|
208
|
+
: T extends `${string}*${infer Param}`
|
|
209
|
+
? Param extends ''
|
|
210
|
+
? never
|
|
211
|
+
: Param
|
|
212
|
+
: never;
|