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 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
+ [![npm](https://badgen.net/npm/v/sv-router)](https://www.npmjs.com/package/sv-router)
8
+ [![install size](https://packagephobia.com/badge?p=sv-router)](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.6",
4
- "description": "Modern Svelte routing",
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.27.11",
40
- "@eslint/js": "^9.18.0",
41
- "@types/node": "^22.10.7",
42
- "eslint-config-prettier": "^10.0.1",
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": "^2.46.1",
45
- "eslint-plugin-unicorn": "^56.0.1",
46
- "globals": "^15.14.0",
47
- "prettier": "^3.4.2",
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.7.3",
52
- "typescript-eslint": "^8.20.0",
53
- "vite": "^6.0.7",
54
- "vitest": "^3.0.2"
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
- "ex:config-based": "pnpm --filter config-based-example",
61
- "ex:file-based": "pnpm --filter file-based-example",
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": "tsc --noEmit && pnpm -r 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
@@ -6,7 +6,7 @@
6
6
  onNavigate();
7
7
 
8
8
  $effect(() => {
9
- const off1 = on(globalThis, 'popstate', onNavigate);
9
+ const off1 = on(globalThis, 'popstate', () => onNavigate());
10
10
  const off2 = on(globalThis, 'click', onGlobalClick);
11
11
 
12
12
  return () => {
@@ -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 === location.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
- path += (options.search.startsWith('?') ? '' : '?') + options.search;
71
+ if (options.search && !options.search.startsWith('?')) {
72
+ options.search = '?' + options.search;
69
73
  }
70
- if (options.hash) {
71
- path += options.hash;
74
+ if (options.hash && !options.hash.startsWith('#')) {
75
+ options.hash = '#' + options.hash;
72
76
  }
73
- const historyMethod = options.replace ? 'replaceState' : 'pushState';
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
- export function onNavigate() {
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
- syncSearchParams();
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 { match, layouts, params: newParams } = matchRoute(globalThis.location.pathname, routes);
90
- params.value = newParams || {};
91
- resolveRouteComponents(match ? [...layouts, match] : layouts).then((components) => {
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
- const historyMethod = replace === undefined || replace === 'false' ? 'pushState' : 'replaceState';
110
- globalThis.history[historyMethod](state || {}, '', anchor.href);
111
- onNavigate();
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 PARAM_FILENAME_REGEX = /\[(.*)\].svelte/g;
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 fileTree = buildFileTree(path.join(process.cwd(), routesPath));
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')) continue;
39
- handleFlatFilename(tree, entry);
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
- /** @type {FileTree} */
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
- const catchAll = /\[\.\.\.(.*)\]\.svelte/g.exec(entry); // Match [...slug].svelte
79
- switch (true) {
80
- case entry === 'index.svelte': {
81
- result['/'] = prefix + entry;
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
- case !!catchAll: {
89
- result['*' + catchAll[1]] = prefix + entry;
90
- break;
91
- }
92
- default: {
93
- if (PARAM_FILENAME_REGEX.test(entry)) {
94
- result['/' + entry.replaceAll(PARAM_FILENAME_REGEX, ':$1')] = prefix + entry;
95
- break;
96
- }
97
- result['/' + entry.replace('.svelte', '')] = prefix + entry;
98
- break;
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
- result['/' + entry.name] = createRouteMap(entry.tree, prefix + entry.name + '/');
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
- const jsonRoutes = JSON.stringify(routes, undefined, 2);
119
- const withImports = jsonRoutes.replaceAll(
120
- /"(.*)": "(.*)",?/g,
121
- `"$1": () => import("${routesPath}$2"),`,
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
- 'import { createRouter } from "sv-router";',
125
- '\n\n',
126
- `export const { p, navigate, isActive, route } = createRouter(${withImports});`,
127
- ].join('');
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('Error during routes generation:', error);
40
+ console.error(
41
+ 'Error during routes generation:',
42
+ error instanceof Error ? error.message : String(error),
43
+ );
41
44
  }
42
45
  }
43
46
 
@@ -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 pathname === location.pathname;
30
+ return compareFn(location.pathname, pathname);
12
31
  }
13
32
 
14
33
  if (params) {
15
- return constructPath(pathname, params) === location.pathname;
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 === routePart;
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
  /**
@@ -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\(.*\)/g.test(String(input));
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<HTMLAnchorElement, { className?: string } | undefined>;
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.back();
81
- * navigate.forward();
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
- <U extends Path<T>>(...args: NavigateArgs<U>): void;
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<U extends Path<T>>(...args: IsActiveArgs<U>): boolean;
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?: `#${string}`;
164
+ hash?: string;
143
165
  }
144
166
  | undefined;
145
167
 
146
168
  type NavigateArgs<T extends string> =
147
- PathParams<T> extends never
148
- ? [T, NavigateOptions]
149
- : [T, NavigateOptions & { params: PathParams<T> }];
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 'layout'
177
+ : K extends `(*${string})`
155
178
  ? never
156
- : K]: T[K] extends Routes ? StripNonRoutes<T[K]> : T[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}*${infer Param}`
178
- ? Param extends ''
179
- ? never
180
- : Param
181
- : never;
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;