@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.
- package/dist/adapter/analysis/static-child-loop-bake.d.ts +61 -0
- package/dist/adapter/analysis/static-child-loop-bake.d.ts.map +1 -0
- package/dist/adapter/analysis/static-element-loop-bake.d.ts +83 -0
- package/dist/adapter/analysis/static-element-loop-bake.d.ts.map +1 -0
- package/dist/adapter/emit-context.d.ts +11 -5
- package/dist/adapter/emit-context.d.ts.map +1 -1
- package/dist/adapter/go-template-adapter.d.ts +153 -6
- package/dist/adapter/go-template-adapter.d.ts.map +1 -1
- package/dist/adapter/index.js +514 -53
- package/dist/adapter/lib/compile-state.d.ts +24 -0
- package/dist/adapter/lib/compile-state.d.ts.map +1 -1
- package/dist/adapter/lib/types.d.ts +9 -0
- package/dist/adapter/lib/types.d.ts.map +1 -1
- package/dist/adapter/props/prop-classes.d.ts +28 -9
- package/dist/adapter/props/prop-classes.d.ts.map +1 -1
- package/dist/adapter/props/prop-types.d.ts +45 -0
- package/dist/adapter/props/prop-types.d.ts.map +1 -1
- package/dist/build.js +514 -53
- package/dist/conformance-pins.d.ts.map +1 -1
- package/dist/index.js +516 -62
- package/dist/render-divergences.d.ts.map +1 -1
- package/dist/test-render.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/go-template-adapter.test.ts +876 -21
- package/src/adapter/analysis/static-child-loop-bake.ts +119 -0
- package/src/adapter/analysis/static-element-loop-bake.ts +211 -0
- package/src/adapter/emit-context.ts +14 -5
- package/src/adapter/go-template-adapter.ts +620 -45
- package/src/adapter/lib/compile-state.ts +27 -0
- package/src/adapter/lib/types.ts +9 -0
- package/src/adapter/props/prop-classes.ts +34 -9
- package/src/adapter/props/prop-types.ts +178 -1
- package/src/adapter/value/value-lowering.ts +1 -1
- package/src/conformance-pins.ts +30 -31
- package/src/render-divergences.ts +12 -0
- 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
|
package/src/adapter/lib/types.ts
CHANGED
|
@@ -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
|
|
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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
}
|
package/src/conformance-pins.ts
CHANGED
|
@@ -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
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
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` — #2209 — for 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
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
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
|
}
|
package/src/test-render.ts
CHANGED
|
@@ -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
|
-
|
|
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}:
|
|
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}:
|
|
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(
|
|
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}:
|
|
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`)
|