@barefootjs/perl 0.17.1 → 0.18.1

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.17.0";
2
+ our $VERSION = "0.18.0";
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} // [] };
@@ -132,12 +142,104 @@ sub evaluate ($node, $env) {
132
142
  # false rather than throwing, mirroring the reference.
133
143
  return _bool(0);
134
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
+ }
135
159
 
136
160
  # arrow-fn / higher-order / unsupported array-method: a callback body
137
161
  # containing these is refused upstream (BF101); never reached here.
138
162
  return undef;
139
163
  }
140
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
+
141
243
  # eval_json($json, $env): decode a ParsedExpr JSON string and evaluate it.
142
244
  # Mirrors the Go EvalExpr entry point. Requires JSON::PP only when used.
143
245
  sub eval_json ($json, $env) {
package/lib/BarefootJS.pm CHANGED
@@ -1,5 +1,5 @@
1
1
  package BarefootJS;
2
- our $VERSION = "0.17.0";
2
+ our $VERSION = "0.18.0";
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 }
@@ -807,6 +862,59 @@ sub flat ($self, $recv, $depth = 1) {
807
862
  return \@out;
808
863
  }
809
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
+
810
918
  # `Array.prototype.flatMap(fn)` value-returning field projection
811
919
  # (#1448 Tier C) — map each element through a self / field projection,
812
920
  # then flatten one level. `field` reads a HASH-ref key (the raw JS prop
@@ -1421,6 +1529,25 @@ sub _style_to_css ($value) {
1421
1529
  return @parts ? CORE::join(';', @parts) : undef;
1422
1530
  }
1423
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
+
1424
1551
  sub spread_attrs ($self, $bag) {
1425
1552
  return '' unless defined $bag && ref($bag) eq 'HASH';
1426
1553
  my @parts;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/perl",
3
- "version": "0.17.1",
3
+ "version": "0.18.1",
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
@@ -330,4 +330,92 @@ subtest 'map_items projects one result per element (no flatten)' => sub {
330
330
  is_deeply($mj, [ 'Ada', 'Grace' ], 'map_json decodes + projects');
331
331
  };
332
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
+
333
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,77 +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
- 'filter_truthy/the string "0" is truthy' => {
228
- expect => ['x'],
229
- reason => 'Perl truthiness treats the string "0" as false',
230
- },
231
- 'sort/localeCompare orders case-insensitively (ICU collation)' => {
232
- expect => ['B', 'a'],
233
- reason => 'cmp is byte order, not ICU collation',
234
- },
235
- 'sort/relational compare on numeric strings is lexical' => {
236
- expect => ['9', '10'],
237
- reason => 'the "auto" compare goes numeric when both keys look_like_number',
238
- },
239
- 'reduce/numeric-string items concatenate under JS +' => {
240
- expect => 11,
241
- reason => 'numeric folds parse numeric strings instead of concatenating',
242
- },
243
- );
244
-
245
- # Helper ids not implemented on this backend yet — skipped visibly.
246
- # Empty for the Perl backends; the mechanism exists so a bootstrapping
247
- # backend can land its harness first and burn the list down.
248
- my %UNSUPPORTED = ();
249
-
250
197
  my %seen_declarations;
251
198
  for my $case (@{ $doc->{cases} }) {
252
199
  my ($fn, $note) = @{$case}{qw(fn note)};
@@ -267,7 +214,7 @@ for my $case (@{ $doc->{cases} }) {
267
214
  if (my $d = $DIVERGENCES{$key}) {
268
215
  $seen_declarations{$key} = 1;
269
216
  my $label = "$key (declared divergence: $d->{reason})";
270
- if ($d->{dies}) {
217
+ if ($d->{throws}) {
271
218
  ok($err, $label) or diag("expected the call to die, got: " . explain_value($got));
272
219
  next;
273
220
  }
@@ -280,10 +227,10 @@ for my $case (@{ $doc->{cases} }) {
280
227
  fail("stale divergence declaration for '$key' — the backend now matches JS; remove it");
281
228
  next;
282
229
  }
283
- my $want_ok = $d->{nan} ? (looks_like_number($got // '') && $got != $got)
284
- : _match_plain($got, $d->{expect});
285
- ok($want_ok, $label)
286
- 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}));
287
234
  next;
288
235
  }
289
236
 
@@ -352,21 +299,6 @@ sub _match {
352
299
  return ($got eq $expect) ? 1 : 0;
353
300
  }
354
301
 
355
- # _match_plain: like _match but against a plain Perl value from a
356
- # divergence declaration (no JSON boolean / sentinel forms).
357
- sub _match_plain {
358
- my ($got, $expect) = @_;
359
- return !defined $got if !defined $expect;
360
- if (ref $expect eq 'ARRAY') {
361
- return 0 unless ref $got eq 'ARRAY' && @$got == @$expect;
362
- _match_plain($got->[$_], $expect->[$_]) or return 0 for 0 .. $#$expect;
363
- return 1;
364
- }
365
- return 0 if !defined $got || ref $got;
366
- return ($got == $expect ? 1 : 0) if looks_like_number($expect) && looks_like_number($got);
367
- return ($got eq $expect) ? 1 : 0;
368
- }
369
-
370
302
  sub explain_value {
371
303
  my ($v) = @_;
372
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
@@ -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
+ }