@barefootjs/go-template 0.18.4 → 0.18.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/adapter/analysis/static-child-loop-bake.d.ts +61 -0
  2. package/dist/adapter/analysis/static-child-loop-bake.d.ts.map +1 -0
  3. package/dist/adapter/analysis/static-element-loop-bake.d.ts +83 -0
  4. package/dist/adapter/analysis/static-element-loop-bake.d.ts.map +1 -0
  5. package/dist/adapter/go-template-adapter.d.ts +151 -3
  6. package/dist/adapter/go-template-adapter.d.ts.map +1 -1
  7. package/dist/adapter/index.js +500 -52
  8. package/dist/adapter/lib/compile-state.d.ts +15 -0
  9. package/dist/adapter/lib/compile-state.d.ts.map +1 -1
  10. package/dist/adapter/lib/constants.d.ts.map +1 -1
  11. package/dist/adapter/memo/memo-compute.d.ts.map +1 -1
  12. package/dist/adapter/props/prop-classes.d.ts +40 -0
  13. package/dist/adapter/props/prop-classes.d.ts.map +1 -0
  14. package/dist/adapter/props/prop-types.d.ts.map +1 -1
  15. package/dist/adapter/type/type-codegen.d.ts +5 -1
  16. package/dist/adapter/type/type-codegen.d.ts.map +1 -1
  17. package/dist/adapter/value/value-lowering.d.ts.map +1 -1
  18. package/dist/build.js +500 -52
  19. package/dist/conformance-pins.d.ts.map +1 -1
  20. package/dist/index.js +503 -79
  21. package/dist/render-divergences.d.ts.map +1 -1
  22. package/dist/test-render.d.ts.map +1 -1
  23. package/package.json +3 -3
  24. package/src/__tests__/go-template-adapter.test.ts +708 -4
  25. package/src/adapter/analysis/static-child-loop-bake.ts +119 -0
  26. package/src/adapter/analysis/static-element-loop-bake.ts +211 -0
  27. package/src/adapter/go-template-adapter.ts +661 -34
  28. package/src/adapter/lib/compile-state.ts +17 -0
  29. package/src/adapter/lib/constants.ts +1 -0
  30. package/src/adapter/memo/memo-compute.ts +37 -9
  31. package/src/adapter/props/prop-classes.ts +70 -0
  32. package/src/adapter/props/prop-types.ts +69 -1
  33. package/src/adapter/type/type-codegen.ts +19 -2
  34. package/src/adapter/value/value-lowering.ts +27 -2
  35. package/src/conformance-pins.ts +30 -36
  36. package/src/render-divergences.ts +12 -30
  37. package/src/test-render.ts +131 -13
@@ -66,9 +66,17 @@ import {
66
66
  prepareLoweringMatchers,
67
67
  envSignalReaderFor,
68
68
  computeSsrSeedPlan,
69
+ isStringConcatBinary,
70
+ isDangerousInnerHtmlAttr,
71
+ resolveDangerousInnerHtml,
72
+ dangerousInnerHtmlMetacharViolation,
73
+ dangerousInnerHtmlDiagnostic,
74
+ resolveStaticLoopSource,
75
+ collectLoopBoundNames,
76
+ evaluateStaticLiteral,
69
77
  } from '@barefootjs/jsx'
70
78
  import { findInterpolationEnd } from '@barefootjs/jsx/scanner'
71
- import { BF_REGION } from '@barefootjs/shared'
79
+ import { BF_REGION, escapeHtml } from '@barefootjs/shared'
72
80
 
73
81
  import {
74
82
  GO_IDENTIFIER,
@@ -110,6 +118,8 @@ import { collectRootScopeNodes } from "./lib/ir-scope.ts"
110
118
  import { GO_TEMPLATE_PRIMITIVES } from "./lib/constants.ts"
111
119
  import { CompileState } from "./lib/compile-state.ts"
112
120
  import { hasClientInteractivity, findNestedComponents } from "./analysis/component-tree.ts"
121
+ import { analyzeBakeableStaticChildLoop, scalarToGoLiteral, type BakedStaticChildLoop } from "./analysis/static-child-loop-bake.ts"
122
+ import { analyzeBakeableStaticElementLoop } from "./analysis/static-element-loop-bake.ts"
113
123
  import type { GoEmitContext } from "./emit-context.ts"
114
124
  import { inlineLocalHelperCall } from "./expr/helper-inline.ts"
115
125
  import { lowerRegisteredCall } from "./expr/url-builder.ts"
@@ -126,6 +136,7 @@ import { resolveBlockBodyMemoModuleConst } from "./memo/memo-value.ts"
126
136
  import { computeMemoInitialValue, computeMemoInitialValueOrNull, filterArmEarlierSiblingRefs } from "./memo/memo-compute.ts"
127
137
  import { collectSpreadSlots, buildSpreadInitializer } from "./spread/spread-codegen.ts"
128
138
  import { buildPropTypeOverrides, resolvePropGoType, collectNillablePropNames } from "./props/prop-types.ts"
139
+ import { collectStringValueNames } from "./props/prop-classes.ts"
129
140
 
130
141
  export type { GoTemplateAdapterOptions } from "./lib/types.ts"
131
142
 
@@ -224,7 +235,32 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
224
235
  }
225
236
 
226
237
  private inLoop: boolean = false
238
+ /**
239
+ * Memoized `analyzeBakeableStaticChildLoop` result per loop marker id
240
+ * (#2208). Consulted from three sites that all need the SAME verdict for
241
+ * the SAME loop — the `renderLoop` gate, the Input struct's field list,
242
+ * and the constructor's per-item construction. `renderLoop`'s gate runs
243
+ * during `generate()`'s render pass; the other two run during
244
+ * `generateTypes()`'s constructor-generation pass, which `generate()`
245
+ * also invokes internally partway through — so this cache is reset (in
246
+ * `primeCompileState`, not here) between those two passes too. Agreement
247
+ * across all three sites is therefore guaranteed by `analyzeBakeable-
248
+ * StaticChildLoop` being a deterministic pure function of the (re-primed)
249
+ * per-compile state, not by one shared memo spanning every read; the
250
+ * cache's value is avoiding redundant recomputation WITHIN the pass that
251
+ * populated it, not correctness across passes.
252
+ */
253
+ private bakedStaticChildLoopCache = new Map<string, BakedStaticChildLoop | null>()
227
254
  private loopParamStack: string[] = []
255
+ /**
256
+ * Stack of `IRLoop.depth` values (innermost last), pushed/popped around
257
+ * `renderChildren(loop.children)` in `renderLoop`. `renderAttributes`
258
+ * reads the top of this stack to derive the `key` → `data-key`/
259
+ * `data-key-N` suffix — the depth is IR-computed (jsx-to-ir.ts), not
260
+ * re-derived here; this stack only threads that value down to the
261
+ * loop body's root element (#2168 nested-loop-outer-binding).
262
+ */
263
+ private loopKeyDepthStack: number[] = []
228
264
  /**
229
265
  * Per-loop: true when the body renders the bare range value (scalar-item
230
266
  * inline-literal loop), so the `bf_tmpl` companion is fed `.BfLoopItem` (the
@@ -261,6 +297,30 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
261
297
  * alone (#2087 Phase B).
262
298
  */
263
299
  private loopRestExcludeStack: Array<Map<string, { parent: string; excludeKeys: string[] }>> = []
