@barefootjs/rust 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 (87) hide show
  1. package/README.md +194 -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/boolean-result.d.ts +85 -0
  5. package/dist/adapter/boolean-result.d.ts.map +1 -0
  6. package/dist/adapter/emit-context.d.ts +107 -0
  7. package/dist/adapter/emit-context.d.ts.map +1 -0
  8. package/dist/adapter/expr/array-method.d.ts +75 -0
  9. package/dist/adapter/expr/array-method.d.ts.map +1 -0
  10. package/dist/adapter/expr/emitters.d.ts +143 -0
  11. package/dist/adapter/expr/emitters.d.ts.map +1 -0
  12. package/dist/adapter/index.d.ts +6 -0
  13. package/dist/adapter/index.d.ts.map +1 -0
  14. package/dist/adapter/index.js +189091 -0
  15. package/dist/adapter/lib/constants.d.ts +25 -0
  16. package/dist/adapter/lib/constants.d.ts.map +1 -0
  17. package/dist/adapter/lib/ir-scope.d.ts +50 -0
  18. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  19. package/dist/adapter/lib/minijinja-naming.d.ts +64 -0
  20. package/dist/adapter/lib/minijinja-naming.d.ts.map +1 -0
  21. package/dist/adapter/lib/types.d.ts +32 -0
  22. package/dist/adapter/lib/types.d.ts.map +1 -0
  23. package/dist/adapter/memo/seed.d.ts +84 -0
  24. package/dist/adapter/memo/seed.d.ts.map +1 -0
  25. package/dist/adapter/minijinja-adapter.d.ts +421 -0
  26. package/dist/adapter/minijinja-adapter.d.ts.map +1 -0
  27. package/dist/adapter/props/prop-classes.d.ts +33 -0
  28. package/dist/adapter/props/prop-classes.d.ts.map +1 -0
  29. package/dist/adapter/spread/spread-codegen.d.ts +63 -0
  30. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  31. package/dist/adapter/value/parsed-literal.d.ts +28 -0
  32. package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
  33. package/dist/build.d.ts +29 -0
  34. package/dist/build.d.ts.map +1 -0
  35. package/dist/build.js +189111 -0
  36. package/dist/conformance-pins.d.ts +13 -0
  37. package/dist/conformance-pins.d.ts.map +1 -0
  38. package/dist/index.d.ts +12 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +189112 -0
  41. package/package.json +67 -0
  42. package/runtime/Cargo.lock +124 -0
  43. package/runtime/Cargo.toml +21 -0
  44. package/runtime/src/backend_minijinja.rs +176 -0
  45. package/runtime/src/bin/bf-render.rs +147 -0
  46. package/runtime/src/evaluator.rs +770 -0
  47. package/runtime/src/lib.rs +19 -0
  48. package/runtime/src/manifest.rs +258 -0
  49. package/runtime/src/num.rs +558 -0
  50. package/runtime/src/runtime.rs +1548 -0
  51. package/runtime/src/search_params.rs +173 -0
  52. package/runtime/tests/eval_vectors.rs +94 -0
  53. package/runtime/tests/evaluator.rs +407 -0
  54. package/runtime/tests/helper_vectors.rs +348 -0
  55. package/runtime/tests/manifest.rs +169 -0
  56. package/runtime/tests/omit.rs +79 -0
  57. package/runtime/tests/props_attr.rs +75 -0
  58. package/runtime/tests/query.rs +50 -0
  59. package/runtime/tests/render_child.rs +210 -0
  60. package/runtime/tests/search_params.rs +68 -0
  61. package/runtime/tests/spread_attrs.rs +94 -0
  62. package/runtime/tests/template_primitives.rs +376 -0
  63. package/runtime/tests/vector-divergences.json +33 -0
  64. package/src/__tests__/minijinja-adapter-unit.test.ts +392 -0
  65. package/src/__tests__/minijinja-adapter.test.ts +58 -0
  66. package/src/__tests__/minijinja-counter.test.ts +61 -0
  67. package/src/__tests__/minijinja-query-href.test.ts +101 -0
  68. package/src/__tests__/minijinja-spread-attrs.test.ts +227 -0
  69. package/src/adapter/analysis/component-tree.ts +119 -0
  70. package/src/adapter/boolean-result.ts +177 -0
  71. package/src/adapter/emit-context.ts +119 -0
  72. package/src/adapter/expr/array-method.ts +346 -0
  73. package/src/adapter/expr/emitters.ts +608 -0
  74. package/src/adapter/index.ts +6 -0
  75. package/src/adapter/lib/constants.ts +37 -0
  76. package/src/adapter/lib/ir-scope.ts +95 -0
  77. package/src/adapter/lib/minijinja-naming.ts +85 -0
  78. package/src/adapter/lib/types.ts +35 -0
  79. package/src/adapter/memo/seed.ts +135 -0
  80. package/src/adapter/minijinja-adapter.ts +1796 -0
  81. package/src/adapter/props/prop-classes.ts +65 -0
  82. package/src/adapter/spread/spread-codegen.ts +168 -0
  83. package/src/adapter/value/parsed-literal.ts +76 -0
  84. package/src/build.ts +38 -0
  85. package/src/conformance-pins.ts +101 -0
  86. package/src/index.ts +12 -0
  87. package/src/test-render.ts +680 -0
