sv-router 0.0.7 → 0.1.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 +38 -1
- package/package.json +1 -1
- package/src/actions.svelte.js +2 -2
- package/src/create-router.svelte.js +6 -1
- package/src/gen/generate-router-code.js +22 -11
- package/src/gen/write-router-code.js +14 -6
- package/src/helpers/is-active.js +22 -6
- package/src/helpers/match-route.js +3 -0
- package/src/index.d.ts +33 -13
- package/src/search-params.svelte.js +16 -18
package/README.md
CHANGED
|
@@ -1,3 +1,40 @@
|
|
|
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
|
+
- 🔄 **Flexibility**: Choose between code-based or file-based routing approaches.
|
|
22
|
+
- 🌿 **Nested routes**: Create complex layouts with ease.
|
|
23
|
+
- 🔍 **Reactive search params**: For simpler state management in the URL.
|
|
24
|
+
- 🛡️ **Hooks**: For navigation guards, data loading, or analytics tracking.
|
|
25
|
+
- ⚡ **Performance**: Optimized for speed with built-in code splitting and preloading.
|
|
26
|
+
- 🧩 **Familiar API**: Follows established conventions from popular meta frameworks.
|
|
27
|
+
- 🪶 **Lightweight**: Minimal impact on your bundle size.
|
|
28
|
+
- 🚀 **Made for Svelte 5**: True Svelte 5 implementation with the latest features.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
Add it to an existing Svelte project:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install sv-router
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## License
|
|
39
|
+
|
|
40
|
+
[MIT](./LICENSE) © Colin Lienard
|
package/package.json
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);
|
|
@@ -113,6 +113,10 @@ export async function onNavigate(path, options = {}) {
|
|
|
113
113
|
syncSearchParams();
|
|
114
114
|
Object.assign(location, updatedLocation());
|
|
115
115
|
|
|
116
|
+
if (options.scrollToTop !== false) {
|
|
117
|
+
window.scrollTo({ top: 0, left: 0, behavior: options.scrollToTop });
|
|
118
|
+
}
|
|
119
|
+
|
|
116
120
|
for (const { afterLoad } of hooks) {
|
|
117
121
|
afterLoad?.();
|
|
118
122
|
}
|
|
@@ -130,12 +134,13 @@ export function onGlobalClick(event) {
|
|
|
130
134
|
if (url.origin !== currentOrigin) return;
|
|
131
135
|
|
|
132
136
|
event.preventDefault();
|
|
133
|
-
const { replace, state } = anchor.dataset;
|
|
137
|
+
const { replace, state, scrollToTop } = anchor.dataset;
|
|
134
138
|
onNavigate(url.pathname, {
|
|
135
139
|
replace: replace === '' || replace === 'true',
|
|
136
140
|
search: url.search,
|
|
137
141
|
state,
|
|
138
142
|
hash: url.hash,
|
|
143
|
+
scrollToTop: scrollToTop === 'false' ? false : /** @type ScrollBehavior */ (scrollToTop),
|
|
139
144
|
});
|
|
140
145
|
}
|
|
141
146
|
|
|
@@ -9,18 +9,24 @@ import path from 'node:path';
|
|
|
9
9
|
* }} GeneratedRoutes
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
12
|
+
const FILENAME_REGEX = /(?<=[/.]|^)\(?([\w-]+)\)?(\.lazy)?\.svelte$/; // any.svelte, any.lazy.svelte, (any).svelte
|
|
13
|
+
const PARAM_FILENAME_REGEX = /(?<=[/.]|^)\(?\[([\w-]+)\]\)?(\.lazy)?\.svelte$/; // [any].svelte, [any].lazy.svelte, ([any]).svelte
|
|
14
|
+
const CATCH_ALL_FILENAME_REGEX = /(?<=[/.]|^)\(?\[\.\.\.([\w-]+)\]\)?(\.lazy)?\.svelte$/; // [...any].svelte, [...any].lazy.svelte, ([...any]).svelte
|
|
15
|
+
const OUT_OF_LAYOUT_FILENAME_REGEX = /(?<=[/.]|^)\(\[\.?\.?\.?([\w-]+)\]\)(\.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
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* @param {string} routesPath
|
|
18
20
|
* @returns {string}
|
|
19
21
|
*/
|
|
20
22
|
export function generateRouterCode(routesPath) {
|
|
21
|
-
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);
|
|
22
28
|
const routeMap = createRouteMap(fileTree);
|
|
23
|
-
return createRouterCode(routeMap, path.join('..', routesPath));
|
|
29
|
+
return createRouterCode(routeMap, path.posix.join('..', routesPath));
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
/**
|
|
@@ -74,15 +80,20 @@ export function createRouteMap(fileTree, prefix = '') {
|
|
|
74
80
|
continue;
|
|
75
81
|
}
|
|
76
82
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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;
|
|
88
|
+
}
|
|
89
|
+
result[key] = prefix + entry;
|
|
80
90
|
continue;
|
|
81
91
|
}
|
|
82
92
|
|
|
83
|
-
// Match [id].svelte
|
|
84
93
|
if (PARAM_FILENAME_REGEX.test(entry)) {
|
|
85
|
-
|
|
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;
|
|
86
97
|
continue;
|
|
87
98
|
}
|
|
88
99
|
|
|
@@ -176,7 +187,7 @@ export function pathToCorrectCasing(value) {
|
|
|
176
187
|
extractLastPart(CATCH_ALL_FILENAME_REGEX) ||
|
|
177
188
|
extractLastPart(PARAM_FILENAME_REGEX) ||
|
|
178
189
|
extractLastPart(HOOKS_FILENAME_REGEX) ||
|
|
179
|
-
extractLastPart(
|
|
190
|
+
extractLastPart(FILENAME_REGEX);
|
|
180
191
|
if (!lastPart) {
|
|
181
192
|
throw new Error(`Invalid filename: ${value}`);
|
|
182
193
|
}
|
|
@@ -9,10 +9,6 @@ export function writeRouterCode() {
|
|
|
9
9
|
fs.mkdirSync(genConfig.genCodeDirPath);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
// Write `.router/router.ts` file
|
|
13
|
-
const routerCode = generateRouterCode(genConfig.routesPath);
|
|
14
|
-
writeFileIfDifferent(genConfig.routerPath, routerCode);
|
|
15
|
-
|
|
16
12
|
// Write `.router/tsconfig.json` file
|
|
17
13
|
const tsConfig = {
|
|
18
14
|
compilerOptions: {
|
|
@@ -35,9 +31,20 @@ export function writeRouterCode() {
|
|
|
35
31
|
};
|
|
36
32
|
writeFileIfDifferent(genConfig.tsconfigPath, JSON.stringify(tsConfig, undefined, 2));
|
|
37
33
|
|
|
38
|
-
|
|
34
|
+
// Write `.router/router.ts` file
|
|
35
|
+
const routerCode = generateRouterCode(genConfig.routesPath);
|
|
36
|
+
const written = writeFileIfDifferent(genConfig.routerPath, routerCode);
|
|
37
|
+
|
|
38
|
+
if (written) {
|
|
39
|
+
console.log('✅️ Routes generated');
|
|
40
|
+
} else {
|
|
41
|
+
console.log('✅️ Routes already up to date');
|
|
42
|
+
}
|
|
39
43
|
} catch (error) {
|
|
40
|
-
console.error(
|
|
44
|
+
console.error(
|
|
45
|
+
'Error during routes generation:',
|
|
46
|
+
error instanceof Error ? error.message : String(error),
|
|
47
|
+
);
|
|
41
48
|
}
|
|
42
49
|
}
|
|
43
50
|
|
|
@@ -48,5 +55,6 @@ export function writeRouterCode() {
|
|
|
48
55
|
function writeFileIfDifferent(filePath, content) {
|
|
49
56
|
if (!fs.existsSync(filePath) || fs.readFileSync(filePath, 'utf8') !== content) {
|
|
50
57
|
fs.writeFileSync(filePath, content);
|
|
58
|
+
return true;
|
|
51
59
|
}
|
|
52
60
|
}
|
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
|
}
|
|
@@ -59,6 +59,9 @@ export function matchRoute(pathname, routes) {
|
|
|
59
59
|
if (param) {
|
|
60
60
|
params[param] = pathParts.slice(index).join('/');
|
|
61
61
|
}
|
|
62
|
+
if (breakFromLayouts) {
|
|
63
|
+
routePart = `(${routePart})`;
|
|
64
|
+
}
|
|
62
65
|
const resolvedPath = /** @type {keyof Routes} */ (
|
|
63
66
|
(index ? '/' : '') + routeParts.join('/')
|
|
64
67
|
);
|
package/src/index.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export const Router: Component;
|
|
|
29
29
|
* The reactive search params of the URL. It is just a wrapper around `SvelteURLSearchParam` that
|
|
30
30
|
* will update the url on change.
|
|
31
31
|
*/
|
|
32
|
-
export const searchParams:
|
|
32
|
+
export const searchParams: SearchParams;
|
|
33
33
|
|
|
34
34
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
35
35
|
type BaseProps = {};
|
|
@@ -63,12 +63,15 @@ export type Hooks = {
|
|
|
63
63
|
|
|
64
64
|
export type Routes = {
|
|
65
65
|
[_: `/${string}`]: RouteComponent | Routes;
|
|
66
|
-
[_: `*${string}`]: RouteComponent | undefined;
|
|
66
|
+
[_: `*${string}` | `(*${string})`]: RouteComponent | undefined;
|
|
67
67
|
layout?: LayoutComponent;
|
|
68
68
|
hooks?: Hooks;
|
|
69
69
|
};
|
|
70
70
|
|
|
71
|
-
export type IsActiveLink = Action<
|
|
71
|
+
export type IsActiveLink = Action<
|
|
72
|
+
HTMLAnchorElement,
|
|
73
|
+
{ className?: string; startsWith?: boolean } | undefined
|
|
74
|
+
>;
|
|
72
75
|
|
|
73
76
|
export type RouterApi<T extends Routes> = {
|
|
74
77
|
/**
|
|
@@ -114,7 +117,10 @@ export type RouterApi<T extends Routes> = {
|
|
|
114
117
|
* @param path The route to check.
|
|
115
118
|
* @param params The optional parameters to replace in the route.
|
|
116
119
|
*/
|
|
117
|
-
isActive
|
|
120
|
+
isActive: {
|
|
121
|
+
<U extends Path<T>>(...args: IsActiveArgs<U>): boolean;
|
|
122
|
+
startsWith<U extends Path<T>>(...args: IsActiveArgs<U>): boolean;
|
|
123
|
+
};
|
|
118
124
|
route: {
|
|
119
125
|
/**
|
|
120
126
|
* An object containing the parameters of the current route.
|
|
@@ -156,9 +162,17 @@ export type NavigateOptions =
|
|
|
156
162
|
search?: string;
|
|
157
163
|
state?: string;
|
|
158
164
|
hash?: string;
|
|
165
|
+
scrollToTop?: ScrollBehavior | false;
|
|
159
166
|
}
|
|
160
167
|
| undefined;
|
|
161
168
|
|
|
169
|
+
export type SearchParams = URLSearchParams & {
|
|
170
|
+
append: (name: string, value: string, options?: { replace?: boolean }) => void;
|
|
171
|
+
delete: (name: string, value?: string, options?: { replace?: boolean }) => void;
|
|
172
|
+
set: (name: string, value: string, options?: { replace?: boolean }) => void;
|
|
173
|
+
sort: (options?: { replace?: boolean }) => void;
|
|
174
|
+
};
|
|
175
|
+
|
|
162
176
|
type NavigateArgs<T extends string> =
|
|
163
177
|
| (PathParams<T> extends never
|
|
164
178
|
? [T] | [T, NavigateOptions]
|
|
@@ -168,11 +182,13 @@ type NavigateArgs<T extends string> =
|
|
|
168
182
|
type StripNonRoutes<T extends Routes> = {
|
|
169
183
|
[K in keyof T as K extends `*${string}`
|
|
170
184
|
? never
|
|
171
|
-
: K extends
|
|
185
|
+
: K extends `(*${string})`
|
|
172
186
|
? never
|
|
173
|
-
: K extends '
|
|
187
|
+
: K extends 'layout'
|
|
174
188
|
? never
|
|
175
|
-
: K
|
|
189
|
+
: K extends 'hooks'
|
|
190
|
+
? never
|
|
191
|
+
: K]: T[K] extends Routes ? StripNonRoutes<T[K]> : T[K];
|
|
176
192
|
};
|
|
177
193
|
|
|
178
194
|
type RecursiveKeys<T extends Routes, Prefix extends string = ''> = {
|
|
@@ -191,10 +207,14 @@ type RemoveParenthesis<T extends string> = T extends `${infer A}(${infer B})${in
|
|
|
191
207
|
|
|
192
208
|
type ExtractParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}`
|
|
193
209
|
? Param | ExtractParams<`/${Rest}`>
|
|
194
|
-
: T extends `${string}:${infer Param}`
|
|
210
|
+
: T extends `${string}(:${infer Param})`
|
|
195
211
|
? Param
|
|
196
|
-
: T extends `${string}
|
|
197
|
-
? Param
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
212
|
+
: T extends `${string}:${infer Param}`
|
|
213
|
+
? Param
|
|
214
|
+
: T extends `${string}(*${infer Param})`
|
|
215
|
+
? Param
|
|
216
|
+
: T extends `${string}*${infer Param}`
|
|
217
|
+
? Param extends ''
|
|
218
|
+
? never
|
|
219
|
+
: Param
|
|
220
|
+
: never;
|
|
@@ -2,15 +2,15 @@ import { SvelteURLSearchParams } from 'svelte/reactivity';
|
|
|
2
2
|
|
|
3
3
|
let searchParams = new SvelteURLSearchParams(globalThis.location.search);
|
|
4
4
|
|
|
5
|
-
/** @type {
|
|
5
|
+
/** @type {import('./index.js').SearchParams} */
|
|
6
6
|
const shell = {
|
|
7
|
-
append(
|
|
8
|
-
searchParams.append(
|
|
9
|
-
updateUrlSearchParams();
|
|
7
|
+
append(name, value, options) {
|
|
8
|
+
searchParams.append(name, value);
|
|
9
|
+
updateUrlSearchParams(options);
|
|
10
10
|
},
|
|
11
|
-
delete(
|
|
12
|
-
searchParams.delete(
|
|
13
|
-
updateUrlSearchParams();
|
|
11
|
+
delete(name, value, options) {
|
|
12
|
+
searchParams.delete(name, value);
|
|
13
|
+
updateUrlSearchParams(options);
|
|
14
14
|
},
|
|
15
15
|
entries() {
|
|
16
16
|
return searchParams.entries();
|
|
@@ -31,13 +31,13 @@ const shell = {
|
|
|
31
31
|
keys() {
|
|
32
32
|
return searchParams.keys();
|
|
33
33
|
},
|
|
34
|
-
set(
|
|
35
|
-
searchParams.set(
|
|
36
|
-
updateUrlSearchParams();
|
|
34
|
+
set(name, value, options) {
|
|
35
|
+
searchParams.set(name, value);
|
|
36
|
+
updateUrlSearchParams(options);
|
|
37
37
|
},
|
|
38
|
-
sort() {
|
|
38
|
+
sort(options) {
|
|
39
39
|
searchParams.sort();
|
|
40
|
-
updateUrlSearchParams();
|
|
40
|
+
updateUrlSearchParams(options);
|
|
41
41
|
},
|
|
42
42
|
toString() {
|
|
43
43
|
return searchParams.toString();
|
|
@@ -60,19 +60,17 @@ export function syncSearchParams() {
|
|
|
60
60
|
if (searchParams.toString() === newSearchParams.toString()) {
|
|
61
61
|
return;
|
|
62
62
|
}
|
|
63
|
-
|
|
64
|
-
for (const key of searchParams.keys()) {
|
|
65
|
-
searchParams.delete(key);
|
|
66
|
-
}
|
|
63
|
+
searchParams = new SvelteURLSearchParams();
|
|
67
64
|
for (const [key, value] of newSearchParams.entries()) {
|
|
68
65
|
searchParams.append(key, value);
|
|
69
66
|
}
|
|
70
67
|
}
|
|
71
68
|
|
|
72
|
-
|
|
69
|
+
/** @param {{ replace?: boolean }} [options] */
|
|
70
|
+
function updateUrlSearchParams(options) {
|
|
73
71
|
let url = globalThis.location.origin + globalThis.location.pathname;
|
|
74
72
|
if (searchParams.size > 0) {
|
|
75
73
|
url += '?' + searchParams.toString();
|
|
76
74
|
}
|
|
77
|
-
globalThis.history
|
|
75
|
+
globalThis.history[options?.replace ? 'replaceState' : 'pushState']({}, '', url);
|
|
78
76
|
}
|