sv-router 0.5.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sv-router",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Modern Svelte Routing",
5
5
  "keywords": [
6
6
  "svelte",
@@ -36,24 +36,24 @@
36
36
  "esm-env": "^1.2.2"
37
37
  },
38
38
  "devDependencies": {
39
- "@changesets/cli": "^2.29.3",
40
- "@eslint/js": "^9.26.0",
41
- "@types/node": "^22.15.15",
42
- "eslint": "^9.26.0",
43
- "eslint-config-prettier": "^10.1.3",
39
+ "@changesets/cli": "^2.29.4",
40
+ "@eslint/js": "^9.28.0",
41
+ "@types/node": "^22.15.30",
42
+ "eslint": "^9.28.0",
43
+ "eslint-config-prettier": "^10.1.5",
44
44
  "eslint-plugin-simple-import-sort": "^12.1.1",
45
- "eslint-plugin-svelte": "^3.5.1",
45
+ "eslint-plugin-svelte": "^3.9.1",
46
46
  "eslint-plugin-unicorn": "^59.0.1",
47
- "globals": "^16.1.0",
47
+ "globals": "^16.2.0",
48
48
  "prettier": "^3.5.3",
49
49
  "prettier-plugin-jsdoc": "^1.3.2",
50
- "prettier-plugin-svelte": "^3.3.3",
51
- "svelte-check": "^4.1.7",
50
+ "prettier-plugin-svelte": "^3.4.0",
51
+ "svelte-check": "^4.2.1",
52
52
  "type-testing": "^0.2.0",
53
53
  "typescript": "^5.8.3",
54
- "typescript-eslint": "^8.32.0",
54
+ "typescript-eslint": "^8.33.1",
55
55
  "vite": "^6.3.5",
56
- "vitest": "^3.1.3"
56
+ "vitest": "^3.2.2"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "svelte": "^5"
@@ -2,7 +2,7 @@ import { BROWSER, DEV } from 'esm-env';
2
2
  import { isActive } from './helpers/is-active.js';
3
3
  import { matchRoute } from './helpers/match-route.js';
4
4
  import { preload, preloadOnHover } from './helpers/preload.js';
5
- import { constructPath, join, resolveRouteComponents } from './helpers/utils.js';
5
+ import { constructPath, join, resolveRouteComponents, updatedLocation } from './helpers/utils.js';
6
6
  import { syncSearchParams } from './search-params.svelte.js';
7
7
 
8
8
  /** @type {import('./index.d.ts').Routes} */
@@ -16,6 +16,8 @@ export let params = $state({ value: {} });
16
16
 
17
17
  export let location = $state(updatedLocation());
18
18
 
19
+ let meta = $state({ value: {} });
20
+
19
21
  let navigationIndex = 0;
20
22
  let pendingNavigationIndex = 0;
21
23
 
@@ -51,6 +53,12 @@ export function createRouter(r) {
51
53
  get params() {
52
54
  return params.value;
53
55
  },
56
+ getParams(pathname) {
57
+ if (!isActive(pathname)) {
58
+ throw new Error(`\`${pathname}\` does not match the current route`);
59
+ }
60
+ return params.value;
61
+ },
54
62
  get pathname() {
55
63
  return /** @type {import('./index.d.ts').Path<T>} */ (location.pathname);
56
64
  },
@@ -63,6 +71,9 @@ export function createRouter(r) {
63
71
  get hash() {
64
72
  return location.hash;
65
73
  },
74
+ get meta() {
75
+ return meta.value;
76
+ },
66
77
  },
67
78
  };
68
79
  }
