@barefootjs/go-template 0.31.7 → 0.31.9

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.
@@ -14,8 +14,31 @@ import { typeInfoToGo } from '../type/type-codegen.ts'
14
14
  /**
15
15
  * Build a map from prop name to a better Go type inferred from signals. When a
16
16
  * signal is initialized from a prop (`createSignal(props.initial ?? 0)`), the
17
- * signal's type annotation may be more specific than the prop's `TypeInfo`. Only
18
- * generic prop types (containing `interface{}`) are overridden.
17
+ * signal's type annotation may be more specific than the prop's `TypeInfo`.
18
+ *
19
+ * Two trigger conditions, mirroring `emitPropsDataFields`'s identical
20
+ * signal-vs-prop reconciliation for the PROPS struct field (`generate - Go
21
+ * struct types` — "Let a specific signal type override a less-specific prop
22
+ * type in either direction"), so the Input/Props field type this override
23
+ * feeds (via `resolvePropGoType`) and the constructor's baked VALUE
24
+ * (`convertInitialValue`'s `extractPropNameFromInitialValue` prop-passthrough
25
+ * shortcut) never disagree about which type is authoritative:
26
+ *
27
+ * 1. A generic prop type (containing `interface{}`) — the historical case.
28
+ * 2. #2674: BOTH sides resolve to a CONCRETE type that DISAGREES — e.g. an
29
+ * inline array-element prop type (`initialTodos: Array<{ id, text,
30
+ * done }>`) now independently synthesizes its OWN named struct
31
+ * (`TodoAppInitialTodosItem`) rather than falling to `interface{}`, but
32
+ * a signal seeded from it via a shape-widening transform
33
+ * (`createSignal<Todo[]>((props.initialTodos ?? []).map(t => ({...t,
34
+ * editing: false})))`, `Todo` carrying an EXTRA `editing` field) still
35
+ * wants the signal's own `Todo` element type. Before #2674 this case
36
+ * was unreachable — every inline object/array prop type WAS
37
+ * `interface{}`-containing, so case 1 always fired — leaving the
38
+ * passthrough shortcut safe by accident (prop and signal Go types were
39
+ * always forced equal). Widening to case 2 restores that same
40
+ * equality now that a prop's own type can independently resolve to
41
+ * something concrete but narrower.
19
42
  */
20
43
  export function buildPropTypeOverrides(ctx: GoEmitContext, ir: ComponentIR): Map<string, string> {
21
44
  const overrides = new Map<string, string>()
@@ -28,11 +51,10 @@ export function buildPropTypeOverrides(ctx: GoEmitContext, ir: ComponentIR): Map
28
51
  const param = ir.metadata.propsParams.find(p => p.name === propName)
29
52
  if (!param) continue
30
53
  const propGoType = typeInfoToGo(ctx, param.type, param.defaultValue, param.parsed)
31
- if (propGoType.includes('interface{}')) {
32
- const signalGoType = typeInfoToGo(ctx, signal.type, signal.initialValue, signal.parsed)
33
- if (!signalGoType.includes('interface{}')) {
34
- overrides.set(propName, signalGoType)
35
- }
54
+ const signalGoType = typeInfoToGo(ctx, signal.type, signal.initialValue, signal.parsed)
55
+ if (signalGoType.includes('interface{}')) continue // never widen TO something less resolved
56
+ if (propGoType.includes('interface{}') || signalGoType !== propGoType) {
57
+ overrides.set(propName, signalGoType)
36
58
  }
37
59
  }
38
60
  }
@@ -141,8 +141,25 @@ export function typeInfoToGo(
141
141
  return `[]${typeInfoToGo(ctx, typeInfo.elementType)}`
142
142
  }
143
143
  return '[]interface{}'
