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.
Files changed (37) hide show
  1. package/compiler/parser/_extract.js +393 -0
  2. package/compiler/parser/blocks.js +361 -0
  3. package/compiler/parser/core.js +306 -0
  4. package/compiler/parser/expressions.js +386 -0
  5. package/compiler/parser/imports.js +108 -0
  6. package/compiler/parser/index.js +47 -0
  7. package/compiler/parser/state.js +155 -0
  8. package/compiler/parser/style.js +445 -0
  9. package/compiler/parser/view.js +632 -0
  10. package/compiler/parser.js +15 -2372
  11. package/compiler/parser.js.original +2376 -0
  12. package/package.json +2 -1
  13. package/runtime/a11y/announcements.js +213 -0
  14. package/runtime/a11y/contrast.js +125 -0
  15. package/runtime/a11y/focus.js +412 -0
  16. package/runtime/a11y/index.js +35 -0
  17. package/runtime/a11y/preferences.js +121 -0
  18. package/runtime/a11y/utils.js +164 -0
  19. package/runtime/a11y/validation.js +258 -0
  20. package/runtime/a11y/widgets.js +545 -0
  21. package/runtime/a11y.js +15 -1840
  22. package/runtime/a11y.js.original +1844 -0
  23. package/runtime/graphql/cache.js +69 -0
  24. package/runtime/graphql/client.js +563 -0
  25. package/runtime/graphql/hooks.js +492 -0
  26. package/runtime/graphql/index.js +62 -0
  27. package/runtime/graphql/subscriptions.js +241 -0
  28. package/runtime/graphql.js +12 -1322
  29. package/runtime/graphql.js.original +1326 -0
  30. package/runtime/router/core.js +956 -0
  31. package/runtime/router/guards.js +90 -0
  32. package/runtime/router/history.js +204 -0
  33. package/runtime/router/index.js +36 -0
  34. package/runtime/router/lazy.js +180 -0
  35. package/runtime/router/utils.js +226 -0
  36. package/runtime/router.js +12 -1600
  37. package/runtime/router.js.original +1605 -0
package/runtime/router.js CHANGED
@@ -1,1605 +1,17 @@
1
1
  /**
2
- * Pulse Router - SPA routing system
2
+ * Pulse Router - Backward Compatibility Export
3
3
  *
4
- * A simple but powerful router that integrates with Pulse reactivity
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
- * Features:
7
- * - Route params and query strings
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
- import { pulse, effect, batch } from './pulse.js';
22
- import { el } from './dom.js';
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';