@barefootjs/mojolicious 0.7.0 → 0.9.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
@@ -8,7 +8,10 @@ import {
8
8
  identifierPath,
9
9
  emitParsedExpr,
10
10
  emitIRNode,
11
- emitAttrValue
11
+ emitAttrValue,
12
+ augmentInheritedPropAccesses,
13
+ parseRecordIndexAccess,
14
+ evalStringArrayJoin
12
15
  } from "@barefootjs/jsx";
13
16
 
14
17
  // src/adapter/boolean-result.ts
@@ -95,7 +98,10 @@ function parsePureStringLiteral(source) {
95
98
  if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
96
99
  return init.text;
97
100
  }
98
- return null;
101
+ return evalStringArrayJoin(source);
102
+ }
103
+ function perlHashKey(name) {
104
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : `'${name.replace(/'/g, "\\'")}'`;
99
105
  }
100
106
 
101
107
  class MojoAdapter extends BaseAdapter {
@@ -112,7 +118,9 @@ class MojoAdapter extends BaseAdapter {
112
118
  propsParams = [];
113
119
  stringValueNames = new Set;
114
120
  moduleStringConsts = new Map;
121
+ localConstants = [];
115
122
  loopBoundNames = new Map;
123
+ nullableOptionalProps = new Set;
116
124
  constructor(options = {}) {
117
125
  super();
118
126
  this.options = {
@@ -123,7 +131,9 @@ class MojoAdapter extends BaseAdapter {
123
131
  generate(ir, options) {
124
132
  this.componentName = ir.metadata.componentName;
125
133
  this.propsObjectName = ir.metadata.propsObjectName ?? null;
134
+ augmentInheritedPropAccesses(ir);
126
135
  this.propsParams = ir.metadata.propsParams.map((p) => ({ name: p.name }));
136
+ this.nullableOptionalProps = new Set(ir.metadata.propsParams.filter((p) => p.defaultValue === undefined && !p.isRest && p.type?.kind !== "primitive").map((p) => p.name));
127
137
  this.stringValueNames = new Set;
128
138
  for (const s of ir.metadata.signals) {
129
139
  if (isStringTypeInfo(s.type) || isBareStringLiteral(s.initialValue)) {
@@ -135,6 +145,7 @@ class MojoAdapter extends BaseAdapter {
135
145
  this.stringValueNames.add(p.name);
136
146
  }
137
147
  this.moduleStringConsts = this.collectModuleStringConsts(ir.metadata.localConstants);
148
+ this.localConstants = ir.metadata.localConstants ?? [];
138
149
  this.loopBoundNames.clear();
139
150
  this.errors = [];
140
151
  this.childrenCaptureCounter = 0;
@@ -497,20 +508,20 @@ ${renderedChildren}` : renderedChildren;
497
508
  `);
498
509
  }
499
510
  componentPropEmitter = {
500
- emitLiteral: (value, name) => `${name} => '${value.value}'`,
511
+ emitLiteral: (value, name) => `${perlHashKey(name)} => '${value.value}'`,
501
512
  emitExpression: (value, name) => {
502
513
  if (value.parts) {
503
- return `${name} => ${this.convertTemplateLiteralPartsToPerl(value.parts)}`;
514
+ return `${perlHashKey(name)} => ${this.convertTemplateLiteralPartsToPerl(value.parts)}`;
504
515
  }
505
- return `${name} => ${this.convertExpressionToPerl(value.expr)}`;
516
+ return `${perlHashKey(name)} => ${this.convertExpressionToPerl(value.expr)}`;
506
517
  },
507
518
  emitSpread: (value) => {
508
519
  const perlExpr = this.convertExpressionToPerl(value.expr);
509
520
  return perlExpr.startsWith("%") ? perlExpr : `%{${perlExpr}}`;
510
521
  },
511
- emitTemplate: (value, name) => `${name} => ${this.convertTemplateLiteralPartsToPerl(value.parts)}`,
512
- emitBooleanAttr: (_value, name) => `${name} => 1`,
513
- emitBooleanShorthand: (_value, name) => `${name} => 1`,
522
+ emitTemplate: (value, name) => `${perlHashKey(name)} => ${this.convertTemplateLiteralPartsToPerl(value.parts)}`,
523
+ emitBooleanAttr: (_value, name) => `${perlHashKey(name)} => 1`,
524
+ emitBooleanShorthand: (_value, name) => `${perlHashKey(name)} => 1`,
514
525
  emitJsxChildren: () => ""
515
526
  };
516
527
  renderComponent(comp) {
@@ -582,6 +593,13 @@ ${children}`;
582
593
  if (this.refuseUnsupportedAttrExpression(value.expr, name)) {
583
594
  return "";
584
595
  }
596
+ const bareId = value.expr.trim();
597
+ const normalizedBareId = this.propsObjectName && bareId.startsWith(`${this.propsObjectName}.`) ? bareId.slice(this.propsObjectName.length + 1) : bareId;
598
+ if (!isBooleanAttr(name) && !value.presenceOrUndefined && /^[A-Za-z_$][\w$]*$/.test(normalizedBareId) && this.nullableOptionalProps.has(normalizedBareId)) {
599
+ const perl2 = this.convertExpressionToPerl(value.expr);
600
+ const body = isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name) ? `${name}="<%= bf->bool_str(${perl2}) %>"` : `${name}="<%= ${perl2} %>"`;
601
+ return `<% if (defined ${perl2}) { %>${body}<% } %>`;
602
+ }
585
603
  if (isBooleanAttr(name) || value.presenceOrUndefined) {
586
604
  return `<%= ${this.convertExpressionToPerl(value.expr)} ? '${name}' : '' %>`;
587
605
  }
@@ -602,6 +620,22 @@ ${children}`;
602
620
  const entries = this.propsParams.map((p) => `${JSON.stringify(p.name)} => $${p.name}`);
603
621
  return `<%== bf->spread_attrs({${entries.join(", ")}}) %>`;
604
622
  }
623
+ const ternaryHashref = this.conditionalSpreadToPerl(trimmed);
624
+ if (ternaryHashref !== null) {
625
+ return `<%== bf->spread_attrs(${ternaryHashref}) %>`;
626
+ }
627
+ if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) {
628
+ const localConst = this.localConstants.find((c) => c.name === trimmed && !c.isModule);
629
+ if (localConst?.value !== undefined) {
630
+ const initTrimmed = localConst.value.trim();
631
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(initTrimmed)) {
632
+ const resolved = this.conditionalSpreadToPerl(initTrimmed);
633
+ if (resolved !== null) {
634
+ return `<%== bf->spread_attrs(${resolved}) %>`;
635
+ }
636
+ }
637
+ }
638
+ }
605
639
  const perlExpr = this.convertExpressionToPerl(value.expr);
606
640
  return `<%== bf->spread_attrs(${perlExpr}) %>`;
607
641
  },
@@ -766,6 +800,71 @@ ${reason}` : "";
766
800
  });
767
801
  return true;
768
802
  }
803
+ conditionalSpreadToPerl(expr) {
804
+ const sf = ts.createSourceFile("__spread.ts", `(${expr})`, ts.ScriptTarget.Latest, true);
805
+ if (sf.statements.length !== 1)
806
+ return null;
807
+ const stmt = sf.statements[0];
808
+ if (!ts.isExpressionStatement(stmt))
809
+ return null;
810
+ let node = stmt.expression;
811
+ while (ts.isParenthesizedExpression(node))
812
+ node = node.expression;
813
+ if (!ts.isConditionalExpression(node))
814
+ return null;
815
+ const unwrap = (e) => {
816
+ let n = e;
817
+ while (ts.isParenthesizedExpression(n))
818
+ n = n.expression;
819
+ return n;
820
+ };
821
+ const whenTrue = unwrap(node.whenTrue);
822
+ const whenFalse = unwrap(node.whenFalse);
823
+ if (!ts.isObjectLiteralExpression(whenTrue) || !ts.isObjectLiteralExpression(whenFalse)) {
824
+ return null;
825
+ }
826
+ const condPerl = this.convertExpressionToPerl(node.condition.getText(sf));
827
+ const truePerl = this.objectLiteralToPerlHashref(whenTrue, sf);
828
+ const falsePerl = this.objectLiteralToPerlHashref(whenFalse, sf);
829
+ if (truePerl === null || falsePerl === null)
830
+ return null;
831
+ return `${condPerl} ? ${truePerl} : ${falsePerl}`;
832
+ }
833
+ objectLiteralToPerlHashref(obj, sf) {
834
+ const entries = [];
835
+ for (const prop of obj.properties) {
836
+ if (!ts.isPropertyAssignment(prop))
837
+ return null;
838
+ let key;
839
+ if (ts.isIdentifier(prop.name)) {
840
+ key = prop.name.text;
841
+ } else if (ts.isStringLiteral(prop.name) || ts.isNoSubstitutionTemplateLiteral(prop.name)) {
842
+ key = prop.name.text;
843
+ } else {
844
+ return null;
845
+ }
846
+ const initNode = (() => {
847
+ let n = prop.initializer;
848
+ while (ts.isParenthesizedExpression(n))
849
+ n = n.expression;
850
+ return n;
851
+ })();
852
+ const indexed = this.recordIndexAccessToPerl(initNode);
853
+ const valPerl = indexed !== null ? indexed : this.convertExpressionToPerl(prop.initializer.getText(sf));
854
+ entries.push(`'${key.replace(/'/g, "\\'")}' => ${valPerl}`);
855
+ }
856
+ return entries.length === 0 ? "{}" : `{ ${entries.join(", ")} }`;
857
+ }
858
+ recordIndexAccessToPerl(val) {
859
+ const parsed = parseRecordIndexAccess(val, this.localConstants, this.propsParams);
860
+ if (!parsed)
861
+ return null;
862
+ const entries = parsed.entries.map((e) => {
863
+ const mapVal = e.value.kind === "number" ? e.value.text : `'${e.value.text.replace(/'/g, "\\'")}'`;
864
+ return `'${e.key.replace(/'/g, "\\'")}' => ${mapVal}`;
865
+ });
866
+ return `{ ${entries.join(", ")} }->{$${parsed.indexPropName}}`;
867
+ }
769
868
  convertExpressionToPerl(expr) {
770
869
  const trimmed = expr.trim();
771
870
  if (trimmed === "")
@@ -1 +1 @@
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"}
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;AA+RD;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,OAAO,CAuBT"}
@@ -1,5 +1,5 @@
1
1
  package BarefootJS::Backend::Mojo;
2
- our $VERSION = "0.01";
2
+ our $VERSION = "0.8.0";
3
3
  use Mojo::Base -base, -signatures;
4
4
 
5
5
  use Mojo::ByteStream qw(b);
@@ -1,5 +1,5 @@
1
1
  package Mojolicious::Plugin::BarefootJS::DevReload;
2
- our $VERSION = "0.01";
2
+ our $VERSION = "0.8.0";
3
3
  use Mojo::Base 'Mojolicious::Plugin', -signatures;
4
4
 
5
5
  =head1 NAME
@@ -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;
@@ -1,5 +1,5 @@
1
1
  package Mojolicious::Plugin::BarefootJS;
2
- our $VERSION = "0.01";
2
+ our $VERSION = "0.8.0";
3
3
  use Mojo::Base 'Mojolicious::Plugin', -signatures;
4
4
 
5
5
  use Mojo::File qw(path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/mojolicious",
3
- "version": "0.7.0",
3
+ "version": "0.9.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.9.0",
56
+ "@barefootjs/shared": "0.9.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.9.0"
65
65
  }
66
66
  }
