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 +2 -1
- package/package.json +2 -2
- package/src/create-router.svelte.js +42 -37
- package/src/gen/generate-router-code.js +47 -6
- package/src/gen/write-router-code.js +4 -1
- package/src/helpers/preload.js +164 -14
- package/src/helpers/utils.js +86 -2
- package/src/index.d.ts +32 -11
- package/src/index.js +1 -0
- package/src/search-params.svelte.js +21 -15
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.
|
|
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": "^
|
|
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:
|
|
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 & {
|
|
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?.(
|
|
157
|
+
await beforeLoad?.(hooksContext);
|
|
172
158
|
} catch (error) {
|
|
173
159
|
for (const { onError } of errorHooks) {
|
|
174
|
-
void onError?.(error,
|
|
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,
|
|
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
|
-
|
|
200
|
-
url
|
|
201
|
-
|
|
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(
|
|
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?.(
|
|
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
|
|
114
|
-
|
|
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, {
|
|
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) {
|
package/src/helpers/preload.js
CHANGED
|
@@ -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?.({
|
|
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.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
});
|
package/src/helpers/utils.js
CHANGED
|
@@ -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
|
|
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
|
|
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?:
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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()
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
}
|