@barefootjs/go-template 0.18.5 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) 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/emit-context.d.ts +11 -5
  6. package/dist/adapter/emit-context.d.ts.map +1 -1
  7. package/dist/adapter/go-template-adapter.d.ts +153 -6
  8. package/dist/adapter/go-template-adapter.d.ts.map +1 -1
  9. package/dist/adapter/index.js +514 -53
  10. package/dist/adapter/lib/compile-state.d.ts +24 -0
  11. package/dist/adapter/lib/compile-state.d.ts.map +1 -1
  12. package/dist/adapter/lib/types.d.ts +9 -0
  13. package/dist/adapter/lib/types.d.ts.map +1 -1
  14. package/dist/adapter/props/prop-classes.d.ts +28 -9
  15. package/dist/adapter/props/prop-classes.d.ts.map +1 -1
  16. package/dist/adapter/props/prop-types.d.ts +45 -0
  17. package/dist/adapter/props/prop-types.d.ts.map +1 -1
  18. package/dist/build.js +514 -53
  19. package/dist/conformance-pins.d.ts.map +1 -1
  20. package/dist/index.js +516 -62
  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 +876 -21
  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/emit-context.ts +14 -5
  28. package/src/adapter/go-template-adapter.ts +620 -45
  29. package/src/adapter/lib/compile-state.ts +27 -0
  30. package/src/adapter/lib/types.ts +9 -0
  31. package/src/adapter/props/prop-classes.ts +34 -9
  32. package/src/adapter/props/prop-types.ts +178 -1
  33. package/src/adapter/value/value-lowering.ts +1 -1
  34. package/src/conformance-pins.ts +30 -31
  35. package/src/render-divergences.ts +12 -0
  36. package/src/test-render.ts +127 -10
@@ -66,6 +66,15 @@ export class CompileState {
66
66
  */
67
67
  localConstants: IRMetadata['localConstants'] = []
68
68
 
69
+ /**
70
+ * Every name a `.map()`/`.filter()` loop callback binds as its item/index
71
+ * parameter anywhere in the component (#2208 fable review). Consulted by
72
+ * static loop-source resolution (`getBakedStaticChildLoop`) so a const
73
+ * whose name a DIFFERENT, enclosing loop's own callback param shadows is
74
+ * never resolved as that const's static value.
75
+ */
76
+ staticLoopSourceBoundNames: Set<string> = new Set()
77
+
69
78
  /**
70
79
  * Names of component-scope arrow-const helpers (`const sortClass = …`),
71
80
  * eligible for call-site inlining.
@@ -137,6 +146,24 @@ export class CompileState {
137
146
  */
138
147
  nillablePropNames: Set<string> = new Set()
139
148
 
149
+ /**
150
+ * OPTIONAL prop names consumed nullish-sensitively (`??` left operand in a
151
+ * parsed expression tree, or a signal's `props.X ?? <literal>` seed) —
152
+ * #2248. Consulted by `resolvePropGoType` to flip an optional scalar to the
153
+ * nillable `interface{}` representation, so it MUST be populated before the
154
+ * first `resolvePropGoType` call of a compile (see `generate()`'s ordering
155
+ * against `collectNillablePropNames`).
156
+ */
157
+ nullishConsumedPropNames: Set<string> = new Set()
158
+
159
+ /**
160
+ * OPTIONAL no-default prop names consumed as a BARE omittable-attribute
161
+ * value (`rows={rows}`) — #2259. Same `resolvePropGoType` flip and same
162
+ * populate-before-first-resolve ordering as `nullishConsumedPropNames`:
163
+ * the attribute-omission guard (`{{if ne .X nil}}`) needs a nillable field.
164
+ */
165
+ omittableAttrConsumedPropNames: Set<string> = new Set()
166
+
140
167
  /**
141
168
  * String-typed signal getter / prop names (#2168 string-concat-plus).
142
169
  * Feeds `isStringName` for `isStringConcatBinary`, which decides whether a
@@ -140,6 +140,15 @@ export interface PropFallbackVar {
140
140
  goFallback: string
141
141
  /** Go zero literal for the prop's type (`0`, `""`, etc.). */
142
142
  zeroLiteral: string
143
+ /**
144
+ * Set when the prop lowered to the nillable `interface{}` representation
145
+ * (#2248): the concrete scalar Go type (`string`/`int`/`float64`/`bool`)
146
+ * the constructor materializes the hoisted local as. Presence switches
147
+ * the emission from the zero-value check (`if v == 0`) to a nil check
148
+ * (`if in.X != nil`), which is what makes an explicit `''`/`0` input
149
+ * distinguishable from an absent one.
150
+ */
151
+ assertType?: string
143
152
  }
144
153
 
145
154
  /**
@@ -7,7 +7,7 @@
7
7
  * function over `ir.metadata`; no adapter instance state.
8
8
  */
9
9
 
10
- import type { ComponentIR, TypeInfo } from '@barefootjs/jsx'
10
+ import { collectLoopBoundNames, type ComponentIR, type TypeInfo } from '@barefootjs/jsx'
11
11
 
