@barefootjs/rust 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
@@ -187369,7 +187369,7 @@ function isAriaBooleanAttr(name) {
187369
187369
  }
187370
187370
 
187371
187371
  // src/adapter/minijinja-adapter.ts
187372
- import { BF_SLOT, BF_COND, BF_REGION } from "@barefootjs/shared";
187372
+ import { BF_SLOT, BF_COND, BF_REGION, escapeHtml } from "@barefootjs/shared";
187373
187373
 
187374
187374
  // src/adapter/lib/constants.ts
187375
187375
  var JINJA_TEMPLATE_PRIMITIVES = {
@@ -187378,7 +187378,10 @@ var JINJA_TEMPLATE_PRIMITIVES = {
187378
187378
  Number: { arity: 1, emit: (args) => `bf.number(${args[0]})` },
187379
187379
  "Math.floor": { arity: 1, emit: (args) => `bf.floor(${args[0]})` },
187380
187380
  "Math.ceil": { arity: 1, emit: (args) => `bf.ceil(${args[0]})` },
187381
- "Math.round": { arity: 1, emit: (args) => `bf.round(${args[0]})` }
187381
+ "Math.round": { arity: 1, emit: (args) => `bf.round(${args[0]})` },
187382
+ "Math.min": { arity: 2, emit: (args) => `bf.min(${args[0]}, ${args[1]})` },
187383
+ "Math.max": { arity: 2, emit: (args) => `bf.max(${args[0]}, ${args[1]})` },
187384
+ "Math.abs": { arity: 1, emit: (args) => `bf.abs(${args[0]})` }
187382
187385
  };
187383
187386
  var JINJA_PRIMITIVE_EMIT_MAP = Object.fromEntries(Object.entries(JINJA_TEMPLATE_PRIMITIVES).map(([k, v]) => [k, v.emit]));
187384
187387
 
@@ -187544,6 +187547,12 @@ function renderArrayMethod(method, object, args, emit) {
187544
187547
  const recv = emit(object);
187545
187548
  return `bf.trim(${recv})`;
187546
187549
  }
187550
+ case "trimStart":
187551
+ case "trimEnd": {
187552
+ const fn = method === "trimStart" ? "trim_start" : "trim_end";
187553
+ const recv = emit(object);
187554
+ return `bf.${fn}(${recv})`;
187555
+ }
187547
187556
  case "toFixed": {
187548
187557
  const recv = emit(object);
187549
187558
  const digits = args.length >= 1 ? emit(args[0]) : "0";
@@ -187577,6 +187586,12 @@ function renderArrayMethod(method, object, args, emit) {
187577
187586
  const newS = emit(args[1]);
187578
187587
  return `bf.replace(${recv}, ${oldS}, ${newS})`;
187579
187588
  }
187589
+ case "replaceAll": {
187590
+ const recv = emit(object);
187591
+ const oldS = emit(args[0]);
187592
+ const newS = emit(args[1]);
187593
+ return `bf.replace_all(${recv}, ${oldS}, ${newS})`;
187594
+ }
187580
187595
  case "repeat": {
187581
187596
  const recv = emit(object);
187582
187597
  const count = args.length === 0 ? "0" : emit(args[0]);
@@ -187673,6 +187688,7 @@ function renderFlatMethod(recv, depth, emit) {
187673
187688
 
187674
187689
  // src/adapter/expr/emitters.ts
187675
187690
  import {
187691
+ groupBinaryOperand,
187676
187692
  identifierPath,
187677
187693
  matchSearchParamsMethodCall,
187678
187694
  sortComparatorFromArrow
@@ -187718,7 +187734,7 @@ class JinjaFilterEmitter {
187718
187734
  return "none";
187719
187735
  return String(value);
187720
187736
  }
187721
- member(object, property, _computed, emit) {
187737
+ member(object, property, _computed, _optional, emit) {
187722
187738
  if (property === "length") {
187723
187739
  return `bf.length(${emit(object)})`;
187724
187740
  }
@@ -187741,8 +187757,8 @@ class JinjaFilterEmitter {
187741
187757
  return emit(argument);
187742
187758
  }
187743
187759
  binary(op, left, right, emit) {
187744
- const l = emit(left);
187745
- const r = emit(right);
187760
+ const l = groupBinaryOperand(left, emit(left));
187761
+ const r = groupBinaryOperand(right, emit(right));
187746
187762
  const opMap = {
187747
187763
  "===": "==",
187748
187764
  "!==": "!=",
@@ -187824,7 +187840,7 @@ class JinjaTopLevelEmitter {
187824
187840
  return "none";
187825
187841
  return String(value);
187826
187842
  }
187827
- member(object, property, _computed, emit) {
187843
+ member(object, property, _computed, _optional, emit) {
187828
187844
  if (object.kind === "identifier" && object.name === "props") {
187829
187845
  return minijinjaIdent(property);
187830
187846
  }
@@ -187870,8 +187886,8 @@ class JinjaTopLevelEmitter {
187870
187886
  return emit(argument);
187871
187887
  }
187872
187888
  binary(op, left, right, emit) {
187873
- const l = emit(left);
187874
- const r = emit(right);
187889
+ const l = groupBinaryOperand(left, emit(left));
187890
+ const r = groupBinaryOperand(right, emit(right));
187875
187891
  const opMap = {
187876
187892
  "===": "==",
187877
187893
  "!==": "!=",
@@ -188251,6 +188267,7 @@ class MinijinjaAdapter extends BaseAdapter {
188251
188267
  options;
188252
188268
  errors = [];
188253
188269
  inLoop = false;
188270
+ currentLoopKeyDepth = 0;
188254
188271
  propsObjectName = null;
188255
188272
  propsParams = [];
188256
188273
  booleanTypedProps = new Set;
@@ -188327,7 +188344,7 @@ class MinijinjaAdapter extends BaseAdapter {
188327
188344
  return this.renderElement(node);
188328
188345
  }
188329
188346
  emitText(node) {
188330
- return node.value;
188347
+ return escapeHtml(node.value);
188331
188348
  }
188332
188349
  emitExpression(node) {
188333
188350
  return this.renderExpression(node);
@@ -188557,7 +188574,7 @@ ${whenTrue}
188557
188574
  const renderedChildren = this.renderChildren(loop.children);
188558
188575
  const loopVar = loop.iterationShape === "keys" ? "__bf_item" : supportableDestructure ? "__bf_item" : param;
188559
188576
  const indexLocalLines = [];
188560
- if (loop.iterationShape === "keys") {
188577
+ if (loop.objectIteration) {} else if (loop.iterationShape === "keys") {
188561
188578
  indexLocalLines.push(`{% set ${minijinjaIdent(param)} = loop.index0 %}`);
188562
188579
  } else if (loop.index) {
188563
188580
  indexLocalLines.push(`{% set ${minijinjaIdent(loop.index)} = loop.index0 %}`);
@@ -188577,13 +188594,17 @@ ${whenTrue}
188577
188594
  }
188578
188595
  const prevInLoop = this.inLoop;
188579
188596
  this.inLoop = true;
188597
+ const prevLoopKeyDepth = this.currentLoopKeyDepth;
188598
+ this.currentLoopKeyDepth = loop.depth;
188580
188599
  const childrenUnderLoop = this.renderChildren(loop.children);
188600
+ this.currentLoopKeyDepth = prevLoopKeyDepth;
188581
188601
  this.inLoop = prevInLoop;
188582
188602
  const bodyChildren = loop.bodyIsItemConditional && loop.key ? `{{ bf.comment("loop-i:" ~ bf.string(${this.convertExpressionToJinja(loop.key)})) | safe }}
188583
188603
  ${childrenUnderLoop}` : childrenUnderLoop;
188584
188604
  const lines = [];
188585
188605
  lines.push(`{{ bf.comment("loop:${loop.markerId}") | safe }}`);
188586
- lines.push(`{% for ${minijinjaIdent(loopVar)} in ${array} %}`);
188606
+ const forHeader = loop.objectIteration === "entries" ? `{% for ${minijinjaIdent(loop.index ?? param)}, ${minijinjaIdent(param)} in ${array}|items %}` : loop.objectIteration === "keys" ? `{% for ${minijinjaIdent(param)}, __bf_v in ${array}|items %}` : loop.objectIteration === "values" ? `{% for __bf_k, ${minijinjaIdent(param)} in ${array}|items %}` : `{% for ${minijinjaIdent(loopVar)} in ${array} %}`;
188607
+ lines.push(forHeader);
188587
188608
  for (const il of indexLocalLines)
188588
188609
  lines.push(il);
188589
188610
  if (loop.filterPredicate) {
@@ -188655,9 +188676,20 @@ ${childrenUnderLoop}` : childrenUnderLoop;
188655
188676
  renderComponent(comp) {
188656
188677
  const segments = [{ kind: "entries", parts: [] }];
188657
188678
  const currentEntries = () => this.componentPropSegmentEntries(segments);
188679
+ const namedSlotSetBlocks = [];
188658
188680
  for (const p of comp.props) {
188659
188681
  if ((p.name.match(/^on[A-Z]/) || p.name === "ref") && p.value.kind === "expression")
188660
188682
  continue;
188683
+ if (p.value.kind === "jsx-children" && p.name !== "children") {
188684
+ const prevInLoop = this.inLoop;
188685
+ this.inLoop = false;
188686
+ const slotBody = this.renderChildren(p.value.children);
188687
+ this.inLoop = prevInLoop;
188688
+ const captureName = `bf_prop_${this.childrenCaptureCounter++}`;
188689
+ namedSlotSetBlocks.push(`{% set ${captureName} %}${slotBody}{% endset %}`);
188690
+ currentEntries().push(`${minijinjaHashKey(p.name)}: ${captureName}`);
188691
+ continue;
188692
+ }
188661
188693
  if (p.value.kind === "spread") {
188662
188694
  const trimmed = p.value.expr.trim();
188663
188695
  if (this.propsObjectName && this.propsObjectName === trimmed) {
@@ -188686,11 +188718,11 @@ ${childrenUnderLoop}` : childrenUnderLoop;
188686
188718
  const captureName = `bf_children_${comp.slotId ?? "c" + this.childrenCaptureCounter++}`;
188687
188719
  currentEntries().push(`${minijinjaHashKey("children")}: ${captureName}`);
188688
188720
  const dict = this.combineComponentPropSegments(segments);
188689
- return `{% set ${captureName} %}${childrenBody}{% endset %}{{ bf.render_child('${tplName}', ${dict}) | safe }}`;
188721
+ return `${namedSlotSetBlocks.join("")}{% set ${captureName} %}${childrenBody}{% endset %}{{ bf.render_child('${tplName}', ${dict}) | safe }}`;
188690
188722
  }
188691
188723
  const isEmpty = segments.every((s) => s.kind === "entries" && s.parts.length === 0);
188692
188724
  const dictEntries = isEmpty ? "" : `, ${this.combineComponentPropSegments(segments)}`;
188693
- return `{{ bf.render_child('${tplName}'${dictEntries}) | safe }}`;
188725
+ return `${namedSlotSetBlocks.join("")}{{ bf.render_child('${tplName}'${dictEntries}) | safe }}`;
188694
188726
  }
188695
188727
  childrenCaptureCounter = 0;
188696
188728
  presenceVarCounter = 0;
@@ -188735,7 +188767,7 @@ ${alternate}
188735
188767
  ${children}`;
188736
188768
  }
188737
188769
  elementAttrEmitter = {
188738
- emitLiteral: (value, name) => `${name}="${value.value}"`,
188770
+ emitLiteral: (value, name) => `${name}="${escapeHtml(value.value)}"`,
188739
188771
  emitExpression: (value, name) => {
188740
188772
  if (name === "style") {
188741
188773
  const css = this.tryLowerStyleObject(value.expr);
@@ -188843,9 +188875,10 @@ ${name}="{{ bf.string(${val}) }}"
188843
188875
  let attrName;
188844
188876
  if (attr.name === "className")
188845
188877
  attrName = "class";
188846
- else if (attr.name === "key")
188847
- attrName = "data-key";
188848
- else
188878
+ else if (attr.name === "key") {
188879
+ const depth = this.currentLoopKeyDepth;
188880
+ attrName = depth > 0 ? `data-key-${depth}` : "data-key";
188881
+ } else
188849
188882
  attrName = attr.name;
188850
188883
  const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName);
188851
188884
  if (lowered)
@@ -189104,24 +189137,10 @@ var conformancePins = {
189104
189137
  { code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2038" }
189105
189138
  ],
189106
189139
  "array-map-function-reference": [{ code: "BF101", severity: "error" }],
189107
- "dangerous-inner-html": [{ code: "BF101", severity: "error" }],
189108
- "string-replaceall": [{ code: "BF101", severity: "error" }]
189140
+ "dangerous-inner-html": [{ code: "BF101", severity: "error" }]
189109
189141
  };
189110
189142
  // src/render-divergences.ts
189111
- var renderDivergences = {
189112
- "arithmetic-text": "`(count() + 2) * 3` renders 10 instead of 18 — the parenthesised sub-expression loses its grouping (silent wrong arithmetic)",
189113
- "html-entity-text": "`©` in JSX literal text: Hono decodes to `©`, this adapter re-emits the raw entity — same DOM, different bytes",
189114
- "math-methods": "Math.min/max/abs over a signal render empty (only Math.floor is in the template-primitive registry)",
189115
- "boolean-attr-literals": 'camelCase boolean alias `readOnly`: Hono SSRs `readOnly="true"`, this adapter emits bare presence',
189116
- "camelcase-attributes": "`htmlFor` is not lowered to `for` (Hono maps it)",
189117
- "static-attr-escape": 'static attribute values are not HTML-escaped (`title="Fish & Chips"` emitted raw; Hono escapes)',
189118
- "svg-icon": "SVG camelCase presentation attrs (`strokeWidth`, `strokeLinecap`) pass through unmapped; Hono lowers to kebab-case",
189119
- "object-entries-map": "`Object.entries(prop).map(([k, v]) => …)` renders an EMPTY list — the object-shaped prop silently produces zero iterations",
189120
- "nested-loop-outer-binding": "nested-loop inner items carry `data-key` where the reference emits the depth-suffixed `data-key-1`",
189121
- "jsx-element-prop": "a JSX element passed as a NON-children prop renders an empty slot — the element value is silently dropped",
189122
- "string-slice": "`.slice()` on a STRING renders empty (array-slice helper misfires on strings)",
189123
- "string-trim-sided": "`.trimStart()` / `.trimEnd()` render empty (no lowering)"
189124
- };
189143
+ var renderDivergences = {};
189125
189144
  export {
189126
189145
  renderDivergences,
189127
189146
  minijinjaAdapter,
@@ -1 +1 @@
1
- {"version":3,"file":"render-divergences.d.ts","sourceRoot":"","sources":["../src/render-divergences.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAExD,eAAO,MAAM,iBAAiB,EAAE,iBAyB/B,CAAA"}
1
+ {"version":3,"file":"render-divergences.d.ts","sourceRoot":"","sources":["../src/render-divergences.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAExD,eAAO,MAAM,iBAAiB,EAAE,iBAAsB,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/rust",
3
- "version": "0.18.3",
3
+ "version": "0.18.5",
4
4
  "description": "minijinja (Rust) adapter for BarefootJS — compiles IR to .j2 templates and ships a Rust rendering runtime (packages/adapter-rust/runtime/); runs under any Rust web framework (axum, etc.)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -54,14 +54,14 @@
54
54
  "directory": "packages/adapter-rust"
55
55
  },
56
56
  "dependencies": {
57
- "@barefootjs/shared": "0.18.3"
57
+ "@barefootjs/shared": "0.18.5"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "@barefootjs/jsx": ">=0.2.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@barefootjs/adapter-tests": "0.1.0",
64
- "@barefootjs/jsx": "0.18.3",
64
+ "@barefootjs/jsx": "0.18.5",
65
65
  "typescript": "^5.0.0"
66
66
  }
67
67
  }
@@ -373,6 +373,36 @@ pub fn js_round(n: f64) -> f64 {
373
373
  (n + 0.5).floor()
374
374
  }
375
375
 
376
+ /// `Math.min(a, b)` / `Math.max(a, b)` -- two-arg forms only (#2168
377
+ /// math-methods). NOT `f64::min`/`f64::max`: those follow IEEE-754
378
+ /// `minNum`/`maxNum` semantics and return the non-NaN operand when only
379
+ /// one side is NaN, whereas JS returns NaN if EITHER operand is NaN.
380
+ pub fn js_min(a: f64, b: f64) -> f64 {
381
+ if a.is_nan() {
382
+ return a;
383
+ }
384
+ if b.is_nan() {
385
+ return b;
386
+ }
387
+ if a < b { a } else { b }
388
+ }
389
+
390
+ pub fn js_max(a: f64, b: f64) -> f64 {
391
+ if a.is_nan() {
392
+ return a;
393
+ }
394
+ if b.is_nan() {
395
+ return b;
396
+ }
397
+ if a > b { a } else { b }
398
+ }
399
+
400
+ /// `Math.abs()` (#2168 math-methods). `f64::abs` already matches JS for
401
+ /// NaN (NaN.abs() is NaN) and both infinities, no guard needed.
402
+ pub fn js_abs(n: f64) -> f64 {
403
+ n.abs()
404
+ }
405
+
376
406
  /// JS `%`: remainder with the dividend's sign. Rust's `%` on `f64` already
377
407
  /// implements C `fmod` semantics (IEEE-754 remainder), so this is a
378
408
  /// documentation-only wrapper -- see the module docstring for why no
@@ -166,6 +166,34 @@ fn char_slice_to(s: &str, n: usize) -> String {
166
166
  s.chars().take(n).collect()
167
167
  }
168
168
 
169
+ /// `[start, end)` range slice by Unicode scalar value (`char`), not
170
+ /// byte offset -- shared by `slice`'s string branch. Matches JS except
171
+ /// for astral-plane input, the same divergence boundary `char_len` /
172
+ /// `char_slice_from` / `char_slice_to` already accept.
173
+ fn char_slice_range(s: &str, start: usize, end: usize) -> String {
174
+ if start >= end {
175
+ return String::new();
176
+ }
177
+ s.chars().skip(start).take(end - start).collect()
178
+ }
179
+
180
+ /// Clamp JS `.slice(start, end?)` bounds against a receiver of
181
+ /// `length` elements -- shared by `slice`'s array and string branches
182
+ /// below.
183
+ fn clamp_slice_range(length: i64, start: &JsValue, end: &JsValue) -> (i64, i64) {
184
+ let mut s = if matches!(start, JsValue::Null) { 0 } else { num::to_f64(start) as i64 };
185
+ if s < 0 {
186
+ s += length;
187
+ }
188
+ s = s.clamp(0, length);
189
+ let mut e = if matches!(end, JsValue::Null) { length } else { num::to_f64(end) as i64 };
190
+ if e < 0 {
191
+ e += length;
192
+ }
193
+ e = e.clamp(0, length);
194
+ (s, e)
195
+ }
196
+
169
197
  // ---------------------------------------------------------------------------
170
198
  // spread_attrs support (JSX intrinsic-element spread, #1407).
171
199
  // ---------------------------------------------------------------------------
@@ -1155,6 +1183,9 @@ impl Object for BfInstance {
1155
1183
  "floor" => Ok(MjValue::from(num::js_floor(js_number(a(0))))),
1156
1184
  "ceil" => Ok(MjValue::from(num::js_ceil(js_number(a(0))))),
1157
1185
  "round" => Ok(MjValue::from(num::js_round(js_number(a(0))))),
1186
+ "min" => Ok(MjValue::from(num::js_min(js_number(a(0)), js_number(a(1))))),
1187
+ "max" => Ok(MjValue::from(num::js_max(js_number(a(0)), js_number(a(1))))),
1188
+ "abs" => Ok(MjValue::from(num::js_abs(js_number(a(0))))),
1158
1189
  "to_fixed" => Ok(MjValue::from(num::to_fixed(js_number(a(0)), num::to_f64(a(1)) as i32))),
1159
1190
 
1160
1191
  // -- Array / string method helpers (#1448 Tier A) ------------------
@@ -1191,6 +1222,8 @@ impl Object for BfInstance {
1191
1222
  Ok(js_to_mj(&flat_map_tuple(a(0), &specs)))
1192
1223
  }
1193
1224
  "trim" => Ok(MjValue::from(trim(a(0)))),
1225
+ "trim_start" => Ok(MjValue::from(trim_start(a(0)))),
1226
+ "trim_end" => Ok(MjValue::from(trim_end(a(0)))),
1194
1227
  "split" => {
1195
1228
  let sep = if args.len() > 1 { Some(a(1)) } else { None };
1196
1229
  let limit = if args.len() > 2 && !matches!(a(2), JsValue::Null) { Some(num::to_f64(a(2)) as i64) } else { None };
@@ -1199,6 +1232,7 @@ impl Object for BfInstance {
1199
1232
  "starts_with" => Ok(MjValue::from(starts_with(a(0), a(1), a(2)))),
1200
1233
  "ends_with" => Ok(MjValue::from(ends_with(a(0), a(1), a(2)))),
1201
1234
  "replace" => Ok(MjValue::from(replace(a(0), a(1), a(2)))),
1235
+ "replace_all" => Ok(MjValue::from(replace_all(a(0), a(1), a(2)))),
1202
1236
  "query" => Ok(MjValue::from(query(a(0), &js_args[1..]))),
1203
1237
  "repeat" => Ok(MjValue::from(repeat(a(0), a(1)))),
1204
1238
  "pad_start" => Ok(MjValue::from(pad(&scalar_or_empty(a(0)), a(1), a(2), true))),
@@ -1351,7 +1385,18 @@ pub fn concat(a: &JsValue, b: &JsValue) -> JsValue {
1351
1385
  JsValue::Array(out)
1352
1386
  }
1353
1387
 
1388
+ /// `Array.prototype.slice(start, end?)` AND `String.prototype.slice`
1389
+ /// (the `string-slice` divergence) -- the adapter emits the same
1390
+ /// `bf.slice(recv, start, end)` call for both receiver shapes (it
1391
+ /// can't disambiguate string vs. array at compile time), so this
1392
+ /// dispatches on `recv`'s `JsValue` variant, mirroring `includes` /
1393
+ /// `length` above.
1354
1394
  pub fn slice(recv: &JsValue, start: &JsValue, end: &JsValue) -> JsValue {
1395
+ if let JsValue::String(s) = recv {
1396
+ let length = char_len(s) as i64;
1397
+ let (s_idx, e_idx) = clamp_slice_range(length, start, end);
1398
+ return JsValue::String(char_slice_range(s, s_idx as usize, e_idx as usize));
1399
+ }
1355
1400
  let items = match recv.as_array() {
1356
1401
  Some(a) => a,
1357
1402
  None => return JsValue::Array(Vec::new()),
@@ -1360,16 +1405,7 @@ pub fn slice(recv: &JsValue, start: &JsValue, end: &JsValue) -> JsValue {
1360
1405
  if length == 0 {
1361
1406
  return JsValue::Array(Vec::new());
1362
1407
  }
1363
- let mut s = if matches!(start, JsValue::Null) { 0 } else { num::to_f64(start) as i64 };
1364
- if s < 0 {
1365
- s += length;
1366
- }
1367
- s = s.clamp(0, length);
1368
- let mut e = if matches!(end, JsValue::Null) { length } else { num::to_f64(end) as i64 };
1369
- if e < 0 {
1370
- e += length;
1371
- }
1372
- e = e.clamp(0, length);
1408
+ let (s, e) = clamp_slice_range(length, start, end);
1373
1409
  if s >= e {
1374
1410
  return JsValue::Array(Vec::new());
1375
1411
  }
@@ -1420,6 +1456,24 @@ pub fn trim(recv: &JsValue) -> String {
1420
1456
  }
1421
1457
  }
1422
1458
 
1459
+ /// `String.prototype.trimStart()` -- the one-sided sibling of `trim`
1460
+ /// above (#2183 follow-up), via Rust's native `str::trim_start`.
1461
+ pub fn trim_start(recv: &JsValue) -> String {
1462
+ match recv {
1463
+ JsValue::Null | JsValue::Array(_) | JsValue::Object(_) => String::new(),
1464
+ other => js_string(other).trim_start().to_string(),
1465
+ }
1466
+ }
1467
+
1468
+ /// `String.prototype.trimEnd()` -- the one-sided sibling of `trim`
1469
+ /// above (#2183 follow-up), via Rust's native `str::trim_end`.
1470
+ pub fn trim_end(recv: &JsValue) -> String {
1471
+ match recv {
1472
+ JsValue::Null | JsValue::Array(_) | JsValue::Object(_) => String::new(),
1473
+ other => js_string(other).trim_end().to_string(),
1474
+ }
1475
+ }
1476
+
1423
1477
  pub fn split(recv: &JsValue, sep: Option<&JsValue>, limit: Option<i64>) -> JsValue {
1424
1478
  let s = scalar_or_empty(recv);
1425
1479
  let mut parts: Vec<String> = match sep {
@@ -1488,6 +1542,20 @@ pub fn replace(recv: &JsValue, pattern: &JsValue, replacement: &JsValue) -> Stri
1488
1542
  }
1489
1543
  }
1490
1544
 
1545
+ /// `String.prototype.replaceAll(pattern, replacement)`, string-pattern
1546
+ /// form only (#2182) -- every occurrence, the all-occurrences sibling
1547
+ /// of `replace` above. Rust's own `str::replace` (no count arg) is
1548
+ /// already global by default, including the empty-pattern-inserts-at-
1549
+ /// every-boundary edge case (`"abc".replace("", "X")` -> "XaXbXcX"),
1550
+ /// so it needs no hand-rolled loop the way `replace_all` on the
1551
+ /// backends whose native replace is first-occurrence-only does.
1552
+ pub fn replace_all(recv: &JsValue, pattern: &JsValue, replacement: &JsValue) -> String {
1553
+ let s = scalar_or_empty(recv);
1554
+ let o = js_string(pattern);
1555
+ let n = js_string(replacement);
1556
+ s.replace(&o, &n)
1557
+ }
1558
+
1491
1559
  /// `queryHref(base, {...})` (#2042) -- build `"$base?k=v&..."` from a flat
1492
1560
  /// list of (guard, key, value) triples. A pair is included iff its guard is
1493
1561
  /// JS-truthy AND its value is a non-empty string. A value may also be a
@@ -116,6 +116,9 @@ fn call_binding(fn_name: &str, args: &[JsValue]) -> Option<JsValue> {
116
116
  "floor" => JsValue::Number(num::js_floor(runtime::js_number(&a(0)))),
117
117
  "ceil" => JsValue::Number(num::js_ceil(runtime::js_number(&a(0)))),
118
118
  "round" => JsValue::Number(num::js_round(runtime::js_number(&a(0)))),
119
+ "min" => JsValue::Number(num::js_min(runtime::js_number(&a(0)), runtime::js_number(&a(1)))),
120
+ "max" => JsValue::Number(num::js_max(runtime::js_number(&a(0)), runtime::js_number(&a(1)))),
121
+ "abs" => JsValue::Number(num::js_abs(runtime::js_number(&a(0)))),
119
122
  "to_fixed" => {
120
123
  let digits = if args.len() > 1 { num::to_f64(&a(1)) as i32 } else { 0 };
121
124
  JsValue::String(num::to_fixed(runtime::js_number(&a(0)), digits))
@@ -123,9 +126,12 @@ fn call_binding(fn_name: &str, args: &[JsValue]) -> Option<JsValue> {
123
126
  "lower" => JsValue::String(runtime::js_string(&a(0)).to_lowercase()),
124
127
  "upper" => JsValue::String(runtime::js_string(&a(0)).to_uppercase()),
125
128
  "trim" => JsValue::String(runtime::trim(&a(0))),
129
+ "trim_start" => JsValue::String(runtime::trim_start(&a(0))),
130
+ "trim_end" => JsValue::String(runtime::trim_end(&a(0))),
126
131
  "starts_with" => JsValue::Bool(runtime::starts_with(&a(0), &a(1), &a(2))),
127
132
  "ends_with" => JsValue::Bool(runtime::ends_with(&a(0), &a(1), &a(2))),
128
133
  "replace" => JsValue::String(runtime::replace(&a(0), &a(1), &a(2))),
134
+ "replace_all" => JsValue::String(runtime::replace_all(&a(0), &a(1), &a(2))),
129
135
  "repeat" => JsValue::String(runtime::repeat(&a(0), &a(1))),
130
136
  "pad_start" => JsValue::String(runtime::pad(&runtime::js_string(&a(0)), &a(1), &a(2), true)),
131
137
  "pad_end" => JsValue::String(runtime::pad(&runtime::js_string(&a(0)), &a(1), &a(2), false)),
@@ -169,6 +169,20 @@ fn slice_mutation_isolation_and_clamping() {
169
169
  assert_eq!(src, arr(vec![s("a"), s("b"), s("c")]));
170
170
  }
171
171
 
172
+ /// The `string-slice` divergence (#2182): a string receiver used to
173
+ /// fall through the array-only branch and return an empty array
174
+ /// instead of a substring.
175
+ #[test]
176
+ fn slice_string_receiver() {
177
+ let word = s("barefootjs");
178
+ assert_eq!(runtime::slice(&word, &n(0.0), &n(4.0)), s("bare"));
179
+ assert_eq!(runtime::slice(&word, &n(-4.0), &JsValue::Null), s("otjs"));
180
+ assert_eq!(runtime::slice(&word, &n(4.0), &JsValue::Null), s("footjs"));
181
+ assert_eq!(runtime::slice(&word, &n(5.0), &n(2.0)), s(""));
182
+ // Multi-byte: index by character, not byte.
183
+ assert_eq!(runtime::slice(&s("héllo"), &n(0.0), &n(2.0)), s("hé"));
184
+ }
185
+
172
186
  #[test]
173
187
  fn reverse_mutation_isolation() {
174
188
  assert_eq!(runtime::reverse(&arr(vec![s("a"), s("b"), s("c")])), arr(vec![s("c"), s("b"), s("a")]));
@@ -192,6 +206,34 @@ fn trim_helper() {
192
206
  assert_eq!(runtime::trim(&n(42.0)), "42");
193
207
  }
194
208
 
209
+ #[test]
210
+ fn trim_start_and_trim_end_helpers() {
211
+ // The one-sided siblings of `trim` above (#2183 follow-up). Padding
212
+ // BOTH sides of the flagship input so a swapped side (or a
213
+ // routed-through-both-sides regression) fails visibly.
214
+ assert_eq!(runtime::trim_start(&s(" padded ")), "padded ");
215
+ assert_eq!(runtime::trim_end(&s(" padded ")), " padded");
216
+
217
+ assert_eq!(runtime::trim_start(&s("\t\nleading")), "leading");
218
+ assert_eq!(runtime::trim_end(&s("trailing ")), "trailing");
219
+
220
+ assert_eq!(runtime::trim_start(&s("no-pad")), "no-pad");
221
+ assert_eq!(runtime::trim_end(&s("no-pad")), "no-pad");
222
+
223
+ assert_eq!(runtime::trim_start(&s(" ")), "");
224
+ assert_eq!(runtime::trim_end(&s(" ")), "");
225
+
226
+ assert_eq!(runtime::trim_start(&s("")), "");
227
+ assert_eq!(runtime::trim_end(&s("")), "");
228
+
229
+ assert_eq!(runtime::trim_start(&JsValue::Null), "");
230
+ assert_eq!(runtime::trim_end(&JsValue::Null), "");
231
+ assert_eq!(runtime::trim_start(&obj(&[("a", n(1.0))])), "");
232
+ assert_eq!(runtime::trim_end(&obj(&[("a", n(1.0))])), "");
233
+ assert_eq!(runtime::trim_start(&n(42.0)), "42");
234
+ assert_eq!(runtime::trim_end(&n(42.0)), "42");
235
+ }
236
+
195
237
  #[test]
196
238
  fn split_helper() {
197
239
  assert_eq!(runtime::split(&s("a,b,c"), Some(&s(",")), None), arr(vec![s("a"), s("b"), s("c")]));
@@ -385,6 +385,27 @@ 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
@@ -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])
@@ -48,7 +48,7 @@
48
48
  * entry point `_renderJinjaFilterExprPublic`.
49
49
  */
50
50
 
51
- import {
51
+ import { groupBinaryOperand,
52
52
  type ParsedExprEmitter,
53
53
  type HigherOrderMethod,
54
54
  type ArrayMethod,
@@ -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
@@ -175,8 +175,11 @@ export class JinjaFilterEmitter implements ParsedExprEmitter {
175
175
  }
176
176
 
177
177
  binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
178
- const l = emit(left)
179
- const r = emit(right)
178
+ // Preserve source grouping: a compound operand re-emitted as infix
179
+ // text is otherwise re-parsed under THIS language's precedence —
180
+ // `(count() + 2) * 3` would silently become `count + 2 * 3` (#2173).
181
+ const l = groupBinaryOperand(left, emit(left))
182
+ const r = groupBinaryOperand(right, emit(right))
180
183
  // Jinja's `==` / `!=` are value-equality operators that compare strings
181
184
  // and numbers correctly — unlike Perl's numeric `==` (which the Mojo
182
185
  // adapter must steer around with `eq`/`ne`). Same reasoning as Kolon.
@@ -310,7 +313,7 @@ export class JinjaTopLevelEmitter implements ParsedExprEmitter {
310
313
  return String(value)
311
314
  }
312
315
 
313
- 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 {
314
317
  // `props.x` flattens to the bare context var the SSR caller binds each
315
318
  // prop to (props arrive as individual top-level context entries, not a
316
319
  // nested `props` dict).
@@ -378,8 +381,11 @@ export class JinjaTopLevelEmitter implements ParsedExprEmitter {
378
381
  }
379
382
 
380
383
  binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
381
- const l = emit(left)
382
- const r = emit(right)
384
+ // Preserve source grouping: a compound operand re-emitted as infix
385
+ // text is otherwise re-parsed under THIS language's precedence —
386
+ // `(count() + 2) * 3` would silently become `count + 2 * 3` (#2173).
387
+ const l = groupBinaryOperand(left, emit(left))
388
+ const r = groupBinaryOperand(right, emit(right))
383
389
  // Jinja's `==` / `!=` handle both strings and numbers (unlike Perl's
384
390
  // numeric `==`), so all equality comparisons stay on `==` / `!=`.
385
391
  const opMap: Record<string, string> = {
@@ -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
  /**