@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.
- package/README.md +73 -0
- package/dist/adapter/analysis/component-tree.d.ts +26 -0
- package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
- package/dist/adapter/blade-adapter.d.ts +537 -0
- package/dist/adapter/blade-adapter.d.ts.map +1 -0
- package/dist/adapter/boolean-result.d.ts +76 -0
- package/dist/adapter/boolean-result.d.ts.map +1 -0
- package/dist/adapter/emit-context.d.ts +105 -0
- package/dist/adapter/emit-context.d.ts.map +1 -0
- package/dist/adapter/expr/array-method.d.ts +74 -0
- package/dist/adapter/expr/array-method.d.ts.map +1 -0
- package/dist/adapter/expr/emitters.d.ts +176 -0
- package/dist/adapter/expr/emitters.d.ts.map +1 -0
- package/dist/adapter/expr/operand.d.ts +25 -0
- package/dist/adapter/expr/operand.d.ts.map +1 -0
- package/dist/adapter/index.d.ts +6 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/index.js +189060 -0
- package/dist/adapter/lib/blade-naming.d.ts +128 -0
- package/dist/adapter/lib/blade-naming.d.ts.map +1 -0
- package/dist/adapter/lib/constants.d.ts +21 -0
- package/dist/adapter/lib/constants.d.ts.map +1 -0
- package/dist/adapter/lib/ir-scope.d.ts +48 -0
- package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
- package/dist/adapter/lib/types.d.ts +27 -0
- package/dist/adapter/lib/types.d.ts.map +1 -0
- package/dist/adapter/memo/seed.d.ts +84 -0
- package/dist/adapter/memo/seed.d.ts.map +1 -0
- package/dist/adapter/props/prop-classes.d.ts +34 -0
- package/dist/adapter/props/prop-classes.d.ts.map +1 -0
- package/dist/adapter/spread/spread-codegen.d.ts +72 -0
- package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
- package/dist/adapter/value/parsed-literal.d.ts +27 -0
- package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
- package/dist/build.d.ts +28 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +189080 -0
- package/dist/conformance-pins.d.ts +12 -0
- package/dist/conformance-pins.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +189079 -0
- package/package.json +67 -0
- package/php/composer.json +32 -0
- package/php/src/BladeBackend.php +197 -0
- package/php/src/naming.php +84 -0
- package/php/tests/test_render.php +159 -0
- package/src/__tests__/blade-adapter-unit.test.ts +392 -0
- package/src/__tests__/blade-adapter.test.ts +52 -0
- package/src/__tests__/blade-counter.test.ts +63 -0
- package/src/__tests__/blade-query-href.test.ts +113 -0
- package/src/__tests__/blade-spread-attrs.test.ts +235 -0
- package/src/adapter/analysis/component-tree.ts +119 -0
- package/src/adapter/blade-adapter.ts +1912 -0
- package/src/adapter/boolean-result.ts +168 -0
- package/src/adapter/emit-context.ts +117 -0
- package/src/adapter/expr/array-method.ts +345 -0
- package/src/adapter/expr/emitters.ts +636 -0
- package/src/adapter/expr/operand.ts +35 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/lib/blade-naming.ts +180 -0
- package/src/adapter/lib/constants.ts +33 -0
- package/src/adapter/lib/ir-scope.ts +83 -0
- package/src/adapter/lib/types.ts +30 -0
- package/src/adapter/memo/seed.ts +135 -0
- package/src/adapter/props/prop-classes.ts +66 -0
- package/src/adapter/spread/spread-codegen.ts +179 -0
- package/src/adapter/value/parsed-literal.ts +75 -0
- package/src/build.ts +37 -0
- package/src/conformance-pins.ts +86 -0
- package/src/index.ts +9 -0
- 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, '&')
|
|
1480
|
+
.replace(/"/g, '"')
|
|
1481
|
+
.replace(/'/g, ''')
|
|
1482
|
+
.replace(/</g, '<')
|
|
1483
|
+
.replace(/>/g, '>')
|
|
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()
|