@barefootjs/go-template 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,10 +27,14 @@ import type {
27
27
  ParsedExpr,
28
28
  ParsedStatement,
29
29
  SortComparator,
30
+ ReduceOp,
31
+ FlatDepth,
32
+ FlatMapOp,
30
33
  TemplatePart,
31
34
  IRIfStatement,
32
35
  IRProvider,
33
36
  IRAsync,
37
+ IRMetadata,
34
38
  TemplatePrimitiveRegistry,
35
39
  } from '@barefootjs/jsx'
36
40
  import {
@@ -45,6 +49,7 @@ import {
45
49
  type IRNodeEmitter,
46
50
  type EmitIRNode,
47
51
  type AttrValueEmitter,
52
+ type SupportResult,
48
53
  isBooleanAttr,
49
54
  parseExpression,
50
55
  isSupported,
@@ -208,8 +213,56 @@ function emitBfSort(recv: string, c: SortComparator): string {
208
213
  return `bf_sort ${wrapIfMultiToken(recv)} ${groups.join(' ')}`
209
214
  }
210
215
 
216
+ /**
217
+ * Emit the `bf_reduce` call for a `.reduce(fn, init)` arithmetic fold
218
+ * (#1448 Tier C):
219
+ *
220
+ * bf_reduce <recv> "<op>" "<keyKind>" "<keyName>" "<type>" "<init>" "<direction>"
221
+ *
222
+ * op: "+" | "*"
223
+ * keyKind: "self" | "field"
224
+ * keyName: "" when keyKind=self; capitalised field name otherwise
225
+ * (matches the Go struct-field convention, mirroring
226
+ * `emitBfSort`)
227
+ * type: "numeric" | "string"
228
+ * init: the fold's start value — the numeric literal's text for a
229
+ * numeric fold, or the string literal's contents for a
230
+ * concat fold
231
+ * direction: "left" (reduce) | "right" (reduceRight)
232
+ *
233
+ * The runtime folds `init <op> key(item)` in `direction` order and
234
+ * returns the accumulated value (float64 for numeric, string for
235
+ * concat). The order is only observable for string concat — numeric
236
+ * sum / product commute.
237
+ */
238
+ function emitBfReduce(recv: string, op: ReduceOp, direction: 'left' | 'right'): string {
239
+ const keyName = op.key.kind === 'field' ? capitalize(op.key.field) : ''
240
+ // `op.init` is already the decoded seed value (canonical decimal for
241
+ // numeric folds — `strconv.ParseFloat`-safe; escape-free contents for
242
+ // concat folds). Pass it as a quoted operand the runtime interprets
243
+ // by `type`. `direction` is "left" (reduce) or "right" (reduceRight)
244
+ // — only observable for string concatenation; numeric folds are
245
+ // commutative.
246
+ return `bf_reduce ${wrapIfMultiToken(recv)} "${op.op}" "${op.key.kind}" "${keyName}" "${op.type}" "${escapeGoString(op.init)}" "${direction}"`
247
+ }
248
+
249
+ /** Escape a value for embedding in a Go-template double-quoted string. */
250
+ function escapeGoString(s: string): string {
251
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
252
+ }
253
+
211
254
  function capitalize(s: string): string {
212
- return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1)
255
+ if (s.length === 0) return s
256
+ // Match the adapter's struct-field naming (`capitalizeFieldName`):
257
+ // a whole-word Go initialism uppercases entirely (`id` → `ID`,
258
+ // `url` → `URL`) so the `bf_sort` / `bf_reduce` reflect lookup
259
+ // resolves the generated exported field instead of silently folding
260
+ // a zero value. The class is fully initialised by the time any emit
261
+ // helper runs, so referencing the static set here is safe.
262
+ if (GoTemplateAdapter.GO_INITIALISMS.has(s.toLowerCase())) {
263
+ return s.toUpperCase()
264
+ }
265
+ return s[0].toUpperCase() + s.slice(1)
213
266
  }
214
267
 
215
268
  /**
@@ -239,6 +292,22 @@ interface PrimitiveSpec {
239
292
  emit: (args: string[]) => string
240
293
  }
241
294
 
295
+ // Generic remediation appended to BF101 / BF102 diagnostics whose reason
296
+ // doesn't already carry actionable next steps.
297
+ const GO_REMEDIATION_OPTIONS =
298
+ 'Options:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code'
299
+
300
+ // Build the `suggestion.message` for an unsupported expression/condition.
301
+ // A self-contained reason (it already spells out the fix — e.g. the
302
+ // pre-compute / @client hint or the tailored forEach message) is shown
303
+ // as-is; a low-level reason gets the generic options appended; with no
304
+ // reason at all we fall back to the options alone.
305
+ function buildUnsupportedSuggestion(support: SupportResult): string {
306
+ if (!support.reason) return GO_REMEDIATION_OPTIONS
307
+ if (support.selfContained) return support.reason
308
+ return `${support.reason}\n\n${GO_REMEDIATION_OPTIONS}`
309
+ }
310
+
242
311
  const GO_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
243
312
  'JSON.stringify': { arity: 1, emit: (args) => `bf_json ${args[0]}` },
244
313
  'String': { arity: 1, emit: (args) => `bf_string ${args[0]}` },
@@ -350,6 +419,21 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
350
419
  * `template.HTML(...)`; toggles the `"html/template"` import. */
351
420
  private usesHtmlTemplate: boolean = false
352
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
+
353
437
  constructor(options: GoTemplateAdapterOptions = {}) {
354
438
  super()
355
439
  this.options = {
@@ -370,6 +454,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
370
454
  this.templateVarCounter = 0
371
455
  this.propsObjectName = ir.metadata.propsObjectName
372
456
  this.restPropsName = ir.metadata.restPropsName ?? null
457
+ this.moduleStringConsts = this.collectModuleStringConsts(ir.metadata.localConstants)
373
458
 
374
459
  // Surface loop-body usages of components imported from sibling
375
460
  // .tsx files. The adapter emits `{{template "X" .}}` for these,
@@ -1556,6 +1641,16 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1556
1641
  ): void {
1557
1642
  if (node.type === 'component') {
1558
1643
  const comp = node as IRComponent
1644
+ // Dynamic-tag locals (`const Tag = children.tag`) have no registrable
1645
+ // template, so they get no `.<Name>SlotN` struct field. Recurse into
1646
+ // their children (which lower as a passthrough) so any real static
1647
+ // child components nested inside still get their slot fields.
1648
+ if (comp.dynamicTag) {
1649
+ for (const child of comp.children) {
1650
+ this.collectStaticChildInstancesRecursive(child, result, inLoop)
1651
+ }
1652
+ return
1653
+ }
1559
1654
  // Skip Portal components (handled separately via PortalCollector)
1560
1655
  // Skip components inside loops (handled by nestedComponents)
1561
1656
  if (comp.name !== 'Portal' && !inLoop && comp.slotId) {
@@ -2579,7 +2674,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2579
2674
  private static GO_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
2580
2675
 
2581
2676
  /** Go common initialisms that should be fully uppercased (https://go.dev/wiki/CodeReviewComments#initialisms) */
2582
- private static GO_INITIALISMS = new Set([
2677
+ // Not `private`: the module-level `capitalize` helper reads this so
2678
+ // `bf_sort` / `bf_reduce` field projection matches `capitalizeFieldName`.
2679
+ static GO_INITIALISMS = new Set([
2583
2680
  'id', 'url', 'http', 'https', 'api', 'json', 'xml', 'html', 'css', 'sql',
2584
2681
  'ip', 'tcp', 'udp', 'dns', 'ssh', 'tls', 'ssl', 'uri', 'uid', 'uuid',
2585
2682
  'ascii', 'utf8', 'eof', 'grpc', 'rpc', 'cpu', 'gpu', 'ram', 'os',
@@ -2821,6 +2918,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2821
2918
  // ===========================================================================
2822
2919
 
2823
2920
  identifier(name: string): string {
2921
+ // Module pure-string const (e.g. `const baseClasses = '...'` used in a
2922
+ // className template literal): inline the literal value rather than
2923
+ // emit `{{.BaseClasses}}` against a Props field that never exists.
2924
+ const inlined = this.resolveModuleStringConst(name)
2925
+ if (inlined !== null) return inlined
2824
2926
  const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
2825
2927
  if (currentLoopParam && name === currentLoopParam) return '.'
2826
2928
  // An *outer* loop's value variable (we're in a nested loop) is in scope as
@@ -2857,6 +2959,72 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2857
2959
  return `${prefix}${this.capitalizeFieldName(name)}`
2858
2960
  }
2859
2961
 
2962
+ /**
2963
+ * Build the module pure-string-const map from the IR's localConstants.
2964
+ * A const qualifies only when it is module-scope (`isModule`) and its
2965
+ * initializer parses to a single string literal (`ts.StringLiteral` or
2966
+ * `ts.NoSubstitutionTemplateLiteral` — a backtick string with no `${}`).
2967
+ * Template literals *with* interpolations, numeric/object initializers,
2968
+ * `Record<T,string>` maps, memos, and signals are all excluded: only a
2969
+ * pure compile-time string can be safely inlined byte-for-byte.
2970
+ */
2971
+ private collectModuleStringConsts(constants: IRMetadata['localConstants']): Map<string, string> {
2972
+ const map = new Map<string, string>()
2973
+ for (const c of constants ?? []) {
2974
+ if (!c.isModule) continue
2975
+ if (c.value === undefined) continue
2976
+ const literal = this.parsePureStringLiteral(c.value)
2977
+ if (literal !== null) map.set(c.name, literal)
2978
+ }
2979
+ return map
2980
+ }
2981
+
2982
+ /**
2983
+ * Parse a const initializer's source text. Returns the unescaped string
2984
+ * value when the whole initializer is a single string literal (or a
2985
+ * no-substitution template literal), else `null`. Uses the TS parser so
2986
+ * escapes/quotes are resolved exactly as JS would, matching the value
2987
+ * the Hono reference inlines at runtime.
2988
+ */
2989
+ private parsePureStringLiteral(source: string): string | null {
2990
+ const sf = ts.createSourceFile(
2991
+ '__const.ts',
2992
+ `const __x = (${source});`,
2993
+ ts.ScriptTarget.Latest,
2994
+ /*setParentNodes*/ false,
2995
+ )
2996
+ const stmt = sf.statements[0]
2997
+ if (!stmt || !ts.isVariableStatement(stmt)) return null
2998
+ const decl = stmt.declarationList.declarations[0]
2999
+ let init = decl?.initializer
3000
+ while (init && ts.isParenthesizedExpression(init)) init = init.expression
3001
+ if (!init) return null
3002
+ if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
3003
+ return init.text
3004
+ }
3005
+ return null
3006
+ }
3007
+
3008
+ /**
3009
+ * Resolve an identifier to its inlined Go string literal when it names a
3010
+ * module pure-string const. Returns the Go template literal form
3011
+ * (`"<escaped>"`) so callers can drop it straight into a `{{...}}` action,
3012
+ * or `null` when the name is not such a const (the caller then falls back
3013
+ * to its normal field-ref lowering). The value is escaped for a Go
3014
+ * double-quoted string literal — Go's `html/template` then applies the
3015
+ * same contextual auto-escaping it applies to any literal, matching Hono.
3016
+ */
3017
+ private resolveModuleStringConst(name: string): string | null {
3018
+ if (this.loopParamStack.length > 0 && this.loopParamStack[this.loopParamStack.length - 1] === name) {
3019
+ return null
3020
+ }
3021
+ if (this.loopVarRefCount.has(name)) return null
3022
+ if (this.isOuterLoopParam(name)) return null
3023
+ const value = this.moduleStringConsts.get(name)
3024
+ if (value === undefined) return null
3025
+ return `"${this.escapeGoString(value)}"`
3026
+ }
3027
+
2860
3028
  literal(value: string | number | boolean | null, literalType: LiteralType): string {
2861
3029
  if (literalType === 'string') return `"${value}"`
2862
3030
  if (literalType === 'null') return 'nil'
@@ -3089,7 +3257,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3089
3257
  message: `Higher-order method '.${method}' shape cannot be lowered to a Go template action`,
3090
3258
  loc: this.makeLoc(),
3091
3259
  suggestion: {
3092
- message: 'Options:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code',
3260
+ message: GO_REMEDIATION_OPTIONS,
3093
3261
  },
3094
3262
  })
3095
3263
  return `""`
@@ -3311,6 +3479,51 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3311
3479
  return emitBfSort(emit(object), comparator)
3312
3480
  }
3313
3481
 
3482
+ reduceMethod(
3483
+ method: 'reduce' | 'reduceRight',
3484
+ object: ParsedExpr,
3485
+ reduceOp: ReduceOp,
3486
+ emit: (e: ParsedExpr) => string,
3487
+ ): string {
3488
+ // `.reduce(fn, init)` / `.reduceRight(fn, init)` arithmetic-fold
3489
+ // lowering (#1448 Tier C). The structured `ReduceOp` (op / key /
3490
+ // type / init) plus the fold direction feed the `bf_reduce` runtime
3491
+ // helper, which folds the receiver into a scalar.
3492
+ const direction = method === 'reduceRight' ? 'right' : 'left'
3493
+ return emitBfReduce(emit(object), reduceOp, direction)
3494
+ }
3495
+
3496
+ flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
3497
+ // `.flat(depth?)` → `bf_flat <recv> <depth>`. The `Infinity` form
3498
+ // lowers to the `-1` sentinel (flatten fully); a finite depth flattens
3499
+ // that many levels (`0` = shallow copy). See packages/adapter-go-
3500
+ // template/runtime/bf.go.
3501
+ const d = depth === 'infinity' ? -1 : depth
3502
+ return `bf_flat ${wrapIfMultiToken(emit(object))} ${d}`
3503
+ }
3504
+
3505
+ flatMapMethod(object: ParsedExpr, op: FlatMapOp, emit: (e: ParsedExpr) => string): string {
3506
+ const recv = wrapIfMultiToken(emit(object))
3507
+ const proj = op.projection
3508
+ // Tuple projection `i => [i.a, i.b]` → `bf_flat_map_tuple <recv>
3509
+ // "<kind>" "<name>" ...` (one quoted pair per leaf). flat(1) removes
3510
+ // only the literal's wrapper, so the runtime appends each leaf verbatim.
3511
+ if (proj.kind === 'tuple') {
3512
+ const pairs = proj.elements
3513
+ .map(l => (l.kind === 'self' ? `"self" ""` : `"field" "${capitalize(l.field)}"`))
3514
+ .join(' ')
3515
+ return `bf_flat_map_tuple ${recv} ${pairs}`
3516
+ }
3517
+ // Scalar `.flatMap(i => i)` / `.flatMap(i => i.field)` → `bf_flat_map
3518
+ // <recv> "<kind>" "<name>"`. The runtime projects each item then
3519
+ // flattens one level. The field name uses the Go struct-field
3520
+ // capitalisation, matching `bf_reduce` / `bf_sort`.
3521
+ if (proj.kind === 'self') {
3522
+ return `bf_flat_map ${recv} "self" ""`
3523
+ }
3524
+ return `bf_flat_map ${recv} "field" "${capitalize(proj.field)}"`
3525
+ }
3526
+
3314
3527
  unsupported(raw: string, _reason: string): string {
3315
3528
  // Should not happen if `isSupported` was checked at parse time.
3316
3529
  return `[UNSUPPORTED: ${raw}]`
@@ -4046,9 +4259,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4046
4259
  message: `Expression not supported: ${trimmed}`,
4047
4260
  loc: this.makeLoc(),
4048
4261
  suggestion: {
4049
- message: support.reason
4050
- ? `${support.reason}\n\nOptions:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code`
4051
- : 'Options:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code',
4262
+ message: buildUnsupportedSuggestion(support),
4052
4263
  },
4053
4264
  })
4054
4265
  // Return empty string - Go template comments must be separate actions
@@ -4086,7 +4297,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4086
4297
  message: `Complex predicate in else-if is not supported: ${altIfStmt.condition}`,
4087
4298
  loc: this.makeLoc(),
4088
4299
  suggestion: {
4089
- message: 'Options:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code',
4300
+ message: GO_REMEDIATION_OPTIONS,
4090
4301
  },
4091
4302
  })
4092
4303
  }
@@ -4183,9 +4394,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4183
4394
  message: `Condition not supported: ${trimmed}`,
4184
4395
  loc: this.makeLoc(),
4185
4396
  suggestion: {
4186
- message: support.reason
4187
- ? `${support.reason}\n\nOptions:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code`
4188
- : 'Expression contains unsupported syntax',
4397
+ message: buildUnsupportedSuggestion(support),
4189
4398
  },
4190
4399
  })
