@zenithbuild/cli 0.4.10 → 0.5.0-beta.2.12
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 +175 -0
- package/dist/manifest.js +273 -0
- package/dist/preview.js +655 -0
- package/dist/resolve-components.js +490 -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 +172 -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,490 @@
|
|
|
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
|
+
if (isInsideExpressionScope(source, match.index)) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
name,
|
|
253
|
+
start: match.index,
|
|
254
|
+
end: OPEN_COMPONENT_TAG_RE.lastIndex,
|
|
255
|
+
selfClosing: match[3] === '/',
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Detect whether `index` is inside a `{ ... }` expression scope.
|
|
264
|
+
*
|
|
265
|
+
* This prevents component macro expansion inside embedded markup expressions,
|
|
266
|
+
* which must remain expression-local so the compiler can lower them safely.
|
|
267
|
+
*
|
|
268
|
+
* @param {string} source
|
|
269
|
+
* @param {number} index
|
|
270
|
+
* @returns {boolean}
|
|
271
|
+
*/
|
|
272
|
+
function isInsideExpressionScope(source, index) {
|
|
273
|
+
let depth = 0;
|
|
274
|
+
let mode = 'code';
|
|
275
|
+
let escaped = false;
|
|
276
|
+
const lower = source.toLowerCase();
|
|
277
|
+
|
|
278
|
+
for (let i = 0; i < index; i++) {
|
|
279
|
+
if (mode === 'code') {
|
|
280
|
+
if (lower.startsWith('<script', i)) {
|
|
281
|
+
const close = lower.indexOf('</script>', i + 7);
|
|
282
|
+
if (close < 0 || close >= index) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
i = close + '</script>'.length - 1;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (lower.startsWith('<style', i)) {
|
|
289
|
+
const close = lower.indexOf('</style>', i + 6);
|
|
290
|
+
if (close < 0 || close >= index) {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
i = close + '</style>'.length - 1;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const ch = source[i];
|
|
299
|
+
const next = i + 1 < index ? source[i + 1] : '';
|
|
300
|
+
|
|
301
|
+
if (mode === 'line-comment') {
|
|
302
|
+
if (ch === '\n') {
|
|
303
|
+
mode = 'code';
|
|
304
|
+
}
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (mode === 'block-comment') {
|
|
308
|
+
if (ch === '*' && next === '/') {
|
|
309
|
+
mode = 'code';
|
|
310
|
+
i += 1;
|
|
311
|
+
}
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (mode === 'single-quote' || mode === 'double-quote' || mode === 'template') {
|
|
315
|
+
if (escaped) {
|
|
316
|
+
escaped = false;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (ch === '\\') {
|
|
320
|
+
escaped = true;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (
|
|
324
|
+
(mode === 'single-quote' && ch === "'") ||
|
|
325
|
+
(mode === 'double-quote' && ch === '"') ||
|
|
326
|
+
(mode === 'template' && ch === '`')
|
|
327
|
+
) {
|
|
328
|
+
mode = 'code';
|
|
329
|
+
}
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (ch === '/' && next === '/') {
|
|
334
|
+
mode = 'line-comment';
|
|
335
|
+
i += 1;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (ch === '/' && next === '*') {
|
|
339
|
+
mode = 'block-comment';
|
|
340
|
+
i += 1;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (ch === "'") {
|
|
344
|
+
mode = 'single-quote';
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (ch === '"') {
|
|
348
|
+
mode = 'double-quote';
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (ch === '`') {
|
|
352
|
+
mode = 'template';
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (ch === '{') {
|
|
356
|
+
depth += 1;
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (ch === '}') {
|
|
360
|
+
depth = Math.max(0, depth - 1);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return depth > 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Find the matching </Name> for an opening tag, accounting for nested
|
|
369
|
+
* tags with the same name.
|
|
370
|
+
*
|
|
371
|
+
* @param {string} source — full source
|
|
372
|
+
* @param {string} tagName — tag name to match
|
|
373
|
+
* @param {number} startAfterOpen — position after the opening tag's `>`
|
|
374
|
+
* @returns {{ contentEnd: number, tagEnd: number } | null}
|
|
375
|
+
*/
|
|
376
|
+
function findMatchingClose(source, tagName, startAfterOpen) {
|
|
377
|
+
let depth = 1;
|
|
378
|
+
const escapedName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
379
|
+
const tagRe = new RegExp(`<(/?)${escapedName}(?:\\s[^<>]*?)?\\s*(/?)>`, 'g');
|
|
380
|
+
tagRe.lastIndex = startAfterOpen;
|
|
381
|
+
|
|
382
|
+
let match;
|
|
383
|
+
while ((match = tagRe.exec(source)) !== null) {
|
|
384
|
+
const isClose = match[1] === '/';
|
|
385
|
+
const isSelfClose = match[2] === '/';
|
|
386
|
+
|
|
387
|
+
if (isSelfClose && !isClose) {
|
|
388
|
+
// Self-closing <Name />, doesn't affect depth.
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (isClose) {
|
|
393
|
+
depth--;
|
|
394
|
+
if (depth === 0) {
|
|
395
|
+
return {
|
|
396
|
+
contentEnd: match.index,
|
|
397
|
+
tagEnd: match.index + match[0].length,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
depth++;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Expand a single component tag into its template HTML.
|
|
410
|
+
*
|
|
411
|
+
* @param {string} name — component name
|
|
412
|
+
* @param {string} children — children content (inner HTML of the tag)
|
|
413
|
+
* @param {Map<string, string>} registry
|
|
414
|
+
* @param {string} sourceFile
|
|
415
|
+
* @param {string[]} chain
|
|
416
|
+
* @param {string[]} usedComponents
|
|
417
|
+
* @returns {string}
|
|
418
|
+
*/
|
|
419
|
+
function expandTag(name, children, registry, sourceFile, chain, usedComponents) {
|
|
420
|
+
|
|
421
|
+
const compPath = registry.get(name);
|
|
422
|
+
if (!compPath) {
|
|
423
|
+
throw new Error(`Unknown component "${name}" referenced in ${sourceFile}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Cycle detection
|
|
427
|
+
if (chain.includes(name)) {
|
|
428
|
+
const cycle = [...chain, name].join(' -> ');
|
|
429
|
+
throw new Error(
|
|
430
|
+
`Circular component dependency detected: ${cycle}\n` +
|
|
431
|
+
`File: ${sourceFile}`
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const compSource = readFileSync(compPath, 'utf8');
|
|
436
|
+
let template = extractTemplate(compSource);
|
|
437
|
+
|
|
438
|
+
// Check Document Mode
|
|
439
|
+
const docMode = isDocumentMode(template);
|
|
440
|
+
|
|
441
|
+
if (docMode) {
|
|
442
|
+
// Document Mode: must contain exactly one <slot />
|
|
443
|
+
const slotCount = countSlots(template);
|
|
444
|
+
if (slotCount !== 1) {
|
|
445
|
+
throw new Error(
|
|
446
|
+
`Document Mode component "${name}" must contain exactly one <slot />, found ${slotCount}.\n` +
|
|
447
|
+
`File: ${compPath}`
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
// Replace <slot /> with children
|
|
451
|
+
template = replaceSlot(template, children);
|
|
452
|
+
} else {
|
|
453
|
+
// Standard component
|
|
454
|
+
const slotCount = countSlots(template);
|
|
455
|
+
if (children.trim().length > 0 && slotCount === 0) {
|
|
456
|
+
throw new Error(
|
|
457
|
+
`Component "${name}" has children but its template has no <slot />.\n` +
|
|
458
|
+
`Either add <slot /> to ${compPath} or make the tag self-closing.`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
if (slotCount > 0) {
|
|
462
|
+
template = replaceSlot(template, children || '');
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
usedComponents.push(name);
|
|
467
|
+
|
|
468
|
+
return expandSource(template, registry, compPath, [...chain, name], usedComponents);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Count occurrences of <slot /> or <slot></slot> in template.
|
|
473
|
+
* @param {string} template
|
|
474
|
+
* @returns {number}
|
|
475
|
+
*/
|
|
476
|
+
function countSlots(template) {
|
|
477
|
+
const matches = template.match(/<slot\s*>\s*<\/slot>|<slot\s*\/>|<slot\s*>/gi);
|
|
478
|
+
return matches ? matches.length : 0;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Replace <slot />, <slot/>, or <slot></slot> with replacement content.
|
|
483
|
+
* @param {string} template
|
|
484
|
+
* @param {string} content
|
|
485
|
+
* @returns {string}
|
|
486
|
+
*/
|
|
487
|
+
function replaceSlot(template, content) {
|
|
488
|
+
// Replace first occurrence of <slot /> or <slot></slot> or <slot>
|
|
489
|
+
return template.replace(/<slot\s*>\s*<\/slot>|<slot\s*\/>|<slot\s*>/i, content);
|
|
490
|
+
}
|
|
@@ -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
|
+
}
|