@barefootjs/go-template 0.14.0 → 0.15.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.
@@ -64,8 +64,11 @@ import {
64
64
  collectContextConsumers,
65
65
  isLowerableObjectRestDestructure,
66
66
  type ContextConsumer,
67
+ collectModuleStringConsts as collectModuleStringConstsShared,
68
+ searchParamsLocalNames
67
69
  } from '@barefootjs/jsx'
68
70
  import { findInterpolationEnd } from '@barefootjs/jsx/scanner'
71
+ import { BF_REGION } from '@barefootjs/shared'
69
72
 
70
73
  /**
71
74
  * Go-template adapter's IRNode render context. Only `isRootOfClientComponent`
@@ -88,6 +91,18 @@ interface NestedComponentInfo extends IRLoopChildComponent {
88
91
  * name (`item`), so the loop-child init can stamp `data-key` per item. */
89
92
  loopKey?: string
90
93
  loopParam?: string
94
+ /** The loop body component's JSX children (e.g. the 4 `<TableCell>` nodes
95
+ * inside `<TableRow>` in data-table's `.map(payment => <TableRow>…</TableRow>)`).
96
+ * Non-empty when the loop body component has children that need a companion
97
+ * define rendered via `bf_with_children` + `bf_tmpl`. (#1897) */
98
+ bodyChildren?: IRNode[]
99
+ /** The loop's array expression for baking (e.g. `sortedData()`) */
100
+ loopArray?: string
101
+ /** The enclosing loop's `markerId` (e.g. `l0`) for unique naming */
102
+ loopMarkerId?: string
103
+ /** The loop item's TS type (`Payment` from `sortedData().map(payment => …)`),
104
+ * resolved to Go struct fields for the wrapper struct's datum fields. */
105
+ loopItemType?: TypeInfo | null
91
106
  }
92
107
 
93
108
  interface StaticChildInstance {
@@ -182,6 +197,19 @@ interface PropFallbackVar {
182
197
  zeroLiteral: string
183
198
  }
184
199
 
200
+ /**
201
+ * Scope for `lowerCtorExpr` — lowering a JS expression to Go in the
202
+ * `NewXxxProps` constructor context (#1897 PostList derived state).
203
+ */
204
+ interface CtorLowerEnv {
205
+ /** Local names bound to `searchParams()` (`const sp = searchParams()`). */
206
+ searchParamsVars: Set<string>
207
+ /** Helper-param name → its already-lowered Go argument, for inlining. */
208
+ params: Map<string, string>
209
+ /** Component-scope const names currently being inlined (cycle guard). */
210
+ consts?: Set<string>
211
+ }
212
+
185
213
  export interface GoTemplateAdapterOptions {
186
214
  /** Go package name for generated types (default: 'components') */
187
215
  packageName?: string
@@ -388,13 +416,49 @@ function buildUnsupportedSuggestion(support: SupportResult): string {
388
416
  return `${support.reason}\n\n${GO_REMEDIATION_OPTIONS}`
389
417
  }
390
418
 
419
+ /**
420
+ * Parenthesize a compound Go template argument (`or .Checked false`) so a
421
+ * primitive call reads it as ONE argument — unwrapped, the parser splits
422
+ * it into three and `bf_string` fails with "want 1 got 3" (#1896,
423
+ * DropdownMenuCheckboxItem's `String(props.checked ?? false)`).
424
+ */
425
+ function wrapGoArg(arg: string): string {
426
+ if (!/\s/.test(arg)) return arg
427
+ if (arg.startsWith('(') && arg.endsWith(')')) return arg
428
+ return `(${arg})`
429
+ }
430
+
431
+ /**
432
+ * Make an equality comparison string-tolerant when exactly one side is a
433
+ * Go string literal (#1896): JS `sorted === 'asc'` is loosely false for
434
+ * `sorted = false`, but Go's template `eq` ERRORS on bool-vs-string
435
+ * (`incompatible types for comparison` — DataTableColumnHeader's
436
+ * `'asc' | 'desc' | false` union prop). Routing the non-literal side
437
+ * through `bf_string` preserves JS comparison semantics for every
438
+ * concrete type while keeping same-kind comparisons untouched.
439
+ */
440
+ function stringTolerantEqOperands(l: string, r: string): [string, string] {
441
+ const isStrLit = (x: string) => /^"(?:[^"\\]|\\.)*"$/.test(x)
442
+ if (isStrLit(l) === isStrLit(r)) return [l, r]
443
+ // Keep `wrapGoArg`'s parentheses: a compound operand must reach
444
+ // `bf_string` as ONE argument — `(bf_string (or .Placement "top"))`,
445
+ // not `(bf_string or .Placement "top")` (which the template parser
446
+ // reads as three arguments and fails at runtime).
447
+ const wrap = (x: string) => (isStrLit(x) ? x : `(bf_string ${wrapGoArg(x)})`)
448
+ return [wrap(l), wrap(r)]
449
+ }
450
+
391
451
  const GO_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
392
- 'JSON.stringify': { arity: 1, emit: (args) => `bf_json ${args[0]}` },
393
- 'String': { arity: 1, emit: (args) => `bf_string ${args[0]}` },
394
- 'Number': { arity: 1, emit: (args) => `bf_number ${args[0]}` },
395
- 'Math.floor': { arity: 1, emit: (args) => `bf_floor ${args[0]}` },
396
- 'Math.ceil': { arity: 1, emit: (args) => `bf_ceil ${args[0]}` },
397
- 'Math.round': { arity: 1, emit: (args) => `bf_round ${args[0]}` },
452
+ 'JSON.stringify': { arity: 1, emit: (args) => `bf_json ${wrapGoArg(args[0])}` },
453
+ 'String': { arity: 1, emit: (args) => `bf_string ${wrapGoArg(args[0])}` },
454
+ 'Number': { arity: 1, emit: (args) => `bf_number ${wrapGoArg(args[0])}` },
455
+ 'Math.floor': { arity: 1, emit: (args) => `bf_floor ${wrapGoArg(args[0])}` },
456
+ 'Math.ceil': { arity: 1, emit: (args) => `bf_ceil ${wrapGoArg(args[0])}` },
457
+ 'Math.round': { arity: 1, emit: (args) => `bf_round ${wrapGoArg(args[0])}` },
458
+ // Two-arg forms only; an N-arg `Math.min(a, b, c)` falls through to the
459
+ // standard BF101 unsupported-call diagnostic via the arity gate.
460
+ 'Math.min': { arity: 2, emit: (args) => `bf_min ${wrapGoArg(args[0])} ${wrapGoArg(args[1])}` },
461
+ 'Math.max': { arity: 2, emit: (args) => `bf_max ${wrapGoArg(args[0])} ${wrapGoArg(args[1])}` },
398
462
  }
399
463
 
400
464
  export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter, IRNodeEmitter<GoRenderCtx> {
@@ -467,6 +531,17 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
467
531
  private componentName: string = ''
468
532
  private options: Required<GoTemplateAdapterOptions>
469
533
  private inLoop: boolean = false
534
+ /**
535
+ * Companion `{{define "<Component>__children_<slot>"}}` blocks queued
536
+ * while rendering the template body (#1896): JSX children passed to an
537
+ * imported child component that contain template actions (nested
538
+ * components, dynamic text) can't be baked to a static `Children`
539
+ * string — they render through a per-call-site define executed via
540
+ * `bf_tmpl` with the PARENT's data, and the result is injected into
541
+ * the child props with `bf_with_children`. Flushed after the main
542
+ * define in `generate()`.
543
+ */
544
+ private pendingChildrenDefines: Array<{ name: string; content: string }> = []
470
545
  private loopParamStack: string[] = []
471
546
  private loopVarRefCount: Map<string, number> = new Map()
472
547
  /** Stack of destructure-param binding maps (binding name → Go accessor on the
@@ -475,6 +550,10 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
475
550
  * refusing with BF104. (#1310) */
476
551
  private loopBindingStack: Array<Map<string, string>> = []
477
552
  private errors: CompilerError[] = []
553
+ /** The current IR's memos, stashed like `localConstants` so nested memo
554
+ * resolution (a ternary memo whose condition is another memo, #1896)
555
+ * can recurse without threading the list through every signature. */
556
+ private currentMemos: Array<{ name: string; computation: string; deps: string[] }> = []
478
557
  private propsObjectName: string | null = null
479
558
  /**
480
559
  * Component-scoped rest binding identifier (`function({ a, ...rest }: P)`
@@ -504,6 +583,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
504
583
  * initial-value baker.
505
584
  */
506
585
  private synthStructTypes: Map<string, TypeInfo> = new Map()
586
+ /** Full type definitions from the current IR, stashed for loop-datum field resolution (#1897). */
587
+ private currentTypeDefinitions: TypeDefinition[] = []
507
588
 
508
589
  /** Set during type generation when any emit references
509
590
  * `template.HTML(...)`; toggles the `"html/template"` import. */
@@ -562,6 +643,36 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
562
643
  */
563
644
  private contextConsumers: ContextConsumer[] = []
564
645
 
646
+ /**
647
+ * (#1922) Local binding names the request-scoped `searchParams()` env signal
648
+ * is imported under (handles `import { searchParams as sp }`). A zero-arg call
649
+ * on one of these names lowers to the canonical `.SearchParams` field
650
+ * regardless of the JS alias. Set at `generate()` / `generateTypes()` entry.
651
+ */
652
+ private searchParamsLocals: Set<string> = new Set()
653
+
654
+ /**
655
+ * Names of component-scope arrow-const helpers (`const sortClass = …`),
656
+ * eligible for call-site inlining (#1897). Precomputed per component so the
657
+ * inliner can skip the AST parse for the common non-helper expression.
658
+ */
659
+ private localHelperNames: Set<string> = new Set()
660
+
661
+ /** Set when a constructor-context lowering emits a `strings.` call (#1897), so
662
+ * `strings` is added to the generated types file's import block. */
663
+ private needsStringsImport = false
664
+
665
+ /** Component-scope derived consts referenced by the template during rendering
666
+ * (e.g. `root` → `.Root`). `generateTypes` emits a computed field for each
667
+ * that's resolvable and non-colliding (#1897). */
668
+ private referencedDerivedConsts: Set<string> = new Set()
669
+
670
+ /** Array-memo name → the handler-filled loop slice field its `.map()` feeds
671
+ * (e.g. `visible` → `PostListItems`). Lets `<memo>().length` lower to
672
+ * `len .<Slice>` instead of `len .<Memo>` (a nil/unset memo field) — the
673
+ * slice IS the rendered (filtered) items, so its length is the count (#1897). */
674
+ private memoBackedLoopSlice: Map<string, string> = new Map()
675
+
565
676
  /** Child component name → the contexts it consumes (cross-component, for provider wiring). */
566
677
  private childContextConsumers: Map<string, ContextConsumer[]> = new Map()
567
678
 
@@ -597,12 +708,20 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
597
708
  generate(ir: ComponentIR, options?: AdapterGenerateOptions): AdapterOutput {
598
709
  this.componentName = ir.metadata.componentName
599
710
  this.errors = []
711
+ this.referencedDerivedConsts = new Set()
600
712
  this.templateVarCounter = 0
713
+ this.pendingChildrenDefines = []
601
714
  this.propsObjectName = ir.metadata.propsObjectName
602
715
  this.restPropsName = ir.metadata.restPropsName ?? null
603
716
  this.moduleStringConsts = this.collectModuleStringConsts(ir.metadata.localConstants)
604
717
  this.localConstants = ir.metadata.localConstants ?? []
718
+ this.localHelperNames = new Set(
719
+ this.localConstants.filter(c => !c.isModule && c.containsArrow).map(c => c.name),
720
+ )
721
+ this.currentMemos = ir.metadata.memos ?? []
722
+ this.currentTypeDefinitions = ir.metadata.typeDefinitions ?? []
605
723
  this.contextConsumers = collectContextConsumers(ir.metadata)
724
+ this.searchParamsLocals = searchParamsLocalNames(ir.metadata)
606
725
  // (#checkbox) Enumerate inherited-attribute accesses (props-object pattern)
607
726
  // before computing the nillable set / rendering, so the synthetic params
608
727
  // participate in attribute omission and field binding uniformly. Shared
@@ -639,6 +758,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
639
758
  const isIfStatement = ir.root.type === 'if-statement'
640
759
 
641
760
  this.rootScopeNodes = collectRootScopeNodes(ir.root)
761
+ // Map each array memo that backs a loop (`<memo>().map(...)`) to that loop's
762
+ // handler-filled slice field, so `<memo>().length` can lower to the slice's
763
+ // length (#1897 PostList status count). Built before rendering — the
764
+ // `.length` reference can appear before the loop in source order.
765
+ this.memoBackedLoopSlice = new Map()
766
+ for (const nested of this.findNestedComponents(ir.root)) {
767
+ const memoName = this.extractMemoNameFromLoopArray(nested.loopArray)
768
+ if (memoName) this.memoBackedLoopSlice.set(memoName, `${nested.name}s`)
769
+ }
642
770
  const templateBody = isIfStatement
643
771
  ? this.renderIfStatement(ir.root as IRIfStatement, { isRootOfClientComponent: hasInteractivity })
644
772
  : this.renderNode(ir.root, { isRootOfClientComponent: hasInteractivity && isRootComponent })
@@ -648,7 +776,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
648
776
  ? ''
649
777
  : this.generateScriptRegistrations(ir, options?.scriptBaseName)
650
778
 
651
- const template = `{{define "${this.componentName}"}}\n${scriptRegistrations}${templateBody}\n{{end}}\n`
779
+ let template = `{{define "${this.componentName}"}}\n${scriptRegistrations}${templateBody}\n{{end}}\n`
780
+ // Flush the companion children defines (#1896) — they execute with
781
+ // the parent's data via `bf_tmpl`, so they belong to this
782
+ // component's template output.
783
+ for (const d of this.pendingChildrenDefines) {
784
+ template += `{{define "${d.name}"}}${d.content}{{end}}\n`
785
+ }
652
786
  const types = this.generateTypes(ir)
653
787
 
654
788
  // Merge collected errors into IR errors
@@ -958,13 +1092,28 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
958
1092
  // round-tripped IR, so this must be applied here too; the method is
959
1093
  // idempotent. `propsObjectName` is needed by the scan.
960
1094
  this.propsObjectName = ir.metadata.propsObjectName
1095
+ // Mirror `generate()` for the rest binding too (#1896):
1096
+ // `classifySpreadBagSource` decides whether a `{...props}` slot is an
1097
+ // Input-side bag by comparing against `this.restPropsName`. Without
1098
+ // this line the stash holds whichever component `generate()` ran
1099
+ // LAST — in a multi-component file (`tabs/index.tsx`) that is a
1100
+ // different component, so the Input struct dropped its `Props`
1101
+ // bag field while `NewXxxProps` (which reads the per-IR metadata)
1102
+ // still emitted `Spread_N: in.Props` — `in.Props undefined`.
1103
+ this.restPropsName = ir.metadata.restPropsName ?? null
961
1104
  augmentInheritedPropAccesses(ir)
962
1105
  // Mirror `generate()`: the `NewXxxProps` initializer computes memo SSR
963
1106
  // values, which inline module string consts and resolve `Record`-index
964
1107
  // lookups — both need the const tables populated on this standalone entry.
965
1108
  this.moduleStringConsts = this.collectModuleStringConsts(ir.metadata.localConstants)
966
1109
  this.localConstants = ir.metadata.localConstants ?? []
1110
+ this.localHelperNames = new Set(
1111
+ this.localConstants.filter(c => !c.isModule && c.containsArrow).map(c => c.name),
1112
+ )
1113
+ this.currentMemos = ir.metadata.memos ?? []
1114
+ this.currentTypeDefinitions = ir.metadata.typeDefinitions ?? []
967
1115
  this.contextConsumers = collectContextConsumers(ir.metadata)
1116
+ this.searchParamsLocals = searchParamsLocalNames(ir.metadata)
968
1117
  const lines: string[] = []
969
1118
 
970
1119
  const componentName = ir.metadata.componentName
@@ -1029,6 +1178,52 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1029
1178
  // Find nested components (loops with childComponent)
1030
1179
  const nestedComponents = this.findNestedComponents(ir.root)
1031
1180
 
1181
+ // (#1897) When a loop's `itemType` is null, resolve the element type
1182
+ // from the source array so wrapper structs get correct datum fields.
1183
+ // Two cases:
1184
+ // 1. Memo-derived: `sortedData()` → resolve through the memo's SSR
1185
+ // path to the module const it returns (block-body memo baking).
1186
+ // 2. Direct module const: `payments` → look up the constant directly.
1187
+ for (const nested of nestedComponents) {
1188
+ if (nested.loopItemType || !nested.loopArray) continue
1189
+
1190
+ // Case 1: memo-derived loop array (`sortedData()`)
1191
+ const memoName = this.extractMemoNameFromLoopArray(nested.loopArray)
1192
+ if (memoName) {
1193
+ const memo = ir.metadata.memos.find(m => m.name === memoName)
1194
+ if (memo) {
1195
+ const blockReturn = this.resolveBlockBodyMemoModuleConst(
1196
+ memo.computation, ir.metadata.signals,
1197
+ )
1198
+ if (blockReturn) {
1199
+ const constant = (ir.metadata.localConstants ?? []).find(
1200
+ c => c.name === blockReturn.constName && c.origin?.scope === 'module',
1201
+ )
1202
+ if (constant?.type?.elementType) {
1203
+ nested.loopItemType = constant.type.elementType
1204
+ }
1205
+ }
1206
+ }
1207
+ continue
1208
+ }
1209
+
1210
+ // Case 2: direct module-const array reference (`payments`)
1211
+ const directConst = (ir.metadata.localConstants ?? []).find(
1212
+ c => c.name === nested.loopArray && c.origin?.scope === 'module',
1213
+ )
1214
+ if (directConst?.type?.elementType) {
1215
+ nested.loopItemType = directConst.type.elementType
1216
+ }
1217
+ }
1218
+
1219
+ // (#1897) Generate wrapper structs for loop body components with JSX children.
1220
+ // The wrapper embeds the child component's Props and adds datum fields from
1221
+ // the loop's item type + static child instances from the body children.
1222
+ for (const nested of nestedComponents) {
1223
+ if (!nested.bodyChildren || nested.bodyChildren.length === 0) continue
1224
+ this.generateLoopBodyWrapperStruct(lines, componentName, nested)
1225
+ }
1226
+
1032
1227
  // Build prop type overrides from signal types
1033
1228
  const propTypeOverrides = this.buildPropTypeOverrides(ir)
1034
1229
 
@@ -1045,10 +1240,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1045
1240
  this.generateInputStruct(lines, ir, componentName, nestedComponents, propTypeOverrides, spreadSlots)
1046
1241
 
1047
1242
  // Generate Props struct for main component
1243
+ this.needsStringsImport = false
1048
1244
  this.generatePropsStruct(lines, ir, componentName, nestedComponents, propTypeOverrides, spreadSlots)
1049
1245
 
1050
1246
  // Generate NewXxxProps function
1051
- this.generateNewPropsFunction(lines, ir, componentName, nestedComponents, spreadSlots)
1247
+ this.generateNewPropsFunction(lines, ir, componentName, nestedComponents, spreadSlots, propTypeOverrides)
1052
1248
 
1053
1249
  // Imports come at the top, but `usesHtmlTemplate` is only known
1054
1250
  // after the body has been generated. Compose package + imports +
@@ -1062,6 +1258,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1062
1258
  if (this.usesFmt) header.push('\t"fmt"')
1063
1259
  if (this.usesHtmlTemplate) header.push('\t"html/template"')
1064
1260
  header.push('\t"math/rand"')
1261
+ if (this.needsStringsImport) header.push('\t"strings"')
1065
1262
  header.push('')
1066
1263
  header.push('\tbf "github.com/barefootjs/runtime/bf"')
1067
1264
  header.push(')')
@@ -1319,6 +1516,39 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1319
1516
  return nillable
1320
1517
  }
1321
1518
 
1519
+ /**
1520
+ * Whether the component reads the request-scoped `searchParams()`
1521
+ * environment signal (router v0.5, #1922). Detected from `searchParamsLocals`
1522
+ * — the binding names the shared `searchParamsLocalNames` helper found at
1523
+ * `generate()` / `generateTypes()` entry, covering any local name (including
1524
+ * an aliased `import { searchParams as sp }`). When non-empty the generated
1525
+ * structs carry a `SearchParams bf.SearchParams` binding the route handler
1526
+ * fills per request and the template reads via `.SearchParams.Get "key"`.
1527
+ *
1528
+ * Guarded against a name collision with a user prop / signal / memo also
1529
+ * called `searchParams`: that author owns the `SearchParams` field, so the
1530
+ * env-signal field is dropped (the reference would resolve to their value).
1531
+ */
1532
+ private usesSearchParams(ir: ComponentIR): boolean {
1533
+ if (this.searchParamsLocals.size === 0) return false
1534
+ // Every other source that contributes a Props/Input struct field: a
1535
+ // collision on `SearchParams` would redeclare the field and break the Go
1536
+ // compile. Covers props, signals, memos, `useContext` consumers, and the
1537
+ // `{...rest}` bag — the same field-producing sets the struct emitters draw
1538
+ // from. When the author already owns `SearchParams`, drop the env-signal
1539
+ // field (their binding lowers to the same `.SearchParams` reference).
1540
+ const taken = new Set<string>([
1541
+ ...ir.metadata.propsParams.map(p => this.capitalizeFieldName(p.name)),
1542
+ ...ir.metadata.signals.map(s => this.capitalizeFieldName(s.getter)),
1543
+ ...ir.metadata.memos.map(m => this.capitalizeFieldName(m.name)),
1544
+ ...this.contextConsumers.map(c => this.contextFieldName(c)),
1545
+ ])
1546
+ if (ir.metadata.restPropsName) {
1547
+ taken.add(this.capitalizeFieldName(ir.metadata.restPropsName))
1548
+ }
1549
+ return !taken.has('SearchParams')
1550
+ }
1551
+
1322
1552
  /**
1323
1553
  * Generate Input struct for a component
1324
1554
  */
@@ -1339,6 +1569,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1339
1569
  lines.push('\tBfParent string // Optional: parent scope id')
1340
1570
  lines.push('\tBfMount string // Optional: slot id in parent')
1341
1571
 
1572
+ // (#1922) Request-scoped `searchParams()` binding. The route handler
1573
+ // builds it from the request URL (`bf.NewSearchParams(r.URL.RawQuery)`);
1574
+ // the zero value is an empty query, so an omitted field resolves every
1575
+ // `.Get` to "" — the author's `?? default` then renders.
1576
+ if (this.usesSearchParams(ir)) {
1577
+ lines.push('\tSearchParams bf.SearchParams // Optional: request query for searchParams()')
1578
+ }
1579
+
1342
1580
  // Static + prop-derived nested components appear in Input;
1343
1581
  // signal-backed dynamic ones are template-only
1344
1582
  const inputNested = nestedComponents.filter(n => !n.isDynamic || n.isPropDerived)
@@ -1423,6 +1661,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1423
1661
  // Add Scripts field for dynamic script collection
1424
1662
  lines.push('\tScripts *bf.ScriptCollector `json:"-"`')
1425
1663
 
1664
+ // (#1922) Request-scoped `searchParams()` SSR value. Read by the
1665
+ // template as `.SearchParams.Get "key"`. Not serialised for hydration
1666
+ // (`json:"-"`) — the client re-reads `window.location.search` itself.
1667
+ if (this.usesSearchParams(ir)) {
1668
+ lines.push('\tSearchParams bf.SearchParams `json:"-"`')
1669
+ }
1670
+
1426
1671
  // Collect nested component array field names to skip from propsParams
1427
1672
  const nestedArrayFields = new Set(nestedComponents.map(n => `${n.name}s`))
1428
1673
 
@@ -1492,15 +1737,35 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1492
1737
  lines.push(`\t${fieldName} ${goType} \`json:"${jsonTag}"\``)
1493
1738
  }
