@barefootjs/jinja 0.18.3 → 0.18.5

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.
@@ -143,7 +143,7 @@ import {
143
143
  } from '@barefootjs/jsx'
144
144
  import { isAriaBooleanAttr, isBooleanResultExpr, isExplicitStringCall } from './boolean-result.ts'
145
145
  import type { ParsedExpr, LoweringMatcher } from '@barefootjs/jsx'
146
- import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
146
+ import { BF_SLOT, BF_COND, BF_REGION, escapeHtml } from '@barefootjs/shared'
147
147
 
148
148
  import type { JinjaRenderCtx } from './lib/types.ts'
149
149
  import { JINJA_PRIMITIVE_EMIT_MAP } from './lib/constants.ts'
@@ -206,6 +206,14 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
206
206
  private options: Required<JinjaAdapterOptions>
207
207
  private errors: CompilerError[] = []
208
208
  private inLoop: boolean = false
209
+ /**
210
+ * `IRLoop.depth` of the loop currently being rendered (save/restore
211
+ * around `renderChildren(loop.children)`, mirroring `inLoop` above).
212
+ * `renderAttributes` reads this to derive the `key` → `data-key`/
213
+ * `data-key-N` suffix — the depth is IR-computed (jsx-to-ir.ts), not
214
+ * re-derived here (#2168 nested-loop-outer-binding).
215
+ */
216
+ private currentLoopKeyDepth = 0
209
217
  /**
210
218
  * SolidJS-style props identifier (`function(props: P)`) and the
211
219
  * analyzer-extracted prop names. Stashed at `generate()` entry so the
@@ -401,7 +409,9 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
401
409
  }
402
410
 
403
411
  emitText(node: IRText): string {
404
- return node.value
412
+ // IRText carries the entity-DECODED value (Phase 1 decodes JSX
413
+ // character references); re-escape for direct HTML emission.
414
+ return escapeHtml(node.value)
405
415
  }
406
416
 
407
417
  emitExpression(node: IRExpression): string {
@@ -777,7 +787,11 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
777
787
  // `{...rest}` → `bf.spread_attrs(rest)` emit path both see only the
778
788
  // keys NOT already destructured.
779
789
  const indexLocalLines: string[] = []
780
- if (loop.iterationShape === 'keys') {
790
+ if (loop.objectIteration) {
791
+ // `key`/`value` bind directly in the for-header (see `array` below)
792
+ // via Jinja's own dict iteration — no derived `loop.index0` local
793
+ // needed, unlike the array `iterationShape` cases.
794
+ } else if (loop.iterationShape === 'keys') {
781
795
  indexLocalLines.push(`{% set ${jinjaIdent(param)} = loop.index0 %}`)
782
796
  } else if (loop.index) {
783
797
  indexLocalLines.push(`{% set ${jinjaIdent(loop.index)} = loop.index0 %}`)
@@ -802,10 +816,13 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
802
816
 
803
817
  const prevInLoop = this.inLoop
804
818
  this.inLoop = true
819
+ const prevLoopKeyDepth = this.currentLoopKeyDepth
820
+ this.currentLoopKeyDepth = loop.depth
805
821
  // Re-render children now that inLoop is set (so nested components use the
806
822
  // loop-child naming convention). renderedChildren above was computed with
807
823
  // the previous flag; recompute under the loop flag.
808
824
  const childrenUnderLoop = this.renderChildren(loop.children)
825
+ this.currentLoopKeyDepth = prevLoopKeyDepth
809
826
  this.inLoop = prevInLoop
810
827
  void renderedChildren
811
828
 
@@ -822,7 +839,19 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
822
839
  // Scoped per-call-site marker so sibling `.map()`s under the same parent
823
840
  // each get their own reconciliation range.
824
841
  lines.push(`{{ bf.comment("loop:${loop.markerId}") | safe }}`)
825
- lines.push(`{% for ${jinjaIdent(loopVar)} in ${array} %}`)
842
+ // `objectIteration` (#2168 object-entries-map): Python `dict` preserves
843
+ // JS `Object.entries()`'s insertion-order semantics natively, so this
844
+ // lowers straight to Jinja's own dict-iteration forms — no runtime
845
+ // helper needed. `.items()` binds `index` (the KEY) alongside `param`;
846
+ // `.keys()`/`.values()` bind `param` alone.
847
+ const forHeader = loop.objectIteration === 'entries'
848
+ ? `{% for ${jinjaIdent(loop.index ?? param)}, ${jinjaIdent(param)} in ${array}.items() %}`
849
+ : loop.objectIteration === 'keys'
850
+ ? `{% for ${jinjaIdent(param)} in ${array}.keys() %}`
851
+ : loop.objectIteration === 'values'
852
+ ? `{% for ${jinjaIdent(param)} in ${array}.values() %}`
853
+ : `{% for ${jinjaIdent(loopVar)} in ${array} %}`
854
+ lines.push(forHeader)
826
855
  for (const il of indexLocalLines) lines.push(il)
827
856
 
828
857
  // Handle filter().map() pattern by wrapping children in if-condition
@@ -974,11 +1003,33 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
974
1003
  type Segment = { kind: 'entries'; parts: string[] } | { kind: 'spread'; expr: string }
975
1004
  const segments: Segment[] = [{ kind: 'entries', parts: [] }]
976
1005
  const currentEntries = () => this.componentPropSegmentEntries(segments)
1006
+ // Named JSX-valued props OTHER than the reserved `children`
1007
+ // (`header={<strong>Title</strong>}`, #2168 jsx-element-prop) each get
1008
+ // their own `{% set %}` capture, prepended to the final returned
1009
+ // string below — same mechanism as the reserved children capture,
1010
+ // just keyed by the prop's own name instead of `children`.
1011
+ const namedSlotSetBlocks: string[] = []
977
1012
 
978
1013
  for (const p of comp.props) {
979
1014
  // Skip callback props (onXxx) and `ref` — both are client-only for
980
1015
  // SSR (Hono renders neither; the client JS wires them at hydration).
981
1016
  if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
1017
+ if (p.value.kind === 'jsx-children' && p.name !== 'children') {
1018
+ const prevInLoop = this.inLoop
1019
+ this.inLoop = false
1020
+ const slotBody = this.renderChildren(p.value.children)
1021
+ this.inLoop = prevInLoop
1022
+ // Purely counter-based — NOT derived from `p.name` or `comp.slotId`.
1023
+ // A JSX prop name can contain characters (`data-slot`) that aren't a
1024
+ // valid Jinja `{% set %}` target, and `comp.slotId` alone would
1025
+ // collide across two named-slot props on the same component
1026
+ // invocation (unlike the reserved children slot, there's only ever
1027
+ // one of those per invocation).
1028
+ const captureName = `bf_prop_${this.childrenCaptureCounter++}`
1029
+ namedSlotSetBlocks.push(`{% set ${captureName} %}${slotBody}{% endset %}`)
1030
+ currentEntries().push(`${jinjaHashKey(p.name)}: ${captureName}`)
1031
+ continue
1032
+ }
982
1033
  if (p.value.kind === 'spread') {
983
1034
  const trimmed = p.value.expr.trim()
984
1035
  // SolidJS-style props identifier (`function(props: P)`) has no
@@ -1042,12 +1093,12 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
1042
1093
  const captureName = `bf_children_${comp.slotId ?? 'c' + this.childrenCaptureCounter++}`
1043
1094
  currentEntries().push(`${jinjaHashKey('children')}: ${captureName}`)
1044
1095
  const dict = this.combineComponentPropSegments(segments)
1045
- return `{% set ${captureName} %}${childrenBody}{% endset %}{{ bf.render_child('${tplName}', ${dict}) | safe }}`
1096
+ return `${namedSlotSetBlocks.join('')}{% set ${captureName} %}${childrenBody}{% endset %}{{ bf.render_child('${tplName}', ${dict}) | safe }}`
1046
1097
  }
1047
1098
 
1048
1099
  const isEmpty = segments.every(s => s.kind === 'entries' && s.parts.length === 0)
1049
1100
  const dictEntries = isEmpty ? '' : `, ${this.combineComponentPropSegments(segments)}`
1050
- return `{{ bf.render_child('${tplName}'${dictEntries}) | safe }}`
1101
+ return `${namedSlotSetBlocks.join('')}{{ bf.render_child('${tplName}'${dictEntries}) | safe }}`
1051
1102
  }
1052
1103
 
1053
1104
  private childrenCaptureCounter = 0
@@ -1132,7 +1183,7 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
1132
1183
  * AttrValue lowering for intrinsic-element attributes (Jinja).
1133
1184
  */
