tova 0.7.0 → 0.9.4

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.
Files changed (59) hide show
  1. package/bin/tova.js +1312 -139
  2. package/package.json +8 -1
  3. package/src/analyzer/analyzer.js +539 -11
  4. package/src/analyzer/browser-analyzer.js +56 -8
  5. package/src/analyzer/deploy-analyzer.js +44 -0
  6. package/src/analyzer/scope.js +7 -0
  7. package/src/analyzer/server-analyzer.js +33 -1
  8. package/src/codegen/base-codegen.js +1296 -23
  9. package/src/codegen/browser-codegen.js +725 -20
  10. package/src/codegen/codegen.js +87 -5
  11. package/src/codegen/deploy-codegen.js +49 -0
  12. package/src/codegen/server-codegen.js +54 -6
  13. package/src/codegen/shared-codegen.js +5 -0
  14. package/src/codegen/theme-codegen.js +69 -0
  15. package/src/codegen/wasm-codegen.js +6 -0
  16. package/src/config/edit-toml.js +6 -2
  17. package/src/config/git-resolver.js +128 -0
  18. package/src/config/lock-file.js +57 -0
  19. package/src/config/module-cache.js +58 -0
  20. package/src/config/module-entry.js +37 -0
  21. package/src/config/module-path.js +63 -0
  22. package/src/config/pkg-errors.js +62 -0
  23. package/src/config/resolve.js +26 -0
  24. package/src/config/resolver.js +139 -0
  25. package/src/config/search.js +28 -0
  26. package/src/config/semver.js +72 -0
  27. package/src/config/toml.js +61 -6
  28. package/src/deploy/deploy.js +217 -0
  29. package/src/deploy/infer.js +218 -0
  30. package/src/deploy/provision.js +315 -0
  31. package/src/diagnostics/security-scorecard.js +111 -0
  32. package/src/lexer/lexer.js +18 -3
  33. package/src/lsp/server.js +482 -0
  34. package/src/parser/animate-ast.js +45 -0
  35. package/src/parser/ast.js +39 -0
  36. package/src/parser/browser-ast.js +19 -1
  37. package/src/parser/browser-parser.js +221 -4
  38. package/src/parser/concurrency-ast.js +15 -0
  39. package/src/parser/concurrency-parser.js +236 -0
  40. package/src/parser/deploy-ast.js +37 -0
  41. package/src/parser/deploy-parser.js +132 -0
  42. package/src/parser/parser.js +42 -5
  43. package/src/parser/select-ast.js +39 -0
  44. package/src/parser/theme-ast.js +29 -0
  45. package/src/parser/theme-parser.js +70 -0
  46. package/src/registry/plugins/concurrency-plugin.js +32 -0
  47. package/src/registry/plugins/deploy-plugin.js +33 -0
  48. package/src/registry/plugins/theme-plugin.js +20 -0
  49. package/src/registry/register-all.js +6 -0
  50. package/src/runtime/charts.js +547 -0
  51. package/src/runtime/embedded.js +6 -2
  52. package/src/runtime/reactivity.js +60 -0
  53. package/src/runtime/router.js +703 -295
  54. package/src/runtime/table.js +606 -33
  55. package/src/stdlib/inline.js +365 -10
  56. package/src/stdlib/runtime-bridge.js +152 -0
  57. package/src/stdlib/string.js +84 -2
  58. package/src/stdlib/validation.js +1 -1
  59. package/src/version.js +1 -1
@@ -1,191 +1,662 @@
1
- // Client-side router for Tova — integrated with the signal system
2
- // Route changes are reactive: components that read route() or params() auto-update.
1
+ // Client-side router for Tova — class-based, signal-integrated, production-grade.
2
+ // Route changes are reactive: components that read route signals auto-update.
3
3
 
4
4
  import { createSignal, tova_el, tova_fragment, createEffect, onCleanup } from './reactivity.js';
5
5
 
6
- // ─── Route Signal ─────────────────────────────────────────
7
- // The route is a signal, so any component/effect that reads it
8
- // will automatically re-run when the route changes.
9
-
10
- const [route, setRoute] = createSignal({
11
- path: '/',
12
- pattern: null,
13
- component: null,
14
- params: {},
15
- query: {},
16
- });
17
-
18
- let routeDefinitions = [];
19
- let routeChangeCallbacks = [];
20
- let notFoundComponent = null;
21
- let beforeNavigateHooks = [];
22
- let afterNavigateHooks = [];
6
+ // ─── Active Router Instance ──────────────────────────────
7
+ let _activeRouter = null;
23
8
 
24
9
  // ─── Path Validation ─────────────────────────────────────
25
- // Reject absolute URLs, protocol-relative URLs, and javascript: URIs
26
- // to prevent open redirects and XSS.
27
-
28
10
  function isValidPath(path) {
29
11
  if (typeof path !== 'string') return false;
30
- // Reject protocol-relative URLs (//evil.com)
31
12
  if (path.startsWith('//')) return false;
32
- // Reject absolute URLs with schemes (http:, javascript:, data:, etc.)
33
13
  if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(path)) return false;
34
- // Must start with / or be a relative path segment
35
14
  return true;
36
15
  }
37
16
 
38
- // ─── Public API ───────────────────────────────────────────
17
+ // ─── Query String Helpers ────────────────────────────────
18
+ function parseQueryString(search) {
19
+ const query = {};
20
+ if (!search || search === '?') return query;
21
+ const str = search.startsWith('?') ? search.slice(1) : search;
22
+ for (const pair of str.split('&')) {
23
+ const [key, ...rest] = pair.split('=');
24
+ const value = rest.join('=');
25
+ if (key) {
26
+ const decodedKey = decodeURIComponent(key);
27
+ const decodedValue = value !== undefined ? decodeURIComponent(value) : '';
28
+ // Support repeated keys as arrays
29
+ if (decodedKey in query) {
30
+ if (Array.isArray(query[decodedKey])) {
31
+ query[decodedKey].push(decodedValue);
32
+ } else {
33
+ query[decodedKey] = [query[decodedKey], decodedValue];
34
+ }
35
+ } else {
36
+ query[decodedKey] = decodedValue;
37
+ }
38
+ }
39
+ }
40
+ return query;
41
+ }
39
42
 
