@barefootjs/mojolicious 0.6.1 → 0.7.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.pm DELETED
@@ -1,1060 +0,0 @@
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/"/&quot;/gr;
45
- push @parts, qq{bf-h="$h"};
46
- }
47
- if (defined $mount && length $mount) {
48
- my $m = $mount =~ s/"/&quot;/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
- # `Array.prototype.flat(depth?)` (#1448 Tier C) — flatten nested ARRAY
512
- # refs `$depth` levels deep. A `$depth` of -1 is the `Infinity` sentinel
513
- # (flatten fully); 0 returns a shallow copy. Non-ARRAY elements are kept
514
- # as-is (JS only flattens nested arrays). Non-ARRAY receiver → [].
515
- sub flat ($self, $recv, $depth = 1) {
516
- return [] unless ref($recv) eq 'ARRAY';
517
- my @out;
518
- for my $el (@$recv) {
519
- if ($depth != 0 && ref($el) eq 'ARRAY') {
520
- my $next = $depth > 0 ? $depth - 1 : $depth;
521
- push @out, @{ $self->flat($el, $next) };
522
- }
523
- else {
524
- push @out, $el;
525
- }
526
- }
527
- return \@out;
528
- }
529
-
530
- # `Array.prototype.flatMap(fn)` value-returning field projection
531
- # (#1448 Tier C) — map each element through a self / field projection,
532
- # then flatten one level. `field` reads a HASH-ref key (the raw JS prop
533
- # name, as `bf->reduce` does); a projected non-ARRAY value is kept as-is
534
- # (flatMap = map + flat(1)). Non-ARRAY receiver → [].
535
- sub flat_map ($self, $recv, $key_kind, $key) {
536
- return [] unless ref($recv) eq 'ARRAY';
537
- my @projected;
538
- for my $el (@$recv) {
539
- if ($key_kind eq 'field') {
540
- # JS `i => i.field` on a non-object yields `undefined`, not the
541
- # element itself — push `undef` so a scalar element doesn't leak
542
- # into the output (matches Go's `getFieldValue` returning nil).
543
- push @projected, ref($el) eq 'HASH' ? $el->{$key} : undef;
544
- }
545
- else {
546
- push @projected, $el;
547
- }
548
- }
549
- return $self->flat(\@projected, 1);
550
- }
551
-
552
- # `Array.prototype.flatMap(i => [i.a, i.b])` — array-literal tuple
553
- # projection (#1448 Tier C). Each `@specs` entry is a [kind, key] arrayref
554
- # (['self', ''] or ['field', 'a']). For each element, every leaf's value
555
- # is appended in order. flat(1) removes only the literal wrapper, so an
556
- # array-valued leaf is appended verbatim (no spread) — i.e. just append
557
- # each leaf. A non-HASH element under a `field` leaf yields undef (JS
558
- # `i.field` on a non-object). Non-ARRAY receiver → [].
559
- sub flat_map_tuple ($self, $recv, @specs) {
560
- return [] unless ref($recv) eq 'ARRAY';
561
- my @out;
562
- for my $el (@$recv) {
563
- for my $spec (@specs) {
564
- my ($kind, $key) = @$spec;
565
- if ($kind eq 'field') {
566
- push @out, ref($el) eq 'HASH' ? $el->{$key} : undef;
567
- }
568
- else {
569
- push @out, $el;
570
- }
571
- }
572
- }
573
- return \@out;
574
- }
575
-
576
- # `String.prototype.trim()` — strip leading + trailing whitespace.
577
- # JS's `String.prototype.trim` matches `\s` in the Unicode sense
578
- # (any whitespace including non-breaking space U+00A0); Perl's `\s`
579
- # inside a regex with `/u` flag is the same. Undef receivers return
580
- # the empty string (matches JS's `String(undefined).trim()` which
581
- # would be "undefined" → "undefined", but in our template context
582
- # undef commonly means "missing prop"; rendering the empty string
583
- # is the safer choice and mirrors the JS-compat divergence we
584
- # already document for `bf->string(undef) === ""`).
585
-
586
- sub trim ($self, $recv) {
587
- return '' unless defined $recv;
588
- return '' if ref($recv);
589
- my $s = "$recv";
590
- $s =~ s/^\s+|\s+$//gu;
591
- return $s;
592
- }
593
-
594
- # `String.prototype.split(sep)` (#1448 Tier B) — string → ARRAY ref.
595
- #
596
- # Two JS-parity wrinkles drive the helper (a bare `split` emit would
597
- # diverge from both JS and Go):
598
- #
599
- # * Perl's `split` treats its first argument as a *regex*, so a
600
- # separator like '.' or '|' would match far too much. We
601
- # `quotemeta` it to force literal-string matching, mirroring JS's
602
- # string-separator semantics (the regex-separator form stays
603
- # refused upstream — see the parser arm).
604
- # * Perl's `split` drops trailing empty fields by default; JS keeps
605
- # them (`"a,".split(",")` is `["a", ""]`). Passing the `-1` limit
606
- # preserves them, matching JS and Go's `strings.Split`.
607
- #
608
- # An empty separator splits into individual characters (JS + Go agree).
609
- # Undef receiver renders as the single-element `['']` — the same
610
- # "missing prop → empty string" convention `bf->trim` uses.
611
-
612
- sub split ($self, $recv, $sep = undef, $limit = undef) {
613
- my $s = defined $recv && !ref($recv) ? "$recv" : '';
614
-
615
- my @parts;
616
- if (!defined $sep) {
617
- # No separator → the whole string in a single-element array
618
- # (matches JS `"x".split()` / `.split(undefined)`).
619
- @parts = ($s);
620
- }
621
- elsif ("$sep" eq '') {
622
- # Empty separator → individual characters. No `-1` limit here:
623
- # on an empty pattern Perl's `split` with `-1` appends a spurious
624
- # trailing empty field ("abc" → 'a','b','c',''), which JS/Go don't.
625
- @parts = split //, $s;
626
- }
627
- elsif ($s eq '') {
628
- # Empty input with a non-empty separator: JS `"".split(",")` is
629
- # `[""]` and Go's `strings.Split("", ",")` is `[""]`, but Perl's
630
- # `split /,/, ''` returns the empty list — special-case for parity.
631
- @parts = ('');
632
- }
633
- else {
634
- # `quotemeta` forces literal-string matching (JS string-separator
635
- # semantics); the `-1` keeps trailing empty fields (JS keeps them,
636
- # Perl's bare `split` drops them).
637
- my $q = quotemeta("$sep");
638
- @parts = split /$q/, $s, -1;
639
- }
640
-
641
- # Optional `limit` caps the number of pieces (JS `split(sep, limit)`).
642
- # 0 → empty; a negative limit keeps all (JS ToUint32 wrap makes it
643
- # effectively unbounded) — both match Go's `bf_split`.
644
- if (defined $limit) {
645
- my $n = int($limit);
646
- if ($n == 0) { @parts = () }
647
- elsif ($n > 0 && $n < scalar @parts) { @parts = @parts[0 .. $n - 1] }
648
- }
649
-
650
- return [@parts];
651
- }
652
-
653
- # `String.prototype.startsWith(prefix, position?)` (#1448 Tier B) —
654
- # string → boolean (1 / 0). `substr`-anchored literal comparison mirrors
655
- # Go's `strings.HasPrefix`. An empty prefix is always true (JS parity);
656
- # undef / non-string receivers coerce to the empty string first. The
657
- # optional `position` re-anchors the test (clamped to `[0, length]`),
658
- # matching JS `"abc".startsWith("b", 1)`.
659
-
660
- sub starts_with ($self, $recv, $prefix, $position = undef) {
661
- my $s = defined $recv && !ref($recv) ? "$recv" : '';
662
- my $p = defined $prefix ? "$prefix" : '';
663
- if (defined $position) {
664
- my $n = int($position);
665
- $n = 0 if $n < 0;
666
- $n = length($s) if $n > length($s);
667
- $s = substr($s, $n);
668
- }
669
- return substr($s, 0, length $p) eq $p ? 1 : 0;
670
- }
671
-
672
- # `String.prototype.endsWith(suffix, endPosition?)` (#1448 Tier B) —
673
- # string → boolean (1 / 0). Mirrors Go's `strings.HasSuffix`. An empty
674
- # suffix is always true (JS parity); a suffix longer than the string is
675
- # false. `substr($s, -length $x)` would mis-read the whole string when
676
- # `length $x == 0`, so that case short-circuits. The optional
677
- # `endPosition` treats the string as if it were only that many chars
678
- # long (clamped to `[0, length]`), matching JS `"abc".endsWith("b", 2)`.
679
-
680
- sub ends_with ($self, $recv, $suffix, $end_position = undef) {
681
- my $s = defined $recv && !ref($recv) ? "$recv" : '';
682
- my $x = defined $suffix ? "$suffix" : '';
683
- if (defined $end_position) {
684
- my $e = int($end_position);
685
- $e = 0 if $e < 0;
686
- $e = length($s) if $e > length($s);
687
- $s = substr($s, 0, $e);
688
- }
689
- return 1 if $x eq '';
690
- return 0 if length($s) < length($x);
691
- return substr($s, -length $x) eq $x ? 1 : 0;
692
- }
693
-
694
- # `String.prototype.replace(pattern, replacement)` — string-pattern
695
- # form only (#1448 Tier B), replacing the FIRST occurrence (JS string-
696
- # pattern semantics). Spliced via index/substr rather than `s///` so
697
- # BOTH the pattern and the replacement are literal: no Perl regex
698
- # metacharacters in the pattern and no `$1` / `$&` interpolation in the
699
- # replacement. Go's `bf_replace` (strings.Replace, n=1) treats the
700
- # replacement literally too, so the two adapters stay byte-equal — this
701
- # diverges from JS only for replacement strings containing `$`-patterns
702
- # (rare in template position). An empty pattern inserts the replacement
703
- # at the front (`"abc".replace("", "X")` → "Xabc"), matching JS + Go.
704
-
705
- sub replace ($self, $recv, $pattern, $replacement) {
706
- my $s = defined $recv && !ref($recv) ? "$recv" : '';
707
- my $o = defined $pattern ? "$pattern" : '';
708
- my $n = defined $replacement ? "$replacement" : '';
709
- return $n . $s if $o eq '';
710
- my $i = index($s, $o);
711
- return $s if $i < 0;
712
- return substr($s, 0, $i) . $n . substr($s, $i + length($o));
713
- }
714
-
715
- # `String.prototype.repeat(n)` — the receiver concatenated n times
716
- # (#1448 Tier B), via Perl's `x` operator. JS throws RangeError for a
717
- # negative count, but SSR templates degrade to the empty string rather
718
- # than dying mid-render, so a count <= 0 returns "" (Go's `bf_repeat`
719
- # applies the same clamp). The count is truncated toward zero
720
- # (`int`), matching JS's ToIntegerOrInfinity on `"a".repeat(3.7)`.
721
-
722
- sub repeat ($self, $recv, $count) {
723
- my $s = defined $recv && !ref($recv) ? "$recv" : '';
724
- my $n = defined $count ? int($count) : 0;
725
- return $n <= 0 ? '' : $s x $n;
726
- }
727
-
728
- # `String.prototype.padStart` / `padEnd` (#1448 Tier B) — pad the
729
- # receiver to `$target` characters with `$pad` (default a single space)
730
- # repeated and truncated to fill, prepended or appended. Length is
731
- # measured in characters (Perl `length`), matching Go's rune-based
732
- # `bf_pad_*` — diverges from JS's UTF-16-unit length only for
733
- # astral-plane input. An empty pad, or a receiver already >= `$target`,
734
- # returns the receiver unchanged (JS parity). The `$target` is
735
- # truncated toward zero (JS ToLength on the first arg).
736
-
737
- sub _pad ($s, $target, $pad, $at_start) {
738
- $pad = ' ' unless defined $pad;
739
- $pad = "$pad";
740
- return $s if $pad eq '';
741
- my $len = length $s;
742
- my $t = int($target // 0);
743
- return $s if $len >= $t;
744
- my $need = $t - $len;
745
- # Repeat enough copies to cover $need, then trim to exactly $need.
746
- my $fill = substr($pad x (int($need / length($pad)) + 1), 0, $need);
747
- return $at_start ? $fill . $s : $s . $fill;
748
- }
749
-
750
- sub pad_start ($self, $recv, $target, $pad = undef) {
751
- my $s = defined $recv && !ref($recv) ? "$recv" : '';
752
- return _pad($s, $target, $pad, 1);
753
- }
754
-
755
- sub pad_end ($self, $recv, $target, $pad = undef) {
756
- my $s = defined $recv && !ref($recv) ? "$recv" : '';
757
- return _pad($s, $target, $pad, 0);
758
- }
759
-
760
- # `Array.prototype.sort(cmp)` / `Array.prototype.toSorted(cmp)`
761
- # lowering (#1448 Tier B). Non-mutating — JS's mutate-vs-new
762
- # distinction is moot in SSR template context.
763
- #
764
- # Opts hash-ref. The compiler emits a `keys` list of per-key hashes
765
- # in priority order; each hash carries:
766
- #
767
- # key_kind => 'self' | 'field'
768
- # key => '' when key_kind eq 'self'; field name verbatim
769
- # from the comparator AST (e.g. 'price', 'createdAt')
770
- # when key_kind eq 'field' — no case normalisation
771
- # applied. Perl hash lookups are case-sensitive so
772
- # the key here must match the actual hash key the
773
- # user populated.
774
- # compare_type => 'numeric' | 'string' | 'auto'
775
- # direction => 'asc' | 'desc'
776
- #
777
- # Accepted comparator catalogue (gated upstream at parse time —
778
- # anything outside refuses with BF101 before reaching this helper):
779
- #
780
- # (a,b) => a.f - b.f → field, numeric
781
- # (a,b) => a - b → self, numeric
782
- # (a,b) => a[.f].localeCompare(b[.f]) → field|self, string
783
- # (a,b) => a.f > b.f ? 1 : -1 → field|self, auto
784
- # any of the above ||-chained → multi-key tie-breaks
785
- # (and reversed-operand variants for `desc`).
786
- #
787
- # `auto` (relational-ternary lowering) compares numerically when both
788
- # keys `looks_like_number`, else lexically — Go's `bf_sort` applies the
789
- # same rule so the two template adapters stay byte-equal.
790
- #
791
- # A future `nulls => 'first' | 'last'` knob can land per key without
792
- # churn — the opts hash is the right place to grow.
793
-
794
- sub sort ($self, $recv, $opts = {}) {
795
- return [] unless ref($recv) eq 'ARRAY';
796
-
797
- # Normalise the per-key specs (priority order, length >= 1).
798
- my @spec = map {
799
- {
800
- key_kind => $_->{key_kind} // 'self',
801
- key => $_->{key} // '',
802
- compare_type => $_->{compare_type} // 'numeric',
803
- direction => $_->{direction} // 'asc',
804
- }
805
- } @{ $opts->{keys} // [] };
806
- return [ @$recv ] unless @spec;
807
-
808
- # Schwartzian transform: project each item to all its sort keys
809
- # once, then compare projected keys. Cheaper than re-resolving the
810
- # field accessors inside every comparison for non-trivial arrays.
811
- my @keyed = map {
812
- my $item = $_;
813
- my @ks = map {
814
- $_->{key_kind} eq 'field' && ref($item) eq 'HASH' ? $item->{ $_->{key} } : $item;
815
- } @spec;
816
- [ \@ks, $item ];
817
- } @$recv;
818
-
819
- my $cmp = sub {
820
- for my $i (0 .. $#spec) {
821
- my $sp = $spec[$i];
822
- my $c = _compare_sort_key($a->[0][$i], $b->[0][$i], $sp->{compare_type});
823
- next if $c == 0; # tie on this key — try the next
824
- return $sp->{direction} eq 'desc' ? -$c : $c;
825
- }
826
- return 0;
827
- };
828
-
829
- my @sorted = sort $cmp @keyed;
830
- return [ map { $_->[1] } @sorted ];
831
- }
832
-
833
- # Compare two projected keys, ascending orientation (-1 / 0 / 1); the
834
- # caller negates for 'desc'. 'auto' compares numerically when both
835
- # keys look like numbers, else lexically (matches Go's `bf_sort`).
836
- # undef coalesces to '' / 0 so the order stays total without warnings.
837
- sub _compare_sort_key ($av, $bv, $compare_type) {
838
- if ($compare_type eq 'string') {
839
- return ($av // '') cmp ($bv // '');
840
- }
841
- if ($compare_type eq 'auto') {
842
- if (looks_like_number($av // '') && looks_like_number($bv // '')) {
843
- return ($av // 0) <=> ($bv // 0);
844
- }
845
- return ($av // '') cmp ($bv // '');
846
- }
847
- return ($av // 0) <=> ($bv // 0); # numeric
848
- }
849
-
850
- # Fold an array into a scalar via the arithmetic-fold catalogue
851
- # (#1448 Tier C). Mirrors Go's `bf_reduce` and JS `reduce(fn, init)` /
852
- # `reduceRight(fn, init)` for the shapes `(acc, x) => acc <op> x` /
853
- # `(acc, x) => acc <op> x.field`:
854
- #
855
- # bf->reduce($recv, {
856
- # op => '+' | '*',
857
- # key_kind => 'self' | 'field',
858
- # key => '<field>', # when key_kind eq 'field'
859
- # type => 'numeric' | 'string',
860
- # init => <seed>, # number, or string for concat
861
- # direction => 'left' | 'right', # 'right' = reduceRight (default 'left')
862
- # })
863
- #
864
- # Numeric folds accumulate with `+` / `*` (non-numeric keys coalesce to
865
- # 0); string folds concatenate via `bf->string` (undef → ''). The init
866
- # seeds the accumulator, so an empty array returns it unchanged — exactly
867
- # like JS. `direction => 'right'` folds right-to-left (reduceRight); only
868
- # observable for string concat, since numeric sum / product commute.
869
- # Float stringification can diverge from Go's for inexact binary
870
- # fractions (e.g. 0.1 + 0.2); integer sums — the common case — agree.
871
- sub reduce ($self, $recv, $opts = {}) {
872
- my $op = $opts->{op} // '+';
873
- my $key_kind = $opts->{key_kind} // 'self';
874
- my $key = $opts->{key} // '';
875
- my $type = $opts->{type} // 'numeric';
876
- my $direction = $opts->{direction} // 'left';
877
-
878
- my @items = ref($recv) eq 'ARRAY' ? @$recv : ();
879
- # reduceRight folds right-to-left; reversing the snapshot keeps the
880
- # single forward loop below. Only observable for string concat —
881
- # numeric sum / product commute. Qualify as CORE::reverse — this
882
- # package defines `sub reverse` (the `.reverse()` helper), so a bare
883
- # `reverse` is ambiguous under `use warnings`.
884
- @items = CORE::reverse(@items) if $direction eq 'right';
885
- my $project = sub ($item) {
886
- $key_kind eq 'field' && ref($item) eq 'HASH' ? $item->{$key} : $item;
887
- };
888
-
889
- if ($type eq 'string') {
890
- my $acc = $opts->{init} // '';
891
- $acc .= $self->string($project->($_)) for @items;
892
- return $acc;
893
- }
894
-
895
- my $acc = $opts->{init} // 0;
896
- for my $item (@items) {
897
- my $n = $project->($item);
898
- # Guard `defined` before `looks_like_number` so a missing field
899
- # (undef) folds as 0 without an "uninitialized value" warning
900
- # under `use warnings` — matching the `$av // ''` style `sort` uses.
901
- $n = 0 unless defined $n && looks_like_number($n);
902
- $op eq '*' ? ($acc *= $n) : ($acc += $n);
903
- }
904
- return $acc;
905
- }
906
-
907
- # ---------------------------------------------------------------------------
908
- # JSX intrinsic-element spread (#1407)
909
- # ---------------------------------------------------------------------------
910
- #
911
- # Mirrors the JS `spreadAttrs` runtime
912
- # (`packages/client/src/runtime/spread-attrs.ts`) and the Go adapter's
913
- # `bf.SpreadAttrs` so SSR output stays byte-equal across the three
914
- # adapters. Generated Mojo templates invoke this as
915
- # `<%== bf->spread_attrs($bag) %>`.
916
- #
917
- # Skip rules: nil/false values, event handlers (`on[A-Z]…` shape
918
- # matching JS `key[2] === key[2].toUpperCase()` — true for any
919
- # character whose uppercase is itself, including digits and
920
- # underscore), `children`. `ref` is intentionally NOT filtered,
921
- # matching the JS reference.
922
- #
923
- # Key remap: className → class, htmlFor → for; SVG camelCase
924
- # attrs preserved (case-sensitive XML spec); other camelCase keys
925
- # lowered to kebab-case with a leading `-` for an initial
926
- # uppercase letter (mirrors JS `key.replace(/([A-Z])/g, '-$1')`).
927
- #
928
- # `style` is routed through `_style_to_css` so object literals
929
- # serialise to a real CSS string instead of Perl's default
930
- # `HASH(0x...)` form.
931
- #
932
- # Output is deterministic: keys are sorted alphabetically before
933
- # emission, matching the Go adapter's `sort.Strings(keys)` policy
934
- # and Mojo::JSON's marshal order.
935
- #
936
- # The return value is a Mojo::ByteStream so the calling template's
937
- # `<%==` raw-emit skips re-escaping (the helper has already
938
- # HTML-escaped each value).
939
-
940
- my %SVG_CAMEL_CASE_ATTRS = map { $_ => 1 } qw(
941
- allowReorder attributeName attributeType autoReverse
942
- baseFrequency baseProfile calcMode clipPathUnits
943
- contentScriptType contentStyleType diffuseConstant edgeMode
944
- externalResourcesRequired filterRes filterUnits glyphRef
945
- gradientTransform gradientUnits kernelMatrix kernelUnitLength
946
- keyPoints keySplines keyTimes lengthAdjust limitingConeAngle
947
- markerHeight markerUnits markerWidth maskContentUnits
948
- maskUnits numOctaves pathLength patternContentUnits
949
- patternTransform patternUnits pointsAtX pointsAtY pointsAtZ
950
- preserveAlpha preserveAspectRatio primitiveUnits refX refY
951
- repeatCount repeatDur requiredExtensions requiredFeatures
952
- specularConstant specularExponent spreadMethod startOffset
953
- stdDeviation stitchTiles surfaceScale systemLanguage
954
- tableValues targetX targetY textLength viewBox viewTarget
955
- xChannelSelector yChannelSelector zoomAndPan
956
- );
957
-
958
- sub _to_attr_name ($key) {
959
- return 'class' if $key eq 'className';
960
- return 'for' if $key eq 'htmlFor';
961
- return $key if $SVG_CAMEL_CASE_ATTRS{$key};
962
- # camelCase → kebab-case, with a leading `-` for an initial
963
- # uppercase letter (JS-reference parity, even though that case
964
- # produces an HTML-invalid attribute name — same documented
965
- # behaviour as the Go adapter's `toAttrName`).
966
- my $out = $key;
967
- $out =~ s/([A-Z])/-\L$1/g;
968
- return $out;
969
- }
970
-
971
- sub _html_escape ($value) {
972
- # HTML attribute-value escape for SSR string emission. The
973
- # spread bag's values reach the browser as part of a generated
974
- # `key="..."` substring inside the rendered HTML, so the
975
- # escape set has to cover everything that could break either
976
- # the surrounding double-quoted attribute or the enclosing
977
- # tag: `&`, `<`, `>`, `"`, and `'`. Matches Go's
978
- # `template.HTMLEscapeString` semantics byte-for-byte (using
979
- # `&#34;` / `&#39;` for quotes rather than the named entities)
980
- # so the SSR output is identical across the Go and Mojo
981
- # adapters (#1407, #1413 review). The CSR-side
982
- # `applyRestAttrs` calls `el.setAttribute(name, String(value))`
983
- # — which does its own DOM-level escaping in the browser —
984
- # so JS doesn't need an explicit escape pass; Perl/Go emit a
985
- # string, so we do.
986
- my $s = defined $value ? "$value" : '';
987
- $s =~ s/&/&amp;/g;
988
- $s =~ s/</&lt;/g;
989
- $s =~ s/>/&gt;/g;
990
- $s =~ s/"/&#34;/g;
991
- $s =~ s/'/&#39;/g;
992
- return $s;
993
- }
994
-
995
- sub _style_to_css ($value) {
996
- return undef unless defined $value;
997
- # Non-hashref values pass through stringified — matches the JS
998
- # `typeof value !== 'object'` branch in `styleToCss`.
999
- if (ref($value) ne 'HASH') {
1000
- my $s = "$value";
1001
- return length $s ? $s : undef;
1002
- }
1003
- my @parts;
1004
- for my $key (sort keys %$value) {
1005
- my $v = $value->{$key};
1006
- next unless defined $v;
1007
- my $prop = $key;
1008
- $prop =~ s/([A-Z])/-\L$1/g;
1009
- push @parts, "$prop:$v";
1010
- }
1011
- return @parts ? join(';', @parts) : undef;
1012
- }
1013
-
1014
- sub spread_attrs ($self, $bag) {
1015
- return '' unless defined $bag && ref($bag) eq 'HASH';
1016
- my @parts;
1017
- for my $key (sort keys %$bag) {
1018
- # Event handlers: skip when key starts `on` and the third
1019
- # character is its own uppercase form (uppercase letter,
1020
- # digit, underscore, …). Mirrors the JS predicate.
1021
- if (length($key) > 2 && substr($key, 0, 2) eq 'on') {
1022
- my $c = substr($key, 2, 1);
1023
- next if uc($c) eq $c;
1024
- }
1025
- next if $key eq 'children';
1026
- my $val = $bag->{$key};
1027
- # null / undef → drop.
1028
- next unless defined $val;
1029
- # Boolean values arrive as Mojo::JSON sentinel objects
1030
- # (`Mojo::JSON::true` / `false`) — both from JSON-deserialised
1031
- # props and from the test harness's `toPerlLiteral`
1032
- # (which emits the sentinels rather than plain 0/1 to avoid
1033
- # conflating booleans with numeric attribute values like
1034
- # `tabindex="0"`). The contract is: callers MUST use the
1035
- # sentinels for boolean values; plain Perl scalars 0/1
1036
- # render as numeric attribute values, matching how JS
1037
- # `spreadAttrs` treats a `0`/`1` JS number.
1038
- if (ref($val) eq 'JSON::PP::Boolean' || ref($val) eq 'Mojo::JSON::_Bool') {
1039
- next unless $val;
1040
- push @parts, _to_attr_name($key);
1041
- next;
1042
- }
1043
- # `style` routes through `_style_to_css` so object literals
1044
- # serialise to a real CSS string.
1045
- if ($key eq 'style') {
1046
- my $css = _style_to_css($val);
1047
- next unless defined $css && length $css;
1048
- push @parts, qq{style="} . _html_escape($css) . qq{"};
1049
- next;
1050
- }
1051
- my $name = _to_attr_name($key);
1052
- push @parts, $name . qq{="} . _html_escape($val) . qq{"};
1053
- }
1054
- return '' unless @parts;
1055
- # Return a Mojo::ByteStream so the calling template's `<%==`
1056
- # raw-emit doesn't re-escape the already-escaped values.
1057
- return b(join(' ', @parts));
1058
- }
1059
-
1060
- 1;