1134
1185
  private readonly elementAttrEmitter: AttrValueEmitter = {
1135
- emitLiteral: (value, name) => `${name}="${value.value}"`,
1186
+ emitLiteral: (value, name) => `${name}="${escapeHtml(value.value)}"`,
1136
1187
  emitExpression: (value, name) => {
1137
1188
  // `style={{ … }}` object literal → a CSS string with dynamic values
1138
1189
  // interpolated, instead of refusing the bare object with BF101 (#1322).
@@ -1337,7 +1388,10 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
1337
1388
  // Rewrite JSX special-prop names to their HTML-attribute counterparts.
1338
1389
  let attrName: string
1339
1390
  if (attr.name === 'className') attrName = 'class'
1340
- else if (attr.name === 'key') attrName = 'data-key'
1391
+ else if (attr.name === 'key') {
1392
+ const depth = this.currentLoopKeyDepth
1393
+ attrName = depth > 0 ? `data-key-${depth}` : 'data-key'
1394
+ }
1341
1395
  else attrName = attr.name
1342
1396
  const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
1343
1397
  if (lowered) parts.push(lowered)
@@ -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
  /**
@@ -104,9 +104,4 @@ export const conformancePins: ConformancePins = {
104
104
  // the shape loudly instead of emitting entity-escaped markup that
105
105
  // silently renders tags as text.
106
106
  'dangerous-inner-html': [{ code: 'BF101', severity: 'error' }],
107
- // Edge-case sweep (Priority 12): `.replaceAll` has no lowering yet —
108
- // only first-occurrence `.replace` is wired to the runtime helpers.
109
- // Refused with BF101 rather than reusing the first-only lowering,
110
- // which would silently change semantics.
111
- 'string-replaceall': [{ code: 'BF101', severity: 'error' }],
112
107
  }
@@ -14,29 +14,4 @@
14
14
 
15
15
  import type { RenderDivergences } from '@barefootjs/jsx'
16
16
 
17
- export const renderDivergences: RenderDivergences = {
18
- 'arithmetic-text':
19
- '`(count() + 2) * 3` renders 10 instead of 18 — the parenthesised sub-expression loses its grouping (silent wrong arithmetic)',
20
- 'html-entity-text':
21
- '`&copy;` in JSX literal text: Hono decodes to `©`, this adapter re-emits the raw entity — same DOM, different bytes',
22
- 'math-methods':
23
- 'Math.min/max/abs over a signal render empty (only Math.floor is in the template-primitive registry)',
24
- 'boolean-attr-literals':
25
- 'camelCase boolean alias `readOnly`: Hono SSRs `readOnly="true"`, this adapter emits bare presence',
26
- 'camelcase-attributes':
27
- '`htmlFor` is not lowered to `for` (Hono maps it)',
28
- 'static-attr-escape':
29
- 'static attribute values are not HTML-escaped (`title="Fish & Chips"` emitted raw; Hono escapes)',
30
- 'svg-icon':
31
- 'SVG camelCase presentation attrs (`strokeWidth`, `strokeLinecap`) pass through unmapped; Hono lowers to kebab-case',
32
- 'object-entries-map':
33
- '`Object.entries(prop).map(([k, v]) => …)` renders an EMPTY list — the object-shaped prop silently produces zero iterations',
34
- 'nested-loop-outer-binding':
35
- 'nested-loop inner items carry `data-key` where the reference emits the depth-suffixed `data-key-1`',
36
- 'jsx-element-prop':
37
- 'a JSX element passed as a NON-children prop renders an empty slot — the element value is silently dropped',
38
- 'string-slice':
39
- '`.slice()` on a STRING renders empty (array-slice helper misfires on strings)',
40
- 'string-trim-sided':
41
- '`.trimStart()` / `.trimEnd()` render empty (no lowering)',
42
- }
17
+ export const renderDivergences: RenderDivergences = {}