@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/README.md +178 -0
- package/package.json +168 -0
- package/src/analyze.js +534 -0
- package/src/async-state.js +555 -0
- package/src/bundle-runtime.js +35 -0
- package/src/clarity-bundle.js +332 -0
- package/src/clarity-test.js +622 -0
- package/src/cli.js +453 -0
- package/src/codegen.js +1934 -0
- package/src/dev-server.js +362 -0
- package/src/devtools.js +765 -0
- package/src/edge.js +606 -0
- package/src/error-overlay.js +535 -0
- package/src/file-conventions.js +472 -0
- package/src/font.js +513 -0
- package/src/game-loop.js +106 -0
- package/src/head.js +393 -0
- package/src/hydrate.js +292 -0
- package/src/i18n.js +403 -0
- package/src/image.js +352 -0
- package/src/index.js +193 -0
- package/src/islands.js +284 -0
- package/src/isr.js +306 -0
- package/src/layout.js +342 -0
- package/src/lexer.js +572 -0
- package/src/linter.js +547 -0
- package/src/pages-router.js +229 -0
- package/src/parser.js +1108 -0
- package/src/router.js +732 -0
- package/src/runtime.js +1465 -0
- package/src/scoped-css.js +641 -0
- package/src/server-actions.js +439 -0
- package/src/server-data.js +225 -0
- package/src/sourcemap.js +130 -0
- package/src/ssg.js +310 -0
- package/src/ssr.js +621 -0
- package/src/store.js +276 -0
- package/src/transitions.js +438 -0
- package/src/ts-plugin.js +613 -0
- package/src/typegen.js +240 -0
- package/src/vite-plugin.js +447 -0
- package/types/index.d.ts +366 -0
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
|
+
}
|