@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.
package/dist/index.js CHANGED
@@ -187369,7 +187369,7 @@ function isAriaBooleanAttr(name) {
187369
187369
  }
187370
187370
 
187371
187371
  // src/adapter/jinja-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
 
@@ -187557,6 +187560,12 @@ function renderArrayMethod(method, object, args, emit) {
187557
187560
  const recv = emit(object);
187558
187561
  return `bf.trim(${recv})`;
187559
187562
  }
187563
+ case "trimStart":
187564
+ case "trimEnd": {
187565
+ const fn = method === "trimStart" ? "trim_start" : "trim_end";
187566
+ const recv = emit(object);
187567
+ return `bf.${fn}(${recv})`;
187568
+ }
187560
187569
  case "toFixed": {
187561
187570
  const recv = emit(object);
187562
187571
  const digits = args.length >= 1 ? emit(args[0]) : "0";
@@ -187590,6 +187599,12 @@ function renderArrayMethod(method, object, args, emit) {
187590
187599
  const newS = emit(args[1]);
187591
187600
  return `bf.replace(${recv}, ${oldS}, ${newS})`;
187592
187601
  }
187602
+ case "replaceAll": {
187603
+ const recv = emit(object);
187604
+ const oldS = emit(args[0]);
187605
+ const newS = emit(args[1]);
187606
+ return `bf.replace_all(${recv}, ${oldS}, ${newS})`;
187607
+ }
187593
187608
  case "repeat": {
187594
187609
  const recv = emit(object);
187595
187610
  const count = args.length === 0 ? "0" : emit(args[0]);
@@ -187686,6 +187701,7 @@ function renderFlatMethod(recv, depth, emit) {
187686
187701
 
187687
187702
  // src/adapter/expr/emitters.ts
187688
187703
  import {
187704
+ groupBinaryOperand,
187689
187705
  identifierPath,
187690
187706
  matchSearchParamsMethodCall,
187691
187707
  sortComparatorFromArrow
@@ -187731,11 +187747,11 @@ class JinjaFilterEmitter {
187731
187747
  return "none";
187732
187748
  return String(value);
187733
187749
  }
187734
- member(object, property, _computed, emit) {
187750
+ member(object, property, _computed, _optional, emit) {
187735
187751
  if (property === "length") {
187736
187752
  return `bf.length(${emit(object)})`;
187737
187753
  }
187738
- return `${emit(object)}.${property}`;
187754
+ return `${emit(object)}['${escapeJinjaSingleQuoted(property)}']`;
187739
187755
  }
187740
187756
  indexAccess(object, index, emit) {
187741
187757
  return `${emit(object)}[${emit(index)}]`;
@@ -187754,8 +187770,8 @@ class JinjaFilterEmitter {
187754
187770
  return emit(argument);
187755
187771
  }
187756
187772
  binary(op, left, right, emit) {
187757
- const l = emit(left);
187758
- const r = emit(right);
187773
+ const l = groupBinaryOperand(left, emit(left));
187774
+ const r = groupBinaryOperand(right, emit(right));
187759
187775
  const opMap = {
187760
187776
  "===": "==",
187761
187777
  "!==": "!=",
@@ -187837,7 +187853,7 @@ class JinjaTopLevelEmitter {
187837
187853
  return "none";
187838
187854
  return String(value);
187839
187855
  }
187840
- member(object, property, _computed, emit) {
187856
+ member(object, property, _computed, _optional, emit) {
187841
187857
  if (object.kind === "identifier" && object.name === "props") {
187842
187858
  return jinjaIdent(property);
187843
187859
  }
@@ -187849,7 +187865,7 @@ class JinjaTopLevelEmitter {
187849
187865
  const obj = emit(object);
187850
187866
  if (property === "length")
187851
187867
  return `bf.length(${obj})`;
187852
- return `${obj}.${property}`;
187868
+ return `${obj}['${escapeJinjaSingleQuoted(property)}']`;
187853
187869
  }
187854
187870
  indexAccess(object, index, emit) {
187855
187871
  return `${emit(object)}[${emit(index)}]`;
@@ -187883,8 +187899,8 @@ class JinjaTopLevelEmitter {
187883
187899
  return emit(argument);
187884
187900
  }
187885
187901
  binary(op, left, right, emit) {
187886
- const l = emit(left);
187887
- const r = emit(right);
187902
+ const l = groupBinaryOperand(left, emit(left));
187903
+ const r = groupBinaryOperand(right, emit(right));
187888
187904
  const opMap = {
187889
187905
  "===": "==",
187890
187906
  "!==": "!=",
@@ -188256,6 +188272,7 @@ class JinjaAdapter extends BaseAdapter {
188256
188272
  options;
188257
188273
  errors = [];
188258
188274
  inLoop = false;
188275
+ currentLoopKeyDepth = 0;
188259
188276
  propsObjectName = null;
188260
188277
  propsParams = [];
188261
188278
  booleanTypedProps = new Set;
@@ -188332,7 +188349,7 @@ class JinjaAdapter extends BaseAdapter {
188332
188349
  return this.renderElement(node);
188333
188350
  }
188334
188351
  emitText(node) {
188335
- return node.value;
188352
+ return escapeHtml(node.value);
188336
188353
  }
188337
188354
  emitExpression(node) {
188338
188355
  return this.renderExpression(node);
@@ -188563,7 +188580,7 @@ ${whenTrue}
188563
188580
  const renderedChildren = this.renderChildren(loop.children);
188564
188581
  const loopVar = loop.iterationShape === "keys" ? "__bf_item" : supportableDestructure ? "__bf_item" : param;
188565
188582
  const indexLocalLines = [];
188566
- if (loop.iterationShape === "keys") {
188583
+ if (loop.objectIteration) {} else if (loop.iterationShape === "keys") {
188567
188584
  indexLocalLines.push(`{% set ${jinjaIdent(param)} = loop.index0 %}`);
188568
188585
  } else if (loop.index) {
188569
188586
  indexLocalLines.push(`{% set ${jinjaIdent(loop.index)} = loop.index0 %}`);
@@ -188583,13 +188600,17 @@ ${whenTrue}
188583
188600
  }
188584
188601
  const prevInLoop = this.inLoop;
188585
188602
  this.inLoop = true;
188603
+ const prevLoopKeyDepth = this.currentLoopKeyDepth;
188604
+ this.currentLoopKeyDepth = loop.depth;
188586
188605
  const childrenUnderLoop = this.renderChildren(loop.children);
188606
+ this.currentLoopKeyDepth = prevLoopKeyDepth;
188587
188607
  this.inLoop = prevInLoop;
188588
188608
  const bodyChildren = loop.bodyIsItemConditional && loop.key ? `{{ bf.comment("loop-i:" ~ bf.string(${this.convertExpressionToJinja(loop.key)})) | safe }}
188589
188609
  ${childrenUnderLoop}` : childrenUnderLoop;
188590
188610
  const lines = [];
188591
188611
  lines.push(`{{ bf.comment("loop:${loop.markerId}") | safe }}`);
188592
- lines.push(`{% for ${jinjaIdent(loopVar)} in ${array} %}`);
188612
+ const forHeader = loop.objectIteration === "entries" ? `{% for ${jinjaIdent(loop.index ?? param)}, ${jinjaIdent(param)} in ${array}.items() %}` : loop.objectIteration === "keys" ? `{% for ${jinjaIdent(param)} in ${array}.keys() %}` : loop.objectIteration === "values" ? `{% for ${jinjaIdent(param)} in ${array}.values() %}` : `{% for ${jinjaIdent(loopVar)} in ${array} %}`;
188613
+ lines.push(forHeader);
188593
188614
  for (const il of indexLocalLines)
188594
188615
  lines.push(il);
188595
188616
  if (loop.filterPredicate) {
@@ -188661,9 +188682,20 @@ ${childrenUnderLoop}` : childrenUnderLoop;
188661
188682
  renderComponent(comp) {
188662
188683
  const segments = [{ kind: "entries", parts: [] }];
188663
188684
  const currentEntries = () => this.componentPropSegmentEntries(segments);
188685
+ const namedSlotSetBlocks = [];
188664
188686
  for (const p of comp.props) {
188665
188687
  if ((p.name.match(/^on[A-Z]/) || p.name === "ref") && p.value.kind === "expression")
188666
188688
  continue;
188689
+ if (p.value.kind === "jsx-children" && p.name !== "children") {
188690
+ const prevInLoop = this.inLoop;
188691
+ this.inLoop = false;
188692
+ const slotBody = this.renderChildren(p.value.children);
188693
+ this.inLoop = prevInLoop;
188694
+ const captureName = `bf_prop_${this.childrenCaptureCounter++}`;
188695
+ namedSlotSetBlocks.push(`{% set ${captureName} %}${slotBody}{% endset %}`);
188696
+ currentEntries().push(`${jinjaHashKey(p.name)}: ${captureName}`);
188697
+ continue;
188698
+ }
188667
188699
  if (p.value.kind === "spread") {
188668
188700
  const trimmed = p.value.expr.trim();
188669
188701
  if (this.propsObjectName && this.propsObjectName === trimmed) {
@@ -188692,11 +188724,11 @@ ${childrenUnderLoop}` : childrenUnderLoop;
188692
188724
  const captureName = `bf_children_${comp.slotId ?? "c" + this.childrenCaptureCounter++}`;
188693
188725
  currentEntries().push(`${jinjaHashKey("children")}: ${captureName}`);
188694
188726
  const dict = this.combineComponentPropSegments(segments);
188695
- return `{% set ${captureName} %}${childrenBody}{% endset %}{{ bf.render_child('${tplName}', ${dict}) | safe }}`;
188727
+ return `${namedSlotSetBlocks.join("")}{% set ${captureName} %}${childrenBody}{% endset %}{{ bf.render_child('${tplName}', ${dict}) | safe }}`;
188696
188728
  }
188697
188729
  const isEmpty = segments.every((s) => s.kind === "entries" && s.parts.length === 0);
188698
188730
  const dictEntries = isEmpty ? "" : `, ${this.combineComponentPropSegments(segments)}`;
188699
- return `{{ bf.render_child('${tplName}'${dictEntries}) | safe }}`;
188731
+ return `${namedSlotSetBlocks.join("")}{{ bf.render_child('${tplName}'${dictEntries}) | safe }}`;
188700
188732
  }
188701
188733
  childrenCaptureCounter = 0;
188702
188734
  presenceVarCounter = 0;
@@ -188741,7 +188773,7 @@ ${alternate}
188741
188773
  ${children}`;
188742
188774
  }
188743
188775
  elementAttrEmitter = {
188744
- emitLiteral: (value, name) => `${name}="${value.value}"`,
188776
+ emitLiteral: (value, name) => `${name}="${escapeHtml(value.value)}"`,
188745
188777
  emitExpression: (value, name) => {
188746
188778
  if (name === "style") {
188747
188779
  const css = this.tryLowerStyleObject(value.expr);
@@ -188849,9 +188881,10 @@ ${name}="{{ bf.string(${val}) }}"
188849
188881
  let attrName;
188850
188882
  if (attr.name === "className")
188851
188883
  attrName = "class";
188852
- else if (attr.name === "key")
188853
- attrName = "data-key";
188854
- else
188884
+ else if (attr.name === "key") {
188885
+ const depth = this.currentLoopKeyDepth;
188886
+ attrName = depth > 0 ? `data-key-${depth}` : "data-key";
188887
+ } else
188855
188888
  attrName = attr.name;
188856
188889
  const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName);
188857
188890
  if (lowered)
@@ -189110,24 +189143,10 @@ var conformancePins = {
189110
189143
  { code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2038" }
189111
189144
  ],
189112
189145
  "array-map-function-reference": [{ code: "BF101", severity: "error" }],
189113
- "dangerous-inner-html": [{ code: "BF101", severity: "error" }],
189114
- "string-replaceall": [{ code: "BF101", severity: "error" }]
189146
+ "dangerous-inner-html": [{ code: "BF101", severity: "error" }]
189115
189147
  };
189116
189148
  // src/render-divergences.ts
189117
- var renderDivergences = {
189118
- "arithmetic-text": "`(count() + 2) * 3` renders 10 instead of 18 — the parenthesised sub-expression loses its grouping (silent wrong arithmetic)",
189119
- "html-entity-text": "`©` in JSX literal text: Hono decodes to `©`, this adapter re-emits the raw entity — same DOM, different bytes",
189120
- "math-methods": "Math.min/max/abs over a signal render empty (only Math.floor is in the template-primitive registry)",
189121
- "boolean-attr-literals": 'camelCase boolean alias `readOnly`: Hono SSRs `readOnly="true"`, this adapter emits bare presence',
189122
- "camelcase-attributes": "`htmlFor` is not lowered to `for` (Hono maps it)",
189123
- "static-attr-escape": 'static attribute values are not HTML-escaped (`title="Fish & Chips"` emitted raw; Hono escapes)',
189124
- "svg-icon": "SVG camelCase presentation attrs (`strokeWidth`, `strokeLinecap`) pass through unmapped; Hono lowers to kebab-case",
189125
- "object-entries-map": "`Object.entries(prop).map(([k, v]) => …)` renders an EMPTY list — the object-shaped prop silently produces zero iterations",
189126
- "nested-loop-outer-binding": "nested-loop inner items carry `data-key` where the reference emits the depth-suffixed `data-key-1`",
189127
- "jsx-element-prop": "a JSX element passed as a NON-children prop renders an empty slot — the element value is silently dropped",
189128
- "string-slice": "`.slice()` on a STRING renders empty (array-slice helper misfires on strings)",
189129
- "string-trim-sided": "`.trimStart()` / `.trimEnd()` render empty (no lowering)"
189130
- };
189149
+ var renderDivergences = {};
189131
189150
  export {
189132
189151
  renderDivergences,
189133
189152
  jinjaAdapter,
@@ -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,iBAyB/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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/jinja",
3
- "version": "0.18.3",
3
+ "version": "0.18.5",
4
4
  "description": "Jinja2 adapter for BarefootJS — compiles IR to .jinja templates and ships the Python BarefootJS rendering runtime; runs under any Python web framework (Flask, etc.)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -53,14 +53,14 @@
53
53
  "directory": "packages/adapter-jinja"
54
54
  },
55
55
  "dependencies": {
56
- "@barefootjs/shared": "0.18.3"
56
+ "@barefootjs/shared": "0.18.5"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "@barefootjs/jsx": ">=0.2.0"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@barefootjs/adapter-tests": "0.1.0",
63
- "@barefootjs/jsx": "0.18.3",
63
+ "@barefootjs/jsx": "0.18.5",
64
64
  "typescript": "^5.0.0"
65
65
  }
66
66
  }
@@ -819,6 +819,29 @@ class BarefootJS:
819
819
  # is -1, not -2). `floor(n + 0.5)` reproduces that for both signs.
820
820
  return float(math.floor(n + 0.5))
821
821
 
822
+ def min(self, a: Any, b: Any) -> float:
823
+ """`Math.min(a, b)` -- two-arg form only (#2168 math-methods).
824
+ JS returns NaN if either operand is NaN."""
825
+ x, y = js_number(a), js_number(b)
826
+ if _is_nan(x):
827
+ return x
828
+ if _is_nan(y):
829
+ return y
830
+ return x if x < y else y
831
+
832
+ def max(self, a: Any, b: Any) -> float:
833
+ x, y = js_number(a), js_number(b)
834
+ if _is_nan(x):
835
+ return x
836
+ if _is_nan(y):
837
+ return y
838
+ return x if x > y else y
839
+
840
+ def abs(self, value: Any) -> float:
841
+ """`Math.abs()` (#2168 math-methods)."""
842
+ n = js_number(value)
843
+ return n if _is_nan(n) else abs(n)
844
+
822
845
  # -----------------------------------------------------------------
823
846
  # Array / String method helpers (#1448 Tier A)
824
847
  # -----------------------------------------------------------------
@@ -933,12 +956,20 @@ class BarefootJS:
933
956
  out.extend(b)
934
957
  return out
935
958
 
936
- def slice(self, recv: Any, start: Any, end: Any) -> list:
937
- if not isinstance(recv, list):
959
+ def slice(self, recv: Any, start: Any, end: Any) -> Any:
960
+ # `Array.prototype.slice(start, end?)` AND `String.prototype.slice`
961
+ # (the `string-slice` divergence, #2182) -- the adapter emits the
962
+ # same `bf.slice(recv, start, end)` call for both receiver shapes
963
+ # (it can't disambiguate string vs. array at compile time), so
964
+ # this dispatches on the Python type, mirroring `includes` above.
965
+ # `str` and `list` share slicing semantics (`recv[s:e]`) and
966
+ # `len()`, so one clamp computation serves both; `str` indexing
967
+ # is by Unicode code point already, matching JS except for
968
+ # astral-plane input (the same divergence boundary every other
969
+ # adapter's pad/trim helpers already accept).
970
+ if not isinstance(recv, (list, str)):
938
971
  return []
939
972
  length = len(recv)
940
- if length == 0:
941
- return []
942
973
  s = start if start is not None else 0
943
974
  if s < 0:
944
975
  s = length + s
@@ -950,8 +981,8 @@ class BarefootJS:
950
981
  e = max(e, 0)
951
982
  e = min(e, length)
952
983
  if s >= e:
953
- return []
954
- return list(recv[s:e])
984
+ return recv[0:0]
985
+ return recv[s:e] if isinstance(recv, str) else list(recv[s:e])
955
986
 
956
987
  def reverse(self, recv: Any) -> list:
957
988
  if not isinstance(recv, list):
@@ -1014,6 +1045,20 @@ class BarefootJS:
1014
1045
  return ""
1015
1046
  return js_string(recv).strip()
1016
1047
 
1048
+ def trim_start(self, recv: Any) -> str:
1049
+ """`String.prototype.trimStart()` -- the one-sided sibling of `trim`
1050
+ above (#2183 follow-up)."""
1051
+ if recv is None or isinstance(recv, (list, dict)):
1052
+ return ""
1053
+ return js_string(recv).lstrip()
1054
+
1055
+ def trim_end(self, recv: Any) -> str:
1056
+ """`String.prototype.trimEnd()` -- the one-sided sibling of `trim`
1057
+ above (#2183 follow-up)."""
1058
+ if recv is None or isinstance(recv, (list, dict)):
1059
+ return ""
1060
+ return js_string(recv).rstrip()
1061
+
1017
1062
  def to_fixed(self, value: Any, digits: int = 0) -> str:
1018
1063
  n = self.number(value)
1019
1064
  if _is_nan(n):
@@ -1076,6 +1121,19 @@ class BarefootJS:
1076
1121
  return s
1077
1122
  return s[:i] + n + s[i + len(o) :]
1078
1123
 
1124
+ def replace_all(self, recv: Any, pattern: Any, replacement: Any) -> str:
1125
+ """`String.prototype.replaceAll(pattern, replacement)`, string-pattern
1126
+ form only (#2182) -- every occurrence, the all-occurrences sibling of
1127
+ `replace` above. Python's own `str.replace(old, new)` (no count arg)
1128
+ is already global by default, including the empty-pattern-inserts-
1129
+ at-every-boundary edge case (`"abc".replace("", "X")` -> "XaXbXcX"),
1130
+ so it needs no hand-rolled loop the way the other runtimes' first-
1131
+ occurrence-only native replace does."""
1132
+ s = _scalar_or_empty(recv)
1133
+ o = js_string(pattern)
1134
+ n = js_string(replacement)
1135
+ return s.replace(o, n)
1136
+
1079
1137
  def query(self, base: Any, *triples: Any) -> str:
1080
1138
  """`queryHref(base, {...})` (#2042) -- build `"$base?k=v&..."` from a
1081
1139
  flat list of (guard, key, value) triples. A pair is included iff its
@@ -117,13 +117,19 @@ BINDINGS = {
117
117
  "floor": bf.floor,
118
118
  "ceil": bf.ceil,
119
119
  "round": bf.round,
120
+ "min": bf.min,
121
+ "max": bf.max,
122
+ "abs": bf.abs,
120
123
  "to_fixed": lambda *a: bf.to_fixed(*a),
121
124
  "lower": bf.lc,
122
125
  "upper": bf.uc,
123
126
  "trim": bf.trim,
127
+ "trim_start": bf.trim_start,
128
+ "trim_end": bf.trim_end,
124
129
  "starts_with": lambda *a: bf.starts_with(*a),
125
130
  "ends_with": lambda *a: bf.ends_with(*a),
126
131
  "replace": lambda *a: bf.replace(*a),
132
+ "replace_all": lambda *a: bf.replace_all(*a),
127
133
  "repeat": lambda *a: bf.repeat(*a),
128
134
  "pad_start": lambda *a: bf.pad_start(*a),
129
135
  "pad_end": lambda *a: bf.pad_end(*a),
@@ -74,6 +74,27 @@ class TemplatePrimitivesTest(unittest.TestCase):
74
74
  self.assertEqual(self.bf.round(-1.6), -2)
75
75
  self.assertTrue(_is_nan(self.bf.round("not")))
76
76
 
77
+ def test_min_max_abs(self):
78
+ # `Math.min(a, b)` / `Math.max(a, b)` (two-arg forms only) and
79
+ # `Math.abs()` (#2168 math-methods). JS returns NaN if EITHER
80
+ # min/max operand is NaN.
81
+ self.assertEqual(self.bf.min(3, 7), 3)
82
+ self.assertEqual(self.bf.min(7, 3), 3)
83
+ self.assertEqual(self.bf.min(-2, -5), -5)
84
+ self.assertTrue(_is_nan(self.bf.min("not", 5)))
85
+ self.assertTrue(_is_nan(self.bf.min(5, "not")))
86
+
87
+ self.assertEqual(self.bf.max(3, 7), 7)
88
+ self.assertEqual(self.bf.max(7, 3), 7)
89
+ self.assertEqual(self.bf.max(-2, -5), -2)
90
+ self.assertTrue(_is_nan(self.bf.max("not", 5)))
91
+ self.assertTrue(_is_nan(self.bf.max(5, "not")))
92
+
93
+ self.assertEqual(self.bf.abs(-7.6), 7.6)
94
+ self.assertEqual(self.bf.abs(7.6), 7.6)
95
+ self.assertEqual(self.bf.abs(0), 0)
96
+ self.assertTrue(_is_nan(self.bf.abs("not")))
97
+
77
98
  def test_includes_dispatch(self):
78
99
  # `Array.prototype.includes(x)` + `String.prototype.includes(sub)`
79
100
  # lower to the same `bf.includes(recv, elem)` shape -- see #1448
@@ -158,6 +179,18 @@ class TemplatePrimitivesTest(unittest.TestCase):
158
179
  out.append("mutated")
159
180
  self.assertEqual(src, ["a", "b", "c"])
160
181
 
182
+ def test_slice_string_receiver(self):
183
+ # The `string-slice` divergence (#2182): a string receiver used
184
+ # to fall through the array-only branch and return an empty
185
+ # list instead of a substring.
186
+ word = "barefootjs"
187
+ self.assertEqual(self.bf.slice(word, 0, 4), "bare")
188
+ self.assertEqual(self.bf.slice(word, -4, None), "otjs")
189
+ self.assertEqual(self.bf.slice(word, 4, None), "footjs")
190
+ self.assertEqual(self.bf.slice(word, 5, 2), "")
191
+ # Multi-byte: index by character, not byte.
192
+ self.assertEqual(self.bf.slice("héllo", 0, 2), "hé")
193
+
161
194
  def test_reverse_mutation_isolation(self):
162
195
  self.assertEqual(self.bf.reverse(["a", "b", "c"]), ["c", "b", "a"])
163
196
  self.assertEqual(self.bf.reverse([]), [])
@@ -175,6 +208,18 @@ class TemplatePrimitivesTest(unittest.TestCase):
175
208
  self.assertEqual(self.bf.trim({"a": 1}), "")
176
209
  self.assertEqual(self.bf.trim(42), "42")
177
210
 
211
+ def test_trim_start_and_trim_end(self):
212
+ # The one-sided siblings of `trim` above (#2183). Padding BOTH
213
+ # sides so a swapped side fails visibly.
214
+ self.assertEqual(self.bf.trim_start(" padded "), "padded ")
215
+ self.assertEqual(self.bf.trim_end(" padded "), " padded")
216
+ self.assertEqual(self.bf.trim_start(""), "")
217
+ self.assertEqual(self.bf.trim_end(""), "")
218
+ self.assertEqual(self.bf.trim_start(None), "")
219
+ self.assertEqual(self.bf.trim_end(None), "")
220
+ self.assertEqual(self.bf.trim_start({"a": 1}), "")
221
+ self.assertEqual(self.bf.trim_end([1, 2]), "")
222
+
178
223
  def test_split(self):
179
224
  self.assertEqual(self.bf.split("a,b,c", ","), ["a", "b", "c"])
180
225
  self.assertEqual(self.bf.split("a.b.c", "."), ["a", "b", "c"])
@@ -383,6 +383,27 @@ 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
@@ -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])
@@ -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
@@ -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 {
@@ -175,8 +183,11 @@ export class JinjaFilterEmitter implements ParsedExprEmitter {
175
183
  }
176
184
 
177
185
  binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
178
- const l = emit(left)
179
- const r = emit(right)
186
+ // Preserve source grouping: a compound operand re-emitted as infix
187
+ // text is otherwise re-parsed under THIS language's precedence —
188
+ // `(count() + 2) * 3` would silently become `count + 2 * 3` (#2173).
189
+ const l = groupBinaryOperand(left, emit(left))
190
+ const r = groupBinaryOperand(right, emit(right))
180
191
  // Jinja's `==` / `!=` are value-equality operators that compare strings
181
192
  // and numbers correctly — unlike Perl's numeric `==` (which the Mojo
182
193
  // adapter must steer around with `eq`/`ne`). Same reasoning as Kolon.
@@ -310,7 +321,7 @@ export class JinjaTopLevelEmitter implements ParsedExprEmitter {
310
321
  return String(value)
311
322
  }
312
323
 
313
- 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 {
314
325
  // `props.x` flattens to the bare context var the SSR caller binds each
315
326
  // prop to (props arrive as individual top-level context entries, not a
316
327
  // nested `props` dict).
@@ -328,8 +339,13 @@ export class JinjaTopLevelEmitter implements ParsedExprEmitter {
328
339
  const obj = emit(object)
329
340
  // `.length` → `bf.length` (array count or string char count, JS-compat).
330
341
  if (property === 'length') return `bf.length(${obj})`
331
- // Jinja `.` access works for both dicts and objects.
332
- 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)}']`
333
349
  }
334
350
 
335
351
  indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
@@ -378,8 +394,11 @@ export class JinjaTopLevelEmitter implements ParsedExprEmitter {
378
394
  }
379
395
 
380
396
  binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
381
- const l = emit(left)
382
- const r = emit(right)
397
+ // Preserve source grouping: a compound operand re-emitted as infix
398
+ // text is otherwise re-parsed under THIS language's precedence —
399
+ // `(count() + 2) * 3` would silently become `count + 2 * 3` (#2173).
400
+ const l = groupBinaryOperand(left, emit(left))
401
+ const r = groupBinaryOperand(right, emit(right))
383
402
  // Jinja's `==` / `!=` handle both strings and numbers (unlike Perl's
384
403
  // numeric `==`), so all equality comparisons stay on `==` / `!=`.
385
404
  const opMap: Record<string, string> = {