@barefootjs/jinja 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 +66 -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 +84 -0
- package/dist/adapter/boolean-result.d.ts.map +1 -0
- package/dist/adapter/emit-context.d.ts +106 -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/expr/operand.d.ts +25 -0
- package/dist/adapter/expr/operand.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 +189097 -0
- package/dist/adapter/jinja-adapter.d.ts +394 -0
- package/dist/adapter/jinja-adapter.d.ts.map +1 -0
- package/dist/adapter/lib/constants.d.ts +21 -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/jinja-naming.d.ts +77 -0
- package/dist/adapter/lib/jinja-naming.d.ts.map +1 -0
- package/dist/adapter/lib/types.d.ts +27 -0
- package/dist/adapter/lib/types.d.ts.map +1 -0
- package/dist/adapter/memo/seed.d.ts +81 -0
- package/dist/adapter/memo/seed.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 +61 -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 +28 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +189117 -0
- package/dist/conformance-pins.d.ts +12 -0
- package/dist/conformance-pins.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +189118 -0
- package/package.json +66 -0
- package/python/VERSION +1 -0
- package/python/barefootjs/__init__.py +57 -0
- package/python/barefootjs/backend_jinja.py +125 -0
- package/python/barefootjs/evaluator.py +787 -0
- package/python/barefootjs/runtime.py +1334 -0
- package/python/barefootjs/search_params.py +89 -0
- package/python/pyproject.toml +32 -0
- package/python/tests/__init__.py +0 -0
- package/python/tests/test_eval_vectors.py +88 -0
- package/python/tests/test_evaluator.py +406 -0
- package/python/tests/test_helper_vectors.py +288 -0
- package/python/tests/test_omit.py +62 -0
- package/python/tests/test_props_attr.py +54 -0
- package/python/tests/test_query.py +41 -0
- package/python/tests/test_render.py +102 -0
- package/python/tests/test_render_child.py +96 -0
- package/python/tests/test_search_params.py +50 -0
- package/python/tests/test_spread_attrs.py +86 -0
- package/python/tests/test_template_primitives.py +347 -0
- package/python/tests/vector-divergences.json +42 -0
- package/src/__tests__/jinja-adapter-unit.test.ts +390 -0
- package/src/__tests__/jinja-adapter.test.ts +53 -0
- package/src/__tests__/jinja-counter.test.ts +62 -0
- package/src/__tests__/jinja-query-href.test.ts +99 -0
- package/src/__tests__/jinja-spread-attrs.test.ts +225 -0
- package/src/adapter/analysis/component-tree.ts +119 -0
- package/src/adapter/boolean-result.ts +176 -0
- package/src/adapter/emit-context.ts +118 -0
- package/src/adapter/expr/array-method.ts +346 -0
- package/src/adapter/expr/emitters.ts +608 -0
- package/src/adapter/expr/operand.ts +35 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/jinja-adapter.ts +1747 -0
- package/src/adapter/lib/constants.ts +33 -0
- package/src/adapter/lib/ir-scope.ts +95 -0
- package/src/adapter/lib/jinja-naming.ts +114 -0
- package/src/adapter/lib/types.ts +30 -0
- package/src/adapter/memo/seed.ts +132 -0
- package/src/adapter/props/prop-classes.ts +65 -0
- package/src/adapter/spread/spread-codegen.ts +166 -0
- package/src/adapter/value/parsed-literal.ts +76 -0
- package/src/build.ts +37 -0
- package/src/conformance-pins.ts +101 -0
- package/src/index.ts +9 -0
- package/src/test-render.ts +714 -0
|
@@ -0,0 +1,1334 @@
|
|
|
1
|
+
"""Python port of packages/adapter-perl/lib/BarefootJS.pm.
|
|
2
|
+
|
|
3
|
+
Engine- and framework-agnostic server runtime for BarefootJS marked
|
|
4
|
+
templates. This module is the server-side runtime the marked templates call
|
|
5
|
+
into at render time (as the `bf` object: `{{ bf.scope_attr() }}`,
|
|
6
|
+
`{{ bf.json(data) }}`, `{{ bf.spread_attrs(bag) }}`). Every operation that
|
|
7
|
+
depends on *how* a template is rendered -- JSON marshalling, raw-string
|
|
8
|
+
marking, JSX-children materialisation, and named-template rendering -- is
|
|
9
|
+
delegated to a pluggable `backend` (see `backend_jinja.JinjaBackend` for the
|
|
10
|
+
Jinja2 implementation), mirroring the Perl runtime's `BarefootJS::Backend::*`
|
|
11
|
+
seam.
|
|
12
|
+
|
|
13
|
+
Method names are kept snake_case and VERBATIM from the Perl runtime
|
|
14
|
+
(`render_child`, `scope_attr`, `hydration_attrs`, ...) since the Jinja
|
|
15
|
+
adapter's TS emitter generates calls to these exact names.
|
|
16
|
+
|
|
17
|
+
Divergences from the Perl port (all intentional, all documented at the call
|
|
18
|
+
site below):
|
|
19
|
+
|
|
20
|
+
* `new`/`__init__` does not lazily fall back to a default framework
|
|
21
|
+
backend (Perl falls back to `BarefootJS::Backend::Mojo`). This Python
|
|
22
|
+
distribution ships exactly one backend (`backend_jinja.JinjaBackend`);
|
|
23
|
+
building runtime.py -> backend_jinja.py would be a circular import, and
|
|
24
|
+
there is no Python analogue of the Perl "reference implementation"
|
|
25
|
+
fallback to fall back to. A host MUST inject a backend via
|
|
26
|
+
`BarefootJS(c, {'backend': backend})`.
|
|
27
|
+
* `c` / `config` / `backend` are plain Python attributes (idiomatic),
|
|
28
|
+
rather than the Perl accessor-base's dual get/set methods. The
|
|
29
|
+
per-render mutable state Perl mutates through dual accessors
|
|
30
|
+
(`_scope_id`, `_bf_parent`, `_bf_mount`, `_props`, `_data_key`,
|
|
31
|
+
`_is_child`, `_scripts`, `_script_seen`, `_child_renderers`) keeps the
|
|
32
|
+
SAME call-as-getter/call-as-setter shape here, because generated render
|
|
33
|
+
scripts and ported tests call them exactly as the Perl harness does
|
|
34
|
+
(`bf._scope_id('Widget_test')`).
|
|
35
|
+
* Perl has no cyclic garbage collector by default, so `register_
|
|
36
|
+
components_from_manifest` weakens its `$parent` capture to avoid a
|
|
37
|
+
per-request reference cycle leak. CPython's garbage collector handles
|
|
38
|
+
reference cycles natively, so the Python port captures `parent` (self)
|
|
39
|
+
directly with no `weakref` dance -- functionally equivalent, no leak.
|
|
40
|
+
* `index_of` / `last_index_of` compare array elements with Python's native
|
|
41
|
+
`==` rather than Perl's `eq` (which stringifies both sides). Python's
|
|
42
|
+
`int`/`float`/`str` are genuinely distinct types (unlike Perl scalars),
|
|
43
|
+
so `==` gives true JS strict-equality behaviour for cross-type probes
|
|
44
|
+
(`2 == "2"` is False) -- this actually ELIMINATES the Perl-documented
|
|
45
|
+
"cross-type probe is strict-equality false" divergence rather than
|
|
46
|
+
reproducing it. (Edge case: Python `bool` is an `int` subclass, so
|
|
47
|
+
`True == 1`, which JS `true === 1` is not; not exercised by the golden
|
|
48
|
+
vectors.) `includes` (#2075) no longer shares this native-`==` path: it
|
|
49
|
+
dispatches through `evaluator._same_value_zero` -- the same
|
|
50
|
+
SameValueZero algorithm the evaluator's serialized-callback `.includes`
|
|
51
|
+
arm uses, so both positions agree, and native `==`'s two remaining gaps
|
|
52
|
+
(NaN never equals itself; `bool` is an `int` subclass) are closed too.
|
|
53
|
+
* Several Perl helpers stringify a value via raw Perl interpolation
|
|
54
|
+
(`"$val"`) rather than routing through `bf->string`. In Python, raw
|
|
55
|
+
`str()` on a float would print `3.0` instead of the JS-correct `"3"`, so
|
|
56
|
+
every internal stringification in this port (query, join, reduce's
|
|
57
|
+
string fold, replace/split/pad/repeat's receiver coercion, ...) goes
|
|
58
|
+
through the same `js_string()` helper that backs the public `string()`
|
|
59
|
+
method -- a uniform policy, not a per-call fix.
|
|
60
|
+
* `spread_attrs`'s boolean-attribute detection uses a plain Python
|
|
61
|
+
`isinstance(val, bool)` check instead of Perl's `JSON::PP::Boolean` /
|
|
62
|
+
`Mojo::JSON::_Bool` sentinel-ref dance -- Python has a real boolean
|
|
63
|
+
type, so no sentinel objects are needed for the "caller MUST pass a
|
|
64
|
+
real boolean for boolean attributes" contract.
|
|
65
|
+
* `truthy` and `mod` do not exist in the Perl runtime; both are added
|
|
66
|
+
per the Python-adapter plan (JS truthiness / JS `%` are needed
|
|
67
|
+
uniformly by the Jinja TS emitter's lowering policy for conditions and
|
|
68
|
+
any `%` operator it emits).
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
from __future__ import annotations
|
|
72
|
+
|
|
73
|
+
import functools
|
|
74
|
+
import math
|
|
75
|
+
import re
|
|
76
|
+
import uuid
|
|
77
|
+
from typing import Any, Callable, Optional
|
|
78
|
+
|
|
79
|
+
from . import evaluator as _evaluator
|
|
80
|
+
from .evaluator import looks_like_number, parse_number_literal
|
|
81
|
+
from .search_params import SearchParams
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Keyword mangling (Python/Jinja adapter plan, "Reserved words" divergence
|
|
85
|
+
# policy). `jinja_ident` is the canonical name -- it is the exact symbol the
|
|
86
|
+
# TS emitter's `packages/adapter-jinja/src/adapter/lib/jinja-naming.ts`
|
|
87
|
+
# docstring names as its Python-side counterpart
|
|
88
|
+
# (`barefootjs.runtime.jinja_ident`), so the reserved-word set MUST be kept
|
|
89
|
+
# in lock-step with that file's `RESERVED_WORDS`. `mangle_ident` is exported
|
|
90
|
+
# as an alias for the same function under the more generic name.
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
RESERVED_WORDS = frozenset(
|
|
94
|
+
{
|
|
95
|
+
"if", "else", "for", "in", "is", "not", "and", "or", "none", "true", "false",
|
|
96
|
+
"import", "from", "class", "def", "pass", "del", "return", "lambda", "global",
|
|
97
|
+
"with", "as", "raise", "try", "except", "finally", "while", "break",
|
|
98
|
+
"continue", "elif", "yield", "assert", "nonlocal",
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def jinja_ident(name: str) -> str:
|
|
104
|
+
"""Mangle a JS identifier (prop name, signal getter, loop param, ...)
|
|
105
|
+
into a Jinja/Python-safe variable name: reserved words get a trailing
|
|
106
|
+
`_` suffix, everything else passes through unchanged. Applied at every
|
|
107
|
+
point a props dict is turned into template variables (`render_named`,
|
|
108
|
+
`render_child` prop passing) -- see the module divergence notes above."""
|
|
109
|
+
return f"{name}_" if name in RESERVED_WORDS else name
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# Alias for the exact name used in the task/plan prose ("a function
|
|
113
|
+
# mangle_ident(name)"); `jinja_ident` is the canonical name shared with the
|
|
114
|
+
# TS emitter's docstring contract.
|
|
115
|
+
mangle_ident = jinja_ident
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# JS-equivalent value stringification / coercion -- free functions so they
|
|
120
|
+
# are reusable by the array/string helpers below without needing `self`
|
|
121
|
+
# (mirrors the Perl port's free `_pad`, `_style_to_css`, `_to_attr_name`,
|
|
122
|
+
# `_form_escape`, `_html_escape` subs).
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_nan(n: float) -> bool:
|
|
127
|
+
return n != n
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _is_inf(n: float) -> bool:
|
|
131
|
+
return n in (float("inf"), float("-inf"))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _format_js_number(n: float) -> str:
|
|
135
|
+
if n != n:
|
|
136
|
+
return "NaN"
|
|
137
|
+
if n == float("inf"):
|
|
138
|
+
return "Infinity"
|
|
139
|
+
if n == float("-inf"):
|
|
140
|
+
return "-Infinity"
|
|
141
|
+
if n == 0:
|
|
142
|
+
return "0" # normalises -0.0 to JS's "0" spelling
|
|
143
|
+
if n == int(n) and abs(n) < 1e21:
|
|
144
|
+
return str(int(n))
|
|
145
|
+
return repr(n) # shortest round-trip; see evaluator._format_number
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def js_string(value: Any) -> str:
|
|
149
|
+
"""JS `String(v)` mirror, with the SAME `undef` divergence the Perl
|
|
150
|
+
runtime documents: `None` renders as the empty string (not "null") so an
|
|
151
|
+
unset prop doesn't surface as a literal "null"/"undefined" in
|
|
152
|
+
user-facing HTML."""
|
|
153
|
+
if value is None:
|
|
154
|
+
return ""
|
|
155
|
+
if isinstance(value, bool):
|
|
156
|
+
return "true" if value else "false"
|
|
157
|
+
if isinstance(value, float):
|
|
158
|
+
return _format_js_number(value)
|
|
159
|
+
if isinstance(value, int):
|
|
160
|
+
return str(value)
|
|
161
|
+
if isinstance(value, str):
|
|
162
|
+
return value
|
|
163
|
+
if isinstance(value, list):
|
|
164
|
+
# JS `Array.prototype.toString` == `.join(',')`; never exercised by
|
|
165
|
+
# the golden vectors (they stay scalar-domain) but a reasonable,
|
|
166
|
+
# JS-faithful fallback rather than a Python repr.
|
|
167
|
+
return ",".join("" if v is None else js_string(v) for v in value)
|
|
168
|
+
if isinstance(value, dict):
|
|
169
|
+
return "[object Object]"
|
|
170
|
+
return str(value)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def js_number(value: Any) -> float:
|
|
174
|
+
"""JS `Number(v)` mirror, with the SAME deliberate divergence the Perl
|
|
175
|
+
runtime documents: `None` and non-numeric strings yield real NaN (not
|
|
176
|
+
0), so an unset prop / parse failure can't silently zero downstream
|
|
177
|
+
arithmetic."""
|
|
178
|
+
if value is None:
|
|
179
|
+
return float("nan")
|
|
180
|
+
if isinstance(value, bool):
|
|
181
|
+
return 1.0 if value else 0.0
|
|
182
|
+
if isinstance(value, (int, float)):
|
|
183
|
+
return float(value)
|
|
184
|
+
if isinstance(value, str):
|
|
185
|
+
return parse_number_literal(value) if looks_like_number(value) else float("nan")
|
|
186
|
+
return float("nan") # list / dict
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _coerce_flat_depth(depth: Any) -> int:
|
|
190
|
+
"""JS `ToIntegerOrInfinity` for a dynamic `.flat(depth)` argument
|
|
191
|
+
(#2094), returning an int in `flat`'s own contract (`-1` = unbounded,
|
|
192
|
+
`>= 0` = that many levels). Mirrors Go's `coerceFlatDepth` /
|
|
193
|
+
`flatDepthToFloat`: reuses `js_number` (the module's existing JS
|
|
194
|
+
`Number(v)` mirror -- `None` and non-numeric strings already yield NaN
|
|
195
|
+
there, and `bool` is already checked before the numeric branch, which is
|
|
196
|
+
exactly right here too since Python's `bool` is a subclass of `int`) and
|
|
197
|
+
then applies `ToIntegerOrInfinity`'s NaN/Infinity/truncation rules on
|
|
198
|
+
top: NaN (incl. `None` / non-numeric strings) -> 0; truncate toward
|
|
199
|
+
zero; negative -> 0; +Infinity or a huge finite value -> `-1` (`flat`'s
|
|
200
|
+
"flatten fully" sentinel, matching the golden `flat_dynamic` vectors)."""
|
|
201
|
+
f = js_number(depth)
|
|
202
|
+
if f != f: # NaN
|
|
203
|
+
return 0
|
|
204
|
+
if f == float("inf"):
|
|
205
|
+
return -1 # flat's "flatten fully" sentinel
|
|
206
|
+
if f == float("-inf"):
|
|
207
|
+
return 0
|
|
208
|
+
trunc = math.trunc(f)
|
|
209
|
+
if trunc < 0:
|
|
210
|
+
return 0
|
|
211
|
+
# A huge finite depth behaves identically to "flatten fully" in
|
|
212
|
+
# practice -- real data bottoms out at its actual nesting depth long
|
|
213
|
+
# before a counter this large would ever reach zero (mirrors Go's cap).
|
|
214
|
+
if trunc > 1_000_000:
|
|
215
|
+
return -1
|
|
216
|
+
return int(trunc)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def js_truthy(value: Any) -> bool:
|
|
220
|
+
"""JS truthiness: `[]` / `{}` are truthy; only `None`, `False`, `0`,
|
|
221
|
+
`0.0`, `''`, and NaN are falsy.
|
|
222
|
+
|
|
223
|
+
A template-engine "this variable was never bound" sentinel (Jinja's
|
|
224
|
+
`Undefined` under `ChainableUndefined`, e.g. an optional prop a caller
|
|
225
|
+
never passed and that isn't listed in the caller's props dict at all --
|
|
226
|
+
NOT the same as a prop explicitly set to `None`) is not one of the
|
|
227
|
+
isinstance branches below, so it falls through to the final line. It
|
|
228
|
+
must NOT hit the list/dict/tuple `True` short-circuit -- that would make
|
|
229
|
+
`bf.truthy(unset_var)` true, silently flipping every
|
|
230
|
+
`props.optionalFlag`-style condition on an unset prop (`asChild`-style
|
|
231
|
+
branches, `<Slot>` composition, ...) to its truthy branch. Kept
|
|
232
|
+
engine-agnostic (no jinja2 import here -- see the module divergence
|
|
233
|
+
notes): Jinja's `Undefined.__bool__` already returns `False`, matching
|
|
234
|
+
JS's own `Boolean(undefined) === false`, so routing the true catch-all
|
|
235
|
+
through Python's `bool(value)` does the right thing for both an
|
|
236
|
+
unrecognised engine sentinel AND any other unhandled type, while list /
|
|
237
|
+
dict / tuple keep the explicit JS-truthy-even-when-empty override."""
|
|
238
|
+
if value is None or value is False:
|
|
239
|
+
return False
|
|
240
|
+
if value is True:
|
|
241
|
+
return True
|
|
242
|
+
if isinstance(value, (int, float)):
|
|
243
|
+
if isinstance(value, float) and value != value: # NaN
|
|
244
|
+
return False
|
|
245
|
+
return value != 0
|
|
246
|
+
if isinstance(value, str):
|
|
247
|
+
return value != "" # incl. the JS-truthy "0"
|
|
248
|
+
if isinstance(value, (list, dict, tuple)):
|
|
249
|
+
return True # JS objects/arrays are always truthy, even when empty
|
|
250
|
+
return bool(value)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def js_bool_str(value: Any) -> str:
|
|
254
|
+
"""Map a boolean-shaped value to the JS `String(bool)` form. Contract is
|
|
255
|
+
boolean-only (mirrors BarefootJS.pm::bool_str): callers must have
|
|
256
|
+
classified the expression as boolean-result before routing through this
|
|
257
|
+
helper; non-boolean attribute bindings stay on the plain interpolation
|
|
258
|
+
path and never reach this function."""
|
|
259
|
+
return "true" if value else "false"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _scalar_or_empty(value: Any) -> str:
|
|
263
|
+
"""String receivers arriving as a list/dict coerce to '' (Perl: `ref($recv)
|
|
264
|
+
? '' : "$recv"`); anything else (including None) goes through
|
|
265
|
+
`js_string`. Shared by every string-method helper below."""
|
|
266
|
+
if isinstance(value, (list, dict)):
|
|
267
|
+
return ""
|
|
268
|
+
return js_string(value)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
# Context (SSR mirror of the client `provideContext` / `useContext`) -- a
|
|
273
|
+
# module-level store (like Perl's package-level `%CONTEXT_STACKS`), not
|
|
274
|
+
# per-instance: a parent template and the child templates it renders via
|
|
275
|
+
# `render_child` are separate `BarefootJS` instances that don't share one.
|
|
276
|
+
# SSR rendering is synchronous, and push/pop are perfectly balanced, so the
|
|
277
|
+
# per-name stack always unwinds to empty at the end of each provider
|
|
278
|
+
# subtree, keeping concurrent root renders isolated.
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
_CONTEXT_STACKS: dict[str, list[Any]] = {}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
# spread_attrs support (JSX intrinsic-element spread, #1407) -- mirrors the
|
|
286
|
+
# JS `spreadAttrs` runtime and the Go/Perl adapters' equivalents so SSR
|
|
287
|
+
# output stays byte-equal across adapters.
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
_SVG_CAMEL_CASE_ATTRS = frozenset(
|
|
291
|
+
{
|
|
292
|
+
"allowReorder", "attributeName", "attributeType", "autoReverse",
|
|
293
|
+
"baseFrequency", "baseProfile", "calcMode", "clipPathUnits",
|
|
294
|
+
"contentScriptType", "contentStyleType", "diffuseConstant", "edgeMode",
|
|
295
|
+
"externalResourcesRequired", "filterRes", "filterUnits", "glyphRef",
|
|
296
|
+
"gradientTransform", "gradientUnits", "kernelMatrix", "kernelUnitLength",
|
|
297
|
+
"keyPoints", "keySplines", "keyTimes", "lengthAdjust", "limitingConeAngle",
|
|
298
|
+
"markerHeight", "markerUnits", "markerWidth", "maskContentUnits",
|
|
299
|
+
"maskUnits", "numOctaves", "pathLength", "patternContentUnits",
|
|
300
|
+
"patternTransform", "patternUnits", "pointsAtX", "pointsAtY", "pointsAtZ",
|
|
301
|
+
"preserveAlpha", "preserveAspectRatio", "primitiveUnits", "refX", "refY",
|
|
302
|
+
"repeatCount", "repeatDur", "requiredExtensions", "requiredFeatures",
|
|
303
|
+
"specularConstant", "specularExponent", "spreadMethod", "startOffset",
|
|
304
|
+
"stdDeviation", "stitchTiles", "surfaceScale", "systemLanguage",
|
|
305
|
+
"tableValues", "targetX", "targetY", "textLength", "viewBox", "viewTarget",
|
|
306
|
+
"xChannelSelector", "yChannelSelector", "zoomAndPan",
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
_CAMEL_TO_KEBAB_RE = re.compile(r"([A-Z])")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _to_attr_name(key: str) -> str:
|
|
314
|
+
if key == "className":
|
|
315
|
+
return "class"
|
|
316
|
+
if key == "htmlFor":
|
|
317
|
+
return "for"
|
|
318
|
+
if key in _SVG_CAMEL_CASE_ATTRS:
|
|
319
|
+
return key
|
|
320
|
+
# camelCase -> kebab-case, with a leading `-` for an initial uppercase
|
|
321
|
+
# letter (JS-reference parity, even though that case produces an
|
|
322
|
+
# HTML-invalid attribute name -- same documented behaviour as the Go /
|
|
323
|
+
# Perl adapters' `toAttrName` / `_to_attr_name`).
|
|
324
|
+
return _CAMEL_TO_KEBAB_RE.sub(lambda m: "-" + m.group(1).lower(), key)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
_FORM_SAFE_BYTES = frozenset(
|
|
328
|
+
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789*-._ "
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _form_escape(s: Any) -> str:
|
|
333
|
+
"""application/x-www-form-urlencoded serialisation, matching the
|
|
334
|
+
browser's `URLSearchParams` (which the SSR query render must equal):
|
|
335
|
+
keep ASCII alphanumerics and `* - . _`; encode every other byte as `%XX`
|
|
336
|
+
(UPPER hex); space -> `+`. Non-ASCII is encoded byte-wise over its UTF-8
|
|
337
|
+
bytes."""
|
|
338
|
+
text = js_string(s)
|
|
339
|
+
raw = text.encode("utf-8")
|
|
340
|
+
out: list[str] = []
|
|
341
|
+
for byte in raw:
|
|
342
|
+
if byte in _FORM_SAFE_BYTES:
|
|
343
|
+
out.append(chr(byte))
|
|
344
|
+
else:
|
|
345
|
+
out.append(f"%{byte:02X}")
|
|
346
|
+
return "".join(out).replace(" ", "+")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _html_escape(value: Any) -> str:
|
|
350
|
+
"""HTML attribute-value escape for SSR string emission -- covers `&`,
|
|
351
|
+
`<`, `>`, `"`, `'` (matches Go's `template.HTMLEscapeString` semantics
|
|
352
|
+
byte-for-byte, using `"` / `'` for quotes rather than the named
|
|
353
|
+
entities, so SSR output stays identical across adapters)."""
|
|
354
|
+
s = js_string(value)
|
|
355
|
+
s = s.replace("&", "&")
|
|
356
|
+
s = s.replace("<", "<")
|
|
357
|
+
s = s.replace(">", ">")
|
|
358
|
+
s = s.replace('"', """)
|
|
359
|
+
s = s.replace("'", "'")
|
|
360
|
+
return s
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _style_to_css(value: Any) -> Optional[str]:
|
|
364
|
+
if value is None:
|
|
365
|
+
return None
|
|
366
|
+
if not isinstance(value, dict):
|
|
367
|
+
# Non-dict values pass through stringified -- matches the JS
|
|
368
|
+
# `typeof value !== 'object'` branch in `styleToCss`.
|
|
369
|
+
s = js_string(value)
|
|
370
|
+
return s if len(s) else None
|
|
371
|
+
parts = []
|
|
372
|
+
for key in sorted(value.keys()):
|
|
373
|
+
v = value[key]
|
|
374
|
+
if v is None:
|
|
375
|
+
continue
|
|
376
|
+
prop = _CAMEL_TO_KEBAB_RE.sub(lambda m: "-" + m.group(1).lower(), key)
|
|
377
|
+
parts.append(f"{prop}:{js_string(v)}")
|
|
378
|
+
return ";".join(parts) if parts else None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _compare_sort_key(av: Any, bv: Any, compare_type: str) -> int:
|
|
382
|
+
"""Compare two projected sort keys, ascending orientation (-1/0/1); the
|
|
383
|
+
caller negates for 'desc'. 'auto' compares numerically when both keys
|
|
384
|
+
look like numbers, else lexically (matches Go/Perl's `bf_sort`). None
|
|
385
|
+
coalesces to '' / 0 so the order stays total."""
|
|
386
|
+
if compare_type == "string":
|
|
387
|
+
a = js_string(av) if av is not None else ""
|
|
388
|
+
b = js_string(bv) if bv is not None else ""
|
|
389
|
+
return -1 if a < b else (1 if a > b else 0)
|
|
390
|
+
if compare_type == "auto":
|
|
391
|
+
if _is_numeric_like(av) and _is_numeric_like(bv):
|
|
392
|
+
an, bn = _numeric_value(av), _numeric_value(bv)
|
|
393
|
+
return -1 if an < bn else (1 if an > bn else 0)
|
|
394
|
+
a = js_string(av) if av is not None else ""
|
|
395
|
+
b = js_string(bv) if bv is not None else ""
|
|
396
|
+
return -1 if a < b else (1 if a > b else 0)
|
|
397
|
+
# numeric
|
|
398
|
+
an = _numeric_value(av) if av is not None else 0.0
|
|
399
|
+
bn = _numeric_value(bv) if bv is not None else 0.0
|
|
400
|
+
return -1 if an < bn else (1 if an > bn else 0)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _is_numeric_like(v: Any) -> bool:
|
|
404
|
+
if v is None or isinstance(v, bool):
|
|
405
|
+
return False
|
|
406
|
+
if isinstance(v, (int, float)):
|
|
407
|
+
return True
|
|
408
|
+
if isinstance(v, str):
|
|
409
|
+
return looks_like_number(v)
|
|
410
|
+
return False
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _numeric_value(v: Any) -> float:
|
|
414
|
+
if v is None or isinstance(v, (list, dict)):
|
|
415
|
+
return 0.0
|
|
416
|
+
if isinstance(v, bool):
|
|
417
|
+
return 1.0 if v else 0.0
|
|
418
|
+
if isinstance(v, (int, float)):
|
|
419
|
+
return float(v)
|
|
420
|
+
if isinstance(v, str):
|
|
421
|
+
return parse_number_literal(v) if looks_like_number(v) else 0.0
|
|
422
|
+
return 0.0
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _derive_stash_from_defaults(defaults: dict, props: dict) -> dict:
|
|
426
|
+
"""Derive template-stash kvs from a manifest entry's `ssrDefaults`
|
|
427
|
+
section. See BarefootJS.pm's `_derive_stash_from_defaults` docstring for
|
|
428
|
+
the full field-shape contract."""
|
|
429
|
+
extra: dict = {}
|
|
430
|
+
for name, d in (defaults or {}).items():
|
|
431
|
+
if not isinstance(d, dict):
|
|
432
|
+
extra[name] = d
|
|
433
|
+
continue
|
|
434
|
+
if d.get("isRestProps"):
|
|
435
|
+
extra[name] = props[name] if name in props else d.get("value")
|
|
436
|
+
continue
|
|
437
|
+
prop_name = d.get("propName")
|
|
438
|
+
if prop_name is not None and props.get(prop_name) is not None:
|
|
439
|
+
extra[name] = props[prop_name]
|
|
440
|
+
else:
|
|
441
|
+
extra[name] = d.get("value")
|
|
442
|
+
return extra
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
_STRIPPED_TEMPLATE_SUFFIXES = (".html.ep", ".tx", ".jinja")
|
|
446
|
+
_MANIFEST_ENTRY_RE = re.compile(r"^ui/([^/]+)/index$")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# ---------------------------------------------------------------------------
|
|
450
|
+
# Dual-purpose (get/set) accessor factory -- mirrors the Perl accessor base
|
|
451
|
+
# (`for my $attr (qw(...)) { *{"BarefootJS::$attr"} = sub {...} }`): calling
|
|
452
|
+
# with no args reads (building the default on first access), calling with
|
|
453
|
+
# one positional arg writes and returns `self` for chaining.
|
|
454
|
+
# ---------------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _dual_accessor(attr: str, default: Any = None) -> Callable:
|
|
458
|
+
def accessor(self: "BarefootJS", *args: Any) -> Any:
|
|
459
|
+
if args:
|
|
460
|
+
self._attrs[attr] = args[0]
|
|
461
|
+
return self
|
|
462
|
+
if attr not in self._attrs:
|
|
463
|
+
self._attrs[attr] = default(self) if callable(default) else default
|
|
464
|
+
return self._attrs.get(attr)
|
|
465
|
+
|
|
466
|
+
accessor.__name__ = attr
|
|
467
|
+
return accessor
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class BarefootJS:
|
|
471
|
+
"""The `bf` object generated Jinja templates call."""
|
|
472
|
+
|
|
473
|
+
_scripts = _dual_accessor("_scripts", lambda self: [])
|
|
474
|
+
_script_seen = _dual_accessor("_script_seen", lambda self: {})
|
|
475
|
+
_child_renderers = _dual_accessor("_child_renderers", lambda self: {})
|
|
476
|
+
_is_child = _dual_accessor("_is_child", False)
|
|
477
|
+
_scope_id = _dual_accessor("_scope_id")
|
|
478
|
+
_bf_parent = _dual_accessor("_bf_parent")
|
|
479
|
+
_bf_mount = _dual_accessor("_bf_mount")
|
|
480
|
+
_props = _dual_accessor("_props")
|
|
481
|
+
_data_key = _dual_accessor("_data_key")
|
|
482
|
+
|
|
483
|
+
def __init__(self, c: Any = None, config: Optional[dict] = None):
|
|
484
|
+
self._attrs: dict = {}
|
|
485
|
+
self.c = c
|
|
486
|
+
self.config = config or {}
|
|
487
|
+
# See the module divergence notes: no lazy framework-backend
|
|
488
|
+
# fallback (Perl falls back to BarefootJS::Backend::Mojo). A host
|
|
489
|
+
# MUST inject one.
|
|
490
|
+
self.backend = self.config.get("backend")
|
|
491
|
+
|
|
492
|
+
# -----------------------------------------------------------------
|
|
493
|
+
# search_params(query='')
|
|
494
|
+
# -----------------------------------------------------------------
|
|
495
|
+
#
|
|
496
|
+
# Build a request-scoped reader for the reactive searchParams()
|
|
497
|
+
# environment signal from a raw query string. Callable as
|
|
498
|
+
# `BarefootJS.search_params(...)` (class-level, mirroring Perl's
|
|
499
|
+
# `BarefootJS->search_params(...)`) or as an instance method.
|
|
500
|
+
|
|
501
|
+
@staticmethod
|
|
502
|
+
def search_params(query: str = "") -> SearchParams:
|
|
503
|
+
return SearchParams(query)
|
|
504
|
+
|
|
505
|
+
# -----------------------------------------------------------------
|
|
506
|
+
# Scope & Props
|
|
507
|
+
# -----------------------------------------------------------------
|
|
508
|
+
|
|
509
|
+
def scope_attr(self) -> str:
|
|
510
|
+
# bf-s is the addressable scope id only (#1249).
|
|
511
|
+
return self._scope_id() or ""
|
|
512
|
+
|
|
513
|
+
def hydration_attrs(self) -> str:
|
|
514
|
+
"""Emits `bf-h="<host>" bf-m="<slot>" bf-r=""` conditionally. See
|
|
515
|
+
spec/compiler.md "Slot identity"."""
|
|
516
|
+
parts = []
|
|
517
|
+
host = self._bf_parent()
|
|
518
|
+
mount = self._bf_mount()
|
|
519
|
+
if host:
|
|
520
|
+
parts.append(f'bf-h="{js_string(host).replace(chr(34), """)}"')
|
|
521
|
+
if mount:
|
|
522
|
+
parts.append(f'bf-m="{js_string(mount).replace(chr(34), """)}"')
|
|
523
|
+
if not self._is_child():
|
|
524
|
+
parts.append('bf-r=""')
|
|
525
|
+
return " ".join(parts)
|
|
526
|
+
|
|
527
|
+
def data_key_attr(self) -> str:
|
|
528
|
+
"""Emits ` data-key="<key>"` for a keyed loop item, else ''."""
|
|
529
|
+
k = self._data_key()
|
|
530
|
+
if k is None:
|
|
531
|
+
return ""
|
|
532
|
+
k_str = js_string(k).replace("&", "&").replace('"', """)
|
|
533
|
+
return f' data-key="{k_str}"'
|
|
534
|
+
|
|
535
|
+
def props_attr(self) -> str:
|
|
536
|
+
props = self._props()
|
|
537
|
+
if not props:
|
|
538
|
+
return ""
|
|
539
|
+
# The JSON must be attribute-escaped: a raw `'` inside a string value
|
|
540
|
+
# (e.g. a blog paragraph) terminates the single-quoted attribute and
|
|
541
|
+
# truncates the hydration payload. The browser entity-decodes the
|
|
542
|
+
# attribute value, so the client's JSON.parse sees the original text.
|
|
543
|
+
j = _html_escape(self.backend.encode_json(props))
|
|
544
|
+
return f" bf-p='{j}'"
|
|
545
|
+
|
|
546
|
+
# -----------------------------------------------------------------
|
|
547
|
+
# Context (SSR mirror of the client `provideContext` / `useContext`)
|
|
548
|
+
# -----------------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
def provide_context(self, name: str, value: Any) -> str:
|
|
551
|
+
_CONTEXT_STACKS.setdefault(name, []).append(value)
|
|
552
|
+
return ""
|
|
553
|
+
|
|
554
|
+
def revoke_context(self, name: str) -> str:
|
|
555
|
+
stack = _CONTEXT_STACKS.get(name)
|
|
556
|
+
if stack:
|
|
557
|
+
stack.pop()
|
|
558
|
+
return ""
|
|
559
|
+
|
|
560
|
+
def use_context(self, name: str, default: Any = None) -> Any:
|
|
561
|
+
stack = _CONTEXT_STACKS.get(name)
|
|
562
|
+
if not stack:
|
|
563
|
+
return default
|
|
564
|
+
return stack[-1]
|
|
565
|
+
|
|
566
|
+
# -----------------------------------------------------------------
|
|
567
|
+
# Comment Markers
|
|
568
|
+
# -----------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
def comment(self, text: str) -> str:
|
|
571
|
+
return f"<!--bf-{text}-->"
|
|
572
|
+
|
|
573
|
+
# -----------------------------------------------------------------
|
|
574
|
+
# JS-equivalent value stringification
|
|
575
|
+
# -----------------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
def bool_str(self, value: Any) -> str:
|
|
578
|
+
return js_bool_str(value)
|
|
579
|
+
|
|
580
|
+
def text_start(self, slot_id: str) -> str:
|
|
581
|
+
return f"<!--bf:{slot_id}-->"
|
|
582
|
+
|
|
583
|
+
def text_end(self) -> str:
|
|
584
|
+
return "<!--/-->"
|
|
585
|
+
|
|
586
|
+
def scope_comment(self) -> str:
|
|
587
|
+
"""See spec/compiler.md "Slot identity" for the comment-scope wire
|
|
588
|
+
format."""
|
|
589
|
+
scope_id = self._scope_id() or ""
|
|
590
|
+
host_segment = ""
|
|
591
|
+
host = self._bf_parent()
|
|
592
|
+
mount = self._bf_mount()
|
|
593
|
+
if host:
|
|
594
|
+
host_segment = f"|h={host}|m={mount or ''}"
|
|
595
|
+
props_json = ""
|
|
596
|
+
props = self._props()
|
|
597
|
+
if props:
|
|
598
|
+
props_json = "|" + self.backend.encode_json(props)
|
|
599
|
+
return f"<!--bf-scope:{scope_id}{host_segment}{props_json}-->"
|
|
600
|
+
|
|
601
|
+
# -----------------------------------------------------------------
|
|
602
|
+
# Script Registration
|
|
603
|
+
# -----------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
def register_script(self, path: str) -> None:
|
|
606
|
+
seen = self._script_seen()
|
|
607
|
+
if seen.get(path):
|
|
608
|
+
return
|
|
609
|
+
seen[path] = True
|
|
610
|
+
self._scripts().append(path)
|
|
611
|
+
|
|
612
|
+
# -----------------------------------------------------------------
|
|
613
|
+
# Child Component Rendering
|
|
614
|
+
# -----------------------------------------------------------------
|
|
615
|
+
|
|
616
|
+
def register_child_renderer(self, name: str, renderer: Callable) -> None:
|
|
617
|
+
self._child_renderers()[name] = renderer
|
|
618
|
+
|
|
619
|
+
def render_child(self, name: str, *args: Any, **kwargs: Any) -> Any:
|
|
620
|
+
"""Renderer contract (#1897): the renderer is invoked with TWO
|
|
621
|
+
arguments -- the props dict and the INVOKING instance. A renderer
|
|
622
|
+
registered on the root may be called from a nested child render, and
|
|
623
|
+
the grandchild's scope/slot identity must chain off the CALLER's
|
|
624
|
+
scope id, not the registrant's.
|
|
625
|
+
|
|
626
|
+
Accepts both the kwargs form -- `bf.render_child(name, k=v, ...)` --
|
|
627
|
+
and the single-dict form -- `bf.render_child(name, {'k': v})` -- the
|
|
628
|
+
latter mirroring the Perl port's hashref form for callers that can't
|
|
629
|
+
splat a hash into positional/keyword args."""
|
|
630
|
+
renderer = self._child_renderers().get(name)
|
|
631
|
+
if renderer is None:
|
|
632
|
+
raise RuntimeError(f"No renderer registered for child component '{name}'")
|
|
633
|
+
if len(args) == 1 and isinstance(args[0], dict) and not kwargs:
|
|
634
|
+
props = dict(args[0])
|
|
635
|
+
else:
|
|
636
|
+
props = {}
|
|
637
|
+
it = iter(args)
|
|
638
|
+
for k, v in zip(it, it):
|
|
639
|
+
props[k] = v
|
|
640
|
+
props.update(kwargs)
|
|
641
|
+
# Guard on `in` so a childless invocation doesn't gain a spurious
|
|
642
|
+
# `children: None` key -- preserves the historical "only touch
|
|
643
|
+
# children when present" behaviour.
|
|
644
|
+
if "children" in props:
|
|
645
|
+
props["children"] = self.backend.materialize(props["children"])
|
|
646
|
+
# Keyword mangling applied wherever a props dict becomes template
|
|
647
|
+
# variables -- see the module docstring's divergence notes.
|
|
648
|
+
props = {jinja_ident(k): v for k, v in props.items()}
|
|
649
|
+
return renderer(props, self)
|
|
650
|
+
|
|
651
|
+
# -----------------------------------------------------------------
|
|
652
|
+
# Bulk registration from build manifest
|
|
653
|
+
# -----------------------------------------------------------------
|
|
654
|
+
|
|
655
|
+
def register_components_from_manifest(
|
|
656
|
+
self, manifest: dict, signal_init: Optional[dict] = None
|
|
657
|
+
) -> None:
|
|
658
|
+
"""`bf build` emits a manifest describing every component the page
|
|
659
|
+
might invoke. This walks that manifest and registers one child
|
|
660
|
+
renderer per UI registry entry -- the path shape `ui/<name>/index`
|
|
661
|
+
maps to the `<name>` slot key the generated template invokes via
|
|
662
|
+
`bf.render_child('<name>', ...)`.
|
|
663
|
+
|
|
664
|
+
`signal_init` is an opt-in override dict keyed by slot key
|
|
665
|
+
(`{slot_key: props -> dict}`) for cases the static ssrDefaults
|
|
666
|
+
extractor can't see through."""
|
|
667
|
+
signal_inits = signal_init or {}
|
|
668
|
+
parent_scope = self._scope_id()
|
|
669
|
+
parent = self # see module divergence notes re: no weakref needed
|
|
670
|
+
|
|
671
|
+
for entry_name, entry in (manifest or {}).items():
|
|
672
|
+
# `__barefoot__` is the runtime entry, not a component.
|
|
673
|
+
if entry_name == "__barefoot__":
|
|
674
|
+
continue
|
|
675
|
+
m = _MANIFEST_ENTRY_RE.match(entry_name)
|
|
676
|
+
if not m:
|
|
677
|
+
continue
|
|
678
|
+
slot_key = m.group(1)
|
|
679
|
+
marked = (entry or {}).get("markedTemplate", "") if isinstance(entry, dict) else ""
|
|
680
|
+
if not marked:
|
|
681
|
+
continue
|
|
682
|
+
template_name = marked
|
|
683
|
+
if template_name.startswith("templates/"):
|
|
684
|
+
template_name = template_name[len("templates/") :]
|
|
685
|
+
for suffix in _STRIPPED_TEMPLATE_SUFFIXES:
|
|
686
|
+
if template_name.endswith(suffix):
|
|
687
|
+
template_name = template_name[: -len(suffix)]
|
|
688
|
+
break
|
|
689
|
+
|
|
690
|
+
signal_init_fn = signal_inits.get(slot_key)
|
|
691
|
+
manifest_defaults = (entry or {}).get("ssrDefaults") if isinstance(entry, dict) else None
|
|
692
|
+
|
|
693
|
+
def make_renderer(
|
|
694
|
+
template_name: str = template_name,
|
|
695
|
+
signal_init_fn: Optional[Callable] = signal_init_fn,
|
|
696
|
+
manifest_defaults: Optional[dict] = manifest_defaults,
|
|
697
|
+
) -> Callable:
|
|
698
|
+
def renderer(props: dict, caller: Optional["BarefootJS"] = None) -> str:
|
|
699
|
+
host = caller or parent
|
|
700
|
+
host_scope = host._scope_id() or parent_scope
|
|
701
|
+
# Child shares the parent's backend so nested renders go
|
|
702
|
+
# through the same engine.
|
|
703
|
+
child_bf = BarefootJS(parent.c, {"backend": parent.backend})
|
|
704
|
+
slot_id = props.pop("_bf_slot", None)
|
|
705
|
+
# JSX `key` (a reserved prop) -> data-key on the child's
|
|
706
|
+
# scope root for keyed-loop reconciliation.
|
|
707
|
+
data_key = props.pop("key", None)
|
|
708
|
+
if data_key is not None:
|
|
709
|
+
child_bf._data_key(data_key)
|
|
710
|
+
if slot_id:
|
|
711
|
+
child_bf._scope_id(f"{host_scope}_{slot_id}")
|
|
712
|
+
else:
|
|
713
|
+
child_bf._scope_id(f"{template_name}_{uuid.uuid4().hex[:6]}")
|
|
714
|
+
child_bf._is_child(True)
|
|
715
|
+
# (#1249) Slot identity: host scope + slot id.
|
|
716
|
+
if slot_id:
|
|
717
|
+
child_bf._bf_parent(host_scope)
|
|
718
|
+
child_bf._bf_mount(slot_id)
|
|
719
|
+
# Share the root registry so the child's own template
|
|
720
|
+
# can render further imported components (#1897).
|
|
721
|
+
child_bf._child_renderers(parent._child_renderers())
|
|
722
|
+
child_bf._scripts(parent._scripts())
|
|
723
|
+
child_bf._script_seen(parent._script_seen())
|
|
724
|
+
|
|
725
|
+
extra: dict = {}
|
|
726
|
+
if signal_init_fn:
|
|
727
|
+
extra = signal_init_fn(props)
|
|
728
|
+
elif manifest_defaults:
|
|
729
|
+
extra = _derive_stash_from_defaults(manifest_defaults, props)
|
|
730
|
+
|
|
731
|
+
html = parent.backend.render_named(
|
|
732
|
+
template_name, child_bf, {**props, **extra}
|
|
733
|
+
)
|
|
734
|
+
if isinstance(html, str) and html.endswith("\n"):
|
|
735
|
+
html = html[:-1] # chomp: remove at most one trailing newline
|
|
736
|
+
return html
|
|
737
|
+
|
|
738
|
+
return renderer
|
|
739
|
+
|
|
740
|
+
self.register_child_renderer(slot_key, make_renderer())
|
|
741
|
+
|
|
742
|
+
# -----------------------------------------------------------------
|
|
743
|
+
# Script Output
|
|
744
|
+
# -----------------------------------------------------------------
|
|
745
|
+
|
|
746
|
+
def scripts(self) -> str:
|
|
747
|
+
tags = [f'<script type="module" src="{path}"></script>' for path in self._scripts()]
|
|
748
|
+
return "\n".join(tags)
|
|
749
|
+
|
|
750
|
+
# -----------------------------------------------------------------
|
|
751
|
+
# Streaming SSR (Out-of-Order)
|
|
752
|
+
# -----------------------------------------------------------------
|
|
753
|
+
|
|
754
|
+
def streaming_bootstrap(self) -> str:
|
|
755
|
+
return (
|
|
756
|
+
"<script>(function(){function s(id){"
|
|
757
|
+
"var a=document.querySelector('[bf-async=\"'+id+'\"]');"
|
|
758
|
+
"var t=document.querySelector('template[bf-async-resolve=\"'+id+'\"]');"
|
|
759
|
+
"if(!a||!t)return;"
|
|
760
|
+
"a.replaceChildren(t.content.cloneNode(true));"
|
|
761
|
+
"a.removeAttribute('bf-async');"
|
|
762
|
+
"t.remove();"
|
|
763
|
+
"requestAnimationFrame(function(){if(window.__bf_hydrate)window.__bf_hydrate()})"
|
|
764
|
+
"};window.__bf_swap=s})()</script>"
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
def async_boundary(self, id_: str, fallback_html: Any) -> str:
|
|
768
|
+
fallback_html = self.backend.materialize(fallback_html)
|
|
769
|
+
return f'<div bf-async="{id_}">{fallback_html}</div>'
|
|
770
|
+
|
|
771
|
+
def async_resolve(self, id_: str, content_html: Any) -> str:
|
|
772
|
+
return f'<template bf-async-resolve="{id_}">{content_html}</template><script>__bf_swap("{id_}")</script>'
|
|
773
|
+
|
|
774
|
+
# -----------------------------------------------------------------
|
|
775
|
+
# JS-compat callees (#1189) -- invoked from generated Jinja templates as
|
|
776
|
+
# `{{ bf.json(val) }}`, `{{ bf.floor(val) }}`, etc.
|
|
777
|
+
# -----------------------------------------------------------------
|
|
778
|
+
|
|
779
|
+
def json(self, value: Any) -> str:
|
|
780
|
+
return self.backend.encode_json(value)
|
|
781
|
+
|
|
782
|
+
def string(self, value: Any) -> str:
|
|
783
|
+
return js_string(value)
|
|
784
|
+
|
|
785
|
+
def number(self, value: Any) -> float:
|
|
786
|
+
return js_number(value)
|
|
787
|
+
|
|
788
|
+
def truthy(self, value: Any) -> bool:
|
|
789
|
+
return js_truthy(value)
|
|
790
|
+
|
|
791
|
+
def mod(self, a: Any, b: Any) -> float:
|
|
792
|
+
"""JS `%`: remainder with the dividend's sign. Python's `math.fmod`
|
|
793
|
+
implements the same C-style fmod semantics as JS `%` for finite
|
|
794
|
+
operands (sign-of-dividend remainder); domain errors (zero divisor,
|
|
795
|
+
infinite dividend) map to NaN, matching JS."""
|
|
796
|
+
an, bn = js_number(a), js_number(b)
|
|
797
|
+
try:
|
|
798
|
+
return math.fmod(an, bn)
|
|
799
|
+
except (ValueError, OverflowError):
|
|
800
|
+
return float("nan")
|
|
801
|
+
|
|
802
|
+
def floor(self, value: Any) -> float:
|
|
803
|
+
n = js_number(value)
|
|
804
|
+
if _is_nan(n) or _is_inf(n):
|
|
805
|
+
return n
|
|
806
|
+
return float(math.floor(n))
|
|
807
|
+
|
|
808
|
+
def ceil(self, value: Any) -> float:
|
|
809
|
+
n = js_number(value)
|
|
810
|
+
if _is_nan(n) or _is_inf(n):
|
|
811
|
+
return n
|
|
812
|
+
return float(math.ceil(n))
|
|
813
|
+
|
|
814
|
+
def round(self, value: Any) -> float:
|
|
815
|
+
n = js_number(value)
|
|
816
|
+
if _is_nan(n) or _is_inf(n):
|
|
817
|
+
return n
|
|
818
|
+
# JS `Math.round` rounds half toward +Infinity (`Math.round(-1.5)`
|
|
819
|
+
# is -1, not -2). `floor(n + 0.5)` reproduces that for both signs.
|
|
820
|
+
return float(math.floor(n + 0.5))
|
|
821
|
+
|
|
822
|
+
# -----------------------------------------------------------------
|
|
823
|
+
# Array / String method helpers (#1448 Tier A)
|
|
824
|
+
# -----------------------------------------------------------------
|
|
825
|
+
|
|
826
|
+
def includes(self, recv: Any, elem: Any) -> bool:
|
|
827
|
+
# `Array.prototype.includes(x)` + `String.prototype.includes(sub)`
|
|
828
|
+
# lower to the same `bf.includes(recv, elem)` shape -- see #1448
|
|
829
|
+
# Tier A. Dispatch on `recv`'s Python type: a `list` scans elements
|
|
830
|
+
# with `evaluator._same_value_zero` (#2075) -- SameValueZero
|
|
831
|
+
# membership, matching `Array.prototype.includes`'s semantics (no
|
|
832
|
+
# cross-type coercion, e.g. `[2].includes("2")` is false; NaN
|
|
833
|
+
# matches NaN) and the evaluator's serialized-callback `.includes`
|
|
834
|
+
# arm, so both positions agree. A `dict` (JS has no `.includes` on
|
|
835
|
+
# plain objects) and anything else falls through to the string
|
|
836
|
+
# branch: substring search via `js_string` coercion.
|
|
837
|
+
if isinstance(recv, list):
|
|
838
|
+
return any(_evaluator._same_value_zero(item, elem) for item in recv)
|
|
839
|
+
if isinstance(recv, dict):
|
|
840
|
+
return False
|
|
841
|
+
s = js_string(recv)
|
|
842
|
+
e = js_string(elem)
|
|
843
|
+
return e in s
|
|
844
|
+
|
|
845
|
+
def filter(self, recv: Any, pred: Callable[[Any], Any]) -> list:
|
|
846
|
+
if not isinstance(recv, list):
|
|
847
|
+
return []
|
|
848
|
+
return [x for x in recv if pred(x)]
|
|
849
|
+
|
|
850
|
+
def every(self, recv: Any, pred: Callable[[Any], Any]) -> bool:
|
|
851
|
+
if not isinstance(recv, list):
|
|
852
|
+
return True
|
|
853
|
+
return all(pred(x) for x in recv)
|
|
854
|
+
|
|
855
|
+
def some(self, recv: Any, pred: Callable[[Any], Any]) -> bool:
|
|
856
|
+
if not isinstance(recv, list):
|
|
857
|
+
return False
|
|
858
|
+
return any(pred(x) for x in recv)
|
|
859
|
+
|
|
860
|
+
def find(self, recv: Any, pred: Callable[[Any], Any]) -> Any:
|
|
861
|
+
if not isinstance(recv, list):
|
|
862
|
+
return None
|
|
863
|
+
for item in recv:
|
|
864
|
+
if pred(item):
|
|
865
|
+
return item
|
|
866
|
+
return None
|
|
867
|
+
|
|
868
|
+
def find_index(self, recv: Any, pred: Callable[[Any], Any]) -> int:
|
|
869
|
+
if not isinstance(recv, list):
|
|
870
|
+
return -1
|
|
871
|
+
for i, item in enumerate(recv):
|
|
872
|
+
if pred(item):
|
|
873
|
+
return i
|
|
874
|
+
return -1
|
|
875
|
+
|
|
876
|
+
def find_last(self, recv: Any, pred: Callable[[Any], Any]) -> Any:
|
|
877
|
+
if not isinstance(recv, list):
|
|
878
|
+
return None
|
|
879
|
+
for item in reversed(recv):
|
|
880
|
+
if pred(item):
|
|
881
|
+
return item
|
|
882
|
+
return None
|
|
883
|
+
|
|
884
|
+
def find_last_index(self, recv: Any, pred: Callable[[Any], Any]) -> int:
|
|
885
|
+
if not isinstance(recv, list):
|
|
886
|
+
return -1
|
|
887
|
+
for i in range(len(recv) - 1, -1, -1):
|
|
888
|
+
if pred(recv[i]):
|
|
889
|
+
return i
|
|
890
|
+
return -1
|
|
891
|
+
|
|
892
|
+
def lc(self, s: Any) -> str:
|
|
893
|
+
return js_string(s).lower()
|
|
894
|
+
|
|
895
|
+
def uc(self, s: Any) -> str:
|
|
896
|
+
return js_string(s).upper()
|
|
897
|
+
|
|
898
|
+
def join(self, recv: Any, sep: Optional[str] = None) -> str:
|
|
899
|
+
if not isinstance(recv, list):
|
|
900
|
+
return ""
|
|
901
|
+
sep = "," if sep is None else sep
|
|
902
|
+
return sep.join(js_string(x) for x in recv)
|
|
903
|
+
|
|
904
|
+
def length(self, recv: Any) -> int:
|
|
905
|
+
if isinstance(recv, list):
|
|
906
|
+
return len(recv)
|
|
907
|
+
if isinstance(recv, dict):
|
|
908
|
+
return 0
|
|
909
|
+
return len(js_string(recv))
|
|
910
|
+
|
|
911
|
+
def index_of(self, recv: Any, elem: Any) -> int:
|
|
912
|
+
return _array_index_of(recv, elem, reverse=False)
|
|
913
|
+
|
|
914
|
+
def last_index_of(self, recv: Any, elem: Any) -> int:
|
|
915
|
+
return _array_index_of(recv, elem, reverse=True)
|
|
916
|
+
|
|
917
|
+
def at(self, recv: Any, i: Any) -> Any:
|
|
918
|
+
if not isinstance(recv, list) or i is None:
|
|
919
|
+
return None
|
|
920
|
+
length = len(recv)
|
|
921
|
+
if length == 0:
|
|
922
|
+
return None
|
|
923
|
+
idx = length + i if i < 0 else i
|
|
924
|
+
if idx < 0 or idx >= length:
|
|
925
|
+
return None
|
|
926
|
+
return recv[idx]
|
|
927
|
+
|
|
928
|
+
def concat(self, a: Any, b: Any) -> list:
|
|
929
|
+
out: list = []
|
|
930
|
+
if isinstance(a, list):
|
|
931
|
+
out.extend(a)
|
|
932
|
+
if isinstance(b, list):
|
|
933
|
+
out.extend(b)
|
|
934
|
+
return out
|
|
935
|
+
|
|
936
|
+
def slice(self, recv: Any, start: Any, end: Any) -> list:
|
|
937
|
+
if not isinstance(recv, list):
|
|
938
|
+
return []
|
|
939
|
+
length = len(recv)
|
|
940
|
+
if length == 0:
|
|
941
|
+
return []
|
|
942
|
+
s = start if start is not None else 0
|
|
943
|
+
if s < 0:
|
|
944
|
+
s = length + s
|
|
945
|
+
s = max(s, 0)
|
|
946
|
+
s = min(s, length)
|
|
947
|
+
e = end if end is not None else length
|
|
948
|
+
if e < 0:
|
|
949
|
+
e = length + e
|
|
950
|
+
e = max(e, 0)
|
|
951
|
+
e = min(e, length)
|
|
952
|
+
if s >= e:
|
|
953
|
+
return []
|
|
954
|
+
return list(recv[s:e])
|
|
955
|
+
|
|
956
|
+
def reverse(self, recv: Any) -> list:
|
|
957
|
+
if not isinstance(recv, list):
|
|
958
|
+
return []
|
|
959
|
+
return list(reversed(recv))
|
|
960
|
+
|
|
961
|
+
def flat(self, recv: Any, depth: int = 1) -> list:
|
|
962
|
+
if not isinstance(recv, list):
|
|
963
|
+
return []
|
|
964
|
+
out: list = []
|
|
965
|
+
for el in recv:
|
|
966
|
+
if depth != 0 and isinstance(el, list):
|
|
967
|
+
nxt = depth - 1 if depth > 0 else depth
|
|
968
|
+
out.extend(self.flat(el, nxt))
|
|
969
|
+
else:
|
|
970
|
+
out.append(el)
|
|
971
|
+
return out
|
|
972
|
+
|
|
973
|
+
def flat_dynamic(self, recv: Any, depth: Any) -> list:
|
|
974
|
+
"""`.flat(depth)` where `depth` is itself an arbitrary expression
|
|
975
|
+
(#2094), e.g. a prop -- as opposed to `flat`'s compile-time-literal
|
|
976
|
+
`depth` (whose `-1` is a SENTINEL baked into the template source
|
|
977
|
+
meaning "the source literally said `Infinity`"). A dynamic `depth`
|
|
978
|
+
value that happens to evaluate to `-1` at render time means the
|
|
979
|
+
OPPOSITE in real JS (`[1,[2]].flat(-1)` never recurses -- same as
|
|
980
|
+
`.flat(0)`), so this is deliberately a separate entry point rather
|
|
981
|
+
than a smarter overload of `flat`: coerce `depth` via JS
|
|
982
|
+
`ToIntegerOrInfinity` (truncate toward zero; negative -> 0; NaN /
|
|
983
|
+
non-numeric -> 0; +Infinity or a huge finite value -> flatten fully,
|
|
984
|
+
represented as `flat`'s `-1` sentinel) and delegate to `flat` with
|
|
985
|
+
the now-unambiguous coerced int. Mirrors Go's
|
|
986
|
+
`FlatDynamicDepth`/`coerceFlatDepth`."""
|
|
987
|
+
return self.flat(recv, _coerce_flat_depth(depth))
|
|
988
|
+
|
|
989
|
+
def flat_map(self, recv: Any, key_kind: str, key: str) -> list:
|
|
990
|
+
if not isinstance(recv, list):
|
|
991
|
+
return []
|
|
992
|
+
projected = []
|
|
993
|
+
for el in recv:
|
|
994
|
+
if key_kind == "field":
|
|
995
|
+
projected.append(el.get(key) if isinstance(el, dict) else None)
|
|
996
|
+
else:
|
|
997
|
+
projected.append(el)
|
|
998
|
+
return self.flat(projected, 1)
|
|
999
|
+
|
|
1000
|
+
def flat_map_tuple(self, recv: Any, *specs: tuple) -> list:
|
|
1001
|
+
if not isinstance(recv, list):
|
|
1002
|
+
return []
|
|
1003
|
+
out: list = []
|
|
1004
|
+
for el in recv:
|
|
1005
|
+
for kind, key in specs:
|
|
1006
|
+
if kind == "field":
|
|
1007
|
+
out.append(el.get(key) if isinstance(el, dict) else None)
|
|
1008
|
+
else:
|
|
1009
|
+
out.append(el)
|
|
1010
|
+
return out
|
|
1011
|
+
|
|
1012
|
+
def trim(self, recv: Any) -> str:
|
|
1013
|
+
if recv is None or isinstance(recv, (list, dict)):
|
|
1014
|
+
return ""
|
|
1015
|
+
return js_string(recv).strip()
|
|
1016
|
+
|
|
1017
|
+
def to_fixed(self, value: Any, digits: int = 0) -> str:
|
|
1018
|
+
n = self.number(value)
|
|
1019
|
+
if _is_nan(n):
|
|
1020
|
+
return "NaN"
|
|
1021
|
+
if _is_inf(n):
|
|
1022
|
+
return "-Infinity" if n < 0 else "Infinity"
|
|
1023
|
+
if digits is None or digits < 0:
|
|
1024
|
+
digits = 0
|
|
1025
|
+
digits = int(digits)
|
|
1026
|
+
factor = 10**digits
|
|
1027
|
+
rounded = math.floor(n * factor + 0.5)
|
|
1028
|
+
return f"{rounded / factor:.{digits}f}"
|
|
1029
|
+
|
|
1030
|
+
def split(self, recv: Any, sep: Optional[Any] = None, limit: Optional[int] = None) -> list:
|
|
1031
|
+
s = _scalar_or_empty(recv)
|
|
1032
|
+
if sep is None:
|
|
1033
|
+
parts = [s]
|
|
1034
|
+
elif js_string(sep) == "":
|
|
1035
|
+
parts = list(s)
|
|
1036
|
+
elif s == "":
|
|
1037
|
+
parts = [""]
|
|
1038
|
+
else:
|
|
1039
|
+
parts = s.split(js_string(sep))
|
|
1040
|
+
if limit is not None:
|
|
1041
|
+
n = int(limit)
|
|
1042
|
+
if n == 0:
|
|
1043
|
+
parts = []
|
|
1044
|
+
elif n > 0 and n < len(parts):
|
|
1045
|
+
parts = parts[:n]
|
|
1046
|
+
return parts
|
|
1047
|
+
|
|
1048
|
+
def starts_with(self, recv: Any, prefix: Any, position: Optional[Any] = None) -> bool:
|
|
1049
|
+
s = _scalar_or_empty(recv)
|
|
1050
|
+
p = js_string(prefix)
|
|
1051
|
+
if position is not None:
|
|
1052
|
+
n = max(0, min(int(position), len(s)))
|
|
1053
|
+
s = s[n:]
|
|
1054
|
+
return s.startswith(p)
|
|
1055
|
+
|
|
1056
|
+
def ends_with(self, recv: Any, suffix: Any, end_position: Optional[Any] = None) -> bool:
|
|
1057
|
+
s = _scalar_or_empty(recv)
|
|
1058
|
+
x = js_string(suffix)
|
|
1059
|
+
if end_position is not None:
|
|
1060
|
+
e = max(0, min(int(end_position), len(s)))
|
|
1061
|
+
s = s[:e]
|
|
1062
|
+
if x == "":
|
|
1063
|
+
return True
|
|
1064
|
+
if len(s) < len(x):
|
|
1065
|
+
return False
|
|
1066
|
+
return s[-len(x) :] == x
|
|
1067
|
+
|
|
1068
|
+
def replace(self, recv: Any, pattern: Any, replacement: Any) -> str:
|
|
1069
|
+
s = _scalar_or_empty(recv)
|
|
1070
|
+
o = js_string(pattern)
|
|
1071
|
+
n = js_string(replacement)
|
|
1072
|
+
if o == "":
|
|
1073
|
+
return n + s
|
|
1074
|
+
i = s.find(o)
|
|
1075
|
+
if i < 0:
|
|
1076
|
+
return s
|
|
1077
|
+
return s[:i] + n + s[i + len(o) :]
|
|
1078
|
+
|
|
1079
|
+
def query(self, base: Any, *triples: Any) -> str:
|
|
1080
|
+
"""`queryHref(base, {...})` (#2042) -- build `"$base?k=v&..."` from a
|
|
1081
|
+
flat list of (guard, key, value) triples. A pair is included iff its
|
|
1082
|
+
guard is JS-truthy AND its value is a non-empty string. A value may
|
|
1083
|
+
also be a list, appending one pair per non-empty member. Repeating a
|
|
1084
|
+
key overwrites the value at its first position."""
|
|
1085
|
+
b = _scalar_or_empty(base)
|
|
1086
|
+
pairs: list[tuple[str, str]] = []
|
|
1087
|
+
pos: dict[str, int] = {}
|
|
1088
|
+
items = list(triples)
|
|
1089
|
+
i = 0
|
|
1090
|
+
while i + 2 < len(items):
|
|
1091
|
+
guard, key, val = items[i], items[i + 1], items[i + 2]
|
|
1092
|
+
i += 3
|
|
1093
|
+
if not self.truthy(guard):
|
|
1094
|
+
continue
|
|
1095
|
+
key_s = _scalar_or_empty(key)
|
|
1096
|
+
if isinstance(val, list):
|
|
1097
|
+
for m in val:
|
|
1098
|
+
s = _scalar_or_empty(m)
|
|
1099
|
+
if s == "":
|
|
1100
|
+
continue
|
|
1101
|
+
pairs.append((key_s, s))
|
|
1102
|
+
continue
|
|
1103
|
+
val_s = _scalar_or_empty(val)
|
|
1104
|
+
if val_s == "":
|
|
1105
|
+
continue
|
|
1106
|
+
if key_s in pos:
|
|
1107
|
+
pairs[pos[key_s]] = (key_s, val_s)
|
|
1108
|
+
else:
|
|
1109
|
+
pos[key_s] = len(pairs)
|
|
1110
|
+
pairs.append((key_s, val_s))
|
|
1111
|
+
if not pairs:
|
|
1112
|
+
return b
|
|
1113
|
+
return b + "?" + "&".join(f"{_form_escape(k)}={_form_escape(v)}" for k, v in pairs)
|
|
1114
|
+
|
|
1115
|
+
def repeat(self, recv: Any, count: Any) -> str:
|
|
1116
|
+
s = _scalar_or_empty(recv)
|
|
1117
|
+
n = int(count) if count is not None else 0
|
|
1118
|
+
return s * n if n > 0 else ""
|
|
1119
|
+
|
|
1120
|
+
def pad_start(self, recv: Any, target: Any, pad: Optional[Any] = None) -> str:
|
|
1121
|
+
return _pad(_scalar_or_empty(recv), target, pad, at_start=True)
|
|
1122
|
+
|
|
1123
|
+
def pad_end(self, recv: Any, target: Any, pad: Optional[Any] = None) -> str:
|
|
1124
|
+
return _pad(_scalar_or_empty(recv), target, pad, at_start=False)
|
|
1125
|
+
|
|
1126
|
+
# -----------------------------------------------------------------
|
|
1127
|
+
# Array.prototype.sort(cmp) / toSorted(cmp) -- structured comparator
|
|
1128
|
+
# dispatch (#1448 Tier B). `opts['keys']` is a priority-ordered list of
|
|
1129
|
+
# per-key dicts: key_kind ('self'|'field'), key, compare_type
|
|
1130
|
+
# ('numeric'|'string'|'auto'), direction ('asc'|'desc').
|
|
1131
|
+
# -----------------------------------------------------------------
|
|
1132
|
+
|
|
1133
|
+
def sort(self, recv: Any, opts: Optional[dict] = None) -> list:
|
|
1134
|
+
if not isinstance(recv, list):
|
|
1135
|
+
return []
|
|
1136
|
+
opts = opts or {}
|
|
1137
|
+
spec = [
|
|
1138
|
+
{
|
|
1139
|
+
"key_kind": k.get("key_kind", "self"),
|
|
1140
|
+
"key": k.get("key", ""),
|
|
1141
|
+
"compare_type": k.get("compare_type", "numeric"),
|
|
1142
|
+
"direction": k.get("direction", "asc"),
|
|
1143
|
+
}
|
|
1144
|
+
for k in (opts.get("keys") or [])
|
|
1145
|
+
]
|
|
1146
|
+
if not spec:
|
|
1147
|
+
return list(recv)
|
|
1148
|
+
|
|
1149
|
+
keyed = []
|
|
1150
|
+
for item in recv:
|
|
1151
|
+
ks = []
|
|
1152
|
+
for s in spec:
|
|
1153
|
+
if s["key_kind"] == "field" and isinstance(item, dict):
|
|
1154
|
+
ks.append(item.get(s["key"]))
|
|
1155
|
+
else:
|
|
1156
|
+
ks.append(item)
|
|
1157
|
+
keyed.append((ks, item))
|
|
1158
|
+
|
|
1159
|
+
def cmp(a: tuple, b: tuple) -> int:
|
|
1160
|
+
for i, s in enumerate(spec):
|
|
1161
|
+
c = _compare_sort_key(a[0][i], b[0][i], s["compare_type"])
|
|
1162
|
+
if c == 0:
|
|
1163
|
+
continue
|
|
1164
|
+
return -c if s["direction"] == "desc" else c
|
|
1165
|
+
return 0
|
|
1166
|
+
|
|
1167
|
+
ordered = sorted(keyed, key=functools.cmp_to_key(cmp))
|
|
1168
|
+
return [item for _, item in ordered]
|
|
1169
|
+
|
|
1170
|
+
def reduce(self, recv: Any, opts: Optional[dict] = None) -> Any:
|
|
1171
|
+
"""Fold via the arithmetic-fold catalogue (#1448 Tier C). Mirrors
|
|
1172
|
+
`Array.prototype.reduce` / `.reduceRight` for the shapes `(acc, x) =>
|
|
1173
|
+
acc <op> x` / `(acc, x) => acc <op> x.field`."""
|
|
1174
|
+
opts = opts or {}
|
|
1175
|
+
op = opts.get("op", "+")
|
|
1176
|
+
key_kind = opts.get("key_kind", "self")
|
|
1177
|
+
key = opts.get("key", "")
|
|
1178
|
+
rtype = opts.get("type", "numeric")
|
|
1179
|
+
direction = opts.get("direction", "left")
|
|
1180
|
+
|
|
1181
|
+
items = list(recv) if isinstance(recv, list) else []
|
|
1182
|
+
if direction == "right":
|
|
1183
|
+
items = list(reversed(items))
|
|
1184
|
+
|
|
1185
|
+
def project(item: Any) -> Any:
|
|
1186
|
+
return item.get(key) if key_kind == "field" and isinstance(item, dict) else item
|
|
1187
|
+
|
|
1188
|
+
if rtype == "string":
|
|
1189
|
+
acc = opts.get("init")
|
|
1190
|
+
acc = "" if acc is None else acc
|
|
1191
|
+
for item in items:
|
|
1192
|
+
acc += self.string(project(item))
|
|
1193
|
+
return acc
|
|
1194
|
+
|
|
1195
|
+
acc = opts.get("init")
|
|
1196
|
+
acc = 0 if acc is None else acc
|
|
1197
|
+
for item in items:
|
|
1198
|
+
val = project(item)
|
|
1199
|
+
n = _numeric_value(val) if val is not None and _is_numeric_like(val) else 0
|
|
1200
|
+
acc = acc * n if op == "*" else acc + n
|
|
1201
|
+
return acc
|
|
1202
|
+
|
|
1203
|
+
# -----------------------------------------------------------------
|
|
1204
|
+
# JSX intrinsic-element spread (#1407)
|
|
1205
|
+
# -----------------------------------------------------------------
|
|
1206
|
+
|
|
1207
|
+
def spread_attrs(self, bag: Any) -> Any:
|
|
1208
|
+
if not isinstance(bag, dict):
|
|
1209
|
+
return ""
|
|
1210
|
+
parts = []
|
|
1211
|
+
for key in sorted(bag.keys()):
|
|
1212
|
+
# Event handlers: skip when key starts `on` and the third
|
|
1213
|
+
# character is its own uppercase form.
|
|
1214
|
+
if len(key) > 2 and key[:2] == "on":
|
|
1215
|
+
c = key[2]
|
|
1216
|
+
if c.upper() == c:
|
|
1217
|
+
continue
|
|
1218
|
+
if key == "children":
|
|
1219
|
+
continue
|
|
1220
|
+
val = bag.get(key)
|
|
1221
|
+
if val is None:
|
|
1222
|
+
continue
|
|
1223
|
+
if isinstance(val, bool):
|
|
1224
|
+
if val:
|
|
1225
|
+
parts.append(_to_attr_name(key))
|
|
1226
|
+
continue
|
|
1227
|
+
if key == "style":
|
|
1228
|
+
css = _style_to_css(val)
|
|
1229
|
+
if not css:
|
|
1230
|
+
continue
|
|
1231
|
+
parts.append(f'style="{_html_escape(css)}"')
|
|
1232
|
+
continue
|
|
1233
|
+
name = _to_attr_name(key)
|
|
1234
|
+
parts.append(f'{name}="{_html_escape(val)}"')
|
|
1235
|
+
if not parts:
|
|
1236
|
+
return ""
|
|
1237
|
+
return self.backend.mark_raw(" ".join(parts))
|
|
1238
|
+
|
|
1239
|
+
# -----------------------------------------------------------------
|
|
1240
|
+
# Loop-destructure object-rest residual object (#2087 Phase B)
|
|
1241
|
+
# -----------------------------------------------------------------
|
|
1242
|
+
|
|
1243
|
+
def omit(self, recv: Any, keys: Any) -> dict:
|
|
1244
|
+
"""Shallow copy of `recv` with `keys` removed -- the TRUE residual
|
|
1245
|
+
dict for an object-rest `.map()` callback binding
|
|
1246
|
+
(`{ id, title, ...rest }` -> `{% set rest = bf.omit(item, ['id',
|
|
1247
|
+
'title']) %}`), so `rest.flag` member reads and
|
|
1248
|
+
`bf.spread_attrs(rest)` (forwarding `{...rest}` onto an element)
|
|
1249
|
+
both see exactly the sibling keys NOT already destructured -- never
|
|
1250
|
+
the whole item. Mirrors `spread_attrs`'s defensive non-dict
|
|
1251
|
+
handling: a non-dict `recv` yields `{}` rather than raising.
|
|
1252
|
+
"""
|
|
1253
|
+
if not isinstance(recv, dict):
|
|
1254
|
+
return {}
|
|
1255
|
+
exclude = set(keys) if keys else set()
|
|
1256
|
+
return {k: v for k, v in recv.items() if k not in exclude}
|
|
1257
|
+
|
|
1258
|
+
# -----------------------------------------------------------------
|
|
1259
|
+
# Evaluator-driven sort / reduce / higher-order predicates (#2018):
|
|
1260
|
+
# the comparator / reducer / predicate body rides as a
|
|
1261
|
+
# serialized-ParsedExpr JSON string and is evaluated per element,
|
|
1262
|
+
# delegating to the shared `evaluator` module.
|
|
1263
|
+
# -----------------------------------------------------------------
|
|
1264
|
+
|
|
1265
|
+
def sort_eval(
|
|
1266
|
+
self, recv: Any, cmp_json: str, param_a: str, param_b: str, base_env: Optional[dict] = None
|
|
1267
|
+
) -> list:
|
|
1268
|
+
return _evaluator.sort_by_json(recv, cmp_json, param_a, param_b, base_env or {})
|
|
1269
|
+
|
|
1270
|
+
def reduce_eval(
|
|
1271
|
+
self,
|
|
1272
|
+
recv: Any,
|
|
1273
|
+
body_json: str,
|
|
1274
|
+
acc_name: str,
|
|
1275
|
+
item_name: str,
|
|
1276
|
+
init: Any,
|
|
1277
|
+
direction: str = "left",
|
|
1278
|
+
base_env: Optional[dict] = None,
|
|
1279
|
+
) -> Any:
|
|
1280
|
+
return _evaluator.fold_json(recv, body_json, acc_name, item_name, init, direction, base_env or {})
|
|
1281
|
+
|
|
1282
|
+
def filter_eval(self, recv: Any, pred_json: str, param: str, base_env: Optional[dict] = None) -> list:
|
|
1283
|
+
return _evaluator.filter_json(recv, pred_json, param, base_env or {})
|
|
1284
|
+
|
|
1285
|
+
def every_eval(self, recv: Any, pred_json: str, param: str, base_env: Optional[dict] = None) -> bool:
|
|
1286
|
+
return _evaluator.every_json(recv, pred_json, param, base_env or {})
|
|
1287
|
+
|
|
1288
|
+
def some_eval(self, recv: Any, pred_json: str, param: str, base_env: Optional[dict] = None) -> bool:
|
|
1289
|
+
return _evaluator.some_json(recv, pred_json, param, base_env or {})
|
|
1290
|
+
|
|
1291
|
+
def find_eval(
|
|
1292
|
+
self, recv: Any, pred_json: str, param: str, forward: bool = True, base_env: Optional[dict] = None
|
|
1293
|
+
) -> Any:
|
|
1294
|
+
return _evaluator.find_json(recv, pred_json, param, forward, base_env or {})
|
|
1295
|
+
|
|
1296
|
+
def find_index_eval(
|
|
1297
|
+
self, recv: Any, pred_json: str, param: str, forward: bool = True, base_env: Optional[dict] = None
|
|
1298
|
+
) -> int:
|
|
1299
|
+
return _evaluator.find_index_json(recv, pred_json, param, forward, base_env or {})
|
|
1300
|
+
|
|
1301
|
+
def flat_map_eval(self, recv: Any, proj_json: str, param: str, base_env: Optional[dict] = None) -> list:
|
|
1302
|
+
return _evaluator.flat_map_json(recv, proj_json, param, base_env or {})
|
|
1303
|
+
|
|
1304
|
+
def map_eval(self, recv: Any, proj_json: str, param: str, base_env: Optional[dict] = None) -> list:
|
|
1305
|
+
return _evaluator.map_json(recv, proj_json, param, base_env or {})
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
def _array_index_of(recv: Any, elem: Any, reverse: bool) -> int:
|
|
1309
|
+
if not isinstance(recv, list):
|
|
1310
|
+
return -1
|
|
1311
|
+
idxs = range(len(recv) - 1, -1, -1) if reverse else range(len(recv))
|
|
1312
|
+
for i in idxs:
|
|
1313
|
+
item = recv[i]
|
|
1314
|
+
if item is None:
|
|
1315
|
+
if elem is None:
|
|
1316
|
+
return i
|
|
1317
|
+
continue
|
|
1318
|
+
if elem is not None and item == elem:
|
|
1319
|
+
return i
|
|
1320
|
+
return -1
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def _pad(s: str, target: Any, pad: Optional[Any], at_start: bool) -> str:
|
|
1324
|
+
p = " " if pad is None else js_string(pad)
|
|
1325
|
+
if p == "":
|
|
1326
|
+
return s
|
|
1327
|
+
length = len(s)
|
|
1328
|
+
t = int(target) if target is not None else 0
|
|
1329
|
+
if length >= t:
|
|
1330
|
+
return s
|
|
1331
|
+
need = t - length
|
|
1332
|
+
reps = need // len(p) + 1
|
|
1333
|
+
fill = (p * reps)[:need]
|
|
1334
|
+
return fill + s if at_start else s + fill
|