@barefootjs/go-template 0.8.0 → 0.9.1

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.
@@ -58,6 +58,10 @@ import {
58
58
  emitParsedExpr,
59
59
  emitIRNode,
60
60
  emitAttrValue,
61
+ augmentInheritedPropAccesses,
62
+ parseRecordIndexAccess,
63
+ collectContextConsumers,
64
+ type ContextConsumer,
61
65
  } from '@barefootjs/jsx'
62
66
  import { findInterpolationEnd } from '@barefootjs/jsx/scanner'
63
67
 
@@ -100,6 +104,24 @@ interface StaticChildInstance {
100
104
  * through the parent's `{{.Children}}` read; those cases stay on
101
105
  * the existing drop path. */
102
106
  childrenHtml: string | null
107
+ /**
108
+ * Context values from enclosing `<Ctx.Provider value>` ancestors
109
+ * (`createContext` identifier → Go value literal), wired into this child
110
+ * slot's input against its own context-consumer fields. Empty/undefined when
111
+ * the child isn't under any provider. (#1297)
112
+ */
113
+ contextBindings?: ReadonlyMap<string, string>
114
+ }
115
+
116
+ /**
117
+ * Cross-component shape of a child component the parent renders (#checkbox).
118
+ * `paramNames` are the child's declared `propsParams`; `restBagField` is the
119
+ * Go field name of the child's open-ended rest bag (`Capitalize(restPropsName)`),
120
+ * or null when the child has no `...props` rest spread.
121
+ */
122
+ interface ChildComponentShape {
123
+ paramNames: Set<string>
124
+ restBagField: string | null
103
125
  }
104
126
 
105
127
  /**
@@ -419,6 +441,24 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
419
441
  * `template.HTML(...)`; toggles the `"html/template"` import. */
420
442
  private usesHtmlTemplate: boolean = false
421
443
 
444
+ /** Set during type generation when any emit references
445
+ * `fmt.Sprint(...)` — e.g. a `Record<staticKeys, scalar>[propKey]`
446
+ * indexed-map spread value (#checkbox); toggles the `"fmt"` import. */
447
+ private usesFmt: boolean = false
448
+
449
+ /**
450
+ * Cross-component child shapes (#checkbox), keyed by child component name.
451
+ * Populated out-of-band via `registerChildComponentShape` before the parent
452
+ * component's `generateTypes` runs, so the static-child-init codegen can
453
+ * route an attribute that is NOT a declared param of the child
454
+ * (`<CheckIcon data-slot=.../>`) into the child's rest bag
455
+ * (`Capitalize(restPropsName)` map field) instead of emitting an invalid
456
+ * hyphenated top-level field (`Data-slot:`). A child with no rest bag and
457
+ * an unknown attr is left as-is so the existing field path / Go compile
458
+ * error still surfaces.
459
+ */
460
+ private childComponentShapes: Map<string, ChildComponentShape> = new Map()
461
+
422
462
  /**
423
463
  * Module-scope pure string-literal constants (`const X = 'literal'` at
424
464
  * file top-level), keyed by name → resolved literal value. Populated at
@@ -434,6 +474,25 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
434
474
  */
435
475
  private moduleStringConsts: Map<string, string> = new Map()
436
476
 
477
+ /**
478
+ * All local constants (module + function-scope) from the IR, retained for
479
+ * the lifetime of `generate()` so the memo-computation path can resolve
480
+ * `Record`-index lookups (`variantClasses[variant]`) without re-threading the
481
+ * full `ir` through every helper. Reset at `generate()` entry.
482
+ */
483
+ private localConstants: IRMetadata['localConstants'] = []
484
+
485
+ /**
486
+ * `useContext(...)` consumers in the component being generated. Each becomes
487
+ * a struct field defaulted to the `createContext` default, which an enclosing
488
+ * `<Ctx.Provider value>` overwrites for descendant child slots. Reset at
489
+ * `generate()` / `generateTypes()` entry. (#1297)
490
+ */
491
+ private contextConsumers: ContextConsumer[] = []
492
+
493
+ /** Child component name → the contexts it consumes (cross-component, for provider wiring). */
494
+ private childContextConsumers: Map<string, ContextConsumer[]> = new Map()
495
+
437
496
  /**
438
497
  * Set of prop NAMES whose resolved Go struct-field type is exactly
439
498
  * `interface{}` — i.e. nillable. Populated at `generate()` entry from
@@ -470,6 +529,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
470
529
  this.propsObjectName = ir.metadata.propsObjectName
471
530
  this.restPropsName = ir.metadata.restPropsName ?? null
472
531
  this.moduleStringConsts = this.collectModuleStringConsts(ir.metadata.localConstants)
532
+ this.localConstants = ir.metadata.localConstants ?? []
533
+ this.contextConsumers = collectContextConsumers(ir.metadata)
534
+ // (#checkbox) Enumerate inherited-attribute accesses (props-object pattern)
535
+ // before computing the nillable set / rendering, so the synthetic params
536
+ // participate in attribute omission and field binding uniformly. Shared
537
+ // with the Mojo adapter (single source of truth in `@barefootjs/jsx`).
538
+ augmentInheritedPropAccesses(ir)
473
539
  this.nillablePropNames = this.collectNillablePropNames(ir)
474
540
 
475
541
  // Surface loop-body usages of components imported from sibling
@@ -762,8 +828,70 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
762
828
  return `{{if .Scripts}}${registrations.join('')}{{end}}\n`
763
829
  }
764
830
 
831
+ /**
832
+ * Register a child component's shape (#checkbox) so a parent's
833
+ * static-child-init codegen can route non-param attributes into the child's
834
+ * rest bag rather than emitting an invalid hyphenated top-level field. Call
835
+ * once per known child IR (siblings in the same source, auto-inferred
836
+ * `../<name>` imports) before generating the parent's types. Idempotent.
837
+ */
838
+ registerChildComponentShape(ir: ComponentIR): void {
839
+ const name = ir.metadata.componentName
840
+ if (!name) return
841
+ const paramNames = new Set((ir.metadata.propsParams ?? []).map(p => p.name))
842
+ const restPropsName = ir.metadata.restPropsName ?? null
843
+ const restBagField = restPropsName ? this.capitalizeFieldName(restPropsName) : null
844
+ this.childComponentShapes.set(name, { paramNames, restBagField })
845
+ // Record the contexts this child consumes so a parent wrapping it in
846
+ // `<Ctx.Provider value>` can set the matching field on the child's slot input.
847
+ this.childContextConsumers.set(name, collectContextConsumers(ir.metadata))
848
+ }
849
+
850
+ /** Go field name for a `useContext` consumer (the capitalized local binding). */
851
+ private contextFieldName(c: ContextConsumer): string {
852
+ return this.capitalizeFieldName(c.localName)
853
+ }
854
+
855
+ /** Go type for a context-consumer field, from its `createContext` default's type. */
856
+ private contextConsumerGoType(c: ContextConsumer): string {
857
+ if (typeof c.defaultValue === 'number') return 'int'
858
+ if (typeof c.defaultValue === 'boolean') return 'bool'
859
+ return 'string'
860
+ }
861
+
862
+ /** Go literal for a context-consumer's default value (the `createContext` arg). */
863
+ private contextConsumerGoDefault(c: ContextConsumer): string {
864
+ if (typeof c.defaultValue === 'number') return String(c.defaultValue)
865
+ if (typeof c.defaultValue === 'boolean') return String(c.defaultValue)
866
+ if (typeof c.defaultValue === 'string') return `"${this.escapeGoString(c.defaultValue)}"`
867
+ return '""'
868
+ }
869
+
870
+ /**
871
+ * Context-consumer fields that don't collide with an already-emitted prop /
872
+ * signal / memo field. The template reads them as `{{.Field}}` (the local
873
+ * `useContext` binding lowered to a root field); the struct must carry them.
874
+ */
875
+ private nonCollidingContextConsumers(taken: ReadonlySet<string>): ContextConsumer[] {
876
+ return this.contextConsumers.filter(c => !taken.has(this.contextFieldName(c)))
877
+ }
878
+
765
879
  generateTypes(ir: ComponentIR): string | null {
766
880
  this.usesHtmlTemplate = false
881
+ this.usesFmt = false
882
+ // (#checkbox) Mirror `generate()`: enumerate inherited-attribute accesses
883
+ // so the Input/Props structs expose `ClassName`/`ID`/`Disabled` fields the
884
+ // template + caller bind against. `generateTypes` runs on a separately
885
+ // round-tripped IR, so this must be applied here too; the method is
886
+ // idempotent. `propsObjectName` is needed by the scan.
887
+ this.propsObjectName = ir.metadata.propsObjectName
888
+ augmentInheritedPropAccesses(ir)
889
+ // Mirror `generate()`: the `NewXxxProps` initializer computes memo SSR
890
+ // values, which inline module string consts and resolve `Record`-index
891
+ // lookups — both need the const tables populated on this standalone entry.
892
+ this.moduleStringConsts = this.collectModuleStringConsts(ir.metadata.localConstants)
893
+ this.localConstants = ir.metadata.localConstants ?? []
894
+ this.contextConsumers = collectContextConsumers(ir.metadata)
767
895
  const lines: string[] = []
768
896
 
769
897
  const componentName = ir.metadata.componentName
@@ -856,6 +984,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
856
984
  header.push(`package ${this.options.packageName}`)
857
985
  header.push('')
858
986
  header.push('import (')
987
+ // Go's import block is conventionally sorted; emit in lexical order
988
+ // (`fmt` < `html/template` < `math/rand`).
989
+ if (this.usesFmt) header.push('\t"fmt"')
859
990
  if (this.usesHtmlTemplate) header.push('\t"html/template"')
860
991
  header.push('\t"math/rand"')
861
992
  header.push('')
@@ -1155,6 +1286,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1155
1286
  lines.push(`\t${nested.name}s []${nested.name}Input`)
1156
1287
  }
