@zenithbuild/cli 0.4.11 → 0.5.0-beta.2.3
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/README.md +3 -0
- package/dist/build.js +1475 -0
- package/dist/dev-server.js +214 -0
- package/dist/index.js +118 -0
- package/dist/manifest.js +273 -0
- package/dist/preview.js +655 -0
- package/dist/resolve-components.js +382 -0
- package/dist/server/resolve-request-route.js +169 -0
- package/dist/server-contract.js +146 -0
- package/dist/types/generate-env-dts.js +52 -0
- package/dist/types/generate-routes-dts.js +22 -0
- package/dist/types/index.js +34 -0
- package/dist/ui/env.js +41 -0
- package/dist/ui/format.js +133 -0
- package/dist/ui/logger.js +105 -0
- package/package.json +22 -50
- package/bin/zen-build.ts +0 -2
- package/bin/zen-dev.ts +0 -2
- package/bin/zen-preview.ts +0 -2
- package/bin/zenith.ts +0 -2
- package/dist/zen-build.js +0 -9622
- package/dist/zen-dev.js +0 -9622
- package/dist/zen-preview.js +0 -9622
- package/dist/zenith.js +0 -9622
- package/src/commands/add.ts +0 -37
- package/src/commands/build.ts +0 -36
- package/src/commands/create.ts +0 -702
- package/src/commands/dev.ts +0 -472
- package/src/commands/index.ts +0 -112
- package/src/commands/preview.ts +0 -62
- package/src/commands/remove.ts +0 -33
- package/src/index.ts +0 -10
- package/src/main.ts +0 -101
- package/src/utils/branding.ts +0 -178
- package/src/utils/logger.ts +0 -52
- package/src/utils/plugin-manager.ts +0 -114
- package/src/utils/project.ts +0 -77
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// resolve-components.js — Compile-time component expansion
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Zenith components are structural macros. This module expands PascalCase
|
|
5
|
+
// component tags into their template HTML at build time, so the compiler
|
|
6
|
+
// only ever sees standard HTML.
|
|
7
|
+
//
|
|
8
|
+
// Pipeline:
|
|
9
|
+
// buildComponentRegistry() → expandComponents() → expanded source string
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
13
|
+
import { basename, extname, join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Registry: Map<PascalCaseName, absolutePath>
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Walk `srcDir/components/` recursively. Return Map<PascalName, absPath>.
|
|
21
|
+
* Errors on duplicate component names within the registry.
|
|
22
|
+
*
|
|
23
|
+
* Also scans `srcDir/layouts/` for layout components (Document Mode).
|
|
24
|
+
*
|
|
25
|
+
* @param {string} srcDir — absolute path to the project's `src/` directory
|
|
26
|
+
* @returns {Map<string, string>}
|
|
27
|
+
*/
|
|
28
|
+
export function buildComponentRegistry(srcDir) {
|
|
29
|
+
/** @type {Map<string, string>} */
|
|
30
|
+
const registry = new Map();
|
|
31
|
+
|
|
32
|
+
const scanDirs = ['components', 'layouts', 'globals'];
|
|
33
|
+
for (const sub of scanDirs) {
|
|
34
|
+
const dir = join(srcDir, sub);
|
|
35
|
+
try {
|
|
36
|
+
statSync(dir);
|
|
37
|
+
} catch {
|
|
38
|
+
continue; // Directory doesn't exist, skip
|
|
39
|
+
}
|
|
40
|
+
walkDir(dir, registry);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return registry;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} dir
|
|
48
|
+
* @param {Map<string, string>} registry
|
|
49
|
+
*/
|
|
50
|
+
function walkDir(dir, registry) {
|
|
51
|
+
let entries;
|
|
52
|
+
try {
|
|
53
|
+
entries = readdirSync(dir);
|
|
54
|
+
} catch {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
entries.sort();
|
|
58
|
+
|
|
59
|
+
for (const name of entries) {
|
|
60
|
+
const fullPath = join(dir, name);
|
|
61
|
+
const info = statSync(fullPath);
|
|
62
|
+
if (info.isDirectory()) {
|
|
63
|
+
walkDir(fullPath, registry);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (extname(name) !== '.zen') continue;
|
|
67
|
+
|
|
68
|
+
const componentName = basename(name, '.zen');
|
|
69
|
+
// Only register PascalCase names (first char uppercase)
|
|
70
|
+
if (!/^[A-Z]/.test(componentName)) continue;
|
|
71
|
+
|
|
72
|
+
if (registry.has(componentName)) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Duplicate component name "${componentName}":\n` +
|
|
75
|
+
` 1) ${registry.get(componentName)}\n` +
|
|
76
|
+
` 2) ${fullPath}\n` +
|
|
77
|
+
`Rename one to resolve the conflict.`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
registry.set(componentName, fullPath);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Template extraction
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Strip all <script ...>...</script> and <style ...>...</style> blocks
|
|
90
|
+
* from a .zen source. Return template-only markup.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} zenSource
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
export function extractTemplate(zenSource) {
|
|
96
|
+
// Remove <script ...>...</script> blocks (greedy matching for nested content)
|
|
97
|
+
let template = zenSource;
|
|
98
|
+
|
|
99
|
+
// Strip script blocks (handles <script>, <script lang="ts">, etc.)
|
|
100
|
+
template = stripBlock(template, 'script');
|
|
101
|
+
// Strip style blocks
|
|
102
|
+
template = stripBlock(template, 'style');
|
|
103
|
+
|
|
104
|
+
return template.trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Strip a matched pair of <tag ...>...</tag> from source.
|
|
109
|
+
* Handles multiple occurrences and attributes on the opening tag.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} source
|
|
112
|
+
* @param {string} tag
|
|
113
|
+
* @returns {string}
|
|
114
|
+
*/
|
|
115
|
+
function stripBlock(source, tag) {
|
|
116
|
+
// Use a regex that matches <tag ...>...</tag> including multiline content
|
|
117
|
+
// We need a non-greedy approach for nested scenarios, but script/style
|
|
118
|
+
// blocks cannot be nested in HTML, so we can match the first closing tag.
|
|
119
|
+
const re = new RegExp(
|
|
120
|
+
`<${tag}(?:\\s[^>]*)?>` + // opening tag with optional attributes
|
|
121
|
+
`[\\s\\S]*?` + // content (non-greedy)
|
|
122
|
+
`</${tag}>`, // closing tag
|
|
123
|
+
'gi'
|
|
124
|
+
);
|
|
125
|
+
return source.replace(re, '');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Document Mode detection
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Returns true if the template contains <!doctype or <html,
|
|
134
|
+
* indicating it's a Document Mode component (layout wrapper).
|
|
135
|
+
*
|
|
136
|
+
* @param {string} template
|
|
137
|
+
* @returns {boolean}
|
|
138
|
+
*/
|
|
139
|
+
export function isDocumentMode(template) {
|
|
140
|
+
const lower = template.toLowerCase();
|
|
141
|
+
return lower.includes('<!doctype') || lower.includes('<html');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Component expansion
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
const OPEN_COMPONENT_TAG_RE = /<([A-Z][a-zA-Z0-9]*)(\s[^<>]*?)?\s*(\/?)>/g;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Recursively expand PascalCase component tags in `source`.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} source — page or component template source
|
|
154
|
+
* @param {Map<string, string>} registry — component name → .zen file path
|
|
155
|
+
* @param {string} sourceFile — source file path (for error messages)
|
|
156
|
+
* @param {Set<string>} [visited] — cycle detection set
|
|
157
|
+
* @returns {{ expandedSource: string, usedComponents: string[] }}
|
|
158
|
+
*/
|
|
159
|
+
export function expandComponents(source, registry, sourceFile, visited) {
|
|
160
|
+
if (visited && visited.size > 0) {
|
|
161
|
+
throw new Error('expandComponents() does not accept a pre-populated visited set');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const usedComponents = [];
|
|
165
|
+
const expandedSource = expandSource(source, registry, sourceFile, [], usedComponents);
|
|
166
|
+
return {
|
|
167
|
+
expandedSource,
|
|
168
|
+
usedComponents: [...new Set(usedComponents)],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Expand component tags recursively.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} source
|
|
176
|
+
* @param {Map<string, string>} registry
|
|
177
|
+
* @param {string} sourceFile
|
|
178
|
+
* @param {string[]} chain
|
|
179
|
+
* @param {string[]} usedComponents
|
|
180
|
+
* @returns {string}
|
|
181
|
+
*/
|
|
182
|
+
function expandSource(source, registry, sourceFile, chain, usedComponents) {
|
|
183
|
+
let output = source;
|
|
184
|
+
let iterations = 0;
|
|
185
|
+
const MAX_ITERATIONS = 10_000;
|
|
186
|
+
|
|
187
|
+
while (iterations < MAX_ITERATIONS) {
|
|
188
|
+
iterations += 1;
|
|
189
|
+
const tag = findNextKnownTag(output, registry, 0);
|
|
190
|
+
if (!tag) {
|
|
191
|
+
return output;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let children = '';
|
|
195
|
+
let replaceEnd = tag.end;
|
|
196
|
+
|
|
197
|
+
if (!tag.selfClosing) {
|
|
198
|
+
const close = findMatchingClose(output, tag.name, tag.end);
|
|
199
|
+
if (!close) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`Unclosed component tag <${tag.name}> in ${sourceFile} at offset ${tag.start}`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
children = expandSource(
|
|
205
|
+
output.slice(tag.end, close.contentEnd),
|
|
206
|
+
registry,
|
|
207
|
+
sourceFile,
|
|
208
|
+
chain,
|
|
209
|
+
usedComponents
|
|
210
|
+
);
|
|
211
|
+
replaceEnd = close.tagEnd;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const replacement = expandTag(
|
|
215
|
+
tag.name,
|
|
216
|
+
children,
|
|
217
|
+
registry,
|
|
218
|
+
sourceFile,
|
|
219
|
+
chain,
|
|
220
|
+
usedComponents
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
output = output.slice(0, tag.start) + replacement + output.slice(replaceEnd);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
throw new Error(
|
|
227
|
+
`Component expansion exceeded ${MAX_ITERATIONS} replacements in ${sourceFile}.`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Find the next component opening tag that exists in the registry.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} source
|
|
235
|
+
* @param {Map<string, string>} registry
|
|
236
|
+
* @param {number} startIndex
|
|
237
|
+
* @returns {{ name: string, start: number, end: number, selfClosing: boolean } | null}
|
|
238
|
+
*/
|
|
239
|
+
function findNextKnownTag(source, registry, startIndex) {
|
|
240
|
+
OPEN_COMPONENT_TAG_RE.lastIndex = startIndex;
|
|
241
|
+
|
|
242
|
+
let match;
|
|
243
|
+
while ((match = OPEN_COMPONENT_TAG_RE.exec(source)) !== null) {
|
|
244
|
+
const name = match[1];
|
|
245
|
+
if (!registry.has(name)) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
name,
|
|
250
|
+
start: match.index,
|
|
251
|
+
end: OPEN_COMPONENT_TAG_RE.lastIndex,
|
|
252
|
+
selfClosing: match[3] === '/',
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Find the matching </Name> for an opening tag, accounting for nested
|
|
261
|
+
* tags with the same name.
|
|
262
|
+
*
|
|
263
|
+
* @param {string} source — full source
|
|
264
|
+
* @param {string} tagName — tag name to match
|
|
265
|
+
* @param {number} startAfterOpen — position after the opening tag's `>`
|
|
266
|
+
* @returns {{ contentEnd: number, tagEnd: number } | null}
|
|
267
|
+
*/
|
|
268
|
+
function findMatchingClose(source, tagName, startAfterOpen) {
|
|
269
|
+
let depth = 1;
|
|
270
|
+
const escapedName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
271
|
+
const tagRe = new RegExp(`<(/?)${escapedName}(?:\\s[^<>]*?)?\\s*(/?)>`, 'g');
|
|
272
|
+
tagRe.lastIndex = startAfterOpen;
|
|
273
|
+
|
|
274
|
+
let match;
|
|
275
|
+
while ((match = tagRe.exec(source)) !== null) {
|
|
276
|
+
const isClose = match[1] === '/';
|
|
277
|
+
const isSelfClose = match[2] === '/';
|
|
278
|
+
|
|
279
|
+
if (isSelfClose && !isClose) {
|
|
280
|
+
// Self-closing <Name />, doesn't affect depth.
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (isClose) {
|
|
285
|
+
depth--;
|
|
286
|
+
if (depth === 0) {
|
|
287
|
+
return {
|
|
288
|
+
contentEnd: match.index,
|
|
289
|
+
tagEnd: match.index + match[0].length,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
depth++;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Expand a single component tag into its template HTML.
|
|
302
|
+
*
|
|
303
|
+
* @param {string} name — component name
|
|
304
|
+
* @param {string} children — children content (inner HTML of the tag)
|
|
305
|
+
* @param {Map<string, string>} registry
|
|
306
|
+
* @param {string} sourceFile
|
|
307
|
+
* @param {string[]} chain
|
|
308
|
+
* @param {string[]} usedComponents
|
|
309
|
+
* @returns {string}
|
|
310
|
+
*/
|
|
311
|
+
function expandTag(name, children, registry, sourceFile, chain, usedComponents) {
|
|
312
|
+
|
|
313
|
+
const compPath = registry.get(name);
|
|
314
|
+
if (!compPath) {
|
|
315
|
+
throw new Error(`Unknown component "${name}" referenced in ${sourceFile}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Cycle detection
|
|
319
|
+
if (chain.includes(name)) {
|
|
320
|
+
const cycle = [...chain, name].join(' -> ');
|
|
321
|
+
throw new Error(
|
|
322
|
+
`Circular component dependency detected: ${cycle}\n` +
|
|
323
|
+
`File: ${sourceFile}`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const compSource = readFileSync(compPath, 'utf8');
|
|
328
|
+
let template = extractTemplate(compSource);
|
|
329
|
+
|
|
330
|
+
// Check Document Mode
|
|
331
|
+
const docMode = isDocumentMode(template);
|
|
332
|
+
|
|
333
|
+
if (docMode) {
|
|
334
|
+
// Document Mode: must contain exactly one <slot />
|
|
335
|
+
const slotCount = countSlots(template);
|
|
336
|
+
if (slotCount !== 1) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`Document Mode component "${name}" must contain exactly one <slot />, found ${slotCount}.\n` +
|
|
339
|
+
`File: ${compPath}`
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
// Replace <slot /> with children
|
|
343
|
+
template = replaceSlot(template, children);
|
|
344
|
+
} else {
|
|
345
|
+
// Standard component
|
|
346
|
+
const slotCount = countSlots(template);
|
|
347
|
+
if (children.trim().length > 0 && slotCount === 0) {
|
|
348
|
+
throw new Error(
|
|
349
|
+
`Component "${name}" has children but its template has no <slot />.\n` +
|
|
350
|
+
`Either add <slot /> to ${compPath} or make the tag self-closing.`
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
if (slotCount > 0) {
|
|
354
|
+
template = replaceSlot(template, children || '');
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
usedComponents.push(name);
|
|
359
|
+
|
|
360
|
+
return expandSource(template, registry, compPath, [...chain, name], usedComponents);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Count occurrences of <slot /> or <slot></slot> in template.
|
|
365
|
+
* @param {string} template
|
|
366
|
+
* @returns {number}
|
|
367
|
+
*/
|
|
368
|
+
function countSlots(template) {
|
|
369
|
+
const matches = template.match(/<slot\s*>\s*<\/slot>|<slot\s*\/>|<slot\s*>/gi);
|
|
370
|
+
return matches ? matches.length : 0;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Replace <slot />, <slot/>, or <slot></slot> with replacement content.
|
|
375
|
+
* @param {string} template
|
|
376
|
+
* @param {string} content
|
|
377
|
+
* @returns {string}
|
|
378
|
+
*/
|
|
379
|
+
function replaceSlot(template, content) {
|
|
380
|
+
// Replace first occurrence of <slot /> or <slot></slot> or <slot>
|
|
381
|
+
return template.replace(/<slot\s*>\s*<\/slot>|<slot\s*\/>|<slot\s*>/i, content);
|
|
382
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic route precedence:
|
|
3
|
+
* static segment > param segment > catch-all segment.
|
|
4
|
+
* Tie-breakers: segment count (more specific first), then lexicographic path.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} a
|
|
7
|
+
* @param {string} b
|
|
8
|
+
* @returns {number}
|
|
9
|
+
*/
|
|
10
|
+
export function compareRouteSpecificity(a, b) {
|
|
11
|
+
if (a === '/' && b !== '/') return -1;
|
|
12
|
+
if (b === '/' && a !== '/') return 1;
|
|
13
|
+
|
|
14
|
+
const aSegs = splitPath(a);
|
|
15
|
+
const bSegs = splitPath(b);
|
|
16
|
+
const aClass = routeClass(aSegs);
|
|
17
|
+
const bClass = routeClass(bSegs);
|
|
18
|
+
if (aClass !== bClass) {
|
|
19
|
+
return bClass - aClass;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const max = Math.min(aSegs.length, bSegs.length);
|
|
23
|
+
for (let i = 0; i < max; i++) {
|
|
24
|
+
const aWeight = segmentWeight(aSegs[i]);
|
|
25
|
+
const bWeight = segmentWeight(bSegs[i]);
|
|
26
|
+
if (aWeight !== bWeight) {
|
|
27
|
+
return bWeight - aWeight;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (aSegs.length !== bSegs.length) {
|
|
32
|
+
return bSegs.length - aSegs.length;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return a.localeCompare(b);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} pathname
|
|
40
|
+
* @param {Array<{ path: string }>} routes
|
|
41
|
+
* @returns {{ entry: { path: string }, params: Record<string, string> } | null}
|
|
42
|
+
*/
|
|
43
|
+
export function matchRoute(pathname, routes) {
|
|
44
|
+
const target = splitPath(pathname);
|
|
45
|
+
const ordered = [...routes].sort((a, b) => compareRouteSpecificity(a.path, b.path));
|
|
46
|
+
for (const entry of ordered) {
|
|
47
|
+
const pattern = splitPath(entry.path);
|
|
48
|
+
const params = Object.create(null);
|
|
49
|
+
let patternIndex = 0;
|
|
50
|
+
let valueIndex = 0;
|
|
51
|
+
let matched = true;
|
|
52
|
+
|
|
53
|
+
while (patternIndex < pattern.length) {
|
|
54
|
+
const segment = pattern[patternIndex];
|
|
55
|
+
if (segment.startsWith('*')) {
|
|
56
|
+
const optionalCatchAll = segment.endsWith('?');
|
|
57
|
+
const key = optionalCatchAll ? segment.slice(1, -1) : segment.slice(1);
|
|
58
|
+
if (patternIndex !== pattern.length - 1) {
|
|
59
|
+
matched = false;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
const rest = target.slice(valueIndex);
|
|
63
|
+
const rootRequiredCatchAll = !optionalCatchAll && pattern.length === 1;
|
|
64
|
+
if (rest.length === 0 && !optionalCatchAll && !rootRequiredCatchAll) {
|
|
65
|
+
matched = false;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
params[key] = normalizeCatchAll(rest);
|
|
69
|
+
valueIndex = target.length;
|
|
70
|
+
patternIndex = pattern.length;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (valueIndex >= target.length) {
|
|
75
|
+
matched = false;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const value = target[valueIndex];
|
|
80
|
+
if (segment.startsWith(':')) {
|
|
81
|
+
params[segment.slice(1)] = value;
|
|
82
|
+
} else if (segment !== value) {
|
|
83
|
+
matched = false;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
patternIndex += 1;
|
|
88
|
+
valueIndex += 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!matched) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (valueIndex !== target.length || patternIndex !== pattern.length) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { entry, params: { ...params } };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve an incoming request URL against a manifest route list.
|
|
107
|
+
*
|
|
108
|
+
* @param {string | URL} reqUrl
|
|
109
|
+
* @param {Array<{ path: string }>} manifest
|
|
110
|
+
* @returns {{ matched: boolean, route: { path: string } | null, params: Record<string, string> }}
|
|
111
|
+
*/
|
|
112
|
+
export function resolveRequestRoute(reqUrl, manifest) {
|
|
113
|
+
const url = reqUrl instanceof URL ? reqUrl : new URL(String(reqUrl), 'http://localhost');
|
|
114
|
+
const matched = matchRoute(url.pathname, manifest);
|
|
115
|
+
if (!matched) {
|
|
116
|
+
return { matched: false, route: null, params: {} };
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
matched: true,
|
|
120
|
+
route: matched.entry,
|
|
121
|
+
params: matched.params
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {string[]} segments
|
|
127
|
+
* @returns {number}
|
|
128
|
+
*/
|
|
129
|
+
function routeClass(segments) {
|
|
130
|
+
let hasParam = false;
|
|
131
|
+
let hasCatchAll = false;
|
|
132
|
+
for (const segment of segments) {
|
|
133
|
+
if (segment.startsWith('*')) {
|
|
134
|
+
hasCatchAll = true;
|
|
135
|
+
} else if (segment.startsWith(':')) {
|
|
136
|
+
hasParam = true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!hasParam && !hasCatchAll) return 3;
|
|
140
|
+
if (hasCatchAll) return 1;
|
|
141
|
+
return 2;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {string | undefined} segment
|
|
146
|
+
* @returns {number}
|
|
147
|
+
*/
|
|
148
|
+
function segmentWeight(segment) {
|
|
149
|
+
if (!segment) return 0;
|
|
150
|
+
if (segment.startsWith('*')) return 1;
|
|
151
|
+
if (segment.startsWith(':')) return 2;
|
|
152
|
+
return 3;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {string} pathname
|
|
157
|
+
* @returns {string[]}
|
|
158
|
+
*/
|
|
159
|
+
function splitPath(pathname) {
|
|
160
|
+
return pathname.split('/').filter(Boolean);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @param {string[]} segments
|
|
165
|
+
* @returns {string}
|
|
166
|
+
*/
|
|
167
|
+
function normalizeCatchAll(segments) {
|
|
168
|
+
return segments.filter(Boolean).join('/');
|
|
169
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// server-contract.js — Zenith CLI V0
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Shared validation and payload resolution logic for <script server> blocks.
|
|
4
|
+
|
|
5
|
+
const NEW_KEYS = new Set(['data', 'load', 'prerender']);
|
|
6
|
+
const LEGACY_KEYS = new Set(['ssr_data', 'props', 'ssr', 'prerender']);
|
|
7
|
+
const ALLOWED_KEYS = new Set(['data', 'load', 'prerender', 'ssr_data', 'props', 'ssr']);
|
|
8
|
+
|
|
9
|
+
export function validateServerExports({ exports, filePath }) {
|
|
10
|
+
const exportKeys = Object.keys(exports);
|
|
11
|
+
const illegalKeys = exportKeys.filter(k => !ALLOWED_KEYS.has(k));
|
|
12
|
+
|
|
13
|
+
if (illegalKeys.length > 0) {
|
|
14
|
+
throw new Error(`[Zenith] ${filePath}: illegal export(s): ${illegalKeys.join(', ')}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const hasData = 'data' in exports;
|
|
18
|
+
const hasLoad = 'load' in exports;
|
|
19
|
+
|
|
20
|
+
const hasNew = hasData || hasLoad;
|
|
21
|
+
const hasLegacy = ('ssr_data' in exports) || ('props' in exports) || ('ssr' in exports);
|
|
22
|
+
|
|
23
|
+
if (hasData && hasLoad) {
|
|
24
|
+
throw new Error(`[Zenith] ${filePath}: cannot export both "data" and "load". Choose one.`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (hasNew && hasLegacy) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`[Zenith] ${filePath}: cannot mix new ("data"/"load") with legacy ("ssr_data"/"props"/"ssr") exports.`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if ('prerender' in exports && typeof exports.prerender !== 'boolean') {
|
|
34
|
+
throw new Error(`[Zenith] ${filePath}: "prerender" must be a boolean.`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (hasLoad && typeof exports.load !== 'function') {
|
|
38
|
+
throw new Error(`[Zenith] ${filePath}: "load" must be a function.`);
|
|
39
|
+
}
|
|
40
|
+
if (hasLoad) {
|
|
41
|
+
if (exports.load.length !== 1) {
|
|
42
|
+
throw new Error(`[Zenith] ${filePath}: "load(ctx)" must take exactly 1 argument.`);
|
|
43
|
+
}
|
|
44
|
+
const fnStr = exports.load.toString();
|
|
45
|
+
const paramsMatch = fnStr.match(/^[^{=]+\(([^)]*)\)/);
|
|
46
|
+
if (paramsMatch && paramsMatch[1].includes('...')) {
|
|
47
|
+
throw new Error(`[Zenith] ${filePath}: "load(ctx)" must not contain rest parameters.`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function assertJsonSerializable(value, where = 'payload') {
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
|
|
55
|
+
function walk(v, path) {
|
|
56
|
+
const t = typeof v;
|
|
57
|
+
|
|
58
|
+
if (v === null) return;
|
|
59
|
+
if (t === 'string' || t === 'number' || t === 'boolean') return;
|
|
60
|
+
|
|
61
|
+
if (t === 'bigint' || t === 'function' || t === 'symbol') {
|
|
62
|
+
throw new Error(`[Zenith] ${where}: non-serializable ${t} at ${path}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (t === 'undefined') {
|
|
66
|
+
throw new Error(`[Zenith] ${where}: undefined is not allowed at ${path}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (v instanceof Date) {
|
|
70
|
+
throw new Error(`[Zenith] ${where}: Date is not allowed at ${path} (convert to ISO string)`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (v instanceof Map || v instanceof Set) {
|
|
74
|
+
throw new Error(`[Zenith] ${where}: Map/Set not allowed at ${path}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (t === 'object') {
|
|
78
|
+
if (seen.has(v)) throw new Error(`[Zenith] ${where}: circular reference at ${path}`);
|
|
79
|
+
seen.add(v);
|
|
80
|
+
|
|
81
|
+
if (Array.isArray(v)) {
|
|
82
|
+
if (path === '$') {
|
|
83
|
+
throw new Error(`[Zenith] ${where}: top-level payload must be a plain object, not an array at ${path}`);
|
|
84
|
+
}
|
|
85
|
+
for (let i = 0; i < v.length; i++) walk(v[i], `${path}[${i}]`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const proto = Object.getPrototypeOf(v);
|
|
90
|
+
const isPlainObject = proto === null ||
|
|
91
|
+
proto === Object.prototype ||
|
|
92
|
+
(proto && proto.constructor && proto.constructor.name === 'Object');
|
|
93
|
+
|
|
94
|
+
if (!isPlainObject) {
|
|
95
|
+
throw new Error(`[Zenith] ${where}: non-plain object at ${path}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const k of Object.keys(v)) {
|
|
99
|
+
if (k === '__proto__' || k === 'constructor' || k === 'prototype') {
|
|
100
|
+
throw new Error(`[Zenith] ${where}: forbidden prototype pollution key "${k}" at ${path}.${k}`);
|
|
101
|
+
}
|
|
102
|
+
walk(v[k], `${path}.${k}`);
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
throw new Error(`[Zenith] ${where}: unsupported type at ${path}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
walk(value, '$');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function resolveServerPayload({ exports, ctx, filePath }) {
|
|
114
|
+
validateServerExports({ exports, filePath });
|
|
115
|
+
|
|
116
|
+
let payload;
|
|
117
|
+
if ('load' in exports) {
|
|
118
|
+
payload = await exports.load(ctx);
|
|
119
|
+
assertJsonSerializable(payload, `${filePath}: load(ctx) return`);
|
|
120
|
+
return payload;
|
|
121
|
+
}
|
|
122
|
+
if ('data' in exports) {
|
|
123
|
+
payload = exports.data;
|
|
124
|
+
assertJsonSerializable(payload, `${filePath}: data export`);
|
|
125
|
+
return payload;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// legacy fallback
|
|
129
|
+
if ('ssr_data' in exports) {
|
|
130
|
+
payload = exports.ssr_data;
|
|
131
|
+
assertJsonSerializable(payload, `${filePath}: ssr_data export`);
|
|
132
|
+
return payload;
|
|
133
|
+
}
|
|
134
|
+
if ('props' in exports) {
|
|
135
|
+
payload = exports.props;
|
|
136
|
+
assertJsonSerializable(payload, `${filePath}: props export`);
|
|
137
|
+
return payload;
|
|
138
|
+
}
|
|
139
|
+
if ('ssr' in exports) {
|
|
140
|
+
payload = exports.ssr;
|
|
141
|
+
assertJsonSerializable(payload, `${filePath}: ssr export`);
|
|
142
|
+
return payload;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {};
|
|
146
|
+
}
|