@barefootjs/go-template 0.5.0 → 0.5.2

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.
@@ -21,6 +21,7 @@ import type {
21
21
  IRTemplatePart,
22
22
  IRProp,
23
23
  TypeInfo,
24
+ TypeDefinition,
24
25
  CompilerError,
25
26
  SourceLocation,
26
27
  ParsedExpr,
@@ -329,6 +330,21 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
329
330
  private localTypeNames: Set<string> = new Set()
330
331
  /** Local type aliases mapping type name to base type (e.g., Filter → 'string') */
331
332
  private localTypeAliases: Map<string, string> = new Map()
333
+ /**
334
+ * Per-struct field map (type name → source TS key → Go field name), populated
335
+ * during generateTypes. The object-literal baker consults this so a baked
336
+ * struct literal only names fields the generated struct actually declares.
337
+ */
338
+ private localStructFields: Map<string, Map<string, string>> = new Map()
339
+ /**
340
+ * Synthesised array types for untyped object-array signals (signal getter →
341
+ * `[]SynthStruct` TypeInfo), populated during generateTypes (#1680). An
342
+ * untyped `createSignal([{ id: "a" }])` has no element type to bake against;
343
+ * we infer a struct from the literal's shape so the field can be typed and
344
+ * the items baked. Consulted by both the signal field-type emitter and the
345
+ * initial-value baker.
346
+ */
347
+ private synthStructTypes: Map<string, TypeInfo> = new Map()
332
348
 
333
349
  /** Set during type generation when any emit references
334
350
  * `template.HTML(...)`; toggles the `"html/template"` import. */
@@ -654,6 +670,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
654
670
  // Build set of locally-defined type names and aliases so typeInfoToGo can resolve them
655
671
  this.localTypeNames = new Set<string>()
656
672
  this.localTypeAliases = new Map<string, string>()
673
+ this.localStructFields = new Map<string, Map<string, string>>()
657
674
  for (const td of ir.metadata.typeDefinitions) {
658
675
  // Skip the Props type itself (it's the component's own props, not a reusable type)
659
676
  if (td.name === 'Props' || td.name === `${componentName}Props`) continue
@@ -663,6 +680,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
663
680
  // Track string literal union aliases (e.g., type Filter = 'all' | 'active')
664
681
  if (td.definition.match(/^type \w+ = ('[^']*'(\s*\|\s*'[^']*')*)/)) {
665
682
  this.localTypeAliases.set(td.name, 'string')
683
+ } else {
684
+ // Record the struct's source-key → Go-field-name map for the baker,
685
+ // from the same field derivation the struct emitter uses.
686
+ const fields = this.structFieldsFor(td)
687
+ if (fields.length > 0) {
688
+ this.localStructFields.set(td.name, new Map(fields.map(f => [f.tsName, f.goName])))
689
+ }
666
690
  }
667
691
  }
668
692
 
@@ -677,6 +701,29 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
677
701
  }
678
702
  }
679
703
 
704
+ // Synthesise a struct for each untyped object-array signal (#1680) and emit
705
+ // it, so the signal field can be typed `[]Synth` and its inline items baked
706
+ // (the loop body reaches each item via struct field access). Registered in
707
+ // localTypeNames/localStructFields so the baker resolves the element type.
708
+ this.synthStructTypes = new Map<string, TypeInfo>()
709
+ for (const signal of ir.metadata.signals) {
710
+ const synth = this.synthesizeStructFromSignal(signal, componentName)
711
+ if (!synth) continue
712
+ this.localTypeNames.add(synth.name)
713
+ this.localStructFields.set(synth.name, new Map(synth.fields.map(f => [f.tsName, f.goName])))
714
+ this.synthStructTypes.set(signal.getter, {
715
+ kind: 'array',
716
+ raw: `${synth.name}[]`,
717
+ elementType: { kind: 'interface', raw: synth.name },
718
+ })
719
+ const goFields = synth.fields.map(
720
+ f => `\t${f.goName} ${f.goType} \`json:"${this.toJsonTag(f.tsName)}"\``,
721
+ )
722
+ lines.push(`// ${synth.name} is a synthesised element type for the ${signal.getter} signal.`)
723
+ lines.push(`type ${synth.name} struct {\n${goFields.join('\n')}\n}`)
724
+ lines.push('')
725
+ }
726
+
680
727
  // Find nested components (loops with childComponent)
