@barefootjs/perl 0.17.0 → 0.18.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.
@@ -1,5 +1,5 @@
1
1
  package BarefootJS::DevReload;
2
- our $VERSION = "0.16.0";
2
+ our $VERSION = "0.17.1";
3
3
  use strict;
4
4
  use warnings;
5
5
  use feature 'signatures';
@@ -81,6 +81,16 @@ sub evaluate ($node, $env) {
81
81
  return _read_index(evaluate($node->{object}, $env), evaluate($node->{index}, $env));
82
82
  }
83
83
  if ($kind eq 'call') {
84
+ # Nested `.map(cb)` / `.filter(cb)` (#2094) — e.g. a `.flatMap(p =>
85
+ # p.tags.map(t => '#'+t))` projection body that itself contains a
86
+ # `.map`/`.filter` call. Checked BEFORE builtin-name resolution
87
+ # (the callee is a `member` node, never a bare/dotted builtin
88
+ # identifier, so the two can't collide). Mirrors Go's
89
+ # evalArrayCallbackCall / evalArrayCallback.
90
+ my ($method, $object_node, $arrow_node) = _array_callback_call($node);
91
+ if (defined $method) {
92
+ return _array_callback($method, $object_node, $arrow_node, $env);
93
+ }
84
94
  my $name = _builtin_name($node->{callee});
85
95
  return undef unless defined $name && $name ne '';
86
96
  my @args = map { evaluate($_, $env) } @{ $node->{args} // [] };
@@ -108,12 +118,128 @@ sub evaluate ($node, $env) {
108
118
  }
109
119
  return \%out;
110
120
  }
111
-
112
- # arrow-fn / higher-order / array-method / unsupported: a callback body
121
+ if ($kind eq 'array-method' && ($node->{method} // '') eq 'includes'
122
+ && @{ $node->{args} // [] } == 1)
123
+ {
124
+ # `.includes(x)` (#2075) — the one `array-method` in the evaluator
125
+ # subset, shared between `Array.prototype.includes` (SameValueZero
126
+ # membership) and `String.prototype.includes` (substring search),
127
+ # matching the receiver-type dispatch the SSR template lowering does
128
+ # at runtime (`bf_includes` / `$bf->includes`). Mirrors the JS
129
+ # reference's `includes()` (eval-reference.ts).
130
+ my $obj = evaluate($node->{object}, $env);
131
+ my $needle = evaluate($node->{args}[0], $env);
132
+ if (ref $obj eq 'ARRAY') {
133
+ for my $el (@$obj) {
134
+ return _bool(1) if _same_value_zero($el, $needle);
135
+ }
136
+ return _bool(0);
137
+ }
138
+ if (_is_string($obj)) {
139
+ return _bool(index($obj, _to_string($needle)) != -1);
140
+ }
141
+ # Any other receiver is not a JS `.includes` target — degrade to
142
+ # false rather than throwing, mirroring the reference.
143
+ return _bool(0);
144
+ }
145
+ if ($kind eq 'array-method' && ($node->{method} // '') eq 'join'
146
+ && @{ $node->{args} // [] } <= 1)
147
+ {
148
+ # `.join(sep?)` (#2094), a sibling of `.includes` above in the
149
+ # `array-method` node kind — needed for a composed case like
150
+ # `.flatMap(p => p.tags.map(...).join(' ')).join(', ')`. JS
151
+ # semantics: default separator is `,`; a `null`/`undefined`
152
+ # element joins as `''` (NOT the string "null"). Mirrors the Go
153
+ # evaluator's evalJoin.
154
+ my $obj = evaluate($node->{object}, $env);
155
+ my $args = $node->{args} // [];
156
+ my $sep = @$args == 1 ? _to_string(evaluate($args->[0], $env)) : ',';
157
+ return _join_array($obj, $sep);
158
+ }
159
+
160
+ # arrow-fn / higher-order / unsupported array-method: a callback body
113
161
  # containing these is refused upstream (BF101); never reached here.
114
162
  return undef;
115
163
  }
116
164
 
165
+ # ---------------------------------------------------------------------------
166
+ # Nested `.map` / `.filter` callback recognition (#2094) — the evaluator
167
+ # widening that lets a callback body (already evaluated here for reduce /
168
+ # sort / filter / find / …) itself contain a `.map(cb)` or `.filter(cb)`
169
+ # call, e.g. the #1938 blog-showcase `.flatMap(p => p.tags.map(t => '#'+t))`
170
+ # projection. Everything else nested (`.some`/`.find`/`.every`/`.sort`/
171
+ # `.reduce`/`.flat`/`.flatMap`, standalone arrows) stays refused upstream —
172
+ # only `.map`/`.filter` are recognized here, mirroring Go's
173
+ # evalArrayCallbackCall / evalArrayCallback exactly.
174
+ # ---------------------------------------------------------------------------
175
+
176
+ # _array_callback_call($node): if $node (a `call` node) is a `.map(arrow)` /
177
+ # `.filter(arrow)` call — i.e. its callee is an uncomputed `member` node
178
+ # whose property is "map"/"filter", and its first argument is an `arrow`
179
+ # node — returns `(method, object_node, arrow_node)`. Otherwise returns the
180
+ # empty list (so `if (my (...) = _array_callback_call($node))` is false).
181
+ sub _array_callback_call ($node) {
182
+ my $callee = $node->{callee};
183
+ return () unless ref $callee eq 'HASH' && ($callee->{kind} // '') eq 'member';
184
+ return () if $callee->{computed};
185
+ my $prop = $callee->{property} // '';
186
+ return () unless $prop eq 'map' || $prop eq 'filter';
187
+ my $args = $node->{args} // [];
188
+ return () unless @$args;
189
+ my $arrow = $args->[0];
190
+ return () unless ref $arrow eq 'HASH' && ($arrow->{kind} // '') eq 'arrow';
191
+ return ($prop, $callee->{object}, $arrow);
192
+ }
193
+
194
+ # _array_callback($method, $object_node, $arrow_node, $env): evaluate the
195
+ # receiver (`$object_node`) against $env, then map/filter it through the
196
+ # arrow's body. `params` in the decoded JSON is an array ref of plain
197
+ # strings (e.g. `["t"]` or `["t","i"]`) — 1 param binds the element, 2
198
+ # params bind (element, index). The child env is a COPY of the parent env
199
+ # (never mutated in place across sibling iterations), with the param
200
+ # name(s) bound per call — matches Go's per-call `inner` env copy.
201
+ sub _array_callback ($method, $object_node, $arrow_node, $env) {
202
+ my $arr = evaluate($object_node, $env);
203
+ return undef unless ref $arr eq 'ARRAY';
204
+ my @params = @{ $arrow_node->{params} // [] };
205
+ my $body = $arrow_node->{body};
206
+ my $call_cb = sub {
207
+ my ($item, $index) = @_;
208
+ my %inner = %$env;
209
+ $inner{ $params[0] } = $item if @params >= 1;
210
+ $inner{ $params[1] } = $index if @params >= 2;
211
+ return evaluate($body, \%inner);
212
+ };
213
+ if ($method eq 'map') {
214
+ my @out;
215
+ my $i = 0;
216
+ for my $item (@$arr) {
217
+ push @out, $call_cb->($item, $i);
218
+ $i++;
219
+ }
220
+ return \@out;
221
+ }
222
+ # filter
223
+ my @out;
224
+ my $i = 0;
225
+ for my $item (@$arr) {
226
+ push @out, $item if _truthy($call_cb->($item, $i));
227
+ $i++;
228
+ }
229
+ return \@out;
230
+ }
231
+
232
+ # _join_array($obj, $sep): `Array.prototype.join` semantics — non-ARRAY
233
+ # receiver -> '' (the evaluator's degrade-rather-than-die convention,
234
+ # matching `.includes` above); a `null`/`undefined` (Perl `undef`) element
235
+ # joins as '' (not the string "null"); every other element goes through
236
+ # `_to_string` (JS `String(x)` — a JS boolean stringifies "true"/"false", a
237
+ # number "42"/"NaN"/"Infinity", etc.). Mirrors Go's evalJoin.
238
+ sub _join_array ($obj, $sep) {
239
+ return '' unless ref $obj eq 'ARRAY';
240
+ return join($sep, map { defined $_ ? _to_string($_) : '' } @$obj);
241
+ }
242
+
117
243
  # eval_json($json, $env): decode a ParsedExpr JSON string and evaluate it.
118
244
  # Mirrors the Go EvalExpr entry point. Requires JSON::PP only when used.
119
245
  sub eval_json ($json, $env) {
@@ -276,6 +402,19 @@ sub _strict_eq ($l, $r) {
276
402
  return 0;
277
403
  }
278
404
 
405
+ # _same_value_zero: `Array.prototype.includes` membership test — `===`
406
+ # except `NaN` equals itself (and +0/-0 are not distinguished, which the
407
+ # JSON-decoded values here can't represent anyway). Reuses `_strict_eq`'s
408
+ # type/value rules and only special-cases the two-NaN case that `_strict_eq`
409
+ # (deliberately, for `===`) reports as unequal.
410
+ sub _same_value_zero ($l, $r) {
411
+ if (_is_number($l) && _is_number($r)) {
412
+ my ($lf, $rf) = ($l + 0, $r + 0);
413
+ return 1 if $lf != $lf && $rf != $rf; # NaN sameValueZero NaN
414
+ }
415
+ return _strict_eq($l, $r);
416
+ }
417
+
279
418
  sub _unary ($op, $v) {
280
419
  return _bool(!_truthy($v)) if $op eq '!';
281
420
  return -_to_number($v) if $op eq '-';
@@ -539,6 +678,21 @@ sub flat_map ($items, $proj, $param, $base_env = undef) {
539
678
  return \@out;
540
679
  }
541
680
 
681
+ # map_items — project each element through $proj (a pure ParsedExpr), keeping
682
+ # each result as one element (no flatten): JS value-producing `.map(cb)`
683
+ # (#2073). Named map_items (not `map`) so the Perl builtin stays unshadowed.
684
+ # Mirrors Go's MapEval. $base_env is optional.
685
+ sub map_items ($items, $proj, $param, $base_env = undef) {
686
+ my @arr = ref $items eq 'ARRAY' ? @$items : ();
687
+ my %env = $base_env ? %$base_env : ();
688
+ my @out;
689
+ for my $item (@arr) {
690
+ $env{$param} = $item;
691
+ push @out, evaluate($proj, \%env);
692
+ }
693
+ return \@out;
694
+ }
695
+
542
696
  # ---------------------------------------------------------------------------
543
697
  # JSON-string seams — the adapters emit `bf->filter_eval($recv, '<json>', …)`;
544
698
  # the predicate body arrives as a JSON string here, decoded then handed to the
@@ -575,4 +729,9 @@ sub flat_map_json ($items, $proj_json, $param, $base_env = undef) {
575
729
  return flat_map($items, JSON::PP->new->decode($proj_json), $param, $base_env);
576
730
  }
577
731
 
732
+ sub map_json ($items, $proj_json, $param, $base_env = undef) {
733
+ require JSON::PP;
734
+ return map_items($items, JSON::PP->new->decode($proj_json), $param, $base_env);
735
+ }
736
+
578
737
  1;
package/lib/BarefootJS.pm CHANGED
@@ -1,5 +1,5 @@
1
1
  package BarefootJS;
2
- our $VERSION = "0.16.0";
2
+ our $VERSION = "0.17.1";
3
3
  use strict;
4
4
  use warnings;
5
5
  use utf8;
@@ -168,7 +168,11 @@ sub props_attr ($self) {
168
168
  return '' unless $props && %$props;
169
169
  # encode_json returns a character string (not bytes) for safe embedding
170
170
  # in templates (the Mojo backend uses Mojo::JSON::to_json).
171
- my $json = $self->backend->encode_json($props);
171
+ # The JSON must then be attribute-escaped: a raw `'` inside a string
172
+ # value (e.g. a blog paragraph) terminates the single-quoted attribute
173
+ # and truncates the hydration payload. The browser entity-decodes the
174
+ # attribute value, so the client's JSON.parse sees the original text.
175
+ my $json = _html_escape($self->backend->encode_json($props));
172
176
  return qq{ bf-p='$json'};
173
177
  }
174
178
 
@@ -345,17 +349,19 @@ sub render_child ($self, $name, @args) {
345
349
  # it takes precedence over the manifest's `ssrDefaults` for that
346
350
  # child, allowing callers to mix manual overrides with auto-derived
347
351
  # defaults for siblings.
352
+ #
353
+ # Multi-component modules (#2132): a registry module exporting several
354
+ # components from one file (`ui/toast/index.tsx` → ToastProvider, Toast,
355
+ # ToastTitle, ...) compiles to one template PER component, listed in the
356
+ # entry's `components` map (`{ ToastProvider => { markedTemplate,
357
+ # ssrDefaults }, ... }`). Compiled parent templates invoke each one under
358
+ # its snake_cased component name (`render_child('toast_provider')`), so a
359
+ # renderer is registered per component. These run AFTER the directory-name
360
+ # registration and win on collision — for `ui/toast/index` the key `toast`
361
+ # must resolve to Toast's own template, not the module's first template
362
+ # (ToastProvider), which is all the bare `markedTemplate` carries.
348
363
  sub register_components_from_manifest ($self, $manifest, %opts) {
349
364
  my $signal_inits = $opts{signal_init} // {};
350
- my $parent_scope = $self->_scope_id;
351
- # Weaken the parent capture so the child-renderer closures stored on
352
- # `$self->_child_renderers` don't keep `$self` alive (the direct
353
- # closure <-> parent cycle). The controller is reached through `$parent`
354
- # at call time rather than captured strongly here, so the closures hold
355
- # no strong reference to `$c` either — see the controller-cycle note in
356
- # `new`. `$parent` is always live whenever a closure runs (the closure is
357
- # stored on `$parent`, so `$parent` outlives every invocation).
358
- weaken(my $parent = $self);
359
365
 
360
366
  for my $entry_name (keys %$manifest) {
361
367
  # `__barefoot__` is the runtime entry, not a component.
@@ -365,70 +371,119 @@ sub register_components_from_manifest ($self, $manifest, %opts) {
365
371
  # render target rather than a child.
366
372
  next unless $entry_name =~ m{^ui/([^/]+)/index$};
367
373
  my $slot_key = $1;
368
- my $marked = $manifest->{$entry_name}{markedTemplate} // '';
369
- next unless $marked;
370
- # `templates/ui/button/index.html.ep` `ui/button/index`
371
- my $template_name = $marked;
372
- $template_name =~ s{^templates/}{};
373
- $template_name =~ s{\.html\.ep$}{};
374
-
375
- my $signal_init = $signal_inits->{$slot_key};
376
- my $manifest_defaults = $manifest->{$entry_name}{ssrDefaults};
377
- $self->register_child_renderer($slot_key, sub {
378
- # `$caller` is the instance whose template invoked
379
- # `render_child` (#1897) — for a nested render that is a child
380
- # instance, and the grandchild's scope/slot identity must chain
381
- # off ITS scope id (`root_s0_s0`), not the registrant's.
382
- my ($props, $caller) = @_;
383
- my $host = $caller // $parent;
384
- my $host_scope = $host->_scope_id // $parent_scope;
385
- # Child shares the parent's backend so nested renders go
386
- # through the same engine + controller (and inherit any
387
- # injected json_encoder). The controller is fetched via the weak
388
- # `$parent` at call time — never captured strongly — so the
389
- # closure adds no edge to the per-request reference cycle.
390
- my $child_bf = BarefootJS->new($parent->c, { backend => $parent->backend });
391
- my $slot_id = delete $props->{_bf_slot};
392
- # JSX `key` (a reserved prop) → data-key on the child's scope root
393
- # for keyed-loop reconciliation (see `data_key_attr`).
394
- my $data_key = delete $props->{key};
395
- $child_bf->_data_key($data_key) if defined $data_key;
396
- $child_bf->_scope_id(
397
- $slot_id ? $host_scope . '_' . $slot_id
398
- : $template_name . '_' . substr(rand() =~ s/^0\.//r, 0, 6)
399
- );
400
- $child_bf->_is_child(1);
401
- # (#1249) Slot identity: host scope + slot id. Emitted as
402
- # bf-h / bf-m attributes by hydration_attrs.
403
- if ($slot_id) {
404
- $child_bf->_bf_parent($host_scope);
405
- $child_bf->_bf_mount($slot_id);
406
- }
407
- # Share the root registry so the child's own template can
408
- # render further imported components (#1897).
409
- $child_bf->_child_renderers($parent->_child_renderers);
410
- $child_bf->_scripts($parent->_scripts);
411
- $child_bf->_script_seen($parent->_script_seen);
412
-
413
- my %extra;
414
- if ($signal_init) {
415
- %extra = $signal_init->($props);
416
- } elsif ($manifest_defaults) {
417
- %extra = _derive_stash_from_defaults($manifest_defaults, $props);
418
- }
374
+ my $entry = $manifest->{$entry_name};
375
+
376
+ # Directory-name registration — the pre-`components` convention,
377
+ # kept so manifests from older builds (no `components` map) still
378
+ # resolve single-component modules like `button`.
379
+ $self->_register_manifest_child(
380
+ $slot_key, $entry->{markedTemplate},
381
+ $signal_inits->{$slot_key}, $entry->{ssrDefaults},
382
+ );
419
383
 
420
- # Render the child template with $child_bf bound as the active
421
- # instance for the nested render. The backend owns the
422
- # engine-specific binding + restore (stash juggle for Mojo).
423
- my $html = $parent->backend->render_named(
424
- $template_name, $child_bf, { %$props, %extra },
384
+ # Per-component registrations (#2132), keyed by the snake_cased
385
+ # component name the compiled templates actually call.
386
+ my $components = $entry->{components};
387
+ next unless ref($components) eq 'HASH';
388
+ for my $component_name (sort keys %$components) {
389
+ my $component = $components->{$component_name};
390
+ next unless ref($component) eq 'HASH';
391
+ my $component_key = _snake_case($component_name);
392
+ $self->_register_manifest_child(
393
+ $component_key, $component->{markedTemplate},
394
+ $signal_inits->{$component_key}, $component->{ssrDefaults},
425
395
  );
426
- chomp $html;
427
- return $html;
428
- });
396
+ }
429
397
  }
430
398
  }
431
399
 
400
+ # PascalCase → snake_case, mirroring the Mojo adapter's `toTemplateName`
401
+ # (prefix every uppercase letter with `_`, lowercase the whole string,
402
+ # strip the leading `_`): `ToastProvider` → `toast_provider`.
403
+ sub _snake_case ($name) {
404
+ my $s = $name;
405
+ $s =~ s/([A-Z])/_$1/g;
406
+ $s = CORE::lc $s;
407
+ $s =~ s/^_//;
408
+ return $s;
409
+ }
410
+
411
+ # Register one manifest-driven child renderer: `$slot_key` becomes the
412
+ # `render_child` name, `$marked` locates the template, and `$signal_init`
413
+ # / `$manifest_defaults` seed the child's template vars (see
414
+ # `register_components_from_manifest` for the contract).
415
+ sub _register_manifest_child ($self, $slot_key, $marked, $signal_init, $manifest_defaults) {
416
+ $marked //= '';
417
+ return unless $marked;
418
+ my $parent_scope = $self->_scope_id;
419
+ # Weaken the parent capture so the child-renderer closures stored on
420
+ # `$self->_child_renderers` don't keep `$self` alive (the direct
421
+ # closure <-> parent cycle). The controller is reached through `$parent`
422
+ # at call time rather than captured strongly here, so the closures hold
423
+ # no strong reference to `$c` either — see the controller-cycle note in
424
+ # `new`. `$parent` is always live whenever a closure runs (the closure is
425
+ # stored on `$parent`, so `$parent` outlives every invocation).
426
+ weaken(my $parent = $self);
427
+
428
+ # `templates/ui/button/index.html.ep` → `ui/button/index`
429
+ my $template_name = $marked;
430
+ $template_name =~ s{^templates/}{};
431
+ $template_name =~ s{\.html\.ep$}{};
432
+
433
+ $self->register_child_renderer($slot_key, sub {
434
+ # `$caller` is the instance whose template invoked
435
+ # `render_child` (#1897) — for a nested render that is a child
436
+ # instance, and the grandchild's scope/slot identity must chain
437
+ # off ITS scope id (`root_s0_s0`), not the registrant's.
438
+ my ($props, $caller) = @_;
439
+ my $host = $caller // $parent;
440
+ my $host_scope = $host->_scope_id // $parent_scope;
441
+ # Child shares the parent's backend so nested renders go
442
+ # through the same engine + controller (and inherit any
443
+ # injected json_encoder). The controller is fetched via the weak
444
+ # `$parent` at call time — never captured strongly — so the
445
+ # closure adds no edge to the per-request reference cycle.
446
+ my $child_bf = BarefootJS->new($parent->c, { backend => $parent->backend });
447
+ my $slot_id = delete $props->{_bf_slot};
448
+ # JSX `key` (a reserved prop) → data-key on the child's scope root
449
+ # for keyed-loop reconciliation (see `data_key_attr`).
450
+ my $data_key = delete $props->{key};
451
+ $child_bf->_data_key($data_key) if defined $data_key;
452
+ $child_bf->_scope_id(
453
+ $slot_id ? $host_scope . '_' . $slot_id
454
+ : $template_name . '_' . substr(rand() =~ s/^0\.//r, 0, 6)
455
+ );
456
+ $child_bf->_is_child(1);
457
+ # (#1249) Slot identity: host scope + slot id. Emitted as
458
+ # bf-h / bf-m attributes by hydration_attrs.
459
+ if ($slot_id) {
460
+ $child_bf->_bf_parent($host_scope);
461
+ $child_bf->_bf_mount($slot_id);
462
+ }
463
+ # Share the root registry so the child's own template can
464
+ # render further imported components (#1897).
465
+ $child_bf->_child_renderers($parent->_child_renderers);
466
+ $child_bf->_scripts($parent->_scripts);
467
+ $child_bf->_script_seen($parent->_script_seen);
468
+
469
+ my %extra;
470
+ if ($signal_init) {
471
+ %extra = $signal_init->($props);
472
+ } elsif ($manifest_defaults) {
473
+ %extra = _derive_stash_from_defaults($manifest_defaults, $props);
474
+ }
475
+
476
+ # Render the child template with $child_bf bound as the active
477
+ # instance for the nested render. The backend owns the
478
+ # engine-specific binding + restore (stash juggle for Mojo).
479
+ my $html = $parent->backend->render_named(
480
+ $template_name, $child_bf, { %$props, %extra },
481
+ );
482
+ chomp $html;
483
+ return $html;
484
+ });
485
+ }
486
+
432
487
  # Derive template-stash kvs from a manifest entry's `ssrDefaults`
433
488
  # section. Each entry shape:
434
489
  # { value => <static-fallback>, propName => <prop>, isRestProps => bool }
@@ -581,8 +636,14 @@ sub round ($self, $value) {
581
636
  # receiver shapes apart without TS type inference, so both lower to
582
637
  # the same IR node (`array-method` / method `includes`). This helper
583
638
  # dispatches at the Perl level via `ref()`:
584
- # - ARRAY ref: scan elements with `eq`; one defined-vs-undef
585
- # hop matches JS's `===` for null/undefined.
639
+ # - ARRAY ref: scan elements with `BarefootJS::Evaluator::_same_value_zero`,
640
+ # matching `Array.prototype.includes`'s SameValueZero
641
+ # semantics (no cross-type coercion, e.g. `[2].includes("2")`
642
+ # is false; NaN matches NaN) — the same algorithm the
643
+ # evaluator's serialized-callback path already uses for
644
+ # `.includes`, so both positions agree. This used to be a
645
+ # stringy `eq` scan, which coerced numbers to strings
646
+ # (`[2].includes("2")` was true) and diverged from JS.
586
647
  # - scalar: `index($recv, $sub) != -1`, with both args
587
648
  # coerced through `// ''` so an undef receiver /
588
649
  # needle doesn't trip Perl's substr warning.
@@ -593,11 +654,7 @@ sub round ($self, $value) {
593
654
  sub includes ($self, $recv, $elem) {
594
655
  if (ref($recv) eq 'ARRAY') {
595
656
  for my $item (@$recv) {
596
- if (!defined $item) {
597
- return 1 if !defined $elem;
598
- next;
599
- }
600
- return 1 if defined $elem && $item eq $elem;
657
+ return 1 if BarefootJS::Evaluator::_same_value_zero($item, $elem);
601
658
  }
602
659
  return 0;
603
660
  }
@@ -805,6 +862,59 @@ sub flat ($self, $recv, $depth = 1) {
805
862
  return \@out;
806
863
  }
807
864
 
865
+ # `Array.prototype.flat(depth)` with a DYNAMIC depth (#2094) — the depth is
866
+ # itself an arbitrary runtime value (e.g. a prop), not a compile-time literal,
867
+ # so it must be coerced with JS's `ToIntegerOrInfinity` before delegating to
868
+ # `flat` above: truncate toward zero; negative -> 0; NaN / non-numeric -> 0;
869
+ # +Infinity or a huge finite value -> flatten fully.
870
+ #
871
+ # Deliberately a SEPARATE entry point from `flat`, not a smarter version of
872
+ # it: the literal-depth path's `-1` argument is a compile-time SENTINEL baked
873
+ # into the template source, meaning "the source literally said `Infinity`". A
874
+ # genuinely dynamic depth that happens to evaluate to `-1` at render time
875
+ # means the OPPOSITE in real JS (`[1,[2]].flat(-1)` never recurses — same as
876
+ # `.flat(0)`). Since both paths would otherwise hand the same literal-looking
877
+ # argument to one shared function, that function couldn't tell which case
878
+ # it's in — so the two stay separate. Mirrors Go's `FlatDynamicDepth` /
879
+ # `coerceFlatDepth` in bf.go.
880
+ sub flat_dynamic ($self, $recv, $depth) {
881
+ return $self->flat($recv, _coerce_flat_depth($depth));
882
+ }
883
+
884
+ # _coerce_flat_depth: JS `ToIntegerOrInfinity` for a dynamic `.flat(depth)`
885
+ # argument, collapsed to `flat`'s int contract (`-1` == flatten fully).
886
+ #
887
+ # Perl's scalar type system blurs string/number duality, so `looks_like_number`
888
+ # (already the codebase's ToNumber-style coercion check, see BarefootJS::
889
+ # Evaluator's `_to_number`) is reused here rather than inventing a new
890
+ # convention. `looks_like_number` on this Perl (5.38) already recognises the
891
+ # strings "Infinity" / "-Infinity" / "NaN" as numeric (verified empirically:
892
+ # `perl -MScalar::Util=looks_like_number -e 'print looks_like_number("Infinity")'`
893
+ # prints 1), and `$str + 0` on those strings yields the corresponding Perl
894
+ # non-finite double, so no extra string special-casing is needed here.
895
+ sub _coerce_flat_depth ($depth) {
896
+ return 0 unless defined $depth;
897
+ my $f;
898
+ if (ref($depth) eq 'JSON::PP::Boolean') {
899
+ $f = $depth ? 1 : 0;
900
+ }
901
+ elsif (!ref($depth) && looks_like_number($depth)) {
902
+ $f = $depth + 0;
903
+ }
904
+ else {
905
+ # undef handled above; anything else non-numeric (a plain string
906
+ # that isn't a number, a HASH/ARRAY ref, ...) coerces via NaN.
907
+ return 0;
908
+ }
909
+ return 0 if $f != $f; # NaN
910
+ return -1 if $f == 9**9**9; # +Infinity -> flatten fully sentinel
911
+ return 0 if $f == -(9**9**9); # -Infinity -> 0
912
+ my $trunc = int($f); # Perl's int() truncates toward zero
913
+ return 0 if $trunc < 0;
914
+ return -1 if $trunc > 1_000_000; # huge finite ~= flatten fully
915
+ return $trunc;
916
+ }
917
+
808
918
  # `Array.prototype.flatMap(fn)` value-returning field projection
809
919
  # (#1448 Tier C) — map each element through a self / field projection,
810
920
  # then flatten one level. `field` reads a HASH-ref key (the raw JS prop
@@ -1180,6 +1290,13 @@ sub flat_map_eval ($self, $recv, $proj_json, $param, $base_env = {}) {
1180
1290
  return BarefootJS::Evaluator::flat_map_json($recv, $proj_json, $param, $base_env);
1181
1291
  }
1182
1292
 
1293
+ # Value-producing `.map(cb)` (#2073): project each element through the
1294
+ # serialized projection body, one result per element (no flatten). Composes
1295
+ # through the array-method chain (`.map(cb).join(' ')`).
1296
+ sub map_eval ($self, $recv, $proj_json, $param, $base_env = {}) {
1297
+ return BarefootJS::Evaluator::map_json($recv, $proj_json, $param, $base_env);
1298
+ }
1299
+
1183
1300
  sub sort ($self, $recv, $opts = {}) {
1184
1301
  return [] unless ref($recv) eq 'ARRAY';
1185
1302
 
@@ -1412,6 +1529,25 @@ sub _style_to_css ($value) {
1412
1529
  return @parts ? CORE::join(';', @parts) : undef;
1413
1530
  }
1414
1531
 
1532
+
1533
+ # Object-rest residual for a `.map()` destructure binding
1534
+ # (`{ id, ...rest } => …`, #2087 Phase B): returns a NEW hashref holding
1535
+ # every key of `$bag` except those named in `$keys` (an ARRAY ref of key
1536
+ # strings). This is plain JS destructure semantics (`const { id, ...rest }
1537
+ # = item`) — unlike `spread_attrs` below, there's no event-handler /
1538
+ # `children` filtering or key remapping here, because the residual is a
1539
+ # *value* the template may read fields off of (`$rest->{flag}`) or later
1540
+ # forward wholesale to `spread_attrs` (`{...rest}` on an element) — either
1541
+ # consumer applies its own rules downstream. A non-hashref `$bag` returns
1542
+ # an empty hashref rather than dying, so this stays safe as a `my` local
1543
+ # initializer even off unexpected/absent data (same defensive contract as
1544
+ # `spread_attrs`'s "no bag → nothing").
1545
+ sub omit ($self, $bag, $keys) {
1546
+ return {} unless defined $bag && ref($bag) eq 'HASH';
1547
+ my %exclude = map { $_ => 1 } @$keys;
1548
+ return { map { $_ => $bag->{$_} } grep { !$exclude{$_} } keys %$bag };
1549
+ }
1550
+
1415
1551
  sub spread_attrs ($self, $bag) {
1416
1552
  return '' unless defined $bag && ref($bag) eq 'HASH';
1417
1553
  my @parts;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/perl",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "BarefootJS engine-agnostic Perl runtime (BarefootJS.pm) — pluggable rendering backend; the shared basis for Mojolicious and other Perl framework integrations",
5
5
  "type": "module",
6
6
  "files": [
package/t/eval_vectors.t CHANGED
@@ -16,7 +16,7 @@ use BarefootJS::Evaluator;
16
16
  # the CPAN dist — packages/adapter-tests only exists in a monorepo
17
17
  # checkout — so skip everywhere else.
18
18
  my $vectors_path = File::Spec->catfile(
19
- $FindBin::Bin, '..', '..', 'adapter-tests', 'helper-vectors', 'eval-vectors.json'
19
+ $FindBin::Bin, '..', '..', 'adapter-tests', 'vectors', 'eval-vectors.json'
20
20
  );
21
21
  plan skip_all => 'eval vectors not available outside the monorepo checkout'
22
22
  unless -e $vectors_path;
package/t/evaluator.t CHANGED
@@ -20,6 +20,11 @@ sub ncall_math {
20
20
  return { kind => 'call', callee => nmem(nid('Math'), $fn), args => [$arg] };
21
21
  }
22
22
 
23
+ sub nincludes {
24
+ my ($object, $needle) = @_;
25
+ return { kind => 'array-method', method => 'includes', object => $object, args => [$needle] };
26
+ }
27
+
23
28
  # A plain false for the `computed` flag; the evaluator only checks truthiness.
24
29
  sub JSON_false { return 0 }
25
30
 
@@ -134,6 +139,40 @@ subtest 'boolean-valued ops return JS booleans, not 1/0' => sub {
134
139
  ok(!defined $len, '(123).length is null, not 3');
135
140
  };
136
141
 
142
+ # `.includes` (#2075) is the one `array-method` in the evaluator subset,
143
+ # dispatching on the receiver type like the SSR template lowering does at
144
+ # runtime (`bf_includes` / `$bf->includes`): array → SameValueZero membership
145
+ # (the same value rules as `===`, so a numeric 2 does NOT match the string
146
+ # "2"); string → substring search; anything else degrades to false rather
147
+ # than dying.
148
+ subtest 'array-method includes' => sub {
149
+ my $hit = BarefootJS::Evaluator::evaluate(nincludes(nid('tags'), nstr('go')), { tags => [ 'perl', 'go' ] });
150
+ ok(JSON::PP::is_bool($hit), 'array hit is a JS boolean');
151
+ ok($hit, 'array .includes: hit');
152
+
153
+ my $miss = BarefootJS::Evaluator::evaluate(nincludes(nid('tags'), nstr('rust')), { tags => [ 'perl', 'go' ] });
154
+ ok(JSON::PP::is_bool($miss), 'array miss is a JS boolean');
155
+ ok(!$miss, 'array .includes: miss');
156
+
157
+ # SameValueZero, not loose equality: the numeric element 2 matches the
158
+ # numeric needle 2, but the string needle "2" (a different JS type) does
159
+ # not — mirroring `===`'s type-sensitivity.
160
+ my $num_hit = BarefootJS::Evaluator::evaluate(nincludes(nid('nums'), { kind => 'literal', value => 2 }), { nums => [ 1, 2, 3 ] });
161
+ ok($num_hit, 'array .includes: numeric element hit');
162
+ my $num_vs_string = BarefootJS::Evaluator::evaluate(nincludes(nid('nums'), nstr('2')), { nums => [ 1, 2, 3 ] });
163
+ ok(!$num_vs_string, 'array .includes: numeric element does not match a string needle');
164
+
165
+ my $sub = BarefootJS::Evaluator::evaluate(nincludes(nid('name'), nstr('ar')), { name => 'bare' });
166
+ ok($sub, 'string .includes: substring hit');
167
+
168
+ # A non-array, non-string receiver (number, null, object) is not a JS
169
+ # `.includes` target; the evaluator degrades to false rather than dying.
170
+ my $scalar_recv = BarefootJS::Evaluator::evaluate(nincludes(nid('n'), { kind => 'literal', value => 1 }), { n => 42 });
171
+ ok(!$scalar_recv, 'non-collection receiver (number) is false, not a die');
172
+ my $null_recv = BarefootJS::Evaluator::evaluate(nincludes(nid('n'), nstr('x')), { n => undef });
173
+ ok(!$null_recv, 'non-collection receiver (null) is false, not a die');
174
+ };
175
+
137
176
  # sort_by tolerates a non-array receiver by returning an empty arrayref (the
138
177
  # BarefootJS->sort convention), never undef — so callers can always deref it.
139
178
  subtest 'sort_by non-array receiver returns []' => sub {
@@ -262,4 +301,121 @@ subtest 'flat_map projects + flattens one level' => sub {
262
301
  is_deeply($fj, [ 'a', 'b', 'c' ], 'flat_map_json decodes + projects');
263
302
  };
264
303
 
304
+ # #2073: map_items is the value-producing `.map(cb)` — one result per element,
305
+ # NO flatten (an array-valued projection stays one element). Mirrors the Go
306
+ # TestMapEval shapes.
307
+ subtest 'map_items projects one result per element (no flatten)' => sub {
308
+ my $tmpl = {
309
+ kind => 'template-literal',
310
+ parts => [
311
+ { type => 'string', value => '#' },
312
+ { type => 'expression', expr => nid('t') },
313
+ ],
314
+ };
315
+ is_deeply(BarefootJS::Evaluator::map_items([ 'perl', 'go' ], $tmpl, 't'),
316
+ [ '#perl', '#go' ], 'template-literal projection maps each element');
317
+
318
+ my @users = ({ name => 'Ada' }, { name => 'Grace' });
319
+ my $field = nmem(nid('u'), 'name');
320
+ is_deeply(BarefootJS::Evaluator::map_items(\@users, $field, 'u'),
321
+ [ 'Ada', 'Grace' ], 'field projection maps each element');
322
+
323
+ my @rows = ({ tags => [ 'a', 'b' ] });
324
+ is_deeply(BarefootJS::Evaluator::map_items(\@rows, nmem(nid('i'), 'tags'), 'i'),
325
+ [ [ 'a', 'b' ] ], 'array-valued projection stays ONE element (no flatten)');
326
+
327
+ # JSON seam.
328
+ my $mj = BarefootJS::Evaluator::map_json(\@users,
329
+ JSON::PP->new->encode($field), 'u');
330
+ is_deeply($mj, [ 'Ada', 'Grace' ], 'map_json decodes + projects');
331
+ };
332
+
333
+ # #2094: the evaluator widening lets a callback body (already evaluated here
334
+ # for reduce/sort/filter/find/…) itself contain a nested `.map(cb)` /
335
+ # `.filter(cb)` call — e.g. the #1938 blog-showcase
336
+ # `.flatMap(p => p.tags.map(t => '#'+t))` projection body. Mirrors the Go
337
+ # TestArrayCallback shapes.
338
+ subtest 'nested .map / .filter callback calls (#2094)' => sub {
339
+ # item.tags.map(t => '#' + t)
340
+ my $map_call = {
341
+ kind => 'call',
342
+ callee => nmem(nmem(nid('item'), 'tags'), 'map'),
343
+ args => [ {
344
+ kind => 'arrow',
345
+ params => ['t'],
346
+ body => nbin('+', nstr('#'), nid('t')),
347
+ } ],
348
+ };
349
+ is_deeply(
350
+ BarefootJS::Evaluator::evaluate($map_call, { item => { tags => [ 'go', 'perl' ] } }),
351
+ [ '#go', '#perl' ],
352
+ 'nested .map: string-prefix projection',
353
+ );
354
+
355
+ # item.tags.filter(t => t.active)
356
+ my $filter_call = {
357
+ kind => 'call',
358
+ callee => nmem(nmem(nid('item'), 'tags'), 'filter'),
359
+ args => [ {
360
+ kind => 'arrow',
361
+ params => ['t'],
362
+ body => nmem(nid('t'), 'active'),
363
+ } ],
364
+ };
365
+ my $filtered = BarefootJS::Evaluator::evaluate($filter_call, {
366
+ item => { tags => [ { active => JSON::PP::true, n => 1 }, { active => JSON::PP::false, n => 2 } ] },
367
+ });
368
+ is_deeply([ map { $_->{n} } @$filtered ], [1], 'nested .filter: keeps only active');
369
+
370
+ # 2-param arrow (value, index): item.tags.map((t, i) => t.n + i)
371
+ my $indexed = {
372
+ kind => 'call',
373
+ callee => nmem(nmem(nid('item'), 'tags'), 'map'),
374
+ args => [ {
375
+ kind => 'arrow',
376
+ params => ['t', 'i'],
377
+ body => nbin('+', nmem(nid('t'), 'n'), nid('i')),
378
+ } ],
379
+ };
380
+ is_deeply(
381
+ BarefootJS::Evaluator::evaluate($indexed, { item => { tags => [ { n => 10 }, { n => 20 }, { n => 30 } ] } }),
382
+ [ 10, 21, 32 ],
383
+ 'nested .map: 2-param arrow does not leak the index across sibling calls',
384
+ );
385
+
386
+ # .filter(...).length > 0 — the doc/#2038 motivating composed shape.
387
+ my $len_gt = {
388
+ kind => 'binary', op => '>',
389
+ left => nmem($filter_call, 'length'),
390
+ right => { kind => 'literal', value => 0, literalType => 'number' },
391
+ };
392
+ ok(BarefootJS::Evaluator::evaluate($len_gt, {
393
+ item => { tags => [ { active => JSON::PP::true }, { active => JSON::PP::false } ] },
394
+ }), '.filter(...).length > 0 composes: at least one active');
395
+ ok(!BarefootJS::Evaluator::evaluate($len_gt, {
396
+ item => { tags => [ { active => JSON::PP::false }, { active => JSON::PP::false } ] },
397
+ }), '.filter(...).length > 0 composes: none active is falsy');
398
+ };
399
+
400
+ # #2094: `.join(sep?)` — a sibling `array-method` alongside `.includes`.
401
+ subtest 'array-method join (#2094)' => sub {
402
+ my $tags = nmem(nid('item'), 'tags');
403
+ my $join_default = { kind => 'array-method', method => 'join', object => $tags, args => [] };
404
+ is(BarefootJS::Evaluator::evaluate($join_default, { item => { tags => [ 'a', 'b' ] } }),
405
+ 'a,b', 'no separator defaults to a comma');
406
+
407
+ my $join_custom = {
408
+ kind => 'array-method', method => 'join', object => $tags,
409
+ args => [ nstr('-') ],
410
+ };
411
+ is(BarefootJS::Evaluator::evaluate($join_custom, { item => { tags => [ 'a', 'b', 'c' ] } }),
412
+ 'a-b-c', 'custom separator');
413
+
414
+ is(BarefootJS::Evaluator::evaluate($join_custom, { item => { tags => [] } }),
415
+ '', 'empty array joins to the empty string');
416
+
417
+ is(BarefootJS::Evaluator::evaluate($join_default, { item => { tags => [ 'a', undef, 'b' ] } }),
418
+ 'a,,b', 'a null/undef element joins as empty, not the string "null"');
419
+ };
420
+
265
421
  done_testing;
@@ -35,7 +35,7 @@ my $bf = bless { c => undef, config => {}, backend => PureBackend->new }, 'Baref
35
35
  # the CPAN dist — packages/adapter-tests only exists in a monorepo
36
36
  # checkout — so skip everywhere else.
37
37
  my $vectors_path = File::Spec->catfile(
38
- $FindBin::Bin, '..', '..', 'adapter-tests', 'helper-vectors', 'vectors.json'
38
+ $FindBin::Bin, '..', '..', 'adapter-tests', 'vectors', 'vectors.json'
39
39
  );
40
40
  plan skip_all => 'golden vectors not available outside the monorepo checkout'
41
41
  unless -e $vectors_path;
@@ -49,6 +49,23 @@ my $doc = do {
49
49
  JSON::PP->new->utf8->decode(scalar <$fh>);
50
50
  };
51
51
 
52
+ # Per-backend status declarations (spec/template-helpers.md "Adapter
53
+ # status model") live in t/vector-divergences.json, package-local to this
54
+ # adapter — the spec stays backend-neutral. This harness enforces them: a
55
+ # pinned case that starts matching JS fails as stale, and a key that
56
+ # matches no vector case fails as dead (see the two checks below).
57
+ my $divergences_path = File::Spec->catfile($FindBin::Bin, 'vector-divergences.json');
58
+ die "divergences file not found: $divergences_path (it is package-local and must always be present)"
59
+ unless -e $divergences_path;
60
+
61
+ my $divergences_doc = do {
62
+ open my $fh, '<:raw', $divergences_path or die "open $divergences_path: $!";
63
+ local $/;
64
+ JSON::PP->new->utf8->decode(scalar <$fh>);
65
+ };
66
+ my %DIVERGENCES = %{ $divergences_doc->{divergences} };
67
+ my %UNSUPPORTED = %{ $divergences_doc->{unsupported} };
68
+
52
69
  # One binding per canonical helper id in the spec catalogue, bound to
53
70
  # the exact code shape compiled templates execute on the Perl backends.
54
71
  # Where the adapters lower an operation to a native Perl operator
@@ -97,6 +114,7 @@ my %bindings = (
97
114
  slice => sub { $bf->slice($_[0], $_[1], $_[2]) },
98
115
  reverse => sub { $bf->reverse($_[0]) },
99
116
  flat => sub { $bf->flat(@_) },
117
+ flat_dynamic => sub { $bf->flat_dynamic(@_) },
100
118
  join => sub { $bf->join(@_) },
101
119
  # Array literals are native arrayrefs on the Perl backends.
102
120
  arr => sub { [@_] },
@@ -176,81 +194,6 @@ sub _field_eq_pred {
176
194
  : sub { my $v = $get->($_[0]); defined $v && $v eq $value };
177
195
  }
178
196
 
179
- # Per-backend status declarations (spec/template-helpers.md "Adapter
180
- # status model"). This file is the single source of truth for the
181
- # Perl backends' divergences — the spec stays backend-neutral.
182
- #
183
- # %DIVERGENCES pins a deliberate divergence from the JS-normative
184
- # expect, keyed by `fn/note`. Forms:
185
- # { expect => <value>, reason => ... } assert the pinned value
186
- # { nan => 1, reason => ... } assert a real NaN result
187
- # { dies => 1, reason => ... } assert the call dies
188
- # A pinned case that starts matching JS fails as stale; a key that
189
- # matches no vector case fails as dead.
190
- my %DIVERGENCES = (
191
- 'add/beyond the safe-integer edge rounds as a double' => {
192
- expect => 9007199254740993,
193
- reason => 'Perl IV arithmetic is 64-bit exact, not double-rounded',
194
- },
195
- 'div/zero divisor yields Infinity' => {
196
- dies => 1,
197
- reason => 'Perl native / dies on a zero divisor',
198
- },
199
- 'mod/remainder keeps the dividend sign' => {
200
- expect => 2,
201
- reason => 'Perl native % takes the divisor sign',
202
- },
203
- 'mod/float remainder' => {
204
- expect => 1,
205
- reason => 'Perl native % truncates operands to integers',
206
- },
207
- 'number/empty string coerces to 0' => {
208
- nan => 1,
209
- reason => 'deliberate: empty input must not silently zero downstream arithmetic',
210
- },
211
- 'number/null coerces to 0' => {
212
- nan => 1,
213
- reason => 'deliberate: unset props must not silently zero downstream arithmetic',
214
- },
215
- 'string/null renders as the string "null"' => {
216
- expect => '',
217
- reason => 'deliberate: an unset prop must not surface a literal "null" in HTML',
218
- },
219
- 'string/true renders as the string "true"' => {
220
- expect => '1',
221
- reason => 'Perl has no boolean type; template data carries 1/0',
222
- },
223
- 'string/17-significant-digit double round-trips' => {
224
- expect => '0.3',
225
- reason => 'Perl stringifies doubles via %.15g',
226
- },
227
- 'includes/cross-type probe is strict-equality false' => {
228
- expect => 1,
229
- reason => 'bf->includes scans with eq (string equality), so 2 eq "2" matches',
230
- },
231
- 'filter_truthy/the string "0" is truthy' => {
232
- expect => ['x'],
233
- reason => 'Perl truthiness treats the string "0" as false',
234
- },
235
- 'sort/localeCompare orders case-insensitively (ICU collation)' => {
236
- expect => ['B', 'a'],
237
- reason => 'cmp is byte order, not ICU collation',
238
- },
239
- 'sort/relational compare on numeric strings is lexical' => {
240
- expect => ['9', '10'],
241
- reason => 'the "auto" compare goes numeric when both keys look_like_number',
242
- },
243
- 'reduce/numeric-string items concatenate under JS +' => {
244
- expect => 11,
245
- reason => 'numeric folds parse numeric strings instead of concatenating',
246
- },
247
- );
248
-
249
- # Helper ids not implemented on this backend yet — skipped visibly.
250
- # Empty for the Perl backends; the mechanism exists so a bootstrapping
251
- # backend can land its harness first and burn the list down.
252
- my %UNSUPPORTED = ();
253
-
254
197
  my %seen_declarations;
255
198
  for my $case (@{ $doc->{cases} }) {
256
199
  my ($fn, $note) = @{$case}{qw(fn note)};
@@ -271,7 +214,7 @@ for my $case (@{ $doc->{cases} }) {
271
214
  if (my $d = $DIVERGENCES{$key}) {
272
215
  $seen_declarations{$key} = 1;
273
216
  my $label = "$key (declared divergence: $d->{reason})";
274
- if ($d->{dies}) {
217
+ if ($d->{throws}) {
275
218
  ok($err, $label) or diag("expected the call to die, got: " . explain_value($got));
276
219
  next;
277
220
  }
@@ -284,10 +227,10 @@ for my $case (@{ $doc->{cases} }) {
284
227
  fail("stale divergence declaration for '$key' — the backend now matches JS; remove it");
285
228
  next;
286
229
  }
287
- my $want_ok = $d->{nan} ? (looks_like_number($got // '') && $got != $got)
288
- : _match_plain($got, $d->{expect});
289
- ok($want_ok, $label)
290
- or diag('got ' . explain_value($got) . ', pinned ' . explain_value($d->{nan} ? 'NaN' : $d->{expect}));
230
+ die "divergence '$key' has neither throws nor expect malformed perl.json entry"
231
+ unless exists $d->{expect};
232
+ ok(_match($got, $d->{expect}), $label)
233
+ or diag('got ' . explain_value($got) . ', pinned ' . explain_value($d->{expect}));
291
234
  next;
292
235
  }
293
236
 
@@ -356,21 +299,6 @@ sub _match {
356
299
  return ($got eq $expect) ? 1 : 0;
357
300
  }
358
301
 
359
- # _match_plain: like _match but against a plain Perl value from a
360
- # divergence declaration (no JSON boolean / sentinel forms).
361
- sub _match_plain {
362
- my ($got, $expect) = @_;
363
- return !defined $got if !defined $expect;
364
- if (ref $expect eq 'ARRAY') {
365
- return 0 unless ref $got eq 'ARRAY' && @$got == @$expect;
366
- _match_plain($got->[$_], $expect->[$_]) or return 0 for 0 .. $#$expect;
367
- return 1;
368
- }
369
- return 0 if !defined $got || ref $got;
370
- return ($got == $expect ? 1 : 0) if looks_like_number($expect) && looks_like_number($got);
371
- return ($got eq $expect) ? 1 : 0;
372
- }
373
-
374
302
  sub explain_value {
375
303
  my ($v) = @_;
376
304
  return 'undef' unless defined $v;
@@ -0,0 +1,170 @@
1
+ use strict;
2
+ use warnings;
3
+ use utf8;
4
+
5
+ use Test2::V0;
6
+
7
+ use FindBin qw($Bin);
8
+ use lib "$Bin/../lib";
9
+
10
+ use BarefootJS;
11
+
12
+ # Multi-component registry modules (#2132).
13
+ #
14
+ # A registry module exporting several components from one file
15
+ # (`ui/toast/index.tsx` → ToastProvider / Toast / ToastTitle) compiles to
16
+ # one template per component, and `bf build` lists them in the manifest
17
+ # entry's `components` map. Compiled parent templates call each one under
18
+ # its snake_cased component name (`render_child('toast_provider')`), so
19
+ # `register_components_from_manifest` must register one child renderer per
20
+ # component — the old dir-name-only registration left every sub-component
21
+ # unresolvable and the render died with "No renderer registered".
22
+
23
+ # Backend stub that records which template each nested render resolved to,
24
+ # along with the vars it received.
25
+ {
26
+ package RecordingBackend;
27
+ sub new { bless { calls => [] }, shift }
28
+ sub calls { $_[0]{calls} }
29
+ sub encode_json { '{}' }
30
+ sub mark_raw { $_[1] }
31
+ sub materialize { $_[1] }
32
+ sub render_named {
33
+ my ($self, $template_name, $child_bf, $vars) = @_;
34
+ push @{ $self->{calls} }, { template => $template_name, vars => $vars };
35
+ return "<rendered:$template_name>";
36
+ }
37
+ }
38
+
39
+ sub new_bf {
40
+ my $backend = RecordingBackend->new;
41
+ my $bf = BarefootJS->new(undef, { backend => $backend });
42
+ $bf->_scope_id('Root_test');
43
+ return ($bf, $backend);
44
+ }
45
+
46
+ # The manifest shape `bf build` emits for the toast module (see the emitter
47
+ # pin in packages/cli/src/__tests__/build-manifest-components.test.ts).
48
+ my $TOAST_ENTRY = {
49
+ markedTemplate => 'templates/ui/toast/ToastProvider.html.ep',
50
+ clientJs => 'client/ui/toast/index.client.js',
51
+ ssrDefaults => { children => { propName => 'children', value => undef } },
52
+ components => {
53
+ ToastProvider => {
54
+ markedTemplate => 'templates/ui/toast/ToastProvider.html.ep',
55
+ ssrDefaults => { children => { propName => 'children', value => undef } },
56
+ },
57
+ Toast => {
58
+ markedTemplate => 'templates/ui/toast/Toast.html.ep',
59
+ ssrDefaults => {
60
+ open => { propName => 'open', value => 0 },
61
+ children => { propName => 'children', value => undef },
62
+ },
63
+ },
64
+ ToastTitle => {
65
+ markedTemplate => 'templates/ui/toast/ToastTitle.html.ep',
66
+ ssrDefaults => { children => { propName => 'children', value => undef } },
67
+ },
68
+ },
69
+ };
70
+
71
+ subtest 'each exported component gets a renderer under its snake_case name' => sub {
72
+ my ($bf, $backend) = new_bf();
73
+ $bf->register_components_from_manifest({ 'ui/toast/index' => $TOAST_ENTRY });
74
+
75
+ for my $case (
76
+ [ toast_provider => 'ui/toast/ToastProvider' ],
77
+ [ toast => 'ui/toast/Toast' ],
78
+ [ toast_title => 'ui/toast/ToastTitle' ],
79
+ ) {
80
+ my ($slot_key, $template) = @$case;
81
+ my $html = $bf->render_child($slot_key);
82
+ is $html, "<rendered:$template>",
83
+ "render_child('$slot_key') renders $template";
84
+ }
85
+ };
86
+
87
+ subtest 'the dir-name key resolves to the COMPONENT template, not the module primary' => sub {
88
+ # `ui/toast/index`'s dir key is `toast`, which collides with
89
+ # snake_case('Toast'). The per-component registration must win: without
90
+ # it, `render_child('toast')` renders the module's FIRST template
91
+ # (ToastProvider) — the wrong markup entirely.
92
+ my ($bf, $backend) = new_bf();
93
+ $bf->register_components_from_manifest({ 'ui/toast/index' => $TOAST_ENTRY });
94
+
95
+ $bf->render_child('toast');
96
+ is $backend->calls->[-1]{template}, 'ui/toast/Toast',
97
+ 'toast maps to Toast.html.ep (not ToastProvider.html.ep)';
98
+ };
99
+
100
+ subtest 'per-component ssrDefaults seed the child vars' => sub {
101
+ my ($bf, $backend) = new_bf();
102
+ $bf->register_components_from_manifest({ 'ui/toast/index' => $TOAST_ENTRY });
103
+
104
+ # No caller prop → Toast's own static default.
105
+ $bf->render_child('toast');
106
+ is $backend->calls->[-1]{vars}{open}, 0,
107
+ 'omitted prop falls back to the component default';
108
+
109
+ # Caller-supplied prop wins.
110
+ $bf->render_child('toast', open => 1);
111
+ is $backend->calls->[-1]{vars}{open}, 1, 'caller prop overrides the default';
112
+ };
113
+
114
+ subtest 'signal_init override applies per component key' => sub {
115
+ my ($bf, $backend) = new_bf();
116
+ $bf->register_components_from_manifest(
117
+ { 'ui/toast/index' => $TOAST_ENTRY },
118
+ signal_init => { toast => sub { (open => 'forced') } },
119
+ );
120
+
121
+ $bf->render_child('toast');
122
+ is $backend->calls->[-1]{vars}{open}, 'forced',
123
+ 'signal_init keyed by the snake_case component name wins over ssrDefaults';
124
+
125
+ $bf->render_child('toast_title');
126
+ is $backend->calls->[-1]{template}, 'ui/toast/ToastTitle',
127
+ 'sibling components without an override still render';
128
+ };
129
+
130
+ subtest 'manifests without a components map keep the dir-name registration' => sub {
131
+ # Older builds emit no `components` map; the single-component
132
+ # convention (dir name == snake_cased component name) must keep working.
133
+ my ($bf, $backend) = new_bf();
134
+ $bf->register_components_from_manifest({
135
+ 'ui/button/index' => {
136
+ markedTemplate => 'templates/ui/button/index.html.ep',
137
+ ssrDefaults => { variant => { propName => 'variant', value => 'default' } },
138
+ },
139
+ });
140
+
141
+ my $html = $bf->render_child('button');
142
+ is $html, '<rendered:ui/button/index>', 'dir-name key still renders';
143
+ is $backend->calls->[-1]{vars}{variant}, 'default', 'entry ssrDefaults still seed';
144
+ };
145
+
146
+ subtest 'a components row without a markedTemplate is skipped, siblings survive' => sub {
147
+ my ($bf, $backend) = new_bf();
148
+ my $entry = {
149
+ %$TOAST_ENTRY,
150
+ components => {
151
+ %{ $TOAST_ENTRY->{components} },
152
+ Broken => {}, # no markedTemplate
153
+ },
154
+ };
155
+ $bf->register_components_from_manifest({ 'ui/toast/index' => $entry });
156
+
157
+ like dies { $bf->render_child('broken') }, qr/No renderer registered/,
158
+ 'template-less row registers nothing';
159
+ is $bf->render_child('toast_title'), '<rendered:ui/toast/ToastTitle>',
160
+ 'sibling components still registered';
161
+ };
162
+
163
+ subtest '_snake_case mirrors the Mojo adapter toTemplateName' => sub {
164
+ is BarefootJS::_snake_case('Toast'), 'toast', 'single word';
165
+ is BarefootJS::_snake_case('ToastProvider'), 'toast_provider', 'two words';
166
+ is BarefootJS::_snake_case('DropdownMenu'), 'dropdown_menu', 'kebab-dir module component';
167
+ is BarefootJS::_snake_case('InputOTP'), 'input_o_t_p', 'acronym splits per capital';
168
+ };
169
+
170
+ done_testing;
package/t/omit.t ADDED
@@ -0,0 +1,61 @@
1
+ use Test2::V0;
2
+
3
+ # omit — object-rest residual runtime helper for a `.map()` destructure
4
+ # binding (`{ id, ...rest } => …`, #2087 Phase B). Both the Mojo and Xslate
5
+ # template adapters lower an object-rest binding to `bf->omit($parent,
6
+ # [...excluded keys...])` / `$bf.omit($parent, [...])` so the residual is a
7
+ # TRUE hashref (not the whole item aliased) — `rest.flag` and `{...rest}`
8
+ # both read from the same real value. See packages/adapter-perl/lib/BarefootJS.pm.
9
+
10
+ use FindBin qw($Bin);
11
+ use lib "$Bin/../lib";
12
+
13
+ use BarefootJS;
14
+
15
+ # Minimal pure-Perl backend: `omit` never reaches the backend (no
16
+ # mark_raw / escaping — it returns a plain hashref, not markup).
17
+ {
18
+ package PureBackend;
19
+ sub new { bless {}, shift }
20
+ }
21
+
22
+ my $bf = bless { c => undef, config => {}, backend => PureBackend->new }, 'BarefootJS';
23
+
24
+ subtest 'basic shapes' => sub {
25
+ is $bf->omit(undef, []), {}, 'undef bag -> empty hashref';
26
+ is $bf->omit('not a hash', []), {}, 'non-hash scalar -> empty hashref';
27
+ is $bf->omit({}, []), {}, 'empty bag -> empty hashref';
28
+ is $bf->omit({ id => 'a', title => 'b' }, []),
29
+ { id => 'a', title => 'b' },
30
+ 'no excluded keys -> full copy';
31
+ };
32
+
33
+ subtest 'excludes named keys' => sub {
34
+ is $bf->omit({ id => 't1', title => 'one', flag => 'a' }, ['id', 'title']),
35
+ { flag => 'a' },
36
+ 'excludes the destructured sibling keys, keeps the rest';
37
+ is $bf->omit({ id => 't1' }, ['id', 'title']),
38
+ {},
39
+ 'excluding a key absent from the bag is a no-op for that key';
40
+ };
41
+
42
+ subtest 'non-identifier keys (rest-spread-onto-element shape)' => sub {
43
+ # Mirrors the `rest-destructure-object-spread-in-map` fixture:
44
+ # `{ id, title, ...rest }` with a hyphenated sibling key
45
+ # (`'data-priority'`) surviving into the residual untouched.
46
+ is $bf->omit(
47
+ { id => 't1', title => 'one', 'data-priority' => 'high', tag => 'urgent' },
48
+ ['id', 'title'],
49
+ ),
50
+ { 'data-priority' => 'high', tag => 'urgent' },
51
+ 'non-identifier keys pass through the residual unchanged';
52
+ };
53
+
54
+ subtest 'returns a new hashref (no aliasing)' => sub {
55
+ my $bag = { id => 'a', flag => 'x' };
56
+ my $rest = $bf->omit($bag, ['id']);
57
+ $rest->{flag} = 'mutated';
58
+ is $bag->{flag}, 'x', 'mutating the residual does not affect the source bag';
59
+ };
60
+
61
+ done_testing;
package/t/props_attr.t ADDED
@@ -0,0 +1,44 @@
1
+ use Test2::V0;
2
+
3
+ # props_attr — the `bf-p` hydration-payload attribute. The encoded JSON is
4
+ # embedded in a SINGLE-quoted attribute, so it must be attribute-escaped: a
5
+ # raw `'` inside a string value (e.g. a blog paragraph) terminates the
6
+ # attribute early and the client hydrates from truncated JSON (empty island
7
+ # text; found via the shared blog-ssr e2e). Same fix across the Perl,
8
+ # Python, Ruby, and Rust runtimes — keep the four tests in sync.
9
+
10
+ use FindBin qw($Bin);
11
+ use lib "$Bin/../lib";
12
+
13
+ use BarefootJS;
14
+ use JSON::PP ();
15
+
16
+ {
17
+ package PropsAttrStubBackend;
18
+ sub new { bless {}, shift }
19
+ sub encode_json { JSON::PP->new->canonical->encode($_[1]) }
20
+ }
21
+
22
+ sub bf_with {
23
+ my ($props) = @_;
24
+ my $bf = BarefootJS->new(undef, { backend => PropsAttrStubBackend->new });
25
+ $bf->_props($props) if $props;
26
+ return $bf;
27
+ }
28
+
29
+ is bf_with(undef)->props_attr, '', 'no props emits nothing';
30
+ is bf_with({})->props_attr, '', 'empty props emits nothing';
31
+
32
+ my $attr = bf_with({ note => q{it's <b> & co} })->props_attr;
33
+ is $attr, q{ bf-p='{&#34;note&#34;:&#34;it&#39;s &lt;b&gt; &amp; co&#34;}'},
34
+ 'JSON is attribute-escaped';
35
+
36
+ my ($value) = $attr =~ /bf-p='([^']*)'/;
37
+ my %ent = ('&#34;' => '"', '&#39;' => "'", '&lt;' => '<', '&gt;' => '>', '&amp;' => '&');
38
+ (my $decoded = $value) =~ s/(&#34;|&#39;|&lt;|&gt;|&amp;)/$ent{$1}/g;
39
+ # Assign first: `is JSON::PP->...` parses as indirect-object notation.
40
+ my $parsed = JSON::PP->new->decode($decoded);
41
+ is $parsed, { note => q{it's <b> & co} },
42
+ 'attribute round-trips through entity decoding';
43
+
44
+ done_testing;
package/t/query.t CHANGED
@@ -6,7 +6,7 @@ use utf8;
6
6
  #
7
7
  # The full CROSS-BACKEND behaviour (control flow + form-encoding parity with the
8
8
  # browser's URLSearchParams) is defined ONCE in the shared golden helper vectors
9
- # — packages/adapter-tests/helper-vectors/vectors.json, fn "query" — and run
9
+ # — packages/adapter-tests/vectors/vectors.json, fn "query" — and run
10
10
  # here by t/helper_vectors.t and by the Go runtime's TestHelperVectors, so the
11
11
  # Perl and Go expectations can't drift apart.
12
12
  #
package/t/search_params.t CHANGED
@@ -7,7 +7,7 @@ use utf8;
7
7
  # The cross-language VALUE semantics of `get` (absent → null/undef, present →
8
8
  # first value, repeated keys, present-but-empty, `+`/`%XX`/encoded-separator
9
9
  # decoding, first-`=` split) are owned by the language-independent golden
10
- # vectors — `search_params_get` in packages/adapter-tests/helper-vectors,
10
+ # vectors — `search_params_get` in packages/adapter-tests/vectors,
11
11
  # asserted here via t/helper_vectors.t and in the Go runtime's vectors_test.go,
12
12
  # so Go/Perl/JS parity is mechanical. This file covers only what those value
13
13
  # vectors can't: the lazy-load factory seam, lenient parsing (never dies), the
@@ -81,11 +81,16 @@ subtest 'floor / ceil / round — Math.* mirrors; propagate NaN' => sub {
81
81
  # `Array.prototype.includes(x)` + `String.prototype.includes(sub)` lower
82
82
  # to the same `$bf->includes($recv, $elem)` shape — see #1448 Tier A.
83
83
  # The Perl helper dispatches on `ref()`: ARRAY ref scans elements with
84
- # `eq`; scalar falls back to `index(..., ...) != -1`. Anything else
85
- # (HASH ref, code ref) returns false to match the JS semantic that
86
- # `.includes` is only defined on Array / TypedArray / String.
84
+ # `BarefootJS::Evaluator::_same_value_zero` (SameValueZero no cross-type
85
+ # coercion, NaN matches NaN), matching the evaluator's serialized-callback
86
+ # `.includes` path so both positions agree; scalar falls back to
87
+ # `index(..., ...) != -1`. Anything else (HASH ref, code ref) returns
88
+ # false to match the JS semantic that `.includes` is only defined on
89
+ # Array / TypedArray / String.
87
90
  subtest 'includes — array + string + non-array/string dispatch' => sub {
88
- # Array receiver: element-wise `eq` (handles defined/undef parity).
91
+ # Array receiver: SameValueZero element search (handles defined/undef
92
+ # parity, and no numeric/string coercion — see the cross-type cases
93
+ # below).
89
94
  ok $bf->includes(['a', 'b', 'c'], 'b'), 'array contains element → 1';
90
95
  ok !$bf->includes(['a', 'b', 'c'], 'z'), 'array does not contain → 0';
91
96
  ok $bf->includes([1, 2, 3], 2), 'numeric element';
@@ -93,6 +98,12 @@ subtest 'includes — array + string + non-array/string dispatch' => sub {
93
98
  ok $bf->includes([undef, 'a'], undef), 'undef element matches undef needle';
94
99
  ok !$bf->includes(['a', 'b'], undef), 'undef needle, no undef element → 0';
95
100
 
101
+ # SameValueZero never coerces across types — pins the divergence from
102
+ # the old stringy `eq` scan (where `[2].includes("2")` was true).
103
+ ok !$bf->includes([2], '2'), '[2].includes("2") → 0 (no numeric→string coercion)';
104
+ ok $bf->includes([2], 2), '[2].includes(2) → 1';
105
+ ok $bf->includes(['2'], '2'), '["2"].includes("2") → 1 (string vs string still matches)';
106
+
96
107
  # String receiver: substring search.
97
108
  ok $bf->includes('hello world', 'world'), 'substring present → 1';
98
109
  ok !$bf->includes('hello world', 'earth'), 'substring absent → 0';
@@ -0,0 +1,61 @@
1
+ {
2
+ "version": 1,
3
+ "backend": "perl",
4
+ "runner": "packages/adapter-perl/t/helper_vectors.t",
5
+ "spec": "spec/template-helpers.md",
6
+ "divergences": {
7
+ "add/beyond the safe-integer edge rounds as a double": {
8
+ "expect": 9007199254740993,
9
+ "reason": "Perl IV arithmetic is 64-bit exact, not double-rounded"
10
+ },
11
+ "div/zero divisor yields Infinity": {
12
+ "throws": true,
13
+ "reason": "Perl native / dies on a zero divisor"
14
+ },
15
+ "mod/remainder keeps the dividend sign": {
16
+ "expect": 2,
17
+ "reason": "Perl native % takes the divisor sign"
18
+ },
19
+ "mod/float remainder": {
20
+ "expect": 1,
21
+ "reason": "Perl native % truncates operands to integers"
22
+ },
23
+ "number/empty string coerces to 0": {
24
+ "expect": { "$num": "NaN" },
25
+ "reason": "deliberate: empty input must not silently zero downstream arithmetic"
26
+ },
27
+ "number/null coerces to 0": {
28
+ "expect": { "$num": "NaN" },
29
+ "reason": "deliberate: unset props must not silently zero downstream arithmetic"
30
+ },
31
+ "string/null renders as the string \"null\"": {
32
+ "expect": "",
33
+ "reason": "deliberate: an unset prop must not surface a literal \"null\" in HTML"
34
+ },
35
+ "string/true renders as the string \"true\"": {
36
+ "expect": "1",
37
+ "reason": "Perl has no boolean type; template data carries 1/0"
38
+ },
39
+ "string/17-significant-digit double round-trips": {
40
+ "expect": "0.3",
41
+ "reason": "Perl stringifies doubles via %.15g"
42
+ },
43
+ "filter_truthy/the string \"0\" is truthy": {
44
+ "expect": ["x"],
45
+ "reason": "Perl truthiness treats the string \"0\" as false"
46
+ },
47
+ "sort/localeCompare orders case-insensitively (ICU collation)": {
48
+ "expect": ["B", "a"],
49
+ "reason": "cmp is byte order, not ICU collation"
50
+ },
51
+ "sort/relational compare on numeric strings is lexical": {
52
+ "expect": ["9", "10"],
53
+ "reason": "the \"auto\" compare goes numeric when both keys look_like_number"
54
+ },
55
+ "reduce/numeric-string items concatenate under JS +": {
56
+ "expect": 11,
57
+ "reason": "numeric folds parse numeric strings instead of concatenating"
58
+ }
59
+ },
60
+ "unsupported": {}
61
+ }