1494
1739
 
1495
- // Add memos to Props (they are computed values needed for SSR)
1740
+ // Add memos to Props (they are computed values needed for SSR).
1741
+ // Skip a memo whose name collides with an already-emitted prop field
1742
+ // (#1896): `const className = createMemo(() => props.className ?? '')`
1743
+ // — common in site/ui (AccordionContent, PaginationLink) — would
1744
+ // otherwise redeclare `ClassName`. Both readers (the memo usage and
1745
+ // the inherited `props.className` access) lower to the same `.Field`,
1746
+ // so the prop field carries the value; the memo's `?? fallback` is
1747
+ // folded into the prop's initializer in `generateNewPropsFunction`.
1496
1748
  for (const memo of ir.metadata.memos) {
1497
1749
  const fieldName = this.capitalizeFieldName(memo.name)
1750
+ if (propFieldNames.has(fieldName)) continue
1498
1751
  const jsonTag = this.toJsonTag(memo.name)
1499
1752
  // Memos that depend on number signals are usually numbers
1500
1753
  const goType = this.inferMemoType(memo, ir.metadata.signals, propsParamMap)
1501
1754
  lines.push(`\t${fieldName} ${goType} \`json:"${jsonTag}"\``)
1502
1755
  }
1503
1756
 
1757
+ // (#1897 PostList) Computed fields for component-scope derived string consts
1758
+ // the template references (e.g. `root = base || '/'`). Not serialised — the
1759
+ // route handler doesn't supply them; `NewXxxProps` computes them.
1760
+ const takenForDerivedConsts = new Set<string>([
1761
+ ...ir.metadata.propsParams.map(p => this.capitalizeFieldName(p.name)),
1762
+ ...ir.metadata.signals.map(s => this.capitalizeFieldName(s.getter)),
1763
+ ...ir.metadata.memos.map(m => this.capitalizeFieldName(m.name)),
1764
+ ])
1765
+ for (const f of this.computeDerivedConstFields(takenForDerivedConsts)) {
1766
+ lines.push(`\t${f.name} string \`json:"-"\``)
1767
+ }
1768
+
1504
1769
  // `useContext` consumer fields (skip names already taken by a prop /
1505
1770
  // signal / memo field).