1157
1288
 
1289
+ // `useContext` consumer fields — settable by an enclosing provider on the
1290
+ // parent's side; default applied in NewXxxProps.
1291
+ const takenInput = new Set(ir.metadata.propsParams.map(p => this.capitalizeFieldName(p.name)))
1292
+ for (const c of this.nonCollidingContextConsumers(takenInput)) {
1293
+ lines.push(`\t${this.contextFieldName(c)} ${this.contextConsumerGoType(c)}`)
1294
+ }
1295
+
1158
1296
  // (#1407 follow-up) Input-side bag field for restPropsName spreads.
1159
1297
  // The destructured-rest pattern
1160
1298
  // (`function({a, ...rest}: P) { <el {...rest}/> }`) surfaces
@@ -1287,6 +1425,18 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1287
1425
  lines.push(`\t${fieldName} ${goType} \`json:"${jsonTag}"\``)
1288
1426
  }
1289
1427
 
1428
+ // `useContext` consumer fields (skip names already taken by a prop /
1429
+ // signal / memo field).
1430
+ const takenProps = new Set<string>([
1431
+ ...ir.metadata.propsParams.map(p => this.capitalizeFieldName(p.name)),
1432
+ ...ir.metadata.signals.map(s => this.capitalizeFieldName(s.getter)),
1433
+ ...ir.metadata.memos.map(m => this.capitalizeFieldName(m.name)),
1434
+ ])
1435
+ for (const c of this.nonCollidingContextConsumers(takenProps)) {
1436
+ const jsonTag = this.toJsonTag(c.localName)
1437
+ lines.push(`\t${this.contextFieldName(c)} ${this.contextConsumerGoType(c)} \`json:"${jsonTag}"\``)
1438
+ }
1439
+
1290
1440
  // Add array fields for nested components (for template rendering)
