@barefootjs/go-template 0.18.7 → 0.19.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.
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { ComponentIR, IRMetadata, IRNode, ParsedExpr } from '@barefootjs/jsx'
9
+ import { isBooleanAttr } from '@barefootjs/jsx'
9
10
 
10
11
  import type { GoEmitContext } from '../emit-context.ts'
11
12
  import { typeInfoToGo } from '../type/type-codegen.ts'
@@ -20,7 +21,7 @@ export function buildPropTypeOverrides(ctx: GoEmitContext, ir: ComponentIR): Map
20
21
  const overrides = new Map<string, string>()
21
22
  for (const signal of ir.metadata.signals) {
22
23
  const propNames = [signal.initialValue]
23
- const extracted = ctx.extractPropNameFromInitialValue(signal.initialValue)
24
+ const extracted = ctx.extractPropNameFromInitialValue(signal.initialValue, signal.parsed)
24
25
  if (extracted) propNames.push(extracted)
25
26
 
26
27
  for (const propName of propNames) {
@@ -106,6 +107,151 @@ function collectToFixedPropNames(root: IRNode): Set<string> {
106
107
  return names
107
108
  }
108
109
 
110
+ /**
111
+ * Concrete scalar Go types eligible for the nullish (`??`) nillable flip in
112
+ * `resolvePropGoType`. Only these can round-trip through an `interface{}`
113
+ * field and back via a constructor type assertion / `bf.ToInt`-style
114
+ * coercion (see the fallback-var emission in `generateNewPropsFunction`).
115
+ */
116
+ export const NULLISH_SCALAR_GO_TYPES: ReadonlySet<string> = new Set(['string', 'int', 'float64', 'bool'])
117
+
118
+ /**
119
+ * Names of OPTIONAL props consumed nullish-sensitively — the left operand of
120
+ * a `??` anywhere in the component's parsed expression trees, or a signal's
121
+ * `props.X ?? <literal>` initial value (#2248).
122
+ *
123
+ * Why this matters: JS `??` falls back only on `null`/`undefined`, keeping
124
+ * `''`/`0`/`false`. A Go zero-valued scalar field cannot represent "absent",
125
+ * so a `??`-consumed optional scalar must lower to the adapter's established
126
+ * nillable representation (`interface{}`) for the distinction to exist at
127
+ * render time. `resolvePropGoType` applies that flip using this set.
128
+ *
129
+ * The IR walk is generic (any nested object/array is descended, so parsed
130
+ * trees inside expressions, conditions, attributes, and loops are all seen)
131
+ * and the match is shape-precise: `identifier` left (destructured props) or
132
+ * `<propsObject>.<name>` member left. KNOWN LIMITATION (same class as
133
+ * `collectToFixedPropNames`): an `identifier` left shadowed by a same-named
134
+ * loop/callback param is misattributed to the prop — the flip is then merely
135
+ * unnecessary, not incorrect.
136
+ */
137
+ export function collectNullishConsumedPropNames(ctx: GoEmitContext, ir: ComponentIR): Set<string> {
138
+ const names = new Set<string>()
139
+ // A destructure default (`{ className = '' }`) means the binding is never
140
+ // nullish in JS — the default already applied — so such props never need
141
+ // the nillable flip (and `applyGoFallback`'s concrete-typed baking relies
142
+ // on them staying concrete).
143
+ const optionalParams = new Set(
144
+ ir.metadata.propsParams.filter(p => p.optional && p.defaultValue == null).map(p => p.name),
145
+ )
146
+ if (optionalParams.size === 0) return names
147
+
148
+ const propsObject = ctx.state.propsObjectName
149
+ const propNameOfLeft = (left: ParsedExpr): string | null => {
150
+ if (left.kind === 'identifier') return left.name
151
+ if (
152
+ left.kind === 'member' &&
153
+ !left.computed &&
154
+ left.object.kind === 'identifier' &&
155
+ left.object.name === propsObject
156
+ ) {
157
+ return left.property
158
+ }
159
+ return null
160
+ }
161
+
162
+ // `?? <zero-equivalent literal>` (`?? ''`, `?? 0`, `?? false`) is the one
163
+ // shape where nullish and truthiness semantics coincide — nil and the zero
164
+ // value both land on the same output — so it earns no flip. Only a
165
+ // fallback the zero value must NOT collapse into makes the distinction
166
+ // observable.
167
+ const isZeroEquivalentLiteral = (right: ParsedExpr): boolean =>
168
+ right.kind === 'literal' &&
169
+ (right.value === '' || right.value === false || right.value === null ||
170
+ (right.literalType === 'number' && Number(right.value) === 0))
171
+
172
+ const walk = (node: unknown): void => {
173
+ if (!node || typeof node !== 'object') return
174
+ if (Array.isArray(node)) {
175
+ for (const item of node) walk(item)
176
+ return
177
+ }
178
+ const rec = node as Record<string, unknown>
179
+ if (rec.kind === 'logical' && rec.op === '??' && rec.left && rec.right) {
180
+ const propName = propNameOfLeft(rec.left as ParsedExpr)
181
+ if (propName && optionalParams.has(propName) && !isZeroEquivalentLiteral(rec.right as ParsedExpr)) {
182
+ names.add(propName)
183
+ }
184
+ }
185
+ for (const value of Object.values(rec)) walk(value)
186
+ }
187
+ walk(ir.root)
188
+
189
+ // Signal seeds (`createSignal(props.X ?? 1)`) live in metadata, not the
190
+ // tree — reuse the seam's existing `props.X ?? <literal>` recognizer. The
191
+ // zero-equivalent exclusion matches on the Go-formatted fallback literal.
192
+ for (const signal of ir.metadata.signals) {
193
+ const match = ctx.extractPropFallback(signal.initialValue, signal.parsed)
194
+ if (!match || !optionalParams.has(match.propName)) continue
195
+ const f = match.goFallback
196
+ if (f === '""' || f === 'false' || f === 'nil' || Number(f) === 0) continue
197
+ names.add(match.propName)
198
+ }
199
+ return names
200
+ }
201
+
202
+ /**
203
+ * Names of OPTIONAL no-default props consumed as the BARE value of an
204
+ * omittable attribute (`rows={rows}` / `rows={props.rows}`) anywhere in the
205
+ * component's element tree.
206
+ *
207
+ * Why this matters: Hono omits an attribute whose value is `undefined`, and
208
+ * the adapter's `emitExpression` mirrors that with a `{{if ne .X nil}}` guard
209
+ * — which needs a nillable field to test. A concrete scalar field would
210
+ * render the zero value (`rows="0"`) instead of dropping the attribute, so
211
+ * these props take the same `interface{}` flip as `??` consumption
212
+ * (`resolvePropGoType`). The match mirrors `emitExpression`'s guard branch:
213
+ * bare identifiers / `<propsObject>.<name>` only, skipping `class`/`style`
214
+ * (own lowerings) and boolean/presence attrs (truthiness-tested, where a nil
215
+ * and a zero value already coincide). Same shadowing caveat as
216
+ * `collectNullishConsumedPropNames`: a same-named loop/callback param can
217
+ * misattribute, making the flip merely unnecessary, not incorrect.
218
+ */
219
+ export function collectOmittableAttrConsumedPropNames(ctx: GoEmitContext, ir: ComponentIR): Set<string> {
220
+ const names = new Set<string>()
221
+ const optionalParams = new Set(
222
+ ir.metadata.propsParams.filter(p => p.optional && p.defaultValue == null).map(p => p.name),
223
+ )
224
+ if (optionalParams.size === 0) return names
225
+
226
+ const propsObject = ctx.state.propsObjectName
227
+ const walk = (node: unknown): void => {
228
+ if (!node || typeof node !== 'object') return
229
+ if (Array.isArray(node)) {
230
+ for (const item of node) walk(item)
231
+ return
232
+ }
233
+ const rec = node as Record<string, unknown>
234
+ if (rec.type === 'element' && Array.isArray(rec.attrs)) {
235
+ for (const attr of rec.attrs as Array<{ name: string; value: Record<string, unknown> }>) {
236
+ if (attr.name === 'class' || attr.name === 'className' || attr.name === 'style') continue
237
+ if (isBooleanAttr(attr.name)) continue
238
+ if (attr.value?.kind !== 'expression' || attr.value.presenceOrUndefined) continue
239
+ const bareId = String(attr.value.expr ?? '').trim()
240
+ const propName =
241
+ propsObject && bareId.startsWith(`${propsObject}.`)
242
+ ? bareId.slice(propsObject.length + 1)
243
+ : bareId
244
+ if (/^[A-Za-z_$][\w$]*$/.test(propName) && optionalParams.has(propName)) {
245
+ names.add(propName)
246
+ }
247
+ }
248
+ }
249
+ for (const value of Object.values(rec)) walk(value)
250
+ }
251
+ walk(ir.root)
252
+ return names
253
+ }
254
+
109
255
  /**
110
256
  * Resolve a prop param's Go struct-field type using the SAME logic
111
257
  * `generatePropsStruct` / `generateInputStruct` use: a `propTypeOverrides` entry
@@ -131,6 +277,37 @@ export function resolvePropGoType(
131
277
  if (param.optional && ctx.state.localStructFields.has(base)) {
132
278
  return 'map[string]interface{}'
133
279
  }
280
+ // An OPTIONAL scalar prop consumed by `??` lowers to `interface{}` (#2248):
281
+ // a zero-valued `string`/`int` field cannot distinguish "absent" from
282
+ // `''`/`0`, so JS nullish semantics (which KEEP `''`/`0`) are unexpressible
283
+ // on the concrete type. `interface{}` is the adapter's established nillable
284
+ // representation; the `??` template lowering then tests nil-ness via
285
+ // `bf_nullish` and the constructor seeds via a nil check + assertion.
286
+ // Assignment ergonomics are unchanged (plain literals assign into
287
+ // `interface{}`), and the prop joins the existing nillable behaviours
288
+ // (bare-attribute omission) by construction.
289
+ //
290
+ // Gated to `kind: 'primitive'` — a string-union prop
291
+ // (`placement?: 'top' | 'bottom'`) must stay its scalar Go type (the same
292
+ // invariant the struct-map gate above documents): the union-typed
293
+ // class-composition lowerings key off the concrete type, AND the flip
294
+ // would buy nothing — the zero value (`""`) is never a legal union
295
+ // member, so the zero-check already coincides with nullish semantics for
296
+ // every valid input. (A literal-number union containing 0 would be the
297
+ // exception; none exists in the corpus and the conflation is the
298
+ // documented pre-#2248 trade-off there.)
299
+ // A bare-attribute consumption (`rows={rows}`) takes the same flip (#2259):
300
+ // attribute omission for an absent optional needs a nil to test — see
301
+ // `collectOmittableAttrConsumedPropNames`.
302
+ if (
303
+ param.optional &&
304
+ param.type.kind === 'primitive' &&
305
+ (ctx.state.nullishConsumedPropNames.has(param.name) ||
306
+ ctx.state.omittableAttrConsumedPropNames.has(param.name)) &&
307
+ NULLISH_SCALAR_GO_TYPES.has(base)
308
+ ) {
309
+ return 'interface{}'
310
+ }
134
311
  return base
135
312
  }
136
313
 
@@ -32,7 +32,7 @@ export function convertInitialValue(
32
32
  }
33
33
  }
34
34
 
35
- const propName = ctx.extractPropNameFromInitialValue(value)
35
+ const propName = ctx.extractPropNameFromInitialValue(value, preParsed)
36
36
  if (propName && propsParams?.some(p => p.name === propName)) {
37
37
  return `in.${capitalizeFieldName(propName)}`
38
38
  }
@@ -699,7 +699,7 @@ function buildGoPropsInit(
699
699
  // not the naive `Id`) — see `goMapLiteralFromObject`'s identical fix.
700
700
  const goField = capitalizeFieldName(key)
701
701
  if (typeof value === 'string') {
702
- lines.push(`\t\t${goField}: "${value}",`)
702
+ lines.push(`\t\t${goField}: ${goStringLit(value)},`)
703
703
  } else if (typeof value === 'number') {
704
704
  lines.push(`\t\t${goField}: ${value},`)
705
705
  } else if (typeof value === 'boolean') {
@@ -828,6 +828,20 @@ function goTypedMapSliceLiteralFromArray(arr: unknown[], elemType: string): stri
828
828
  * names. Only the keys the caller supplied are set, so an omitted optional prop
829
829
  * (e.g. `defaultOn` on the third toggle item) takes the Go zero value. (#1297)
830
830
  */
831
+ /**
832
+ * Emit a JS string as a Go interpreted string literal. JSON string
833
+ * escaping is a subset of Go's (`\"`, `\\`, `\n`, `\uXXXX` are all valid
834
+ * Go escapes), so `JSON.stringify` is a correct emitter — unlike the
835
+ * previous quote-only `.replace(/"/g, '\\"')`, it also survives
836
+ * backslashes, newlines, and control characters (data-point conformance
837
+ * caught the quote case: a `"` in a string prop broke the generated
838
+ * `main.go` at compile time). Lone surrogates emit as `\uDXXX`, which Go
839
+ * rejects — acceptable until a fixture needs malformed-UTF-16 props.
840
+ */
841
+ function goStringLit(v: string): string {
842
+ return JSON.stringify(v)
843
+ }
844
+
831
845
  function goStructLiteral(obj: Record<string, unknown>, typeName: string): string {
832
846
  const fields: string[] = []
833
847
  for (const [k, v] of Object.entries(obj)) {
@@ -836,7 +850,7 @@ function goStructLiteral(obj: Record<string, unknown>, typeName: string): string
836
850
  // adapter's own struct-literal baking (`parsed-literal-to-go.ts`)
837
851
  // sanitizes those to `DataX`, not `Data-x` (Copilot review, #2202).
838
852
  const goField = goFieldNameForKey(k)
839
- if (typeof v === 'string') fields.push(`${goField}: "${v.replace(/"/g, '\\"')}"`)
853
+ if (typeof v === 'string') fields.push(`${goField}: ${goStringLit(v)}`)
840
854
  else if (typeof v === 'number' || typeof v === 'boolean') fields.push(`${goField}: ${v}`)
841
855
  else if (v === null) fields.push(`${goField}: nil`)
842
856
  else if (Array.isArray(v)) fields.push(`${goField}: ${goArrayLiteralFromArray(v)}`)
@@ -848,7 +862,7 @@ function goStructLiteral(obj: Record<string, unknown>, typeName: string): string
848
862
  function goArrayLiteralFromArray(arr: unknown[]): string {
849
863
  const entries: string[] = []
850
864
  for (const v of arr) {
851
- if (typeof v === 'string') entries.push(`"${v.replace(/"/g, '\\"')}"`)
865
+ if (typeof v === 'string') entries.push(goStringLit(v))
852
866
  else if (typeof v === 'number') entries.push(String(v))
853
867
  else if (typeof v === 'boolean') entries.push(String(v))
854
868
  else if (v === null) entries.push('nil')
@@ -886,7 +900,7 @@ function goMapLiteralFromObject(
886
900
  // uses for exactly this key-to-Go-field sanitization.
887
901
  const emittedKey = capitalizeKeys ? goFieldNameForKey(k) : k
888
902
  const key = JSON.stringify(emittedKey)
889
- if (typeof v === 'string') entries.push(`${key}: "${v.replace(/"/g, '\\"')}"`)
903
+ if (typeof v === 'string') entries.push(`${key}: ${goStringLit(v)}`)
890
904
  else if (typeof v === 'number') entries.push(`${key}: ${v}`)
891
905
  else if (typeof v === 'boolean') entries.push(`${key}: ${v}`)
892
906
  else if (v === null) entries.push(`${key}: nil`)