@bquery/bquery 1.1.2 → 1.2.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.
@@ -0,0 +1,718 @@
1
+ /**
2
+ * Minimal SPA router with History API integration.
3
+ *
4
+ * This module provides a lightweight, signal-based router for single-page
5
+ * applications. Features include:
6
+ * - History API navigation
7
+ * - Route matching with params and wildcards
8
+ * - Lazy route loading
9
+ * - Navigation guards (beforeEach, afterEach)
10
+ * - Reactive current route via signals
11
+ * - Multi-value query params (e.g., `?tag=a&tag=b` → `{ tag: ['a', 'b'] }`)
12
+ *
13
+ * @module bquery/router
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * import { createRouter, navigate, currentRoute } from 'bquery/router';
18
+ * import { effect } from 'bquery/reactive';
19
+ *
20
+ * const router = createRouter({
21
+ * routes: [
22
+ * { path: '/', component: () => import('./Home') },
23
+ * { path: '/user/:id', component: () => import('./User') },
24
+ * { path: '*', component: () => import('./NotFound') },
25
+ * ],
26
+ * });
27
+ *
28
+ * effect(() => {
29
+ * console.log('Route changed:', currentRoute.value);
30
+ * });
31
+ *
32
+ * navigate('/user/42');
33
+ * ```
34
+ */
35
+
36
+ import { computed, signal, type ReadonlySignal, type Signal } from '../reactive/index';
37
+
38
+ // ============================================================================
39
+ // Types
40
+ // ============================================================================
41
+
42
+ /**
43
+ * Represents a parsed route with matched params.
44
+ */
45
+ export type Route = {
46
+ /** The current path (e.g., '/user/42') */
47
+ path: string;
48
+ /** Extracted route params (e.g., { id: '42' }) */
49
+ params: Record<string, string>;
50
+ /**
51
+ * Query string params.
52
+ * Each key maps to a single string value by default.
53
+ * Only keys that appear multiple times in the query string become arrays.
54
+ * @example
55
+ * // ?foo=1 → { foo: '1' }
56
+ * // ?tag=a&tag=b → { tag: ['a', 'b'] }
57
+ * // ?x=1&y=2&x=3 → { x: ['1', '3'], y: '2' }
58
+ */
59
+ query: Record<string, string | string[]>;
60
+ /** The matched route definition */
61
+ matched: RouteDefinition | null;
62
+ /** Hash fragment without # */
63
+ hash: string;
64
+ };
65
+
66
+ /**
67
+ * Route definition for configuration.
68
+ */
69
+ export type RouteDefinition = {
70
+ /** Path pattern (e.g., '/user/:id', '/posts/*') */
71
+ path: string;
72
+ /** Component loader (sync or async) */
73
+ component: () => unknown | Promise<unknown>;
74
+ /** Optional route name for programmatic navigation */
75
+ name?: string;
76
+ /** Optional metadata */
77
+ meta?: Record<string, unknown>;
78
+ /** Nested child routes */
79
+ children?: RouteDefinition[];
80
+ };
81
+
82
+ /**
83
+ * Router configuration options.
84
+ */
85
+ export type RouterOptions = {
86
+ /** Array of route definitions */
87
+ routes: RouteDefinition[];
88
+ /** Base path for all routes (default: '') */
89
+ base?: string;
90
+ /** Use hash-based routing instead of history (default: false) */
91
+ hash?: boolean;
92
+ };
93
+
94
+ /**
95
+ * Navigation guard function type.
96
+ */
97
+ export type NavigationGuard = (to: Route, from: Route) => boolean | void | Promise<boolean | void>;
98
+
99
+ /**
100
+ * Router instance returned by createRouter.
101
+ */
102
+ export type Router = {
103
+ /** Navigate to a path */
104
+ push: (path: string) => Promise<void>;
105
+ /** Replace current history entry */
106
+ replace: (path: string) => Promise<void>;
107
+ /** Go back in history */
108
+ back: () => void;
109
+ /** Go forward in history */
110
+ forward: () => void;
111
+ /** Go to a specific history entry */
112
+ go: (delta: number) => void;
113
+ /** Add a beforeEach guard */
114
+ beforeEach: (guard: NavigationGuard) => () => void;
115
+ /** Add an afterEach hook */
116
+ afterEach: (hook: (to: Route, from: Route) => void) => () => void;
117
+ /** Current route (reactive) */
118
+ currentRoute: ReadonlySignal<Route>;
119
+ /** All route definitions */
120
+ routes: RouteDefinition[];
121
+ /** Destroy the router and cleanup listeners */
122
+ destroy: () => void;
123
+ };
124
+
125
+ // ============================================================================
126
+ // Internal State
127
+ // ============================================================================
128
+
129
+ /** @internal */
130
+ let activeRouter: Router | null = null;
131
+
132
+ /** @internal */
133
+ const routeSignal: Signal<Route> = signal<Route>({
134
+ path: '',
135
+ params: {},
136
+ query: {},
137
+ matched: null,
138
+ hash: '',
139
+ });
140
+
141
+ /**
142
+ * Reactive signal containing the current route.
143
+ *
144
+ * @example
145
+ * ```ts
146
+ * import { currentRoute } from 'bquery/router';
147
+ * import { effect } from 'bquery/reactive';
148
+ *
149
+ * effect(() => {
150
+ * document.title = `Page: ${currentRoute.value.path}`;
151
+ * });
152
+ * ```
153
+ */
154
+ export const currentRoute: ReadonlySignal<Route> = computed(() => routeSignal.value);
155
+
156
+ // ============================================================================
157
+ // Route Matching
158
+ // ============================================================================
159
+
160
+ /**
161
+ * Converts a route path pattern to a RegExp for matching.
162
+ * Uses placeholder approach to preserve :param and * patterns during escaping.
163
+ * @internal
164
+ */
165
+ const pathToRegex = (path: string): RegExp => {
166
+ // Handle wildcard-only route
167
+ if (path === '*') {
168
+ return /^.*$/;
169
+ }
170
+
171
+ // Unique placeholders using null chars (won't appear in normal paths)
172
+ const PARAM_MARKER = '\u0000P\u0000';
173
+ const WILDCARD_MARKER = '\u0000W\u0000';
174
+
175
+ // Store param names for restoration
176
+ const paramNames: string[] = [];
177
+
178
+ // Step 1: Extract :param patterns before escaping
179
+ let pattern = path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
180
+ paramNames.push(name);
181
+ return PARAM_MARKER;
182
+ });
183
+
184
+ // Step 2: Extract * wildcards before escaping
185
+ pattern = pattern.replace(/\*/g, WILDCARD_MARKER);
186
+
187
+ // Step 3: Escape ALL regex metacharacters: \ ^ $ . * + ? ( ) [ ] { } |
188
+ pattern = pattern.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
189
+
190
+ // Step 4: Restore param capture groups
191
+ let paramIdx = 0;
192
+ pattern = pattern.replace(/\u0000P\u0000/g, () => `(?<${paramNames[paramIdx++]}>[^/]+)`);
193
+
194
+ // Step 5: Restore wildcards as .*
195
+ pattern = pattern.replace(/\u0000W\u0000/g, '.*');
196
+
197
+ return new RegExp(`^${pattern}$`);
198
+ };
199
+
200
+ /**
201
+ * Extracts param names from a route path.
202
+ * @internal
203
+ */
204
+ const extractParamNames = (path: string): string[] => {
205
+ const matches = path.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g);
206
+ return matches ? matches.map((m) => m.slice(1)) : [];
207
+ };
208
+
209
+ /**
210
+ * Matches a path against route definitions and extracts params.
211
+ * @internal
212
+ */
213
+ const matchRoute = (
214
+ path: string,
215
+ routes: RouteDefinition[]
216
+ ): { matched: RouteDefinition; params: Record<string, string> } | null => {
217
+ for (const route of routes) {
218
+ const regex = pathToRegex(route.path);
219
+ const match = path.match(regex);
220
+
221
+ if (match) {
222
+ const paramNames = extractParamNames(route.path);
223
+ const params: Record<string, string> = {};
224
+
225
+ // Extract named groups if available
226
+ if (match.groups) {
227
+ Object.assign(params, match.groups);
228
+ } else {
229
+ // Fallback for browsers without named groups
230
+ paramNames.forEach((name, index) => {
231
+ params[name] = match[index + 1] || '';
232
+ });
233
+ }
234
+
235
+ return { matched: route, params };
236
+ }
237
+ }
238
+
239
+ return null;
240
+ };
241
+
242
+ /**
243
+ * Parses query string into an object.
244
+ * Single values are stored as strings, duplicate keys become arrays.
245
+ * @internal
246
+ *
247
+ * @example
248
+ * parseQuery('?foo=1') // { foo: '1' }
249
+ * parseQuery('?tag=a&tag=b') // { tag: ['a', 'b'] }
250
+ * parseQuery('?x=1&y=2&x=3') // { x: ['1', '3'], y: '2' }
251
+ */
252
+ const parseQuery = (search: string): Record<string, string | string[]> => {
253
+ const query: Record<string, string | string[]> = {};
254
+ const params = new URLSearchParams(search);
255
+
256
+ params.forEach((value, key) => {
257
+ const existing = query[key];
258
+ if (existing === undefined) {
259
+ // First occurrence: store as string
260
+ query[key] = value;
261
+ } else if (Array.isArray(existing)) {
262
+ // Already an array: append
263
+ existing.push(value);
264
+ } else {
265
+ // Second occurrence: convert to array
266
+ query[key] = [existing, value];
267
+ }
268
+ });
269
+
270
+ return query;
271
+ };
272
+
273
+ /**
274
+ * Creates a Route object from the current URL.
275
+ * @internal
276
+ */
277
+ const createRoute = (
278
+ pathname: string,
279
+ search: string,
280
+ hash: string,
281
+ routes: RouteDefinition[]
282
+ ): Route => {
283
+ const result = matchRoute(pathname, routes);
284
+
285
+ return {
286
+ path: pathname,
287
+ params: result?.params ?? {},
288
+ query: parseQuery(search),
289
+ matched: result?.matched ?? null,
290
+ hash: hash.replace(/^#/, ''),
291
+ };
292
+ };
293
+
294
+ // ============================================================================
295
+ // Navigation
296
+ // ============================================================================
297
+
298
+ /**
299
+ * Navigates to a new path.
300
+ *
301
+ * @param path - The path to navigate to
302
+ * @param options - Navigation options
303
+ *
304
+ * @example
305
+ * ```ts
306
+ * import { navigate } from 'bquery/router';
307
+ *
308
+ * // Push to history
309
+ * await navigate('/dashboard');
310
+ *
311
+ * // Replace current entry
312
+ * await navigate('/login', { replace: true });
313
+ * ```
314
+ */
315
+ export const navigate = async (
316
+ path: string,
317
+ options: { replace?: boolean } = {}
318
+ ): Promise<void> => {
319
+ if (!activeRouter) {
320
+ throw new Error('bQuery router: No router initialized. Call createRouter() first.');
321
+ }
322
+
323
+ await activeRouter[options.replace ? 'replace' : 'push'](path);
324
+ };
325
+
326
+ /**
327
+ * Programmatically go back in history.
328
+ *
329
+ * @example
330
+ * ```ts
331
+ * import { back } from 'bquery/router';
332
+ * back();
333
+ * ```
334
+ */
335
+ export const back = (): void => {
336
+ if (activeRouter) {
337
+ activeRouter.back();
338
+ } else {
339
+ history.back();
340
+ }
341
+ };
342
+
343
+ /**
344
+ * Programmatically go forward in history.
345
+ *
346
+ * @example
347
+ * ```ts
348
+ * import { forward } from 'bquery/router';
349
+ * forward();
350
+ * ```
351
+ */
352
+ export const forward = (): void => {
353
+ if (activeRouter) {
354
+ activeRouter.forward();
355
+ } else {
356
+ history.forward();
357
+ }
358
+ };
359
+
360
+ // ============================================================================
361
+ // Router Creation
362
+ // ============================================================================
363
+
364
+ /**
365
+ * Creates and initializes a router instance.
366
+ *
367
+ * @param options - Router configuration
368
+ * @returns The router instance
369
+ *
370
+ * @example
371
+ * ```ts
372
+ * import { createRouter } from 'bquery/router';
373
+ *
374
+ * const router = createRouter({
375
+ * routes: [
376
+ * { path: '/', component: () => import('./pages/Home') },
377
+ * { path: '/about', component: () => import('./pages/About') },
378
+ * { path: '/user/:id', component: () => import('./pages/User') },
379
+ * { path: '*', component: () => import('./pages/NotFound') },
380
+ * ],
381
+ * base: '/app',
382
+ * });
383
+ *
384
+ * router.beforeEach((to, from) => {
385
+ * if (to.path === '/admin' && !isAuthenticated()) {
386
+ * return false; // Cancel navigation
387
+ * }
388
+ * });
389
+ * ```
390
+ */
391
+ export const createRouter = (options: RouterOptions): Router => {
392
+ // Clean up any existing router to prevent guard leakage
393
+ if (activeRouter) {
394
+ activeRouter.destroy();
395
+ }
396
+
397
+ const { routes, base = '', hash: useHash = false } = options;
398
+
399
+ // Instance-specific guards and hooks (not shared globally)
400
+ const beforeGuards: NavigationGuard[] = [];
401
+ const afterHooks: Array<(to: Route, from: Route) => void> = [];
402
+
403
+ // Flatten nested routes
404
+ const flatRoutes = flattenRoutes(routes, base);
405
+
406
+ /**
407
+ * Gets the current path from the URL.
408
+ */
409
+ const getCurrentPath = (): { pathname: string; search: string; hash: string } => {
410
+ if (useHash) {
411
+ const hashPath = window.location.hash.slice(1) || '/';
412
+ const [pathname, rest = ''] = hashPath.split('?');
413
+ const [search, hashPart = ''] = rest.split('#');
414
+ return {
415
+ pathname,
416
+ search: search ? `?${search}` : '',
417
+ hash: hashPart ? `#${hashPart}` : '',
418
+ };
419
+ }
420
+
421
+ let pathname = window.location.pathname;
422
+ if (base && pathname.startsWith(base)) {
423
+ pathname = pathname.slice(base.length) || '/';
424
+ }
425
+
426
+ return {
427
+ pathname,
428
+ search: window.location.search,
429
+ hash: window.location.hash,
430
+ };
431
+ };
432
+
433
+ /**
434
+ * Updates the route signal with current URL state.
435
+ */
436
+ const syncRoute = (): void => {
437
+ const { pathname, search, hash } = getCurrentPath();
438
+ const newRoute = createRoute(pathname, search, hash, flatRoutes);
439
+ routeSignal.value = newRoute;
440
+ };
441
+
442
+ /**
443
+ * Performs navigation with guards.
444
+ */
445
+ const performNavigation = async (
446
+ path: string,
447
+ method: 'pushState' | 'replaceState'
448
+ ): Promise<void> => {
449
+ const { pathname, search, hash } = getCurrentPath();
450
+ const from = createRoute(pathname, search, hash, flatRoutes);
451
+
452
+ // Parse the target path
453
+ const url = new URL(path, window.location.origin);
454
+ const toPath = useHash ? path : url.pathname;
455
+ const to = createRoute(toPath, url.search, url.hash, flatRoutes);
456
+
457
+ // Run beforeEach guards
458
+ for (const guard of beforeGuards) {
459
+ const result = await guard(to, from);
460
+ if (result === false) {
461
+ return; // Cancel navigation
462
+ }
463
+ }
464
+
465
+ // Update browser history
466
+ const fullPath = useHash ? `#${path}` : `${base}${path}`;
467
+ history[method]({}, '', fullPath);
468
+
469
+ // Update route signal
470
+ syncRoute();
471
+
472
+ // Run afterEach hooks
473
+ for (const hook of afterHooks) {
474
+ hook(routeSignal.value, from);
475
+ }
476
+ };
477
+
478
+ /**
479
+ * Handle popstate events (back/forward).
480
+ */
481
+ const handlePopState = async (): Promise<void> => {
482
+ const { pathname, search, hash } = getCurrentPath();
483
+ const from = routeSignal.value;
484
+ const to = createRoute(pathname, search, hash, flatRoutes);
485
+
486
+ // Run beforeEach guards (supports async guards)
487
+ for (const guard of beforeGuards) {
488
+ const result = await guard(to, from);
489
+ if (result === false) {
490
+ // Restore previous state
491
+ const restorePath = useHash ? `#${from.path}` : `${base}${from.path}`;
492
+ history.pushState({}, '', restorePath);
493
+ return;
494
+ }
495
+ }
496
+
497
+ syncRoute();
498
+
499
+ for (const hook of afterHooks) {
500
+ hook(routeSignal.value, from);
501
+ }
502
+ };
503
+
504
+ // Attach popstate listener
505
+ window.addEventListener('popstate', handlePopState);
506
+
507
+ // Initialize route
508
+ syncRoute();
509
+
510
+ const router: Router = {
511
+ push: (path: string) => performNavigation(path, 'pushState'),
512
+ replace: (path: string) => performNavigation(path, 'replaceState'),
513
+ back: () => history.back(),
514
+ forward: () => history.forward(),
515
+ go: (delta: number) => history.go(delta),
516
+
517
+ beforeEach: (guard: NavigationGuard) => {
518
+ beforeGuards.push(guard);
519
+ return () => {
520
+ const index = beforeGuards.indexOf(guard);
521
+ if (index > -1) beforeGuards.splice(index, 1);
522
+ };
523
+ },
524
+
525
+ afterEach: (hook: (to: Route, from: Route) => void) => {
526
+ afterHooks.push(hook);
527
+ return () => {
528
+ const index = afterHooks.indexOf(hook);
529
+ if (index > -1) afterHooks.splice(index, 1);
530
+ };
531
+ },
532
+
533
+ currentRoute,
534
+ routes: flatRoutes,
535
+
536
+ destroy: () => {
537
+ window.removeEventListener('popstate', handlePopState);
538
+ beforeGuards.length = 0;
539
+ afterHooks.length = 0;
540
+ activeRouter = null;
541
+ },
542
+ };
543
+
544
+ activeRouter = router;
545
+ return router;
546
+ };
547
+
548
+ // ============================================================================
549
+ // Utilities
550
+ // ============================================================================
551
+
552
+ /**
553
+ * Flattens nested routes into a single array with full paths.
554
+ * @internal
555
+ */
556
+ const flattenRoutes = (routes: RouteDefinition[], base = ''): RouteDefinition[] => {
557
+ const result: RouteDefinition[] = [];
558
+
559
+ for (const route of routes) {
560
+ const fullPath = route.path === '*' ? '*' : `${base}${route.path}`.replace(/\/+/g, '/');
561
+
562
+ result.push({
563
+ ...route,
564
+ path: fullPath,
565
+ });
566
+
567
+ if (route.children) {
568
+ result.push(...flattenRoutes(route.children, fullPath));
569
+ }
570
+ }
571
+
572
+ return result;
573
+ };
574
+
575
+ /**
576
+ * Resolves a route by name and params.
577
+ *
578
+ * @param name - The route name
579
+ * @param params - Route params to interpolate
580
+ * @returns The resolved path
581
+ *
582
+ * @example
583
+ * ```ts
584
+ * import { resolve } from 'bquery/router';
585
+ *
586
+ * const path = resolve('user', { id: '42' });
587
+ * // Returns '/user/42' if route is defined as { name: 'user', path: '/user/:id' }
588
+ * ```
589
+ */
590
+ export const resolve = (name: string, params: Record<string, string> = {}): string => {
591
+ if (!activeRouter) {
592
+ throw new Error('bQuery router: No router initialized.');
593
+ }
594
+
595
+ const route = activeRouter.routes.find((r) => r.name === name);
596
+ if (!route) {
597
+ throw new Error(`bQuery router: Route "${name}" not found.`);
598
+ }
599
+
600
+ let path = route.path;
601
+ for (const [key, value] of Object.entries(params)) {
602
+ path = path.replace(`:${key}`, encodeURIComponent(value));
603
+ }
604
+
605
+ return path;
606
+ };
607
+
608
+ /**
609
+ * Checks if a path matches the current route.
610
+ *
611
+ * @param path - Path to check
612
+ * @param exact - Whether to match exactly (default: false)
613
+ * @returns True if the path matches
614
+ *
615
+ * @example
616
+ * ```ts
617
+ * import { isActive } from 'bquery/router';
618
+ *
619
+ * if (isActive('/dashboard')) {
620
+ * // Highlight nav item
621
+ * }
622
+ * ```
623
+ */
624
+ export const isActive = (path: string, exact = false): boolean => {
625
+ const current = routeSignal.value.path;
626
+ return exact ? current === path : current.startsWith(path);
627
+ };
628
+
629
+ /**
630
+ * Creates a computed signal that checks if a path is active.
631
+ *
632
+ * @param path - Path to check
633
+ * @param exact - Whether to match exactly
634
+ * @returns A reactive signal
635
+ *
636
+ * @example
637
+ * ```ts
638
+ * import { isActiveSignal } from 'bquery/router';
639
+ * import { effect } from 'bquery/reactive';
640
+ *
641
+ * const dashboardActive = isActiveSignal('/dashboard');
642
+ * effect(() => {
643
+ * navItem.classList.toggle('active', dashboardActive.value);
644
+ * });
645
+ * ```
646
+ */
647
+ export const isActiveSignal = (path: string, exact = false): ReadonlySignal<boolean> => {
648
+ return computed(() => {
649
+ const current = routeSignal.value.path;
650
+ return exact ? current === path : current.startsWith(path);
651
+ });
652
+ };
653
+
654
+ // ============================================================================
655
+ // Router Link Helper
656
+ // ============================================================================
657
+
658
+ /**
659
+ * Creates click handler for router links.
660
+ * Attach to anchor elements to enable client-side navigation.
661
+ *
662
+ * @param path - Target path
663
+ * @param options - Navigation options
664
+ * @returns Click event handler
665
+ *
666
+ * @example
667
+ * ```ts
668
+ * import { link } from 'bquery/router';
669
+ * import { $ } from 'bquery/core';
670
+ *
671
+ * $('#nav-home').on('click', link('/'));
672
+ * $('#nav-about').on('click', link('/about'));
673
+ * ```
674
+ */
675
+ export const link = (path: string, options: { replace?: boolean } = {}): ((e: Event) => void) => {
676
+ return (e: Event) => {
677
+ e.preventDefault();
678
+ navigate(path, options);
679
+ };
680
+ };
681
+
682
+ /**
683
+ * Intercepts all link clicks within a container for client-side routing.
684
+ * Only intercepts links with matching origins and no target attribute.
685
+ *
686
+ * @param container - The container element to intercept links in
687
+ * @returns Cleanup function to remove the listener
688
+ *
689
+ * @example
690
+ * ```ts
691
+ * import { interceptLinks } from 'bquery/router';
692
+ *
693
+ * // Intercept all links in the app
694
+ * const cleanup = interceptLinks(document.body);
695
+ *
696
+ * // Later, remove the interceptor
697
+ * cleanup();
698
+ * ```
699
+ */
700
+ export const interceptLinks = (container: Element = document.body): (() => void) => {
701
+ const handler = (e: Event) => {
702
+ const target = e.target as HTMLElement;
703
+ const anchor = target.closest('a');
704
+
705
+ if (!anchor) return;
706
+ if (anchor.target) return; // Has target attribute
707
+ if (anchor.hasAttribute('download')) return;
708
+ if (anchor.origin !== window.location.origin) return; // External link
709
+
710
+ const path = anchor.pathname + anchor.search + anchor.hash;
711
+
712
+ e.preventDefault();
713
+ navigate(path);
714
+ };
715
+
716
+ container.addEventListener('click', handler);
717
+ return () => container.removeEventListener('click', handler);
718
+ };