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
@@ -0,0 +1,956 @@
1
+ /**
2
+ * Pulse Router - Core
3
+ *
4
+ * Core router implementation with RouteTrie, createRouter, and main router logic
5
+ *
6
+ * @module pulse-js-framework/runtime/router/core
7
+ */
8
+
9
+ import { pulse, effect, batch } from '../pulse.js';
10
+ import { el } from '../dom.js';
11
+ import { loggers } from '../logger.js';
12
+ import { Errors } from '../errors.js';
13
+ import { parsePattern, normalizeRoute, matchRoute, parseQuery, buildQueryString } from './utils.js';
14
+ import { createMiddlewareRunner } from './guards.js';
15
+ import { createScrollManager, handleScroll, back, forward, go } from './history.js';
16
+
17
+ const log = loggers.router;
18
+
19
+ /**
20
+ * Radix Trie for efficient route matching
21
+ * Provides O(path length) lookup instead of O(routes count)
22
+ */
23
+ class RouteTrie {
24
+ constructor() {
25
+ this.root = { children: new Map(), route: null, paramName: null, isWildcard: false };
26
+ }
27
+
28
+ /**
29
+ * Insert a route into the trie
30
+ */
31
+ insert(pattern, route) {
32
+ const segments = pattern === '/' ? [''] : pattern.split('/').filter(Boolean);
33
+ let node = this.root;
34
+
35
+ for (const segment of segments) {
36
+ let key;
37
+ let paramName = null;
38
+ let isWildcard = false;
39
+
40
+ if (segment.startsWith(':')) {
41
+ // Dynamic segment - :param
42
+ key = ':';
43
+ paramName = segment.slice(1);
44
+ } else if (segment.startsWith('*')) {
45
+ // Wildcard segment - *path
46
+ key = '*';
47
+ paramName = segment.slice(1) || 'wildcard';
48
+ isWildcard = true;
49
+ } else {
50
+ // Static segment
51
+ key = segment;
52
+ }
53
+
54
+ if (!node.children.has(key)) {
55
+ node.children.set(key, {
56
+ children: new Map(),
57
+ route: null,
58
+ paramName,
59
+ isWildcard
60
+ });
61
+ }
62
+ node = node.children.get(key);
63
+ }
64
+
65
+ node.route = route;
66
+ }
67
+
68
+ /**
69
+ * Find a matching route for a path
70
+ */
71
+ find(path) {
72
+ const segments = path === '/' ? [''] : path.split('/').filter(Boolean);
73
+ return this._findRecursive(this.root, segments, 0, {});
74
+ }
75
+
76
+ _findRecursive(node, segments, index, params) {
77
+ // End of path
78
+ if (index === segments.length) {
79
+ if (node.route) {
80
+ return { route: node.route, params };
81
+ }
82
+ return null;
83
+ }
84
+
85
+ const segment = segments[index];
86
+
87
+ // Try static match first (most specific)
88
+ if (node.children.has(segment)) {
89
+ const result = this._findRecursive(node.children.get(segment), segments, index + 1, params);
90
+ if (result) return result;
91
+ }
92
+
93
+ // Try dynamic param match
94
+ if (node.children.has(':')) {
95
+ const paramNode = node.children.get(':');
96
+ const newParams = { ...params, [paramNode.paramName]: decodeURIComponent(segment) };
97
+ const result = this._findRecursive(paramNode, segments, index + 1, newParams);
98
+ if (result) return result;
99
+ }
100
+
101
+ // Try wildcard match (catches all remaining segments)
102
+ if (node.children.has('*')) {
103
+ const wildcardNode = node.children.get('*');
104
+ const remaining = segments.slice(index).map(decodeURIComponent).join('/');
105
+ return {
106
+ route: wildcardNode.route,
107
+ params: { ...params, [wildcardNode.paramName]: remaining }
108
+ };
109
+ }
110
+
111
+ return null;
112
+ }
113
+ }
114
+
115
+ // Issue #66: Active router instance for standalone lifecycle exports
116
+ let _activeRouter = null;
117
+
118
+ /**
119
+ * Create a router instance
120
+ */
121
+ export function createRouter(options = {}) {
122
+ const {
123
+ routes = {},
124
+ mode = 'history', // 'history' or 'hash'
125
+ base = '',
126
+ scrollBehavior = null, // Function to control scroll restoration
127
+ middleware: initialMiddleware = [], // Middleware functions
128
+ persistScroll = false, // Persist scroll positions to sessionStorage
129
+ persistScrollKey = 'pulse-router-scroll', // Storage key for scroll persistence
130
+ parseQueryTypes = false, // Parse typed query params (numbers, booleans)
131
+ transition = null // CSS transition config { enterClass, enterActiveClass, leaveClass, leaveActiveClass, duration }
132
+ } = options;
133
+
134
+ // Validate transition duration to prevent DoS (max 10s)
135
+ const transitionConfig = transition ? {
136
+ enterClass: transition.enterClass || 'route-enter',
137
+ enterActiveClass: transition.enterActiveClass || 'route-enter-active',
138
+ leaveClass: transition.leaveClass || 'route-leave',
139
+ leaveActiveClass: transition.leaveActiveClass || 'route-leave-active',
140
+ duration: Math.min(Math.max(transition.duration || 300, 0), 10000)
141
+ } : null;
142
+
143
+ // Middleware array (mutable for dynamic registration)
144
+ const middleware = [...initialMiddleware];
145
+
146
+ // Reactive state
147
+ const currentPath = pulse(getPath());
148
+ const currentRoute = pulse(null);
149
+ const currentParams = pulse({});
150
+ const currentQuery = pulse({});
151
+ const currentMeta = pulse({});
152
+ const isLoading = pulse(false);
153
+ const routeError = pulse(null);
154
+
155
+ // Route error handler (configurable)
156
+ let onRouteError = options.onRouteError || null;
157
+
158
+ // Scroll manager
159
+ const scrollManager = createScrollManager({ persist: persistScroll, persistKey: persistScrollKey });
160
+
161
+ // Route trie for O(path length) lookups
162
+ const routeTrie = new RouteTrie();
163
+
164
+ // Compile routes (supports nested routes)
165
+ const compiledRoutes = [];
166
+
167
+ function compileRoutes(routeConfig, parentPath = '', parentLayout = null) {
168
+ for (const [pattern, config] of Object.entries(routeConfig)) {
169
+ const normalized = normalizeRoute(pattern, config);
170
+
171
+ // Issue #71: Route groups — key starting with _ and group: true
172
+ // Group children get NO URL prefix from the group key
173
+ if (normalized.group && normalized.children) {
174
+ const groupLayout = normalized.layout || parentLayout;
175
+ compileRoutes(normalized.children, parentPath, groupLayout);
176
+ continue;
177
+ }
178
+
179
+ const fullPattern = parentPath + pattern;
180
+
181
+ // Inherit layout from parent group if not specified
182
+ const routeLayout = normalized.layout || parentLayout;
183
+
184
+ const route = {
185
+ ...normalized,
186
+ pattern: fullPattern,
187
+ layout: routeLayout,
188
+ ...parsePattern(fullPattern)
189
+ };
190
+
191
+ compiledRoutes.push(route);
192
+
193
+ // Insert into trie for fast lookup
194
+ routeTrie.insert(fullPattern, route);
195
+
196
+ // Issue #68: Route aliases — the alias route is already in the trie at its own path
197
+ // (e.g., '/fake' with alias: '/real'). The navigate() function resolves
198
+ // aliases by following the alias chain to find the target handler.
199
+
200
+ // Compile children (nested routes)
201
+ if (normalized.children) {
202
+ compileRoutes(normalized.children, fullPattern, routeLayout);
203
+ }
204
+ }
205
+ }
206
+
207
+ compileRoutes(routes);
208
+
209
+ // Hooks
210
+ const beforeHooks = [];
211
+ const resolveHooks = [];
212
+ const afterHooks = [];
213
+
214
+ // Issue #66: Route lifecycle hooks (per-route, registered by components)
215
+ const beforeLeaveHooks = new Map(); // path → [callbacks]
216
+ const afterEnterHooks = new Map(); // path → [callbacks]
217
+
218
+ // Issue #72: Loading change listeners
219
+ const loadingListeners = [];
220
+
221
+ /**
222
+ * Get current path based on mode
223
+ */
224
+ function getPath() {
225
+ if (mode === 'hash') {
226
+ return window.location.hash.slice(1) || '/';
227
+ }
228
+ let path = window.location.pathname;
229
+ if (base && path.startsWith(base)) {
230
+ path = path.slice(base.length) || '/';
231
+ }
232
+ return path;
233
+ }
234
+
235
+ /**
236
+ * Find matching route using trie for O(path length) lookup
237
+ */
238
+ function findRoute(path) {
239
+ // Use trie for efficient lookup
240
+ const result = routeTrie.find(path);
241
+ if (result) {
242
+ return result;
243
+ }
244
+
245
+ // Fallback to catch-all route if exists
246
+ for (const route of compiledRoutes) {
247
+ if (route.pattern === '*') {
248
+ return { route, params: {} };
249
+ }
250
+ }
251
+
252
+ return null;
253
+ }
254
+
255
+ /**
256
+ * Navigate to a path
257
+ */
258
+ async function navigate(path, options = {}) {
259
+ const { replace = false, query = {}, state = null } = options;
260
+
261
+ // Issue #72: Set loading state at start of navigation
262
+ const hasAsyncWork = middleware.length > 0 || beforeHooks.length > 0 || resolveHooks.length > 0;
263
+ if (hasAsyncWork) {
264
+ isLoading.set(true);
265
+ }
266
+
267
+ try {
268
+ // Find matching route first (needed for beforeEnter guard)
269
+ let match = findRoute(path);
270
+
271
+ // Issue #68: Resolve alias — follow alias chain (with loop protection)
272
+ const visited = new Set();
273
+ while (match?.route?.alias && !visited.has(match.route.pattern)) {
274
+ visited.add(match.route.pattern);
275
+ const aliasTarget = match.route.alias;
276
+ const aliasMatch = findRoute(aliasTarget);
277
+ if (aliasMatch) {
278
+ match = aliasMatch;
279
+ } else {
280
+ break;
281
+ }
282
+ }
283
+
284
+ // Issue #70: Build full path with query using buildQueryString (array + null support)
285
+ let fullPath = path;
286
+ const queryString = buildQueryString(query);
287
+ if (queryString) {
288
+ fullPath += '?' + queryString;
289
+ }
290
+
291
+ // Handle redirect
292
+ if (match?.route?.redirect) {
293
+ const redirectPath = typeof match.route.redirect === 'function'
294
+ ? match.route.redirect({ params: match.params, query })
295
+ : match.route.redirect;
296
+ return navigate(redirectPath, { replace: true });
297
+ }
298
+
299
+ // Create navigation context for guards
300
+ const from = {
301
+ path: currentPath.peek(),
302
+ params: currentParams.peek(),
303
+ query: currentQuery.peek(),
304
+ meta: currentMeta.peek()
305
+ };
306
+
307
+ // Issue #70: Parse query with typed option
308
+ const parsedQuery = parseQuery(queryString, { typed: parseQueryTypes });
309
+
310
+ const to = {
311
+ path,
312
+ params: match?.params || {},
313
+ query: parsedQuery,
314
+ meta: match?.route?.meta || {}
315
+ };
316
+
317
+ // Issue #66: Run beforeLeave hooks for the current route
318
+ const leavePath = currentPath.peek();
319
+ const leaveCallbacks = beforeLeaveHooks.get(leavePath);
320
+ if (leaveCallbacks && leaveCallbacks.length > 0) {
321
+ for (const cb of [...leaveCallbacks]) {
322
+ const result = await cb(to, from);
323
+ if (result === false) return false;
324
+ }
325
+ }
326
+
327
+ // Run middleware if configured
328
+ if (middleware.length > 0) {
329
+ const runMiddleware = createMiddlewareRunner(middleware);
330
+ const middlewareResult = await runMiddleware({ to, from });
331
+ if (middlewareResult.aborted) {
332
+ return false;
333
+ }
334
+ if (middlewareResult.redirectPath) {
335
+ return navigate(middlewareResult.redirectPath, { replace: true });
336
+ }
337
+ // Merge middleware meta into route meta
338
+ Object.assign(to.meta, middlewareResult.meta);
339
+ }
340
+
341
+ // Run global beforeEach hooks
342
+ for (const hook of beforeHooks) {
343
+ const result = await hook(to, from);
344
+ if (result === false) return false;
345
+ if (typeof result === 'string') {
346
+ return navigate(result, options);
347
+ }
348
+ }
349
+
350
+ // Run per-route beforeEnter guard
351
+ if (match?.route?.beforeEnter) {
352
+ const result = await match.route.beforeEnter(to, from);
353
+ if (result === false) return false;
354
+ if (typeof result === 'string') {
355
+ return navigate(result, options);
356
+ }
357
+ }
358
+
359
+ // Run beforeResolve hooks (after per-route guards)
360
+ for (const hook of resolveHooks) {
361
+ const result = await hook(to, from);
362
+ if (result === false) return false;
363
+ if (typeof result === 'string') {
364
+ return navigate(result, options);
365
+ }
366
+ }
367
+
368
+ // Save scroll position before leaving
369
+ scrollManager.saveScrollPosition(currentPath.peek());
370
+
371
+ // Update URL
372
+ const url = mode === 'hash' ? `#${fullPath}` : `${base}${fullPath}`;
373
+ const historyState = { path: fullPath, ...(state || {}) };
374
+
375
+ if (replace) {
376
+ window.history.replaceState(historyState, '', url);
377
+ } else {
378
+ window.history.pushState(historyState, '', url);
379
+ }
380
+
381
+ // Update reactive state
382
+ await updateRoute(path, parsedQuery, match);
383
+
384
+ // Handle scroll behavior
385
+ handleScroll(to, from, scrollManager.getScrollPosition(path), scrollBehavior);
386
+
387
+ // Issue #66: Run afterEnter hooks for the new route
388
+ const enterCallbacks = afterEnterHooks.get(path);
389
+ if (enterCallbacks && enterCallbacks.length > 0) {
390
+ for (const cb of [...enterCallbacks]) {
391
+ cb(to);
392
+ }
393
+ }
394
+
395
+ return true;
396
+ } finally {
397
+ // Issue #72: Always reset loading state
398
+ isLoading.set(false);
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Update the current route state
404
+ */
405
+ async function updateRoute(path, query = {}, match = null) {
406
+ if (!match) {
407
+ match = findRoute(path);
408
+ }
409
+
410
+ batch(() => {
411
+ currentPath.set(path);
412
+ currentQuery.set(query);
413
+
414
+ if (match) {
415
+ currentRoute.set(match.route);
416
+ currentParams.set(match.params);
417
+ currentMeta.set(match.route.meta || {});
418
+ } else {
419
+ currentRoute.set(null);
420
+ currentParams.set({});
421
+ currentMeta.set({});
422
+ }
423
+ });
424
+
425
+ // Run after hooks with full context
426
+ const to = {
427
+ path,
428
+ params: match?.params || {},
429
+ query,
430
+ meta: match?.route?.meta || {}
431
+ };
432
+
433
+ for (const hook of afterHooks) {
434
+ await hook(to);
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Handle browser navigation (back/forward)
440
+ */
441
+ function handlePopState() {
442
+ const path = getPath();
443
+ const query = parseQuery(window.location.search);
444
+ updateRoute(path, query);
445
+ }
446
+
447
+ /**
448
+ * Start listening to navigation events
449
+ */
450
+ function start() {
451
+ window.addEventListener('popstate', handlePopState);
452
+
453
+ // Initial route
454
+ const query = parseQuery(window.location.search);
455
+ updateRoute(getPath(), query);
456
+
457
+ return () => {
458
+ window.removeEventListener('popstate', handlePopState);
459
+ };
460
+ }
461
+
462
+ /**
463
+ * Create a link element that uses the router
464
+ */
465
+ function link(path, content, options = {}) {
466
+ const href = mode === 'hash' ? `#${path}` : `${base}${path}`;
467
+ const a = el('a', content);
468
+ a.href = href;
469
+
470
+ const handleClick = (e) => {
471
+ // Allow ctrl/cmd+click for new tab
472
+ if (e.ctrlKey || e.metaKey) return;
473
+
474
+ e.preventDefault();
475
+ navigate(path, options);
476
+ };
477
+
478
+ a.addEventListener('click', handleClick);
479
+
480
+ // Add active class when route matches
481
+ const disposeEffect = effect(() => {
482
+ const current = currentPath.get();
483
+ if (current === path || (options.exact === false && current.startsWith(path))) {
484
+ a.classList.add(options.activeClass || 'active');
485
+ } else {
486
+ a.classList.remove(options.activeClass || 'active');
487
+ }
488
+ });
489
+
490
+ a.cleanup = () => {
491
+ a.removeEventListener('click', handleClick);
492
+ disposeEffect();
493
+ };
494
+
495
+ return a;
496
+ }
497
+
498
+ /**
499
+ * Router outlet - renders the current route's component
500
+ *
501
+ * MEMORY SAFETY: Aborts any pending lazy loads when navigating away
502
+ * to prevent stale callbacks from updating the DOM.
503
+ *
504
+ * Supports:
505
+ * - Route groups with shared layouts (#71)
506
+ * - CSS route transitions (#66)
507
+ */
508
+ function outlet(container) {
509
+ if (typeof container === 'string') {
510
+ container = document.querySelector(container);
511
+ }
512
+
513
+ let currentView = null;
514
+ let cleanup = null;
515
+
516
+ /**
517
+ * Remove old view, optionally with CSS transition
518
+ */
519
+ function removeOldView(oldView, onDone) {
520
+ if (!oldView) {
521
+ onDone();
522
+ return;
523
+ }
524
+
525
+ // Abort any pending lazy loads before removing the view
526
+ if (oldView._pulseAbortLazyLoad) {
527
+ oldView._pulseAbortLazyLoad();
528
+ }
529
+
530
+ // Issue #66: CSS transition on leave
531
+ if (transitionConfig && oldView.classList) {
532
+ oldView.classList.add(transitionConfig.leaveClass);
533
+ requestAnimationFrame(() => {
534
+ oldView.classList.add(transitionConfig.leaveActiveClass);
535
+ });
536
+ setTimeout(() => {
537
+ oldView.classList.remove(transitionConfig.leaveClass, transitionConfig.leaveActiveClass);
538
+ container.replaceChildren();
539
+ onDone();
540
+ }, transitionConfig.duration);
541
+ } else {
542
+ container.replaceChildren();
543
+ onDone();
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Add new view, optionally with CSS transition
549
+ */
550
+ function addNewView(view) {
551
+ container.appendChild(view);
552
+ currentView = view;
553
+
554
+ // Issue #66: CSS transition on enter
555
+ if (transitionConfig && view.classList) {
556
+ view.classList.add(transitionConfig.enterClass);
557
+ requestAnimationFrame(() => {
558
+ view.classList.add(transitionConfig.enterActiveClass);
559
+ setTimeout(() => {
560
+ view.classList.remove(transitionConfig.enterClass, transitionConfig.enterActiveClass);
561
+ }, transitionConfig.duration);
562
+ });
563
+ }
564
+ }
565
+
566
+ effect(() => {
567
+ const route = currentRoute.get();
568
+ const params = currentParams.get();
569
+ const query = currentQuery.get();
570
+
571
+ // Cleanup previous view
572
+ if (cleanup) cleanup();
573
+
574
+ const oldView = currentView;
575
+ currentView = null;
576
+
577
+ function renderRoute() {
578
+ if (route && route.handler) {
579
+ // Create context for the route handler
580
+ const ctx = {
581
+ params,
582
+ query,
583
+ path: currentPath.peek(),
584
+ navigate,
585
+ router
586
+ };
587
+
588
+ // Helper to handle errors
589
+ const handleError = (error) => {
590
+ routeError.set(error);
591
+ log.error('Route component error:', error);
592
+
593
+ if (onRouteError) {
594
+ try {
595
+ const errorView = onRouteError(error, ctx);
596
+ if (errorView instanceof Node) {
597
+ addNewView(errorView);
598
+ return true;
599
+ }
600
+ } catch (handlerError) {
601
+ log.error('Route error handler threw:', handlerError);
602
+ }
603
+ }
604
+
605
+ const errorEl = el('div.route-error', [
606
+ el('h2', 'Route Error'),
607
+ el('p', error.message || 'Failed to load route component')
608
+ ]);
609
+ addNewView(errorEl);
610
+ return true;
611
+ };
612
+
613
+ // Call handler and render result (with error handling)
614
+ let result;
615
+ try {
616
+ result = typeof route.handler === 'function'
617
+ ? route.handler(ctx)
618
+ : route.handler;
619
+ } catch (error) {
620
+ handleError(error);
621
+ return;
622
+ }
623
+
624
+ if (result instanceof Node) {
625
+ // Issue #71: Wrap with layout if route has one
626
+ let view = result;
627
+ if (route.layout && typeof route.layout === 'function') {
628
+ try {
629
+ const layoutResult = route.layout(() => result, ctx);
630
+ if (layoutResult instanceof Node) {
631
+ view = layoutResult;
632
+ }
633
+ } catch (error) {
634
+ log.error('Layout error:', error);
635
+ }
636
+ }
637
+
638
+ addNewView(view);
639
+ routeError.set(null);
640
+ } else if (result && typeof result.then === 'function') {
641
+ // Async component
642
+ isLoading.set(true);
643
+ routeError.set(null);
644
+ result
645
+ .then(component => {
646
+ isLoading.set(false);
647
+ let view = typeof component === 'function' ? component(ctx) : component;
648
+ if (view instanceof Node) {
649
+ // Issue #71: Wrap with layout
650
+ if (route.layout && typeof route.layout === 'function') {
651
+ try {
652
+ const layoutResult = route.layout(() => view, ctx);
653
+ if (layoutResult instanceof Node) {
654
+ view = layoutResult;
655
+ }
656
+ } catch (error) {
657
+ log.error('Layout error:', error);
658
+ }
659
+ }
660
+
661
+ addNewView(view);
662
+ }
663
+ })
664
+ .catch(error => {
665
+ isLoading.set(false);
666
+ handleError(error);
667
+ });
668
+ }
669
+ }
670
+ }
671
+
672
+ // Remove old view (with optional transition), then render new route
673
+ removeOldView(oldView, renderRoute);
674
+ });
675
+
676
+ return container;
677
+ }
678
+
679
+ /**
680
+ * Add middleware dynamically
681
+ * @param {function} middlewareFn - Middleware function (ctx, next) => {}
682
+ * @returns {function} Unregister function
683
+ */
684
+ function use(middlewareFn) {
685
+ middleware.push(middlewareFn);
686
+ return () => {
687
+ const index = middleware.indexOf(middlewareFn);
688
+ if (index > -1) middleware.splice(index, 1);
689
+ };
690
+ }
691
+
692
+ /**
693
+ * Add navigation guard
694
+ */
695
+ function beforeEach(hook) {
696
+ beforeHooks.push(hook);
697
+ return () => {
698
+ const index = beforeHooks.indexOf(hook);
699
+ if (index > -1) beforeHooks.splice(index, 1);
700
+ };
701
+ }
702
+
703
+ /**
704
+ * Add before resolve hook (runs after per-route guards)
705
+ */
706
+ function beforeResolve(hook) {
707
+ resolveHooks.push(hook);
708
+ return () => {
709
+ const index = resolveHooks.indexOf(hook);
710
+ if (index > -1) resolveHooks.splice(index, 1);
711
+ };
712
+ }
713
+
714
+ /**
715
+ * Add after navigation hook
716
+ */
717
+ function afterEach(hook) {
718
+ afterHooks.push(hook);
719
+ return () => {
720
+ const index = afterHooks.indexOf(hook);
721
+ if (index > -1) afterHooks.splice(index, 1);
722
+ };
723
+ }
724
+
725
+ /**
726
+ * Check if a path matches the current route
727
+ * @param {string} path - Path to check
728
+ * @param {boolean} [exact=false] - If true, requires exact match; if false, matches prefixes
729
+ * @returns {boolean} True if path is active
730
+ * @example
731
+ * // Current path: /users/123
732
+ * router.isActive('/users'); // true (prefix match)
733
+ * router.isActive('/users', true); // false (not exact)
734
+ * router.isActive('/users/123', true); // true (exact match)
735
+ */
736
+ function isActive(path, exact = false) {
737
+ const current = currentPath.get();
738
+ if (exact) {
739
+ return current === path;
740
+ }
741
+ return current.startsWith(path);
742
+ }
743
+
744
+ /**
745
+ * Get all routes that match a given path (useful for nested routes)
746
+ * @param {string} path - Path to match against routes
747
+ * @returns {Array<{route: Object, params: Object}>} Array of matched routes with extracted params
748
+ * @example
749
+ * const matches = router.getMatchedRoutes('/admin/users/123');
750
+ * // Returns: [{route: adminRoute, params: {}}, {route: userRoute, params: {id: '123'}}]
751
+ */
752
+ function getMatchedRoutes(path) {
753
+ const matches = [];
754
+ for (const route of compiledRoutes) {
755
+ const params = matchRoute(route.pattern, path);
756
+ if (params !== null) {
757
+ matches.push({ route, params });
758
+ }
759
+ }
760
+ return matches;
761
+ }
762
+
763
+ /**
764
+ * Set route error handler
765
+ * @param {function} handler - Error handler (error, ctx) => Node
766
+ * @returns {function} Previous handler
767
+ */
768
+ function setErrorHandler(handler) {
769
+ const prev = onRouteError;
770
+ onRouteError = handler;
771
+ return prev;
772
+ }
773
+
774
+ /**
775
+ * Issue #72: Subscribe to loading state changes
776
+ * @param {function} callback - Called with (loading: boolean) when loading state changes
777
+ * @returns {function} Unsubscribe function
778
+ */
779
+ function onLoadingChange(callback) {
780
+ const dispose = effect(() => {
781
+ callback(isLoading.get());
782
+ });
783
+ loadingListeners.push(dispose);
784
+ return () => {
785
+ dispose();
786
+ const idx = loadingListeners.indexOf(dispose);
787
+ if (idx > -1) loadingListeners.splice(idx, 1);
788
+ };
789
+ }
790
+
791
+ /**
792
+ * Issue #66: Register a callback to run before leaving the current route
793
+ * If callback returns false, navigation is blocked
794
+ * @param {function} callback - (to, from) => boolean|void
795
+ * @returns {function} Unsubscribe function
796
+ */
797
+ function registerBeforeLeave(callback) {
798
+ const path = currentPath.peek();
799
+ if (!beforeLeaveHooks.has(path)) {
800
+ beforeLeaveHooks.set(path, []);
801
+ }
802
+ beforeLeaveHooks.get(path).push(callback);
803
+
804
+ return () => {
805
+ const hooks = beforeLeaveHooks.get(path);
806
+ if (hooks) {
807
+ const idx = hooks.indexOf(callback);
808
+ if (idx > -1) hooks.splice(idx, 1);
809
+ if (hooks.length === 0) beforeLeaveHooks.delete(path);
810
+ }
811
+ };
812
+ }
813
+
814
+ /**
815
+ * Issue #66: Register a callback to run after entering the current route
816
+ * @param {function} callback - (to) => void
817
+ * @returns {function} Unsubscribe function
818
+ */
819
+ function registerAfterEnter(callback) {
820
+ const path = currentPath.peek();
821
+ if (!afterEnterHooks.has(path)) {
822
+ afterEnterHooks.set(path, []);
823
+ }
824
+ afterEnterHooks.get(path).push(callback);
825
+
826
+ return () => {
827
+ const hooks = afterEnterHooks.get(path);
828
+ if (hooks) {
829
+ const idx = hooks.indexOf(callback);
830
+ if (idx > -1) hooks.splice(idx, 1);
831
+ if (hooks.length === 0) afterEnterHooks.delete(path);
832
+ }
833
+ };
834
+ }
835
+
836
+ /**
837
+ * Router instance with reactive state and navigation methods.
838
+ *
839
+ * Reactive properties (use .get() to read value, auto-updates in effects):
840
+ * - path: Current URL path as string
841
+ * - route: Current matched route object or null
842
+ * - params: Route params object, e.g., {id: '123'}
843
+ * - query: Query params object, e.g., {page: '1'}
844
+ * - meta: Route meta data object
845
+ * - loading: Boolean indicating async route loading
846
+ * - error: Current route error or null
847
+ *
848
+ * @example
849
+ * // Read reactive state
850
+ * router.path.get(); // '/users/123'
851
+ * router.params.get(); // {id: '123'}
852
+ *
853
+ * // Subscribe to changes
854
+ * effect(() => {
855
+ * console.log('Path changed:', router.path.get());
856
+ * });
857
+ *
858
+ * // Navigate
859
+ * router.navigate('/users/456');
860
+ * router.back();
861
+ */
862
+ const router = {
863
+ // Reactive state (read-only) - use .get() to read, subscribe with effects
864
+ path: currentPath,
865
+ route: currentRoute,
866
+ params: currentParams,
867
+ query: currentQuery,
868
+ meta: currentMeta,
869
+ loading: isLoading,
870
+ error: routeError,
871
+
872
+ // Navigation methods
873
+ navigate,
874
+ start,
875
+ link,
876
+ outlet,
877
+ back: () => {
878
+ scrollManager.saveScrollPosition(currentPath.peek());
879
+ return back();
880
+ },
881
+ forward: () => {
882
+ scrollManager.saveScrollPosition(currentPath.peek());
883
+ return forward();
884
+ },
885
+ go: (delta) => {
886
+ scrollManager.saveScrollPosition(currentPath.peek());
887
+ return go(delta);
888
+ },
889
+
890
+ // Guards and middleware
891
+ use,
892
+ beforeEach,
893
+ beforeResolve,
894
+ afterEach,
895
+ setErrorHandler,
896
+
897
+ // Issue #66: Route lifecycle hooks
898
+ onBeforeLeave: registerBeforeLeave,
899
+ onAfterEnter: registerAfterEnter,
900
+
901
+ // Issue #72: Loading state listener
902
+ onLoadingChange,
903
+
904
+ // Route inspection
905
+ isActive,
906
+ getMatchedRoutes,
907
+
908
+ // Utility functions
909
+ matchRoute,
910
+ parseQuery,
911
+ buildQueryString
912
+ };
913
+
914
+ // Set as active router for standalone exports (onBeforeLeave, onAfterEnter)
915
+ _activeRouter = router;
916
+
917
+ return router;
918
+ }
919
+
920
+ /**
921
+ * Create a simple router for quick setup
922
+ */
923
+ export function simpleRouter(routes, target = '#app') {
924
+ const router = createRouter({ routes });
925
+ router.start();
926
+ router.outlet(target);
927
+ return router;
928
+ }
929
+
930
+ /**
931
+ * Register a callback to run before leaving the current route
932
+ * Must be called within a route handler context
933
+ * @param {function} callback - (to, from) => boolean|void — return false to block
934
+ * @returns {function} Unsubscribe function
935
+ */
936
+ export function onBeforeLeave(callback) {
937
+ if (!_activeRouter) {
938
+ log.warn('onBeforeLeave() called outside of a router context');
939
+ return () => {};
940
+ }
941
+ return _activeRouter.onBeforeLeave(callback);
942
+ }
943
+
944
+ /**
945
+ * Register a callback to run after entering the current route
946
+ * Must be called within a route handler context
947
+ * @param {function} callback - (to) => void
948
+ * @returns {function} Unsubscribe function
949
+ */
950
+ export function onAfterEnter(callback) {
951
+ if (!_activeRouter) {
952
+ log.warn('onAfterEnter() called outside of a router context');
953
+ return () => {};
954
+ }
955
+ return _activeRouter.onAfterEnter(callback);
956
+ }