@barefootjs/rust 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.
@@ -385,8 +385,148 @@ export function C(props: { count: number }) {
385
385
  })
386
386
  })
387
387
 
388
+ describe('MinijinjaAdapter - named-slot capture identifier safety (#2168 jsx-element-prop)', () => {
389
+ // A JSX-valued prop under a hyphenated name (`data-slot`, a valid JSX
390
+ // attribute name) must not leak into the `{% set %}` capture variable's
391
+ // identifier — minijinja variable names can't contain `-`. The capture
392
+ // identifier is purely counter-based (never derived from the prop name);
393
+ // the hash KEY passed to `render_child` still carries the real name,
394
+ // quoted via `minijinjaHashKey`.
395
+ test('a hyphenated prop name does not appear in the capture variable', () => {
396
+ const { template } = compileAndGenerate(`
397
+ function Card(props) { return null }
398
+ export function Parent() {
399
+ return <Card data-slot={<strong>Title</strong>}>text</Card>
400
+ }
401
+ `)
402
+ expect(template).toContain('{% set bf_prop_0 %}')
403
+ expect(template).toContain("'data-slot': bf_prop_0")
404
+ expect(template).not.toContain('data-slot %}')
405
+ expect(template).not.toContain('data-slot_')
406
+ })
407
+ })
408
+
388
409
  // #2038 nested-callback-predicate loudness is pinned at the shared
389
410
  // conformance layer (workstream C): `filter-nested-callback-predicate` /
390
411
  // `filter-nested-find-predicate` (BF101 via `expectedDiagnostics`) and
391
412
  // `filter-nested-callback-predicate-client` (the `/* @client */` suppression
392
413
  // twin, which must render clean).
