@barefootjs/rust 0.1.0

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.
Files changed (87) hide show
  1. package/README.md +194 -0
  2. package/dist/adapter/analysis/component-tree.d.ts +26 -0
  3. package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
  4. package/dist/adapter/boolean-result.d.ts +85 -0
  5. package/dist/adapter/boolean-result.d.ts.map +1 -0
  6. package/dist/adapter/emit-context.d.ts +107 -0
  7. package/dist/adapter/emit-context.d.ts.map +1 -0
  8. package/dist/adapter/expr/array-method.d.ts +75 -0
  9. package/dist/adapter/expr/array-method.d.ts.map +1 -0
  10. package/dist/adapter/expr/emitters.d.ts +143 -0
  11. package/dist/adapter/expr/emitters.d.ts.map +1 -0
  12. package/dist/adapter/index.d.ts +6 -0
  13. package/dist/adapter/index.d.ts.map +1 -0
  14. package/dist/adapter/index.js +189091 -0
  15. package/dist/adapter/lib/constants.d.ts +25 -0
  16. package/dist/adapter/lib/constants.d.ts.map +1 -0
  17. package/dist/adapter/lib/ir-scope.d.ts +50 -0
  18. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  19. package/dist/adapter/lib/minijinja-naming.d.ts +64 -0
  20. package/dist/adapter/lib/minijinja-naming.d.ts.map +1 -0
  21. package/dist/adapter/lib/types.d.ts +32 -0
  22. package/dist/adapter/lib/types.d.ts.map +1 -0
  23. package/dist/adapter/memo/seed.d.ts +84 -0
  24. package/dist/adapter/memo/seed.d.ts.map +1 -0
  25. package/dist/adapter/minijinja-adapter.d.ts +421 -0
  26. package/dist/adapter/minijinja-adapter.d.ts.map +1 -0
  27. package/dist/adapter/props/prop-classes.d.ts +33 -0
  28. package/dist/adapter/props/prop-classes.d.ts.map +1 -0
  29. package/dist/adapter/spread/spread-codegen.d.ts +63 -0
  30. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  31. package/dist/adapter/value/parsed-literal.d.ts +28 -0
  32. package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
  33. package/dist/build.d.ts +29 -0
  34. package/dist/build.d.ts.map +1 -0
  35. package/dist/build.js +189111 -0
  36. package/dist/conformance-pins.d.ts +13 -0
  37. package/dist/conformance-pins.d.ts.map +1 -0
  38. package/dist/index.d.ts +12 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +189112 -0
  41. package/package.json +67 -0
  42. package/runtime/Cargo.lock +124 -0
  43. package/runtime/Cargo.toml +21 -0
  44. package/runtime/src/backend_minijinja.rs +176 -0
  45. package/runtime/src/bin/bf-render.rs +147 -0
  46. package/runtime/src/evaluator.rs +770 -0
  47. package/runtime/src/lib.rs +19 -0
  48. package/runtime/src/manifest.rs +258 -0
  49. package/runtime/src/num.rs +558 -0
  50. package/runtime/src/runtime.rs +1548 -0
  51. package/runtime/src/search_params.rs +173 -0
  52. package/runtime/tests/eval_vectors.rs +94 -0
  53. package/runtime/tests/evaluator.rs +407 -0
  54. package/runtime/tests/helper_vectors.rs +348 -0
  55. package/runtime/tests/manifest.rs +169 -0
  56. package/runtime/tests/omit.rs +79 -0
  57. package/runtime/tests/props_attr.rs +75 -0
  58. package/runtime/tests/query.rs +50 -0
  59. package/runtime/tests/render_child.rs +210 -0
  60. package/runtime/tests/search_params.rs +68 -0
  61. package/runtime/tests/spread_attrs.rs +94 -0
  62. package/runtime/tests/template_primitives.rs +376 -0
  63. package/runtime/tests/vector-divergences.json +33 -0
  64. package/src/__tests__/minijinja-adapter-unit.test.ts +392 -0
  65. package/src/__tests__/minijinja-adapter.test.ts +58 -0
  66. package/src/__tests__/minijinja-counter.test.ts +61 -0
  67. package/src/__tests__/minijinja-query-href.test.ts +101 -0
  68. package/src/__tests__/minijinja-spread-attrs.test.ts +227 -0
  69. package/src/adapter/analysis/component-tree.ts +119 -0
  70. package/src/adapter/boolean-result.ts +177 -0
  71. package/src/adapter/emit-context.ts +119 -0
  72. package/src/adapter/expr/array-method.ts +346 -0
  73. package/src/adapter/expr/emitters.ts +608 -0
  74. package/src/adapter/index.ts +6 -0
  75. package/src/adapter/lib/constants.ts +37 -0
  76. package/src/adapter/lib/ir-scope.ts +95 -0
  77. package/src/adapter/lib/minijinja-naming.ts +85 -0
  78. package/src/adapter/lib/types.ts +35 -0
  79. package/src/adapter/memo/seed.ts +135 -0
  80. package/src/adapter/minijinja-adapter.ts +1796 -0
  81. package/src/adapter/props/prop-classes.ts +65 -0
  82. package/src/adapter/spread/spread-codegen.ts +168 -0
  83. package/src/adapter/value/parsed-literal.ts +76 -0
  84. package/src/build.ts +38 -0
  85. package/src/conformance-pins.ts +101 -0
  86. package/src/index.ts +12 -0
  87. package/src/test-render.ts +680 -0
