@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.
- package/cli/commands/dev.ts +107 -48
- package/compiler/discovery/componentDiscovery.ts +75 -11
- package/compiler/output/types.ts +15 -1
- package/compiler/parse/parseTemplate.ts +29 -0
- package/compiler/runtime/dataExposure.ts +27 -12
- package/compiler/runtime/generateDOM.ts +12 -3
- package/compiler/runtime/transformIR.ts +36 -0
- package/compiler/runtime/wrapExpression.ts +32 -13
- package/compiler/runtime/wrapExpressionWithLoop.ts +24 -10
- package/compiler/ssg-build.ts +71 -7
- package/compiler/test/component-stacking.test.ts +365 -0
- package/compiler/transform/componentResolver.ts +42 -4
- package/compiler/transform/fragmentLowering.ts +153 -1
- package/compiler/transform/generateBindings.ts +31 -10
- package/compiler/transform/transformNode.ts +114 -1
- package/core/config/index.ts +5 -3
- package/core/config/types.ts +67 -37
- package/core/plugins/bridge.ts +193 -0
- package/core/plugins/registry.ts +51 -6
- package/dist/cli.js +8 -0
- package/dist/zen-build.js +482 -1802
- package/dist/zen-dev.js +482 -1802
- package/dist/zen-preview.js +482 -1802
- package/dist/zenith.js +482 -1802
- package/package.json +11 -3
- package/runtime/bundle-generator.ts +10 -1
- package/runtime/client-runtime.ts +462 -120
- package/cli/utils/content.ts +0 -112
- package/router/manifest.ts +0 -314
- package/router/navigation/ZenLink.zen +0 -231
- package/router/navigation/index.ts +0 -78
- package/router/navigation/zen-link.ts +0 -584
- package/router/runtime.ts +0 -458
- package/router/types.ts +0 -168
package/cli/utils/content.ts
DELETED
|
@@ -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
|
-
}
|
package/router/manifest.ts
DELETED
|
@@ -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>
|