681
728
  const nestedComponents = this.findNestedComponents(ir.root)
682
729
 
@@ -722,38 +769,166 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
722
769
  * Convert a TypeScript type definition to a Go type.
723
770
  * Handles object types → Go structs, and union string literals → string alias.
724
771
  */
725
- private typeDefinitionToGo(td: { kind: string; name: string; definition: string }): string | null {
726
- const def = td.definition
727
-
728
- // String literal union: type Filter = 'all' | 'active' | 'completed'
729
- if (def.match(/^type \w+ = ('[^']*'(\s*\|\s*'[^']*')*)/)) {
772
+ private typeDefinitionToGo(td: TypeDefinition): string | null {
773
+ // String literal union: type Filter = 'all' | 'active' | 'completed'.
774
+ // These carry no `properties`, so the analyzer leaves the field set empty;
775
+ // detect the alias from the definition and map it to a Go string.
776
+ if (td.definition.match(/^type \w+ = ('[^']*'(\s*\|\s*'[^']*')*)/)) {
730
777
  // Map to Go string (union of string literals → just string in Go)
731
778
  return `// ${td.name} is a string type.\ntype ${td.name} = string`
732
779
  }
733
780
 
734
781
  // Object/interface type: type Todo = { id: number; text: string; ... }
735
- const bodyMatch = def.match(/(?:type \w+ = |interface \w+ )\{([\s\S]*)\}/)
736
- if (!bodyMatch) return null
782
+ const fields = this.structFieldsFor(td)
783
+ if (fields.length === 0) return null
784
+
785
+ const goFields = fields.map(
786
+ f => `\t${f.goName} ${f.goType} \`json:"${this.toJsonTag(f.tsName)}"\``,
787
+ )
788
+ return `// ${td.name} represents a ${td.name.toLowerCase()}.\ntype ${td.name} struct {\n${goFields.join('\n')}\n}`
789
+ }
790
+
791
+ /**
792
+ * Derive a struct's Go fields from the analyzer-provided structured
793
+ * properties — no string parsing of the definition. This is the single
794
+ * source of truth for which fields a generated struct has and each field's Go
795
+ * name/type; both the struct emitter ({@link typeDefinitionToGo}) and the
796
+ * object-literal baker ({@link tsLiteralToGo}) consume it, so a baked literal
797
+ * can never name a field the struct doesn't declare.
798
+ *
799
+ * A property whose source key isn't a valid Go identifier (`"data-id"`, a
800
+ * numeric key, …) can't become a struct field, so it's dropped here — and is
801
+ * therefore absent from the baker's field map too, which bails to nil for any
802
+ * literal that uses such a key.
803
+ */
804
+ private structFieldsFor(td: TypeDefinition): Array<{ tsName: string; goName: string; goType: string }> {
805
+ const fields: Array<{ tsName: string; goName: string; goType: string }> = []
806
+ for (const prop of td.properties ?? []) {
807
+ if (!GoTemplateAdapter.GO_IDENTIFIER.test(prop.name)) continue
808
+ fields.push({
809
+ tsName: prop.name,
810
+ goName: this.capitalizeFieldName(prop.name),
811
+ goType: this.typeInfoToGo(prop.type),
812
+ })
813
+ }
814
+ return fields
815
+ }
816
+
817
+ /**
818
+ * Synthesise a Go struct from an untyped object-array signal's inline initial
819
+ * value (#1680). Returns the struct name + fields, or `null` when synthesis
820
+ * isn't possible so the caller keeps the field `[]interface{}`/`nil`.
821
+ *
822
+ * Synthesis applies only when:
823
+ * - the signal's type is an array with no usable element type (untyped),
824
+ * - the initial value is a non-empty array literal of object literals,
825
+ * - every element shares the same set of Go-identifier keys, and
826
+ * - every value is a scalar literal whose Go type is consistent per key
827
+ * (numeric keys widen int→float64 when mixed).
828
+ *
829
+ * Any deviation (heterogeneous shape, a nested object/array value, a
830
+ * non-literal value, a non-identifier key, or a name collision with an
831
+ * existing type) returns `null`.
832
+ */
833
+ private synthesizeStructFromSignal(
834
+ signal: { getter: string; type: TypeInfo; initialValue: string },
835
+ componentName: string,
836
+ ): { name: string; fields: Array<{ tsName: string; goName: string; goType: string }> } | null {
837
+ // Only untyped arrays: a typed (`Item[]`) or scalar (`string[]`) element
838
+ // already bakes through the normal path.
839
+ if (signal.type.kind !== 'array') return null
840
+ const elem = signal.type.elementType
841
+ if (elem && elem.kind !== 'unknown') return null
842
+
843
+ const node = this.parseLiteralExpression(signal.initialValue)
844
+ if (!node || !ts.isArrayLiteralExpression(node) || node.elements.length === 0) return null
845
+
846
+ // Collect the field order + per-key Go types from the first element, then
847
+ // require every other element to match exactly.
848
+ const order: string[] = []
849
+ const goTypes = new Map<string, string>()
850
+ for (let i = 0; i < node.elements.length; i++) {
851
+ const el = node.elements[i]
852
+ if (!ts.isObjectLiteralExpression(el)) return null
853
+ const seen = new Set<string>()
854
+ for (const prop of el.properties) {
855
+ if (!ts.isPropertyAssignment(prop)) return null
856
+ if (
857
+ !ts.isIdentifier(prop.name) &&
858
+ !ts.isStringLiteral(prop.name) &&
859
+ !ts.isNumericLiteral(prop.name)
860
+ ) {
861
+ return null
862
+ }
863
+ const key = prop.name.text
864
+ if (!GoTemplateAdapter.GO_IDENTIFIER.test(key)) return null
865
+ const goType = this.scalarLiteralGoType(prop.initializer)
866
+ if (!goType) return null
867
+ seen.add(key)
868
+ const prev = goTypes.get(key)
869
+ if (prev === undefined) {
870
+ if (i !== 0) return null // key absent from the first element → shape differs
871
+ order.push(key)
872
+ goTypes.set(key, goType)
873
+ } else {
874
+ const merged = this.mergeScalarGoType(prev, goType)
875
+ if (!merged) return null
876
+ goTypes.set(key, merged)
877
+ }
878
+ }
879
+ // Every key from the first element must be present in this element too.
880
+ if (seen.size !== order.length) return null
881
+ }
737
882
 
738
- const body = bodyMatch[1]
739
- const goFields: string[] = []
883
+ const name = `${componentName}${this.capitalizeFieldName(signal.getter)}Item`
884
+ // Don't shadow a user-defined or already-synthesised type.
885
+ if (this.localTypeNames.has(name)) return null
740
886
 
741
- // Parse each field: "fieldName: type" or "fieldName?: type"
742
- // Handle both semicolon-separated and newline-separated
743
- const fieldEntries = body.split(/[;\n]/).map(s => s.trim()).filter(Boolean)
744
- for (const entry of fieldEntries) {
745
- const fieldMatch = entry.match(/^(\w+)\??\s*:\s*(.+)$/)
746
- if (!fieldMatch) continue
747
- const [, fieldName, tsType] = fieldMatch
748
- const goFieldName = this.capitalizeFieldName(fieldName)
749
- const goType = this.tsTypeStringToGo(tsType.trim())
750
- const jsonTag = this.toJsonTag(fieldName)
751
- goFields.push(`\t${goFieldName} ${goType} \`json:"${jsonTag}"\``)
887
+ return {
888
+ name,
889
+ fields: order.map(key => ({
890
+ tsName: key,
891
+ goName: this.capitalizeFieldName(key),
892
+ goType: goTypes.get(key)!,
893
+ })),
752
894
  }
895
+ }
753
896
 
754
- if (goFields.length === 0) return null
897
+ /**
898
+ * The Go type for a scalar JS literal used as a synthesised struct field
899
+ * value, or `null` for anything non-scalar (objects, arrays, identifiers,
900
+ * calls, interpolated templates) so the caller bails out of synthesis.
901
+ */
902
+ private scalarLiteralGoType(node: ts.Expression): string | null {
903
+ if (
904
+ ts.isPrefixUnaryExpression(node) &&
905
+ node.operator === ts.SyntaxKind.MinusToken &&
906
+ ts.isNumericLiteral(node.operand)
907
+ ) {
908
+ return this.numericLiteralGoType(node.operand.text)
909
+ }
910
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return 'string'
911
+ if (ts.isNumericLiteral(node)) return this.numericLiteralGoType(node.text)
912
+ if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) {
913
+ return 'bool'
914
+ }
915
+ return null
916
+ }
755
917
 
