@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,168 @@
1
+ /**
2
+ * Structural classifier for JS expressions whose result is a boolean value
3
+ * (or unambiguously stringifies to "true"/"false" in JS).
4
+ *
5
+ * Ported from `packages/adapter-jinja/src/adapter/boolean-result.ts`
6
+ * (itself ported from the Xslate/Mojo adapters' `bf->bool_str` classifier).
7
+ * Used by the Blade adapter for TWO purposes:
8
+ *
9
+ * 1. **Attribute/text stringification**: route a boolean-shaped reactive
10
+ * binding through the runtime `bf.bool_str` helper so the serialised
11
+ * value matches JS `String(boolean)` ("true"/"false"). PHP has a real
12
+ * `bool` type, but PHP's own `(string) true` == `"1"` (not `"true"`) and
13
+ * `(string) false` == `""` — still wrong for HTML output — so the same
14
+ * explicit routing is required.
15
+ * 2. **Condition-position truthy wrapping**: PHP truthiness diverges from JS
16
+ * specifically on the string `'0'` (falsy in PHP, truthy in JS) and empty
17
+ * arrays (`[]` — JS objects/arrays are unconditionally truthy; a PHP
18
+ * empty array is falsy). The Blade adapter's condition-emission call sites
19
+ * (see `blade-adapter.ts`'s `convertConditionToBlade`) reuse this SAME
20
+ * structural classifier: a condition that is already unambiguously
21
+ * boolean-shaped emits directly; everything else is wrapped in
22
+ * `bf.truthy(...)` (a JS-faithful `ToBoolean`) before being used as an
23
+ * `{% if %}` / ternary test.
24
+ *
25
+ * The classifier walks a `ParsedExpr` produced by
26
+ * `@barefootjs/jsx::parseExpression` — same AST the filter / loop lowerings
27
+ * already use — so detection is structural rather than regex-text-matching.
28
+ * Wrapped expression text is left to the caller's existing
29
+ * `convertExpressionToBlade` pipeline; this module only decides whether to
30
+ * wrap.
31
+ *
32
+ * Detected shapes:
33
+ * - `binary` with a comparison operator (`<`, `>`, `<=`, `>=`, `==`, `===`,
34
+ * `!=`, `!==`)
35
+ * - `unary` with logical `!`
36
+ * - `literal` with `literalType: 'boolean'`
37
+ * - `logical` (`&&` / `||` / `??`) when both sides are themselves
38
+ * boolean-result (catches `x > 0 && y < 10`; intentionally does NOT
39
+ * catch `x() || 'fallback'` whose right side stringifies as a regular
40
+ * value)
41
+ * - `conditional` (`?:`) when both branches are themselves boolean-result
42
+ *
43
+ * Anything else returns `false` — including bare identifiers (`accepted`)
44
+ * and call expressions (`accepted()`) whose return type the adapter has no
45
+ * way to infer from source text alone.
46
+ */
47
+
48
+ import { parseExpression, type ParsedExpr } from '@barefootjs/jsx'
49
+
50
+ const COMPARISON_OPS = new Set([
51
+ '<',
52
+ '>',
53
+ '<=',
54
+ '>=',
55
+ '==',
56
+ '===',
57
+ '!=',
58
+ '!==',
59
+ ])
60
+
61
+ /**
62
+ * Structural boolean-result check over an already-parsed `ParsedExpr` tree.
63
+ * Exported so the condition-position truthy-wrapping call sites can reuse it
64
+ * without a stringify → re-parse round-trip.
65
+ */
66
+ export function isBooleanResultParsed(node: ParsedExpr): boolean {
67
+ switch (node.kind) {
68
+ case 'literal':
69
+ return node.literalType === 'boolean'
70
+ case 'binary':
71
+ return COMPARISON_OPS.has(node.op)
72
+ case 'unary':
73
+ return node.op === '!'
74
+ case 'logical':
75
+ // `x > 0 && y < 10` is boolean; `x() || 'fallback'` is not.
76
+ // Only both-sides-boolean qualifies.
77
+ return (
78
+ isBooleanResultParsed(node.left) && isBooleanResultParsed(node.right)
79
+ )
80
+ case 'conditional':
81
+ // `cond ? bool : bool` is boolean; `cond ? 'a' : 'b'` is not.
82
+ return (
83
+ isBooleanResultParsed(node.consequent) &&
84
+ isBooleanResultParsed(node.alternate)
85
+ )
86
+ default:
87
+ return false
88
+ }
89
+ }
90
+
91
+ export function isBooleanResultExpr(expr: string): boolean {
92
+ const parsed = parseExpression(expr.trim())
93
+ if (!parsed) return false
94
+ return isBooleanResultParsed(parsed)
95
+ }
96
+
97
+ /**
98
+ * True when `expr`'s top-level shape is an explicit JS `String(x)` call
99
+ * (the `EVAL_BUILTIN_IDENTS` builtin the compiler recognizes structurally —
100
+ * `packages/jsx/src/expression-parser.ts`'s `EVAL_BUILTIN_IDENTS`; lowered
101
+ * by this adapter's `String` template primitive to `bf.string(x)`, see
102
+ * `lib/constants.ts`).
103
+ *
104
+ * Guards the `isAriaBooleanAttr`-driven `bf.bool_str(...)` override in
105
+ * `blade-adapter.ts`'s `elementAttrEmitter`: `bf.string` and `bf.bool_str`
106
+ * produce IDENTICAL text for a real PHP `bool` (both are `"true"` /
107
+ * `"false"`), so applying `bf.bool_str` to `String(x)`'s ALREADY-STRINGIFIED
108
+ * result is not a no-op — it is a PHP-truthiness test over that STRING
109
+ * ("false" is a non-empty PHP string, hence truthy, so
110
+ * `bf.bool_str(bf.string(false))` would wrongly render `"true"`). An author
111
+ * who explicitly writes `String(...)` has already opted into JS `String()`
112
+ * semantics — `bf.string(x)` alone (which DOES special-case booleans, see
113
+ * the PHP runtime's `string()` helper) is the complete, correct lowering; no
114
+ * attribute-name-driven override should run again on top of it.
115
+ */
116
+ export function isExplicitStringCall(expr: string): boolean {
117
+ const parsed = parseExpression(expr.trim())
118
+ return (
119
+ !!parsed &&
120
+ parsed.kind === 'call' &&
121
+ parsed.callee.kind === 'identifier' &&
122
+ parsed.callee.name === 'String' &&
123
+ parsed.args.length === 1
124
+ )
125
+ }
126
+
127
+ /**
128
+ * ARIA attributes whose spec values are `"true"`, `"false"`, and (for
129
+ * tri-state members) `"mixed"`. When a fixture binds one of these to an
130
+ * arbitrary JS expression (`aria-checked={accepted()}`), the expression's
131
+ * actual type isn't recoverable from source text — but the attribute name
132
+ * itself witnesses that the binding is boolean-shaped. Routing these through
133
+ * `bf.bool_str` produces the spec-canonical `"true"` / `"false"` even when
134
+ * the expression is opaque.
135
+ *
136
+ * Deliberately conservative — only includes ARIA attributes whose spec value
137
+ * set is exactly `true | false` or `true | false | mixed`. Tokenised ARIA
138
+ * attributes (`aria-current` is `page | step | …`, `aria-sort` is
139
+ * `ascending | descending | …`) are intentionally excluded so a
140
+ * string-valued binding doesn't get coerced to `"true"` / `"false"`.
141
+ */
142
+ const ARIA_BOOLEAN_ATTRS = new Set([
143
+ // Strict boolean state (true | false; some allow `undefined` = attribute
144
+ // absent, which the runtime emits as no-attr regardless).
145
+ 'aria-atomic',
146
+ 'aria-busy',
147
+ 'aria-disabled',
148
+ 'aria-hidden',
149
+ 'aria-modal',
150
+ 'aria-multiline',
151
+ 'aria-multiselectable',
152
+ 'aria-readonly',
153
+ 'aria-required',
154
+ // true | false | undefined (absent) — selection / disclosure state.
155
+ 'aria-selected',
156
+ 'aria-expanded',
157
+ // Tri-state (true | false | mixed). The `bool_str` helper only maps
158
+ // truthy / falsy to true / false — a fixture that wants the literal
159
+ // "mixed" would bind a string-valued JSX attr (`aria-checked="mixed"`),
160
+ // which lowers through the `literal` emit path and never touches this
161
+ // code.
162
+ 'aria-checked',
163
+ 'aria-pressed',
164
+ ])
165
+
166
+ export function isAriaBooleanAttr(name: string): boolean {
167
+ return ARIA_BOOLEAN_ATTRS.has(name)
168
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The contract the extracted expression-emitter modules depend on instead of
3
+ * the concrete `BladeAdapter`.
4
+ *
5
+ * Ported from `packages/adapter-jinja/src/adapter/emit-context.ts`. The Blade
6
+ * adapter's top-level expression lowering is mutually recursive with the
7
+ * adapter's own const/record resolution and its filter-predicate emitter, so
8
+ * the extracted `BladeTopLevelEmitter` still needs to call back into shared
9
+ * per-compile state and recursive entry points. `BladeEmitContext` is that
10
+ * seam: the emitter takes a `BladeEmitContext` built by the adapter's private
11
+ * `emitCtx` getter (the adapter does NOT `implements` this interface, so the
12
+ * wrapped members stay private and off its exported public type). The
13
+ * emitter depends on this narrow interface rather than the full class, so
14
+ * the coupling is explicit and it's unit-testable against a stub.
15
+ *
16
+ * Keep this surface minimal: add a member only when an extracted module
17
+ * genuinely needs it, so the seam documents the real cross-module coupling
18
+ * rather than re-exposing the whole adapter.
19
+ */
20
+
21
+ import type { ParsedExpr, CompilerError, IRMetadata } from '@barefootjs/jsx'
22
+
23
+ export interface BladeEmitContext {
24
+ /**
25
+ * (#1922) Local binding names the request-scoped `searchParams()` env signal
26
+ * is imported under. Non-empty enables the env-signal method-call lowering.
27
+ */
28
+ readonly _searchParamsLocals: Set<string>
29
+
30
+ /**
31
+ * Inline a module-scope pure string-literal const by name as the resolved
32
+ * literal value, or null when the name is not such a const.
33
+ */
34
+ _resolveModuleStringConst(name: string): string | null
35
+
36
+ /** Resolve a literal const (`const totalPages = 5`) to its Blade value, or null. */
37
+ _resolveLiteralConst(name: string): string | null
38
+
39
+ /**
40
+ * Resolve a static property access on a module object-literal const
41
+ * (`variantClasses.ghost`) to its Blade value at compile time, or null.
42
+ */
43
+ _resolveStaticRecordLiteral(objectName: string, key: string): string | null
44
+
45
+ /** Record a BF101 unsupported-expression diagnostic. */
46
+ _recordExprBF101(message: string, reason?: string): void
47
+
48
+ /** Lower a filter/predicate body to its Blade form, bound to `param`. */
49
+ _renderBladeFilterExprPublic(expr: ParsedExpr, param: string): string
50
+ }
51
+
52
+ /**
53
+ * The contract the extracted object-literal / conditional-spread lowering
54
+ * (`spread/spread-codegen.ts`) depends on. Declared separately from
55
+ * `BladeEmitContext` so each extracted module's real coupling is documented
56
+ * precisely. Mirror of the Jinja adapter's `JinjaSpreadContext`.
57
+ */
58
+ export interface BladeSpreadContext {
59
+ /** Component name, for diagnostic source locations. */
60
+ readonly componentName: string
61
+
62
+ /** Per-compile diagnostic list the spread lowering appends to. */
63
+ readonly errors: CompilerError[]
64
+
65
+ /** Local-constant metadata, for resolving `Record[key]` spread values. */
66
+ readonly localConstants: IRMetadata['localConstants']
67
+
68
+ /** Prop params, for classifying a bare-identifier index as a prop. */
69
+ readonly propsParams: { name: string }[]
70
+
71
+ /**
72
+ * Lower a JS expression to its Blade form (the core recursive entry).
73
+ *
74
+ * When the IR already carries a structured `ParsedExpr` tree, pass it as
75
+ * `preParsed` so the converter threads it straight through instead of
76
+ * re-parsing `expr`. With `preParsed` set, `expr` is unused for parsing
77
+ * (the converter derives any diagnostic text from the tree), so callers
78
+ * may pass `''`.
79
+ */
80
+ convertExpressionToBlade(expr: string, preParsed?: ParsedExpr): string
81
+
82
+ /**
83
+ * Lower a JS expression to a Blade CONDITION (routes through `bf.truthy`
84
+ * unless the expression is structurally already boolean-shaped — see
85
+ * `boolean-result.ts`). Used for the conditional-spread ternary's test,
86
+ * which is a condition position, not a value position. Same `preParsed`
87
+ * contract as `convertExpressionToBlade`.
88
+ */
89
+ convertConditionToBlade(expr: string, preParsed?: ParsedExpr): string
90
+ }
91
+
92
+ /**
93
+ * The contract the extracted in-template memo / context seeding
94
+ * (`memo/seed.ts`) depends on. The seed lowering recurses into the core
95
+ * expression lowering to compute a derived signal/memo value or a context
96
+ * default; that recursive entry is its only adapter coupling.
97
+ */
98
+ export interface BladeMemoContext {
99
+ /**
100
+ * Lower a JS expression to its Blade form (the core recursive entry). See
101
+ * `BladeSpreadContext.convertExpressionToBlade` for the `preParsed` contract.
102
+ */
103
+ convertExpressionToBlade(expr: string, preParsed?: ParsedExpr): string
104
+
105
+ /**
106
+ * Per-compile diagnostic list `convertExpressionToBlade` appends to on an
107
+ * unsupported shape (`_recordExprBF101`). `memo/seed.ts`'s
108
+ * `generateDerivedMemoSeed` is a SPECULATIVE "try this in-template
109
+ * recomputation, else fall back to the static ssrDefault seed" attempt per
110
+ * plan step — unlike every other `convertExpressionToBlade` call site, a
111
+ * failure here must NOT become a hard compile error, so it snapshots this
112
+ * array's length before calling in and truncates back to it on failure
113
+ * (discarding whatever `_recordExprBF101` appended) rather than letting
114
+ * the error escape.
115
+ */
116
+ readonly errors: CompilerError[]
117
+ }
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Array / string method lowering for the Blade template adapter.
3
+ *
4
+ * Ported from `packages/adapter-jinja/src/adapter/expr/array-method.ts`.
5
+ * Pure free functions shared by both the filter-context emitter and the
6
+ * top-level emitter — they take an `emit` callback for receiver / argument
7
+ * recursion and read no adapter instance state.
8
+ *
9
+ * The receiver/array helpers are the same runtime methods the Jinja adapter
10
+ * calls, invoked as `$bf->NAME(...)`.
11
+ */
12
+
13
+ import {
14
+ serializeParsedExpr,
15
+ freeVarsInBody,
16
+ } from '@barefootjs/jsx'
17
+ import type {
18
+ ParsedExpr,
19
+ ArrayMethod,
20
+ SortComparator,
21
+ FlatDepth,
22
+ } from '@barefootjs/jsx'
23
+ import { escapeBladeSingleQuoted, bladeHashKey } from '../lib/blade-naming.ts'
24
+
25
+ export function renderArrayMethod(
26
+ method: ArrayMethod,
27
+ object: ParsedExpr,
28
+ args: ParsedExpr[],
29
+ emit: (e: ParsedExpr) => string,
30
+ ): string {
31
+ switch (method) {
32
+ case 'join': {
33
+ // Route through the runtime (`$bf->join`) rather than a Blade builtin, so
34
+ // the JS-compat element handling (undef → empty, default separator) is
35
+ // applied consistently.
36
+ const obj = emit(object)
37
+ const sep = args.length >= 1 ? emit(args[0]) : `','`
38
+ return `$bf->join(${obj}, ${sep})`
39
+ }
40
+ case 'includes': {
41
+ const obj = emit(object)
42
+ const needle = emit(args[0])
43
+ return `$bf->includes(${obj}, ${needle})`
44
+ }
45
+ case 'indexOf':
46
+ case 'lastIndexOf': {
47
+ const fn = method === 'indexOf' ? 'index_of' : 'last_index_of'
48
+ const obj = emit(object)
49
+ const needle = emit(args[0])
50
+ return `$bf->${fn}(${obj}, ${needle})`
51
+ }
52
+ case 'at': {
53
+ const obj = emit(object)
54
+ const idx = args.length >= 1 ? emit(args[0]) : '0'
55
+ return `$bf->at(${obj}, ${idx})`
56
+ }
57
+ case 'concat': {
58
+ if (args.length === 0) {
59
+ return emit(object)
60
+ }
61
+ const a = emit(object)
62
+ const b = emit(args[0])
63
+ return `$bf->concat(${a}, ${b})`
64
+ }
65
+ case 'slice': {
66
+ const recv = emit(object)
67
+ const start = args.length >= 1 ? emit(args[0]) : '0'
68
+ // Blade's undefined-literal is `null`, not Kolon's `nil` or Jinja's
69
+ // `none` — the runtime `slice` treats it as "to end".
70
+ const end = args.length >= 2 ? emit(args[1]) : 'null'
71
+ return `$bf->slice(${recv}, ${start}, ${end})`
72
+ }
73
+ case 'reverse':
74
+ case 'toReversed': {
75
+ const recv = emit(object)
76
+ return `$bf->reverse(${recv})`
77
+ }
78
+ case 'toLowerCase': {
79
+ // Route through the runtime (consistent with bf.includes / bf.slice /
80
+ // etc.) rather than a bare `|lower` filter, so a non-string operand
81
+ // still gets JS-compatible coercion first.
82
+ const recv = emit(object)
83
+ return `$bf->lc(${recv})`
84
+ }
85
+ case 'toUpperCase': {
86
+ const recv = emit(object)
87
+ return `$bf->uc(${recv})`
88
+ }
89
+ case 'trim': {
90
+ const recv = emit(object)
91
+ return `$bf->trim(${recv})`
92
+ }
93
+ case 'toFixed': {
94
+ // `.toFixed(digits?)` — `$bf->to_fixed` mirrors JS rounding +
95
+ // zero-padding (default 0 digits). #1897.
96
+ const recv = emit(object)
97
+ const digits = args.length >= 1 ? emit(args[0]) : '0'
98
+ return `$bf->to_fixed(${recv}, ${digits})`
99
+ }
100
+ case 'split': {
101
+ const recv = emit(object)
102
+ if (args.length === 0) {
103
+ return `$bf->split(${recv})`
104
+ }
105
+ const sep = emit(args[0])
106
+ if (args.length === 1) {
107
+ return `$bf->split(${recv}, ${sep})`
108
+ }
109
+ const limit = emit(args[1])
110
+ return `$bf->split(${recv}, ${sep}, ${limit})`
111
+ }
112
+ case 'startsWith':
113
+ case 'endsWith': {
114
+ const fn = method === 'startsWith' ? 'starts_with' : 'ends_with'
115
+ const recv = emit(object)
116
+ const arg = emit(args[0])
117
+ if (args.length >= 2) {
118
+ return `$bf->${fn}(${recv}, ${arg}, ${emit(args[1])})`
119
+ }
120
+ return `$bf->${fn}(${recv}, ${arg})`
121
+ }
122
+ case 'replace': {
123
+ const recv = emit(object)
124
+ const oldS = emit(args[0])
125
+ const newS = emit(args[1])
126
+ return `$bf->replace(${recv}, ${oldS}, ${newS})`
127
+ }
128
+ case 'repeat': {
129
+ const recv = emit(object)
130
+ const count = args.length === 0 ? '0' : emit(args[0])
131
+ return `$bf->repeat(${recv}, ${count})`
132
+ }
133
+ case 'padStart':
134
+ case 'padEnd': {
135
+ const fn = method === 'padStart' ? 'pad_start' : 'pad_end'
136
+ const recv = emit(object)
137
+ if (args.length === 0) {
138
+ return `$bf->${fn}(${recv}, 0)`
139
+ }
140
+ const target = emit(args[0])
141
+ if (args.length === 1) {
142
+ return `$bf->${fn}(${recv}, ${target})`
143
+ }
144
+ const pad = emit(args[1])
145
+ return `$bf->${fn}(${recv}, ${target}, ${pad})`
146
+ }
147
+ default: {
148
+ // TS-level exhaustiveness guard.
149
+ const _exhaustive: never = method
150
+ throw new Error(
151
+ `renderArrayMethod: unhandled ArrayMethod '${(_exhaustive as string)}'`,
152
+ )
153
+ }
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Build the `base_env` PHP-array argument for an evaluator call: the
159
+ * comparator / reducer body's free variables (body idents minus the
160
+ * callback params), each materialised to its SSR value via `emit`. An
161
+ * empty capture set yields `[]` — the runtime's seed-once env.
162
+ */
163
+ function emitEvalEnvArg(
164
+ body: ParsedExpr,
165
+ params: string[],
166
+ emit: (e: ParsedExpr) => string,
167
+ ): string {
168
+ const free = freeVarsInBody(body, new Set(params))
169
+ if (free.length === 0) return '[]'
170
+ const pairs = free.map(
171
+ n => `${bladeHashKey(n)} => ${emit({ kind: 'identifier', name: n })}`,
172
+ )
173
+ return `[${pairs.join(', ')}]`
174
+ }
175
+
176
+ /**
177
+ * Emit a `.sort(cmp)` / `.toSorted(cmp)` via the runtime evaluator (#2018):
178
+ * the comparator body travels as serialized-ParsedExpr JSON, evaluated per
179
+ * comparison against `{paramA, paramB, …captured}`. Returns null when the
180
+ * body is outside the evaluator surface (e.g. a `localeCompare` comparator —
181
+ * `serializeParsedExpr` refuses it), so the caller falls back to the
182
+ * structured `$bf->sort`. `params` are the comparator arrow's two params
183
+ * (`[paramA, paramB]`).
184
+ */
185
+ export function renderSortEval(
186
+ recv: string,
187
+ body: ParsedExpr,
188
+ params: string[],
189
+ emit: (e: ParsedExpr) => string,
190
+ ): string | null {
191
+ const json = serializeParsedExpr(body)
192
+ if (json === null) return null
193
+ // A comparator needs both params; a wrong-arity arrow would emit an
194
+ // 'undefined' param name, so fail over to the structured fallback / BF101
195
+ // (mirrors the Go / Jinja guards).
196
+ if (params.length < 2) return null
197
+ const [paramA, paramB] = params
198
+ const env = emitEvalEnvArg(body, params, emit)
199
+ return `$bf->sort_eval(${recv}, '${escapeBladeSingleQuoted(json)}', '${paramA}', '${paramB}', ${env})`
200
+ }
201
+
202
+ /**
203
+ * Emit a `.reduce(fn, init)` / `.reduceRight(fn, init)` via the runtime
204
+ * evaluator (#2018): the reducer body travels as serialized-ParsedExpr JSON,
205
+ * folded over the receiver from `init` in `direction` order. `params` are the
206
+ * reducer arrow's params (`[paramAcc, paramItem]`); `init` is the initial-value
207
+ * `ParsedExpr` from the call's trailing argument. Returns null when the body is
208
+ * outside the evaluator surface, or when `init` is not a literal string/number
209
+ * (→ caller refuses with BF101). A numeric seed passes through as a bare
210
+ * Blade number; a string seed as a single-quoted literal.
211
+ */
212
+ export function renderReduceEval(
213
+ recv: string,
214
+ body: ParsedExpr,
215
+ params: string[],
216
+ init: ParsedExpr,
217
+ direction: 'left' | 'right',
218
+ emit: (e: ParsedExpr) => string,
219
+ ): string | null {
220
+ const json = serializeParsedExpr(body)
221
+ if (json === null) return null
222
+ if (init.kind !== 'literal') return null
223
+ const initOut =
224
+ init.literalType === 'string'
225
+ ? `'${escapeBladeSingleQuoted(String(init.value))}'`
226
+ : init.literalType === 'number'
227
+ ? String(init.value)
228
+ : null
229
+ if (initOut === null) return null
230
+ // A reducer needs both the accumulator and element param; refuse a
231
+ // wrong-arity arrow cleanly (→ BF101) rather than emitting an 'undefined'
232
+ // param name (mirrors the Go / Jinja guards).
233
+ if (params.length < 2) return null
234
+ const [paramAcc, paramItem] = params
235
+ const env = emitEvalEnvArg(body, params, emit)
236
+ return `$bf->reduce_eval(${recv}, '${escapeBladeSingleQuoted(json)}', '${paramAcc}', '${paramItem}', ${initOut}, '${direction}', ${env})`
237
+ }
238
+
239
+ /**
240
+ * Emit a higher-order predicate call via the runtime evaluator (#2018, P2):
241
+ * `$bf->filter_eval` / `$bf->every_eval` / `$bf->some_eval` / `$bf->find_eval` /
242
+ * `$bf->find_index_eval`, carrying the serialized predicate body + captured env
243
+ * dict. Generalizes the lambda lowering to the same JS-faithful evaluator
244
+ * the Go/Jinja adapters use. Returns null when the predicate is outside the
245
+ * evaluator surface (e.g. a method-call predicate — `serializeParsedExpr`
246
+ * refuses it), so the caller falls back to the lambda form. `forward`
247
+ * (find / findIndex family only) selects the search direction — `false` =
248
+ * findLast / findLastIndex.
249
+ */
250
+ export function renderPredicateEval(
251
+ funcName: string,
252
+ recv: string,
253
+ predicate: ParsedExpr,
254
+ param: string,
255
+ emit: (e: ParsedExpr) => string,
256
+ forward?: boolean,
257
+ ): string | null {
258
+ const json = serializeParsedExpr(predicate)
259
+ if (json === null) return null
260
+ const env = emitEvalEnvArg(predicate, [param], emit)
261
+ const fwd = forward === undefined ? '' : `, ${forward ? 'true' : 'false'}`
262
+ return `$bf->${funcName}(${recv}, '${escapeBladeSingleQuoted(json)}', '${param}'${fwd}, ${env})`
263
+ }
264
+
265
+ /**
266
+ * Emit a `.flatMap(proj)` via the runtime evaluator (#2018, P3): the projection
267
+ * body serializes to JSON and `$bf->flat_map_eval` projects + flattens one
268
+ * level. `param` is the projection arrow's single param. Returns null when the
269
+ * projection is outside the evaluator surface (→ caller refuses with BF101).
270
+ */
271
+ export function renderFlatMapEval(
272
+ recv: string,
273
+ body: ParsedExpr,
274
+ param: string,
275
+ emit: (e: ParsedExpr) => string,
276
+ ): string | null {
277
+ const json = serializeParsedExpr(body)
278
+ if (json === null) return null
279
+ const env = emitEvalEnvArg(body, [param], emit)
280
+ return `$bf->flat_map_eval(${recv}, '${escapeBladeSingleQuoted(json)}', '${param}', ${env})`
281
+ }
282
+
283
+ /**
284
+ * Emit a value-producing `.map(cb)` via the runtime evaluator (#2073): the
285
+ * projection body serializes to JSON and `$bf->map_eval` projects each element,
286
+ * one result per element (no flatten — the JS `.map` contract). Composes
287
+ * through the array-method chain (`.map(cb).join(' ')`). Returns null when
288
+ * the projection is outside the evaluator surface (→ caller refuses with
289
+ * BF101). The JSX-returning `.map` is an IRLoop upstream and never reaches
290
+ * this emit.
291
+ */
292
+ export function renderMapEval(
293
+ recv: string,
294
+ body: ParsedExpr,
295
+ param: string,
296
+ emit: (e: ParsedExpr) => string,
297
+ ): string | null {
298
+ const json = serializeParsedExpr(body)
299
+ if (json === null) return null
300
+ const env = emitEvalEnvArg(body, [param], emit)
301
+ return `$bf->map_eval(${recv}, '${escapeBladeSingleQuoted(json)}', '${param}', ${env})`
302
+ }
303
+
304
+ /**
305
+ * Shared Blade emit for `.sort(cmp)` / `.toSorted(cmp)`. Used by both the
306
+ * filter-context emitter and the top-level emitter, plus the loop-array
307
+ * wrap in `renderLoop`. The runtime `$bf->sort` accepts an opts dict and
308
+ * returns a fresh list.
309
+ */
310
+ export function renderSortMethod(recv: string, c: SortComparator): string {
311
+ const keyDicts = c.keys.map((k) => {
312
+ const keyEntry =
313
+ k.key.kind === 'self'
314
+ ? `'key_kind' => 'self'`
315
+ : `'key_kind' => 'field', 'key' => '${k.key.field}'`
316
+ return `[${keyEntry}, 'compare_type' => '${k.type}', 'direction' => '${k.direction}']`
317
+ })
318
+ return `$bf->sort(${recv}, ['keys' => [${keyDicts.join(', ')}]])`
319
+ }
320
+
321
+ // `.flat(depth?)` → `$bf->flat(recv, depth)`.
322
+ export function renderFlatMethod(
323
+ recv: string,
324
+ depth: FlatDepth | { expr: ParsedExpr },
325
+ emit: (e: ParsedExpr) => string,
326
+ ): string {
327
+ if (typeof depth === 'object') {
328
+ // Dynamic depth (#2094): routed to a distinct runtime helper,
329
+ // `$bf->flat_dynamic`, rather than reusing `$bf->flat`. The literal-depth
330
+ // path (`$bf->flat`) bakes `-1` in as a compile-time SENTINEL meaning "the
331
+ // source literally said `Infinity`". A genuinely dynamic depth value
332
+ // that happens to evaluate to `-1` at render time means the OPPOSITE in
333
+ // real JS (`[1,[2]].flat(-1)` never recurses — same as `.flat(0)`).
334
+ // Since both paths would otherwise hand the same literal-looking `-1`
335
+ // argument to one shared function, that function cannot tell which case
336
+ // it's in — so it must be two different runtime entry points.
337
+ // `$bf->flat_dynamic` performs the JS `ToIntegerOrInfinity` coercion on its
338
+ // second argument at render time (truncate toward zero; negative → 0;
339
+ // NaN/non-numeric → 0; +Infinity or a huge finite value → flatten
340
+ // fully) before delegating to the same recursive flattening as `flat`.
341
+ return `$bf->flat_dynamic(${recv}, ${emit(depth.expr)})`
342
+ }
343
+ const d = depth === 'infinity' ? -1 : depth
344
+ return `$bf->flat(${recv}, ${d})`
345
+ }