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