sv-router 0.8.1 → 0.10.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.1",
3
+ "version": "0.10.0",
4
4
  "description": "Modern Svelte Routing",
5
5
  "keywords": [
6
6
  "svelte",
@@ -47,7 +47,7 @@
47
47
  "eslint-config-prettier": "^10.1.8",
48
48
  "eslint-plugin-simple-import-sort": "^12.1.1",
49
49
  "eslint-plugin-svelte": "^3.11.0",
50
- "eslint-plugin-unicorn": "^60.0.0",
50
+ "eslint-plugin-unicorn": "^61.0.2",
51
51
  "globals": "^16.3.0",
52
52
  "happy-dom": "^18.0.1",
53
53
  "prettier": "^3.6.2",
@@ -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';
@@ -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) {
@@ -110,8 +110,15 @@ export function createRouteMap(fileTree, prefix = '') {
110
110
  result['/' + filePathToRoute(entry.replace('.svelte', ''))] = prefix + entry;
111
111
  } else {
112
112
  const entryName = filePathToRoute(entry.name);
113
- const paramFolder = entryName.replace(/^\[(.*)\]$/, ':$1');
114
- result['/' + paramFolder] = createRouteMap(entry.tree, prefix + entryName + '/');
113
+ const isRouteGroup = /^_[^_[]/.test(entry.name);
114
+
115
+ if (isRouteGroup) {
116
+ const childMap = createRouteMap(entry.tree, prefix + entryName + '/');
117
+ mergeRouteGroup(result, childMap);
118
+ } else {
119
+ const paramFolder = entryName.replace(/^\[(.*)\]$/, ':$1');
120
+ result['/' + paramFolder] = createRouteMap(entry.tree, prefix + entryName + '/');
121
+ }
115
122
  }
116
123
  }
117
124
  return result;
@@ -127,13 +134,45 @@ function filePathToRoute(filename) {
127
134
  return filename.replaceAll(/\.(?!\.\.)/g, '/');
128
135
  }
129
136
 
137
+ /**
138
+ * @param {GeneratedRoutes} result
139
+ * @param {GeneratedRoutes} childMap
140
+ */
141
+ function mergeRouteGroup(result, childMap) {
142
+ const layout = childMap.layout;
143
+ const hooks = childMap.hooks;
144
+ const meta = childMap.meta;
145
+
146
+ for (const [key, val] of Object.entries(childMap)) {
147
+ if (key === 'layout' || key === 'hooks' || key === 'meta') {
148
+ continue;
149
+ }
150
+
151
+ /** @type {GeneratedRoutes} */
152
+ let routeWithGroupFiles = {};
153
+ if (typeof val === 'string') {
154
+ routeWithGroupFiles = { '/': val };
155
+ } else {
156
+ routeWithGroupFiles = { ...val };
157
+ }
158
+ if (layout) routeWithGroupFiles.layout = layout;
159
+ if (hooks) routeWithGroupFiles.hooks = hooks;
160
+ if (meta) routeWithGroupFiles.meta = meta;
161
+ if (result[key]) {
162
+ throw new Error(`Route conflict at \`${key}\``);
163
+ }
164
+
165
+ result[key] = routeWithGroupFiles;
166
+ }
167
+ }
168
+
130
169
  /**
131
170
  * @param {GeneratedRoutes} routes
132
171
  * @param {string} routesPath
133
- * @param {{ allLazy?: boolean }} [options]
172
+ * @param {{ allLazy?: boolean; js?: boolean }} [options]
134
173
  * @returns {string}
135
174
  */
136
- export function createRouterCode(routes, routesPath, { allLazy = false } = {}) {
175
+ export function createRouterCode(routes, routesPath, { allLazy = false, js = false } = {}) {
137
176
  if (!routesPath.endsWith('/')) {
138
177
  routesPath += '/';
139
178
  }
@@ -178,8 +217,9 @@ export function createRouterCode(routes, routesPath, { allLazy = false } = {}) {
178
217
  ...imports,
179
218
  '',
180
219
  `const routes = ${stringifiedRoutes};`,
181
- 'export type Routes = typeof routes;',
220
+ ...(js ? [] : ['export type Routes = typeof routes;']),
182
221
  'export const { p, navigate, isActive, preload, route } = createRouter(routes);',
222
+ '',
183
223
  ].join('\n');
184
224
  }
185
225
 
@@ -213,6 +253,7 @@ export function pathToCorrectCasing(value) {
213
253
  parts.push(...lastPart.split('-'));
214
254
 
215
255
  const uppercased = parts.map((part, index) => {
256
+ part = part.replace(/^_+/, '');
216
257
  if (index === 0 && (lastPart === 'hooks' || lastPart === 'meta')) return part;
217
258
  part = part.replace(/^\[(.*)\]$/, '$1');
218
259
  return part.charAt(0).toUpperCase() + part.slice(1);
@@ -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) {
@@ -1,5 +1,9 @@
1
1
  import { matchRoute } from './match-route.js';
2
- import { resolveRouteComponents } from './utils.js';
2
+ import { parseSearch, resolveRouteComponents, stripBase } from './utils.js';
3
+
4
+ const PREDICT_CONE_LENGTH = 200;
5
+ const PREDICT_CONE_ANGLE = Math.PI / 6;
6
+ const PREDICT_TIMEOUT = 50;
3
7
 
4
8
  /**
5
9
  * @param {import('../index.js').Routes} routes
@@ -9,35 +13,181 @@ import { resolveRouteComponents } from './utils.js';
9
13
  export async function preload(routes, path, options) {
10
14
  const { match, layouts, hooks, meta } = matchRoute(path, routes);
11
15
  for (const { onPreload } of hooks) {
12
- void onPreload?.({ pathname: path, meta, ...options });
16
+ void onPreload?.({
17
+ pathname: path,
18
+ meta,
19
+ ...options,
20
+ search: parseSearch(options?.search),
21
+ });
13
22
  }
14
23
  await resolveRouteComponents(match ? [...layouts, match] : layouts);
15
24
  }
16
25
 
26
+ /** @type {Set<HTMLAnchorElement>} */
17
27
  const linkSet = new Set();
28
+ /** @type {Set<HTMLAnchorElement>} */
29
+ const predictedLinks = new Set();
18
30
 
19
31
  /** @param {import('../index.js').Routes} routes */
20
32
  export function preloadOnHover(routes) {
33
+ /** @param {HTMLAnchorElement} link */
34
+ function anchorPreload(link) {
35
+ const href = link.getAttribute('href');
36
+ if (!href) return;
37
+ const url = new URL(link.href);
38
+ const pathname = stripBase(url.pathname);
39
+ const { replace, state } = link.dataset;
40
+ preload(routes, pathname, {
41
+ replace: replace === '' || replace === 'true',
42
+ search: url.search,
43
+ state,
44
+ hash: url.hash,
45
+ });
46
+ }
47
+
48
+ /** @type {ReturnType<typeof setTimeout> | null} */
49
+ let throttleTimer = null;
50
+ function pointerMoveListener(/** @type {PointerEvent} */ event) {
51
+ if (!event.getPredictedEvents || throttleTimer) return;
52
+ throttleTimer = setTimeout(() => {
53
+ throttleTimer = null;
54
+ }, PREDICT_TIMEOUT);
55
+
56
+ if (predictedLinks.size === 0) {
57
+ document.removeEventListener('pointermove', pointerMoveListener);
58
+ return;
59
+ }
60
+
61
+ const predictedEvents = event.getPredictedEvents();
62
+ const lastPredicted = predictedEvents.at(-1);
63
+ if (!lastPredicted) return;
64
+
65
+ const currentX = event.clientX;
66
+ const currentY = event.clientY;
67
+ const dx = lastPredicted.clientX - currentX;
68
+ const dy = lastPredicted.clientY - currentY;
69
+
70
+ const distance = Math.hypot(dx, dy);
71
+ if (distance < 2) return;
72
+ const dirX = dx / distance;
73
+ const dirY = dy / distance;
74
+
75
+ // Visualize the cone (comment out when not debugging)
76
+ /*
77
+ const canvas = document.createElement('canvas');
78
+ canvas.style.position = 'fixed';
79
+ canvas.style.left = '0';
80
+ canvas.style.top = '0';
81
+ canvas.style.width = '100%';
82
+ canvas.style.height = '100%';
83
+ canvas.style.pointerEvents = 'none';
84
+ canvas.style.zIndex = '99999';
85
+ canvas.width = window.innerWidth;
86
+ canvas.height = window.innerHeight;
87
+ document.body.append(canvas);
88
+ const ctx = canvas.getContext('2d');
89
+ ctx.fillStyle = 'rgba(255, 0, 0, 0.3)';
90
+ ctx.strokeStyle = 'rgba(255, 0, 0, 0.6)';
91
+ ctx.lineWidth = 2;
92
+ ctx.beginPath();
93
+ ctx.moveTo(currentX, currentY);
94
+ const leftAngle = Math.atan2(dirY, dirX) - PREDICT_CONE_ANGLE;
95
+ const rightAngle = Math.atan2(dirY, dirX) + PREDICT_CONE_ANGLE;
96
+ const leftX = currentX + Math.cos(leftAngle) * PREDICT_CONE_LENGTH;
97
+ const leftY = currentY + Math.sin(leftAngle) * PREDICT_CONE_LENGTH;
98
+ ctx.lineTo(leftX, leftY);
99
+ ctx.arc(currentX, currentY, PREDICT_CONE_LENGTH, leftAngle, rightAngle, false);
100
+ ctx.lineTo(currentX, currentY);
101
+ ctx.closePath();
102
+ ctx.fill();
103
+ ctx.stroke();
104
+ setTimeout(() => canvas.remove(), PREDICT_TIMEOUT);
105
+ */
106
+
107
+ outer: for (const link of predictedLinks) {
108
+ if (!link.isConnected) {
109
+ predictedLinks.delete(link);
110
+ }
111
+ const rect = link.getBoundingClientRect();
112
+ const points = [
113
+ { x: rect.left, y: rect.top },
114
+ { x: rect.right, y: rect.top },
115
+ { x: rect.left, y: rect.bottom },
116
+ { x: rect.right, y: rect.bottom },
117
+ { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
118
+ ];
119
+
120
+ for (const point of points) {
121
+ const toPointX = point.x - currentX;
122
+ const toPointY = point.y - currentY;
123
+ const distToPoint = Math.hypot(toPointX, toPointY);
124
+ if (distToPoint > PREDICT_CONE_LENGTH || distToPoint < 0.001) continue;
125
+
126
+ const toPointDirX = toPointX / distToPoint;
127
+ const toPointDirY = toPointY / distToPoint;
128
+ const dotProduct = dirX * toPointDirX + dirY * toPointDirY;
129
+ const angle = Math.acos(Math.max(-1, Math.min(1, dotProduct)));
130
+
131
+ if (angle <= PREDICT_CONE_ANGLE) {
132
+ anchorPreload(link);
133
+ predictedLinks.delete(link);
134
+ continue outer;
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ const intersectionObserver = new IntersectionObserver((entries) => {
141
+ for (const entry of entries) {
142
+ if (entry.isIntersecting) {
143
+ intersectionObserver.unobserve(entry.target);
144
+ anchorPreload(/** @type {HTMLAnchorElement} */ (entry.target));
145
+ }
146
+ }
147
+ });
148
+
21
149
  const observer = new MutationObserver(() => {
22
150
  const links = /** @type {NodeListOf<HTMLAnchorElement>} */ (
23
151
  document.querySelectorAll('a[data-preload]')
24
152
  );
153
+
25
154
  for (const link of links) {
26
155
  if (linkSet.has(link)) continue;
27
156
  linkSet.add(link);
28
157
 
29
- link.addEventListener('mouseenter', function callback() {
30
- link.removeEventListener('mouseenter', callback);
31
- const href = link.getAttribute('href');
32
- if (!href) return;
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
- });
158
+ switch (link.dataset.preload) {
159
+ case '':
160
+ case 'hover': {
161
+ link.addEventListener('mouseenter', function callback() {
162
+ link.removeEventListener('mouseenter', callback);
163
+ anchorPreload(link);
164
+ });
165
+ break;
166
+ }
167
+ case 'predict': {
168
+ if (predictedLinks.size === 0) {
169
+ document.addEventListener('pointermove', pointerMoveListener);
170
+ }
171
+ predictedLinks.add(link);
172
+ break;
173
+ }
174
+ case 'viewport': {
175
+ intersectionObserver.observe(link);
176
+ break;
177
+ }
178
+ default: {
179
+ console.warn(
180
+ `Unknown preload strategy \`${link.dataset.preload}\` on`,
181
+ link,
182
+ '\nAvailable strategies are: hover, viewport, predict',
183
+ );
184
+ break;
185
+ }
186
+ }
187
+
188
+ link.addEventListener('focus', function callback() {
189
+ link.removeEventListener('focus', callback);
190
+ anchorPreload(link);
41
191
  });
42
192
  }
43
193
  });
@@ -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,69 @@ 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
+ if (Object.keys(stringValues).length === 0) {
137
+ return;
138
+ }
139
+ const urlSearchParams = new URLSearchParams(stringValues);
140
+ return '?' + urlSearchParams.toString();
141
+ }
142
+
143
+ /**
144
+ * @param {import('../index.d.ts').Search} [value]
145
+ * @returns {Record<string, string | number | boolean>}
146
+ */
147
+ export function parseSearch(value) {
148
+ if (!value) {
149
+ return {};
150
+ }
151
+
152
+ if (typeof value === 'string') {
153
+ const searchParams = new URLSearchParams(value);
154
+ return Object.fromEntries(
155
+ searchParams.entries().map(([key, value]) => [key, parseSearchValue(value)]),
156
+ );
157
+ }
158
+
159
+ return value;
160
+ }
161
+
162
+ /**
163
+ * @param {string} value
164
+ * @returns {string | number | boolean}
165
+ */
166
+ export function parseSearchValue(value) {
167
+ if (value === '') {
168
+ return '';
169
+ }
170
+ if (value === 'true') {
171
+ return true;
172
+ }
173
+ if (value === 'false') {
174
+ return false;
175
+ }
176
+ const number = Number(value);
177
+ if (!Number.isNaN(number)) {
178
+ return number;
179
+ }
180
+ return value;
181
+ }
package/src/index.d.ts CHANGED
@@ -12,6 +12,9 @@ import type { Action } from 'svelte/action';
12
12
  */
13
13
  export const isActiveLink: IsActiveLink;
14
14
 
15
+ /** Create a search string from the search object that is provided in hooks. */
16
+ export function serializeSearch(search: Search): string | undefined;
17
+
15
18
  /**
16
19
  * Setup a new router instance with the given routes.
17
20
  *
@@ -183,7 +186,7 @@ export type RouterApi<T extends Routes> = {
183
186
  /** The reactive pathname of the URL. */
184
187
  pathname: (Path<T, true> & {}) | (string & {});
185
188
  /** The reactive query string part of the URL. */
186
- search: string;
189
+ search: Record<string, string | number | boolean>;
187
190
  /** The reactive history state that can be passed to the `navigate` function. */
188
191
  state: unknown;
189
192
  /** The reactive hash part of the URL. */
@@ -198,7 +201,9 @@ export type Path<T extends Routes, AnyParam extends boolean = false> = RemovePar
198
201
  >;
199
202
 
200
203
  export type ConstructPathArgs<TPath extends string> = {
201
- [Path in TPath]: PathParams<Path> extends never ? [Path] : [Path, PathParams<Path>];
204
+ [Path in TPath]: PathParams<Path> extends never
205
+ ? [Path] | [Path, ConstructUrlOptions]
206
+ : [Path, ConstructUrlOptions & { params: PathParams<Path> }];
202
207
  }[TPath];
203
208
 
204
209
  export type IsActiveArgs<
@@ -223,19 +228,21 @@ export type AllParams<TRoutes extends Routes> = Partial<
223
228
  Record<ExtractParams<RemoveParenthesis<RecursiveKeys<TRoutes>>>, string>
224
229
  >;
225
230
 
231
+ export type Search = string | Record<string, string | number | boolean>;
232
+
226
233
  export type HooksContext = {
227
234
  hash?: string;
228
235
  meta: RouteMeta;
229
236
  pathname: string;
230
237
  replace?: boolean;
231
- search?: string;
238
+ search: Record<string, string | number | boolean>;
232
239
  state?: string;
233
240
  };
234
241
 
235
242
  export type NavigateOptions =
236
243
  | {
237
244
  replace?: boolean;
238
- search?: string;
245
+ search?: Search;
239
246
  state?: string;
240
247
  hash?: string;
241
248
  scrollToTop?: ScrollBehavior | false;
@@ -243,17 +250,31 @@ export type NavigateOptions =
243
250
  }
244
251
  | undefined;
245
252
 
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;
253
+ export type ConstructUrlOptions =
254
+ | {
255
+ search?: Search;
256
+ hash?: string;
257
+ }
258
+ | undefined;
259
+
260
+ export type SearchParams = Omit<
261
+ URLSearchParams,
262
+ 'append' | 'delete' | 'entries' | 'get' | 'getAll' | 'set' | 'sort' | 'values'
263
+ > & {
264
+ append(name: string, value: string | number | boolean, options?: { replace?: boolean }): void;
265
+ delete(name: string, value?: string | number | boolean, options?: { replace?: boolean }): void;
266
+ entries(): IterableIterator<[string, string | number | boolean]>;
267
+ get(name: string): string | number | boolean | null;
268
+ getAll(name: string): (string | number | boolean)[];
269
+ set(name: string, value: string | number | boolean, options?: { replace?: boolean }): void;
270
+ sort(options?: { replace?: boolean }): void;
271
+ values(): IterableIterator<string | number | boolean>;
251
272
  };
252
273
 
253
274
  type NavigateArgs<T extends string> =
254
275
  | (PathParams<T> extends never
255
- ? [T] | [T, NavigateOptions]
256
- : [T, NavigateOptions & { params: PathParams<T> }])
276
+ ? [T] | [T, Omit<NavigateOptions, 'search'> & { search: Search }]
277
+ : [T, Omit<NavigateOptions, 'search'> & { search: Search; params: PathParams<T> }])
257
278
  | [number];
258
279
 
259
280
  type StripNonRoutes<T extends Routes> = {
package/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { isActiveLink } from './actions.svelte.js';
2
2
  export { createRouter } from './create-router.svelte.js';
3
+ export { serializeSearch } from './helpers/utils.js';
3
4
  export { default as Router } from './Router.svelte';
4
5
  export { searchParams } from './search-params.svelte.js';
@@ -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
  }