@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.
- package/dist/adapter/expr/url-builder.d.ts.map +1 -1
- package/dist/adapter/go-template-adapter.d.ts +144 -14
- package/dist/adapter/go-template-adapter.d.ts.map +1 -1
- package/dist/adapter/index.js +449 -118
- package/dist/adapter/lib/compile-state.d.ts +27 -1
- package/dist/adapter/lib/compile-state.d.ts.map +1 -1
- package/dist/adapter/lib/go-emit.d.ts +9 -0
- package/dist/adapter/lib/go-emit.d.ts.map +1 -1
- package/dist/adapter/lib/go-naming.d.ts +23 -0
- package/dist/adapter/lib/go-naming.d.ts.map +1 -1
- package/dist/adapter/memo/memo-compute.d.ts +54 -5
- package/dist/adapter/memo/memo-compute.d.ts.map +1 -1
- package/dist/adapter/memo/memo-type.d.ts +10 -0
- package/dist/adapter/memo/memo-type.d.ts.map +1 -1
- package/dist/adapter/type/type-codegen.d.ts +5 -1
- package/dist/adapter/type/type-codegen.d.ts.map +1 -1
- package/dist/adapter/value/parsed-literal-to-go.d.ts +13 -4
- package/dist/adapter/value/parsed-literal-to-go.d.ts.map +1 -1
- package/dist/build.js +449 -118
- package/dist/conformance-pins.d.ts +12 -0
- package/dist/conformance-pins.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +468 -118
- package/package.json +3 -3
- package/src/__tests__/go-template-adapter.test.ts +652 -129
- package/src/__tests__/lowering-plugin.test.ts +56 -0
- package/src/adapter/expr/url-builder.ts +14 -3
- package/src/adapter/go-template-adapter.ts +637 -140
- package/src/adapter/lib/compile-state.ts +31 -0
- package/src/adapter/lib/go-emit.ts +20 -0
- package/src/adapter/lib/go-naming.ts +29 -0
- package/src/adapter/memo/memo-compute.ts +228 -18
- package/src/adapter/memo/memo-type.ts +19 -0
- package/src/adapter/type/type-codegen.ts +22 -3
- package/src/adapter/value/parsed-literal-to-go.ts +82 -9
- package/src/conformance-pins.ts +135 -0
- package/src/index.ts +1 -0
- 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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
e.object
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
11
|
-
*
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
if (prop.value.kind === '
|
|
85
|
-
|
|
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
|
}
|