756
- return `// ${td.name} represents a ${td.name.toLowerCase()}.\ntype ${td.name} struct {\n${goFields.join('\n')}\n}`
918
+ /** `int` for an integer literal, `float64` when the literal has a fraction
919
+ * or exponent. */
920
+ private numericLiteralGoType(text: string): string {
921
+ return /[.eE]/.test(text) && !text.startsWith('0x') ? 'float64' : 'int'
922
+ }
923
+
924
+ /** Reconcile two inferred Go types for the same key across elements: equal
925
+ * types stay; mixed numeric (int/float64) widens to float64; otherwise null
926
+ * (incompatible → bail). */
927
+ private mergeScalarGoType(a: string, b: string): string | null {
928
+ if (a === b) return a
929
+ const numeric = new Set(['int', 'float64'])
930
+ if (numeric.has(a) && numeric.has(b)) return 'float64'
931
+ return null
757
932
  }
758
933
 
759
934
  /**
@@ -923,6 +1098,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
923
1098
  // Skip if a prop field with the same name was already emitted
924
1099
  if (propFieldNames.has(fieldName)) continue
925
1100
  const jsonTag = this.toJsonTag(signal.getter)
1101
+ // A synthesised struct type (#1680) wins outright — the signal is an
1102
+ // untyped object array we gave a concrete element type.
1103
+ const synthType = this.synthStructTypes.get(signal.getter)
1104
+ if (synthType) {
1105
+ lines.push(`\t${fieldName} ${this.typeInfoToGo(synthType)} \`json:"${jsonTag}"\``)
1106
+ continue
1107
+ }
926
1108
  // Infer type from initial value or referenced prop's type
927
1109
  let goType: string
928
1110
  let referencedProp = propsParamMap.get(signal.initialValue)
@@ -1134,7 +1316,10 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1134
1316
  if (hoisted) {
1135
1317
  lines.push(`\t\t${fieldName}: ${hoisted.varName},`)
1136
1318
  } else {
1137
- const initialValue = this.convertInitialValue(signal.initialValue, signal.type, ir.metadata.propsParams)
1319
+ // Bake against the synthesised struct type when one was inferred for
1320
+ // this untyped object-array signal (#1680), else the signal's own type.
1321
+ const bakeType = this.synthStructTypes.get(signal.getter) ?? signal.type
1322
+ const initialValue = this.convertInitialValue(signal.initialValue, bakeType, ir.metadata.propsParams)
1138
1323
  lines.push(`\t\t${fieldName}: ${initialValue},`)
1139
1324
  }
1140
1325
  }
@@ -1735,14 +1920,22 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1735
1920
  }
1736
1921
  }
1737
1922
 
1738
- // For arrays, use nil for complex JS expressions
1923
+ // For arrays, bake a fully-literal initial value into a Go slice literal
1924
+ // so the SSR data context carries the items (#1672). Empty / nullish
1925
+ // literals collapse to nil, and any non-literal element (a call, a
1926
+ // variable reference, …) falls back to nil so the handler populates it.
1927
+ //
1928
+ // The baked literal is element-type-aware so it both compiles and renders:
1929
+ // - scalar elements → `[]string{…}` / `[]interface{}{…}` (template `{{.}}`)
1930
+ // - struct elements → `[]Item{Item{ID: …}}` (template `.ID`)
1931
+ // An untyped object array would land in a `[]interface{}` field whose
1932
+ // `map[string]interface{}` items the template can't reach via field access
1933
+ // (`.ID` → <nil>), so `jsLiteralToGo` returns null there and we keep nil.
1739
1934
  if (typeInfo.kind === 'array') {
1740
- // Simple array literal or empty
1741
- if (value === '[]' || value === 'null' || value === 'undefined') {
1742
- return 'nil'
1743
- }
1744
- // Complex expression - use nil as placeholder
1745
- return 'nil'
1935
+ // Bake a fully-literal initial value into a Go slice literal; anything
1936
+ // the parser can't reduce to a literal a call, an identifier, `null` /
1937
+ // `undefined`, or an empty array — yields null and we keep `nil`.
1938
+ return this.jsLiteralToGo(value, typeInfo) ?? 'nil'
1746
1939
  }
1747
1940
 
1748
1941
  // String alias (e.g., Filter = string) — return string value instead of nil
@@ -1760,6 +1953,137 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1760
1953
  return 'nil'
1761
1954
  }
1762
1955
 
1956
+ /**
1957
+ * Convert a fully-literal JS expression string into an equivalent Go literal
1958
+ * whose Go type matches `typeInfo` (#1672), used to bake a signal's inline
1959
+ * initial value into the SSR data context:
1960
+ *
1961
+ * `["x", "y"]` (string[]) → `[]string{"x", "y"}`
1962
+ * `["x", "y"]` (unknown[]) → `[]interface{}{"x", "y"}`
1963
+ * `[{ id: "a" }]` (Item[]) → `[]Item{Item{ID: "a"}}`
1964
+ *
1965
+ * Returns `null` — so the caller keeps `nil` — when the expression (or any
1966
+ * nested element) is not a pure literal (a call, identifier, template with
1967
+ * interpolation, …) or cannot be expressed in the target Go type without a
1968
+ * render/compile mismatch (e.g. an object element in a `[]interface{}` field,
1969
+ * which the SSR template reaches via struct field access the map lacks).
1970
+ */
1971
+ private jsLiteralToGo(value: string, typeInfo: TypeInfo): string | null {
1972
+ const expr = this.parseLiteralExpression(value)
1973
+ if (!expr) return null
1974
+ return this.tsLiteralToGo(expr, typeInfo)
1975
+ }
1976
+
1977
+ /**
1978
+ * Parse a JS expression string into its TS AST node (parentheses unwrapped),
1979
+ * or `null` when it isn't a single expression. Shared by the literal baker
1980
+ * and the struct-shape synthesiser.
1981
+ */
1982
+ private parseLiteralExpression(value: string): ts.Expression | null {
1983
+ const sf = ts.createSourceFile(
1984
+ '__lit.ts', `(${value})`, ts.ScriptTarget.Latest, /* setParentNodes */ true,
1985
+ )
1986
+ // Require exactly one expression statement. A value that error-recovers
1987
+ // into multiple statements (e.g. `1; 2`) isn't a single literal — bail
1988
+ // rather than silently baking only the first.
1989
+ if (sf.statements.length !== 1) return null
1990
+ const stmt = sf.statements[0]
1991
+ if (!ts.isExpressionStatement(stmt)) return null
1992
+ let expr: ts.Expression = stmt.expression
1993
+ while (ts.isParenthesizedExpression(expr)) expr = expr.expression
1994
+ return expr
1995
+ }
1996
+
1997
+ /**
1998
+ * Recursively convert a TS literal AST node to a Go literal typed as
1999
+ * `typeInfo`, or null when the node is not a pure literal / cannot be
2000
+ * represented in that Go type.
2001
+ */
2002
+ private tsLiteralToGo(node: ts.Expression, typeInfo?: TypeInfo): string | null {
2003
+ // Unwrap a leading unary minus on a numeric literal (`-1`).
2004
+ if (
2005
+ ts.isPrefixUnaryExpression(node) &&
2006
+ node.operator === ts.SyntaxKind.MinusToken &&
2007
+ ts.isNumericLiteral(node.operand)
2008
+ ) {
2009
+ return `-${node.operand.text}`
2010
+ }
2011
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
2012
+ return JSON.stringify(node.text)
2013
+ }
2014
+ // Pass the numeric literal's source spelling through verbatim. Every form
2015
+ // the TS parser accepts here (`1`, `1.5`, `1e3`, `0x10`, `1_000`) is also a
2016
+ // valid Go numeric literal, so no re-formatting is needed.
2017
+ if (ts.isNumericLiteral(node)) return node.text
2018
+ if (node.kind === ts.SyntaxKind.TrueKeyword) return 'true'
2019
+ if (node.kind === ts.SyntaxKind.FalseKeyword) return 'false'
2020
+ if (node.kind === ts.SyntaxKind.NullKeyword) return 'nil'
2021
+
2022
+ if (ts.isArrayLiteralExpression(node)) {
2023
+ // An empty array literal (`[]`, and — since the TS parser tolerates
2024
+ // whitespace/comments — `[ ]`, `[/* */]`) carries no items, so there's
2025
+ // nothing to bake. Returning null keeps the field `nil`, which JSON-
2026
+ // marshals as `null` rather than the `[]` an empty slice would produce.
2027
+ if (node.elements.length === 0) return null
2028
+
2029
+ // Slice header mirrors the field's Go type (`[]string`, `[]Item`,
2030
+ // `[]interface{}`); elements are converted against the element type.
2031
+ const elemType = typeInfo?.kind === 'array' ? typeInfo.elementType : undefined
2032
+ const sliceHeader = typeInfo?.kind === 'array'
2033
+ ? this.typeInfoToGo(typeInfo)
2034
+ : '[]interface{}'
2035
+ const elems: string[] = []
2036
+ for (const el of node.elements) {
2037
+ const go = this.tsLiteralToGo(el, elemType)
2038
+ if (go === null) return null
2039
+ elems.push(go)
2040
+ }
2041
+ return `${sliceHeader}{${elems.join(', ')}}`
2042
+ }
2043
+
2044
+ if (ts.isObjectLiteralExpression(node)) {
2045
+ // An object can only be baked when the target Go type is a concrete
2046
+ // struct — otherwise it would land in `interface{}` / a map the SSR
2047
+ // template can't reach via field access. The struct's field map is the
2048
+ // source of truth: it tells us the exact Go field name for each source
2049
+ // key and, by omission, which keys the struct doesn't declare. Bail
2050
+ // (→ nil) when the type isn't a known struct.
2051
+ const goType = typeInfo ? this.typeInfoToGo(typeInfo) : 'interface{}'
2052
+ const structFields = this.localStructFields.get(goType)
2053
+ if (!structFields) return null
2054
+ const entries: string[] = []
2055
+ for (const prop of node.properties) {
2056
+ // Only plain `key: scalar` pairs are baked; spreads, methods,
2057
+ // shorthand, computed/accessor members, and nested object/array
2058
+ // values (whose struct field types we don't track here) bail to nil.
2059
+ if (!ts.isPropertyAssignment(prop)) return null
2060
+ if (
2061
+ !ts.isIdentifier(prop.name) &&
2062
+ !ts.isStringLiteral(prop.name) &&
2063
+ !ts.isNumericLiteral(prop.name)
2064
+ ) {
2065
+ return null
2066
+ }
2067
+ // Resolve the Go field name from the struct's own field map rather
2068
+ // than re-deriving it. A key the struct doesn't declare (a typo, or a
2069
+ // non-identifier key like `"data-id"` that never became a field) is
2070
+ // absent here, so we bail to nil instead of emitting a literal that
2071
+ // names a nonexistent field and won't compile.
2072
+ const goField = structFields.get(prop.name.text)
2073
+ if (!goField) return null
2074
+ const init = prop.initializer
2075
+ if (ts.isObjectLiteralExpression(init) || ts.isArrayLiteralExpression(init)) {
2076
+ return null
2077
+ }
2078
+ const go = this.tsLiteralToGo(init)
2079
+ if (go === null) return null
2080
+ entries.push(`${goField}: ${go}`)
2081
+ }
2082
+ return `${goType}{${entries.join(', ')}}`
2083
+ }
2084
+ return null
2085
+ }
2086
+
1763
2087
  /**
1764
2088
  * Convert TypeInfo to Go type string.
1765
2089
  * If type is unknown, tries to infer from defaultValue.
@@ -2246,6 +2570,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2246
2570
  return null
2247
2571
  }
2248
2572
 
2573
+ /**
2574
+ * A source key that can become a Go struct field — i.e. a valid Go
2575
+ * identifier. TS object keys that aren't (e.g. `"data-id"`, numeric keys)
2576
+ * can't be exported struct fields, so they're excluded from struct generation
2577
+ * and from the baker's field map.
2578
+ */
2579
+ private static GO_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
2580
+
2249
2581
  /** Go common initialisms that should be fully uppercased (https://go.dev/wiki/CodeReviewComments#initialisms) */
