sv-router 0.9.0 → 0.10.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.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Modern Svelte Routing",
5
5
  "keywords": [
6
6
  "svelte",
@@ -36,28 +36,28 @@
36
36
  "esm-env": "^1.2.2"
37
37
  },
38
38
  "devDependencies": {
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",
39
+ "@changesets/cli": "^2.29.7",
40
+ "@eslint/js": "^9.36.0",
41
+ "@sveltejs/vite-plugin-svelte": "^6.2.1",
42
+ "@testing-library/jest-dom": "^6.9.0",
43
43
  "@testing-library/svelte": "^5.2.8",
44
44
  "@testing-library/user-event": "^14.6.1",
45
- "@types/node": "^24.2.0",
46
- "eslint": "^9.32.0",
45
+ "@types/node": "^24.6.0",
46
+ "eslint": "^9.36.0",
47
47
  "eslint-config-prettier": "^10.1.8",
48
48
  "eslint-plugin-simple-import-sort": "^12.1.1",
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",
49
+ "eslint-plugin-svelte": "^3.12.4",
50
+ "eslint-plugin-unicorn": "^61.0.2",
51
+ "globals": "^16.4.0",
52
+ "happy-dom": "^19.0.2",
53
53
  "prettier": "^3.6.2",
54
54
  "prettier-plugin-jsdoc": "^1.3.3",
55
55
  "prettier-plugin-svelte": "^3.4.0",
56
- "svelte-check": "^4.3.1",
56
+ "svelte-check": "^4.3.2",
57
57
  "type-testing": "^0.2.0",
58
58
  "typescript": "^5.9.2",
59
- "typescript-eslint": "^8.39.0",
60
- "vite": "^7.1.0",
59
+ "typescript-eslint": "^8.45.0",
60
+ "vite": "^7.1.7",
61
61
  "vitest": "^3.2.4"
62
62
  },
63
63
  "peerDependencies": {
@@ -242,7 +242,12 @@ export function onGlobalClick(event) {
242
242
  const anchor = /** @type {HTMLElement} */ (event.target).closest('a');
243
243
  if (!anchor) return;
244
244
 
245
- if (anchor.hasAttribute('target') || anchor.hasAttribute('download')) return;
245
+ if (
246
+ anchor.hasAttribute('target') ||
247
+ anchor.hasAttribute('download') ||
248
+ !anchor.hasAttribute('href')
249
+ )
250
+ return;
246
251
 
247
252
  const url = new URL(anchor.href);
248
253
  const currentOrigin = globalThis.location.origin;
@@ -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,6 +134,38 @@ 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
@@ -177,7 +216,7 @@ export function createRouterCode(routes, routesPath, { allLazy = false, js = fal
177
216
  `import { createRouter } from 'sv-router';`,
178
217
  ...imports,
179
218
  '',
180
- `const routes = ${stringifiedRoutes};`,
219
+ `export const routes = ${stringifiedRoutes};`,
181
220
  ...(js ? [] : ['export type Routes = typeof routes;']),
182
221
  'export const { p, navigate, isActive, preload, route } = createRouter(routes);',
183
222
  '',
@@ -214,6 +253,7 @@ export function pathToCorrectCasing(value) {
214
253
  parts.push(...lastPart.split('-'));
215
254
 
216
255
  const uppercased = parts.map((part, index) => {
256
+ part = part.replace(/^_+/, '');
217
257
  if (index === 0 && (lastPart === 'hooks' || lastPart === 'meta')) return part;
218
258
  part = part.replace(/^\[(.*)\]$/, '$1');
219
259
  return part.charAt(0).toUpperCase() + part.slice(1);
@@ -1,5 +1,9 @@
1
1
  import { matchRoute } from './match-route.js';
2
- import { parseSearch, 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
@@ -19,30 +23,171 @@ export async function preload(routes, path, options) {
19
23
  await resolveRouteComponents(match ? [...layouts, match] : layouts);
20
24
  }
21
25
 
26
+ /** @type {Set<HTMLAnchorElement>} */
22
27
  const linkSet = new Set();
28
+ /** @type {Set<HTMLAnchorElement>} */
29
+ const predictedLinks = new Set();
23
30
 
24
31
  /** @param {import('../index.js').Routes} routes */
25
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
+
26
149
  const observer = new MutationObserver(() => {
27
150
  const links = /** @type {NodeListOf<HTMLAnchorElement>} */ (
28
151
  document.querySelectorAll('a[data-preload]')
29
152
  );
153
+
30
154
  for (const link of links) {
31
155
  if (linkSet.has(link)) continue;
32
156
  linkSet.add(link);
33
157
 
34
- link.addEventListener('mouseenter', function callback() {
35
- link.removeEventListener('mouseenter', callback);
36
- const href = link.getAttribute('href');
37
- if (!href) return;
38
- const url = new URL(link.href);
39
- const { replace, state } = link.dataset;
40
- preload(routes, href, {
41
- replace: replace === '' || replace === 'true',
42
- search: url.search,
43
- state,
44
- hash: url.hash,
45
- });
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);
46
191
  });
47
192
  }
48
193
  });
@@ -133,6 +133,9 @@ export function serializeSearch(value) {
133
133
  const stringValues = Object.fromEntries(
134
134
  Object.entries(value).map(([key, value]) => [key, String(value)]),
135
135
  );
136
+ if (Object.keys(stringValues).length === 0) {
137
+ return;
138
+ }
136
139
  const urlSearchParams = new URLSearchParams(stringValues);
137
140
  return '?' + urlSearchParams.toString();
138
141
  }
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
  *
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';