@@ -104,12 +115,12 @@ export async function onNavigate(path, options = {}) {
104
115
  if (base.name && matchPath.startsWith(base.name)) {
105
116
  matchPath = matchPath.slice(base.name.length) || '/';
106
117
  }
107
- const { match, layouts, hooks, params: newParams } = matchRoute(matchPath, routes);
118
+ const { match, layouts, hooks, meta: newMeta, params: newParams } = matchRoute(matchPath, routes);
108
119
 
109
120
  for (const { beforeLoad } of hooks) {
110
121
  try {
111
122
  pendingNavigationIndex = currentNavigationIndex;
112
- await beforeLoad?.();
123
+ await beforeLoad?.({ pathname: matchPath, ...options });
113
124
  } catch {
114
125
  return;
115
126
  }
@@ -140,7 +151,8 @@ export async function onNavigate(path, options = {}) {
140
151
  } else {
141
152
  componentTree.value = routeComponents;
142
153
  }
143
- params.value = newParams || {};
154
+ params.value = newParams;
155
+ meta.value = newMeta;
144
156
  syncSearchParams();
145
157
  Object.assign(location, updatedLocation());
146
158
 
@@ -149,7 +161,7 @@ export async function onNavigate(path, options = {}) {
149
161
  }
150
162
 
151
163
  for (const { afterLoad } of hooks) {
152
- afterLoad?.();
164
+ afterLoad?.({ pathname: matchPath, ...options });
153
165
  }
154
166
  }
155
167
 
@@ -175,12 +187,3 @@ export function onGlobalClick(event) {
175
187
  viewTransition: viewTransition === '' || viewTransition === 'true',
176
188
  });
177
189
  }
178
-
179
- function updatedLocation() {
180
- return {
181
- pathname: globalThis.location.pathname,
182
- search: globalThis.location.search,
183
- state: history.state,
184
- hash: globalThis.location.hash,
185
- };
186
- }
@@ -14,6 +14,7 @@ const PARAM_FILENAME_REGEX = /(?<=[/.]|^)\(?\[([\w-]+)\]\)?(\.lazy)?\.svelte$/;
14
14
  const CATCH_ALL_FILENAME_REGEX = /(?<=[/.]|^)\(?\[\.\.\.([\w-]+)\]\)?(\.lazy)?\.svelte$/; // [...any].svelte, [...any].lazy.svelte, ([...any]).svelte
15
15
  const OUT_OF_LAYOUT_FILENAME_REGEX = /(?<=[/.]|^)\(\[\.?\.?\.?([\w-]+)\]\)(\.lazy)?\.svelte$/; // ([any]).svelte, ([...any]).lazy.svelte
16
16
  const HOOKS_FILENAME_REGEX = /(?<=[/.]|^)(hooks)(\.svelte)?\.(js|ts)$/; // hooks.js, hooks.svelte.js, hooks.ts, hooks.svelte.ts
17
+ const META_FILENAME_REGEX = /(?<=[/.]|^)(meta)(\.svelte)?\.(js|ts)$/; // meta.js, meta.svelte.js, meta.ts, meta.svelte.ts
17
18
 
