@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.
Files changed (88) hide show
  1. package/README.md +66 -0
  2. package/dist/adapter/analysis/component-tree.d.ts +26 -0
  3. package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
  4. package/dist/adapter/boolean-result.d.ts +84 -0
  5. package/dist/adapter/boolean-result.d.ts.map +1 -0
  6. package/dist/adapter/emit-context.d.ts +106 -0
  7. package/dist/adapter/emit-context.d.ts.map +1 -0
  8. package/dist/adapter/expr/array-method.d.ts +75 -0
  9. package/dist/adapter/expr/array-method.d.ts.map +1 -0
  10. package/dist/adapter/expr/emitters.d.ts +143 -0
  11. package/dist/adapter/expr/emitters.d.ts.map +1 -0
  12. package/dist/adapter/expr/operand.d.ts +25 -0
  13. package/dist/adapter/expr/operand.d.ts.map +1 -0
  14. package/dist/adapter/index.d.ts +6 -0
  15. package/dist/adapter/index.d.ts.map +1 -0
  16. package/dist/adapter/index.js +189097 -0
  17. package/dist/adapter/jinja-adapter.d.ts +394 -0
  18. package/dist/adapter/jinja-adapter.d.ts.map +1 -0
  19. package/dist/adapter/lib/constants.d.ts +21 -0
  20. package/dist/adapter/lib/constants.d.ts.map +1 -0
  21. package/dist/adapter/lib/ir-scope.d.ts +50 -0
  22. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  23. package/dist/adapter/lib/jinja-naming.d.ts +77 -0
  24. package/dist/adapter/lib/jinja-naming.d.ts.map +1 -0
  25. package/dist/adapter/lib/types.d.ts +27 -0
  26. package/dist/adapter/lib/types.d.ts.map +1 -0
  27. package/dist/adapter/memo/seed.d.ts +81 -0
  28. package/dist/adapter/memo/seed.d.ts.map +1 -0
  29. package/dist/adapter/props/prop-classes.d.ts +33 -0
  30. package/dist/adapter/props/prop-classes.d.ts.map +1 -0
  31. package/dist/adapter/spread/spread-codegen.d.ts +61 -0
  32. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  33. package/dist/adapter/value/parsed-literal.d.ts +28 -0
  34. package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
  35. package/dist/build.d.ts +28 -0
  36. package/dist/build.d.ts.map +1 -0
  37. package/dist/build.js +189117 -0
  38. package/dist/conformance-pins.d.ts +12 -0
  39. package/dist/conformance-pins.d.ts.map +1 -0
  40. package/dist/index.d.ts +9 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +189118 -0
  43. package/package.json +66 -0
  44. package/python/VERSION +1 -0
  45. package/python/barefootjs/__init__.py +57 -0
  46. package/python/barefootjs/backend_jinja.py +125 -0
  47. package/python/barefootjs/evaluator.py +787 -0
  48. package/python/barefootjs/runtime.py +1334 -0
  49. package/python/barefootjs/search_params.py +89 -0
  50. package/python/pyproject.toml +32 -0
  51. package/python/tests/__init__.py +0 -0
  52. package/python/tests/test_eval_vectors.py +88 -0
  53. package/python/tests/test_evaluator.py +406 -0
  54. package/python/tests/test_helper_vectors.py +288 -0
  55. package/python/tests/test_omit.py +62 -0
  56. package/python/tests/test_props_attr.py +54 -0
  57. package/python/tests/test_query.py +41 -0
  58. package/python/tests/test_render.py +102 -0
  59. package/python/tests/test_render_child.py +96 -0
  60. package/python/tests/test_search_params.py +50 -0
  61. package/python/tests/test_spread_attrs.py +86 -0
  62. package/python/tests/test_template_primitives.py +347 -0
  63. package/python/tests/vector-divergences.json +42 -0
  64. package/src/__tests__/jinja-adapter-unit.test.ts +390 -0
  65. package/src/__tests__/jinja-adapter.test.ts +53 -0
  66. package/src/__tests__/jinja-counter.test.ts +62 -0
  67. package/src/__tests__/jinja-query-href.test.ts +99 -0
  68. package/src/__tests__/jinja-spread-attrs.test.ts +225 -0
  69. package/src/adapter/analysis/component-tree.ts +119 -0
  70. package/src/adapter/boolean-result.ts +176 -0
  71. package/src/adapter/emit-context.ts +118 -0
  72. package/src/adapter/expr/array-method.ts +346 -0
  73. package/src/adapter/expr/emitters.ts +608 -0
  74. package/src/adapter/expr/operand.ts +35 -0
  75. package/src/adapter/index.ts +6 -0
  76. package/src/adapter/jinja-adapter.ts +1747 -0
  77. package/src/adapter/lib/constants.ts +33 -0
  78. package/src/adapter/lib/ir-scope.ts +95 -0
  79. package/src/adapter/lib/jinja-naming.ts +114 -0
  80. package/src/adapter/lib/types.ts +30 -0
  81. package/src/adapter/memo/seed.ts +132 -0
  82. package/src/adapter/props/prop-classes.ts +65 -0
  83. package/src/adapter/spread/spread-codegen.ts +166 -0
  84. package/src/adapter/value/parsed-literal.ts +76 -0
  85. package/src/build.ts +37 -0
  86. package/src/conformance-pins.ts +101 -0
  87. package/src/index.ts +9 -0
  88. 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 `&#34;` / `&#39;` for quotes rather than the named
353
+ entities, so SSR output stays identical across adapters)."""
354
+ s = js_string(value)
355
+ s = s.replace("&", "&amp;")
356
+ s = s.replace("<", "&lt;")
357
+ s = s.replace(">", "&gt;")
358
+ s = s.replace('"', "&#34;")
359
+ s = s.replace("'", "&#39;")
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), "&quot;")}"')
521
+ if mount:
522
+ parts.append(f'bf-m="{js_string(mount).replace(chr(34), "&quot;")}"')
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("&", "&amp;").replace('"', "&quot;")
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