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