@zenithbuild/router 1.3.3 → 1.3.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenithbuild/router",
3
- "version": "1.3.3",
3
+ "version": "1.3.9",
4
4
  "description": "File-based SPA router for Zenith framework with deterministic, compile-time route resolution",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Navigation & Prefetch Runtime
3
+ *
4
+ * Phase 7: Prefetch compiled output, safe SPA navigation, route caching
5
+ *
6
+ * This runtime handles:
7
+ * - Prefetching compiled HTML + JS for routes
8
+ * - Caching prefetched routes
9
+ * - Safe DOM mounting and hydration
10
+ * - Browser history management
11
+ * - Explicit data exposure for navigation
12
+ */
13
+
14
+ /**
15
+ * Route cache entry containing compiled output
16
+ */
17
+ export interface RouteCacheEntry {
18
+ html: string
19
+ js: string
20
+ styles: string[]
21
+ routePath: string
22
+ compiledAt: number
23
+ }
24
+
25
+ /**
26
+ * Navigation options with explicit data
27
+ */
28
+ export interface NavigateOptions {
29
+ loaderData?: any
30
+ props?: any
31
+ stores?: any
32
+ replace?: boolean
33
+ prefetch?: boolean
34
+ }
35
+
36
+ /**
37
+ * Route cache - stores prefetched compiled output
38
+ */
39
+ const routeCache = new Map<string, RouteCacheEntry>()
40
+
41
+ /**
42
+ * Current navigation state
43
+ */
44
+ let currentRoute: string = ''
45
+ let navigationInProgress: boolean = false
46
+
47
+ /**
48
+ * Prefetch a route's compiled output
49
+ *
50
+ * @param routePath - The route path to prefetch (e.g., "/dashboard")
51
+ * @returns Promise that resolves when prefetch is complete
52
+ */
53
+ export async function prefetchRoute(routePath: string): Promise<void> {
54
+ // Normalize route path
55
+ const normalizedPath = routePath === '' ? '/' : routePath
56
+
57
+ // Check if already cached
58
+ if (routeCache.has(normalizedPath)) {
59
+ return Promise.resolve()
60
+ }
61
+
62
+ // In a real implementation, this would fetch from the build output
63
+ // For Phase 7, we'll generate a placeholder that indicates the route needs to be built
64
+ try {
65
+ // Fetch compiled HTML + JS
66
+ // In production, this would be:
67
+ // const htmlResponse = await fetch(`${normalizedPath}.html`)
68
+ // const jsResponse = await fetch(`${normalizedPath}.js`)
69
+
70
+ // For now, return a placeholder that indicates prefetch structure
71
+ const cacheEntry: RouteCacheEntry = {
72
+ html: `<!-- Prefetched route: ${normalizedPath} -->`,
73
+ js: `// Prefetched route runtime: ${normalizedPath}`,
74
+ styles: [],
75
+ routePath: normalizedPath,
76
+ compiledAt: Date.now()
77
+ }
78
+
79
+ routeCache.set(normalizedPath, cacheEntry)
80
+ } catch (error) {
81
+ console.warn(`[Zenith] Failed to prefetch route ${normalizedPath}:`, error)
82
+ throw error
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Get cached route entry
88
+ */
89
+ export function getCachedRoute(routePath: string): RouteCacheEntry | null {
90
+ const normalizedPath = routePath === '' ? '/' : routePath
91
+ return routeCache.get(normalizedPath) || null
92
+ }
93
+
94
+ /**
95
+ * Navigate to a route with explicit data
96
+ *
97
+ * @param routePath - The route path to navigate to
98
+ * @param options - Navigation options with loaderData, props, stores
99
+ */
100
+ export async function navigate(
101
+ routePath: string,
102
+ options: NavigateOptions = {}
103
+ ): Promise<void> {
104
+ if (navigationInProgress) {
105
+ console.warn('[Zenith] Navigation already in progress, skipping')
106
+ return
107
+ }
108
+
109
+ navigationInProgress = true
110
+
111
+ try {
112
+ const normalizedPath = routePath === '' ? '/' : routePath
113
+
114
+ // Check if route is cached, otherwise prefetch
115
+ let cacheEntry = getCachedRoute(normalizedPath)
116
+ if (!cacheEntry && options.prefetch !== false) {
117
+ await prefetchRoute(normalizedPath)
118
+ cacheEntry = getCachedRoute(normalizedPath)
119
+ }
120
+
121
+ if (!cacheEntry) {
122
+ throw new Error(`Route ${normalizedPath} not found. Ensure the route is compiled.`)
123
+ }
124
+
125
+ // Cleanup previous route
126
+ cleanupPreviousRoute()
127
+
128
+ // Get router outlet
129
+ const outlet = getRouterOutlet()
130
+ if (!outlet) {
131
+ throw new Error('Router outlet not found. Ensure <div id="zenith-outlet"></div> exists.')
132
+ }
133
+
134
+ // Mount compiled HTML
135
+ outlet.innerHTML = cacheEntry.html
136
+
137
+ // Inject styles
138
+ injectStyles(cacheEntry.styles)
139
+
140
+ // Execute JS runtime (compiled expressions + hydration)
141
+ await executeRouteRuntime(cacheEntry.js, {
142
+ loaderData: options.loaderData || {},
143
+ props: options.props || {},
144
+ stores: options.stores || {}
145
+ })
146
+
147
+ // Update browser history
148
+ if (typeof window !== 'undefined') {
149
+ const url = normalizedPath + (window.location.search || '')
150
+ if (options.replace) {
151
+ window.history.replaceState({ route: normalizedPath }, '', url)
152
+ } else {
153
+ window.history.pushState({ route: normalizedPath }, '', url)
154
+ }
155
+ }
156
+
157
+ currentRoute = normalizedPath
158
+
159
+ // Dispatch navigation event
160
+ dispatchNavigationEvent(normalizedPath, options)
161
+ } catch (error) {
162
+ console.error('[Zenith] Navigation error:', error)
163
+ throw error
164
+ } finally {
165
+ navigationInProgress = false
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Cleanup previous route
171
+ */
172
+ function cleanupPreviousRoute(): void {
173
+ if (typeof window === 'undefined') return
174
+
175
+ // Cleanup hydration runtime
176
+ if ((window as any).zenithCleanup) {
177
+ ; (window as any).zenithCleanup()
178
+ }
179
+
180
+ // Remove previous page styles
181
+ document.querySelectorAll('style[data-zen-route-style]').forEach(style => {
182
+ style.remove()
183
+ })
184
+
185
+ // Clear window state (if needed)
186
+ // State is managed per-route, so we don't clear it here
187
+ }
188
+
189
+ /**
190
+ * Get router outlet element
191
+ */
192
+ function getRouterOutlet(): HTMLElement | null {
193
+ if (typeof window === 'undefined') return null
194
+ return document.querySelector('#zenith-outlet') || document.querySelector('[data-zen-outlet]')
195
+ }
196
+
197
+ /**
198
+ * Inject route styles
199
+ */
200
+ function injectStyles(styles: string[]): void {
201
+ if (typeof window === 'undefined') return
202
+
203
+ styles.forEach((styleContent, index) => {
204
+ const style = document.createElement('style')
205
+ style.setAttribute('data-zen-route-style', String(index))
206
+ style.textContent = styleContent
207
+ document.head.appendChild(style)
208
+ })
209
+ }
210
+
211
+ /**
212
+ * Execute route runtime JS
213
+ *
214
+ * This executes the compiled JS bundle for the route, which includes:
215
+ * - Expression wrappers
216
+ * - Hydration runtime
217
+ * - Event bindings
218
+ */
219
+ async function executeRouteRuntime(
220
+ jsCode: string,
221
+ data: { loaderData: any; props: any; stores: any }
222
+ ): Promise<void> {
223
+ if (typeof window === 'undefined') return
224
+
225
+ try {
226
+ // Execute the compiled JS (which registers expressions and hydration functions)
227
+ // In a real implementation, this would use a script tag or eval (secure context)
228
+ const script = document.createElement('script')
229
+ script.textContent = jsCode
230
+ document.head.appendChild(script)
231
+ document.head.removeChild(script)
232
+
233
+ // After JS executes, call hydrate with explicit data
234
+ if ((window as any).zenithHydrate) {
235
+ const state = (window as any).__ZENITH_STATE__ || {}
236
+ ; (window as any).zenithHydrate(
237
+ state,
238
+ data.loaderData,
239
+ data.props,
240
+ data.stores,
241
+ getRouterOutlet()
242
+ )
243
+ }
244
+ } catch (error) {
245
+ console.error('[Zenith] Error executing route runtime:', error)
246
+ throw error
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Dispatch navigation event
252
+ */
253
+ function dispatchNavigationEvent(routePath: string, options: NavigateOptions): void {
254
+ if (typeof window === 'undefined') return
255
+
256
+ const event = new CustomEvent('zenith:navigate', {
257
+ detail: {
258
+ route: routePath,
259
+ loaderData: options.loaderData,
260
+ props: options.props,
261
+ stores: options.stores
262
+ }
263
+ })
264
+ window.dispatchEvent(event)
265
+ }
266
+
267
+ /**
268
+ * Handle browser back/forward navigation
269
+ */
270
+ export function setupHistoryHandling(): void {
271
+ if (typeof window === 'undefined') return
272
+
273
+ window.addEventListener('popstate', (event) => {
274
+ const state = event.state
275
+ const routePath = state?.route || window.location.pathname
276
+
277
+ // Navigate without pushing to history (browser already changed it)
278
+ navigate(routePath, { replace: true, prefetch: false }).catch((error) => {
279
+ console.error('[Zenith] History navigation error:', error)
280
+ })
281
+ })
282
+ }
283
+
284
+ /**
285
+ * Generate navigation runtime code (to be included in bundle)
286
+ */
287
+ export function generateNavigationRuntime(): string {
288
+ return `
289
+ // Zenith Navigation Runtime (Phase 7)
290
+ (function() {
291
+ 'use strict';
292
+
293
+ // Route cache
294
+ const __zen_routeCache = new Map();
295
+
296
+ // Current route state
297
+ let __zen_currentRoute = '';
298
+ let __zen_navigationInProgress = false;
299
+
300
+ /**
301
+ * Prefetch a route
302
+ */
303
+ async function prefetchRoute(routePath) {
304
+ const normalizedPath = routePath === '' ? '/' : routePath;
305
+
306
+ if (__zen_routeCache.has(normalizedPath)) {
307
+ return Promise.resolve();
308
+ }
309
+
310
+ try {
311
+ // Fetch compiled HTML + JS
312
+ // This is a placeholder - in production, fetch from build output
313
+ const cacheEntry = {
314
+ html: '<!-- Prefetched: ' + normalizedPath + ' -->',
315
+ js: '// Prefetched runtime: ' + normalizedPath,
316
+ styles: [],
317
+ routePath: normalizedPath,
318
+ compiledAt: Date.now()
319
+ };
320
+
321
+ __zen_routeCache.set(normalizedPath, cacheEntry);
322
+ } catch (error) {
323
+ console.warn('[Zenith] Prefetch failed:', routePath, error);
324
+ throw error;
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Navigate to route with explicit data
330
+ */
331
+ async function navigate(routePath, options) {
332
+ options = options || {};
333
+
334
+ if (__zen_navigationInProgress) {
335
+ console.warn('[Zenith] Navigation in progress');
336
+ return;
337
+ }
338
+
339
+ __zen_navigationInProgress = true;
340
+
341
+ try {
342
+ const normalizedPath = routePath === '' ? '/' : routePath;
343
+
344
+ // Get cached route or prefetch
345
+ let cacheEntry = __zen_routeCache.get(normalizedPath);
346
+ if (!cacheEntry && options.prefetch !== false) {
347
+ await prefetchRoute(normalizedPath);
348
+ cacheEntry = __zen_routeCache.get(normalizedPath);
349
+ }
350
+
351
+ if (!cacheEntry) {
352
+ throw new Error('Route not found: ' + normalizedPath);
353
+ }
354
+
355
+ // Get outlet
356
+ const outlet = document.querySelector('#zenith-outlet') || document.querySelector('[data-zen-outlet]');
357
+ if (!outlet) {
358
+ throw new Error('Router outlet not found');
359
+ }
360
+
361
+ // Mount HTML
362
+ outlet.innerHTML = cacheEntry.html;
363
+
364
+ // Execute runtime JS
365
+ if (cacheEntry.js) {
366
+ const script = document.createElement('script');
367
+ script.textContent = cacheEntry.js;
368
+ document.head.appendChild(script);
369
+ document.head.removeChild(script);
370
+ }
371
+
372
+ // Hydrate with explicit data
373
+ if (window.zenithHydrate) {
374
+ const state = window.__ZENITH_STATE__ || {};
375
+ window.zenithHydrate(
376
+ state,
377
+ options.loaderData || {},
378
+ options.props || {},
379
+ options.stores || {},
380
+ outlet
381
+ );
382
+ }
383
+
384
+ // Update history
385
+ const url = normalizedPath + (window.location.search || '');
386
+ if (options.replace) {
387
+ window.history.replaceState({ route: normalizedPath }, '', url);
388
+ } else {
389
+ window.history.pushState({ route: normalizedPath }, '', url);
390
+ }
391
+
392
+ __zen_currentRoute = normalizedPath;
393
+
394
+ // Dispatch event
395
+ window.dispatchEvent(new CustomEvent('zenith:navigate', {
396
+ detail: { route: normalizedPath, options: options }
397
+ }));
398
+ } catch (error) {
399
+ console.error('[Zenith] Navigation error:', error);
400
+ throw error;
401
+ } finally {
402
+ __zen_navigationInProgress = false;
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Handle browser history
408
+ */
409
+ function setupHistoryHandling() {
410
+ window.addEventListener('popstate', function(event) {
411
+ const state = event.state;
412
+ const routePath = state && state.route ? state.route : window.location.pathname;
413
+
414
+ navigate(routePath, { replace: true, prefetch: false }).catch(function(error) {
415
+ console.error('[Zenith] History navigation error:', error);
416
+ });
417
+ });
418
+ }
419
+
420
+ // Initialize history handling
421
+ setupHistoryHandling();
422
+
423
+ // Expose API
424
+ if (typeof window !== 'undefined') {
425
+ window.__zenith_navigate = navigate;
426
+ window.__zenith_prefetch = prefetchRoute;
427
+ window.navigate = navigate; // Global convenience
428
+ }
429
+ })();
430
+ `
431
+ }
@@ -70,6 +70,8 @@ export {
70
70
  normalizePath
71
71
  } from './zen-link'
72
72
 
73
+ export * from './client-router'
74
+
73
75
  // Export types
74
76
  export type {
75
77
  ZenLinkProps,
package/tsconfig.json CHANGED
@@ -16,7 +16,7 @@
16
16
  "DOM.Iterable"
17
17
  ],
18
18
  "types": [
19
- "bun-types",
19
+ "bun",
20
20
  "node"
21
21
  ]
22
22
  },