sv-router 0.3.0 → 0.5.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
@@ -27,9 +27,15 @@ A feature-rich yet intuitive routing library for Svelte single-page apps.
27
27
  - ðŸŠķ **Lightweight**: Minimal impact on your bundle size.
28
28
  - 🚀 **Made for Svelte 5**: True Svelte 5 implementation with the latest features.
29
29
 
30
- ## Installation
30
+ ## Getting Started
31
31
 
32
- Add it to an existing Svelte project:
32
+ Kickstart a new project:
33
+
34
+ ```bash
35
+ npm create sv-router
36
+ ```
37
+
38
+ ...or add it to an existing project:
33
39
 
34
40
  ```bash
35
41
  npm install sv-router
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sv-router",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Modern Svelte Routing",
5
5
  "keywords": [
6
6
  "svelte",
@@ -36,29 +36,31 @@
36
36
  "esm-env": "^1.2.2"
37
37
  },
38
38
  "devDependencies": {
39
- "@changesets/cli": "^2.28.1",
40
- "@eslint/js": "^9.23.0",
41
- "@types/node": "^22.13.14",
42
- "eslint": "^9.23.0",
43
- "eslint-config-prettier": "^10.1.1",
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",
44
44
  "eslint-plugin-simple-import-sort": "^12.1.1",
45
- "eslint-plugin-svelte": "^3.3.3",
46
- "eslint-plugin-unicorn": "^58.0.0",
47
- "globals": "^16.0.0",
45
+ "eslint-plugin-svelte": "^3.5.1",
46
+ "eslint-plugin-unicorn": "^59.0.1",
47
+ "globals": "^16.1.0",
48
48
  "prettier": "^3.5.3",
49
49
  "prettier-plugin-jsdoc": "^1.3.2",
50
50
  "prettier-plugin-svelte": "^3.3.3",
51
- "svelte-check": "^4.1.5",
51
+ "svelte-check": "^4.1.7",
52
52
  "type-testing": "^0.2.0",
53
- "typescript": "^5.8.2",
54
- "typescript-eslint": "^8.28.0",
55
- "vite": "^6.2.3",
56
- "vitest": "^3.0.9"
53
+ "typescript": "^5.8.3",
54
+ "typescript-eslint": "^8.32.0",
55
+ "vite": "^6.3.5",
56
+ "vitest": "^3.1.3"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "svelte": "^5"
60
60
  },
61
61
  "scripts": {
62
+ "create:dev": "pnpm --filter create-sv-router dev",
63
+ "create:build": "pnpm --filter create-sv-router build",
62
64
  "docs:dev": "pnpm --filter docs dev",
63
65
  "docs:build": "pnpm --filter docs build",
64
66
  "docs:preview": "pnpm --filter docs preview",
@@ -1,7 +1,7 @@
1
1
  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
- import { preloadOnHover } from './helpers/preload-on-hover.js';
4
+ import { preload, preloadOnHover } from './helpers/preload.js';
5
5
  import { constructPath, join, resolveRouteComponents } from './helpers/utils.js';
6
6
  import { syncSearchParams } from './search-params.svelte.js';
7
7
 
@@ -16,6 +16,9 @@ export let params = $state({ value: {} });
16
16
 
17
17
  export let location = $state(updatedLocation());
18
18
 
19
+ let navigationIndex = 0;
20
+ let pendingNavigationIndex = 0;
21
+
19
22
  /** @type {{ name?: string }} */
20
23
  export const base = {
21
24
  name: undefined,
@@ -41,6 +44,9 @@ export function createRouter(r) {
41
44
  p: constructPath,
42
45
  navigate,
43
46
  isActive,
47
+ async preload(pathname) {
48
+ await preload(routes, pathname);
49
+ },
44
50
  route: {
45
51
  get params() {
46
52
  return params.value;
@@ -90,6 +96,10 @@ export async function onNavigate(path, options = {}) {
90
96
  if (!routes) {
91
97
  throw new Error('Router not initialized: `createRouter` was not called.');
92
98
  }
99
+
100
+ navigationIndex++;
101
+ const currentNavigationIndex = navigationIndex;
102
+
93
103
  let matchPath = path || globalThis.location.pathname;
94
104
  if (base.name && matchPath.startsWith(base.name)) {
95
105
  matchPath = matchPath.slice(base.name.length) || '/';
@@ -98,14 +108,22 @@ export async function onNavigate(path, options = {}) {
98
108
 
99
109
  for (const { beforeLoad } of hooks) {
100
110
  try {
111
+ pendingNavigationIndex = currentNavigationIndex;
101
112
  await beforeLoad?.();
102
113
  } catch {
103
114
  return;
104
115
  }
105
116
  }
106
117
 
107
- componentTree.value = await resolveRouteComponents(match ? [...layouts, match] : layouts);
108
- params.value = newParams || {};
118
+ const fromBeforeLoadHook = new Error().stack?.includes('beforeLoad');
119
+
120
+ const routeComponents = await resolveRouteComponents(match ? [...layouts, match] : layouts);
121
+ if (
122
+ navigationIndex !== currentNavigationIndex ||
123
+ (fromBeforeLoadHook && pendingNavigationIndex + 1 !== currentNavigationIndex)
124
+ ) {
125
+ return;
126
+ }
109
127
 
110
128
  if (path) {
111
129
  if (options.search) path += options.search;
@@ -115,6 +133,14 @@ export async function onNavigate(path, options = {}) {
115
133
  globalThis.history[historyMethod](options.state || {}, '', to);
116
134
  }
117
135
 
136
+ if (options.viewTransition && document.startViewTransition !== undefined) {
137
+ document.startViewTransition(() => {
138
+ componentTree.value = routeComponents;
139
+ });
140
+ } else {
141
+ componentTree.value = routeComponents;
142
+ }
143
+ params.value = newParams || {};
118
144
  syncSearchParams();
119
145
  Object.assign(location, updatedLocation());
120
146
 
@@ -139,13 +165,14 @@ export function onGlobalClick(event) {
139
165
  if (url.origin !== currentOrigin) return;
140
166
 
141
167
  event.preventDefault();
142
- const { replace, state, scrollToTop } = anchor.dataset;
168
+ const { replace, state, scrollToTop, viewTransition } = anchor.dataset;
143
169
  onNavigate(url.pathname, {
144
170
  replace: replace === '' || replace === 'true',
145
171
  search: url.search,
146
172
  state,
147
173
  hash: url.hash,
148
174
  scrollToTop: scrollToTop === 'false' ? false : /** @type ScrollBehavior */ (scrollToTop),
175
+ viewTransition: viewTransition === '' || viewTransition === 'true',
149
176
  });
150
177
  }
151
178
 
@@ -163,7 +163,7 @@ export function createRouterCode(routes, routesPath, { allLazy = false } = {}) {
163
163
  `import { createRouter } from 'sv-router';`,
164
164
  ...imports,
165
165
  '',
166
- `export const { p, navigate, isActive, route } = createRouter(${stringifiedRoutes});`,
166
+ `export const { p, navigate, isActive, preload, route } = createRouter(${stringifiedRoutes});`,
167
167
  ].join('\n');
168
168
  }
169
169
 
@@ -1,9 +1,21 @@
1
1
  import { matchRoute } from './match-route.js';
2
2
  import { resolveRouteComponents } from './utils.js';
3
3
 
4
+ /**
5
+ * @param {import('../index.js').Routes} routes
6
+ * @param {string} path
7
+ */
8
+ export async function preload(routes, path) {
9
+ const { match, layouts, hooks } = matchRoute(path, routes);
10
+ for (const { onPreload } of hooks) {
11
+ onPreload?.();
12
+ }
13
+ await resolveRouteComponents(match ? [...layouts, match] : layouts);
14
+ }
15
+
4
16
  const linkSet = new Set();
5
17
 
6
- /** @param {import('../index.d.ts').Routes} routes */
18
+ /** @param {import('../index.js').Routes} routes */
7
19
  export function preloadOnHover(routes) {
8
20
  const observer = new MutationObserver(() => {
9
21
  const links = document.querySelectorAll('a[data-preload]');
@@ -15,8 +27,7 @@ export function preloadOnHover(routes) {
15
27
  link.removeEventListener('mouseenter', callback);
16
28
  const href = link.getAttribute('href');
17
29
  if (!href) return;
18
- const { match, layouts } = matchRoute(href, routes);
19
- resolveRouteComponents(match ? [...layouts, match] : layouts);
30
+ preload(routes, href);
20
31
  });
21
32
  }
22
33
  });
package/src/index.d.ts CHANGED
@@ -7,10 +7,11 @@ import type { Action } from 'svelte/action';
7
7
  * to `is-active`.
8
8
  *
9
9
  * ```svelte