1291
1441
  for (const nested of nestedComponents) {
1292
1442
  if (nested.isDynamic && !nested.isPropDerived) {
@@ -1466,12 +1616,34 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1466
1616
  }
1467
1617
 
1468
1618
  // Add memo initial values (computed from signal initial values)
1619
+ const memoPropsParamMap = new Map(ir.metadata.propsParams.map(p => [p.name, p]))
1469
1620
  for (const memo of ir.metadata.memos) {
1470
1621
  const fieldName = this.capitalizeFieldName(memo.name)
1471
- const memoValue = this.computeMemoInitialValue(memo, ir.metadata.signals, ir.metadata.propsParams, propFallbackVars)
1622
+ // (#checkbox) Pass the memo's inferred Go type so an unresolved
1623
+ // computation falls back to that type's zero value (`false` for a
1624
+ // boolean memo like `isChecked`), not the int `0`.
1625
+ const goType = this.inferMemoType(memo, ir.metadata.signals, memoPropsParamMap)
1626
+ const memoValue = this.computeMemoInitialValue(memo, ir.metadata.signals, ir.metadata.propsParams, propFallbackVars, goType)
1472
1627
  lines.push(`\t\t${fieldName}: ${memoValue},`)
1473
1628
  }
1474
1629
 
1630
+ // `useContext` consumer fields: default to the `createContext` default
1631
+ // when the caller (a provider) didn't set them.
1632
+ const takenInit = new Set<string>([
1633
+ ...ir.metadata.propsParams.map(p => this.capitalizeFieldName(p.name)),
1634
+ ...ir.metadata.signals.map(s => this.capitalizeFieldName(s.getter)),
1635
+ ...ir.metadata.memos.map(m => this.capitalizeFieldName(m.name)),
1636
+ ])
1637
+ for (const c of this.nonCollidingContextConsumers(takenInit)) {
1638
+ const field = this.contextFieldName(c)
1639
+ const def = this.contextConsumerGoDefault(c)
1640
+ const defaulted =
1641
+ c.defaultValue === null || def === '""' || def === '0' || def === 'false'
1642
+ ? `in.${field}`
1643
+ : this.applyGoFallback(`in.${field}`, def)
1644
+ lines.push(`\t\t${field}: ${defaulted},`)
1645
+ }
1646
+
1475
1647
  // Add static child component instances
1476
1648
  const staticChildren = this.collectStaticChildInstances(ir.root)
1477
1649
  for (const child of staticChildren) {
@@ -1481,15 +1653,49 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1481
1653
  // own NewProps (BfParent/BfMount fields).
1482
1654
  lines.push(`\t\t\tBfParent: scopeID,`)
1483
1655
  lines.push(`\t\t\tBfMount: "${child.slotId}",`)
1656
+ // SSR context propagation: if this child is wrapped in a `<Ctx.Provider
1657
+ // value>` it consumes, set its context-consumer field to the provider
1658
+ // value (else the child's own NewProps applies the `createContext`
1659
+ // default). (#1297)
1660
+ if (child.contextBindings) {
1661
+ for (const consumer of this.childContextConsumers.get(child.name) ?? []) {
1662
+ const goVal = child.contextBindings.get(consumer.contextName)
1663
+ if (goVal !== undefined) {
1664
+ lines.push(`\t\t\t${this.contextFieldName(consumer)}: ${goVal},`)
1665
+ }
1666
+ }
1667
+ }
1668
+ // (#checkbox) Cross-component shape lookup: an attribute that is NOT a
1669
+ // declared param of the child but the child has a `...props` rest bag
1670
+ // (`<CheckIcon data-slot=.../>`, where CheckIcon's params are
1671
+ // `size`/`className` and the rest binding is `props`) must be routed
1672
+ // into the child's rest-bag map field — emitting `Data-slot:` as a
1673
+ // top-level Go field is a syntax error (hyphen). `restBagEntries`
1674
+ // collects `"jsx-attr-name": goValue` pairs for that map.
1675
+ const childShape = this.childComponentShapes.get(child.name)
1676
+ const restBagEntries: string[] = []
1677
+ // Emit a child input field, OR collect it as a rest-bag entry when the
1678
+ // attr isn't a declared child param and a rest bag exists.
1679
+ const emitChildField = (jsxName: string, goValue: string): void => {
1680
+ if (
1681
+ childShape &&
1682
+ childShape.restBagField &&
1683
+ !childShape.paramNames.has(jsxName)
1684
+ ) {
1685
+ restBagEntries.push(`${JSON.stringify(jsxName)}: ${goValue}`)
1686
+ return
1687
+ }
1688
+ lines.push(`\t\t\t${this.capitalizeFieldName(jsxName)}: ${goValue},`)
1689
+ }
1484
1690
  // Add prop values
1485
1691
  for (const prop of child.props) {
1486
1692
  switch (prop.value.kind) {
1487
1693
  case 'literal':
1488
- lines.push(`\t\t\t${this.capitalizeFieldName(prop.name)}: ${this.goLiteral(prop.value.value)},`)
1694
+ emitChildField(prop.name, this.goLiteral(prop.value.value))
1489
1695
  break
1490
1696
  case 'boolean-shorthand':
1491
1697
  case 'boolean-attr':
1492
- lines.push(`\t\t\t${this.capitalizeFieldName(prop.name)}: true,`)
1698
+ emitChildField(prop.name, 'true')
1493
1699
  break
1494
1700
  case 'expression':
1495
1701
  case 'spread':
@@ -1508,7 +1714,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1508
1714
  const goExpr = this.templatePartsToGoCode(parts, ir.metadata.propsParams)
1509
1715
  if (goExpr !== null) {
1510
1716
  // Parts path succeeded — emit and move on.
1511
- lines.push(`\t\t\t${this.capitalizeFieldName(prop.name)}: ${goExpr},`)
1717
+ emitChildField(prop.name, goExpr)
1512
1718
  break
1513
1719
  }
1514
1720
  // Parts exist but templatePartsToGoCode opted out (unsupported
@@ -1526,7 +1732,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1526
1732
  ir.metadata.propsParams
1527
1733
  )
1528
1734
  if (resolvedValue !== null) {
1529
- lines.push(`\t\t\t${this.capitalizeFieldName(prop.name)}: ${resolvedValue},`)
1735
+ emitChildField(prop.name, resolvedValue)
1530
1736
  }
1531
1737
  break
1532
1738
  }
@@ -1535,6 +1741,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1535
1741
  break
1536
1742
  }
1537
1743
  }
1744
+ // (#checkbox) Emit the collected rest-bag entries as the child's
1745
+ // open-ended bag field (`Props: map[string]any{...}`), matching how the
1746
+ // child's `NewXxxProps` maps `in.Props` onto its `Spread_<N>` field.
1747
+ if (childShape?.restBagField && restBagEntries.length > 0) {
1748
+ lines.push(
1749
+ `\t\t\t${childShape.restBagField}: map[string]any{${restBagEntries.join(', ')}},`,
1750
+ )
1751
+ }
1538
1752
  // Pass through JSX children as the child slot's `Children` input.
1539
1753
  // Two paths:
1540
1754
  // 1. Plain text (`<Button>+1</Button>`) → quote with JSON.stringify
@@ -1646,7 +1860,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1646
1860
  */
1647
1861
  private collectStaticChildInstances(node: IRNode): Array<StaticChildInstance> {
1648
1862
  const result: StaticChildInstance[] = []
1649
- this.collectStaticChildInstancesRecursive(node, result, false)
1863
+ this.collectStaticChildInstancesRecursive(node, result, false, new Map())
1650
1864
  return result
1651
1865
  }
1652
1866
 
@@ -1687,7 +1901,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1687
1901
  private collectStaticChildInstancesRecursive(
1688
1902
  node: IRNode,
1689
1903
  result: StaticChildInstance[],
1690
- inLoop: boolean
1904
+ inLoop: boolean,
1905
+ providerCtx: ReadonlyMap<string, string>,
1691
1906
  ): void {
1692
1907
  if (node.type === 'component') {
1693
1908
  const comp = node as IRComponent
@@ -1697,7 +1912,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1697
1912
  // child components nested inside still get their slot fields.
1698
1913
  if (comp.dynamicTag) {
1699
1914
  for (const child of comp.children) {
1700
- this.collectStaticChildInstancesRecursive(child, result, inLoop)
1915
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
1701
1916
  }
1702
1917
  return
1703
1918
  }
@@ -1712,57 +1927,79 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1712
1927
  fieldName: `${comp.name}${suffix}`,
1713
1928
  childrenText: this.extractTextChildren(comp.children),
1714
1929
  childrenHtml: this.extractHtmlChildren(comp.children),
1930
+ contextBindings: providerCtx.size > 0 ? providerCtx : undefined,
1715
1931
  })