2250
2582
  private static GO_INITIALISMS = new Set([
2251
2583
  'id', 'url', 'http', 'https', 'api', 'json', 'xml', 'html', 'css', 'sql',
@@ -2491,8 +2823,38 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2491
2823
  identifier(name: string): string {
2492
2824
  const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
2493
2825
  if (currentLoopParam && name === currentLoopParam) return '.'
2826
+ // An *outer* loop's value variable (we're in a nested loop) is in scope as
2827
+ // the Go range variable `$name` declared by that loop's `{{range … := …}}`;
2828
+ // the inner dot no longer refers to it, and it's not a root field. (#1677)
2829
+ if (this.isOuterLoopParam(name)) return `$${name}`
2494
2830
  if (this.loopVarRefCount.has(name)) return `$${name}`
2495
- return `.${this.capitalizeFieldName(name)}`
2831
+ return this.rootFieldRef(name)
2832
+ }
2833
+
2834
+ /**
2835
+ * True when `name` is a loop value variable from an enclosing (not the
2836
+ * current) loop — i.e. it sits on `loopParamStack` below the top. Such a
2837
+ * reference resolves to the Go range variable `$name`, not the inner dot or
2838
+ * the root data.
2839
+ */
2840
+ private isOuterLoopParam(name: string): boolean {
2841
+ const top = this.loopParamStack.length - 1
2842
+ for (let i = 0; i < top; i++) {
2843
+ if (this.loopParamStack[i] === name) return true
2844
+ }
2845
+ return false
2846
+ }
2847
+
2848
+ /**
2849
+ * A reference to a root-scope field — a signal, a prop, or a derived value
2850
+ * that lives on the component's top-level data struct. Inside a `{{range}}`
2851
+ * the dot is rebound to the iteration element, so root data must be reached
2852
+ * through Go template's `$` (the top-level argument to Execute), which never
2853
+ * rebinds. Outside any loop the root *is* the dot, so we emit `.Field` (#1677).
2854
+ */
2855
+ private rootFieldRef(name: string): string {
2856
+ const prefix = this.loopParamStack.length > 0 ? '$.' : '.'
2857
+ return `${prefix}${this.capitalizeFieldName(name)}`
2496
2858
  }
2497
2859
 
2498
2860
  literal(value: string | number | boolean | null, literalType: LiteralType): string {
@@ -2502,9 +2864,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2502
2864
  }
2503
2865
 
2504
2866
  call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
2505
- // Signal call: count() -> .Count
2867
+ // Signal call: count() -> .Count (or $.Count inside a loop, #1677)
2506
2868
  if (callee.kind === 'identifier' && args.length === 0) {
2507
- return `.${this.capitalizeFieldName(callee.name)}`
2869
+ return this.rootFieldRef(callee.name)
2508
2870
  }
2509
2871
  // Array methods (`.join` and any others added to ArrayMethod, #1443)
2510
2872
  // are lifted into the `array-method` IR kind at parse time, so
@@ -2568,9 +2930,10 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2568
2930
  if (templateBlock) return templateBlock
2569
2931
  }
2570
2932
 
2571
- // SolidJS-style props pattern: props.xxx -> .Xxx
2933
+ // SolidJS-style props pattern: props.xxx -> .Xxx (or $.Xxx inside a loop,
2934
+ // since props live on the root data struct, not the iteration element #1677)
2572
2935
  if (object.kind === 'identifier' && this.propsObjectName && object.name === this.propsObjectName) {
2573
- return `.${this.capitalizeFieldName(property)}`
2936
+ return this.rootFieldRef(property)
2574
2937
  }
2575
2938
 
2576
2939
  // Inside a loop, the loop param variable refers to the current item
@@ -3763,11 +4126,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3763
4126
  if (currentLoopParam && expr.name === currentLoopParam) {
3764
4127
  return plain('.')
3765
4128
  }
4129
+ // Outer loop value variable (nested loop) → its range var `$name`.
4130
+ if (this.isOuterLoopParam(expr.name)) {
4131
+ return plain(`$${expr.name}`)
4132
+ }
3766
4133
  if (this.loopVarRefCount.has(expr.name)) {
3767
4134
  return plain(`$${expr.name}`)
3768
4135
  }
3769
4136
  }