@@ -17,69 +17,19 @@ runAdapterConformanceTests({
17
17
  name: 'mojo',
18
18
  factory: () => new MojoAdapter(),
19
19
  render: renderMojoComponent,
20
- // Dynamic style objects (non-static values) require Perl template
21
- // interpolation support for JS object literals, not yet implemented.
22
- // Mojo currently emits invalid Perl silently for this shape — the
23
- // Go adapter records BF101 via `convertExpressionToGo()` for the
24
- // same fixture (now contracted via `expectedDiagnostics`), but the
25
- // Mojo adapter's expression gate doesn't yet lift the same
26
- // failure into a `CompilerError`, so the fixture stays on `skipJsx`
27
- // until that gate is extended (#1266 follow-up).
28
- // `logical-or-jsx`, `nullish-coalescing-jsx`, `branch-map` reference
29
- // a prop directly inside a conditional branch (`$label`, `$banner`,
30
- // `$active`). The Mojo adapter emits these as bare Perl variables
31
- // (`% if ($label) { ... }`) without a corresponding
32
- // `my $label = ...;` declaration, so Perl rejects the template with
33
- // "Global symbol requires explicit package name". Same class of
34
- // Perl-scoping divergence that motivates the existing skips —
35
- // out of scope for the #971 refactor.
36
- // Return-position variants of the same divergence —
37
- // `return-logical-or` / `return-nullish-coalescing` reference
38
- // `$label` / `$banner` directly; `return-map` iterates over `$items`
39
- // without a `my` declaration.
40
- //
41
- // `static-array-children` / `static-array-from-props` /
42
- // `static-array-from-props-with-component` are no longer here —
43
- // they're covered by `expectedDiagnostics` below, asserting that
44
- // the adapter emits `BF103` / `BF104` at build time instead of
45
- // silently emitting invalid Perl / unresolved cross-template
46
- // references (#1266).
47
20
  skipJsx: [
48
- 'style-object-dynamic',
49
- 'logical-or-jsx',
50
- 'nullish-coalescing-jsx',
51
- 'branch-map',
52
- 'return-logical-or',
53
- 'return-nullish-coalescing',
54
- 'return-map',
55
- // #1297 fixed the harness-side IR emission gate. The remaining
56
- // gap is adapter-side: the Mojo adapter has no SSR context-
57
- // propagation mechanism, so `<Ctx.Provider value="dark">` doesn't
58
- // make `useContext(Ctx)` resolve to `"dark"` at template-eval
59
- // time — the template emits `<%= $theme %>` against a hash that
60
- // never receives a `theme` key. Provider SSR coverage on Mojo
61
- // waits on that adapter feature; see #1297 follow-up.
21
+ // SSR context propagation (`<Ctx.Provider value>` → `useContext`): the
22
+ // template reads a stash key that's never seeded. Implemented on Go; the
23
+ // Perl stash-seed path is a follow-up port, so Mojo stays skipped (#1297).
62
24
  'context-provider',
63
- // Multi-component fixtures still diverge because Mojo's child
64
- // template emitter pins the child's `bf-s` to the literal
65
- // `test_<sN>` (`_scope_id("test_$sid")` in `test-render.ts`)
66
- // instead of `<ChildName>_<id>_<sN>` like Hono / CSR. Same family
67
- // of test-harness scope-id plumbing the `componentName` option
68
- // fixed on the Hono side. Separate follow-up.
25
+ // Multi-component shared fixtures with per-item loop state. The child
26
+ // scope-id plumbing is now fixed (children derive `<parentScope>_<sN>`
27
+ // from `$bf->_scope_id` in `test-render.ts`, which unblocked
28
+ // `reactive-props`); these two still diverge on additional per-item
29
+ // loop-state seeding the Mojo harness doesn't yet replicate. (xslate
30
+ // skips the same pair.)
69
31
  'toggle-shared',
70
- 'reactive-props',
71
32
  'props-reactivity-comparison',
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` / `textarea` are
77
- // pass-through native controls.)
78
- 'toggle',
79
- 'switch',
80
- 'checkbox',
81
- 'textarea',
82
- 'kbd',
83
33
  ],
84
34
  // Per-fixture build-time contracts for shapes the Mojo adapter
85
35
  // intentionally refuses to lower. Owned by this adapter test file
@@ -123,6 +73,11 @@ runAdapterConformanceTests({
123
73
  // surfaces BF101 with a wrap-in-`/* @client */` suggestion, matching
124
74
  // the Go adapter's behaviour.
125
75
  'style-3-signals': [{ code: 'BF101', severity: 'error' }],
76
+ // Dynamic JS object literal in `style={{ … }}` — same no-EP-form
77
+ // refusal as `style-3-signals`; `refuseUnsupportedAttrExpression`
78
+ // surfaces BF101 (mirrors the xslate + Go adapters). Was a stale
79
+ // `skipJsx` entry claiming the gate didn't lift to a CompilerError.
80
+ 'style-object-dynamic': [{ code: 'BF101', severity: 'error' }],
126
81
  // #1244 stress catalog #12 (#1323): tagged-template-literal call
127
82
  // (`cn\`base \${tone()}\``) — same family as #1322 above and refused
128
83
  // via the same gate.
@@ -240,6 +195,190 @@ function compileAndGenerate(source: string, adapter?: MojoAdapter) {
240
195
  // Mojo-Specific Tests
241
196
  // =============================================================================
242
197
 
198
+ describe('MojoAdapter - conditional inline-object spread (textarea aria-describedby)', () => {
199
+ // `{...(cond ? { 'aria-describedby': cond } : {})}` lowers to a Perl
200
+ // inline ternary of hashrefs so the falsy `{}` branch OMITS the key
201
+ // (bf->spread_attrs does not filter empty strings). The shared
202
+ // fixture only exercises the falsy branch; this pins the truthy one.
203
+ test('emits a Perl inline ternary of hashrefs through bf->spread_attrs', () => {
204
+ const { template } = compileAndGenerate(`
205
+ function Box({ describedBy }: { describedBy?: string }) {
206
+ return <div {...(describedBy ? { 'aria-describedby': describedBy } : {})} />
207
+ }
208
+ `)
209
+ expect(template).toContain(
210
+ "bf->spread_attrs($describedBy ? { 'aria-describedby' => $describedBy } : {})",
211
+ )
212
+ })
213
+
214
+ test('resolves the value reference and preserves the static key for a second prop', () => {
215
+ const { template } = compileAndGenerate(`
216
+ function Box({ label }: { label: string }) {
217
+ return <div {...(label ? { 'data-label': label } : {})} />
218
+ }
219
+ `)
220
+ expect(template).toContain(
221
+ "bf->spread_attrs($label ? { 'data-label' => $label } : {})",
222
+ )
223
+ })
224
+
225
+ test('falls back to BF101 for a computed (non-static) object key', () => {
226
+ const adapter = new MojoAdapter()
227
+ const ir = compileToIR(`
228
+ function Box({ k, v }: { k?: string; v?: string }) {
229
+ return <div {...(v ? { [k]: v } : {})} />
230
+ }
231
+ `, adapter)
232
+ adapter.generate(ir)
233
+ const errs = (adapter as unknown as { errors: { code: string }[] }).errors
234
+ expect(errs.some(e => e.code === 'BF101')).toBe(true)
235
+ })
236
+ })
237
+
238
+ describe('MojoAdapter - local-const conditional-spread resolution (#checkbox icon)', () => {
239
+ // A FUNCTION-scope const holding a `cond ? {…} : {}` ternary, spread as
240
+ // a bare identifier (`{...attrs}`), resolves through the same Perl
241
+ // ternary-of-hashrefs lowering as the inline form. CheckIcon's
242
+ // `const sizeAttrs = size ? {…} : {}` is exactly this shape.
243
+ test('resolves a bare-identifier spread of a function-scope conditional const', () => {
244
+ const { template } = compileAndGenerate(`
245
+ function Box({ flag }: { flag?: boolean }) {
246
+ const attrs = flag ? { 'data-on': 'yes' } : {}
247
+ return <div {...attrs} />
248
+ }
249
+ `)
250
+ expect(template).toContain(
251
+ "bf->spread_attrs($flag ? { 'data-on' => 'yes' } : {})",
252
+ )
253
+ })
254
+
255
+ // A const that aliases another bare identifier must NOT be forwarded
256
+ // (loop guard): the resolver bails, so the spread falls through to the
257
+ // standard `convertExpressionToPerl` path emitting the bare `$attrs`
258
+ // variable rather than recursively resolving the alias into a hashref.
259
+ test('does not forward a const that aliases another identifier (loop guard)', () => {
260
+ const { template } = compileAndGenerate(`
261
+ function Box({ other }: { other?: object }) {
262
+ const attrs = other
263
+ return <div {...attrs} />
264
+ }
265
+ `)
266
+ expect(template).toContain('bf->spread_attrs($attrs)')
267
+ })
268
+ })
269
+
270
+ describe('MojoAdapter - Record<staticKeys,scalar>[propKey] spread value (#checkbox icon)', () => {
271
+ // `const sizeMap: Record<IconSize, number> = { sm: 16, ... }` indexed by
272
+ // a prop inside a conditional-spread object value lowers to an inline
273
+ // indexed Perl hashref `{ ... }->{$key}`. This is CheckIcon's
274
+ // `{ width: sizeMap[size], height: sizeMap[size] }` shape.
275
+ test('lowers an indexed module-const map to an inline hashref index', () => {
276
+ const { template } = compileAndGenerate(`
277
+ const sizeMap: Record<string, number> = { sm: 16, md: 20, lg: 24, xl: 32 }
278
+ function Box({ size }: { size?: string }) {
279
+ const attrs = size ? { width: sizeMap[size] } : {}
280
+ return <div {...attrs} />
281
+ }
282
+ `)
283
+ expect(template).toContain(
284
+ "{ 'sm' => 16, 'md' => 20, 'lg' => 24, 'xl' => 32 }->{$size}",
285
+ )
286
+ })
287
+
288
+ test('lowers string-valued record maps too', () => {
289
+ const { template } = compileAndGenerate(`
290
+ const labelMap: Record<string, string> = { a: 'Alpha', b: 'Beta' }
291
+ function Box({ k }: { k?: string }) {
292
+ const attrs = k ? { 'data-label': labelMap[k] } : {}
293
+ return <div {...attrs} />
294
+ }
295
+ `)
296
+ expect(template).toContain("{ 'a' => 'Alpha', 'b' => 'Beta' }->{$k}")
297
+ })
298
+
299
+ // A non-scalar record value (object) is out of shape: the spread object
300
+ // value can't lower, so the whole spread falls back to BF101.
301
+ test('refuses a non-scalar record value with BF101 (out-of-shape fallback)', () => {
302
+ const adapter = new MojoAdapter()
303
+ const ir = compileToIR(`
304
+ const sizeMap: Record<string, object> = { sm: { w: 1 } }
305
+ function Box({ size }: { size?: string }) {
306
+ const attrs = size ? { width: sizeMap[size] } : {}
307
+ return <div {...attrs} />
308
+ }
309
+ `, adapter)
310
+ adapter.generate(ir)
311
+ const errs = (adapter as unknown as { errors: { code: string }[] }).errors
312
+ expect(errs.some(e => e.code === 'BF101')).toBe(true)
313
+ })
314
+ })
315
+
316
+ describe('MojoAdapter - props-object inherited-attribute enumeration (#checkbox)', () => {
317
+ // A SolidJS props-object component reads inherited attributes (`props.id`)
318
+ // not enumerated in `propsParams`. The bare optional attribute must be
319
+ // guarded with Perl `defined` so it's omitted when unset (Hono parity),
320
+ // even though `id` isn't a declared param.
321
+ test('guards a props-object bare optional attr (props.id) with defined', () => {
322
+ const { template } = compileAndGenerate(`
323
+ "use client"
324
+ interface P { tone?: string }
325
+ export function Widget(props: P) {
326
+ return <button id={props.id}>x</button>
327
+ }
328
+ `)
329
+ expect(template).toContain('<% if (defined $id) { %>id="<%= $id %>"<% } %>')
330
+ })
331
+ })
332
+
333
+ describe('MojoAdapter - hyphenated child attr hash key (#checkbox)', () => {
334
+ // A child component prop whose JSX name isn't a bare Perl identifier
335
+ // (`<CheckIcon data-slot="..."/>`) must be quoted in the `render_child`
336
+ // named-arg list — an unquoted `data-slot => ...` parses as `data - slot`.
337
+ test('quotes a hyphenated child attribute name in render_child', () => {
338
+ const { template } = compileAndGenerate(`
339
+ "use client"
340
+ import { Leaf } from './leaf'
341
+ export function Host() {
342
+ return <div><Leaf data-slot="indicator" size="sm" /></div>
343
+ }
344
+ `)
345
+ expect(template).toContain("'data-slot' => 'indicator'")
346
+ // A bare-identifier name stays unquoted.
347
+ expect(template).toContain('size => ')
348
+ expect(template).not.toContain('data-slot => ')
349
+ })
350
+ })
351
+
352
+ describe('MojoAdapter - nullish optional-attribute omission (textarea rows)', () => {
353
+ // A no-destructure-default, nillable-typed prop is `undef` when the
354
+ // caller omits it; guard its bare-reference attribute with Perl
355
+ // `defined` so it DROPS instead of rendering `attr=""` — matching
356
+ // Hono's nullish-attribute omission. Concrete/defaulted props are
357
+ // never `undef` and stay unconditional.
358
+ test('guards a no-default nillable attr with a Perl defined check', () => {
359
+ const { template } = compileAndGenerate(`
360
+ function C({ rows }: { rows?: number }) {
361
+ return <textarea rows={rows} />
362
+ }
363
+ `)
364
+ expect(template).toContain('<% if (defined $rows) { %>rows="<%= $rows %>"<% } %>')
365
+ // Must NOT emit the bare unconditional form.
366
+ expect(template).not.toMatch(/(?<!\{ %>)rows="<%= \$rows %>"/)
367
+ })
368
+
369
+ test('leaves a defaulted attr unconditional (scope did not widen)', () => {
370
+ const { template } = compileAndGenerate(`
371
+ function C({ value = '' }: { value?: string }) {
372
+ return <textarea value={value} />
373
+ }
374
+ `)
375
+ // `value` has a destructure default → never undef → unconditional,
376
+ // exactly like Hono's value="".
377
+ expect(template).toContain('value="<%= $value %>"')
378
+ expect(template).not.toContain('defined $value')
379
+ })
380
+ })
381
+
243
382
  describe('MojoAdapter - Template Generation', () => {
244
383
  test('generates basic element with scope marker', () => {
245
384
  const result = compileAndGenerate(`