1716
1932
  }
1717
1933
  // Recurse into Portal's children to find nested components
1718
1934
  if (comp.name === 'Portal' && comp.children) {
1719
1935
  for (const child of comp.children) {
1720
- this.collectStaticChildInstancesRecursive(child, result, inLoop)
1936
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
1721
1937
  }
1722
1938
  }
1723
1939
  } else if (node.type === 'loop') {
1724
1940
  const loop = node as IRLoop
1725
1941
  // Mark children as inside loop
1726
1942
  for (const child of loop.children) {
1727
- this.collectStaticChildInstancesRecursive(child, result, true)
1943
+ this.collectStaticChildInstancesRecursive(child, result, true, providerCtx)
1728
1944
  }
1729
1945
  } else if (node.type === 'element') {
1730
1946
  const element = node as IRElement
1731
1947
  for (const child of element.children) {
1732
- this.collectStaticChildInstancesRecursive(child, result, inLoop)
1948
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
1733
1949
  }
1734
1950
  } else if (node.type === 'fragment') {
1735
1951
  const fragment = node as IRFragment
1736
1952
  for (const child of fragment.children) {
1737
- this.collectStaticChildInstancesRecursive(child, result, inLoop)
1953
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
1738
1954
  }
1739
1955
  } else if (node.type === 'conditional') {
1740
1956
  const cond = node as IRConditional
1741
- this.collectStaticChildInstancesRecursive(cond.whenTrue, result, inLoop)
1957
+ this.collectStaticChildInstancesRecursive(cond.whenTrue, result, inLoop, providerCtx)
1742
1958
  if (cond.whenFalse) {
1743
- this.collectStaticChildInstancesRecursive(cond.whenFalse, result, inLoop)
1959
+ this.collectStaticChildInstancesRecursive(cond.whenFalse, result, inLoop, providerCtx)
1744
1960
  }
1745
1961
  } else if (node.type === 'provider') {
1746
- // Provider is a transparent wrapper at the SSR layer context
1747
- // propagation is purely a client-runtime concern. Recurse into
1748
- // its children so any static <Child/> nested under <Ctx.Provider>
1749
- // still gets a slot field generated on the parent's props type.
1962
+ // SSR context propagation: record the provider's value against its
1963
+ // context name and extend the active binding map for descendants. A
1964
+ // literal value lowers to a Go literal; a non-literal is left unbound
1965
+ // (the consumer keeps its default). (#1297)
1750
1966
  const p = node as IRProvider
1967
+ const childCtx = this.extendProviderContext(providerCtx, p)
1751
1968
  for (const child of p.children) {
1752
- this.collectStaticChildInstancesRecursive(child, result, inLoop)
1969
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, childCtx)
1753
1970
  }
1754
1971
  } else if (node.type === 'async') {
1755
1972
  // Async fallback + children render server-side via the OOS
1756
1973
  // protocol; static child components inside them still need slot
1757
1974
  // fields on the parent struct.
1758
1975
  const a = node as IRAsync
1759
- this.collectStaticChildInstancesRecursive(a.fallback, result, inLoop)
1976
+ this.collectStaticChildInstancesRecursive(a.fallback, result, inLoop, providerCtx)
1760
1977
  for (const child of a.children) {
1761
- this.collectStaticChildInstancesRecursive(child, result, inLoop)
1978
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
1762
1979
  }
1763
1980
  }
1764
1981
  }
1765
1982
 