18
19
  /**
19
20
  * @param {string} routesPath
@@ -44,7 +45,11 @@ export function buildFileTree(routesPath) {
44
45
  tree.push({ name: entry, tree: buildFileTree(path.join(routesPath, entry)) });
45
46
  continue;
46
47
  }
47
- if (!entry.endsWith('.svelte') && !HOOKS_FILENAME_REGEX.test(entry)) {
48
+ if (
49
+ !entry.endsWith('.svelte') &&
50
+ !HOOKS_FILENAME_REGEX.test(entry) &&
51
+ !META_FILENAME_REGEX.test(entry)
52
+ ) {
48
53
  continue;
49
54
  }
50
55
  tree.push(entry);
@@ -67,6 +72,10 @@ export function createRouteMap(fileTree, prefix = '') {
67
72
  result['hooks'] = prefix + entry;
68
73
  continue;
69
74
  }
75
+ if (META_FILENAME_REGEX.test(entry)) {
76
+ result['meta'] = prefix + entry;
77
+ continue;
78
+ }
70
79
  continue;
71
80
  }
72
81
 
@@ -101,7 +110,8 @@ export function createRouteMap(fileTree, prefix = '') {
101
110
  result['/' + filePathToRoute(entry.replace('.svelte', ''))] = prefix + entry;
102
111
  } else {
103
112
  const entryName = filePathToRoute(entry.name);
104
- result['/' + entryName] = createRouteMap(entry.tree, prefix + entryName + '/');
113
+ const paramFolder = entryName.replace(/^\[(.*)\]$/, ':$1');
114
+ result['/' + paramFolder] = createRouteMap(entry.tree, prefix + entryName + '/');
105
115
  }
106
116
  }
107
117
  return result;
@@ -137,7 +147,11 @@ export function createRouterCode(routes, routesPath, { allLazy = false } = {}) {
137
147
  for (const [key, value] of Object.entries(routes)) {
138
148
  if (typeof value === 'object') {
139
149
  result[key] = handleImports(value, routesPath);
140
- } else if (key === 'hooks' || (!value.endsWith('.lazy.svelte') && !allLazy)) {
150
+ } else if (
151
+ key === 'hooks' ||
152
+ key === 'meta' ||
153
+ (!value.endsWith('.lazy.svelte') && !allLazy)
154
+ ) {
141
155
  const variableName = pathToCorrectCasing(value);
142
156
  importsMap.set(variableName, routesPath + value);
143
157
  result[key] = variableName;
@@ -189,6 +203,7 @@ export function pathToCorrectCasing(value) {
189
203
  extractLastPart(CATCH_ALL_FILENAME_REGEX) ||
190
204
  extractLastPart(PARAM_FILENAME_REGEX) ||
191
205
  extractLastPart(HOOKS_FILENAME_REGEX) ||
206
+ extractLastPart(META_FILENAME_REGEX) ||
192
207
  extractLastPart(FILENAME_REGEX);
193
208
  if (!lastPart) {
194
209
  throw new Error(`Invalid filename: ${value}`);
@@ -196,7 +211,8 @@ export function pathToCorrectCasing(value) {
196
211
  parts.push(...lastPart.split('-'));
197
212
 
198
213
  const uppercased = parts.map((part, index) => {
199
- if (index === 0 && lastPart === 'hooks') return part;
214
+ if (index === 0 && (lastPart === 'hooks' || lastPart === 'meta')) return part;
215
+ part = part.replace(/^\[(.*)\]$/, '$1');
200
216
  return part.charAt(0).toUpperCase() + part.slice(1);
201
217
  });
202
218
  return uppercased.join('');
@@ -38,10 +38,12 @@ function compare(compareFn, pathname, params) {
38
38
  const routeParts = location.pathname.split('/').slice(1);
39
39
  for (const [index, pathPart] of pathParts.entries()) {
40
40
  const routePart = routeParts[index];
41
- if (routePart.startsWith(':')) {
41
+ if (pathPart.startsWith(':')) {
42
42
  continue;
43
43
  }
44
- return compareFn(pathPart, routePart);
44
+ if (pathPart !== routePart) {
45
+ return false;
46
+ }
45
47
  }
46
- return false;
48
+ return true;
47
49
  }
@@ -6,6 +6,8 @@
6
6
  * @typedef {import('../index.d.ts').Hooks} Hooks
7
7
  *
8
8
  * @typedef {import('../index.d.ts').Routes} Routes
9
+ *
10
+ * @typedef {import('../index.d.ts').RouteMeta} RouteMeta
9
11
  */
10
12
 
11
13
  /**
@@ -15,6 +17,7 @@
15
17
  * match: RouteComponent | undefined;
16
18
  * layouts: LayoutComponent[];
17
19
  * hooks: Hooks[];
20
+ * meta: RouteMeta;
18
21
  * params: Record<string, string>;
19
22
  * breakFromLayouts: boolean;
20
23
  * }}
@@ -39,6 +42,9 @@ export function matchRoute(pathname, routes) {
39
42
  /** @type {Record<string, string>} */
40
43
  let params = {};
41
44
 
45
+ /** @type {RouteMeta} */
46
+ let meta = {};
47
+
42
48
  let breakFromLayouts = false;
43
49
 
44
50
  outer: for (const route of allRoutes) {
@@ -69,7 +75,7 @@ export function matchRoute(pathname, routes) {
69
75
  );
70
76
  match = /** @type {RouteComponent} */ (routes[resolvedPath]);
71
77
  break outer;
72
- } else if (routePart !== pathPart) {
78
+ } else if (routePart !== pathPart?.toLowerCase()) {
73
79
  break;
74
80
  }
75
81
 
@@ -89,6 +95,10 @@ export function matchRoute(pathname, routes) {
89
95
  hooks.push(routes.hooks);
90
96
  }
91
97
 
