@barefootjs/rust 0.1.0

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