1506
1771
  const takenProps = new Set<string>([
@@ -1515,14 +1780,18 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1515
1780
 
1516
1781
  // Add array fields for nested components (for template rendering)
1517
1782
  for (const nested of nestedComponents) {
1783
+ // (#1897) Loop body with JSX children → use the wrapper struct type
1784
+ const elemType = nested.bodyChildren?.length
1785
+ ? this.loopBodyWrapperName(componentName, nested)
1786
+ : `${nested.name}Props`
1518
1787
  if (nested.isDynamic && !nested.isPropDerived) {
1519
1788
  // Dynamic signal array loops: template-only, not in JSON
1520
- lines.push(`\t${nested.name}s []${nested.name}Props \`json:"-"\``)
1789
+ lines.push(`\t${nested.name}s []${elemType} \`json:"-"\``)
1521
1790
  } else {
1522
1791
  // Static arrays and prop-derived dynamic arrays: include in JSON
1523
1792
  // so the client can hydrate via mapArray or forEach
1524
1793
  const jsonTag = this.toJsonTag(`${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`)
1525
- lines.push(`\t${nested.name}s []${nested.name}Props \`json:"${jsonTag}"\``)
1794
+ lines.push(`\t${nested.name}s []${elemType} \`json:"${jsonTag}"\``)
1526
1795
  }
1527
1796
  }
1528
1797
 
@@ -1549,6 +1818,90 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1549
1818
  lines.push('')
1550
1819
  }
1551
1820
 
1821
+ /**
1822
+ * (#1897) Generate a wrapper struct for loop body components with JSX children.
1823
+ * The wrapper embeds the child component's Props, adds datum fields from the
1824
+ * loop's item type, and adds static child instance fields for sub-components
1825
+ * within the loop body children (e.g. the `TableCell` instances inside
1826
+ * `<TableRow>…</TableRow>`).
1827
+ */
1828
+ private generateLoopBodyWrapperStruct(
1829
+ lines: string[],
1830
+ parentComponentName: string,
1831
+ nested: NestedComponentInfo,
1832
+ ): void {
1833
+ const wrapperName = this.loopBodyWrapperName(parentComponentName, nested)
1834
+
1835
+ // Resolve datum fields from the loop's item type
1836
+ const datumFields = this.resolveLoopDatumFields(nested.loopItemType)
1837
+
1838
+ // Collect static child instances from the body children
1839
+ const bodyChildInstances = this.collectBodyChildInstances(nested.bodyChildren!)
1840
+
1841
+ lines.push(`// ${wrapperName} wraps ${nested.name}Props with per-row loop datum`)
1842
+ lines.push(`// fields and child component slots for the loop body children. (#1897)`)
1843
+ lines.push(`type ${wrapperName} struct {`)
1844
+ lines.push(`\t${nested.name}Props`)
1845
+ for (const f of datumFields) {
1846
+ lines.push(`\t${f.goName} ${f.goType} \`json:"-"\``)
1847
+ }
1848
+ for (const child of bodyChildInstances) {
1849
+ lines.push(`\t${child.fieldName} ${child.name}Props \`json:"-"\``)
1850
+ }
1851
+ lines.push('}')
1852
+ lines.push('')
1853
+ }
1854
+
1855
+ /** Extract a memo name from a loop array expression like `sortedData()` → `sortedData`. */
1856
+ private extractMemoNameFromLoopArray(loopArray: string | undefined): string | null {
1857
+ if (!loopArray) return null
1858
+ const match = loopArray.match(/^(\w+)\(\)$/)
1859
+ return match ? match[1] : null
1860
+ }
1861
+
1862
+ /** Stable name for the wrapper struct: `<Parent><Child>L<Marker>Ctx` */
1863
+ private loopBodyWrapperName(parentName: string, nested: NestedComponentInfo): string {
1864
+ return `${parentName}${nested.name}L${nested.loopMarkerId ?? '0'}Ctx`
1865
+ }
1866
+
1867
+ /** Resolve a loop item's TypeInfo to Go struct fields for the wrapper. */
1868
+ private resolveLoopDatumFields(
1869
+ itemType: TypeInfo | null | undefined,
1870
+ ): Array<{ tsName: string; goName: string; goType: string }> {
1871
+ if (!itemType) return []
1872
+ const typeName = itemType.raw?.replace(/\[\]$/, '') ?? itemType.raw
1873
+ if (!typeName) return []
1874
+ for (const td of this.currentTypeDefinitions) {
1875
+ if (td.name === typeName) {
1876
+ const fields: Array<{ tsName: string; goName: string; goType: string }> = []
1877
+ for (const prop of td.properties ?? []) {
1878
+ if (!GoTemplateAdapter.GO_IDENTIFIER.test(prop.name)) continue
1879
+ fields.push({
1880
+ tsName: prop.name,
1881
+ goName: this.capitalizeFieldName(prop.name),
1882
+ goType: this.typeInfoToGo(prop.type),
1883
+ })
1884
+ }
1885
+ if (fields.length > 0) return fields
1886
+ break
1887
+ }
1888
+ }
1889
+ const structFields = this.localStructFields.get(typeName)
1890
+ if (structFields) {
1891
+ return Array.from(structFields, ([tsName, goName]) => ({ tsName, goName, goType: 'interface{}' }))
1892
+ }
1893
+ return []
1894
+ }
1895
+
1896
+ /** Collect static child instances from loop body children for the wrapper struct. */
1897
+ private collectBodyChildInstances(bodyChildren: IRNode[]): StaticChildInstance[] {
1898
+ const result: StaticChildInstance[] = []
1899
+ for (const child of bodyChildren) {
1900
+ this.collectStaticChildInstancesRecursive(child, result, false, new Map())
1901
+ }
1902
+ return result
1903
+ }
1904
+
1552
1905
  /**
1553
1906
  * Generate NewXxxProps function
1554
1907
  */
@@ -1557,7 +1910,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1557
1910
  ir: ComponentIR,
1558
1911
  componentName: string,
1559
1912
  nestedComponents: NestedComponentInfo[],
1560
- spreadSlots: SpreadSlotInfo[]
1913
+ spreadSlots: SpreadSlotInfo[],
1914
+ propTypeOverrides: Map<string, string>,
1561
1915
  ): void {
1562
1916
  const inputTypeName = `${componentName}Input`
1563
1917
  const propsTypeName = `${componentName}Props`
@@ -1569,7 +1923,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1569
1923
  // field, the SSR template iterates over it, but
1570
1924
  // `NewTodoAppProps(TodoAppInput{Initial: ...})` returns it empty
1571
1925
  // and the page renders a blank list (#1442 echo TodoApp repro).
1572
- const signalDynamicNested = nestedComponents.filter(n => n.isDynamic && !n.isPropDerived)
1926
+ // (#1897) Split dynamic nested: components with body children get auto-populated
1927
+ // from baked memo data; components without stay handler-populated.
1928
+ const dynamicWithBody = nestedComponents.filter(
1929
+ n => n.isDynamic && !n.isPropDerived && n.bodyChildren && n.bodyChildren.length > 0,
1930
+ )
1931
+ const signalDynamicNested = nestedComponents.filter(
1932
+ n => n.isDynamic && !n.isPropDerived && !(n.bodyChildren && n.bodyChildren.length > 0),
1933
+ )
1573
1934
  lines.push(`// New${componentName}Props creates ${propsTypeName} from ${inputTypeName}.`)
1574
1935
  for (const nested of signalDynamicNested) {
1575
1936
  const arrayField = `${nested.name}s`
@@ -1598,27 +1959,103 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1598
1959
  // Signal-backed dynamic arrays are set manually by the handler.
1599
1960
  const staticNested = nestedComponents.filter(n => !n.isDynamic || n.isPropDerived)
1600
1961
 
1601
- // Handle nested components
1602
- if (staticNested.length > 0) {
1603
- for (const nested of staticNested) {
1604
- const varName = `${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`
1605
- lines.push(`\t${varName} := make([]${nested.name}Props, len(in.${nested.name}s))`)
1606
- lines.push(`\tfor i, item := range in.${nested.name}s {`)
1607
- lines.push(`\t\t${varName}[i] = New${nested.name}Props(item)`)
1608
- // (#1249) Stamp slot identity on each child item so bf-h / bf-m
1609
- // mark it as a slot-attached child of this scope.
1610
- lines.push(`\t\t${varName}[i].BfParent = scopeID`)
1611
- lines.push(`\t\t${varName}[i].BfMount = "${nested.slotId}"`)
1612
- // (#1297) Stamp the keyed-loop `data-key` per item from the loop's
1613
- // `key` expression (`item.label` → `item.Label`), matching Hono.
1614
- const keyField = loopKeyToGoFieldPath(nested.loopKey, nested.loopParam)
1615
- if (keyField) {
1616
- lines.push(`\t\t${varName}[i].BfDataKey = fmt.Sprint(${keyField})`)
1617
- this.usesFmt = true
1962
+ // Track which wrapper vars have been emitted so the return struct can
1963
+ // conditionally include them (both static-with-body and dynamic-with-body
1964
+ // paths may emit wrappers).
1965
+ const emittedWrapperVars = new Set<string>()
1966
+
1967
+ // (#1897) Split static nested into those with and without body children.
1968
+ // Static loops with body children backed by module consts bake the data
1969
+ // directly (like the dynamic-with-body path) because Input items don't
1970
+ // carry the datum fields the wrapper struct needs.
1971
+ const staticWithBody = staticNested.filter(
1972
+ n => n.bodyChildren && n.bodyChildren.length > 0,
1973
+ )
1974
+ const staticWithoutBody = staticNested.filter(
1975
+ n => !n.bodyChildren || n.bodyChildren.length === 0,
1976
+ )
1977
+
1978
+ // Handle static nested components WITHOUT body children (original path)
1979
+ for (const nested of staticWithoutBody) {
1980
+ const varName = `${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`
1981
+ lines.push(`\t${varName} := make([]${nested.name}Props, len(in.${nested.name}s))`)
1982
+ lines.push(`\tfor i, item := range in.${nested.name}s {`)
1983
+ lines.push(`\t\t${varName}[i] = New${nested.name}Props(item)`)
1984
+ lines.push(`\t\t${varName}[i].BfParent = scopeID`)
1985
+ lines.push(`\t\t${varName}[i].BfMount = "${nested.slotId}"`)
1986
+ const keyField = loopKeyToGoFieldPath(nested.loopKey, nested.loopParam)
1987
+ if (keyField) {
1988
+ lines.push(`\t\t${varName}[i].BfDataKey = fmt.Sprint(${keyField})`)
1989
+ this.usesFmt = true
1990
+ }
1991
+ lines.push('\t}')
1992
+ lines.push('')
1993
+ }
1994
+
1995
+ // (#1897) Handle static nested components WITH body children.
1996
+ // Bake the module-const array into the constructor so wrapper structs
1997
+ // get their datum fields directly from the data, not from Input items
1998
+ // (Input items only carry child-component params, not loop datum fields).
1999
+ for (const nested of staticWithBody) {
2000
+ const loopArray = nested.loopArray
2001
+ const moduleConst = loopArray
2002
+ ? (ir.metadata.localConstants ?? []).find(
2003
+ c => c.name === loopArray && c.origin?.scope === 'module' && c.value && c.type,
2004
+ )
2005
+ : null
2006
+ const bakedValue = moduleConst?.type
2007
+ ? this.convertInitialValue(moduleConst.value!, moduleConst.type, ir.metadata.propsParams)
2008
+ : null
2009
+ if (!bakedValue || bakedValue === 'nil' || bakedValue === '0') continue
2010
+
2011
+ const varName = `${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`
2012
+ const wrapperType = this.loopBodyWrapperName(componentName, nested)
2013
+ const datumFields = this.resolveLoopDatumFields(nested.loopItemType)
2014
+ const bodyChildInstances = this.collectBodyChildInstances(nested.bodyChildren!)
2015
+
2016
+ for (const child of bodyChildInstances) {
2017
+ const childVar = `child_${child.fieldName}`
2018
+ lines.push(`\t${childVar} := New${child.name}Props(${child.name}Input{`)
2019
+ lines.push(`\t\tScopeID: scopeID + "_${child.slotId}",`)
2020
+ lines.push(`\t\tBfParent: scopeID,`)
2021
+ lines.push(`\t\tBfMount: "${child.slotId}",`)
2022
+ for (const prop of child.props) {
2023
+ if (prop.value.kind === 'literal') {
2024
+ lines.push(`\t\t${this.capitalizeFieldName(prop.name)}: ${this.goLiteral(prop.value.value)},`)
2025
+ } else if (prop.value.kind === 'boolean-shorthand' || prop.value.kind === 'boolean-attr') {
2026
+ lines.push(`\t\t${this.capitalizeFieldName(prop.name)}: true,`)
2027
+ }
1618
2028
  }
1619
- lines.push('\t}')
1620
- lines.push('')
1621
- }
2029
+ lines.push(`\t})`)
2030
+ }
2031
+ if (bodyChildInstances.length > 0) lines.push('')
2032
+
2033
+ const dataVar = `${varName}Data`
2034
+ lines.push(`\t${dataVar} := ${bakedValue}`)
2035
+ lines.push(`\t${varName} := make([]${wrapperType}, len(${dataVar}))`)
2036
+ lines.push(`\tfor i, item := range ${dataVar} {`)
2037
+ lines.push(`\t\t${varName}[i] = ${wrapperType}{`)
2038
+ lines.push(`\t\t\t${nested.name}Props: New${nested.name}Props(${nested.name}Input{`)
2039
+ lines.push(`\t\t\t\tBfParent: scopeID,`)
2040
+ lines.push(`\t\t\t\tBfMount: "${nested.slotId}",`)
2041
+ lines.push(`\t\t\t}),`)
2042
+ for (const f of datumFields) {
2043
+ lines.push(`\t\t\t${f.goName}: item.${f.goName},`)
2044
+ }
2045
+ for (const child of bodyChildInstances) {
2046
+ lines.push(`\t\t\t${child.fieldName}: child_${child.fieldName},`)
2047
+ }
2048
+ lines.push(`\t\t}`)
2049
+ lines.push(`\t\t${varName}[i].BfParent = scopeID`)
2050
+ lines.push(`\t\t${varName}[i].BfMount = "${nested.slotId}"`)
2051
+ const keyField = loopKeyToGoFieldPath(nested.loopKey, nested.loopParam)
2052
+ if (keyField) {
2053
+ lines.push(`\t\t${varName}[i].BfDataKey = fmt.Sprint(${keyField})`)
2054
+ this.usesFmt = true
2055
+ }
2056
+ lines.push('\t}')
2057
+ lines.push('')
2058
+ emittedWrapperVars.add(varName)
1622
2059
  }
1623
2060
 
1624
2061
  // (#1423) Collect signal-time prop fallbacks: when a signal is
@@ -1638,12 +2075,80 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1638
2075
  }
1639
2076
  if (propFallbackVars.size > 0) lines.push('')
1640
2077
 
2078
+ // (#1897) Build wrapper items for dynamic loop body components whose array
2079
+ // bakes to a module-const via a memo. Creates the wrapper slice with
2080
+ // embedded child Props + datum fields + static sub-component instances.
2081
+ const propsParamMap = new Map(ir.metadata.propsParams.map(p => [p.name, p]))
2082
+ for (const nested of dynamicWithBody) {
2083
+ const memoName = this.extractMemoNameFromLoopArray(nested.loopArray)
2084
+ if (!memoName) continue
2085
+ const memo = ir.metadata.memos.find(m => m.name === memoName)
2086
+ if (!memo) continue
2087
+
2088
+ const goType = this.inferMemoType(memo, ir.metadata.signals, propsParamMap)
2089
+ const bakedValue = this.computeMemoInitialValue(
2090
+ memo, ir.metadata.signals, ir.metadata.propsParams, propFallbackVars, goType,
2091
+ )
2092
+ if (bakedValue === 'nil' || bakedValue === '0') continue
2093
+
2094
+ const wrapperType = this.loopBodyWrapperName(componentName, nested)
2095
+ const varName = `${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`
2096
+ const datumFields = this.resolveLoopDatumFields(nested.loopItemType)
2097
+ const bodyChildInstances = this.collectBodyChildInstances(nested.bodyChildren!)
2098
+
2099
+ // Create child sub-component instances once (identical scope IDs across rows)
2100
+ for (const child of bodyChildInstances) {
2101
+ const childVar = `child_${child.fieldName}`
2102
+ lines.push(`\t${childVar} := New${child.name}Props(${child.name}Input{`)
2103
+ lines.push(`\t\tScopeID: scopeID + "_${child.slotId}",`)
2104
+ lines.push(`\t\tBfParent: scopeID,`)
2105
+ lines.push(`\t\tBfMount: "${child.slotId}",`)
2106
+ for (const prop of child.props) {
2107
+ if (prop.value.kind === 'literal') {
2108
+ lines.push(`\t\t${this.capitalizeFieldName(prop.name)}: ${this.goLiteral(prop.value.value)},`)
2109
+ } else if (prop.value.kind === 'boolean-shorthand' || prop.value.kind === 'boolean-attr') {
2110
+ lines.push(`\t\t${this.capitalizeFieldName(prop.name)}: true,`)
2111
+ }
2112
+ }
2113
+ lines.push(`\t})`)
2114
+ }
2115
+ if (bodyChildInstances.length > 0) lines.push('')
2116
+
2117
+ lines.push(`\tbakedData := ${bakedValue}`)
2118
+ lines.push(`\t${varName} := make([]${wrapperType}, len(bakedData))`)
2119
+ lines.push(`\tfor i, item := range bakedData {`)
2120
+ lines.push(`\t\t${varName}[i] = ${wrapperType}{`)
2121
+ lines.push(`\t\t\t${nested.name}Props: New${nested.name}Props(${nested.name}Input{`)
2122
+ lines.push(`\t\t\t\tBfParent: scopeID,`)
2123
+ lines.push(`\t\t\t\tBfMount: "${nested.slotId}",`)
2124
+ lines.push(`\t\t\t}),`)
2125
+ for (const f of datumFields) {
2126
+ lines.push(`\t\t\t${f.goName}: item.${f.goName},`)
2127
+ }
2128
+ for (const child of bodyChildInstances) {
2129
+ lines.push(`\t\t\t${child.fieldName}: child_${child.fieldName},`)
2130
+ }
2131
+ lines.push(`\t\t}`)
2132
+ const keyField = loopKeyToGoFieldPath(nested.loopKey, nested.loopParam)
2133
+ if (keyField) {
2134
+ lines.push(`\t\t${varName}[i].BfDataKey = fmt.Sprint(${keyField})`)
2135
+ this.usesFmt = true
2136
+ }
2137
+ lines.push(`\t}`)
2138
+ lines.push('')
2139
+ emittedWrapperVars.add(varName)
2140
+ }
2141
+
1641
2142
  lines.push(`\treturn ${propsTypeName}{`)
1642
2143
  lines.push('\t\tScopeID: scopeID,')
1643
2144
  // (#1249) Forward host context for when *this* component is itself a
1644
2145
  // slot-attached child of an outer page/component.
1645
2146
  lines.push('\t\tBfParent: in.BfParent,')
1646
2147
  lines.push('\t\tBfMount: in.BfMount,')
2148
+ // (#1922) Forward the request-scoped searchParams() binding unchanged.
2149
+ if (this.usesSearchParams(ir)) {
2150
+ lines.push('\t\tSearchParams: in.SearchParams,')
2151
+ }
1647
2152
 
1648
2153
  // Collect nested component array field names
1649
2154
  const nestedArrayFields = new Set(nestedComponents.map(n => `${n.name}s`))
@@ -1654,6 +2159,31 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1654
2159
  // doesn't silently shadow the JSX-side default. The same logic
1655
2160
  // applies for signal-side fallbacks (`createSignal(props.X ?? N)`)
1656
2161
  // via the hoisted variable from `propFallbackVars` (#1423).
2162
+ // (#1896) A memo that shadows a prop of the same name
2163
+ // (`const className = createMemo(() => props.className ?? '')`)
2164
+ // shares the prop's struct field (see `generatePropsStruct`). Fold
2165
+ // the memo's `?? fallback` into the prop's own initializer so the
2166
+ // SSR value matches the memo semantics when the caller omits it.
2167
+ const memoFallbacks = new Map<string, { goFallback: string; goType: string }>()
2168
+ for (const memo of ir.metadata.memos) {
2169
+ const stripped = memo.computation.replace(/^\(\)\s*=>\s*/, '')
2170
+ const m = this.extractPropFallback(stripped)
2171
+ if (!m) continue
2172
+ if (this.capitalizeFieldName(m.propName) !== this.capitalizeFieldName(memo.name)) continue
2173
+ // `applyGoFallback` emits a string-typed zero-value check; folding
2174
+ // onto a prop whose Input field resolved to `interface{}` (e.g. a
2175
+ // string-literal-union type the resolver can't narrow —
2176
+ // PaginationLink's `size`) would not compile. Such props keep the
2177
+ // plain `in.<Field>` assignment.
2178
+ const param = ir.metadata.propsParams.find(
2179
+ p => this.capitalizeFieldName(p.name) === this.capitalizeFieldName(memo.name),
2180
+ )
2181
+ if (!param) continue
2182
+ const goType = this.resolvePropGoType(param, propTypeOverrides)
2183
+ if (goType !== 'string' && goType !== 'interface{}') continue
2184
+ memoFallbacks.set(this.capitalizeFieldName(memo.name), { goFallback: m.goFallback, goType })
2185
+ }
2186
+
1657
2187
  const propFieldNames = new Set<string>()
1658
2188
  for (const param of ir.metadata.propsParams) {
1659
2189
  const fieldName = this.capitalizeFieldName(param.name)
@@ -1662,9 +2192,19 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1662
2192
  if (hoisted) {
1663
2193
  lines.push(`\t\t${fieldName}: ${hoisted.varName},`)
1664
2194
  } else {
1665
- const fallback = this.goPropDefault(param.defaultValue)
1666
- if (fallback !== null) {
1667
- lines.push(`\t\t${fieldName}: ${this.applyGoFallback(`in.${fieldName}`, fallback)},`)
2195
+ const paramDefault = this.goPropDefault(param.defaultValue)
2196
+ const memoFold = memoFallbacks.get(fieldName)
2197
+ if (paramDefault !== null) {
2198
+ lines.push(`\t\t${fieldName}: ${this.applyGoFallback(`in.${fieldName}`, paramDefault)},`)
2199
+ } else if (memoFold !== undefined && memoFold.goType === 'string') {
2200
+ lines.push(`\t\t${fieldName}: ${this.applyGoFallback(`in.${fieldName}`, memoFold.goFallback)},`)
2201
+ } else if (memoFold !== undefined) {
2202
+ // interface{} field (#1896, PaginationLink's `size ?? 'icon'`):
2203
+ // applyGoFallback's string zero-check doesn't compile here, so
2204
+ // emit a nil/empty-tolerant wrapper instead.
2205
+ lines.push(
2206
+ `\t\t${fieldName}: func() interface{} { v := interface{}(in.${fieldName}); if v == nil || v == "" { return ${memoFold.goFallback} }; return v }(),`,
2207
+ )
1668
2208
  } else {
1669
2209
  lines.push(`\t\t${fieldName}: in.${fieldName},`)
1670
2210
  }
@@ -1692,16 +2232,24 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1692
2232
  }
1693
2233
  }
1694
2234
 
1695
- // Add nested component arrays (static only; dynamic ones are set by the handler)
1696
- for (const nested of staticNested) {
2235
+ // Add nested component arrays (static without body always emitted;
2236
+ // static with body + dynamic with body use emittedWrapperVars guard)
2237
+ for (const nested of staticWithoutBody) {
2238
+ const varName = `${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`
2239
+ lines.push(`\t\t${nested.name}s: ${varName},`)
2240
+ }
2241
+ for (const nested of [...staticWithBody, ...dynamicWithBody]) {
1697
2242
  const varName = `${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`
2243
+ if (!emittedWrapperVars.has(varName)) continue
1698
2244
  lines.push(`\t\t${nested.name}s: ${varName},`)
1699
2245
  }
1700
2246
 
1701
- // Add memo initial values (computed from signal initial values)
2247
+ // Add memo initial values (computed from signal initial values).
2248
+ // Prop-shadowing memos were folded into the prop field above (#1896).
1702
2249
  const memoPropsParamMap = new Map(ir.metadata.propsParams.map(p => [p.name, p]))
1703
2250
  for (const memo of ir.metadata.memos) {
1704
2251
  const fieldName = this.capitalizeFieldName(memo.name)
2252
+ if (propFieldNames.has(fieldName)) continue
1705
2253
  // (#checkbox) Pass the memo's inferred Go type so an unresolved
1706
2254
  // computation falls back to that type's zero value (`false` for a
1707
2255
  // boolean memo like `isChecked`), not the int `0`.
@@ -1710,6 +2258,17 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1710
2258
  lines.push(`\t\t${fieldName}: ${memoValue},`)
1711
2259
  }
1712
2260
 
2261
+ // (#1897 PostList) Initialise computed derived-const fields
2262
+ // (e.g. `Root: func() string { … }()`), matching `generatePropsStruct`.
2263
+ const takenDerivedInit = new Set<string>([
2264
+ ...ir.metadata.propsParams.map(p => this.capitalizeFieldName(p.name)),
2265
+ ...ir.metadata.signals.map(s => this.capitalizeFieldName(s.getter)),
2266
+ ...ir.metadata.memos.map(m => this.capitalizeFieldName(m.name)),
2267
+ ])
2268
+ for (const f of this.computeDerivedConstFields(takenDerivedInit)) {
2269
+ lines.push(`\t\t${f.name}: ${f.init},`)
2270
+ }
2271
+
1713
2272
  // `useContext` consumer fields: default to the `createContext` default
1714
2273
  // when the caller (a provider) didn't set them.
1715
2274
  const takenInit = new Set<string>([
@@ -1768,6 +2327,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1768
2327
  restBagEntries.push(`${JSON.stringify(jsxName)}: ${goValue}`)
1769
2328
  return
1770
2329
  }
2330
+ // A hyphenated attribute (`aria-label`) can't be a Go struct
2331
+ // field, and with no rest bag on the child there is nowhere to
2332
+ // route it — the child has no `{...props}` spread, so the Hono
2333
+ // reference drops it on the child's root too. Skip rather than
2334
+ // emit invalid Go (#1896, data-table's selection sibling).
2335
+ if (jsxName.includes('-')) return
1771
2336
  lines.push(`\t\t\t${this.capitalizeFieldName(jsxName)}: ${goValue},`)
1772
2337
  }
1773
2338
  // Add prop values
@@ -1907,12 +2472,17 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1907
2472
  if (loop.childComponent) {
1908
2473
  // Check for duplicates
1909
2474
  if (!result.some(c => c.name === loop.childComponent!.name)) {
2475
+ const hasBodyChildren = loop.childComponent.children.length > 0
1910
2476
  result.push({
1911
2477
  ...loop.childComponent,
1912
2478
  isDynamic: !loop.isStaticArray,
1913
2479
  isPropDerived: !!loop.isPropDerivedArray,
1914
2480
  loopKey: loop.key ?? undefined,
1915
2481
  loopParam: loop.param ?? undefined,
2482
+ bodyChildren: hasBodyChildren ? loop.childComponent.children : undefined,
2483
+ loopArray: loop.array,
2484
+ loopMarkerId: loop.markerId,
2485
+ loopItemType: loop.itemType,
1916
2486
  })
1917
2487
  }
1918
2488
  }
@@ -1935,6 +2505,22 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1935
2505
  if (cond.whenFalse) {
1936
2506
  this.collectNestedComponents(cond.whenFalse, result)
1937
2507
  }
2508
+ } else if (node.type === 'if-statement') {
2509
+ const stmt = node as IRIfStatement
2510
+ this.collectNestedComponents(stmt.consequent, result)
2511
+ if (stmt.alternate) {
2512
+ this.collectNestedComponents(stmt.alternate, result)
2513
+ }
2514
+ } else if (node.type === 'component') {
2515
+ // (#1896) JSX children passed to an imported component render via
2516
+ // a companion define with the PARENT's data, so a keyed loop
2517
+ // nested inside them (DataTablePreviewDemo's `sortedData().map(…)`
2518
+ // inside `<TableBody>`) needs its `<Name>s` slice on THIS
2519
+ // component's props like any other nested loop.
2520
+ const comp = node as IRComponent
2521
+ for (const child of comp.children) {
2522
+ this.collectNestedComponents(child, result)
2523
+ }
1938
2524
  }
1939
2525
  }
1940
2526
 
@@ -2070,6 +2656,16 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2070
2656
  childrenScopedHtmlExpr: this.extractScopedHtmlChildren(effectiveChildren),
2071
2657
  contextBindings: providerCtx.size > 0 ? providerCtx : undefined,
2072
2658
  })
2659
+ // (#1896) Action-bearing JSX children render through a companion
2660
+ // define with the PARENT's data (see `queueDynamicChildrenDefine`),
2661
+ // so component instances nested inside them need their own
2662
+ // `<Name>SlotN` fields + constructor inits on THIS component's
2663
+ // props. Statically-baked children never contain components
2664
+ // (any nested component renders a `{{template}}` action, which
2665
+ // the bake extractors reject), so recursing is a no-op for them.
2666
+ for (const child of effectiveChildren) {
2667
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
2668
+ }
2073
2669
  }
2074
2670
  // Recurse into Portal's children to find nested components
2075
2671
  if (comp.name === 'Portal' && comp.children) {
@@ -2099,6 +2695,16 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2099
2695
  if (cond.whenFalse) {
2100
2696
  this.collectStaticChildInstancesRecursive(cond.whenFalse, result, inLoop, providerCtx)
2101
2697
  }
2698
+ } else if (node.type === 'if-statement') {
2699
+ // (#1896) An early-return if-statement root (AccordionTrigger's
2700
+ // asChild split) keeps its subtrees in consequent/alternate — the
2701
+ // non-asChild branch's ChevronDownIcon needs its slot field like
2702
+ // any other static child.
2703
+ const stmt = node as IRIfStatement
2704
+ this.collectStaticChildInstancesRecursive(stmt.consequent, result, inLoop, providerCtx)
2705
+ if (stmt.alternate) {
2706
+ this.collectStaticChildInstancesRecursive(stmt.alternate, result, inLoop, providerCtx)
2707
+ }
2102
2708
  } else if (node.type === 'provider') {
2103
2709
  // SSR context propagation: record the provider's value against its
2104
2710
  // context name and extend the active binding map for descendants. A
@@ -2974,8 +3580,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2974
3580
  }
2975
3581
  const lines: string[] = []
2976
3582
  lines.push('func() string {')
2977
- lines.push(`\t\t\tk, _ := in.${fieldName}.(string)`)
2978
- lines.push('\t\t\tswitch k {')
3583
+ // `fmt.Sprint` is type-tolerant: the key field is `interface{}`
3584
+ // when the analyzer typed the prop, but a `string` when the
3585
+ // shared inherited-prop augmentation synthesised it (#1896) — a
3586
+ // `.(string)` assertion compiles for the former only.
3587
+ this.usesFmt = true
3588
+ lines.push(`\t\t\tswitch fmt.Sprint(in.${fieldName}) {`)
2979
3589
  for (const [k, v] of caseEntries) {
2980
3590
  lines.push(`\t\t\tcase ${JSON.stringify(k)}: return ${JSON.stringify(v)}`)
2981
3591
  }
@@ -2999,6 +3609,31 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2999
3609
  memos: { name: string; computation: string; deps: string[] }[],
3000
3610
  propsParams: { name: string }[]
3001
3611
  ): string | null {
3612
+ // `getter() === 'lit'` / `!==` as a child-instance prop value
3613
+ // (#1896 — accordion's `open={openItem() === 'item-1'}`): resolves
3614
+ // to a Go bool when the signal's initial value is a string literal.
3615
+ const cmpMatch = expr.match(
3616
+ /^(\w+)\(\)\s*([!=]==?)\s*(?:'([^']*)'|(-?\d+(?:\.\d+)?))\s*$/,
3617
+ )
3618
+ if (cmpMatch) {
3619
+ const [, depName, op, strLit, numLit] = cmpMatch
3620
+ const signal = signals.find(sg => sg.getter === depName)
3621
+ const init = signal?.initialValue.trim()
3622
+ const initMatch = init !== undefined
3623
+ ? /^(?:'([^'\\]*)'|(-?\d+(?:\.\d+)?))$/.exec(init)
3624
+ : null
3625
+ if (initMatch) {
3626
+ const initVal = initMatch[1] ?? initMatch[2]
3627
+ const litVal = strLit ?? numLit
3628
+ // Same-kind comparison only (string vs string, number vs number).
3629
+ const sameKind = (initMatch[1] !== undefined) === (strLit !== undefined)
3630
+ if (sameKind) {
3631
+ const equal = initVal === litVal
3632
+ return String(op.startsWith('!') ? !equal : equal)
3633
+ }
3634
+ }
3635
+ }
3636
+
3002
3637
  // Match signal/memo getter calls like count(), doubled()
3003
3638
  const getterMatch = expr.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\(\)$/)
3004
3639
  if (getterMatch) {
@@ -3010,10 +3645,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3010
3645
  return this.convertInitialValue(signal.initialValue, signal.type, propsParams)
3011
3646
  }
3012
3647
 
3013
- // Check if it's a memo
3648
+ // Check if it's a memo. Use the pattern-matching core: when no
3649
+ // pattern applies, return null so the caller OMITS the field and
3650
+ // Go's typed zero value applies (#1896).
3014
3651
  const memo = memos.find(m => m.name === getterName)
3015
3652
  if (memo) {
3016
- return this.computeMemoInitialValue(memo, signals, propsParams)
3653
+ return this.computeMemoInitialValueOrNull(memo, signals, propsParams)
3017
3654
  }
3018
3655
  }
3019
3656
 
@@ -3256,6 +3893,47 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3256
3893
  // `string`, else the historical `0`).
3257
3894
  goType?: string,
