@barefootjs/jinja 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +66 -0
  2. package/dist/adapter/analysis/component-tree.d.ts +26 -0
  3. package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
  4. package/dist/adapter/boolean-result.d.ts +84 -0
  5. package/dist/adapter/boolean-result.d.ts.map +1 -0
  6. package/dist/adapter/emit-context.d.ts +106 -0
  7. package/dist/adapter/emit-context.d.ts.map +1 -0
  8. package/dist/adapter/expr/array-method.d.ts +75 -0
  9. package/dist/adapter/expr/array-method.d.ts.map +1 -0
  10. package/dist/adapter/expr/emitters.d.ts +143 -0
  11. package/dist/adapter/expr/emitters.d.ts.map +1 -0
  12. package/dist/adapter/expr/operand.d.ts +25 -0
  13. package/dist/adapter/expr/operand.d.ts.map +1 -0
  14. package/dist/adapter/index.d.ts +6 -0
  15. package/dist/adapter/index.d.ts.map +1 -0
  16. package/dist/adapter/index.js +189097 -0
  17. package/dist/adapter/jinja-adapter.d.ts +394 -0
  18. package/dist/adapter/jinja-adapter.d.ts.map +1 -0
  19. package/dist/adapter/lib/constants.d.ts +21 -0
  20. package/dist/adapter/lib/constants.d.ts.map +1 -0
  21. package/dist/adapter/lib/ir-scope.d.ts +50 -0
  22. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  23. package/dist/adapter/lib/jinja-naming.d.ts +77 -0
  24. package/dist/adapter/lib/jinja-naming.d.ts.map +1 -0
  25. package/dist/adapter/lib/types.d.ts +27 -0
  26. package/dist/adapter/lib/types.d.ts.map +1 -0
  27. package/dist/adapter/memo/seed.d.ts +81 -0
  28. package/dist/adapter/memo/seed.d.ts.map +1 -0
  29. package/dist/adapter/props/prop-classes.d.ts +33 -0
  30. package/dist/adapter/props/prop-classes.d.ts.map +1 -0
  31. package/dist/adapter/spread/spread-codegen.d.ts +61 -0
  32. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  33. package/dist/adapter/value/parsed-literal.d.ts +28 -0
  34. package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
  35. package/dist/build.d.ts +28 -0
  36. package/dist/build.d.ts.map +1 -0
  37. package/dist/build.js +189117 -0
  38. package/dist/conformance-pins.d.ts +12 -0
  39. package/dist/conformance-pins.d.ts.map +1 -0
  40. package/dist/index.d.ts +9 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +189118 -0
  43. package/package.json +66 -0
  44. package/python/VERSION +1 -0
  45. package/python/barefootjs/__init__.py +57 -0
  46. package/python/barefootjs/backend_jinja.py +125 -0
  47. package/python/barefootjs/evaluator.py +787 -0
  48. package/python/barefootjs/runtime.py +1334 -0
  49. package/python/barefootjs/search_params.py +89 -0
  50. package/python/pyproject.toml +32 -0
  51. package/python/tests/__init__.py +0 -0
  52. package/python/tests/test_eval_vectors.py +88 -0
  53. package/python/tests/test_evaluator.py +406 -0
  54. package/python/tests/test_helper_vectors.py +288 -0
  55. package/python/tests/test_omit.py +62 -0
  56. package/python/tests/test_props_attr.py +54 -0
  57. package/python/tests/test_query.py +41 -0
  58. package/python/tests/test_render.py +102 -0
  59. package/python/tests/test_render_child.py +96 -0
  60. package/python/tests/test_search_params.py +50 -0
  61. package/python/tests/test_spread_attrs.py +86 -0
  62. package/python/tests/test_template_primitives.py +347 -0
  63. package/python/tests/vector-divergences.json +42 -0
  64. package/src/__tests__/jinja-adapter-unit.test.ts +390 -0
  65. package/src/__tests__/jinja-adapter.test.ts +53 -0
  66. package/src/__tests__/jinja-counter.test.ts +62 -0
  67. package/src/__tests__/jinja-query-href.test.ts +99 -0
  68. package/src/__tests__/jinja-spread-attrs.test.ts +225 -0
  69. package/src/adapter/analysis/component-tree.ts +119 -0
  70. package/src/adapter/boolean-result.ts +176 -0
  71. package/src/adapter/emit-context.ts +118 -0
  72. package/src/adapter/expr/array-method.ts +346 -0
  73. package/src/adapter/expr/emitters.ts +608 -0
  74. package/src/adapter/expr/operand.ts +35 -0
  75. package/src/adapter/index.ts +6 -0
  76. package/src/adapter/jinja-adapter.ts +1747 -0
  77. package/src/adapter/lib/constants.ts +33 -0
  78. package/src/adapter/lib/ir-scope.ts +95 -0
  79. package/src/adapter/lib/jinja-naming.ts +114 -0
  80. package/src/adapter/lib/types.ts +30 -0
  81. package/src/adapter/memo/seed.ts +132 -0
  82. package/src/adapter/props/prop-classes.ts +65 -0
  83. package/src/adapter/spread/spread-codegen.ts +166 -0
  84. package/src/adapter/value/parsed-literal.ts +76 -0
  85. package/src/build.ts +37 -0
  86. package/src/conformance-pins.ts +101 -0
  87. package/src/index.ts +9 -0
  88. package/src/test-render.ts +714 -0
