@barefootjs/go-template 0.17.0 → 0.18.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 (39) hide show
  1. package/dist/adapter/expr/url-builder.d.ts.map +1 -1
  2. package/dist/adapter/go-template-adapter.d.ts +144 -14
  3. package/dist/adapter/go-template-adapter.d.ts.map +1 -1
  4. package/dist/adapter/index.js +449 -118
  5. package/dist/adapter/lib/compile-state.d.ts +27 -1
  6. package/dist/adapter/lib/compile-state.d.ts.map +1 -1
  7. package/dist/adapter/lib/go-emit.d.ts +9 -0
  8. package/dist/adapter/lib/go-emit.d.ts.map +1 -1
  9. package/dist/adapter/lib/go-naming.d.ts +23 -0
  10. package/dist/adapter/lib/go-naming.d.ts.map +1 -1
  11. package/dist/adapter/memo/memo-compute.d.ts +54 -5
  12. package/dist/adapter/memo/memo-compute.d.ts.map +1 -1
  13. package/dist/adapter/memo/memo-type.d.ts +10 -0
  14. package/dist/adapter/memo/memo-type.d.ts.map +1 -1
  15. package/dist/adapter/type/type-codegen.d.ts +5 -1
  16. package/dist/adapter/type/type-codegen.d.ts.map +1 -1
  17. package/dist/adapter/value/parsed-literal-to-go.d.ts +13 -4
  18. package/dist/adapter/value/parsed-literal-to-go.d.ts.map +1 -1
  19. package/dist/build.js +449 -118
  20. package/dist/conformance-pins.d.ts +12 -0
  21. package/dist/conformance-pins.d.ts.map +1 -0
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +468 -118
  25. package/package.json +3 -3
  26. package/src/__tests__/go-template-adapter.test.ts +652 -129
  27. package/src/__tests__/lowering-plugin.test.ts +56 -0
  28. package/src/adapter/expr/url-builder.ts +14 -3
  29. package/src/adapter/go-template-adapter.ts +637 -140
  30. package/src/adapter/lib/compile-state.ts +31 -0
  31. package/src/adapter/lib/go-emit.ts +20 -0
  32. package/src/adapter/lib/go-naming.ts +29 -0
  33. package/src/adapter/memo/memo-compute.ts +228 -18
  34. package/src/adapter/memo/memo-type.ts +19 -0
  35. package/src/adapter/type/type-codegen.ts +22 -3
  36. package/src/adapter/value/parsed-literal-to-go.ts +82 -9
  37. package/src/conformance-pins.ts +135 -0
  38. package/src/index.ts +1 -0
  39. package/src/test-render.ts +46 -8
@@ -13,10 +13,12 @@
13
13
  import type {
14
14
  CompilerError,
15
15
  ContextConsumer,
16
+ EnvSignalReader,
16
17
  IRMetadata,
17
18
  IRNode,
18
19
  LoweringMatcher,
19
20
  MemoInfo,
21
+ SsrSeedPlan,
20
22
  TypeDefinition,
21
23
  TypeInfo,
22
24
  } from '@barefootjs/jsx'
