@barefootjs/blade 0.1.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 (72) hide show
  1. package/README.md +73 -0
  2. package/dist/adapter/analysis/component-tree.d.ts +26 -0
  3. package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
  4. package/dist/adapter/blade-adapter.d.ts +537 -0
  5. package/dist/adapter/blade-adapter.d.ts.map +1 -0
  6. package/dist/adapter/boolean-result.d.ts +76 -0
  7. package/dist/adapter/boolean-result.d.ts.map +1 -0
  8. package/dist/adapter/emit-context.d.ts +105 -0
  9. package/dist/adapter/emit-context.d.ts.map +1 -0
  10. package/dist/adapter/expr/array-method.d.ts +74 -0
  11. package/dist/adapter/expr/array-method.d.ts.map +1 -0
  12. package/dist/adapter/expr/emitters.d.ts +176 -0
  13. package/dist/adapter/expr/emitters.d.ts.map +1 -0
  14. package/dist/adapter/expr/operand.d.ts +25 -0
  15. package/dist/adapter/expr/operand.d.ts.map +1 -0
  16. package/dist/adapter/index.d.ts +6 -0
  17. package/dist/adapter/index.d.ts.map +1 -0
  18. package/dist/adapter/index.js +189060 -0
  19. package/dist/adapter/lib/blade-naming.d.ts +128 -0
  20. package/dist/adapter/lib/blade-naming.d.ts.map +1 -0
  21. package/dist/adapter/lib/constants.d.ts +21 -0
  22. package/dist/adapter/lib/constants.d.ts.map +1 -0
  23. package/dist/adapter/lib/ir-scope.d.ts +48 -0
  24. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  25. package/dist/adapter/lib/types.d.ts +27 -0
  26. package/dist/adapter/lib/types.d.ts.map +1 -0
  27. package/dist/adapter/memo/seed.d.ts +84 -0
  28. package/dist/adapter/memo/seed.d.ts.map +1 -0
  29. package/dist/adapter/props/prop-classes.d.ts +34 -0
  30. package/dist/adapter/props/prop-classes.d.ts.map +1 -0
  31. package/dist/adapter/spread/spread-codegen.d.ts +72 -0
  32. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  33. package/dist/adapter/value/parsed-literal.d.ts +27 -0
  34. package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
  35. package/dist/build.d.ts +28 -0
  36. package/dist/build.d.ts.map +1 -0
  37. package/dist/build.js +189080 -0
  38. package/dist/conformance-pins.d.ts +12 -0
  39. package/dist/conformance-pins.d.ts.map +1 -0
  40. package/dist/index.d.ts +9 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +189079 -0
  43. package/package.json +67 -0
  44. package/php/composer.json +32 -0
  45. package/php/src/BladeBackend.php +197 -0
  46. package/php/src/naming.php +84 -0
  47. package/php/tests/test_render.php +159 -0
  48. package/src/__tests__/blade-adapter-unit.test.ts +392 -0
  49. package/src/__tests__/blade-adapter.test.ts +52 -0
  50. package/src/__tests__/blade-counter.test.ts +63 -0
  51. package/src/__tests__/blade-query-href.test.ts +113 -0
  52. package/src/__tests__/blade-spread-attrs.test.ts +235 -0
  53. package/src/adapter/analysis/component-tree.ts +119 -0
  54. package/src/adapter/blade-adapter.ts +1912 -0
  55. package/src/adapter/boolean-result.ts +168 -0
  56. package/src/adapter/emit-context.ts +117 -0
  57. package/src/adapter/expr/array-method.ts +345 -0
  58. package/src/adapter/expr/emitters.ts +636 -0
  59. package/src/adapter/expr/operand.ts +35 -0
  60. package/src/adapter/index.ts +6 -0
  61. package/src/adapter/lib/blade-naming.ts +180 -0
  62. package/src/adapter/lib/constants.ts +33 -0
  63. package/src/adapter/lib/ir-scope.ts +83 -0
  64. package/src/adapter/lib/types.ts +30 -0
  65. package/src/adapter/memo/seed.ts +135 -0
  66. package/src/adapter/props/prop-classes.ts +66 -0
  67. package/src/adapter/spread/spread-codegen.ts +179 -0
  68. package/src/adapter/value/parsed-literal.ts +75 -0
  69. package/src/build.ts +37 -0
  70. package/src/conformance-pins.ts +86 -0
  71. package/src/index.ts +9 -0
  72. package/src/test-render.ts +782 -0