414
+
415
+ // #2221: `_resolveLiteralConst` is a flat name lookup against
416
+ // `ir.metadata.localConstants` with no notion of AST scope — it used to
417
+ // substitute an outer const's literal value even at an occurrence that is
418
+ // actually an enclosing loop callback's own (shadowing) parameter, so every
419
+ // iteration rendered the same hard-coded literal. Guarded with the same
420
+ // coarse `collectLoopBoundNames` exclusion as #2212: any name a loop binds
421
+ // anywhere in the component never inlines, falling back to the bare
422
+ // identifier.
423
+ describe('MinijinjaAdapter - const inlining vs loop-param shadowing (#2221)', () => {
424
+ test('a loop param shadowing an outer literal const emits the identifier, not the const value', () => {
425
+ const { template } = compileAndGenerate(`
426
+ function Widget() {
427
+ const label: string = 'x'
428
+ return <ul>{[2, 5].map((label) => <li key={label}>{1 + label}</li>)}</ul>
429
+ }
430
+ `)
431
+ // The loop body must reference the per-iteration loop var...
432
+ expect(template).toContain('1 + label')
433
+ // ...never the outer const's hard-coded value.
434
+ expect(template).not.toContain("1 + 'x'")
435
+ })
436
+
437
+ test('a numeric const shadowed by a loop param emits the identifier too', () => {
438
+ const { template } = compileAndGenerate(`
439
+ function Widget() {
440
+ const count = 7
441
+ return <ul>{[2, 5].map((count) => <li key={count}>{1 + count}</li>)}</ul>
442
+ }
443
+ `)
444
+ expect(template).toContain('1 + count')
445
+ expect(template).not.toContain('1 + 7')
446
+ })
447
+
448
+ test('a literal const NOT shadowed by any loop still inlines (#1897 pin)', () => {
449
+ const { template } = compileAndGenerate(`
450
+ function Widget({ values }: { values: number[] }) {
451
+ const totalPages = 5
452
+ return <div>
453
+ <p>Page 1 of {1 + totalPages}</p>
454
+ <ul>{values.map((v) => <li key={v}>{v}</li>)}</ul>
455
+ </div>
456
+ }
457
+ `)
458
+ expect(template).toContain('1 + 5')
459
+ })
460
+
461
+ // The accepted coarse-exclusion trade-off (same as #2212): a name that is
462
+ // loop-bound ANYWHERE in the component never inlines, even at a genuinely
463
+ // non-shadowed occurrence outside the loop — the bare identifier is
464
+ // emitted instead of the value.
465
+ test('a const referenced outside the loop whose name is loop-bound elsewhere falls back to the identifier (accepted trade-off)', () => {
466
+ const { template } = compileAndGenerate(`
467
+ function Widget({ values }: { values: number[] }) {
468
+ const label: string = 'x'
469
+ return <div>
470
+ <p>{1 + label}</p>
471
+ <ul>{values.map((label) => <li key={label}>{2 + label}</li>)}</ul>
472
+ </div>
473
+ }
474
+ `)
475
+ expect(template).not.toContain("1 + 'x'")
476
+ expect(template).toContain('2 + label')
477
+ })
478
+ })
479
+
480
+ // #2237: `_resolveStaticRecordLiteral` (`IDENT.key` on a module-scope
481
+ // object-literal const, e.g. `variantClasses.ghost` — #1896/#1897) is a
482
+ // flat name lookup on `objectName` with no notion of AST scope, the
483
+ // record-literal sibling of #2221's `_resolveLiteralConst` bug. It used to
484
+ // substitute the outer const's member value even at an occurrence that is
485
+ // actually an enclosing loop callback's own (shadowing) parameter, so every
486
+ // iteration rendered the same hard-coded literal instead of the per-item
487
+ // value. Guarded with the same coarse `staticLoopSourceBoundNames`
488
+ // exclusion as #2221: any name a loop binds anywhere in the component
489
+ // never inlines, falling back to the bare `cfg.x` member expression.
490
+ describe('MinijinjaAdapter - record-literal member lookup vs loop-param shadowing (#2237)', () => {
491
+ test('a loop param shadowing an outer module object const emits the member access, not the outer literal', () => {
492
+ const { template } = compileAndGenerate(`
493
+ const cfg = { x: 'outer-lit' }
494
+ function Widget({ rows }: { rows: { x: string }[] }) {
495
+ return <ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
496
+ }
497
+ `)
498
+ // The loop body must reference the per-iteration member access...
499
+ expect(template).toContain('bf.string(cfg.x)')
500
+ // ...never the outer const's hard-coded value.
501
+ expect(template).not.toContain("bf.string('outer-lit')")
502
+ })
503
+
504
+ test('a module object const NOT shadowed by any loop still inlines (variantClasses.ghost shape, #1896/#1897 pin)', () => {
505
+ const { template } = compileAndGenerate(`
506
+ const variantClasses = { solid: 'bg-solid', ghost: 'bg-ghost' }
507
+ function Widget({ variant }: { variant: 'solid' | 'ghost' }) {
508
+ return <div>{variantClasses.ghost}</div>
509
+ }
510
+ `)
511
+ expect(template).toContain("bf.string('bg-ghost')")
512
+ })
513
+
514
+ // The accepted coarse-exclusion trade-off (same as #2221/#2212): an
515
+ // object name that is loop-bound ANYWHERE in the component never
516
+ // inlines its member lookups, even at a genuinely non-shadowed
517
+ // occurrence outside the loop — the bare member expression is emitted
518
+ // instead of the value.
519
+ test('a record member referenced outside the loop whose object name is loop-bound elsewhere falls back to the member expression (accepted trade-off)', () => {
520
+ const { template } = compileAndGenerate(`
521
+ const cfg = { x: 'outer-lit' }
522
+ function Widget({ rows }: { rows: { x: string }[] }) {
523
+ return <div>
524
+ <p>{cfg.x}</p>
525
+ <ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
526
+ </div>
527
+ }
528
+ `)
529
+ expect(template).not.toContain("bf.string('outer-lit')")
530
+ expect(template).toContain('bf.string(cfg.x)')
531
+ })
532
+ })
@@ -91,6 +91,15 @@ export function renderArrayMethod(
91
91
  const recv = emit(object)
92
92
  return `bf.trim(${recv})`
93
93
  }
