@barefootjs/mojolicious 0.7.0 → 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.
@@ -113,6 +113,7 @@ class MojoAdapter extends BaseAdapter {
113
113
  stringValueNames = new Set;
114
114
  moduleStringConsts = new Map;
115
115
  loopBoundNames = new Map;
116
+ nullableOptionalProps = new Set;
116
117
  constructor(options = {}) {
117
118
  super();
118
119
  this.options = {
@@ -124,6 +125,7 @@ class MojoAdapter extends BaseAdapter {
124
125
  this.componentName = ir.metadata.componentName;
125
126
  this.propsObjectName = ir.metadata.propsObjectName ?? null;
126
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));
127
129
  this.stringValueNames = new Set;
128
130
  for (const s of ir.metadata.signals) {
129
131
  if (isStringTypeInfo(s.type) || isBareStringLiteral(s.initialValue)) {
@@ -582,6 +584,12 @@ ${children}`;
582
584
  if (this.refuseUnsupportedAttrExpression(value.expr, name)) {
583
585
  return "";
584
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
+ }
585
593
  if (isBooleanAttr(name) || value.presenceOrUndefined) {
586
594
  return `<%= ${this.convertExpressionToPerl(value.expr)} ? '${name}' : '' %>`;
587
595
  }
@@ -602,6 +610,10 @@ ${children}`;
602
610
  const entries = this.propsParams.map((p) => `${JSON.stringify(p.name)} => $${p.name}`);
603
611
  return `<%== bf->spread_attrs({${entries.join(", ")}}) %>`;
604
612
  }
613
+ const ternaryHashref = this.conditionalSpreadToPerl(trimmed);
614
+ if (ternaryHashref !== null) {
615
+ return `<%== bf->spread_attrs(${ternaryHashref}) %>`;
616
+ }
605
617
  const perlExpr = this.convertExpressionToPerl(value.expr);
606
618
  return `<%== bf->spread_attrs(${perlExpr}) %>`;
607
619
  },
@@ -766,6 +778,54 @@ ${reason}` : "";
766
778
  });
767
779
  return true;
768
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
+ }
769
829
  convertExpressionToPerl(expr) {
770
830
  const trimmed = expr.trim();
771
831
  if (trimmed === "")
@@ -78,6 +78,23 @@ export declare class MojoAdapter extends BaseAdapter implements IRNodeEmitter<Mo
78
78
  * loop-var shadowing guards). (#1749 review)
79
79
  */
80
80
  private loopBoundNames;
81
+ /**
82
+ * Prop names whose value is `undef` in the template body when the caller
83
+ * omits them — so a bare-reference attribute should be dropped rather
84
+ * than rendered as `attr=""`. The actual population criterion (see
85
+ * `generate()`) is: NO destructure default (`defaultValue === undefined`)
86
+ * AND non-rest (`!isRest`) AND non-primitive type (`type.kind !==
87
+ * 'primitive'`). It deliberately does NOT consult `p.optional`: the
88
+ * analyzer derives `optional` from the presence of a default initializer,
89
+ * not the `?` token, so it's not the right witness here. Excluding
90
+ * concrete primitives (`string`/`number`/`boolean`) mirrors the Go
91
+ * adapter's scope, which guards only `interface{}` (nillable) fields.
92
+ * Used by `elementAttrEmitter.emitExpression` to guard such an attribute
93
+ * with a Perl `defined $x` check (`<textarea>` omits `rows`), matching
94
+ * Hono's nullish-attribute omission. Concrete/defaulted props are
95
+ * excluded and always emit unconditionally.
96
+ */
97
+ private nullableOptionalProps;
81
98
  constructor(options?: MojoAdapterOptions);
82
99
  generate(ir: ComponentIR, options?: AdapterGenerateOptions): AdapterOutput;
83
100
  /**
@@ -202,6 +219,32 @@ export declare class MojoAdapter extends BaseAdapter implements IRNodeEmitter<Mo
202
219
  * should drop the attribute / skip the emit).
203
220
  */
204
221
  private refuseUnsupportedAttrExpression;
222
+ /**
223
+ * Lower a conditional inline-object spread expression
224
+ * `(COND ? { 'aria-describedby': describedBy } : {})`
225
+ * (either branch possibly `{}`) into a Perl inline ternary of
226
+ * hashrefs for `bf->spread_attrs`:
227
+ * `$describedBy ? { 'aria-describedby' => $describedBy } : {}`
228
+ *
229
+ * The condition is translated via `convertExpressionToPerl` (a bare
230
+ * prop ident becomes `$describedBy`; Perl truthiness handles the
231
+ * test). Object literals become Perl hashrefs with `=>`; string-
232
+ * literal keys are quoted, values resolve via `convertExpressionToPerl`.
233
+ *
234
+ * Returns null when the expression is NOT this shape, or when a part
235
+ * can't be faithfully lowered (non-static key, etc.) so the caller
236
+ * falls back to the standard `convertExpressionToPerl` path (which
237
+ * records BF101). Scoped strictly to ternary-of-object-literals so no
238
+ * other spread shape regresses.
239
+ */
240
+ private conditionalSpreadToPerl;
241
+ /**
242
+ * Convert a static object literal into a Perl hashref string for a
243
+ * conditional spread. Only static string/identifier keys are allowed;
244
+ * values resolve via `convertExpressionToPerl`. Returns null for any
245
+ * computed/spread/dynamic key. Empty object → `{}`.
246
+ */
247
+ private objectLiteralToPerlHashref;
205
248
  private convertExpressionToPerl;
206
249
  /**
207
250
  * Render a full ParsedExpr tree to Perl for top-level (non-filter)
@@ -1 +1 @@
1
- {"version":3,"file":"mojo-adapter.d.ts","sourceRoot":"","sources":["../../src/adapter/mojo-adapter.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"mojo-adapter.d.ts","sourceRoot":"","sources":["../../src/adapter/mojo-adapter.ts"],"names":[],"mappings":"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"}
package/dist/build.js CHANGED
@@ -113,6 +113,7 @@ class MojoAdapter extends BaseAdapter {
113
113
  stringValueNames = new Set;
114
114
  moduleStringConsts = new Map;
115
115
  loopBoundNames = new Map;
116
+ nullableOptionalProps = new Set;
116
117
  constructor(options = {}) {
117
118
  super();
118
119
  this.options = {
@@ -124,6 +125,7 @@ class MojoAdapter extends BaseAdapter {
124
125
  this.componentName = ir.metadata.componentName;
125
126
  this.propsObjectName = ir.metadata.propsObjectName ?? null;
126
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));
127
129
  this.stringValueNames = new Set;
128
130
  for (const s of ir.metadata.signals) {
129
131
  if (isStringTypeInfo(s.type) || isBareStringLiteral(s.initialValue)) {
@@ -582,6 +584,12 @@ ${children}`;
582
584
  if (this.refuseUnsupportedAttrExpression(value.expr, name)) {
583
585
  return "";
584
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
+ }
585
593
  if (isBooleanAttr(name) || value.presenceOrUndefined) {
586
594
  return `<%= ${this.convertExpressionToPerl(value.expr)} ? '${name}' : '' %>`;
587
595
  }
@@ -602,6 +610,10 @@ ${children}`;
602
610
  const entries = this.propsParams.map((p) => `${JSON.stringify(p.name)} => $${p.name}`);
603
611
  return `<%== bf->spread_attrs({${entries.join(", ")}}) %>`;
604
612
  }
613
+ const ternaryHashref = this.conditionalSpreadToPerl(trimmed);
614
+ if (ternaryHashref !== null) {
615
+ return `<%== bf->spread_attrs(${ternaryHashref}) %>`;
616
+ }
605
617
  const perlExpr = this.convertExpressionToPerl(value.expr);
606
618
  return `<%== bf->spread_attrs(${perlExpr}) %>`;
607
619
  },
@@ -766,6 +778,54 @@ ${reason}` : "";
766
778
  });