1983
+ /**
1984
+ * Extend the active provider-context map with one `<Ctx.Provider value>`. A
1985
+ * string/number/boolean literal value is lowered to a Go literal; any other
1986
+ * shape is skipped (the descendant consumer keeps its `createContext` default).
1987
+ */
1988
+ private extendProviderContext(
1989
+ current: ReadonlyMap<string, string>,
1990
+ p: IRProvider,
1991
+ ): ReadonlyMap<string, string> {
1992
+ const v = p.valueProp?.value as { kind?: string; value?: unknown } | undefined
1993
+ if (!v || v.kind !== 'literal') return current
1994
+ let goLit: string | null = null
1995
+ if (typeof v.value === 'string') goLit = `"${this.escapeGoString(v.value)}"`
1996
+ else if (typeof v.value === 'number' || typeof v.value === 'boolean') goLit = String(v.value)
1997
+ if (goLit === null) return current
1998
+ const next = new Map(current)
1999
+ next.set(p.contextName, goLit)
2000
+ return next
2001
+ }
2002
+
1766
2003
  /**
1767
2004
  * Collect top-level (non-loop) JSX intrinsic-element spread slots
1768
2005
  * from the IR (#1407). Loop-internal spreads are skipped — they
@@ -2029,6 +2266,30 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2029
2266
  if (ir.metadata.restPropsName === trimmed) {
2030
2267
  return `in.${this.capitalizeFieldName(trimmed)}`
2031
2268
  }
2269
+ // 4. Function-scope local const holding a conditional inline-object
2270
+ // spread: `const sizeAttrs = size ? {…} : {}` then `{...sizeAttrs}`
2271
+ // (#checkbox / icon). Resolve the identifier to its initializer
2272
+ // text and route through the conditional-spread lowering. Only
2273
+ // function-scope (`!isModule`) consts qualify — a module const is
2274
+ // a different shape, and the resolved initializer must itself be a
2275
+ // conditional-of-object-literals (else `buildConditionalSpreadInitializer`
2276
+ // returns undefined and we fall through to BF101). Guard against a
2277
+ // const that resolves to another bare identifier (loop / non-literal).
2278
+ const localConst = (ir.metadata.localConstants ?? []).find(
2279
+ c => c.name === trimmed && !c.isModule,
2280
+ )
2281
+ if (localConst?.value !== undefined) {
2282
+ const initTrimmed = localConst.value.trim()
2283
+ // Reject a const resolving to a bare identifier to avoid an
2284
+ // unbounded resolution loop / non-literal forwarding.
2285
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(initTrimmed)) {
2286
+ const resolved = this.buildConditionalSpreadInitializer(initTrimmed, ir)
2287
+ // `undefined` → not a conditional-spread shape; fall through to
2288
+ // BF101. `null` → that shape but unconvertible; also BF101.
2289
+ if (resolved) return resolved
2290
+ if (resolved === null) return null
2291
+ }
2292
+ }
2032
2293
  }
2033
2294
  return null
2034
2295
  }
@@ -2172,13 +2433,52 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2172
2433
  if (!param) return null
2173
2434
  goVal = `in.${this.capitalizeFieldName(param.name)}`
2174
2435
  } else {
2175
- return null
2436
+ const indexed = this.recordIndexAccessToGoMap(val, ir)
2437
+ if (indexed === null) return null
2438
+ goVal = indexed
2176
2439
  }
2177
2440
  entries.push(`${JSON.stringify(key)}: ${goVal}`)
2178
2441
  }
2179
2442
  return `map[string]any{${entries.join(', ')}}`
2180
2443
  }
2181
2444
 
2445
+ /**
2446
+ * Lower a spread-object VALUE of the form `IDENT[KEY]` where:
2447
+ * - `IDENT` resolves via `localConstants` to a MODULE-scope object
2448
+ * literal whose property values are all scalar (number/string)
2449
+ * literals under static (string-literal or identifier) keys
2450
+ * (a `Record<staticKeys, scalar>` map like `sizeMap`), AND
2451
+ * - `KEY` is a bare identifier that is a prop.
2452
+ * Emits an inline indexed Go map:
2453
+ * `map[string]any{"sm": 16, ...}[fmt.Sprint(in.Size)]`
2454
+ * (`fmt.Sprint` coerces the `interface{}`/typed prop to the map's
2455
+ * string key space — sets `usesFmt` so the `"fmt"` import is added).
2456
+ *
2457
+ * Returns the Go string when convertible, else `null` (caller → BF101)
2458
+ * for any non-scalar value, non-static key, or non-prop index so
2459
+ * unrelated shapes don't regress. (#checkbox / icon `sizeMap[size]`.)
2460
+ */
2461
+ private recordIndexAccessToGoMap(
2462
+ val: ts.Expression,
2463
+ ir: ComponentIR,
2464
+ ): string | null {
2465
+ // Shared structural parse (single source of truth in `@barefootjs/jsx`);
2466
+ // this wrapper only does the Go-specific emit from the structured result.
2467
+ const parsed = parseRecordIndexAccess(
2468
+ val,
2469
+ ir.metadata.localConstants ?? [],
2470
+ ir.metadata.propsParams,
2471
+ )
2472
+ if (!parsed) return null
2473
+ const entries = parsed.entries.map(e => {
2474
+ const mapVal = e.value.kind === 'number' ? e.value.text : JSON.stringify(e.value.text)
2475
+ return `${JSON.stringify(e.key)}: ${mapVal}`
2476
+ })
2477
+ this.usesFmt = true
2478
+ const field = `in.${this.capitalizeFieldName(parsed.indexPropName)}`
2479
+ return `map[string]any{${entries.join(', ')}}[fmt.Sprint(${field})]`
2480
+ }
2481
+
2182
2482
  /**
2183
2483
  * Convert JavaScript initial value to Go value for NewXxxProps function.
2184
2484
  * References to props params are converted to in.FieldName format.
@@ -2588,11 +2888,232 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2588
2888
  * referenced prop, substitute it for `in.FieldName` so the memo
2589
2889
  * inherits the signal-time `??` fallback.
2590
2890
  */
