@barefootjs/xslate 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.
package/dist/index.js CHANGED
@@ -187365,7 +187365,7 @@ function isAriaBooleanAttr(name) {
187365
187365
  }
187366
187366
 
187367
187367
  // src/adapter/xslate-adapter.ts
187368
- import { BF_SLOT, BF_COND, BF_REGION } from "@barefootjs/shared";
187368
+ import { BF_SLOT, BF_COND, BF_REGION, escapeHtml } from "@barefootjs/shared";
187369
187369
 
187370
187370
  // src/adapter/lib/constants.ts
187371
187371
  var XSLATE_TEMPLATE_PRIMITIVES = {
@@ -187374,7 +187374,10 @@ var XSLATE_TEMPLATE_PRIMITIVES = {
187374
187374
  Number: { arity: 1, emit: (args) => `$bf.number(${args[0]})` },
187375
187375
  "Math.floor": { arity: 1, emit: (args) => `$bf.floor(${args[0]})` },
187376
187376
  "Math.ceil": { arity: 1, emit: (args) => `$bf.ceil(${args[0]})` },
187377
- "Math.round": { arity: 1, emit: (args) => `$bf.round(${args[0]})` }
187377
+ "Math.round": { arity: 1, emit: (args) => `$bf.round(${args[0]})` },
187378
+ "Math.min": { arity: 2, emit: (args) => `$bf.min(${args[0]}, ${args[1]})` },
187379
+ "Math.max": { arity: 2, emit: (args) => `$bf.max(${args[0]}, ${args[1]})` },
187380
+ "Math.abs": { arity: 1, emit: (args) => `$bf.abs(${args[0]})` }
187378
187381
  };
187379
187382
  var XSLATE_PRIMITIVE_EMIT_MAP = Object.fromEntries(Object.entries(XSLATE_TEMPLATE_PRIMITIVES).map(([k, v]) => [k, v.emit]));
187380
187383
 
@@ -187479,6 +187482,12 @@ function renderArrayMethod(method, object, args, emit) {
187479
187482
  const recv = emit(object);
187480
187483
  return `$bf.trim(${recv})`;
187481
187484
  }
187485
+ case "trimStart":
187486
+ case "trimEnd": {
187487
+ const fn = method === "trimStart" ? "trim_start" : "trim_end";
187488
+ const recv = emit(object);
187489
+ return `$bf.${fn}(${recv})`;
187490
+ }
187482
187491
  case "toFixed": {
187483
187492
  const recv = emit(object);
187484
187493
  const digits = args.length >= 1 ? emit(args[0]) : "0";
@@ -187512,6 +187521,12 @@ function renderArrayMethod(method, object, args, emit) {
187512
187521
  const newS = emit(args[1]);
187513
187522
  return `$bf.replace(${recv}, ${oldS}, ${newS})`;
187514
187523
  }
187524
+ case "replaceAll": {
187525
+ const recv = emit(object);
187526
+ const oldS = emit(args[0]);
187527
+ const newS = emit(args[1]);
187528
+ return `$bf.replace_all(${recv}, ${oldS}, ${newS})`;
187529
+ }
187515
187530
  case "repeat": {
187516
187531
  const recv = emit(object);
187517
187532
  const count = args.length === 0 ? "0" : emit(args[0]);
@@ -187611,6 +187626,8 @@ function renderFlatMethod(recv, depth, emit) {
187611
187626
 
187612
187627
  // src/adapter/expr/emitters.ts
187613
187628
  import {
187629
+ groupBinaryOperand,
187630
+ isStringConcatBinary,
187614
187631
  identifierPath,
187615
187632
  matchSearchParamsMethodCall,
187616
187633
  sortComparatorFromArrow
@@ -187653,7 +187670,7 @@ class XslateFilterEmitter {
187653
187670
  return "nil";
187654
187671
  return String(value);
187655
187672
  }
187656
- member(object, property, _computed, emit) {
187673
+ member(object, property, _computed, _optional, emit) {
187657
187674
  if (property === "length") {
187658
187675
  return `$bf.length(${emit(object)})`;
187659
187676
  }
@@ -187679,8 +187696,11 @@ class XslateFilterEmitter {
187679
187696
  return arg;
187680
187697
  }
187681
187698
  binary(op, left, right, emit) {
187682
- const l = emit(left);
187683
- const r = emit(right);
187699
+ const l = groupBinaryOperand(left, emit(left));
187700
+ const r = groupBinaryOperand(right, emit(right));
187701
+ if (isStringConcatBinary(op, left, right, this.isStringName)) {
187702
+ return `${l} ~ ${r}`;
187703
+ }
187684
187704
  const opMap = {
187685
187705
  "===": "==",
187686
187706
  "!==": "!=",
@@ -187762,7 +187782,7 @@ class XslateTopLevelEmitter {
187762
187782
  return "nil";
187763
187783
  return String(value);
187764
187784
  }
187765
- member(object, property, _computed, emit) {
187785
+ member(object, property, _computed, _optional, emit) {
187766
187786
  if (object.kind === "identifier" && object.name === "props") {
187767
187787
  return `$${property}`;
187768
187788
  }
@@ -187809,8 +187829,11 @@ class XslateTopLevelEmitter {
187809
187829
  return arg;
187810
187830
  }
187811
187831
  binary(op, left, right, emit) {
187812
- const l = emit(left);
187813
- const r = emit(right);
187832
+ const l = groupBinaryOperand(left, emit(left));
187833
+ const r = groupBinaryOperand(right, emit(right));
187834
+ if (isStringConcatBinary(op, left, right, (n) => this.ctx._isStringValueName(n))) {
187835
+ return `${l} ~ ${r}`;
187836
+ }
187814
187837
  const opMap = {
187815
187838
  "===": "==",
187816
187839
  "!==": "!=",
@@ -188200,6 +188223,7 @@ class XslateAdapter extends BaseAdapter {
188200
188223
  options;
188201
188224
  errors = [];
188202
188225
  inLoop = false;
188226
+ currentLoopKeyDepth = 0;
188203
188227
  propsObjectName = null;
188204
188228
  propsParams = [];
188205
188229
  booleanTypedProps = new Set;
@@ -188276,7 +188300,7 @@ class XslateAdapter extends BaseAdapter {
188276
188300
  return this.renderElement(node);
188277
188301
  }
188278
188302
  emitText(node) {
188279
- return node.value;
188303
+ return escapeHtml(node.value);
188280
188304
  }
188281
188305
  emitExpression(node) {
188282
188306
  return this.renderExpression(node);
@@ -188500,9 +188524,12 @@ ${whenTrue}
188500
188524
  }
188501
188525
  const param = loop.param;
188502
188526
  const renderedChildren = this.renderChildren(loop.children);
188503
- const loopVar = loop.iterationShape === "keys" ? "__bf_item" : supportableDestructure ? "__bf_item" : param;
188527
+ const loopVar = loop.objectIteration === "entries" ? "__bf_pair" : loop.objectIteration ? param : loop.iterationShape === "keys" ? "__bf_item" : supportableDestructure ? "__bf_item" : param;
188504
188528
  const indexLocalLines = [];
188505
- if (loop.iterationShape === "keys") {
188529
+ if (loop.objectIteration === "entries") {
188530
+ indexLocalLines.push(`: my $${loop.index ?? param} = $${loopVar}.key;`);
188531
+ indexLocalLines.push(`: my $${param} = $${loopVar}.value;`);
188532
+ } else if (loop.objectIteration) {} else if (loop.iterationShape === "keys") {
188506
188533
  indexLocalLines.push(`: my $${param} = $~${loopVar}.index;`);
188507
188534
  } else if (loop.index) {
188508
188535
  indexLocalLines.push(`: my $${loop.index} = $~${loopVar}.index;`);
@@ -188522,13 +188549,17 @@ ${whenTrue}
188522
188549
  }
188523
188550
  const prevInLoop = this.inLoop;
188524
188551
  this.inLoop = true;
188552
+ const prevLoopKeyDepth = this.currentLoopKeyDepth;
188553
+ this.currentLoopKeyDepth = loop.depth;
188525
188554
  const childrenUnderLoop = this.renderChildren(loop.children);
188555
+ this.currentLoopKeyDepth = prevLoopKeyDepth;
188526
188556
  this.inLoop = prevInLoop;
188527
188557
  const bodyChildren = loop.bodyIsItemConditional && loop.key ? `<: $bf.comment("loop-i:" ~ ${this.convertExpressionToKolon(loop.key)}) | mark_raw :>
188528
188558
  ${childrenUnderLoop}` : childrenUnderLoop;
188529
188559
  const lines = [];
188530
188560
  lines.push(`<: $bf.comment("loop:${loop.markerId}") | mark_raw :>`);
188531
- lines.push(`: for ${array} -> $${loopVar} {`);
188561
+ const forHeader = loop.objectIteration === "entries" ? `: for ${array}.kv() -> $${loopVar} {` : loop.objectIteration === "keys" ? `: for ${array}.keys() -> $${loopVar} {` : loop.objectIteration === "values" ? `: for ${array}.values() -> $${loopVar} {` : `: for ${array} -> $${loopVar} {`;
188562
+ lines.push(forHeader);
188532
188563
  for (const il of indexLocalLines)
188533
188564
  lines.push(il);
188534
188565
  if (loop.filterPredicate) {
@@ -188599,9 +188630,20 @@ ${childrenUnderLoop}` : childrenUnderLoop;
188599
188630
  renderComponent(comp) {
188600
188631
  const segments = [{ kind: "entries", parts: [] }];
188601
188632
  const currentEntries = () => this.componentPropSegmentEntries(segments);
188633
+ const namedSlotMacros = [];
188602
188634
  for (const p of comp.props) {
188603
188635
  if ((p.name.match(/^on[A-Z]/) || p.name === "ref") && p.value.kind === "expression")
188604
188636
  continue;
188637
+ if (p.value.kind === "jsx-children" && p.name !== "children") {
188638
+ const prevInLoop = this.inLoop;
188639
+ this.inLoop = false;
188640
+ const slotBody = this.renderChildren(p.value.children);
188641
+ this.inLoop = prevInLoop;
188642
+ const macroName = `bf_prop_${this.childrenCaptureCounter++}`;
188643
+ namedSlotMacros.push(`<: macro ${macroName} -> () { :>${slotBody}<: } :>`);
188644
+ currentEntries().push(`${kolonHashKey(p.name)} => ${macroName}()`);
188645
+ continue;
188646
+ }
188605
188647
  if (p.value.kind === "spread") {
188606
188648
  const trimmed = p.value.expr.trim();
188607
188649
  if (this.propsObjectName && this.propsObjectName === trimmed) {
@@ -188630,11 +188672,11 @@ ${childrenUnderLoop}` : childrenUnderLoop;
188630
188672
  const macroName = `bf_children_${comp.slotId ?? "c" + this.childrenCaptureCounter++}`;
188631
188673
  currentEntries().push(`children => ${macroName}()`);
188632
188674
  const dict = this.combineComponentPropSegments(segments);
188633
- return `<: macro ${macroName} -> () { :>${childrenBody}<: } :><: $bf.render_child('${tplName}', ${dict}) | mark_raw :>`;
188675
+ return `${namedSlotMacros.join("")}<: macro ${macroName} -> () { :>${childrenBody}<: } :><: $bf.render_child('${tplName}', ${dict}) | mark_raw :>`;
188634
188676
  }
188635
188677
  const isEmpty = segments.every((s) => s.kind === "entries" && s.parts.length === 0);
188636
188678
  const hashEntries = isEmpty ? "" : `, ${this.combineComponentPropSegments(segments)}`;
188637
- return `<: $bf.render_child('${tplName}'${hashEntries}) | mark_raw :>`;
188679
+ return `${namedSlotMacros.join("")}<: $bf.render_child('${tplName}'${hashEntries}) | mark_raw :>`;
188638
188680
  }
188639
188681
  childrenCaptureCounter = 0;
188640
188682
  presenceVarCounter = 0;
@@ -188679,7 +188721,7 @@ ${alternate}
188679
188721
  ${children}`;
188680
188722
  }
188681
188723
  elementAttrEmitter = {
188682
- emitLiteral: (value, name) => `${name}="${value.value}"`,
188724
+ emitLiteral: (value, name) => `${name}="${escapeHtml(value.value)}"`,
188683
188725
  emitExpression: (value, name) => {
188684
188726
  if (name === "style") {
188685
188727
  const css = this.tryLowerStyleObject(value.expr);
@@ -188786,9 +188828,10 @@ ${name}="<: ${val} :>"
188786
188828
  let attrName;
188787
188829
  if (attr.name === "className")
188788
188830
  attrName = "class";
188789
- else if (attr.name === "key")
188790
- attrName = "data-key";
188791
- else
188831
+ else if (attr.name === "key") {
188832
+ const depth = this.currentLoopKeyDepth;
188833
+ attrName = depth > 0 ? `data-key-${depth}` : "data-key";
188834
+ } else
188792
188835
  attrName = attr.name;
188793
188836
  const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName);
188794
188837
  if (lowered)
@@ -188875,6 +188918,7 @@ ${reason}` : "";
188875
188918
  _resolveModuleStringConst: (name) => this._resolveModuleStringConst(name),
188876
188919
  _resolveLiteralConst: (name) => this._resolveLiteralConst(name),
188877
188920
  _resolveStaticRecordLiteral: (o, k) => this._resolveStaticRecordLiteral(o, k),
188921
+ _isStringValueName: (name) => this._isStringValueName(name),
188878
188922
  _recordExprBF101: (message, reason) => this._recordExprBF101(message, reason),
188879
188923
  _renderKolonFilterExprPublic: (e, p) => this._renderKolonFilterExprPublic(e, p)
188880
188924
  };
@@ -189028,25 +189072,10 @@ var conformancePins = {
189028
189072
  { code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2038" }
189029
189073
  ],
189030
189074
  "array-map-function-reference": [{ code: "BF101", severity: "error" }],
189031
- "dangerous-inner-html": [{ code: "BF101", severity: "error" }],
189032
- "string-replaceall": [{ code: "BF101", severity: "error" }]
189075
+ "dangerous-inner-html": [{ code: "BF101", severity: "error" }]
189033
189076
  };
189034
189077
  // src/render-divergences.ts
189035
- var renderDivergences = {
189036
- "arithmetic-text": "`(count() + 2) * 3` renders 10 instead of 18 — the parenthesised sub-expression loses its grouping (silent wrong arithmetic)",
189037
- "string-concat-plus": "`'Hello, ' + name` numeric-coerces through Perl's `+` (needs `.`/`~` concat)",
189038
- "html-entity-text": "`&copy;` in JSX literal text: Hono decodes to `©`, this adapter re-emits the raw entity — same DOM, different bytes",
189039
- "math-methods": "Math.min/max/abs over a signal render empty (only Math.floor is in the template-primitive registry)",
189040
- "boolean-attr-literals": 'camelCase boolean alias `readOnly`: Hono SSRs `readOnly="true"`, this adapter emits bare presence',
189041
- "camelcase-attributes": "`htmlFor` is not lowered to `for` (Hono maps it)",
189042
- "static-attr-escape": 'static attribute values are not HTML-escaped (`title="Fish & Chips"` emitted raw; Hono escapes)',
189043
- "svg-icon": "SVG camelCase presentation attrs (`strokeWidth`, `strokeLinecap`) pass through unmapped; Hono lowers to kebab-case",
189044
- "object-entries-map": "`Object.entries(prop).map(([k, v]) => …)` renders an EMPTY list — the object-shaped prop silently produces zero iterations",
189045
- "nested-loop-outer-binding": "nested-loop inner items carry `data-key` where the reference emits the depth-suffixed `data-key-1`",
189046
- "jsx-element-prop": "a JSX element passed as a NON-children prop renders an empty slot — the element value is silently dropped",
189047
- "string-slice": "`.slice()` on a STRING misfires through the array slice helper",
189048
- "string-trim-sided": "`.trimStart()` / `.trimEnd()` render empty (no lowering)"
189049
- };
189078
+ var renderDivergences = {};
189050
189079
  export {
189051
189080
  xslateAdapter,
189052
189081
  renderDivergences,
@@ -1 +1 @@
1
- {"version":3,"file":"render-divergences.d.ts","sourceRoot":"","sources":["../src/render-divergences.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAExD,eAAO,MAAM,iBAAiB,EAAE,iBA2B/B,CAAA"}
1
+ {"version":3,"file":"render-divergences.d.ts","sourceRoot":"","sources":["../src/render-divergences.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAExD,eAAO,MAAM,iBAAiB,EAAE,iBAAsB,CAAA"}
@@ -1,5 +1,5 @@
1
1
  package BarefootJS::Backend::Xslate;
2
- our $VERSION = "0.18.2";
2
+ our $VERSION = "0.18.4";
3
3
  use strict;
4
4
  use warnings;
5
5
  use utf8;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/xslate",
3
- "version": "0.18.3",
3
+ "version": "0.18.5",
4
4
  "description": "Text::Xslate (Kolon) adapter for BarefootJS — compiles IR to .tx templates and ships the Xslate rendering backend; runs under any PSGI/Plack app",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -55,14 +55,14 @@
55
55
  "directory": "packages/adapter-xslate"
56
56
  },
57
57
  "dependencies": {
58
- "@barefootjs/shared": "0.18.3"
58
+ "@barefootjs/shared": "0.18.5"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@barefootjs/jsx": ">=0.2.0"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@barefootjs/adapter-tests": "0.1.0",
65
- "@barefootjs/jsx": "0.18.3",
65
+ "@barefootjs/jsx": "0.18.5",
66
66
  "typescript": "^5.0.0"
67
67
  }
68
68
  }
@@ -396,6 +396,26 @@ export { A }
396
396
  })
397
397
  })
398
398
 
399
+ describe('XslateAdapter - named-slot capture identifier safety (#2168 jsx-element-prop)', () => {
400
+ // A JSX-valued prop under a hyphenated name (`data-slot`, a valid JSX
401
+ // attribute name) must not leak into the Kolon macro's identifier — Kolon
402
+ // macro names can't contain `-`. The macro name is purely counter-based
403
+ // (never derived from the prop name); the hash KEY passed to
404
+ // `render_child` still carries the real name, quoted via `kolonHashKey`.
405
+ test('a hyphenated prop name does not appear in the macro name', () => {
406
+ const { template } = compileAndGenerate(`
407
+ function Card(props) { return null }
408
+ export function Parent() {
409
+ return <Card data-slot={<strong>Title</strong>}>text</Card>
410
+ }
411
+ `)
412
+ expect(template).toContain('<: macro bf_prop_0 -> ()')
413
+ expect(template).toContain("'data-slot' => bf_prop_0()")
414
+ expect(template).not.toContain('data-slot -> ()')
415
+ expect(template).not.toContain('data-slot_')
416
+ })
417
+ })
418
+
399
419
  // #2038 nested-callback-predicate loudness is pinned at the shared
400
420
  // conformance layer: `filter-nested-callback-predicate` /
401
421
  // `filter-nested-find-predicate` (BF101 via `expectedDiagnostics` above) and
@@ -42,6 +42,9 @@ export interface XslateEmitContext {
42
42
  */
43
43
  _resolveStaticRecordLiteral(objectName: string, key: string): string | null
44
44
 
45
+ /** Whether a getter/prop name resolves to a string-typed SSR value. */
46
+ _isStringValueName(name: string): boolean
47
+
45
48
  /** Record a BF101 unsupported-expression diagnostic. */
46
49
  _recordExprBF101(message: string, reason?: string): void
47
50
 
@@ -89,6 +89,15 @@ export function renderArrayMethod(
89
89
  const recv = emit(object)
90
90
  return `$bf.trim(${recv})`
91
91
  }
92
+ case 'trimStart':
93
+ case 'trimEnd': {
94
+ // `.trimStart()` / `.trimEnd()` — the one-sided siblings of
95
+ // `.trim()` (#2183 follow-up). Dedicated `$bf.trim_start` /
96
+ // `$bf.trim_end` helpers, not `$bf.trim` with a flag.
97
+ const fn = method === 'trimStart' ? 'trim_start' : 'trim_end'
98
+ const recv = emit(object)
99
+ return `$bf.${fn}(${recv})`
100
+ }
92
101
  case 'toFixed': {
93
102
  // `.toFixed(digits?)` — `$bf.to_fixed` mirrors JS rounding +
94
103
  // zero-padding (default 0 digits). #1897.
@@ -124,6 +133,16 @@ export function renderArrayMethod(
124
133
  const newS = emit(args[1])
125
134
  return `$bf.replace(${recv}, ${oldS}, ${newS})`
126
135
  }
136
+ case 'replaceAll': {
137
+ // `.replaceAll(old, new)` — string-pattern form, EVERY occurrence,
138
+ // via the dedicated `$bf.replace_all` helper (not `$bf.replace`
139
+ // with a flag) — the regex-pattern form is refused upstream at
140
+ // the parser, same as `.replace`. See #2182.
141
+ const recv = emit(object)
142
+ const oldS = emit(args[0])
143
+ const newS = emit(args[1])
144
+ return `$bf.replace_all(${recv}, ${oldS}, ${newS})`
145
+ }
127
146
  case 'repeat': {
128
147
  const recv = emit(object)
129
148
  const count = args.length === 0 ? '0' : emit(args[0])
@@ -11,7 +11,8 @@
11
11
  * the adapter only through the narrow `XslateEmitContext` seam.
12
12
  */
13
13
 
14
- import {
14
+ import { groupBinaryOperand,
15
+ isStringConcatBinary,
15
16
  type ParsedExprEmitter,
16
17
  type HigherOrderMethod,
17
18
  type ArrayMethod,
@@ -93,7 +94,7 @@ export class XslateFilterEmitter implements ParsedExprEmitter {
93
94
  return String(value)
94
95
  }
95
96
 
96
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
97
+ member(object: ParsedExpr, property: string, _computed: boolean, _optional: boolean, emit: (e: ParsedExpr) => string): string {
97
98
  // `.length` — route through `$bf.length` (handles both array element
98
99
  // count and string char count, JS-compatibly). Kolon's builtin `.size()`
99
100
  // is array-only and faults on a string.
@@ -130,8 +131,17 @@ export class XslateFilterEmitter implements ParsedExprEmitter {
130
131
  }
131
132
 
132
133
  binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
133
- const l = emit(left)
134
- const r = emit(right)
134
+ // Preserve source grouping: a compound operand re-emitted as infix
135
+ // text is otherwise re-parsed under THIS language's precedence —
136
+ // `(count() + 2) * 3` would silently become `count + 2 * 3` (#2173).
137
+ const l = groupBinaryOperand(left, emit(left))
138
+ const r = groupBinaryOperand(right, emit(right))
139
+ // JS `+` with a string-typed operand is CONCATENATION, not addition —
140
+ // Kolon's `+` is numeric-only and coerces `'Hello, ' + $name` to 0
141
+ // (#2176). Lower to Kolon's `~` concat operator.
142
+ if (isStringConcatBinary(op, left, right, this.isStringName)) {
143
+ return `${l} ~ ${r}`
144
+ }
135
145
  // Kolon's `==` / `!=` are value-equality operators that compare strings
136
146
  // and numbers correctly — unlike Perl's numeric `==` (which the Mojo
137
147
  // adapter must steer around with `eq`/`ne`). Kolon has no `eq`/`ne`
@@ -253,7 +263,7 @@ export class XslateTopLevelEmitter implements ParsedExprEmitter {
253
263
  return String(value)
254
264
  }
255
265
 
256
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
266
+ member(object: ParsedExpr, property: string, _computed: boolean, _optional: boolean, emit: (e: ParsedExpr) => string): string {
257
267
  // `props.x` flattens to the bare `$x` the SSR caller binds each prop to
258
268
  // (props arrive as individual top-level vars, not a `$props` hashref).
259
269
  if (object.kind === 'identifier' && object.name === 'props') {
@@ -322,8 +332,19 @@ export class XslateTopLevelEmitter implements ParsedExprEmitter {
322
332
  }
323
333
 
324
334
  binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
325
- const l = emit(left)
326
- const r = emit(right)
335
+ // Preserve source grouping: a compound operand re-emitted as infix
336
+ // text is otherwise re-parsed under THIS language's precedence —
337
+ // `(count() + 2) * 3` would silently become `count + 2 * 3` (#2173).
338
+ const l = groupBinaryOperand(left, emit(left))
339
+ const r = groupBinaryOperand(right, emit(right))
340
+ // JS `+` with a string-typed operand is CONCATENATION, not addition —
341
+ // Kolon's `+` is numeric-only and coerces `'Hello, ' + $name` to 0
342
+ // (#2176). Lower to Kolon's `~` concat operator. The adapter's
343
+ // string-value registry catches getter/prop operands with no literal
344
+ // present (`firstName() + lastName()`).
345
+ if (isStringConcatBinary(op, left, right, n => this.ctx._isStringValueName(n))) {
346
+ return `${l} ~ ${r}`
347
+ }
327
348
  // Kolon's `==` / `!=` are value-equality operators handling both strings
328
349
  // and numbers (unlike Perl's numeric `==`, which the Mojo adapter must
329
350
  // route around with `eq`/`ne`). Kolon has no `eq`/`ne` operator, so all
@@ -23,6 +23,9 @@ export const XSLATE_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
23
23
  'Math.floor': { arity: 1, emit: (args) => `$bf.floor(${args[0]})` },
24
24
  'Math.ceil': { arity: 1, emit: (args) => `$bf.ceil(${args[0]})` },
25
25
  'Math.round': { arity: 1, emit: (args) => `$bf.round(${args[0]})` },
26
+ 'Math.min': { arity: 2, emit: (args) => `$bf.min(${args[0]}, ${args[1]})` },
27
+ 'Math.max': { arity: 2, emit: (args) => `$bf.max(${args[0]}, ${args[1]})` },
28
+ 'Math.abs': { arity: 1, emit: (args) => `$bf.abs(${args[0]})` },
26
29
  }
27
30
 
28
31
  /**
@@ -77,7 +77,7 @@ import {
77
77
  import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result.ts'
78
78
  import ts from 'typescript'
79
79
  import type { ParsedExpr, LoweringMatcher } from '@barefootjs/jsx'
80
- import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
80
+ import { BF_SLOT, BF_COND, BF_REGION, escapeHtml } from '@barefootjs/shared'
81
81
 
82
82
  import type { XslateRenderCtx } from './lib/types.ts'
83
83
  import { XSLATE_PRIMITIVE_EMIT_MAP } from './lib/constants.ts'
@@ -176,6 +176,14 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
176
176
  private options: Required<XslateAdapterOptions>
177
177
  private errors: CompilerError[] = []
178
178
  private inLoop: boolean = false
179
+ /**
180
+ * `IRLoop.depth` of the loop currently being rendered (save/restore
181
+ * around `renderChildren(loop.children)`, mirroring `inLoop` above).
182
+ * `renderAttributes` reads this to derive the `key` → `data-key`/
183
+ * `data-key-N` suffix — the depth is IR-computed (jsx-to-ir.ts), not
184
+ * re-derived here (#2168 nested-loop-outer-binding).
185
+ */
186
+ private currentLoopKeyDepth = 0
179
187
  /**
180
188
  * SolidJS-style props identifier (`function(props: P)`) and the
181
189
  * analyzer-extracted prop names. Stashed at `generate()` entry so the
@@ -375,7 +383,9 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
375
383
  }
376
384
 
377
385
  emitText(node: IRText): string {
378
- return node.value
386
+ // IRText carries the entity-DECODED value (Phase 1 decodes JSX
387
+ // character references); re-escape for direct HTML emission.
388
+ return escapeHtml(node.value)
379
389
  }
380
390
 
381
391
  emitExpression(node: IRExpression): string {
@@ -724,9 +734,13 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
724
734
  // For `keys`-shape iterations the callback param IS the index. We iterate
725
735
  // the array but bind the loop var to a throwaway and expose the index as
726
736
  // `$param`. Kolon's `$~loopvar.index` provides the 0-based index.
727
- const loopVar = loop.iterationShape === 'keys'
728
- ? '__bf_item'
729
- : supportableDestructure ? '__bf_item' : param
737
+ const loopVar = loop.objectIteration === 'entries'
738
+ ? '__bf_pair'
739
+ : loop.objectIteration
740
+ ? param
741
+ : loop.iterationShape === 'keys'
742
+ ? '__bf_item'
743
+ : supportableDestructure ? '__bf_item' : param
730
744
 
731
745
  // Index alias: when an explicit `index` param is present (`.map((x, i) =>
732
746
  // ...)`) or the iteration is `keys`-shaped, expose it via a `: my` Kolon
@@ -746,7 +760,16 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
746
760
  // `segments` (empty at the loop root, per the `LoopParamBinding` jsdoc) —
747
761
  // NOT the same as a fixed binding's full-accessor segments.
748
762
  const indexLocalLines: string[] = []
749
- if (loop.iterationShape === 'keys') {
763
+ if (loop.objectIteration === 'entries') {
764
+ // `key`/`value` bind off the `.kv()` pair (see the for-header below)
765
+ // — no derived `.index` local needed, unlike the array
766
+ // `iterationShape` cases.
767
+ indexLocalLines.push(`: my $${loop.index ?? param} = $${loopVar}.key;`)
768
+ indexLocalLines.push(`: my $${param} = $${loopVar}.value;`)
769
+ } else if (loop.objectIteration) {
770
+ // 'keys'/'values': `.keys()`/`.values()` already yield the bound
771
+ // value directly — no derived local needed either.
772
+ } else if (loop.iterationShape === 'keys') {
750
773
  indexLocalLines.push(`: my $${param} = $~${loopVar}.index;`)
751
774
  } else if (loop.index) {
752
775
  indexLocalLines.push(`: my $${loop.index} = $~${loopVar}.index;`)
@@ -769,10 +792,13 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
769
792
 
770
793
  const prevInLoop = this.inLoop
771
794
  this.inLoop = true
795
+ const prevLoopKeyDepth = this.currentLoopKeyDepth
796
+ this.currentLoopKeyDepth = loop.depth
772
797
  // Re-render children now that inLoop is set (so nested components use the
773
798
  // loop-child naming convention). renderedChildren above was computed with
774
799
  // the previous flag; recompute under the loop flag.
775
800
  const childrenUnderLoop = this.renderChildren(loop.children)
801
+ this.currentLoopKeyDepth = prevLoopKeyDepth
776
802
  this.inLoop = prevInLoop
777
803
  void renderedChildren
778
804
 
@@ -789,7 +815,21 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
789
815
  // Scoped per-call-site marker so sibling `.map()`s under the same parent
790
816
  // each get their own reconciliation range.
791
817
  lines.push(`<: $bf.comment("loop:${loop.markerId}") | mark_raw :>`)
792
- lines.push(`: for ${array} -> $${loopVar} {`)
818
+ // `objectIteration` (#2168 object-entries-map): Kolon has no built-in
819
+ // hash-destructure `for` target, so `'entries'` iterates `.kv()`
820
+ // (yielding `{key, value}` pair objects, unpacked via the `: my`
821
+ // locals above) while `'keys'`/`'values'` iterate `.keys()`/`.values()`
822
+ // directly. All three are alphabetically sorted by Text::Xslate itself
823
+ // (verified empirically) — not JS insertion order, a documented known
824
+ // limitation for out-of-alphabetical-order data, same as Go/Rust.
825
+ const forHeader = loop.objectIteration === 'entries'
826
+ ? `: for ${array}.kv() -> $${loopVar} {`
827
+ : loop.objectIteration === 'keys'
828
+ ? `: for ${array}.keys() -> $${loopVar} {`
829
+ : loop.objectIteration === 'values'
830
+ ? `: for ${array}.values() -> $${loopVar} {`
831
+ : `: for ${array} -> $${loopVar} {`
832
+ lines.push(forHeader)
793
833
  for (const il of indexLocalLines) lines.push(il)
794
834
 
795
835
  // Handle filter().map() pattern by wrapping children in if-condition
@@ -932,11 +972,33 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
932
972
  type Segment = { kind: 'entries'; parts: string[] } | { kind: 'spread'; expr: string }
933
973
  const segments: Segment[] = [{ kind: 'entries', parts: [] }]
934
974
  const currentEntries = () => this.componentPropSegmentEntries(segments)
975
+ // Named JSX-valued props OTHER than the reserved `children`
976
+ // (`header={<strong>Title</strong>}`, #2168 jsx-element-prop) each get
977
+ // their own macro, prepended to the final returned string below —
978
+ // same mechanism as the reserved children macro, just keyed by the
979
+ // prop's own name instead of `children`.
980
+ const namedSlotMacros: string[] = []
935
981
 
936
982
  for (const p of comp.props) {
937
983
  // Skip callback props (onXxx) and `ref` — both are client-only for
938
984
  // SSR (Hono renders neither; the client JS wires them at hydration).
939
985
  if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
986
+ if (p.value.kind === 'jsx-children' && p.name !== 'children') {
987
+ const prevInLoop = this.inLoop
988
+ this.inLoop = false
989
+ const slotBody = this.renderChildren(p.value.children)
990
+ this.inLoop = prevInLoop
991
+ // Purely counter-based — NOT derived from `p.name` or `comp.slotId`.
992
+ // A JSX prop name can contain characters (`data-slot`) that aren't a
993
+ // valid Kolon macro identifier, and `comp.slotId` alone would
994
+ // collide across two named-slot props on the same component
995
+ // invocation (unlike the reserved children slot, there's only ever
996
+ // one of those per invocation).
997
+ const macroName = `bf_prop_${this.childrenCaptureCounter++}`
998
+ namedSlotMacros.push(`<: macro ${macroName} -> () { :>${slotBody}<: } :>`)
999
+ currentEntries().push(`${kolonHashKey(p.name)} => ${macroName}()`)
1000
+ continue
1001
+ }
940
1002
  if (p.value.kind === 'spread') {
941
1003
  const trimmed = p.value.expr.trim()
942
1004
  // SolidJS-style props identifier (`function(props: P)`) has no
@@ -996,12 +1058,12 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
996
1058
  const macroName = `bf_children_${comp.slotId ?? 'c' + this.childrenCaptureCounter++}`
997
1059
  currentEntries().push(`children => ${macroName}()`)
998
1060
  const dict = this.combineComponentPropSegments(segments)
999
- return `<: macro ${macroName} -> () { :>${childrenBody}<: } :><: $bf.render_child('${tplName}', ${dict}) | mark_raw :>`
1061
+ return `${namedSlotMacros.join('')}<: macro ${macroName} -> () { :>${childrenBody}<: } :><: $bf.render_child('${tplName}', ${dict}) | mark_raw :>`
1000
1062
  }
1001
1063
 
1002
1064
  const isEmpty = segments.every(s => s.kind === 'entries' && s.parts.length === 0)
1003
1065
  const hashEntries = isEmpty ? '' : `, ${this.combineComponentPropSegments(segments)}`
1004
- return `<: $bf.render_child('${tplName}'${hashEntries}) | mark_raw :>`
1066
+ return `${namedSlotMacros.join('')}<: $bf.render_child('${tplName}'${hashEntries}) | mark_raw :>`
1005
1067
  }
1006
1068
 
1007
1069
  private childrenCaptureCounter = 0
@@ -1085,7 +1147,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1085
1147
  * AttrValue lowering for intrinsic-element attributes (Kolon).
1086
1148
  */
1087
1149
  private readonly elementAttrEmitter: AttrValueEmitter = {
1088
- emitLiteral: (value, name) => `${name}="${value.value}"`,
1150
+ emitLiteral: (value, name) => `${name}="${escapeHtml(value.value)}"`,
1089
1151
  emitExpression: (value, name) => {
1090
1152
  // `style={{ … }}` object literal → a CSS string with dynamic values
1091
1153
  // interpolated, instead of refusing the bare object with BF101 (#1322).
@@ -1285,7 +1347,10 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1285
1347
  // Rewrite JSX special-prop names to their HTML-attribute counterparts.
1286
1348
  let attrName: string
1287
1349
  if (attr.name === 'className') attrName = 'class'
1288
- else if (attr.name === 'key') attrName = 'data-key'
1350
+ else if (attr.name === 'key') {
1351
+ const depth = this.currentLoopKeyDepth
1352
+ attrName = depth > 0 ? `data-key-${depth}` : 'data-key'
1353
+ }
1289
1354
  else attrName = attr.name
1290
1355
  const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
1291
1356
  if (lowered) parts.push(lowered)
@@ -1436,6 +1501,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1436
1501
  _resolveModuleStringConst: (name) => this._resolveModuleStringConst(name),
1437
1502
  _resolveLiteralConst: (name) => this._resolveLiteralConst(name),
1438
1503
  _resolveStaticRecordLiteral: (o, k) => this._resolveStaticRecordLiteral(o, k),
1504
+ _isStringValueName: (name) => this._isStringValueName(name),
1439
1505
  _recordExprBF101: (message, reason) => this._recordExprBF101(message, reason),
1440
1506
  _renderKolonFilterExprPublic: (e, p) => this._renderKolonFilterExprPublic(e, p),
1441
1507
  }
@@ -115,9 +115,4 @@ export const conformancePins: ConformancePins = {
115
115
  // the shape loudly instead of emitting entity-escaped markup that
116
116
  // silently renders tags as text.
117
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' }],
123
118
  }