@barefootjs/mojolicious 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -47,6 +47,9 @@ import {
47
47
  emitParsedExpr,
48
48
  emitIRNode,
49
49
  emitAttrValue,
50
+ augmentInheritedPropAccesses,
51
+ parseRecordIndexAccess,
52
+ evalStringArrayJoin,
50
53
  } from '@barefootjs/jsx'
51
54
  import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result'
52
55
 
@@ -142,7 +145,20 @@ function parsePureStringLiteral(source: string): string | null {
142
145
  if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
143
146
  return init.text
144
147
  }
145
- return null
148
+ // `[<literals>].join(' ')` module consts (e.g. Switch's `trackStateClasses`)
149
+ // → inline the flattened string byte-for-byte. See `evalStringArrayJoin`.
150
+ return evalStringArrayJoin(source)
151
+ }
152
+
153
+ /**
154
+ * (#checkbox) Quote a `render_child` named-arg / hashref key when it isn't a
155
+ * bare Perl identifier. A JSX attribute name like `data-slot` would otherwise
156
+ * emit `data-slot => '...'`, which Perl parses as the subtraction
157
+ * `data - slot`. Identifier-safe names (`className`, `size`, `_bf_slot`) pass
158
+ * through unquoted to keep the generated template readable.
159
+ */
160
+ function perlHashKey(name: string): string {
161
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : `'${name.replace(/'/g, "\\'")}'`
146
162
  }
147
163
 
