@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.
- package/README.md +194 -0
- package/dist/adapter/analysis/component-tree.d.ts +26 -0
- package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
- package/dist/adapter/boolean-result.d.ts +85 -0
- package/dist/adapter/boolean-result.d.ts.map +1 -0
- package/dist/adapter/emit-context.d.ts +107 -0
- package/dist/adapter/emit-context.d.ts.map +1 -0
- package/dist/adapter/expr/array-method.d.ts +75 -0
- package/dist/adapter/expr/array-method.d.ts.map +1 -0
- package/dist/adapter/expr/emitters.d.ts +143 -0
- package/dist/adapter/expr/emitters.d.ts.map +1 -0
- package/dist/adapter/index.d.ts +6 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/index.js +189091 -0
- package/dist/adapter/lib/constants.d.ts +25 -0
- package/dist/adapter/lib/constants.d.ts.map +1 -0
- package/dist/adapter/lib/ir-scope.d.ts +50 -0
- package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
- package/dist/adapter/lib/minijinja-naming.d.ts +64 -0
- package/dist/adapter/lib/minijinja-naming.d.ts.map +1 -0
- package/dist/adapter/lib/types.d.ts +32 -0
- package/dist/adapter/lib/types.d.ts.map +1 -0
- package/dist/adapter/memo/seed.d.ts +84 -0
- package/dist/adapter/memo/seed.d.ts.map +1 -0
- package/dist/adapter/minijinja-adapter.d.ts +421 -0
- package/dist/adapter/minijinja-adapter.d.ts.map +1 -0
- package/dist/adapter/props/prop-classes.d.ts +33 -0
- package/dist/adapter/props/prop-classes.d.ts.map +1 -0
- package/dist/adapter/spread/spread-codegen.d.ts +63 -0
- package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
- package/dist/adapter/value/parsed-literal.d.ts +28 -0
- package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
- package/dist/build.d.ts +29 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +189111 -0
- package/dist/conformance-pins.d.ts +13 -0
- package/dist/conformance-pins.d.ts.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +189112 -0
- package/package.json +67 -0
- package/runtime/Cargo.lock +124 -0
- package/runtime/Cargo.toml +21 -0
- package/runtime/src/backend_minijinja.rs +176 -0
- package/runtime/src/bin/bf-render.rs +147 -0
- package/runtime/src/evaluator.rs +770 -0
- package/runtime/src/lib.rs +19 -0
- package/runtime/src/manifest.rs +258 -0
- package/runtime/src/num.rs +558 -0
- package/runtime/src/runtime.rs +1548 -0
- package/runtime/src/search_params.rs +173 -0
- package/runtime/tests/eval_vectors.rs +94 -0
- package/runtime/tests/evaluator.rs +407 -0
- package/runtime/tests/helper_vectors.rs +348 -0
- package/runtime/tests/manifest.rs +169 -0
- package/runtime/tests/omit.rs +79 -0
- package/runtime/tests/props_attr.rs +75 -0
- package/runtime/tests/query.rs +50 -0
- package/runtime/tests/render_child.rs +210 -0
- package/runtime/tests/search_params.rs +68 -0
- package/runtime/tests/spread_attrs.rs +94 -0
- package/runtime/tests/template_primitives.rs +376 -0
- package/runtime/tests/vector-divergences.json +33 -0
- package/src/__tests__/minijinja-adapter-unit.test.ts +392 -0
- package/src/__tests__/minijinja-adapter.test.ts +58 -0
- package/src/__tests__/minijinja-counter.test.ts +61 -0
- package/src/__tests__/minijinja-query-href.test.ts +101 -0
- package/src/__tests__/minijinja-spread-attrs.test.ts +227 -0
- package/src/adapter/analysis/component-tree.ts +119 -0
- package/src/adapter/boolean-result.ts +177 -0
- package/src/adapter/emit-context.ts +119 -0
- package/src/adapter/expr/array-method.ts +346 -0
- package/src/adapter/expr/emitters.ts +608 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/lib/constants.ts +37 -0
- package/src/adapter/lib/ir-scope.ts +95 -0
- package/src/adapter/lib/minijinja-naming.ts +85 -0
- package/src/adapter/lib/types.ts +35 -0
- package/src/adapter/memo/seed.ts +135 -0
- package/src/adapter/minijinja-adapter.ts +1796 -0
- package/src/adapter/props/prop-classes.ts +65 -0
- package/src/adapter/spread/spread-codegen.ts +168 -0
- package/src/adapter/value/parsed-literal.ts +76 -0
- package/src/build.ts +38 -0
- package/src/conformance-pins.ts +101 -0
- package/src/index.ts +12 -0
- 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 `"` / `'` 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 (`"` 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('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\'', "'")
|
|
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('"', """)));
|
|
775
|
+
}
|
|
776
|
+
if let Some(mount) = &self.bf_mount {
|
|
777
|
+
parts.push(format!("bf-m=\"{}\"", mount.replace('"', """)));
|
|
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('&', "&").replace('"', """);
|
|
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
|
+
}
|