94
+ case 'trimStart':
95
+ case 'trimEnd': {
96
+ // `.trimStart()` / `.trimEnd()` — the one-sided siblings of
97
+ // `.trim()` (#2183 follow-up). Dedicated `bf.trim_start` /
98
+ // `bf.trim_end` helpers, not `bf.trim` with a flag.
99
+ const fn = method === 'trimStart' ? 'trim_start' : 'trim_end'
100
+ const recv = emit(object)
101
+ return `bf.${fn}(${recv})`
102
+ }
94
103
  case 'toFixed': {
95
104
  // `.toFixed(digits?)` — `bf.to_fixed` mirrors JS rounding +
96
105
  // zero-padding (default 0 digits). #1897.
@@ -126,6 +135,16 @@ export function renderArrayMethod(
126
135
  const newS = emit(args[1])
127
136
  return `bf.replace(${recv}, ${oldS}, ${newS})`
128
137
  }
138
+ case 'replaceAll': {
139
+ // `.replaceAll(old, new)` — string-pattern form, EVERY occurrence,
140
+ // via the dedicated `bf.replace_all` helper (not `bf.replace`
141
+ // with a flag) — the regex-pattern form is refused upstream at
142
+ // the parser, same as `.replace`. See #2182.
143
+ const recv = emit(object)
144
+ const oldS = emit(args[0])
145
+ const newS = emit(args[1])
146
+ return `bf.replace_all(${recv}, ${oldS}, ${newS})`
147
+ }
129
148
  case 'repeat': {
130
149
  const recv = emit(object)
131
150
  const count = args.length === 0 ? '0' : emit(args[0])
@@ -141,7 +141,7 @@ export class JinjaFilterEmitter implements ParsedExprEmitter {
141
141
  return String(value)
142
142
  }
143
143
 
144
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
144
+ member(object: ParsedExpr, property: string, _computed: boolean, _optional: boolean, emit: (e: ParsedExpr) => string): string {
145
145
  // `.length` — route through `bf.length` (handles both array element
146
146
  // count and string char count, JS-compatibly). Jinja's builtin
147
147
  // `|length` filter also faults trying to match JS semantics for every
@@ -313,7 +313,7 @@ export class JinjaTopLevelEmitter implements ParsedExprEmitter {
313
313
  return String(value)
314
314
  }
315
315
 
316
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
316
+ member(object: ParsedExpr, property: string, _computed: boolean, _optional: boolean, emit: (e: ParsedExpr) => string): string {
317
317
  // `props.x` flattens to the bare context var the SSR caller binds each
318
318
  // prop to (props arrive as individual top-level context entries, not a
319
319
  // nested `props` dict).
@@ -26,6 +26,9 @@ export const JINJA_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
26
26
  'Math.floor': { arity: 1, emit: (args) => `bf.floor(${args[0]})` },
27
27
  'Math.ceil': { arity: 1, emit: (args) => `bf.ceil(${args[0]})` },
28
28
  'Math.round': { arity: 1, emit: (args) => `bf.round(${args[0]})` },
29
+ 'Math.min': { arity: 2, emit: (args) => `bf.min(${args[0]}, ${args[1]})` },
30
+ 'Math.max': { arity: 2, emit: (args) => `bf.max(${args[0]}, ${args[1]})` },
31
+ 'Math.abs': { arity: 1, emit: (args) => `bf.abs(${args[0]})` },
29
32
  }
30
33
 
31
34
  /**
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Serialize a compile-time-evaluated JS value (`@barefootjs/jsx`'s
3
+ * `evaluateStaticLiteral`/`resolveStaticLoopSource`, #2208) into a native
4
+ * MiniJinja literal. Used to inline a fully-static loop source (an inline
5
+ * array literal, or a function-scope local const with a static
6
+ * initializer) directly in a `{% for %}` header, rather than requiring a
7
+ * bound template variable.
8
+ *
9
+ * Returns `null` for a value this adapter can't represent as a literal —
10
+ * the caller falls back to its existing BF101 refusal instead of guessing.
11
+ */
12
+
13
+ import { escapeMinijinjaSingleQuoted, minijinjaHashKey } from './minijinja-naming.ts'
14
+
15
+ export function staticValueToMinijinja(value: unknown): string | null {
16
+ if (value === null || value === undefined) return 'none'
17
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
18
+ if (typeof value === 'number') return String(value)
19
+ if (typeof value === 'string') return `'${escapeMinijinjaSingleQuoted(value)}'`
20
+ if (Array.isArray(value)) {
21
+ const items: string[] = []
22
+ for (const el of value) {
23
+ const serialized = staticValueToMinijinja(el)
24
+ if (serialized === null) return null
25
+ items.push(serialized)
26
+ }
27
+ return `[${items.join(', ')}]`
28
+ }
29
+ if (typeof value === 'object') {
30
+ const entries: string[] = []
31
+ for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
32
+ const serialized = staticValueToMinijinja(val)
33
+ if (serialized === null) return null
34
+ entries.push(`${minijinjaHashKey(key)}: ${serialized}`)
35
+ }
36
+ return `{${entries.join(', ')}}`
37
+ }
38
+ return null
39
+ }
@@ -165,10 +165,16 @@ import {
165
165
  queryHrefArgs,
166
166
  isValidHelperId,
167
167
  sortComparatorFromArrow,
168
+ isDangerousInnerHtmlAttr,
169
+ resolveDangerousInnerHtml,
170
+ dangerousInnerHtmlMetacharViolation,
171
+ dangerousInnerHtmlDiagnostic,
172
+ resolveStaticLoopSource,
173
+ collectLoopBoundNames,
168
174
  } from '@barefootjs/jsx'
169
175
  import { isAriaBooleanAttr, isBooleanResultExpr, isExplicitStringCall } from './boolean-result.ts'
170
176
  import type { ParsedExpr, LoweringMatcher, LoopBindingPathSegment } from '@barefootjs/jsx'
171
- import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
177
+ import { BF_SLOT, BF_COND, BF_REGION, escapeHtml } from '@barefootjs/shared'
172
178
 
173
179
  import type { JinjaRenderCtx } from './lib/types.ts'
174
180
  import { JINJA_PRIMITIVE_EMIT_MAP } from './lib/constants.ts'
@@ -178,6 +184,7 @@ import {
178
184
  collectRootScopeNodes,
179
185
  } from './lib/ir-scope.ts'
180
186
  import { renderSortMethod, renderSortEval } from './expr/array-method.ts'
187
+ import { staticValueToMinijinja } from './lib/static-value.ts'
181
188
  import { JinjaFilterEmitter, JinjaTopLevelEmitter, truthyTest } from './expr/emitters.ts'
182
189
  import type { JinjaEmitContext, JinjaSpreadContext, JinjaMemoContext } from './emit-context.ts'
183
190
  import {
@@ -254,6 +261,14 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
254
261
  private options: Required<MinijinjaAdapterOptions>
255
262
  private errors: CompilerError[] = []
256
263
  private inLoop: boolean = false
264
+ /**
265
+ * `IRLoop.depth` of the loop currently being rendered (save/restore
266
+ * around `renderChildren(loop.children)`, mirroring `inLoop` above).
267
+ * `renderAttributes` reads this to derive the `key` → `data-key`/
268
+ * `data-key-N` suffix — the depth is IR-computed (jsx-to-ir.ts), not
269
+ * re-derived here (#2168 nested-loop-outer-binding).
270
+ */
271
+ private currentLoopKeyDepth = 0
257
272
  /**
258
273
  * SolidJS-style props identifier (`function(props: P)`) and the
259
274
  * analyzer-extracted prop names. Stashed at `generate()` entry so the
@@ -306,6 +321,17 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
306
321
  */
307
322
  private localConstants: IRMetadata['localConstants'] = []
308
323
 
324
+ /**
325
+ * Every name a `.map()`/`.filter()` loop callback binds as its item/index
326
+ * parameter anywhere in the component (#2208 fable review). A static
327
+ * loop-SOURCE name (e.g. a function-scope `const items = [...]`) must
328
+ * never resolve through `resolveStaticLoopSource` at a use site where a
329
+ * DIFFERENT, enclosing loop's own callback param shadows it — same
330
+ * shadowing hazard, and same coarse-but-safe mitigation, as #2212's
331
+ * `collectLoopBoundNames` use in `collectStringValueNames`.
332
+ */
333
+ private staticLoopSourceBoundNames: Set<string> = new Set()
334
+
309
335
  /**
310
336
  * Optional, no-default props that are `None` when the caller omits them.
311
337
  * Their bare-reference attribute emission is guarded with a Jinja
@@ -338,6 +364,7 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
338
364
  // ("True"/"False") (#1897, pagination's data-active).
339
365
  this.booleanTypedProps = collectBooleanTypedProps(ir)
340
366
  this.localConstants = ir.metadata.localConstants ?? []
367
+ this.staticLoopSourceBoundNames = collectLoopBoundNames(ir)
341
368
  this.nullableOptionalProps = collectNullableOptionalProps(ir)
342
369
  this.stringValueNames = collectStringValueNames(ir)
343
370
  this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
@@ -449,7 +476,9 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
449
476
  }
450
477
 
451
478
  emitText(node: IRText): string {
452
- return node.value
479
+ // IRText carries the entity-DECODED value (Phase 1 decodes JSX
480
+ // character references); re-escape for direct HTML emission.
481
+ return escapeHtml(node.value)
453
482
  }
454
483
 
455
484
  emitExpression(node: IRExpression): string {
@@ -558,7 +587,8 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
558
587
  renderElement(element: IRElement): string {
559
588
  const tag = element.tag
560
589
  const attrs = this.renderAttributes(element)
561
- const children = this.renderChildren(element.children)
590
+ const dangerousHtml = this.renderDangerousInnerHtml(element)
591
+ const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children)
562
592
 
563
593
  let hydrationAttrs = ''
564
594
  if (element.needsScope) {
@@ -593,6 +623,28 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
593
623
  return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
594
624
  }
595
625
 
626
+ /**
627
+ * `dangerouslySetInnerHTML={{ __html: '...' }}` (#2207) — see the Blade
628
+ * adapter's identical helper for the full rationale. `null` means the
629
+ * attribute is absent (caller falls through to normal `renderChildren`);
630
+ * a non-`null` string (possibly `''`) replaces the children outright.
631
+ */
632
+ private renderDangerousInnerHtml(element: IRElement): string | null {
633
+ const resolution = resolveDangerousInnerHtml(element)
634
+ if (!resolution) return null
635
+ if (resolution.kind === 'dynamic') {
636
+ this.errors.push(dangerousInnerHtmlDiagnostic(resolution.expr, resolution.loc))
637
+ return ''
638
+ }
639
+ const violation = dangerousInnerHtmlMetacharViolation(resolution.html, this.name)
640
+ if (violation) {
641
+ const attr = element.attrs.find(isDangerousInnerHtmlAttr)!
642
+ this.errors.push(dangerousInnerHtmlDiagnostic(`{ __html: ${JSON.stringify(resolution.html)} }`, attr.loc, violation))
643
+ return ''
644
+ }
645
+ return resolution.html
646
+ }
647
+
596
648
  // ===========================================================================
597
649
  // Expression Rendering
598
650
  // ===========================================================================
@@ -606,8 +658,12 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
606
658
  }
607
659
 
608
660
  // Text-position interpolation of a possibly-non-string value — see the
609
- // file header, divergence 2.
610
- const jinjaExpr = `bf.string(${this.convertExpressionToJinja(expr.expr)})`
661
+ // file header, divergence 2. Thread the IR-carried `.parsed` tree
662
+ // through (mirrors go-template's `convertExpressionToGo(expr.expr,
663
+ // classify, expr.parsed)`) so a resolved bare-identifier
664
+ // `.map`/`.filter`/… callback (`resolveCallbackMethodFunctionReferences`,
665
+ // #2206) isn't lost to a fresh, unresolved re-parse of the raw string.
666
+ const jinjaExpr = `bf.string(${this.convertExpressionToJinja(expr.expr, expr.parsed)})`
611
667
 
612
668
  if (expr.slotId) {
613
669
  return `{{ bf.text_start("${expr.slotId}") | safe }}{{ ${jinjaExpr} }}{{ bf.text_end() | safe }}`
@@ -752,8 +808,27 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
752
808
  // corpus only because the widened destructure gate (#2087 Phase A/B)
753
809
  // no longer refuses this fixture's `([emoji, users]) => ...` param
754
810
  // first. Mirrors adapter-jinja's identical check.
811
+ // #2208: a loop source that is a fully-static array literal — either
812
+ // inline (`[{ label: 'Alpha' }, ...].map(...)`) or a bare identifier
813
+ // bound to a FUNCTION-scope local const whose initializer has no
814
+ // prop/signal/function-call dependency — inlines as a native MiniJinja
815
+ // list/dict literal below, the same way a module-scope const's value
816
+ // is already seeded. A runtime-computed local (#2069, e.g.
817
+ // `Object.entries(props.tags).filter(...)`) still refuses below.
818
+ // `isNameShadowed` guards a DIFFERENT, enclosing loop's own callback
819
+ // param shadowing this identifier (fable review) — never resolve the
820
+ // static const in that case. `rawArray` then falls through to the
821
+ // bare identifier expression below, same as before #2208 — which
822
+ // still trips the pre-existing BF101 gate for an unresolvable local
823
+ // const reference (a loud, conservative refusal, not a silent wrong
824
+ // value).
825
+ const staticItems = resolveStaticLoopSource(loop.arrayParsed, this.localConstants, {
826
+ isNameShadowed: name => this.staticLoopSourceBoundNames.has(name),
827
+ })
828
+ const staticArray = staticItems !== null ? staticValueToMinijinja(staticItems) : null
829
+
755
830
  const arrayName = loop.array.trim()
756
- if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
831
+ if (staticArray === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
757
832
  const arrayConst = (this.localConstants ?? []).find(c => c.name === arrayName)
758
833
  if (arrayConst && !arrayConst.isModule && this._resolveLiteralConst(arrayName) === null) {
759
834
  this.errors.push({
@@ -769,7 +844,7 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
769
844
  }
770
845
  }
771
846
 
772
- const rawArray = this.convertExpressionToJinja(loop.array)
847
+ const rawArray = staticArray ?? this.convertExpressionToJinja(loop.array)
773
848
  // Apply sort if present: wrap the loop array in the shared `bf.sort`
774
849
  // helper, binding the sorted result to a per-iteration local so the
775
850
  // helper runs once.
@@ -810,7 +885,11 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
810
885
  // adds one `{% set %}` local per binding (`rest` aliases the item so
811
886
  // `rest.flag` resolves).
812
887
  const indexLocalLines: string[] = []
813
- if (loop.iterationShape === 'keys') {
888
+ if (loop.objectIteration) {
889
+ // `key`/`value` bind directly in the for-header (see below) via the
890
+ // `|items` filter — no derived `loop.index0` local needed, unlike
891
+ // the array `iterationShape` cases.
892
+ } else if (loop.iterationShape === 'keys') {
814
893
  indexLocalLines.push(`{% set ${minijinjaIdent(param)} = loop.index0 %}`)
815
894
  } else if (loop.index) {
816
895
  indexLocalLines.push(`{% set ${minijinjaIdent(loop.index)} = loop.index0 %}`)
@@ -843,10 +922,13 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
843
922
 
844
923
  const prevInLoop = this.inLoop
845
924
  this.inLoop = true
925
+ const prevLoopKeyDepth = this.currentLoopKeyDepth
926
+ this.currentLoopKeyDepth = loop.depth
846
927
  // Re-render children now that inLoop is set (so nested components use the
847
928
  // loop-child naming convention). renderedChildren above was computed with
848
929
  // the previous flag; recompute under the loop flag.
849
930
  const childrenUnderLoop = this.renderChildren(loop.children)
931
+ this.currentLoopKeyDepth = prevLoopKeyDepth
850
932
  this.inLoop = prevInLoop
851
933
  void renderedChildren
852
934
 
@@ -863,7 +945,25 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
863
945
  // Scoped per-call-site marker so sibling `.map()`s under the same parent
864
946
  // each get their own reconciliation range.
865
947
  lines.push(`{{ bf.comment("loop:${loop.markerId}") | safe }}`)
866
- lines.push(`{% for ${minijinjaIdent(loopVar)} in ${array} %}`)
948
+ // `objectIteration` (#2168 object-entries-map): minijinja has no
949
+ // built-in `.items()` OBJECT METHOD (unlike Python's dict) — `|items`
950
+ // is a FILTER, yielding `[key, value]` pairs, which the `for` tag's
951
+ // own tuple-unpack target (`for a, b in ...`, mirroring Jinja2) binds
952
+ // directly. There's no `|keys`/`|values` filter, so `'keys'`/`'values'`
953
+ // reuse the SAME `|items` pairs and bind the unused half to a
954
+ // throwaway name. Order is whatever the underlying `BTreeMap` gives —
955
+ // sorted-by-key, not JS insertion order (a deliberate design choice
956
+ // for a DIFFERENT feature, canonical JSON encoding — see `num.rs`);
957
+ // this happens to satisfy the current fixture, but is a documented
958
+ // known limitation for out-of-alphabetical-order data, same as Go.
959
+ const forHeader = loop.objectIteration === 'entries'
960
+ ? `{% for ${minijinjaIdent(loop.index ?? param)}, ${minijinjaIdent(param)} in ${array}|items %}`
961
+ : loop.objectIteration === 'keys'
962
+ ? `{% for ${minijinjaIdent(param)}, __bf_v in ${array}|items %}`
963
+ : loop.objectIteration === 'values'
964
+ ? `{% for __bf_k, ${minijinjaIdent(param)} in ${array}|items %}`
965
+ : `{% for ${minijinjaIdent(loopVar)} in ${array} %}`
966
+ lines.push(forHeader)
867
967
  for (const il of indexLocalLines) lines.push(il)
868
968
 
869
969
  // Handle filter().map() pattern by wrapping children in if-condition
@@ -1017,11 +1117,33 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
1017
1117
  type Segment = { kind: 'entries'; parts: string[] } | { kind: 'spread'; expr: string }
1018
1118
  const segments: Segment[] = [{ kind: 'entries', parts: [] }]
1019
1119
  const currentEntries = () => this.componentPropSegmentEntries(segments)
1120
+ // Named JSX-valued props OTHER than the reserved `children`
1121
+ // (`header={<strong>Title</strong>}`, #2168 jsx-element-prop) each get
1122
+ // their own `{% set %}` capture, prepended to the final returned
1123
+ // string below — same mechanism as the reserved children capture,
1124
+ // just keyed by the prop's own name instead of `children`.
1125
+ const namedSlotSetBlocks: string[] = []
1020
1126
 
1021
1127
  for (const p of comp.props) {
1022
1128
  // Skip callback props (onXxx) and `ref` — both are client-only for
1023
1129
  // SSR (Hono renders neither; the client JS wires them at hydration).
1024
1130
  if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
1131
+ if (p.value.kind === 'jsx-children' && p.name !== 'children') {
1132
+ const prevInLoop = this.inLoop
1133
+ this.inLoop = false
1134
+ const slotBody = this.renderChildren(p.value.children)
1135
+ this.inLoop = prevInLoop
1136
+ // Purely counter-based — NOT derived from `p.name` or `comp.slotId`.
1137
+ // A JSX prop name can contain characters (`data-slot`) that aren't a
1138
+ // valid minijinja `{% set %}` target, and `comp.slotId` alone would
1139
+ // collide across two named-slot props on the same component
1140
+ // invocation (unlike the reserved children slot, there's only ever
1141
+ // one of those per invocation).
1142
+ const captureName = `bf_prop_${this.childrenCaptureCounter++}`
1143
+ namedSlotSetBlocks.push(`{% set ${captureName} %}${slotBody}{% endset %}`)
1144
+ currentEntries().push(`${minijinjaHashKey(p.name)}: ${captureName}`)
1145
+ continue
1146
+ }
1025
1147
  if (p.value.kind === 'spread') {
1026
1148
  const trimmed = p.value.expr.trim()
1027
1149
  // SolidJS-style props identifier (`function(props: P)`) has no
@@ -1085,12 +1207,12 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
1085
1207
  const captureName = `bf_children_${comp.slotId ?? 'c' + this.childrenCaptureCounter++}`
1086
1208
  currentEntries().push(`${minijinjaHashKey('children')}: ${captureName}`)
1087
1209
  const dict = this.combineComponentPropSegments(segments)
1088
- return `{% set ${captureName} %}${childrenBody}{% endset %}{{ bf.render_child('${tplName}', ${dict}) | safe }}`
1210
+ return `${namedSlotSetBlocks.join('')}{% set ${captureName} %}${childrenBody}{% endset %}{{ bf.render_child('${tplName}', ${dict}) | safe }}`
1089
1211
  }
1090
1212
 
1091
1213
  const isEmpty = segments.every(s => s.kind === 'entries' && s.parts.length === 0)
1092
1214
  const dictEntries = isEmpty ? '' : `, ${this.combineComponentPropSegments(segments)}`
1093
- return `{{ bf.render_child('${tplName}'${dictEntries}) | safe }}`
1215
+ return `${namedSlotSetBlocks.join('')}{{ bf.render_child('${tplName}'${dictEntries}) | safe }}`
1094
1216
  }
1095
1217
 
1096
1218
  private childrenCaptureCounter = 0
@@ -1175,7 +1297,7 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
1175
1297
  * AttrValue lowering for intrinsic-element attributes (Jinja).
1176
1298
  */
1177
1299
  private readonly elementAttrEmitter: AttrValueEmitter = {
1178
- emitLiteral: (value, name) => `${name}="${value.value}"`,
1300
+ emitLiteral: (value, name) => `${name}="${escapeHtml(value.value)}"`,
1179
1301
  emitExpression: (value, name) => {
1180
1302
  // `style={{ … }}` object literal → a CSS string with dynamic values
1181
1303
  // interpolated, instead of refusing the bare object with BF101 (#1322).
@@ -1377,10 +1499,19 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
1377
1499
  // the unsupported-expression lowering is never reached for a deferred
1378
1500
  // predicate (no BF101 / BF102). #1966
1379
1501
  if (attr.clientOnly) continue
1502
+ // `dangerouslySetInnerHTML` never renders as an HTML attribute — it's
1503
+ // handled by `renderDangerousInnerHtml` instead, which replaces the
1504
+ // element's children. Skip it here so its `{ __html: ... }` object
1505
+ // literal never reaches the generic object-literal BF101 refusal
1506
+ // (which would double-report alongside the purpose-built one).
1507
+ if (isDangerousInnerHtmlAttr(attr)) continue
1380
1508
  // Rewrite JSX special-prop names to their HTML-attribute counterparts.
1381
1509
  let attrName: string
1382
1510
  if (attr.name === 'className') attrName = 'class'
1383
- else if (attr.name === 'key') attrName = 'data-key'
1511
+ else if (attr.name === 'key') {
1512
+ const depth = this.currentLoopKeyDepth
1513
+ attrName = depth > 0 ? `data-key-${depth}` : 'data-key'
1514
+ }
1384
1515
  else attrName = attr.name
1385
1516
  const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
1386
1517
  if (lowered) parts.push(lowered)
@@ -1744,8 +1875,19 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
1744
1875
  * single-quoted string literal (`const totalPages = 5`, #1897
1745
1876
  * pagination) — function-scope consts never reach the per-render
1746
1877
  * context, so a bare reference would resolve to Undefined.
1878
+ *
1879
+ * The lookup is a flat name match with no notion of AST scope, so a
1880
+ * name that any loop callback binds as its item/index param never
1881
+ * inlines (#2221) — the occurrence may be the loop's own (shadowing)
1882
+ * binding, and substituting the outer const's value there renders every
1883
+ * iteration with the same hard-coded literal. Coarse (a genuinely
1884
+ * non-shadowed same-named const elsewhere in the component also stops
1885
+ * inlining, falling back to the bare identifier) but safe — the same
1886
+ * trade-off as #2212's `collectLoopBoundNames` use in
1887
+ * `collectStringValueNames`.
1747
1888
  */
1748
1889
  private _resolveLiteralConst(name: string): string | null {
1890
+ if (this.staticLoopSourceBoundNames.has(name)) return null
1749
1891
  const c = (this.localConstants ?? []).find(lc => lc.name === name)
1750
1892
  if (c?.value === undefined) return null
1751
1893
  const v = c.value.trim()
@@ -1755,7 +1897,22 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
1755
1897
  return null
1756
1898
  }
1757
1899
 
1900
+ /**
1901
+ * Resolve `IDENT.key` where `IDENT` is a module-scope object-literal const
1902
+ * (`variantClasses.ghost`, #1896/#1897) to the looked-up scalar.
1903
+ *
1904
+ * The lookup is a flat name match on `objectName` with no notion of AST
1905
+ * scope, so an enclosing loop callback's own param of the same name
1906
+ * (`.map((cfg) => <li>{cfg.x}</li>)` shadowing a module `const cfg = {…}`)
1907
+ * still resolved to the OUTER const's member value at every iteration
1908
+ * (#2237) — the sibling hazard to #2221's `_resolveLiteralConst`. Same
1909
+ * coarse-but-safe `staticLoopSourceBoundNames` guard: any name a loop
1910
+ * binds anywhere in the component never inlines, falling back to the bare
1911
+ * `cfg.x` member expression (which a minijinja `for` loop binds correctly
1912
+ * at the shadowed occurrences).
1913
+ */
1758
1914
  private _resolveStaticRecordLiteral(objectName: string, key: string): string | null {
1915
+ if (this.staticLoopSourceBoundNames.has(objectName)) return null
1759
1916
  const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
1760
1917
  if (!hit) return null
1761
1918
  return hit.kind === 'number'