@barefootjs/mojolicious 0.1.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/dist/adapter/__tests__/boolean-result.test.d.ts +2 -0
- package/dist/adapter/__tests__/boolean-result.test.d.ts.map +1 -0
- package/dist/adapter/boolean-result.d.ts +42 -0
- package/dist/adapter/boolean-result.d.ts.map +1 -0
- package/dist/adapter/index.d.ts +6 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/index.js +1143 -0
- package/dist/adapter/mojo-adapter.d.ts +219 -0
- package/dist/adapter/mojo-adapter.d.ts.map +1 -0
- package/dist/build.d.ts +28 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +1163 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1143 -0
- package/dist/test-render.d.ts +38 -0
- package/dist/test-render.d.ts.map +1 -0
- package/lib/BarefootJS.pm +745 -0
- package/lib/Mojolicious/Plugin/BarefootJS/DevReload.pm +150 -0
- package/lib/Mojolicious/Plugin/BarefootJS.pm +104 -0
- package/package.json +65 -0
- package/src/__tests__/mojo-adapter.test.ts +940 -0
- package/src/__tests__/mojo-streaming.test.ts +136 -0
- package/src/__tests__/scaffold.test.ts +224 -0
- package/src/__tests__/template-base-name.test.ts +26 -0
- package/src/adapter/__tests__/boolean-result.test.ts +106 -0
- package/src/adapter/boolean-result.ts +126 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/mojo-adapter.ts +1931 -0
- package/src/build.ts +37 -0
- package/src/index.ts +8 -0
- package/src/test-render.ts +704 -0
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
package BarefootJS;
|
|
2
|
+
use Mojo::Base -base, -signatures;
|
|
3
|
+
|
|
4
|
+
use Mojo::ByteStream qw(b);
|
|
5
|
+
use Mojo::JSON qw(encode_json to_json);
|
|
6
|
+
use POSIX ();
|
|
7
|
+
use Scalar::Util qw(looks_like_number weaken);
|
|
8
|
+
|
|
9
|
+
has 'c'; # Mojolicious controller
|
|
10
|
+
has 'config'; # Plugin config
|
|
11
|
+
|
|
12
|
+
# Internal state
|
|
13
|
+
has '_scripts' => sub { [] };
|
|
14
|
+
has '_script_seen' => sub { {} };
|
|
15
|
+
has '_scope_id';
|
|
16
|
+
has '_is_child' => 0;
|
|
17
|
+
has '_bf_parent'; # Host scope id when this scope is a slot-attached child
|
|
18
|
+
has '_bf_mount'; # Slot id in host
|
|
19
|
+
has '_props';
|
|
20
|
+
|
|
21
|
+
sub new ($class, $c, $config = {}) {
|
|
22
|
+
return $class->SUPER::new(
|
|
23
|
+
c => $c,
|
|
24
|
+
config => $config,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Scope & Props
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
sub scope_attr ($self) {
|
|
33
|
+
# bf-s is the addressable scope id only (#1249).
|
|
34
|
+
return $self->_scope_id // '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Emits `bf-h="<host>" bf-m="<slot>" bf-r=""` conditionally.
|
|
38
|
+
# See spec/compiler.md "Slot identity".
|
|
39
|
+
sub hydration_attrs ($self) {
|
|
40
|
+
my @parts;
|
|
41
|
+
my $host = $self->_bf_parent;
|
|
42
|
+
my $mount = $self->_bf_mount;
|
|
43
|
+
if (defined $host && length $host) {
|
|
44
|
+
my $h = $host =~ s/"/"/gr;
|
|
45
|
+
push @parts, qq{bf-h="$h"};
|
|
46
|
+
}
|
|
47
|
+
if (defined $mount && length $mount) {
|
|
48
|
+
my $m = $mount =~ s/"/"/gr;
|
|
49
|
+
push @parts, qq{bf-m="$m"};
|
|
50
|
+
}
|
|
51
|
+
unless ($self->_is_child) {
|
|
52
|
+
push @parts, q{bf-r=""};
|
|
53
|
+
}
|
|
54
|
+
return join(' ', @parts);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
sub props_attr ($self) {
|
|
58
|
+
my $props = $self->_props;
|
|
59
|
+
return '' unless $props && %$props;
|
|
60
|
+
# to_json returns a character string (not bytes) for safe embedding in templates
|
|
61
|
+
my $json = to_json($props);
|
|
62
|
+
return qq{ bf-p='$json'};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Comment Markers
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
sub comment ($self, $text) {
|
|
70
|
+
return "<!--bf-$text-->";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# JS-equivalent value stringification
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
# Map a Perl boolean-shaped value to the JS `String(bool)` form.
|
|
78
|
+
# Used by the Mojo adapter when emitting reactive attribute bindings
|
|
79
|
+
# whose JS source `isBooleanResultExpr` classified as boolean —
|
|
80
|
+
# a comparison (`count() > 0`), a logical negation (`!ok()`), or a
|
|
81
|
+
# literal `true` / `false`. Perl's auto-stringification of those
|
|
82
|
+
# expressions yields `''` / `1`; Hono and Go emit `'false'` / `'true'`.
|
|
83
|
+
# Centralising the bool → string mapping here keeps the contract
|
|
84
|
+
# testable and the template-emit syntax tidy
|
|
85
|
+
# (`<%= bf->bool_str(...) %>` vs an inline ternary).
|
|
86
|
+
#
|
|
87
|
+
# Contract is boolean-only: callers must have classified the
|
|
88
|
+
# expression as boolean-result before routing through this helper.
|
|
89
|
+
# Non-boolean values reaching here will be Perl-truthy-coerced to
|
|
90
|
+
# 'true' / 'false', which is generally wrong — non-boolean attribute
|
|
91
|
+
# bindings stay on the plain `<%= expr %>` emit path and never reach
|
|
92
|
+
# this function.
|
|
93
|
+
sub bool_str ($self, $value) {
|
|
94
|
+
return $value ? 'true' : 'false';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
sub text_start ($self, $slot_id) {
|
|
98
|
+
return "<!--bf:$slot_id-->";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
sub text_end ($self) {
|
|
102
|
+
return "<!--/-->";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# See spec/compiler.md "Slot identity" for the comment-scope wire format.
|
|
106
|
+
sub scope_comment ($self) {
|
|
107
|
+
my $scope_id = $self->_scope_id // '';
|
|
108
|
+
my $host_segment = '';
|
|
109
|
+
my $host = $self->_bf_parent;
|
|
110
|
+
my $mount = $self->_bf_mount;
|
|
111
|
+
if (defined $host && length $host) {
|
|
112
|
+
$host_segment = "|h=$host|m=" . ($mount // '');
|
|
113
|
+
}
|
|
114
|
+
my $props_json = '';
|
|
115
|
+
if ($self->_props && %{$self->_props}) {
|
|
116
|
+
$props_json = '|' . to_json($self->_props);
|
|
117
|
+
}
|
|
118
|
+
return "<!--bf-scope:$scope_id$host_segment$props_json-->";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Script Registration
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
sub register_script ($self, $path) {
|
|
126
|
+
return if $self->_script_seen->{$path};
|
|
127
|
+
$self->_script_seen->{$path} = 1;
|
|
128
|
+
push @{$self->_scripts}, $path;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Child Component Rendering
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
has '_child_renderers' => sub { {} };
|
|
136
|
+
|
|
137
|
+
sub register_child_renderer ($self, $name, $renderer) {
|
|
138
|
+
$self->_child_renderers->{$name} = $renderer;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
sub render_child ($self, $name, %props) {
|
|
142
|
+
my $renderer = $self->_child_renderers->{$name};
|
|
143
|
+
die "No renderer registered for child component '$name'" unless $renderer;
|
|
144
|
+
# JSX children come in via Mojo `begin %>...<% end` capture, which
|
|
145
|
+
# produces a CODE ref returning a Mojo::ByteStream. Materialize it
|
|
146
|
+
# before handing the props to the child renderer so the child
|
|
147
|
+
# template sees `$children` as already-rendered HTML.
|
|
148
|
+
$props{children} = $props{children}->() if ref($props{children}) eq 'CODE';
|
|
149
|
+
return $renderer->(\%props);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Bulk registration from build manifest
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
#
|
|
156
|
+
# `bf build` emits dist/templates/manifest.json describing every
|
|
157
|
+
# component the page might invoke (Counter, ui/button/index, ...).
|
|
158
|
+
# This helper walks that manifest and registers one child renderer per
|
|
159
|
+
# UI registry entry — the path shape `ui/<name>/index` maps to the
|
|
160
|
+
# `<name>` slot key Counter.html.ep and friends use via
|
|
161
|
+
# `<%= bf->render_child('<name>', ...) %>`.
|
|
162
|
+
#
|
|
163
|
+
# Each manifest entry carries an `ssrDefaults` hash derived statically
|
|
164
|
+
# from the component's JSX (prop destructure defaults + signal /
|
|
165
|
+
# memo initial values, see packages/jsx/src/ssr-defaults.ts). The
|
|
166
|
+
# child renderer seeds every template variable from that hash,
|
|
167
|
+
# preferring the caller's matching prop where one exists. This
|
|
168
|
+
# replaces the per-component `signal_init` callback that every
|
|
169
|
+
# scaffold's `app.pl` used to hand-roll for items 1/3 of issue #1416.
|
|
170
|
+
#
|
|
171
|
+
# `signal_init` remains as an opt-in override for cases the static
|
|
172
|
+
# extractor can't see through (e.g. signal initial values that
|
|
173
|
+
# reference imported helpers). When supplied for a given slot key
|
|
174
|
+
# it takes precedence over the manifest's `ssrDefaults` for that
|
|
175
|
+
# child, allowing callers to mix manual overrides with auto-derived
|
|
176
|
+
# defaults for siblings.
|
|
177
|
+
sub register_components_from_manifest ($self, $manifest, %opts) {
|
|
178
|
+
my $c = $self->c;
|
|
179
|
+
my $signal_inits = $opts{signal_init} // {};
|
|
180
|
+
my $parent_scope = $self->_scope_id;
|
|
181
|
+
weaken(my $parent = $self);
|
|
182
|
+
|
|
183
|
+
for my $entry_name (keys %$manifest) {
|
|
184
|
+
# `__barefoot__` is the runtime entry, not a component.
|
|
185
|
+
next if $entry_name eq '__barefoot__';
|
|
186
|
+
# Only UI registry components (path shape `ui/<name>/index`)
|
|
187
|
+
# become child renderers; top-level page components are the
|
|
188
|
+
# render target rather than a child.
|
|
189
|
+
next unless $entry_name =~ m{^ui/([^/]+)/index$};
|
|
190
|
+
my $slot_key = $1;
|
|
191
|
+
my $marked = $manifest->{$entry_name}{markedTemplate} // '';
|
|
192
|
+
next unless $marked;
|
|
193
|
+
# `templates/ui/button/index.html.ep` → `ui/button/index`
|
|
194
|
+
my $template_name = $marked;
|
|
195
|
+
$template_name =~ s{^templates/}{};
|
|
196
|
+
$template_name =~ s{\.html\.ep$}{};
|
|
197
|
+
|
|
198
|
+
my $signal_init = $signal_inits->{$slot_key};
|
|
199
|
+
my $manifest_defaults = $manifest->{$entry_name}{ssrDefaults};
|
|
200
|
+
$self->register_child_renderer($slot_key, sub {
|
|
201
|
+
my ($props) = @_;
|
|
202
|
+
my $child_bf = BarefootJS->new($c, {});
|
|
203
|
+
my $slot_id = delete $props->{_bf_slot};
|
|
204
|
+
$child_bf->_scope_id(
|
|
205
|
+
$slot_id ? $parent_scope . '_' . $slot_id
|
|
206
|
+
: $template_name . '_' . substr(rand() =~ s/^0\.//r, 0, 6)
|
|
207
|
+
);
|
|
208
|
+
$child_bf->_is_child(1);
|
|
209
|
+
# (#1249) Slot identity: host scope + slot id. Emitted as
|
|
210
|
+
# bf-h / bf-m attributes by hydration_attrs.
|
|
211
|
+
if ($slot_id) {
|
|
212
|
+
$child_bf->_bf_parent($parent_scope);
|
|
213
|
+
$child_bf->_bf_mount($slot_id);
|
|
214
|
+
}
|
|
215
|
+
$child_bf->_scripts($parent->_scripts);
|
|
216
|
+
$child_bf->_script_seen($parent->_script_seen);
|
|
217
|
+
|
|
218
|
+
my %extra;
|
|
219
|
+
if ($signal_init) {
|
|
220
|
+
%extra = $signal_init->($props);
|
|
221
|
+
} elsif ($manifest_defaults) {
|
|
222
|
+
%extra = _derive_stash_from_defaults($manifest_defaults, $props);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
my $prev = $c->stash->{'bf.instance'};
|
|
226
|
+
$c->stash->{'bf.instance'} = $child_bf;
|
|
227
|
+
my $html = $c->render_to_string(
|
|
228
|
+
template => $template_name, %$props, %extra,
|
|
229
|
+
);
|
|
230
|
+
$c->stash->{'bf.instance'} = $prev;
|
|
231
|
+
chomp $html;
|
|
232
|
+
return $html;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
# Derive template-stash kvs from a manifest entry's `ssrDefaults`
|
|
238
|
+
# section. Each entry shape:
|
|
239
|
+
# { value => <static-fallback>, propName => <prop>, isRestProps => bool }
|
|
240
|
+
# For `isRestProps`, the rest bag passes through unchanged (or the
|
|
241
|
+
# static `{}` if the caller didn't supply one). For ordinary entries
|
|
242
|
+
# the caller's `$props->{propName}` wins when defined, otherwise the
|
|
243
|
+
# static `value` does. `propName`-less entries (signal / memo locals)
|
|
244
|
+
# always use the static value — the caller cannot override them.
|
|
245
|
+
sub _derive_stash_from_defaults ($defaults, $props) {
|
|
246
|
+
my %extra;
|
|
247
|
+
for my $name (keys %$defaults) {
|
|
248
|
+
my $d = $defaults->{$name};
|
|
249
|
+
if (ref($d) ne 'HASH') {
|
|
250
|
+
$extra{$name} = $d;
|
|
251
|
+
next;
|
|
252
|
+
}
|
|
253
|
+
if ($d->{isRestProps}) {
|
|
254
|
+
$extra{$name} = exists $props->{$name} ? $props->{$name} : $d->{value};
|
|
255
|
+
next;
|
|
256
|
+
}
|
|
257
|
+
my $prop_name = $d->{propName};
|
|
258
|
+
if (defined $prop_name && exists $props->{$prop_name} && defined $props->{$prop_name}) {
|
|
259
|
+
$extra{$name} = $props->{$prop_name};
|
|
260
|
+
} else {
|
|
261
|
+
$extra{$name} = $d->{value};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return %extra;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# Script Output
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
sub scripts ($self) {
|
|
272
|
+
my @tags;
|
|
273
|
+
for my $path (@{$self->_scripts}) {
|
|
274
|
+
push @tags, qq{<script type="module" src="$path"></script>};
|
|
275
|
+
}
|
|
276
|
+
return join("\n", @tags);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
# Streaming SSR (Out-of-Order)
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
sub streaming_bootstrap ($self) {
|
|
284
|
+
return q{<script>(function(){function s(id){var a=document.querySelector('[bf-async="'+id+'"]');var t=document.querySelector('template[bf-async-resolve="'+id+'"]');if(!a||!t)return;a.replaceChildren(t.content.cloneNode(true));a.removeAttribute('bf-async');t.remove();requestAnimationFrame(function(){if(window.__bf_hydrate)window.__bf_hydrate()})};window.__bf_swap=s})()</script>};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
sub async_boundary ($self, $id, $fallback_html) {
|
|
288
|
+
# The fallback comes in via Mojo `begin %>...<% end` capture (see
|
|
289
|
+
# MojoAdapter::renderAsync), which produces a CODE ref returning a
|
|
290
|
+
# Mojo::ByteStream. Materialize it so the rendered HTML embeds in
|
|
291
|
+
# the placeholder rather than the CODE ref's stringification.
|
|
292
|
+
$fallback_html = $fallback_html->() if ref($fallback_html) eq 'CODE';
|
|
293
|
+
return qq{<div bf-async="$id">$fallback_html</div>};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
sub async_resolve ($self, $id, $content_html) {
|
|
297
|
+
return qq{<template bf-async-resolve="$id">$content_html</template><script>__bf_swap("$id")</script>};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
# JS-compat callees (#1189) — invoked from generated Mojo templates as
|
|
302
|
+
# <%= bf->json($val) %>, <%= bf->floor($val) %>, etc. The MojoAdapter's
|
|
303
|
+
# `templatePrimitives` registry emits these helper calls in place of the
|
|
304
|
+
# corresponding JS callees (`JSON.stringify`, `Math.floor`, …) so the SSR
|
|
305
|
+
# template can render value-equivalent output without a JS engine.
|
|
306
|
+
#
|
|
307
|
+
# Failure policy mirrors the Go adapter (#1188): user-data marshalling
|
|
308
|
+
# (json) bubbles errors so Mojolicious aborts loudly on cycles /
|
|
309
|
+
# unsupported values rather than silently producing an empty payload.
|
|
310
|
+
# Numeric coercion follows JS semantics (NaN propagates as the special
|
|
311
|
+
# string 'NaN'; non-numeric input returns 'NaN' rather than 0). Strings
|
|
312
|
+
# always coerce to a string representation.
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
sub json ($self, $value) {
|
|
316
|
+
# Mojo::JSON::to_json returns a character string (not bytes), suitable
|
|
317
|
+
# for embedding in HTML output via Mojo::ByteStream / `<%==`.
|
|
318
|
+
#
|
|
319
|
+
# Documented divergence from JS: JS distinguishes `null` (renders as
|
|
320
|
+
# "null") from `undefined` (`JSON.stringify(undefined)` returns the
|
|
321
|
+
# JS value `undefined`, not a string). Perl has no such distinction
|
|
322
|
+
# — both map to `undef`. We choose the `null` rendering for SSR
|
|
323
|
+
# ergonomics: an unset prop becomes the string "null" rather than
|
|
324
|
+
# the literal text "undefined" or an empty attribute. Matches the
|
|
325
|
+
# `null` case of JS exactly; diverges from the `undefined` case.
|
|
326
|
+
return to_json($value);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
sub string ($self, $value) {
|
|
330
|
+
# JS `String(v)` mirror. `undef` renders as the empty string here so
|
|
331
|
+
# an unset prop doesn't surface as a literal "undefined" / "null"
|
|
332
|
+
# in user-facing HTML — same divergence the Go adapter documents
|
|
333
|
+
# for `bf_string`.
|
|
334
|
+
return defined $value ? "$value" : '';
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
sub number ($self, $value) {
|
|
338
|
+
# JS `Number(v)` mirror. Numeric coerces via Perl's implicit
|
|
339
|
+
# numeric context; non-numeric / undef yield real numeric NaN
|
|
340
|
+
# (`'nan' + 0`) so downstream arithmetic propagates correctly
|
|
341
|
+
# (`Math.floor(NaN) === NaN`). Returning the literal string
|
|
342
|
+
# "NaN" would conflate the user-passing-the-string-"NaN" case
|
|
343
|
+
# with the parse-failure case, and break NaN detection in
|
|
344
|
+
# downstream helpers.
|
|
345
|
+
return 0 + 'nan' unless defined $value;
|
|
346
|
+
return $value + 0 if looks_like_number($value);
|
|
347
|
+
return 0 + 'nan';
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
# NaN is the only float for which `$x != $x` holds. Used as the
|
|
351
|
+
# portable sentinel check in floor/ceil/round.
|
|
352
|
+
sub _is_nan { my $n = shift; return $n != $n }
|
|
353
|
+
|
|
354
|
+
sub floor ($self, $value) {
|
|
355
|
+
my $n = $self->number($value);
|
|
356
|
+
return $n if _is_nan($n);
|
|
357
|
+
return POSIX::floor($n);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
sub ceil ($self, $value) {
|
|
361
|
+
my $n = $self->number($value);
|
|
362
|
+
return $n if _is_nan($n);
|
|
363
|
+
return POSIX::ceil($n);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
sub round ($self, $value) {
|
|
367
|
+
my $n = $self->number($value);
|
|
368
|
+
return $n if _is_nan($n);
|
|
369
|
+
# POSIX has no `round`. JS `Math.round` rounds half toward
|
|
370
|
+
# +Infinity (so `Math.round(-1.5) === -1`, not -2). `floor(n
|
|
371
|
+
# + 0.5)` reproduces that for both signs.
|
|
372
|
+
return POSIX::floor($n + 0.5);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
# ---------------------------------------------------------------------------
|
|
376
|
+
# Array / String method helpers (#1448 Tier A)
|
|
377
|
+
# ---------------------------------------------------------------------------
|
|
378
|
+
#
|
|
379
|
+
# `Array.prototype.includes(x)` and `String.prototype.includes(sub)`
|
|
380
|
+
# share a method name in JS; the JSX parser can't tell the two
|
|
381
|
+
# receiver shapes apart without TS type inference, so both lower to
|
|
382
|
+
# the same IR node (`array-method` / method `includes`). This helper
|
|
383
|
+
# dispatches at the Perl level via `ref()`:
|
|
384
|
+
# - ARRAY ref: scan elements with `eq`; one defined-vs-undef
|
|
385
|
+
# hop matches JS's `===` for null/undefined.
|
|
386
|
+
# - scalar: `index($recv, $sub) != -1`, with both args
|
|
387
|
+
# coerced through `// ''` so an undef receiver /
|
|
388
|
+
# needle doesn't trip Perl's substr warning.
|
|
389
|
+
# Anything else (HASH ref, code ref) returns false — matches the
|
|
390
|
+
# JS semantic where `.includes` is only defined on Array /
|
|
391
|
+
# TypedArray / String.
|
|
392
|
+
|
|
393
|
+
sub includes ($self, $recv, $elem) {
|
|
394
|
+
if (ref($recv) eq 'ARRAY') {
|
|
395
|
+
for my $item (@$recv) {
|
|
396
|
+
if (!defined $item) {
|
|
397
|
+
return 1 if !defined $elem;
|
|
398
|
+
next;
|
|
399
|
+
}
|
|
400
|
+
return 1 if defined $elem && $item eq $elem;
|
|
401
|
+
}
|
|
402
|
+
return 0;
|
|
403
|
+
}
|
|
404
|
+
return 0 if ref($recv);
|
|
405
|
+
return index($recv // '', $elem // '') != -1 ? 1 : 0;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
# `Array.prototype.indexOf(x)` / `Array.prototype.lastIndexOf(x)`
|
|
409
|
+
# value-equality search (#1448 Tier A). Returns the 0-based position
|
|
410
|
+
# of the first / last matching element, or -1 if not found.
|
|
411
|
+
# Non-array receivers return -1 — matches the JS semantic that
|
|
412
|
+
# `.indexOf` / `.lastIndexOf` are only defined on Array / TypedArray.
|
|
413
|
+
# (The string-position `indexOf` form isn't in Tier A; if it lands
|
|
414
|
+
# later the helper can grow a ref()-dispatch branch like `includes`.)
|
|
415
|
+
|
|
416
|
+
sub _array_index_of ($recv, $elem, $reverse) {
|
|
417
|
+
return -1 unless ref($recv) eq 'ARRAY';
|
|
418
|
+
my @indices = $reverse ? (reverse 0 .. $#{$recv}) : (0 .. $#{$recv});
|
|
419
|
+
for my $i (@indices) {
|
|
420
|
+
my $item = $recv->[$i];
|
|
421
|
+
if (!defined $item) {
|
|
422
|
+
return $i if !defined $elem;
|
|
423
|
+
next;
|
|
424
|
+
}
|
|
425
|
+
return $i if defined $elem && $item eq $elem;
|
|
426
|
+
}
|
|
427
|
+
return -1;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
sub index_of ($self, $recv, $elem) {
|
|
431
|
+
return _array_index_of($recv, $elem, 0);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
sub last_index_of ($self, $recv, $elem) {
|
|
435
|
+
return _array_index_of($recv, $elem, 1);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
# `Array.prototype.at(i)` — supports negative indices (`.at(-1)` is
|
|
439
|
+
# the last element); out-of-bounds returns undef (which Mojo's
|
|
440
|
+
# auto-escape renders as the empty string, matching JS's `undefined`).
|
|
441
|
+
# Non-array receivers return undef. Matches the Go `bf_at` arithmetic
|
|
442
|
+
# (`length + i` for i < 0) so adapter output stays symmetric.
|
|
443
|
+
|
|
444
|
+
sub at ($self, $recv, $i) {
|
|
445
|
+
return undef unless ref($recv) eq 'ARRAY';
|
|
446
|
+
return undef if !defined $i;
|
|
447
|
+
my $len = scalar @$recv;
|
|
448
|
+
return undef if $len == 0;
|
|
449
|
+
my $idx = $i < 0 ? $len + $i : $i;
|
|
450
|
+
return undef if $idx < 0 || $idx >= $len;
|
|
451
|
+
return $recv->[$idx];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
# `Array.prototype.concat(other)` — merges two arrays in order
|
|
455
|
+
# into a new ARRAY ref. Non-array operands collapse to empty
|
|
456
|
+
# (matches the Go `bf_concat` semantic so cross-adapter output
|
|
457
|
+
# stays symmetric; differs from JS where a non-Array argument
|
|
458
|
+
# with `Symbol.isConcatSpreadable` would be spread, a behaviour
|
|
459
|
+
# the template-language path never observes).
|
|
460
|
+
|
|
461
|
+
sub concat ($self, $a, $b) {
|
|
462
|
+
my @out;
|
|
463
|
+
push @out, @$a if ref($a) eq 'ARRAY';
|
|
464
|
+
push @out, @$b if ref($b) eq 'ARRAY';
|
|
465
|
+
return \@out;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
# `Array.prototype.slice(start, end?)` — carves out a sub-range
|
|
469
|
+
# into a new ARRAY ref. Mirrors the Go `bf_slice` arithmetic so
|
|
470
|
+
# adapter output stays symmetric:
|
|
471
|
+
# - start < 0 → length + start (e.g. -1 = last index)
|
|
472
|
+
# - end < 0 → length + end
|
|
473
|
+
# - start < 0 after clamp → 0
|
|
474
|
+
# - end > length → length
|
|
475
|
+
# - start >= end → empty
|
|
476
|
+
# - end undef → "to length"
|
|
477
|
+
# Non-array receivers return an empty ARRAY ref.
|
|
478
|
+
|
|
479
|
+
sub slice ($self, $recv, $start, $end) {
|
|
480
|
+
return [] unless ref($recv) eq 'ARRAY';
|
|
481
|
+
my $len = scalar @$recv;
|
|
482
|
+
return [] if $len == 0;
|
|
483
|
+
|
|
484
|
+
my $s = $start // 0;
|
|
485
|
+
$s = $len + $s if $s < 0;
|
|
486
|
+
$s = 0 if $s < 0;
|
|
487
|
+
$s = $len if $s > $len;
|
|
488
|
+
|
|
489
|
+
my $e = defined $end ? $end : $len;
|
|
490
|
+
$e = $len + $e if $e < 0;
|
|
491
|
+
$e = 0 if $e < 0;
|
|
492
|
+
$e = $len if $e > $len;
|
|
493
|
+
|
|
494
|
+
return [] if $s >= $e;
|
|
495
|
+
return [ @{$recv}[$s .. $e - 1] ];
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
# `Array.prototype.reverse()` / `Array.prototype.toReversed()` —
|
|
499
|
+
# both shapes share this lowering. SSR templates render a snapshot
|
|
500
|
+
# of state, so JS's mutate-receiver (`reverse`) vs
|
|
501
|
+
# return-new-array (`toReversed`) distinction has no template-
|
|
502
|
+
# level meaning. Always returns a new ARRAY ref to keep callers
|
|
503
|
+
# safe from accidental aliasing. Non-array receivers return an
|
|
504
|
+
# empty ARRAY ref.
|
|
505
|
+
|
|
506
|
+
sub reverse ($self, $recv) {
|
|
507
|
+
return [] unless ref($recv) eq 'ARRAY';
|
|
508
|
+
return [ reverse @$recv ];
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
# `String.prototype.trim()` — strip leading + trailing whitespace.
|
|
512
|
+
# JS's `String.prototype.trim` matches `\s` in the Unicode sense
|
|
513
|
+
# (any whitespace including non-breaking space U+00A0); Perl's `\s`
|
|
514
|
+
# inside a regex with `/u` flag is the same. Undef receivers return
|
|
515
|
+
# the empty string (matches JS's `String(undefined).trim()` which
|
|
516
|
+
# would be "undefined" → "undefined", but in our template context
|
|
517
|
+
# undef commonly means "missing prop"; rendering the empty string
|
|
518
|
+
# is the safer choice and mirrors the JS-compat divergence we
|
|
519
|
+
# already document for `bf->string(undef) === ""`).
|
|
520
|
+
|
|
521
|
+
sub trim ($self, $recv) {
|
|
522
|
+
return '' unless defined $recv;
|
|
523
|
+
return '' if ref($recv);
|
|
524
|
+
my $s = "$recv";
|
|
525
|
+
$s =~ s/^\s+|\s+$//gu;
|
|
526
|
+
return $s;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
# `Array.prototype.sort(cmp)` / `Array.prototype.toSorted(cmp)`
|
|
530
|
+
# lowering (#1448 Tier B). Non-mutating — JS's mutate-vs-new
|
|
531
|
+
# distinction is moot in SSR template context.
|
|
532
|
+
#
|
|
533
|
+
# Opts hash-ref (compiler emits exactly these four keys):
|
|
534
|
+
#
|
|
535
|
+
# key_kind => 'self' | 'field'
|
|
536
|
+
# key => '' when key_kind eq 'self'; field name verbatim
|
|
537
|
+
# from the comparator AST (e.g. 'price', 'createdAt')
|
|
538
|
+
# when key_kind eq 'field' — no case normalisation
|
|
539
|
+
# applied. Perl hash lookups are case-sensitive so
|
|
540
|
+
# the key here must match the actual hash key the
|
|
541
|
+
# user populated.
|
|
542
|
+
# compare_type => 'numeric' | 'string'
|
|
543
|
+
# direction => 'asc' | 'desc'
|
|
544
|
+
#
|
|
545
|
+
# Accepted comparator catalogue (gated upstream at parse time —
|
|
546
|
+
# anything outside refuses with BF101 before reaching this helper):
|
|
547
|
+
#
|
|
548
|
+
# (a,b) => a.f - b.f → field, numeric
|
|
549
|
+
# (a,b) => a - b → self, numeric
|
|
550
|
+
# (a,b) => a[.f].localeCompare(b[.f]) → field|self, string
|
|
551
|
+
# (and reversed-operand variants for `desc`).
|
|
552
|
+
#
|
|
553
|
+
# A future `nulls => 'first' | 'last'` knob can land alongside
|
|
554
|
+
# without churn — the opts hash is the right place to grow.
|
|
555
|
+
|
|
556
|
+
sub sort ($self, $recv, $opts = {}) {
|
|
557
|
+
return [] unless ref($recv) eq 'ARRAY';
|
|
558
|
+
my $key_kind = $opts->{key_kind} // 'self';
|
|
559
|
+
my $key = $opts->{key} // '';
|
|
560
|
+
my $compare_type = $opts->{compare_type} // 'numeric';
|
|
561
|
+
my $direction = $opts->{direction} // 'asc';
|
|
562
|
+
|
|
563
|
+
# Schwartzian transform: project each item to its sort key once,
|
|
564
|
+
# then sort by key, then drop the keys. Cheaper than re-resolving
|
|
565
|
+
# the field accessor inside every comparison for non-trivial arrays.
|
|
566
|
+
my @keyed = map {
|
|
567
|
+
my $item = $_;
|
|
568
|
+
my $k = $key_kind eq 'field' && ref($item) eq 'HASH' ? $item->{$key} : $item;
|
|
569
|
+
[$k, $item]
|
|
570
|
+
} @$recv;
|
|
571
|
+
|
|
572
|
+
my $cmp;
|
|
573
|
+
if ($compare_type eq 'string') {
|
|
574
|
+
$cmp = $direction eq 'desc'
|
|
575
|
+
? sub { ($b->[0] // '') cmp ($a->[0] // '') }
|
|
576
|
+
: sub { ($a->[0] // '') cmp ($b->[0] // '') };
|
|
577
|
+
} else {
|
|
578
|
+
# Numeric: undef projects to 0 so the sort is total without
|
|
579
|
+
# warnings on missing fields. Documented divergence from JS
|
|
580
|
+
# (which would coerce undef → NaN and produce indeterminate
|
|
581
|
+
# ordering); matching Go's `toFloat64(nil) == 0` keeps the
|
|
582
|
+
# adapter outputs symmetric.
|
|
583
|
+
$cmp = $direction eq 'desc'
|
|
584
|
+
? sub { ($b->[0] // 0) <=> ($a->[0] // 0) }
|
|
585
|
+
: sub { ($a->[0] // 0) <=> ($b->[0] // 0) };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
my @sorted = sort $cmp @keyed;
|
|
589
|
+
return [ map { $_->[1] } @sorted ];
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
# ---------------------------------------------------------------------------
|
|
593
|
+
# JSX intrinsic-element spread (#1407)
|
|
594
|
+
# ---------------------------------------------------------------------------
|
|
595
|
+
#
|
|
596
|
+
# Mirrors the JS `spreadAttrs` runtime
|
|
597
|
+
# (`packages/client/src/runtime/spread-attrs.ts`) and the Go adapter's
|
|
598
|
+
# `bf.SpreadAttrs` so SSR output stays byte-equal across the three
|
|
599
|
+
# adapters. Generated Mojo templates invoke this as
|
|
600
|
+
# `<%== bf->spread_attrs($bag) %>`.
|
|
601
|
+
#
|
|
602
|
+
# Skip rules: nil/false values, event handlers (`on[A-Z]…` shape
|
|
603
|
+
# matching JS `key[2] === key[2].toUpperCase()` — true for any
|
|
604
|
+
# character whose uppercase is itself, including digits and
|
|
605
|
+
# underscore), `children`. `ref` is intentionally NOT filtered,
|
|
606
|
+
# matching the JS reference.
|
|
607
|
+
#
|
|
608
|
+
# Key remap: className → class, htmlFor → for; SVG camelCase
|
|
609
|
+
# attrs preserved (case-sensitive XML spec); other camelCase keys
|
|
610
|
+
# lowered to kebab-case with a leading `-` for an initial
|
|
611
|
+
# uppercase letter (mirrors JS `key.replace(/([A-Z])/g, '-$1')`).
|
|
612
|
+
#
|
|
613
|
+
# `style` is routed through `_style_to_css` so object literals
|
|
614
|
+
# serialise to a real CSS string instead of Perl's default
|
|
615
|
+
# `HASH(0x...)` form.
|
|
616
|
+
#
|
|
617
|
+
# Output is deterministic: keys are sorted alphabetically before
|
|
618
|
+
# emission, matching the Go adapter's `sort.Strings(keys)` policy
|
|
619
|
+
# and Mojo::JSON's marshal order.
|
|
620
|
+
#
|
|
621
|
+
# The return value is a Mojo::ByteStream so the calling template's
|
|
622
|
+
# `<%==` raw-emit skips re-escaping (the helper has already
|
|
623
|
+
# HTML-escaped each value).
|
|
624
|
+
|
|
625
|
+
my %SVG_CAMEL_CASE_ATTRS = map { $_ => 1 } qw(
|
|
626
|
+
allowReorder attributeName attributeType autoReverse
|
|
627
|
+
baseFrequency baseProfile calcMode clipPathUnits
|
|
628
|
+
contentScriptType contentStyleType diffuseConstant edgeMode
|
|
629
|
+
externalResourcesRequired filterRes filterUnits glyphRef
|
|
630
|
+
gradientTransform gradientUnits kernelMatrix kernelUnitLength
|
|
631
|
+
keyPoints keySplines keyTimes lengthAdjust limitingConeAngle
|
|
632
|
+
markerHeight markerUnits markerWidth maskContentUnits
|
|
633
|
+
maskUnits numOctaves pathLength patternContentUnits
|
|
634
|
+
patternTransform patternUnits pointsAtX pointsAtY pointsAtZ
|
|
635
|
+
preserveAlpha preserveAspectRatio primitiveUnits refX refY
|
|
636
|
+
repeatCount repeatDur requiredExtensions requiredFeatures
|
|
637
|
+
specularConstant specularExponent spreadMethod startOffset
|
|
638
|
+
stdDeviation stitchTiles surfaceScale systemLanguage
|
|
639
|
+
tableValues targetX targetY textLength viewBox viewTarget
|
|
640
|
+
xChannelSelector yChannelSelector zoomAndPan
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
sub _to_attr_name ($key) {
|
|
644
|
+
return 'class' if $key eq 'className';
|
|
645
|
+
return 'for' if $key eq 'htmlFor';
|
|
646
|
+
return $key if $SVG_CAMEL_CASE_ATTRS{$key};
|
|
647
|
+
# camelCase → kebab-case, with a leading `-` for an initial
|
|
648
|
+
# uppercase letter (JS-reference parity, even though that case
|
|
649
|
+
# produces an HTML-invalid attribute name — same documented
|
|
650
|
+
# behaviour as the Go adapter's `toAttrName`).
|
|
651
|
+
my $out = $key;
|
|
652
|
+
$out =~ s/([A-Z])/-\L$1/g;
|
|
653
|
+
return $out;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
sub _html_escape ($value) {
|
|
657
|
+
# HTML attribute-value escape for SSR string emission. The
|
|
658
|
+
# spread bag's values reach the browser as part of a generated
|
|
659
|
+
# `key="..."` substring inside the rendered HTML, so the
|
|
660
|
+
# escape set has to cover everything that could break either
|
|
661
|
+
# the surrounding double-quoted attribute or the enclosing
|
|
662
|
+
# tag: `&`, `<`, `>`, `"`, and `'`. Matches Go's
|
|
663
|
+
# `template.HTMLEscapeString` semantics byte-for-byte (using
|
|
664
|
+
# `"` / `'` for quotes rather than the named entities)
|
|
665
|
+
# so the SSR output is identical across the Go and Mojo
|
|
666
|
+
# adapters (#1407, #1413 review). The CSR-side
|
|
667
|
+
# `applyRestAttrs` calls `el.setAttribute(name, String(value))`
|
|
668
|
+
# — which does its own DOM-level escaping in the browser —
|
|
669
|
+
# so JS doesn't need an explicit escape pass; Perl/Go emit a
|
|
670
|
+
# string, so we do.
|
|
671
|
+
my $s = defined $value ? "$value" : '';
|
|
672
|
+
$s =~ s/&/&/g;
|
|
673
|
+
$s =~ s/</</g;
|
|
674
|
+
$s =~ s/>/>/g;
|
|
675
|
+
$s =~ s/"/"/g;
|
|
676
|
+
$s =~ s/'/'/g;
|
|
677
|
+
return $s;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
sub _style_to_css ($value) {
|
|
681
|
+
return undef unless defined $value;
|
|
682
|
+
# Non-hashref values pass through stringified — matches the JS
|
|
683
|
+
# `typeof value !== 'object'` branch in `styleToCss`.
|
|
684
|
+
if (ref($value) ne 'HASH') {
|
|
685
|
+
my $s = "$value";
|
|
686
|
+
return length $s ? $s : undef;
|
|
687
|
+
}
|
|
688
|
+
my @parts;
|
|
689
|
+
for my $key (sort keys %$value) {
|
|
690
|
+
my $v = $value->{$key};
|
|
691
|
+
next unless defined $v;
|
|
692
|
+
my $prop = $key;
|
|
693
|
+
$prop =~ s/([A-Z])/-\L$1/g;
|
|
694
|
+
push @parts, "$prop:$v";
|
|
695
|
+
}
|
|
696
|
+
return @parts ? join(';', @parts) : undef;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
sub spread_attrs ($self, $bag) {
|
|
700
|
+
return '' unless defined $bag && ref($bag) eq 'HASH';
|
|
701
|
+
my @parts;
|
|
702
|
+
for my $key (sort keys %$bag) {
|
|
703
|
+
# Event handlers: skip when key starts `on` and the third
|
|
704
|
+
# character is its own uppercase form (uppercase letter,
|
|
705
|
+
# digit, underscore, …). Mirrors the JS predicate.
|
|
706
|
+
if (length($key) > 2 && substr($key, 0, 2) eq 'on') {
|
|
707
|
+
my $c = substr($key, 2, 1);
|
|
708
|
+
next if uc($c) eq $c;
|
|
709
|
+
}
|
|
710
|
+
next if $key eq 'children';
|
|
711
|
+
my $val = $bag->{$key};
|
|
712
|
+
# null / undef → drop.
|
|
713
|
+
next unless defined $val;
|
|
714
|
+
# Boolean values arrive as Mojo::JSON sentinel objects
|
|
715
|
+
# (`Mojo::JSON::true` / `false`) — both from JSON-deserialised
|
|
716
|
+
# props and from the test harness's `toPerlLiteral`
|
|
717
|
+
# (which emits the sentinels rather than plain 0/1 to avoid
|
|
718
|
+
# conflating booleans with numeric attribute values like
|
|
719
|
+
# `tabindex="0"`). The contract is: callers MUST use the
|
|
720
|
+
# sentinels for boolean values; plain Perl scalars 0/1
|
|
721
|
+
# render as numeric attribute values, matching how JS
|
|
722
|
+
# `spreadAttrs` treats a `0`/`1` JS number.
|
|
723
|
+
if (ref($val) eq 'JSON::PP::Boolean' || ref($val) eq 'Mojo::JSON::_Bool') {
|
|
724
|
+
next unless $val;
|
|
725
|
+
push @parts, _to_attr_name($key);
|
|
726
|
+
next;
|
|
727
|
+
}
|
|
728
|
+
# `style` routes through `_style_to_css` so object literals
|
|
729
|
+
# serialise to a real CSS string.
|
|
730
|
+
if ($key eq 'style') {
|
|
731
|
+
my $css = _style_to_css($val);
|
|
732
|
+
next unless defined $css && length $css;
|
|
733
|
+
push @parts, qq{style="} . _html_escape($css) . qq{"};
|
|
734
|
+
next;
|
|
735
|
+
}
|
|
736
|
+
my $name = _to_attr_name($key);
|
|
737
|
+
push @parts, $name . qq{="} . _html_escape($val) . qq{"};
|
|
738
|
+
}
|
|
739
|
+
return '' unless @parts;
|
|
740
|
+
# Return a Mojo::ByteStream so the calling template's `<%==`
|
|
741
|
+
# raw-emit doesn't re-escape the already-escaped values.
|
|
742
|
+
return b(join(' ', @parts));
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
1;
|