@barefootjs/go-template 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,6 +34,7 @@ import type {
34
34
  IRIfStatement,
35
35
  IRProvider,
36
36
  IRAsync,
37
+ IRMetadata,
37
38
  TemplatePrimitiveRegistry,
38
39
  } from '@barefootjs/jsx'
39
40
  import {
@@ -418,6 +419,36 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
418
419
  * `template.HTML(...)`; toggles the `"html/template"` import. */
419
420
  private usesHtmlTemplate: boolean = false
420
421
 
422
+ /**
423
+ * Module-scope pure string-literal constants (`const X = 'literal'` at
424
+ * file top-level), keyed by name → resolved literal value. Populated at
425
+ * `generate()` entry from `ir.metadata.localConstants`. When an identifier
426
+ * in an expression resolves to one of these, the adapter inlines the
427
+ * literal value instead of emitting a struct-field reference
428
+ * (`{{.X}}`) — the field never exists on the Props struct, so without
429
+ * inlining Go's template engine fails with `can't evaluate field X`.
430
+ * Hono inlines it for free (it evaluates real JS); this restores parity.
431
+ * Only module-scope, pure string literals qualify — function-scope
432
+ * locals legitimately become template vars/props, and `Record<T,string>`
433
+ * indexed lookups / memos / signals are deliberately excluded.
434
+ */
435
+ private moduleStringConsts: Map<string, string> = new Map()
436
+
437
+ /**
438
+ * Set of prop NAMES whose resolved Go struct-field type is exactly
439
+ * `interface{}` — i.e. nillable. Populated at `generate()` entry from
440
+ * the SAME per-prop Go-type computation `generatePropsStruct` /
441
+ * `generateInputStruct` use (`propTypeOverrides` + `typeInfoToGo`), so
442
+ * it can't drift from the actual field types. Used by
443
+ * `elementAttrEmitter.emitExpression` to omit a dynamic attribute whose
444
+ * value is a bare reference to a nillable prop when that prop is nil
445
+ * (Hono-style nullish-attribute omission: an `undefined`-valued
446
+ * attribute is dropped rather than rendered as `attr=""`). Concrete
447
+ * (`string`/`int`/`bool`) fields are never in this set and always emit
448
+ * unconditionally, matching Hono's `value=""` / `data-count="0"`.
449
+ */
450
+ private nillablePropNames: Set<string> = new Set()
451
+
421
452
  constructor(options: GoTemplateAdapterOptions = {}) {
422
453
  super()
423
454
  this.options = {
@@ -438,6 +469,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
438
469
  this.templateVarCounter = 0
439
470
  this.propsObjectName = ir.metadata.propsObjectName
440
471
  this.restPropsName = ir.metadata.restPropsName ?? null
472
+ this.moduleStringConsts = this.collectModuleStringConsts(ir.metadata.localConstants)
473
+ this.nillablePropNames = this.collectNillablePropNames(ir)
441
474
 
442
475
  // Surface loop-body usages of components imported from sibling
443
476
  // .tsx files. The adapter emits `{{template "X" .}}` for these,
@@ -1048,6 +1081,40 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1048
1081
  return overrides
1049
1082
  }
1050
1083
 
1084
+ /**
1085
+ * Resolve a prop param's Go struct-field type using the SAME logic
1086
+ * `generatePropsStruct` / `generateInputStruct` use for the field
1087
+ * declaration: a `propTypeOverrides` entry (signal-inferred override)
1088
+ * wins, otherwise `typeInfoToGo(param.type, param.defaultValue)`.
1089
+ * Factored out so the nillable-field set (`collectNillablePropNames`)
1090
+ * can't drift from the actual emitted field types.
1091
+ */
1092
+ private resolvePropGoType(
1093
+ param: IRMetadata['propsParams'][number],
1094
+ propTypeOverrides: Map<string, string>,
1095
+ ): string {
1096
+ return propTypeOverrides.get(param.name) ?? this.typeInfoToGo(param.type, param.defaultValue)
1097
+ }
1098
+
1099
+ /**
1100
+ * Build the set of prop NAMES whose resolved Go field type is exactly
1101
+ * `interface{}` (nillable). Uses the same `propTypeOverrides` +
1102
+ * `resolvePropGoType` pipeline as the struct generators, so a prop
1103
+ * that ends up `interface{}` on the Props struct — and only such a
1104
+ * prop — is treated as nillable for Hono-style attribute omission.
1105
+ * Concrete (`string`/`int`/`bool`/`[]T`/struct) types are excluded.
1106
+ */
1107
+ private collectNillablePropNames(ir: ComponentIR): Set<string> {
1108
+ const propTypeOverrides = this.buildPropTypeOverrides(ir)
1109
+ const nillable = new Set<string>()
1110
+ for (const param of ir.metadata.propsParams) {
1111
+ if (this.resolvePropGoType(param, propTypeOverrides) === 'interface{}') {
1112
+ nillable.add(param.name)
1113
+ }
1114
+ }
1115
+ return nillable
1116
+ }
1117
+
1051
1118
  /**
1052
1119
  * Generate Input struct for a component
1053
1120
  */
@@ -1079,7 +1146,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1079
1146
  for (const param of ir.metadata.propsParams) {
1080
1147
  const fieldName = this.capitalizeFieldName(param.name)
1081
1148
  if (nestedArrayFields.has(fieldName)) continue
1082
- const goType = propTypeOverrides.get(param.name) ?? this.typeInfoToGo(param.type, param.defaultValue)
1149
+ const goType = this.resolvePropGoType(param, propTypeOverrides)
1083
1150
  lines.push(`\t${fieldName} ${goType}`)
1084
1151
  }
1085
1152
 
@@ -1152,7 +1219,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1152
1219
  const fieldName = this.capitalizeFieldName(param.name)
1153
1220
  // Skip if this field will be replaced by a typed array for nested components
1154
1221
  if (nestedArrayFields.has(fieldName)) continue
1155
- const goType = propTypeOverrides.get(param.name) ?? this.typeInfoToGo(param.type, param.defaultValue)
1222
+ const goType = this.resolvePropGoType(param, propTypeOverrides)
1156
1223
  const jsonTag = this.toJsonTag(param.name)
1157
1224
  lines.push(`\t${fieldName} ${goType} \`json:"${jsonTag}"\``)
1158
1225
  propFieldNames.add(fieldName)
@@ -1624,6 +1691,16 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1624
1691
  ): void {
1625
1692
  if (node.type === 'component') {
1626
1693
  const comp = node as IRComponent
1694
+ // Dynamic-tag locals (`const Tag = children.tag`) have no registrable
1695
+ // template, so they get no `.<Name>SlotN` struct field. Recurse into
1696
+ // their children (which lower as a passthrough) so any real static
1697
+ // child components nested inside still get their slot fields.
1698
+ if (comp.dynamicTag) {
1699
+ for (const child of comp.children) {
1700
+ this.collectStaticChildInstancesRecursive(child, result, inLoop)
1701
+ }
1702
+ return
1703
+ }
1627
1704
  // Skip Portal components (handled separately via PortalCollector)
1628
1705
  // Skip components inside loops (handled by nestedComponents)
1629
1706
  if (comp.name !== 'Portal' && !inLoop && comp.slotId) {
@@ -1895,6 +1972,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1895
1972
  ir: ComponentIR,
1896
1973
  ): string | null {
1897
1974
  const trimmed = spreadExpr.trim()
1975
+ // Conditional inline-object spread:
1976
+ // `{...(COND ? { 'k': v } : {})}` (either branch possibly `{}`).
1977
+ // Lower to an immediately-invoked func literal that conditionally
1978
+ // builds the bag, so the falsy branch yields an empty map (the key
1979
+ // is OMITTED rather than rendered as `k=""` — `SpreadAttrs` does
1980
+ // NOT filter empty strings). Returns null for any shape it can't
1981
+ // faithfully convert so the caller falls back to BF101 (#textarea).
1982
+ const conditional = this.buildConditionalSpreadInitializer(trimmed, ir)
1983
+ if (conditional !== undefined) return conditional
1898
1984
  // Signal-getter call: `attrs()` — pluck the signal's initialValue
1899
1985
  // and translate the JS object literal to a Go map literal.
1900
1986
  const callMatch = /^([a-zA-Z_][a-zA-Z0-9_]*)\s*\(\s*\)$/.exec(trimmed)
@@ -1947,6 +2033,152 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1947
2033
  return null
1948
2034
  }
1949
2035
 
2036
+ /**
2037
+ * Lower a conditional inline-object spread bag value:
2038
+ * `(COND ? { 'aria-describedby': describedBy } : {})`
2039
+ * into an immediately-invoked Go func literal that conditionally
2040
+ * builds the map (so the falsy branch OMITS the key rather than
2041
+ * rendering it as an empty string, which `SpreadAttrs` does not
2042
+ * filter):
2043
+ *
2044
+ * func() map[string]any {
2045
+ * if in.DescribedBy != nil && in.DescribedBy != "" {
2046
+ * return map[string]any{"aria-describedby": in.DescribedBy}
2047
+ * }
2048
+ * return map[string]any{}
2049
+ * }()
2050
+ *
2051
+ * Returns:
2052
+ * - `undefined` when the expression is NOT a parenthesized ternary
2053
+ * of object literals — the caller falls through to other shapes.
2054
+ * - `null` when it IS that shape but a part can't be faithfully
2055
+ * converted (non-static key, unsupported condition, …) — the
2056
+ * caller raises BF101.
2057
+ * - the Go IIFE string when fully convertible.
2058
+ */
2059
+ private buildConditionalSpreadInitializer(
2060
+ spreadExpr: string,
2061
+ ir: ComponentIR,
2062
+ ): string | null | undefined {
2063
+ const expr = this.parseLiteralExpression(spreadExpr)
2064
+ if (!expr || !ts.isConditionalExpression(expr)) return undefined
2065
+ const whenTrue = this.unwrapParens(expr.whenTrue)
2066
+ const whenFalse = this.unwrapParens(expr.whenFalse)
2067
+ if (!ts.isObjectLiteralExpression(whenTrue) || !ts.isObjectLiteralExpression(whenFalse)) {
2068
+ return undefined
2069
+ }
2070
+ // Condition → Go bool against `in.`, type-aware on the prop.
2071
+ const goCond = this.conditionToGoBool(expr.condition, ir)
2072
+ if (goCond === null) return null
2073
+ const trueMap = this.objectLiteralToGoSpreadMap(whenTrue, ir)
2074
+ const falseMap = this.objectLiteralToGoSpreadMap(whenFalse, ir)
2075
+ if (trueMap === null || falseMap === null) return null
2076
+ return (
2077
+ `func() map[string]any {\n` +
2078
+ `\t\tif ${goCond} {\n` +
2079
+ `\t\t\treturn ${trueMap}\n` +
2080
+ `\t\t}\n` +
2081
+ `\t\treturn ${falseMap}\n` +
2082
+ `\t}()`
2083
+ )
2084
+ }
2085
+
2086
+ /** Strip redundant parenthesised wrappers off a TS expression. */
2087
+ private unwrapParens(node: ts.Expression): ts.Expression {
2088
+ let e = node
2089
+ while (ts.isParenthesizedExpression(e)) e = e.expression
2090
+ return e
2091
+ }
2092
+
2093
+ /**
2094
+ * Convert a conditional-spread condition expression to a Go bool in
2095
+ * the `in.` context. Supports a bare prop identifier (`describedBy`)
2096
+ * and its negation (`!describedBy`), type-aware on the prop:
2097
+ * string → `in.X != ""`
2098
+ * boolean → `in.X`
2099
+ * number → `in.X != 0`
2100
+ * unknown / interface{} → `in.X != nil && in.X != ""`
2101
+ * (faithful JS string-truthiness for an interface holding a
2102
+ * string — textarea's `describedBy` resolves to interface{}).
2103
+ * Returns null for any other shape (caller → BF101).
2104
+ */
2105
+ private conditionToGoBool(
2106
+ condition: ts.Expression,
2107
+ ir: ComponentIR,
2108
+ ): string | null {
2109
+ let node = this.unwrapParens(condition)
2110
+ let negate = false
2111
+ if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) {
2112
+ negate = true
2113
+ node = this.unwrapParens(node.operand)
2114
+ }
2115
+ if (!ts.isIdentifier(node)) return null
2116
+ const param = ir.metadata.propsParams.find(p => p.name === node.text)
2117
+ if (!param) return null
2118
+ const field = `in.${this.capitalizeFieldName(param.name)}`
2119
+ const prim = param.type.kind === 'primitive' ? param.type.primitive : undefined
2120
+ let truthy: string
2121
+ if (prim === 'boolean') {
2122
+ truthy = field
2123
+ } else if (prim === 'number') {
2124
+ truthy = `${field} != 0`
2125
+ } else if (prim === 'string') {
2126
+ truthy = `${field} != ""`
2127
+ } else {
2128
+ // unknown / interface{}: the runtime value may be a string, number,
2129
+ // bool, etc., so a string-biased `!= ""` test would diverge from JS
2130
+ // truthiness (e.g. an `interface{}` holding `0` or `false` is falsy in
2131
+ // JS but `!= ""` reads true). Route through `bf.Truthy`, the exported
2132
+ // `Boolean(x)` equivalent, for a faithful check (Copilot review #1752).
2133
+ truthy = `bf.Truthy(${field})`
2134
+ }
2135
+ if (!negate) return truthy
2136
+ // Negation: wrap so `!` applies to the whole truthiness test.
2137
+ if (prim === 'boolean') return `!${field}`
2138
+ if (prim === 'number') return `${field} == 0`
2139
+ if (prim === 'string') return `${field} == ""`
2140
+ return `!bf.Truthy(${field})`
2141
+ }
2142
+
2143
+ /**
2144
+ * Convert a static object literal (`{ 'aria-describedby': describedBy }`)
2145
+ * into a Go `map[string]any{...}` literal for a conditional spread.
2146
+ * Only static string/identifier keys are allowed; values resolve
2147
+ * prop-identifier references to `in.FieldName` and string literals to
2148
+ * Go string literals. Returns null for any computed/spread/dynamic
2149
+ * key or unsupported value (caller → BF101). Empty object → `map[string]any{}`.
2150
+ */
2151
+ private objectLiteralToGoSpreadMap(
2152
+ obj: ts.ObjectLiteralExpression,
2153
+ ir: ComponentIR,
2154
+ ): string | null {
2155
+ const entries: string[] = []
2156
+ for (const prop of obj.properties) {
2157
+ if (!ts.isPropertyAssignment(prop)) return null
2158
+ let key: string
2159
+ if (ts.isIdentifier(prop.name)) {
2160
+ key = prop.name.text
2161
+ } else if (ts.isStringLiteral(prop.name) || ts.isNoSubstitutionTemplateLiteral(prop.name)) {
2162
+ key = prop.name.text
2163
+ } else {
2164
+ return null
2165
+ }
2166
+ const val = this.unwrapParens(prop.initializer)
2167
+ let goVal: string
2168
+ if (ts.isStringLiteral(val) || ts.isNoSubstitutionTemplateLiteral(val)) {
2169
+ goVal = JSON.stringify(val.text)
2170
+ } else if (ts.isIdentifier(val)) {
2171
+ const param = ir.metadata.propsParams.find(p => p.name === val.text)
2172
+ if (!param) return null
2173
+ goVal = `in.${this.capitalizeFieldName(param.name)}`
2174
+ } else {
2175
+ return null
2176
+ }
2177
+ entries.push(`${JSON.stringify(key)}: ${goVal}`)
2178
+ }
2179
+ return `map[string]any{${entries.join(', ')}}`
2180
+ }
2181
+
1950
2182
  /**
1951
2183
  * Convert JavaScript initial value to Go value for NewXxxProps function.
1952
2184
  * References to props params are converted to in.FieldName format.
@@ -2891,6 +3123,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2891
3123
  // ===========================================================================
2892
3124
 
2893
3125
  identifier(name: string): string {
3126
+ // Module pure-string const (e.g. `const baseClasses = '...'` used in a
3127
+ // className template literal): inline the literal value rather than
3128
+ // emit `{{.BaseClasses}}` against a Props field that never exists.
3129
+ const inlined = this.resolveModuleStringConst(name)
3130
+ if (inlined !== null) return inlined
2894
3131
  const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
2895
3132
  if (currentLoopParam && name === currentLoopParam) return '.'
2896
3133
  // An *outer* loop's value variable (we're in a nested loop) is in scope as
@@ -2927,6 +3164,72 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2927
3164
  return `${prefix}${this.capitalizeFieldName(name)}`
2928
3165
  }
2929
3166
 
3167
+ /**
3168
+ * Build the module pure-string-const map from the IR's localConstants.
3169
+ * A const qualifies only when it is module-scope (`isModule`) and its
3170
+ * initializer parses to a single string literal (`ts.StringLiteral` or
3171
+ * `ts.NoSubstitutionTemplateLiteral` — a backtick string with no `${}`).
3172
+ * Template literals *with* interpolations, numeric/object initializers,
3173
+ * `Record<T,string>` maps, memos, and signals are all excluded: only a
3174
+ * pure compile-time string can be safely inlined byte-for-byte.
3175
+ */
3176
+ private collectModuleStringConsts(constants: IRMetadata['localConstants']): Map<string, string> {
3177
+ const map = new Map<string, string>()
3178
+ for (const c of constants ?? []) {
3179
+ if (!c.isModule) continue
3180
+ if (c.value === undefined) continue
3181
+ const literal = this.parsePureStringLiteral(c.value)
3182
+ if (literal !== null) map.set(c.name, literal)
3183
+ }
3184
+ return map
3185
+ }
3186
+
3187
+ /**
3188
+ * Parse a const initializer's source text. Returns the unescaped string
3189
+ * value when the whole initializer is a single string literal (or a
3190
+ * no-substitution template literal), else `null`. Uses the TS parser so
3191
+ * escapes/quotes are resolved exactly as JS would, matching the value
3192
+ * the Hono reference inlines at runtime.
3193
+ */
3194
+ private parsePureStringLiteral(source: string): string | null {
3195
+ const sf = ts.createSourceFile(
3196
+ '__const.ts',
3197
+ `const __x = (${source});`,
3198
+ ts.ScriptTarget.Latest,
3199
+ /*setParentNodes*/ false,
3200
+ )
3201
+ const stmt = sf.statements[0]
3202
+ if (!stmt || !ts.isVariableStatement(stmt)) return null
3203
+ const decl = stmt.declarationList.declarations[0]
3204
+ let init = decl?.initializer
3205
+ while (init && ts.isParenthesizedExpression(init)) init = init.expression
3206
+ if (!init) return null
3207
+ if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
3208
+ return init.text
3209
+ }
3210
+ return null
3211
+ }
3212
+
3213
+ /**
3214
+ * Resolve an identifier to its inlined Go string literal when it names a
3215
+ * module pure-string const. Returns the Go template literal form
3216
+ * (`"<escaped>"`) so callers can drop it straight into a `{{...}}` action,
3217
+ * or `null` when the name is not such a const (the caller then falls back
3218
+ * to its normal field-ref lowering). The value is escaped for a Go
3219
+ * double-quoted string literal — Go's `html/template` then applies the
3220
+ * same contextual auto-escaping it applies to any literal, matching Hono.
3221
+ */
3222
+ private resolveModuleStringConst(name: string): string | null {
3223
+ if (this.loopParamStack.length > 0 && this.loopParamStack[this.loopParamStack.length - 1] === name) {
3224
+ return null
3225
+ }
3226
+ if (this.loopVarRefCount.has(name)) return null
3227
+ if (this.isOuterLoopParam(name)) return null
3228
+ const value = this.moduleStringConsts.get(name)
3229
+ if (value === undefined) return null
3230
+ return `"${this.escapeGoString(value)}"`
3231
+ }
3232
+
2930
3233
  literal(value: string | number | boolean | null, literalType: LiteralType): string {
2931
3234
  if (literalType === 'string') return `"${value}"`
2932
3235
  if (literalType === 'null') return 'nil'
@@ -4313,6 +4616,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4313
4616
  switch (expr.kind) {
4314
4617
  case 'identifier':
4315
4618
  {
4619
+ const inlined = this.resolveModuleStringConst(expr.name)
4620
+ if (inlined !== null) return plain(inlined)
4316
4621
  const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
4317
4622
  if (currentLoopParam && expr.name === currentLoopParam) {
4318
4623
  return plain('.')
@@ -4336,6 +4641,46 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4336
4641
  if (expr.callee.kind === 'identifier' && expr.args.length === 0) {
4337
4642
  return plain(this.rootFieldRef(expr.callee.name))
4338
4643
  }
4644
+ // `isValidElement(x)` — the framework "is this a renderable element?"
4645
+ // predicate. In the Go SSR children model an element is represented by
4646
+ // its already-rendered markup, so this evaluates faithfully as a
4647
+ // truthiness check on the argument (an element is "valid" when there is
4648
+ // something to render). Lowering it as a real, evaluatable expression —
4649
+ // rather than a fabricated `.IsValidElement` field access — is what lets
4650
+ // the `Slot` dynamic-tag guard register and run cleanly on Go.
4651
+ if (
4652
+ expr.callee.kind === 'identifier' &&
4653
+ (identifierPath(expr.callee) ?? expr.callee.name) === 'isValidElement' &&
4654
+ expr.args.length === 1
4655
+ ) {
4656
+ return this.renderConditionExpr(expr.args[0])
4657
+ }
4658
+ // Any other user-defined predicate call with arguments (e.g.
4659
+ // `isAdmin(user)`) has no server-side evaluator and is not a registered
4660
+ // template primitive. There is no honest way to evaluate it at SSR time,
4661
+ // and silently forcing it (to true OR false) is a correctness hazard —
4662
+ // a forced-true could expose auth-gated content, a forced-false could
4663
+ // hide required content, and a warning is too easily ignored. Refuse
4664
+ // with a hard BF102 error so the author must move the predicate to a
4665
+ // supported primitive or defer it with `/* @client */`.
4666
+ if (
4667
+ expr.callee.kind === 'identifier' &&
4668
+ !this.templatePrimitives[identifierPath(expr.callee) ?? '']
4669
+ ) {
4670
+ const path = identifierPath(expr.callee) ?? expr.callee.name
4671
+ this.errors.push({
4672
+ code: 'BF102',
4673
+ severity: 'error',
4674
+ message:
4675
+ `Predicate '${path}(...)' cannot be evaluated in a Go template. ` +
4676
+ `A server-side template cannot call user-defined JavaScript predicates.`,
4677
+ loc: this.makeLoc(),
4678
+ suggestion: { message: GO_REMEDIATION_OPTIONS },
4679
+ })
4680
+ // Go template actions must be self-contained; emit a literal so the
4681
+ // partial still parses while the build fails on the BF102 error.
4682
+ return plain('false')
4683
+ }
4339
4684
  return plain(this.renderParsedExpr(expr))
4340
4685
  }
4341
4686
 
@@ -4634,6 +4979,18 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4634
4979
  return this.renderPortalComponent(comp)
4635
4980
  }
4636
4981
 
4982
+ // Dynamic-tag local (`const Tag = children.tag`): there is no template
4983
+ // named `<Tag>` to call — emitting `{{template "Tag" .TagSlot0}}` would
4984
+ // reference a template that can never be registered, and Go's
4985
+ // html/template escape-walks ALL registered templates (even dead
4986
+ // branches), so the whole render fails with `no such template "Tag"`.
4987
+ // Lower it to its children passthrough instead, so the dead branch
4988
+ // renders harmlessly and the Slot template registers cleanly. (Real
4989
+ // server-side asChild prop-merge on Go is a separate, deferred concern.)
4990
+ if (comp.dynamicTag) {
4991
+ return this.renderChildren(comp.children)
4992
+ }
4993
+
4637
4994
  // In Go templates, components are rendered using {{template "name" data}}
4638
4995
  let templateCall: string
4639
4996
  if (this.inLoop) {
@@ -4734,6 +5091,20 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4734
5091
  // Inline Go template syntax with embedded `{{...}}` actions.
4735
5092
  return `${name}="${this.renderParsedExpr(parsed)}"`
4736
5093
  }
5094
+ // Hono-style nullish-attribute omission (#textarea rows): when the
5095
+ // attribute value is a BARE reference to a nillable (`interface{}`)
5096
+ // prop field, guard emission on `ne .X nil` so an unset optional
5097
+ // prop drops the attribute entirely instead of rendering `attr=""`.
5098
+ // Hono omits `undefined`/`null`-valued attributes; this restores
5099
+ // parity. Scope is deliberately narrow — bare identifiers only — so
5100
+ // member exprs, calls, ternaries, template literals, and
5101
+ // concrete-typed props (which are never nil) are unaffected and
5102
+ // still emit `attr=""` / `attr="0"` exactly as Hono does.
5103
+ const bareId = value.expr.trim()
5104
+ if (this.nillablePropNames.has(bareId)) {
5105
+ const field = `.${this.capitalizeFieldName(bareId)}`
5106
+ return `{{if ne ${field} nil}}${name}="{{${this.convertExpressionToGo(value.expr)}}}"{{end}}`
5107
+ }
4737
5108
  return `${name}="{{${this.convertExpressionToGo(value.expr)}}}"`
4738
5109
  },
4739
5110
  emitBooleanAttr: (_value, name) => name,
@@ -4907,7 +5278,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4907
5278
  if (caseEntries.length === 0) continue
4908
5279
  const branches = caseEntries.map(([k, v], i) => {
4909
5280
  const head = i === 0 ? '{{if' : '{{else if'
4910
- return `${head} eq ${keyExpr} ${JSON.stringify(k)}}}${v}`
5281
+ // The case value is a static Record<T,string> literal emitted
5282
+ // straight into attribute-value text, so HTML-escape it the same
5283
+ // way `string` parts are (via substituteJsInterpolations →
5284
+ // escapeAttrText). Without this, UnoCSS tokens like
5285
+ // `has-[>svg]:px-2.5` would leak a raw `>` and diverge from the
5286
+ // Hono reference, which escapes it to `&gt;`.
5287
+ return `${head} eq ${keyExpr} ${JSON.stringify(k)}}}${this.escapeAttrText(v)}`
4911
5288
  })
4912
5289
  output += branches.join('') + '{{end}}'
4913
5290
  }
@@ -180,7 +180,7 @@ export async function renderGoTemplateComponent(options: RenderOptions): Promise
180
180
  const escapedTemplate = template.replace(/`/g, '` + "`" + `')
181
181
 
182
182
  // Build props initialization
183
- const propsInit = buildGoPropsInit(componentName, props)
183
+ const propsInit = buildGoPropsInit(componentName, props, ir)
184
184
 
185
185
  // Honour `__instanceId` from props for the root scope id so
186
186
  // shared-component fixtures (which pin `<ComponentName>_test`) match
@@ -280,10 +280,26 @@ ${propsInit}
280
280
  function buildGoPropsInit(
281
281
  _componentName: string,
282
282
  props?: Record<string, unknown>,
283
+ ir?: ComponentIR,
283
284
  ): string {
284
285
  if (!props) return ''
285
286
 
287
+ // A `{...props}` rest spread on a component means props that are NOT
288
+ // declared as named params don't get their own top-level Input struct
289
+ // field — they flow through the open-ended rest bag (`Props map[string]any`,
290
+ // the `Capitalize(restPropsName)` field). Without routing them there, a
291
+ // fixture passing e.g. `placeholder` to the `Input` component (whose only
292
+ // declared params are `className` / `type`) emits a top-level
293
+ // `Placeholder:` initializer and Go fails with `unknown field Placeholder
294
+ // in struct literal of type InputInput`. (#1467 Phase 2b)
295
+ const declaredParams = new Set((ir?.metadata.propsParams ?? []).map(p => p.name))
296
+ const restPropsName = ir?.metadata.restPropsName ?? null
297
+ const restBagField = restPropsName
298
+ ? restPropsName.charAt(0).toUpperCase() + restPropsName.slice(1)
299
+ : null
300
+
286
301
  const lines: string[] = []
302
+ const restBagEntries: Array<[string, unknown]> = []
287
303
  for (const [key, value] of Object.entries(props)) {
288
304
  // Skip internal hydration markers — `__instanceId` / `__bfScope`
289
305
  // / `__bfChild` are routed by the framework (consumed via the
@@ -291,6 +307,15 @@ function buildGoPropsInit(
291
307
  // appear on the user-facing input struct). Including them produces
292
308
  // `unknown field __instanceId in struct literal of type XxxInput`.
293
309
  if (key.startsWith('__')) continue
310
+ // A prop that isn't a declared named param on a rest-spread component
311
+ // belongs in the rest bag, not a top-level field. A key that literally
312
+ // matches `restPropsName` already carries a pre-formed bag object and
313
+ // maps straight onto the `Capitalize(restPropsName)` field, so it falls
314
+ // through to the normal (object → map literal) emit below.
315
+ if (restBagField && key !== restPropsName && !declaredParams.has(key)) {
316
+ restBagEntries.push([key, value])
317
+ continue
318
+ }
294
319
  // Capitalize first letter for Go field name
295
320
  const goField = key.charAt(0).toUpperCase() + key.slice(1)
296
321
  if (typeof value === 'string') {
@@ -319,6 +344,13 @@ function buildGoPropsInit(
319
344
  lines.push(`\t\t${goField}: ${goMapLiteralFromObject(value as Record<string, unknown>)},`)
320
345
  }
321
346
  }
347
+ // Emit the collected rest-bag entries as the open-ended bag field. Skip
348
+ // when a direct `restPropsName`-keyed prop already populated it above
349
+ // (merging two sources isn't a shape any fixture needs).
350
+ if (restBagField && restBagEntries.length > 0 && !(restPropsName! in props)) {
351
+ const bag = Object.fromEntries(restBagEntries)
352
+ lines.push(`\t\t${restBagField}: ${goMapLiteralFromObject(bag)},`)
353
+ }
322
354
  return lines.join('\n')
323
355
  }
324
356