File Coverage

lib/BarefootJS.pm
Criterion Covered Total %
statement 409 758 53.9
branch 187 338 55.3
condition 61 173 35.2
subroutine 44 82 53.6
pod 0 61 0.0
total 701 1412 49.6


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