@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.
- package/dist/adapter/go-template-adapter.d.ts +116 -0
- package/dist/adapter/go-template-adapter.d.ts.map +1 -1
- package/dist/adapter/index.js +243 -33
- package/dist/build.js +243 -33
- package/dist/index.js +243 -33
- package/package.json +2 -2
- package/src/__tests__/go-template-adapter.test.ts +532 -0
- package/src/adapter/go-template-adapter.ts +427 -41
|
@@ -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:
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
//
|
|
729
|
-
if (
|
|
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
|
|
736
|
-
if (
|
|
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
|
|
739
|
-
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
1741
|
-
|
|
1742
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
*/
|