@barefootjs/go-template 0.18.5 → 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.
@@ -67,6 +67,13 @@ import {
67
67
  envSignalReaderFor,
68
68
  computeSsrSeedPlan,
69
69
  isStringConcatBinary,
70
+ isDangerousInnerHtmlAttr,
71
+ resolveDangerousInnerHtml,
72
+ dangerousInnerHtmlMetacharViolation,
73
+ dangerousInnerHtmlDiagnostic,
74
+ resolveStaticLoopSource,
75
+ collectLoopBoundNames,
76
+ evaluateStaticLiteral,
70
77
  } from '@barefootjs/jsx'
71
78
  import { findInterpolationEnd } from '@barefootjs/jsx/scanner'
72
79
  import { BF_REGION, escapeHtml } from '@barefootjs/shared'
@@ -111,6 +118,8 @@ import { collectRootScopeNodes } from "./lib/ir-scope.ts"
111
118
  import { GO_TEMPLATE_PRIMITIVES } from "./lib/constants.ts"
112
119
  import { CompileState } from "./lib/compile-state.ts"
113
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"
114
123
  import type { GoEmitContext } from "./emit-context.ts"
115
124
  import { inlineLocalHelperCall } from "./expr/helper-inline.ts"
116
125
  import { lowerRegisteredCall } from "./expr/url-builder.ts"
@@ -226,6 +235,22 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
226
235
  }
227
236
 
228
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>()
229
254
  private loopParamStack: string[] = []
230
255
  /**
231
256
  * Stack of `IRLoop.depth` values (innermost last), pushed/popped around
@@ -272,6 +297,30 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
272
297
  * alone (#2087 Phase B).
273
298
  */
274
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
275
324
 
276
325
  /**
277
326
  * Cross-component child shapes, keyed by child component name. Populated via
@@ -307,6 +356,32 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
307
356
  this.state.restPropsName = ir.metadata.restPropsName ?? null
308
357
  this.state.moduleStringConsts = this.collectModuleStringConsts(ir.metadata.localConstants)
309
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()
310
385
  this.state.localHelperNames = new Set(
311
386
  this.state.localConstants.filter(c => !c.isModule && c.containsArrow).map(c => c.name),
312
387
  )
@@ -856,6 +931,18 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
856
931
  }
857
932
 
858
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
859
946
  lines.push(`\t${nested.name}s []${nested.name}Input`)
860
947
  }
861
948
 
@@ -1074,6 +1161,36 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1074
1161
  // Static nested WITHOUT body children.
1075
1162
  for (const nested of staticWithoutBody) {
1076
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
+ }
1077
1194
  lines.push(`\t${varName} := make([]${nested.name}Props, len(in.${nested.name}s))`)
1078
1195
  lines.push(`\tfor i, item := range in.${nested.name}s {`)
1079
1196
  lines.push(`\t\t${varName}[i] = New${nested.name}Props(item)`)
@@ -2757,7 +2874,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2757
2874
  renderElement(element: IRElement): string {
2758
2875
  const tag = element.tag
2759
2876
  const attrs = this.renderAttributes(element)
2760
- const children = this.renderChildren(element.children)
2877
+ const dangerousHtml = this.renderDangerousInnerHtml(element)
2878
+ const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children)
2761
2879
 
2762
2880
  let hydrationAttrs = ''
2763
2881
  if (element.needsScope) {
@@ -2792,6 +2910,28 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2792
2910
  return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
2793
2911
  }
2794
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
+
2795
2935
  renderExpression(expr: IRExpression): string {
2796
2936
  // @client directive: render a comment marker; ClientJS evaluates the
2797
2937
  // expression via updateClientMarker().
@@ -4101,10 +4241,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4101
4241
 
4102
4242
  /**
4103
4243
  * Render a predicate for use in Go template `{{if}}` conditions, substituting
4104
- * 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.
4105
4250
  */
4106
- private renderPredicateCondition(pred: ParsedExpr, param: string): string {
4107
- 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)
4108
4253
  }
4109
4254
 
4110
4255
  /** Whether an expression needs parentheses when used in and/or. */