12
12
  /** True when `type` is the `string` primitive. */
13
13
  function isStringTypeInfo(type: TypeInfo): boolean {
@@ -22,14 +22,33 @@ function isBareStringLiteral(initialValue: string | undefined): boolean {
22
22
  }
23
23
 
24
24
  /**
25
- * String-typed signals and props. A signal is string-typed when its inferred
26
- * type is `string` (or, defensively, when its initial value is a bare string
27
- * literal); a prop when its annotated type is `string`. Drives `isStringName`
28
- * for `isStringConcatBinary` the shared helper (`@barefootjs/jsx`) that
29
- * decides whether a JS `+` is string concatenation rather than numeric
30
- * addition (Go's `html/template` has no native `+` at all; `binary()` always
31
- * emits a runtime call, `bf_add` for addition or `bf_concat_str` for
32
- * concatenation see `go-template-adapter.ts`'s `binary()`).
25
+ * String-typed signals, props, and same-file local consts (#2212, ported
26
+ * here for #2236). A signal is string-typed when its inferred type is
27
+ * `string` (or, defensively, when its initial value is a bare string
28
+ * literal); a prop when its annotated type is `string`; a local const the
29
+ * same way. Drives `isStringName` for `isStringConcatBinary` the shared
30
+ * helper (`@barefootjs/jsx`) that decides whether a JS `+` is string
31
+ * concatenation rather than numeric addition (Go's `html/template` has no
32
+ * native `+` at all; `binary()` always emits a runtime call, `bf_add` for
33
+ * addition or `bf_concat_str` for concatenation — see
34
+ * `go-template-adapter.ts`'s `binary()`). Local consts matter for exactly
35
+ * the shadowing shape this exclusion exists for: with a loop-bound `label`
36
+ * subtracted, an outer `{label + suffix}` (where `suffix = '!'`) must
37
+ * still classify as string concat via its OTHER operand, or it would fall
38
+ * back to `bf_add` and render `0`.
39
+ *
40
+ * Excludes any name bound as a `.map()`/`.filter()` loop callback's item or
41
+ * index parameter ANYWHERE in the component (#2212, ported here for #2236):
42
+ * this lookup is a flat, scope-blind `Set<string>` with no notion of a loop
43
+ * param shadowing an outer string-typed binding of the same name
44
+ * (`values.map((label) => 1 + label)` inside a component that also has a
45
+ * string `label` prop) — left unguarded, that shadowed `label` would be
46
+ * misdetected as string-typed and `1 + label` would silently lower to
47
+ * `bf_concat_str` instead of staying numeric `bf_add`. Subtracting loop-bound
48
+ * names is coarse (it also suppresses a genuinely non-shadowed same-named
49
+ * string elsewhere in the component) but safe: the suppressed case just
50
+ * falls back to today's numeric `bf_add` — the same, already-accepted
51
+ * residual as an unresolvable operand — never silently-wrong output.
33
52
  */
34
53
  export function collectStringValueNames(ir: ComponentIR): Set<string> {
35
54
  const names = new Set<string>()
@@ -41,5 +60,11 @@ export function collectStringValueNames(ir: ComponentIR): Set<string> {
41
60
  for (const p of ir.metadata.propsParams) {
42
61
  if (isStringTypeInfo(p.type)) names.add(p.name)
43
62
  }
63
+ for (const c of ir.metadata.localConstants) {
64
+ if ((c.type !== null && isStringTypeInfo(c.type)) || isBareStringLiteral(c.value)) {
65
+ names.add(c.name)
66
+ }
67
+ }
68
+ for (const bound of collectLoopBoundNames(ir)) names.delete(bound)
44
69
  return names
45
70
  }
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { ComponentIR, IRMetadata, IRNode, ParsedExpr } from '@barefootjs/jsx'
9
+ import { isBooleanAttr } from '@barefootjs/jsx'
9
10
 
10
11
  import type { GoEmitContext } from '../emit-context.ts'
11
12
  import { typeInfoToGo } from '../type/type-codegen.ts'
@@ -20,7 +21,7 @@ export function buildPropTypeOverrides(ctx: GoEmitContext, ir: ComponentIR): Map
20
21
  const overrides = new Map<string, string>()
21
22
  for (const signal of ir.metadata.signals) {
22
23
  const propNames = [signal.initialValue]
23
- const extracted = ctx.extractPropNameFromInitialValue(signal.initialValue)
24
+ const extracted = ctx.extractPropNameFromInitialValue(signal.initialValue, signal.parsed)
24
25
  if (extracted) propNames.push(extracted)
25
26
 
26
27
  for (const propName of propNames) {
@@ -106,6 +107,151 @@ function collectToFixedPropNames(root: IRNode): Set<string> {
106
107
  return names
107
108
  }
108
109
 
110
+ /**
111
+ * Concrete scalar Go types eligible for the nullish (`??`) nillable flip in
112
+ * `resolvePropGoType`. Only these can round-trip through an `interface{}`
113
+ * field and back via a constructor type assertion / `bf.ToInt`-style
114
+ * coercion (see the fallback-var emission in `generateNewPropsFunction`).
115
+ */
116
+ export const NULLISH_SCALAR_GO_TYPES: ReadonlySet<string> = new Set(['string', 'int', 'float64', 'bool'])
117
+
118
+ /**
119
+ * Names of OPTIONAL props consumed nullish-sensitively — the left operand of
120
+ * a `??` anywhere in the component's parsed expression trees, or a signal's
121
+ * `props.X ?? <literal>` initial value (#2248).
122
+ *
123
+ * Why this matters: JS `??` falls back only on `null`/`undefined`, keeping
124
+ * `''`/`0`/`false`. A Go zero-valued scalar field cannot represent "absent",
125
+ * so a `??`-consumed optional scalar must lower to the adapter's established
126
+ * nillable representation (`interface{}`) for the distinction to exist at
127
+ * render time. `resolvePropGoType` applies that flip using this set.
128
+ *
129
+ * The IR walk is generic (any nested object/array is descended, so parsed
130
+ * trees inside expressions, conditions, attributes, and loops are all seen)
131
+ * and the match is shape-precise: `identifier` left (destructured props) or
132
+ * `<propsObject>.<name>` member left. KNOWN LIMITATION (same class as
133
+ * `collectToFixedPropNames`): an `identifier` left shadowed by a same-named
134
+ * loop/callback param is misattributed to the prop — the flip is then merely
135
+ * unnecessary, not incorrect.
136
+ */
137
+ export function collectNullishConsumedPropNames(ctx: GoEmitContext, ir: ComponentIR): Set<string> {
138
+ const names = new Set<string>()
139
+ // A destructure default (`{ className = '' }`) means the binding is never
140
+ // nullish in JS — the default already applied — so such props never need
141
+ // the nillable flip (and `applyGoFallback`'s concrete-typed baking relies
142
+ // on them staying concrete).
143
+ const optionalParams = new Set(
144
+ ir.metadata.propsParams.filter(p => p.optional && p.defaultValue == null).map(p => p.name),
145
+ )
146
+ if (optionalParams.size === 0) return names
147
+
148
+ const propsObject = ctx.state.propsObjectName
149
+ const propNameOfLeft = (left: ParsedExpr): string | null => {
150
+ if (left.kind === 'identifier') return left.name
151
+ if (
152
+ left.kind === 'member' &&
153
+ !left.computed &&
154
+ left.object.kind === 'identifier' &&
155
+ left.object.name === propsObject
156
+ ) {
157
+ return left.property
158
+ }
159
+ return null
160
+ }
161
+
162
+ // `?? <zero-equivalent literal>` (`?? ''`, `?? 0`, `?? false`) is the one
163
+ // shape where nullish and truthiness semantics coincide — nil and the zero
164
+ // value both land on the same output — so it earns no flip. Only a
165
+ // fallback the zero value must NOT collapse into makes the distinction
166
+ // observable.
167
+ const isZeroEquivalentLiteral = (right: ParsedExpr): boolean =>
168
+ right.kind === 'literal' &&
169
+ (right.value === '' || right.value === false || right.value === null ||
170
+ (right.literalType === 'number' && Number(right.value) === 0))
171
+
172
+ const walk = (node: unknown): void => {
173
+ if (!node || typeof node !== 'object') return
174
+ if (Array.isArray(node)) {
175
+ for (const item of node) walk(item)
176
+ return
177
+ }
178
+ const rec = node as Record<string, unknown>
179
+ if (rec.kind === 'logical' && rec.op === '??' && rec.left && rec.right) {
180
+ const propName = propNameOfLeft(rec.left as ParsedExpr)
181
+ if (propName && optionalParams.has(propName) && !isZeroEquivalentLiteral(rec.right as ParsedExpr)) {
182
+ names.add(propName)
183
+ }
184
+ }
185
+ for (const value of Object.values(rec)) walk(value)
186
+ }
187
+ walk(ir.root)
188
+
189
+ // Signal seeds (`createSignal(props.X ?? 1)`) live in metadata, not the
190
+ // tree — reuse the seam's existing `props.X ?? <literal>` recognizer. The
191
+ // zero-equivalent exclusion matches on the Go-formatted fallback literal.
192
+ for (const signal of ir.metadata.signals) {
193
+ const match = ctx.extractPropFallback(signal.initialValue, signal.parsed)
194
+ if (!match || !optionalParams.has(match.propName)) continue
195
+ const f = match.goFallback
196
+ if (f === '""' || f === 'false' || f === 'nil' || Number(f) === 0) continue
197
+ names.add(match.propName)
198
+ }
199
+ return names
200
+ }
201
+
202
+ /**
203
+ * Names of OPTIONAL no-default props consumed as the BARE value of an
204
+ * omittable attribute (`rows={rows}` / `rows={props.rows}`) anywhere in the
205
+ * component's element tree.
206
+ *
207
+ * Why this matters: Hono omits an attribute whose value is `undefined`, and
208
+ * the adapter's `emitExpression` mirrors that with a `{{if ne .X nil}}` guard
209
+ * — which needs a nillable field to test. A concrete scalar field would
210
+ * render the zero value (`rows="0"`) instead of dropping the attribute, so
211
+ * these props take the same `interface{}` flip as `??` consumption
212
+ * (`resolvePropGoType`). The match mirrors `emitExpression`'s guard branch:
213
+ * bare identifiers / `<propsObject>.<name>` only, skipping `class`/`style`
214
+ * (own lowerings) and boolean/presence attrs (truthiness-tested, where a nil
215
+ * and a zero value already coincide). Same shadowing caveat as
216
+ * `collectNullishConsumedPropNames`: a same-named loop/callback param can
217
+ * misattribute, making the flip merely unnecessary, not incorrect.
218
+ */
219
+ export function collectOmittableAttrConsumedPropNames(ctx: GoEmitContext, ir: ComponentIR): Set<string> {
220
+ const names = new Set<string>()
221
+ const optionalParams = new Set(
222
+ ir.metadata.propsParams.filter(p => p.optional && p.defaultValue == null).map(p => p.name),
223
+ )
224
+ if (optionalParams.size === 0) return names
225
+
226
+ const propsObject = ctx.state.propsObjectName
227
+ const walk = (node: unknown): void => {
228
+ if (!node || typeof node !== 'object') return
229
+ if (Array.isArray(node)) {
230
+ for (const item of node) walk(item)
231
+ return
232
+ }
233
+ const rec = node as Record<string, unknown>
234
+ if (rec.type === 'element' && Array.isArray(rec.attrs)) {
235
+ for (const attr of rec.attrs as Array<{ name: string; value: Record<string, unknown> }>) {
236
+ if (attr.name === 'class' || attr.name === 'className' || attr.name === 'style') continue
237
+ if (isBooleanAttr(attr.name)) continue
238
+ if (attr.value?.kind !== 'expression' || attr.value.presenceOrUndefined) continue
239
+ const bareId = String(attr.value.expr ?? '').trim()
240
+ const propName =
241
+ propsObject && bareId.startsWith(`${propsObject}.`)
242
+ ? bareId.slice(propsObject.length + 1)
243
+ : bareId
244
+ if (/^[A-Za-z_$][\w$]*$/.test(propName) && optionalParams.has(propName)) {
245
+ names.add(propName)
246
+ }
247
+ }
248
+ }
249
+ for (const value of Object.values(rec)) walk(value)
250
+ }
251
+ walk(ir.root)
252
+ return names
253
+ }
254
+
109
255
  /**
110
256
  * Resolve a prop param's Go struct-field type using the SAME logic
111
257
  * `generatePropsStruct` / `generateInputStruct` use: a `propTypeOverrides` entry
@@ -131,6 +277,37 @@ export function resolvePropGoType(
131
277
  if (param.optional && ctx.state.localStructFields.has(base)) {
132
278
  return 'map[string]interface{}'
133
279
  }
280
+ // An OPTIONAL scalar prop consumed by `??` lowers to `interface{}` (#2248):
281
+ // a zero-valued `string`/`int` field cannot distinguish "absent" from
282
+ // `''`/`0`, so JS nullish semantics (which KEEP `''`/`0`) are unexpressible
283
+ // on the concrete type. `interface{}` is the adapter's established nillable
284
+ // representation; the `??` template lowering then tests nil-ness via
285
+ // `bf_nullish` and the constructor seeds via a nil check + assertion.
286
+ // Assignment ergonomics are unchanged (plain literals assign into
287
+ // `interface{}`), and the prop joins the existing nillable behaviours
288
+ // (bare-attribute omission) by construction.
289
+ //
290
+ // Gated to `kind: 'primitive'` — a string-union prop
291
+ // (`placement?: 'top' | 'bottom'`) must stay its scalar Go type (the same
292
+ // invariant the struct-map gate above documents): the union-typed
293
+ // class-composition lowerings key off the concrete type, AND the flip
294
+ // would buy nothing — the zero value (`""`) is never a legal union
295
+ // member, so the zero-check already coincides with nullish semantics for
296
+ // every valid input. (A literal-number union containing 0 would be the
297
+ // exception; none exists in the corpus and the conflation is the
298
+ // documented pre-#2248 trade-off there.)
299
+ // A bare-attribute consumption (`rows={rows}`) takes the same flip (#2259):
300
+ // attribute omission for an absent optional needs a nil to test — see
301
+ // `collectOmittableAttrConsumedPropNames`.
302
+ if (
303
+ param.optional &&
304
+ param.type.kind === 'primitive' &&
305
+ (ctx.state.nullishConsumedPropNames.has(param.name) ||
306
+ ctx.state.omittableAttrConsumedPropNames.has(param.name)) &&
307
+ NULLISH_SCALAR_GO_TYPES.has(base)
308
+ ) {
309
+ return 'interface{}'
310
+ }
134
311
  return base
135
312
  }
136
313
 
@@ -32,7 +32,7 @@ export function convertInitialValue(
32
32
  }
33
33
  }
34
34
 
35
- const propName = ctx.extractPropNameFromInitialValue(value)
35
+ const propName = ctx.extractPropNameFromInitialValue(value, preParsed)
36
36
  if (propName && propsParams?.some(p => p.name === propName)) {
37
37
  return `in.${capitalizeFieldName(propName)}`
38
38
  }
@@ -15,19 +15,21 @@ export const conformancePins: ConformancePins = {
15
15
  // `style={{ … }}` object literal now lowers to a CSS string with dynamic
16
16
  // values interpolated (`background-color:{{.Color}};padding:8px`) via
17
17
  // `tryLowerStyleObject` (#1322).
18
- // Sibling-imported child component inside a loop body: the adapter
19
- // emits `{{template "X" .}}` which only resolves if the user has
20
- // compiled the sibling file and registered the template on the
21
- // same instance. BF103 makes that requirement loud. (The barefoot
22
- // CLI passes `siblingTemplatesRegistered: true` so CLI builds
23
- // suppress the diagnosticsee compileJSX `siblingTemplatesRegistered`.)
24
- 'static-array-children': [{ code: 'BF103', severity: 'error' }],
25
- // TodoApp / TodoAppSSR import `TodoItem` from a sibling file and
26
- // call it inside a keyed `.map`. Same BF103 surface as
27
- // `static-array-children` above pinned at adapter level so the
28
- // shared-component corpus stays adapter-neutral.
29
- 'todo-app': [{ code: 'BF103', severity: 'error' }],
30
- 'todo-app-ssr': [{ code: 'BF103', severity: 'error' }],
18
+ // `todo-app` / `todo-app-ssr` no longer pinned (#2205) the conformance
19
+ // harness now passes `siblingTemplatesRegistered: true` for fixtures with
20
+ // sibling `components`, matching `bf build`'s real semantics, so the
21
+ // BF103 loop-body cross-template check no longer fires spuriously.
22
+ // (`todo-app-ssr` is still skipped on this adapter via
23
+ // `render-divergences.ts` #2209for an unrelated signal-seeding gap;
24
+ // `todo-app`'s pre-hydration empty render is unaffected.)
25
+ // `static-array-children` no longer pinned (#2208) — `items`'s
26
+ // array-literal initializer is now recognized as fully-static and its
27
+ // per-item ListItem props/data-key are baked directly into
28
+ // `NewStaticListProps`'s constructor (`analyzeBakeableStaticChildLoop`),
29
+ // since the loop body is a single child component with a plain-value
30
+ // prop set. See #2224 for the narrower remaining gap (a plain-ELEMENT
31
+ // loop body over a static array, or an inline/unnamed array literal —
32
+ // still refused).
31
33
  // `([emoji, users]) => ...` is an array-index tuple destructure — #2087
32
34
  // Phase B's widened gate now admits this shape (`destructure-array-index-in-map`
33
35
  // exercises the same `segments`-based lowering). The remaining refusal here
@@ -40,13 +42,11 @@ export const conformancePins: ConformancePins = {
40
42
  // array bound to such a const. See the `renderLoop` comment at the check
41
43
  // site; Jinja / ERB apply the same narrow check for the same reason.
42
44
  'static-array-from-props': [{ code: 'BF101', severity: 'error' }],
43
- // Same computed-const array as above, plus the pre-existing BF103 (a
44
- // sibling-imported child component used inside the loop body) — the
45
- // destructure param itself no longer contributes a diagnostic.
46
- 'static-array-from-props-with-component': [
47
- { code: 'BF103', severity: 'error' },
48
- { code: 'BF101', severity: 'error' },
49
- ],
45
+ // Same computed-const array as above the destructure param itself no
46
+ // longer contributes a diagnostic, and BF103 (sibling-imported child
47
+ // component in the loop body) no longer fires either now that the
48
+ // conformance harness passes `siblingTemplatesRegistered: true` (#2205).
49
+ 'static-array-from-props-with-component': [{ code: 'BF101', severity: 'error' }],
50
50
  // (`style-3-signals` graduated alongside `style-object-dynamic` — see note
51
51
  // above; the `style={{ … }}` object now lowers to a CSS string.)
52
52
  // (`tagged-template-classname` graduated by #2092 — the tag resolves
@@ -127,15 +127,14 @@ export const conformancePins: ConformancePins = {
127
127
  // `string-trim` no longer pinned — pre-existing `bf_trim`
128
128
  // (wraps `strings.TrimSpace`) handles the strip (#1448 Tier A
129
129
  // ninth PR, closing out Tier A).
130
- // #2073 follow-up: a function-reference `.map(format)` callback has no
131
- // arrow body to serialize not a CALLBACK_METHODS shape — so the
132
- // UNSUPPORTED_METHODS gate refuses it with BF101 rather than emitting
133
- // a broken template.
134
- 'array-map-function-reference': [{ code: 'BF101', severity: 'error' }],
135
- // Edge-case sweep (Priority 12): `dangerouslySetInnerHTML` requires a
136
- // deliberate raw-HTML (unescaped) output affordance in the target
137
- // template language. No lowering exists yet, so the compiler refuses
138
- // the shape loudly instead of emitting entity-escaped markup that
139
- // silently renders tags as text.
140
- 'dangerous-inner-html': [{ code: 'BF101', severity: 'error' }],
130
+ // `array-map-function-reference` no longer pinned — a bare-identifier
131
+ // `.map(format)` callback now resolves one hop to its declaration
132
+ // (`resolveCallbackMethodFunctionReferences`, #2206), the same mechanism
133
+ // #2090 established for `.sort(fnref)`.
134
+ // `dangerous-inner-html` no longer pinned a compile-time string-literal
135
+ // `dangerouslySetInnerHTML={{ __html: '...' }}` is spliced directly into
136
+ // the template as trusted raw text (`resolveDangerousInnerHtml`, #2207).
137
+ // A dynamic/signal-derived value still refuses with BF101 see the
138
+ // `dangerous-inner-html-dynamic` fixture/pin below (tracked: #2215).
139
+ 'dangerous-inner-html-dynamic': [{ code: 'BF101', severity: 'error', issue: 'https://github.com/piconic-ai/barefootjs/issues/2215' }],
141
140
  }
@@ -17,4 +17,16 @@
17
17
  import type { RenderDivergences } from '@barefootjs/jsx'
18
18
 
19
19
  export const renderDivergences: RenderDivergences = {
20
+ // `todo-app-ssr` no longer diverges (#2209). Two parts: (1) `.Todos`
21
+ // (the loop's DATUM slice) is already seeded straight from the caller's
22
+ // Input — the constructor derives it from `initialTodos`, and `[]Todo`
23
+ // zero-fills `Editing: false`, so the `.map(t => ({ ...t, editing:
24
+ // false }))` transform in the signal initializer was never actually the
25
+ // gap on Go, unlike the 7 template-string adapters. (2) The real gap was
26
+ // `.TodoItems []TodoItemProps` — the loop-body CHILD COMPONENT slice the
27
+ // template actually ranges over — which has no server-side population
28
+ // path in this harness (documented as route-handler-populated in
29
+ // production). `buildDynamicChildLoopSeeding` (this package's
30
+ // `test-render.ts`) now replicates that documented contract for a
31
+ // signal-backed dynamic child-component loop.
20
32
  }
@@ -6,10 +6,12 @@
6
6
  */
7
7
 
8
8
  import { compileJSX } from '@barefootjs/jsx'
9
- import type { TemplateAdapter, ComponentIR } from '@barefootjs/jsx'
9
+ import type { TemplateAdapter, ComponentIR, ParsedExpr } from '@barefootjs/jsx'
10
10
  import { GoTemplateAdapter } from './adapter/go-template-adapter.ts'
11
11
  import { deduplicateGoTypes } from './build.ts'
12
- import { capitalizeFieldName, goFieldNameForKey } from './adapter/lib/go-naming.ts'
12
+ import { capitalizeFieldName, goFieldNameForKey, loopKeyToGoFieldPath } from './adapter/lib/go-naming.ts'
13
+ import { findNestedComponents } from './adapter/analysis/component-tree.ts'
14
+ import type { NestedComponentInfo } from './adapter/lib/types.ts'
13
15
  import { mkdir, rm } from 'node:fs/promises'
14
16
  import { resolve } from 'node:path'
15
17
 
@@ -157,8 +159,15 @@ export async function renderGoTemplateComponent(options: RenderOptions): Promise
157
159
  }
158
160
  }
159
161
 
160
- // Compile parent source
161
- const result = compileJSX(source, 'component.tsx', { adapter, outputIR: true })
162
+ // Compile parent source. `siblingTemplatesRegistered: Boolean(components)`
163
+ // matches this harness's real behavior every sibling child template is concatenated
164
+ // into `tmplContent` and parsed onto one `*template.Template` instance
165
+ // below, so a loop-body cross-template call resolves at render time (#2205).
166
+ const result = compileJSX(source, 'component.tsx', {
167
+ adapter,
168
+ outputIR: true,
169
+ siblingTemplatesRegistered: Boolean(components),
170
+ })
162
171
 
163
172
  const errors = result.errors.filter(e => e.severity === 'error')
164
173
  if (errors.length > 0) {
@@ -319,11 +328,16 @@ export async function renderGoTemplateComponent(options: RenderOptions): Promise
319
328
  // cross-adapter; default to 'test' otherwise.
320
329
  const rootScopeId = typeof props?.__instanceId === 'string' ? props.__instanceId : 'test'
321
330
 
331
+ // (#2209 part 2) Route-handler-equivalent seeding for a signal-backed
332
+ // dynamic child-component loop — see buildDynamicChildLoopSeeding's
333
+ // docstring.
334
+ const { lines: dynamicSeedingLines, needsFmt } = buildDynamicChildLoopSeeding(ir, template)
335
+
322
336
  // main.go — render program
323
337
  const mainGo = `package main
324
338
 
325
339
  import (
326
- "html/template"
340
+ ${needsFmt ? '\t"fmt"\n' : ''} "html/template"
327
341
  "math/rand"
328
342
  "os"
329
343
 
@@ -368,7 +382,7 @@ func main() {
368
382
  ScopeID: ${JSON.stringify(rootScopeId)},
369
383
  ${propsInit}
370
384
  })
371
- if err := tmpl.ExecuteTemplate(os.Stdout, "${componentName}", props); err != nil {
385
+ ${dynamicSeedingLines.length > 0 ? dynamicSeedingLines.join('\n') + '\n' : ''} if err := tmpl.ExecuteTemplate(os.Stdout, "${componentName}", props); err != nil {
372
386
  os.Stderr.WriteString("template error: " + err.Error() + "\\n")
373
387
  os.Exit(1)
374
388
  }
@@ -551,6 +565,95 @@ function ensureMergedStdlibImports(goTypes: string): string {
551
565
  return goTypes.replace(/import\s*\([^)]*\)/, newBlock)
552
566
  }
553
567
 
568
+ /**
569
+ * Recursively resolve `expr` (a loop's `arrayParsed`) down through
570
+ * `call`/`member` chains to the base signal getter it reads
571
+ * (`todos().filter(...)` → `todos`), or `null` when the base isn't a
572
+ * signal getter call. Structural `ParsedExpr` walk, not string/regex
573
+ * parsing (see CLAUDE.md's "never parse JS with regex" rule).
574
+ */
575
+ function findBaseSignalGetter(expr: ParsedExpr | undefined, signalGetters: ReadonlySet<string>): string | null {
576
+ if (!expr) return null
577
+ switch (expr.kind) {
578
+ case 'identifier':
579
+ return signalGetters.has(expr.name) ? expr.name : null
580
+ case 'call':
581
+ return findBaseSignalGetter(expr.callee, signalGetters)
582
+ case 'member':
583
+ return findBaseSignalGetter(expr.object, signalGetters)
584
+ default:
585
+ return null
586
+ }
587
+ }
588
+
589
+ /**
590
+ * (#2209 part 2) Replicate, in the generated `main.go`, the documented
591
+ * "the route handler populates the loop-body child-component slice at
592
+ * request time" contract for a signal-backed dynamic loop —
593
+ * `generateNewPropsFunction`'s doc comment on `<Name>s []<Name>Props`
594
+ * in `adapter/go-template-adapter.ts`. The constructor only ever seeds
595
+ * the loop's DATUM slice (e.g. `.Todos`, straight from the caller's
596
+ * Input); the child-component Props slice the template actually
597
+ * ranges over (`.TodoItems`) has no server-side population path in
598
+ * this harness — the Hono reference materializes it by literally
599
+ * executing the component, so this closes the gap the same way:
600
+ * derive each item's child Props from the datum slice, exactly as a
601
+ * real route handler is documented to.
602
+ *
603
+ * Deliberately narrow: only fires for a loop whose (a) array source
604
+ * resolves, through `call`/`member` chains, to a component signal
605
+ * getter, (b) generated Go TEMPLATE text actually ranges over
606
+ * `.<Name>s` (a plain substring check on GENERATED GO OUTPUT — not JS
607
+ * parsing — so a `/* @client *\/`-marked loop, whose SSR template has
608
+ * no such range, is untouched by construction), and (c) at least one
609
+ * child prop is a bare pass-through of the loop item (`todo={todo}`).
610
+ * Returns the Go statements to splice into `main()` plus whether `fmt`
611
+ * needs importing.
612
+ */
613
+ function buildDynamicChildLoopSeeding(
614
+ ir: ComponentIR,
615
+ template: string,
616
+ ): { lines: string[]; needsFmt: boolean } {
617
+ const signalGetters = new Set(ir.metadata.signals.map(s => s.getter))
618
+ const lines: string[] = []
619
+ let needsFmt = false
620
+ for (const nested of findNestedComponents(ir.root) as NestedComponentInfo[]) {
621
+ if (!nested.isDynamic || nested.isPropDerived) continue
622
+ if (nested.bodyChildren && nested.bodyChildren.length > 0) continue
623
+ if (!nested.loopParam) continue
624
+ if (!template.includes(`:= .${nested.name}s}}`)) continue
625
+ const datumField = findBaseSignalGetter(nested.loopArrayParsed, signalGetters)
626
+ if (!datumField) continue
627
+
628
+ const inputFields: string[] = []
629
+ for (const prop of nested.props) {
630
+ if (prop.isEventHandler) continue
631
+ if (prop.name === 'key' || prop.name.includes('-')) continue
632
+ if (
633
+ prop.value.kind === 'expression' &&
634
+ prop.value.parsed?.kind === 'identifier' &&
635
+ prop.value.parsed.name === nested.loopParam
636
+ ) {
637
+ inputFields.push(`${capitalizeFieldName(prop.name)}: item`)
638
+ }
639
+ }
640
+ if (inputFields.length === 0) continue
641
+
642
+ lines.push(`\tprops.${nested.name}s = make([]${nested.name}Props, len(props.${capitalizeFieldName(datumField)}))`)
643
+ lines.push(`\tfor i, item := range props.${capitalizeFieldName(datumField)} {`)
644
+ lines.push(`\t\tprops.${nested.name}s[i] = New${nested.name}Props(${nested.name}Input{${inputFields.join(', ')}})`)
645
+ lines.push(`\t\tprops.${nested.name}s[i].BfParent = props.ScopeID`)
646
+ lines.push(`\t\tprops.${nested.name}s[i].BfMount = ${JSON.stringify(nested.slotId ?? '')}`)
647
+ const keyField = loopKeyToGoFieldPath(nested.loopKey, nested.loopParam)
648
+ if (keyField) {
649
+ lines.push(`\t\tprops.${nested.name}s[i].BfDataKey = fmt.Sprint(${keyField})`)
650
+ needsFmt = true
651
+ }
652
+ lines.push(`\t}`)
653
+ }
654
+ return { lines, needsFmt }
655
+ }
656
+
554
657
  /**
555
658
  * Build Go struct field initializers from props.
556
659
  */
@@ -596,7 +699,7 @@ function buildGoPropsInit(
596
699
  // not the naive `Id`) — see `goMapLiteralFromObject`'s identical fix.
597
700
  const goField = capitalizeFieldName(key)
598
701
  if (typeof value === 'string') {
599
- lines.push(`\t\t${goField}: "${value}",`)
702
+ lines.push(`\t\t${goField}: ${goStringLit(value)},`)
600
703
  } else if (typeof value === 'number') {
601
704
  lines.push(`\t\t${goField}: ${value},`)
602
705
  } else if (typeof value === 'boolean') {
@@ -725,6 +828,20 @@ function goTypedMapSliceLiteralFromArray(arr: unknown[], elemType: string): stri
725
828
  * names. Only the keys the caller supplied are set, so an omitted optional prop
726
829
  * (e.g. `defaultOn` on the third toggle item) takes the Go zero value. (#1297)
727
830
  */
831
+ /**
832
+ * Emit a JS string as a Go interpreted string literal. JSON string
833
+ * escaping is a subset of Go's (`\"`, `\\`, `\n`, `\uXXXX` are all valid
834
+ * Go escapes), so `JSON.stringify` is a correct emitter — unlike the
835
+ * previous quote-only `.replace(/"/g, '\\"')`, it also survives
836
+ * backslashes, newlines, and control characters (data-point conformance
837
+ * caught the quote case: a `"` in a string prop broke the generated
838
+ * `main.go` at compile time). Lone surrogates emit as `\uDXXX`, which Go
839
+ * rejects — acceptable until a fixture needs malformed-UTF-16 props.
840
+ */
841
+ function goStringLit(v: string): string {
842
+ return JSON.stringify(v)
843
+ }
844
+
728
845
  function goStructLiteral(obj: Record<string, unknown>, typeName: string): string {
729
846
  const fields: string[] = []
730
847
  for (const [k, v] of Object.entries(obj)) {
@@ -733,7 +850,7 @@ function goStructLiteral(obj: Record<string, unknown>, typeName: string): string
733
850
  // adapter's own struct-literal baking (`parsed-literal-to-go.ts`)
734
851
  // sanitizes those to `DataX`, not `Data-x` (Copilot review, #2202).
735
852
  const goField = goFieldNameForKey(k)
736
- if (typeof v === 'string') fields.push(`${goField}: "${v.replace(/"/g, '\\"')}"`)
853
+ if (typeof v === 'string') fields.push(`${goField}: ${goStringLit(v)}`)
737
854
  else if (typeof v === 'number' || typeof v === 'boolean') fields.push(`${goField}: ${v}`)
738
855
  else if (v === null) fields.push(`${goField}: nil`)
739
856
  else if (Array.isArray(v)) fields.push(`${goField}: ${goArrayLiteralFromArray(v)}`)
@@ -745,7 +862,7 @@ function goStructLiteral(obj: Record<string, unknown>, typeName: string): string
745
862
  function goArrayLiteralFromArray(arr: unknown[]): string {
746
863
  const entries: string[] = []
747
864
  for (const v of arr) {
748
- if (typeof v === 'string') entries.push(`"${v.replace(/"/g, '\\"')}"`)
865
+ if (typeof v === 'string') entries.push(goStringLit(v))
749
866
  else if (typeof v === 'number') entries.push(String(v))
750
867
  else if (typeof v === 'boolean') entries.push(String(v))
751
868
  else if (v === null) entries.push('nil')
@@ -783,7 +900,7 @@ function goMapLiteralFromObject(
783
900
  // uses for exactly this key-to-Go-field sanitization.
784
901
  const emittedKey = capitalizeKeys ? goFieldNameForKey(k) : k
785
902
  const key = JSON.stringify(emittedKey)
786
- if (typeof v === 'string') entries.push(`${key}: "${v.replace(/"/g, '\\"')}"`)
903
+ if (typeof v === 'string') entries.push(`${key}: ${goStringLit(v)}`)
787
904
  else if (typeof v === 'number') entries.push(`${key}: ${v}`)
788
905
  else if (typeof v === 'boolean') entries.push(`${key}: ${v}`)
789
906
  else if (v === null) entries.push(`${key}: nil`)