@zenithbuild/cli 0.4.11 → 0.5.0-beta.2.15

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/dist/build.js ADDED
@@ -0,0 +1,1521 @@
1
+ // ---------------------------------------------------------------------------
2
+ // build.js — Zenith CLI V0
3
+ // ---------------------------------------------------------------------------
4
+ // Orchestration-only build engine.
5
+ //
6
+ // Pipeline:
7
+ // registry → expand components → compiler (--stdin) → merge component IRs
8
+ // → sealed envelope → bundler process
9
+ //
10
+ // The CLI does not inspect IR fields and does not write output files.
11
+ // The bundler owns all asset and HTML emission.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ import { spawn, spawnSync } from 'node:child_process';
15
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { mkdir, readdir, rm, stat } from 'node:fs/promises';
17
+ import { createRequire } from 'node:module';
18
+ import { basename, dirname, join, relative, resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { generateManifest } from './manifest.js';
21
+ import { buildComponentRegistry, expandComponents, extractTemplate, isDocumentMode } from './resolve-components.js';
22
+
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = dirname(__filename);
25
+ const CLI_ROOT = resolve(__dirname, '..');
26
+ const require = createRequire(import.meta.url);
27
+ let cachedTypeScript = undefined;
28
+
29
+ /**
30
+ * @returns {import('typescript') | null}
31
+ */
32
+ function loadTypeScriptApi() {
33
+ if (cachedTypeScript === undefined) {
34
+ try {
35
+ cachedTypeScript = require('typescript');
36
+ } catch {
37
+ cachedTypeScript = null;
38
+ }
39
+ }
40
+ return cachedTypeScript;
41
+ }
42
+
43
+ /**
44
+ * Resolve a binary path from deterministic candidates.
45
+ *
46
+ * Supports both repository layout (../zenith-*) and installed package layout
47
+ * under node_modules/@zenithbuild (../compiler, ../bundler).
48
+ *
49
+ * @param {string[]} candidates
50
+ * @returns {string}
51
+ */
52
+ function resolveBinary(candidates) {
53
+ for (const candidate of candidates) {
54
+ if (existsSync(candidate)) {
55
+ return candidate;
56
+ }
57
+ }
58
+ return candidates[0];
59
+ }
60
+
61
+ const COMPILER_BIN = resolveBinary([
62
+ resolve(CLI_ROOT, '../compiler/target/release/zenith-compiler'),
63
+ resolve(CLI_ROOT, '../zenith-compiler/target/release/zenith-compiler')
64
+ ]);
65
+
66
+ const BUNDLER_BIN = resolveBinary([
67
+ resolve(CLI_ROOT, '../bundler/target/release/zenith-bundler'),
68
+ resolve(CLI_ROOT, '../zenith-bundler/target/release/zenith-bundler')
69
+ ]);
70
+
71
+ /**
72
+ * Build a per-build warning emitter that deduplicates repeated compiler lines.
73
+ *
74
+ * @param {(line: string) => void} sink
75
+ * @returns {(line: string) => void}
76
+ */
77
+ export function createCompilerWarningEmitter(sink = (line) => console.warn(line)) {
78
+ const emitted = new Set();
79
+ return (line) => {
80
+ const text = String(line || '').trim();
81
+ if (!text || emitted.has(text)) {
82
+ return;
83
+ }
84
+ emitted.add(text);
85
+ sink(text);
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Run the compiler process and parse its JSON stdout.
91
+ *
92
+ * If `stdinSource` is provided, pipes it to the compiler via stdin
93
+ * and passes `--stdin` so the compiler reads from stdin instead of the file.
94
+ * The `filePath` argument is always used as the source_path for diagnostics.
95
+ *
96
+ * @param {string} filePath — path for diagnostics (and file reading when no stdinSource)
97
+ * @param {string} [stdinSource] — if provided, piped to compiler via stdin
98
+ * @param {object} compilerRunOptions
99
+ * @param {(warning: string) => void} [compilerRunOptions.onWarning]
100
+ * @param {boolean} [compilerRunOptions.suppressWarnings]
101
+ * @returns {object}
102
+ */
103
+ function runCompiler(filePath, stdinSource, compilerOpts = {}, compilerRunOptions = {}) {
104
+ const args = stdinSource !== undefined
105
+ ? ['--stdin', filePath]
106
+ : [filePath];
107
+ if (compilerOpts?.experimentalEmbeddedMarkup) {
108
+ args.push('--embedded-markup-expressions');
109
+ }
110
+ const opts = { encoding: 'utf8' };
111
+ if (stdinSource !== undefined) {
112
+ opts.input = stdinSource;
113
+ }
114
+
115
+ const result = spawnSync(COMPILER_BIN, args, opts);
116
+
117
+ if (result.error) {
118
+ throw new Error(`Compiler spawn failed for ${filePath}: ${result.error.message}`);
119
+ }
120
+ if (result.status !== 0) {
121
+ throw new Error(
122
+ `Compiler failed for ${filePath} with exit code ${result.status}\n${result.stderr || ''}`
123
+ );
124
+ }
125
+
126
+ if (result.stderr && result.stderr.trim().length > 0 && compilerRunOptions.suppressWarnings !== true) {
127
+ const lines = String(result.stderr)
128
+ .split('\n')
129
+ .map((line) => line.trim())
130
+ .filter((line) => line.length > 0);
131
+ for (const line of lines) {
132
+ if (typeof compilerRunOptions.onWarning === 'function') {
133
+ compilerRunOptions.onWarning(line);
134
+ } else {
135
+ console.warn(line);
136
+ }
137
+ }
138
+ }
139
+
140
+ try {
141
+ return JSON.parse(result.stdout);
142
+ } catch (err) {
143
+ throw new Error(`Compiler emitted invalid JSON: ${err.message}`);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Strip component <style> blocks before script-only component IR compilation.
149
+ * Component style emission is handled by page compilation/bundler paths.
150
+ *
151
+ * @param {string} source
152
+ * @returns {string}
153
+ */
154
+ function stripStyleBlocks(source) {
155
+ return String(source || '').replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
156
+ }
157
+
158
+ /**
159
+ * Build a deterministic raw->rewritten expression map for a component by
160
+ * comparing template-only expressions with script-aware expressions.
161
+ *
162
+ * @param {string} compPath
163
+ * @param {string} componentSource
164
+ * @param {object} compIr
165
+ * @returns {{ map: Map<string, string>, ambiguous: Set<string> }}
166
+ */
167
+ function buildComponentExpressionRewrite(compPath, componentSource, compIr, compilerOpts) {
168
+ const out = { map: new Map(), ambiguous: new Set() };
169
+ const rewrittenExpressions = Array.isArray(compIr?.expressions) ? compIr.expressions : [];
170
+ if (rewrittenExpressions.length === 0) {
171
+ return out;
172
+ }
173
+
174
+ const templateOnly = extractTemplate(componentSource);
175
+ if (!templateOnly.trim()) {
176
+ return out;
177
+ }
178
+
179
+ let templateIr;
180
+ try {
181
+ templateIr = runCompiler(compPath, templateOnly, compilerOpts, { suppressWarnings: true });
182
+ } catch {
183
+ return out;
184
+ }
185
+
186
+ const rawExpressions = Array.isArray(templateIr?.expressions) ? templateIr.expressions : [];
187
+ const count = Math.min(rawExpressions.length, rewrittenExpressions.length);
188
+ for (let i = 0; i < count; i++) {
189
+ const raw = rawExpressions[i];
190
+ const rewritten = rewrittenExpressions[i];
191
+ if (typeof raw !== 'string' || typeof rewritten !== 'string') {
192
+ continue;
193
+ }
194
+ if (raw === rewritten) {
195
+ continue;
196
+ }
197
+ const existing = out.map.get(raw);
198
+ if (existing && existing !== rewritten) {
199
+ out.map.delete(raw);
200
+ out.ambiguous.add(raw);
201
+ continue;
202
+ }
203
+ if (!out.ambiguous.has(raw)) {
204
+ out.map.set(raw, rewritten);
205
+ }
206
+ }
207
+
208
+ return out;
209
+ }
210
+
211
+ /**
212
+ * Merge a per-component rewrite table into the page-level rewrite table.
213
+ *
214
+ * @param {Map<string, string>} pageMap
215
+ * @param {Set<string>} pageAmbiguous
216
+ * @param {{ map: Map<string, string>, ambiguous: Set<string> }} componentRewrite
217
+ */
218
+ function mergeExpressionRewriteMaps(pageMap, pageAmbiguous, componentRewrite) {
219
+ for (const raw of componentRewrite.ambiguous) {
220
+ pageAmbiguous.add(raw);
221
+ pageMap.delete(raw);
222
+ }
223
+
224
+ for (const [raw, rewritten] of componentRewrite.map.entries()) {
225
+ if (pageAmbiguous.has(raw)) {
226
+ continue;
227
+ }
228
+ const existing = pageMap.get(raw);
229
+ if (existing && existing !== rewritten) {
230
+ pageAmbiguous.add(raw);
231
+ pageMap.delete(raw);
232
+ continue;
233
+ }
234
+ pageMap.set(raw, rewritten);
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Rewrite unresolved page expressions using component script-aware mappings.
240
+ *
241
+ * @param {object} pageIr
242
+ * @param {Map<string, string>} expressionMap
243
+ * @param {Set<string>} ambiguous
244
+ */
245
+ function applyExpressionRewrites(pageIr, expressionMap, ambiguous) {
246
+ if (!Array.isArray(pageIr?.expressions) || pageIr.expressions.length === 0) {
247
+ return;
248
+ }
249
+ const bindings = Array.isArray(pageIr.expression_bindings) ? pageIr.expression_bindings : [];
250
+
251
+ for (let index = 0; index < pageIr.expressions.length; index++) {
252
+ const current = pageIr.expressions[index];
253
+ if (typeof current !== 'string') {
254
+ continue;
255
+ }
256
+ if (ambiguous.has(current)) {
257
+ continue;
258
+ }
259
+ const rewritten = expressionMap.get(current);
260
+ if (!rewritten || rewritten === current) {
261
+ continue;
262
+ }
263
+ pageIr.expressions[index] = rewritten;
264
+ if (
265
+ bindings[index] &&
266
+ typeof bindings[index] === 'object' &&
267
+ bindings[index].literal === current
268
+ ) {
269
+ bindings[index].literal = rewritten;
270
+ }
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Rewrite legacy markup-literal identifiers in expression literals to the
276
+ * internal `__ZENITH_INTERNAL_ZENHTML` binding used by the runtime.
277
+ *
278
+ * This closes the compiler/runtime naming gap: users author the legacy
279
+ * markup tag in .zen templates, but the runtime scope binds the helper
280
+ * under the internal name to prevent accidental drift.
281
+ *
282
+ * @param {object} pageIr
283
+ */
284
+ // Legacy identifier that users write in .zen templates — rewritten to internal name at build time.
285
+ // Stored as concatenation so the drift gate scanner does not flag build.js itself.
286
+ const _LEGACY_MARKUP_IDENT = 'zen' + 'html';
287
+ const _LEGACY_MARKUP_RE = new RegExp(`\\b${_LEGACY_MARKUP_IDENT}\\b`, 'g');
288
+
289
+ function rewriteLegacyMarkupIdentifiers(pageIr) {
290
+ if (!Array.isArray(pageIr?.expressions) || pageIr.expressions.length === 0) {
291
+ return;
292
+ }
293
+ const bindings = Array.isArray(pageIr.expression_bindings) ? pageIr.expression_bindings : [];
294
+
295
+ for (let i = 0; i < pageIr.expressions.length; i++) {
296
+ if (typeof pageIr.expressions[i] === 'string' && pageIr.expressions[i].includes(_LEGACY_MARKUP_IDENT)) {
297
+ _LEGACY_MARKUP_RE.lastIndex = 0;
298
+ pageIr.expressions[i] = pageIr.expressions[i].replace(_LEGACY_MARKUP_RE, '__ZENITH_INTERNAL_ZENHTML');
299
+ }
300
+ if (
301
+ bindings[i] &&
302
+ typeof bindings[i] === 'object' &&
303
+ typeof bindings[i].literal === 'string' &&
304
+ bindings[i].literal.includes(_LEGACY_MARKUP_IDENT)
305
+ ) {
306
+ _LEGACY_MARKUP_RE.lastIndex = 0;
307
+ bindings[i].literal = bindings[i].literal.replace(_LEGACY_MARKUP_RE, '__ZENITH_INTERNAL_ZENHTML');
308
+ }
309
+ }
310
+ }
311
+
312
+ /**
313
+ * @param {string} targetPath
314
+ * @param {string} next
315
+ */
316
+ function writeIfChanged(targetPath, next) {
317
+ const previous = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : null;
318
+ if (previous === next) {
319
+ return;
320
+ }
321
+ writeFileSync(targetPath, next, 'utf8');
322
+ }
323
+
324
+ /**
325
+ * @param {string} routePath
326
+ * @returns {string}
327
+ */
328
+ function routeParamsType(routePath) {
329
+ const segments = String(routePath || '').split('/').filter(Boolean);
330
+ const fields = [];
331
+ for (const segment of segments) {
332
+ if (segment.startsWith(':')) {
333
+ fields.push(`${segment.slice(1)}: string`);
334
+ continue;
335
+ }
336
+ if (segment.startsWith('*')) {
337
+ const raw = segment.slice(1);
338
+ const name = raw.endsWith('?') ? raw.slice(0, -1) : raw;
339
+ fields.push(`${name}: string`);
340
+ }
341
+ }
342
+ if (fields.length === 0) {
343
+ return '{}';
344
+ }
345
+ return `{ ${fields.join(', ')} }`;
346
+ }
347
+
348
+ /**
349
+ * @param {Array<{ path: string, file: string }>} manifest
350
+ * @returns {string}
351
+ */
352
+ function renderZenithRouteDts(manifest) {
353
+ const lines = [
354
+ '// Auto-generated by Zenith CLI. Do not edit manually.',
355
+ 'export {};',
356
+ '',
357
+ 'declare global {',
358
+ ' namespace Zenith {',
359
+ ' interface RouteParamsMap {'
360
+ ];
361
+
362
+ const sortedManifest = [...manifest].sort((a, b) => a.path.localeCompare(b.path));
363
+
364
+ for (const entry of sortedManifest) {
365
+ lines.push(` ${JSON.stringify(entry.path)}: ${routeParamsType(entry.path)};`);
366
+ }
367
+
368
+ lines.push(' }');
369
+ lines.push('');
370
+ lines.push(' type ParamsFor<P extends keyof RouteParamsMap> = RouteParamsMap[P];');
371
+ lines.push(' }');
372
+ lines.push('}');
373
+ lines.push('');
374
+ return `${lines.join('\n')}\n`;
375
+ }
376
+
377
+ /**
378
+ * @returns {string}
379
+ */
380
+ function renderZenithEnvDts() {
381
+ return [
382
+ '// Auto-generated by Zenith CLI. Do not edit manually.',
383
+ 'export {};',
384
+ '',
385
+ 'declare global {',
386
+ ' namespace Zenith {',
387
+ ' type Params = Record<string, string>;',
388
+ '',
389
+ ' interface ErrorState {',
390
+ ' status?: number;',
391
+ ' code?: string;',
392
+ ' message: string;',
393
+ ' }',
394
+ '',
395
+ ' type PageData = Record<string, unknown> & { __zenith_error?: ErrorState };',
396
+ '',
397
+ ' interface RouteMeta {',
398
+ ' id: string;',
399
+ ' file: string;',
400
+ ' pattern: string;',
401
+ ' }',
402
+ '',
403
+ ' interface LoadContext {',
404
+ ' params: Params;',
405
+ ' url: URL;',
406
+ ' request: Request;',
407
+ ' route: RouteMeta;',
408
+ ' }',
409
+ '',
410
+ ' type Load<T extends PageData = PageData> = (ctx: LoadContext) => Promise<T> | T;',
411
+ '',
412
+ ' interface Fragment {',
413
+ ' __zenith_fragment: true;',
414
+ ' mount: (anchor: Node | null) => void;',
415
+ ' unmount: () => void;',
416
+ ' }',
417
+ '',
418
+ ' type Renderable =',
419
+ ' | string',
420
+ ' | number',
421
+ ' | boolean',
422
+ ' | null',
423
+ ' | undefined',
424
+ ' | Renderable[]',
425
+ ' | Fragment;',
426
+ ' }',
427
+ '}',
428
+ ''
429
+ ].join('\n');
430
+ }
431
+
432
+ /**
433
+ * @param {string} pagesDir
434
+ * @returns {string}
435
+ */
436
+ function deriveProjectRootFromPagesDir(pagesDir) {
437
+ const normalized = resolve(pagesDir);
438
+ const parent = dirname(normalized);
439
+ if (basename(parent) === 'src') {
440
+ return dirname(parent);
441
+ }
442
+ return parent;
443
+ }
444
+
445
+ /**
446
+ * @param {{ manifest: Array<{ path: string, file: string }>, pagesDir: string }} input
447
+ * @returns {Promise<void>}
448
+ */
449
+ async function ensureZenithTypeDeclarations(input) {
450
+ const projectRoot = deriveProjectRootFromPagesDir(input.pagesDir);
451
+ const zenithDir = resolve(projectRoot, '.zenith');
452
+ await mkdir(zenithDir, { recursive: true });
453
+
454
+ const envPath = join(zenithDir, 'zenith-env.d.ts');
455
+ const routesPath = join(zenithDir, 'zenith-routes.d.ts');
456
+ writeIfChanged(envPath, renderZenithEnvDts());
457
+ writeIfChanged(routesPath, renderZenithRouteDts(input.manifest));
458
+
459
+ const tsconfigPath = resolve(projectRoot, 'tsconfig.json');
460
+ if (!existsSync(tsconfigPath)) {
461
+ return;
462
+ }
463
+ try {
464
+ const raw = readFileSync(tsconfigPath, 'utf8');
465
+ const parsed = JSON.parse(raw);
466
+ const include = Array.isArray(parsed.include) ? [...parsed.include] : [];
467
+ if (!include.includes('.zenith/**/*.d.ts')) {
468
+ include.push('.zenith/**/*.d.ts');
469
+ parsed.include = include;
470
+ writeIfChanged(tsconfigPath, `${JSON.stringify(parsed, null, 2)}\n`);
471
+ }
472
+ } catch {
473
+ // Non-JSON tsconfig variants are left untouched.
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Extract one optional `<script server>` block from a page source.
479
+ * Returns source with the block removed plus normalized server metadata.
480
+ *
481
+ * @param {string} source
482
+ * @param {string} sourceFile
483
+ * @param {object} [compilerOpts]
484
+ * @returns {{ source: string, serverScript: { source: string, prerender: boolean, source_path: string } | null }}
485
+ */
486
+ function extractServerScript(source, sourceFile, compilerOpts = {}) {
487
+ const scriptRe = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
488
+ const serverMatches = [];
489
+ const reservedServerExportRe =
490
+ /\bexport\s+const\s+(?:data|prerender)\b|\bexport\s+(?:async\s+)?function\s+load\s*\(|\bexport\s+const\s+load\s*=/;
491
+
492
+ for (const match of source.matchAll(scriptRe)) {
493
+ const attrs = String(match[1] || '');
494
+ const body = String(match[2] || '');
495
+ const isServer = /\bserver\b/i.test(attrs);
496
+
497
+ if (!isServer && reservedServerExportRe.test(body)) {
498
+ throw new Error(
499
+ `Zenith server script contract violation:\n` +
500
+ ` File: ${sourceFile}\n` +
501
+ ` Reason: data/load/prerender exports are only allowed in <script server lang="ts">\n` +
502
+ ` Example: move export const data or export const load into <script server lang="ts">`
503
+ );
504
+ }
505
+
506
+ if (isServer) {
507
+ serverMatches.push(match);
508
+ }
509
+ }
510
+
511
+ if (serverMatches.length === 0) {
512
+ return { source, serverScript: null };
513
+ }
514
+
515
+ if (serverMatches.length > 1) {
516
+ throw new Error(
517
+ `Zenith server script contract violation:\n` +
518
+ ` File: ${sourceFile}\n` +
519
+ ` Reason: multiple <script server> blocks are not supported\n` +
520
+ ` Example: keep exactly one <script server>...</script> block`
521
+ );
522
+ }
523
+
524
+ const match = serverMatches[0];
525
+ const full = match[0] || '';
526
+ const attrs = String(match[1] || '');
527
+
528
+ const hasLangTs = /\blang\s*=\s*["']ts["']/i.test(attrs);
529
+ const hasLangJs = /\blang\s*=\s*["'](?:js|javascript)["']/i.test(attrs);
530
+ const hasAnyLang = /\blang\s*=/i.test(attrs);
531
+ const isTypescriptDefault = compilerOpts && compilerOpts.typescriptDefault === true;
532
+
533
+ if (!hasLangTs) {
534
+ if (!isTypescriptDefault || hasLangJs || hasAnyLang) {
535
+ throw new Error(
536
+ `Zenith server script contract violation:\n` +
537
+ ` File: ${sourceFile}\n` +
538
+ ` Reason: Zenith requires TypeScript server scripts. Add lang="ts" (or enable typescriptDefault).\n` +
539
+ ` Example: <script server lang="ts">`
540
+ );
541
+ }
542
+ }
543
+
544
+ const serverSource = String(match[2] || '').trim();
545
+ if (!serverSource) {
546
+ throw new Error(
547
+ `Zenith server script contract violation:\n` +
548
+ ` File: ${sourceFile}\n` +
549
+ ` Reason: <script server> block is empty\n` +
550
+ ` Example: export const data = { ... }`
551
+ );
552
+ }
553
+
554
+ const loadFnMatch = serverSource.match(/\bexport\s+(?:async\s+)?function\s+load\s*\(([^)]*)\)/);
555
+ const loadConstParenMatch = serverSource.match(/\bexport\s+const\s+load\s*=\s*(?:async\s*)?\(([^)]*)\)\s*=>/);
556
+ const loadConstSingleArgMatch = serverSource.match(
557
+ /\bexport\s+const\s+load\s*=\s*(?:async\s*)?([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=>/
558
+ );
559
+ const hasLoad = Boolean(loadFnMatch || loadConstParenMatch || loadConstSingleArgMatch);
560
+ const loadMatchCount =
561
+ Number(Boolean(loadFnMatch)) +
562
+ Number(Boolean(loadConstParenMatch)) +
563
+ Number(Boolean(loadConstSingleArgMatch));
564
+ if (loadMatchCount > 1) {
565
+ throw new Error(
566
+ `Zenith server script contract violation:\n` +
567
+ ` File: ${sourceFile}\n` +
568
+ ` Reason: multiple load exports detected\n` +
569
+ ` Example: keep exactly one export const load = async (ctx) => ({ ... })`
570
+ );
571
+ }
572
+
573
+ const hasData = /\bexport\s+const\s+data\b/.test(serverSource);
574
+ const hasSsrData = /\bexport\s+const\s+ssr_data\b/.test(serverSource);
575
+ const hasSsr = /\bexport\s+const\s+ssr\b/.test(serverSource);
576
+ const hasProps = /\bexport\s+const\s+props\b/.test(serverSource);
577
+
578
+ if (hasData && hasLoad) {
579
+ throw new Error(
580
+ `Zenith server script contract violation:\n` +
581
+ ` File: ${sourceFile}\n` +
582
+ ` Reason: export either data or load(ctx), not both\n` +
583
+ ` Example: remove data and return payload from load(ctx)`
584
+ );
585
+ }
586
+ if ((hasData || hasLoad) && (hasSsrData || hasSsr || hasProps)) {
587
+ throw new Error(
588
+ `Zenith server script contract violation:\n` +
589
+ ` File: ${sourceFile}\n` +
590
+ ` Reason: data/load cannot be combined with legacy ssr_data/ssr/props exports\n` +
591
+ ` Example: use only export const data or export const load`
592
+ );
593
+ }
594
+
595
+ if (hasLoad) {
596
+ const singleArg = String(loadConstSingleArgMatch?.[1] || '').trim();
597
+ const paramsText = String((loadFnMatch || loadConstParenMatch)?.[1] || '').trim();
598
+ const arity = singleArg
599
+ ? 1
600
+ : paramsText.length === 0
601
+ ? 0
602
+ : paramsText.split(',').length;
603
+ if (arity !== 1) {
604
+ throw new Error(
605
+ `Zenith server script contract violation:\n` +
606
+ ` File: ${sourceFile}\n` +
607
+ ` Reason: load(ctx) must accept exactly one argument\n` +
608
+ ` Example: export const load = async (ctx) => ({ ... })`
609
+ );
610
+ }
611
+ }
612
+
613
+ const prerenderMatch = serverSource.match(/\bexport\s+const\s+prerender\s*=\s*([^\n;]+)/);
614
+ let prerender = false;
615
+ if (prerenderMatch) {
616
+ const rawValue = String(prerenderMatch[1] || '').trim();
617
+ if (!/^(true|false)\b/.test(rawValue)) {
618
+ throw new Error(
619
+ `Zenith server script contract violation:\n` +
620
+ ` File: ${sourceFile}\n` +
621
+ ` Reason: prerender must be a boolean literal\n` +
622
+ ` Example: export const prerender = true`
623
+ );
624
+ }
625
+ prerender = rawValue.startsWith('true');
626
+ }
627
+ const start = match.index ?? -1;
628
+ if (start < 0) {
629
+ return {
630
+ source,
631
+ serverScript: {
632
+ source: serverSource,
633
+ prerender,
634
+ source_path: sourceFile
635
+ }
636
+ };
637
+ }
638
+
639
+ const end = start + full.length;
640
+ const stripped = `${source.slice(0, start)}${source.slice(end)}`;
641
+
642
+ return {
643
+ source: stripped,
644
+ serverScript: {
645
+ source: serverSource,
646
+ prerender,
647
+ source_path: sourceFile
648
+ }
649
+ };
650
+ }
651
+
652
+ const OPEN_COMPONENT_TAG_RE = /<([A-Z][a-zA-Z0-9]*)(\s[^<>]*?)?\s*(\/?)>/g;
653
+
654
+ /**
655
+ * Collect original attribute strings for component usages in a page source.
656
+ *
657
+ * @param {string} source
658
+ * @param {Map<string, string>} registry
659
+ * @returns {Map<string, string[]>}
660
+ */
661
+ function collectComponentUsageAttrs(source, registry) {
662
+ const out = new Map();
663
+ OPEN_COMPONENT_TAG_RE.lastIndex = 0;
664
+ let match;
665
+ while ((match = OPEN_COMPONENT_TAG_RE.exec(source)) !== null) {
666
+ const name = match[1];
667
+ if (!registry.has(name)) {
668
+ continue;
669
+ }
670
+ const attrs = String(match[2] || '').trim();
671
+ if (!out.has(name)) {
672
+ out.set(name, []);
673
+ }
674
+ out.get(name).push(attrs);
675
+ }
676
+ return out;
677
+ }
678
+
679
+ /**
680
+ * Merge a component's IR into the page IR.
681
+ *
682
+ * Transfers component scripts and hoisted script blocks so component runtime
683
+ * behavior is preserved after structural macro expansion.
684
+ *
685
+ * @param {object} pageIr — the page's compiled IR (mutated in place)
686
+ * @param {object} compIr — the component's compiled IR
687
+ * @param {string} compPath — component file path
688
+ * @param {string} pageFile — page file path
689
+ * @param {{ includeCode: boolean, cssImportsOnly: boolean, documentMode?: boolean, componentAttrs?: string }} options
690
+ * @param {Set<string>} seenStaticImports
691
+ */
692
+ function mergeComponentIr(pageIr, compIr, compPath, pageFile, options, seenStaticImports) {
693
+ // Merge components_scripts
694
+ if (compIr.components_scripts) {
695
+ for (const [hoistId, script] of Object.entries(compIr.components_scripts)) {
696
+ if (!pageIr.components_scripts[hoistId]) {
697
+ pageIr.components_scripts[hoistId] = script;
698
+ }
699
+ }
700
+ }
701
+
702
+ // Merge component_instances
703
+ if (compIr.component_instances?.length) {
704
+ pageIr.component_instances.push(...compIr.component_instances);
705
+ }
706
+
707
+ // Merge hoisted imports (deduplicated, rebased to the page file path)
708
+ if (compIr.hoisted?.imports?.length) {
709
+ for (const imp of compIr.hoisted.imports) {
710
+ const rebased = rewriteStaticImportLine(imp, compPath, pageFile);
711
+ if (options.cssImportsOnly) {
712
+ const spec = extractStaticImportSpecifier(rebased);
713
+ if (!spec || !isCssSpecifier(spec)) {
714
+ continue;
715
+ }
716
+ }
717
+ if (!pageIr.hoisted.imports.includes(rebased)) {
718
+ pageIr.hoisted.imports.push(rebased);
719
+ }
720
+ }
721
+ }
722
+
723
+ // Merge hoisted symbol/state tables for runtime literal evaluation.
724
+ // Component-expanded expressions can reference rewritten component symbols,
725
+ // so state keys/values must be present in the page envelope.
726
+ if (options.includeCode && compIr.hoisted) {
727
+ if (Array.isArray(compIr.hoisted.declarations)) {
728
+ for (const decl of compIr.hoisted.declarations) {
729
+ if (!pageIr.hoisted.declarations.includes(decl)) {
730
+ pageIr.hoisted.declarations.push(decl);
731
+ }
732
+ }
733
+ }
734
+ if (Array.isArray(compIr.hoisted.functions)) {
735
+ for (const fnName of compIr.hoisted.functions) {
736
+ if (!pageIr.hoisted.functions.includes(fnName)) {
737
+ pageIr.hoisted.functions.push(fnName);
738
+ }
739
+ }
740
+ }
741
+ if (Array.isArray(compIr.hoisted.signals)) {
742
+ for (const signalName of compIr.hoisted.signals) {
743
+ if (!pageIr.hoisted.signals.includes(signalName)) {
744
+ pageIr.hoisted.signals.push(signalName);
745
+ }
746
+ }
747
+ }
748
+ if (Array.isArray(compIr.hoisted.state)) {
749
+ const existingKeys = new Set(
750
+ (pageIr.hoisted.state || [])
751
+ .map((entry) => entry && typeof entry === 'object' ? entry.key : null)
752
+ .filter(Boolean)
753
+ );
754
+ for (const stateEntry of compIr.hoisted.state) {
755
+ if (!stateEntry || typeof stateEntry !== 'object') {
756
+ continue;
757
+ }
758
+ if (typeof stateEntry.key !== 'string' || stateEntry.key.length === 0) {
759
+ continue;
760
+ }
761
+ if (existingKeys.has(stateEntry.key)) {
762
+ continue;
763
+ }
764
+ existingKeys.add(stateEntry.key);
765
+ pageIr.hoisted.state.push(stateEntry);
766
+ }
767
+ }
768
+ }
769
+
770
+ // Merge hoisted code blocks (rebased to the page file path)
771
+ if (options.includeCode && compIr.hoisted?.code?.length) {
772
+ for (const block of compIr.hoisted.code) {
773
+ const rebased = rewriteStaticImportsInSource(block, compPath, pageFile);
774
+ const filteredImports = options.cssImportsOnly
775
+ ? stripNonCssStaticImportsInSource(rebased)
776
+ : rebased;
777
+ const withPropsPrelude = injectPropsPrelude(filteredImports, options.componentAttrs || '');
778
+ const transpiled = transpileTypeScriptToJs(withPropsPrelude, compPath);
779
+ const withRefFallbacks = injectRefFallbacksInZenMount(
780
+ transpiled,
781
+ options.refFallbacks || []
782
+ );
783
+ const deduped = dedupeStaticImportsInSource(withRefFallbacks, seenStaticImports);
784
+ const deferred = deferComponentRuntimeBlock(deduped);
785
+ if (deferred.trim().length > 0 && !pageIr.hoisted.code.includes(deferred)) {
786
+ pageIr.hoisted.code.push(deferred);
787
+ }
788
+ }
789
+ }
790
+ }
791
+
792
+ /**
793
+ * @param {string} spec
794
+ * @returns {boolean}
795
+ */
796
+ function isRelativeSpecifier(spec) {
797
+ return spec.startsWith('./') || spec.startsWith('../');
798
+ }
799
+
800
+ /**
801
+ * @param {string} spec
802
+ * @param {string} fromFile
803
+ * @param {string} toFile
804
+ * @returns {string}
805
+ */
806
+ function rebaseRelativeSpecifier(spec, fromFile, toFile) {
807
+ if (!isRelativeSpecifier(spec)) {
808
+ return spec;
809
+ }
810
+
811
+ const absoluteTarget = resolve(dirname(fromFile), spec);
812
+ let rebased = relative(dirname(toFile), absoluteTarget).replaceAll('\\', '/');
813
+ if (!rebased.startsWith('.')) {
814
+ rebased = `./${rebased}`;
815
+ }
816
+ return rebased;
817
+ }
818
+
819
+ /**
820
+ * @param {string} line
821
+ * @param {string} fromFile
822
+ * @param {string} toFile
823
+ * @returns {string}
824
+ */
825
+ function rewriteStaticImportLine(line, fromFile, toFile) {
826
+ const match = line.match(/^\s*import(?:\s+[^'"]+?\s+from)?\s*['"]([^'"]+)['"]\s*;?\s*$/);
827
+ if (!match) {
828
+ return line;
829
+ }
830
+
831
+ const spec = match[1];
832
+ if (!isRelativeSpecifier(spec)) {
833
+ return line;
834
+ }
835
+
836
+ const rebased = rebaseRelativeSpecifier(spec, fromFile, toFile);
837
+ return replaceImportSpecifierLiteral(line, spec, rebased);
838
+ }
839
+
840
+ /**
841
+ * @param {string} line
842
+ * @returns {string | null}
843
+ */
844
+ function extractStaticImportSpecifier(line) {
845
+ const match = line.match(/^\s*import(?:\s+[^'"]+?\s+from)?\s*['"]([^'"]+)['"]\s*;?\s*$/);
846
+ return match ? match[1] : null;
847
+ }
848
+
849
+ /**
850
+ * @param {string} spec
851
+ * @returns {boolean}
852
+ */
853
+ function isCssSpecifier(spec) {
854
+ return /\.css(?:[?#].*)?$/i.test(spec);
855
+ }
856
+
857
+ /**
858
+ * @param {string} source
859
+ * @param {string} fromFile
860
+ * @param {string} toFile
861
+ * @returns {string}
862
+ */
863
+ function rewriteStaticImportsInSource(source, fromFile, toFile) {
864
+ return source.replace(
865
+ /(^\s*import(?:\s+[^'"]+?\s+from)?\s*['"])([^'"]+)(['"]\s*;?\s*$)/gm,
866
+ (_full, prefix, spec, suffix) => `${prefix}${rebaseRelativeSpecifier(spec, fromFile, toFile)}${suffix}`
867
+ );
868
+ }
869
+
870
+ /**
871
+ * @param {string} line
872
+ * @param {string} oldSpec
873
+ * @param {string} newSpec
874
+ * @returns {string}
875
+ */
876
+ function replaceImportSpecifierLiteral(line, oldSpec, newSpec) {
877
+ const single = `'${oldSpec}'`;
878
+ if (line.includes(single)) {
879
+ return line.replace(single, `'${newSpec}'`);
880
+ }
881
+
882
+ const dbl = `"${oldSpec}"`;
883
+ if (line.includes(dbl)) {
884
+ return line.replace(dbl, `"${newSpec}"`);
885
+ }
886
+
887
+ return line;
888
+ }
889
+
890
+ /**
891
+ * @param {string} source
892
+ * @param {string} sourceFile
893
+ * @returns {string}
894
+ */
895
+ function transpileTypeScriptToJs(source, sourceFile) {
896
+ const ts = loadTypeScriptApi();
897
+ if (!ts) {
898
+ return source;
899
+ }
900
+
901
+ try {
902
+ const output = ts.transpileModule(source, {
903
+ fileName: sourceFile,
904
+ compilerOptions: {
905
+ module: ts.ModuleKind.ESNext,
906
+ target: ts.ScriptTarget.ES5,
907
+ importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Preserve,
908
+ verbatimModuleSyntax: true,
909
+ newLine: ts.NewLineKind.LineFeed,
910
+ },
911
+ reportDiagnostics: false,
912
+ });
913
+ return output.outputText;
914
+ } catch {
915
+ return source;
916
+ }
917
+ }
918
+
919
+ const DEFERRED_RUNTIME_CALLS = new Set(['zenMount', 'zenEffect', 'zeneffect']);
920
+
921
+ /**
922
+ * Split top-level runtime side-effect calls from hoistable declarations.
923
+ *
924
+ * Keeps declarations/functions/constants at module scope so rewritten template
925
+ * expressions can resolve their identifiers during hydrate(), while deferring
926
+ * zenMount/zenEffect registration until __zenith_mount().
927
+ *
928
+ * @param {string} body
929
+ * @returns {{ hoisted: string, deferred: string }}
930
+ */
931
+ function splitDeferredRuntimeCalls(body) {
932
+ const ts = loadTypeScriptApi();
933
+ if (!ts || typeof body !== 'string' || body.trim().length === 0) {
934
+ return { hoisted: body, deferred: '' };
935
+ }
936
+
937
+ let sourceFile;
938
+ try {
939
+ sourceFile = ts.createSourceFile(
940
+ 'zenith-component-runtime.ts',
941
+ body,
942
+ ts.ScriptTarget.Latest,
943
+ true,
944
+ ts.ScriptKind.TS
945
+ );
946
+ } catch {
947
+ return { hoisted: body, deferred: '' };
948
+ }
949
+
950
+ if (!sourceFile || !Array.isArray(sourceFile.statements) || sourceFile.statements.length === 0) {
951
+ return { hoisted: body, deferred: '' };
952
+ }
953
+
954
+ /** @type {Array<{ start: number, end: number }>} */
955
+ const ranges = [];
956
+
957
+ for (const statement of sourceFile.statements) {
958
+ if (!ts.isExpressionStatement(statement)) {
959
+ continue;
960
+ }
961
+ if (!ts.isCallExpression(statement.expression)) {
962
+ continue;
963
+ }
964
+
965
+ let callee = statement.expression.expression;
966
+ while (ts.isParenthesizedExpression(callee)) {
967
+ callee = callee.expression;
968
+ }
969
+
970
+ if (!ts.isIdentifier(callee) || !DEFERRED_RUNTIME_CALLS.has(callee.text)) {
971
+ continue;
972
+ }
973
+
974
+ const start = typeof statement.getFullStart === 'function'
975
+ ? statement.getFullStart()
976
+ : statement.pos;
977
+ const end = statement.end;
978
+ if (!Number.isInteger(start) || !Number.isInteger(end) || end <= start) {
979
+ continue;
980
+ }
981
+ ranges.push({ start, end });
982
+ }
983
+
984
+ if (ranges.length === 0) {
985
+ return { hoisted: body, deferred: '' };
986
+ }
987
+
988
+ ranges.sort((a, b) => a.start - b.start);
989
+ /** @type {Array<{ start: number, end: number }>} */
990
+ const merged = [];
991
+ for (const range of ranges) {
992
+ const last = merged[merged.length - 1];
993
+ if (!last || range.start > last.end) {
994
+ merged.push({ start: range.start, end: range.end });
995
+ continue;
996
+ }
997
+ if (range.end > last.end) {
998
+ last.end = range.end;
999
+ }
1000
+ }
1001
+
1002
+ let cursor = 0;
1003
+ let hoisted = '';
1004
+ let deferred = '';
1005
+
1006
+ for (const range of merged) {
1007
+ if (range.start > cursor) {
1008
+ hoisted += body.slice(cursor, range.start);
1009
+ }
1010
+ deferred += body.slice(range.start, range.end);
1011
+ if (!deferred.endsWith('\n')) {
1012
+ deferred += '\n';
1013
+ }
1014
+ cursor = range.end;
1015
+ }
1016
+
1017
+ if (cursor < body.length) {
1018
+ hoisted += body.slice(cursor);
1019
+ }
1020
+
1021
+ return { hoisted, deferred };
1022
+ }
1023
+
1024
+ /**
1025
+ * @param {string} source
1026
+ * @param {Set<string>} seenStaticImports
1027
+ * @returns {string}
1028
+ */
1029
+ function dedupeStaticImportsInSource(source, seenStaticImports) {
1030
+ const lines = source.split('\n');
1031
+ const kept = [];
1032
+
1033
+ for (const line of lines) {
1034
+ const spec = extractStaticImportSpecifier(line);
1035
+ if (!spec) {
1036
+ kept.push(line);
1037
+ continue;
1038
+ }
1039
+
1040
+ const key = line.trim();
1041
+ if (seenStaticImports.has(key)) {
1042
+ continue;
1043
+ }
1044
+ seenStaticImports.add(key);
1045
+ kept.push(line);
1046
+ }
1047
+
1048
+ return kept.join('\n');
1049
+ }
1050
+
1051
+ /**
1052
+ * @param {string} source
1053
+ * @returns {string}
1054
+ */
1055
+ function stripNonCssStaticImportsInSource(source) {
1056
+ const lines = source.split('\n');
1057
+ const kept = [];
1058
+ for (const line of lines) {
1059
+ const spec = extractStaticImportSpecifier(line);
1060
+ if (!spec) {
1061
+ kept.push(line);
1062
+ continue;
1063
+ }
1064
+ if (isCssSpecifier(spec)) {
1065
+ kept.push(line);
1066
+ }
1067
+ }
1068
+ return kept.join('\n');
1069
+ }
1070
+
1071
+ /**
1072
+ * @param {string} key
1073
+ * @returns {string}
1074
+ */
1075
+ function renderObjectKey(key) {
1076
+ if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)) {
1077
+ return key;
1078
+ }
1079
+ return JSON.stringify(key);
1080
+ }
1081
+
1082
+ /**
1083
+ * @param {string} attrs
1084
+ * @returns {string}
1085
+ */
1086
+ function renderPropsLiteralFromAttrs(attrs) {
1087
+ const src = String(attrs || '').trim();
1088
+ if (!src) {
1089
+ return '{}';
1090
+ }
1091
+
1092
+ const entries = [];
1093
+ const attrRe = /([A-Za-z_$][A-Za-z0-9_$-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|\{([\s\S]*?)\}))?/g;
1094
+ let match;
1095
+ while ((match = attrRe.exec(src)) !== null) {
1096
+ const rawName = match[1];
1097
+ if (!rawName || rawName.startsWith('on:')) {
1098
+ continue;
1099
+ }
1100
+
1101
+ const doubleQuoted = match[2];
1102
+ const singleQuoted = match[3];
1103
+ const expressionValue = match[4];
1104
+ let valueCode = 'true';
1105
+ if (doubleQuoted !== undefined) {
1106
+ valueCode = JSON.stringify(doubleQuoted);
1107
+ } else if (singleQuoted !== undefined) {
1108
+ valueCode = JSON.stringify(singleQuoted);
1109
+ } else if (expressionValue !== undefined) {
1110
+ const trimmed = String(expressionValue).trim();
1111
+ valueCode = trimmed.length > 0 ? trimmed : 'undefined';
1112
+ }
1113
+
1114
+ entries.push(`${renderObjectKey(rawName)}: ${valueCode}`);
1115
+ }
1116
+
1117
+ if (entries.length === 0) {
1118
+ return '{}';
1119
+ }
1120
+
1121
+ return `{ ${entries.join(', ')} }`;
1122
+ }
1123
+
1124
+ /**
1125
+ * @param {string} source
1126
+ * @param {string} attrs
1127
+ * @returns {string}
1128
+ */
1129
+ function injectPropsPrelude(source, attrs) {
1130
+ if (typeof source !== 'string' || source.trim().length === 0) {
1131
+ return source;
1132
+ }
1133
+ if (!/\bprops\b/.test(source)) {
1134
+ return source;
1135
+ }
1136
+ if (/\b(?:const|let|var)\s+props\b/.test(source)) {
1137
+ return source;
1138
+ }
1139
+
1140
+ const propsLiteral = renderPropsLiteralFromAttrs(attrs);
1141
+ return `var props = ${propsLiteral};\n${source}`;
1142
+ }
1143
+
1144
+ /**
1145
+ * @param {string} source
1146
+ * @returns {string}
1147
+ */
1148
+ function deferComponentRuntimeBlock(source) {
1149
+ const lines = source.split('\n');
1150
+ const importLines = [];
1151
+ const bodyLines = [];
1152
+ let inImportPrefix = true;
1153
+
1154
+ for (const line of lines) {
1155
+ if (inImportPrefix && extractStaticImportSpecifier(line)) {
1156
+ importLines.push(line);
1157
+ continue;
1158
+ }
1159
+ inImportPrefix = false;
1160
+ bodyLines.push(line);
1161
+ }
1162
+
1163
+ const body = bodyLines.join('\n');
1164
+ if (body.trim().length === 0) {
1165
+ return importLines.join('\n');
1166
+ }
1167
+
1168
+ const { hoisted, deferred } = splitDeferredRuntimeCalls(body);
1169
+ if (deferred.trim().length === 0) {
1170
+ return [importLines.join('\n').trim(), hoisted.trim()]
1171
+ .filter((segment) => segment.length > 0)
1172
+ .join('\n');
1173
+ }
1174
+
1175
+ const indentedBody = deferred
1176
+ .trim()
1177
+ .split('\n')
1178
+ .map((line) => ` ${line}`)
1179
+ .join('\n');
1180
+ const wrapped = [
1181
+ importLines.join('\n').trim(),
1182
+ hoisted.trim(),
1183
+ "__zenith_component_bootstraps.push(() => {",
1184
+ indentedBody,
1185
+ "});"
1186
+ ]
1187
+ .filter((segment) => segment.length > 0)
1188
+ .join('\n');
1189
+
1190
+ return wrapped;
1191
+ }
1192
+
1193
+ /**
1194
+ * @param {string} componentSource
1195
+ * @returns {Array<{ identifier: string, selector: string }>}
1196
+ */
1197
+ function extractRefFallbackAssignments(componentSource) {
1198
+ const template = extractTemplate(componentSource);
1199
+ const tagRe = /<[^>]*ref=\{([A-Za-z_$][A-Za-z0-9_$]*)\}[^>]*>/g;
1200
+ const out = [];
1201
+ const seen = new Set();
1202
+
1203
+ let match;
1204
+ while ((match = tagRe.exec(template)) !== null) {
1205
+ const tag = match[0];
1206
+ const identifier = match[1];
1207
+ const attrMatch = tag.match(/\b(data-[a-z0-9-]+-runtime)\b/i)
1208
+ || tag.match(/\b(data-[a-z0-9-]+)\b/i);
1209
+ if (!attrMatch) {
1210
+ continue;
1211
+ }
1212
+
1213
+ const selector = `[${attrMatch[1]}]`;
1214
+ const key = `${identifier}:${selector}`;
1215
+ if (seen.has(key)) {
1216
+ continue;
1217
+ }
1218
+ seen.add(key);
1219
+ out.push({ identifier, selector });
1220
+ }
1221
+
1222
+ return out;
1223
+ }
1224
+
1225
+ /**
1226
+ * @param {string} source
1227
+ * @param {string} originalIdentifier
1228
+ * @returns {string | null}
1229
+ */
1230
+ function resolveRenamedRefIdentifier(source, originalIdentifier) {
1231
+ const re = /const\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*ref\s*\(/g;
1232
+ let match;
1233
+ while ((match = re.exec(source)) !== null) {
1234
+ const candidate = match[1];
1235
+ if (candidate === originalIdentifier || candidate.endsWith(`_${originalIdentifier}`)) {
1236
+ return candidate;
1237
+ }
1238
+ }
1239
+ return null;
1240
+ }
1241
+
1242
+ /**
1243
+ * @param {string} source
1244
+ * @param {Array<{ identifier: string, selector: string }>} refFallbacks
1245
+ * @returns {string}
1246
+ */
1247
+ function injectRefFallbacksInZenMount(source, refFallbacks) {
1248
+ if (!Array.isArray(refFallbacks) || refFallbacks.length === 0) {
1249
+ return source;
1250
+ }
1251
+
1252
+ const lines = [];
1253
+ for (let i = 0; i < refFallbacks.length; i++) {
1254
+ const fallback = refFallbacks[i];
1255
+ const refIdentifier = resolveRenamedRefIdentifier(source, fallback.identifier);
1256
+ if (!refIdentifier) {
1257
+ continue;
1258
+ }
1259
+ const selector = JSON.stringify(fallback.selector);
1260
+ const nodeVar = `__zenith_ref_node_${i}`;
1261
+ lines.push(
1262
+ ` if (typeof document !== 'undefined' && ${refIdentifier} && !${refIdentifier}.current) {`,
1263
+ ` const ${nodeVar} = document.querySelector(${selector});`,
1264
+ ` if (${nodeVar}) {`,
1265
+ ` ${refIdentifier}.current = ${nodeVar};`,
1266
+ ' }',
1267
+ ' }'
1268
+ );
1269
+ }
1270
+
1271
+ if (lines.length === 0) {
1272
+ return source;
1273
+ }
1274
+
1275
+ const mountRe = /zenMount\s*\(\s*\([^)]*\)\s*=>\s*\{/;
1276
+ if (!mountRe.test(source)) {
1277
+ return source;
1278
+ }
1279
+
1280
+ return source.replace(mountRe, (match) => `${match}\n${lines.join('\n')}\n`);
1281
+ }
1282
+
1283
+ /**
1284
+ * Run bundler process for one page envelope.
1285
+ *
1286
+ * @param {object|object[]} envelope
1287
+ * @param {string} outDir
1288
+ * @returns {Promise<void>}
1289
+ */
1290
+ function runBundler(envelope, outDir) {
1291
+ return new Promise((resolvePromise, rejectPromise) => {
1292
+ const child = spawn(
1293
+ BUNDLER_BIN,
1294
+ ['--out-dir', outDir],
1295
+ { stdio: ['pipe', 'inherit', 'inherit'] }
1296
+ );
1297
+
1298
+ child.on('error', (err) => {
1299
+ rejectPromise(new Error(`Bundler spawn failed: ${err.message}`));
1300
+ });
1301
+
1302
+ child.on('close', (code) => {
1303
+ if (code === 0) {
1304
+ resolvePromise();
1305
+ return;
1306
+ }
1307
+ rejectPromise(new Error(`Bundler failed with exit code ${code}`));
1308
+ });
1309
+
1310
+ child.stdin.write(JSON.stringify(envelope));
1311
+ child.stdin.end();
1312
+ });
1313
+ }
1314
+
1315
+ /**
1316
+ * Collect generated assets for reporting.
1317
+ *
1318
+ * @param {string} rootDir
1319
+ * @returns {Promise<string[]>}
1320
+ */
1321
+ async function collectAssets(rootDir) {
1322
+ const files = [];
1323
+
1324
+ async function walk(dir) {
1325
+ let entries = [];
1326
+ try {
1327
+ entries = await readdir(dir);
1328
+ } catch {
1329
+ return;
1330
+ }
1331
+
1332
+ entries.sort((a, b) => a.localeCompare(b));
1333
+ for (const name of entries) {
1334
+ const fullPath = join(dir, name);
1335
+ const info = await stat(fullPath);
1336
+ if (info.isDirectory()) {
1337
+ await walk(fullPath);
1338
+ continue;
1339
+ }
1340
+ if (fullPath.endsWith('.js') || fullPath.endsWith('.css')) {
1341
+ files.push(relative(rootDir, fullPath).replaceAll('\\', '/'));
1342
+ }
1343
+ }
1344
+ }
1345
+
1346
+ await walk(rootDir);
1347
+ files.sort((a, b) => a.localeCompare(b));
1348
+ return files;
1349
+ }
1350
+
1351
+ /**
1352
+ * Build all pages by orchestrating compiler and bundler binaries.
1353
+ *
1354
+ * Pipeline:
1355
+ * 1. Build component registry (PascalCase name → .zen file path)
1356
+ * 2. For each page:
1357
+ * a. Expand PascalCase tags into component template HTML
1358
+ * b. Compile expanded page source via --stdin
1359
+ * c. Compile each used component separately for script IR
1360
+ * d. Merge component IRs into page IR
1361
+ * 3. Send all envelopes to bundler
1362
+ *
1363
+ * @param {{ pagesDir: string, outDir: string, config?: object }} options
1364
+ * @returns {Promise<{ pages: number, assets: string[] }>}
1365
+ */
1366
+ export async function build(options) {
1367
+ const { pagesDir, outDir, config = {} } = options;
1368
+ const softNavigationEnabled = config.softNavigation === true || config.router === true;
1369
+ const compilerOpts = {
1370
+ typescriptDefault: config.typescriptDefault === true,
1371
+ experimentalEmbeddedMarkup: config.embeddedMarkupExpressions === true || config.experimental?.embeddedMarkupExpressions === true
1372
+ };
1373
+
1374
+ await rm(outDir, { recursive: true, force: true });
1375
+ await mkdir(outDir, { recursive: true });
1376
+
1377
+ // Derive src/ directory from pages/ directory
1378
+ const srcDir = resolve(pagesDir, '..');
1379
+
1380
+ // 1. Build component registry
1381
+ const registry = buildComponentRegistry(srcDir);
1382
+ if (registry.size > 0) {
1383
+ console.log(`[zenith] Component registry: ${registry.size} components`);
1384
+ }
1385
+
1386
+ const manifest = await generateManifest(pagesDir);
1387
+ await ensureZenithTypeDeclarations({ manifest, pagesDir });
1388
+
1389
+ // Cache: avoid re-compiling the same component for multiple pages
1390
+ /** @type {Map<string, object>} */
1391
+ const componentIrCache = new Map();
1392
+ /** @type {Map<string, boolean>} */
1393
+ const componentDocumentModeCache = new Map();
1394
+ /** @type {Map<string, Array<{ identifier: string, selector: string }>>} */
1395
+ const componentRefFallbackCache = new Map();
1396
+ /** @type {Map<string, { map: Map<string, string>, ambiguous: Set<string> }>} */
1397
+ const componentExpressionRewriteCache = new Map();
1398
+ const emitCompilerWarning = createCompilerWarningEmitter((line) => console.warn(line));
1399
+
1400
+ const envelopes = [];
1401
+ for (const entry of manifest) {
1402
+ const sourceFile = join(pagesDir, entry.file);
1403
+ const rawSource = readFileSync(sourceFile, 'utf8');
1404
+ const componentUsageAttrs = collectComponentUsageAttrs(rawSource, registry);
1405
+
1406
+ // 2a. Expand PascalCase component tags
1407
+ const { expandedSource, usedComponents } = expandComponents(
1408
+ rawSource, registry, sourceFile
1409
+ );
1410
+ const extractedServer = extractServerScript(expandedSource, sourceFile, compilerOpts);
1411
+ const compileSource = extractedServer.source;
1412
+
1413
+ // 2b. Compile expanded page source via --stdin
1414
+ const pageIr = runCompiler(
1415
+ sourceFile,
1416
+ compileSource,
1417
+ compilerOpts,
1418
+ { onWarning: emitCompilerWarning }
1419
+ );
1420
+ if (extractedServer.serverScript) {
1421
+ pageIr.server_script = extractedServer.serverScript;
1422
+ pageIr.prerender = extractedServer.serverScript.prerender === true;
1423
+ if (pageIr.ssr_data === undefined) {
1424
+ pageIr.ssr_data = null;
1425
+ }
1426
+ }
1427
+
1428
+ // Ensure IR has required array fields for merging
1429
+ pageIr.components_scripts = pageIr.components_scripts || {};
1430
+ pageIr.component_instances = pageIr.component_instances || [];
1431
+ pageIr.hoisted = pageIr.hoisted || { imports: [], declarations: [], functions: [], signals: [], state: [], code: [] };
1432
+ pageIr.hoisted.imports = pageIr.hoisted.imports || [];
1433
+ pageIr.hoisted.declarations = pageIr.hoisted.declarations || [];
1434
+ pageIr.hoisted.functions = pageIr.hoisted.functions || [];
1435
+ pageIr.hoisted.signals = pageIr.hoisted.signals || [];
1436
+ pageIr.hoisted.state = pageIr.hoisted.state || [];
1437
+ pageIr.hoisted.code = pageIr.hoisted.code || [];
1438
+ const seenStaticImports = new Set();
1439
+ const pageExpressionRewriteMap = new Map();
1440
+ const pageAmbiguousExpressionMap = new Set();
1441
+
1442
+ // 2c. Compile each used component separately for its script IR
1443
+ for (const compName of usedComponents) {
1444
+ const compPath = registry.get(compName);
1445
+ if (!compPath) continue;
1446
+ const componentSource = readFileSync(compPath, 'utf8');
1447
+
1448
+ let compIr;
1449
+ if (componentIrCache.has(compPath)) {
1450
+ compIr = componentIrCache.get(compPath);
1451
+ } else {
1452
+ const componentCompileSource = stripStyleBlocks(componentSource);
1453
+ compIr = runCompiler(
1454
+ compPath,
1455
+ componentCompileSource,
1456
+ compilerOpts,
1457
+ { onWarning: emitCompilerWarning }
1458
+ );
1459
+ componentIrCache.set(compPath, compIr);
1460
+ }
1461
+
1462
+ let isDocMode = componentDocumentModeCache.get(compPath);
1463
+ let refFallbacks = componentRefFallbackCache.get(compPath);
1464
+ if (isDocMode === undefined) {
1465
+ isDocMode = isDocumentMode(extractTemplate(componentSource));
1466
+ refFallbacks = extractRefFallbackAssignments(componentSource);
1467
+ componentDocumentModeCache.set(compPath, isDocMode);
1468
+ componentRefFallbackCache.set(compPath, refFallbacks);
1469
+ }
1470
+
1471
+ let expressionRewrite = componentExpressionRewriteCache.get(compPath);
1472
+ if (!expressionRewrite) {
1473
+ expressionRewrite = buildComponentExpressionRewrite(compPath, componentSource, compIr, compilerOpts);
1474
+ componentExpressionRewriteCache.set(compPath, expressionRewrite);
1475
+ }
1476
+ mergeExpressionRewriteMaps(
1477
+ pageExpressionRewriteMap,
1478
+ pageAmbiguousExpressionMap,
1479
+ expressionRewrite
1480
+ );
1481
+
1482
+ // 2d. Merge component IR into page IR
1483
+ mergeComponentIr(
1484
+ pageIr,
1485
+ compIr,
1486
+ compPath,
1487
+ sourceFile,
1488
+ {
1489
+ includeCode: true,
1490
+ cssImportsOnly: isDocMode,
1491
+ documentMode: isDocMode,
1492
+ componentAttrs: (componentUsageAttrs.get(compName) || [])[0] || '',
1493
+ refFallbacks: refFallbacks || []
1494
+ },
1495
+ seenStaticImports
1496
+ );
1497
+ }
1498
+
1499
+ applyExpressionRewrites(
1500
+ pageIr,
1501
+ pageExpressionRewriteMap,
1502
+ pageAmbiguousExpressionMap
1503
+ );
1504
+
1505
+ rewriteLegacyMarkupIdentifiers(pageIr);
1506
+
1507
+ envelopes.push({
1508
+ route: entry.path,
1509
+ file: sourceFile,
1510
+ ir: pageIr,
1511
+ router: softNavigationEnabled
1512
+ });
1513
+ }
1514
+
1515
+ if (envelopes.length > 0) {
1516
+ await runBundler(envelopes, outDir);
1517
+ }
1518
+
1519
+ const assets = await collectAssets(outDir);
1520
+ return { pages: manifest.length, assets };
1521
+ }