3258
3895
  ): string {
3896
+ const resolved = this.computeMemoInitialValueOrNull(
3897
+ memo, signals, propsParams, propFallbackVars,
3898
+ )
3899
+ if (resolved !== null) return resolved
3900
+ // Default: zero value for the memo's Go type (#checkbox). A boolean memo
3901
+ // (`isChecked`) renders `false`, a string memo `""`; reference types
3902
+ // (map / slice / interface / pointer) take `nil` — `0` is not
3903
+ // assignable to them and broke the struct literal for a
3904
+ // block-bodied string-building memo whose type inferred to
3905
+ // `map[string]any` (#1896, select-demo's `summary`). Other types
3906
+ // keep the historical int `0`.
3907
+ if (goType === 'bool') return 'false'
3908
+ if (goType === 'string') return '""'
3909
+ if (
3910
+ goType !== undefined &&
3911
+ (goType.startsWith('map[') ||
3912
+ goType.startsWith('[]') ||
3913
+ goType.startsWith('*') ||
3914
+ goType.includes('interface{}') ||
3915
+ goType === 'any')
3916
+ ) {
3917
+ return 'nil'
3918
+ }
3919
+ return '0'
3920
+ }
3921
+
3922
+ /**
3923
+ * Pattern-matching core of `computeMemoInitialValue`: returns the
3924
+ * memo's SSR initial value as a Go expression, or `null` when no
3925
+ * pattern applies. Callers that have a typed field to fill use the
3926
+ * zero-value-defaulting wrapper above; callers that can simply OMIT
3927
+ * the field (a child-instance prop init — Go's zero values then apply
3928
+ * with the right type for free) use this directly (#1896,
3929
+ * data-table's `Checked: 0` into a bool field).
3930
+ */
3931
+ private computeMemoInitialValueOrNull(
3932
+ memo: { name: string; computation: string; deps: string[] },
3933
+ signals: { getter: string; initialValue: string }[],
3934
+ propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
3935
+ propFallbackVars: ReadonlyMap<string, PropFallbackVar> = GoTemplateAdapter.EMPTY_PROP_FALLBACK_VARS,
3936
+ ): string | null {
3259
3937
  const computation = memo.computation
3260
3938
  // Helper to pick the hoisted var (if any) or fall back to `in.X`.
3261
3939
  const propRef = (propName: string): string => {
@@ -3274,6 +3952,71 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3274
3952
  const tmplMemo = this.computeTemplateLiteralMemoInitialValue(computation, propsParams)
3275
3953
  if (tmplMemo !== null) return tmplMemo
3276
3954
 
3955
+ // Pattern: () => getter() === 'lit' / !== 'lit' — a selection memo
3956
+ // (#1896, tabs' `isAccountSelected = createMemo(() => activeTab() ===
3957
+ // 'account')`). Resolves to a Go bool when the signal's initial value
3958
+ // is itself a string literal.
3959
+ const eqMatch = computation.match(
3960
+ /^\(\)\s*=>\s*(\w+)\(\)\s*([!=]==?)\s*'([^']*)'\s*$/,
3961
+ )
3962
+ if (eqMatch) {
3963
+ const [, depName, op, lit] = eqMatch
3964
+ const signal = signals.find(sg => sg.getter === depName)
3965
+ const initLit = signal ? /^'([^'\\]*)'$/.exec(signal.initialValue.trim()) : null
3966
+ if (initLit) {
3967
+ const equal = initLit[1] === lit
3968
+ return String(op.startsWith('!') ? !equal : equal)
3969
+ }
3970
+ }
3971
+
3972
+ // Pattern: () => props.X ?? false — a boolean passthrough memo
3973
+ // (#1896, SelectItem's `isDisabled`). Go's bool zero value IS the
3974
+ // `?? false` fallback, so the raw input field carries the memo
3975
+ // semantics exactly.
3976
+ const boolPassthrough = computation.match(
3977
+ /^\(\)\s*=>\s*props\.(\w+)\s*\?\?\s*false\s*$/,
3978
+ )
3979
+ if (boolPassthrough) {
3980
+ return propRef(boolPassthrough[1])
3981
+ }
3982
+
3983
+ // Pattern: () => cond() ? A : B where each branch is a module string
3984
+ // const or a string literal, and `cond` is a signal/memo this
3985
+ // resolver can already evaluate (#1896, SelectItem's
3986
+ // `stateClasses`). Emits a Go conditional so the SSR value tracks
3987
+ // the caller's input rather than a baked branch.
3988
+ const ternMatch = computation.match(
3989
+ /^\(\)\s*=>\s*(\w+)\(\)\s*\?\s*([\w$]+|'[^']*')\s*:\s*([\w$]+|'[^']*')\s*$/,
3990
+ )
3991
+ if (ternMatch) {
3992
+ const [, condName, whenTrue, whenFalse] = ternMatch
3993
+ const resolveBranch = (b: string): string | null => {
3994
+ const lit = /^'([^']*)'$/.exec(b)
3995
+ if (lit) return JSON.stringify(lit[1])
3996
+ const constVal = this.moduleStringConsts.get(b)
3997
+ return constVal !== undefined ? JSON.stringify(constVal) : null
3998
+ }
3999
+ const t = resolveBranch(whenTrue)
4000
+ const f = resolveBranch(whenFalse)
4001
+ if (t !== null && f !== null) {
4002
+ let condGo: string | null = null
4003
+ const condSignal = signals.find(sg => sg.getter === condName)
4004
+ if (condSignal) {
4005
+ condGo = this.getSignalInitialValueAsGo(condSignal.initialValue, propsParams, propFallbackVars)
4006
+ } else {
4007
+ const condMemo = (this.currentMemos ?? []).find(m => m.name === condName)
4008
+ if (condMemo) {
4009
+ condGo = this.computeMemoInitialValueOrNull(condMemo, signals, propsParams, propFallbackVars)
4010
+ }
4011
+ }
4012
+ if (condGo === 'true') return t
4013
+ if (condGo === 'false') return f
4014
+ if (condGo !== null) {
4015
+ return `func() string { if ${condGo} { return ${t} }; return ${f} }()`
4016
+ }
4017
+ }
4018
+ }
4019
+
3277
4020
  // Pattern: () => dep() * N or () => dep() + N etc.
3278
4021
  const arithmeticMatch = computation.match(/\(\)\s*=>\s*(\w+)\(\)\s*([*+\-/])\s*(\d+)/)
3279
4022
  if (arithmeticMatch) {
@@ -3352,55 +4095,486 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3352
4095
  }
3353
4096
  }
3354
4097
 
3355
- // Default: zero value for the memo's Go type (#checkbox). A boolean memo
3356
- // (`isChecked`) renders `false`, a string memo `""`; otherwise the
3357
- // historical int `0`.
3358
- if (goType === 'bool') return 'false'
3359
- if (goType === 'string') return '""'
3360
- return '0'
4098
+ // (#1897) Pattern: block-body memo that early-returns a module-const array
4099
+ // when a guard signal is falsy — `() => { const k = getter(); if (!k)
4100
+ // return MODULE_ARRAY; return /* @client */ ... }`. When the signal starts
4101
+ // null, the SSR value is the module-const array. The constant's literal
4102
+ // value (not the identifier) is passed to the baker so `jsLiteralToGo`
4103
+ // can reduce it to a Go slice.
4104
+ const blockReturn = this.resolveBlockBodyMemoModuleConst(computation, signals)
4105
+ if (blockReturn !== null && blockReturn.constValue && blockReturn.constType) {
4106
+ return this.convertInitialValue(
4107
+ blockReturn.constValue,
4108
+ blockReturn.constType,
4109
+ propsParams,
4110
+ )
4111
+ }
4112
+
4113
+ // (#1897 PostList) Pattern: an object-returning block-body memo derived from
4114
+ // `searchParams()` — `() => { const sp = searchParams(); return { sort:
4115
+ // asSortKey(sp.get('sort')), tag: sp.get('tag') ?? '' } }`. Compute a Go
4116
+ // `map[string]interface{}` whose values are lowered from the request query,
4117
+ // so `.Params.Sort` / `.Params.Tag` resolve at execute time instead of
4118
+ // reading a nil map. Returns null for any unsupported shape (→ nil fallback,
4119
+ // no regression).
4120
+ const objMemo = this.computeObjectMemoInitialValue(computation)
4121
+ if (objMemo !== null) return objMemo
4122
+
4123
+ return null
3361
4124
  }
3362
4125
 
3363
4126
  /**
3364
- * Whether a memo is an arrow whose result is a template literal — either a
3365
- * concise body (`() => \`…\``) or a block body whose `return` is one.
4127
+ * (#1897) Recognises a block-body memo whose SSR path returns a module-const
4128
+ * array when the guard signal starts falsy:
4129
+ * `() => { const k = getter(); if (!k) return MODULE_CONST; … }`
4130
+ * Returns the constant's name and inferred type, or null.
3366
4131
  */
3367
- private isTemplateLiteralMemo(computation: string): boolean {
3368
- const sf = ts.createSourceFile(
3369
- '__memo.ts', `const __x = (${computation});`, ts.ScriptTarget.Latest, /*setParentNodes*/ false,
3370
- )
3371
- const stmt = sf.statements[0]
3372
- if (!stmt || !ts.isVariableStatement(stmt)) return false
3373
- let init = stmt.declarationList.declarations[0]?.initializer
3374
- while (init && ts.isParenthesizedExpression(init)) init = init.expression
3375
- if (!init || !ts.isArrowFunction(init)) return false
3376
- let body = init.body as ts.Node
3377
- while (ts.isParenthesizedExpression(body as ts.Expression)) {
3378
- body = (body as ts.ParenthesizedExpression).expression
3379
- }
3380
- if (ts.isBlock(body)) {
3381
- const ret = body.statements.find(ts.isReturnStatement)
3382
- if (!ret || !ret.expression) return false
3383
- body = ret.expression
3384
- while (ts.isParenthesizedExpression(body as ts.Expression)) {
3385
- body = (body as ts.ParenthesizedExpression).expression
4132
+ private resolveBlockBodyMemoModuleConst(
4133
+ computation: string,
4134
+ signals: { getter: string; initialValue: string }[],
4135
+ ): { constName: string; constValue: string | undefined; constType: TypeInfo | undefined } | null {
4136
+ try {
4137
+ const sf = ts.createSourceFile(
4138
+ '__memo.ts',
4139
+ `const __x = (${computation});`,
4140
+ ts.ScriptTarget.Latest,
4141
+ false,
4142
+ )
4143
+ const stmt = sf.statements[0]
4144
+ if (!stmt || !ts.isVariableStatement(stmt)) return null
4145
+ let init = stmt.declarationList.declarations[0]?.initializer
4146
+ while (init && ts.isParenthesizedExpression(init)) init = init.expression
4147
+ if (!init || !ts.isArrowFunction(init)) return null
4148
+ const body = init.body
4149
+ if (!ts.isBlock(body)) return null
4150
+
4151
+ // Walk the block's statements collecting:
4152
+ // const <varName> = <signalGetter>() → varToSignal map
4153
+ // if (!<varName>) return <moduleConst> → match varName back to signal
4154
+ const varToSignal = new Map<string, string>()
4155
+ let guardSignalGetter: string | null = null
4156
+ let returnedConst: string | null = null
4157
+
4158
+ for (const s of body.statements) {
4159
+ if (ts.isVariableStatement(s)) {
4160
+ for (const decl of s.declarationList.declarations) {
4161
+ if (
4162
+ ts.isIdentifier(decl.name) &&
4163
+ decl.initializer &&
4164
+ ts.isCallExpression(decl.initializer) &&
4165
+ ts.isIdentifier(decl.initializer.expression)
4166
+ ) {
4167
+ const callee = decl.initializer.expression.text
4168
+ if (signals.some(sg => sg.getter === callee)) {
4169
+ varToSignal.set(decl.name.text, callee)
4170
+ }
4171
+ }
4172
+ }
4173
+ }
4174
+ if (
4175
+ ts.isIfStatement(s) &&
4176
+ ts.isPrefixUnaryExpression(s.expression) &&
4177
+ s.expression.operator === ts.SyntaxKind.ExclamationToken &&
4178
+ ts.isIdentifier(s.expression.operand)
4179
+ ) {
4180
+ const guardVar = s.expression.operand.text
4181
+ const signalGetter = varToSignal.get(guardVar)
4182
+ if (!signalGetter) continue
4183
+ const thenBlock = ts.isBlock(s.thenStatement)
4184
+ ? s.thenStatement.statements
4185
+ : [s.thenStatement]
4186
+ for (const rs of thenBlock) {
4187
+ if (
4188
+ ts.isReturnStatement(rs) &&
4189
+ rs.expression &&
4190
+ ts.isIdentifier(rs.expression)
4191
+ ) {
4192
+ guardSignalGetter = signalGetter
4193
+ returnedConst = rs.expression.text
4194
+ }
4195
+ }
4196
+ }
4197
+ if (guardSignalGetter && returnedConst) break
4198
+ }
4199
+
4200
+ if (!guardSignalGetter || !returnedConst) return null
4201
+
4202
+ // The guard signal must start falsy (null, '', 0, false)
4203
+ const guardSignal = signals.find(sg => sg.getter === guardSignalGetter)
4204
+ if (!guardSignal) return null
4205
+ const iv = guardSignal.initialValue.trim()
4206
+ if (iv !== 'null' && iv !== "''" && iv !== '""' && iv !== '0' && iv !== 'false') {
4207
+ return null
4208
+ }
4209
+
4210
+ // The returned identifier must be a module-scope constant
4211
+ const constant = this.localConstants.find(
4212
+ c => c.name === returnedConst && c.origin?.scope === 'module',
4213
+ )
4214
+ if (!constant) return null
4215
+
4216
+ return {
4217
+ constName: constant.name,
4218
+ constValue: constant.value,
4219
+ constType: constant.type ?? undefined,
3386
4220
  }
4221
+ } catch {
4222
+ return null
3387
4223
  }
3388
- return ts.isTemplateExpression(body) || ts.isNoSubstitutionTemplateLiteral(body)
3389
4224
  }
3390
4225
 
3391
4226
  /**
3392
- * Infer the Go type for a memo based on its computation and dependencies.
4227
+ * (#1897 PostList) Compute the SSR value of an object-returning block-body
4228
+ * memo derived from `searchParams()`:
4229
+ * () => { const sp = searchParams(); return { sort: asSortKey(sp.get('sort')),
4230
+ * tag: sp.get('tag') ?? '' } }
4231
+ * Emits a Go `map[string]interface{}{ "Sort": …, "Tag": … }` whose values are
4232
+ * lowered from the request query (see `lowerCtorExpr`). Keys are capitalized to
4233
+ * match the template's `.Params.<Field>` map access. Returns null for any shape
4234
+ * the lowerer can't represent, so the caller falls back to a nil map.
3393
4235
  */
3394
- private inferMemoType(
3395
- memo: { name: string; computation: string; type: TypeInfo; deps: string[] },
3396
- signals: { getter: string; initialValue: string; type: TypeInfo }[],
3397
- propsParamMap: Map<string, { name: string; type: TypeInfo; defaultValue?: string }>
3398
- ): string {
3399
- // A template-literal memo always produces a string. Decide this first so a
3400
- // class-string `/` (e.g. `ring-ring/50`) doesn't trip the arithmetic
3401
- // heuristic below into `int`.
3402
- if (this.isTemplateLiteralMemo(memo.computation)) return 'string'
3403
-
4236
+ private computeObjectMemoInitialValue(computation: string): string | null {
4237
+ const arrow = this.parseLiteralExpression(computation)
4238
+ if (!arrow || !ts.isArrowFunction(arrow) || !ts.isBlock(arrow.body)) return null
4239
+
4240
+ // Accept only a strict shape: zero or more `const`/`let` declarations
4241
+ // followed by exactly one `return { }` as the LAST statement. Any other
4242
+ // statement kind `if`, an early/extra `return`, a loop, a bare call —
4243
+ // means the block has control flow this resolver can't reason about, so we
4244
+ // bail to the nil fallback rather than silently lowering one of several
4245
+ // returns (#1941 review). Along the way, collect `const <v> = searchParams()`
4246
+ // bindings (the env for `<v>.get('k')`).
4247
+ const searchParamsVars = new Set<string>()
4248
+ let retObj: ts.ObjectLiteralExpression | null = null
4249
+ const statements = arrow.body.statements
4250
+ for (let i = 0; i < statements.length; i++) {
4251
+ const s = statements[i]
4252
+ if (ts.isVariableStatement(s)) {
4253
+ for (const d of s.declarationList.declarations) {
4254
+ if (
4255
+ ts.isIdentifier(d.name) &&
4256
+ d.initializer &&
4257
+ ts.isCallExpression(d.initializer) &&
4258
+ ts.isIdentifier(d.initializer.expression) &&
4259
+ d.initializer.arguments.length === 0 &&
4260
+ this.searchParamsLocals.has(d.initializer.expression.text)
4261
+ ) {
4262
+ searchParamsVars.add(d.name.text)
4263
+ }
4264
+ }
4265
+ continue
4266
+ }
4267
+ if (ts.isReturnStatement(s)) {
4268
+ // The return must be the last statement (so it's the only one) and
4269
+ // return an object literal.
4270
+ if (i !== statements.length - 1 || !s.expression) return null
4271
+ let e: ts.Expression = s.expression
4272
+ while (ts.isParenthesizedExpression(e)) e = e.expression
4273
+ if (!ts.isObjectLiteralExpression(e)) return null
4274
+ retObj = e
4275
+ continue
4276
+ }
4277
+ // Any other statement kind → control flow we don't model → bail.
4278
+ return null
4279
+ }
4280
+ if (!retObj || retObj.properties.length === 0) return null
4281
+
4282
+ const env: CtorLowerEnv = { searchParamsVars, params: new Map() }
4283
+ const entries: string[] = []
4284
+ for (const prop of retObj.properties) {
4285
+ if (!ts.isPropertyAssignment(prop)) return null
4286
+ const key =
4287
+ ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name) ? prop.name.text : null
4288
+ if (!key) return null
4289
+ const go = this.lowerCtorExpr(prop.initializer, env)
4290
+ if (go === null) return null
4291
+ entries.push(`"${this.capitalizeFieldName(key)}": ${go}`)
4292
+ }
4293
+ return `map[string]interface{}{\n\t\t${entries.join(',\n\t\t')},\n\t}`
4294
+ }
4295
+
4296
+ /**
4297
+ * Lower a JS expression to a Go expression in the `NewXxxProps` constructor
4298
+ * context. This is Go *code*, not template syntax — so a search-param read
4299
+ * becomes `in.SearchParams.Get("k")` (method call), not the template's
4300
+ * `.SearchParams.Get "k"`. Supports the narrow surface derived-state memos
4301
+ * need: string/number literals, `<sp>.get('k')`, `<arr>.includes(<x>)`,
4302
+ * module arrow-helper inlining, `<expr> ?? <fallback>`, and string ternaries.
4303
+ * Returns null for anything else so the caller can fall back safely.
4304
+ */
4305
+ private lowerCtorExpr(node: ts.Expression, env: CtorLowerEnv): string | null {
4306
+ while (ts.isParenthesizedExpression(node)) node = node.expression
4307
+
4308
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
4309
+ return JSON.stringify(node.text)
4310
+ }
4311
+ if (ts.isNumericLiteral(node)) return node.text
4312
+
4313
+ // Identifier: a substituted helper param, a module string const, or a
4314
+ // component-scope derived const inlined recursively (e.g. `root` → its
4315
+ // `base || '/'` value).
4316
+ if (ts.isIdentifier(node)) {
4317
+ const sub = env.params.get(node.text)
4318
+ if (sub !== undefined) return sub
4319
+ const c = this.localConstants.find(lc => lc.name === node.text)
4320
+ if (c?.value !== undefined) {
4321
+ if (c.isModule) {
4322
+ const lit = this.parseLiteralExpression(c.value)
4323
+ if (lit && (ts.isStringLiteral(lit) || ts.isNoSubstitutionTemplateLiteral(lit))) {
4324
+ return JSON.stringify(lit.text)
4325
+ }
4326
+ return null
4327
+ }
4328
+ // Component-scope const: inline its computed value, guarding cycles.
4329
+ if (env.consts?.has(node.text)) return null
4330
+ const inner = this.parseLiteralExpression(c.value)
4331
+ if (!inner) return null
4332
+ return this.lowerCtorExpr(inner, {
4333
+ ...env,
4334
+ consts: new Set([...(env.consts ?? []), node.text]),
4335
+ })
4336
+ }
4337
+ return null
4338
+ }
4339
+
4340
+ // `props.<X>` → the constructor input field `in.<X>`.
4341
+ if (
4342
+ ts.isPropertyAccessExpression(node) &&
4343
+ ts.isIdentifier(node.expression) &&
4344
+ node.expression.text === this.propsObjectName
4345
+ ) {
4346
+ return `in.${this.capitalizeFieldName(node.name.text)}`
4347
+ }
4348
+
4349
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
4350
+ const method = node.expression.name.text
4351
+ const recv = node.expression.expression
4352
+ // `<sp>.get('k')` where <sp> is bound to searchParams().
4353
+ if (
4354
+ method === 'get' &&
4355
+ ts.isIdentifier(recv) &&
4356
+ env.searchParamsVars.has(recv.text) &&
4357
+ node.arguments.length === 1 &&
4358
+ ts.isStringLiteral(node.arguments[0])
4359
+ ) {
4360
+ return `in.SearchParams.Get(${JSON.stringify(node.arguments[0].text)})`
4361
+ }
4362
+ // `<arr>.includes(<x>)` → bf.Includes(<[]string{…}>, <x>) (bool).
4363
+ if (method === 'includes' && node.arguments.length === 1) {
4364
+ const arr = this.lowerCtorStringArray(recv)
4365
+ const elem = this.lowerCtorExpr(node.arguments[0], env)
4366
+ if (arr !== null && elem !== null) return `bf.Includes(${arr}, ${elem})`
4367
+ return null
4368
+ }
4369
+ // `<s>.replace(/\/+$/, '')` — strip trailing slashes → strings.TrimRight.
4370
+ // Only this exact trailing-slash regex is recognized (a general regex
4371
+ // replace would need Go's regexp; out of scope).
4372
+ if (
4373
+ method === 'replace' &&
4374
+ node.arguments.length === 2 &&
4375
+ node.arguments[0].kind === ts.SyntaxKind.RegularExpressionLiteral &&
4376
+ (node.arguments[0] as ts.Node).getText() === '/\\/+$/' &&
4377
+ (ts.isStringLiteral(node.arguments[1]) ||
4378
+ ts.isNoSubstitutionTemplateLiteral(node.arguments[1])) &&
4379
+ node.arguments[1].text === ''
4380
+ ) {
4381
+ const recvGo = this.lowerCtorExpr(recv, env)
4382
+ if (recvGo === null) return null
4383
+ this.needsStringsImport = true
4384
+ return `strings.TrimRight(${recvGo}, "/")`
4385
+ }
4386
+ return null
4387
+ }
4388
+
4389
+ // `helper(<args>)` where helper is a module arrow const → inline its body.
4390
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
4391
+ const fnConst = this.localConstants.find(
4392
+ lc => lc.name === (node.expression as ts.Identifier).text && lc.isModule,
4393
+ )
4394
+ if (fnConst?.value) {
4395
+ const fn = this.parseLiteralExpression(fnConst.value)
4396
+ if (
4397
+ fn &&
4398
+ ts.isArrowFunction(fn) &&
4399
+ !ts.isBlock(fn.body) &&
4400
+ fn.parameters.length === node.arguments.length
4401
+ ) {
4402
+ const params = new Map(env.params)
4403
+ for (let i = 0; i < fn.parameters.length; i++) {
4404
+ const p = fn.parameters[i]
4405
+ if (!ts.isIdentifier(p.name)) return null
4406
+ const argGo = this.lowerCtorExpr(node.arguments[i], env)
4407
+ if (argGo === null) return null
4408
+ params.set(p.name.text, argGo)
4409
+ }
4410
+ return this.lowerCtorExpr(fn.body, { searchParamsVars: env.searchParamsVars, params })
4411
+ }
4412
+ }
4413
+ return null
4414
+ }
4415
+
4416
+ // `<expr> ?? <fallback>`
4417
+ if (
4418
+ ts.isBinaryExpression(node) &&
4419
+ node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken
4420
+ ) {
4421
+ const left = this.lowerCtorExpr(node.left, env)
4422
+ if (left === null) return null
4423
+ let r: ts.Expression = node.right
4424
+ while (ts.isParenthesizedExpression(r)) r = r.expression
4425
+ // `?? ''` is a no-op: SearchParams.Get already returns "" for a missing key.
4426
+ if ((ts.isStringLiteral(r) || ts.isNoSubstitutionTemplateLiteral(r)) && r.text === '') {
4427
+ return left
4428
+ }
4429
+ const right = this.lowerCtorExpr(node.right, env)
4430
+ if (right === null) return null
4431
+ return `func() string { v := ${left}; if v != "" { return v }; return ${right} }()`
4432
+ }
4433
+
4434
+ // `<expr> || <fallback>` (string `||` — falsy = empty, like `?? ''` but the
4435
+ // left can itself be empty): `base || '/'`.
4436
+ if (
4437
+ ts.isBinaryExpression(node) &&
4438
+ node.operatorToken.kind === ts.SyntaxKind.BarBarToken
4439
+ ) {
4440
+ const left = this.lowerCtorExpr(node.left, env)
4441
+ const right = this.lowerCtorExpr(node.right, env)
4442
+ if (left === null || right === null) return null
4443
+ return `func() string { v := ${left}; if v != "" { return v }; return ${right} }()`
4444
+ }
4445
+
4446
+ // `<cond> ? <t> : <f>` (string result)
4447
+ if (ts.isConditionalExpression(node)) {
4448
+ // The condition must be lowered as a *boolean* (`lowerCtorCond`), not a
4449
+ // value: a string-valued JS condition like `sp.get('tag') ? a : b` is
4450
+ // truthy in JS, but `if "<string>"` does not compile in Go — such shapes
4451
+ // return null so the memo falls back to nil rather than emitting invalid
4452
+ // code (#1941 review).
4453
+ const cond = this.lowerCtorCond(node.condition, env)
4454
+ const t = this.lowerCtorExpr(node.whenTrue, env)
4455
+ const f = this.lowerCtorExpr(node.whenFalse, env)
4456
+ if (cond !== null && t !== null && f !== null) {
4457
+ return `func() string { if ${cond} { return ${t} }; return ${f} }()`
4458
+ }
4459
+ return null
4460
+ }
4461
+
4462
+ return null
4463
+ }
4464
+
4465
+ /**
4466
+ * Lower a JS expression used as a *boolean* condition to a Go bool expression,
4467
+ * or null when it is not provably boolean. Distinct from `lowerCtorExpr`,
4468
+ * which lowers value expressions: a string-valued condition (`sp.get('tag')`)
4469
+ * is truthy in JS but `if "<string>"` does not compile in Go, so anything not
4470
+ * known to yield a Go bool must fall back to null (#1941 review).
4471
+ */
4472
+ private lowerCtorCond(node: ts.Expression, env: CtorLowerEnv): string | null {
4473
+ while (ts.isParenthesizedExpression(node)) node = node.expression
4474
+
4475
+ if (node.kind === ts.SyntaxKind.TrueKeyword) return 'true'
4476
+ if (node.kind === ts.SyntaxKind.FalseKeyword) return 'false'
4477
+
4478
+ // `!<cond>`
4479
+ if (
4480
+ ts.isPrefixUnaryExpression(node) &&
4481
+ node.operator === ts.SyntaxKind.ExclamationToken
4482
+ ) {
4483
+ const inner = this.lowerCtorCond(node.operand, env)
4484
+ return inner === null ? null : `!(${inner})`
4485
+ }
4486
+
4487
+ // `<a> && <b>` / `<a> || <b>` — both operands must themselves be boolean.
4488
+ if (ts.isBinaryExpression(node)) {
4489
+ const op =
4490
+ node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken
4491
+ ? '&&'
4492
+ : node.operatorToken.kind === ts.SyntaxKind.BarBarToken
4493
+ ? '||'
4494
+ : null
4495
+ if (op) {
4496
+ const l = this.lowerCtorCond(node.left, env)
4497
+ const r = this.lowerCtorCond(node.right, env)
4498
+ return l !== null && r !== null ? `(${l} ${op} ${r})` : null
4499
+ }
4500
+ }
4501
+
4502
+ // `<arr>.includes(<x>)` is the one value-shape `lowerCtorExpr` lowers to a
4503
+ // Go bool (`bf.Includes(...)`); reuse it for that case only.
4504
+ if (
4505
+ ts.isCallExpression(node) &&
4506
+ ts.isPropertyAccessExpression(node.expression) &&
4507
+ node.expression.name.text === 'includes'
4508
+ ) {
4509
+ return this.lowerCtorExpr(node, env)
4510
+ }
4511
+
4512
+ return null
4513
+ }
4514
+
4515
+ /**
4516
+ * Resolve a string-array expression (a `['a','b']` literal, or a module const
4517
+ * bound to one) to a Go `[]string{…}` literal, or null when it isn't a pure
4518
+ * string-array. Used by `lowerCtorExpr` for `<arr>.includes(<x>)`.
4519
+ */
4520
+ private lowerCtorStringArray(node: ts.Expression): string | null {
4521
+ let arr: ts.Expression | null = node
4522
+ if (ts.isIdentifier(node)) {
4523
+ const c = this.localConstants.find(lc => lc.name === node.text && lc.isModule)
4524
+ if (!c?.value) return null
4525
+ arr = this.parseLiteralExpression(c.value)
4526
+ }
4527
+ if (!arr || !ts.isArrayLiteralExpression(arr)) return null
4528
+ const elems: string[] = []
4529
+ for (const el of arr.elements) {
4530
+ if (ts.isStringLiteral(el) || ts.isNoSubstitutionTemplateLiteral(el)) {
4531
+ elems.push(JSON.stringify(el.text))
4532
+ } else return null
4533
+ }
4534
+ return `[]string{${elems.join(', ')}}`
4535
+ }
4536
+
4537
+ /**
4538
+ * Whether a memo is an arrow whose result is a template literal — either a
4539
+ * concise body (`() => \`…\``) or a block body whose `return` is one.
4540
+ */
4541
+ private isTemplateLiteralMemo(computation: string): boolean {
4542
+ const sf = ts.createSourceFile(
4543
+ '__memo.ts', `const __x = (${computation});`, ts.ScriptTarget.Latest, /*setParentNodes*/ false,
4544
+ )
4545
+ const stmt = sf.statements[0]
4546
+ if (!stmt || !ts.isVariableStatement(stmt)) return false
4547
+ let init = stmt.declarationList.declarations[0]?.initializer
4548
+ while (init && ts.isParenthesizedExpression(init)) init = init.expression
4549
+ if (!init || !ts.isArrowFunction(init)) return false
4550
+ let body = init.body as ts.Node
4551
+ while (ts.isParenthesizedExpression(body as ts.Expression)) {
4552
+ body = (body as ts.ParenthesizedExpression).expression
4553
+ }
4554
+ if (ts.isBlock(body)) {
4555
+ const ret = body.statements.find(ts.isReturnStatement)
4556
+ if (!ret || !ret.expression) return false
4557
+ body = ret.expression
4558
+ while (ts.isParenthesizedExpression(body as ts.Expression)) {
4559
+ body = (body as ts.ParenthesizedExpression).expression
4560
+ }
4561
+ }
4562
+ return ts.isTemplateExpression(body) || ts.isNoSubstitutionTemplateLiteral(body)
4563
+ }
4564
+
4565
+ /**
4566
+ * Infer the Go type for a memo based on its computation and dependencies.
4567
+ */
4568
+ private inferMemoType(
4569
+ memo: { name: string; computation: string; type: TypeInfo; deps: string[] },
4570
+ signals: { getter: string; initialValue: string; type: TypeInfo }[],
4571
+ propsParamMap: Map<string, { name: string; type: TypeInfo; defaultValue?: string }>
4572
+ ): string {
4573
+ // A template-literal memo always produces a string. Decide this first so a
4574
+ // class-string `/` (e.g. `ring-ring/50`) doesn't trip the arithmetic
4575
+ // heuristic below into `int`.
4576
+ if (this.isTemplateLiteralMemo(memo.computation)) return 'string'
4577
+
3404
4578
  // Check if computation involves multiplication (*) - likely number
3405
4579
  if (memo.computation.includes('*') || memo.computation.includes('/') ||
3406
4580
  memo.computation.includes('+') || memo.computation.includes('-')) {
@@ -3438,6 +4612,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3438
4612
  return 'bool'
3439
4613
  }
3440
4614
 
4615
+ // (#1897) Block-body memo returning a module-const array: use the
4616
+ // constant's array type instead of the memo's generic `object`.
4617
+ const blockReturn = this.resolveBlockBodyMemoModuleConst(memo.computation, signals)
4618
+ if (blockReturn?.constType?.kind === 'array') {
4619
+ return this.typeInfoToGo(blockReturn.constType)
4620
+ }
4621
+
3441
4622
  // Default to the memo's declared type
3442
4623
  return this.typeInfoToGo(memo.type)
3443
4624
  }
