@barefootjs/go-template 0.1.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/go-template-adapter.d.ts +683 -0
- package/dist/adapter/go-template-adapter.d.ts.map +1 -0
- package/dist/adapter/index.d.ts +6 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/index.js +2672 -0
- package/dist/build.d.ts +53 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +3198 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2672 -0
- package/dist/test-render.d.ts +22 -0
- package/dist/test-render.d.ts.map +1 -0
- package/package.json +59 -0
- package/src/__tests__/build.test.ts +65 -0
- package/src/__tests__/go-template-adapter.test.ts +1757 -0
- package/src/adapter/go-template-adapter.ts +4316 -0
- package/src/adapter/index.ts +6 -0
- package/src/build.ts +258 -0
- package/src/index.ts +8 -0
- package/src/test-render.ts +363 -0
|
@@ -0,0 +1,4316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BarefootJS Go html/template Adapter
|
|
3
|
+
*
|
|
4
|
+
* Generates Go html/template files from BarefootJS IR.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import ts from 'typescript'
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
ComponentIR,
|
|
11
|
+
IRNode,
|
|
12
|
+
IRElement,
|
|
13
|
+
IRText,
|
|
14
|
+
IRExpression,
|
|
15
|
+
IRConditional,
|
|
16
|
+
IRLoop,
|
|
17
|
+
IRLoopChildComponent,
|
|
18
|
+
IRComponent,
|
|
19
|
+
IRFragment,
|
|
20
|
+
IRSlot,
|
|
21
|
+
IRTemplatePart,
|
|
22
|
+
IRProp,
|
|
23
|
+
TypeInfo,
|
|
24
|
+
CompilerError,
|
|
25
|
+
SourceLocation,
|
|
26
|
+
ParsedExpr,
|
|
27
|
+
ParsedStatement,
|
|
28
|
+
SortComparator,
|
|
29
|
+
TemplatePart,
|
|
30
|
+
IRIfStatement,
|
|
31
|
+
IRProvider,
|
|
32
|
+
IRAsync,
|
|
33
|
+
TemplatePrimitiveRegistry,
|
|
34
|
+
} from '@barefootjs/jsx'
|
|
35
|
+
import {
|
|
36
|
+
BaseAdapter,
|
|
37
|
+
type AdapterOutput,
|
|
38
|
+
type AdapterGenerateOptions,
|
|
39
|
+
type TemplateSections,
|
|
40
|
+
type ParsedExprEmitter,
|
|
41
|
+
type HigherOrderMethod,
|
|
42
|
+
type ArrayMethod,
|
|
43
|
+
type LiteralType,
|
|
44
|
+
type IRNodeEmitter,
|
|
45
|
+
type EmitIRNode,
|
|
46
|
+
type AttrValueEmitter,
|
|
47
|
+
isBooleanAttr,
|
|
48
|
+
parseExpression,
|
|
49
|
+
isSupported,
|
|
50
|
+
exprToString,
|
|
51
|
+
identifierPath,
|
|
52
|
+
emitParsedExpr,
|
|
53
|
+
emitIRNode,
|
|
54
|
+
emitAttrValue,
|
|
55
|
+
} from '@barefootjs/jsx'
|
|
56
|
+
import { findInterpolationEnd } from '@barefootjs/jsx/scanner'
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Go-template adapter's IRNode render context. Only `isRootOfClientComponent`
|
|
60
|
+
* is consumed today (forwarded into `renderComponent` / `renderIfStatement`);
|
|
61
|
+
* the type stays open so future render-position flags can be added without
|
|
62
|
+
* widening the `IRNodeEmitter` contract.
|
|
63
|
+
*/
|
|
64
|
+
type GoRenderCtx = {
|
|
65
|
+
isRootOfClientComponent?: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Extended nested component info that tracks whether the component
|
|
70
|
+
* comes from a dynamic (signal) array loop vs a static array loop.
|
|
71
|
+
*/
|
|
72
|
+
interface NestedComponentInfo extends IRLoopChildComponent {
|
|
73
|
+
isDynamic: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface StaticChildInstance {
|
|
77
|
+
name: string
|
|
78
|
+
slotId: string
|
|
79
|
+
props: IRProp[]
|
|
80
|
+
fieldName: string
|
|
81
|
+
/** Concatenated text content from JSX children (e.g. `+1` for
|
|
82
|
+
* `<Button>+1</Button>`). Null when children include any non-text
|
|
83
|
+
* node; those go through the `childrenHtml` path when they're
|
|
84
|
+
* purely static HTML, otherwise they're dropped. */
|
|
85
|
+
childrenText: string | null
|
|
86
|
+
/** Rendered Go-template fragment for purely-static, non-text JSX
|
|
87
|
+
* children (e.g. `<Card><span>x</span></Card>`). Forwarded to the
|
|
88
|
+
* child via `Children: template.HTML(...)` so the child's
|
|
89
|
+
* `{{or .Children ""}}` skips re-escaping. Null when children are
|
|
90
|
+
* text-only or absent — and also null when the rendered fragment
|
|
91
|
+
* contains any `{{...}}` action (signal expressions, nested
|
|
92
|
+
* components, conditionals, etc.) since those wouldn't re-evaluate
|
|
93
|
+
* through the parent's `{{.Children}}` read; those cases stay on
|
|
94
|
+
* the existing drop path. */
|
|
95
|
+
childrenHtml: string | null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Top-level (non-loop) JSX intrinsic-element spread slot (#1407).
|
|
100
|
+
* Collected by `collectSpreadSlots` so the adapter can emit one
|
|
101
|
+
* `Spread_<slotId> map[string]any` field on the component's Props
|
|
102
|
+
* struct and initialise it in `NewXxxProps` from the source JS
|
|
103
|
+
* expression. Loop-internal spreads don't appear here — they emit
|
|
104
|
+
* the bag inline via the loop's iteration variable instead.
|
|
105
|
+
*
|
|
106
|
+
* `bagSource` records how the bag is supplied so the Input struct
|
|
107
|
+
* and `NewXxxProps` can be wired correctly (#1407 follow-up):
|
|
108
|
+
*
|
|
109
|
+
* - `'inline'`: bag is constructed inside `NewXxxProps` from
|
|
110
|
+
* compile-time-known data (signal initial values, prop refs,
|
|
111
|
+
* propsObject enumeration). No Input field needed.
|
|
112
|
+
* - `'input-bag'`: bag is provided by the caller as a
|
|
113
|
+
* `Spread_<slotId> map[string]any` field on the Input struct
|
|
114
|
+
* (used for `restPropsName` spreads where the rest's keys are
|
|
115
|
+
* open-ended and Go's static typing can't enumerate them).
|
|
116
|
+
*/
|
|
117
|
+
interface SpreadSlotInfo {
|
|
118
|
+
slotId: string
|
|
119
|
+
expr: string
|
|
120
|
+
templateExpr: string | undefined
|
|
121
|
+
bagSource: 'inline' | 'input-bag'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* (#1423) Hoisted local var representing a prop with a signal-time
|
|
126
|
+
* `??` fallback. Used by `generateNewPropsFunction` to share the
|
|
127
|
+
* fallback-applied value across the prop, signal, and memo fields.
|
|
128
|
+
*/
|
|
129
|
+
interface PropFallbackVar {
|
|
130
|
+
/** Local variable name (typically the lowercase prop identifier). */
|
|
131
|
+
varName: string
|
|
132
|
+
/** Capitalised Go field name on the `Input` struct. */
|
|
133
|
+
fieldName: string
|
|
134
|
+
/** Go literal used when the input value equals its zero value. */
|
|
135
|
+
goFallback: string
|
|
136
|
+
/** Go zero literal for the prop's type (`0`, `""`, etc.). */
|
|
137
|
+
zeroLiteral: string
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface GoTemplateAdapterOptions {
|
|
141
|
+
/** Go package name for generated types (default: 'components') */
|
|
142
|
+
packageName?: string
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Base path for client JS files (e.g., '/static/client/').
|
|
146
|
+
* Used to generate script registration paths.
|
|
147
|
+
*/
|
|
148
|
+
clientJsBasePath?: string
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Path to barefoot.js runtime (e.g., '/static/client/barefoot.js').
|
|
152
|
+
*/
|
|
153
|
+
barefootJsPath?: string
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Wrap a rendered Go template fragment in parens when it would
|
|
158
|
+
* otherwise parse as multiple sibling args of an enclosing prefix
|
|
159
|
+
* call. A bare identifier / dotted path / quoted literal stays
|
|
160
|
+
* uncluttered; anything containing whitespace (a function call,
|
|
161
|
+
* `len ...`, etc.) gets `(...)` so `bf_join (...) bf_trim .Raw`
|
|
162
|
+
* doesn't degrade to four args of `bf_join`. Used by emitters that
|
|
163
|
+
* compose runtime helpers (#1443 / #1445 Copilot review).
|
|
164
|
+
*/
|
|
165
|
+
function wrapIfMultiToken(rendered: string): string {
|
|
166
|
+
// Already wrapped — don't double-wrap.
|
|
167
|
+
if (rendered.startsWith('(') && rendered.endsWith(')')) return rendered
|
|
168
|
+
// Quoted literals can contain spaces inside the string but parse
|
|
169
|
+
// as a single token; leave them alone.
|
|
170
|
+
if (rendered.startsWith('"') && rendered.endsWith('"')) return rendered
|
|
171
|
+
if (/\s/.test(rendered)) return `(${rendered})`
|
|
172
|
+
return rendered
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Emit the `bf_sort` call shared by the standalone `sortMethod()`
|
|
177
|
+
* arm and the chained `.sort().map()` loop hoist. The runtime helper
|
|
178
|
+
* takes 4 string operands so a future `nulls` knob can grow on the
|
|
179
|
+
* end without rewriting either call site (#1448 Tier B):
|
|
180
|
+
*
|
|
181
|
+
* bf_sort <recv> <keyKind> <keyName> <compareType> <direction>
|
|
182
|
+
*
|
|
183
|
+
* keyKind: "self" | "field"
|
|
184
|
+
* keyName: "" when keyKind=self; capitalised field name otherwise
|
|
185
|
+
* compareType: "numeric" | "string"
|
|
186
|
+
* direction: "asc" | "desc"
|
|
187
|
+
*
|
|
188
|
+
* The capitalisation mirrors the Go-side struct-field convention
|
|
189
|
+
* (`bf_sort .Items "field" "Price" "numeric" "asc"`) so the runtime
|
|
190
|
+
* helper's reflect lookup matches without a recapitalise step.
|
|
191
|
+
*/
|
|
192
|
+
function emitBfSort(recv: string, c: SortComparator): string {
|
|
193
|
+
const keyKind = c.key.kind
|
|
194
|
+
const keyName = c.key.kind === 'field' ? capitalize(c.key.field) : ''
|
|
195
|
+
return `bf_sort ${wrapIfMultiToken(recv)} "${keyKind}" "${keyName}" "${c.type}" "${c.direction}"`
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function capitalize(s: string): string {
|
|
199
|
+
return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Convert a slot ID (e.g., 's6') to a Go struct field suffix (e.g., 'Slot6').
|
|
204
|
+
* Keeps field names human-readable regardless of the internal slot ID format.
|
|
205
|
+
*/
|
|
206
|
+
function slotIdToFieldSuffix(slotId: string): string {
|
|
207
|
+
// Strip parent-owned prefix (^) for Go struct field names
|
|
208
|
+
const cleanId = slotId.startsWith('^') ? slotId.slice(1) : slotId
|
|
209
|
+
const match = cleanId.match(/^s(\d+)$/)
|
|
210
|
+
if (match) {
|
|
211
|
+
return `Slot${match[1]}`
|
|
212
|
+
}
|
|
213
|
+
// Fallback for legacy format or non-standard IDs
|
|
214
|
+
return cleanId.replace('slot_', 'Slot')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Single source of truth for the Go adapter's template-primitive
|
|
219
|
+
* surface (#1188). Each entry pairs the expected arity with the
|
|
220
|
+
* emit function so adding / removing a primitive is a one-line
|
|
221
|
+
* change and the two derived maps (`templatePrimitives` and
|
|
222
|
+
* `templatePrimitiveArities`) can't drift out of sync.
|
|
223
|
+
*/
|
|
224
|
+
interface PrimitiveSpec {
|
|
225
|
+
arity: number
|
|
226
|
+
emit: (args: string[]) => string
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const GO_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
|
|
230
|
+
'JSON.stringify': { arity: 1, emit: (args) => `bf_json ${args[0]}` },
|
|
231
|
+
'String': { arity: 1, emit: (args) => `bf_string ${args[0]}` },
|
|
232
|
+
'Number': { arity: 1, emit: (args) => `bf_number ${args[0]}` },
|
|
233
|
+
'Math.floor': { arity: 1, emit: (args) => `bf_floor ${args[0]}` },
|
|
234
|
+
'Math.ceil': { arity: 1, emit: (args) => `bf_ceil ${args[0]}` },
|
|
235
|
+
'Math.round': { arity: 1, emit: (args) => `bf_round ${args[0]}` },
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter, IRNodeEmitter<GoRenderCtx> {
|
|
239
|
+
name = 'go-template'
|
|
240
|
+
extension = '.tmpl'
|
|
241
|
+
|
|
242
|
+
// Recursion-scoped state for `renderFilterExpr`. `filterExprDepth`
|
|
243
|
+
// tracks nesting so the outer call resets `filterExprUnsupported`
|
|
244
|
+
// before each independent filter expression; the flag itself is set
|
|
245
|
+
// in the `default` branch (BF101 emission) and propagated by parent
|
|
246
|
+
// branches so the final template stays syntactically valid even
|
|
247
|
+
// when a child rendered to the fallback sentinel (#1440 review).
|
|
248
|
+
private filterExprDepth = 0
|
|
249
|
+
private filterExprUnsupported = false
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Identifier-path callees the Go runtime can render in template
|
|
253
|
+
* scope (#1188). The relocate pass consults this map to mark
|
|
254
|
+
* matching calls as template-safe so the surrounding expression
|
|
255
|
+
* stays inlinable; the SSR template emitter (`renderParsedExpr`'s
|
|
256
|
+
* `call` branch) uses the same map to substitute the JS call with
|
|
257
|
+
* the registered Go template form.
|
|
258
|
+
*
|
|
259
|
+
* Keys are the textual callee path as written in the JSX
|
|
260
|
+
* expression. Values are emit functions that receive the already-
|
|
261
|
+
* Go-rendered argument expressions (e.g. `.Config`, `_p.Score`)
|
|
262
|
+
* and return the substituted Go template body — without the
|
|
263
|
+
* surrounding `{{ }}` action delimiters, so callers can wrap the
|
|
264
|
+
* result in `{{...}}` or compose into larger expressions like
|
|
265
|
+
* `{{if eq (bf_json .X) "..."}}`.
|
|
266
|
+
*
|
|
267
|
+
* V1 scope (#1187 R1): identifier-path callees only. Method calls
|
|
268
|
+
* on values (`(arr).join(",")`) require analyzer-resolved receiver
|
|
269
|
+
* type and are explicitly out of scope — users fall back to
|
|
270
|
+
* `/* @client *\/` for those.
|
|
271
|
+
*
|
|
272
|
+
* Public because the `TemplateAdapter` interface contract requires
|
|
273
|
+
* the relocate pass to read this for boolean acceptance. The arity
|
|
274
|
+
* map below is implementation detail (private) — the asymmetry is
|
|
275
|
+
* deliberate.
|
|
276
|
+
*/
|
|
277
|
+
templatePrimitives: TemplatePrimitiveRegistry =
|
|
278
|
+
Object.fromEntries(
|
|
279
|
+
Object.entries(GO_TEMPLATE_PRIMITIVES).map(([k, v]) => [k, v.emit])
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Expected arg count per primitive. Consulted before invoking the
|
|
284
|
+
* registered emit fn so a 0-arg `JSON.stringify()` or 2-arg
|
|
285
|
+
* `JSON.stringify(x, replacer)` doesn't silently produce invalid
|
|
286
|
+
* Go template syntax (the V1 emit fns blindly read `args[0]`).
|
|
287
|
+
*
|
|
288
|
+
* Derived from `GO_TEMPLATE_PRIMITIVES` so it can't drift from
|
|
289
|
+
* `templatePrimitives` — a wrong-arity call falls back to the
|
|
290
|
+
* standard BF101 unsupported-call diagnostic.
|
|
291
|
+
*/
|
|
292
|
+
private readonly templatePrimitiveArities: Record<string, number> =
|
|
293
|
+
Object.fromEntries(
|
|
294
|
+
Object.entries(GO_TEMPLATE_PRIMITIVES).map(([k, v]) => [k, v.arity])
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
private componentName: string = ''
|
|
298
|
+
private options: Required<GoTemplateAdapterOptions>
|
|
299
|
+
private inLoop: boolean = false
|
|
300
|
+
private loopParamStack: string[] = []
|
|
301
|
+
private errors: CompilerError[] = []
|
|
302
|
+
private propsObjectName: string | null = null
|
|
303
|
+
/**
|
|
304
|
+
* Component-scoped rest binding identifier (`function({ a, ...rest }: P)`
|
|
305
|
+
* → `'rest'`). Stashed at `generate()` entry so per-attribute
|
|
306
|
+
* emitter callbacks can classify a spread expression against it
|
|
307
|
+
* without threading the IR through each recursion (#1407
|
|
308
|
+
* follow-up).
|
|
309
|
+
*/
|
|
310
|
+
private restPropsName: string | null = null
|
|
311
|
+
/** Local type names resolved from typeDefinitions (populated during generateTypes) */
|
|
312
|
+
private localTypeNames: Set<string> = new Set()
|
|
313
|
+
/** Local type aliases mapping type name to base type (e.g., Filter → 'string') */
|
|
314
|
+
private localTypeAliases: Map<string, string> = new Map()
|
|
315
|
+
|
|
316
|
+
/** Set during type generation when any emit references
|
|
317
|
+
* `template.HTML(...)`; toggles the `"html/template"` import. */
|
|
318
|
+
private usesHtmlTemplate: boolean = false
|
|
319
|
+
|
|
320
|
+
constructor(options: GoTemplateAdapterOptions = {}) {
|
|
321
|
+
super()
|
|
322
|
+
this.options = {
|
|
323
|
+
packageName: options.packageName ?? 'components',
|
|
324
|
+
clientJsBasePath: options.clientJsBasePath ?? '/static/client/',
|
|
325
|
+
barefootJsPath: options.barefootJsPath ?? '/static/client/barefoot.js',
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Generate template output for a component.
|
|
331
|
+
* @param ir - The component IR
|
|
332
|
+
* @param options - Generation options
|
|
333
|
+
*/
|
|
334
|
+
generate(ir: ComponentIR, options?: AdapterGenerateOptions): AdapterOutput {
|
|
335
|
+
this.componentName = ir.metadata.componentName
|
|
336
|
+
this.errors = []
|
|
337
|
+
this.propsObjectName = ir.metadata.propsObjectName
|
|
338
|
+
this.restPropsName = ir.metadata.restPropsName ?? null
|
|
339
|
+
|
|
340
|
+
// Surface loop-body usages of components imported from sibling
|
|
341
|
+
// .tsx files. The adapter emits `{{template "X" .}}` for these,
|
|
342
|
+
// which Go's template engine resolves only if the user has
|
|
343
|
+
// compiled the sibling file with the same adapter and registered
|
|
344
|
+
// the resulting `{{define "X"}}` block on the same Template
|
|
345
|
+
// instance. When that doesn't happen, the failure is silent at
|
|
346
|
+
// build time and surfaces as a `template: "X" is undefined` at
|
|
347
|
+
// request time — the exact "silent-when-should-be-loud" shape
|
|
348
|
+
// #1266 calls out. The check is scoped to loop bodies because
|
|
349
|
+
// that's the natural Hono-style pattern (factor a list item
|
|
350
|
+
// into a sibling file, .map() over data) and is where users
|
|
351
|
+
// are most likely to hit the request-time failure unawares.
|
|
352
|
+
//
|
|
353
|
+
// The barefoot CLI passes `siblingTemplatesRegistered: true`
|
|
354
|
+
// because it compiles every source-dir file together and
|
|
355
|
+
// registers them all on the same `*template.Template` instance —
|
|
356
|
+
// for that caller the cross-template lookup always resolves, so
|
|
357
|
+
// the diagnostic would be noise. Stand-alone `compileJSX` callers
|
|
358
|
+
// (conformance runner, third-party tooling) leave the flag unset
|
|
359
|
+
// and get the loud build-time error.
|
|
360
|
+
if (!options?.siblingTemplatesRegistered) {
|
|
361
|
+
this.checkImportedLoopChildComponents(ir)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const hasInteractivity = this.hasClientInteractivity(ir)
|
|
365
|
+
const isRootComponent = ir.root.type === 'component'
|
|
366
|
+
const isIfStatement = ir.root.type === 'if-statement'
|
|
367
|
+
|
|
368
|
+
const templateBody = isIfStatement
|
|
369
|
+
? this.renderIfStatement(ir.root as IRIfStatement, { isRootOfClientComponent: hasInteractivity })
|
|
370
|
+
: this.renderNode(ir.root, { isRootOfClientComponent: hasInteractivity && isRootComponent })
|
|
371
|
+
|
|
372
|
+
// Generate script registration code at template start (unless skipped)
|
|
373
|
+
const scriptRegistrations = options?.skipScriptRegistration
|
|
374
|
+
? ''
|
|
375
|
+
: this.generateScriptRegistrations(ir, options?.scriptBaseName)
|
|
376
|
+
|
|
377
|
+
const template = `{{define "${this.componentName}"}}\n${scriptRegistrations}${templateBody}\n{{end}}\n`
|
|
378
|
+
const types = this.generateTypes(ir)
|
|
379
|
+
|
|
380
|
+
// Merge collected errors into IR errors
|
|
381
|
+
if (this.errors.length > 0) {
|
|
382
|
+
ir.errors.push(...this.errors)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Go templates have no JS-style imports / types / default-export sections;
|
|
386
|
+
// the entire `{{define}}…{{end}}` block is the component body. The compiler
|
|
387
|
+
// assembles multi-component files by concatenating the `component` parts.
|
|
388
|
+
const sections: TemplateSections = {
|
|
389
|
+
imports: '',
|
|
390
|
+
types: '',
|
|
391
|
+
component: template,
|
|
392
|
+
defaultExport: '',
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
template,
|
|
397
|
+
sections,
|
|
398
|
+
types: types || undefined,
|
|
399
|
+
extension: this.extension,
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Check if a component has client interactivity (needs client JS).
|
|
405
|
+
* A component has client interactivity if it has:
|
|
406
|
+
* - Signals (reactive state)
|
|
407
|
+
* - Effects (side effects)
|
|
408
|
+
* - Events on elements
|
|
409
|
+
*/
|
|
410
|
+
private hasClientInteractivity(ir: ComponentIR): boolean {
|
|
411
|
+
// Check for signals
|
|
412
|
+
if (ir.metadata.signals.length > 0) return true
|
|
413
|
+
|
|
414
|
+
// Check for effects
|
|
415
|
+
if (ir.metadata.effects.length > 0) return true
|
|
416
|
+
|
|
417
|
+
// Check for onMounts
|
|
418
|
+
if (ir.metadata.onMounts.length > 0) return true
|
|
419
|
+
|
|
420
|
+
// Check for events in the IR tree
|
|
421
|
+
if (this.hasEventsInTree(ir.root)) return true
|
|
422
|
+
|
|
423
|
+
// Check for child components (they need parent's hydration)
|
|
424
|
+
if (this.findChildComponentNames(ir.root).size > 0) return true
|
|
425
|
+
|
|
426
|
+
return false
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Recursively check if any element in the tree has events.
|
|
431
|
+
*/
|
|
432
|
+
private hasEventsInTree(node: IRNode): boolean {
|
|
433
|
+
if (node.type === 'element') {
|
|
434
|
+
const element = node as IRElement
|
|
435
|
+
if (element.events.length > 0) return true
|
|
436
|
+
for (const child of element.children) {
|
|
437
|
+
if (this.hasEventsInTree(child)) return true
|
|
438
|
+
}
|
|
439
|
+
} else if (node.type === 'fragment') {
|
|
440
|
+
const fragment = node as IRFragment
|
|
441
|
+
for (const child of fragment.children) {
|
|
442
|
+
if (this.hasEventsInTree(child)) return true
|
|
443
|
+
}
|
|
444
|
+
} else if (node.type === 'conditional') {
|
|
445
|
+
const cond = node as IRConditional
|
|
446
|
+
if (this.hasEventsInTree(cond.whenTrue)) return true
|
|
447
|
+
if (cond.whenFalse && this.hasEventsInTree(cond.whenFalse)) return true
|
|
448
|
+
} else if (node.type === 'loop') {
|
|
449
|
+
const loop = node as IRLoop
|
|
450
|
+
for (const child of loop.children) {
|
|
451
|
+
if (this.hasEventsInTree(child)) return true
|
|
452
|
+
}
|
|
453
|
+
} else if (node.type === 'if-statement') {
|
|
454
|
+
const ifStmt = node as IRIfStatement
|
|
455
|
+
if (this.hasEventsInTree(ifStmt.consequent)) return true
|
|
456
|
+
if (ifStmt.alternate && this.hasEventsInTree(ifStmt.alternate)) return true
|
|
457
|
+
}
|
|
458
|
+
return false
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Find all child component names used in the IR tree.
|
|
463
|
+
*/
|
|
464
|
+
private findChildComponentNames(node: IRNode): Set<string> {
|
|
465
|
+
const names = new Set<string>()
|
|
466
|
+
this.collectChildComponentNames(node, names)
|
|
467
|
+
return names
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private collectChildComponentNames(node: IRNode, names: Set<string>): void {
|
|
471
|
+
if (node.type === 'component') {
|
|
472
|
+
const comp = node as IRComponent
|
|
473
|
+
names.add(comp.name)
|
|
474
|
+
} else if (node.type === 'element') {
|
|
475
|
+
const element = node as IRElement
|
|
476
|
+
for (const child of element.children) {
|
|
477
|
+
this.collectChildComponentNames(child, names)
|
|
478
|
+
}
|
|
479
|
+
} else if (node.type === 'fragment') {
|
|
480
|
+
const fragment = node as IRFragment
|
|
481
|
+
for (const child of fragment.children) {
|
|
482
|
+
this.collectChildComponentNames(child, names)
|
|
483
|
+
}
|
|
484
|
+
} else if (node.type === 'conditional') {
|
|
485
|
+
const cond = node as IRConditional
|
|
486
|
+
this.collectChildComponentNames(cond.whenTrue, names)
|
|
487
|
+
if (cond.whenFalse) {
|
|
488
|
+
this.collectChildComponentNames(cond.whenFalse, names)
|
|
489
|
+
}
|
|
490
|
+
} else if (node.type === 'loop') {
|
|
491
|
+
const loop = node as IRLoop
|
|
492
|
+
for (const child of loop.children) {
|
|
493
|
+
this.collectChildComponentNames(child, names)
|
|
494
|
+
}
|
|
495
|
+
} else if (node.type === 'if-statement') {
|
|
496
|
+
const ifStmt = node as IRIfStatement
|
|
497
|
+
this.collectChildComponentNames(ifStmt.consequent, names)
|
|
498
|
+
if (ifStmt.alternate) {
|
|
499
|
+
this.collectChildComponentNames(ifStmt.alternate, names)
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Push a `BF103` diagnostic for every component reference inside a
|
|
506
|
+
* loop body whose name is imported from a relative-path module
|
|
507
|
+
* (i.e. a sibling .tsx file). The Go adapter renders these as
|
|
508
|
+
* `{{template "X" .}}` calls, which Go's template engine resolves
|
|
509
|
+
* only against templates registered on the same `*template.Template`
|
|
510
|
+
* — so a user who factored a list item into `./list-item.tsx` and
|
|
511
|
+
* mapped over it gets a working build and a `template: "X" is
|
|
512
|
+
* undefined` at request time. Surfacing this at build time matches
|
|
513
|
+
* the louder-over-silent contract (#1266).
|
|
514
|
+
*
|
|
515
|
+
* Scoped to loop bodies because that's the natural Hono-style
|
|
516
|
+
* pattern the issue calls out; static (non-loop) usage of imported
|
|
517
|
+
* components is left alone so existing static-layout patterns
|
|
518
|
+
* keep working without noise.
|
|
519
|
+
*/
|
|
520
|
+
private checkImportedLoopChildComponents(ir: ComponentIR): void {
|
|
521
|
+
// Collect every name imported from a relative-path module (no
|
|
522
|
+
// case filter — `IRComponent` nodes only exist for PascalCase JSX
|
|
523
|
+
// usages, so a lowercase utility import in the set can't match
|
|
524
|
+
// anyway, and any heuristic on the import name itself would be
|
|
525
|
+
// strictly less robust than the structural IR check below).
|
|
526
|
+
const relativeImports = new Set<string>()
|
|
527
|
+
for (const imp of ir.metadata.templateImports ?? ir.metadata.imports ?? []) {
|
|
528
|
+
if (!imp.source.startsWith('./') && !imp.source.startsWith('../')) continue
|
|
529
|
+
if (imp.isTypeOnly) continue
|
|
530
|
+
for (const spec of imp.specifiers) {
|
|
531
|
+
relativeImports.add(spec.alias ?? spec.name)
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (relativeImports.size === 0) return
|
|
535
|
+
|
|
536
|
+
const visit = (node: IRNode, inLoop: boolean): void => {
|
|
537
|
+
switch (node.type) {
|
|
538
|
+
case 'component': {
|
|
539
|
+
const comp = node as IRComponent
|
|
540
|
+
if (inLoop && relativeImports.has(comp.name)) {
|
|
541
|
+
this.errors.push({
|
|
542
|
+
code: 'BF103',
|
|
543
|
+
severity: 'error',
|
|
544
|
+
message: `Component <${comp.name}> is imported from a sibling module and used inside a loop. The Go template adapter emits a cross-template call ({{template "${comp.name}" .}}); the child template must be registered on the same *template.Template instance at render time.`,
|
|
545
|
+
loc: comp.loc ?? this.makeLoc(),
|
|
546
|
+
suggestion: {
|
|
547
|
+
message:
|
|
548
|
+
`Options:\n` +
|
|
549
|
+
` 1. Compile '${comp.name}' (its source file) with the same adapter and register the resulting {{define "${comp.name}"}} on the same *template.Template instance at render time.\n` +
|
|
550
|
+
` 2. Inline <${comp.name}> directly inside the loop body so no cross-file template lookup is needed.\n` +
|
|
551
|
+
` 3. Mark the loop position as @client-only so the template is materialised on the client instead of at SSR time.`,
|
|
552
|
+
},
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
for (const child of comp.children) visit(child, inLoop)
|
|
556
|
+
break
|
|
557
|
+
}
|
|
558
|
+
case 'element': {
|
|
559
|
+
const el = node as IRElement
|
|
560
|
+
for (const child of el.children) visit(child, inLoop)
|
|
561
|
+
break
|
|
562
|
+
}
|
|
563
|
+
case 'fragment': {
|
|
564
|
+
const frag = node as IRFragment
|
|
565
|
+
for (const child of frag.children) visit(child, inLoop)
|
|
566
|
+
break
|
|
567
|
+
}
|
|
568
|
+
case 'conditional': {
|
|
569
|
+
const cond = node as IRConditional
|
|
570
|
+
visit(cond.whenTrue, inLoop)
|
|
571
|
+
if (cond.whenFalse) visit(cond.whenFalse, inLoop)
|
|
572
|
+
break
|
|
573
|
+
}
|
|
574
|
+
case 'loop': {
|
|
575
|
+
const loop = node as IRLoop
|
|
576
|
+
for (const child of loop.children) visit(child, true)
|
|
577
|
+
break
|
|
578
|
+
}
|
|
579
|
+
case 'if-statement': {
|
|
580
|
+
const stmt = node as IRIfStatement
|
|
581
|
+
visit(stmt.consequent, inLoop)
|
|
582
|
+
if (stmt.alternate) visit(stmt.alternate, inLoop)
|
|
583
|
+
break
|
|
584
|
+
}
|
|
585
|
+
case 'provider': {
|
|
586
|
+
const p = node as IRProvider
|
|
587
|
+
for (const child of p.children) visit(child, inLoop)
|
|
588
|
+
break
|
|
589
|
+
}
|
|
590
|
+
case 'async': {
|
|
591
|
+
const a = node as IRAsync
|
|
592
|
+
visit(a.fallback, inLoop)
|
|
593
|
+
for (const child of a.children) visit(child, inLoop)
|
|
594
|
+
break
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
visit(ir.root, false)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Generate script registration code for the template.
|
|
603
|
+
* Scripts are registered at the beginning of the template.
|
|
604
|
+
* Uses .Scripts which is available on all Props structs.
|
|
605
|
+
* The same ScriptCollector should be shared across parent and child props.
|
|
606
|
+
* Wrapped in {{if .Scripts}} to safely handle nil Scripts.
|
|
607
|
+
*/
|
|
608
|
+
private generateScriptRegistrations(ir: ComponentIR, scriptBaseName?: string): string {
|
|
609
|
+
// Check if this component has client interactivity
|
|
610
|
+
const hasInteractivity = this.hasClientInteractivity(ir)
|
|
611
|
+
|
|
612
|
+
if (!hasInteractivity) {
|
|
613
|
+
return ''
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const registrations: string[] = []
|
|
617
|
+
|
|
618
|
+
// Register barefoot.js runtime first
|
|
619
|
+
registrations.push(`{{.Scripts.Register "${this.options.barefootJsPath}"}}`)
|
|
620
|
+
|
|
621
|
+
// Register this component's script
|
|
622
|
+
// Use scriptBaseName if provided (for non-default exports sharing parent's .client.js)
|
|
623
|
+
const scriptName = scriptBaseName || ir.metadata.componentName
|
|
624
|
+
registrations.push(`{{.Scripts.Register "${this.options.clientJsBasePath}${scriptName}.client.js"}}`)
|
|
625
|
+
|
|
626
|
+
// Wrap in nil check to safely handle cases where Scripts is not set
|
|
627
|
+
return `{{if .Scripts}}${registrations.join('')}{{end}}\n`
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
generateTypes(ir: ComponentIR): string | null {
|
|
631
|
+
this.usesHtmlTemplate = false
|
|
632
|
+
const lines: string[] = []
|
|
633
|
+
|
|
634
|
+
const componentName = ir.metadata.componentName
|
|
635
|
+
|
|
636
|
+
// Build set of locally-defined type names and aliases so typeInfoToGo can resolve them
|
|
637
|
+
this.localTypeNames = new Set<string>()
|
|
638
|
+
this.localTypeAliases = new Map<string, string>()
|
|
639
|
+
for (const td of ir.metadata.typeDefinitions) {
|
|
640
|
+
// Skip the Props type itself (it's the component's own props, not a reusable type)
|
|
641
|
+
if (td.name === 'Props' || td.name === `${componentName}Props`) continue
|
|
642
|
+
// Skip child component Props — they are generated by the child's own generatePropsStruct()
|
|
643
|
+
if (td.name.endsWith('Props')) continue
|
|
644
|
+
this.localTypeNames.add(td.name)
|
|
645
|
+
// Track string literal union aliases (e.g., type Filter = 'all' | 'active')
|
|
646
|
+
if (td.definition.match(/^type \w+ = ('[^']*'(\s*\|\s*'[^']*')*)/)) {
|
|
647
|
+
this.localTypeAliases.set(td.name, 'string')
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Generate Go structs for local type definitions (e.g., Todo, Filter → string alias)
|
|
652
|
+
for (const td of ir.metadata.typeDefinitions) {
|
|
653
|
+
if (td.name === 'Props' || td.name === `${componentName}Props`) continue
|
|
654
|
+
if (td.name.endsWith('Props')) continue
|
|
655
|
+
const goStruct = this.typeDefinitionToGo(td)
|
|
656
|
+
if (goStruct) {
|
|
657
|
+
lines.push(goStruct)
|
|
658
|
+
lines.push('')
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Find nested components (loops with childComponent)
|
|
663
|
+
const nestedComponents = this.findNestedComponents(ir.root)
|
|
664
|
+
|
|
665
|
+
// Build prop type overrides from signal types
|
|
666
|
+
const propTypeOverrides = this.buildPropTypeOverrides(ir)
|
|
667
|
+
|
|
668
|
+
// Compute spread slot info once and thread it through all three
|
|
669
|
+
// generators — `collectSpreadSlots` walks the IR tree, so caching
|
|
670
|
+
// here saves repeated walks (#1411 review). `spreadSlots` also
|
|
671
|
+
// controls whether `generateInputStruct` adds a
|
|
672
|
+
// `Spread_<N> map[string]any` field for `input-bag` slots so the
|
|
673
|
+
// caller can populate the open-ended restPropsName spread bag
|
|
674
|
+
// (#1407 follow-up).
|
|
675
|
+
const spreadSlots = this.collectSpreadSlots(ir.root)
|
|
676
|
+
|
|
677
|
+
// Generate Input struct for main component
|
|
678
|
+
this.generateInputStruct(lines, ir, componentName, nestedComponents, propTypeOverrides, spreadSlots)
|
|
679
|
+
|
|
680
|
+
// Generate Props struct for main component
|
|
681
|
+
this.generatePropsStruct(lines, ir, componentName, nestedComponents, propTypeOverrides, spreadSlots)
|
|
682
|
+
|
|
683
|
+
// Generate NewXxxProps function
|
|
684
|
+
this.generateNewPropsFunction(lines, ir, componentName, nestedComponents, spreadSlots)
|
|
685
|
+
|
|
686
|
+
// Imports come at the top, but `usesHtmlTemplate` is only known
|
|
687
|
+
// after the body has been generated. Compose package + imports +
|
|
688
|
+
// body once everything has been collected.
|
|
689
|
+
const header: string[] = []
|
|
690
|
+
header.push(`package ${this.options.packageName}`)
|
|
691
|
+
header.push('')
|
|
692
|
+
header.push('import (')
|
|
693
|
+
if (this.usesHtmlTemplate) header.push('\t"html/template"')
|
|
694
|
+
header.push('\t"math/rand"')
|
|
695
|
+
header.push('')
|
|
696
|
+
header.push('\tbf "github.com/barefootjs/runtime/bf"')
|
|
697
|
+
header.push(')')
|
|
698
|
+
header.push('')
|
|
699
|
+
|
|
700
|
+
return [...header, ...lines].join('\n')
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Convert a TypeScript type definition to a Go type.
|
|
705
|
+
* Handles object types → Go structs, and union string literals → string alias.
|
|
706
|
+
*/
|
|
707
|
+
private typeDefinitionToGo(td: { kind: string; name: string; definition: string }): string | null {
|
|
708
|
+
const def = td.definition
|
|
709
|
+
|
|
710
|
+
// String literal union: type Filter = 'all' | 'active' | 'completed'
|
|
711
|
+
if (def.match(/^type \w+ = ('[^']*'(\s*\|\s*'[^']*')*)/)) {
|
|
712
|
+
// Map to Go string (union of string literals → just string in Go)
|
|
713
|
+
return `// ${td.name} is a string type.\ntype ${td.name} = string`
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Object/interface type: type Todo = { id: number; text: string; ... }
|
|
717
|
+
const bodyMatch = def.match(/(?:type \w+ = |interface \w+ )\{([\s\S]*)\}/)
|
|
718
|
+
if (!bodyMatch) return null
|
|
719
|
+
|
|
720
|
+
const body = bodyMatch[1]
|
|
721
|
+
const goFields: string[] = []
|
|
722
|
+
|
|
723
|
+
// Parse each field: "fieldName: type" or "fieldName?: type"
|
|
724
|
+
// Handle both semicolon-separated and newline-separated
|
|
725
|
+
const fieldEntries = body.split(/[;\n]/).map(s => s.trim()).filter(Boolean)
|
|
726
|
+
for (const entry of fieldEntries) {
|
|
727
|
+
const fieldMatch = entry.match(/^(\w+)\??\s*:\s*(.+)$/)
|
|
728
|
+
if (!fieldMatch) continue
|
|
729
|
+
const [, fieldName, tsType] = fieldMatch
|
|
730
|
+
const goFieldName = this.capitalizeFieldName(fieldName)
|
|
731
|
+
const goType = this.tsTypeStringToGo(tsType.trim())
|
|
732
|
+
const jsonTag = this.toJsonTag(fieldName)
|
|
733
|
+
goFields.push(`\t${goFieldName} ${goType} \`json:"${jsonTag}"\``)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (goFields.length === 0) return null
|
|
737
|
+
|
|
738
|
+
return `// ${td.name} represents a ${td.name.toLowerCase()}.\ntype ${td.name} struct {\n${goFields.join('\n')}\n}`
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Convert a raw TypeScript type string to a Go type string.
|
|
743
|
+
* Handles primitives (number, string, boolean) and basic arrays.
|
|
744
|
+
*/
|
|
745
|
+
private tsTypeStringToGo(tsType: string): string {
|
|
746
|
+
const t = tsType.trim()
|
|
747
|
+
if (t === 'number') return 'int'
|
|
748
|
+
if (t === 'string') return 'string'
|
|
749
|
+
if (t === 'boolean' || t === 'bool') return 'bool'
|
|
750
|
+
if (t.endsWith('[]')) {
|
|
751
|
+
const elem = t.slice(0, -2)
|
|
752
|
+
return `[]${this.tsTypeStringToGo(elem)}`
|
|
753
|
+
}
|
|
754
|
+
const arrayMatch = t.match(/^Array<(.+)>$/)
|
|
755
|
+
if (arrayMatch) return `[]${this.tsTypeStringToGo(arrayMatch[1])}`
|
|
756
|
+
// Check if it's a known local type
|
|
757
|
+
if (this.localTypeNames.has(t)) return t
|
|
758
|
+
return 'interface{}'
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Build a map from prop name to a better Go type inferred from signals.
|
|
763
|
+
* When a signal is initialized from a prop (e.g., createSignal(props.initial ?? 0)),
|
|
764
|
+
* the signal's type annotation may be more specific than the prop's TypeInfo.
|
|
765
|
+
*/
|
|
766
|
+
private buildPropTypeOverrides(ir: ComponentIR): Map<string, string> {
|
|
767
|
+
const overrides = new Map<string, string>()
|
|
768
|
+
for (const signal of ir.metadata.signals) {
|
|
769
|
+
// Check simple identifier reference
|
|
770
|
+
const propNames = [signal.initialValue]
|
|
771
|
+
const extracted = this.extractPropNameFromInitialValue(signal.initialValue)
|
|
772
|
+
if (extracted) propNames.push(extracted)
|
|
773
|
+
|
|
774
|
+
for (const propName of propNames) {
|
|
775
|
+
const param = ir.metadata.propsParams.find(p => p.name === propName)
|
|
776
|
+
if (!param) continue
|
|
777
|
+
const propGoType = this.typeInfoToGo(param.type, param.defaultValue)
|
|
778
|
+
// Override when prop type is generic (interface{} or contains interface{})
|
|
779
|
+
if (propGoType.includes('interface{}')) {
|
|
780
|
+
const signalGoType = this.typeInfoToGo(signal.type, signal.initialValue)
|
|
781
|
+
if (!signalGoType.includes('interface{}')) {
|
|
782
|
+
overrides.set(propName, signalGoType)
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return overrides
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Generate Input struct for a component
|
|
792
|
+
*/
|
|
793
|
+
private generateInputStruct(
|
|
794
|
+
lines: string[],
|
|
795
|
+
ir: ComponentIR,
|
|
796
|
+
componentName: string,
|
|
797
|
+
nestedComponents: NestedComponentInfo[],
|
|
798
|
+
propTypeOverrides: Map<string, string>,
|
|
799
|
+
spreadSlots: SpreadSlotInfo[]
|
|
800
|
+
): void {
|
|
801
|
+
const inputTypeName = `${componentName}Input`
|
|
802
|
+
lines.push(`// ${inputTypeName} is the user-facing input type.`)
|
|
803
|
+
lines.push(`type ${inputTypeName} struct {`)
|
|
804
|
+
lines.push('\tScopeID string // Optional: if empty, random ID is generated')
|
|
805
|
+
// (#1249) Slot identity for child scopes mounted as a slot of an
|
|
806
|
+
// outer component. Forwarded to Props's BfParent / BfMount.
|
|
807
|
+
lines.push('\tBfParent string // Optional: parent scope id')
|
|
808
|
+
lines.push('\tBfMount string // Optional: slot id in parent')
|
|
809
|
+
|
|
810
|
+
// Static nested components appear in Input; dynamic ones are template-only
|
|
811
|
+
const staticNested = nestedComponents.filter(n => !n.isDynamic)
|
|
812
|
+
|
|
813
|
+
// Collect nested component array field names to skip from propsParams
|
|
814
|
+
const nestedArrayFields = new Set(nestedComponents.map(n => `${n.name}s`))
|
|
815
|
+
|
|
816
|
+
// Add props params (excluding nested array fields)
|
|
817
|
+
for (const param of ir.metadata.propsParams) {
|
|
818
|
+
const fieldName = this.capitalizeFieldName(param.name)
|
|
819
|
+
if (nestedArrayFields.has(fieldName)) continue
|
|
820
|
+
const goType = propTypeOverrides.get(param.name) ?? this.typeInfoToGo(param.type, param.defaultValue)
|
|
821
|
+
lines.push(`\t${fieldName} ${goType}`)
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Add nested component input arrays (static only)
|
|
825
|
+
for (const nested of staticNested) {
|
|
826
|
+
lines.push(`\t${nested.name}s []${nested.name}Input`)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// (#1407 follow-up) Input-side bag field for restPropsName spreads.
|
|
830
|
+
// The destructured-rest pattern
|
|
831
|
+
// (`function({a, ...rest}: P) { <el {...rest}/> }`) surfaces
|
|
832
|
+
// as a `bagSource: 'input-bag'` slot — Go's static typing
|
|
833
|
+
// can't enumerate the open-ended key set, so the caller passes
|
|
834
|
+
// the bag as a `map[string]any` field. The field is named
|
|
835
|
+
// after the JS-side rest binding (`rest` → `Rest`) so
|
|
836
|
+
// callers — `parent.NewXxxProps(XxxInput{Rest: ...})`
|
|
837
|
+
// construction sites, including the bun-test harness — can
|
|
838
|
+
// address it by the same identifier they used in source. The
|
|
839
|
+
// JSON tag uses the rest binding name too so JSON round-trips
|
|
840
|
+
// line up.
|
|
841
|
+
const restPropsName = ir.metadata.restPropsName
|
|
842
|
+
if (restPropsName) {
|
|
843
|
+
const seen = new Set<string>()
|
|
844
|
+
for (const slot of spreadSlots) {
|
|
845
|
+
if (slot.bagSource !== 'input-bag') continue
|
|
846
|
+
const fieldName = this.capitalizeFieldName(restPropsName)
|
|
847
|
+
if (seen.has(fieldName)) continue
|
|
848
|
+
seen.add(fieldName)
|
|
849
|
+
const jsonTag = this.toJsonTag(restPropsName)
|
|
850
|
+
lines.push(`\t${fieldName} map[string]any \`json:"${jsonTag}"\``)
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
lines.push('}')
|
|
855
|
+
lines.push('')
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Generate Props struct for a component
|
|
860
|
+
*/
|
|
861
|
+
private generatePropsStruct(
|
|
862
|
+
lines: string[],
|
|
863
|
+
ir: ComponentIR,
|
|
864
|
+
componentName: string,
|
|
865
|
+
nestedComponents: NestedComponentInfo[],
|
|
866
|
+
propTypeOverrides: Map<string, string>,
|
|
867
|
+
spreadSlots: SpreadSlotInfo[]
|
|
868
|
+
): void {
|
|
869
|
+
const propsTypeName = `${componentName}Props`
|
|
870
|
+
lines.push(`// ${propsTypeName} is the props type for the ${componentName} component.`)
|
|
871
|
+
lines.push(`type ${propsTypeName} struct {`)
|
|
872
|
+
lines.push('\tScopeID string `json:"scopeID"`')
|
|
873
|
+
lines.push('\tBfIsRoot bool `json:"-"`')
|
|
874
|
+
lines.push('\tBfIsChild bool `json:"-"`')
|
|
875
|
+
// (#1249) Slot identity for child scopes: host scope id + slot id.
|
|
876
|
+
// Emitted as bf-h / bf-m HTML attributes by `bfHydrationAttrs`.
|
|
877
|
+
lines.push('\tBfParent string `json:"-"`')
|
|
878
|
+
lines.push('\tBfMount string `json:"-"`')
|
|
879
|
+
|
|
880
|
+
// Add Scripts field for dynamic script collection
|
|
881
|
+
lines.push('\tScripts *bf.ScriptCollector `json:"-"`')
|
|
882
|
+
|
|
883
|
+
// Collect nested component array field names to skip from propsParams
|
|
884
|
+
const nestedArrayFields = new Set(nestedComponents.map(n => `${n.name}s`))
|
|
885
|
+
|
|
886
|
+
// Track emitted prop field names to avoid duplicate fields when signal name matches prop name
|
|
887
|
+
const propFieldNames = new Set<string>()
|
|
888
|
+
|
|
889
|
+
for (const param of ir.metadata.propsParams) {
|
|
890
|
+
const fieldName = this.capitalizeFieldName(param.name)
|
|
891
|
+
// Skip if this field will be replaced by a typed array for nested components
|
|
892
|
+
if (nestedArrayFields.has(fieldName)) continue
|
|
893
|
+
const goType = propTypeOverrides.get(param.name) ?? this.typeInfoToGo(param.type, param.defaultValue)
|
|
894
|
+
const jsonTag = this.toJsonTag(param.name)
|
|
895
|
+
lines.push(`\t${fieldName} ${goType} \`json:"${jsonTag}"\``)
|
|
896
|
+
propFieldNames.add(fieldName)
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Find signal types by looking at their initial values
|
|
900
|
+
const propsParamMap = new Map(ir.metadata.propsParams.map(p => [p.name, p]))
|
|
901
|
+
|
|
902
|
+
for (const signal of ir.metadata.signals) {
|
|
903
|
+
const fieldName = this.capitalizeFieldName(signal.getter)
|
|
904
|
+
// Skip if a prop field with the same name was already emitted
|
|
905
|
+
if (propFieldNames.has(fieldName)) continue
|
|
906
|
+
const jsonTag = this.toJsonTag(signal.getter)
|
|
907
|
+
// Infer type from initial value or referenced prop's type
|
|
908
|
+
let goType: string
|
|
909
|
+
let referencedProp = propsParamMap.get(signal.initialValue)
|
|
910
|
+
if (!referencedProp) {
|
|
911
|
+
const propName = this.extractPropNameFromInitialValue(signal.initialValue)
|
|
912
|
+
if (propName) referencedProp = propsParamMap.get(propName)
|
|
913
|
+
}
|
|
914
|
+
if (referencedProp) {
|
|
915
|
+
const propGoType = this.typeInfoToGo(referencedProp.type, referencedProp.defaultValue)
|
|
916
|
+
const signalGoType = this.typeInfoToGo(signal.type, signal.initialValue)
|
|
917
|
+
// The "prop type wins" heuristic exists for cases where the
|
|
918
|
+
// signal infer is less specific than the prop (e.g. the signal
|
|
919
|
+
// is `createSignal(props.todos)` and we want `[]Todo`, not
|
|
920
|
+
// `interface{}`). It actively HURTS when the initial expression
|
|
921
|
+
// transforms the prop type — `createSignal((props.todos ?? []).length)`
|
|
922
|
+
// is a `number`, not the prop's `[]Todo`. Let a specific signal
|
|
923
|
+
// type override a less-specific prop type in either direction
|
|
924
|
+
// so `.length` / `.some()` / `.every()` chains land on their
|
|
925
|
+
// actual Go type (#1442 echo TodoApp repro).
|
|
926
|
+
if (propGoType.includes('interface{}')) {
|
|
927
|
+
goType = signalGoType
|
|
928
|
+
} else if (
|
|
929
|
+
!signalGoType.includes('interface{}') &&
|
|
930
|
+
signalGoType !== propGoType
|
|
931
|
+
) {
|
|
932
|
+
// Both sides resolved, but they disagree — trust the signal's
|
|
933
|
+
// inferred shape (it's based on the literal expression text,
|
|
934
|
+
// including the trailing accessor).
|
|
935
|
+
goType = signalGoType
|
|
936
|
+
} else {
|
|
937
|
+
goType = propGoType
|
|
938
|
+
}
|
|
939
|
+
} else {
|
|
940
|
+
goType = this.typeInfoToGo(signal.type, signal.initialValue)
|
|
941
|
+
}
|
|
942
|
+
lines.push(`\t${fieldName} ${goType} \`json:"${jsonTag}"\``)
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Add memos to Props (they are computed values needed for SSR)
|
|
946
|
+
for (const memo of ir.metadata.memos) {
|
|
947
|
+
const fieldName = this.capitalizeFieldName(memo.name)
|
|
948
|
+
const jsonTag = this.toJsonTag(memo.name)
|
|
949
|
+
// Memos that depend on number signals are usually numbers
|
|
950
|
+
const goType = this.inferMemoType(memo, ir.metadata.signals, propsParamMap)
|
|
951
|
+
lines.push(`\t${fieldName} ${goType} \`json:"${jsonTag}"\``)
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Add array fields for nested components (for template rendering)
|
|
955
|
+
for (const nested of nestedComponents) {
|
|
956
|
+
if (nested.isDynamic) {
|
|
957
|
+
// Dynamic (signal) array loops: template-only, not in JSON
|
|
958
|
+
lines.push(`\t${nested.name}s []${nested.name}Props \`json:"-"\``)
|
|
959
|
+
} else {
|
|
960
|
+
const jsonTag = this.toJsonTag(`${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`)
|
|
961
|
+
lines.push(`\t${nested.name}s []${nested.name}Props \`json:"${jsonTag}"\``)
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// Add fields for static child component instances
|
|
966
|
+
const staticChildren = this.collectStaticChildInstances(ir.root)
|
|
967
|
+
for (const child of staticChildren) {
|
|
968
|
+
lines.push(`\t${child.fieldName} ${child.name}Props \`json:"-"\``)
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// (#1407) Add fields for top-level JSX intrinsic-element spreads.
|
|
972
|
+
// Each non-loop spread gets a `Spread_<slotId> map[string]any`
|
|
973
|
+
// field; the Go template references it as `.Spread_<slotId>` via
|
|
974
|
+
// `{{bf_spread_attrs}}`. Loop-internal spreads emit inline and
|
|
975
|
+
// don't appear here. The slot list is computed once in
|
|
976
|
+
// `generateTypes` and threaded through both struct/init emitters
|
|
977
|
+
// so the IR walk runs exactly once per `generate()` call (#1411
|
|
978
|
+
// review).
|
|
979
|
+
for (const slot of spreadSlots) {
|
|
980
|
+
const jsonTag = this.toJsonTag(slot.slotId)
|
|
981
|
+
lines.push(`\t${slot.slotId} map[string]any \`json:"${jsonTag}"\``)
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
lines.push('}')
|
|
985
|
+
lines.push('')
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Generate NewXxxProps function
|
|
990
|
+
*/
|
|
991
|
+
private generateNewPropsFunction(
|
|
992
|
+
lines: string[],
|
|
993
|
+
ir: ComponentIR,
|
|
994
|
+
componentName: string,
|
|
995
|
+
nestedComponents: NestedComponentInfo[],
|
|
996
|
+
spreadSlots: SpreadSlotInfo[]
|
|
997
|
+
): void {
|
|
998
|
+
const inputTypeName = `${componentName}Input`
|
|
999
|
+
const propsTypeName = `${componentName}Props`
|
|
1000
|
+
|
|
1001
|
+
// Surface the "dynamic loop slices stay empty until the handler
|
|
1002
|
+
// populates them" rule as a doc comment above the generator, with
|
|
1003
|
+
// a concrete example per child component. Without it the contract
|
|
1004
|
+
// is implicit: `TodoAppProps` carries a `TodoItems []TodoItemProps`
|
|
1005
|
+
// field, the SSR template iterates over it, but
|
|
1006
|
+
// `NewTodoAppProps(TodoAppInput{Initial: ...})` returns it empty
|
|
1007
|
+
// and the page renders a blank list (#1442 echo TodoApp repro).
|
|
1008
|
+
const dynamicNested = nestedComponents.filter(n => n.isDynamic)
|
|
1009
|
+
lines.push(`// New${componentName}Props creates ${propsTypeName} from ${inputTypeName}.`)
|
|
1010
|
+
for (const nested of dynamicNested) {
|
|
1011
|
+
const arrayField = `${nested.name}s`
|
|
1012
|
+
lines.push(`//`)
|
|
1013
|
+
lines.push(`// NOTE: \`${arrayField}\` is populated by the route handler, not by`)
|
|
1014
|
+
lines.push(`// New${componentName}Props — the SSR template iterates over it`)
|
|
1015
|
+
lines.push(`// dynamically (\`.${arrayField}\`). Build the slice from your source data and`)
|
|
1016
|
+
lines.push(`// assign it before passing the props to your renderer. Example:`)
|
|
1017
|
+
lines.push(`//`)
|
|
1018
|
+
lines.push(`// props := New${componentName}Props(${inputTypeName}{ /* ... */ })`)
|
|
1019
|
+
lines.push(`// props.${arrayField} = make([]${nested.name}Props, len(items))`)
|
|
1020
|
+
lines.push(`// for i, item := range items {`)
|
|
1021
|
+
lines.push(`// props.${arrayField}[i] = New${nested.name}Props(${nested.name}Input{ /* fields */ })`)
|
|
1022
|
+
lines.push(`// props.${arrayField}[i].BfParent = props.ScopeID`)
|
|
1023
|
+
lines.push(`// props.${arrayField}[i].BfMount = "${nested.slotId}"`)
|
|
1024
|
+
lines.push(`// }`)
|
|
1025
|
+
}
|
|
1026
|
+
lines.push(`func New${componentName}Props(in ${inputTypeName}) ${propsTypeName} {`)
|
|
1027
|
+
lines.push('\tscopeID := in.ScopeID')
|
|
1028
|
+
lines.push('\tif scopeID == "" {')
|
|
1029
|
+
lines.push(`\t\tscopeID = "${componentName}_" + randomID(6)`)
|
|
1030
|
+
lines.push('\t}')
|
|
1031
|
+
lines.push('')
|
|
1032
|
+
|
|
1033
|
+
// Static nested components only — dynamic ones are set manually by the handler
|
|
1034
|
+
const staticNested = nestedComponents.filter(n => !n.isDynamic)
|
|
1035
|
+
|
|
1036
|
+
// Handle nested components
|
|
1037
|
+
if (staticNested.length > 0) {
|
|
1038
|
+
for (const nested of staticNested) {
|
|
1039
|
+
const varName = `${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`
|
|
1040
|
+
lines.push(`\t${varName} := make([]${nested.name}Props, len(in.${nested.name}s))`)
|
|
1041
|
+
lines.push(`\tfor i, item := range in.${nested.name}s {`)
|
|
1042
|
+
lines.push(`\t\t${varName}[i] = New${nested.name}Props(item)`)
|
|
1043
|
+
// (#1249) Stamp slot identity on each child item so bf-h / bf-m
|
|
1044
|
+
// mark it as a slot-attached child of this scope.
|
|
1045
|
+
lines.push(`\t\t${varName}[i].BfParent = scopeID`)
|
|
1046
|
+
lines.push(`\t\t${varName}[i].BfMount = "${nested.slotId}"`)
|
|
1047
|
+
lines.push('\t}')
|
|
1048
|
+
lines.push('')
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// (#1423) Collect signal-time prop fallbacks: when a signal is
|
|
1053
|
+
// initialized via `createSignal(props.X ?? N)`, hoist `N` as a
|
|
1054
|
+
// local variable so the signal, any memo derived from it, and the
|
|
1055
|
+
// prop field itself all derive from the same fallback-applied
|
|
1056
|
+
// value. Mirrors the Mojo adapter's `ssrDefaults` consumption
|
|
1057
|
+
// (#1419) — Go's primitive zero values can't distinguish an
|
|
1058
|
+
// explicit `Initial: 0` from an omitted field, so the substitution
|
|
1059
|
+
// also fires when the caller passes the type's zero value.
|
|
1060
|
+
const propFallbackVars = this.collectPropFallbackVars(ir)
|
|
1061
|
+
for (const [, info] of propFallbackVars) {
|
|
1062
|
+
lines.push(`\t${info.varName} := in.${info.fieldName}`)
|
|
1063
|
+
lines.push(`\tif ${info.varName} == ${info.zeroLiteral} {`)
|
|
1064
|
+
lines.push(`\t\t${info.varName} = ${info.goFallback}`)
|
|
1065
|
+
lines.push(`\t}`)
|
|
1066
|
+
}
|
|
1067
|
+
if (propFallbackVars.size > 0) lines.push('')
|
|
1068
|
+
|
|
1069
|
+
lines.push(`\treturn ${propsTypeName}{`)
|
|
1070
|
+
lines.push('\t\tScopeID: scopeID,')
|
|
1071
|
+
// (#1249) Forward host context for when *this* component is itself a
|
|
1072
|
+
// slot-attached child of an outer page/component.
|
|
1073
|
+
lines.push('\t\tBfParent: in.BfParent,')
|
|
1074
|
+
lines.push('\t\tBfMount: in.BfMount,')
|
|
1075
|
+
|
|
1076
|
+
// Collect nested component array field names
|
|
1077
|
+
const nestedArrayFields = new Set(nestedComponents.map(n => `${n.name}s`))
|
|
1078
|
+
|
|
1079
|
+
// Add props params, tracking field names to skip duplicate signal assignments.
|
|
1080
|
+
// When the JSX function declared a default (e.g. `variant = 'default'`),
|
|
1081
|
+
// bake that fallback into the generated assignment so a Go zero value
|
|
1082
|
+
// doesn't silently shadow the JSX-side default. The same logic
|
|
1083
|
+
// applies for signal-side fallbacks (`createSignal(props.X ?? N)`)
|
|
1084
|
+
// via the hoisted variable from `propFallbackVars` (#1423).
|
|
1085
|
+
const propFieldNames = new Set<string>()
|
|
1086
|
+
for (const param of ir.metadata.propsParams) {
|
|
1087
|
+
const fieldName = this.capitalizeFieldName(param.name)
|
|
1088
|
+
if (nestedArrayFields.has(fieldName)) continue
|
|
1089
|
+
const hoisted = propFallbackVars.get(param.name)
|
|
1090
|
+
if (hoisted) {
|
|
1091
|
+
lines.push(`\t\t${fieldName}: ${hoisted.varName},`)
|
|
1092
|
+
} else {
|
|
1093
|
+
const fallback = this.goPropDefault(param.defaultValue)
|
|
1094
|
+
if (fallback !== null) {
|
|
1095
|
+
lines.push(`\t\t${fieldName}: ${this.applyGoFallback(`in.${fieldName}`, fallback)},`)
|
|
1096
|
+
} else {
|
|
1097
|
+
lines.push(`\t\t${fieldName}: in.${fieldName},`)
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
propFieldNames.add(fieldName)
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Add signal initial values (skip if prop field with same name already emitted)
|
|
1104
|
+
for (const signal of ir.metadata.signals) {
|
|
1105
|
+
const fieldName = this.capitalizeFieldName(signal.getter)
|
|
1106
|
+
if (propFieldNames.has(fieldName)) continue
|
|
1107
|
+
// (#1423) If this signal's initial value is `props.X ?? N` and we
|
|
1108
|
+
// hoisted a fallback variable for `X`, reuse the hoisted variable
|
|
1109
|
+
// so the signal and any memo computation share the same value.
|
|
1110
|
+
const fallbackMatch = this.extractPropFallback(signal.initialValue)
|
|
1111
|
+
const hoisted = fallbackMatch ? propFallbackVars.get(fallbackMatch.propName) : undefined
|
|
1112
|
+
if (hoisted) {
|
|
1113
|
+
lines.push(`\t\t${fieldName}: ${hoisted.varName},`)
|
|
1114
|
+
} else {
|
|
1115
|
+
const initialValue = this.convertInitialValue(signal.initialValue, signal.type, ir.metadata.propsParams)
|
|
1116
|
+
lines.push(`\t\t${fieldName}: ${initialValue},`)
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// Add nested component arrays (static only; dynamic ones are set by the handler)
|
|
1121
|
+
for (const nested of staticNested) {
|
|
1122
|
+
const varName = `${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`
|
|
1123
|
+
lines.push(`\t\t${nested.name}s: ${varName},`)
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Add memo initial values (computed from signal initial values)
|
|
1127
|
+
for (const memo of ir.metadata.memos) {
|
|
1128
|
+
const fieldName = this.capitalizeFieldName(memo.name)
|
|
1129
|
+
const memoValue = this.computeMemoInitialValue(memo, ir.metadata.signals, ir.metadata.propsParams, propFallbackVars)
|
|
1130
|
+
lines.push(`\t\t${fieldName}: ${memoValue},`)
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Add static child component instances
|
|
1134
|
+
const staticChildren = this.collectStaticChildInstances(ir.root)
|
|
1135
|
+
for (const child of staticChildren) {
|
|
1136
|
+
lines.push(`\t\t${child.fieldName}: New${child.name}Props(${child.name}Input{`)
|
|
1137
|
+
lines.push(`\t\t\tScopeID: scopeID + "_${child.slotId}",`)
|
|
1138
|
+
// (#1249) Slot identity stamps onto the child's Props via its
|
|
1139
|
+
// own NewProps (BfParent/BfMount fields).
|
|
1140
|
+
lines.push(`\t\t\tBfParent: scopeID,`)
|
|
1141
|
+
lines.push(`\t\t\tBfMount: "${child.slotId}",`)
|
|
1142
|
+
// Add prop values
|
|
1143
|
+
for (const prop of child.props) {
|
|
1144
|
+
switch (prop.value.kind) {
|
|
1145
|
+
case 'literal':
|
|
1146
|
+
lines.push(`\t\t\t${this.capitalizeFieldName(prop.name)}: ${this.goLiteral(prop.value.value)},`)
|
|
1147
|
+
break
|
|
1148
|
+
case 'boolean-shorthand':
|
|
1149
|
+
case 'boolean-attr':
|
|
1150
|
+
lines.push(`\t\t\t${this.capitalizeFieldName(prop.name)}: true,`)
|
|
1151
|
+
break
|
|
1152
|
+
case 'expression':
|
|
1153
|
+
case 'spread':
|
|
1154
|
+
case 'template': {
|
|
1155
|
+
// Prefer the parsed template parts when present — `expression`
|
|
1156
|
+
// carries them in `parts` after the IR producer's
|
|
1157
|
+
// `template → expression` collapse for component props, and
|
|
1158
|
+
// `template` exposes them directly. This handles the
|
|
1159
|
+
// shadcn-style variant lookup (`record-index-lookup-via-child-prop`)
|
|
1160
|
+
// which `resolveDynamicPropValue` can't represent.
|
|
1161
|
+
const parts =
|
|
1162
|
+
prop.value.kind === 'template' || prop.value.kind === 'expression'
|
|
1163
|
+
? prop.value.parts
|
|
1164
|
+
: undefined
|
|
1165
|
+
if (parts) {
|
|
1166
|
+
const goExpr = this.templatePartsToGoCode(parts, ir.metadata.propsParams)
|
|
1167
|
+
if (goExpr !== null) {
|
|
1168
|
+
// Parts path succeeded — emit and move on.
|
|
1169
|
+
lines.push(`\t\t\t${this.capitalizeFieldName(prop.name)}: ${goExpr},`)
|
|
1170
|
+
break
|
|
1171
|
+
}
|
|
1172
|
+
// Parts exist but templatePartsToGoCode opted out (unsupported
|
|
1173
|
+
// part kind). Fall through to the bare-expression path below.
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// Bare-expression fallback. `template` kind has no raw expr string
|
|
1177
|
+
// (its JS was discarded in favour of the parts structure), so skip.
|
|
1178
|
+
const exprText = prop.value.kind === 'template' ? '' : prop.value.expr
|
|
1179
|
+
if (!exprText) break
|
|
1180
|
+
const resolvedValue = this.resolveDynamicPropValue(
|
|
1181
|
+
exprText,
|
|
1182
|
+
ir.metadata.signals,
|
|
1183
|
+
ir.metadata.memos,
|
|
1184
|
+
ir.metadata.propsParams
|
|
1185
|
+
)
|
|
1186
|
+
if (resolvedValue !== null) {
|
|
1187
|
+
lines.push(`\t\t\t${this.capitalizeFieldName(prop.name)}: ${resolvedValue},`)
|
|
1188
|
+
}
|
|
1189
|
+
break
|
|
1190
|
+
}
|
|
1191
|
+
case 'jsx-children':
|
|
1192
|
+
// Handled separately via `child.childrenText` / `child.childrenHtml` below.
|
|
1193
|
+
break
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
// Pass through JSX children as the child slot's `Children` input.
|
|
1197
|
+
// Two paths:
|
|
1198
|
+
// 1. Plain text (`<Button>+1</Button>`) → quote with JSON.stringify
|
|
1199
|
+
// to dodge `goLiteral`'s number-detection branch (which would
|
|
1200
|
+
// silently emit `-1` as an int for `<Button>-1</Button>`).
|
|
1201
|
+
// 2. Mixed/HTML (`<Card><span>x</span></Card>`) → wrap in
|
|
1202
|
+
// `template.HTML(...)` so html/template skips re-escaping the
|
|
1203
|
+
// angle brackets at render time. The fragment is rendered up
|
|
1204
|
+
// front via the adapter so any nested template directives are
|
|
1205
|
+
// already in their final Go-template form.
|
|
1206
|
+
if (child.childrenText !== null) {
|
|
1207
|
+
lines.push(`\t\t\tChildren: ${JSON.stringify(child.childrenText)},`)
|
|
1208
|
+
} else if (child.childrenHtml !== null) {
|
|
1209
|
+
this.usesHtmlTemplate = true
|
|
1210
|
+
lines.push(`\t\t\tChildren: template.HTML(${JSON.stringify(child.childrenHtml)}),`)
|
|
1211
|
+
}
|
|
1212
|
+
lines.push(`\t\t}),`)
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// (#1407) Initialise spread bag fields. Unsupported shapes (e.g.
|
|
1216
|
+
// signal getters whose initialValue isn't a plain object literal,
|
|
1217
|
+
// identifiers that don't resolve to a propsParam) fall through to
|
|
1218
|
+
// BF101 below — the field is still declared on the struct so the
|
|
1219
|
+
// template compiles even when the initializer is missing.
|
|
1220
|
+
// `spreadSlots` is computed once in `generateTypes` and threaded
|
|
1221
|
+
// through to avoid a second IR walk (#1411 review).
|
|
1222
|
+
for (const slot of spreadSlots) {
|
|
1223
|
+
const goExpr = this.buildSpreadInitializer(slot.expr, ir)
|
|
1224
|
+
if (goExpr) {
|
|
1225
|
+
lines.push(`\t\t${slot.slotId}: ${goExpr},`)
|
|
1226
|
+
} else {
|
|
1227
|
+
this.errors.push({
|
|
1228
|
+
code: 'BF101',
|
|
1229
|
+
severity: 'error',
|
|
1230
|
+
message: `JSX spread '{...${slot.expr}}' on an intrinsic element has no Go template lowering. Supported shapes: signal-getter calls (attrs()), destructured-prop identifiers ({ extras }: P with {...extras}), SolidJS-style props identifier ((props: P) with {...props}), rest-prop identifiers ({...rest}: P with {...rest})`,
|
|
1231
|
+
loc: this.makeLoc(),
|
|
1232
|
+
suggestion: {
|
|
1233
|
+
message: 'Pre-compute the spread bag as a discrete prop, or expand the spread into per-attribute props at the call site.',
|
|
1234
|
+
},
|
|
1235
|
+
})
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
lines.push('\t}')
|
|
1240
|
+
lines.push('}')
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Convert field name to JSON tag (camelCase)
|
|
1245
|
+
*/
|
|
1246
|
+
private toJsonTag(name: string): string {
|
|
1247
|
+
return name.charAt(0).toLowerCase() + name.slice(1)
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Find all nested components (loops with childComponent).
|
|
1252
|
+
* Returns extended info that includes whether the component comes from a dynamic (signal) array loop.
|
|
1253
|
+
*/
|
|
1254
|
+
private findNestedComponents(node: IRNode): NestedComponentInfo[] {
|
|
1255
|
+
const result: NestedComponentInfo[] = []
|
|
1256
|
+
this.collectNestedComponents(node, result)
|
|
1257
|
+
return result
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
private collectNestedComponents(node: IRNode, result: NestedComponentInfo[]): void {
|
|
1261
|
+
if (node.type === 'loop') {
|
|
1262
|
+
const loop = node as IRLoop
|
|
1263
|
+
if (loop.childComponent) {
|
|
1264
|
+
// Check for duplicates
|
|
1265
|
+
if (!result.some(c => c.name === loop.childComponent!.name)) {
|
|
1266
|
+
result.push({
|
|
1267
|
+
...loop.childComponent,
|
|
1268
|
+
isDynamic: !loop.isStaticArray,
|
|
1269
|
+
})
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
for (const child of loop.children) {
|
|
1273
|
+
this.collectNestedComponents(child, result)
|
|
1274
|
+
}
|
|
1275
|
+
} else if (node.type === 'element') {
|
|
1276
|
+
const element = node as IRElement
|
|
1277
|
+
for (const child of element.children) {
|
|
1278
|
+
this.collectNestedComponents(child, result)
|
|
1279
|
+
}
|
|
1280
|
+
} else if (node.type === 'fragment') {
|
|
1281
|
+
const fragment = node as IRFragment
|
|
1282
|
+
for (const child of fragment.children) {
|
|
1283
|
+
this.collectNestedComponents(child, result)
|
|
1284
|
+
}
|
|
1285
|
+
} else if (node.type === 'conditional') {
|
|
1286
|
+
const cond = node as IRConditional
|
|
1287
|
+
this.collectNestedComponents(cond.whenTrue, result)
|
|
1288
|
+
if (cond.whenFalse) {
|
|
1289
|
+
this.collectNestedComponents(cond.whenFalse, result)
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* Collect all static child component instances from the IR tree.
|
|
1296
|
+
* Excludes components inside loops (which are handled by nestedComponents).
|
|
1297
|
+
*
|
|
1298
|
+
* Each instance is identified by:
|
|
1299
|
+
* - name: Component name (e.g., "ReactiveChild")
|
|
1300
|
+
* - slotId: Unique slot ID (e.g., "slot_6")
|
|
1301
|
+
* - props: Component props
|
|
1302
|
+
* - fieldName: Go field name (e.g., "ReactiveChildSlot6")
|
|
1303
|
+
*/
|
|
1304
|
+
private collectStaticChildInstances(node: IRNode): Array<StaticChildInstance> {
|
|
1305
|
+
const result: StaticChildInstance[] = []
|
|
1306
|
+
this.collectStaticChildInstancesRecursive(node, result, false)
|
|
1307
|
+
return result
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* Return the concatenated text content of a list of IR nodes when
|
|
1312
|
+
* every node is plain text; otherwise null.
|
|
1313
|
+
*/
|
|
1314
|
+
private extractTextChildren(children: IRNode[]): string | null {
|
|
1315
|
+
if (children.length === 0) return null
|
|
1316
|
+
let out = ''
|
|
1317
|
+
for (const child of children) {
|
|
1318
|
+
if (child.type !== 'text') return null
|
|
1319
|
+
out += (child as { value: string }).value
|
|
1320
|
+
}
|
|
1321
|
+
return out
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Render JSX children to a Go-template-ready HTML fragment when
|
|
1326
|
+
* children are non-text but produce purely-static HTML (no Go
|
|
1327
|
+
* template actions). Returns null when:
|
|
1328
|
+
* - children are absent or text-only (handled by extractTextChildren), or
|
|
1329
|
+
* - the rendered fragment contains any `{{...}}` action — passing
|
|
1330
|
+
* such a fragment through `template.HTML` and the parent's
|
|
1331
|
+
* `{{.Children}}` would output the actions verbatim instead of
|
|
1332
|
+
* evaluating them, which is worse than the existing
|
|
1333
|
+
* "drop children" fallback. Dynamic / component-bearing children
|
|
1334
|
+
* stay on the drop path until a re-evaluation hook lands.
|
|
1335
|
+
*/
|
|
1336
|
+
private extractHtmlChildren(children: IRNode[]): string | null {
|
|
1337
|
+
if (children.length === 0) return null
|
|
1338
|
+
if (children.every(c => c.type === 'text')) return null
|
|
1339
|
+
const html = this.renderChildren(children)
|
|
1340
|
+
if (html.includes('{{')) return null
|
|
1341
|
+
return html
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
private collectStaticChildInstancesRecursive(
|
|
1345
|
+
node: IRNode,
|
|
1346
|
+
result: StaticChildInstance[],
|
|
1347
|
+
inLoop: boolean
|
|
1348
|
+
): void {
|
|
1349
|
+
if (node.type === 'component') {
|
|
1350
|
+
const comp = node as IRComponent
|
|
1351
|
+
// Skip Portal components (handled separately via PortalCollector)
|
|
1352
|
+
// Skip components inside loops (handled by nestedComponents)
|
|
1353
|
+
if (comp.name !== 'Portal' && !inLoop && comp.slotId) {
|
|
1354
|
+
const suffix = slotIdToFieldSuffix(comp.slotId)
|
|
1355
|
+
result.push({
|
|
1356
|
+
name: comp.name,
|
|
1357
|
+
slotId: comp.slotId,
|
|
1358
|
+
props: comp.props,
|
|
1359
|
+
fieldName: `${comp.name}${suffix}`,
|
|
1360
|
+
childrenText: this.extractTextChildren(comp.children),
|
|
1361
|
+
childrenHtml: this.extractHtmlChildren(comp.children),
|
|
1362
|
+
})
|
|
1363
|
+
}
|
|
1364
|
+
// Recurse into Portal's children to find nested components
|
|
1365
|
+
if (comp.name === 'Portal' && comp.children) {
|
|
1366
|
+
for (const child of comp.children) {
|
|
1367
|
+
this.collectStaticChildInstancesRecursive(child, result, inLoop)
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
} else if (node.type === 'loop') {
|
|
1371
|
+
const loop = node as IRLoop
|
|
1372
|
+
// Mark children as inside loop
|
|
1373
|
+
for (const child of loop.children) {
|
|
1374
|
+
this.collectStaticChildInstancesRecursive(child, result, true)
|
|
1375
|
+
}
|
|
1376
|
+
} else if (node.type === 'element') {
|
|
1377
|
+
const element = node as IRElement
|
|
1378
|
+
for (const child of element.children) {
|
|
1379
|
+
this.collectStaticChildInstancesRecursive(child, result, inLoop)
|
|
1380
|
+
}
|
|
1381
|
+
} else if (node.type === 'fragment') {
|
|
1382
|
+
const fragment = node as IRFragment
|
|
1383
|
+
for (const child of fragment.children) {
|
|
1384
|
+
this.collectStaticChildInstancesRecursive(child, result, inLoop)
|
|
1385
|
+
}
|
|
1386
|
+
} else if (node.type === 'conditional') {
|
|
1387
|
+
const cond = node as IRConditional
|
|
1388
|
+
this.collectStaticChildInstancesRecursive(cond.whenTrue, result, inLoop)
|
|
1389
|
+
if (cond.whenFalse) {
|
|
1390
|
+
this.collectStaticChildInstancesRecursive(cond.whenFalse, result, inLoop)
|
|
1391
|
+
}
|
|
1392
|
+
} else if (node.type === 'provider') {
|
|
1393
|
+
// Provider is a transparent wrapper at the SSR layer — context
|
|
1394
|
+
// propagation is purely a client-runtime concern. Recurse into
|
|
1395
|
+
// its children so any static <Child/> nested under <Ctx.Provider>
|
|
1396
|
+
// still gets a slot field generated on the parent's props type.
|
|
1397
|
+
const p = node as IRProvider
|
|
1398
|
+
for (const child of p.children) {
|
|
1399
|
+
this.collectStaticChildInstancesRecursive(child, result, inLoop)
|
|
1400
|
+
}
|
|
1401
|
+
} else if (node.type === 'async') {
|
|
1402
|
+
// Async fallback + children render server-side via the OOS
|
|
1403
|
+
// protocol; static child components inside them still need slot
|
|
1404
|
+
// fields on the parent struct.
|
|
1405
|
+
const a = node as IRAsync
|
|
1406
|
+
this.collectStaticChildInstancesRecursive(a.fallback, result, inLoop)
|
|
1407
|
+
for (const child of a.children) {
|
|
1408
|
+
this.collectStaticChildInstancesRecursive(child, result, inLoop)
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* Collect top-level (non-loop) JSX intrinsic-element spread slots
|
|
1415
|
+
* from the IR (#1407). Loop-internal spreads are skipped — they
|
|
1416
|
+
* emit the bag inline via the loop's iteration variable in
|
|
1417
|
+
* `elementAttrEmitter.emitSpread`, so they don't need a Props
|
|
1418
|
+
* struct field.
|
|
1419
|
+
*
|
|
1420
|
+
* Walks the IR tree, descending into elements, fragments,
|
|
1421
|
+
* conditionals, providers, async, and components, but stopping at
|
|
1422
|
+
* loop bodies. Each `IRElement.attrs[i].value` of kind `'spread'`
|
|
1423
|
+
* that has a `slotId` becomes one `SpreadSlotInfo` entry.
|
|
1424
|
+
*/
|
|
1425
|
+
private collectSpreadSlots(node: IRNode): SpreadSlotInfo[] {
|
|
1426
|
+
const result: SpreadSlotInfo[] = []
|
|
1427
|
+
this.collectSpreadSlotsRecursive(node, result)
|
|
1428
|
+
return result
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* Decide how a spread bag should be plumbed onto the Input/Props
|
|
1433
|
+
* structs (#1407 follow-up). A bare-identifier spread that
|
|
1434
|
+
* matches the component's `restPropsName` is open-ended (Go's
|
|
1435
|
+
* static typing can't enumerate the keys), so the caller must
|
|
1436
|
+
* supply the bag via an Input-side `map[string]any` field. Every
|
|
1437
|
+
* other shape — signal getter, `propsObjectName`, plain
|
|
1438
|
+
* propsParam, object literal — can be constructed inline in
|
|
1439
|
+
* `NewXxxProps` from compile-time-known data.
|
|
1440
|
+
*
|
|
1441
|
+
* Reads `this.restPropsName` (stashed at `generate()` entry)
|
|
1442
|
+
* rather than receiving the IR per-call — matches the existing
|
|
1443
|
+
* `this.propsObjectName` / `this.componentName` storage pattern.
|
|
1444
|
+
*/
|
|
1445
|
+
private classifySpreadBagSource(spreadExpr: string): 'input-bag' | 'inline' {
|
|
1446
|
+
const trimmed = spreadExpr.trim()
|
|
1447
|
+
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)
|
|
1448
|
+
&& this.restPropsName === trimmed) {
|
|
1449
|
+
return 'input-bag'
|
|
1450
|
+
}
|
|
1451
|
+
return 'inline'
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
private collectSpreadSlotsRecursive(node: IRNode, result: SpreadSlotInfo[]): void {
|
|
1455
|
+
if (node.type === 'element') {
|
|
1456
|
+
const element = node as IRElement
|
|
1457
|
+
for (const attr of element.attrs) {
|
|
1458
|
+
if (attr.value.kind !== 'spread') continue
|
|
1459
|
+
if (!attr.value.slotId) continue
|
|
1460
|
+
result.push({
|
|
1461
|
+
slotId: attr.value.slotId,
|
|
1462
|
+
expr: attr.value.expr,
|
|
1463
|
+
templateExpr: attr.value.templateExpr,
|
|
1464
|
+
bagSource: this.classifySpreadBagSource(attr.value.expr),
|
|
1465
|
+
})
|
|
1466
|
+
}
|
|
1467
|
+
for (const child of element.children) {
|
|
1468
|
+
this.collectSpreadSlotsRecursive(child, result)
|
|
1469
|
+
}
|
|
1470
|
+
return
|
|
1471
|
+
}
|
|
1472
|
+
if (node.type === 'fragment') {
|
|
1473
|
+
const fragment = node as IRFragment
|
|
1474
|
+
for (const child of fragment.children) {
|
|
1475
|
+
this.collectSpreadSlotsRecursive(child, result)
|
|
1476
|
+
}
|
|
1477
|
+
return
|
|
1478
|
+
}
|
|
1479
|
+
if (node.type === 'conditional') {
|
|
1480
|
+
const cond = node as IRConditional
|
|
1481
|
+
this.collectSpreadSlotsRecursive(cond.whenTrue, result)
|
|
1482
|
+
if (cond.whenFalse) this.collectSpreadSlotsRecursive(cond.whenFalse, result)
|
|
1483
|
+
return
|
|
1484
|
+
}
|
|
1485
|
+
if (node.type === 'if-statement') {
|
|
1486
|
+
const stmt = node as IRIfStatement
|
|
1487
|
+
this.collectSpreadSlotsRecursive(stmt.consequent, result)
|
|
1488
|
+
if (stmt.alternate) this.collectSpreadSlotsRecursive(stmt.alternate, result)
|
|
1489
|
+
return
|
|
1490
|
+
}
|
|
1491
|
+
if (node.type === 'component') {
|
|
1492
|
+
const comp = node as IRComponent
|
|
1493
|
+
// `IRComponent.children` are the JSX children passed to *this*
|
|
1494
|
+
// component instance at the call site (`<Child>...</Child>`).
|
|
1495
|
+
// They are part of the PARENT's IR and evaluate in the parent's
|
|
1496
|
+
// render scope, so any spreads inside them belong on the parent's
|
|
1497
|
+
// Props struct. The child component's own template body is a
|
|
1498
|
+
// separate `ComponentIR` with its own `ir.root`, compiled in a
|
|
1499
|
+
// separate `generate()` pass — it never appears in the parent's
|
|
1500
|
+
// IR tree, so the recursion never crosses a component boundary
|
|
1501
|
+
// and the per-component `spreadIdCounter` can't collide across
|
|
1502
|
+
// unrelated components (#1411 review).
|
|
1503
|
+
for (const child of comp.children) {
|
|
1504
|
+
this.collectSpreadSlotsRecursive(child, result)
|
|
1505
|
+
}
|
|
1506
|
+
return
|
|
1507
|
+
}
|
|
1508
|
+
if (node.type === 'provider') {
|
|
1509
|
+
const p = node as IRProvider
|
|
1510
|
+
for (const child of p.children) {
|
|
1511
|
+
this.collectSpreadSlotsRecursive(child, result)
|
|
1512
|
+
}
|
|
1513
|
+
return
|
|
1514
|
+
}
|
|
1515
|
+
if (node.type === 'async') {
|
|
1516
|
+
const a = node as IRAsync
|
|
1517
|
+
this.collectSpreadSlotsRecursive(a.fallback, result)
|
|
1518
|
+
for (const child of a.children) {
|
|
1519
|
+
this.collectSpreadSlotsRecursive(child, result)
|
|
1520
|
+
}
|
|
1521
|
+
return
|
|
1522
|
+
}
|
|
1523
|
+
// Loops are intentionally not descended — loop-internal spreads
|
|
1524
|
+
// emit `{{bf_spread_attrs <go-expr>}}` inline from
|
|
1525
|
+
// `elementAttrEmitter.emitSpread` instead of plumbing through a
|
|
1526
|
+
// Props struct field.
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* Parse a JS object-literal source text (the raw string captured
|
|
1531
|
+
* for a signal's `initialValue` or a spread expression's argument)
|
|
1532
|
+
* into a Go `map[string]any{...}` literal source (#1407).
|
|
1533
|
+
*
|
|
1534
|
+
* Supports a deliberately conservative subset so the Go output is
|
|
1535
|
+
* a 1:1 translation of the JS source: string/number/boolean/null
|
|
1536
|
+
* values keyed by identifier or string-literal keys. Returns null
|
|
1537
|
+
* for unsupported shapes (nested objects, computed values,
|
|
1538
|
+
* function calls, spread elements) — callers fall back to BF101.
|
|
1539
|
+
*/
|
|
1540
|
+
private parseJsObjectLiteralToGoMap(jsText: string): string | null {
|
|
1541
|
+
const sf = ts.createSourceFile('inline.ts', `(${jsText})`, ts.ScriptTarget.Latest, true)
|
|
1542
|
+
if (sf.statements.length !== 1) return null
|
|
1543
|
+
const stmt = sf.statements[0]
|
|
1544
|
+
if (!ts.isExpressionStatement(stmt)) return null
|
|
1545
|
+
let expr: ts.Expression = stmt.expression
|
|
1546
|
+
while (ts.isParenthesizedExpression(expr)) expr = expr.expression
|
|
1547
|
+
if (!ts.isObjectLiteralExpression(expr)) return null
|
|
1548
|
+
const entries: string[] = []
|
|
1549
|
+
for (const prop of expr.properties) {
|
|
1550
|
+
if (!ts.isPropertyAssignment(prop)) return null
|
|
1551
|
+
let key: string
|
|
1552
|
+
if (ts.isIdentifier(prop.name)) {
|
|
1553
|
+
key = prop.name.text
|
|
1554
|
+
} else if (ts.isStringLiteral(prop.name) || ts.isNoSubstitutionTemplateLiteral(prop.name)) {
|
|
1555
|
+
key = prop.name.text
|
|
1556
|
+
} else {
|
|
1557
|
+
return null
|
|
1558
|
+
}
|
|
1559
|
+
const val = prop.initializer
|
|
1560
|
+
let goVal: string
|
|
1561
|
+
if (ts.isStringLiteral(val) || ts.isNoSubstitutionTemplateLiteral(val)) {
|
|
1562
|
+
goVal = JSON.stringify(val.text)
|
|
1563
|
+
} else if (ts.isNumericLiteral(val)) {
|
|
1564
|
+
goVal = val.text
|
|
1565
|
+
} else if (
|
|
1566
|
+
// TypeScript parses `-1` and `+1` as `PrefixUnaryExpression`
|
|
1567
|
+
// rather than `NumericLiteral` — accept both signs explicitly
|
|
1568
|
+
// so a bag like `{count: -1}` doesn't collapse to BF101
|
|
1569
|
+
// (#1411 review).
|
|
1570
|
+
ts.isPrefixUnaryExpression(val)
|
|
1571
|
+
&& (val.operator === ts.SyntaxKind.MinusToken || val.operator === ts.SyntaxKind.PlusToken)
|
|
1572
|
+
&& ts.isNumericLiteral(val.operand)
|
|
1573
|
+
) {
|
|
1574
|
+
const sign = val.operator === ts.SyntaxKind.MinusToken ? '-' : ''
|
|
1575
|
+
goVal = `${sign}${val.operand.text}`
|
|
1576
|
+
} else if (val.kind === ts.SyntaxKind.TrueKeyword) {
|
|
1577
|
+
goVal = 'true'
|
|
1578
|
+
} else if (val.kind === ts.SyntaxKind.FalseKeyword) {
|
|
1579
|
+
goVal = 'false'
|
|
1580
|
+
} else if (val.kind === ts.SyntaxKind.NullKeyword) {
|
|
1581
|
+
goVal = 'nil'
|
|
1582
|
+
} else {
|
|
1583
|
+
return null
|
|
1584
|
+
}
|
|
1585
|
+
entries.push(`${JSON.stringify(key)}: ${goVal}`)
|
|
1586
|
+
}
|
|
1587
|
+
return `map[string]any{${entries.join(', ')}}`
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/**
|
|
1591
|
+
* Build a Go expression for a JSX spread bag's initial value, to
|
|
1592
|
+
* be placed inside `NewXxxProps`'s return literal (#1407).
|
|
1593
|
+
*
|
|
1594
|
+
* Supported shapes:
|
|
1595
|
+
* - Signal-getter call (e.g. `attrs()`): look up the signal,
|
|
1596
|
+
* parse its `initialValue` as a JS object literal, and emit a
|
|
1597
|
+
* Go `map[string]any{...}` literal.
|
|
1598
|
+
* - Bare identifier matching a destructured `propsParam` (e.g.
|
|
1599
|
+
* `function({ extras }: P) { <el {...extras}/> }`): emit
|
|
1600
|
+
* `in.<FieldName>` — works when the prop's Go type is a map
|
|
1601
|
+
* type the bag is assignable to.
|
|
1602
|
+
* - Bare identifier matching `propsObjectName` (SolidJS-style
|
|
1603
|
+
* `function(props: P) { <el {...props}/> }`): enumerate the
|
|
1604
|
+
* analyzer-extracted `propsParams` into an inline
|
|
1605
|
+
* `map[string]any{...}` literal so each typed Input field
|
|
1606
|
+
* surfaces as a bag key (#1407 follow-up).
|
|
1607
|
+
* - Bare identifier matching `restPropsName` (the destructured-
|
|
1608
|
+
* rest pattern `function({a, ...rest}: P) { <el {...rest}/> }`):
|
|
1609
|
+
* emit `in.<slotId>` against the `map[string]any` Input field
|
|
1610
|
+
* that `generateInputStruct` adds for `input-bag` slots. The
|
|
1611
|
+
* caller (parent component or test harness) populates the
|
|
1612
|
+
* bag with the open-ended rest values (#1407 follow-up).
|
|
1613
|
+
*
|
|
1614
|
+
* Returns null for unsupported shapes so the caller can raise a
|
|
1615
|
+
* narrowed BF101 with the offending expression.
|
|
1616
|
+
*/
|
|
1617
|
+
private buildSpreadInitializer(
|
|
1618
|
+
spreadExpr: string,
|
|
1619
|
+
ir: ComponentIR,
|
|
1620
|
+
): string | null {
|
|
1621
|
+
const trimmed = spreadExpr.trim()
|
|
1622
|
+
// Signal-getter call: `attrs()` — pluck the signal's initialValue
|
|
1623
|
+
// and translate the JS object literal to a Go map literal.
|
|
1624
|
+
const callMatch = /^([a-zA-Z_][a-zA-Z0-9_]*)\s*\(\s*\)$/.exec(trimmed)
|
|
1625
|
+
if (callMatch) {
|
|
1626
|
+
const getterName = callMatch[1]
|
|
1627
|
+
const signal = ir.metadata.signals.find(s => s.getter === getterName)
|
|
1628
|
+
if (signal && signal.initialValue) {
|
|
1629
|
+
const goMap = this.parseJsObjectLiteralToGoMap(signal.initialValue)
|
|
1630
|
+
if (goMap) return goMap
|
|
1631
|
+
}
|
|
1632
|
+
return null
|
|
1633
|
+
}
|
|
1634
|
+
// Bare-identifier paths.
|
|
1635
|
+
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) {
|
|
1636
|
+
// 1. Destructured-from-props parameter: `function({ extras }: P)`
|
|
1637
|
+
// → spread `{...extras}` resolves to `in.Extras`.
|
|
1638
|
+
const param = ir.metadata.propsParams.find(p => p.name === trimmed)
|
|
1639
|
+
if (param) {
|
|
1640
|
+
return `in.${this.capitalizeFieldName(param.name)}`
|
|
1641
|
+
}
|
|
1642
|
+
// 2. SolidJS-style props object: `function(props: P)` → spread
|
|
1643
|
+
// `{...props}` enumerates all analyzer-extracted propsParams
|
|
1644
|
+
// into a `map[string]any` literal. Every Input field becomes
|
|
1645
|
+
// a bag key. When `propsParams` is empty (analyzer couldn't
|
|
1646
|
+
// enumerate the type — e.g. an unresolved interface
|
|
1647
|
+
// `extends` chain), the literal is `map[string]any{}`. SSR
|
|
1648
|
+
// then renders no spread attrs; the CSR `applyRestAttrs`
|
|
1649
|
+
// hydrate path still applies them. Strictly worse than a
|
|
1650
|
+
// full enumeration, but strictly better than BF101 blocking
|
|
1651
|
+
// the build.
|
|
1652
|
+
if (ir.metadata.propsObjectName === trimmed) {
|
|
1653
|
+
const entries = ir.metadata.propsParams.map(p =>
|
|
1654
|
+
`${JSON.stringify(p.name)}: in.${this.capitalizeFieldName(p.name)}`,
|
|
1655
|
+
)
|
|
1656
|
+
return `map[string]any{${entries.join(', ')}}`
|
|
1657
|
+
}
|
|
1658
|
+
// 3. Destructured-rest identifier:
|
|
1659
|
+
// `function({a, ...rest}: P) { <el {...rest}/> }`. The
|
|
1660
|
+
// rest's key set is open-ended (Go can't enumerate it
|
|
1661
|
+
// statically when the analyzer's `restPropsExpandedKeys`
|
|
1662
|
+
// isn't populated), so `generateInputStruct` added an
|
|
1663
|
+
// Input field named after the rest binding itself
|
|
1664
|
+
// (`rest` → `Rest`) so callers can write
|
|
1665
|
+
// `XxxInput{Rest: ...}` using the same identifier they
|
|
1666
|
+
// saw in source. Forward it through.
|
|
1667
|
+
if (ir.metadata.restPropsName === trimmed) {
|
|
1668
|
+
return `in.${this.capitalizeFieldName(trimmed)}`
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
return null
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* Convert JavaScript initial value to Go value for NewXxxProps function.
|
|
1676
|
+
* References to props params are converted to in.FieldName format.
|
|
1677
|
+
*/
|
|
1678
|
+
private convertInitialValue(value: string, typeInfo: TypeInfo, propsParams?: { name: string }[]): string {
|
|
1679
|
+
// Check if it's a simple identifier (props param reference)
|
|
1680
|
+
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value)) {
|
|
1681
|
+
// Check if this matches a props param
|
|
1682
|
+
if (propsParams?.some(p => p.name === value)) {
|
|
1683
|
+
return `in.${this.capitalizeFieldName(value)}`
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// Check for props.xxx pattern (e.g., "props.initial ?? 0")
|
|
1688
|
+
const propName = this.extractPropNameFromInitialValue(value)
|
|
1689
|
+
if (propName && propsParams?.some(p => p.name === propName)) {
|
|
1690
|
+
return `in.${this.capitalizeFieldName(propName)}`
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
if (typeInfo.kind === 'primitive') {
|
|
1694
|
+
if (typeInfo.primitive === 'boolean') {
|
|
1695
|
+
return value === 'true' ? 'true' : 'false'
|
|
1696
|
+
}
|
|
1697
|
+
if (typeInfo.primitive === 'number') {
|
|
1698
|
+
// Check if it's a simple number
|
|
1699
|
+
if (/^\d+$/.test(value)) return value
|
|
1700
|
+
if (/^\d+\.\d+$/.test(value)) return value
|
|
1701
|
+
return '0'
|
|
1702
|
+
}
|
|
1703
|
+
if (typeInfo.primitive === 'string') {
|
|
1704
|
+
// Remove quotes if present and add Go string syntax
|
|
1705
|
+
if (value.startsWith("'") || value.endsWith("'")) {
|
|
1706
|
+
return value.replace(/'/g, '"')
|
|
1707
|
+
}
|
|
1708
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
1709
|
+
return value
|
|
1710
|
+
}
|
|
1711
|
+
return '""'
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// For arrays, use nil for complex JS expressions
|
|
1716
|
+
if (typeInfo.kind === 'array') {
|
|
1717
|
+
// Simple array literal or empty
|
|
1718
|
+
if (value === '[]' || value === 'null' || value === 'undefined') {
|
|
1719
|
+
return 'nil'
|
|
1720
|
+
}
|
|
1721
|
+
// Complex expression - use nil as placeholder
|
|
1722
|
+
return 'nil'
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// String alias (e.g., Filter = string) — return string value instead of nil
|
|
1726
|
+
if (typeInfo.kind === 'interface' && typeInfo.raw) {
|
|
1727
|
+
const aliasBase = this.localTypeAliases.get(typeInfo.raw)
|
|
1728
|
+
if (aliasBase === 'string') {
|
|
1729
|
+
if (value.startsWith("'") || value.startsWith('"')) {
|
|
1730
|
+
return value.replace(/'/g, '"')
|
|
1731
|
+
}
|
|
1732
|
+
return '""'
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// Default for complex expressions
|
|
1737
|
+
return 'nil'
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/**
|
|
1741
|
+
* Convert TypeInfo to Go type string.
|
|
1742
|
+
* If type is unknown, tries to infer from defaultValue.
|
|
1743
|
+
*/
|
|
1744
|
+
private typeInfoToGo(typeInfo: TypeInfo, defaultValue?: string): string {
|
|
1745
|
+
switch (typeInfo.kind) {
|
|
1746
|
+
case 'primitive':
|
|
1747
|
+
switch (typeInfo.primitive) {
|
|
1748
|
+
case 'string':
|
|
1749
|
+
return 'string'
|
|
1750
|
+
case 'number':
|
|
1751
|
+
return 'int'
|
|
1752
|
+
case 'boolean':
|
|
1753
|
+
return 'bool'
|
|
1754
|
+
default:
|
|
1755
|
+
return 'interface{}'
|
|
1756
|
+
}
|
|
1757
|
+
case 'array':
|
|
1758
|
+
if (typeInfo.elementType) {
|
|
1759
|
+
return `[]${this.typeInfoToGo(typeInfo.elementType)}`
|
|
1760
|
+
}
|
|
1761
|
+
return '[]interface{}'
|
|
1762
|
+
case 'object':
|
|
1763
|
+
return 'map[string]interface{}'
|
|
1764
|
+
case 'interface':
|
|
1765
|
+
// Check if raw type name matches a locally-defined type
|
|
1766
|
+
if (typeInfo.raw && this.localTypeNames.has(typeInfo.raw)) {
|
|
1767
|
+
return typeInfo.raw
|
|
1768
|
+
}
|
|
1769
|
+
// Try to parse raw type string as a known pattern (e.g., Array<Todo>)
|
|
1770
|
+
if (typeInfo.raw) {
|
|
1771
|
+
const resolved = this.tsTypeStringToGo(typeInfo.raw)
|
|
1772
|
+
if (resolved !== 'interface{}') return resolved
|
|
1773
|
+
}
|
|
1774
|
+
return 'interface{}'
|
|
1775
|
+
case 'unknown':
|
|
1776
|
+
// Try to infer type from default value
|
|
1777
|
+
if (defaultValue !== undefined) {
|
|
1778
|
+
return this.inferTypeFromValue(defaultValue)
|
|
1779
|
+
}
|
|
1780
|
+
return 'interface{}'
|
|
1781
|
+
default:
|
|
1782
|
+
return 'interface{}'
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
/**
|
|
1787
|
+
* Get signal's initial value as Go code.
|
|
1788
|
+
* Handles both literal values (0, true, "str") and props references (initial).
|
|
1789
|
+
*
|
|
1790
|
+
* (#1423) When the signal references a prop via `props.X ?? N` and
|
|
1791
|
+
* the caller hoisted a fallback variable for `X`, return the hoisted
|
|
1792
|
+
* variable's name so the memo inherits the signal-time fallback.
|
|
1793
|
+
*/
|
|
1794
|
+
private getSignalInitialValueAsGo(
|
|
1795
|
+
initialValue: string,
|
|
1796
|
+
propsParams: { name: string }[],
|
|
1797
|
+
propFallbackVars: ReadonlyMap<string, PropFallbackVar> = GoTemplateAdapter.EMPTY_PROP_FALLBACK_VARS,
|
|
1798
|
+
): string {
|
|
1799
|
+
// Check if it's a props param reference
|
|
1800
|
+
if (propsParams.some(p => p.name === initialValue)) {
|
|
1801
|
+
const hoisted = propFallbackVars.get(initialValue)
|
|
1802
|
+
if (hoisted) return hoisted.varName
|
|
1803
|
+
return `in.${this.capitalizeFieldName(initialValue)}`
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
// Check for props.xxx pattern (e.g., "props.initial ?? 0")
|
|
1807
|
+
const propName = this.extractPropNameFromInitialValue(initialValue)
|
|
1808
|
+
if (propName && propsParams.some(p => p.name === propName)) {
|
|
1809
|
+
const hoisted = propFallbackVars.get(propName)
|
|
1810
|
+
if (hoisted) return hoisted.varName
|
|
1811
|
+
return `in.${this.capitalizeFieldName(propName)}`
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// Check if it's a literal value
|
|
1815
|
+
// Number literals
|
|
1816
|
+
if (/^-?\d+$/.test(initialValue)) {
|
|
1817
|
+
return initialValue
|
|
1818
|
+
}
|
|
1819
|
+
if (/^-?\d+\.\d+$/.test(initialValue)) {
|
|
1820
|
+
return initialValue
|
|
1821
|
+
}
|
|
1822
|
+
// Boolean literals
|
|
1823
|
+
if (initialValue === 'true' || initialValue === 'false') {
|
|
1824
|
+
return initialValue
|
|
1825
|
+
}
|
|
1826
|
+
// String literals
|
|
1827
|
+
if ((initialValue.startsWith("'") && initialValue.endsWith("'")) ||
|
|
1828
|
+
(initialValue.startsWith('"') && initialValue.endsWith('"'))) {
|
|
1829
|
+
return initialValue.replace(/'/g, '"')
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
// Default: return 0 for unknown
|
|
1833
|
+
return '0'
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
/**
|
|
1837
|
+
* Resolve dynamic prop value (e.g., signal/memo getter calls) to Go initial value.
|
|
1838
|
+
* Handles expressions like `count()` → signal's initial value
|
|
1839
|
+
*/
|
|
1840
|
+
/**
|
|
1841
|
+
* Convert a template literal's parsed parts into a Go expression of
|
|
1842
|
+
* type `string`, evaluated in `NewXxxProps` scope (where destructured
|
|
1843
|
+
* prop refs resolve via `in.FieldName`). Returns null when any part
|
|
1844
|
+
* is not representable in static Go code so the caller can fall back
|
|
1845
|
+
* to `resolveDynamicPropValue` (which handles the simpler shapes).
|
|
1846
|
+
*
|
|
1847
|
+
* Supported parts:
|
|
1848
|
+
* - `string`: emit as a Go string literal.
|
|
1849
|
+
* - `lookup`: `${MAP[KEY]}` against a `Record<T, string>` literal —
|
|
1850
|
+
* emit an IIFE that switches on the key prop and returns the
|
|
1851
|
+
* matching case (empty when no case matches). The key must be a
|
|
1852
|
+
* bare prop identifier today; other key shapes opt out.
|
|
1853
|
+
*
|
|
1854
|
+
* `ternary` is intentionally left unsupported — the existing
|
|
1855
|
+
* element-attribute path handles it via Go template `{{if}}` syntax,
|
|
1856
|
+
* and component-prop-via-ternary cases are rarer and can be added
|
|
1857
|
+
* incrementally.
|
|
1858
|
+
*/
|
|
1859
|
+
private templatePartsToGoCode(
|
|
1860
|
+
parts: IRTemplatePart[],
|
|
1861
|
+
propsParams: { name: string }[]
|
|
1862
|
+
): string | null {
|
|
1863
|
+
const segments: string[] = []
|
|
1864
|
+
for (const part of parts) {
|
|
1865
|
+
if (part.type === 'string') {
|
|
1866
|
+
// The IR analyzer already inlined identifier references into the
|
|
1867
|
+
// `lookup` part shape. Residual `${ident}` slips in a `string`
|
|
1868
|
+
// part only occur when resolution failed (e.g. a destructured
|
|
1869
|
+
// prop the analyzer couldn't trace). Emit verbatim for now —
|
|
1870
|
+
// the Mojo adapter substitutes these via
|
|
1871
|
+
// `substituteJsInterpolationsToPerl`; a Go equivalent would
|
|
1872
|
+
// walk the string and emit `in.FieldName` references, but that
|
|
1873
|
+
// path is not yet hit by the conformance suite.
|
|
1874
|
+
segments.push(JSON.stringify(part.value))
|
|
1875
|
+
continue
|
|
1876
|
+
}
|
|
1877
|
+
if (part.type === 'lookup') {
|
|
1878
|
+
const keyExpr = part.key.trim()
|
|
1879
|
+
const param = propsParams.find(p => p.name === keyExpr)
|
|
1880
|
+
if (!param) return null
|
|
1881
|
+
const fieldName = this.capitalizeFieldName(keyExpr)
|
|
1882
|
+
const caseEntries = Object.entries(part.cases)
|
|
1883
|
+
if (caseEntries.length === 0) {
|
|
1884
|
+
segments.push('""')
|
|
1885
|
+
continue
|
|
1886
|
+
}
|
|
1887
|
+
const lines: string[] = []
|
|
1888
|
+
lines.push('func() string {')
|
|
1889
|
+
lines.push(`\t\t\tk, _ := in.${fieldName}.(string)`)
|
|
1890
|
+
lines.push('\t\t\tswitch k {')
|
|
1891
|
+
for (const [k, v] of caseEntries) {
|
|
1892
|
+
lines.push(`\t\t\tcase ${JSON.stringify(k)}: return ${JSON.stringify(v)}`)
|
|
1893
|
+
}
|
|
1894
|
+
lines.push('\t\t\t}')
|
|
1895
|
+
lines.push('\t\t\treturn ""')
|
|
1896
|
+
lines.push('\t\t}()')
|
|
1897
|
+
segments.push(lines.join('\n'))
|
|
1898
|
+
continue
|
|
1899
|
+
}
|
|
1900
|
+
// ternary or future part kinds — opt out and let the caller
|
|
1901
|
+
// fall back to the bare-expression path.
|
|
1902
|
+
return null
|
|
1903
|
+
}
|
|
1904
|
+
if (segments.length === 0) return '""'
|
|
1905
|
+
return segments.join(' + ')
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
private resolveDynamicPropValue(
|
|
1909
|
+
expr: string,
|
|
1910
|
+
signals: { getter: string; setter: string | null; initialValue: string; type: TypeInfo }[],
|
|
1911
|
+
memos: { name: string; computation: string; deps: string[] }[],
|
|
1912
|
+
propsParams: { name: string }[]
|
|
1913
|
+
): string | null {
|
|
1914
|
+
// Match signal/memo getter calls like count(), doubled()
|
|
1915
|
+
const getterMatch = expr.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\(\)$/)
|
|
1916
|
+
if (getterMatch) {
|
|
1917
|
+
const getterName = getterMatch[1]
|
|
1918
|
+
|
|
1919
|
+
// Check if it's a signal
|
|
1920
|
+
const signal = signals.find(s => s.getter === getterName)
|
|
1921
|
+
if (signal) {
|
|
1922
|
+
return this.convertInitialValue(signal.initialValue, signal.type, propsParams)
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
// Check if it's a memo
|
|
1926
|
+
const memo = memos.find(m => m.name === getterName)
|
|
1927
|
+
if (memo) {
|
|
1928
|
+
return this.computeMemoInitialValue(memo, signals, propsParams)
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
return null
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
/**
|
|
1936
|
+
* Compute the initial value for a memo based on its computation and signal initial values.
|
|
1937
|
+
* Handles simple cases like `() => count() * 2` → `in.Initial * 2`
|
|
1938
|
+
* Also handles props.xxx patterns like `() => props.value * 10` → `in.Value * 10`
|
|
1939
|
+
*
|
|
1940
|
+
* (#1423) When `propFallbackVars` carries a hoisted variable for the
|
|
1941
|
+
* referenced prop, substitute it for `in.FieldName` so the memo
|
|
1942
|
+
* inherits the signal-time `??` fallback.
|
|
1943
|
+
*/
|
|
1944
|
+
private computeMemoInitialValue(
|
|
1945
|
+
memo: { name: string; computation: string; deps: string[] },
|
|
1946
|
+
signals: { getter: string; initialValue: string }[],
|
|
1947
|
+
propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
|
|
1948
|
+
propFallbackVars: ReadonlyMap<string, PropFallbackVar> = GoTemplateAdapter.EMPTY_PROP_FALLBACK_VARS,
|
|
1949
|
+
): string {
|
|
1950
|
+
const computation = memo.computation
|
|
1951
|
+
// Helper to pick the hoisted var (if any) or fall back to `in.X`.
|
|
1952
|
+
const propRef = (propName: string): string => {
|
|
1953
|
+
const hoisted = propFallbackVars.get(propName)
|
|
1954
|
+
if (hoisted) return hoisted.varName
|
|
1955
|
+
return `in.${this.capitalizeFieldName(propName)}`
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// Pattern: () => dep() * N or () => dep() + N etc.
|
|
1959
|
+
const arithmeticMatch = computation.match(/\(\)\s*=>\s*(\w+)\(\)\s*([*+\-/])\s*(\d+)/)
|
|
1960
|
+
if (arithmeticMatch) {
|
|
1961
|
+
const [, depName, operator, operand] = arithmeticMatch
|
|
1962
|
+
const signal = signals.find(s => s.getter === depName)
|
|
1963
|
+
if (signal) {
|
|
1964
|
+
// Get the signal's initial value in Go format
|
|
1965
|
+
const signalInitial = this.getSignalInitialValueAsGo(signal.initialValue, propsParams, propFallbackVars)
|
|
1966
|
+
return `${signalInitial} ${operator} ${operand}`
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
// Pattern: () => props.xxx * N (for SolidJS-style props object)
|
|
1971
|
+
const propsArithmeticMatch = computation.match(/\(\)\s*=>\s*props\.(\w+)\s*([*+\-/])\s*(\d+)/)
|
|
1972
|
+
if (propsArithmeticMatch) {
|
|
1973
|
+
const [, propName, operator, operand] = propsArithmeticMatch
|
|
1974
|
+
// Check if this prop is in propsParams (passed from parent)
|
|
1975
|
+
const param = propsParams.find(p => p.name === propName)
|
|
1976
|
+
if (param) {
|
|
1977
|
+
const hoisted = propFallbackVars.get(propName)
|
|
1978
|
+
if (hoisted) return `${hoisted.varName} ${operator} ${operand}`
|
|
1979
|
+
const fieldName = this.capitalizeFieldName(propName)
|
|
1980
|
+
// Guard: if the prop resolves to interface{}, use type assertion for arithmetic
|
|
1981
|
+
if (param.type) {
|
|
1982
|
+
const goType = this.typeInfoToGo(param.type, param.defaultValue)
|
|
1983
|
+
if (goType === 'interface{}') return `in.${fieldName}.(int) ${operator} ${operand}`
|
|
1984
|
+
}
|
|
1985
|
+
return `in.${fieldName} ${operator} ${operand}`
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
// Pattern: () => dep() (just return the signal value)
|
|
1990
|
+
const simpleMatch = computation.match(/\(\)\s*=>\s*(\w+)\(\)$/)
|
|
1991
|
+
if (simpleMatch) {
|
|
1992
|
+
const [, depName] = simpleMatch
|
|
1993
|
+
const signal = signals.find(s => s.getter === depName)
|
|
1994
|
+
if (signal) {
|
|
1995
|
+
return this.getSignalInitialValueAsGo(signal.initialValue, propsParams, propFallbackVars)
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// Pattern: () => props.xxx (just return the prop value)
|
|
2000
|
+
const propsSimpleMatch = computation.match(/\(\)\s*=>\s*props\.(\w+)$/)
|
|
2001
|
+
if (propsSimpleMatch) {
|
|
2002
|
+
const [, propName] = propsSimpleMatch
|
|
2003
|
+
const param = propsParams.find(p => p.name === propName)
|
|
2004
|
+
if (param) {
|
|
2005
|
+
return propRef(propName)
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
// Pattern: () => varName * N (for destructured props like { value })
|
|
2010
|
+
const varArithmeticMatch = computation.match(/\(\)\s*=>\s*(\w+)\s*([*+\-/])\s*(\d+)/)
|
|
2011
|
+
if (varArithmeticMatch) {
|
|
2012
|
+
const [, varName, operator, operand] = varArithmeticMatch
|
|
2013
|
+
// Check if this is a destructured prop (not a signal getter)
|
|
2014
|
+
const param = propsParams.find(p => p.name === varName)
|
|
2015
|
+
if (param) {
|
|
2016
|
+
const fieldName = this.capitalizeFieldName(varName)
|
|
2017
|
+
// Guard: if the prop resolves to interface{}, use type assertion for arithmetic
|
|
2018
|
+
if (param.type) {
|
|
2019
|
+
const goType = this.typeInfoToGo(param.type, param.defaultValue)
|
|
2020
|
+
if (goType === 'interface{}') return `in.${fieldName}.(int) ${operator} ${operand}`
|
|
2021
|
+
}
|
|
2022
|
+
return `in.${fieldName} ${operator} ${operand}`
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
// Pattern: () => varName (just return the prop value for destructured props)
|
|
2027
|
+
const varSimpleMatch = computation.match(/\(\)\s*=>\s*(\w+)$/)
|
|
2028
|
+
if (varSimpleMatch) {
|
|
2029
|
+
const [, varName] = varSimpleMatch
|
|
2030
|
+
const param = propsParams.find(p => p.name === varName)
|
|
2031
|
+
if (param) {
|
|
2032
|
+
return `in.${this.capitalizeFieldName(varName)}`
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
// Default: return 0 for unknown computations
|
|
2037
|
+
return '0'
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
/**
|
|
2041
|
+
* Infer the Go type for a memo based on its computation and dependencies.
|
|
2042
|
+
*/
|
|
2043
|
+
private inferMemoType(
|
|
2044
|
+
memo: { name: string; computation: string; type: TypeInfo; deps: string[] },
|
|
2045
|
+
signals: { getter: string; initialValue: string; type: TypeInfo }[],
|
|
2046
|
+
propsParamMap: Map<string, { name: string; type: TypeInfo; defaultValue?: string }>
|
|
2047
|
+
): string {
|
|
2048
|
+
// Check if computation involves multiplication (*) - likely number
|
|
2049
|
+
if (memo.computation.includes('*') || memo.computation.includes('/') ||
|
|
2050
|
+
memo.computation.includes('+') || memo.computation.includes('-')) {
|
|
2051
|
+
// Check if deps are number-typed signals
|
|
2052
|
+
for (const dep of memo.deps) {
|
|
2053
|
+
const signal = signals.find(s => s.getter === dep)
|
|
2054
|
+
if (signal) {
|
|
2055
|
+
let referencedProp = propsParamMap.get(signal.initialValue)
|
|
2056
|
+
if (!referencedProp) {
|
|
2057
|
+
const propName = this.extractPropNameFromInitialValue(signal.initialValue)
|
|
2058
|
+
if (propName) referencedProp = propsParamMap.get(propName)
|
|
2059
|
+
}
|
|
2060
|
+
if (referencedProp) {
|
|
2061
|
+
const propType = this.typeInfoToGo(referencedProp.type, referencedProp.defaultValue)
|
|
2062
|
+
if (propType === 'int' || propType === 'float64') {
|
|
2063
|
+
return 'int'
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
// Check signal's own initial value
|
|
2067
|
+
const signalType = this.typeInfoToGo(signal.type, signal.initialValue)
|
|
2068
|
+
if (signalType === 'int' || signalType === 'float64') {
|
|
2069
|
+
return 'int'
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
// Default to the memo's declared type
|
|
2076
|
+
return this.typeInfoToGo(memo.type)
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
/**
|
|
2080
|
+
* Infer Go type from a JavaScript value literal.
|
|
2081
|
+
*/
|
|
2082
|
+
private inferTypeFromValue(value: string): string {
|
|
2083
|
+
// Boolean literals
|
|
2084
|
+
if (value === 'true' || value === 'false') return 'bool'
|
|
2085
|
+
// Number literals (int)
|
|
2086
|
+
if (/^-?\d+$/.test(value)) return 'int'
|
|
2087
|
+
// Number literals (float)
|
|
2088
|
+
if (/^-?\d+\.\d+$/.test(value)) return 'float64'
|
|
2089
|
+
// String literals
|
|
2090
|
+
if ((value.startsWith("'") && value.endsWith("'")) ||
|
|
2091
|
+
(value.startsWith('"') && value.endsWith('"'))) {
|
|
2092
|
+
return 'string'
|
|
2093
|
+
}
|
|
2094
|
+
// Empty string
|
|
2095
|
+
if (value === '""' || value === "''") return 'string'
|
|
2096
|
+
// Array literals
|
|
2097
|
+
if (value.startsWith('[')) return '[]interface{}'
|
|
2098
|
+
// Default
|
|
2099
|
+
return 'interface{}'
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
/**
|
|
2103
|
+
* (#1423) Hoisted-variable record for a prop with a signal-time
|
|
2104
|
+
* `??` fallback. The same record is referenced from the prop field
|
|
2105
|
+
* loop, the signal field loop, and the memo computation path.
|
|
2106
|
+
*/
|
|
2107
|
+
private static EMPTY_PROP_FALLBACK_VARS: ReadonlyMap<string, PropFallbackVar> = new Map()
|
|
2108
|
+
|
|
2109
|
+
/**
|
|
2110
|
+
* (#1423) Walk signals to collect prop fallbacks. Skips props that
|
|
2111
|
+
* already have a destructure-side default (`{ X = N }`) or signals
|
|
2112
|
+
* whose fallback resolves to the type's Go zero value (no-op).
|
|
2113
|
+
*/
|
|
2114
|
+
private collectPropFallbackVars(ir: ComponentIR): Map<string, PropFallbackVar> {
|
|
2115
|
+
const result = new Map<string, PropFallbackVar>()
|
|
2116
|
+
const localTaken = new Set(['scopeID'])
|
|
2117
|
+
for (const nested of this.findNestedComponents(ir.root)) {
|
|
2118
|
+
localTaken.add(`${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`)
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
for (const signal of ir.metadata.signals) {
|
|
2122
|
+
const match = this.extractPropFallback(signal.initialValue)
|
|
2123
|
+
if (!match) continue
|
|
2124
|
+
if (result.has(match.propName)) continue
|
|
2125
|
+
const param = ir.metadata.propsParams.find(p => p.name === match.propName)
|
|
2126
|
+
if (!param) continue
|
|
2127
|
+
// A destructure default already wins via applyGoFallback below.
|
|
2128
|
+
if (this.goPropDefault(param.defaultValue) !== null) continue
|
|
2129
|
+
const fieldName = this.capitalizeFieldName(match.propName)
|
|
2130
|
+
// Pick the zero literal based on the fallback's literal shape.
|
|
2131
|
+
// Bool fallbacks (`?? true`) hoist against the `false` zero —
|
|
2132
|
+
// matches the same Go-zero conflation the int / string cases
|
|
2133
|
+
// accept: caller can't distinguish "explicit false" from
|
|
2134
|
+
// "unset", but for SSR-time defaults that's the documented
|
|
2135
|
+
// trade-off (#1423 Option B).
|
|
2136
|
+
let zeroLiteral: string
|
|
2137
|
+
if (match.goFallback === 'true' || match.goFallback === 'false') {
|
|
2138
|
+
zeroLiteral = 'false'
|
|
2139
|
+
} else if (/^-?\d+(\.\d+)?$/.test(match.goFallback)) {
|
|
2140
|
+
zeroLiteral = '0'
|
|
2141
|
+
} else if (match.goFallback.startsWith('"')) {
|
|
2142
|
+
zeroLiteral = '""'
|
|
2143
|
+
} else {
|
|
2144
|
+
continue
|
|
2145
|
+
}
|
|
2146
|
+
// Zero-equivalent fallback is a no-op against the Go zero value
|
|
2147
|
+
// (`?? 0`, `?? ''`, `?? false`, `?? 0.0`). Compare against the
|
|
2148
|
+
// computed zeroLiteral so spelling variants like `0.0` collapse
|
|
2149
|
+
// to the same skip as `0`.
|
|
2150
|
+
if (match.goFallback === zeroLiteral) continue
|
|
2151
|
+
if (zeroLiteral === '0' && Number(match.goFallback) === 0) continue
|
|
2152
|
+
// The JSX-side identifier is the natural local name.
|
|
2153
|
+
// Suffix with `_` if it collides with a Go keyword or a local we
|
|
2154
|
+
// already emit.
|
|
2155
|
+
let varName = match.propName
|
|
2156
|
+
while (localTaken.has(varName) || GoTemplateAdapter.GO_KEYWORDS.has(varName)) {
|
|
2157
|
+
varName += '_'
|
|
2158
|
+
}
|
|
2159
|
+
localTaken.add(varName)
|
|
2160
|
+
result.set(match.propName, { varName, fieldName, goFallback: match.goFallback, zeroLiteral })
|
|
2161
|
+
}
|
|
2162
|
+
return result
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
/**
|
|
2166
|
+
* (#1423) Parse a signal-time initial value of the form
|
|
2167
|
+
* `props.X ?? <literal>` into the source prop name and the Go-formatted
|
|
2168
|
+
* fallback. Returns null when:
|
|
2169
|
+
* - the expression isn't a `??` against a property access on
|
|
2170
|
+
* `propsObjectName`
|
|
2171
|
+
* - the fallback isn't a simple literal `goPropDefault` can translate
|
|
2172
|
+
*
|
|
2173
|
+
* The Go-adapter equivalent of the same parse already done by the
|
|
2174
|
+
* static evaluator in `ssr-defaults.ts` — duplicated here because we
|
|
2175
|
+
* need the original prop reference (not just the resolved value)
|
|
2176
|
+
* to honour caller-supplied non-zero inputs.
|
|
2177
|
+
*/
|
|
2178
|
+
private extractPropFallback(initialValue: string): { propName: string; goFallback: string } | null {
|
|
2179
|
+
if (!this.propsObjectName) return null
|
|
2180
|
+
const trimmed = initialValue.trim()
|
|
2181
|
+
const name = this.propsObjectName
|
|
2182
|
+
|
|
2183
|
+
// `props.X ?? <rhs>` — capture RHS greedily up to end of string.
|
|
2184
|
+
const re = new RegExp(`^${name}\\.(\\w+)\\s*\\?\\?\\s*(.+)$`)
|
|
2185
|
+
const m = trimmed.match(re)
|
|
2186
|
+
if (!m) return null
|
|
2187
|
+
const goFallback = this.goPropDefault(m[2].trim())
|
|
2188
|
+
if (goFallback === null) return null
|
|
2189
|
+
return { propName: m[1], goFallback }
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
/**
|
|
2193
|
+
* Extract prop name from a signal's initialValue that uses props.xxx pattern.
|
|
2194
|
+
* e.g., "props.initial ?? 0" → "initial", "props.checked" → "checked"
|
|
2195
|
+
*/
|
|
2196
|
+
private extractPropNameFromInitialValue(initialValue: string): string | null {
|
|
2197
|
+
if (!this.propsObjectName) return null
|
|
2198
|
+
const trimmed = initialValue.trim()
|
|
2199
|
+
const name = this.propsObjectName
|
|
2200
|
+
|
|
2201
|
+
// "props.initial ?? 0", "props.checked", "p.value || ''"
|
|
2202
|
+
const direct = new RegExp(`^${name}\\.(\\w+)(?:\\s*(?:\\?\\?|\\|\\|)\\s*.+)?$`)
|
|
2203
|
+
const m1 = trimmed.match(direct)
|
|
2204
|
+
if (m1) return m1[1]
|
|
2205
|
+
|
|
2206
|
+
// "(props.initialTodos ?? []).map(...)"
|
|
2207
|
+
const wrapped = new RegExp(`^\\(${name}\\.(\\w+)\\s*(?:\\?\\?|\\|\\|)\\s*[^)]+\\)(.*)$`)
|
|
2208
|
+
const m2 = trimmed.match(wrapped)
|
|
2209
|
+
if (m2) {
|
|
2210
|
+
const tail = m2[2]
|
|
2211
|
+
// The propagation rule is "this signal's Go type is the prop's Go
|
|
2212
|
+
// type". That breaks when the trailing access transforms the prop
|
|
2213
|
+
// type — e.g. `(props.initial ?? []).length` is a `number`, not the
|
|
2214
|
+
// prop's `[]Todo`. Bail out in those cases so the caller falls
|
|
2215
|
+
// back to `inferTypeFromValue` on the full expression, which
|
|
2216
|
+
// recognises `.length` / `.some()` / `.every()` etc.
|
|
2217
|
+
if (/^\s*\.(length|size|some|every|includes|indexOf|findIndex|lastIndexOf)\b/.test(tail)) {
|
|
2218
|
+
return null
|
|
2219
|
+
}
|
|
2220
|
+
return m2[1]
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
return null
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
/** Go common initialisms that should be fully uppercased (https://go.dev/wiki/CodeReviewComments#initialisms) */
|
|
2227
|
+
private static GO_INITIALISMS = new Set([
|
|
2228
|
+
'id', 'url', 'http', 'https', 'api', 'json', 'xml', 'html', 'css', 'sql',
|
|
2229
|
+
'ip', 'tcp', 'udp', 'dns', 'ssh', 'tls', 'ssl', 'uri', 'uid', 'uuid',
|
|
2230
|
+
'ascii', 'utf8', 'eof', 'grpc', 'rpc', 'cpu', 'gpu', 'ram', 'os',
|
|
2231
|
+
])
|
|
2232
|
+
|
|
2233
|
+
/**
|
|
2234
|
+
* (#1423) Go reserved keywords. When we hoist a local var named after
|
|
2235
|
+
* a JSX prop, the prop name could collide with one of these — append
|
|
2236
|
+
* `_` until the name is free.
|
|
2237
|
+
*/
|
|
2238
|
+
private static GO_KEYWORDS = new Set([
|
|
2239
|
+
'break', 'case', 'chan', 'const', 'continue', 'default', 'defer',
|
|
2240
|
+
'else', 'fallthrough', 'for', 'func', 'go', 'goto', 'if', 'import',
|
|
2241
|
+
'interface', 'map', 'package', 'range', 'return', 'select', 'struct',
|
|
2242
|
+
'switch', 'type', 'var',
|
|
2243
|
+
])
|
|
2244
|
+
|
|
2245
|
+
private capitalizeFieldName(name: string): string {
|
|
2246
|
+
if (!name) return name
|
|
2247
|
+
// Check if the entire name is a Go initialism (e.g., 'id' → 'ID')
|
|
2248
|
+
if (GoTemplateAdapter.GO_INITIALISMS.has(name.toLowerCase())) {
|
|
2249
|
+
return name.toUpperCase()
|
|
2250
|
+
}
|
|
2251
|
+
return name.charAt(0).toUpperCase() + name.slice(1)
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
/**
|
|
2255
|
+
* Convert a JavaScript literal value to Go literal syntax.
|
|
2256
|
+
*/
|
|
2257
|
+
/**
|
|
2258
|
+
* Translate a JSX param default (e.g. `'default'`, `0`, `false`) into
|
|
2259
|
+
* the corresponding Go literal. Returns null when the default is
|
|
2260
|
+
* absent or non-trivial (objects, arrow functions, etc.) — those
|
|
2261
|
+
* fall back to letting Go's zero value win.
|
|
2262
|
+
*/
|
|
2263
|
+
private goPropDefault(defaultValue: string | undefined): string | null {
|
|
2264
|
+
if (!defaultValue) return null
|
|
2265
|
+
const trimmed = defaultValue.trim()
|
|
2266
|
+
if (trimmed === '') return null
|
|
2267
|
+
if (trimmed === 'true' || trimmed === 'false') return trimmed
|
|
2268
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed
|
|
2269
|
+
// Single- and double-quoted strings.
|
|
2270
|
+
if (
|
|
2271
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
|
2272
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"'))
|
|
2273
|
+
) {
|
|
2274
|
+
const body = trimmed.slice(1, -1)
|
|
2275
|
+
return JSON.stringify(body)
|
|
2276
|
+
}
|
|
2277
|
+
// Bail on anything richer (objects, arrays, expressions). The
|
|
2278
|
+
// generated Go would mis-execute a JS expression.
|
|
2279
|
+
return null
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
/**
|
|
2283
|
+
* Wrap an `in.X` reference in a Go expression that substitutes
|
|
2284
|
+
* `fallback` when the input is the zero value for its type. We pick
|
|
2285
|
+
* the comparison based on the fallback literal's shape.
|
|
2286
|
+
*
|
|
2287
|
+
* Asymmetry on bool defaults is intentional and worth flagging:
|
|
2288
|
+
* - For a `true` default, the generated expression is
|
|
2289
|
+
* `(in.X || true)` — which is **always `true`**. Go has no
|
|
2290
|
+
* unset-vs-explicit-false distinction at the struct-field level,
|
|
2291
|
+
* so any caller wanting to thread `false` through has to set it
|
|
2292
|
+
* after `NewXxxProps` rather than via the input struct.
|
|
2293
|
+
* - For a `false` default, the Go zero value already matches, so
|
|
2294
|
+
* the helper is a no-op (returns `ref` unchanged).
|
|
2295
|
+
* Numeric `0` defaults are similarly indistinguishable from "unset"
|
|
2296
|
+
* and pass through as the zero value; non-zero numeric defaults
|
|
2297
|
+
* substitute, matching the JSX behavior of `(initial = 5) => ...`.
|
|
2298
|
+
*/
|
|
2299
|
+
private applyGoFallback(ref: string, fallback: string): string {
|
|
2300
|
+
if (fallback === 'true' || fallback === 'false') {
|
|
2301
|
+
return fallback === 'true' ? `(${ref} || true)` : ref
|
|
2302
|
+
}
|
|
2303
|
+
if (/^-?\d+(\.\d+)?$/.test(fallback)) {
|
|
2304
|
+
if (fallback === '0') return ref
|
|
2305
|
+
return `func() int { if ${ref} == 0 { return ${fallback} }; return ${ref} }()`
|
|
2306
|
+
}
|
|
2307
|
+
// String fallback (quoted)
|
|
2308
|
+
return `func() string { if ${ref} == "" { return ${fallback} }; return ${ref} }()`
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
private goLiteral(value: string): string {
|
|
2312
|
+
// Boolean
|
|
2313
|
+
if (value === 'true' || value === 'false') return value
|
|
2314
|
+
// Number
|
|
2315
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) return value
|
|
2316
|
+
// String with single quotes -> Go double quotes
|
|
2317
|
+
if (value.startsWith("'") && value.endsWith("'")) {
|
|
2318
|
+
return `"${value.slice(1, -1)}"`
|
|
2319
|
+
}
|
|
2320
|
+
// String with double quotes -> keep as is
|
|
2321
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
2322
|
+
return value
|
|
2323
|
+
}
|
|
2324
|
+
// Default: wrap in quotes
|
|
2325
|
+
return `"${value}"`
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
/**
|
|
2329
|
+
* Public entry point for node rendering. Delegates to the shared
|
|
2330
|
+
* `IRNodeEmitter` dispatcher (#1290 step 1); per-kind logic lives in
|
|
2331
|
+
* the `IRNodeEmitter` methods below.
|
|
2332
|
+
*/
|
|
2333
|
+
renderNode(node: IRNode, ctx?: GoRenderCtx): string {
|
|
2334
|
+
return emitIRNode<GoRenderCtx>(node, this, ctx ?? {})
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
// ===========================================================================
|
|
2338
|
+
// IRNodeEmitter implementation (Go templates)
|
|
2339
|
+
// ===========================================================================
|
|
2340
|
+
|
|
2341
|
+
emitElement(node: IRElement, _ctx: GoRenderCtx, _emit: EmitIRNode<GoRenderCtx>): string {
|
|
2342
|
+
return this.renderElement(node)
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
emitText(node: IRText): string {
|
|
2346
|
+
return node.value
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
emitExpression(node: IRExpression): string {
|
|
2350
|
+
return this.renderExpression(node)
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
emitConditional(node: IRConditional, _ctx: GoRenderCtx, _emit: EmitIRNode<GoRenderCtx>): string {
|
|
2354
|
+
return this.renderConditional(node)
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
emitLoop(node: IRLoop, _ctx: GoRenderCtx, _emit: EmitIRNode<GoRenderCtx>): string {
|
|
2358
|
+
return this.renderLoop(node)
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
emitComponent(node: IRComponent, ctx: GoRenderCtx, _emit: EmitIRNode<GoRenderCtx>): string {
|
|
2362
|
+
return this.renderComponent(node, ctx)
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
emitFragment(node: IRFragment, _ctx: GoRenderCtx, _emit: EmitIRNode<GoRenderCtx>): string {
|
|
2366
|
+
return this.renderFragment(node)
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
emitSlot(node: IRSlot): string {
|
|
2370
|
+
return this.renderSlot(node)
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
emitIfStatement(node: IRIfStatement, ctx: GoRenderCtx, _emit: EmitIRNode<GoRenderCtx>): string {
|
|
2374
|
+
return this.renderIfStatement(node, ctx)
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
emitProvider(node: IRProvider, _ctx: GoRenderCtx, _emit: EmitIRNode<GoRenderCtx>): string {
|
|
2378
|
+
return this.renderChildren(node.children)
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
emitAsync(node: IRAsync, _ctx: GoRenderCtx, _emit: EmitIRNode<GoRenderCtx>): string {
|
|
2382
|
+
return this.renderAsync(node)
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
renderElement(element: IRElement): string {
|
|
2386
|
+
const tag = element.tag
|
|
2387
|
+
const attrs = this.renderAttributes(element)
|
|
2388
|
+
const children = this.renderChildren(element.children)
|
|
2389
|
+
|
|
2390
|
+
let hydrationAttrs = ''
|
|
2391
|
+
if (element.needsScope) {
|
|
2392
|
+
hydrationAttrs += ` ${this.renderScopeMarker('.ScopeID')}`
|
|
2393
|
+
}
|
|
2394
|
+
if (element.slotId) {
|
|
2395
|
+
hydrationAttrs += ` ${this.renderSlotMarker(element.slotId)}`
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
const voidElements = [
|
|
2399
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
2400
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
2401
|
+
]
|
|
2402
|
+
|
|
2403
|
+
if (voidElements.includes(tag.toLowerCase())) {
|
|
2404
|
+
return `<${tag}${attrs}${hydrationAttrs}>`
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
renderExpression(expr: IRExpression): string {
|
|
2411
|
+
// Handle @client directive - render comment marker for client-side evaluation
|
|
2412
|
+
// The expression will be evaluated in ClientJS via updateClientMarker()
|
|
2413
|
+
if (expr.clientOnly) {
|
|
2414
|
+
if (expr.slotId) {
|
|
2415
|
+
return `{{bfComment "client:${expr.slotId}"}}`
|
|
2416
|
+
}
|
|
2417
|
+
return ''
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
const goExpr = this.convertExpressionToGo(expr.expr)
|
|
2421
|
+
|
|
2422
|
+
// If the expression already contains Go template blocks (e.g., {{with ...}}),
|
|
2423
|
+
// don't wrap it again in {{...}} to avoid double-wrapping.
|
|
2424
|
+
// Use comment markers instead of <span> to avoid changing DOM structure.
|
|
2425
|
+
if (goExpr.startsWith('{{')) {
|
|
2426
|
+
if (expr.slotId) {
|
|
2427
|
+
return `{{bfTextStart "${expr.slotId}"}}${goExpr}{{bfTextEnd}}`
|
|
2428
|
+
}
|
|
2429
|
+
return goExpr
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
// Mark expressions with slotId using comment nodes for client JS to find.
|
|
2433
|
+
// This includes reactive expressions AND loop-param-dependent expressions.
|
|
2434
|
+
if (expr.slotId) {
|
|
2435
|
+
return `{{bfTextStart "${expr.slotId}"}}{{${goExpr}}}{{bfTextEnd}}`
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
return `{{${goExpr}}}`
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
/**
|
|
2442
|
+
* Render a client-only conditional as comment markers.
|
|
2443
|
+
* Used when @client directive is applied to an unsupported conditional.
|
|
2444
|
+
* The condition is evaluated on the client side via insert().
|
|
2445
|
+
*/
|
|
2446
|
+
private renderClientOnlyConditional(cond: IRConditional): string {
|
|
2447
|
+
if (cond.slotId) {
|
|
2448
|
+
// Render comment markers (empty initially, client will populate)
|
|
2449
|
+
return `{{bfComment "cond-start:${cond.slotId}"}}{{bfComment "cond-end:${cond.slotId}"}}`
|
|
2450
|
+
}
|
|
2451
|
+
return ''
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
/**
|
|
2455
|
+
* Render a ParsedExpr to Go template syntax via the shared
|
|
2456
|
+
* dispatcher (#1250 phase 1). The per-kind logic lives in the
|
|
2457
|
+
* `ParsedExprEmitter` methods below; this method is a thin wrapper
|
|
2458
|
+
* so existing call sites keep working.
|
|
2459
|
+
*/
|
|
2460
|
+
private renderParsedExpr(expr: ParsedExpr): string {
|
|
2461
|
+
return emitParsedExpr(expr, this)
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
// ===========================================================================
|
|
2465
|
+
// ParsedExprEmitter implementation (Go template syntax)
|
|
2466
|
+
// ===========================================================================
|
|
2467
|
+
|
|
2468
|
+
identifier(name: string): string {
|
|
2469
|
+
return `.${this.capitalizeFieldName(name)}`
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
literal(value: string | number | boolean | null, literalType: LiteralType): string {
|
|
2473
|
+
if (literalType === 'string') return `"${value}"`
|
|
2474
|
+
if (literalType === 'null') return '""'
|
|
2475
|
+
return String(value)
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
|
|
2479
|
+
// Signal call: count() -> .Count
|
|
2480
|
+
if (callee.kind === 'identifier' && args.length === 0) {
|
|
2481
|
+
return `.${this.capitalizeFieldName(callee.name)}`
|
|
2482
|
+
}
|
|
2483
|
+
// Array methods (`.join` and any others added to ArrayMethod, #1443)
|
|
2484
|
+
// are lifted into the `array-method` IR kind at parse time, so
|
|
2485
|
+
// they never reach this dispatcher. See `arrayMethod()` below.
|
|
2486
|
+
// Identifier-path primitive callee (#1188): if the JS call resolves
|
|
2487
|
+
// to a path registered on `templatePrimitives` (e.g. `JSON.stringify`,
|
|
2488
|
+
// `Math.floor`), substitute the Go template form. The emit fn
|
|
2489
|
+
// receives args already rendered to Go template syntax. Wrap in
|
|
2490
|
+
// parens to preserve operator precedence in the surrounding
|
|
2491
|
+
// expression (e.g. `bf_floor x` composed inside `gt (bf_floor x) 3`).
|
|
2492
|
+
//
|
|
2493
|
+
// Arity is checked against `templatePrimitiveArities` so a wrong-arity
|
|
2494
|
+
// call (`JSON.stringify()`, `JSON.stringify(x, replacer)`) falls
|
|
2495
|
+
// through to the standard BF101 path instead of emitting invalid
|
|
2496
|
+
// Go template syntax via `args[0]` on a missing or extra argument.
|
|
2497
|
+
const path = identifierPath(callee)
|
|
2498
|
+
if (path && this.templatePrimitives[path]) {
|
|
2499
|
+
const expected = this.templatePrimitiveArities[path]
|
|
2500
|
+
if (expected === undefined || args.length === expected) {
|
|
2501
|
+
const renderedArgs = args.map(emit)
|
|
2502
|
+
return `(${this.templatePrimitives[path](renderedArgs)})`
|
|
2503
|
+
}
|
|
2504
|
+
this.errors.push({
|
|
2505
|
+
code: 'BF101',
|
|
2506
|
+
severity: 'error',
|
|
2507
|
+
message: `templatePrimitive '${path}' expects ${expected} arg(s), got ${args.length}`,
|
|
2508
|
+
loc: this.makeLoc(),
|
|
2509
|
+
suggestion: {
|
|
2510
|
+
message: `Call '${path}' with exactly ${expected} argument(s), or wrap the JSX expression in /* @client */ to defer evaluation.`,
|
|
2511
|
+
},
|
|
2512
|
+
})
|
|
2513
|
+
}
|
|
2514
|
+
// Generic call: render callee and args.
|
|
2515
|
+
const calleeStr = emit(callee)
|
|
2516
|
+
if (args.length === 0) return calleeStr
|
|
2517
|
+
const argsStr = args.map(emit).join(' ')
|
|
2518
|
+
return `${calleeStr} ${argsStr}`
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
member(
|
|
2522
|
+
object: ParsedExpr,
|
|
2523
|
+
property: string,
|
|
2524
|
+
_computed: boolean,
|
|
2525
|
+
emit: (e: ParsedExpr) => string,
|
|
2526
|
+
): string {
|
|
2527
|
+
// .length on higher-order filter → len (bf_filter ...)
|
|
2528
|
+
if (property === 'length' && object.kind === 'higher-order') {
|
|
2529
|
+
const result = this.renderFilterLengthExpr(object, emit)
|
|
2530
|
+
if (result) return result
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
// find().property → {{with bf_find ...}}{{.Property}}{{end}}
|
|
2534
|
+
if (object.kind === 'higher-order' && object.method === 'find') {
|
|
2535
|
+
const findResult = this.renderHigherOrderExpr(object, emit)
|
|
2536
|
+
if (findResult) {
|
|
2537
|
+
return `{{with ${findResult}}}{{.${this.capitalizeFieldName(property)}}}{{end}}`
|
|
2538
|
+
}
|
|
2539
|
+
const templateBlock = this.renderFindTemplateBlock(
|
|
2540
|
+
object, emit, this.capitalizeFieldName(property),
|
|
2541
|
+
)
|
|
2542
|
+
if (templateBlock) return templateBlock
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
// SolidJS-style props pattern: props.xxx -> .Xxx
|
|
2546
|
+
if (object.kind === 'identifier' && this.propsObjectName && object.name === this.propsObjectName) {
|
|
2547
|
+
return `.${this.capitalizeFieldName(property)}`
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
// Inside a loop, the loop param variable refers to the current item
|
|
2551
|
+
// (dot). e.g. `msg.role` inside `{{range $_, $msg := .Messages}}` → `.Role`
|
|
2552
|
+
const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
|
|
2553
|
+
if (object.kind === 'identifier' && currentLoopParam && object.name === currentLoopParam) {
|
|
2554
|
+
return `.${this.capitalizeFieldName(property)}`
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
const obj = emit(object)
|
|
2558
|
+
if (property === 'length') return `len ${obj}`
|
|
2559
|
+
return `${obj}.${this.capitalizeFieldName(property)}`
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
2563
|
+
const l = emit(left)
|
|
2564
|
+
const r = emit(right)
|
|
2565
|
+
switch (op) {
|
|
2566
|
+
case '===':
|
|
2567
|
+
case '==':
|
|
2568
|
+
return `eq ${l} ${r}`
|
|
2569
|
+
case '!==':
|
|
2570
|
+
case '!=':
|
|
2571
|
+
return `ne ${l} ${r}`
|
|
2572
|
+
case '>':
|
|
2573
|
+
return `gt ${l} ${r}`
|
|
2574
|
+
case '<':
|
|
2575
|
+
return `lt ${l} ${r}`
|
|
2576
|
+
case '>=':
|
|
2577
|
+
return `ge ${l} ${r}`
|
|
2578
|
+
case '<=':
|
|
2579
|
+
return `le ${l} ${r}`
|
|
2580
|
+
case '+':
|
|
2581
|
+
return `bf_add ${l} ${r}`
|
|
2582
|
+
case '-':
|
|
2583
|
+
return `bf_sub ${l} ${r}`
|
|
2584
|
+
case '*':
|
|
2585
|
+
return `bf_mul ${l} ${r}`
|
|
2586
|
+
case '/':
|
|
2587
|
+
return `bf_div ${l} ${r}`
|
|
2588
|
+
case '%':
|
|
2589
|
+
return `bf_mod ${l} ${r}`
|
|
2590
|
+
default:
|
|
2591
|
+
return `${l} ${op} ${r}`
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
2596
|
+
const arg = emit(argument)
|
|
2597
|
+
if (op === '!') return `not ${arg}`
|
|
2598
|
+
if (op === '-') return `bf_neg ${arg}`
|
|
2599
|
+
return arg
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
logical(
|
|
2603
|
+
op: '&&' | '||' | '??',
|
|
2604
|
+
left: ParsedExpr,
|
|
2605
|
+
right: ParsedExpr,
|
|
2606
|
+
emit: (e: ParsedExpr) => string,
|
|
2607
|
+
): string {
|
|
2608
|
+
const l = emit(left)
|
|
2609
|
+
const r = emit(right)
|
|
2610
|
+
const wrapLeft = this.needsParens(left) ? `(${l})` : l
|
|
2611
|
+
const wrapRight = this.needsParens(right) ? `(${r})` : r
|
|
2612
|
+
if (op === '&&') return `and ${wrapLeft} ${wrapRight}`
|
|
2613
|
+
return `or ${wrapLeft} ${wrapRight}`
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
conditional(
|
|
2617
|
+
test: ParsedExpr,
|
|
2618
|
+
consequent: ParsedExpr,
|
|
2619
|
+
alternate: ParsedExpr,
|
|
2620
|
+
emit: (e: ParsedExpr) => string,
|
|
2621
|
+
): string {
|
|
2622
|
+
const t = emit(test)
|
|
2623
|
+
// Nested conditionals already return complete {{if}}...{{end}} blocks;
|
|
2624
|
+
// literals return bare text (used within attributes).
|
|
2625
|
+
const c = this.renderConditionalBranch(consequent)
|
|
2626
|
+
const a = this.renderConditionalBranch(alternate)
|
|
2627
|
+
return `{{if ${t}}}${c}{{else}}${a}{{end}}`
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
templateLiteral(parts: TemplatePart[], emit: (e: ParsedExpr) => string): string {
|
|
2631
|
+
let result = ''
|
|
2632
|
+
for (const part of parts) {
|
|
2633
|
+
if (part.type === 'string') {
|
|
2634
|
+
result += part.value
|
|
2635
|
+
} else {
|
|
2636
|
+
result += `{{${emit(part.expr)}}}`
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
return result
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
arrowFn(param: string, _body: ParsedExpr, _emit: (e: ParsedExpr) => string): string {
|
|
2643
|
+
// Arrow functions shouldn't appear standalone in rendering.
|
|
2644
|
+
return `[ARROW-FN: ${param} => ...]`
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
|
|
2648
|
+
// `[a, b]` lowers to `bf_arr a b` (#1443) — a variadic runtime
|
|
2649
|
+
// helper that returns `[]any`. The Go template `slice` builtin
|
|
2650
|
+
// can't carry the JS-style heterogeneous element types (string,
|
|
2651
|
+
// signal call, prop reference) without coercion, so we use a
|
|
2652
|
+
// BF-owned helper. Elements get parens so a nested call doesn't
|
|
2653
|
+
// run together with its arguments (`bf_arr .A (bf_filter ...) .B`).
|
|
2654
|
+
// Empty `[]` is `bf_arr` with no args — the helper handles it.
|
|
2655
|
+
if (elements.length === 0) return 'bf_arr'
|
|
2656
|
+
const parts = elements.map(el => {
|
|
2657
|
+
const rendered = emit(el)
|
|
2658
|
+
// Wrap multi-token results (function calls, dotted paths with
|
|
2659
|
+
// arguments) in parens. Simple identifiers / literals stay bare.
|
|
2660
|
+
return rendered.includes(' ') ? `(${rendered})` : rendered
|
|
2661
|
+
})
|
|
2662
|
+
return `bf_arr ${parts.join(' ')}`
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
higherOrder(
|
|
2666
|
+
method: HigherOrderMethod,
|
|
2667
|
+
object: ParsedExpr,
|
|
2668
|
+
param: string,
|
|
2669
|
+
predicate: ParsedExpr,
|
|
2670
|
+
emit: (e: ParsedExpr) => string,
|
|
2671
|
+
): string {
|
|
2672
|
+
const reconstructed = { kind: 'higher-order' as const, method, object, param, predicate }
|
|
2673
|
+
const result = this.renderHigherOrderExpr(reconstructed, emit)
|
|
2674
|
+
if (result) return result
|
|
2675
|
+
if (method === 'find' || method === 'findIndex') {
|
|
2676
|
+
const templateBlock = this.renderFindTemplateBlock(reconstructed, emit)
|
|
2677
|
+
if (templateBlock) return templateBlock
|
|
2678
|
+
}
|
|
2679
|
+
if (method === 'every' || method === 'some') {
|
|
2680
|
+
const templateBlock = this.renderEverySomeTemplateBlock(reconstructed, emit)
|
|
2681
|
+
if (templateBlock) return templateBlock
|
|
2682
|
+
}
|
|
2683
|
+
// No Go template form for this higher-order shape. Pre-#1443 the
|
|
2684
|
+
// upstream `UNSUPPORTED_METHODS` parser gate refused most of these
|
|
2685
|
+
// before they reached the emitter, so this sentinel was unreachable
|
|
2686
|
+
// in practice; #1443 widened the parser surface (synthetic
|
|
2687
|
+
// identity predicate for `.filter(Boolean)`) and exposed the gap.
|
|
2688
|
+
// Record BF101 explicitly so the diagnostic surfaces at build time
|
|
2689
|
+
// instead of leaking `[UNSUPPORTED: filter]` into the template
|
|
2690
|
+
// (Copilot review on #1444). The stacked Go-side PR (#1445) adds
|
|
2691
|
+
// the actual identity-predicate lowering so the user-visible
|
|
2692
|
+
// case stops hitting this fallback altogether.
|
|
2693
|
+
this.errors.push({
|
|
2694
|
+
code: 'BF101',
|
|
2695
|
+
severity: 'error',
|
|
2696
|
+
message: `Higher-order method '.${method}' shape cannot be lowered to a Go template action`,
|
|
2697
|
+
loc: this.makeLoc(),
|
|
2698
|
+
suggestion: {
|
|
2699
|
+
message: 'Options:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code',
|
|
2700
|
+
},
|
|
2701
|
+
})
|
|
2702
|
+
return `""`
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
arrayMethod(
|
|
2706
|
+
method: ArrayMethod,
|
|
2707
|
+
object: ParsedExpr,
|
|
2708
|
+
args: ParsedExpr[],
|
|
2709
|
+
emit: (e: ParsedExpr) => string,
|
|
2710
|
+
): string {
|
|
2711
|
+
// #1443: `bf_join` is registered in the runtime FuncMap as a
|
|
2712
|
+
// wrapper around `strings.Join`. The exhaustive switch on
|
|
2713
|
+
// `method` here mirrors the IR-level discriminator — adding a
|
|
2714
|
+
// new `ArrayMethod` variant becomes a TS compile error until
|
|
2715
|
+
// every adapter declares its lowering.
|
|
2716
|
+
switch (method) {
|
|
2717
|
+
case 'join': {
|
|
2718
|
+
const obj = emit(object)
|
|
2719
|
+
const sep = emit(args[0])
|
|
2720
|
+
// Both operands need paren-wrapping when they emit a
|
|
2721
|
+
// multi-token prefix-call form (e.g. `sep` lowering to
|
|
2722
|
+
// `bf_trim .Raw` would make Go template parse
|
|
2723
|
+
// `bf_join (...) bf_trim .Raw` as four args to `bf_join`).
|
|
2724
|
+
// Identifiers / literals stay bare to keep the common case
|
|
2725
|
+
// readable. Copilot review on #1445 surfaced the gap.
|
|
2726
|
+
return `bf_join (${obj}) ${wrapIfMultiToken(sep)}`
|
|
2727
|
+
}
|
|
2728
|
+
case 'includes': {
|
|
2729
|
+
// Both `arr.includes(x)` and `str.includes(sub)` route here —
|
|
2730
|
+
// the parser can't disambiguate the receiver type. The Go
|
|
2731
|
+
// runtime's `Includes` helper inspects `reflect.Kind()` and
|
|
2732
|
+
// dispatches: slices/arrays use DeepEqual element search,
|
|
2733
|
+
// strings use `strings.Contains`. See packages/adapter-go-
|
|
2734
|
+
// template/runtime/bf.go.
|
|
2735
|
+
const obj = emit(object)
|
|
2736
|
+
const needle = emit(args[0])
|
|
2737
|
+
return `bf_includes ${wrapIfMultiToken(obj)} ${wrapIfMultiToken(needle)}`
|
|
2738
|
+
}
|
|
2739
|
+
case 'indexOf':
|
|
2740
|
+
case 'lastIndexOf': {
|
|
2741
|
+
// Value-equality search (DeepEqual). The existing
|
|
2742
|
+
// `bf_find_index` operates on struct-field equality (used by
|
|
2743
|
+
// the higher-order `.find` lowering); the new helpers handle
|
|
2744
|
+
// the bare `.indexOf(x)` / `.lastIndexOf(x)` shape where
|
|
2745
|
+
// there's no `.field` accessor on the elements.
|
|
2746
|
+
const fn = method === 'indexOf' ? 'bf_index_of' : 'bf_last_index_of'
|
|
2747
|
+
const obj = emit(object)
|
|
2748
|
+
const needle = emit(args[0])
|
|
2749
|
+
return `${fn} ${wrapIfMultiToken(obj)} ${wrapIfMultiToken(needle)}`
|
|
2750
|
+
}
|
|
2751
|
+
case 'at': {
|
|
2752
|
+
// `.at(i)` supports negative indices (`.at(-1)` → last
|
|
2753
|
+
// element). The Go `bf_at` helper was already registered in
|
|
2754
|
+
// the FuncMap for the runtime — this PR wires it to the JS
|
|
2755
|
+
// method name at the adapter layer.
|
|
2756
|
+
const obj = emit(object)
|
|
2757
|
+
const idx = emit(args[0])
|
|
2758
|
+
return `bf_at ${wrapIfMultiToken(obj)} ${wrapIfMultiToken(idx)}`
|
|
2759
|
+
}
|
|
2760
|
+
case 'concat': {
|
|
2761
|
+
// `.concat(other)` merges two arrays. The runtime helper
|
|
2762
|
+
// `bf_concat` reflects over both operands so callers can
|
|
2763
|
+
// mix `[]string` + `[]string` or `[]any` + `[]string` etc.
|
|
2764
|
+
// without per-call-site type-juggling.
|
|
2765
|
+
const a = emit(object)
|
|
2766
|
+
const b = emit(args[0])
|
|
2767
|
+
return `bf_concat ${wrapIfMultiToken(a)} ${wrapIfMultiToken(b)}`
|
|
2768
|
+
}
|
|
2769
|
+
case 'slice': {
|
|
2770
|
+
// `.slice(start)` / `.slice(start, end)` — both forms route
|
|
2771
|
+
// through `bf_slice`. The runtime helper treats a `nil`
|
|
2772
|
+
// `end` (the variadic-arg absence) as "to length", matching
|
|
2773
|
+
// the JS semantic. Out-of-bounds indices clamp instead of
|
|
2774
|
+
// panicking (also JS-compat); same with `start > end`
|
|
2775
|
+
// returning an empty slice.
|
|
2776
|
+
const recv = emit(object)
|
|
2777
|
+
const start = emit(args[0])
|
|
2778
|
+
if (args.length === 1) {
|
|
2779
|
+
return `bf_slice ${wrapIfMultiToken(recv)} ${wrapIfMultiToken(start)}`
|
|
2780
|
+
}
|
|
2781
|
+
const end = emit(args[1])
|
|
2782
|
+
return `bf_slice ${wrapIfMultiToken(recv)} ${wrapIfMultiToken(start)} ${wrapIfMultiToken(end)}`
|
|
2783
|
+
}
|
|
2784
|
+
case 'reverse':
|
|
2785
|
+
case 'toReversed': {
|
|
2786
|
+
// SSR templates render a snapshot of state, so JS's
|
|
2787
|
+
// mutate-and-return-receiver (`reverse`) vs return-new-
|
|
2788
|
+
// array (`toReversed`) distinction has no template-level
|
|
2789
|
+
// meaning. Both shapes route through `bf_reverse`, which
|
|
2790
|
+
// always returns a fresh `[]any` (safest interpretation —
|
|
2791
|
+
// the input array is whatever the template binds).
|
|
2792
|
+
const recv = emit(object)
|
|
2793
|
+
return `bf_reverse ${wrapIfMultiToken(recv)}`
|
|
2794
|
+
}
|
|
2795
|
+
case 'toLowerCase': {
|
|
2796
|
+
// The Go runtime registers `bf_lower` from a prior code path;
|
|
2797
|
+
// this PR is purely the adapter wiring of the JS method name
|
|
2798
|
+
// to that helper.
|
|
2799
|
+
const recv = emit(object)
|
|
2800
|
+
return `bf_lower ${wrapIfMultiToken(recv)}`
|
|
2801
|
+
}
|
|
2802
|
+
case 'toUpperCase': {
|
|
2803
|
+
// Mirrors `toLowerCase` — pre-existing `bf_upper` runtime
|
|
2804
|
+
// helper, just adapter wiring.
|
|
2805
|
+
const recv = emit(object)
|
|
2806
|
+
return `bf_upper ${wrapIfMultiToken(recv)}`
|
|
2807
|
+
}
|
|
2808
|
+
case 'trim': {
|
|
2809
|
+
// Pre-existing `bf_trim` runtime helper (wraps
|
|
2810
|
+
// `strings.TrimSpace`). Adapter wiring only.
|
|
2811
|
+
const recv = emit(object)
|
|
2812
|
+
return `bf_trim ${wrapIfMultiToken(recv)}`
|
|
2813
|
+
}
|
|
2814
|
+
default: {
|
|
2815
|
+
const _exhaustive: never = method
|
|
2816
|
+
throw new Error(`Go arrayMethod: unhandled ArrayMethod '${(_exhaustive as string)}'`)
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
sortMethod(
|
|
2822
|
+
method: 'sort' | 'toSorted',
|
|
2823
|
+
object: ParsedExpr,
|
|
2824
|
+
comparator: SortComparator,
|
|
2825
|
+
emit: (e: ParsedExpr) => string,
|
|
2826
|
+
): string {
|
|
2827
|
+
// `.sort(cmp)` / `.toSorted(cmp)` lowering (#1448 Tier B). Both
|
|
2828
|
+
// shapes share the helper — template SSR context renders a
|
|
2829
|
+
// snapshot, so the JS mutate vs return-new distinction has no
|
|
2830
|
+
// template-level meaning. The same emit serves the standalone
|
|
2831
|
+
// call site here and the chained `.sort().map()` loop hoist in
|
|
2832
|
+
// `renderLoop` below (both feed `bf_sort` the same 4 string
|
|
2833
|
+
// operands).
|
|
2834
|
+
//
|
|
2835
|
+
// `method` is preserved for future divergence (e.g. should one
|
|
2836
|
+
// flavour warn?) but is unused today.
|
|
2837
|
+
void method
|
|
2838
|
+
return emitBfSort(emit(object), comparator)
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
unsupported(raw: string, _reason: string): string {
|
|
2842
|
+
// Should not happen if `isSupported` was checked at parse time.
|
|
2843
|
+
return `[UNSUPPORTED: ${raw}]`
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
/**
|
|
2847
|
+
* Extract field name and negation from a simple predicate.
|
|
2848
|
+
* t => t.done → { field: "Done", negated: false }
|
|
2849
|
+
* t => !t.done → { field: "Done", negated: true }
|
|
2850
|
+
*/
|
|
2851
|
+
private extractFieldPredicate(pred: ParsedExpr, param: string): { field: string | null; negated: boolean } {
|
|
2852
|
+
// t.done
|
|
2853
|
+
if (pred.kind === 'member' && pred.object.kind === 'identifier' && pred.object.name === param) {
|
|
2854
|
+
return { field: this.capitalizeFieldName(pred.property), negated: false }
|
|
2855
|
+
}
|
|
2856
|
+
// !t.done
|
|
2857
|
+
if (pred.kind === 'unary' && pred.op === '!' && pred.argument.kind === 'member') {
|
|
2858
|
+
const mem = pred.argument
|
|
2859
|
+
if (mem.object.kind === 'identifier' && mem.object.name === param) {
|
|
2860
|
+
return { field: this.capitalizeFieldName(mem.property), negated: true }
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
return { field: null, negated: false }
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
/**
|
|
2867
|
+
* Extract field name and value from an equality predicate.
|
|
2868
|
+
* Extends extractFieldPredicate to also handle equality comparisons.
|
|
2869
|
+
*
|
|
2870
|
+
* t.done → { field: "Done", value: "true" }
|
|
2871
|
+
* !t.done → { field: "Done", value: "false" }
|
|
2872
|
+
* u.id === selectedId() → { field: "Id", value: <rendered expr> }
|
|
2873
|
+
* selectedId() === u.id → same (supports both operand orders)
|
|
2874
|
+
*/
|
|
2875
|
+
private extractEqualityPredicate(
|
|
2876
|
+
pred: ParsedExpr,
|
|
2877
|
+
param: string,
|
|
2878
|
+
renderValue: (e: ParsedExpr) => string
|
|
2879
|
+
): { field: string; value: string } | null {
|
|
2880
|
+
// Boolean field: t.done → { field: "Done", value: "true" }
|
|
2881
|
+
if (pred.kind === 'member' && pred.object.kind === 'identifier' && pred.object.name === param) {
|
|
2882
|
+
return { field: this.capitalizeFieldName(pred.property), value: 'true' }
|
|
2883
|
+
}
|
|
2884
|
+
// Negated boolean: !t.done → { field: "Done", value: "false" }
|
|
2885
|
+
if (pred.kind === 'unary' && pred.op === '!' && pred.argument.kind === 'member') {
|
|
2886
|
+
const mem = pred.argument
|
|
2887
|
+
if (mem.object.kind === 'identifier' && mem.object.name === param) {
|
|
2888
|
+
return { field: this.capitalizeFieldName(mem.property), value: 'false' }
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
// Equality: u.id === expr or expr === u.id
|
|
2892
|
+
if (pred.kind === 'binary' && (pred.op === '===' || pred.op === '==')) {
|
|
2893
|
+
// Left is param.field
|
|
2894
|
+
if (pred.left.kind === 'member' && pred.left.object.kind === 'identifier' && pred.left.object.name === param) {
|
|
2895
|
+
return { field: this.capitalizeFieldName(pred.left.property), value: renderValue(pred.right) }
|
|
2896
|
+
}
|
|
2897
|
+
// Right is param.field (reversed operand order)
|
|
2898
|
+
if (pred.right.kind === 'member' && pred.right.object.kind === 'identifier' && pred.right.object.name === param) {
|
|
2899
|
+
return { field: this.capitalizeFieldName(pred.right.property), value: renderValue(pred.left) }
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
return null
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
/**
|
|
2906
|
+
* Render a higher-order expression (filter, every, some, find, findIndex) to Go template.
|
|
2907
|
+
* Returns null if the expression is not supported.
|
|
2908
|
+
*
|
|
2909
|
+
* @param expr - The higher-order expression
|
|
2910
|
+
* @param renderArray - Function to render the array expression (allows recursion via different methods)
|
|
2911
|
+
*/
|
|
2912
|
+
private renderHigherOrderExpr(
|
|
2913
|
+
expr: Extract<ParsedExpr, { kind: 'higher-order' }>,
|
|
2914
|
+
renderArray: (e: ParsedExpr) => string
|
|
2915
|
+
): string | null {
|
|
2916
|
+
const arrayExpr = renderArray(expr.object)
|
|
2917
|
+
|
|
2918
|
+
if (expr.method === 'every' || expr.method === 'some') {
|
|
2919
|
+
const { field } = this.extractFieldPredicate(expr.predicate, expr.param)
|
|
2920
|
+
if (!field) return null
|
|
2921
|
+
return expr.method === 'every'
|
|
2922
|
+
? `bf_every ${arrayExpr} "${field}"`
|
|
2923
|
+
: `bf_some ${arrayExpr} "${field}"`
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
if (expr.method === 'filter') {
|
|
2927
|
+
// .filter(Boolean) — synthesised by the parser as an identity
|
|
2928
|
+
// predicate (`x => x`) so adapters can reuse the higher-order
|
|
2929
|
+
// lowering path (#1443). Lower to `bf_filter_truthy` so the
|
|
2930
|
+
// registry Slot's `[a, b].filter(Boolean).join(' ')` chain
|
|
2931
|
+
// renders server-side on Go templates.
|
|
2932
|
+
if (
|
|
2933
|
+
expr.predicate.kind === 'identifier' &&
|
|
2934
|
+
expr.predicate.name === expr.param
|
|
2935
|
+
) {
|
|
2936
|
+
return `bf_filter_truthy (${arrayExpr})`
|
|
2937
|
+
}
|
|
2938
|
+
const { field, negated } = this.extractFieldPredicate(expr.predicate, expr.param)
|
|
2939
|
+
if (!field) return null
|
|
2940
|
+
const value = negated ? 'false' : 'true'
|
|
2941
|
+
return `bf_filter ${arrayExpr} "${field}" ${value}`
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
if (expr.method === 'find' || expr.method === 'findIndex') {
|
|
2945
|
+
const eqPred = this.extractEqualityPredicate(
|
|
2946
|
+
expr.predicate, expr.param, e => this.renderParsedExpr(e)
|
|
2947
|
+
)
|
|
2948
|
+
if (!eqPred) return null
|
|
2949
|
+
const func = expr.method === 'find' ? 'bf_find' : 'bf_find_index'
|
|
2950
|
+
return `${func} ${arrayExpr} "${eqPred.field}" ${eqPred.value}`
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
return null
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
/**
|
|
2957
|
+
* Render find()/findIndex() with complex predicates using {{range}}{{if}}...{{break}} blocks.
|
|
2958
|
+
* Falls back from bf_find/bf_find_index when extractEqualityPredicate returns null.
|
|
2959
|
+
* Reuses renderFilterExpr for condition rendering.
|
|
2960
|
+
*
|
|
2961
|
+
* @param expr - The higher-order find/findIndex expression
|
|
2962
|
+
* @param renderArray - Function to render the array expression
|
|
2963
|
+
* @param propertyAccess - Optional property to access on the found element (for find().property)
|
|
2964
|
+
*/
|
|
2965
|
+
private renderFindTemplateBlock(
|
|
2966
|
+
expr: Extract<ParsedExpr, { kind: 'higher-order' }>,
|
|
2967
|
+
renderArray: (e: ParsedExpr) => string,
|
|
2968
|
+
propertyAccess?: string
|
|
2969
|
+
): string | null {
|
|
2970
|
+
const arrayExpr = renderArray(expr.object)
|
|
2971
|
+
const condition = this.renderFilterExpr(expr.predicate, expr.param)
|
|
2972
|
+
if (condition.includes('[UNSUPPORTED')) return null
|
|
2973
|
+
|
|
2974
|
+
if (expr.method === 'find') {
|
|
2975
|
+
const output = propertyAccess ? `{{.${propertyAccess}}}` : '{{.}}'
|
|
2976
|
+
return `{{range ${arrayExpr}}}{{if ${condition}}}${output}{{break}}{{end}}{{end}}`
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
if (expr.method === 'findIndex') {
|
|
2980
|
+
return `{{range $i, $_ := ${arrayExpr}}}{{if ${condition}}}{{$i}}{{break}}{{end}}{{end}}`
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
return null
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
/**
|
|
2987
|
+
* Render every()/some() with complex predicates using {{range}}{{if}} with variable reassignment.
|
|
2988
|
+
* Falls back from bf_every/bf_some when extractFieldPredicate returns null.
|
|
2989
|
+
* Reuses renderFilterExpr for condition rendering.
|
|
2990
|
+
*
|
|
2991
|
+
* every: start true, set false on first failure, break early
|
|
2992
|
+
* some: start false, set true on first match, break early
|
|
2993
|
+
*
|
|
2994
|
+
* @param expr - The higher-order every/some expression
|
|
2995
|
+
* @param renderArray - Function to render the array expression
|
|
2996
|
+
*/
|
|
2997
|
+
private renderEverySomeTemplateBlock(
|
|
2998
|
+
expr: Extract<ParsedExpr, { kind: 'higher-order' }>,
|
|
2999
|
+
renderArray: (e: ParsedExpr) => string
|
|
3000
|
+
): string | null {
|
|
3001
|
+
const arrayExpr = renderArray(expr.object)
|
|
3002
|
+
const condition = this.renderFilterExpr(expr.predicate, expr.param)
|
|
3003
|
+
if (condition.includes('[UNSUPPORTED')) return null
|
|
3004
|
+
|
|
3005
|
+
if (expr.method === 'every') {
|
|
3006
|
+
// Negate condition: if NOT condition, set false and break
|
|
3007
|
+
const negated = this.negateGoCondition(condition)
|
|
3008
|
+
return `{{$bf_result := true}}{{range ${arrayExpr}}}{{if ${negated}}}{{$bf_result = false}}{{break}}{{end}}{{end}}{{$bf_result}}`
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
if (expr.method === 'some') {
|
|
3012
|
+
return `{{$bf_result := false}}{{range ${arrayExpr}}}{{if ${condition}}}{{$bf_result = true}}{{break}}{{end}}{{end}}{{$bf_result}}`
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
return null
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
/**
|
|
3019
|
+
* Negate a Go template condition.
|
|
3020
|
+
* Wraps in `not (...)` when the condition is a Go function call (eq, ne, gt, etc.),
|
|
3021
|
+
* otherwise uses `not condition`.
|
|
3022
|
+
*/
|
|
3023
|
+
private negateGoCondition(condition: string): string {
|
|
3024
|
+
const goFuncPattern = /^(eq|ne|gt|lt|ge|le|and|or|not|bf_)\b/
|
|
3025
|
+
if (goFuncPattern.test(condition)) {
|
|
3026
|
+
return `not (${condition})`
|
|
3027
|
+
}
|
|
3028
|
+
return `not ${condition}`
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
/**
|
|
3032
|
+
* Render .length on a filter higher-order expression.
|
|
3033
|
+
* e.g., todos().filter(t => !t.done).length → len (bf_filter .Todos "Done" false)
|
|
3034
|
+
*
|
|
3035
|
+
* @param filterExpr - The filter higher-order expression
|
|
3036
|
+
* @param renderArray - Function to render the array expression
|
|
3037
|
+
*/
|
|
3038
|
+
private renderFilterLengthExpr(
|
|
3039
|
+
filterExpr: Extract<ParsedExpr, { kind: 'higher-order' }>,
|
|
3040
|
+
renderArray: (e: ParsedExpr) => string
|
|
3041
|
+
): string | null {
|
|
3042
|
+
if (filterExpr.method !== 'filter') {
|
|
3043
|
+
return null
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
const { field, negated } = this.extractFieldPredicate(filterExpr.predicate, filterExpr.param)
|
|
3047
|
+
if (!field) {
|
|
3048
|
+
return null
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
const arrayExpr = renderArray(filterExpr.object)
|
|
3052
|
+
const value = negated ? 'false' : 'true'
|
|
3053
|
+
return `len (bf_filter ${arrayExpr} "${field}" ${value})`
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
/**
|
|
3057
|
+
* Render a predicate expression for use in Go template {{if}} conditions.
|
|
3058
|
+
* Substitutes the loop parameter (e.g., 't' in 't.done') with dot notation.
|
|
3059
|
+
*/
|
|
3060
|
+
private renderPredicateCondition(pred: ParsedExpr, param: string): string {
|
|
3061
|
+
return this.renderFilterExpr(pred, param)
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
/**
|
|
3065
|
+
* Check if expression needs parentheses when used in and/or.
|
|
3066
|
+
*/
|
|
3067
|
+
private needsParens(expr: ParsedExpr): boolean {
|
|
3068
|
+
return expr.kind === 'logical' || expr.kind === 'unary' || expr.kind === 'conditional'
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
// =============================================================================
|
|
3072
|
+
// Block Body Condition Rendering
|
|
3073
|
+
// =============================================================================
|
|
3074
|
+
|
|
3075
|
+
/**
|
|
3076
|
+
* Render block body filter into a single Go template condition.
|
|
3077
|
+
*
|
|
3078
|
+
* Example block body:
|
|
3079
|
+
* ```
|
|
3080
|
+
* filter(t => {
|
|
3081
|
+
* const f = filter()
|
|
3082
|
+
* if (f === 'active') return !t.done
|
|
3083
|
+
* if (f === 'completed') return t.done
|
|
3084
|
+
* return true
|
|
3085
|
+
* })
|
|
3086
|
+
* ```
|
|
3087
|
+
*
|
|
3088
|
+
* Becomes:
|
|
3089
|
+
* ```
|
|
3090
|
+
* or (and (eq $.Filter "active") (not .Done))
|
|
3091
|
+
* (and (eq $.Filter "completed") .Done)
|
|
3092
|
+
* (and (ne $.Filter "active") (ne $.Filter "completed"))
|
|
3093
|
+
* ```
|
|
3094
|
+
*/
|
|
3095
|
+
private renderBlockBodyCondition(
|
|
3096
|
+
statements: ParsedStatement[],
|
|
3097
|
+
param: string
|
|
3098
|
+
): string {
|
|
3099
|
+
// Build a map of local variables to their signal sources
|
|
3100
|
+
// e.g., { f: 'filter' } when we see `const f = filter()`
|
|
3101
|
+
const localVarMap = new Map<string, string>()
|
|
3102
|
+
|
|
3103
|
+
// Collect all return paths through the block body
|
|
3104
|
+
const paths = this.collectReturnPaths(statements, [], localVarMap, param)
|
|
3105
|
+
|
|
3106
|
+
if (paths.length === 0) {
|
|
3107
|
+
// No return paths found, default to true
|
|
3108
|
+
return 'true'
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
if (paths.length === 1) {
|
|
3112
|
+
// Single path: render conditions with AND, then check result
|
|
3113
|
+
const path = paths[0]
|
|
3114
|
+
return this.buildSinglePathCondition(path, param, localVarMap)
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
// Multiple paths: build OR condition
|
|
3118
|
+
return this.buildOrCondition(paths, param, localVarMap)
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
/**
|
|
3122
|
+
* Recursively collect all return paths through the statements.
|
|
3123
|
+
* Returns an array of ReturnPath objects.
|
|
3124
|
+
*/
|
|
3125
|
+
private collectReturnPaths(
|
|
3126
|
+
statements: ParsedStatement[],
|
|
3127
|
+
currentConditions: ParsedExpr[],
|
|
3128
|
+
localVarMap: Map<string, string>,
|
|
3129
|
+
param: string
|
|
3130
|
+
): Array<{ conditions: ParsedExpr[]; result: ParsedExpr }> {
|
|
3131
|
+
const paths: Array<{ conditions: ParsedExpr[]; result: ParsedExpr }> = []
|
|
3132
|
+
|
|
3133
|
+
for (const stmt of statements) {
|
|
3134
|
+
if (stmt.kind === 'var-decl') {
|
|
3135
|
+
// Track local variable to signal mapping
|
|
3136
|
+
// e.g., const f = filter() -> f maps to 'filter'
|
|
3137
|
+
if (stmt.init.kind === 'call' && stmt.init.callee.kind === 'identifier') {
|
|
3138
|
+
localVarMap.set(stmt.name, stmt.init.callee.name)
|
|
3139
|
+
}
|
|
3140
|
+
} else if (stmt.kind === 'return') {
|
|
3141
|
+
// This is a return path
|
|
3142
|
+
paths.push({
|
|
3143
|
+
conditions: [...currentConditions],
|
|
3144
|
+
result: stmt.value
|
|
3145
|
+
})
|
|
3146
|
+
// After a return, subsequent statements in this branch are unreachable
|
|
3147
|
+
break
|
|
3148
|
+
} else if (stmt.kind === 'if') {
|
|
3149
|
+
// If statement: collect paths from both branches
|
|
3150
|
+
const thenConditions = [...currentConditions, stmt.condition]
|
|
3151
|
+
const thenPaths = this.collectReturnPaths(stmt.consequent, thenConditions, localVarMap, param)
|
|
3152
|
+
paths.push(...thenPaths)
|
|
3153
|
+
|
|
3154
|
+
if (stmt.alternate) {
|
|
3155
|
+
// Negate the condition for the else branch
|
|
3156
|
+
const negatedCondition: ParsedExpr = { kind: 'unary', op: '!', argument: stmt.condition }
|
|
3157
|
+
const elseConditions = [...currentConditions, negatedCondition]
|
|
3158
|
+
const elsePaths = this.collectReturnPaths(stmt.alternate, elseConditions, localVarMap, param)
|
|
3159
|
+
paths.push(...elsePaths)
|
|
3160
|
+
} else {
|
|
3161
|
+
// No else branch: implicit fall-through (continue to next statement)
|
|
3162
|
+
// Need to track the negated condition for subsequent statements
|
|
3163
|
+
const negatedCondition: ParsedExpr = { kind: 'unary', op: '!', argument: stmt.condition }
|
|
3164
|
+
currentConditions.push(negatedCondition)
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
return paths
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
/**
|
|
3173
|
+
* Build a condition for a single return path.
|
|
3174
|
+
*/
|
|
3175
|
+
private buildSinglePathCondition(
|
|
3176
|
+
path: { conditions: ParsedExpr[]; result: ParsedExpr },
|
|
3177
|
+
param: string,
|
|
3178
|
+
localVarMap: Map<string, string>
|
|
3179
|
+
): string {
|
|
3180
|
+
// If result is a literal boolean
|
|
3181
|
+
if (path.result.kind === 'literal' && path.result.literalType === 'boolean') {
|
|
3182
|
+
if (path.result.value === true) {
|
|
3183
|
+
// Return true: the conditions themselves determine visibility
|
|
3184
|
+
if (path.conditions.length === 0) {
|
|
3185
|
+
return 'true'
|
|
3186
|
+
}
|
|
3187
|
+
return this.renderConditionsAnd(path.conditions, param, localVarMap)
|
|
3188
|
+
} else {
|
|
3189
|
+
// Return false: this path should NOT match
|
|
3190
|
+
return 'false'
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3194
|
+
// Non-boolean result: combine conditions AND result
|
|
3195
|
+
if (path.conditions.length === 0) {
|
|
3196
|
+
return this.renderFilterExpr(path.result, param, localVarMap)
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
const condPart = this.renderConditionsAnd(path.conditions, param, localVarMap)
|
|
3200
|
+
const resultPart = this.renderFilterExpr(path.result, param, localVarMap)
|
|
3201
|
+
return `and (${condPart}) (${resultPart})`
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
/**
|
|
3205
|
+
* Build an OR condition from multiple return paths.
|
|
3206
|
+
*/
|
|
3207
|
+
private buildOrCondition(
|
|
3208
|
+
paths: Array<{ conditions: ParsedExpr[]; result: ParsedExpr }>,
|
|
3209
|
+
param: string,
|
|
3210
|
+
localVarMap: Map<string, string>
|
|
3211
|
+
): string {
|
|
3212
|
+
const parts: string[] = []
|
|
3213
|
+
|
|
3214
|
+
for (const path of paths) {
|
|
3215
|
+
// Skip paths that always return false
|
|
3216
|
+
if (path.result.kind === 'literal' && path.result.literalType === 'boolean' && path.result.value === false) {
|
|
3217
|
+
continue
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
const pathCond = this.buildSinglePathCondition(path, param, localVarMap)
|
|
3221
|
+
if (pathCond !== 'false') {
|
|
3222
|
+
parts.push(pathCond)
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
if (parts.length === 0) {
|
|
3227
|
+
return 'false'
|
|
3228
|
+
}
|
|
3229
|
+
if (parts.length === 1) {
|
|
3230
|
+
return parts[0]
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
// Wrap each part in parentheses for clarity
|
|
3234
|
+
return `or ${parts.map(p => `(${p})`).join(' ')}`
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
/**
|
|
3238
|
+
* Render multiple conditions combined with AND.
|
|
3239
|
+
*/
|
|
3240
|
+
private renderConditionsAnd(
|
|
3241
|
+
conditions: ParsedExpr[],
|
|
3242
|
+
param: string,
|
|
3243
|
+
localVarMap: Map<string, string>
|
|
3244
|
+
): string {
|
|
3245
|
+
if (conditions.length === 0) {
|
|
3246
|
+
return 'true'
|
|
3247
|
+
}
|
|
3248
|
+
if (conditions.length === 1) {
|
|
3249
|
+
return this.renderFilterExpr(conditions[0], param, localVarMap)
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
const parts = conditions.map(c => this.renderFilterExpr(c, param, localVarMap))
|
|
3253
|
+
// Build nested and: and (a) (and (b) (c))
|
|
3254
|
+
let result = parts[parts.length - 1]
|
|
3255
|
+
for (let i = parts.length - 2; i >= 0; i--) {
|
|
3256
|
+
result = `and (${parts[i]}) (${result})`
|
|
3257
|
+
}
|
|
3258
|
+
return result
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
/**
|
|
3262
|
+
* Unified method for rendering filter predicate expressions.
|
|
3263
|
+
* Used for both expression body (t => !t.done) and block body filters.
|
|
3264
|
+
*
|
|
3265
|
+
* @param expr - The parsed expression to render
|
|
3266
|
+
* @param param - The loop parameter name (e.g., 't' in filter(t => ...))
|
|
3267
|
+
* @param localVarMap - Optional map of local variables to signal names (for block body)
|
|
3268
|
+
*/
|
|
3269
|
+
private renderFilterExpr(
|
|
3270
|
+
expr: ParsedExpr,
|
|
3271
|
+
param: string,
|
|
3272
|
+
localVarMap: Map<string, string> = new Map()
|
|
3273
|
+
): string {
|
|
3274
|
+
// Top-of-recursion: clear the unsupported sentinel so a previous
|
|
3275
|
+
// filter expression's failure doesn't poison this one. Parents
|
|
3276
|
+
// (`member` / `binary` / `unary` / `logical` / `call`) check the
|
|
3277
|
+
// flag after each child render and propagate `false` upward so the
|
|
3278
|
+
// emitted template stays syntactically valid even when the default
|
|
3279
|
+
// branch had to bail out (#1440 review).
|
|
3280
|
+
if (this.filterExprDepth === 0) this.filterExprUnsupported = false
|
|
3281
|
+
this.filterExprDepth++
|
|
3282
|
+
try {
|
|
3283
|
+
return this.renderFilterExprNode(expr, param, localVarMap)
|
|
3284
|
+
} finally {
|
|
3285
|
+
this.filterExprDepth--
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
|
|
3289
|
+
private renderFilterExprNode(
|
|
3290
|
+
expr: ParsedExpr,
|
|
3291
|
+
param: string,
|
|
3292
|
+
localVarMap: Map<string, string>
|
|
3293
|
+
): string {
|
|
3294
|
+
switch (expr.kind) {
|
|
3295
|
+
case 'identifier': {
|
|
3296
|
+
// Check if it's the loop param
|
|
3297
|
+
if (expr.name === param) {
|
|
3298
|
+
return '.'
|
|
3299
|
+
}
|
|
3300
|
+
// Check if it's a local variable mapped to a signal
|
|
3301
|
+
const signal = localVarMap.get(expr.name)
|
|
3302
|
+
if (signal) {
|
|
3303
|
+
return `$.${this.capitalizeFieldName(signal)}`
|
|
3304
|
+
}
|
|
3305
|
+
return `.${this.capitalizeFieldName(expr.name)}`
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
case 'literal':
|
|
3309
|
+
if (expr.literalType === 'string') {
|
|
3310
|
+
return `"${expr.value}"`
|
|
3311
|
+
}
|
|
3312
|
+
if (expr.literalType === 'null') {
|
|
3313
|
+
return '""'
|
|
3314
|
+
}
|
|
3315
|
+
return String(expr.value)
|
|
3316
|
+
|
|
3317
|
+
case 'member': {
|
|
3318
|
+
// t.done -> .Done
|
|
3319
|
+
if (expr.object.kind === 'identifier' && expr.object.name === param) {
|
|
3320
|
+
return `.${this.capitalizeFieldName(expr.property)}`
|
|
3321
|
+
}
|
|
3322
|
+
// `.length` on a higher-order filter result (e.g.
|
|
3323
|
+
// `x.tags.filter(t => t.active).length > 0`, #1443 PR4).
|
|
3324
|
+
// Reuse the top-level `renderFilterLengthExpr` path so the
|
|
3325
|
+
// inner filter lowers to `bf_filter <arr> "<field>" <value>`
|
|
3326
|
+
// and the outer `.length` wraps it in `len (...)`. Pre-PR4
|
|
3327
|
+
// this fell into the `default` arm and emitted BF101.
|
|
3328
|
+
//
|
|
3329
|
+
// Wrap in parens because the filter-context `binary` /
|
|
3330
|
+
// `unary` arms emit prefix function calls (`gt <l> <r>`) and
|
|
3331
|
+
// Go template would parse `gt len (bf_filter ...) 0` as four
|
|
3332
|
+
// siblings instead of `gt (len (bf_filter ...)) 0`.
|
|
3333
|
+
if (
|
|
3334
|
+
expr.property === 'length' &&
|
|
3335
|
+
expr.object.kind === 'higher-order' &&
|
|
3336
|
+
expr.object.method === 'filter'
|
|
3337
|
+
) {
|
|
3338
|
+
const lenExpr = this.renderFilterLengthExpr(expr.object, e =>
|
|
3339
|
+
this.renderFilterExpr(e, param, localVarMap),
|
|
3340
|
+
)
|
|
3341
|
+
if (lenExpr) return `(${lenExpr})`
|
|
3342
|
+
}
|
|
3343
|
+
// Nested member access or local var.prop
|
|
3344
|
+
const obj = this.renderFilterExpr(expr.object, param, localVarMap)
|
|
3345
|
+
if (this.filterExprUnsupported) return 'false'
|
|
3346
|
+
return `${obj}.${this.capitalizeFieldName(expr.property)}`
|
|
3347
|
+
}
|
|
3348
|
+
|
|
3349
|
+
case 'call': {
|
|
3350
|
+
// Handle calls like t.isDone() -> .IsDone
|
|
3351
|
+
if (expr.callee.kind === 'member' && expr.callee.object.kind === 'identifier' && expr.callee.object.name === param) {
|
|
3352
|
+
return `.${this.capitalizeFieldName(expr.callee.property)}`
|
|
3353
|
+
}
|
|
3354
|
+
// Signal calls: filter() -> $.Filter
|
|
3355
|
+
if (expr.callee.kind === 'identifier' && expr.args.length === 0) {
|
|
3356
|
+
return `$.${this.capitalizeFieldName(expr.callee.name)}`
|
|
3357
|
+
}
|
|
3358
|
+
const result = this.renderFilterExpr(expr.callee, param, localVarMap)
|
|
3359
|
+
if (this.filterExprUnsupported) return 'false'
|
|
3360
|
+
return result
|
|
3361
|
+
}
|
|
3362
|
+
|
|
3363
|
+
case 'unary': {
|
|
3364
|
+
const arg = this.renderFilterExpr(expr.argument, param, localVarMap)
|
|
3365
|
+
if (this.filterExprUnsupported) return 'false'
|
|
3366
|
+
if (expr.op === '!') {
|
|
3367
|
+
// Wrap in parens if arg is a function call (eq, ne, gt, etc.) for Go template syntax
|
|
3368
|
+
const needsParens = this.isGoFunctionCall(expr.argument)
|
|
3369
|
+
return needsParens ? `not (${arg})` : `not ${arg}`
|
|
3370
|
+
}
|
|
3371
|
+
if (expr.op === '-') {
|
|
3372
|
+
return `bf_neg ${arg}`
|
|
3373
|
+
}
|
|
3374
|
+
return arg
|
|
3375
|
+
}
|
|
3376
|
+
|
|
3377
|
+
case 'binary': {
|
|
3378
|
+
const left = this.renderFilterExpr(expr.left, param, localVarMap)
|
|
3379
|
+
if (this.filterExprUnsupported) return 'false'
|
|
3380
|
+
const right = this.renderFilterExpr(expr.right, param, localVarMap)
|
|
3381
|
+
if (this.filterExprUnsupported) return 'false'
|
|
3382
|
+
|
|
3383
|
+
switch (expr.op) {
|
|
3384
|
+
case '===':
|
|
3385
|
+
case '==':
|
|
3386
|
+
return `eq ${left} ${right}`
|
|
3387
|
+
case '!==':
|
|
3388
|
+
case '!=':
|
|
3389
|
+
return `ne ${left} ${right}`
|
|
3390
|
+
case '>':
|
|
3391
|
+
return `gt ${left} ${right}`
|
|
3392
|
+
case '<':
|
|
3393
|
+
return `lt ${left} ${right}`
|
|
3394
|
+
case '>=':
|
|
3395
|
+
return `ge ${left} ${right}`
|
|
3396
|
+
case '<=':
|
|
3397
|
+
return `le ${left} ${right}`
|
|
3398
|
+
case '+':
|
|
3399
|
+
return `bf_add ${left} ${right}`
|
|
3400
|
+
case '-':
|
|
3401
|
+
return `bf_sub ${left} ${right}`
|
|
3402
|
+
case '*':
|
|
3403
|
+
return `bf_mul ${left} ${right}`
|
|
3404
|
+
case '/':
|
|
3405
|
+
return `bf_div ${left} ${right}`
|
|
3406
|
+
default:
|
|
3407
|
+
return `${left} ${expr.op} ${right}`
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
case 'logical': {
|
|
3412
|
+
const left = this.renderFilterExpr(expr.left, param, localVarMap)
|
|
3413
|
+
if (this.filterExprUnsupported) return 'false'
|
|
3414
|
+
const right = this.renderFilterExpr(expr.right, param, localVarMap)
|
|
3415
|
+
if (this.filterExprUnsupported) return 'false'
|
|
3416
|
+
if (expr.op === '&&') {
|
|
3417
|
+
return `and (${left}) (${right})`
|
|
3418
|
+
}
|
|
3419
|
+
return `or (${left}) (${right})`
|
|
3420
|
+
}
|
|
3421
|
+
|
|
3422
|
+
default: {
|
|
3423
|
+
// The filter predicate body contains a node kind we can't lower
|
|
3424
|
+
// to a Go template action — most commonly a nested higher-order
|
|
3425
|
+
// (`x => x.tags.filter(...).length > 0`). Surface BF101 with the
|
|
3426
|
+
// offending expression so the user can either rewrite the
|
|
3427
|
+
// predicate or add `/* @client */`. Set the recursion-wide
|
|
3428
|
+
// `filterExprUnsupported` flag so parent branches return `false`
|
|
3429
|
+
// instead of wrapping the sentinel into `false.Length` / `gt
|
|
3430
|
+
// false.Length 0` etc. — the build will fail on BF101 anyway,
|
|
3431
|
+
// but the emitted template must still be syntactically valid
|
|
3432
|
+
// so `text/template` parsing doesn't blow up with a cascade of
|
|
3433
|
+
// confusing secondary errors (#1440 review).
|
|
3434
|
+
this.filterExprUnsupported = true
|
|
3435
|
+
this.errors.push({
|
|
3436
|
+
code: 'BF101',
|
|
3437
|
+
severity: 'error',
|
|
3438
|
+
message: `Filter predicate contains an expression that cannot be lowered to a Go template action: ${exprToString(expr)}`,
|
|
3439
|
+
loc: this.makeLoc(),
|
|
3440
|
+
suggestion: {
|
|
3441
|
+
message: 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Rewrite the predicate to avoid nested higher-order methods (`.filter()` / `.map()` / etc. inside the predicate body)',
|
|
3442
|
+
},
|
|
3443
|
+
})
|
|
3444
|
+
return 'false'
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
/**
|
|
3450
|
+
* Check if a ParsedExpr will render as a Go template function call.
|
|
3451
|
+
* Used to determine if parentheses are needed around the expression.
|
|
3452
|
+
*/
|
|
3453
|
+
private isGoFunctionCall(expr: ParsedExpr): boolean {
|
|
3454
|
+
switch (expr.kind) {
|
|
3455
|
+
case 'binary':
|
|
3456
|
+
// Comparison operators become function calls (eq, ne, gt, lt, etc.)
|
|
3457
|
+
return ['===', '==', '!==', '!=', '>', '<', '>=', '<='].includes(expr.op)
|
|
3458
|
+
case 'logical':
|
|
3459
|
+
// Logical operators become function calls (and, or)
|
|
3460
|
+
return true
|
|
3461
|
+
case 'unary':
|
|
3462
|
+
// Unary operators become function calls (not, bf_neg)
|
|
3463
|
+
return true
|
|
3464
|
+
case 'member':
|
|
3465
|
+
// .length becomes len function call
|
|
3466
|
+
return expr.property === 'length'
|
|
3467
|
+
default:
|
|
3468
|
+
return false
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
/**
|
|
3473
|
+
* Render a branch of a conditional expression.
|
|
3474
|
+
* String literals render as bare text (no quotes).
|
|
3475
|
+
* Nested conditionals render as complete {{if}}...{{end}} blocks.
|
|
3476
|
+
*/
|
|
3477
|
+
private renderConditionalBranch(expr: ParsedExpr): string {
|
|
3478
|
+
if (expr.kind === 'literal' && expr.literalType === 'string') {
|
|
3479
|
+
// String literals return as bare text
|
|
3480
|
+
return String(expr.value)
|
|
3481
|
+
}
|
|
3482
|
+
if (expr.kind === 'conditional') {
|
|
3483
|
+
// Nested ternary renders as complete Go template block
|
|
3484
|
+
const test = this.renderParsedExpr(expr.test)
|
|
3485
|
+
const consequent = this.renderConditionalBranch(expr.consequent)
|
|
3486
|
+
const alternate = this.renderConditionalBranch(expr.alternate)
|
|
3487
|
+
return `{{if ${test}}}${consequent}{{else}}${alternate}{{end}}`
|
|
3488
|
+
}
|
|
3489
|
+
// Other expressions render normally with {{...}} wrapper
|
|
3490
|
+
return `{{${this.renderParsedExpr(expr)}}}`
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
/**
|
|
3494
|
+
* Check if a ParsedExpr renders to a Go template function call that needs parentheses.
|
|
3495
|
+
* In Go templates, function calls like `len .X` or `bf_add .A .B` need parentheses
|
|
3496
|
+
* when used as arguments to comparison operators (eq, gt, lt, etc.).
|
|
3497
|
+
*/
|
|
3498
|
+
private needsParensInGoTemplate(expr: ParsedExpr): boolean {
|
|
3499
|
+
switch (expr.kind) {
|
|
3500
|
+
case 'member':
|
|
3501
|
+
// .length becomes `len .X` which is a function call
|
|
3502
|
+
return expr.property === 'length'
|
|
3503
|
+
|
|
3504
|
+
case 'binary':
|
|
3505
|
+
// Arithmetic operators become function calls (bf_add, bf_sub, etc.)
|
|
3506
|
+
return ['+', '-', '*', '/', '%'].includes(expr.op)
|
|
3507
|
+
|
|
3508
|
+
case 'unary':
|
|
3509
|
+
// Negation becomes `bf_neg .X`
|
|
3510
|
+
return expr.op === '-'
|
|
3511
|
+
|
|
3512
|
+
default:
|
|
3513
|
+
return false
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
/**
|
|
3518
|
+
* Convert a JS expression to Go template syntax.
|
|
3519
|
+
*/
|
|
3520
|
+
private convertExpressionToGo(jsExpr: string): string {
|
|
3521
|
+
const trimmed = jsExpr.trim()
|
|
3522
|
+
|
|
3523
|
+
// Handle null/undefined specially
|
|
3524
|
+
if (trimmed === 'null' || trimmed === 'undefined') {
|
|
3525
|
+
return '""'
|
|
3526
|
+
}
|
|
3527
|
+
|
|
3528
|
+
const parsed = parseExpression(trimmed)
|
|
3529
|
+
const support = isSupported(parsed)
|
|
3530
|
+
|
|
3531
|
+
if (!support.supported) {
|
|
3532
|
+
// Log error and return Go template comment (safe for parsing)
|
|
3533
|
+
this.errors.push({
|
|
3534
|
+
code: 'BF101',
|
|
3535
|
+
severity: 'error',
|
|
3536
|
+
message: `Expression not supported: ${trimmed}`,
|
|
3537
|
+
loc: this.makeLoc(),
|
|
3538
|
+
suggestion: {
|
|
3539
|
+
message: support.reason
|
|
3540
|
+
? `${support.reason}\n\nOptions:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code`
|
|
3541
|
+
: 'Options:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code',
|
|
3542
|
+
},
|
|
3543
|
+
})
|
|
3544
|
+
// Return empty string - Go template comments must be separate actions
|
|
3545
|
+
return `""`
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3548
|
+
return this.renderParsedExpr(parsed)
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
/**
|
|
3552
|
+
* Create a source location for error reporting.
|
|
3553
|
+
*/
|
|
3554
|
+
private makeLoc(): SourceLocation {
|
|
3555
|
+
return {
|
|
3556
|
+
file: this.componentName + '.tsx',
|
|
3557
|
+
start: { line: 1, column: 0 },
|
|
3558
|
+
end: { line: 1, column: 0 },
|
|
3559
|
+
}
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
private renderIfStatement(ifStmt: IRIfStatement, ctx?: { isRootOfClientComponent?: boolean }): string {
|
|
3563
|
+
const { condition: goCondition, preamble } = this.convertConditionToGo(ifStmt.condition)
|
|
3564
|
+
const consequent = this.renderNode(ifStmt.consequent, ctx)
|
|
3565
|
+
let result = `${preamble}{{if ${goCondition}}}${consequent}`
|
|
3566
|
+
|
|
3567
|
+
if (ifStmt.alternate) {
|
|
3568
|
+
if (ifStmt.alternate.type === 'if-statement') {
|
|
3569
|
+
const altIfStmt = ifStmt.alternate as IRIfStatement
|
|
3570
|
+
const { condition: altCondition, preamble: altPreamble } = this.convertConditionToGo(altIfStmt.condition)
|
|
3571
|
+
if (altPreamble) {
|
|
3572
|
+
// Preamble in else-if context is not supported
|
|
3573
|
+
this.errors.push({
|
|
3574
|
+
code: 'BF102',
|
|
3575
|
+
severity: 'error',
|
|
3576
|
+
message: `Complex predicate in else-if is not supported: ${altIfStmt.condition}`,
|
|
3577
|
+
loc: this.makeLoc(),
|
|
3578
|
+
suggestion: {
|
|
3579
|
+
message: 'Options:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code',
|
|
3580
|
+
},
|
|
3581
|
+
})
|
|
3582
|
+
}
|
|
3583
|
+
const altConsequent = this.renderNode(altIfStmt.consequent, ctx)
|
|
3584
|
+
result += `{{else if ${altCondition}}}${altConsequent}`
|
|
3585
|
+
if (altIfStmt.alternate) {
|
|
3586
|
+
const altElse = this.renderNode(altIfStmt.alternate, ctx)
|
|
3587
|
+
result += `{{else}}${altElse}`
|
|
3588
|
+
}
|
|
3589
|
+
} else {
|
|
3590
|
+
const alternate = this.renderNode(ifStmt.alternate, ctx)
|
|
3591
|
+
result += `{{else}}${alternate}`
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
|
|
3595
|
+
result += '{{end}}'
|
|
3596
|
+
return result
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
renderConditional(cond: IRConditional): string {
|
|
3600
|
+
// Handle @client directive - render as comment markers for client-side evaluation
|
|
3601
|
+
if (cond.clientOnly) {
|
|
3602
|
+
return this.renderClientOnlyConditional(cond)
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3605
|
+
const { condition: goCondition, preamble } = this.convertConditionToGo(cond.condition)
|
|
3606
|
+
const whenTrue = this.renderNode(cond.whenTrue)
|
|
3607
|
+
|
|
3608
|
+
// If reactive (has slotId), wrap each branch with cond marker
|
|
3609
|
+
if (cond.slotId) {
|
|
3610
|
+
const whenTrueWrapped = this.wrapWithCondMarker(whenTrue, cond.slotId)
|
|
3611
|
+
let result = `${preamble}{{if ${goCondition}}}${whenTrueWrapped}`
|
|
3612
|
+
|
|
3613
|
+
if (cond.whenFalse) {
|
|
3614
|
+
// Handle null/undefined branches with empty comment markers for client hydration
|
|
3615
|
+
if (cond.whenFalse.type === 'expression') {
|
|
3616
|
+
const exprNode = cond.whenFalse as IRExpression
|
|
3617
|
+
if (exprNode.expr === 'null' || exprNode.expr === 'undefined') {
|
|
3618
|
+
// Output empty comment markers so client can insert content later
|
|
3619
|
+
const emptyMarkers = `{{bfComment "cond-start:${cond.slotId}"}}{{bfComment "cond-end:${cond.slotId}"}}`
|
|
3620
|
+
result += `{{else}}${emptyMarkers}`
|
|
3621
|
+
} else {
|
|
3622
|
+
const whenFalse = this.renderNode(cond.whenFalse)
|
|
3623
|
+
const whenFalseWrapped = this.wrapWithCondMarker(whenFalse, cond.slotId)
|
|
3624
|
+
result += `{{else}}${whenFalseWrapped}`
|
|
3625
|
+
}
|
|
3626
|
+
} else {
|
|
3627
|
+
const whenFalse = this.renderNode(cond.whenFalse)
|
|
3628
|
+
const whenFalseWrapped = this.wrapWithCondMarker(whenFalse, cond.slotId)
|
|
3629
|
+
result += `{{else}}${whenFalseWrapped}`
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
|
|
3633
|
+
result += '{{end}}'
|
|
3634
|
+
return result
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3637
|
+
// Non-reactive: original logic
|
|
3638
|
+
let result = `${preamble}{{if ${goCondition}}}${whenTrue}`
|
|
3639
|
+
|
|
3640
|
+
if (cond.whenFalse && cond.whenFalse.type !== 'expression') {
|
|
3641
|
+
const whenFalse = this.renderNode(cond.whenFalse)
|
|
3642
|
+
if (whenFalse && whenFalse !== '{{""}}') {
|
|
3643
|
+
result += `{{else}}${whenFalse}`
|
|
3644
|
+
}
|
|
3645
|
+
} else if (cond.whenFalse && cond.whenFalse.type === 'expression') {
|
|
3646
|
+
const exprNode = cond.whenFalse as IRExpression
|
|
3647
|
+
if (exprNode.expr !== 'null' && exprNode.expr !== 'undefined') {
|
|
3648
|
+
const whenFalse = this.renderNode(cond.whenFalse)
|
|
3649
|
+
if (whenFalse && whenFalse !== '{{""}}') {
|
|
3650
|
+
result += `{{else}}${whenFalse}`
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
result += '{{end}}'
|
|
3656
|
+
return result
|
|
3657
|
+
}
|
|
3658
|
+
|
|
3659
|
+
/**
|
|
3660
|
+
* Convert a JS condition to Go template condition syntax.
|
|
3661
|
+
* Returns { condition, preamble } where preamble contains template blocks
|
|
3662
|
+
* that must be emitted before the {{if}} (e.g., every/some range blocks).
|
|
3663
|
+
*/
|
|
3664
|
+
private convertConditionToGo(jsCondition: string): { condition: string; preamble: string } {
|
|
3665
|
+
const trimmed = jsCondition.trim()
|
|
3666
|
+
const parsed = parseExpression(trimmed)
|
|
3667
|
+
const support = isSupported(parsed)
|
|
3668
|
+
|
|
3669
|
+
if (!support.supported) {
|
|
3670
|
+
this.errors.push({
|
|
3671
|
+
code: 'BF102',
|
|
3672
|
+
severity: 'error',
|
|
3673
|
+
message: `Condition not supported: ${trimmed}`,
|
|
3674
|
+
loc: this.makeLoc(),
|
|
3675
|
+
suggestion: {
|
|
3676
|
+
message: support.reason
|
|
3677
|
+
? `${support.reason}\n\nOptions:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code`
|
|
3678
|
+
: 'Expression contains unsupported syntax',
|
|
3679
|
+
},
|
|
3680
|
+
})
|
|
3681
|
+
// Return false - Go template comments must be separate actions
|
|
3682
|
+
return { condition: `false`, preamble: '' }
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3685
|
+
const rendered = this.renderConditionExpr(parsed)
|
|
3686
|
+
|
|
3687
|
+
// Detect template blocks (e.g., from every/some with complex predicates).
|
|
3688
|
+
// These cannot be placed inside {{if ...}} directly.
|
|
3689
|
+
// Split into preamble (template block) + condition variable.
|
|
3690
|
+
if (rendered.startsWith('{{')) {
|
|
3691
|
+
const lastOpen = rendered.lastIndexOf('{{')
|
|
3692
|
+
const lastClose = rendered.lastIndexOf('}}')
|
|
3693
|
+
if (lastOpen >= 0 && lastClose > lastOpen) {
|
|
3694
|
+
const preamble = rendered.substring(0, lastOpen)
|
|
3695
|
+
const condition = rendered.substring(lastOpen + 2, lastClose)
|
|
3696
|
+
return { condition, preamble }
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
return { condition: rendered, preamble: '' }
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
/**
|
|
3704
|
+
* Render a ParsedExpr as a Go template condition.
|
|
3705
|
+
*/
|
|
3706
|
+
private renderConditionExpr(expr: ParsedExpr): string {
|
|
3707
|
+
switch (expr.kind) {
|
|
3708
|
+
case 'identifier':
|
|
3709
|
+
// Inside a `{{range $_, $todo := .Todos}}` loop, a bare reference
|
|
3710
|
+
// to the loop variable (`todo`) is just Go template's dot. The
|
|
3711
|
+
// `ParsedExprEmitter` path already handles this at memberAccess
|
|
3712
|
+
// (line ~2449); this condition-expression path needs the same
|
|
3713
|
+
// normalization or `todo.done` ends up as `.Todo.Done` — a
|
|
3714
|
+
// non-existent field that Go template silently expands to ""
|
|
3715
|
+
// and then aborts the surrounding `{{if}}`/template execution
|
|
3716
|
+
// (echo logs it as a 200 with truncated bytes; #1442 repro).
|
|
3717
|
+
{
|
|
3718
|
+
const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
|
|
3719
|
+
if (currentLoopParam && expr.name === currentLoopParam) {
|
|
3720
|
+
return '.'
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
3723
|
+
return `.${this.capitalizeFieldName(expr.name)}`
|
|
3724
|
+
|
|
3725
|
+
case 'literal':
|
|
3726
|
+
if (expr.literalType === 'string') {
|
|
3727
|
+
return `"${expr.value}"`
|
|
3728
|
+
}
|
|
3729
|
+
if (expr.literalType === 'null') {
|
|
3730
|
+
return '""'
|
|
3731
|
+
}
|
|
3732
|
+
return String(expr.value)
|
|
3733
|
+
|
|
3734
|
+
case 'call': {
|
|
3735
|
+
// Signal call: count() -> .Count
|
|
3736
|
+
if (expr.callee.kind === 'identifier' && expr.args.length === 0) {
|
|
3737
|
+
return `.${this.capitalizeFieldName(expr.callee.name)}`
|
|
3738
|
+
}
|
|
3739
|
+
return this.renderParsedExpr(expr)
|
|
3740
|
+
}
|
|
3741
|
+
|
|
3742
|
+
case 'member': {
|
|
3743
|
+
// Handle .length with higher-order filter → len (bf_filter ...)
|
|
3744
|
+
if (expr.property === 'length' && expr.object.kind === 'higher-order') {
|
|
3745
|
+
const result = this.renderFilterLengthExpr(expr.object, e => this.renderConditionExpr(e))
|
|
3746
|
+
if (result) {
|
|
3747
|
+
return result
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
|
|
3751
|
+
// Handle SolidJS-style props pattern: props.xxx -> .Xxx
|
|
3752
|
+
if (expr.object.kind === 'identifier' && this.propsObjectName && expr.object.name === this.propsObjectName) {
|
|
3753
|
+
return `.${this.capitalizeFieldName(expr.property)}`
|
|
3754
|
+
}
|
|
3755
|
+
|
|
3756
|
+
// Loop-param member access: `todo.done` inside
|
|
3757
|
+
// `{{range $_, $todo := .Todos}}` is `.Done` (Go template's dot
|
|
3758
|
+
// is the current item). The `ParsedExprEmitter` already does
|
|
3759
|
+
// this for renderParsedExpr; mirror it here so condition-only
|
|
3760
|
+
// positions like boolean attributes (`checked={todo.done}`)
|
|
3761
|
+
// and `{{if}}` operands don't fall through to the generic
|
|
3762
|
+
// `.Todo.Done` shape, which references a non-existent field.
|
|
3763
|
+
{
|
|
3764
|
+
const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
|
|
3765
|
+
if (expr.object.kind === 'identifier' && currentLoopParam && expr.object.name === currentLoopParam) {
|
|
3766
|
+
return `.${this.capitalizeFieldName(expr.property)}`
|
|
3767
|
+
}
|
|
3768
|
+
}
|
|
3769
|
+
|
|
3770
|
+
const obj = this.renderConditionExpr(expr.object)
|
|
3771
|
+
if (expr.property === 'length') {
|
|
3772
|
+
return `len ${obj}`
|
|
3773
|
+
}
|
|
3774
|
+
return `${obj}.${this.capitalizeFieldName(expr.property)}`
|
|
3775
|
+
}
|
|
3776
|
+
|
|
3777
|
+
case 'binary': {
|
|
3778
|
+
// Check if left operand needs parentheses (e.g., function calls in Go template)
|
|
3779
|
+
const leftNeedsParens = this.needsParensInGoTemplate(expr.left)
|
|
3780
|
+
let left = this.renderConditionExpr(expr.left)
|
|
3781
|
+
if (leftNeedsParens) {
|
|
3782
|
+
left = `(${left})`
|
|
3783
|
+
}
|
|
3784
|
+
|
|
3785
|
+
const rightNeedsParens = this.needsParensInGoTemplate(expr.right)
|
|
3786
|
+
let right = this.renderConditionExpr(expr.right)
|
|
3787
|
+
if (rightNeedsParens) {
|
|
3788
|
+
right = `(${right})`
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
switch (expr.op) {
|
|
3792
|
+
case '===':
|
|
3793
|
+
case '==':
|
|
3794
|
+
return `eq ${left} ${right}`
|
|
3795
|
+
case '!==':
|
|
3796
|
+
case '!=':
|
|
3797
|
+
return `ne ${left} ${right}`
|
|
3798
|
+
case '>':
|
|
3799
|
+
return `gt ${left} ${right}`
|
|
3800
|
+
case '<':
|
|
3801
|
+
return `lt ${left} ${right}`
|
|
3802
|
+
case '>=':
|
|
3803
|
+
return `ge ${left} ${right}`
|
|
3804
|
+
case '<=':
|
|
3805
|
+
return `le ${left} ${right}`
|
|
3806
|
+
// Arithmetic in conditions
|
|
3807
|
+
case '+':
|
|
3808
|
+
return `bf_add ${left} ${right}`
|
|
3809
|
+
case '-':
|
|
3810
|
+
return `bf_sub ${left} ${right}`
|
|
3811
|
+
case '*':
|
|
3812
|
+
return `bf_mul ${left} ${right}`
|
|
3813
|
+
case '/':
|
|
3814
|
+
return `bf_div ${left} ${right}`
|
|
3815
|
+
default:
|
|
3816
|
+
return `${left} ${expr.op} ${right}`
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
case 'unary': {
|
|
3821
|
+
const arg = this.renderConditionExpr(expr.argument)
|
|
3822
|
+
if (expr.op === '!') {
|
|
3823
|
+
return `not ${arg}`
|
|
3824
|
+
}
|
|
3825
|
+
if (expr.op === '-') {
|
|
3826
|
+
return `bf_neg ${arg}`
|
|
3827
|
+
}
|
|
3828
|
+
return arg
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3831
|
+
case 'logical': {
|
|
3832
|
+
const left = this.renderConditionExpr(expr.left)
|
|
3833
|
+
const right = this.renderConditionExpr(expr.right)
|
|
3834
|
+
// Wrap in parentheses if needed
|
|
3835
|
+
const wrapLeft = this.needsParens(expr.left) ? `(${left})` : left
|
|
3836
|
+
const wrapRight = this.needsParens(expr.right) ? `(${right})` : right
|
|
3837
|
+
if (expr.op === '&&') {
|
|
3838
|
+
return `and ${wrapLeft} ${wrapRight}`
|
|
3839
|
+
}
|
|
3840
|
+
return `or ${wrapLeft} ${wrapRight}`
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3843
|
+
case 'conditional': {
|
|
3844
|
+
// Ternary in condition: (cond ? a : b) is unusual but handle it
|
|
3845
|
+
const test = this.renderConditionExpr(expr.test)
|
|
3846
|
+
return test // Just return the test part for condition context
|
|
3847
|
+
}
|
|
3848
|
+
|
|
3849
|
+
case 'template-literal':
|
|
3850
|
+
// Template literals as conditions are unusual
|
|
3851
|
+
return this.renderParsedExpr(expr)
|
|
3852
|
+
|
|
3853
|
+
case 'arrow-fn':
|
|
3854
|
+
// Arrow functions shouldn't appear in conditions
|
|
3855
|
+
return '[ARROW-FN]'
|
|
3856
|
+
|
|
3857
|
+
case 'higher-order':
|
|
3858
|
+
// Higher-order methods in conditions need special handling
|
|
3859
|
+
return this.renderParsedExpr(expr)
|
|
3860
|
+
|
|
3861
|
+
case 'array-literal':
|
|
3862
|
+
// Array literals in conditions have no Go template form —
|
|
3863
|
+
// delegate to renderParsedExpr so the `arrayLiteral` BF101
|
|
3864
|
+
// gate fires consistently with non-condition positions.
|
|
3865
|
+
return this.renderParsedExpr(expr)
|
|
3866
|
+
|
|
3867
|
+
case 'array-method':
|
|
3868
|
+
// Same delegation pattern — `arrayMethod` records the
|
|
3869
|
+
// refusal diagnostic at one site rather than duplicating it
|
|
3870
|
+
// for condition-position emission.
|
|
3871
|
+
return this.renderParsedExpr(expr)
|
|
3872
|
+
|
|
3873
|
+
case 'unsupported':
|
|
3874
|
+
return expr.raw
|
|
3875
|
+
}
|
|
3876
|
+
}
|
|
3877
|
+
|
|
3878
|
+
renderLoop(loop: IRLoop): string {
|
|
3879
|
+
// clientOnly loops: emit SSR markers so client can insert DOM nodes.
|
|
3880
|
+
// The marker id disambiguates sibling `.map()` calls under the same parent (#1087).
|
|
3881
|
+
if (loop.clientOnly) {
|
|
3882
|
+
return `{{bfComment "loop:${loop.markerId}"}}{{bfComment "/loop:${loop.markerId}"}}`
|
|
3883
|
+
}
|
|
3884
|
+
|
|
3885
|
+
// An array/object-destructure loop param (`([emoji, users]) => ...`
|
|
3886
|
+
// or `({ name, age }) => ...`) requires multi-variable
|
|
3887
|
+
// `{{range $k, $v := ...}}` semantics that Go templates don't
|
|
3888
|
+
// provide for arbitrary tuples — the adapter would otherwise emit
|
|
3889
|
+
// `{{range $_, $[emoji, users] := .Entries}}`, which is invalid Go
|
|
3890
|
+
// template syntax. Surface this at build time (#1266) instead of
|
|
3891
|
+
// shipping the broken `{{range}}` line for the user to discover at
|
|
3892
|
+
// request time.
|
|
3893
|
+
//
|
|
3894
|
+
// Check the IR's structured `paramBindings` field rather than
|
|
3895
|
+
// string-matching `loop.param`: Phase 1 populates `paramBindings`
|
|
3896
|
+
// iff the param is a destructure pattern (array or object); a
|
|
3897
|
+
// simple identifier leaves it `undefined`. The structured check is
|
|
3898
|
+
// robust to whitespace / formatting variants in the source.
|
|
3899
|
+
if (loop.paramBindings && loop.paramBindings.length > 0) {
|
|
3900
|
+
this.errors.push({
|
|
3901
|
+
code: 'BF104',
|
|
3902
|
+
severity: 'error',
|
|
3903
|
+
message: `Loop callback uses an array/object destructure pattern (\`${loop.param}\`) that the Go template adapter cannot lower — Go's \`{{range}}\` only supports single-name bindings.`,
|
|
3904
|
+
loc: loop.loc ?? this.makeLoc(),
|
|
3905
|
+
suggestion: {
|
|
3906
|
+
message:
|
|
3907
|
+
`Options:\n` +
|
|
3908
|
+
` 1. Rename the parameter to a single name and access tuple elements with index syntax in the body (e.g. \`entry => entry[0]\` instead of \`([k, v]) => ...\`).\n` +
|
|
3909
|
+
` 2. Mark the loop position as @client-only so the destructure runs in JS on the client.\n` +
|
|
3910
|
+
` 3. Move the loop into a primitive that the adapter registers explicitly.`,
|
|
3911
|
+
},
|
|
3912
|
+
})
|
|
3913
|
+
}
|
|
3914
|
+
|
|
3915
|
+
let goArray = this.convertExpressionToGo(loop.array)
|
|
3916
|
+
const param = loop.param
|
|
3917
|
+
const index = loop.index || '_'
|
|
3918
|
+
|
|
3919
|
+
// Check if the loop contains a component child
|
|
3920
|
+
// If so, use .{ComponentName}s which has ScopeID for each item
|
|
3921
|
+
// e.g., TodoItem children use .TodoItems, ToggleItem children use .ToggleItems
|
|
3922
|
+
const childComponent = this.findChildComponent(loop.children)
|
|
3923
|
+
if (childComponent) {
|
|
3924
|
+
goArray = `.${childComponent.name}s`
|
|
3925
|
+
}
|
|
3926
|
+
|
|
3927
|
+
this.inLoop = true
|
|
3928
|
+
this.loopParamStack.push(param)
|
|
3929
|
+
const children = this.renderChildren(loop.children)
|
|
3930
|
+
this.loopParamStack.pop()
|
|
3931
|
+
this.inLoop = false
|
|
3932
|
+
|
|
3933
|
+
// Apply sort if present: wrap array with bf_sort pipeline. The
|
|
3934
|
+
// same `emitBfSort` helper feeds both this loop-chained call
|
|
3935
|
+
// site and the standalone `sortMethod()` arm above so a
|
|
3936
|
+
// regression in either path surfaces with the same emit shape.
|
|
3937
|
+
if (loop.sortComparator) {
|
|
3938
|
+
goArray = `(${emitBfSort(goArray, loop.sortComparator)})`
|
|
3939
|
+
}
|
|
3940
|
+
|
|
3941
|
+
// Handle filter().map() pattern by adding if-condition
|
|
3942
|
+
if (loop.filterPredicate) {
|
|
3943
|
+
let filterCond: string
|
|
3944
|
+
|
|
3945
|
+
if (loop.filterPredicate.blockBody) {
|
|
3946
|
+
// Block body: collect return paths and build OR condition
|
|
3947
|
+
filterCond = this.renderBlockBodyCondition(
|
|
3948
|
+
loop.filterPredicate.blockBody,
|
|
3949
|
+
loop.filterPredicate.param
|
|
3950
|
+
)
|
|
3951
|
+
} else if (loop.filterPredicate.predicate) {
|
|
3952
|
+
// Expression body: render predicate directly
|
|
3953
|
+
filterCond = this.renderPredicateCondition(
|
|
3954
|
+
loop.filterPredicate.predicate,
|
|
3955
|
+
loop.filterPredicate.param
|
|
3956
|
+
)
|
|
3957
|
+
} else {
|
|
3958
|
+
// Fallback: always true
|
|
3959
|
+
filterCond = 'true'
|
|
3960
|
+
}
|
|
3961
|
+
|
|
3962
|
+
// Per-item start marker for multi-root Fragment items (#1212).
|
|
3963
|
+
const itemMarker = loop.bodyIsMultiRoot ? `{{bfComment "bf-loop-i"}}` : ''
|
|
3964
|
+
return `{{bfComment "loop:${loop.markerId}"}}{{range $${index}, $${param} := ${goArray}}}{{if ${filterCond}}}${itemMarker}${children}{{end}}{{end}}{{bfComment "/loop:${loop.markerId}"}}`
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3967
|
+
const itemMarker = loop.bodyIsMultiRoot ? `{{bfComment "bf-loop-i"}}` : ''
|
|
3968
|
+
return `{{bfComment "loop:${loop.markerId}"}}{{range $${index}, $${param} := ${goArray}}}${itemMarker}${children}{{end}}{{bfComment "/loop:${loop.markerId}"}}`
|
|
3969
|
+
}
|
|
3970
|
+
|
|
3971
|
+
/**
|
|
3972
|
+
* Find the first component child in a list of nodes
|
|
3973
|
+
*/
|
|
3974
|
+
private findChildComponent(nodes: IRNode[]): IRComponent | null {
|
|
3975
|
+
for (const node of nodes) {
|
|
3976
|
+
if (node.type === 'component') {
|
|
3977
|
+
return node as IRComponent
|
|
3978
|
+
}
|
|
3979
|
+
// Check children of elements
|
|
3980
|
+
if (node.type === 'element' && (node as IRElement).children) {
|
|
3981
|
+
const found = this.findChildComponent((node as IRElement).children)
|
|
3982
|
+
if (found) return found
|
|
3983
|
+
}
|
|
3984
|
+
// Check children of fragments
|
|
3985
|
+
if (node.type === 'fragment' && (node as IRFragment).children) {
|
|
3986
|
+
const found = this.findChildComponent((node as IRFragment).children)
|
|
3987
|
+
if (found) return found
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3990
|
+
return null
|
|
3991
|
+
}
|
|
3992
|
+
|
|
3993
|
+
renderComponent(comp: IRComponent, ctx?: { isRootOfClientComponent?: boolean }): string {
|
|
3994
|
+
// Handle Portal component specially - collect content for body end
|
|
3995
|
+
if (comp.name === 'Portal') {
|
|
3996
|
+
return this.renderPortalComponent(comp)
|
|
3997
|
+
}
|
|
3998
|
+
|
|
3999
|
+
// In Go templates, components are rendered using {{template "name" data}}
|
|
4000
|
+
let templateCall: string
|
|
4001
|
+
if (this.inLoop) {
|
|
4002
|
+
// Loop children: dot becomes loop item (already has correct props)
|
|
4003
|
+
templateCall = `{{template "${comp.name}" .}}`
|
|
4004
|
+
} else if (comp.slotId) {
|
|
4005
|
+
// Static children with slotId: use unique field name based on slotId
|
|
4006
|
+
const suffix = slotIdToFieldSuffix(comp.slotId)
|
|
4007
|
+
templateCall = `{{template "${comp.name}" .${comp.name}${suffix}}}`
|
|
4008
|
+
} else {
|
|
4009
|
+
// Static children without slotId: fallback to .ComponentName
|
|
4010
|
+
templateCall = `{{template "${comp.name}" .${comp.name}}}`
|
|
4011
|
+
}
|
|
4012
|
+
|
|
4013
|
+
// Root component in client component needs scope comment for hydration boundary
|
|
4014
|
+
if (ctx?.isRootOfClientComponent) {
|
|
4015
|
+
return `{{bfScopeComment .}}${templateCall}`
|
|
4016
|
+
}
|
|
4017
|
+
return templateCall
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
/**
|
|
4021
|
+
* Render a Portal component by adding its children to PortalCollector.
|
|
4022
|
+
* Portal content is rendered at </body> instead of inline.
|
|
4023
|
+
*
|
|
4024
|
+
* For static content: uses simple string literal with Add()
|
|
4025
|
+
* For dynamic content: uses bfPortalHTML() to parse and execute template string
|
|
4026
|
+
*/
|
|
4027
|
+
private renderPortalComponent(comp: IRComponent): string {
|
|
4028
|
+
// Render children content
|
|
4029
|
+
const children = this.renderChildren(comp.children)
|
|
4030
|
+
|
|
4031
|
+
// Escape for Go double-quoted string literal
|
|
4032
|
+
const escapedContent = children
|
|
4033
|
+
.replace(/\\/g, '\\\\')
|
|
4034
|
+
.replace(/"/g, '\\"')
|
|
4035
|
+
.replace(/\n/g, '\\n')
|
|
4036
|
+
|
|
4037
|
+
// Check if content has template expressions (dynamic content)
|
|
4038
|
+
if (children.includes('{{')) {
|
|
4039
|
+
// Content has dynamic parts - use bfPortalHTML to capture and render
|
|
4040
|
+
// bfPortalHTML parses and executes the template string with provided data
|
|
4041
|
+
return `{{.Portals.Add .ScopeID (bfPortalHTML . "${escapedContent}")}}`
|
|
4042
|
+
}
|
|
4043
|
+
|
|
4044
|
+
// Static content - can use simple string literal
|
|
4045
|
+
return `{{.Portals.Add .ScopeID "${escapedContent}"}}`
|
|
4046
|
+
}
|
|
4047
|
+
|
|
4048
|
+
private renderFragment(fragment: IRFragment): string {
|
|
4049
|
+
const children = this.renderChildren(fragment.children)
|
|
4050
|
+
if (fragment.needsScopeComment) {
|
|
4051
|
+
// Emit comment-based scope marker for fragment roots
|
|
4052
|
+
return `{{bfScopeComment .}}${children}`
|
|
4053
|
+
}
|
|
4054
|
+
return children
|
|
4055
|
+
}
|
|
4056
|
+
|
|
4057
|
+
private renderSlot(slot: IRSlot): string {
|
|
4058
|
+
// Use Go template's block for slots
|
|
4059
|
+
const slotName = slot.name === 'default' ? 'children' : slot.name
|
|
4060
|
+
return `{{block "${slotName}" .}}{{end}}`
|
|
4061
|
+
}
|
|
4062
|
+
|
|
4063
|
+
renderAsync(node: IRAsync): string {
|
|
4064
|
+
const fallback = this.renderNode(node.fallback)
|
|
4065
|
+
const children = this.renderChildren(node.children)
|
|
4066
|
+
// Go templates use the OOS protocol: render a placeholder with fallback,
|
|
4067
|
+
// the StreamRenderer resolves boundaries and streams replacement chunks.
|
|
4068
|
+
return `{{bfAsyncBoundary "${node.id}" "${this.escapeGoString(fallback)}"}}\n${children}`
|
|
4069
|
+
}
|
|
4070
|
+
|
|
4071
|
+
private escapeGoString(s: string): string {
|
|
4072
|
+
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
4073
|
+
}
|
|
4074
|
+
|
|
4075
|
+
/**
|
|
4076
|
+
* AttrValue lowering for intrinsic-element attributes (Go templates).
|
|
4077
|
+
* Per-kind logic that used to live in a `switch (v.kind)` inside
|
|
4078
|
+
* `renderAttributes`; routed through the shared dispatcher so a new
|
|
4079
|
+
* AttrValue kind becomes a TS compile error here (#1290 step 2).
|
|
4080
|
+
*
|
|
4081
|
+
* Components have no equivalent AttrValueEmitter on this adapter:
|
|
4082
|
+
* Go templates pass component-instance props as Go struct fields
|
|
4083
|
+
* (`collectStaticChildInstances` builds them), not as string-emitted
|
|
4084
|
+
* markup, so that path does not share a contract with the
|
|
4085
|
+
* intrinsic-attribute one.
|
|
4086
|
+
*/
|
|
4087
|
+
private readonly elementAttrEmitter: AttrValueEmitter = {
|
|
4088
|
+
emitLiteral: (value, name) => `${name}="${value.value}"`,
|
|
4089
|
+
emitExpression: (value, name) => {
|
|
4090
|
+
if (isBooleanAttr(name) || value.presenceOrUndefined) {
|
|
4091
|
+
const { condition: goCond, preamble } = this.convertConditionToGo(value.expr)
|
|
4092
|
+
return `${preamble}{{if ${goCond}}}${name}{{end}}`
|
|
4093
|
+
}
|
|
4094
|
+
const parsed = parseExpression(value.expr.trim())
|
|
4095
|
+
if (parsed.kind === 'conditional' || parsed.kind === 'template-literal') {
|
|
4096
|
+
// Inline Go template syntax with embedded `{{...}}` actions.
|
|
4097
|
+
return `${name}="${this.renderParsedExpr(parsed)}"`
|
|
4098
|
+
}
|
|
4099
|
+
return `${name}="{{${this.convertExpressionToGo(value.expr)}}}"`
|
|
4100
|
+
},
|
|
4101
|
+
emitBooleanAttr: (_value, name) => name,
|
|
4102
|
+
// Spread attributes (`<div {...attrs()} />`) lower through the
|
|
4103
|
+
// `bf_spread_attrs` runtime helper (#1407). Two paths:
|
|
4104
|
+
// - Top-level spread: the bag was plumbed onto the component's
|
|
4105
|
+
// Props struct as `.Spread_<slotId>` by `generatePropsStruct`
|
|
4106
|
+
// + `generateNewPropsFunction`. Emit a reference to it.
|
|
4107
|
+
// - Loop-internal spread: the bag lives in the loop iteration
|
|
4108
|
+
// variable (which surfaces as Go template's `.` plus
|
|
4109
|
+
// property access). Translate the JS expression via
|
|
4110
|
+
// `convertExpressionToGo` and emit `{{bf_spread_attrs <e>}}`
|
|
4111
|
+
// inline — no Props plumbing needed.
|
|
4112
|
+
// Slot IDs are assigned at IR build time so identity is stable
|
|
4113
|
+
// across re-emits; if one isn't present we fall back to BF101.
|
|
4114
|
+
emitSpread: (value) => {
|
|
4115
|
+
if (!value.slotId) {
|
|
4116
|
+
this.errors.push({
|
|
4117
|
+
code: 'BF101',
|
|
4118
|
+
severity: 'error',
|
|
4119
|
+
message: `JSX spread '{...${value.expr}}' on an intrinsic element has no Go template lowering (missing slot id)`,
|
|
4120
|
+
loc: this.makeLoc(),
|
|
4121
|
+
suggestion: {
|
|
4122
|
+
message: 'This usually means a closed-type rest-prop spread was unexpectedly routed through the bag path — file a bug with the source.',
|
|
4123
|
+
},
|
|
4124
|
+
})
|
|
4125
|
+
return ''
|
|
4126
|
+
}
|
|
4127
|
+
if (this.inLoop) {
|
|
4128
|
+
// Inside `{{range $_, $t := .Tasks}}`, the iteration value
|
|
4129
|
+
// surfaces as Go template's `.` (current context). A bare
|
|
4130
|
+
// reference to the loop param therefore translates to `.`,
|
|
4131
|
+
// not `.T` (which is what `convertExpressionToGo` would emit
|
|
4132
|
+
// via the generic identifier path). Property access through
|
|
4133
|
+
// the loop param (`t.attrs`) is already handled by the
|
|
4134
|
+
// member-expression path that returns `.Attrs`.
|
|
4135
|
+
//
|
|
4136
|
+
// The emit path is wired up but end-to-end fixture coverage
|
|
4137
|
+
// is gated on two orthogonal harness gaps: (a) `buildGoPropsInit`
|
|
4138
|
+
// in `test-render.ts` can't pass nested-object arrays from JS
|
|
4139
|
+
// into the Go input struct, and (b) `convertInitialValue`
|
|
4140
|
+
// returns `nil` for complex literal arrays so signal-init
|
|
4141
|
+
// arrays of objects don't reach the SSR template. Both are
|
|
4142
|
+
// pre-existing limitations independent of #1407.
|
|
4143
|
+
const trimmed = value.expr.trim()
|
|
4144
|
+
const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
|
|
4145
|
+
if (currentLoopParam && trimmed === currentLoopParam) {
|
|
4146
|
+
return `{{bf_spread_attrs .}}`
|
|
4147
|
+
}
|
|
4148
|
+
const goExpr = this.convertExpressionToGo(value.expr)
|
|
4149
|
+
// `convertExpressionToGo` already pushes BF101 for
|
|
4150
|
+
// unsupported expressions and returns `""`; pass through to
|
|
4151
|
+
// produce a consistent template that still compiles.
|
|
4152
|
+
return `{{bf_spread_attrs ${goExpr}}}`
|
|
4153
|
+
}
|
|
4154
|
+
return `{{bf_spread_attrs .${value.slotId}}}`
|
|
4155
|
+
},
|
|
4156
|
+
emitTemplate: (value, name) => `${name}="${this.renderTemplateLiteralParts(value.parts)}"`,
|
|
4157
|
+
// Neither variant is legal on intrinsic elements.
|
|
4158
|
+
emitBooleanShorthand: () => '',
|
|
4159
|
+
emitJsxChildren: () => '',
|
|
4160
|
+
}
|
|
4161
|
+
|
|
4162
|
+
private renderAttributes(element: IRElement): string {
|
|
4163
|
+
const parts: string[] = []
|
|
4164
|
+
|
|
4165
|
+
for (const attr of element.attrs) {
|
|
4166
|
+
// Rewrite JSX special-prop names to their HTML-attribute
|
|
4167
|
+
// counterparts. The Hono reference adapter relies on its JSX
|
|
4168
|
+
// runtime to strip `key` and emit `data-key` from a separate
|
|
4169
|
+
// emit path; the Go template adapter has no such runtime, so
|
|
4170
|
+
// the rewrite happens at attribute-emit time. Mirror of
|
|
4171
|
+
// `packages/jsx/src/ir-to-client-js/html-template.ts:878`
|
|
4172
|
+
// (`a.name === 'key'` branch). #1475
|
|
4173
|
+
let attrName: string
|
|
4174
|
+
if (attr.name === 'className') attrName = 'class'
|
|
4175
|
+
else if (attr.name === 'key') attrName = 'data-key'
|
|
4176
|
+
else attrName = attr.name
|
|
4177
|
+
const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
|
|
4178
|
+
if (lowered) parts.push(lowered)
|
|
4179
|
+
}
|
|
4180
|
+
|
|
4181
|
+
return parts.length > 0 ? ' ' + parts.join(' ') : ''
|
|
4182
|
+
}
|
|
4183
|
+
|
|
4184
|
+
/**
|
|
4185
|
+
* Replace `${EXPR}` JS-template-literal interpolations in a static
|
|
4186
|
+
* string part with Go template actions (`{{<expr-as-go>}}`), and
|
|
4187
|
+
* HTML-escape the surrounding literal text so embedded characters
|
|
4188
|
+
* don't break the attribute quoting we render into.
|
|
4189
|
+
*
|
|
4190
|
+
* UnoCSS arbitrary-value classes like `[class*="size-"]:size-4`
|
|
4191
|
+
* legitimately contain `"`, which would otherwise terminate the
|
|
4192
|
+
* `class="..."` attribute early and produce invalid HTML / a
|
|
4193
|
+
* `html/template` error at execution time.
|
|
4194
|
+
*
|
|
4195
|
+
* The interpolation parser is brace-depth aware: nested `{...}`
|
|
4196
|
+
* inside an expression (object literals, nested template literals,
|
|
4197
|
+
* etc.) are skipped past correctly so the closing brace of the
|
|
4198
|
+
* outer `${...}` is found. An unterminated `${` falls back to
|
|
4199
|
+
* literal text — better to output something than swallow it.
|
|
4200
|
+
*/
|
|
4201
|
+
private substituteJsInterpolations(s: string): string {
|
|
4202
|
+
let out = ''
|
|
4203
|
+
let i = 0
|
|
4204
|
+
while (i < s.length) {
|
|
4205
|
+
const open = s.indexOf('${', i)
|
|
4206
|
+
if (open === -1) {
|
|
4207
|
+
out += this.escapeAttrText(s.slice(i))
|
|
4208
|
+
break
|
|
4209
|
+
}
|
|
4210
|
+
out += this.escapeAttrText(s.slice(i, open))
|
|
4211
|
+
const close = findInterpolationEnd(s, open + 2)
|
|
4212
|
+
if (close === -1) {
|
|
4213
|
+
// Unterminated `${` — emit the rest as escaped literal so we
|
|
4214
|
+
// don't silently drop content.
|
|
4215
|
+
out += this.escapeAttrText(s.slice(open))
|
|
4216
|
+
break
|
|
4217
|
+
}
|
|
4218
|
+
const inner = s.slice(open + 2, close).trim()
|
|
4219
|
+
if (inner) {
|
|
4220
|
+
out += `{{${this.convertExpressionToGo(inner)}}}`
|
|
4221
|
+
} else {
|
|
4222
|
+
out += s.slice(open, close + 1)
|
|
4223
|
+
}
|
|
4224
|
+
i = close + 1
|
|
4225
|
+
}
|
|
4226
|
+
return out
|
|
4227
|
+
}
|
|
4228
|
+
|
|
4229
|
+
/**
|
|
4230
|
+
* HTML-attribute-safe escaping for double-quoted attribute values.
|
|
4231
|
+
* `&`/`"`/`<` are non-negotiable — without them the surrounding
|
|
4232
|
+
* `class="..."` quoting breaks (a real bug we hit with UnoCSS's
|
|
4233
|
+
* `[class*="size-"]`). `>`/`'` are belt-and-suspenders: HTML5
|
|
4234
|
+
* permits both inside double-quoted attrs, but Go's `html/template`
|
|
4235
|
+
* lexer is contextual and we'd rather not bet on its edge cases
|
|
4236
|
+
* matching ours forever.
|
|
4237
|
+
*/
|
|
4238
|
+
private escapeAttrText(s: string): string {
|
|
4239
|
+
return s
|
|
4240
|
+
.replace(/&/g, '&')
|
|
4241
|
+
.replace(/"/g, '"')
|
|
4242
|
+
.replace(/'/g, ''')
|
|
4243
|
+
.replace(/</g, '<')
|
|
4244
|
+
.replace(/>/g, '>')
|
|
4245
|
+
}
|
|
4246
|
+
|
|
4247
|
+
private renderTemplateLiteralParts(parts: IRTemplatePart[]): string {
|
|
4248
|
+
let output = ''
|
|
4249
|
+
for (const part of parts) {
|
|
4250
|
+
if (part.type === 'string') {
|
|
4251
|
+
// String parts can carry unresolved `${expr}` placeholders
|
|
4252
|
+
// (e.g. for function params like `className` that the IR
|
|
4253
|
+
// analyzer couldn't substitute structurally). Translate each
|
|
4254
|
+
// span to a Go template action so the SSR output matches the
|
|
4255
|
+
// JS-side runtime evaluation. Static text passes through as-is.
|
|
4256
|
+
output += this.substituteJsInterpolations(part.value)
|
|
4257
|
+
} else if (part.type === 'ternary') {
|
|
4258
|
+
const { condition: goCond, preamble } = this.convertConditionToGo(part.condition)
|
|
4259
|
+
output += `${preamble}{{if ${goCond}}}${part.whenTrue}{{else}}${part.whenFalse}{{end}}`
|
|
4260
|
+
} else if (part.type === 'lookup') {
|
|
4261
|
+
// `${MAP[KEY]}` against a Record<T, string> literal — emit a
|
|
4262
|
+
// chained `{{if eq .Key "<case>"}}<value>{{else if ...}}{{end}}`
|
|
4263
|
+
// so the right case lights up at SSR time. Empty when no
|
|
4264
|
+
// case matches; consumers shouldn't rely on a default fallback
|
|
4265
|
+
// here (the JSX-side `variant = 'default'` default already
|
|
4266
|
+
// shows up via the per-prop fallback in `NewXxxProps`).
|
|
4267
|
+
const keyExpr = this.convertExpressionToGo(part.key)
|
|
4268
|
+
const caseEntries = Object.entries(part.cases)
|
|
4269
|
+
if (caseEntries.length === 0) continue
|
|
4270
|
+
const branches = caseEntries.map(([k, v], i) => {
|
|
4271
|
+
const head = i === 0 ? '{{if' : '{{else if'
|
|
4272
|
+
return `${head} eq ${keyExpr} ${JSON.stringify(k)}}}${v}`
|
|
4273
|
+
})
|
|
4274
|
+
output += branches.join('') + '{{end}}'
|
|
4275
|
+
}
|
|
4276
|
+
}
|
|
4277
|
+
return output
|
|
4278
|
+
}
|
|
4279
|
+
|
|
4280
|
+
renderScopeMarker(_instanceIdExpr: string): string {
|
|
4281
|
+
// bfScopeAttr returns the bare scope id (#1249 — no `~` prefix).
|
|
4282
|
+
// bfHydrationAttrs emits bf-h / bf-m / bf-r conditionally (slot
|
|
4283
|
+
// identity + root-of-client-component marker).
|
|
4284
|
+
return `bf-s="{{bfScopeAttr .}}" {{bfHydrationAttrs .}} {{bfPropsAttr .}}`
|
|
4285
|
+
}
|
|
4286
|
+
|
|
4287
|
+
renderSlotMarker(slotId: string): string {
|
|
4288
|
+
return `bf="${slotId}"`
|
|
4289
|
+
}
|
|
4290
|
+
|
|
4291
|
+
renderCondMarker(condId: string): string {
|
|
4292
|
+
return `bf-c="${condId}"`
|
|
4293
|
+
}
|
|
4294
|
+
|
|
4295
|
+
private wrapWithCondMarker(content: string, condId: string): string {
|
|
4296
|
+
// If content is a single HTML element, add bf-c attribute.
|
|
4297
|
+
// For fragments (multiple sibling elements), use comment markers.
|
|
4298
|
+
if (content.startsWith('<')) {
|
|
4299
|
+
const match = content.match(/^<(\w+)/)
|
|
4300
|
+
if (match) {
|
|
4301
|
+
const tag = match[1]
|
|
4302
|
+
const trimmed = content.trim()
|
|
4303
|
+
const isSingle = new RegExp(`</${tag}>\\s*$`).test(trimmed) || /^<\w+[^>]*\/>$/.test(trimmed)
|
|
4304
|
+
if (isSingle) {
|
|
4305
|
+
return content.replace(`<${match[1]}`, `<${match[1]} ${this.renderCondMarker(condId)}`)
|
|
4306
|
+
}
|
|
4307
|
+
}
|
|
4308
|
+
}
|
|
4309
|
+
// Text: use bfComment function to output comment markers
|
|
4310
|
+
// Go's html/template strips raw HTML comments, so we use a custom function
|
|
4311
|
+
// bfComment automatically adds "bf-" prefix, so "cond-start:x" becomes "<!--bf-cond-start:x-->"
|
|
4312
|
+
return `{{bfComment "cond-start:${condId}"}}${content}{{bfComment "cond-end:${condId}"}}`
|
|
4313
|
+
}
|
|
4314
|
+
}
|
|
4315
|
+
|
|
4316
|
+
export const goTemplateAdapter = new GoTemplateAdapter()
|