4191
4400
  // Return false - Go template comments must be separate actions
@@ -4202,6 +4411,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4202
4411
  switch (expr.kind) {
4203
4412
  case 'identifier':
4204
4413
  {
4414
+ const inlined = this.resolveModuleStringConst(expr.name)
4415
+ if (inlined !== null) return plain(inlined)
4205
4416
  const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
4206
4417
  if (currentLoopParam && expr.name === currentLoopParam) {
4207
4418
  return plain('.')
@@ -4225,6 +4436,46 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4225
4436
  if (expr.callee.kind === 'identifier' && expr.args.length === 0) {
4226
4437
  return plain(this.rootFieldRef(expr.callee.name))
4227
4438
  }
4439
+ // `isValidElement(x)` — the framework "is this a renderable element?"
4440
+ // predicate. In the Go SSR children model an element is represented by
4441
+ // its already-rendered markup, so this evaluates faithfully as a
4442
+ // truthiness check on the argument (an element is "valid" when there is
4443
+ // something to render). Lowering it as a real, evaluatable expression —
4444
+ // rather than a fabricated `.IsValidElement` field access — is what lets
4445
+ // the `Slot` dynamic-tag guard register and run cleanly on Go.
4446
+ if (
4447
+ expr.callee.kind === 'identifier' &&
4448
+ (identifierPath(expr.callee) ?? expr.callee.name) === 'isValidElement' &&
4449
+ expr.args.length === 1
4450
+ ) {
4451
+ return this.renderConditionExpr(expr.args[0])
4452
+ }
4453
+ // Any other user-defined predicate call with arguments (e.g.
4454
+ // `isAdmin(user)`) has no server-side evaluator and is not a registered
4455
+ // template primitive. There is no honest way to evaluate it at SSR time,
4456
+ // and silently forcing it (to true OR false) is a correctness hazard —
4457
+ // a forced-true could expose auth-gated content, a forced-false could
4458
+ // hide required content, and a warning is too easily ignored. Refuse
4459
+ // with a hard BF102 error so the author must move the predicate to a
4460
+ // supported primitive or defer it with `/* @client */`.
4461
+ if (
4462
+ expr.callee.kind === 'identifier' &&
4463
+ !this.templatePrimitives[identifierPath(expr.callee) ?? '']
4464
+ ) {
4465
+ const path = identifierPath(expr.callee) ?? expr.callee.name
4466
+ this.errors.push({
4467
+ code: 'BF102',
4468
+ severity: 'error',
4469
+ message:
4470
+ `Predicate '${path}(...)' cannot be evaluated in a Go template. ` +
4471
+ `A server-side template cannot call user-defined JavaScript predicates.`,
4472
+ loc: this.makeLoc(),
4473
+ suggestion: { message: GO_REMEDIATION_OPTIONS },
4474
+ })
4475
+ // Go template actions must be self-contained; emit a literal so the
4476
+ // partial still parses while the build fails on the BF102 error.
4477
+ return plain('false')
4478
+ }
4228
4479
  return plain(this.renderParsedExpr(expr))
4229
4480
  }
4230
4481
 
@@ -4523,6 +4774,18 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4523
4774
  return this.renderPortalComponent(comp)
4524
4775
  }
4525
4776
 
4777
+ // Dynamic-tag local (`const Tag = children.tag`): there is no template
4778
+ // named `<Tag>` to call — emitting `{{template "Tag" .TagSlot0}}` would
4779
+ // reference a template that can never be registered, and Go's
4780
+ // html/template escape-walks ALL registered templates (even dead
4781
+ // branches), so the whole render fails with `no such template "Tag"`.
4782
+ // Lower it to its children passthrough instead, so the dead branch
4783
+ // renders harmlessly and the Slot template registers cleanly. (Real
4784
+ // server-side asChild prop-merge on Go is a separate, deferred concern.)
4785
+ if (comp.dynamicTag) {
4786
+ return this.renderChildren(comp.children)
4787
+ }
4788
+
4526
4789
  // In Go templates, components are rendered using {{template "name" data}}
4527
4790
  let templateCall: string
4528
4791
  if (this.inLoop) {
@@ -4796,7 +5059,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4796
5059
  if (caseEntries.length === 0) continue
4797
5060
  const branches = caseEntries.map(([k, v], i) => {
4798
5061
  const head = i === 0 ? '{{if' : '{{else if'
4799
- return `${head} eq ${keyExpr} ${JSON.stringify(k)}}}${v}`
5062
+ // The case value is a static Record<T,string> literal emitted
5063
+ // straight into attribute-value text, so HTML-escape it the same
5064
+ // way `string` parts are (via substituteJsInterpolations →
5065
+ // escapeAttrText). Without this, UnoCSS tokens like
5066
+ // `has-[>svg]:px-2.5` would leak a raw `>` and diverge from the
5067
+ // Hono reference, which escapes it to `&gt;`.
5068
+ return `${head} eq ${keyExpr} ${JSON.stringify(k)}}}${this.escapeAttrText(v)}`
4800
5069
  })
4801
5070
  output += branches.join('') + '{{end}}'
4802
5071
  }
@@ -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
 
@@ -355,7 +387,14 @@ function goMapLiteralFromObject(
355
387
  else if (typeof v === 'number') entries.push(`${key}: ${v}`)
356
388
  else if (typeof v === 'boolean') entries.push(`${key}: ${v}`)
357
389
  else if (v === null) entries.push(`${key}: nil`)
358
- else if (v && typeof v === 'object' && !Array.isArray(v)) {
390
+ else if (Array.isArray(v)) {
391
+ // Array-valued field inside an object-in-array (e.g. the `tags`
392
+ // of `items.flatMap(i => i.tags)`). Without this branch the field
393
+ // was silently dropped, leaving an empty map so the template
394
+ // rendered nothing for the projection (#1448 Tier C flatMap).
395
+ entries.push(`${key}: ${goArrayLiteralFromArray(v)}`)
396
+ }
397
+ else if (v && typeof v === 'object') {
359
398
  entries.push(`${key}: ${goMapLiteralFromObject(v as Record<string, unknown>, capitalizeKeys)}`)
360
399
  }
361
400
  }