sv-router 0.18.0 → 0.19.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 CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  Flexible, ergonomic, and complete Svelte 5 router.
13
13
 
14
- [Documentation](https://sv-router.vercel.app/) • [Getting Started](https://sv-router.vercel.app/guide/getting-started) • [Reference](https://sv-router.vercel.app/reference)
14
+ [Documentation](https://sv-router.dev/) • [Getting Started](https://sv-router.dev/guide/getting-started) • [Reference](https://sv-router.dev/reference)
15
15
 
16
16
  </div>
17
17
 
@@ -21,7 +21,7 @@ Flexible, ergonomic, and complete Svelte 5 router.
21
21
 
22
22
  - 🔒 **Type-safe navigation**: Catch broken links before you ship.
23
23
  - 🗂️ **File-based routing (optional)**: DX of a meta-framework-like approach.
24
- - 🪶 **Lightweight**: < 5kB gzipped.
24
+ - 🪶 **Lightweight**: ~ 5kB gzipped.
25
25
  - ⚡ **Performance**: Built-in code splitting and preloading.
26
26
  - 🔍 **Reactive search params**: For simpler state management in the URL.
27
27
  - 🌿 **Nested routes**: Share layouts across pages.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sv-router",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Type-safe routing for Svelte SPAs",
5
5
  "keywords": [
6
6
  "router",
@@ -16,6 +16,9 @@
16
16
  "type": "git",
17
17
  "url": "git+https://github.com/colinlienard/sv-router.git"
18
18
  },
19
+ "bin": {
20
+ "sv-router": "./src/cli/index.js"
21
+ },
19
22
  "files": [
20
23
  "src"
21
24
  ],
@@ -32,32 +35,35 @@
32
35
  "types": "./src/vite-plugin/index.d.ts"
33
36
  }
34
37
  },
38
+ "publishConfig": {
39
+ "provenance": true
40
+ },
35
41
  "dependencies": {
36
42
  "esm-env": "^1.2.2"
37
43
  },
38
44
  "devDependencies": {
39
- "@changesets/cli": "^2.31.0",
45
+ "@changesets/cli": "^3.0.2",
40
46
  "@eslint/js": "^10.0.1",
41
- "@sveltejs/vite-plugin-svelte": "^7.1.2",
42
- "@testing-library/jest-dom": "^6.9.1",
47
+ "@sveltejs/vite-plugin-svelte": "^7.3.0",
48
+ "@testing-library/jest-dom": "^7.0.1",
43
49
  "@testing-library/svelte": "^5.4.2",
44
- "@testing-library/user-event": "^14.6.1",
45
- "@types/node": "^24.13.2",
46
- "@vitest/coverage-v8": "^4.1.9",
47
- "eslint": "^10.6.0",
50
+ "@testing-library/user-event": "^14.6.7",
51
+ "@types/node": "^24.13.4",
52
+ "@vitest/coverage-v8": "^5.0.0",
53
+ "eslint": "^10.10.0",
48
54
  "eslint-config-prettier": "^10.1.8",
49
- "eslint-plugin-simple-import-sort": "^13.0.0",
50
- "eslint-plugin-svelte": "^3.20.0",
51
- "eslint-plugin-unicorn": "^70.0.0",
52
- "globals": "^17.7.0",
53
- "happy-dom": "^20.10.6",
54
- "oxfmt": "^0.57.0",
55
- "svelte-check": "^4.7.1",
55
+ "eslint-plugin-simple-import-sort": "^14.0.0",
56
+ "eslint-plugin-svelte": "^3.23.0",
57
+ "eslint-plugin-unicorn": "^74.0.0",
58
+ "globals": "^17.12.0",
59
+ "happy-dom": "^20.14.3",
60
+ "oxfmt": "^0.67.0",
61
+ "svelte-check": "^4.7.6",
56
62
  "type-testing": "^0.2.0",
57
63
  "typescript": "^6.0.3",
58
- "typescript-eslint": "^8.62.1",
59
- "vite": "^8.1.3",
60
- "vitest": "^4.1.9"
64
+ "typescript-eslint": "^8.70.0",
65
+ "vite": "^8.2.2",
66
+ "vitest": "^5.0.0"
61
67
  },
62
68
  "peerDependencies": {
63
69
  "svelte": "^5"
@@ -76,8 +82,5 @@
76
82
  "format": "oxfmt --check",
77
83
  "format:fix": "oxfmt",
78
84
  "changeset": "changeset"
79
- },
80
- "bin": {
81
- "sv-router": "./src/cli/index.js"
82
85
  }
