@barefootjs/jinja 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 (88) hide show
  1. package/README.md +66 -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 +84 -0
  5. package/dist/adapter/boolean-result.d.ts.map +1 -0
  6. package/dist/adapter/emit-context.d.ts +106 -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/expr/operand.d.ts +25 -0
  13. package/dist/adapter/expr/operand.d.ts.map +1 -0
  14. package/dist/adapter/index.d.ts +6 -0
  15. package/dist/adapter/index.d.ts.map +1 -0
  16. package/dist/adapter/index.js +189097 -0
  17. package/dist/adapter/jinja-adapter.d.ts +394 -0
  18. package/dist/adapter/jinja-adapter.d.ts.map +1 -0
  19. package/dist/adapter/lib/constants.d.ts +21 -0
  20. package/dist/adapter/lib/constants.d.ts.map +1 -0
  21. package/dist/adapter/lib/ir-scope.d.ts +50 -0
  22. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  23. package/dist/adapter/lib/jinja-naming.d.ts +77 -0
  24. package/dist/adapter/lib/jinja-naming.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 +81 -0
  28. package/dist/adapter/memo/seed.d.ts.map +1 -0
  29. package/dist/adapter/props/prop-classes.d.ts +33 -0
  30. package/dist/adapter/props/prop-classes.d.ts.map +1 -0
  31. package/dist/adapter/spread/spread-codegen.d.ts +61 -0
  32. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  33. package/dist/adapter/value/parsed-literal.d.ts +28 -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 +189117 -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 +189118 -0
  43. package/package.json +66 -0
  44. package/python/VERSION +1 -0
  45. package/python/barefootjs/__init__.py +57 -0
  46. package/python/barefootjs/backend_jinja.py +125 -0
  47. package/python/barefootjs/evaluator.py +787 -0
  48. package/python/barefootjs/runtime.py +1334 -0
  49. package/python/barefootjs/search_params.py +89 -0
  50. package/python/pyproject.toml +32 -0
  51. package/python/tests/__init__.py +0 -0
  52. package/python/tests/test_eval_vectors.py +88 -0
  53. package/python/tests/test_evaluator.py +406 -0
  54. package/python/tests/test_helper_vectors.py +288 -0
  55. package/python/tests/test_omit.py +62 -0
  56. package/python/tests/test_props_attr.py +54 -0
  57. package/python/tests/test_query.py +41 -0
  58. package/python/tests/test_render.py +102 -0
  59. package/python/tests/test_render_child.py +96 -0
  60. package/python/tests/test_search_params.py +50 -0
  61. package/python/tests/test_spread_attrs.py +86 -0
  62. package/python/tests/test_template_primitives.py +347 -0
  63. package/python/tests/vector-divergences.json +42 -0
  64. package/src/__tests__/jinja-adapter-unit.test.ts +390 -0
  65. package/src/__tests__/jinja-adapter.test.ts +53 -0
  66. package/src/__tests__/jinja-counter.test.ts +62 -0
  67. package/src/__tests__/jinja-query-href.test.ts +99 -0
  68. package/src/__tests__/jinja-spread-attrs.test.ts +225 -0
  69. package/src/adapter/analysis/component-tree.ts +119 -0
  70. package/src/adapter/boolean-result.ts +176 -0
  71. package/src/adapter/emit-context.ts +118 -0
  72. package/src/adapter/expr/array-method.ts +346 -0
  73. package/src/adapter/expr/emitters.ts +608 -0
  74. package/src/adapter/expr/operand.ts +35 -0
  75. package/src/adapter/index.ts +6 -0
  76. package/src/adapter/jinja-adapter.ts +1747 -0
  77. package/src/adapter/lib/constants.ts +33 -0
  78. package/src/adapter/lib/ir-scope.ts +95 -0
  79. package/src/adapter/lib/jinja-naming.ts +114 -0
  80. package/src/adapter/lib/types.ts +30 -0
  81. package/src/adapter/memo/seed.ts +132 -0
  82. package/src/adapter/props/prop-classes.ts +65 -0
  83. package/src/adapter/spread/spread-codegen.ts +166 -0
  84. package/src/adapter/value/parsed-literal.ts +76 -0
  85. package/src/build.ts +37 -0
  86. package/src/conformance-pins.ts +101 -0
  87. package/src/index.ts +9 -0
  88. package/src/test-render.ts +714 -0
