@barefootjs/go-template 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,40 @@ 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
+
496
+ /**
497
+ * Set of prop NAMES whose resolved Go struct-field type is exactly
498
+ * `interface{}` — i.e. nillable. Populated at `generate()` entry from
499
+ * the SAME per-prop Go-type computation `generatePropsStruct` /
500
+ * `generateInputStruct` use (`propTypeOverrides` + `typeInfoToGo`), so
501
+ * it can't drift from the actual field types. Used by
502
+ * `elementAttrEmitter.emitExpression` to omit a dynamic attribute whose
503
+ * value is a bare reference to a nillable prop when that prop is nil
504
+ * (Hono-style nullish-attribute omission: an `undefined`-valued
505
+ * attribute is dropped rather than rendered as `attr=""`). Concrete
506
+ * (`string`/`int`/`bool`) fields are never in this set and always emit
507
+ * unconditionally, matching Hono's `value=""` / `data-count="0"`.
508
+ */
509
+ private nillablePropNames: Set<string> = new Set()
510
+
437
511
  constructor(options: GoTemplateAdapterOptions = {}) {
438
512
  super()
439
513
  this.options = {
@@ -455,6 +529,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
455
529
  this.propsObjectName = ir.metadata.propsObjectName
456
530
  this.restPropsName = ir.metadata.restPropsName ?? null
457
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)
539
+ this.nillablePropNames = this.collectNillablePropNames(ir)
458
540
 
459
541
  // Surface loop-body usages of components imported from sibling
460
542
  // .tsx files. The adapter emits `{{template "X" .}}` for these,
@@ -746,8 +828,70 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
746
828
  return `{{if .Scripts}}${registrations.join('')}{{end}}\n`
747
829
  }
748
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
+
749
879
  generateTypes(ir: ComponentIR): string | null {
750
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)
751
895
  const lines: string[] = []
752
896
 
753
897
  const componentName = ir.metadata.componentName
@@ -840,6 +984,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
840
984
  header.push(`package ${this.options.packageName}`)
841
985
  header.push('')
842
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"')
843
990
  if (this.usesHtmlTemplate) header.push('\t"html/template"')
844
991
  header.push('\t"math/rand"')
845
992
  header.push('')
@@ -1065,6 +1212,40 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1065
1212
  return overrides
1066
1213
  }
1067
1214
 
1215
+ /**
1216
+ * Resolve a prop param's Go struct-field type using the SAME logic
1217
+ * `generatePropsStruct` / `generateInputStruct` use for the field
1218
+ * declaration: a `propTypeOverrides` entry (signal-inferred override)
1219
+ * wins, otherwise `typeInfoToGo(param.type, param.defaultValue)`.
1220
+ * Factored out so the nillable-field set (`collectNillablePropNames`)
1221
+ * can't drift from the actual emitted field types.
1222
+ */
1223
+ private resolvePropGoType(
1224
+ param: IRMetadata['propsParams'][number],
1225
+ propTypeOverrides: Map<string, string>,
1226
+ ): string {
1227
+ return propTypeOverrides.get(param.name) ?? this.typeInfoToGo(param.type, param.defaultValue)
1228
+ }
1229
+
1230
+ /**
1231
+ * Build the set of prop NAMES whose resolved Go field type is exactly
1232
+ * `interface{}` (nillable). Uses the same `propTypeOverrides` +
1233
+ * `resolvePropGoType` pipeline as the struct generators, so a prop
1234
+ * that ends up `interface{}` on the Props struct — and only such a
1235
+ * prop — is treated as nillable for Hono-style attribute omission.
1236
+ * Concrete (`string`/`int`/`bool`/`[]T`/struct) types are excluded.
1237
+ */
1238
+ private collectNillablePropNames(ir: ComponentIR): Set<string> {
1239
+ const propTypeOverrides = this.buildPropTypeOverrides(ir)
1240
+ const nillable = new Set<string>()
1241
+ for (const param of ir.metadata.propsParams) {
1242
+ if (this.resolvePropGoType(param, propTypeOverrides) === 'interface{}') {
1243
+ nillable.add(param.name)
1244
+ }
1245
+ }
1246
+ return nillable
1247
+ }
1248
+
1068
1249
  /**
1069
1250
  * Generate Input struct for a component
1070
1251
  */