3770
- return plain(`.${this.capitalizeFieldName(expr.name)}`)
4137
+ return plain(this.rootFieldRef(expr.name))
3771
4138
 
3772
4139
  case 'literal':
3773
4140
  if (expr.literalType === 'string') return plain(`"${expr.value}"`)
@@ -3776,7 +4143,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3776
4143
 
3777
4144
  case 'call': {
3778
4145
  if (expr.callee.kind === 'identifier' && expr.args.length === 0) {
3779
- return plain(`.${this.capitalizeFieldName(expr.callee.name)}`)
4146
+ return plain(this.rootFieldRef(expr.callee.name))
3780
4147
  }
3781
4148
  return plain(this.renderParsedExpr(expr))
3782
4149
  }
@@ -3792,7 +4159,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3792
4159
  }
3793
4160
 
3794
4161
  if (expr.object.kind === 'identifier' && this.propsObjectName && expr.object.name === this.propsObjectName) {
3795
- return plain(`.${this.capitalizeFieldName(expr.property)}`)
4162
+ return plain(this.rootFieldRef(expr.property))
3796
4163
  }
3797
4164
 
3798
4165
  {
@@ -3983,6 +4350,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3983
4350
  }
3984
4351
  }
3985
4352
  const children = this.renderChildren(loop.children)