40
- export function defineRoutes(routeMap) {
41
- routeDefinitions = [];
42
- const entries = Object.entries(routeMap);
43
- for (const [path, value] of entries) {
44
- // Special 404 route
45
- if (path === '404' || path === '*') {
46
- const component = (typeof value === 'object' && value !== null && value.component) ? value.component : value;
47
- notFoundComponent = component;
48
- // Catch-all '*' still gets a regex pattern for matching
49
- if (path === '*') {
50
- routeDefinitions.push({ path, pattern: /^(.*)$/, component, isCatchAll: true, children: null });
43
+ function serializeQuery(query) {
44
+ if (!query || typeof query !== 'object') return '';
45
+ const parts = [];
46
+ for (const [key, value] of Object.entries(query)) {
47
+ if (Array.isArray(value)) {
48
+ for (const v of value) {
49
+ parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(v));
51
50
  }
52
- continue;
51
+ } else if (value === true) {
52
+ parts.push(encodeURIComponent(key));
53
+ } else if (value !== undefined && value !== null && value !== false) {
54
+ parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(value));
53
55
  }
56
+ }
57
+ return parts.length ? '?' + parts.join('&') : '';
58
+ }
54
59
 
55
- if (typeof value === 'object' && value !== null && value.component) {
56
- // Nested route definition: { component: Layout, children: { "/": Home, "/settings": Settings } }
57
- const parentDef = {
58
- path,
59
- pattern: pathToRegex(path, true), // prefix match for parent
60
- component: value.component,
61
- isCatchAll: false,
62
- children: [],
63
- };
64
- if (value.children) {
65
- for (const [childPath, childComponent] of Object.entries(value.children)) {
66
- const fullPath = childPath === '/' ? path : path + childPath;
67
- parentDef.children.push({
68
- path: fullPath,
69
- relativePath: childPath,
70
- pattern: pathToRegex(fullPath),
71
- component: childComponent,
72
- isCatchAll: false,
73
- children: null,
74
- });
75
- }
60
+ // ─── Path Regex Conversion ─────────────────────────────
61
+ function pathToRegex(path, prefixMatch) {
62
+ // Split on dynamic segments (:param, :param?, *) and escape static parts
63
+ const segments = path.split(/(:[\w]+\??|\*)/);
64
+ let pattern = '';
65
+ for (const segment of segments) {
66
+ if (!segment) continue;
67
+ if (segment === '*') {
68
+ pattern += '(.*)';
69
+ } else if (segment.startsWith(':')) {
70
+ if (segment.endsWith('?')) {
71
+ pattern += '([^/]*)?';
72
+ } else {
73
+ pattern += '([^/]+)';
76
74
  }
77
- routeDefinitions.push(parentDef);
78
75
  } else {
79
- routeDefinitions.push({
80
- path,
81
- pattern: pathToRegex(path),
82
- component: value,
83
- isCatchAll: false,
84
- children: null,
85
- });
76
+ // Escape regex special chars in static path segments
77
+ pattern += segment.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
86
78
  }
87
79
  }
88
- // Match initial route
89
- handleRouteChange();
80
+ if (prefixMatch) {
81
+ return new RegExp('^' + pattern + '(?:/.*)?$');
82
+ }
83
+ return new RegExp('^' + pattern + '$');
90
84
  }
91
85
 
92
- export function navigate(path) {
93
- // Validate path first — reject unsafe URLs regardless of environment
94
- if (!isValidPath(path)) {
95
- if (typeof console !== 'undefined') {
96
- console.warn('Tova router: Blocked navigation to unsafe path: ' + path);
86
+ function extractParams(routePath, match) {
87
+ const params = {};
88
+ if (!match) return params;
89
+ const paramNames = (routePath.match(/:([a-zA-Z_]+)\??/g) || [])
90
+ .map(p => p.replace(/^:/, '').replace(/\?$/, ''));
91
+ for (let i = 0; i < paramNames.length; i++) {
92
+ const val = match[i + 1];
93
+ if (val !== undefined && val !== '') {
94
+ params[paramNames[i]] = decodeURIComponent(val);
97
95
  }
98
- return;
99
96
  }
100
- // Normalize: ensure path starts with /
101
- const normalizedPath = path.startsWith('/') ? path : '/' + path;
102
-
103
- // Run beforeNavigate hooks — any returning false cancels navigation
104
- const from = route();
105
- for (const hook of beforeNavigateHooks) {
106
- const result = hook(from, normalizedPath);
107
- if (result === false) return;
108
- // If hook returns a string, redirect to that path instead
109
- if (typeof result === 'string' && isValidPath(result)) {
110
- if (typeof window !== 'undefined') {
111
- window.history.pushState({}, '', result);
97
+ // Handle catch-all wildcard
98
+ if (routePath.includes('*') && match[paramNames.length + 1] !== undefined) {
99
+ params['*'] = match[paramNames.length + 1];
100
+ }
101
+ return params;
102
+ }
103
+
104
+ // ─── Lazy Route Marker ───────────────────────────────────
105
+ export function lazy(importFn) {
106
+ return { __lazy: true, load: importFn, _cached: null, _error: null };
107
+ }
108
+
109
+ // ─── TovRouter Class ─────────────────────────────────────
110
+ class TovRouter {
111
+ constructor(config = {}) {
112
+ // Config
113
+ this.base = (config.base || '').replace(/\/$/, '');
114
+ this.scrollBehavior = config.scroll || 'auto';
115
+ this.loadingComponent = config.loading || null;
116
+ this.errorComponent = config.error || null;
117
+
118
+ // Signals
119
+ const [routeSignal, setRouteSignal] = createSignal({
120
+ path: '/',
121
+ pattern: null,
122
+ component: null,
123
+ params: {},
124
+ query: {},
125
+ meta: {},
126
+ });
127
+ this._route = routeSignal;
128
+ this._setRoute = setRouteSignal;
129
+
130
+ const [childRoute, setChildRoute] = createSignal(null);
131
+ this._childRoute = childRoute;
132
+ this._setChildRoute = setChildRoute;
133
+
134
+ const [isLoadingSignal, setIsLoadingSignal] = createSignal(false);
135
+ this._isLoading = isLoadingSignal;
136
+ this._setIsLoading = setIsLoadingSignal;
137
+
138
+ // Route definitions
139
+ this._routeDefinitions = [];
140
+ this._notFoundComponent = null;
141
+
142
+ // Hooks
143
+ this._beforeHooks = [];
144
+ this._afterHooks = [];
145
+ this._onChangeCallbacks = [];
146
+
147
+ // Scroll positions (keyed by history state key or path)
148
+ this._scrollPositions = new Map();
149
+ this._historyIndex = 0;
150
+
151
+ // Redirect loop protection
152
+ this._redirectCount = 0;
153
+ this._redirectWindowStart = 0;
154
+
155
+ // Bound handlers for cleanup
156
+ this._popstateHandler = null;
157
+ this._clickHandler = null;
158
+
159
+ // Process routes if provided
160
+ if (config.routes) {
161
+ this._processRoutes(config.routes);
162
+ }
163
+
164
+ // Setup browser listeners
165
+ this._setupBrowserListeners();
166
+
167
+ // Register as active router
168
+ _activeRouter = this;
169
+
170
+ // Match initial route
171
+ this._handleRouteChange();
172
+ }
173
+
174
+ // ─── Signal Getters (reactive) ───────────────────────────
175
+ get path() { return () => this._route().path; }
176
+ get params() { return () => this._route().params; }
177
+ get query() { return () => this._route().query; }
178
+ get meta() { return () => this._route().meta; }
179
+ get route() { return this._route; }
180
+ get loading() { return this._isLoading; }
181
+
182
+ // ─── Navigation ──────────────────────────────────────────
183
+ navigate(path, options = {}) {
184
+ if (!isValidPath(path)) {
185
+ if (typeof console !== 'undefined') {
186
+ console.warn('Tova router: Blocked navigation to unsafe path: ' + path);
112
187
  }
113
- handleRouteChange();
114
188
  return;
115
189
  }
190
+
191
+ // Parse path for query string
192
+ let normalizedPath = path.startsWith('/') ? path : '/' + path;
193
+ let queryString = '';
194
+
195
+ // Extract query from path if present
196
+ const qIdx = normalizedPath.indexOf('?');
197
+ if (qIdx !== -1) {
198
+ queryString = normalizedPath.slice(qIdx);
199
+ normalizedPath = normalizedPath.slice(0, qIdx);
200
+ }
201
+
202
+ // Merge query option
203
+ if (options.query) {
204
+ const serialized = serializeQuery(options.query);
205
+ queryString = serialized || queryString;
206
+ }
207
+
208
+ // Extract hash
209
+ let hash = '';
210
+ const hashIdx = normalizedPath.indexOf('#');
211
+ if (hashIdx !== -1) {
212
+ hash = normalizedPath.slice(hashIdx);
213
+ normalizedPath = normalizedPath.slice(0, hashIdx);
214
+ }
215
+
216
+ // Run beforeNavigate hooks
217
+ const from = this._route();
218
+ const to = normalizedPath;
219
+ for (const hook of this._beforeHooks) {
220
+ const result = hook(from, to);
221
+ if (result === false) return;
222
+ if (typeof result === 'string' && isValidPath(result)) {
223
+ // Redirect
224
+ this._pushState(result, options);
225
+ this._handleRouteChange();
226
+ return;
227
+ }
228
+ }
229
+
230
+ // Save scroll position before navigating
231
+ this._saveScrollPosition();
232
+
233
+ // Push or replace history state
234
+ const fullUrl = this.base + normalizedPath + queryString + hash;
235
+ const state = { __tova_idx: ++this._historyIndex, ...(options.state || {}) };
236
+
237
+ if (typeof window !== 'undefined' && window.history) {
238
+ if (options.replace) {
239
+ window.history.replaceState(state, '', fullUrl);
240
+ } else {
241
+ window.history.pushState(state, '', fullUrl);
242
+ }
243
+ }
244
+
245
+ this._handleRouteChange();
246
+
247
+ // Restore or reset scroll
248
+ if (!options.replace) {
249
+ this._restoreScrollPosition(normalizedPath, false);
250
+ }
116
251
  }
117
252
 
118
- if (typeof window !== 'undefined') {
119
- window.history.pushState({}, '', normalizedPath);
253
+ back() {
254
+ if (typeof window !== 'undefined' && window.history) {
255
+ window.history.back();
256
+ }
120
257
  }
121
- handleRouteChange();
122
- }
123
258
 
124
- export function getCurrentRoute() {
125
- return route; // returns the signal getter
126
- }
259
+ forward() {
260
+ if (typeof window !== 'undefined' && window.history) {
261
+ window.history.forward();
262
+ }
263
+ }
127
264
 
128
- export function getParams() {
129
- return () => route().params;
130
- }
265
+ // ─── Active State ────────────────────────────────────────
266
+ isActive(path, exact = false) {
267
+ return () => {
268
+ const currentPath = this._route().path;
269
+ if (exact) return currentPath === path;
270
+ if (path === '/') return currentPath === '/';
271
+ return currentPath === path || currentPath.startsWith(path + '/');
272
+ };
273
+ }
131
274
 
132
- export function getPath() {
133
- return () => route().path;
134
- }
275
+ // ─── Navigation Guards ───────────────────────────────────
276
+ beforeNavigate(callback) {
277
+ this._beforeHooks.push(callback);
278
+ return () => {
279
+ const idx = this._beforeHooks.indexOf(callback);
280
+ if (idx !== -1) this._beforeHooks.splice(idx, 1);
281
+ };
282
+ }
135
283
 
136
- export function getQuery() {
137
- return () => route().query;
138
- }
284
+ afterNavigate(callback) {
285
+ this._afterHooks.push(callback);
286
+ return () => {
287
+ const idx = this._afterHooks.indexOf(callback);
288
+ if (idx !== -1) this._afterHooks.splice(idx, 1);
289
+ };
290
+ }
139
291
 
140
- // Legacy callback API (still works alongside signals)
141
- export function onRouteChange(callback) {
142
- routeChangeCallbacks.push(callback);
143
- }
292
+ onRouteChange(callback) {
293
+ this._onChangeCallbacks.push(callback);
294
+ return () => {
295
+ const idx = this._onChangeCallbacks.indexOf(callback);
296
+ if (idx !== -1) this._onChangeCallbacks.splice(idx, 1);
297
+ };
298
+ }
144
299
 
145
- // ─── Navigation Guards ───────────────────────────────────
146
- // beforeNavigate: called before route changes. Return false to cancel,
147
- // return a string to redirect, return true/undefined to proceed.
148
- // afterNavigate: called after route has changed.
300
+ // ─── Cleanup ─────────────────────────────────────────────
301
+ destroy() {
302
+ if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {
303
+ if (this._popstateHandler) window.removeEventListener('popstate', this._popstateHandler);
304
+ if (this._clickHandler && typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
305
+ document.removeEventListener('click', this._clickHandler);
306
+ }
307
+ }
308
+ this._routeDefinitions = [];
309
+ this._notFoundComponent = null;
310
+ this._beforeHooks = [];
311
+ this._afterHooks = [];
312
+ this._onChangeCallbacks = [];
313
+ this._scrollPositions.clear();
314
+ if (_activeRouter === this) _activeRouter = null;
315
+ }
149
316
 
150
- export function beforeNavigate(callback) {
151
- beforeNavigateHooks.push(callback);
152
- // Return unsubscribe function
153
- return () => {
154
- const idx = beforeNavigateHooks.indexOf(callback);
155
- if (idx !== -1) beforeNavigateHooks.splice(idx, 1);
156
- };
317
+ // ─── Route Processing ────────────────────────────────────
318
+ _processRoutes(routeMap) {
319
+ this._routeDefinitions = [];
320
+ this._notFoundComponent = null;
321
+ const entries = Object.entries(routeMap);
322
+ for (const [path, value] of entries) {
323
+ // Handle lazy routes
324
+ const isLazy = value && value.__lazy;
325
+ const isObjectConfig = typeof value === 'object' && value !== null && !isLazy;
326
+
327
+ // Special 404 route
328
+ if (path === '404' || path === '*') {
329
+ const component = (isObjectConfig && value.component) ? value.component : value;
330
+ this._notFoundComponent = component;
331
+ if (path === '*') {
332
+ this._routeDefinitions.push({
333
+ path, pattern: /^(.*)$/, component, isCatchAll: true,
334
+ children: null, meta: (isObjectConfig && value.meta) || {},
335
+ });
336
+ }
337
+ continue;
338
+ }
339
+
340
+ if (isObjectConfig && value.component) {
341
+ // Nested or metadata route
342
+ const meta = value.meta || {};
343
+ const parentDef = {
344
+ path,
345
+ pattern: pathToRegex(path, !!value.children),
346
+ component: value.component,
347
+ isCatchAll: false,
348
+ children: [],
349
+ meta,
350
+ };
351
+ if (value.children) {
352
+ for (const [childPath, childValue] of Object.entries(value.children)) {
353
+ const fullPath = childPath === '/' ? path : path + childPath;
354
+ const childIsLazy = childValue && childValue.__lazy;
355
+ const childIsObj = typeof childValue === 'object' && childValue !== null && !childIsLazy;
356
+ parentDef.children.push({
357
+ path: fullPath,
358
+ relativePath: childPath,
359
+ pattern: pathToRegex(fullPath),
360
+ component: childIsObj ? childValue.component : childValue,
361
+ isCatchAll: false,
362
+ children: null,
363
+ meta: (childIsObj && childValue.meta) || {},
364
+ });
365
+ }
366
+ }
367
+ this._routeDefinitions.push(parentDef);
368
+ } else {
369
+ // Simple route or lazy route
370
+ this._routeDefinitions.push({
371
+ path,
372
+ pattern: pathToRegex(path),
373
+ component: value,
374
+ isCatchAll: false,
375
+ children: null,
376
+ meta: {},
377
+ });
378
+ }
379
+ }
380
+ }
381
+
382
+ // ─── Route Matching ──────────────────────────────────────
383
+ _matchRoute(path) {
384
+ for (const def of this._routeDefinitions) {
385
+ if (def.children && def.children.length > 0) {
386
+ for (const child of def.children) {
387
+ const childMatch = child.pattern.exec(path);
388
+ if (childMatch) {
389
+ const childParams = extractParams(child.path, childMatch);
390
+ this._setChildRoute({ component: child.component, params: childParams, meta: child.meta });
391
+ const parentMatch = def.pattern.exec(path);
392
+ const parentParams = extractParams(def.path, parentMatch || []);
393
+ return {
394
+ path: def.path,
395
+ component: def.component,
396
+ params: { ...parentParams, ...childParams },
397
+ meta: { ...def.meta, ...child.meta },
398
+ };
399
+ }
400
+ }
401
+ const parentMatch = def.pattern.exec(path);
402
+ if (parentMatch) {
403
+ this._setChildRoute(null);
404
+ const params = extractParams(def.path, parentMatch);
405
+ return { path: def.path, component: def.component, params, meta: def.meta };
406
+ }
407
+ } else {
408
+ const match = def.pattern.exec(path);
409
+ if (match) {
410
+ this._setChildRoute(null);
411
+ const params = extractParams(def.path, match);
412
+ return { path: def.path, component: def.component, params, meta: def.meta };
413
+ }
414
+ }
415
+ }
416
+ this._setChildRoute(null);
417
+ return null;
418
+ }
419
+
420
+ // ─── Route Change Handler ────────────────────────────────
421
+ _handleRouteChange() {
422
+ let path = '/';
423
+ let query = {};
424
+ if (typeof window !== 'undefined' && window.location) {
425
+ path = window.location.pathname;
426
+ query = parseQueryString(window.location.search);
427
+ // Strip base prefix
428
+ if (this.base && path.startsWith(this.base)) {
429
+ path = path.slice(this.base.length) || '/';
430
+ }
431
+ }
432
+
433
+ const matched = this._matchRoute(path);
434
+
435
+ if (matched) {
436
+ // Handle lazy routes
437
+ if (matched.component && matched.component.__lazy) {
438
+ this._loadLazyRoute(matched, query);
439
+ return;
440
+ }
441
+ this._setRoute({ ...matched, query });
442
+ } else if (this._notFoundComponent) {
443
+ this._setRoute({ path, pattern: null, component: this._notFoundComponent, params: {}, query, meta: {} });
444
+ } else {
445
+ this._setRoute({ path, pattern: null, component: null, params: {}, query, meta: {} });
446
+ }
447
+
448
+ // Fire callbacks
449
+ for (const cb of this._onChangeCallbacks) {
450
+ cb(matched);
451
+ }
452
+
453
+ // Run afterNavigate hooks
454
+ const currentRoute = this._route();
455
+ for (const hook of this._afterHooks) {
456
+ hook(currentRoute);
457
+ }
458
+ }
459
+
460
+ // ─── Lazy Loading ────────────────────────────────────────
461
+ _loadLazyRoute(matched, query) {
462
+ const lazyDef = matched.component;
463
+
464
+ // If already cached, use it
465
+ if (lazyDef._cached) {
466
+ this._setRoute({ ...matched, component: lazyDef._cached, query });
467
+ this._firePostNavigateHooks(matched);
468
+ return;
469
+ }
470
+
471
+ // Show loading state
472
+ this._setIsLoading(true);
473
+ if (this.loadingComponent) {
474
+ this._setRoute({ ...matched, component: this.loadingComponent, query });
475
+ }
476
+
477
+ const loadPromise = lazyDef.load();
478
+ loadPromise.then((module) => {
479
+ const component = module.default || module.Page || module;
480
+ lazyDef._cached = component;
481
+ this._setIsLoading(false);
482
+ this._setRoute({ ...matched, component, query });
483
+ this._firePostNavigateHooks(matched);
484
+ }).catch((err) => {
485
+ lazyDef._error = err;
486
+ this._setIsLoading(false);
487
+ if (this.errorComponent) {
488
+ this._setRoute({ ...matched, component: this.errorComponent, query });
489
+ } else {
490
+ if (typeof console !== 'undefined') {
491
+ console.error('Tova router: Failed to load lazy route:', err);
492
+ }
493
+ this._setRoute({ ...matched, component: null, query });
494
+ }
495
+ this._firePostNavigateHooks(matched);
496
+ });
497
+ }
498
+
499
+ _firePostNavigateHooks(matched) {
500
+ for (const cb of this._onChangeCallbacks) {
501
+ cb(matched);
502
+ }
503
+ const currentRoute = this._route();
504
+ for (const hook of this._afterHooks) {
505
+ hook(currentRoute);
506
+ }
507
+ }
508
+
509
+ // ─── Scroll Restoration ──────────────────────────────────
510
+ _saveScrollPosition() {
511
+ if (typeof window === 'undefined' || !window.location) return;
512
+ const key = window.location.pathname + window.location.search;
513
+ this._scrollPositions.set(key, { x: window.scrollX, y: window.scrollY });
514
+ // Cap stored positions to prevent memory leak
515
+ if (this._scrollPositions.size > 200) {
516
+ const firstKey = this._scrollPositions.keys().next().value;
517
+ this._scrollPositions.delete(firstKey);
518
+ }
519
+ }
520
+
521
+ _restoreScrollPosition(path, isBack) {
522
+ if (typeof window === 'undefined') return;
523
+ if (this.scrollBehavior === 'none') return;
524
+
525
+ const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (fn) => fn();
526
+ const scrollTo = typeof window.scrollTo === 'function' ? (x, y) => window.scrollTo(x, y) : () => {};
527
+
528
+ if (typeof this.scrollBehavior === 'function') {
529
+ const saved = this._scrollPositions.get(path) || null;
530
+ const result = this.scrollBehavior({ savedPosition: saved, to: path });
531
+ if (result) {
532
+ raf(() => scrollTo(result.x || 0, result.y || 0));
533
+ }
534
+ return;
535
+ }
536
+
537
+ // Default "auto" behavior
538
+ if (isBack) {
539
+ const saved = this._scrollPositions.get(path);
540
+ if (saved) {
541
+ raf(() => scrollTo(saved.x, saved.y));
542
+ return;
543
+ }
544
+ }
545
+ // New navigation: scroll to top
546
+ raf(() => scrollTo(0, 0));
547
+ }
548
+
549
+ // ─── Browser Listeners ───────────────────────────────────
550
+ _setupBrowserListeners() {
551
+ if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
552
+
553
+ this._popstateHandler = () => {
554
+ const from = this._route();
555
+ const toPath = window.location.pathname;
556
+
557
+ for (const hook of this._beforeHooks) {
558
+ const result = hook(from, toPath);
559
+ if (result === false) {
560
+ // Cancel: restore the previous URL without triggering another popstate
561
+ window.history.pushState({}, '', this.base + from.path);
562
+ return;
563
+ }
564
+ }
565
+
566
+ this._handleRouteChange();
567
+ // Restore scroll on back/forward
568
+ let path = toPath;
569
+ if (this.base && path.startsWith(this.base)) {
570
+ path = path.slice(this.base.length) || '/';
571
+ }
572
+ this._restoreScrollPosition(path, true);
573
+ };
574
+ window.addEventListener('popstate', this._popstateHandler);
575
+
576
+ this._clickHandler = (e) => {
577
+ const link = e.target.closest('a[href]');
578
+ if (!link) return;
579
+ if (!link.href.startsWith(window.location.origin)) return;
580
+ if (link.target === '_blank') return;
581
+ if (typeof link.hasAttribute === 'function' && link.hasAttribute('download')) return;
582
+ if (typeof link.getAttribute === 'function') {
583
+ const rel = link.getAttribute('rel');
584
+ if (rel && rel.includes('external')) return;
585
+ }
586
+ // Skip if meta/ctrl/shift click (open in new tab)
587
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
588
+
589
+ e.preventDefault();
590
+ try {
591
+ const url = new URL(link.href);
592
+ let navPath = url.pathname;
593
+ // Strip base prefix for internal navigation
594
+ if (this.base && navPath.startsWith(this.base)) {
595
+ navPath = navPath.slice(this.base.length) || '/';
596
+ }
597
+ this.navigate(navPath + (url.search || '') + (url.hash || ''));
598
+ } catch (_) {
599
+ const href = typeof link.getAttribute === 'function' ? link.getAttribute('href') : link.href;
600
+ if (href && isValidPath(href)) this.navigate(href);
601
+ }
602
+ };
603
+ document.addEventListener('click', this._clickHandler);
604
+ }
605
+
606
+ // ─── History State Helper ────────────────────────────────
607
+ _pushState(path, options = {}) {
608
+ if (typeof window === 'undefined' || !window.history) return;
609
+ const fullUrl = this.base + (path.startsWith('/') ? path : '/' + path);
610
+ const state = { __tova_idx: ++this._historyIndex, ...(options.state || {}) };
611
+ if (options.replace) {
612
+ window.history.replaceState(state, '', fullUrl);
613
+ } else {
614
+ window.history.pushState(state, '', fullUrl);
615
+ }
616
+ }
157
617
  }
158
618
 
159
- export function afterNavigate(callback) {
160
- afterNavigateHooks.push(callback);
161
- // Return unsubscribe function
162
- return () => {
163
- const idx = afterNavigateHooks.indexOf(callback);
164
- if (idx !== -1) afterNavigateHooks.splice(idx, 1);
165
- };
619
+ // ─── createRouter Factory ────────────────────────────────
620
+ export function createRouter(config) {
621
+ // Destroy previous router if exists
622
+ if (_activeRouter) {
623
+ _activeRouter.destroy();
624
+ }
625
+ return new TovRouter(config);
166
626
  }
167
627
 
168
- // ─── Router Component ─────────────────────────────────────
169
- // Renders the matched route's component reactively.
170
- // Usage: <Router /> in JSX
628
+ // ─── resetRouter (for testing) ───────────────────────────
629
+ export function resetRouter() {
630
+ if (_activeRouter) {
631
+ _activeRouter.destroy();
632
+ }
633
+ _activeRouter = null;
634
+ }
171
635
 
636
+ // ─── Router Component ────────────────────────────────────
172
637
  export function Router() {
173
- const r = route();
174
- if (r && r.component) {
175
- return typeof r.component === 'function' ? r.component(r.params) : r.component;
176
- }
177
- return null;
638
+ return {
639
+ __tova: true,
640
+ tag: '__dynamic',
641
+ compute: () => {
642
+ const router = _activeRouter;
643
+ if (!router) return null;
644
+ const r = router._route();
645
+ if (r && r.component) {
646
+ return typeof r.component === 'function' ? r.component(r.params) : r.component;
647
+ }
648
+ return null;
649
+ },
650
+ props: {},
651
+ children: [],
652
+ };
178
653
  }
179
654
 
180
655
  // ─── Outlet Component ────────────────────────────────────
181
- // Renders the matched child route's component inside a parent layout.
182
- // Usage: <Outlet /> inside a layout component
183
- // Child route is stored as a signal for reactivity and isolation.
184
-
185
- const [currentChildRoute, setCurrentChildRoute] = createSignal(null);
186
-
187
656
  export function Outlet() {
188
- const child = currentChildRoute();
657
+ const router = _activeRouter;
658
+ if (!router) return null;
659
+ const child = router._childRoute();
189
660
  if (child && child.component) {
190
661
  const comp = child.component;
191
662
  const params = child.params || {};
@@ -194,197 +665,134 @@ export function Outlet() {
194
665
  return null;
195
666
  }
196
667
 
197
- // ─── Link Component ───────────────────────────────────────
198
- // Client-side navigation link.
199
- // Usage: <Link href="/about">"About"</Link>
668
+ // ─── Link Component ──────────────────────────────────────
669
+ export function Link({ href, children, activeClass, exactActiveClass, ...rest }) {
670
+ const router = _activeRouter;
671
+ const baseHref = router ? router.base + href : href;
200
672
 
201
- export function Link({ href, children, ...rest }) {
202
- return tova_el('a', {
203
- href,
673
+ const props = {
674
+ href: baseHref,
204
675
  onClick: (e) => {
205
676
  e.preventDefault();
206
- navigate(href);
677
+ if (router) {
678
+ router.navigate(href);
679
+ }
207
680
  },
208
681
  ...rest,
209
- }, children || []);
682
+ };
683
+
684
+ // Active class computation — returns a function for reactive binding
685
+ if ((activeClass || exactActiveClass) && router) {
686
+ const baseClass = rest.class || '';
687
+ props.class = () => {
688
+ const currentPath = router._route().path;
689
+ const isExact = currentPath === href;
690
+ const isPrefix = href === '/' ? isExact : (currentPath === href || currentPath.startsWith(href + '/'));
691
+ let cls = baseClass;
692
+ if (exactActiveClass && isExact) {
693
+ cls = cls ? cls + ' ' + exactActiveClass : exactActiveClass;
694
+ } else if (activeClass && isPrefix) {
695
+ cls = cls ? cls + ' ' + activeClass : activeClass;
696
+ }
697
+ return cls;
698
+ };
699
+ }
700
+
701
+ return tova_el('a', props, children || []);
210
702
  }
211
703
 
212
704
  // ─── Redirect Component ──────────────────────────────────
213
- // Immediately navigates to a different path when rendered.
214
- // Usage: <Redirect to="/login" />
215
- // Loop protection: max 10 redirects in 1 second to prevent infinite loops.
216
-
217
- let _redirectCount = 0;
218
- let _redirectWindowStart = 0;
219
705
  const _MAX_REDIRECTS = 10;
220
706
  const _REDIRECT_WINDOW_MS = 1000;
221
707
 
222
708
  export function Redirect({ to }) {
223
709
  if (typeof window !== 'undefined') {
224
710
  queueMicrotask(() => {
711
+ const router = _activeRouter;
712
+ if (!router) return;
225
713
  const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
226
- if (now - _redirectWindowStart > _REDIRECT_WINDOW_MS) {
227
- _redirectCount = 0;
228
- _redirectWindowStart = now;
714
+ if (now - router._redirectWindowStart > _REDIRECT_WINDOW_MS) {
715
+ router._redirectCount = 0;
716
+ router._redirectWindowStart = now;
229
717
  }
230
- _redirectCount++;
231
- if (_redirectCount > _MAX_REDIRECTS) {
232
- console.error(`Tova router: Redirect loop detected (>${_MAX_REDIRECTS} redirects in ${_REDIRECT_WINDOW_MS}ms). Aborting redirect to "${to}".`);
718
+ router._redirectCount++;
719
+ if (router._redirectCount > _MAX_REDIRECTS) {
720
+ console.error('Tova router: Redirect loop detected (>' + _MAX_REDIRECTS + ' redirects in ' + _REDIRECT_WINDOW_MS + 'ms). Aborting redirect to "' + to + '".');
233
721
  return;
234
722
  }
235
- navigate(to);
723
+ router.navigate(to);
236
724
  });
237
725
  }
238
726
  return null;
239
727
  }
240
728
 
241
- // ─── Internals ────────────────────────────────────────────
729
+ // ─── Backward-Compatible Module-Level API ────────────────
730
+ // These functions delegate to the active router instance.
731
+ // Existing code using defineRoutes/navigate/getPath/etc. continues working.
242
732
 
243
- function parseQueryString(search) {
244
- const query = {};
245
- if (!search || search === '?') return query;
246
- const str = search.startsWith('?') ? search.slice(1) : search;
247
- for (const pair of str.split('&')) {
248
- const [key, ...rest] = pair.split('=');
249
- const value = rest.join('=');
250
- if (key) {
251
- query[decodeURIComponent(key)] = value !== undefined ? decodeURIComponent(value) : '';
252
- }
253
- }
254
- return query;
733
+ export function defineRoutes(routeMap) {
734
+ createRouter({ routes: routeMap });
255
735
  }
256
736
 
257
- function handleRouteChange() {
258
- let path = '/';
259
- let query = {};
260
- if (typeof window !== 'undefined') {
261
- path = window.location.pathname;
262
- query = parseQueryString(window.location.search);
737
+ export function navigate(path, options) {
738
+ if (_activeRouter) {
739
+ _activeRouter.navigate(path, options);
740
+ } else {
741
+ // Auto-create a router if none exists
742
+ createRouter({});
743
+ _activeRouter.navigate(path, options);
263
744
  }
745
+ }
264
746
 
265
- const matched = matchRoute(path);
747
+ export function getCurrentRoute() {
748
+ if (!_activeRouter) return () => ({ path: '/', params: {}, query: {}, meta: {} });
749
+ return _activeRouter._route;
750
+ }
266
751
 
267
- if (matched) {
268
- setRoute({ ...matched, query });
269
- } else if (notFoundComponent) {
270
- setRoute({ path, pattern: null, component: notFoundComponent, params: {}, query });
271
- } else {
272
- setRoute({ path, pattern: null, component: null, params: {}, query });
273
- }
752
+ export function getParams() {
753
+ return () => {
754
+ if (!_activeRouter) return {};
755
+ return _activeRouter._route().params;
756
+ };
757
+ }
274
758
 
275
- for (const cb of routeChangeCallbacks) {
276
- cb(matched);
277
- }
759
+ export function getPath() {
760
+ return () => {
761
+ if (!_activeRouter) return '/';
762
+ return _activeRouter._route().path;
763
+ };
764
+ }
278
765
 
279
- // Run afterNavigate hooks
280
- const currentRoute = route();
281
- for (const hook of afterNavigateHooks) {
282
- hook(currentRoute);
283
- }
766
+ export function getQuery() {
767
+ return () => {
768
+ if (!_activeRouter) return {};
769
+ return _activeRouter._route().query;
770
+ };
284
771
  }
285
772
 
286
- function matchRoute(path) {
287
- for (const def of routeDefinitions) {
288
- if (def.children && def.children.length > 0) {
289
- for (const child of def.children) {
290
- const childMatch = child.pattern.exec(path);
291
- if (childMatch) {
292
- const childParams = extractParams(child.path, childMatch);
293
- setCurrentChildRoute({ component: child.component, params: childParams });
294
- const parentMatch = def.pattern.exec(path);
295
- const parentParams = extractParams(def.path, parentMatch || []);
296
- return { path: def.path, component: def.component, params: { ...parentParams, ...childParams } };
297
- }
298
- }
299
- const parentMatch = def.pattern.exec(path);
300
- if (parentMatch) {
301
- setCurrentChildRoute(null);
302
- const params = extractParams(def.path, parentMatch);
303
- return { path: def.path, component: def.component, params };
304
- }
305
- } else {
306
- const match = def.pattern.exec(path);
307
- if (match) {
308
- setCurrentChildRoute(null);
309
- const params = extractParams(def.path, match);
310
- return { path: def.path, component: def.component, params };
311
- }
312
- }
313
- }
314
- setCurrentChildRoute(null);
315
- return null;
773
+ export function getMeta() {
774
+ return () => {
775
+ if (!_activeRouter) return {};
776
+ return _activeRouter._route().meta;
777
+ };
316
778
  }
317
779
 
318
- function pathToRegex(path, prefixMatch) {
319
- // Handle optional parameters: :id? becomes ([^/]*)?
320
- // Handle required parameters: :id becomes ([^/]+)
321
- // Handle catch-all: * becomes (.*)
322
- const pattern = path
323
- .replace(/:([a-zA-Z_]+)\?/g, '([^/]*)?') // optional params
324
- .replace(/:([a-zA-Z_]+)/g, '([^/]+)') // required params
325
- .replace(/\*/g, '(.*)'); // catch-all
326
- // For parent routes with children, match as prefix
327
- if (prefixMatch) {
328
- return new RegExp('^' + pattern + '(?:/.*)?$');
329
- }
330
- return new RegExp('^' + pattern + '$');
780
+ export function onRouteChange(callback) {
781
+ if (_activeRouter) return _activeRouter.onRouteChange(callback);
782
+ return () => {};
331
783
  }
332
784
 
333
- function extractParams(routePath, match) {
334
- const params = {};
335
- if (!match) return params;
336
- // Match both required (:name) and optional (:name?) params
337
- const paramNames = (routePath.match(/:([a-zA-Z_]+)\??/g) || [])
338
- .map(p => p.replace(/^:/, '').replace(/\?$/, ''));
339
- paramNames.forEach((name, index) => {
340
- const val = match[index + 1];
341
- if (val !== undefined && val !== '') {
342
- params[name] = val;
343
- }
344
- });
345
- return params;
785
+ export function beforeNavigate(callback) {
786
+ if (_activeRouter) return _activeRouter.beforeNavigate(callback);
787
+ return () => {};
346
788
  }
347
789
 
348
- // ─── Browser Init ─────────────────────────────────────────
349
-
350
- if (typeof window !== 'undefined') {
351
- window.addEventListener('popstate', () => {
352
- // Run beforeNavigate hooks for browser back/forward
353
- const from = route();
354
- const toPath = window.location.pathname;
355
- for (const hook of beforeNavigateHooks) {
356
- const result = hook(from, toPath);
357
- if (result === false) {
358
- // Cancel: push the previous path back
359
- window.history.pushState({}, '', from.path);
360
- return;
361
- }
362
- }
363
- handleRouteChange();
364
- });
365
-
366
- // Intercept link clicks for client-side navigation
367
- document.addEventListener('click', (e) => {
368
- const link = e.target.closest('a[href]');
369
- if (!link) return;
370
- // Use the resolved href for origin comparison (not raw attribute)
371
- if (!link.href.startsWith(window.location.origin)) return;
372
- // Skip links with target, download, or external rel
373
- if (link.target === '_blank') return;
374
- if (typeof link.hasAttribute === 'function' && link.hasAttribute('download')) return;
375
- if (typeof link.getAttribute === 'function') {
376
- const rel = link.getAttribute('rel');
377
- if (rel && rel.includes('external')) return;
378
- }
379
- e.preventDefault();
380
- // Use pathname from the resolved URL for safe navigation
381
- try {
382
- const url = new URL(link.href);
383
- navigate(url.pathname + (url.search || '') + (url.hash || ''));
384
- } catch (_) {
385
- // Fallback for environments without URL constructor
386
- const href = typeof link.getAttribute === 'function' ? link.getAttribute('href') : link.href;
387
- if (href && isValidPath(href)) navigate(href);
388
- }
389
- });
790
+ export function afterNavigate(callback) {
791
+ if (_activeRouter) return _activeRouter.afterNavigate(callback);
792
+ return () => {};
793
+ }
794
+
795
+ // ─── Utility Exports ─────────────────────────────────────
796
+ export function getRouter() {
797
+ return _activeRouter;
390
798
  }