98
+ if ('meta' in routes && routes.meta) {
99
+ meta = { ...meta, ...routes.meta };
100
+ }
101
+
92
102
  if (typeof routeMatch === 'function') {
93
103
  if (routeParts.length === pathParts.length) {
94
104
  match = routeMatch;
@@ -103,6 +113,7 @@ export function matchRoute(pathname, routes) {
103
113
  match = result.match;
104
114
  params = { ...params, ...result.params };
105
115
  hooks.push(...result.hooks);
116
+ meta = { ...meta, ...result.meta };
106
117
  if (result.breakFromLayouts) {
107
118
  layouts = [];
108
119
  } else {
@@ -113,7 +124,7 @@ export function matchRoute(pathname, routes) {
113
124
  }
114
125
  }
115
126
 
116
- return { match, layouts, hooks, params, breakFromLayouts };
127
+ return { match, layouts, hooks, params, meta, breakFromLayouts };
117
128
  }
118
129
 
119
130
  /**
@@ -4,11 +4,12 @@ import { resolveRouteComponents } from './utils.js';
4
4
  /**
5
5
  * @param {import('../index.js').Routes} routes
6
6
  * @param {string} path
7
+ * @param {import('../index.d.ts').NavigateOptions} [options]
7
8
  */
8
- export async function preload(routes, path) {
9
+ export async function preload(routes, path, options) {
9
10
  const { match, layouts, hooks } = matchRoute(path, routes);
10
11
  for (const { onPreload } of hooks) {
11
- onPreload?.();
12
+ onPreload?.({ pathname: path, ...options });
12
13
  }
13
14
  await resolveRouteComponents(match ? [...layouts, match] : layouts);
14
15
  }
@@ -18,7 +19,9 @@ const linkSet = new Set();
18
19
  /** @param {import('../index.js').Routes} routes */
19
20
  export function preloadOnHover(routes) {
20
21
  const observer = new MutationObserver(() => {
21
- const links = document.querySelectorAll('a[data-preload]');
22
+ const links = /** @type {NodeListOf<HTMLAnchorElement>} */ (
23
+ document.querySelectorAll('a[data-preload]')
24
+ );
22
25
  for (const link of links) {
23
26
  if (linkSet.has(link)) continue;
24
27
  linkSet.add(link);
@@ -27,7 +30,14 @@ export function preloadOnHover(routes) {
27
30
  link.removeEventListener('mouseenter', callback);
28
31
  const href = link.getAttribute('href');
29
32
  if (!href) return;
30
- preload(routes, href);
33
+ const url = new URL(link.href);
34
+ const { replace, state } = link.dataset;
35
+ preload(routes, href, {
36
+ replace: replace === '' || replace === 'true',
37
+ search: url.search,
38
+ state,
39
+ hash: url.hash,
40
+ });
31
41
  });
32
42
  }
33
43
  });
@@ -25,7 +25,7 @@ export function resolveRouteComponents(input) {
25
25
  * @param {import('../index.d.ts').RouteComponent} input
26
26
  * @returns {Promise<import('svelte').Component>}
27
27
  */
28
- export function resolveRouteComponent(input) {
28
+ function resolveRouteComponent(input) {
29
29
  return new Promise((resolve) => {
30
30
  if (isLazyImport(input)) {
31
31
  Promise.resolve(input()).then((module) => {
@@ -59,3 +59,12 @@ export function join(...parts) {
59
59
  }
60
60
  return result;
61
61
  }
62
+
63
+ export function updatedLocation() {
64
+ return {
65
+ pathname: globalThis.location.pathname,
66
+ search: globalThis.location.search,
67
+ state: history.state,
68
+ hash: globalThis.location.hash,
69
+ };
70
+ }
@@ -26,9 +26,12 @@ export function validateRoutes(routes) {
26
26
  export function getRoutePaths(routes) {
27
27
  const paths = [];
28
28
  for (const [key, value] of Object.entries(routes)) {
29
+ if (['layout', 'hooks', 'meta'].includes(key)) {
30
+ continue;
31
+ }
29
32
  if (typeof value === 'object') {
30
33
  paths.push(
31
- ...getRoutePaths(value).map((path) => {
34
+ ...getRoutePaths(/** @type {import('../index.d.ts').Routes} */ (value)).map((path) => {
32
35
  if (path === '*') {
33
36
  return key + '/*';
34
37
  }
package/src/index.d.ts CHANGED
@@ -56,17 +56,17 @@ export type Hooks = {
56
56
  * You can throw a `navigate` call to redirect to another route.
57
57
  *
58
58
  * ```js
59
- * async beforeLoad() {
59
+ * async beforeLoad({ pathname }) {
60
60
  * await ...
61
61
  * throw navigate('/home');
62
62
  * }
63
63
  * ```
64
64
  */
65
- beforeLoad?(): void | Promise<void>;
65
+ beforeLoad?(context: HooksContext): void | Promise<void>;
66
66
  /** A function that will be called after the route is loaded. */
67
- afterLoad?(): void;
67
+ afterLoad?(context: HooksContext): void;
68
68
  /** A function that will be called when the route is preloaded. */
69
- onPreload?(): void;
69
+ onPreload?(context: HooksContext): void;
70
70
  };
71
71
 
72
72
  export type Routes = {
@@ -74,6 +74,7 @@ export type Routes = {
74
74
  [_: `*${string}` | `(*${string})`]: RouteComponent | undefined;
75
75
  layout?: LayoutComponent;
76
76
  hooks?: Hooks;
77
+ meta?: RouteMeta;
77
78
  };
78
79
 
79
80
  export type IsActiveLink = Action<
@@ -81,6 +82,20 @@ export type IsActiveLink = Action<
81
82
  { className?: string; startsWith?: boolean } | undefined
82
83
  >;
83
84
 
85
+ /**
86
+ * Route metadata that can be extended via module augmentation.
87
+ *
88
+ * @example
89
+ * declare module 'sv-router' {
90
+ * interface RouteMeta {
91
+ * public?: boolean;
92
+ * requiresAuth?: boolean;
93
+ * }
94
+ * }
95
+ */
96
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
97
+ export interface RouteMeta {}
98
+
84
99
  export type RouterApi<T extends Routes> = {
85
100
  /**
86
101
  * Construct a path while ensuring type safety.
@@ -147,6 +162,17 @@ export type RouterApi<T extends Routes> = {
147
162
  * 'hello-world', commentId: '123' }`.
148
163
  */
149
164
  params: AllParams<T>;
165
+ /**
166
+ * Extract parameters from the given pathname. Will throw if the pathname does not match the
167
+ * current route.
168
+ *
169
+ * ```ts
170
+ * route.getParams('/posts/:slug').slug;
171
+ * ```
172
+ *
173
+ * @param pathname
174
+ */
175
+ getParams<U extends Path<T>>(pathname: U): Record<ExtractParams<U>, string>;
150
176
  /** The reactive pathname of the URL. */
151
177
  pathname: (Path<T, true> & {}) | (string & {});
152
178
  /** The reactive query string part of the URL. */
@@ -155,6 +181,8 @@ export type RouterApi<T extends Routes> = {
155
181
  state: unknown;
156
182
  /** The reactive hash part of the URL. */
157
183
  hash: string;
184
+ /** Arbitrary metadata associated with the route. */
185
+ meta: RouteMeta;
158
186
  };
159
187
  };
160
188
 
@@ -177,6 +205,14 @@ export type AllParams<T extends Routes> = Partial<
177
205
  Record<ExtractParams<RemoveParenthesis<RecursiveKeys<T>>>, string>
178
206
  >;
179
207
 
208
+ export type HooksContext = {
209
+ pathname: string;
210
+ replace?: boolean;
211
+ search?: string;
212
+ state?: string;
213
+ hash?: string;
214
+ };
215
+
180
216
  export type NavigateOptions =
181
217
  | {
182
218
  replace?: boolean;
@@ -210,7 +246,9 @@ type StripNonRoutes<T extends Routes> = {
210
246
  ? never
211
247
  : K extends 'hooks'
212
248
  ? never
213
- : K]: T[K] extends Routes ? StripNonRoutes<T[K]> : T[K];
249
+ : K extends 'meta'
250
+ ? never
251
+ : K]: T[K] extends Routes ? StripNonRoutes<T[K]> : T[K];
214
252
  };
215
253
 
216
254
  type RecursiveKeys<