@barefootjs/perl 0.13.0 → 0.15.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.10.1";
2
+ our $VERSION = "0.14.0";
3
3
  use strict;
4
4
  use warnings;
5
5
  use feature 'signatures';
@@ -0,0 +1,71 @@
1
+ package BarefootJS::SearchParams;
2
+ our $VERSION = "0.14.0";
3
+ use strict;
4
+ use warnings;
5
+ use utf8;
6
+ use feature 'signatures';
7
+ no warnings 'experimental::signatures';
8
+
9
+ # Request-scoped SSR view of the query string behind the reactive
10
+ # `searchParams()` environment signal (router v0.5, #1922). The framework
11
+ # integration builds one per request from the request URL and threads it into
12
+ # the template scope as `$searchParams` (the camelCase JS name the adapters
13
+ # keep, like every other signal/prop var); the compiled template reads it via
14
+ # `$searchParams->get('key')` (Mojo) / `$searchParams.get('key')` (Xslate).
15
+ #
16
+ # This runtime is template-engine- and framework-agnostic (core Perl only),
17
+ # matching the rest of BarefootJS.pm, so it can ship in the standalone
18
+ # @barefootjs/perl distribution.
19
+ #
20
+ # Semantics mirror the browser's URLSearchParams.get exactly under the
21
+ # adapters' `?? → //` lowering: get() returns the first value for a key, or
22
+ # `undef` when the key is absent. Perl's `//` (defined-or) coalesces only
23
+ # `undef`, so an absent key falls back to the author's default while a
24
+ # present-but-empty value (`?sort=`) keeps the empty string — the same
25
+ # distinction JS `??` draws between `null` and `''`. (This is a closer match
26
+ # than the Go adapter, whose `or` lowering also coalesces the empty string.)
27
+
28
+ # new($class, $query = '')
29
+ #
30
+ # Parse a raw query string into the reader. A leading '?' is tolerated, '+'
31
+ # decodes to a space, and %XX escapes are decoded — mirroring URLSearchParams's
32
+ # application/x-www-form-urlencoded parsing. A malformed pair never dies; it
33
+ # simply contributes nothing, matching the browser's lenient parsing.
34
+ sub new ($class, $query = '') {
35
+ $query //= '';
36
+ $query =~ s/\A\?//;
37
+ my %values;
38
+ for my $pair (split /[&;]/, $query) {
39
+ next if $pair eq '';
40
+ my ($key, $val) = split /=/, $pair, 2;
41
+ $key = _decode($key);
42
+ $val = defined $val ? _decode($val) : '';
43
+ push @{ $values{$key} }, $val;
44
+ }
45
+ return bless { values => \%values }, $class;
46
+ }
47
+
48
+ # get($self, $key)
49
+ #
50
+ # First value for $key, or `undef` when the key is absent (see the package
51
+ # docstring for why `undef` — not '' — is the right "missing" sentinel under
52
+ # the `//` lowering). A present-but-empty value returns ''.
53
+ sub get ($self, $key) {
54
+ my $vals = $self->{values}{$key};
55
+ return undef unless $vals && @$vals;
56
+ return $vals->[0];
57
+ }
58
+
59
+ sub _decode ($s) {
60
+ $s //= '';
61
+ $s =~ tr/+/ /;
62
+ # %XX → raw octet, then interpret the octet stream as UTF-8 (what
63
+ # URLSearchParams does). `utf8::decode` is a core builtin — no CPAN URI /
64
+ # URI::Escape dependency, keeping this runtime core-Perl-only. A byte run
65
+ # that isn't valid UTF-8 is left as-is rather than dying (lenient parsing).
66
+ $s =~ s/%([0-9A-Fa-f]{2})/chr hex $1/ge;
67
+ utf8::decode($s);
68
+ return $s;
69
+ }
70
+
71
+ 1;
package/lib/BarefootJS.pm CHANGED
@@ -1,5 +1,5 @@
1
1
  package BarefootJS;
2
- our $VERSION = "0.10.1";
2
+ our $VERSION = "0.14.0";
3
3
  use strict;
4
4
  use warnings;
5
5
  use utf8;
@@ -103,6 +103,22 @@ sub new ($class, $c, $config = {}) {
103
103
  return $self;
104
104
  }
105
105
 