@@ -3816,6 +4997,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3816
4997
  if (element.slotId) {
3817
4998
  hydrationAttrs += ` ${this.renderSlotMarker(element.slotId)}`
3818
4999
  }
5000
+ // Page-lifecycle boundary lowered from `<Region>` (spec/router.md). The id
5001
+ // is a deterministic static string (`<file scope>:<index>`), so it emits as
5002
+ // a plain literal attribute — no Go-template interpolation.
5003
+ if (element.regionId) {
5004
+ hydrationAttrs += ` ${BF_REGION}="${element.regionId}"`
5005
+ }
3819
5006
 
3820
5007
  const voidElements = [
3821
5008
  'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
@@ -3839,12 +5026,32 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3839
5026
  return ''
3840
5027
  }
3841
5028
 
3842
- const goExpr = this.convertExpressionToGo(expr.expr)
3843
-
3844
- // If the expression already contains Go template blocks (e.g., {{with ...}}),
3845
- // don't wrap it again in {{...}} to avoid double-wrapping.
5029
+ // Let `convertExpressionToGo` report the `ParsedExpr` it already builds (if
5030
+ // it gets that far) so the wrap decision below reuses that single parse —
5031
+ // no extra `ts.createSourceFile`, and no parse at all for expressions it
5032
+ // resolves via early returns (`null`/`undefined`, inlined consts) on the
5033
+ // `bf build` hot path.
5034
+ const classify: { parsed?: ParsedExpr } = {}
5035
+ const goExpr = this.convertExpressionToGo(expr.expr, classify)
5036
+
5037
+ // If the lowered expression is already template text, don't wrap it again
5038
+ // in {{...}} (double-wrapping). Two distinct shapes reach here:
5039
+ // - whole-expression blocks that START with `{{` (a `{{with ...}}` /
5040
+ // `{{if ...}}` chain from a nested ternary), and
5041
+ // - template literals, which lower via `templateLiteral()` to a MIX of
5042
+ // literal text and actions (` · #${tag}` → ` · #{{.Tag}}`). Those don't
5043
+ // start with `{{`, so a plain `startsWith` test let them fall through to
5044
+ // the wrap below and produced `{{ · #{{.Tag}}}}` — invalid
5045
+ // `html/template` syntax that panics at parse time (#1933, blog PostList
5046
+ // status line).
5047
+ //
5048
+ // `isTemplateFragment` makes this decision structurally (a `{{`-leading
5049
+ // action block or a template-literal kind), not by substring-matching `{{`:
5050
+ // a bare string literal that merely CONTAINS `{{` (JSX `{"{{"}` → Go expr
5051
+ // `"{{"`) is neither — it must still be wrapped so html/template evaluates
5052
+ // and escapes the string instead of emitting the raw quotes (#1937 review).
3846
5053
  // Use comment markers instead of <span> to avoid changing DOM structure.
3847
- if (goExpr.startsWith('{{')) {
5054
+ if (this.isTemplateFragment(goExpr, classify.parsed?.kind)) {
3848
5055
  if (expr.slotId) {
3849
5056
  return `{{bfTextStart "${expr.slotId}"}}${goExpr}{{bfTextEnd}}`
3850
5057
  }
@@ -3860,6 +5067,41 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3860
5067
  return `{{${goExpr}}}`
3861
5068
  }
3862
5069
 
5070
+ /**
5071
+ * Decide whether a lowered Go string is ALREADY a self-contained template
5072
+ * fragment — i.e. it carries its own `{{...}}` actions and so must NOT be
5073
+ * re-wrapped in another `{{...}}` (doing so yields `{{ {{...}} }}`, which
5074
+ * `html/template` rejects at parse time). Single source of truth for the
5075
+ * wrap-or-not decision across `renderExpression`, `templateLiteral`, and the
5076
+ * static-string / attribute interpolation paths.
5077
+ *
5078
+ * The decision is STRUCTURAL, deliberately NOT a `{{` substring scan: literal
5079
+ * text and Go string literals are ambiguous to scan — `5" ${x}` lowers to
5080
+ * `5" {{.X}}` while JS `{"{{"}` lowers to the Go literal `"{{"`; both mix
5081
+ * quotes and braces, so no scan can tell them apart. Two — and only two —
5082
+ * structural shapes are fragments:
5083
+ *
5084
+ * 1. A pure action block (`{{if}}` / `{{with}}` / `{{range}}` from a ternary,
5085
+ * a `find().prop`, a `filter().length`, …). The emitter prepends NO
5086
+ * literal text to these, so they ALWAYS start with `{{`; a leading `{{`
5087
+ * is therefore an unambiguous structural marker for this whole class.
5088
+ * 2. A template literal — the ONLY source form that interleaves author
5089
+ * literal text with `{{...}}` actions (` · #${tag}` → ` · #{{.Tag}}`), so
5090
+ * it may begin with literal text and is detected by its parsed `kind`.
5091
+ *
5092
+ * Everything else is a bare pipeline (`.Foo`, `len .X`, `bf_arr …`) — even one
5093
+ * whose value contains `{{` inside a Go string literal — and MUST be wrapped.
5094
+ *
5095
+ * Invariant (enforced by the `template-fragment invariant` tests): no
5096
+ * non-template-literal fragment ever begins with literal text, so case 1's
5097
+ * `startsWith('{{')` is complete. If a future emitter prepends literal text to
5098
+ * an action block those tests fail — fix it by giving that shape a parsed kind
5099
+ * this helper can key off, exactly as template literals are handled here.
5100
+ */
5101
+ private isTemplateFragment(go: string, kind?: ParsedExpr['kind']): boolean {
5102
+ return go.startsWith('{{') || kind === 'template-literal'
5103
+ }
5104
+
3863
5105
  /**
3864
5106
  * Render a client-only conditional as comment markers.
3865
5107
  * Used when @client directive is applied to an unsupported conditional.
@@ -3888,6 +5130,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3888
5130
  // ===========================================================================
3889
5131
 
3890
5132
  identifier(name: string): string {
5133
+ // `undefined` / `null` inside a larger expression tree (a ternary
5134
+ // branch like `props.isActive ? 'page' : undefined`, #1896
5135
+ // pagination) renders as the empty string — the top-level
5136
+ // `convertExpressionToGo` short-circuit doesn't see nested ones.
5137
+ if (name === 'undefined' || name === 'null') return '""'
3891
5138
  // Module pure-string const (e.g. `const baseClasses = '...'` used in a
3892
5139
  // className template literal): inline the literal value rather than
3893
5140
  // emit `{{.BaseClasses}}` against a Props field that never exists.
@@ -3901,6 +5148,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3901
5148
  }
3902
5149
  const inlined = this.resolveModuleStringConst(name)
3903
5150
  if (inlined !== null) return inlined
5151
+ // Module numeric const (e.g. `const TRACK = 8` used in a width expression):
5152
+ // inline the literal value rather than emit `{{.TRACK}}` against a Props
5153
+ // field that never exists. Mirrors the string-const inlining above.
5154
+ const inlinedNum = this.resolveModuleNumericConst(name)
5155
+ if (inlinedNum !== null) return inlinedNum
3904
5156
  const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
3905
5157
  if (currentLoopParam && name === currentLoopParam) return '.'
3906
5158
  // An *outer* loop's value variable (we're in a nested loop) is in scope as
@@ -3908,7 +5160,101 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3908
5160
  // the inner dot no longer refers to it, and it's not a root field. (#1677)
3909
5161
  if (this.isOuterLoopParam(name)) return `$${name}`
3910
5162
  if (this.loopVarRefCount.has(name)) return `$${name}`
3911
- return this.rootFieldRef(name)
5163
+ // (#1897) A bare reference to a component-scope derived const (e.g. `root`)
5164
+ // lowers to `.Root`; note it so `generateTypes` emits a computed field.
5165
+ if (this.localConstants.some(c => c.name === name && !c.isModule && !c.containsArrow)) {
5166
+ this.referencedDerivedConsts.add(name)
5167
+ }
5168
+ // Env-signal binding (incl. an alias) → canonical `.SearchParams` (#1922).
5169
+ return this.searchParamsFieldRef(name) ?? this.rootFieldRef(name)
5170
+ }
5171
+
5172
+ /**
5173
+ * (#1897 PostList) Compute the Go struct fields for component-scope derived
5174
+ * string consts referenced by the template (e.g. `root = base || '/'`). Each
5175
+ * is lowered to a constructor-context Go expression via `lowerCtorExpr`, with
5176
+ * its dependency consts inlined. Skips names that collide with an existing
5177
+ * field (`takenFieldNames`) or that the lowerer can't represent.
5178
+ */
5179
+ private computeDerivedConstFields(
5180
+ takenFieldNames: ReadonlySet<string>,
5181
+ ): { name: string; init: string }[] {
5182
+ const fields: { name: string; init: string }[] = []
5183
+ for (const name of this.referencedDerivedConsts) {
5184
+ const fieldName = this.capitalizeFieldName(name)
5185
+ if (takenFieldNames.has(fieldName)) continue
5186
+ const c = this.localConstants.find(lc => lc.name === name && !lc.isModule && lc.value)
5187
+ if (!c?.value) continue
5188
+ const expr = this.parseLiteralExpression(c.value)
5189
+ if (!expr) continue
5190
+ // The field is typed `string`; only emit when the value is provably a Go
5191
+ // string, so a numeric/other const referenced in the template can't be
5192
+ // assigned into a string field (#1945 review).
5193
+ if (!this.isStringExpr(expr, new Set())) continue
5194
+ const init = this.lowerCtorExpr(expr, {
5195
+ searchParamsVars: new Set(),
5196
+ params: new Map(),
5197
+ consts: new Set([name]),
5198
+ })
5199
+ if (init === null) continue
5200
+ fields.push({ name: fieldName, init })
5201
+ }
5202
+ return fields
5203
+ }
5204
+
5205
+ /**
5206
+ * Conservative check that a JS expression is *definitely* string-valued —
5207
+ * used to gate derived-const field emission (the field is typed `string`).
5208
+ * Recognizes string/template literals, string-returning methods (`.replace`,
5209
+ * `.trim`, … and `searchParams().get`), `+` / `||` / `??` where a branch is
5210
+ * string-valued, and a component-const reference to such a value. Anything
5211
+ * unproven (numbers, `props.X`, calls it doesn't know) returns false.
5212
+ */
5213
+ private isStringExpr(node: ts.Expression, seen: Set<string>): boolean {
5214
+ while (ts.isParenthesizedExpression(node)) node = node.expression
5215
+ if (
5216
+ ts.isStringLiteral(node) ||
5217
+ ts.isNoSubstitutionTemplateLiteral(node) ||
5218
+ ts.isTemplateExpression(node)
5219
+ ) {
5220
+ return true
5221
+ }
5222
+ if (ts.isBinaryExpression(node)) {
5223
+ const op = node.operatorToken.kind
5224
+ // `+`: a string on *either* side forces string concatenation.
5225
+ if (op === ts.SyntaxKind.PlusToken) {
5226
+ return this.isStringExpr(node.left, seen) || this.isStringExpr(node.right, seen)
5227
+ }
5228
+ // `||` / `??` evaluate to *one* operand, so the result is only provably a
5229
+ // string when *both* sides are (`props.count ?? ''` is not — it can be the
5230
+ // number) (#1945 review).
5231
+ if (op === ts.SyntaxKind.BarBarToken || op === ts.SyntaxKind.QuestionQuestionToken) {
5232
+ return this.isStringExpr(node.left, seen) && this.isStringExpr(node.right, seen)
5233
+ }
5234
+ return false
5235
+ }
5236
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
5237
+ const m = node.expression.name.text
5238
+ const STRING_METHODS = new Set([
5239
+ 'replace', 'trim', 'trimStart', 'trimEnd', 'toLowerCase', 'toUpperCase',
5240
+ 'slice', 'substring', 'substr', 'padStart', 'padEnd', 'concat', 'repeat', 'get',
5241
+ ])
5242
+ return STRING_METHODS.has(m)
5243
+ }
5244
+ if (ts.isConditionalExpression(node)) {
5245
+ return (
5246
+ this.isStringExpr(node.whenTrue, seen) && this.isStringExpr(node.whenFalse, seen)
5247
+ )
5248
+ }
5249
+ if (ts.isIdentifier(node)) {
5250
+ if (seen.has(node.text)) return false
5251
+ const c = this.localConstants.find(lc => lc.name === node.text && !lc.isModule && lc.value)
5252
+ if (c?.value) {
5253
+ const inner = this.parseLiteralExpression(c.value)
5254
+ if (inner) return this.isStringExpr(inner, new Set([...seen, node.text]))
5255
+ }
5256
+ }
5257
+ return false
3912
5258
  }