@@ -1096,7 +1277,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1096
1277
  for (const param of ir.metadata.propsParams) {
1097
1278
  const fieldName = this.capitalizeFieldName(param.name)
1098
1279
  if (nestedArrayFields.has(fieldName)) continue
1099
- const goType = propTypeOverrides.get(param.name) ?? this.typeInfoToGo(param.type, param.defaultValue)
1280
+ const goType = this.resolvePropGoType(param, propTypeOverrides)
1100
1281
  lines.push(`\t${fieldName} ${goType}`)
1101
1282
  }
1102
1283
 
@@ -1105,6 +1286,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1105
1286
  lines.push(`\t${nested.name}s []${nested.name}Input`)
1106
1287
  }
1107
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
+
1108
1296
  // (#1407 follow-up) Input-side bag field for restPropsName spreads.
1109
1297
  // The destructured-rest pattern
1110
1298
  // (`function({a, ...rest}: P) { <el {...rest}/> }`) surfaces
@@ -1169,7 +1357,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1169
1357
  const fieldName = this.capitalizeFieldName(param.name)
1170
1358
  // Skip if this field will be replaced by a typed array for nested components
1171
1359
  if (nestedArrayFields.has(fieldName)) continue
1172
- const goType = propTypeOverrides.get(param.name) ?? this.typeInfoToGo(param.type, param.defaultValue)
1360
+ const goType = this.resolvePropGoType(param, propTypeOverrides)
1173
1361
  const jsonTag = this.toJsonTag(param.name)
1174
1362
  lines.push(`\t${fieldName} ${goType} \`json:"${jsonTag}"\``)
1175
1363
  propFieldNames.add(fieldName)
@@ -1237,6 +1425,18 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1237
1425
  lines.push(`\t${fieldName} ${goType} \`json:"${jsonTag}"\``)
1238
1426
  }
1239
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
+
1240
1440
  // Add array fields for nested components (for template rendering)
1241
1441
  for (const nested of nestedComponents) {
1242
1442
  if (nested.isDynamic && !nested.isPropDerived) {
@@ -1416,12 +1616,34 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1416
1616
  }
1417
1617
 
1418
1618
  // Add memo initial values (computed from signal initial values)
1619
+ const memoPropsParamMap = new Map(ir.metadata.propsParams.map(p => [p.name, p]))
1419
1620
  for (const memo of ir.metadata.memos) {
1420
1621
  const fieldName = this.capitalizeFieldName(memo.name)
1421
- 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)
1422
1627
  lines.push(`\t\t${fieldName}: ${memoValue},`)
1423
1628
  }
1424
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
+
1425
1647
  // Add static child component instances
1426
1648
  const staticChildren = this.collectStaticChildInstances(ir.root)
1427
1649
  for (const child of staticChildren) {
@@ -1431,15 +1653,49 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1431
1653
  // own NewProps (BfParent/BfMount fields).
1432
1654
  lines.push(`\t\t\tBfParent: scopeID,`)
1433
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
+ }
1434
1690
  // Add prop values