300
+ /**
301
+ * Active per-item static bindings for a #2224 unrolled plain-element loop
302
+ * (`renderUnrolledStaticElementLoop`) — innermost last, so a nested static
303
+ * unroll inside another one resolves against its own item, not an outer
304
+ * one's. When non-empty, `convertExpressionToGo`'s top-of-function check
305
+ * resolves EVERY expression against the innermost entry's item via
306
+ * `evaluateStaticLiteral` and returns a literal Go value instead of
307
+ * descending into the normal `.Field`-style dot-context lowering — there
308
+ * is no real `{{range}}` establishing that context during an unroll, so
309
+ * `identifier()`'s `currentLoopParam → '.'` branch would otherwise resolve
310
+ * against the WRONG (enclosing) dot context. `analyzeBakeableStaticElementLoop`
311
+ * has already verified every expression in the body resolves this way for
312
+ * every item, so the fallback failure branch below is a defensive
313
+ * invariant check, not a real code path.
314
+ */
315
+ private staticLoopItemStack: Array<{ param: string; item: unknown }> = []
316
+ /**
317
+ * Set by the `convertExpressionToGo` override above when a
318
+ * `staticLoopItemStack` entry is active but the current expression fails
319
+ * to resolve against it — should never happen (see that check's comment),
320
+ * but `renderUnrolledStaticElementLoop` asserts this stays `false` after
321
+ * every item render rather than silently shipping a `""` sentinel.
322
+ */
323
+ private staticLoopBakeFailed = false
264
324
 