3913
5259
 
3914
5260
  /**
@@ -3937,6 +5283,17 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3937
5283
  return `${prefix}${this.capitalizeFieldName(name)}`
3938
5284
  }
3939
5285
 
5286
+ /**
5287
+ * (#1922) When `name` is a local binding of the `searchParams()` env signal,
5288
+ * resolve it to the canonical `.SearchParams` field — not `.<Capitalized
5289
+ * name>` — so an aliased `import { searchParams as sp }` (`sp()`) reaches the
5290
+ * same struct field the generator emits. Returns null for any other name so
5291
+ * callers fall back to their normal field-ref lowering.
5292
+ */
5293
+ private searchParamsFieldRef(name: string): string | null {
5294
+ return this.searchParamsLocals.has(name) ? this.rootFieldRef('searchParams') : null
5295
+ }
5296
+
3940
5297
  /**
3941
5298
  * Build the module pure-string-const map from the IR's localConstants.
3942
5299
  * A const qualifies only when it is module-scope (`isModule`) and its
@@ -3947,49 +5304,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3947
5304
  * pure compile-time string can be safely inlined byte-for-byte.
3948
5305
  */
3949
5306
  private collectModuleStringConsts(constants: IRMetadata['localConstants']): Map<string, string> {
3950
- const map = new Map<string, string>()
3951
- for (const c of constants ?? []) {
3952
- if (!c.isModule) continue
3953
- if (c.value === undefined) continue
3954
- const literal = this.parsePureStringLiteral(c.value)
3955
- if (literal !== null) map.set(c.name, literal)
3956
- }
3957
- return map
5307
+ // Single source of truth shared with the Mojo / Xslate adapters
5308
+ // (fixed-point resolution incl. composed template-literal consts and
5309
+ // `[...].join(sep)` — #1896 / #1897).
5310
+ return collectModuleStringConstsShared(constants)
3958
5311
  }
3959
5312
 
3960
- /**
3961
- * Parse a const initializer's source text. Returns the unescaped string
3962
- * value when the whole initializer is a single string literal (or a
3963
- * no-substitution template literal), else `null`. Uses the TS parser so
3964
- * escapes/quotes are resolved exactly as JS would, matching the value
3965
- * the Hono reference inlines at runtime.
3966
- */
3967
- private parsePureStringLiteral(source: string): string | null {
3968
- const sf = ts.createSourceFile(
3969
- '__const.ts',
3970
- `const __x = (${source});`,
3971
- ts.ScriptTarget.Latest,
3972
- /*setParentNodes*/ false,
3973
- )
3974
- const stmt = sf.statements[0]
3975
- if (!stmt || !ts.isVariableStatement(stmt)) return null
3976
- const decl = stmt.declarationList.declarations[0]
3977
- let init = decl?.initializer
3978
- while (init && ts.isParenthesizedExpression(init)) init = init.expression
3979
- if (!init) return null
3980
- if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
3981
- return init.text
3982
- }
3983
- // (#checkbox) `[...string literals].join(SEP)` — a const that flattens an
3984
- // array of pure string literals to a single string at module load (e.g.
3985
- // Checkbox's `stateClasses = [...].join(' ')`). Evaluate it statically so
3986
- // the const inlines byte-for-byte like the Hono reference. Only fires when
3987
- // every array element is a string/no-substitution-template literal and the
3988
- // separator is a string-literal argument (or omitted → default `,`).
3989
- const joined = this.evalStringArrayJoin(init)
3990
- if (joined !== null) return joined
3991
- return null
3992
- }
5313
+
3993
5314
 
3994
5315
  /**
3995
5316
  * (#checkbox) Statically evaluate `[<string literals>].join(<sep?>)`.
@@ -3998,31 +5319,6 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3998
5319
  * non-string-literal separator). Comments/whitespace between elements are
3999
5320
  * irrelevant — the TS parser already discarded them.
4000
5321
  */
4001
- private evalStringArrayJoin(node: ts.Expression): string | null {
4002
- if (!ts.isCallExpression(node)) return null
4003
- const callee = node.expression
4004
- if (!ts.isPropertyAccessExpression(callee)) return null
4005
- if (callee.name.text !== 'join') return null
4006
- let recv: ts.Expression = callee.expression
4007
- while (ts.isParenthesizedExpression(recv)) recv = recv.expression
4008
- if (!ts.isArrayLiteralExpression(recv)) return null
4009
- const parts: string[] = []
4010
- for (const el of recv.elements) {
4011
- if (ts.isStringLiteral(el) || ts.isNoSubstitutionTemplateLiteral(el)) {
4012
- parts.push(el.text)
4013
- } else {
4014
- return null
4015
- }
4016
- }
4017
- let sep = ','
4018
- if (node.arguments.length >= 1) {
4019
- const arg = node.arguments[0]
4020
- if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) sep = arg.text
4021
- else return null
4022
- }
4023
- return parts.join(sep)
4024
- }
4025
-
4026
5322
  /**
4027
5323
  * Resolve an identifier to its inlined Go string literal when it names a
4028
5324
  * module pure-string const. Returns the Go template literal form
@@ -4043,6 +5339,31 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4043
5339
  return `"${this.escapeGoString(value)}"`
4044
5340
  }
4045
5341
 
5342
+ /**
5343
+ * Inline a module-level numeric const (`const TRACK = 8`) as its literal
5344
+ * value. Only a plain numeric initializer qualifies — anything computed or
5345
+ * non-numeric falls through to the normal field/ident resolution. Scoped to
5346
+ * module consts (like the string variant) and guarded against loop vars so a
5347
+ * range variable that shadows a const name still wins.
5348
+ */
5349
+ private resolveModuleNumericConst(name: string): string | null {
5350
+ if (this.loopParamStack.length > 0 && this.loopParamStack[this.loopParamStack.length - 1] === name) {
5351
+ return null
5352
+ }
5353
+ if (this.loopVarRefCount.has(name)) return null
5354
+ if (this.isOuterLoopParam(name)) return null
5355
+ const c = this.localConstants.find(
5356
+ (k) => k.name === name && k.isModule && !k.containsArrow,
5357
+ )
5358
+ if (!c || c.value === undefined) return null
5359
+ // `value` is reconstructed from source text, so a valid TS literal may carry
5360
+ // numeric separators (`100_000`). Strip them between digits, then accept a
5361
+ // plain decimal / float; Go template numeric literals don't allow `_`, so
5362
+ // the stripped form is what gets emitted.
5363
+ const v = c.value.trim().replace(/(?<=\d)_(?=\d)/g, '')
5364
+ return /^-?\d+(\.\d+)?$/.test(v) ? v : null
5365
+ }
5366
+
4046
5367
  literal(value: string | number | boolean | null, literalType: LiteralType): string {
4047
5368
  if (literalType === 'string') return `"${value}"`
4048
5369
  if (literalType === 'null') return 'nil'
@@ -4050,9 +5371,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4050
5371
  }
4051
5372
 
4052
5373
  call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
4053
- // Signal call: count() -> .Count (or $.Count inside a loop, #1677)
5374
+ // Signal call: count() -> .Count (or $.Count inside a loop, #1677).
5375
+ // An env-signal binding (`searchParams()`, or an aliased `sp()`) resolves to
5376
+ // the canonical `.SearchParams` field regardless of the JS name (#1922).
4054
5377
  if (callee.kind === 'identifier' && args.length === 0) {
4055
- return this.rootFieldRef(callee.name)
5378
+ return this.searchParamsFieldRef(callee.name) ?? this.rootFieldRef(callee.name)
4056
5379
  }
4057
5380
  // Array methods (`.join` and any others added to ArrayMethod, #1443)
4058
5381
  // are lifted into the `array-method` IR kind at parse time, so
@@ -4104,6 +5427,24 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4104
5427
  if (result) return result
4105
5428
  }
4106
5429
 
5430
+ // `<memo>().length` where the memo's `.map()` feeds a handler-filled loop
5431
+ // slice → `len .<Slice>` (#1897 PostList: `visible().length` →
5432
+ // `len .PostListItems`). The slice holds the rendered (filtered) items, so
5433
+ // its length is the count — unlike the memo's own field, which is unset.
5434
+ if (
5435
+ property === 'length' &&
5436
+ object.kind === 'call' &&
5437
+ object.callee.kind === 'identifier' &&
5438
+ object.args.length === 0
5439
+ ) {
5440
+ const slice = this.memoBackedLoopSlice.get(object.callee.name)
5441
+ if (slice) {
5442
+ // Root field, so reach it through `$.` inside a loop (#1677).
5443
+ const prefix = this.loopParamStack.length > 0 ? '$.' : '.'
5444
+ return `len ${prefix}${slice}`
5445
+ }
5446
+ }
5447
+
4107
5448
  // find().property / findLast().property → {{with bf_find ...}}{{.Property}}{{end}}
4108
5449
  if (object.kind === 'higher-order' && (object.method === 'find' || object.method === 'findLast')) {
4109
5450
  const findResult = this.renderHigherOrderExpr(object, emit)
@@ -4122,6 +5463,18 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4122
5463
  return this.rootFieldRef(property)
4123
5464
  }
4124
5465
 
5466
+ // Static property access on a module object-literal const
5467
+ // (`variantClasses.ghost` in a class template literal, #1896
5468
+ // pagination) resolves at compile time — same lookup as the
5469
+ // bracket-index form in `resolveStaticRecordLiteralIndex`, reached
5470
+ // here when the access is nested inside a larger ParsedExpr tree.
5471
+ if (object.kind === 'identifier') {
5472
+ const staticValue = this.resolveStaticRecordLiteralIndex(
5473
+ `${object.name}.${property}`,
5474
+ )
5475
+ if (staticValue !== null) return staticValue
5476
+ }
5477
+
4125
5478
  // Inside a loop, the loop param variable refers to the current item
4126
5479
  // (dot). e.g. `msg.role` inside `{{range $_, $msg := .Messages}}` → `.Role`
4127
5480
  const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
@@ -4134,34 +5487,56 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4134
5487
  return `${obj}.${this.capitalizeFieldName(property)}`
4135
5488
  }
4136
5489
 
5490
+ indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
5491
+ // Go's `index` builtin: `index $arr $i`. Both operands render
5492
+ // through the same emitter so a loop-variable / arithmetic index
5493
+ // lowers correctly. A multi-token operand (`bf_add $i 1`) must be
5494
+ // parenthesised or Go parses it as extra `index` arguments. #1897
5495
+ // (`selected()[index]`). (data-table's broader keyed-loop SSR stays
5496
+ // tracked under #1896.)
5497
+ return `index ${wrapIfMultiToken(emit(object))} ${wrapIfMultiToken(emit(index))}`
5498
+ }
5499
+
4137
5500
  binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
4138
5501
  const l = emit(left)
4139
5502
  const r = emit(right)
5503
+ // Every Go form below is a prefix function call (`bf_mul a b`, `gt a b`,
5504
+ // `eq a b`), so a COMPOUND operand must be parenthesised or the template
5505
+ // parser folds its tokens into the call's argument list — e.g. nested
5506
+ // arithmetic `(elapsed / TRACK) * 100` would emit `bf_mul bf_div .Elapsed
5507
+ // .TRACK 100`, handing `bf_mul` four args. `wrapIfMultiToken` is a no-op
5508
+ // for single tokens and quoted literals, so simple operands are untouched.
5509
+ const wl = wrapIfMultiToken(l)
5510
+ const wr = wrapIfMultiToken(r)
4140
5511
  switch (op) {
4141
5512
  case '===':
4142
- case '==':
4143
- return `eq ${l} ${r}`
5513
+ case '==': {
5514
+ const [el, er] = stringTolerantEqOperands(l, r)
5515
+ return `eq ${wrapIfMultiToken(el)} ${wrapIfMultiToken(er)}`
5516
+ }
4144
5517
  case '!==':
4145
- case '!=':
4146
- return `ne ${l} ${r}`
5518
+ case '!=': {
5519
+ const [el, er] = stringTolerantEqOperands(l, r)
5520
+ return `ne ${wrapIfMultiToken(el)} ${wrapIfMultiToken(er)}`
5521
+ }
4147
5522
  case '>':
4148
- return `gt ${l} ${r}`
5523
+ return `gt ${wl} ${wr}`
4149
5524
  case '<':
4150
- return `lt ${l} ${r}`
5525
+ return `lt ${wl} ${wr}`
4151
5526
  case '>=':
4152
- return `ge ${l} ${r}`
5527
+ return `ge ${wl} ${wr}`
4153
5528
  case '<=':
4154
- return `le ${l} ${r}`
5529
+ return `le ${wl} ${wr}`
4155
5530
  case '+':
4156
- return `bf_add ${l} ${r}`
5531
+ return `bf_add ${wl} ${wr}`
4157
5532
  case '-':
4158
- return `bf_sub ${l} ${r}`
5533
+ return `bf_sub ${wl} ${wr}`
4159
5534
  case '*':
4160
- return `bf_mul ${l} ${r}`
5535
+ return `bf_mul ${wl} ${wr}`
4161
5536
  case '/':
4162
- return `bf_div ${l} ${r}`
5537
+ return `bf_div ${wl} ${wr}`
4163
5538
  case '%':
4164
- return `bf_mod ${l} ${r}`
5539
+ return `bf_mod ${wl} ${wr}`
4165
5540
  default:
4166
5541
  return `${l} ${op} ${r}`
4167
5542
  }
@@ -4180,10 +5555,18 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4180
5555
  right: ParsedExpr,
4181
5556
  emit: (e: ParsedExpr) => string,
4182
5557
  ): string {
4183
- const l = emit(left)
4184
- const r = emit(right)
4185
- const wrapLeft = this.needsParens(left) ? `(${l})` : l
4186
- const wrapRight = this.needsParens(right) ? `(${r})` : r
5558
+ // Go's `and`/`or` are prefix builtins, so every operand that renders to
5559
+ // more than one token (a method/function call like `.SearchParams.Get
5560
+ // "sort"`, an arithmetic `bf_add a b`, a comparison `eq a b`, a nested
5561
+ // `not …` / `or …`) must be parenthesised or it degrades into extra
5562
+ // sibling args of the enclosing `and`/`or`. `wrapIfMultiToken` is the
5563
+ // file-wide idiom for exactly this (every other prefix-helper emitter
5564
+ // composes operands through it); a bare field ref / quoted literal stays
5565
+ // uncluttered. This is what makes `searchParams().get(k) ?? d` lower to
5566
+ // `or (.SearchParams.Get "sort") "none"` instead of the broken
5567
+ // `or .SearchParams.Get "sort" "none"` (#1922).
5568
+ const wrapLeft = wrapIfMultiToken(emit(left))
5569
+ const wrapRight = wrapIfMultiToken(emit(right))
4187
5570
  if (op === '&&') return `and ${wrapLeft} ${wrapRight}`
4188
5571
  return `or ${wrapLeft} ${wrapRight}`
4189
5572
  }
@@ -4212,7 +5595,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4212
5595
  if (part.type === 'string') {
4213
5596
  result += part.value
4214
5597
  } else {
4215
- result += `{{${emit(part.expr)}}}`
5598
+ // A nested ternary emits a complete `{{if …}}…{{end}}` action
5599
+ // chain (see `conditional` above) — wrapping it again produces
5600
+ // `{{{{if …}}` and a template parse error (#1896, the tooltip
5601
+ // open/closed class ternary with module-const branches). Same
5602
+ // `isTemplateFragment` guard as `renderExpression`.
5603
+ const e = emit(part.expr)
5604
+ result += this.isTemplateFragment(e, part.expr.kind) ? e : `{{${e}}}`
4216
5605
  }
4217
5606
  }
4218
5607
  return result
@@ -4398,6 +5787,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4398
5787
  const recv = emit(object)
4399
5788
  return `bf_trim ${wrapIfMultiToken(recv)}`
4400
5789
  }
