@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,112 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { marked } from 'marked';
4
-
5
- export interface ContentItem {
6
- id?: string | number;
7
- slug?: string | null;
8
- collection?: string | null;
9
- content?: string | null;
10
- [key: string]: any | null;
11
- }
12
-
13
- /**
14
- * Load all content from the content directory
15
- */
16
- export function loadContent(contentDir: string): Record<string, ContentItem[]> {
17
- if (!fs.existsSync(contentDir)) {
18
- return {};
19
- }
20
-
21
- const collections: Record<string, ContentItem[]> = {};
22
- const files = getAllFiles(contentDir);
23
-
24
- for (const filePath of files) {
25
- const ext = path.extname(filePath).toLowerCase();
26
- const relativePath = path.relative(contentDir, filePath);
27
- const collection = relativePath.split(path.sep)[0];
28
- if (!collection) continue;
29
-
30
- const slug = relativePath.replace(/\.(md|mdx|json)$/, '').replace(/\\/g, '/');
31
- const id = slug;
32
-
33
- const rawContent = fs.readFileSync(filePath, 'utf-8');
34
-
35
- if (!collections[collection]) {
36
- collections[collection] = [];
37
- }
38
-
39
- if (ext === '.json') {
40
- try {
41
- const data = JSON.parse(rawContent);
42
- collections[collection].push({
43
- id,
44
- slug,
45
- collection,
46
- content: '',
47
- ...data
48
- });
49
- } catch (e) {
50
- console.error(`Error parsing JSON file ${filePath}:`, e);
51
- }
52
- } else if (ext === '.md' || ext === '.mdx') {
53
- const { metadata, content } = parseMarkdown(rawContent);
54
- collections[collection].push({
55
- id,
56
- slug,
57
- collection,
58
- content,
59
- ...metadata
60
- });
61
- }
62
- }
63
-
64
- return collections;
65
- }
66
-
67
- function getAllFiles(dir: string, fileList: string[] = []): string[] {
68
- const files = fs.readdirSync(dir);
69
- files.forEach((file: string) => {
70
- const name = path.join(dir, file);
71
- if (fs.statSync(name).isDirectory()) {
72
- getAllFiles(name, fileList);
73
- } else {
74
- fileList.push(name);
75
- }
76
- });
77
- return fileList;
78
- }
79
-
80
- function parseMarkdown(content: string): { metadata: Record<string, any>, content: string } {
81
- const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
82
- const match = content.match(frontmatterRegex);
83
-
84
- if (!match) {
85
- return { metadata: {}, content: content.trim() };
86
- }
87
-
88
- const [, yamlStr, body] = match;
89
- const metadata: Record<string, any> = {};
90
-
91
- if (yamlStr) {
92
- yamlStr.split('\n').forEach(line => {
93
- const [key, ...values] = line.split(':');
94
- if (key && values.length > 0) {
95
- const value = values.join(':').trim();
96
- // Basic type conversion
97
- if (value === 'true') metadata[key.trim()] = true;
98
- else if (value === 'false') metadata[key.trim()] = false;
99
- else if (!isNaN(Number(value))) metadata[key.trim()] = Number(value);
100
- else if (value.startsWith('[') && value.endsWith(']')) {
101
- metadata[key.trim()] = value.slice(1, -1).split(',').map(v => v.trim().replace(/^['"]|['"]$/g, ''));
102
- }
103
- else metadata[key.trim()] = value.replace(/^['"]|['"]$/g, '');
104
- }
105
- });
106
- }
107
-
108
- return {
109
- metadata,
110
- content: marked.parse((body || '').trim()) as string
111
- };
112
- }
@@ -1,314 +0,0 @@
1
- /**
2
- * Zenith Route Manifest Generator
3
- *
4
- * Scans pages/ directory at build time and generates a route manifest
5
- * with proper scoring for deterministic route matching.
6
- */
7
-
8
- import fs from "fs"
9
- import path from "path"
10
- import {
11
- type RouteDefinition,
12
- type ParsedSegment,
13
- SegmentType
14
- } from "./types"
15
-
16
- /**
17
- * Scoring constants for route ranking
18
- * Higher scores = higher priority
19
- */
20
- const SEGMENT_SCORES = {
21
- [SegmentType.STATIC]: 10,
22
- [SegmentType.DYNAMIC]: 5,
23
- [SegmentType.CATCH_ALL]: 1,
24
- [SegmentType.OPTIONAL_CATCH_ALL]: 0
25
- } as const
26
-
27
- /**
28
- * Discover all .zen files in the pages directory
29
- */
30
- export function discoverPages(pagesDir: string): string[] {
31
- const pages: string[] = []
32
-
33
- function walk(dir: string): void {
34
- if (!fs.existsSync(dir)) return
35
-
36
- const entries = fs.readdirSync(dir, { withFileTypes: true })
37
-
38
- for (const entry of entries) {
39
- const fullPath = path.join(dir, entry.name)
40
-
41
- if (entry.isDirectory()) {
42
- walk(fullPath)
43
- } else if (entry.isFile() && entry.name.endsWith(".zen")) {
44
- pages.push(fullPath)
45
- }
46
- }
47
- }
48
-
49
- walk(pagesDir)
50
- return pages
51
- }
52
-
53
- /**
54
- * Convert a file path to a route path
55
- *
56
- * Examples:
57
- * pages/index.zen → /
58
- * pages/about.zen → /about
59
- * pages/blog/index.zen → /blog
60
- * pages/blog/[id].zen → /blog/:id
61
- * pages/posts/[...slug].zen → /posts/*slug
62
- * pages/[[...all]].zen → /*all (optional)
63
- */
64
- export function filePathToRoutePath(filePath: string, pagesDir: string): string {
65
- // Get relative path from pages directory
66
- const relativePath = path.relative(pagesDir, filePath)
67
-
68
- // Remove .zen extension
69
- const withoutExt = relativePath.replace(/\.zen$/, "")
70
-
71
- // Split into segments
72
- const segments = withoutExt.split(path.sep)
73
-
74
- // Transform segments
75
- const routeSegments: string[] = []
76
-
77
- for (const segment of segments) {
78
- // Handle index files (they represent the directory root)
79
- if (segment === "index") {
80
- continue
81
- }
82
-
83
- // Handle optional catch-all: [[...param]]
84
- const optionalCatchAllMatch = segment.match(/^\[\[\.\.\.(\w+)\]\]$/)
85
- if (optionalCatchAllMatch) {
86
- routeSegments.push(`*${optionalCatchAllMatch[1]}?`)
87
- continue
88
- }
89
-
90
- // Handle required catch-all: [...param]
91
- const catchAllMatch = segment.match(/^\[\.\.\.(\w+)\]$/)
92
- if (catchAllMatch) {
93
- routeSegments.push(`*${catchAllMatch[1]}`)
94
- continue
95
- }
96
-
97
- // Handle dynamic segment: [param]
98
- const dynamicMatch = segment.match(/^\[(\w+)\]$/)
99
- if (dynamicMatch) {
100
- routeSegments.push(`:${dynamicMatch[1]}`)
101
- continue
102
- }
103
-
104
- // Static segment
105
- routeSegments.push(segment)
106
- }
107
-
108
- // Build route path
109
- const routePath = "/" + routeSegments.join("/")
110
-
111
- // Normalize trailing slashes
112
- return routePath === "/" ? "/" : routePath.replace(/\/$/, "")
113
- }
114
-
115
- /**
116
- * Parse a route path into segments with type information
117
- */
118
- export function parseRouteSegments(routePath: string): ParsedSegment[] {
119
- if (routePath === "/") {
120
- return []
121
- }
122
-
123
- const segments = routePath.slice(1).split("/")
124
- const parsed: ParsedSegment[] = []
125
-
126
- for (const segment of segments) {
127
- // Optional catch-all: *param?
128
- if (segment.startsWith("*") && segment.endsWith("?")) {
129
- parsed.push({
130
- type: SegmentType.OPTIONAL_CATCH_ALL,
131
- paramName: segment.slice(1, -1),
132
- raw: segment
133
- })
134
- continue
135
- }
136
-
137
- // Required catch-all: *param
138
- if (segment.startsWith("*")) {
139
- parsed.push({
140
- type: SegmentType.CATCH_ALL,
141
- paramName: segment.slice(1),
142
- raw: segment
143
- })
144
- continue
145
- }
146
-
147
- // Dynamic: :param
148
- if (segment.startsWith(":")) {
149
- parsed.push({
150
- type: SegmentType.DYNAMIC,
151
- paramName: segment.slice(1),
152
- raw: segment
153
- })
154
- continue
155
- }
156
-
157
- // Static
158
- parsed.push({
159
- type: SegmentType.STATIC,
160
- raw: segment
161
- })
162
- }
163
-
164
- return parsed
165
- }
166
-
167
- /**
168
- * Calculate route score based on segments
169
- * Higher scores = higher priority for matching
170
- */
171
- export function calculateRouteScore(segments: ParsedSegment[]): number {
172
- if (segments.length === 0) {
173
- // Root route gets a high score
174
- return 100
175
- }
176
-
177
- let score = 0
178
-
179
- for (const segment of segments) {
180
- score += SEGMENT_SCORES[segment.type]
181
- }
182
-
183
- // Bonus for having more static segments (specificity)
184
- const staticCount = segments.filter(s => s.type === SegmentType.STATIC).length
185
- score += staticCount * 2
186
-
187
- return score
188
- }
189
-
190
- /**
191
- * Extract parameter names from parsed segments
192
- */
193
- export function extractParamNames(segments: ParsedSegment[]): string[] {
194
- return segments
195
- .filter(s => s.paramName !== undefined)
196
- .map(s => s.paramName!)
197
- }
198
-
199
- /**
200
- * Convert route path to regex pattern
201
- *
202
- * Examples:
203
- * /about → /^\/about\/?$/
204
- * /blog/:id → /^\/blog\/([^/]+)\/?$/
205
- * /posts/*slug → /^\/posts\/(.+)\/?$/
206
- * / → /^\/$/
207
- * /*all? → /^(?:\/(.*))?$/ (optional catch-all)
208
- */
209
- export function routePathToRegex(routePath: string): RegExp {
210
- if (routePath === "/") {
211
- return /^\/$/
212
- }
213
-
214
- const segments = routePath.slice(1).split("/")
215
- const regexParts: string[] = []
216
-
217
- for (let i = 0; i < segments.length; i++) {
218
- const segment = segments[i]
219
- if (!segment) continue
220
-
221
- // Optional catch-all: *param?
222
- if (segment.startsWith("*") && segment.endsWith("?")) {
223
- // Optional catch-all - matches zero or more path segments
224
- // Should only be at the end
225
- regexParts.push("(?:\\/(.*))?")
226
- continue
227
- }
228
-
229
- // Required catch-all: *param
230
- if (segment.startsWith("*")) {
231
- // Required catch-all - matches one or more path segments
232
- regexParts.push("\\/(.+)")
233
- continue
234
- }
235
-
236
- // Dynamic: :param
237
- if (segment.startsWith(":")) {
238
- regexParts.push("\\/([^/]+)")
239
- continue
240
- }
241
-
242
- // Static segment - escape special regex characters
243
- const escaped = segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
244
- regexParts.push(`\\/${escaped}`)
245
- }
246
-
247
- // Build final regex with optional trailing slash
248
- const pattern = `^${regexParts.join("")}\\/?$`
249
- return new RegExp(pattern)
250
- }
251
-
252
- /**
253
- * Generate a route definition from a file path
254
- */
255
- export function generateRouteDefinition(
256
- filePath: string,
257
- pagesDir: string
258
- ): RouteDefinition {
259
- const routePath = filePathToRoutePath(filePath, pagesDir)
260
- const segments = parseRouteSegments(routePath)
261
- const paramNames = extractParamNames(segments)
262
- const score = calculateRouteScore(segments)
263
-
264
- return {
265
- path: routePath,
266
- segments,
267
- paramNames,
268
- score,
269
- filePath
270
- }
271
- }
272
-
273
- /**
274
- * Generate route manifest from pages directory
275
- * Returns route definitions sorted by score (highest first)
276
- */
277
- export function generateRouteManifest(pagesDir: string): RouteDefinition[] {
278
- const pages = discoverPages(pagesDir)
279
-
280
- const definitions = pages.map(filePath =>
281
- generateRouteDefinition(filePath, pagesDir)
282
- )
283
-
284
- // Sort by score descending (highest priority first)
285
- definitions.sort((a, b) => b.score - a.score)
286
-
287
- return definitions
288
- }
289
-
290
- /**
291
- * Generate the route manifest as JavaScript code for runtime
292
- */
293
- export function generateRouteManifestCode(definitions: RouteDefinition[]): string {
294
- const routeEntries = definitions.map(def => {
295
- const regex = routePathToRegex(def.path)
296
-
297
- return ` {
298
- path: ${JSON.stringify(def.path)},
299
- regex: ${regex.toString()},
300
- paramNames: ${JSON.stringify(def.paramNames)},
301
- score: ${def.score},
302
- filePath: ${JSON.stringify(def.filePath)}
303
- }`
304
- })
305
-
306
- return `// Auto-generated route manifest
307
- // Do not edit directly
308
-
309
- export const routeManifest = [
310
- ${routeEntries.join(",\n")}
311
- ];
312
- `
313
- }
314
-
@@ -1,231 +0,0 @@
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>