2891
+ /**
2892
+ * (#checkbox) Compute the SSR initial value of a template-literal memo as a
2893
+ * Go `string` expression. The memo computation looks like
2894
+ * `() => `${a} ${b} ${props.className ?? ''} grid place-content-center``.
2895
+ *
2896
+ * Each quasi (literal text span) becomes a Go string literal; each
2897
+ * interpolation is resolved:
2898
+ * - an identifier naming a module string const → its inlined literal
2899
+ * (covers both pure-string and `[...].join(' ')` consts);
2900
+ * - `props.<name> ?? '<fallback>'` or bare `props.<name>` → `in.<Field>`
2901
+ * when `<name>` is a known prop param (typed `string`); the `?? ''`
2902
+ * fallback maps to Go's zero value for an unset string field, matching
2903
+ * the Hono reference's empty-string result.
2904
+ *
2905
+ * Returns the `"a" + in.Field + " grid..."` concatenation, or null when the
2906
+ * computation isn't a single template literal or any interpolation isn't
2907
+ * representable (so the caller keeps its existing pattern matching).
2908
+ */
2909
+ private computeTemplateLiteralMemoInitialValue(
2910
+ computation: string,
2911
+ propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
2912
+ ): string | null {
2913
+ const sf = ts.createSourceFile(
2914
+ '__memo.ts',
2915
+ `const __x = (${computation});`,
2916
+ ts.ScriptTarget.Latest,
2917
+ /*setParentNodes*/ false,
2918
+ )
2919
+ const stmt = sf.statements[0]
2920
+ if (!stmt || !ts.isVariableStatement(stmt)) return null
2921
+ let init = stmt.declarationList.declarations[0]?.initializer
2922
+ while (init && ts.isParenthesizedExpression(init)) init = init.expression
2923
+ if (!init || !ts.isArrowFunction(init)) return null
2924
+ let body = init.body as ts.Node
2925
+ while (ts.isParenthesizedExpression(body as ts.Expression)) {
2926
+ body = (body as ts.ParenthesizedExpression).expression
2927
+ }
2928
+
2929
+ // Block-bodied arrow (the Toggle `classes` memo): collect leading
2930
+ // `const X = props.Y ?? 'lit'` key bindings, then resolve against the
2931
+ // single returned template literal. The bindings let `variantClasses[variant]`
2932
+ // resolve `variant` to prop `variant` with its `'default'` fallback key.
2933
+ const localKeyBindings = new Map<string, { propName: string; defaultLiteral?: string }>()
2934
+ if (ts.isBlock(body)) {
2935
+ let returned: ts.Node | null = null
2936
+ for (const s of body.statements) {
2937
+ if (ts.isVariableStatement(s)) {
2938
+ for (const d of s.declarationList.declarations) {
2939
+ if (!ts.isIdentifier(d.name) || !d.initializer) continue
2940
+ const binding = this.parseLocalKeyBinding(d.initializer)
2941
+ if (binding) localKeyBindings.set(d.name.text, binding)
2942
+ }
2943
+ } else if (ts.isReturnStatement(s) && s.expression) {
2944
+ returned = s.expression
2945
+ } else if (ts.isExpressionStatement(s) || ts.isEmptyStatement(s)) {
2946
+ // ignore
2947
+ } else {
2948
+ return null // unsupported statement shape — bail to existing patterns
2949
+ }
2950
+ }
2951
+ if (!returned) return null
2952
+ body = returned
2953
+ while (ts.isParenthesizedExpression(body as ts.Expression)) {
2954
+ body = (body as ts.ParenthesizedExpression).expression
2955
+ }
2956
+ }
2957
+
2958
+ if (!ts.isTemplateExpression(body) && !ts.isNoSubstitutionTemplateLiteral(body)) {
2959
+ return null
2960
+ }
2961
+ const propNames = new Set(propsParams.map(p => p.name))
2962
+ const escGo = (s: string) => `"${this.escapeGoString(s)}"`
2963
+ const segments: string[] = []
2964
+
2965
+ if (ts.isNoSubstitutionTemplateLiteral(body)) {
2966
+ return escGo(body.text)
2967
+ }
2968
+ // head + each span
2969
+ if (body.head.text) segments.push(escGo(body.head.text))
2970
+ for (const span of body.templateSpans) {
2971
+ const resolved = this.resolveTemplateInterpolation(span.expression, propNames, localKeyBindings)
2972
+ if (resolved === null) return null
2973
+ segments.push(resolved)
2974
+ if (span.literal.text) segments.push(escGo(span.literal.text))
2975
+ }
2976
+ if (segments.length === 0) return '""'
2977
+ return segments.join(' + ')
2978
+ }
2979
+
2980
+ /**
2981
+ * (#checkbox) Resolve one `${expr}` interpolation of a template-literal memo
2982
+ * to a Go string expression, or null when unsupported. See
2983
+ * `computeTemplateLiteralMemoInitialValue` for the supported shapes.
2984
+ */
2985
+ private resolveTemplateInterpolation(
2986
+ expr: ts.Expression,
2987
+ propNames: Set<string>,
2988
+ localKeyBindings: ReadonlyMap<string, { propName: string; defaultLiteral?: string }> = new Map(),
2989
+ ): string | null {
2990
+ let node: ts.Expression = expr
2991
+ while (ts.isParenthesizedExpression(node)) node = node.expression
2992
+
2993
+ // Identifier → module string const inline.
2994
+ if (ts.isIdentifier(node)) {
2995
+ const inlined = this.resolveModuleStringConst(node.text)
2996
+ if (inlined !== null) return inlined
2997
+ return null
2998
+ }
2999
+
3000
+ // `recordConst[key]` → inline indexed Go map (the Toggle `classes` memo's
3001
+ // `variantClasses[variant]` / `sizeClasses[size]`).
3002
+ if (ts.isElementAccessExpression(node)) {
3003
+ const indexed = this.recordIndexInterpolationToGo(node, propNames, localKeyBindings)
3004
+ if (indexed !== null) return indexed
3005
+ return null
3006
+ }
3007
+
3008
+ // `props.X ?? '...'` — string-typed prop with an empty-string fallback.
3009
+ if (
3010
+ ts.isBinaryExpression(node) &&
3011
+ node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken
3012
+ ) {
3013
+ const right = node.right
3014
+ const isEmptyStr =
3015
+ (ts.isStringLiteral(right) || ts.isNoSubstitutionTemplateLiteral(right)) &&
3016
+ right.text === ''
3017
+ const propName = this.propsAccessName(node.left)
3018
+ if (propName && propNames.has(propName) && isEmptyStr) {
3019
+ // Unset string field is "" in Go — same as `?? ''`.
3020
+ return `in.${this.capitalizeFieldName(propName)}`
3021
+ }
3022
+ return null
3023
+ }
3024
+
3025
+ // Bare `props.X` (string-typed prop).
3026
+ const propName = this.propsAccessName(node)
3027
+ if (propName && propNames.has(propName)) {
3028
+ return `in.${this.capitalizeFieldName(propName)}`
3029
+ }
3030
+ return null
3031
+ }
3032
+
3033
+ /**
3034
+ * Parse a memo-local `const X = …` initializer into a `Record`-index key
3035
+ * binding: `props.Y ?? 'lit'` → `{ propName: 'Y', defaultLiteral: 'lit' }`,
3036
+ * or bare `props.Y` → `{ propName: 'Y' }`. Returns null for any other shape
3037
+ * (a literal const, a call, etc.) so it simply isn't registered as a key.
3038
+ */
3039
+ private parseLocalKeyBinding(
3040
+ init: ts.Expression,
3041
+ ): { propName: string; defaultLiteral?: string } | null {
3042
+ let node: ts.Expression = init
3043
+ while (ts.isParenthesizedExpression(node)) node = node.expression
3044
+ if (
3045
+ ts.isBinaryExpression(node) &&
3046
+ node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken
3047
+ ) {
3048
+ const propName = this.propsAccessName(node.left)
3049
+ const right = node.right
3050
+ if (
3051
+ propName &&
3052
+ (ts.isStringLiteral(right) || ts.isNoSubstitutionTemplateLiteral(right))
3053
+ ) {
3054
+ return { propName, defaultLiteral: right.text }
3055
+ }
3056
+ return null
3057
+ }
3058
+ const propName = this.propsAccessName(node)
3059
+ if (propName) return { propName }
3060
+ return null
3061
+ }
3062
+
3063
+ /**
3064
+ * Lower a `recordConst[key]` interpolation to an inline indexed Go map,
3065
+ * emitting `map[string]string{…}[fmt.Sprint(in.Field)]` (or `map[string]any`
3066
+ * for mixed values). `key` is a bare prop or a memo-local const bound to
3067
+ * `props.X ?? 'default'` (resolved via `localKeyBindings`); a `'default'`
3068
+ * fallback also maps `""` to that entry, so an unset prop (Go zero value `""`)
3069
+ * renders the default — matching Hono's `props.X ?? 'default'`. Returns null
3070
+ * for any non-record / non-resolvable key so the caller falls through.
3071
+ */
3072
+ private recordIndexInterpolationToGo(
3073
+ node: ts.ElementAccessExpression,
3074
+ propNames: Set<string>,
3075
+ localKeyBindings: ReadonlyMap<string, { propName: string; defaultLiteral?: string }>,
3076
+ ): string | null {
3077
+ const parsed = parseRecordIndexAccess(
3078
+ node,
3079
+ this.localConstants ?? [],
3080
+ [...propNames].map(name => ({ name })),
3081
+ name => localKeyBindings.get(name) ?? null,
3082
+ )
3083
+ if (!parsed) return null
3084
+ const goVal = (v: { kind: 'number' | 'string'; text: string }) =>
3085
+ v.kind === 'number' ? v.text : JSON.stringify(v.text)
3086
+ const entries = parsed.entries.map(e => `${JSON.stringify(e.key)}: ${goVal(e.value)}`)
3087
+ if (parsed.defaultKey !== undefined) {
3088
+ const def = parsed.entries.find(e => e.key === parsed.defaultKey)
3089
+ if (def) entries.unshift(`"": ${goVal(def.value)}`)
3090
+ }
3091
+ const allString = parsed.entries.every(e => e.value.kind === 'string')
3092
+ const mapType = allString ? 'map[string]string' : 'map[string]any'
3093
+ this.usesFmt = true
3094
+ return `${mapType}{${entries.join(', ')}}[fmt.Sprint(in.${this.capitalizeFieldName(parsed.indexPropName)})]`
3095
+ }
3096
+
3097
+ /**
3098
+ * If `node` is a `<propsObjectName>.<name>` access, return `<name>`, else
3099
+ * null. Used to recognize props-object reads inside memo interpolations.
3100
+ */
3101
+ private propsAccessName(node: ts.Expression): string | null {
3102
+ if (!ts.isPropertyAccessExpression(node)) return null
3103
+ if (!ts.isIdentifier(node.expression)) return null
3104
+ if (!this.propsObjectName || node.expression.text !== this.propsObjectName) return null
3105
+ return node.name.text
3106
+ }
3107
+
2591
3108
  private computeMemoInitialValue(
2592
3109
  memo: { name: string; computation: string; deps: string[] },
2593
3110
  signals: { getter: string; initialValue: string }[],
2594
3111
  propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
2595
3112
  propFallbackVars: ReadonlyMap<string, PropFallbackVar> = GoTemplateAdapter.EMPTY_PROP_FALLBACK_VARS,
3113
+ // (#checkbox) Go type of the memo field; used to pick the zero-value
3114
+ // fallback for an unresolved computation (`false` for `bool`, `""` for
3115
+ // `string`, else the historical `0`).
3116
+ goType?: string,
2596
3117
  ): string {
2597
3118
  const computation = memo.computation
2598
3119
  // Helper to pick the hoisted var (if any) or fall back to `in.X`.
@@ -2602,6 +3123,16 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2602
3123
  return `in.${this.capitalizeFieldName(propName)}`
2603
3124
  }
