@barefootjs/jinja 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.
@@ -383,8 +383,149 @@ export function C(props: { count: number }) {
383
383
  })
384
384
  })
385
385
 
386
+ describe('JinjaAdapter - named-slot capture identifier safety (#2168 jsx-element-prop)', () => {
387
+ // A JSX-valued prop under a hyphenated name (`data-slot`, a valid JSX
388
+ // attribute name) must not leak into the `{% set %}` capture variable's
389
+ // identifier — Jinja variable names can't contain `-`. The capture
390
+ // identifier is purely counter-based (never derived from the prop name);
391
+ // the hash KEY passed to `render_child` still carries the real name,
392
+ // quoted via `jinjaHashKey`.
393
+ test('a hyphenated prop name does not appear in the capture variable', () => {
394
+ const { template } = compileAndGenerate(`
395
+ function Card(props) { return null }
396
+ export function Parent() {
397
+ return <Card data-slot={<strong>Title</strong>}>text</Card>
398
+ }
399
+ `)
400
+ expect(template).toContain('{% set bf_prop_0 %}')
401
+ expect(template).toContain("'data-slot': bf_prop_0")
402
+ expect(template).not.toContain('data-slot %}')
403
+ expect(template).not.toContain('data-slot_')
404
+ })
405
+ })
406
+
386
407
  // #2038 nested-callback-predicate loudness is pinned at the shared
387
408
  // conformance layer (workstream C): `filter-nested-callback-predicate` /
388
409
  // `filter-nested-find-predicate` (BF101 via `expectedDiagnostics`) and
389
410
  // `filter-nested-callback-predicate-client` (the `/* @client */` suppression
390
411
  // twin, which must render clean).
