@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,668 @@
1
+ /**
2
+ * ERB (Embedded Ruby) template test renderer
3
+ *
4
+ * Compiles JSX source with `ErbAdapter` and renders the resulting `.erb`
5
+ * templates to HTML via `ruby` + Ruby stdlib `erb`, driven through
6
+ * `BarefootJS::Context` + `BarefootJS::Backend::Erb`. Used by the
7
+ * adapter-tests conformance runner (`runAdapterConformanceTests`).
8
+ *
9
+ * Mirrors the sibling Text::Xslate `test-render.ts` (same `RenderOptions`
10
+ * contract, same prop / signal / memo seeding, same multi-component
11
+ * child-renderer registration), but two things are ERB-specific:
12
+ *
13
+ * - Props / ssrDefaults cross the JS→Ruby boundary as JSON
14
+ * (`JSON.parse(..., symbolize_names: true)`), never hand-built Ruby
15
+ * literals — the runtime's whole value domain is JSON-shaped
16
+ * symbol-keyed Hashes, so this is a straight `JSON.stringify` on the JS
17
+ * side with no per-type literal-escaping logic to keep in sync.
18
+ * - The Ruby runtime lives entirely inside this package (`lib/`); unlike
19
+ * the Perl ports there is no separate "core" package to add to the
20
+ * load path.
21
+ *
22
+ * Child components are wired through the production
23
+ * `BarefootJS::Context#register_child_renderer` so the child's `bf-s` scope
24
+ * id derives from `<parentScope>_<slotId>` exactly as a real `bf build`
25
+ * page would.
26
+ */
27
+
28
+ import { compileJSX, extractSsrDefaults, importsSearchParams } from '@barefootjs/jsx'
29
+ import type { ComponentIR, SsrDefault } from '@barefootjs/jsx'
30
+ import { mkdir, rm } from 'node:fs/promises'
31
+ import { resolve } from 'node:path'
32
+
33
+ const RENDER_TEMP_DIR = resolve(import.meta.dir, '../.render-temp')
34
+ // The Ruby runtime (BarefootJS::Context + BarefootJS::Backend::Erb) lives
35
+ // entirely under this package's `lib/` — no separate core package to add
36
+ // to the load path (unlike the Perl ports, which split a shared
37
+ // `@barefootjs/perl` core from the engine-specific backend).
38
+ const LIB_DIR = resolve(import.meta.dir, '../lib')
39
+
40
+ export class ErbNotAvailableError extends Error {
41
+ constructor(message: string) {
42
+ super(message)
43
+ this.name = 'ErbNotAvailableError'
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Recover the bare component name from a compiler-emitted template file
49
+ * path. `templatesPerComponent` adapters write each component to
50
+ * `<dir>/<ComponentName><adapter.extension>` (ERB: `.erb`), and
51
+ * downstream pairing logic needs the raw component name back so it can
52
+ * look up the matching IR in `irsByName`.
53
+ *
54
+ * Exported for testing.
55
+ */
56
+ export function templateBaseName(path: string, extension: string): string {
57
+ const filename = path.substring(path.lastIndexOf('/') + 1)
58
+ return filename.endsWith(extension)
59
+ ? filename.slice(0, -extension.length)
60
+ : filename
61
+ }
62
+
63
+ /** Escape a string for a Ruby single-quoted literal: backslash first (so it
64
+ * doesn't double-escape the quote we add next), then the quote. Used only
65
+ * for the small set of values embedded directly in the generated
66
+ * `render.rb` script (paths, scope ids, template names) — everything
67
+ * prop/data-shaped crosses the boundary as JSON instead. */
68
+ function rubyStringLiteral(s: string): string {
69
+ return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
70
+ }
71
+
72
+ let _rubyAvailable: boolean | null = null
73
+ async function isErbAvailable(): Promise<boolean> {
74
+ if (_rubyAvailable !== null) return _rubyAvailable
75
+ try {
76
+ const proc = Bun.spawn(['ruby', '-e', 'require "erb"'], {
77
+ stdout: 'pipe',
78
+ stderr: 'pipe',
79
+ })
80
+ await proc.exited
81
+ _rubyAvailable = proc.exitCode === 0
82
+ } catch {
83
+ _rubyAvailable = false
84
+ }
85
+ return _rubyAvailable
86
+ }
87
+
88
+ export interface RenderOptions {
89
+ /** JSX source code */
90
+ source: string
91
+ /** Template adapter to use */
92
+ adapter: import('@barefootjs/jsx').TemplateAdapter
93
+ /** Props to inject (optional) */
94
+ props?: Record<string, unknown>
95
+ /** Additional component files (filename → source) */
96
+ components?: Record<string, string>
97
+ /**
98
+ * Explicit component to render when `source` declares multiple
99
+ * exports (e.g. `ReactiveProps.tsx` → `PropsReactivityComparison`).
100
+ * Mirrors the Hono reference's `componentName`; omitted for
101
+ * single-export fixtures, which fall back to the default/first export.
102
+ */
103
+ componentName?: string
104
+ }
105
+
106
+ export async function renderErbComponent(options: RenderOptions): Promise<string> {
107
+ const { source, adapter, props, components, componentName: requestedName } = options
108
+
109
+ // Compile child components first.
110
+ //
111
+ // A child SOURCE FILE may export more components than the parent actually
112
+ // references (e.g. `../icon` exports ~30 icons + a generic `Icon`, but
113
+ // `Checkbox` only imports `CheckIcon`). Some of those unreferenced
114
+ // components legitimately can't lower to ERB. Throwing on those would
115
+ // block a fixture that never renders them. So defer the per-file error
116
+ // gate: collect every component's template + IR up front, then (after the
117
+ // parent compile pins the reachable set) re-generate ONLY the reachable
118
+ // children and throw if any of THOSE error. Mirrors the Go harness's
119
+ // reachable-children emission (#checkbox).
120
+ const childTemplates: Map<string, { template: string; ir: ComponentIR }> = new Map()
121
+ if (components) {
122
+ for (const [filename, childSource] of Object.entries(components)) {
123
+ const childResult = compileJSX(childSource, filename, { adapter, outputIR: true })
124
+ const childTemplateFiles = childResult.files.filter(f => f.type === 'markedTemplate')
125
+ if (childTemplateFiles.length === 0) throw new Error(`No marked template for ${filename}`)
126
+ const childIrFiles = childResult.files.filter(f => f.type === 'ir')
127
+ if (childIrFiles.length === 0) throw new Error(`No IR output for ${filename}`)
128
+ const childIrs = childIrFiles.map(f => JSON.parse(f.content) as ComponentIR)
129
+ if (childTemplateFiles.length === 1) {
130
+ childTemplates.set(childIrs[0].metadata.componentName, { template: childTemplateFiles[0].content, ir: childIrs[0] })
131
+ } else {
132
+ // Multi-component child source: pair template ↔ IR by basename.
133
+ const childIrsByName = new Map(childIrs.map(i => [i.metadata.componentName, i]))
134
+ for (const tf of childTemplateFiles) {
135
+ const baseName = templateBaseName(tf.path, adapter.extension)
136
+ const matchedIR = childIrsByName.get(baseName) ?? childIrs[0]
137
+ childTemplates.set(matchedIR.metadata.componentName, { template: tf.content, ir: matchedIR })
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ // Compile parent source.
144
+ const result = compileJSX(source, 'component.tsx', { adapter, outputIR: true })
145
+
146
+ const errors = result.errors.filter(e => e.severity === 'error')
147
+ if (errors.length > 0) {
148
+ throw new Error(`Compilation errors:\n${errors.map(e => e.message).join('\n')}`)
149
+ }
150
+
151
+ const templateFiles = result.files.filter(f => f.type === 'markedTemplate')
152
+ if (templateFiles.length === 0) throw new Error('No marked template in compile output')
153
+
154
+ const irFiles = result.files.filter(f => f.type === 'ir')
155
+ if (irFiles.length === 0) throw new Error('No IR output (set outputIR: true)')
156
+ const irs = irFiles.map(f => JSON.parse(f.content) as ComponentIR)
157
+ // Explicit `componentName` wins (multi-export sources pin the render
158
+ // target); otherwise default-export, first inline-exported, first IR.
159
+ // Mirrors the Hono reference so multi-component fixtures render the
160
+ // same export across adapters.
161
+ const ir =
162
+ (requestedName ? irs.find(i => i.metadata.componentName === requestedName) : undefined) ??
163
+ irs.find(i => i.metadata.hasDefaultExport) ??
164
+ irs.find(i => i.metadata.isExported) ??
165
+ irs[0]
166
+
167
+ let templateFile: { content: string } | undefined
168
+ if (templateFiles.length === 1) {
169
+ templateFile = templateFiles[0]
170
+ } else {
171
+ // Multi-component source: split the entry-point template from
172
+ // siblings by pairing each template file to its IR by basename.
173
+ const irsByName = new Map(irs.map(i => [i.metadata.componentName, i]))
174
+ for (const tf of templateFiles) {
175
+ const baseName = templateBaseName(tf.path, adapter.extension)
176
+ const matchedIR = irsByName.get(baseName)
177
+ if (matchedIR === ir) {
178
+ templateFile = tf
179
+ } else if (matchedIR) {
180
+ childTemplates.set(matchedIR.metadata.componentName, { template: tf.content, ir: matchedIR })
181
+ }
182
+ }
183
+ }
184
+ if (!templateFile) throw new Error('No marked template in compile output')
185
+
186
+ // Reachable-children error gate (#checkbox). Now that the entry-point `ir` is
187
+ // pinned, close transitively over its cross-file component imports and verify
188
+ // each reachable child lowers without error — re-generating the child IR
189
+ // through a fresh adapter to attribute errors per component (the aggregate
190
+ // compile errors aren't component-tagged). A child file may export
191
+ // unreferenced components that legitimately can't lower (e.g. `../icon`'s
192
+ // generic `Icon`); those are dropped silently rather than failing a fixture
193
+ // that never renders them.
194
+ {
195
+ const reachable = new Set<string>()
196
+ const queue = [...collectImportedComponentNames(ir)]
197
+ while (queue.length > 0) {
198
+ const name = queue.shift()!
199
+ if (reachable.has(name)) continue
200
+ const entry = childTemplates.get(name)
201
+ if (!entry) continue // in-source sibling or non-compiled import
202
+ reachable.add(name)
203
+ queue.push(...collectImportedComponentNames(entry.ir))
204
+ }
205
+ for (const name of reachable) {
206
+ const entry = childTemplates.get(name)
207
+ if (!entry) continue
208
+ // The child was first compiled WITHOUT `siblingTemplatesRegistered`, so
209
+ // `entry.ir.errors` may already carry suppressible BF103s (cross-template
210
+ // loop references the harness DOES register). Re-generate with siblings
211
+ // registered and inspect ONLY the errors that pass appends — `generate`
212
+ // resets its own error list and appends to `ir.errors`, so anything after
213
+ // the pre-existing count is the authoritative siblings-registered result.
214
+ const before = entry.ir.errors?.length ?? 0
215
+ adapter.generate(entry.ir, { siblingTemplatesRegistered: true })
216
+ const childErrors = (entry.ir.errors ?? [])
217
+ .slice(before)
218
+ .filter(e => e.severity === 'error')
219
+ if (childErrors.length > 0) {
220
+ throw new Error(
221
+ `Compilation errors in reachable child ${name}:\n${childErrors.map(e => e.message).join('\n')}`,
222
+ )
223
+ }
224
+ }
225
+ }
226
+
227
+ const componentName = ir.metadata.componentName
228
+
229
+ // Build temp directory.
230
+ const tempDir = resolve(
231
+ RENDER_TEMP_DIR,
232
+ `erb-${Date.now()}-${Math.random().toString(36).slice(2)}`,
233
+ )
234
+ await mkdir(tempDir, { recursive: true })
235
+
236
+ try {
237
+ // Write `.erb` files (parent + children), named by snake_case so
238
+ // the adapter's `bf.render_child('<snake>', …)` calls + the
239
+ // backend's `render_named('<snake>', …)` resolve from the dir.
240
+ await Bun.write(resolve(tempDir, `${toSnakeCase(componentName)}.erb`), templateFile.content)
241
+ for (const [childName, { template }] of childTemplates) {
242
+ await Bun.write(resolve(tempDir, `${toSnakeCase(childName)}.erb`), template)
243
+ }
244
+
245
+ // Build the root props Hash and write it as JSON — the runtime's
246
+ // value domain is JSON-shaped symbol-keyed Hashes throughout, so
247
+ // `JSON.parse(..., symbolize_names: true)` on the Ruby side is the
248
+ // whole marshalling story; no hand-built Ruby literals.
249
+ const { obj: rootProps, needsSearchParams } = buildRubyProps(props, ir)
250
+ await Bun.write(resolve(tempDir, 'props.json'), JSON.stringify(rootProps))
251
+
252
+ // Honour `__instanceId` from props for the root scope id so
253
+ // shared-component fixtures (which pin `<ComponentName>_test`) match
254
+ // cross-adapter; default to 'test' otherwise.
255
+ const rootScopeIdRaw = typeof props?.__instanceId === 'string' ? props.__instanceId : 'test'
256
+ const rootScopeId = rubyStringLiteral(rootScopeIdRaw)
257
+
258
+ // Static ssrDefaults per child, simplified to bare values (the
259
+ // caller's props always win — see `buildChildRenderersRuby`) and
260
+ // written as one JSON file keyed by snake_case template name.
261
+ const childDefaults: Record<string, Record<string, unknown>> = {}
262
+ for (const [childName, { ir: childIR }] of childTemplates) {
263
+ childDefaults[toSnakeCase(childName)] = simplifySsrDefaults(extractSsrDefaults(childIR.metadata) ?? {})
264
+ }
265
+ if (childTemplates.size > 0) {
266
+ await Bun.write(resolve(tempDir, 'child_defaults.json'), JSON.stringify(childDefaults))
267
+ }
268
+
269
+ const childRenderers = buildChildRenderersRuby(childTemplates)
270
+
271
+ const renderScript = `#!/usr/bin/env ruby
272
+ # frozen_string_literal: true
273
+
274
+ require 'barefoot_js'
275
+ require 'barefoot_js/backend/erb'
276
+ require 'json'
277
+
278
+ # Single ERB backend over the temp template dir.
279
+ backend = BarefootJS::Backend::Erb.new(path: ${rubyStringLiteral(tempDir)})
280
+ bf = BarefootJS::Context.new(backend)
281
+ # Honour an explicit __instanceId so shared-component fixtures match the
282
+ # scope ids Hono / Go emit; default to 'test'.
283
+ bf._scope_id(${rootScopeId})
284
+
285
+ props = JSON.parse(File.read(File.join(__dir__, 'props.json')), symbolize_names: true)
286
+ ${needsSearchParams ? "# (#1922) Request-scoped searchParams() env signal: bind the reserved\n# `search_params` vars key to an empty-query reader. Only when the\n# component imports `searchParams`.\nprops[:search_params] = bf.search_params('')\n" : ''}
287
+ ${childRenderers}
288
+ html = backend.render_named(${rubyStringLiteral(toSnakeCase(componentName))}, bf, props)
289
+ print html
290
+ `
291
+ await Bun.write(resolve(tempDir, 'render.rb'), renderScript)
292
+
293
+ if (!await isErbAvailable()) {
294
+ throw new ErbNotAvailableError('ruby with erb not found — skipping ERB rendering')
295
+ }
296
+
297
+ const proc = Bun.spawn(['ruby', '-I', LIB_DIR, 'render.rb'], {
298
+ cwd: tempDir,
299
+ stdout: 'pipe',
300
+ stderr: 'pipe',
301
+ })
302
+
303
+ const [stdout, stderr] = await Promise.all([
304
+ new Response(proc.stdout).text(),
305
+ new Response(proc.stderr).text(),
306
+ ])
307
+
308
+ const exitCode = await proc.exited
309
+ if (exitCode !== 0) {
310
+ throw new Error(`ruby render failed (exit ${exitCode}):\n${stderr}`)
311
+ }
312
+
313
+ return stdout
314
+ } finally {
315
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {})
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Component names a component IR imports from sibling source files — i.e.
321
+ * non-type imports from relative (`./` / `../`) specifiers. Used to compute the
322
+ * transitive set of child components a fixture actually references (#checkbox).
323
+ * Mirrors the Go harness helper of the same name.
324
+ */
325
+ function collectImportedComponentNames(ir: ComponentIR): string[] {
326
+ const names: string[] = []
327
+ for (const imp of ir.metadata.imports ?? []) {
328
+ if (imp.isTypeOnly) continue
329
+ if (!imp.source.startsWith('.')) continue
330
+ for (const spec of imp.specifiers ?? []) {
331
+ if (spec.isNamespace) continue
332
+ names.push(spec.alias ?? spec.name)
333
+ }
334
+ }
335
+ return names
336
+ }
337
+
338
+ /**
339
+ * Build Ruby code that registers one child-component renderer per child
340
+ * template via the production `BarefootJS::Context#register_child_renderer`.
341
+ *
342
+ * The closure mirrors the manifest-driven path in `barefoot_js.rb`: it
343
+ * derives the child scope id from `<parentScope>_<slotId>` (the parent's
344
+ * `bf.render_child('<name>', { …, _bf_slot: '<slotId>' })` passes
345
+ * `_bf_slot`), seeds signal / memo / prop defaults from the child IR's
346
+ * `ssrDefaults` (loaded once from `child_defaults.json`, caller props win
347
+ * via `Hash#merge`), shares the parent's script list, and renders the
348
+ * child `.erb` through the same backend. Loop children (no `_bf_slot`)
349
+ * fall back to `<ComponentName>_<rand>` like the Perl harnesses.
350
+ */
351
+ function buildChildRenderersRuby(
352
+ childTemplates: Map<string, { template: string; ir: ComponentIR }>,
353
+ ): string {
354
+ if (childTemplates.size === 0) return ''
355
+
356
+ const lines: string[] = []
357
+ lines.push(`child_defaults = JSON.parse(File.read(File.join(__dir__, 'child_defaults.json')), symbolize_names: true)`)
358
+ lines.push(``)
359
+ lines.push(`# Register child component renderers`)
360
+
361
+ for (const [componentName, { ir: childIR }] of childTemplates) {
362
+ const snakeName = toSnakeCase(componentName)
363
+ const restPropsName = childIR.metadata.restPropsName
364
+
365
+ lines.push(`defaults_${snakeName} = child_defaults[${rubySymbol(snakeName)}] || {}`)
366
+ lines.push(`bf.register_child_renderer(${rubyStringLiteral(snakeName)}, lambda do |child_props, caller_bf|`)
367
+ // `caller_bf` is the instance whose template invoked render_child
368
+ // (#1897) — nested children chain their scope/slot identity off it.
369
+ lines.push(` host_scope = caller_bf ? caller_bf._scope_id : bf._scope_id`)
370
+ if (restPropsName) {
371
+ const restSym = rubySymbol(restPropsName)
372
+ // A child that destructures a rest bag references `v[:<rest>]` in its
373
+ // template; seed it with an empty Hash when the caller didn't pass
374
+ // one so a symbol-key lookup on a missing key still resolves (to nil,
375
+ // via Hash#[]) rather than a partially-shaped bag.
376
+ lines.push(` child_props[${restSym}] ||= {}`)
377
+ // (#1897) Route non-param props into the rest bag, mirroring the
378
+ // production runtime's `derive_vars_from_defaults` isRestProps
379
+ // branch and JSX rest semantics: a caller prop the child didn't
380
+ // destructure belongs in the bag, not as a top-level vars key the
381
+ // template never reads.
382
+ const paramNames = (childIR.metadata.propsParams ?? []).map(p => p.name)
383
+ const keep = [...new Set([...paramNames, restPropsName, 'children', 'key', '_bf_slot'])]
384
+ const keepList = keep.map(rubySymbol).join(', ')
385
+ lines.push(` keep = [${keepList}]`)
386
+ lines.push(` child_props.keys.each do |k|`)
387
+ lines.push(` next if keep.include?(k)`)
388
+ lines.push(` child_props[${restSym}][k] = child_props.delete(k)`)
389
+ lines.push(` end`)
390
+ }
391
+ lines.push(` slot_id = child_props.delete(:_bf_slot)`)
392
+ lines.push(` child_bf = BarefootJS::Context.new(backend)`)
393
+ // JSX `key` (reserved prop) → data-key on the child scope root, for keyed
394
+ // loop reconciliation parity with Hono.
395
+ lines.push(` data_key = child_props.delete(:key)`)
396
+ lines.push(` child_bf._data_key(data_key) unless data_key.nil?`)
397
+ // A loop child (no slot) gets a fresh `<ComponentName>_<rand>` id per
398
+ // iteration — the PascalCase name is what `normalizeHTML` canonicalises to
399
+ // `<ComponentName>_*`; a slotted child derives from the parent scope.
400
+ lines.push(` child_bf._scope_id(slot_id ? "#{host_scope}_#{slot_id}" : "${componentName}_#{rand.to_s[2, 6]}")`)
401
+ lines.push(` child_bf._is_child(true)`)
402
+ lines.push(` if slot_id`)
403
+ lines.push(` child_bf._bf_parent(host_scope)`)
404
+ lines.push(` child_bf._bf_mount(slot_id)`)
405
+ lines.push(` end`)
406
+ // (#1897) A child template may itself call `bf.render_child(...)`
407
+ // (AccordionTrigger renders ChevronDownIcon) — inside that template
408
+ // `bf` is THIS fresh child instance, whose renderer registry starts
409
+ // empty, so the nested call would silently render ''. Share the
410
+ // parent's registry so nested child renders resolve.
411
+ lines.push(` child_bf._child_renderers(bf._child_renderers)`)
412
+ lines.push(` child_bf._scripts(bf._scripts)`)
413
+ lines.push(` child_bf._script_seen(bf._script_seen)`)
414
+ // Seed template vars: static ssrDefaults first, caller's props win.
415
+ lines.push(` vars = defaults_${snakeName}.merge(child_props)`)
416
+ lines.push(` rendered = backend.render_named(${rubyStringLiteral(snakeName)}, child_bf, vars)`)
417
+ lines.push(` rendered.chomp`)
418
+ lines.push(`end)`)
419
+ lines.push(``)
420
+ }
421
+
422
+ return lines.join('\n') + '\n'
423
+ }
424
+
425
+ /** Render `name` as a Ruby symbol literal (`:name` / `:"data-slot"`) for
426
+ * embedding in the generated `render.rb`. Mirrors the adapter's own
427
+ * `rubySymbolLiteral` (kept local so the test harness doesn't reach into
428
+ * adapter internals for a three-line string helper). */
429
+ function rubySymbol(name: string): string {
430
+ return /^[A-Za-z_][A-Za-z0-9_]*[?!]?$/.test(name) ? `:${name}` : `:"${name.replace(/"/g, '\\"')}"`
431
+ }
432
+
433
+ /**
434
+ * Simplify an `extractSsrDefaults` map to bare values for the child
435
+ * defaults JSON file. Each entry is either a bare value or
436
+ * `{ value, propName?, isRestProps? }`; the child renderer always merges
437
+ * the caller's live `child_props` OVER these defaults (`Hash#merge`), so
438
+ * only the static fallback `value` is needed here — the `propName`-aware
439
+ * resolution the production manifest path does is redundant with that
440
+ * merge.
441
+ */
442
+ function simplifySsrDefaults(defaults: Record<string, SsrDefault>): Record<string, unknown> {
443
+ const out: Record<string, unknown> = {}
444
+ for (const [name, d] of Object.entries(defaults)) {
445
+ out[name] = d.value
446
+ }
447
+ return out
448
+ }
449
+
450
+ /**
451
+ * Convert PascalCase to snake_case for template naming (matches the
452
+ * adapter's `toTemplateName`).
453
+ */
454
+ function toSnakeCase(name: string): string {
455
+ return name
456
+ .replace(/([A-Z])/g, '_$1')
457
+ .toLowerCase()
458
+ .replace(/^_/, '')
459
+ }
460
+
461
+ /**
462
+ * Build the root props object (later JSON-serialised) + whether the
463
+ * component imports `searchParams`.
464
+ */
465
+ function buildRubyProps(
466
+ props: Record<string, unknown> | undefined,
467
+ ir: ComponentIR,
468
+ ): { obj: Record<string, unknown>; needsSearchParams: boolean } {
469
+ const obj: Record<string, unknown> = {}
470
+
471
+ const explicitScope = typeof props?.__instanceId === 'string' ? props.__instanceId : 'test'
472
+ obj.scope_id = explicitScope
473
+
474
+ // Prop params with defaults (before signals, so signals can reference them).
475
+ for (const param of ir.metadata.propsParams) {
476
+ if (props && param.name in props) continue
477
+ if (param.defaultValue) {
478
+ const value = jsDefaultLiteral(param.defaultValue)
479
+ if (value !== undefined) {
480
+ obj[param.name] = value
481
+ continue
482
+ }
483
+ }
484
+ // No default + no caller value: seed `nil` so a bare reference to an
485
+ // optional prop's vars-Hash key resolves (to nil) instead of the key
486
+ // being wholly absent — matches the Perl harnesses' explicit `undef`.
487
+ obj[param.name] = null
488
+ }
489
+
490
+ // Route undeclared props into the rest bag (`spread_attrs(v[:<rest>])`).
491
+ const restPropsName = ir.metadata.restPropsName
492
+ const declaredParams = new Set(ir.metadata.propsParams.map(p => p.name))
493
+ const restBagEntries: Array<[string, unknown]> = []
494
+ if (restPropsName && props) {
495
+ for (const [key, value] of Object.entries(props)) {
496
+ if (key.startsWith('__')) continue
497
+ if (key === restPropsName || declaredParams.has(key)) continue
498
+ restBagEntries.push([key, value])
499
+ }
500
+ }
501
+ const routedKeys = new Set(restBagEntries.map(([k]) => k))
502
+
503
+ if (restPropsName && !(props && restPropsName in props)) {
504
+ obj[restPropsName] = Object.fromEntries(restBagEntries)
505
+ }
506
+
507
+ // User props.
508
+ if (props) {
509
+ for (const [key, value] of Object.entries(props)) {
510
+ if (key.startsWith('__')) continue
511
+ if (routedKeys.has(key)) continue
512
+ obj[key] = value
513
+ }
514
+ }
515
+
516
+ // Signal values evaluated from props (after user props).
517
+ for (const signal of ir.metadata.signals) {
518
+ // Env signals (#2057 / #1922) are bound below via `search_params('')`,
519
+ // not from a static initial value.
520
+ if (signal.envReader) continue
521
+ const value = evaluateSignalInit(signal.initialValue.trim(), props)
522
+ if (value !== null) {
523
+ obj[signal.getter] = value
524
+ }
525
+ }
526
+
527
+ // Memo values seeded from the statically-evaluated ssrDefaults, same
528
+ // as the production plugin's before_render hook.
529
+ const ssrDefaults = extractSsrDefaults(ir.metadata) ?? {}
530
+ for (const memo of ir.metadata.memos) {
531
+ obj[memo.name] = ssrDefaults[memo.name]?.value ?? 0
532
+ }
533
+
534
+ const needsSearchParams = importsSearchParams(ir.metadata)
535
+
536
+ return { obj, needsSearchParams }
537
+ }
538
+
539
+ /**
540
+ * Best-effort literal evaluation of a prop-destructure default's source
541
+ * text (`{ size = 'md' }` → `'md'`), including a `props.x ?? default`
542
+ * nullish fallback (handled generically, though destructure defaults
543
+ * rarely reference `props`) and delegating to `parseLiteral` for
544
+ * everything else. Returns `undefined` for a non-literal (computed)
545
+ * default, matching the Perl harnesses' "fall through to undef" behaviour.
546
+ */
547
+ function jsDefaultLiteral(expr: string): unknown {
548
+ const t = expr.trim()
549
+ const nullishMatch = t.match(/\?\?\s*(.+)$/)
550
+ if (nullishMatch) return jsDefaultLiteral(nullishMatch[1])
551
+ if (t.startsWith('props.')) return undefined
552
+ const parsed = parseLiteral(t)
553
+ return parsed === null && t !== 'null' ? undefined : parsed
554
+ }
555
+
556
+ /**
557
+ * Evaluate a signal initializer expression using provided props.
558
+ * Handles: props.initial ?? 0, props.value, literal values.
559
+ */
560
+ export function evaluateSignalInit(
561
+ expr: string,
562
+ props?: Record<string, unknown>,
563
+ ): unknown {
564
+ const nullishMatch = expr.match(/^props\.(\w+)\s*\?\?\s*(.+)$/)
565
+ if (nullishMatch) {
566
+ const propName = nullishMatch[1]
567
+ const defaultExpr = nullishMatch[2].trim()
568
+ if (props && propName in props) return props[propName]
569
+ return parseLiteral(defaultExpr)
570
+ }
571
+
572
+ const propsMatch = expr.match(/^props\.(\w+)$/)
573
+ if (propsMatch) {
574
+ if (props && propsMatch[1] in props) return props[propsMatch[1]]
575
+ return null
576
+ }
577
+
578
+ return parseLiteral(expr)
579
+ }
580
+
581
+ function parseLiteral(expr: string): unknown {
582
+ if (/^-?\d+(\.\d+)?$/.test(expr)) return Number(expr)
583
+ if (expr === 'true') return true
584
+ if (expr === 'false') return false
585
+ if (expr === '[]') return []
586
+
587
+ {
588
+ const t = expr.trim()
589
+ if (t.startsWith('[') && t.endsWith(']')) {
590
+ const inner = t.slice(1, -1).trim()
591
+ if (!inner) return []
592
+ const out: unknown[] = []
593
+ for (const seg of splitTopLevelCommas(inner)) {
594
+ if (!seg.trim()) continue
595
+ const parsed = parseLiteral(seg.trim())
596
+ if (parsed === null && seg.trim() !== 'null') return null
597
+ out.push(parsed)
598
+ }
599
+ return out
600
+ }
601
+ }
602
+
603
+ const stringMatch = expr.match(/^(['"])(.*)\1$/s)
604
+ if (stringMatch) return unescapeJsString(stringMatch[2])
605
+
606
+ const trimmed = expr.trim()
607
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
608
+ const inner = trimmed.slice(1, -1).trim()
609
+ if (!inner) return {}
610
+ const obj: Record<string, unknown> = {}
611
+ for (const pair of splitTopLevelCommas(inner)) {
612
+ if (!pair.trim()) continue
613
+ const colonIdx = pair.indexOf(':')
614
+ if (colonIdx < 0) return null
615
+ let key = pair.slice(0, colonIdx).trim()
616
+ const val = pair.slice(colonIdx + 1).trim()
617
+ const keyMatch = key.match(/^(['"])(.*)\1$/s)
618
+ if (keyMatch) key = unescapeJsString(keyMatch[2])
619
+ const parsedVal = parseLiteral(val)
620
+ if (parsedVal === null && val !== 'null') return null
621
+ obj[key] = parsedVal
622
+ }
623
+ return obj
624
+ }
625
+ return null
626
+ }
627
+
628
+ function splitTopLevelCommas(inner: string): string[] {
629
+ const segments: string[] = []
630
+ let depth = 0
631
+ let start = 0
632
+ let quote: string | null = null
633
+ for (let i = 0; i < inner.length; i++) {
634
+ const c = inner[i]
635
+ if (quote) {
636
+ if (c === quote) {
637
+ let backslashes = 0
638
+ for (let j = i - 1; j >= 0 && inner[j] === '\\'; j--) backslashes++
639
+ if (backslashes % 2 === 0) quote = null
640
+ }
641
+ continue
642
+ }
643
+ if (c === '"' || c === "'") {
644
+ quote = c
645
+ continue
646
+ }
647
+ if (c === '{' || c === '[') depth++
648
+ else if (c === '}' || c === ']') depth--
649
+ else if (c === ',' && depth === 0) {
650
+ segments.push(inner.slice(start, i))
651
+ start = i + 1
652
+ }
653
+ }
654
+ segments.push(inner.slice(start))
655
+ return segments
656
+ }
657
+
658
+ function unescapeJsString(s: string): string {
659
+ return s.replace(/\\(.)/g, (_, c) => {
660
+ switch (c) {
661
+ case 'n': return '\n'
662
+ case 'r': return '\r'
663
+ case 't': return '\t'
664
+ case '0': return '\0'
665
+ default: return c
666
+ }
667
+ })
668
+ }