2604
3125
 
3126
+ // (#checkbox) Pattern: () => `...${expr}...` — a template-literal memo
3127
+ // (the classes memo: `${baseClasses} ${focusClasses} ... ${props.className
3128
+ // ?? ''} grid place-content-center`). Build a Go string concatenation that
3129
+ // inlines module string consts (incl. `[...].join(' ')` consts resolved by
3130
+ // `resolveModuleStringConst`) and resolves `props.X ?? ''` / bare `props.X`
3131
+ // to the corresponding `in.Field`. Returns null when any interpolation
3132
+ // isn't representable, so the existing patterns below still apply.
3133
+ const tmplMemo = this.computeTemplateLiteralMemoInitialValue(computation, propsParams)
3134
+ if (tmplMemo !== null) return tmplMemo
3135
+
2605
3136
  // Pattern: () => dep() * N or () => dep() + N etc.
2606
3137
  const arithmeticMatch = computation.match(/\(\)\s*=>\s*(\w+)\(\)\s*([*+\-/])\s*(\d+)/)
2607
3138
  if (arithmeticMatch) {
@@ -2680,10 +3211,42 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2680
3211
  }
2681
3212
  }
2682
3213
 
2683
- // Default: return 0 for unknown computations
3214
+ // Default: zero value for the memo's Go type (#checkbox). A boolean memo
3215
+ // (`isChecked`) renders `false`, a string memo `""`; otherwise the
3216
+ // historical int `0`.
3217
+ if (goType === 'bool') return 'false'
3218
+ if (goType === 'string') return '""'
2684
3219
  return '0'
2685
3220
  }
2686
3221
 
3222
+ /**
3223
+ * Whether a memo is an arrow whose result is a template literal — either a
3224
+ * concise body (`() => \`…\``) or a block body whose `return` is one.
3225
+ */
3226
+ private isTemplateLiteralMemo(computation: string): boolean {
3227
+ const sf = ts.createSourceFile(
3228
+ '__memo.ts', `const __x = (${computation});`, ts.ScriptTarget.Latest, /*setParentNodes*/ false,
3229
+ )
3230
+ const stmt = sf.statements[0]
3231
+ if (!stmt || !ts.isVariableStatement(stmt)) return false
3232
+ let init = stmt.declarationList.declarations[0]?.initializer
3233
+ while (init && ts.isParenthesizedExpression(init)) init = init.expression
3234
+ if (!init || !ts.isArrowFunction(init)) return false
3235
+ let body = init.body as ts.Node
3236
+ while (ts.isParenthesizedExpression(body as ts.Expression)) {
3237
+ body = (body as ts.ParenthesizedExpression).expression
3238
+ }
3239
+ if (ts.isBlock(body)) {
3240
+ const ret = body.statements.find(ts.isReturnStatement)
3241
+ if (!ret || !ret.expression) return false
3242
+ body = ret.expression
3243
+ while (ts.isParenthesizedExpression(body as ts.Expression)) {
3244
+ body = (body as ts.ParenthesizedExpression).expression
3245
+ }
3246
+ }
3247
+ return ts.isTemplateExpression(body) || ts.isNoSubstitutionTemplateLiteral(body)
3248
+ }
3249
+
2687
3250
  /**
2688
3251
  * Infer the Go type for a memo based on its computation and dependencies.
2689
3252
  */
@@ -2692,6 +3255,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2692
3255
  signals: { getter: string; initialValue: string; type: TypeInfo }[],
2693
3256
  propsParamMap: Map<string, { name: string; type: TypeInfo; defaultValue?: string }>
2694
3257
  ): string {
3258
+ // A template-literal memo always produces a string. Decide this first so a
3259
+ // class-string `/` (e.g. `ring-ring/50`) doesn't trip the arithmetic
3260
+ // heuristic below into `int`.
3261
+ if (this.isTemplateLiteralMemo(memo.computation)) return 'string'
3262
+
2695
3263
  // Check if computation involves multiplication (*) - likely number
2696
3264
  if (memo.computation.includes('*') || memo.computation.includes('/') ||
2697
3265
  memo.computation.includes('+') || memo.computation.includes('-')) {
@@ -2719,10 +3287,58 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2719
3287
  }
2720
3288
  }
2721
3289
 
3290
+ // (#checkbox) Boolean memo: a comparison/negation/ternary whose dependency
3291
+ // signals are all boolean (`isChecked = isControlled() ? controlledChecked()
3292
+ // : internalChecked()`). Inferring `bool` makes the field render `false`
3293
+ // (not the int `0`) for `aria-checked={isChecked()}`, matching Hono's SSR
3294
+ // initial value. Only fires when the declared memo type is unknown so an
3295
+ // explicitly-typed memo still wins.
3296
+ if (this.typeInfoToGo(memo.type) === 'interface{}' && this.isBooleanMemo(memo, signals, propsParamMap)) {
3297
+ return 'bool'
3298
+ }
3299
+
2722
3300
  // Default to the memo's declared type