@@ -4138,12 +4283,19 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4138
4283
  * Render a filter predicate expression (`t => !t.done`, or a block body
4139
4284
  * normalized to one — #2040). `localVarMap` is a vestigial empty default kept
4140
4285
  * on the recursion; block-body locals are now inlined upstream, so no caller
4141
- * 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.
4142
4293
  */
4143
4294
  private renderFilterExpr(
4144
4295
  expr: ParsedExpr,
4145
4296
  param: string,
4146
- localVarMap: Map<string, string> = new Map()
4297
+ localVarMap: Map<string, string> = new Map(),
4298
+ datumField?: string
4147
4299
  ): string {
4148
4300
  // Top-of-recursion: clear the unsupported sentinel so a previous filter
4149
4301
  // expression's failure doesn't poison this one. Parents (`member` /
@@ -4153,7 +4305,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4153
4305
  if (this.filterExprDepth === 0) this.filterExprUnsupported = false
4154
4306
  this.filterExprDepth++
4155
4307
  try {
4156
- return this.renderFilterExprNode(expr, param, localVarMap)
4308
+ return this.renderFilterExprNode(expr, param, localVarMap, datumField)
4157
4309
  } finally {
4158
4310
  this.filterExprDepth--
4159
4311
  }
@@ -4162,12 +4314,22 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4162
4314
  private renderFilterExprNode(
4163
4315
  expr: ParsedExpr,
4164
4316
  param: string,
4165
- localVarMap: Map<string, string>
4317
+ localVarMap: Map<string, string>,
4318
+ datumField?: string
4166
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 || '.'
4167
4329
  switch (expr.kind) {
4168
4330
  case 'identifier': {
4169
4331
  if (expr.name === param) {
4170
- return '.'
4332
+ return paramDot
4171
4333
  }
4172
4334
  // A local variable mapped to a signal.
4173
4335
  const signal = localVarMap.get(expr.name)
@@ -4187,9 +4349,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4187
4349
  return String(expr.value)
4188
4350
 
4189
4351
  case 'member': {
4190
- // t.done -> .Done
4352
+ // t.done -> .Done (or .Todo.Done under a wrapper struct, #2228)
4191
4353
  if (expr.object.kind === 'identifier' && expr.object.name === param) {
4192
- return `.${capitalizeFieldName(expr.property)}`
4354
+ return `${paramPrefix}.${capitalizeFieldName(expr.property)}`
4193
4355
  }
4194
4356
  // `.length` on a higher-order filter result (e.g.
4195
4357
  // `x.tags.filter(t => t.active).length > 0`). Reuse
@@ -4202,21 +4364,21 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4202
4364
  const innerHO = this.higherOrderShapeOf(expr.object)
4203
4365
  if (innerHO && innerHO.method === 'filter') {
4204
4366
  const lenExpr = this.renderFilterLengthExpr(innerHO, e =>
4205
- this.renderFilterExpr(e, param, localVarMap),
4367
+ this.renderFilterExpr(e, param, localVarMap, datumField),
4206
4368
  )
4207
4369
  if (lenExpr) return `(${lenExpr})`
4208
4370
  }
4209
4371
  }
4210
4372
  // Nested member access or local var.prop.
4211
- const obj = this.renderFilterExpr(expr.object, param, localVarMap)
4373
+ const obj = this.renderFilterExpr(expr.object, param, localVarMap, datumField)
4212
4374
  if (this.filterExprUnsupported) return 'false'
4213
4375
  return `${obj}.${capitalizeFieldName(expr.property)}`
4214
4376
  }
4215
4377
 
4216
4378
  case 'call': {
4217
- // `t.isDone()` -> `.IsDone`
4379
+ // `t.isDone()` -> `.IsDone` (or `.Todo.IsDone` under a wrapper, #2228)
4218
4380
  if (expr.callee.kind === 'member' && expr.callee.object.kind === 'identifier' && expr.callee.object.name === param) {
4219
- return `.${capitalizeFieldName(expr.callee.property)}`
4381
+ return `${paramPrefix}.${capitalizeFieldName(expr.callee.property)}`
4220
4382
  }
4221
4383
  // Signal calls: `filter()` -> `$.Filter`
4222
4384
  if (expr.callee.kind === 'identifier' && expr.args.length === 0) {
@@ -4232,13 +4394,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4232
4394
  if (asCallbackMethodCall(expr) !== null) {
4233
4395
  return this.refuseFilterExprNode(expr)
4234
4396
  }
4235
- const result = this.renderFilterExpr(expr.callee, param, localVarMap)
4397
+ const result = this.renderFilterExpr(expr.callee, param, localVarMap, datumField)
4236
4398
  if (this.filterExprUnsupported) return 'false'
4237
4399
  return result
4238
4400
  }
4239
4401
 
4240
4402
  case 'unary': {
4241
- const arg = this.renderFilterExpr(expr.argument, param, localVarMap)
4403
+ const arg = this.renderFilterExpr(expr.argument, param, localVarMap, datumField)
4242
4404
  if (this.filterExprUnsupported) return 'false'
4243
4405
  if (expr.op === '!') {
4244
4406
  // Wrap in parens if arg is a function call (eq, ne, gt, …).
@@ -4252,9 +4414,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4252
4414
  }
4253
4415
 
4254
4416
  case 'binary': {
4255
- const left = this.renderFilterExpr(expr.left, param, localVarMap)
4417
+ const left = this.renderFilterExpr(expr.left, param, localVarMap, datumField)
4256
4418
  if (this.filterExprUnsupported) return 'false'
4257
- const right = this.renderFilterExpr(expr.right, param, localVarMap)
4419
+ const right = this.renderFilterExpr(expr.right, param, localVarMap, datumField)
4258
4420
  if (this.filterExprUnsupported) return 'false'
4259
4421
 
4260
4422
  switch (expr.op) {
@@ -4286,9 +4448,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4286
4448
  }
4287
4449
 
4288
4450
  case 'logical': {
4289
- const left = this.renderFilterExpr(expr.left, param, localVarMap)
4451
+ const left = this.renderFilterExpr(expr.left, param, localVarMap, datumField)
4290
4452
  if (this.filterExprUnsupported) return 'false'
4291
- const right = this.renderFilterExpr(expr.right, param, localVarMap)
4453
+ const right = this.renderFilterExpr(expr.right, param, localVarMap, datumField)
4292
4454
  if (this.filterExprUnsupported) return 'false'
4293
4455
  if (expr.op === '&&') {
4294
4456
  return `and (${left}) (${right})`
@@ -4409,6 +4571,36 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4409
4571
  return '""'
4410
4572
  }
4411
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
+
4412
4604
  // `IDENT['key']` over a module object-literal const with a STRING-LITERAL key
4413
4605
  // is a fully static lookup — resolve it at compile time. The generic member
4414
4606
  // lowering below would otherwise capitalize the bracket access into a field
@@ -4424,7 +4616,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4424
4616
  // the generic lowering would reference a nonexistent `.TotalPages` field).
4425
4617
  // Only pure numeric / single-quoted-string initializers qualify; anything
4426
4618
  // else may be runtime-dependent.
4427
- 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)) {
4428
4628
  const litConst = (this.state.localConstants ?? []).find(c => c.name === trimmed)
4429
4629
  if (litConst?.value !== undefined) {
4430
4630
  const v = litConst.value.trim()
@@ -4495,6 +4695,26 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4495
4695
  return this.renderParsedExpr(parsed)
4496
4696
  }
4497
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
+
4498
4718
  /**
4499
4719
  * Resolve `IDENT['key']` / `IDENT["key"]` where `IDENT` is a module-scope
4500
4720
  * object-literal const and the key is a string literal — a compile-time-static
@@ -4512,6 +4732,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4512
4732
  // ordinary props/locals never match.
4513
4733
  /^([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/.exec(jsExpr)
4514
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
4515
4741
  const key = m[2] ?? m[3]
4516
4742
  const constInfo = (this.state.localConstants ?? []).find(
4517
4743
  c => c.name === m[1] && c.isModule,
@@ -4974,6 +5200,68 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4974
5200
  return undefined
4975
5201
  }
4976
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
+
4977
5265
  renderLoop(loop: IRLoop): string {
4978
5266
  // clientOnly loops: emit SSR markers so the client can insert DOM nodes. The
4979
5267
  // marker id disambiguates sibling `.map()` calls under the same parent.
@@ -5036,8 +5324,44 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5036
5324
  // Phase A/B) no longer refuses `static-array-from-props`'s `([emoji,
5037
5325
  // users]) => ...` param first. Cross-adapter policy: Jinja / ERB apply the
5038
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
+
5039
5363
  const arrayName = loop.array.trim()
5040
- if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
5364
+ if (bakedChildLoop === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
5041
5365
  const arrayConst = this.state.localConstants.find(c => c.name === arrayName)
5042
5366
  if (arrayConst && !arrayConst.isModule && arrayConst.parsed && !this.isStringExpr(arrayConst.parsed, new Set())) {
5043
5367
  this.state.errors.push({
@@ -5053,7 +5377,22 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5053
5377
  }
5054
5378
  }
5055
5379
 
5056
- 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)
5057
5396
  const param = loop.param
5058
5397
  let index = loop.index || '_'
5059
5398
 
@@ -5174,9 +5513,19 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5174
5513
  let filterCond: string
5175
5514
 
5176
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)
5177
5525
  filterCond = this.renderPredicateCondition(
5178
5526
  loop.filterPredicate.predicate,
5179
- loop.filterPredicate.param
5527
+ loop.filterPredicate.param,
5528
+ datumField
5180
5529
  )
5181
5530
  } else {
5182
5531
  filterCond = 'true'
@@ -5188,6 +5537,61 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5188
5537
  return `{{bfComment "loop:${loop.markerId}"}}{{range $${rangeIndex}, $${rangeValue} := ${goArray}}}${itemMarker}${children}{{end}}{{bfComment "/loop:${loop.markerId}"}}`
5189
5538
  }
5190
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
+
5191
5595
  /**
5192
5596
  * Per-item `<!--bf-loop-i-->` / `<!--bf-loop-i:KEY-->` start marker emitted
5193
5597
  * inside a `{{range}}` body. Multi-root Fragment items get the bare anchor;
@@ -5574,6 +5978,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5574
5978
  // predicate (no BF101 / BF102). This keeps the BF102 remediation ("defer
5575
5979
  // it with /* @client */") accurate for attribute-only state.
5576
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
5577
5987
  // Rewrite JSX special-prop names to their HTML-attribute counterparts. The
5578
5988
  // Go template adapter has no JSX runtime to strip `key` / emit `data-key`,
5579
5989
  // so the rewrite happens at attribute-emit time. Mirror of the `key`