@zenithbuild/core 0.6.3 → 1.2.0

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