@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,208 @@
1
+ import fs from "fs"
2
+ import path from "path"
3
+ import native from "../index.js"
4
+ import {
5
+ type RouteDefinition,
6
+ type ParsedSegment,
7
+ SegmentType
8
+ } from "./types"
9
+
10
+ const { generateRouteManifestNative } = native
11
+
12
+ /**
13
+ * Scoring constants for route ranking
14
+ */
15
+ const SEGMENT_SCORES = {
16
+ [SegmentType.Static]: 10,
17
+ [SegmentType.Dynamic]: 5,
18
+ [SegmentType.CatchAll]: 1,
19
+ [SegmentType.OptionalCatchAll]: 0
20
+ } as const
21
+
22
+ /**
23
+ * Discover all .zen files in the pages directory
24
+ */
25
+ export function discoverPages(pagesDir: string): string[] {
26
+ const pages: string[] = []
27
+
28
+ function walk(dir: string): void {
29
+ if (!fs.existsSync(dir)) return
30
+
31
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
32
+
33
+ for (const entry of entries) {
34
+ const fullPath = path.join(dir, entry.name)
35
+
36
+ if (entry.isDirectory()) {
37
+ walk(fullPath)
38
+ } else if (entry.isFile() && entry.name.endsWith(".zen")) {
39
+ pages.push(fullPath)
40
+ }
41
+ }
42
+ }
43
+
44
+ walk(pagesDir)
45
+ return pages
46
+ }
47
+
48
+ /**
49
+ * Convert a file path to a route path
50
+ */
51
+ export function filePathToRoutePath(filePath: string, pagesDir: string): string {
52
+ const relativePath = path.relative(pagesDir, filePath)
53
+ const withoutExt = relativePath.replace(/\.zen$/, "")
54
+ const segmentsList = withoutExt.split(path.sep)
55
+ const routeSegments: string[] = []
56
+
57
+ for (const segment of segmentsList) {
58
+ if (segment === "index") continue
59
+
60
+ const optionalCatchAllMatch = segment.match(/^\[\[\.\.\.(\w+)\]\]$/)
61
+ if (optionalCatchAllMatch) {
62
+ routeSegments.push(`*${optionalCatchAllMatch[1]}?`)
63
+ continue
64
+ }
65
+
66
+ const catchAllMatch = segment.match(/^\[\.\.\.(\w+)\]$/)
67
+ if (catchAllMatch) {
68
+ routeSegments.push(`*${catchAllMatch[1]}`)
69
+ continue
70
+ }
71
+
72
+ const dynamicMatch = segment.match(/^\[(\w+)\]$/)
73
+ if (dynamicMatch) {
74
+ routeSegments.push(`:${dynamicMatch[1]}`)
75
+ continue
76
+ }
77
+
78
+ routeSegments.push(segment)
79
+ }
80
+
81
+ const routePath = "/" + routeSegments.join("/")
82
+ return routePath === "/" ? "/" : routePath.replace(/\/$/, "")
83
+ }
84
+
85
+ /**
86
+ * Parse a route path into segments
87
+ */
88
+ export function parseRouteSegments(routePath: string): ParsedSegment[] {
89
+ if (routePath === "/") return []
90
+
91
+ const segmentsList = routePath.slice(1).split("/")
92
+ const parsed: ParsedSegment[] = []
93
+
94
+ for (const segment of segmentsList) {
95
+ if (segment.startsWith("*") && segment.endsWith("?")) {
96
+ parsed.push({ segmentType: SegmentType.OptionalCatchAll, paramName: segment.slice(1, -1), raw: segment })
97
+ continue
98
+ }
99
+ if (segment.startsWith("*")) {
100
+ parsed.push({ segmentType: SegmentType.CatchAll, paramName: segment.slice(1), raw: segment })
101
+ continue
102
+ }
103
+ if (segment.startsWith(":")) {
104
+ parsed.push({ segmentType: SegmentType.Dynamic, paramName: segment.slice(1), raw: segment })
105
+ continue
106
+ }
107
+ parsed.push({ segmentType: SegmentType.Static, raw: segment })
108
+ }
109
+
110
+ return parsed
111
+ }
112
+
113
+ /**
114
+ * Calculate route score
115
+ */
116
+ export function calculateRouteScore(segments: ParsedSegment[]): number {
117
+ if (segments.length === 0) return 100
118
+ let score = 0
119
+ for (const segment of segments) {
120
+ score += SEGMENT_SCORES[segment.segmentType]
121
+ }
122
+ const staticCount = segments.filter(s => s.segmentType === SegmentType.Static).length
123
+ score += staticCount * 2
124
+ return score
125
+ }
126
+
127
+ /**
128
+ * Extract parameter names
129
+ */
130
+ export function extractParamNames(segments: ParsedSegment[]): string[] {
131
+ return segments
132
+ .filter(s => s.paramName !== undefined)
133
+ .map(s => s.paramName!)
134
+ }
135
+
136
+ /**
137
+ * Convert route path to regex pattern
138
+ */
139
+ export function routePathToRegex(routePath: string): RegExp {
140
+ if (routePath === "/") return /^\/$/
141
+
142
+ const segmentsList = routePath.slice(1).split("/")
143
+ const regexParts: string[] = []
144
+
145
+ for (let i = 0; i < segmentsList.length; i++) {
146
+ const segment = segmentsList[i]
147
+ if (!segment) continue
148
+
149
+ if (segment.startsWith("*") && segment.endsWith("?")) {
150
+ regexParts.push("(?:\\/(.*))?")
151
+ continue
152
+ }
153
+ if (segment.startsWith("*")) {
154
+ regexParts.push("\\/(.+)")
155
+ continue
156
+ }
157
+ if (segment.startsWith(":")) {
158
+ regexParts.push("\\/([^/]+)")
159
+ continue
160
+ }
161
+ const escaped = segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
162
+ regexParts.push(`\\/${escaped}`)
163
+ }
164
+
165
+ return new RegExp(`^${regexParts.join("")}\\/?$`)
166
+ }
167
+
168
+ /**
169
+ * Generate a route definition from a file path
170
+ */
171
+ export function generateRouteDefinition(filePath: string, pagesDir: string): RouteDefinition {
172
+ const routePath = filePathToRoutePath(filePath, pagesDir)
173
+ const segments = parseRouteSegments(routePath)
174
+ const paramNames = extractParamNames(segments)
175
+ const score = calculateRouteScore(segments)
176
+
177
+ // Note: RouteDefinition extends RouteRecord, which no longer has segments
178
+ return {
179
+ path: routePath,
180
+ paramNames,
181
+ score,
182
+ filePath,
183
+ regex: routePathToRegex(routePath)
184
+ }
185
+ }
186
+
187
+ export function generateRouteManifest(pagesDir: string): RouteDefinition[] {
188
+ // Optional: use native generator if available, but for now we keep the TS one for build-time safety
189
+ const pages = discoverPages(pagesDir)
190
+ const definitions = pages.map(filePath => generateRouteDefinition(filePath, pagesDir))
191
+ definitions.sort((a, b) => b.score - a.score)
192
+ return definitions
193
+ }
194
+
195
+ export function generateRouteManifestCode(definitions: RouteDefinition[]): string {
196
+ const routeEntries = definitions.map(def => {
197
+ const regex = routePathToRegex(def.path)
198
+ return ` {
199
+ path: ${JSON.stringify(def.path)},
200
+ regex: ${regex.toString()},
201
+ paramNames: ${JSON.stringify(def.paramNames)},
202
+ score: ${def.score},
203
+ filePath: ${JSON.stringify(def.filePath)}
204
+ }`
205
+ })
206
+
207
+ return `// Auto-generated route manifest\n// Do not edit directly\n\nexport const routeManifest = [\n${routeEntries.join(",\n")}\n];\n`
208
+ }
@@ -0,0 +1,231 @@
1
+ <script>
2
+ // Props extend HTMLAnchorElement properties + custom ZenLink attributes
3
+ // Standard anchor attributes: href, target, rel, download, hreflang, type, ping, referrerPolicy, etc.
4
+ // Custom ZenLink attributes: preload, exact, onClick
5
+ type Props = {
6
+ // Standard HTMLAnchorElement attributes
7
+ href?: string
8
+ target?: '_blank' | '_self' | '_parent' | '_top' | string
9
+ rel?: string
10
+ download?: string | boolean
11
+ hreflang?: string
12
+ type?: string
13
+ ping?: string
14
+ referrerPolicy?: string
15
+ class?: string
16
+ id?: string
17
+ title?: string
18
+ ariaLabel?: string
19
+ role?: string
20
+ tabIndex?: number | string
21
+ // Custom ZenLink attributes
22
+ preload?: boolean
23
+ exact?: boolean
24
+ onClick?: (event?: MouseEvent) => void | boolean
25
+ }
26
+
27
+ /**
28
+ * Handle link click - prevents default and uses SPA navigation
29
+ * Respects target="_blank" and other standard anchor behaviors
30
+ */
31
+ function handleClick(event, el) {
32
+ // Ensure attributes are set from props
33
+ if (el) {
34
+ ensureAttributes(el)
35
+ }
36
+
37
+ // Get target from the element attribute (more reliable than prop)
38
+ const linkTarget = el ? el.getAttribute('target') : (typeof target !== 'undefined' ? target : null)
39
+
40
+ // If target is _blank, _parent, or _top, let browser handle it (opens in new tab/window)
41
+ if (linkTarget === '_blank' || linkTarget === '_parent' || linkTarget === '_top') {
42
+ // Let browser handle standard navigation
43
+ return
44
+ }
45
+
46
+ // Allow modifier keys for native behavior (Cmd/Ctrl+click, etc.)
47
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
48
+ return
49
+ }
50
+
51
+ // Get href from element or prop
52
+ const linkHref = el ? el.getAttribute('href') : (typeof href !== 'undefined' ? href : null)
53
+ if (!linkHref) return
54
+
55
+ // Check if external URL (http://, https://, //, mailto:, tel:, etc.)
56
+ if (linkHref.startsWith('http://') ||
57
+ linkHref.startsWith('https://') ||
58
+ linkHref.startsWith('//') ||
59
+ linkHref.startsWith('mailto:') ||
60
+ linkHref.startsWith('tel:') ||
61
+ linkHref.startsWith('javascript:')) {
62
+ // External/special link - open in new tab if target not specified
63
+ if (!linkTarget) {
64
+ el?.setAttribute('target', '_blank')
65
+ el?.setAttribute('rel', 'noopener noreferrer')
66
+ }
67
+ // Let browser handle it
68
+ return
69
+ }
70
+
71
+ // Prevent default navigation for internal SPA links
72
+ event.preventDefault()
73
+ event.stopPropagation()
74
+
75
+ // Call onClick prop if provided
76
+ if (typeof onClick === 'function') {
77
+ const result = onClick(event)
78
+ // If onClick returns false, cancel navigation
79
+ if (result === false) {
80
+ return
81
+ }
82
+ }
83
+
84
+ // Normalize path for comparison
85
+ const normalizedHref = linkHref === '' ? '/' : linkHref
86
+ const currentPath = window.location.pathname === '' ? '/' : window.location.pathname
87
+
88
+ // Only navigate if path is different (idempotent navigation)
89
+ if (normalizedHref !== currentPath) {
90
+ console.log('[ZenLink] Navigating to:', linkHref)
91
+ // Navigate using SPA router
92
+ if (window.__zenith_router && window.__zenith_router.navigate) {
93
+ console.log('[ZenLink] Using router.navigate')
94
+ window.__zenith_router.navigate(linkHref)
95
+ } else {
96
+ console.log('[ZenLink] Using fallback history API')
97
+ // Fallback to history API
98
+ window.history.pushState(null, '', linkHref)
99
+ window.dispatchEvent(new PopStateEvent('popstate'))
100
+ }
101
+ } else {
102
+ console.log('[ZenLink] Already on route:', linkHref, '- skipping navigation')
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Handle mouse enter for preloading
108
+ */
109
+ function handleMouseEnter(event, el) {
110
+ // Ensure attributes are set
111
+ if (el) {
112
+ ensureAttributes(el)
113
+ }
114
+
115
+ const shouldPreload = typeof preload !== 'undefined' ? preload : false
116
+ console.log('[ZenLink] handleMouseEnter called, preload:', shouldPreload)
117
+ if (!shouldPreload) {
118
+ console.log('[ZenLink] Preload disabled, returning early')
119
+ return
120
+ }
121
+
122
+ const linkHref = el ? el.getAttribute('href') : (typeof href !== 'undefined' ? href : null)
123
+ if (!linkHref) {
124
+ return
125
+ }
126
+
127
+ // Skip external URLs
128
+ if (linkHref.startsWith('http://') || linkHref.startsWith('https://') || linkHref.startsWith('//')) {
129
+ return
130
+ }
131
+
132
+ console.log('[ZenLink] Prefetch triggered on hover:', linkHref)
133
+
134
+ // Prefetch the route
135
+ if (window.__zenith_router && window.__zenith_router.prefetch) {
136
+ console.log('[ZenLink] Calling router.prefetch for:', linkHref)
137
+ window.__zenith_router.prefetch(linkHref).then(() => {
138
+ console.log('[ZenLink] Prefetch complete for:', linkHref)
139
+ }).catch((error) => {
140
+ console.warn('[ZenLink] Prefetch failed for:', linkHref, error)
141
+ })
142
+ } else {
143
+ console.warn('[ZenLink] Router prefetch not available')
144
+ }
145
+ }
146
+
147
+ // Apply attributes on mount
148
+ if (typeof zenOnMount !== 'undefined') {
149
+ zenOnMount(() => {
150
+ setTimeout(() => {
151
+ // Find all ZenLink anchor elements and apply attributes
152
+ document.querySelectorAll('a[data-zen-component="Zenlink"]').forEach(el => {
153
+ ensureAttributes(el)
154
+ })
155
+ }, 0)
156
+ })
157
+ }
158
+
159
+ /**
160
+ * Apply standard anchor attributes from props to the element
161
+ * Called when the element is clicked to ensure attributes are set
162
+ */
163
+ function ensureAttributes(el) {
164
+ if (!el) return
165
+
166
+ // Set attributes from props (only if they exist and aren't already set)
167
+ const attrs = {
168
+ target: typeof target !== 'undefined' ? target : null,
169
+ rel: typeof rel !== 'undefined' ? rel : null,
170
+ download: typeof download !== 'undefined' ? download : null,
171
+ hreflang: typeof hreflang !== 'undefined' ? hreflang : null,
172
+ type: typeof type !== 'undefined' ? type : null,
173
+ ping: typeof ping !== 'undefined' ? ping : null,
174
+ referrerPolicy: typeof referrerPolicy !== 'undefined' ? referrerPolicy : null,
175
+ id: typeof id !== 'undefined' ? id : null,
176
+ title: typeof title !== 'undefined' ? title : null,
177
+ ariaLabel: typeof ariaLabel !== 'undefined' ? ariaLabel : null,
178
+ role: typeof role !== 'undefined' ? role : null,
179
+ tabIndex: typeof tabIndex !== 'undefined' ? tabIndex : null
180
+ }
181
+
182
+ // Map to HTML attribute names
183
+ const htmlAttrs = {
184
+ target: 'target',
185
+ rel: 'rel',
186
+ download: 'download',
187
+ hreflang: 'hreflang',
188
+ type: 'type',
189
+ ping: 'ping',
190
+ referrerPolicy: 'referrerpolicy',
191
+ id: 'id',
192
+ title: 'title',
193
+ ariaLabel: 'aria-label',
194
+ role: 'role',
195
+ tabIndex: 'tabindex'
196
+ }
197
+
198
+ // Set attributes that have values
199
+ for (const [prop, value] of Object.entries(attrs)) {
200
+ if (value !== null && value !== undefined && value !== '') {
201
+ const htmlAttr = htmlAttrs[prop]
202
+ if (htmlAttr && !el.hasAttribute(htmlAttr)) {
203
+ el.setAttribute(htmlAttr, String(value))
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ </script>
210
+
211
+ <style>
212
+ .zen-link {
213
+ color: inherit;
214
+ text-decoration: none;
215
+ cursor: pointer;
216
+ }
217
+
218
+ .zen-link:hover {
219
+ text-decoration: underline;
220
+ }
221
+ </style>
222
+
223
+ <a
224
+ href="{ href }"
225
+ class="zen-link { class }"
226
+ onclick="handleClick"
227
+ onmouseenter="handleMouseEnter"
228
+ style="cursor: pointer;"
229
+ >
230
+ <slot />
231
+ </a>
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Zenith Navigation System
3
+ *
4
+ * Provides SPA navigation utilities and the ZenLink API.
5
+ *
6
+ * @package @zenithbuild/router
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import { navigate, isActive, prefetch, zenLink } from '@zenithbuild/router/navigation'
11
+ *
12
+ * // Programmatic navigation
13
+ * navigate('/about')
14
+ *
15
+ * // Check active state
16
+ * if (isActive('/blog')) {
17
+ * console.log('On blog section')
18
+ * }
19
+ *
20
+ * // Prefetch for faster navigation
21
+ * prefetch('/dashboard')
22
+ *
23
+ * // Create link programmatically
24
+ * const link = zenLink({ href: '/contact', children: 'Contact' })
25
+ * ```
26
+ */
27
+
28
+ // Export all navigation utilities
29
+ export {
30
+ // Navigation API
31
+ zenNavigate,
32
+ navigate,
33
+ zenBack,
34
+ back,
35
+ zenForward,
36
+ forward,
37
+ zenGo,
38
+ go,
39
+
40
+ // Active state
41
+ zenIsActive,
42
+ isActive,
43
+
44
+ // Prefetching
45
+ zenPrefetch,
46
+ prefetch,
47
+ zenIsPrefetched,
48
+ isPrefetched,
49
+
50
+ // Transitions API
51
+ setGlobalTransition,
52
+ getGlobalTransition,
53
+ createTransitionContext,
54
+
55
+ // Route state
56
+ zenGetRoute,
57
+ getRoute,
58
+ zenGetParam,
59
+ getParam,
60
+ zenGetQuery,
61
+ getQuery,
62
+
63
+ // ZenLink factory
64
+ createZenLink,
65
+ zenLink,
66
+
67
+ // Utilities
68
+ isExternalUrl,
69
+ shouldUseSPANavigation,
70
+ normalizePath
71
+ } from './zen-link'
72
+
73
+ // Export types
74
+ export type {
75
+ ZenLinkProps,
76
+ TransitionContext,
77
+ TransitionHandler,
78
+ NavigateOptions
79
+ } from './zen-link'