265
325
  /**
266
326
  * Cross-component child shapes, keyed by child component name. Populated via
@@ -296,6 +356,32 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
296
356
  this.state.restPropsName = ir.metadata.restPropsName ?? null
297
357
  this.state.moduleStringConsts = this.collectModuleStringConsts(ir.metadata.localConstants)
298
358
  this.state.localConstants = ir.metadata.localConstants ?? []
359
+ // #2208 fable review: every name a `.map()`/`.filter()` loop callback
360
+ // binds as its item/index parameter anywhere in the component. Static
361
+ // loop-source resolution (`getBakedStaticChildLoop` /
362
+ // `analyzeBakeableStaticChildLoop`) must never resolve a const whose
363
+ // name a DIFFERENT, enclosing loop's own callback param shadows.
364
+ // Computed here (not from live render-time stack state) because this
365
+ // must agree across THREE call sites, two of which (`generateTypes`'s
366
+ // Input-struct + constructor generation) run OUTSIDE the live
367
+ // `renderLoop` tree-walk that would otherwise track shadowing via
368
+ // stack push/pop — same coarse-but-safe mitigation as #2212.
369
+ this.state.staticLoopSourceBoundNames = collectLoopBoundNames(ir)
370
+ // #2208 fable re-review: the adapter instance is a reused singleton —
371
+ // `generate()` calls this once per component, but `generateTypes()` is
372
+ // ALSO a standalone public entry point (the Go conformance harness in
373
+ // `test-render.ts` calls it directly on an already-`generate()`d
374
+ // adapter for a sibling/child IR). Resetting the bake cache HERE, not
375
+ // just in `generate()`, closes that door too: a stale entry keyed by a
376
+ // marker id that collides with a PREVIOUS component (marker ids
377
+ // restart at `l0` per component) would otherwise either silently
378
+ // suppress this fix or leak that other component's baked data into
379
+ // this one's constructor. `generate()` itself calls `generateTypes()`
380
+ // partway through — re-priming (and so re-clearing the cache) there is
381
+ // harmless: `analyzeBakeableStaticChildLoop` is deterministic over
382
+ // identically-primed state, so a cache miss on the second pass just
383
+ // recomputes the same answer.
384
+ this.bakedStaticChildLoopCache = new Map()
299
385
  this.state.localHelperNames = new Set(
300
386
  this.state.localConstants.filter(c => !c.isModule && c.containsArrow).map(c => c.name),
301
387
  )
@@ -331,6 +417,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
331
417
  this.state.pendingChildrenDefines = []
332
418
  this.primeCompileState(ir)
333
419
  this.state.nillablePropNames = collectNillablePropNames(this.emitCtx, ir)
420
+ this.state.stringValueNames = collectStringValueNames(ir)
334
421
 
335
422
  // Surface loop-body usages of sibling-imported components (see
336
423
  // `checkImportedLoopChildComponents`). The barefoot CLI compiles a
@@ -844,6 +931,18 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
844
931
  }
845
932
 
846
933
  for (const nested of inputNested) {
934
+ // #2208: a static loop whose array source is itself fully-static
935
+ // (baked directly in the constructor — see `generateNewPropsFunction`)
936
+ // has no caller-supplied data to accept; the Input struct carries no
937
+ // field for it at all (there was never a working `in.<Name>s` path
938
+ // for this shape before this fix — it refused with BF101).
939
+ if (nested.loopMarkerId && this.getBakedStaticChildLoop(
940
+ nested.loopMarkerId,
941
+ nested,
942
+ nested.loopArrayParsed,
943
+ nested.loopParam,
944
+ nested.loopKey,
945
+ )) continue
847
946
  lines.push(`\t${nested.name}s []${nested.name}Input`)
848
947
  }
849
948
 
@@ -1062,6 +1161,36 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1062
1161
  // Static nested WITHOUT body children.
1063
1162
  for (const nested of staticWithoutBody) {
1064
1163
  const varName = `${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`
1164
+ // #2208: a static loop whose ARRAY SOURCE is itself fully-static
1165
+ // (`const items = [{ label: 'Alpha' }, ...]`) has no caller input to
1166
+ // wait for — every item's props/data-key are already known at
1167
+ // compile time. Bake them directly instead of ranging over
1168
+ // `in.<Name>s` (which stays empty forever for this shape, since the
1169
+ // loop-source gate above only lets this fixture through BECAUSE it's
1170
+ // baked here).
1171
+ const baked = nested.loopMarkerId
1172
+ ? this.getBakedStaticChildLoop(
1173
+ nested.loopMarkerId,
1174
+ nested,
1175
+ nested.loopArrayParsed,
1176
+ nested.loopParam,
1177
+ nested.loopKey,
1178
+ )
1179
+ : null
1180
+ if (baked) {
1181
+ lines.push(`\t${varName} := make([]${nested.name}Props, ${baked.items.length})`)
1182
+ baked.items.forEach((item, i) => {
1183
+ const fields = item.inputFields.map(f => `${f.goField}: ${f.goValue}`).join(', ')
1184
+ lines.push(`\t${varName}[${i}] = New${nested.name}Props(${nested.name}Input{${fields}})`)
1185
+ lines.push(`\t${varName}[${i}].BfParent = scopeID`)
1186
+ lines.push(`\t${varName}[${i}].BfMount = "${nested.slotId}"`)
1187
+ if (item.dataKey !== null) {
1188
+ lines.push(`\t${varName}[${i}].BfDataKey = ${JSON.stringify(item.dataKey)}`)
1189
+ }
1190
+ })
1191
+ lines.push('')
1192
+ continue
1193
+ }
1065
1194
  lines.push(`\t${varName} := make([]${nested.name}Props, len(in.${nested.name}s))`)
1066
1195
  lines.push(`\tfor i, item := range in.${nested.name}s {`)
1067
1196
  lines.push(`\t\t${varName}[i] = New${nested.name}Props(item)`)
@@ -1369,6 +1498,35 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1369
1498
  break
1370
1499
  }
1371
1500
  }
1501
+ // A number/boolean JSX-EXPRESSION literal (`count={5}`,
1502
+ // `active={true}`) — as opposed to a plain quoted string attr
1503
+ // (`label="mail"`, which is `case 'literal':` above, an entirely
1504
+ // different `AttrValue` kind) — is still `kind: 'expression'`
1505
+ // here, since curly braces always parse to an expression
1506
+ // container regardless of what's inside them. #2168
1507
+ // child-primitive-props: `resolveDynamicPropValue` below only
1508
+ // recognizes a getter call or a comparison against one; a bare
1509
+ // `5`/`true` matches neither, so the field was silently OMITTED
1510
+ // and the Badge's `Count`/`Active` fields defaulted to Go's zero
1511
+ // value (`0`/`false`) regardless of the actual literal.
1512
+ //
1513
+ // Route through `parsedLiteralToGo` rather than hand-rolling a
1514
+ // switch on `.value`/`.literalType`: a numeric literal needs its
1515
+ // exact source `raw` token (Copilot review — `String(value)` can
1516
+ // change spelling/precision, e.g. a large integer or `-0`), and
1517
+ // `parsedLiteralToGo` also covers the LEADING-UNARY-MINUS shape
1518
+ // (`count={-5}` parses as `kind: 'unary'` wrapping the literal,
1519
+ // not `kind: 'literal'` itself — a case this branch's earlier
1520
+ // `parsedValue?.kind === 'literal'` gate missed entirely and
1521
+ // would have silently reintroduced the same omitted-field bug
1522
+ // for a negative numeric literal).
1523
+ if (parsedValue) {
1524
+ const goVal = parsedLiteralToGo(this.emitCtx, parsedValue)
1525
+ if (goVal !== null) {
1526
+ emitChildField(prop.name, goVal)
1527
+ break
1528
+ }
1529
+ }
1372
1530
  const resolvedValue = this.resolveDynamicPropValue(
1373
1531
  exprText,
1374
1532
  ir.metadata.signals,
@@ -1381,7 +1539,40 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1381
1539
  break
1382
1540
  }
1383
1541
  case 'jsx-children':
1384
- // Handled below via `child.childrenText` / `child.childrenHtml`.
1542
+ // The RESERVED children slot (`comp.children.length > 0 ? comp.children
1543
+ // : jsxChildrenPropNodes(...)`) is handled below via
1544
+ // `child.childrenText` / `child.childrenHtml` and must not be
1545
+ // re-emitted here. A JSX-valued prop under any OTHER name
1546
+ // (`header={<strong>Title</strong>}`, #2168 jsx-element-prop) is a
1547
+ // named slot — bake it the same way real children are baked
1548
+ // (`extractTextChildren` / `extractHtmlChildren`) and emit it as
1549
+ // its own struct field, mirroring the `Children` field's
1550
+ // text-vs-HTML branching just below.
1551
+ if (prop.name !== 'children') {
1552
+ const text = this.extractTextChildren(prop.value.children)
1553
+ if (text !== null) {
1554
+ emitChildField(prop.name, JSON.stringify(text))
1555
+ } else {
1556
+ const html = this.extractHtmlChildren(prop.value.children)
1557
+ if (html !== null) {
1558
+ this.state.usesHtmlTemplate = true
1559
+ emitChildField(prop.name, `template.HTML(${JSON.stringify(html)})`)
1560
+ } else {
1561
+ // The value's root needs the PARENT's runtime scope id
1562
+ // (`<strong>` hoisted from the call site inherits the
1563
+ // caller's `bf-s`, not a bake-time constant) — same
1564
+ // needsScope case `childrenScopedHtmlExpr` handles for
1565
+ // the reserved children slot. The returned string is
1566
+ // already a Go concatenation expression (`"..." +
1567
+ // scopeID + "..."`), not a literal to re-quote.
1568
+ const scopedHtml = this.extractScopedHtmlChildren(prop.value.children)
1569
+ if (scopedHtml !== null) {
1570
+ this.state.usesHtmlTemplate = true
1571
+ emitChildField(prop.name, `template.HTML(${scopedHtml})`)
1572
+ }
1573
+ }
1574
+ }
1575
+ }
1385
1576
  break
1386
1577
  }
1387
1578
  }
@@ -2371,6 +2562,66 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2371
2562
  }
2372
2563
  }
2373
2564
 
2565
+ // A bare passthrough of the CALLER's own prop — `text={props.label}`
2566
+ // forwarding into a nested child-component invocation (or the
2567
+ // destructured form, `text={label}`) — #2168 grandchild-composition: a
2568
+ // prop re-forwarded through a second component layer matched neither
2569
+ // pattern above (not a getter call, not a literal — that's `case
2570
+ // 'literal'` above this function entirely) and fell through to `null`,
2571
+ // silently OMITTING the field so Go's zero value applied instead of the
2572
+ // threaded value. Only an EXACT `props.<name>` / bare `<name>` — no `??`
2573
+ // /`||` suffix — qualifies; a fallback-bearing expression isn't a pure
2574
+ // passthrough and must keep falling through to `null` rather than
2575
+ // silently dropping the fallback.
2576
+ //
2577
+ // Guarded against a LOCAL const/helper shadowing a propsParam's name
2578
+ // (`const label = compute(); ... text={label}` inside a component whose
2579
+ // OWN prop is also called `label`) — that bare `label` means the local,
2580
+ // not the prop, and must keep falling through to `null` rather than
2581
+ // being misresolved to `in.Label`.
2582
+ const propsObjectName = this.state.propsObjectName
2583
+ // `$` is a valid JS/TS identifier character (Copilot review, #2198) —
2584
+ // without it, a passthrough like `text={$label}` or `text={props.$label}`
2585
+ // never matches either shape below and silently falls through to `null`.
2586
+ const identifierPattern = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/
2587
+ const bareIdentifier = identifierPattern.test(expr) ? expr : null
2588
+ // Plain prefix/suffix check rather than an `expr`-interpolated `RegExp`:
2589
+ // `propsObjectName` is an arbitrary identifier and could itself contain
2590
+ // regex metacharacters (e.g. `$props`), which would silently build a
2591
+ // malformed pattern instead of erroring.
2592
+ const barePropAccess =
2593
+ propsObjectName &&
2594
+ expr.startsWith(`${propsObjectName}.`) &&
2595
+ identifierPattern.test(expr.slice(propsObjectName.length + 1))
2596
+ ? expr.slice(propsObjectName.length + 1)
2597
+ : null
2598
+ const passthroughName = bareIdentifier ?? barePropAccess
2599
+ // A `localConstants` entry sharing this name is a genuine SHADOW only
2600
+ // when it's an independent local — NOT when it's the analyzer's own
2601
+ // body-level destructure alias (`const { label } = props`, expanded to
2602
+ // a `localConstants` entry named `label` valued exactly `props.label`,
2603
+ // #1138/analyzer.ts `collectConstant`). That alias IS the same prop
2604
+ // value, so a bare `label` reference is still a pure passthrough
2605
+ // (Copilot review, #2198 — the earlier blanket "any `localConstants`
2606
+ // match blocks it" check wrongly reintroduced the omitted-field bug for
2607
+ // exactly this common destructured-body pattern). A `?? default`-bearing
2608
+ // alias value does NOT match the exact-string check below, so it's
2609
+ // correctly still treated as a shadow (a fallback-bearing local isn't a
2610
+ // pure passthrough either — same reasoning as the outer `??`/`||` guard
2611
+ // above).
2612
+ const localConst = this.state.localConstants.find(c => c.name === passthroughName)
2613
+ const isPropsDestructureAlias =
2614
+ localConst !== undefined &&
2615
+ propsObjectName !== null &&
2616
+ localConst.value === `${propsObjectName}.${passthroughName}`
2617
+ const shadowedByLocal =
2618
+ passthroughName !== null &&
2619
+ ((localConst !== undefined && !isPropsDestructureAlias) ||
2620
+ this.state.localHelperNames.has(passthroughName))
2621
+ if (passthroughName && !shadowedByLocal && propsParams.some(p => p.name === passthroughName)) {
2622
+ return `in.${capitalizeFieldName(passthroughName)}`
2623
+ }
2624
+
2374
2625
  return null
2375
2626
  }
2376
2627
 
@@ -2579,7 +2830,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2579
2830
  }
2580
2831
 
2581
2832
  emitText(node: IRText): string {
2582
- return node.value
2833
+ // IRText carries the entity-DECODED value (Phase 1 decodes JSX
2834
+ // character references); re-escape for direct HTML emission.
2835
+ return escapeHtml(node.value)
2583
2836
  }
2584
2837
 
2585
2838
  emitExpression(node: IRExpression): string {
@@ -2621,7 +2874,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2621
2874
  renderElement(element: IRElement): string {
2622
2875
  const tag = element.tag
2623
2876
  const attrs = this.renderAttributes(element)
2624
- const children = this.renderChildren(element.children)
2877
+ const dangerousHtml = this.renderDangerousInnerHtml(element)
2878
+ const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children)
2625
2879
 
2626
2880
  let hydrationAttrs = ''
2627
2881
  if (element.needsScope) {
@@ -2656,6 +2910,28 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2656
2910
  return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
2657
2911
  }
2658
2912
 
2913
+ /**
2914
+ * `dangerouslySetInnerHTML={{ __html: '...' }}` (#2207) — see the Blade
2915
+ * adapter's identical helper for the full rationale. `null` means the
2916
+ * attribute is absent (caller falls through to normal `renderChildren`);
2917
+ * a non-`null` string (possibly `''`) replaces the children outright.
2918
+ */
2919
+ private renderDangerousInnerHtml(element: IRElement): string | null {
2920
+ const resolution = resolveDangerousInnerHtml(element)
2921
+ if (!resolution) return null
2922
+ if (resolution.kind === 'dynamic') {
2923
+ this.errors.push(dangerousInnerHtmlDiagnostic(resolution.expr, resolution.loc))
2924
+ return ''
2925
+ }
2926
+ const violation = dangerousInnerHtmlMetacharViolation(resolution.html, this.name)
2927
+ if (violation) {
2928
+ const attr = element.attrs.find(isDangerousInnerHtmlAttr)!
2929
+ this.errors.push(dangerousInnerHtmlDiagnostic(`{ __html: ${JSON.stringify(resolution.html)} }`, attr.loc, violation))
2930
+ return ''
2931
+ }
2932
+ return resolution.html
2933
+ }
2934
+
2659
2935
  renderExpression(expr: IRExpression): string {
2660
2936
  // @client directive: render a comment marker; ClientJS evaluates the
2661
2937
  // expression via updateClientMarker().
@@ -3026,6 +3302,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3026
3302
  object: ParsedExpr,
3027
3303
  property: string,
3028
3304
  _computed: boolean,
3305
+ optional: boolean,
3029
3306
  emit: (e: ParsedExpr) => string,
3030
3307
  ): string {
3031
3308
  // .length on a `.filter(...)` callback call → len (bf_filter ...)
@@ -3116,6 +3393,21 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3116
3393
 
3117
3394
  const obj = emit(object)
3118
3395
  if (property === 'length') return `len ${obj}`
3396
+ // A `?.`-written access (`user?.name`, #2168 optional-chaining-prop):
3397
+ // a plain `.Field` dot-chain panics evaluating a field on a nil
3398
+ // interface/pointer (`nil pointer evaluating interface {}.Name`), so
3399
+ // route through the runtime's existing nil-safe reflection-based
3400
+ // getter instead — `bf_get`/`getFieldValue` (bf.go), already used for
3401
+ // map-rooted context chains above, guards the nil case and returns Go
3402
+ // `nil` (which `or`/`bf.truthy?`-style fallbacks treat as falsy) —
3403
+ // unlike that map-rooted call site, this passes the Go-cased field
3404
+ // name (`goFieldNameForKey`), matching `getFieldValue`'s struct-branch
3405
+ // exact-match fast path. Only guards the single written `?.` hop, not
3406
+ // a JS-style whole-chain short-circuit — see the `ParsedExpr` `member`
3407
+ // variant's docstring for the multi-hop caveat.
3408
+ if (optional) {
3409
+ return `bf_get ${wrapIfMultiToken(obj)} ${JSON.stringify(goFieldNameForKey(property))}`
3410
+ }
3119
3411
  return `${obj}.${goFieldNameForKey(property)}`
3120
3412
  }