@@ -90,6 +92,35 @@ export class CompileState {
90
92
  */
91
93
  searchParamsLocals: Set<string> = new Set()
92
94
 
95
+ /**
96
+ * Env-signal getter (local binding name, e.g. `searchParams` or an alias) →
97
+ * its registered {@link EnvSignalReader}, keyed by the RECEIVER signal's own
98
+ * `envReader` registration rather than a hardcoded key — so a memo body's
99
+ * `<local>().<method>(...)` resolves to whichever reader that particular
100
+ * local belongs to (open-closed for a future second env signal).
101
+ */
102
+ envSignalReadersByLocal: Map<string, EnvSignalReader> = new Map()
103
+
104
+ /**
105
+ * The backend-neutral SSR seed plan (`computeSsrSeedPlan`, Package G) for
106
+ * the IR currently being compiled — single authority for which signal/memo
107
+ * getters are value-materializable at SSR time (`env-reader` steps are
108
+ * per-request readers; everything else is `derived`/`opaque`). Populated in
109
+ * `primeCompileState`; `envSignalReadersByLocal` and `searchParamsLocals`
110
+ * derive from it there, and the memo-analysis path in `memo-compute.ts`
111
+ * reads its `steps` directly instead of re-deriving from `metadata`.
112
+ */
113
+ ssrSeedPlan: SsrSeedPlan = { baseScope: [], steps: [] }
114
+
115
+ /**
116
+ * Memo name → the hoisted Go local variable name the constructor assigned
117
+ * its SSR value to (populated by the sibling-memo hoisting pre-pass before
118
+ * the main memo-field loop runs). When a later filter-arm memo's free-var
119
+ * resolution finds an entry here, it reuses the local instead of
120
+ * recomputing the sibling's expression a second time.
121
+ */
122
+ hoistedMemoLocals: Map<string, string> = new Map()
123
+
93
124
  /**
94
125
  * Call-lowering matchers active for this component (#2057), bound to its
95
126
  * metadata at init via `prepareLoweringMatchers`. Each maps a recognised call
@@ -192,6 +192,26 @@ export function emitFlatMapEval(
192
192
  return `bf_flat_map_eval ${wrapIfMultiToken(recv)} "${escapeGoString(json)}" "${param}" ${env}`
193
193
  }
194
194
 
195
+ /**
196
+ * Emit a value-producing `.map(cb)` via the evaluator (#2073): the projection
197
+ * body is serialized and evaluated per element by `bf_map_eval`, one result
198
+ * per element (no flatten — the JS `.map` contract). Composes through the
199
+ * array-method chain (`bf_join (bf_map_eval …) " "`). Returns null when the
200
+ * projection is outside the evaluator surface (→ caller pushes BF101). The
201
+ * JSX-returning `.map` is an IRLoop upstream and never reaches this emit.
202
+ */
203
+ export function emitMapEval(
204
+ recv: string,
205
+ body: ParsedExpr,
206
+ param: string,
207
+ emit: (e: ParsedExpr) => string,
208
+ ): string | null {
209
+ const json = serializeParsedExpr(body)
210
+ if (json === null) return null
211
+ const env = emitEvalEnvArg(body, [param], emit)
212
+ return `bf_map_eval ${wrapIfMultiToken(recv)} "${escapeGoString(json)}" "${param}" ${env}`
213
+ }
214
+
195
215
  /**
196
216
  * Make an equality comparison string-tolerant when exactly one side is a Go
197
217
  * string literal: JS `sorted === 'asc'` is loosely false for `sorted = false`,
@@ -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.
@@ -9,10 +9,17 @@
9
9
  */
10
10
 
11
11
  import type { ParsedExpr, ParsedStatement, TypeInfo } from '@barefootjs/jsx'
12
+ import {
13
+ asCallbackMethodCall,
14
+ freeVarsInBody,
15
+ materializeGetterCalls,
16
+ serializeParsedExpr,
17
+ } from '@barefootjs/jsx'
12
18
 
13
19
  import type { GoEmitContext } from '../emit-context.ts'
14
20
  import type { PropFallbackVar } from '../lib/types.ts'
15
21
  import { capitalizeFieldName } from '../lib/go-naming.ts'
22
+ import { escapeGoString } from '../lib/go-emit.ts'
16
23
  import { convertInitialValue, getSignalInitialValueAsGo } from '../value/value-lowering.ts'
17
24
  import { typeInfoToGo } from '../type/type-codegen.ts'
18
25
  import { computeTemplateLiteralMemoInitialValue } from './template-interp.ts'
@@ -21,6 +28,91 @@ import { resolveBlockBodyMemoModuleConst, computeObjectMemoInitialValue } from '
21
28
  /** Default for the optional `propFallbackVars` argument. */
22
29
  const EMPTY_PROP_FALLBACK_VARS: ReadonlyMap<string, PropFallbackVar> = new Map()
23
30
 
31
+ /** A bare zero-arg getter call (`count()`) → its name, else null. */
32
+ function getterCallName(e: ParsedExpr): string | null {
33
+ return e.kind === 'call' && e.callee.kind === 'identifier' && e.args.length === 0
34
+ ? e.callee.name
35
+ : null
36
+ }
37
+
38
+ /** A `props.X` member access → the prop name, else null. */
39
+ function propsMemberName(e: ParsedExpr): string | null {
40
+ return e.kind === 'member' &&
41
+ !e.computed &&
42
+ e.object.kind === 'identifier' &&
43
+ e.object.name === 'props'
44
+ ? e.property
45
+ : null
46
+ }
47
+
48
+ /** A `() => props.X.filter((p) => <predicate>)` match: the array prop name,
49
+ * the predicate serialized to the runtime evaluator's ParsedExpr JSON, the
50
+ * arrow's param name, and the free variable names its predicate captures.
51
+ * Null when `body` isn't this shape, `propName` doesn't resolve to a known
52
+ * prop, or the predicate isn't representable (`serializeParsedExpr` refusal).
53
+ * Shared by the SSR-value emitter ({@link memoInitialFromParsedBody}) and the
54
+ * constructor's sibling-memo hoisting pre-pass so both walk the shape
55
+ * identically. */
56
+ export function matchFilterArmMemo(
57
+ ctx: GoEmitContext,
58
+ body: ParsedExpr,
59
+ signals: { getter: string; initialValue: string }[],
60
+ propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
61
+ ): { propName: string; predJSON: string; paramName: string; freeVars: string[] } | null {
62
+ const cb = asCallbackMethodCall(body)
63
+ if (!cb || cb.method !== 'filter') return null
64
+ const propName = propsMemberName(cb.object)
65
+ const param = propName ? propsParams.find(p => p.name === propName) : undefined
66
+ if (!propName || !param) return null
67
+
68
+ // Single authority (Package G plan): every step that isn't `env-reader` is
69
+ // value-materializable at SSR time — env readers are per-request readers
70
+ // the runtime evaluator can't invoke, so they're excluded here exactly as
71
+ // the plan's own consumers exclude them.
72
+ const knownGetterNames = new Set<string>(
73
+ ctx.state.ssrSeedPlan.steps.filter(s => s.kind !== 'env-reader').map(s => s.name),
74
+ )
75
+
76
+ const materialized = materializeGetterCalls(cb.arrow.body, knownGetterNames)
77
+ const predJSON = serializeParsedExpr(materialized)
78
+ if (predJSON === null) return null
79
+
80
+ const paramName = cb.arrow.params[0] ?? '_'
81
+ const freeVars = freeVarsInBody(materialized, new Set(cb.arrow.params))
82
+ return { propName, predJSON, paramName, freeVars }
83
+ }
84
+
85
+ /**
86
+ * Names of EARLIER sibling memos (per `ctx.state.currentMemos` declaration
87
+ * order) that a filter-arm memo's predicate free variables resolve to — the
88
+ * constructor generator hoists these into a shared local so the sibling's
89
+ * expression isn't emitted twice (once for its own field, once inlined in
90
+ * this memo's `bf.FilterEval` env map, #2075/#2077 review finding 3). Empty
91
+ * when `memo` isn't a filter-arm memo or references nothing hoistable.
92
+ */
93
+ export function filterArmEarlierSiblingRefs(
94
+ ctx: GoEmitContext,
95
+ memo: { name: string; parsed?: ParsedExpr },
96
+ signals: { getter: string; initialValue: string }[],
97
+ propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
98
+ ): string[] {
99
+ if (!memo.parsed) return []
100
+ const match = matchFilterArmMemo(ctx, memo.parsed, signals, propsParams)
101
+ if (!match) return []
102
+ // `currentMemos` order equals the plan's memo-step order (plan lists
103
+ // signals first, then memos, both in declaration order) — kept here rather
104
+ // than filtering `ssrSeedPlan.steps` since this index is memo-only already.
105
+ const memos = ctx.state.currentMemos ?? []
106
+ const currentIndex = memos.findIndex(m => m.name === memo.name)
107
+ if (currentIndex < 0) return []
108
+ const refs: string[] = []
109
+ for (const name of match.freeVars) {
110
+ const idx = memos.findIndex(m => m.name === name)
111
+ if (idx >= 0 && idx < currentIndex) refs.push(name)
112
+ }
113
+ return refs
114
+ }
115
+
24
116
  /**
25
117
  * Compute a memo's SSR initial value as a Go expression — e.g.
26
118
  * `() => count() * 2` → `in.Initial * 2`, `() => props.value * 10` →
@@ -39,8 +131,11 @@ export function computeMemoInitialValue(
39
131
  propFallbackVars: ReadonlyMap<string, PropFallbackVar> = EMPTY_PROP_FALLBACK_VARS,
40
132
  goType?: string,
41
133
  ): string {
134
+ // Seed the resolution stack with this memo's own name — every external call
135
+ // is a fresh top-level computation, so self-reference must be caught on the
136
+ // very first recursion, not only on the second visit.
42
137
  const resolved = computeMemoInitialValueOrNull(
43
- ctx, memo, signals, propsParams, propFallbackVars,
138
+ ctx, memo, signals, propsParams, propFallbackVars, new Set([memo.name]),
44
139
  )
45
140
  if (resolved !== null) return resolved
46
141
  // Zero value for the memo's Go type: `false` for bool, `""` for string,
@@ -76,25 +171,112 @@ export function memoInitialFromParsedBody(
76
171
  signals: { getter: string; initialValue: string }[],
77
172
  propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
78
173
  propFallbackVars: ReadonlyMap<string, PropFallbackVar>,
174
+ currentMemoName: string,
175
+ resolving: ReadonlySet<string> = new Set(),
79
176
  ): string | null {
80
177
  const propRef = (propName: string): string => {
81
178
  const hoisted = propFallbackVars.get(propName)
82
179
  if (hoisted) return hoisted.varName
83
180
  return `in.${capitalizeFieldName(propName)}`
84
181
  }
85
- // A bare zero-arg getter call (`count()`) its name, else null.
86
- const getterCallName = (e: ParsedExpr): string | null =>
87
- e.kind === 'call' && e.callee.kind === 'identifier' && e.args.length === 0
88
- ? e.callee.name
89
- : null
90
- // A `props.X` member access the prop name, else null.
91
- const propsMemberName = (e: ParsedExpr): string | null =>
92
- e.kind === 'member' &&
93
- !e.computed &&
94
- e.object.kind === 'identifier' &&
95
- e.object.name === 'props'
96
- ? e.property
97
- : null
182
+ // A request-scoped env-signal read (`searchParams().get('k')`, any local
183
+ // alias, #1922) the literal key and the reader's canonical field name,
184
+ // else null. Constraints: the receiver must be a zero-arg call of a local
185
+ // bound to a registered env signal (`ctx.state.envSignalReadersByLocal`,
186
+ // keyed by that reader's own registration — not a hardcoded key); the
187
+ // method must be one that reader's `methods` set recognises; the key
188
+ // argument must be a string literal.
189
+ const envGetKey = (e: ParsedExpr): { key: string; fieldName: string } | null => {
190
+ if (e.kind !== 'call' || e.callee.kind !== 'member' || e.callee.computed) return null
191
+ const recvName = getterCallName(e.callee.object)
192
+ if (recvName === null) return null
193
+ const reader = ctx.state.envSignalReadersByLocal.get(recvName)
194
+ if (!reader || !reader.methods.has(e.callee.property)) return null
195
+ const arg = e.args[0]
196
+ if (!arg || arg.kind !== 'literal' || arg.literalType !== 'string') return null
197
+ return { key: String(arg.value), fieldName: capitalizeFieldName(reader.canonicalName) }
198
+ }
199
+
200
+ // () => searchParams().get('k') — a derived memo over the env signal
201
+ // (#2069). The constructor reads the canonical `in.SearchParams` reader the
202
+ // handler fills per request, so the memo SSR-computes instead of zero-valuing.
203
+ {
204
+ const m = envGetKey(body)
205
+ if (m !== null) return `in.${m.fieldName}.Get(${JSON.stringify(m.key)})`
206
+ }
207
+
208
+ // () => searchParams().get('k') ?? '<lit>' — the defaulted form. Go's
209
+ // `SearchParams.Get` returns "" for an absent key (it can't surface JS's
210
+ // null), so the default fires on "" — the same documented `?? → or`
211
+ // divergence as the template-position lowering (`or (.SearchParams.Get
212
+ // "k") "<lit>"`); the two positions stay consistent.
213
+ if (
214
+ body.kind === 'logical' &&
215
+ (body.op === '??' || body.op === '||') &&
216
+ body.right.kind === 'literal' &&
217
+ body.right.literalType === 'string'
218
+ ) {
219
+ const m = envGetKey(body.left)
220
+ if (m !== null) {
221
+ const def = JSON.stringify(String(body.right.value))
222
+ return `func() string { if v := in.${m.fieldName}.Get(${JSON.stringify(m.key)}); v != "" { return v }; return ${def} }()`
223
+ }
224
+ }
225
+
226
+ // () => props.X.filter((p) => <predicate>) — a LIST-valued derived memo
227
+ // chained off a sibling memo the predicate closes over (e.g. a
228
+ // searchParams()-derived `tag`). Env-signal getters are excluded from the
229
+ // free-var materialization set: they're per-request READERS, not values,
230
+ // and the runtime evaluator has no way to invoke one.
231
+ {
232
+ const match = matchFilterArmMemo(ctx, body, signals, propsParams)
233
+ if (match) {
234
+ const { propName, predJSON, paramName, freeVars } = match
235
+ const currentIndex = (ctx.state.currentMemos ?? []).findIndex(m => m.name === currentMemoName)
236
+ const envEntries: string[] = []
237
+ let unresolved = false
238
+ for (const name of freeVars) {
239
+ let goExpr: string | null = null
240
+ const siblingIndex = (ctx.state.currentMemos ?? []).findIndex(m => m.name === name)
241
+ if (siblingIndex >= 0) {
242
+ // Only a sibling declared EARLIER than the memo being computed is
243
+ // eligible — mirrors JS declaration order and rules out both
244
+ // self-reference and any mutual pair (at most one direction can
245
+ // satisfy `earlier`), independent of the resolving-stack guard
246
+ // below. A hoisted local (the constructor emitted it once already,
247
+ // #2075/#2077 review finding 3) is reused verbatim instead of
248
+ // recomputing the expression a second time.
249
+ const siblingMemo = ctx.state.currentMemos![siblingIndex]
250
+ const eligible = currentIndex >= 0 && siblingIndex < currentIndex && !resolving.has(siblingMemo.name)
251
+ if (eligible) {
252
+ const hoisted = ctx.state.hoistedMemoLocals.get(siblingMemo.name)
253
+ goExpr = hoisted ?? computeMemoInitialValueOrNull(
254
+ ctx, siblingMemo, signals, propsParams, propFallbackVars,
255
+ new Set([...resolving, siblingMemo.name]),
256
+ )
257
+ }
258
+ } else {
259
+ const sig = signals.find(s => s.getter === name)
260
+ if (sig) {
261
+ goExpr = getSignalInitialValueAsGo(ctx, sig.initialValue, propsParams, propFallbackVars)
262
+ } else {
263
+ const p = propsParams.find(pp => pp.name === name)
264
+ if (p) goExpr = propRef(name)
265
+ }
266
+ }
267
+ if (goExpr === null) {
268
+ unresolved = true
269
+ break
270
+ }
271
+ envEntries.push(`${JSON.stringify(name)}: ${goExpr}`)
272
+ }
273
+ if (!unresolved) {
274
+ const itemsField = `in.${capitalizeFieldName(propName)}`
275
+ const envMap = `map[string]any{${envEntries.join(', ')}}`
276
+ return `bf.FilterEval(${itemsField}, "${escapeGoString(predJSON)}", ${JSON.stringify(paramName)}, ${envMap})`
277
+ }
278
+ }
279
+ }
98
280
 
99
281
  // () => getter() === 'lit' / !== 'lit' — a selection memo. Resolves to a Go
100
282
  // bool when the signal's initial value is itself a string literal.
@@ -150,8 +332,15 @@ export function memoInitialFromParsedBody(
150
332
  condGo = getSignalInitialValueAsGo(ctx, condSignal.initialValue, propsParams, propFallbackVars)
151
333
  } else {
152
334
  const condMemo = (ctx.state.currentMemos ?? []).find(m => m.name === condName)
153
- if (condMemo) {
154
- condGo = computeMemoInitialValueOrNull(ctx, condMemo, signals, propsParams, propFallbackVars)
335
+ // A condition memo already on the resolution stack (self- or
336
+ // mutually-referencing ternaries) falls back to null — the same
337
+ // zero-value default as any other unresolved shape — instead of
338
+ // recursing forever.
339
+ if (condMemo && !resolving.has(condMemo.name)) {
340
+ condGo = computeMemoInitialValueOrNull(
341
+ ctx, condMemo, signals, propsParams, propFallbackVars,
342
+ new Set([...resolving, condMemo.name]),
343
+ )
155
344
  }
156
345
  }
157
346
  if (condGo === 'true') return t
@@ -259,6 +448,15 @@ export function computeMemoInitialValueOrNull(
259
448
  signals: { getter: string; initialValue: string }[],
260
449
  propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
261
450
  propFallbackVars: ReadonlyMap<string, PropFallbackVar> = EMPTY_PROP_FALLBACK_VARS,
451
+ /**
452
+ * Memo names currently being resolved on this call stack — guards against
453
+ * unbounded recursion when memos reference each other (mutually, or a
454
+ * self-reference). Callers that start a fresh top-level computation seed
455
+ * this with `memo.name`; recursive calls extend it with the sibling memo
456
+ * about to be entered. A candidate already in the set resolves to `null`
457
+ * (the shape's usual "unsupported" fallback) instead of recursing.
458
+ */
459
+ resolving: ReadonlySet<string> = new Set(),
262
460
  ): string | null {
263
461
  const computation = memo.computation
264
462
 
@@ -275,6 +473,8 @@ export function computeMemoInitialValueOrNull(
275
473
  signals,
276
474
  propsParams,
277
475
  propFallbackVars,
476
+ memo.name,
477
+ resolving,
278
478
  )
279
479
  if (fromParsed !== null) return fromParsed
280
480
  }