106
+ # search_params($query = '')
107
+ #
108
+ # Build a request-scoped reader for the reactive searchParams() environment
109
+ # signal (router v0.5, #1922) from a raw query string. Callable as a class or
110
+ # instance method — the invocant is unused.
111
+ #
112
+ # The `require` lives here so consumers (the Mojo plugin, the Xslate host, the
113
+ # test harness, generated render scripts) reach BarefootJS::SearchParams through
114
+ # the BarefootJS object they already hold, never `use`-ing it directly — the
115
+ # same lazy-load seam the Mojo backend uses above. The compiled template reads
116
+ # the returned object via `$searchParams->get('key')`.
117
+ sub search_params ($invocant, $query = '') {
118
+ require BarefootJS::SearchParams;
119
+ return BarefootJS::SearchParams->new($query);
120
+ }
121
+
106
122
  # ---------------------------------------------------------------------------
107
123
  # Scope & Props
108
124
  # ---------------------------------------------------------------------------
@@ -265,6 +281,11 @@ sub register_script ($self, $path) {
265
281
  # ---------------------------------------------------------------------------
266
282
  # (`_child_renderers` accessor is generated by the minimal accessor base above.)
267
283
 
284
+ # Register a renderer for `render_child($name, ...)`. The renderer is
285
+ # invoked as `$renderer->($props_hashref, $invoking_bf)` — unpack `@_`
286
+ # (`my ($props, $caller) = @_;`) instead of declaring a one-argument
287
+ # subroutine signature, which would enforce arity and die on the second
288
+ # argument.
268
289
  sub register_child_renderer ($self, $name, $renderer) {
269
290
  $self->_child_renderers->{$name} = $renderer;
270
291
  }
@@ -287,7 +308,15 @@ sub render_child ($self, $name, @args) {
287
308
  # touch children when present" behaviour.
288
309
  $props{children} = $self->backend->materialize($props{children})
289
310
  if exists $props{children};
290
- return $renderer->(\%props);
311
+ # Renderer contract (#1897): the renderer is invoked with TWO
312
+ # arguments — the props hashref and the INVOKING instance. A renderer
313
+ # registered on the root may be called from a nested child render
314
+ # (AccordionTrigger -> ChevronDownIcon), and the grandchild's scope /
315
+ # slot identity must chain off the CALLER's scope id, not the
316
+ # registrant's. Renderers unpack `@_` (`my ($props, $caller) = @_;`)
317
+ # rather than enforcing arity with a one-arg subroutine signature —
318
+ # see `register_child_renderer`.
319
+ return $renderer->(\%props, $self);
291
320
  }
292
321
 
293
322
  # ---------------------------------------------------------------------------
@@ -345,7 +374,13 @@ sub register_components_from_manifest ($self, $manifest, %opts) {
345
374
  my $signal_init = $signal_inits->{$slot_key};
346
375
  my $manifest_defaults = $manifest->{$entry_name}{ssrDefaults};
347
376
  $self->register_child_renderer($slot_key, sub {
348
- my ($props) = @_;
377
+ # `$caller` is the instance whose template invoked
378
+ # `render_child` (#1897) — for a nested render that is a child
379
+ # instance, and the grandchild's scope/slot identity must chain
380
+ # off ITS scope id (`root_s0_s0`), not the registrant's.
381
+ my ($props, $caller) = @_;
382
+ my $host = $caller // $parent;
383
+ my $host_scope = $host->_scope_id // $parent_scope;
349
384
  # Child shares the parent's backend so nested renders go
350
385
  # through the same engine + controller (and inherit any
351
386
  # injected json_encoder). The controller is fetched via the weak
@@ -358,16 +393,19 @@ sub register_components_from_manifest ($self, $manifest, %opts) {
358
393
  my $data_key = delete $props->{key};
359
394
  $child_bf->_data_key($data_key) if defined $data_key;
360
395
  $child_bf->_scope_id(
361
- $slot_id ? $parent_scope . '_' . $slot_id
396
+ $slot_id ? $host_scope . '_' . $slot_id
362
397
  : $template_name . '_' . substr(rand() =~ s/^0\.//r, 0, 6)
363
398
  );
364
399
  $child_bf->_is_child(1);
365
400
  # (#1249) Slot identity: host scope + slot id. Emitted as
366
401
  # bf-h / bf-m attributes by hydration_attrs.
367
402
  if ($slot_id) {
368
- $child_bf->_bf_parent($parent_scope);
403
+ $child_bf->_bf_parent($host_scope);
369
404
  $child_bf->_bf_mount($slot_id);
370
405
  }
406
+ # Share the root registry so the child's own template can
407
+ # render further imported components (#1897).
408
+ $child_bf->_child_renderers($parent->_child_renderers);
371
409
  $child_bf->_scripts($parent->_scripts);
372
410
  $child_bf->_script_seen($parent->_script_seen);
373
411
 
@@ -508,6 +546,10 @@ sub number ($self, $value) {
508
546
  # portable sentinel check in floor/ceil/round.
509
547
  sub _is_nan { my $n = shift; return $n != $n }
510
548
 
549
+ # True for +/-Infinity. `9**9**9` is Perl's portable infinity literal; a
550
+ # finite number is always strictly less than +Inf in magnitude.
551
+ sub _is_inf { my $n = shift; return $n == 9**9**9 || $n == -9**9**9 }
552
+
511
553
  sub floor ($self, $value) {
512
554
  my $n = $self->number($value);
513
555
  return $n if _is_nan($n);
@@ -826,6 +868,27 @@ sub trim ($self, $recv) {
826
868
  return $s;
827
869
  }
828
870
 
871
+ # `Number.prototype.toFixed(digits)` (#1897) — fixed-decimal string with
872
+ # zero-padding. JS rounds the scaled integer half toward +Infinity (the
873
+ # spec's "pick the larger n" tie-break), so `(2.5).toFixed(0)` is "3";
874
+ # bare `sprintf("%.*f")` would round half-to-even ("2"), diverging. Scale
875
+ # by 10**digits, round with `floor(x + 0.5)` (the same tie-break the
876
+ # `round` helper uses), then format the exact multiple. A negative
877
+ # `digits` clamps to 0, mirroring how the adapters default an omitted
878
+ # argument.
879
+ sub to_fixed ($self, $value, $digits = 0) {
880
+ my $n = $self->number($value);
881
+ # JS toFixed returns the STRINGS "NaN" / "Infinity" / "-Infinity" for
882
+ # non-finite inputs; the numeric values would stringify per-platform
883
+ # ("nan"/"inf"/...) and diverge.
884
+ return 'NaN' if _is_nan($n);
885
+ return $n < 0 ? '-Infinity' : 'Infinity' if _is_inf($n);
886
+ $digits = 0 if !defined $digits || $digits < 0;
887
+ my $factor = 10 ** $digits;
888
+ my $rounded = POSIX::floor($n * $factor + 0.5);
889
+ return sprintf('%.*f', $digits, $rounded / $factor);
890
+ }
891
+
829
892
  # `String.prototype.split(sep)` (#1448 Tier B) — string → ARRAY ref.
830
893
  #
831
894
  # Two JS-parity wrinkles drive the helper (a bare `split` emit would
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/perl",
3
- "version": "0.13.0",
3
+ "version": "0.15.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": [
@@ -0,0 +1,366 @@
1
+ use strict;
2
+ use warnings;
3
+ use Test::More;
4
+ use FindBin;
5
+ use File::Spec;
6
+ use JSON::PP ();
7
+ use Scalar::Util qw(looks_like_number);
8
+
9
+ use lib "$FindBin::Bin/../lib";
10
+ use BarefootJS;
11
+
12
+ # Pure-Perl backend (core JSON::PP only) so this test runs with zero
13
+ # Mojo present — same pattern as t/template_primitives.t.
14
+ {
15
+ package PureBackend;
16
+ use JSON::PP ();
17
+ my $J = JSON::PP->new->canonical->allow_nonref;
18
+ sub new { bless {}, shift }
19
+ sub encode_json { $J->encode($_[1]) }
20
+ sub mark_raw { $_[1] }
21
+ sub materialize { ref($_[1]) eq 'CODE' ? $_[1]->() : $_[1] }
22
+ sub render_named { '' }
23
+ }
24
+
25
+ my $bf = bless { c => undef, config => {}, backend => PureBackend->new }, 'BarefootJS';
26
+
27
+ # Golden helper vectors generated from the JS reference implementations
28
+ # (spec/template-helpers.md in the monorepo). The file is not shipped in
29
+ # the CPAN dist — packages/adapter-tests only exists in a monorepo
30
+ # checkout — so skip everywhere else.
31
+ my $vectors_path = File::Spec->catfile(
32
+ $FindBin::Bin, '..', '..', 'adapter-tests', 'helper-vectors', 'vectors.json'
33
+ );
34
+ plan skip_all => 'golden vectors not available outside the monorepo checkout'
35
+ unless -e $vectors_path;
36
+
37
+ my $doc = do {
38
+ open my $fh, '<:raw', $vectors_path or die "open $vectors_path: $!";
39
+ local $/;
40
+ JSON::PP->new->decode(<$fh>);
41
+ };
42
+
43
+ # One binding per canonical helper id in the spec catalogue, bound to
44
+ # the exact code shape compiled templates execute on the Perl backends.
45
+ # Where the adapters lower an operation to a native Perl operator
46
+ # (mojo-adapter.ts maps JSX `+` straight to Perl `+`), the binding IS
47
+ # that operator rather than a BarefootJS.pm method. Per the spec, a
48
+ # vector with no binding here fails the test — the Perl backend must
49
+ # not silently fall behind the catalogue.
50
+ my %bindings = (
51
+ add => sub { $_[0] + $_[1] },
52
+ sub => sub { $_[0] - $_[1] },
53
+ mul => sub { $_[0] * $_[1] },
54
+ div => sub { $_[0] / $_[1] },
55
+ mod => sub { $_[0] % $_[1] },
56
+ neg => sub { -$_[0] },
57
+
58
+ string => sub { $bf->string($_[0]) },
59
+ json => sub { $bf->json($_[0]) },
60
+ number => sub { $bf->number($_[0]) },
61
+ floor => sub { $bf->floor($_[0]) },
62
+ ceil => sub { $bf->ceil($_[0]) },
63
+ round => sub { $bf->round($_[0]) },
64
+ to_fixed => sub { $bf->to_fixed(@_) },
65
+
66
+ # The Mojo renderer emits native lc()/uc(); Xslate emits $bf.lc /
67
+ # $bf.uc. The helper methods wrap CORE::lc/uc, so binding them
68
+ # covers both shapes at value level.
69
+ lower => sub { $bf->lc($_[0]) },
70
+ upper => sub { $bf->uc($_[0]) },
71
+ trim => sub { $bf->trim($_[0]) },
72
+ starts_with => sub { $bf->starts_with(@_) },
73
+ ends_with => sub { $bf->ends_with(@_) },
74
+ replace => sub { $bf->replace(@_) },
75
+ repeat => sub { $bf->repeat(@_) },
76
+ pad_start => sub { $bf->pad_start(@_) },
77
+ pad_end => sub { $bf->pad_end(@_) },
78
+ split => sub { $bf->split(@_) },
79
+
80
+ len => sub { $bf->length($_[0]) },
81
+ at => sub { $bf->at(@_) },
82
+ includes => sub { $bf->includes(@_) },
83
+ index_of => sub { $bf->index_of(@_) },
84
+ last_index_of => sub { $bf->last_index_of(@_) },
85
+ concat => sub { $bf->concat(@_) },
86
+ # The Mojo emit always passes three value args (`undef` for an
87
+ # absent end) — mirror that exact shape.
88
+ slice => sub { $bf->slice($_[0], $_[1], $_[2]) },
89
+ reverse => sub { $bf->reverse($_[0]) },
90
+ flat => sub { $bf->flat(@_) },
91
+ join => sub { $bf->join(@_) },
92
+ # Array literals are native arrayrefs on the Perl backends.
93
+ arr => sub { [@_] },
94
+ # Mirrors the Mojo inline `[grep { $_ } @{...}]` for filter(Boolean).
95
+ filter_truthy => sub { [grep { $_ } @{ $_[0] }] },
96
+
97
+ # searchParams().get(key) (#1922) via the lazy factory consumers use.
98
+ # No divergence entry: get() returns undef for an absent key (~ JS null)
99
+ # and '' for present-but-empty, so the Perl backend matches JS exactly.
100
+ search_params_get => sub { BarefootJS->search_params($_[0])->get($_[1]) },
101
+
102
+ # Higher-order entries arrive in the canonical projection form
103
+ # (spec: items + field [+ value]); the closures below rebuild the
104
+ # predicate the adapters compile (`i => i.field === value`,
105
+ # `i => i.field`), choosing eq vs == by the probe's string-typing
106
+ # the same way the Mojo emitter does.
107
+ every => sub { $bf->every($_[0], _truthy_pred($_[1])) },
108
+ some => sub { $bf->some($_[0], _truthy_pred($_[1])) },
109
+ filter => sub { $bf->filter($_[0], _field_eq_pred($_[1], $_[2])) },
110
+ find => sub { $bf->find($_[0], _field_eq_pred($_[1], $_[2])) },
111
+ find_index => sub { $bf->find_index($_[0], _field_eq_pred($_[1], $_[2])) },
112
+ find_last => sub { $bf->find_last($_[0], _field_eq_pred($_[1], $_[2])) },
113
+ find_last_index => sub { $bf->find_last_index($_[0], _field_eq_pred($_[1], $_[2])) },
114
+
115
+ sort => sub {
116
+ my ($recv, @spec) = @_;
117
+ my @keys;
118
+ while (@spec >= 4) {
119
+ my ($kind, $name, $ct, $dir) = splice(@spec, 0, 4);
120
+ push @keys, {
121
+ key_kind => $kind,
122
+ key => $name,
123
+ compare_type => $ct,
124
+ direction => $dir,
125
+ };
126
+ }
127
+ return $bf->sort($recv, { keys => \@keys });
128
+ },
129
+ reduce => sub {
130
+ my ($recv, $op, $key_kind, $key, $type, $init, $direction) = @_;
131
+ return $bf->reduce($recv, {
132
+ op => $op,
133
+ key_kind => $key_kind,
134
+ key => $key,
135
+ type => $type,
136
+ init => $init,
137
+ direction => $direction,
138
+ });
139
+ },
140
+ flat_map => sub { $bf->flat_map(@_) },
141
+ flat_map_tuple => sub {
142
+ my ($recv, @flat) = @_;
143
+ my @specs;
144
+ while (@flat >= 2) {
145
+ my ($kind, $name) = splice(@flat, 0, 2);
146
+ push @specs, [$kind, $name];
147
+ }
148
+ return $bf->flat_map_tuple($recv, @specs);
149
+ },
150
+ );
151
+
152
+ sub _truthy_pred {
153
+ my ($field) = @_;
154
+ return sub { ref $_[0] eq 'HASH' ? $_[0]{$field} : undef };
155
+ }
156
+
157
+ sub _field_eq_pred {
158
+ my ($field, $value) = @_;
159
+ my $get = sub { ref $_[0] eq 'HASH' ? $_[0]{$field} : undef };
160
+ return looks_like_number($value)
161
+ ? sub { my $v = $get->($_[0]); defined $v && $v == $value }
162
+ : sub { my $v = $get->($_[0]); defined $v && $v eq $value };
163
+ }
164
+
165
+ # Per-backend status declarations (spec/template-helpers.md "Adapter
166
+ # status model"). This file is the single source of truth for the
167
+ # Perl backends' divergences — the spec stays backend-neutral.
168
+ #
169
+ # %DIVERGENCES pins a deliberate divergence from the JS-normative
170
+ # expect, keyed by `fn/note`. Forms:
171
+ # { expect => <value>, reason => ... } assert the pinned value
172
+ # { nan => 1, reason => ... } assert a real NaN result
173
+ # { dies => 1, reason => ... } assert the call dies
174
+ # A pinned case that starts matching JS fails as stale; a key that
175
+ # matches no vector case fails as dead.
176
+ my %DIVERGENCES = (
177
+ 'add/beyond the safe-integer edge rounds as a double' => {
178
+ expect => 9007199254740993,
179
+ reason => 'Perl IV arithmetic is 64-bit exact, not double-rounded',
180
+ },
181
+ 'div/zero divisor yields Infinity' => {
182
+ dies => 1,
183
+ reason => 'Perl native / dies on a zero divisor',
184
+ },
185
+ 'mod/remainder keeps the dividend sign' => {
186
+ expect => 2,
187
+ reason => 'Perl native % takes the divisor sign',
188
+ },
189
+ 'mod/float remainder' => {
190
+ expect => 1,
191
+ reason => 'Perl native % truncates operands to integers',
192
+ },
193
+ 'number/empty string coerces to 0' => {
194
+ nan => 1,
195
+ reason => 'deliberate: empty input must not silently zero downstream arithmetic',
196
+ },
197
+ 'number/null coerces to 0' => {
198
+ nan => 1,
199
+ reason => 'deliberate: unset props must not silently zero downstream arithmetic',
200
+ },
201
+ 'string/null renders as the string "null"' => {
202
+ expect => '',
203
+ reason => 'deliberate: an unset prop must not surface a literal "null" in HTML',
204
+ },
205
+ 'string/true renders as the string "true"' => {
206
+ expect => '1',
207
+ reason => 'Perl has no boolean type; template data carries 1/0',
208
+ },
209
+ 'string/17-significant-digit double round-trips' => {
210
+ expect => '0.3',
211
+ reason => 'Perl stringifies doubles via %.15g',
212
+ },
213
+ 'includes/cross-type probe is strict-equality false' => {
214
+ expect => 1,
215
+ reason => 'bf->includes scans with eq (string equality), so 2 eq "2" matches',
216
+ },
217
+ 'filter_truthy/the string "0" is truthy' => {
218
+ expect => ['x'],
219
+ reason => 'Perl truthiness treats the string "0" as false',
220
+ },
221
+ 'sort/localeCompare orders case-insensitively (ICU collation)' => {
222
+ expect => ['B', 'a'],
223
+ reason => 'cmp is byte order, not ICU collation',
224
+ },
225
+ 'sort/relational compare on numeric strings is lexical' => {
226
+ expect => ['9', '10'],
227
+ reason => 'the "auto" compare goes numeric when both keys look_like_number',
228
+ },
229
+ 'reduce/numeric-string items concatenate under JS +' => {
230
+ expect => 11,
231
+ reason => 'numeric folds parse numeric strings instead of concatenating',
232
+ },
233
+ );
234
+
235
+ # Helper ids not implemented on this backend yet — skipped visibly.
236
+ # Empty for the Perl backends; the mechanism exists so a bootstrapping
237
+ # backend can land its harness first and burn the list down.
238
+ my %UNSUPPORTED = ();
239
+
240
+ my %seen_declarations;
241
+ for my $case (@{ $doc->{cases} }) {
242
+ my ($fn, $note) = @{$case}{qw(fn note)};
243
+ my $key = "$fn/$note";
244
+ if (my $why = $UNSUPPORTED{$fn}) {
245
+ SKIP: { skip "unsupported on this backend: $why", 1 }
246
+ next;
247
+ }
248
+ my $bind = $bindings{$fn};
249
+ if (!$bind) {
250
+ fail("no Perl binding for helper '$fn' — add it to %bindings in $0");
251
+ next;
252
+ }
253
+ my @args = map { normalize_arg($_) } @{ $case->{args} };
254
+ my $got = eval { $bind->(@args) };
255
+ my $err = $@;
256
+
257
+ if (my $d = $DIVERGENCES{$key}) {
258
+ $seen_declarations{$key} = 1;
259
+ my $label = "$key (declared divergence: $d->{reason})";
260
+ if ($d->{dies}) {
261
+ ok($err, $label) or diag("expected the call to die, got: " . explain_value($got));
262
+ next;
263
+ }
264
+ if ($err) {
265
+ fail($label);
266
+ diag("died unexpectedly: $err");
267
+ next;
268
+ }
269
+ if (_match($got, $case->{expect})) {
270
+ fail("stale divergence declaration for '$key' — the backend now matches JS; remove it");
271
+ next;
272
+ }
273
+ my $want_ok = $d->{nan} ? (looks_like_number($got // '') && $got != $got)
274
+ : _match_plain($got, $d->{expect});
275
+ ok($want_ok, $label)
276
+ or diag('got ' . explain_value($got) . ', pinned ' . explain_value($d->{nan} ? 'NaN' : $d->{expect}));
277
+ next;
278
+ }
279
+
280
+ if ($err) {
281
+ fail("$key died: $err");
282
+ next;
283
+ }
284
+ ok(_match($got, $case->{expect}), "$key")
285
+ or diag('got ' . explain_value($got) . ', want ' . explain_value($case->{expect}));
286
+ }
287
+
288
+ for my $key (keys %DIVERGENCES) {
289
+ fail("divergence declaration '$key' matches no vector case — renamed note?")
290
+ unless $seen_declarations{$key};
291
+ }
292
+
293
+ done_testing;
294
+
295
+ # Spec value-compat contract: numbers compare numerically (JSON::PP
296
+ # decodes vector numbers to IV/NV, `==` compares the values), booleans
297
+ # by truthiness, everything else structurally. Arrays currently go
298
+ # through is_deeply (string compare per element) — refine to a
299
+ # recursive numeric walk when the first float-array vector lands.
300
+ # Production Perl template data has no boolean type — the adapters pass
301
+ # 1/0 where JS has true/false — so JSON::PP boolean objects in vector
302
+ # ARGS are lowered to 1/0 before reaching a binding. Expects keep their
303
+ # boolean identity (vector_ok compares those by truthiness).
304
+ sub normalize_arg {
305
+ my ($v) = @_;
306
+ return [ map { normalize_arg($_) } @$v ] if ref $v eq 'ARRAY';
307
+ return { map { $_ => normalize_arg($v->{$_}) } keys %$v } if ref $v eq 'HASH';
308
+ return JSON::PP::is_bool($v) ? ($v ? 1 : 0) : $v;
309
+ }
310
+
311
+ # _match: boolean form of the spec's value-compat comparison against a
312
+ # JSON-decoded expect — sentinel hashes, booleans by truthiness,
313
+ # numbers numerically, arrays/hashes recursively.
314
+ sub _match {
315
+ my ($got, $expect) = @_;
316
+ return !defined $got if !defined $expect;
317
+ if (ref $expect eq 'HASH' && exists $expect->{'$num'}) {
318
+ my $kind = $expect->{'$num'};
319
+ return 0 unless defined $got && looks_like_number($got);
320
+ return $got != $got ? 1 : 0 if $kind eq 'NaN';
321
+ my $inf = 9**9**9;
322
+ return $got == ($kind eq 'Infinity' ? $inf : -$inf) ? 1 : 0;
323
+ }
324
+ if (JSON::PP::is_bool($expect)) {
325
+ return (!!$got eq !!$expect) ? 1 : 0;
326
+ }
327
+ if (ref $expect eq 'ARRAY') {
328
+ return 0 unless ref $got eq 'ARRAY' && @$got == @$expect;
329
+ _match($got->[$_], $expect->[$_]) or return 0 for 0 .. $#$expect;
330
+ return 1;
331
+ }
332
+ if (ref $expect eq 'HASH') {
333
+ return 0 unless ref $got eq 'HASH' && keys %$got == keys %$expect;
334
+ for my $k (keys %$expect) {
335
+ return 0 unless exists $got->{$k};
336
+ _match($got->{$k}, $expect->{$k}) or return 0;
337
+ }
338
+ return 1;
339
+ }
340
+ return 0 if !defined $got || ref $got;
341
+ return ($got == $expect ? 1 : 0) if looks_like_number($expect) && looks_like_number($got);
342
+ return ($got eq $expect) ? 1 : 0;
343
+ }
344
+
345
+ # _match_plain: like _match but against a plain Perl value from a
346
+ # divergence declaration (no JSON boolean / sentinel forms).
347
+ sub _match_plain {
348
+ my ($got, $expect) = @_;
349
+ return !defined $got if !defined $expect;
350
+ if (ref $expect eq 'ARRAY') {
351
+ return 0 unless ref $got eq 'ARRAY' && @$got == @$expect;
352
+ _match_plain($got->[$_], $expect->[$_]) or return 0 for 0 .. $#$expect;
353
+ return 1;
354
+ }
355
+ return 0 if !defined $got || ref $got;
356
+ return ($got == $expect ? 1 : 0) if looks_like_number($expect) && looks_like_number($got);
357
+ return ($got eq $expect) ? 1 : 0;
358
+ }
359
+
360
+ sub explain_value {
361
+ my ($v) = @_;
362
+ return 'undef' unless defined $v;
363
+ return JSON::PP->new->canonical->allow_nonref->allow_blessed->convert_blessed->encode($v)
364
+ if ref $v;
365
+ return "'$v'";
366
+ }
@@ -0,0 +1,59 @@
1
+ use strict;
2
+ use warnings;
3
+ use utf8;
4
+
5
+ use Test2::V0;
6
+
7
+ use lib 'lib';
8
+ use BarefootJS;
9
+
10
+ # A do-nothing backend: render_child only touches the backend to
11
+ # materialize a `children` prop, which these cases never pass — but
12
+ # `new` eagerly builds the default Mojo backend, which isn't a dependency
13
+ # of this dist's test environment.
14
+ {
15
+ package StubBackend;
16
+ sub new { bless {}, shift }
17
+ sub materialize { $_[1] }
18
+ }
19
+ sub new_bf { BarefootJS->new(undef, { backend => StubBackend->new }) }
20
+
21
+ # render_child renderer-invocation contract (#1897): the renderer is
22
+ # invoked with ($props_hashref, $invoking_bf) so nested renders can chain
23
+ # scope/slot identity off the caller. Renderers unpack @_ rather than
24
+ # enforcing arity with a one-arg subroutine signature (see
25
+ # register_child_renderer's doc).
26
+
27
+ subtest 'renderer receives the invoking instance' => sub {
28
+ my $bf = new_bf();
29
+ $bf->_scope_id('Root_test');
30
+
31
+ my ($seen_props, $seen_caller);
32
+ $bf->register_child_renderer('probe', sub {
33
+ my ($props, $caller) = @_;
34
+ ($seen_props, $seen_caller) = ($props, $caller);
35
+ return 'ok';
36
+ });
37
+
38
+ is $bf->render_child('probe', value => 1), 'ok', 'renderer output returned';
39
+ is $seen_props->{value}, 1, 'props forwarded';
40
+ ref_is $seen_caller, $bf, 'second argument is the invoking instance';
41
+
42
+ # A nested invocation from a different instance passes THAT instance.
43
+ my $child = new_bf();
44
+ $child->_scope_id('Root_test_s0');
45
+ $child->_child_renderers($bf->_child_renderers);
46
+ $child->render_child('probe');
47
+ ref_is $seen_caller, $child, 'nested call passes the nested instance';
48
+ };
49
+
50
+ subtest 'renderer exceptions propagate' => sub {
51
+ my $bf = new_bf();
52
+ $bf->register_child_renderer('boom', sub {
53
+ die "renderer exploded\n";
54
+ });
55
+ like dies { $bf->render_child('boom') }, qr/renderer exploded/,
56
+ 'renderer errors propagate to the caller';
57
+ };
58
+
59
+ done_testing;
@@ -0,0 +1,58 @@
1
+ use Test2::V0;
2
+ use utf8;
3
+
4
+ # BarefootJS::SearchParams — Perl-specific concerns of the searchParams()
5
+ # environment-signal reader (router v0.5, #1922).
6
+ #
7
+ # The cross-language VALUE semantics of `get` (absent → null/undef, present →
8
+ # first value, repeated keys, present-but-empty, `+`/`%XX`/encoded-separator
9
+ # decoding, first-`=` split) are owned by the language-independent golden
10
+ # vectors — `search_params_get` in packages/adapter-tests/helper-vectors,
11
+ # asserted here via t/helper_vectors.t and in the Go runtime's vectors_test.go,
12
+ # so Go/Perl/JS parity is mechanical. This file covers only what those value
13
+ # vectors can't: the lazy-load factory seam, lenient parsing (never dies), the
14
+ # `//` composition, and UTF-8 decoding.
15
+
16
+ use FindBin qw($Bin);
17
+ use lib "$Bin/../lib";
18
+
19
+ use BarefootJS;
20
+
21
+ # The lazy factory on the BarefootJS object is how every consumer (Mojo plugin,
22
+ # Xslate host, render harness) reaches the reader — no one `use`s the class
23
+ # directly. Assert it loads + builds a working reader.
24
+ subtest 'BarefootJS->search_params lazy factory' => sub {
25
+ my $sp = BarefootJS->search_params('sort=price');
26
+ is ref($sp), 'BarefootJS::SearchParams', 'factory returns a reader instance';
27
+ is $sp->get('sort'), 'price', 'factory-built reader resolves the query';
28
+ is ref(BarefootJS->search_params), 'BarefootJS::SearchParams', 'default empty query';
29
+ };
30
+
31
+ # The adapters lower `searchParams().get(k) ?? d` to Perl's defined-or
32
+ # (`$searchParams->get(k) // d`), which coalesces only undef — so an absent key
33
+ # falls back to the default while a present-but-empty value keeps ''. This is
34
+ # the Perl-specific divergence from the Go `or` lowering (which coalesces '').
35
+ subtest '// composition (the ?? lowering) coalesces only undef' => sub {
36
+ my $absent = BarefootJS->search_params('other=x');
37
+ is(($absent->get('sort') // 'none'), 'none', 'absent key → author default');
38
+
39
+ my $empty = BarefootJS->search_params('sort=');
40
+ is(($empty->get('sort') // 'none'), '', 'present-but-empty value is kept, NOT defaulted');
41
+ };
42
+
43
+ # Percent-encoded UTF-8 decodes to characters via the core `utf8::decode`
44
+ # builtin (no URI / URI::Escape dependency) — kept here rather than in the
45
+ # ASCII-only shared vectors to avoid cross-harness byte/char encoding skew.
46
+ subtest 'UTF-8 percent-decoding (core utf8::decode, no URI dep)' => sub {
47
+ my $sp = BarefootJS->search_params('q=%E2%9C%93');
48
+ is $sp->get('q'), "\x{2713}", 'percent-encoded UTF-8 → decoded character (✓)';
49
+ };
50
+
51
+ # Malformed input must degrade, never die (SSR survives junk query strings).
52
+ subtest 'lenient parsing never dies' => sub {
53
+ ok lives { BarefootJS->search_params(undef) }, 'undef query';
54
+ ok lives { BarefootJS->search_params('&&&')->get('x') }, 'only separators';
55
+ ok lives { BarefootJS->search_params('=novalue')->get('x') }, 'empty key pair';
56
+ };
57
+
58
+ done_testing;