@zenithbuild/router 1.0.1

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.
@@ -0,0 +1,585 @@
1
+ /**
2
+ * ZenLink Runtime Module
3
+ *
4
+ * Provides programmatic navigation and ZenLink utilities.
5
+ * This module can be imported in `.zen` files or TypeScript.
6
+ *
7
+ * @package @zenithbuild/router
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { navigate, zenLink, isActive } from '@zenithbuild/router'
12
+ *
13
+ * // Programmatic navigation
14
+ * navigate('/about')
15
+ *
16
+ * // Check active state
17
+ * if (isActive('/blog')) {
18
+ * console.log('On blog section')
19
+ * }
20
+ * ```
21
+ */
22
+
23
+ // ============================================
24
+ // Types
25
+ // ============================================
26
+
27
+ /**
28
+ * Props for ZenLink component
29
+ */
30
+ export interface ZenLinkProps {
31
+ /** Target URL path */
32
+ href: string
33
+ /** Optional CSS class(es) */
34
+ class?: string
35
+ /** Link target (_blank, _self, etc.) */
36
+ target?: '_blank' | '_self' | '_parent' | '_top'
37
+ /** Click handler (called before navigation) */
38
+ onClick?: (event: MouseEvent) => void | boolean
39
+ /** Preload the linked page on hover */
40
+ preload?: boolean
41
+ /** Future: Transition configuration */
42
+ onTransition?: TransitionHandler
43
+ /** Future: Disable page transition animation */
44
+ noTransition?: boolean
45
+ /** Match exact path for active state */
46
+ exact?: boolean
47
+ /** Additional aria attributes */
48
+ ariaLabel?: string
49
+ /** Replace history instead of push */
50
+ replace?: boolean
51
+ /** Link content (children) */
52
+ children?: string | HTMLElement | HTMLElement[]
53
+ }
54
+
55
+ /**
56
+ * Transition context for Transitions API
57
+ */
58
+ export interface TransitionContext {
59
+ /** Current page element */
60
+ currentPage: HTMLElement | null
61
+ /** Next page element (after load) */
62
+ nextPage: HTMLElement | null
63
+ /** Between/loading page element */
64
+ betweenPage: HTMLElement | null
65
+ /** Navigation direction */
66
+ direction: 'forward' | 'back'
67
+ /** Origin path */
68
+ fromPath: string
69
+ /** Destination path */
70
+ toPath: string
71
+ /** Route params */
72
+ params: Record<string, string>
73
+ /** Query params */
74
+ query: Record<string, string>
75
+ }
76
+
77
+ /**
78
+ * Transition handler function
79
+ */
80
+ export type TransitionHandler = (context: TransitionContext) => void | Promise<void>
81
+
82
+ /**
83
+ * Navigation options
84
+ */
85
+ export interface NavigateOptions {
86
+ /** Replace current history entry instead of pushing */
87
+ replace?: boolean
88
+ /** Scroll to top after navigation */
89
+ scrollToTop?: boolean
90
+ /** Transition handler for this navigation */
91
+ onTransition?: TransitionHandler
92
+ /** Skip transition animation */
93
+ noTransition?: boolean
94
+ /** State to pass to the next page */
95
+ state?: Record<string, unknown>
96
+ }
97
+
98
+ // ============================================
99
+ // Internal State
100
+ // ============================================
101
+
102
+ /** Prefetched routes cache */
103
+ const prefetchedRoutes = new Set<string>()
104
+
105
+ /** Global transition handler (set at layout level) */
106
+ let globalTransitionHandler: TransitionHandler | null = null
107
+
108
+ /** Navigation in progress flag */
109
+ let isNavigating = false
110
+
111
+ // ============================================
112
+ // Utilities
113
+ // ============================================
114
+
115
+ /**
116
+ * Check if URL is external (different origin)
117
+ */
118
+ export function isExternalUrl(url: string): boolean {
119
+ if (!url) return false
120
+
121
+ // Protocol-relative or absolute URLs with different origin
122
+ if (url.startsWith('//') || url.startsWith('http://') || url.startsWith('https://')) {
123
+ try {
124
+ const linkUrl = new URL(url, window.location.origin)
125
+ return linkUrl.origin !== window.location.origin
126
+ } catch {
127
+ return true
128
+ }
129
+ }
130
+
131
+ // mailto:, tel:, javascript:, etc.
132
+ if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url)) {
133
+ return true
134
+ }
135
+
136
+ return false
137
+ }
138
+
139
+ /**
140
+ * Check if link should use SPA navigation
141
+ */
142
+ export function shouldUseSPANavigation(href: string, target?: string): boolean {
143
+ // Don't use SPA for external links
144
+ if (isExternalUrl(href)) return false
145
+
146
+ // Don't use SPA if target is set (except _self)
147
+ if (target && target !== '_self') return false
148
+
149
+ // Don't use SPA for hash-only links on same page
150
+ if (href.startsWith('#')) return false
151
+
152
+ // Don't use SPA for download links or special protocols
153
+ if (href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('javascript:')) {
154
+ return false
155
+ }
156
+
157
+ return true
158
+ }
159
+
160
+ /**
161
+ * Normalize a path
162
+ */
163
+ export function normalizePath(path: string): string {
164
+ // Ensure path starts with /
165
+ if (!path.startsWith('/')) {
166
+ const currentDir = window.location.pathname.split('/').slice(0, -1).join('/')
167
+ path = currentDir + '/' + path
168
+ }
169
+
170
+ // Remove trailing slash (except for root)
171
+ if (path !== '/' && path.endsWith('/')) {
172
+ path = path.slice(0, -1)
173
+ }
174
+
175
+ return path
176
+ }
177
+
178
+ // ============================================
179
+ // Navigation API
180
+ // ============================================
181
+
182
+ /**
183
+ * Navigate to a new URL (SPA navigation)
184
+ *
185
+ * This is the primary API for programmatic navigation.
186
+ *
187
+ * @example
188
+ * ```ts
189
+ * // Simple navigation
190
+ * navigate('/about')
191
+ *
192
+ * // With options
193
+ * navigate('/dashboard', { replace: true })
194
+ *
195
+ * // With transition
196
+ * navigate('/gallery', {
197
+ * onTransition: async (ctx) => {
198
+ * await animateOut(ctx.currentPage)
199
+ * await animateIn(ctx.nextPage)
200
+ * }
201
+ * })
202
+ * ```
203
+ */
204
+ export async function zenNavigate(
205
+ to: string,
206
+ options: NavigateOptions = {}
207
+ ): Promise<void> {
208
+ // Prevent concurrent navigations
209
+ if (isNavigating) {
210
+ console.warn('[ZenLink] Navigation already in progress')
211
+ return
212
+ }
213
+
214
+ isNavigating = true
215
+
216
+ try {
217
+ // Access global router
218
+ const router = (window as any).__zenith_router
219
+
220
+ if (router && router.navigate) {
221
+ // Use router's navigate function
222
+ await router.navigate(to, options)
223
+ } else {
224
+ // Fallback: use History API directly
225
+ const normalizedPath = normalizePath(to)
226
+
227
+ if (options.replace) {
228
+ window.history.replaceState(options.state || null, '', normalizedPath)
229
+ } else {
230
+ window.history.pushState(options.state || null, '', normalizedPath)
231
+ }
232
+
233
+ // Dispatch popstate to trigger route resolution
234
+ window.dispatchEvent(new PopStateEvent('popstate'))
235
+ }
236
+
237
+ // Scroll to top if requested (default: true)
238
+ if (options.scrollToTop !== false) {
239
+ window.scrollTo({ top: 0, behavior: 'smooth' })
240
+ }
241
+ } finally {
242
+ isNavigating = false
243
+ }
244
+ }
245
+
246
+ // Clean alias
247
+ export const navigate = zenNavigate
248
+
249
+ /**
250
+ * Navigate back in history
251
+ */
252
+ export function zenBack(): void {
253
+ window.history.back()
254
+ }
255
+
256
+ export const back = zenBack
257
+
258
+ /**
259
+ * Navigate forward in history
260
+ */
261
+ export function zenForward(): void {
262
+ window.history.forward()
263
+ }
264
+
265
+ export const forward = zenForward
266
+
267
+ /**
268
+ * Navigate to a specific history index
269
+ */
270
+ export function zenGo(delta: number): void {
271
+ window.history.go(delta)
272
+ }
273
+
274
+ export const go = zenGo
275
+
276
+ // ============================================
277
+ // Active State
278
+ // ============================================
279
+
280
+ /**
281
+ * Check if a path is currently active
282
+ *
283
+ * @example
284
+ * ```ts
285
+ * // Check if on blog section
286
+ * if (isActive('/blog')) {
287
+ * addClass(link, 'active')
288
+ * }
289
+ *
290
+ * // Exact match only
291
+ * if (isActive('/blog', true)) {
292
+ * addClass(link, 'active-exact')
293
+ * }
294
+ * ```
295
+ */
296
+ export function zenIsActive(path: string, exact: boolean = false): boolean {
297
+ const router = (window as any).__zenith_router
298
+
299
+ if (router && router.isActive) {
300
+ return router.isActive(path, exact)
301
+ }
302
+
303
+ // Fallback: compare with current pathname
304
+ const currentPath = window.location.pathname
305
+ const normalizedPath = normalizePath(path)
306
+
307
+ if (exact) {
308
+ return currentPath === normalizedPath
309
+ }
310
+
311
+ // Root path special case
312
+ if (normalizedPath === '/') {
313
+ return currentPath === '/'
314
+ }
315
+
316
+ return currentPath.startsWith(normalizedPath)
317
+ }
318
+
319
+ export const isActive = zenIsActive
320
+
321
+ // ============================================
322
+ // Prefetching
323
+ // ============================================
324
+
325
+ /**
326
+ * Prefetch a route for faster navigation
327
+ *
328
+ * @example
329
+ * ```ts
330
+ * // Prefetch on hover
331
+ * element.addEventListener('mouseenter', () => {
332
+ * prefetch('/about')
333
+ * })
334
+ * ```
335
+ */
336
+ export async function zenPrefetch(path: string): Promise<void> {
337
+ // Normalize path
338
+ const normalizedPath = normalizePath(path)
339
+
340
+ // Don't prefetch if already done
341
+ if (prefetchedRoutes.has(normalizedPath)) {
342
+ return
343
+ }
344
+
345
+ // Mark as prefetched
346
+ prefetchedRoutes.add(normalizedPath)
347
+
348
+ // Try router prefetch first
349
+ const router = (window as any).__zenith_router
350
+
351
+ if (router && router.prefetch) {
352
+ try {
353
+ await router.prefetch(normalizedPath)
354
+ } catch {
355
+ // Silently ignore prefetch errors
356
+ }
357
+ return
358
+ }
359
+
360
+ // Fallback: use link preload hint
361
+ try {
362
+ const link = document.createElement('link')
363
+ link.rel = 'prefetch'
364
+ link.href = normalizedPath
365
+ link.as = 'document'
366
+ document.head.appendChild(link)
367
+ } catch {
368
+ // Ignore errors
369
+ }
370
+ }
371
+
372
+ export const prefetch = zenPrefetch
373
+
374
+ /**
375
+ * Check if a route has been prefetched
376
+ */
377
+ export function zenIsPrefetched(path: string): boolean {
378
+ return prefetchedRoutes.has(normalizePath(path))
379
+ }
380
+
381
+ export const isPrefetched = zenIsPrefetched
382
+
383
+ // ============================================
384
+ // Transitions API
385
+ // ============================================
386
+
387
+ /**
388
+ * Set global transition handler
389
+ *
390
+ * This allows setting a layout-level transition that applies to all navigations.
391
+ *
392
+ * @example
393
+ * ```ts
394
+ * // In layout component
395
+ * setGlobalTransition(async (ctx) => {
396
+ * ctx.currentPage?.classList.add('fade-out')
397
+ * await delay(300)
398
+ * ctx.nextPage?.classList.add('fade-in')
399
+ * })
400
+ * ```
401
+ */
402
+ export function setGlobalTransition(handler: TransitionHandler | null): void {
403
+ globalTransitionHandler = handler
404
+ }
405
+
406
+ /**
407
+ * Get current global transition handler
408
+ */
409
+ export function getGlobalTransition(): TransitionHandler | null {
410
+ return globalTransitionHandler
411
+ }
412
+
413
+ /**
414
+ * Create a transition context
415
+ */
416
+ export function createTransitionContext(
417
+ fromPath: string,
418
+ toPath: string,
419
+ direction: 'forward' | 'back' = 'forward'
420
+ ): TransitionContext {
421
+ return {
422
+ currentPage: document.querySelector('[data-zen-page]') as HTMLElement | null,
423
+ nextPage: null,
424
+ betweenPage: null,
425
+ direction,
426
+ fromPath,
427
+ toPath,
428
+ params: {},
429
+ query: {}
430
+ }
431
+ }
432
+
433
+ // ============================================
434
+ // Route State
435
+ // ============================================
436
+
437
+ /**
438
+ * Get current route state
439
+ */
440
+ export function zenGetRoute(): {
441
+ path: string
442
+ params: Record<string, string>
443
+ query: Record<string, string>
444
+ } {
445
+ const router = (window as any).__zenith_router
446
+
447
+ if (router && router.getRoute) {
448
+ return router.getRoute()
449
+ }
450
+
451
+ // Fallback
452
+ const query: Record<string, string> = {}
453
+ const params = new URLSearchParams(window.location.search)
454
+ params.forEach((value, key) => {
455
+ query[key] = value
456
+ })
457
+
458
+ return {
459
+ path: window.location.pathname,
460
+ params: {},
461
+ query
462
+ }
463
+ }
464
+
465
+ export const getRoute = zenGetRoute
466
+
467
+ /**
468
+ * Get a route parameter
469
+ */
470
+ export function zenGetParam(name: string): string | undefined {
471
+ return zenGetRoute().params[name]
472
+ }
473
+
474
+ export const getParam = zenGetParam
475
+
476
+ /**
477
+ * Get a query parameter
478
+ */
479
+ export function zenGetQuery(name: string): string | undefined {
480
+ return zenGetRoute().query[name]
481
+ }
482
+
483
+ export const getQuery = zenGetQuery
484
+
485
+ // ============================================
486
+ // ZenLink Factory (for programmatic creation)
487
+ // ============================================
488
+
489
+ /**
490
+ * Create a ZenLink element programmatically
491
+ *
492
+ * @example
493
+ * ```ts
494
+ * const link = createZenLink({
495
+ * href: '/about',
496
+ * class: 'nav-link',
497
+ * children: 'About Us'
498
+ * })
499
+ * container.appendChild(link)
500
+ * ```
501
+ */
502
+ export function createZenLink(props: ZenLinkProps): HTMLAnchorElement {
503
+ const link = document.createElement('a')
504
+
505
+ // Set href
506
+ link.href = props.href
507
+
508
+ // Set class
509
+ const classes = ['zen-link']
510
+ if (props.class) classes.push(props.class)
511
+ if (zenIsActive(props.href, props.exact)) classes.push('zen-link-active')
512
+ if (isExternalUrl(props.href)) classes.push('zen-link-external')
513
+ link.className = classes.join(' ')
514
+
515
+ // Set target
516
+ if (props.target) {
517
+ link.target = props.target
518
+ } else if (isExternalUrl(props.href)) {
519
+ link.target = '_blank'
520
+ }
521
+
522
+ // Set rel for security
523
+ if (isExternalUrl(props.href) || props.target === '_blank') {
524
+ link.rel = 'noopener noreferrer'
525
+ }
526
+
527
+ // Set aria-label
528
+ if (props.ariaLabel) {
529
+ link.setAttribute('aria-label', props.ariaLabel)
530
+ }
531
+
532
+ // Set content
533
+ if (props.children) {
534
+ if (typeof props.children === 'string') {
535
+ link.textContent = props.children
536
+ } else if (Array.isArray(props.children)) {
537
+ props.children.forEach(child => link.appendChild(child))
538
+ } else {
539
+ link.appendChild(props.children)
540
+ }
541
+ }
542
+
543
+ // Click handler
544
+ link.addEventListener('click', (event: MouseEvent) => {
545
+ // Allow modifier keys for native behavior
546
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
547
+ return
548
+ }
549
+
550
+ // Check if we should use SPA navigation
551
+ if (!shouldUseSPANavigation(props.href, props.target)) {
552
+ return
553
+ }
554
+
555
+ // Prevent default
556
+ event.preventDefault()
557
+
558
+ // Call user's onClick handler
559
+ if (props.onClick) {
560
+ const result = props.onClick(event)
561
+ if (result === false) return
562
+ }
563
+
564
+ // Navigate
565
+ zenNavigate(props.href, {
566
+ replace: props.replace,
567
+ onTransition: props.onTransition,
568
+ noTransition: props.noTransition
569
+ })
570
+ })
571
+
572
+ // Preload on hover
573
+ if (props.preload) {
574
+ link.addEventListener('mouseenter', () => {
575
+ if (shouldUseSPANavigation(props.href, props.target)) {
576
+ zenPrefetch(props.href)
577
+ }
578
+ })
579
+ }
580
+
581
+ return link
582
+ }
583
+
584
+ // Alias
585
+ export const zenLink = createZenLink
package/src/render.rs ADDED
@@ -0,0 +1,8 @@
1
+ use napi_derive::napi;
2
+
3
+ #[napi]
4
+ pub fn render_route_native(input_json: String) -> String {
5
+ // This needs actual ZenIR parsing/types to work properly
6
+ // For now we placeholder since we are separate from compiler-native
7
+ format!("<!-- Native SSR Placeholder for {} -->", input_json)
8
+ }
package/src/resolve.rs ADDED
@@ -0,0 +1,53 @@
1
+ use crate::types::{RouteRecord, RouteState};
2
+ use napi_derive::napi;
3
+ use regex::Regex;
4
+ use std::collections::HashMap;
5
+
6
+ #[napi]
7
+ pub fn resolve_route_native(path: String, routes: Vec<RouteRecord>) -> Option<RouteState> {
8
+ let parts: Vec<&str> = path.splitn(2, '?').collect();
9
+ let pathname = parts[0];
10
+ let query_str = if parts.len() > 1 { parts[1] } else { "" };
11
+
12
+ let query = parse_query_string(query_str);
13
+
14
+ for route in routes {
15
+ let re = Regex::new(&route.regex).ok()?;
16
+ if let Some(caps) = re.captures(pathname) {
17
+ let mut params = HashMap::new();
18
+
19
+ for (i, name) in route.param_names.iter().enumerate() {
20
+ if let Some(m) = caps.get(i + 1) {
21
+ params.insert(name.clone(), m.as_str().to_string());
22
+ }
23
+ }
24
+
25
+ return Some(RouteState {
26
+ path: pathname.to_string(),
27
+ params,
28
+ query,
29
+ matched: Some(route.clone()),
30
+ });
31
+ }
32
+ }
33
+
34
+ None
35
+ }
36
+
37
+ pub fn parse_query_string(query: &str) -> HashMap<String, String> {
38
+ let mut params = HashMap::new();
39
+ if query.is_empty() {
40
+ return params;
41
+ }
42
+
43
+ for pair in query.split('&') {
44
+ let mut parts = pair.splitn(2, '=');
45
+ let key = parts.next().unwrap_or("");
46
+ let val = parts.next().unwrap_or("");
47
+ if !key.is_empty() {
48
+ params.insert(key.to_string(), val.to_string());
49
+ }
50
+ }
51
+
52
+ params
53
+ }