@barefootjs/xslate 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.
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Serialize a compile-time-evaluated JS value (`@barefootjs/jsx`'s
3
+ * `evaluateStaticLiteral`/`resolveStaticLoopSource`, #2208) into a native
4
+ * Text::Xslate (Kolon) literal. Used to inline a fully-static loop source
5
+ * (an inline array literal, or a function-scope local const with a static
6
+ * initializer) directly in a `for EXPR -> $item { ... }` header, rather
7
+ * than requiring a bound template variable.
8
+ *
9
+ * Booleans deliberately return `null` (defer to the caller's BF101
10
+ * refusal) rather than baking a `1`/absent-value stand-in — Kolon has no
11
+ * native boolean literal in this position, and guessing one would diverge
12
+ * from JS's `String(true) === "true"` at render.
13
+ *
14
+ * Returns `null` for a value this adapter can't represent as a literal —
15
+ * the caller falls back to its existing BF101 refusal instead of guessing.
16
+ */
17
+
18
+ import { escapeKolonSingleQuoted, kolonHashKey } from './kolon-naming.ts'
19
+
20
+ export function staticValueToKolon(value: unknown): string | null {
21
+ if (value === null || value === undefined) return 'nil'
22
+ if (typeof value === 'boolean') return null
23
+ if (typeof value === 'number') return String(value)
24
+ if (typeof value === 'string') return `'${escapeKolonSingleQuoted(value)}'`
25
+ if (Array.isArray(value)) {
26
+ const items: string[] = []
27
+ for (const el of value) {
28
+ const serialized = staticValueToKolon(el)
29
+ if (serialized === null) return null
30
+ items.push(serialized)
31
+ }
32
+ return `[${items.join(', ')}]`
33
+ }
34
+ if (typeof value === 'object') {
35
+ const entries: string[] = []
36
+ for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
37
+ const serialized = staticValueToKolon(val)
38
+ if (serialized === null) return null
39
+ entries.push(`${kolonHashKey(key)} => ${serialized}`)
40
+ }
41
+ return `{ ${entries.join(', ')} }`
42
+ }
43
+ return null
44
+ }
@@ -7,7 +7,7 @@
7
7
  * adapter's `props/prop-types.ts`. No adapter instance state.
8
8
  */
9
9
 
10
- import type { ComponentIR } from '@barefootjs/jsx'
10
+ import { collectLoopBoundNames, type ComponentIR } from '@barefootjs/jsx'
11
11
  import { isStringTypeInfo, isBareStringLiteral } from '../value/parsed-literal.ts'
12
12
 
13
13
  /**
@@ -43,12 +43,30 @@ export function collectNullableOptionalProps(ir: ComponentIR): Set<string> {
43
43
  }
44
44
 
45
45
  /**
46
- * String-typed signals and props. A signal is string-typed when its inferred
47
- * type is `string` (or, defensively, when its initial value is a bare string
48
- * literal); a prop when its annotated type is `string`. In the Mojo adapter
49
- * this drives `eq`/`ne` selection for string equality; the Kolon emitters
50
- * don't consume the distinction (Kolon's `==`/`!=` compare strings and numbers
51
- * correctly), so this set is carried for parity with the Mojo adapter.
46
+ * String-typed signals, props, and same-file local consts (#2212). A
47
+ * signal is string-typed when its inferred type is `string` (or,
48
+ * defensively, when its initial value is a bare string literal); a prop
49
+ * when its annotated type is `string`; a local const the same way. Consumed
50
+ * by `isStringConcatBinary`/`isStringTypedOperand` (`@barefootjs/jsx`) to
51
+ * pick Kolon's `~` over JS `+`'s numeric fallback (#2163, #2212)
52
+ * including now for a bare identifier operand, not just a prop/getter/
53
+ * literal. In the Mojo adapter this ALSO drives `eq`/`ne` selection for
54
+ * string equality; the Kolon emitters don't consume that distinction
55
+ * (Kolon's `==`/`!=` compare strings and numbers correctly), so that half
56
+ * of this set is carried only for parity with the Mojo adapter.
57
+ *
58
+ * Excludes any name bound as a `.map()`/`.filter()` loop callback's item
59
+ * or index parameter ANYWHERE in the component (Fable review, #2212): the
60
+ * lookup below is a flat, scope-blind `Set<string>` with no notion of a
61
+ * loop param shadowing an outer string-typed binding of the same name
62
+ * (`items.map((name) => 1 + name)` inside a component that also has a
63
+ * string `name` prop) — left unguarded, that shadowed `name` would be
64
+ * misdetected as string-typed and `1 + name` would silently lower to `~`
65
+ * instead of staying numeric `+`. Subtracting loop-bound names is coarse
66
+ * (it also suppresses a genuinely non-shadowed same-named string
67
+ * elsewhere in the component) but safe: the suppressed case just falls
68
+ * back to today's numeric `+` — the same, already-accepted residual as an
69
+ * unresolvable operand — never silently-wrong output.
52
70
  */
