@barefootjs/go-template 0.17.1 → 0.18.1

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.
@@ -49,6 +49,35 @@ export function capitalizeFieldName(name: string): string {
49
49
  return name.charAt(0).toUpperCase() + name.slice(1)
50
50
  }
51
51
 
52
+ /**
53
+ * Resolve ANY source property key — identifier or not (`data-priority`,
54
+ * `aria-label`, a numeric key) — to a valid Go struct field name. Splits on
55
+ * runs of characters that are invalid in a Go identifier (underscores are
56
+ * VALID and preserved, so a snake_case key round-trips to the exact same
57
+ * name `capitalizeFieldName` alone has always produced — `foo_bar` →
58
+ * `Foo_bar`, never `FooBar`; renaming it would break both existing member
59
+ * emission and consumers' hand-written constructors against generated
60
+ * types) and PascalCases each segment through `capitalizeFieldName`, so a
61
+ * hyphenated key gets a real field instead of being silently dropped
62
+ * (`data-priority` → `DataPriority`). A result that would start with a
63
+ * digit (numeric key `0`) is prefixed with `Field` (`Field0`), and a key
64
+ * with no usable characters at all falls back to `Field` — both keep the
65
+ * emitted struct compiling (not expected from real TS property names, but
66
+ * keeps the function total).
67
+ *
68
+ * Single source of truth for the source-key → Go-name mapping: used for
69
+ * struct-field generation (#2087 Phase B — `structFieldsFor`), inline-map
70
+ * baking (`bakeInlineObjectAsGoMap`), and the `member()` dot-access
71
+ * emitter, so the baked side and the accessor side can never disagree
72
+ * (PR #2089 review).
73
+ */
74
+ export function goFieldNameForKey(key: string): string {
75
+ const parts = key.split(/[^A-Za-z0-9_]+/).filter(Boolean)
76
+ if (parts.length === 0) return 'Field'
77
+ const name = parts.map(capitalizeFieldName).join('')
78
+ return /^[0-9]/.test(name) ? `Field${name}` : name
79
+ }
80
+
52
81
  /**
53
82
  * Convert a slot ID (e.g., 's6') to a Go struct field suffix (e.g., 'Slot6').
54
83
  * Keeps field names human-readable regardless of the internal slot ID format.
@@ -4,7 +4,11 @@
4
4
  * Free functions over a {@link GoEmitContext}. They resolve a prop/signal/const's
5
5
  * type (`TypeInfo`, a raw type string, or — as a last resort — an inferred shape
6
6
  * from a literal value) into the Go type used for its struct field. They read
7
- * only `state.localTypeNames`; `inferTypeFromValue` is fully pure.
7
+ * `state.localStructFields` / `state.localTypeAliases` (an ACTUAL Go-backed
8
+ * local type — a generated struct or a string-union alias) rather than the
9
+ * broader `state.localTypeNames` (every type definition, including a tuple
10
+ * alias no struct was ever emitted for — #2087); `inferTypeFromValue` is fully
11
+ * pure.
8
12
  */
9
13
 
10
14
  import type { TypeInfo } from '@barefootjs/jsx'
