@barefootjs/mojolicious 0.6.1 → 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/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/adapter/mojo-adapter.ts
2
+ import ts from "typescript";
2
3
  import {
3
4
  BaseAdapter,
4
5
  isBooleanAttr,
@@ -80,6 +81,22 @@ function resolveJsxChildrenProp(props) {
80
81
  return [];
81
82
  return prop.value.children;
82
83
  }
84
+ function parsePureStringLiteral(source) {
85
+ const sf = ts.createSourceFile("__const.ts", `const __x = (${source});`, ts.ScriptTarget.Latest, false);
86
+ const stmt = sf.statements[0];
87
+ if (!stmt || !ts.isVariableStatement(stmt))
88
+ return null;
89
+ const decl = stmt.declarationList.declarations[0];
90
+ let init = decl?.initializer;
91
+ while (init && ts.isParenthesizedExpression(init))
92
+ init = init.expression;
93
+ if (!init)
94
+ return null;
95
+ if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
96
+ return init.text;
97
+ }
98
+ return null;
99
+ }
83
100
 
84
101
  class MojoAdapter extends BaseAdapter {
85
102
  name = "mojolicious";
@@ -94,6 +111,9 @@ class MojoAdapter extends BaseAdapter {
94
111
  propsObjectName = null;
95
112
  propsParams = [];
96
113
  stringValueNames = new Set;
114
+ moduleStringConsts = new Map;
115
+ loopBoundNames = new Map;
116
+ nullableOptionalProps = new Set;
97
117
  constructor(options = {}) {
98
118
  super();
99
119
  this.options = {
@@ -105,6 +125,7 @@ class MojoAdapter extends BaseAdapter {
105
125
  this.componentName = ir.metadata.componentName;
106
126
  this.propsObjectName = ir.metadata.propsObjectName ?? null;
107
127
  this.propsParams = ir.metadata.propsParams.map((p) => ({ name: p.name }));
128
+ this.nullableOptionalProps = new Set(ir.metadata.propsParams.filter((p) => p.defaultValue === undefined && !p.isRest && p.type?.kind !== "primitive").map((p) => p.name));
108
129
  this.stringValueNames = new Set;
109
130
  for (const s of ir.metadata.signals) {
110
131
  if (isStringTypeInfo(s.type) || isBareStringLiteral(s.initialValue)) {
@@ -115,6 +136,8 @@ class MojoAdapter extends BaseAdapter {
115
136
  if (isStringTypeInfo(p.type))
116
137
  this.stringValueNames.add(p.name);
117
138
  }
139
+ this.moduleStringConsts = this.collectModuleStringConsts(ir.metadata.localConstants);
140
+ this.loopBoundNames.clear();
118
141
  this.errors = [];
119
142
  this.childrenCaptureCounter = 0;
120
143
  if (!options?.siblingTemplatesRegistered) {
@@ -139,6 +162,27 @@ class MojoAdapter extends BaseAdapter {
139
162
  extension: this.extension
140
163
  };
141
164
  }
165
+ collectModuleStringConsts(constants) {
166
+ const map = new Map;
167
+ for (const c of constants ?? []) {
168
+ if (!c.isModule)
169
+ continue;
170
+ if (c.value === undefined)
171
+ continue;
172
+ const literal = parsePureStringLiteral(c.value);
173
+ if (literal !== null)
174
+ map.set(c.name, literal);
175
+ }
176
+ return map;
177
+ }
178
+ resolveModuleStringConst(name) {
179
+ if (this.loopBoundNames.has(name))
180
+ return null;
181
+ const value = this.moduleStringConsts.get(name);
182
+ if (value === undefined)
183
+ return null;
184
+ return `'${value.replace(/[\\']/g, (m) => `\\${m}`)}'`;
185
+ }
142
186
  generateScriptRegistrations(ir, scriptBaseName) {
143
187
  const hasInteractivity = this.hasClientInteractivity(ir);
144
188
  if (!hasInteractivity)
@@ -405,6 +449,10 @@ ${whenTrue}
405
449
  }
406
450
  const param = loop.param;
407
451
  const indexVar = loop.iterationShape === "keys" ? `$${param}` : loop.index ? `$${loop.index}` : "$_i";
452
+ const loopBound = loop.iterationShape === "keys" ? [param] : [param, loop.index ?? "_i"];
453
+ for (const n of loopBound) {
454
+ this.loopBoundNames.set(n, (this.loopBoundNames.get(n) ?? 0) + 1);
455
+ }
408
456
  const prevInLoop = this.inLoop;
409
457
  this.inLoop = true;
410
458
  const renderedChildren = this.renderChildren(loop.children);
@@ -438,6 +486,13 @@ ${renderedChildren}` : renderedChildren;
438
486
  } else {
439
487
  lines.push(children);
440
488
  }
489
+ for (const n of loopBound) {
490
+ const c = (this.loopBoundNames.get(n) ?? 1) - 1;
491
+ if (c <= 0)
492
+ this.loopBoundNames.delete(n);
493
+ else
494
+ this.loopBoundNames.set(n, c);
495
+ }
441
496
  lines.push(`% }`);
442
497
  lines.push(`<%== bf->comment("/loop:${loop.markerId}") %>`);
443
498
  return lines.join(`
@@ -529,6 +584,12 @@ ${children}`;
529
584
  if (this.refuseUnsupportedAttrExpression(value.expr, name)) {
530
585
  return "";
531
586
  }
587
+ const bareId = value.expr.trim();
588
+ if (!isBooleanAttr(name) && !value.presenceOrUndefined && this.nullableOptionalProps.has(bareId)) {
589
+ const perl2 = this.convertExpressionToPerl(value.expr);
590
+ const body = isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name) ? `${name}="<%= bf->bool_str(${perl2}) %>"` : `${name}="<%= ${perl2} %>"`;
591
+ return `<% if (defined ${perl2}) { %>${body}<% } %>`;
592
+ }
532
593
  if (isBooleanAttr(name) || value.presenceOrUndefined) {
533
594
  return `<%= ${this.convertExpressionToPerl(value.expr)} ? '${name}' : '' %>`;
534
595
  }
@@ -549,6 +610,10 @@ ${children}`;
549
610
  const entries = this.propsParams.map((p) => `${JSON.stringify(p.name)} => $${p.name}`);
550
611
  return `<%== bf->spread_attrs({${entries.join(", ")}}) %>`;
551
612
  }
613
+ const ternaryHashref = this.conditionalSpreadToPerl(trimmed);
614
+ if (ternaryHashref !== null) {
615
+ return `<%== bf->spread_attrs(${ternaryHashref}) %>`;
616
+ }
552
617
  const perlExpr = this.convertExpressionToPerl(value.expr);
553
618
  return `<%== bf->spread_attrs(${perlExpr}) %>`;
554
619
  },
@@ -713,6 +778,54 @@ ${reason}` : "";
713
778
  });
714
779
  return true;
715
780
  }
781
+ conditionalSpreadToPerl(expr) {
782
+ const sf = ts.createSourceFile("__spread.ts", `(${expr})`, ts.ScriptTarget.Latest, true);
783
+ if (sf.statements.length !== 1)
784
+ return null;
785
+ const stmt = sf.statements[0];
786
+ if (!ts.isExpressionStatement(stmt))
787
+ return null;
788
+ let node = stmt.expression;
789
+ while (ts.isParenthesizedExpression(node))
790
+ node = node.expression;
791
+ if (!ts.isConditionalExpression(node))
792
+ return null;
793
+ const unwrap = (e) => {
794
+ let n = e;
795
+ while (ts.isParenthesizedExpression(n))
796
+ n = n.expression;
797
+ return n;
798
+ };
799
+ const whenTrue = unwrap(node.whenTrue);
800
+ const whenFalse = unwrap(node.whenFalse);
801
+ if (!ts.isObjectLiteralExpression(whenTrue) || !ts.isObjectLiteralExpression(whenFalse)) {
802
+ return null;
803
+ }
804
+ const condPerl = this.convertExpressionToPerl(node.condition.getText(sf));
805
+ const truePerl = this.objectLiteralToPerlHashref(whenTrue, sf);
806
+ const falsePerl = this.objectLiteralToPerlHashref(whenFalse, sf);
807
+ if (truePerl === null || falsePerl === null)
808
+ return null;
809
+ return `${condPerl} ? ${truePerl} : ${falsePerl}`;
810
+ }
811
+ objectLiteralToPerlHashref(obj, sf) {
812
+ const entries = [];
813
+ for (const prop of obj.properties) {
814
+ if (!ts.isPropertyAssignment(prop))
815
+ return null;
816
+ let key;
817
+ if (ts.isIdentifier(prop.name)) {
818
+ key = prop.name.text;
819
+ } else if (ts.isStringLiteral(prop.name) || ts.isNoSubstitutionTemplateLiteral(prop.name)) {
820
+ key = prop.name.text;
821
+ } else {
822
+ return null;
823
+ }
824
+ const valPerl = this.convertExpressionToPerl(prop.initializer.getText(sf));
825
+ entries.push(`'${key.replace(/'/g, "\\'")}' => ${valPerl}`);
826
+ }
827
+ return entries.length === 0 ? "{}" : `{ ${entries.join(", ")} }`;
828
+ }
716
829
  convertExpressionToPerl(expr) {
717
830
  const trimmed = expr.trim();
718
831
  if (trimmed === "")
@@ -1056,6 +1169,9 @@ class MojoTopLevelEmitter {
1056
1169
  this.adapter = adapter;
1057
1170
  }
1058
1171
  identifier(name) {
1172
+ const inlined = this.adapter.resolveModuleStringConst(name);
1173
+ if (inlined !== null)
1174
+ return inlined;
1059
1175
  return `$${name}`;
1060
1176
  }
1061
1177
  literal(value, literalType) {
@@ -1 +1 @@
1
- {"version":3,"file":"test-render.d.ts","sourceRoot":"","sources":["../src/test-render.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAUH,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAKxE;AAkCD,MAAM,WAAW,aAAa;IAC5B,sBAAsB;IACtB,MAAM,EAAE,MAAM,CAAA;IACd,8BAA8B;IAC9B,OAAO,EAAE,OAAO,iBAAiB,EAAE,eAAe,CAAA;IAClD,iCAAiC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC/B,qDAAqD;IACrD,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACpC;AAED,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CA2MjF;AAgND;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,OAAO,CAuBT"}
1
+ {"version":3,"file":"test-render.d.ts","sourceRoot":"","sources":["../src/test-render.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAeH,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAKxE;AAkCD,MAAM,WAAW,aAAa;IAC5B,sBAAsB;IACtB,MAAM,EAAE,MAAM,CAAA;IACd,8BAA8B;IAC9B,OAAO,EAAE,OAAO,iBAAiB,EAAE,eAAe,CAAA;IAClD,iCAAiC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC/B,qDAAqD;IACrD,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACpC;AAED,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CA2MjF;AAsOD;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,OAAO,CAuBT"}
@@ -0,0 +1,91 @@
1
+ package BarefootJS::Backend::Mojo;
2
+ our $VERSION = "0.01";
3
+ use Mojo::Base -base, -signatures;
4
+
5
+ use Mojo::ByteStream qw(b);
6
+ use Mojo::JSON qw(to_json);
7
+ use Scalar::Util qw(weaken);
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Reference rendering backend (Mojolicious / Mojo::Template).
11
+ # ---------------------------------------------------------------------------
12
+ #
13
+ # BarefootJS.pm holds all the template-engine-agnostic logic (the JS-compat
14
+ # value helpers, array/string methods, hydration markers). Everything that is
15
+ # specific to *how a template is rendered* — JSON marshalling, raw-string
16
+ # marking, JSX-children materialisation, and named-template rendering — lives
17
+ # behind this backend object so the same runtime can drive a different Perl
18
+ # template engine (Text::Xslate, Template Toolkit, …) without rewriting the
19
+ # helper surface.
20
+ #
21
+ # A backend MUST implement:
22
+ # - encode_json($data) -> string
23
+ # - mark_raw($str) -> value the engine emits without escaping
24
+ # - materialize($value) -> string (resolve a captured-children ref)
25
+ # - render_named($name, $bf, \%vars) -> string
26
+ #
27
+ # This Mojo implementation is the reference. To target another engine, write a
28
+ # sibling backend (BarefootJS::Backend::Xslate, …) implementing the same four
29
+ # methods and pass it via `BarefootJS->new($c, { backend => $b })`.
30
+
31
+ # The Mojolicious controller. Optional: the value-marshalling helpers
32
+ # (`encode_json` / `mark_raw` / `materialize`) work without it; only
33
+ # `render_named` reaches into the controller's renderer + stash.
34
+ has 'c';
35
+
36
+ # Pluggable JSON encoder (#engine-abstraction). Defaults to
37
+ # `Mojo::JSON::to_json`, which returns a *character* string (not bytes)
38
+ # suitable for embedding in HTML output via `<%==` / Mojo::ByteStream.
39
+ #
40
+ # Override with any `sub ($data) { ... }` to swap in a faster XS encoder —
41
+ # e.g. `json_encoder => sub { Cpanel::JSON::XS->new->canonical->encode($_[0]) }`.
42
+ # The pure-Perl JSON::PP fallback Mojo::JSON uses can be a hot spot for large
43
+ # props payloads; the seam lets a host pick its own implementation without
44
+ # touching the runtime.
45
+ has 'json_encoder' => sub { \&to_json };
46
+
47
+ # Hold the controller weakly for the same reason BarefootJS does: the
48
+ # controller owns the bf instance (which owns this backend) via its stash,
49
+ # so a strong back-reference would close a per-request cycle the refcount GC
50
+ # can't reclaim. `render_named` only touches `$self->c` mid-render, while the
51
+ # controller is still alive on the request stack.
52
+ sub new ($class, %args) {
53
+ my $self = $class->SUPER::new(%args);
54
+ weaken($self->{c}) if $self->{c};
55
+ return $self;
56
+ }
57
+
58
+ sub encode_json ($self, $data) {
59
+ return $self->json_encoder->($data);
60
+ }
61
+
62
+ # Mark a string as already-safe so the template engine emits it verbatim
63
+ # (no re-escaping). In Mojo this is a Mojo::ByteStream, which the calling
64
+ # template's `<%==` raw-emit passes through unescaped.
65
+ sub mark_raw ($self, $str) {
66
+ return b($str);
67
+ }
68
+
69
+ # JSX children / async fallbacks arrive via Mojo's `begin %>...<% end`
70
+ # capture, which produces a CODE ref returning a Mojo::ByteStream. Resolve
71
+ # it to a string before embedding. Plain (already-rendered) strings pass
72
+ # through unchanged.
73
+ sub materialize ($self, $value) {
74
+ return ref($value) eq 'CODE' ? $value->() : $value;
75
+ }
76
+
77
+ # Render a named template with `$child_bf` bound as the active runtime
78
+ # instance for that render. The Mojo `bf` helper resolves the current
79
+ # instance off `$c->stash->{'bf.instance'}`; swap it for the duration of
80
+ # the nested render and restore it afterwards so sibling renders are
81
+ # unaffected.
82
+ sub render_named ($self, $template_name, $child_bf, $vars) {
83
+ my $c = $self->c;
84
+ my $prev = $c->stash->{'bf.instance'};
85
+ $c->stash->{'bf.instance'} = $child_bf;
86
+ my $html = $c->render_to_string(template => $template_name, %$vars);
87
+ $c->stash->{'bf.instance'} = $prev;
88
+ return $html;
89
+ }
90
+
91
+ 1;
@@ -1,4 +1,5 @@
1
1
  package Mojolicious::Plugin::BarefootJS::DevReload;
2
+ our $VERSION = "0.01";
2
3
  use Mojo::Base 'Mojolicious::Plugin', -signatures;
3
4
 
4
5
  =head1 NAME
@@ -29,21 +30,12 @@ C<< enabled => 1 >> to force-enable.
29
30
  use Mojo::ByteStream qw(b);
30
31
  use Mojo::IOLoop;
31
32
  use File::Spec;
33
+ use BarefootJS::DevReload ();
32
34
 
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;
35
+ # Engine-agnostic snippet, build-id reading, and timing constants are shared
36
+ # with the PSGI/Plack path in BarefootJS::DevReload — one source of truth.
37
+ my $HEARTBEAT_S = $BarefootJS::DevReload::HEARTBEAT_S;
38
+ my $POLL_S = $BarefootJS::DevReload::POLL_S;
47
39
 
48
40
  sub register ($self, $app, $config = {}) {
49
41
  my $dist_dir = $config->{dist_dir} // 'dist';
@@ -56,7 +48,7 @@ sub register ($self, $app, $config = {}) {
56
48
  # on mode — it simply returns an empty ByteStream when disabled.
57
49
  $app->helper(bf_dev_snippet => sub ($c) {
58
50
  return b('') unless $enabled;
59
- return b(_snippet($endpoint));
51
+ return b(BarefootJS::DevReload->snippet($endpoint));
60
52
  });
61
53
 
62
54
  return unless $enabled;
@@ -67,9 +59,8 @@ sub register ($self, $app, $config = {}) {
67
59
  my $dist_abs = File::Spec->file_name_is_absolute($dist_dir)
68
60
  ? $dist_dir
69
61
  : $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;
62
+ BarefootJS::DevReload->ensure_dev_dir($dist_abs);
63
+ my $build_id_path = BarefootJS::DevReload->build_id_path($dist_abs);
73
64
 
74
65
  $app->routes->get($endpoint => sub ($c) {
75
66
  my $last_event_id = $c->req->headers->header('Last-Event-ID') // '';
@@ -82,7 +73,7 @@ sub register ($self, $app, $config = {}) {
82
73
 
83
74
  $c->write("retry: 1000\n\n");
84
75
 
85
- my $initial_id = _read_build_id($build_id_path);
76
+ my $initial_id = BarefootJS::DevReload->read_build_id($build_id_path);
86
77
  my $last_sent = '';
87
78
  if (length $initial_id) {
88
79
  $last_sent = $initial_id;
@@ -105,7 +96,7 @@ sub register ($self, $app, $config = {}) {
105
96
  $c->write(": hb\n\n");
106
97
  });
107
98
  $poll_id = Mojo::IOLoop->recurring($POLL_S => sub {
108
- my $id = _read_build_id($build_id_path);
99
+ my $id = BarefootJS::DevReload->read_build_id($build_id_path);
109
100
  return unless length $id;
110
101
  return if $id eq $last_sent;
111
102
  $last_sent = $id;
@@ -116,35 +107,4 @@ sub register ($self, $app, $config = {}) {
116
107
  return;
117
108
  }
118
109
 
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
110
  1;
@@ -1,4 +1,5 @@
1
1
  package Mojolicious::Plugin::BarefootJS;
2
+ our $VERSION = "0.01";
2
3
  use Mojo::Base 'Mojolicious::Plugin', -signatures;
3
4
 
4
5
  use Mojo::File qw(path);
@@ -102,3 +103,58 @@ sub _load_manifest ($app, $config) {
102
103
  }
103
104
 
104
105
  1;
106
+ __END__
107
+
108
+ =encoding utf8
109
+
110
+ =head1 NAME
111
+
112
+ Mojolicious::Plugin::BarefootJS - Mojolicious integration for BarefootJS
113
+
114
+ =head1 SYNOPSIS
115
+
116
+ # Mojolicious application
117
+ $self->plugin('BarefootJS');
118
+
119
+ # In a controller / template, the `bf` helper exposes a per-request
120
+ # BarefootJS runtime backed by BarefootJS::Backend::Mojo.
121
+
122
+ =head1 DESCRIPTION
123
+
124
+ Wires the L<BarefootJS> server runtime into L<Mojolicious>. It registers a
125
+ C<bf> controller helper that lazily instantiates one BarefootJS object per
126
+ request (rendering via L<BarefootJS::Backend::Mojo>), and supports rendering
127
+ compiled marked templates as Mojolicious templates.
128
+
129
+ For non-Mojolicious / PSGI hosts, see L<BarefootJS::Backend::Xslate>, which
130
+ drives the same runtime with Text::Xslate and no web framework.
131
+
132
+ =head1 METHODS
133
+
134
+ L<Mojolicious::Plugin::BarefootJS> inherits all methods from
135
+ L<Mojolicious::Plugin> and implements the following new one.
136
+
137
+ =head2 register
138
+
139
+ $plugin->register(Mojolicious->new, \%conf);
140
+
141
+ Registers the plugin (the C<bf> helper and supporting hooks) in a Mojolicious
142
+ application.
143
+
144
+ =head1 SEE ALSO
145
+
146
+ L<BarefootJS>, L<BarefootJS::Backend::Mojo>, L<BarefootJS::Backend::Xslate>,
147
+ L<Mojolicious>, L<https://github.com/piconic-ai/barefootjs>
148
+
149
+ =head1 AUTHOR
150
+
151
+ kobaken E<lt>kentafly88@gmail.comE<gt>
152
+
153
+ =head1 LICENSE
154
+
155
+ Copyright (c) 2025-present BarefootJS Contributors.
156
+
157
+ This library is free software; you can redistribute it and/or modify it under
158
+ the MIT License. See the F<LICENSE> file in the distribution for the full text.
159
+
160
+ =cut
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/mojolicious",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "Mojolicious EP template adapter for BarefootJS - generates .html.ep files from IR",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -29,7 +29,7 @@
29
29
  ],
30
30
  "scripts": {
31
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",
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 --external typescript",
33
33
  "build:types": "tsgo --emitDeclarationOnly --outDir ./dist",
34
34
  "test": "bun test",
35
35
  "clean": "rm -rf dist",
@@ -52,14 +52,15 @@
52
52
  "directory": "packages/adapter-mojolicious"
53
53
  },
54
54
  "dependencies": {
55
- "@barefootjs/shared": "0.6.1"
55
+ "@barefootjs/perl": "0.8.0",
56
+ "@barefootjs/shared": "0.8.0"
56
57
  },
57
58
  "peerDependencies": {
58
- "@barefootjs/jsx": ">=0.2.0"
59
+ "@barefootjs/jsx": ">=0.2.0",
60
+ "typescript": "^5.0.0"
59
61
  },
60
62
  "devDependencies": {
61
63
  "@barefootjs/adapter-tests": "0.1.0",
62
- "@barefootjs/jsx": "0.6.1",
63
- "typescript": "^5.0.0"
64
+ "@barefootjs/jsx": "0.8.0"
64
65
  }
65
66
  }
@@ -69,16 +69,24 @@ runAdapterConformanceTests({
69
69
  'toggle-shared',
70
70
  'reactive-props',
71
71
  'props-reactivity-comparison',
72
- // #1467 Phase 2a: first `site/ui` source-root fixture. Button
73
- // compiles cleanly on the Mojo adapter (no diagnostic), but its
74
- // variant/size class composition (`Record<…>[key]` indexed object
75
- // literals) and `applyRestAttrs` spread haven't been validated for
76
- // byte-parity against the Hono reference under Mojo's template
77
- // semantics. Cross-adapter parity for the `site/ui` corpus is
78
- // explicitly Phase 3 ("cross-adapter parity (Mojo/Go templates)"),
79
- // so Button participates only in Hono SSR conformance + the
80
- // fixture-hydrate runtime layer for now.
81
- 'button',
72
+ // #1467 Phase 2b: basic interactive `site/ui` primitives. Cross-adapter
73
+ // parity for the `site/ui` corpus is Phase 3, so these participate only
74
+ // in Hono SSR conformance + the fixture-hydrate runtime layer for now.
75
+ // (`label` and `kbd` are static helpers; `toggle` / `switch` /
76
+ // `checkbox` carry uncontrolled state; `input` is a pass-through
77
+ // native control.)
78
+ //
79
+ // `textarea` now participates: its conditional inline-object spread
80
+ // (`{...(describedBy ? {...} : {})}`) lowers via
81
+ // `conditionalSpreadToPerl`, and its optional `rows={rows}`
82
+ // attribute is omitted when `undef` via the `defined`-guard in
83
+ // `elementAttrEmitter.emitExpression`
84
+ // (`<% if (defined $rows) { %>rows="<%= $rows %>"<% } %>`), matching
85
+ // Hono's nullish-attribute omission.
86
+ 'toggle',
87
+ 'switch',
88
+ 'checkbox',
89
+ 'kbd',
82
90
  ],
83
91
  // Per-fixture build-time contracts for shapes the Mojo adapter
84
92
  // intentionally refuses to lower. Owned by this adapter test file
@@ -239,6 +247,76 @@ function compileAndGenerate(source: string, adapter?: MojoAdapter) {
239
247
  // Mojo-Specific Tests
240
248
  // =============================================================================
241
249
 
250
+ describe('MojoAdapter - conditional inline-object spread (textarea aria-describedby)', () => {
251
+ // `{...(cond ? { 'aria-describedby': cond } : {})}` lowers to a Perl
252
+ // inline ternary of hashrefs so the falsy `{}` branch OMITS the key
253
+ // (bf->spread_attrs does not filter empty strings). The shared
254
+ // fixture only exercises the falsy branch; this pins the truthy one.
255
+ test('emits a Perl inline ternary of hashrefs through bf->spread_attrs', () => {
256
+ const { template } = compileAndGenerate(`
257
+ function Box({ describedBy }: { describedBy?: string }) {
258
+ return <div {...(describedBy ? { 'aria-describedby': describedBy } : {})} />
259
+ }
260
+ `)
261
+ expect(template).toContain(
262
+ "bf->spread_attrs($describedBy ? { 'aria-describedby' => $describedBy } : {})",
263
+ )
264
+ })
265
+
266
+ test('resolves the value reference and preserves the static key for a second prop', () => {
267
+ const { template } = compileAndGenerate(`
268
+ function Box({ label }: { label: string }) {
269
+ return <div {...(label ? { 'data-label': label } : {})} />
270
+ }
271
+ `)
272
+ expect(template).toContain(
273
+ "bf->spread_attrs($label ? { 'data-label' => $label } : {})",
274
+ )
275
+ })
276
+
277
+ test('falls back to BF101 for a computed (non-static) object key', () => {
278
+ const adapter = new MojoAdapter()
279
+ const ir = compileToIR(`
280
+ function Box({ k, v }: { k?: string; v?: string }) {
281
+ return <div {...(v ? { [k]: v } : {})} />
282
+ }
283
+ `, adapter)
284
+ adapter.generate(ir)
285
+ const errs = (adapter as unknown as { errors: { code: string }[] }).errors
286
+ expect(errs.some(e => e.code === 'BF101')).toBe(true)
287
+ })
288
+ })
289
+
290
+ describe('MojoAdapter - nullish optional-attribute omission (textarea rows)', () => {
291
+ // A no-destructure-default, nillable-typed prop is `undef` when the
292
+ // caller omits it; guard its bare-reference attribute with Perl
293
+ // `defined` so it DROPS instead of rendering `attr=""` — matching
294
+ // Hono's nullish-attribute omission. Concrete/defaulted props are
295
+ // never `undef` and stay unconditional.
296
+ test('guards a no-default nillable attr with a Perl defined check', () => {
297
+ const { template } = compileAndGenerate(`
298
+ function C({ rows }: { rows?: number }) {
299
+ return <textarea rows={rows} />
300
+ }
301
+ `)
302
+ expect(template).toContain('<% if (defined $rows) { %>rows="<%= $rows %>"<% } %>')
303
+ // Must NOT emit the bare unconditional form.
304
+ expect(template).not.toMatch(/(?<!\{ %>)rows="<%= \$rows %>"/)
305
+ })
306
+
307
+ test('leaves a defaulted attr unconditional (scope did not widen)', () => {
308
+ const { template } = compileAndGenerate(`
309
+ function C({ value = '' }: { value?: string }) {
310
+ return <textarea value={value} />
311
+ }
312
+ `)
313
+ // `value` has a destructure default → never undef → unconditional,
314
+ // exactly like Hono's value="".
315
+ expect(template).toContain('value="<%= $value %>"')
316
+ expect(template).not.toContain('defined $value')
317
+ })
318
+ })
319
+
242
320
  describe('MojoAdapter - Template Generation', () => {
243
321
  test('generates basic element with scope marker', () => {
244
322
  const result = compileAndGenerate(`
@@ -256,6 +334,42 @@ export function Hello() {
256
334
  expect(adapter.extension).toBe('.html.ep')
257
335
  })
258
336
 
337
+ test('module pure-string const referenced in className inlines the literal (#1467 Phase 2b)', () => {
338
+ // A module-scope `const X = 'literal'` used inside a className template
339
+ // literal must inline its value, NOT emit `$X` against a stash variable
340
+ // that is never bound (the value would render empty). Hono inlines it at
341
+ // runtime; this restores byte-parity.
342
+ const result = compileAndGenerate(`
343
+ "use client"
344
+ const labelClasses = 'flex items-center group-data-[disabled=true]:opacity-50'
345
+ export function Label({ className = '' }: { className?: string }) {
346
+ return <label className={\`\${labelClasses} \${className}\`} />
347
+ }
348
+ `)
349
+ // Inlined as a Perl single-quoted literal, escaped tokens intact.
350
+ expect(result.template).toContain(
351
+ "'flex items-center group-data-[disabled=true]:opacity-50'",
352
+ )
353
+ // No stash-variable reference to the const.
354
+ expect(result.template).not.toContain('$labelClasses')
355
+ })
356
+
357
+ test('module pure-string const is NOT inlined when shadowed by a loop variable (#1749 review)', () => {
358
+ // A loop param whose name matches a module const must keep its loop
359
+ // binding (`$label`) inside the body — the const literal must not leak
360
+ // in. `renderLoop` guards module-const inlining for the loop body.
361
+ const result = compileAndGenerate(`
362
+ "use client"
363
+ const label = 'MODULE_CONST'
364
+ export function List({ items }: { items: string[] }) {
365
+ return <ul>{items.map(label => <li>{label}</li>)}</ul>
366
+ }
367
+ `)
368
+ // Inside the loop the param wins — emit the loop variable, not the const.
369
+ expect(result.template).toContain('$label')
370
+ expect(result.template).not.toContain('MODULE_CONST')
371
+ })
372
+
259
373
  test('generates conditional with Perl if/else', () => {
260
374
  const result = compileAndGenerate(`
261
375
  "use client"