@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,1747 @@
1
+ /**
2
+ * BarefootJS Jinja2 Template Adapter
3
+ *
4
+ * Generates Jinja2 template files (.jinja) from BarefootJS IR.
5
+ *
6
+ * Near-mechanical port of the Text::Xslate (Kolon) adapter
7
+ * (packages/adapter-xslate/src/adapter/xslate-adapter.ts), itself a
8
+ * near-mechanical port of the Mojolicious EP adapter, from Kolon syntax to
9
+ * Jinja2 syntax. The expression-lowering PIPELINE (JS → scalar values /
10
+ * `bf.helper(...)` calls) is shared in spirit with the Xslate adapter; the
11
+ * surrounding template syntax differs:
12
+ *
13
+ * Kolon `<: EXPR :>` → Jinja `{{ EXPR }}` (HTML-escaped)
14
+ * Kolon `<: EXPR | mark_raw :>` → Jinja `{{ EXPR | safe }}` (raw)
15
+ * Kolon `$bf.method(args)` → Jinja `bf.method(args)`
16
+ * Kolon `$name` → Jinja `name`
17
+ * Kolon `: if (C) { A : } else { B : }` → Jinja `{% if C %}A{% else %}B{% endif %}` (`elsif` → `{% elif %}`)
18
+ * Kolon `: for $arr -> $item { … : }` → Jinja `{% for item in arr %}…{% endfor %}`
19
+ * Kolon `: my $x = e;` → Jinja `{% set x = e %}`
20
+ * Kolon `{ k => v }` hashref → Jinja `{'k': v}` dict literal (ALWAYS quoted key — see `lib/jinja-naming.ts`)
21
+ * Kolon `~` concat → Jinja `~` concat
22
+ * Kolon `//` defined-or → `(l if (l is defined and l is not none) else r)` inline (Jinja has no `//`; the `is defined` guard also treats a context var that was never seeded — Jinja's `ChainableUndefined` — as nullish, matching JS `??`'s null-OR-undefined check — see `expr/emitters.ts`'s `logical` for the full rationale)
23
+ * Kolon macro children capture → Jinja `{% set NAME %}…{% endset %}` set-block (see "Children capture" below)
24
+ *
25
+ * The render `jinja2.Environment` this adapter's output assumes:
26
+ * `autoescape=True`, `undefined=ChainableUndefined`, `FileSystemLoader`,
27
+ * PLUS **`trim_blocks=True, lstrip_blocks=True`** — required because this
28
+ * adapter places `{% … %}` control tags on their own source line (mirroring
29
+ * Kolon's line-statement `:` mode, which consumes its own line for free);
30
+ * without `trim_blocks`/`lstrip_blocks` every such line would leak a stray
31
+ * newline/indentation into the rendered HTML. Templates are named
32
+ * `<snake_case_component>.jinja` (same convention as Xslate's `.tx` files).
33
+ *
34
+ * Divergences beyond the syntax table above (all uniform, not per-fixture —
35
+ * see the individual definition sites for the full rationale):
36
+ *
37
+ * 1. **JS truthiness** (`boolean-result.ts`, `expr/emitters.ts`'s
38
+ * `truthyTest`, this file's `convertConditionToJinja`). Python's `[]` /
39
+ * `{}` are falsy; JS's are truthy. Perl doesn't have this problem (a
40
+ * Perl reference is always true), so Kolon needed no truthy-routing
41
+ * layer. Every condition-TEST position (an `{% if %}` / `{% elif %}`
42
+ * test, a ternary test, the left operand of `&&`/`||`) routes through
43
+ * `bf.truthy(...)` unless it is structurally already boolean-shaped.
44
+ * 2. **Stringification** (`bf.string`, applied at every text/attribute
45
+ * interpolation position). Perl's default scalar stringification is
46
+ * close enough to JS's `String(x)` that Kolon only special-cases
47
+ * explicit `String()` calls and boolean-typed values (routed to
48
+ * `bf.bool_str`). Python's default `str()` diverges further —
49
+ * `str(True)` == `"True"`, `str(1.0)` == `"1.0"`, `str(None)` ==
50
+ * `"None"`, and Jinja's `~` concatenation operator calls `str()` on
51
+ * each operand internally — so this port explicitly routes EVERY
52
+ * text/attribute-position value (not already boolean-routed) through
53
+ * `bf.string(...)` before it reaches Jinja's own escaping/concat
54
+ * machinery. Verified empirically (`'a' ~ true ~ none` → `"aTrueNone"`
55
+ * under plain Jinja) — the reason this wrapping is mandatory, not
56
+ * cosmetic.
57
+ * 3. **No Jinja lambda** (`expr/emitters.ts` header, divergence 2). Kolon's
58
+ * `-> $x { … }` lambda — the Xslate top-level emitter's fallback when a
59
+ * predicate callback can't be serialized to the runtime evaluator's
60
+ * JSON form — has no Jinja equivalent. This adapter uses ONE mechanism
61
+ * for every higher-order callback (the evaluator-JSON `*_eval`
62
+ * payload); an unserializable predicate surfaces `BF101` instead of a
63
+ * lambda fallback. `.sort`'s non-lambda STRUCTURED fallback
64
+ * (`bf.sort` with a `{keys: […]}` descriptor) is unaffected and ports
65
+ * unchanged.
66
+ * 4. **Children/fallback capture via `{% set %}...{% endset %}`, never a
67
+ * macro.** Every Kolon macro-capture site in the ported adapter
68
+ * (`renderComponent`'s children forward, `renderAsync`'s fallback) is
69
+ * invoked immediately, in place, with zero arguments — never reused
70
+ * elsewhere or invoked lazily with different arguments. Jinja's
71
+ * set-block (`{% set NAME %}…{% endset %}`) captures exactly that
72
+ * shape as a `Markup` string (under `autoescape=True`) with no macro
73
+ * indirection needed; the captured name is then referenced bare
74
+ * (`NAME`, not `NAME()`) everywhere the Kolon port called `NAME()`.
75
+ * 5. **Reserved-word identifier mangling** (`lib/jinja-naming.ts`). Every
76
+ * bare Jinja variable reference / `{% set %}` target is passed through
77
+ * `jinjaIdent()`; the Python runtime must apply the IDENTICAL mangling
78
+ * when it builds the per-render context dict (so a prop literally
79
+ * named e.g. `class` is threaded through as context key `'class_'` on
80
+ * both sides). Dict-LITERAL keys are a separate, unconditional
81
+ * concern — see `jinjaHashKey`'s docstring for why they are always
82
+ * quoted (unlike Kolon's bareword-key sugar).
83
+ * 6. **In-template signal/memo self-reference seeding is NOT skipped**
84
+ * (`memo/seed.ts`'s file header) — Jinja's `{% set x = x + 1 %}` safely
85
+ * resolves the right-hand `x` from the enclosing scope (verified
86
+ * empirically), unlike Kolon's `my`-shadowing hazard, so a same-name
87
+ * prop-derived signal/memo IS seeded in-template here (Xslate skips
88
+ * it). Strictly more correct, not merely a port artifact.
89
+ */
90
+
91
+ import type {
92
+ ComponentIR,
93
+ IRNode,
94
+ IRElement,
95
+ IRText,
96
+ IRExpression,
97
+ IRConditional,
98
+ IRLoop,
99
+ IRComponent,
100
+ IRFragment,
101
+ IRSlot,
102
+ IRIfStatement,
103
+ IRProvider,
104
+ IRAsync,
105
+ IRProp,
106
+ IRTemplatePart,
107
+ CompilerError,
108
+ TypeInfo,
109
+ TemplatePrimitiveRegistry,
110
+ IRMetadata,
111
+ } from '@barefootjs/jsx'
112
+ import {
113
+ BaseAdapter,
114
+ type AdapterOutput,
115
+ type AdapterGenerateOptions,
116
+ type TemplateSections,
117
+ type IRNodeEmitter,
118
+ type EmitIRNode,
119
+ type AttrValueEmitter,
120
+ isBooleanAttr,
121
+ parseExpression,
122
+ stringifyParsedExpr,
123
+ exprToString,
124
+ parseProviderObjectLiteral,
125
+ parseStyleObjectEntries,
126
+ isSupported,
127
+ emitParsedExpr,
128
+ emitIRNode,
129
+ emitAttrValue,
130
+ augmentInheritedPropAccesses,
131
+ parseRecordIndexAccess,
132
+ collectModuleStringConsts,
133
+ extractArrowBodyExpression,
134
+ collectContextConsumers,
135
+ isLowerableLoopDestructure,
136
+ type ContextConsumer,
137
+ lookupStaticRecordLiteral,
138
+ searchParamsLocalNames,
139
+ prepareLoweringMatchers,
140
+ queryHrefArgs,
141
+ isValidHelperId,
142
+ sortComparatorFromArrow,
143
+ } from '@barefootjs/jsx'
144
+ import { isAriaBooleanAttr, isBooleanResultExpr, isExplicitStringCall } from './boolean-result.ts'
145
+ import type { ParsedExpr, LoweringMatcher } from '@barefootjs/jsx'
146
+ import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
147
+
148
+ import type { JinjaRenderCtx } from './lib/types.ts'
149
+ import { JINJA_PRIMITIVE_EMIT_MAP } from './lib/constants.ts'
150
+ import {
151
+ jinjaHashKey,
152
+ jinjaIdent,
153
+ escapeJinjaSingleQuoted,
154
+ jinjaAccessorFromSegments,
155
+ } from './lib/jinja-naming.ts'
156
+ import {
157
+ resolveJsxChildrenProp,
158
+ collectRootScopeNodes,
159
+ } from './lib/ir-scope.ts'
160
+ import { renderSortMethod, renderSortEval } from './expr/array-method.ts'
161
+ import { JinjaFilterEmitter, JinjaTopLevelEmitter, truthyTest } from './expr/emitters.ts'
162
+ import type { JinjaEmitContext, JinjaSpreadContext, JinjaMemoContext } from './emit-context.ts'
163
+ import {
164
+ hasClientInteractivity,
165
+ collectImportedLoopChildComponentErrors,
166
+ } from './analysis/component-tree.ts'
167
+ import {
168
+ conditionalSpreadToJinja,
169
+ objectLiteralExprToJinjaDict,
170
+ } from './spread/spread-codegen.ts'
171
+ import {
172
+ generateContextConsumerSeed,
173
+ generateDerivedMemoSeed,
174
+ } from './memo/seed.ts'
175
+ import {
176
+ collectBooleanTypedProps,
177
+ collectNullableOptionalProps,
178
+ collectStringValueNames,
179
+ } from './props/prop-classes.ts'
180
+
181
+ export type { JinjaAdapterOptions } from './lib/types.ts'
182
+ import type { JinjaAdapterOptions } from './lib/types.ts'
183
+
184
+ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRenderCtx> {
185
+ name = 'jinja'
186
+ extension = '.jinja'
187
+ templatesPerComponent = true
188
+ // Template-string target with no component layer: `bf build` emits a static
189
+ // import-map HTML snippet to include into the page <head>.
190
+ importMapInjection = 'html-snippet' as const
191
+
192
+ /**
193
+ * Identifier-path callees the Jinja runtime can render in template scope.
194
+ * The relocate pass consults this map to mark matching calls as
195
+ * template-safe; the SSR template emitter substitutes the JS call with the
196
+ * registered `bf.NAME(...)` helper invocation.
197
+ */
198
+ templatePrimitives: TemplatePrimitiveRegistry = JINJA_PRIMITIVE_EMIT_MAP
199
+
200
+ private componentName: string = ''
201
+ /** Component root scope element(s) — each carries `data-key` for a keyed loop
202
+ * item (set by the child renderer from the JSX `key` prop). A plain element
203
+ * root is one node; an `if-statement` (early-return) root contributes the
204
+ * top element of every branch. */
205
+ private rootScopeNodes: Set<IRNode> = new Set()
206
+ private options: Required<JinjaAdapterOptions>
207
+ private errors: CompilerError[] = []
208
+ private inLoop: boolean = false
209
+ /**
210
+ * SolidJS-style props identifier (`function(props: P)`) and the
211
+ * analyzer-extracted prop names. Stashed at `generate()` entry so the
212
+ * per-attribute `emitSpread` callback can build a propsObject spread bag as
213
+ * an inline Jinja dict literal without re-walking the IR.
214
+ */
215
+ private propsObjectName: string | null = null
216
+ private propsParams: { name: string }[] = []
217
+ private booleanTypedProps: Set<string> = new Set()
218
+ /**
219
+ * Names (signal getters + props) whose value is a string. Carried for
220
+ * parity with the Perl-family adapters (Mojo needs it for `eq`/`ne`
221
+ * selection); the Jinja emitters don't consume it — Jinja's `==`/`!=`
222
+ * compare strings and numbers correctly.
223
+ */
224
+ private stringValueNames: Set<string> = new Set()
225
+
226
+ /**
227
+ * Module-scope pure-string consts (`const x = 'literal'`), keyed by name →
228
+ * unescaped value. A className template literal that references such a const
229
+ * (`className={`${x} ${className}`}`) must inline the literal: the const is
230
+ * module-scope, so it never reaches the per-render context, and a bare
231
+ * reference to `x` would resolve to Undefined.
232
+ */
233
+ private moduleStringConsts: Map<string, string> = new Map()
234
+
235
+ /**
236
+ * (#1922) Local binding names the request-scoped `searchParams()` env signal
237
+ * is imported under (handles `import { searchParams as sp }`). When non-empty
238
+ * the emitter lowers a `<binding>().get(k)` call to a real method call on the
239
+ * per-request `searchParams` reader (`searchParams.get('sort')`) instead of
240
+ * the generic dot deref. Set at `generate()` entry from `ir.metadata.imports`;
241
+ * read by the top-level ParsedExpr emitter.
242
+ */
243
+ private _searchParamsLocals: Set<string> = new Set()
244
+
245
+ /**
246
+ * Call-lowering matchers active for this component (#2057). Bound at
247
+ * `generate()` entry via `prepareLoweringMatchers` and read by the top-level
248
+ * emitter. Covers both userland plugins and the compiler's built-in plugins
249
+ * (e.g. `queryHref` → `bf.query`, #2042) — one uniform path, no per-API branch.
250
+ */
251
+ private _loweringMatchers: LoweringMatcher[] = []
252
+
253
+ /**
254
+ * Local + module constants from the IR, used by the conditional-spread and
255
+ * `Record<staticKeys, scalar>[propKey]` lowering paths (#textarea / #checkbox).
256
+ * Stashed at `generate()` entry so `emitSpread` can resolve a bare local
257
+ * const (`const sizeAttrs = size ? {…} : {}`) to its initializer text.
258
+ */
259
+ private localConstants: IRMetadata['localConstants'] = []
260
+
261
+ /**
262
+ * Optional, no-default props that are `None` when the caller omits them.
263
+ * Their bare-reference attribute emission is guarded with a Jinja
264
+ * `is defined and is not none` test so the attribute DROPS rather than
265
+ * rendering `attr=""` (Hono-style nullish omission, e.g. textarea's
266
+ * `rows`). The filter excludes destructure-defaulted, rest, and
267
+ * concrete-primitive props.
268
+ */
269
+ private nullableOptionalProps: Set<string> = new Set()
270
+
271
+ constructor(options: JinjaAdapterOptions = {}) {
272
+ super()
273
+ this.options = {
274
+ clientJsBasePath: options.clientJsBasePath ?? '/static/components/',
275
+ barefootJsPath: options.barefootJsPath ?? '/static/components/barefoot.js',
276
+ }
277
+ }
278
+
279
+ generate(ir: ComponentIR, options?: AdapterGenerateOptions): AdapterOutput {
280
+ this.componentName = ir.metadata.componentName
281
+ this.propsObjectName = ir.metadata.propsObjectName ?? null
282
+ // (#checkbox) Enumerate the props-object pattern's inherited attribute
283
+ // accesses (`props.className`/`id`/`disabled`) into propsParams via the
284
+ // shared helper, before deriving `nullableOptionalProps` below.
285
+ augmentInheritedPropAccesses(ir)
286
+ this.propsParams = ir.metadata.propsParams.map(p => ({ name: p.name }))
287
+ // Props whose declared TS type is boolean — a bare binding of one
288
+ // (`data-active={props.isActive}`) must stringify as JS
289
+ // `String(boolean)` ("true"/"false"), not Python's `str(bool)`
290
+ // ("True"/"False") (#1897, pagination's data-active).
291
+ this.booleanTypedProps = collectBooleanTypedProps(ir)
292
+ this.localConstants = ir.metadata.localConstants ?? []
293
+ this.nullableOptionalProps = collectNullableOptionalProps(ir)
294
+ this.stringValueNames = collectStringValueNames(ir)
295
+ this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
296
+ this._searchParamsLocals = searchParamsLocalNames(ir.metadata)
297
+ this._loweringMatchers = prepareLoweringMatchers(ir.metadata)
298
+ this.errors = []
299
+ this.childrenCaptureCounter = 0
300
+
301
+ // Mirror of the Xslate adapter's BF103 check: a child component referenced
302
+ // inside a loop body that is imported from a sibling .tsx emits a
303
+ // cross-template `bf.render_child(...)` call that resolves only if the
304
+ // sibling template is registered alongside the parent at render time.
305
+ // Surface it loudly here. Suppressed when the caller guarantees that all
306
+ // sibling templates are registered on the same instance at render time.
307
+ if (!options?.siblingTemplatesRegistered) {
308
+ this.errors.push(...collectImportedLoopChildComponentErrors(ir, this.componentName))
309
+ }
310
+
311
+ this.rootScopeNodes = collectRootScopeNodes(ir.root)
312
+ const templateBody = ir.root.type === 'if-statement'
313
+ ? this.renderIfStatement(ir.root as IRIfStatement)
314
+ : this.renderNode(ir.root)
315
+
316
+ // Generate script registration
317
+ const scriptReg = options?.skipScriptRegistration
318
+ ? ''
319
+ : this.generateScriptRegistrations(ir, options?.scriptBaseName)
320
+
321
+ // SSR context consumers (`const x = useContext(Ctx)`): seed each local
322
+ // from the active provider value (or the `createContext` default). The
323
+ // provider side pushes the value via `emitProvider`. (#1297)
324
+ const ctxSeed = generateContextConsumerSeed(ir)
325
+
326
+ // Prop/signal-derived memos with a `null` static SSR default (e.g.
327
+ // `createMemo(() => props.value * 10)`) are computed in-template from the
328
+ // already-seeded prop/signal vars — mirroring Go's generated child
329
+ // constructor. (#1297)
330
+ const memoSeed = generateDerivedMemoSeed(this.memoCtx, ir)
331
+
332
+ const template = `${scriptReg}${ctxSeed}${memoSeed}${templateBody}\n`
333
+
334
+ // Merge collected errors into IR errors
335
+ if (this.errors.length > 0) {
336
+ ir.errors.push(...this.errors)
337
+ }
338
+
339
+ // Jinja templates have no JS-style imports / types / default-export
340
+ // sections. The `templatesPerComponent` mode emits one file per component
341
+ // using the raw `template` value; sections are populated for contract
342
+ // uniformity so the compiler never has to string-parse the template.
343
+ const sections: TemplateSections = {
344
+ imports: '',
345
+ types: '',
346
+ component: template,
347
+ defaultExport: '',
348
+ }
349
+
350
+ return {
351
+ template,
352
+ sections,
353
+ extension: this.extension,
354
+ }
355
+ }
356
+
357
+ // ===========================================================================
358
+ // Script Registration
359
+ // ===========================================================================
360
+
361
+ private generateScriptRegistrations(ir: ComponentIR, scriptBaseName?: string): string {
362
+ const hasInteractivity = hasClientInteractivity(ir)
363
+ if (!hasInteractivity) return ''
364
+
365
+ const name = scriptBaseName ?? ir.metadata.componentName
366
+ const runtimePath = this.options.barefootJsPath
367
+ const clientJsPath = `${this.options.clientJsBasePath}${name}.client.js`
368
+
369
+ // Unlike Kolon's `:` line marker (which PRINTS a bare statement's value,
370
+ // forcing a throwaway `my` bind so `register_script`'s return value
371
+ // doesn't leak into the HTML), Jinja's `{% set %}` statement tag never
372
+ // prints anything regardless — no throwaway-bind trick is needed here.
373
+ // Distinct names are kept anyway for direct traceability with the Kolon
374
+ // port (Jinja has no restriction on re-`{% set %}`ing the same name).
375
+ const lines: string[] = []
376
+ lines.push(`{% set _bf_reg0 = bf.register_script('${runtimePath}') %}`)
377
+ lines.push(`{% set _bf_reg1 = bf.register_script('${clientJsPath}') %}`)
378
+ lines.push('')
379
+ return lines.join('\n')
380
+ }
381
+
382
+ // ===========================================================================
383
+ // Node Rendering
384
+ // ===========================================================================
385
+
386
+ /**
387
+ * Public entry point for node rendering. Delegates to the shared
388
+ * `IRNodeEmitter` dispatcher; per-kind logic lives in the `IRNodeEmitter`
389
+ * methods below.
390
+ */
391
+ renderNode(node: IRNode): string {
392
+ return emitIRNode<JinjaRenderCtx>(node, this, {} as JinjaRenderCtx)
393
+ }
394
+
395
+ // ===========================================================================
396
+ // IRNodeEmitter implementation (Jinja2)
397
+ // ===========================================================================
398
+
399
+ emitElement(node: IRElement, _ctx: JinjaRenderCtx, _emit: EmitIRNode<JinjaRenderCtx>): string {
400
+ return this.renderElement(node)
401
+ }
402
+
403
+ emitText(node: IRText): string {
404
+ return node.value
405
+ }
406
+
407
+ emitExpression(node: IRExpression): string {
408
+ return this.renderExpression(node)
409
+ }
410
+
411
+ emitConditional(node: IRConditional, _ctx: JinjaRenderCtx, _emit: EmitIRNode<JinjaRenderCtx>): string {
412
+ return this.renderConditional(node)
413
+ }
414
+
415
+ emitLoop(node: IRLoop, _ctx: JinjaRenderCtx, _emit: EmitIRNode<JinjaRenderCtx>): string {
416
+ return this.renderLoop(node)
417
+ }
418
+
419
+ emitComponent(node: IRComponent, _ctx: JinjaRenderCtx, _emit: EmitIRNode<JinjaRenderCtx>): string {
420
+ return this.renderComponent(node)
421
+ }
422
+
423
+ emitFragment(node: IRFragment, _ctx: JinjaRenderCtx, _emit: EmitIRNode<JinjaRenderCtx>): string {
424
+ return this.renderFragment(node)
425
+ }
426
+
427
+ emitSlot(node: IRSlot): string {
428
+ return this.renderSlot(node)
429
+ }
430
+
431
+ emitIfStatement(node: IRIfStatement, _ctx: JinjaRenderCtx, _emit: EmitIRNode<JinjaRenderCtx>): string {
432
+ return this.renderIfStatement(node)
433
+ }
434
+
435
+ emitProvider(node: IRProvider, _ctx: JinjaRenderCtx, _emit: EmitIRNode<JinjaRenderCtx>): string {
436
+ // SSR context propagation (#1297): bracket the children with a
437
+ // provide/revoke pair on the shared controller-stash context stack so a
438
+ // descendant `useContext` consumer reads the value during the same
439
+ // render. Both helpers return '' (empty), so the inline `{{ … }}`
440
+ // expression form discards their output cleanly — no extra whitespace,
441
+ // no line-statement needed inside the element body.
442
+ const value = this.providerValueJinja(node.valueProp)
443
+ const children = this.renderChildren(node.children)
444
+ const name = node.contextName
445
+ return (
446
+ `{{ bf.provide_context('${name}', ${value}) }}` +
447
+ children +
448
+ `{{ bf.revoke_context('${name}') }}`
449
+ )
450
+ }
451
+
452
+ /** Lower a `<Ctx.Provider value>` value prop to a Jinja expression. */
453
+ private providerValueJinja(valueProp: IRProvider['valueProp']): string {
454
+ const v = valueProp.value
455
+ if (v.kind === 'literal') {
456
+ if (typeof v.value === 'string') {
457
+ return `'${escapeJinjaSingleQuoted(v.value)}'`
458
+ }
459
+ if (typeof v.value === 'boolean') return v.value ? 'true' : 'false'
460
+ return String(v.value)
461
+ }
462
+ if (v.kind === 'expression') {
463
+ const dict = this.providerObjectLiteralJinja(v.expr)
464
+ if (dict !== null) return dict
465
+ return this.convertExpressionToJinja(v.expr)
466
+ }
467
+ if (v.kind === 'template') return this.convertTemplateLiteralPartsToJinja(v.parts)
468
+ // Out-of-shape value (spread / jsx-children) — none; consumer defaults.
469
+ return 'none'
470
+ }
471
+
472
+ /**
473
+ * Lower an object-literal provider value (`value={{ open: () => props.open
474
+ * ?? false, onOpenChange: … }}`) to a Jinja dict literal (#1897). The
475
+ * SSR lowering is a per-member snapshot of what a consumer would READ
476
+ * during the same render:
477
+ *
478
+ * - zero-param expression-body arrows are getters — lower the body (the
479
+ * value is fixed for the render, so the call-time indirection drops out)
480
+ * - `on[A-Z]`-named members and function-shaped values are client-only
481
+ * behavior SSR never invokes — lower to `none`
482
+ * - anything else lowers through the normal expression pipeline (so an
483
+ * unsupported getter body still refuses loudly with BF101)
484
+ *
485
+ * Keys keep their JS names verbatim so a consumer-side `ctx.open` access
486
+ * maps onto the same dict key. Returns `null` when the expression is not a
487
+ * plain object literal (spread / computed key) — the caller falls back to
488
+ * the whole-expression path, which refuses those shapes with BF101.
489
+ */
490
+ private providerObjectLiteralJinja(expr: string): string | null {
491
+ const members = parseProviderObjectLiteral(expr.trim())
492
+ if (members === null) return null
493
+ const entries = members.map(m => {
494
+ const key = jinjaHashKey(m.name)
495
+ if (m.kind === 'function' || /^on[A-Z]/.test(m.name)) return `${key}: none`
496
+ const src = m.kind === 'getter' ? m.body : m.expr
497
+ return `${key}: ${this.convertExpressionToJinja(src)}`
498
+ })
499
+ return `{${entries.join(', ')}}`
500
+ }
501
+
502
+ emitAsync(node: IRAsync, _ctx: JinjaRenderCtx, _emit: EmitIRNode<JinjaRenderCtx>): string {
503
+ return this.renderAsync(node)
504
+ }
505
+
506
+ // ===========================================================================
507
+ // Element Rendering
508
+ // ===========================================================================
509
+
510
+ renderElement(element: IRElement): string {
511
+ const tag = element.tag
512
+ const attrs = this.renderAttributes(element)
513
+ const children = this.renderChildren(element.children)
514
+
515
+ let hydrationAttrs = ''
516
+ if (element.needsScope) {
517
+ hydrationAttrs += ` ${this.renderScopeMarker('')}`
518
+ }
519
+ // A root scope element carries `data-key` for a keyed loop item (set on the
520
+ // bf instance by the child renderer from the JSX `key` prop); non-keyed
521
+ // renders add nothing. Mirrors Hono stamping data-key on each loop item's
522
+ // root, including early-return (if-statement) roots. (#1297)
523
+ if (this.rootScopeNodes.has(element) && element.needsScope) {
524
+ hydrationAttrs += ` {{ bf.data_key_attr() | safe }}`
525
+ }
526
+ if (element.slotId) {
527
+ hydrationAttrs += ` ${this.renderSlotMarker(element.slotId)}`
528
+ }
529
+ // Page-lifecycle boundary lowered from `<Region>` (spec/router.md). The id
530
+ // is a deterministic static string (`<file scope>:<index>`), so it emits as
531
+ // a plain literal attribute — no Jinja template tag.
532
+ if (element.regionId) {
533
+ hydrationAttrs += ` ${BF_REGION}="${element.regionId}"`
534
+ }
535
+
536
+ const voidElements = [
537
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
538
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
539
+ ]
540
+
541
+ if (voidElements.includes(tag.toLowerCase())) {
542
+ return `<${tag}${attrs}${hydrationAttrs}>`
543
+ }
544
+
545
+ return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
546
+ }
547
+
548
+ // ===========================================================================
549
+ // Expression Rendering
550
+ // ===========================================================================
551
+
552
+ renderExpression(expr: IRExpression): string {
553
+ if (expr.clientOnly) {
554
+ if (expr.slotId) {
555
+ return `{{ bf.comment("client:${expr.slotId}") | safe }}`
556
+ }
557
+ return ''
558
+ }
559
+
560
+ // Text-position interpolation of a possibly-non-string value — see the
561
+ // file header, divergence 2.
562
+ const jinjaExpr = `bf.string(${this.convertExpressionToJinja(expr.expr)})`
563
+
564
+ if (expr.slotId) {
565
+ return `{{ bf.text_start("${expr.slotId}") | safe }}{{ ${jinjaExpr} }}{{ bf.text_end() | safe }}`
566
+ }
567
+
568
+ return `{{ ${jinjaExpr} }}`
569
+ }
570
+
571
+ // ===========================================================================
572
+ // Conditional Rendering
573
+ // ===========================================================================
574
+
575
+ renderConditional(cond: IRConditional): string {
576
+ if (cond.clientOnly && cond.slotId) {
577
+ return `{{ bf.comment("cond-start:${cond.slotId}") | safe }}{{ bf.comment("cond-end:${cond.slotId}") | safe }}`
578
+ }
579
+
580
+ const condition = this.convertConditionToJinja(cond.condition)
581
+ const whenTrue = this.renderNode(cond.whenTrue)
582
+ const whenFalse = this.renderNodeOrNull(cond.whenFalse)
583
+
584
+ // When slotId is present, add bf-c marker.
585
+ // Use comment markers for fragments (multiple sibling elements), attribute
586
+ // for single elements.
587
+ const isFragmentBranch = cond.whenTrue.type === 'fragment' || cond.whenFalse.type === 'fragment'
588
+ const useCommentMarkers = cond.slotId && isFragmentBranch
589
+
590
+ let markedTrue = whenTrue
591
+ let markedFalse = whenFalse
592
+ if (cond.slotId && !useCommentMarkers) {
593
+ markedTrue = this.addCondMarkerToFirstElement(whenTrue, cond.slotId)
594
+ markedFalse = whenFalse ? this.addCondMarkerToFirstElement(whenFalse, cond.slotId) : whenFalse
595
+ }
596
+
597
+ let result: string
598
+ if (useCommentMarkers) {
599
+ // Fragment branches: use comment markers
600
+ const inner = whenFalse
601
+ ? `\n{% if ${condition} %}\n${whenTrue}\n{% else %}\n${whenFalse}\n{% endif %}\n`
602
+ : `\n{% if ${condition} %}\n${whenTrue}\n{% endif %}\n`
603
+ result = `{{ bf.comment("cond-start:${cond.slotId}") | safe }}${inner}{{ bf.comment("cond-end:${cond.slotId}") | safe }}`
604
+ } else if (markedFalse) {
605
+ result = `\n{% if ${condition} %}\n${markedTrue}\n{% else %}\n${markedFalse}\n{% endif %}\n`
606
+ } else if (cond.slotId) {
607
+ // Conditional with no else: wrap with comment markers for client hydration
608
+ result = `{{ bf.comment("cond-start:${cond.slotId}") | safe }}\n{% if ${condition} %}\n${whenTrue}\n{% endif %}\n{{ bf.comment("cond-end:${cond.slotId}") | safe }}`
609
+ } else {
610
+ result = `\n{% if ${condition} %}\n${whenTrue}\n{% endif %}\n`
611
+ }
612
+
613
+ return result
614
+ }
615
+
616
+ private renderNodeOrNull(node: IRNode): string | null {
617
+ if (node.type === 'expression' && (node.expr === 'null' || node.expr === 'undefined')) {
618
+ return null
619
+ }
620
+ return this.renderNode(node)
621
+ }
622
+
623
+ /**
624
+ * Add bf-c attribute to the first HTML element in a branch.
625
+ * If no element found, wrap with comment markers.
626
+ */
627
+ private addCondMarkerToFirstElement(content: string, condId: string): string {
628
+ // Match first HTML open tag
629
+ const match = content.match(/^(<\w+)([\s>])/)
630
+ if (match) {
631
+ return content.replace(/^(<\w+)([\s>])/, `$1 ${BF_COND}="${condId}"$2`)
632
+ }
633
+ // Fall back to comment markers for non-element content
634
+ return `{{ bf.comment("cond-start:${condId}") | safe }}${content}{{ bf.comment("cond-end:${condId}") | safe }}`
635
+ }
636
+
637
+ // ===========================================================================
638
+ // Loop Rendering
639
+ // ===========================================================================
640
+
641
+ renderLoop(loop: IRLoop): string {
642
+ // clientOnly loops must not render items at SSR time, but must still emit
643
+ // the `loop:`/`/loop:` boundary marker pair (Hono and Go parity) so the
644
+ // client runtime's mapArray() can locate the insertion anchor when
645
+ // hydrating the array. Without the markers, mapArray() resolves
646
+ // anchor = null and appends after sibling markers (#872). The marker id
647
+ // disambiguates sibling `.map()` calls under the same parent (#1087).
648
+ if (loop.clientOnly) {
649
+ return `{{ bf.comment("loop:${loop.markerId}") | safe }}{{ bf.comment("/loop:${loop.markerId}") | safe }}`
650
+ }
651
+
652
+ // Jinja's `{% for item in list %}` binds a single loop variable — it
653
+ // can't natively unpack a tuple the way a Python `for` statement can, so
654
+ // a `.map()` destructure param never lowers to a bare Jinja for-target.
655
+ // Instead, `isLowerableLoopDestructure` (#2087) admits any shape whose
656
+ // bindings resolve to a per-adapter accessor without needing the JS/CSR
657
+ // runtime: fixed bindings at any field/index depth (`{ id }`, `[k, v]`,
658
+ // `{ user: { name } }`), array-rest (`[first, ...tail]`), and
659
+ // object-rest whose every use is a member read (`rest.flag`) or a
660
+ // `{...rest}` spread onto an intrinsic element. Each admitted binding
661
+ // becomes a `{% set %}` local off the per-item var (a native accessor
662
+ // for fixed bindings, `bf.slice`/`bf.omit` for rest), so the body's
663
+ // `id` / `v` / `name` / `rest.flag` all resolve. Still refused (BF104):
664
+ // a bare-value rest use (`String(rest)`, `{rest}` as text, `{...fn(rest)}`),
665
+ // a rest spread onto a component/provider, `.filter().map(destructure)`,
666
+ // and `__bf_`-prefixed binding names (would collide with the synthetic
667
+ // per-item var).
668
+ const destructure = !!(loop.paramBindings && loop.paramBindings.length > 0)
669
+ const supportableDestructure = destructure && isLowerableLoopDestructure(loop)
670
+ if (destructure && !supportableDestructure) {
671
+ this.errors.push({
672
+ code: 'BF104',
673
+ severity: 'error',
674
+ message: `Loop callback uses a destructure pattern (\`${loop.param}\`) that the Jinja adapter cannot lower to a native accessor — Jinja \`for item in list\` binds a single loop variable and this shape needs the actual residual value materialized (e.g. a bare object-rest use, or a rest spread onto a component).`,
675
+ loc: loop.loc ?? { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
676
+ suggestion: {
677
+ message:
678
+ `Options:\n` +
679
+ ` 1. Rename the parameter to a single name and access tuple/object elements directly in the body (e.g. \`entry => entry[0]\` instead of \`([k, v]) => ...\`).\n` +
680
+ ` 2. If using object rest, only read individual fields off it (\`rest.flag\`) or spread it onto an intrinsic element (\`{...rest}\`) — not as a bare value.\n` +
681
+ ` 3. Mark the loop position as @client-only so the destructure runs in JS on the client.\n` +
682
+ ` 4. Move the loop into a primitive that the adapter registers explicitly.`,
683
+ },
684
+ })
685
+ }
686
+
687
+ // A `.map()` loop whose array is a bare identifier bound to a
688
+ // FUNCTION-scope local const with a non-statically-evaluable initializer
689
+ // that reads props/signals (e.g. `const entries =
690
+ // Object.entries(props.x ?? {}).filter(...)`) can't render correctly.
691
+ // Module-scope consts (`isModule`, e.g. `const payments = [...]` at the
692
+ // top of the file) are a DIFFERENT, already-working case — the shared
693
+ // `ssr-defaults.ts` statically evaluates those and seeds them straight
694
+ // into the render context, so a bare `payments` reference resolves for
695
+ // free (data-table demo). Function-scope locals get no such seeding
696
+ // (`ssr-defaults.ts`: "component-scope locals can depend on
697
+ // signals/props and are evaluated lazily elsewhere") — and this
698
+ // adapter's only "elsewhere" is inlining a const's value at its use
699
+ // site (`_resolveLiteralConst`'s numeric/single-quoted-string fast
700
+ // path, or a static-record-literal lookup), never binding one as a
701
+ // `{% set %}` template local. Left unchecked, `{% for item in entries
702
+ // %}` over an unbound name would silently iterate zero times (Jinja's
703
+ // `ChainableUndefined` tolerates it rather than raising) instead of
704
+ // failing loudly. Pre-existing, general limitation, orthogonal to
705
+ // #2087's destructure-binding work — newly reachable in this adapter's
706
+ // test corpus only because the widened destructure gate (#2087 Phase
707
+ // A/B) no longer refuses this fixture's `([emoji, users]) => ...`
708
+ // param first.
709
+ const arrayName = loop.array.trim()
710
+ if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
711
+ const arrayConst = (this.localConstants ?? []).find(c => c.name === arrayName)
712
+ if (arrayConst && !arrayConst.isModule && this._resolveLiteralConst(arrayName) === null) {
713
+ this.errors.push({
714
+ code: 'BF101',
715
+ severity: 'error',
716
+ message: `Loop array \`${arrayName}\` is a local computed value (\`${arrayConst.value}\`) that the Jinja adapter cannot bind as a template variable — only numeric/string-literal locals inline at their use site.`,
717
+ loc: loop.loc ?? { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
718
+ suggestion: {
719
+ message:
720
+ 'Pre-compute the array server-side and pass it as a prop, or mark the loop position as @client-only so it runs in JS on the client.',
721
+ },
722
+ })
723
+ }
724
+ }
725
+
726
+ const rawArray = this.convertExpressionToJinja(loop.array)
727
+ // Apply sort if present: wrap the loop array in the shared `bf.sort`
728
+ // helper, binding the sorted result to a per-iteration local so the
729
+ // helper runs once.
730
+ let array = rawArray
731
+ if (loop.sortComparator) {
732
+ // Evaluator-first (#2018 P3): serialize the comparator arrow body + emit
733
+ // `bf.sort_eval`; fall back to the structured `bf.sort` for a
734
+ // comparator the evaluator can't model (e.g. `localeCompare`). The
735
+ // comparator now arrives as an `IRLoopSort` carrying the generic
736
+ // `arrow` + its params.
737
+ const sort = loop.sortComparator
738
+ const sortEmit = (e: ParsedExpr) => this.convertExpressionToJinja('', e)
739
+ const arrow = sort.arrow
740
+ const params =
741
+ arrow.kind === 'arrow' ? arrow.params : [sort.paramA, sort.paramB]
742
+ const structured = sortComparatorFromArrow(arrow)
743
+ array =
744
+ renderSortEval(rawArray, arrow.kind === 'arrow' ? arrow.body : arrow, params, sortEmit) ??
745
+ (structured !== null ? renderSortMethod(rawArray, structured) : rawArray)
746
+ }
747
+ const param = loop.param
748
+ // Jinja's `{% for item in array %}` binds the item directly. The index,
749
+ // when needed (`.keys().map(k => ...)` or an explicit `index` param),
750
+ // comes from Jinja's own loop object (`loop.index0`, 0-based) — no
751
+ // Kolon-style `$~loopvar.index` indirection needed.
752
+ const renderedChildren = this.renderChildren(loop.children)
753
+
754
+ // For `keys`-shape iterations the callback param IS the index. We iterate
755
+ // the array but bind the loop var to a throwaway and expose the index as
756
+ // the param name via Jinja's built-in `loop.index0`.
757
+ const loopVar = loop.iterationShape === 'keys'
758
+ ? '__bf_item'
759
+ : supportableDestructure ? '__bf_item' : param
760
+
761
+ // Index alias: when an explicit `index` param is present (`.map((x, i) =>
762
+ // ...)`) or the iteration is `keys`-shaped, expose it via a `{% set %}`
763
+ // local bound to Jinja's `loop.index0`. A supported destructure param
764
+ // adds one `{% set %}` local per binding, built from the binding's
765
+ // structured `segments` path (#2087 Phase B — never string-parse
766
+ // `b.path`):
767
+ //
768
+ // - fixed binding: a native accessor walking `segments` off the
769
+ // per-item var (`.field` / `[index]`, any depth).
770
+ // - array-rest binding: `bf.slice(<parent accessor>, from, none)` —
771
+ // the exact JS slice, so every read of the binding (including
772
+ // `.length` via the shared member emitter's `bf.length` routing)
773
+ // matches JS semantics with no adapter-side special-casing.
774
+ // - object-rest binding: `bf.omit(<parent accessor>, [<excluded
775
+ // sibling keys>])` — a TRUE residual dict (not an alias to the
776
+ // whole item), so `rest.flag` member reads and the existing
777
+ // `{...rest}` → `bf.spread_attrs(rest)` emit path both see only the
778
+ // keys NOT already destructured.
779
+ const indexLocalLines: string[] = []
780
+ if (loop.iterationShape === 'keys') {
781
+ indexLocalLines.push(`{% set ${jinjaIdent(param)} = loop.index0 %}`)
782
+ } else if (loop.index) {
783
+ indexLocalLines.push(`{% set ${jinjaIdent(loop.index)} = loop.index0 %}`)
784
+ }
785
+ if (supportableDestructure) {
786
+ for (const b of loop.paramBindings ?? []) {
787
+ const parentAccessor = jinjaAccessorFromSegments(jinjaIdent(loopVar), b.segments ?? [])
788
+ if (!b.rest) {
789
+ indexLocalLines.push(`{% set ${jinjaIdent(b.name)} = ${parentAccessor} %}`)
790
+ } else if (b.rest.kind === 'array') {
791
+ indexLocalLines.push(
792
+ `{% set ${jinjaIdent(b.name)} = bf.slice(${parentAccessor}, ${b.rest.from}, none) %}`,
793
+ )
794
+ } else {
795
+ const excludeKeys = b.rest.exclude.map(k => jinjaHashKey(k.key)).join(', ')
796
+ indexLocalLines.push(
797
+ `{% set ${jinjaIdent(b.name)} = bf.omit(${parentAccessor}, [${excludeKeys}]) %}`,
798
+ )
799
+ }
800
+ }
801
+ }
802
+
803
+ const prevInLoop = this.inLoop
804
+ this.inLoop = true
805
+ // Re-render children now that inLoop is set (so nested components use the
806
+ // loop-child naming convention). renderedChildren above was computed with
807
+ // the previous flag; recompute under the loop flag.
808
+ const childrenUnderLoop = this.renderChildren(loop.children)
809
+ this.inLoop = prevInLoop
810
+ void renderedChildren
811
+
812
+ // Whole-item conditional: prepend an always-present `<!--bf-loop-i:KEY-->`
813
+ // anchor before each item's (possibly empty) conditional content so the
814
+ // client's `mapArrayAnchored` can hydrate every SSR-rendered item by its
815
+ // anchor.
816
+ const bodyChildren =
817
+ loop.bodyIsItemConditional && loop.key
818
+ ? `{{ bf.comment("loop-i:" ~ bf.string(${this.convertExpressionToJinja(loop.key)})) | safe }}\n${childrenUnderLoop}`
819
+ : childrenUnderLoop
820
+
821
+ const lines: string[] = []
822
+ // Scoped per-call-site marker so sibling `.map()`s under the same parent
823
+ // each get their own reconciliation range.
824
+ lines.push(`{{ bf.comment("loop:${loop.markerId}") | safe }}`)
825
+ lines.push(`{% for ${jinjaIdent(loopVar)} in ${array} %}`)
826
+ for (const il of indexLocalLines) lines.push(il)
827
+
828
+ // Handle filter().map() pattern by wrapping children in if-condition
829
+ if (loop.filterPredicate) {
830
+ let filterCond: string
831
+ if (loop.filterPredicate.predicate) {
832
+ filterCond = this.renderJinjaFilterExpr(
833
+ loop.filterPredicate.predicate,
834
+ loop.filterPredicate.param
835
+ )
836
+ // See the file header, divergence 1: the loop-hoist filter test is a
837
+ // condition position too.
838
+ filterCond = truthyTest(loop.filterPredicate.predicate, filterCond)
839
+ } else {
840
+ filterCond = 'true'
841
+ }
842
+ // Map filter param to loop param (e.g., t → todo). Word-boundary
843
+ // rename over the RENDERED text — same mechanism Kolon uses (there
844
+ // scoped to `$`-sigiled tokens; here scoped by plain word boundaries,
845
+ // since Jinja identifiers have no sigil). Bounded, pre-existing risk:
846
+ // see `lib/ir-scope.ts`'s file header for the general sigil-less
847
+ // text-scan caveat.
848
+ if (loop.filterPredicate.param !== param) {
849
+ filterCond = filterCond.replace(
850
+ new RegExp(`\\b${loop.filterPredicate.param}\\b`, 'g'),
851
+ jinjaIdent(param)
852
+ )
853
+ }
854
+ lines.push(`{% if ${filterCond} %}`)
855
+ lines.push(bodyChildren)
856
+ lines.push(`{% endif %}`)
857
+ } else {
858
+ lines.push(bodyChildren)
859
+ }
860
+
861
+ lines.push(`{% endfor %}`)
862
+ lines.push(`{{ bf.comment("/loop:${loop.markerId}") | safe }}`)
863
+
864
+ return lines.join('\n')
865
+ }
866
+
867
+ // ===========================================================================
868
+ // Component Rendering
869
+ // ===========================================================================
870
+
871
+ /**
872
+ * AttrValue lowering for component invocation props (Jinja dict-entry
873
+ * form). Jinja CANNOT splat a dict into positional args, so every prop is
874
+ * emitted as a `'key': value` entry that the caller collects into ONE dict
875
+ * literal passed to `bf.render_child(name, { ... })`.
876
+ *
877
+ * `jsx-children` returns empty — children are captured via a Jinja
878
+ * set-block below, not threaded through the dict entry list.
879
+ */
880
+ private readonly componentPropEmitter: AttrValueEmitter = {
881
+ emitLiteral: (value, name) => `${jinjaHashKey(name)}: '${escapeJinjaSingleQuoted(value.value)}'`,
882
+ emitExpression: (value, name) => {
883
+ if (value.parts) {
884
+ return `${jinjaHashKey(name)}: ${this.convertTemplateLiteralPartsToJinja(value.parts)}`
885
+ }
886
+ // Inline object-literal child prop (carousel's `opts={{ align: 'start' }}`):
887
+ // lower to a Jinja dict so the child can serialize it (`data-opts`),
888
+ // instead of refusing the bare object with BF101. (#1971) Read the
889
+ // IR-carried structured `ParsedExpr` tree (#2018) instead of
890
+ // re-parsing `value.expr`; the lowering returns null for any
891
+ // non-object-literal shape, so the common non-object case falls
892
+ // straight through to the bare-expression path below.
893
+ if (value.parsed) {
894
+ const dict = objectLiteralExprToJinjaDict(this.spreadCtx, value.parsed)
895
+ if (dict !== null) return `${jinjaHashKey(name)}: ${dict}`
896
+ }
897
+ return `${jinjaHashKey(name)}: ${this.convertExpressionToJinja(value.expr)}`
898
+ },
899
+ emitSpread: (value) => {
900
+ // Jinja dicts can't be splatted into the entry list the way `**`
901
+ // flattens Python kwargs into a call literal. `renderComponent`
902
+ // handles EVERY spread shape itself (both the enumerated propsObject
903
+ // case and the general nested `dict(base, **spread)` fold — see its
904
+ // own docstring), so this callback is never reached for `kind:
905
+ // 'spread'` props; it only exists to satisfy the `AttrValueEmitter`
906
+ // interface.
907
+ return this.convertExpressionToJinja(value.expr)
908
+ },
909
+ emitTemplate: (value, name) =>
910
+ `${jinjaHashKey(name)}: ${this.convertTemplateLiteralPartsToJinja(value.parts)}`,
911
+ emitBooleanAttr: (_value, name) => `${jinjaHashKey(name)}: true`,
912
+ emitBooleanShorthand: (_value, name) => `${jinjaHashKey(name)}: true`,
913
+ // JSX children flow through the Jinja set-block capture below; they're
914
+ // not part of the dict entry list.
915
+ emitJsxChildren: () => '',
916
+ }
917
+
918
+ /**
919
+ * A `renderComponent` props dict, built as an ORDERED sequence of
920
+ * segments so `{...before, ...spread, after: 1}` JSX spread semantics
921
+ * (later entries win) survive the trip through Jinja, which has no
922
+ * dict-splat syntax for anything past a SINGLE `**` per `dict(...)`
923
+ * call. Each `'entries'` segment is a literal Jinja dict `{'k': v, ...}`;
924
+ * each `'spread'` segment is an arbitrary expression lowered from a
925
+ * `{...expr}` prop. `combineComponentPropSegments` folds the sequence
926
+ * into ONE expression via nested `dict(base, **top)` calls (later
927
+ * segment wins on key conflict, matching `Object.assign`/JSX order).
928
+ */
929
+ private componentPropSegmentEntries(
930
+ segments: Array<{ kind: 'entries'; parts: string[] } | { kind: 'spread'; expr: string }>,
931
+ ): string[] {
932
+ const last = segments[segments.length - 1]
933
+ if (last && last.kind === 'entries') return last.parts
934
+ const seg = { kind: 'entries' as const, parts: [] as string[] }
935
+ segments.push(seg)
936
+ return seg.parts
937
+ }
938
+
939
+ /**
940
+ * Fold ordered prop segments into a single Jinja expression via nested
941
+ * `dict(base, **top)` calls — CPython's `dict()` builtin raises
942
+ * `TemplateSyntaxError` on MORE THAN ONE `**` per call, so a flat
943
+ * `dict(**a, **b, **c)` is not an option; each successive segment wraps
944
+ * the accumulator as the positional `base` with the new segment
945
+ * `**`-unpacked on top, later argument wins on key conflict — exactly
946
+ * like `{...a, ...b}`. A spread segment's expression is wrapped
947
+ * `(EXPR or {})` before unpacking: Jinja's `ChainableUndefined` lets a
948
+ * missing bag (e.g. `children.props` when `children` was never passed)
949
+ * chain through member access without raising, but `**`-unpacking an
950
+ * `Undefined`/`None` value still fails, so the `or {}` guard normalises
951
+ * it to an empty dict first (verified against real jinja2 3.1.6).
952
+ * Empty `'entries'` segments are dropped so a leading/trailing spread
953
+ * doesn't drag in a needless `dict({}, **...)`. Returns `'{}'` when
954
+ * every segment is empty (no props at all).
955
+ */
956
+ private combineComponentPropSegments(
957
+ segments: ReadonlyArray<{ kind: 'entries'; parts: string[] } | { kind: 'spread'; expr: string }>,
958
+ ): string {
959
+ let acc: string | null = null
960
+ for (const seg of segments) {
961
+ if (seg.kind === 'entries') {
962
+ if (seg.parts.length === 0) continue
963
+ const text = `{${seg.parts.join(', ')}}`
964
+ acc = acc === null ? text : `dict(${acc}, **${text})`
965
+ } else {
966
+ const text = `(${seg.expr} or {})`
967
+ acc = acc === null ? text : `dict(${acc}, **${text})`
968
+ }
969
+ }
970
+ return acc ?? '{}'
971
+ }
972
+
973
+ renderComponent(comp: IRComponent): string {
974
+ type Segment = { kind: 'entries'; parts: string[] } | { kind: 'spread'; expr: string }
975
+ const segments: Segment[] = [{ kind: 'entries', parts: [] }]
976
+ const currentEntries = () => this.componentPropSegmentEntries(segments)
977
+
978
+ for (const p of comp.props) {
979
+ // Skip callback props (onXxx) and `ref` — both are client-only for
980
+ // SSR (Hono renders neither; the client JS wires them at hydration).
981
+ if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
982
+ if (p.value.kind === 'spread') {
983
+ const trimmed = p.value.expr.trim()
984
+ // SolidJS-style props identifier (`function(props: P)`) has no
985
+ // matching runtime dict in Jinja scope — props arrive as a flat
986
+ // set of top-level template vars, so enumerate the
987
+ // analyzer-extracted props params into dict entries instead of
988
+ // treating it as a runtime spread expression.
989
+ if (this.propsObjectName && this.propsObjectName === trimmed) {
990
+ for (const pp of this.propsParams) {
991
+ currentEntries().push(`${jinjaHashKey(pp.name)}: ${jinjaIdent(pp.name)}`)
992
+ }
993
+ continue
994
+ }
995
+ // Every other spread shape (a destructure rest-bag `props`, a
996
+ // member-access bag like `children.props`, an intrinsic-element
997
+ // spread helper's own operand, …) — Jinja dict literals can't
998
+ // splat a runtime dict into named entries at a call site, but a
999
+ // nested `dict(base, **top)` call can fold it into the
1000
+ // accumulated dict at the right ordinal position (CPython's
1001
+ // `dict()` rejects more than one `**` per call, so the fold
1002
+ // nests rather than flattens — see `combineComponentPropSegments`).
1003
+ // No compile-time filtering of onXxx/ref keys out of the runtime
1004
+ // bag (the render contract tolerates them, same as the other
1005
+ // spread-lowering adapters).
1006
+ segments.push({ kind: 'spread', expr: this.convertExpressionToJinja(p.value.expr) })
1007
+ continue
1008
+ }
1009
+ const lowered = emitAttrValue(p.value, this.componentPropEmitter, p.name)
1010
+ if (lowered) currentEntries().push(lowered)
1011
+ }
1012
+ // Pass slot ID so the child renderer can set correct scope ID for
1013
+ // hydration. Skip for loop children — they use ComponentName_random.
1014
+ // Appended to whatever the trailing entries segment is so a spread's
1015
+ // own `_bf_slot`/`children` keys (if any) never win over these
1016
+ // compiler-controlled entries.
1017
+ if (comp.slotId && !this.inLoop) {
1018
+ currentEntries().push(`${jinjaHashKey('_bf_slot')}: '${comp.slotId}'`)
1019
+ }
1020
+ const tplName = this.toTemplateName(comp.name)
1021
+
1022
+ // Resolve the effective children: a nested `<Box>…</Box>` populates
1023
+ // `comp.children`; an attribute-form `<Box children={<jsx/>} />` lands in
1024
+ // a `jsx-children` AttrValue on the corresponding prop.
1025
+ const effectiveChildren: IRNode[] = comp.children.length > 0
1026
+ ? comp.children
1027
+ : resolveJsxChildrenProp(comp.props)
1028
+
1029
+ if (effectiveChildren.length > 0) {
1030
+ // Forward JSX children via a Jinja set-block. The block body is
1031
+ // evaluated in the parent's template scope (signals, conditionals) and
1032
+ // produces the children HTML as a captured `Markup` string; the
1033
+ // captured name is passed as the `children` entry of the
1034
+ // render_child dict. `render_child` materializes it through the
1035
+ // backend before handing it to the child. See the file header,
1036
+ // divergence 4, for why a set-block (not a macro) is the uniform
1037
+ // mechanism here.
1038
+ const prevInLoop = this.inLoop
1039
+ this.inLoop = false
1040
+ const childrenBody = this.renderChildren(effectiveChildren)
1041
+ this.inLoop = prevInLoop
1042
+ const captureName = `bf_children_${comp.slotId ?? 'c' + this.childrenCaptureCounter++}`
1043
+ currentEntries().push(`${jinjaHashKey('children')}: ${captureName}`)
1044
+ const dict = this.combineComponentPropSegments(segments)
1045
+ return `{% set ${captureName} %}${childrenBody}{% endset %}{{ bf.render_child('${tplName}', ${dict}) | safe }}`
1046
+ }
1047
+
1048
+ const isEmpty = segments.every(s => s.kind === 'entries' && s.parts.length === 0)
1049
+ const dictEntries = isEmpty ? '' : `, ${this.combineComponentPropSegments(segments)}`
1050
+ return `{{ bf.render_child('${tplName}'${dictEntries}) | safe }}`
1051
+ }
1052
+
1053
+ private childrenCaptureCounter = 0
1054
+
1055
+ /** Uniquifies the `presenceOrUndefined` temp binding (`bf_puN`) so two
1056
+ * presence-folded attrs in one template don't collide. */
1057
+ private presenceVarCounter = 0
1058
+
1059
+ private toTemplateName(componentName: string): string {
1060
+ // Convert PascalCase to snake_case for template naming.
1061
+ return componentName
1062
+ .replace(/([A-Z])/g, '_$1')
1063
+ .toLowerCase()
1064
+ .replace(/^_/, '')
1065
+ }
1066
+
1067
+ // ===========================================================================
1068
+ // If-Statement (Conditional Return) Rendering
1069
+ // ===========================================================================
1070
+
1071
+ private renderIfStatement(ifStmt: IRIfStatement): string {
1072
+ const condition = this.convertConditionToJinja(ifStmt.condition)
1073
+ const consequent = ifStmt.consequent.type === 'if-statement'
1074
+ ? this.renderIfStatement(ifStmt.consequent as IRIfStatement)
1075
+ : this.renderNode(ifStmt.consequent)
1076
+ let result = `{% if ${condition} %}\n${consequent}\n`
1077
+
1078
+ if (ifStmt.alternate) {
1079
+ if (ifStmt.alternate.type === 'if-statement') {
1080
+ const altResult = this.renderIfStatement(ifStmt.alternate as IRIfStatement)
1081
+ // Replace leading "{% if" with "{% elif"
1082
+ result += altResult.replace(/^\{% if/, '{% elif')
1083
+ } else {
1084
+ const alternate = this.renderNode(ifStmt.alternate)
1085
+ result += `{% else %}\n${alternate}\n`
1086
+ }
1087
+ }
1088
+
1089
+ result += `{% endif %}`
1090
+ return result
1091
+ }
1092
+
1093
+ // ===========================================================================
1094
+ // Fragment & Slot Rendering
1095
+ // ===========================================================================
1096
+
1097
+ private renderFragment(fragment: IRFragment): string {
1098
+ const children = this.renderChildren(fragment.children)
1099
+ if (fragment.needsScopeComment) {
1100
+ return `{{ bf.scope_comment() | safe }}${children}`
1101
+ }
1102
+ return children
1103
+ }
1104
+
1105
+ private renderSlot(_slot: IRSlot): string {
1106
+ // Captured children arrive under the `children` context key (see
1107
+ // renderComponent's set-block capture + render_child call), so the var
1108
+ // is `children`. The content is already-rendered markup, so emit it
1109
+ // as-is via `| safe` — otherwise Jinja's autoescape would entity-escape
1110
+ // the child tags. (The IR producer doesn't currently emit `slot`
1111
+ // nodes — `{children}` lowers to an expression whose captured value is
1112
+ // already raw — so this is defensive correctness for if/when a slot
1113
+ // node is produced.)
1114
+ return `{{ ${jinjaIdent('children')} | safe }}`
1115
+ }
1116
+
1117
+ override renderAsync(node: IRAsync): string {
1118
+ const fallback = this.renderNode(node.fallback)
1119
+ const children = this.renderChildren(node.children)
1120
+ // Capture the fallback into a Jinja set-block and pass its rendered HTML
1121
+ // to `bf.async_boundary`, which wraps it in a `<div bf-async="aX">`
1122
+ // placeholder. Same shape as `renderComponent`'s children capture.
1123
+ const captureName = `bf_async_fallback_${node.id}`
1124
+ return `{% set ${captureName} %}${fallback}{% endset %}{{ bf.async_boundary('${node.id}', ${captureName}) | safe }}\n${children}`
1125
+ }
1126
+
1127
+ // ===========================================================================
1128
+ // Attribute Rendering
1129
+ // ===========================================================================
1130
+
1131
+ /**
1132
+ * AttrValue lowering for intrinsic-element attributes (Jinja).
1133
+ */
1134
+ private readonly elementAttrEmitter: AttrValueEmitter = {
1135
+ emitLiteral: (value, name) => `${name}="${value.value}"`,
1136
+ emitExpression: (value, name) => {
1137
+ // `style={{ … }}` object literal → a CSS string with dynamic values
1138
+ // interpolated, instead of refusing the bare object with BF101 (#1322).
1139
+ if (name === 'style') {
1140
+ const css = this.tryLowerStyleObject(value.expr)
1141
+ if (css !== null) return `style="${css}"`
1142
+ }
1143
+ // Refuse shapes that the lowering pipeline can't represent in Jinja —
1144
+ // tagged-template-literal call expressions (`cn\`base \${tone()}\``).
1145
+ // Same gate as the Xslate adapter.
1146
+ if (this.refuseUnsupportedAttrExpression(value.expr, name)) {
1147
+ return ''
1148
+ }
1149
+ // Hono-style nullish omission: a bare reference to an optional,
1150
+ // no-default prop (`nullableOptionalProps`) is guarded so the
1151
+ // attribute drops instead of rendering `attr=""`. Narrowly scoped to
1152
+ // bare identifiers — member exprs, calls, and concrete/defaulted
1153
+ // props are unaffected.
1154
+ const bareId = value.expr.trim()
1155
+ // Normalize a props-object access (`props.id`) to its bare prop name
1156
+ // (`id`) so the nullable-optional set — keyed by bare name — matches the
1157
+ // SolidJS props-object pattern, not just destructured params.
1158
+ const normalizedBareId =
1159
+ this.propsObjectName && bareId.startsWith(`${this.propsObjectName}.`)
1160
+ ? bareId.slice(this.propsObjectName.length + 1)
1161
+ : bareId
1162
+ if (
1163
+ !isBooleanAttr(name) &&
1164
+ !value.presenceOrUndefined &&
1165
+ /^[A-Za-z_$][\w$]*$/.test(normalizedBareId) &&
1166
+ this.nullableOptionalProps.has(normalizedBareId)
1167
+ ) {
1168
+ const jinja = this.convertExpressionToJinja(value.expr)
1169
+ const body = this.shouldBoolStr(value.expr, name)
1170
+ ? `${name}="{{ bf.bool_str(${jinja}) }}"`
1171
+ : `${name}="{{ bf.string(${jinja}) }}"`
1172
+ // `jinja` is a bare identifier reference for this narrowly-gated
1173
+ // shape, so it doubles as both the guard test and the display
1174
+ // value — same "is defined and is not none" pair the Kolon port's
1175
+ // `defined` check maps to (see `providerValueJinja`'s header for
1176
+ // why `is not none` alone isn't enough: a var missing from context
1177
+ // entirely reads as Undefined, not `none`).
1178
+ return `\n{% if ${jinja} is defined and ${jinja} is not none %}\n${body}\n{% endif %}\n`
1179
+ }
1180
+ if (isBooleanAttr(name)) {
1181
+ // Boolean attributes: render conditionally (present or absent).
1182
+ const jinja = this.convertExpressionToJinja(value.expr)
1183
+ return `{{ ('${name}' if ${this.wrapConditionExpr(value.expr, jinja)} else '') }}`
1184
+ }
1185
+ if (value.presenceOrUndefined) {
1186
+ // `attr={expr || undefined}` on a NON-boolean attribute: Hono
1187
+ // renders the attr with its stringified value when truthy and
1188
+ // omits it otherwise (`aria-disabled={isDisabled() || undefined}`
1189
+ // → `aria-disabled="true"`), so bare presence would diverge.
1190
+ // Route through `bool_str` when the name/shape witnesses a
1191
+ // boolean value, same as the unconditional path below (#1897).
1192
+ // Bind to a temp first so the expression evaluates once, not in
1193
+ // both the guard and the value.
1194
+ const jinja = this.convertExpressionToJinja(value.expr)
1195
+ const tmp = `bf_pu${this.presenceVarCounter++}`
1196
+ const body = this.shouldBoolStr(value.expr, name)
1197
+ ? `${name}="{{ bf.bool_str(${tmp}) }}"`
1198
+ : `${name}="{{ bf.string(${tmp}) }}"`
1199
+ return `\n{% set ${tmp} = ${jinja} %}\n{% if ${this.wrapConditionExpr(value.expr, tmp)} %}\n${body}\n{% endif %}\n`
1200
+ }
1201
+ // `attr={cond ? value : undefined}` OMITS the attribute on the
1202
+ // falsy branch (Hono drops undefined-valued attributes) — wrap the
1203
+ // whole attribute in the condition instead of rendering `attr=""`
1204
+ // (#1897, pagination's `aria-current={props.isActive ? 'page' :
1205
+ // undefined}`). Same parity rule the Go adapter applies.
1206
+ {
1207
+ const m = this.parseUndefinedAlternateTernary(value.expr)
1208
+ if (m) {
1209
+ const cond = this.convertConditionToJinja(m.condition)
1210
+ const val = this.convertExpressionToJinja(m.consequent)
1211
+ return `\n{% if ${cond} %}\n${name}="{{ bf.string(${val}) }}"\n{% endif %}\n`
1212
+ }
1213
+ }
1214
+ // Boolean-result handling: route boolean-shaped values through
1215
+ // `bf.bool_str` so the wire bytes match JS `String(boolean)`. Every
1216
+ // other value is a text-position interpolation — route through
1217
+ // `bf.string` (see the file header, divergence 2).
1218
+ const jinja = this.convertExpressionToJinja(value.expr)
1219
+ if (this.shouldBoolStr(value.expr, name)) {
1220
+ return `${name}="{{ bf.bool_str(${jinja}) }}"`
1221
+ }
1222
+ return `${name}="{{ bf.string(${jinja}) }}"`
1223
+ },
1224
+ emitBooleanAttr: (_value, name) => name,
1225
+ emitTemplate: (value, name) =>
1226
+ `${name}="{{ ${this.convertTemplateLiteralPartsToJinja(value.parts)} }}"`,
1227
+ // Spread attributes (`<div {...attrs()} />`) lower through the
1228
+ // `bf.spread_attrs` runtime helper, mirroring the Xslate adapter.
1229
+ emitSpread: (value) => {
1230
+ if (this.refuseUnsupportedAttrExpression(value.expr, '...')) {
1231
+ return ''
1232
+ }
1233
+ // SolidJS-style props identifier (`(props: P) { <el {...props}/> }`) has
1234
+ // no matching context dict in Jinja scope — props arrive as a flat set
1235
+ // of top-level context vars. Emit an inline dict literal enumerating
1236
+ // the analyzer-extracted props params.
1237
+ const trimmed = value.expr.trim()
1238
+ if (this.propsObjectName && this.propsObjectName === trimmed) {
1239
+ const entries = this.propsParams.map(p =>
1240
+ `${jinjaHashKey(p.name)}: ${jinjaIdent(p.name)}`,
1241
+ )
1242
+ return `{{ bf.spread_attrs({${entries.join(', ')}}) | safe }}`
1243
+ }
1244
+ // Conditional inline-object spread (#textarea):
1245
+ // `{...(COND ? { 'aria-describedby': describedBy } : {})}`
1246
+ // Emit a Jinja inline ternary of dicts — the falsy `{}` branch OMITS
1247
+ // the key (`spread_attrs` does NOT emit empty-dict entries).
1248
+ // Read the spread's IR-carried `ParsedExpr` tree (#2018) instead of
1249
+ // re-parsing `trimmed`.
1250
+ const ternaryDict = conditionalSpreadToJinja(this.spreadCtx, value.parsed)
1251
+ if (ternaryDict !== null) {
1252
+ return `{{ bf.spread_attrs(${ternaryDict}) | safe }}`
1253
+ }
1254
+ // Function-scope local const holding a conditional inline-object
1255
+ // `const sizeAttrs = size ? {…} : {}` then `{...sizeAttrs}`
1256
+ // (#checkbox / icon). Resolve the bare identifier to its initializer text
1257
+ // and route through the same conditional-spread lowering. Only
1258
+ // function-scope (`!isModule`) consts whose value is NOT itself a bare
1259
+ // identifier (loop guard) are considered.
1260
+ if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) {
1261
+ const localConst = (this.localConstants ?? []).find(
1262
+ c => c.name === trimmed && !c.isModule,
1263
+ )
1264
+ if (localConst?.value !== undefined) {
1265
+ const initTrimmed = localConst.value.trim()
1266
+ if (!/^[A-Za-z_$][\w$]*$/.test(initTrimmed)) {
1267
+ // The local const's initializer text isn't carried as a structured
1268
+ // tree on the spread attr, so parse it once via the shared
1269
+ // `parseExpression` (the analyzer's own entry) — not
1270
+ // `ts.createSourceFile` — mirroring go-template's same local-const
1271
+ // resolution path.
1272
+ const resolved = conditionalSpreadToJinja(
1273
+ this.spreadCtx,
1274
+ parseExpression(initTrimmed),
1275
+ )
1276
+ if (resolved !== null) {
1277
+ return `{{ bf.spread_attrs(${resolved}) | safe }}`
1278
+ }
1279
+ }
1280
+ }
1281
+ }
1282
+ const jinjaExpr = this.convertExpressionToJinja(value.expr)
1283
+ return `{{ bf.spread_attrs(${jinjaExpr}) | safe }}`
1284
+ },
1285
+ // Neither variant is legal on intrinsic elements.
1286
+ emitBooleanShorthand: () => '',
1287
+ emitJsxChildren: () => '',
1288
+ }
1289
+
1290
+ /**
1291
+ * Lower a `style={{ … }}` object literal to a CSS string with dynamic values
1292
+ * interpolated as Jinja expressions, e.g. `{ backgroundColor: color }` →
1293
+ * `background-color:{{ bf.string(color) }}`. Returns null when the shape is
1294
+ * unsupported or any value can't be lowered (caller falls through to
1295
+ * BF101). (#1322)
1296
+ */
1297
+ private tryLowerStyleObject(expr: string): string | null {
1298
+ const entries = parseStyleObjectEntries(expr)
1299
+ if (!entries) return null
1300
+ for (const e of entries) {
1301
+ if (e.kind === 'expr' && !isSupported(parseExpression(e.expr)).supported) return null
1302
+ }
1303
+ // The static CSS key + literal value are inlined into a double-quoted
1304
+ // `style="..."` attribute as raw template text, so HTML-attr escape them
1305
+ // (a value like `'"'` would otherwise break the attribute / inject
1306
+ // markup). The dynamic arm's `{{ … }}` is HTML-escaped by Jinja.
1307
+ return entries
1308
+ .map(e =>
1309
+ e.kind === 'literal'
1310
+ ? `${this.escapeAttrText(e.cssKey)}:${this.escapeAttrText(e.value)}`
1311
+ : `${this.escapeAttrText(e.cssKey)}:{{ bf.string(${this.convertExpressionToJinja(e.expr)}) }}`,
1312
+ )
1313
+ .join(';')
1314
+ }
1315
+
1316
+ /** HTML-attribute escape for static text inlined into a `"..."` attribute. */
1317
+ private escapeAttrText(s: string): string {
1318
+ return s
1319
+ .replace(/&/g, '&amp;')
1320
+ .replace(/"/g, '&quot;')
1321
+ .replace(/'/g, '&#39;')
1322
+ .replace(/</g, '&lt;')
1323
+ .replace(/>/g, '&gt;')
1324
+ }
1325
+
1326
+ private renderAttributes(element: IRElement): string {
1327
+ const parts: string[] = []
1328
+
1329
+ for (const attr of element.attrs) {
1330
+ // `/* @client */` attribute bindings are deferred to hydrate: the
1331
+ // client runtime sets/patches the attribute in a mount effect (the
1332
+ // CSR template omits it; ir-to-client-js emits the setAttribute
1333
+ // effect). Skip SSR emission so the server omits the attribute and
1334
+ // the unsupported-expression lowering is never reached for a deferred
1335
+ // predicate (no BF101 / BF102). #1966
1336
+ if (attr.clientOnly) continue
1337
+ // Rewrite JSX special-prop names to their HTML-attribute counterparts.
1338
+ let attrName: string
1339
+ if (attr.name === 'className') attrName = 'class'
1340
+ else if (attr.name === 'key') attrName = 'data-key'
1341
+ else attrName = attr.name
1342
+ const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
1343
+ if (lowered) parts.push(lowered)
1344
+ }
1345
+
1346
+ return parts.length > 0 ? ' ' + parts.join(' ') : ''
1347
+ }
1348
+
1349
+ // ===========================================================================
1350
+ // Hydration Markers
1351
+ // ===========================================================================
1352
+
1353
+ renderScopeMarker(_instanceIdExpr: string): string {
1354
+ // bf-s is the addressable scope id. hydration_attrs adds bf-h / bf-m /
1355
+ // bf-r conditionally; props_attr adds bf-p when props are present.
1356
+ return `bf-s="{{ bf.scope_attr() }}" {{ bf.hydration_attrs() | safe }} {{ bf.props_attr() | safe }}`
1357
+ }
1358
+
1359
+ renderSlotMarker(slotId: string): string {
1360
+ return `${BF_SLOT}="${slotId}"`
1361
+ }
1362
+
1363
+ renderCondMarker(condId: string): string {
1364
+ return `${BF_COND}="${condId}"`
1365
+ }
1366
+
1367
+ // ===========================================================================
1368
+ // Filter Predicate Rendering (ParsedExpr → Jinja)
1369
+ // ===========================================================================
1370
+
1371
+ /**
1372
+ * Convert a ParsedExpr AST to a Jinja expression string for filter
1373
+ * predicates. Wraps the shared ParsedExpr dispatcher with a
1374
+ * `JinjaFilterEmitter` carrying the predicate's loop param and any
1375
+ * block-body local var aliases.
1376
+ */
1377
+ private renderJinjaFilterExpr(
1378
+ expr: ParsedExpr,
1379
+ param: string,
1380
+ localVarMap: Map<string, string> = new Map(),
1381
+ ): string {
1382
+ return emitParsedExpr(
1383
+ expr,
1384
+ new JinjaFilterEmitter(
1385
+ param,
1386
+ localVarMap,
1387
+ n => this._isStringValueName(n),
1388
+ // A nested callback method inside the predicate has no Jinja scalar
1389
+ // form — surface BF101 (#2038) instead of silently degrading it to
1390
+ // its receiver.
1391
+ (message, reason) => this._recordExprBF101(message, reason),
1392
+ ),
1393
+ )
1394
+ }
1395
+
1396
+ // ===========================================================================
1397
+ // Expression Conversion: JS → Jinja
1398
+ // ===========================================================================
1399
+
1400
+ private convertTemplateLiteralPartsToJinja(literalParts: IRTemplatePart[]): string {
1401
+ const parts: string[] = []
1402
+ for (const part of literalParts) {
1403
+ if (part.type === 'string') {
1404
+ parts.push(this.substituteJsInterpolationsToJinja(part.value))
1405
+ } else if (part.type === 'ternary') {
1406
+ const cond = this.convertConditionToJinja(part.condition)
1407
+ parts.push(
1408
+ `('${escapeJinjaSingleQuoted(part.whenTrue)}' if ${cond} else '${escapeJinjaSingleQuoted(part.whenFalse)}')`,
1409
+ )
1410
+ } else if (part.type === 'lookup') {
1411
+ // `${MAP[KEY]}` against a Record<T, string> literal — emit a Jinja
1412
+ // dict literal with an immediate `.get(key, '')` lookup. `.get`'s
1413
+ // explicit default mirrors the go-template adapter's "empty when no
1414
+ // case matches" semantics directly (Jinja's bare `{…}[missing]`
1415
+ // already stringifies to '' too, verified empirically, but `.get`
1416
+ // is the more direct/idiomatic expression of the same contract).
1417
+ const keyExpr = this.convertExpressionToJinja(part.key)
1418
+ const entries = Object.entries(part.cases)
1419
+ .map(([k, v]) => `${jinjaHashKey(k)}: '${escapeJinjaSingleQuoted(v)}'`)
1420
+ .join(', ')
1421
+ parts.push(`bf.string({${entries}}.get(${keyExpr}, ''))`)
1422
+ }
1423
+ }
1424
+ // Join with Jinja string concatenation (`~`). Every term is already a
1425
+ // string (literal or `bf.string(...)`-wrapped), so `~`'s own `str()`
1426
+ // coercion is a no-op here.
1427
+ return parts.length === 1 ? parts[0] : parts.join(' ~ ')
1428
+ }
1429
+
1430
+ /**
1431
+ * Translate `${EXPR}` interpolations in a static template-part string into
1432
+ * Jinja variable references and concatenate them with the surrounding
1433
+ * literal text. Each interpolated (non-literal) segment routes through
1434
+ * `bf.string(...)` — see the file header, divergence 2.
1435
+ */
1436
+ private substituteJsInterpolationsToJinja(s: string): string {
1437
+ const segments: string[] = []
1438
+ const re = /\$\{([^}]+)\}/g
1439
+ let lastIndex = 0
1440
+ let m: RegExpExecArray | null
1441
+ while ((m = re.exec(s)) !== null) {
1442
+ if (m.index > lastIndex) {
1443
+ segments.push(`'${escapeJinjaSingleQuoted(s.slice(lastIndex, m.index))}'`)
1444
+ }
1445
+ segments.push(`bf.string(${this.convertExpressionToJinja(m[1].trim())})`)
1446
+ lastIndex = re.lastIndex
1447
+ }
1448
+ if (lastIndex < s.length) {
1449
+ segments.push(`'${escapeJinjaSingleQuoted(s.slice(lastIndex))}'`)
1450
+ }
1451
+ if (segments.length === 0) return `''`
1452
+ return segments.length === 1 ? segments[0] : `(${segments.join(' ~ ')})`
1453
+ }
1454
+
1455
+ /**
1456
+ * Refuse JS expression shapes that have no idiomatic Jinja representation:
1457
+ * object literals (`style={{...}}`) and tagged-template-literal call
1458
+ * expressions (`cn\`base \${tone()}\``). Records `BF101`. Returns `true`
1459
+ * when the shape was rejected (caller should drop the attribute).
1460
+ */
1461
+ private refuseUnsupportedAttrExpression(expr: string, attrName: string): boolean {
1462
+ let probe = expr.trim()
1463
+ while (probe.startsWith('(')) probe = probe.slice(1).trimStart()
1464
+ const startsAsObjectLiteral = probe.startsWith('{')
1465
+ const hasTaggedTemplate = /[A-Za-z_$][\w$]*\s*`/.test(probe)
1466
+ if (!startsAsObjectLiteral && !hasTaggedTemplate) return false
1467
+ const parsed = parseExpression(expr.trim())
1468
+ const support = isSupported(parsed)
1469
+ if (parsed.kind !== 'unsupported' && support.supported) return false
1470
+ const reason = support.reason ?? (parsed.kind === 'unsupported' ? parsed.reason : undefined)
1471
+ const reasonLine = reason ? `\n${reason}` : ''
1472
+ this.errors.push({
1473
+ code: 'BF101',
1474
+ severity: 'error',
1475
+ message: `Expression not supported on attribute '${attrName}': ${expr.trim()}${reasonLine}`,
1476
+ loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1477
+ suggestion: {
1478
+ message: 'The Jinja adapter cannot lower JS object literals or tagged-template-literal expressions into Jinja. Move the expression into a `\'use client\'` component (so hydration computes it), or expand it into discrete attributes whose values are values the adapter can lower.',
1479
+ },
1480
+ })
1481
+ return true
1482
+ }
1483
+
1484
+ /**
1485
+ * Build the EmitContext seam the top-level `ParsedExpr` emitter depends on.
1486
+ * Built as a private object (the adapter does NOT `implements JinjaEmitContext`)
1487
+ * so the wrapped bookkeeping — `_searchParamsLocals`, the const/record
1488
+ * resolvers, BF101 recording, the filter-predicate entry — stays private and
1489
+ * off the exported adapter's public type, matching the Go adapter's
1490
+ * `emitCtx` and the `spreadCtx` / `memoCtx` seams below.
1491
+ */
1492
+ private get emitCtx(): JinjaEmitContext {
1493
+ return {
1494
+ _searchParamsLocals: this._searchParamsLocals,
1495
+ _resolveModuleStringConst: (name) => this._resolveModuleStringConst(name),
1496
+ _resolveLiteralConst: (name) => this._resolveLiteralConst(name),
1497
+ _resolveStaticRecordLiteral: (o, k) => this._resolveStaticRecordLiteral(o, k),
1498
+ _recordExprBF101: (message, reason) => this._recordExprBF101(message, reason),
1499
+ _renderJinjaFilterExprPublic: (e, p) => this._renderJinjaFilterExprPublic(e, p),
1500
+ }
1501
+ }
1502
+
1503
+ /**
1504
+ * Build the narrow context the extracted spread lowering depends on. Passing
1505
+ * a purpose-built object (rather than `this`) keeps the adapter's bookkeeping
1506
+ * members private — they stay internal implementation detail, not part of the
1507
+ * exported class's public surface.
1508
+ */
1509
+ private get spreadCtx(): JinjaSpreadContext {
1510
+ return {
1511
+ componentName: this.componentName,
1512
+ errors: this.errors,
1513
+ localConstants: this.localConstants,
1514
+ propsParams: this.propsParams,
1515
+ convertExpressionToJinja: (e, preParsed) => this.convertExpressionToJinja(e, preParsed),
1516
+ convertConditionToJinja: (e, preParsed) => this.convertConditionToJinja(e, preParsed),
1517
+ }
1518
+ }
1519
+
1520
+ /** Build the narrow context the extracted memo seeding depends on. */
1521
+ private get memoCtx(): JinjaMemoContext {
1522
+ return {
1523
+ convertExpressionToJinja: (e, preParsed) => this.convertExpressionToJinja(e, preParsed),
1524
+ errors: this.errors,
1525
+ }
1526
+ }
1527
+
1528
+ private convertExpressionToJinja(expr: string, preParsed?: ParsedExpr): string {
1529
+ // Parse-first lowering — parity with the Xslate adapter's
1530
+ // `convertExpressionToKolon`. Parse the JS expression once, gate it on the
1531
+ // shared `isSupported`, and render every supported shape through the AST
1532
+ // emitter. Unsupported shapes surface as BF101.
1533
+ //
1534
+ // `preParsed` is the IR-carried `ParsedExpr` tree (cf. go-template's
1535
+ // `convertExpressionToGo(jsExpr, out?, preParsed?)`); when present it is
1536
+ // used directly instead of re-parsing `expr`, so spread condition/value
1537
+ // lowering threads the carried tree through without a stringify→re-parse
1538
+ // round-trip. The diagnostic text is then derived from the tree
1539
+ // (`stringifyParsedExpr`) so callers can pass `''` for `expr`.
1540
+ let parsed: ParsedExpr
1541
+ if (preParsed) {
1542
+ parsed = preParsed
1543
+ } else {
1544
+ const trimmed = expr.trim()
1545
+ if (trimmed === '') return "''"
1546
+ parsed = parseExpression(trimmed)
1547
+ }
1548
+
1549
+ // Registered call lowerings (#2057) — including the built-in `queryHref`
1550
+ // plugin (#2042), which lowers `queryHref(base, { … })` to a neutral
1551
+ // `guard-list` on the `query` helper → `bf.query(base, <triples>)`.
1552
+ // Recognised before the support gate because the object-literal arg is
1553
+ // otherwise `unsupported` (BF101). The `query` helper includes a pair iff its
1554
+ // guard is truthy AND its value is a non-empty string (the client's
1555
+ // `if (value)`): a plain `key: v` passes guard `true`, a conditional
1556
+ // `key: cond ? v : undefined` passes the lowered cond. Only the `query`
1557
+ // helper renders to `bf.query`; another guard-list helper must not be
1558
+ // silently mis-rendered as a query.
1559
+ if (parsed.kind === 'call') {
1560
+ for (const matcher of this._loweringMatchers) {
1561
+ const node = matcher(parsed.callee, parsed.args)
1562
+ if (node?.kind === 'guard-list' && node.helper === 'query') {
1563
+ const qArgs = queryHrefArgs(node, n => this.renderParsedExprToJinja(n))
1564
+ return `bf.query(${qArgs.join(', ')})`
1565
+ }
1566
+ // Generic `helper-call` (#2069) — the neutral vocabulary's escape
1567
+ // hatch for a userland `LoweringPlugin` that lowers to a single
1568
+ // runtime-helper invocation. `bf.<helper>(args…)` mirrors the
1569
+ // `query` helper's own naming convention exactly: the framework
1570
+ // renders the call, the plugin author registers `<helper>` as a
1571
+ // Jinja-callable function/filter in their own runtime — same
1572
+ // contract as `bf.query` itself, just not built in.
1573
+ if (node?.kind === 'helper-call' && isValidHelperId(node.helper)) {
1574
+ const argsX = node.args.map(a => this.renderParsedExprToJinja(a))
1575
+ return `bf.${node.helper}(${argsX.join(', ')})`
1576
+ }
1577
+ }
1578
+ }
1579
+
1580
+ const support = isSupported(parsed)
1581
+ if (!support.supported) {
1582
+ this.errors.push({
1583
+ code: 'BF101',
1584
+ severity: 'error',
1585
+ message: `Expression not supported: ${preParsed ? stringifyParsedExpr(parsed) : expr.trim()}`,
1586
+ loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1587
+ suggestion: {
1588
+ message: support.reason
1589
+ ? `${support.reason}\n\nOptions:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in the backend`
1590
+ : 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in the backend',
1591
+ },
1592
+ })
1593
+ // Safe Jinja empty-string literal — valid in every context the result
1594
+ // might land in.
1595
+ return "''"
1596
+ }
1597
+
1598
+ return this.renderParsedExprToJinja(parsed)
1599
+ }
1600
+
1601
+ /**
1602
+ * Convert a JS condition (an `if` / ternary / loop-filter test) to a Jinja
1603
+ * boolean expression, routing through `bf.truthy(...)` unless the
1604
+ * expression is structurally already boolean-shaped. See the file header,
1605
+ * divergence 1.
1606
+ */
1607
+ private convertConditionToJinja(expr: string, preParsed?: ParsedExpr): string {
1608
+ const jinja = this.convertExpressionToJinja(expr, preParsed)
1609
+ return this.wrapConditionExpr(expr, jinja, preParsed)
1610
+ }
1611
+
1612
+ /**
1613
+ * Shared helper: given the ORIGINAL JS expression (or its already-parsed
1614
+ * tree) and its ALREADY-RENDERED Jinja text, wrap the rendered text with
1615
+ * `bf.truthy(...)` unless the expression is structurally boolean-shaped.
1616
+ * Split from `convertConditionToJinja` so a caller that already lowered the
1617
+ * expression for another purpose (e.g. the `presenceOrUndefined` temp bind)
1618
+ * doesn't lower it twice.
1619
+ */
1620
+ private wrapConditionExpr(expr: string, jinja: string, preParsed?: ParsedExpr): string {
1621
+ const isBoolean = preParsed
1622
+ ? isBooleanResultExpr(stringifyParsedExpr(preParsed))
1623
+ : isBooleanResultExpr(expr)
1624
+ return isBoolean ? jinja : `bf.truthy(${jinja})`
1625
+ }
1626
+
1627
+ /**
1628
+ * Render a full ParsedExpr tree to Jinja for top-level (non-filter)
1629
+ * expressions where identifiers are signals / template vars.
1630
+ */
1631
+ private renderParsedExprToJinja(expr: ParsedExpr): string {
1632
+ return emitParsedExpr(expr, new JinjaTopLevelEmitter(this.emitCtx))
1633
+ }
1634
+
1635
+ /** Whether `name` (a signal getter or prop) holds a string value. Carried
1636
+ * for parity with the Perl-family adapters; the Jinja emitters don't
1637
+ * consume it (Jinja's `==`/`!=` compare strings and numbers correctly). */
1638
+ private _isStringValueName(name: string): boolean {
1639
+ return this.stringValueNames.has(name)
1640
+ }
1641
+
1642
+ /**
1643
+ * Parse `cond ? value : undefined` (or `: null`), returning the
1644
+ * condition/consequent source spans, else `null`. Used for the
1645
+ * attribute-omission rule (#1897).
1646
+ */
1647
+ parseUndefinedAlternateTernary(
1648
+ expr: string,
1649
+ ): { condition: string; consequent: string } | null {
1650
+ const parsed = parseExpression(expr.trim())
1651
+ if (parsed?.kind !== 'conditional') return null
1652
+ const alt = parsed.alternate
1653
+ const isUndef =
1654
+ (alt.kind === 'identifier' && (alt.name === 'undefined' || alt.name === 'null')) ||
1655
+ (alt.kind === 'literal' && (alt.value === null || alt.value === undefined))
1656
+ if (!isUndef) return null
1657
+ // Serialise the parsed sub-expressions back to JS source rather than
1658
+ // slicing `expr` text — `indexOf('?')` / `lastIndexOf(':')` would
1659
+ // mis-split when the consequent itself contains `?` / `:` inside a
1660
+ // string or nested ternary (`cond ? 'a:b' : undefined`).
1661
+ return {
1662
+ condition: exprToString(parsed.test),
1663
+ consequent: exprToString(parsed.consequent),
1664
+ }
1665
+ }
1666
+
1667
+ isBooleanTypedPropRef(expr: string): boolean {
1668
+ let bare = expr.trim()
1669
+ if (this.propsObjectName && bare.startsWith(`${this.propsObjectName}.`)) {
1670
+ bare = bare.slice(this.propsObjectName.length + 1)
1671
+ }
1672
+ if (!/^[A-Za-z_$][\w$]*$/.test(bare)) return false
1673
+ return this.booleanTypedProps.has(bare)
1674
+ }
1675
+
1676
+ /**
1677
+ * Whether an attribute-value expression should route through
1678
+ * `bf.bool_str` (vs. plain `bf.string`) at its interpolation site.
1679
+ * `isExplicitStringCall` is checked FIRST and short-circuits the other
1680
+ * three: an explicit `String(x)` call already lowers to `bf.string(x)`,
1681
+ * which — unlike Kolon's Perl port — correctly stringifies a real
1682
+ * boolean on its own (see `runtime.js_string`'s bool branch), so
1683
+ * layering `bf.bool_str` on top would run Python truthiness over the
1684
+ * ALREADY-STRINGIFIED text instead of the original boolean. See
1685
+ * `isExplicitStringCall`'s docstring in `boolean-result.ts` for the full
1686
+ * double-wrap failure mode this guards against.
1687
+ */
1688
+ private shouldBoolStr(expr: string, name: string): boolean {
1689
+ if (isExplicitStringCall(expr)) return false
1690
+ return isBooleanResultExpr(expr) || isAriaBooleanAttr(name) || this.isBooleanTypedPropRef(expr)
1691
+ }
1692
+
1693
+ /**
1694
+ * Inline a const (any scope) whose initializer is a pure numeric or
1695
+ * single-quoted string literal (`const totalPages = 5`, #1897
1696
+ * pagination) — function-scope consts never reach the per-render
1697
+ * context, so a bare reference would resolve to Undefined.
1698
+ */
1699
+ private _resolveLiteralConst(name: string): string | null {
1700
+ const c = (this.localConstants ?? []).find(lc => lc.name === name)
1701
+ if (c?.value === undefined) return null
1702
+ const v = c.value.trim()
1703
+ if (/^-?\d+(\.\d+)?$/.test(v)) return v
1704
+ const strLit = /^'([^'\\]*)'$/.exec(v) ?? /^"([^"\\]*)"$/.exec(v)
1705
+ if (strLit) return `'${escapeJinjaSingleQuoted(strLit[1])}'`
1706
+ return null
1707
+ }
1708
+
1709
+ private _resolveStaticRecordLiteral(objectName: string, key: string): string | null {
1710
+ const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
1711
+ if (!hit) return null
1712
+ return hit.kind === 'number'
1713
+ ? hit.text
1714
+ : `'${escapeJinjaSingleQuoted(hit.text)}'`
1715
+ }
1716
+
1717
+ private _resolveModuleStringConst(name: string): string | null {
1718
+ // A loop body may bind a `{% set %}` local that shadows a module const of
1719
+ // the same name; never inline inside one (conservative — drop to the
1720
+ // bare identifier).
1721
+ if (this.inLoop) return null
1722
+ const value = this.moduleStringConsts.get(name)
1723
+ if (value === undefined) return null
1724
+ return `'${escapeJinjaSingleQuoted(value)}'`
1725
+ }
1726
+
1727
+ private _recordExprBF101(message: string, reason?: string): void {
1728
+ this.errors.push({
1729
+ code: 'BF101',
1730
+ severity: 'error',
1731
+ message,
1732
+ loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1733
+ suggestion: {
1734
+ message: reason
1735
+ ? `${reason}\n\nOptions:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in the backend`
1736
+ : 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in the backend',
1737
+ },
1738
+ })
1739
+ }
1740
+
1741
+ /** Internal hook for higher-order: predicate body re-uses the filter emitter. */
1742
+ private _renderJinjaFilterExprPublic(expr: ParsedExpr, param: string): string {
1743
+ return this.renderJinjaFilterExpr(expr, param)
1744
+ }
1745
+ }
1746
+
1747
+ export const jinjaAdapter = new JinjaAdapter()