@@ -42,7 +46,19 @@ export function typeInfoToGo(
42
46
  case 'object':
43
47
  return 'map[string]interface{}'
44
48
  case 'interface':
45
- if (typeInfo.raw && ctx.state.localTypeNames.has(typeInfo.raw)) {
49
+ // Gate on an ACTUAL backing (a generated struct — `localStructFields` —
50
+ // or a string-union alias — `localTypeAliases`, which emits `type X =
51
+ // string`), not mere presence in `localTypeNames`: the latter registers
52
+ // EVERY type definition unconditionally (#2087), including a tuple alias
53
+ // (`type Row = readonly [string, string]`) that `typeDefinitionToGo`
54
+ // can't turn into a struct (no object properties) and so never actually
55
+ // emits. Returning the bare name for one of those would reference an
56
+ // undeclared Go type (`[]Row`) and fail to compile — fall through to the
57
+ // generic-array/interface{} handling below instead, so a tuple-typed
58
+ // signal bakes as `[]interface{}` (each item itself an `interface{}`
59
+ // holding a `[]interface{}`) and the destructure `index`/`bf_slice`
60
+ // lowering still works via reflection regardless of the static type.
61
+ if (typeInfo.raw && (ctx.state.localStructFields.has(typeInfo.raw) || ctx.state.localTypeAliases.has(typeInfo.raw))) {
46
62
  return typeInfo.raw
47
63
  }
48
64
  // Resolve a raw type string pattern (e.g. `Array<Todo>`).
@@ -76,7 +92,10 @@ export function tsTypeStringToGo(ctx: GoEmitContext, tsType: string): string {
76
92
  }
77
93
  const arrayMatch = t.match(/^Array<(.+)>$/)
78
94
  if (arrayMatch) return `[]${tsTypeStringToGo(ctx, arrayMatch[1])}`
79
- if (ctx.state.localTypeNames.has(t)) return t
95
+ // Same backing gate as `typeInfoToGo`'s 'interface' case above — an
96
+ // unbacked local type name (a tuple alias with no struct fields) must not
97
+ // be returned bare, or the generated code references an undeclared type.
98
+ if (ctx.state.localStructFields.has(t) || ctx.state.localTypeAliases.has(t)) return t
80
99
  return 'interface{}'
81
100
  }
82
101
 
@@ -3,20 +3,77 @@
3
3
  * for baking a signal's inline initial value into the SSR data context.
4
4
  *
5
5
  * Covers scalar literals, a unary-minus number, arrays of those, and object
6
- * literals baked against a concrete local struct.
6
+ * literals baked against a concrete local struct — including, per #2087, a
7
+ * struct property whose OWN value is a nested array or object literal
8
+ * (`{ id, cells: ['a', 'b'] }`, `{ id, user: { name: 'Ada' } }`): the struct's
9
+ * declared property TYPE (looked up from `ctx.state.currentTypeDefinitions`)
10
+ * threads through so a typed nested array bakes via the normal array branch
11
+ * below, and a nested INLINE object (one with no named Go struct —
12
+ * `typeInfoToGo`'s `'object'` case always falls back to
13
+ * `map[string]interface{}`) bakes as a capitalized-key Go map literal instead
14
+ * — the same convention `test-render.ts`'s harness-prop baking already uses
15
+ * for object elements, since `html/template`'s map field access is an exact
16
+ * case-sensitive `MapIndex`.
7
17
  *
8
18
  * Contract is **null-means-defer**: returns null for anything not reproduced
9
19
  * exactly — an object whose target type isn't a known struct, a key the struct
10
- * doesn't declare, a nested object/array property value, an empty array, an
11
- * identifier/call, or a numeric literal missing its `raw` token. The caller
12
- * then keeps `nil`.
20
+ * doesn't declare, an empty array, an identifier/call, or a numeric literal
21
+ * missing its `raw` token. The caller then keeps `nil`.
13
22
  */
14
23
 
15
- import type { ParsedExpr, TypeInfo } from '@barefootjs/jsx'
24
+ import type { ParsedExpr, TypeDefinition, TypeInfo } from '@barefootjs/jsx'
16
25
 
17
26
  import type { GoEmitContext } from '../emit-context.ts'
27
+ import { goFieldNameForKey } from '../lib/go-naming.ts'
18
28
  import { typeInfoToGo } from '../type/type-codegen.ts'
19
29
 
30
+ /**
31
+ * Look up a struct property's declared `TypeInfo` by source key, from the
32
+ * user's own `TypeDefinition` (not a synthesized struct — those only arise
33
+ * from an UNTYPED literal, which never carries nested object/array elements
34
+ * because `synthesizeStructFromSignal` requires every property value to be a
35
+ * scalar literal). Returns undefined when the struct name isn't a user type
36
+ * or the key isn't declared — callers treat that as "nested type unknown"
37
+ * and fall back to the generic/inline lowering.
38
+ */
39
+ function structPropertyType(ctx: GoEmitContext, structGoType: string, key: string): TypeInfo | undefined {
40
+ const td = ctx.state.currentTypeDefinitions.find((t: TypeDefinition) => t.name === structGoType)
41
+ return td?.properties?.find(p => p.name === key)?.type
42
+ }
43
+
44
+ /**
45
+ * Bake a nested INLINE object-literal property value — one whose declared
46
+ * type has no named Go struct (`user: { name: string }` lowers its field to
47
+ * `map[string]interface{}`, not a struct) — as a Go map literal with
48
+ * CAPITALIZED keys, recursing for further nesting. `html/template`'s dot
49
+ * access on a map value does an exact-string `MapIndex`, so a template
50
+ * action like `.User.Name` only resolves when the baked key is literally
51
+ * `"Name"`, not the source-cased `"name"` — mirrors the same convention
52
+ * `test-render.ts`'s `goArrayLiteralFromArray` uses for object elements
53
+ * passed as harness props.
54
+ *
55
+ * Keys are sanitized with `goFieldNameForKey` — the SAME function the
56
+ * accessor side (`buildSegmentAccessor` / `structFieldsFor`) uses — so a
57
+ * non-identifier key (`'data-x'`) bakes as `"DataX"` and dot access
58
+ * `.Meta.DataX` finds it. `capitalizeFieldName` alone would bake
59
+ * `"Data-x"`, a key no emitted accessor can ever reach (Copilot review,
60
+ * PR #2089).
61
+ */
62
+ function bakeInlineObjectAsGoMap(ctx: GoEmitContext, expr: ParsedExpr): string | null {
63
+ if (expr.kind !== 'object-literal') return null
64
+ const entries: string[] = []
65
+ for (const prop of expr.properties) {
66
+ if (prop.shorthand) return null
67
+ const go =
68
+ prop.value.kind === 'object-literal'
69
+ ? bakeInlineObjectAsGoMap(ctx, prop.value)
70
+ : parsedLiteralToGo(ctx, prop.value)
71
+ if (go === null) return null
72
+ entries.push(`${JSON.stringify(goFieldNameForKey(prop.key))}: ${go}`)
73
+ }
74
+ return `map[string]interface{}{${entries.join(', ')}}`
75
+ }
76
+
20
77
  export function parsedLiteralToGo(
21
78
  ctx: GoEmitContext,
22
79
  expr: ParsedExpr,
@@ -79,10 +136,26 @@ export function parsedLiteralToGo(
79
136
  // and defers the whole object.
80
137
  const goField = structFields.get(prop.key)
81
138
  if (!goField) return null
82
- // Nested object/array property values aren't baked here (field types
83
- // untracked) defer.
84
- if (prop.value.kind === 'object-literal' || prop.value.kind === 'array-literal') return null
85
- const go = parsedLiteralToGo(ctx, prop.value)
139
+ const propType = structPropertyType(ctx, goType, prop.key)
140
+ let go: string | null
141
+ if (prop.value.kind === 'array-literal') {
142
+ // A nested array property (`cells: readonly string[]`, #2087):
143
+ // thread the struct's own declared property type through so the
144
+ // array branch below bakes a properly-typed slice (`[]string{…}`)
145
+ // instead of deferring.
146
+ go = parsedLiteralToGo(ctx, prop.value, propType)
147
+ } else if (prop.value.kind === 'object-literal') {
148
+ // A nested object property (`user: { name: string }`, #2087): bake
149
+ // against a named struct if the declared type resolves to one,
150
+ // else fall back to the capitalized-key inline-map convention.
151
+ const nestedGoType = propType ? typeInfoToGo(ctx, propType) : undefined
152
+ go =
153
+ nestedGoType && ctx.state.localStructFields.has(nestedGoType)
154
+ ? parsedLiteralToGo(ctx, prop.value, propType)
155
+ : bakeInlineObjectAsGoMap(ctx, prop.value)
156
+ } else {
157
+ go = parsedLiteralToGo(ctx, prop.value)
158
+ }
86
159
  if (go === null) return null
87
160
  entries.push(`${goField}: ${go}`)
88
161
  }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Per-fixture build-time contracts for shapes the Go template adapter
3
+ * intentionally refuses to lower. Lives here (not on the shared fixtures)
4
+ * so adding a new adapter doesn't require touching any cross-adapter
5
+ * file — every adapter declares its own refusal set against the
6
+ * canonical fixture corpus. Consumed by this package's own conformance
7
+ * test (as `expectedDiagnostics`) and by `bf compat` (issue-URL
8
+ * attribution).
9
+ */
10
+
11
+ import type { ConformancePins } from '@barefootjs/jsx'
12
+
13
+ export const conformancePins: ConformancePins = {
14
+ // `style-object-dynamic` / `style-3-signals` no longer pinned — a
15
+ // `style={{ … }}` object literal now lowers to a CSS string with dynamic
16
+ // values interpolated (`background-color:{{.Color}};padding:8px`) via
17
+ // `tryLowerStyleObject` (#1322).
18
+ // Sibling-imported child component inside a loop body: the adapter
19
+ // emits `{{template "X" .}}` which only resolves if the user has
20
+ // compiled the sibling file and registered the template on the
21
+ // same instance. BF103 makes that requirement loud. (The barefoot
22
+ // CLI passes `siblingTemplatesRegistered: true` so CLI builds
23
+ // suppress the diagnostic — see compileJSX `siblingTemplatesRegistered`.)
24
+ 'static-array-children': [{ code: 'BF103', severity: 'error' }],
25
+ // TodoApp / TodoAppSSR import `TodoItem` from a sibling file and
26
+ // call it inside a keyed `.map`. Same BF103 surface as
27
+ // `static-array-children` above — pinned at adapter level so the
28
+ // shared-component corpus stays adapter-neutral.
29
+ 'todo-app': [{ code: 'BF103', severity: 'error' }],
30
+ 'todo-app-ssr': [{ code: 'BF103', severity: 'error' }],
31
+ // `([emoji, users]) => ...` is an array-index tuple destructure — #2087
32
+ // Phase B's widened gate now admits this shape (`destructure-array-index-in-map`
33
+ // exercises the same `segments`-based lowering). The remaining refusal here
34
+ // is orthogonal: `entries` is a function-scope local const with a computed
35
+ // initializer (`Object.entries(props.reactions ?? {}).filter(...)`) that
36
+ // the Go adapter has no binding for (only a STRING-derived local resolves
37
+ // to a generated struct field, via `computeDerivedConstFields`/`isStringExpr`)
38
+ // — left unchecked this would silently execute-time-fail instead of
39
+ // building loud, so `renderLoop` raises BF101 for a bare-identifier loop
40
+ // array bound to such a const. See the `renderLoop` comment at the check
41
+ // site; Jinja / ERB apply the same narrow check for the same reason.
42
+ 'static-array-from-props': [{ code: 'BF101', severity: 'error' }],
43
+ // Same computed-const array as above, plus the pre-existing BF103 (a
44
+ // sibling-imported child component used inside the loop body) — the
45
+ // destructure param itself no longer contributes a diagnostic.
46
+ 'static-array-from-props-with-component': [
47
+ { code: 'BF103', severity: 'error' },
48
+ { code: 'BF101', severity: 'error' },
49
+ ],
50
+ // (`style-3-signals` graduated alongside `style-object-dynamic` — see note
51
+ // above; the `style={{ … }}` object now lowers to a CSS string.)
52
+ // (`tagged-template-classname` graduated by #2092 — the tag resolves
53
+ // through the interleave-tag catalogue and desugars to an untagged
54
+ // template literal, so it lowers like any other className template.)
55
+ // #2038: a filter predicate whose body contains a NESTED callback call
56
+ // (`t => !picked().some(p => …)` / `t => picked().find(p => …)`). The
57
+ // evaluator refuses nested arrows and `renderFilterExpr` has no faithful
58
+ // Go form for the inner call (its `call` arm used to silently drop the
59
+ // arrow argument and render only the callee) — the compiler is loud
60
+ // instead of lossy. The `/* @client */` twin
61
+ // (`filter-nested-callback-predicate-client`) has no pin here: it must
62
+ // render clean on every adapter, which asserts the suppression contract.
63
+ // https://github.com/piconic-ai/barefootjs/issues/2038
64
+ 'filter-nested-callback-predicate': [
65
+ { code: 'BF101', severity: 'error', issue: 'https://github.com/piconic-ai/barefootjs/issues/2038' },
66
+ ],
67
+ 'filter-nested-find-predicate': [
68
+ { code: 'BF101', severity: 'error', issue: 'https://github.com/piconic-ai/barefootjs/issues/2038' },
69
+ ],
70
+ // #1310 / #2087: rest destructure in .map() callback. `isLowerableLoopDestructure`
71
+ // now admits every shape this fixture family exercises — each fixed/rest
72
+ // binding resolves via `buildSegmentAccessor`/`buildDestructureBindingMap`
73
+ // against a synthetic `$__bf_item0` range var (the reserved `__bf_item`
74
+ // name, depth-suffixed): a plain field → `$__bf_item0.Id`, an array-index
75
+ // step → `(index $__bf_item0 0)`, an array-rest → `(bf_slice $__bf_item0
76
+ // 1)` (composes under `.length` via `member()`'s generic `len <obj>` arm),
77
+ // and an object-rest member read (`rest.flag`) → `$__bf_item0.Flag`. A
78
+ // `{...rest}` SPREAD (`rest-destructure-object-spread-in-map`) routes
79
+ // through the new `bf_omit` runtime helper instead, so the residual omits
80
+ // exactly the sibling keys the pattern destructured out. No fixture in
81
+ // this family is pinned anymore — all six render to real Go / byte-exact
82
+ // HTML (`rest-destructure-object-in-map`, `rest-destructure-object-spread-in-map`,
83
+ // `rest-destructure-array-in-map`, `rest-destructure-nested-in-map`,
84
+ // `destructure-array-index-in-map`, `destructure-nested-object-in-map`).
85
+ // #1443: `[a, b].filter(Boolean).join(' ')` (registry Slot) now
86
+ // lowers to `bf_join (bf_filter_truthy (bf_arr ...)) " "`. No
87
+ // BF101 expected — pinned positively by the
88
+ // `branch-local-filter-join-go` template-output test below.
89
+ //
90
+ // #1448 Tier A — JS Array / String methods that the Go template
91
+ // adapter hasn't lowered yet. Each row drops once the
92
+ // corresponding method PR lands. Hono / CSR pass these out of
93
+ // the box (they evaluate JS at runtime) so the pin only applies
94
+ // here.
95
+ //
96
+ // `array-includes` / `string-includes` no longer pinned — both
97
+ // shapes lower via the shared `array-method` IR + the polymorphic
98
+ // `bf_includes` runtime helper that dispatches on
99
+ // `reflect.Kind()` (slice/array → element search, string →
100
+ // substring search). The condition-position lowering picks up
101
+ // the same emit through the `array-method` arm of
102
+ // `renderConditionExpr` (#1448 Tier A first PR).
103
+ //
104
+ // Remaining fixtures land at expression position and surface BF101
105
+ // via `convertExpressionToGo`. Distinct codes for the two paths is
106
+ // pre-existing adapter behaviour, not something this catalog
107
+ // should paper over — pinned literally here.
108
+ // `array-indexOf` / `array-lastIndexOf` no longer pinned —
109
+ // value-equality `bf_index_of` / `bf_last_index_of` Go runtime
110
+ // helpers handle the shape (#1448 Tier A second PR).
111
+ // `array-at` no longer pinned — the pre-existing `bf_at` runtime
112
+ // helper now lowers `.at(i)` (#1448 Tier A third PR).
113
+ // `array-concat` no longer pinned — the new `bf_concat` runtime
114
+ // helper merges two arrays into a single `[]any` (#1448 Tier A
115
+ // fourth PR).
116
+ // `array-slice` no longer pinned — the new `bf_slice` runtime
117
+ // helper carves out a sub-range with JS-compat clamping
118
+ // (#1448 Tier A fifth PR).
119
+ // `array-reverse` / `array-toReversed` no longer pinned —
120
+ // both share the `bf_reverse` helper since SSR templates
121
+ // render a snapshot and the JS mutate-vs-new distinction has
122
+ // no template-level meaning (#1448 Tier A sixth PR).
123
+ // `string-toLowerCase` / `string-toUpperCase` no longer pinned —
124
+ // pre-existing `bf_lower` / `bf_upper` runtime helpers wire to
125
+ // the JS method names at the adapter layer (#1448 Tier A
126
+ // seventh + eighth PRs).
127
+ // `string-trim` no longer pinned — pre-existing `bf_trim`
128
+ // (wraps `strings.TrimSpace`) handles the strip (#1448 Tier A
129
+ // ninth PR, closing out Tier A).
130
+ // #2073 follow-up: a function-reference `.map(format)` callback has no
131
+ // arrow body to serialize — not a CALLBACK_METHODS shape — so the
132
+ // UNSUPPORTED_METHODS gate refuses it with BF101 rather than emitting
133
+ // a broken template.
134
+ 'array-map-function-reference': [{ code: 'BF101', severity: 'error' }],
135
+ }
package/src/index.ts CHANGED
@@ -6,3 +6,4 @@
6
6
 
7
7
  export { GoTemplateAdapter, goTemplateAdapter } from './adapter/index.ts'
8
8
  export type { GoTemplateAdapterOptions } from './adapter/index.ts'
9
+ export { conformancePins } from './conformance-pins.ts'