@barefootjs/php 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.
@@ -0,0 +1,1705 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Barefoot;
6
+
7
+ /**
8
+ * Port of packages/adapter-perl/lib/BarefootJS.pm (see also the Python port
9
+ * packages/adapter-jinja/python/barefootjs/runtime.py).
10
+ *
11
+ * Engine- and framework-agnostic server runtime for BarefootJS marked
12
+ * templates. This class is the server-side runtime the compiled marked
13
+ * templates call into at render time (as the `bf` object:
14
+ * `{{ bf.scope_attr() }}`, `{{ bf.json(data) }}`, `{{ bf.spread_attrs(bag) }}`
15
+ * for the Twig syntax the `@barefootjs/twig` adapter emits -- other PHP
16
+ * engine adapters bind the same methods in their own template syntax).
17
+ * Every operation that depends on *how* a template is rendered -- JSON
18
+ * marshalling, raw-string marking, JSX-children materialisation,
19
+ * named-template rendering, and template-variable-name mangling -- is
20
+ * delegated to a pluggable `backend` (see e.g. `packages/adapter-twig`'s
21
+ * `TwigBackend`), mirroring the Perl runtime's `BarefootJS::Backend::*` seam
22
+ * and the Python port's `JinjaBackend` seam.
23
+ *
24
+ * Method names are kept snake_case and VERBATIM from the Perl runtime
25
+ * (`render_child`, `scope_attr`, `hydration_attrs`, ...) per the adapter
26
+ * design doc section 2 -- the TS emitter generates calls to these exact
27
+ * names, and Twig resolves `bf.foo(...)` to the PHP method `foo`.
28
+ *
29
+ * Divergences from the Perl port (mirroring the Python port's documented
30
+ * divergences where the same reasoning applies):
31
+ *
32
+ * - `new`/`__construct` does not lazily fall back to a default framework
33
+ * backend (Perl falls back to `BarefootJS::Backend::Mojo`). This
34
+ * engine-agnostic runtime package ships NO backend implementations --
35
+ * each PHP engine adapter package (e.g. `packages/adapter-twig`'s
36
+ * `TwigBackend`) supplies exactly one; a host MUST inject it via
37
+ * `new BarefootJS($c, ['backend' => $backend])`.
38
+ * - The per-render mutable state Perl mutates through dual get/set
39
+ * accessors (`_scope_id`, `_bf_parent`, `_bf_mount`, `_props`,
40
+ * `_data_key`, `_is_child`, `_scripts`, `_script_seen`,
41
+ * `_child_renderers`) keeps the SAME call-as-getter/call-as-setter shape
42
+ * here (via `__call`), because generated render scripts and ported
43
+ * tests call them exactly as the Perl/Python harnesses do
44
+ * (`$bf->_scope_id('Widget_test')`).
45
+ * - PHP has a cycle-collecting garbage collector (like CPython), so
46
+ * `register_components_from_manifest` captures `$parent` directly with
47
+ * no `weaken()` dance, unlike the Perl port (which has no cyclic GC by
48
+ * default) -- functionally equivalent, no leak. Same reasoning the
49
+ * Python port documents.
50
+ * - `index_of` / `last_index_of` compare array elements with
51
+ * `Evaluator::strictEq()` (JS `===` semantics) rather than Perl's `eq`
52
+ * stringy comparison -- PHP's `json_decode()` (without `assoc`) already
53
+ * preserves the JS type distinction (int vs. float vs. string vs. bool),
54
+ * so no stringify-both-sides workaround is needed; this is a strict
55
+ * improvement over the Perl port's documented "cross-type probe is
56
+ * strict-equality false" divergence AND avoids the Python port's noted
57
+ * `bool` -is-an-`int`-subclass wrinkle (PHP `bool` is its own type).
58
+ * - `spread_attrs`'s boolean-attribute detection uses `is_bool()` --
59
+ * PHP has a real boolean type, so no Perl-style `JSON::PP::Boolean`
60
+ * sentinel-ref dance is needed.
61
+ * - `truthy`, `mod`, and `eq`/`neq` do not exist in the Perl runtime
62
+ * (`eq`/`neq` are the NEW helpers the design doc calls out; `truthy` and
63
+ * `mod` mirror the Python-port additions the Twig TS emitter's lowering
64
+ * policy needs uniformly for JS-truthiness condition routing and JS `%`).
65
+ */
66
+ final class BarefootJS
67
+ {
68
+ /** @var mixed Framework controller/context (kept for API parity; unused internally). */
69
+ public $c;
70
+
71
+ /** @var array<string, mixed> */
72
+ public array $config;
73
+
74
+ /** @var mixed The pluggable rendering backend (e.g. TwigBackend). */
75
+ public $backend;
76
+
77
+ /** @var array<string, mixed> Backing store for the dual get/set accessors. */
78
+ private array $attrs = [];
79
+
80
+ /** @var array<string, list<mixed>> SSR mirror of client provideContext/useContext. */
81
+ private static array $contextStacks = [];
82
+
83
+ /** @var list<string> Dual get/set (Perl accessor-base) attribute names that
84
+ * default to `null` when unread. */
85
+ private const SIMPLE_ACCESSORS = ['_scope_id', '_bf_parent', '_bf_mount', '_props', '_data_key'];
86
+
87
+ public function __construct($c = null, array $config = [])
88
+ {
89
+ $this->c = $c;
90
+ $this->config = $config;
91
+ $this->backend = $config['backend'] ?? null;
92
+ }
93
+
94
+ /**
95
+ * Dual get/set accessors mirroring the Perl accessor base: calling with
96
+ * no args reads (building the default on first access for the
97
+ * collection-typed attrs); calling with one positional arg writes and
98
+ * returns `$this` for chaining. Covers `_scope_id`, `_bf_parent`,
99
+ * `_bf_mount`, `_props`, `_data_key`, `_is_child`, `_scripts`,
100
+ * `_script_seen`, `_child_renderers` -- the internal state generated
101
+ * render scripts and the test harness poke directly (e.g.
102
+ * `$bf->_scope_id('Widget_test')`), mirroring the Python port's
103
+ * `_dual_accessor` factory.
104
+ */
105
+ public function __call(string $name, array $args)
106
+ {
107
+ if (in_array($name, self::SIMPLE_ACCESSORS, true)) {
108
+ if ($args) {
109
+ $this->attrs[$name] = $args[0];
110
+ return $this;
111
+ }
112
+ return $this->attrs[$name] ?? null;
113
+ }
114
+ if ($name === '_is_child') {
115
+ if ($args) {
116
+ $this->attrs[$name] = $args[0];
117
+ return $this;
118
+ }
119
+ return $this->attrs[$name] ?? false;
120
+ }
121
+ if ($name === '_scripts' || $name === '_script_seen' || $name === '_child_renderers') {
122
+ if ($args) {
123
+ $this->attrs[$name] = $args[0];
124
+ return $this;
125
+ }
126
+ if (!isset($this->attrs[$name])) {
127
+ $this->attrs[$name] = [];
128
+ }
129
+ return $this->attrs[$name];
130
+ }
131
+ throw new \BadMethodCallException("Call to undefined method Barefoot\\BarefootJS::{$name}()");
132
+ }
133
+
134
+ // -----------------------------------------------------------------
135
+ // search_params(query='')
136
+ // -----------------------------------------------------------------
137
+
138
+ public static function search_params(string $query = ''): SearchParams
139
+ {
140
+ return new SearchParams($query);
141
+ }
142
+
143
+ // -----------------------------------------------------------------
144
+ // Scope & Props
145
+ // -----------------------------------------------------------------
146
+
147
+ public function scope_attr(): string
148
+ {
149
+ // bf-s is the addressable scope id only (#1249).
150
+ $id = $this->_scope_id();
151
+ return $id !== null ? (string) $id : '';
152
+ }
153
+
154
+ /** Emits `bf-h="<host>" bf-m="<slot>" bf-r=""` conditionally. See
155
+ * spec/compiler.md "Slot identity". */
156
+ public function hydration_attrs(): string
157
+ {
158
+ $parts = [];
159
+ $host = $this->_bf_parent();
160
+ $mount = $this->_bf_mount();
161
+ if ($host !== null && $host !== '') {
162
+ $h = str_replace('"', '&quot;', $this->string($host));
163
+ $parts[] = 'bf-h="' . $h . '"';
164
+ }
165
+ if ($mount !== null && $mount !== '') {
166
+ $m = str_replace('"', '&quot;', $this->string($mount));
167
+ $parts[] = 'bf-m="' . $m . '"';
168
+ }
169
+ if (!$this->_is_child()) {
170
+ $parts[] = 'bf-r=""';
171
+ }
172
+ return implode(' ', $parts);
173
+ }
174
+
175
+ /** Emits ` data-key="<key>"` for a keyed loop item, else ''. */
176
+ public function data_key_attr(): string
177
+ {
178
+ $k = $this->_data_key();
179
+ if ($k === null) {
180
+ return '';
181
+ }
182
+ $ks = str_replace(['&', '"'], ['&amp;', '&quot;'], $this->string($k));
183
+ return ' data-key="' . $ks . '"';
184
+ }
185
+
186
+ public function props_attr(): string
187
+ {
188
+ $props = $this->_props();
189
+ if (!self::hasEntries($props)) {
190
+ return '';
191
+ }
192
+ // The JSON must be attribute-escaped: a raw `'` inside a string value
193
+ // (e.g. a blog paragraph) terminates the single-quoted attribute and
194
+ // truncates the hydration payload. The browser entity-decodes the
195
+ // attribute value, so the client's JSON.parse sees the original text
196
+ // (#2086, same fix as the Perl/Python/Ruby/Rust runtimes).
197
+ $json = $this->htmlEscape($this->backend->encode_json($props));
198
+ return " bf-p='" . $json . "'";
199
+ }
200
+
201
+ // -----------------------------------------------------------------
202
+ // Context (SSR mirror of the client provideContext / useContext)
203
+ // -----------------------------------------------------------------
204
+
205
+ public function provide_context(string $name, $value): string
206
+ {
207
+ self::$contextStacks[$name][] = $value;
208
+ return '';
209
+ }
210
+
211
+ public function revoke_context(string $name): string
212
+ {
213
+ if (!empty(self::$contextStacks[$name])) {
214
+ array_pop(self::$contextStacks[$name]);
215
+ }
216
+ return '';
217
+ }
218
+
219
+ public function use_context(string $name, $default = null)
220
+ {
221
+ if (empty(self::$contextStacks[$name])) {
222
+ return $default;
223
+ }
224
+ return end(self::$contextStacks[$name]);
225
+ }
226
+
227
+ // -----------------------------------------------------------------
228
+ // Comment Markers
229
+ // -----------------------------------------------------------------
230
+
231
+ public function comment(string $text): string
232
+ {
233
+ return "<!--bf-{$text}-->";
234
+ }
235
+
236
+ // -----------------------------------------------------------------
237
+ // Async streaming boundary (#2100)
238
+ // -----------------------------------------------------------------
239
+
240
+ /**
241
+ * Wrap a synchronously-resolved async boundary's fallback markup in a
242
+ * `<div bf-async="ID">` placeholder. Ported from the Python/Perl/Ruby
243
+ * runtimes' `async_boundary` (`packages/adapter-jinja/python/barefootjs/
244
+ * runtime.py`, `packages/adapter-perl/lib/BarefootJS.pm`,
245
+ * `packages/adapter-erb/lib/barefoot_js.rb`) -- was previously missing
246
+ * from this PHP port entirely; Twig's dot-attribute call resolution
247
+ * silently no-ops an unresolvable method under `strict_variables:
248
+ * false`, masking the gap, but Blade's `$bf->async_boundary(...)` is a
249
+ * direct PHP method call with no such fallback, surfacing it as a hard
250
+ * `BadMethodCallException` (#2100 Phase 2). `$fallbackHtml` is
251
+ * materialized through the backend first (mirrors `render_child`'s
252
+ * `children` handling) so a captured-but-not-yet-stringified value
253
+ * (e.g. a closure, on backends that model capture that way) renders as
254
+ * its resolved HTML rather than a PHP value dump.
255
+ */
256
+ public function async_boundary(string $id, $fallbackHtml): string
257
+ {
258
+ $fallbackHtml = $this->backend->materialize($fallbackHtml);
259
+ return "<div bf-async=\"{$id}\">{$fallbackHtml}</div>";
260
+ }
261
+
262
+ /**
263
+ * Wrap resolved async content in a `<template bf-async-resolve="ID">`
264
+ * swap target plus the inline swap-trigger script. Ported from the same
265
+ * sibling runtimes as `async_boundary` above; not currently invoked by
266
+ * any first-party adapter template emitter (both Twig's and Blade's
267
+ * `renderAsync` only emit the synchronous `async_boundary` fallback
268
+ * wrapper), but included for parity with the engine-agnostic contract
269
+ * every other PHP-runtime-consuming adapter can rely on.
270
+ */
271
+ public function async_resolve(string $id, $contentHtml): string
272
+ {
273
+ return "<template bf-async-resolve=\"{$id}\">{$contentHtml}</template><script>__bf_swap(\"{$id}\")</script>";
274
+ }
275
+
276
+ // -----------------------------------------------------------------
277
+ // JS-equivalent value stringification
278
+ // -----------------------------------------------------------------
279
+
280
+ /** Contract is boolean-only: callers must have classified the
281
+ * expression as boolean-result before routing through this helper. */
282
+ public function bool_str($value): string
283
+ {
284
+ return $value ? 'true' : 'false';
285
+ }
286
+
287
+ public function text_start(string $slotId): string
288
+ {
289
+ return "<!--bf:{$slotId}-->";
290
+ }
291
+
292
+ public function text_end(): string
293
+ {
294
+ return '<!--/-->';
295
+ }
296
+
297
+ /** See spec/compiler.md "Slot identity" for the comment-scope wire format. */
298
+ public function scope_comment(): string
299
+ {
300
+ $scopeId = $this->_scope_id() ?? '';
301
+ $hostSegment = '';
302
+ $host = $this->_bf_parent();
303
+ $mount = $this->_bf_mount();
304
+ if ($host !== null && $host !== '') {
305
+ $hostSegment = '|h=' . $host . '|m=' . ($mount ?? '');
306
+ }
307
+ $propsJson = '';
308
+ $props = $this->_props();
309
+ if (self::hasEntries($props)) {
310
+ $propsJson = '|' . $this->backend->encode_json($props);
311
+ }
312
+ return "<!--bf-scope:{$scopeId}{$hostSegment}{$propsJson}-->";
313
+ }
314
+
315
+ // -----------------------------------------------------------------
316
+ // Script Registration
317
+ // -----------------------------------------------------------------
318
+
319
+ public function register_script(string $path): void
320
+ {
321
+ $seen = $this->_script_seen();
322
+ if (!empty($seen[$path])) {
323
+ return;
324
+ }
325
+ $seen[$path] = true;
326
+ $this->_script_seen($seen);
327
+ $scripts = $this->_scripts();
328
+ $scripts[] = $path;
329
+ $this->_scripts($scripts);
330
+ }
331
+
332
+ // -----------------------------------------------------------------
333
+ // Child Component Rendering
334
+ // -----------------------------------------------------------------
335
+
336
+ /** Register a renderer for `render_child($name, ...)`. The renderer is
337
+ * invoked as `$renderer($props, $invokingBf)` (#1897). */
338
+ public function register_child_renderer(string $name, callable $renderer): void
339
+ {
340
+ $renderers = $this->_child_renderers();
341
+ $renderers[$name] = $renderer;
342
+ $this->_child_renderers($renderers);
343
+ }
344
+
345
+ /**
346
+ * Renderer contract (#1897): the renderer is invoked with TWO arguments
347
+ * -- the props array and the INVOKING instance. Accepts both the
348
+ * flat-pairs form -- `bf.render_child(name, 'k', v, ...)` -- and the
349
+ * single-array form -- `bf.render_child(name, {'k': v})` -- mirroring
350
+ * the Perl port's hashref form for callers that can't splat a hash into
351
+ * positional args.
352
+ */
353
+ public function render_child(string $name, ...$args)
354
+ {
355
+ $renderers = $this->_child_renderers();
356
+ $renderer = $renderers[$name] ?? null;
357
+ if ($renderer === null) {
358
+ throw new \RuntimeException("No renderer registered for child component '{$name}'");
359
+ }
360
+ if (count($args) === 1 && (is_array($args[0]) || $args[0] instanceof \stdClass)) {
361
+ $props = $args[0] instanceof \stdClass ? get_object_vars($args[0]) : $args[0];
362
+ } else {
363
+ $props = [];
364
+ for ($i = 0; $i + 1 < count($args); $i += 2) {
365
+ $props[$args[$i]] = $args[$i + 1];
366
+ }
367
+ }
368
+ // Guard on array_key_exists so a childless invocation doesn't gain a
369
+ // spurious `children: null` key -- preserves the historical
370
+ // "only touch children when present" behaviour.
371
+ if (array_key_exists('children', $props)) {
372
+ $props['children'] = $this->backend->materialize($props['children']);
373
+ }
374
+ // Keyword mangling applied wherever a props array becomes template
375
+ // variables -- delegated to the backend's `ident()` (the fifth
376
+ // engine-specific operation in the backend contract) so this
377
+ // engine-agnostic runtime carries no engine-specific reserved-word
378
+ // set of its own (e.g. Twig's, in `packages/adapter-twig/php/src/naming.php`).
379
+ $mangled = [];
380
+ foreach ($props as $k => $v) {
381
+ $mangled[$this->backend->ident((string) $k)] = $v;
382
+ }
383
+ return $renderer($mangled, $this);
384
+ }
385
+
386
+ // -----------------------------------------------------------------
387
+ // Bulk registration from build manifest
388
+ // -----------------------------------------------------------------
389
+
390
+ /**
391
+ * `bf build` emits a manifest describing every component the page might
392
+ * invoke. Walks that manifest and registers one child renderer per UI
393
+ * registry entry -- the path shape `ui/<name>/index` maps to the
394
+ * `<name>` slot key the generated template invokes via
395
+ * `bf.render_child('<name>', ...)`.
396
+ *
397
+ * `$manifest` may be a `stdClass` (canonical JSON-object decode) or a
398
+ * plain associative array; likewise each entry and its `ssrDefaults`.
399
+ */
400
+ public function register_components_from_manifest($manifest, array $opts = []): void
401
+ {
402
+ $signalInits = $opts['signal_init'] ?? [];
403
+ $parentScope = $this->_scope_id();
404
+ $parent = $this;
405
+
406
+ foreach (self::toAssoc($manifest) as $entryName => $entry) {
407
+ // `__barefoot__` is the runtime entry, not a component.
408
+ if ($entryName === '__barefoot__') {
409
+ continue;
410
+ }
411
+ if (!preg_match('#^ui/([^/]+)/index$#', (string) $entryName, $m)) {
412
+ continue;
413
+ }
414
+ $slotKey = $m[1];
415
+ $entryArr = self::toAssoc($entry);
416
+ $marked = $entryArr['markedTemplate'] ?? '';
417
+ if ($marked === '' || $marked === null) {
418
+ continue;
419
+ }
420
+ $templateName = (string) $marked;
421
+ if (str_starts_with($templateName, 'templates/')) {
422
+ $templateName = substr($templateName, strlen('templates/'));
423
+ }
424
+ foreach (['.html.ep', '.tx', '.jinja', '.twig'] as $suffix) {
425
+ if (str_ends_with($templateName, $suffix)) {
426
+ $templateName = substr($templateName, 0, -strlen($suffix));
427
+ break;
428
+ }
429
+ }
430
+
431
+ $signalInit = $signalInits[$slotKey] ?? null;
432
+ $manifestDefaults = $entryArr['ssrDefaults'] ?? null;
433
+
434
+ $renderer = function (array $props, ?BarefootJS $caller = null) use ($parent, $parentScope, $templateName, $signalInit, $manifestDefaults) {
435
+ $host = $caller ?? $parent;
436
+ $hostScope = $host->_scope_id() ?? $parentScope;
437
+ // Child shares the parent's backend so nested renders go
438
+ // through the same engine.
439
+ $childBf = new BarefootJS($parent->c, ['backend' => $parent->backend]);
440
+ $slotId = $props['_bf_slot'] ?? null;
441
+ unset($props['_bf_slot']);
442
+ // JSX `key` (a reserved prop) -> data-key on the child's
443
+ // scope root for keyed-loop reconciliation.
444
+ $dataKey = $props['key'] ?? null;
445
+ unset($props['key']);
446
+ if ($dataKey !== null) {
447
+ $childBf->_data_key($dataKey);
448
+ }
449
+ if ($slotId) {
450
+ $childBf->_scope_id($hostScope . '_' . $slotId);
451
+ } else {
452
+ $childBf->_scope_id($templateName . '_' . substr(bin2hex(random_bytes(4)), 0, 6));
453
+ }
454
+ $childBf->_is_child(true);
455
+ // (#1249) Slot identity: host scope + slot id.
456
+ if ($slotId) {
457
+ $childBf->_bf_parent($hostScope);
458
+ $childBf->_bf_mount($slotId);
459
+ }
460
+ // Share the root registry so the child's own template can
461
+ // render further imported components (#1897).
462
+ $childBf->_child_renderers($parent->_child_renderers());
463
+ $childBf->_scripts($parent->_scripts());
464
+ $childBf->_script_seen($parent->_script_seen());
465
+
466
+ $extra = [];
467
+ if ($signalInit !== null) {
468
+ $extra = $signalInit($props);
469
+ } elseif ($manifestDefaults !== null) {
470
+ $extra = self::deriveStashFromDefaults($manifestDefaults, $props);
471
+ }
472
+
473
+ $html = $parent->backend->render_named($templateName, $childBf, array_merge($props, $extra));
474
+ if (is_string($html) && str_ends_with($html, "\n")) {
475
+ $html = substr($html, 0, -1); // chomp: remove at most one trailing newline
476
+ }
477
+ return $html;
478
+ };
479
+
480
+ $this->register_child_renderer($slotKey, $renderer);
481
+ }
482
+ }
483
+
484
+ /** `$manifest`/`$entry` values may arrive as `stdClass` (canonical) or
485
+ * a plain associative array (accepted per the design's tolerance
486
+ * convention). Normalises either shape to an associative array; a
487
+ * non-object/array value normalises to `[]`. */
488
+ private static function toAssoc($v): array
489
+ {
490
+ if ($v instanceof \stdClass) {
491
+ return get_object_vars($v);
492
+ }
493
+ if (is_array($v)) {
494
+ return $v;
495
+ }
496
+ return [];
497
+ }
498
+
499
+ /** True for a non-empty JSON object/array value under either canonical
500
+ * shape (stdClass or a non-empty PHP array). */
501
+ private static function hasEntries($v): bool
502
+ {
503
+ if ($v instanceof \stdClass) {
504
+ return (bool) get_object_vars($v);
505
+ }
506
+ if (is_array($v)) {
507
+ return (bool) $v;
508
+ }
509
+ return false;
510
+ }
511
+
512
+ /** Derive template-stash kvs from a manifest entry's `ssrDefaults`
513
+ * section. Each entry shape: `{value, propName, isRestProps}`. */
514
+ private static function deriveStashFromDefaults($defaults, array $props): array
515
+ {
516
+ $extra = [];
517
+ foreach (self::toAssoc($defaults) as $name => $d) {
518
+ $dArr = ($d instanceof \stdClass || is_array($d)) ? self::toAssoc($d) : null;
519
+ if ($dArr === null) {
520
+ $extra[$name] = $d;
521
+ continue;
522
+ }
523
+ if (!empty($dArr['isRestProps'])) {
524
+ $extra[$name] = array_key_exists($name, $props) ? $props[$name] : ($dArr['value'] ?? null);
525
+ continue;
526
+ }
527
+ $propName = $dArr['propName'] ?? null;
528
+ if ($propName !== null && array_key_exists($propName, $props) && $props[$propName] !== null) {
529
+ $extra[$name] = $props[$propName];
530
+ } else {
531
+ $extra[$name] = $dArr['value'] ?? null;
532
+ }
533
+ }
534
+ return $extra;
535
+ }
536
+
537
+ // -----------------------------------------------------------------
538
+ // Script Output
539
+ // -----------------------------------------------------------------
540
+
541
+ public function scripts(): string
542
+ {
543
+ $tags = [];
544
+ foreach ($this->_scripts() as $path) {
545
+ $tags[] = '<script type="module" src="' . $path . '"></script>';
546
+ }
547
+ return implode("\n", $tags);
548
+ }
549
+
550
+ // -----------------------------------------------------------------
551
+ // JS-compat callees (#1189) -- invoked from generated Twig templates as
552
+ // `{{ bf.json(val) }}`, `{{ bf.floor(val) }}`, etc.
553
+ // -----------------------------------------------------------------
554
+
555
+ public function json($value): string
556
+ {
557
+ return $this->backend->encode_json($value);
558
+ }
559
+
560
+ /** JS `String(v)` mirror: `null` renders as the empty string (not
561
+ * "null") so an unset prop doesn't surface a literal "null"/"undefined"
562
+ * in user-facing HTML (documented divergence, matches the Perl/Python
563
+ * ports).
564
+ *
565
+ * A per-engine "already-safe markup" wrapper (`\Twig\Markup` for the
566
+ * Twig backend, `\Illuminate\Support\HtmlString` for the Blade backend —
567
+ * see #2100) passes through UNCHANGED (object identity, not its string
568
+ * content): captured children / `render_child` output arrive at a
569
+ * text-interpolation position wrapped in the engine's own markup type,
570
+ * and only that wrapped instance survives the ENGINE's own autoescaper
571
+ * un-escaped (Twig's `{{ }}`, Blade's `{!! e(...) !!}` — see each
572
+ * backend's file header). Rather than hard-coding each engine's concrete
573
+ * class here (which would make this engine-agnostic runtime depend on
574
+ * knowing every backend that will ever exist), this checks the built-in
575
+ * `\Stringable` interface: EVERY value this runtime's own `mark_raw`
576
+ * implementations ever produce declares (or auto-implements, PHP 8's
577
+ * behavior for any class with `__toString()`) `\Stringable` — verified
578
+ * for both `Twig\Markup` and `Illuminate\Support\HtmlString` — while the
579
+ * OTHER object shapes this method ever receives (JSON-decoded
580
+ * `stdClass`, plain assoc arrays) never do. The Python port gets the
581
+ * Twig case for free (`markupsafe.Markup` is a `str` subclass, so
582
+ * `js_string` returns it via the string arm, safety intact); PHP's
583
+ * markup wrappers are not strings, so the pass-through must be explicit
584
+ * here.
585
+ *
586
+ * @return string|\Stringable
587
+ */
588
+ public function string($value)
589
+ {
590
+ if ($value instanceof \Stringable) {
591
+ return $value;
592
+ }
593
+ if ($value === null) {
594
+ return '';
595
+ }
596
+ if (is_bool($value)) {
597
+ return $value ? 'true' : 'false';
598
+ }
599
+ if (is_int($value)) {
600
+ return (string) $value;
601
+ }
602
+ if (is_float($value)) {
603
+ if (is_nan($value)) {
604
+ return 'NaN';
605
+ }
606
+ if ($value === INF) {
607
+ return 'Infinity';
608
+ }
609
+ if ($value === -INF) {
610
+ return '-Infinity';
611
+ }
612
+ return Evaluator::formatNumber($value);
613
+ }
614
+ if (is_string($value)) {
615
+ return $value;
616
+ }
617
+ if (Evaluator::isJsArray($value)) {
618
+ // JS `Array.prototype.toString` == `.join(',')`.
619
+ $parts = array_map(fn ($v) => $v === null ? '' : $this->string($v), $value);
620
+ return implode(',', $parts);
621
+ }
622
+ return '[object Object]'; // stdClass or a non-list (assoc) array
623
+ }
624
+
625
+ /** JS `Number(v)` mirror. Deliberate divergence (matches the Perl/Python
626
+ * ports): `null` and non-numeric/empty strings yield real NaN (not 0),
627
+ * so an unset prop / parse failure can't silently zero downstream
628
+ * arithmetic. */
629
+ public function number($value): float
630
+ {
631
+ if ($value === null) {
632
+ return NAN;
633
+ }
634
+ if (is_bool($value)) {
635
+ return $value ? 1.0 : 0.0;
636
+ }
637
+ if (is_int($value) || is_float($value)) {
638
+ return (float) $value;
639
+ }
640
+ if (is_string($value)) {
641
+ $t = trim($value);
642
+ if ($t === '') {
643
+ return NAN;
644
+ }
645
+ return Evaluator::looksLikeNumber($t) ? Evaluator::parseNumberLiteral($t) : NAN;
646
+ }
647
+ return NAN; // array / object
648
+ }
649
+
650
+ public function truthy($value): bool
651
+ {
652
+ return Evaluator::truthy($value);
653
+ }
654
+
655
+ /** JS `%`: remainder with the dividend's sign. PHP's `fmod` already
656
+ * implements C-style fmod semantics matching JS `%` for every case
657
+ * (zero divisor, infinite/NaN operands all yield NaN natively). */
658
+ public function mod($a, $b): float
659
+ {
660
+ return fmod($this->number($a), $this->number($b));
661
+ }
662
+
663
+ public function floor($value): float
664
+ {
665
+ $n = $this->number($value);
666
+ return (is_nan($n) || is_infinite($n)) ? $n : floor($n);
667
+ }
668
+
669
+ public function ceil($value): float
670
+ {
671
+ $n = $this->number($value);
672
+ return (is_nan($n) || is_infinite($n)) ? $n : ceil($n);
673
+ }
674
+
675
+ /** JS `Math.round` rounds half toward +Infinity (`Math.round(-1.5)` is
676
+ * -1, not -2). `floor(n + 0.5)` reproduces that for both signs. */
677
+ public function round($value): float
678
+ {
679
+ $n = $this->number($value);
680
+ return (is_nan($n) || is_infinite($n)) ? $n : floor($n + 0.5);
681
+ }
682
+
683
+ // -----------------------------------------------------------------
684
+ // Array / String method helpers (#1448 Tier A)
685
+ // -----------------------------------------------------------------
686
+
687
+ /** String receivers arriving as an array/object coerce to '' (mirrors
688
+ * Perl's `ref($recv) ? '' : "$recv"`); anything else (incl. null) goes
689
+ * through `string()`. Shared by every string-method helper below. */
690
+ private function scalarOrEmpty($value): string
691
+ {
692
+ if (is_array($value) || $value instanceof \stdClass) {
693
+ return '';
694
+ }
695
+ return $this->string($value);
696
+ }
697
+
698
+ private function getField($el, string $key)
699
+ {
700
+ if ($el instanceof \stdClass) {
701
+ return property_exists($el, $key) ? $el->$key : null;
702
+ }
703
+ if (is_array($el) && !Evaluator::isJsArray($el)) {
704
+ return array_key_exists($key, $el) ? $el[$key] : null;
705
+ }
706
+ return null;
707
+ }
708
+
709
+ /** `Array.prototype.includes(x)` / `String.prototype.includes(sub)`
710
+ * share a method name in JS and lower to the same call. Dispatches on
711
+ * the receiver's PHP type: a JS-array scans elements with
712
+ * `Evaluator::sameValueZero()` (SameValueZero membership -- no
713
+ * cross-type coercion, e.g. `[2].includes("2")` is false; NaN matches
714
+ * NaN); anything else falls back to substring search. */
715
+ public function includes($recv, $elem): bool
716
+ {
717
+ if (Evaluator::isJsArray($recv)) {
718
+ foreach ($recv as $item) {
719
+ if (Evaluator::sameValueZero($item, $elem)) {
720
+ return true;
721
+ }
722
+ }
723
+ return false;
724
+ }
725
+ if (is_array($recv) || $recv instanceof \stdClass) {
726
+ return false; // a plain object is not a JS `.includes` target
727
+ }
728
+ return str_contains($this->scalarOrEmpty($recv), $this->scalarOrEmpty($elem));
729
+ }
730
+
731
+ public function filter($recv, callable $pred): array
732
+ {
733
+ if (!Evaluator::isJsArray($recv)) {
734
+ return [];
735
+ }
736
+ return array_values(array_filter($recv, $pred));
737
+ }
738
+
739
+ public function every($recv, callable $pred): bool
740
+ {
741
+ if (!Evaluator::isJsArray($recv)) {
742
+ return true;
743
+ }
744
+ foreach ($recv as $item) {
745
+ if (!$pred($item)) {
746
+ return false;
747
+ }
748
+ }
749
+ return true;
750
+ }
751
+
752
+ public function some($recv, callable $pred): bool
753
+ {
754
+ if (!Evaluator::isJsArray($recv)) {
755
+ return false;
756
+ }
757
+ foreach ($recv as $item) {
758
+ if ($pred($item)) {
759
+ return true;
760
+ }
761
+ }
762
+ return false;
763
+ }
764
+
765
+ public function find($recv, callable $pred)
766
+ {
767
+ if (!Evaluator::isJsArray($recv)) {
768
+ return null;
769
+ }
770
+ foreach ($recv as $item) {
771
+ if ($pred($item)) {
772
+ return $item;
773
+ }
774
+ }
775
+ return null;
776
+ }
777
+
778
+ public function find_index($recv, callable $pred): int
779
+ {
780
+ if (!Evaluator::isJsArray($recv)) {
781
+ return -1;
782
+ }
783
+ foreach (array_values($recv) as $i => $item) {
784
+ if ($pred($item)) {
785
+ return $i;
786
+ }
787
+ }
788
+ return -1;
789
+ }
790
+
791
+ public function find_last($recv, callable $pred)
792
+ {
793
+ if (!Evaluator::isJsArray($recv)) {
794
+ return null;
795
+ }
796
+ $arr = array_values($recv);
797
+ for ($i = count($arr) - 1; $i >= 0; $i--) {
798
+ if ($pred($arr[$i])) {
799
+ return $arr[$i];
800
+ }
801
+ }
802
+ return null;
803
+ }
804
+
805
+ public function find_last_index($recv, callable $pred): int
806
+ {
807
+ if (!Evaluator::isJsArray($recv)) {
808
+ return -1;
809
+ }
810
+ $arr = array_values($recv);
811
+ for ($i = count($arr) - 1; $i >= 0; $i--) {
812
+ if ($pred($arr[$i])) {
813
+ return $i;
814
+ }
815
+ }
816
+ return -1;
817
+ }
818
+
819
+ public function lc($s): string
820
+ {
821
+ return $s === null ? '' : mb_strtolower($this->string($s), 'UTF-8');
822
+ }
823
+
824
+ public function uc($s): string
825
+ {
826
+ return $s === null ? '' : mb_strtoupper($this->string($s), 'UTF-8');
827
+ }
828
+
829
+ /** `Array.prototype.join(sep)` -- separator defaults to ",", undefined/
830
+ * null elements render as empty. */
831
+ public function join($recv, $sep = null): string
832
+ {
833
+ if (!Evaluator::isJsArray($recv)) {
834
+ return '';
835
+ }
836
+ $sepStr = $sep === null ? ',' : $this->string($sep);
837
+ $parts = array_map(fn ($x) => $x === null ? '' : $this->string($x), $recv);
838
+ return implode($sepStr, $parts);
839
+ }
840
+
841
+ /** `.length` -- JS works on both arrays (element count) and strings
842
+ * (character count). Code-point length (see the design doc's `len`
843
+ * vector note: the golden vectors only pin ASCII cases). */
844
+ public function length($recv): int
845
+ {
846
+ if (Evaluator::isJsArray($recv)) {
847
+ return count($recv);
848
+ }
849
+ if (is_array($recv) || $recv instanceof \stdClass) {
850
+ return 0; // a plain object has no `.length`
851
+ }
852
+ return mb_strlen($this->scalarOrEmpty($recv), 'UTF-8');
853
+ }
854
+
855
+ private function arrayIndexOf($recv, $elem, bool $reverse): int
856
+ {
857
+ if (!Evaluator::isJsArray($recv)) {
858
+ return -1;
859
+ }
860
+ $arr = array_values($recv);
861
+ $n = count($arr);
862
+ if ($n === 0) {
863
+ return -1;
864
+ }
865
+ $indices = $reverse ? range($n - 1, 0) : range(0, $n - 1);
866
+ foreach ($indices as $i) {
867
+ if (Evaluator::strictEq($arr[$i], $elem)) {
868
+ return $i;
869
+ }
870
+ }
871
+ return -1;
872
+ }
873
+
874
+ public function index_of($recv, $elem): int
875
+ {
876
+ return $this->arrayIndexOf($recv, $elem, false);
877
+ }
878
+
879
+ public function last_index_of($recv, $elem): int
880
+ {
881
+ return $this->arrayIndexOf($recv, $elem, true);
882
+ }
883
+
884
+ /** `Array.prototype.at(i)` -- supports negative indices. */
885
+ public function at($recv, $i)
886
+ {
887
+ if (!Evaluator::isJsArray($recv) || $i === null) {
888
+ return null;
889
+ }
890
+ $len = count($recv);
891
+ if ($len === 0) {
892
+ return null;
893
+ }
894
+ $idx = $i < 0 ? $len + $i : $i;
895
+ if ($idx < 0 || $idx >= $len) {
896
+ return null;
897
+ }
898
+ return array_values($recv)[$idx];
899
+ }
900
+
901
+ public function concat($a, $b): array
902
+ {
903
+ $out = [];
904
+ if (Evaluator::isJsArray($a)) {
905
+ foreach ($a as $x) {
906
+ $out[] = $x;
907
+ }
908
+ }
909
+ if (Evaluator::isJsArray($b)) {
910
+ foreach ($b as $x) {
911
+ $out[] = $x;
912
+ }
913
+ }
914
+ return $out;
915
+ }
916
+
917
+ /** `Array.prototype.slice(start, end?)`. */
918
+ public function slice($recv, $start, $end): array
919
+ {
920
+ if (!Evaluator::isJsArray($recv)) {
921
+ return [];
922
+ }
923
+ $arr = array_values($recv);
924
+ $len = count($arr);
925
+ if ($len === 0) {
926
+ return [];
927
+ }
928
+ $s = $start ?? 0;
929
+ if ($s < 0) {
930
+ $s += $len;
931
+ }
932
+ $s = max($s, 0);
933
+ $s = min($s, $len);
934
+ $e = $end ?? $len;
935
+ if ($e < 0) {
936
+ $e += $len;
937
+ }
938
+ $e = max($e, 0);
939
+ $e = min($e, $len);
940
+ if ($s >= $e) {
941
+ return [];
942
+ }
943
+ return array_slice($arr, (int) $s, (int) ($e - $s));
944
+ }
945
+
946
+ public function reverse($recv): array
947
+ {
948
+ return Evaluator::isJsArray($recv) ? array_reverse($recv) : [];
949
+ }
950
+
951
+ /** `Array.prototype.flat(depth?)` -- a `$depth` of -1 is the `Infinity`
952
+ * sentinel (flatten fully); 0 returns a shallow copy. */
953
+ public function flat($recv, $depth = 1): array
954
+ {
955
+ if (!Evaluator::isJsArray($recv)) {
956
+ return [];
957
+ }
958
+ $out = [];
959
+ foreach ($recv as $el) {
960
+ if ($depth != 0 && Evaluator::isJsArray($el)) {
961
+ $next = $depth > 0 ? $depth - 1 : $depth;
962
+ foreach ($this->flat($el, $next) as $x) {
963
+ $out[] = $x;
964
+ }
965
+ } else {
966
+ $out[] = $el;
967
+ }
968
+ }
969
+ return $out;
970
+ }
971
+
972
+ /**
973
+ * Coerce an arbitrary PHP value to `[float, ok]` mirroring JS
974
+ * `ToNumber`, for `flat_dynamic()`'s depth-coercion pipeline. `ok` is
975
+ * `false` for a shape `ToNumber` can't coerce meaningfully (`null`, or a
976
+ * non-numeric string) -- `coerceFlatDepth()` treats that the same as an
977
+ * actual `NaN` result (-> depth `0`), matching JS. Mirrors Go's
978
+ * `flatDepthToFloat` (adapter-go-template/runtime/bf.go).
979
+ *
980
+ * PHP-specific traps this deliberately avoids: PHP's `(int)`/`(float)`
981
+ * casts on a non-numeric string silently yield `0` rather than
982
+ * signalling failure (`(int)"Infinity"` is `0` in PHP), which would
983
+ * misclassify `"Infinity"` as a NaN-that-looks-like-0 instead of
984
+ * `+Infinity`; and PHP's `is_numeric()` returns `false` for
985
+ * `"Infinity"`/`"NaN"` (unlike JS `Number("Infinity")`), so those exact
986
+ * (case-sensitive, as JS spells them) spellings are checked explicitly
987
+ * BEFORE falling back to `is_numeric()` + `(float)` for ordinary
988
+ * numeric strings.
989
+ *
990
+ * @return array{0: float, 1: bool}
991
+ */
992
+ private static function flatDepthToFloat($depth): array
993
+ {
994
+ if ($depth === null) {
995
+ return [0.0, false];
996
+ }
997
+ if (is_int($depth) || is_float($depth)) {
998
+ return [(float) $depth, true];
999
+ }
1000
+ if (is_bool($depth)) {
1001
+ return [$depth ? 1.0 : 0.0, true];
1002
+ }
1003
+ if (is_string($depth)) {
1004
+ $s = trim($depth);
1005
+ if ($s === '') {
1006
+ return [0.0, true]; // JS: Number("") is 0
1007
+ }
1008
+ if ($s === 'Infinity' || $s === '+Infinity') {
1009
+ return [INF, true];
1010
+ }
1011
+ if ($s === '-Infinity') {
1012
+ return [-INF, true];
1013
+ }
1014
+ if ($s === 'NaN') {
1015
+ return [NAN, false]; // NaN input -> "not ok", like Go
1016
+ }
1017
+ if (is_numeric($s)) {
1018
+ return [(float) $s, true];
1019
+ }
1020
+ return [0.0, false]; // not numeric -> NaN
1021
+ }
1022
+ return [0.0, false]; // array / object
1023
+ }
1024
+
1025
+ /**
1026
+ * JS `ToIntegerOrInfinity` coercion for `.flat(depth)`'s dynamic
1027
+ * argument (#2094): truncate toward zero; NaN/non-numeric -> 0;
1028
+ * negative -> 0; `+Infinity` or a huge finite value -> flatten fully
1029
+ * (mapped to `flat()`'s existing `-1` "flatten fully" sentinel).
1030
+ *
1031
+ * Mirrors Go's `coerceFlatDepth` (adapter-go-template/runtime/bf.go) --
1032
+ * see that function's doc for why `flat_dynamic()` cannot share
1033
+ * `flat()`'s literal-depth entry point: a genuinely dynamic depth that
1034
+ * evaluates to `-1` means "never recurse" in real JS
1035
+ * (`[1,[2]].flat(-1)` behaves like `.flat(0)`) -- the OPPOSITE of what
1036
+ * `flat()`'s `-1` argument means when it arrives from a *literal*
1037
+ * `Infinity` in the source. Since both call sites would otherwise hand
1038
+ * the same literal-looking `-1` to one shared function, that function
1039
+ * cannot tell which case it's in, so the two paths must stay separate
1040
+ * entry points.
1041
+ */
1042
+ private static function coerceFlatDepth($depth): int
1043
+ {
1044
+ [$f, $ok] = self::flatDepthToFloat($depth);
1045
+ if (!$ok || is_nan($f)) {
1046
+ return 0;
1047
+ }
1048
+ if ($f === INF) {
1049
+ return -1; // flat()'s "flatten fully" sentinel
1050
+ }
1051
+ if ($f === -INF) {
1052
+ return 0;
1053
+ }
1054
+ $trunc = $f >= 0 ? floor($f) : ceil($f); // truncate toward zero
1055
+ if ($trunc < 0) {
1056
+ return 0;
1057
+ }
1058
+ if ($trunc > 1_000_000) {
1059
+ return -1; // huge finite depth ~= flatten fully
1060
+ }
1061
+ return (int) $trunc;
1062
+ }
1063
+
1064
+ /**
1065
+ * `Array.prototype.flat(depth)` where `$depth` is a genuinely dynamic
1066
+ * value (#2094) -- e.g. a prop -- rather than a compile-time literal.
1067
+ * Coerces `$depth` via JS `ToIntegerOrInfinity` (`coerceFlatDepth()`)
1068
+ * then delegates to `flat()`. Deliberately a DISTINCT entry point from
1069
+ * `flat()` rather than a smarter overload of it -- see
1070
+ * `coerceFlatDepth()`'s docstring for why.
1071
+ */
1072
+ public function flat_dynamic($recv, $depth): array
1073
+ {
1074
+ return $this->flat($recv, self::coerceFlatDepth($depth));
1075
+ }
1076
+
1077
+ /** `Array.prototype.flatMap(fn)` field/self projection then flatten one
1078
+ * level. */
1079
+ public function flat_map($recv, string $keyKind, string $key): array
1080
+ {
1081
+ if (!Evaluator::isJsArray($recv)) {
1082
+ return [];
1083
+ }
1084
+ $projected = [];
1085
+ foreach ($recv as $el) {
1086
+ $projected[] = $keyKind === 'field' ? $this->getField($el, $key) : $el;
1087
+ }
1088
+ return $this->flat($projected, 1);
1089
+ }
1090
+
1091
+ /** `Array.prototype.flatMap(i => [i.a, i.b])` -- array-literal tuple
1092
+ * projection. `$flat` is a flat (kind, key, kind, key, ...) sequence,
1093
+ * paired internally. */
1094
+ public function flat_map_tuple($recv, ...$flat): array
1095
+ {
1096
+ if (!Evaluator::isJsArray($recv)) {
1097
+ return [];
1098
+ }
1099
+ $specs = [];
1100
+ for ($i = 0; $i + 1 < count($flat); $i += 2) {
1101
+ $specs[] = [$flat[$i], $flat[$i + 1]];
1102
+ }
1103
+ $out = [];
1104
+ foreach ($recv as $el) {
1105
+ foreach ($specs as [$kind, $key]) {
1106
+ $out[] = $kind === 'field' ? $this->getField($el, $key) : $el;
1107
+ }
1108
+ }
1109
+ return $out;
1110
+ }
1111
+
1112
+ public function trim($recv): string
1113
+ {
1114
+ if ($recv === null || is_array($recv) || $recv instanceof \stdClass) {
1115
+ return '';
1116
+ }
1117
+ return (string) preg_replace('/^\s+|\s+$/u', '', $this->string($recv));
1118
+ }
1119
+
1120
+ /** `Number.prototype.toFixed(digits)` -- JS rounds the scaled integer
1121
+ * half toward +Infinity (the spec's "pick the larger n" tie-break); a
1122
+ * bare `sprintf('%.*f')` would round half-to-even, diverging. */
1123
+ public function to_fixed($value, $digits = 0): string
1124
+ {
1125
+ $n = $this->number($value);
1126
+ if (is_nan($n)) {
1127
+ return 'NaN';
1128
+ }
1129
+ if (is_infinite($n)) {
1130
+ return $n < 0 ? '-Infinity' : 'Infinity';
1131
+ }
1132
+ $digits = $digits === null ? 0 : (int) $digits;
1133
+ if ($digits < 0) {
1134
+ $digits = 0;
1135
+ }
1136
+ $factor = 10 ** $digits;
1137
+ $rounded = floor($n * $factor + 0.5);
1138
+ return sprintf('%.' . $digits . 'f', $rounded / $factor);
1139
+ }
1140
+
1141
+ /** `String.prototype.split(sep)`. An empty separator splits into
1142
+ * individual (UTF-8) characters; undefined separator -> single-element
1143
+ * array; empty receiver with a non-empty separator -> `['']`. */
1144
+ public function split($recv, $sep = null, $limit = null): array
1145
+ {
1146
+ $s = $this->scalarOrEmpty($recv);
1147
+ if ($sep === null) {
1148
+ $parts = [$s];
1149
+ } elseif ($this->string($sep) === '') {
1150
+ $parts = $s === '' ? [] : preg_split('//u', $s, -1, PREG_SPLIT_NO_EMPTY);
1151
+ } elseif ($s === '') {
1152
+ $parts = [''];
1153
+ } else {
1154
+ $parts = explode($this->string($sep), $s);
1155
+ }
1156
+ if ($limit !== null) {
1157
+ $n = (int) $limit;
1158
+ if ($n === 0) {
1159
+ $parts = [];
1160
+ } elseif ($n > 0 && $n < count($parts)) {
1161
+ $parts = array_slice($parts, 0, $n);
1162
+ }
1163
+ }
1164
+ return $parts;
1165
+ }
1166
+
1167
+ public function starts_with($recv, $prefix, $position = null): bool
1168
+ {
1169
+ $s = $this->scalarOrEmpty($recv);
1170
+ $p = $this->string($prefix);
1171
+ if ($position !== null) {
1172
+ $len = mb_strlen($s, 'UTF-8');
1173
+ $n = max(0, min((int) $position, $len));
1174
+ $s = mb_substr($s, $n, null, 'UTF-8');
1175
+ }
1176
+ return str_starts_with($s, $p);
1177
+ }
1178
+
1179
+ public function ends_with($recv, $suffix, $endPosition = null): bool
1180
+ {
1181
+ $s = $this->scalarOrEmpty($recv);
1182
+ $x = $this->string($suffix);
1183
+ if ($endPosition !== null) {
1184
+ $len = mb_strlen($s, 'UTF-8');
1185
+ $e = max(0, min((int) $endPosition, $len));
1186
+ $s = mb_substr($s, 0, $e, 'UTF-8');
1187
+ }
1188
+ if ($x === '') {
1189
+ return true;
1190
+ }
1191
+ return str_ends_with($s, $x);
1192
+ }
1193
+
1194
+ /** `String.prototype.replace(pattern, replacement)` -- string-pattern
1195
+ * form only, replacing the FIRST occurrence (literal, not regex). */
1196
+ public function replace($recv, $pattern, $replacement): string
1197
+ {
1198
+ $s = $this->scalarOrEmpty($recv);
1199
+ $o = $this->string($pattern);
1200
+ $n = $this->string($replacement);
1201
+ if ($o === '') {
1202
+ return $n . $s;
1203
+ }
1204
+ $i = strpos($s, $o);
1205
+ if ($i === false) {
1206
+ return $s;
1207
+ }
1208
+ return substr($s, 0, $i) . $n . substr($s, $i + strlen($o));
1209
+ }
1210
+
1211
+ /** `queryHref(base, {...})` (#2042) -- build `"$base?k=v&..."` from a
1212
+ * flat (guard, key, value) triple sequence. A pair is included iff its
1213
+ * guard is JS-truthy AND its value is a non-empty string; an array value
1214
+ * appends one pair per non-empty member; repeating a key overwrites the
1215
+ * value at its first position. */
1216
+ public function query($base, ...$triples): string
1217
+ {
1218
+ $b = $this->scalarOrEmpty($base);
1219
+ $pairs = [];
1220
+ $pos = [];
1221
+ $n = count($triples);
1222
+ $i = 0;
1223
+ while ($i + 2 < $n) {
1224
+ $guard = $triples[$i];
1225
+ $key = $triples[$i + 1];
1226
+ $val = $triples[$i + 2];
1227
+ $i += 3;
1228
+ if (!$this->truthy($guard)) {
1229
+ continue;
1230
+ }
1231
+ $keyS = $this->scalarOrEmpty($key);
1232
+ if (Evaluator::isJsArray($val)) {
1233
+ foreach ($val as $m) {
1234
+ $s = $this->scalarOrEmpty($m);
1235
+ if ($s === '') {
1236
+ continue;
1237
+ }
1238
+ $pairs[] = [$keyS, $s];
1239
+ }
1240
+ continue;
1241
+ }
1242
+ $valS = $this->scalarOrEmpty($val);
1243
+ if ($valS === '') {
1244
+ continue;
1245
+ }
1246
+ if (array_key_exists($keyS, $pos)) {
1247
+ $pairs[$pos[$keyS]] = [$keyS, $valS];
1248
+ } else {
1249
+ $pos[$keyS] = count($pairs);
1250
+ $pairs[] = [$keyS, $valS];
1251
+ }
1252
+ }
1253
+ if (!$pairs) {
1254
+ return $b;
1255
+ }
1256
+ $parts = array_map(fn ($p) => self::formEscape($p[0]) . '=' . self::formEscape($p[1]), $pairs);
1257
+ return $b . '?' . implode('&', $parts);
1258
+ }
1259
+
1260
+ /** `application/x-www-form-urlencoded` serialisation matching the
1261
+ * browser's `URLSearchParams`: keep ASCII alphanumerics and `* - . _`;
1262
+ * encode every other byte as `%XX` (upper hex); space -> `+`. Byte-wise
1263
+ * (PHP strings are raw bytes), so UTF-8 multi-byte sequences are
1264
+ * percent-encoded byte-by-byte, matching the Perl/Python ports. */
1265
+ private static function formEscape(string $s): string
1266
+ {
1267
+ $encoded = preg_replace_callback(
1268
+ '/[^A-Za-z0-9*\-._ ]/',
1269
+ static fn (array $m) => '%' . strtoupper(bin2hex($m[0])),
1270
+ $s
1271
+ );
1272
+ return str_replace(' ', '+', $encoded);
1273
+ }
1274
+
1275
+ public function repeat($recv, $count): string
1276
+ {
1277
+ $s = $this->scalarOrEmpty($recv);
1278
+ $n = $count !== null ? (int) $count : 0;
1279
+ return $n > 0 ? str_repeat($s, $n) : '';
1280
+ }
1281
+
1282
+ /** Pad `$s` to `$target` (UTF-8) characters with `$pad` (default a
1283
+ * single space) repeated and truncated to fill. */
1284
+ private function pad(string $s, $target, $pad, bool $atStart): string
1285
+ {
1286
+ $p = $pad === null ? ' ' : $this->string($pad);
1287
+ if ($p === '') {
1288
+ return $s;
1289
+ }
1290
+ $len = mb_strlen($s, 'UTF-8');
1291
+ $t = $target !== null ? (int) $target : 0;
1292
+ if ($len >= $t) {
1293
+ return $s;
1294
+ }
1295
+ $need = $t - $len;
1296
+ $plen = mb_strlen($p, 'UTF-8');
1297
+ $reps = intdiv($need, $plen) + 1;
1298
+ $fill = mb_substr(str_repeat($p, $reps), 0, $need, 'UTF-8');
1299
+ return $atStart ? $fill . $s : $s . $fill;
1300
+ }
1301
+
1302
+ public function pad_start($recv, $target, $pad = null): string
1303
+ {
1304
+ return $this->pad($this->scalarOrEmpty($recv), $target, $pad, true);
1305
+ }
1306
+
1307
+ public function pad_end($recv, $target, $pad = null): string
1308
+ {
1309
+ return $this->pad($this->scalarOrEmpty($recv), $target, $pad, false);
1310
+ }
1311
+
1312
+ // -----------------------------------------------------------------
1313
+ // Array.prototype.sort(cmp) / reduce(fn) -- structured dispatch
1314
+ // (#1448 Tier B/C). `$opts` is an associative array (or stdClass)
1315
+ // with the field shapes documented in the Perl port.
1316
+ // -----------------------------------------------------------------
1317
+
1318
+ private function toOptsArray($opts): array
1319
+ {
1320
+ if ($opts instanceof \stdClass) {
1321
+ return get_object_vars($opts);
1322
+ }
1323
+ return is_array($opts) ? $opts : [];
1324
+ }
1325
+
1326
+ private function isNumericLike($v): bool
1327
+ {
1328
+ if ($v === null || is_bool($v)) {
1329
+ return false;
1330
+ }
1331
+ if (is_int($v) || is_float($v)) {
1332
+ return true;
1333
+ }
1334
+ if (is_string($v)) {
1335
+ return Evaluator::looksLikeNumber($v);
1336
+ }
1337
+ return false;
1338
+ }
1339
+
1340
+ private function numericValue($v): float
1341
+ {
1342
+ if ($v === null || is_array($v) || $v instanceof \stdClass) {
1343
+ return 0.0;
1344
+ }
1345
+ if (is_bool($v)) {
1346
+ return $v ? 1.0 : 0.0;
1347
+ }
1348
+ if (is_int($v) || is_float($v)) {
1349
+ return (float) $v;
1350
+ }
1351
+ if (is_string($v)) {
1352
+ return Evaluator::looksLikeNumber($v) ? Evaluator::parseNumberLiteral($v) : 0.0;
1353
+ }
1354
+ return 0.0;
1355
+ }
1356
+
1357
+ /** Compare two projected sort keys, ascending orientation (-1/0/1); the
1358
+ * caller negates for 'desc'. 'auto' compares numerically when both keys
1359
+ * look like numbers, else lexically. `null` coalesces to '' / 0. */
1360
+ private function compareSortKey($av, $bv, string $compareType): int
1361
+ {
1362
+ // String comparisons below MUST use strcmp(), not PHP's `<=>`: PHP
1363
+ // applies "smart" numeric-string comparison when both operands look
1364
+ // numeric ("10" <=> "9" compares 10 > 9 numerically), which would
1365
+ // silently make the 'string' compare_type behave like 'auto' for
1366
+ // numeric-looking values -- see the matching note on
1367
+ // Evaluator::relational().
1368
+ if ($compareType === 'string') {
1369
+ $a = $av === null ? '' : $this->string($av);
1370
+ $b = $bv === null ? '' : $this->string($bv);
1371
+ return strcmp($a, $b) <=> 0;
1372
+ }
1373
+ if ($compareType === 'auto') {
1374
+ if ($this->isNumericLike($av) && $this->isNumericLike($bv)) {
1375
+ return $this->numericValue($av) <=> $this->numericValue($bv);
1376
+ }
1377
+ $a = $av === null ? '' : $this->string($av);
1378
+ $b = $bv === null ? '' : $this->string($bv);
1379
+ return strcmp($a, $b) <=> 0;
1380
+ }
1381
+ // numeric
1382
+ $an = $av === null ? 0.0 : $this->numericValue($av);
1383
+ $bn = $bv === null ? 0.0 : $this->numericValue($bv);
1384
+ return $an <=> $bn;
1385
+ }
1386
+
1387
+ public function sort($recv, $opts = null): array
1388
+ {
1389
+ if (!Evaluator::isJsArray($recv)) {
1390
+ return [];
1391
+ }
1392
+ $optsArr = $this->toOptsArray($opts);
1393
+ $keysRaw = $optsArr['keys'] ?? [];
1394
+ $spec = [];
1395
+ foreach ((is_array($keysRaw) ? $keysRaw : []) as $k) {
1396
+ $kArr = $this->toOptsArray($k);
1397
+ $spec[] = [
1398
+ 'key_kind' => $kArr['key_kind'] ?? 'self',
1399
+ 'key' => $kArr['key'] ?? '',
1400
+ 'compare_type' => $kArr['compare_type'] ?? 'numeric',
1401
+ 'direction' => $kArr['direction'] ?? 'asc',
1402
+ ];
1403
+ }
1404
+ if (!$spec) {
1405
+ return array_values($recv);
1406
+ }
1407
+
1408
+ $keyed = [];
1409
+ foreach ($recv as $item) {
1410
+ $ks = [];
1411
+ foreach ($spec as $s) {
1412
+ $ks[] = $s['key_kind'] === 'field' ? $this->getField($item, $s['key']) : $item;
1413
+ }
1414
+ $keyed[] = [$ks, $item];
1415
+ }
1416
+
1417
+ usort($keyed, function ($a, $b) use ($spec) {
1418
+ foreach ($spec as $i => $s) {
1419
+ $c = $this->compareSortKey($a[0][$i], $b[0][$i], $s['compare_type']);
1420
+ if ($c === 0) {
1421
+ continue;
1422
+ }
1423
+ return $s['direction'] === 'desc' ? -$c : $c;
1424
+ }
1425
+ return 0;
1426
+ });
1427
+
1428
+ return array_map(fn ($p) => $p[1], $keyed);
1429
+ }
1430
+
1431
+ /** Fold an array into a scalar via the arithmetic-fold catalogue
1432
+ * (`{op, key_kind, key, type, init, direction}`). */
1433
+ public function reduce($recv, $opts = null)
1434
+ {
1435
+ $optsArr = $this->toOptsArray($opts);
1436
+ $op = $optsArr['op'] ?? '+';
1437
+ $keyKind = $optsArr['key_kind'] ?? 'self';
1438
+ $key = $optsArr['key'] ?? '';
1439
+ $type = $optsArr['type'] ?? 'numeric';
1440
+ $direction = $optsArr['direction'] ?? 'left';
1441
+
1442
+ $items = Evaluator::isJsArray($recv) ? $recv : [];
1443
+ if ($direction === 'right') {
1444
+ $items = array_reverse($items);
1445
+ }
1446
+
1447
+ $project = fn ($item) => $keyKind === 'field' ? $this->getField($item, $key) : $item;
1448
+
1449
+ if ($type === 'string') {
1450
+ $acc = $optsArr['init'] ?? '';
1451
+ foreach ($items as $item) {
1452
+ $acc .= $this->string($project($item));
1453
+ }
1454
+ return $acc;
1455
+ }
1456
+
1457
+ $acc = $optsArr['init'] ?? 0;
1458
+ foreach ($items as $item) {
1459
+ $n = $project($item);
1460
+ $numeric = ($n !== null && $this->isNumericLike($n)) ? $this->numericValue($n) : 0;
1461
+ $acc = $op === '*' ? $acc * $numeric : $acc + $numeric;
1462
+ }
1463
+ return $acc;
1464
+ }
1465
+
1466
+ // -----------------------------------------------------------------
1467
+ // JSX intrinsic-element spread (#1407)
1468
+ // -----------------------------------------------------------------
1469
+
1470
+ private const SVG_CAMEL_CASE_ATTRS = [
1471
+ 'allowReorder', 'attributeName', 'attributeType', 'autoReverse',
1472
+ 'baseFrequency', 'baseProfile', 'calcMode', 'clipPathUnits',
1473
+ 'contentScriptType', 'contentStyleType', 'diffuseConstant', 'edgeMode',
1474
+ 'externalResourcesRequired', 'filterRes', 'filterUnits', 'glyphRef',
1475
+ 'gradientTransform', 'gradientUnits', 'kernelMatrix', 'kernelUnitLength',
1476
+ 'keyPoints', 'keySplines', 'keyTimes', 'lengthAdjust', 'limitingConeAngle',
1477
+ 'markerHeight', 'markerUnits', 'markerWidth', 'maskContentUnits',
1478
+ 'maskUnits', 'numOctaves', 'pathLength', 'patternContentUnits',
1479
+ 'patternTransform', 'patternUnits', 'pointsAtX', 'pointsAtY', 'pointsAtZ',
1480
+ 'preserveAlpha', 'preserveAspectRatio', 'primitiveUnits', 'refX', 'refY',
1481
+ 'repeatCount', 'repeatDur', 'requiredExtensions', 'requiredFeatures',
1482
+ 'specularConstant', 'specularExponent', 'spreadMethod', 'startOffset',
1483
+ 'stdDeviation', 'stitchTiles', 'surfaceScale', 'systemLanguage',
1484
+ 'tableValues', 'targetX', 'targetY', 'textLength', 'viewBox', 'viewTarget',
1485
+ 'xChannelSelector', 'yChannelSelector', 'zoomAndPan',
1486
+ ];
1487
+
1488
+ private static function toAttrName(string $key): string
1489
+ {
1490
+ if ($key === 'className') {
1491
+ return 'class';
1492
+ }
1493
+ if ($key === 'htmlFor') {
1494
+ return 'for';
1495
+ }
1496
+ if (in_array($key, self::SVG_CAMEL_CASE_ATTRS, true)) {
1497
+ return $key;
1498
+ }
1499
+ // camelCase -> kebab-case, with a leading `-` for an initial
1500
+ // uppercase letter (JS-reference parity -- same documented
1501
+ // behaviour as the Go/Perl/Python adapters).
1502
+ return (string) preg_replace_callback('/([A-Z])/', fn ($m) => '-' . strtolower($m[1]), $key);
1503
+ }
1504
+
1505
+ /** HTML attribute-value escape -- numeric-entity quotes (`&#34;`/`&#39;`)
1506
+ * for cross-adapter byte parity (matches Go's `template.HTMLEscapeString`
1507
+ * / the Perl/Python ports' `_html_escape`). Used only by the runtime's
1508
+ * OWN escape paths (`spread_attrs`, `style` values) that bypass Twig's
1509
+ * autoescaper via `mark_raw` -- NOT by plain `{{ }}` interpolation, which
1510
+ * uses Twig's own (differently-spelled but conformance-equivalent)
1511
+ * default escaper. */
1512
+ private function htmlEscape($value): string
1513
+ {
1514
+ $s = $this->string($value);
1515
+ $s = str_replace('&', '&amp;', $s);
1516
+ $s = str_replace('<', '&lt;', $s);
1517
+ $s = str_replace('>', '&gt;', $s);
1518
+ $s = str_replace('"', '&#34;', $s);
1519
+ $s = str_replace("'", '&#39;', $s);
1520
+ return $s;
1521
+ }
1522
+
1523
+ private function styleToCss($value): ?string
1524
+ {
1525
+ if ($value === null) {
1526
+ return null;
1527
+ }
1528
+ if (!($value instanceof \stdClass || is_array($value))) {
1529
+ // Non-object values pass through stringified.
1530
+ $s = $this->string($value);
1531
+ return $s !== '' ? $s : null;
1532
+ }
1533
+ $assoc = self::toAssoc($value);
1534
+ ksort($assoc, SORT_STRING);
1535
+ $parts = [];
1536
+ foreach ($assoc as $key => $v) {
1537
+ if ($v === null) {
1538
+ continue;
1539
+ }
1540
+ $prop = preg_replace_callback('/([A-Z])/', fn ($m) => '-' . strtolower($m[1]), (string) $key);
1541
+ $parts[] = $prop . ':' . $this->string($v);
1542
+ }
1543
+ return $parts ? implode(';', $parts) : null;
1544
+ }
1545
+
1546
+ /** Mirrors the JS `spreadAttrs` runtime so SSR output stays byte-equal
1547
+ * across adapters. Skip rules: null values, event handlers
1548
+ * (`on[A-Z]...`), `children`. Real PHP booleans -> bare attr (true) /
1549
+ * dropped (false) -- no sentinel dance needed, unlike Perl. Keys sorted
1550
+ * alphabetically; `style` routes through `styleToCss`; result wrapped
1551
+ * `mark_raw`. */
1552
+ public function spread_attrs($bag)
1553
+ {
1554
+ if ($bag instanceof \stdClass) {
1555
+ $assoc = get_object_vars($bag);
1556
+ } elseif (is_array($bag)) {
1557
+ $assoc = $bag;
1558
+ } else {
1559
+ return '';
1560
+ }
1561
+ ksort($assoc, SORT_STRING);
1562
+
1563
+ $parts = [];
1564
+ foreach ($assoc as $key => $val) {
1565
+ $key = (string) $key;
1566
+ if (strlen($key) > 2 && substr($key, 0, 2) === 'on') {
1567
+ $c = substr($key, 2, 1);
1568
+ if (strtoupper($c) === $c) {
1569
+ continue; // event handler
1570
+ }
1571
+ }
1572
+ if ($key === 'children') {
1573
+ continue;
1574
+ }
1575
+ if ($val === null) {
1576
+ continue;
1577
+ }
1578
+ if (is_bool($val)) {
1579
+ if ($val) {
1580
+ $parts[] = self::toAttrName($key);
1581
+ }
1582
+ continue;
1583
+ }
1584
+ if ($key === 'style') {
1585
+ $css = $this->styleToCss($val);
1586
+ if ($css === null || $css === '') {
1587
+ continue;
1588
+ }
1589
+ $parts[] = 'style="' . $this->htmlEscape($css) . '"';
1590
+ continue;
1591
+ }
1592
+ $parts[] = self::toAttrName($key) . '="' . $this->htmlEscape($val) . '"';
1593
+ }
1594
+ if (!$parts) {
1595
+ return '';
1596
+ }
1597
+ return $this->backend->mark_raw(implode(' ', $parts));
1598
+ }
1599
+
1600
+ /**
1601
+ * Object-rest residual (`{ id, title, ...rest }` -> `rest`), #2087 Phase
1602
+ * B. Returns a TRUE residual -- a fresh assoc array holding every key of
1603
+ * `$bag` EXCEPT those in `$exclude` (the sibling keys the destructure
1604
+ * pattern already bound explicitly) -- not an alias of the whole item,
1605
+ * so a template read of `rest.someExplicitlyDestructuredKey` can't
1606
+ * observe a value the JS destructure semantics say it shouldn't see.
1607
+ *
1608
+ * Mirrors `spread_attrs`'s bag-shape handling: `$bag` is either a
1609
+ * `stdClass` (a JSON-decoded loop item) or a plain PHP assoc array (the
1610
+ * "canonical value convention" accepts both as an "object" -- see
1611
+ * `spread_attrs`'s docstring above). Returns a plain PHP array (not
1612
+ * `stdClass`) so both Twig's dot accessor (`rest.flag`) and
1613
+ * `spread_attrs`'s own bag handling work on the result unchanged --
1614
+ * verified empirically against Twig 3.x: dot notation resolves an
1615
+ * ARRAY-ITEM key before falling back to an object property, so `.flag`
1616
+ * on an assoc array works exactly like `.flag` on a `stdClass`.
1617
+ */
1618
+ public function omit($bag, array $exclude): array
1619
+ {
1620
+ if ($bag instanceof \stdClass) {
1621
+ $assoc = get_object_vars($bag);
1622
+ } elseif (is_array($bag)) {
1623
+ $assoc = $bag;
1624
+ } else {
1625
+ return [];
1626
+ }
1627
+ $excludeSet = array_flip(array_map('strval', $exclude));
1628
+ $out = [];
1629
+ foreach ($assoc as $key => $val) {
1630
+ if (array_key_exists((string) $key, $excludeSet)) {
1631
+ continue;
1632
+ }
1633
+ $out[$key] = $val;
1634
+ }
1635
+ return $out;
1636
+ }
1637
+
1638
+ // -----------------------------------------------------------------
1639
+ // NEW helpers vs. the Perl/Python ports (design doc section 2): JS
1640
+ // `===`/`!==` for the JSON value domain -- Twig `==` compiles to PHP
1641
+ // loose `==` (`'1' == 1` is true, wrong), and `is same as` is PHP `===`
1642
+ // which fails `1 === 1.0`. ONE shared implementation with the Evaluator.
1643
+ // -----------------------------------------------------------------
1644
+
1645
+ public function eq($a, $b): bool
1646
+ {
1647
+ return Evaluator::strictEq($a, $b);
1648
+ }
1649
+
1650
+ public function neq($a, $b): bool
1651
+ {
1652
+ return !Evaluator::strictEq($a, $b);
1653
+ }
1654
+
1655
+ // -----------------------------------------------------------------
1656
+ // Evaluator-driven sort / reduce / higher-order predicates (#2018): the
1657
+ // comparator/reducer/predicate body rides as a serialized-ParsedExpr
1658
+ // JSON string and is evaluated per element, delegating to Evaluator.
1659
+ // -----------------------------------------------------------------
1660
+
1661
+ public function sort_eval($recv, string $cmpJson, string $paramA, string $paramB, array $baseEnv = []): array
1662
+ {
1663
+ return Evaluator::sortByJson($recv, $cmpJson, $paramA, $paramB, $baseEnv);
1664
+ }
1665
+
1666
+ public function reduce_eval($recv, string $bodyJson, string $accName, string $itemName, $init, string $direction = 'left', array $baseEnv = [])
1667
+ {
1668
+ return Evaluator::foldJson($recv, $bodyJson, $accName, $itemName, $init, $direction, $baseEnv);
1669
+ }
1670
+
1671
+ public function filter_eval($recv, string $predJson, string $param, array $baseEnv = []): array
1672
+ {
1673
+ return Evaluator::filterJson($recv, $predJson, $param, $baseEnv);
1674
+ }
1675
+
1676
+ public function every_eval($recv, string $predJson, string $param, array $baseEnv = []): bool
1677
+ {
1678
+ return Evaluator::everyJson($recv, $predJson, $param, $baseEnv);
1679
+ }
1680
+
1681
+ public function some_eval($recv, string $predJson, string $param, array $baseEnv = []): bool
1682
+ {
1683
+ return Evaluator::someJson($recv, $predJson, $param, $baseEnv);
1684
+ }
1685
+
1686
+ public function find_eval($recv, string $predJson, string $param, bool $forward = true, array $baseEnv = [])
1687
+ {
1688
+ return Evaluator::findJson($recv, $predJson, $param, $forward, $baseEnv);
1689
+ }
1690
+
1691
+ public function find_index_eval($recv, string $predJson, string $param, bool $forward = true, array $baseEnv = []): int
1692
+ {
1693
+ return Evaluator::findIndexJson($recv, $predJson, $param, $forward, $baseEnv);
1694
+ }
1695
+
1696
+ public function flat_map_eval($recv, string $projJson, string $param, array $baseEnv = []): array
1697
+ {
1698
+ return Evaluator::flatMapJson($recv, $projJson, $param, $baseEnv);
1699
+ }
1700
+
1701
+ public function map_eval($recv, string $projJson, string $param, array $baseEnv = []): array
1702
+ {
1703
+ return Evaluator::mapJson($recv, $projJson, $param, $baseEnv);
1704
+ }
1705
+ }