144
- case 'object':
145
- return 'map[string]interface{}'
144
+ case 'object': {
145
+ // #2674 Plan A: an ANONYMOUS object type — an inline array-element
146
+ // type (`items: { id: number }[]`) or a nested anonymous property
147
+ // inside a named type (`Row.user`) — synthesizes a deterministically-
148
+ // named, json-tagged struct in `emitSynthPropStructs`, registered here
149
+ // by the exact `TypeInfo` object's IDENTITY (not name/shape — two
150
+ // anonymous types at different structural positions get different
151
+ // synthesized names even when shaped identically). That pre-pass runs
152
+ // before this function is ever consulted for the current compile (see
153
+ // `generateTypes()`'s ordering), so a hit here means a real struct was
154
+ // emitted; a miss (no pre-pass ran, or the pre-pass skipped this exact
155
+ // type on a synthesized-name collision) falls back to the historical
156
+ // `map[string]interface{}` — the same PascalCase-baked map
157
+ // `bakeInlineObjectAsGoMap` (`parsed-literal-to-go.ts`) still targets,
158
+ // so SSR stays correct and only the hydration-payload leak the
159
+ // synthesis closes remains open for that one skipped type.
160
+ const synthName = ctx.state.synthObjectStructNames.get(typeInfo)
161
+ return synthName ?? 'map[string]interface{}'
162
+ }
146
163
  case 'interface':
147
164
  // Gate on an ACTUAL backing (a generated struct — `localStructFields` —
148
165
  // or a string-union alias — `localTypeAliases`, which emits `type X =
@@ -5,14 +5,16 @@
5
5
  * one object, so the skip list and the declaration can't drift. Keep the
6
6
  * file even when the set is empty — the next divergence lands here, not in
7
7
  * a re-created file.
8
+ *
9
+ * Empty — #2630's `static-array-from-props-with-component-precomputed`
10
+ * divergence graduated once the harness (`test-render.ts`'s
11
+ * `buildDynamicChildLoopSeeding`, despite the name — see its doc comment)
12
+ * learned to seed a prop-backed static child-component loop's Props slice
13
+ * the same way it already seeded a signal-backed dynamic one: the adapter's
14
+ * own `emission` was never the bug, only this harness's route-handler
15
+ * stand-in was missing the prop-derived case.
8
16
  */
9
17
 
10
18
  import type { RenderDivergences } from '@barefootjs/jsx'
11
19
 
12
- export const renderDivergences: RenderDivergences = {
13
- // #2630: the exact shape BF101's pass-as-prop suggestion (#2321) steers
14
- // go-template users into — compiles clean, runs clean, silently renders the
15
- // loop host empty. Full analysis in the issue.
16
- 'static-array-from-props-with-component-precomputed':
17
- 'prop-backed child-component loop renders the loop host empty at SSR (https://github.com/piconic-ai/barefootjs/issues/2630)',
18
- }
20
+ export const renderDivergences: RenderDivergences = {}
@@ -382,6 +382,12 @@ func main() {
382
382
  ScopeID: ${JSON.stringify(rootScopeId)},
383
383
  ${propsInit}
384
384
  })