148
164
  export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRenderCtx> {
@@ -198,6 +214,14 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
198
214
  * module-scope pure string literals qualify (see `collectModuleStringConsts`).
199
215
  */
200
216
  private moduleStringConsts: Map<string, string> = new Map()
217
+ /**
218
+ * Full local-constant metadata from the entry IR, kept so spread
219
+ * lowering can resolve a bare-identifier spread (`{...sizeAttrs}`) to
220
+ * its initializer text and a `Record[propKey]` spread value to the
221
+ * module-const object literal it indexes (#checkbox / icon). Populated
222
+ * at `generate()` entry alongside `moduleStringConsts`.
223
+ */
224
+ private localConstants: IRMetadata['localConstants'] = []
201
225
  /**
202
226
  * Names currently bound by an enclosing loop body — the `my $<param>` and
203
227
  * `my $<index>` bindings `renderLoop` introduces — ref-counted so nested
@@ -207,6 +231,23 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
207
231
  * loop-var shadowing guards). (#1749 review)
208
232
  */
209
233
  private loopBoundNames: Map<string, number> = new Map()
234
+ /**
235
+ * Prop names whose value is `undef` in the template body when the caller
236
+ * omits them — so a bare-reference attribute should be dropped rather
237
+ * than rendered as `attr=""`. The actual population criterion (see
238
+ * `generate()`) is: NO destructure default (`defaultValue === undefined`)
239
+ * AND non-rest (`!isRest`) AND non-primitive type (`type.kind !==
240
+ * 'primitive'`). It deliberately does NOT consult `p.optional`: the
241
+ * analyzer derives `optional` from the presence of a default initializer,
242
+ * not the `?` token, so it's not the right witness here. Excluding
243
+ * concrete primitives (`string`/`number`/`boolean`) mirrors the Go
244
+ * adapter's scope, which guards only `interface{}` (nillable) fields.
245
+ * Used by `elementAttrEmitter.emitExpression` to guard such an attribute
246
+ * with a Perl `defined $x` check (`<textarea>` omits `rows`), matching
247
+ * Hono's nullish-attribute omission. Concrete/defaulted props are
248
+ * excluded and always emit unconditionally.
249
+ */
250
+ private nullableOptionalProps: Set<string> = new Set()
210
251
 
211
252
  constructor(options: MojoAdapterOptions = {}) {
212
253
  super()
@@ -219,7 +260,40 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
219
260
  generate(ir: ComponentIR, options?: AdapterGenerateOptions): AdapterOutput {
220
261
  this.componentName = ir.metadata.componentName
221
262
  this.propsObjectName = ir.metadata.propsObjectName ?? null
263
+ // (#checkbox) Enumerate inherited-attribute accesses for the props-object
264
+ // pattern (`function Checkbox(props: CheckboxProps)`) before deriving
265
+ // `nullableOptionalProps`, so a bare optional attribute like
266
+ // `id={props.id}` gets the Perl `defined`-guard (Hono-style omission).
267
+ // Shared with the Go adapter (single source of truth in `@barefootjs/jsx`).
268
+ // The harness separately declares the matching stash vars (Pass-1 IR
269
+ // serialization happens before `generate`, so this mutation doesn't reach it).
270
+ augmentInheritedPropAccesses(ir)
222
271
  this.propsParams = ir.metadata.propsParams.map(p => ({ name: p.name }))
272
+ // No-destructure-default props → `undef` when the caller omits them
273
+ // → guard their bare-reference attribute emission with Perl `defined`
274
+ // so the attribute drops instead of rendering `attr=""` (Hono-style
275
+ // nullish omission). A prop WITH a destructure default (`value = ''`)
276
+ // is never `undef` in the body and must stay unconditional, so it is
277
+ // excluded. This mirrors the Go adapter's nillable-field guard: there
278
+ // the witness is the resolved `interface{}` field type; here it is
279
+ // the absence of a default (the analyzer reports `rows` — a
280
+ // `TextareaHTMLAttributes` member destructured without a default — as
281
+ // no-default, `type.kind: 'unknown'`).
282
+ // Excludes concrete-primitive types (`string`/`number`/`boolean`)
283
+ // to match the Go adapter's scope, which guards only `interface{}`
284
+ // (nillable) fields and leaves concrete fields unconditional. So a
285
+ // required, no-default `string` prop still emits `attr=""` like Hono,
286
+ // and only nillable (`unknown`/object/array) no-default props guard.
287
+ this.nullableOptionalProps = new Set(
288
+ ir.metadata.propsParams
289
+ .filter(
290
+ p =>
291
+ p.defaultValue === undefined &&
292
+ !p.isRest &&
293
+ p.type?.kind !== 'primitive',
294
+ )
295
+ .map(p => p.name),
296
+ )
223
297
  // Record string-typed signals and props so equality comparisons against
224
298
  // them lower to `eq`/`ne` (#1672). A signal is string-typed when its
225
299
  // inferred type is `string` (the analyzer infers this from a string-literal
@@ -235,6 +309,7 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
235
309
  if (isStringTypeInfo(p.type)) this.stringValueNames.add(p.name)
236
310
  }
237
311
  this.moduleStringConsts = this.collectModuleStringConsts(ir.metadata.localConstants)
312
+ this.localConstants = ir.metadata.localConstants ?? []
238
313
  this.loopBoundNames.clear()
239
314
  this.errors = []
240
315
  this.childrenCaptureCounter = 0
@@ -766,7 +841,7 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
766
841
  * `render_child` named-arg list.
767
842
  */
768
843
  private readonly componentPropEmitter: AttrValueEmitter = {
769
- emitLiteral: (value, name) => `${name} => '${value.value}'`,
844
+ emitLiteral: (value, name) => `${perlHashKey(name)} => '${value.value}'`,
770
845
  emitExpression: (value, name) => {
771
846
  // The IR producer collapses component-prop `template` kinds
772
847
  // into `expression` for client-runtime reasons but preserves
@@ -775,9 +850,9 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
775
850
  // `${MAP[KEY]}` shapes (the JS object literal leaks into the
776
851
  // Perl template).
777
852
  if (value.parts) {
778
- return `${name} => ${this.convertTemplateLiteralPartsToPerl(value.parts)}`
853
+ return `${perlHashKey(name)} => ${this.convertTemplateLiteralPartsToPerl(value.parts)}`
779
854
  }
780
- return `${name} => ${this.convertExpressionToPerl(value.expr)}`
855
+ return `${perlHashKey(name)} => ${this.convertExpressionToPerl(value.expr)}`
781
856
  },
782
857
  emitSpread: (value) => {
783
858
  // Perl has no JS-style spread — emit the source as a hash
@@ -790,9 +865,9 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
790
865
  return perlExpr.startsWith('%') ? perlExpr : `%{${perlExpr}}`
791
866
  },
792
867
  emitTemplate: (value, name) =>
793
- `${name} => ${this.convertTemplateLiteralPartsToPerl(value.parts)}`,
794
- emitBooleanAttr: (_value, name) => `${name} => 1`,
795
- emitBooleanShorthand: (_value, name) => `${name} => 1`,
868
+ `${perlHashKey(name)} => ${this.convertTemplateLiteralPartsToPerl(value.parts)}`,
869
+ emitBooleanAttr: (_value, name) => `${perlHashKey(name)} => 1`,
870
+ emitBooleanShorthand: (_value, name) => `${perlHashKey(name)} => 1`,
796
871
  // JSX children flow through Mojo's `begin %>…<% end` capture
797
872
  // below; they're not part of the named-arg list.
798
873
  emitJsxChildren: () => '',
@@ -935,6 +1010,41 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
935
1010
  if (this.refuseUnsupportedAttrExpression(value.expr, name)) {
936
1011
  return ''
937
1012
  }
1013
+ // Hono-style nullish-attribute omission (#textarea rows): when the
1014
+ // attribute value is a BARE reference to an optional, no-default
1015
+ // prop (which is `undef` when the caller omits it), guard the
1016
+ // attribute with Perl `defined` so it DROPS rather than rendering
1017
+ // `attr=""`. The guarded body reuses the exact normal emission, so
1018
+ // value escaping (`<%= ... %>`) is unchanged; only the presence is
1019
+ // conditional. The `% if`/`% end` line directives surround the
1020
+ // attribute inline — the conformance comparator collapses the
1021
+ // resulting whitespace, exactly like the existing boolean-attr and
1022
+ // hydration-marker patterns. Scope is deliberately narrow (bare
1023
+ // identifiers resolving to an optional-no-default prop) so member
1024
+ // exprs, calls, concrete/defaulted props, and boolean attrs are
1025
+ // unaffected and still emit unconditionally.
1026
+ const bareId = value.expr.trim()
1027
+ // Normalize a props-object access (`props.id`) to its bare prop name
1028
+ // (`id`) so the nullable-optional set — keyed by bare name — matches the
1029
+ // SolidJS props-object pattern, not just destructured params (#checkbox
1030
+ // `id={props.id}`).
1031
+ const normalizedBareId =
1032
+ this.propsObjectName && bareId.startsWith(`${this.propsObjectName}.`)
1033
+ ? bareId.slice(this.propsObjectName.length + 1)
1034
+ : bareId
1035
+ if (
1036
+ !isBooleanAttr(name) &&
1037
+ !value.presenceOrUndefined &&
1038
+ /^[A-Za-z_$][\w$]*$/.test(normalizedBareId) &&
1039
+ this.nullableOptionalProps.has(normalizedBareId)
1040
+ ) {
1041
+ const perl = this.convertExpressionToPerl(value.expr)
1042
+ const body =
1043
+ isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name)
1044
+ ? `${name}="<%= bf->bool_str(${perl}) %>"`
1045
+ : `${name}="<%= ${perl} %>"`
1046
+ return `<% if (defined ${perl}) { %>${body}<% } %>`
1047
+ }
938
1048
  if (isBooleanAttr(name) || value.presenceOrUndefined) {
939
1049
  // Boolean attributes: render conditionally (present or absent).
940
1050
  return `<%= ${this.convertExpressionToPerl(value.expr)} ? '${name}' : '' %>`
@@ -1008,6 +1118,37 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1008
1118
  )
1009
1119
  return `<%== bf->spread_attrs({${entries.join(', ')}}) %>`
1010
1120
  }
1121
+ // Conditional inline-object spread:
1122
+ // `{...(COND ? { 'aria-describedby': describedBy } : {})}`
1123
+ // Emit a Perl inline ternary of hashrefs — Perl truthiness
1124
+ // handles the condition for free, and the falsy `{}` branch
1125
+ // OMITS the key (`bf->spread_attrs` does NOT filter empty
1126
+ // strings, so we cannot always-include it). Mirrors the Go
1127
+ // adapter's IIFE-of-maps lowering (#textarea).
1128
+ const ternaryHashref = this.conditionalSpreadToPerl(trimmed)
1129
+ if (ternaryHashref !== null) {
1130
+ return `<%== bf->spread_attrs(${ternaryHashref}) %>`
1131
+ }
1132
+ // Function-scope local const holding a conditional inline-object
1133
+ // `const sizeAttrs = size ? {…} : {}` then `{...sizeAttrs}`
1134
+ // (#checkbox / icon). Resolve the bare identifier to its
1135
+ // initializer text and route through the same conditional-spread
1136
+ // lowering. Only function-scope (`!isModule`) consts whose value is
1137
+ // NOT itself a bare identifier (loop guard) are considered.
1138
+ if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) {
1139
+ const localConst = this.localConstants.find(
1140
+ c => c.name === trimmed && !c.isModule,
1141
+ )
1142
+ if (localConst?.value !== undefined) {
1143
+ const initTrimmed = localConst.value.trim()
1144
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(initTrimmed)) {
1145
+ const resolved = this.conditionalSpreadToPerl(initTrimmed)
1146
+ if (resolved !== null) {
1147
+ return `<%== bf->spread_attrs(${resolved}) %>`
1148
+ }
1149
+ }
1150
+ }
1151
+ }
1011
1152
  const perlExpr = this.convertExpressionToPerl(value.expr)
