File Coverage

lib/BarefootJS.pm
Criterion Covered Total %
statement 476 928 51.2
branch 203 382 53.1
condition 67 188 35.6
subroutine 50 99 50.5
pod 0 73 0.0
total 796 1670 47.6


line stmt bran cond sub pod time code
1             package BarefootJS;
2             our $VERSION = "0.18.2";
3 6     6   928131 use strict;
  6         10  
  6         214  
4 6     6   43 use warnings;
  6         19  
  6         264  
5 6     6   603 use utf8;
  6         595  
  6         1647  
6 6     6   230 use feature 'signatures';
  6         12  
  6         927  
7 6     6   33 no warnings 'experimental::signatures';
  6         22  
  6         242  
8              
9 6     6   3257 use POSIX ();
  6         43427  
  6         235  
10 6     6   46 use Scalar::Util qw(looks_like_number weaken);
  6         16  
  6         377  
11 6     6   3562 use BarefootJS::Evaluator ();
  6         19  
  6         943  
12              
13             # NOTE: This runtime is template-engine-agnostic AND framework-agnostic by
14             # design, so it can ship as a standalone CPAN distribution. It depends only on
15             # core Perl (subroutine signatures + the hand-rolled minimal accessor base
16             # below — no Mojo::Base, no Class::Tiny). Every operation that depends on *how*
17             # a template is rendered — JSON marshalling, raw-string marking, JSX-children
18             # materialisation, and named-template rendering — is delegated to a pluggable
19             # `backend` (see BarefootJS::Backend::Mojo for the reference Mojolicious
20             # implementation), which is the only component that pulls in the Mojo
21             # distribution, and only when it is actually used.
22              
23             # ---------------------------------------------------------------------------
24             # Minimal accessor base (no Mojo::Base / Class::Tiny dependency)
25             # ---------------------------------------------------------------------------
26             #
27             # Generates read/write accessors with optional lazy defaults so the runtime
28             # stays free of any non-core OO base. Semantics mirror the Mojo::Base `has`
29             # this class used to inherit: a getter returns the stored value (building it
30             # from the default on first access if unset); a setter stores the value and
31             # returns $self for chaining. A default is either a plain scalar or a coderef
32             # invoked as `$default->($self)` (for per-instance refs like `[]` / `{}` and
33             # the lazily-required Mojo backend).
34             my %ATTR_DEFAULT = (
35             _scripts => sub { [] },
36             _script_seen => sub { {} },
37             _child_renderers => sub { {} },
38             _is_child => 0,
39             # Lazily fall back to the Mojo reference backend so a bare-blessed
40             # instance (the pure-function unit tests) and the historical
41             # `BarefootJS->new($c, ...)` callers keep working unchanged. A non-Mojo
42             # host injects its own backend via `BarefootJS->new($c, { backend => $b })`
43             # and never triggers this require — keeping the core load Mojo-free.
44             backend => sub {
45             require BarefootJS::Backend::Mojo;
46             return BarefootJS::Backend::Mojo->new;
47             },
48             );
49              
50             # c — Mojolicious controller (kept for back-compat accessors)
51             # config — plugin / instance config
52             # backend — the template-engine seam (#engine-abstraction)
53             # _scope_id — addressable scope id
54             # _bf_parent / _bf_mount — slot identity when this scope is slot-attached
55             # _props — props serialised into bf-p / the scope comment
56             # _data_key — keyed-loop-item key, emitted as data-key on the scope root
57             for my $attr (qw(
58             c config backend
59             _scripts _script_seen _scope_id _is_child _bf_parent _bf_mount _props
60             _data_key _child_renderers
61             )) {
62 6     6   49 no strict 'refs';
  6         9  
  6         54802  
63             *{"BarefootJS::$attr"} = sub {
64 208     208   208 my $self = shift;
65 208 100       255 if (@_) { $self->{$attr} = shift; return $self; }
  57         88  
  57         59  
66 151 50 66     274 if (!exists $self->{$attr} && exists $ATTR_DEFAULT{$attr}) {
67 19         23 my $d = $ATTR_DEFAULT{$attr};
68 19 50       44 $self->{$attr} = ref($d) eq 'CODE' ? $d->($self) : $d;
69             }
70 151         430 return $self->{$attr};
71             };
72             }
73              
74 17     17 0 354107 sub new ($class, $c, $config = {}) {
  17         21  
  17         19  
  17         18  
  17         14  
75             # Build (or accept an injected) rendering backend. The default Mojo
76             # backend wraps the controller and honours an optional `json_encoder`
77             # override so a host can swap in a faster XS JSON implementation
78             # without subclassing. A caller targeting another template engine
79             # passes its own backend via `$config->{backend}`.
80 17         19 my $backend = $config->{backend};
81 17 50       35 unless ($backend) {
82 0         0 require BarefootJS::Backend::Mojo;
83             $backend = BarefootJS::Backend::Mojo->new(
84             c => $c,
85             ($config->{json_encoder}
86             ? (json_encoder => $config->{json_encoder})
87 0 0       0 : ()),
88             );
89             }
90 17         39 my $self = bless {
91             c => $c,
92             config => $config,
93             backend => $backend,
94             }, $class;
95             # Hold the controller weakly. Mojolicious stashes this bf instance under
96             # `$c->stash->{'bf.instance'}`, so a strong bf -> controller back-reference
97             # closes a per-request cycle ($c -> stash -> bf -> $c) that Perl's
98             # refcount GC cannot reclaim, leaking one controller + bf + child-renderer
99             # closures per request. The controller owns (outlives) the per-request bf,
100             # so the weak ref stays valid for the whole render. Callers that need the
101             # controller to outlive the bf instance independently must keep their own
102             # strong reference (the normal Mojo request scope already does).
103 17 100       26 weaken($self->{c}) if defined $c;
104 17         25 return $self;
105             }
106              
107             # search_params($query = '')
108             #
109             # Build a request-scoped reader for the reactive searchParams() environment
110             # signal (router v0.5, #1922) from a raw query string. Callable as a class or
111             # instance method — the invocant is unused.
112             #
113             # The `require` lives here so consumers (the Mojo plugin, the Xslate host, the
114             # test harness, generated render scripts) reach BarefootJS::SearchParams through
115             # the BarefootJS object they already hold, never `use`-ing it directly — the
116             # same lazy-load seam the Mojo backend uses above. The compiled template reads
117             # the returned object via `$searchParams->get('key')`.
118 8     8 0 162943 sub search_params ($invocant, $query = '') {
  8         11  
  8         10  
  8         10  
119 8         754 require BarefootJS::SearchParams;
120 8         26 return BarefootJS::SearchParams->new($query);
121             }
122              
123             # ---------------------------------------------------------------------------
124             # Scope & Props
125             # ---------------------------------------------------------------------------
126              
127 0     0 0 0 sub scope_attr ($self) {
  0         0  
  0         0  
128             # bf-s is the addressable scope id only (#1249).
129 0   0     0 return $self->_scope_id // '';
130             }
131              
132             # Emits `bf-h="" bf-m="" bf-r=""` conditionally.
133             # See spec/compiler.md "Slot identity".
134 0     0 0 0 sub hydration_attrs ($self) {
  0         0  
  0         0  
135 0         0 my @parts;
136 0         0 my $host = $self->_bf_parent;
137 0         0 my $mount = $self->_bf_mount;
138 0 0 0     0 if (defined $host && length $host) {
139 0         0 my $h = $host =~ s/"/"/gr;
140 0         0 push @parts, qq{bf-h="$h"};
141             }
142 0 0 0     0 if (defined $mount && length $mount) {
143 0         0 my $m = $mount =~ s/"/"/gr;
144 0         0 push @parts, qq{bf-m="$m"};
145             }
146 0 0       0 unless ($self->_is_child) {
147 0         0 push @parts, q{bf-r=""};
148             }
149 0         0 return join(' ', @parts);
150             }
151              
152             # Emits ` data-key=""` for a keyed loop item, else ''. The client
153             # runtime uses data-key for list reconciliation; SSR must match the Hono
154             # reference, which stamps it on each loop item's scope root. The value is set
155             # on the child instance by the child renderer (`register_child_renderer` /
156             # `register_components_from_manifest`) from the JSX `key` prop — a reserved
157             # prop, never a real template variable.
158 0     0 0 0 sub data_key_attr ($self) {
  0         0  
  0         0  
159 0         0 my $k = $self->_data_key;
160 0 0       0 return '' unless defined $k;
161 0         0 $k =~ s/&/&/g;
162 0         0 $k =~ s/"/"/g;
163 0         0 return qq{ data-key="$k"};
164             }
165              
166 0     0 0 0 sub props_attr ($self) {
  0         0  
  0         0  
167 0         0 my $props = $self->_props;
168 0 0 0     0 return '' unless $props && %$props;
169             # encode_json returns a character string (not bytes) for safe embedding
170             # in templates (the Mojo backend uses Mojo::JSON::to_json).
171             # The JSON must then be attribute-escaped: a raw `'` inside a string
172             # value (e.g. a blog paragraph) terminates the single-quoted attribute
173             # and truncates the hydration payload. The browser entity-decodes the
174             # attribute value, so the client's JSON.parse sees the original text.
175 0         0 my $json = _html_escape($self->backend->encode_json($props));
176 0         0 return qq{ bf-p='$json'};
177             }
178              
179             # ---------------------------------------------------------------------------
180             # Context (SSR mirror of the client `provideContext` / `useContext`)
181             # ---------------------------------------------------------------------------
182             #
183             # A `` seeds a value that descendant `useContext(Ctx)`
184             # consumers read during the same render. Dynamic scoping mirrors the client:
185             # the provider pushes the value before rendering its children and pops it
186             # after, and `use_context` reads the innermost active value (or the
187             # `createContext` default when none is active).
188             #
189             # The value stacks live in a package-level store rather than per-instance or
190             # on `$c->stash`: a parent template and the child templates it renders via
191             # `render_child` are separate bf instances that don't reliably share a
192             # controller (the Xslate backend runs with `c => undef`) nor a backend (the
193             # Mojo path lazily builds one per instance). SSR rendering is synchronous —
194             # nothing awaits between a provider's push and its matching pop — and the
195             # push/pop are perfectly balanced, so the per-name stack always unwinds to
196             # empty at the end of each provider subtree, keeping concurrent root renders
197             # isolated. provide/revoke return '' so they drop cleanly into an inline
198             # `<: … :>` (Kolon) or `% … ;` (EP) emit.
199              
200             my %CONTEXT_STACKS;
201              
202 0     0 0 0 sub provide_context ($self, $name, $value) {
  0         0  
  0         0  
  0         0  
  0         0  
203 0   0     0 push @{ $CONTEXT_STACKS{$name} //= [] }, $value;
  0         0  
204 0         0 return '';
205             }
206              
207 0     0 0 0 sub revoke_context ($self, $name) {
  0         0  
  0         0  
  0         0  
208 0 0 0     0 pop @{ $CONTEXT_STACKS{$name} } if $CONTEXT_STACKS{$name} && @{ $CONTEXT_STACKS{$name} };
  0         0  
  0         0  
209 0         0 return '';
210             }
211              
212 0     0 0 0 sub use_context ($self, $name, $default = undef) {
  0         0  
  0         0  
  0         0  
  0         0  
213 0         0 my $stack = $CONTEXT_STACKS{$name};
214 0 0 0     0 return $default unless $stack && @$stack;
215 0         0 return $stack->[-1];
216             }
217              
218             # ---------------------------------------------------------------------------
219             # Comment Markers
220             # ---------------------------------------------------------------------------
221              
222 0     0 0 0 sub comment ($self, $text) {
  0         0  
  0         0  
  0         0  
223 0         0 return "";
224             }
225              
226             # ---------------------------------------------------------------------------
227             # JS-equivalent value stringification
228             # ---------------------------------------------------------------------------
229              
230             # Map a Perl boolean-shaped value to the JS `String(bool)` form.
231             # Used by the Mojo adapter when emitting reactive attribute bindings
232             # whose JS source `isBooleanResultExpr` classified as boolean —
233             # a comparison (`count() > 0`), a logical negation (`!ok()`), or a
234             # literal `true` / `false`. Perl's auto-stringification of those
235             # expressions yields `''` / `1`; Hono and Go emit `'false'` / `'true'`.
236             # Centralising the bool → string mapping here keeps the contract
237             # testable and the template-emit syntax tidy
238             # (`<%= bf->bool_str(...) %>` vs an inline ternary).
239             #
240             # Contract is boolean-only: callers must have classified the
241             # expression as boolean-result before routing through this helper.
242             # Non-boolean values reaching here will be Perl-truthy-coerced to
243             # 'true' / 'false', which is generally wrong — non-boolean attribute
244             # bindings stay on the plain `<%= expr %>` emit path and never reach
245             # this function.
246 0     0 0 0 sub bool_str ($self, $value) {
  0         0  
  0         0  
  0         0  
247 0 0       0 return $value ? 'true' : 'false';
248             }
249              
250 0     0 0 0 sub text_start ($self, $slot_id) {
  0         0  
  0         0  
  0         0  
251 0         0 return "";
252             }
253              
254 0     0 0 0 sub text_end ($self) {
  0         0  
  0         0  
255 0         0 return "";
256             }
257              
258             # See spec/compiler.md "Slot identity" for the comment-scope wire format.
259 0     0 0 0 sub scope_comment ($self) {
  0         0  
  0         0  
260 0   0     0 my $scope_id = $self->_scope_id // '';
261 0         0 my $host_segment = '';
262 0         0 my $host = $self->_bf_parent;
263 0         0 my $mount = $self->_bf_mount;
264 0 0 0     0 if (defined $host && length $host) {
265 0   0     0 $host_segment = "|h=$host|m=" . ($mount // '');
266             }
267 0         0 my $props_json = '';
268 0 0 0     0 if ($self->_props && %{$self->_props}) {
  0         0  
269 0         0 $props_json = '|' . $self->backend->encode_json($self->_props);
270             }
271 0         0 return "";
272             }
273              
274             # ---------------------------------------------------------------------------
275             # Script Registration
276             # ---------------------------------------------------------------------------
277              
278 0     0 0 0 sub register_script ($self, $path) {
  0         0  
  0         0  
  0         0  
279 0 0       0 return if $self->_script_seen->{$path};
280 0         0 $self->_script_seen->{$path} = 1;
281 0         0 push @{$self->_scripts}, $path;
  0         0  
282             }
283              
284             # ---------------------------------------------------------------------------
285             # Child Component Rendering
286             # ---------------------------------------------------------------------------
287             # (`_child_renderers` accessor is generated by the minimal accessor base above.)
288              
289             # Register a renderer for `render_child($name, ...)`. The renderer is
290             # invoked as `$renderer->($props_hashref, $invoking_bf)` — unpack `@_`
291             # (`my ($props, $caller) = @_;`) instead of declaring a one-argument
292             # subroutine signature, which would enforce arity and die on the second
293             # argument.
294 22     22 0 18 sub register_child_renderer ($self, $name, $renderer) {
  22         20  
  22         21  
  22         18  
  22         21  
295 22         30 $self->_child_renderers->{$name} = $renderer;
296             }
297              
298 11     11 0 2006 sub render_child ($self, $name, @args) {
  11         15  
  11         12  
  11         12  
  11         7  
299 11         17 my $renderer = $self->_child_renderers->{$name};
300 11 100       25 die "No renderer registered for child component '$name'" unless $renderer;
301             # Accept both the Mojo list form — `bf->render_child($name, k => v, ...)`
302             # — and the single-hashref form — `$bf.render_child($name, { k => v })`.
303             # Template languages whose method calls can't splat a hash into positional
304             # args (Text::Xslate Kolon, Template Toolkit) pass one hashref instead.
305 10 50 33     47 my %props = (@args == 1 && ref $args[0] eq 'HASH') ? %{ $args[0] } : @args;
  0         0  
306             # JSX children come in via the engine's children-capture mechanism
307             # (Mojo's `begin %>...<% end`, which produces a CODE ref returning a
308             # Mojo::ByteStream). Materialize it through the backend before handing
309             # the props to the child renderer so the child template sees
310             # `$children` as already-rendered HTML. Guard on `exists` so a
311             # childless invocation (`bf->render_child('counter')`) doesn't gain a
312             # spurious `children => undef` key — preserving the historical "only
313             # touch children when present" behaviour.
314             $props{children} = $self->backend->materialize($props{children})
315 10 50       19 if exists $props{children};
316             # Renderer contract (#1897): the renderer is invoked with TWO
317             # arguments — the props hashref and the INVOKING instance. A renderer
318             # registered on the root may be called from a nested child render
319             # (AccordionTrigger -> ChevronDownIcon), and the grandchild's scope /
320             # slot identity must chain off the CALLER's scope id, not the
321             # registrant's. Renderers unpack `@_` (`my ($props, $caller) = @_;`)
322             # rather than enforcing arity with a one-arg subroutine signature —
323             # see `register_child_renderer`.
324 10         16 return $renderer->(\%props, $self);
325             }
326              
327             # ---------------------------------------------------------------------------
328             # Bulk registration from build manifest
329             # ---------------------------------------------------------------------------
330             #
331             # `bf build` emits dist/templates/manifest.json describing every
332             # component the page might invoke (Counter, ui/button/index, ...).
333             # This helper walks that manifest and registers one child renderer per
334             # UI registry entry — the path shape `ui//index` maps to the
335             # `` slot key Counter.html.ep and friends use via
336             # `<%= bf->render_child('', ...) %>`.
337             #
338             # Each manifest entry carries an `ssrDefaults` hash derived statically
339             # from the component's JSX (prop destructure defaults + signal /
340             # memo initial values, see packages/jsx/src/ssr-defaults.ts). The
341             # child renderer seeds every template variable from that hash,
342             # preferring the caller's matching prop where one exists. This
343             # replaces the per-component `signal_init` callback that every
344             # scaffold's `app.pl` used to hand-roll for items 1/3 of issue #1416.
345             #
346             # `signal_init` remains as an opt-in override for cases the static
347             # extractor can't see through (e.g. signal initial values that
348             # reference imported helpers). When supplied for a given slot key
349             # it takes precedence over the manifest's `ssrDefaults` for that
350             # child, allowing callers to mix manual overrides with auto-derived
351             # defaults for siblings.
352             #
353             # Multi-component modules (#2132): a registry module exporting several
354             # components from one file (`ui/toast/index.tsx` → ToastProvider, Toast,
355             # ToastTitle, ...) compiles to one template PER component, listed in the
356             # entry's `components` map (`{ ToastProvider => { markedTemplate,
357             # ssrDefaults }, ... }`). Compiled parent templates invoke each one under
358             # its snake_cased component name (`render_child('toast_provider')`), so a
359             # renderer is registered per component. These run AFTER the directory-name
360             # registration and win on collision — for `ui/toast/index` the key `toast`
361             # must resolve to Toast's own template, not the module's first template
362             # (ToastProvider), which is all the bare `markedTemplate` carries.
363 7     7 0 50 sub register_components_from_manifest ($self, $manifest, %opts) {
  7         8  
  7         9  
  7         9  
  7         6  
364 7   100     23 my $signal_inits = $opts{signal_init} // {};
365              
366 7         21 for my $entry_name (keys %$manifest) {
367             # `__barefoot__` is the runtime entry, not a component.
368 7 50       16 next if $entry_name eq '__barefoot__';
369             # Only UI registry components (path shape `ui//index`)
370             # become child renderers; top-level page components are the
371             # render target rather than a child.
372 7 50       53 next unless $entry_name =~ m{^ui/([^/]+)/index$};
373 7         20 my $slot_key = $1;
374 7         12 my $entry = $manifest->{$entry_name};
375              
376             # Directory-name registration — the pre-`components` convention,
377             # kept so manifests from older builds (no `components` map) still
378             # resolve single-component modules like `button`.
379             $self->_register_manifest_child(
380             $slot_key, $entry->{markedTemplate},
381             $signal_inits->{$slot_key}, $entry->{ssrDefaults},
382 7         28 );
383              
384             # Per-component registrations (#2132), keyed by the snake_cased
385             # component name the compiled templates actually call.
386 7         13 my $components = $entry->{components};
387 7 100       19 next unless ref($components) eq 'HASH';
388 5         19 for my $component_name (sort keys %$components) {
389 16         17 my $component = $components->{$component_name};
390 16 50       22 next unless ref($component) eq 'HASH';
391 16         44 my $component_key = _snake_case($component_name);
392             $self->_register_manifest_child(
393             $component_key, $component->{markedTemplate},
394             $signal_inits->{$component_key}, $component->{ssrDefaults},
395 16         35 );
396             }
397             }
398             }
399              
400             # PascalCase → snake_case, mirroring the Mojo adapter's `toTemplateName`
401             # (prefix every uppercase letter with `_`, lowercase the whole string,
402             # strip the leading `_`): `ToastProvider` → `toast_provider`.
403 20     20   1346 sub _snake_case ($name) {
  20         20  
  20         16  
404 20         19 my $s = $name;
405 20         103 $s =~ s/([A-Z])/_$1/g;
406 20         30 $s = CORE::lc $s;
407 20         35 $s =~ s/^_//;
408 20         51 return $s;
409             }
410              
411             # Register one manifest-driven child renderer: `$slot_key` becomes the
412             # `render_child` name, `$marked` locates the template, and `$signal_init`
413             # / `$manifest_defaults` seed the child's template vars (see
414             # `register_components_from_manifest` for the contract).
415 23     23   24 sub _register_manifest_child ($self, $slot_key, $marked, $signal_init, $manifest_defaults) {
  23         23  
  23         26  
  23         23  
  23         52  
  23         20  
  23         20  
416 23   100     33 $marked //= '';
417 23 100       36 return unless $marked;
418 22         44 my $parent_scope = $self->_scope_id;
419             # Weaken the parent capture so the child-renderer closures stored on
420             # `$self->_child_renderers` don't keep `$self` alive (the direct
421             # closure <-> parent cycle). The controller is reached through `$parent`
422             # at call time rather than captured strongly here, so the closures hold
423             # no strong reference to `$c` either — see the controller-cycle note in
424             # `new`. `$parent` is always live whenever a closure runs (the closure is
425             # stored on `$parent`, so `$parent` outlives every invocation).
426 22         28 weaken(my $parent = $self);
427              
428             # `templates/ui/button/index.html.ep` → `ui/button/index`
429 22         20 my $template_name = $marked;
430 22         46 $template_name =~ s{^templates/}{};
431 22         52 $template_name =~ s{\.html\.ep$}{};
432              
433             $self->register_child_renderer($slot_key, sub {
434             # `$caller` is the instance whose template invoked
435             # `render_child` (#1897) — for a nested render that is a child
436             # instance, and the grandchild's scope/slot identity must chain
437             # off ITS scope id (`root_s0_s0`), not the registrant's.
438 10     10   13 my ($props, $caller) = @_;
439 10   33     17 my $host = $caller // $parent;
440 10   33     15 my $host_scope = $host->_scope_id // $parent_scope;
441             # Child shares the parent's backend so nested renders go
442             # through the same engine + controller (and inherit any
443             # injected json_encoder). The controller is fetched via the weak
444             # `$parent` at call time — never captured strongly — so the
445             # closure adds no edge to the per-request reference cycle.
446 10         21 my $child_bf = BarefootJS->new($parent->c, { backend => $parent->backend });
447 10         13 my $slot_id = delete $props->{_bf_slot};
448             # JSX `key` (a reserved prop) → data-key on the child's scope root
449             # for keyed-loop reconciliation (see `data_key_attr`).
450 10         20 my $data_key = delete $props->{key};
451 10 50       21 $child_bf->_data_key($data_key) if defined $data_key;
452 10 50       117 $child_bf->_scope_id(
453             $slot_id ? $host_scope . '_' . $slot_id
454             : $template_name . '_' . substr(rand() =~ s/^0\.//r, 0, 6)
455             );
456 10         24 $child_bf->_is_child(1);
457             # (#1249) Slot identity: host scope + slot id. Emitted as
458             # bf-h / bf-m attributes by hydration_attrs.
459 10 50       16 if ($slot_id) {
460 0         0 $child_bf->_bf_parent($host_scope);
461 0         0 $child_bf->_bf_mount($slot_id);
462             }
463             # Share the root registry so the child's own template can
464             # render further imported components (#1897).
465 10         13 $child_bf->_child_renderers($parent->_child_renderers);
466 10         16 $child_bf->_scripts($parent->_scripts);
467 10         17 $child_bf->_script_seen($parent->_script_seen);
468              
469 10         10 my %extra;
470 10 100       17 if ($signal_init) {
    50          
471 1         3 %extra = $signal_init->($props);
472             } elsif ($manifest_defaults) {
473 9         15 %extra = _derive_stash_from_defaults($manifest_defaults, $props);
474             }
475              
476             # Render the child template with $child_bf bound as the active
477             # instance for the nested render. The backend owns the
478             # engine-specific binding + restore (stash juggle for Mojo).
479 10         29 my $html = $parent->backend->render_named(
480             $template_name, $child_bf, { %$props, %extra },
481             );
482 10         79 chomp $html;
483 10         47 return $html;
484 22         110 });
485             }
486              
487             # Derive template-stash kvs from a manifest entry's `ssrDefaults`
488             # section. Each entry shape:
489             # { value => , propName => , isRestProps => bool }
490             # For `isRestProps`, the rest bag passes through unchanged (or the
491             # static `{}` if the caller didn't supply one). For ordinary entries
492             # the caller's `$props->{propName}` wins when defined, otherwise the
493             # static `value` does. `propName`-less entries (signal / memo locals)
494             # always use the static value — the caller cannot override them.
495 9     9   10 sub _derive_stash_from_defaults ($defaults, $props) {
  9         6  
  9         9  
  9         8  
496 9         30 my %extra;
497 9         33 for my $name (keys %$defaults) {
498 13         15 my $d = $defaults->{$name};
499 13 50       19 if (ref($d) ne 'HASH') {
500 0         0 $extra{$name} = $d;
501 0         0 next;
502             }
503 13 50       31 if ($d->{isRestProps}) {
504 0 0       0 $extra{$name} = exists $props->{$name} ? $props->{$name} : $d->{value};
505 0         0 next;
506             }
507 13         14 my $prop_name = $d->{propName};
508 13 100 66     34 if (defined $prop_name && exists $props->{$prop_name} && defined $props->{$prop_name}) {
      66        
509 1         3 $extra{$name} = $props->{$prop_name};
510             } else {
511 12         22 $extra{$name} = $d->{value};
512             }
513             }
514 9         26 return %extra;
515             }
516              
517             # ---------------------------------------------------------------------------
518             # Script Output
519             # ---------------------------------------------------------------------------
520              
521 0     0 0 0 sub scripts ($self) {
  0         0  
  0         0  
522 0         0 my @tags;
523 0         0 for my $path (@{$self->_scripts}) {
  0         0  
524 0         0 push @tags, qq{};
525             }
526 0         0 return join("\n", @tags);
527             }
528              
529             # ---------------------------------------------------------------------------
530             # Streaming SSR (Out-of-Order)
531             # ---------------------------------------------------------------------------
532              
533 0     0 0 0 sub streaming_bootstrap ($self) {
  0         0  
  0         0  
534 0         0 return q{};
535             }
536              
537 0     0 0 0 sub async_boundary ($self, $id, $fallback_html) {
  0         0  
  0         0  
  0         0  
  0         0  
538             # The fallback comes in via Mojo `begin %>...<% end` capture (see
539             # MojoAdapter::renderAsync), which produces a CODE ref returning a
540             # Mojo::ByteStream. Materialize it through the backend so the rendered
541             # HTML embeds in the placeholder rather than the CODE ref's
542             # stringification.
543 0         0 $fallback_html = $self->backend->materialize($fallback_html);
544 0         0 return qq{
$fallback_html
};
545             }
546              
547 0     0 0 0 sub async_resolve ($self, $id, $content_html) {
  0         0  
  0         0  
  0         0  
  0         0  
548 0         0 return qq{};
549             }
550              
551             # ---------------------------------------------------------------------------
552             # JS-compat callees (#1189) — invoked from generated Mojo templates as
553             # <%= bf->json($val) %>, <%= bf->floor($val) %>, etc. The MojoAdapter's
554             # `templatePrimitives` registry emits these helper calls in place of the
555             # corresponding JS callees (`JSON.stringify`, `Math.floor`, …) so the SSR
556             # template can render value-equivalent output without a JS engine.
557             #
558             # Failure policy mirrors the Go adapter (#1188): user-data marshalling
559             # (json) bubbles errors so Mojolicious aborts loudly on cycles /
560             # unsupported values rather than silently producing an empty payload.
561             # Numeric coercion follows JS semantics (NaN propagates as the special
562             # string 'NaN'; non-numeric input returns 'NaN' rather than 0). Strings
563             # always coerce to a string representation.
564             # ---------------------------------------------------------------------------
565              
566 4     4 0 144029 sub json ($self, $value) {
  4         6  
  4         4  
  4         5  
567             # Mojo::JSON::to_json returns a character string (not bytes), suitable
568             # for embedding in HTML output via Mojo::ByteStream / `<%==`.
569             #
570             # Documented divergence from JS: JS distinguishes `null` (renders as
571             # "null") from `undefined` (`JSON.stringify(undefined)` returns the
572             # JS value `undefined`, not a string). Perl has no such distinction
573             # — both map to `undef`. We choose the `null` rendering for SSR
574             # ergonomics: an unset prop becomes the string "null" rather than
575             # the literal text "undefined" or an empty attribute. Matches the
576             # `null` case of JS exactly; diverges from the `undefined` case.
577 4         22 return $self->backend->encode_json($value);
578             }
579              
580 3     3 0 1692 sub string ($self, $value) {
  3         4  
  3         4  
  3         2  
581             # JS `String(v)` mirror. `undef` renders as the empty string here so
582             # an unset prop doesn't surface as a literal "undefined" / "null"
583             # in user-facing HTML — same divergence the Go adapter documents
584             # for `bf_string`.
585 3 100       15 return defined $value ? "$value" : '';
586             }
587              
588 15     15 0 1501 sub number ($self, $value) {
  15         17  
  15         15  
  15         14  
589             # JS `Number(v)` mirror. Numeric coerces via Perl's implicit
590             # numeric context; non-numeric / undef yield real numeric NaN
591             # (`'nan' + 0`) so downstream arithmetic propagates correctly
592             # (`Math.floor(NaN) === NaN`). Returning the literal string
593             # "NaN" would conflate the user-passing-the-string-"NaN" case
594             # with the parse-failure case, and break NaN detection in
595             # downstream helpers.
596 15 100       27 return 0 + 'nan' unless defined $value;
597 14 100       59 return $value + 0 if looks_like_number($value);
598 4         7 return 0 + 'nan';
599             }
600              
601             # NaN is the only float for which `$x != $x` holds. Used as the
602             # portable sentinel check in floor/ceil/round.
603 11     11   15 sub _is_nan { my $n = shift; return $n != $n }
  11         38  
604              
605             # True for +/-Infinity. `9**9**9` is Perl's portable infinity literal; a
606             # finite number is always strictly less than +Inf in magnitude.
607 0   0 0   0 sub _is_inf { my $n = shift; return $n == 9**9**9 || $n == -9**9**9 }
  0         0  
608              
609 3     3 0 1391 sub floor ($self, $value) {
  3         4  
  3         3  
  3         3  
610 3         7 my $n = $self->number($value);
611 3 100       6 return $n if _is_nan($n);
612 2         24 return POSIX::floor($n);
613             }
614              
615 3     3 0 172 sub ceil ($self, $value) {
  3         4  
  3         4  
  3         4  
616 3         6 my $n = $self->number($value);
617 3 100       18 return $n if _is_nan($n);
618 2         9 return POSIX::ceil($n);
619             }
620              
621 5     5 0 182 sub round ($self, $value) {
  5         6  
  5         6  
  5         4  
622 5         10 my $n = $self->number($value);
623 5 100       8 return $n if _is_nan($n);
624             # POSIX has no `round`. JS `Math.round` rounds half toward
625             # +Infinity (so `Math.round(-1.5) === -1`, not -2). `floor(n
626             # + 0.5)` reproduces that for both signs.
627 4         16 return POSIX::floor($n + 0.5);
628             }
629              
630             # ---------------------------------------------------------------------------
631             # Array / String method helpers (#1448 Tier A)
632             # ---------------------------------------------------------------------------
633             #
634             # `Array.prototype.includes(x)` and `String.prototype.includes(sub)`
635             # share a method name in JS; the JSX parser can't tell the two
636             # receiver shapes apart without TS type inference, so both lower to
637             # the same IR node (`array-method` / method `includes`). This helper
638             # dispatches at the Perl level via `ref()`:
639             # - ARRAY ref: scan elements with `BarefootJS::Evaluator::_same_value_zero`,
640             # matching `Array.prototype.includes`'s SameValueZero
641             # semantics (no cross-type coercion, e.g. `[2].includes("2")`
642             # is false; NaN matches NaN) — the same algorithm the
643             # evaluator's serialized-callback path already uses for
644             # `.includes`, so both positions agree. This used to be a
645             # stringy `eq` scan, which coerced numbers to strings
646             # (`[2].includes("2")` was true) and diverged from JS.
647             # - scalar: `index($recv, $sub) != -1`, with both args
648             # coerced through `// ''` so an undef receiver /
649             # needle doesn't trip Perl's substr warning.
650             # Anything else (HASH ref, code ref) returns false — matches the
651             # JS semantic where `.includes` is only defined on Array /
652             # TypedArray / String.
653              
654 16     16 0 1888 sub includes ($self, $recv, $elem) {
  16         20  
  16         17  
  16         14  
  16         13  
655 16 100       28 if (ref($recv) eq 'ARRAY') {
656 9         16 for my $item (@$recv) {
657 13 100       25 return 1 if BarefootJS::Evaluator::_same_value_zero($item, $elem);
658             }
659 4         12 return 0;
660             }
661 7 100       14 return 0 if ref($recv);
662 5 100 100     24 return index($recv // '', $elem // '') != -1 ? 1 : 0;
      50        
663             }
664              
665             # `Array.prototype.filter(fn)` / `.every(fn)` / `.some(fn)`. The Xslate adapter
666             # lowers a JS arrow predicate to a Kolon lambda (`-> $x { ... }`), which is
667             # callable from Perl as a code ref, and emits `$bf.filter($arr, )`.
668             # `filter` returns a new arrayref; `every` / `some` return 1/0. Non-array /
669             # empty receivers follow JS (`filter` → [], `every` → true, `some` → false).
670             # (The Mojo adapter lowers these shapes inline and never reaches these methods.)
671 0     0 0 0 sub filter ($self, $recv, $pred) {
  0         0  
  0         0  
  0         0  
  0         0  
672 0 0       0 return [] unless ref($recv) eq 'ARRAY';
673 0         0 return [ grep { $pred->($_) } @$recv ];
  0         0  
674             }
675              
676 0     0 0 0 sub every ($self, $recv, $pred) {
  0         0  
  0         0  
  0         0  
  0         0  
677 0 0       0 return 1 unless ref($recv) eq 'ARRAY';
678 0 0       0 for my $item (@$recv) { return 0 unless $pred->($item) }
  0         0  
679 0         0 return 1;
680             }
681              
682 0     0 0 0 sub some ($self, $recv, $pred) {
  0         0  
  0         0  
  0         0  
  0         0  
683 0 0       0 return 0 unless ref($recv) eq 'ARRAY';
684 0 0       0 for my $item (@$recv) { return 1 if $pred->($item) }
  0         0  
685 0         0 return 0;
686             }
687              
688             # `Array.prototype.find(fn)` / `.findIndex(fn)` / `.findLast(fn)` /
689             # `.findLastIndex(fn)` — same Kolon-lambda predicate mechanism as filter. The
690             # camelCase JS names lower to these snake_case methods (like index_of /
691             # last_index_of). `find` / `find_last` return the matching element (or undef →
692             # JS `undefined`); the index forms return the 0-based position (or -1).
693 0     0 0 0 sub find ($self, $recv, $pred) {
  0         0  
  0         0  
  0         0  
  0         0  
694 0 0       0 return undef unless ref($recv) eq 'ARRAY';
695 0 0       0 for my $item (@$recv) { return $item if $pred->($item) }
  0         0  
696 0         0 return undef;
697             }
698              
699 0     0 0 0 sub find_index ($self, $recv, $pred) {
  0         0  
  0         0  
  0         0  
  0         0  
700 0 0       0 return -1 unless ref($recv) eq 'ARRAY';
701 0 0       0 for my $i (0 .. $#$recv) { return $i if $pred->($recv->[$i]) }
  0         0  
702 0         0 return -1;
703             }
704              
705 0     0 0 0 sub find_last ($self, $recv, $pred) {
  0         0  
  0         0  
  0         0  
  0         0  
706 0 0       0 return undef unless ref($recv) eq 'ARRAY';
707 0 0       0 for my $i (reverse 0 .. $#$recv) { return $recv->[$i] if $pred->($recv->[$i]) }
  0         0  
708 0         0 return undef;
709             }
710              
711 0     0 0 0 sub find_last_index ($self, $recv, $pred) {
  0         0  
  0         0  
  0         0  
  0         0  
712 0 0       0 return -1 unless ref($recv) eq 'ARRAY';
713 0 0       0 for my $i (reverse 0 .. $#$recv) { return $i if $pred->($recv->[$i]) }
  0         0  
714 0         0 return -1;
715             }
716              
717             # `String.prototype.toLowerCase()` / `.toUpperCase()`. Kolon has a builtin
718             # `.join` array method (so the adapter uses that directly) but no builtin
719             # `lc` / `uc`, so these live on the runtime object. `CORE::` avoids recursing
720             # into these methods.
721 0 0   0 0 0 sub lc ($self, $s) { return defined $s ? CORE::lc($s) : '' }
  0         0  
  0         0  
  0         0  
  0         0  
722 0 0   0 0 0 sub uc ($self, $s) { return defined $s ? CORE::uc($s) : '' }
  0         0  
  0         0  
  0         0  
  0         0  
723              
724             # `Array.prototype.join(sep)` with JS semantics: separator defaults to ",",
725             # and undefined / null elements render as empty (`[1,,2].join(",")` → "1,,2").
726             # Kolon has a builtin `.join`, but routing through the runtime keeps the
727             # JS-compat element handling in one place. `CORE::join` avoids recursing.
728 0     0 0 0 sub join ($self, $recv, $sep = undef) {
  0         0  
  0         0  
  0         0  
  0         0  
729 0 0       0 return '' unless ref($recv) eq 'ARRAY';
730 0   0     0 $sep //= ',';
731 0 0       0 return CORE::join($sep, map { defined $_ ? $_ : '' } @$recv);
  0         0  
732             }
733              
734             # `.length` — JS works on BOTH arrays (element count) and strings (character
735             # count); Kolon's builtin `.size()` is array-only and faults on a string. So
736             # dispatch on ref type here. `CORE::length` avoids recursing into this method.
737 0     0 0 0 sub length ($self, $recv) {
  0         0  
  0         0  
  0         0  
738 0 0       0 return scalar @$recv if ref($recv) eq 'ARRAY';
739 0 0       0 return 0 if ref($recv);
740 0   0     0 return CORE::length($recv // '');
741             }
742              
743             # `Array.prototype.indexOf(x)` / `Array.prototype.lastIndexOf(x)`
744             # value-equality search (#1448 Tier A). Returns the 0-based position
745             # of the first / last matching element, or -1 if not found.
746             # Non-array receivers return -1 — matches the JS semantic that
747             # `.indexOf` / `.lastIndexOf` are only defined on Array / TypedArray.
748             # (The string-position `indexOf` form isn't in Tier A; if it lands
749             # later the helper can grow a ref()-dispatch branch like `includes`.)
750              
751 12     12   11 sub _array_index_of ($recv, $elem, $reverse) {
  12         12  
  12         12  
  12         12  
  12         11  
752 12 100       27 return -1 unless ref($recv) eq 'ARRAY';
753 11 100       34 my @indices = $reverse ? (reverse 0 .. $#{$recv}) : (0 .. $#{$recv});
  5         10  
  6         12  
754 11         15 for my $i (@indices) {
755 27         24 my $item = $recv->[$i];
756 27 100       30 if (!defined $item) {
757 2 50       9 return $i if !defined $elem;
758 0         0 next;
759             }
760 25 100 66     69 return $i if defined $elem && $item eq $elem;
761             }
762 4         12 return -1;
763             }
764              
765 7     7 0 2349 sub index_of ($self, $recv, $elem) {
  7         9  
  7         7  
  7         7  
  7         7  
766 7         12 return _array_index_of($recv, $elem, 0);
767             }
768              
769 5     5 0 6 sub last_index_of ($self, $recv, $elem) {
  5         5  
  5         5  
  5         6  
  5         4  
770 5         8 return _array_index_of($recv, $elem, 1);
771             }
772              
773             # `Array.prototype.at(i)` — supports negative indices (`.at(-1)` is
774             # the last element); out-of-bounds returns undef (which Mojo's
775             # auto-escape renders as the empty string, matching JS's `undefined`).
776             # Non-array receivers return undef. Matches the Go `bf_at` arithmetic
777             # (`length + i` for i < 0) so adapter output stays symmetric.
778              
779 10     10 0 1969 sub at ($self, $recv, $i) {
  10         11  
  10         10  
  10         8  
  10         11  
780 10 100       25 return undef unless ref($recv) eq 'ARRAY';
781 7 50       25 return undef if !defined $i;
782 7         9 my $len = scalar @$recv;
783 7 100       14 return undef if $len == 0;
784 6 100       10 my $idx = $i < 0 ? $len + $i : $i;
785 6 100 100     21 return undef if $idx < 0 || $idx >= $len;
786 4         12 return $recv->[$idx];
787             }
788              
789             # `Array.prototype.concat(other)` — merges two arrays in order
790             # into a new ARRAY ref. Non-array operands collapse to empty
791             # (matches the Go `bf_concat` semantic so cross-adapter output
792             # stays symmetric; differs from JS where a non-Array argument
793             # with `Symbol.isConcatSpreadable` would be spread, a behaviour
794             # the template-language path never observes).
795              
796 9     9 0 1822 sub concat ($self, $a, $b) {
  9         10  
  9         10  
  9         9  
  9         10  
797 9         8 my @out;
798 9 100       20 push @out, @$a if ref($a) eq 'ARRAY';
799 9 100       17 push @out, @$b if ref($b) eq 'ARRAY';
800 9         28 return \@out;
801             }
802              
803             # `Array.prototype.slice(start, end?)` — carves out a sub-range
804             # into a new ARRAY ref. Mirrors the Go `bf_slice` arithmetic so
805             # adapter output stays symmetric:
806             # - start < 0 → length + start (e.g. -1 = last index)
807             # - end < 0 → length + end
808             # - start < 0 after clamp → 0
809             # - end > length → length
810             # - start >= end → empty
811             # - end undef → "to length"
812             # Non-array receivers return an empty ARRAY ref.
813              
814 13     13 0 2616 sub slice ($self, $recv, $start, $end) {
  13         15  
  13         13  
  13         14  
  13         12  
  13         12  
815 13 100       29 return [] unless ref($recv) eq 'ARRAY';
816 11         12 my $len = scalar @$recv;
817 11 100       32 return [] if $len == 0;
818              
819 10   50     15 my $s = $start // 0;
820 10 100       17 $s = $len + $s if $s < 0;
821 10 50       14 $s = 0 if $s < 0;
822 10 100       15 $s = $len if $s > $len;
823              
824 10 100       14 my $e = defined $end ? $end : $len;
825 10 100       13 $e = $len + $e if $e < 0;
826 10 50       14 $e = 0 if $e < 0;
827 10 50       15 $e = $len if $e > $len;
828              
829 10 100       17 return [] if $s >= $e;
830 7         12 return [ @{$recv}[$s .. $e - 1] ];
  7         51  
831             }
832              
833             # `Array.prototype.reverse()` / `Array.prototype.toReversed()` —
834             # both shapes share this lowering. SSR templates render a snapshot
835             # of state, so JS's mutate-receiver (`reverse`) vs
836             # return-new-array (`toReversed`) distinction has no template-
837             # level meaning. Always returns a new ARRAY ref to keep callers
838             # safe from accidental aliasing. Non-array receivers return an
839             # empty ARRAY ref.
840              
841 8     8 0 3361 sub reverse ($self, $recv) {
  8         12  
  8         9  
  8         8  
842 8 100       25 return [] unless ref($recv) eq 'ARRAY';
843 5         20 return [ reverse @$recv ];
844             }
845              
846             # `Array.prototype.flat(depth?)` (#1448 Tier C) — flatten nested ARRAY
847             # refs `$depth` levels deep. A `$depth` of -1 is the `Infinity` sentinel
848             # (flatten fully); 0 returns a shallow copy. Non-ARRAY elements are kept
849             # as-is (JS only flattens nested arrays). Non-ARRAY receiver → [].
850 0     0 0 0 sub flat ($self, $recv, $depth = 1) {
  0         0  
  0         0  
  0         0  
  0         0  
851 0 0       0 return [] unless ref($recv) eq 'ARRAY';
852 0         0 my @out;
853 0         0 for my $el (@$recv) {
854 0 0 0     0 if ($depth != 0 && ref($el) eq 'ARRAY') {
855 0 0       0 my $next = $depth > 0 ? $depth - 1 : $depth;
856 0         0 push @out, @{ $self->flat($el, $next) };
  0         0  
857             }
858             else {
859 0         0 push @out, $el;
860             }
861             }
862 0         0 return \@out;
863             }
864              
865             # `Array.prototype.flat(depth)` with a DYNAMIC depth (#2094) — the depth is
866             # itself an arbitrary runtime value (e.g. a prop), not a compile-time literal,
867             # so it must be coerced with JS's `ToIntegerOrInfinity` before delegating to
868             # `flat` above: truncate toward zero; negative -> 0; NaN / non-numeric -> 0;
869             # +Infinity or a huge finite value -> flatten fully.
870             #
871             # Deliberately a SEPARATE entry point from `flat`, not a smarter version of
872             # it: the literal-depth path's `-1` argument is a compile-time SENTINEL baked
873             # into the template source, meaning "the source literally said `Infinity`". A
874             # genuinely dynamic depth that happens to evaluate to `-1` at render time
875             # means the OPPOSITE in real JS (`[1,[2]].flat(-1)` never recurses — same as
876             # `.flat(0)`). Since both paths would otherwise hand the same literal-looking
877             # argument to one shared function, that function couldn't tell which case
878             # it's in — so the two stay separate. Mirrors Go's `FlatDynamicDepth` /
879             # `coerceFlatDepth` in bf.go.
880 0     0 0 0 sub flat_dynamic ($self, $recv, $depth) {
  0         0  
  0         0  
  0         0  
  0         0  
881 0         0 return $self->flat($recv, _coerce_flat_depth($depth));
882             }
883              
884             # _coerce_flat_depth: JS `ToIntegerOrInfinity` for a dynamic `.flat(depth)`
885             # argument, collapsed to `flat`'s int contract (`-1` == flatten fully).
886             #
887             # Perl's scalar type system blurs string/number duality, so `looks_like_number`
888             # (already the codebase's ToNumber-style coercion check, see BarefootJS::
889             # Evaluator's `_to_number`) is reused here rather than inventing a new
890             # convention. `looks_like_number` on this Perl (5.38) already recognises the
891             # strings "Infinity" / "-Infinity" / "NaN" as numeric (verified empirically:
892             # `perl -MScalar::Util=looks_like_number -e 'print looks_like_number("Infinity")'`
893             # prints 1), and `$str + 0` on those strings yields the corresponding Perl
894             # non-finite double, so no extra string special-casing is needed here.
895 0     0   0 sub _coerce_flat_depth ($depth) {
  0         0  
  0         0  
896 0 0       0 return 0 unless defined $depth;
897 0         0 my $f;
898 0 0 0     0 if (ref($depth) eq 'JSON::PP::Boolean') {
    0          
899 0 0       0 $f = $depth ? 1 : 0;
900             }
901             elsif (!ref($depth) && looks_like_number($depth)) {
902 0         0 $f = $depth + 0;
903             }
904             else {
905             # undef handled above; anything else non-numeric (a plain string
906             # that isn't a number, a HASH/ARRAY ref, ...) coerces via NaN.
907 0         0 return 0;
908             }
909 0 0       0 return 0 if $f != $f; # NaN
910 0 0       0 return -1 if $f == 9**9**9; # +Infinity -> flatten fully sentinel
911 0 0       0 return 0 if $f == -(9**9**9); # -Infinity -> 0
912 0         0 my $trunc = int($f); # Perl's int() truncates toward zero
913 0 0       0 return 0 if $trunc < 0;
914 0 0       0 return -1 if $trunc > 1_000_000; # huge finite ~= flatten fully
915 0         0 return $trunc;
916             }
917              
918             # `Array.prototype.flatMap(fn)` value-returning field projection
919             # (#1448 Tier C) — map each element through a self / field projection,
920             # then flatten one level. `field` reads a HASH-ref key (the raw JS prop
921             # name, as `bf->reduce` does); a projected non-ARRAY value is kept as-is
922             # (flatMap = map + flat(1)). Non-ARRAY receiver → [].
923 0     0 0 0 sub flat_map ($self, $recv, $key_kind, $key) {
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
924 0 0       0 return [] unless ref($recv) eq 'ARRAY';
925 0         0 my @projected;
926 0         0 for my $el (@$recv) {
927 0 0       0 if ($key_kind eq 'field') {
928             # JS `i => i.field` on a non-object yields `undefined`, not the
929             # element itself — push `undef` so a scalar element doesn't leak
930             # into the output (matches Go's `getFieldValue` returning nil).
931 0 0       0 push @projected, ref($el) eq 'HASH' ? $el->{$key} : undef;
932             }
933             else {
934 0         0 push @projected, $el;
935             }
936             }
937 0         0 return $self->flat(\@projected, 1);
938             }
939              
940             # `Array.prototype.flatMap(i => [i.a, i.b])` — array-literal tuple
941             # projection (#1448 Tier C). Each `@specs` entry is a [kind, key] arrayref
942             # (['self', ''] or ['field', 'a']). For each element, every leaf's value
943             # is appended in order. flat(1) removes only the literal wrapper, so an
944             # array-valued leaf is appended verbatim (no spread) — i.e. just append
945             # each leaf. A non-HASH element under a `field` leaf yields undef (JS
946             # `i.field` on a non-object). Non-ARRAY receiver → [].
947 0     0 0 0 sub flat_map_tuple ($self, $recv, @specs) {
  0         0  
  0         0  
  0         0  
  0         0  
948 0 0       0 return [] unless ref($recv) eq 'ARRAY';
949 0         0 my @out;
950 0         0 for my $el (@$recv) {
951 0         0 for my $spec (@specs) {
952 0         0 my ($kind, $key) = @$spec;
953 0 0       0 if ($kind eq 'field') {
954 0 0       0 push @out, ref($el) eq 'HASH' ? $el->{$key} : undef;
955             }
956             else {
957 0         0 push @out, $el;
958             }
959             }
960             }
961 0         0 return \@out;
962             }
963              
964             # `String.prototype.trim()` — strip leading + trailing whitespace.
965             # JS's `String.prototype.trim` matches `\s` in the Unicode sense
966             # (any whitespace including non-breaking space U+00A0); Perl's `\s`
967             # inside a regex with `/u` flag is the same. Undef receivers return
968             # the empty string (matches JS's `String(undefined).trim()` which
969             # would be "undefined" → "undefined", but in our template context
970             # undef commonly means "missing prop"; rendering the empty string
971             # is the safer choice and mirrors the JS-compat divergence we
972             # already document for `bf->string(undef) === ""`).
973              
974 11     11 0 1771 sub trim ($self, $recv) {
  11         14  
  11         12  
  11         12  
975 11 100       22 return '' unless defined $recv;
976 10 100       19 return '' if ref($recv);
977 8         14 my $s = "$recv";
978 8         34 $s =~ s/^\s+|\s+$//gu;
979 8         27 return $s;
980             }
981              
982             # `Number.prototype.toFixed(digits)` (#1897) — fixed-decimal string with
983             # zero-padding. JS rounds the scaled integer half toward +Infinity (the
984             # spec's "pick the larger n" tie-break), so `(2.5).toFixed(0)` is "3";
985             # bare `sprintf("%.*f")` would round half-to-even ("2"), diverging. Scale
986             # by 10**digits, round with `floor(x + 0.5)` (the same tie-break the
987             # `round` helper uses), then format the exact multiple. A negative
988             # `digits` clamps to 0, mirroring how the adapters default an omitted
989             # argument.
990 0     0 0 0 sub to_fixed ($self, $value, $digits = 0) {
  0         0  
  0         0  
  0         0  
  0         0  
991 0         0 my $n = $self->number($value);
992             # JS toFixed returns the STRINGS "NaN" / "Infinity" / "-Infinity" for
993             # non-finite inputs; the numeric values would stringify per-platform
994             # ("nan"/"inf"/...) and diverge.
995 0 0       0 return 'NaN' if _is_nan($n);
996 0 0       0 return $n < 0 ? '-Infinity' : 'Infinity' if _is_inf($n);
    0          
997 0 0 0     0 $digits = 0 if !defined $digits || $digits < 0;
998 0         0 my $factor = 10 ** $digits;
999 0         0 my $rounded = POSIX::floor($n * $factor + 0.5);
1000 0         0 return sprintf('%.*f', $digits, $rounded / $factor);
1001             }
1002              
1003             # `String.prototype.split(sep)` (#1448 Tier B) — string → ARRAY ref.
1004             #
1005             # Two JS-parity wrinkles drive the helper (a bare `split` emit would
1006             # diverge from both JS and Go):
1007             #
1008             # * Perl's `split` treats its first argument as a *regex*, so a
1009             # separator like '.' or '|' would match far too much. We
1010             # `quotemeta` it to force literal-string matching, mirroring JS's
1011             # string-separator semantics (the regex-separator form stays
1012             # refused upstream — see the parser arm).
1013             # * Perl's `split` drops trailing empty fields by default; JS keeps
1014             # them (`"a,".split(",")` is `["a", ""]`). Passing the `-1` limit
1015             # preserves them, matching JS and Go's `strings.Split`.
1016             #
1017             # An empty separator splits into individual characters (JS + Go agree).
1018             # Undef receiver renders as the single-element `['']` — the same
1019             # "missing prop → empty string" convention `bf->trim` uses.
1020              
1021 17     17 0 1924 sub split ($self, $recv, $sep = undef, $limit = undef) {
  17         21  
  17         18  
  17         25  
  17         18  
  17         16  
1022 17 100 66     74 my $s = defined $recv && !ref($recv) ? "$recv" : '';
1023              
1024 17         18 my @parts;
1025 17 100       41 if (!defined $sep) {
    100          
    100          
1026             # No separator → the whole string in a single-element array
1027             # (matches JS `"x".split()` / `.split(undefined)`).
1028 1         2 @parts = ($s);
1029             }
1030             elsif ("$sep" eq '') {
1031             # Empty separator → individual characters. No `-1` limit here:
1032             # on an empty pattern Perl's `split` with `-1` appends a spurious
1033             # trailing empty field ("abc" → 'a','b','c',''), which JS/Go don't.
1034 2         3 @parts = split //, $s;
1035             }
1036             elsif ($s eq '') {
1037             # Empty input with a non-empty separator: JS `"".split(",")` is
1038             # `[""]` and Go's `strings.Split("", ",")` is `[""]`, but Perl's
1039             # `split /,/, ''` returns the empty list — special-case for parity.
1040 1         3 @parts = ('');
1041             }
1042             else {
1043             # `quotemeta` forces literal-string matching (JS string-separator
1044             # semantics); the `-1` keeps trailing empty fields (JS keeps them,
1045             # Perl's bare `split` drops them).
1046 13         19 my $q = quotemeta("$sep");
1047 13         115 @parts = split /$q/, $s, -1;
1048             }
1049              
1050             # Optional `limit` caps the number of pieces (JS `split(sep, limit)`).
1051             # 0 → empty; a negative limit keeps all (JS ToUint32 wrap makes it
1052             # effectively unbounded) — both match Go's `bf_split`.
1053 17 100       32 if (defined $limit) {
1054 4         6 my $n = int($limit);
1055 4 100 100     13 if ($n == 0) { @parts = () }
  1 100       2  
1056 1         4 elsif ($n > 0 && $n < scalar @parts) { @parts = @parts[0 .. $n - 1] }
1057             }
1058              
1059 17         88 return [@parts];
1060             }
1061              
1062             # `String.prototype.startsWith(prefix, position?)` (#1448 Tier B) —
1063             # string → boolean (1 / 0). `substr`-anchored literal comparison mirrors
1064             # Go's `strings.HasPrefix`. An empty prefix is always true (JS parity);
1065             # undef / non-string receivers coerce to the empty string first. The
1066             # optional `position` re-anchors the test (clamped to `[0, length]`),
1067             # matching JS `"abc".startsWith("b", 1)`.
1068              
1069 12     12 0 2652 sub starts_with ($self, $recv, $prefix, $position = undef) {
  12         15  
  12         12  
  12         14  
  12         13  
  12         12  
1070 12 100 66     38 my $s = defined $recv && !ref($recv) ? "$recv" : '';
1071 12 50       19 my $p = defined $prefix ? "$prefix" : '';
1072 12 100       19 if (defined $position) {
1073 4         6 my $n = int($position);
1074 4 100       6 $n = 0 if $n < 0;
1075 4 100       9 $n = CORE::length($s) if $n > CORE::length($s);
1076 4         7 $s = substr($s, $n);
1077             }
1078 12 100       50 return substr($s, 0, CORE::length $p) eq $p ? 1 : 0;
1079             }
1080              
1081             # `String.prototype.endsWith(suffix, endPosition?)` (#1448 Tier B) —
1082             # string → boolean (1 / 0). Mirrors Go's `strings.HasSuffix`. An empty
1083             # suffix is always true (JS parity); a suffix longer than the string is
1084             # false. `substr($s, -length $x)` would mis-read the whole string when
1085             # `length $x == 0`, so that case short-circuits. The optional
1086             # `endPosition` treats the string as if it were only that many chars
1087             # long (clamped to `[0, length]`), matching JS `"abc".endsWith("b", 2)`.
1088              
1089 10     10 0 13 sub ends_with ($self, $recv, $suffix, $end_position = undef) {
  10         25  
  10         11  
  10         10  
  10         10  
  10         9  
1090 10 50 33     33 my $s = defined $recv && !ref($recv) ? "$recv" : '';
1091 10 50       17 my $x = defined $suffix ? "$suffix" : '';
1092 10 100       15 if (defined $end_position) {
1093 4         4 my $e = int($end_position);
1094 4 100       6 $e = 0 if $e < 0;
1095 4 100       8 $e = CORE::length($s) if $e > CORE::length($s);
1096 4         7 $s = substr($s, 0, $e);
1097             }
1098 10 100       15 return 1 if $x eq '';
1099 9 100       20 return 0 if CORE::length($s) < CORE::length($x);
1100 7 100       24 return substr($s, -CORE::length $x) eq $x ? 1 : 0;
1101             }
1102              
1103             # `String.prototype.replace(pattern, replacement)` — string-pattern
1104             # form only (#1448 Tier B), replacing the FIRST occurrence (JS string-
1105             # pattern semantics). Spliced via index/substr rather than `s///` so
1106             # BOTH the pattern and the replacement are literal: no Perl regex
1107             # metacharacters in the pattern and no `$1` / `$&` interpolation in the
1108             # replacement. Go's `bf_replace` (strings.Replace, n=1) treats the
1109             # replacement literally too, so the two adapters stay byte-equal — this
1110             # diverges from JS only for replacement strings containing `$`-patterns
1111             # (rare in template position). An empty pattern inserts the replacement
1112             # at the front (`"abc".replace("", "X")` → "Xabc"), matching JS + Go.
1113              
1114 9     9 0 2723 sub replace ($self, $recv, $pattern, $replacement) {
  9         9  
  9         13  
  9         11  
  9         9  
  9         9  
1115 9 100 66     30 my $s = defined $recv && !ref($recv) ? "$recv" : '';
1116 9 50       34 my $o = defined $pattern ? "$pattern" : '';
1117 9 50       11 my $n = defined $replacement ? "$replacement" : '';
1118 9 100       20 return $n . $s if $o eq '';
1119 8         13 my $i = index($s, $o);
1120 8 100       17 return $s if $i < 0;
1121 6         27 return substr($s, 0, $i) . $n . substr($s, $i + CORE::length($o));
1122             }
1123              
1124             # `queryHref(base, { … })` (#2042) — build `"$base?k=v&…"` from a flat list of
1125             # (guard, key, value) triples. A pair is included iff its guard is truthy AND
1126             # its value is a non-empty string, mirroring the client `queryHref`'s `if
1127             # (value)` over string values: the adapter passes the guard `1` for a plain
1128             # `key: v`, or the lowered condition for `key: cond ? v : undefined`. Repeating a
1129             # key overwrites the value at its first position (`URLSearchParams.set`
1130             # semantics).
1131             #
1132             # A value may instead be an array ref, which APPENDS one pair per non-empty
1133             # member (`{ tag => ['a','b'] }` → `tag=a&tag=b`, i.e. `URLSearchParams.append`);
1134             # empty members are skipped, so an empty/all-empty array contributes nothing.
1135             #
1136             # Keys/values are form-encoded to equal the browser render byte-for-byte; no
1137             # surviving pair yields the bare base.
1138 0     0 0 0 sub query ($self, $base, @triples) {
  0         0  
  0         0  
  0         0  
  0         0  
1139 0 0 0     0 my $b = defined $base && !ref($base) ? "$base" : '';
1140 0         0 my (@pairs, %pos);
1141 0         0 my $i = 0;
1142 0         0 while ($i + 2 < @triples) {
1143 0         0 my ($guard, $key, $val) = @triples[$i, $i + 1, $i + 2];
1144 0         0 $i += 3;
1145 0 0       0 next unless $guard;
1146 0 0 0     0 $key = defined $key && !ref($key) ? "$key" : '';
1147 0 0       0 if (ref($val) eq 'ARRAY') {
1148             # Append each non-empty member; appended pairs never overwrite, so
1149             # they don't participate in the set()-position map.
1150 0         0 for my $m (@$val) {
1151 0 0 0     0 my $s = defined $m && !ref($m) ? "$m" : '';
1152 0 0       0 next if $s eq '';
1153 0         0 push @pairs, [$key, $s];
1154             }
1155 0         0 next;
1156             }
1157 0 0 0     0 $val = defined $val && !ref($val) ? "$val" : '';
1158 0 0       0 next if $val eq '';
1159 0 0       0 if (exists $pos{$key}) {
1160 0         0 $pairs[$pos{$key}][1] = $val;
1161             }
1162             else {
1163 0         0 $pos{$key} = scalar @pairs;
1164 0         0 push @pairs, [$key, $val];
1165             }
1166             }
1167 0 0       0 return $b unless @pairs;
1168 0         0 return "$b?" . CORE::join('&', map { _form_escape($_->[0]) . '=' . _form_escape($_->[1]) } @pairs);
  0         0  
1169             }
1170              
1171             # `String.prototype.repeat(n)` — the receiver concatenated n times
1172             # (#1448 Tier B), via Perl's `x` operator. JS throws RangeError for a
1173             # negative count, but SSR templates degrade to the empty string rather
1174             # than dying mid-render, so a count <= 0 returns "" (Go's `bf_repeat`
1175             # applies the same clamp). The count is truncated toward zero
1176             # (`int`), matching JS's ToIntegerOrInfinity on `"a".repeat(3.7)`.
1177              
1178 7     7 0 2876 sub repeat ($self, $recv, $count) {
  7         8  
  7         11  
  7         5  
  7         6  
1179 7 100 66     27 my $s = defined $recv && !ref($recv) ? "$recv" : '';
1180 7 50       13 my $n = defined $count ? int($count) : 0;
1181 7 100       35 return $n <= 0 ? '' : $s x $n;
1182             }
1183              
1184             # `String.prototype.padStart` / `padEnd` (#1448 Tier B) — pad the
1185             # receiver to `$target` characters with `$pad` (default a single space)
1186             # repeated and truncated to fill, prepended or appended. Length is
1187             # measured in characters (Perl `length`), matching Go's rune-based
1188             # `bf_pad_*` — diverges from JS's UTF-16-unit length only for
1189             # astral-plane input. An empty pad, or a receiver already >= `$target`,
1190             # returns the receiver unchanged (JS parity). The `$target` is
1191             # truncated toward zero (JS ToLength on the first arg).
1192              
1193 10     10   10 sub _pad ($s, $target, $pad, $at_start) {
  10         12  
  10         10  
  10         10  
  10         9  
  10         8  
1194 10 100       15 $pad = ' ' unless defined $pad;
1195 10         11 $pad = "$pad";
1196 10 100       19 return $s if $pad eq '';
1197 9         10 my $len = CORE::length $s;
1198 9   50     15 my $t = int($target // 0);
1199 9 100       28 return $s if $len >= $t;
1200 8         10 my $need = $t - $len;
1201             # Repeat enough copies to cover $need, then trim to exactly $need.
1202 8         19 my $fill = substr($pad x (int($need / CORE::length($pad)) + 1), 0, $need);
1203 8 100       32 return $at_start ? $fill . $s : $s . $fill;
1204             }
1205              
1206 7     7 0 1794 sub pad_start ($self, $recv, $target, $pad = undef) {
  7         7  
  7         9  
  7         7  
  7         11  
  7         7  
1207 7 100 66     23 my $s = defined $recv && !ref($recv) ? "$recv" : '';
1208 7         14 return _pad($s, $target, $pad, 1);
1209             }
1210              
1211 3     3 0 3 sub pad_end ($self, $recv, $target, $pad = undef) {
  3         4  
  3         4  
  3         4  
  3         3  
  3         4  
1212 3 50 33     11 my $s = defined $recv && !ref($recv) ? "$recv" : '';
1213 3         5 return _pad($s, $target, $pad, 0);
1214             }
1215              
1216             # `Array.prototype.sort(cmp)` / `Array.prototype.toSorted(cmp)`
1217             # lowering (#1448 Tier B). Non-mutating — JS's mutate-vs-new
1218             # distinction is moot in SSR template context.
1219             #
1220             # Opts hash-ref. The compiler emits a `keys` list of per-key hashes
1221             # in priority order; each hash carries:
1222             #
1223             # key_kind => 'self' | 'field'
1224             # key => '' when key_kind eq 'self'; field name verbatim
1225             # from the comparator AST (e.g. 'price', 'createdAt')
1226             # when key_kind eq 'field' — no case normalisation
1227             # applied. Perl hash lookups are case-sensitive so
1228             # the key here must match the actual hash key the
1229             # user populated.
1230             # compare_type => 'numeric' | 'string' | 'auto'
1231             # direction => 'asc' | 'desc'
1232             #
1233             # Accepted comparator catalogue (gated upstream at parse time —
1234             # anything outside refuses with BF101 before reaching this helper):
1235             #
1236             # (a,b) => a.f - b.f → field, numeric
1237             # (a,b) => a - b → self, numeric
1238             # (a,b) => a[.f].localeCompare(b[.f]) → field|self, string
1239             # (a,b) => a.f > b.f ? 1 : -1 → field|self, auto
1240             # any of the above ||-chained → multi-key tie-breaks
1241             # (and reversed-operand variants for `desc`).
1242             #
1243             # `auto` (relational-ternary lowering) compares numerically when both
1244             # keys `looks_like_number`, else lexically — Go's `bf_sort` applies the
1245             # same rule so the two template adapters stay byte-equal.
1246             #
1247             # A future `nulls => 'first' | 'last'` knob can land per key without
1248             # churn — the opts hash is the right place to grow.
1249              
1250             # Evaluator-driven sort / reduce (#2018): the comparator / reducer body rides
1251             # as a serialized-ParsedExpr JSON string and is evaluated per element, delegating
1252             # to the shared BarefootJS::Evaluator. The adapter emits `bf->sort_eval(...)` /
1253             # `bf->reduce_eval(...)` for any pure comparator / reducer body; a body it can't
1254             # model (e.g. localeCompare) keeps the legacy `bf->sort` / `bf->reduce` path.
1255 0     0 0 0 sub sort_eval ($self, $recv, $cmp_json, $param_a, $param_b, $base_env = {}) {
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
1256 0         0 return BarefootJS::Evaluator::sort_by_json($recv, $cmp_json, $param_a, $param_b, $base_env);
1257             }
1258              
1259 0     0 0 0 sub reduce_eval ($self, $recv, $body_json, $acc_name, $item_name, $init, $direction = 'left', $base_env = {}) {
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
1260 0         0 return BarefootJS::Evaluator::fold_json($recv, $body_json, $acc_name, $item_name, $init, $direction, $base_env);
1261             }
1262              
1263             # Evaluator-driven higher-order predicates (#2018, P2): the predicate body
1264             # rides as a serialized-ParsedExpr JSON string evaluated per element, delegating
1265             # to the shared BarefootJS::Evaluator. The adapter emits `bf->filter_eval(...)`
1266             # etc. for any pure predicate; a body it can't model (e.g. a method-call
1267             # predicate) keeps the legacy `grep` / `bf->find` path. `find_eval` /
1268             # `find_index_eval` take a `$forward` flag (false → findLast / findLastIndex).
1269 0     0 0 0 sub filter_eval ($self, $recv, $pred_json, $param, $base_env = {}) {
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
1270 0         0 return BarefootJS::Evaluator::filter_json($recv, $pred_json, $param, $base_env);
1271             }
1272              
1273 0     0 0 0 sub every_eval ($self, $recv, $pred_json, $param, $base_env = {}) {
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
1274 0         0 return BarefootJS::Evaluator::every_json($recv, $pred_json, $param, $base_env);
1275             }
1276              
1277 0     0 0 0 sub some_eval ($self, $recv, $pred_json, $param, $base_env = {}) {
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
1278 0         0 return BarefootJS::Evaluator::some_json($recv, $pred_json, $param, $base_env);
1279             }
1280              
1281 0     0 0 0 sub find_eval ($self, $recv, $pred_json, $param, $forward = 1, $base_env = {}) {
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
1282 0         0 return BarefootJS::Evaluator::find_json($recv, $pred_json, $param, $forward, $base_env);
1283             }
1284              
1285 0     0 0 0 sub find_index_eval ($self, $recv, $pred_json, $param, $forward = 1, $base_env = {}) {
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
1286 0         0 return BarefootJS::Evaluator::find_index_json($recv, $pred_json, $param, $forward, $base_env);
1287             }
1288              
1289 0     0 0 0 sub flat_map_eval ($self, $recv, $proj_json, $param, $base_env = {}) {
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
1290 0         0 return BarefootJS::Evaluator::flat_map_json($recv, $proj_json, $param, $base_env);
1291             }
1292              
1293             # Value-producing `.map(cb)` (#2073): project each element through the
1294             # serialized projection body, one result per element (no flatten). Composes
1295             # through the array-method chain (`.map(cb).join(' ')`).
1296 0     0 0 0 sub map_eval ($self, $recv, $proj_json, $param, $base_env = {}) {
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
1297 0         0 return BarefootJS::Evaluator::map_json($recv, $proj_json, $param, $base_env);
1298             }
1299              
1300 16     16 0 7134 sub sort ($self, $recv, $opts = {}) {
  16         20  
  16         15  
  16         30  
  16         18  
1301 16 100       40 return [] unless ref($recv) eq 'ARRAY';
1302              
1303             # Normalise the per-key specs (priority order, length >= 1).
1304             my @spec = map {
1305             {
1306             key_kind => $_->{key_kind} // 'self',
1307             key => $_->{key} // '',
1308             compare_type => $_->{compare_type} // 'numeric',
1309 15   50     103 direction => $_->{direction} // 'asc',
      100        
      50        
      50        
1310             }
1311 13   50     15 } @{ $opts->{keys} // [] };
  13         34  
1312 13 50       23 return [ @$recv ] unless @spec;
1313              
1314             # Schwartzian transform: project each item to all its sort keys
1315             # once, then compare projected keys. Cheaper than re-resolving the
1316             # field accessors inside every comparison for non-trivial arrays.
1317             my @keyed = map {
1318 13         16 my $item = $_;
  36         34  
1319             my @ks = map {
1320 36 100 66     35 $_->{key_kind} eq 'field' && ref($item) eq 'HASH' ? $item->{ $_->{key} } : $item;
  42         101  
1321             } @spec;
1322 36         57 [ \@ks, $item ];
1323             } @$recv;
1324              
1325             my $cmp = sub {
1326 35     35   56 for my $i (0 .. $#spec) {
1327 39         35 my $sp = $spec[$i];
1328 39         65 my $c = _compare_sort_key($a->[0][$i], $b->[0][$i], $sp->{compare_type});
1329 39 100       52 next if $c == 0; # tie on this key — try the next
1330 32 100       66 return $sp->{direction} eq 'desc' ? -$c : $c;
1331             }
1332 3         6 return 0;
1333 13         46 };
1334              
1335 13         39 my @sorted = sort $cmp @keyed;
1336 13         23 return [ map { $_->[1] } @sorted ];
  36         149  
1337             }
1338              
1339             # Compare two projected keys, ascending orientation (-1 / 0 / 1); the
1340             # caller negates for 'desc'. 'auto' compares numerically when both
1341             # keys look like numbers, else lexically (matches Go's `bf_sort`).
1342             # undef coalesces to '' / 0 so the order stays total without warnings.
1343 39     39   34 sub _compare_sort_key ($av, $bv, $compare_type) {
  39         34  
  39         33  
  39         42  
  39         34  
1344 39 100       52 if ($compare_type eq 'string') {
1345 10   50     24 return ($av // '') cmp ($bv // '');
      50        
1346             }
1347 29 100       36 if ($compare_type eq 'auto') {
1348 9 100 50     33 if (looks_like_number($av // '') && looks_like_number($bv // '')) {
      50        
      66        
1349 6   50     16 return ($av // 0) <=> ($bv // 0);
      50        
1350             }
1351 3   50     8 return ($av // '') cmp ($bv // '');
      50        
1352             }
1353 20   50     56 return ($av // 0) <=> ($bv // 0); # numeric
      50        
1354             }
1355              
1356             # Fold an array into a scalar via the arithmetic-fold catalogue
1357             # (#1448 Tier C). Mirrors Go's `bf_reduce` and JS `reduce(fn, init)` /
1358             # `reduceRight(fn, init)` for the shapes `(acc, x) => acc x` /
1359             # `(acc, x) => acc x.field`:
1360             #
1361             # bf->reduce($recv, {
1362             # op => '+' | '*',
1363             # key_kind => 'self' | 'field',
1364             # key => '', # when key_kind eq 'field'
1365             # type => 'numeric' | 'string',
1366             # init => , # number, or string for concat
1367             # direction => 'left' | 'right', # 'right' = reduceRight (default 'left')
1368             # })
1369             #
1370             # Numeric folds accumulate with `+` / `*` (non-numeric keys coalesce to
1371             # 0); string folds concatenate via `bf->string` (undef → ''). The init
1372             # seeds the accumulator, so an empty array returns it unchanged — exactly
1373             # like JS. `direction => 'right'` folds right-to-left (reduceRight); only
1374             # observable for string concat, since numeric sum / product commute.
1375             # Float stringification can diverge from Go's for inexact binary
1376             # fractions (e.g. 0.1 + 0.2); integer sums — the common case — agree.
1377 0     0 0 0 sub reduce ($self, $recv, $opts = {}) {
  0         0  
  0         0  
  0         0  
  0         0  
1378 0   0     0 my $op = $opts->{op} // '+';
1379 0   0     0 my $key_kind = $opts->{key_kind} // 'self';
1380 0   0     0 my $key = $opts->{key} // '';
1381 0   0     0 my $type = $opts->{type} // 'numeric';
1382 0   0     0 my $direction = $opts->{direction} // 'left';
1383              
1384 0 0       0 my @items = ref($recv) eq 'ARRAY' ? @$recv : ();
1385             # reduceRight folds right-to-left; reversing the snapshot keeps the
1386             # single forward loop below. Only observable for string concat —
1387             # numeric sum / product commute. Qualify as CORE::reverse — this
1388             # package defines `sub reverse` (the `.reverse()` helper), so a bare
1389             # `reverse` is ambiguous under `use warnings`.
1390 0 0       0 @items = CORE::reverse(@items) if $direction eq 'right';
1391 0     0   0 my $project = sub ($item) {
  0         0  
  0         0  
1392 0 0 0     0 $key_kind eq 'field' && ref($item) eq 'HASH' ? $item->{$key} : $item;
1393 0         0 };
1394              
1395 0 0       0 if ($type eq 'string') {
1396 0   0     0 my $acc = $opts->{init} // '';
1397 0         0 $acc .= $self->string($project->($_)) for @items;
1398 0         0 return $acc;
1399             }
1400              
1401 0   0     0 my $acc = $opts->{init} // 0;
1402 0         0 for my $item (@items) {
1403 0         0 my $n = $project->($item);
1404             # Guard `defined` before `looks_like_number` so a missing field
1405             # (undef) folds as 0 without an "uninitialized value" warning
1406             # under `use warnings` — matching the `$av // ''` style `sort` uses.
1407 0 0 0     0 $n = 0 unless defined $n && looks_like_number($n);
1408 0 0       0 $op eq '*' ? ($acc *= $n) : ($acc += $n);
1409             }
1410 0         0 return $acc;
1411             }
1412              
1413             # ---------------------------------------------------------------------------
1414             # JSX intrinsic-element spread (#1407)
1415             # ---------------------------------------------------------------------------
1416             #
1417             # Mirrors the JS `spreadAttrs` runtime
1418             # (`packages/client/src/runtime/spread-attrs.ts`) and the Go adapter's
1419             # `bf.SpreadAttrs` so SSR output stays byte-equal across the three
1420             # adapters. Generated Mojo templates invoke this as
1421             # `<%== bf->spread_attrs($bag) %>`.
1422             #
1423             # Skip rules: nil/false values, event handlers (`on[A-Z]…` shape
1424             # matching JS `key[2] === key[2].toUpperCase()` — true for any
1425             # character whose uppercase is itself, including digits and
1426             # underscore), `children`. `ref` is intentionally NOT filtered,
1427             # matching the JS reference.
1428             #
1429             # Key remap: className → class, htmlFor → for; SVG camelCase
1430             # attrs preserved (case-sensitive XML spec); other camelCase keys
1431             # lowered to kebab-case with a leading `-` for an initial
1432             # uppercase letter (mirrors JS `key.replace(/([A-Z])/g, '-$1')`).
1433             #
1434             # `style` is routed through `_style_to_css` so object literals
1435             # serialise to a real CSS string instead of Perl's default
1436             # `HASH(0x...)` form.
1437             #
1438             # Output is deterministic: keys are sorted alphabetically before
1439             # emission, matching the Go adapter's `sort.Strings(keys)` policy
1440             # and Mojo::JSON's marshal order.
1441             #
1442             # The return value is a Mojo::ByteStream so the calling template's
1443             # `<%==` raw-emit skips re-escaping (the helper has already
1444             # HTML-escaped each value).
1445              
1446             my %SVG_CAMEL_CASE_ATTRS = map { $_ => 1 } qw(
1447             allowReorder attributeName attributeType autoReverse
1448             baseFrequency baseProfile calcMode clipPathUnits
1449             contentScriptType contentStyleType diffuseConstant edgeMode
1450             externalResourcesRequired filterRes filterUnits glyphRef
1451             gradientTransform gradientUnits kernelMatrix kernelUnitLength
1452             keyPoints keySplines keyTimes lengthAdjust limitingConeAngle
1453             markerHeight markerUnits markerWidth maskContentUnits
1454             maskUnits numOctaves pathLength patternContentUnits
1455             patternTransform patternUnits pointsAtX pointsAtY pointsAtZ
1456             preserveAlpha preserveAspectRatio primitiveUnits refX refY
1457             repeatCount repeatDur requiredExtensions requiredFeatures
1458             specularConstant specularExponent spreadMethod startOffset
1459             stdDeviation stitchTiles surfaceScale systemLanguage
1460             tableValues targetX targetY textLength viewBox viewTarget
1461             xChannelSelector yChannelSelector zoomAndPan
1462             );
1463              
1464 23     23   25 sub _to_attr_name ($key) {
  23         59  
  23         24  
1465 23 100       30 return 'class' if $key eq 'className';
1466 22 100       28 return 'for' if $key eq 'htmlFor';
1467 21 100       37 return $key if $SVG_CAMEL_CASE_ATTRS{$key};
1468             # camelCase → kebab-case, with a leading `-` for an initial
1469             # uppercase letter (JS-reference parity, even though that case
1470             # produces an HTML-invalid attribute name — same documented
1471             # behaviour as the Go adapter's `toAttrName`).
1472 19         18 my $out = $key;
1473 19         52 $out =~ s/([A-Z])/-\L$1/g;
1474 19         33 return $out;
1475             }
1476              
1477 0     0   0 sub _form_escape ($s) {
  0         0  
  0         0  
1478             # application/x-www-form-urlencoded serialisation, matching the browser's
1479             # `URLSearchParams` (which the SSR query render must equal): keep ASCII
1480             # alphanumerics and `* - . _`; encode every other byte as `%XX` (UPPER hex);
1481             # space → `+`. Non-ASCII is encoded byte-wise over its UTF-8 bytes.
1482 0 0       0 my $bytes = defined $s ? "$s" : '';
1483 0 0       0 utf8::encode($bytes) if utf8::is_utf8($bytes);
1484 0         0 $bytes =~ s/([^A-Za-z0-9*\-._ ])/sprintf('%%%02X', ord($1))/ge;
  0         0  
1485 0         0 $bytes =~ tr/ /+/;
1486 0         0 return $bytes;
1487             }
1488              
1489 24     24   23 sub _html_escape ($value) {
  24         27  
  24         18  
1490             # HTML attribute-value escape for SSR string emission. The
1491             # spread bag's values reach the browser as part of a generated
1492             # `key="..."` substring inside the rendered HTML, so the
1493             # escape set has to cover everything that could break either
1494             # the surrounding double-quoted attribute or the enclosing
1495             # tag: `&`, `<`, `>`, `"`, and `'`. Matches Go's
1496             # `template.HTMLEscapeString` semantics byte-for-byte (using
1497             # `"` / `'` for quotes rather than the named entities)
1498             # so the SSR output is identical across the Go and Mojo
1499             # adapters (#1407, #1413 review). The CSR-side
1500             # `applyRestAttrs` calls `el.setAttribute(name, String(value))`
1501             # — which does its own DOM-level escaping in the browser —
1502             # so JS doesn't need an explicit escape pass; Perl/Go emit a
1503             # string, so we do.
1504 24 50       43 my $s = defined $value ? "$value" : '';
1505 24         39 $s =~ s/&/&/g;
1506 24         30 $s =~ s/
1507 24         31 $s =~ s/>/>/g;
1508 24         28 $s =~ s/"/"/g;
1509 24         26 $s =~ s/'/'/g;
1510 24         77 return $s;
1511             }
1512              
1513 2     2   3 sub _style_to_css ($value) {
  2         2  
  2         2  
1514 2 50       4 return undef unless defined $value;
1515             # Non-hashref values pass through stringified — matches the JS
1516             # `typeof value !== 'object'` branch in `styleToCss`.
1517 2 100       4 if (ref($value) ne 'HASH') {
1518 1         2 my $s = "$value";
1519 1 50       4 return CORE::length $s ? $s : undef;
1520             }
1521 1         1 my @parts;
1522 1         3 for my $key (sort keys %$value) {
1523 2         3 my $v = $value->{$key};
1524 2 50       3 next unless defined $v;
1525 2         3 my $prop = $key;
1526 2         11 $prop =~ s/([A-Z])/-\L$1/g;
1527 2         6 push @parts, "$prop:$v";
1528             }
1529 1 50       5 return @parts ? CORE::join(';', @parts) : undef;
1530             }
1531              
1532              
1533             # Object-rest residual for a `.map()` destructure binding
1534             # (`{ id, ...rest } => …`, #2087 Phase B): returns a NEW hashref holding
1535             # every key of `$bag` except those named in `$keys` (an ARRAY ref of key
1536             # strings). This is plain JS destructure semantics (`const { id, ...rest }
1537             # = item`) — unlike `spread_attrs` below, there's no event-handler /
1538             # `children` filtering or key remapping here, because the residual is a
1539             # *value* the template may read fields off of (`$rest->{flag}`) or later
1540             # forward wholesale to `spread_attrs` (`{...rest}` on an element) — either
1541             # consumer applies its own rules downstream. A non-hashref `$bag` returns
1542             # an empty hashref rather than dying, so this stays safe as a `my` local
1543             # initializer even off unexpected/absent data (same defensive contract as
1544             # `spread_attrs`'s "no bag → nothing").
1545 0     0 0 0 sub omit ($self, $bag, $keys) {
  0         0  
  0         0  
  0         0  
  0         0  
1546 0 0 0     0 return {} unless defined $bag && ref($bag) eq 'HASH';
1547 0         0 my %exclude = map { $_ => 1 } @$keys;
  0         0  
1548 0         0 return { map { $_ => $bag->{$_} } grep { !$exclude{$_} } keys %$bag };
  0         0  
  0         0  
1549             }
1550              
1551 25     25 0 170619 sub spread_attrs ($self, $bag) {
  25         32  
  25         32  
  25         24  
1552 25 100 100     117 return '' unless defined $bag && ref($bag) eq 'HASH';
1553 23         27 my @parts;
1554 23         94 for my $key (sort keys %$bag) {
1555             # Event handlers: skip when key starts `on` and the third
1556             # character is its own uppercase form (uppercase letter,
1557             # digit, underscore, …). Mirrors the JS predicate.
1558 31 100 100     92 if (CORE::length($key) > 2 && substr($key, 0, 2) eq 'on') {
1559 4         8 my $c = substr($key, 2, 1);
1560 4 100       10 next if CORE::uc($c) eq $c;
1561             }
1562 28 100       43 next if $key eq 'children';
1563 27         35 my $val = $bag->{$key};
1564             # null / undef → drop.
1565 27 100       59 next unless defined $val;
1566             # Boolean values arrive as Mojo::JSON sentinel objects
1567             # (`Mojo::JSON::true` / `false`) — both from JSON-deserialised
1568             # props and from the test harness's `toPerlLiteral`
1569             # (which emits the sentinels rather than plain 0/1 to avoid
1570             # conflating booleans with numeric attribute values like
1571             # `tabindex="0"`). The contract is: callers MUST use the
1572             # sentinels for boolean values; plain Perl scalars 0/1
1573             # render as numeric attribute values, matching how JS
1574             # `spreadAttrs` treats a `0`/`1` JS number.
1575 26 100 66     73 if (ref($val) eq 'JSON::PP::Boolean' || ref($val) eq 'Mojo::JSON::_Bool') {
1576 2 100       25 next unless $val;
1577 1         9 push @parts, _to_attr_name($key);
1578 1         2 next;
1579             }
1580             # `style` routes through `_style_to_css` so object literals
1581             # serialise to a real CSS string.
1582 24 100       30 if ($key eq 'style') {
1583 2         5 my $css = _style_to_css($val);
1584 2 50 33     8 next unless defined $css && CORE::length $css;
1585 2         4 push @parts, qq{style="} . _html_escape($css) . qq{"};
1586 2         4 next;
1587             }
1588 22         54 my $name = _to_attr_name($key);
1589 22         41 push @parts, $name . qq{="} . _html_escape($val) . qq{"};
1590             }
1591 23 100       39 return '' unless @parts;
1592             # Mark the result raw so the calling template's `<%==` raw-emit
1593             # doesn't re-escape the already-escaped values (the Mojo backend
1594             # returns a Mojo::ByteStream).
1595 22         40 return $self->backend->mark_raw(CORE::join(' ', @parts));
1596             }
1597              
1598             1;
1599             __END__