10
- * <a href="/about" use:isActiveLink={{ className: 'active-link' }}>
10
+ * <a href={p('/about')} use:isActiveLink={{ className: 'active-link' }}>
11
11
  * ```
12
12
  */
13
13
  export const isActiveLink: IsActiveLink;
14
+
14
15
  /**
15
16
  * Setup a new router instance with the given routes.
16
17
  *
@@ -23,11 +24,13 @@ export const isActiveLink: IsActiveLink;
23
24
  * ```
24
25
  */
25
26
  export function createRouter<T extends Routes>(r: T): RouterApi<T>;
27
+
26
28
  /**
27
29
  * The component that will render the current route. You can pass a `base` prop to set the base path
28
30
  * that is prepended to every url.
29
31
  */
30
32
  export const Router: Component<{ base?: string }>;
33
+
31
34
  /**
32
35
  * The reactive search params of the URL. It is just a wrapper around `SvelteURLSearchParam` that
33
36
  * will update the url on change.
@@ -62,6 +65,8 @@ export type Hooks = {
62
65
  beforeLoad?(): void | Promise<void>;
63
66
  /** A function that will be called after the route is loaded. */
64
67
  afterLoad?(): void;
68
+ /** A function that will be called when the route is preloaded. */
69
+ onPreload?(): void;
65
70
  };