3121
3413
 
@@ -3158,7 +3450,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3158
3450
  case '<=':
3159
3451
  return `le ${wl} ${wr}`
3160
3452
  case '+':
3161
- return `bf_add ${wl} ${wr}`
3453
+ return this._emitPlus(left, right, wl, wr)
3162
3454
  case '-':
3163
3455
  return `bf_sub ${wl} ${wr}`
3164
3456
  case '*':
@@ -3172,6 +3464,45 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3172
3464
  }
3173
3465
  }
3174
3466
 
3467
+ /**
3468
+ * JS `+` with a string-typed operand is CONCATENATION, not addition — Go's
3469
+ * `bf_add` coerces both sides through `toFloat64` (#2168
3470
+ * string-concat-plus: `'Hello, ' + name` rendered "0", since a string
3471
+ * operand's `toFloat64` is 0). `html/template` has no infix `+` at all, so
3472
+ * both directions are a runtime call; route through `bf_concat_str` when
3473
+ * either operand is string-typed.
3474
+ *
3475
+ * Shared by all THREE `case '+':` sites in this file (`binary()` here, the
3476
+ * filter-predicate emitter's own `binary` case, and the condition-
3477
+ * expression emitter's own `binary` case) — each recurses over its own
3478
+ * `ParsedExpr` tree with its own rendered-operand strings, but the
3479
+ * string-vs-numeric decision itself must stay identical everywhere so the
3480
+ * three never drift out of sync (a Copilot review comment on #2197 flagged
3481
+ * exactly this risk when they were three independent inline checks).
3482
+ * Callers pass their own `leftExpr`/`rightExpr` (the raw `ParsedExpr` nodes
3483
+ * `isStringConcatBinary` inspects) and `leftRendered`/`rightRendered` (the
3484
+ * already-emitted, already-wrapped/parenthesised operand text for THIS
3485
+ * call site's Go form).
3486
+ */
3487
+ private _emitPlus(
3488
+ leftExpr: ParsedExpr,
3489
+ rightExpr: ParsedExpr,
3490
+ leftRendered: string,
3491
+ rightRendered: string,
3492
+ ): string {
3493
+ if (isStringConcatBinary('+', leftExpr, rightExpr, n => this._isStringValueName(n))) {
3494
+ return `bf_concat_str ${leftRendered} ${rightRendered}`
3495
+ }
3496
+ return `bf_add ${leftRendered} ${rightRendered}`
3497
+ }
3498
+
3499
+ /** Whether `name` (a signal getter or prop) holds a string value — drives
3500
+ * `isStringConcatBinary`'s string-vs-numeric `+` decision (#2168
3501
+ * string-concat-plus). */
3502
+ private _isStringValueName(name: string): boolean {
3503
+ return this.state.stringValueNames.has(name)
3504
+ }
3505
+
3175
3506
  unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
3176
3507
  const arg = emit(argument)
3177
3508
  if (op === '!') return `not ${arg}`
@@ -3509,6 +3840,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3509
3840
  const recv = emit(object)
3510
3841
  return `bf_trim ${wrapIfMultiToken(recv)}`
3511
3842
  }
3843
+ case 'trimStart':
3844
+ case 'trimEnd': {
3845
+ // `.trimStart()` / `.trimEnd()` — the one-sided siblings of `.trim()`
3846
+ // (#2183 follow-up). Dedicated `bf_trim_start` / `bf_trim_end`
3847
+ // helpers, not `bf_trim` with a flag.
3848
+ const fn = method === 'trimStart' ? 'bf_trim_start' : 'bf_trim_end'
3849
+ const recv = emit(object)
3850
+ return `${fn} ${wrapIfMultiToken(recv)}`
3851
+ }
3512
3852
  case 'toFixed': {
3513
3853
  // `.toFixed(digits?)` → `bf_to_fixed` (`fmt.Sprintf("%.*f", …)`); default
3514
3854
  // 0 digits when the argument is omitted.
@@ -3555,6 +3895,16 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3555
3895
  const newS = emit(args[1])
3556
3896
  return `bf_replace ${wrapIfMultiToken(recv)} ${wrapIfMultiToken(oldS)} ${wrapIfMultiToken(newS)}`
3557
3897
  }