767
779
  return true;
768
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
+ }
769
829
  convertExpressionToPerl(expr) {
770
830
  const trimmed = expr.trim();
771
831
  if (trimmed === "")
package/dist/index.js CHANGED
@@ -113,6 +113,7 @@ class MojoAdapter extends BaseAdapter {
113
113
  stringValueNames = new Set;
114
114
  moduleStringConsts = new Map;
115
115
  loopBoundNames = new Map;
116
+ nullableOptionalProps = new Set;
116
117
  constructor(options = {}) {
117
118
  super();
118
119
  this.options = {
@@ -124,6 +125,7 @@ class MojoAdapter extends BaseAdapter {
124
125
  this.componentName = ir.metadata.componentName;
125
126
  this.propsObjectName = ir.metadata.propsObjectName ?? null;
126
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));
127
129
  this.stringValueNames = new Set;
128
130
  for (const s of ir.metadata.signals) {
129
131
  if (isStringTypeInfo(s.type) || isBareStringLiteral(s.initialValue)) {
@@ -582,6 +584,12 @@ ${children}`;
582
584
  if (this.refuseUnsupportedAttrExpression(value.expr, name)) {
583
585
  return "";
584
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
+ }
585
593
  if (isBooleanAttr(name) || value.presenceOrUndefined) {
586
594
  return `<%= ${this.convertExpressionToPerl(value.expr)} ? '${name}' : '' %>`;
587
595
  }
@@ -602,6 +610,10 @@ ${children}`;
602
610
  const entries = this.propsParams.map((p) => `${JSON.stringify(p.name)} => $${p.name}`);
603
611
  return `<%== bf->spread_attrs({${entries.join(", ")}}) %>`;
604
612
  }
613
+ const ternaryHashref = this.conditionalSpreadToPerl(trimmed);
614
+ if (ternaryHashref !== null) {
615
+ return `<%== bf->spread_attrs(${ternaryHashref}) %>`;
616
+ }
605
617
  const perlExpr = this.convertExpressionToPerl(value.expr);
606
618
  return `<%== bf->spread_attrs(${perlExpr}) %>`;
607
619
  },
@@ -766,6 +778,54 @@ ${reason}` : "";
766
778
  });
767
779
  return true;
768
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
+ }
769
829
  convertExpressionToPerl(expr) {
770
830
  const trimmed = expr.trim();
771
831
  if (trimmed === "")
@@ -30,21 +30,12 @@ C<< enabled => 1 >> to force-enable.
30
30
  use Mojo::ByteStream qw(b);
31
31
  use Mojo::IOLoop;
32
32
  use File::Spec;
33
+ use BarefootJS::DevReload ();
33
34
 
34
- # Sentinel path contract with @barefootjs/cli (DEV_SENTINEL_SUBDIR /
35
- # DEV_SENTINEL_FILENAME in packages/cli/src/lib/build.ts). Duplicated so this
36
- # package avoids a runtime dep on the CLI — keep in sync with the CLI.
37
- my $DEV_SUBDIR = '.dev';
38
- my $BUILD_ID_FILE = 'build-id';
39
- my $SCROLL_STORAGE_KEY = '__bf_devreload_scroll';
40
-
41
- # Heartbeat < any reasonable proxy/IOLoop idle timeout so a quiet connection
42
- # doesn't get reaped between rebuilds.
43
- my $HEARTBEAT_S = 5;
44
-
45
- # Polling instead of Linux::Inotify2 / Mac::FSEvents keeps the runtime
46
- # dependency-free. Sub-second latency is imperceptible next to browser reload.
47
- 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;
48
39
 
49
40
  sub register ($self, $app, $config = {}) {
50
41
  my $dist_dir = $config->{dist_dir} // 'dist';
@@ -57,7 +48,7 @@ sub register ($self, $app, $config = {}) {
57
48
  # on mode — it simply returns an empty ByteStream when disabled.
58
49
  $app->helper(bf_dev_snippet => sub ($c) {
59
50
  return b('') unless $enabled;
60
- return b(_snippet($endpoint));
51
+ return b(BarefootJS::DevReload->snippet($endpoint));
61
52
  });
62
53
 
63
54
  return unless $enabled;
@@ -68,9 +59,8 @@ sub register ($self, $app, $config = {}) {
68
59
  my $dist_abs = File::Spec->file_name_is_absolute($dist_dir)
69
60
  ? $dist_dir
70
61
  : $app->home->child($dist_dir)->to_string;
71
- my $dev_dir = File::Spec->catdir($dist_abs, $DEV_SUBDIR);
72
- my $build_id_path = File::Spec->catfile($dev_dir, $BUILD_ID_FILE);
73
- 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);
74
64
 
75
65
  $app->routes->get($endpoint => sub ($c) {
76
66
  my $last_event_id = $c->req->headers->header('Last-Event-ID') // '';
@@ -83,7 +73,7 @@ sub register ($self, $app, $config = {}) {
83
73
 
84
74
  $c->write("retry: 1000\n\n");
85
75
 
86
- my $initial_id = _read_build_id($build_id_path);
76
+ my $initial_id = BarefootJS::DevReload->read_build_id($build_id_path);
87
77
  my $last_sent = '';
88
78
  if (length $initial_id) {
89
79
  $last_sent = $initial_id;
@@ -106,7 +96,7 @@ sub register ($self, $app, $config = {}) {
106
96
  $c->write(": hb\n\n");
107
97
  });
108
98
  $poll_id = Mojo::IOLoop->recurring($POLL_S => sub {
109
- my $id = _read_build_id($build_id_path);
99
+ my $id = BarefootJS::DevReload->read_build_id($build_id_path);
110
100
  return unless length $id;
111
101
  return if $id eq $last_sent;
112
102
  $last_sent = $id;
@@ -117,35 +107,4 @@ sub register ($self, $app, $config = {}) {
117
107
  return;
118
108
  }
119
109
 
120
- sub _read_build_id ($path) {
121
- return '' unless -f $path;
122
- open my $fh, '<', $path or return '';
123
- local $/;
124
- my $content = <$fh>;
125
- close $fh;
126
- $content //= '';
127
- $content =~ s/^\s+|\s+$//g;
128
- return $content;
129
- }
130
-
131
- sub _snippet ($endpoint) {
132
- my $ep = _js_str($endpoint);
133
- my $sk = _js_str($SCROLL_STORAGE_KEY);
134
- # Small IIFE: EventSource subscriber + scrollY preservation. Idempotent
135
- # across duplicate mounts (window.__bfDevReload guard).
136
- 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>};
137
- }
138
-
139
- sub _js_str ($s) {
140
- # Minimal JS string escape for the handful of characters that can appear
141
- # in a URL path or storage key. Good enough for package-internal + trusted
142
- # operator-supplied strings; never interpolate untrusted input here.
143
- my $t = $s;
144
- $t =~ s/\\/\\\\/g;
145
- $t =~ s/"/\\"/g;
146
- $t =~ s/\n/\\n/g;
147
- $t =~ s/\r/\\r/g;
148
- return qq{"$t"};
149
- }
150
-
151
110
  1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/mojolicious",
3
- "version": "0.7.0",
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",
@@ -52,8 +52,8 @@
52
52
  "directory": "packages/adapter-mojolicious"
53
53
  },
54
54
  "dependencies": {
55
- "@barefootjs/perl": "0.7.0",
56
- "@barefootjs/shared": "0.7.0"
55
+ "@barefootjs/perl": "0.8.0",
56
+ "@barefootjs/shared": "0.8.0"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "@barefootjs/jsx": ">=0.2.0",
@@ -61,6 +61,6 @@
61
61
  },
62
62
  "devDependencies": {
63
63
  "@barefootjs/adapter-tests": "0.1.0",
64
- "@barefootjs/jsx": "0.7.0"
64
+ "@barefootjs/jsx": "0.8.0"
65
65
  }
66
66
  }
@@ -73,12 +73,19 @@ runAdapterConformanceTests({
73
73
  // parity for the `site/ui` corpus is Phase 3, so these participate only
74
74
  // in Hono SSR conformance + the fixture-hydrate runtime layer for now.
75
75
  // (`label` and `kbd` are static helpers; `toggle` / `switch` /
76
- // `checkbox` carry uncontrolled state; `input` / `textarea` are
77
- // pass-through native controls.)
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.
78
86
  'toggle',
79
87
  'switch',
80
88
  'checkbox',
81
- 'textarea',
82
89
  'kbd',
83
90
  ],
84
91
  // Per-fixture build-time contracts for shapes the Mojo adapter
@@ -240,6 +247,76 @@ function compileAndGenerate(source: string, adapter?: MojoAdapter) {
240
247
  // Mojo-Specific Tests
241
248
  // =============================================================================
242
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
+
243
320
  describe('MojoAdapter - Template Generation', () => {
244
321
  test('generates basic element with scope marker', () => {
245
322
  const result = compileAndGenerate(`
@@ -207,6 +207,23 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
207
207
  * loop-var shadowing guards). (#1749 review)
208
208
  */
209
209
  private loopBoundNames: Map<string, number> = new Map()
210
+ /**
211
+ * Prop names whose value is `undef` in the template body when the caller
212
+ * omits them — so a bare-reference attribute should be dropped rather
213
+ * than rendered as `attr=""`. The actual population criterion (see
214
+ * `generate()`) is: NO destructure default (`defaultValue === undefined`)
215
+ * AND non-rest (`!isRest`) AND non-primitive type (`type.kind !==
216
+ * 'primitive'`). It deliberately does NOT consult `p.optional`: the
217
+ * analyzer derives `optional` from the presence of a default initializer,
218
+ * not the `?` token, so it's not the right witness here. Excluding
219
+ * concrete primitives (`string`/`number`/`boolean`) mirrors the Go
220
+ * adapter's scope, which guards only `interface{}` (nillable) fields.
221
+ * Used by `elementAttrEmitter.emitExpression` to guard such an attribute
222
+ * with a Perl `defined $x` check (`<textarea>` omits `rows`), matching
223
+ * Hono's nullish-attribute omission. Concrete/defaulted props are
224
+ * excluded and always emit unconditionally.
225
+ */
226
+ private nullableOptionalProps: Set<string> = new Set()
210
227
 
211
228
  constructor(options: MojoAdapterOptions = {}) {
212
229
  super()
@@ -220,6 +237,31 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
220
237
  this.componentName = ir.metadata.componentName
221
238
  this.propsObjectName = ir.metadata.propsObjectName ?? null
222
239
  this.propsParams = ir.metadata.propsParams.map(p => ({ name: p.name }))
240
+ // No-destructure-default props → `undef` when the caller omits them
241
+ // → guard their bare-reference attribute emission with Perl `defined`
242
+ // so the attribute drops instead of rendering `attr=""` (Hono-style
243
+ // nullish omission). A prop WITH a destructure default (`value = ''`)
244
+ // is never `undef` in the body and must stay unconditional, so it is
245
+ // excluded. This mirrors the Go adapter's nillable-field guard: there
246
+ // the witness is the resolved `interface{}` field type; here it is
247
+ // the absence of a default (the analyzer reports `rows` — a
248
+ // `TextareaHTMLAttributes` member destructured without a default — as
249
+ // no-default, `type.kind: 'unknown'`).
250
+ // Excludes concrete-primitive types (`string`/`number`/`boolean`)
251
+ // to match the Go adapter's scope, which guards only `interface{}`
252
+ // (nillable) fields and leaves concrete fields unconditional. So a
253
+ // required, no-default `string` prop still emits `attr=""` like Hono,
254
+ // and only nillable (`unknown`/object/array) no-default props guard.
255
+ this.nullableOptionalProps = new Set(
256
+ ir.metadata.propsParams
257
+ .filter(
258
+ p =>
259
+ p.defaultValue === undefined &&
260
+ !p.isRest &&
261
+ p.type?.kind !== 'primitive',
262
+ )
263
+ .map(p => p.name),
264
+ )
223
265
  // Record string-typed signals and props so equality comparisons against
224
266
  // them lower to `eq`/`ne` (#1672). A signal is string-typed when its
225
267
  // inferred type is `string` (the analyzer infers this from a string-literal
@@ -935,6 +977,32 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
935
977
  if (this.refuseUnsupportedAttrExpression(value.expr, name)) {
936
978
  return ''
937
979
  }
980
+ // Hono-style nullish-attribute omission (#textarea rows): when the
981
+ // attribute value is a BARE reference to an optional, no-default
982
+ // prop (which is `undef` when the caller omits it), guard the
983
+ // attribute with Perl `defined` so it DROPS rather than rendering
984
+ // `attr=""`. The guarded body reuses the exact normal emission, so
985
+ // value escaping (`<%= ... %>`) is unchanged; only the presence is
986
+ // conditional. The `% if`/`% end` line directives surround the
987
+ // attribute inline — the conformance comparator collapses the
988
+ // resulting whitespace, exactly like the existing boolean-attr and
989
+ // hydration-marker patterns. Scope is deliberately narrow (bare
990
+ // identifiers resolving to an optional-no-default prop) so member
991
+ // exprs, calls, concrete/defaulted props, and boolean attrs are
992
+ // unaffected and still emit unconditionally.
993
+ const bareId = value.expr.trim()
994
+ if (
995
+ !isBooleanAttr(name) &&
996
+ !value.presenceOrUndefined &&
997
+ this.nullableOptionalProps.has(bareId)
998
+ ) {
999
+ const perl = this.convertExpressionToPerl(value.expr)
1000
+ const body =
1001
+ isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name)
1002
+ ? `${name}="<%= bf->bool_str(${perl}) %>"`
1003
+ : `${name}="<%= ${perl} %>"`
1004
+ return `<% if (defined ${perl}) { %>${body}<% } %>`
1005
+ }
938
1006
  if (isBooleanAttr(name) || value.presenceOrUndefined) {
939
1007
  // Boolean attributes: render conditionally (present or absent).
940
1008
  return `<%= ${this.convertExpressionToPerl(value.expr)} ? '${name}' : '' %>`
@@ -1008,6 +1076,17 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1008
1076
  )
1009
1077
  return `<%== bf->spread_attrs({${entries.join(', ')}}) %>`
1010
1078
  }
1079
+ // Conditional inline-object spread:
1080
+ // `{...(COND ? { 'aria-describedby': describedBy } : {})}`
1081
+ // Emit a Perl inline ternary of hashrefs — Perl truthiness
1082
+ // handles the condition for free, and the falsy `{}` branch
1083
+ // OMITS the key (`bf->spread_attrs` does NOT filter empty
1084
+ // strings, so we cannot always-include it). Mirrors the Go
1085
+ // adapter's IIFE-of-maps lowering (#textarea).
1086
+ const ternaryHashref = this.conditionalSpreadToPerl(trimmed)
1087
+ if (ternaryHashref !== null) {
1088
+ return `<%== bf->spread_attrs(${ternaryHashref}) %>`
1089
+ }
1011
1090
  const perlExpr = this.convertExpressionToPerl(value.expr)
1012
1091
  return `<%== bf->spread_attrs(${perlExpr}) %>`
1013
1092
  },
@@ -1294,6 +1373,77 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1294
1373
  }
