@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.
- package/lib/BarefootJS/DevReload.pm +1 -1
- package/lib/BarefootJS/Evaluator.pm +102 -0
- package/lib/BarefootJS.pm +197 -70
- package/package.json +1 -1
- package/t/eval_vectors.t +1 -1
- package/t/evaluator.t +88 -0
- package/t/helper_vectors.t +24 -92
- 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/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} // [] };
|
|
@@ -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.
|
|
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
|
-
|
|
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 }
|
|
@@ -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.
|
|
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', '
|
|
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;
|
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,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->{
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
ok($
|
|
286
|
-
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}));
|
|
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='{"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
|
|
@@ -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
|
+
}
|