1435
1691
  for (const prop of child.props) {
1436
1692
  switch (prop.value.kind) {
1437
1693
  case 'literal':
1438
- lines.push(`\t\t\t${this.capitalizeFieldName(prop.name)}: ${this.goLiteral(prop.value.value)},`)
1694
+ emitChildField(prop.name, this.goLiteral(prop.value.value))
1439
1695
  break
1440
1696
  case 'boolean-shorthand':
1441
1697
  case 'boolean-attr':
1442
- lines.push(`\t\t\t${this.capitalizeFieldName(prop.name)}: true,`)
1698
+ emitChildField(prop.name, 'true')
1443
1699
  break
1444
1700
  case 'expression':
1445
1701
  case 'spread':
@@ -1458,7 +1714,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1458
1714
  const goExpr = this.templatePartsToGoCode(parts, ir.metadata.propsParams)
1459
1715
  if (goExpr !== null) {
1460
1716
  // Parts path succeeded — emit and move on.
1461
- lines.push(`\t\t\t${this.capitalizeFieldName(prop.name)}: ${goExpr},`)
1717
+ emitChildField(prop.name, goExpr)
1462
1718
  break
1463
1719
  }
1464
1720
  // Parts exist but templatePartsToGoCode opted out (unsupported
@@ -1476,7 +1732,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1476
1732
  ir.metadata.propsParams
1477
1733
  )
1478
1734
  if (resolvedValue !== null) {
1479
- lines.push(`\t\t\t${this.capitalizeFieldName(prop.name)}: ${resolvedValue},`)
1735
+ emitChildField(prop.name, resolvedValue)
1480
1736
  }
1481
1737
  break
1482
1738
  }
@@ -1485,6 +1741,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1485
1741
  break
1486
1742
  }
1487
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
+ }
1488
1752
  // Pass through JSX children as the child slot's `Children` input.
1489
1753
  // Two paths:
1490
1754
  // 1. Plain text (`<Button>+1</Button>`) → quote with JSON.stringify
@@ -1596,7 +1860,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1596
1860
  */
1597
1861
  private collectStaticChildInstances(node: IRNode): Array<StaticChildInstance> {
1598
1862
  const result: StaticChildInstance[] = []
1599
- this.collectStaticChildInstancesRecursive(node, result, false)
1863
+ this.collectStaticChildInstancesRecursive(node, result, false, new Map())
1600
1864
  return result
1601
1865
  }
1602
1866
 
@@ -1637,7 +1901,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1637
1901
  private collectStaticChildInstancesRecursive(
1638
1902
  node: IRNode,
1639
1903
  result: StaticChildInstance[],
1640
- inLoop: boolean
1904
+ inLoop: boolean,
1905
+ providerCtx: ReadonlyMap<string, string>,
1641
1906
  ): void {
1642
1907
  if (node.type === 'component') {
1643
1908
  const comp = node as IRComponent
@@ -1647,7 +1912,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1647
1912
  // child components nested inside still get their slot fields.
1648
1913
  if (comp.dynamicTag) {
1649
1914
  for (const child of comp.children) {
1650
- this.collectStaticChildInstancesRecursive(child, result, inLoop)
1915
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
1651
1916
  }
1652
1917
  return
1653
1918
  }
@@ -1662,57 +1927,79 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1662
1927
  fieldName: `${comp.name}${suffix}`,
1663
1928
  childrenText: this.extractTextChildren(comp.children),
1664
1929
  childrenHtml: this.extractHtmlChildren(comp.children),
1930
+ contextBindings: providerCtx.size > 0 ? providerCtx : undefined,
1665
1931
  })
1666
1932
  }
1667
1933
  // Recurse into Portal's children to find nested components
1668
1934
  if (comp.name === 'Portal' && comp.children) {
1669
1935
  for (const child of comp.children) {
1670
- this.collectStaticChildInstancesRecursive(child, result, inLoop)
1936
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
1671
1937
  }
1672
1938
  }
