@barefootjs/go-template 0.6.0 → 0.7.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/go-template-adapter.d.ts +47 -2
- package/dist/adapter/go-template-adapter.d.ts.map +1 -1
- package/dist/adapter/index.js +123 -20
- package/dist/build.js +123 -20
- package/dist/index.js +123 -20
- package/package.json +2 -2
- package/src/__tests__/go-template-adapter.test.ts +150 -14
- package/src/__tests__/slot-dynamic-tag.test.ts +79 -0
- package/src/adapter/go-template-adapter.ts +280 -11
- package/src/test-render.ts +41 -2
package/dist/index.js
CHANGED
|
@@ -51,8 +51,20 @@ function emitBfSort(recv, c) {
|
|
|
51
51
|
});
|
|
52
52
|
return `bf_sort ${wrapIfMultiToken(recv)} ${groups.join(" ")}`;
|
|
53
53
|
}
|
|
54
|
+
function emitBfReduce(recv, op, direction) {
|
|
55
|
+
const keyName = op.key.kind === "field" ? capitalize(op.key.field) : "";
|
|
56
|
+
return `bf_reduce ${wrapIfMultiToken(recv)} "${op.op}" "${op.key.kind}" "${keyName}" "${op.type}" "${escapeGoString(op.init)}" "${direction}"`;
|
|
57
|
+
}
|
|
58
|
+
function escapeGoString(s) {
|
|
59
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
|
60
|
+
}
|
|
54
61
|
function capitalize(s) {
|
|
55
|
-
|
|
62
|
+
if (s.length === 0)
|
|
63
|
+
return s;
|
|
64
|
+
if (GoTemplateAdapter.GO_INITIALISMS.has(s.toLowerCase())) {
|
|
65
|
+
return s.toUpperCase();
|
|
66
|
+
}
|
|
67
|
+
return s[0].toUpperCase() + s.slice(1);
|
|
56
68
|
}
|
|
57
69
|
function slotIdToFieldSuffix(slotId) {
|
|
58
70
|
const cleanId = slotId.startsWith("^") ? slotId.slice(1) : slotId;
|
|
@@ -62,6 +74,18 @@ function slotIdToFieldSuffix(slotId) {
|
|
|
62
74
|
}
|
|
63
75
|
return cleanId.replace("slot_", "Slot");
|
|
64
76
|
}
|
|
77
|
+
var GO_REMEDIATION_OPTIONS = `Options:
|
|
78
|
+
1. Use @client directive for client-side evaluation
|
|
79
|
+
2. Pre-compute the value in Go code`;
|
|
80
|
+
function buildUnsupportedSuggestion(support) {
|
|
81
|
+
if (!support.reason)
|
|
82
|
+
return GO_REMEDIATION_OPTIONS;
|
|
83
|
+
if (support.selfContained)
|
|
84
|
+
return support.reason;
|
|
85
|
+
return `${support.reason}
|
|
86
|
+
|
|
87
|
+
${GO_REMEDIATION_OPTIONS}`;
|
|
88
|
+
}
|
|
65
89
|
var GO_TEMPLATE_PRIMITIVES = {
|
|
66
90
|
"JSON.stringify": { arity: 1, emit: (args) => `bf_json ${args[0]}` },
|
|
67
91
|
String: { arity: 1, emit: (args) => `bf_string ${args[0]}` },
|
|
@@ -93,6 +117,7 @@ class GoTemplateAdapter extends BaseAdapter {
|
|
|
93
117
|
localStructFields = new Map;
|
|
94
118
|
synthStructTypes = new Map;
|
|
95
119
|
usesHtmlTemplate = false;
|
|
120
|
+
moduleStringConsts = new Map;
|
|
96
121
|
constructor(options = {}) {
|
|
97
122
|
super();
|
|
98
123
|
this.options = {
|
|
@@ -107,6 +132,7 @@ class GoTemplateAdapter extends BaseAdapter {
|
|
|
107
132
|
this.templateVarCounter = 0;
|
|
108
133
|
this.propsObjectName = ir.metadata.propsObjectName;
|
|
109
134
|
this.restPropsName = ir.metadata.restPropsName ?? null;
|
|
135
|
+
this.moduleStringConsts = this.collectModuleStringConsts(ir.metadata.localConstants);
|
|
110
136
|
if (!options?.siblingTemplatesRegistered) {
|
|
111
137
|
this.checkImportedLoopChildComponents(ir);
|
|
112
138
|
}
|
|
@@ -888,6 +914,12 @@ ${goFields.join(`
|
|
|
888
914
|
collectStaticChildInstancesRecursive(node, result, inLoop) {
|
|
889
915
|
if (node.type === "component") {
|
|
890
916
|
const comp = node;
|
|
917
|
+
if (comp.dynamicTag) {
|
|
918
|
+
for (const child of comp.children) {
|
|
919
|
+
this.collectStaticChildInstancesRecursive(child, result, inLoop);
|
|
920
|
+
}
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
891
923
|
if (comp.name !== "Portal" && !inLoop && comp.slotId) {
|
|
892
924
|
const suffix = slotIdToFieldSuffix(comp.slotId);
|
|
893
925
|
result.push({
|
|
@@ -1724,6 +1756,9 @@ ${goFields.join(`
|
|
|
1724
1756
|
return emitParsedExpr(expr, this);
|
|
1725
1757
|
}
|
|
1726
1758
|
identifier(name) {
|
|
1759
|
+
const inlined = this.resolveModuleStringConst(name);
|
|
1760
|
+
if (inlined !== null)
|
|
1761
|
+
return inlined;
|
|
1727
1762
|
const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1];
|
|
1728
1763
|
if (currentLoopParam && name === currentLoopParam)
|
|
1729
1764
|
return ".";
|
|
@@ -1745,6 +1780,48 @@ ${goFields.join(`
|
|
|
1745
1780
|
const prefix = this.loopParamStack.length > 0 ? "$." : ".";
|
|
1746
1781
|
return `${prefix}${this.capitalizeFieldName(name)}`;
|
|
1747
1782
|
}
|
|
1783
|
+
collectModuleStringConsts(constants) {
|
|
1784
|
+
const map = new Map;
|
|
1785
|
+
for (const c of constants ?? []) {
|
|
1786
|
+
if (!c.isModule)
|
|
1787
|
+
continue;
|
|
1788
|
+
if (c.value === undefined)
|
|
1789
|
+
continue;
|
|
1790
|
+
const literal = this.parsePureStringLiteral(c.value);
|
|
1791
|
+
if (literal !== null)
|
|
1792
|
+
map.set(c.name, literal);
|
|
1793
|
+
}
|
|
1794
|
+
return map;
|
|
1795
|
+
}
|
|
1796
|
+
parsePureStringLiteral(source) {
|
|
1797
|
+
const sf = ts.createSourceFile("__const.ts", `const __x = (${source});`, ts.ScriptTarget.Latest, false);
|
|
1798
|
+
const stmt = sf.statements[0];
|
|
1799
|
+
if (!stmt || !ts.isVariableStatement(stmt))
|
|
1800
|
+
return null;
|
|
1801
|
+
const decl = stmt.declarationList.declarations[0];
|
|
1802
|
+
let init = decl?.initializer;
|
|
1803
|
+
while (init && ts.isParenthesizedExpression(init))
|
|
1804
|
+
init = init.expression;
|
|
1805
|
+
if (!init)
|
|
1806
|
+
return null;
|
|
1807
|
+
if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
|
|
1808
|
+
return init.text;
|
|
1809
|
+
}
|
|
1810
|
+
return null;
|
|
1811
|
+
}
|
|
1812
|
+
resolveModuleStringConst(name) {
|
|
1813
|
+
if (this.loopParamStack.length > 0 && this.loopParamStack[this.loopParamStack.length - 1] === name) {
|
|
1814
|
+
return null;
|
|
1815
|
+
}
|
|
1816
|
+
if (this.loopVarRefCount.has(name))
|
|
1817
|
+
return null;
|
|
1818
|
+
if (this.isOuterLoopParam(name))
|
|
1819
|
+
return null;
|
|
1820
|
+
const value = this.moduleStringConsts.get(name);
|
|
1821
|
+
if (value === undefined)
|
|
1822
|
+
return null;
|
|
1823
|
+
return `"${this.escapeGoString(value)}"`;
|
|
1824
|
+
}
|
|
1748
1825
|
literal(value, literalType) {
|
|
1749
1826
|
if (literalType === "string")
|
|
1750
1827
|
return `"${value}"`;
|
|
@@ -1905,9 +1982,7 @@ ${goFields.join(`
|
|
|
1905
1982
|
message: `Higher-order method '.${method}' shape cannot be lowered to a Go template action`,
|
|
1906
1983
|
loc: this.makeLoc(),
|
|
1907
1984
|
suggestion: {
|
|
1908
|
-
message:
|
|
1909
|
-
1. Use @client directive for client-side evaluation
|
|
1910
|
-
2. Pre-compute the value in Go code`
|
|
1985
|
+
message: GO_REMEDIATION_OPTIONS
|
|
1911
1986
|
}
|
|
1912
1987
|
});
|
|
1913
1988
|
return `""`;
|
|
@@ -2027,6 +2102,26 @@ ${goFields.join(`
|
|
|
2027
2102
|
sortMethod(method, object, comparator, emit) {
|
|
2028
2103
|
return emitBfSort(emit(object), comparator);
|
|
2029
2104
|
}
|
|
2105
|
+
reduceMethod(method, object, reduceOp, emit) {
|
|
2106
|
+
const direction = method === "reduceRight" ? "right" : "left";
|
|
2107
|
+
return emitBfReduce(emit(object), reduceOp, direction);
|
|
2108
|
+
}
|
|
2109
|
+
flatMethod(object, depth, emit) {
|
|
2110
|
+
const d = depth === "infinity" ? -1 : depth;
|
|
2111
|
+
return `bf_flat ${wrapIfMultiToken(emit(object))} ${d}`;
|
|
2112
|
+
}
|
|
2113
|
+
flatMapMethod(object, op, emit) {
|
|
2114
|
+
const recv = wrapIfMultiToken(emit(object));
|
|
2115
|
+
const proj = op.projection;
|
|
2116
|
+
if (proj.kind === "tuple") {
|
|
2117
|
+
const pairs = proj.elements.map((l) => l.kind === "self" ? `"self" ""` : `"field" "${capitalize(l.field)}"`).join(" ");
|
|
2118
|
+
return `bf_flat_map_tuple ${recv} ${pairs}`;
|
|
2119
|
+
}
|
|
2120
|
+
if (proj.kind === "self") {
|
|
2121
|
+
return `bf_flat_map ${recv} "self" ""`;
|
|
2122
|
+
}
|
|
2123
|
+
return `bf_flat_map ${recv} "field" "${capitalize(proj.field)}"`;
|
|
2124
|
+
}
|
|
2030
2125
|
unsupported(raw, _reason) {
|
|
2031
2126
|
return `[UNSUPPORTED: ${raw}]`;
|
|
2032
2127
|
}
|
|
@@ -2449,13 +2544,7 @@ ${goFields.join(`
|
|
|
2449
2544
|
message: `Expression not supported: ${trimmed}`,
|
|
2450
2545
|
loc: this.makeLoc(),
|
|
2451
2546
|
suggestion: {
|
|
2452
|
-
message: support
|
|
2453
|
-
|
|
2454
|
-
Options:
|
|
2455
|
-
1. Use @client directive for client-side evaluation
|
|
2456
|
-
2. Pre-compute the value in Go code` : `Options:
|
|
2457
|
-
1. Use @client directive for client-side evaluation
|
|
2458
|
-
2. Pre-compute the value in Go code`
|
|
2547
|
+
message: buildUnsupportedSuggestion(support)
|
|
2459
2548
|
}
|
|
2460
2549
|
});
|
|
2461
2550
|
return `""`;
|
|
@@ -2484,9 +2573,7 @@ Options:
|
|
|
2484
2573
|
message: `Complex predicate in else-if is not supported: ${altIfStmt.condition}`,
|
|
2485
2574
|
loc: this.makeLoc(),
|
|
2486
2575
|
suggestion: {
|
|
2487
|
-
message:
|
|
2488
|
-
1. Use @client directive for client-side evaluation
|
|
2489
|
-
2. Pre-compute the value in Go code`
|
|
2576
|
+
message: GO_REMEDIATION_OPTIONS
|
|
2490
2577
|
}
|
|
2491
2578
|
});
|
|
2492
2579
|
}
|
|
@@ -2562,11 +2649,7 @@ Options:
|
|
|
2562
2649
|
message: `Condition not supported: ${trimmed}`,
|
|
2563
2650
|
loc: this.makeLoc(),
|
|
2564
2651
|
suggestion: {
|
|
2565
|
-
message: support
|
|
2566
|
-
|
|
2567
|
-
Options:
|
|
2568
|
-
1. Use @client directive for client-side evaluation
|
|
2569
|
-
2. Pre-compute the value in Go code` : "Expression contains unsupported syntax"
|
|
2652
|
+
message: buildUnsupportedSuggestion(support)
|
|
2570
2653
|
}
|
|
2571
2654
|
});
|
|
2572
2655
|
return { condition: `false`, preamble: "" };
|
|
@@ -2579,6 +2662,9 @@ Options:
|
|
|
2579
2662
|
switch (expr.kind) {
|
|
2580
2663
|
case "identifier":
|
|
2581
2664
|
{
|
|
2665
|
+
const inlined = this.resolveModuleStringConst(expr.name);
|
|
2666
|
+
if (inlined !== null)
|
|
2667
|
+
return plain(inlined);
|
|
2582
2668
|
const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1];
|
|
2583
2669
|
if (currentLoopParam && expr.name === currentLoopParam) {
|
|
2584
2670
|
return plain(".");
|
|
@@ -2601,6 +2687,20 @@ Options:
|
|
|
2601
2687
|
if (expr.callee.kind === "identifier" && expr.args.length === 0) {
|
|
2602
2688
|
return plain(this.rootFieldRef(expr.callee.name));
|
|
2603
2689
|
}
|
|
2690
|
+
if (expr.callee.kind === "identifier" && (identifierPath(expr.callee) ?? expr.callee.name) === "isValidElement" && expr.args.length === 1) {
|
|
2691
|
+
return this.renderConditionExpr(expr.args[0]);
|
|
2692
|
+
}
|
|
2693
|
+
if (expr.callee.kind === "identifier" && !this.templatePrimitives[identifierPath(expr.callee) ?? ""]) {
|
|
2694
|
+
const path = identifierPath(expr.callee) ?? expr.callee.name;
|
|
2695
|
+
this.errors.push({
|
|
2696
|
+
code: "BF102",
|
|
2697
|
+
severity: "error",
|
|
2698
|
+
message: `Predicate '${path}(...)' cannot be evaluated in a Go template. ` + `A server-side template cannot call user-defined JavaScript predicates.`,
|
|
2699
|
+
loc: this.makeLoc(),
|
|
2700
|
+
suggestion: { message: GO_REMEDIATION_OPTIONS }
|
|
2701
|
+
});
|
|
2702
|
+
return plain("false");
|
|
2703
|
+
}
|
|
2604
2704
|
return plain(this.renderParsedExpr(expr));
|
|
2605
2705
|
}
|
|
2606
2706
|
case "member": {
|
|
@@ -2812,6 +2912,9 @@ Options:
|
|
|
2812
2912
|
if (comp.name === "Portal") {
|
|
2813
2913
|
return this.renderPortalComponent(comp);
|
|
2814
2914
|
}
|
|
2915
|
+
if (comp.dynamicTag) {
|
|
2916
|
+
return this.renderChildren(comp.children);
|
|
2917
|
+
}
|
|
2815
2918
|
let templateCall;
|
|
2816
2919
|
if (this.inLoop) {
|
|
2817
2920
|
templateCall = `{{template "${comp.name}" .}}`;
|
|
@@ -2955,7 +3058,7 @@ ${children}`;
|
|
|
2955
3058
|
continue;
|
|
2956
3059
|
const branches = caseEntries.map(([k, v], i) => {
|
|
2957
3060
|
const head = i === 0 ? "{{if" : "{{else if";
|
|
2958
|
-
return `${head} eq ${keyExpr} ${JSON.stringify(k)}}}${v}`;
|
|
3061
|
+
return `${head} eq ${keyExpr} ${JSON.stringify(k)}}}${this.escapeAttrText(v)}`;
|
|
2959
3062
|
});
|
|
2960
3063
|
output += branches.join("") + "{{end}}";
|
|
2961
3064
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@barefootjs/go-template",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Go html/template adapter for BarefootJS - generates Go template files from IR",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -54,6 +54,6 @@
|
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@barefootjs/adapter-tests": "0.1.0",
|
|
57
|
-
"@barefootjs/jsx": "0.
|
|
57
|
+
"@barefootjs/jsx": "0.7.0"
|
|
58
58
|
}
|
|
59
59
|
}
|
|
@@ -76,16 +76,17 @@ runAdapterConformanceTests({
|
|
|
76
76
|
// option fixed on the Hono side. Separate follow-up.
|
|
77
77
|
'toggle-shared',
|
|
78
78
|
'props-reactivity-comparison',
|
|
79
|
-
// #1467 Phase
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
'
|
|
79
|
+
// #1467 Phase 2b: basic interactive `site/ui` primitives. Cross-adapter
|
|
80
|
+
// parity for the `site/ui` corpus is Phase 3, so these participate only
|
|
81
|
+
// in Hono SSR conformance + the fixture-hydrate runtime layer for now.
|
|
82
|
+
// (`label` and `kbd` are static helpers; `toggle` / `switch` /
|
|
83
|
+
// `checkbox` carry uncontrolled state; `input` / `textarea` are
|
|
84
|
+
// pass-through native controls.)
|
|
85
|
+
'toggle',
|
|
86
|
+
'switch',
|
|
87
|
+
'checkbox',
|
|
88
|
+
'textarea',
|
|
89
|
+
'kbd',
|
|
89
90
|
],
|
|
90
91
|
// Per-fixture build-time contracts for shapes the Go template
|
|
91
92
|
// adapter intentionally refuses to lower. Lives here (not on the
|
|
@@ -298,6 +299,28 @@ export function Counter(props: { initial?: number }) {
|
|
|
298
299
|
expect(result.types).toContain('Count int')
|
|
299
300
|
})
|
|
300
301
|
|
|
302
|
+
test('module pure-string const referenced in className inlines the literal (#1467 Phase 2b)', () => {
|
|
303
|
+
// A module-scope `const X = 'literal'` used inside a className
|
|
304
|
+
// template literal must inline its value, NOT emit `{{.X}}` against a
|
|
305
|
+
// Props field that never exists (Go fails `can't evaluate field X`).
|
|
306
|
+
// Hono inlines it at runtime; this restores byte-parity.
|
|
307
|
+
const source = `
|
|
308
|
+
"use client"
|
|
309
|
+
const labelClasses = 'flex items-center group-data-[disabled=true]:opacity-50'
|
|
310
|
+
export function Label({ className = '' }: { className?: string }) {
|
|
311
|
+
return <label className={\`\${labelClasses} \${className}\`} />
|
|
312
|
+
}
|
|
313
|
+
`
|
|
314
|
+
const { template, types } = compileAndGenerate(source)
|
|
315
|
+
// The literal is inlined as a Go string literal, escaped tokens intact.
|
|
316
|
+
expect(template).toContain(
|
|
317
|
+
'{{"flex items-center group-data-[disabled=true]:opacity-50"}}',
|
|
318
|
+
)
|
|
319
|
+
// No struct-field reference to the const, and no Props field for it.
|
|
320
|
+
expect(template).not.toContain('.LabelClasses')
|
|
321
|
+
expect(types).not.toContain('LabelClasses')
|
|
322
|
+
})
|
|
323
|
+
|
|
301
324
|
test('dynamic loop with child component → NewXxxProps carries a populate-this-slice doc comment (#1442 echo TodoApp repro)', () => {
|
|
302
325
|
// Regression: a `todos().map(t => <TodoItem todo={t} />)` loop with a
|
|
303
326
|
// dynamic array (signal getter, not a static prop) declares
|
|
@@ -2318,6 +2341,81 @@ describe('GoTemplateAdapter - #1448 Tier A/B fixture-driven lowering pins', () =
|
|
|
2318
2341
|
// now listed in `UNSUPPORTED_METHODS`, so `isSupported` refuses them
|
|
2319
2342
|
// and `convertExpressionToGo` records BF101 — the same treatment the
|
|
2320
2343
|
// unsupported array methods already got. These tests pin that parity.
|
|
2344
|
+
describe('GoTemplateAdapter - #1448 Tier C reduce field capitalisation', () => {
|
|
2345
|
+
// #1728 review: `bf_reduce`'s projected field name must use the same
|
|
2346
|
+
// initialism-aware capitalisation the adapter applies when generating
|
|
2347
|
+
// struct fields (`capitalizeFieldName`), or the runtime reflect lookup
|
|
2348
|
+
// misses the exported field (`id` → struct `ID`, not `Id`) and folds a
|
|
2349
|
+
// zero value. Pin the emitted key so a regression to plain
|
|
2350
|
+
// first-letter capitalisation fails here.
|
|
2351
|
+
test('reduce over an initialism field emits the Go-initialism key (ID, not Id)', () => {
|
|
2352
|
+
const adapter = new GoTemplateAdapter()
|
|
2353
|
+
const ir = compileToIR(`
|
|
2354
|
+
function C({ items }: { items: { id: number }[] }) {
|
|
2355
|
+
return <div>{items.reduce((sum, x) => sum + x.id, 0)}</div>
|
|
2356
|
+
}
|
|
2357
|
+
export { C }
|
|
2358
|
+
`, adapter)
|
|
2359
|
+
const template = adapter.generate(ir).template ?? ''
|
|
2360
|
+
expect(template).toContain('bf_reduce .Items "+" "field" "ID" "numeric" "0"')
|
|
2361
|
+
expect(template).not.toContain('"Id"')
|
|
2362
|
+
})
|
|
2363
|
+
})
|
|
2364
|
+
|
|
2365
|
+
describe('GoTemplateAdapter - #1448 Tier C .flat(depth?)', () => {
|
|
2366
|
+
function emitFlat(expr: string): string {
|
|
2367
|
+
const adapter = new GoTemplateAdapter()
|
|
2368
|
+
const ir = compileToIR(`
|
|
2369
|
+
function C({ rows }: { rows: number[][] }) {
|
|
2370
|
+
return <div>{${expr}}</div>
|
|
2371
|
+
}
|
|
2372
|
+
export { C }
|
|
2373
|
+
`, adapter)
|
|
2374
|
+
return adapter.generate(ir).template ?? ''
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
test('.flat() emits bf_flat with default depth 1', () => {
|
|
2378
|
+
expect(emitFlat('rows.flat()')).toContain('bf_flat .Rows 1')
|
|
2379
|
+
})
|
|
2380
|
+
|
|
2381
|
+
test('.flat(2) emits the explicit depth', () => {
|
|
2382
|
+
expect(emitFlat('rows.flat(2)')).toContain('bf_flat .Rows 2')
|
|
2383
|
+
})
|
|
2384
|
+
|
|
2385
|
+
test('.flat(Infinity) emits the -1 full-depth sentinel', () => {
|
|
2386
|
+
expect(emitFlat('rows.flat(Infinity)')).toContain('bf_flat .Rows -1')
|
|
2387
|
+
})
|
|
2388
|
+
})
|
|
2389
|
+
|
|
2390
|
+
describe('GoTemplateAdapter - #1448 Tier C .flatMap(field projection)', () => {
|
|
2391
|
+
function emitFlatMap(expr: string): string {
|
|
2392
|
+
const adapter = new GoTemplateAdapter()
|
|
2393
|
+
const ir = compileToIR(`
|
|
2394
|
+
function C({ rows }: { rows: { a: string; b: string; tags: string[] }[] }) {
|
|
2395
|
+
return <div>{${expr}}</div>
|
|
2396
|
+
}
|
|
2397
|
+
export { C }
|
|
2398
|
+
`, adapter)
|
|
2399
|
+
return adapter.generate(ir).template ?? ''
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
test('.flatMap(i => i.field) emits bf_flat_map with the Go-capitalised field', () => {
|
|
2403
|
+
expect(emitFlatMap('rows.flatMap(i => i.tags).join(" ")')).toContain('bf_flat_map .Rows "field" "Tags"')
|
|
2404
|
+
})
|
|
2405
|
+
|
|
2406
|
+
test('.flatMap(i => i) emits the self projection', () => {
|
|
2407
|
+
expect(emitFlatMap('rows.flatMap(i => i).join(" ")')).toContain('bf_flat_map .Rows "self" ""')
|
|
2408
|
+
})
|
|
2409
|
+
|
|
2410
|
+
test('.flatMap(i => [i.a, i.b]) emits bf_flat_map_tuple with capitalised leaves', () => {
|
|
2411
|
+
expect(emitFlatMap('rows.flatMap(i => [i.a, i.b]).join(" ")')).toContain('bf_flat_map_tuple .Rows "field" "A" "field" "B"')
|
|
2412
|
+
})
|
|
2413
|
+
|
|
2414
|
+
test('tuple self + field leaves', () => {
|
|
2415
|
+
expect(emitFlatMap('rows.flatMap(i => [i, i.a]).join(" ")')).toContain('bf_flat_map_tuple .Rows "self" "" "field" "A"')
|
|
2416
|
+
})
|
|
2417
|
+
})
|
|
2418
|
+
|
|
2321
2419
|
describe('GoTemplateAdapter - #1448 @client escape hatch (unsupported methods)', () => {
|
|
2322
2420
|
// Compile a single expression placed in `<div>` text position, with
|
|
2323
2421
|
// and without the directive, and return both the build errors and
|
|
@@ -2363,10 +2461,14 @@ export function C() {
|
|
|
2363
2461
|
// Go fragment that must NOT survive into the template (the pre-fix
|
|
2364
2462
|
// silent-footgun output for the string rows).
|
|
2365
2463
|
const unsupported: Array<{ name: string; expr: string; badEmit: string }> = [
|
|
2366
|
-
// Tier C array methods.
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2464
|
+
// Tier C array methods. The arithmetic-fold `.reduce(fn, init)`
|
|
2465
|
+
// catalogue now lowers (pinned in the positive reduce-* fixtures);
|
|
2466
|
+
// the no-initial-value form stays refused — JS throws on an empty
|
|
2467
|
+
// array there, which a template can't mirror.
|
|
2468
|
+
{ name: 'reduce (no init)', expr: `items().reduce((a, b) => a + b.n)`, badEmit: '.Reduce' },
|
|
2469
|
+
// Self / field / field-tuple `.flatMap` now lowers (#1448 Tier C); a
|
|
2470
|
+
// tuple with a non-leaf element (here a string literal) stays refused.
|
|
2471
|
+
{ name: 'flatMap (literal element)', expr: `items().flatMap(i => [i.name, "x"])`, badEmit: '.FlatMap' },
|
|
2370
2472
|
// Lowered methods whose MEANINGFUL extra argument isn't lowered yet
|
|
2371
2473
|
// (#1448): the `fromIndex` of `.includes`/`.indexOf`/`.lastIndexOf`
|
|
2372
2474
|
// and the variadic `.concat`. The parser refuses these (silently
|
|
@@ -2401,6 +2503,40 @@ export function C() {
|
|
|
2401
2503
|
})
|
|
2402
2504
|
}
|
|
2403
2505
|
|
|
2506
|
+
// The diagnostic's `suggestion.message` is shaped by `isSupported`'s
|
|
2507
|
+
// `selfContained` flag: reasons that already spell out the fix are shown
|
|
2508
|
+
// as-is, while low-level parser reasons still get the adapter's generic
|
|
2509
|
+
// "Options" remediation appended so users never lose actionable steps.
|
|
2510
|
+
function suggestionFor(expr: string): string {
|
|
2511
|
+
const { errors } = emit(expr, false)
|
|
2512
|
+
const e = errors.find(e => e.code === 'BF101' || e.code === 'BF102')
|
|
2513
|
+
return e?.suggestion?.message ?? ''
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
test('self-contained reason is shown without the generic Options block', () => {
|
|
2517
|
+
// Generic unsupported-method reason already carries the remedy.
|
|
2518
|
+
const msg = suggestionFor('items().reduce((a, b) => a + b.n)')
|
|
2519
|
+
expect(msg).toContain('no SSR')
|
|
2520
|
+
expect(msg).not.toContain('Options:')
|
|
2521
|
+
})
|
|
2522
|
+
|
|
2523
|
+
test('tailored forEach reason keeps its own guidance, no Options block', () => {
|
|
2524
|
+
const msg = suggestionFor('items().forEach(x => x)')
|
|
2525
|
+
expect(msg).toContain("'.map(")
|
|
2526
|
+
// The forEach message deliberately steers away from @client; the
|
|
2527
|
+
// generic Options block (which re-suggests it) must not be appended.
|
|
2528
|
+
expect(msg).not.toContain('Options:')
|
|
2529
|
+
expect(msg).not.toContain('@client')
|
|
2530
|
+
})
|
|
2531
|
+
|
|
2532
|
+
test('low-level reason still gets the actionable Options block appended', () => {
|
|
2533
|
+
// `typeof` has no structured remedy reason → users must keep the
|
|
2534
|
+
// generic next steps (regression guard for #1730 review).
|
|
2535
|
+
const msg = suggestionFor('typeof items()')
|
|
2536
|
+
expect(msg).toContain('Options:')
|
|
2537
|
+
expect(msg).toContain('@client')
|
|
2538
|
+
})
|
|
2539
|
+
|
|
2404
2540
|
// Predicate-level use of an unsupported string method also fails the
|
|
2405
2541
|
// build loudly (intended): a `.filter(t => t.name.charAt(0) === "a")`
|
|
2406
2542
|
// whose predicate calls one of the gated methods now refuses the whole
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the Go template lowering of the `Slot` component's dynamic tag.
|
|
3
|
+
*
|
|
4
|
+
* `Slot` does `const Tag = children.tag; return <Tag …>{…}</Tag>` inside
|
|
5
|
+
* an `if (isValidElement(children)) { … }` block. A naive lowering would:
|
|
6
|
+
* 1. emit `{{template "Tag" .TagSlot0}}` — a template that can never be
|
|
7
|
+
* registered. Go's html/template escape-walks ALL registered
|
|
8
|
+
* templates (even dead branches), so the whole render fails with
|
|
9
|
+
* `no such template "Tag"`.
|
|
10
|
+
* 2. lower `isValidElement(children)` to a bogus `.IsValidElement`
|
|
11
|
+
* struct-field access (a user type-guard predicate Go can't evaluate).
|
|
12
|
+
*
|
|
13
|
+
* The `dynamicTag` IR flag (jsx-to-ir) plus the call-condition fallback
|
|
14
|
+
* (go-template-adapter) defuse both. This test asserts the *emitted Go
|
|
15
|
+
* template string* carries neither pathology, complementing the full
|
|
16
|
+
* conformance render in `go-template-adapter.test.ts`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, test, expect } from 'bun:test'
|
|
20
|
+
import { GoTemplateAdapter } from '../adapter/go-template-adapter'
|
|
21
|
+
import { compileJSX } from '@barefootjs/jsx'
|
|
22
|
+
|
|
23
|
+
// The committed SSR-precompiled Slot module the conformance harness feeds
|
|
24
|
+
// to Go (`children.tag` dynamic tag inside an isValidElement guard).
|
|
25
|
+
const SLOT_SSR = `/** @jsxImportSource hono/jsx */
|
|
26
|
+
interface SlotProps { children?: unknown; className?: string; [key: string]: unknown }
|
|
27
|
+
function isValidElement(element: unknown): element is { tag: unknown; props: Record<string, unknown> } {
|
|
28
|
+
return !!(element && typeof element === 'object' && 'tag' in element && 'props' in element)
|
|
29
|
+
}
|
|
30
|
+
export function Slot({ children, className, ...props }: SlotProps) {
|
|
31
|
+
if (children && isValidElement(children)) {
|
|
32
|
+
const Tag = children.tag as any
|
|
33
|
+
const childProps = children.props
|
|
34
|
+
const childClass = (childProps.className as string) || ''
|
|
35
|
+
const childChildren = childProps.children
|
|
36
|
+
const mergedClass = [className, childClass].filter(Boolean).join(' ')
|
|
37
|
+
return <Tag {...childProps} {...props} className={mergedClass || undefined}>{childChildren}</Tag>
|
|
38
|
+
}
|
|
39
|
+
return <>{children}</>
|
|
40
|
+
}
|
|
41
|
+
`
|
|
42
|
+
|
|
43
|
+
describe('Slot dynamic-tag Go lowering', () => {
|
|
44
|
+
test('emitted Go template has no `{{template "Tag"` call and no `.IsValidElement` field', () => {
|
|
45
|
+
const adapter = new GoTemplateAdapter()
|
|
46
|
+
const result = compileJSX(SLOT_SSR, 'slot.tsx', { adapter, componentName: 'Slot' })
|
|
47
|
+
expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
|
|
48
|
+
const template = result.files.find(f => f.type === 'markedTemplate')!
|
|
49
|
+
expect(template).toBeDefined()
|
|
50
|
+
expect(template.content).not.toContain('{{template "Tag"')
|
|
51
|
+
expect(template.content).not.toContain('.IsValidElement')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('`isValidElement(children)` lowers to a real truthiness check — no diagnostic, no forced literal', () => {
|
|
55
|
+
const adapter = new GoTemplateAdapter()
|
|
56
|
+
const result = compileJSX(SLOT_SSR, 'slot.tsx', { adapter, componentName: 'Slot' })
|
|
57
|
+
// The guard evaluates faithfully on Go (element ⟺ has children to render),
|
|
58
|
+
// so there is neither an error nor an ignorable warning, and the condition
|
|
59
|
+
// is a real `.Children` truthiness check rather than a fudged literal.
|
|
60
|
+
expect(result.errors).toEqual([])
|
|
61
|
+
const template = result.files.find(f => f.type === 'markedTemplate')!
|
|
62
|
+
expect(template.content).toContain('.Children')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('a non-isValidElement user predicate (e.g. `isAdmin`) is a hard BF102 error, not a silent literal', () => {
|
|
66
|
+
const SRC = `/** @jsxImportSource hono/jsx */
|
|
67
|
+
declare function isAdmin(u: unknown): boolean
|
|
68
|
+
export function Gate({ user }: { user?: unknown }) {
|
|
69
|
+
return <div>{isAdmin(user) ? <span>secret</span> : null}</div>
|
|
70
|
+
}
|
|
71
|
+
`
|
|
72
|
+
const adapter = new GoTemplateAdapter()
|
|
73
|
+
const result = compileJSX(SRC, 'gate.tsx', { adapter, componentName: 'Gate' })
|
|
74
|
+
const errors = result.errors.filter(e => e.severity === 'error')
|
|
75
|
+
expect(
|
|
76
|
+
errors.some(e => e.code === 'BF102' && /cannot be evaluated/i.test(e.message))
|
|
77
|
+
).toBe(true)
|
|
78
|
+
})
|
|
79
|
+
})
|