@barefootjs/erb 0.17.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.
Files changed (68) hide show
  1. package/dist/adapter/analysis/component-tree.d.ts +24 -0
  2. package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
  3. package/dist/adapter/boolean-result.d.ts +66 -0
  4. package/dist/adapter/boolean-result.d.ts.map +1 -0
  5. package/dist/adapter/emit-context.d.ts +107 -0
  6. package/dist/adapter/emit-context.d.ts.map +1 -0
  7. package/dist/adapter/erb-adapter.d.ts +374 -0
  8. package/dist/adapter/erb-adapter.d.ts.map +1 -0
  9. package/dist/adapter/expr/array-method.d.ts +87 -0
  10. package/dist/adapter/expr/array-method.d.ts.map +1 -0
  11. package/dist/adapter/expr/emitters.d.ts +102 -0
  12. package/dist/adapter/expr/emitters.d.ts.map +1 -0
  13. package/dist/adapter/expr/operand.d.ts +34 -0
  14. package/dist/adapter/expr/operand.d.ts.map +1 -0
  15. package/dist/adapter/index.d.ts +6 -0
  16. package/dist/adapter/index.d.ts.map +1 -0
  17. package/dist/adapter/index.js +189154 -0
  18. package/dist/adapter/lib/constants.d.ts +25 -0
  19. package/dist/adapter/lib/constants.d.ts.map +1 -0
  20. package/dist/adapter/lib/ir-scope.d.ts +27 -0
  21. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  22. package/dist/adapter/lib/ruby-naming.d.ts +65 -0
  23. package/dist/adapter/lib/ruby-naming.d.ts.map +1 -0
  24. package/dist/adapter/lib/types.d.ts +28 -0
  25. package/dist/adapter/lib/types.d.ts.map +1 -0
  26. package/dist/adapter/memo/seed.d.ts +35 -0
  27. package/dist/adapter/memo/seed.d.ts.map +1 -0
  28. package/dist/adapter/props/prop-classes.d.ts +50 -0
  29. package/dist/adapter/props/prop-classes.d.ts.map +1 -0
  30. package/dist/adapter/spread/spread-codegen.d.ts +63 -0
  31. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  32. package/dist/adapter/value/parsed-literal.d.ts +21 -0
  33. package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
  34. package/dist/build.d.ts +28 -0
  35. package/dist/build.d.ts.map +1 -0
  36. package/dist/build.js +189174 -0
  37. package/dist/conformance-pins.d.ts +13 -0
  38. package/dist/conformance-pins.d.ts.map +1 -0
  39. package/dist/index.d.ts +9 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +189172 -0
  42. package/lib/barefoot_js/backend/erb.rb +123 -0
  43. package/lib/barefoot_js/dev_reload.rb +159 -0
  44. package/lib/barefoot_js/evaluator.rb +714 -0
  45. package/lib/barefoot_js/search_params.rb +63 -0
  46. package/lib/barefoot_js.rb +1155 -0
  47. package/package.json +67 -0
  48. package/src/__tests__/erb-adapter.test.ts +298 -0
  49. package/src/adapter/analysis/component-tree.ts +122 -0
  50. package/src/adapter/boolean-result.ts +157 -0
  51. package/src/adapter/emit-context.ts +119 -0
  52. package/src/adapter/erb-adapter.ts +1768 -0
  53. package/src/adapter/expr/array-method.ts +424 -0
  54. package/src/adapter/expr/emitters.ts +629 -0
  55. package/src/adapter/expr/operand.ts +55 -0
  56. package/src/adapter/index.ts +6 -0
  57. package/src/adapter/lib/constants.ts +37 -0
  58. package/src/adapter/lib/ir-scope.ts +51 -0
  59. package/src/adapter/lib/ruby-naming.ts +127 -0
  60. package/src/adapter/lib/types.ts +31 -0
  61. package/src/adapter/memo/seed.ts +75 -0
  62. package/src/adapter/props/prop-classes.ts +89 -0
  63. package/src/adapter/spread/spread-codegen.ts +176 -0
  64. package/src/adapter/value/parsed-literal.ts +29 -0
  65. package/src/build.ts +37 -0
  66. package/src/conformance-pins.ts +100 -0
  67. package/src/index.ts +9 -0
  68. package/src/test-render.ts +668 -0