2723
3301
  return this.typeInfoToGo(memo.type)
2724
3302
  }
2725
3303
 
3304
+ /**
3305
+ * (#checkbox) Heuristic: does this memo evaluate to a boolean? True when its
3306
+ * computation is a comparison (`!==`/`===`/`!=`/`==`), a negation (`!x`), or
3307
+ * a ternary whose branches are all boolean signals/props. Used to pick `bool`
3308
+ * (zero value `false`) over the int `0` default for the SSR initial value.
3309
+ */
3310
+ private isBooleanMemo(
3311
+ memo: { computation: string; deps: string[] },
3312
+ signals: { getter: string; initialValue: string; type: TypeInfo }[],
3313
+ propsParamMap: Map<string, { name: string; type: TypeInfo; defaultValue?: string }>,
3314
+ ): boolean {
3315
+ const c = memo.computation
3316
+ if (/(!==|===|!=(?!=)|==(?!=))/.test(c)) return true
3317
+ if (/=>\s*!/.test(c)) return true
3318
+ // Ternary `() => cond() ? a() : b()` — boolean when both branches are
3319
+ // boolean-resolving getters (signals whose value is boolean, or boolean
3320
+ // props).
3321
+ const isBoolGetter = (name: string): boolean => {
3322
+ const sig = signals.find(s => s.getter === name)
3323
+ if (sig) {
3324
+ if (this.typeInfoToGo(sig.type) === 'bool') return true
3325
+ // Signal initialised from `props.X ?? false` / a boolean prop.
3326
+ if (/\?\?\s*(true|false)\b/.test(sig.initialValue)) return true
3327
+ const propName = this.extractPropNameFromInitialValue(sig.initialValue) ?? sig.initialValue
3328
+ const prop = propsParamMap.get(propName)
3329
+ if (prop && this.typeInfoToGo(prop.type, prop.defaultValue) === 'bool') return true
3330
+ return false
3331
+ }
3332
+ const prop = propsParamMap.get(name)
3333
+ return !!prop && this.typeInfoToGo(prop.type, prop.defaultValue) === 'bool'
3334
+ }
3335
+ const ternary = c.match(/=>\s*\w+\(\)\s*\?\s*(\w+)\(\)\s*:\s*(\w+)\(\)/)
3336
+ if (ternary) {
3337
+ return isBoolGetter(ternary[1]) && isBoolGetter(ternary[2])
3338
+ }
3339
+ return false
3340
+ }
3341
+
2726
3342
  /**
2727
3343
  * Infer Go type from a JavaScript value literal.
2728
3344
  */
@@ -3207,9 +3823,49 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3207
3823
  if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
3208
3824
  return init.text
3209
3825
  }
3826
+ // (#checkbox) `[...string literals].join(SEP)` — a const that flattens an
3827
+ // array of pure string literals to a single string at module load (e.g.
3828
+ // Checkbox's `stateClasses = [...].join(' ')`). Evaluate it statically so
3829
+ // the const inlines byte-for-byte like the Hono reference. Only fires when
3830
+ // every array element is a string/no-substitution-template literal and the
3831
+ // separator is a string-literal argument (or omitted → default `,`).
3832
+ const joined = this.evalStringArrayJoin(init)
3833
+ if (joined !== null) return joined
3210
3834
  return null
3211
3835
  }
3212
3836
 
3837
+ /**
3838
+ * (#checkbox) Statically evaluate `[<string literals>].join(<sep?>)`.
3839
+ * Returns the joined string, or null when the shape doesn't match (non-call,
3840
+ * non-`.join`, non-array receiver, any non-string-literal element, or a
3841
+ * non-string-literal separator). Comments/whitespace between elements are
3842
+ * irrelevant — the TS parser already discarded them.
3843
+ */
3844
+ private evalStringArrayJoin(node: ts.Expression): string | null {
3845
+ if (!ts.isCallExpression(node)) return null
3846
+ const callee = node.expression
3847
+ if (!ts.isPropertyAccessExpression(callee)) return null
3848
+ if (callee.name.text !== 'join') return null
3849
+ let recv: ts.Expression = callee.expression
3850
+ while (ts.isParenthesizedExpression(recv)) recv = recv.expression
3851
+ if (!ts.isArrayLiteralExpression(recv)) return null
3852
+ const parts: string[] = []
3853
+ for (const el of recv.elements) {
3854
+ if (ts.isStringLiteral(el) || ts.isNoSubstitutionTemplateLiteral(el)) {
3855
+ parts.push(el.text)
3856
+ } else {
3857
+ return null
3858
+ }
3859
+ }
3860
+ let sep = ','
3861
+ if (node.arguments.length >= 1) {
3862
+ const arg = node.arguments[0]
3863
+ if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) sep = arg.text
3864
+ else return null
3865
+ }
3866
+ return parts.join(sep)
3867
+ }
3868
+
3213
3869
  /**
3214
3870
  * Resolve an identifier to its inlined Go string literal when it names a
3215
3871
  * module pure-string const. Returns the Go template literal form
@@ -5101,8 +5757,16 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5101
5757
  // concrete-typed props (which are never nil) are unaffected and
5102
5758
  // still emit `attr=""` / `attr="0"` exactly as Hono does.
5103
5759
  const bareId = value.expr.trim()
5104
- if (this.nillablePropNames.has(bareId)) {
5105
- const field = `.${this.capitalizeFieldName(bareId)}`
5760
+ // Normalize a props-object access (`props.id`) to its bare prop name
5761
+ // (`id`) so the nillable set — which is keyed by bare prop name —
5762
+ // matches the SolidJS-style props-object pattern too, not just
5763
+ // destructured params (#checkbox `id={props.id}`).
5764
+ const propName =
5765
+ this.propsObjectName && bareId.startsWith(`${this.propsObjectName}.`)
5766
+ ? bareId.slice(this.propsObjectName.length + 1)
5767
+ : bareId
5768
+ if (/^[A-Za-z_$][\w$]*$/.test(propName) && this.nillablePropNames.has(propName)) {
5769
+ const field = `.${this.capitalizeFieldName(propName)}`
5106
5770
  return `{{if ne ${field} nil}}${name}="{{${this.convertExpressionToGo(value.expr)}}}"{{end}}`
5107
5771
  }
5108
5772
  return `${name}="{{${this.convertExpressionToGo(value.expr)}}}"`