412
+
413
+ // #2221: `_resolveLiteralConst` is a flat name lookup against
414
+ // `ir.metadata.localConstants` with no notion of AST scope — it used to
415
+ // substitute an outer const's literal value even at an occurrence that is
416
+ // actually an enclosing loop callback's own (shadowing) parameter, so every
417
+ // iteration rendered the same hard-coded literal. Guarded with the same
418
+ // coarse `collectLoopBoundNames` exclusion as #2212: any name a loop binds
419
+ // anywhere in the component never inlines, falling back to the bare
420
+ // identifier. SSR-only tests for the same #2222 reason as the #2212
421
+ // describe below.
422
+ describe('JinjaAdapter - const inlining vs loop-param shadowing (#2221)', () => {
423
+ test('a loop param shadowing an outer literal const emits the identifier, not the const value', () => {
424
+ const { template } = compileAndGenerate(`
425
+ function Widget() {
426
+ const label: string = 'x'
427
+ return <ul>{[2, 5].map((label) => <li key={label}>{1 + label}</li>)}</ul>
428
+ }
429
+ `)
430
+ // The loop body must reference the per-iteration loop var...
431
+ expect(template).toContain('1 + label')
432
+ // ...never the outer const's hard-coded value.
433
+ expect(template).not.toContain("1 + 'x'")
434
+ })
435
+
436
+ test('a numeric const shadowed by a loop param emits the identifier too', () => {
437
+ const { template } = compileAndGenerate(`
438
+ function Widget() {
439
+ const count = 7
440
+ return <ul>{[2, 5].map((count) => <li key={count}>{1 + count}</li>)}</ul>
441
+ }
442
+ `)
443
+ expect(template).toContain('1 + count')
444
+ expect(template).not.toContain('1 + 7')
445
+ })
446
+
447
+ test('a literal const NOT shadowed by any loop still inlines (#1897 pin)', () => {
448
+ const { template } = compileAndGenerate(`
449
+ function Widget({ values }: { values: number[] }) {
450
+ const totalPages = 5
451
+ return <div>
452
+ <p>Page 1 of {1 + totalPages}</p>
453
+ <ul>{values.map((v) => <li key={v}>{v}</li>)}</ul>
454
+ </div>
455
+ }
456
+ `)
457
+ expect(template).toContain('1 + 5')
458
+ })
459
+
460
+ // The accepted coarse-exclusion trade-off (same as #2212): a name that is
461
+ // loop-bound ANYWHERE in the component never inlines, even at a genuinely
462
+ // non-shadowed occurrence outside the loop — the bare identifier is
463
+ // emitted instead of the value.
464
+ test('a const referenced outside the loop whose name is loop-bound elsewhere falls back to the identifier (accepted trade-off)', () => {
465
+ const { template } = compileAndGenerate(`
466
+ function Widget({ values }: { values: number[] }) {
467
+ const label: string = 'x'
468
+ return <div>
469
+ <p>{1 + label}</p>
470
+ <ul>{values.map((label) => <li key={label}>{2 + label}</li>)}</ul>
471
+ </div>
472
+ }
473
+ `)
474
+ expect(template).not.toContain("1 + 'x'")
475
+ expect(template).toContain('2 + label')
476
+ })
477
+ })
478
+
479
+ // #2237: `_resolveStaticRecordLiteral` (`IDENT.key` on a module-scope
480
+ // object-literal const, e.g. `variantClasses.ghost` — #1896/#1897) is a
481
+ // flat name lookup on `objectName` with no notion of AST scope, the
482
+ // record-literal sibling of #2221's `_resolveLiteralConst` bug. It used to
483
+ // substitute the outer const's member value even at an occurrence that is
484
+ // actually an enclosing loop callback's own (shadowing) parameter, so every
485
+ // iteration rendered the same hard-coded literal instead of the per-item
486
+ // value. Guarded with the same coarse `staticLoopSourceBoundNames`
487
+ // exclusion as #2221: any name a loop binds anywhere in the component
488
+ // never inlines, falling back to the bare `cfg['x']` member expression.
489
+ describe('JinjaAdapter - record-literal member lookup vs loop-param shadowing (#2237)', () => {
490
+ test('a loop param shadowing an outer module object const emits the member access, not the outer literal', () => {
491
+ const { template } = compileAndGenerate(`
492
+ const cfg = { x: 'outer-lit' }
493
+ function Widget({ rows }: { rows: { x: string }[] }) {
494
+ return <ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
495
+ }
496
+ `)
497
+ // The loop body must reference the per-iteration member access...
498
+ expect(template).toContain("bf.string(cfg['x'])")
499
+ // ...never the outer const's hard-coded value.
500
+ expect(template).not.toContain("bf.string('outer-lit')")
501
+ })
502
+
503
+ test('a module object const NOT shadowed by any loop still inlines (variantClasses.ghost shape, #1896/#1897 pin)', () => {
504
+ const { template } = compileAndGenerate(`
505
+ const variantClasses = { solid: 'bg-solid', ghost: 'bg-ghost' }
506
+ function Widget({ variant }: { variant: 'solid' | 'ghost' }) {
507
+ return <div>{variantClasses.ghost}</div>
508
+ }
509
+ `)
510
+ expect(template).toContain("bf.string('bg-ghost')")
511
+ })
512
+
513
+ // The accepted coarse-exclusion trade-off (same as #2221/#2212): an
514
+ // object name that is loop-bound ANYWHERE in the component never
515
+ // inlines its member lookups, even at a genuinely non-shadowed
516
+ // occurrence outside the loop — the bare member expression is emitted
517
+ // instead of the value.
518
+ test('a record member referenced outside the loop whose object name is loop-bound elsewhere falls back to the member expression (accepted trade-off)', () => {
519
+ const { template } = compileAndGenerate(`
520
+ const cfg = { x: 'outer-lit' }
521
+ function Widget({ rows }: { rows: { x: string }[] }) {
522
+ return <div>
523
+ <p>{cfg.x}</p>
524
+ <ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
525
+ </div>
526
+ }
527
+ `)
528
+ expect(template).not.toContain("bf.string('outer-lit')")
529
+ expect(template).toContain("bf.string(cfg['x'])")
530
+ })
531
+ })
@@ -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` with
141
+ // a flag) — the regex-pattern form is refused upstream at the
142
+ // 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
@@ -149,8 +149,16 @@ export class JinjaFilterEmitter implements ParsedExprEmitter {
149
149
  if (property === 'length') {
150
150
  return `bf.length(${emit(object)})`
151
151
  }
152
- // Attribute / dict-key access Jinja `.` resolves both transparently.
153
- return `${emit(object)}.${property}`
152
+ // Bracket/item access, NOT `.` attribute access: Jinja's default
153
+ // `getattr` semantics try a Python ATTRIBUTE first, falling back to a
154
+ // dict key only if no such attribute exists — so `group.items` (a
155
+ // dict key from the JS object) instead resolves to the built-in bound
156
+ // method `dict.items`, raising "not iterable" downstream instead of
157
+ // returning the key's value. `[...]` compiles through Jinja's
158
+ // `getitem`, which tries the KEY first, sidestepping any built-in
159
+ // dict method name (items/keys/values/get/pop/update/...) that would
160
+ // otherwise shadow a same-named JS object field.
161
+ return `${emit(object)}['${escapeJinjaSingleQuoted(property)}']`
154
162
  }
155
163
 
156
164
  indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
@@ -313,7 +321,7 @@ export class JinjaTopLevelEmitter implements ParsedExprEmitter {
313
321
  return String(value)
314
322
  }
315
323
 
316
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
324
+ member(object: ParsedExpr, property: string, _computed: boolean, _optional: boolean, emit: (e: ParsedExpr) => string): string {
317
325
  // `props.x` flattens to the bare context var the SSR caller binds each
318
326
  // prop to (props arrive as individual top-level context entries, not a
319
327
  // nested `props` dict).
@@ -331,8 +339,13 @@ export class JinjaTopLevelEmitter implements ParsedExprEmitter {
331
339
  const obj = emit(object)
332
340
  // `.length` → `bf.length` (array count or string char count, JS-compat).
333
341
  if (property === 'length') return `bf.length(${obj})`
334
- // Jinja `.` access works for both dicts and objects.
335
- return `${obj}.${property}`
342
+ // Bracket/item access, NOT `.` attribute access see the sibling
343
+ // `JinjaFilterEmitter.member()` for why: Jinja's `.` tries a Python
344
+ // ATTRIBUTE first, so a dict key that happens to share a name with a
345
+ // built-in dict method (`items`, `keys`, `values`, `get`, ...) resolves
346
+ // to the bound method instead of the value. `[...]` (Jinja `getitem`)
347
+ // tries the key first.
348
+ return `${obj}['${escapeJinjaSingleQuoted(property)}']`
336
349
  }
337
350
 
338
351
  indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
@@ -140,10 +140,16 @@ import {
140
140
  queryHrefArgs,
141
141
  isValidHelperId,
142
142
  sortComparatorFromArrow,
143
+ isDangerousInnerHtmlAttr,
144
+ resolveDangerousInnerHtml,
145
+ dangerousInnerHtmlMetacharViolation,
146
+ dangerousInnerHtmlDiagnostic,
147
+ resolveStaticLoopSource,
148
+ collectLoopBoundNames,
143
149
  } from '@barefootjs/jsx'
144
150
  import { isAriaBooleanAttr, isBooleanResultExpr, isExplicitStringCall } from './boolean-result.ts'
145
151
  import type { ParsedExpr, LoweringMatcher } from '@barefootjs/jsx'
146
- import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
152
+ import { BF_SLOT, BF_COND, BF_REGION, escapeHtml } from '@barefootjs/shared'
147
153
 
148
154
  import type { JinjaRenderCtx } from './lib/types.ts'
149
155
  import { JINJA_PRIMITIVE_EMIT_MAP } from './lib/constants.ts'
@@ -158,6 +164,7 @@ import {
158
164
  collectRootScopeNodes,
159
165
  } from './lib/ir-scope.ts'
160
166
  import { renderSortMethod, renderSortEval } from './expr/array-method.ts'
167
+ import { staticValueToJinja } from './lib/static-value.ts'
161
168
  import { JinjaFilterEmitter, JinjaTopLevelEmitter, truthyTest } from './expr/emitters.ts'
162
169
  import type { JinjaEmitContext, JinjaSpreadContext, JinjaMemoContext } from './emit-context.ts'
163
170
  import {
@@ -206,6 +213,14 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
206
213
  private options: Required<JinjaAdapterOptions>
207
214
  private errors: CompilerError[] = []
208
215
  private inLoop: boolean = false
216
+ /**
217
+ * `IRLoop.depth` of the loop currently being rendered (save/restore
218
+ * around `renderChildren(loop.children)`, mirroring `inLoop` above).
219
+ * `renderAttributes` reads this to derive the `key` → `data-key`/
220
+ * `data-key-N` suffix — the depth is IR-computed (jsx-to-ir.ts), not
221
+ * re-derived here (#2168 nested-loop-outer-binding).
222
+ */
223
+ private currentLoopKeyDepth = 0
209
224
  /**
210
225
  * SolidJS-style props identifier (`function(props: P)`) and the
211
226
  * analyzer-extracted prop names. Stashed at `generate()` entry so the
@@ -258,6 +273,17 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
258
273
  */
259
274
  private localConstants: IRMetadata['localConstants'] = []
260
275
 
276
+ /**
277
+ * Every name a `.map()`/`.filter()` loop callback binds as its item/index
278
+ * parameter anywhere in the component (#2208 fable review). A static
279
+ * loop-SOURCE name (e.g. a function-scope `const items = [...]`) must
280
+ * never resolve through `resolveStaticLoopSource` at a use site where a
281
+ * DIFFERENT, enclosing loop's own callback param shadows it — same
282
+ * shadowing hazard, and same coarse-but-safe mitigation, as #2212's
283
+ * `collectLoopBoundNames` use in `collectStringValueNames`.
284
+ */
285
+ private staticLoopSourceBoundNames: Set<string> = new Set()
286
+
261
287
  /**
262
288
  * Optional, no-default props that are `None` when the caller omits them.
263
289
  * Their bare-reference attribute emission is guarded with a Jinja
@@ -290,6 +316,7 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
290
316
  // ("True"/"False") (#1897, pagination's data-active).
291
317
  this.booleanTypedProps = collectBooleanTypedProps(ir)
292
318
  this.localConstants = ir.metadata.localConstants ?? []
319
+ this.staticLoopSourceBoundNames = collectLoopBoundNames(ir)
293
320
  this.nullableOptionalProps = collectNullableOptionalProps(ir)
294
321
  this.stringValueNames = collectStringValueNames(ir)
295
322
  this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
@@ -401,7 +428,9 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
401
428
  }
402
429
 
403
430
  emitText(node: IRText): string {
404
- return node.value
431
+ // IRText carries the entity-DECODED value (Phase 1 decodes JSX
432
+ // character references); re-escape for direct HTML emission.
433
+ return escapeHtml(node.value)
405
434
  }
406
435
 
407
436
  emitExpression(node: IRExpression): string {
@@ -510,7 +539,8 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
510
539
  renderElement(element: IRElement): string {
511
540
  const tag = element.tag
512
541
  const attrs = this.renderAttributes(element)
513
- const children = this.renderChildren(element.children)
542
+ const dangerousHtml = this.renderDangerousInnerHtml(element)
543
+ const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children)
514
544
 
515
545
  let hydrationAttrs = ''
516
546
  if (element.needsScope) {
@@ -545,6 +575,28 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
545
575
  return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
546
576
  }
547
577
 
578
+ /**
579
+ * `dangerouslySetInnerHTML={{ __html: '...' }}` (#2207) — see the Blade
580
+ * adapter's identical helper for the full rationale. `null` means the
581
+ * attribute is absent (caller falls through to normal `renderChildren`);
582
+ * a non-`null` string (possibly `''`) replaces the children outright.
583
+ */
584
+ private renderDangerousInnerHtml(element: IRElement): string | null {
585
+ const resolution = resolveDangerousInnerHtml(element)
586
+ if (!resolution) return null
587
+ if (resolution.kind === 'dynamic') {
588
+ this.errors.push(dangerousInnerHtmlDiagnostic(resolution.expr, resolution.loc))
589
+ return ''
590
+ }
591
+ const violation = dangerousInnerHtmlMetacharViolation(resolution.html, this.name)
592
+ if (violation) {
593
+ const attr = element.attrs.find(isDangerousInnerHtmlAttr)!
594
+ this.errors.push(dangerousInnerHtmlDiagnostic(`{ __html: ${JSON.stringify(resolution.html)} }`, attr.loc, violation))
595
+ return ''
596
+ }
597
+ return resolution.html
598
+ }
599
+
548
600
  // ===========================================================================
549
601
  // Expression Rendering
550
602
  // ===========================================================================
@@ -558,8 +610,12 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
558
610
  }
559
611
 
560
612
  // Text-position interpolation of a possibly-non-string value — see the
561
- // file header, divergence 2.
562
- const jinjaExpr = `bf.string(${this.convertExpressionToJinja(expr.expr)})`
613
+ // file header, divergence 2. Thread the IR-carried `.parsed` tree
614
+ // through (mirrors go-template's `convertExpressionToGo(expr.expr,
615
+ // classify, expr.parsed)`) so a resolved bare-identifier
616
+ // `.map`/`.filter`/… callback (`resolveCallbackMethodFunctionReferences`,
617
+ // #2206) isn't lost to a fresh, unresolved re-parse of the raw string.
618
+ const jinjaExpr = `bf.string(${this.convertExpressionToJinja(expr.expr, expr.parsed)})`
563
619
 
564
620
  if (expr.slotId) {
565
621
  return `{{ bf.text_start("${expr.slotId}") | safe }}{{ ${jinjaExpr} }}{{ bf.text_end() | safe }}`
@@ -706,8 +762,27 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
706
762
  // test corpus only because the widened destructure gate (#2087 Phase
707
763
  // A/B) no longer refuses this fixture's `([emoji, users]) => ...`
708
764
  // param first.
765
+ // #2208: a loop source that is a fully-static array literal — either
766
+ // inline (`[{ label: 'Alpha' }, ...].map(...)`) or a bare identifier
767
+ // bound to a FUNCTION-scope local const whose initializer has no
768
+ // prop/signal/function-call dependency — inlines as a native Jinja
769
+ // list/dict literal below, the same way a module-scope const's value
770
+ // is already seeded. A runtime-computed local (#2069, e.g.
771
+ // `Object.entries(props.tags).filter(...)`) still refuses below.
772
+ // `isNameShadowed` guards a DIFFERENT, enclosing loop's own callback
773
+ // param shadowing this identifier (fable review) — never resolve the
774
+ // static const in that case. `rawArray` then falls through to the
775
+ // bare identifier expression below, same as before #2208 — which
776
+ // still trips the pre-existing BF101 gate for an unresolvable local
777
+ // const reference (a loud, conservative refusal, not a silent wrong
778
+ // value).
779
+ const staticItems = resolveStaticLoopSource(loop.arrayParsed, this.localConstants, {
780
+ isNameShadowed: name => this.staticLoopSourceBoundNames.has(name),
781
+ })
782
+ const staticArray = staticItems !== null ? staticValueToJinja(staticItems) : null
783
+
709
784
  const arrayName = loop.array.trim()
710
- if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
785
+ if (staticArray === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
711
786
  const arrayConst = (this.localConstants ?? []).find(c => c.name === arrayName)
712
787
  if (arrayConst && !arrayConst.isModule && this._resolveLiteralConst(arrayName) === null) {
713
788
  this.errors.push({
@@ -723,7 +798,7 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
723
798
  }
724
799
  }
725
800
 
726
- const rawArray = this.convertExpressionToJinja(loop.array)
801
+ const rawArray = staticArray ?? this.convertExpressionToJinja(loop.array)
727
802
  // Apply sort if present: wrap the loop array in the shared `bf.sort`
728
803
  // helper, binding the sorted result to a per-iteration local so the
729
804
  // helper runs once.
@@ -777,7 +852,11 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
777
852
  // `{...rest}` → `bf.spread_attrs(rest)` emit path both see only the
778
853
  // keys NOT already destructured.
779
854
  const indexLocalLines: string[] = []
780
- if (loop.iterationShape === 'keys') {
855
+ if (loop.objectIteration) {
856
+ // `key`/`value` bind directly in the for-header (see `array` below)
857
+ // via Jinja's own dict iteration — no derived `loop.index0` local
858
+ // needed, unlike the array `iterationShape` cases.
859
+ } else if (loop.iterationShape === 'keys') {
781
860
  indexLocalLines.push(`{% set ${jinjaIdent(param)} = loop.index0 %}`)
782
861
  } else if (loop.index) {
783
862
  indexLocalLines.push(`{% set ${jinjaIdent(loop.index)} = loop.index0 %}`)
@@ -802,10 +881,13 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
802
881
 
803
882
  const prevInLoop = this.inLoop
804
883
  this.inLoop = true
884
+ const prevLoopKeyDepth = this.currentLoopKeyDepth
885
+ this.currentLoopKeyDepth = loop.depth
805
886
  // Re-render children now that inLoop is set (so nested components use the
806
887
  // loop-child naming convention). renderedChildren above was computed with
807
888
  // the previous flag; recompute under the loop flag.
808
889
  const childrenUnderLoop = this.renderChildren(loop.children)
890
+ this.currentLoopKeyDepth = prevLoopKeyDepth
809
891
  this.inLoop = prevInLoop
810
892
  void renderedChildren
811
893
 
@@ -822,7 +904,19 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
822
904
  // Scoped per-call-site marker so sibling `.map()`s under the same parent
823
905
  // each get their own reconciliation range.
824
906
  lines.push(`{{ bf.comment("loop:${loop.markerId}") | safe }}`)
825
- lines.push(`{% for ${jinjaIdent(loopVar)} in ${array} %}`)
907
+ // `objectIteration` (#2168 object-entries-map): Python `dict` preserves
908
+ // JS `Object.entries()`'s insertion-order semantics natively, so this
909
+ // lowers straight to Jinja's own dict-iteration forms — no runtime
910
+ // helper needed. `.items()` binds `index` (the KEY) alongside `param`;
911
+ // `.keys()`/`.values()` bind `param` alone.
912
+ const forHeader = loop.objectIteration === 'entries'
913
+ ? `{% for ${jinjaIdent(loop.index ?? param)}, ${jinjaIdent(param)} in ${array}.items() %}`
914
+ : loop.objectIteration === 'keys'
915
+ ? `{% for ${jinjaIdent(param)} in ${array}.keys() %}`
916
+ : loop.objectIteration === 'values'
917
+ ? `{% for ${jinjaIdent(param)} in ${array}.values() %}`
918
+ : `{% for ${jinjaIdent(loopVar)} in ${array} %}`
919
+ lines.push(forHeader)
826
920
  for (const il of indexLocalLines) lines.push(il)
827
921
 
828
922
  // Handle filter().map() pattern by wrapping children in if-condition
@@ -974,11 +1068,33 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
974
1068
  type Segment = { kind: 'entries'; parts: string[] } | { kind: 'spread'; expr: string }
975
1069
  const segments: Segment[] = [{ kind: 'entries', parts: [] }]
976
1070
  const currentEntries = () => this.componentPropSegmentEntries(segments)
1071
+ // Named JSX-valued props OTHER than the reserved `children`
1072
+ // (`header={<strong>Title</strong>}`, #2168 jsx-element-prop) each get
1073
+ // their own `{% set %}` capture, prepended to the final returned
1074
+ // string below — same mechanism as the reserved children capture,
1075
+ // just keyed by the prop's own name instead of `children`.
1076
+ const namedSlotSetBlocks: string[] = []
977
1077
 
978
1078
  for (const p of comp.props) {
979
1079
  // Skip callback props (onXxx) and `ref` — both are client-only for
980
1080
  // SSR (Hono renders neither; the client JS wires them at hydration).
981
1081
  if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
1082
+ if (p.value.kind === 'jsx-children' && p.name !== 'children') {
1083
+ const prevInLoop = this.inLoop
1084
+ this.inLoop = false
1085
+ const slotBody = this.renderChildren(p.value.children)
1086
+ this.inLoop = prevInLoop
1087
+ // Purely counter-based — NOT derived from `p.name` or `comp.slotId`.
1088
+ // A JSX prop name can contain characters (`data-slot`) that aren't a
1089
+ // valid Jinja `{% set %}` target, and `comp.slotId` alone would
1090
+ // collide across two named-slot props on the same component
1091
+ // invocation (unlike the reserved children slot, there's only ever
1092
+ // one of those per invocation).
1093
+ const captureName = `bf_prop_${this.childrenCaptureCounter++}`
1094
+ namedSlotSetBlocks.push(`{% set ${captureName} %}${slotBody}{% endset %}`)
1095
+ currentEntries().push(`${jinjaHashKey(p.name)}: ${captureName}`)
1096
+ continue
1097
+ }
982
1098
  if (p.value.kind === 'spread') {
983
1099
  const trimmed = p.value.expr.trim()
984
1100
  // SolidJS-style props identifier (`function(props: P)`) has no
@@ -1042,12 +1158,12 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
1042
1158
  const captureName = `bf_children_${comp.slotId ?? 'c' + this.childrenCaptureCounter++}`
1043
1159
  currentEntries().push(`${jinjaHashKey('children')}: ${captureName}`)
1044
1160
  const dict = this.combineComponentPropSegments(segments)
1045
- return `{% set ${captureName} %}${childrenBody}{% endset %}{{ bf.render_child('${tplName}', ${dict}) | safe }}`
1161
+ return `${namedSlotSetBlocks.join('')}{% set ${captureName} %}${childrenBody}{% endset %}{{ bf.render_child('${tplName}', ${dict}) | safe }}`
1046
1162
  }
1047
1163
 
1048
1164
  const isEmpty = segments.every(s => s.kind === 'entries' && s.parts.length === 0)
1049
1165
  const dictEntries = isEmpty ? '' : `, ${this.combineComponentPropSegments(segments)}`
1050
- return `{{ bf.render_child('${tplName}'${dictEntries}) | safe }}`
1166
+ return `${namedSlotSetBlocks.join('')}{{ bf.render_child('${tplName}'${dictEntries}) | safe }}`
1051
1167
  }
1052
1168
 
1053
1169
  private childrenCaptureCounter = 0
@@ -1132,7 +1248,7 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
1132
1248
  * AttrValue lowering for intrinsic-element attributes (Jinja).
1133
1249
  */
1134
1250
  private readonly elementAttrEmitter: AttrValueEmitter = {
1135
- emitLiteral: (value, name) => `${name}="${value.value}"`,
1251
+ emitLiteral: (value, name) => `${name}="${escapeHtml(value.value)}"`,
1136
1252
  emitExpression: (value, name) => {
1137
1253
  // `style={{ … }}` object literal → a CSS string with dynamic values
1138
1254
  // interpolated, instead of refusing the bare object with BF101 (#1322).
@@ -1334,10 +1450,19 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
1334
1450
  // the unsupported-expression lowering is never reached for a deferred
1335
1451
  // predicate (no BF101 / BF102). #1966
1336
1452
  if (attr.clientOnly) continue
1453
+ // `dangerouslySetInnerHTML` never renders as an HTML attribute — it's
1454
+ // handled by `renderDangerousInnerHtml` instead, which replaces the
1455
+ // element's children. Skip it here so its `{ __html: ... }` object
1456
+ // literal never reaches the generic object-literal BF101 refusal
1457
+ // (which would double-report alongside the purpose-built one).
1458
+ if (isDangerousInnerHtmlAttr(attr)) continue
1337
1459
  // Rewrite JSX special-prop names to their HTML-attribute counterparts.
1338
1460
  let attrName: string
1339
1461
  if (attr.name === 'className') attrName = 'class'
1340
- else if (attr.name === 'key') attrName = 'data-key'
1462
+ else if (attr.name === 'key') {
1463
+ const depth = this.currentLoopKeyDepth
1464
+ attrName = depth > 0 ? `data-key-${depth}` : 'data-key'
1465
+ }
1341
1466
  else attrName = attr.name
1342
1467
  const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
1343
1468
  if (lowered) parts.push(lowered)
@@ -1695,8 +1820,19 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
1695
1820
  * single-quoted string literal (`const totalPages = 5`, #1897
1696
1821
  * pagination) — function-scope consts never reach the per-render
1697
1822
  * context, so a bare reference would resolve to Undefined.
1823
+ *
1824
+ * The lookup is a flat name match with no notion of AST scope, so a
1825
+ * name that any loop callback binds as its item/index param never
1826
+ * inlines (#2221) — the occurrence may be the loop's own (shadowing)
1827
+ * binding, and substituting the outer const's value there renders every
1828
+ * iteration with the same hard-coded literal. Coarse (a genuinely
1829
+ * non-shadowed same-named const elsewhere in the component also stops
1830
+ * inlining, falling back to the bare identifier) but safe — the same
1831
+ * trade-off as #2212's `collectLoopBoundNames` use in
1832
+ * `collectStringValueNames`.
1698
1833
  */
1699
1834
  private _resolveLiteralConst(name: string): string | null {
1835
+ if (this.staticLoopSourceBoundNames.has(name)) return null
1700
1836
  const c = (this.localConstants ?? []).find(lc => lc.name === name)
1701
1837
  if (c?.value === undefined) return null
1702
1838
  const v = c.value.trim()
@@ -1706,7 +1842,22 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
1706
1842
  return null
1707
1843
  }
1708
1844
 
1845
+ /**
1846
+ * Resolve `IDENT.key` where `IDENT` is a module-scope object-literal const
1847
+ * (`variantClasses.ghost`, #1896/#1897) to the looked-up scalar.
1848
+ *
1849
+ * The lookup is a flat name match on `objectName` with no notion of AST
1850
+ * scope, so an enclosing loop callback's own param of the same name
1851
+ * (`.map((cfg) => <li>{cfg.x}</li>)` shadowing a module `const cfg = {…}`)
1852
+ * still resolved to the OUTER const's member value at every iteration
1853
+ * (#2237) — the sibling hazard to #2221's `_resolveLiteralConst`. Same
1854
+ * coarse-but-safe `staticLoopSourceBoundNames` guard: any name a loop
1855
+ * binds anywhere in the component never inlines, falling back to the bare
1856
+ * `cfg['x']` member expression (which a Jinja for-loop binds correctly
1857
+ * at the shadowed occurrences).
1858
+ */
1709
1859
  private _resolveStaticRecordLiteral(objectName: string, key: string): string | null {
1860
+ if (this.staticLoopSourceBoundNames.has(objectName)) return null
1710
1861
  const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
1711
1862
  if (!hit) return null
1712
1863
  return hit.kind === 'number'
@@ -22,6 +22,9 @@ export const JINJA_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
22
22
  'Math.floor': { arity: 1, emit: (args) => `bf.floor(${args[0]})` },
23
23
  'Math.ceil': { arity: 1, emit: (args) => `bf.ceil(${args[0]})` },
24
24
  'Math.round': { arity: 1, emit: (args) => `bf.round(${args[0]})` },
25
+ 'Math.min': { arity: 2, emit: (args) => `bf.min(${args[0]}, ${args[1]})` },
26
+ 'Math.max': { arity: 2, emit: (args) => `bf.max(${args[0]}, ${args[1]})` },
27
+ 'Math.abs': { arity: 1, emit: (args) => `bf.abs(${args[0]})` },
25
28
  }
26
29
 
27
30
  /**
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Serialize a compile-time-evaluated JS value (`@barefootjs/jsx`'s
3
+ * `evaluateStaticLiteral`/`resolveStaticLoopSource`, #2208) into a native
4
+ * Jinja2 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
+ * (e.g. `undefined` reads for a missing object key are still representable
11
+ * as `none`, but anything else falls back to the caller's existing BF101
12
+ * refusal instead of guessing).
13
+ */
14
+
15
+ import { escapeJinjaSingleQuoted, jinjaHashKey } from './jinja-naming.ts'
16
+
17
+ export function staticValueToJinja(value: unknown): string | null {
18
+ if (value === null || value === undefined) return 'none'
19
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
20
+ if (typeof value === 'number') return String(value)
21
+ if (typeof value === 'string') return `'${escapeJinjaSingleQuoted(value)}'`
22
+ if (Array.isArray(value)) {
23
+ const items: string[] = []
24
+ for (const el of value) {
25
+ const serialized = staticValueToJinja(el)
26
+ if (serialized === null) return null
27
+ items.push(serialized)
28
+ }
29
+ return `[${items.join(', ')}]`
30
+ }
31
+ if (typeof value === 'object') {
32
+ const entries: string[] = []
33
+ for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
34
+ const serialized = staticValueToJinja(val)
35
+ if (serialized === null) return null
36
+ entries.push(`${jinjaHashKey(key)}: ${serialized}`)
37
+ }
38
+ return `{${entries.join(', ')}}`
39
+ }
40
+ return null
41
+ }