5790
+ case 'toFixed': {
5791
+ // `.toFixed(digits?)` → `bf_to_fixed` (`fmt.Sprintf("%.*f", …)`).
5792
+ // Default 0 digits when the argument is omitted. #1897.
5793
+ const recv = emit(object)
5794
+ const digits = args.length >= 1 ? emit(args[0]) : '0'
5795
+ return `bf_to_fixed ${wrapIfMultiToken(recv)} ${wrapIfMultiToken(digits)}`
5796
+ }
4401
5797
  case 'split': {
4402
5798
  // `.split()` / `.split(sep)` / `.split(sep, limit)` — string →
4403
5799
  // `[]any`. No separator → the whole string as a single element
@@ -5258,7 +6654,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5258
6654
  /**
5259
6655
  * Convert a JS expression to Go template syntax.
5260
6656
  */
5261
- private convertExpressionToGo(jsExpr: string): string {
6657
+ private convertExpressionToGo(jsExpr: string, out?: { parsed?: ParsedExpr }): string {
5262
6658
  const trimmed = jsExpr.trim()
5263
6659
 
5264
6660
  // Handle null/undefined specially
@@ -5266,6 +6662,59 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5266
6662
  return '""'
5267
6663
  }
5268
6664
 
6665
+ // `IDENT['key']` over a module object-literal const with a STRING
6666
+ // LITERAL key is a fully static lookup — resolve it at compile time
6667
+ // (#1896). The generic member lowering below would otherwise
6668
+ // capitalize the bracket access into a field reference and emit an
6669
+ // invalid hyphenated path (`strokePaths['chevron-down']` →
6670
+ // `.StrokePaths.Chevron-down`, a template parse error).
6671
+ const staticIndexed = this.resolveStaticRecordLiteralIndex(trimmed)
6672
+ if (staticIndexed !== null) {
6673
+ return staticIndexed
6674
+ }
6675
+
6676
+ // A bare identifier bound to a literal const inlines at compile time
6677
+ // (#1896 — pagination's `Page {currentPage()} of {totalPages}`:
6678
+ // `totalPages` is a function-scope `const totalPages = 5`, not a
6679
+ // prop, so the generic lowering would reference a nonexistent
6680
+ // `.TotalPages` field). Only pure numeric / single-quoted-string
6681
+ // initializers qualify; anything else may be runtime-dependent.
6682
+ if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) {
6683
+ const litConst = (this.localConstants ?? []).find(c => c.name === trimmed)
6684
+ if (litConst?.value !== undefined) {
6685
+ const v = litConst.value.trim()
6686
+ if (/^-?\d+(\.\d+)?$/.test(v)) return v
6687
+ const strLit = /^'([^'\\]*)'$/.exec(v) ?? /^"([^"\\]*)"$/.exec(v)
6688
+ if (strLit) return JSON.stringify(strLit[1])
6689
+ }
6690
+ }
6691
+
6692
+ // (#1897 PostList) A local URL-builder helper (`hrefFor`, or `sortHref` /
6693
+ // `tagHref` delegating to it) lowers to a `bf_query` action — there is no Go
6694
+ // method backing a `.SortHref "date"` call. Tried before the generic inliner
6695
+ // because these helpers are block-bodied / delegate, which the inliner skips.
6696
+ const urlBuilt = this.lowerUrlBuilderHelperCall(trimmed)
6697
+ if (urlBuilt !== null) return urlBuilt
6698
+
6699
+ // Inline a call to a local, expression-bodied helper arrow
6700
+ // (`sortClass(k)` / `tagClass(t)`) by substituting its params with the call
6701
+ // args and lowering the resulting expression. There is no Go method backing
6702
+ // a `.SortClass "date"` call, so the call site must carry the computation
6703
+ // (`{{if eq .Params.Sort "date"}}sort on{{else}}sort{{end}}`). Only self-
6704
+ // contained helpers are inlined; one that delegates to another local helper
6705
+ // (e.g. `sortHref` → `hrefFor`) is left for a later capability.
6706
+ const inlined = this.inlineLocalHelperCall(trimmed)
6707
+ if (inlined !== null) {
6708
+ return this.convertExpressionToGo(inlined, out)
6709
+ }
6710
+
6711
+ // Parse only here — *after* the early returns above, which resolve
6712
+ // `null`/`undefined`, static record indexes, and inlined literal consts
6713
+ // without a parse. The result is reported to the caller via `out` below
6714
+ // (after the support gate) so `renderExpression` can classify the
6715
+ // expression (template literal vs. not) off this single `parseExpression`,
6716
+ // with no extra `ts.createSourceFile` on the `bf build` hot path and no
6717
+ // parse at all for the early-return shapes.
5269
6718
  const parsed = parseExpression(trimmed)
5270
6719
  const support = isSupported(parsed)
5271
6720
 
@@ -5280,13 +6729,429 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5280
6729
  message: buildUnsupportedSuggestion(support),
5281
6730
  },
5282
6731
  })
5283
- // Return empty string - Go template comments must be separate actions
6732
+ // Return empty string - Go template comments must be separate actions.
6733
+ // Deliberately leave `out.parsed` unset here: the sentinel `""` must take
6734
+ // the normal wrap path in `renderExpression` (→ `{{""}}`), not the
6735
+ // template-literal "already template text" path — otherwise an
6736
+ // unsupported interpolation (`template-literal` kind) would emit `""`
6737
+ // outside an action and render literal quotes into the HTML (#1937 review).
5284
6738
  return `""`
5285
6739
  }
5286
6740
 
6741
+ // Report the supported parse to the caller (template-literal classification
6742
+ // for `renderExpression`) only after the support gate, so the wrap-skip path
6743
+ // can never trigger on the error sentinel above.
6744
+ if (out) out.parsed = parsed
6745
+
5287
6746
  return this.renderParsedExpr(parsed)
5288
6747
  }
5289
6748
 