@@ -0,0 +1,680 @@
1
+ /**
2
+ * minijinja (Rust) template test renderer
3
+ *
4
+ * Compiles JSX source with `MinijinjaAdapter` and renders the resulting
5
+ * `.j2` templates to HTML via the compiled `bf-render` binary
6
+ * (`packages/adapter-rust/runtime/`: a `minijinja::Environment` wired up in
7
+ * `backend_minijinja.rs`, driven by the payload-protocol conformance
8
+ * renderer `src/bin/bf-render.rs`). Used by the adapter-tests conformance
9
+ * runner (`runAdapterConformanceTests`).
10
+ *
11
+ * Near-verbatim port of the sibling Jinja2 harness
12
+ * (`packages/adapter-jinja/src/test-render.ts`) — same `RenderOptions`
13
+ * contract, same prop / signal / memo seeding order, same multi-component
14
+ * IR pairing by basename and reachable-children error gate. The ONE
15
+ * structural difference is how a render is invoked: adapter-jinja generates
16
+ * a throwaway Python SCRIPT per fixture that inline-builds a props dict and
17
+ * registers child renderers as Python closures; this harness instead
18
+ * SERIALIZES that same information to a JSON payload (see the payload
19
+ * protocol in the design doc) and hands it to one long-lived compiled Rust
20
+ * binary — `bf-render` builds the child renderer closures itself from the
21
+ * payload's `children` array (mirroring `buildChildRenderers` below, ported
22
+ * to Rust in the runtime crate). `buildPythonProps` therefore becomes
23
+ * `buildVars`, returning a plain JS object (the payload's `vars` field)
24
+ * instead of a Python dict *source string* — no `pyStr`/`toPyLiteral`
25
+ * string-building layer is needed, JSON.stringify does that job, EXCEPT for
26
+ * non-finite numbers (`NaN`/`Infinity`/`-Infinity`), which JSON cannot
27
+ * represent — see `encodeSpecials` below for the `__bf_special` sentinel
28
+ * that closes that gap.
29
+ */
30
+
31
+ import { compileJSX, extractSsrDefaults, importsSearchParams } from '@barefootjs/jsx'
32
+ import type { ComponentIR } from '@barefootjs/jsx'
33
+ import { mkdir, rm } from 'node:fs/promises'
34
+ import { resolve } from 'node:path'
35
+
36
+ const RENDER_TEMP_DIR = resolve(import.meta.dir, '../.render-temp')
37
+ // The Rust runtime crate (`barefootjs`, binary `bf-render`) lives alongside
38
+ // this package (mirrors adapter-jinja bundling `python/` in-tree). Built
39
+ // once (memoized below) and re-used across all conformance fixtures —
40
+ // NEVER built per fixture.
41
+ const RUNTIME_DIR = resolve(import.meta.dir, '../runtime')
42
+ const RUNTIME_MANIFEST = resolve(RUNTIME_DIR, 'Cargo.toml')
43
+ const BF_RENDER_BIN = resolve(RUNTIME_DIR, 'target/debug/bf-render')
44
+
45
+ export class RustNotAvailableError extends Error {
46
+ constructor(message: string) {
47
+ super(message)
48
+ this.name = 'RustNotAvailableError'
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Recover the bare component name from a compiler-emitted template file
54
+ * path. `templatesPerComponent` adapters write each component to
55
+ * `<dir>/<ComponentName><adapter.extension>` (minijinja: `.j2`), and
56
+ * downstream pairing logic needs the raw component name back so it can
57
+ * look up the matching IR in `irsByName`.
58
+ *
59
+ * Exported for testing.
60
+ */
61
+ export function templateBaseName(path: string, extension: string): string {
62
+ const filename = path.substring(path.lastIndexOf('/') + 1)
63
+ return filename.endsWith(extension)
64
+ ? filename.slice(0, -extension.length)
65
+ : filename
66
+ }
67
+
68
+ let _cargoAvailable: boolean | null = null
69
+ async function isCargoAvailable(): Promise<boolean> {
70
+ if (_cargoAvailable !== null) return _cargoAvailable
71
+ try {
72
+ const proc = Bun.spawn(['cargo', '--version'], {
73
+ stdout: 'pipe',
74
+ stderr: 'pipe',
75
+ })
76
+ await proc.exited
77
+ _cargoAvailable = proc.exitCode === 0
78
+ } catch {
79
+ _cargoAvailable = false
80
+ }
81
+ return _cargoAvailable
82
+ }
83
+
84
+ /**
85
+ * Module-scope memoized build of the `bf-render` binary. The first caller
86
+ * triggers `cargo build`; every subsequent fixture in the same test run
87
+ * awaits the SAME promise (or observes it already resolved) instead of
88
+ * re-invoking cargo — cargo's own incremental `target/` cache would make a
89
+ * repeat build cheap, but there is no reason to pay even that per fixture.
90
+ * A build FAILURE is a real `Error` (not `RustNotAvailableError`) — a
91
+ * present-but-broken toolchain should fail loudly, not be silently skipped
92
+ * like a genuinely absent one.
93
+ */
94
+ let _buildPromise: Promise<void> | null = null
95
+ function ensureBfRenderBuilt(): Promise<void> {
96
+ if (!_buildPromise) {
97
+ _buildPromise = (async () => {
98
+ const proc = Bun.spawn(
99
+ ['cargo', 'build', '--manifest-path', RUNTIME_MANIFEST, '--bin', 'bf-render'],
100
+ { stdout: 'pipe', stderr: 'pipe' },
101
+ )
102
+ const [stdout, stderr] = await Promise.all([
103
+ new Response(proc.stdout).text(),
104
+ new Response(proc.stderr).text(),
105
+ ])
106
+ const exitCode = await proc.exited
107
+ if (exitCode !== 0) {
108
+ throw new Error(`cargo build --bin bf-render failed (exit ${exitCode}):\n${stderr}\n${stdout}`)
109
+ }
110
+ })()
111
+ }
112
+ return _buildPromise
113
+ }
114
+
115
+ export interface RenderOptions {
116
+ /** JSX source code */
117
+ source: string
118
+ /** Template adapter to use */
119
+ adapter: import('@barefootjs/jsx').TemplateAdapter
120
+ /** Props to inject (optional) */
121
+ props?: Record<string, unknown>
122
+ /** Additional component files (filename → source) */
123
+ components?: Record<string, string>
124
+ /**
125
+ * Explicit component to render when `source` declares multiple
126
+ * exports (e.g. `ReactiveProps.tsx` → `PropsReactivityComparison`).
127
+ * Mirrors the Hono reference's `componentName`; omitted for
128
+ * single-export fixtures, which fall back to the default/first export.
129
+ */
130
+ componentName?: string
131
+ }
132
+
133
+ export async function renderMinijinjaComponent(options: RenderOptions): Promise<string> {
134
+ const { source, adapter, props, components, componentName: requestedName } = options
135
+
136
+ // Compile child components first.
137
+ //
138
+ // A child SOURCE FILE may export more components than the parent actually
139
+ // references (e.g. `../icon` exports ~30 icons + a generic `Icon`, but
140
+ // `Checkbox` only imports `CheckIcon`). Some of those unreferenced
141
+ // components legitimately can't lower to Jinja — the generic `Icon` spreads
142
+ // `{...props}` onto CHILD components (`<GitHubIcon {...props}/>`), which has
143
+ // no Jinja dict-splat form (Jinja dict literals can't splat a runtime dict
144
+ // into named entries at a call site). Throwing on those would block a
145
+ // fixture that never renders them. So defer the per-file error gate: collect
146
+ // every component's template + IR up front, then (after the parent compile
147
+ // pins the reachable set) re-generate ONLY the reachable children and throw
148
+ // if any of THOSE error. Mirrors the Xslate/Jinja harness's reachable-children
149
+ // emission (#checkbox).
150
+ const childTemplates: Map<string, { template: string; ir: ComponentIR }> = new Map()
151
+ if (components) {
152
+ for (const [filename, childSource] of Object.entries(components)) {
153
+ const childResult = compileJSX(childSource, filename, { adapter, outputIR: true })
154
+ const childTemplateFiles = childResult.files.filter(f => f.type === 'markedTemplate')
155
+ if (childTemplateFiles.length === 0) throw new Error(`No marked template for ${filename}`)
156
+ const childIrFiles = childResult.files.filter(f => f.type === 'ir')
157
+ if (childIrFiles.length === 0) throw new Error(`No IR output for ${filename}`)
158
+ const childIrs = childIrFiles.map(f => JSON.parse(f.content) as ComponentIR)
159
+ if (childTemplateFiles.length === 1) {
160
+ childTemplates.set(childIrs[0].metadata.componentName, { template: childTemplateFiles[0].content, ir: childIrs[0] })
161
+ } else {
162
+ // Multi-component child source: pair template ↔ IR by basename.
163
+ const childIrsByName = new Map(childIrs.map(i => [i.metadata.componentName, i]))
164
+ for (const tf of childTemplateFiles) {
165
+ const baseName = templateBaseName(tf.path, adapter.extension)
166
+ const matchedIR = childIrsByName.get(baseName) ?? childIrs[0]
167
+ childTemplates.set(matchedIR.metadata.componentName, { template: tf.content, ir: matchedIR })
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ // Compile parent source.
174
+ const result = compileJSX(source, 'component.tsx', { adapter, outputIR: true })
175
+
176
+ const errors = result.errors.filter(e => e.severity === 'error')
177
+ if (errors.length > 0) {
178
+ throw new Error(`Compilation errors:\n${errors.map(e => e.message).join('\n')}`)
179
+ }
180
+
181
+ const templateFiles = result.files.filter(f => f.type === 'markedTemplate')
182
+ if (templateFiles.length === 0) throw new Error('No marked template in compile output')
183
+
184
+ const irFiles = result.files.filter(f => f.type === 'ir')
185
+ if (irFiles.length === 0) throw new Error('No IR output (set outputIR: true)')
186
+ const irs = irFiles.map(f => JSON.parse(f.content) as ComponentIR)
187
+ // Explicit `componentName` wins (multi-export sources pin the render
188
+ // target); otherwise default-export, first inline-exported, first IR.
189
+ // Mirrors the Hono reference so multi-component fixtures render the
190
+ // same export across adapters.
191
+ const ir =
192
+ (requestedName ? irs.find(i => i.metadata.componentName === requestedName) : undefined) ??
193
+ irs.find(i => i.metadata.hasDefaultExport) ??
194
+ irs.find(i => i.metadata.isExported) ??
195
+ irs[0]
196
+
197
+ let templateFile: { content: string } | undefined
198
+ if (templateFiles.length === 1) {
199
+ templateFile = templateFiles[0]
200
+ } else {
201
+ // Multi-component source: split the entry-point template from
202
+ // siblings by pairing each template file to its IR by basename.
203
+ const irsByName = new Map(irs.map(i => [i.metadata.componentName, i]))
204
+ for (const tf of templateFiles) {
205
+ const baseName = templateBaseName(tf.path, adapter.extension)
206
+ const matchedIR = irsByName.get(baseName)
207
+ if (matchedIR === ir) {
208
+ templateFile = tf
209
+ } else if (matchedIR) {
210
+ childTemplates.set(matchedIR.metadata.componentName, { template: tf.content, ir: matchedIR })
211
+ }
212
+ }
213
+ }
214
+ if (!templateFile) throw new Error('No marked template in compile output')
215
+
216
+ // Reachable-children error gate (#checkbox). Now that the entry-point `ir` is
217
+ // pinned, close transitively over its cross-file component imports and verify
218
+ // each reachable child lowers without error — re-generating the child IR
219
+ // through a fresh adapter to attribute errors per component (the aggregate
220
+ // compile errors aren't component-tagged). A child file may export
221
+ // unreferenced components that legitimately can't lower (e.g. `../icon`'s
222
+ // generic `Icon`); those are dropped silently rather than failing a fixture
223
+ // that never renders them.
224
+ {
225
+ const reachable = new Set<string>()
226
+ const queue = [...collectImportedComponentNames(ir)]
227
+ while (queue.length > 0) {
228
+ const name = queue.shift()!
229
+ if (reachable.has(name)) continue
230
+ const entry = childTemplates.get(name)
231
+ if (!entry) continue // in-source sibling or non-compiled import
232
+ reachable.add(name)
233
+ queue.push(...collectImportedComponentNames(entry.ir))
234
+ }
235
+ for (const name of reachable) {
236
+ const entry = childTemplates.get(name)
237
+ if (!entry) continue
238
+ // The child was first compiled WITHOUT `siblingTemplatesRegistered`, so
239
+ // `entry.ir.errors` may already carry suppressible BF103s (cross-template
240
+ // loop references the harness DOES register). Re-generate with siblings
241
+ // registered and inspect ONLY the errors that pass appends — `generate`
242
+ // resets its own error list and appends to `ir.errors`, so anything after
243
+ // the pre-existing count is the authoritative siblings-registered result.
244
+ const before = entry.ir.errors?.length ?? 0
245
+ adapter.generate(entry.ir, { siblingTemplatesRegistered: true })
246
+ const childErrors = (entry.ir.errors ?? [])
247
+ .slice(before)
248
+ .filter(e => e.severity === 'error')
249
+ if (childErrors.length > 0) {
250
+ throw new Error(
251
+ `Compilation errors in reachable child ${name}:\n${childErrors.map(e => e.message).join('\n')}`,
252
+ )
253
+ }
254
+ }
255
+ }
256
+
257
+ const componentName = ir.metadata.componentName
258
+
259
+ if (!(await isCargoAvailable())) {
260
+ throw new RustNotAvailableError('cargo not found — skipping minijinja rendering')
261
+ }
262
+ await ensureBfRenderBuilt()
263
+
264
+ // Build temp directory.
265
+ const tempDir = resolve(
266
+ RENDER_TEMP_DIR,
267
+ `minijinja-${Date.now()}-${Math.random().toString(36).slice(2)}`,
268
+ )
269
+ await mkdir(tempDir, { recursive: true })
270
+
271
+ try {
272
+ // Write `.j2` files (parent + children), named by snake_case so the
273
+ // adapter's `bf.render_child('<snake>', …)` calls + the runtime's
274
+ // `render_named('<snake>', …)` resolve from the dir.
275
+ await Bun.write(resolve(tempDir, `${toSnakeCase(componentName)}.j2`), templateFile.content)
276
+ for (const [childName, { template }] of childTemplates) {
277
+ await Bun.write(resolve(tempDir, `${toSnakeCase(childName)}.j2`), template)
278
+ }
279
+
280
+ // Honour `__instanceId` from props for the root scope id so
281
+ // shared-component fixtures (which pin `<ComponentName>_test`) match
282
+ // cross-adapter; default to 'test' otherwise.
283
+ const rootScopeIdRaw = typeof props?.__instanceId === 'string' ? props.__instanceId : 'test'
284
+
285
+ // Build the JSON payload's `vars` field (a plain JS object — the JSON
286
+ // equivalent of `buildPythonProps`'s Python dict SOURCE, minus the
287
+ // string-building layer JSON.stringify now does for us).
288
+ const vars = buildVars(props, ir)
289
+
290
+ // Build the JSON payload's `children` registration array — metadata
291
+ // only; `bf-render` builds the actual child-renderer closures itself
292
+ // (mirroring `buildChildRenderers`'s per-child logic, ported to Rust).
293
+ const childrenPayload = buildChildrenPayload(childTemplates)
294
+
295
+ const payload: Record<string, unknown> = {
296
+ templates_dir: tempDir,
297
+ entry: toSnakeCase(componentName),
298
+ scope_id: rootScopeIdRaw,
299
+ vars,
300
+ children: childrenPayload,
301
+ }
302
+ // (#1922) Request-scoped `searchParams()`: bind to an empty-query
303
+ // reader only when the component imports `searchParams`, mirroring the
304
+ // Jinja harness's conditional `SearchParams('')` binding.
305
+ if (importsSearchParams(ir.metadata)) {
306
+ payload.search_params = ''
307
+ }
308
+
309
+ await Bun.write(resolve(tempDir, 'payload.json'), JSON.stringify(encodeSpecials(payload)))
310
+
311
+ const proc = Bun.spawn([BF_RENDER_BIN, resolve(tempDir, 'payload.json')], {
312
+ stdout: 'pipe',
313
+ stderr: 'pipe',
314
+ })
315
+
316
+ const [stdout, stderr] = await Promise.all([
317
+ new Response(proc.stdout).text(),
318
+ new Response(proc.stderr).text(),
319
+ ])
320
+
321
+ const exitCode = await proc.exited
322
+ if (exitCode !== 0) {
323
+ throw new Error(`bf-render failed (exit ${exitCode}):\n${stderr}`)
324
+ }
325
+
326
+ return stdout
327
+ } finally {
328
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {})
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Component names a component IR imports from sibling source files — i.e.
334
+ * non-type imports from relative (`./` / `../`) specifiers. Used to compute the
335
+ * transitive set of child components a fixture actually references (#checkbox).
336
+ * Mirrors the Go / Xslate / Jinja harness helper of the same name.
337
+ */
338
+ function collectImportedComponentNames(ir: ComponentIR): string[] {
339
+ const names: string[] = []
340
+ for (const imp of ir.metadata.imports ?? []) {
341
+ if (imp.isTypeOnly) continue
342
+ if (!imp.source.startsWith('.')) continue
343
+ for (const spec of imp.specifiers ?? []) {
344
+ if (spec.isNamespace) continue
345
+ names.push(spec.alias ?? spec.name)
346
+ }
347
+ }
348
+ return names
349
+ }
350
+
351
+ /**
352
+ * Build the JSON payload's `children` array: one entry per child template,
353
+ * carrying exactly the metadata `bf-render` needs to construct a child
354
+ * renderer closure at render time (mirrors the payload protocol in the
355
+ * design doc; see `runtime/src/backend_minijinja.rs`'s `render_child` for
356
+ * the Rust-side closure — the near-verbatim structural counterpart of
357
+ * adapter-jinja's `buildChildRenderers`, which instead emitted Python
358
+ * closure SOURCE per child). `ssr_defaults` mirrors `ssrDefaultsToPy`:
359
+ * only the static fallback `value` of each `{ value, propName?,
360
+ * isRestProps? }` ssrDefaults entry is needed — the child renderer's
361
+ * caller props always win over it.
362
+ */
363
+ function buildChildrenPayload(
364
+ childTemplates: Map<string, { template: string; ir: ComponentIR }>,
365
+ ): Array<{
366
+ name: string
367
+ template: string
368
+ ssr_defaults: Record<string, unknown>
369
+ rest_props_name: string | null
370
+ param_names: string[]
371
+ }> {
372
+ const out: Array<{
373
+ name: string
374
+ template: string
375
+ ssr_defaults: Record<string, unknown>
376
+ rest_props_name: string | null
377
+ param_names: string[]
378
+ }> = []
379
+ for (const [componentName, { ir: childIR }] of childTemplates) {
380
+ const ssrDefaults = extractSsrDefaults(childIR.metadata) ?? {}
381
+ out.push({
382
+ name: componentName,
383
+ template: toSnakeCase(componentName),
384
+ ssr_defaults: ssrDefaultsToVars(ssrDefaults),
385
+ rest_props_name: childIR.metadata.restPropsName ?? null,
386
+ param_names: (childIR.metadata.propsParams ?? []).map(p => p.name),
387
+ })
388
+ }
389
+ return out
390
+ }
391
+
392
+ /** Reduce an ssrDefaults map to its static fallback values (plain JS object). */
393
+ function ssrDefaultsToVars(defaults: Record<string, unknown>): Record<string, unknown> {
394
+ const out: Record<string, unknown> = {}
395
+ for (const [name, d] of Object.entries(defaults)) {
396
+ // ssrDefaults entries are `{ value, propName?, isRestProps? }` or a
397
+ // bare value. The child renderer's caller props win, so we only need
398
+ // the static fallback `value` here.
399
+ out[name] =
400
+ d && typeof d === 'object' && 'value' in (d as Record<string, unknown>)
401
+ ? (d as Record<string, unknown>).value
402
+ : d
403
+ }
404
+ return out
405
+ }
406
+
407
+ /**
408
+ * Convert PascalCase to snake_case for template naming (matches the
409
+ * adapter's `toTemplateName`).
410
+ */
411
+ function toSnakeCase(name: string): string {
412
+ return name
413
+ .replace(/([A-Z])/g, '_$1')
414
+ .toLowerCase()
415
+ .replace(/^_/, '')
416
+ }
417
+
418
+ /**
419
+ * Build the JSON payload's `vars` object (props + signal / memo seeds), a
420
+ * plain JS object — the direct JSON counterpart of adapter-jinja's
421
+ * `buildPythonProps`, minus the Python-dict-SOURCE string-building it did
422
+ * (`pyStr`/`toPyLiteral`); `JSON.stringify` (via `encodeSpecials` for the
423
+ * non-finite-number edge case) does that job here. Unlike
424
+ * `buildPythonProps`, this does NOT include a `scope_id` entry — the
425
+ * payload protocol carries `scope_id` as its own top-level field (see
426
+ * `renderMinijinjaComponent`), so threading a second copy through `vars`
427
+ * would be redundant.
428
+ *
429
+ * Keys are the RAW (unmangled) prop/signal/memo names — same as
430
+ * `buildPythonProps` — because reserved-word mangling is applied
431
+ * backend-side, in ONE place: the Rust runtime's `render_named` (see
432
+ * `lib/minijinja-naming.ts`'s file header, divergence 5).
433
+ */
434
+ function buildVars(
435
+ props: Record<string, unknown> | undefined,
436
+ ir: ComponentIR,
437
+ ): Record<string, unknown> {
438
+ const vars: Record<string, unknown> = {}
439
+
440
+ // Prop params with defaults (before signals, so signals can reference them).
441
+ for (const param of ir.metadata.propsParams) {
442
+ if (props && param.name in props) continue
443
+ if (param.defaultValue) {
444
+ const value = jsDefaultToVarValue(param.defaultValue)
445
+ if (value !== null) {
446
+ vars[param.name] = value
447
+ continue
448
+ }
449
+ }
450
+ // No default + no caller value: pass `null` (Rust's `None`/minijinja
451
+ // Undefined) so a bare reference to an optional prop doesn't fault
452
+ // before its falsy branch elides.
453
+ vars[param.name] = null
454
+ }
455
+
456
+ // Route undeclared props into the rest bag (`bf.spread_attrs($<rest>)`).
457
+ const restPropsName = ir.metadata.restPropsName
458
+ const declaredParams = new Set(ir.metadata.propsParams.map(p => p.name))
459
+ const restBagEntries: Array<[string, unknown]> = []
460
+ if (restPropsName && props) {
461
+ for (const [key, value] of Object.entries(props)) {
462
+ if (key.startsWith('__')) continue
463
+ if (key === restPropsName || declaredParams.has(key)) continue
464
+ restBagEntries.push([key, value])
465
+ }
466
+ }
467
+ const routedKeys = new Set(restBagEntries.map(([k]) => k))
468
+
469
+ if (restPropsName && !(props && restPropsName in props)) {
470
+ vars[restPropsName] = Object.fromEntries(restBagEntries)
471
+ }
472
+
473
+ // User props.
474
+ if (props) {
475
+ for (const [key, value] of Object.entries(props)) {
476
+ if (key.startsWith('__')) continue
477
+ if (routedKeys.has(key)) continue
478
+ if (
479
+ typeof value === 'string' ||
480
+ typeof value === 'number' ||
481
+ typeof value === 'boolean' ||
482
+ Array.isArray(value) ||
483
+ (value && typeof value === 'object')
484
+ ) {
485
+ vars[key] = value
486
+ }
487
+ }
488
+ }
489
+
490
+ // Signal values evaluated from props (after user props).
491
+ for (const signal of ir.metadata.signals) {
492
+ // Env signals (#2057) are bound below via `search_params`, not from a
493
+ // static initial value.
494
+ if (signal.envReader) continue
495
+ const value = evaluateSignalInit(signal.initialValue.trim(), props)
496
+ if (value !== null) {
497
+ vars[signal.getter] = value
498
+ }
499
+ }
500
+
501
+ // Memo values seeded from the statically-evaluated ssrDefaults, same
502
+ // as the production plugin's before_render hook.
503
+ const ssrDefaults = extractSsrDefaults(ir.metadata) ?? {}
504
+ for (const memo of ir.metadata.memos) {
505
+ const entry = ssrDefaults[memo.name]
506
+ const value = entry && typeof entry === 'object' && 'value' in entry ? entry.value : 0
507
+ vars[memo.name] = value ?? 0
508
+ }
509
+
510
+ return vars
511
+ }
512
+
513
+ /**
514
+ * Convert a destructure-default's JS source text (`{ size = 'md' }`'s
515
+ * `'md'`) to a real JS value. Near-verbatim port of `buildPythonProps`'s
516
+ * `jsToPyValue` helper — which returned Python SOURCE text (safe to reuse
517
+ * verbatim for a string/numeric literal, since JS and Python share that
518
+ * literal grammar) — ported to resolve directly to the JS runtime value
519
+ * instead, via the shared `parseLiteral` for the literal shapes both
520
+ * versions handle identically. The match ORDER is preserved from
521
+ * `jsToPyValue`: numeric/string/bool/`[]` are checked BEFORE the `??`
522
+ * regex, so a string literal containing a literal `??` substring (e.g.
523
+ * `'a??b'`) is caught by the string-literal branch first, not
524
+ * mis-parsed as a nullish-coalescing default.
525
+ */
526
+ function jsDefaultToVarValue(jsValue: string): unknown {
527
+ const v = jsValue.trim()
528
+ if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v)
529
+ const strMatch = v.match(/^(['"])(.*)\1$/s)
530
+ if (strMatch) return unescapeJsString(strMatch[2])
531
+ if (v === 'true') return true
532
+ if (v === 'false') return false
533
+ if (v === '[]') return []
534
+ const nullishMatch = v.match(/\?\?\s*(.+)$/)
535
+ if (nullishMatch) return jsDefaultToVarValue(nullishMatch[1])
536
+ if (v.startsWith('props.')) return null
537
+ return null
538
+ }
539
+
540
+ /**
541
+ * Evaluate a signal initializer expression using provided props.
542
+ * Handles: props.initial ?? 0, props.value, literal values.
543
+ */
544
+ export function evaluateSignalInit(
545
+ expr: string,
546
+ props?: Record<string, unknown>,
547
+ ): unknown {
548
+ const nullishMatch = expr.match(/^props\.(\w+)\s*\?\?\s*(.+)$/)
549
+ if (nullishMatch) {
550
+ const propName = nullishMatch[1]
551
+ const defaultExpr = nullishMatch[2].trim()
552
+ if (props && propName in props) return props[propName]
553
+ return parseLiteral(defaultExpr)
554
+ }
555
+
556
+ const propsMatch = expr.match(/^props\.(\w+)$/)
557
+ if (propsMatch) {
558
+ if (props && propsMatch[1] in props) return props[propsMatch[1]]
559
+ return null
560
+ }
561
+
562
+ return parseLiteral(expr)
563
+ }
564
+
565
+ function parseLiteral(expr: string): unknown {
566
+ if (/^-?\d+(\.\d+)?$/.test(expr)) return Number(expr)
567
+ if (expr === 'true') return true
568
+ if (expr === 'false') return false
569
+ if (expr === '[]') return []
570
+
571
+ {
572
+ const t = expr.trim()
573
+ if (t.startsWith('[') && t.endsWith(']')) {
574
+ const inner = t.slice(1, -1).trim()
575
+ if (!inner) return []
576
+ const out: unknown[] = []
577
+ for (const seg of splitTopLevelCommas(inner)) {
578
+ if (!seg.trim()) continue
579
+ const parsed = parseLiteral(seg.trim())
580
+ if (parsed === null && seg.trim() !== 'null') return null
581
+ out.push(parsed)
582
+ }
583
+ return out
584
+ }
585
+ }
586
+
587
+ const stringMatch = expr.match(/^(['"])(.*)\1$/s)
588
+ if (stringMatch) return unescapeJsString(stringMatch[2])
589
+
590
+ const trimmed = expr.trim()
591
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
592
+ const inner = trimmed.slice(1, -1).trim()
593
+ if (!inner) return {}
594
+ const obj: Record<string, unknown> = {}
595
+ for (const pair of splitTopLevelCommas(inner)) {
596
+ if (!pair.trim()) continue
597
+ const colonIdx = pair.indexOf(':')
598
+ if (colonIdx < 0) return null
599
+ let key = pair.slice(0, colonIdx).trim()
600
+ const val = pair.slice(colonIdx + 1).trim()
601
+ const keyMatch = key.match(/^(['"])(.*)\1$/s)
602
+ if (keyMatch) key = unescapeJsString(keyMatch[2])
603
+ const parsedVal = parseLiteral(val)
604
+ if (parsedVal === null && val !== 'null') return null
605
+ obj[key] = parsedVal
606
+ }
607
+ return obj
608
+ }
609
+ return null
610
+ }
611
+
612
+ function splitTopLevelCommas(inner: string): string[] {
613
+ const segments: string[] = []
614
+ let depth = 0
615
+ let start = 0
616
+ let quote: string | null = null
617
+ for (let i = 0; i < inner.length; i++) {
618
+ const c = inner[i]
619
+ if (quote) {
620
+ if (c === quote) {
621
+ let backslashes = 0
622
+ for (let j = i - 1; j >= 0 && inner[j] === '\\'; j--) backslashes++
623
+ if (backslashes % 2 === 0) quote = null
624
+ }
625
+ continue
626
+ }
627
+ if (c === '"' || c === "'") {
628
+ quote = c
629
+ continue
630
+ }
631
+ if (c === '{' || c === '[') depth++
632
+ else if (c === '}' || c === ']') depth--
633
+ else if (c === ',' && depth === 0) {
634
+ segments.push(inner.slice(start, i))
635
+ start = i + 1
636
+ }
637
+ }
638
+ segments.push(inner.slice(start))
639
+ return segments
640
+ }
641
+
642
+ function unescapeJsString(s: string): string {
643
+ return s.replace(/\\(.)/g, (_, c) => {
644
+ switch (c) {
645
+ case 'n': return '\n'
646
+ case 'r': return '\r'
647
+ case 't': return '\t'
648
+ case '0': return '\0'
649
+ default: return c
650
+ }
651
+ })
652
+ }
653
+
654
+ /**
655
+ * Recursively replace JS's non-finite numbers (`NaN`, `Infinity`,
656
+ * `-Infinity`) with the `{"__bf_special": "nan" | "inf" | "-inf"}` sentinel
657
+ * — plain JSON has no way to represent them (`JSON.stringify(NaN)` silently
658
+ * becomes `null`, losing the value). `bf-render` decodes the sentinel back
659
+ * to the corresponding `f64` after `serde_json` parsing (see the design
660
+ * doc's payload protocol). Applied once to the whole payload object before
661
+ * `JSON.stringify` — covers `vars` and every child's `ssr_defaults`
662
+ * uniformly rather than threading the transform through each builder.
663
+ */
664
+ function encodeSpecials(value: unknown): unknown {
665
+ if (typeof value === 'number') {
666
+ if (Number.isNaN(value)) return { __bf_special: 'nan' }
667
+ if (value === Infinity) return { __bf_special: 'inf' }
668
+ if (value === -Infinity) return { __bf_special: '-inf' }
669
+ return value
670
+ }
671
+ if (Array.isArray(value)) return value.map(encodeSpecials)
672
+ if (value && typeof value === 'object') {
673
+ const out: Record<string, unknown> = {}
674
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
675
+ out[k] = encodeSpecials(v)
676
+ }
677
+ return out
678
+ }
679
+ return value
680
+ }