@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.
- package/README.md +69 -0
- package/composer.json +21 -0
- package/package.json +26 -0
- package/src/BarefootJS.php +1705 -0
- package/src/Evaluator.php +968 -0
- package/src/Json.php +67 -0
- package/src/SearchParams.php +77 -0
- package/tests/_harness.php +164 -0
- package/tests/run.php +72 -0
- package/tests/test_eval_vectors.php +119 -0
- package/tests/test_evaluator.php +274 -0
- package/tests/test_helper_vectors.php +334 -0
- package/tests/test_omit.php +57 -0
- package/tests/test_props_attr.php +63 -0
- package/tests/test_query.php +47 -0
- package/tests/test_render_child.php +123 -0
- package/tests/test_search_params.php +56 -0
- package/tests/test_spread_attrs.php +98 -0
- package/tests/test_template_primitives.php +370 -0
- package/tests/vector-divergences.json +42 -0
|
@@ -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('"', '"', $this->string($host));
|
|
163
|
+
$parts[] = 'bf-h="' . $h . '"';
|
|
164
|
+
}
|
|
165
|
+
if ($mount !== null && $mount !== '') {
|
|
166
|
+
$m = str_replace('"', '"', $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(['&', '"'], ['&', '"'], $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 (`"`/`'`)
|
|
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('&', '&', $s);
|
|
1516
|
+
$s = str_replace('<', '<', $s);
|
|
1517
|
+
$s = str_replace('>', '>', $s);
|
|
1518
|
+
$s = str_replace('"', '"', $s);
|
|
1519
|
+
$s = str_replace("'", ''', $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
|
+
}
|