1673
1939
  } else if (node.type === 'loop') {
1674
1940
  const loop = node as IRLoop
1675
1941
  // Mark children as inside loop
1676
1942
  for (const child of loop.children) {
1677
- this.collectStaticChildInstancesRecursive(child, result, true)
1943
+ this.collectStaticChildInstancesRecursive(child, result, true, providerCtx)
1678
1944
  }
1679
1945
  } else if (node.type === 'element') {
1680
1946
  const element = node as IRElement
1681
1947
  for (const child of element.children) {
1682
- this.collectStaticChildInstancesRecursive(child, result, inLoop)
1948
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
1683
1949
  }
1684
1950
  } else if (node.type === 'fragment') {
1685
1951
  const fragment = node as IRFragment
1686
1952
  for (const child of fragment.children) {
1687
- this.collectStaticChildInstancesRecursive(child, result, inLoop)
1953
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
1688
1954
  }
1689
1955
  } else if (node.type === 'conditional') {
1690
1956
  const cond = node as IRConditional
1691
- this.collectStaticChildInstancesRecursive(cond.whenTrue, result, inLoop)
1957
+ this.collectStaticChildInstancesRecursive(cond.whenTrue, result, inLoop, providerCtx)
1692
1958
  if (cond.whenFalse) {
1693
- this.collectStaticChildInstancesRecursive(cond.whenFalse, result, inLoop)
1959
+ this.collectStaticChildInstancesRecursive(cond.whenFalse, result, inLoop, providerCtx)
1694
1960
  }
1695
1961
  } else if (node.type === 'provider') {
1696
- // Provider is a transparent wrapper at the SSR layer context
1697
- // propagation is purely a client-runtime concern. Recurse into
1698
- // its children so any static <Child/> nested under <Ctx.Provider>
1699
- // 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)
1700
1966
  const p = node as IRProvider
1967
+ const childCtx = this.extendProviderContext(providerCtx, p)
1701
1968
  for (const child of p.children) {
1702
- this.collectStaticChildInstancesRecursive(child, result, inLoop)
1969
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, childCtx)
1703
1970
  }
1704
1971
  } else if (node.type === 'async') {
1705
1972
  // Async fallback + children render server-side via the OOS
1706
1973
  // protocol; static child components inside them still need slot
1707
1974
  // fields on the parent struct.
1708
1975
  const a = node as IRAsync
1709
- this.collectStaticChildInstancesRecursive(a.fallback, result, inLoop)
1976
+ this.collectStaticChildInstancesRecursive(a.fallback, result, inLoop, providerCtx)
1710
1977
  for (const child of a.children) {
1711
- this.collectStaticChildInstancesRecursive(child, result, inLoop)
1978
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
1712
1979
  }
1713
1980
  }
1714
1981
  }
