@zenithbuild/cli 0.6.0 → 0.6.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/dist/build.js CHANGED
@@ -92,6 +92,35 @@ export function createCompilerWarningEmitter(sink = (line) => console.warn(line)
92
92
  };
93
93
  }
94
94
 
95
+ /**
96
+ * Forward child-process output line-by-line through the structured logger.
97
+ *
98
+ * @param {import('node:stream').Readable | null | undefined} stream
99
+ * @param {(line: string) => void} onLine
100
+ */
101
+ function forwardStreamLines(stream, onLine) {
102
+ if (!stream || typeof stream.on !== 'function') {
103
+ return;
104
+ }
105
+ let pending = '';
106
+ stream.setEncoding?.('utf8');
107
+ stream.on('data', (chunk) => {
108
+ pending += String(chunk || '');
109
+ const lines = pending.split(/\r?\n/);
110
+ pending = lines.pop() || '';
111
+ for (const line of lines) {
112
+ if (line.trim().length > 0) {
113
+ onLine(line);
114
+ }
115
+ }
116
+ });
117
+ stream.on('end', () => {
118
+ if (pending.trim().length > 0) {
119
+ onLine(pending);
120
+ }
121
+ });
122
+ }
123
+
95
124
  /**
96
125
  * Run the compiler process and parse its JSON stdout.
97
126
  *
@@ -171,11 +200,31 @@ function stripStyleBlocks(source) {
171
200
  * @param {string} compPath
172
201
  * @param {string} componentSource
173
202
  * @param {object} compIr
174
- * @returns {{ map: Map<string, string>, ambiguous: Set<string> }}
203
+ * @returns {{
204
+ * map: Map<string, string>,
205
+ * bindings: Map<string, {
206
+ * compiled_expr: string | null,
207
+ * signal_index: number | null,
208
+ * signal_indices: number[],
209
+ * state_index: number | null,
210
+ * component_instance: string | null,
211
+ * component_binding: string | null
212
+ * }>,
213
+ * signals: Array<{ id?: number, kind?: string, state_index?: number }>,
214
+ * stateBindings: Array<{ key?: string, value?: string }>,
215
+ * ambiguous: Set<string>
216
+ * }}
175
217
  */