@@ -0,0 +1,714 @@
1
+ /**
2
+ * Jinja2 template test renderer
3
+ *
4
+ * Compiles JSX source with `JinjaAdapter` and renders the resulting `.jinja`
5
+ * templates to HTML via `python3` + `Jinja2` driven through the bundled
6
+ * `barefootjs` Python runtime (`packages/adapter-jinja/python/barefootjs/`:
7
+ * `runtime.BarefootJS` + `backend_jinja.JinjaBackend`). Used by the
8
+ * adapter-tests conformance runner (`runAdapterConformanceTests`).
9
+ *
10
+ * Near-mechanical port of the sibling Xslate harness
11
+ * (`packages/adapter-xslate/src/test-render.ts`) — same `RenderOptions`
12
+ * contract, same prop / signal / memo seeding order, same multi-component
13
+ * child-renderer registration via the production `register_child_renderer`
14
+ * path (so a child's `bf-s` scope id derives from `<parentScope>_<slotId>`
15
+ * exactly as a real `bf build` page would). Only the target language of the
16
+ * generated render script (Python, not Perl) and its literal syntax differ.
17
+ */
18
+
19
+ import { compileJSX, extractSsrDefaults, importsSearchParams } from '@barefootjs/jsx'
20
+ import type { ComponentIR } from '@barefootjs/jsx'
21
+ import { mkdir, rm } from 'node:fs/promises'
22
+ import { resolve } from 'node:path'
23
+
24
+ const RENDER_TEMP_DIR = resolve(import.meta.dir, '../.render-temp')
25
+ // The bundled Python runtime (`barefootjs.BarefootJS` + `barefootjs.backend_jinja.JinjaBackend`)
26
+ // lives alongside this package (mirrors adapter-go-template bundling `runtime/`
27
+ // in-tree). The render script's `sys.path` must include this directory so
28
+ // `import barefootjs` resolves.
29
+ const PYTHON_DIR = resolve(import.meta.dir, '../python')
30
+
31
+ export class PythonNotAvailableError extends Error {
32
+ constructor(message: string) {
33
+ super(message)
34
+ this.name = 'PythonNotAvailableError'
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Recover the bare component name from a compiler-emitted template file
40
+ * path. `templatesPerComponent` adapters write each component to
41
+ * `<dir>/<ComponentName><adapter.extension>` (Jinja: `.jinja`), and
42
+ * downstream pairing logic needs the raw component name back so it can
43
+ * look up the matching IR in `irsByName`.
44
+ *
45
+ * Exported for testing.
46
+ */
47
+ export function templateBaseName(path: string, extension: string): string {
48
+ const filename = path.substring(path.lastIndexOf('/') + 1)
49
+ return filename.endsWith(extension)
50
+ ? filename.slice(0, -extension.length)
51
+ : filename
52
+ }
53
+
54
+ let _pythonAvailable: boolean | null = null
55
+ async function isJinjaAvailable(): Promise<boolean> {
56
+ if (_pythonAvailable !== null) return _pythonAvailable
57
+ try {
58
+ const proc = Bun.spawn(['python3', '-c', 'import jinja2'], {
59
+ stdout: 'pipe',
60
+ stderr: 'pipe',
61
+ })
62
+ await proc.exited
63
+ _pythonAvailable = proc.exitCode === 0
64
+ } catch {
65
+ _pythonAvailable = false
66
+ }
67
+ return _pythonAvailable
68
+ }
69
+
70
+ export interface RenderOptions {
71
+ /** JSX source code */
72
+ source: string
73
+ /** Template adapter to use */
74
+ adapter: import('@barefootjs/jsx').TemplateAdapter
75
+ /** Props to inject (optional) */
76
+ props?: Record<string, unknown>
77
+ /** Additional component files (filename → source) */
78
+ components?: Record<string, string>
79
+ /**
80
+ * Explicit component to render when `source` declares multiple
81
+ * exports (e.g. `ReactiveProps.tsx` → `PropsReactivityComparison`).
82
+ * Mirrors the Hono reference's `componentName`; omitted for
83
+ * single-export fixtures, which fall back to the default/first export.
84
+ */
85
+ componentName?: string
86
+ }
87
+
88
+ export async function renderJinjaComponent(options: RenderOptions): Promise<string> {
89
+ const { source, adapter, props, components, componentName: requestedName } = options
90
+
91
+ // Compile child components first.
92
+ //
93
+ // A child SOURCE FILE may export more components than the parent actually
94
+ // references (e.g. `../icon` exports ~30 icons + a generic `Icon`, but
95
+ // `Checkbox` only imports `CheckIcon`). Some of those unreferenced
96
+ // components legitimately can't lower to Jinja — the generic `Icon` spreads
97
+ // `{...props}` onto CHILD components (`<GitHubIcon {...props}/>`), which has
98
+ // no Jinja dict-splat form (Jinja dict literals can't splat a runtime dict
99
+ // into named entries at a call site). Throwing on those would block a
100
+ // fixture that never renders them. So defer the per-file error gate: collect
101
+ // every component's template + IR up front, then (after the parent compile
102
+ // pins the reachable set) re-generate ONLY the reachable children and throw
103
+ // if any of THOSE error. Mirrors the Xslate harness's reachable-children
104
+ // emission (#checkbox).
105
+ const childTemplates: Map<string, { template: string; ir: ComponentIR }> = new Map()
106
+ if (components) {
107
+ for (const [filename, childSource] of Object.entries(components)) {
108
+ const childResult = compileJSX(childSource, filename, { adapter, outputIR: true })
109
+ const childTemplateFiles = childResult.files.filter(f => f.type === 'markedTemplate')
110
+ if (childTemplateFiles.length === 0) throw new Error(`No marked template for ${filename}`)
111
+ const childIrFiles = childResult.files.filter(f => f.type === 'ir')
112
+ if (childIrFiles.length === 0) throw new Error(`No IR output for ${filename}`)
113
+ const childIrs = childIrFiles.map(f => JSON.parse(f.content) as ComponentIR)
114
+ if (childTemplateFiles.length === 1) {
115
+ childTemplates.set(childIrs[0].metadata.componentName, { template: childTemplateFiles[0].content, ir: childIrs[0] })
116
+ } else {
117
+ // Multi-component child source: pair template ↔ IR by basename.
118
+ const childIrsByName = new Map(childIrs.map(i => [i.metadata.componentName, i]))
119
+ for (const tf of childTemplateFiles) {
120
+ const baseName = templateBaseName(tf.path, adapter.extension)
121
+ const matchedIR = childIrsByName.get(baseName) ?? childIrs[0]
122
+ childTemplates.set(matchedIR.metadata.componentName, { template: tf.content, ir: matchedIR })
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ // Compile parent source.
129
+ const result = compileJSX(source, 'component.tsx', { adapter, outputIR: true })
130
+
131
+ const errors = result.errors.filter(e => e.severity === 'error')
132
+ if (errors.length > 0) {
133
+ throw new Error(`Compilation errors:\n${errors.map(e => e.message).join('\n')}`)
134
+ }
135
+
136
+ const templateFiles = result.files.filter(f => f.type === 'markedTemplate')
137
+ if (templateFiles.length === 0) throw new Error('No marked template in compile output')
138
+
139
+ const irFiles = result.files.filter(f => f.type === 'ir')
140
+ if (irFiles.length === 0) throw new Error('No IR output (set outputIR: true)')
141
+ const irs = irFiles.map(f => JSON.parse(f.content) as ComponentIR)
142
+ // Explicit `componentName` wins (multi-export sources pin the render
143
+ // target); otherwise default-export, first inline-exported, first IR.
144
+ // Mirrors the Hono reference so multi-component fixtures render the
145
+ // same export across adapters.
146
+ const ir =
147
+ (requestedName ? irs.find(i => i.metadata.componentName === requestedName) : undefined) ??
148
+ irs.find(i => i.metadata.hasDefaultExport) ??
149
+ irs.find(i => i.metadata.isExported) ??
150
+ irs[0]
151
+
152
+ let templateFile: { content: string } | undefined
153
+ if (templateFiles.length === 1) {
154
+ templateFile = templateFiles[0]
155
+ } else {
156
+ // Multi-component source: split the entry-point template from
157
+ // siblings by pairing each template file to its IR by basename.
158
+ const irsByName = new Map(irs.map(i => [i.metadata.componentName, i]))
159
+ for (const tf of templateFiles) {
160
+ const baseName = templateBaseName(tf.path, adapter.extension)
161
+ const matchedIR = irsByName.get(baseName)
162
+ if (matchedIR === ir) {
163
+ templateFile = tf
164
+ } else if (matchedIR) {
165
+ childTemplates.set(matchedIR.metadata.componentName, { template: tf.content, ir: matchedIR })
166
+ }
167
+ }
168
+ }
169
+ if (!templateFile) throw new Error('No marked template in compile output')
170
+
171
+ // Reachable-children error gate (#checkbox). Now that the entry-point `ir` is
172
+ // pinned, close transitively over its cross-file component imports and verify
173
+ // each reachable child lowers without error — re-generating the child IR
174
+ // through a fresh adapter to attribute errors per component (the aggregate
175
+ // compile errors aren't component-tagged). A child file may export
176
+ // unreferenced components that legitimately can't lower (e.g. `../icon`'s
177
+ // generic `Icon`); those are dropped silently rather than failing a fixture
178
+ // that never renders them.
179
+ {
180
+ const reachable = new Set<string>()
181
+ const queue = [...collectImportedComponentNames(ir)]
182
+ while (queue.length > 0) {
183
+ const name = queue.shift()!
184
+ if (reachable.has(name)) continue
185
+ const entry = childTemplates.get(name)
186
+ if (!entry) continue // in-source sibling or non-compiled import
187
+ reachable.add(name)
188
+ queue.push(...collectImportedComponentNames(entry.ir))
189
+ }
190
+ for (const name of reachable) {
191
+ const entry = childTemplates.get(name)
192
+ if (!entry) continue
193
+ // The child was first compiled WITHOUT `siblingTemplatesRegistered`, so
194
+ // `entry.ir.errors` may already carry suppressible BF103s (cross-template
195
+ // loop references the harness DOES register). Re-generate with siblings
196
+ // registered and inspect ONLY the errors that pass appends — `generate`
197
+ // resets its own error list and appends to `ir.errors`, so anything after
198
+ // the pre-existing count is the authoritative siblings-registered result.
199
+ const before = entry.ir.errors?.length ?? 0
200
+ adapter.generate(entry.ir, { siblingTemplatesRegistered: true })
201
+ const childErrors = (entry.ir.errors ?? [])
202
+ .slice(before)
203
+ .filter(e => e.severity === 'error')
204
+ if (childErrors.length > 0) {
205
+ throw new Error(
206
+ `Compilation errors in reachable child ${name}:\n${childErrors.map(e => e.message).join('\n')}`,
207
+ )
208
+ }
209
+ }
210
+ }
211
+
212
+ const componentName = ir.metadata.componentName
213
+
214
+ // Build temp directory.
215
+ const tempDir = resolve(
216
+ RENDER_TEMP_DIR,
217
+ `jinja-${Date.now()}-${Math.random().toString(36).slice(2)}`,
218
+ )
219
+ await mkdir(tempDir, { recursive: true })
220
+
221
+ try {
222
+ // Write `.jinja` files (parent + children), named by snake_case so
223
+ // the adapter's `bf.render_child('<snake>', …)` calls + the backend's
224
+ // `render_named('<snake>', …)` resolve from the dir.
225
+ await Bun.write(resolve(tempDir, `${toSnakeCase(componentName)}.jinja`), templateFile.content)
226
+ for (const [childName, { template }] of childTemplates) {
227
+ await Bun.write(resolve(tempDir, `${toSnakeCase(childName)}.jinja`), template)
228
+ }
229
+
230
+ // Build props dict for Python.
231
+ const propsPy = buildPythonProps(componentName, props, ir)
232
+
233
+ // Honour `__instanceId` from props for the root scope id so
234
+ // shared-component fixtures (which pin `<ComponentName>_test`) match
235
+ // cross-adapter; default to 'test' otherwise.
236
+ const rootScopeIdRaw = typeof props?.__instanceId === 'string' ? props.__instanceId : 'test'
237
+
238
+ // Build child-renderer registration for Python.
239
+ const childRenderers = buildChildRenderers(childTemplates, ir)
240
+
241
+ const renderScript = `import sys
242
+ sys.path.insert(0, ${pyStr(PYTHON_DIR)})
243
+
244
+ import uuid
245
+
246
+ from barefootjs import BarefootJS, SearchParams
247
+ from barefootjs.backend_jinja import JinjaBackend
248
+ from barefootjs.runtime import jinja_ident
249
+
250
+ # Single Jinja2 backend over the temp template dir.
251
+ backend = JinjaBackend(paths=[${pyStr(tempDir)}])
252
+ bf = BarefootJS(None, {'backend': backend})
253
+ # Honour an explicit __instanceId so shared-component fixtures match the
254
+ # scope ids Hono / Go emit; default to 'test'.
255
+ bf._scope_id(${pyStr(rootScopeIdRaw)})
256
+
257
+ props = ${propsPy}
258
+
259
+ ${childRenderers}
260
+ html = backend.render_named(${pyStr(toSnakeCase(componentName))}, bf, props)
261
+ sys.stdout.write(html)
262
+ `
263
+ await Bun.write(resolve(tempDir, 'render.py'), renderScript)
264
+
265
+ if (!(await isJinjaAvailable())) {
266
+ throw new PythonNotAvailableError('python3 with jinja2 not found — skipping Jinja rendering')
267
+ }
268
+
269
+ const proc = Bun.spawn(['python3', 'render.py'], {
270
+ cwd: tempDir,
271
+ stdout: 'pipe',
272
+ stderr: 'pipe',
273
+ })
274
+
275
+ const [stdout, stderr] = await Promise.all([
276
+ new Response(proc.stdout).text(),
277
+ new Response(proc.stderr).text(),
278
+ ])
279
+
280
+ const exitCode = await proc.exited
281
+ if (exitCode !== 0) {
282
+ throw new Error(`python3 render failed (exit ${exitCode}):\n${stderr}`)
283
+ }
284
+
285
+ return stdout
286
+ } finally {
287
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {})
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Component names a component IR imports from sibling source files — i.e.
293
+ * non-type imports from relative (`./` / `../`) specifiers. Used to compute the
294
+ * transitive set of child components a fixture actually references (#checkbox).
295
+ * Mirrors the Go / Xslate harness helper of the same name.
296
+ */
297
+ function collectImportedComponentNames(ir: ComponentIR): string[] {
298
+ const names: string[] = []
299
+ for (const imp of ir.metadata.imports ?? []) {
300
+ if (imp.isTypeOnly) continue
301
+ if (!imp.source.startsWith('.')) continue
302
+ for (const spec of imp.specifiers ?? []) {
303
+ if (spec.isNamespace) continue
304
+ names.push(spec.alias ?? spec.name)
305
+ }
306
+ }
307
+ return names
308
+ }
309
+
310
+ /**
311
+ * Build Python code that registers one child-component renderer per child
312
+ * template via the production `BarefootJS.register_child_renderer`.
313
+ *
314
+ * The closure mirrors the manifest-driven path in `runtime.py`
315
+ * (`register_components_from_manifest`'s `make_renderer`): it derives the
316
+ * child scope id from `<parentScope>_<slotId>` (the parent's
317
+ * `bf.render_child('<name>', {…, '_bf_slot': '<slotId>'})` passes
318
+ * `_bf_slot`), seeds signal / memo / prop defaults from the child IR's
319
+ * `ssrDefaults`, shares the parent's script list, and renders the child
320
+ * `.jinja` through the same backend. Loop children (no `_bf_slot`) fall back
321
+ * to `<ComponentName>_<random>` like the Xslate harness.
322
+ *
323
+ * `child_props` arrives ALREADY keyword-mangled: `bf.render_child` (the
324
+ * runtime method the compiled parent template calls) mangles every prop key
325
+ * via `jinja_ident` before invoking the registered renderer — see
326
+ * `runtime.BarefootJS.render_child`'s docstring. So the rest-bag routing
327
+ * below and the `_bf_slot` / `key` / `children` pops all compare against
328
+ * ALREADY-mangled key spellings; the `keep` set mangles the child's declared
329
+ * param names to match.
330
+ */
331
+ function buildChildRenderers(
332
+ childTemplates: Map<string, { template: string; ir: ComponentIR }>,
333
+ _parentIR: ComponentIR,
334
+ ): string {
335
+ if (childTemplates.size === 0) return ''
336
+
337
+ const lines: string[] = []
338
+ lines.push(`# Register child component renderers`)
339
+
340
+ for (const [componentName, { ir: childIR }] of childTemplates) {
341
+ const snakeName = toSnakeCase(componentName)
342
+ const fnSuffix = snakeName.replace(/[^a-zA-Z0-9_]/g, '_')
343
+ // Statically-derived ssrDefaults the child template's vars seed from
344
+ // (prop defaults + signal / memo initial values), serialised to a
345
+ // Python dict literal.
346
+ const ssrDefaults = extractSsrDefaults(childIR.metadata) ?? {}
347
+ const defaultsPy = ssrDefaultsToPy(ssrDefaults)
348
+ const restPropsName = childIR.metadata.restPropsName
349
+ const paramNames = (childIR.metadata.propsParams ?? []).map(p => p.name)
350
+
351
+ lines.push(`def _make_child_renderer_${fnSuffix}():`)
352
+ lines.push(` _defaults = ${defaultsPy}`)
353
+ lines.push(` def _renderer(child_props, caller_bf=None):`)
354
+ // `caller_bf` is the instance whose template invoked render_child
355
+ // (#1897) — nested children chain their scope/slot identity off it.
356
+ lines.push(` host_scope = caller_bf._scope_id() if caller_bf is not None else bf._scope_id()`)
357
+ if (restPropsName) {
358
+ // A child that destructures a rest bag references it in its template;
359
+ // seed it with an empty dict when the caller didn't pass one so
360
+ // Jinja's var lookup doesn't fault. Route non-param props into the
361
+ // rest bag, mirroring the production runtime's
362
+ // `_derive_stash_from_defaults` isRestProps branch and JSX rest
363
+ // semantics: a caller prop the child didn't destructure belongs in
364
+ // the bag, not as a top-level stash var the template never reads.
365
+ const restKeyPy = pyStr(restPropsName)
366
+ const keepNamesPy = pyList([...new Set([...paramNames, restPropsName, 'children', 'key', '_bf_slot'])])
367
+ lines.push(` _rest_key = jinja_ident(${restKeyPy})`)
368
+ lines.push(` child_props.setdefault(_rest_key, {})`)
369
+ lines.push(` _keep = {jinja_ident(k) for k in ${keepNamesPy}}`)
370
+ lines.push(` for _k in list(child_props.keys()):`)
371
+ lines.push(` if _k not in _keep:`)
372
+ lines.push(` child_props[_rest_key][_k] = child_props.pop(_k)`)
373
+ }
374
+ lines.push(` slot_id = child_props.pop('_bf_slot', None)`)
375
+ lines.push(` child_bf = BarefootJS(None, {'backend': backend})`)
376
+ // JSX `key` (reserved prop) → data-key on the child scope root, for keyed
377
+ // loop reconciliation parity with Hono.
378
+ lines.push(` data_key = child_props.pop('key', None)`)
379
+ lines.push(` if data_key is not None:`)
380
+ lines.push(` child_bf._data_key(data_key)`)
381
+ // A loop child (no slot) gets a fresh `<ComponentName>_<rand>` id per
382
+ // iteration — the PascalCase name is what `normalizeHTML` canonicalises to
383
+ // `<ComponentName>_*`; a slotted child derives from the parent scope.
384
+ lines.push(` if slot_id:`)
385
+ lines.push(` child_bf._scope_id(host_scope + '_' + slot_id)`)
386
+ lines.push(` else:`)
387
+ lines.push(` child_bf._scope_id(${pyStr(componentName)} + '_' + uuid.uuid4().hex[:6])`)
388
+ lines.push(` child_bf._is_child(True)`)
389
+ lines.push(` if slot_id:`)
390
+ lines.push(` child_bf._bf_parent(host_scope)`)
391
+ lines.push(` child_bf._bf_mount(slot_id)`)
392
+ // (#1897) A child template may itself call `bf.render_child(...)`
393
+ // (AccordionTrigger renders ChevronDownIcon) — inside that template
394
+ // `bf` is THIS fresh child instance, whose renderer registry starts
395
+ // empty, so the nested call silently rendered ''. Share the parent's
396
+ // registry so nested child renders resolve.
397
+ lines.push(` child_bf._child_renderers(bf._child_renderers())`)
398
+ lines.push(` child_bf._scripts(bf._scripts())`)
399
+ lines.push(` child_bf._script_seen(bf._script_seen())`)
400
+ // Seed template vars: static ssrDefaults first, caller's props win.
401
+ lines.push(` _vars = {**_defaults, **child_props}`)
402
+ lines.push(` rendered = backend.render_named(${pyStr(snakeName)}, child_bf, _vars)`)
403
+ lines.push(` if isinstance(rendered, str) and rendered.endswith('\\n'):`)
404
+ lines.push(` rendered = rendered[:-1]`)
405
+ lines.push(` return rendered`)
406
+ lines.push(` return _renderer`)
407
+ lines.push(``)
408
+ lines.push(`bf.register_child_renderer(${pyStr(snakeName)}, _make_child_renderer_${fnSuffix}())`)
409
+ lines.push(``)
410
+ }
411
+
412
+ return lines.join('\n')
413
+ }
414
+
415
+ /** Render a JSON string-array as a Python list-of-strings literal. */
416
+ function pyList(names: string[]): string {
417
+ return `[${names.map(pyStr).join(', ')}]`
418
+ }
419
+
420
+ /** Serialise an ssrDefaults map to a Python dict literal. */
421
+ function ssrDefaultsToPy(defaults: Record<string, unknown>): string {
422
+ const entries: string[] = []
423
+ for (const [name, d] of Object.entries(defaults)) {
424
+ // ssrDefaults entries are `{ value, propName?, isRestProps? }` or a
425
+ // bare value. The child renderer's caller props win, so we only need
426
+ // the static fallback `value` here.
427
+ let value: unknown = d
428
+ if (d && typeof d === 'object' && 'value' in (d as Record<string, unknown>)) {
429
+ value = (d as Record<string, unknown>).value
430
+ }
431
+ entries.push(`${pyStr(name)}: ${toPyLiteral(value)}`)
432
+ }
433
+ return `{${entries.join(', ')}}`
434
+ }
435
+
436
+ /**
437
+ * Convert PascalCase to snake_case for template naming (matches the
438
+ * adapter's `toTemplateName`).
439
+ */
440
+ function toSnakeCase(name: string): string {
441
+ return name
442
+ .replace(/([A-Z])/g, '_$1')
443
+ .toLowerCase()
444
+ .replace(/^_/, '')
445
+ }
446
+
447
+ /**
448
+ * Build a Python dict literal from props (+ signal / memo seeds).
449
+ */
450
+ function buildPythonProps(
451
+ _componentName: string,
452
+ props: Record<string, unknown> | undefined,
453
+ ir: ComponentIR,
454
+ ): string {
455
+ const entries: string[] = []
456
+
457
+ const explicitScope = typeof props?.__instanceId === 'string' ? props.__instanceId : 'test'
458
+ entries.push(`${pyStr('scope_id')}: ${pyStr(explicitScope)}`)
459
+
460
+ // Prop params with defaults (before signals, so signals can reference them).
461
+ for (const param of ir.metadata.propsParams) {
462
+ if (props && param.name in props) continue
463
+ if (param.defaultValue) {
464
+ const pyValue = jsToPyValue(param.defaultValue)
465
+ if (pyValue !== null) {
466
+ entries.push(`${pyStr(param.name)}: ${pyValue}`)
467
+ continue
468
+ }
469
+ }
470
+ // No default + no caller value: pass `None` so Jinja's var lookup for
471
+ // an optional prop doesn't fault before its falsy branch elides.
472
+ entries.push(`${pyStr(param.name)}: None`)
473
+ }
474
+
475
+ // Route undeclared props into the rest bag (`bf.spread_attrs($<rest>)`).
476
+ const restPropsName = ir.metadata.restPropsName
477
+ const declaredParams = new Set(ir.metadata.propsParams.map(p => p.name))
478
+ const restBagEntries: Array<[string, unknown]> = []
479
+ if (restPropsName && props) {
480
+ for (const [key, value] of Object.entries(props)) {
481
+ if (key.startsWith('__')) continue
482
+ if (key === restPropsName || declaredParams.has(key)) continue
483
+ restBagEntries.push([key, value])
484
+ }
485
+ }
486
+ const routedKeys = new Set(restBagEntries.map(([k]) => k))
487
+
488
+ if (restPropsName && !(props && restPropsName in props)) {
489
+ entries.push(`${pyStr(restPropsName)}: ${toPyLiteral(Object.fromEntries(restBagEntries))}`)
490
+ }
491
+
492
+ // User props.
493
+ if (props) {
494
+ for (const [key, value] of Object.entries(props)) {
495
+ if (key.startsWith('__')) continue
496
+ if (routedKeys.has(key)) continue
497
+ if (typeof value === 'string') {
498
+ entries.push(`${pyStr(key)}: ${pyStr(value)}`)
499
+ } else if (typeof value === 'number') {
500
+ entries.push(`${pyStr(key)}: ${pyNumber(value)}`)
501
+ } else if (typeof value === 'boolean') {
502
+ entries.push(`${pyStr(key)}: ${value ? 'True' : 'False'}`)
503
+ } else if (Array.isArray(value) || (value && typeof value === 'object')) {
504
+ entries.push(`${pyStr(key)}: ${toPyLiteral(value)}`)
505
+ }
506
+ }
507
+ }
508
+
509
+ // Signal values evaluated from props (after user props).
510
+ for (const signal of ir.metadata.signals) {
511
+ // Env signals (#2057) are bound below via `SearchParams('')`, not from a
512
+ // static initial value.
513
+ if (signal.envReader) continue
514
+ const value = evaluateSignalInit(signal.initialValue.trim(), props)
515
+ if (value !== null) {
516
+ entries.push(`${pyStr(signal.getter)}: ${toPyLiteral(value)}`)
517
+ }
518
+ }
519
+
520
+ // Memo values seeded from the statically-evaluated ssrDefaults, same
521
+ // as the production plugin's before_render hook.
522
+ const ssrDefaults = extractSsrDefaults(ir.metadata) ?? {}
523
+ for (const memo of ir.metadata.memos) {
524
+ const entry = ssrDefaults[memo.name]
525
+ const value = entry && typeof entry === 'object' && 'value' in entry ? entry.value : 0
526
+ entries.push(`${pyStr(memo.name)}: ${toPyLiteral(value ?? 0)}`)
527
+ }
528
+
529
+ // (#1922) Request-scoped `searchParams()`: bind `searchParams` to an
530
+ // empty-query reader (so the render script needn't build one from a real
531
+ // request). The conformance harness issues no query string (the
532
+ // production Flask integration builds this from the request's query), so
533
+ // `.get(k)` resolves to `None` and the author's `?? default` renders. Only
534
+ // when the component imports `searchParams`.
535
+ if (importsSearchParams(ir.metadata)) {
536
+ entries.push(`${pyStr('searchParams')}: SearchParams('')`)
537
+ }
538
+
539
+ return `{${entries.join(', ')}}`
540
+ }
541
+
542
+ /**
543
+ * Evaluate a signal initializer expression using provided props.
544
+ * Handles: props.initial ?? 0, props.value, literal values.
545
+ */
546
+ export function evaluateSignalInit(
547
+ expr: string,
548
+ props?: Record<string, unknown>,
549
+ ): unknown {
550
+ const nullishMatch = expr.match(/^props\.(\w+)\s*\?\?\s*(.+)$/)
551
+ if (nullishMatch) {
552
+ const propName = nullishMatch[1]
553
+ const defaultExpr = nullishMatch[2].trim()
554
+ if (props && propName in props) return props[propName]
555
+ return parseLiteral(defaultExpr)
556
+ }
557
+
558
+ const propsMatch = expr.match(/^props\.(\w+)$/)
559
+ if (propsMatch) {
560
+ if (props && propsMatch[1] in props) return props[propsMatch[1]]
561
+ return null
562
+ }
563
+
564
+ return parseLiteral(expr)
565
+ }
566
+
567
+ function parseLiteral(expr: string): unknown {
568
+ if (/^-?\d+(\.\d+)?$/.test(expr)) return Number(expr)
569
+ if (expr === 'true') return true
570
+ if (expr === 'false') return false
571
+ if (expr === '[]') return []
572
+
573
+ {
574
+ const t = expr.trim()
575
+ if (t.startsWith('[') && t.endsWith(']')) {
576
+ const inner = t.slice(1, -1).trim()
577
+ if (!inner) return []
578
+ const out: unknown[] = []
579
+ for (const seg of splitTopLevelCommas(inner)) {
580
+ if (!seg.trim()) continue
581
+ const parsed = parseLiteral(seg.trim())
582
+ if (parsed === null && seg.trim() !== 'null') return null
583
+ out.push(parsed)
584
+ }
585
+ return out
586
+ }
587
+ }
588
+
589
+ const stringMatch = expr.match(/^(['"])(.*)\1$/s)
590
+ if (stringMatch) return unescapeJsString(stringMatch[2])
591
+
592
+ const trimmed = expr.trim()
593
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
594
+ const inner = trimmed.slice(1, -1).trim()
595
+ if (!inner) return {}
596
+ const obj: Record<string, unknown> = {}
597
+ for (const pair of splitTopLevelCommas(inner)) {
598
+ if (!pair.trim()) continue
599
+ const colonIdx = pair.indexOf(':')
600
+ if (colonIdx < 0) return null
601
+ let key = pair.slice(0, colonIdx).trim()
602
+ const val = pair.slice(colonIdx + 1).trim()
603
+ const keyMatch = key.match(/^(['"])(.*)\1$/s)
604
+ if (keyMatch) key = unescapeJsString(keyMatch[2])
605
+ const parsedVal = parseLiteral(val)
606
+ if (parsedVal === null && val !== 'null') return null
607
+ obj[key] = parsedVal
608
+ }
609
+ return obj
610
+ }
611
+ return null
612
+ }
613
+
614
+ function splitTopLevelCommas(inner: string): string[] {
615
+ const segments: string[] = []
616
+ let depth = 0
617
+ let start = 0
618
+ let quote: string | null = null
619
+ for (let i = 0; i < inner.length; i++) {
620
+ const c = inner[i]
621
+ if (quote) {
622
+ if (c === quote) {
623
+ let backslashes = 0
624
+ for (let j = i - 1; j >= 0 && inner[j] === '\\'; j--) backslashes++
625
+ if (backslashes % 2 === 0) quote = null
626
+ }
627
+ continue
628
+ }
629
+ if (c === '"' || c === "'") {
630
+ quote = c
631
+ continue
632
+ }
633
+ if (c === '{' || c === '[') depth++
634
+ else if (c === '}' || c === ']') depth--
635
+ else if (c === ',' && depth === 0) {
636
+ segments.push(inner.slice(start, i))
637
+ start = i + 1
638
+ }
639
+ }
640
+ segments.push(inner.slice(start))
641
+ return segments
642
+ }
643
+
644
+ function unescapeJsString(s: string): string {
645
+ return s.replace(/\\(.)/g, (_, c) => {
646
+ switch (c) {
647
+ case 'n': return '\n'
648
+ case 'r': return '\r'
649
+ case 't': return '\t'
650
+ case '0': return '\0'
651
+ default: return c
652
+ }
653
+ })
654
+ }
655
+
656
+ /**
657
+ * Python string literal for arbitrary text, via `JSON.stringify`. JSON's
658
+ * string grammar (`\\`, `\"`, `\n`, `\r`, `\t`, `\b`, `\f`, `\uXXXX`) is a
659
+ * faithful subset of Python's double-quoted string-literal escapes, so this
660
+ * is exact — simpler and more robust than a hand-rolled escaper (no
661
+ * character can slip through unescaped).
662
+ */
663
+ function pyStr(s: string): string {
664
+ return JSON.stringify(s)
665
+ }
666
+
667
+ /** Python numeric literal, with JS's NaN/±Infinity mapped to `float(...)` calls. */
668
+ function pyNumber(n: number): string {
669
+ if (Number.isNaN(n)) return "float('nan')"
670
+ if (n === Infinity) return "float('inf')"
671
+ if (n === -Infinity) return "float('-inf')"
672
+ return String(n)
673
+ }
674
+
675
+ function toPyLiteral(value: unknown): string {
676
+ if (typeof value === 'string') return pyStr(value)
677
+ if (typeof value === 'number') return pyNumber(value)
678
+ if (typeof value === 'boolean') return value ? 'True' : 'False'
679
+ if (Array.isArray(value)) {
680
+ return `[${value.map(toPyLiteral).join(', ')}]`
681
+ }
682
+ if (value && typeof value === 'object') {
683
+ const entries: string[] = []
684
+ for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
685
+ entries.push(`${pyStr(key)}: ${toPyLiteral(v)}`)
686
+ }
687
+ return `{${entries.join(', ')}}`
688
+ }
689
+ return 'None'
690
+ }
691
+
692
+ /**
693
+ * Convert a JS literal value to a Python literal.
694
+ * Handles: numbers, strings, booleans, empty arrays, props.xxx ?? default.
695
+ */
696
+ function jsToPyValue(jsValue: string): string | null {
697
+ const v = jsValue.trim()
698
+
699
+ if (/^-?\d+(\.\d+)?$/.test(v)) return v
700
+ // A JS string literal (single- or double-quoted) is, character-for-character,
701
+ // ALSO a valid Python string literal for the common escape sequences both
702
+ // languages share — pass it through verbatim rather than re-quoting.
703
+ if (/^['"].*['"]$/.test(v)) return v
704
+ if (v === 'true') return 'True'
705
+ if (v === 'false') return 'False'
706
+ if (v === '[]') return '[]'
707
+
708
+ const nullishMatch = v.match(/\?\?\s*(.+)$/)
709
+ if (nullishMatch) return jsToPyValue(nullishMatch[1])
710
+
711
+ if (v.startsWith('props.')) return 'None'
712
+
713
+ return null
714
+ }