@@ -0,0 +1,1768 @@
1
+ /**
2
+ * BarefootJS ERB (Embedded Ruby) Template Adapter
3
+ *
4
+ * Generates ERB template files (.erb) from BarefootJS IR. Ported from the
5
+ * Mojolicious EP adapter (`@barefootjs/mojolicious`) — EP (Embedded Perl)
6
+ * maps 1:1 onto ERB (Embedded Ruby) for control flow and hydration-marker
7
+ * shape; the substantive divergences are:
8
+ *
9
+ * - **Variable model.** Templates receive exactly two locals: `bf`
10
+ * (runtime) and `v` (vars Hash, symbol keys). Every prop / signal /
11
+ * memo / module-constant reference lowers to `v[:name]` — never a bare
12
+ * Ruby local. Perl's uniform `$name` sigil doesn't need this split
13
+ * (a lexical `my $name` and a stash `$name` render identically); Ruby's
14
+ * bare-identifier ambiguity (reserved words, leading-uppercase =
15
+ * constant) is why props/signals/consts move to a Hash instead.
16
+ * Loop/block params are the one case that stays a bare Ruby local
17
+ * (`lib/ruby-naming.ts::rubyLocal`).
18
+ * - **Escaping.** Mojo's `<%= %>` auto-escapes; stdlib ERB does not — every
19
+ * text/attribute-value interpolation that was a plain mojo `<%= %>`
20
+ * becomes ERB `<%= bf.h(...) %>`. Every *raw* mojo `<%== %>` (runtime
21
+ * helper output, already HTML) becomes a plain ERB `<%= %>` (no `bf.h`
22
+ * — the plan's blanket EP→ERB mapping rule, applied mechanically
23
+ * everywhere so there's exactly one place to audit for escaping bugs).
24
+ * - **Ruby truthiness.** Ruby's falsy set is only `nil`/`false` — JS's is
25
+ * `false, 0, NaN, "", null/undefined`. Every JS conditional TEST (`if`,
26
+ * `&&`, `||`, `!`, `?:`) wraps in `bf.truthy?(...)`; ERB conditionals
27
+ * wrap the same way. See `expr/emitters.ts`'s file docstring for the
28
+ * `&&`/`||` operand-returning rewrite this forces.
29
+ * - **Content capture.** Mojo's `begin %>…<% end` block-capture (for
30
+ * forwarded JSX children / async fallback) has no ERB syntax analog;
31
+ * ERB captures by slicing the shared output buffer around the nested
32
+ * render (see `renderComponent` / `renderAsync`).
33
+ *
34
+ * See `expr/emitters.ts`, `expr/operand.ts`, and `lib/ruby-naming.ts` for
35
+ * the rest of the Perl→Ruby emission-contract detail.
36
+ */
37
+
38
+ import type {
39
+ ComponentIR,
40
+ IRMetadata,
41
+ IRNode,
42
+ IRElement,
43
+ IRText,
44
+ IRExpression,
45
+ IRConditional,
46
+ IRLoop,
47
+ IRComponent,
48
+ IRFragment,
49
+ IRSlot,
50
+ IRIfStatement,
51
+ IRProvider,
52
+ IRAsync,
53
+ IRProp,
54
+ IRTemplatePart,
55
+ AttrValue,
56
+ CompilerError,
57
+ TypeInfo,
58
+ TemplatePrimitiveRegistry,
59
+ } from '@barefootjs/jsx'
60
+ import {
61
+ BaseAdapter,
62
+ type AdapterOutput,
63
+ type AdapterGenerateOptions,
64
+ type TemplateSections,
65
+ type IRNodeEmitter,
66
+ type EmitIRNode,
67
+ type AttrValueEmitter,
68
+ isBooleanAttr,
69
+ parseExpression,
70
+ stringifyParsedExpr,
71
+ parseStyleObjectEntries,
72
+ isSupported,
73
+ exprToString,
74
+ parseProviderObjectLiteral,
75
+ emitParsedExpr,
76
+ emitIRNode,
77
+ emitAttrValue,
78
+ augmentInheritedPropAccesses,
79
+ parseRecordIndexAccess,
80
+ extractArrowBodyExpression,
81
+ collectContextConsumers,
82
+ isLowerableLoopDestructure,
83
+ type ContextConsumer,
84
+ collectModuleStringConsts,
85
+ lookupStaticRecordLiteral,
86
+ searchParamsLocalNames,
87
+ prepareLoweringMatchers,
88
+ queryHrefArgs,
89
+ isValidHelperId,
90
+ sortComparatorFromArrow,
91
+ } from '@barefootjs/jsx'
92
+ import { isAriaBooleanAttr, isBooleanResultExpr, isExplicitStringCall } from './boolean-result.ts'
93
+ import type { ParsedExpr, LoweringMatcher, LoopBindingPathSegment } from '@barefootjs/jsx'
94
+ import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
95
+
96
+ import type { ErbRenderCtx } from './lib/types.ts'
97
+ import { ERB_PRIMITIVE_EMIT_MAP } from './lib/constants.ts'
98
+ import {
99
+ rubyLocal,
100
+ rubyStringLiteral,
101
+ rubySymbolKey,
102
+ rubySymbolLiteral,
103
+ rubyIdentifierFromMarkerId,
104
+ } from './lib/ruby-naming.ts'
105
+ import {
106
+ resolveJsxChildrenProp,
107
+ collectRootScopeNodes,
108
+ } from './lib/ir-scope.ts'
109
+ import { renderSortMethod, renderSortEval } from './expr/array-method.ts'
110
+ import { ErbFilterEmitter, ErbTopLevelEmitter } from './expr/emitters.ts'
111
+ import type { ErbEmitContext, ErbSpreadContext, ErbMemoContext } from './emit-context.ts'
112
+ import {
113
+ hasClientInteractivity,
114
+ collectImportedLoopChildComponentErrors,
115
+ } from './analysis/component-tree.ts'
116
+ import {
117
+ conditionalSpreadToRuby,
118
+ objectLiteralExprToRubyHash,
119
+ } from './spread/spread-codegen.ts'
120
+ import {
121
+ generateContextConsumerSeed,
122
+ generateDerivedMemoSeed,
123
+ } from './memo/seed.ts'
124
+ import {
125
+ collectProviderDataNames,
126
+ collectBooleanTypedProps,
127
+ collectNullableOptionalProps,
128
+ collectStringValueNames,
129
+ } from './props/prop-classes.ts'
130
+
131
+ export type { ErbAdapterOptions } from './lib/types.ts'
132
+ import type { ErbAdapterOptions } from './lib/types.ts'
133
+
134
+ /**
135
+ * Build a chained Ruby Hash/Array accessor from a `.map()` destructure
136
+ * binding's structured `segments` path (#2087 Phase B) — walking `segments`
137
+ * instead of string-parsing `LoopParamBinding.path` (repo rule: never parse
138
+ * JS/TS syntax with regex or string matching). A `field` step reads a
139
+ * symbol key (`[:name]` / `[:"data-priority"]`, matching every item Hash's
140
+ * `JSON.parse(..., symbolize_names: true)` construction); an `index` step
141
+ * reads a numeric Array index (`[0]`). Empty `segments` (a rest binding at
142
+ * the loop root) returns `base` unchanged.
143
+ */
144
+ function rubyAccessorFromSegments(base: string, segments: readonly LoopBindingPathSegment[]): string {
145
+ let accessor = base
146
+ for (const seg of segments) {
147
+ accessor += seg.kind === 'index' ? `[${seg.index}]` : `[${rubySymbolLiteral(seg.key)}]`
148
+ }
149
+ return accessor
150
+ }
151
+
152
+ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCtx> {
153
+ name = 'erb'
154
+ extension = '.erb'
155
+ templatesPerComponent = true
156
+ // Template-string target with no component layer: `bf build` emits a
157
+ // static `barefoot-importmap.html` to include in the page <head>, same as
158
+ // the Mojo/Go adapters.
159
+ importMapInjection = 'html-snippet' as const
160
+
161
+ /**
162
+ * Identifier-path callees the ERB runtime can render in template scope.
163
+ * The relocate pass consults this map to mark matching calls as
164
+ * template-safe so the surrounding expression stays inlinable; the SSR
165
+ * template emitter substitutes the JS call with the registered Ruby
166
+ * helper invocation.
167
+ */
168
+ templatePrimitives: TemplatePrimitiveRegistry = ERB_PRIMITIVE_EMIT_MAP
169
+
170
+ private componentName: string = ''
171
+ /** The component's root scope element(s) — each carries `data-key` for a
172
+ * keyed loop item (set by the child renderer from the JSX `key` prop). A
173
+ * plain element root is a single node; an `if-statement` (early-return)
174
+ * root contributes the top element of every branch, since any one of
175
+ * them can be the rendered root at runtime. */
176
+ private rootScopeNodes: Set<IRNode> = new Set()
177
+ private options: Required<ErbAdapterOptions>
178
+ private errors: CompilerError[] = []
179
+ private inLoop: boolean = false
180
+ /**
181
+ * SolidJS-style props identifier (`function(props: P)`) and the
182
+ * analyzer-extracted prop names. Stashed at `generate()` entry so the
183
+ * per-attribute `emitSpread` callback can build a propsObject spread bag
184
+ * as an inline Ruby Hash literal without re-walking the IR.
185
+ */
186
+ private propsObjectName: string | null = null
187
+ private propsParams: { name: string }[] = []
188
+ private booleanTypedProps: Set<string> = new Set()
189
+ /**
190
+ * Names that resolve to a real SSR template var (via `v[:name]`) — prop
191
+ * param, signal getter, or memo. A `<Ctx.Provider value>` member
192
+ * referencing a name NOT in this set is a client-only function (a local
193
+ * handler const, or a signal setter) with no SSR value: it would read an
194
+ * un-seeded vars-Hash key, so it's lowered to `nil` instead.
195
+ */
196
+ private providerDataNames: Set<string> = new Set()
197
+ /**
198
+ * Names (signal getters + props) whose value is a string. Ruby's `==`
199
+ * doesn't drive equality-operator selection the way Perl's `eq`/`ne`
200
+ * split does (see `props/prop-classes.ts`), but this set still gates
201
+ * index-access Hash-vs-Array lowering (`expr/operand.ts`).
202
+ */
203
+ private stringValueNames: Set<string> = new Set()
204
+ /**
205
+ * Local binding names the request-scoped `searchParams()` env signal is
206
+ * imported under (handles `import { searchParams as sp }`). When
207
+ * non-empty the emitter lowers a `<binding>().get(k)` call to a real
208
+ * method call on the reserved `v[:search_params]` reader instead of the
209
+ * generic Hash lookup. Set at `generate()` entry from `ir.metadata.imports`;
210
+ * read by the top-level ParsedExpr emitter.
211
+ */
212
+ private _searchParamsLocals: Set<string> = new Set()
213
+
214
+ /**
215
+ * Call-lowering matchers active for this component. Bound at
216
+ * `generate()` entry via `prepareLoweringMatchers` and read by the
217
+ * top-level emitter. Covers both userland plugins and the compiler's
218
+ * built-in plugins (e.g. `queryHref` → `bf.query`) — one uniform path,
219
+ * no per-API branch.
220
+ */
221
+ private _loweringMatchers: LoweringMatcher[] = []
222
+ /**
223
+ * Module-scope pure string-literal constants (`const X = 'literal'` at
224
+ * file top-level), keyed by name → resolved literal value. Populated at
225
+ * `generate()` entry from `ir.metadata.localConstants`. When an
226
+ * identifier in an expression resolves to one of these, the adapter
227
+ * inlines the literal instead of emitting `v[:X]` against a vars-Hash key
228
+ * that is never seeded (a module const isn't a prop, signal, or local —
229
+ * the value would render empty).
230
+ */
231
+ private moduleStringConsts: Map<string, string> = new Map()
232
+ /**
233
+ * Full local-constant metadata from the entry IR, kept so spread
234
+ * lowering can resolve a bare-identifier spread (`{...sizeAttrs}`) to
235
+ * its initializer text and a `Record[propKey]` spread value to the
236
+ * module-const object literal it indexes. Populated at `generate()`
237
+ * entry alongside `moduleStringConsts`.
238
+ */
239
+ private localConstants: IRMetadata['localConstants'] = []
240
+ /**
241
+ * Names currently bound by an enclosing loop body — the block-param
242
+ * locals `renderLoop` introduces (item, index, per-binding destructure
243
+ * fields) — ref-counted so nested loops compose. This is load-bearing
244
+ * for TWO things in the ERB adapter (more than the Mojo original, which
245
+ * only used it to guard const-inlining): it also decides the
246
+ * fundamental `v[:name]` vs bare-Ruby-local rendering choice in
247
+ * `ErbTopLevelEmitter.identifier` — see `emit-context.ts`'s
248
+ * `isLoopBoundName` docstring for why ERB's two-locals model needs this
249
+ * where Perl's uniform `$name` sigil does not.
250
+ */
251
+ private loopBoundNames: Map<string, number> = new Map()
252
+ /**
253
+ * Prop names whose value is `nil` in the template body when the caller
254
+ * omits them — so a bare-reference attribute should be dropped rather
255
+ * than rendered as `attr=""`. See `props/prop-classes.ts`'s
256
+ * `collectNullableOptionalProps` for the exact population criterion.
257
+ * Used by `elementAttrEmitter.emitExpression` to guard such an attribute
258
+ * with a Ruby nil-check (`<textarea>` omits `rows`), matching Hono's
259
+ * nullish-attribute omission.
260
+ */
261
+ private nullableOptionalProps: Set<string> = new Set()
262
+
263
+ constructor(options: ErbAdapterOptions = {}) {
264
+ super()
265
+ this.options = {
266
+ clientJsBasePath: options.clientJsBasePath ?? '/static/components/',
267
+ barefootJsPath: options.barefootJsPath ?? '/static/components/barefoot.js',
268
+ }
269
+ }
270
+
271
+ generate(ir: ComponentIR, options?: AdapterGenerateOptions): AdapterOutput {
272
+ this.componentName = ir.metadata.componentName
273
+ this.propsObjectName = ir.metadata.propsObjectName ?? null
274
+ // Enumerate inherited-attribute accesses for the props-object pattern
275
+ // (`function Checkbox(props: CheckboxProps)`) before deriving
276
+ // `nullableOptionalProps`, so a bare optional attribute like
277
+ // `id={props.id}` gets the Ruby nil-guard (Hono-style omission).
278
+ // Shared with the Go/Mojo adapters (single source of truth in
279
+ // `@barefootjs/jsx`).
280
+ augmentInheritedPropAccesses(ir)
281
+ this.propsParams = ir.metadata.propsParams.map(p => ({ name: p.name }))
282
+ // Per-compile prop classifications (see `props/prop-classes.ts`).
283
+ this.providerDataNames = collectProviderDataNames(ir)
284
+ this.booleanTypedProps = collectBooleanTypedProps(ir)
285
+ this.nullableOptionalProps = collectNullableOptionalProps(ir)
286
+ this.stringValueNames = collectStringValueNames(ir)
287
+ this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
288
+ this._searchParamsLocals = searchParamsLocalNames(ir.metadata)
289
+ this._loweringMatchers = prepareLoweringMatchers(ir.metadata)
290
+ this.localConstants = ir.metadata.localConstants ?? []
291
+ this.loopBoundNames.clear()
292
+ this.errors = []
293
+ this.childrenCaptureCounter = 0
294
+
295
+ // Mirror of the Go/Mojo adapters' BF103 check: when a child component
296
+ // referenced inside a loop body is imported from a sibling .tsx, the
297
+ // ERB adapter emits a `<%= bf.render_child(...) %>`-style cross-
298
+ // template call that resolves only if the user has compiled the
299
+ // sibling file and registered the resulting template alongside the
300
+ // parent. When that doesn't happen the failure is silent at build time
301
+ // and surfaces at request time — surface it loudly here so the user
302
+ // can act on it. Suppressed when the caller (e.g. the barefoot CLI)
303
+ // guarantees that all sibling templates are registered on the same
304
+ // template instance at render time.
305
+ if (!options?.siblingTemplatesRegistered) {
306
+ this.errors.push(...collectImportedLoopChildComponentErrors(ir, this.componentName))
307
+ }
308
+
309
+ this.rootScopeNodes = collectRootScopeNodes(ir.root)
310
+ const templateBody = ir.root.type === 'if-statement'
311
+ ? this.renderIfStatement(ir.root as IRIfStatement)
312
+ : this.renderNode(ir.root)
313
+
314
+ // Generate script registration
315
+ const scriptReg = options?.skipScriptRegistration
316
+ ? ''
317
+ : this.generateScriptRegistrations(ir, options?.scriptBaseName)
318
+
319
+ // SSR context consumers (`const x = useContext(Ctx)`): seed each local
320
+ // from the active provider value (or the `createContext` default) so
321
+ // the body's `v[:x]` resolves. The provider side pushes the value via
322
+ // `emitProvider`; here the consumer reads it.
323
+ const ctxSeed = generateContextConsumerSeed(ir)
324
+
325
+ // Prop/signal-derived memos that aren't statically evaluable (e.g.
326
+ // `createMemo(() => props.value * 10)`) have a `null` SSR default, so
327
+ // their `v[:x]` would render empty. Compute them in-template from the
328
+ // already-seeded prop/signal vars — mirroring Go's generated child
329
+ // constructor that evaluates the memo from the passed prop.
330
+ const memoSeed = generateDerivedMemoSeed(this.memoCtx, ir)
331
+
332
+ const template = `${scriptReg}${ctxSeed}${memoSeed}${templateBody}\n`
333
+
334
+ // Merge collected errors into IR errors
335
+ if (this.errors.length > 0) {
336
+ ir.errors.push(...this.errors)
337
+ }
338
+
339
+ // ERB templates have no JS-style imports / types / default-export
340
+ // sections. The `templatesPerComponent` mode emits one file per
341
+ // component using the raw `template` value; sections are populated for
342
+ // contract uniformity so the compiler never has to fall back to
343
+ // string-parsing the template.
344
+ const sections: TemplateSections = {
345
+ imports: '',
346
+ types: '',
347
+ component: template,
348
+ defaultExport: '',
349
+ }
350
+
351
+ return {
352
+ template,
353
+ sections,
354
+ extension: this.extension,
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Whether `expr` is a bare reference to a boolean-TYPED prop
360
+ * (`props.isActive` / destructured `isActive`) — used to route the
361
+ * binding through `bool_str` even though the expression itself is
362
+ * structurally opaque.
363
+ */
364
+ isBooleanTypedPropRef(expr: string): boolean {
365
+ let bare = expr.trim()
366
+ if (this.propsObjectName && bare.startsWith(`${this.propsObjectName}.`)) {
367
+ bare = bare.slice(this.propsObjectName.length + 1)
368
+ }
369
+ if (!/^[A-Za-z_$][\w$]*$/.test(bare)) return false
370
+ return this.booleanTypedProps.has(bare)
371
+ }
372
+
373
+ /**
374
+ * Whether an attribute-value expression should route through
375
+ * `bf.bool_str` (single source of truth for all three attribute-emission
376
+ * call sites that make this decision). Three witnesses (any one
377
+ * suffices): the JS source structurally evaluates to a boolean
378
+ * (`isBooleanResultExpr`), the attribute name is one of the ARIA
379
+ * true/false(/mixed) names (`isAriaBooleanAttr` — the expression itself
380
+ * may be opaque, e.g. `accepted()`), or the expression is a bare
381
+ * reference to a boolean-TYPED prop (`isBooleanTypedPropRef`).
382
+ *
383
+ * EXCEPT when the expression is already a top-level `String(...)` call
384
+ * (`isExplicitStringCall`): `convertExpressionToRuby` lowers that to
385
+ * `bf.string(...)`, which for a real boolean already returns the
386
+ * JS-correct `"true"`/`"false"` text — wrapping that STRING in
387
+ * `bf.bool_str` again is a bug (Ruby has no falsy-string, so
388
+ * `bf.bool_str("false")` always returns `"true"`), not a harmless
389
+ * no-op. See `isExplicitStringCall`'s docstring for the full
390
+ * Perl-vs-Ruby truthiness contrast.
391
+ */
392
+ private shouldWrapBoolStr(expr: string, name: string): boolean {
393
+ if (isExplicitStringCall(expr)) return false
394
+ return isBooleanResultExpr(expr) || isAriaBooleanAttr(name) || this.isBooleanTypedPropRef(expr)
395
+ }
396
+
397
+ /**
398
+ * Parse `cond ? value : undefined` (or `: null`), returning the
399
+ * condition/consequent source spans, else `null`. Used for the
400
+ * attribute-omission rule; mirrors the Mojo/Xslate adapters.
401
+ */
402
+ parseUndefinedAlternateTernary(
403
+ expr: string,
404
+ ): { condition: string; consequent: string } | null {
405
+ const parsed = parseExpression(expr.trim())
406
+ if (parsed?.kind !== 'conditional') return null
407
+ const alt = parsed.alternate
408
+ const isUndef =
409
+ (alt.kind === 'identifier' && (alt.name === 'undefined' || alt.name === 'null')) ||
410
+ (alt.kind === 'literal' && (alt.value === null || alt.value === undefined))
411
+ if (!isUndef) return null
412
+ // Serialise the parsed sub-expressions back to JS source rather than
413
+ // slicing `expr` text — `indexOf('?')` / `lastIndexOf(':')` would
414
+ // mis-split when the consequent itself contains `?` / `:` inside a
415
+ // string or nested ternary (`cond ? 'a:b' : undefined`).
416
+ return {
417
+ condition: exprToString(parsed.test),
418
+ consequent: exprToString(parsed.consequent),
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Inline a const (any scope) whose initializer is a pure numeric or
424
+ * quoted string literal (`const totalPages = 5`) — function-scope
425
+ * consts never reach the per-render vars Hash, so a bare `v[:totalPages]`
426
+ * would read nil.
427
+ */
428
+ private resolveLiteralConst(name: string): string | null {
429
+ if (this.loopBoundNames?.has?.(name)) return null
430
+ const c = (this.localConstants ?? []).find(lc => lc.name === name)
431
+ if (c?.value === undefined) return null
432
+ const v = c.value.trim()
433
+ if (/^-?\d+(\.\d+)?$/.test(v)) return v
434
+ const strLit = /^'([^'\\]*)'$/.exec(v) ?? /^"([^"\\]*)"$/.exec(v)
435
+ if (strLit) return rubyStringLiteral(strLit[1])
436
+ return null
437
+ }
438
+
439
+ private resolveStaticRecordLiteral(objectName: string, key: string): string | null {
440
+ if (this.loopBoundNames?.has?.(objectName)) return null
441
+ const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
442
+ if (!hit) return null
443
+ return hit.kind === 'number' ? hit.text : rubyStringLiteral(hit.text)
444
+ }
445
+
446
+ private resolveModuleStringConst(name: string): string | null {
447
+ // A loop body introduces block-param bindings that shadow a module
448
+ // const of the same name — never inline inside one.
449
+ if (this.loopBoundNames.has(name)) return null
450
+ const value = this.moduleStringConsts.get(name)
451
+ if (value === undefined) return null
452
+ return rubyStringLiteral(value)
453
+ }
454
+
455
+ /** Whether `name` currently names a loop-bound Ruby local. See
456
+ * `ErbEmitContext.isLoopBoundName`'s docstring. */
457
+ private isLoopBoundName(name: string): boolean {
458
+ return this.loopBoundNames.has(name)
459
+ }
460
+
461
+ // ===========================================================================
462
+ // Script Registration
463
+ // ===========================================================================
464
+
465
+ private generateScriptRegistrations(ir: ComponentIR, scriptBaseName?: string): string {
466
+ const hasInteractivity = hasClientInteractivity(ir)
467
+ if (!hasInteractivity) return ''
468
+
469
+ const name = scriptBaseName ?? ir.metadata.componentName
470
+ const runtimePath = this.options.barefootJsPath
471
+ const clientJsPath = `${this.options.clientJsBasePath}${name}.client.js`
472
+
473
+ const lines: string[] = []
474
+ lines.push(`<%- bf.register_script('${runtimePath}') -%>`)
475
+ lines.push(`<%- bf.register_script('${clientJsPath}') -%>`)
476
+ lines.push('')
477
+ return lines.join('\n')
478
+ }
479
+
480
+ // ===========================================================================
481
+ // Node Rendering
482
+ // ===========================================================================
483
+
484
+ /**
485
+ * Public entry point for node rendering. Delegates to the shared
486
+ * `IRNodeEmitter` dispatcher; per-kind logic lives in the
487
+ * `IRNodeEmitter` methods below.
488
+ */
489
+ renderNode(node: IRNode): string {
490
+ return emitIRNode<ErbRenderCtx>(node, this, {} as ErbRenderCtx)
491
+ }
492
+
493
+ // ===========================================================================
494
+ // IRNodeEmitter implementation (ERB / Ruby)
495
+ // ===========================================================================
496
+
497
+ emitElement(node: IRElement, _ctx: ErbRenderCtx, _emit: EmitIRNode<ErbRenderCtx>): string {
498
+ return this.renderElement(node)
499
+ }
500
+
501
+ emitText(node: IRText): string {
502
+ return node.value
503
+ }
504
+
505
+ emitExpression(node: IRExpression): string {
506
+ return this.renderExpression(node)
507
+ }
508
+
509
+ emitConditional(node: IRConditional, _ctx: ErbRenderCtx, _emit: EmitIRNode<ErbRenderCtx>): string {
510
+ return this.renderConditional(node)
511
+ }
512
+
513
+ emitLoop(node: IRLoop, _ctx: ErbRenderCtx, _emit: EmitIRNode<ErbRenderCtx>): string {
514
+ return this.renderLoop(node)
515
+ }
516
+
517
+ emitComponent(node: IRComponent, _ctx: ErbRenderCtx, _emit: EmitIRNode<ErbRenderCtx>): string {
518
+ return this.renderComponent(node)
519
+ }
520
+
521
+ emitFragment(node: IRFragment, _ctx: ErbRenderCtx, _emit: EmitIRNode<ErbRenderCtx>): string {
522
+ return this.renderFragment(node)
523
+ }
524
+
525
+ emitSlot(node: IRSlot): string {
526
+ return this.renderSlot(node)
527
+ }
528
+
529
+ emitIfStatement(node: IRIfStatement, _ctx: ErbRenderCtx, _emit: EmitIRNode<ErbRenderCtx>): string {
530
+ return this.renderIfStatement(node)
531
+ }
532
+
533
+ emitProvider(node: IRProvider, _ctx: ErbRenderCtx, _emit: EmitIRNode<ErbRenderCtx>): string {
534
+ // SSR context propagation: push the provider value onto the shared
535
+ // controller context stack, render the children (descendant
536
+ // `useContext` consumers read it via `bf.use_context`), then pop. The
537
+ // push/pop bracket the children in the same render so the value scopes
538
+ // exactly to the subtree — mirroring the client `provideContext`.
539
+ // Both calls are actions (no output) — trimmed non-outputting tags.
540
+ const value = this.providerValueRuby(node.valueProp)
541
+ const children = this.renderChildren(node.children)
542
+ const name = node.contextName
543
+ return (
544
+ `<%- bf.provide_context('${name}', ${value}) -%>` +
545
+ children +
546
+ `<%- bf.revoke_context('${name}') -%>`
547
+ )
548
+ }
549
+
550
+ /** Lower a `<Ctx.Provider value>` value prop to a Ruby expression. */
551
+ private providerValueRuby(valueProp: IRProvider['valueProp']): string {
552
+ const v = valueProp.value
553
+ if (v.kind === 'literal') {
554
+ return typeof v.value === 'string' ? rubyStringLiteral(v.value) : String(v.value)
555
+ }
556
+ if (v.kind === 'expression') {
557
+ const hashRuby = this.providerObjectLiteralRuby(v.expr)
558
+ if (hashRuby !== null) return hashRuby
559
+ return this.convertExpressionToRuby(v.expr)
560
+ }
561
+ if (v.kind === 'template') return this.convertTemplateLiteralPartsToRuby(v.parts)
562
+ // Out-of-shape value (spread / jsx-children) — render as nil rather
563
+ // than emit invalid Ruby; the consumer falls back to its default.
564
+ return 'nil'
565
+ }
566
+
567
+ /**
568
+ * Lower an object-literal provider value (`value={{ open: () => props.open
569
+ * ?? false, onOpenChange: … }}`) to a Ruby Hash. The SSR lowering is a
570
+ * per-member snapshot of what a consumer would READ during the same
571
+ * render:
572
+ *
573
+ * - zero-param expression-body arrows are getters — lower the body (the
574
+ * value is fixed for the render, so the call-time indirection drops out)
575
+ * - `on[A-Z]`-named members and function-shaped values are client-only
576
+ * behavior SSR never invokes — lower to `nil`
577
+ * - anything else lowers through the normal expression pipeline (so an
578
+ * unsupported getter body still refuses loudly with BF101)
579
+ *
580
+ * Keys keep their JS names verbatim (as symbol keys) so a consumer-side
581
+ * `ctx.open` access maps onto the same key. Returns `null` when the
582
+ * expression is not a plain object literal (spread / computed key) — the
583
+ * caller falls back to the whole-expression path, which refuses those
584
+ * shapes with BF101.
585
+ */
586
+ private providerObjectLiteralRuby(expr: string): string | null {
587
+ const members = parseProviderObjectLiteral(expr.trim())
588
+ if (members === null) return null
589
+ const entries = members.map(m => {
590
+ const key = rubySymbolKey(m.name)
591
+ if (m.kind === 'function' || /^on[A-Z]/.test(m.name)) return `${key} nil`
592
+ const src = m.kind === 'getter' ? m.body : m.expr
593
+ // A member whose value is a bare identifier that doesn't resolve to
594
+ // a prop/signal/memo is a client-only function reference (a local
595
+ // handler const like `scrollPrev`, or a signal setter like
596
+ // `setCanScrollPrev`) — no SSR value, and emitting `v[:scrollPrev]`
597
+ // would read an un-seeded vars-Hash key. Lower to nil.
598
+ if (this.isClientOnlyContextIdentifier(src)) return `${key} nil`
599
+ return `${key} ${this.convertExpressionToRuby(src)}`
600
+ })
601
+ return `{ ${entries.join(', ')} }`
602
+ }
603
+
604
+ /**
605
+ * True when `src` is a bare identifier that doesn't resolve to a
606
+ * prop/signal/memo or an SSR-inlinable module string const — i.e. a
607
+ * client-only function reference in a context value (a local handler
608
+ * const like `scrollPrev`, or a signal setter like `setCanScrollPrev`).
609
+ * See `providerDataNames`. Module-scope string consts (`carouselClasses`)
610
+ * ARE SSR-resolvable via `moduleStringConsts`, so they're excluded here.
611
+ */
612
+ private isClientOnlyContextIdentifier(src: string): boolean {
613
+ const t = src.trim()
614
+ if (!/^[A-Za-z_$][\w$]*$/.test(t)) return false
615
+ return !this.providerDataNames.has(t) && !this.moduleStringConsts.has(t)
616
+ }
617
+
618
+ emitAsync(node: IRAsync, _ctx: ErbRenderCtx, _emit: EmitIRNode<ErbRenderCtx>): string {
619
+ return this.renderAsync(node)
620
+ }
621
+
622
+ // ===========================================================================
623
+ // Element Rendering
624
+ // ===========================================================================
625
+
626
+ renderElement(element: IRElement): string {
627
+ const tag = element.tag
628
+ const attrs = this.renderAttributes(element)
629
+ const children = this.renderChildren(element.children)
630
+
631
+ let hydrationAttrs = ''
632
+ if (element.needsScope) {
633
+ hydrationAttrs += ` ${this.renderScopeMarker('')}`
634
+ }
635
+ // A root scope element carries `data-key` for a keyed loop item
636
+ // (emitted from the bf instance; the child renderer sets it from the
637
+ // JSX `key` prop), so a non-keyed render adds nothing. Mirrors Hono
638
+ // stamping data-key on each loop item's scope root, including
639
+ // early-return (if-statement) roots where every branch's top element
640
+ // qualifies.
641
+ if (this.rootScopeNodes.has(element) && element.needsScope) {
642
+ hydrationAttrs += ` <%= bf.data_key_attr %>`
643
+ }
644
+ if (element.slotId) {
645
+ hydrationAttrs += ` ${this.renderSlotMarker(element.slotId)}`
646
+ }
647
+ // Page-lifecycle boundary lowered from `<Region>` (spec/router.md). The
648
+ // id is a deterministic static string (`<file scope>:<index>`), so it
649
+ // emits as a plain literal attribute — no ERB tag.
650
+ if (element.regionId) {
651
+ hydrationAttrs += ` ${BF_REGION}="${element.regionId}"`
652
+ }
653
+
654
+ const voidElements = [
655
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
656
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
657
+ ]
658
+
659
+ if (voidElements.includes(tag.toLowerCase())) {
660
+ return `<${tag}${attrs}${hydrationAttrs}>`
661
+ }
662
+
663
+ return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
664
+ }
665
+
666
+ // ===========================================================================
667
+ // Expression Rendering
668
+ // ===========================================================================
669
+
670
+ renderExpression(expr: IRExpression): string {
671
+ if (expr.clientOnly) {
672
+ if (expr.slotId) {
673
+ return `<%= bf.comment("client:${expr.slotId}") %>`
674
+ }
675
+ return ''
676
+ }
677
+
678
+ const rubyExpr = this.convertExpressionToRuby(expr.expr)
679
+
680
+ // A bare read of the `children` prop (`{children}` / `{props.children}`,
681
+ // optionally `?? fallback`) is pre-rendered HTML — captured via the
682
+ // ERB output-buffer slice in `renderComponent` when THIS component was
683
+ // itself invoked as a child, or materialized by `Context#render_child`
684
+ // before the registered renderer runs. Mojo / Xslate don't need this
685
+ // check: their runtimes bless the captured value itself (Mojo::ByteStream
686
+ // / Kolon's `mark_raw`), so any later `<%= %>`/`<: :>` auto-escape
687
+ // dispatches on the VALUE and skips already-raw content transparently.
688
+ // Stdlib ERB's `<%=` has no such wrapper type (see
689
+ // `BarefootJS::Backend::Erb#mark_raw`'s docstring — "there is nothing to
690
+ // opt out of"), so the ADAPTER must decide raw-vs-escaped at this call
691
+ // site instead: every other raw-HTML position (component forwarding,
692
+ // spread_attrs, hydration markers) already routes around `bf.h`
693
+ // structurally; a component reading its OWN `children` back as a text
694
+ // expression was the one position still falling through the generic
695
+ // escape-everything path. Emitting the same `bf.h(...)` wrap here would
696
+ // double-encode markup the runtime already rendered.
697
+ const wrapped = this.isChildrenValueExpr(expr) ? rubyExpr : `bf.h(${rubyExpr})`
698
+
699
+ if (expr.slotId) {
700
+ return `<%= bf.text_start("${expr.slotId}") %><%= ${wrapped} %><%= bf.text_end %>`
701
+ }
702
+
703
+ return `<%= ${wrapped} %>`
704
+ }
705
+
706
+ /**
707
+ * True when `expr` is a structural read of the `children` prop: a bare
708
+ * `children` identifier, `props.children` / `<propsObjectName>.children`,
709
+ * or either of those on the left of a `?? fallback`. Prefers the IR's
710
+ * already-parsed `expr.parsed` tree (attached once during IR construction)
711
+ * and falls back to parsing `expr.expr` only when that's absent — never a
712
+ * regex/string scan of the source text.
713
+ */
714
+ private isChildrenValueExpr(expr: IRExpression): boolean {
715
+ const parsed = expr.parsed ?? parseExpression(expr.expr.trim()) ?? undefined
716
+ return this.isChildrenReferenceParsedExpr(parsed)
717
+ }
718
+
719
+ private isChildrenReferenceParsedExpr(parsed: ParsedExpr | undefined): boolean {
720
+ if (!parsed) return false
721
+ if (parsed.kind === 'logical' && parsed.op === '??') {
722
+ return this.isChildrenReferenceParsedExpr(parsed.left)
723
+ }
724
+ if (parsed.kind === 'identifier') return parsed.name === 'children'
725
+ if (parsed.kind === 'member' && !parsed.computed && parsed.property === 'children') {
726
+ return (
727
+ parsed.object.kind === 'identifier' &&
728
+ (parsed.object.name === 'props' ||
729
+ (this.propsObjectName !== null && parsed.object.name === this.propsObjectName))
730
+ )
731
+ }
732
+ return false
733
+ }
734
+
735
+ // ===========================================================================
736
+ // Conditional Rendering
737
+ // ===========================================================================
738
+
739
+ renderConditional(cond: IRConditional): string {
740
+ if (cond.clientOnly && cond.slotId) {
741
+ return `<%= bf.comment("cond-start:${cond.slotId}") %><%= bf.comment("cond-end:${cond.slotId}") %>`
742
+ }
743
+
744
+ const condition = this.convertExpressionToRuby(cond.condition)
745
+ const whenTrue = this.renderNode(cond.whenTrue)
746
+ const whenFalse = this.renderNodeOrNull(cond.whenFalse)
747
+
748
+ // When slotId is present, add bf-c marker.
749
+ // Use comment markers for fragments (multiple sibling elements), attribute for single elements.
750
+ const isFragmentBranch = cond.whenTrue.type === 'fragment' || cond.whenFalse.type === 'fragment'
751
+ const useCommentMarkers = cond.slotId && isFragmentBranch
752
+
753
+ let markedTrue = whenTrue
754
+ let markedFalse = whenFalse
755
+ if (cond.slotId && !useCommentMarkers) {
756
+ markedTrue = this.addCondMarkerToFirstElement(whenTrue, cond.slotId)
757
+ markedFalse = whenFalse ? this.addCondMarkerToFirstElement(whenFalse, cond.slotId) : whenFalse
758
+ }
759
+
760
+ let result: string
761
+ if (useCommentMarkers) {
762
+ // Fragment branches: use comment markers
763
+ const inner = whenFalse
764
+ ? `\n<%- if bf.truthy?(${condition}) -%>\n${whenTrue}\n<%- else -%>\n${whenFalse}\n<%- end -%>\n`
765
+ : `\n<%- if bf.truthy?(${condition}) -%>\n${whenTrue}\n<%- end -%>\n`
766
+ result = `<%= bf.comment("cond-start:${cond.slotId}") %>${inner}<%= bf.comment("cond-end:${cond.slotId}") %>`
767
+ } else if (markedFalse) {
768
+ result = `\n<%- if bf.truthy?(${condition}) -%>\n${markedTrue}\n<%- else -%>\n${markedFalse}\n<%- end -%>\n`
769
+ } else if (cond.slotId) {
770
+ // Conditional with no else: wrap with comment markers for client hydration
771
+ result = `<%= bf.comment("cond-start:${cond.slotId}") %>\n<%- if bf.truthy?(${condition}) -%>\n${whenTrue}\n<%- end -%>\n<%= bf.comment("cond-end:${cond.slotId}") %>`
772
+ } else {
773
+ result = `\n<%- if bf.truthy?(${condition}) -%>\n${whenTrue}\n<%- end -%>\n`
774
+ }
775
+
776
+ return result
777
+ }
778
+
779
+ private renderNodeOrNull(node: IRNode): string | null {
780
+ if (node.type === 'expression' && (node.expr === 'null' || node.expr === 'undefined')) {
781
+ return null
782
+ }
783
+ return this.renderNode(node)
784
+ }
785
+
786
+ /**
787
+ * Add bf-c attribute to the first HTML element in a branch. If no element
788
+ * found, wrap with comment markers. Operates on already-emitted ERB
789
+ * template TEXT (not JS/TS source), matching the Mojo/Go adapters'
790
+ * identical marker-injection precedent.
791
+ */
792
+ private addCondMarkerToFirstElement(content: string, condId: string): string {
793
+ const match = content.match(/^(<\w+)([\s>])/)
794
+ if (match) {
795
+ return content.replace(/^(<\w+)([\s>])/, `$1 ${BF_COND}="${condId}"$2`)
796
+ }
797
+ // Fall back to comment markers for non-element content
798
+ return `<%= bf.comment("cond-start:${condId}") %>${content}<%= bf.comment("cond-end:${condId}") %>`
799
+ }
800
+
801
+ // ===========================================================================
802
+ // Loop Rendering
803
+ // ===========================================================================
804
+
805
+ renderLoop(loop: IRLoop): string {
806
+ // clientOnly loops must not render items at SSR time, but must still
807
+ // emit the `loop:`/`/loop:` boundary marker pair (Hono and Go parity)
808
+ // so the client runtime's mapArray() can locate the insertion anchor
809
+ // when hydrating the array. The marker id disambiguates sibling
810
+ // `.map()` calls under the same parent.
811
+ if (loop.clientOnly) {
812
+ return `<%= bf.comment("loop:${loop.markerId}") %><%= bf.comment("/loop:${loop.markerId}") %>`
813
+ }
814
+
815
+ // An array/object-destructure loop param (`([emoji, users]) => ...` or
816
+ // `({ name, age }) => ...`) lowers to invalid Ruby block-param syntax in
817
+ // general — Ruby has no destructuring-assignment analog for an arbitrary
818
+ // nested pattern in a single statement. `isLowerableLoopDestructure`
819
+ // (#2087) instead admits any FIXED-binding shape — single field, nested
820
+ // field, array-index, any depth/mix (`{ user: { name } }`, `([k, v])`,
821
+ // `{ cells: [head] }`) — by walking the binding's structured `segments`
822
+ // path into a chained Ruby accessor (`__bf_item[:user][:name]`), plus
823
+ // array-rest (`[first, ...tail]`, native `bf.slice`) and object-rest
824
+ // (`{ id, ...rest }`, native `Hash#except`) whose every use is a member
825
+ // read (`rest.flag`) or a `{...rest}` spread onto an intrinsic element.
826
+ // Bare-value rest uses, a spread onto a component/provider, and
827
+ // `.filter().map(destructure)` still have no Ruby scalar form → BF104.
828
+ const destructure = !!(loop.paramBindings && loop.paramBindings.length > 0)
829
+ const supportableDestructure = destructure && isLowerableLoopDestructure(loop)
830
+ if (destructure && !supportableDestructure) {
831
+ this.errors.push({
832
+ code: 'BF104',
833
+ severity: 'error',
834
+ message: `Loop callback uses an array/object destructure pattern (\`${loop.param}\`) that the ERB adapter cannot lower — the rest binding is used in a way (bare value, or spread onto a component) that has no native Ruby accessor form.`,
835
+ loc: loop.loc ?? { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
836
+ suggestion: {
837
+ message:
838
+ `Options:\n` +
839
+ ` 1. Read the rest binding as a member access (\`rest.field\`) or spread it onto an intrinsic element (\`<li {...rest}>\`) instead of using it as a bare value.\n` +
840
+ ` 2. Mark the loop position as @client-only so the destructure runs in JS on the client.\n` +
841
+ ` 3. Move the loop into a primitive that the adapter registers explicitly.`,
842
+ },
843
+ })
844
+ }
845
+
846
+ // Loop array is a bare identifier naming a component-scope (non-module)
847
+ // `const` the adapter has no way to compute at SSR render time. Only a
848
+ // pure-literal const (`resolveLiteralConst`) or a module-scope
849
+ // pure-string const (`resolveModuleStringConst`) is ever inlined —
850
+ // anything else (`const entries = Object.entries(props.x).filter(...)`)
851
+ // falls through to the generic `v[:entries]` vars-Hash read, which is
852
+ // never seeded (the ERB test/production vars-seed only covers props,
853
+ // signals, and memos — see `test-render.ts::buildRubyProps`) and raises
854
+ // a Ruby `NoMethodError` on `.length` at render time. This is a
855
+ // pre-existing, orthogonal gap surfaced by #2087 widening the loop
856
+ // destructure gate (`isLowerableLoopDestructure` now admits the
857
+ // array-index destructure `static-array-from-props(-with-component)`
858
+ // use, which used to be hidden behind the old destructure BF104) — it
859
+ // reproduces identically with a non-destructured param, so it is NOT a
860
+ // destructure-lowering limitation. Surface BF101 honestly instead of
861
+ // emitting a loop bound that silently crashes / renders empty.
862
+ if (loop.arrayParsed?.kind === 'identifier') {
863
+ const arrayName = loop.arrayParsed.name
864
+ const isUnresolvableLocalConst =
865
+ !this.loopBoundNames.has(arrayName) &&
866
+ this.resolveModuleStringConst(arrayName) === null &&
867
+ this.resolveLiteralConst(arrayName) === null &&
868
+ this.localConstants.some(c => c.name === arrayName && !c.isModule)
869
+ if (isUnresolvableLocalConst) {
870
+ this._recordExprBF101(
871
+ `Loop array \`${arrayName}\` is a component-scope const computed from a runtime expression the ERB adapter cannot evaluate at SSR render time.`,
872
+ `Options:\n1. Inline the array expression directly in the .map() call instead of a preceding const.\n2. Mark the loop position as @client-only so the array materialises on the client.\n3. Precompute the value server-side and pass it in as a prop.`,
873
+ )
874
+ }
875
+ }
876
+
877
+ const rawArray = this.convertExpressionToRuby(loop.array)
878
+ // Apply sort if present: hoist the (possibly sorted) array into a Ruby
879
+ // local BEFORE the index loop, so both the loop bound and the per-item
880
+ // lookup reference the same materialised array — otherwise a sort
881
+ // helper call spliced into both spots would run twice per render.
882
+ let sortedHoist: string | null = null
883
+ let array = rawArray
884
+ if (loop.sortComparator) {
885
+ sortedHoist = `bf_iter_${rubyIdentifierFromMarkerId(loop.markerId)}`
886
+ array = sortedHoist
887
+ }
888
+ const param = loop.param
889
+ // `.keys().map(k => ...)` — the callback param is the index. Use it as
890
+ // the loop's index local and skip the per-item value assignment.
891
+ const indexVar = loop.iterationShape === 'keys'
892
+ ? rubyLocal(param)
893
+ : rubyLocal(loop.index ?? '_i')
894
+ // Names this loop binds in body scope. Guard module-const inlining (and,
895
+ // in ERB, the fundamental v[:name]-vs-local rendering choice) for the
896
+ // whole body (children + key + filter) so a same-named loop variable
897
+ // isn't replaced by the const literal / a vars-Hash read. Ref-counted
898
+ // for nested loops; released after the body lines are assembled below.
899
+ const loopBound = loop.iterationShape === 'keys'
900
+ ? [param]
901
+ : supportableDestructure
902
+ ? ['__bf_item', ...(loop.paramBindings ?? []).map(b => b.name), loop.index ?? '_i']
903
+ : [param, loop.index ?? '_i']
904
+ for (const n of loopBound) {
905
+ this.loopBoundNames.set(n, (this.loopBoundNames.get(n) ?? 0) + 1)
906
+ }
907
+ const prevInLoop = this.inLoop
908
+ this.inLoop = true
909
+ const renderedChildren = this.renderChildren(loop.children)
910
+ this.inLoop = prevInLoop
911
+
912
+ // Whole-item conditional: prepend an always-present
913
+ // `<!--bf-loop-i:KEY-->` anchor before each item's (possibly empty)
914
+ // conditional content so the client's `mapArrayAnchored` can hydrate
915
+ // every SSR-rendered item by its anchor. `bf.comment` prepends `bf-`,
916
+ // so `"loop-i:" + KEY` yields `<!--bf-loop-i:KEY-->`. `bf.string` keeps
917
+ // the concatenation valid when KEY isn't already a Ruby String.
918
+ const children =
919
+ loop.bodyIsItemConditional && loop.key
920
+ ? `<%= bf.comment("loop-i:" + bf.string(${this.convertExpressionToRuby(loop.key)})) %>\n${renderedChildren}`
921
+ : renderedChildren
922
+
923
+ const lines: string[] = []
924
+ // Scoped per-call-site marker so sibling `.map()`s under the same
925
+ // parent each get their own reconciliation range.
926
+ lines.push(`<%= bf.comment("loop:${loop.markerId}") %>`)
927
+ if (sortedHoist && loop.sortComparator) {
928
+ // Evaluator-first: serialize the comparator + emit `bf.sort_eval`;
929
+ // fall back to the structured `bf.sort` for a comparator the
930
+ // evaluator can't model (e.g. `localeCompare`).
931
+ //
932
+ // The hoisted sort runs OUTSIDE this loop, so this loop's bound names
933
+ // must not shadow the comparator's captured free vars while emitting
934
+ // the env — otherwise a captured var that happens to share a
935
+ // loop-param name is blocked from inlining its module const / from
936
+ // reading `v[:name]` and instead resolves to the (out-of-scope) loop
937
+ // local. Drop this loop's bound names for the sort emit, then
938
+ // restore (a nested loop's outer bindings, ref-counted, stay in effect).
939
+ for (const n of loopBound) {
940
+ const c = (this.loopBoundNames.get(n) ?? 1) - 1
941
+ if (c <= 0) this.loopBoundNames.delete(n)
942
+ else this.loopBoundNames.set(n, c)
943
+ }
944
+ const sortEmit = (e: ParsedExpr) => this.convertExpressionToRuby('', e)
945
+ const sortArrow = loop.sortComparator.arrow
946
+ let sorted: string | null = null
947
+ if (sortArrow.kind === 'arrow') {
948
+ sorted = renderSortEval(rawArray, sortArrow.body, sortArrow.params, sortEmit)
949
+ }
950
+ if (sorted === null) {
951
+ const structured = sortComparatorFromArrow(sortArrow)
952
+ if (structured !== null) sorted = renderSortMethod(rawArray, structured)
953
+ }
954
+ if (sorted === null) {
955
+ // Neither the evaluator nor the structured fallback can model this
956
+ // comparator — record BF101 and fall through with the unsorted
957
+ // array so the hoist line stays syntactically valid.
958
+ this._recordExprBF101(
959
+ `.sort(...) loop comparator is not lowerable to a template sort`,
960
+ `Pre-sort the array in the route handler, or mark the loop @client-only.`,
961
+ )
962
+ sorted = rawArray
963
+ }
964
+ for (const n of loopBound) {
965
+ this.loopBoundNames.set(n, (this.loopBoundNames.get(n) ?? 0) + 1)
966
+ }
967
+ lines.push(`<%- ${sortedHoist} = ${sorted} -%>`)
968
+ }
969
+ lines.push(`<%- (0...${array}.length).each do |${indexVar}| -%>`)
970
+ if (loop.iterationShape !== 'keys') {
971
+ if (supportableDestructure) {
972
+ // Per-item local + one local per binding, built off the binding's
973
+ // structured `segments` path (never `b.path` — repo rule: no
974
+ // string-parsing of a JS-shaped accessor). See `rubyAccessorFromSegments`.
975
+ lines.push(`<%- __bf_item = ${array}[${indexVar}] -%>`)
976
+ for (const b of loop.paramBindings ?? []) {
977
+ // Fixed bindings: `segments` is the FULL accessor path from the
978
+ // item to this binding. Rest bindings: `segments` is the PARENT
979
+ // prefix (possibly empty, at the loop root) — the accessor below
980
+ // is the rest's parent object/array, not the rest binding itself.
981
+ const accessor = rubyAccessorFromSegments('__bf_item', b.segments ?? [])
982
+ if (b.rest?.kind === 'array') {
983
+ // Native Ruby range-slice has different past-end nil semantics
984
+ // than JS `Array.prototype.slice` (`arr[4..]` is `nil` where
985
+ // `arr.slice(4)` is `[]`) — route through the runtime's `slice`
986
+ // helper (barefoot_js.rb) so both edge cases (`from` at or past
987
+ // length) return `[]` like JS, matching the Go/Perl ports.
988
+ lines.push(`<%- ${rubyLocal(b.name)} = bf.slice(${accessor}, ${b.rest.from}) -%>`)
989
+ } else if (b.rest?.kind === 'object') {
990
+ // A TRUE residual Hash (not an alias of the parent) via native
991
+ // `Hash#except` (Ruby >= 3.0; CI pins 3.3) — so a member read
992
+ // (`rest.flag` → `rest[:flag]`, `ErbTopLevelEmitter#member`) and
993
+ // the existing `{...rest}` spread emit (`bf.spread_attrs`) both
994
+ // see only the non-destructured keys, same as the Hono/CSR IIFE.
995
+ // Symbol keys match the vars-Hash convention: every item Hash is
996
+ // built by `JSON.parse(..., symbolize_names: true)`.
997
+ const excludeSyms = b.rest.exclude.map(k => rubySymbolLiteral(k.key)).join(', ')
998
+ lines.push(`<%- ${rubyLocal(b.name)} = ${accessor}.except(${excludeSyms}) -%>`)
999
+ } else {
1000
+ lines.push(`<%- ${rubyLocal(b.name)} = ${accessor} -%>`)
1001
+ }
1002
+ }
1003
+ } else {
1004
+ lines.push(`<%- ${rubyLocal(param)} = ${array}[${indexVar}] -%>`)
1005
+ }
1006
+ }
1007
+
1008
+ // Handle filter().map() pattern by wrapping children in if-condition
1009
+ if (loop.filterPredicate) {
1010
+ let filterCond: string
1011
+ if (loop.filterPredicate.predicate) {
1012
+ // The loop's own (possibly destructure-adjusted) param name IS the
1013
+ // filter predicate's parameter for rendering purposes — pass it
1014
+ // straight through as the filter emitter's bound param. Ruby's
1015
+ // named-block-param model makes the Mojo original's regex
1016
+ // `$filterParam → $loopParam` rename unnecessary here: the emitter
1017
+ // just renders every reference to the predicate's own arrow
1018
+ // parameter AS `param` from the start.
1019
+ filterCond = this.renderRubyFilterExpr(loop.filterPredicate.predicate, param)
1020
+ } else {
1021
+ filterCond = 'true'
1022
+ }
1023
+ lines.push(`<%- if bf.truthy?(${filterCond}) -%>`)
1024
+ lines.push(children)
1025
+ lines.push(`<%- end -%>`)
1026
+ } else {
1027
+ lines.push(children)
1028
+ }
1029
+
1030
+ // Body fully rendered — release the loop-bound names.
1031
+ for (const n of loopBound) {
1032
+ const c = (this.loopBoundNames.get(n) ?? 1) - 1
1033
+ if (c <= 0) this.loopBoundNames.delete(n)
1034
+ else this.loopBoundNames.set(n, c)
1035
+ }
1036
+
1037
+ lines.push(`<%- end -%>`)
1038
+ lines.push(`<%= bf.comment("/loop:${loop.markerId}") %>`)
1039
+
1040
+ return lines.join('\n')
1041
+ }
1042
+
1043
+ // ===========================================================================
1044
+ // Component Rendering
1045
+ // ===========================================================================
1046
+
1047
+ /**
1048
+ * AttrValue lowering for component invocation props (ERB / Ruby Hash
1049
+ * literal form). Routed through the shared dispatcher so a new AttrValue
1050
+ * kind becomes a TS compile error here.
1051
+ *
1052
+ * `jsx-children` returns empty — children are captured via the ERB
1053
+ * output-buffer slice below, not threaded through the `render_child`
1054
+ * props Hash.
1055
+ */
1056
+ private readonly componentPropEmitter: AttrValueEmitter = {
1057
+ emitLiteral: (value, name) => `${rubySymbolKey(name)} ${rubyStringLiteral(String(value.value))}`,
1058
+ emitExpression: (value, name) => {
1059
+ // The IR producer collapses component-prop `template` kinds into
1060
+ // `expression` for client-runtime reasons but preserves the parsed
1061
+ // parts on `v.parts`. Prefer the structured form when available —
1062
+ // the bare-expression path can't handle `${MAP[KEY]}` shapes (the JS
1063
+ // object literal leaks into the Ruby template).
1064
+ if (value.parts) {
1065
+ return `${rubySymbolKey(name)} ${this.convertTemplateLiteralPartsToRuby(value.parts)}`
1066
+ }
1067
+ // Inline object-literal child prop (carousel's `opts={{ align: 'start' }}`):
1068
+ // lower to a Ruby Hash so the child can serialize it (`data-opts`),
1069
+ // instead of refusing the bare object with BF101. Read the
1070
+ // IR-carried structured `ParsedExpr` tree instead of re-parsing
1071
+ // `value.expr`; the lowering returns null for any non-object-literal
1072
+ // shape, so the common non-object case falls straight through to the
1073
+ // bare-expression path below.
1074
+ if (value.parsed) {
1075
+ const hashRuby = objectLiteralExprToRubyHash(this.spreadCtx, value.parsed)
1076
+ if (hashRuby !== null) return `${rubySymbolKey(name)} ${hashRuby}`
1077
+ }
1078
+ return `${rubySymbolKey(name)} ${this.convertExpressionToRuby(value.expr)}`
1079
+ },
1080
+ emitSpread: (value) => {
1081
+ // Ruby's `**hash` double-splat flattens a Hash's entries into a
1082
+ // surrounding Hash literal — the direct analog of Perl's `%{$props}`
1083
+ // deref, but native syntax rather than a deref workaround.
1084
+ const rubyExpr = this.convertExpressionToRuby(value.expr)
1085
+ return `**${rubyExpr}`
1086
+ },
1087
+ emitTemplate: (value, name) =>
1088
+ `${rubySymbolKey(name)} ${this.convertTemplateLiteralPartsToRuby(value.parts)}`,
1089
+ emitBooleanAttr: (_value, name) => `${rubySymbolKey(name)} true`,
1090
+ emitBooleanShorthand: (_value, name) => `${rubySymbolKey(name)} true`,
1091
+ // JSX children flow through the ERB buffer-slice capture below; they're
1092
+ // not part of the props Hash.
1093
+ emitJsxChildren: () => '',
1094
+ }
1095
+
1096
+ renderComponent(comp: IRComponent): string {
1097
+ const propParts: string[] = []
1098
+ for (const p of comp.props) {
1099
+ // Skip callback props (onXxx) and `ref` — both are client-only for
1100
+ // SSR (Hono renders neither; the client JS wires them at hydration).
1101
+ if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
1102
+ const lowered = emitAttrValue(p.value, this.componentPropEmitter, p.name)
1103
+ if (lowered) propParts.push(lowered)
1104
+ }
1105
+ // Pass slot ID so the child renderer can set correct scope ID for
1106
+ // hydration. Skip for loop children — they use ComponentName_random
1107
+ // pattern instead.
1108
+ if (comp.slotId && !this.inLoop) {
1109
+ propParts.push(`${rubySymbolKey('_bf_slot')} '${comp.slotId}'`)
1110
+ }
1111
+ const tplName = this.toTemplateName(comp.name)
1112
+ // Resolve the effective children: a nested `<Box>…</Box>` populates
1113
+ // `comp.children`; an attribute-form `<Box children={<jsx/>} />`
1114
+ // lands in a `jsx-children` AttrValue on the corresponding prop. The
1115
+ // parent's scope marker is already attached to each hoisted root by
1116
+ // the IR collector (`needsScope: true`), so the adapter just needs to
1117
+ // render the IR through the same children pipeline as the nested form.
1118
+ const effectiveChildren: IRNode[] = comp.children.length > 0
1119
+ ? comp.children
1120
+ : resolveJsxChildrenProp(comp.props)
1121
+ if (effectiveChildren.length > 0) {
1122
+ // Forward JSX children via an ERB output-buffer slice so dynamic
1123
+ // segments inside the children (signals, conditionals) get evaluated
1124
+ // in the parent's template scope before reaching the child renderer.
1125
+ // Mark the buffer position, render the children inline (they append
1126
+ // straight to `_erbout`, the same buffer the surrounding template
1127
+ // uses — `ERB.new(src, eoutvar: '_erbout')`), then slice everything
1128
+ // appended since the mark back OUT of the buffer into a local. No
1129
+ // literal text (not even a newline) may sit between the mark/slice
1130
+ // tags and the children — the slice captures byte-for-byte whatever
1131
+ // `_erbout` gained in between, so any interposed template text would
1132
+ // leak into (or out of) the capture.
1133
+ const prevInLoop = this.inLoop
1134
+ this.inLoop = false
1135
+ const childrenBody = this.renderChildren(effectiveChildren)
1136
+ this.inLoop = prevInLoop
1137
+ const suffix = comp.slotId ?? `c${this.childrenCaptureCounter++}`
1138
+ const lenVar = `__bf_len_${suffix}`
1139
+ const capVar = `__bf_children_${suffix}`
1140
+ const propsHash = `{ ${[...propParts, `children: ${capVar}`].join(', ')} }`
1141
+ return `<% ${lenVar} = _erbout.length %>${childrenBody}<% ${capVar} = _erbout.slice!(${lenVar}..) %><%= bf.render_child('${tplName}', ${propsHash}) %>`
1142
+ }
1143
+ const propsHash = propParts.length > 0 ? `{ ${propParts.join(', ')} }` : '{}'
1144
+ return `<%= bf.render_child('${tplName}', ${propsHash}) %>`
1145
+ }
1146
+
1147
+ private childrenCaptureCounter = 0
1148
+
1149
+ private toTemplateName(componentName: string): string {
1150
+ // Convert PascalCase to snake_case for ERB template naming
1151
+ return componentName
1152
+ .replace(/([A-Z])/g, '_$1')
1153
+ .toLowerCase()
1154
+ .replace(/^_/, '')
1155
+ }
1156
+
1157
+ // ===========================================================================
1158
+ // If-Statement (Conditional Return) Rendering
1159
+ // ===========================================================================
1160
+
1161
+ private renderIfStatement(ifStmt: IRIfStatement): string {
1162
+ const condition = this.convertExpressionToRuby(ifStmt.condition)
1163
+ const consequent = ifStmt.consequent.type === 'if-statement'
1164
+ ? this.renderIfStatement(ifStmt.consequent as IRIfStatement)
1165
+ : this.renderNode(ifStmt.consequent)
1166
+ let result = `<%- if bf.truthy?(${condition}) -%>\n${consequent}\n`
1167
+
1168
+ if (ifStmt.alternate) {
1169
+ if (ifStmt.alternate.type === 'if-statement') {
1170
+ const altResult = this.renderIfStatement(ifStmt.alternate as IRIfStatement)
1171
+ // Replace the leading "<%- if" with "<%- elsif" — operating on
1172
+ // already-emitted ERB text we constructed above (the exact literal
1173
+ // prefix every `renderIfStatement` call emits), not on JS/TS source.
1174
+ result += altResult.replace(/^<%- if /, '<%- elsif ')
1175
+ } else {
1176
+ const alternate = this.renderNode(ifStmt.alternate)
1177
+ result += `<%- else -%>\n${alternate}\n`
1178
+ }
1179
+ }
1180
+
1181
+ result += `<%- end -%>`
1182
+ return result
1183
+ }
1184
+
1185
+ // ===========================================================================
1186
+ // Fragment & Slot Rendering
1187
+ // ===========================================================================
1188
+
1189
+ private renderFragment(fragment: IRFragment): string {
1190
+ const children = this.renderChildren(fragment.children)
1191
+ if (fragment.needsScopeComment) {
1192
+ return `<%= bf.scope_comment %>${children}`
1193
+ }
1194
+ return children
1195
+ }
1196
+
1197
+ private renderSlot(_slot: IRSlot): string {
1198
+ // ERB's native layout-content mechanism is `yield` (the partial/layout
1199
+ // convention closest to Mojo's implicit `content` helper).
1200
+ return `<%= yield %>`
1201
+ }
1202
+
1203
+ override renderAsync(node: IRAsync): string {
1204
+ const fallback = this.renderNode(node.fallback)
1205
+ const children = this.renderChildren(node.children)
1206
+ // Use the BarefootJS runtime's streaming helpers for OOS streaming.
1207
+ // bf.async_boundary() wraps the fallback in a <div bf-async="aX">
1208
+ // placeholder. The resolved content is rendered below for
1209
+ // non-streaming fallback; in streaming mode the backend's write_chunk
1210
+ // delivers it as a resolve chunk.
1211
+ //
1212
+ // The fallback is captured via the same output-buffer slice
1213
+ // `renderComponent` uses for forwarded children — see that method's
1214
+ // docstring for why no literal text may sit between the mark/slice
1215
+ // tags and the fallback markup.
1216
+ const lenVar = `__bf_alen_${node.id}`
1217
+ const fallbackVar = `bf_async_fallback_${node.id}`
1218
+ return `<% ${lenVar} = _erbout.length %>${fallback}<% ${fallbackVar} = _erbout.slice!(${lenVar}..) %><%= bf.async_boundary('${node.id}', ${fallbackVar}) %>\n${children}`
1219
+ }
1220
+
1221
+ // ===========================================================================
1222
+ // Attribute Rendering
1223
+ // ===========================================================================
1224
+
1225
+ /**
1226
+ * AttrValue lowering for intrinsic-element attributes (ERB template).
1227
+ * Routed through the shared dispatcher.
1228
+ */
1229
+ private readonly elementAttrEmitter: AttrValueEmitter = {
1230
+ emitLiteral: (value, name) => `${name}="${value.value}"`,
1231
+ emitExpression: (value, name) => {
1232
+ // `style={{ … }}` object literal → a CSS string with dynamic values
1233
+ // interpolated, instead of refusing the bare object with BF101.
1234
+ if (name === 'style') {
1235
+ const css = this.tryLowerStyleObject(value.expr)
1236
+ if (css !== null) return `style="${css}"`
1237
+ }
1238
+ // Refuse shapes that have no idiomatic ERB template representation.
1239
+ // Tagged-template-literal call expressions (`cn\`base \${tone()}\``)
1240
+ // have no idiomatic ERB template form; the Go adapter raises BF101
1241
+ // here via `convertExpressionToGo` + `isSupported`. Lift the same
1242
+ // gate so the user gets a clear diagnostic instead of broken output.
1243
+ if (this.refuseUnsupportedAttrExpression(value.expr, name)) {
1244
+ return ''
1245
+ }
1246
+ // Hono-style nullish-attribute omission (#textarea rows): when the
1247
+ // attribute value is a BARE reference to an optional, no-default
1248
+ // prop (which reads `nil` when the caller omits it), guard the
1249
+ // attribute with a Ruby nil-check so it DROPS rather than rendering
1250
+ // `attr=""`. The guarded body reuses the exact normal emission, so
1251
+ // value escaping is unchanged; only the presence is conditional.
1252
+ const bareId = value.expr.trim()
1253
+ // Normalize a props-object access (`props.id`) to its bare prop name
1254
+ // (`id`) so the nullable-optional set — keyed by bare name — matches
1255
+ // the SolidJS props-object pattern, not just destructured params
1256
+ // (#checkbox `id={props.id}`).
1257
+ const normalizedBareId =
1258
+ this.propsObjectName && bareId.startsWith(`${this.propsObjectName}.`)
1259
+ ? bareId.slice(this.propsObjectName.length + 1)
1260
+ : bareId
1261
+ if (
1262
+ !isBooleanAttr(name) &&
1263
+ !value.presenceOrUndefined &&
1264
+ /^[A-Za-z_$][\w$]*$/.test(normalizedBareId) &&
1265
+ this.nullableOptionalProps.has(normalizedBareId)
1266
+ ) {
1267
+ const ruby = this.convertExpressionToRuby(value.expr)
1268
+ const body =
1269
+ this.shouldWrapBoolStr(value.expr, name)
1270
+ ? `${name}="<%= bf.h(bf.bool_str(${ruby})) %>"`
1271
+ : `${name}="<%= bf.h(${ruby}) %>"`
1272
+ return `<% if !(${ruby}).nil? %>${body}<% end %>`
1273
+ }
1274
+ if (isBooleanAttr(name)) {
1275
+ // Boolean attributes: render conditionally (present or absent).
1276
+ return `<%= bf.truthy?(${this.convertExpressionToRuby(value.expr)}) ? '${name}' : '' %>`
1277
+ }
1278
+ if (value.presenceOrUndefined) {
1279
+ // `attr={expr || undefined}` on a NON-boolean attribute: Hono
1280
+ // renders the attr with its stringified value when truthy and
1281
+ // omits it otherwise (`aria-disabled={isDisabled() || undefined}`
1282
+ // → `aria-disabled="true"`), so bare presence would diverge. Route
1283
+ // through `bool_str` when the name/shape witnesses a boolean
1284
+ // value, same as the unconditional path below. Bind to a temp
1285
+ // first so the expression evaluates once, not in both the guard
1286
+ // and the value.
1287
+ const ruby = this.convertExpressionToRuby(value.expr)
1288
+ const tmp = `__bf_pu${this.presenceVarCounter++}`
1289
+ const body =
1290
+ this.shouldWrapBoolStr(value.expr, name)
1291
+ ? `${name}="<%= bf.h(bf.bool_str(${tmp})) %>"`
1292
+ : `${name}="<%= bf.h(${tmp}) %>"`
1293
+ return `<% ${tmp} = ${ruby}; if bf.truthy?(${tmp}) %>${body}<% end %>`
1294
+ }
1295
+ // Boolean-result handling: see `shouldWrapBoolStr`'s docstring for
1296
+ // the three wrap witnesses and the `String(...)`-call exception.
1297
+ //
1298
+ // `attr={cond ? value : undefined}` OMITS the attribute on the falsy
1299
+ // branch (Hono drops undefined-valued attributes) — wrap the whole
1300
+ // attribute in the condition instead of rendering `attr=""`.
1301
+ {
1302
+ const m = this.parseUndefinedAlternateTernary(value.expr)
1303
+ if (m) {
1304
+ const cond = this.convertExpressionToRuby(m.condition)
1305
+ const val = this.convertExpressionToRuby(m.consequent)
1306
+ return `<% if bf.truthy?(${cond}) %>${name}="<%= bf.h(${val}) %>"<% end %>`
1307
+ }
1308
+ }
1309
+ const ruby = this.convertExpressionToRuby(value.expr)
1310
+ if (this.shouldWrapBoolStr(value.expr, name)) {
1311
+ return `${name}="<%= bf.h(bf.bool_str(${ruby})) %>"`
1312
+ }
1313
+ return `${name}="<%= bf.h(${ruby}) %>"`
1314
+ },
1315
+ emitBooleanAttr: (_value, name) => name,
1316
+ emitTemplate: (value, name) =>
1317
+ `${name}="<%= bf.h(${this.convertTemplateLiteralPartsToRuby(value.parts)}) %>"`,
1318
+ // Spread attributes (`<div {...attrs()} />`) lower through the
1319
+ // `bf.spread_attrs` Ruby runtime helper, mirroring the Go adapter's
1320
+ // `bf_spread_attrs` and the JS `spreadAttrs` from
1321
+ // `@barefootjs/client/runtime`. The bag's source JS expression is
1322
+ // translated to a Ruby expression via `convertExpressionToRuby`
1323
+ // (e.g. `attrs()` → `v[:attrs]`, `props.bag` → `v[:bag]`); the helper
1324
+ // accepts a Hash and emits pre-escaped, sorted `key="value"` pairs.
1325
+ //
1326
+ // `IRAttribute.slotId` is set by the IR pass but the ERB adapter
1327
+ // ignores it — the slot field exists only for the Go adapter's
1328
+ // static-typed Props struct.
1329
+ emitSpread: (value) => {
1330
+ if (this.refuseUnsupportedAttrExpression(value.expr, '...')) {
1331
+ return ''
1332
+ }
1333
+ // SolidJS-style props identifier (`(props: P) { <el {...props}/> }`)
1334
+ // has no matching `v[:props]` entry in the ERB vars Hash — props
1335
+ // arrive as one vars-Hash key per `propsParams` entry, not as a
1336
+ // single nested Hash. Emit an inline Hash literal that enumerates
1337
+ // the analyzer-extracted props params so `bf.spread_attrs(...)` gets
1338
+ // a real Hash (matches the Go adapter's same-shape map-literal
1339
+ // path). For `restPropsName` and other identifier shapes, the
1340
+ // standard `convertExpressionToRuby` translation handles it (rest
1341
+ // binding name → `v[:<name>]` resolves against the Hash the caller /
1342
+ // harness placed under that key).
1343
+ const trimmed = value.expr.trim()
1344
+ if (this.propsObjectName && this.propsObjectName === trimmed) {
1345
+ const entries = this.propsParams.map(p =>
1346
+ `${rubySymbolKey(p.name)} v[${rubySymbolLiteral(p.name)}]`,
1347
+ )
1348
+ return `<%= bf.spread_attrs({${entries.join(', ')}}) %>`
1349
+ }
1350
+ // Conditional inline-object spread:
1351
+ // `{...(COND ? { 'aria-describedby': describedBy } : {})}`
1352
+ // Emit a Ruby inline ternary of Hashes — the falsy `{}` branch OMITS
1353
+ // the key (`bf.spread_attrs` does NOT filter empty strings, so we
1354
+ // cannot always-include it). Mirrors the Go adapter's IIFE-of-maps
1355
+ // lowering (#textarea).
1356
+ const ternaryHash = conditionalSpreadToRuby(this.spreadCtx, value.parsed)
1357
+ if (ternaryHash !== null) {
1358
+ return `<%= bf.spread_attrs(${ternaryHash}) %>`
1359
+ }
1360
+ // Function-scope local const holding a conditional inline-object
1361
+ // `const sizeAttrs = size ? {…} : {}` then `{...sizeAttrs}`
1362
+ // (#checkbox / icon). Resolve the bare identifier to its initializer
1363
+ // text and route through the same conditional-spread lowering. Only
1364
+ // function-scope (`!isModule`) consts whose value is NOT itself a
1365
+ // bare identifier (loop guard) are considered.
1366
+ if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) {
1367
+ const localConst = this.localConstants.find(
1368
+ c => c.name === trimmed && !c.isModule,
1369
+ )
1370
+ if (localConst?.value !== undefined) {
1371
+ const initTrimmed = localConst.value.trim()
1372
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(initTrimmed)) {
1373
+ const resolved = conditionalSpreadToRuby(
1374
+ this.spreadCtx,
1375
+ parseExpression(initTrimmed),
1376
+ )
1377
+ if (resolved !== null) {
1378
+ return `<%= bf.spread_attrs(${resolved}) %>`
1379
+ }
1380
+ }
1381
+ }
1382
+ }
1383
+ const rubyExpr = this.convertExpressionToRuby(value.expr)
1384
+ return `<%= bf.spread_attrs(${rubyExpr}) %>`
1385
+ },
1386
+ // Neither variant is legal on intrinsic elements.
1387
+ emitBooleanShorthand: () => '',
1388
+ emitJsxChildren: () => '',
1389
+ }
1390
+
1391
+ /**
1392
+ * Uniquifies the `presenceOrUndefined` temp binding (`__bf_puN`) so two
1393
+ * presence-folded attrs in one template don't collide.
1394
+ */
1395
+ private presenceVarCounter = 0
1396
+
1397
+ /**
1398
+ * Lower a `style={{ … }}` object literal to a CSS string with dynamic
1399
+ * values interpolated as ERB tags, e.g. `{ backgroundColor: color,
1400
+ * padding: '8px' }` → `background-color:<%= bf.h(v[:color]) %>;padding:8px`.
1401
+ * Returns null when the shape is unsupported or any value can't be
1402
+ * lowered (caller then falls through to the BF101 refusal).
1403
+ */
1404
+ private tryLowerStyleObject(expr: string): string | null {
1405
+ const entries = parseStyleObjectEntries(expr)
1406
+ if (!entries) return null
1407
+ for (const e of entries) {
1408
+ if (e.kind === 'expr' && !isSupported(parseExpression(e.expr)).supported) return null
1409
+ }
1410
+ // The static CSS key + literal value are inlined into a double-quoted
1411
+ // `style="..."` attribute as raw template text, so HTML-attr escape
1412
+ // them (a value like `'"'` would otherwise break the attribute /
1413
+ // inject markup). The dynamic arm's `<%= bf.h(...) %>` is explicitly
1414
+ // escaped (stdlib ERB doesn't auto-escape).
1415
+ return entries
1416
+ .map(e =>
1417
+ e.kind === 'literal'
1418
+ ? `${this.escapeAttrText(e.cssKey)}:${this.escapeAttrText(e.value)}`
1419
+ : `${this.escapeAttrText(e.cssKey)}:<%= bf.h(${this.convertExpressionToRuby(e.expr)}) %>`,
1420
+ )
1421
+ .join(';')
1422
+ }
1423
+
1424
+ /** HTML-attribute escape for static text inlined into a `"..."` attribute. */
1425
+ private escapeAttrText(s: string): string {
1426
+ return s
1427
+ .replace(/&/g, '&amp;')
1428
+ .replace(/"/g, '&quot;')
1429
+ .replace(/'/g, '&#39;')
1430
+ .replace(/</g, '&lt;')
1431
+ .replace(/>/g, '&gt;')
1432
+ }
1433
+
1434
+ private renderAttributes(element: IRElement): string {
1435
+ const parts: string[] = []
1436
+
1437
+ for (const attr of element.attrs) {
1438
+ // `/* @client */` attribute bindings are deferred to hydrate: the
1439
+ // client runtime sets/patches the attribute in a mount effect (the
1440
+ // CSR template omits it; ir-to-client-js emits the setAttribute
1441
+ // effect). Skip SSR emission so the server omits the attribute and
1442
+ // the unsupported-expression lowering is never reached for a
1443
+ // deferred predicate (no BF101 / BF102).
1444
+ if (attr.clientOnly) continue
1445
+ // Rewrite JSX special-prop names to their HTML-attribute
1446
+ // counterparts. `className` → `class`; `key` → `data-key` matches
1447
+ // the canonical Hono attribute name the client runtime reconciles
1448
+ // against. Hono SSR strips raw `key` via its JSX runtime; the ERB
1449
+ // template path has no such layer so the rewrite happens at
1450
+ // attribute-emit time.
1451
+ let attrName: string
1452
+ if (attr.name === 'className') attrName = 'class'
1453
+ else if (attr.name === 'key') attrName = 'data-key'
1454
+ else attrName = attr.name
1455
+ const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
1456
+ if (lowered) parts.push(lowered)
1457
+ }
1458
+
1459
+ return parts.length > 0 ? ' ' + parts.join(' ') : ''
1460
+ }
1461
+
1462
+ // ===========================================================================
1463
+ // Hydration Markers
1464
+ // ===========================================================================
1465
+
1466
+ renderScopeMarker(_instanceIdExpr: string): string {
1467
+ // bf-s is the addressable scope id. hydration_attrs adds bf-h / bf-m /
1468
+ // bf-r conditionally; props_attr adds bf-p when props are present.
1469
+ // These are all runtime-generated markup (raw helper output, not user
1470
+ // text), so no `bf.h` wrap — mirrors mojo's `<%==` raw-output tags.
1471
+ return `bf-s="<%= bf.scope_attr %>" <%= bf.hydration_attrs %> <%= bf.props_attr %>`
1472
+ }
1473
+
1474
+ renderSlotMarker(slotId: string): string {
1475
+ return `${BF_SLOT}="${slotId}"`
1476
+ }
1477
+
1478
+ renderCondMarker(condId: string): string {
1479
+ return `${BF_COND}="${condId}"`
1480
+ }
1481
+
1482
+ // ===========================================================================
1483
+ // Filter Predicate Rendering (ParsedExpr → Ruby)
1484
+ // ===========================================================================
1485
+
1486
+ /**
1487
+ * Convert a ParsedExpr AST to Ruby expression string for filter
1488
+ * predicates. Wraps the shared ParsedExpr dispatcher with an
1489
+ * `ErbFilterEmitter` carrying the predicate's loop param and any
1490
+ * block-body local var aliases.
1491
+ */
1492
+ private renderRubyFilterExpr(
1493
+ expr: ParsedExpr,
1494
+ param: string,
1495
+ localVarMap: Map<string, string> = new Map(),
1496
+ ): string {
1497
+ return emitParsedExpr(
1498
+ expr,
1499
+ new ErbFilterEmitter(
1500
+ param,
1501
+ localVarMap,
1502
+ n => this.isLoopBoundName(n),
1503
+ n => this._isStringValueName(n),
1504
+ (message, reason) => this._recordExprBF101(message, reason),
1505
+ ),
1506
+ )
1507
+ }
1508
+
1509
+ // ===========================================================================
1510
+ // Expression Conversion: JS → Ruby
1511
+ // ===========================================================================
1512
+
1513
+ private convertTemplateLiteralPartsToRuby(literalParts: IRTemplatePart[]): string {
1514
+ const parts: string[] = []
1515
+ for (const part of literalParts) {
1516
+ if (part.type === 'string') {
1517
+ // The IR producer may leave `${ident}` / `${_p.ident}`
1518
+ // interpolations in `string` parts when it can't statically inline
1519
+ // them (typically a destructured prop the caller will supply at
1520
+ // hydrate time, e.g. `${className}` in shadcn-style composition).
1521
+ // Substitute those to their Ruby variable form before quoting,
1522
+ // otherwise the single-quoted literal here passes the JS-shape
1523
+ // interpolation through verbatim into the rendered HTML.
1524
+ parts.push(this.substituteJsInterpolationsToRuby(part.value))
1525
+ } else if (part.type === 'ternary') {
1526
+ const cond = this.convertExpressionToRuby(part.condition)
1527
+ parts.push(`(bf.truthy?(${cond}) ? ${rubyStringLiteral(part.whenTrue)} : ${rubyStringLiteral(part.whenFalse)})`)
1528
+ } else if (part.type === 'lookup') {
1529
+ // `${MAP[KEY]}` against a Record<T, string> literal — emit a Ruby
1530
+ // Hash literal with an immediate symbol-key lookup, `|| ''`
1531
+ // guarded so a miss turns into an empty string, matching the
1532
+ // go-template adapter's "empty when no case matches" semantics.
1533
+ // Pass `key` through `convertExpressionToRuby` so its
1534
+ // top-level-identifier tail (`variant` → `v[:variant]`) and
1535
+ // existing `props.x` rule apply uniformly.
1536
+ const keyExpr = this.convertExpressionToRuby(part.key)
1537
+ const entries = Object.entries(part.cases)
1538
+ .map(([k, v]) => `${rubySymbolKey(k)} ${rubyStringLiteral(v)}`)
1539
+ .join(', ')
1540
+ parts.push(`({ ${entries} }[(${keyExpr}).to_sym] || '')`)
1541
+ }
1542
+ }
1543
+ // Join with Ruby string concatenation
1544
+ return parts.length === 1 ? parts[0] : `(${parts.join(' + ')})`
1545
+ }
1546
+
1547
+ /**
1548
+ * Translate `${EXPR}` interpolations in a static template-part string
1549
+ * into Ruby variable references and concatenate them with the
1550
+ * surrounding literal text. Used by `convertTemplateLiteralPartsToRuby`
1551
+ * when a `string` part still carries unresolved interpolations (e.g.
1552
+ * `${className}` from a destructured prop the IR analyzer couldn't
1553
+ * inline statically).
1554
+ */
1555
+ private substituteJsInterpolationsToRuby(s: string): string {
1556
+ const segments: string[] = []
1557
+ const re = /\$\{([^}]+)\}/g
1558
+ let lastIndex = 0
1559
+ let m: RegExpExecArray | null
1560
+ while ((m = re.exec(s)) !== null) {
1561
+ if (m.index > lastIndex) {
1562
+ segments.push(rubyStringLiteral(s.slice(lastIndex, m.index)))
1563
+ }
1564
+ segments.push(`bf.string(${this.convertExpressionToRuby(m[1].trim())})`)
1565
+ lastIndex = re.lastIndex
1566
+ }
1567
+ if (lastIndex < s.length) {
1568
+ segments.push(rubyStringLiteral(s.slice(lastIndex)))
1569
+ }
1570
+ if (segments.length === 0) return `''`
1571
+ return segments.length === 1 ? segments[0] : `(${segments.join(' + ')})`
1572
+ }
1573
+
1574
+ /**
1575
+ * Refuse JS expression shapes that have no idiomatic ERB template
1576
+ * representation. Currently catches:
1577
+ *
1578
+ * - Object literals (`style={{ background: bg(), color: fg() }}`):
1579
+ * the regex pipeline strips signal calls but leaves the surrounding
1580
+ * `{ k: v, ... }` syntax intact, producing invalid Ruby inside
1581
+ * `<%= ... %>`.
1582
+ * - Tagged-template-literal call expressions
1583
+ * (`className={cn\`base \${tone()}\`}`): regex translation
1584
+ * produces malformed Ruby with no callable target.
1585
+ *
1586
+ * Records `BF101` with the same shape the Go/Mojo adapters emit, so
1587
+ * cross-adapter diagnostics stay consistent. Returns `true` when the
1588
+ * shape was rejected (caller should drop the attribute / skip the emit).
1589
+ */
1590
+ private refuseUnsupportedAttrExpression(expr: string, attrName: string): boolean {
1591
+ let probe = expr.trim()
1592
+ while (probe.startsWith('(')) probe = probe.slice(1).trimStart()
1593
+ const startsAsObjectLiteral = probe.startsWith('{')
1594
+ const hasTaggedTemplate = /[A-Za-z_$][\w$]*\s*`/.test(probe)
1595
+ if (!startsAsObjectLiteral && !hasTaggedTemplate) return false
1596
+ const parsed = parseExpression(expr.trim())
1597
+ const support = isSupported(parsed)
1598
+ if (parsed.kind !== 'unsupported' && support.supported) return false
1599
+ const reason = support.reason ?? (parsed.kind === 'unsupported' ? parsed.reason : undefined)
1600
+ const reasonLine = reason ? `\n${reason}` : ''
1601
+ this.errors.push({
1602
+ code: 'BF101',
1603
+ severity: 'error',
1604
+ message: `Expression not supported on attribute '${attrName}': ${expr.trim()}${reasonLine}`,
1605
+ loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1606
+ suggestion: {
1607
+ message: 'The ERB adapter cannot lower JS object literals or tagged-template-literal expressions into Embedded Ruby. Move the expression into a `\'use client\'` component (so hydration computes it), or expand it into discrete attributes whose values are values the adapter can lower.',
1608
+ },
1609
+ })
1610
+ return true
1611
+ }
1612
+
1613
+ /**
1614
+ * Build the EmitContext seam the top-level `ParsedExpr` emitter depends on.
1615
+ * Built as a private object (the adapter does NOT `implements ErbEmitContext`)
1616
+ * so the wrapped bookkeeping — `_searchParamsLocals`, the const/record
1617
+ * resolvers, the loop-bound-name predicate, BF101 recording, the
1618
+ * filter-predicate entry — stays private and off the exported adapter's
1619
+ * public type, matching the Mojo/Go adapters' `emitCtx`.
1620
+ */
1621
+ private get emitCtx(): ErbEmitContext {
1622
+ return {
1623
+ _searchParamsLocals: this._searchParamsLocals,
1624
+ resolveModuleStringConst: (name) => this.resolveModuleStringConst(name),
1625
+ resolveLiteralConst: (name) => this.resolveLiteralConst(name),
1626
+ resolveStaticRecordLiteral: (o, k) => this.resolveStaticRecordLiteral(o, k),
1627
+ isLoopBoundName: (name) => this.isLoopBoundName(name),
1628
+ _isStringValueName: (name) => this._isStringValueName(name),
1629
+ _recordExprBF101: (message, reason) => this._recordExprBF101(message, reason),
1630
+ _renderRubyFilterExprPublic: (e, p) => this._renderRubyFilterExprPublic(e, p),
1631
+ }
1632
+ }
1633
+
1634
+ /**
1635
+ * Build the narrow context the extracted spread lowering depends on.
1636
+ * Passing a purpose-built object (rather than `this`) keeps the
1637
+ * adapter's bookkeeping members private.
1638
+ */
1639
+ private get spreadCtx(): ErbSpreadContext {
1640
+ return {
1641
+ componentName: this.componentName,
1642
+ errors: this.errors,
1643
+ localConstants: this.localConstants,
1644
+ propsParams: this.propsParams,
1645
+ convertExpressionToRuby: (e, preParsed) => this.convertExpressionToRuby(e, preParsed),
1646
+ }
1647
+ }
1648
+
1649
+ /** Build the narrow context the extracted memo seeding depends on. */
1650
+ private get memoCtx(): ErbMemoContext {
1651
+ return { convertExpressionToRuby: (e, preParsed) => this.convertExpressionToRuby(e, preParsed) }
1652
+ }
1653
+
1654
+ private convertExpressionToRuby(expr: string, preParsed?: ParsedExpr): string {
1655
+ // Parse-first lowering — parity with the Go adapter's
1656
+ // `convertExpressionToGo`. Parse the JS expression once, gate it on
1657
+ // the shared `isSupported`, and render every supported shape through
1658
+ // the AST emitter (`renderParsedExprToRuby`). The parser's
1659
+ // `UNSUPPORTED_METHODS` is the single source of truth for what's
1660
+ // refused — there are no per-method routing regexes and no regex
1661
+ // string-rewriting pipeline. Unsupported shapes (un-lowered methods,
1662
+ // unparseable hand-written JS, etc.) surface as BF101 with the
1663
+ // `/* @client */` escape hatch instead of being silently mangled.
1664
+ //
1665
+ // `preParsed` is the IR-carried `ParsedExpr` tree (cf. go-template's
1666
+ // `convertExpressionToGo(jsExpr, out?, preParsed?)`); when present it is
1667
+ // used directly instead of re-parsing `expr`, so spread condition/value
1668
+ // lowering threads the carried tree through without a stringify→re-parse
1669
+ // round-trip. The diagnostic text is then derived from the tree
1670
+ // (`stringifyParsedExpr`) so callers can pass `''` for `expr`.
1671
+ let parsed: ParsedExpr
1672
+ if (preParsed) {
1673
+ parsed = preParsed
1674
+ } else {
1675
+ const trimmed = expr.trim()
1676
+ if (trimmed === '') return "''"
1677
+ parsed = parseExpression(trimmed)
1678
+ }
1679
+
1680
+ // Registered call lowerings, including the built-in `queryHref` plugin,
1681
+ // which lowers `queryHref(base, { … })` to a neutral `guard-list` on
1682
+ // the `query` helper → `bf.query(base, <triples>)`. Recognised before
1683
+ // the support gate because the object-literal arg is otherwise
1684
+ // `unsupported` (BF101). The `bf.query` helper includes a pair iff its
1685
+ // guard is truthy AND its value is a non-empty string (the client's
1686
+ // `if (value)`): a plain `key: v` passes guard `true`, a conditional
1687
+ // `key: cond ? v : undefined` passes the lowered cond. Only the
1688
+ // `query` helper renders to `bf.query`; another guard-list helper must
1689
+ // not be silently mis-rendered as a query.
1690
+ if (parsed.kind === 'call') {
1691
+ for (const matcher of this._loweringMatchers) {
1692
+ const node = matcher(parsed.callee, parsed.args)
1693
+ if (node?.kind === 'guard-list' && node.helper === 'query') {
1694
+ const argsRuby = queryHrefArgs(node, n => this.renderParsedExprToRuby(n))
1695
+ return `bf.query(${argsRuby.join(', ')})`
1696
+ }
1697
+ // Generic `helper-call` (#2069) — the neutral vocabulary's escape
1698
+ // hatch for a userland `LoweringPlugin` that lowers to a single
1699
+ // runtime-helper invocation. `bf.<helper>(args…)` mirrors the
1700
+ // `query` helper's own naming convention exactly: the framework
1701
+ // renders the call, the plugin author registers `<helper>` as a
1702
+ // Ruby-callable method on their own `bf` helper object — same
1703
+ // contract as `bf.query` itself, just not built in.
1704
+ if (node?.kind === 'helper-call' && isValidHelperId(node.helper)) {
1705
+ const argsX = node.args.map(a => this.renderParsedExprToRuby(a))
1706
+ return `bf.${node.helper}(${argsX.join(', ')})`
1707
+ }
1708
+ }
1709
+ }
1710
+
1711
+ const support = isSupported(parsed)
1712
+ if (!support.supported) {
1713
+ this.errors.push({
1714
+ code: 'BF101',
1715
+ severity: 'error',
1716
+ message: `Expression not supported: ${preParsed ? stringifyParsedExpr(parsed) : expr.trim()}`,
1717
+ loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1718
+ suggestion: {
1719
+ message: support.reason
1720
+ ? `${support.reason}\n\nOptions:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Ruby`
1721
+ : 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Ruby',
1722
+ },
1723
+ })
1724
+ // Safe Ruby empty-string literal — valid in every context the
1725
+ // result might land in (`<%= '' %>`, `if bf.truthy?('')`, attribute
1726
+ // interpolation, template-literal substitution).
1727
+ return "''"
1728
+ }
1729
+
1730
+ return this.renderParsedExprToRuby(parsed)
1731
+ }
1732
+
1733
+ /**
1734
+ * Render a full ParsedExpr tree to Ruby for top-level (non-filter)
1735
+ * expressions where identifiers are signals / vars-Hash entries.
1736
+ * Delegates to the shared ParsedExpr dispatcher with `ErbTopLevelEmitter`.
1737
+ */
1738
+ private renderParsedExprToRuby(expr: ParsedExpr): string {
1739
+ return emitParsedExpr(expr, new ErbTopLevelEmitter(this.emitCtx))
1740
+ }
1741
+
1742
+ /** Whether `name` (a signal getter or prop) holds a string value — gates
1743
+ * index-access Hash-vs-Array lowering (see `expr/operand.ts`). */
1744
+ private _isStringValueName(name: string): boolean {
1745
+ return this.stringValueNames.has(name)
1746
+ }
1747
+
1748
+ private _recordExprBF101(message: string, reason?: string): void {
1749
+ this.errors.push({
1750
+ code: 'BF101',
1751
+ severity: 'error',
1752
+ message,
1753
+ loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1754
+ suggestion: {
1755
+ message: reason
1756
+ ? `${reason}\n\nOptions:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Ruby`
1757
+ : 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Ruby',
1758
+ },
1759
+ })
1760
+ }
1761
+
1762
+ /** Internal hook for higher-order: predicate body re-uses the filter emitter. */
1763
+ private _renderRubyFilterExprPublic(expr: ParsedExpr, param: string): string {
1764
+ return this.renderRubyFilterExpr(expr, param)
1765
+ }
1766
+ }
1767
+
1768
+ export const erbAdapter = new ErbAdapter()