@@ -0,0 +1,1548 @@
1
+ //! Port of `packages/adapter-jinja/python/barefootjs/runtime.py` (itself a
2
+ //! port of `packages/adapter-perl/lib/BarefootJS.pm`).
3
+ //!
4
+ //! Engine- and framework-agnostic server runtime for BarefootJS marked
5
+ //! templates. This module is the server-side runtime the marked templates
6
+ //! call into at render time as the `bf` object: `{{ bf.scope_attr() }}`,
7
+ //! `{{ bf.json(data) }}`, `{{ bf.spread_attrs(bag) }}`. The template-facing
8
+ //! `bf` is a [`minijinja::value::Object`] ([`BfInstance`]) whose
9
+ //! `call_method` dispatches every snake_case helper below by name --
10
+ //! method names are kept VERBATIM from the Python/Perl runtimes since the
11
+ //! minijinja adapter's TS emitter generates calls to these exact names.
12
+ //!
13
+ //! ## Divergences from the Python port (all intentional, documented at the
14
+ //! call site below)
15
+ //!
16
+ //! * `index_of` / `last_index_of` compare elements with [`num::strict_eq`]
17
+ //! (kind-aware JS `===`) rather than the Python port's native `==`.
18
+ //! Python's module docstring documents native `==` as "mostly"
19
+ //! JS-strict-equality-equivalent but with an acknowledged, unexercised
20
+ //! gap (Python `bool` is an `int` subclass, so `True == 1`, unlike JS
21
+ //! `true === 1`). Routing through `strict_eq` closes that gap entirely
22
+ //! rather than reproducing it -- this is a fidelity IMPROVEMENT, not a
23
+ //! new divergence from JS.
24
+ //! * `register_components_from_manifest` / `_derive_stash_from_defaults`
25
+ //! are NOT ported: they implement Python's alternate manifest-driven
26
+ //! child-registration path, which the `bf-render` conformance binary
27
+ //! does not use (child renderers here are registered directly from the
28
+ //! payload's `children[]` array -- see `bin/bf-render.rs` -- mirroring
29
+ //! `packages/adapter-jinja/src/test-render.ts`'s `buildChildRenderers`,
30
+ //! which seeds `_vars` via a plain `{**_defaults, **child_props}` merge,
31
+ //! not the manifest entry's richer `{value, propName, isRestProps}`
32
+ //! shape). There is no code path in this crate that would ever call
33
+ //! the ported function, so it is omitted rather than carried as dead
34
+ //! code.
35
+ //! * `spread_attrs`'s boolean-attribute detection matches on
36
+ //! [`crate::num::JsValue::Bool`] directly -- Rust's real `enum`, like
37
+ //! Python's real `bool` type, needs no sentinel-ref dance (the
38
+ //! Perl-only concern the Python port's docstring notes).
39
+ //! * `truthy` and `mod` (added in the Python port beyond the Perl
40
+ //! runtime, per the Python-adapter plan) are ported here too, for the
41
+ //! same reason: JS truthiness / JS `%` are needed uniformly by the
42
+ //! minijinja TS emitter's lowering policy for conditions and any `%`
43
+ //! operator it emits.
44
+
45
+ use crate::backend_minijinja;
46
+ use crate::evaluator;
47
+ use crate::num::{self, JsValue};
48
+ use minijinja::value::{from_args, Enumerator, Kwargs, Object, Value as MjValue, ValueKind};
49
+ use minijinja::{Error, ErrorKind, State};
50
+ use std::collections::{BTreeMap, HashMap, HashSet};
51
+ use std::sync::{Arc, Mutex};
52
+
53
+ const NULL_JS: JsValue = JsValue::Null;
54
+
55
+ fn arg(args: &[JsValue], i: usize) -> &JsValue {
56
+ args.get(i).unwrap_or(&NULL_JS)
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Keyword mangling (Python/Jinja adapter plan, "Reserved words" divergence
61
+ // policy). `mangle_ident` MUST be kept in lock-step with the TS emitter's
62
+ // `packages/adapter-rust/src/adapter/lib/minijinja-naming.ts` `RESERVED_WORDS`
63
+ // (itself a copy of `packages/adapter-jinja/src/adapter/lib/jinja-naming.ts`'s
64
+ // set -- see the design doc's "RESERVED_WORDS" note: identical set, no
65
+ // minijinja-specific re-derivation).
66
+ // ---------------------------------------------------------------------------
67
+
68
+ const RESERVED_WORDS: &[&str] = &[
69
+ "if", "else", "for", "in", "is", "not", "and", "or", "none", "true", "false", "import", "from", "class", "def",
70
+ "pass", "del", "return", "lambda", "global", "with", "as", "raise", "try", "except", "finally", "while", "break",
71
+ "continue", "elif", "yield", "assert", "nonlocal",
72
+ ];
73
+
74
+ /// Mangle a JS identifier (prop name, signal getter, loop param, ...) into a
75
+ /// minijinja/Jinja-safe variable name: reserved words get a trailing `_`
76
+ /// suffix, everything else passes through unchanged. Applied at every point
77
+ /// a props dict is turned into template variables (`render_named`,
78
+ /// `render_child` prop passing).
79
+ pub fn mangle_ident(name: &str) -> String {
80
+ if RESERVED_WORDS.contains(&name) {
81
+ format!("{name}_")
82
+ } else {
83
+ name.to_string()
84
+ }
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // JS-equivalent value stringification / coercion -- free functions, reused
89
+ // by the array/string helpers below.
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /// JS `String(v)` mirror, with the SAME `null`/`undefined` divergence the
93
+ /// Perl/Python runtimes document: [`JsValue::Null`] (JS `null` AND
94
+ /// `undefined` -- both collapse to `Null` in this domain, see `num.rs`)
95
+ /// renders as the empty string (not `"null"`) so an unset prop doesn't
96
+ /// surface as a literal "null"/"undefined" in user-facing HTML. Contrast
97
+ /// with `evaluator::to_string`, which is JS-faithful (`null` -> `"null"`)
98
+ /// -- see that function's docstring.
99
+ pub fn js_string(v: &JsValue) -> String {
100
+ match v {
101
+ JsValue::Null => String::new(),
102
+ JsValue::Bool(b) => if *b { "true" } else { "false" }.to_string(),
103
+ JsValue::Number(n) => num::format_js_number(*n),
104
+ JsValue::String(s) => s.clone(),
105
+ // JS `Array.prototype.toString` == `.join(',')`; never exercised by
106
+ // the golden vectors (they stay scalar-domain) but a reasonable,
107
+ // JS-faithful fallback rather than a Rust `Debug` dump.
108
+ JsValue::Array(items) => items
109
+ .iter()
110
+ .map(|v| if matches!(v, JsValue::Null) { String::new() } else { js_string(v) })
111
+ .collect::<Vec<_>>()
112
+ .join(","),
113
+ JsValue::Object(_) => "[object Object]".to_string(),
114
+ }
115
+ }
116
+
117
+ /// JS `Number(v)` mirror, with the SAME deliberate divergence the Perl/
118
+ /// Python runtimes document: `null`/`undefined` and non-numeric strings
119
+ /// (INCLUDING the empty string) yield real `NaN` (not 0), so an unset prop
120
+ /// / parse failure can't silently zero downstream arithmetic.
121
+ pub fn js_number(v: &JsValue) -> f64 {
122
+ match v {
123
+ JsValue::Null => f64::NAN,
124
+ JsValue::Bool(b) => if *b { 1.0 } else { 0.0 },
125
+ JsValue::Number(n) => *n,
126
+ JsValue::String(s) => if num::looks_like_number(s) { num::parse_number_literal(s) } else { f64::NAN },
127
+ JsValue::Array(_) | JsValue::Object(_) => f64::NAN,
128
+ }
129
+ }
130
+
131
+ /// JS truthiness: `[]` / `{}` are truthy; only `null`/`undefined`, `false`,
132
+ /// `0`, `''`, and `NaN` are falsy.
133
+ pub fn js_truthy(v: &JsValue) -> bool {
134
+ match v {
135
+ JsValue::Null => false,
136
+ JsValue::Bool(b) => *b,
137
+ JsValue::Number(n) => !n.is_nan() && *n != 0.0, // not NaN, not zero
138
+ JsValue::String(s) => !s.is_empty(), // incl. the JS-truthy "0"
139
+ JsValue::Array(_) | JsValue::Object(_) => true,
140
+ }
141
+ }
142
+
143
+ pub fn js_bool_str(v: bool) -> &'static str {
144
+ if v { "true" } else { "false" }
145
+ }
146
+
147
+ /// String receivers arriving as an array/object coerce to `''`; anything
148
+ /// else (including `null`) goes through [`js_string`]. Shared by every
149
+ /// string-method helper below.
150
+ fn scalar_or_empty(v: &JsValue) -> String {
151
+ match v {
152
+ JsValue::Array(_) | JsValue::Object(_) => String::new(),
153
+ other => js_string(other),
154
+ }
155
+ }
156
+
157
+ fn char_len(s: &str) -> usize {
158
+ s.chars().count()
159
+ }
160
+
161
+ fn char_slice_from(s: &str, n: usize) -> String {
162
+ s.chars().skip(n).collect()
163
+ }
164
+
165
+ fn char_slice_to(s: &str, n: usize) -> String {
166
+ s.chars().take(n).collect()
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // spread_attrs support (JSX intrinsic-element spread, #1407).
171
+ // ---------------------------------------------------------------------------
172
+
173
+ const SVG_CAMEL_CASE_ATTRS: &[&str] = &[
174
+ "allowReorder", "attributeName", "attributeType", "autoReverse", "baseFrequency", "baseProfile", "calcMode",
175
+ "clipPathUnits", "contentScriptType", "contentStyleType", "diffuseConstant", "edgeMode",
176
+ "externalResourcesRequired", "filterRes", "filterUnits", "glyphRef", "gradientTransform", "gradientUnits",
177
+ "kernelMatrix", "kernelUnitLength", "keyPoints", "keySplines", "keyTimes", "lengthAdjust", "limitingConeAngle",
178
+ "markerHeight", "markerUnits", "markerWidth", "maskContentUnits", "maskUnits", "numOctaves", "pathLength",
179
+ "patternContentUnits", "patternTransform", "patternUnits", "pointsAtX", "pointsAtY", "pointsAtZ",
180
+ "preserveAlpha", "preserveAspectRatio", "primitiveUnits", "refX", "refY", "repeatCount", "repeatDur",
181
+ "requiredExtensions", "requiredFeatures", "specularConstant", "specularExponent", "spreadMethod",
182
+ "startOffset", "stdDeviation", "stitchTiles", "surfaceScale", "systemLanguage", "tableValues", "targetX",
183
+ "targetY", "textLength", "viewBox", "viewTarget", "xChannelSelector", "yChannelSelector", "zoomAndPan",
184
+ ];
185
+
186
+ fn to_kebab_case(key: &str) -> String {
187
+ let mut out = String::with_capacity(key.len() + 4);
188
+ for c in key.chars() {
189
+ if c.is_ascii_uppercase() {
190
+ out.push('-');
191
+ out.push(c.to_ascii_lowercase());
192
+ } else {
193
+ out.push(c);
194
+ }
195
+ }
196
+ out
197
+ }
198
+
199
+ fn to_attr_name(key: &str) -> String {
200
+ if key == "className" {
201
+ return "class".to_string();
202
+ }
203
+ if key == "htmlFor" {
204
+ return "for".to_string();
205
+ }
206
+ if SVG_CAMEL_CASE_ATTRS.contains(&key) {
207
+ return key.to_string();
208
+ }
209
+ // camelCase -> kebab-case, with a leading `-` for an initial uppercase
210
+ // letter (JS-reference parity, even though that case produces an
211
+ // HTML-invalid attribute name -- same documented behaviour as the Go /
212
+ // Perl adapters' `toAttrName` / `_to_attr_name`).
213
+ to_kebab_case(key)
214
+ }
215
+
216
+ const FORM_SAFE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789*-._ ";
217
+
218
+ /// `application/x-www-form-urlencoded` serialisation, matching the
219
+ /// browser's `URLSearchParams`.
220
+ fn form_escape_str(s: &str) -> String {
221
+ let mut out = String::with_capacity(s.len());
222
+ for &b in s.as_bytes() {
223
+ if FORM_SAFE.contains(&b) {
224
+ out.push(b as char);
225
+ } else {
226
+ out.push_str(&format!("%{b:02X}"));
227
+ }
228
+ }
229
+ out.replace(' ', "+")
230
+ }
231
+
232
+ fn form_escape(v: &JsValue) -> String {
233
+ form_escape_str(&js_string(v))
234
+ }
235
+
236
+ /// HTML attribute-value escape for SSR string emission -- covers `&`, `<`,
237
+ /// `>`, `"`, `'` using `&#34;` / `&#39;` for quotes (matches Go's
238
+ /// `template.HTMLEscapeString` semantics byte-for-byte, so SSR output stays
239
+ /// identical across adapters). NOT the same escaper as the custom minijinja
240
+ /// formatter's plain-interpolation escaper (`&quot;` for `"`) -- see
241
+ /// `backend_minijinja.rs`'s formatter docstring for why the two differ.
242
+ fn html_escape(v: &JsValue) -> String {
243
+ escape_html_chars(&js_string(v))
244
+ }
245
+
246
+ fn escape_html_chars(s: &str) -> String {
247
+ s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&#34;").replace('\'', "&#39;")
248
+ }
249
+
250
+ fn style_to_css(v: &JsValue) -> Option<String> {
251
+ match v {
252
+ JsValue::Null => None,
253
+ JsValue::Object(map) => {
254
+ let mut parts = Vec::new();
255
+ for (key, val) in map {
256
+ if matches!(val, JsValue::Null) {
257
+ continue;
258
+ }
259
+ let prop = to_kebab_case(key);
260
+ parts.push(format!("{prop}:{}", js_string(val)));
261
+ }
262
+ if parts.is_empty() { None } else { Some(parts.join(";")) }
263
+ }
264
+ other => {
265
+ let s = js_string(other);
266
+ if s.is_empty() { None } else { Some(s) }
267
+ }
268
+ }
269
+ }
270
+
271
+ fn is_on_handler_skip(key: &str) -> bool {
272
+ // Skip when key starts `on` and the third character is its own
273
+ // uppercase form (matches `runtime.py`'s exact predicate, which is
274
+ // broader than the JS-reference `/^on[A-Z]/`: it ALSO swallows digits
275
+ // and `_` because `'0'.upper() == '0'` and `'_'.upper() == '_'` -- see
276
+ // `packages/adapter-jinja/python/tests/test_spread_attrs.py`'s
277
+ // `on0`/`on_custom` cases, which pin this exact behaviour).
278
+ if key.len() <= 2 || &key[0..2] != "on" {
279
+ return false;
280
+ }
281
+ match key[2..].chars().next() {
282
+ Some(c) => c.to_uppercase().collect::<String>() == c.to_string(),
283
+ None => false,
284
+ }
285
+ }
286
+
287
+ pub fn spread_attrs(bag: &JsValue) -> String {
288
+ let map = match bag.as_object() {
289
+ Some(m) => m,
290
+ None => return String::new(),
291
+ };
292
+ let mut parts = Vec::new();
293
+ for (key, val) in map {
294
+ if is_on_handler_skip(key) {
295
+ continue;
296
+ }
297
+ if key == "children" {
298
+ continue;
299
+ }
300
+ if matches!(val, JsValue::Null) {
301
+ continue;
302
+ }
303
+ if let JsValue::Bool(b) = val {
304
+ if *b {
305
+ parts.push(to_attr_name(key));
306
+ }
307
+ continue;
308
+ }
309
+ if key == "style" {
310
+ if let Some(css) = style_to_css(val) {
311
+ parts.push(format!("style=\"{}\"", html_escape(&JsValue::String(css))));
312
+ }
313
+ continue;
314
+ }
315
+ let name = to_attr_name(key);
316
+ parts.push(format!("{name}=\"{}\"", html_escape(val)));
317
+ }
318
+ parts.join(" ")
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // sort / reduce structured catalogues (#1448 Tier B/C).
323
+ // ---------------------------------------------------------------------------
324
+
325
+ fn is_numeric_like(v: &JsValue) -> bool {
326
+ match v {
327
+ JsValue::Null | JsValue::Bool(_) => false,
328
+ JsValue::Number(_) => true,
329
+ JsValue::String(s) => num::looks_like_number(s),
330
+ JsValue::Array(_) | JsValue::Object(_) => false,
331
+ }
332
+ }
333
+
334
+ fn numeric_value(v: &JsValue) -> f64 {
335
+ match v {
336
+ JsValue::Null | JsValue::Array(_) | JsValue::Object(_) => 0.0,
337
+ JsValue::Bool(b) => if *b { 1.0 } else { 0.0 },
338
+ JsValue::Number(n) => *n,
339
+ JsValue::String(s) => if num::looks_like_number(s) { num::parse_number_literal(s) } else { 0.0 },
340
+ }
341
+ }
342
+
343
+ /// Compare two projected sort keys, ascending orientation; the caller
344
+ /// reverses for `desc`. `"auto"` compares numerically when both keys look
345
+ /// like numbers, else lexically (matches Go/Perl's `bf_sort`). `null`
346
+ /// coalesces to `''` / `0` so the order stays total.
347
+ fn compare_sort_key(a: &JsValue, b: &JsValue, compare_type: &str) -> std::cmp::Ordering {
348
+ use std::cmp::Ordering;
349
+ let str_of = |v: &JsValue| if matches!(v, JsValue::Null) { String::new() } else { js_string(v) };
350
+ match compare_type {
351
+ "string" => str_of(a).cmp(&str_of(b)),
352
+ "auto" => {
353
+ if is_numeric_like(a) && is_numeric_like(b) {
354
+ numeric_value(a).partial_cmp(&numeric_value(b)).unwrap_or(Ordering::Equal)
355
+ } else {
356
+ str_of(a).cmp(&str_of(b))
357
+ }
358
+ }
359
+ _ => numeric_value(a).partial_cmp(&numeric_value(b)).unwrap_or(Ordering::Equal),
360
+ }
361
+ }
362
+
363
+ struct SortKeySpec {
364
+ key_kind: String,
365
+ key: String,
366
+ compare_type: String,
367
+ direction: String,
368
+ }
369
+
370
+ pub fn sort(recv: &JsValue, opts: &JsValue) -> JsValue {
371
+ let items = match recv.as_array() {
372
+ Some(a) => a,
373
+ None => return JsValue::Array(Vec::new()),
374
+ };
375
+ let keys = opts.as_object().and_then(|m| m.get("keys")).and_then(|v| v.as_array()).unwrap_or(&[]);
376
+ let spec: Vec<SortKeySpec> = keys
377
+ .iter()
378
+ .map(|k| {
379
+ let m = k.as_object();
380
+ SortKeySpec {
381
+ key_kind: m.and_then(|m| m.get("key_kind")).and_then(|v| v.as_str()).unwrap_or("self").to_string(),
382
+ key: m.and_then(|m| m.get("key")).and_then(|v| v.as_str()).unwrap_or("").to_string(),
383
+ compare_type: m.and_then(|m| m.get("compare_type")).and_then(|v| v.as_str()).unwrap_or("numeric").to_string(),
384
+ direction: m.and_then(|m| m.get("direction")).and_then(|v| v.as_str()).unwrap_or("asc").to_string(),
385
+ }
386
+ })
387
+ .collect();
388
+ if spec.is_empty() {
389
+ return JsValue::Array(items.to_vec());
390
+ }
391
+
392
+ let project = |item: &JsValue, s: &SortKeySpec| -> JsValue {
393
+ if s.key_kind == "field" {
394
+ item.as_object().and_then(|m| m.get(&s.key)).cloned().unwrap_or(JsValue::Null)
395
+ } else {
396
+ item.clone()
397
+ }
398
+ };
399
+
400
+ let mut out = items.to_vec();
401
+ out.sort_by(|a, b| {
402
+ for s in &spec {
403
+ let mut c = compare_sort_key(&project(a, s), &project(b, s), &s.compare_type);
404
+ if s.direction == "desc" {
405
+ c = c.reverse();
406
+ }
407
+ if c != std::cmp::Ordering::Equal {
408
+ return c;
409
+ }
410
+ }
411
+ std::cmp::Ordering::Equal
412
+ });
413
+ JsValue::Array(out)
414
+ }
415
+
416
+ /// Fold via the arithmetic-fold catalogue (#1448 Tier C). Mirrors
417
+ /// `Array.prototype.reduce` / `.reduceRight` for the shapes
418
+ /// `(acc, x) => acc <op> x` / `(acc, x) => acc <op> x.field`.
419
+ pub fn reduce(recv: &JsValue, opts: &JsValue) -> JsValue {
420
+ let om = opts.as_object();
421
+ let op = om.and_then(|m| m.get("op")).and_then(|v| v.as_str()).unwrap_or("+");
422
+ let key_kind = om.and_then(|m| m.get("key_kind")).and_then(|v| v.as_str()).unwrap_or("self");
423
+ let key = om.and_then(|m| m.get("key")).and_then(|v| v.as_str()).unwrap_or("");
424
+ let rtype = om.and_then(|m| m.get("type")).and_then(|v| v.as_str()).unwrap_or("numeric");
425
+ let direction = om.and_then(|m| m.get("direction")).and_then(|v| v.as_str()).unwrap_or("left");
426
+
427
+ let mut items: Vec<JsValue> = recv.as_array().map(|a| a.to_vec()).unwrap_or_default();
428
+ if direction == "right" {
429
+ items.reverse();
430
+ }
431
+ let project = |item: &JsValue| -> JsValue {
432
+ if key_kind == "field" {
433
+ item.as_object().and_then(|m| m.get(key)).cloned().unwrap_or(JsValue::Null)
434
+ } else {
435
+ item.clone()
436
+ }
437
+ };
438
+
439
+ if rtype == "string" {
440
+ let mut acc = match om.and_then(|m| m.get("init")) {
441
+ Some(JsValue::Null) | None => String::new(),
442
+ Some(other) => js_string(other),
443
+ };
444
+ for item in &items {
445
+ acc.push_str(&js_string(&project(item)));
446
+ }
447
+ return JsValue::String(acc);
448
+ }
449
+
450
+ let mut acc: f64 = match om.and_then(|m| m.get("init")) {
451
+ Some(JsValue::Null) | None => 0.0,
452
+ Some(JsValue::Number(n)) => *n,
453
+ Some(other) => js_number(other),
454
+ };
455
+ for item in &items {
456
+ let val = project(item);
457
+ let n = if !matches!(val, JsValue::Null) && is_numeric_like(&val) { numeric_value(&val) } else { 0.0 };
458
+ acc = if op == "*" { acc * n } else { acc + n };
459
+ }
460
+ JsValue::Number(acc)
461
+ }
462
+
463
+ pub fn flat(items: &[JsValue], depth: i64) -> Vec<JsValue> {
464
+ let mut out = Vec::new();
465
+ for el in items {
466
+ if depth != 0 {
467
+ if let JsValue::Array(inner) = el {
468
+ let next = if depth > 0 { depth - 1 } else { depth };
469
+ out.extend(flat(inner, next));
470
+ continue;
471
+ }
472
+ }
473
+ out.push(el.clone());
474
+ }
475
+ out
476
+ }
477
+
478
+ /// `.flat(depth)` where `depth` is a genuinely DYNAMIC runtime value (#2094)
479
+ /// -- e.g. a prop, rather than a compile-time literal integer / `Infinity`.
480
+ /// Coerces `depth` via JS `ToIntegerOrInfinity` (truncate toward zero;
481
+ /// NaN/non-numeric -> 0; -Infinity -> 0; +Infinity or a huge finite value ->
482
+ /// flatten fully) and delegates to [`flat`].
483
+ ///
484
+ /// Deliberately a SEPARATE entry point from [`flat`], not a smarter overload
485
+ /// of it: `flat`'s `depth` is a compile-time int baked directly into the
486
+ /// template source, where `-1` is a SENTINEL meaning "the source literally
487
+ /// wrote `Infinity`". A genuinely dynamic depth that happens to evaluate to
488
+ /// `-1` at render time means the OPPOSITE in real JS (`[1,[2]].flat(-1)`
489
+ /// never recurses -- same as `.flat(0)`). Since both call sites would
490
+ /// otherwise hand the SAME literal-looking argument to one shared function,
491
+ /// that function could not tell which case it's in -- so it must be two
492
+ /// functions. Mirrors Go's `FlatDynamicDepth` / `coerceFlatDepth`
493
+ /// (`packages/adapter-go-template/runtime/bf.go`).
494
+ pub fn flat_dynamic(items: &[JsValue], depth: &JsValue) -> Vec<JsValue> {
495
+ flat(items, coerce_flat_depth(depth))
496
+ }
497
+
498
+ /// JS `ToIntegerOrInfinity` on a dynamic `.flat(depth)` argument, mapped
499
+ /// onto [`flat`]'s int contract (`-1` = flatten fully). Reuses
500
+ /// [`crate::evaluator::to_number`] for the `ToNumber` step (already
501
+ /// JS-faithful, including string-literal coercion of `"Infinity"` /
502
+ /// `"NaN"` via `num::looks_like_number` / `num::parse_number_literal`).
503
+ fn coerce_flat_depth(depth: &JsValue) -> i64 {
504
+ let f = evaluator::to_number(depth);
505
+ if f.is_nan() {
506
+ return 0;
507
+ }
508
+ if f.is_infinite() {
509
+ return if f > 0.0 { -1 } else { 0 };
510
+ }
511
+ let trunc = f.trunc();
512
+ if trunc < 0.0 {
513
+ return 0;
514
+ }
515
+ if trunc > 1_000_000.0 {
516
+ return -1;
517
+ }
518
+ trunc as i64
519
+ }
520
+
521
+ pub fn flat_map(recv: &JsValue, key_kind: &str, key: &str) -> JsValue {
522
+ let items = match recv.as_array() {
523
+ Some(a) => a,
524
+ None => return JsValue::Array(Vec::new()),
525
+ };
526
+ let projected: Vec<JsValue> = items
527
+ .iter()
528
+ .map(|el| if key_kind == "field" { el.as_object().and_then(|m| m.get(key)).cloned().unwrap_or(JsValue::Null) } else { el.clone() })
529
+ .collect();
530
+ JsValue::Array(flat(&projected, 1))
531
+ }
532
+
533
+ pub fn flat_map_tuple(recv: &JsValue, specs: &[(String, String)]) -> JsValue {
534
+ let items = match recv.as_array() {
535
+ Some(a) => a,
536
+ None => return JsValue::Array(Vec::new()),
537
+ };
538
+ let mut out = Vec::new();
539
+ for el in items {
540
+ for (kind, key) in specs {
541
+ if kind == "field" {
542
+ out.push(el.as_object().and_then(|m| m.get(key)).cloned().unwrap_or(JsValue::Null));
543
+ } else {
544
+ out.push(el.clone());
545
+ }
546
+ }
547
+ }
548
+ JsValue::Array(out)
549
+ }
550
+
551
+ pub fn array_index_of(recv: &JsValue, elem: &JsValue, reverse: bool) -> i64 {
552
+ let items = match recv.as_array() {
553
+ Some(a) => a,
554
+ None => return -1,
555
+ };
556
+ let idxs: Vec<usize> = if reverse { (0..items.len()).rev().collect() } else { (0..items.len()).collect() };
557
+ for i in idxs {
558
+ let item = &items[i];
559
+ if matches!(item, JsValue::Null) {
560
+ if matches!(elem, JsValue::Null) {
561
+ return i as i64;
562
+ }
563
+ continue;
564
+ }
565
+ if !matches!(elem, JsValue::Null) && num::strict_eq(item, elem) {
566
+ return i as i64;
567
+ }
568
+ }
569
+ -1
570
+ }
571
+
572
+ pub fn pad(s: &str, target: &JsValue, pad_v: &JsValue, at_start: bool) -> String {
573
+ let p = if matches!(pad_v, JsValue::Null) { " ".to_string() } else { js_string(pad_v) };
574
+ if p.is_empty() {
575
+ return s.to_string();
576
+ }
577
+ let length = char_len(s);
578
+ let t = (if matches!(target, JsValue::Null) { 0.0 } else { num::to_f64(target) }) as i64;
579
+ let t = t.max(0) as usize;
580
+ if length >= t {
581
+ return s.to_string();
582
+ }
583
+ let need = t - length;
584
+ let p_len = char_len(&p);
585
+ let reps = need / p_len + 1;
586
+ let fill = char_slice_to(&p.repeat(reps), need);
587
+ if at_start { format!("{fill}{s}") } else { format!("{s}{fill}") }
588
+ }
589
+
590
+ // ---------------------------------------------------------------------------
591
+ // Rust-only predicate-taking array helpers (#1448 Tier A). NOT exposed via
592
+ // `call_method` -- unlike the structured `sort`/`reduce`/`flat_map`
593
+ // catalogues (constructible from a plain dict/string literal in template
594
+ // syntax), these take an arbitrary predicate, which neither Jinja2 nor
595
+ // minijinja templates can construct (no lambda literal in expression
596
+ // position). The compiled adapter routes ALL predicate-driven filtering
597
+ // through the `_eval` JSON-string-seam family below instead. These exist
598
+ // purely as a reusable Rust API for the golden-vector test harness
599
+ // (`tests/helper_vectors.rs`), mirroring `runtime.py`'s `filter`/`every`/
600
+ // `some`/`find`/`find_index`/`find_last`/`find_last_index`, which take a
601
+ // Python `Callable` for the exact same reason (only reachable from Python
602
+ // test code, never from a compiled template).
603
+ // ---------------------------------------------------------------------------
604
+
605
+ pub fn filter(recv: &JsValue, pred: impl Fn(&JsValue) -> bool) -> JsValue {
606
+ match recv.as_array() {
607
+ Some(items) => JsValue::Array(items.iter().filter(|x| pred(x)).cloned().collect()),
608
+ None => JsValue::Array(Vec::new()),
609
+ }
610
+ }
611
+
612
+ pub fn every(recv: &JsValue, pred: impl Fn(&JsValue) -> bool) -> bool {
613
+ match recv.as_array() {
614
+ Some(items) => items.iter().all(pred),
615
+ None => true,
616
+ }
617
+ }
618
+
619
+ pub fn some(recv: &JsValue, pred: impl Fn(&JsValue) -> bool) -> bool {
620
+ match recv.as_array() {
621
+ Some(items) => items.iter().any(pred),
622
+ None => false,
623
+ }
624
+ }
625
+
626
+ pub fn find(recv: &JsValue, pred: impl Fn(&JsValue) -> bool) -> JsValue {
627
+ match recv.as_array() {
628
+ Some(items) => items.iter().find(|x| pred(x)).cloned().unwrap_or(JsValue::Null),
629
+ None => JsValue::Null,
630
+ }
631
+ }
632
+
633
+ pub fn find_index(recv: &JsValue, pred: impl Fn(&JsValue) -> bool) -> i64 {
634
+ match recv.as_array() {
635
+ Some(items) => items.iter().position(pred).map(|i| i as i64).unwrap_or(-1),
636
+ None => -1,
637
+ }
638
+ }
639
+
640
+ pub fn find_last(recv: &JsValue, pred: impl Fn(&JsValue) -> bool) -> JsValue {
641
+ match recv.as_array() {
642
+ Some(items) => items.iter().rev().find(|x| pred(x)).cloned().unwrap_or(JsValue::Null),
643
+ None => JsValue::Null,
644
+ }
645
+ }
646
+
647
+ pub fn find_last_index(recv: &JsValue, pred: impl Fn(&JsValue) -> bool) -> i64 {
648
+ match recv.as_array() {
649
+ Some(items) => {
650
+ for i in (0..items.len()).rev() {
651
+ if pred(&items[i]) {
652
+ return i as i64;
653
+ }
654
+ }
655
+ -1
656
+ }
657
+ None => -1,
658
+ }
659
+ }
660
+
661
+ // ---------------------------------------------------------------------------
662
+ // RenderSession: session-scoped equivalent of Python's module globals
663
+ // (`_CONTEXT_STACKS`) plus the mutable per-render registries Perl/Python
664
+ // keep as dual-accessor instance state (`_scripts`, `_script_seen`,
665
+ // `_child_renderers`). `Arc<Mutex<...>>` per field (not one big mutex) so
666
+ // unrelated concerns don't contend; thread-safe for parallel `cargo test`.
667
+ // ---------------------------------------------------------------------------
668
+
669
+ #[derive(Debug, Clone)]
670
+ pub struct ChildRendererSpec {
671
+ /// PascalCase component name -- the loop-child scope-id prefix
672
+ /// (`<ComponentName>_<rand6>`) when a child has no `_bf_slot`.
673
+ pub component_name: String,
674
+ /// snake_case `.j2` template base name.
675
+ pub template: String,
676
+ /// Static ssrDefaults values (already flattened to plain values, NOT
677
+ /// the `{value, propName, isRestProps}` wrapper shape -- see the module
678
+ /// docstring's note on why `_derive_stash_from_defaults` isn't ported).
679
+ /// Converted from JSON to `Value` at REGISTRATION time (`bf-render.rs`)
680
+ /// -- see `render_child`'s docstring on why child props stay `Value`
681
+ /// end-to-end rather than round-tripping through `JsValue`.
682
+ pub ssr_defaults: MjValue,
683
+ pub rest_props_name: Option<String>,
684
+ pub param_names: Vec<String>,
685
+ }
686
+
687
+ #[derive(Debug)]
688
+ pub struct RenderSession {
689
+ pub scripts: Mutex<Vec<String>>,
690
+ pub script_seen: Mutex<HashSet<String>>,
691
+ pub child_renderers: Mutex<HashMap<String, ChildRendererSpec>>,
692
+ pub context_stacks: Mutex<HashMap<String, Vec<JsValue>>>,
693
+ rng_counter: Mutex<u64>,
694
+ }
695
+
696
+ impl RenderSession {
697
+ pub fn new() -> Arc<RenderSession> {
698
+ Arc::new(RenderSession {
699
+ scripts: Mutex::new(Vec::new()),
700
+ script_seen: Mutex::new(HashSet::new()),
701
+ child_renderers: Mutex::new(HashMap::new()),
702
+ context_stacks: Mutex::new(HashMap::new()),
703
+ rng_counter: Mutex::new(0),
704
+ })
705
+ }
706
+
707
+ pub fn register_child_renderer(&self, key: String, spec: ChildRendererSpec) {
708
+ self.child_renderers.lock().unwrap().insert(key, spec);
709
+ }
710
+
711
+ /// 6 lowercase hex chars, deterministic-per-session (splitmix64 over an
712
+ /// incrementing counter -- explicitly NOT `std::time`, per the design
713
+ /// doc: loop-child scope ids only need to be locally unique within one
714
+ /// render: `normalizeHTML` canonicalises `<ComponentName>_*` suffixes
715
+ /// away in the conformance fixtures anyway).
716
+ ///
717
+ /// `pub` (beyond `render_child`'s own internal use above) so a
718
+ /// production host can mint the SAME kind of locally-unique suffix for
719
+ /// a request's ROOT scope id (mirrors `packages/adapter-jinja/python/
720
+ /// barefootjs`-based integrations' `rand_suffix()` helper, e.g.
721
+ /// `integrations/flask/app.py`'s `f"{component}_{rand_suffix()}"`) --
722
+ /// see `packages/adapter-rust/runtime/src/manifest.rs` and the axum
723
+ /// integration's route handlers.
724
+ pub fn next_rand_hex6(&self) -> String {
725
+ let mut counter = self.rng_counter.lock().unwrap();
726
+ *counter = counter.wrapping_add(0x9E37_79B9_7F4A_7C15);
727
+ let mut z = *counter;
728
+ z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
729
+ z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
730
+ z ^= z >> 31;
731
+ format!("{:06x}", z & 0xFFFFFF)
732
+ }
733
+ }
734
+
735
+ // ---------------------------------------------------------------------------
736
+ // BfInstance: the template-facing `bf` object.
737
+ // ---------------------------------------------------------------------------
738
+
739
+ #[derive(Debug, Clone)]
740
+ pub struct BfInstance {
741
+ pub session: Arc<RenderSession>,
742
+ pub scope_id: String,
743
+ pub is_child: bool,
744
+ pub bf_parent: Option<String>,
745
+ pub bf_mount: Option<String>,
746
+ pub props: Option<JsValue>,
747
+ pub data_key: Option<JsValue>,
748
+ }
749
+
750
+ impl BfInstance {
751
+ pub fn root(session: Arc<RenderSession>, scope_id: impl Into<String>) -> BfInstance {
752
+ BfInstance {
753
+ session,
754
+ scope_id: scope_id.into(),
755
+ is_child: false,
756
+ bf_parent: None,
757
+ bf_mount: None,
758
+ props: None,
759
+ data_key: None,
760
+ }
761
+ }
762
+
763
+ pub fn as_mj_value(&self) -> MjValue {
764
+ MjValue::from_object(self.clone())
765
+ }
766
+
767
+ fn scope_attr(&self) -> String {
768
+ self.scope_id.clone()
769
+ }
770
+
771
+ fn hydration_attrs(&self) -> String {
772
+ let mut parts = Vec::new();
773
+ if let Some(host) = &self.bf_parent {
774
+ parts.push(format!("bf-h=\"{}\"", host.replace('"', "&quot;")));
775
+ }
776
+ if let Some(mount) = &self.bf_mount {
777
+ parts.push(format!("bf-m=\"{}\"", mount.replace('"', "&quot;")));
778
+ }
779
+ if !self.is_child {
780
+ parts.push("bf-r=\"\"".to_string());
781
+ }
782
+ parts.join(" ")
783
+ }
784
+
785
+ fn data_key_attr(&self) -> String {
786
+ match &self.data_key {
787
+ None => String::new(),
788
+ Some(k) => {
789
+ let k_str = js_string(k).replace('&', "&amp;").replace('"', "&quot;");
790
+ format!(" data-key=\"{k_str}\"")
791
+ }
792
+ }
793
+ }
794
+
795
+ fn props_is_empty(&self) -> bool {
796
+ match &self.props {
797
+ None => true,
798
+ Some(p) => p.as_object().map(|m| m.is_empty()).unwrap_or(false),
799
+ }
800
+ }
801
+
802
+ fn props_attr(&self) -> String {
803
+ if self.props_is_empty() {
804
+ return String::new();
805
+ }
806
+ // The JSON must be attribute-escaped: a raw `'` inside a string value
807
+ // (e.g. a blog paragraph) terminates the single-quoted attribute and
808
+ // truncates the hydration payload. The browser entity-decodes the
809
+ // attribute value, so the client's JSON.parse sees the original text.
810
+ let j = escape_html_chars(&backend_minijinja::encode_json(self.props.as_ref().unwrap()));
811
+ format!(" bf-p='{j}'")
812
+ }
813
+
814
+ fn scope_comment(&self) -> String {
815
+ let mut host_segment = String::new();
816
+ if let Some(host) = &self.bf_parent {
817
+ host_segment = format!("|h={}|m={}", host, self.bf_mount.clone().unwrap_or_default());
818
+ }
819
+ let mut props_json = String::new();
820
+ if !self.props_is_empty() {
821
+ props_json = format!("|{}", backend_minijinja::encode_json(self.props.as_ref().unwrap()));
822
+ }
823
+ format!("<!--bf-scope:{}{host_segment}{props_json}-->", self.scope_id)
824
+ }
825
+
826
+ fn provide_context(&self, name: &str, value: JsValue) {
827
+ self.session.context_stacks.lock().unwrap().entry(name.to_string()).or_default().push(value);
828
+ }
829
+
830
+ fn revoke_context(&self, name: &str) {
831
+ if let Some(stack) = self.session.context_stacks.lock().unwrap().get_mut(name) {
832
+ stack.pop();
833
+ }
834
+ }
835
+
836
+ fn use_context(&self, name: &str, default: JsValue) -> JsValue {
837
+ self.session.context_stacks.lock().unwrap().get(name).and_then(|s| s.last()).cloned().unwrap_or(default)
838
+ }
839
+
840
+ fn register_script(&self, path: &str) {
841
+ let mut seen = self.session.script_seen.lock().unwrap();
842
+ if seen.contains(path) {
843
+ return;
844
+ }
845
+ seen.insert(path.to_string());
846
+ self.session.scripts.lock().unwrap().push(path.to_string());
847
+ }
848
+
849
+ /// `pub` (beyond the `"scripts"` `call_method` dispatch below) so a
850
+ /// production host can read back the accumulated `<script>` tags AFTER
851
+ /// rendering, to splice into its own page layout (mirrors Python
852
+ /// integrations' `bf.scripts()` call in their layout helper, e.g.
853
+ /// `integrations/flask/app.py`'s `layout(..., scripts=bf.scripts())`).
854
+ pub fn scripts(&self) -> String {
855
+ self.session
856
+ .scripts
857
+ .lock()
858
+ .unwrap()
859
+ .iter()
860
+ .map(|p| format!("<script type=\"module\" src=\"{p}\"></script>"))
861
+ .collect::<Vec<_>>()
862
+ .join("\n")
863
+ }
864
+
865
+ /// Renderer contract (#1897): invoked from a template as
866
+ /// `bf.render_child(name, {...})` (dict-literal form -- Jinja/minijinja
867
+ /// can't splat a dict into positional/kwargs args) or
868
+ /// `bf.render_child(name)` for a childless invocation. Keyword mangling
869
+ /// happens HERE, before rest-bag routing / `_bf_slot`/`key` popping --
870
+ /// mirrors `runtime.py`'s `render_child` docstring: this is the ONE
871
+ /// place a caller's raw prop keys become template-variable-safe names.
872
+ ///
873
+ /// `props` (and everything derived from it -- the rest bag, the merged
874
+ /// `vars` handed to the child template) stays `minijinja::Value`
875
+ /// end-to-end, all the way through to `render_named_from_state_values`.
876
+ /// This mirrors the Python runtime, where `render_child` passes its
877
+ /// `dict` straight through untouched: a JSX children capture
878
+ /// (`{% set cap %}...{% endset %}`) is a SAFE `Value`, and routing it
879
+ /// through the `JsValue` domain (which has no safe/unsafe distinction)
880
+ /// would silently strip that flag, double-escaping the child's HTML.
881
+ /// The lone exception is `data_key` (`key` prop), converted to
882
+ /// `JsValue` right at the point it's extracted -- `data_key_attr`
883
+ /// genuinely needs `js_string` JS-stringification semantics for a
884
+ /// scalar attribute value, not safe-value semantics.
885
+ pub fn render_child(&self, state: &State, name: &str, props: BTreeMap<String, MjValue>) -> Result<String, Error> {
886
+ let spec = {
887
+ let renderers = self.session.child_renderers.lock().unwrap();
888
+ renderers.get(name).cloned()
889
+ }
890
+ .ok_or_else(|| Error::new(ErrorKind::InvalidOperation, format!("No renderer registered for child component '{name}'")))?;
891
+
892
+ let mut map: BTreeMap<String, MjValue> = props.into_iter().map(|(k, v)| (mangle_ident(&k), v)).collect();
893
+
894
+ // Rest-bag routing -- mirrors
895
+ // packages/adapter-jinja/src/test-render.ts buildChildRenderers
896
+ // (lines 331-413): a child that destructures a rest bag gets every
897
+ // prop the child didn't explicitly declare routed into it.
898
+ if let Some(rest_name) = &spec.rest_props_name {
899
+ let rest_key = mangle_ident(rest_name);
900
+ let mut keep: HashSet<String> = spec.param_names.iter().map(|p| mangle_ident(p)).collect();
901
+ keep.insert(rest_key.clone());
902
+ keep.insert("children".to_string());
903
+ keep.insert(mangle_ident("key"));
904
+ keep.insert("_bf_slot".to_string());
905
+
906
+ let mut rest_bag: BTreeMap<String, MjValue> = match map.remove(&rest_key) {
907
+ Some(v) => mj_map_to_btreemap(&v),
908
+ None => BTreeMap::new(),
909
+ };
910
+ let extra_keys: Vec<String> = map.keys().filter(|k| !keep.contains(*k)).cloned().collect();
911
+ for k in extra_keys {
912
+ if let Some(v) = map.remove(&k) {
913
+ rest_bag.insert(k, v);
914
+ }
915
+ }
916
+ map.insert(rest_key, MjValue::from(rest_bag));
917
+ }
918
+
919
+ let slot_id = map.remove("_bf_slot").and_then(|v| v.as_str().map(str::to_string));
920
+ // JSX `key` (a reserved prop) -> data-key on the child's scope root
921
+ // for keyed-loop reconciliation. `data_key_attr` needs JS
922
+ // stringification semantics (`js_string`), not safe-value
923
+ // semantics, so this is the one deliberate `JsValue` conversion
924
+ // point in this method -- see the docstring above.
925
+ let data_key = map.remove(&mangle_ident("key")).map(|v| mj_to_js(&v));
926
+
927
+ let host_scope = self.scope_id.clone();
928
+ let (child_scope_id, bf_parent, bf_mount) = match &slot_id {
929
+ Some(slot) => (format!("{host_scope}_{slot}"), Some(host_scope.clone()), Some(slot.clone())),
930
+ // Loop child (no slot): a fresh `<ComponentName>_<rand6>` id.
931
+ None => (format!("{}_{}", spec.component_name, self.session.next_rand_hex6()), None, None),
932
+ };
933
+
934
+ let child = BfInstance {
935
+ session: Arc::clone(&self.session),
936
+ scope_id: child_scope_id,
937
+ is_child: true,
938
+ bf_parent,
939
+ bf_mount,
940
+ props: None,
941
+ data_key,
942
+ };
943
+
944
+ // Seed template vars: static ssrDefaults first, caller's props win
945
+ // -- mirrors buildChildRenderers' `_vars = {**_defaults, **child_props}`.
946
+ let mut vars: BTreeMap<String, MjValue> =
947
+ mj_map_to_btreemap(&spec.ssr_defaults).into_iter().map(|(k, v)| (mangle_ident(&k), v)).collect();
948
+ vars.extend(map);
949
+
950
+ let rendered = backend_minijinja::render_named_from_state_values(state, &spec.template, child.as_mj_value(), vars)?;
951
+ // chomp: remove at most one trailing newline.
952
+ Ok(rendered.strip_suffix('\n').map(str::to_string).unwrap_or(rendered))
953
+ }
954
+ }
955
+
956
+ // ---------------------------------------------------------------------------
957
+ // minijinja::Value <-> JsValue conversion. minijinja 2.x represents EVERY
958
+ // sequence/mapping as a `Object` (`ObjectRepr::Seq`/`Map`/`Iterable`) --
959
+ // there is no native array/map `ValueRepr` variant to match on -- so this
960
+ // goes through the public iteration API (`try_iter` / `get_item`) rather
961
+ // than any internal representation.
962
+ // ---------------------------------------------------------------------------
963
+
964
+ pub fn mj_to_js(v: &MjValue) -> JsValue {
965
+ match v.kind() {
966
+ ValueKind::Undefined | ValueKind::None => JsValue::Null,
967
+ ValueKind::Bool => JsValue::Bool(v.is_true()),
968
+ // Preserves NaN/Infinity: minijinja's `F64` repr is a bare `f64`
969
+ // with no finiteness constraint (unlike `serde_json::Value`, see
970
+ // the `num` module docstring), and `f64::try_from` doesn't reject
971
+ // non-finite values either.
972
+ ValueKind::Number => JsValue::Number(f64::try_from(v.clone()).unwrap_or(f64::NAN)),
973
+ ValueKind::String => JsValue::String(v.as_str().unwrap_or("").to_string()),
974
+ ValueKind::Seq | ValueKind::Iterable => {
975
+ let mut out = Vec::new();
976
+ if let Ok(iter) = v.try_iter() {
977
+ for item in iter {
978
+ out.push(mj_to_js(&item));
979
+ }
980
+ }
981
+ JsValue::Array(out)
982
+ }
983
+ ValueKind::Map => {
984
+ let mut out = BTreeMap::new();
985
+ if let Ok(keys) = v.try_iter() {
986
+ for key in keys {
987
+ if let Some(k) = key.as_str() {
988
+ if let Ok(val) = v.get_item(&key) {
989
+ out.insert(k.to_string(), mj_to_js(&val));
990
+ }
991
+ }
992
+ }
993
+ }
994
+ JsValue::Object(out)
995
+ }
996
+ // Bytes / Plain / Invalid: never produced by JSON-sourced template
997
+ // vars; not a JS-value shape.
998
+ _ => JsValue::Null,
999
+ }
1000
+ }
1001
+
1002
+ /// Shallow-flatten a minijinja `Map`-kind `Value` into a `BTreeMap` of
1003
+ /// (still-`Value`) entries, WITHOUT recursing through [`mj_to_js`] --
1004
+ /// unlike [`mj_to_js`]'s `Map` branch, this keeps every entry's safe flag
1005
+ /// (and any non-JSON-shaped `Object` value) intact. Used by `render_child`
1006
+ /// to keep child props as `Value` end-to-end (see its docstring). Any
1007
+ /// non-`Map`-kind input (e.g. the `None`/`Undefined` a childless/default-
1008
+ /// less registration decodes to) yields an empty map, matching the
1009
+ /// pre-port `.as_object().cloned().unwrap_or_default()` fallback.
1010
+ pub fn mj_map_to_btreemap(v: &MjValue) -> BTreeMap<String, MjValue> {
1011
+ let mut out = BTreeMap::new();
1012
+ if v.kind() != ValueKind::Map {
1013
+ return out;
1014
+ }
1015
+ if let Ok(keys) = v.try_iter() {
1016
+ for key in keys {
1017
+ if let Some(k) = key.as_str() {
1018
+ if let Ok(val) = v.get_item(&key) {
1019
+ out.insert(k.to_string(), val);
1020
+ }
1021
+ }
1022
+ }
1023
+ }
1024
+ out
1025
+ }
1026
+
1027
+ pub fn js_to_mj(v: &JsValue) -> MjValue {
1028
+ match v {
1029
+ JsValue::Null => MjValue::from(()),
1030
+ JsValue::Bool(b) => MjValue::from(*b),
1031
+ JsValue::Number(n) => MjValue::from(*n),
1032
+ JsValue::String(s) => MjValue::from(s.clone()),
1033
+ JsValue::Array(a) => MjValue::from(a.iter().map(js_to_mj).collect::<Vec<_>>()),
1034
+ JsValue::Object(o) => {
1035
+ let map: BTreeMap<String, MjValue> = o.iter().map(|(k, v)| (k.clone(), js_to_mj(v))).collect();
1036
+ MjValue::from(map)
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ /// Mark a plain string as HTML-structural markup, bypassing the custom
1042
+ /// formatter's auto-escaping. Every genuinely markup-returning helper
1043
+ /// below routes through this (mirrors the Python backend's `mark_raw` /
1044
+ /// the template-side `| safe` filter the TS emitter still ALSO emits at
1045
+ /// these call sites -- marking safe twice is idempotent, so this holds
1046
+ /// regardless of whether the emitted template additionally applies
1047
+ /// `| safe`).
1048
+ fn safe(s: impl Into<String>) -> MjValue {
1049
+ MjValue::from_safe_string(s.into())
1050
+ }
1051
+
1052
+ impl Object for BfInstance {
1053
+ fn call_method(self: &Arc<Self>, state: &State<'_, '_>, method: &str, args: &[MjValue]) -> Result<MjValue, Error> {
1054
+ // render_child needs special argument handling (dict-literal
1055
+ // positional form OR kwargs form -- see its docstring) and access
1056
+ // to `state` for re-entrant template rendering, so it's dispatched
1057
+ // before the generic per-arg conversion below. Crucially, its props
1058
+ // MUST stay `minijinja::Value` end-to-end here (never routed through
1059
+ // `mj_to_js`/`JsValue`): a JSX-children capture (`{% set cap %}...
1060
+ // {% endset %}`) is a SAFE minijinja Value, and `JsValue` has no
1061
+ // safe/unsafe distinction, so converting through it would silently
1062
+ // strip the safe flag and cause the child to double-escape its
1063
+ // children (verified regression). See `render_child`'s docstring.
1064
+ if method == "render_child" {
1065
+ let name = args
1066
+ .first()
1067
+ .and_then(|v| v.as_str())
1068
+ .ok_or_else(|| Error::new(ErrorKind::MissingArgument, "render_child requires a name"))?
1069
+ .to_string();
1070
+ let rest = args.get(1..).unwrap_or(&[]);
1071
+ let (positional, kwargs): (&[MjValue], Kwargs) = from_args(rest)?;
1072
+ let props: BTreeMap<String, MjValue> = if let Some(first) = positional.first() {
1073
+ mj_map_to_btreemap(first)
1074
+ } else {
1075
+ let keys: Vec<String> = kwargs.args().map(str::to_string).collect();
1076
+ let mut map = BTreeMap::new();
1077
+ for k in keys {
1078
+ let v: MjValue = kwargs.get(&k)?;
1079
+ map.insert(k, v);
1080
+ }
1081
+ map
1082
+ };
1083
+ let html = self.render_child(state, &name, props)?;
1084
+ return Ok(safe(html));
1085
+ }
1086
+
1087
+ // `bf.string` mirrors the Python runtime's `js_string`: a `str`
1088
+ // input is returned AS-IS (a `Markup`/safe input stays safe). Every
1089
+ // other helper below intentionally builds a fresh plain `MjValue`
1090
+ // (matches Python, where only `js_string` has this passthrough), so
1091
+ // this is special-cased here, before the generic per-arg `mj_to_js`
1092
+ // conversion would strip the safe flag off a safe string argument.
1093
+ if method == "string" {
1094
+ if let Some(first) = args.first() {
1095
+ if first.is_safe() && first.kind() == ValueKind::String {
1096
+ return Ok(first.clone());
1097
+ }
1098
+ }
1099
+ let js_args: Vec<JsValue> = args.iter().map(mj_to_js).collect();
1100
+ return Ok(MjValue::from(js_string(arg(&js_args, 0))));
1101
+ }
1102
+
1103
+ let js_args: Vec<JsValue> = args.iter().map(mj_to_js).collect();
1104
+ let a = |i: usize| arg(&js_args, i);
1105
+
1106
+ match method {
1107
+ // -- Scope & props ------------------------------------------
1108
+ "scope_attr" => Ok(MjValue::from(self.scope_attr())),
1109
+ "hydration_attrs" => Ok(safe(self.hydration_attrs())),
1110
+ "data_key_attr" => Ok(safe(self.data_key_attr())),
1111
+ "props_attr" => Ok(safe(self.props_attr())),
1112
+
1113
+ // -- Context --------------------------------------------------
1114
+ "provide_context" => {
1115
+ self.provide_context(a(0).as_str().unwrap_or(""), a(1).clone());
1116
+ Ok(MjValue::from(()))
1117
+ }
1118
+ "revoke_context" => {
1119
+ self.revoke_context(a(0).as_str().unwrap_or(""));
1120
+ Ok(MjValue::from(()))
1121
+ }
1122
+ "use_context" => Ok(js_to_mj(&self.use_context(a(0).as_str().unwrap_or(""), a(1).clone()))),
1123
+
1124
+ // -- Comment markers -------------------------------------------
1125
+ "comment" => Ok(safe(format!("<!--bf-{}-->", js_string(a(0))))),
1126
+ "bool_str" => Ok(MjValue::from(js_bool_str(js_truthy(a(0))))),
1127
+ "text_start" => Ok(safe(format!("<!--bf:{}-->", js_string(a(0))))),
1128
+ "text_end" => Ok(safe("<!--/-->".to_string())),
1129
+ "scope_comment" => Ok(safe(self.scope_comment())),
1130
+
1131
+ // -- Script registration ---------------------------------------
1132
+ "register_script" => {
1133
+ self.register_script(a(0).as_str().unwrap_or(""));
1134
+ Ok(MjValue::from(()))
1135
+ }
1136
+ "scripts" => Ok(safe(self.scripts())),
1137
+
1138
+ // -- Streaming SSR ----------------------------------------------
1139
+ "streaming_bootstrap" => Ok(safe(STREAMING_BOOTSTRAP.to_string())),
1140
+ "async_boundary" => Ok(safe(format!("<div bf-async=\"{}\">{}</div>", js_string(a(0)), js_string(a(1))))),
1141
+ "async_resolve" => Ok(safe(format!(
1142
+ "<template bf-async-resolve=\"{0}\">{1}</template><script>__bf_swap(\"{0}\")</script>",
1143
+ js_string(a(0)),
1144
+ js_string(a(1))
1145
+ ))),
1146
+
1147
+ // -- JS-compat callees (#1189) -----------------------------------
1148
+ // NOTE: "string" is dispatched above (before the generic
1149
+ // `js_args` conversion) so a safe input can pass through
1150
+ // unchanged -- see that early-return's docstring.
1151
+ "json" => Ok(MjValue::from(backend_minijinja::encode_json(a(0)))),
1152
+ "number" => Ok(MjValue::from(js_number(a(0)))),
1153
+ "truthy" => Ok(MjValue::from(js_truthy(a(0)))),
1154
+ "mod" => Ok(MjValue::from(num::js_mod(js_number(a(0)), js_number(a(1))))),
1155
+ "floor" => Ok(MjValue::from(num::js_floor(js_number(a(0))))),
1156
+ "ceil" => Ok(MjValue::from(num::js_ceil(js_number(a(0))))),
1157
+ "round" => Ok(MjValue::from(num::js_round(js_number(a(0))))),
1158
+ "to_fixed" => Ok(MjValue::from(num::to_fixed(js_number(a(0)), num::to_f64(a(1)) as i32))),
1159
+
1160
+ // -- Array / string method helpers (#1448 Tier A) ------------------
1161
+ "includes" => Ok(MjValue::from(includes(a(0), a(1)))),
1162
+ "lc" => Ok(MjValue::from(js_string(a(0)).to_lowercase())),
1163
+ "uc" => Ok(MjValue::from(js_string(a(0)).to_uppercase())),
1164
+ "join" => Ok(MjValue::from(join(a(0), a(1)))),
1165
+ "length" => Ok(MjValue::from(length(a(0)))),
1166
+ "index_of" => Ok(MjValue::from(array_index_of(a(0), a(1), false))),
1167
+ "last_index_of" => Ok(MjValue::from(array_index_of(a(0), a(1), true))),
1168
+ "at" => Ok(js_to_mj(&at(a(0), a(1)))),
1169
+ "concat" => Ok(js_to_mj(&concat(a(0), a(1)))),
1170
+ "slice" => Ok(js_to_mj(&slice(a(0), a(1), a(2)))),
1171
+ // Object-rest residual for a `.map()` destructure binding (#2087
1172
+ // Phase B) -- see `omit`'s docstring.
1173
+ "omit" => Ok(js_to_mj(&omit(a(0), a(1)))),
1174
+ "reverse" => Ok(js_to_mj(&reverse(a(0)))),
1175
+ "flat" => {
1176
+ let depth = if matches!(a(1), JsValue::Null) { 1 } else { num::to_f64(a(1)) as i64 };
1177
+ Ok(js_to_mj(&JsValue::Array(flat(a(0).as_array().unwrap_or(&[]), depth))))
1178
+ }
1179
+ // Dynamic-depth `.flat(depth)` (#2094) -- `depth` is an arbitrary
1180
+ // runtime value (not a compile-time literal), coerced here via
1181
+ // JS `ToIntegerOrInfinity`. Deliberately a distinct entry point
1182
+ // from "flat" above -- see `flat_dynamic`'s docstring.
1183
+ "flat_dynamic" => Ok(js_to_mj(&JsValue::Array(flat_dynamic(a(0).as_array().unwrap_or(&[]), a(1))))),
1184
+ "flat_map" => Ok(js_to_mj(&flat_map(a(0), a(1).as_str().unwrap_or(""), a(2).as_str().unwrap_or("")))),
1185
+ "flat_map_tuple" => {
1186
+ let specs: Vec<(String, String)> = js_args[1..]
1187
+ .chunks(2)
1188
+ .filter(|c| c.len() == 2)
1189
+ .map(|c| (js_string(&c[0]), js_string(&c[1])))
1190
+ .collect();
1191
+ Ok(js_to_mj(&flat_map_tuple(a(0), &specs)))
1192
+ }
1193
+ "trim" => Ok(MjValue::from(trim(a(0)))),
1194
+ "split" => {
1195
+ let sep = if args.len() > 1 { Some(a(1)) } else { None };
1196
+ let limit = if args.len() > 2 && !matches!(a(2), JsValue::Null) { Some(num::to_f64(a(2)) as i64) } else { None };
1197
+ Ok(js_to_mj(&split(a(0), sep, limit)))
1198
+ }
1199
+ "starts_with" => Ok(MjValue::from(starts_with(a(0), a(1), a(2)))),
1200
+ "ends_with" => Ok(MjValue::from(ends_with(a(0), a(1), a(2)))),
1201
+ "replace" => Ok(MjValue::from(replace(a(0), a(1), a(2)))),
1202
+ "query" => Ok(MjValue::from(query(a(0), &js_args[1..]))),
1203
+ "repeat" => Ok(MjValue::from(repeat(a(0), a(1)))),
1204
+ "pad_start" => Ok(MjValue::from(pad(&scalar_or_empty(a(0)), a(1), a(2), true))),
1205
+ "pad_end" => Ok(MjValue::from(pad(&scalar_or_empty(a(0)), a(1), a(2), false))),
1206
+
1207
+ // -- Structured comparator / fold catalogues (#1448 Tier B/C) -----
1208
+ "sort" => Ok(js_to_mj(&sort(a(0), a(1)))),
1209
+ "reduce" => Ok(js_to_mj(&reduce(a(0), a(1)))),
1210
+
1211
+ // -- JSX intrinsic-element spread (#1407) --------------------------
1212
+ "spread_attrs" => Ok(safe(spread_attrs(a(0)))),
1213
+
1214
+ // -- Evaluator-driven sort / reduce / higher-order predicates (#2018)
1215
+ "sort_eval" => {
1216
+ let base_env = eval_base_env(a(4));
1217
+ let out = evaluator::sort_by_json(a(0), a(1).as_str().unwrap_or(""), a(2).as_str().unwrap_or(""), a(3).as_str().unwrap_or(""), &base_env)
1218
+ .map_err(eval_json_error)?;
1219
+ Ok(js_to_mj(&JsValue::Array(out)))
1220
+ }
1221
+ "reduce_eval" => {
1222
+ let base_env = eval_base_env(a(6));
1223
+ let direction = if matches!(a(5), JsValue::Null) { "left".to_string() } else { js_string(a(5)) };
1224
+ let out = evaluator::fold_json(a(0), a(1).as_str().unwrap_or(""), a(2).as_str().unwrap_or(""), a(3).as_str().unwrap_or(""), a(4).clone(), &direction, &base_env)
1225
+ .map_err(eval_json_error)?;
1226
+ Ok(js_to_mj(&out))
1227
+ }
1228
+ "filter_eval" => {
1229
+ let base_env = eval_base_env(a(3));
1230
+ let out = evaluator::filter_json(a(0), a(1).as_str().unwrap_or(""), a(2).as_str().unwrap_or(""), &base_env).map_err(eval_json_error)?;
1231
+ Ok(js_to_mj(&JsValue::Array(out)))
1232
+ }
1233
+ "every_eval" => {
1234
+ let base_env = eval_base_env(a(3));
1235
+ let out = evaluator::every_json(a(0), a(1).as_str().unwrap_or(""), a(2).as_str().unwrap_or(""), &base_env).map_err(eval_json_error)?;
1236
+ Ok(MjValue::from(out))
1237
+ }
1238
+ "some_eval" => {
1239
+ let base_env = eval_base_env(a(3));
1240
+ let out = evaluator::some_json(a(0), a(1).as_str().unwrap_or(""), a(2).as_str().unwrap_or(""), &base_env).map_err(eval_json_error)?;
1241
+ Ok(MjValue::from(out))
1242
+ }
1243
+ "find_eval" => {
1244
+ let forward = if matches!(a(3), JsValue::Null) { true } else { js_truthy(a(3)) };
1245
+ let base_env = eval_base_env(a(4));
1246
+ let out = evaluator::find_json(a(0), a(1).as_str().unwrap_or(""), a(2).as_str().unwrap_or(""), forward, &base_env).map_err(eval_json_error)?;
1247
+ Ok(js_to_mj(&out))
1248
+ }
1249
+ "find_index_eval" => {
1250
+ let forward = if matches!(a(3), JsValue::Null) { true } else { js_truthy(a(3)) };
1251
+ let base_env = eval_base_env(a(4));
1252
+ let out = evaluator::find_index_json(a(0), a(1).as_str().unwrap_or(""), a(2).as_str().unwrap_or(""), forward, &base_env).map_err(eval_json_error)?;
1253
+ Ok(MjValue::from(out))
1254
+ }
1255
+ "flat_map_eval" => {
1256
+ let base_env = eval_base_env(a(3));
1257
+ let out = evaluator::flat_map_json(a(0), a(1).as_str().unwrap_or(""), a(2).as_str().unwrap_or(""), &base_env).map_err(eval_json_error)?;
1258
+ Ok(js_to_mj(&JsValue::Array(out)))
1259
+ }
1260
+ "map_eval" => {
1261
+ let base_env = eval_base_env(a(3));
1262
+ let out = evaluator::map_json(a(0), a(1).as_str().unwrap_or(""), a(2).as_str().unwrap_or(""), &base_env).map_err(eval_json_error)?;
1263
+ Ok(js_to_mj(&JsValue::Array(out)))
1264
+ }
1265
+
1266
+ _ => Err(Error::from(ErrorKind::UnknownMethod)),
1267
+ }
1268
+ }
1269
+
1270
+ fn enumerate(self: &Arc<Self>) -> Enumerator {
1271
+ Enumerator::NonEnumerable
1272
+ }
1273
+ }
1274
+
1275
+ fn eval_base_env(v: &JsValue) -> evaluator::Env {
1276
+ match v.as_object() {
1277
+ Some(m) => m.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
1278
+ None => evaluator::Env::new(),
1279
+ }
1280
+ }
1281
+
1282
+ fn eval_json_error(e: serde_json::Error) -> Error {
1283
+ Error::new(ErrorKind::InvalidOperation, format!("invalid ParsedExpr JSON: {e}"))
1284
+ }
1285
+
1286
+ const STREAMING_BOOTSTRAP: &str = "<script>(function(){function s(id){\
1287
+ var a=document.querySelector('[bf-async=\"'+id+'\"]');\
1288
+ var t=document.querySelector('template[bf-async-resolve=\"'+id+'\"]');\
1289
+ if(!a||!t)return;\
1290
+ a.replaceChildren(t.content.cloneNode(true));\
1291
+ a.removeAttribute('bf-async');\
1292
+ t.remove();\
1293
+ requestAnimationFrame(function(){if(window.__bf_hydrate)window.__bf_hydrate()})\
1294
+ };window.__bf_swap=s})()</script>";
1295
+
1296
+ // ---------------------------------------------------------------------------
1297
+ // Array / string helpers (free functions -- no instance state needed,
1298
+ // mirrors runtime.py's methods, which only use `self` for `self.backend`).
1299
+ // ---------------------------------------------------------------------------
1300
+
1301
+ pub fn includes(recv: &JsValue, elem: &JsValue) -> bool {
1302
+ match recv {
1303
+ JsValue::Array(items) => items.iter().any(|item| num::same_value_zero(item, elem)),
1304
+ JsValue::Object(_) => false,
1305
+ other => js_string(other).contains(&js_string(elem)),
1306
+ }
1307
+ }
1308
+
1309
+ pub fn join(recv: &JsValue, sep: &JsValue) -> String {
1310
+ let items = match recv.as_array() {
1311
+ Some(a) => a,
1312
+ None => return String::new(),
1313
+ };
1314
+ let sep_s = if matches!(sep, JsValue::Null) { ",".to_string() } else { js_string(sep) };
1315
+ items.iter().map(js_string).collect::<Vec<_>>().join(&sep_s)
1316
+ }
1317
+
1318
+ pub fn length(recv: &JsValue) -> f64 {
1319
+ match recv {
1320
+ JsValue::Array(a) => a.len() as f64,
1321
+ JsValue::Object(_) => 0.0,
1322
+ other => char_len(&js_string(other)) as f64,
1323
+ }
1324
+ }
1325
+
1326
+ pub fn at(recv: &JsValue, i: &JsValue) -> JsValue {
1327
+ let items = match recv.as_array() {
1328
+ Some(a) => a,
1329
+ None => return JsValue::Null,
1330
+ };
1331
+ if matches!(i, JsValue::Null) {
1332
+ return JsValue::Null;
1333
+ }
1334
+ let length = items.len() as i64;
1335
+ if length == 0 {
1336
+ return JsValue::Null;
1337
+ }
1338
+ let idx = num::to_f64(i) as i64;
1339
+ let idx = if idx < 0 { length + idx } else { idx };
1340
+ if idx < 0 || idx >= length { JsValue::Null } else { items[idx as usize].clone() }
1341
+ }
1342
+
1343
+ pub fn concat(a: &JsValue, b: &JsValue) -> JsValue {
1344
+ let mut out = Vec::new();
1345
+ if let Some(arr) = a.as_array() {
1346
+ out.extend(arr.iter().cloned());
1347
+ }
1348
+ if let Some(arr) = b.as_array() {
1349
+ out.extend(arr.iter().cloned());
1350
+ }
1351
+ JsValue::Array(out)
1352
+ }
1353
+
1354
+ pub fn slice(recv: &JsValue, start: &JsValue, end: &JsValue) -> JsValue {
1355
+ let items = match recv.as_array() {
1356
+ Some(a) => a,
1357
+ None => return JsValue::Array(Vec::new()),
1358
+ };
1359
+ let length = items.len() as i64;
1360
+ if length == 0 {
1361
+ return JsValue::Array(Vec::new());
1362
+ }
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);
1373
+ if s >= e {
1374
+ return JsValue::Array(Vec::new());
1375
+ }
1376
+ JsValue::Array(items[s as usize..e as usize].to_vec())
1377
+ }
1378
+
1379
+ /// Build a TRUE residual object -- every key of `recv` NOT listed in
1380
+ /// `exclude` -- for a `.map()` callback's object-rest destructure binding
1381
+ /// (`{ id, title, ...rest }`, #2087 Phase B). Mirrors `slice`'s array-rest
1382
+ /// counterpart: the adapter binds the destructured local straight to this
1383
+ /// helper's result (`{% set rest = bf.omit(item, ["id", "title"]) %}`), so a
1384
+ /// member read (`rest.flag`) or the existing `{...rest}` spread emit
1385
+ /// (`bf.spread_attrs`) both see only the non-destructured keys, same as the
1386
+ /// Hono/CSR IIFE (`(({ id: __bfR0, title: __bfR1, ...__bfRest }) =>
1387
+ /// __bfRest)(__bfItem())`). A non-object `recv`, or a non-array `exclude`,
1388
+ /// degrades to an empty object (consistent with `slice`'s non-array-recv →
1389
+ /// empty-array fallback) rather than panicking.
1390
+ pub fn omit(recv: &JsValue, exclude: &JsValue) -> JsValue {
1391
+ let map = match recv.as_object() {
1392
+ Some(m) => m,
1393
+ None => return JsValue::Object(BTreeMap::new()),
1394
+ };
1395
+ let exclude_keys: HashSet<&str> =
1396
+ exclude.as_array().unwrap_or(&[]).iter().filter_map(|v| v.as_str()).collect();
1397
+ JsValue::Object(
1398
+ map.iter()
1399
+ .filter(|(k, _)| !exclude_keys.contains(k.as_str()))
1400
+ .map(|(k, v)| (k.clone(), v.clone()))
1401
+ .collect(),
1402
+ )
1403
+ }
1404
+
1405
+ pub fn reverse(recv: &JsValue) -> JsValue {
1406
+ match recv.as_array() {
1407
+ Some(a) => {
1408
+ let mut v = a.to_vec();
1409
+ v.reverse();
1410
+ JsValue::Array(v)
1411
+ }
1412
+ None => JsValue::Array(Vec::new()),
1413
+ }
1414
+ }
1415
+
1416
+ pub fn trim(recv: &JsValue) -> String {
1417
+ match recv {
1418
+ JsValue::Null | JsValue::Array(_) | JsValue::Object(_) => String::new(),
1419
+ other => js_string(other).trim().to_string(),
1420
+ }
1421
+ }
1422
+
1423
+ pub fn split(recv: &JsValue, sep: Option<&JsValue>, limit: Option<i64>) -> JsValue {
1424
+ let s = scalar_or_empty(recv);
1425
+ let mut parts: Vec<String> = match sep {
1426
+ None => vec![s],
1427
+ Some(JsValue::Null) => vec![s],
1428
+ Some(sep_v) => {
1429
+ let sep_s = js_string(sep_v);
1430
+ if sep_s.is_empty() {
1431
+ s.chars().map(|c| c.to_string()).collect()
1432
+ } else if s.is_empty() {
1433
+ vec![String::new()]
1434
+ } else {
1435
+ s.split(sep_s.as_str()).map(str::to_string).collect()
1436
+ }
1437
+ }
1438
+ };
1439
+ if let Some(n) = limit {
1440
+ if n == 0 {
1441
+ parts.clear();
1442
+ } else if n > 0 && (n as usize) < parts.len() {
1443
+ parts.truncate(n as usize);
1444
+ }
1445
+ }
1446
+ JsValue::Array(parts.into_iter().map(JsValue::String).collect())
1447
+ }
1448
+
1449
+ pub fn starts_with(recv: &JsValue, prefix: &JsValue, position: &JsValue) -> bool {
1450
+ let mut s = scalar_or_empty(recv);
1451
+ let p = js_string(prefix);
1452
+ if !matches!(position, JsValue::Null) {
1453
+ let len = char_len(&s);
1454
+ let n = (num::to_f64(position).max(0.0) as usize).min(len);
1455
+ s = char_slice_from(&s, n);
1456
+ }
1457
+ s.starts_with(&p)
1458
+ }
1459
+
1460
+ pub fn ends_with(recv: &JsValue, suffix: &JsValue, end_position: &JsValue) -> bool {
1461
+ let mut s = scalar_or_empty(recv);
1462
+ let x = js_string(suffix);
1463
+ if !matches!(end_position, JsValue::Null) {
1464
+ let len = char_len(&s);
1465
+ let e = (num::to_f64(end_position).max(0.0) as usize).min(len);
1466
+ s = char_slice_to(&s, e);
1467
+ }
1468
+ if x.is_empty() {
1469
+ return true;
1470
+ }
1471
+ let (ls, lx) = (char_len(&s), char_len(&x));
1472
+ if ls < lx {
1473
+ return false;
1474
+ }
1475
+ char_slice_from(&s, ls - lx) == x
1476
+ }
1477
+
1478
+ pub fn replace(recv: &JsValue, pattern: &JsValue, replacement: &JsValue) -> String {
1479
+ let s = scalar_or_empty(recv);
1480
+ let o = js_string(pattern);
1481
+ let n = js_string(replacement);
1482
+ if o.is_empty() {
1483
+ return format!("{n}{s}");
1484
+ }
1485
+ match s.find(&o) {
1486
+ None => s,
1487
+ Some(byte_idx) => format!("{}{}{}", &s[..byte_idx], n, &s[byte_idx + o.len()..]),
1488
+ }
1489
+ }
1490
+
1491
+ /// `queryHref(base, {...})` (#2042) -- build `"$base?k=v&..."` from a flat
1492
+ /// list of (guard, key, value) triples. A pair is included iff its guard is
1493
+ /// JS-truthy AND its value is a non-empty string. A value may also be a
1494
+ /// list, appending one pair per non-empty member. Repeating a key
1495
+ /// overwrites the value at its first position.
1496
+ pub fn query(base: &JsValue, triples: &[JsValue]) -> String {
1497
+ let b = scalar_or_empty(base);
1498
+ let mut pairs: Vec<(String, String)> = Vec::new();
1499
+ let mut pos: HashMap<String, usize> = HashMap::new();
1500
+ let mut i = 0;
1501
+ while i + 2 < triples.len() {
1502
+ let (guard, key, val) = (&triples[i], &triples[i + 1], &triples[i + 2]);
1503
+ i += 3;
1504
+ if !js_truthy(guard) {
1505
+ continue;
1506
+ }
1507
+ let key_s = scalar_or_empty(key);
1508
+ if let JsValue::Array(vals) = val {
1509
+ for m in vals {
1510
+ let s = scalar_or_empty(m);
1511
+ if s.is_empty() {
1512
+ continue;
1513
+ }
1514
+ pairs.push((key_s.clone(), s));
1515
+ }
1516
+ continue;
1517
+ }
1518
+ let val_s = scalar_or_empty(val);
1519
+ if val_s.is_empty() {
1520
+ continue;
1521
+ }
1522
+ if let Some(&idx) = pos.get(&key_s) {
1523
+ pairs[idx] = (key_s, val_s);
1524
+ } else {
1525
+ pos.insert(key_s.clone(), pairs.len());
1526
+ pairs.push((key_s, val_s));
1527
+ }
1528
+ }
1529
+ if pairs.is_empty() {
1530
+ return b;
1531
+ }
1532
+ let joined = pairs.iter().map(|(k, v)| format!("{}={}", form_escape_str(k), form_escape_str(v))).collect::<Vec<_>>().join("&");
1533
+ format!("{b}?{joined}")
1534
+ }
1535
+
1536
+ pub fn repeat(recv: &JsValue, count: &JsValue) -> String {
1537
+ let s = scalar_or_empty(recv);
1538
+ let n = if matches!(count, JsValue::Null) { 0 } else { num::to_f64(count) as i64 };
1539
+ if n > 0 { s.repeat(n as usize) } else { String::new() }
1540
+ }
1541
+
1542
+ // Re-export form_escape for callers that want a JsValue-in convenience
1543
+ // (used by `query`'s internal string-shaped pairs already, kept for parity
1544
+ // with runtime.py's `_form_escape` free-function shape).
1545
+ #[allow(dead_code)]
1546
+ fn _form_escape_value(v: &JsValue) -> String {
1547
+ form_escape(v)
1548
+ }