176
218
  function buildComponentExpressionRewrite(compPath, componentSource, compIr, compilerOpts) {
177
- const out = { map: new Map(), ambiguous: new Set() };
219
+ const out = {
220
+ map: new Map(),
221
+ bindings: new Map(),
222
+ signals: Array.isArray(compIr?.signals) ? compIr.signals : [],
223
+ stateBindings: Array.isArray(compIr?.hoisted?.state) ? compIr.hoisted.state : [],
224
+ ambiguous: new Set()
225
+ };
178
226
  const rewrittenExpressions = Array.isArray(compIr?.expressions) ? compIr.expressions : [];
227
+ const rewrittenBindings = Array.isArray(compIr?.expression_bindings) ? compIr.expression_bindings : [];
179
228
  if (rewrittenExpressions.length === 0) {
180
229
  return out;
181
230
  }
@@ -200,34 +249,243 @@ function buildComponentExpressionRewrite(compPath, componentSource, compIr, comp
200
249
  if (typeof raw !== 'string' || typeof rewritten !== 'string') {
201
250
  continue;
202
251
  }
203
- if (raw === rewritten) {
204
- continue;
252
+
253
+ const binding = rewrittenBindings[i];
254
+ const normalizedBinding = binding && typeof binding === 'object'
255
+ ? {
256
+ compiled_expr: typeof binding.compiled_expr === 'string' ? binding.compiled_expr : null,
257
+ signal_index: Number.isInteger(binding.signal_index) ? binding.signal_index : null,
258
+ signal_indices: Array.isArray(binding.signal_indices)
259
+ ? binding.signal_indices.filter((value) => Number.isInteger(value))
260
+ : [],
261
+ state_index: Number.isInteger(binding.state_index) ? binding.state_index : null,
262
+ component_instance: typeof binding.component_instance === 'string' ? binding.component_instance : null,
263
+ component_binding: typeof binding.component_binding === 'string' ? binding.component_binding : null
264
+ }
265
+ : null;
266
+
267
+ if (!out.ambiguous.has(raw) && normalizedBinding) {
268
+ const existingBinding = out.bindings.get(raw);
269
+ if (existingBinding) {
270
+ if (JSON.stringify(existingBinding) !== JSON.stringify(normalizedBinding)) {
271
+ out.bindings.delete(raw);
272
+ out.map.delete(raw);
273
+ out.ambiguous.add(raw);
274
+ continue;
275
+ }
276
+ } else {
277
+ out.bindings.set(raw, normalizedBinding);
278
+ }
205
279
  }
206
- const existing = out.map.get(raw);
207
- if (existing && existing !== rewritten) {
208
- out.map.delete(raw);
209
- out.ambiguous.add(raw);
280
+
281
+ if (raw !== rewritten) {
282
+ const existing = out.map.get(raw);
283
+ if (existing && existing !== rewritten) {
284
+ out.bindings.delete(raw);
285
+ out.map.delete(raw);
286
+ out.ambiguous.add(raw);
287
+ continue;
288
+ }
289
+ if (!out.ambiguous.has(raw)) {
290
+ out.map.set(raw, rewritten);
291
+ }
292
+ }
293
+ }
294
+
295
+ return out;
296
+ }
297
+
298
+ function remapCompiledExpressionSignals(compiledExpr, componentSignals, componentStateBindings, pageSignalIndexByStateKey) {
299
+ if (typeof compiledExpr !== 'string' || compiledExpr.length === 0) {
300
+ return null;
301
+ }
302
+
303
+ return compiledExpr.replace(/signalMap\.get\((\d+)\)/g, (full, rawIndex) => {
304
+ const localIndex = Number.parseInt(rawIndex, 10);
305
+ if (!Number.isInteger(localIndex)) {
306
+ return full;
307
+ }
308
+ const signal = componentSignals[localIndex];
309
+ if (!signal || !Number.isInteger(signal.state_index)) {
310
+ return full;
311
+ }
312
+ const stateKey = componentStateBindings[signal.state_index]?.key;
313
+ if (typeof stateKey !== 'string' || stateKey.length === 0) {
314
+ return full;
315
+ }
316
+ const pageIndex = pageSignalIndexByStateKey.get(stateKey);
317
+ if (!Number.isInteger(pageIndex)) {
318
+ return full;
319
+ }
320
+ return `signalMap.get(${pageIndex})`;
321
+ });
322
+ }
323
+
324
+ function resolveRewrittenBindingMetadata(pageIr, componentRewrite, binding) {
325
+ if (!binding || typeof binding !== 'object') {
326
+ return null;
327
+ }
328
+
329
+ const pageStateBindings = Array.isArray(pageIr?.hoisted?.state) ? pageIr.hoisted.state : [];
330
+ const pageSignals = Array.isArray(pageIr?.hoisted?.signals) ? pageIr.hoisted.signals : [];
331
+ const pageStateIndexByKey = new Map();
332
+ const pageSignalIndexByStateKey = new Map();
333
+
334
+ for (let index = 0; index < pageStateBindings.length; index++) {
335
+ const key = pageStateBindings[index]?.key;
336
+ if (typeof key === 'string' && key.length > 0 && !pageStateIndexByKey.has(key)) {
337
+ pageStateIndexByKey.set(key, index);
338
+ }
339
+ }
340
+
341
+ for (let index = 0; index < pageSignals.length; index++) {
342
+ const stateIndex = pageSignals[index]?.state_index;
343
+ if (!Number.isInteger(stateIndex)) {
210
344
  continue;
211
345
  }
212
- if (!out.ambiguous.has(raw)) {
213
- out.map.set(raw, rewritten);
346
+ const stateKey = pageStateBindings[stateIndex]?.key;
347
+ if (typeof stateKey === 'string' && stateKey.length > 0 && !pageSignalIndexByStateKey.has(stateKey)) {
348
+ pageSignalIndexByStateKey.set(stateKey, index);
214
349
  }
215
350
  }
216
351
 
217
- return out;
352
+ const componentSignals = Array.isArray(componentRewrite?.signals) ? componentRewrite.signals : [];
353
+ const componentStateBindings = Array.isArray(componentRewrite?.stateBindings) ? componentRewrite.stateBindings : [];
354
+
355
+ let signalIndices = Array.isArray(binding.signal_indices)
356
+ ? [...new Set(
357
+ binding.signal_indices
358
+ .map((signalIndex) => {
359
+ if (!Number.isInteger(signalIndex)) {
360
+ return null;
361
+ }
362
+ const signal = componentSignals[signalIndex];
363
+ if (!signal || !Number.isInteger(signal.state_index)) {
364
+ return null;
365
+ }
366
+ const stateKey = componentStateBindings[signal.state_index]?.key;
367
+ if (typeof stateKey !== 'string' || stateKey.length === 0) {
368
+ return null;
369
+ }
370
+ const pageIndex = pageSignalIndexByStateKey.get(stateKey);
371
+ return Number.isInteger(pageIndex) ? pageIndex : null;
372
+ })
373
+ .filter((value) => Number.isInteger(value))
374
+ )].sort((a, b) => a - b)
375
+ : [];
376
+
377
+ let signalIndex = null;
378
+ if (Number.isInteger(binding.signal_index)) {
379
+ const signal = componentSignals[binding.signal_index];
380
+ const stateKey = signal && Number.isInteger(signal.state_index)
381
+ ? componentStateBindings[signal.state_index]?.key
382
+ : null;
383
+ const pageIndex = typeof stateKey === 'string' ? pageSignalIndexByStateKey.get(stateKey) : null;
384
+ signalIndex = Number.isInteger(pageIndex) ? pageIndex : null;
385
+ }
386
+ if (signalIndex === null && signalIndices.length === 1) {
387
+ signalIndex = signalIndices[0];
388
+ }
389
+
390
+ let stateIndex = null;
391
+ if (Number.isInteger(binding.state_index)) {
392
+ const stateKey = componentStateBindings[binding.state_index]?.key;
393
+ const pageIndex = typeof stateKey === 'string' ? pageStateIndexByKey.get(stateKey) : null;
394
+ stateIndex = Number.isInteger(pageIndex) ? pageIndex : null;
395
+ }
396
+
397
+ if (Number.isInteger(stateIndex)) {
398
+ const fallbackSignalIndices = pageSignals
399
+ .map((signal, index) => signal?.state_index === stateIndex ? index : null)
400
+ .filter((value) => Number.isInteger(value));
401
+ const signalIndicesMatchState = signalIndices.every(
402
+ (index) => pageSignals[index]?.state_index === stateIndex
403
+ );
404
+ if ((!signalIndicesMatchState || signalIndices.length === 0) && fallbackSignalIndices.length > 0) {
405
+ signalIndices = fallbackSignalIndices;
406
+ }
407
+ if (
408
+ (signalIndex === null || pageSignals[signalIndex]?.state_index !== stateIndex) &&
409
+ fallbackSignalIndices.length === 1
410
+ ) {
411
+ signalIndex = fallbackSignalIndices[0];
412
+ }
413
+ }
414
+
415
+ let compiledExpr = remapCompiledExpressionSignals(
416
+ binding.compiled_expr,
417
+ componentSignals,
418
+ componentStateBindings,
419
+ pageSignalIndexByStateKey
420
+ );
421
+ if (
422
+ typeof compiledExpr === 'string' &&
423
+ signalIndices.length === 1 &&
424
+ Array.isArray(binding.signal_indices) &&
425
+ binding.signal_indices.length <= 1
426
+ ) {
427
+ compiledExpr = compiledExpr.replace(/signalMap\.get\(\d+\)/g, `signalMap.get(${signalIndices[0]})`);
428
+ }
429
+
430
+ return {
431
+ compiled_expr: compiledExpr,
432
+ signal_index: signalIndex,
433
+ signal_indices: signalIndices,
434
+ state_index: stateIndex,
435
+ component_instance: typeof binding.component_instance === 'string' ? binding.component_instance : null,
436
+ component_binding: typeof binding.component_binding === 'string' ? binding.component_binding : null
437
+ };
218
438
  }
219
439
 
220
440
  /**
221
441
  * Merge a per-component rewrite table into the page-level rewrite table.
222
442
  *
223
443
  * @param {Map<string, string>} pageMap
444
+ * @param {Map<string, {
445
+ * compiled_expr: string | null,
446
+ * signal_index: number | null,
447
+ * signal_indices: number[],
448
+ * state_index: number | null,
449
+ * component_instance: string | null,
450
+ * component_binding: string | null
451
+ * }>} pageBindingMap
224
452
  * @param {Set<string>} pageAmbiguous
225
- * @param {{ map: Map<string, string>, ambiguous: Set<string> }} componentRewrite
453
+ * @param {{
454
+ * map: Map<string, string>,
455
+ * bindings: Map<string, {
456
+ * compiled_expr: string | null,
457
+ * signal_index: number | null,
458
+ * signal_indices: number[],
459
+ * state_index: number | null,
460
+ * component_instance: string | null,
461
+ * component_binding: string | null
462
+ * }>,
463
+ * signals: Array<{ id?: number, kind?: string, state_index?: number }>,
464
+ * stateBindings: Array<{ key?: string, value?: string }>,
465
+ * ambiguous: Set<string>
466
+ * }} componentRewrite
467
+ * @param {object} pageIr
226
468
  */
227
- function mergeExpressionRewriteMaps(pageMap, pageAmbiguous, componentRewrite) {
469
+ function mergeExpressionRewriteMaps(pageMap, pageBindingMap, pageAmbiguous, componentRewrite, pageIr) {
228
470
  for (const raw of componentRewrite.ambiguous) {
229
471
  pageAmbiguous.add(raw);
230
472
  pageMap.delete(raw);
473
+ pageBindingMap.delete(raw);
474
+ }
475
+
476
+ for (const [raw, binding] of componentRewrite.bindings.entries()) {
477
+ if (pageAmbiguous.has(raw)) {
478
+ continue;
479
+ }
480
+ const resolved = resolveRewrittenBindingMetadata(pageIr, componentRewrite, binding);
481
+ const existing = pageBindingMap.get(raw);
482
+ if (existing && JSON.stringify(existing) !== JSON.stringify(resolved)) {
483
+ pageAmbiguous.add(raw);
484
+ pageMap.delete(raw);
485
+ pageBindingMap.delete(raw);
486
+ continue;
487
+ }
488
+ pageBindingMap.set(raw, resolved);
231
489
  }
232
490
 
233
491
  for (const [raw, rewritten] of componentRewrite.map.entries()) {
@@ -238,20 +496,80 @@ function mergeExpressionRewriteMaps(pageMap, pageAmbiguous, componentRewrite) {
238
496
  if (existing && existing !== rewritten) {
239
497
  pageAmbiguous.add(raw);
240
498
  pageMap.delete(raw);
499
+ pageBindingMap.delete(raw);
241
500
  continue;
242
501
  }
243
502
  pageMap.set(raw, rewritten);
244
503
  }
245
504
  }
246
505
 
506
+ function resolveStateKeyFromBindings(identifier, stateBindings, preferredKeys = null) {
507
+ const ident = String(identifier || '').trim();
508
+ if (!ident) {
509
+ return null;
510
+ }
511
+
512
+ const exact = stateBindings.find((entry) => String(entry?.key || '') === ident);
513
+ if (exact && typeof exact.key === 'string') {
514
+ return exact.key;
515
+ }
516
+
517
+ const suffix = `_${ident}`;
518
+ const matches = stateBindings
519
+ .map((entry) => String(entry?.key || ''))
520
+ .filter((key) => key.endsWith(suffix));
521
+
522
+ if (preferredKeys instanceof Set && preferredKeys.size > 0) {
523
+ const preferredMatches = matches.filter((key) => preferredKeys.has(key));
524
+ if (preferredMatches.length === 1) {
525
+ return preferredMatches[0];
526
+ }
527
+ }
528
+
529
+ if (matches.length === 1) {
530
+ return matches[0];
531
+ }
532
+
533
+ return null;
534
+ }
535
+
536
+ function rewriteRefBindingIdentifiers(pageIr, preferredKeys = null) {
537
+ if (!Array.isArray(pageIr?.ref_bindings) || pageIr.ref_bindings.length === 0) {
538
+ return;
539
+ }
540
+
541
+ const stateBindings = Array.isArray(pageIr?.hoisted?.state) ? pageIr.hoisted.state : [];
542
+ if (stateBindings.length === 0) {
543
+ return;
544
+ }
545
+
546
+ for (const binding of pageIr.ref_bindings) {
547
+ if (!binding || typeof binding !== 'object' || typeof binding.identifier !== 'string') {
548
+ continue;
549
+ }
550
+ const resolved = resolveStateKeyFromBindings(binding.identifier, stateBindings, preferredKeys);
551
+ if (resolved) {
552
+ binding.identifier = resolved;
553
+ }
554
+ }
555
+ }
556
+
247
557
  /**
248
558
  * Rewrite unresolved page expressions using component script-aware mappings.
249
559
  *
250
560
  * @param {object} pageIr
251
561
  * @param {Map<string, string>} expressionMap
562
+ * @param {Map<string, {
563
+ * compiled_expr: string | null,
564
+ * signal_index: number | null,
565
+ * signal_indices: number[],
566
+ * state_index: number | null,
567
+ * component_instance: string | null,
568
+ * component_binding: string | null
569
+ * }>} bindingMap
252
570
  * @param {Set<string>} ambiguous
253
571
  */
254
- function applyExpressionRewrites(pageIr, expressionMap, ambiguous) {
572
+ function applyExpressionRewrites(pageIr, expressionMap, bindingMap, ambiguous) {
255
573
  if (!Array.isArray(pageIr?.expressions) || pageIr.expressions.length === 0) {
256
574
  return;
257
575
  }
@@ -265,21 +583,109 @@ function applyExpressionRewrites(pageIr, expressionMap, ambiguous) {
265
583
  if (ambiguous.has(current)) {
266
584
  continue;
267
585
  }
586
+
268
587
  const rewritten = expressionMap.get(current);
269
- if (!rewritten || rewritten === current) {
588
+ const rewrittenBinding = bindingMap.get(current);
589
+ if (rewritten && rewritten !== current) {
590
+ pageIr.expressions[index] = rewritten;
591
+ }
592
+
593
+ if (!bindings[index] || typeof bindings[index] !== 'object') {
270
594
  continue;
271
595
  }
272
- pageIr.expressions[index] = rewritten;
596
+
597
+ if (rewritten && rewritten !== current && bindings[index].literal === current) {
598
+ bindings[index].literal = rewritten;
599
+ }
600
+
601
+ if (rewrittenBinding) {
602
+ bindings[index].compiled_expr = rewrittenBinding.compiled_expr;
603
+ bindings[index].signal_index = rewrittenBinding.signal_index;
604
+ bindings[index].signal_indices = rewrittenBinding.signal_indices;
605
+ bindings[index].state_index = rewrittenBinding.state_index;
606
+ bindings[index].component_instance = rewrittenBinding.component_instance;
607
+ bindings[index].component_binding = rewrittenBinding.component_binding;
608
+ } else if (rewritten && rewritten !== current && bindings[index].compiled_expr === current) {
609
+ bindings[index].compiled_expr = rewritten;
610
+ }
611
+
273
612
  if (
274
- bindings[index] &&
275
- typeof bindings[index] === 'object' &&
276
- bindings[index].literal === current
613
+ !rewrittenBinding &&
614
+ (!rewritten || rewritten === current) &&
615
+ bindings[index].literal === current &&
616
+ bindings[index].compiled_expr === current
277
617
  ) {
278
- bindings[index].literal = rewritten;
279
- if (bindings[index].compiled_expr === current) {
280
- bindings[index].compiled_expr = rewritten;
618
+ bindings[index].compiled_expr = current;
619
+ }
620
+ }
621
+ }
622
+
623
+ function normalizeExpressionBindingDependencies(pageIr) {
624
+ if (!Array.isArray(pageIr?.expression_bindings) || pageIr.expression_bindings.length === 0) {
625
+ return;
626
+ }
627
+
628
+ const signals = Array.isArray(pageIr.signals) ? pageIr.signals : [];
629
+ const dependencyRe = /signalMap\.get\((\d+)\)/g;
630
+
631
+ for (const binding of pageIr.expression_bindings) {
632
+ if (!binding || typeof binding !== 'object' || typeof binding.compiled_expr !== 'string') {
633
+ continue;
634
+ }
635
+
636
+ const indices = [];
637
+ dependencyRe.lastIndex = 0;
638
+ let match;
639
+ while ((match = dependencyRe.exec(binding.compiled_expr)) !== null) {
640
+ const index = Number.parseInt(match[1], 10);
641
+ if (Number.isInteger(index)) {
642
+ indices.push(index);
281
643
  }
282
644
  }
645
+
646
+ if (indices.length === 0) {
647
+ continue;
648
+ }
649
+
650
+ let signalIndices = [...new Set(indices)].sort((a, b) => a - b);
651
+ if (Number.isInteger(binding.state_index)) {
652
+ const owningSignalIndices = signals
653
+ .map((signal, index) => signal?.state_index === binding.state_index ? index : null)
654
+ .filter((value) => Number.isInteger(value));
655
+ const extractedMatchState =
656
+ signalIndices.length > 0 &&
657
+ signalIndices.every((index) => signals[index]?.state_index === binding.state_index);
658
+ if (owningSignalIndices.length > 0 && !extractedMatchState) {
659
+ signalIndices = owningSignalIndices;
660
+ }
661
+ }
662
+
663
+ if (
664
+ !Array.isArray(binding.signal_indices) ||
665
+ binding.signal_indices.length === 0 ||
666
+ binding.signal_indices.some((index) => signals[index]?.state_index !== binding.state_index)
667
+ ) {
668
+ binding.signal_indices = signalIndices;
669
+ }
670
+ if (
671
+ (!Number.isInteger(binding.signal_index) ||
672
+ signals[binding.signal_index]?.state_index !== binding.state_index) &&
673
+ signalIndices.length === 1
674
+ ) {
675
+ binding.signal_index = signalIndices[0];
676
+ }
677
+ if (!Number.isInteger(binding.state_index) && Number.isInteger(binding.signal_index)) {
678
+ const stateIndex = signals[binding.signal_index]?.state_index;
679
+ if (Number.isInteger(stateIndex)) {
680
+ binding.state_index = stateIndex;
681
+ }
682
+ }
683
+ if (signalIndices.length === 1) {
684
+ binding.compiled_expr = binding.compiled_expr.replace(
685
+ /signalMap\.get\(\d+\)/g,
686
+ `signalMap.get(${signalIndices[0]})`
687
+ );
688
+ }
283
689
  }
284
690
  }
285
691
 
@@ -718,9 +1124,10 @@ const OPEN_COMPONENT_TAG_RE = /<([A-Z][a-zA-Z0-9]*)(\s[^<>]*?)?\s*(\/?)>/g;
718
1124
  *
719
1125
  * @param {string} source
720
1126
  * @param {Map<string, string>} registry
721
- * @returns {Map<string, string[]>}
1127
+ * @param {string | null} ownerPath
1128
+ * @returns {Map<string, Array<{ attrs: string, ownerPath: string | null }>>}
722
1129
  */
723
- function collectComponentUsageAttrs(source, registry) {
1130
+ function collectComponentUsageAttrs(source, registry, ownerPath = null) {
724
1131
  const out = new Map();
725
1132
  OPEN_COMPONENT_TAG_RE.lastIndex = 0;
726
1133
  let match;
@@ -733,8 +1140,44 @@ function collectComponentUsageAttrs(source, registry) {
733
1140
  if (!out.has(name)) {
734
1141
  out.set(name, []);
735
1142
  }
736
- out.get(name).push(attrs);
1143
+ out.get(name).push({ attrs, ownerPath });
1144
+ }
1145
+ return out;
1146
+ }
1147
+
1148
+ /**
1149
+ * Collect component usage attrs recursively so nested component callsites
1150
+ * receive deterministic props preludes during page-hoist merging.
1151
+ *
1152
+ * Current Zenith architecture still resolves one attrs set per component type.
1153
+ * This helper preserves that model while ensuring nested usages are not lost.
1154
+ *
1155
+ * @param {string} source
1156
+ * @param {Map<string, string>} registry
1157
+ * @param {string | null} ownerPath
1158
+ * @param {Set<string>} visitedFiles
1159
+ * @param {Map<string, Array<{ attrs: string, ownerPath: string | null }>>} out
1160
+ * @returns {Map<string, Array<{ attrs: string, ownerPath: string | null }>>}
1161
+ */
1162
+ function collectRecursiveComponentUsageAttrs(source, registry, ownerPath = null, visitedFiles = new Set(), out = new Map()) {
1163
+ const local = collectComponentUsageAttrs(source, registry, ownerPath);
1164
+ for (const [name, attrsList] of local.entries()) {
1165
+ if (!out.has(name)) {
1166
+ out.set(name, []);
1167
+ }
1168
+ out.get(name).push(...attrsList);
1169
+ }
1170
+
1171
+ for (const name of local.keys()) {
1172
+ const compPath = registry.get(name);
1173
+ if (!compPath || visitedFiles.has(compPath)) {
1174
+ continue;
1175
+ }
1176
+ visitedFiles.add(compPath);
1177
+ const componentSource = readFileSync(compPath, 'utf8');
1178
+ collectRecursiveComponentUsageAttrs(componentSource, registry, compPath, visitedFiles, out);
737
1179
  }
1180
+
738
1181
  return out;
739
1182
  }
740
1183
 
@@ -748,10 +1191,19 @@ function collectComponentUsageAttrs(source, registry) {
748
1191
  * @param {object} compIr — the component's compiled IR
749
1192
  * @param {string} compPath — component file path
750
1193
  * @param {string} pageFile — page file path
751
- * @param {{ includeCode: boolean, cssImportsOnly: boolean, documentMode?: boolean, componentAttrs?: string }} options
1194
+ * @param {{
1195
+ * includeCode: boolean,
1196
+ * cssImportsOnly: boolean,
1197
+ * documentMode?: boolean,
1198
+ * componentAttrs?: string,
1199
+ * componentAttrsRewrite?: {
1200
+ * expressionRewrite?: { map?: Map<string, string>, ambiguous?: Set<string> } | null,
1201
+ * scopeRewrite?: { map?: Map<string, string>, ambiguous?: Set<string> } | null
1202
+ * } | null
1203
+ * }} options
752
1204
  * @param {Set<string>} seenStaticImports
753
1205
  */
754
- function mergeComponentIr(pageIr, compIr, compPath, pageFile, options, seenStaticImports) {
1206
+ function mergeComponentIr(pageIr, compIr, compPath, pageFile, options, seenStaticImports, knownRefKeys = null) {
755
1207
  // Merge components_scripts
756
1208
  if (compIr.components_scripts) {
757
1209
  for (const [hoistId, script] of Object.entries(compIr.components_scripts)) {
@@ -766,6 +1218,17 @@ function mergeComponentIr(pageIr, compIr, compPath, pageFile, options, seenStati
766
1218
  pageIr.component_instances.push(...compIr.component_instances);
767
1219
  }
768
1220
 
1221
+ if (knownRefKeys instanceof Set && Array.isArray(compIr.ref_bindings)) {
1222
+ const componentStateBindings = Array.isArray(compIr?.hoisted?.state) ? compIr.hoisted.state : [];
1223
+ for (const binding of compIr.ref_bindings) {
1224
+ if (!binding || typeof binding.identifier !== 'string' || binding.identifier.length === 0) {
1225
+ continue;
1226
+ }
1227
+ const resolved = resolveStateKeyFromBindings(binding.identifier, componentStateBindings);
1228
+ knownRefKeys.add(resolved || binding.identifier);
1229
+ }
1230
+ }
1231
+
769
1232
  // Merge hoisted imports (deduplicated, rebased to the page file path)
770
1233
  if (compIr.hoisted?.imports?.length) {
771
1234
  for (const imp of compIr.hoisted.imports) {
@@ -829,6 +1292,41 @@ function mergeComponentIr(pageIr, compIr, compPath, pageFile, options, seenStati
829
1292
  }
830
1293
  }
831
1294
 
1295
+ if (options.includeCode && Array.isArray(compIr.signals)) {
1296
+ pageIr.signals = Array.isArray(pageIr.signals) ? pageIr.signals : [];
1297
+ const existingSignalStateKeys = new Set(
1298
+ pageIr.signals
1299
+ .map((signal) => {
1300
+ const stateIndex = signal?.state_index;
1301
+ return Number.isInteger(stateIndex) ? pageIr.hoisted.state?.[stateIndex]?.key : null;
1302
+ })
1303
+ .filter(Boolean)
1304
+ );
1305
+
1306
+ for (const signal of compIr.signals) {
1307
+ if (!signal || !Number.isInteger(signal.state_index)) {
1308
+ continue;
1309
+ }
1310
+ const stateKey = compIr.hoisted?.state?.[signal.state_index]?.key;
1311
+ if (typeof stateKey !== 'string' || stateKey.length === 0) {
1312
+ continue;
1313
+ }
1314
+ const pageStateIndex = pageIr.hoisted.state.findIndex((entry) => entry?.key === stateKey);
1315
+ if (!Number.isInteger(pageStateIndex) || pageStateIndex < 0) {
1316
+ continue;
1317
+ }
1318
+ if (existingSignalStateKeys.has(stateKey)) {
1319
+ continue;
1320
+ }
1321
+ existingSignalStateKeys.add(stateKey);
1322
+ pageIr.signals.push({
1323
+ id: pageIr.signals.length,
1324
+ kind: typeof signal.kind === 'string' && signal.kind.length > 0 ? signal.kind : 'signal',
1325
+ state_index: pageStateIndex
1326
+ });
1327
+ }
1328
+ }
1329
+
832
1330
  // Merge hoisted code blocks (rebased to the page file path)
833
1331
  if (options.includeCode && compIr.hoisted?.code?.length) {
834
1332
  for (const block of compIr.hoisted.code) {
@@ -836,7 +1334,11 @@ function mergeComponentIr(pageIr, compIr, compPath, pageFile, options, seenStati
836
1334
  const filteredImports = options.cssImportsOnly
837
1335
  ? stripNonCssStaticImportsInSource(rebased)
838
1336
  : rebased;
839
- const withPropsPrelude = injectPropsPrelude(filteredImports, options.componentAttrs || '');
1337
+ const withPropsPrelude = injectPropsPrelude(
1338
+ filteredImports,
1339
+ options.componentAttrs || '',
1340
+ options.componentAttrsRewrite || null
1341
+ );
840
1342
  const transpiled = transpileTypeScriptToJs(withPropsPrelude, compPath);
841
1343
  const deduped = dedupeStaticImportsInSource(transpiled, seenStaticImports);
842
1344
  const deferred = deferComponentRuntimeBlock(deduped);
@@ -1137,11 +1639,123 @@ function renderObjectKey(key) {
1137
1639
  return JSON.stringify(key);
1138
1640
  }
1139
1641
 
1642
+ /**
1643
+ * @param {string} value
1644
+ * @returns {string | null}
1645
+ */
1646
+ function deriveScopedIdentifierAlias(value) {
1647
+ const ident = String(value || '').trim();
1648
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(ident)) {
1649
+ return null;
1650
+ }
1651
+ const parts = ident.split('_').filter(Boolean);
1652
+ const candidate = parts.length > 1 ? parts[parts.length - 1] : ident;
1653
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(candidate) ? candidate : ident;
1654
+ }
1655
+
1656
+ /**
1657
+ * @param {Map<string, string>} map
1658
+ * @param {Set<string>} ambiguous
1659
+ * @param {string | null} raw
1660
+ * @param {string | null} rewritten
1661
+ */
1662
+ function recordScopedIdentifierRewrite(map, ambiguous, raw, rewritten) {
1663
+ if (typeof raw !== 'string' || raw.length === 0 || typeof rewritten !== 'string' || rewritten.length === 0) {
1664
+ return;
1665
+ }
1666
+ const existing = map.get(raw);
1667
+ if (existing && existing !== rewritten) {
1668
+ map.delete(raw);
1669
+ ambiguous.add(raw);
1670
+ return;
1671
+ }
1672
+ if (!ambiguous.has(raw)) {
1673
+ map.set(raw, rewritten);
1674
+ }
1675
+ }
1676
+
1677
+ /**
1678
+ * @param {object | null | undefined} ir
1679
+ * @returns {{ map: Map<string, string>, ambiguous: Set<string> }}
1680
+ */
1681
+ function buildScopedIdentifierRewrite(ir) {
1682
+ const out = { map: new Map(), ambiguous: new Set() };
1683
+ if (!ir || typeof ir !== 'object') {
1684
+ return out;
1685
+ }
1686
+
1687
+ const stateBindings = Array.isArray(ir?.hoisted?.state) ? ir.hoisted.state : [];
1688
+ for (const stateEntry of stateBindings) {
1689
+ const key = typeof stateEntry?.key === 'string' ? stateEntry.key : null;
1690
+ recordScopedIdentifierRewrite(out.map, out.ambiguous, deriveScopedIdentifierAlias(key), key);
1691
+ }
1692
+
1693
+ const functionBindings = Array.isArray(ir?.hoisted?.functions) ? ir.hoisted.functions : [];
1694
+ for (const fnName of functionBindings) {
1695
+ if (typeof fnName !== 'string') {
1696
+ continue;
1697
+ }
1698
+ recordScopedIdentifierRewrite(out.map, out.ambiguous, deriveScopedIdentifierAlias(fnName), fnName);
1699
+ }
1700
+
1701
+ return out;
1702
+ }
1703
+
1704
+ /**
1705
+ * @param {string} expr
1706
+ * @param {{
1707
+ * expressionRewrite?: { map?: Map<string, string>, ambiguous?: Set<string> } | null,
1708
+ * scopeRewrite?: { map?: Map<string, string>, ambiguous?: Set<string> } | null
1709
+ * } | null} rewriteContext
1710
+ * @returns {string}
1711
+ */
1712
+ function rewritePropsExpression(expr, rewriteContext = null) {
1713
+ const trimmed = String(expr || '').trim();
1714
+ if (!trimmed) {
1715
+ return trimmed;
1716
+ }
1717
+
1718
+ const expressionMap = rewriteContext?.expressionRewrite?.map;
1719
+ const expressionAmbiguous = rewriteContext?.expressionRewrite?.ambiguous;
1720
+ if (
1721
+ expressionMap instanceof Map &&
1722
+ !(expressionAmbiguous instanceof Set && expressionAmbiguous.has(trimmed))
1723
+ ) {
1724
+ const exact = expressionMap.get(trimmed);
1725
+ if (typeof exact === 'string' && exact.length > 0) {
1726
+ return exact;
1727
+ }
1728
+ }
1729
+
1730
+ const scopeMap = rewriteContext?.scopeRewrite?.map;
1731
+ const scopeAmbiguous = rewriteContext?.scopeRewrite?.ambiguous;
1732
+ const rootMatch = trimmed.match(/^([A-Za-z_$][A-Za-z0-9_$]*)([\s\S]*)$/);
1733
+ if (!rootMatch || !(scopeMap instanceof Map)) {
1734
+ return trimmed;
1735
+ }
1736
+
1737
+ const root = rootMatch[1];
1738
+ if (scopeAmbiguous instanceof Set && scopeAmbiguous.has(root)) {
1739
+ return trimmed;
1740
+ }
1741
+
1742
+ const rewrittenRoot = scopeMap.get(root);
1743
+ if (typeof rewrittenRoot !== 'string' || rewrittenRoot.length === 0 || rewrittenRoot === root) {
1744
+ return trimmed;
1745
+ }
1746
+
1747
+ return `${rewrittenRoot}${rootMatch[2]}`;
1748
+ }
1749
+
1140
1750
  /**
1141
1751
  * @param {string} attrs
1752
+ * @param {{
1753
+ * expressionRewrite?: { map?: Map<string, string>, ambiguous?: Set<string> } | null,
1754
+ * scopeRewrite?: { map?: Map<string, string>, ambiguous?: Set<string> } | null
1755
+ * } | null} rewriteContext
1142
1756
  * @returns {string}
1143
1757
  */
1144
- function renderPropsLiteralFromAttrs(attrs) {
1758
+ function renderPropsLiteralFromAttrs(attrs, rewriteContext = null) {
1145
1759
  const src = String(attrs || '').trim();
1146
1760
  if (!src) {
1147
1761
  return '{}';
@@ -1166,7 +1780,7 @@ function renderPropsLiteralFromAttrs(attrs) {
1166
1780
  valueCode = JSON.stringify(singleQuoted);
1167
1781
  } else if (expressionValue !== undefined) {
1168
1782
  const trimmed = String(expressionValue).trim();
1169
- valueCode = trimmed.length > 0 ? trimmed : 'undefined';
1783
+ valueCode = trimmed.length > 0 ? rewritePropsExpression(trimmed, rewriteContext) : 'undefined';
1170
1784
  }
1171
1785
 
1172
1786
  entries.push(`${renderObjectKey(rawName)}: ${valueCode}`);
@@ -1182,9 +1796,13 @@ function renderPropsLiteralFromAttrs(attrs) {
1182
1796
  /**
1183
1797
  * @param {string} source
1184
1798
  * @param {string} attrs
1799
+ * @param {{
1800
+ * expressionRewrite?: { map?: Map<string, string>, ambiguous?: Set<string> } | null,
1801
+ * scopeRewrite?: { map?: Map<string, string>, ambiguous?: Set<string> } | null
1802
+ * } | null} rewriteContext
1185
1803
  * @returns {string}
1186
1804
  */
1187
- function injectPropsPrelude(source, attrs) {
1805
+ function injectPropsPrelude(source, attrs, rewriteContext = null) {
1188
1806
  if (typeof source !== 'string' || source.trim().length === 0) {
1189
1807
  return source;
1190
1808
  }
@@ -1195,7 +1813,7 @@ function injectPropsPrelude(source, attrs) {
1195
1813
  return source;
1196
1814
  }
1197
1815
 
1198
- const propsLiteral = renderPropsLiteralFromAttrs(attrs);
1816
+ const propsLiteral = renderPropsLiteralFromAttrs(attrs, rewriteContext);
1199
1817
  return `var props = ${propsLiteral};\n${source}`;
1200
1818
  }
1201
1819
 
@@ -1253,16 +1871,32 @@ function deferComponentRuntimeBlock(source) {
1253
1871
  *
1254
1872
  * @param {object|object[]} envelope
1255
1873
  * @param {string} outDir
1874
+ * @param {string} projectRoot
1875
+ * @param {object | null} [logger]
1876
+ * @param {boolean} [showInfo]
1256
1877
  * @returns {Promise<void>}
1257
1878
  */
1258
- function runBundler(envelope, outDir) {
1879
+ function runBundler(envelope, outDir, projectRoot, logger = null, showInfo = true) {
1259
1880
  return new Promise((resolvePromise, rejectPromise) => {
1881
+ const useStructuredLogger = Boolean(logger && typeof logger.childLine === 'function');
1260
1882
  const child = spawn(
1261
1883
  getBundlerBin(),
1262
1884
  ['--out-dir', outDir],
1263
- { stdio: ['pipe', 'inherit', 'inherit'] }
1885
+ {
1886
+ cwd: projectRoot,
1887
+ stdio: useStructuredLogger ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'inherit', 'inherit']
1888
+ }
1264
1889
  );
1265
1890
 
1891
+ if (useStructuredLogger) {
1892
+ forwardStreamLines(child.stdout, (line) => {
1893
+ logger.childLine('bundler', line, { stream: 'stdout', showInfo });
1894
+ });
1895
+ forwardStreamLines(child.stderr, (line) => {
1896
+ logger.childLine('bundler', line, { stream: 'stderr', showInfo: true });
1897
+ });
1898
+ }
1899
+
1266
1900
  child.on('error', (err) => {
1267
1901
  rejectPromise(new Error(`Bundler spawn failed: ${err.message}`));
1268
1902
  });
@@ -1328,11 +1962,12 @@ async function collectAssets(rootDir) {
1328
1962
  * d. Merge component IRs into page IR
1329
1963
  * 3. Send all envelopes to bundler
1330
1964
  *
1331
- * @param {{ pagesDir: string, outDir: string, config?: object }} options
1965
+ * @param {{ pagesDir: string, outDir: string, config?: object, logger?: object | null, showBundlerInfo?: boolean }} options
1332
1966
  * @returns {Promise<{ pages: number, assets: string[] }>}
1333
1967
  */
1334
1968
  export async function build(options) {
1335
- const { pagesDir, outDir, config = {} } = options;
1969
+ const { pagesDir, outDir, config = {}, logger = null, showBundlerInfo = true } = options;
1970
+ const projectRoot = deriveProjectRootFromPagesDir(pagesDir);
1336
1971
  const softNavigationEnabled = config.softNavigation === true || config.router === true;
1337
1972
  const compilerOpts = {
1338
1973
  typescriptDefault: config.typescriptDefault === true,
@@ -1349,7 +1984,13 @@ export async function build(options) {
1349
1984
  // 1. Build component registry
1350
1985
  const registry = buildComponentRegistry(srcDir);
1351
1986
  if (registry.size > 0) {
1352
- console.log(`[zenith] Component registry: ${registry.size} components`);
1987
+ if (logger && typeof logger.build === 'function') {
1988
+ logger.build(`registry=${registry.size} components`, {
1989
+ onceKey: `component-registry:${registry.size}`
1990
+ });
1991
+ } else {
1992
+ console.log(`[zenith] Component registry: ${registry.size} components`);
1993
+ }
1353
1994
  }
1354
1995
 
1355
1996
  const manifest = await generateManifest(pagesDir);
@@ -1362,13 +2003,19 @@ export async function build(options) {
1362
2003
  const componentDocumentModeCache = new Map();
1363
2004
  /** @type {Map<string, { map: Map<string, string>, ambiguous: Set<string> }>} */
1364
2005
  const componentExpressionRewriteCache = new Map();
1365
- const emitCompilerWarning = createCompilerWarningEmitter((line) => console.warn(line));
2006
+ const emitCompilerWarning = createCompilerWarningEmitter((line) => {
2007
+ if (logger && typeof logger.warn === 'function') {
2008
+ logger.warn(line, { onceKey: `compiler-warning:${line}` });
2009
+ return;
2010
+ }
2011
+ console.warn(line);
2012
+ });
1366
2013
 
1367
2014
  const envelopes = [];
1368
2015
  for (const entry of manifest) {
1369
2016
  const sourceFile = join(pagesDir, entry.file);
1370
2017
  const rawSource = readFileSync(sourceFile, 'utf8');
1371
- const componentUsageAttrs = collectComponentUsageAttrs(rawSource, registry);
2018
+ const componentUsageAttrs = collectRecursiveComponentUsageAttrs(rawSource, registry, sourceFile);
1372
2019
 
1373
2020
  const baseName = sourceFile.slice(0, -extname(sourceFile).length);
1374
2021
  let adjacentGuard = null;
@@ -1421,6 +2068,7 @@ export async function build(options) {
1421
2068
  // Ensure IR has required array fields for merging
1422
2069
  pageIr.components_scripts = pageIr.components_scripts || {};
1423
2070
  pageIr.component_instances = pageIr.component_instances || [];
2071
+ pageIr.signals = Array.isArray(pageIr.signals) ? pageIr.signals : [];
1424
2072
  pageIr.hoisted = pageIr.hoisted || { imports: [], declarations: [], functions: [], signals: [], state: [], code: [] };
1425
2073
  pageIr.hoisted.imports = pageIr.hoisted.imports || [];
1426
2074
  pageIr.hoisted.declarations = pageIr.hoisted.declarations || [];
@@ -1430,7 +2078,12 @@ export async function build(options) {
1430
2078
  pageIr.hoisted.code = pageIr.hoisted.code || [];
1431
2079
  const seenStaticImports = new Set();
1432
2080
  const pageExpressionRewriteMap = new Map();
2081
+ const pageExpressionBindingMap = new Map();
1433
2082
  const pageAmbiguousExpressionMap = new Set();
2083
+ const knownRefKeys = new Set();
2084
+ const pageScopeRewrite = buildScopedIdentifierRewrite(pageIr);
2085
+ const pageSelfExpressionRewrite = buildComponentExpressionRewrite(sourceFile, compileSource, pageIr, compilerOpts);
2086
+ const componentScopeRewriteCache = new Map();
1434
2087
 
1435
2088
  // 2c. Compile each used component separately for its script IR
1436
2089
  for (const compName of usedComponents) {
@@ -1463,11 +2116,44 @@ export async function build(options) {
1463
2116
  expressionRewrite = buildComponentExpressionRewrite(compPath, componentSource, compIr, compilerOpts);
1464
2117
  componentExpressionRewriteCache.set(compPath, expressionRewrite);
1465
2118
  }
1466
- mergeExpressionRewriteMaps(
1467
- pageExpressionRewriteMap,
1468
- pageAmbiguousExpressionMap,
1469
- expressionRewrite
1470
- );
2119
+
2120
+ let usageEntry = (componentUsageAttrs.get(compName) || [])[0] || { attrs: '', ownerPath: sourceFile };
2121
+ if (!usageEntry || typeof usageEntry !== 'object') {
2122
+ usageEntry = { attrs: '', ownerPath: sourceFile };
2123
+ }
2124
+
2125
+ let attrExpressionRewrite = pageSelfExpressionRewrite;
2126
+ let attrScopeRewrite = pageScopeRewrite;
2127
+ const ownerPath = typeof usageEntry.ownerPath === 'string' && usageEntry.ownerPath.length > 0
2128
+ ? usageEntry.ownerPath
2129
+ : sourceFile;
2130
+
2131
+ if (ownerPath !== sourceFile) {
2132
+ let ownerIr = componentIrCache.get(ownerPath);
2133
+ if (!ownerIr) {
2134
+ const ownerSource = readFileSync(ownerPath, 'utf8');
2135
+ ownerIr = runCompiler(
2136
+ ownerPath,
2137
+ stripStyleBlocks(ownerSource),
2138
+ compilerOpts,
2139
+ { onWarning: emitCompilerWarning }
2140
+ );
2141
+ componentIrCache.set(ownerPath, ownerIr);
2142
+ }
2143
+
2144
+ attrExpressionRewrite = componentExpressionRewriteCache.get(ownerPath);
2145
+ if (!attrExpressionRewrite) {
2146
+ const ownerSource = readFileSync(ownerPath, 'utf8');
2147
+ attrExpressionRewrite = buildComponentExpressionRewrite(ownerPath, ownerSource, ownerIr, compilerOpts);
2148
+ componentExpressionRewriteCache.set(ownerPath, attrExpressionRewrite);
2149
+ }
2150
+
2151
+ attrScopeRewrite = componentScopeRewriteCache.get(ownerPath);
2152
+ if (!attrScopeRewrite) {
2153
+ attrScopeRewrite = buildScopedIdentifierRewrite(ownerIr);
2154
+ componentScopeRewriteCache.set(ownerPath, attrScopeRewrite);
2155
+ }
2156
+ }
1471
2157
 
1472
2158
  // 2d. Merge component IR into page IR
1473
2159
  mergeComponentIr(
@@ -1479,19 +2165,35 @@ export async function build(options) {
1479
2165
  includeCode: true,
1480
2166
  cssImportsOnly: isDocMode,
1481
2167
  documentMode: isDocMode,
1482
- componentAttrs: (componentUsageAttrs.get(compName) || [])[0] || ''
2168
+ componentAttrs: typeof usageEntry.attrs === 'string' ? usageEntry.attrs : '',
2169
+ componentAttrsRewrite: {
2170
+ expressionRewrite: attrExpressionRewrite,
2171
+ scopeRewrite: attrScopeRewrite
2172
+ }
1483
2173
  },
1484
- seenStaticImports
2174
+ seenStaticImports,
2175
+ knownRefKeys
2176
+ );
2177
+
2178
+ mergeExpressionRewriteMaps(
2179
+ pageExpressionRewriteMap,
2180
+ pageExpressionBindingMap,
2181
+ pageAmbiguousExpressionMap,
2182
+ expressionRewrite,
2183
+ pageIr
1485
2184
  );
1486
2185
  }
1487
2186
 
1488
2187
  applyExpressionRewrites(
1489
2188
  pageIr,
1490
2189
  pageExpressionRewriteMap,
2190
+ pageExpressionBindingMap,
1491
2191
  pageAmbiguousExpressionMap
1492
2192
  );
2193
+ normalizeExpressionBindingDependencies(pageIr);
1493
2194
 
1494
2195
  rewriteLegacyMarkupIdentifiers(pageIr);
2196
+ rewriteRefBindingIdentifiers(pageIr, knownRefKeys);
1495
2197
 
1496
2198
  envelopes.push({
1497
2199
  route: entry.path,
@@ -1502,7 +2204,7 @@ export async function build(options) {
1502
2204
  }
1503
2205
 
1504
2206
  if (envelopes.length > 0) {
1505
- await runBundler(envelopes, outDir);
2207
+ await runBundler(envelopes, outDir, projectRoot, logger, showBundlerInfo);
1506
2208
  }
1507
2209
 
1508
2210
  const assets = await collectAssets(outDir);