@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.
- package/dist/adapter/index.js +60 -0
- package/dist/adapter/mojo-adapter.d.ts +43 -0
- package/dist/adapter/mojo-adapter.d.ts.map +1 -1
- package/dist/build.js +60 -0
- package/dist/index.js +60 -0
- package/lib/Mojolicious/Plugin/BarefootJS/DevReload.pm +10 -51
- package/package.json +4 -4
- package/src/__tests__/mojo-adapter.test.ts +80 -3
- package/src/adapter/mojo-adapter.ts +150 -0
package/dist/adapter/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 === "")
|
|
@@ -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":"AAAA;;;;GAIG;AAGH,OAAO,KAAK,EACV,WAAW,EAEX,MAAM,EACN,SAAS,EACT,MAAM,EACN,YAAY,EACZ,aAAa,EACb,MAAM,EACN,WAAW,EACX,UAAU,EACV,MAAM,EACN,aAAa,EACb,UAAU,EACV,OAAO,EAMP,yBAAyB,EAC1B,MAAM,iBAAiB,CAAA;AACxB,OAAO,EACL,WAAW,EACX,KAAK,aAAa,EAClB,KAAK,sBAAsB,EAM3B,KAAK,aAAa,EAClB,KAAK,UAAU,EAUhB,MAAM,iBAAiB,CAAA;AAGxB;;;;;;GAMG;AACH,KAAK,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAA;AAC1C,OAAO,KAAK,EAAE,UAAU,EAAiF,MAAM,iBAAiB,CAAA;AAqDhI,MAAM,WAAW,kBAAkB;IACjC,qEAAqE;IACrE,gBAAgB,CAAC,EAAE,MAAM,CAAA;IAEzB,8EAA8E;IAC9E,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AA4BD,qBAAa,WAAY,SAAQ,WAAY,YAAW,aAAa,CAAC,aAAa,CAAC;IAClF,IAAI,SAAgB;IACpB,SAAS,SAAa;IACtB,qBAAqB,UAAO;IAG5B,kBAAkB,EAAG,cAAc,CAAS;IAE5C;;;;;;;;;;;OAWG;IACH,kBAAkB,EAAE,yBAAyB,CAA0B;IAEvE,OAAO,CAAC,aAAa,CAAa;IAClC,OAAO,CAAC,OAAO,CAA8B;IAC7C,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,MAAM,CAAiB;IAC/B;;;;;;OAMG;IACH,OAAO,CAAC,eAAe,CAAsB;IAC7C,OAAO,CAAC,WAAW,CAAyB;IAC5C;;;;;OAKG;IACH,OAAO,CAAC,gBAAgB,CAAyB;IACjD;;;;;;;;;OASG;IACH,OAAO,CAAC,kBAAkB,CAAiC;IAC3D;;;;;;;OAOG;IACH,OAAO,CAAC,cAAc,CAAiC;
|
|
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
|
-
#
|
|
35
|
-
#
|
|
36
|
-
|
|
37
|
-
my $
|
|
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(
|
|
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
|
-
|
|
72
|
-
my $build_id_path =
|
|
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 =
|
|
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 =
|
|
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.
|
|
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.
|
|
56
|
-
"@barefootjs/shared": "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.
|
|
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`
|
|
77
|
-
//
|
|
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
|