1715
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
+
1716
2003
  /**
1717
2004
  * Collect top-level (non-loop) JSX intrinsic-element spread slots
1718
2005
  * from the IR (#1407). Loop-internal spreads are skipped — they
@@ -1922,6 +2209,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1922
2209
  ir: ComponentIR,
1923
2210
  ): string | null {
1924
2211
  const trimmed = spreadExpr.trim()
2212
+ // Conditional inline-object spread:
2213
+ // `{...(COND ? { 'k': v } : {})}` (either branch possibly `{}`).
2214
+ // Lower to an immediately-invoked func literal that conditionally
2215
+ // builds the bag, so the falsy branch yields an empty map (the key
2216
+ // is OMITTED rather than rendered as `k=""` — `SpreadAttrs` does
2217
+ // NOT filter empty strings). Returns null for any shape it can't
2218
+ // faithfully convert so the caller falls back to BF101 (#textarea).
2219
+ const conditional = this.buildConditionalSpreadInitializer(trimmed, ir)
2220
+ if (conditional !== undefined) return conditional
1925
2221
  // Signal-getter call: `attrs()` — pluck the signal's initialValue
1926
2222
  // and translate the JS object literal to a Go map literal.
1927
2223
  const callMatch = /^([a-zA-Z_][a-zA-Z0-9_]*)\s*\(\s*\)$/.exec(trimmed)
@@ -1970,10 +2266,219 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1970
2266
  if (ir.metadata.restPropsName === trimmed) {
1971
2267
  return `in.${this.capitalizeFieldName(trimmed)}`
1972
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
+ }
1973
2293
  }
1974
2294
  return null
1975
2295
  }
1976
2296
 
2297
+ /**
2298
+ * Lower a conditional inline-object spread bag value:
2299
+ * `(COND ? { 'aria-describedby': describedBy } : {})`
2300
+ * into an immediately-invoked Go func literal that conditionally
2301
+ * builds the map (so the falsy branch OMITS the key rather than
2302
+ * rendering it as an empty string, which `SpreadAttrs` does not
2303
+ * filter):
2304
+ *
2305
+ * func() map[string]any {
2306
+ * if in.DescribedBy != nil && in.DescribedBy != "" {
2307
+ * return map[string]any{"aria-describedby": in.DescribedBy}
2308
+ * }
2309
+ * return map[string]any{}
2310
+ * }()
2311
+ *
2312
+ * Returns:
2313
+ * - `undefined` when the expression is NOT a parenthesized ternary
2314
+ * of object literals — the caller falls through to other shapes.
2315
+ * - `null` when it IS that shape but a part can't be faithfully
2316
+ * converted (non-static key, unsupported condition, …) — the
2317
+ * caller raises BF101.
2318
+ * - the Go IIFE string when fully convertible.
2319
+ */
2320
+ private buildConditionalSpreadInitializer(
2321
+ spreadExpr: string,
2322
+ ir: ComponentIR,
2323
+ ): string | null | undefined {
2324
+ const expr = this.parseLiteralExpression(spreadExpr)
2325
+ if (!expr || !ts.isConditionalExpression(expr)) return undefined
2326
+ const whenTrue = this.unwrapParens(expr.whenTrue)
2327
+ const whenFalse = this.unwrapParens(expr.whenFalse)
2328
+ if (!ts.isObjectLiteralExpression(whenTrue) || !ts.isObjectLiteralExpression(whenFalse)) {
2329
+ return undefined
2330
+ }
2331
+ // Condition → Go bool against `in.`, type-aware on the prop.
2332
+ const goCond = this.conditionToGoBool(expr.condition, ir)
2333
+ if (goCond === null) return null
2334
+ const trueMap = this.objectLiteralToGoSpreadMap(whenTrue, ir)
2335
+ const falseMap = this.objectLiteralToGoSpreadMap(whenFalse, ir)
2336
+ if (trueMap === null || falseMap === null) return null
2337
+ return (
2338
+ `func() map[string]any {\n` +
2339
+ `\t\tif ${goCond} {\n` +
2340
+ `\t\t\treturn ${trueMap}\n` +
2341
+ `\t\t}\n` +
2342
+ `\t\treturn ${falseMap}\n` +
2343
+ `\t}()`
2344
+ )
2345
+ }
2346
+
2347
+ /** Strip redundant parenthesised wrappers off a TS expression. */
2348
+ private unwrapParens(node: ts.Expression): ts.Expression {
2349
+ let e = node
2350
+ while (ts.isParenthesizedExpression(e)) e = e.expression
2351
+ return e
2352
+ }
2353
+
2354
+ /**
2355
+ * Convert a conditional-spread condition expression to a Go bool in
2356
+ * the `in.` context. Supports a bare prop identifier (`describedBy`)
2357
+ * and its negation (`!describedBy`), type-aware on the prop:
2358
+ * string → `in.X != ""`
2359
+ * boolean → `in.X`
2360
+ * number → `in.X != 0`
2361
+ * unknown / interface{} → `in.X != nil && in.X != ""`
2362
+ * (faithful JS string-truthiness for an interface holding a
2363
+ * string — textarea's `describedBy` resolves to interface{}).
2364
+ * Returns null for any other shape (caller → BF101).
2365
+ */
2366
+ private conditionToGoBool(
2367
+ condition: ts.Expression,
2368
+ ir: ComponentIR,
2369
+ ): string | null {
2370
+ let node = this.unwrapParens(condition)
2371
+ let negate = false
2372
+ if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) {
2373
+ negate = true
2374
+ node = this.unwrapParens(node.operand)
2375
+ }
2376
+ if (!ts.isIdentifier(node)) return null
2377
+ const param = ir.metadata.propsParams.find(p => p.name === node.text)
2378
+ if (!param) return null
2379
+ const field = `in.${this.capitalizeFieldName(param.name)}`
2380
+ const prim = param.type.kind === 'primitive' ? param.type.primitive : undefined
2381
+ let truthy: string
2382
+ if (prim === 'boolean') {
2383
+ truthy = field
2384
+ } else if (prim === 'number') {
2385
+ truthy = `${field} != 0`
2386
+ } else if (prim === 'string') {
2387
+ truthy = `${field} != ""`
2388
+ } else {
2389
+ // unknown / interface{}: the runtime value may be a string, number,
2390
+ // bool, etc., so a string-biased `!= ""` test would diverge from JS
2391
+ // truthiness (e.g. an `interface{}` holding `0` or `false` is falsy in
2392
+ // JS but `!= ""` reads true). Route through `bf.Truthy`, the exported
2393
+ // `Boolean(x)` equivalent, for a faithful check (Copilot review #1752).
2394
+ truthy = `bf.Truthy(${field})`
2395
+ }
2396
+ if (!negate) return truthy
2397
+ // Negation: wrap so `!` applies to the whole truthiness test.
2398
+ if (prim === 'boolean') return `!${field}`
2399
+ if (prim === 'number') return `${field} == 0`
2400
+ if (prim === 'string') return `${field} == ""`
2401
+ return `!bf.Truthy(${field})`
2402
+ }
2403
+
2404
+ /**
2405
+ * Convert a static object literal (`{ 'aria-describedby': describedBy }`)
2406
+ * into a Go `map[string]any{...}` literal for a conditional spread.
2407
+ * Only static string/identifier keys are allowed; values resolve
2408
+ * prop-identifier references to `in.FieldName` and string literals to
2409
+ * Go string literals. Returns null for any computed/spread/dynamic
2410
+ * key or unsupported value (caller → BF101). Empty object → `map[string]any{}`.
2411
+ */
2412
+ private objectLiteralToGoSpreadMap(
2413
+ obj: ts.ObjectLiteralExpression,
2414
+ ir: ComponentIR,
2415
+ ): string | null {
2416
+ const entries: string[] = []
2417
+ for (const prop of obj.properties) {
2418
+ if (!ts.isPropertyAssignment(prop)) return null
2419
+ let key: string
2420
+ if (ts.isIdentifier(prop.name)) {
2421
+ key = prop.name.text
2422
+ } else if (ts.isStringLiteral(prop.name) || ts.isNoSubstitutionTemplateLiteral(prop.name)) {
2423
+ key = prop.name.text
2424
+ } else {
2425
+ return null
2426
+ }
2427
+ const val = this.unwrapParens(prop.initializer)
2428
+ let goVal: string
2429
+ if (ts.isStringLiteral(val) || ts.isNoSubstitutionTemplateLiteral(val)) {
2430
+ goVal = JSON.stringify(val.text)
2431
+ } else if (ts.isIdentifier(val)) {
2432
+ const param = ir.metadata.propsParams.find(p => p.name === val.text)
2433
+ if (!param) return null
2434
+ goVal = `in.${this.capitalizeFieldName(param.name)}`
2435
+ } else {
2436
+ const indexed = this.recordIndexAccessToGoMap(val, ir)
2437
+ if (indexed === null) return null
2438
+ goVal = indexed
2439
+ }
2440
+ entries.push(`${JSON.stringify(key)}: ${goVal}`)
2441
+ }
2442
+ return `map[string]any{${entries.join(', ')}}`
2443
+ }
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
+
1977
2482
  /**
1978
2483
  * Convert JavaScript initial value to Go value for NewXxxProps function.
1979
2484
  * References to props params are converted to in.FieldName format.
@@ -2383,11 +2888,232 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2383
2888
  * referenced prop, substitute it for `in.FieldName` so the memo
2384
2889
  * inherits the signal-time `??` fallback.
2385
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
+
2386
3108
  private computeMemoInitialValue(
2387
3109
  memo: { name: string; computation: string; deps: string[] },
2388
3110
  signals: { getter: string; initialValue: string }[],
2389
3111
  propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
2390
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,
2391
3117
  ): string {
2392
3118
  const computation = memo.computation
2393
3119
  // Helper to pick the hoisted var (if any) or fall back to `in.X`.
@@ -2397,6 +3123,16 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2397
3123
  return `in.${this.capitalizeFieldName(propName)}`
2398
3124
  }
2399
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
+
2400
3136
  // Pattern: () => dep() * N or () => dep() + N etc.
2401
3137
  const arithmeticMatch = computation.match(/\(\)\s*=>\s*(\w+)\(\)\s*([*+\-/])\s*(\d+)/)
2402
3138
  if (arithmeticMatch) {
@@ -2475,10 +3211,42 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2475
3211
  }
2476
3212
  }
2477
3213
 
2478
- // 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 '""'
2479
3219
  return '0'
2480
3220
  }
2481
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
+
2482
3250
  /**
2483
3251
  * Infer the Go type for a memo based on its computation and dependencies.
2484
3252
  */
