pulse-js-framework 1.0.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/runtime/router.js CHANGED
@@ -1,392 +1,596 @@
1
- /**
2
- * Pulse Router - SPA routing system
3
- *
4
- * A simple but powerful router that integrates with Pulse reactivity
5
- */
6
-
7
- import { pulse, effect, batch } from './pulse.js';
8
- import { el, mount } from './dom.js';
9
-
10
- /**
11
- * Parse a route pattern into a regex and extract param names
12
- * Supports: /users/:id, /posts/:id/comments, /files/*path
13
- */
14
- function parsePattern(pattern) {
15
- const paramNames = [];
16
- let regexStr = pattern
17
- // Escape special regex chars except : and *
18
- .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
19
- // Handle wildcard params (*name)
20
- .replace(/\*([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
21
- paramNames.push(name);
22
- return '(.*)';
23
- })
24
- // Handle named params (:name)
25
- .replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
26
- paramNames.push(name);
27
- return '([^/]+)';
28
- });
29
-
30
- // Ensure exact match
31
- regexStr = `^${regexStr}$`;
32
-
33
- return {
34
- regex: new RegExp(regexStr),
35
- paramNames
36
- };
37
- }
38
-
39
- /**
40
- * Match a path against a route pattern
41
- */
42
- function matchRoute(pattern, path) {
43
- const { regex, paramNames } = parsePattern(pattern);
44
- const match = path.match(regex);
45
-
46
- if (!match) return null;
47
-
48
- const params = {};
49
- paramNames.forEach((name, i) => {
50
- params[name] = decodeURIComponent(match[i + 1]);
51
- });
52
-
53
- return params;
54
- }
55
-
56
- /**
57
- * Parse query string into object
58
- */
59
- function parseQuery(search) {
60
- const params = new URLSearchParams(search);
61
- const query = {};
62
- for (const [key, value] of params) {
63
- if (key in query) {
64
- // Multiple values for same key
65
- if (Array.isArray(query[key])) {
66
- query[key].push(value);
67
- } else {
68
- query[key] = [query[key], value];
69
- }
70
- } else {
71
- query[key] = value;
72
- }
73
- }
74
- return query;
75
- }
76
-
77
- /**
78
- * Create a router instance
79
- */
80
- export function createRouter(options = {}) {
81
- const {
82
- routes = {},
83
- mode = 'history', // 'history' or 'hash'
84
- base = ''
85
- } = options;
86
-
87
- // Reactive state
88
- const currentPath = pulse(getPath());
89
- const currentRoute = pulse(null);
90
- const currentParams = pulse({});
91
- const currentQuery = pulse({});
92
- const isLoading = pulse(false);
93
-
94
- // Compiled routes
95
- const compiledRoutes = [];
96
- for (const [pattern, handler] of Object.entries(routes)) {
97
- compiledRoutes.push({
98
- pattern,
99
- ...parsePattern(pattern),
100
- handler
101
- });
102
- }
103
-
104
- // Hooks
105
- const beforeHooks = [];
106
- const afterHooks = [];
107
-
108
- /**
109
- * Get current path based on mode
110
- */
111
- function getPath() {
112
- if (mode === 'hash') {
113
- return window.location.hash.slice(1) || '/';
114
- }
115
- let path = window.location.pathname;
116
- if (base && path.startsWith(base)) {
117
- path = path.slice(base.length) || '/';
118
- }
119
- return path;
120
- }
121
-
122
- /**
123
- * Find matching route
124
- */
125
- function findRoute(path) {
126
- for (const route of compiledRoutes) {
127
- const params = matchRoute(route.pattern, path);
128
- if (params !== null) {
129
- return { route, params };
130
- }
131
- }
132
- return null;
133
- }
134
-
135
- /**
136
- * Navigate to a path
137
- */
138
- async function navigate(path, options = {}) {
139
- const { replace = false, query = {} } = options;
140
-
141
- // Build full path with query
142
- let fullPath = path;
143
- const queryString = new URLSearchParams(query).toString();
144
- if (queryString) {
145
- fullPath += '?' + queryString;
146
- }
147
-
148
- // Run before hooks
149
- for (const hook of beforeHooks) {
150
- const result = await hook(path, currentPath.peek());
151
- if (result === false) return false;
152
- if (typeof result === 'string') {
153
- return navigate(result, options);
154
- }
155
- }
156
-
157
- // Update URL
158
- const url = mode === 'hash' ? `#${fullPath}` : `${base}${fullPath}`;
159
- if (replace) {
160
- window.history.replaceState(null, '', url);
161
- } else {
162
- window.history.pushState(null, '', url);
163
- }
164
-
165
- // Update reactive state
166
- await updateRoute(path, parseQuery(queryString));
167
-
168
- return true;
169
- }
170
-
171
- /**
172
- * Update the current route state
173
- */
174
- async function updateRoute(path, query = {}) {
175
- const match = findRoute(path);
176
-
177
- batch(() => {
178
- currentPath.set(path);
179
- currentQuery.set(query);
180
-
181
- if (match) {
182
- currentRoute.set(match.route);
183
- currentParams.set(match.params);
184
- } else {
185
- currentRoute.set(null);
186
- currentParams.set({});
187
- }
188
- });
189
-
190
- // Run after hooks
191
- for (const hook of afterHooks) {
192
- await hook(path);
193
- }
194
- }
195
-
196
- /**
197
- * Handle browser navigation (back/forward)
198
- */
199
- function handlePopState() {
200
- const path = getPath();
201
- const query = parseQuery(window.location.search);
202
- updateRoute(path, query);
203
- }
204
-
205
- /**
206
- * Start listening to navigation events
207
- */
208
- function start() {
209
- window.addEventListener('popstate', handlePopState);
210
-
211
- // Initial route
212
- const query = parseQuery(window.location.search);
213
- updateRoute(getPath(), query);
214
-
215
- return () => {
216
- window.removeEventListener('popstate', handlePopState);
217
- };
218
- }
219
-
220
- /**
221
- * Create a link element that uses the router
222
- */
223
- function link(path, content, options = {}) {
224
- const href = mode === 'hash' ? `#${path}` : `${base}${path}`;
225
- const a = el('a', content);
226
- a.href = href;
227
-
228
- a.addEventListener('click', (e) => {
229
- // Allow ctrl/cmd+click for new tab
230
- if (e.ctrlKey || e.metaKey) return;
231
-
232
- e.preventDefault();
233
- navigate(path, options);
234
- });
235
-
236
- // Add active class when route matches
237
- effect(() => {
238
- const current = currentPath.get();
239
- if (current === path || (options.exact === false && current.startsWith(path))) {
240
- a.classList.add(options.activeClass || 'active');
241
- } else {
242
- a.classList.remove(options.activeClass || 'active');
243
- }
244
- });
245
-
246
- return a;
247
- }
248
-
249
- /**
250
- * Router outlet - renders the current route's component
251
- */
252
- function outlet(container) {
253
- if (typeof container === 'string') {
254
- container = document.querySelector(container);
255
- }
256
-
257
- let currentView = null;
258
- let cleanup = null;
259
-
260
- effect(() => {
261
- const route = currentRoute.get();
262
- const params = currentParams.get();
263
- const query = currentQuery.get();
264
-
265
- // Cleanup previous view
266
- if (cleanup) cleanup();
267
- if (currentView) {
268
- container.innerHTML = '';
269
- }
270
-
271
- if (route && route.handler) {
272
- // Create context for the route handler
273
- const ctx = {
274
- params,
275
- query,
276
- path: currentPath.peek(),
277
- navigate,
278
- router
279
- };
280
-
281
- // Call handler and render result
282
- const result = typeof route.handler === 'function'
283
- ? route.handler(ctx)
284
- : route.handler;
285
-
286
- if (result instanceof Node) {
287
- container.appendChild(result);
288
- currentView = result;
289
- } else if (result && typeof result.then === 'function') {
290
- // Async component
291
- isLoading.set(true);
292
- result.then(component => {
293
- isLoading.set(false);
294
- const view = typeof component === 'function' ? component(ctx) : component;
295
- if (view instanceof Node) {
296
- container.appendChild(view);
297
- currentView = view;
298
- }
299
- });
300
- }
301
- }
302
- });
303
-
304
- return container;
305
- }
306
-
307
- /**
308
- * Add navigation guard
309
- */
310
- function beforeEach(hook) {
311
- beforeHooks.push(hook);
312
- return () => {
313
- const index = beforeHooks.indexOf(hook);
314
- if (index > -1) beforeHooks.splice(index, 1);
315
- };
316
- }
317
-
318
- /**
319
- * Add after navigation hook
320
- */
321
- function afterEach(hook) {
322
- afterHooks.push(hook);
323
- return () => {
324
- const index = afterHooks.indexOf(hook);
325
- if (index > -1) afterHooks.splice(index, 1);
326
- };
327
- }
328
-
329
- /**
330
- * Go back in history
331
- */
332
- function back() {
333
- window.history.back();
334
- }
335
-
336
- /**
337
- * Go forward in history
338
- */
339
- function forward() {
340
- window.history.forward();
341
- }
342
-
343
- /**
344
- * Go to specific history entry
345
- */
346
- function go(delta) {
347
- window.history.go(delta);
348
- }
349
-
350
- const router = {
351
- // Reactive state (read-only)
352
- path: currentPath,
353
- route: currentRoute,
354
- params: currentParams,
355
- query: currentQuery,
356
- loading: isLoading,
357
-
358
- // Methods
359
- navigate,
360
- start,
361
- link,
362
- outlet,
363
- beforeEach,
364
- afterEach,
365
- back,
366
- forward,
367
- go,
368
-
369
- // Utils
370
- matchRoute,
371
- parseQuery
372
- };
373
-
374
- return router;
375
- }
376
-
377
- /**
378
- * Create a simple router for quick setup
379
- */
380
- export function simpleRouter(routes, target = '#app') {
381
- const router = createRouter({ routes });
382
- router.start();
383
- router.outlet(target);
384
- return router;
385
- }
386
-
387
- export default {
388
- createRouter,
389
- simpleRouter,
390
- matchRoute,
391
- parseQuery
392
- };
1
+ /**
2
+ * Pulse Router - SPA routing system
3
+ *
4
+ * A simple but powerful router that integrates with Pulse reactivity
5
+ *
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
+ */
14
+
15
+ import { pulse, effect, batch } from './pulse.js';
16
+ import { el } from './dom.js';
17
+
18
+ /**
19
+ * Parse a route pattern into a regex and extract param names
20
+ * Supports: /users/:id, /posts/:id/comments, /files/*path, * (catch-all)
21
+ */
22
+ function parsePattern(pattern) {
23
+ const paramNames = [];
24
+
25
+ // Handle standalone * as catch-all
26
+ if (pattern === '*') {
27
+ return {
28
+ regex: /^.*$/,
29
+ paramNames: []
30
+ };
31
+ }
32
+
33
+ let regexStr = pattern
34
+ // Escape special regex chars except : and *
35
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
36
+ // Handle wildcard params (*name)
37
+ .replace(/\*([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
38
+ paramNames.push(name);
39
+ return '(.*)';
40
+ })
41
+ // Handle named params (:name)
42
+ .replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
43
+ paramNames.push(name);
44
+ return '([^/]+)';
45
+ });
46
+
47
+ // Ensure exact match
48
+ regexStr = `^${regexStr}$`;
49
+
50
+ return {
51
+ regex: new RegExp(regexStr),
52
+ paramNames
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Normalize route configuration
58
+ * Supports both simple (handler function) and full (object with meta) definitions
59
+ */
60
+ function normalizeRoute(pattern, config) {
61
+ // Simple format: pattern -> handler
62
+ if (typeof config === 'function') {
63
+ return {
64
+ pattern,
65
+ handler: config,
66
+ meta: {},
67
+ beforeEnter: null,
68
+ children: null
69
+ };
70
+ }
71
+
72
+ // Full format: pattern -> { handler, meta, beforeEnter, children }
73
+ return {
74
+ pattern,
75
+ handler: config.handler || config.component,
76
+ meta: config.meta || {},
77
+ beforeEnter: config.beforeEnter || null,
78
+ children: config.children || null,
79
+ redirect: config.redirect || null
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Match a path against a route pattern
85
+ */
86
+ function matchRoute(pattern, path) {
87
+ const { regex, paramNames } = parsePattern(pattern);
88
+ const match = path.match(regex);
89
+
90
+ if (!match) return null;
91
+
92
+ const params = {};
93
+ paramNames.forEach((name, i) => {
94
+ params[name] = decodeURIComponent(match[i + 1]);
95
+ });
96
+
97
+ return params;
98
+ }
99
+
100
+ /**
101
+ * Parse query string into object
102
+ */
103
+ function parseQuery(search) {
104
+ const params = new URLSearchParams(search);
105
+ const query = {};
106
+ for (const [key, value] of params) {
107
+ if (key in query) {
108
+ // Multiple values for same key
109
+ if (Array.isArray(query[key])) {
110
+ query[key].push(value);
111
+ } else {
112
+ query[key] = [query[key], value];
113
+ }
114
+ } else {
115
+ query[key] = value;
116
+ }
117
+ }
118
+ return query;
119
+ }
120
+
121
+ /**
122
+ * Create a router instance
123
+ */
124
+ export function createRouter(options = {}) {
125
+ const {
126
+ routes = {},
127
+ mode = 'history', // 'history' or 'hash'
128
+ base = '',
129
+ scrollBehavior = null // Function to control scroll restoration
130
+ } = options;
131
+
132
+ // Reactive state
133
+ const currentPath = pulse(getPath());
134
+ const currentRoute = pulse(null);
135
+ const currentParams = pulse({});
136
+ const currentQuery = pulse({});
137
+ const currentMeta = pulse({});
138
+ const isLoading = pulse(false);
139
+
140
+ // Scroll positions for history
141
+ const scrollPositions = new Map();
142
+
143
+ // Compile routes (supports nested routes)
144
+ const compiledRoutes = [];
145
+
146
+ function compileRoutes(routeConfig, parentPath = '') {
147
+ for (const [pattern, config] of Object.entries(routeConfig)) {
148
+ const normalized = normalizeRoute(pattern, config);
149
+ const fullPattern = parentPath + pattern;
150
+
151
+ compiledRoutes.push({
152
+ ...normalized,
153
+ pattern: fullPattern,
154
+ ...parsePattern(fullPattern)
155
+ });
156
+
157
+ // Compile children (nested routes)
158
+ if (normalized.children) {
159
+ compileRoutes(normalized.children, fullPattern);
160
+ }
161
+ }
162
+ }
163
+
164
+ compileRoutes(routes);
165
+
166
+ // Hooks
167
+ const beforeHooks = [];
168
+ const resolveHooks = [];
169
+ const afterHooks = [];
170
+
171
+ /**
172
+ * Get current path based on mode
173
+ */
174
+ function getPath() {
175
+ if (mode === 'hash') {
176
+ return window.location.hash.slice(1) || '/';
177
+ }
178
+ let path = window.location.pathname;
179
+ if (base && path.startsWith(base)) {
180
+ path = path.slice(base.length) || '/';
181
+ }
182
+ return path;
183
+ }
184
+
185
+ /**
186
+ * Find matching route
187
+ */
188
+ function findRoute(path) {
189
+ for (const route of compiledRoutes) {
190
+ const params = matchRoute(route.pattern, path);
191
+ if (params !== null) {
192
+ return { route, params };
193
+ }
194
+ }
195
+ return null;
196
+ }
197
+
198
+ /**
199
+ * Navigate to a path
200
+ */
201
+ async function navigate(path, options = {}) {
202
+ const { replace = false, query = {}, state = null } = options;
203
+
204
+ // Find matching route first (needed for beforeEnter guard)
205
+ const match = findRoute(path);
206
+
207
+ // Build full path with query
208
+ let fullPath = path;
209
+ const queryString = new URLSearchParams(query).toString();
210
+ if (queryString) {
211
+ fullPath += '?' + queryString;
212
+ }
213
+
214
+ // Handle redirect
215
+ if (match?.route?.redirect) {
216
+ const redirectPath = typeof match.route.redirect === 'function'
217
+ ? match.route.redirect({ params: match.params, query })
218
+ : match.route.redirect;
219
+ return navigate(redirectPath, { replace: true });
220
+ }
221
+
222
+ // Create navigation context for guards
223
+ const from = {
224
+ path: currentPath.peek(),
225
+ params: currentParams.peek(),
226
+ query: currentQuery.peek(),
227
+ meta: currentMeta.peek()
228
+ };
229
+
230
+ const to = {
231
+ path,
232
+ params: match?.params || {},
233
+ query: parseQuery(queryString),
234
+ meta: match?.route?.meta || {}
235
+ };
236
+
237
+ // Run global beforeEach hooks
238
+ for (const hook of beforeHooks) {
239
+ const result = await hook(to, from);
240
+ if (result === false) return false;
241
+ if (typeof result === 'string') {
242
+ return navigate(result, options);
243
+ }
244
+ }
245
+
246
+ // Run per-route beforeEnter guard
247
+ if (match?.route?.beforeEnter) {
248
+ const result = await match.route.beforeEnter(to, from);
249
+ if (result === false) return false;
250
+ if (typeof result === 'string') {
251
+ return navigate(result, options);
252
+ }
253
+ }
254
+
255
+ // Run beforeResolve hooks (after per-route guards)
256
+ for (const hook of resolveHooks) {
257
+ const result = await hook(to, from);
258
+ if (result === false) return false;
259
+ if (typeof result === 'string') {
260
+ return navigate(result, options);
261
+ }
262
+ }
263
+
264
+ // Save scroll position before leaving
265
+ const currentFullPath = currentPath.peek();
266
+ if (currentFullPath) {
267
+ scrollPositions.set(currentFullPath, {
268
+ x: window.scrollX,
269
+ y: window.scrollY
270
+ });
271
+ }
272
+
273
+ // Update URL
274
+ const url = mode === 'hash' ? `#${fullPath}` : `${base}${fullPath}`;
275
+ const historyState = { path: fullPath, ...(state || {}) };
276
+
277
+ if (replace) {
278
+ window.history.replaceState(historyState, '', url);
279
+ } else {
280
+ window.history.pushState(historyState, '', url);
281
+ }
282
+
283
+ // Update reactive state
284
+ await updateRoute(path, parseQuery(queryString), match);
285
+
286
+ // Handle scroll behavior
287
+ handleScroll(to, from, scrollPositions.get(path));
288
+
289
+ return true;
290
+ }
291
+
292
+ /**
293
+ * Handle scroll behavior after navigation
294
+ */
295
+ function handleScroll(to, from, savedPosition) {
296
+ if (scrollBehavior) {
297
+ const position = scrollBehavior(to, from, savedPosition);
298
+ if (position) {
299
+ if (position.selector) {
300
+ // Scroll to element
301
+ const el = document.querySelector(position.selector);
302
+ if (el) {
303
+ el.scrollIntoView({ behavior: position.behavior || 'auto' });
304
+ }
305
+ } else if (typeof position.x === 'number' || typeof position.y === 'number') {
306
+ window.scrollTo({
307
+ left: position.x || 0,
308
+ top: position.y || 0,
309
+ behavior: position.behavior || 'auto'
310
+ });
311
+ }
312
+ }
313
+ } else if (savedPosition) {
314
+ // Default: restore saved position
315
+ window.scrollTo(savedPosition.x, savedPosition.y);
316
+ } else {
317
+ // Default: scroll to top
318
+ window.scrollTo(0, 0);
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Update the current route state
324
+ */
325
+ async function updateRoute(path, query = {}, match = null) {
326
+ if (!match) {
327
+ match = findRoute(path);
328
+ }
329
+
330
+ batch(() => {
331
+ currentPath.set(path);
332
+ currentQuery.set(query);
333
+
334
+ if (match) {
335
+ currentRoute.set(match.route);
336
+ currentParams.set(match.params);
337
+ currentMeta.set(match.route.meta || {});
338
+ } else {
339
+ currentRoute.set(null);
340
+ currentParams.set({});
341
+ currentMeta.set({});
342
+ }
343
+ });
344
+
345
+ // Run after hooks with full context
346
+ const to = {
347
+ path,
348
+ params: match?.params || {},
349
+ query,
350
+ meta: match?.route?.meta || {}
351
+ };
352
+
353
+ for (const hook of afterHooks) {
354
+ await hook(to);
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Handle browser navigation (back/forward)
360
+ */
361
+ function handlePopState() {
362
+ const path = getPath();
363
+ const query = parseQuery(window.location.search);
364
+ updateRoute(path, query);
365
+ }
366
+
367
+ /**
368
+ * Start listening to navigation events
369
+ */
370
+ function start() {
371
+ window.addEventListener('popstate', handlePopState);
372
+
373
+ // Initial route
374
+ const query = parseQuery(window.location.search);
375
+ updateRoute(getPath(), query);
376
+
377
+ return () => {
378
+ window.removeEventListener('popstate', handlePopState);
379
+ };
380
+ }
381
+
382
+ /**
383
+ * Create a link element that uses the router
384
+ */
385
+ function link(path, content, options = {}) {
386
+ const href = mode === 'hash' ? `#${path}` : `${base}${path}`;
387
+ const a = el('a', content);
388
+ a.href = href;
389
+
390
+ a.addEventListener('click', (e) => {
391
+ // Allow ctrl/cmd+click for new tab
392
+ if (e.ctrlKey || e.metaKey) return;
393
+
394
+ e.preventDefault();
395
+ navigate(path, options);
396
+ });
397
+
398
+ // Add active class when route matches
399
+ effect(() => {
400
+ const current = currentPath.get();
401
+ if (current === path || (options.exact === false && current.startsWith(path))) {
402
+ a.classList.add(options.activeClass || 'active');
403
+ } else {
404
+ a.classList.remove(options.activeClass || 'active');
405
+ }
406
+ });
407
+
408
+ return a;
409
+ }
410
+
411
+ /**
412
+ * Router outlet - renders the current route's component
413
+ */
414
+ function outlet(container) {
415
+ if (typeof container === 'string') {
416
+ container = document.querySelector(container);
417
+ }
418
+
419
+ let currentView = null;
420
+ let cleanup = null;
421
+
422
+ effect(() => {
423
+ const route = currentRoute.get();
424
+ const params = currentParams.get();
425
+ const query = currentQuery.get();
426
+
427
+ // Cleanup previous view
428
+ if (cleanup) cleanup();
429
+ if (currentView) {
430
+ container.innerHTML = '';
431
+ }
432
+
433
+ if (route && route.handler) {
434
+ // Create context for the route handler
435
+ const ctx = {
436
+ params,
437
+ query,
438
+ path: currentPath.peek(),
439
+ navigate,
440
+ router
441
+ };
442
+
443
+ // Call handler and render result
444
+ const result = typeof route.handler === 'function'
445
+ ? route.handler(ctx)
446
+ : route.handler;
447
+
448
+ if (result instanceof Node) {
449
+ container.appendChild(result);
450
+ currentView = result;
451
+ } else if (result && typeof result.then === 'function') {
452
+ // Async component
453
+ isLoading.set(true);
454
+ result.then(component => {
455
+ isLoading.set(false);
456
+ const view = typeof component === 'function' ? component(ctx) : component;
457
+ if (view instanceof Node) {
458
+ container.appendChild(view);
459
+ currentView = view;
460
+ }
461
+ });
462
+ }
463
+ }
464
+ });
465
+
466
+ return container;
467
+ }
468
+
469
+ /**
470
+ * Add navigation guard
471
+ */
472
+ function beforeEach(hook) {
473
+ beforeHooks.push(hook);
474
+ return () => {
475
+ const index = beforeHooks.indexOf(hook);
476
+ if (index > -1) beforeHooks.splice(index, 1);
477
+ };
478
+ }
479
+
480
+ /**
481
+ * Add before resolve hook (runs after per-route guards)
482
+ */
483
+ function beforeResolve(hook) {
484
+ resolveHooks.push(hook);
485
+ return () => {
486
+ const index = resolveHooks.indexOf(hook);
487
+ if (index > -1) resolveHooks.splice(index, 1);
488
+ };
489
+ }
490
+
491
+ /**
492
+ * Add after navigation hook
493
+ */
494
+ function afterEach(hook) {
495
+ afterHooks.push(hook);
496
+ return () => {
497
+ const index = afterHooks.indexOf(hook);
498
+ if (index > -1) afterHooks.splice(index, 1);
499
+ };
500
+ }
501
+
502
+ /**
503
+ * Check if a route matches the given path
504
+ */
505
+ function isActive(path, exact = false) {
506
+ const current = currentPath.get();
507
+ if (exact) {
508
+ return current === path;
509
+ }
510
+ return current.startsWith(path);
511
+ }
512
+
513
+ /**
514
+ * Get all matched routes (for nested routes)
515
+ */
516
+ function getMatchedRoutes(path) {
517
+ const matches = [];
518
+ for (const route of compiledRoutes) {
519
+ const params = matchRoute(route.pattern, path);
520
+ if (params !== null) {
521
+ matches.push({ route, params });
522
+ }
523
+ }
524
+ return matches;
525
+ }
526
+
527
+ /**
528
+ * Go back in history
529
+ */
530
+ function back() {
531
+ window.history.back();
532
+ }
533
+
534
+ /**
535
+ * Go forward in history
536
+ */
537
+ function forward() {
538
+ window.history.forward();
539
+ }
540
+
541
+ /**
542
+ * Go to specific history entry
543
+ */
544
+ function go(delta) {
545
+ window.history.go(delta);
546
+ }
547
+
548
+ const router = {
549
+ // Reactive state (read-only)
550
+ path: currentPath,
551
+ route: currentRoute,
552
+ params: currentParams,
553
+ query: currentQuery,
554
+ meta: currentMeta,
555
+ loading: isLoading,
556
+
557
+ // Methods
558
+ navigate,
559
+ start,
560
+ link,
561
+ outlet,
562
+ beforeEach,
563
+ beforeResolve,
564
+ afterEach,
565
+ back,
566
+ forward,
567
+ go,
568
+
569
+ // Route inspection
570
+ isActive,
571
+ getMatchedRoutes,
572
+
573
+ // Utils
574
+ matchRoute,
575
+ parseQuery
576
+ };
577
+
578
+ return router;
579
+ }
580
+
581
+ /**
582
+ * Create a simple router for quick setup
583
+ */
584
+ export function simpleRouter(routes, target = '#app') {
585
+ const router = createRouter({ routes });
586
+ router.start();
587
+ router.outlet(target);
588
+ return router;
589
+ }
590
+
591
+ export default {
592
+ createRouter,
593
+ simpleRouter,
594
+ matchRoute,
595
+ parseQuery
596
+ };