barefoot_js 0.18.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.
- checksums.yaml +7 -0
- data/lib/barefoot_js/backend/erb.rb +123 -0
- data/lib/barefoot_js/dev_reload.rb +159 -0
- data/lib/barefoot_js/evaluator.rb +714 -0
- data/lib/barefoot_js/search_params.rb +63 -0
- data/lib/barefoot_js.rb +1155 -0
- metadata +51 -0
data/lib/barefoot_js.rb
ADDED
|
@@ -0,0 +1,1155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
require 'barefoot_js/evaluator'
|
|
5
|
+
require 'barefoot_js/search_params'
|
|
6
|
+
|
|
7
|
+
# BarefootJS - engine- and framework-agnostic server runtime for BarefootJS
|
|
8
|
+
# marked templates (ERB port).
|
|
9
|
+
#
|
|
10
|
+
# Ruby port of BarefootJS.pm (@barefootjs/perl), keeping method names 1:1
|
|
11
|
+
# with the Perl runtime (already snake_case) so the ERB compile-time
|
|
12
|
+
# adapter and this runtime share one naming contract. This module is
|
|
13
|
+
# deliberately template-engine- and framework-agnostic: every operation
|
|
14
|
+
# that depends on *how* a template is rendered -- JSON marshalling,
|
|
15
|
+
# raw-string marking, JSX-children materialisation, and named-template
|
|
16
|
+
# rendering -- is delegated to a pluggable `backend` (see
|
|
17
|
+
# BarefootJS::Backend::Erb for the ERB reference implementation), which
|
|
18
|
+
# is the only component that knows about a specific template engine.
|
|
19
|
+
#
|
|
20
|
+
# Value domain: JSON-shaped Ruby data with SYMBOL hash keys throughout
|
|
21
|
+
# (props, env hashes, array-of-hash records). Ruby's real Integer/Float/
|
|
22
|
+
# String/true/false/nil type system maps onto the JS value domain far more
|
|
23
|
+
# directly than Perl's blurred numeric-string scalars, so this port needs
|
|
24
|
+
# none of the `JSON::PP::Boolean` sentinel-detection machinery the Perl
|
|
25
|
+
# runtime carries -- a Ruby `true`/`false` IS a boolean, distinguishable
|
|
26
|
+
# from `0`/`1` for free.
|
|
27
|
+
module BarefootJS
|
|
28
|
+
# Context is the `bf` object every compiled `.erb` template receives as a
|
|
29
|
+
# local. One instance per render (root or child); `render_child` /
|
|
30
|
+
# `register_components_from_manifest` construct a fresh child instance
|
|
31
|
+
# per nested render, chaining scope/slot identity off the caller.
|
|
32
|
+
class Context
|
|
33
|
+
# ---------------------------------------------------------------------
|
|
34
|
+
# Minimal get/set accessors (Perl-style: no-arg reads, one-arg writes,
|
|
35
|
+
# matching BarefootJS.pm's hand-rolled accessor base so the exact same
|
|
36
|
+
# calling convention -- `bf._scope_id`, `bf._scope_id('root_s0')` --
|
|
37
|
+
# ports across languages unchanged).
|
|
38
|
+
# ---------------------------------------------------------------------
|
|
39
|
+
def self.bf_accessor(name, default: nil)
|
|
40
|
+
ivar = :"@#{name.to_s.sub(/\A_/, '')}"
|
|
41
|
+
define_method(name) do |*args|
|
|
42
|
+
if args.empty?
|
|
43
|
+
unless instance_variable_defined?(ivar)
|
|
44
|
+
instance_variable_set(ivar, default ? default.call : nil)
|
|
45
|
+
end
|
|
46
|
+
instance_variable_get(ivar)
|
|
47
|
+
else
|
|
48
|
+
instance_variable_set(ivar, args.first)
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
private_class_method :bf_accessor
|
|
54
|
+
|
|
55
|
+
bf_accessor :backend
|
|
56
|
+
bf_accessor :_scripts, default: -> { [] }
|
|
57
|
+
bf_accessor :_script_seen, default: -> { {} }
|
|
58
|
+
bf_accessor :_scope_id
|
|
59
|
+
bf_accessor :_is_child, default: -> { false }
|
|
60
|
+
bf_accessor :_bf_parent
|
|
61
|
+
bf_accessor :_bf_mount
|
|
62
|
+
bf_accessor :_props
|
|
63
|
+
bf_accessor :_data_key
|
|
64
|
+
bf_accessor :_child_renderers, default: -> { {} }
|
|
65
|
+
|
|
66
|
+
def initialize(backend = nil)
|
|
67
|
+
@backend = backend
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# search_params(query = '') -- request-scoped reader for the reactive
|
|
71
|
+
# searchParams() environment signal (router v0.5, #1922), built from a
|
|
72
|
+
# raw query string. The compiled template reads it via
|
|
73
|
+
# `v[:searchParams].get('key')`.
|
|
74
|
+
def search_params(query = '')
|
|
75
|
+
SearchParams.new(query)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# -----------------------------------------------------------------
|
|
79
|
+
# Scope & Props
|
|
80
|
+
# -----------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
# bf-s is the addressable scope id only (#1249).
|
|
83
|
+
def scope_attr
|
|
84
|
+
_scope_id || ''
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Emits `bf-h="<host>" bf-m="<slot>" bf-r=""` conditionally. See
|
|
88
|
+
# spec/compiler.md "Slot identity".
|
|
89
|
+
def hydration_attrs
|
|
90
|
+
parts = []
|
|
91
|
+
host = _bf_parent
|
|
92
|
+
mount = _bf_mount
|
|
93
|
+
parts << %(bf-h="#{host.gsub('"', '"')}") if host && !host.empty?
|
|
94
|
+
parts << %(bf-m="#{mount.gsub('"', '"')}") if mount && !mount.empty?
|
|
95
|
+
parts << 'bf-r=""' unless _is_child
|
|
96
|
+
parts.join(' ')
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Emits ` data-key="<key>"` for a keyed loop item, else ''. See
|
|
100
|
+
# BarefootJS.pm's docstring on the client reconciliation contract.
|
|
101
|
+
def data_key_attr
|
|
102
|
+
k = _data_key
|
|
103
|
+
return '' if k.nil?
|
|
104
|
+
|
|
105
|
+
escaped = k.to_s.gsub('&', '&').gsub('"', '"')
|
|
106
|
+
%( data-key="#{escaped}")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def props_attr
|
|
110
|
+
props = _props
|
|
111
|
+
return '' unless props && !props.empty?
|
|
112
|
+
|
|
113
|
+
# The JSON must be attribute-escaped: a raw `'` inside a string value
|
|
114
|
+
# (e.g. a blog paragraph) terminates the single-quoted attribute and
|
|
115
|
+
# truncates the hydration payload. The browser entity-decodes the
|
|
116
|
+
# attribute value, so the client's JSON.parse sees the original text.
|
|
117
|
+
json = html_escape(backend.encode_json(props))
|
|
118
|
+
%( bf-p='#{json}')
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# -----------------------------------------------------------------
|
|
122
|
+
# Context (SSR mirror of the client `provideContext` / `useContext`)
|
|
123
|
+
# -----------------------------------------------------------------
|
|
124
|
+
#
|
|
125
|
+
# A `<Ctx.Provider value>` seeds a value that descendant
|
|
126
|
+
# `useContext(Ctx)` consumers read during the same render. The stacks
|
|
127
|
+
# live at module scope (like the Perl `my %CONTEXT_STACKS`), not on
|
|
128
|
+
# `self`, because a parent template and the child templates it renders
|
|
129
|
+
# via `render_child` are separate `Context` instances. SSR rendering is
|
|
130
|
+
# synchronous, and push/pop are perfectly balanced, so each provider
|
|
131
|
+
# subtree's stack always unwinds to empty by the time its render
|
|
132
|
+
# finishes -- keeping concurrent root renders isolated.
|
|
133
|
+
CONTEXT_STACKS = Hash.new { |h, k| h[k] = [] }
|
|
134
|
+
private_constant :CONTEXT_STACKS
|
|
135
|
+
|
|
136
|
+
def provide_context(name, value)
|
|
137
|
+
CONTEXT_STACKS[name].push(value)
|
|
138
|
+
''
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def revoke_context(name)
|
|
142
|
+
stack = CONTEXT_STACKS[name]
|
|
143
|
+
stack.pop unless stack.empty?
|
|
144
|
+
''
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def use_context(name, default = nil)
|
|
148
|
+
stack = CONTEXT_STACKS[name]
|
|
149
|
+
stack.empty? ? default : stack.last
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# -----------------------------------------------------------------
|
|
153
|
+
# Comment Markers
|
|
154
|
+
# -----------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def comment(text)
|
|
157
|
+
"<!--bf-#{text}-->"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Map a JS-boolean-shaped value to the JS `String(bool)` form. See
|
|
161
|
+
# BarefootJS.pm's `bool_str` docstring for the boolean-only contract --
|
|
162
|
+
# callers must have already classified the expression as boolean-
|
|
163
|
+
# result; non-boolean attribute bindings never reach this helper.
|
|
164
|
+
def bool_str(value)
|
|
165
|
+
value ? 'true' : 'false'
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def text_start(slot_id)
|
|
169
|
+
"<!--bf:#{slot_id}-->"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def text_end
|
|
173
|
+
'<!--/-->'
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# See spec/compiler.md "Slot identity" for the comment-scope wire format.
|
|
177
|
+
def scope_comment
|
|
178
|
+
scope_id = _scope_id || ''
|
|
179
|
+
host_segment = ''
|
|
180
|
+
host = _bf_parent
|
|
181
|
+
mount = _bf_mount
|
|
182
|
+
host_segment = "|h=#{host}|m=#{mount || ''}" if host && !host.empty?
|
|
183
|
+
props_json = ''
|
|
184
|
+
props_json = "|#{backend.encode_json(_props)}" if _props && !_props.empty?
|
|
185
|
+
"<!--bf-scope:#{scope_id}#{host_segment}#{props_json}-->"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# -----------------------------------------------------------------
|
|
189
|
+
# Script Registration
|
|
190
|
+
# -----------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def register_script(path)
|
|
193
|
+
return if _script_seen.key?(path)
|
|
194
|
+
|
|
195
|
+
_script_seen[path] = true
|
|
196
|
+
_scripts.push(path)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def scripts
|
|
200
|
+
_scripts.map { |path| %(<script type="module" src="#{path}"></script>) }.join("\n")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# -----------------------------------------------------------------
|
|
204
|
+
# Child Component Rendering
|
|
205
|
+
# -----------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
# Register a renderer for `render_child(name, ...)`. `renderer` is
|
|
208
|
+
# called as `renderer.call(props_hash, invoking_bf)` -- the invoking
|
|
209
|
+
# `Context` matters because a renderer registered on the root may be
|
|
210
|
+
# called from a nested child render, and the grandchild's scope / slot
|
|
211
|
+
# identity must chain off the CALLER's scope id, not the registrant's
|
|
212
|
+
# (#1897).
|
|
213
|
+
def register_child_renderer(name, renderer)
|
|
214
|
+
_child_renderers[name] = renderer
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def render_child(name, *args)
|
|
218
|
+
renderer = _child_renderers[name]
|
|
219
|
+
raise "No renderer registered for child component '#{name}'" unless renderer
|
|
220
|
+
|
|
221
|
+
# Accept both `render_child(name, k: v, ...)` (kwargs collapse into a
|
|
222
|
+
# trailing Hash under Ruby's argument-splat rules) and the explicit
|
|
223
|
+
# single-Hash form `render_child(name, { k: v })`.
|
|
224
|
+
props = (args.length == 1 && args[0].is_a?(Hash)) ? args[0].dup : Hash[*args]
|
|
225
|
+
# JSX children come in via the ERB backend's content-capture buffer
|
|
226
|
+
# slice; materialize it through the backend so the child renderer sees
|
|
227
|
+
# `props[:children]` as already-rendered HTML. Guard on `key?` so a
|
|
228
|
+
# childless invocation doesn't gain a spurious `children: nil` key.
|
|
229
|
+
props[:children] = backend.materialize(props[:children]) if props.key?(:children)
|
|
230
|
+
renderer.call(props, self)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# -----------------------------------------------------------------
|
|
234
|
+
# Bulk registration from build manifest
|
|
235
|
+
# -----------------------------------------------------------------
|
|
236
|
+
#
|
|
237
|
+
# `bf build` emits dist/templates/manifest.json describing every
|
|
238
|
+
# component the page might invoke. This walks that manifest and
|
|
239
|
+
# registers one child renderer per UI registry entry (`ui/<name>/index`
|
|
240
|
+
# -> slot key `<name>`), seeding each child's template vars from the
|
|
241
|
+
# manifest's statically-derived `ssrDefaults` (prop destructure
|
|
242
|
+
# defaults + signal/memo initial values). `signal_init[slot_key]` is an
|
|
243
|
+
# opt-in override for defaults the static extractor can't see through.
|
|
244
|
+
def register_components_from_manifest(manifest, signal_init: {})
|
|
245
|
+
parent_scope = _scope_id
|
|
246
|
+
parent = self
|
|
247
|
+
|
|
248
|
+
manifest.each do |entry_name, entry|
|
|
249
|
+
next if entry_name.to_s == '__barefoot__'
|
|
250
|
+
|
|
251
|
+
m = entry_name.to_s.match(%r{\Aui/([^/]+)/index\z})
|
|
252
|
+
next unless m
|
|
253
|
+
|
|
254
|
+
slot_key = m[1]
|
|
255
|
+
marked = entry[:markedTemplate] || ''
|
|
256
|
+
next if marked.empty?
|
|
257
|
+
|
|
258
|
+
template_name = marked.sub(%r{\Atemplates/}, '').sub(/\.erb\z/, '')
|
|
259
|
+
sig_init = signal_init[slot_key]
|
|
260
|
+
manifest_defaults = entry[:ssrDefaults]
|
|
261
|
+
|
|
262
|
+
register_child_renderer(slot_key, lambda do |props, caller|
|
|
263
|
+
host = caller || parent
|
|
264
|
+
host_scope = host._scope_id || parent_scope
|
|
265
|
+
child_bf = self.class.new(parent.backend)
|
|
266
|
+
slot_id = props.delete(:_bf_slot)
|
|
267
|
+
data_key = props.delete(:key)
|
|
268
|
+
child_bf._data_key(data_key) unless data_key.nil?
|
|
269
|
+
child_bf._scope_id(
|
|
270
|
+
slot_id ? "#{host_scope}_#{slot_id}" : "#{template_name}_#{rand.to_s[2, 6]}",
|
|
271
|
+
)
|
|
272
|
+
child_bf._is_child(true)
|
|
273
|
+
if slot_id
|
|
274
|
+
child_bf._bf_parent(host_scope)
|
|
275
|
+
child_bf._bf_mount(slot_id)
|
|
276
|
+
end
|
|
277
|
+
# Share the root registry so the child's own template can render
|
|
278
|
+
# further imported components (#1897).
|
|
279
|
+
child_bf._child_renderers(parent._child_renderers)
|
|
280
|
+
child_bf._scripts(parent._scripts)
|
|
281
|
+
child_bf._script_seen(parent._script_seen)
|
|
282
|
+
|
|
283
|
+
extra =
|
|
284
|
+
if sig_init
|
|
285
|
+
sig_init.call(props)
|
|
286
|
+
elsif manifest_defaults
|
|
287
|
+
self.class.send(:derive_vars_from_defaults, manifest_defaults, props)
|
|
288
|
+
else
|
|
289
|
+
{}
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
html = parent.backend.render_named(template_name, child_bf, props.merge(extra))
|
|
293
|
+
html.chomp
|
|
294
|
+
end)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Derive template-var kvs from a manifest entry's `ssrDefaults` section.
|
|
299
|
+
# Each entry shape: `{ value:, propName:, isRestProps: }`. For
|
|
300
|
+
# `isRestProps`, the rest bag passes through unchanged (or the static
|
|
301
|
+
# `{}` if the caller didn't supply one). For ordinary entries the
|
|
302
|
+
# caller's `props[propName]` wins when present, otherwise the static
|
|
303
|
+
# `value` does. `propName`-less entries (signal / memo locals) always
|
|
304
|
+
# use the static value.
|
|
305
|
+
#
|
|
306
|
+
# Public (not `private_class_method`): `register_components_from_manifest`
|
|
307
|
+
# above uses it for the `ui/*` registry path, but a page that composes
|
|
308
|
+
# *flat* (non-`ui/*`) components by hand -- e.g. the blog islands in the
|
|
309
|
+
# Sinatra/xslate/Mojolicious integrations -- needs the exact same
|
|
310
|
+
# ssrDefaults-seeding logic for its own manual `register_child_renderer`
|
|
311
|
+
# calls. Mirrors the Perl runtime's `BarefootJS::_derive_stash_from_defaults`,
|
|
312
|
+
# which is likewise callable from integration code (Perl has no enforced
|
|
313
|
+
# privacy; the leading underscore is convention only) -- see
|
|
314
|
+
# integrations/xslate/app.psgi's `_register_blog_child` and
|
|
315
|
+
# integrations/mojolicious/app.pl's equivalent.
|
|
316
|
+
def self.derive_vars_from_defaults(defaults, props)
|
|
317
|
+
extra = {}
|
|
318
|
+
defaults.each do |name, d|
|
|
319
|
+
unless d.is_a?(Hash)
|
|
320
|
+
extra[name] = d
|
|
321
|
+
next
|
|
322
|
+
end
|
|
323
|
+
if d[:isRestProps]
|
|
324
|
+
extra[name] = props.key?(name) ? props[name] : d[:value]
|
|
325
|
+
next
|
|
326
|
+
end
|
|
327
|
+
prop_name = d[:propName]
|
|
328
|
+
extra[name] =
|
|
329
|
+
if !prop_name.nil? && props.key?(prop_name) && !props[prop_name].nil?
|
|
330
|
+
props[prop_name]
|
|
331
|
+
else
|
|
332
|
+
d[:value]
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
extra
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# -----------------------------------------------------------------
|
|
339
|
+
# Streaming SSR (Out-of-Order)
|
|
340
|
+
# -----------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
def streaming_bootstrap
|
|
343
|
+
%{<script>(function(){function s(id){var a=document.querySelector('[bf-async="'+id+'"]');var t=document.querySelector('template[bf-async-resolve="'+id+'"]');if(!a||!t)return;a.replaceChildren(t.content.cloneNode(true));a.removeAttribute('bf-async');t.remove();requestAnimationFrame(function(){if(window.__bf_hydrate)window.__bf_hydrate()})};window.__bf_swap=s})()</script>}
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def async_boundary(id, fallback_html)
|
|
347
|
+
fallback_html = backend.materialize(fallback_html)
|
|
348
|
+
%(<div bf-async="#{id}">#{fallback_html}</div>)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def async_resolve(id, content_html)
|
|
352
|
+
%(<template bf-async-resolve="#{id}">#{content_html}</template><script>__bf_swap("#{id}")</script>)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# -----------------------------------------------------------------
|
|
356
|
+
# JS-compat callees -- invoked from generated ERB templates as
|
|
357
|
+
# `bf.json(val)`, `bf.floor(val)`, etc. Numeric coercion follows JS
|
|
358
|
+
# semantics (NaN propagates; non-numeric input yields NaN rather than
|
|
359
|
+
# silently 0). `json` bubbles backend/marshalling errors loudly rather
|
|
360
|
+
# than producing an empty payload.
|
|
361
|
+
# -----------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
NUMERIC_STRING_RE = /\A\s*[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\s*\z/.freeze
|
|
364
|
+
|
|
365
|
+
def json(value)
|
|
366
|
+
backend.encode_json(value)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# JS `String(v)` mirror, EXCEPT `nil` renders as '' (not the literal
|
|
370
|
+
# "null") so an unset prop doesn't surface as literal text in
|
|
371
|
+
# user-facing HTML -- the same divergence the Go/Perl adapters document
|
|
372
|
+
# for their `string` helper. This is the canonical JS-ish stringifier
|
|
373
|
+
# used throughout this file (join, spread_attrs, `h`, reduce's string
|
|
374
|
+
# fold, ...).
|
|
375
|
+
def string(value)
|
|
376
|
+
return '' if value.nil?
|
|
377
|
+
return value ? 'true' : 'false' if value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
|
378
|
+
return Evaluator.number_to_string(value) if value.is_a?(Numeric)
|
|
379
|
+
|
|
380
|
+
value.to_s
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# HTML-escaping helper for text interpolation (`<%= bf.h(expr) %>` --
|
|
384
|
+
# stdlib ERB does not auto-escape). JS-style stringification via
|
|
385
|
+
# `string` (numbers per JS Number#toString, nil -> "", booleans ->
|
|
386
|
+
# "true"/"false"), then HTML-escaped.
|
|
387
|
+
def h(value)
|
|
388
|
+
html_escape(string(value))
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# JS truthiness (falsy: false, nil, 0, NaN, ""). Delegates to the
|
|
392
|
+
# shared evaluator so template-emitted conditionals
|
|
393
|
+
# (`if bf.truthy?(x)`) and callback-body evaluation agree byte-for-byte.
|
|
394
|
+
def truthy?(value)
|
|
395
|
+
Evaluator.truthy?(value)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# JS `Number(v)` mirror: numeric / boolean inputs convert as expected;
|
|
399
|
+
# non-numeric / nil yield real numeric NaN so downstream arithmetic
|
|
400
|
+
# propagates correctly (`Math.floor(NaN) === NaN`).
|
|
401
|
+
def number(value)
|
|
402
|
+
return Float::NAN if value.nil?
|
|
403
|
+
return value ? 1 : 0 if value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
|
404
|
+
return value if value.is_a?(Numeric)
|
|
405
|
+
return Float(value.strip) if value.is_a?(String) && value.strip =~ NUMERIC_STRING_RE
|
|
406
|
+
|
|
407
|
+
Float::NAN
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def floor(value)
|
|
411
|
+
n = number(value)
|
|
412
|
+
finite_number?(n) ? n.floor : n
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def ceil(value)
|
|
416
|
+
n = number(value)
|
|
417
|
+
finite_number?(n) ? n.ceil : n
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def round(value)
|
|
421
|
+
n = number(value)
|
|
422
|
+
finite_number?(n) ? (n + 0.5).floor : n
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# -----------------------------------------------------------------
|
|
426
|
+
# Array / String method helpers
|
|
427
|
+
# -----------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
# `Array.prototype.includes(x)` / `String.prototype.includes(sub)`
|
|
430
|
+
# share a method name in JS; dispatch on Ruby class the way BarefootJS.pm
|
|
431
|
+
# dispatches on `ref()`. The Array arm scans with
|
|
432
|
+
# `Evaluator.same_value_zero?` (SameValueZero: no cross-type coercion,
|
|
433
|
+
# e.g. `[2].includes("2")` is false; `NaN` matches `NaN`) -- the same
|
|
434
|
+
# algorithm the evaluator's serialized-callback `array-method` path uses
|
|
435
|
+
# for `.includes`, so both positions agree.
|
|
436
|
+
def includes(recv, elem)
|
|
437
|
+
return recv.any? { |item| Evaluator.same_value_zero?(item, elem) } if recv.is_a?(Array)
|
|
438
|
+
return false if recv.is_a?(Hash)
|
|
439
|
+
|
|
440
|
+
s = recv.nil? ? '' : string(recv)
|
|
441
|
+
needle = elem.nil? ? '' : string(elem)
|
|
442
|
+
s.include?(needle)
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# `.filter(fn)` / `.every(fn)` / `.some(fn)` / `.find(fn)` /
|
|
446
|
+
# `.findIndex(fn)` / `.findLast(fn)` / `.findLastIndex(fn)` -- legacy
|
|
447
|
+
# block-predicate path for shapes the compiler lowers to a native
|
|
448
|
+
# callable (e.g. a Kolon-style lambda literal). `pred` is anything
|
|
449
|
+
# responding to `#call(item)`. The `_eval` family below is the
|
|
450
|
+
# evaluator-driven generalisation used for arbitrary pure bodies.
|
|
451
|
+
def filter(recv, pred)
|
|
452
|
+
return [] unless recv.is_a?(Array)
|
|
453
|
+
|
|
454
|
+
recv.select { |item| pred.call(item) }
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def every(recv, pred)
|
|
458
|
+
return true unless recv.is_a?(Array)
|
|
459
|
+
|
|
460
|
+
recv.all? { |item| pred.call(item) }
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def some(recv, pred)
|
|
464
|
+
return false unless recv.is_a?(Array)
|
|
465
|
+
|
|
466
|
+
recv.any? { |item| pred.call(item) }
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def find(recv, pred)
|
|
470
|
+
return nil unless recv.is_a?(Array)
|
|
471
|
+
|
|
472
|
+
recv.find { |item| pred.call(item) }
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def find_index(recv, pred)
|
|
476
|
+
return -1 unless recv.is_a?(Array)
|
|
477
|
+
|
|
478
|
+
recv.each_index { |i| return i if pred.call(recv[i]) }
|
|
479
|
+
-1
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def find_last(recv, pred)
|
|
483
|
+
return nil unless recv.is_a?(Array)
|
|
484
|
+
|
|
485
|
+
recv.reverse_each { |item| return item if pred.call(item) }
|
|
486
|
+
nil
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def find_last_index(recv, pred)
|
|
490
|
+
return -1 unless recv.is_a?(Array)
|
|
491
|
+
|
|
492
|
+
(recv.length - 1).downto(0) { |i| return i if pred.call(recv[i]) }
|
|
493
|
+
-1
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# `String.prototype.toLowerCase()` / `.toUpperCase()`.
|
|
497
|
+
def lc(s)
|
|
498
|
+
s.nil? ? '' : string(s).downcase
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def uc(s)
|
|
502
|
+
s.nil? ? '' : string(s).upcase
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# `Array.prototype.join(sep)` with JS semantics: separator defaults to
|
|
506
|
+
# ",", undefined/null elements render as empty.
|
|
507
|
+
def join(recv, sep = nil)
|
|
508
|
+
return '' unless recv.is_a?(Array)
|
|
509
|
+
|
|
510
|
+
sep = ',' if sep.nil?
|
|
511
|
+
recv.map { |el| string(el) }.join(sep)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# `.length` works on both arrays (element count) and strings (character
|
|
515
|
+
# count).
|
|
516
|
+
def length(recv)
|
|
517
|
+
return recv.length if recv.is_a?(Array)
|
|
518
|
+
return 0 if recv.is_a?(Hash) || recv.nil?
|
|
519
|
+
|
|
520
|
+
string(recv).length
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# `Array.prototype.indexOf(x)` / `.lastIndexOf(x)` -- value-equality
|
|
524
|
+
# search. Non-array receivers return -1.
|
|
525
|
+
def index_of(recv, elem)
|
|
526
|
+
array_index_of(recv, elem, false)
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def last_index_of(recv, elem)
|
|
530
|
+
array_index_of(recv, elem, true)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# `Array.prototype.at(i)` -- negative indices count from the end;
|
|
534
|
+
# out-of-bounds -> nil (renders as '' via `h`, matching JS `undefined`).
|
|
535
|
+
def at(recv, i)
|
|
536
|
+
return nil unless recv.is_a?(Array)
|
|
537
|
+
return nil if i.nil?
|
|
538
|
+
|
|
539
|
+
idx = i.to_i
|
|
540
|
+
len = recv.length
|
|
541
|
+
return nil if len.zero?
|
|
542
|
+
|
|
543
|
+
idx = len + idx if idx.negative?
|
|
544
|
+
return nil if idx.negative? || idx >= len
|
|
545
|
+
|
|
546
|
+
recv[idx]
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# `Array.prototype.concat(other)` -- merges two arrays in order into a
|
|
550
|
+
# new Array. Non-array operands collapse to empty.
|
|
551
|
+
def concat(a, b)
|
|
552
|
+
out = []
|
|
553
|
+
out.concat(a) if a.is_a?(Array)
|
|
554
|
+
out.concat(b) if b.is_a?(Array)
|
|
555
|
+
out
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# `Array.prototype.slice(start, end?)`. Mirrors the Go/Perl `bf_slice` /
|
|
559
|
+
# `slice` arithmetic so adapter output stays symmetric.
|
|
560
|
+
def slice(recv, start, end_ = nil)
|
|
561
|
+
return [] unless recv.is_a?(Array)
|
|
562
|
+
|
|
563
|
+
len = recv.length
|
|
564
|
+
return [] if len.zero?
|
|
565
|
+
|
|
566
|
+
s = start.nil? ? 0 : start.to_i
|
|
567
|
+
s = len + s if s.negative?
|
|
568
|
+
s = 0 if s.negative?
|
|
569
|
+
s = len if s > len
|
|
570
|
+
|
|
571
|
+
e = end_.nil? ? len : end_.to_i
|
|
572
|
+
e = len + e if e.negative?
|
|
573
|
+
e = 0 if e.negative?
|
|
574
|
+
e = len if e > len
|
|
575
|
+
|
|
576
|
+
return [] if s >= e
|
|
577
|
+
|
|
578
|
+
recv[s...e]
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# `Array.prototype.reverse()` / `.toReversed()` -- always returns a new
|
|
582
|
+
# Array (SSR renders a snapshot; the mutate-vs-copy JS distinction is
|
|
583
|
+
# moot here).
|
|
584
|
+
def reverse(recv)
|
|
585
|
+
recv.is_a?(Array) ? recv.reverse : []
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# `Array.prototype.flat(depth?)` -- flatten nested arrays `depth` levels
|
|
589
|
+
# deep. `depth` of -1 is the `Infinity` sentinel (flatten fully); 0
|
|
590
|
+
# returns a shallow copy.
|
|
591
|
+
def flat(recv, depth = 1)
|
|
592
|
+
return [] unless recv.is_a?(Array)
|
|
593
|
+
|
|
594
|
+
out = []
|
|
595
|
+
recv.each do |el|
|
|
596
|
+
if !depth.zero? && el.is_a?(Array)
|
|
597
|
+
out.concat(flat(el, depth.positive? ? depth - 1 : depth))
|
|
598
|
+
else
|
|
599
|
+
out << el
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
out
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# `Array.prototype.flat(depth)` where `depth` is a DYNAMIC value (#2094)
|
|
606
|
+
# -- e.g. `items.flat(props.depth)` -- rather than a compile-time
|
|
607
|
+
# literal. Coerces `depth` via JS `ToIntegerOrInfinity` (truncate toward
|
|
608
|
+
# zero; NaN/non-numeric -> 0; negative -> 0; +Infinity or a huge finite
|
|
609
|
+
# value -> flatten fully) and delegates to `flat`.
|
|
610
|
+
#
|
|
611
|
+
# This is a SEPARATE method from `flat`, not a smarter overload of it:
|
|
612
|
+
# `flat`'s `depth` parameter treats `-1` as a compile-time SENTINEL
|
|
613
|
+
# meaning "the source literally wrote `Infinity`" (the parser's own
|
|
614
|
+
# normalisation, baked into the emitted template). A genuinely dynamic
|
|
615
|
+
# depth value that happens to be `-1` at render time means the
|
|
616
|
+
# JS-correct OPPOSITE: `.flat(-1)` never recurses (same as `.flat(0)`,
|
|
617
|
+
# a shallow copy), because real JS only recurses when depth > 0.
|
|
618
|
+
# Reusing `flat`'s int contract for a raw dynamic value would silently
|
|
619
|
+
# invert that case, so this coerces FIRST -- mapping a real `+Infinity`
|
|
620
|
+
# / huge finite value to `flat`'s own `-1` sentinel, and a real negative
|
|
621
|
+
# value to `0` -- and only then delegates to `flat`'s recursion. Mirrors
|
|
622
|
+
# Go's `FlatDynamicDepth`/`coerceFlatDepth` (adapter-go-template/runtime/bf.go).
|
|
623
|
+
def flat_dynamic(recv, depth)
|
|
624
|
+
flat(recv, coerce_flat_depth(depth))
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# `Array.prototype.flatMap(fn)` value-returning field projection: map
|
|
628
|
+
# each element through a self/field projection, then flatten one level.
|
|
629
|
+
def flat_map(recv, key_kind, key)
|
|
630
|
+
return [] unless recv.is_a?(Array)
|
|
631
|
+
|
|
632
|
+
projected = recv.map { |el| key_kind == 'field' ? field_value(el, key) : el }
|
|
633
|
+
flat(projected, 1)
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# `Array.prototype.flatMap(i => [i.a, i.b])` -- array-literal tuple
|
|
637
|
+
# projection. Each spec is `[kind, key]` (`['self', '']` or
|
|
638
|
+
# `['field', 'a']`).
|
|
639
|
+
def flat_map_tuple(recv, *specs)
|
|
640
|
+
return [] unless recv.is_a?(Array)
|
|
641
|
+
|
|
642
|
+
out = []
|
|
643
|
+
recv.each do |el|
|
|
644
|
+
specs.each do |kind, key|
|
|
645
|
+
out << (kind == 'field' ? field_value(el, key) : el)
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
out
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
# `String.prototype.trim()`.
|
|
652
|
+
def trim(recv)
|
|
653
|
+
return '' if recv.nil? || recv.is_a?(Array) || recv.is_a?(Hash)
|
|
654
|
+
|
|
655
|
+
string(recv).gsub(/\A\p{Space}+|\p{Space}+\z/, '')
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# `Number.prototype.toFixed(digits)` -- fixed-decimal string with
|
|
659
|
+
# zero-padding, rounding half toward +Infinity (matching `round`).
|
|
660
|
+
def to_fixed(value, digits = 0)
|
|
661
|
+
n = number(value)
|
|
662
|
+
return 'NaN' if n.respond_to?(:nan?) && n.nan?
|
|
663
|
+
return n.negative? ? '-Infinity' : 'Infinity' if n.respond_to?(:infinite?) && n.infinite?
|
|
664
|
+
|
|
665
|
+
digits = 0 if digits.nil? || digits.negative?
|
|
666
|
+
factor = 10.0**digits
|
|
667
|
+
rounded = (n * factor + 0.5).floor
|
|
668
|
+
format("%.#{digits}f", rounded / factor)
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
# `String.prototype.split(sep)` -- string -> Array. An empty separator
|
|
672
|
+
# splits into individual characters; a nil separator returns the whole
|
|
673
|
+
# string in a single-element Array; trailing empty fields are kept
|
|
674
|
+
# (JS parity -- Ruby's `String#split(str, -1)` already matches this,
|
|
675
|
+
# and (unlike a Regexp) a String separator is matched literally).
|
|
676
|
+
def split(recv, sep = nil, limit = nil)
|
|
677
|
+
s = (recv.nil? || recv.is_a?(Array) || recv.is_a?(Hash)) ? '' : string(recv)
|
|
678
|
+
parts =
|
|
679
|
+
if sep.nil?
|
|
680
|
+
[s]
|
|
681
|
+
elsif string(sep).empty?
|
|
682
|
+
s.chars
|
|
683
|
+
elsif s.empty?
|
|
684
|
+
['']
|
|
685
|
+
else
|
|
686
|
+
s.split(string(sep), -1)
|
|
687
|
+
end
|
|
688
|
+
unless limit.nil?
|
|
689
|
+
n = limit.to_i
|
|
690
|
+
if n.zero?
|
|
691
|
+
parts = []
|
|
692
|
+
elsif n.positive? && n < parts.length
|
|
693
|
+
parts = parts[0...n]
|
|
694
|
+
end
|
|
695
|
+
end
|
|
696
|
+
parts
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# `String.prototype.startsWith(prefix, position?)`.
|
|
700
|
+
def starts_with(recv, prefix, position = nil)
|
|
701
|
+
s = recv.nil? ? '' : string(recv)
|
|
702
|
+
p = prefix.nil? ? '' : string(prefix)
|
|
703
|
+
unless position.nil?
|
|
704
|
+
n = clamp_index(position.to_i, s.length)
|
|
705
|
+
s = s[n..] || ''
|
|
706
|
+
end
|
|
707
|
+
s.start_with?(p)
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
# `String.prototype.endsWith(suffix, endPosition?)`.
|
|
711
|
+
def ends_with(recv, suffix, end_position = nil)
|
|
712
|
+
s = recv.nil? ? '' : string(recv)
|
|
713
|
+
x = suffix.nil? ? '' : string(suffix)
|
|
714
|
+
unless end_position.nil?
|
|
715
|
+
e = clamp_index(end_position.to_i, s.length)
|
|
716
|
+
s = s[0...e]
|
|
717
|
+
end
|
|
718
|
+
s.end_with?(x)
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# `String.prototype.replace(pattern, replacement)` -- string-pattern
|
|
722
|
+
# form only, replacing the FIRST occurrence, literally (no regex
|
|
723
|
+
# metacharacters, no `$1`-style replacement interpolation).
|
|
724
|
+
def replace(recv, pattern, replacement)
|
|
725
|
+
s = recv.nil? ? '' : string(recv)
|
|
726
|
+
o = pattern.nil? ? '' : string(pattern)
|
|
727
|
+
n = replacement.nil? ? '' : string(replacement)
|
|
728
|
+
return n + s if o.empty?
|
|
729
|
+
|
|
730
|
+
idx = s.index(o)
|
|
731
|
+
return s if idx.nil?
|
|
732
|
+
|
|
733
|
+
s[0...idx] + n + s[(idx + o.length)..]
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
# `queryHref(base, { ... })` (#2042) -- build "base?k=v&..." from a flat
|
|
737
|
+
# list of (guard, key, value) triples. A pair is included iff its guard
|
|
738
|
+
# is truthy AND its value is a non-empty string. A value may instead be
|
|
739
|
+
# an Array, which APPENDS one pair per non-empty member. Repeating a key
|
|
740
|
+
# overwrites the value at its first position (`URLSearchParams.set`
|
|
741
|
+
# semantics); array members always append (`.append` semantics).
|
|
742
|
+
def query(base, *triples)
|
|
743
|
+
b = base.nil? ? '' : string(base)
|
|
744
|
+
pairs = []
|
|
745
|
+
pos = {}
|
|
746
|
+
i = 0
|
|
747
|
+
while i + 2 < triples.length
|
|
748
|
+
guard, key, val = triples[i], triples[i + 1], triples[i + 2]
|
|
749
|
+
i += 3
|
|
750
|
+
next unless truthy?(guard)
|
|
751
|
+
|
|
752
|
+
k = key.nil? ? '' : string(key)
|
|
753
|
+
if val.is_a?(Array)
|
|
754
|
+
val.each do |m|
|
|
755
|
+
sm = string(m)
|
|
756
|
+
pairs << [k, sm] unless sm.empty?
|
|
757
|
+
end
|
|
758
|
+
next
|
|
759
|
+
end
|
|
760
|
+
v = val.nil? ? '' : string(val)
|
|
761
|
+
next if v.empty?
|
|
762
|
+
|
|
763
|
+
if pos.key?(k)
|
|
764
|
+
pairs[pos[k]][1] = v
|
|
765
|
+
else
|
|
766
|
+
pos[k] = pairs.length
|
|
767
|
+
pairs << [k, v]
|
|
768
|
+
end
|
|
769
|
+
end
|
|
770
|
+
return b if pairs.empty?
|
|
771
|
+
|
|
772
|
+
"#{b}?#{pairs.map { |pk, pv| "#{form_escape(pk)}=#{form_escape(pv)}" }.join('&')}"
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
# `String.prototype.repeat(n)` -- a count <= 0 degrades to '' rather
|
|
776
|
+
# than raising (JS throws RangeError for negative counts; SSR
|
|
777
|
+
# templates degrade instead of dying mid-render).
|
|
778
|
+
def repeat(recv, count)
|
|
779
|
+
s = recv.nil? ? '' : string(recv)
|
|
780
|
+
n = count.nil? ? 0 : count.to_i
|
|
781
|
+
n.positive? ? s * n : ''
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
# `String.prototype.padStart` / `padEnd`.
|
|
785
|
+
def pad_start(recv, target, pad_str = nil)
|
|
786
|
+
pad_string(recv.nil? ? '' : string(recv), target, pad_str, true)
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
def pad_end(recv, target, pad_str = nil)
|
|
790
|
+
pad_string(recv.nil? ? '' : string(recv), target, pad_str, false)
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# `Array.prototype.sort(cmp)` / `.toSorted(cmp)` -- fixed comparator
|
|
794
|
+
# catalogue (legacy, pre-#2018 path; `sort_eval` below handles arbitrary
|
|
795
|
+
# comparator bodies). `opts[:keys]` is a priority-ordered list of
|
|
796
|
+
# `{ key_kind:, key:, compare_type:, direction: }`. Stable (ties break
|
|
797
|
+
# on original index) and non-mutating.
|
|
798
|
+
def sort(recv, opts = {})
|
|
799
|
+
return [] unless recv.is_a?(Array)
|
|
800
|
+
|
|
801
|
+
spec = (opts[:keys] || []).map do |k|
|
|
802
|
+
{
|
|
803
|
+
key_kind: k[:key_kind] || 'self',
|
|
804
|
+
key: k[:key] || '',
|
|
805
|
+
compare_type: k[:compare_type] || 'numeric',
|
|
806
|
+
direction: k[:direction] || 'asc',
|
|
807
|
+
}
|
|
808
|
+
end
|
|
809
|
+
return recv.dup if spec.empty?
|
|
810
|
+
|
|
811
|
+
decorated = recv.each_with_index.map do |item, idx|
|
|
812
|
+
keys = spec.map { |sp| sp[:key_kind] == 'field' ? field_value(item, sp[:key]) : item }
|
|
813
|
+
[keys, item, idx]
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
sorted = decorated.sort do |x, y|
|
|
817
|
+
result = 0
|
|
818
|
+
spec.each_index do |i|
|
|
819
|
+
c = compare_sort_key(x[0][i], y[0][i], spec[i][:compare_type])
|
|
820
|
+
next if c.zero?
|
|
821
|
+
|
|
822
|
+
result = spec[i][:direction] == 'desc' ? -c : c
|
|
823
|
+
break
|
|
824
|
+
end
|
|
825
|
+
result.zero? ? (x[2] <=> y[2]) : result
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
sorted.map { |pair| pair[1] }
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
# Fold an array into a scalar via the arithmetic-fold catalogue
|
|
832
|
+
# (legacy, pre-#2018 path; `reduce_eval` below handles arbitrary
|
|
833
|
+
# reducer bodies). `opts`: `{ op: '+'|'*', key_kind:, key:,
|
|
834
|
+
# type: 'numeric'|'string', init:, direction: 'left'|'right' }`.
|
|
835
|
+
def reduce(recv, opts = {})
|
|
836
|
+
op = opts[:op] || '+'
|
|
837
|
+
key_kind = opts[:key_kind] || 'self'
|
|
838
|
+
key = opts[:key] || ''
|
|
839
|
+
type = opts[:type] || 'numeric'
|
|
840
|
+
direction = opts[:direction] || 'left'
|
|
841
|
+
|
|
842
|
+
items = recv.is_a?(Array) ? recv.dup : []
|
|
843
|
+
items.reverse! if direction == 'right'
|
|
844
|
+
project = lambda { |item| key_kind == 'field' ? field_value(item, key) : item }
|
|
845
|
+
|
|
846
|
+
if type == 'string'
|
|
847
|
+
acc = opts[:init].nil? ? '' : string(opts[:init])
|
|
848
|
+
items.each { |item| acc += string(project.call(item)) }
|
|
849
|
+
return acc
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
# `init` rides through the adapter as whatever literal the template
|
|
853
|
+
# emits -- often a numeric-looking String (JSON-decoded, not a Ruby
|
|
854
|
+
# numeric literal) -- so route it through the same numeric coercion
|
|
855
|
+
# as every per-element projection rather than trusting its Ruby class.
|
|
856
|
+
acc = opts[:init].nil? ? 0 : numeric_or_zero(opts[:init])
|
|
857
|
+
items.each do |item|
|
|
858
|
+
n = numeric_or_zero(project.call(item))
|
|
859
|
+
acc = op == '*' ? acc * n : acc + n
|
|
860
|
+
end
|
|
861
|
+
acc
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
# -----------------------------------------------------------------
|
|
865
|
+
# Evaluator-driven sort / reduce / higher-order predicates (#2018):
|
|
866
|
+
# the comparator / reducer / predicate body rides as a serialized-
|
|
867
|
+
# ParsedExpr JSON string and is evaluated per element, delegating to
|
|
868
|
+
# the shared BarefootJS::Evaluator. `find_eval` / `find_index_eval`
|
|
869
|
+
# take a `forward` flag (false -> findLast / findLastIndex).
|
|
870
|
+
# -----------------------------------------------------------------
|
|
871
|
+
|
|
872
|
+
def sort_eval(recv, cmp_json, param_a, param_b, base_env = {})
|
|
873
|
+
Evaluator.sort_by_json(recv, cmp_json, param_a, param_b, base_env)
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
def reduce_eval(recv, body_json, acc_name, item_name, init, direction = 'left', base_env = {})
|
|
877
|
+
Evaluator.fold_json(recv, body_json, acc_name, item_name, init, direction, base_env)
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
def filter_eval(recv, pred_json, param, base_env = {})
|
|
881
|
+
Evaluator.filter_json(recv, pred_json, param, base_env)
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
def every_eval(recv, pred_json, param, base_env = {})
|
|
885
|
+
Evaluator.every_json(recv, pred_json, param, base_env)
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
def some_eval(recv, pred_json, param, base_env = {})
|
|
889
|
+
Evaluator.some_json(recv, pred_json, param, base_env)
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
def find_eval(recv, pred_json, param, forward = true, base_env = {})
|
|
893
|
+
Evaluator.find_json(recv, pred_json, param, forward, base_env)
|
|
894
|
+
end
|
|
895
|
+
|
|
896
|
+
def find_index_eval(recv, pred_json, param, forward = true, base_env = {})
|
|
897
|
+
Evaluator.find_index_json(recv, pred_json, param, forward, base_env)
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
def flat_map_eval(recv, proj_json, param, base_env = {})
|
|
901
|
+
Evaluator.flat_map_json(recv, proj_json, param, base_env)
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
def map_eval(recv, proj_json, param, base_env = {})
|
|
905
|
+
Evaluator.map_json(recv, proj_json, param, base_env)
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
# -----------------------------------------------------------------
|
|
909
|
+
# JSX intrinsic-element spread (#1407)
|
|
910
|
+
# -----------------------------------------------------------------
|
|
911
|
+
#
|
|
912
|
+
# Mirrors the JS `spreadAttrs` runtime and the Go/Perl adapters'
|
|
913
|
+
# spread helpers so SSR output stays byte-equal across adapters.
|
|
914
|
+
# Generated ERB templates invoke this as `<%= bf.spread_attrs(bag) %>`.
|
|
915
|
+
#
|
|
916
|
+
# Skip rules: nil/false values, event handlers (`on[A-Z]...`), and
|
|
917
|
+
# `children`. `ref` is intentionally NOT filtered (matches the JS
|
|
918
|
+
# reference). Key remap: className -> class, htmlFor -> for; SVG
|
|
919
|
+
# camelCase attrs preserved; other camelCase keys lowered to
|
|
920
|
+
# kebab-case. `style` routes through `style_to_css`. Output is
|
|
921
|
+
# deterministic: keys are sorted alphabetically before emission.
|
|
922
|
+
#
|
|
923
|
+
# Unlike the Perl/Go ports, no boolean-sentinel detection is needed --
|
|
924
|
+
# Ruby's `true`/`false` are real booleans, distinct from `0`/`1`, so a
|
|
925
|
+
# bag value's Ruby class alone tells JS-boolean from JS-number.
|
|
926
|
+
def spread_attrs(bag)
|
|
927
|
+
return '' unless bag.is_a?(Hash)
|
|
928
|
+
|
|
929
|
+
parts = []
|
|
930
|
+
bag.keys.sort_by(&:to_s).each do |key|
|
|
931
|
+
key_s = key.to_s
|
|
932
|
+
if key_s.length > 2 && key_s.start_with?('on')
|
|
933
|
+
c = key_s[2]
|
|
934
|
+
next if c.upcase == c
|
|
935
|
+
end
|
|
936
|
+
next if key_s == 'children'
|
|
937
|
+
|
|
938
|
+
val = bag[key]
|
|
939
|
+
next if val.nil?
|
|
940
|
+
|
|
941
|
+
if val.is_a?(TrueClass) || val.is_a?(FalseClass)
|
|
942
|
+
next unless val
|
|
943
|
+
|
|
944
|
+
parts << to_attr_name(key_s)
|
|
945
|
+
next
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
if key_s == 'style'
|
|
949
|
+
css = style_to_css(val)
|
|
950
|
+
next if css.nil? || css.empty?
|
|
951
|
+
|
|
952
|
+
parts << %(style="#{html_escape(css)}")
|
|
953
|
+
next
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
parts << %(#{to_attr_name(key_s)}="#{html_escape(string(val))}")
|
|
957
|
+
end
|
|
958
|
+
return '' if parts.empty?
|
|
959
|
+
|
|
960
|
+
# Mark the result raw so the calling template's plain `<%=` doesn't
|
|
961
|
+
# need a second escape pass (the backend decides how "raw" is
|
|
962
|
+
# represented for its engine; ERB's own emit has no auto-escape, so
|
|
963
|
+
# BarefootJS::Backend::Erb's `mark_raw` is the identity function).
|
|
964
|
+
backend.mark_raw(parts.join(' '))
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
private
|
|
968
|
+
|
|
969
|
+
def finite_number?(n)
|
|
970
|
+
!(n.respond_to?(:nan?) && n.nan?) && !(n.respond_to?(:infinite?) && n.infinite?)
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
def html_escape(s)
|
|
974
|
+
s.gsub('&', '&').gsub('<', '<').gsub('>', '>').gsub('"', '"').gsub("'", ''')
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
SVG_CAMEL_CASE_ATTRS = Set.new(%w[
|
|
978
|
+
allowReorder attributeName attributeType autoReverse
|
|
979
|
+
baseFrequency baseProfile calcMode clipPathUnits
|
|
980
|
+
contentScriptType contentStyleType diffuseConstant edgeMode
|
|
981
|
+
externalResourcesRequired filterRes filterUnits glyphRef
|
|
982
|
+
gradientTransform gradientUnits kernelMatrix kernelUnitLength
|
|
983
|
+
keyPoints keySplines keyTimes lengthAdjust limitingConeAngle
|
|
984
|
+
markerHeight markerUnits markerWidth maskContentUnits
|
|
985
|
+
maskUnits numOctaves pathLength patternContentUnits
|
|
986
|
+
patternTransform patternUnits pointsAtX pointsAtY pointsAtZ
|
|
987
|
+
preserveAlpha preserveAspectRatio primitiveUnits refX refY
|
|
988
|
+
repeatCount repeatDur requiredExtensions requiredFeatures
|
|
989
|
+
specularConstant specularExponent spreadMethod startOffset
|
|
990
|
+
stdDeviation stitchTiles surfaceScale systemLanguage
|
|
991
|
+
tableValues targetX targetY textLength viewBox viewTarget
|
|
992
|
+
xChannelSelector yChannelSelector zoomAndPan
|
|
993
|
+
]).freeze
|
|
994
|
+
private_constant :SVG_CAMEL_CASE_ATTRS
|
|
995
|
+
|
|
996
|
+
def to_attr_name(key)
|
|
997
|
+
return 'class' if key == 'className'
|
|
998
|
+
return 'for' if key == 'htmlFor'
|
|
999
|
+
return key if SVG_CAMEL_CASE_ATTRS.include?(key)
|
|
1000
|
+
|
|
1001
|
+
# camelCase -> kebab-case, with a leading '-' for an initial uppercase
|
|
1002
|
+
# letter (JS-reference parity, even though that case produces an
|
|
1003
|
+
# HTML-invalid attribute name -- same documented behaviour as the
|
|
1004
|
+
# Go/Perl adapters' `toAttrName`).
|
|
1005
|
+
key.gsub(/([A-Z])/) { "-#{Regexp.last_match(1).downcase}" }
|
|
1006
|
+
end
|
|
1007
|
+
|
|
1008
|
+
def style_to_css(value)
|
|
1009
|
+
return nil if value.nil?
|
|
1010
|
+
unless value.is_a?(Hash)
|
|
1011
|
+
s = string(value)
|
|
1012
|
+
return s.empty? ? nil : s
|
|
1013
|
+
end
|
|
1014
|
+
parts = value.keys.sort_by(&:to_s).filter_map do |key|
|
|
1015
|
+
v = value[key]
|
|
1016
|
+
next if v.nil?
|
|
1017
|
+
|
|
1018
|
+
prop = key.to_s.gsub(/([A-Z])/) { "-#{Regexp.last_match(1).downcase}" }
|
|
1019
|
+
"#{prop}:#{string(v)}"
|
|
1020
|
+
end
|
|
1021
|
+
parts.empty? ? nil : parts.join(';')
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
# application/x-www-form-urlencoded serialisation, matching the
|
|
1025
|
+
# browser's URLSearchParams (which the SSR query render must equal):
|
|
1026
|
+
# keep ASCII alphanumerics and `* - . _`; encode every other byte as
|
|
1027
|
+
# `%XX` (upper hex); space -> `+`. Non-ASCII is encoded byte-wise over
|
|
1028
|
+
# its UTF-8 bytes.
|
|
1029
|
+
def form_escape(s)
|
|
1030
|
+
bytes = (s || '').to_s.encode('UTF-8').b
|
|
1031
|
+
escaped = bytes.gsub(/[^A-Za-z0-9*\-._ ]/n) { |c| format('%%%02X', c.ord) }
|
|
1032
|
+
escaped.tr(' ', '+')
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
def field_value(item, key)
|
|
1036
|
+
item.is_a?(Hash) ? item[field_key(key)] : nil
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
def field_key(key)
|
|
1040
|
+
key.is_a?(Symbol) ? key : key.to_s.to_sym
|
|
1041
|
+
end
|
|
1042
|
+
|
|
1043
|
+
def numeric_like?(v)
|
|
1044
|
+
return true if v.is_a?(Numeric)
|
|
1045
|
+
|
|
1046
|
+
v.is_a?(String) && v.strip =~ NUMERIC_STRING_RE
|
|
1047
|
+
end
|
|
1048
|
+
|
|
1049
|
+
def numeric_value(v)
|
|
1050
|
+
return 0 if v.nil?
|
|
1051
|
+
return v if v.is_a?(Numeric)
|
|
1052
|
+
return Float(v.strip) if v.is_a?(String) && v.strip =~ NUMERIC_STRING_RE
|
|
1053
|
+
|
|
1054
|
+
0
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1057
|
+
def numeric_or_zero(v)
|
|
1058
|
+
numeric_like?(v) ? numeric_value(v) : 0
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
# ToIntegerOrInfinity-ish coercion for a dynamic `.flat(depth)` argument
|
|
1062
|
+
# (#2094), returning an int in `flat`'s own contract (`-1` = unbounded,
|
|
1063
|
+
# `>= 0` = that many levels). Mirrors Go's `coerceFlatDepth`.
|
|
1064
|
+
def coerce_flat_depth(depth)
|
|
1065
|
+
f = flat_depth_to_float(depth)
|
|
1066
|
+
return 0 if f.nil? || f.nan?
|
|
1067
|
+
return -1 if f.infinite? == 1
|
|
1068
|
+
return 0 if f.infinite? == -1
|
|
1069
|
+
|
|
1070
|
+
trunc = f.truncate
|
|
1071
|
+
return 0 if trunc.negative?
|
|
1072
|
+
# A huge finite depth behaves identically to "flatten fully" in
|
|
1073
|
+
# practice -- capping it here avoids an absurd countdown without
|
|
1074
|
+
# needing a second sentinel (mirrors Go's `coerceFlatDepth`).
|
|
1075
|
+
return -1 if trunc > 1_000_000
|
|
1076
|
+
|
|
1077
|
+
trunc
|
|
1078
|
+
end
|
|
1079
|
+
|
|
1080
|
+
# ToNumber-ish coercion feeding `coerce_flat_depth`, mirroring JS across
|
|
1081
|
+
# the value shapes a dynamic `.flat(depth)` argument can carry: nil ->
|
|
1082
|
+
# not coercible (signalled as `nil`, treated as NaN by the caller);
|
|
1083
|
+
# Numeric -> its float value; bool -> 1/0; a numeric string -> its
|
|
1084
|
+
# value, INCLUDING the "Infinity"/"-Infinity" spellings (unlike Go's
|
|
1085
|
+
# `strconv.ParseFloat`, Ruby's own `Float()` does not parse those, so
|
|
1086
|
+
# they are special-cased here first); any other string -> not coercible.
|
|
1087
|
+
def flat_depth_to_float(v)
|
|
1088
|
+
return nil if v.nil?
|
|
1089
|
+
return v.to_f if v.is_a?(Numeric)
|
|
1090
|
+
return v ? 1.0 : 0.0 if v.is_a?(TrueClass) || v.is_a?(FalseClass)
|
|
1091
|
+
return nil unless v.is_a?(String)
|
|
1092
|
+
|
|
1093
|
+
s = v.strip
|
|
1094
|
+
return 0.0 if s.empty?
|
|
1095
|
+
return Float::INFINITY if s == 'Infinity' || s == '+Infinity'
|
|
1096
|
+
return -Float::INFINITY if s == '-Infinity'
|
|
1097
|
+
return Float(s) if s =~ NUMERIC_STRING_RE
|
|
1098
|
+
|
|
1099
|
+
nil
|
|
1100
|
+
end
|
|
1101
|
+
|
|
1102
|
+
# Compare two projected sort keys, ascending orientation (-1/0/1); the
|
|
1103
|
+
# caller negates for 'desc'. 'auto' compares numerically when both keys
|
|
1104
|
+
# look like numbers, else lexically.
|
|
1105
|
+
def compare_sort_key(av, bv, compare_type)
|
|
1106
|
+
case compare_type
|
|
1107
|
+
when 'string'
|
|
1108
|
+
(av.nil? ? '' : string(av)) <=> (bv.nil? ? '' : string(bv))
|
|
1109
|
+
when 'auto'
|
|
1110
|
+
if numeric_like?(av) && numeric_like?(bv)
|
|
1111
|
+
numeric_value(av) <=> numeric_value(bv)
|
|
1112
|
+
else
|
|
1113
|
+
(av.nil? ? '' : string(av)) <=> (bv.nil? ? '' : string(bv))
|
|
1114
|
+
end
|
|
1115
|
+
else
|
|
1116
|
+
numeric_value(av) <=> numeric_value(bv)
|
|
1117
|
+
end
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
def array_index_of(recv, elem, reverse)
|
|
1121
|
+
return -1 unless recv.is_a?(Array)
|
|
1122
|
+
|
|
1123
|
+
indices = reverse ? (recv.length - 1).downto(0).to_a : (0...recv.length).to_a
|
|
1124
|
+
indices.each do |i|
|
|
1125
|
+
item = recv[i]
|
|
1126
|
+
if item.nil?
|
|
1127
|
+
return i if elem.nil?
|
|
1128
|
+
|
|
1129
|
+
next
|
|
1130
|
+
end
|
|
1131
|
+
return i if !elem.nil? && item == elem
|
|
1132
|
+
end
|
|
1133
|
+
-1
|
|
1134
|
+
end
|
|
1135
|
+
|
|
1136
|
+
def clamp_index(n, len)
|
|
1137
|
+
n = 0 if n.negative?
|
|
1138
|
+
n = len if n > len
|
|
1139
|
+
n
|
|
1140
|
+
end
|
|
1141
|
+
|
|
1142
|
+
def pad_string(s, target, pad_str, at_start)
|
|
1143
|
+
pad_str = pad_str.nil? ? ' ' : string(pad_str)
|
|
1144
|
+
return s if pad_str.empty?
|
|
1145
|
+
|
|
1146
|
+
len = s.length
|
|
1147
|
+
t = target.nil? ? 0 : target.to_i
|
|
1148
|
+
return s if len >= t
|
|
1149
|
+
|
|
1150
|
+
need = t - len
|
|
1151
|
+
fill = (pad_str * ((need / pad_str.length) + 1))[0, need]
|
|
1152
|
+
at_start ? fill + s : s + fill
|
|
1153
|
+
end
|
|
1154
|
+
end
|
|
1155
|
+
end
|