4353
+ // Build the per-item anchor marker while the loop param is still on the
4354
+ // stack, so a `bodyIsItemConditional` key expression (#1665) resolves
4355
+ // against the range item (`.` context) like `data-key` does — popping
4356
+ // first would rewrite `t.id` to `.T.ID` instead of `.ID`.
4357
+ const itemMarker = this.loopItemMarker(loop)
3986
4358
  for (const v of addedLoopVars) {
3987
4359
  const rc = (this.loopVarRefCount.get(v) ?? 1) - 1
3988
4360
  if (rc <= 0) this.loopVarRefCount.delete(v)
@@ -4020,15 +4392,29 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4020
4392
  filterCond = 'true'
4021
4393
  }
4022
4394
 
4023
- // Per-item start marker for multi-root Fragment items (#1212).
4024
- const itemMarker = loop.bodyIsMultiRoot ? `{{bfComment "bf-loop-i"}}` : ''
4025
4395
  return `{{bfComment "loop:${loop.markerId}"}}{{range $${rangeIndex}, $${rangeValue} := ${goArray}}}{{if ${filterCond}}}${itemMarker}${children}{{end}}{{end}}{{bfComment "/loop:${loop.markerId}"}}`
4026
4396
  }
4027
4397
 
4028
- const itemMarker = loop.bodyIsMultiRoot ? `{{bfComment "bf-loop-i"}}` : ''
4029
4398
  return `{{bfComment "loop:${loop.markerId}"}}{{range $${rangeIndex}, $${rangeValue} := ${goArray}}}${itemMarker}${children}{{end}}{{bfComment "/loop:${loop.markerId}"}}`
