@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.
@@ -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, '&amp;')
4241
+ .replace(/"/g, '&quot;')
4242
+ .replace(/'/g, '&#39;')
4243
+ .replace(/</g, '&lt;')
4244
+ .replace(/>/g, '&gt;')
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()