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