@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.
@@ -0,0 +1,150 @@
1
+ package Mojolicious::Plugin::BarefootJS::DevReload;
2
+ use Mojo::Base 'Mojolicious::Plugin', -signatures;
3
+
4
+ =head1 NAME
5
+
6
+ Mojolicious::Plugin::BarefootJS::DevReload - Dev-only browser auto-reload for BarefootJS apps
7
+
8
+ =head1 SYNOPSIS
9
+
10
+ # In your Mojolicious::Lite app (development mode)
11
+ plugin 'BarefootJS::DevReload';
12
+
13
+ # Then in your layout template, before </body>:
14
+ %== bf_dev_snippet
15
+
16
+ =head1 DESCRIPTION
17
+
18
+ Companion to C<barefoot build --watch> in C<@barefootjs/cli>. The CLI drops
19
+ C<< <dist>/.dev/build-id >> after every successful rebuild that changed
20
+ output; this plugin watches that file and streams SSE C<< event: reload >>
21
+ to subscribed browsers so an editor save triggers an automatic reload.
22
+
23
+ Disabled automatically when C<< $app->mode eq 'production' >> (set via
24
+ C<MOJO_MODE=production>). Pass C<< enabled => 0 >> to disable explicitly or
25
+ C<< enabled => 1 >> to force-enable.
26
+
27
+ =cut
28
+
29
+ use Mojo::ByteStream qw(b);
30
+ use Mojo::IOLoop;
31
+ use File::Spec;
32
+
33
+ # Sentinel path contract with @barefootjs/cli (DEV_SENTINEL_SUBDIR /
34
+ # DEV_SENTINEL_FILENAME in packages/cli/src/lib/build.ts). Duplicated so this
35
+ # package avoids a runtime dep on the CLI — keep in sync with the CLI.
36
+ my $DEV_SUBDIR = '.dev';
37
+ my $BUILD_ID_FILE = 'build-id';
38
+ my $SCROLL_STORAGE_KEY = '__bf_devreload_scroll';
39
+
40
+ # Heartbeat < any reasonable proxy/IOLoop idle timeout so a quiet connection
41
+ # doesn't get reaped between rebuilds.
42
+ my $HEARTBEAT_S = 5;
43
+
44
+ # Polling instead of Linux::Inotify2 / Mac::FSEvents keeps the runtime
45
+ # dependency-free. Sub-second latency is imperceptible next to browser reload.
46
+ my $POLL_S = 0.5;
47
+
48
+ sub register ($self, $app, $config = {}) {
49
+ my $dist_dir = $config->{dist_dir} // 'dist';
50
+ my $endpoint = $config->{endpoint} // '/_bf/reload';
51
+ my $enabled = exists $config->{enabled}
52
+ ? $config->{enabled}
53
+ : ($app->mode ne 'production');
54
+
55
+ # Snippet helper is always registered so templates don't have to branch
56
+ # on mode — it simply returns an empty ByteStream when disabled.
57
+ $app->helper(bf_dev_snippet => sub ($c) {
58
+ return b('') unless $enabled;
59
+ return b(_snippet($endpoint));
60
+ });
61
+
62
+ return unless $enabled;
63
+
64
+ # Resolve dist_dir relative to the Mojolicious home when not already
65
+ # absolute, so both `dist_dir => 'dist'` (the common case) and
66
+ # `dist_dir => '/abs/path'` (tests) work.
67
+ my $dist_abs = File::Spec->file_name_is_absolute($dist_dir)
68
+ ? $dist_dir
69
+ : $app->home->child($dist_dir)->to_string;
70
+ my $dev_dir = File::Spec->catdir($dist_abs, $DEV_SUBDIR);
71
+ my $build_id_path = File::Spec->catfile($dev_dir, $BUILD_ID_FILE);
72
+ mkdir $dev_dir unless -d $dev_dir;
73
+
74
+ $app->routes->get($endpoint => sub ($c) {
75
+ my $last_event_id = $c->req->headers->header('Last-Event-ID') // '';
76
+ $last_event_id =~ s/^\s+|\s+$//g;
77
+
78
+ $c->res->headers->content_type('text/event-stream');
79
+ $c->res->headers->cache_control('no-cache, no-transform');
80
+ $c->res->headers->connection('keep-alive');
81
+ $c->res->headers->header('X-Accel-Buffering' => 'no');
82
+
83
+ $c->write("retry: 1000\n\n");
84
+
85
+ my $initial_id = _read_build_id($build_id_path);
86
+ my $last_sent = '';
87
+ if (length $initial_id) {
88
+ $last_sent = $initial_id;
89
+ # When the client reconnects with a stale Last-Event-ID, a build
90
+ # happened during its disconnected window — fire `reload`
91
+ # immediately so the missed rebuild does not silently stay
92
+ # unpainted until the next change.
93
+ my $event = (length $last_event_id && $last_event_id ne $initial_id)
94
+ ? 'reload' : 'hello';
95
+ $c->write("event: $event\nid: $initial_id\ndata: $initial_id\n\n");
96
+ }
97
+
98
+ my ($hb_id, $poll_id);
99
+ $c->on(finish => sub {
100
+ Mojo::IOLoop->remove($hb_id) if $hb_id;
101
+ Mojo::IOLoop->remove($poll_id) if $poll_id;
102
+ });
103
+
104
+ $hb_id = Mojo::IOLoop->recurring($HEARTBEAT_S => sub {
105
+ $c->write(": hb\n\n");
106
+ });
107
+ $poll_id = Mojo::IOLoop->recurring($POLL_S => sub {
108
+ my $id = _read_build_id($build_id_path);
109
+ return unless length $id;
110
+ return if $id eq $last_sent;
111
+ $last_sent = $id;
112
+ $c->write("event: reload\nid: $id\ndata: $id\n\n");
113
+ });
114
+ });
115
+
116
+ return;
117
+ }
118
+
119
+ sub _read_build_id ($path) {
120
+ return '' unless -f $path;
121
+ open my $fh, '<', $path or return '';
122
+ local $/;
123
+ my $content = <$fh>;
124
+ close $fh;
125
+ $content //= '';
126
+ $content =~ s/^\s+|\s+$//g;
127
+ return $content;
128
+ }
129
+
130
+ sub _snippet ($endpoint) {
131
+ my $ep = _js_str($endpoint);
132
+ my $sk = _js_str($SCROLL_STORAGE_KEY);
133
+ # Small IIFE: EventSource subscriber + scrollY preservation. Idempotent
134
+ # across duplicate mounts (window.__bfDevReload guard).
135
+ return qq{<script>(function(){if(window.__bfDevReload)return;window.__bfDevReload=1;try{var s=sessionStorage.getItem($sk);if(s){sessionStorage.removeItem($sk);var y=parseInt(s,10);if(!isNaN(y)){var restore=function(){window.scrollTo(0,y)};if(document.readyState==='loading'){addEventListener('DOMContentLoaded',restore,{once:true})}else{restore()}}}}catch(e){}var es=new EventSource($ep);es.addEventListener('reload',function(){try{sessionStorage.setItem($sk,String(window.scrollY))}catch(e){}location.reload()});es.addEventListener('error',function(){})})();</script>};
136
+ }
137
+
138
+ sub _js_str ($s) {
139
+ # Minimal JS string escape for the handful of characters that can appear
140
+ # in a URL path or storage key. Good enough for package-internal + trusted
141
+ # operator-supplied strings; never interpolate untrusted input here.
142
+ my $t = $s;
143
+ $t =~ s/\\/\\\\/g;
144
+ $t =~ s/"/\\"/g;
145
+ $t =~ s/\n/\\n/g;
146
+ $t =~ s/\r/\\r/g;
147
+ return qq{"$t"};
148
+ }
149
+
150
+ 1;
@@ -0,0 +1,104 @@
1
+ package Mojolicious::Plugin::BarefootJS;
2
+ use Mojo::Base 'Mojolicious::Plugin', -signatures;
3
+
4
+ use Mojo::File qw(path);
5
+ use Mojo::JSON qw(decode_json);
6
+
7
+ use BarefootJS;
8
+
9
+ # Plugin entry point. Wires up:
10
+ #
11
+ # 1. The `bf` controller helper. Lazily instantiates one
12
+ # BarefootJS object per request and stashes it under
13
+ # `bf.instance`.
14
+ #
15
+ # 2. A `before_render` hook that, when the rendered template name
16
+ # matches a top-level component in the build manifest, fills the
17
+ # heavy boilerplate the user previously hand-rolled in `app.pl`:
18
+ # generates the scope id, registers every UI-registry child
19
+ # renderer from the manifest, and seeds the stash with each
20
+ # template variable's static default (issue #1416).
21
+ #
22
+ # Configuration (all optional):
23
+ # - manifest_path: absolute path to the `bf build`-emitted
24
+ # `manifest.json`. Defaults to `<app->home>/dist/templates/manifest.json`.
25
+ # Pass `undef` to disable manifest-driven auto-init entirely; the
26
+ # bf helper is still installed and callers can drive everything
27
+ # manually as before.
28
+ sub register ($self, $app, $config = {}) {
29
+ $app->helper(bf => sub ($c) {
30
+ $c->stash->{'bf.instance'} //= BarefootJS->new($c, $config);
31
+ });
32
+
33
+ my $manifest = _load_manifest($app, $config);
34
+ return unless $manifest;
35
+
36
+ # Cache the set of UI-registry slot keys so we can answer
37
+ # "is this template name a child or a top-level page?" with a
38
+ # single hash lookup at render time. Top-level entries are
39
+ # everything that isn't `__barefoot__` and doesn't match
40
+ # `ui/<name>/index` — the same partition `register_components_from_manifest`
41
+ # applies internally.
42
+ my %is_child_entry;
43
+ for my $entry_name (keys %$manifest) {
44
+ next if $entry_name eq '__barefoot__';
45
+ next unless $entry_name =~ m{^ui/[^/]+/index$};
46
+ $is_child_entry{$entry_name} = 1;
47
+ }
48
+
49
+ $app->hook(before_render => sub ($c, $args) {
50
+ my $template = $args->{template};
51
+ return unless defined $template && length $template;
52
+ my $entry = $manifest->{$template};
53
+ return unless $entry;
54
+ return if $is_child_entry{$template};
55
+ # Idempotency guard for nested renders. A controller might
56
+ # call `render_to_string` inside an action and then `render`
57
+ # — without this we'd re-init `bf` on the second pass and
58
+ # wipe the script registrations the first pass collected.
59
+ return if $c->stash->{'bf.auto_init_done'};
60
+
61
+ # Escape hatch for callers that wire `bf` up by hand (the
62
+ # existing `render_component` helper in the showcase app does
63
+ # this). If `_scope_id` is already set we treat the request as
64
+ # "manually managed" and leave it alone — same outcome as
65
+ # before the plugin gained auto-init.
66
+ my $bf = $c->bf;
67
+ if (defined $bf->_scope_id && length $bf->_scope_id) {
68
+ $c->stash->{'bf.auto_init_done'} = 1;
69
+ return;
70
+ }
71
+ $c->stash->{'bf.auto_init_done'} = 1;
72
+
73
+ $bf->_scope_id($template . '_' . substr(rand() =~ s/^0\.//r, 0, 6));
74
+ $bf->register_components_from_manifest($manifest);
75
+
76
+ # Seed each ssrDefault into the stash unless the caller has
77
+ # already supplied a value for that key — callers always win.
78
+ my $defaults = $entry->{ssrDefaults};
79
+ if (ref($defaults) eq 'HASH') {
80
+ for my $name (keys %$defaults) {
81
+ next if exists $c->stash->{$name};
82
+ my $d = $defaults->{$name};
83
+ my $value = ref($d) eq 'HASH' ? $d->{value} : $d;
84
+ $c->stash->{$name} = $value;
85
+ }
86
+ }
87
+ });
88
+ }
89
+
90
+ sub _load_manifest ($app, $config) {
91
+ return undef if exists $config->{manifest_path} && !defined $config->{manifest_path};
92
+ my $manifest_path = $config->{manifest_path}
93
+ // $app->home->child('dist/templates/manifest.json');
94
+ my $file = path($manifest_path);
95
+ return undef unless -r $file;
96
+ my $manifest = eval { decode_json($file->slurp) };
97
+ if ($@ || ref($manifest) ne 'HASH') {
98
+ $app->log->warn("BarefootJS: cannot parse manifest at $file: $@") if $@;
99
+ return undef;
100
+ }
101
+ return $manifest;
102
+ }
103
+
104
+ 1;
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@barefootjs/mojolicious",
3
+ "version": "0.1.0",
4
+ "description": "Mojolicious EP template adapter for BarefootJS - generates .html.ep files from IR",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./adapter": {
14
+ "types": "./dist/adapter/index.d.ts",
15
+ "import": "./dist/adapter/index.js"
16
+ },
17
+ "./test-render": {
18
+ "bun": "./src/test-render.ts"
19
+ },
20
+ "./build": {
21
+ "types": "./dist/build.d.ts",
22
+ "import": "./dist/build.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src",
28
+ "lib"
29
+ ],
30
+ "scripts": {
31
+ "build": "bun run build:js && bun run build:types",
32
+ "build:js": "bun build ./src/index.ts ./src/adapter/index.ts ./src/build.ts --root ./src --outdir ./dist --format esm --external @barefootjs/jsx --external @barefootjs/shared",
33
+ "build:types": "tsgo --emitDeclarationOnly --outDir ./dist",
34
+ "test": "bun test",
35
+ "clean": "rm -rf dist",
36
+ "prepack": "node ../../scripts/swap-publish-config.mjs pack",
37
+ "postpack": "node ../../scripts/swap-publish-config.mjs unpack"
38
+ },
39
+ "keywords": [
40
+ "mojolicious",
41
+ "mojo",
42
+ "perl",
43
+ "template",
44
+ "barefoot",
45
+ "ssr"
46
+ ],
47
+ "author": "kobaken <kentafly88@gmail.com>",
48
+ "license": "MIT",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "https://github.com/piconic-ai/barefootjs",
52
+ "directory": "packages/adapter-mojolicious"
53
+ },
54
+ "dependencies": {
55
+ "@barefootjs/shared": "workspace:*"
56
+ },
57
+ "peerDependencies": {
58
+ "@barefootjs/jsx": "workspace:*"
59
+ },
60
+ "devDependencies": {
61
+ "@barefootjs/adapter-tests": "workspace:*",
62
+ "@barefootjs/jsx": "workspace:*",
63
+ "typescript": "^5.0.0"
64
+ }
65
+ }