@@ -0,0 +1,782 @@
1
+ /**
2
+ * Blade template test renderer
3
+ *
4
+ * Compiles JSX source with `BladeAdapter` and renders the resulting `.blade.php`
5
+ * templates to HTML via `php` + `illuminate/view` (standalone) driven through the PHP runtime:
6
+ * `Barefoot\BarefootJS` (engine-agnostic, `packages/adapter-php/src/`) +
7
+ * `Barefoot\BladeBackend` (`packages/adapter-blade/php/src/`), wired together
8
+ * via the `barefootjs/runtime` path-repo dependency declared in
9
+ * `packages/adapter-blade/php/composer.json`. Used by the adapter-tests
10
+ * conformance runner (`runAdapterConformanceTests`).
11
+ *
12
+ * Near-mechanical port of the sibling Jinja harness
13
+ * (`packages/adapter-jinja/src/test-render.ts`) — same `RenderOptions`
14
+ * contract, same prop / signal / memo seeding order, same multi-component
15
+ * child-renderer registration via the production `register_child_renderer`
16
+ * path (so a child's `bf-s` scope id derives from `<parentScope>_<slotId>`
17
+ * exactly as a real `bf build` page would). Two things differ from the
18
+ * Jinja port:
19
+ *
20
+ * 1. Target language: the generated render script is PHP, not Python, and
21
+ * its runtime is invoked via `Barefoot\`-namespaced classes / snake_case
22
+ * methods (see `packages/adapter-php/src/` for the runtime,
23
+ * `packages/adapter-blade/php/src/` for the Blade backend).
24
+ * 2. Prop transport: request-scoped caller PROPS (everything the harness's
25
+ * `RenderOptions.props` supplies, plus statically-resolved defaults/
26
+ * signal/memo seeds derived from them) are written to a `props.json`
27
+ * file and loaded via `json_decode(...)` (NO assoc — the canonical
28
+ * "JSON objects = stdClass, JSON arrays = PHP lists" convention), rather
29
+ * than serialised as inline Python source the way the Jinja port's
30
+ * `buildPythonProps`/`toPyLiteral` do. Two kinds of values genuinely
31
+ * can't round-trip through JSON and are instead emitted as literal PHP
32
+ * statements appended after the `json_decode` call: non-finite numbers
33
+ * (`NAN`/`INF`/`-INF` — signal-init values only; JSON has no way to
34
+ * represent them) and the `searchParams` binding (a `Barefoot\SearchParams`
35
+ * *object instance*, not a JSON value). Compile-time-derived STATIC data
36
+ * (a child template's `ssrDefaults`) stays literal PHP source (mirrors
37
+ * the Jinja port's `ssrDefaultsToPy`) since it never came from the
38
+ * caller's `props` and needs no JSON round-trip.
39
+ */
40
+
41
+ import { compileJSX, extractSsrDefaults, importsSearchParams } from '@barefootjs/jsx'
42
+ import type { ComponentIR } from '@barefootjs/jsx'
43
+ import { mkdir, rm } from 'node:fs/promises'
44
+ import { resolve } from 'node:path'
45
+
46
+ const RENDER_TEMP_DIR = resolve(import.meta.dir, '../.render-temp')
47
+ // The bundled PHP runtime (`Barefoot\BarefootJS` + `Barefoot\BladeBackend`)
48
+ // lives alongside this package (mirrors adapter-jinja bundling `python/` /
49
+ // adapter-go-template bundling `runtime/` in-tree). The render script
50
+ // `require`s this directory's Composer autoloader so `Barefoot\...` classes
51
+ // resolve.
52
+ const PHP_DIR = resolve(import.meta.dir, '../php')
53
+
54
+ export class BladeNotAvailableError extends Error {
55
+ constructor(message: string) {
56
+ super(message)
57
+ this.name = 'BladeNotAvailableError'
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Recover the bare component name from a compiler-emitted template file
63
+ * path. `templatesPerComponent` adapters write each component to
64
+ * `<dir>/<ComponentName><adapter.extension>` (Blade: `.blade.php`), and
65
+ * downstream pairing logic needs the raw component name back so it can
66
+ * look up the matching IR in `irsByName`.
67
+ *
68
+ * Exported for testing.
69
+ */
70
+ export function templateBaseName(path: string, extension: string): string {
71
+ const filename = path.substring(path.lastIndexOf('/') + 1)
72
+ return filename.endsWith(extension)
73
+ ? filename.slice(0, -extension.length)
74
+ : filename
75
+ }
76
+
77
+ let _bladeAvailable: boolean | null = null
78
+
79
+ /**
80
+ * Memoized availability probe: `php` on PATH, the adapter's own Composer
81
+ * vendor dir installed, and `Illuminate\View\Factory` (illuminate/view's
82
+ * entry-point contract) resolvable from it. Uses `Bun.spawnSync` (not the
83
+ * async `Bun.spawn` the actual render uses) per the design doc — a cheap
84
+ * synchronous check run once per process.
85
+ */
86
+ function isBladeAvailable(): boolean {
87
+ if (_bladeAvailable !== null) return _bladeAvailable
88
+ try {
89
+ const autoloadPath = resolve(PHP_DIR, 'vendor/autoload.php')
90
+ const probe = `require ${phpStr(autoloadPath)}; exit(class_exists('\\Illuminate\\View\\Factory') ? 0 : 1);`
91
+ const proc = Bun.spawnSync(['php', '-r', probe], {
92
+ stdout: 'ignore',
93
+ stderr: 'ignore',
94
+ })
95
+ _bladeAvailable = proc.exitCode === 0
96
+ } catch {
97
+ _bladeAvailable = false
98
+ }
99
+ return _bladeAvailable
100
+ }
101
+
102
+ export interface RenderOptions {
103
+ /** JSX source code */
104
+ source: string
105
+ /** Template adapter to use */
106
+ adapter: import('@barefootjs/jsx').TemplateAdapter
107
+ /** Props to inject (optional) */
108
+ props?: Record<string, unknown>
109
+ /** Additional component files (filename → source) */
110
+ components?: Record<string, string>
111
+ /**
112
+ * Explicit component to render when `source` declares multiple
113
+ * exports (e.g. `ReactiveProps.tsx` → `PropsReactivityComparison`).
114
+ * Mirrors the Hono reference's `componentName`; omitted for
115
+ * single-export fixtures, which fall back to the default/first export.
116
+ */
117
+ componentName?: string
118
+ }
119
+
120
+ export async function renderBladeComponent(options: RenderOptions): Promise<string> {
121
+ const { source, adapter, props, components, componentName: requestedName } = options
122
+
123
+ // Compile child components first.
124
+ //
125
+ // A child SOURCE FILE may export more components than the parent actually
126
+ // references (e.g. `../icon` exports ~30 icons + a generic `Icon`, but
127
+ // `Checkbox` only imports `CheckIcon`). Some of those unreferenced
128
+ // components legitimately can't lower to Blade — the generic `Icon` spreads
129
+ // `{...props}` onto CHILD components (`<GitHubIcon {...props}/>`), which has
130
+ // no Blade hash-splat form (Blade hash literals can't splat a runtime dict
131
+ // into named entries at a call site). Throwing on those would block a
132
+ // fixture that never renders them. So defer the per-file error gate: collect
133
+ // every component's template + IR up front, then (after the parent compile
134
+ // pins the reachable set) re-generate ONLY the reachable children and throw
135
+ // if any of THOSE error. Mirrors the Jinja harness's reachable-children
136
+ // emission (#checkbox).
137
+ const childTemplates: Map<string, { template: string; ir: ComponentIR }> = new Map()
138
+ if (components) {
139
+ for (const [filename, childSource] of Object.entries(components)) {
140
+ const childResult = compileJSX(childSource, filename, { adapter, outputIR: true })
141
+ const childTemplateFiles = childResult.files.filter(f => f.type === 'markedTemplate')
142
+ if (childTemplateFiles.length === 0) throw new Error(`No marked template for ${filename}`)
143
+ const childIrFiles = childResult.files.filter(f => f.type === 'ir')
144
+ if (childIrFiles.length === 0) throw new Error(`No IR output for ${filename}`)
145
+ const childIrs = childIrFiles.map(f => JSON.parse(f.content) as ComponentIR)
146
+ if (childTemplateFiles.length === 1) {
147
+ childTemplates.set(childIrs[0].metadata.componentName, { template: childTemplateFiles[0].content, ir: childIrs[0] })
148
+ } else {
149
+ // Multi-component child source: pair template ↔ IR by basename.
150
+ const childIrsByName = new Map(childIrs.map(i => [i.metadata.componentName, i]))
151
+ for (const tf of childTemplateFiles) {
152
+ const baseName = templateBaseName(tf.path, adapter.extension)
153
+ const matchedIR = childIrsByName.get(baseName) ?? childIrs[0]
154
+ childTemplates.set(matchedIR.metadata.componentName, { template: tf.content, ir: matchedIR })
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ // Compile parent source.
161
+ const result = compileJSX(source, 'component.tsx', { adapter, outputIR: true })
162
+
163
+ const errors = result.errors.filter(e => e.severity === 'error')
164
+ if (errors.length > 0) {
165
+ throw new Error(`Compilation errors:\n${errors.map(e => e.message).join('\n')}`)
166
+ }
167
+
168
+ const templateFiles = result.files.filter(f => f.type === 'markedTemplate')
169
+ if (templateFiles.length === 0) throw new Error('No marked template in compile output')
170
+
171
+ const irFiles = result.files.filter(f => f.type === 'ir')
172
+ if (irFiles.length === 0) throw new Error('No IR output (set outputIR: true)')
173
+ const irs = irFiles.map(f => JSON.parse(f.content) as ComponentIR)
174
+ // Explicit `componentName` wins (multi-export sources pin the render
175
+ // target); otherwise default-export, first inline-exported, first IR.
176
+ // Mirrors the Hono reference so multi-component fixtures render the
177
+ // same export across adapters.
178
+ const ir =
179
+ (requestedName ? irs.find(i => i.metadata.componentName === requestedName) : undefined) ??
180
+ irs.find(i => i.metadata.hasDefaultExport) ??
181
+ irs.find(i => i.metadata.isExported) ??
182
+ irs[0]
183
+
184
+ let templateFile: { content: string } | undefined
185
+ if (templateFiles.length === 1) {
186
+ templateFile = templateFiles[0]
187
+ } else {
188
+ // Multi-component source: split the entry-point template from
189
+ // siblings by pairing each template file to its IR by basename.
190
+ const irsByName = new Map(irs.map(i => [i.metadata.componentName, i]))
191
+ for (const tf of templateFiles) {
192
+ const baseName = templateBaseName(tf.path, adapter.extension)
193
+ const matchedIR = irsByName.get(baseName)
194
+ if (matchedIR === ir) {
195
+ templateFile = tf
196
+ } else if (matchedIR) {
197
+ childTemplates.set(matchedIR.metadata.componentName, { template: tf.content, ir: matchedIR })
198
+ }
199
+ }
200
+ }
201
+ if (!templateFile) throw new Error('No marked template in compile output')
202
+
203
+ // Reachable-children error gate (#checkbox). Now that the entry-point `ir` is
204
+ // pinned, close transitively over its cross-file component imports and verify
205
+ // each reachable child lowers without error — re-generating the child IR
206
+ // through a fresh adapter to attribute errors per component (the aggregate
207
+ // compile errors aren't component-tagged). A child file may export
208
+ // unreferenced components that legitimately can't lower (e.g. `../icon`'s
209
+ // generic `Icon`); those are dropped silently rather than failing a fixture
210
+ // that never renders them.
211
+ {
212
+ const reachable = new Set<string>()
213
+ const queue = [...collectImportedComponentNames(ir)]
214
+ while (queue.length > 0) {
215
+ const name = queue.shift()!
216
+ if (reachable.has(name)) continue
217
+ const entry = childTemplates.get(name)
218
+ if (!entry) continue // in-source sibling or non-compiled import
219
+ reachable.add(name)
220
+ queue.push(...collectImportedComponentNames(entry.ir))
221
+ }
222
+ for (const name of reachable) {
223
+ const entry = childTemplates.get(name)
224
+ if (!entry) continue
225
+ // The child was first compiled WITHOUT `siblingTemplatesRegistered`, so
226
+ // `entry.ir.errors` may already carry suppressible BF103s (cross-template
227
+ // loop references the harness DOES register). Re-generate with siblings
228
+ // registered and inspect ONLY the errors that pass appends — `generate`
229
+ // resets its own error list and appends to `ir.errors`, so anything after
230
+ // the pre-existing count is the authoritative siblings-registered result.
231
+ const before = entry.ir.errors?.length ?? 0
232
+ adapter.generate(entry.ir, { siblingTemplatesRegistered: true })
233
+ const childErrors = (entry.ir.errors ?? [])
234
+ .slice(before)
235
+ .filter(e => e.severity === 'error')
236
+ if (childErrors.length > 0) {
237
+ throw new Error(
238
+ `Compilation errors in reachable child ${name}:\n${childErrors.map(e => e.message).join('\n')}`,
239
+ )
240
+ }
241
+ }
242
+ }
243
+
244
+ const componentName = ir.metadata.componentName
245
+
246
+ // Build temp directory.
247
+ const tempDir = resolve(
248
+ RENDER_TEMP_DIR,
249
+ `blade-${Date.now()}-${Math.random().toString(36).slice(2)}`,
250
+ )
251
+ await mkdir(tempDir, { recursive: true })
252
+
253
+ try {
254
+ // Write `.blade.php` files (parent + children), named by snake_case so
255
+ // the adapter's `bf.render_child('<snake>', …)` calls + the backend's
256
+ // `render_named('<snake>', …)` resolve from the dir.
257
+ await Bun.write(resolve(tempDir, `${toSnakeCase(componentName)}.blade.php`), templateFile.content)
258
+ for (const [childName, { template }] of childTemplates) {
259
+ await Bun.write(resolve(tempDir, `${toSnakeCase(childName)}.blade.php`), template)
260
+ }
261
+
262
+ // Honour `__instanceId` from props for the root scope id so
263
+ // shared-component fixtures (which pin `<ComponentName>_test`) match
264
+ // cross-adapter; default to 'test' otherwise.
265
+ const rootScopeIdRaw = typeof props?.__instanceId === 'string' ? props.__instanceId : 'test'
266
+
267
+ // Build the JSON-transportable props payload + any literal PHP
268
+ // assignments for values JSON can't carry (non-finite numbers, the
269
+ // `searchParams` object binding).
270
+ const { data: propsData, literalAssignments } = buildBladeProps(props, ir)
271
+ await Bun.write(resolve(tempDir, 'props.json'), JSON.stringify(propsData))
272
+
273
+ // Build child-renderer registration PHP.
274
+ const childRenderers = buildChildRenderersPhp(childTemplates)
275
+
276
+ const autoloadPath = resolve(PHP_DIR, 'vendor/autoload.php')
277
+
278
+ const renderScript = `<?php
279
+ require ${phpStr(autoloadPath)};
280
+
281
+ // Single Blade backend over the temp template dir.
282
+ $backend = new \\Barefoot\\BladeBackend(['paths' => [${phpStr(tempDir)}]]);
283
+ $bf = new \\Barefoot\\BarefootJS(null, ['backend' => $backend]);
284
+ // Honour an explicit __instanceId so shared-component fixtures match the
285
+ // scope ids Hono / Go / Jinja emit; default to 'test'.
286
+ $bf->_scope_id(${phpStr(rootScopeIdRaw)});
287
+
288
+ $vars = (array) json_decode(file_get_contents('props.json'));
289
+ ${literalAssignments.join('\n')}
290
+
291
+ ${childRenderers}
292
+ $html = $backend->render_named(${phpStr(toSnakeCase(componentName))}, $bf, $vars);
293
+ echo $html;
294
+ `
295
+ await Bun.write(resolve(tempDir, 'render.php'), renderScript)
296
+
297
+ if (!isBladeAvailable()) {
298
+ throw new BladeNotAvailableError(
299
+ 'php with illuminate/view not found (run: composer install --working-dir packages/adapter-blade/php) — skipping Blade rendering',
300
+ )
301
+ }
302
+
303
+ const proc = Bun.spawn(['php', 'render.php'], {
304
+ cwd: tempDir,
305
+ stdout: 'pipe',
306
+ stderr: 'pipe',
307
+ })
308
+
309
+ const [stdout, stderr] = await Promise.all([
310
+ new Response(proc.stdout).text(),
311
+ new Response(proc.stderr).text(),
312
+ ])
313
+
314
+ const exitCode = await proc.exited
315
+ if (exitCode !== 0) {
316
+ throw new Error(`php render failed (exit ${exitCode}):\n${stderr}`)
317
+ }
318
+
319
+ return stdout
320
+ } finally {
321
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {})
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Component names a component IR imports from sibling source files — i.e.
327
+ * non-type imports from relative (`./` / `../`) specifiers. Used to compute the
328
+ * transitive set of child components a fixture actually references (#checkbox).
329
+ * Mirrors the Go / Jinja / Xslate harness helper of the same name.
330
+ */
331
+ function collectImportedComponentNames(ir: ComponentIR): string[] {
332
+ const names: string[] = []
333
+ for (const imp of ir.metadata.imports ?? []) {
334
+ if (imp.isTypeOnly) continue
335
+ if (!imp.source.startsWith('.')) continue
336
+ for (const spec of imp.specifiers ?? []) {
337
+ if (spec.isNamespace) continue
338
+ names.push(spec.alias ?? spec.name)
339
+ }
340
+ }
341
+ return names
342
+ }
343
+
344
+ /**
345
+ * Build PHP code that registers one child-component renderer per child
346
+ * template via the production `BarefootJS::register_child_renderer`.
347
+ *
348
+ * The closure mirrors the manifest-driven path in the PHP runtime (ports
349
+ * `runtime.py`'s `register_components_from_manifest`'s `make_renderer`,
350
+ * see the Jinja harness's `buildChildRenderers` docstring for the full
351
+ * rationale): it derives the child scope id from `<parentScope>_<slotId>`
352
+ * (the parent's `bf.render_child('<name>', {…, '_bf_slot': '<slotId>'})`
353
+ * passes `_bf_slot`), seeds signal / memo / prop defaults from the child
354
+ * IR's `ssrDefaults`, shares the parent's script list, and renders the
355
+ * child `.blade.php` through the same backend. Loop children (no `_bf_slot`)
356
+ * fall back to `<ComponentName>_<random>` like the Jinja/Xslate harnesses.
357
+ *
358
+ * Unlike the Jinja port's Python closures, this does NOT need a
359
+ * `_make_child_renderer_<name>()` factory-function wrapper: PHP's
360
+ * `function (...) use ($x)` captures `$x` BY VALUE at the point the closure
361
+ * literal is evaluated (unless written `use (&$x)`), so there is no
362
+ * Python-style late-binding-loop-closure hazard to guard against — each
363
+ * `register_child_renderer` call's closure gets its own `$_defaults`
364
+ * (computed as a literal PHP expression evaluated fresh, per closure) and
365
+ * captures only `$backend` / `$bf` (deliberately BY VALUE — this test
366
+ * harness never reassigns those top-level bindings after the closures are
367
+ * defined, so a value capture is equivalent to and simpler than a reference
368
+ * capture here).
369
+ *
370
+ * `child_props` arrives from the compiled `.blade.php` template's own PHP
371
+ * array literal at the `$bf->render_child(...)` call site — a genuine PHP
372
+ * array from the start (no separate "hash" runtime type to normalize away,
373
+ * unlike Twig), so the renderer closure receives a fresh, mutation-safe PHP
374
+ * array per call. Every key is ALREADY keyword-mangled: `$bf->render_child`
375
+ * (the runtime method the compiled parent template calls) mangles every
376
+ * prop key via `blade_ident` before invoking the registered renderer
377
+ * (mirrors the Jinja/Python runtime's `render_child` contract) — so the
378
+ * rest-bag routing below and the `_bf_slot` / `key` / `children` pops all
379
+ * compare against ALREADY-mangled key spellings; the `keep` set mangles the
380
+ * child's declared param names to match.
381
+ */
382
+ function buildChildRenderersPhp(
383
+ childTemplates: Map<string, { template: string; ir: ComponentIR }>,
384
+ ): string {
385
+ if (childTemplates.size === 0) return ''
386
+
387
+ const lines: string[] = []
388
+ lines.push(`// Register child component renderers`)
389
+
390
+ for (const [componentName, { ir: childIR }] of childTemplates) {
391
+ const snakeName = toSnakeCase(componentName)
392
+ const ssrDefaults = extractSsrDefaults(childIR.metadata) ?? {}
393
+ const defaultsPhp = ssrDefaultsToPhp(ssrDefaults)
394
+ const restPropsName = childIR.metadata.restPropsName
395
+ const paramNames = (childIR.metadata.propsParams ?? []).map(p => p.name)
396
+
397
+ lines.push(
398
+ `$bf->register_child_renderer(${phpStr(snakeName)}, function ($child_props, $caller_bf = null) use ($backend, $bf) {`,
399
+ )
400
+ lines.push(` $_defaults = ${defaultsPhp};`)
401
+ // `caller_bf` is the instance whose template invoked render_child
402
+ // (#1897) — nested children chain their scope/slot identity off it.
403
+ lines.push(` $host_scope = $caller_bf !== null ? $caller_bf->_scope_id() : $bf->_scope_id();`)
404
+ if (restPropsName) {
405
+ // A child that destructures a rest bag references it in its template;
406
+ // seed it with an empty array when the caller didn't pass one so the
407
+ // Blade var lookup doesn't fault. Route non-param props into the rest
408
+ // bag, mirroring the production runtime's `_derive_stash_from_defaults`
409
+ // isRestProps branch and JSX rest semantics: a caller prop the child
410
+ // didn't destructure belongs in the bag, not as a top-level stash var
411
+ // the template never reads. A plain PHP assoc array (not stdClass) is
412
+ // used for the rest bag — the "canonical value convention" documents
413
+ // assoc arrays as an accepted "object" shape for runtime helpers
414
+ // (`spread_attrs`, the Evaluator) alongside stdClass.
415
+ const restKeyPhp = phpStr(restPropsName)
416
+ const keepNames = [...new Set([...paramNames, restPropsName, 'children', 'key', '_bf_slot'])]
417
+ const keepNamesPhp = `[${keepNames.map(phpStr).join(', ')}]`
418
+ lines.push(` $_rest_key = \\Barefoot\\blade_ident(${restKeyPhp});`)
419
+ lines.push(` if (!array_key_exists($_rest_key, $child_props)) { $child_props[$_rest_key] = []; }`)
420
+ lines.push(` $_keep = array_map(fn($k) => \\Barefoot\\blade_ident($k), ${keepNamesPhp});`)
421
+ lines.push(` foreach (array_keys($child_props) as $_k) {`)
422
+ lines.push(` if (!in_array($_k, $_keep, true)) {`)
423
+ lines.push(` $child_props[$_rest_key][$_k] = $child_props[$_k];`)
424
+ lines.push(` unset($child_props[$_k]);`)
425
+ lines.push(` }`)
426
+ lines.push(` }`)
427
+ }
428
+ lines.push(` $slot_id = $child_props['_bf_slot'] ?? null;`)
429
+ lines.push(` unset($child_props['_bf_slot']);`)
430
+ lines.push(` $child_bf = new \\Barefoot\\BarefootJS(null, ['backend' => $backend]);`)
431
+ // JSX `key` (reserved prop) → data-key on the child scope root, for keyed
432
+ // loop reconciliation parity with Hono.
433
+ lines.push(` $data_key = $child_props['key'] ?? null;`)
434
+ lines.push(` unset($child_props['key']);`)
435
+ lines.push(` if ($data_key !== null) { $child_bf->_data_key($data_key); }`)
436
+ // A loop child (no slot) gets a fresh `<ComponentName>_<rand>` id per
437
+ // iteration — the PascalCase name is what `normalizeHTML` canonicalises
438
+ // to `<ComponentName>_*`; a slotted child derives from the parent scope.
439
+ lines.push(` if ($slot_id) {`)
440
+ lines.push(` $child_bf->_scope_id($host_scope . '_' . $slot_id);`)
441
+ lines.push(` } else {`)
442
+ lines.push(` $child_bf->_scope_id(${phpStr(componentName)} . '_' . bin2hex(random_bytes(3)));`)
443
+ lines.push(` }`)
444
+ lines.push(` $child_bf->_is_child(true);`)
445
+ lines.push(` if ($slot_id) {`)
446
+ lines.push(` $child_bf->_bf_parent($host_scope);`)
447
+ lines.push(` $child_bf->_bf_mount($slot_id);`)
448
+ lines.push(` }`)
449
+ // (#1897) A child template may itself call `bf.render_child(...)`
450
+ // (AccordionTrigger renders ChevronDownIcon) — inside that template
451
+ // `bf` is THIS fresh child instance, whose renderer registry starts
452
+ // empty, so the nested call silently rendered ''. Share the parent's
453
+ // registry so nested child renders resolve.
454
+ lines.push(` $child_bf->_child_renderers($bf->_child_renderers());`)
455
+ lines.push(` $child_bf->_scripts($bf->_scripts());`)
456
+ lines.push(` $child_bf->_script_seen($bf->_script_seen());`)
457
+ // Seed template vars: static ssrDefaults first, caller's props win.
458
+ lines.push(` $_vars = array_merge($_defaults, $child_props);`)
459
+ lines.push(` $rendered = $backend->render_named(${phpStr(snakeName)}, $child_bf, $_vars);`)
460
+ lines.push(` if (is_string($rendered) && substr($rendered, -1) === "\\n") { $rendered = substr($rendered, 0, -1); }`)
461
+ lines.push(` return $rendered;`)
462
+ lines.push(`});`)
463
+ lines.push(``)
464
+ }
465
+
466
+ return lines.join('\n')
467
+ }
468
+
469
+ /**
470
+ * Build the JSON-transportable props payload (`data`) plus any literal PHP
471
+ * `$vars[...] = ...;` statements (`literalAssignments`) for values JSON
472
+ * can't carry: non-finite numbers (only reachable via a signal's evaluated
473
+ * initial value, or a caller-supplied numeric prop) and the `searchParams`
474
+ * binding (a `Barefoot\SearchParams` object instance, not a JSON value).
475
+ *
476
+ * Mirrors the Jinja harness's `buildPythonProps`, except every ordinary
477
+ * value stays a genuine JS value here (destined for `JSON.stringify`)
478
+ * rather than being pre-rendered to Python source text — `render_named`
479
+ * (the PHP backend) applies `blade_ident` key-mangling once, at render time,
480
+ * exactly like the Jinja backend's `render_named` does for `jinja_ident`, so
481
+ * this harness doesn't need to mangle keys itself either.
482
+ */
483
+ function buildBladeProps(
484
+ props: Record<string, unknown> | undefined,
485
+ ir: ComponentIR,
486
+ ): { data: Record<string, unknown>; literalAssignments: string[] } {
487
+ const data: Record<string, unknown> = {}
488
+ const literalAssignments: string[] = []
489
+
490
+ function setEntry(name: string, value: unknown): void {
491
+ if (typeof value === 'number' && !Number.isFinite(value)) {
492
+ literalAssignments.push(`$vars[${phpStr(name)}] = ${phpNumberLiteral(value)};`)
493
+ return
494
+ }
495
+ data[name] = value
496
+ }
497
+
498
+ const explicitScope = typeof props?.__instanceId === 'string' ? props.__instanceId : 'test'
499
+ setEntry('scope_id', explicitScope)
500
+
501
+ // Prop params with defaults (before signals, so signals can reference them).
502
+ for (const param of ir.metadata.propsParams) {
503
+ if (props && param.name in props) continue
504
+ if (param.defaultValue) {
505
+ const value = evaluateSignalInit(param.defaultValue.trim(), props)
506
+ if (value !== null) {
507
+ setEntry(param.name, value)
508
+ continue
509
+ }
510
+ }
511
+ // No default + no caller value: pass `null` so Blade's var lookup for
512
+ // an optional prop doesn't fault before its falsy branch elides.
513
+ setEntry(param.name, null)
514
+ }
515
+
516
+ // Route undeclared props into the rest bag (`bf.spread_attrs($<rest>)`).
517
+ const restPropsName = ir.metadata.restPropsName
518
+ const declaredParams = new Set(ir.metadata.propsParams.map(p => p.name))
519
+ const restBagEntries: Array<[string, unknown]> = []
520
+ if (restPropsName && props) {
521
+ for (const [key, value] of Object.entries(props)) {
522
+ if (key.startsWith('__')) continue
523
+ if (key === restPropsName || declaredParams.has(key)) continue
524
+ restBagEntries.push([key, value])
525
+ }
526
+ }
527
+ const routedKeys = new Set(restBagEntries.map(([k]) => k))
528
+
529
+ if (restPropsName && !(props && restPropsName in props)) {
530
+ setEntry(restPropsName, Object.fromEntries(restBagEntries))
531
+ }
532
+
533
+ // User props.
534
+ if (props) {
535
+ for (const [key, value] of Object.entries(props)) {
536
+ if (key.startsWith('__')) continue
537
+ if (routedKeys.has(key)) continue
538
+ if (
539
+ typeof value === 'string' ||
540
+ typeof value === 'number' ||
541
+ typeof value === 'boolean' ||
542
+ Array.isArray(value) ||
543
+ (value && typeof value === 'object')
544
+ ) {
545
+ setEntry(key, value)
546
+ }
547
+ }
548
+ }
549
+
550
+ // Signal values evaluated from props (after user props).
551
+ for (const signal of ir.metadata.signals) {
552
+ // Env signals (#2057) are bound below via `SearchParams('')`, not from a
553
+ // static initial value.
554
+ if (signal.envReader) continue
555
+ const value = evaluateSignalInit(signal.initialValue.trim(), props)
556
+ if (value !== null) {
557
+ setEntry(signal.getter, value)
558
+ }
559
+ }
560
+
561
+ // Memo values seeded from the statically-evaluated ssrDefaults, same
562
+ // as the production plugin's before_render hook.
563
+ const ssrDefaults = extractSsrDefaults(ir.metadata) ?? {}
564
+ for (const memo of ir.metadata.memos) {
565
+ const entry = ssrDefaults[memo.name]
566
+ const value =
567
+ entry && typeof entry === 'object' && 'value' in (entry as Record<string, unknown>)
568
+ ? (entry as Record<string, unknown>).value
569
+ : 0
570
+ setEntry(memo.name, value ?? 0)
571
+ }
572
+
573
+ // (#1922) Request-scoped `searchParams()`: bind `searchParams` to an
574
+ // empty-query reader (so the render script needn't build one from a real
575
+ // request). The conformance harness issues no query string (the
576
+ // production Flask/PHP integration builds this from the request's query),
577
+ // so `.get(k)` resolves to `null` and the author's `?? default` renders.
578
+ // Only when the component imports `searchParams`. A `SearchParams`
579
+ // instance isn't a JSON value, so it's a literal PHP assignment, not a
580
+ // `props.json` entry.
581
+ if (importsSearchParams(ir.metadata)) {
582
+ literalAssignments.push(`$vars['searchParams'] = new \\Barefoot\\SearchParams('');`)
583
+ }
584
+
585
+ return { data, literalAssignments }
586
+ }
587
+
588
+ /**
589
+ * Evaluate a signal initializer expression using provided props.
590
+ * Handles: props.initial ?? 0, props.value, literal values.
591
+ *
592
+ * Language-agnostic (returns a real JS value, not source text) — shared
593
+ * verbatim with the Jinja harness's helper of the same name.
594
+ */
595
+ export function evaluateSignalInit(
596
+ expr: string,
597
+ props?: Record<string, unknown>,
598
+ ): unknown {
599
+ const nullishMatch = expr.match(/^props\.(\w+)\s*\?\?\s*(.+)$/)
600
+ if (nullishMatch) {
601
+ const propName = nullishMatch[1]
602
+ const defaultExpr = nullishMatch[2].trim()
603
+ if (props && propName in props) return props[propName]
604
+ return parseLiteral(defaultExpr)
605
+ }
606
+
607
+ const propsMatch = expr.match(/^props\.(\w+)$/)
608
+ if (propsMatch) {
609
+ if (props && propsMatch[1] in props) return props[propsMatch[1]]
610
+ return null
611
+ }
612
+
613
+ return parseLiteral(expr)
614
+ }
615
+
616
+ function parseLiteral(expr: string): unknown {
617
+ if (/^-?\d+(\.\d+)?$/.test(expr)) return Number(expr)
618
+ if (expr === 'true') return true
619
+ if (expr === 'false') return false
620
+ if (expr === '[]') return []
621
+
622
+ {
623
+ const t = expr.trim()
624
+ if (t.startsWith('[') && t.endsWith(']')) {
625
+ const inner = t.slice(1, -1).trim()
626
+ if (!inner) return []
627
+ const out: unknown[] = []
628
+ for (const seg of splitTopLevelCommas(inner)) {
629
+ if (!seg.trim()) continue
630
+ const parsed = parseLiteral(seg.trim())
631
+ if (parsed === null && seg.trim() !== 'null') return null
632
+ out.push(parsed)
633
+ }
634
+ return out
635
+ }
636
+ }
637
+
638
+ const stringMatch = expr.match(/^(['"])(.*)\1$/s)
639
+ if (stringMatch) return unescapeJsString(stringMatch[2])
640
+
641
+ const trimmed = expr.trim()
642
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
643
+ const inner = trimmed.slice(1, -1).trim()
644
+ if (!inner) return {}
645
+ const obj: Record<string, unknown> = {}
646
+ for (const pair of splitTopLevelCommas(inner)) {
647
+ if (!pair.trim()) continue
648
+ const colonIdx = pair.indexOf(':')
649
+ if (colonIdx < 0) return null
650
+ let key = pair.slice(0, colonIdx).trim()
651
+ const val = pair.slice(colonIdx + 1).trim()
652
+ const keyMatch = key.match(/^(['"])(.*)\1$/s)
653
+ if (keyMatch) key = unescapeJsString(keyMatch[2])
654
+ const parsedVal = parseLiteral(val)
655
+ if (parsedVal === null && val !== 'null') return null
656
+ obj[key] = parsedVal
657
+ }
658
+ return obj
659
+ }
660
+ return null
661
+ }
662
+
663
+ function splitTopLevelCommas(inner: string): string[] {
664
+ const segments: string[] = []
665
+ let depth = 0
666
+ let start = 0
667
+ let quote: string | null = null
668
+ for (let i = 0; i < inner.length; i++) {
669
+ const c = inner[i]
670
+ if (quote) {
671
+ if (c === quote) {
672
+ let backslashes = 0
673
+ for (let j = i - 1; j >= 0 && inner[j] === '\\'; j--) backslashes++
674
+ if (backslashes % 2 === 0) quote = null
675
+ }
676
+ continue
677
+ }
678
+ if (c === '"' || c === "'") {
679
+ quote = c
680
+ continue
681
+ }
682
+ if (c === '{' || c === '[') depth++
683
+ else if (c === '}' || c === ']') depth--
684
+ else if (c === ',' && depth === 0) {
685
+ segments.push(inner.slice(start, i))
686
+ start = i + 1
687
+ }
688
+ }
689
+ segments.push(inner.slice(start))
690
+ return segments
691
+ }
692
+
693
+ function unescapeJsString(s: string): string {
694
+ return s.replace(/\\(.)/g, (_, c) => {
695
+ switch (c) {
696
+ case 'n': return '\n'
697
+ case 'r': return '\r'
698
+ case 't': return '\t'
699
+ case '0': return '\0'
700
+ default: return c
701
+ }
702
+ })
703
+ }
704
+
705
+ /**
706
+ * Convert PascalCase to snake_case for template naming (matches the
707
+ * adapter's `toTemplateName`).
708
+ */
709
+ function toSnakeCase(name: string): string {
710
+ return name
711
+ .replace(/([A-Z])/g, '_$1')
712
+ .toLowerCase()
713
+ .replace(/^_/, '')
714
+ }
715
+
716
+ /**
717
+ * PHP single-quoted string literal for arbitrary text. PHP single-quoted
718
+ * strings support exactly two escapes (`\\` and `\'`) and pass everything
719
+ * else through as raw bytes — since this file is written with `Bun.write`
720
+ * (UTF-8), embedding non-ASCII text directly is safe and simpler than a
721
+ * JSON-based escaper. Mirrors `escapeBladeSingleQuoted` /
722
+ * `escapeJinjaSingleQuoted` / `escapeKolonSingleQuoted`'s escaping rule.
723
+ */
724
+ function phpStr(s: string): string {
725
+ return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
726
+ }
727
+
728
+ /** PHP numeric literal, with JS's NaN/±Infinity mapped to PHP's real constants. */
729
+ function phpNumberLiteral(n: number): string {
730
+ if (Number.isNaN(n)) return 'NAN'
731
+ if (n === Infinity) return 'INF'
732
+ if (n === -Infinity) return '-INF'
733
+ return String(n)
734
+ }
735
+
736
+ /**
737
+ * Serialise a static (compiler-derived) value to PHP source. Used ONLY for
738
+ * `ssrDefaults` (never for caller-supplied `props`, which route through
739
+ * `props.json` — see `buildBladeProps`) since those values never need a JSON
740
+ * round-trip. Follows the "canonical value convention": a JS array becomes a
741
+ * PHP list (`[...]`); a JS object becomes a `stdClass` (`(object) [...]`,
742
+ * PHP's only object-literal spelling) so it round-trips through the same
743
+ * runtime "object vs list" distinction a `props.json`-sourced value would.
744
+ */
745
+ function toPhpLiteral(value: unknown): string {
746
+ if (value === null || value === undefined) return 'null'
747
+ if (typeof value === 'string') return phpStr(value)
748
+ if (typeof value === 'number') return phpNumberLiteral(value)
749
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
750
+ if (Array.isArray(value)) {
751
+ return `[${value.map(toPhpLiteral).join(', ')}]`
752
+ }
753
+ if (typeof value === 'object') {
754
+ const entries = Object.entries(value as Record<string, unknown>)
755
+ .map(([k, v]) => `${phpStr(k)} => ${toPhpLiteral(v)}`)
756
+ .join(', ')
757
+ return `(object) [${entries}]`
758
+ }
759
+ return 'null'
760
+ }
761
+
762
+ /**
763
+ * Serialise an ssrDefaults map to a PHP assoc-array literal (the `$_defaults`
764
+ * var a child renderer merges caller props over). This top-level container
765
+ * is the "vars bag" domain (like `$vars` elsewhere in this file), not a JS
766
+ * VALUE flowing into a template — so, unlike a nested object VALUE inside
767
+ * one of its entries, it stays a plain PHP array rather than `(object) [...]`.
768
+ */
769
+ function ssrDefaultsToPhp(defaults: Record<string, unknown>): string {
770
+ const entries: string[] = []
771
+ for (const [name, d] of Object.entries(defaults)) {
772
+ // ssrDefaults entries are `{ value, propName?, isRestProps? }` or a
773
+ // bare value. The child renderer's caller props win, so we only need
774
+ // the static fallback `value` here.
775
+ let value: unknown = d
776
+ if (d && typeof d === 'object' && 'value' in (d as Record<string, unknown>)) {
777
+ value = (d as Record<string, unknown>).value
778
+ }
779
+ entries.push(`${phpStr(name)} => ${toPhpLiteral(value)}`)
780
+ }
781
+ return `[${entries.join(', ')}]`
782
+ }