83
86
  }
@@ -1,4 +1,5 @@
1
1
  import { base, location } from './create-router.svelte.js';
2
+ import { comparePathParts, toPathParts } from './helpers/utils.js';
2
3
 
3
4
  /** @type {import('./index.d.ts').IsActiveLink} */
4
5
  export function isActiveLink({ className = 'is-active', startsWith = false } = {}) {
@@ -8,14 +9,13 @@ export function isActiveLink({ className = 'is-active', startsWith = false } = {
8
9
  }
9
10
 
10
11
  $effect(() => {
11
- let pathname;
12
- if (base.name === '#') {
13
- pathname = new URL(node.href).hash.slice(1);
14
- } else {
15
- pathname = new URL(node.href).pathname;
16
- }
17
- const tokens = className.split(' ').filter(Boolean) ?? [];
18
- if (startsWith ? location.pathname.startsWith(pathname) : location.pathname === pathname) {
12
+ const url = new URL(node.href);
13
+ const pathname = base.name === '#' ? url.hash.slice(1) : url.pathname;
14
+ const tokens = className.split(' ').filter(Boolean);
15
+ const isActive =
16
+ pathname !== '' &&
17
+ comparePathParts(toPathParts(pathname), toPathParts(location.pathname), startsWith);
18
+ if (isActive) {
19
19
  node.classList.add(...tokens);
20
20
  } else {
21
21
  node.classList.remove(...tokens);
@@ -147,7 +147,7 @@ export function createRouter(r, options = {}) {
147
147
  return params.value;
148
148
  },
149
149
  getParams(pathname) {
150
- if (!isActive(pathname)) {
150
+ if (!isActive.startsWith(pathname)) {
151
151
  throw new Error(`\`${pathname}\` does not match the current route`);
152
152
  }
153
153
  return params.value;
@@ -220,21 +220,21 @@ export async function onNavigate(path, options = {}) {
220
220
  return;
221
221
  }
222
222
 
223
+ const popstateDelta = path ? 0 : historyIndex - (history.state?._routerIndex ?? 0);
224
+ function revertPopstate() {
225
+ if (path || popstateDelta === 0) return;
226
+ isSkipNextPopstate = true;
227
+ history.go(popstateDelta);
228
+ }
229
+
223
230
  if (navigationBlockers.size > 0) {
224
- const popstateDelta = path ? 0 : historyIndex - (history.state?._routerIndex ?? 0);
225
231
  for (const blocker of navigationBlockers.values()) {
226
232
  const shouldNavigate = typeof blocker === 'object' ? blocker.onNavigate : blocker;
227
233
  if (!(await shouldNavigate())) {
228
- if (!path && popstateDelta !== 0) {
229
- isSkipNextPopstate = true;
230
- history.go(popstateDelta);
231
- }
234
+ revertPopstate();
232
235
  return;
233
236
  }
234
237
  }
235
- if (!path) {
236
- historyIndex = history.state?._routerIndex ?? historyIndex;
237
- }
238
238
  }
239
239
 
240
240
  if (pendingController && pendingController !== currentNavigationController) {
@@ -250,7 +250,13 @@ export async function onNavigate(path, options = {}) {
250
250
 
251
251
  const navContext = path ? options : { ...options, ...updatedLocation() };
252
252
  const search = parseSearch(navContext.search);
253
- const hooksContext = { ...navContext, pathname: matchPath, meta: newMeta, search };
253
+ const hooksContext = {
254
+ ...navContext,
255
+ pathname: matchPath,
256
+ meta: newMeta,
257
+ params: newParams,
258
+ search,
259
+ };
254
260
 
255
261
  let errorHooks = [];
256
262
  for (const hook of hooks) {
@@ -259,7 +265,11 @@ export async function onNavigate(path, options = {}) {
259
265
  const { beforeLoad } = hook;
260
266
  errorHooks.push(hook);
261
267
  pendingController = currentNavigationController;
262
- await beforeLoad?.(hooksContext);
268
+ if ((await beforeLoad?.(hooksContext)) === false) {
269
+ if (signal.aborted) return currentNavigationPromise;
270
+ revertPopstate();
271
+ return;
272
+ }
263
273
  } catch (error) {
264
274
  if (signal.aborted) return currentNavigationPromise;
265
275
  for (const { onError } of errorHooks) {
@@ -271,6 +281,10 @@ export async function onNavigate(path, options = {}) {
271
281
  }
272
282
  }
273
283
 
284
+ if (!path) {
285
+ historyIndex = history.state?._routerIndex ?? historyIndex;
286
+ }
287
+
274
288
  let routeComponents;
275
289
  try {
276
290
  routeComponents = await resolveRouteComponents(match ? [...layouts, match] : layouts);
@@ -212,7 +212,7 @@ export function createRouterCode(routes, routesPath, { allLazy = false, base, js
212
212
  result[key] = `{ ...${varNames.toReversed().join(', ...')} }`;
213
213
  } else if (
214
214
  typeof value === 'string' &&
215
- (key === 'hooks' || key === 'meta' || (!value.endsWith('.lazy.svelte') && !allLazy))
215
+ (key === 'hooks' || key === 'meta' || (!allLazy && !value.endsWith('.lazy.svelte')))
216
216
  ) {
217
217
  const variableName = pathToCorrectCasing(value);
218
218
  importsMap.set(variableName, routesPath + value);
@@ -1,5 +1,5 @@
1
- import { base, location } from '../create-router.svelte.js';
2
- import { constructPath } from './utils.js';
1
+ import { location } from '../create-router.svelte.js';
2
+ import { comparePathParts, stripBase, toPathParts } from './utils.js';
3
3
 
4
4
  /**
5
5
  * @param {string} pathname
@@ -7,7 +7,7 @@ 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);
10
+ return compare(pathname, false, params);
11
11
  }
12
12
 
13
13
  /**
@@ -16,41 +16,20 @@ export function isActive(pathname, params) {
16
16
  * @returns {boolean}
17
17
  */
18
18
  isActive.startsWith = (pathname, params) => {
19
- return compare((a, b) => a.startsWith(b), pathname, params);
19
+ return compare(pathname, true, params);
20
20
  };
21
21
 
22
22
  /**
23
- * @param {function(string, string): boolean} compareFn
24
23
  * @param {string} pathname
24
+ * @param {boolean} startsWith
25
25
  * @param {Record<string, string>} [params]
26
26
  * @returns {boolean}
27
27
  */
28
- function compare(compareFn, pathname, params) {
29
- if (!pathname.includes(':')) {
30
- const target = base.name && base.name !== '#' ? constructPath(pathname) : pathname;
31
- return compareFn(location.pathname, target);
32
- }
33
-
34
- if (params) {
35
- if (base.name === '#') {
36
- return compareFn(location.pathname, constructPath(pathname, params).replace('/#', ''));
37
- }
38
- return compareFn(location.pathname, constructPath(pathname, params));
39
- }
40
-
41
- const pathParts = pathname.split('/').slice(1);
42
- let routeParts = location.pathname.split('/').slice(1);
43
- if (pathParts.length > routeParts.length) {
44
- return false;
45
- }
46
- for (const [index, pathPart] of pathParts.entries()) {
47
- if (pathPart.startsWith(':')) {
48
- continue;
49
- }
50
- const routePart = routeParts[index];
51
- if (pathPart !== routePart) {
52
- return false;
53
- }
54
- }
55
- return true;
28
+ function compare(pathname, startsWith, params) {
29
+ return comparePathParts(
30
+ toPathParts(pathname),
31
+ toPathParts(stripBase(location.pathname)),
32
+ startsWith,
33
+ params,
34
+ );
56
35
  }
@@ -118,7 +118,7 @@ function tryMatch(route, pathParts, pathname, routes, baseMeta) {
118
118
  };
119
119
  }
120
120
  // Static segment mismatch
121
- else if (routePart.toLowerCase() !== pathPart?.toLowerCase() && !isLayoutGroup) {
121
+ else if (!isLayoutGroup && routePart.toLowerCase() !== pathPart?.toLowerCase()) {
122
122
  return null;
123
123
  }
124
124
 
@@ -12,11 +12,12 @@ const PREDICT_TIMEOUT = 50;
12
12
  * @param {import('../index.d.ts').NavigateOptions} [options]
13
13
  */
14
14
  export async function preload(routes, path, options) {
15
- const { match, layouts, hooks, meta } = matchRoute(path, routes);
15
+ const { match, layouts, hooks, meta, params } = matchRoute(path, routes);
16
16
  for (const { onPreload } of hooks) {
17
17
  void onPreload?.({
18
18
  pathname: path,
19
19
  meta,
20
+ params,
20
21
  ...options,
21
22
  search: parseSearch(options?.search),
22
23
  });
@@ -49,7 +50,7 @@ export function preloadOnHover(routes) {
49
50
  /** @type {ReturnType<typeof setTimeout> | null} */
50
51
  let throttleTimer = null;
51
52
  function pointerMoveListener(/** @type {PointerEvent} */ event) {
52
- if (!event.getPredictedEvents || throttleTimer) return;
53
+ if (throttleTimer || !event.getPredictedEvents) return;
53
54
  throttleTimer = setTimeout(() => {
54
55
  throttleTimer = null;
55
56
  }, PREDICT_TIMEOUT);
@@ -92,6 +92,42 @@ export function join(...parts) {
92
92
  return result;
93
93
  }
94
94
 
95
+ /**
96
+ * @param {string} pathname
97
+ * @returns {string[]}
98
+ */
99
+ export function toPathParts(pathname) {
100
+ return pathname.replace(/\/+$/, '').split('/').slice(1);
101
+ }
102
+
103
+ /**
104
+ * @param {string[]} templateParts
105
+ * @param {string[]} pathParts
106
+ * @param {boolean} [startsWith]
107
+ * @param {Record<string, string>} [params]
108
+ * @returns {boolean}
109
+ */
110
+ export function comparePathParts(templateParts, pathParts, startsWith, params) {
111
+ if (
112
+ startsWith ? pathParts.length < templateParts.length : pathParts.length !== templateParts.length
113
+ ) {
114
+ return false;
115
+ }
116
+
117
+ for (const [index, templatePart] of templateParts.entries()) {
118
+ const pathPart = pathParts[index];
119
+ if (templatePart.startsWith(':')) {
120
+ const param = params?.[templatePart.slice(1)];
121
+ if (param !== undefined && encodeURIComponent(param) !== pathPart) {
122
+ return false;
123
+ }
124
+ } else if (templatePart.toLowerCase() !== pathPart.toLowerCase()) {
125
+ return false;
126
+ }
127
+ }
128
+ return true;
129
+ }
130
+
95
131
  /**
96
132
  * @param {string} pathname
97
133
  * @returns {string}
package/src/index.d.ts CHANGED
@@ -119,8 +119,18 @@ export type Hooks = {
119
119
  * throw navigate('/home');
120
120
  * }
121
121
  * ```
122
+ *
123
+ * Returning `false` cancels the navigation and stays on the current route.
124
+ *
125
+ * ```js
126
+ * beforeLoad({ meta }) {
127
+ * if (meta.requiresAuth && !user) {
128
+ * return false;
129
+ * }
130
+ * }
131
+ * ```
122
132
  */
123
- beforeLoad?(context: HooksContext): void | Promise<void>;
133
+ beforeLoad?(context: HooksContext): void | boolean | Promise<void | boolean>;
124
134
  /** A function that will be called after the route is loaded. */
125
135
  afterLoad?(context: HooksContext): void | Promise<void>;
126
136
  /** A function that will be called when the route is preloaded. */
@@ -304,6 +314,7 @@ export type Search = string | Record<string, string | number | boolean>;
304
314
  export type HooksContext = {
305
315
  hash?: string;
306
316
  meta: RouteMeta;
317
+ params: Record<string, string>;
307
318
  pathname: string;
308
319
  replace?: boolean;
309
320
  search: Record<string, string | number | boolean>;
@@ -350,17 +361,19 @@ type NavigateArgs<T extends string> =
350
361
  | [number];
351
362
 
352
363
  type StripNonRoutes<T extends Routes> = {
353
- [K in keyof T as K extends `*${string}`
354
- ? never
355
- : K extends `(*${string})`
364
+ [
365
+ K in keyof T as K extends `*${string}`
356
366
  ? never
357
- : K extends 'layout'
367
+ : K extends `(*${string})`
358
368
  ? never
359
- : K extends 'hooks'
369
+ : K extends 'layout'
360
370
  ? never
361
- : K extends 'meta'
371
+ : K extends 'hooks'
362
372
  ? never
363
- : K]: T[K] extends Routes ? StripNonRoutes<T[K]> : T[K];
373
+ : K extends 'meta'
374
+ ? never
375
+ : K
376
+ ]: T[K] extends Routes ? StripNonRoutes<T[K]> : T[K];
364
377
  };
365
378
 
366
379
  type NormalizeSlashes<T extends string> = T extends `${infer A}//${infer B}`