@@ -2487,6 +3255,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2487
3255
  signals: { getter: string; initialValue: string; type: TypeInfo }[],
2488
3256
  propsParamMap: Map<string, { name: string; type: TypeInfo; defaultValue?: string }>
2489
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
+
2490
3263
  // Check if computation involves multiplication (*) - likely number
2491
3264
  if (memo.computation.includes('*') || memo.computation.includes('/') ||
2492
3265
  memo.computation.includes('+') || memo.computation.includes('-')) {
@@ -2514,10 +3287,58 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2514
3287
  }
2515
3288
  }
2516
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
+
2517
3300
  // Default to the memo's declared type
2518
3301
  return this.typeInfoToGo(memo.type)
2519
3302
  }
2520
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
+
2521
3342
  /**
2522
3343
  * Infer Go type from a JavaScript value literal.
2523
3344
  */
@@ -3002,9 +3823,49 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3002
3823
  if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
3003
3824
  return init.text
3004
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
3005
3834
  return null
3006
3835
  }
3007
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
+
3008
3869
  /**
3009
3870
  * Resolve an identifier to its inlined Go string literal when it names a
3010
3871
  * module pure-string const. Returns the Go template literal form
@@ -4886,6 +5747,28 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4886
5747
  // Inline Go template syntax with embedded `{{...}}` actions.
4887
5748
  return `${name}="${this.renderParsedExpr(parsed)}"`
4888
5749
  }
5750
+ // Hono-style nullish-attribute omission (#textarea rows): when the
5751
+ // attribute value is a BARE reference to a nillable (`interface{}`)
5752
+ // prop field, guard emission on `ne .X nil` so an unset optional
5753
+ // prop drops the attribute entirely instead of rendering `attr=""`.
5754
+ // Hono omits `undefined`/`null`-valued attributes; this restores
5755
+ // parity. Scope is deliberately narrow — bare identifiers only — so
5756
+ // member exprs, calls, ternaries, template literals, and
5757
+ // concrete-typed props (which are never nil) are unaffected and
5758
+ // still emit `attr=""` / `attr="0"` exactly as Hono does.
5759
+ const bareId = value.expr.trim()
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)}`
5770
+ return `{{if ne ${field} nil}}${name}="{{${this.convertExpressionToGo(value.expr)}}}"{{end}}`
5771
+ }
4889
5772
  return `${name}="{{${this.convertExpressionToGo(value.expr)}}}"`
4890
5773
  },
4891
5774
  emitBooleanAttr: (_value, name) => name,