sv-router 0.7.2 → 0.8.1

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.7.2",
3
+ "version": "0.8.1",
4
4
  "description": "Modern Svelte Routing",
5
5
  "keywords": [
6
6
  "svelte",
@@ -36,28 +36,29 @@
36
36
  "esm-env": "^1.2.2"
37
37
  },
38
38
  "devDependencies": {
39
- "@changesets/cli": "^2.29.4",
40
- "@eslint/js": "^9.28.0",
41
- "@sveltejs/vite-plugin-svelte": "^6.0.0",
42
- "@testing-library/jest-dom": "^6.6.3",
39
+ "@changesets/cli": "^2.29.5",
40
+ "@eslint/js": "^9.32.0",
41
+ "@sveltejs/vite-plugin-svelte": "^6.1.0",
42
+ "@testing-library/jest-dom": "^6.6.4",
43
43
  "@testing-library/svelte": "^5.2.8",
44
- "@types/node": "^22.15.30",
45
- "eslint": "^9.28.0",
46
- "eslint-config-prettier": "^10.1.5",
44
+ "@testing-library/user-event": "^14.6.1",
45
+ "@types/node": "^24.2.0",
46
+ "eslint": "^9.32.0",
47
+ "eslint-config-prettier": "^10.1.8",
47
48
  "eslint-plugin-simple-import-sort": "^12.1.1",
48
- "eslint-plugin-svelte": "^3.9.1",
49
- "eslint-plugin-unicorn": "^59.0.1",
50
- "globals": "^16.2.0",
51
- "jsdom": "^26.1.0",
52
- "prettier": "^3.5.3",
53
- "prettier-plugin-jsdoc": "^1.3.2",
49
+ "eslint-plugin-svelte": "^3.11.0",
50
+ "eslint-plugin-unicorn": "^60.0.0",
51
+ "globals": "^16.3.0",
52
+ "happy-dom": "^18.0.1",
53
+ "prettier": "^3.6.2",
54
+ "prettier-plugin-jsdoc": "^1.3.3",
54
55
  "prettier-plugin-svelte": "^3.4.0",
55
- "svelte-check": "^4.2.1",
56
+ "svelte-check": "^4.3.1",
56
57
  "type-testing": "^0.2.0",
57
- "typescript": "^5.8.3",
58
- "typescript-eslint": "^8.33.1",
59
- "vite": "^7.0.0",
60
- "vitest": "^3.2.2"
58
+ "typescript": "^5.9.2",
59
+ "typescript-eslint": "^8.39.0",
60
+ "vite": "^7.1.0",
61
+ "vitest": "^3.2.4"
61
62
  },
