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