4030
4399
  }
4031
4400
 
4401
+ /**
4402
+ * Per-item `<!--bf-loop-i-->` / `<!--bf-loop-i:KEY-->` start marker emitted
4403
+ * inside a `{{range}}` body. Multi-root Fragment items (#1212) get the bare
4404
+ * anchor; whole-item conditional items (#1665) get the key-bearing anchor so
4405
+ * the client's `mapArrayAnchored` can hydrate items that render no element.
4406
+ */
4407
+ private loopItemMarker(loop: { bodyIsMultiRoot?: boolean; bodyIsItemConditional?: boolean; key?: string | null }): string {
4408
+ if (loop.bodyIsMultiRoot) return `{{bfComment "bf-loop-i"}}`
4409
+ if (loop.bodyIsItemConditional && loop.key) {
4410
+ // `bfComment` prepends `bf-`, so `printf "loop-i:%v"` yields
4411
+ // `<!--bf-loop-i:KEY-->`. The key expression resolves against the
4412
+ // current range item (`.` context), matching `data-key`'s emission.
4413
+ return `{{bfComment (printf "loop-i:%v" ${this.convertExpressionToGo(loop.key)})}}`
4414
+ }
4415
+ return ''
4416
+ }
4417
+
4032
4418
  /**
4033
4419
  * Find the first component child in a list of nodes
4034
4420
  */