@barefootjs/go-template 0.16.0 → 0.17.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.
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 +302 -1071
  10. package/dist/adapter/go-template-adapter.d.ts.map +1 -1
  11. package/dist/adapter/index.js +3116 -2692
  12. package/dist/adapter/lib/compile-state.d.ts +142 -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 +126 -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 +173 -0
  27. package/dist/adapter/memo/memo-compute.d.ts.map +1 -0
  28. package/dist/adapter/memo/memo-type.d.ts +48 -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 +3114 -2690
  45. package/dist/index.js +3116 -2692
  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 +398 -54
  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 +2189 -5320
  57. package/src/adapter/lib/compile-state.ts +181 -0
  58. package/src/adapter/lib/constants.ts +24 -0
  59. package/src/adapter/lib/go-emit.ts +309 -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 +661 -0
  65. package/src/adapter/memo/memo-type.ts +93 -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 +48 -22
@@ -0,0 +1,661 @@
1
+ /**
2
+ * Memo initial-value computation — lower a memo's computation to its SSR initial
3
+ * value as a Go expression.
4
+ *
5
+ * Free functions over a {@link GoEmitContext}. `computeMemoInitialValue` is the
6
+ * typed-field entry (zero-value defaulting); `computeMemoInitialValueOrNull` is
7
+ * the pattern-matching core dispatching over template-literal, parsed-body,
8
+ * comparison-ternary, block-body and object memo shapes.
9
+ */
10
+
11
+ import type { ParsedExpr, ParsedStatement, TypeInfo } from '@barefootjs/jsx'
12
+ import {
13
+ asCallbackMethodCall,
14
+ freeVarsInBody,
15
+ materializeGetterCalls,
16
+ serializeParsedExpr,
17
+ } from '@barefootjs/jsx'
18
+
19
+ import type { GoEmitContext } from '../emit-context.ts'
20
+ import type { PropFallbackVar } from '../lib/types.ts'
21
+ import { capitalizeFieldName } from '../lib/go-naming.ts'
22
+ import { escapeGoString } from '../lib/go-emit.ts'
23
+ import { convertInitialValue, getSignalInitialValueAsGo } from '../value/value-lowering.ts'
24
+ import { typeInfoToGo } from '../type/type-codegen.ts'
25
+ import { computeTemplateLiteralMemoInitialValue } from './template-interp.ts'
26
+ import { resolveBlockBodyMemoModuleConst, computeObjectMemoInitialValue } from './memo-value.ts'
27
+
28
+ /** Default for the optional `propFallbackVars` argument. */
29
+ const EMPTY_PROP_FALLBACK_VARS: ReadonlyMap<string, PropFallbackVar> = new Map()
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
+
116
+ /**
117
+ * Compute a memo's SSR initial value as a Go expression — e.g.
118
+ * `() => count() * 2` → `in.Initial * 2`, `() => props.value * 10` →
119
+ * `in.Value * 10`. Unresolved computations default to the memo's Go zero value.
120
+ *
121
+ * @param propFallbackVars when a hoisted fallback var exists for a referenced
122
+ * prop, it is substituted for `in.FieldName` so the memo inherits the
123
+ * signal-time `??` fallback
124
+ * @param goType Go type of the memo field, used to pick the zero-value fallback
125
+ */
126
+ export function computeMemoInitialValue(
127
+ ctx: GoEmitContext,
128
+ memo: { name: string; computation: string; deps: string[]; parsed?: ParsedExpr },
129
+ signals: { getter: string; initialValue: string }[],
130
+ propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
131
+ propFallbackVars: ReadonlyMap<string, PropFallbackVar> = EMPTY_PROP_FALLBACK_VARS,
132
+ goType?: string,
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.
137
+ const resolved = computeMemoInitialValueOrNull(
138
+ ctx, memo, signals, propsParams, propFallbackVars, new Set([memo.name]),
139
+ )
140
+ if (resolved !== null) return resolved
141
+ // Zero value for the memo's Go type: `false` for bool, `""` for string,
142
+ // `nil` for reference types (map / slice / interface / pointer — `0` is not
143
+ // assignable to them), else the int `0`.
144
+ if (goType === 'bool') return 'false'
145
+ if (goType === 'string') return '""'
146
+ if (
147
+ goType !== undefined &&
148
+ (goType.startsWith('map[') ||
149
+ goType.startsWith('[]') ||
150
+ goType.startsWith('*') ||
151
+ goType.includes('interface{}') ||
152
+ goType === 'any')
153
+ ) {
154
+ return 'nil'
155
+ }
156
+ return '0'
157
+ }
158
+
159
+ /**
160
+ * Structural matcher for the common expression-bodied memo shapes, driven by
161
+ * the analyzer-attached `MemoInfo.parsed` (the memo arrow's body): `getter() ===
162
+ * 'lit'`, `props.X ?? false`, `cond() ? A : B`, `<getter()|props.X|var> <*+-/>
163
+ * <int>`, and bare `getter()` / `props.X` / `var`.
164
+ *
165
+ * @returns the Go SSR value, or null when the body is none of these (the caller
166
+ * then falls through to comparison-ternary / block-body / object-memo handling)
167
+ */
168
+ export function memoInitialFromParsedBody(
169
+ ctx: GoEmitContext,
170
+ body: ParsedExpr,
171
+ signals: { getter: string; initialValue: string }[],
172
+ propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
173
+ propFallbackVars: ReadonlyMap<string, PropFallbackVar>,
174
+ currentMemoName: string,
175
+ resolving: ReadonlySet<string> = new Set(),
176
+ ): string | null {
177
+ const propRef = (propName: string): string => {
178
+ const hoisted = propFallbackVars.get(propName)
179
+ if (hoisted) return hoisted.varName
180
+ return `in.${capitalizeFieldName(propName)}`
181
+ }
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
+ }
280
+
281
+ // () => getter() === 'lit' / !== 'lit' — a selection memo. Resolves to a Go
282
+ // bool when the signal's initial value is itself a string literal.
283
+ if (
284
+ body.kind === 'binary' &&
285
+ ['===', '!==', '==', '!='].includes(body.op) &&
286
+ body.right.kind === 'literal' &&
287
+ body.right.literalType === 'string'
288
+ ) {
289
+ const depName = getterCallName(body.left)
290
+ if (depName) {
291
+ const lit = String(body.right.value)
292
+ const signal = signals.find(sg => sg.getter === depName)
293
+ const initLit = signal ? /^'([^'\\]*)'$/.exec(signal.initialValue.trim()) : null
294
+ if (initLit) {
295
+ const equal = initLit[1] === lit
296
+ return String(body.op.startsWith('!') ? !equal : equal)
297
+ }
298
+ }
299
+ }
300
+
301
+ // () => props.X ?? false — a boolean passthrough memo. Go's bool zero value
302
+ // IS the `?? false` fallback, so the raw input field carries it exactly.
303
+ if (
304
+ body.kind === 'logical' &&
305
+ body.op === '??' &&
306
+ body.right.kind === 'literal' &&
307
+ body.right.value === false
308
+ ) {
309
+ const propName = propsMemberName(body.left)
310
+ if (propName) return propRef(propName)
311
+ }
312
+
313
+ // () => cond() ? A : B where each branch is a module string const or a
314
+ // string literal, and `cond` is a signal/memo this resolver can evaluate.
315
+ if (body.kind === 'conditional') {
316
+ const condName = getterCallName(body.test)
317
+ if (condName) {
318
+ const resolveBranch = (b: ParsedExpr): string | null => {
319
+ if (b.kind === 'literal' && b.literalType === 'string') return JSON.stringify(b.value)
320
+ if (b.kind === 'identifier') {
321
+ const constVal = ctx.state.moduleStringConsts.get(b.name)
322
+ return constVal !== undefined ? JSON.stringify(constVal) : null
323
+ }
324
+ return null
325
+ }
326
+ const t = resolveBranch(body.consequent)
327
+ const f = resolveBranch(body.alternate)
328
+ if (t !== null && f !== null) {
329
+ let condGo: string | null = null
330
+ const condSignal = signals.find(sg => sg.getter === condName)
331
+ if (condSignal) {
332
+ condGo = getSignalInitialValueAsGo(ctx, condSignal.initialValue, propsParams, propFallbackVars)
333
+ } else {
334
+ const condMemo = (ctx.state.currentMemos ?? []).find(m => m.name === condName)
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
+ )
344
+ }
345
+ }
346
+ if (condGo === 'true') return t
347
+ if (condGo === 'false') return f
348
+ if (condGo !== null) {
349
+ return `func() string { if ${condGo} { return ${t} }; return ${f} }()`
350
+ }
351
+ }
352
+ }
353
+ }
354
+
355
+ // () => <ref> <*|+|-|/> <non-negative int>. The operand must be a
356
+ // non-negative integer literal; float/negative bodies fall through unchanged.
357
+ if (
358
+ body.kind === 'binary' &&
359
+ ['*', '+', '-', '/'].includes(body.op) &&
360
+ body.right.kind === 'literal' &&
361
+ body.right.literalType === 'number' &&
362
+ typeof body.right.value === 'number' &&
363
+ Number.isInteger(body.right.value) &&
364
+ body.right.value >= 0
365
+ ) {
366
+ const operator = body.op
367
+ const operand = String(body.right.value)
368
+
369
+ // getter() * N — return the signal's Go initial value times N.
370
+ const depName = getterCallName(body.left)
371
+ if (depName) {
372
+ const signal = signals.find(s => s.getter === depName)
373
+ if (signal) {
374
+ const signalInitial = getSignalInitialValueAsGo(ctx, signal.initialValue, propsParams, propFallbackVars)
375
+ return `${signalInitial} ${operator} ${operand}`
376
+ }
377
+ }
378
+
379
+ // props.X * N — hoisted var if any, else the input field (asserting int
380
+ // when the field lowers to interface{}).
381
+ const propName = propsMemberName(body.left)
382
+ if (propName) {
383
+ const param = propsParams.find(p => p.name === propName)
384
+ if (param) {
385
+ const hoisted = propFallbackVars.get(propName)
386
+ if (hoisted) return `${hoisted.varName} ${operator} ${operand}`
387
+ const fieldName = capitalizeFieldName(propName)
388
+ if (param.type) {
389
+ const goType = typeInfoToGo(ctx, param.type, param.defaultValue)
390
+ if (goType === 'interface{}') return `in.${fieldName}.(int) ${operator} ${operand}`
391
+ }
392
+ return `in.${fieldName} ${operator} ${operand}`
393
+ }
394
+ }
395
+
396
+ // var * N — destructured prop (no hoisted-var lookup).
397
+ if (body.left.kind === 'identifier') {
398
+ const varName = body.left.name
399
+ const param = propsParams.find(p => p.name === varName)
400
+ if (param) {
401
+ const fieldName = capitalizeFieldName(varName)
402
+ if (param.type) {
403
+ const goType = typeInfoToGo(ctx, param.type, param.defaultValue)
404
+ if (goType === 'interface{}') return `in.${fieldName}.(int) ${operator} ${operand}`
405
+ }
406
+ return `in.${fieldName} ${operator} ${operand}`
407
+ }
408
+ }
409
+ }
410
+
411
+ // () => getter() — just return the signal's Go initial value.
412
+ const simpleDep = getterCallName(body)
413
+ if (simpleDep) {
414
+ const signal = signals.find(s => s.getter === simpleDep)
415
+ if (signal) {
416
+ return getSignalInitialValueAsGo(ctx, signal.initialValue, propsParams, propFallbackVars)
417
+ }
418
+ }
419
+
420
+ // () => props.X — return the prop value (hoisted-aware).
421
+ const simpleProp = propsMemberName(body)
422
+ if (simpleProp) {
423
+ const param = propsParams.find(p => p.name === simpleProp)
424
+ if (param) return propRef(simpleProp)
425
+ }
426
+
427
+ // () => var — destructured prop, return the input field directly.
428
+ if (body.kind === 'identifier') {
429
+ const param = propsParams.find(p => p.name === body.name)
430
+ if (param) return `in.${capitalizeFieldName(body.name)}`
431
+ }
432
+
433
+ return null
434
+ }
435
+
436
+ /**
437
+ * Pattern-matching core of `computeMemoInitialValue`.
438
+ *
439
+ * @returns the memo's SSR initial value as a Go expression, or `null` when no
440
+ * pattern applies. Callers with a typed field to fill use the
441
+ * zero-value-defaulting wrapper above; callers that can OMIT the field (a
442
+ * child-instance prop init, where Go's zero values then apply with the right
443
+ * type) use this directly.
444
+ */
445
+ export function computeMemoInitialValueOrNull(
446
+ ctx: GoEmitContext,
447
+ memo: { name: string; computation: string; deps: string[]; parsed?: ParsedExpr; parsedBlock?: ParsedStatement[]; parsedBlockComplete?: boolean },
448
+ signals: { getter: string; initialValue: string }[],
449
+ propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
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(),
460
+ ): string | null {
461
+ const computation = memo.computation
462
+
463
+ // () => `...${expr}...` — template-literal memo (the classes memo). Builds a
464
+ // Go string concatenation; null when any interpolation isn't representable.
465
+ const tmplMemo = computeTemplateLiteralMemoInitialValue(ctx, memo, propsParams)
466
+ if (tmplMemo !== null) return tmplMemo
467
+
468
+ // Expression-bodied memo shapes, matched structurally on `parsed`.
469
+ if (memo.parsed) {
470
+ const fromParsed = memoInitialFromParsedBody(
471
+ ctx,
472
+ memo.parsed,
473
+ signals,
474
+ propsParams,
475
+ propFallbackVars,
476
+ memo.name,
477
+ resolving,
478
+ )
479
+ if (fromParsed !== null) return fromParsed
480
+ }
481
+
482
+ // () => <operand> ===/!== 'lit' ? A : B, where each branch is a string
483
+ // literal/module-const and <operand> is a getter call, an inline
484
+ // nullish-defaulted prop (`props.X ?? 'horizontal'`), or a bare `props.X`.
485
+ const cmpTernary = computeComparisonTernaryGo(
486
+ ctx,
487
+ memo.parsed,
488
+ signals,
489
+ propsParams,
490
+ propFallbackVars,
491
+ resolving,
492
+ )
493
+ if (cmpTernary !== null) return cmpTernary
494
+
495
+ // Block-body memo that early-returns a module-const array when a guard signal
496
+ // is falsy: `() => { const k = getter(); if (!k) return MODULE_ARRAY; … }`.
497
+ // When the signal starts null the SSR value is that array; its literal value
498
+ // (not the identifier) is passed to the baker so it reduces to a Go slice.
499
+ const blockReturn = resolveBlockBodyMemoModuleConst(ctx, memo, signals)
500
+ if (blockReturn !== null && blockReturn.constValue && blockReturn.constType) {
501
+ return convertInitialValue(ctx,
502
+ blockReturn.constValue,
503
+ blockReturn.constType,
504
+ propsParams,
505
+ blockReturn.constParsed,
506
+ )
507
+ }
508
+
509
+ // Object-returning block-body memo derived from `searchParams()`. Computes a
510
+ // Go `map[string]interface{}` whose values are lowered from the request
511
+ // query, so `.Params.Sort` etc. resolve at execute time instead of reading a
512
+ // nil map; null for any unsupported shape (→ nil fallback).
513
+ const objMemo = computeObjectMemoInitialValue(ctx, memo)
514
+ if (objMemo !== null) return objMemo
515
+
516
+ return null
517
+ }
518
+
519
+ /**
520
+ * Resolve a signal/memo getter NAME to a Go value expression, for use as the
521
+ * condition operand of another memo's ternary. Handles a plain signal, a
522
+ * prop-shadow memo `() => props.X ?? 'lit'` (→ a nil/empty-tolerant field
523
+ * read), else recurses.
524
+ *
525
+ * @returns the Go expression, or null when the shape isn't representable
526
+ */
527
+ export function resolveGetterValueAsGo(
528
+ ctx: GoEmitContext,
529
+ name: string,
530
+ signals: { getter: string; initialValue: string }[],
531
+ propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
532
+ propFallbackVars: ReadonlyMap<string, PropFallbackVar>,
533
+ resolving: ReadonlySet<string> = new Set(),
534
+ ): string | null {
535
+ const signal = signals.find(s => s.getter === name)
536
+ if (signal) {
537
+ return getSignalInitialValueAsGo(ctx, signal.initialValue, propsParams, propFallbackVars)
538
+ }
539
+ const memo = (ctx.state.currentMemos ?? []).find(m => m.name === name)
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
544
+ const stripped = memo.computation.replace(/^\(\)\s*=>\s*/, '')
545
+ const fb = ctx.extractPropFallback(stripped)
546
+ if (fb && capitalizeFieldName(fb.propName) === capitalizeFieldName(memo.name)) {
547
+ const field = `in.${capitalizeFieldName(fb.propName)}`
548
+ return `func() interface{} { v := interface{}(${field}); if v == nil || v == "" { return ${fb.goFallback} }; return v }()`
549
+ }
550
+ return computeMemoInitialValueOrNull(
551
+ ctx, memo, signals, propsParams, propFallbackVars, new Set([...resolving, memo.name]),
552
+ )
553
+ }
554
+ const param = propsParams.find(p => p.name === name)
555
+ if (param) {
556
+ const hoisted = propFallbackVars.get(name)
557
+ return hoisted ? hoisted.varName : `in.${capitalizeFieldName(name)}`
558
+ }
559
+ return null
560
+ }
561
+
562
+ /**
563
+ * Resolve a string-ternary memo whose condition is a literal comparison —
564
+ * `() => <operand> === 'lit' ? A : B` (or `!==`) — to a Go runtime conditional.
565
+ * Branches must be string literals/module-string-consts; the operand resolves
566
+ * via `resolveComparisonOperandGo`.
567
+ *
568
+ * @returns the Go conditional, or null when the shape isn't supported
569
+ */
570
+ export function computeComparisonTernaryGo(
571
+ ctx: GoEmitContext,
572
+ parsed: ParsedExpr | undefined,
573
+ signals: { getter: string; initialValue: string }[],
574
+ propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
575
+ propFallbackVars: ReadonlyMap<string, PropFallbackVar>,
576
+ resolving: ReadonlySet<string> = new Set(),
577
+ ): string | null {
578
+ // Matches any `conditional` `parsed` — an expression-bodied ternary or, since
579
+ // #2040, a block-bodied memo whose value `if` / early-return folded to one.
580
+ if (!parsed || parsed.kind !== 'conditional') return null
581
+ const cond = parsed.test
582
+ if (cond.kind !== 'binary' || !(cond.right.kind === 'literal' && cond.right.literalType === 'string')) {
583
+ return null
584
+ }
585
+ const isEq = cond.op === '===' || cond.op === '=='
586
+ const isNe = cond.op === '!==' || cond.op === '!='
587
+ if (!isEq && !isNe) return null
588
+
589
+ const branch = (bn: ParsedExpr): string | null => {
590
+ if (bn.kind === 'literal' && bn.literalType === 'string') {
591
+ return JSON.stringify(bn.value)
592
+ }
593
+ if (bn.kind === 'identifier') {
594
+ const cv = ctx.state.moduleStringConsts.get(bn.name)
595
+ return cv !== undefined ? JSON.stringify(cv) : null
596
+ }
597
+ return null
598
+ }
599
+ const t = branch(parsed.consequent)
600
+ const f = branch(parsed.alternate)
601
+ if (t === null || f === null) return null
602
+
603
+ const condGo = resolveComparisonOperandGo(
604
+ ctx,
605
+ cond.left,
606
+ signals,
607
+ propsParams,
608
+ propFallbackVars,
609
+ resolving,
610
+ )
611
+ if (condGo === null) return null
612
+
613
+ // `consequent` fires when the comparison holds; for `!==` the branches swap.
614
+ const eqBranch = isEq ? t : f
615
+ const neBranch = isEq ? f : t
616
+ return `func() string { if ${condGo} == ${JSON.stringify(cond.right.value)} { return ${eqBranch} }; return ${neBranch} }()`
617
+ }
618
+
619
+ /**
620
+ * Resolve the left operand of a string-ternary memo's comparison condition to a
621
+ * Go expression: a zero-arg getter call (a signal/prop-shadow memo), an inline
622
+ * `props.X ?? 'def'` (folds the default), or a bare `props.X`.
623
+ *
624
+ * @returns the Go expression, or null for anything else
625
+ */
626
+ export function resolveComparisonOperandGo(
627
+ ctx: GoEmitContext,
628
+ node: ParsedExpr,
629
+ signals: { getter: string; initialValue: string }[],
630
+ propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
631
+ propFallbackVars: ReadonlyMap<string, PropFallbackVar>,
632
+ resolving: ReadonlySet<string> = new Set(),
633
+ ): string | null {
634
+ // getter(): a signal or memo getter call.
635
+ if (node.kind === 'call' && node.callee.kind === 'identifier' && node.args.length === 0) {
636
+ return resolveGetterValueAsGo(ctx, node.callee.name, signals, propsParams, propFallbackVars, resolving)
637
+ }
638
+ // props.X ?? 'def' — nil/empty-tolerant field read with the default folded in.
639
+ if (node.kind === 'logical' && node.op === '??' && node.right.kind === 'literal' && node.right.literalType === 'string') {
640
+ const propName = propsAccessNameFromParsed(ctx, node.left)
641
+ if (propName) {
642
+ const field = `in.${capitalizeFieldName(propName)}`
643
+ return `func() interface{} { v := interface{}(${field}); if v == nil || v == "" { return ${JSON.stringify(node.right.value)} }; return v }()`
644
+ }
645
+ }
646
+ // Bare props.X.
647
+ const direct = propsAccessNameFromParsed(ctx, node)
648
+ if (direct) return `in.${capitalizeFieldName(direct)}`
649
+ return null
650
+ }
651
+
652
+ /**
653
+ * `ParsedExpr` counterpart of `propsAccessName`: if `node` is a
654
+ * `<propsObjectName>.<name>` member access, return `<name>`, else null.
655
+ */
656
+ function propsAccessNameFromParsed(ctx: GoEmitContext, node: ParsedExpr): string | null {
657
+ if (node.kind !== 'member' || node.computed) return null
658
+ if (node.object.kind !== 'identifier') return null
659
+ if (!ctx.state.propsObjectName || node.object.name !== ctx.state.propsObjectName) return null
660
+ return node.property
661
+ }