@barefootjs/perl 0.8.0

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