385
+ // Mirrors production's Renderer.renderComponentInto (bf.go), which marks
386
+ // the top-level component so BfPropsAttr emits bf-p: this harness calls
387
+ // tmpl.ExecuteTemplate directly instead of going through Renderer, so
388
+ // nothing else sets this flag. Without it every fixture's bf-p attribute
389
+ // is silently absent regardless of what the adapter itself does.
390
+ props.BfIsRoot = true
385
391
  ${dynamicSeedingLines.length > 0 ? dynamicSeedingLines.join('\n') + '\n' : ''} if err := tmpl.ExecuteTemplate(os.Stdout, "${componentName}", props); err != nil {
386
392
  os.Stderr.WriteString("template error: " + err.Error() + "\\n")
387
393
  os.Exit(1)
@@ -587,57 +593,148 @@ function findBaseSignalGetter(expr: ParsedExpr | undefined, signalGetters: Reado
587
593
  }
588
594
 
589
595
  /**
590
- * (#2209 part 2) Replicate, in the generated `main.go`, the documented
591
- * "the route handler populates the loop-body child-component slice at
592
- * request time" contract for a signal-backed dynamic loop
593
- * `generateNewPropsFunction`'s doc comment on `<Name>s []<Name>Props`
594
- * in `adapter/go-template-adapter.ts`. The constructor only ever seeds
595
- * the loop's DATUM slice (e.g. `.Todos`, straight from the caller's
596
- * Input); the child-component Props slice the template actually
597
- * ranges over (`.TodoItems`) has no server-side population path in
598
- * this harness the Hono reference materializes it by literally
599
- * executing the component, so this closes the gap the same way:
600
- * derive each item's child Props from the datum slice, exactly as a
601
- * real route handler is documented to.
596
+ * (#2630) Resolve a `isPropDerived` loop's `arrayParsed` to the JS-level
597
+ * prop name it reads. `isArrayExprDirectPropRef` (`jsx-to-ir.ts`) is what
598
+ * sets `isPropDerivedArray` in the first place, and it only recognizes two
599
+ * shapes a bare destructured-prop identifier (`entries.map(...)` inside
600
+ * `function({ entries })`) or a direct `props.<name>` member access — so
601
+ * unlike `findBaseSignalGetter` there is no `call`/chain walking to do; the
602
+ * compiler already restricted the shape. Returns the prop's LOCAL binding
603
+ * name; the caller resolves it to the CALLER-FACING field
604
+ * (`sourceName ?? name`) via `propsParams`, matching how
605
+ * `generateInputStruct`/`generateNewPropsFunction` key the Go field and how
606
+ * `buildGoPropsInit` keys the harness's own prop initializer (both by the
607
+ * JS `props` object's key, not the local destructure binding).
608
+ */
609
+ function findLoopPropField(expr: ParsedExpr | undefined): string | null {
610
+ if (!expr) return null
611
+ if (expr.kind === 'identifier') return expr.name
612
+ if (expr.kind === 'member' && expr.object.kind === 'identifier') return expr.property
613
+ return null
614
+ }
615
+
616
+ /**
617
+ * Resolve a loop-body child prop's VALUE expression to the Go expression
618
+ * that reads it off the per-row `item` local: a bare pass-through of the
619
+ * loop param (`todo={todo}` → `item`) or a member access on it
620
+ * (`variant={entry.variant}` → `item.Variant`, #2630 — the
621
+ * `static-array-from-props-with-component-precomputed` fixture's `<Tag
622
+ * id={entry.id} variant={entry.variant} />`). `null` for anything else (a
623
+ * prop that doesn't derive from the loop row at all).
624
+ */
625
+ function goItemExprForLoopProp(parsed: ParsedExpr, loopParam: string): string | null {
626
+ if (parsed.kind === 'identifier' && parsed.name === loopParam) return 'item'
627
+ if (
628
+ parsed.kind === 'member' &&
629
+ !parsed.computed &&
630
+ parsed.object.kind === 'identifier' &&
631
+ parsed.object.name === loopParam
632
+ ) {
633
+ return `item.${capitalizeFieldName(parsed.property)}`
634
+ }
635
+ return null
636
+ }
637
+
638
+ /**
639
+ * (#2209 part 2, extended by #2630) Replicate, in the generated `main.go`,
640
+ * the documented "the route handler populates the loop-body child-component
641
+ * slice at request time" contract from `generateNewPropsFunction`'s doc
642
+ * comment on `<Name>s []<Name>Props` in `adapter/go-template-adapter.ts`.
643
+ * Covers TWO loop shapes that both leave `.<Name>s` unpopulated in this
644
+ * harness, for different reasons:
645
+ *
646
+ * - **Signal-backed dynamic** (`isDynamic && !isPropDerived`, e.g.
647
+ * `todos().map(...)`): the constructor only ever seeds the loop's DATUM
648
+ * slice (`.Todos`, straight from the caller's Input) — the child Props
649
+ * slice the template ranges over (`.TodoItems`) is documented as
650
+ * handler-populated and never touched by `NewXxxProps` at all.
651
+ * - **Prop-backed static** (`isPropDerived`, e.g. `props.entries.map(...)`,
652
+ * #2630): `NewXxxProps` DOES try to build `.Tags` from `in.Tags` — but
653
+ * `in.Tags` is a SEPARATE Input field from the driving prop (`Entries`)
654
+ * whenever the child's plural name doesn't literally coincide with the
655
+ * prop's own name (`propDerivedNestedArrayFields`/`isNestedArrayShadowed`
656
+ * in the real adapter), and this harness's `buildGoPropsInit` only ever
657
+ * populates fields from the JS `props` object's own keys — `tags` isn't
658
+ * one. `in.Tags` stays a nil slice, so the constructor's own range over
659
+ * it produces zero rows. Both shapes are closed the same way: derive
660
+ * each item's child Props from the resolved datum slice AFTER
661
+ * construction, overwriting whatever (empty, for the prop-backed case)
662
+ * slice the constructor built — exactly as a real route handler is
663
+ * documented to.
664
+ *
665
+ * The Hono reference needs none of this because it materializes children by
666
+ * literally executing the component.
602
667
  *
603
668
  * Deliberately narrow: only fires for a loop whose (a) array source
604
- * resolves, through `call`/`member` chains, to a component signal
605
- * getter, (b) generated Go TEMPLATE text actually ranges over
669
+ * resolves to a signal getter (dynamic) or a direct prop reference
670
+ * (prop-derived) never a `call`/member chain beyond what each shape's own
671
+ * resolver recognizes, (b) generated Go TEMPLATE text actually ranges over
606
672
  * `.<Name>s` (a plain substring check on GENERATED GO OUTPUT — not JS
607
- * parsing — so a `/* @client *\/`-marked loop, whose SSR template has
608
- * no such range, is untouched by construction), and (c) at least one
609
- * child prop is a bare pass-through of the loop item (`todo={todo}`).
610
- * Returns the Go statements to splice into `main()` plus whether `fmt`
611
- * needs importing.
673
+ * parsing — so a `/* @client *\/`-marked loop, whose SSR template has no
674
+ * such range, is untouched by construction), and (c) every resolved child
675
+ * prop is either a bare pass-through of the loop item (`todo={todo}`) or a
676
+ * member access on it (`variant={entry.variant}`). Returns the Go
677
+ * statements to splice into `main()` plus whether `fmt` needs importing.
612
678
  */
613
679
  function buildDynamicChildLoopSeeding(
614
680
  ir: ComponentIR,
615
681
  template: string,
616
682
  ): { lines: string[]; needsFmt: boolean } {
617
683
  const signalGetters = new Set(ir.metadata.signals.map(s => s.getter))
684
+ const propsParams = ir.metadata.propsParams
618
685
  const lines: string[] = []
619
686
  let needsFmt = false
620
687
  for (const nested of findNestedComponents(ir.root) as NestedComponentInfo[]) {
621
- if (!nested.isDynamic || nested.isPropDerived) continue
622
688
  if (nested.bodyChildren && nested.bodyChildren.length > 0) continue
623
689
  if (!nested.loopParam) continue
624
690
  if (!template.includes(`:= .${nested.name}s}}`)) continue
625
- const datumField = findBaseSignalGetter(nested.loopArrayParsed, signalGetters)
691
+
692
+ let datumField: string | null
693
+ if (nested.isPropDerived) {
694
+ const localName = findLoopPropField(nested.loopArrayParsed)
695
+ const param = localName ? propsParams.find(p => p.name === localName) : undefined
696
+ datumField = param ? (param.sourceName ?? param.name) : null
697
+ // (#2627/#2628 shadowing) When the driving prop's Go field name
698
+ // coincides with the child's own plural (`tags` prop → `Tag`
699
+ // component), the REAL adapter's `isNestedArrayShadowed` drops the
700
+ // prop's separate field entirely — `props.Tags` IS the only field,
701
+ // pre-shaped `TagInput` rows and all, already populated straight from
702
+ // the harness's own `buildGoPropsInit` (which types the literal
703
+ // against the Input struct's declared `[]TagInput` field). Seeding
704
+ // here would `range` the very slice it just emptied with `make` on
705
+ // the line above it — skip so that already-correct path stays
706
+ // untouched.
707
+ if (datumField && capitalizeFieldName(datumField) === capitalizeFieldName(`${nested.name}s`)) continue
708
+ } else if (nested.isDynamic) {
709
+ datumField = findBaseSignalGetter(nested.loopArrayParsed, signalGetters)
710
+ } else {
711
+ continue
712
+ }
626
713
  if (!datumField) continue
627
714
 
715
+ // All-or-nothing (Copilot review on #2630's PR): a prop that is
716
+ // legitimately SSR-irrelevant (event handler, `key`, dashed debug
717
+ // attrs) is exempt, but any OTHER prop this resolver can't express as
718
+ // an `item` read means the seeded `<Name>Input{...}` would carry Go
719
+ // zero values for it — silently wrong SSR instead of the loudly-empty
720
+ // host the unseeded path produces. Bail on the whole component in
721
+ // that case rather than seed an incomplete slice.
628
722
  const inputFields: string[] = []
723
+ let unresolvableProp = false
629
724
  for (const prop of nested.props) {
630
725
  if (prop.isEventHandler) continue
631
726
  if (prop.name === 'key' || prop.name.includes('-')) continue
632
- if (
633
- prop.value.kind === 'expression' &&
634
- prop.value.parsed?.kind === 'identifier' &&
635
- prop.value.parsed.name === nested.loopParam
636
- ) {
637
- inputFields.push(`${capitalizeFieldName(prop.name)}: item`)
727
+ const itemExpr =
728
+ prop.value.kind === 'expression' && prop.value.parsed
729
+ ? goItemExprForLoopProp(prop.value.parsed, nested.loopParam)
730
+ : null
731
+ if (itemExpr === null) {
732
+ unresolvableProp = true
733
+ break
638
734
  }
735
+ inputFields.push(`${capitalizeFieldName(prop.name)}: ${itemExpr}`)
639
736
  }
640
- if (inputFields.length === 0) continue
737
+ if (unresolvableProp || inputFields.length === 0) continue
641
738
 
642
739
  lines.push(`\tprops.${nested.name}s = make([]${nested.name}Props, len(props.${capitalizeFieldName(datumField)}))`)
643
740
  lines.push(`\tfor i, item := range props.${capitalizeFieldName(datumField)} {`)
@@ -734,27 +831,37 @@ function buildGoPropsInit(
734
831
  let sliceLiteral: string
735
832
  if (elemType && elemType.startsWith('map[')) {
736
833
  // An untyped object-array Input field (an inline prop object type
737
- // that didn't synthesize a named struct, e.g. `items: { title:
738
- // string; tags: string[] }[]` `typeInfoToGo`'s 'object' case,
739
- // type-codegen.ts) resolves to `[]map[string]interface{}`, not a
740
- // named struct. `goTypedSliceLiteralFromArray`'s `goStructLiteral`
741
- // emits bare `Field: value` entries, which is struct-literal syntax
742
- // and doesn't compile as a map literal's keys — route these through
834
+ // that didn't synthesize a named struct a synthesized-name
835
+ // collision, #2674's graceful fallback) resolves to
836
+ // `[]map[string]interface{}`, not a named struct.
837
+ // `goTypedSliceLiteralFromArray`'s `goStructLiteral` emits bare
838
+ // `Field: value` entries, which is struct-literal syntax and
839
+ // doesn't compile as a map literal's keys — route these through
743
840
  // the map-literal builder instead (#2075, search-params-derived-filter).
744
841
  sliceLiteral = goTypedMapSliceLiteralFromArray(value, elemType)
745
842
  } else if (elemType) {
746
- sliceLiteral = goTypedSliceLiteralFromArray(value, elemType)
843
+ sliceLiteral = goTypedSliceLiteralFromArray(value, elemType, goTypes)
747
844
  } else {
748
845
  sliceLiteral = goArrayLiteralFromArray(value)
749
846
  }
750
847
  lines.push(`\t\t${goField}: ${sliceLiteral},`)
751
848
  } else if (value && typeof value === 'object') {
752
- // Plain object → Go `map[string]any` literal (#1407 follow-up).
753
- // Used by `jsx-spread-rest-prop` to populate the input-bag
754
- // Spread_<N> field that carries the destructured-rest payload.
755
- // The same harness change is needed when any future fixture
756
- // passes a `Record<string, unknown>`-shaped prop through.
757
- lines.push(`\t\t${goField}: ${goMapLiteralFromObject(value as Record<string, unknown>)},`)
849
+ // Plain object → Go `map[string]any` literal (#1407 follow-up), UNLESS
850
+ // the Input field is itself a synthesized named struct (#2674 — an
851
+ // inline object-typed prop, e.g. `cfg: { id: number; label?: string
852
+ // }`, now resolves its OWN concrete struct instead of falling to
853
+ // `map[string]interface{}`) a `map[string]any{…}` literal doesn't
854
+ // compile against a concretely-typed struct field.
855
+ const fieldGoType = parseGoStructFields(goTypes, `${componentName}Input`)?.get(goField)
856
+ if (fieldGoType && fieldGoType !== 'interface{}' && fieldGoType !== 'any' && !fieldGoType.startsWith('map[')) {
857
+ lines.push(`\t\t${goField}: ${goStructLiteral(value as Record<string, unknown>, fieldGoType, goTypes)},`)
858
+ } else {
859
+ // Used by `jsx-spread-rest-prop` to populate the input-bag
860
+ // Spread_<N> field that carries the destructured-rest payload.
861
+ // The same harness change is needed when any future fixture
862
+ // passes a `Record<string, unknown>`-shaped prop through.
863
+ lines.push(`\t\t${goField}: ${goMapLiteralFromObject(value as Record<string, unknown>)},`)
864
+ }
758
865
  }
759
866
  }
760
867
  // Emit the collected rest-bag entries as the open-ended bag field. Skip
@@ -799,12 +906,20 @@ function goSliceElemType(
799
906
  * Emit a typed Go slice literal (`[]Elem{Elem{…}, …}`). Object elements become
800
907
  * keyed struct literals with PascalCase field names; scalar elements (for an
801
908
  * `[]string` / `[]int` field) are emitted bare. (#1297, toggle-shared)
909
+ *
910
+ * `goTypes` (when supplied) threads through to `goStructLiteral` so it can
911
+ * look up each of ITS OWN fields' declared Go types — needed since #2674:
912
+ * `elemType` may now be a synthesized struct (`TaggedListItemsItem`) with a
913
+ * concretely-typed slice/nested-struct field (`Tags []string`), and a blind
914
+ * `[]any{…}`/`map[string]interface{}{…}` for that field no longer compiles
915
+ * against it the way it always did against the old `map[string]interface{}`
916
+ * element type.
802
917
  */
803
- function goTypedSliceLiteralFromArray(arr: unknown[], elemType: string): string {
918
+ function goTypedSliceLiteralFromArray(arr: unknown[], elemType: string, goTypes?: string): string {
804
919
  const entries = arr.map(v => {
805
920
  if (v instanceof Date) return goStringLit(v.toISOString())
806
921
  if (v && typeof v === 'object' && !Array.isArray(v)) {
807
- return goStructLiteral(v as Record<string, unknown>, elemType)
922
+ return goStructLiteral(v as Record<string, unknown>, elemType, goTypes)
808
923
  }
809
924
  if (typeof v === 'string') return `"${v.replace(/"/g, '\\"')}"`
810
925
  if (typeof v === 'number' || typeof v === 'boolean') return String(v)
@@ -839,11 +954,6 @@ function goTypedMapSliceLiteralFromArray(arr: unknown[], elemType: string): stri
839
954
  return `[]${elemType}{${entries.join(', ')}}`
840
955
  }
841
956
 
842
- /**
843
- * Emit a keyed Go struct literal (`Elem{Field: val, …}`) with PascalCase field
844
- * names. Only the keys the caller supplied are set, so an omitted optional prop
845
- * (e.g. `defaultOn` on the third toggle item) takes the Go zero value. (#1297)
846
- */
847
957
  /**
848
958
  * Emit a JS string as a Go interpreted string literal. JSON string
849
959
  * escaping is a subset of Go's (`\"`, `\\`, `\n`, `\uXXXX` are all valid
@@ -858,7 +968,71 @@ function goStringLit(v: string): string {
858
968
  return JSON.stringify(v)
859
969
  }
860
970
 
861
- function goStructLiteral(obj: Record<string, unknown>, typeName: string): string {
971
+ /**
972
+ * #2674: parse a named Go struct's OWN field → declared-type map out of
973
+ * `goTypes` (the generated `types.go` text), by struct name — the SAME
974
+ * text-scrape strategy `goSliceElemType` uses for `<Component>Input`,
975
+ * generalized to ANY struct name (a synthesized element type, `Row`, a
976
+ * loop-body wrapper, …) so `goStructLiteral` can bake each of ITS fields
977
+ * against what the struct ACTUALLY declares instead of guessing generically.
978
+ * Returns `null` when `goTypes` is absent or the struct isn't found — callers
979
+ * degrade to the pre-#2674 generic (`[]any` / `map[string]interface{}`)
980
+ * baking for every field, same as before this existed.
981
+ */
982
+ function parseGoStructFields(goTypes: string | undefined, typeName: string): Map<string, string> | null {
983
+ if (!goTypes) return null
984
+ const struct = goTypes.match(new RegExp(`type ${typeName} struct \\{([\\s\\S]*?)\\n\\}`))
985
+ if (!struct) return null
986
+ const fields = new Map<string, string>()
987
+ // Field lines are always `\t<GoName> <GoType>[ \`json:"..."\`][ // comment]`
988
+ // — GoType is one whitespace-free token in every shape this harness bakes
989
+ // (`string`, `[]string`, `map[string]interface{}`, `[]TaggedListItemsItem`,
990
+ // `interface{}`); a pointer-typed framework field (`*bf.ScriptCollector`)
991
+ // doesn't match this class and is skipped — this harness never bakes a
992
+ // VALUE for one of those.
993
+ const fieldRe = /\n[ \t]*(\w+)[ \t]+([\w.[\]{}]+)/g
994
+ let m: RegExpExecArray | null
995
+ while ((m = fieldRe.exec(struct[1])) !== null) {
996
+ fields.set(m[1], m[2])
997
+ }
998
+ return fields
999
+ }
1000
+
1001
+ /**
1002
+ * Emit a typed Go scalar slice literal (`[]string{"a", "b"}`) for a struct
1003
+ * field whose declared element type is a Go scalar (#2674 — `Tags []string`
1004
+ * on a synthesized element struct). Falls back to `nil` for a shape that
1005
+ * shouldn't reach a scalar-typed field (an object/array value) — dead in
1006
+ * practice since the caller only routes here for an `Array.isArray(v)` prop
1007
+ * value.
1008
+ */
1009
+ function goScalarSliceLiteral(arr: unknown[], elemGoType: string): string {
1010
+ const entries = arr.map(v => {
1011
+ if (typeof v === 'string') return goStringLit(v)
1012
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v)
1013
+ if (v === null) return 'nil'
1014
+ if (v instanceof Date) return goStringLit(v.toISOString())
1015
+ return 'nil'
1016
+ })
1017
+ return `[]${elemGoType}{${entries.join(', ')}}`
1018
+ }
1019
+
1020
+ /**
1021
+ * Emit a keyed Go struct literal (`Elem{Field: val, …}`) with PascalCase field
1022
+ * names. Only the keys the caller supplied are set, so an omitted optional prop
1023
+ * (e.g. `defaultOn` on the third toggle item) takes the Go zero value. (#1297)
1024
+ *
1025
+ * #2674: when `goTypes` is supplied, each field's VALUE bakes against what
1026
+ * `typeName` ACTUALLY declares for that field (via `parseGoStructFields`) —
1027
+ * a synthesized element struct can now carry a concretely-typed nested slice
1028
+ * (`Tags []string`) or nested struct (`RowUser`), not just scalars, and a
1029
+ * blind `[]any{…}` / `map[string]interface{}{…}` (this function's pre-#2674
1030
+ * behavior, still the fallback when `goTypes` is absent or the field is
1031
+ * unresolved/genuinely `interface{}`/`map[string]interface{}`-typed) no
1032
+ * longer compiles against those.
1033
+ */
1034
+ function goStructLiteral(obj: Record<string, unknown>, typeName: string, goTypes?: string): string {
1035
+ const fieldTypes = parseGoStructFields(goTypes, typeName)
862
1036
  const fields: string[] = []
863
1037
  for (const [k, v] of Object.entries(obj)) {
864
1038
  // `goFieldNameForKey`, not the bare `capitalizeFieldName` — a data-driven
@@ -866,12 +1040,42 @@ function goStructLiteral(obj: Record<string, unknown>, typeName: string): string
866
1040
  // adapter's own struct-literal baking (`parsed-literal-to-go.ts`)
867
1041
  // sanitizes those to `DataX`, not `Data-x` (Copilot review, #2202).
868
1042
  const goField = goFieldNameForKey(k)
1043
+ const fieldGoType = fieldTypes?.get(goField)
869
1044
  if (typeof v === 'string') fields.push(`${goField}: ${goStringLit(v)}`)
870
1045
  else if (typeof v === 'number' || typeof v === 'boolean') fields.push(`${goField}: ${v}`)
871
1046
  else if (v === null) fields.push(`${goField}: nil`)
872
1047
  else if (v instanceof Date) fields.push(`${goField}: ${goStringLit(v.toISOString())}`)
873
- else if (Array.isArray(v)) fields.push(`${goField}: ${goArrayLiteralFromArray(v)}`)
874
- else if (v && typeof v === 'object') fields.push(`${goField}: ${goMapLiteralFromObject(v as Record<string, unknown>, true)}`)
1048
+ else if (Array.isArray(v)) {
1049
+ const elemGoType = fieldGoType?.startsWith('[]') ? fieldGoType.slice(2) : null
1050
+ if (elemGoType === 'interface{}' || elemGoType === 'any') {
1051
+ fields.push(`${goField}: ${goArrayLiteralFromArray(v)}`)
1052
+ } else if (elemGoType?.startsWith('map[')) {
1053
+ fields.push(`${goField}: ${goTypedMapSliceLiteralFromArray(v, elemGoType)}`)
1054
+ } else if (elemGoType && (v.length === 0 || typeof v[0] !== 'object')) {
1055
+ // A scalar-element slice field (`Tags []string`) — bake a typed
1056
+ // scalar literal, not the generic `[]any` `goArrayLiteralFromArray`
1057
+ // would emit (doesn't compile against a concrete `[]string` field).
1058
+ fields.push(`${goField}: ${goScalarSliceLiteral(v, elemGoType)}`)
1059
+ } else if (elemGoType) {
1060
+ // A struct-element slice field — recurse with the SAME `goTypes` so
1061
+ // nesting keeps resolving (a synthesized type nested inside another
1062
+ // synthesized type, #2674's `RowUserAddress`-style chaining).
1063
+ fields.push(`${goField}: ${goTypedSliceLiteralFromArray(v, elemGoType, goTypes)}`)
1064
+ } else {
1065
+ fields.push(`${goField}: ${goArrayLiteralFromArray(v)}`)
1066
+ }
1067
+ }
1068
+ else if (v && typeof v === 'object') {
1069
+ if (fieldGoType && fieldGoType !== 'interface{}' && !fieldGoType.startsWith('map[')) {
1070
+ // A nested named-struct field (#2674 — `RowUser`, `Row.user`'s
1071
+ // synthesized type): recurse as a struct literal, not a map — a
1072
+ // `map[string]interface{}{…}` literal doesn't compile against a
1073
+ // concretely-typed struct field.
1074
+ fields.push(`${goField}: ${goStructLiteral(v as Record<string, unknown>, fieldGoType, goTypes)}`)
1075
+ } else {
1076
+ fields.push(`${goField}: ${goMapLiteralFromObject(v as Record<string, unknown>, true)}`)
1077
+ }
1078
+ }
875
1079
  }
876
1080
  return `${typeName}{${fields.join(', ')}}`
877
1081
  }