3898
+ case 'replaceAll': {
3899
+ // `.replaceAll(old, new)` — string-pattern form, EVERY occurrence, via
3900
+ // `bf_replace_all` (`strings.ReplaceAll`). A dedicated helper, not
3901
+ // `bf_replace` with a different n — the regex-pattern form is refused
3902
+ // upstream at the parser, same as `.replace`.
3903
+ const recv = emit(object)
3904
+ const oldS = emit(args[0])
3905
+ const newS = emit(args[1])
3906
+ return `bf_replace_all ${wrapIfMultiToken(recv)} ${wrapIfMultiToken(oldS)} ${wrapIfMultiToken(newS)}`
3907
+ }
3558
3908
  case 'repeat': {
3559
3909
  // `.repeat(n)` — string repeated `n` times. `bf_repeat` clamps a negative
3560
3910
  // count to "" instead of letting `strings.Repeat` panic. No argument is
@@ -3891,10 +4241,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3891
4241
 
3892
4242
  /**
3893
4243
  * Render a predicate for use in Go template `{{if}}` conditions, substituting
3894
- * the loop parameter (e.g. `t` in `t.done`) with dot notation.
4244
+ * the loop parameter (e.g. `t` in `t.done`) with dot notation. `datumField`
4245
+ * (#2228) is the wrapper struct's datum-carrying field name (e.g. `"Todo"`)
4246
+ * for a loop whose body is a child component — see `wrapperDatumField` — so
4247
+ * `t.done` qualifies through it (`.Todo.Done`) instead of the bare `.Done`
4248
+ * `html/template` can't resolve on the wrapper Props struct. `undefined` for
4249
+ * a non-wrapper (plain-element-body) loop, where `.` already IS the datum.
3895
4250
  */
3896
- private renderPredicateCondition(pred: ParsedExpr, param: string): string {
3897
- return this.renderFilterExpr(pred, param)
4251
+ private renderPredicateCondition(pred: ParsedExpr, param: string, datumField?: string | null): string {
4252
+ return this.renderFilterExpr(pred, param, new Map(), datumField ?? undefined)
3898
4253
  }
3899
4254
 
3900
4255
  /** Whether an expression needs parentheses when used in and/or. */
@@ -3928,12 +4283,19 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3928
4283
  * Render a filter predicate expression (`t => !t.done`, or a block body
3929
4284
  * normalized to one — #2040). `localVarMap` is a vestigial empty default kept
3930
4285
  * on the recursion; block-body locals are now inlined upstream, so no caller
3931
- * populates it.
4286
+ * populates it. `datumField` (#2228) qualifies a bare `param` reference (and
4287
+ * `param.xxx` member/call access) through the wrapper struct's
4288
+ * datum-carrying field — see `wrapperDatumField` — for a loop whose `.` is a
4289
+ * child-component wrapper Props struct rather than the raw datum itself.
4290
+ * `undefined` for every other caller (non-loop `.filter()`/`.find()`/etc.,
4291
+ * or a plain-element-body loop), which keeps emitting the bare `.`/`.Field`
4292
+ * this method always has.
3932
4293
  */
3933
4294
  private renderFilterExpr(
3934
4295
  expr: ParsedExpr,
3935
4296
  param: string,
3936
- localVarMap: Map<string, string> = new Map()
4297
+ localVarMap: Map<string, string> = new Map(),
4298
+ datumField?: string
3937
4299
  ): string {
3938
4300
  // Top-of-recursion: clear the unsupported sentinel so a previous filter
3939
4301
  // expression's failure doesn't poison this one. Parents (`member` /
@@ -3943,7 +4305,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3943
4305
  if (this.filterExprDepth === 0) this.filterExprUnsupported = false
3944
4306
  this.filterExprDepth++
3945
4307
  try {
3946
- return this.renderFilterExprNode(expr, param, localVarMap)
4308
+ return this.renderFilterExprNode(expr, param, localVarMap, datumField)
3947
4309
  } finally {
3948
4310
  this.filterExprDepth--
3949
4311
  }
@@ -3952,12 +4314,22 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3952
4314
  private renderFilterExprNode(
3953
4315
  expr: ParsedExpr,
3954
4316
  param: string,
3955
- localVarMap: Map<string, string>
4317
+ localVarMap: Map<string, string>,
4318
+ datumField?: string
3956
4319
  ): string {
4320
+ // #2228: `paramPrefix` prepends the wrapper's datum-carrying field to a
4321
+ // loop-param access (`.Todo` under a wrapper, `''` otherwise). Two derived
4322
+ // forms because Go template spells "the dot itself" as `.` but "field on
4323
+ // the dot" as `.Field` — a naive shared `'.'` prefix would emit `..Done`
4324
+ // for the non-wrapper member case:
4325
+ // bare `t` → `paramDot` (`.Todo` / `.`)
4326
+ // `t.done` → `${paramPrefix}.Done` (`.Todo.Done` / `.Done`)
4327
+ const paramPrefix = datumField ? `.${datumField}` : ''
4328
+ const paramDot = paramPrefix || '.'
3957
4329
  switch (expr.kind) {
3958
4330
  case 'identifier': {
3959
4331
  if (expr.name === param) {
3960
- return '.'
4332
+ return paramDot
3961
4333
  }
3962
4334
  // A local variable mapped to a signal.
3963
4335
  const signal = localVarMap.get(expr.name)
@@ -3977,9 +4349,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3977
4349
  return String(expr.value)
3978
4350
 
3979
4351
  case 'member': {
3980
- // t.done -> .Done
4352
+ // t.done -> .Done (or .Todo.Done under a wrapper struct, #2228)
3981
4353
  if (expr.object.kind === 'identifier' && expr.object.name === param) {
3982
- return `.${capitalizeFieldName(expr.property)}`
4354
+ return `${paramPrefix}.${capitalizeFieldName(expr.property)}`
3983
4355
  }
3984
4356
  // `.length` on a higher-order filter result (e.g.
3985
4357
  // `x.tags.filter(t => t.active).length > 0`). Reuse
@@ -3992,21 +4364,21 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3992
4364
  const innerHO = this.higherOrderShapeOf(expr.object)
3993
4365
  if (innerHO && innerHO.method === 'filter') {
3994
4366
  const lenExpr = this.renderFilterLengthExpr(innerHO, e =>
3995
- this.renderFilterExpr(e, param, localVarMap),
4367
+ this.renderFilterExpr(e, param, localVarMap, datumField),
3996
4368
  )
3997
4369
  if (lenExpr) return `(${lenExpr})`
3998
4370
  }
3999
4371
  }
4000
4372
  // Nested member access or local var.prop.
4001
- const obj = this.renderFilterExpr(expr.object, param, localVarMap)
4373
+ const obj = this.renderFilterExpr(expr.object, param, localVarMap, datumField)
4002
4374
  if (this.filterExprUnsupported) return 'false'
4003
4375
  return `${obj}.${capitalizeFieldName(expr.property)}`
4004
4376
  }
4005
4377
 
4006
4378
  case 'call': {
4007
- // `t.isDone()` -> `.IsDone`
4379
+ // `t.isDone()` -> `.IsDone` (or `.Todo.IsDone` under a wrapper, #2228)
4008
4380
  if (expr.callee.kind === 'member' && expr.callee.object.kind === 'identifier' && expr.callee.object.name === param) {
4009
- return `.${capitalizeFieldName(expr.callee.property)}`
4381
+ return `${paramPrefix}.${capitalizeFieldName(expr.callee.property)}`
4010
4382
  }
4011
4383
  // Signal calls: `filter()` -> `$.Filter`
4012
4384
  if (expr.callee.kind === 'identifier' && expr.args.length === 0) {
@@ -4022,13 +4394,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4022
4394
  if (asCallbackMethodCall(expr) !== null) {
4023
4395
  return this.refuseFilterExprNode(expr)
4024
4396
  }
4025
- const result = this.renderFilterExpr(expr.callee, param, localVarMap)
4397
+ const result = this.renderFilterExpr(expr.callee, param, localVarMap, datumField)
4026
4398
  if (this.filterExprUnsupported) return 'false'
4027
4399
  return result
4028
4400
  }
4029
4401
 
4030
4402
  case 'unary': {
4031
- const arg = this.renderFilterExpr(expr.argument, param, localVarMap)
4403
+ const arg = this.renderFilterExpr(expr.argument, param, localVarMap, datumField)
4032
4404
  if (this.filterExprUnsupported) return 'false'
4033
4405
  if (expr.op === '!') {
4034
4406
  // Wrap in parens if arg is a function call (eq, ne, gt, …).
@@ -4042,9 +4414,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4042
4414
  }
4043
4415
 
4044
4416
  case 'binary': {
4045
- const left = this.renderFilterExpr(expr.left, param, localVarMap)
4417
+ const left = this.renderFilterExpr(expr.left, param, localVarMap, datumField)
4046
4418
  if (this.filterExprUnsupported) return 'false'
4047
- const right = this.renderFilterExpr(expr.right, param, localVarMap)
4419
+ const right = this.renderFilterExpr(expr.right, param, localVarMap, datumField)
4048
4420
  if (this.filterExprUnsupported) return 'false'
4049
4421
 
4050
4422
  switch (expr.op) {
@@ -4063,7 +4435,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4063
4435
  case '<=':
4064
4436
  return `le ${left} ${right}`
4065
4437
  case '+':
4066
- return `bf_add ${left} ${right}`
4438
+ return this._emitPlus(expr.left, expr.right, left, right)
4067
4439
  case '-':
4068
4440
  return `bf_sub ${left} ${right}`
4069
4441
  case '*':
@@ -4076,9 +4448,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4076
4448
  }
4077
4449
 
4078
4450
  case 'logical': {
4079
- const left = this.renderFilterExpr(expr.left, param, localVarMap)
4451
+ const left = this.renderFilterExpr(expr.left, param, localVarMap, datumField)
4080
4452
  if (this.filterExprUnsupported) return 'false'
4081
- const right = this.renderFilterExpr(expr.right, param, localVarMap)
4453
+ const right = this.renderFilterExpr(expr.right, param, localVarMap, datumField)
4082
4454
  if (this.filterExprUnsupported) return 'false'
4083
4455
  if (expr.op === '&&') {
4084
4456
  return `and (${left}) (${right})`
@@ -4199,6 +4571,36 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4199
4571
  return '""'
4200
4572
  }
4201
4573
 
4574
+ // #2224: inside a `renderUnrolledStaticElementLoop` pass, there is no
4575
+ // real `{{range}}` establishing a per-item dot context — every
4576
+ // expression in the body must instead resolve directly against the
4577
+ // active item via `evaluateStaticLiteral` and lower to a literal Go
4578
+ // value. Highest priority (even over the static-record-index / inlined-
4579
+ // const early returns below): those string-keyed checks were never
4580
+ // designed to reason about a loop item and could otherwise misfire on
4581
+ // text that merely happens to match their shape. `analyzeBakeable-
4582
+ // StaticElementLoop` has already verified every expression in this body
4583
+ // resolves for every item, so the `staticLoopBakeFailed` branch is a
4584
+ // defensive invariant, not a real code path — if it ever fires, the two
4585
+ // passes disagreed and the safest move is a sentinel, not a `.Field`
4586
+ // reference with no range context behind it.
4587
+ if (this.staticLoopItemStack.length > 0) {
4588
+ const top = this.staticLoopItemStack[this.staticLoopItemStack.length - 1]
4589
+ const parsedForBake = preParsed ?? parseExpression(trimmed)
4590
+ const resolved = evaluateStaticLiteral(parsedForBake, new Map([[top.param, top.item]]))
4591
+ const literal = resolved !== null ? scalarToGoLiteral(resolved.value) : null
4592
+ if (literal !== null) {
4593
+ // Deliberately leave `out.parsed` unset — a `template-literal`-kind
4594
+ // source (`` `Hi ${item.label}` ``) must NOT be treated as
4595
+ // already-fragment text by `renderExpression`'s `isTemplateFragment`
4596
+ // check (it would skip the `{{...}}` wrap and print the Go literal's
4597
+ // quote characters raw into the HTML).
4598
+ return literal
4599
+ }
4600
+ this.staticLoopBakeFailed = true
4601
+ return '""'
4602
+ }
4603
+
4202
4604
  // `IDENT['key']` over a module object-literal const with a STRING-LITERAL key
4203
4605
  // is a fully static lookup — resolve it at compile time. The generic member
4204
4606
  // lowering below would otherwise capitalize the bracket access into a field
@@ -4214,7 +4616,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4214
4616
  // the generic lowering would reference a nonexistent `.TotalPages` field).
4215
4617
  // Only pure numeric / single-quoted-string initializers qualify; anything
4216
4618
  // else may be runtime-dependent.
4217
- if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) {
4619
+ //
4620
+ // #2236: this is a string-keyed fast path over `jsExpr` reached directly
4621
+ // by call sites like attribute emission (`key={count}` → `data-key`) that
4622
+ // never go through `identifier()`'s loop-shadow guards below — so it must
4623
+ // carry its OWN guard. When `.map((count) => ...)` shadows the outer
4624
+ // `const count = 7`, the occurrence inside the loop body must resolve to
4625
+ // the range value (via the normal parse-and-lower fallthrough), not the
4626
+ // outer literal.
4627
+ if (!this.isLoopShadowedName(trimmed) && /^[A-Za-z_$][\w$]*$/.test(trimmed)) {
4218
4628
  const litConst = (this.state.localConstants ?? []).find(c => c.name === trimmed)
4219
4629
  if (litConst?.value !== undefined) {
4220
4630
  const v = litConst.value.trim()
@@ -4285,6 +4695,26 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4285
4695
  return this.renderParsedExpr(parsed)
4286
4696
  }
4287
4697
 
4698
+ /**
4699
+ * Whether `name` at the CURRENT emission position is bound by an enclosing
4700
+ * loop callback — its item param (`loopParamStack` top), an outer loop's
4701
+ * range variable, a hoisted loop var, or a destructured binding name
4702
+ * (`loopBindingStack`, which is the ONLY place destructured callbacks
4703
+ * record their names; they push `''` onto `loopParamStack`). Shared by the
4704
+ * string-keyed fast paths (#2236, #2242 Copilot review) that resolve raw
4705
+ * `jsExpr` text before `identifier()`'s own guards can see it. Mirrors the
4706
+ * checks in `resolveModuleStringConst` / `resolveModuleNumericConst`.
4707
+ */
4708
+ private isLoopShadowedName(name: string): boolean {
4709
+ return (
4710
+ (this.loopParamStack.length > 0 &&
4711
+ this.loopParamStack[this.loopParamStack.length - 1] === name) ||
4712
+ this.loopVarRefCount.has(name) ||
4713
+ this.isOuterLoopParam(name) ||
4714
+ this.loopBindingStack.some(bindings => bindings.has(name))
4715
+ )
4716
+ }
4717
+
4288
4718
  /**
4289
4719
  * Resolve `IDENT['key']` / `IDENT["key"]` where `IDENT` is a module-scope
4290
4720
  * object-literal const and the key is a string literal — a compile-time-static
@@ -4302,6 +4732,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4302
4732
  // ordinary props/locals never match.
4303
4733
  /^([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/.exec(jsExpr)
4304
4734
  if (!m) return null
4735
+ // The base name may be an enclosing loop callback's own (shadowing)
4736
+ // param (`rows.map((cfg) => cfg.x)` under a module `const cfg = {...}`)
4737
+ // — the record-member sibling of the #2236 bare-identifier gap, found
4738
+ // by the loop-param-shadows-record-const fixture. Fall through to the
4739
+ // generic lowering, which resolves the member through the loop binding.
4740
+ if (this.isLoopShadowedName(m[1])) return null
4305
4741
  const key = m[2] ?? m[3]
4306
4742
  const constInfo = (this.state.localConstants ?? []).find(
4307
4743
  c => c.name === m[1] && c.isModule,
@@ -4627,7 +5063,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4627
5063
  case '<=':
4628
5064
  result = `le ${left} ${right}`; break
4629
5065
  case '+':
4630
- result = `bf_add ${left} ${right}`; break
5066
+ result = this._emitPlus(expr.left, expr.right, left, right); break
4631
5067
  case '-':
4632
5068
  result = `bf_sub ${left} ${right}`; break
4633
5069
  case '*':
@@ -4764,6 +5200,68 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4764
5200
  return undefined
4765
5201
  }
4766
5202
 
5203
+ /**
5204
+ * #2228: the Go field name on the wrapper Props struct that carries the
5205
+ * loop datum, for a loop whose body IS a child component (`loop.childComponent`,
5206
+ * e.g. `.TodoItems` ranging over `TodoItemProps`). `{{range}}`'s dot context
5207
+ * for such a loop is the WHOLE wrapper struct (`TodoItemProps{ Todo Todo,
5208
+ * OnToggle ..., ... }`), not the raw per-item datum — so a filter predicate
5209
+ * (`t => !t.done`) referencing the loop param can't lower `t.done` straight
5210
+ * to `.Done` (no such top-level field; `html/template` fails at execute time
5211
+ * with `can't evaluate field Done in type TodoItemProps`). The datum lives
5212
+ * nested under whichever child prop was PASSED the loop param verbatim
5213
+ * (`todo={todo}` → field `Todo`, from `capitalizeFieldName('todo')` — the
5214
+ * SAME derivation `generateInputStruct`/`generatePropsStruct` use for every
5215
+ * other prop-to-field mapping, so this never invents a field name the
5216
+ * generated struct doesn't actually have). Returns `null` for a non-wrapper
5217
+ * loop, or when no prop's value is a bare reference to the loop param (the
5218
+ * datum isn't forwarded at all — nothing to qualify through).
5219
+ */
5220
+ private wrapperDatumField(loop: {
5221
+ childComponent?: IRLoopChildComponent
5222
+ param: string
5223
+ }): string | null {
5224
+ if (!loop.childComponent) return null
5225
+ for (const prop of loop.childComponent.props) {
5226
+ if (prop.isEventHandler) continue
5227
+ if (prop.value.kind !== 'expression') continue
5228
+ const parsed = prop.value.parsed
5229
+ const isBareParamRef = parsed
5230
+ ? parsed.kind === 'identifier' && parsed.name === loop.param
5231
+ : prop.value.expr.trim() === loop.param
5232
+ if (isBareParamRef) return capitalizeFieldName(prop.name)
5233
+ }
5234
+ return null
5235
+ }
5236
+
5237
+ /**
5238
+ * Memoized bakeability check for a static-array loop whose body is a
5239
+ * single child component (#2208) — see `analyzeBakeableStaticChildLoop`'s
5240
+ * docstring. Accepts either an `IRLoop` (the `renderLoop` gate) or a
5241
+ * `NestedComponentInfo` (`generateNewPropsFunction`/Input-struct sites) —
5242
+ * both carry the same `loopArrayParsed`/`loopParam`/`loopKey`/props data,
5243
+ * just under different field names, so this normalizes to one shape and
5244
+ * caches by marker id so all three call sites agree.
5245
+ */
5246
+ private getBakedStaticChildLoop(
5247
+ markerId: string,
5248
+ childComponent: { props: IRLoopChildComponent['props'] },
5249
+ arrayParsed: ParsedExpr | undefined,
5250
+ param: string | undefined,
5251
+ key: string | undefined,
5252
+ ): BakedStaticChildLoop | null {
5253
+ if (this.bakedStaticChildLoopCache.has(markerId)) {
5254
+ return this.bakedStaticChildLoopCache.get(markerId) ?? null
5255
+ }
5256
+ const result = analyzeBakeableStaticChildLoop(
5257
+ { props: childComponent.props, loopArrayParsed: arrayParsed, loopParam: param, loopKey: key },
5258
+ this.state.localConstants,
5259
+ { isNameShadowed: name => this.state.staticLoopSourceBoundNames.has(name) },
5260
+ )
5261
+ this.bakedStaticChildLoopCache.set(markerId, result)
5262
+ return result
5263
+ }
5264
+
4767
5265
  renderLoop(loop: IRLoop): string {
4768
5266
  // clientOnly loops: emit SSR markers so the client can insert DOM nodes. The
4769
5267
  // marker id disambiguates sibling `.map()` calls under the same parent.
@@ -4826,8 +5324,44 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4826
5324
  // Phase A/B) no longer refuses `static-array-from-props`'s `([emoji,
4827
5325
  // users]) => ...` param first. Cross-adapter policy: Jinja / ERB apply the
4828
5326
  // same narrow check in their own `renderLoop` (see `jinja-adapter.ts`).
5327
+ // #2208: a static-array loop whose body is a single child component
5328
+ // (`loop.childComponent`) with a plain-value prop set can be BAKED —
5329
+ // every per-item prop and data-key resolves to a compile-time-known Go
5330
+ // literal (see `analyzeBakeableStaticChildLoop`), so the constructor
5331
+ // (`generateNewPropsFunction`'s `staticWithoutBody` path) can emit the
5332
+ // child instances directly instead of requiring the loop array bind as
5333
+ // a template variable at all. Memoized by marker id so this gate and
5334
+ // the constructor's later baking agree. A plain-ELEMENT body (no
5335
+ // `childComponent`) is NOT handled by baking and keeps refusing below —
5336
+ // see the go-only follow-up issue for that narrower gap.
5337
+ const bakedChildLoop = loop.childComponent
5338
+ ? this.getBakedStaticChildLoop(loop.markerId, loop.childComponent, loop.arrayParsed, loop.param, loop.key ?? undefined)
5339
+ : null
5340
+
5341
+ // #2224 shape 1: a static-array loop whose body is a plain ELEMENT tree
5342
+ // (no child component) has no `.{Name}s`-shaped template target for
5343
+ // #2208's baking to feed — there's nothing for `{{range}}` to iterate at
5344
+ // all once the array itself can't bind as a template variable. Rather
5345
+ // than synthesizing a Go struct type for the item shape (see the #2224
5346
+ // issue body), unroll the body once per item at template-generation
5347
+ // time instead, substituting each item's statically-known field values
5348
+ // directly — see `analyzeBakeableStaticElementLoop`'s docstring for the
5349
+ // exact (conservative) acceptance gate. `null` here means the shape
5350
+ // isn't (yet) bakeable this way; the existing gates below keep firing
5351
+ // exactly as before.
5352
+ const bakedElementLoop = loop.childComponent
5353
+ ? null
5354
+ : analyzeBakeableStaticElementLoop(
5355
+ loop,
5356
+ this.state.localConstants,
5357
+ { isNameShadowed: name => this.state.staticLoopSourceBoundNames.has(name) },
5358
+ )
5359
+ if (bakedElementLoop) {
5360
+ return this.renderUnrolledStaticElementLoop(loop, bakedElementLoop.items)
5361
+ }
5362
+
4829
5363
  const arrayName = loop.array.trim()
4830
- if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
5364
+ if (bakedChildLoop === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
4831
5365
  const arrayConst = this.state.localConstants.find(c => c.name === arrayName)
4832
5366
  if (arrayConst && !arrayConst.isModule && arrayConst.parsed && !this.isStringExpr(arrayConst.parsed, new Set())) {
4833
5367
  this.state.errors.push({
@@ -4843,7 +5377,22 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4843
5377
  }
4844
5378
  }
4845
5379
 
4846
- let goArray = this.convertExpressionToGo(loop.array)
5380
+ // #2224 shape 2: when the body IS a child component, `goArray` gets
5381
+ // unconditionally overwritten to `.${componentName}s` below regardless
5382
+ // of what this call returns — so for an INLINE array-literal source
5383
+ // (`[{ label: 'Alpha' }, ...].map(item => <ListItem .../>)`), calling
5384
+ // `convertExpressionToGo` on the raw literal text here is pure waste at
5385
+ // best. At worst it's actively harmful: an array-literal-of-objects
5386
+ // fails the shared `isSupported` gate (`object-literal` is refused
5387
+ // standalone — expression-parser.ts), so this call would push a BF101
5388
+ // as a side effect even though `bakedChildLoop` above (via
5389
+ // `resolveStaticLoopSource`, which evaluates the literal directly
5390
+ // instead of going through `isSupported`) already resolved the SAME
5391
+ // loop just fine. Skip the call entirely for a child-component body —
5392
+ // baked or not, dynamic `.map()` over a real prop/signal array included
5393
+ // — so no spurious diagnostic is ever recorded for a value nothing ends
5394
+ // up consuming.
5395
+ let goArray = loop.childComponent ? '' : this.convertExpressionToGo(loop.array)
4847
5396
  const param = loop.param
4848
5397
  let index = loop.index || '_'
4849
5398
 
@@ -4920,7 +5469,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4920
5469
  this.scalarLiteralLoopGoType(loop.arrayParsed, loop.itemType) !== null,
4921
5470
  )
4922
5471
  this.loopWrapperStack.push(!!loop.childComponent)
5472
+ this.loopKeyDepthStack.push(loop.depth)
4923
5473
  const children = this.renderChildren(loop.children)
5474
+ this.loopKeyDepthStack.pop()
4924
5475
  this.loopWrapperStack.pop()
4925
5476
  this.loopScalarItemStack.pop()
4926
5477
  // Build the per-item anchor marker while the loop param is still on the
@@ -4962,9 +5513,19 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4962
5513
  let filterCond: string
4963
5514
 
4964
5515
  if (loop.filterPredicate.predicate) {
5516
+ // #2228: for a wrapper-slice loop (`.TodoItems` ranging over
5517
+ // TodoItemProps), `.` in the predicate is the WHOLE wrapper struct —
5518
+ // qualify `loop.filterPredicate.param` references through the
5519
+ // datum-carrying field (`.Todo.Done`, not `.Done`) so the emitted
5520
+ // `{{if}}` only ever dereferences fields the wrapper struct actually
5521
+ // has. `wrapperDatumField` returns `null` for a plain-element-body
5522
+ // loop, where `.` already IS the datum — `renderPredicateCondition`
5523
+ // then keeps emitting the bare `.`/`.Field` form unchanged.
5524
+ const datumField = this.wrapperDatumField(loop)
4965
5525
  filterCond = this.renderPredicateCondition(
4966
5526
  loop.filterPredicate.predicate,
4967
- loop.filterPredicate.param
5527
+ loop.filterPredicate.param,
5528
+ datumField
4968
5529
  )
4969
5530
  } else {
4970
5531
  filterCond = 'true'
@@ -4976,6 +5537,61 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4976
5537
  return `{{bfComment "loop:${loop.markerId}"}}{{range $${rangeIndex}, $${rangeValue} := ${goArray}}}${itemMarker}${children}{{end}}{{bfComment "/loop:${loop.markerId}"}}`
4977
5538
  }
4978
5539
 
5540
+ /**
5541
+ * #2224 shape 1: render a static-array, plain-element-body loop's per-item
5542
+ * markup ONCE PER ITEM at template-generation time instead of a Go
5543
+ * `{{range}}` — `analyzeBakeableStaticElementLoop` has already verified
5544
+ * every expression in `loop.children` resolves against each item. Keeps
5545
+ * the SAME `<!--bf-loop:id--> ... <!--/bf-loop:id-->` marker pair a
5546
+ * dynamic loop emits (so the CSR-side static `forEach` wiring, compiled by
5547
+ * the separate `ir-to-client-js.ts` pass and untouched by this change,
5548
+ * still finds the same DOM range), and pushes `loop.param` /
5549
+ * `loop.depth` onto the SAME stacks `renderLoop`'s `{{range}}` path uses,
5550
+ * so `data-key`/`data-key-N` attribute-name derivation
5551
+ * (`renderAttributes`) is unaffected by which path rendered the loop. No
5552
+ * `itemMarker` (`loopItemMarker`) call: the analysis gate already refused
5553
+ * `bodyIsMultiRoot` / `bodyIsItemConditional` bodies, so it would always
5554
+ * return `''` here anyway.
5555
+ */
5556
+ private renderUnrolledStaticElementLoop(loop: IRLoop, items: readonly unknown[]): string {
5557
+ this.inLoop = true
5558
+ this.loopWrapperStack.push(false)
5559
+ this.loopKeyDepthStack.push(loop.depth)
5560
+ this.loopScalarItemStack.push(this.scalarLiteralLoopGoType(loop.arrayParsed, loop.itemType) !== null)
5561
+ this.loopParamStack.push(loop.param)
5562
+
5563
+ let body = ''
5564
+ for (const item of items) {
5565
+ this.staticLoopItemStack.push({ param: loop.param, item })
5566
+ body += this.renderChildren(loop.children)
5567
+ this.staticLoopItemStack.pop()
5568
+ if (this.staticLoopBakeFailed) {
5569
+ // Invariant violation (see `staticLoopBakeFailed`'s docstring): the
5570
+ // gate and this render pass disagreed. Surface it loudly instead of
5571
+ // shipping a template with `""` sentinels silently spliced in.
5572
+ this.staticLoopBakeFailed = false
5573
+ this.state.errors.push({
5574
+ code: 'BF101',
5575
+ severity: 'error',
5576
+ message: `Loop array \`${loop.array.trim()}\` could not be fully unrolled — an expression in the loop body did not resolve against every item as the compile-time analysis expected.`,
5577
+ loc: loop.loc ?? this.makeLoc(),
5578
+ suggestion: {
5579
+ message: 'This indicates a bug in the Go adapter\'s static-loop unrolling (#2224) rather than an unsupported source pattern; please file a bug with a reproduction.',
5580
+ },
5581
+ })
5582
+ break
5583
+ }
5584
+ }
5585
+
5586
+ this.loopParamStack.pop()
5587
+ this.loopScalarItemStack.pop()
5588
+ this.loopKeyDepthStack.pop()
5589
+ this.loopWrapperStack.pop()
5590
+ this.inLoop = false
5591
+
5592
+ return `{{bfComment "loop:${loop.markerId}"}}${body}{{bfComment "/loop:${loop.markerId}"}}`
5593
+ }
5594
+
4979
5595
  /**
4980
5596
  * Per-item `<!--bf-loop-i-->` / `<!--bf-loop-i:KEY-->` start marker emitted
4981
5597
  * inside a `{{range}}` body. Multi-root Fragment items get the bare anchor;
@@ -5181,7 +5797,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5181
5797
  * doesn't share a contract with the intrinsic-attribute one.
5182
5798
  */
5183
5799
  private readonly elementAttrEmitter: AttrValueEmitter = {
5184
- emitLiteral: (value, name) => `${name}="${value.value}"`,
5800
+ emitLiteral: (value, name) => `${name}="${escapeHtml(value.value)}"`,
5185
5801
  emitExpression: (value, name) => {
5186
5802
  // `style={{ … }}` object literal → a CSS string with dynamic values
5187
5803
  // interpolated, instead of refusing the bare object with BF101.
@@ -5362,13 +5978,24 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5362
5978
  // predicate (no BF101 / BF102). This keeps the BF102 remediation ("defer
5363
5979
  // it with /* @client */") accurate for attribute-only state.
5364
5980
  if (attr.clientOnly) continue
5981
+ // `dangerouslySetInnerHTML` never renders as an HTML attribute — it's
5982
+ // handled by `renderDangerousInnerHtml` instead, which replaces the
5983
+ // element's children. Skip it here so its `{ __html: ... }` object
5984
+ // literal never reaches the generic object-literal BF101 refusal
5985
+ // (which would double-report alongside the purpose-built one).
5986
+ if (isDangerousInnerHtmlAttr(attr)) continue
5365
5987
  // Rewrite JSX special-prop names to their HTML-attribute counterparts. The
5366
5988
  // Go template adapter has no JSX runtime to strip `key` / emit `data-key`,
5367
5989
  // so the rewrite happens at attribute-emit time. Mirror of the `key`
5368
- // branch in `ir-to-client-js/html-template.ts`.
5990
+ // branch in `ir-to-client-js/html-template.ts`. The depth-suffix (plain
5991
+ // `data-key` at the outermost loop, `data-key-N` N levels deep) comes
5992
+ // from `IRLoop.depth` via `loopKeyDepthStack`, not re-derived here.
5369
5993
  let attrName: string
5370
5994
  if (attr.name === 'className') attrName = 'class'
5371
- else if (attr.name === 'key') attrName = 'data-key'
5995
+ else if (attr.name === 'key') {
5996
+ const depth = this.loopKeyDepthStack.at(-1) ?? 0
5997
+ attrName = depth > 0 ? `data-key-${depth}` : 'data-key'
5998
+ }
5372
5999
  else attrName = attr.name
5373
6000
  const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
5374
6001
  if (lowered) parts.push(lowered)