1295
1374
 
1296
1375
 
1376
+ /**
1377
+ * Lower a conditional inline-object spread expression
1378
+ * `(COND ? { 'aria-describedby': describedBy } : {})`
1379
+ * (either branch possibly `{}`) into a Perl inline ternary of
1380
+ * hashrefs for `bf->spread_attrs`:
1381
+ * `$describedBy ? { 'aria-describedby' => $describedBy } : {}`
1382
+ *
1383
+ * The condition is translated via `convertExpressionToPerl` (a bare
1384
+ * prop ident becomes `$describedBy`; Perl truthiness handles the
1385
+ * test). Object literals become Perl hashrefs with `=>`; string-
1386
+ * literal keys are quoted, values resolve via `convertExpressionToPerl`.
1387
+ *
1388
+ * Returns null when the expression is NOT this shape, or when a part
1389
+ * can't be faithfully lowered (non-static key, etc.) so the caller
1390
+ * falls back to the standard `convertExpressionToPerl` path (which
1391
+ * records BF101). Scoped strictly to ternary-of-object-literals so no
1392
+ * other spread shape regresses.
1393
+ */
1394
+ private conditionalSpreadToPerl(expr: string): string | null {
1395
+ const sf = ts.createSourceFile('__spread.ts', `(${expr})`, ts.ScriptTarget.Latest, true)
1396
+ if (sf.statements.length !== 1) return null
1397
+ const stmt = sf.statements[0]
1398
+ if (!ts.isExpressionStatement(stmt)) return null
1399
+ let node: ts.Expression = stmt.expression
1400
+ while (ts.isParenthesizedExpression(node)) node = node.expression
1401
+ if (!ts.isConditionalExpression(node)) return null
1402
+ const unwrap = (e: ts.Expression): ts.Expression => {
1403
+ let n = e
1404
+ while (ts.isParenthesizedExpression(n)) n = n.expression
1405
+ return n
1406
+ }
1407
+ const whenTrue = unwrap(node.whenTrue)
1408
+ const whenFalse = unwrap(node.whenFalse)
1409
+ if (!ts.isObjectLiteralExpression(whenTrue) || !ts.isObjectLiteralExpression(whenFalse)) {
1410
+ return null
1411
+ }
1412
+ const condPerl = this.convertExpressionToPerl(node.condition.getText(sf))
1413
+ const truePerl = this.objectLiteralToPerlHashref(whenTrue, sf)
1414
+ const falsePerl = this.objectLiteralToPerlHashref(whenFalse, sf)
1415
+ if (truePerl === null || falsePerl === null) return null
1416
+ return `${condPerl} ? ${truePerl} : ${falsePerl}`
1417
+ }
1418
+
1419
+ /**
1420
+ * Convert a static object literal into a Perl hashref string for a
1421
+ * conditional spread. Only static string/identifier keys are allowed;
1422
+ * values resolve via `convertExpressionToPerl`. Returns null for any
1423
+ * computed/spread/dynamic key. Empty object → `{}`.
1424
+ */
1425
+ private objectLiteralToPerlHashref(
1426
+ obj: ts.ObjectLiteralExpression,
1427
+ sf: ts.SourceFile,
1428
+ ): string | null {
1429
+ const entries: string[] = []
1430
+ for (const prop of obj.properties) {
1431
+ if (!ts.isPropertyAssignment(prop)) return null
1432
+ let key: string
1433
+ if (ts.isIdentifier(prop.name)) {
1434
+ key = prop.name.text
1435
+ } else if (ts.isStringLiteral(prop.name) || ts.isNoSubstitutionTemplateLiteral(prop.name)) {
1436
+ key = prop.name.text
1437
+ } else {
1438
+ return null
1439
+ }
1440
+ const valPerl = this.convertExpressionToPerl(prop.initializer.getText(sf))
1441
+ entries.push(`'${key.replace(/'/g, "\\'")}' => ${valPerl}`)
1442
+ }
1443
+ return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`
1444
+ }
1445
+
1446
+
1297
1447
  private convertExpressionToPerl(expr: string): string {
1298
1448
  // Parse-first lowering — parity with the Go adapter's
1299
1449
  // `convertExpressionToGo`. Parse the JS expression once, gate it on