@ozsarman/clarityjs 0.6.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/src/router.js ADDED
@@ -0,0 +1,732 @@
1
+ /**
2
+ * Clarity.js Router — v0.4 (History API)
3
+ *
4
+ * pushState-based client-side router. Clean URLs (/about, /users/42).
5
+ * SSR-compatible: works on the server with an injected initial path.
6
+ *
7
+ * ─── Features ────────────────────────────────────────────────────────────────
8
+ * • History API pushState / replaceState / popstate
9
+ * • Named params /users/:id → { id: '42' }
10
+ * • Wildcard /docs/*
11
+ * • Nested routes <Route> inside layout components
12
+ * • <Outlet> placeholder for active child route
13
+ * • Guards beforeEnter(pattern, fn) — sync or async, can redirect
14
+ * • Scroll restoration automatic scroll-to-top on navigation (configurable)
15
+ * • SSR mode renderRouter(path) → renders component tree for given path
16
+ * • back() / forward() programmatic browser history
17
+ * • View Transition API document.startViewTransition() on navigation
18
+ *
19
+ * ─── API ─────────────────────────────────────────────────────────────────────
20
+ * navigate(path, opts?) — push to history (runs guards)
21
+ * navigateReplace(path) — replace current entry (runs guards)
22
+ * back() — history.back()
23
+ * forward() — history.forward()
24
+ * currentPath() — reactive current pathname signal
25
+ * routeParams() — reactive params from last matched route
26
+ * matchRoute(pattern, path)— returns params object or null
27
+ * beforeEnter(pattern, fn) — register navigation guard
28
+ * removeGuard(id) — deregister guard
29
+ * createRouter(opts) — configure router-wide options
30
+ * setServerPath(path) — SSR: set initial path from request
31
+ * useViewTransition() — { isTransitioning, startTransition }
32
+ * Route(pattern, fn, _e, opts)
33
+ * Link({ to, children, activeClass, exact, viewTransition })
34
+ * Switch(routes, fallback, _e)
35
+ * Outlet({ routes, fallback })
36
+ *
37
+ * ─── Guard signature ─────────────────────────────────────────────────────────
38
+ * (to: { path, params, query }) => boolean | string | Promise<boolean|string>
39
+ * true / undefined → allow
40
+ * false → cancel
41
+ * '/other' → redirect
42
+ *
43
+ * Author: Claude (Anthropic) + Özdemir Sarman
44
+ */
45
+
46
+ import { signal, effect } from './runtime.js';
47
+
48
+ // ─── Router configuration ─────────────────────────────────────────────────────
49
+
50
+ const _config = {
51
+ scrollBehavior: 'top', // 'top' | 'restore' | 'none'
52
+ base: '', // base URL prefix (e.g. '/app')
53
+ trailingSlash: false, // normalize trailing slashes
54
+ viewTransition: false, // use document.startViewTransition() on navigate
55
+ };
56
+
57
+ /**
58
+ * Configure global router options.
59
+ * Call once at app startup before mounting.
60
+ *
61
+ * @param {object} opts
62
+ * @param {'top'|'restore'|'none'} [opts.scrollBehavior='top'] — scroll behaviour on navigation
63
+ * @param {string} [opts.base=''] — base URL prefix (e.g. '/app')
64
+ * @param {boolean} [opts.trailingSlash] — normalize trailing slashes
65
+ */
66
+ export function createRouter(opts = {}) {
67
+ Object.assign(_config, opts);
68
+ }
69
+
70
+ // ─── Internal state ───────────────────────────────────────────────────────────
71
+
72
+ // Reactive signal: current pathname (e.g. '/about')
73
+ const _path = signal(_getPathname());
74
+ // Reactive signal: params from last matched route
75
+ const _params = signal({});
76
+ // Query string signal: e.g. { q: 'hello' }
77
+ const _query = signal(_parseQuery(typeof window !== 'undefined' ? window.location.search : ''));
78
+
79
+ // Guards registry
80
+ let _guardId = 0;
81
+ const _guards = new Map();
82
+
83
+ // View Transition state
84
+ const _transitioning = signal(false);
85
+
86
+ // Scroll position cache for 'restore' behaviour
87
+ const _scrollCache = new Map();
88
+
89
+ // ─── Initial event listeners (browser only) ───────────────────────────────────
90
+
91
+ if (typeof window !== 'undefined') {
92
+ window.addEventListener('popstate', () => {
93
+ _path.set(_getPathname());
94
+ _query.set(_parseQuery(window.location.search));
95
+ });
96
+
97
+ // Intercept all <a href="/..."> clicks that aren't external/special.
98
+ document.addEventListener('click', (e) => {
99
+ // If a more specific handler (e.g. the <Link> component) already handled
100
+ // this click, do nothing — avoids double navigation.
101
+ if (e.defaultPrevented) return;
102
+
103
+ const a = e.target.closest('a[href]');
104
+ if (!a) return;
105
+
106
+ const href = a.getAttribute('href');
107
+ if (!href) return;
108
+
109
+ // Let browser handle external links, mailto:, tel:, pure #anchors,
110
+ // target=_blank, downloads, and modifier-clicks.
111
+ if (href.startsWith('http') || href.startsWith('//') ||
112
+ href.startsWith('mailto:') || href.startsWith('tel:') ||
113
+ href.startsWith('#') ||
114
+ a.target === '_blank' || a.hasAttribute('download') ||
115
+ e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) {
116
+ return;
117
+ }
118
+
119
+ // Handle in-app links
120
+ const url = new URL(href, window.location.origin);
121
+ if (url.origin !== window.location.origin) return;
122
+
123
+ e.preventDefault();
124
+ // Navigate by pathname + search only — a hash is an in-page anchor, not a
125
+ // route, and must never become part of the matched path.
126
+ navigate(url.pathname + url.search);
127
+ });
128
+ }
129
+
130
+ // ─── Path helpers ─────────────────────────────────────────────────────────────
131
+
132
+ function _getPathname() {
133
+ if (typeof window === 'undefined') return '/';
134
+ let p = window.location.pathname;
135
+ // Strip base prefix
136
+ if (_config.base && p.startsWith(_config.base)) {
137
+ p = p.slice(_config.base.length) || '/';
138
+ }
139
+ return _normalizePath(p);
140
+ }
141
+
142
+ function _normalizePath(p) {
143
+ if (!p || p === '') return '/';
144
+ if (!p.startsWith('/')) p = '/' + p;
145
+ if (_config.trailingSlash === false && p !== '/' && p.endsWith('/')) {
146
+ p = p.slice(0, -1);
147
+ }
148
+ return p;
149
+ }
150
+
151
+ function _parseQuery(search) {
152
+ if (!search) return {};
153
+ const q = {};
154
+ for (const [k, v] of new URLSearchParams(search)) q[k] = v;
155
+ return q;
156
+ }
157
+
158
+ function _buildHref(path) {
159
+ return _config.base + path;
160
+ }
161
+
162
+ // ─── Guard helpers ────────────────────────────────────────────────────────────
163
+
164
+ async function _runGuards(to, params = {}) {
165
+ let resolved = to;
166
+ for (const [, guard] of _guards) {
167
+ if (guard.pattern && matchRoute(guard.pattern, resolved) === null) continue;
168
+ let result;
169
+ try {
170
+ const toParams = guard.pattern ? (matchRoute(guard.pattern, resolved) ?? params) : params;
171
+ result = await guard.fn({ path: resolved, params: toParams, query: _parseQuery(window?.location?.search ?? '') });
172
+ } catch (err) {
173
+ console.error('[Clarity Router] guard threw:', err);
174
+ return null;
175
+ }
176
+ if (result === false) return null;
177
+ if (typeof result === 'string') resolved = result;
178
+ }
179
+ return resolved;
180
+ }
181
+
182
+ // ─── Public navigation API ────────────────────────────────────────────────────
183
+
184
+ /**
185
+ * Navigate to `path` using pushState. Runs guards before committing.
186
+ *
187
+ * @param {string} path — e.g. '/users/42?tab=profile'
188
+ * @param {object} [opts]
189
+ * @param {object} [opts.state] — arbitrary state stored in history entry
190
+ * @param {boolean}[opts.scroll=true] — whether to apply scrollBehavior
191
+ * @returns {Promise<boolean>} false if navigation was cancelled by a guard
192
+ */
193
+ export async function navigate(path, { state = null, scroll = true, viewTransition } = {}) {
194
+ const normalized = _normalizePath(path.split('?')[0]) +
195
+ (path.includes('?') ? '?' + path.split('?')[1] : '');
196
+
197
+ const params = matchRoute('/*', normalized.split('?')[0]) ?? {};
198
+ const resolved = await _runGuards(normalized.split('?')[0], params);
199
+ if (resolved === null) return false;
200
+
201
+ const finalPath = resolved + (normalized.includes('?') && !resolved.includes('?')
202
+ ? '?' + normalized.split('?')[1]
203
+ : '');
204
+
205
+ // Determine whether to use View Transition API
206
+ const useVT = (viewTransition ?? _config.viewTransition) &&
207
+ typeof document !== 'undefined' &&
208
+ typeof document.startViewTransition === 'function';
209
+
210
+ const commit = () => {
211
+ if (typeof window !== 'undefined') {
212
+ // Save scroll position for the page we're leaving (restore mode)
213
+ if (_config.scrollBehavior === 'restore') {
214
+ _scrollCache.set(window.location.pathname, { x: window.scrollX, y: window.scrollY });
215
+ }
216
+ window.history.pushState(state, '', _buildHref(finalPath));
217
+ }
218
+
219
+ _path.set(_normalizePath(finalPath.split('?')[0]));
220
+ _query.set(_parseQuery(finalPath.includes('?') ? finalPath.split('?')[1] : ''));
221
+
222
+ if (scroll && typeof window !== 'undefined') _applyScroll(resolved);
223
+ };
224
+
225
+ if (useVT) {
226
+ _transitioning.set(true);
227
+ const transition = document.startViewTransition(commit);
228
+ transition.finished.finally(() => _transitioning.set(false));
229
+ } else {
230
+ commit();
231
+ }
232
+
233
+ return true;
234
+ }
235
+
236
+ /**
237
+ * Replace the current history entry. Same guard/scroll logic as navigate().
238
+ * @param {string} path
239
+ * @param {object} [opts]
240
+ * @param {boolean} [opts.viewTransition] — override global viewTransition setting
241
+ */
242
+ export async function navigateReplace(path, { viewTransition } = {}) {
243
+ const resolved = await _runGuards(_normalizePath(path.split('?')[0]));
244
+ if (resolved === null) return false;
245
+
246
+ const useVT = (viewTransition ?? _config.viewTransition) &&
247
+ typeof document !== 'undefined' &&
248
+ typeof document.startViewTransition === 'function';
249
+
250
+ const commit = () => {
251
+ if (typeof window !== 'undefined') {
252
+ window.history.replaceState(null, '', _buildHref(resolved));
253
+ }
254
+ _path.set(_normalizePath(resolved.split('?')[0]));
255
+ _query.set(_parseQuery(resolved.includes('?') ? resolved.split('?')[1] : ''));
256
+ };
257
+
258
+ if (useVT) {
259
+ _transitioning.set(true);
260
+ const transition = document.startViewTransition(commit);
261
+ transition.finished.finally(() => _transitioning.set(false));
262
+ } else {
263
+ commit();
264
+ }
265
+
266
+ return true;
267
+ }
268
+
269
+ /** Go back one step in history. */
270
+ export function back() {
271
+ if (typeof window !== 'undefined') window.history.back();
272
+ }
273
+
274
+ /** Go forward one step in history. */
275
+ export function forward() {
276
+ if (typeof window !== 'undefined') window.history.forward();
277
+ }
278
+
279
+ /**
280
+ * SSR: inject the request path so server-rendered components see the right route.
281
+ * Call before rendering the component tree on the server.
282
+ *
283
+ * @param {string} path — from req.url or req.path
284
+ */
285
+ export function setServerPath(path) {
286
+ _path.set(_normalizePath(path.split('?')[0]));
287
+ _query.set(_parseQuery(path.includes('?') ? path.split('?')[1] : ''));
288
+ }
289
+
290
+ // ─── Scroll behaviour ─────────────────────────────────────────────────────────
291
+
292
+ function _applyScroll(path) {
293
+ if (_config.scrollBehavior === 'none') return;
294
+
295
+ if (_config.scrollBehavior === 'restore') {
296
+ const saved = _scrollCache.get(path);
297
+ if (saved) { window.scrollTo(saved.x, saved.y); return; }
298
+ }
299
+
300
+ // Default: scroll to top
301
+ window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
302
+ }
303
+
304
+ // ─── Guards ───────────────────────────────────────────────────────────────────
305
+
306
+ /**
307
+ * Register a navigation guard.
308
+ *
309
+ * @param {string|null} pattern — pattern to guard (null = global)
310
+ * @param {Function} guardFn — ({ path, params, query }) => bool | string | Promise<…>
311
+ * @returns {number} guard id
312
+ */
313
+ export function beforeEnter(pattern, guardFn) {
314
+ if (typeof pattern === 'function') { guardFn = pattern; pattern = null; }
315
+ const id = ++_guardId;
316
+ _guards.set(id, { pattern, fn: guardFn });
317
+ return id;
318
+ }
319
+
320
+ /** Remove a guard by id. */
321
+ export function removeGuard(id) { _guards.delete(id); }
322
+
323
+ // ─── Reactive signals (public) ────────────────────────────────────────────────
324
+
325
+ /**
326
+ * Reactive current pathname. Use .get() in effects or call as function.
327
+ * @returns {string}
328
+ */
329
+ export const currentPath = Object.assign(() => _path.get(), _path);
330
+
331
+ /**
332
+ * Reactive query params object. { q: 'hello', page: '2' }
333
+ */
334
+ export const currentQuery = Object.assign(() => _query.get(), _query);
335
+
336
+ /**
337
+ * Reactive params from the last matched route.
338
+ */
339
+ export const routeParams = Object.assign(() => _params.get(), _params);
340
+
341
+ /**
342
+ * View Transition API hook.
343
+ *
344
+ * Returns a reactive `isTransitioning` flag and a `startTransition` helper
345
+ * that wraps any callback in `document.startViewTransition()` when supported.
346
+ *
347
+ * @example
348
+ * const { isTransitioning, startTransition } = useViewTransition();
349
+ * effect(() => { if (isTransitioning.get()) body.classList.add('transitioning'); });
350
+ *
351
+ * @returns {{ isTransitioning: Signal<boolean>, startTransition: (cb: Function) => void }}
352
+ */
353
+ export function useViewTransition() {
354
+ function startTransition(callback) {
355
+ if (typeof document !== 'undefined' && typeof document.startViewTransition === 'function') {
356
+ _transitioning.set(true);
357
+ const t = document.startViewTransition(callback);
358
+ t.finished.finally(() => _transitioning.set(false));
359
+ } else {
360
+ callback();
361
+ }
362
+ }
363
+
364
+ return { isTransitioning: _transitioning, startTransition };
365
+ }
366
+
367
+ // ─── matchRoute ───────────────────────────────────────────────────────────────
368
+
369
+ /**
370
+ * Match a route pattern against a concrete path.
371
+ *
372
+ * Supports:
373
+ * /users/:id named params
374
+ * /docs/* wildcard suffix
375
+ * /a/:b/c/:d multiple params
376
+ *
377
+ * @param {string} pattern
378
+ * @param {string} path
379
+ * @returns {Record<string,string> | null}
380
+ */
381
+ export function matchRoute(pattern, path) {
382
+ // Normalize
383
+ pattern = _normalizePath(pattern);
384
+ path = _normalizePath(path.split('?')[0]);
385
+
386
+ if (pattern === '/*') return {}; // catch-all
387
+
388
+ const pp = pattern.split('/').filter(Boolean);
389
+ const ap = path.split('/').filter(Boolean);
390
+
391
+ // Wildcard at end: /docs/* matches /docs/a/b/c
392
+ const hasWildcard = pp[pp.length - 1] === '*';
393
+ if (hasWildcard) {
394
+ if (ap.length < pp.length - 1) return null;
395
+ } else {
396
+ if (pp.length !== ap.length) return null;
397
+ }
398
+
399
+ const params = {};
400
+ for (let i = 0; i < pp.length; i++) {
401
+ if (pp[i] === '*') { params['*'] = ap.slice(i).join('/'); break; }
402
+ if (pp[i].startsWith(':')) {
403
+ params[pp[i].slice(1)] = decodeURIComponent(ap[i] ?? '');
404
+ } else if (pp[i] !== ap[i]) {
405
+ return null;
406
+ }
407
+ }
408
+ return params;
409
+ }
410
+
411
+ // ─── Route component ──────────────────────────────────────────────────────────
412
+
413
+ /**
414
+ * Renders children reactively when the current path matches `pattern`.
415
+ * This is the runtime target for `<Route path="…">` in .clarity files.
416
+ *
417
+ * @param {string} pattern
418
+ * @param {Function} renderChildren — (params) => Node | Node[]
419
+ * @param {Function} [_e] — effect dispose collector
420
+ * @param {object} [opts]
421
+ * @param {boolean} [opts.exact] — exact match only (default: prefix-ok for /dash matching /dash/sub)
422
+ * @param {Function} [opts.guard] — inline sync guard
423
+ * @returns {Comment}
424
+ */
425
+ export function Route(pattern, renderChildren, _e, opts = {}) {
426
+ const { guard, exact = true } = opts;
427
+ const anchor = document.createComment(`clarity:route(${pattern})`);
428
+ let currentNodes = [];
429
+
430
+ const dispose = effect(() => {
431
+ const path = _path.get();
432
+ const params = exact
433
+ ? matchRoute(pattern, path)
434
+ : _matchPrefix(pattern, path);
435
+
436
+ // Remove previous nodes
437
+ currentNodes.forEach(n => n.parentNode?.removeChild(n));
438
+ currentNodes = [];
439
+
440
+ if (params === null) return;
441
+
442
+ // Inline sync guard
443
+ if (typeof guard === 'function') {
444
+ const r = guard({ path, params, query: _query.peek() });
445
+ if (r === false) return;
446
+ if (typeof r === 'string') { navigate(r); return; }
447
+ }
448
+
449
+ // Update global params
450
+ _params.set(params);
451
+
452
+ // Render
453
+ const nodes = [renderChildren(params)].flat(Infinity);
454
+ const parent = anchor.parentNode;
455
+ if (!parent) return;
456
+
457
+ nodes.forEach(n => {
458
+ if (n && typeof n === 'object' && (n instanceof Node || n.nodeType)) {
459
+ parent.insertBefore(n, anchor.nextSibling);
460
+ currentNodes.push(n);
461
+ }
462
+ });
463
+ });
464
+
465
+ if (_e) _e(dispose);
466
+ return anchor;
467
+ }
468
+
469
+ /** Prefix match: /dashboard matches /dashboard/stats when exact=false */
470
+ function _matchPrefix(pattern, path) {
471
+ // Try exact first
472
+ const exact = matchRoute(pattern, path);
473
+ if (exact !== null) return exact;
474
+
475
+ // Try prefix: path starts with pattern segments
476
+ const pp = pattern.split('/').filter(Boolean);
477
+ const ap = path.split('/').filter(Boolean);
478
+ if (ap.length < pp.length) return null;
479
+
480
+ const params = {};
481
+ for (let i = 0; i < pp.length; i++) {
482
+ if (pp[i].startsWith(':')) params[pp[i].slice(1)] = ap[i];
483
+ else if (pp[i] !== ap[i]) return null;
484
+ }
485
+ return params;
486
+ }
487
+
488
+ // ─── Switch component ─────────────────────────────────────────────────────────
489
+
490
+ /**
491
+ * Renders the FIRST matching route from an array of { pattern, render } entries.
492
+ * Equivalent to React Router's <Switch> / <Routes>.
493
+ *
494
+ * @param {Array<{pattern:string, render:Function}>} routes
495
+ * @param {Function} [fallback] — rendered when nothing matches (404 UI)
496
+ * @param {Function} [_e]
497
+ * @returns {Comment}
498
+ */
499
+ export function Switch(routes, fallback, _e) {
500
+ const anchor = document.createComment('clarity:switch');
501
+ let currentNodes = [];
502
+
503
+ const dispose = effect(() => {
504
+ const path = _path.get();
505
+
506
+ currentNodes.forEach(n => n.parentNode?.removeChild(n));
507
+ currentNodes = [];
508
+
509
+ let matched = false;
510
+ for (const { pattern, render } of routes) {
511
+ const params = matchRoute(pattern, path);
512
+ if (params !== null) {
513
+ _params.set(params);
514
+ [render(params)].flat(Infinity).forEach(n => {
515
+ if (n instanceof Node) {
516
+ anchor.parentNode?.insertBefore(n, anchor.nextSibling);
517
+ currentNodes.push(n);
518
+ }
519
+ });
520
+ matched = true;
521
+ break;
522
+ }
523
+ }
524
+
525
+ if (!matched && typeof fallback === 'function') {
526
+ [fallback()].flat(Infinity).forEach(n => {
527
+ if (n instanceof Node) {
528
+ anchor.parentNode?.insertBefore(n, anchor.nextSibling);
529
+ currentNodes.push(n);
530
+ }
531
+ });
532
+ }
533
+ });
534
+
535
+ if (_e) _e(dispose);
536
+ return anchor;
537
+ }
538
+
539
+ // ─── Outlet component ─────────────────────────────────────────────────────────
540
+
541
+ /**
542
+ * Slot for nested routes inside a layout component.
543
+ * Advanced mode: pass routes array explicitly for programmatic control.
544
+ *
545
+ * @param {object} [props]
546
+ * @param {Array} [props.routes] — explicit route list (advanced)
547
+ * @param {Function} [props.fallback]
548
+ * @returns {Comment}
549
+ */
550
+ export function Outlet({ routes = [], fallback = null } = {}) {
551
+ if (routes.length === 0 && !fallback) {
552
+ return document.createComment('clarity:outlet');
553
+ }
554
+ return Switch(routes, fallback ?? undefined, null);
555
+ }
556
+
557
+ // ─── Link component ───────────────────────────────────────────────────────────
558
+
559
+ /**
560
+ * Renders an `<a>` that uses navigate() (pushState) instead of full page reload.
561
+ *
562
+ * Props:
563
+ * to — target path
564
+ * children — child nodes or label string
565
+ * activeClass — class when active (default: 'clarity-link-active')
566
+ * exact — exact path match for active state (default: false)
567
+ * replace — use replaceState instead of pushState
568
+ *
569
+ * @param {object} props
570
+ * @returns {HTMLAnchorElement}
571
+ */
572
+ export function Link({
573
+ to,
574
+ label,
575
+ children,
576
+ className = undefined, // base CSS class(es) for the <a>
577
+ 'class': klass = undefined, // allow `class="..."` from .clarity JSX
578
+ activeClass = 'clarity-link-active',
579
+ exact = false,
580
+ replace = false,
581
+ prefetch = true, // hover-prefetch the route chunk (requires code splitting)
582
+ viewTransition = undefined, // override global viewTransition setting per-link
583
+ } = {}) {
584
+ const a = document.createElement('a');
585
+ a.href = _buildHref(to);
586
+
587
+ // Forward an author-supplied class so links can be styled (active class is
588
+ // toggled separately in the effect below).
589
+ const _baseClass = className ?? klass;
590
+ if (_baseClass) a.className = _baseClass;
591
+
592
+ if (Array.isArray(children)) {
593
+ children.forEach(c => {
594
+ if (c instanceof Node) a.appendChild(c);
595
+ else a.appendChild(document.createTextNode(String(c)));
596
+ });
597
+ } else if (label) {
598
+ a.textContent = label;
599
+ }
600
+
601
+ // Use navigate() so pushState fires instead of a page reload
602
+ a.addEventListener('click', (e) => {
603
+ // Let browser handle modifier-clicks naturally
604
+ if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
605
+ e.preventDefault();
606
+ if (replace) {
607
+ navigateReplace(to, { viewTransition });
608
+ } else {
609
+ navigate(to, { viewTransition });
610
+ }
611
+ });
612
+
613
+ // Hover prefetch: start loading the route chunk before the user clicks.
614
+ // Only fires once per link element (idempotent).
615
+ if (prefetch) {
616
+ let _prefetched = false;
617
+ a.addEventListener('mouseenter', () => {
618
+ if (_prefetched) return;
619
+ _prefetched = true;
620
+ prefetchRoute(to).catch(() => {});
621
+ }, { passive: true });
622
+ }
623
+
624
+ // Reactive active class
625
+ effect(() => {
626
+ const current = _path.get();
627
+ const active = exact
628
+ ? current === _normalizePath(to)
629
+ : (current === _normalizePath(to) || current.startsWith(_normalizePath(to) + '/'));
630
+ a.classList.toggle(activeClass, active);
631
+ a.setAttribute('aria-current', active ? 'page' : 'false');
632
+ });
633
+
634
+ return a;
635
+ }
636
+
637
+ // ─── SSR helpers ─────────────────────────────────────────────────────────────
638
+
639
+ /**
640
+ * Render the component tree for a specific server-side path.
641
+ * Call before renderToString/renderToDocument on the server.
642
+ *
643
+ * @param {string} path — e.g. '/users/42'
644
+ * @param {Function} componentFn — root component function
645
+ * @param {object} [props] — additional props
646
+ * @returns {Node}
647
+ */
648
+ export function renderWithPath(path, componentFn, props = {}) {
649
+ setServerPath(path);
650
+ return componentFn(props);
651
+ }
652
+
653
+ /**
654
+ * Express / Fastify middleware factory.
655
+ * Injects the request path into the Clarity router before SSR rendering.
656
+ *
657
+ * @returns {Function} Express middleware (req, res, next) => void
658
+ */
659
+ export function createSSRMiddleware() {
660
+ return function clarityRouterMiddleware(req, res, next) {
661
+ setServerPath(req.path ?? req.url ?? '/');
662
+ next();
663
+ };
664
+ }
665
+
666
+ // ─── Code-splitting prefetch ──────────────────────────────────────────────────
667
+
668
+ /**
669
+ * Route chunk registry — populated by the app when using code splitting.
670
+ * Maps route path patterns to dynamic import functions.
671
+ *
672
+ * registerRouteChunk('/about', () => import('./pages/about.js'));
673
+ * registerRouteChunk('/users/:id', () => import('./pages/users/[id].js'));
674
+ */
675
+ const _routeRegistry = new Map(); // pattern → importFn
676
+
677
+ export function registerRouteChunk(pattern, importFn) {
678
+ _routeRegistry.set(pattern, importFn);
679
+ }
680
+
681
+ /**
682
+ * Prefetch the JavaScript chunk for a route.
683
+ * Uses <link rel="modulepreload"> if the chunk URL is known from the manifest,
684
+ * otherwise calls import() to start fetching.
685
+ *
686
+ * @param {string} path — route path to prefetch (e.g. '/about')
687
+ */
688
+ export async function prefetchRoute(path) {
689
+ const normalized = _normalizePath(path.split('?')[0]);
690
+
691
+ // Try manifest-based modulepreload first (injected by Vite plugin at build time)
692
+ const manifest = globalThis.__clarityRouteManifest__;
693
+ if (manifest) {
694
+ for (const [pattern, entry] of Object.entries(manifest)) {
695
+ if (matchRoute(pattern, normalized)) {
696
+ _injectModulepreload(entry.chunk);
697
+ for (const dep of entry.imports ?? []) _injectModulepreload(dep);
698
+ return;
699
+ }
700
+ }
701
+ }
702
+
703
+ // Fallback: trigger dynamic import() for the matching registered chunk
704
+ for (const [pattern, importFn] of _routeRegistry) {
705
+ if (matchRoute(pattern, normalized)) {
706
+ try { await importFn(); } catch (_) {}
707
+ return;
708
+ }
709
+ }
710
+ }
711
+
712
+ function _injectModulepreload(src) {
713
+ if (!src || typeof document === 'undefined') return;
714
+ const abs = src.startsWith('/') ? src : '/' + src;
715
+ if (document.querySelector(`link[rel="modulepreload"][href="${abs}"]`)) return;
716
+ const link = document.createElement('link');
717
+ link.rel = 'modulepreload';
718
+ link.href = abs;
719
+ document.head.appendChild(link);
720
+ }
721
+
722
+ /**
723
+ * Inject <link rel="modulepreload"> tags for the current route.
724
+ * Call this after the app mounts to preload the current page's chunk
725
+ * and trigger prefetching of likely-next pages.
726
+ *
727
+ * @param {string} [path] — defaults to currentPath().get()
728
+ */
729
+ export function injectModulepreloads(path) {
730
+ const p = path ?? _path.get();
731
+ prefetchRoute(p); // fire and forget
732
+ }