@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/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
- return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1);
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: `Options:
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.reason ? `${support.reason}
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: `Options:
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.reason ? `${support.reason}
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.6.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.6.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 2a: first `site/ui` source-root fixture. Button
80
- // compiles cleanly on the Go adapter (no diagnostic), but its
81
- // variant/size class composition (`Record<…>[key]` indexed object
82
- // literals) and `applyRestAttrs` spread haven't been validated for
83
- // byte-parity against the Hono reference under Go's template
84
- // semantics. Cross-adapter parity for the `site/ui` corpus is
85
- // explicitly Phase 3 ("cross-adapter parity (Mojo/Go templates)"),
86
- // so Button participates only in Hono SSR conformance + the
87
- // fixture-hydrate runtime layer for now.
88
- 'button',
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
- { name: 'reduce', expr: `items().reduce((a, b) => a + b.n, 0)`, badEmit: '.Reduce' },
2368
- { name: 'flatMap', expr: `items().flatMap(i => i.tags)`, badEmit: '.FlatMap' },
2369
- { name: 'flat', expr: `items().flat()`, badEmit: '.Flat' },
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
+ })