66
71
 
67
72
  export type Routes = {
@@ -90,8 +95,9 @@ export type RouterApi<T extends Routes> = {
90
95
  * @param params The parameters to replace in the route.
91
96
  */
92
97
  p<U extends Path<T>>(...args: ConstructPathArgs<U>): string;
98
+
93
99
  /**
94
- * Navigate programatically to a route.
100
+ * Navigate programmatically to a route.
95
101
  *
96
102
  * ```js
97
103
  * navigate('/users');
@@ -124,6 +130,14 @@ export type RouterApi<T extends Routes> = {
124
130
  <U extends Path<T>>(...args: IsActiveArgs<U>): boolean;
125
131
  startsWith<U extends Path<T>>(...args: IsActiveArgs<U>): boolean;
126
132
  };
133
+
134
+ /**
135
+ * Preloads the given route.
136
+ *
137
+ * @param path The route to preload.
138
+ */
139
+ preload<U extends Path<T>>(path: U): Promise<void>;
140
+
127
141
  route: {
128
142
  /**
129
143
  * An object containing the parameters of the current route.
@@ -134,7 +148,7 @@ export type RouterApi<T extends Routes> = {
134
148
  */
135
149
  params: AllParams<T>;
136
150
  /** The reactive pathname of the URL. */
137
- pathname: Path<T>;
151
+ pathname: (Path<T, true> & {}) | (string & {});
138
152
  /** The reactive query string part of the URL. */
139
153
  search: string;
140
154
  /** The reactive history state that can be passed to the `navigate` function. */
@@ -144,8 +158,8 @@ export type RouterApi<T extends Routes> = {
144
158
  };
145
159
  };
146
160
 
147
- export type Path<T extends Routes> = RemoveParenthesis<
148
- RemoveLastSlash<RecursiveKeys<StripNonRoutes<T>>>
161
+ export type Path<T extends Routes, AnyParam extends boolean = false> = RemoveParenthesis<
162
+ RemoveLastSlash<RecursiveKeys<StripNonRoutes<T>, '', AnyParam>>
149
163
  >;
150
164
 
151
165
  export type ConstructPathArgs<T extends string> =
@@ -155,9 +169,13 @@ export type IsActiveArgs<T extends string> =
155
169
  PathParams<T> extends never ? [T] : [T] | [T, PathParams<T>];
156
170
 
157
171
  export type PathParams<T extends string> =
158
- ExtractParams<T> extends never ? never : Record<ExtractParams<T>, string>;
172
+ ExtractParams<RemoveParenthesis<T>> extends never
173
+ ? never
174
+ : Record<ExtractParams<RemoveParenthesis<T>>, string>;
159
175
 
160
- export type AllParams<T extends Routes> = Partial<Record<ExtractParams<RecursiveKeys<T>>, string>>;
176
+ export type AllParams<T extends Routes> = Partial<
177
+ Record<ExtractParams<RemoveParenthesis<RecursiveKeys<T>>>, string>
178
+ >;
161
179
 
162
180
  export type NavigateOptions =
163
181
  | {
@@ -166,6 +184,7 @@ export type NavigateOptions =
166
184
  state?: string;
167
185
  hash?: string;
168
186
  scrollToTop?: ScrollBehavior | false;
187
+ viewTransition?: boolean;
169
188
  }
170
189
  | undefined;
171
190
 
@@ -194,14 +213,28 @@ type StripNonRoutes<T extends Routes> = {
194
213
  : K]: T[K] extends Routes ? StripNonRoutes<T[K]> : T[K];
195
214
  };
196
215
 
197
- type RecursiveKeys<T extends Routes, Prefix extends string = ''> = {
216
+ type RecursiveKeys<
217
+ T extends Routes,
218
+ Prefix extends string = '',
219
+ AnyParam extends boolean = false,
220
+ > = {
198
221
  [K in keyof T]: K extends string
199
222
  ? T[K] extends Routes
200
- ? RecursiveKeys<T[K], `${Prefix}${K}`>
201
- : `${Prefix}${K}`
223
+ ? RecursiveKeys<
224
+ T[K],
225
+ `${Prefix}${AnyParam extends true ? ReplaceParamWithString<K> : K}`,
226
+ AnyParam
227
+ >
228
+ : `${Prefix}${AnyParam extends true ? ReplaceParamWithString<K> : K}`
202
229
  : never;
203
230
  }[keyof T];
204
231
 
232
+ type ReplaceParamWithString<T extends string> = T extends `/:${string}`
233
+ ? `/${string}`
234
+ : T extends `/(:${string})`
235
+ ? `/${string}`
236
+ : T;
237
+
205
238
  type RemoveLastSlash<T extends string> = T extends '/' ? T : T extends `${infer R}/` ? R : T;
206
239
 
207
240
  type RemoveParenthesis<T extends string> = T extends `${infer A}(${infer B})${infer C}`
@@ -210,14 +243,10 @@ type RemoveParenthesis<T extends string> = T extends `${infer A}(${infer B})${in
210
243
 
211
244
  type ExtractParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}`
212
245
  ? Param | ExtractParams<`/${Rest}`>
213
- : T extends `${string}(:${infer Param})`
246
+ : T extends `${string}:${infer Param}`
214
247
  ? Param
215
- : T extends `${string}:${infer Param}`
216
- ? Param
217
- : T extends `${string}(*${infer Param})`
218
- ? Param
219
- : T extends `${string}*${infer Param}`
220
- ? Param extends ''
221
- ? never
222
- : Param
223
- : never;
248
+ : T extends `${string}*${infer Param}`
249
+ ? Param extends ''
250
+ ? never
251
+ : Param
252
+ : never;
@@ -7,9 +7,9 @@ import { writeRouterCode } from '../gen/write-router-code.js';
7
7
  * @returns {import('vite').Plugin}
8
8
  */
9
9
  export function router(options) {
10
- if (options?.allLazy) genConfig.allLazy = options.allLazy;
11
- if (options?.js) genConfig.routesInJs = options.js;
12
- if (options?.path) genConfig.routesPath = options.path;
10
+ genConfig.allLazy = options?.allLazy || false;
11
+ genConfig.routesInJs = options?.js || false;
12
+ genConfig.routesPath = options?.path || 'src/routes';
13
13
 
14
14
  return {
15
15
  name: 'sv-router',