@barefootjs/perl 0.8.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/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@barefootjs/perl",
3
+ "version": "0.8.0",
4
+ "description": "BarefootJS engine-agnostic Perl runtime (BarefootJS.pm) — pluggable rendering backend; the shared basis for Mojolicious and other Perl framework integrations",
5
+ "type": "module",
6
+ "files": [
7
+ "lib",
8
+ "t",
9
+ "README.md"
10
+ ],
11
+ "keywords": [
12
+ "perl",
13
+ "barefoot",
14
+ "ssr",
15
+ "runtime",
16
+ "template"
17
+ ],
18
+ "author": "kobaken <kentafly88@gmail.com>",
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/piconic-ai/barefootjs",
23
+ "directory": "packages/adapter-perl"
24
+ }
25
+ }
@@ -0,0 +1,79 @@
1
+ use strict;
2
+ use warnings;
3
+
4
+ # Per-request reference-cycle regression test.
5
+ #
6
+ # The Mojolicious plugin stashes the bf instance under
7
+ # `$c->stash->{'bf.instance'}`, so any strong bf -> controller back-reference
8
+ # (held by the bf instance, its backend, or a child-renderer closure) closes a
9
+ # cycle that Perl's refcount GC cannot reclaim — leaking one controller + bf +
10
+ # closures per request. BarefootJS / BarefootJS::Backend::Mojo therefore hold
11
+ # the controller weakly, and `register_components_from_manifest` reaches the
12
+ # controller through the weak `$parent` rather than capturing it strongly.
13
+ #
14
+ # This test reproduces the plugin's wiring with a pure-Perl controller +
15
+ # backend (no Mojolicious dependency, so it runs anywhere) and asserts the
16
+ # whole graph is freed once the request-scope lexicals drop.
17
+
18
+ use Test2::V0;
19
+ use Scalar::Util qw(weaken);
20
+
21
+ use FindBin qw($Bin);
22
+ use lib "$Bin/../lib";
23
+
24
+ use BarefootJS;
25
+
26
+ # Controller stand-in that owns a stash, like a Mojolicious controller.
27
+ {
28
+ package FakeController;
29
+ sub new { bless { stash => {} }, shift }
30
+ sub stash { $_[0]{stash} }
31
+ }
32
+
33
+ # Pure-Perl backend mirroring the fixed BarefootJS::Backend::Mojo: it holds the
34
+ # controller weakly so it never contributes a strong edge to the cycle.
35
+ {
36
+ package WeakBackend;
37
+ use Scalar::Util qw(weaken);
38
+ sub new {
39
+ my ($class, %args) = @_;
40
+ my $self = bless {%args}, $class;
41
+ weaken($self->{c}) if $self->{c};
42
+ return $self;
43
+ }
44
+ sub c { $_[0]{c} }
45
+ sub encode_json { '{}' }
46
+ sub mark_raw { $_[1] }
47
+ sub materialize { $_[1] }
48
+ sub render_named { '' }
49
+ }
50
+
51
+ my ($probe_bf, $probe_c);
52
+ {
53
+ my $c = FakeController->new;
54
+ my $bf = BarefootJS->new($c, { backend => WeakBackend->new(c => $c) });
55
+
56
+ # Mimic Mojolicious::Plugin::BarefootJS: the `bf` helper stashes the
57
+ # instance on the controller (the edge that closes the cycle).
58
+ $c->stash->{'bf.instance'} = $bf;
59
+ $bf->_scope_id('root');
60
+
61
+ # The manifest path registers child-renderer closures onto $bf. These must
62
+ # not capture the controller strongly.
63
+ $bf->register_components_from_manifest({
64
+ 'ui/button/index' => {
65
+ markedTemplate => 'templates/ui/button/index.html.ep',
66
+ ssrDefaults => {},
67
+ },
68
+ });
69
+
70
+ weaken($probe_bf = $bf);
71
+ weaken($probe_c = $c);
72
+ } # request-scope lexicals ($c, $bf) drop here
73
+
74
+ is $probe_bf, undef,
75
+ 'parent bf is reclaimed at request end (no controller reference cycle)';
76
+ is $probe_c, undef,
77
+ 'controller is reclaimed at request end (no controller reference cycle)';
78
+
79
+ done_testing;
package/t/dev_reload.t ADDED
@@ -0,0 +1,58 @@
1
+ use Test2::V0;
2
+ use File::Temp qw(tempdir);
3
+
4
+ use BarefootJS::DevReload;
5
+
6
+ # --- browser snippet ---------------------------------------------------------
7
+ my $snip = BarefootJS::DevReload->snippet('/_bf/reload');
8
+ like $snip, qr/new EventSource\("\/_bf\/reload"\)/, 'snippet wires EventSource to the endpoint';
9
+ like $snip, qr/window\.__bfDevReload/, 'snippet is idempotent across duplicate mounts';
10
+ like $snip, qr/location\.reload\(\)/, 'snippet reloads on the `reload` event';
11
+
12
+ # --- build-id sentinel -------------------------------------------------------
13
+ my $dir = tempdir(CLEANUP => 1);
14
+ my $path = BarefootJS::DevReload->build_id_path($dir);
15
+ like $path, qr/\.dev.+build-id$/, 'build_id_path points at <dist>/.dev/build-id';
16
+ is(BarefootJS::DevReload->read_build_id($path), '', 'missing sentinel reads as empty');
17
+
18
+ BarefootJS::DevReload->ensure_dev_dir($dir);
19
+ open my $fh, '>', $path or die $!;
20
+ print $fh "abc123\n";
21
+ close $fh;
22
+ is(BarefootJS::DevReload->read_build_id($path), 'abc123', 'sentinel is read and trimmed');
23
+
24
+ # --- PSGI streaming app ------------------------------------------------------
25
+ # Drive the streaming coderef with a fake responder/writer; break the otherwise
26
+ # infinite poll loop by throwing from write() after the initial events.
27
+ {
28
+ package FakeWriter;
29
+ sub new { bless { n => 0, lines => [] }, shift }
30
+ sub write { my ($s, $d) = @_; push @{ $s->{lines} }, $d; die "stop\n" if ++$s->{n} >= 2 }
31
+ sub close { }
32
+ }
33
+
34
+ my $app = BarefootJS::DevReload->to_app(dist_dir => $dir);
35
+
36
+ is $app->({ 'psgi.streaming' => 0 })->[0], 500, 'requires a psgi.streaming server';
37
+
38
+ my $stream = $app->({ 'psgi.streaming' => 1, HTTP_LAST_EVENT_ID => '' });
39
+ is ref $stream, 'CODE', 'streaming response is a delayed coderef';
40
+
41
+ my $writer = FakeWriter->new;
42
+ my ($status, $headers);
43
+ $stream->(sub { ($status, $headers) = @{ $_[0] }[0, 1]; return $writer });
44
+
45
+ is $status, 200, 'streams 200';
46
+ my %h = @$headers;
47
+ is $h{'Content-Type'}, 'text/event-stream', 'SSE content-type';
48
+ my $out = join '', @{ $writer->{lines} };
49
+ like $out, qr/retry: 1000/, 'sets the SSE retry hint';
50
+ like $out, qr/event: hello/, 'emits hello with the current build-id at connect';
51
+
52
+ # A stale Last-Event-ID means a rebuild was missed → reload immediately.
53
+ my $stream2 = $app->({ 'psgi.streaming' => 1, HTTP_LAST_EVENT_ID => 'STALE' });
54
+ my $w2 = FakeWriter->new;
55
+ $stream2->(sub { return $w2 });
56
+ like join('', @{ $w2->{lines} }), qr/event: reload/, 'stale Last-Event-ID triggers reload';
57
+
58
+ done_testing;
@@ -0,0 +1,134 @@
1
+ use Test2::V0;
2
+
3
+ # spread_attrs — JSX intrinsic-element spread runtime helper (#1407
4
+ # follow-up). Mirrors the JS `spreadAttrs` in
5
+ # packages/client/src/runtime/spread-attrs.ts and the Go adapter's
6
+ # `bf.SpreadAttrs` so SSR output stays byte-equal across the three
7
+ # adapters. Cross-adapter parity regressions surface here first.
8
+
9
+ use FindBin qw($Bin);
10
+ use lib "$Bin/../lib";
11
+
12
+ use BarefootJS;
13
+
14
+ # Boolean sentinels via core JSON::PP (not Mojo::JSON) so this engine-agnostic
15
+ # test runs with zero Mojo present. `spread_attrs` recognises the
16
+ # JSON::PP::Boolean ref the same way it recognises Mojo::JSON's sentinel.
17
+ use JSON::PP ();
18
+ sub true { JSON::PP::true }
19
+ sub false { JSON::PP::false }
20
+
21
+ # Minimal pure-Perl backend: `spread_attrs` only reaches the backend for
22
+ # `mark_raw` (raw-string marking), which is the identity here.
23
+ {
24
+ package PureBackend;
25
+ sub new { bless {}, shift }
26
+ sub mark_raw { $_[1] }
27
+ }
28
+
29
+ # The runtime spread helper is a pure function of $self + bag; a bare blessed
30
+ # hash with an injected pure backend is sufficient.
31
+ my $bf = bless { c => undef, config => {}, backend => PureBackend->new }, 'BarefootJS';
32
+
33
+ # spread_attrs returns Mojo::ByteStream so callers can `<%==` it
34
+ # without re-escaping. Stringify here for assertion convenience.
35
+ sub run { return "" . $bf->spread_attrs(@_) }
36
+
37
+ subtest 'basic shapes' => sub {
38
+ is run(undef), '', 'undef bag → empty';
39
+ is run({}), '', 'empty bag → empty';
40
+ is run('not a hash'), '', 'non-hash scalar → empty';
41
+ is run({id => 'a'}), 'id="a"', 'single string';
42
+ };
43
+
44
+ subtest 'alphabetic key order (deterministic SSR)' => sub {
45
+ is run({id => 'a', class => 'on'}),
46
+ 'class="on" id="a"',
47
+ 'sorted by key name';
48
+ };
49
+
50
+ subtest 'key remapping' => sub {
51
+ is run({className => 'foo'}), 'class="foo"', 'className → class';
52
+ is run({htmlFor => 'x'}), 'for="x"', 'htmlFor → for';
53
+ is run({dataPriority => 'high'}),
54
+ 'data-priority="high"',
55
+ 'camelCase → kebab-case';
56
+ # SVG XML attrs are case-sensitive — preserve verbatim.
57
+ is run({viewBox => '0 0 10 10'}),
58
+ 'viewBox="0 0 10 10"',
59
+ 'SVG viewBox preserved';
60
+ is run({clipPathUnits => 'userSpaceOnUse'}),
61
+ 'clipPathUnits="userSpaceOnUse"',
62
+ 'SVG clipPathUnits preserved';
63
+ # JS-reference parity (#1411): a leading uppercase letter
64
+ # emits a leading dash. The resulting HTML attribute name is
65
+ # invalid in both the JS and Perl/Go runtimes, but the byte-
66
+ # equal output across the three adapters matters more.
67
+ is run({XData => 'x'}),
68
+ '-x-data="x"',
69
+ 'leading-uppercase emits leading dash (JS-reference parity)';
70
+ };
71
+
72
+ subtest 'event handlers — JS predicate parity' => sub {
73
+ # JS: `key.startsWith('on') && key.length > 2 && key[2] === key[2].toUpperCase()`.
74
+ is run({onClick => 'fn', id => 'a'}), 'id="a"',
75
+ 'onClick skipped (uppercase third char)';
76
+ is run({on_custom => 'fn', id => 'a'}), 'id="a"',
77
+ 'on_custom skipped (underscore third char)';
78
+ is run({on0 => 'fn', id => 'a'}), 'id="a"',
79
+ 'on0 skipped (digit third char)';
80
+ is run({oncology => 'x'}), 'oncology="x"',
81
+ 'on + lowercase letter NOT treated as event';
82
+ };
83
+
84
+ subtest 'children skipped, ref passed through (JS-reference parity)' => sub {
85
+ is run({children => 'x', id => 'a'}), 'id="a"',
86
+ 'children skipped';
87
+ # JS `spreadAttrs` does NOT filter `ref` (`applyRestAttrs` does
88
+ # — that's a separate divergence). Match the JS reference so
89
+ # SSR stays byte-equal with Hono / Go.
90
+ is run({ref => 'x', id => 'a'}), 'id="a" ref="x"',
91
+ 'ref passes through';
92
+ };
93
+
94
+ subtest 'boolean values via Mojo::JSON sentinels' => sub {
95
+ # The contract: callers MUST use Mojo::JSON::true/false for
96
+ # booleans. Plain scalar 0/1 render as numeric values.
97
+ is run({hidden => true, id => 'a'}),
98
+ 'hidden id="a"',
99
+ 'true → bare attribute';
100
+ is run({hidden => false, id => 'a'}),
101
+ 'id="a"',
102
+ 'false → omitted';
103
+ # Plain numeric 0 renders as a value (matches HTML
104
+ # `tabindex="0"` use case).
105
+ is run({tabindex => 0}),
106
+ 'tabindex="0"',
107
+ 'plain scalar 0 → numeric attribute value';
108
+ };
109
+
110
+ subtest 'nullish skip' => sub {
111
+ is run({a => undef, b => 'x'}),
112
+ 'b="x"',
113
+ 'undef value omitted';
114
+ };
115
+
116
+ subtest 'HTML escape' => sub {
117
+ is run({title => '<b>"x"</b>'}),
118
+ 'title="&lt;b&gt;&#34;x&#34;&lt;/b&gt;"',
119
+ 'angle brackets and quotes escaped';
120
+ is run({alt => "tom & jerry"}),
121
+ 'alt="tom &amp; jerry"',
122
+ 'ampersand escaped';
123
+ };
124
+
125
+ subtest 'style object lowering' => sub {
126
+ is run({style => {backgroundColor => 'red', color => 'white'}}),
127
+ 'style="background-color:red;color:white"',
128
+ 'style hashref → CSS string';
129
+ is run({style => 'color:red'}),
130
+ 'style="color:red"',
131
+ 'style scalar passthrough';
132
+ };
133
+
134
+ done_testing;