53
71
  export function collectStringValueNames(ir: ComponentIR): Set<string> {
54
72
  const names = new Set<string>()
@@ -60,5 +78,9 @@ export function collectStringValueNames(ir: ComponentIR): Set<string> {
60
78
  for (const p of ir.metadata.propsParams) {
61
79
  if (isStringTypeInfo(p.type)) names.add(p.name)
62
80
  }
81
+ for (const c of ir.metadata.localConstants) {
82
+ if (isStringTypeInfo(c.type ?? undefined) || isBareStringLiteral(c.value)) names.add(c.name)
83
+ }
84
+ for (const bound of collectLoopBoundNames(ir)) names.delete(bound)
63
85
  return names
64
86
  }
@@ -73,11 +73,17 @@ import {
73
73
  isValidHelperId,
74
74
  sortComparatorFromArrow,
75
75
  isLowerableLoopDestructure,
76
+ isDangerousInnerHtmlAttr,
77
+ resolveDangerousInnerHtml,
78
+ dangerousInnerHtmlMetacharViolation,
79
+ dangerousInnerHtmlDiagnostic,
80
+ resolveStaticLoopSource,
81
+ collectLoopBoundNames,
76
82
  } from '@barefootjs/jsx'
77
83
  import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result.ts'
78
84
  import ts from 'typescript'
79
85
  import type { ParsedExpr, LoweringMatcher } from '@barefootjs/jsx'
80
- import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
86
+ import { BF_SLOT, BF_COND, BF_REGION, escapeHtml } from '@barefootjs/shared'
81
87
 
82
88
  import type { XslateRenderCtx } from './lib/types.ts'
83
89
  import { XSLATE_PRIMITIVE_EMIT_MAP } from './lib/constants.ts'
@@ -87,6 +93,7 @@ import {
87
93
  collectRootScopeNodes,
88
94
  } from './lib/ir-scope.ts'
89
95
  import { renderSortMethod, renderSortEval } from './expr/array-method.ts'
96
+ import { staticValueToKolon } from './lib/static-value.ts'
90
97
  import { XslateFilterEmitter, XslateTopLevelEmitter } from './expr/emitters.ts'
91
98
  import type { XslateEmitContext, XslateSpreadContext, XslateMemoContext } from './emit-context.ts'
92
99
  import {
@@ -176,6 +183,14 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
176
183
  private options: Required<XslateAdapterOptions>
177
184
  private errors: CompilerError[] = []
178
185
  private inLoop: boolean = false
186
+ /**
187
+ * `IRLoop.depth` of the loop currently being rendered (save/restore
188
+ * around `renderChildren(loop.children)`, mirroring `inLoop` above).
189
+ * `renderAttributes` reads this to derive the `key` → `data-key`/
190
+ * `data-key-N` suffix — the depth is IR-computed (jsx-to-ir.ts), not
191
+ * re-derived here (#2168 nested-loop-outer-binding).
192
+ */
193
+ private currentLoopKeyDepth = 0
179
194
  /**
180
195
  * SolidJS-style props identifier (`function(props: P)`) and the
181
196
  * analyzer-extracted prop names. Stashed at `generate()` entry so the
@@ -232,6 +247,17 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
232
247
  */
233
248
  private localConstants: IRMetadata['localConstants'] = []
234
249
 
250
+ /**
251
+ * Every name a `.map()`/`.filter()` loop callback binds as its item/index
252
+ * parameter anywhere in the component (#2208 fable review). A static
253
+ * loop-SOURCE name (e.g. a function-scope `const items = [...]`) must
254
+ * never resolve through `resolveStaticLoopSource` at a use site where a
255
+ * DIFFERENT, enclosing loop's own callback param shadows it — same
256
+ * shadowing hazard, and same coarse-but-safe mitigation, as #2212's
257
+ * `collectLoopBoundNames` use in `collectStringValueNames`.
258
+ */
259
+ private staticLoopSourceBoundNames: Set<string> = new Set()
260
+
235
261
  /**
236
262
  * Optional, no-default props that are `undef` when the caller omits them.
237
263
  * Their bare-reference attribute emission is guarded with Kolon `defined` so
@@ -264,6 +290,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
264
290
  // Per-compile prop classifications (see `props/prop-classes.ts`).
265
291
  this.booleanTypedProps = collectBooleanTypedProps(ir)
266
292
  this.localConstants = ir.metadata.localConstants ?? []
293
+ this.staticLoopSourceBoundNames = collectLoopBoundNames(ir)
267
294
  this.nullableOptionalProps = collectNullableOptionalProps(ir)
268
295
  this.stringValueNames = collectStringValueNames(ir)
269
296
  this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
@@ -375,7 +402,9 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
375
402
  }
376
403
 
377
404
  emitText(node: IRText): string {
378
- return node.value
405
+ // IRText carries the entity-DECODED value (Phase 1 decodes JSX
406
+ // character references); re-escape for direct HTML emission.
407
+ return escapeHtml(node.value)
379
408
  }
380
409
 
381
410
  emitExpression(node: IRExpression): string {
@@ -484,7 +513,8 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
484
513
  renderElement(element: IRElement): string {
485
514
  const tag = element.tag
486
515
  const attrs = this.renderAttributes(element)
487
- const children = this.renderChildren(element.children)
516
+ const dangerousHtml = this.renderDangerousInnerHtml(element)
517
+ const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children)
488
518
 
489
519
  let hydrationAttrs = ''
490
520
  if (element.needsScope) {
@@ -519,6 +549,28 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
519
549
  return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
520
550
  }
521
551
 
552
+ /**
553
+ * `dangerouslySetInnerHTML={{ __html: '...' }}` (#2207) — see the Blade
554
+ * adapter's identical helper for the full rationale. `null` means the
555
+ * attribute is absent (caller falls through to normal `renderChildren`);
556
+ * a non-`null` string (possibly `''`) replaces the children outright.
557
+ */
558
+ private renderDangerousInnerHtml(element: IRElement): string | null {
559
+ const resolution = resolveDangerousInnerHtml(element)
560
+ if (!resolution) return null
561
+ if (resolution.kind === 'dynamic') {
562
+ this.errors.push(dangerousInnerHtmlDiagnostic(resolution.expr, resolution.loc))
563
+ return ''
564
+ }
565
+ const violation = dangerousInnerHtmlMetacharViolation(resolution.html, this.name)
566
+ if (violation) {
567
+ const attr = element.attrs.find(isDangerousInnerHtmlAttr)!
568
+ this.errors.push(dangerousInnerHtmlDiagnostic(`{ __html: ${JSON.stringify(resolution.html)} }`, attr.loc, violation))
569
+ return ''
570
+ }
571
+ return resolution.html
572
+ }
573
+
522
574
  // ===========================================================================
523
575
  // Expression Rendering
524
576
  // ===========================================================================
@@ -531,7 +583,12 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
531
583
  return ''
532
584
  }
533
585
 
534
- const perlExpr = this.convertExpressionToKolon(expr.expr)
586
+ // Thread the IR-carried `.parsed` tree through (mirrors go-template's
587
+ // `convertExpressionToGo(expr.expr, classify, expr.parsed)`) so a
588
+ // resolved bare-identifier `.map`/`.filter`/… callback
589
+ // (`resolveCallbackMethodFunctionReferences`, #2206) isn't lost to a
590
+ // fresh, unresolved re-parse of the raw string.
591
+ const perlExpr = this.convertExpressionToKolon(expr.expr, expr.parsed)
535
592
 
536
593
  if (expr.slotId) {
537
594
  return `<: $bf.text_start("${expr.slotId}") | mark_raw :><: ${perlExpr} :><: $bf.text_end() | mark_raw :>`
@@ -677,8 +734,27 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
677
734
  // this adapter's test corpus only because the widened destructure gate
678
735
  // (#2087 Phase A/B) no longer refuses this fixture's `([emoji, users])
679
736
  // => ...` param first.
737
+ // #2208: a loop source that is a fully-static array literal — either
738
+ // inline (`[{ label: 'Alpha' }, ...].map(...)`) or a bare identifier
739
+ // bound to a FUNCTION-scope local const whose initializer has no
740
+ // prop/signal/function-call dependency — inlines as a native Kolon
741
+ // array/hash literal below, the same way a module-scope const's value
742
+ // is already seeded. A runtime-computed local (#2069, e.g.
743
+ // `Object.entries(props.tags).filter(...)`) still refuses below.
744
+ // `isNameShadowed` guards a DIFFERENT, enclosing loop's own callback
745
+ // param shadowing this identifier (fable review) — never resolve the
746
+ // static const in that case. `rawArray` then falls through to the
747
+ // bare identifier expression below, same as before #2208 — which
748
+ // still trips the pre-existing BF101 gate for an unresolvable local
749
+ // const reference (a loud, conservative refusal, not a silent wrong
750
+ // value).
751
+ const staticItems = resolveStaticLoopSource(loop.arrayParsed, this.localConstants, {
752
+ isNameShadowed: name => this.staticLoopSourceBoundNames.has(name),
753
+ })
754
+ const staticArray = staticItems !== null ? staticValueToKolon(staticItems) : null
755
+
680
756
  const arrayName = loop.array.trim()
681
- if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
757
+ if (staticArray === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
682
758
  const arrayConst = (this.localConstants ?? []).find(c => c.name === arrayName)
683
759
  if (arrayConst && !arrayConst.isModule && this._resolveLiteralConst(arrayName) === null) {
684
760
  this.errors.push({
@@ -694,7 +770,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
694
770
  }
695
771
  }
696
772
 
697
- const rawArray = this.convertExpressionToKolon(loop.array)
773
+ const rawArray = staticArray ?? this.convertExpressionToKolon(loop.array)
698
774
  // Apply sort if present: wrap the loop array in the shared `$bf.sort`
699
775
  // helper, binding the sorted result to a per-iteration local so the
700
776
  // helper runs once.
@@ -724,9 +800,13 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
724
800
  // For `keys`-shape iterations the callback param IS the index. We iterate
725
801
  // the array but bind the loop var to a throwaway and expose the index as
726
802
  // `$param`. Kolon's `$~loopvar.index` provides the 0-based index.
727
- const loopVar = loop.iterationShape === 'keys'
728
- ? '__bf_item'
729
- : supportableDestructure ? '__bf_item' : param
803
+ const loopVar = loop.objectIteration === 'entries'
804
+ ? '__bf_pair'
805
+ : loop.objectIteration
806
+ ? param
807
+ : loop.iterationShape === 'keys'
808
+ ? '__bf_item'
809
+ : supportableDestructure ? '__bf_item' : param
730
810
 
731
811
  // Index alias: when an explicit `index` param is present (`.map((x, i) =>
732
812
  // ...)`) or the iteration is `keys`-shaped, expose it via a `: my` Kolon
@@ -746,7 +826,16 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
746
826
  // `segments` (empty at the loop root, per the `LoopParamBinding` jsdoc) —
747
827
  // NOT the same as a fixed binding's full-accessor segments.
748
828
  const indexLocalLines: string[] = []
749
- if (loop.iterationShape === 'keys') {
829
+ if (loop.objectIteration === 'entries') {
830
+ // `key`/`value` bind off the `.kv()` pair (see the for-header below)
831
+ // — no derived `.index` local needed, unlike the array
832
+ // `iterationShape` cases.
833
+ indexLocalLines.push(`: my $${loop.index ?? param} = $${loopVar}.key;`)
834
+ indexLocalLines.push(`: my $${param} = $${loopVar}.value;`)
835
+ } else if (loop.objectIteration) {
836
+ // 'keys'/'values': `.keys()`/`.values()` already yield the bound
837
+ // value directly — no derived local needed either.
838
+ } else if (loop.iterationShape === 'keys') {
750
839
  indexLocalLines.push(`: my $${param} = $~${loopVar}.index;`)
751
840
  } else if (loop.index) {
752
841
  indexLocalLines.push(`: my $${loop.index} = $~${loopVar}.index;`)
@@ -769,10 +858,13 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
769
858
 
770
859
  const prevInLoop = this.inLoop
771
860
  this.inLoop = true
861
+ const prevLoopKeyDepth = this.currentLoopKeyDepth
862
+ this.currentLoopKeyDepth = loop.depth
772
863
  // Re-render children now that inLoop is set (so nested components use the
773
864
  // loop-child naming convention). renderedChildren above was computed with
774
865
  // the previous flag; recompute under the loop flag.
775
866
  const childrenUnderLoop = this.renderChildren(loop.children)
867
+ this.currentLoopKeyDepth = prevLoopKeyDepth
776
868
  this.inLoop = prevInLoop
777
869
  void renderedChildren
778
870
 
@@ -789,7 +881,21 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
789
881
  // Scoped per-call-site marker so sibling `.map()`s under the same parent
790
882
  // each get their own reconciliation range.
791
883
  lines.push(`<: $bf.comment("loop:${loop.markerId}") | mark_raw :>`)
792
- lines.push(`: for ${array} -> $${loopVar} {`)
884
+ // `objectIteration` (#2168 object-entries-map): Kolon has no built-in
885
+ // hash-destructure `for` target, so `'entries'` iterates `.kv()`
886
+ // (yielding `{key, value}` pair objects, unpacked via the `: my`
887
+ // locals above) while `'keys'`/`'values'` iterate `.keys()`/`.values()`
888
+ // directly. All three are alphabetically sorted by Text::Xslate itself
889
+ // (verified empirically) — not JS insertion order, a documented known
890
+ // limitation for out-of-alphabetical-order data, same as Go/Rust.
891
+ const forHeader = loop.objectIteration === 'entries'
892
+ ? `: for ${array}.kv() -> $${loopVar} {`
893
+ : loop.objectIteration === 'keys'
894
+ ? `: for ${array}.keys() -> $${loopVar} {`
895
+ : loop.objectIteration === 'values'
896
+ ? `: for ${array}.values() -> $${loopVar} {`
897
+ : `: for ${array} -> $${loopVar} {`
898
+ lines.push(forHeader)
793
899
  for (const il of indexLocalLines) lines.push(il)
794
900
 
795
901
  // Handle filter().map() pattern by wrapping children in if-condition
@@ -932,11 +1038,33 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
932
1038
  type Segment = { kind: 'entries'; parts: string[] } | { kind: 'spread'; expr: string }
933
1039
  const segments: Segment[] = [{ kind: 'entries', parts: [] }]
934
1040
  const currentEntries = () => this.componentPropSegmentEntries(segments)
1041
+ // Named JSX-valued props OTHER than the reserved `children`
1042
+ // (`header={<strong>Title</strong>}`, #2168 jsx-element-prop) each get
1043
+ // their own macro, prepended to the final returned string below —
1044
+ // same mechanism as the reserved children macro, just keyed by the
1045
+ // prop's own name instead of `children`.
1046
+ const namedSlotMacros: string[] = []
935
1047
 
936
1048
  for (const p of comp.props) {
937
1049
  // Skip callback props (onXxx) and `ref` — both are client-only for
938
1050
  // SSR (Hono renders neither; the client JS wires them at hydration).
939
1051
  if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
1052
+ if (p.value.kind === 'jsx-children' && p.name !== 'children') {
1053
+ const prevInLoop = this.inLoop
1054
+ this.inLoop = false
1055
+ const slotBody = this.renderChildren(p.value.children)
1056
+ this.inLoop = prevInLoop
1057
+ // Purely counter-based — NOT derived from `p.name` or `comp.slotId`.
1058
+ // A JSX prop name can contain characters (`data-slot`) that aren't a
1059
+ // valid Kolon macro identifier, and `comp.slotId` alone would
1060
+ // collide across two named-slot props on the same component
1061
+ // invocation (unlike the reserved children slot, there's only ever
1062
+ // one of those per invocation).
1063
+ const macroName = `bf_prop_${this.childrenCaptureCounter++}`
1064
+ namedSlotMacros.push(`<: macro ${macroName} -> () { :>${slotBody}<: } :>`)
1065
+ currentEntries().push(`${kolonHashKey(p.name)} => ${macroName}()`)
1066
+ continue
1067
+ }
940
1068
  if (p.value.kind === 'spread') {
941
1069
  const trimmed = p.value.expr.trim()
942
1070
  // SolidJS-style props identifier (`function(props: P)`) has no
@@ -996,12 +1124,12 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
996
1124
  const macroName = `bf_children_${comp.slotId ?? 'c' + this.childrenCaptureCounter++}`
997
1125
  currentEntries().push(`children => ${macroName}()`)
998
1126
  const dict = this.combineComponentPropSegments(segments)
999
- return `<: macro ${macroName} -> () { :>${childrenBody}<: } :><: $bf.render_child('${tplName}', ${dict}) | mark_raw :>`
1127
+ return `${namedSlotMacros.join('')}<: macro ${macroName} -> () { :>${childrenBody}<: } :><: $bf.render_child('${tplName}', ${dict}) | mark_raw :>`
1000
1128
  }
1001
1129
 
1002
1130
  const isEmpty = segments.every(s => s.kind === 'entries' && s.parts.length === 0)
1003
1131
  const hashEntries = isEmpty ? '' : `, ${this.combineComponentPropSegments(segments)}`
1004
- return `<: $bf.render_child('${tplName}'${hashEntries}) | mark_raw :>`
1132
+ return `${namedSlotMacros.join('')}<: $bf.render_child('${tplName}'${hashEntries}) | mark_raw :>`
1005
1133
  }
1006
1134
 
1007
1135
  private childrenCaptureCounter = 0
@@ -1085,7 +1213,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1085
1213
  * AttrValue lowering for intrinsic-element attributes (Kolon).
1086
1214
  */
1087
1215
  private readonly elementAttrEmitter: AttrValueEmitter = {
1088
- emitLiteral: (value, name) => `${name}="${value.value}"`,
1216
+ emitLiteral: (value, name) => `${name}="${escapeHtml(value.value)}"`,
1089
1217
  emitExpression: (value, name) => {
1090
1218
  // `style={{ … }}` object literal → a CSS string with dynamic values
1091
1219
  // interpolated, instead of refusing the bare object with BF101 (#1322).
@@ -1282,10 +1410,19 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1282
1410
  // the unsupported-expression lowering is never reached for a deferred
1283
1411
  // predicate (no BF101 / BF102). #1966
1284
1412
  if (attr.clientOnly) continue
1413
+ // `dangerouslySetInnerHTML` never renders as an HTML attribute — it's
1414
+ // handled by `renderDangerousInnerHtml` instead, which replaces the
1415
+ // element's children. Skip it here so its `{ __html: ... }` object
1416
+ // literal never reaches the generic object-literal BF101 refusal
1417
+ // (which would double-report alongside the purpose-built one).
1418
+ if (isDangerousInnerHtmlAttr(attr)) continue
1285
1419
  // Rewrite JSX special-prop names to their HTML-attribute counterparts.
1286
1420
  let attrName: string
1287
1421
  if (attr.name === 'className') attrName = 'class'
1288
- else if (attr.name === 'key') attrName = 'data-key'
1422
+ else if (attr.name === 'key') {
1423
+ const depth = this.currentLoopKeyDepth
1424
+ attrName = depth > 0 ? `data-key-${depth}` : 'data-key'
1425
+ }
1289
1426
  else attrName = attr.name
1290
1427
  const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
1291
1428
  if (lowered) parts.push(lowered)
@@ -1607,8 +1744,19 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1607
1744
  * single-quoted string literal (`const totalPages = 5`, #1897
1608
1745
  * pagination) — function-scope consts never reach the per-render
1609
1746
  * stash, so a bare `$totalPages` renders empty.
1747
+ *
1748
+ * The lookup is a flat name match with no notion of AST scope, so a
1749
+ * name that any loop callback binds as its item/index param never
1750
+ * inlines (#2221) — the occurrence may be the loop's own (shadowing)
1751
+ * binding, and substituting the outer const's value there renders every
1752
+ * iteration with the same hard-coded literal. Coarse (a genuinely
1753
+ * non-shadowed same-named const elsewhere in the component also stops
1754
+ * inlining, falling back to the bare identifier) but safe — the same
1755
+ * trade-off as #2212's `collectLoopBoundNames` use in
1756
+ * `collectStringValueNames`.
1610
1757
  */
1611
1758
  private _resolveLiteralConst(name: string): string | null {
1759
+ if (this.staticLoopSourceBoundNames.has(name)) return null
1612
1760
  const c = (this.localConstants ?? []).find(lc => lc.name === name)
1613
1761
  if (c?.value === undefined) return null
1614
1762
  const v = c.value.trim()
@@ -1618,7 +1766,22 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1618
1766
  return null
1619
1767
  }
1620
1768
 
1769
+ /**
1770
+ * Resolve `IDENT.key` where `IDENT` is a module-scope object-literal const
1771
+ * (`variantClasses.ghost`, #1896/#1897) to the looked-up scalar.
1772
+ *
1773
+ * The lookup is a flat name match on `objectName` with no notion of AST
1774
+ * scope, so an enclosing loop callback's own param of the same name
1775
+ * (`.map((cfg) => <li>{cfg.x}</li>)` shadowing a module `const cfg = {…}`)
1776
+ * still resolved to the OUTER const's member value at every iteration
1777
+ * (#2237) — the sibling hazard to #2221's `_resolveLiteralConst`. Same
1778
+ * coarse-but-safe `staticLoopSourceBoundNames` guard: any name a loop
1779
+ * binds anywhere in the component never inlines, falling back to the bare
1780
+ * `$cfg.x` member expression (which an Xslate `: for` loop binds
1781
+ * correctly at the shadowed occurrences).
1782
+ */
1621
1783
  private _resolveStaticRecordLiteral(objectName: string, key: string): string | null {
1784
+ if (this.staticLoopSourceBoundNames.has(objectName)) return null
1622
1785
  const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
1623
1786
  if (!hit) return null
1624
1787
  return hit.kind === 'number'
@@ -9,17 +9,17 @@
9
9
  import type { ConformancePins } from '@barefootjs/jsx'
10
10
 
11
11
  export const conformancePins: ConformancePins = {
12
- // Sibling-imported child component in a loop body: emits a
13
- // cross-template call needing separate registration. BF103 makes
14
- // the requirement loud (same as mojo).
15
- 'static-array-children': [{ code: 'BF103', severity: 'error' }],
16
- // TodoApp / TodoAppSSR import `TodoItem` from a sibling file and
17
- // call it inside a keyed `.map`. With the standalone-filter fix in
18
- // place these reach the SAME BF103 (imported child in `.map`) as
19
- // mojo NOT BF101 confirming the `.filter(...)` chain itself now
20
- // lowers and the only remaining gate is the imported-child one.
21
- 'todo-app': [{ code: 'BF103', severity: 'error' }],
22
- 'todo-app-ssr': [{ code: 'BF103', severity: 'error' }],
12
+ // `todo-app` / `todo-app-ssr` no longer pinned (#2205) the conformance
13
+ // harness now passes `siblingTemplatesRegistered: true` for fixtures with
14
+ // sibling `components`, matching `bf build`'s real semantics, so the
15
+ // BF103 loop-body cross-template check no longer fires spuriously. (Both
16
+ // fixtures are still skipped on this adapter via `render-divergences.ts`
17
+ // #2209 for an unrelated signal-seeding gap.)
18
+ // `static-array-children` no longer pinned (#2208) `items`'s
19
+ // array-literal initializer is now recognized as fully-static
20
+ // (`resolveStaticLoopSource`) and inlined as a native Kolon array/hash
21
+ // literal in the `for EXPR -> $item` header, the same way a module-scope
22
+ // const's value is already seeded.
23
23
  // `([emoji, users]) => ...` / `([id, t]) => ...` are plain array-index
24
24
  // (tuple) destructures, no rest — #2087 Phase B's `segments`-walking
25
25
  // accessor lowers both to `$__bf_item[0]` / `$__bf_item[1]` `: my` locals
@@ -32,12 +32,11 @@ export const conformancePins: ConformancePins = {
32
32
  // simply unreachable before because BF104 refused the destructure shape
33
33
  // first.
34
34
  'static-array-from-props': [{ code: 'BF101', severity: 'error' }],
35
- // Both BF103 (sibling-imported `<Tag>` child component) and the BF101
36
- // above fire; BF104 no longer does (see above).
37
- 'static-array-from-props-with-component': [
38
- { code: 'BF103', severity: 'error' },
39
- { code: 'BF101', severity: 'error' },
40
- ],
35
+ // The BF101 above fires; BF104 no longer does (see above), and BF103
36
+ // (sibling-imported `<Tag>` child component in the loop body) no longer
37
+ // does either now that the conformance harness passes
38
+ // `siblingTemplatesRegistered: true` (#2205).
39
+ 'static-array-from-props-with-component': [{ code: 'BF101', severity: 'error' }],
41
40
  // #1310 / #2087: rest destructure in .map() callback. All four shapes now
42
41
  // lower via #2087 Phase B's `segments`-walking accessor:
43
42
  // - object-rest read via member access (`rest-destructure-object-in-map`):
@@ -104,20 +103,14 @@ export const conformancePins: ConformancePins = {
104
103
  // `find_last_index` via the same Kolon-lambda mechanism as `.filter` /
105
104
  // `.every` / `.some`, so they render. Only the NESTED-in-a-predicate form
106
105
  // above is refused (#2038).
107
- // #2073 follow-up: a function-reference `.map(format)` callback has no
108
- // arrow body to serialize not a CALLBACK_METHODS shape — so the
109
- // UNSUPPORTED_METHODS gate refuses it with BF101 rather than emitting
110
- // a broken template.
111
- 'array-map-function-reference': [{ code: 'BF101', severity: 'error' }],
112
- // Edge-case sweep (Priority 12): `dangerouslySetInnerHTML` requires a
113
- // deliberate raw-HTML (unescaped) output affordance in the target
114
- // template language. No lowering exists yet, so the compiler refuses
115
- // the shape loudly instead of emitting entity-escaped markup that
116
- // silently renders tags as text.
117
- 'dangerous-inner-html': [{ code: 'BF101', severity: 'error' }],
118
- // Edge-case sweep (Priority 12): `.replaceAll` has no lowering yet —
119
- // only first-occurrence `.replace` is wired to the runtime helpers.
120
- // Refused with BF101 rather than reusing the first-only lowering,
121
- // which would silently change semantics.
122
- 'string-replaceall': [{ code: 'BF101', severity: 'error' }],
106
+ // `array-map-function-reference` no longer pinned — a bare-identifier
107
+ // `.map(format)` callback now resolves one hop to its declaration
108
+ // (`resolveCallbackMethodFunctionReferences`, #2206), the same mechanism
109
+ // #2090 established for `.sort(fnref)`.
110
+ // `dangerous-inner-html` no longer pinned a compile-time string-literal
111
+ // `dangerouslySetInnerHTML={{ __html: '...' }}` is spliced directly into
112
+ // the template as trusted raw text (`resolveDangerousInnerHtml`, #2207).
113
+ // A dynamic/signal-derived value still refuses with BF101 see the
114
+ // `dangerous-inner-html-dynamic` fixture/pin below (tracked: #2215).
115
+ 'dangerous-inner-html-dynamic': [{ code: 'BF101', severity: 'error', issue: 'https://github.com/piconic-ai/barefootjs/issues/2215' }],
123
116
  }
@@ -15,20 +15,8 @@
15
15
  import type { RenderDivergences } from '@barefootjs/jsx'
16
16
 
17
17
  export const renderDivergences: RenderDivergences = {
18
- 'html-entity-text':
19
- '`&copy;` in JSX literal text: Hono decodes to `©`, this adapter re-emits the raw entity — same DOM, different bytes',
20
- 'math-methods':
21
- 'Math.min/max/abs over a signal render empty (only Math.floor is in the template-primitive registry)',
22
- 'static-attr-escape':
23
- 'static attribute values are not HTML-escaped (`title="Fish & Chips"` emitted raw; Hono escapes)',
24
- 'object-entries-map':
25
- '`Object.entries(prop).map(([k, v]) => …)` renders an EMPTY list — the object-shaped prop silently produces zero iterations',
26
- 'nested-loop-outer-binding':
27
- 'nested-loop inner items carry `data-key` where the reference emits the depth-suffixed `data-key-1`',
28
- 'jsx-element-prop':
29
- 'a JSX element passed as a NON-children prop renders an empty slot — the element value is silently dropped',
30
- 'string-slice':
31
- '`.slice()` on a STRING misfires through the array slice helper',
32
- 'string-trim-sided':
33
- '`.trimStart()` / `.trimEnd()` render empty (no lowering)',
18
+ // `todo-app` / `todo-app-ssr` no longer diverge (#2209) — the shared
19
+ // `evaluateSignalInit` (`@barefootjs/jsx`, sandboxed real-JS evaluation
20
+ // instead of a fixed regex-shape catalogue) now correctly seeds `todos`
21
+ // from `(props.initialTodos ?? []).map(t => ({ ...t, editing: false }))`.
34
22
  }