62
63
  "peerDependencies": {
63
64
  "svelte": "^5"
@@ -1,5 +1,4 @@
1
1
  <script>
2
- import { params } from './create-router.svelte.js';
3
2
  import RecursiveComponentTree from './RecursiveComponentTree.svelte';
4
3
 
5
4
  /** @type {{ tree: import('svelte').Component[] }} */
@@ -9,10 +8,8 @@
9
8
  const restTree = $derived(tree.slice(1));
10
9
  </script>
11
10
 
12
- {#key restTree.length > 0 || Object.values(params.value)}
13
- <FirstComponent>
14
- {#if restTree.length > 0}
15
- <RecursiveComponentTree tree={restTree}></RecursiveComponentTree>
16
- {/if}
17
- </FirstComponent>
18
- {/key}
11
+ <FirstComponent>
12
+ {#if restTree.length > 0}
13
+ <RecursiveComponentTree tree={restTree}></RecursiveComponentTree>
14
+ {/if}
15
+ </FirstComponent>
package/src/Router.svelte CHANGED
@@ -1,20 +1,12 @@
1
1
  <script>
2
2
  import { on } from 'svelte/events';
3
- import { base, componentTree, onGlobalClick, onNavigate } from './create-router.svelte.js';
4
- import { join } from './helpers/utils.js';
3
+ import { componentTree, init, onGlobalClick, onNavigate } from './create-router.svelte.js';
5
4
  import RecursiveComponentTree from './RecursiveComponentTree.svelte';
6
5
 
7
6
  /** @type {{ base?: string }} */
8
7
  let { base: basename } = $props();
9
8
 
10
- if (basename) {
11
- base.name = (basename.startsWith('/') ? '' : '/') + basename;
12
- const url = new URL(globalThis.location.href);
13
- if (!url.pathname.startsWith(base.name)) {
14
- url.pathname = join(base.name, url.pathname);
15
- history.replaceState(history.state || {}, '', url.href);
16
- }
17
- }
9
+ init(basename);
18
10
 
19
11
  onNavigate();
20
12
 
@@ -8,9 +8,14 @@ export function isActiveLink(node, { className = 'is-active', startsWith = false
8
8
  }
9
9
 
10
10
  $effect(() => {
11
- let pathname = new URL(node.href).pathname;
12
- if (base.name) {
13
- pathname = join(base.name, pathname);
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
+ if (base.name) {
17
+ pathname = join(base.name, pathname);
18
+ }
14
19
  }
15
20
  const tokens = className.split(' ').filter(Boolean) ?? [];
16
21
  if (startsWith ? location.pathname.startsWith(pathname) : location.pathname === pathname) {
@@ -14,23 +14,44 @@ import { syncSearchParams } from './search-params.svelte.js';
14
14
  /** @type {import('./index.d.ts').Routes} */
15
15
  let routes;
16
16
 
17
+ /** @type {{ name?: string }} */
18
+ export const base = {
19
+ name: undefined,
20
+ };
21
+
17
22
  /** @type {{ value: import('svelte').Component[] }} */
18
23
  export let componentTree = $state({ value: [] });
19
24
 
20
- /** @type {{ value: Record<string, string> }} */
21
- export let params = $state({ value: {} });
22
-
23
25
  export let location = $state(updatedLocation());
24
26
 
27
+ /** @type {{ value: Record<string, string> }} */
28
+ let params = $state({ value: {} });
29
+
25
30
  let meta = $state({ value: {} });
26
31
 
27
32
  let navigationIndex = 0;
28
33
  let pendingNavigationIndex = 0;
29
34
 
30
- /** @type {{ name?: string }} */
31
- export const base = {
32
- name: undefined,
33
- };
35
+ /** @param {string | undefined} basename */
36
+ export function init(basename) {
37
+ if (basename) {
38
+ const url = new URL(globalThis.location.toString());
39
+ if (basename === '#') {
40
+ base.name = '#';
41
+ if (!globalThis.location.href.includes('#')) {
42
+ url.hash = '/';
43
+ history.replaceState(history.state || {}, '', url.toString());
44
+ }
45
+ } else {
46
+ base.name = (basename.startsWith('/') ? '' : '/') + basename;
47
+ if (!url.pathname.startsWith(base.name)) {
48
+ url.pathname = join(base.name, url.pathname);
49
+ history.replaceState(history.state || {}, '', url.toString());
50
+ }
51
+ }
52
+ }
53
+ Object.assign(location, updatedLocation());
54
+ }
34
55
 
35
56
  /**
36
57
  * @template {import('./index.d.ts').Routes} T
@@ -93,18 +114,39 @@ function navigate(path, options = {}) {
93
114
  globalThis.history.go(path);
94
115
  return;
95
116
  }
96
- if (options.params) {
97
- path = constructPath(path, options.params);
98
- }
117
+
118
+ path = constructPath(path, options.params);
99
119
  if (options.search && !options.search.startsWith('?')) {
100
120
  options.search = '?' + options.search;
101
121
  }
102
- if (options.hash && !options.hash.startsWith('#')) {
122
+ if (options.hash && !options.hash.startsWith('#') && base.name !== '#') {
103
123
  options.hash = '#' + options.hash;
104
124
  }
125
+ if (base.name === '#') {
126
+ path = new URL(path).hash;
127
+ }
105
128
  onNavigate(path, options);
106
129
  }
107
130
 
131
+ /** @param {string} [path] */
132
+ function getMatchPath(path) {
133
+ let matchPath = '';
134
+
135
+ if (path) {
136
+ matchPath = path;
137
+ } else if (base.name === '#') {
138
+ matchPath = globalThis.location.hash.slice(1);
139
+ } else {
140
+ matchPath = globalThis.location.pathname;
141
+ }
142
+
143
+ if (base.name && matchPath.startsWith(base.name)) {
144
+ matchPath = matchPath.slice(base.name.length) || '/';
145
+ }
146
+
147
+ return stripBase(matchPath);
148
+ }
149
+
108
150
  /**
109
151
  * @param {string} [path]
110
152
  * @param {import('./index.d.ts').NavigateOptions} options
@@ -117,21 +159,35 @@ export async function onNavigate(path, options = {}) {
117
159
  navigationIndex++;
118
160
  const currentNavigationIndex = navigationIndex;
119
161
 
120
- const matchPath = stripBase(path || globalThis.location.pathname);
162
+ let matchPath = getMatchPath(path);
121
163
  const { match, layouts, hooks, meta: newMeta, params: newParams } = matchRoute(matchPath, routes);
122
164
 
123
- for (const { beforeLoad } of hooks) {
165
+ let errorHooks = [];
166
+ for (const hook of hooks) {
124
167
  try {
168
+ const { beforeLoad } = hook;
169
+ errorHooks.push(hook);
125
170
  pendingNavigationIndex = currentNavigationIndex;
126
171
  await beforeLoad?.({ pathname: matchPath, meta: newMeta, ...options });
127
- } catch {
172
+ } catch (error) {
173
+ for (const { onError } of errorHooks) {
174
+ void onError?.(error, { pathname: matchPath, meta: newMeta, ...options });
175
+ }
128
176
  return;
129
177
  }
130
178
  }
131
179
 
132
180
  const fromBeforeLoadHook = new Error().stack?.includes('beforeLoad');
133
181
 
134
- const routeComponents = await resolveRouteComponents(match ? [...layouts, match] : layouts);
182
+ let routeComponents;
183
+ try {
184
+ routeComponents = await resolveRouteComponents(match ? [...layouts, match] : layouts);
185
+ } catch (error) {
186
+ for (const { onError } of hooks) {
187
+ void onError?.(error, { pathname: matchPath, meta: newMeta, ...options });
188
+ }
189
+ throw error;
190
+ }
135
191
  if (
136
192
  navigationIndex !== currentNavigationIndex ||
137
193
  (fromBeforeLoadHook && pendingNavigationIndex + 1 !== currentNavigationIndex)
@@ -140,11 +196,20 @@ export async function onNavigate(path, options = {}) {
140
196
  }
141
197
 
142
198
  if (path) {
143
- if (options.search) path += options.search;
144
- if (options.hash) path += options.hash;
199
+ let url = new URL(globalThis.location.toString());
200
+ url.search = '';
201
+ if (options.search) url.search = options.search;
202
+ if (base.name === '#') {
203
+ url.hash = path;
204
+ } else {
205
+ if (options.hash) path += options.hash;
206
+ url.pathname = base.name ? join(base.name, path) : path;
207
+ }
145
208
  const historyMethod = options.replace ? 'replaceState' : 'pushState';
146
- const to = base.name ? join(base.name, path) : path;
147
- globalThis.history[historyMethod](options.state || {}, '', to);
209
+ globalThis.history[historyMethod](options.state || {}, '', url.toString());
210
+ syncSearchParams(options.search);
211
+ } else {
212
+ syncSearchParams(globalThis.location.search);
148
213
  }
149
214
 
150
215
  if (options.viewTransition && document.startViewTransition !== undefined) {
@@ -156,7 +221,6 @@ export async function onNavigate(path, options = {}) {
156
221
  }
157
222
  params.value = newParams;
158
223
  meta.value = newMeta;
159
- syncSearchParams();
160
224
  Object.assign(location, updatedLocation());
161
225
 
162
226
  if (options.scrollToTop !== false) {
@@ -179,13 +243,16 @@ export function onGlobalClick(event) {
179
243
  const currentOrigin = globalThis.location.origin;
180
244
  if (url.origin !== currentOrigin) return;
181
245
 
246
+ const path = base.name === '#' ? url.hash : url.pathname;
247
+ const hash = base.name === '#' ? undefined : url.hash;
248
+
182
249
  event.preventDefault();
183
250
  const { replace, state, scrollToTop, viewTransition } = anchor.dataset;
184
- onNavigate(url.pathname, {
251
+ onNavigate(path, {
185
252
  replace: replace === '' || replace === 'true',
186
253
  search: url.search,
187
254
  state,
188
- hash: url.hash,
255
+ hash,
189
256
  scrollToTop: scrollToTop === 'false' ? false : /** @type ScrollBehavior */ (scrollToTop),
190
257
  viewTransition: viewTransition === '' || viewTransition === 'true',
191
258
  });
@@ -7,7 +7,7 @@ import { constructPath, join } from './utils.js';
7
7
  * @returns {boolean}
8
8
  */
9
9
  export function isActive(pathname, params) {
10
- const p = base.name ? join(base.name, pathname) : pathname;
10
+ const p = base.name && base.name !== '#' ? join(base.name, pathname) : pathname;
11
11
  return compare((a, b) => a === b, p, params);
12
12
  }
13
13
 
@@ -17,7 +17,7 @@ export function isActive(pathname, params) {
17
17
  * @returns {boolean}
18
18
  */
19
19
  isActive.startsWith = (pathname, params) => {
20
- const p = base.name ? join(base.name, pathname) : pathname;
20
+ const p = base.name && base.name !== '#' ? join(base.name, pathname) : pathname;
21
21
  return compare((a, b) => a.startsWith(b), p, params);
22
22
  };
23
23
 
@@ -33,11 +33,15 @@ function compare(compareFn, pathname, params) {
33
33
  }
34
34
 
35
35
  if (params) {
36
- return compareFn(location.pathname, constructPath(pathname, params));
36
+ if (base.name === '#') {
37
+ return compareFn(location.pathname, new URL(constructPath(pathname, params)).hash.slice(1));
38
+ } else {
39
+ return compareFn(location.pathname, constructPath(pathname, params));
40
+ }
37
41
  }
38
42
 
39
43
  const pathParts = pathname.split('/').slice(1);
40
- const routeParts = location.pathname.split('/').slice(1);
44
+ let routeParts = location.pathname.split('/').slice(1);
41
45
  if (pathParts.length > routeParts.length) {
42
46
  return false;
43
47
  }
@@ -67,6 +67,7 @@ export function matchRoute(pathname, routes) {
67
67
  }
68
68
  if (breakFromLayouts) {
69
69
  routePart = `(${routePart})`;
70
+ layouts = [];
70
71
  } else if ('layout' in routes && routes.layout) {
71
72
  layouts.push(routes.layout);
72
73
  }
@@ -6,13 +6,21 @@ import { base } from '../create-router.svelte.js';
6
6
  * @returns {string}
7
7
  */
8
8
  export function constructPath(path, params) {
9
- if (!params) return path;
9
+ if (params) {
10
+ for (const key in params) {
11
+ path = path.replace(`:${key}`, params[key]);
12
+ }
13
+ }
14
+
15
+ if (base.name === '#') {
16
+ const url = new URL(globalThis.location.toString());
17
+ url.hash = path;
18
+ url.search = '';
10
19
 
11
- let result = path;
12
- for (const key in params) {
13
- result = result.replace(`:${key}`, params[key]);
20
+ return url.toString();
14
21
  }
15
- return result;
22
+
23
+ return path;
16
24
  }
17
25
 
18
26
  /**
@@ -44,7 +52,10 @@ function resolveRouteComponent(input) {
44
52
  * @returns {input is import('../index.d.ts').LazyRouteComponent}
45
53
  */
46
54
  export function isLazyImport(input) {
47
- return typeof input === 'function' && !!/\(\)\s?=>\s?import\(.*\)/.test(String(input));
55
+ return (
56
+ typeof input === 'function' &&
57
+ !!/\(\)\s?=>\s?(import|__vite_ssr_dynamic_import__)\(.*\)/.test(String(input))
58
+ );
48
59
  }
49
60
 
50
61
  /** @param {...string} parts */
@@ -74,10 +85,13 @@ export function stripBase(pathname) {
74
85
  }
75
86
 
76
87
  export function updatedLocation() {
88
+ const pathname =
89
+ base.name === '#' ? globalThis.location.hash.slice(1) : globalThis.location.pathname;
90
+ const hash = base.name === '#' ? '' : globalThis.location.hash;
77
91
  return {
78
- pathname: globalThis.location.pathname,
92
+ pathname,
79
93
  search: globalThis.location.search,
80
94
  state: history.state,
81
- hash: globalThis.location.hash,
95
+ hash,
82
96
  };
83
97
  }
package/src/index.d.ts CHANGED
@@ -41,11 +41,12 @@ export const searchParams: SearchParams;
41
41
  type BaseProps = {};
42
42
 
43
43
  export type LazyRouteComponent<Props extends BaseProps = BaseProps> = () => Promise<{
44
- default: Component<Props>;
44
+ default: Component<Props> | Snippet<[Props]>;
45
45
  }>;
46
46
 
47
47
  export type RouteComponent<Props extends BaseProps = any> =
48
48
  | Component<Props>
49
+ | Snippet<[Props]>
49
50
  | LazyRouteComponent<Props>;
50
51
  export type LayoutComponent = RouteComponent<{ children: Snippet }>;
51
52
  export type Hooks = {
@@ -67,6 +68,8 @@ export type Hooks = {
67
68
  afterLoad?(context: HooksContext): void | Promise<void>;
68
69
  /** A function that will be called when the route is preloaded. */
69
70
  onPreload?(context: HooksContext): void | Promise<void>;
71
+ /** A function that will be called when the route fails to load. */
72
+ onError?(error: unknown, context: HooksContext): void | Promise<void>;
70
73
  };
71
74
 
72
75
  export type Routes = {
@@ -240,7 +243,7 @@ export type NavigateOptions =
240
243
  }
241
244
  | undefined;
242
245
 
243
- export type SearchParams = URLSearchParams & {
246
+ export type SearchParams = Omit<URLSearchParams, 'append' | 'delete' | 'set' | 'sort'> & {
244
247
  append: (name: string, value: string, options?: { replace?: boolean }) => void;
245
248
  delete: (name: string, value?: string, options?: { replace?: boolean }) => void;
246
249
  set: (name: string, value: string, options?: { replace?: boolean }) => void;
@@ -55,22 +55,21 @@ const shell = {
55
55
 
56
56
  export { shell as searchParams };
57
57
 
58
- export function syncSearchParams() {
59
- const newSearchParams = new URLSearchParams(globalThis.location.search);
60
- if (searchParams.toString() === newSearchParams.toString()) {
61
- return;
62
- }
63
- searchParams = new SvelteURLSearchParams();
64
- for (const [key, value] of newSearchParams.entries()) {
65
- searchParams.append(key, value);
58
+ /** @param {string} [search] */
59
+ export function syncSearchParams(search) {
60
+ if (searchParams.toString() !== search) {
61
+ searchParams = new SvelteURLSearchParams();
62
+ const newSearchParams = new URLSearchParams(search);
63
+ for (const [key, value] of newSearchParams.entries()) {
64
+ searchParams.append(key, value);
65
+ }
66
66
  }
67
67
  }
68
68
 
69
69
  /** @param {{ replace?: boolean }} [options] */
70
70
  function updateUrlSearchParams(options) {
71
- let url = globalThis.location.origin + globalThis.location.pathname;
72
- if (searchParams.size > 0) {
73
- url += '?' + searchParams.toString();
74
- }
71
+ let url = new URL(globalThis.location.toString());
72
+ url.search = searchParams.toString();
73
+
75
74
  globalThis.history[options?.replace ? 'replaceState' : 'pushState']({}, '', url);
76
75
  }