@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.
- package/lib/BarefootJS/DevReload.pm +1 -1
- package/lib/BarefootJS/Evaluator.pm +161 -2
- package/lib/BarefootJS.pm +213 -77
- package/package.json +1 -1
- package/t/eval_vectors.t +1 -1
- package/t/evaluator.t +156 -0
- package/t/helper_vectors.t +24 -96
- package/t/manifest_components.t +170 -0
- package/t/omit.t +61 -0
- package/t/props_attr.t +44 -0
- package/t/query.t +1 -1
- package/t/search_params.t +1 -1
- package/t/template_primitives.t +15 -4
- package/t/vector-divergences.json +61 -0
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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 $
|
|
369
|
-
|
|
370
|
-
#
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
$
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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 `
|
|
585
|
-
#
|
|
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 (
|
|
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.
|
|
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', '
|
|
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;
|
package/t/helper_vectors.t
CHANGED
|
@@ -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', '
|
|
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->{
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
ok($
|
|
290
|
-
or diag('got ' . explain_value($got) . ', pinned ' . explain_value($d->{
|
|
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='{"note":"it's <b> & co"}'},
|
|
34
|
+
'JSON is attribute-escaped';
|
|
35
|
+
|
|
36
|
+
my ($value) = $attr =~ /bf-p='([^']*)'/;
|
|
37
|
+
my %ent = ('"' => '"', ''' => "'", '<' => '<', '>' => '>', '&' => '&');
|
|
38
|
+
(my $decoded = $value) =~ s/("|'|<|>|&)/$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/
|
|
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/
|
|
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
|
package/t/template_primitives.t
CHANGED
|
@@ -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
|
-
# `
|
|
85
|
-
#
|
|
86
|
-
# `.includes`
|
|
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
|
|
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
|
+
}
|