1012
1153
  return `<%== bf->spread_attrs(${perlExpr}) %>`
1013
1154
  },
@@ -1294,6 +1435,114 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1294
1435
  }
1295
1436
 
1296
1437
 
1438
+ /**
1439
+ * Lower a conditional inline-object spread expression
1440
+ * `(COND ? { 'aria-describedby': describedBy } : {})`
1441
+ * (either branch possibly `{}`) into a Perl inline ternary of
1442
+ * hashrefs for `bf->spread_attrs`:
1443
+ * `$describedBy ? { 'aria-describedby' => $describedBy } : {}`
1444
+ *
1445
+ * The condition is translated via `convertExpressionToPerl` (a bare
1446
+ * prop ident becomes `$describedBy`; Perl truthiness handles the
1447
+ * test). Object literals become Perl hashrefs with `=>`; string-
1448
+ * literal keys are quoted, values resolve via `convertExpressionToPerl`.
1449
+ *
1450
+ * Returns null when the expression is NOT this shape, or when a part
1451
+ * can't be faithfully lowered (non-static key, etc.) so the caller
1452
+ * falls back to the standard `convertExpressionToPerl` path (which
1453
+ * records BF101). Scoped strictly to ternary-of-object-literals so no
1454
+ * other spread shape regresses.
1455
+ */
1456
+ private conditionalSpreadToPerl(expr: string): string | null {
1457
+ const sf = ts.createSourceFile('__spread.ts', `(${expr})`, ts.ScriptTarget.Latest, true)
1458
+ if (sf.statements.length !== 1) return null
1459
+ const stmt = sf.statements[0]
1460
+ if (!ts.isExpressionStatement(stmt)) return null
1461
+ let node: ts.Expression = stmt.expression
1462
+ while (ts.isParenthesizedExpression(node)) node = node.expression
1463
+ if (!ts.isConditionalExpression(node)) return null
1464
+ const unwrap = (e: ts.Expression): ts.Expression => {
1465
+ let n = e
1466
+ while (ts.isParenthesizedExpression(n)) n = n.expression
1467
+ return n
1468
+ }
1469
+ const whenTrue = unwrap(node.whenTrue)
1470
+ const whenFalse = unwrap(node.whenFalse)
1471
+ if (!ts.isObjectLiteralExpression(whenTrue) || !ts.isObjectLiteralExpression(whenFalse)) {
1472
+ return null
1473
+ }
1474
+ const condPerl = this.convertExpressionToPerl(node.condition.getText(sf))
1475
+ const truePerl = this.objectLiteralToPerlHashref(whenTrue, sf)
1476
+ const falsePerl = this.objectLiteralToPerlHashref(whenFalse, sf)
1477
+ if (truePerl === null || falsePerl === null) return null
1478
+ return `${condPerl} ? ${truePerl} : ${falsePerl}`
1479
+ }
1480
+
1481
+ /**
1482
+ * Convert a static object literal into a Perl hashref string for a
1483
+ * conditional spread. Only static string/identifier keys are allowed;
1484
+ * values resolve via `convertExpressionToPerl`. Returns null for any
1485
+ * computed/spread/dynamic key. Empty object → `{}`.
1486
+ */
1487
+ private objectLiteralToPerlHashref(
1488
+ obj: ts.ObjectLiteralExpression,
1489
+ sf: ts.SourceFile,
1490
+ ): string | null {
1491
+ const entries: string[] = []
1492
+ for (const prop of obj.properties) {
1493
+ if (!ts.isPropertyAssignment(prop)) return null
1494
+ let key: string
1495
+ if (ts.isIdentifier(prop.name)) {
1496
+ key = prop.name.text
1497
+ } else if (ts.isStringLiteral(prop.name) || ts.isNoSubstitutionTemplateLiteral(prop.name)) {
1498
+ key = prop.name.text
1499
+ } else {
1500
+ return null
1501
+ }
1502
+ const initNode = (() => {
1503
+ let n: ts.Expression = prop.initializer
1504
+ while (ts.isParenthesizedExpression(n)) n = n.expression
1505
+ return n
1506
+ })()
1507
+ const indexed = this.recordIndexAccessToPerl(initNode)
1508
+ const valPerl =
1509
+ indexed !== null
1510
+ ? indexed
1511
+ : this.convertExpressionToPerl(prop.initializer.getText(sf))
1512
+ entries.push(`'${key.replace(/'/g, "\\'")}' => ${valPerl}`)
1513
+ }
1514
+ return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`
1515
+ }
1516
+
1517
+ /**
1518
+ * Lower a spread-object VALUE of the form `IDENT[KEY]` where:
1519
+ * - `IDENT` resolves via `localConstants` to a MODULE-scope object
1520
+ * literal whose property values are all scalar (number/string)
1521
+ * literals under static (string-literal or identifier) keys
1522
+ * (a `Record<staticKeys, scalar>` map like `sizeMap`), AND
1523
+ * - `KEY` is a bare identifier that is a prop.
1524
+ * Emits an inline indexed Perl hashref:
1525
+ * `{ 'sm' => 16, 'md' => 20, ... }->{$size}`
1526
+ *
1527
+ * Returns the Perl string when convertible, else `null` so the caller
1528
+ * falls back to its normal value lowering (which records BF101 for an
1529
+ * unsupported shape). Mirror of the Go adapter's `recordIndexAccessToGoMap`.
1530
+ */
1531
+ private recordIndexAccessToPerl(val: ts.Expression): string | null {
1532
+ // Shared structural parse (single source of truth in `@barefootjs/jsx`);
1533
+ // this wrapper only does the Perl-specific emit (single-quote escaping)
1534
+ // from the structured result.
1535
+ const parsed = parseRecordIndexAccess(val, this.localConstants, this.propsParams)
1536
+ if (!parsed) return null
1537
+ const entries = parsed.entries.map(e => {
1538
+ const mapVal =
1539
+ e.value.kind === 'number' ? e.value.text : `'${e.value.text.replace(/'/g, "\\'")}'`
1540
+ return `'${e.key.replace(/'/g, "\\'")}' => ${mapVal}`
1541
+ })
1542
+ return `{ ${entries.join(', ')} }->{$${parsed.indexPropName}}`
1543
+ }
1544
+
1545
+
1297
1546
  private convertExpressionToPerl(expr: string): string {
1298
1547
  // Parse-first lowering — parity with the Go adapter's
1299
1548
  // `convertExpressionToGo`. Parse the JS expression once, gate it on
@@ -349,7 +349,12 @@ function buildChildRenderers(
349
349
  }
350
350
  lines.push(` ${slotIdsPerl}`)
351
351
  lines.push(` my $child_bf = BarefootJS->new($c, {});`)
352
- lines.push(` $child_bf->_scope_id("test_$sid");`)
352
+ // Child scope id derives from the PARENT's live scope id, not a literal
353
+ // `test_` prefix — so `<ReactiveProps_test>_s5` matches Hono / CSR (and
354
+ // survives an explicit `__instanceId`). Mirrors xslate's
355
+ // `rootChildScopePrefix` (`$bf->_scope_id`); `$bf` is the parent instance
356
+ // captured by this renderer closure.
357
+ lines.push(` $child_bf->_scope_id($bf->_scope_id . "_$sid");`)
353
358
  lines.push(` my $rendered = $child_mt->render($child_tmpl, { %$child_props, bf => $child_bf });`)
354
359
  lines.push(` die $rendered->to_string if ref $rendered;`)
355
360
  lines.push(` chomp $rendered;`)
@@ -433,6 +438,26 @@ function buildPerlProps(
433
438
  entries.push(`${param.name} => undef`)
434
439
  }
435
440
 
441
+ // (#checkbox) SolidJS props-object pattern: `function Checkbox(props:
442
+ // CheckboxProps)` only enumerates `CheckboxProps`'s own members in
443
+ // `propsParams`; inherited `ButtonHTMLAttributes` members the component
444
+ // reads as bare template vars (`$id`, `$disabled`, `$className`) are not.
445
+ // The generated template references them, so the stash must declare them or
446
+ // Perl strict mode aborts with `Global symbol "$id" requires explicit
447
+ // package name`. Scan the IR for `props.<name>` accesses and seed any not
448
+ // already declared (and not supplied by the caller below) as `undef`.
449
+ // Mirrors the Go adapter's `augmentInheritedPropAccesses`.
450
+ if (ir.metadata.propsObjectName) {
451
+ const propsObj = ir.metadata.propsObjectName
452
+ const declared = new Set(ir.metadata.propsParams.map(p => p.name))
453
+ for (const name of collectPropsObjectAccesses(ir, propsObj)) {
454
+ if (declared.has(name)) continue
455
+ if (props && name in props) continue // emitted by the user-props loop
456
+ entries.push(`${name} => undef`)
457
+ declared.add(name)
458
+ }
459
+ }
460
+
436
461
  // A `{...props}` rest spread means props that aren't declared named
437
462
  // params flow through the rest bag (`bf->spread_attrs($<restPropsName>)`),
438
463
  // not their own top-level template var. Route them into the bag hashref so
@@ -524,6 +549,38 @@ function buildPerlProps(
524
549
  return `{${entries.join(', ')}}`
525
550
  }
526
551
 
552
+ /**
553
+ * (#checkbox) Collect `<propsObj>.<name>` accesses across a component's memos,
554
+ * signal initializers, init statements, and template attribute expressions —
555
+ * the inherited-attribute reads (`props.className`, `props.id`, `props.disabled`)
556
+ * a SolidJS props-object component makes but that aren't enumerated in
557
+ * `propsParams`. Used to declare matching stash variables.
558
+ */
559
+ function collectPropsObjectAccesses(ir: ComponentIR, propsObj: string): Set<string> {
560
+ const out = new Set<string>()
561
+ const re = new RegExp(`(?:^|[^\\w$.])${propsObj}\\.([A-Za-z_$][\\w$]*)`, 'g')
562
+ const scan = (s: string | undefined): void => {
563
+ if (!s) return
564
+ for (const m of s.matchAll(re)) out.add(m[1])
565
+ }
566
+ for (const memo of ir.metadata.memos) scan(memo.computation)
567
+ for (const sig of ir.metadata.signals) scan(sig.initialValue)
568
+ for (const stmt of ir.metadata.initStatements ?? []) scan(stmt.body)
569
+ const walk = (node: unknown): void => {
570
+ if (!node || typeof node !== 'object') return
571
+ const el = node as { attrs?: Array<{ value?: { kind?: string; expr?: string } }>; children?: unknown[] }
572
+ for (const attr of el.attrs ?? []) {
573
+ if (attr.value?.kind === 'expression') scan(attr.value.expr)
574
+ }
575
+ for (const child of el.children ?? []) {
576
+ const c = child as { element?: unknown }
577
+ walk(c.element ?? child)
578
+ }
579
+ }
580
+ walk(ir.root)
581
+ return out
582
+ }
583
+
527
584
  /**
528
585
  * Evaluate a signal initializer expression using provided props.
529
586
  * Handles patterns like: props.initial ?? 0, props.value, literal values.