@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.
- package/README.md +194 -0
- package/dist/adapter/analysis/component-tree.d.ts +26 -0
- package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
- package/dist/adapter/boolean-result.d.ts +85 -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/expr/array-method.d.ts +75 -0
- package/dist/adapter/expr/array-method.d.ts.map +1 -0
- package/dist/adapter/expr/emitters.d.ts +143 -0
- package/dist/adapter/expr/emitters.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 +189091 -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 +50 -0
- package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
- package/dist/adapter/lib/minijinja-naming.d.ts +64 -0
- package/dist/adapter/lib/minijinja-naming.d.ts.map +1 -0
- package/dist/adapter/lib/types.d.ts +32 -0
- package/dist/adapter/lib/types.d.ts.map +1 -0
- package/dist/adapter/memo/seed.d.ts +84 -0
- package/dist/adapter/memo/seed.d.ts.map +1 -0
- package/dist/adapter/minijinja-adapter.d.ts +421 -0
- package/dist/adapter/minijinja-adapter.d.ts.map +1 -0
- package/dist/adapter/props/prop-classes.d.ts +33 -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 +28 -0
- package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
- package/dist/build.d.ts +29 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +189111 -0
- package/dist/conformance-pins.d.ts +13 -0
- package/dist/conformance-pins.d.ts.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +189112 -0
- package/package.json +67 -0
- package/runtime/Cargo.lock +124 -0
- package/runtime/Cargo.toml +21 -0
- package/runtime/src/backend_minijinja.rs +176 -0
- package/runtime/src/bin/bf-render.rs +147 -0
- package/runtime/src/evaluator.rs +770 -0
- package/runtime/src/lib.rs +19 -0
- package/runtime/src/manifest.rs +258 -0
- package/runtime/src/num.rs +558 -0
- package/runtime/src/runtime.rs +1548 -0
- package/runtime/src/search_params.rs +173 -0
- package/runtime/tests/eval_vectors.rs +94 -0
- package/runtime/tests/evaluator.rs +407 -0
- package/runtime/tests/helper_vectors.rs +348 -0
- package/runtime/tests/manifest.rs +169 -0
- package/runtime/tests/omit.rs +79 -0
- package/runtime/tests/props_attr.rs +75 -0
- package/runtime/tests/query.rs +50 -0
- package/runtime/tests/render_child.rs +210 -0
- package/runtime/tests/search_params.rs +68 -0
- package/runtime/tests/spread_attrs.rs +94 -0
- package/runtime/tests/template_primitives.rs +376 -0
- package/runtime/tests/vector-divergences.json +33 -0
- package/src/__tests__/minijinja-adapter-unit.test.ts +392 -0
- package/src/__tests__/minijinja-adapter.test.ts +58 -0
- package/src/__tests__/minijinja-counter.test.ts +61 -0
- package/src/__tests__/minijinja-query-href.test.ts +101 -0
- package/src/__tests__/minijinja-spread-attrs.test.ts +227 -0
- package/src/adapter/analysis/component-tree.ts +119 -0
- package/src/adapter/boolean-result.ts +177 -0
- package/src/adapter/emit-context.ts +119 -0
- package/src/adapter/expr/array-method.ts +346 -0
- package/src/adapter/expr/emitters.ts +608 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/lib/constants.ts +37 -0
- package/src/adapter/lib/ir-scope.ts +95 -0
- package/src/adapter/lib/minijinja-naming.ts +85 -0
- package/src/adapter/lib/types.ts +35 -0
- package/src/adapter/memo/seed.ts +135 -0
- package/src/adapter/minijinja-adapter.ts +1796 -0
- package/src/adapter/props/prop-classes.ts +65 -0
- package/src/adapter/spread/spread-codegen.ts +168 -0
- package/src/adapter/value/parsed-literal.ts +76 -0
- package/src/build.ts +38 -0
- package/src/conformance-pins.ts +101 -0
- package/src/index.ts +12 -0
- 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
|
+
}
|