pulse-js-framework 1.10.0 → 1.10.3
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/compiler/parser/_extract.js +393 -0
- package/compiler/parser/blocks.js +361 -0
- package/compiler/parser/core.js +306 -0
- package/compiler/parser/expressions.js +386 -0
- package/compiler/parser/imports.js +108 -0
- package/compiler/parser/index.js +47 -0
- package/compiler/parser/state.js +155 -0
- package/compiler/parser/style.js +445 -0
- package/compiler/parser/view.js +632 -0
- package/compiler/parser.js +15 -2372
- package/compiler/parser.js.original +2376 -0
- package/package.json +2 -1
- package/runtime/a11y/announcements.js +213 -0
- package/runtime/a11y/contrast.js +125 -0
- package/runtime/a11y/focus.js +412 -0
- package/runtime/a11y/index.js +35 -0
- package/runtime/a11y/preferences.js +121 -0
- package/runtime/a11y/utils.js +164 -0
- package/runtime/a11y/validation.js +258 -0
- package/runtime/a11y/widgets.js +545 -0
- package/runtime/a11y.js +15 -1840
- package/runtime/a11y.js.original +1844 -0
- package/runtime/graphql/cache.js +69 -0
- package/runtime/graphql/client.js +563 -0
- package/runtime/graphql/hooks.js +492 -0
- package/runtime/graphql/index.js +62 -0
- package/runtime/graphql/subscriptions.js +241 -0
- package/runtime/graphql.js +12 -1322
- package/runtime/graphql.js.original +1326 -0
- package/runtime/router/core.js +956 -0
- package/runtime/router/guards.js +90 -0
- package/runtime/router/history.js +204 -0
- package/runtime/router/index.js +36 -0
- package/runtime/router/lazy.js +180 -0
- package/runtime/router/utils.js +226 -0
- package/runtime/router.js +12 -1600
- package/runtime/router.js.original +1605 -0
package/runtime/router.js
CHANGED
|
@@ -1,1605 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pulse Router -
|
|
2
|
+
* Pulse Router - Backward Compatibility Export
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* This file maintains backward compatibility by re-exporting from router/
|
|
5
|
+
* The actual implementation has been split into focused sub-modules:
|
|
6
|
+
* - router/core.js - RouteTrie, createRouter, simpleRouter
|
|
7
|
+
* - router/lazy.js - Lazy loading utilities
|
|
8
|
+
* - router/guards.js - Middleware and navigation guards
|
|
9
|
+
* - router/history.js - Browser history and scroll management
|
|
10
|
+
* - router/utils.js - Route parsing and query string utilities
|
|
5
11
|
*
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
* - Nested routes
|
|
9
|
-
* - Route meta fields
|
|
10
|
-
* - Per-route and global guards
|
|
11
|
-
* - Scroll restoration
|
|
12
|
-
* - Lazy-loaded routes
|
|
13
|
-
* - Middleware support
|
|
14
|
-
* - Route aliases and redirects
|
|
15
|
-
* - Typed query parameter parsing
|
|
16
|
-
* - Route groups with shared layouts
|
|
17
|
-
* - Navigation loading state
|
|
18
|
-
* - Route transitions and lifecycle hooks (onBeforeLeave, onAfterEnter)
|
|
12
|
+
* @deprecated Import from 'pulse-js-framework/runtime/router/index.js' instead
|
|
13
|
+
* @module pulse-js-framework/runtime/router
|
|
19
14
|
*/
|
|
20
15
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
import { loggers } from './logger.js';
|
|
24
|
-
import { createVersionedAsync } from './async.js';
|
|
25
|
-
import { Errors } from './errors.js';
|
|
26
|
-
import { LRUCache } from './lru-cache.js';
|
|
27
|
-
|
|
28
|
-
const log = loggers.router;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Lazy load helper for route components
|
|
32
|
-
* Wraps a dynamic import to provide loading states and error handling
|
|
33
|
-
*
|
|
34
|
-
* MEMORY SAFETY: Uses load version tracking to prevent stale promise callbacks
|
|
35
|
-
* from updating containers that are no longer in the DOM (e.g., after navigation).
|
|
36
|
-
*
|
|
37
|
-
* @param {function} importFn - Dynamic import function () => import('./Component.js')
|
|
38
|
-
* @param {Object} options - Lazy loading options
|
|
39
|
-
* @param {function} options.loading - Loading component function
|
|
40
|
-
* @param {function} options.error - Error component function
|
|
41
|
-
* @param {number} options.timeout - Timeout in ms (default: 10000)
|
|
42
|
-
* @param {number} options.delay - Delay before showing loading (default: 200)
|
|
43
|
-
* @returns {function} Lazy route handler
|
|
44
|
-
*
|
|
45
|
-
* @example
|
|
46
|
-
* const routes = {
|
|
47
|
-
* '/dashboard': lazy(() => import('./Dashboard.js')),
|
|
48
|
-
* '/settings': lazy(() => import('./Settings.js'), {
|
|
49
|
-
* loading: () => el('div.spinner', 'Loading...'),
|
|
50
|
-
* error: (err) => el('div.error', `Failed to load: ${err.message}`),
|
|
51
|
-
* timeout: 5000
|
|
52
|
-
* })
|
|
53
|
-
* };
|
|
54
|
-
*/
|
|
55
|
-
export function lazy(importFn, options = {}) {
|
|
56
|
-
const {
|
|
57
|
-
loading: LoadingComponent = null,
|
|
58
|
-
error: ErrorComponent = null,
|
|
59
|
-
timeout = 10000,
|
|
60
|
-
delay = 200
|
|
61
|
-
} = options;
|
|
62
|
-
|
|
63
|
-
// Cache for loaded component
|
|
64
|
-
let cachedComponent = null;
|
|
65
|
-
let loadPromise = null;
|
|
66
|
-
|
|
67
|
-
// Use centralized versioned async for race condition handling
|
|
68
|
-
const versionController = createVersionedAsync();
|
|
69
|
-
|
|
70
|
-
return function lazyHandler(ctx) {
|
|
71
|
-
// Return cached component if already loaded
|
|
72
|
-
if (cachedComponent) {
|
|
73
|
-
return typeof cachedComponent === 'function'
|
|
74
|
-
? cachedComponent(ctx)
|
|
75
|
-
: cachedComponent.default
|
|
76
|
-
? cachedComponent.default(ctx)
|
|
77
|
-
: cachedComponent.render
|
|
78
|
-
? cachedComponent.render(ctx)
|
|
79
|
-
: cachedComponent;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Create container for async loading
|
|
83
|
-
const container = el('div.lazy-route');
|
|
84
|
-
|
|
85
|
-
// Start a new versioned load operation
|
|
86
|
-
const loadCtx = versionController.begin();
|
|
87
|
-
|
|
88
|
-
// Attach abort method to container for cleanup on navigation
|
|
89
|
-
container._pulseAbortLazyLoad = () => versionController.abort();
|
|
90
|
-
|
|
91
|
-
// Start loading if not already
|
|
92
|
-
if (!loadPromise) {
|
|
93
|
-
loadPromise = importFn();
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Delay showing loading state to avoid flash (uses versioned timer)
|
|
97
|
-
if (LoadingComponent && delay > 0) {
|
|
98
|
-
loadCtx.setTimeout(() => {
|
|
99
|
-
if (!cachedComponent && loadCtx.isCurrent()) {
|
|
100
|
-
container.replaceChildren(LoadingComponent());
|
|
101
|
-
}
|
|
102
|
-
}, delay);
|
|
103
|
-
} else if (LoadingComponent) {
|
|
104
|
-
container.replaceChildren(LoadingComponent());
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Set timeout for loading (uses versioned timer)
|
|
108
|
-
let timeoutPromise = null;
|
|
109
|
-
if (timeout > 0) {
|
|
110
|
-
timeoutPromise = new Promise((_, reject) => {
|
|
111
|
-
loadCtx.setTimeout(() => {
|
|
112
|
-
reject(Errors.lazyTimeout(timeout));
|
|
113
|
-
}, timeout);
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Race between load and timeout
|
|
118
|
-
const loadWithTimeout = timeoutPromise
|
|
119
|
-
? Promise.race([loadPromise, timeoutPromise])
|
|
120
|
-
: loadPromise;
|
|
121
|
-
|
|
122
|
-
loadWithTimeout
|
|
123
|
-
.then(module => {
|
|
124
|
-
// Always cache the component, even if navigation occurred
|
|
125
|
-
// This prevents re-showing loading state on future navigations
|
|
126
|
-
cachedComponent = module;
|
|
127
|
-
|
|
128
|
-
// Skip DOM updates if this load attempt is stale (navigation occurred)
|
|
129
|
-
if (loadCtx.isStale()) {
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Get the component from module
|
|
134
|
-
const Component = module.default || module;
|
|
135
|
-
const result = typeof Component === 'function'
|
|
136
|
-
? Component(ctx)
|
|
137
|
-
: Component.render
|
|
138
|
-
? Component.render(ctx)
|
|
139
|
-
: Component;
|
|
140
|
-
|
|
141
|
-
// Replace loading with actual component
|
|
142
|
-
loadCtx.ifCurrent(() => {
|
|
143
|
-
if (result instanceof Node) {
|
|
144
|
-
container.replaceChildren(result);
|
|
145
|
-
}
|
|
146
|
-
});
|
|
147
|
-
})
|
|
148
|
-
.catch(err => {
|
|
149
|
-
loadPromise = null; // Allow retry
|
|
150
|
-
|
|
151
|
-
// Ignore if this load attempt is stale
|
|
152
|
-
if (loadCtx.isStale()) {
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (ErrorComponent) {
|
|
157
|
-
container.replaceChildren(ErrorComponent(err));
|
|
158
|
-
} else {
|
|
159
|
-
log.error('Lazy load error:', err);
|
|
160
|
-
container.replaceChildren(
|
|
161
|
-
el('div.lazy-error', `Failed to load component: ${err.message}`)
|
|
162
|
-
);
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
return container;
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Preload a lazy component without rendering
|
|
172
|
-
* Useful for prefetching on hover or when likely to navigate
|
|
173
|
-
*
|
|
174
|
-
* @param {function} lazyHandler - Lazy handler created with lazy()
|
|
175
|
-
* @returns {Promise} Resolves when component is loaded
|
|
176
|
-
*
|
|
177
|
-
* @example
|
|
178
|
-
* const DashboardLazy = lazy(() => import('./Dashboard.js'));
|
|
179
|
-
* // Preload on link hover
|
|
180
|
-
* link.addEventListener('mouseenter', () => preload(DashboardLazy));
|
|
181
|
-
*/
|
|
182
|
-
export function preload(lazyHandler) {
|
|
183
|
-
// Trigger the lazy handler with a dummy context to start loading
|
|
184
|
-
// The result is discarded, but the component will be cached
|
|
185
|
-
return new Promise(resolve => {
|
|
186
|
-
const result = lazyHandler({});
|
|
187
|
-
if (result instanceof Promise) {
|
|
188
|
-
result.then(resolve);
|
|
189
|
-
} else {
|
|
190
|
-
// Already loaded
|
|
191
|
-
resolve(result);
|
|
192
|
-
}
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Middleware context passed to each middleware function
|
|
198
|
-
* @typedef {Object} MiddlewareContext
|
|
199
|
-
* @property {NavigationTarget} to - Target route
|
|
200
|
-
* @property {NavigationTarget} from - Source route
|
|
201
|
-
* @property {Object} meta - Shared metadata between middlewares
|
|
202
|
-
* @property {function} redirect - Redirect to another path
|
|
203
|
-
* @property {function} abort - Abort navigation
|
|
204
|
-
*/
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Create a middleware runner for the router
|
|
208
|
-
* Middlewares are executed in order, each can modify context or abort navigation
|
|
209
|
-
*
|
|
210
|
-
* @param {Array<function>} middlewares - Array of middleware functions
|
|
211
|
-
* @returns {function} Runner function
|
|
212
|
-
*
|
|
213
|
-
* @example
|
|
214
|
-
* const authMiddleware = async (ctx, next) => {
|
|
215
|
-
* if (ctx.to.meta.requiresAuth && !isAuthenticated()) {
|
|
216
|
-
* return ctx.redirect('/login');
|
|
217
|
-
* }
|
|
218
|
-
* await next();
|
|
219
|
-
* };
|
|
220
|
-
*
|
|
221
|
-
* const loggerMiddleware = async (ctx, next) => {
|
|
222
|
-
* console.log('Navigating to:', ctx.to.path);
|
|
223
|
-
* const start = Date.now();
|
|
224
|
-
* await next();
|
|
225
|
-
* console.log('Navigation took:', Date.now() - start, 'ms');
|
|
226
|
-
* };
|
|
227
|
-
*
|
|
228
|
-
* const router = createRouter({
|
|
229
|
-
* routes,
|
|
230
|
-
* middleware: [loggerMiddleware, authMiddleware]
|
|
231
|
-
* });
|
|
232
|
-
*/
|
|
233
|
-
function createMiddlewareRunner(middlewares) {
|
|
234
|
-
return async function runMiddleware(context) {
|
|
235
|
-
let index = 0;
|
|
236
|
-
let aborted = false;
|
|
237
|
-
let redirectPath = null;
|
|
238
|
-
|
|
239
|
-
// Create enhanced context with redirect and abort
|
|
240
|
-
const ctx = {
|
|
241
|
-
...context,
|
|
242
|
-
meta: {},
|
|
243
|
-
redirect: (path) => {
|
|
244
|
-
redirectPath = path;
|
|
245
|
-
},
|
|
246
|
-
abort: () => {
|
|
247
|
-
aborted = true;
|
|
248
|
-
}
|
|
249
|
-
};
|
|
250
|
-
|
|
251
|
-
async function next() {
|
|
252
|
-
if (aborted || redirectPath) return;
|
|
253
|
-
if (index >= middlewares.length) return;
|
|
254
|
-
|
|
255
|
-
const middlewareIndex = index;
|
|
256
|
-
const middleware = middlewares[index++];
|
|
257
|
-
try {
|
|
258
|
-
await middleware(ctx, next);
|
|
259
|
-
} catch (error) {
|
|
260
|
-
log.error(`Middleware error at index ${middlewareIndex}:`, error);
|
|
261
|
-
throw error; // Re-throw to halt navigation
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
await next();
|
|
266
|
-
|
|
267
|
-
return {
|
|
268
|
-
aborted,
|
|
269
|
-
redirectPath,
|
|
270
|
-
meta: ctx.meta
|
|
271
|
-
};
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Radix Trie for efficient route matching
|
|
277
|
-
* Provides O(path length) lookup instead of O(routes count)
|
|
278
|
-
*/
|
|
279
|
-
class RouteTrie {
|
|
280
|
-
constructor() {
|
|
281
|
-
this.root = { children: new Map(), route: null, paramName: null, isWildcard: false };
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Insert a route into the trie
|
|
286
|
-
*/
|
|
287
|
-
insert(pattern, route) {
|
|
288
|
-
const segments = pattern === '/' ? [''] : pattern.split('/').filter(Boolean);
|
|
289
|
-
let node = this.root;
|
|
290
|
-
|
|
291
|
-
for (const segment of segments) {
|
|
292
|
-
let key;
|
|
293
|
-
let paramName = null;
|
|
294
|
-
let isWildcard = false;
|
|
295
|
-
|
|
296
|
-
if (segment.startsWith(':')) {
|
|
297
|
-
// Dynamic segment - :param
|
|
298
|
-
key = ':';
|
|
299
|
-
paramName = segment.slice(1);
|
|
300
|
-
} else if (segment.startsWith('*')) {
|
|
301
|
-
// Wildcard segment - *path
|
|
302
|
-
key = '*';
|
|
303
|
-
paramName = segment.slice(1) || 'wildcard';
|
|
304
|
-
isWildcard = true;
|
|
305
|
-
} else {
|
|
306
|
-
// Static segment
|
|
307
|
-
key = segment;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
if (!node.children.has(key)) {
|
|
311
|
-
node.children.set(key, {
|
|
312
|
-
children: new Map(),
|
|
313
|
-
route: null,
|
|
314
|
-
paramName,
|
|
315
|
-
isWildcard
|
|
316
|
-
});
|
|
317
|
-
}
|
|
318
|
-
node = node.children.get(key);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
node.route = route;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* Find a matching route for a path
|
|
326
|
-
*/
|
|
327
|
-
find(path) {
|
|
328
|
-
const segments = path === '/' ? [''] : path.split('/').filter(Boolean);
|
|
329
|
-
return this._findRecursive(this.root, segments, 0, {});
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
_findRecursive(node, segments, index, params) {
|
|
333
|
-
// End of path
|
|
334
|
-
if (index === segments.length) {
|
|
335
|
-
if (node.route) {
|
|
336
|
-
return { route: node.route, params };
|
|
337
|
-
}
|
|
338
|
-
return null;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const segment = segments[index];
|
|
342
|
-
|
|
343
|
-
// Try static match first (most specific)
|
|
344
|
-
if (node.children.has(segment)) {
|
|
345
|
-
const result = this._findRecursive(node.children.get(segment), segments, index + 1, params);
|
|
346
|
-
if (result) return result;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// Try dynamic param match
|
|
350
|
-
if (node.children.has(':')) {
|
|
351
|
-
const paramNode = node.children.get(':');
|
|
352
|
-
const newParams = { ...params, [paramNode.paramName]: decodeURIComponent(segment) };
|
|
353
|
-
const result = this._findRecursive(paramNode, segments, index + 1, newParams);
|
|
354
|
-
if (result) return result;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// Try wildcard match (catches all remaining segments)
|
|
358
|
-
if (node.children.has('*')) {
|
|
359
|
-
const wildcardNode = node.children.get('*');
|
|
360
|
-
const remaining = segments.slice(index).map(decodeURIComponent).join('/');
|
|
361
|
-
return {
|
|
362
|
-
route: wildcardNode.route,
|
|
363
|
-
params: { ...params, [wildcardNode.paramName]: remaining }
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
return null;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Parse a route pattern into a regex and extract param names
|
|
373
|
-
* Supports: /users/:id, /posts/:id/comments, /files/*path, * (catch-all)
|
|
374
|
-
*/
|
|
375
|
-
function parsePattern(pattern) {
|
|
376
|
-
const paramNames = [];
|
|
377
|
-
|
|
378
|
-
// Handle standalone * as catch-all
|
|
379
|
-
if (pattern === '*') {
|
|
380
|
-
return {
|
|
381
|
-
regex: /^.*$/,
|
|
382
|
-
paramNames: []
|
|
383
|
-
};
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
let regexStr = pattern
|
|
387
|
-
// Escape special regex chars except : and *
|
|
388
|
-
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
389
|
-
// Handle wildcard params (*name)
|
|
390
|
-
.replace(/\*([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
|
|
391
|
-
paramNames.push(name);
|
|
392
|
-
return '(.*)';
|
|
393
|
-
})
|
|
394
|
-
// Handle named params (:name)
|
|
395
|
-
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
|
|
396
|
-
paramNames.push(name);
|
|
397
|
-
return '([^/]+)';
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
// Ensure exact match
|
|
401
|
-
regexStr = `^${regexStr}$`;
|
|
402
|
-
|
|
403
|
-
return {
|
|
404
|
-
regex: new RegExp(regexStr),
|
|
405
|
-
paramNames
|
|
406
|
-
};
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* Normalize route configuration
|
|
411
|
-
* Supports both simple (handler function) and full (object with meta) definitions
|
|
412
|
-
*/
|
|
413
|
-
function normalizeRoute(pattern, config) {
|
|
414
|
-
// Simple format: pattern -> handler
|
|
415
|
-
if (typeof config === 'function') {
|
|
416
|
-
return {
|
|
417
|
-
pattern,
|
|
418
|
-
handler: config,
|
|
419
|
-
meta: {},
|
|
420
|
-
beforeEnter: null,
|
|
421
|
-
children: null
|
|
422
|
-
};
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// Full format: pattern -> { handler, meta, beforeEnter, children, alias, layout, group }
|
|
426
|
-
return {
|
|
427
|
-
pattern,
|
|
428
|
-
handler: config.handler || config.component,
|
|
429
|
-
meta: config.meta || {},
|
|
430
|
-
beforeEnter: config.beforeEnter || null,
|
|
431
|
-
children: config.children || null,
|
|
432
|
-
redirect: config.redirect || null,
|
|
433
|
-
alias: config.alias || null,
|
|
434
|
-
layout: config.layout || null,
|
|
435
|
-
group: config.group || false
|
|
436
|
-
};
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* Build a query string from an object, supporting arrays and skipping null/undefined
|
|
441
|
-
*
|
|
442
|
-
* @param {Object} query - Query parameters object
|
|
443
|
-
* @returns {string} Encoded query string (without leading ?)
|
|
444
|
-
*
|
|
445
|
-
* @example
|
|
446
|
-
* buildQueryString({ q: 'hello world', tags: ['a', 'b'] })
|
|
447
|
-
* // 'q=hello+world&tags=a&tags=b'
|
|
448
|
-
*
|
|
449
|
-
* buildQueryString({ a: 'x', b: null, c: undefined })
|
|
450
|
-
* // 'a=x'
|
|
451
|
-
*/
|
|
452
|
-
function buildQueryString(query) {
|
|
453
|
-
if (!query || typeof query !== 'object') return '';
|
|
454
|
-
|
|
455
|
-
const params = new URLSearchParams();
|
|
456
|
-
|
|
457
|
-
for (const [key, value] of Object.entries(query)) {
|
|
458
|
-
// Skip null and undefined values
|
|
459
|
-
if (value === null || value === undefined) continue;
|
|
460
|
-
|
|
461
|
-
if (Array.isArray(value)) {
|
|
462
|
-
// Array values: ?tags=a&tags=b
|
|
463
|
-
for (const item of value) {
|
|
464
|
-
if (item !== null && item !== undefined) {
|
|
465
|
-
params.append(key, String(item));
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
} else {
|
|
469
|
-
params.append(key, String(value));
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
return params.toString();
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* Match a path against a route pattern
|
|
478
|
-
*/
|
|
479
|
-
function matchRoute(pattern, path) {
|
|
480
|
-
const { regex, paramNames } = parsePattern(pattern);
|
|
481
|
-
const match = path.match(regex);
|
|
482
|
-
|
|
483
|
-
if (!match) return null;
|
|
484
|
-
|
|
485
|
-
const params = {};
|
|
486
|
-
paramNames.forEach((name, i) => {
|
|
487
|
-
params[name] = decodeURIComponent(match[i + 1]);
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
return params;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
// Query string validation limits
|
|
494
|
-
const QUERY_LIMITS = {
|
|
495
|
-
maxTotalLength: 2048, // 2KB max for entire query string
|
|
496
|
-
maxValueLength: 1024, // 1KB max per individual value
|
|
497
|
-
maxParams: 50 // Maximum number of query parameters
|
|
498
|
-
};
|
|
499
|
-
|
|
500
|
-
/**
|
|
501
|
-
* Parse a single query value into its typed representation
|
|
502
|
-
* Only converts when parseQueryTypes is enabled
|
|
503
|
-
*
|
|
504
|
-
* @param {string} value - Raw string value
|
|
505
|
-
* @returns {string|number|boolean} Typed value
|
|
506
|
-
*/
|
|
507
|
-
function parseTypedValue(value) {
|
|
508
|
-
// Boolean detection
|
|
509
|
-
if (value === 'true') return true;
|
|
510
|
-
if (value === 'false') return false;
|
|
511
|
-
|
|
512
|
-
// Number detection (strict: only numeric strings, not hex/octal/empty)
|
|
513
|
-
if (value !== '' && !isNaN(value) && !isNaN(parseFloat(value))) {
|
|
514
|
-
const num = Number(value);
|
|
515
|
-
if (isFinite(num)) return num;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
return value;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
/**
|
|
522
|
-
* Parse query string into object with validation
|
|
523
|
-
*
|
|
524
|
-
* SECURITY: Enforces hard limits BEFORE parsing to prevent DoS attacks.
|
|
525
|
-
* - Max total length: 2KB
|
|
526
|
-
* - Max value length: 1KB
|
|
527
|
-
* - Max parameters: 50
|
|
528
|
-
*
|
|
529
|
-
* @param {string} search - Query string (with or without leading ?)
|
|
530
|
-
* @param {Object} [options] - Parsing options
|
|
531
|
-
* @param {boolean} [options.typed=false] - Parse numbers and booleans from string values
|
|
532
|
-
* @returns {Object} Parsed query parameters
|
|
533
|
-
*/
|
|
534
|
-
function parseQuery(search, options = {}) {
|
|
535
|
-
if (!search) return {};
|
|
536
|
-
|
|
537
|
-
const { typed = false } = options;
|
|
538
|
-
|
|
539
|
-
// Remove leading ? if present
|
|
540
|
-
let queryStr = search.startsWith('?') ? search.slice(1) : search;
|
|
541
|
-
|
|
542
|
-
// SECURITY: Enforce hard limit BEFORE parsing to prevent DoS
|
|
543
|
-
if (queryStr.length > QUERY_LIMITS.maxTotalLength) {
|
|
544
|
-
log.warn(`Query string exceeds maximum length (${QUERY_LIMITS.maxTotalLength} chars). Truncating.`);
|
|
545
|
-
queryStr = queryStr.slice(0, QUERY_LIMITS.maxTotalLength);
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
const params = new URLSearchParams(queryStr);
|
|
549
|
-
const query = {};
|
|
550
|
-
let paramCount = 0;
|
|
551
|
-
|
|
552
|
-
for (const [key, value] of params) {
|
|
553
|
-
// Check parameter count limit
|
|
554
|
-
if (paramCount >= QUERY_LIMITS.maxParams) {
|
|
555
|
-
log.warn(`Query string exceeds maximum parameters (${QUERY_LIMITS.maxParams}). Ignoring excess.`);
|
|
556
|
-
break;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// Validate and potentially truncate value length
|
|
560
|
-
let safeValue = value;
|
|
561
|
-
if (value.length > QUERY_LIMITS.maxValueLength) {
|
|
562
|
-
log.warn(`Query parameter "${key}" exceeds maximum length. Truncating.`);
|
|
563
|
-
safeValue = value.slice(0, QUERY_LIMITS.maxValueLength);
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
// Apply typed parsing if enabled
|
|
567
|
-
if (typed) {
|
|
568
|
-
safeValue = parseTypedValue(safeValue);
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
if (key in query) {
|
|
572
|
-
// Multiple values for same key
|
|
573
|
-
if (Array.isArray(query[key])) {
|
|
574
|
-
query[key].push(safeValue);
|
|
575
|
-
} else {
|
|
576
|
-
query[key] = [query[key], safeValue];
|
|
577
|
-
}
|
|
578
|
-
} else {
|
|
579
|
-
query[key] = safeValue;
|
|
580
|
-
}
|
|
581
|
-
paramCount++;
|
|
582
|
-
}
|
|
583
|
-
return query;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
// Issue #66: Active router instance for standalone lifecycle exports
|
|
587
|
-
let _activeRouter = null;
|
|
588
|
-
|
|
589
|
-
/**
|
|
590
|
-
* Create a router instance
|
|
591
|
-
*/
|
|
592
|
-
export function createRouter(options = {}) {
|
|
593
|
-
const {
|
|
594
|
-
routes = {},
|
|
595
|
-
mode = 'history', // 'history' or 'hash'
|
|
596
|
-
base = '',
|
|
597
|
-
scrollBehavior = null, // Function to control scroll restoration
|
|
598
|
-
middleware: initialMiddleware = [], // Middleware functions
|
|
599
|
-
persistScroll = false, // Persist scroll positions to sessionStorage
|
|
600
|
-
persistScrollKey = 'pulse-router-scroll', // Storage key for scroll persistence
|
|
601
|
-
parseQueryTypes = false, // Parse typed query params (numbers, booleans)
|
|
602
|
-
transition = null // CSS transition config { enterClass, enterActiveClass, leaveClass, leaveActiveClass, duration }
|
|
603
|
-
} = options;
|
|
604
|
-
|
|
605
|
-
// Validate transition duration to prevent DoS (max 10s)
|
|
606
|
-
const transitionConfig = transition ? {
|
|
607
|
-
enterClass: transition.enterClass || 'route-enter',
|
|
608
|
-
enterActiveClass: transition.enterActiveClass || 'route-enter-active',
|
|
609
|
-
leaveClass: transition.leaveClass || 'route-leave',
|
|
610
|
-
leaveActiveClass: transition.leaveActiveClass || 'route-leave-active',
|
|
611
|
-
duration: Math.min(Math.max(transition.duration || 300, 0), 10000)
|
|
612
|
-
} : null;
|
|
613
|
-
|
|
614
|
-
// Middleware array (mutable for dynamic registration)
|
|
615
|
-
const middleware = [...initialMiddleware];
|
|
616
|
-
|
|
617
|
-
// Reactive state
|
|
618
|
-
const currentPath = pulse(getPath());
|
|
619
|
-
const currentRoute = pulse(null);
|
|
620
|
-
const currentParams = pulse({});
|
|
621
|
-
const currentQuery = pulse({});
|
|
622
|
-
const currentMeta = pulse({});
|
|
623
|
-
const isLoading = pulse(false);
|
|
624
|
-
const routeError = pulse(null);
|
|
625
|
-
|
|
626
|
-
// Route error handler (configurable)
|
|
627
|
-
let onRouteError = options.onRouteError || null;
|
|
628
|
-
|
|
629
|
-
// Scroll positions for history (LRU cache to prevent memory leaks)
|
|
630
|
-
// Keeps last 100 scroll positions - enough for typical navigation patterns
|
|
631
|
-
const scrollPositions = new LRUCache(100);
|
|
632
|
-
|
|
633
|
-
// Restore scroll positions from sessionStorage if persistence is enabled
|
|
634
|
-
if (persistScroll && typeof sessionStorage !== 'undefined') {
|
|
635
|
-
try {
|
|
636
|
-
const stored = sessionStorage.getItem(persistScrollKey);
|
|
637
|
-
if (stored) {
|
|
638
|
-
const parsed = JSON.parse(stored);
|
|
639
|
-
// Restore up to 100 most recent positions
|
|
640
|
-
const entries = Object.entries(parsed).slice(-100);
|
|
641
|
-
for (const [path, pos] of entries) {
|
|
642
|
-
if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
|
|
643
|
-
scrollPositions.set(path, pos);
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
log.debug(`Restored ${entries.length} scroll positions from sessionStorage`);
|
|
647
|
-
}
|
|
648
|
-
} catch (err) {
|
|
649
|
-
log.warn('Failed to restore scroll positions from sessionStorage:', err.message);
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
/**
|
|
654
|
-
* Persist scroll positions to sessionStorage
|
|
655
|
-
*/
|
|
656
|
-
function persistScrollPositions() {
|
|
657
|
-
if (!persistScroll || typeof sessionStorage === 'undefined') return;
|
|
658
|
-
|
|
659
|
-
try {
|
|
660
|
-
const data = {};
|
|
661
|
-
for (const [path, pos] of scrollPositions.entries()) {
|
|
662
|
-
data[path] = pos;
|
|
663
|
-
}
|
|
664
|
-
sessionStorage.setItem(persistScrollKey, JSON.stringify(data));
|
|
665
|
-
} catch (err) {
|
|
666
|
-
// SessionStorage may be full or disabled
|
|
667
|
-
log.warn('Failed to persist scroll positions:', err.message);
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
// Route trie for O(path length) lookups
|
|
672
|
-
const routeTrie = new RouteTrie();
|
|
673
|
-
|
|
674
|
-
// Compile routes (supports nested routes)
|
|
675
|
-
const compiledRoutes = [];
|
|
676
|
-
|
|
677
|
-
function compileRoutes(routeConfig, parentPath = '', parentLayout = null) {
|
|
678
|
-
for (const [pattern, config] of Object.entries(routeConfig)) {
|
|
679
|
-
const normalized = normalizeRoute(pattern, config);
|
|
680
|
-
|
|
681
|
-
// Issue #71: Route groups — key starting with _ and group: true
|
|
682
|
-
// Group children get NO URL prefix from the group key
|
|
683
|
-
if (normalized.group && normalized.children) {
|
|
684
|
-
const groupLayout = normalized.layout || parentLayout;
|
|
685
|
-
compileRoutes(normalized.children, parentPath, groupLayout);
|
|
686
|
-
continue;
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
const fullPattern = parentPath + pattern;
|
|
690
|
-
|
|
691
|
-
// Inherit layout from parent group if not specified
|
|
692
|
-
const routeLayout = normalized.layout || parentLayout;
|
|
693
|
-
|
|
694
|
-
const route = {
|
|
695
|
-
...normalized,
|
|
696
|
-
pattern: fullPattern,
|
|
697
|
-
layout: routeLayout,
|
|
698
|
-
...parsePattern(fullPattern)
|
|
699
|
-
};
|
|
700
|
-
|
|
701
|
-
compiledRoutes.push(route);
|
|
702
|
-
|
|
703
|
-
// Insert into trie for fast lookup
|
|
704
|
-
routeTrie.insert(fullPattern, route);
|
|
705
|
-
|
|
706
|
-
// Issue #68: Route aliases — the alias route is already in the trie at its own path
|
|
707
|
-
// (e.g., '/fake' with alias: '/real'). The navigate() function resolves
|
|
708
|
-
// aliases by following the alias chain to find the target handler.
|
|
709
|
-
|
|
710
|
-
// Compile children (nested routes)
|
|
711
|
-
if (normalized.children) {
|
|
712
|
-
compileRoutes(normalized.children, fullPattern, routeLayout);
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
compileRoutes(routes);
|
|
718
|
-
|
|
719
|
-
// Hooks
|
|
720
|
-
const beforeHooks = [];
|
|
721
|
-
const resolveHooks = [];
|
|
722
|
-
const afterHooks = [];
|
|
723
|
-
|
|
724
|
-
// Issue #66: Route lifecycle hooks (per-route, registered by components)
|
|
725
|
-
const beforeLeaveHooks = new Map(); // path → [callbacks]
|
|
726
|
-
const afterEnterHooks = new Map(); // path → [callbacks]
|
|
727
|
-
|
|
728
|
-
// Issue #72: Loading change listeners
|
|
729
|
-
const loadingListeners = [];
|
|
730
|
-
|
|
731
|
-
/**
|
|
732
|
-
* Get current path based on mode
|
|
733
|
-
*/
|
|
734
|
-
function getPath() {
|
|
735
|
-
if (mode === 'hash') {
|
|
736
|
-
return window.location.hash.slice(1) || '/';
|
|
737
|
-
}
|
|
738
|
-
let path = window.location.pathname;
|
|
739
|
-
if (base && path.startsWith(base)) {
|
|
740
|
-
path = path.slice(base.length) || '/';
|
|
741
|
-
}
|
|
742
|
-
return path;
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
/**
|
|
746
|
-
* Find matching route using trie for O(path length) lookup
|
|
747
|
-
*/
|
|
748
|
-
function findRoute(path) {
|
|
749
|
-
// Use trie for efficient lookup
|
|
750
|
-
const result = routeTrie.find(path);
|
|
751
|
-
if (result) {
|
|
752
|
-
return result;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
// Fallback to catch-all route if exists
|
|
756
|
-
for (const route of compiledRoutes) {
|
|
757
|
-
if (route.pattern === '*') {
|
|
758
|
-
return { route, params: {} };
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
return null;
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
/**
|
|
766
|
-
* Navigate to a path
|
|
767
|
-
*/
|
|
768
|
-
async function navigate(path, options = {}) {
|
|
769
|
-
const { replace = false, query = {}, state = null } = options;
|
|
770
|
-
|
|
771
|
-
// Issue #72: Set loading state at start of navigation
|
|
772
|
-
const hasAsyncWork = middleware.length > 0 || beforeHooks.length > 0 || resolveHooks.length > 0;
|
|
773
|
-
if (hasAsyncWork) {
|
|
774
|
-
isLoading.set(true);
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
try {
|
|
778
|
-
// Find matching route first (needed for beforeEnter guard)
|
|
779
|
-
let match = findRoute(path);
|
|
780
|
-
|
|
781
|
-
// Issue #68: Resolve alias — follow alias chain (with loop protection)
|
|
782
|
-
const visited = new Set();
|
|
783
|
-
while (match?.route?.alias && !visited.has(match.route.pattern)) {
|
|
784
|
-
visited.add(match.route.pattern);
|
|
785
|
-
const aliasTarget = match.route.alias;
|
|
786
|
-
const aliasMatch = findRoute(aliasTarget);
|
|
787
|
-
if (aliasMatch) {
|
|
788
|
-
match = aliasMatch;
|
|
789
|
-
} else {
|
|
790
|
-
break;
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
// Issue #70: Build full path with query using buildQueryString (array + null support)
|
|
795
|
-
let fullPath = path;
|
|
796
|
-
const queryString = buildQueryString(query);
|
|
797
|
-
if (queryString) {
|
|
798
|
-
fullPath += '?' + queryString;
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
// Handle redirect
|
|
802
|
-
if (match?.route?.redirect) {
|
|
803
|
-
const redirectPath = typeof match.route.redirect === 'function'
|
|
804
|
-
? match.route.redirect({ params: match.params, query })
|
|
805
|
-
: match.route.redirect;
|
|
806
|
-
return navigate(redirectPath, { replace: true });
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
// Create navigation context for guards
|
|
810
|
-
const from = {
|
|
811
|
-
path: currentPath.peek(),
|
|
812
|
-
params: currentParams.peek(),
|
|
813
|
-
query: currentQuery.peek(),
|
|
814
|
-
meta: currentMeta.peek()
|
|
815
|
-
};
|
|
816
|
-
|
|
817
|
-
// Issue #70: Parse query with typed option
|
|
818
|
-
const parsedQuery = parseQuery(queryString, { typed: parseQueryTypes });
|
|
819
|
-
|
|
820
|
-
const to = {
|
|
821
|
-
path,
|
|
822
|
-
params: match?.params || {},
|
|
823
|
-
query: parsedQuery,
|
|
824
|
-
meta: match?.route?.meta || {}
|
|
825
|
-
};
|
|
826
|
-
|
|
827
|
-
// Issue #66: Run beforeLeave hooks for the current route
|
|
828
|
-
const leavePath = currentPath.peek();
|
|
829
|
-
const leaveCallbacks = beforeLeaveHooks.get(leavePath);
|
|
830
|
-
if (leaveCallbacks && leaveCallbacks.length > 0) {
|
|
831
|
-
for (const cb of [...leaveCallbacks]) {
|
|
832
|
-
const result = await cb(to, from);
|
|
833
|
-
if (result === false) return false;
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
// Run middleware if configured
|
|
838
|
-
if (middleware.length > 0) {
|
|
839
|
-
const runMiddleware = createMiddlewareRunner(middleware);
|
|
840
|
-
const middlewareResult = await runMiddleware({ to, from });
|
|
841
|
-
if (middlewareResult.aborted) {
|
|
842
|
-
return false;
|
|
843
|
-
}
|
|
844
|
-
if (middlewareResult.redirectPath) {
|
|
845
|
-
return navigate(middlewareResult.redirectPath, { replace: true });
|
|
846
|
-
}
|
|
847
|
-
// Merge middleware meta into route meta
|
|
848
|
-
Object.assign(to.meta, middlewareResult.meta);
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
// Run global beforeEach hooks
|
|
852
|
-
for (const hook of beforeHooks) {
|
|
853
|
-
const result = await hook(to, from);
|
|
854
|
-
if (result === false) return false;
|
|
855
|
-
if (typeof result === 'string') {
|
|
856
|
-
return navigate(result, options);
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
// Run per-route beforeEnter guard
|
|
861
|
-
if (match?.route?.beforeEnter) {
|
|
862
|
-
const result = await match.route.beforeEnter(to, from);
|
|
863
|
-
if (result === false) return false;
|
|
864
|
-
if (typeof result === 'string') {
|
|
865
|
-
return navigate(result, options);
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
// Run beforeResolve hooks (after per-route guards)
|
|
870
|
-
for (const hook of resolveHooks) {
|
|
871
|
-
const result = await hook(to, from);
|
|
872
|
-
if (result === false) return false;
|
|
873
|
-
if (typeof result === 'string') {
|
|
874
|
-
return navigate(result, options);
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
// Save scroll position before leaving
|
|
879
|
-
const currentFullPath = currentPath.peek();
|
|
880
|
-
if (currentFullPath) {
|
|
881
|
-
scrollPositions.set(currentFullPath, {
|
|
882
|
-
x: window.scrollX,
|
|
883
|
-
y: window.scrollY
|
|
884
|
-
});
|
|
885
|
-
persistScrollPositions();
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
// Update URL
|
|
889
|
-
const url = mode === 'hash' ? `#${fullPath}` : `${base}${fullPath}`;
|
|
890
|
-
const historyState = { path: fullPath, ...(state || {}) };
|
|
891
|
-
|
|
892
|
-
if (replace) {
|
|
893
|
-
window.history.replaceState(historyState, '', url);
|
|
894
|
-
} else {
|
|
895
|
-
window.history.pushState(historyState, '', url);
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
// Update reactive state
|
|
899
|
-
await updateRoute(path, parsedQuery, match);
|
|
900
|
-
|
|
901
|
-
// Handle scroll behavior
|
|
902
|
-
handleScroll(to, from, scrollPositions.get(path));
|
|
903
|
-
|
|
904
|
-
// Issue #66: Run afterEnter hooks for the new route
|
|
905
|
-
const enterCallbacks = afterEnterHooks.get(path);
|
|
906
|
-
if (enterCallbacks && enterCallbacks.length > 0) {
|
|
907
|
-
for (const cb of [...enterCallbacks]) {
|
|
908
|
-
cb(to);
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
return true;
|
|
913
|
-
} finally {
|
|
914
|
-
// Issue #72: Always reset loading state
|
|
915
|
-
isLoading.set(false);
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
/**
|
|
920
|
-
* Handle scroll behavior after navigation
|
|
921
|
-
*/
|
|
922
|
-
function handleScroll(to, from, savedPosition) {
|
|
923
|
-
if (scrollBehavior) {
|
|
924
|
-
let position;
|
|
925
|
-
try {
|
|
926
|
-
position = scrollBehavior(to, from, savedPosition);
|
|
927
|
-
} catch (err) {
|
|
928
|
-
loggers.router.warn(`scrollBehavior threw an error: ${err.message}`);
|
|
929
|
-
// Fall back to default behavior
|
|
930
|
-
window.scrollTo(0, 0);
|
|
931
|
-
return;
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
// Validate position is a valid object
|
|
935
|
-
if (position && typeof position === 'object') {
|
|
936
|
-
if (typeof position.selector === 'string' && position.selector) {
|
|
937
|
-
// Scroll to element
|
|
938
|
-
try {
|
|
939
|
-
const el = document.querySelector(position.selector);
|
|
940
|
-
if (el) {
|
|
941
|
-
const behavior = position.behavior === 'smooth' || position.behavior === 'auto'
|
|
942
|
-
? position.behavior
|
|
943
|
-
: 'auto';
|
|
944
|
-
el.scrollIntoView({ behavior });
|
|
945
|
-
}
|
|
946
|
-
} catch (err) {
|
|
947
|
-
loggers.router.warn(`Invalid selector in scrollBehavior: ${position.selector}`);
|
|
948
|
-
}
|
|
949
|
-
} else if (typeof position.x === 'number' || typeof position.y === 'number') {
|
|
950
|
-
const x = typeof position.x === 'number' && isFinite(position.x) ? position.x : 0;
|
|
951
|
-
const y = typeof position.y === 'number' && isFinite(position.y) ? position.y : 0;
|
|
952
|
-
const behavior = position.behavior === 'smooth' || position.behavior === 'auto'
|
|
953
|
-
? position.behavior
|
|
954
|
-
: 'auto';
|
|
955
|
-
window.scrollTo({ left: x, top: y, behavior });
|
|
956
|
-
}
|
|
957
|
-
// If position is object but no valid selector/x/y, do nothing (intentional no-scroll)
|
|
958
|
-
}
|
|
959
|
-
// If position is falsy (null/undefined/false), do nothing (intentional no-scroll)
|
|
960
|
-
} else if (savedPosition) {
|
|
961
|
-
// Default: restore saved position
|
|
962
|
-
const x = typeof savedPosition.x === 'number' && isFinite(savedPosition.x) ? savedPosition.x : 0;
|
|
963
|
-
const y = typeof savedPosition.y === 'number' && isFinite(savedPosition.y) ? savedPosition.y : 0;
|
|
964
|
-
window.scrollTo(x, y);
|
|
965
|
-
} else {
|
|
966
|
-
// Default: scroll to top
|
|
967
|
-
window.scrollTo(0, 0);
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
/**
|
|
972
|
-
* Update the current route state
|
|
973
|
-
*/
|
|
974
|
-
async function updateRoute(path, query = {}, match = null) {
|
|
975
|
-
if (!match) {
|
|
976
|
-
match = findRoute(path);
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
batch(() => {
|
|
980
|
-
currentPath.set(path);
|
|
981
|
-
currentQuery.set(query);
|
|
982
|
-
|
|
983
|
-
if (match) {
|
|
984
|
-
currentRoute.set(match.route);
|
|
985
|
-
currentParams.set(match.params);
|
|
986
|
-
currentMeta.set(match.route.meta || {});
|
|
987
|
-
} else {
|
|
988
|
-
currentRoute.set(null);
|
|
989
|
-
currentParams.set({});
|
|
990
|
-
currentMeta.set({});
|
|
991
|
-
}
|
|
992
|
-
});
|
|
993
|
-
|
|
994
|
-
// Run after hooks with full context
|
|
995
|
-
const to = {
|
|
996
|
-
path,
|
|
997
|
-
params: match?.params || {},
|
|
998
|
-
query,
|
|
999
|
-
meta: match?.route?.meta || {}
|
|
1000
|
-
};
|
|
1001
|
-
|
|
1002
|
-
for (const hook of afterHooks) {
|
|
1003
|
-
await hook(to);
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
/**
|
|
1008
|
-
* Handle browser navigation (back/forward)
|
|
1009
|
-
*/
|
|
1010
|
-
function handlePopState() {
|
|
1011
|
-
const path = getPath();
|
|
1012
|
-
const query = parseQuery(window.location.search);
|
|
1013
|
-
updateRoute(path, query);
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
/**
|
|
1017
|
-
* Start listening to navigation events
|
|
1018
|
-
*/
|
|
1019
|
-
function start() {
|
|
1020
|
-
window.addEventListener('popstate', handlePopState);
|
|
1021
|
-
|
|
1022
|
-
// Initial route
|
|
1023
|
-
const query = parseQuery(window.location.search);
|
|
1024
|
-
updateRoute(getPath(), query);
|
|
1025
|
-
|
|
1026
|
-
return () => {
|
|
1027
|
-
window.removeEventListener('popstate', handlePopState);
|
|
1028
|
-
};
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
/**
|
|
1032
|
-
* Create a link element that uses the router
|
|
1033
|
-
*/
|
|
1034
|
-
function link(path, content, options = {}) {
|
|
1035
|
-
const href = mode === 'hash' ? `#${path}` : `${base}${path}`;
|
|
1036
|
-
const a = el('a', content);
|
|
1037
|
-
a.href = href;
|
|
1038
|
-
|
|
1039
|
-
const handleClick = (e) => {
|
|
1040
|
-
// Allow ctrl/cmd+click for new tab
|
|
1041
|
-
if (e.ctrlKey || e.metaKey) return;
|
|
1042
|
-
|
|
1043
|
-
e.preventDefault();
|
|
1044
|
-
navigate(path, options);
|
|
1045
|
-
};
|
|
1046
|
-
|
|
1047
|
-
a.addEventListener('click', handleClick);
|
|
1048
|
-
|
|
1049
|
-
// Add active class when route matches
|
|
1050
|
-
const disposeEffect = effect(() => {
|
|
1051
|
-
const current = currentPath.get();
|
|
1052
|
-
if (current === path || (options.exact === false && current.startsWith(path))) {
|
|
1053
|
-
a.classList.add(options.activeClass || 'active');
|
|
1054
|
-
} else {
|
|
1055
|
-
a.classList.remove(options.activeClass || 'active');
|
|
1056
|
-
}
|
|
1057
|
-
});
|
|
1058
|
-
|
|
1059
|
-
a.cleanup = () => {
|
|
1060
|
-
a.removeEventListener('click', handleClick);
|
|
1061
|
-
disposeEffect();
|
|
1062
|
-
};
|
|
1063
|
-
|
|
1064
|
-
return a;
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
/**
|
|
1068
|
-
* Router outlet - renders the current route's component
|
|
1069
|
-
*
|
|
1070
|
-
* MEMORY SAFETY: Aborts any pending lazy loads when navigating away
|
|
1071
|
-
* to prevent stale callbacks from updating the DOM.
|
|
1072
|
-
*
|
|
1073
|
-
* Supports:
|
|
1074
|
-
* - Route groups with shared layouts (#71)
|
|
1075
|
-
* - CSS route transitions (#66)
|
|
1076
|
-
*/
|
|
1077
|
-
function outlet(container) {
|
|
1078
|
-
if (typeof container === 'string') {
|
|
1079
|
-
container = document.querySelector(container);
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
let currentView = null;
|
|
1083
|
-
let cleanup = null;
|
|
1084
|
-
|
|
1085
|
-
/**
|
|
1086
|
-
* Remove old view, optionally with CSS transition
|
|
1087
|
-
*/
|
|
1088
|
-
function removeOldView(oldView, onDone) {
|
|
1089
|
-
if (!oldView) {
|
|
1090
|
-
onDone();
|
|
1091
|
-
return;
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
// Abort any pending lazy loads before removing the view
|
|
1095
|
-
if (oldView._pulseAbortLazyLoad) {
|
|
1096
|
-
oldView._pulseAbortLazyLoad();
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
// Issue #66: CSS transition on leave
|
|
1100
|
-
if (transitionConfig && oldView.classList) {
|
|
1101
|
-
oldView.classList.add(transitionConfig.leaveClass);
|
|
1102
|
-
requestAnimationFrame(() => {
|
|
1103
|
-
oldView.classList.add(transitionConfig.leaveActiveClass);
|
|
1104
|
-
});
|
|
1105
|
-
setTimeout(() => {
|
|
1106
|
-
oldView.classList.remove(transitionConfig.leaveClass, transitionConfig.leaveActiveClass);
|
|
1107
|
-
container.replaceChildren();
|
|
1108
|
-
onDone();
|
|
1109
|
-
}, transitionConfig.duration);
|
|
1110
|
-
} else {
|
|
1111
|
-
container.replaceChildren();
|
|
1112
|
-
onDone();
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
/**
|
|
1117
|
-
* Add new view, optionally with CSS transition
|
|
1118
|
-
*/
|
|
1119
|
-
function addNewView(view) {
|
|
1120
|
-
container.appendChild(view);
|
|
1121
|
-
currentView = view;
|
|
1122
|
-
|
|
1123
|
-
// Issue #66: CSS transition on enter
|
|
1124
|
-
if (transitionConfig && view.classList) {
|
|
1125
|
-
view.classList.add(transitionConfig.enterClass);
|
|
1126
|
-
requestAnimationFrame(() => {
|
|
1127
|
-
view.classList.add(transitionConfig.enterActiveClass);
|
|
1128
|
-
setTimeout(() => {
|
|
1129
|
-
view.classList.remove(transitionConfig.enterClass, transitionConfig.enterActiveClass);
|
|
1130
|
-
}, transitionConfig.duration);
|
|
1131
|
-
});
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
effect(() => {
|
|
1136
|
-
const route = currentRoute.get();
|
|
1137
|
-
const params = currentParams.get();
|
|
1138
|
-
const query = currentQuery.get();
|
|
1139
|
-
|
|
1140
|
-
// Cleanup previous view
|
|
1141
|
-
if (cleanup) cleanup();
|
|
1142
|
-
|
|
1143
|
-
const oldView = currentView;
|
|
1144
|
-
currentView = null;
|
|
1145
|
-
|
|
1146
|
-
function renderRoute() {
|
|
1147
|
-
if (route && route.handler) {
|
|
1148
|
-
// Create context for the route handler
|
|
1149
|
-
const ctx = {
|
|
1150
|
-
params,
|
|
1151
|
-
query,
|
|
1152
|
-
path: currentPath.peek(),
|
|
1153
|
-
navigate,
|
|
1154
|
-
router
|
|
1155
|
-
};
|
|
1156
|
-
|
|
1157
|
-
// Helper to handle errors
|
|
1158
|
-
const handleError = (error) => {
|
|
1159
|
-
routeError.set(error);
|
|
1160
|
-
log.error('Route component error:', error);
|
|
1161
|
-
|
|
1162
|
-
if (onRouteError) {
|
|
1163
|
-
try {
|
|
1164
|
-
const errorView = onRouteError(error, ctx);
|
|
1165
|
-
if (errorView instanceof Node) {
|
|
1166
|
-
addNewView(errorView);
|
|
1167
|
-
return true;
|
|
1168
|
-
}
|
|
1169
|
-
} catch (handlerError) {
|
|
1170
|
-
log.error('Route error handler threw:', handlerError);
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
const errorEl = el('div.route-error', [
|
|
1175
|
-
el('h2', 'Route Error'),
|
|
1176
|
-
el('p', error.message || 'Failed to load route component')
|
|
1177
|
-
]);
|
|
1178
|
-
addNewView(errorEl);
|
|
1179
|
-
return true;
|
|
1180
|
-
};
|
|
1181
|
-
|
|
1182
|
-
// Call handler and render result (with error handling)
|
|
1183
|
-
let result;
|
|
1184
|
-
try {
|
|
1185
|
-
result = typeof route.handler === 'function'
|
|
1186
|
-
? route.handler(ctx)
|
|
1187
|
-
: route.handler;
|
|
1188
|
-
} catch (error) {
|
|
1189
|
-
handleError(error);
|
|
1190
|
-
return;
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
if (result instanceof Node) {
|
|
1194
|
-
// Issue #71: Wrap with layout if route has one
|
|
1195
|
-
let view = result;
|
|
1196
|
-
if (route.layout && typeof route.layout === 'function') {
|
|
1197
|
-
try {
|
|
1198
|
-
const layoutResult = route.layout(() => result, ctx);
|
|
1199
|
-
if (layoutResult instanceof Node) {
|
|
1200
|
-
view = layoutResult;
|
|
1201
|
-
}
|
|
1202
|
-
} catch (error) {
|
|
1203
|
-
log.error('Layout error:', error);
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
addNewView(view);
|
|
1208
|
-
routeError.set(null);
|
|
1209
|
-
} else if (result && typeof result.then === 'function') {
|
|
1210
|
-
// Async component
|
|
1211
|
-
isLoading.set(true);
|
|
1212
|
-
routeError.set(null);
|
|
1213
|
-
result
|
|
1214
|
-
.then(component => {
|
|
1215
|
-
isLoading.set(false);
|
|
1216
|
-
let view = typeof component === 'function' ? component(ctx) : component;
|
|
1217
|
-
if (view instanceof Node) {
|
|
1218
|
-
// Issue #71: Wrap with layout
|
|
1219
|
-
if (route.layout && typeof route.layout === 'function') {
|
|
1220
|
-
try {
|
|
1221
|
-
const layoutResult = route.layout(() => view, ctx);
|
|
1222
|
-
if (layoutResult instanceof Node) {
|
|
1223
|
-
view = layoutResult;
|
|
1224
|
-
}
|
|
1225
|
-
} catch (error) {
|
|
1226
|
-
log.error('Layout error:', error);
|
|
1227
|
-
}
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
addNewView(view);
|
|
1231
|
-
}
|
|
1232
|
-
})
|
|
1233
|
-
.catch(error => {
|
|
1234
|
-
isLoading.set(false);
|
|
1235
|
-
handleError(error);
|
|
1236
|
-
});
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
// Remove old view (with optional transition), then render new route
|
|
1242
|
-
removeOldView(oldView, renderRoute);
|
|
1243
|
-
});
|
|
1244
|
-
|
|
1245
|
-
return container;
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
/**
|
|
1249
|
-
* Add middleware dynamically
|
|
1250
|
-
* @param {function} middlewareFn - Middleware function (ctx, next) => {}
|
|
1251
|
-
* @returns {function} Unregister function
|
|
1252
|
-
*/
|
|
1253
|
-
function use(middlewareFn) {
|
|
1254
|
-
middleware.push(middlewareFn);
|
|
1255
|
-
return () => {
|
|
1256
|
-
const index = middleware.indexOf(middlewareFn);
|
|
1257
|
-
if (index > -1) middleware.splice(index, 1);
|
|
1258
|
-
};
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
/**
|
|
1262
|
-
* Add navigation guard
|
|
1263
|
-
*/
|
|
1264
|
-
function beforeEach(hook) {
|
|
1265
|
-
beforeHooks.push(hook);
|
|
1266
|
-
return () => {
|
|
1267
|
-
const index = beforeHooks.indexOf(hook);
|
|
1268
|
-
if (index > -1) beforeHooks.splice(index, 1);
|
|
1269
|
-
};
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
|
-
/**
|
|
1273
|
-
* Add before resolve hook (runs after per-route guards)
|
|
1274
|
-
*/
|
|
1275
|
-
function beforeResolve(hook) {
|
|
1276
|
-
resolveHooks.push(hook);
|
|
1277
|
-
return () => {
|
|
1278
|
-
const index = resolveHooks.indexOf(hook);
|
|
1279
|
-
if (index > -1) resolveHooks.splice(index, 1);
|
|
1280
|
-
};
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
/**
|
|
1284
|
-
* Add after navigation hook
|
|
1285
|
-
*/
|
|
1286
|
-
function afterEach(hook) {
|
|
1287
|
-
afterHooks.push(hook);
|
|
1288
|
-
return () => {
|
|
1289
|
-
const index = afterHooks.indexOf(hook);
|
|
1290
|
-
if (index > -1) afterHooks.splice(index, 1);
|
|
1291
|
-
};
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
/**
|
|
1295
|
-
* Check if a route matches the given path
|
|
1296
|
-
*/
|
|
1297
|
-
/**
|
|
1298
|
-
* Check if a path matches the current route
|
|
1299
|
-
* @param {string} path - Path to check
|
|
1300
|
-
* @param {boolean} [exact=false] - If true, requires exact match; if false, matches prefixes
|
|
1301
|
-
* @returns {boolean} True if path is active
|
|
1302
|
-
* @example
|
|
1303
|
-
* // Current path: /users/123
|
|
1304
|
-
* router.isActive('/users'); // true (prefix match)
|
|
1305
|
-
* router.isActive('/users', true); // false (not exact)
|
|
1306
|
-
* router.isActive('/users/123', true); // true (exact match)
|
|
1307
|
-
*/
|
|
1308
|
-
function isActive(path, exact = false) {
|
|
1309
|
-
const current = currentPath.get();
|
|
1310
|
-
if (exact) {
|
|
1311
|
-
return current === path;
|
|
1312
|
-
}
|
|
1313
|
-
return current.startsWith(path);
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
/**
|
|
1317
|
-
* Get all routes that match a given path (useful for nested routes)
|
|
1318
|
-
* @param {string} path - Path to match against routes
|
|
1319
|
-
* @returns {Array<{route: Object, params: Object}>} Array of matched routes with extracted params
|
|
1320
|
-
* @example
|
|
1321
|
-
* const matches = router.getMatchedRoutes('/admin/users/123');
|
|
1322
|
-
* // Returns: [{route: adminRoute, params: {}}, {route: userRoute, params: {id: '123'}}]
|
|
1323
|
-
*/
|
|
1324
|
-
function getMatchedRoutes(path) {
|
|
1325
|
-
const matches = [];
|
|
1326
|
-
for (const route of compiledRoutes) {
|
|
1327
|
-
const params = matchRoute(route.pattern, path);
|
|
1328
|
-
if (params !== null) {
|
|
1329
|
-
matches.push({ route, params });
|
|
1330
|
-
}
|
|
1331
|
-
}
|
|
1332
|
-
return matches;
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
/**
|
|
1336
|
-
* Save current scroll and wait for popstate to fire
|
|
1337
|
-
* Used by back(), forward(), and go() to integrate with scroll restoration
|
|
1338
|
-
* @returns {Promise} Resolves after popstate fires or timeout
|
|
1339
|
-
*/
|
|
1340
|
-
function saveScrollAndWaitForPopState() {
|
|
1341
|
-
// Save current scroll position
|
|
1342
|
-
const currentFullPath = currentPath.peek();
|
|
1343
|
-
if (currentFullPath) {
|
|
1344
|
-
scrollPositions.set(currentFullPath, {
|
|
1345
|
-
x: window.scrollX,
|
|
1346
|
-
y: window.scrollY
|
|
1347
|
-
});
|
|
1348
|
-
persistScrollPositions();
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
// Return a Promise that resolves on the next popstate (with 100ms fallback)
|
|
1352
|
-
return new Promise(resolve => {
|
|
1353
|
-
let resolved = false;
|
|
1354
|
-
const done = () => {
|
|
1355
|
-
if (resolved) return;
|
|
1356
|
-
resolved = true;
|
|
1357
|
-
window.removeEventListener('popstate', listener);
|
|
1358
|
-
resolve();
|
|
1359
|
-
};
|
|
1360
|
-
const listener = () => done();
|
|
1361
|
-
window.addEventListener('popstate', listener);
|
|
1362
|
-
setTimeout(done, 100);
|
|
1363
|
-
});
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
/**
|
|
1367
|
-
* Navigate back in browser history
|
|
1368
|
-
* Saves scroll position before navigating
|
|
1369
|
-
* @returns {Promise} Resolves after navigation completes
|
|
1370
|
-
* @example
|
|
1371
|
-
* await router.back(); // Go to previous page
|
|
1372
|
-
*/
|
|
1373
|
-
function back() {
|
|
1374
|
-
const promise = saveScrollAndWaitForPopState();
|
|
1375
|
-
window.history.back();
|
|
1376
|
-
return promise;
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
/**
|
|
1380
|
-
* Navigate forward in browser history
|
|
1381
|
-
* Saves scroll position before navigating
|
|
1382
|
-
* @returns {Promise} Resolves after navigation completes
|
|
1383
|
-
* @example
|
|
1384
|
-
* await router.forward(); // Go to next page (if available)
|
|
1385
|
-
*/
|
|
1386
|
-
function forward() {
|
|
1387
|
-
const promise = saveScrollAndWaitForPopState();
|
|
1388
|
-
window.history.forward();
|
|
1389
|
-
return promise;
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
/**
|
|
1393
|
-
* Navigate to a specific position in browser history
|
|
1394
|
-
* Saves scroll position before navigating
|
|
1395
|
-
* @param {number} delta - Number of entries to move (negative = back, positive = forward)
|
|
1396
|
-
* @returns {Promise} Resolves after navigation completes
|
|
1397
|
-
* @example
|
|
1398
|
-
* await router.go(-2); // Go back 2 pages
|
|
1399
|
-
* await router.go(1); // Go forward 1 page
|
|
1400
|
-
*/
|
|
1401
|
-
function go(delta) {
|
|
1402
|
-
const promise = saveScrollAndWaitForPopState();
|
|
1403
|
-
window.history.go(delta);
|
|
1404
|
-
return promise;
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
/**
|
|
1408
|
-
* Set route error handler
|
|
1409
|
-
* @param {function} handler - Error handler (error, ctx) => Node
|
|
1410
|
-
* @returns {function} Previous handler
|
|
1411
|
-
*/
|
|
1412
|
-
function setErrorHandler(handler) {
|
|
1413
|
-
const prev = onRouteError;
|
|
1414
|
-
onRouteError = handler;
|
|
1415
|
-
return prev;
|
|
1416
|
-
}
|
|
1417
|
-
|
|
1418
|
-
/**
|
|
1419
|
-
* Issue #72: Subscribe to loading state changes
|
|
1420
|
-
* @param {function} callback - Called with (loading: boolean) when loading state changes
|
|
1421
|
-
* @returns {function} Unsubscribe function
|
|
1422
|
-
*/
|
|
1423
|
-
function onLoadingChange(callback) {
|
|
1424
|
-
const dispose = effect(() => {
|
|
1425
|
-
callback(isLoading.get());
|
|
1426
|
-
});
|
|
1427
|
-
loadingListeners.push(dispose);
|
|
1428
|
-
return () => {
|
|
1429
|
-
dispose();
|
|
1430
|
-
const idx = loadingListeners.indexOf(dispose);
|
|
1431
|
-
if (idx > -1) loadingListeners.splice(idx, 1);
|
|
1432
|
-
};
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
/**
|
|
1436
|
-
* Issue #66: Register a callback to run before leaving the current route
|
|
1437
|
-
* If callback returns false, navigation is blocked
|
|
1438
|
-
* @param {function} callback - (to, from) => boolean|void
|
|
1439
|
-
* @returns {function} Unsubscribe function
|
|
1440
|
-
*/
|
|
1441
|
-
function registerBeforeLeave(callback) {
|
|
1442
|
-
const path = currentPath.peek();
|
|
1443
|
-
if (!beforeLeaveHooks.has(path)) {
|
|
1444
|
-
beforeLeaveHooks.set(path, []);
|
|
1445
|
-
}
|
|
1446
|
-
beforeLeaveHooks.get(path).push(callback);
|
|
1447
|
-
|
|
1448
|
-
return () => {
|
|
1449
|
-
const hooks = beforeLeaveHooks.get(path);
|
|
1450
|
-
if (hooks) {
|
|
1451
|
-
const idx = hooks.indexOf(callback);
|
|
1452
|
-
if (idx > -1) hooks.splice(idx, 1);
|
|
1453
|
-
if (hooks.length === 0) beforeLeaveHooks.delete(path);
|
|
1454
|
-
}
|
|
1455
|
-
};
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
|
-
/**
|
|
1459
|
-
* Issue #66: Register a callback to run after entering the current route
|
|
1460
|
-
* @param {function} callback - (to) => void
|
|
1461
|
-
* @returns {function} Unsubscribe function
|
|
1462
|
-
*/
|
|
1463
|
-
function registerAfterEnter(callback) {
|
|
1464
|
-
const path = currentPath.peek();
|
|
1465
|
-
if (!afterEnterHooks.has(path)) {
|
|
1466
|
-
afterEnterHooks.set(path, []);
|
|
1467
|
-
}
|
|
1468
|
-
afterEnterHooks.get(path).push(callback);
|
|
1469
|
-
|
|
1470
|
-
return () => {
|
|
1471
|
-
const hooks = afterEnterHooks.get(path);
|
|
1472
|
-
if (hooks) {
|
|
1473
|
-
const idx = hooks.indexOf(callback);
|
|
1474
|
-
if (idx > -1) hooks.splice(idx, 1);
|
|
1475
|
-
if (hooks.length === 0) afterEnterHooks.delete(path);
|
|
1476
|
-
}
|
|
1477
|
-
};
|
|
1478
|
-
}
|
|
1479
|
-
|
|
1480
|
-
/**
|
|
1481
|
-
* Router instance with reactive state and navigation methods.
|
|
1482
|
-
*
|
|
1483
|
-
* Reactive properties (use .get() to read value, auto-updates in effects):
|
|
1484
|
-
* - path: Current URL path as string
|
|
1485
|
-
* - route: Current matched route object or null
|
|
1486
|
-
* - params: Route params object, e.g., {id: '123'}
|
|
1487
|
-
* - query: Query params object, e.g., {page: '1'}
|
|
1488
|
-
* - meta: Route meta data object
|
|
1489
|
-
* - loading: Boolean indicating async route loading
|
|
1490
|
-
* - error: Current route error or null
|
|
1491
|
-
*
|
|
1492
|
-
* @example
|
|
1493
|
-
* // Read reactive state
|
|
1494
|
-
* router.path.get(); // '/users/123'
|
|
1495
|
-
* router.params.get(); // {id: '123'}
|
|
1496
|
-
*
|
|
1497
|
-
* // Subscribe to changes
|
|
1498
|
-
* effect(() => {
|
|
1499
|
-
* console.log('Path changed:', router.path.get());
|
|
1500
|
-
* });
|
|
1501
|
-
*
|
|
1502
|
-
* // Navigate
|
|
1503
|
-
* router.navigate('/users/456');
|
|
1504
|
-
* router.back();
|
|
1505
|
-
*/
|
|
1506
|
-
const router = {
|
|
1507
|
-
// Reactive state (read-only) - use .get() to read, subscribe with effects
|
|
1508
|
-
path: currentPath,
|
|
1509
|
-
route: currentRoute,
|
|
1510
|
-
params: currentParams,
|
|
1511
|
-
query: currentQuery,
|
|
1512
|
-
meta: currentMeta,
|
|
1513
|
-
loading: isLoading,
|
|
1514
|
-
error: routeError,
|
|
1515
|
-
|
|
1516
|
-
// Navigation methods
|
|
1517
|
-
navigate,
|
|
1518
|
-
start,
|
|
1519
|
-
link,
|
|
1520
|
-
outlet,
|
|
1521
|
-
back,
|
|
1522
|
-
forward,
|
|
1523
|
-
go,
|
|
1524
|
-
|
|
1525
|
-
// Guards and middleware
|
|
1526
|
-
use,
|
|
1527
|
-
beforeEach,
|
|
1528
|
-
beforeResolve,
|
|
1529
|
-
afterEach,
|
|
1530
|
-
setErrorHandler,
|
|
1531
|
-
|
|
1532
|
-
// Issue #66: Route lifecycle hooks
|
|
1533
|
-
onBeforeLeave: registerBeforeLeave,
|
|
1534
|
-
onAfterEnter: registerAfterEnter,
|
|
1535
|
-
|
|
1536
|
-
// Issue #72: Loading state listener
|
|
1537
|
-
onLoadingChange,
|
|
1538
|
-
|
|
1539
|
-
// Route inspection
|
|
1540
|
-
isActive,
|
|
1541
|
-
getMatchedRoutes,
|
|
1542
|
-
|
|
1543
|
-
// Utility functions
|
|
1544
|
-
matchRoute,
|
|
1545
|
-
parseQuery,
|
|
1546
|
-
buildQueryString
|
|
1547
|
-
};
|
|
1548
|
-
|
|
1549
|
-
// Set as active router for standalone exports (onBeforeLeave, onAfterEnter)
|
|
1550
|
-
_activeRouter = router;
|
|
1551
|
-
|
|
1552
|
-
return router;
|
|
1553
|
-
}
|
|
1554
|
-
|
|
1555
|
-
/**
|
|
1556
|
-
* Create a simple router for quick setup
|
|
1557
|
-
*/
|
|
1558
|
-
export function simpleRouter(routes, target = '#app') {
|
|
1559
|
-
const router = createRouter({ routes });
|
|
1560
|
-
router.start();
|
|
1561
|
-
router.outlet(target);
|
|
1562
|
-
return router;
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
/**
|
|
1566
|
-
* Register a callback to run before leaving the current route
|
|
1567
|
-
* Must be called within a route handler context
|
|
1568
|
-
* @param {function} callback - (to, from) => boolean|void — return false to block
|
|
1569
|
-
* @returns {function} Unsubscribe function
|
|
1570
|
-
*/
|
|
1571
|
-
export function onBeforeLeave(callback) {
|
|
1572
|
-
if (!_activeRouter) {
|
|
1573
|
-
log.warn('onBeforeLeave() called outside of a router context');
|
|
1574
|
-
return () => {};
|
|
1575
|
-
}
|
|
1576
|
-
return _activeRouter.onBeforeLeave(callback);
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
/**
|
|
1580
|
-
* Register a callback to run after entering the current route
|
|
1581
|
-
* Must be called within a route handler context
|
|
1582
|
-
* @param {function} callback - (to) => void
|
|
1583
|
-
* @returns {function} Unsubscribe function
|
|
1584
|
-
*/
|
|
1585
|
-
export function onAfterEnter(callback) {
|
|
1586
|
-
if (!_activeRouter) {
|
|
1587
|
-
log.warn('onAfterEnter() called outside of a router context');
|
|
1588
|
-
return () => {};
|
|
1589
|
-
}
|
|
1590
|
-
return _activeRouter.onAfterEnter(callback);
|
|
1591
|
-
}
|
|
1592
|
-
|
|
1593
|
-
export { buildQueryString, parseQuery, matchRoute };
|
|
1594
|
-
|
|
1595
|
-
export default {
|
|
1596
|
-
createRouter,
|
|
1597
|
-
simpleRouter,
|
|
1598
|
-
lazy,
|
|
1599
|
-
preload,
|
|
1600
|
-
matchRoute,
|
|
1601
|
-
parseQuery,
|
|
1602
|
-
buildQueryString,
|
|
1603
|
-
onBeforeLeave,
|
|
1604
|
-
onAfterEnter
|
|
1605
|
-
};
|
|
16
|
+
export * from './router/index.js';
|
|
17
|
+
export { default } from './router/index.js';
|