@barefootjs/go-template 0.15.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/adapter/analysis/component-tree.d.ts +23 -0
  2. package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
  3. package/dist/adapter/emit-context.d.ts +53 -0
  4. package/dist/adapter/emit-context.d.ts.map +1 -0
  5. package/dist/adapter/expr/helper-inline.d.ts +31 -0
  6. package/dist/adapter/expr/helper-inline.d.ts.map +1 -0
  7. package/dist/adapter/expr/url-builder.d.ts +23 -0
  8. package/dist/adapter/expr/url-builder.d.ts.map +1 -0
  9. package/dist/adapter/go-template-adapter.d.ts +291 -1070
  10. package/dist/adapter/go-template-adapter.d.ts.map +1 -1
  11. package/dist/adapter/index.js +2859 -2618
  12. package/dist/adapter/lib/compile-state.d.ts +116 -0
  13. package/dist/adapter/lib/compile-state.d.ts.map +1 -0
  14. package/dist/adapter/lib/constants.d.ts +11 -0
  15. package/dist/adapter/lib/constants.d.ts.map +1 -0
  16. package/dist/adapter/lib/go-emit.d.ts +117 -0
  17. package/dist/adapter/lib/go-emit.d.ts.map +1 -0
  18. package/dist/adapter/lib/go-naming.d.ts +39 -0
  19. package/dist/adapter/lib/go-naming.d.ts.map +1 -0
  20. package/dist/adapter/lib/ir-scope.d.ts +13 -0
  21. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  22. package/dist/adapter/lib/types.d.ts +165 -0
  23. package/dist/adapter/lib/types.d.ts.map +1 -0
  24. package/dist/adapter/memo/ctor-lowering.d.ts +39 -0
  25. package/dist/adapter/memo/ctor-lowering.d.ts.map +1 -0
  26. package/dist/adapter/memo/memo-compute.d.ts +124 -0
  27. package/dist/adapter/memo/memo-compute.d.ts.map +1 -0
  28. package/dist/adapter/memo/memo-type.d.ts +38 -0
  29. package/dist/adapter/memo/memo-type.d.ts.map +1 -0
  30. package/dist/adapter/memo/memo-value.d.ts +64 -0
  31. package/dist/adapter/memo/memo-value.d.ts.map +1 -0
  32. package/dist/adapter/memo/template-interp.d.ts +43 -0
  33. package/dist/adapter/memo/template-interp.d.ts.map +1 -0
  34. package/dist/adapter/props/prop-types.d.ts +31 -0
  35. package/dist/adapter/props/prop-types.d.ts.map +1 -0
  36. package/dist/adapter/spread/spread-codegen.d.ts +40 -0
  37. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  38. package/dist/adapter/type/type-codegen.d.ts +25 -0
  39. package/dist/adapter/type/type-codegen.d.ts.map +1 -0
  40. package/dist/adapter/value/parsed-literal-to-go.d.ts +17 -0
  41. package/dist/adapter/value/parsed-literal-to-go.d.ts.map +1 -0
  42. package/dist/adapter/value/value-lowering.d.ts +46 -0
  43. package/dist/adapter/value/value-lowering.d.ts.map +1 -0
  44. package/dist/build.js +2857 -2616
  45. package/dist/index.js +2859 -2618
  46. package/dist/test-render.d.ts.map +1 -1
  47. package/package.json +3 -3
  48. package/src/__tests__/derived-state-memo.test.ts +155 -84
  49. package/src/__tests__/go-template-adapter.test.ts +211 -60
  50. package/src/__tests__/lowering-plugin.test.ts +109 -0
  51. package/src/__tests__/query-href.test.ts +179 -0
  52. package/src/adapter/analysis/component-tree.ts +174 -0
  53. package/src/adapter/emit-context.ts +59 -0
  54. package/src/adapter/expr/helper-inline.ts +274 -0
  55. package/src/adapter/expr/url-builder.ts +123 -0
  56. package/src/adapter/go-template-adapter.ts +2100 -5316
  57. package/src/adapter/lib/compile-state.ts +150 -0
  58. package/src/adapter/lib/constants.ts +24 -0
  59. package/src/adapter/lib/go-emit.ts +289 -0
  60. package/src/adapter/lib/go-naming.ts +84 -0
  61. package/src/adapter/lib/ir-scope.ts +31 -0
  62. package/src/adapter/lib/types.ts +182 -0
  63. package/src/adapter/memo/ctor-lowering.ts +267 -0
  64. package/src/adapter/memo/memo-compute.ts +451 -0
  65. package/src/adapter/memo/memo-type.ts +74 -0
  66. package/src/adapter/memo/memo-value.ts +197 -0
  67. package/src/adapter/memo/template-interp.ts +246 -0
  68. package/src/adapter/props/prop-types.ts +84 -0
  69. package/src/adapter/spread/spread-codegen.ts +458 -0
  70. package/src/adapter/type/type-codegen.ts +95 -0
  71. package/src/adapter/value/parsed-literal-to-go.ts +94 -0
  72. package/src/adapter/value/value-lowering.ts +162 -0
  73. package/src/test-render.ts +2 -14
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Memo value computation for block-body / object-returning memos.
3
+ *
4
+ * Free functions over a {@link GoEmitContext}:
5
+ * - `resolveBlockBodyMemoModuleConst` recognises a guard-and-return-module-
6
+ * const memo and reports the constant, reading `state.localConstants`.
7
+ * - `computeObjectMemoInitialValue` lowers a `searchParams()`-derived
8
+ * object-returning memo to a Go `map[string]interface{}` literal, reading
9
+ * the analyzer-carried `parsedBlock` and `state.searchParamsLocals` and
10
+ * delegating value lowering to `lowerCtorExpr`.
11
+ */
12
+
13
+ import { foldBlockToExpr } from '@barefootjs/jsx'
14
+ import type { ParsedExpr, ParsedStatement, TypeInfo } from '@barefootjs/jsx'
15
+
16
+ import type { GoEmitContext } from '../emit-context.ts'
17
+ import type { CtorLowerEnv } from '../lib/types.ts'
18
+ import { capitalizeFieldName } from '../lib/go-naming.ts'
19
+ import { lowerCtorExpr } from './ctor-lowering.ts'
20
+
21
+ type GuardConstResult = {
22
+ constName: string
23
+ constValue: string | undefined
24
+ constType: TypeInfo | undefined
25
+ constParsed: ParsedExpr | undefined
26
+ }
27
+
28
+ const FALSY_INITS = new Set(['null', "''", '""', '0', 'false'])
29
+
30
+ /** Look the returned const name up as a module-scope constant and package it. */
31
+ function packageModuleConst(ctx: GoEmitContext, name: string): GuardConstResult | null {
32
+ const constant = ctx.state.localConstants.find(
33
+ c => c.name === name && c.origin?.scope === 'module',
34
+ )
35
+ if (!constant) return null
36
+ return {
37
+ constName: constant.name,
38
+ constValue: constant.value,
39
+ constType: constant.type ?? undefined,
40
+ constParsed: constant.parsed,
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Recognise a block-body memo whose SSR path returns a module-const array when
46
+ * the guard signal starts falsy:
47
+ * `() => { const k = getter(); if (!k) return MODULE_CONST; … }`
48
+ *
49
+ * Primary path: the block is normalized to a single expression upstream (#2040,
50
+ * `foldBlockToExpr` in the analyzer), so this reads the folded `MemoInfo.parsed`
51
+ * conditional `!getter() ? MODULE_CONST : <derived>`. When the guard signal's
52
+ * initial value is falsy, `!guard` is `true`, so the `consequent` is the const
53
+ * rendered at SSR.
54
+ *
55
+ * Fallback path: a block the fold REFUSES (e.g. an impure local binding that
56
+ * isn't used exactly once per path sits alongside the guard) leaves
57
+ * `MemoInfo.parsed` unset, but the guard prefix is still present in the tolerant
58
+ * `MemoInfo.parsedBlock`. Scan that prefix so the SSR const bake matches `main`
59
+ * exactly — the bake depends only on the guard, not on the unfoldable tail.
60
+ *
61
+ * @returns the constant's name, value, inferred type and parsed tree, or null
62
+ */
63
+ export function resolveBlockBodyMemoModuleConst(
64
+ ctx: GoEmitContext,
65
+ memo: { parsed?: ParsedExpr; parsedBlock?: ParsedStatement[] },
66
+ signals: { getter: string; initialValue: string }[],
67
+ ): GuardConstResult | null {
68
+ return (
69
+ fromFoldedConditional(ctx, memo.parsed, signals) ??
70
+ fromStatementPrefix(ctx, memo.parsedBlock, signals)
71
+ )
72
+ }
73
+
74
+ /** Primary: read the folded `!getter() ? MODULE_CONST : <derived>` conditional. */
75
+ function fromFoldedConditional(
76
+ ctx: GoEmitContext,
77
+ parsed: ParsedExpr | undefined,
78
+ signals: { getter: string; initialValue: string }[],
79
+ ): GuardConstResult | null {
80
+ if (!parsed || parsed.kind !== 'conditional') return null
81
+ const test = parsed.test
82
+ if (test.kind !== 'unary' || test.op !== '!') return null
83
+ const guardCall = test.argument
84
+ if (
85
+ guardCall.kind !== 'call' ||
86
+ guardCall.callee.kind !== 'identifier' ||
87
+ guardCall.args.length !== 0
88
+ ) {
89
+ return null
90
+ }
91
+ const guardSignal = signals.find(sg => sg.getter === (guardCall.callee as { name: string }).name)
92
+ if (!guardSignal || !FALSY_INITS.has(guardSignal.initialValue.trim())) return null
93
+ // `!falsy` is true → the consequent is the returned module const.
94
+ if (parsed.consequent.kind !== 'identifier') return null
95
+ return packageModuleConst(ctx, parsed.consequent.name)
96
+ }
97
+
98
+ /**
99
+ * Fallback for blocks the fold refused: scan the tolerant statements for the
100
+ * guard prefix `const k = getter(); if (!k) return MODULE_CONST` (later
101
+ * statements are ignored — they don't affect the guard-falsy SSR value).
102
+ */
103
+ function fromStatementPrefix(
104
+ ctx: GoEmitContext,
105
+ parsedBlock: ParsedStatement[] | undefined,
106
+ signals: { getter: string; initialValue: string }[],
107
+ ): GuardConstResult | null {
108
+ if (!parsedBlock) return null
109
+ const varToSignal = new Map<string, string>()
110
+ let guardSignalGetter: string | null = null
111
+ let returnedConst: string | null = null
112
+
113
+ for (const s of parsedBlock) {
114
+ if (s.kind === 'var-decl' && s.init.kind === 'call' && s.init.callee.kind === 'identifier') {
115
+ const callee = s.init.callee.name
116
+ if (signals.some(sg => sg.getter === callee)) varToSignal.set(s.name, callee)
117
+ }
118
+ if (
119
+ s.kind === 'if' &&
120
+ s.condition.kind === 'unary' &&
121
+ s.condition.op === '!' &&
122
+ s.condition.argument.kind === 'identifier'
123
+ ) {
124
+ const signalGetter = varToSignal.get(s.condition.argument.name)
125
+ if (!signalGetter) continue
126
+ for (const rs of s.consequent) {
127
+ if (rs.kind === 'return' && rs.value.kind === 'identifier') {
128
+ guardSignalGetter = signalGetter
129
+ returnedConst = rs.value.name
130
+ }
131
+ }
132
+ }
133
+ if (guardSignalGetter && returnedConst) break
134
+ }
135
+
136
+ if (!guardSignalGetter || !returnedConst) return null
137
+ const guardSignal = signals.find(sg => sg.getter === guardSignalGetter)
138
+ if (!guardSignal || !FALSY_INITS.has(guardSignal.initialValue.trim())) return null
139
+ return packageModuleConst(ctx, returnedConst)
140
+ }
141
+
142
+ /**
143
+ * Compute the SSR value of an object-returning block-body memo derived from
144
+ * `searchParams()`:
145
+ * () => { const sp = searchParams(); return { sort: asSortKey(sp.get('sort')),
146
+ * tag: sp.get('tag') ?? '' } }
147
+ * Emits a Go `map[string]interface{}{ "Sort": …, "Tag": … }` whose values are
148
+ * lowered from the request query (see `lowerCtorExpr`). Keys are capitalized to
149
+ * match the template's `.Params.<Field>` map access.
150
+ *
151
+ * @returns the Go map literal, or null for any shape the lowerer can't
152
+ * represent (→ nil-map fallback)
153
+ */
154
+ export function computeObjectMemoInitialValue(
155
+ ctx: GoEmitContext,
156
+ memo: { parsed?: ParsedExpr; parsedBlock?: ParsedStatement[]; parsedBlockComplete?: boolean },
157
+ ): string | null {
158
+ // Normalize the block to a single object-literal expression (#2040). The
159
+ // analyzer's fold treats only signal/memo reads as pure, so a
160
+ // `const sp = searchParams()` (read twice) isn't folded there; redo the fold
161
+ // here with `searchParams` added to the purity oracle — it is an idempotent
162
+ // request-query read, so inlining `sp` at each `sp.get('k')` site is sound.
163
+ // The folded object's values become `searchParams().get('k')` (sp inlined),
164
+ // lowered by `lowerCtorExpr`. A block that doesn't fold to an object literal
165
+ // (extra control flow, an impure non-searchParams binding) yields null → the
166
+ // caller's nil fallback, exactly as the previous statement walk did.
167
+ let retObj = memo.parsed?.kind === 'object-literal' ? memo.parsed : null
168
+ if (!retObj) {
169
+ if (!memo.parsedBlock || !memo.parsedBlockComplete) return null
170
+ const folded = foldBlockToExpr(memo.parsedBlock, {
171
+ pureCallNames: ctx.state.searchParamsLocals,
172
+ })
173
+ if (!folded.ok || folded.expr.kind !== 'object-literal') return null
174
+ retObj = folded.expr
175
+ }
176
+ if (retObj.properties.length === 0) return null
177
+
178
+ // `sp` is inlined to `searchParams()` in the folded values, so no
179
+ // local→searchParams var env is needed; `lowerCtorExpr` recognises the
180
+ // `searchParams().get('k')` receiver shape directly.
181
+ const env: CtorLowerEnv = { searchParamsVars: new Set(), params: new Map() }
182
+ const entries: string[] = []
183
+ for (const prop of retObj.properties) {
184
+ // Bail on a shorthand property (`return { tag }`): its value is a bare
185
+ // identifier whose name need not match a `.Params.<Field>` accessor.
186
+ if (prop.shorthand) return null
187
+ // The key must be identifier- or string-named (a numeric key has no
188
+ // matching `.Params.<Field>` accessor).
189
+ if (prop.keyKind !== undefined && prop.keyKind !== 'identifier' && prop.keyKind !== 'string') {
190
+ return null
191
+ }
192
+ const go = lowerCtorExpr(ctx, prop.value, env)
193
+ if (go === null) return null
194
+ entries.push(`"${capitalizeFieldName(prop.key)}": ${go}`)
195
+ }
196
+ return `map[string]interface{}{\n\t\t${entries.join(',\n\t\t')},\n\t}`
197
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Template-literal memo lowering.
3
+ *
4
+ * Free functions over a {@link GoEmitContext} that compute the SSR initial
5
+ * value of a template-literal memo (`() => `${a} ${props.x ?? ''} grid``) as a
6
+ * Go `string` expression. Each quasi becomes a Go string literal; each
7
+ * interpolation resolves to a module string const, a `Record`-index access, or
8
+ * a `props.<name>` field read. Sets `state.usesFmt` when a `Record`-index
9
+ * interpolation emits `fmt.Sprint`.
10
+ */
11
+
12
+ import ts from 'typescript'
13
+
14
+ import type { ParsedExpr, ParsedStatement, TypeInfo } from '@barefootjs/jsx'
15
+
16
+ import type { GoEmitContext } from '../emit-context.ts'
17
+ import { escapeGoString } from '../lib/go-emit.ts'
18
+ import { capitalizeFieldName } from '../lib/go-naming.ts'
19
+
20
+ /**
21
+ * Compute the SSR initial value of a template-literal memo as a Go `string`
22
+ * expression, e.g. `() => `${a} ${props.className ?? ''} grid``.
23
+ *
24
+ * Each quasi becomes a Go string literal; each interpolation is resolved:
25
+ * - an identifier naming a module string const → its inlined literal (covers
26
+ * pure-string and `[...].join(' ')` consts);
27
+ * - `props.<name> ?? '<fallback>'` or bare `props.<name>` → `in.<Field>` when
28
+ * `<name>` is a known string-typed prop param (the `?? ''` fallback maps to
29
+ * Go's zero value for an unset string field).
30
+ *
31
+ * @returns the `"a" + in.Field + " grid..."` concatenation, or null when the
32
+ * computation isn't a single template literal or any interpolation isn't
33
+ * representable
34
+ */
35
+ export function computeTemplateLiteralMemoInitialValue(
36
+ ctx: GoEmitContext,
37
+ memo: { parsed?: ParsedExpr; parsedBlock?: ParsedStatement[]; parsedBlockComplete?: boolean },
38
+ propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
39
+ ): string | null {
40
+ // Resolve the template value (a `template-literal`, or a plain string
41
+ // `literal` for a no-substitution `` `plain` `` which folds to one) plus any
42
+ // memo-local `const X = props.Y ?? 'lit'` key bindings.
43
+ const localKeyBindings = new Map<string, { propName: string; defaultLiteral?: string }>()
44
+ let templateValue: ParsedExpr | undefined
45
+ if (memo.parsedBlock) {
46
+ // Block-bodied arrow (the Toggle `classes` memo): collect leading key
47
+ // bindings, then resolve against the single returned template literal. Any
48
+ // statement that isn't a var-decl or the return (an `if`, a loop) is a
49
+ // shape we don't model — bail.
50
+ //
51
+ // `parsedBlock` is tolerant: it OMITS statements it can't represent
52
+ // (`for`/`while`/`switch`), so an incomplete block could look like just
53
+ // `var-decl`s + `return`. Bail when it isn't complete.
54
+ if (!memo.parsedBlockComplete) return null
55
+ for (const s of memo.parsedBlock) {
56
+ if (s.kind === 'var-decl') {
57
+ const binding = parseLocalKeyBinding(ctx, s.init)
58
+ if (binding) localKeyBindings.set(s.name, binding)
59
+ } else if (s.kind === 'return') {
60
+ templateValue = s.value
61
+ } else {
62
+ return null
63
+ }
64
+ }
65
+ if (!templateValue) return null
66
+ } else if (memo.parsed) {
67
+ templateValue = memo.parsed
68
+ } else {
69
+ return null
70
+ }
71
+
72
+ const propNames = new Set(propsParams.map(p => p.name))
73
+ const escGo = (s: string) => `"${escapeGoString(s)}"`
74
+
75
+ // A no-substitution template literal folds to a plain string literal.
76
+ if (templateValue.kind === 'literal' && templateValue.literalType === 'string') {
77
+ return escGo(String(templateValue.value))
78
+ }
79
+ if (templateValue.kind !== 'template-literal') return null
80
+
81
+ const segments: string[] = []
82
+ for (const part of templateValue.parts) {
83
+ if (part.type === 'string') {
84
+ if (part.value) segments.push(escGo(part.value))
85
+ } else {
86
+ const resolved = resolveTemplateInterpolation(ctx, part.expr, propNames, localKeyBindings)
87
+ if (resolved === null) return null
88
+ segments.push(resolved)
89
+ }
90
+ }
91
+ if (segments.length === 0) return '""'
92
+ return segments.join(' + ')
93
+ }
94
+
95
+ /**
96
+ * Resolve one `${expr}` interpolation of a template-literal memo to a Go string
97
+ * expression, or null when unsupported. See
98
+ * `computeTemplateLiteralMemoInitialValue` for the supported shapes.
99
+ */
100
+ function resolveTemplateInterpolation(
101
+ ctx: GoEmitContext,
102
+ node: ParsedExpr,
103
+ propNames: Set<string>,
104
+ localKeyBindings: ReadonlyMap<string, { propName: string; defaultLiteral?: string }>,
105
+ ): string | null {
106
+ // Identifier → module string const inline.
107
+ if (node.kind === 'identifier') {
108
+ return ctx.resolveModuleStringConst(node.name)
109
+ }
110
+
111
+ // `recordConst[key]` → inline indexed Go map (the Toggle `classes` memo's
112
+ // `variantClasses[variant]` / `sizeClasses[size]`). A bare-identifier index
113
+ // parses as `index-access`.
114
+ if (node.kind === 'index-access') {
115
+ return recordIndexInterpolationToGo(ctx, node, propNames, localKeyBindings)
116
+ }
117
+
118
+ // `props.X ?? ''` — string-typed prop with an empty-string fallback.
119
+ if (node.kind === 'logical' && node.op === '??') {
120
+ const right = node.right
121
+ const isEmptyStr = right.kind === 'literal' && right.literalType === 'string' && right.value === ''
122
+ const propName = propsAccessNameFromParsed(ctx, node.left)
123
+ if (propName && propNames.has(propName) && isEmptyStr) {
124
+ // Unset string field is "" in Go — same as `?? ''`.
125
+ return `in.${capitalizeFieldName(propName)}`
126
+ }
127
+ return null
128
+ }
129
+
130
+ // Bare `props.X` (string-typed prop).
131
+ const propName = propsAccessNameFromParsed(ctx, node)
132
+ if (propName && propNames.has(propName)) {
133
+ return `in.${capitalizeFieldName(propName)}`
134
+ }
135
+ return null
136
+ }
137
+
138
+ /**
139
+ * Parse a memo-local `const X = …` initializer (`ParsedExpr`) into a
140
+ * `Record`-index key binding: `props.Y ?? 'lit'` → `{ propName: 'Y',
141
+ * defaultLiteral: 'lit' }`, or bare `props.Y` → `{ propName: 'Y' }`. Returns
142
+ * null for any other shape, so it isn't registered as a key.
143
+ */
144
+ function parseLocalKeyBinding(
145
+ ctx: GoEmitContext,
146
+ node: ParsedExpr,
147
+ ): { propName: string; defaultLiteral?: string } | null {
148
+ if (node.kind === 'logical' && node.op === '??') {
149
+ const propName = propsAccessNameFromParsed(ctx, node.left)
150
+ const right = node.right
151
+ if (propName && right.kind === 'literal' && right.literalType === 'string') {
152
+ return { propName, defaultLiteral: String(right.value) }
153
+ }
154
+ return null
155
+ }
156
+ const propName = propsAccessNameFromParsed(ctx, node)
157
+ if (propName) return { propName }
158
+ return null
159
+ }
160
+
161
+ /**
162
+ * Lower a `recordConst[key]` interpolation to an inline indexed Go map:
163
+ * `map[string]string{…}[fmt.Sprint(in.Field)]` (or `map[string]any` for mixed
164
+ * values). `recordConst` is a module-scope `Record<keys, scalar>` object
165
+ * literal; `key` is a bare prop or a memo-local const bound to `props.X ??
166
+ * 'default'`, whose `'default'` fallback also maps `""` so an unset prop
167
+ * renders the default.
168
+ *
169
+ * @returns the indexed-map expression, or null for any non-record /
170
+ * non-resolvable key
171
+ */
172
+ function recordIndexInterpolationToGo(
173
+ ctx: GoEmitContext,
174
+ node: ParsedExpr & { kind: 'index-access' },
175
+ propNames: Set<string>,
176
+ localKeyBindings: ReadonlyMap<string, { propName: string; defaultLiteral?: string }>,
177
+ ): string | null {
178
+ if (node.object.kind !== 'identifier' || node.index.kind !== 'identifier') return null
179
+ const objName = node.object.name
180
+ const keyName = node.index.name
181
+
182
+ // KEY resolution: a memo-local binding wins (carries the `'default'` key),
183
+ // else the key must be a bare prop.
184
+ let indexPropName: string
185
+ let defaultKey: string | undefined
186
+ const resolved = localKeyBindings.get(keyName)
187
+ if (resolved) {
188
+ indexPropName = resolved.propName
189
+ defaultKey = resolved.defaultLiteral
190
+ } else if (propNames.has(keyName)) {
191
+ indexPropName = keyName
192
+ } else {
193
+ return null
194
+ }
195
+
196
+ // IDENT must be a module-scope object-literal const carried as `parsed`.
197
+ const constInfo = (ctx.state.localConstants ?? []).find(c => c.name === objName && c.isModule)
198
+ const parsedConst = constInfo?.parsed
199
+ if (!parsedConst || parsedConst.kind !== 'object-literal') return null
200
+
201
+ const entries: { key: string; value: { kind: 'number' | 'string'; text: string } }[] = []
202
+ for (const prop of parsedConst.properties) {
203
+ const v = prop.value
204
+ if (v.kind === 'literal' && v.literalType === 'number') {
205
+ entries.push({ key: prop.key, value: { kind: 'number', text: v.raw ?? String(v.value) } })
206
+ } else if (v.kind === 'literal' && v.literalType === 'string') {
207
+ entries.push({ key: prop.key, value: { kind: 'string', text: String(v.value) } })
208
+ } else {
209
+ return null
210
+ }
211
+ }
212
+
213
+ const goVal = (v: { kind: 'number' | 'string'; text: string }) =>
214
+ v.kind === 'number' ? v.text : JSON.stringify(v.text)
215
+ const ents = entries.map(e => `${JSON.stringify(e.key)}: ${goVal(e.value)}`)
216
+ if (defaultKey !== undefined) {
217
+ const def = entries.find(e => e.key === defaultKey)
218
+ if (def) ents.unshift(`"": ${goVal(def.value)}`)
219
+ }
220
+ const allString = entries.every(e => e.value.kind === 'string')
221
+ const mapType = allString ? 'map[string]string' : 'map[string]any'
222
+ ctx.state.usesFmt = true
223
+ return `${mapType}{${ents.join(', ')}}[fmt.Sprint(in.${capitalizeFieldName(indexPropName)})]`
224
+ }
225
+
226
+ /**
227
+ * `ParsedExpr` counterpart of `propsAccessName`: if `node` is a
228
+ * `<propsObjectName>.<name>` member access, return `<name>`, else null.
229
+ */
230
+ function propsAccessNameFromParsed(ctx: GoEmitContext, node: ParsedExpr): string | null {
231
+ if (node.kind !== 'member' || node.computed) return null
232
+ if (node.object.kind !== 'identifier') return null
233
+ if (!ctx.state.propsObjectName || node.object.name !== ctx.state.propsObjectName) return null
234
+ return node.property
235
+ }
236
+
237
+ /**
238
+ * If `node` is a `<propsObjectName>.<name>` access, return `<name>`, else
239
+ * null. Used to recognize props-object reads inside memo interpolations.
240
+ */
241
+ export function propsAccessName(ctx: GoEmitContext, node: ts.Expression): string | null {
242
+ if (!ts.isPropertyAccessExpression(node)) return null
243
+ if (!ts.isIdentifier(node.expression)) return null
244
+ if (!ctx.state.propsObjectName || node.expression.text !== ctx.state.propsObjectName) return null
245
+ return node.name.text
246
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Prop-type resolution: decide each prop's Go struct-field type.
3
+ *
4
+ * Free functions over a {@link GoEmitContext}, shared by the Input/Props struct
5
+ * generators and the nillable-field set so they can't drift.
6
+ */
7
+
8
+ import type { ComponentIR, IRMetadata } from '@barefootjs/jsx'
9
+
10
+ import type { GoEmitContext } from '../emit-context.ts'
11
+ import { typeInfoToGo } from '../type/type-codegen.ts'
12
+
13
+ /**
14
+ * Build a map from prop name to a better Go type inferred from signals. When a
15
+ * signal is initialized from a prop (`createSignal(props.initial ?? 0)`), the
16
+ * signal's type annotation may be more specific than the prop's `TypeInfo`. Only
17
+ * generic prop types (containing `interface{}`) are overridden.
18
+ */
19
+ export function buildPropTypeOverrides(ctx: GoEmitContext, ir: ComponentIR): Map<string, string> {
20
+ const overrides = new Map<string, string>()
21
+ for (const signal of ir.metadata.signals) {
22
+ const propNames = [signal.initialValue]
23
+ const extracted = ctx.extractPropNameFromInitialValue(signal.initialValue)
24
+ if (extracted) propNames.push(extracted)
25
+
26
+ for (const propName of propNames) {
27
+ const param = ir.metadata.propsParams.find(p => p.name === propName)
28
+ if (!param) continue
29
+ const propGoType = typeInfoToGo(ctx, param.type, param.defaultValue)
30
+ if (propGoType.includes('interface{}')) {
31
+ const signalGoType = typeInfoToGo(ctx, signal.type, signal.initialValue)
32
+ if (!signalGoType.includes('interface{}')) {
33
+ overrides.set(propName, signalGoType)
34
+ }
35
+ }
36
+ }
37
+ }
38
+ return overrides
39
+ }
40
+
41
+ /**
42
+ * Resolve a prop param's Go struct-field type using the SAME logic
43
+ * `generatePropsStruct` / `generateInputStruct` use: a `propTypeOverrides` entry
44
+ * wins, otherwise `typeInfoToGo(param.type, param.defaultValue)`. Factored out so
45
+ * the nillable-field set (`collectNillablePropNames`) can't drift from the
46
+ * emitted field types.
47
+ */
48
+ export function resolvePropGoType(
49
+ ctx: GoEmitContext,
50
+ param: IRMetadata['propsParams'][number],
51
+ propTypeOverrides: Map<string, string>,
52
+ ): string {
53
+ const base = propTypeOverrides.get(param.name) ?? typeInfoToGo(ctx, param.type, param.defaultValue)
54
+ // An OPTIONAL prop typed as a named struct (`opts?: EmblaOptionsType`) lowers
55
+ // to `map[string]interface{}`, not the value struct: a value struct is always
56
+ // truthy in Go templates (so a `{{if .Opts}}`-guarded attribute could never be
57
+ // omitted), whereas a nil/empty map is falsy and round-trips through `bf_json`
58
+ // with only the supplied keys — matching JS `JSON.stringify` of a partial
59
+ // object instead of a zero-filled struct.
60
+ // Gate on `localStructFields` (an actual generated struct), NOT
61
+ // `localTypeNames` — the latter also covers string-union aliases
62
+ // (`placement?: 'top' | 'right' | …`), which must stay their scalar Go type.
63
+ if (param.optional && ctx.state.localStructFields.has(base)) {
64
+ return 'map[string]interface{}'
65
+ }
66
+ return base
67
+ }
68
+
69
+ /**
70
+ * Build the set of prop NAMES whose resolved Go field type is exactly
71
+ * `interface{}` (nillable, for Hono-style attribute omission). Uses the same
72
+ * `propTypeOverrides` + `resolvePropGoType` pipeline as the struct generators.
73
+ * Concrete (`string`/`int`/`bool`/`[]T`/struct) types are excluded.
74
+ */
75
+ export function collectNillablePropNames(ctx: GoEmitContext, ir: ComponentIR): Set<string> {
76
+ const propTypeOverrides = buildPropTypeOverrides(ctx, ir)
77
+ const nillable = new Set<string>()
78
+ for (const param of ir.metadata.propsParams) {
79
+ if (resolvePropGoType(ctx, param, propTypeOverrides) === 'interface{}') {
80
+ nillable.add(param.name)
81
+ }
82
+ }
83
+ return nillable
84
+ }