@@ -288,6 +488,7 @@ export function computeMemoInitialValueOrNull(
288
488
  signals,
289
489
  propsParams,
290
490
  propFallbackVars,
491
+ resolving,
291
492
  )
292
493
  if (cmpTernary !== null) return cmpTernary
293
494
 
@@ -329,6 +530,7 @@ export function resolveGetterValueAsGo(
329
530
  signals: { getter: string; initialValue: string }[],
330
531
  propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
331
532
  propFallbackVars: ReadonlyMap<string, PropFallbackVar>,
533
+ resolving: ReadonlySet<string> = new Set(),
332
534
  ): string | null {
333
535
  const signal = signals.find(s => s.getter === name)
334
536
  if (signal) {
@@ -336,13 +538,18 @@ export function resolveGetterValueAsGo(
336
538
  }
337
539
  const memo = (ctx.state.currentMemos ?? []).find(m => m.name === name)
338
540
  if (memo) {
541
+ // Already on the resolution stack (self- or mutually-referencing
542
+ // ternary operands) — fall back to null rather than recurse forever.
543
+ if (resolving.has(memo.name)) return null
339
544
  const stripped = memo.computation.replace(/^\(\)\s*=>\s*/, '')
340
545
  const fb = ctx.extractPropFallback(stripped)
341
546
  if (fb && capitalizeFieldName(fb.propName) === capitalizeFieldName(memo.name)) {
342
547
  const field = `in.${capitalizeFieldName(fb.propName)}`
343
548
  return `func() interface{} { v := interface{}(${field}); if v == nil || v == "" { return ${fb.goFallback} }; return v }()`
344
549
  }
345
- return computeMemoInitialValueOrNull(ctx, memo, signals, propsParams, propFallbackVars)
550
+ return computeMemoInitialValueOrNull(
551
+ ctx, memo, signals, propsParams, propFallbackVars, new Set([...resolving, memo.name]),
552
+ )
346
553
  }
347
554
  const param = propsParams.find(p => p.name === name)
348
555
  if (param) {
@@ -366,6 +573,7 @@ export function computeComparisonTernaryGo(
366
573
  signals: { getter: string; initialValue: string }[],
367
574
  propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
368
575
  propFallbackVars: ReadonlyMap<string, PropFallbackVar>,
576
+ resolving: ReadonlySet<string> = new Set(),
369
577
  ): string | null {
370
578
  // Matches any `conditional` `parsed` — an expression-bodied ternary or, since
371
579
  // #2040, a block-bodied memo whose value `if` / early-return folded to one.
@@ -398,6 +606,7 @@ export function computeComparisonTernaryGo(
398
606
  signals,
399
607
  propsParams,
400
608
  propFallbackVars,
609
+ resolving,
401
610
  )
402
611
  if (condGo === null) return null
403
612
 
@@ -420,10 +629,11 @@ export function resolveComparisonOperandGo(
420
629
  signals: { getter: string; initialValue: string }[],
421
630
  propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
422
631
  propFallbackVars: ReadonlyMap<string, PropFallbackVar>,
632
+ resolving: ReadonlySet<string> = new Set(),
423
633
  ): string | null {
424
634
  // getter(): a signal or memo getter call.
425
635
  if (node.kind === 'call' && node.callee.kind === 'identifier' && node.args.length === 0) {
426
- return resolveGetterValueAsGo(ctx, node.callee.name, signals, propsParams, propFallbackVars)
636
+ return resolveGetterValueAsGo(ctx, node.callee.name, signals, propsParams, propFallbackVars, resolving)
427
637
  }
428
638
  // props.X ?? 'def' — nil/empty-tolerant field read with the default folded in.
429
639
  if (node.kind === 'logical' && node.op === '??' && node.right.kind === 'literal' && node.right.literalType === 'string') {
@@ -6,11 +6,25 @@
6
6
  * right SSR zero value).
7
7
  */
8
8
 
9
+ import { asCallbackMethodCall } from '@barefootjs/jsx'
9
10
  import type { ParsedExpr, TypeInfo } from '@barefootjs/jsx'
10
11
 
11
12
  import type { GoEmitContext } from '../emit-context.ts'
12
13
  import { typeInfoToGo } from '../type/type-codegen.ts'
13
14
 
15
+ /**
16
+ * True when a memo's body is a `.filter(<arrow>)` callback-method call
17
+ * (#2075) — a LIST-valued derived memo (the blog PostList `visible` shape:
18
+ * `createMemo(() => props.items.filter((p) => …))`), not a scalar. Exported
19
+ * so both `isBooleanMemo` (guard below) and `inferMemoType`'s field-type
20
+ * decision (go-template-adapter.ts) share the one recognition point.
21
+ */
22
+ export function isListFilterMemo(memo: { parsed?: ParsedExpr }): boolean {
23
+ if (!memo.parsed) return false
24
+ const cb = asCallbackMethodCall(memo.parsed)
25
+ return cb !== null && cb.method === 'filter'
26
+ }
27
+
14
28
  /**
15
29
  * Heuristic: does this memo evaluate to a boolean? True when its computation is
16
30
  * a comparison (`!==`/`===`/`!=`/`==`), a negation (`!x`), or a ternary whose
@@ -24,6 +38,11 @@ export function isBooleanMemo(
24
38
  propsParamMap: Map<string, { name: string; type: TypeInfo; defaultValue?: string }>,
25
39
  ): boolean {
26
40
  const c = memo.computation
41
+ // A LIST-valued `.filter(arrow)` memo (#2075) is never boolean, even though
42
+ // its predicate arrow body often contains a `!` negation (`!tag() || …`) —
43
+ // that would otherwise trip the `/=>\s*!/` heuristic below into
44
+ // misclassifying the whole memo. Bail before any regex runs.
45
+ if (isListFilterMemo(memo)) return false
27
46
  // A ternary whose two branches are string literals is a STRING memo, not
28
47
  // boolean — the `===` lives in the *condition*, so the blanket comparison
29
48
  // check below would misclassify it as bool and bake `false`. Bail first.
@@ -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
  }