6749
+ /**
6750
+ * (#1897 PostList) If `jsExpr` is a call to a local, expression-bodied helper
6751
+ * arrow const (`sortClass(k)` / `tagClass(t)`), return its body with the call
6752
+ * args substituted for the params (re-emitted to source), so the caller can
6753
+ * lower the inlined expression. Returns null when `jsExpr` is not such a call,
6754
+ * or when the helper delegates to another local helper (e.g. `sortHref` →
6755
+ * `hrefFor`) — those need their own lowering capability and must not be
6756
+ * half-inlined here. Substitution is AST-based (no string matching) so it is
6757
+ * shadowing- and member-name-safe.
6758
+ */
6759
+ private inlineLocalHelperCall(jsExpr: string): string | null {
6760
+ if (this.localHelperNames.size === 0) return null
6761
+ // Fast path: skip the AST parse unless the expression begins with a known
6762
+ // helper name applied as a call (`<helper>(`). The leading-identifier match
6763
+ // is an unambiguous prefix — the real shape check is the AST parse below.
6764
+ const head = /^\s*([A-Za-z_$][\w$]*)\s*\(/.exec(jsExpr)
6765
+ if (!head || !this.localHelperNames.has(head[1])) return null
6766
+
6767
+ const call = this.parseLiteralExpression(jsExpr)
6768
+ if (!call || !ts.isCallExpression(call) || !ts.isIdentifier(call.expression)) return null
6769
+ // A spread arg (`f(...xs)`) can't map to positional params by text splice.
6770
+ if (call.arguments.some(a => ts.isSpreadElement(a))) return null
6771
+ const fnConst = this.localConstants.find(
6772
+ c => c.name === (call.expression as ts.Identifier).text && !c.isModule && c.value,
6773
+ )
6774
+ if (!fnConst?.value) return null
6775
+ const fn = this.parseLiteralExpression(fnConst.value)
6776
+ if (!fn || !ts.isArrowFunction(fn) || ts.isBlock(fn.body)) return null
6777
+ if (fn.parameters.length !== call.arguments.length) return null
6778
+ // Don't half-inline a helper that calls another local helper.
6779
+ if (this.bodyCallsLocalHelper(fn.body)) return null
6780
+
6781
+ const subs = new Map<string, string>()
6782
+ for (let i = 0; i < fn.parameters.length; i++) {
6783
+ const p = fn.parameters[i]
6784
+ if (!ts.isIdentifier(p.name)) return null
6785
+ // Parenthesize the arg so its own precedence survives the splice into the
6786
+ // body (e.g. `x === <param>` with arg `a || b` must not become
6787
+ // `x === a || b`).
6788
+ subs.set(p.name.text, `(${call.arguments[i].getText()})`)
6789
+ }
6790
+ // The splicer is scope-blind, so reject bodies where a textual identifier
6791
+ // replacement could be wrong: nested function scopes (shadowing / param
6792
+ // positions) and object shorthand keys that are params.
6793
+ if (!this.isSpliceSafeHelperBody(fn.body, new Set(subs.keys()))) return null
6794
+ return this.substituteHelperParams(fn.body, subs)
6795
+ }
6796
+
6797
+ /**
6798
+ * Guard for `substituteHelperParams`: the span splicer replaces identifiers by
6799
+ * name without scope tracking, so it is only safe on bodies free of
6800
+ * constructs where a param name could appear in a position the splice can't
6801
+ * handle — a nested function scope (shadowing or its own parameters), or an
6802
+ * object shorthand key (`{ k }`, which can't be rewritten to `{ (arg) }`).
6803
+ */
6804
+ private isSpliceSafeHelperBody(body: ts.Node, paramNames: ReadonlySet<string>): boolean {
6805
+ let safe = true
6806
+ const visit = (n: ts.Node): void => {
6807
+ if (!safe) return
6808
+ if (
6809
+ ts.isArrowFunction(n) ||
6810
+ ts.isFunctionExpression(n) ||
6811
+ ts.isFunctionDeclaration(n)
6812
+ ) {
6813
+ safe = false
6814
+ return
6815
+ }
6816
+ if (ts.isShorthandPropertyAssignment(n) && paramNames.has(n.name.text)) {
6817
+ safe = false
6818
+ return
6819
+ }
6820
+ ts.forEachChild(n, visit)
6821
+ }
6822
+ visit(body)
6823
+ return safe
6824
+ }
6825
+
6826
+ /**
6827
+ * True when `body` contains a call to a *local* (component-scope) arrow helper
6828
+ * const — the signal that inlining `body` here would only push the problem to
6829
+ * another un-lowered helper (e.g. `sortHref`'s body calls `hrefFor`).
6830
+ */
6831
+ private bodyCallsLocalHelper(body: ts.Node): boolean {
6832
+ let found = false
6833
+ const visit = (n: ts.Node): void => {
6834
+ if (found) return
6835
+ if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
6836
+ const name = n.expression.text
6837
+ if (
6838
+ this.localConstants.some(c => c.name === name && !c.isModule && c.containsArrow)
6839
+ ) {
6840
+ found = true
6841
+ return
6842
+ }
6843
+ }
6844
+ ts.forEachChild(n, visit)
6845
+ }
6846
+ visit(body)
6847
+ return found
6848
+ }
6849
+
6850
+ /**
6851
+ * Re-emit `body` to source with each identifier named in `subs` replaced by
6852
+ * the substitution's source text. Implemented as span splicing over `body`'s
6853
+ * own source (single source file — args are passed as text, not cross-file
6854
+ * AST nodes, which would corrupt a printer keyed to `body`'s source). The walk
6855
+ * skips non-value identifier positions — the property NAME in `a.b` and a
6856
+ * plain object-literal key in `{ k: … }` — so a param sharing a name with a
6857
+ * member or key is left untouched. (`isSpliceSafeHelperBody` has already
6858
+ * rejected nested functions and `{ k }` shorthand keys.)
6859
+ */
6860
+ private substituteHelperParams(
6861
+ body: ts.Expression,
6862
+ subs: ReadonlyMap<string, string>,
6863
+ ): string {
6864
+ const sf = body.getSourceFile()
6865
+ const base = body.getStart(sf)
6866
+ // Collect replacement spans (relative to the body's start), right-to-left.
6867
+ const repls: { start: number; end: number; text: string }[] = []
6868
+ const visit = (node: ts.Node): void => {
6869
+ if (ts.isPropertyAccessExpression(node)) {
6870
+ visit(node.expression) // skip `.name`
6871
+ return
6872
+ }
6873
+ if (ts.isPropertyAssignment(node)) {
6874
+ // A plain identifier/string key isn't a value position; only a computed
6875
+ // key (`[expr]`) is. Visit the initializer (and computed key) only.
6876
+ if (ts.isComputedPropertyName(node.name)) visit(node.name)
6877
+ visit(node.initializer)
6878
+ return
6879
+ }
6880
+ if (ts.isIdentifier(node)) {
6881
+ const sub = subs.get(node.text)
6882
+ if (sub !== undefined) {
6883
+ repls.push({ start: node.getStart(sf) - base, end: node.getEnd() - base, text: sub })
6884
+ return
6885
+ }
6886
+ }
6887
+ ts.forEachChild(node, visit)
6888
+ }
6889
+ visit(body)
6890
+
6891
+ let text = body.getText(sf)
6892
+ for (const r of repls.sort((a, b) => b.start - a.start)) {
6893
+ text = text.slice(0, r.start) + r.text + text.slice(r.end)
6894
+ }
6895
+ return text
6896
+ }
6897
+
6898
+ /**
6899
+ * (#1897 PostList) Lower a call to a local URL-builder helper to a `bf_query`
6900
+ * template expression. Handles two shapes:
6901
+ * - the builder itself — `(sort, tag) => { const u = new URLSearchParams();
6902
+ * if (sort !== 'date') u.set('sort', sort); if (tag) u.set('tag', tag);
6903
+ * return u.toString() ? \`${root}?${u}\` : root }` — substitute the call
6904
+ * args for params and emit `bf_query <base> (<guard>) "key" <value> …`;
6905
+ * - a pass-through delegate — `(k) => hrefFor(k, params().tag)` — substitute
6906
+ * and recurse on the delegated call.
6907
+ * Returns null for anything else (→ existing lowering / method-call fallback).
6908
+ */
6909
+ private lowerUrlBuilderHelperCall(jsExpr: string): string | null {
6910
+ if (this.localHelperNames.size === 0) return null
6911
+ const head = /^\s*([A-Za-z_$][\w$]*)\s*\(/.exec(jsExpr)
6912
+ if (!head || !this.localHelperNames.has(head[1])) return null
6913
+
6914
+ const call = this.parseLiteralExpression(jsExpr)
6915
+ if (!call || !ts.isCallExpression(call) || !ts.isIdentifier(call.expression)) return null
6916
+ if (call.arguments.some(a => ts.isSpreadElement(a))) return null
6917
+ const fnConst = this.localConstants.find(
6918
+ c => c.name === (call.expression as ts.Identifier).text && !c.isModule && c.value,
6919
+ )
6920
+ if (!fnConst?.value) return null
6921
+ const fn = this.parseLiteralExpression(fnConst.value)
6922
+ if (!fn || !ts.isArrowFunction(fn) || fn.parameters.length !== call.arguments.length) return null
6923
+
6924
+ const subs = new Map<string, string>()
6925
+ for (let i = 0; i < fn.parameters.length; i++) {
6926
+ const p = fn.parameters[i]
6927
+ if (!ts.isIdentifier(p.name)) return null
6928
+ subs.set(p.name.text, `(${call.arguments[i].getText()})`)
6929
+ }
6930
+
6931
+ // Shape 1: the helper is itself a URLSearchParams builder.
6932
+ const shape = this.extractUrlBuilder(fn)
6933
+ if (shape) return this.emitUrlBuilder(shape, subs)
6934
+
6935
+ // Shape 2: a pass-through delegate to another local URL-builder helper.
6936
+ if (!ts.isBlock(fn.body)) {
6937
+ let body: ts.Expression = fn.body
6938
+ while (ts.isParenthesizedExpression(body)) body = body.expression
6939
+ if (
6940
+ ts.isCallExpression(body) &&
6941
+ ts.isIdentifier(body.expression) &&
6942
+ this.localHelperNames.has(body.expression.text)
6943
+ ) {
6944
+ return this.lowerUrlBuilderHelperCall(this.substituteHelperParams(body, subs))
6945
+ }
6946
+ }
6947
+ return null
6948
+ }
6949
+
6950
+ /**
6951
+ * Extract the `bf_query` shape from a `(…) => { const u = new URLSearchParams();
6952
+ * [if (G)] u.set(K, V); …; return <s> ? … : <base> }` helper, or null when the
6953
+ * block doesn't match that exact builder idiom.
6954
+ */
6955
+ private extractUrlBuilder(
6956
+ arrow: ts.ArrowFunction,
6957
+ ): { base: ts.Expression; sets: { guard: ts.Expression | null; key: string; value: ts.Expression }[] } | null {
6958
+ if (!ts.isBlock(arrow.body)) return null
6959
+ let builderVar: string | null = null
6960
+ let base: ts.Expression | null = null
6961
+ const sets: { guard: ts.Expression | null; key: string; value: ts.Expression }[] = []
6962
+
6963
+ for (const s of arrow.body.statements) {
6964
+ if (ts.isVariableStatement(s)) {
6965
+ for (const d of s.declarationList.declarations) {
6966
+ if (
6967
+ ts.isIdentifier(d.name) &&
6968
+ d.initializer &&
6969
+ ts.isNewExpression(d.initializer) &&
6970
+ ts.isIdentifier(d.initializer.expression) &&
6971
+ d.initializer.expression.text === 'URLSearchParams' &&
6972
+ (d.initializer.arguments?.length ?? 0) === 0
6973
+ ) {
6974
+ builderVar = d.name.text
6975
+ }
6976
+ // Other locals (`const s = u.toString()`) are inert — ignored.
6977
+ }
6978
+ continue
6979
+ }
6980
+ // `if (G) u.set(K, V)` (no else).
6981
+ if (ts.isIfStatement(s) && !s.elseStatement && builderVar) {
6982
+ const set = this.matchUrlSet(s.thenStatement, builderVar)
6983
+ if (!set) return null
6984
+ sets.push({ guard: s.expression, key: set.key, value: set.value })
6985
+ continue
6986
+ }
6987
+ // Unguarded `u.set(K, V)`.
6988
+ if (ts.isExpressionStatement(s) && builderVar) {
6989
+ const set = this.matchUrlSet(s, builderVar)
6990
+ if (set) {
6991
+ sets.push({ guard: null, key: set.key, value: set.value })
6992
+ continue
6993
+ }
6994
+ return null
6995
+ }
6996
+ // `return <s> ? `${base}?…` : <base>` — the builder's return must be the
6997
+ // conditional that picks between the with-query and bare-base URL; its
6998
+ // `whenFalse` (no-query) branch is the base. Any other return shape isn't
6999
+ // this idiom (#1945 review) — bail to the method-call fallback.
7000
+ if (ts.isReturnStatement(s) && s.expression) {
7001
+ let e: ts.Expression = s.expression
7002
+ while (ts.isParenthesizedExpression(e)) e = e.expression
7003
+ if (!ts.isConditionalExpression(e)) return null
7004
+ base = e.whenFalse
7005
+ continue
7006
+ }
7007
+ return null
7008
+ }
7009
+ if (!builderVar || !base || sets.length === 0) return null
7010
+ return { base, sets }
7011
+ }
7012
+
7013
+ /**
7014
+ * Match `<builderVar>.set('literalKey', <value>)` — as a statement or an
7015
+ * if-then — returning the key text and value node, else null.
7016
+ */
7017
+ private matchUrlSet(
7018
+ node: ts.Node,
7019
+ builderVar: string,
7020
+ ): { key: string; value: ts.Expression } | null {
7021
+ const stmt = ts.isBlock(node) ? (node.statements.length === 1 ? node.statements[0] : null) : node
7022
+ if (!stmt || !ts.isExpressionStatement(stmt)) return null
7023
+ const call = stmt.expression
7024
+ if (
7025
+ ts.isCallExpression(call) &&
7026
+ ts.isPropertyAccessExpression(call.expression) &&
7027
+ ts.isIdentifier(call.expression.expression) &&
7028
+ call.expression.expression.text === builderVar &&
7029
+ call.expression.name.text === 'set' &&
7030
+ call.arguments.length === 2 &&
7031
+ ts.isStringLiteral(call.arguments[0])
7032
+ ) {
7033
+ return { key: call.arguments[0].text, value: call.arguments[1] }
7034
+ }
7035
+ return null
7036
+ }
7037
+
7038
+ /**
7039
+ * Emit `bf_query <base> (<guard>) "key" <value> …` from an extracted builder
7040
+ * shape, lowering each part (with the call args substituted for params) via
7041
+ * the normal expression / condition lowering. Unguarded sets use `true`.
7042
+ */
7043
+ private emitUrlBuilder(
7044
+ shape: { base: ts.Expression; sets: { guard: ts.Expression | null; key: string; value: ts.Expression }[] },
7045
+ subs: ReadonlyMap<string, string>,
7046
+ ): string | null {
7047
+ const lowerExpr = (n: ts.Expression): string =>
7048
+ this.convertExpressionToGo(this.substituteHelperParams(n, subs))
7049
+ const baseGo = wrapIfMultiToken(lowerExpr(shape.base))
7050
+ const parts: string[] = [baseGo]
7051
+ for (const set of shape.sets) {
7052
+ const guardGo = set.guard ? this.lowerUrlGuard(set.guard, subs) : 'true'
7053
+ parts.push(`(${guardGo})`)
7054
+ parts.push(JSON.stringify(set.key))
7055
+ parts.push(wrapIfMultiToken(lowerExpr(set.value)))
7056
+ }
7057
+ return `bf_query ${parts.join(' ')}`
7058
+ }
7059
+
7060
+ /**
7061
+ * Lower a `u.set()` guard to a Go *bool* for `bf_query`'s `include` argument.
7062
+ * A comparison / logical / negation / bool-literal already yields a bool
7063
+ * (`convertConditionToGo`); a bare value (`if (tag)`) is JS string-truthiness,
7064
+ * lowered to `ne <value> ""`. The arg must be a real bool — `bf_query` type-
7065
+ * asserts it, so Go-template truthiness (`{{if x}}`) is not enough.
7066
+ */
7067
+ private lowerUrlGuard(guard: ts.Expression, subs: ReadonlyMap<string, string>): string {
7068
+ let g = guard
7069
+ while (ts.isParenthesizedExpression(g)) g = g.expression
7070
+ // Comparisons, `!x`, and bool literals lower to a Go bool via the condition
7071
+ // lowering. `&&` / `||` do NOT qualify: Go's `and`/`or` return one of their
7072
+ // operands (a string for a truthiness guard like `tag && other`), not a
7073
+ // bool — so they take the truthiness-wrap path below, yielding
7074
+ // `ne (and …) ""`, an actual bool (#1945 review).
7075
+ const isBoolShape =
7076
+ (ts.isBinaryExpression(g) &&
7077
+ [
7078
+ ts.SyntaxKind.EqualsEqualsToken,
7079
+ ts.SyntaxKind.EqualsEqualsEqualsToken,
7080
+ ts.SyntaxKind.ExclamationEqualsToken,
7081
+ ts.SyntaxKind.ExclamationEqualsEqualsToken,
7082
+ ts.SyntaxKind.LessThanToken,
7083
+ ts.SyntaxKind.GreaterThanToken,
7084
+ ts.SyntaxKind.LessThanEqualsToken,
7085
+ ts.SyntaxKind.GreaterThanEqualsToken,
7086
+ ].includes(g.operatorToken.kind)) ||
7087
+ (ts.isPrefixUnaryExpression(g) && g.operator === ts.SyntaxKind.ExclamationToken) ||
7088
+ g.kind === ts.SyntaxKind.TrueKeyword ||
7089
+ g.kind === ts.SyntaxKind.FalseKeyword
7090
+ if (isBoolShape) {
7091
+ return this.convertConditionToGo(this.substituteHelperParams(guard, subs)).condition
7092
+ }
7093
+ const valueGo = wrapIfMultiToken(
7094
+ this.convertExpressionToGo(this.substituteHelperParams(guard, subs)),
7095
+ )
7096
+ return `ne ${valueGo} ""`
7097
+ }
7098
+
7099
+ /**
7100
+ * Resolve `IDENT['key']` / `IDENT["key"]` where `IDENT` is a
7101
+ * module-scope object-literal const and the key is a string literal —
7102
+ * a compile-time-static lookup (the icon registry's
7103
+ * `strokePaths['chevron-down']`, #1896). Returns the looked-up value
7104
+ * as a Go literal (quoted string / bare number) usable inside a
7105
+ * template action, or `null` for any other shape so the caller falls
7106
+ * through to the generic lowering. The prop-keyed variant of the same
7107
+ * pattern lives in `parseRecordIndexAccess` (shared with Mojo); this
7108
+ * helper covers the literal-key case that parse rejects.
7109
+ */
7110
+ private resolveStaticRecordLiteralIndex(jsExpr: string): string | null {
7111
+ const m =
7112
+ /^([A-Za-z_$][\w$]*)\[\s*(?:'([^']*)'|"([^"]*)")\s*\]$/.exec(jsExpr) ??
7113
+ // Property-access form of the same static lookup
7114
+ // (`variantClasses.ghost`, #1896 pagination) — only when the base
7115
+ // resolves to a module object-literal const below, so ordinary
7116
+ // props/locals never match.
7117
+ /^([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/.exec(jsExpr)
7118
+ if (!m) return null
7119
+ const key = m[2] ?? m[3]
7120
+ const constInfo = (this.localConstants ?? []).find(
7121
+ c => c.name === m[1] && c.isModule,
7122
+ )
7123
+ if (constInfo?.value === undefined) return null
7124
+ const sf = ts.createSourceFile(
7125
+ '__rec.ts',
7126
+ `(${constInfo.value})`,
7127
+ ts.ScriptTarget.Latest,
7128
+ /* setParentNodes */ true,
7129
+ )
7130
+ if (sf.statements.length !== 1) return null
7131
+ const stmt = sf.statements[0]
7132
+ if (!ts.isExpressionStatement(stmt)) return null
7133
+ let parsed: ts.Expression = stmt.expression
7134
+ while (ts.isParenthesizedExpression(parsed)) parsed = parsed.expression
7135
+ if (!ts.isObjectLiteralExpression(parsed)) return null
7136
+ for (const prop of parsed.properties) {
7137
+ if (!ts.isPropertyAssignment(prop)) continue
7138
+ const name = prop.name
7139
+ const propKey =
7140
+ ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)
7141
+ ? name.text
7142
+ : null
7143
+ if (propKey !== key) continue
7144
+ let v: ts.Expression = prop.initializer
7145
+ while (ts.isParenthesizedExpression(v)) v = v.expression
7146
+ if (ts.isNumericLiteral(v)) return v.text
7147
+ if (ts.isStringLiteral(v) || ts.isNoSubstitutionTemplateLiteral(v)) {
7148
+ return JSON.stringify(v.text)
7149
+ }
7150
+ return null
7151
+ }
7152
+ return null
7153
+ }
7154
+
5290
7155
  /**
5291
7156
  * Create a source location for error reporting.
5292
7157
  */
@@ -5525,6 +7390,18 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5525
7390
  return { preamble: obj.preamble, expr: `${obj.expr}.${this.capitalizeFieldName(expr.property)}` }
5526
7391
  }
5527
7392
 
7393
+ case 'index-access': {
7394
+ // Go's `index` builtin: `index $arr $i`. A multi-token operand
7395
+ // (`bf_add $i 1`) must be parenthesised or Go parses it as extra
7396
+ // `index` arguments. #1897.
7397
+ const obj = this.renderConditionExpr(expr.object)
7398
+ const idx = this.renderConditionExpr(expr.index)
7399
+ return {
7400
+ preamble: obj.preamble + idx.preamble,
7401
+ expr: `index ${wrapIfMultiToken(obj.expr)} ${wrapIfMultiToken(idx.expr)}`,
7402
+ }
7403
+ }
7404
+
5528
7405
  case 'binary': {
5529
7406
  const leftNeedsParens = this.needsParensInGoTemplate(expr.left)
5530
7407
  const leftResult = this.renderConditionExpr(expr.left)
@@ -5539,11 +7416,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5539
7416
  let result: string
5540
7417
  switch (expr.op) {
5541
7418
  case '===':
5542
- case '==':
5543
- result = `eq ${left} ${right}`; break
7419
+ case '==': {
7420
+ const [el, er] = stringTolerantEqOperands(left, right)
7421
+ result = `eq ${el} ${er}`; break
7422
+ }
5544
7423
  case '!==':
5545
- case '!=':
5546
- result = `ne ${left} ${right}`; break
7424
+ case '!=': {
7425
+ const [el, er] = stringTolerantEqOperands(left, right)
7426
+ result = `ne ${el} ${er}`; break
7427
+ }
5547
7428
  case '>':
5548
7429
  result = `gt ${left} ${right}`; break
5549
7430
  case '<':
@@ -5831,6 +7712,61 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5831
7712
  return null
5832
7713
  }
5833
7714
 
7715
+ /**
7716
+ * When `comp`'s JSX children contain template actions (nested
7717
+ * components, dynamic text) — i.e. none of the static bake paths in
7718
+ * `collectStaticChildInstances` apply — render them into a companion
7719
+ * define and return its name; `renderComponent` then routes the child
7720
+ * call through `bf_with_children` + `bf_tmpl` (#1896). Returns null
7721
+ * for childless or statically-bakeable children, which keep the
7722
+ * constructor-baked `Children` value.
7723
+ */
7724
+ private queueDynamicChildrenDefine(comp: IRComponent): string | null {
7725
+ const effectiveChildren = comp.children.length > 0
7726
+ ? comp.children
7727
+ : this.jsxChildrenPropNodes(comp.props)
7728
+ if (effectiveChildren.length === 0) return null
7729
+ if (this.extractTextChildren(effectiveChildren) !== null) return null
7730
+ if (this.extractHtmlChildren(effectiveChildren) !== null) return null
7731
+ if (this.extractScopedHtmlChildren(effectiveChildren) !== null) return null
7732
+ const name = `${this.componentName}__children_${comp.slotId}`
7733
+ if (!this.pendingChildrenDefines.some(d => d.name === name)) {
7734
+ this.pendingChildrenDefines.push({
7735
+ name,
7736
+ content: this.renderChildren(effectiveChildren),
7737
+ })
7738
+ }
7739
+ return name
7740
+ }
7741
+
7742
+ /**
7743
+ * (#1897) Queue a companion define for a loop body component's JSX children.
7744
+ * Like `queueDynamicChildrenDefine` but temporarily exits the `inLoop`
7745
+ * context so nested component calls render with the normal `.NameSlotN`
7746
+ * field-access pattern (the fields live on the wrapper struct that the
7747
+ * companion define receives as its data context). The loop param stack
7748
+ * stays intact so datum-field references (`payment.id` → `.Id`) still
7749
+ * resolve.
7750
+ */
7751
+ private queueLoopBodyChildrenDefine(comp: IRComponent): string | null {
7752
+ const effectiveChildren = comp.children.length > 0
7753
+ ? comp.children
7754
+ : this.jsxChildrenPropNodes(comp.props)
7755
+ if (effectiveChildren.length === 0) return null
7756
+ if (this.extractTextChildren(effectiveChildren) !== null) return null
7757
+ if (this.extractHtmlChildren(effectiveChildren) !== null) return null
7758
+ if (this.extractScopedHtmlChildren(effectiveChildren) !== null) return null
7759
+ const name = `${this.componentName}__loop_children_${comp.slotId}`
7760
+ if (!this.pendingChildrenDefines.some(d => d.name === name)) {
7761
+ const wasInLoop = this.inLoop
7762
+ this.inLoop = false
7763
+ const content = this.renderChildren(effectiveChildren)
7764
+ this.inLoop = wasInLoop
7765
+ this.pendingChildrenDefines.push({ name, content })
7766
+ }
7767
+ return name
7768
+ }
7769
+
5834
7770
  renderComponent(comp: IRComponent, ctx?: { isRootOfClientComponent?: boolean }): string {
5835
7771
  // Handle Portal component specially - collect content for body end
5836
7772
  if (comp.name === 'Portal') {
@@ -5852,12 +7788,25 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5852
7788
  // In Go templates, components are rendered using {{template "name" data}}
5853
7789
  let templateCall: string
5854
7790
  if (this.inLoop) {
5855
- // Loop children: dot becomes loop item (already has correct props)
5856
- templateCall = `{{template "${comp.name}" .}}`
7791
+ // (#1897) Loop body component with JSX children: render children through
7792
+ // a companion define so `bf_with_children` injects them at template
7793
+ // execution time. Temporarily exit loop context so nested component
7794
+ // calls (e.g. TableCell inside TableRow) use the normal non-loop
7795
+ // rendering path (`.TableCellSlotN` fields on the wrapper struct),
7796
+ // while the loop param stack stays intact so datum references resolve.
7797
+ const loopBodyDefine = this.queueLoopBodyChildrenDefine(comp)
7798
+ if (loopBodyDefine) {
7799
+ templateCall = `{{template "${comp.name}" (bf_with_children . (bf_tmpl "${loopBodyDefine}" .))}}`
7800
+ } else {
7801
+ templateCall = `{{template "${comp.name}" .}}`
7802
+ }
5857
7803
  } else if (comp.slotId) {
5858
7804
  // Static children with slotId: use unique field name based on slotId
5859
7805
  const suffix = slotIdToFieldSuffix(comp.slotId)
5860
- templateCall = `{{template "${comp.name}" .${comp.name}${suffix}}}`
7806
+ const childrenDefine = this.queueDynamicChildrenDefine(comp)
7807
+ templateCall = childrenDefine
7808
+ ? `{{template "${comp.name}" (bf_with_children .${comp.name}${suffix} (bf_tmpl "${childrenDefine}" .))}}`
7809
+ : `{{template "${comp.name}" .${comp.name}${suffix}}}`
5861
7810
  } else {
5862
7811
  // Static children without slotId: fallback to .ComponentName
5863
7812
  templateCall = `{{template "${comp.name}" .${comp.name}}}`
@@ -5948,10 +7897,40 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5948
7897
  }
5949
7898
  if (isBooleanAttr(name) || value.presenceOrUndefined) {
5950
7899
  const { condition: goCond, preamble } = this.convertConditionToGo(value.expr)
5951
- return `${preamble}{{if ${goCond}}}${name}{{end}}`
7900
+ // ARIA attributes are string-valued ("true"/"false"), not HTML5
7901
+ // presence booleans — Hono renders the truthy presence form as
7902
+ // `aria-x="true"` (#1896, SelectItem's
7903
+ // `aria-disabled={isDisabled() || undefined}`).
7904
+ const body = name.startsWith('aria-') ? `${name}="true"` : name
7905
+ return `${preamble}{{if ${goCond}}}${body}{{end}}`
5952
7906
  }
5953
7907
  const parsed = parseExpression(value.expr.trim())
5954
- if (parsed.kind === 'conditional' || parsed.kind === 'template-literal') {
7908
+ if (parsed.kind === 'conditional') {
7909
+ // A ternary whose falsy branch is `undefined` / `null` OMITS the
7910
+ // attribute entirely on Hono (`aria-current={props.isActive ?
7911
+ // 'page' : undefined}`, #1896 pagination) — wrap the whole
7912
+ // attribute in the condition instead of rendering `attr=""`.
7913
+ const undef = (e: ParsedExpr): boolean =>
7914
+ (e.kind === 'identifier' && (e.name === 'undefined' || e.name === 'null')) ||
7915
+ (e.kind === 'literal' && (e.value === null || e.value === undefined))
7916
+ const test = parsed.test
7917
+ if (undef(parsed.alternate) && !undef(parsed.consequent)) {
7918
+ const { condition: goCond, preamble } = this.convertConditionToGo(
7919
+ // Re-render the test from its source span via the parsed tree. If it
7920
+ // lowered to a self-contained action block, re-parse the whole
7921
+ // ternary source; otherwise slice off the test portion.
7922
+ this.isTemplateFragment(this.renderParsedExpr(test), test.kind)
7923
+ ? value.expr
7924
+ : value.expr.slice(0, value.expr.indexOf('?')).trim(),
7925
+ )
7926
+ const valueExpr = this.renderParsedExpr(parsed.consequent)
7927
+ const body = `${name}="{{${valueExpr}}}"`
7928
+ return `${preamble}{{if ${goCond}}}${body}{{end}}`
7929
+ }
7930
+ // Inline Go template syntax with embedded `{{...}}` actions.
7931
+ return `${name}="${this.renderParsedExpr(parsed)}"`
7932
+ }
7933
+ if (parsed.kind === 'template-literal') {
5955
7934
  // Inline Go template syntax with embedded `{{...}}` actions.
5956
7935
  return `${name}="${this.renderParsedExpr(parsed)}"`
5957
7936
  }
@@ -5977,7 +7956,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5977
7956
  const field = `.${this.capitalizeFieldName(propName)}`
5978
7957
  return `{{if ne ${field} nil}}${name}="{{${this.convertExpressionToGo(value.expr)}}}"{{end}}`
5979
7958
  }
5980
- return `${name}="{{${this.convertExpressionToGo(value.expr)}}}"`
7959
+ // Lower once; if the result is already a self-contained action block (e.g.
7960
+ // an inlined `sortClass(k)` → `{{if …}}…{{end}}`, #1897), embed it as-is
7961
+ // rather than double-wrapping it in another `{{…}}`.
7962
+ const exprOut: { parsed?: ParsedExpr } = {}
7963
+ const go = this.convertExpressionToGo(value.expr, exprOut)
7964
+ return this.isTemplateFragment(go, exprOut.parsed?.kind)
7965
+ ? `${name}="${go}"`
7966
+ : `${name}="{{${go}}}"`
5981
7967
  },
5982
7968
  emitBooleanAttr: (_value, name) => name,
5983
7969
  // Spread attributes (`<div {...attrs()} />`) lower through the
@@ -6127,7 +8113,33 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
6127
8113
  }
6128
8114
  const inner = s.slice(open + 2, close).trim()
6129
8115
  if (inner) {
6130
- out += `{{${this.convertExpressionToGo(inner)}}}`
8116
+ // Thread the parsed kind out of `convertExpressionToGo` (reusing its
8117
+ // single parse) so a nested template literal here is handled too.
8118
+ const cls: { parsed?: ParsedExpr } = {}
8119
+ const goExpr = this.convertExpressionToGo(inner, cls)
8120
+ const parsed = cls.parsed
8121
+ if (parsed?.kind === 'template-literal') {
8122
+ // Attribute context: a template literal lowers to literal text
8123
+ // interleaved with `{{...}}` actions. That literal text sits OUTSIDE
8124
+ // any action, so — unlike the `{{...}}` actions, which Go escapes for
8125
+ // the attribute context at render time — it bypasses escaping. A `"`,
8126
+ // `<`, or `&` in a UnoCSS arbitrary value (`content-["x"]`) would then
8127
+ // break the surrounding `class="..."`. Escape each string part with
8128
+ // `escapeAttrText`, keeping interpolations as fragment-aware actions
8129
+ // (#1937 review). Mirrors the `templateLiteral` emitter, plus escaping.
8130
+ for (const part of parsed.parts) {
8131
+ if (part.type === 'string') {
8132
+ out += this.escapeAttrText(part.value)
8133
+ } else {
8134
+ const e = this.renderParsedExpr(part.expr)
8135
+ out += this.isTemplateFragment(e, part.expr.kind) ? e : `{{${e}}}`
8136
+ }
8137
+ }
8138
+ } else {
8139
+ // Same `isTemplateFragment` guard as `renderExpression` (#1896): a
8140
+ // ternary lowers to a complete `{{if}}` action chain — don't re-wrap.
8141
+ out += this.isTemplateFragment(goExpr, parsed?.kind) ? goExpr : `{{${goExpr}}}`
8142
+ }
6131
8143
  } else {
6132
8144
  out += s.slice(open, close + 1)
6133
8145
  }
@@ -6174,7 +8186,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
6174
8186
  // case matches; consumers shouldn't rely on a default fallback
6175
8187
  // here (the JSX-side `variant = 'default'` default already
6176
8188
  // shows up via the per-prop fallback in `NewXxxProps`).
6177
- const keyExpr = this.convertExpressionToGo(part.key)
8189
+ const rawKeyExpr = this.convertExpressionToGo(part.key)
8190
+ // A compound key (`props.placement ?? 'top'` → `or .Placement
8191
+ // "top"`) must be parenthesized inside `eq` — unwrapped, Go's
8192
+ // template parser reads `eq or .Placement "top" "left"` as four
8193
+ // arguments with a zero-arg `or` (#1896, tooltip placement).
8194
+ const keyExpr = /\s/.test(rawKeyExpr) ? `(${rawKeyExpr})` : rawKeyExpr
6178
8195
  const caseEntries = Object.entries(part.cases)
6179
8196
  if (caseEntries.length === 0) continue
6180
8197
  const branches = caseEntries.map(([k, v], i) => {