@@ -0,0 +1,608 @@
1
+ /**
2
+ * ParsedExpr → Jinja2 emitters for the Jinja template adapter.
3
+ *
4
+ * Ported from `packages/adapter-xslate/src/adapter/expr/emitters.ts`
5
+ * (`XslateFilterEmitter` / `XslateTopLevelEmitter`). Two `ParsedExprEmitter`
6
+ * implementations:
7
+ *
8
+ * - `JinjaFilterEmitter` — filter/predicate context (loop param + local
9
+ * aliases + bare identifier signal fallback); self-contained, reads no
10
+ * adapter state.
11
+ * - `JinjaTopLevelEmitter` — top-level / per-render-var context; depends on
12
+ * the adapter only through the narrow `JinjaEmitContext` seam.
13
+ *
14
+ * Two deliberate divergences from the Kolon port, both documented at their
15
+ * definition site below:
16
+ *
17
+ * 1. **JS-truthy condition wrapping** (`truthyTest`) — Python truthiness
18
+ * diverges from JS on empty containers; Perl doesn't have this problem
19
+ * (a Perl ref is always true), so Kolon never needed this. Every
20
+ * condition-TEST position (`!x`, the left operand of `&&`/`||`, a
21
+ * ternary's test) routes through the shared `bf.truthy(...)` runtime
22
+ * helper unless the operand is structurally already boolean-shaped
23
+ * (`isBooleanResultParsed`). `&&`/`||` still return the ORIGINAL
24
+ * operand VALUE (not a coerced bool) on the taken branch — matching JS
25
+ * `a || b` returning `a` itself, not `true` — only the BRANCH TEST uses
26
+ * `bf.truthy`. Because Jinja's `if/else` ternary has no way to reuse an
27
+ * already-computed test value for both the test and (conditionally) the
28
+ * result, the left operand's rendered text is emitted TWICE (once as
29
+ * the test, once as the value) — safe because every operand reaching
30
+ * this pipeline is a pure, side-effect-free read (a signal/prop lookup
31
+ * or a call into a pure runtime helper), never a stateful expression.
32
+ * 2. **No Jinja lambda for the predicate-callback fallback.** Kolon's
33
+ * `-> $x { … }` lambda is the Xslate top-level emitter's fallback when a
34
+ * `.filter`/`.every`/`.some`/`.find*` predicate can't be serialized to
35
+ * the runtime evaluator's JSON form (e.g. a nested method-call
36
+ * predicate). Jinja has no lambda-expression syntax at all, so there is
37
+ * no equivalent fallback to port. `JinjaTopLevelEmitter` therefore uses
38
+ * ONE mechanism for every higher-order callback: the evaluator-JSON
39
+ * `*_eval` payload (`bf.filter_eval`, `bf.sort_eval`, …). When
40
+ * `serializeParsedExpr` refuses the body, the call surfaces `BF101`
41
+ * instead of silently degrading — `.sort`/`.toSorted` is the one
42
+ * exception, whose non-lambda STRUCTURED fallback (`bf.sort` with a
43
+ * `{keys: […]}` descriptor, no callback body at all) survives the port
44
+ * unchanged since it was never lambda-shaped to begin with.
45
+ * `JinjaFilterEmitter` (the loop `.filter().map()` INLINE predicate,
46
+ * rendered as a plain boolean expression, never a lambda) is otherwise
47
+ * unaffected and still used for that path plus the filter-predicate
48
+ * entry point `_renderJinjaFilterExprPublic`.
49
+ */
50
+
51
+ import {
52
+ type ParsedExprEmitter,
53
+ type HigherOrderMethod,
54
+ type ArrayMethod,
55
+ type LiteralType,
56
+ type ParsedExpr,
57
+ type ObjectLiteralProperty,
58
+ type FlatDepth,
59
+ type TemplatePart,
60
+ identifierPath,
61
+ matchSearchParamsMethodCall,
62
+ sortComparatorFromArrow,
63
+ } from '@barefootjs/jsx'
64
+
65
+ import type { JinjaEmitContext } from '../emit-context.ts'
66
+ import { JINJA_TEMPLATE_PRIMITIVES } from '../lib/constants.ts'
67
+ import { jinjaIdent, escapeJinjaSingleQuoted } from '../lib/jinja-naming.ts'
68
+ import { isBooleanResultParsed } from '../boolean-result.ts'
69
+ import {
70
+ renderArrayMethod,
71
+ renderSortMethod,
72
+ renderSortEval,
73
+ renderReduceEval,
74
+ renderPredicateEval,
75
+ renderFlatMethod,
76
+ renderFlatMapEval,
77
+ renderMapEval,
78
+ } from './array-method.ts'
79
+
80
+ /**
81
+ * Local shape for the predicate-lowering helper. Mirrors the Kolon port's
82
+ * `PredicateCall`.
83
+ */
84
+ type PredicateCall = {
85
+ method: HigherOrderMethod
86
+ object: ParsedExpr
87
+ param: string
88
+ predicate: ParsedExpr
89
+ }
90
+
91
+ // Methods whose callback is a boolean predicate (`<recv>.<m>(x => …)`).
92
+ const PREDICATE_METHODS = new Set<HigherOrderMethod>([
93
+ 'filter', 'find', 'findIndex', 'findLast', 'findLastIndex', 'every', 'some',
94
+ ])
95
+
96
+ /**
97
+ * Route a condition-TEST position through `bf.truthy(...)` unless the node
98
+ * is structurally already boolean-shaped. See the file header (divergence
99
+ * 1). Shared by both emitters below and reused by the adapter's top-level
100
+ * `convertConditionToJinja` for IR-level `if` / loop-filter conditions.
101
+ */
102
+ export function truthyTest(node: ParsedExpr, rendered: string): string {
103
+ return isBooleanResultParsed(node) ? rendered : `bf.truthy(${rendered})`
104
+ }
105
+
106
+ /**
107
+ * Lowering for the predicate body of a filter / every / some / find, plus the
108
+ * same shape used by the loop-hoist `.filter().map()` inline condition.
109
+ * Higher-order predicates are emitted using Jinja's own scalar comparison
110
+ * operators.
111
+ *
112
+ * NOTE: Jinja has no `[x for x in … if …]`-as-expression form usable inline
113
+ * here (a comprehension is a value producer, not a boolean test), so a
114
+ * nested higher-order call (`x.tags.filter(...)`, `other.some(...)`) inside
115
+ * a predicate has no faithful scalar lowering here either — same BF101
116
+ * surfacing as Kolon (#2038) instead of silently degrading to the
117
+ * callback's receiver.
118
+ */
119
+ export class JinjaFilterEmitter implements ParsedExprEmitter {
120
+ constructor(
121
+ private readonly param: string,
122
+ private readonly localVarMap: Map<string, string>,
123
+ private readonly isStringName: (n: string) => boolean = () => false,
124
+ // Records a BF101 for predicate shapes this emitter can only degrade
125
+ // (#2038). Optional so emitter construction stays possible without an
126
+ // adapter; a missing hook keeps the old silent-degrade emit.
127
+ private readonly onUnsupported?: (message: string, reason?: string) => void,
128
+ ) {}
129
+
130
+ identifier(name: string): string {
131
+ if (name === this.param) return jinjaIdent(this.param)
132
+ const signal = this.localVarMap.get(name)
133
+ if (signal) return jinjaIdent(signal)
134
+ return jinjaIdent(name)
135
+ }
136
+
137
+ literal(value: string | number | boolean | null, literalType: LiteralType): string {
138
+ if (literalType === 'string') return `'${escapeJinjaSingleQuoted(String(value))}'`
139
+ if (literalType === 'boolean') return value ? 'true' : 'false'
140
+ if (literalType === 'null') return 'none'
141
+ return String(value)
142
+ }
143
+
144
+ member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
145
+ // `.length` — route through `bf.length` (handles both array element
146
+ // count and string char count, JS-compatibly). Jinja's builtin
147
+ // `|length` filter also faults trying to match JS semantics for every
148
+ // input shape, so route through the runtime uniformly.
149
+ if (property === 'length') {
150
+ return `bf.length(${emit(object)})`
151
+ }
152
+ // Attribute / dict-key access — Jinja `.` resolves both transparently.
153
+ return `${emit(object)}.${property}`
154
+ }
155
+
156
+ indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
157
+ // Jinja's `[]` postfix is polymorphic (list index or dict key),
158
+ // mirroring JS — no list/dict split is needed. #1897 (data-table's
159
+ // `selected()[index]`).
160
+ return `${emit(object)}[${emit(index)}]`
161
+ }
162
+
163
+ call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
164
+ // Signal getter calls: filter() → filter
165
+ if (callee.kind === 'identifier' && args.length === 0) {
166
+ return jinjaIdent(callee.name)
167
+ }
168
+ return emit(callee)
169
+ }
170
+
171
+ unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
172
+ if (op === '!') return `not ${truthyTest(argument, emit(argument))}`
173
+ if (op === '-') return `-${emit(argument)}`
174
+ return emit(argument)
175
+ }
176
+
177
+ binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
178
+ const l = emit(left)
179
+ const r = emit(right)
180
+ // Jinja's `==` / `!=` are value-equality operators that compare strings
181
+ // and numbers correctly — unlike Perl's numeric `==` (which the Mojo
182
+ // adapter must steer around with `eq`/`ne`). Same reasoning as Kolon.
183
+ const opMap: Record<string, string> = {
184
+ '===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
185
+ '+': '+', '-': '-', '*': '*', '/': '/',
186
+ }
187
+ return `${l} ${opMap[op] ?? op} ${r}`
188
+ }
189
+
190
+ logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
191
+ const l = emit(left)
192
+ const r = emit(right)
193
+ // See the file header, divergence 1: the branch TEST goes through
194
+ // `bf.truthy`, but the RETURNED value stays the original operand text —
195
+ // `l` appears twice for `&&`/`||` (test + fallback value), safe because
196
+ // every operand here is a pure lookup/call.
197
+ if (op === '&&') return `(${r} if ${truthyTest(left, l)} else ${l})`
198
+ if (op === '||') return `(${l} if ${truthyTest(left, l)} else ${r})`
199
+ // `l is not none` alone is NOT equivalent to JS `??`'s null-or-undefined
200
+ // check: a context var that was never seeded at all (not merely set to
201
+ // `None`) resolves to Jinja's `ChainableUndefined` sentinel under
202
+ // `undefined=ChainableUndefined`, and `Undefined is not none` is TRUE
203
+ // (it's a distinct object, not the `None` singleton) — so the coalesce
204
+ // would silently return the Undefined sentinel itself instead of `r`.
205
+ // Guard with `is defined` too, mirroring the same pair this file's
206
+ // `jinja-adapter.ts` already uses for nullable-optional-prop attribute
207
+ // omission (see that file's header, divergence 5, and the `is defined
208
+ // and is not none` call sites).
209
+ return `(${l} if (${l} is defined and ${l} is not none) else ${r})`
210
+ }
211
+
212
+ callbackMethod(
213
+ method: string,
214
+ object: ParsedExpr,
215
+ _arrow: Extract<ParsedExpr, { kind: 'arrow' }>,
216
+ _restArgs: ParsedExpr[],
217
+ emit: (e: ParsedExpr) => string,
218
+ ): string {
219
+ // A nested callback method inside a filter predicate has no Jinja scalar
220
+ // form. Surface BF101 instead of silently changing predicate semantics
221
+ // (`!other.some(r => …)` collapsing to `!other`).
222
+ this.onUnsupported?.(
223
+ `Filter predicate contains a nested '.${method}(...)' callback, which has no Jinja scalar form`,
224
+ `Rewrite the predicate without a nested callback method, or add /* @client */ for client-only evaluation (no SSR).`,
225
+ )
226
+ return emit(object)
227
+ }
228
+
229
+ arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
230
+ return `[${elements.map(emit).join(', ')}]`
231
+ }
232
+
233
+ arrayMethod(
234
+ method: ArrayMethod,
235
+ object: ParsedExpr,
236
+ args: ParsedExpr[],
237
+ emit: (e: ParsedExpr) => string,
238
+ ): string {
239
+ return renderArrayMethod(method, object, args, emit)
240
+ }
241
+
242
+ flatMethod(
243
+ object: ParsedExpr,
244
+ depth: FlatDepth | { expr: ParsedExpr },
245
+ emit: (e: ParsedExpr) => string,
246
+ ): string {
247
+ return renderFlatMethod(emit(object), depth, emit)
248
+ }
249
+
250
+ conditional(_test: ParsedExpr, _consequent: ParsedExpr, _alternate: ParsedExpr): string {
251
+ return 'true'
252
+ }
253
+
254
+ templateLiteral(_parts: TemplatePart[]): string {
255
+ return 'true'
256
+ }
257
+
258
+ arrow(_params: string[], _body: ParsedExpr): string {
259
+ return 'true'
260
+ }
261
+
262
+ regex(_raw: string): string {
263
+ return 'true'
264
+ }
265
+
266
+ unsupported(_raw: string, _reason: string): string {
267
+ return 'true'
268
+ }
269
+
270
+ objectLiteral(_properties: ObjectLiteralProperty[], _raw: string, _emit: (e: ParsedExpr) => string): string {
271
+ // Filter-predicate context: emit the truthy sentinel exactly as
272
+ // `unsupported` does. Object values lower to Jinja dicts in the
273
+ // conditional/attr paths, not through this dispatcher.
274
+ return 'true'
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Lowering for top-level expressions whose identifiers resolve against the
280
+ * Jinja template's per-render context vars (signals, props, locals
281
+ * introduced by `{% set x = … %}`). Differs from the filter emitter mainly
282
+ * in
283
+ * - `conditional` is supported (filter predicates can't return ternaries),
284
+ * - higher-order methods route through `bf.*` array/evaluator helpers,
285
+ * - no lambda fallback exists (see the file header, divergence 2).
286
+ */
287
+ export class JinjaTopLevelEmitter implements ParsedExprEmitter {
288
+ constructor(private readonly ctx: JinjaEmitContext) {}
289
+
290
+ identifier(name: string): string {
291
+ // `undefined` / `null` nested inside a larger expression tree — Jinja
292
+ // `none` (#1897).
293
+ if (name === 'undefined' || name === 'null') return 'none'
294
+ // Inline a module-scope pure-string const (`const x = 'literal'`) — it
295
+ // never reaches the per-render context, so a bare reference would
296
+ // resolve to Undefined.
297
+ const inlined = this.ctx._resolveModuleStringConst(name)
298
+ if (inlined !== null) return inlined
299
+ // Same for a literal const of any scope (`const totalPages = 5`,
300
+ // #1897 pagination's `Page {currentPage()} of {totalPages}`).
301
+ const literalConst = this.ctx._resolveLiteralConst(name)
302
+ if (literalConst !== null) return literalConst
303
+ return jinjaIdent(name)
304
+ }
305
+
306
+ literal(value: string | number | boolean | null, literalType: LiteralType): string {
307
+ if (literalType === 'string') return `'${escapeJinjaSingleQuoted(String(value))}'`
308
+ if (literalType === 'boolean') return value ? 'true' : 'false'
309
+ if (literalType === 'null') return 'none'
310
+ return String(value)
311
+ }
312
+
313
+ member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
314
+ // `props.x` flattens to the bare context var the SSR caller binds each
315
+ // prop to (props arrive as individual top-level context entries, not a
316
+ // nested `props` dict).
317
+ if (object.kind === 'identifier' && object.name === 'props') {
318
+ return jinjaIdent(property)
319
+ }
320
+ // Static property access on a module object-literal const
321
+ // (`variantClasses.ghost`, #1897) resolves at compile time — the
322
+ // generic dot lowering below would reference a context var that
323
+ // doesn't exist server-side and silently resolve to Undefined.
324
+ if (object.kind === 'identifier') {
325
+ const staticValue = this.ctx._resolveStaticRecordLiteral(object.name, property)
326
+ if (staticValue !== null) return staticValue
327
+ }
328
+ const obj = emit(object)
329
+ // `.length` → `bf.length` (array count or string char count, JS-compat).
330
+ if (property === 'length') return `bf.length(${obj})`
331
+ // Jinja `.` access works for both dicts and objects.
332
+ return `${obj}.${property}`
333
+ }
334
+
335
+ indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
336
+ // Jinja's `[]` postfix is polymorphic (list index or dict key),
337
+ // mirroring JS. #1897 (data-table's `selected()[index]`).
338
+ return `${emit(object)}[${emit(index)}]`
339
+ }
340
+
341
+ call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
342
+ // Signal getter: count() → count
343
+ if (callee.kind === 'identifier' && args.length === 0) {
344
+ return jinjaIdent(callee.name)
345
+ }
346
+ // Env-signal method call (#1922): `searchParams().get('sort')` is a real
347
+ // method call on the per-request `searchParams` reader object, not the
348
+ // generic dot deref `member` would emit. Matches the local import
349
+ // binding (incl. an alias).
350
+ if (this.ctx._searchParamsLocals.size > 0) {
351
+ const sp = matchSearchParamsMethodCall(callee, args, this.ctx._searchParamsLocals)
352
+ if (sp) {
353
+ return `searchParams.${sp.method}(${sp.args.map(emit).join(', ')})`
354
+ }
355
+ }
356
+ // Identifier-path templatePrimitive: `JSON.stringify(x)` / `Math.floor(x)`
357
+ // → `bf.json(x)` / `bf.floor(x)`. Args render recursively through this
358
+ // same emitter. A wrong-arity call records BF101 and returns `''`.
359
+ const path = identifierPath(callee)
360
+ const spec = path ? JINJA_TEMPLATE_PRIMITIVES[path] : undefined
361
+ if (path && spec) {
362
+ if (args.length === spec.arity) {
363
+ return spec.emit(args.map(emit))
364
+ }
365
+ this.ctx._recordExprBF101(
366
+ `templatePrimitive '${path}' expects ${spec.arity} arg(s), got ${args.length}`,
367
+ `Call '${path}' with exactly ${spec.arity} argument(s).`,
368
+ )
369
+ return "''"
370
+ }
371
+ return emit(callee)
372
+ }
373
+
374
+ unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
375
+ if (op === '!') return `not ${truthyTest(argument, emit(argument))}`
376
+ if (op === '-') return `-${emit(argument)}`
377
+ return emit(argument)
378
+ }
379
+
380
+ binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
381
+ const l = emit(left)
382
+ const r = emit(right)
383
+ // Jinja's `==` / `!=` handle both strings and numbers (unlike Perl's
384
+ // numeric `==`), so all equality comparisons stay on `==` / `!=`.
385
+ const opMap: Record<string, string> = {
386
+ '===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
387
+ '+': '+', '-': '-', '*': '*',
388
+ }
389
+ return `${l} ${opMap[op] ?? op} ${r}`
390
+ }
391
+
392
+ logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
393
+ const l = emit(left)
394
+ const r = emit(right)
395
+ // See the file header, divergence 1.
396
+ if (op === '&&') return `(${r} if ${truthyTest(left, l)} else ${l})`
397
+ if (op === '||') return `(${l} if ${truthyTest(left, l)} else ${r})`
398
+ // See `JinjaFilterEmitter.logical`'s comment above (same file, same
399
+ // fix): guard with `is defined` too, so a context var that was never
400
+ // seeded (Jinja's `ChainableUndefined`, not Python `None`) still
401
+ // coalesces to `r` instead of leaking the Undefined sentinel through.
402
+ return `(${l} if (${l} is defined and ${l} is not none) else ${r})`
403
+ }
404
+
405
+ callbackMethod(
406
+ method: string,
407
+ object: ParsedExpr,
408
+ arrow: Extract<ParsedExpr, { kind: 'arrow' }>,
409
+ restArgs: ParsedExpr[],
410
+ emit: (e: ParsedExpr) => string,
411
+ ): string {
412
+ const recv = emit(object)
413
+ const body = arrow.body
414
+ const params = arrow.params
415
+
416
+ // Predicate family (#2018 P2/P5): `filter` / `find*` / `every` / `some`.
417
+ if (PREDICATE_METHODS.has(method as HigherOrderMethod)) {
418
+ return this._emitPredicateCallback(
419
+ { method: method as HigherOrderMethod, object, param: params[0], predicate: body },
420
+ recv,
421
+ emit,
422
+ )
423
+ }
424
+
425
+ // `.sort(cmp)` / `.toSorted(cmp)` (#2018): serialize the comparator body +
426
+ // emit `bf.sort_eval`; fall back to the structured `bf.sort` when the
427
+ // body is outside the evaluator surface (e.g. `localeCompare`). This
428
+ // structured fallback is data (a `{keys: […]}` descriptor), never a
429
+ // lambda, so it ports unchanged — see the file header, divergence 2.
430
+ if (method === 'sort' || method === 'toSorted') {
431
+ const evalForm = renderSortEval(recv, body, params, emit)
432
+ if (evalForm !== null) return evalForm
433
+ const structured = sortComparatorFromArrow(arrow)
434
+ if (structured !== null) return renderSortMethod(recv, structured)
435
+ this.ctx._recordExprBF101(
436
+ `'.${method}(...)' comparator is outside the Jinja adapter's evaluable / structured surface`,
437
+ `Pre-compute the sorted array, or move this position to a '/* @client */' boundary.`,
438
+ )
439
+ return "''"
440
+ }
441
+
442
+ // `.reduce(fn, init)` / `.reduceRight(fn, init)` (#2018): serialize the
443
+ // reducer body + emit `bf.reduce_eval`. The init is the trailing arg.
444
+ if (method === 'reduce' || method === 'reduceRight') {
445
+ const direction = method === 'reduceRight' ? 'right' : 'left'
446
+ const init = restArgs[0]
447
+ const evalForm =
448
+ init !== undefined
449
+ ? renderReduceEval(recv, body, params, init, direction, emit)
450
+ : null
451
+ if (evalForm !== null) return evalForm
452
+ this.ctx._recordExprBF101(
453
+ `'.${method}(...)' is outside the Jinja adapter's evaluable surface (needs a literal initial value and an evaluable reducer body)`,
454
+ `Pre-compute the reduced value, or move this position to a '/* @client */' boundary.`,
455
+ )
456
+ return "''"
457
+ }
458
+
459
+ // `.flatMap(proj)` (#2018 P3): serialize the projection body + emit
460
+ // `bf.flat_map_eval`.
461
+ if (method === 'flatMap') {
462
+ const evalForm = renderFlatMapEval(recv, body, params[0], emit)
463
+ if (evalForm !== null) return evalForm
464
+ this.ctx._recordExprBF101(
465
+ `'.flatMap(...)' projection is outside the Jinja adapter's evaluable surface`,
466
+ `Pre-compute the projected array, or move this position to a '/* @client */' boundary.`,
467
+ )
468
+ return "''"
469
+ }
470
+
471
+ // Value-producing `.map(cb)` (#2073): serialize the projection body +
472
+ // emit `bf.map_eval`. (The JSX-returning `.map` is an IRLoop upstream.)
473
+ if (method === 'map') {
474
+ const evalForm = renderMapEval(recv, body, params[0], emit)
475
+ if (evalForm !== null) return evalForm
476
+ this.ctx._recordExprBF101(
477
+ `'.map(...)' projection is outside the Jinja adapter's evaluable surface`,
478
+ `Pre-compute the projected array, or move this position to a '/* @client */' boundary.`,
479
+ )
480
+ return "''"
481
+ }
482
+
483
+ // Unknown callback method (should not arrive — CALLBACK_METHODS is closed).
484
+ void object
485
+ return recv
486
+ }
487
+
488
+ /**
489
+ * Lower a boolean-predicate callback (`filter` / `find*` / `every` /
490
+ * `some`). See the file header, divergence 2: Jinja has no lambda
491
+ * expression, so — unlike Kolon — there is no non-evaluator fallback here.
492
+ * A predicate the evaluator can't model surfaces `BF101`.
493
+ */
494
+ private _emitPredicateCallback(
495
+ call: PredicateCall,
496
+ arrayExpr: string,
497
+ emit: (e: ParsedExpr) => string,
498
+ ): string {
499
+ const { method, param, predicate } = call
500
+
501
+ const evalFn: Record<string, [string, boolean?]> = {
502
+ filter: ['filter_eval'], every: ['every_eval'], some: ['some_eval'],
503
+ find: ['find_eval', true], findLast: ['find_eval', false],
504
+ findIndex: ['find_index_eval', true], findLastIndex: ['find_index_eval', false],
505
+ }
506
+ // `.filter(Boolean)` (identity predicate `_t => _t`) still needs the
507
+ // evaluator path on Jinja (no lambda fallback) — the identity predicate
508
+ // serializes fine (`{"kind":"identifier","name":"_t"}`), so this isn't a
509
+ // special case here the way it is for Kolon's lambda form.
510
+ const spec = evalFn[method]
511
+ if (spec) {
512
+ const evalForm = renderPredicateEval(spec[0], arrayExpr, predicate, param, emit, spec[1])
513
+ if (evalForm !== null) return evalForm
514
+ }
515
+
516
+ this.ctx._recordExprBF101(
517
+ `'.${method}(...)' predicate is outside the Jinja adapter's evaluable surface — Jinja has no lambda-expression form to fall back to`,
518
+ `Rewrite the predicate as a pure expression the evaluator can serialize (no nested method-call callbacks), or move this position to a '/* @client */' boundary.`,
519
+ )
520
+ return "''"
521
+ }
522
+
523
+ arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
524
+ return `[${elements.map(emit).join(', ')}]`
525
+ }
526
+
527
+ arrayMethod(
528
+ method: ArrayMethod,
529
+ object: ParsedExpr,
530
+ args: ParsedExpr[],
531
+ emit: (e: ParsedExpr) => string,
532
+ ): string {
533
+ return renderArrayMethod(method, object, args, emit)
534
+ }
535
+
536
+ flatMethod(
537
+ object: ParsedExpr,
538
+ depth: FlatDepth | { expr: ParsedExpr },
539
+ emit: (e: ParsedExpr) => string,
540
+ ): string {
541
+ return renderFlatMethod(emit(object), depth, emit)
542
+ }
543
+
544
+ conditional(
545
+ test: ParsedExpr,
546
+ consequent: ParsedExpr,
547
+ alternate: ParsedExpr,
548
+ emit: (e: ParsedExpr) => string,
549
+ ): string {
550
+ return `(${emit(consequent)} if ${truthyTest(test, emit(test))} else ${emit(alternate)})`
551
+ }
552
+
553
+ templateLiteral(parts: TemplatePart[], emit: (e: ParsedExpr) => string): string {
554
+ // `` `n=${count() + 1}` `` → Jinja string concatenation (`~`):
555
+ // `'n=' ~ bf.string(count + 1)`. Every interpolated (non-string-literal)
556
+ // segment routes through `bf.string(...)` before concatenation — Jinja's
557
+ // `~` stringifies each operand with Python `str()`, which diverges from
558
+ // JS `String(x)` for floats/booleans/None (see `jinja-adapter.ts`'s file
559
+ // header, "Stringification").
560
+ const terms: string[] = []
561
+ for (const part of parts) {
562
+ if (part.type === 'string') {
563
+ if (part.value !== '') {
564
+ terms.push(`'${escapeJinjaSingleQuoted(part.value)}'`)
565
+ }
566
+ } else {
567
+ const rendered = emit(part.expr)
568
+ const needsParens =
569
+ part.expr.kind === 'binary' ||
570
+ part.expr.kind === 'logical' ||
571
+ part.expr.kind === 'conditional'
572
+ terms.push(`bf.string(${needsParens ? `(${rendered})` : rendered})`)
573
+ }
574
+ }
575
+ if (terms.length === 0) return `''`
576
+ return terms.join(' ~ ')
577
+ }
578
+
579
+ arrow(_params: string[], _body: ParsedExpr): string {
580
+ // A bare arrow never stands alone at a render position (it's only
581
+ // meaningful as a callback, handled by `callbackMethod`). Emit the safe
582
+ // empty-string literal so a stray emit can't produce a Jinja syntax
583
+ // error.
584
+ return "''"
585
+ }
586
+
587
+ regex(_raw: string): string {
588
+ // A bare regex literal has no template-render form — mirror `unsupported`.
589
+ return "''"
590
+ }
591
+
592
+ unsupported(_raw: string, _reason: string): string {
593
+ return "''"
594
+ }
595
+
596
+ objectLiteral(properties: ObjectLiteralProperty[], _raw: string, _emit: (e: ParsedExpr) => string): string {
597
+ // The shared `isSupported` gate only ever lets this dispatcher see an
598
+ // object literal as the EMPTY (`?? {}`) fallback operand of `??`
599
+ // (expression-parser.ts, `logical` case) — any other object literal is
600
+ // refused before reaching here. Emit Jinja's real empty dict literal,
601
+ // matching the `'{}'` convention `objectLiteralToJinjaDict` already uses
602
+ // for the zero-property case in the spread path. A populated literal is
603
+ // structurally unreachable given the gate, but still degrades safely to
604
+ // the pre-existing empty-string sentinel rather than silently dropping
605
+ // keys.
606
+ return properties.length === 0 ? '{}' : "''"
607
+ }
608
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Operand-type classification for the Jinja2 template adapter.
3
+ *
4
+ * Ported from `packages/adapter-xslate/src/adapter/expr/operand.ts`. Pure
5
+ * function over `ParsedExpr` taking an `isStringName` predicate rather than
6
+ * reading adapter instance state.
7
+ *
8
+ * NOTE: like Kolon's `==`/`!=`, Jinja/Python's `==`/`!=` are value-equality
9
+ * operators that compare strings and numbers correctly, so the Jinja
10
+ * emitters never need to steer string comparisons onto a different operator
11
+ * family. This helper is therefore not consumed by the Jinja lowering
12
+ * today either — kept only as the parallel of the Xslate/Mojo adapters'
13
+ * `expr/operand.ts` (groundwork for a future shared codegen surface).
14
+ */
15
+
16
+ import type { ParsedExpr } from '@barefootjs/jsx'
17
+
18
+ /**
19
+ * Whether a comparison operand is string-typed. In the Mojo adapter this
20
+ * selects Perl `eq`/`ne` over numeric `==`/`!=` for a `===`/`!==` against a
21
+ * string operand. Neither the Kolon nor the Jinja emitters consume it —
22
+ * both languages' `==`/`!=` compare strings and numbers correctly, so
23
+ * `===`/`!==` always map to `==`/`!=`. Kept only as the parallel of the
24
+ * Xslate/Mojo helper.
25
+ */
26
+ export function isStringTypedOperand(expr: ParsedExpr, isStringName: (n: string) => boolean): boolean {
27
+ if (expr.kind === 'literal' && expr.literalType === 'string') return true
28
+ if (expr.kind === 'call' && expr.callee.kind === 'identifier' && expr.args.length === 0) {
29
+ return isStringName(expr.callee.name)
30
+ }
31
+ if (expr.kind === 'member' && expr.object.kind === 'identifier' && expr.object.name === 'props') {
32
+ return isStringName(expr.property)
33
+ }
34
+ return false
35
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Jinja2 Template Adapter Exports
3
+ */
4
+
5
+ export { JinjaAdapter, jinjaAdapter } from './jinja-adapter.ts'
6
+ export type { JinjaAdapterOptions } from './jinja-adapter.ts'