sv-router 0.8.0 → 0.9.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
@@ -1,6 +1,6 @@
1
1
  <div align="center">
2
2
 
3
- <img src="./docs/public/logo.svg" alt="" height="128px">
3
+ <img src="./docs/public/logo.svg" alt="" width="128px" height="128px">
4
4
 
5
5
  # sv-router
6
6
 
@@ -26,6 +26,7 @@ A feature-rich yet intuitive routing library for Svelte single-page apps.
26
26
  - 🧩 **Familiar API**: Follows established conventions from popular meta frameworks.
27
27
  - 🪶 **Lightweight**: Minimal impact on your bundle size.
28
28
  - 🚀 **Made for Svelte 5**: True Svelte 5 implementation with the latest features.
29
+ - #️⃣ **Hash-based routing**: Hash-based routing enables usage in local environments.
29
30
 
30
31
  ## Getting Started
31
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sv-router",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Modern Svelte Routing",
5
5
  "keywords": [
6
6
  "svelte",
@@ -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>
@@ -4,8 +4,11 @@ import { matchRoute } from './helpers/match-route.js';
4
4
  import { preload, preloadOnHover } from './helpers/preload.js';
5
5
  import {
6
6
  constructPath,
7
+ constructUrl,
7
8
  join,
9
+ parseSearch,
8
10
  resolveRouteComponents,
11
+ serializeSearch,
9
12
  stripBase,
10
13
  updatedLocation,
11
14
  } from './helpers/utils.js';
@@ -22,11 +25,11 @@ export const base = {
22
25
  /** @type {{ value: import('svelte').Component[] }} */
23
26
  export let componentTree = $state({ value: [] });
24
27
 
25
- /** @type {{ value: Record<string, string> }} */
26
- export let params = $state({ value: {} });
27
-
28
28
  export let location = $state(updatedLocation());
29
29
 
30
+ /** @type {{ value: Record<string, string> }} */
31
+ let params = $state({ value: {} });
32
+
30
33
  let meta = $state({ value: {} });
31
34
 
32
35
  let navigationIndex = 0;
@@ -70,7 +73,7 @@ export function createRouter(r) {
70
73
  preloadOnHover(routes);
71
74
 
72
75
  return {
73
- p: constructPath,
76
+ p: constructUrl,
74
77
  navigate,
75
78
  isActive,
76
79
  async preload(pathname) {
@@ -90,7 +93,7 @@ export function createRouter(r) {
90
93
  return /** @type {import('./index.d.ts').Path<T>} */ (stripBase(location.pathname));
91
94
  },
92
95
  get search() {
93
- return location.search;
96
+ return parseSearch(location.search);
94
97
  },
95
98
  get state() {
96
99
  return location.state;
@@ -107,7 +110,10 @@ export function createRouter(r) {
107
110
 
108
111
  /**
109
112
  * @param {string | number} path
110
- * @param {import('./index.d.ts').NavigateOptions & { params?: Record<string, string> }} options
113
+ * @param {import('./index.d.ts').NavigateOptions & {
114
+ * params?: Record<string, string>;
115
+ * search?: import('./index.d.ts').Search;
116
+ * }} options
111
117
  */
112
118
  function navigate(path, options = {}) {
113
119
  if (typeof path === 'number') {
@@ -116,37 +122,14 @@ function navigate(path, options = {}) {
116
122
  }
117
123
 
118
124
  path = constructPath(path, options.params);
119
- if (options.search && !options.search.startsWith('?')) {
120
- options.search = '?' + options.search;
121
- }
122
- if (options.hash && !options.hash.startsWith('#') && base.name !== '#') {
123
- options.hash = '#' + options.hash;
124
- }
125
125
  if (base.name === '#') {
126
126
  path = new URL(path).hash;
127
+ } else if (options.hash && !options.hash.startsWith('#')) {
128
+ options.hash = '#' + options.hash;
127
129
  }
128
130
  onNavigate(path, options);
129
131
  }
130
132
 
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
-
150
133
  /**
151
134
  * @param {string} [path]
152
135
  * @param {import('./index.d.ts').NavigateOptions} options
@@ -162,16 +145,19 @@ export async function onNavigate(path, options = {}) {
162
145
  let matchPath = getMatchPath(path);
163
146
  const { match, layouts, hooks, meta: newMeta, params: newParams } = matchRoute(matchPath, routes);
164
147
 
148
+ const search = parseSearch(options.search);
149
+ const hooksContext = { pathname: matchPath, meta: newMeta, ...options, search };
150
+
165
151
  let errorHooks = [];
166
152
  for (const hook of hooks) {
167
153
  try {
168
154
  const { beforeLoad } = hook;
169
155
  errorHooks.push(hook);
170
156
  pendingNavigationIndex = currentNavigationIndex;
171
- await beforeLoad?.({ pathname: matchPath, meta: newMeta, ...options });
157
+ await beforeLoad?.(hooksContext);
172
158
  } catch (error) {
173
159
  for (const { onError } of errorHooks) {
174
- void onError?.(error, { pathname: matchPath, meta: newMeta, ...options });
160
+ void onError?.(error, hooksContext);
175
161
  }
176
162
  return;
177
163
  }
@@ -184,7 +170,7 @@ export async function onNavigate(path, options = {}) {
184
170
  routeComponents = await resolveRouteComponents(match ? [...layouts, match] : layouts);
185
171
  } catch (error) {
186
172
  for (const { onError } of hooks) {
187
- void onError?.(error, { pathname: matchPath, meta: newMeta, ...options });
173
+ void onError?.(error, hooksContext);
188
174
  }
189
175
  throw error;
190
176
  }
@@ -196,18 +182,18 @@ export async function onNavigate(path, options = {}) {
196
182
  }
197
183
 
198
184
  if (path) {
199
- let url = new URL(globalThis.location.toString());
200
- url.search = '';
201
- if (options.search) url.search = options.search;
185
+ const search = serializeSearch(options.search);
186
+ const url = new URL(globalThis.location.toString());
187
+ url.search = search || '';
188
+ url.hash = options.hash || '';
202
189
  if (base.name === '#') {
203
190
  url.hash = path;
204
191
  } else {
205
- if (options.hash) path += options.hash;
206
192
  url.pathname = base.name ? join(base.name, path) : path;
207
193
  }
208
194
  const historyMethod = options.replace ? 'replaceState' : 'pushState';
209
195
  globalThis.history[historyMethod](options.state || {}, '', url.toString());
210
- syncSearchParams(options.search);
196
+ syncSearchParams(search);
211
197
  } else {
212
198
  syncSearchParams(globalThis.location.search);
213
199
  }
@@ -228,8 +214,27 @@ export async function onNavigate(path, options = {}) {
228
214
  }
229
215
 
230
216
  for (const { afterLoad } of hooks) {
231
- void afterLoad?.({ pathname: matchPath, meta: newMeta, ...options });
217
+ void afterLoad?.(hooksContext);
218
+ }
219
+ }
220
+
221
+ /** @param {string} [path] */
222
+ function getMatchPath(path) {
223
+ let matchPath = '';
224
+
225
+ if (path) {
226
+ matchPath = path;
227
+ } else if (base.name === '#') {
228
+ matchPath = globalThis.location.hash.slice(1);
229
+ } else {
230
+ matchPath = globalThis.location.pathname;
232
231
  }
232
+
233
+ if (base.name && matchPath.startsWith(base.name)) {
234
+ matchPath = matchPath.slice(base.name.length) || '/';
235
+ }
236
+
237
+ return stripBase(matchPath);
233
238
  }
234
239
 
235
240
  /** @param {Event} event */
@@ -18,7 +18,7 @@ const META_FILENAME_REGEX = /(?<=[/.]|^)(meta)(\.svelte)?\.(js|ts)$/; // meta.js
18
18
 
19
19
  /**
20
20
  * @param {string} routesPath
21
- * @param {{ allLazy?: boolean }} [options]
21
+ * @param {{ allLazy?: boolean; js?: boolean }} [options]
22
22
  * @returns {string}
23
23
  */
24
24
  export function generateRouterCode(routesPath, options) {
@@ -130,10 +130,10 @@ function filePathToRoute(filename) {
130
130
  /**
131
131
  * @param {GeneratedRoutes} routes
132
132
  * @param {string} routesPath
133
- * @param {{ allLazy?: boolean }} [options]
133
+ * @param {{ allLazy?: boolean; js?: boolean }} [options]
134
134
  * @returns {string}
135
135
  */
136
- export function createRouterCode(routes, routesPath, { allLazy = false } = {}) {
136
+ export function createRouterCode(routes, routesPath, { allLazy = false, js = false } = {}) {
137
137
  if (!routesPath.endsWith('/')) {
138
138
  routesPath += '/';
139
139
  }
@@ -178,8 +178,9 @@ export function createRouterCode(routes, routesPath, { allLazy = false } = {}) {
178
178
  ...imports,
179
179
  '',
180
180
  `const routes = ${stringifiedRoutes};`,
181
- 'export type Routes = typeof routes;',
181
+ ...(js ? [] : ['export type Routes = typeof routes;']),
182
182
  'export const { p, navigate, isActive, preload, route } = createRouter(routes);',
183
+ '',
183
184
  ].join('\n');
184
185
  }
185
186
 
@@ -32,7 +32,10 @@ export function writeRouterCode() {
32
32
  writeFileIfDifferent(genConfig.tsconfigPath, JSON.stringify(tsConfig, undefined, 2));
33
33
 
34
34
  // Write `.router/router.ts` file
35
- const routerCode = generateRouterCode(genConfig.routesPath, { allLazy: genConfig.allLazy });
35
+ const routerCode = generateRouterCode(genConfig.routesPath, {
36
+ allLazy: genConfig.allLazy,
37
+ js: genConfig.routesInJs,
38
+ });
36
39
  const written = writeFileIfDifferent(genConfig.routerPath, routerCode);
37
40
 
38
41
  if (written) {
@@ -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
  }
@@ -1,5 +1,5 @@
1
1
  import { matchRoute } from './match-route.js';
2
- import { resolveRouteComponents } from './utils.js';
2
+ import { parseSearch, resolveRouteComponents } from './utils.js';
3
3
 
4
4
  /**
5
5
  * @param {import('../index.js').Routes} routes
@@ -9,7 +9,12 @@ import { resolveRouteComponents } from './utils.js';
9
9
  export async function preload(routes, path, options) {
10
10
  const { match, layouts, hooks, meta } = matchRoute(path, routes);
11
11
  for (const { onPreload } of hooks) {
12
- void onPreload?.({ pathname: path, meta, ...options });
12
+ void onPreload?.({
13
+ pathname: path,
14
+ meta,
15
+ ...options,
16
+ search: parseSearch(options?.search),
17
+ });
13
18
  }
14
19
  await resolveRouteComponents(match ? [...layouts, match] : layouts);
15
20
  }
@@ -2,13 +2,13 @@ import { base } from '../create-router.svelte.js';
2
2
 
3
3
  /**
4
4
  * @param {string} path
5
- * @param {Record<string, string>} [params]
5
+ * @param {Record<string, string | number | boolean>} [params]
6
6
  * @returns {string}
7
7
  */
8
8
  export function constructPath(path, params) {
9
9
  if (params) {
10
10
  for (const key in params) {
11
- path = path.replace(`:${key}`, params[key]);
11
+ path = path.replace(`:${key}`, String(params[key]));
12
12
  }
13
13
  }
14
14
 
@@ -23,6 +23,24 @@ export function constructPath(path, params) {
23
23
  return path;
24
24
  }
25
25
 
26
+ /**
27
+ * @param {string} path
28
+ * @param {import('../index.d.ts').ConstructUrlOptions & {
29
+ * params?: Record<string, string | number | boolean>;
30
+ * }} [options]
31
+ * @returns {string}
32
+ */
33
+ export function constructUrl(path, options) {
34
+ let result = constructPath(path, options?.params);
35
+ if (options?.search) {
36
+ result += serializeSearch(options.search);
37
+ }
38
+ if (options?.hash && !options.hash.startsWith('#')) {
39
+ result += '#' + options.hash;
40
+ }
41
+ return result;
42
+ }
43
+
26
44
  /**
27
45
  * @param {import('../index.d.ts').RouteComponent[]} input
28
46
  * @returns {Promise<import('svelte').Component[]>}
@@ -95,3 +113,66 @@ export function updatedLocation() {
95
113
  hash,
96
114
  };
97
115
  }
116
+
117
+ /**
118
+ * @param {import('../index.d.ts').Search} [value]
119
+ * @returns {string | undefined}
120
+ */
121
+ export function serializeSearch(value) {
122
+ if (!value) {
123
+ return;
124
+ }
125
+
126
+ if (typeof value === 'string') {
127
+ if (!value.startsWith('?')) {
128
+ value = '?' + value;
129
+ }
130
+ return value;
131
+ }
132
+
133
+ const stringValues = Object.fromEntries(
134
+ Object.entries(value).map(([key, value]) => [key, String(value)]),
135
+ );
136
+ const urlSearchParams = new URLSearchParams(stringValues);
137
+ return '?' + urlSearchParams.toString();
138
+ }
139
+
140
+ /**
141
+ * @param {import('../index.d.ts').Search} [value]
142
+ * @returns {Record<string, string | number | boolean>}
143
+ */
144
+ export function parseSearch(value) {
145
+ if (!value) {
146
+ return {};
147
+ }
148
+
149
+ if (typeof value === 'string') {
150
+ const searchParams = new URLSearchParams(value);
151
+ return Object.fromEntries(
152
+ searchParams.entries().map(([key, value]) => [key, parseSearchValue(value)]),
153
+ );
154
+ }
155
+
156
+ return value;
157
+ }
158
+
159
+ /**
160
+ * @param {string} value
161
+ * @returns {string | number | boolean}
162
+ */
163
+ export function parseSearchValue(value) {
164
+ if (value === '') {
165
+ return '';
166
+ }
167
+ if (value === 'true') {
168
+ return true;
169
+ }
170
+ if (value === 'false') {
171
+ return false;
172
+ }
173
+ const number = Number(value);
174
+ if (!Number.isNaN(number)) {
175
+ return number;
176
+ }
177
+ return value;
178
+ }
package/src/index.d.ts CHANGED
@@ -183,7 +183,7 @@ export type RouterApi<T extends Routes> = {
183
183
  /** The reactive pathname of the URL. */
184
184
  pathname: (Path<T, true> & {}) | (string & {});
185
185
  /** The reactive query string part of the URL. */
186
- search: string;
186
+ search: Record<string, string | number | boolean>;
187
187
  /** The reactive history state that can be passed to the `navigate` function. */
188
188
  state: unknown;
189
189
  /** The reactive hash part of the URL. */
@@ -198,7 +198,9 @@ export type Path<T extends Routes, AnyParam extends boolean = false> = RemovePar
198
198
  >;
199
199
 
200
200
  export type ConstructPathArgs<TPath extends string> = {
201
- [Path in TPath]: PathParams<Path> extends never ? [Path] : [Path, PathParams<Path>];
201
+ [Path in TPath]: PathParams<Path> extends never
202
+ ? [Path] | [Path, ConstructUrlOptions]
203
+ : [Path, ConstructUrlOptions & { params: PathParams<Path> }];
202
204
  }[TPath];
203
205
 
204
206
  export type IsActiveArgs<
@@ -223,19 +225,21 @@ export type AllParams<TRoutes extends Routes> = Partial<
223
225
  Record<ExtractParams<RemoveParenthesis<RecursiveKeys<TRoutes>>>, string>
224
226
  >;
225
227
 
228
+ export type Search = string | Record<string, string | number | boolean>;
229
+
226
230
  export type HooksContext = {
227
231
  hash?: string;
228
232
  meta: RouteMeta;
229
233
  pathname: string;
230
234
  replace?: boolean;
231
- search?: string;
235
+ search: Record<string, string | number | boolean>;
232
236
  state?: string;
233
237
  };
234
238
 
235
239
  export type NavigateOptions =
236
240
  | {
237
241
  replace?: boolean;
238
- search?: string;
242
+ search?: Search;
239
243
  state?: string;
240
244
  hash?: string;
241
245
  scrollToTop?: ScrollBehavior | false;
@@ -243,17 +247,31 @@ export type NavigateOptions =
243
247
  }
244
248
  | undefined;
245
249
 
246
- export type SearchParams = Omit<URLSearchParams, 'append' | 'delete' | 'set' | 'sort'> & {
247
- append: (name: string, value: string, options?: { replace?: boolean }) => void;
248
- delete: (name: string, value?: string, options?: { replace?: boolean }) => void;
249
- set: (name: string, value: string, options?: { replace?: boolean }) => void;
250
- sort: (options?: { replace?: boolean }) => void;
250
+ export type ConstructUrlOptions =
251
+ | {
252
+ search?: Search;
253
+ hash?: string;
254
+ }
255
+ | undefined;
256
+
257
+ export type SearchParams = Omit<
258
+ URLSearchParams,
259
+ 'append' | 'delete' | 'entries' | 'get' | 'getAll' | 'set' | 'sort' | 'values'
260
+ > & {
261
+ append(name: string, value: string | number | boolean, options?: { replace?: boolean }): void;
262
+ delete(name: string, value?: string | number | boolean, options?: { replace?: boolean }): void;
263
+ entries(): IterableIterator<[string, string | number | boolean]>;
264
+ get(name: string): string | number | boolean | null;
265
+ getAll(name: string): (string | number | boolean)[];
266
+ set(name: string, value: string | number | boolean, options?: { replace?: boolean }): void;
267
+ sort(options?: { replace?: boolean }): void;
268
+ values(): IterableIterator<string | number | boolean>;
251
269
  };
252
270
 
253
271
  type NavigateArgs<T extends string> =
254
272
  | (PathParams<T> extends never
255
- ? [T] | [T, NavigateOptions]
256
- : [T, NavigateOptions & { params: PathParams<T> }])
273
+ ? [T] | [T, Omit<NavigateOptions, 'search'> & { search: Search }]
274
+ : [T, Omit<NavigateOptions, 'search'> & { search: Search; params: PathParams<T> }])
257
275
  | [number];
258
276
 
259
277
  type StripNonRoutes<T extends Routes> = {
@@ -1,29 +1,31 @@
1
1
  import { SvelteURLSearchParams } from 'svelte/reactivity';
2
+ import { parseSearchValue } from './helpers/utils.js';
2
3
 
3
4
  let searchParams = new SvelteURLSearchParams(globalThis.location.search);
4
5
 
5
6
  /** @type {import('./index.js').SearchParams} */
6
7
  const shell = {
7
8
  append(name, value, options) {
8
- searchParams.append(name, value);
9
+ searchParams.append(name, String(value));
9
10
  updateUrlSearchParams(options);
10
11
  },
11
12
  delete(name, value, options) {
12
- searchParams.delete(name, value);
13
+ searchParams.delete(name, value === undefined ? undefined : String(value));
13
14
  updateUrlSearchParams(options);
14
15
  },
15
16
  entries() {
16
- return searchParams.entries();
17
+ return searchParams.entries().map(([key, value]) => [key, parseSearchValue(value)]);
17
18
  },
18
19
  forEach(...args) {
19
- // eslint-disable-next-line unicorn/no-array-for-each
20
20
  return searchParams.forEach(...args);
21
21
  },
22
22
  get(...args) {
23
- return searchParams.get(...args);
23
+ const value = searchParams.get(...args);
24
+ if (value === null) return null;
25
+ return parseSearchValue(value);
24
26
  },
25
27
  getAll(...args) {
26
- return searchParams.getAll(...args);
28
+ return searchParams.getAll(...args).map(parseSearchValue);
27
29
  },
28
30
  has(...args) {
29
31
  return searchParams.has(...args);
@@ -32,7 +34,7 @@ const shell = {
32
34
  return searchParams.keys();
33
35
  },
34
36
  set(name, value, options) {
35
- searchParams.set(name, value);
37
+ searchParams.set(name, String(value));
36
38
  updateUrlSearchParams(options);
37
39
  },
38
40
  sort(options) {
@@ -43,7 +45,7 @@ const shell = {
43
45
  return searchParams.toString();
44
46
  },
45
47
  values() {
46
- return searchParams.values();
48
+ return searchParams.values().map(parseSearchValue);
47
49
  },
48
50
  get size() {
49
51
  return searchParams.size;
@@ -57,19 +59,23 @@ export { shell as searchParams };
57
59
 
58
60
  /** @param {string} [search] */
59
61
  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);
62
+ if (searchParams.toString() === search) {
63
+ return;
64
+ }
65
+ const newSearch = new URLSearchParams(search);
66
+ for (const [key, value] of newSearch) {
67
+ searchParams.set(key, value);
68
+ }
69
+ for (const key of searchParams.keys()) {
70
+ if (!newSearch.has(key)) {
71
+ searchParams.delete(key);
65
72
  }
66
73
  }
67
74
  }
68
75
 
69
76
  /** @param {{ replace?: boolean }} [options] */
70
77
  function updateUrlSearchParams(options) {
71
- let url = new URL(globalThis.location.toString());
78
+ const url = new URL(globalThis.location.toString());
72
79
  url.search = searchParams.toString();
73
-
74
80
  globalThis.history[options?.replace ? 'replaceState' : 'pushState']({}, '', url);
75
81
  }