@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.
- package/dist/adapter/analysis/component-tree.d.ts +24 -0
- package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
- package/dist/adapter/boolean-result.d.ts +66 -0
- package/dist/adapter/boolean-result.d.ts.map +1 -0
- package/dist/adapter/emit-context.d.ts +107 -0
- package/dist/adapter/emit-context.d.ts.map +1 -0
- package/dist/adapter/erb-adapter.d.ts +374 -0
- package/dist/adapter/erb-adapter.d.ts.map +1 -0
- package/dist/adapter/expr/array-method.d.ts +87 -0
- package/dist/adapter/expr/array-method.d.ts.map +1 -0
- package/dist/adapter/expr/emitters.d.ts +102 -0
- package/dist/adapter/expr/emitters.d.ts.map +1 -0
- package/dist/adapter/expr/operand.d.ts +34 -0
- package/dist/adapter/expr/operand.d.ts.map +1 -0
- package/dist/adapter/index.d.ts +6 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/index.js +189154 -0
- package/dist/adapter/lib/constants.d.ts +25 -0
- package/dist/adapter/lib/constants.d.ts.map +1 -0
- package/dist/adapter/lib/ir-scope.d.ts +27 -0
- package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
- package/dist/adapter/lib/ruby-naming.d.ts +65 -0
- package/dist/adapter/lib/ruby-naming.d.ts.map +1 -0
- package/dist/adapter/lib/types.d.ts +28 -0
- package/dist/adapter/lib/types.d.ts.map +1 -0
- package/dist/adapter/memo/seed.d.ts +35 -0
- package/dist/adapter/memo/seed.d.ts.map +1 -0
- package/dist/adapter/props/prop-classes.d.ts +50 -0
- package/dist/adapter/props/prop-classes.d.ts.map +1 -0
- package/dist/adapter/spread/spread-codegen.d.ts +63 -0
- package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
- package/dist/adapter/value/parsed-literal.d.ts +21 -0
- package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
- package/dist/build.d.ts +28 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +189174 -0
- package/dist/conformance-pins.d.ts +13 -0
- package/dist/conformance-pins.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +189172 -0
- package/lib/barefoot_js/backend/erb.rb +123 -0
- package/lib/barefoot_js/dev_reload.rb +159 -0
- package/lib/barefoot_js/evaluator.rb +714 -0
- package/lib/barefoot_js/search_params.rb +63 -0
- package/lib/barefoot_js.rb +1155 -0
- package/package.json +67 -0
- package/src/__tests__/erb-adapter.test.ts +298 -0
- package/src/adapter/analysis/component-tree.ts +122 -0
- package/src/adapter/boolean-result.ts +157 -0
- package/src/adapter/emit-context.ts +119 -0
- package/src/adapter/erb-adapter.ts +1768 -0
- package/src/adapter/expr/array-method.ts +424 -0
- package/src/adapter/expr/emitters.ts +629 -0
- package/src/adapter/expr/operand.ts +55 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/lib/constants.ts +37 -0
- package/src/adapter/lib/ir-scope.ts +51 -0
- package/src/adapter/lib/ruby-naming.ts +127 -0
- package/src/adapter/lib/types.ts +31 -0
- package/src/adapter/memo/seed.ts +75 -0
- package/src/adapter/props/prop-classes.ts +89 -0
- package/src/adapter/spread/spread-codegen.ts +176 -0
- package/src/adapter/value/parsed-literal.ts +29 -0
- package/src/build.ts +37 -0
- package/src/conformance-pins.ts +100 -0
- package/src/index.ts +9 -0
- 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
|
+
}
|