@barefootjs/erb 0.18.4 → 0.18.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter/erb-adapter.d.ts +15 -0
- package/dist/adapter/erb-adapter.d.ts.map +1 -1
- package/dist/adapter/expr/array-method.d.ts.map +1 -1
- package/dist/adapter/expr/emitters.d.ts +4 -3
- package/dist/adapter/expr/emitters.d.ts.map +1 -1
- package/dist/adapter/index.js +146 -37
- package/dist/adapter/lib/constants.d.ts.map +1 -1
- package/dist/adapter/lib/static-value.d.ts +17 -0
- package/dist/adapter/lib/static-value.d.ts.map +1 -0
- package/dist/build.js +146 -37
- package/dist/conformance-pins.d.ts.map +1 -1
- package/dist/index.js +148 -55
- package/dist/render-divergences.d.ts.map +1 -1
- package/lib/barefoot_js/backend/erb.rb +24 -15
- package/lib/barefoot_js/evaluator.rb +14 -1
- package/lib/barefoot_js.rb +111 -6
- package/package.json +3 -3
- package/src/__tests__/erb-adapter.test.ts +142 -0
- package/src/adapter/erb-adapter.ts +175 -23
- package/src/adapter/expr/array-method.ts +22 -0
- package/src/adapter/expr/emitters.ts +29 -3
- package/src/adapter/lib/constants.ts +3 -0
- package/src/adapter/lib/static-value.ts +43 -0
- package/src/conformance-pins.ts +23 -28
- package/src/render-divergences.ts +4 -18
- package/src/test-render.ts +13 -136
package/lib/barefoot_js.rb
CHANGED
|
@@ -25,6 +25,18 @@ require 'barefoot_js/search_params'
|
|
|
25
25
|
# runtime carries -- a Ruby `true`/`false` IS a boolean, distinguishable
|
|
26
26
|
# from `0`/`1` for free.
|
|
27
27
|
module BarefootJS
|
|
28
|
+
# Marker wrapper for a string that is ALREADY finished HTML and must not
|
|
29
|
+
# be re-escaped by `Context#h` -- e.g. a named-slot / children capture
|
|
30
|
+
# (`renderComponent`'s output-buffer slice) forwarded from a parent
|
|
31
|
+
# template into a child's vars Hash. Stdlib ERB's `<%=` has no built-in
|
|
32
|
+
# "safe string" concept the way Twig's `Markup` or Kolon's `mark_raw`
|
|
33
|
+
# do, so the escape decision that elsewhere falls out of the template
|
|
34
|
+
# engine has to be carried on the VALUE itself here; see
|
|
35
|
+
# `Backend::Erb#mark_raw`, which is what actually wraps a string in this
|
|
36
|
+
# class.
|
|
37
|
+
class SafeString < String
|
|
38
|
+
end
|
|
39
|
+
|
|
28
40
|
# Context is the `bf` object every compiled `.erb` template receives as a
|
|
29
41
|
# local. One instance per render (root or child); `render_child` /
|
|
30
42
|
# `register_components_from_manifest` construct a fresh child instance
|
|
@@ -391,8 +403,13 @@ module BarefootJS
|
|
|
391
403
|
# HTML-escaping helper for text interpolation (`<%= bf.h(expr) %>` --
|
|
392
404
|
# stdlib ERB does not auto-escape). JS-style stringification via
|
|
393
405
|
# `string` (numbers per JS Number#toString, nil -> "", booleans ->
|
|
394
|
-
# "true"/"false"), then HTML-escaped.
|
|
406
|
+
# "true"/"false"), then HTML-escaped. A `SafeString` (already-finished
|
|
407
|
+
# HTML forwarded from a parent's capture -- see that class's docstring)
|
|
408
|
+
# passes through unescaped, matching Twig/Blade/Kolon's safe-string
|
|
409
|
+
# bypass on their own auto-escaping `{{ }}`/`<: :>` output tags.
|
|
395
410
|
def h(value)
|
|
411
|
+
return value if value.is_a?(SafeString)
|
|
412
|
+
|
|
396
413
|
html_escape(string(value))
|
|
397
414
|
end
|
|
398
415
|
|
|
@@ -430,6 +447,34 @@ module BarefootJS
|
|
|
430
447
|
finite_number?(n) ? (n + 0.5).floor : n
|
|
431
448
|
end
|
|
432
449
|
|
|
450
|
+
# `Math.min(a, b)` / `Math.max(a, b)` -- two-arg forms only (#2168
|
|
451
|
+
# math-methods). JS returns NaN if either operand is NaN. `number()`
|
|
452
|
+
# may return a plain Integer (no `#nan?`), so guard like
|
|
453
|
+
# `finite_number?` above rather than calling `#nan?` unconditionally.
|
|
454
|
+
def min(a, b)
|
|
455
|
+
x = number(a)
|
|
456
|
+
y = number(b)
|
|
457
|
+
return x if nan_number?(x)
|
|
458
|
+
return y if nan_number?(y)
|
|
459
|
+
|
|
460
|
+
x < y ? x : y
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def max(a, b)
|
|
464
|
+
x = number(a)
|
|
465
|
+
y = number(b)
|
|
466
|
+
return x if nan_number?(x)
|
|
467
|
+
return y if nan_number?(y)
|
|
468
|
+
|
|
469
|
+
x > y ? x : y
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# `Math.abs()` (#2168 math-methods).
|
|
473
|
+
def abs(value)
|
|
474
|
+
n = number(value)
|
|
475
|
+
nan_number?(n) ? n : n.abs
|
|
476
|
+
end
|
|
477
|
+
|
|
433
478
|
# -----------------------------------------------------------------
|
|
434
479
|
# Array / String method helpers
|
|
435
480
|
# -----------------------------------------------------------------
|
|
@@ -563,13 +608,22 @@ module BarefootJS
|
|
|
563
608
|
out
|
|
564
609
|
end
|
|
565
610
|
|
|
566
|
-
# `Array.prototype.slice(start, end?)
|
|
567
|
-
# `slice`
|
|
611
|
+
# `Array.prototype.slice(start, end?)` AND `String.prototype.slice`
|
|
612
|
+
# (the `string-slice` divergence) -- the adapter emits the same
|
|
613
|
+
# `bf_slice` call for both receiver shapes (it can't disambiguate
|
|
614
|
+
# string vs. array at compile time), so this dispatches on Ruby
|
|
615
|
+
# class, mirroring `includes` above. Mirrors the Go/Perl `bf_slice`
|
|
616
|
+
# / `slice` arithmetic so adapter output stays symmetric.
|
|
617
|
+
# `String#length` / `#[]` already index by character (not byte) for
|
|
618
|
+
# a UTF-8-encoded string, matching JS except for astral-plane input
|
|
619
|
+
# (the same divergence boundary every other adapter's pad/trim
|
|
620
|
+
# helpers already accept).
|
|
568
621
|
def slice(recv, start, end_ = nil)
|
|
569
|
-
return [] unless recv.is_a?(Array)
|
|
622
|
+
return [] unless recv.is_a?(Array) || recv.is_a?(String)
|
|
570
623
|
|
|
624
|
+
empty = recv.is_a?(String) ? '' : []
|
|
571
625
|
len = recv.length
|
|
572
|
-
return
|
|
626
|
+
return empty if len.zero?
|
|
573
627
|
|
|
574
628
|
s = start.nil? ? 0 : start.to_i
|
|
575
629
|
s = len + s if s.negative?
|
|
@@ -581,7 +635,7 @@ module BarefootJS
|
|
|
581
635
|
e = 0 if e.negative?
|
|
582
636
|
e = len if e > len
|
|
583
637
|
|
|
584
|
-
return
|
|
638
|
+
return empty if s >= e
|
|
585
639
|
|
|
586
640
|
recv[s...e]
|
|
587
641
|
end
|
|
@@ -663,6 +717,21 @@ module BarefootJS
|
|
|
663
717
|
string(recv).gsub(/\A\p{Space}+|\p{Space}+\z/, '')
|
|
664
718
|
end
|
|
665
719
|
|
|
720
|
+
# `String.prototype.trimStart()` / `.trimEnd()` -- the one-sided
|
|
721
|
+
# siblings of `trim` above (#2183 follow-up), same `\p{Space}` regex
|
|
722
|
+
# restricted to one side.
|
|
723
|
+
def trim_start(recv)
|
|
724
|
+
return '' if recv.nil? || recv.is_a?(Array) || recv.is_a?(Hash)
|
|
725
|
+
|
|
726
|
+
string(recv).sub(/\A\p{Space}+/, '')
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def trim_end(recv)
|
|
730
|
+
return '' if recv.nil? || recv.is_a?(Array) || recv.is_a?(Hash)
|
|
731
|
+
|
|
732
|
+
string(recv).sub(/\p{Space}+\z/, '')
|
|
733
|
+
end
|
|
734
|
+
|
|
666
735
|
# `Number.prototype.toFixed(digits)` -- fixed-decimal string with
|
|
667
736
|
# zero-padding, rounding half toward +Infinity (matching `round`).
|
|
668
737
|
def to_fixed(value, digits = 0)
|
|
@@ -741,6 +810,38 @@ module BarefootJS
|
|
|
741
810
|
s[0...idx] + n + s[(idx + o.length)..]
|
|
742
811
|
end
|
|
743
812
|
|
|
813
|
+
# `String.prototype.replaceAll(pattern, replacement)` -- string-pattern
|
|
814
|
+
# form only (#2182), replacing EVERY occurrence (the all-occurrences
|
|
815
|
+
# sibling of `replace` above). Deliberately NOT `String#gsub`: Ruby's
|
|
816
|
+
# `gsub` interprets `\1` / `\&` backreference syntax in the replacement
|
|
817
|
+
# even for a literal string pattern (`"abc".gsub("b", "\\1")` -> "ac",
|
|
818
|
+
# not the literal "\1"), which would diverge from `.replace`'s literal
|
|
819
|
+
# splice above and from the other backends' literal treatment. The
|
|
820
|
+
# index/splice loop keeps the replacement literal, matching `replace`.
|
|
821
|
+
# An empty pattern inserts at every boundary, including before the
|
|
822
|
+
# first and after the last character (`"abc".replaceAll("", "X")` ->
|
|
823
|
+
# "XaXbXcX"), matching JS.
|
|
824
|
+
def replace_all(recv, pattern, replacement)
|
|
825
|
+
s = recv.nil? ? '' : string(recv)
|
|
826
|
+
o = pattern.nil? ? '' : string(pattern)
|
|
827
|
+
n = replacement.nil? ? '' : string(replacement)
|
|
828
|
+
return ([''] + s.chars + ['']).join(n) if o.empty?
|
|
829
|
+
|
|
830
|
+
# `+''` (not the frozen `''` literal under frozen_string_literal)
|
|
831
|
+
# so `<<` can append in place instead of `+=` reallocating a new
|
|
832
|
+
# string each iteration (quadratic for long inputs / many matches).
|
|
833
|
+
out = +''
|
|
834
|
+
pos = 0
|
|
835
|
+
loop do
|
|
836
|
+
idx = s.index(o, pos)
|
|
837
|
+
break if idx.nil?
|
|
838
|
+
|
|
839
|
+
out << s[pos...idx] << n
|
|
840
|
+
pos = idx + o.length
|
|
841
|
+
end
|
|
842
|
+
out << s[pos..]
|
|
843
|
+
end
|
|
844
|
+
|
|
744
845
|
# `queryHref(base, { ... })` (#2042) -- build "base?k=v&..." from a flat
|
|
745
846
|
# list of (guard, key, value) triples. A pair is included iff its guard
|
|
746
847
|
# is truthy AND its value is a non-empty string. A value may instead be
|
|
@@ -978,6 +1079,10 @@ module BarefootJS
|
|
|
978
1079
|
!(n.respond_to?(:nan?) && n.nan?) && !(n.respond_to?(:infinite?) && n.infinite?)
|
|
979
1080
|
end
|
|
980
1081
|
|
|
1082
|
+
def nan_number?(n)
|
|
1083
|
+
n.respond_to?(:nan?) && n.nan?
|
|
1084
|
+
end
|
|
1085
|
+
|
|
981
1086
|
def html_escape(s)
|
|
982
1087
|
s.gsub('&', '&').gsub('<', '<').gsub('>', '>').gsub('"', '"').gsub("'", ''')
|
|
983
1088
|
end
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@barefootjs/erb",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.7",
|
|
4
4
|
"description": "ERB (Embedded Ruby) adapter for BarefootJS — compiles IR to .erb templates and ships the Ruby rendering backend; runs under any Rack app (Sinatra, Rails)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -54,14 +54,14 @@
|
|
|
54
54
|
"directory": "packages/adapter-erb"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@barefootjs/shared": "0.18.
|
|
57
|
+
"@barefootjs/shared": "0.18.7"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"@barefootjs/jsx": ">=0.2.0"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@barefootjs/adapter-tests": "0.1.0",
|
|
64
|
-
"@barefootjs/jsx": "0.18.
|
|
64
|
+
"@barefootjs/jsx": "0.18.7",
|
|
65
65
|
"typescript": "^5.0.0"
|
|
66
66
|
}
|
|
67
67
|
}
|
|
@@ -304,3 +304,145 @@ export { Slot }
|
|
|
304
304
|
expect(template).toMatch(/\*\*v\[:rest\]/)
|
|
305
305
|
})
|
|
306
306
|
})
|
|
307
|
+
|
|
308
|
+
describe('ErbAdapter - filter().map() predicate matches the FILTER param, not the loop param (#2245)', () => {
|
|
309
|
+
// `todos.filter(t => t.done).map(todo => ...)`: the predicate's own `t`
|
|
310
|
+
// used to be matched against the LOOP's (map's) param `todo` inside
|
|
311
|
+
// `ErbFilterEmitter.identifier()`, so every reference to `t` inside the
|
|
312
|
+
// predicate fell to the `v[:t]` vars-Hash fallback instead of resolving
|
|
313
|
+
// to the loop-bound `todo` local — `v[:t]` is never seeded, and real
|
|
314
|
+
// Ruby raises `NoMethodError: undefined method '[]' for nil` on
|
|
315
|
+
// `v[:t][:done]` at render time (masked in the shipped `todo-app-ssr`
|
|
316
|
+
// corpus by its `'all'`-default filter short-circuiting the buggy
|
|
317
|
+
// branch away — see `filter-wrapper-props-reachable`'s docstring).
|
|
318
|
+
const DIFFERENTLY_NAMED_SOURCE = `
|
|
319
|
+
'use client'
|
|
320
|
+
import { createSignal } from '@barefootjs/client'
|
|
321
|
+
|
|
322
|
+
type Todo = { id: number; text: string; done: boolean }
|
|
323
|
+
|
|
324
|
+
export function TodoList(props: { initialTodos?: Todo[] }) {
|
|
325
|
+
const [todos] = createSignal<Todo[]>(props.initialTodos ?? [])
|
|
326
|
+
return (
|
|
327
|
+
<ul>
|
|
328
|
+
{todos().filter(t => !t.done).map(todo => (
|
|
329
|
+
<li key={todo.id}>{todo.text}</li>
|
|
330
|
+
))}
|
|
331
|
+
</ul>
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
`
|
|
335
|
+
|
|
336
|
+
function compileToIR(source: string): ComponentIR {
|
|
337
|
+
const result = compileJSX(source.trimStart(), 'test.tsx', {
|
|
338
|
+
adapter: new ErbAdapter(),
|
|
339
|
+
outputIR: true,
|
|
340
|
+
})
|
|
341
|
+
const irFile = result.files.find(f => f.type === 'ir')
|
|
342
|
+
if (!irFile) throw new Error('No IR output')
|
|
343
|
+
return JSON.parse(irFile.content) as ComponentIR
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
test('predicate reference lowers through the loop-bound local, never the filter-param vars-Hash fallback', () => {
|
|
347
|
+
const ir = compileToIR(DIFFERENTLY_NAMED_SOURCE)
|
|
348
|
+
const { template } = new ErbAdapter().generate(ir)
|
|
349
|
+
// The loop-gating `<if>` must reference the loop's actual bound Ruby
|
|
350
|
+
// local (`todo[:done]`, from the MAP callback's param)...
|
|
351
|
+
expect(template).toContain('todo[:done]')
|
|
352
|
+
// ...never the filter callback's own param name resolved as an
|
|
353
|
+
// (unseeded) vars-Hash key — the literal pre-fix bug.
|
|
354
|
+
expect(template).not.toContain('v[:t]')
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
test('same-named filter/map params render byte-identically to the pre-#2245 form (regression pin)', () => {
|
|
358
|
+
const sameNamedSource = DIFFERENTLY_NAMED_SOURCE.replace(
|
|
359
|
+
'filter(t => !t.done)',
|
|
360
|
+
'filter(todo => !todo.done)',
|
|
361
|
+
)
|
|
362
|
+
const ir = compileToIR(sameNamedSource)
|
|
363
|
+
const { template } = new ErbAdapter().generate(ir)
|
|
364
|
+
expect(template).toContain('<%- if bf.truthy?(!bf.truthy?(todo[:done])) -%>')
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('real Ruby render: reachable predicate on differently-named params renders correctly (pre-fix NoMethodError pin)', async () => {
|
|
368
|
+
// `filter` defaults to `'active'` (never `'all'`) so the predicate
|
|
369
|
+
// branch referencing `t.done` is actually REACHABLE at render time —
|
|
370
|
+
// an `'all'`-style short-circuiting default is exactly what hid this
|
|
371
|
+
// bug in the shipped `todo-app-ssr` fixture. Block-body predicate
|
|
372
|
+
// (folded to one expression by #2040's `foldBlockToExpr` +
|
|
373
|
+
// `predicateTernaryToLogical`) matches the real `TodoAppSSR.tsx` shape.
|
|
374
|
+
const source = `
|
|
375
|
+
'use client'
|
|
376
|
+
import { createSignal } from '@barefootjs/client'
|
|
377
|
+
|
|
378
|
+
type Todo = { id: number; text: string; done: boolean }
|
|
379
|
+
type Filter = 'all' | 'active'
|
|
380
|
+
|
|
381
|
+
export function TodoList(props: { initialTodos?: Todo[] }) {
|
|
382
|
+
const [todos] = createSignal<Todo[]>(props.initialTodos ?? [])
|
|
383
|
+
const [filter] = createSignal<Filter>('active')
|
|
384
|
+
return (
|
|
385
|
+
<ul>
|
|
386
|
+
{todos().filter(t => {
|
|
387
|
+
const f = filter()
|
|
388
|
+
if (f === 'active') return !t.done
|
|
389
|
+
return true
|
|
390
|
+
}).map(todo => (
|
|
391
|
+
<li key={todo.id}>{todo.text}</li>
|
|
392
|
+
))}
|
|
393
|
+
</ul>
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
`
|
|
397
|
+
let html: string
|
|
398
|
+
try {
|
|
399
|
+
html = await renderErbComponent({
|
|
400
|
+
source: source.trimStart(),
|
|
401
|
+
adapter: new ErbAdapter(),
|
|
402
|
+
props: {
|
|
403
|
+
initialTodos: [
|
|
404
|
+
{ id: 1, text: 'Eat breakfast', done: true },
|
|
405
|
+
{ id: 2, text: 'Write tests', done: false },
|
|
406
|
+
],
|
|
407
|
+
},
|
|
408
|
+
})
|
|
409
|
+
} catch (err) {
|
|
410
|
+
if (err instanceof ErbNotAvailableError) {
|
|
411
|
+
console.log('Skipping #2245 filter-param e2e: ruby/erb not available')
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
throw err
|
|
415
|
+
}
|
|
416
|
+
// Pre-fix: real Ruby raises `NoMethodError: undefined method '[]' for
|
|
417
|
+
// nil` evaluating `v[:t][:done]` — `renderErbComponent` surfaces that
|
|
418
|
+
// as a thrown "ruby render failed" error, so a `NoMethodError` string
|
|
419
|
+
// anywhere in a caught error would fail this test outright rather than
|
|
420
|
+
// reaching these assertions. Post-fix: only the not-done todo (id 2)
|
|
421
|
+
// survives the 'active' filter.
|
|
422
|
+
expect(html).not.toContain('Eat breakfast')
|
|
423
|
+
expect(html).toContain('Write tests')
|
|
424
|
+
expect(html).toContain('data-key="2"')
|
|
425
|
+
expect(html).not.toContain('data-key="1"')
|
|
426
|
+
})
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
describe('ErbAdapter - named-slot capture identifier safety (#2168 jsx-element-prop)', () => {
|
|
430
|
+
// A JSX-valued prop under a hyphenated name (`data-slot`, a valid JSX
|
|
431
|
+
// attribute name) must not leak into the buffer-slice capture's local
|
|
432
|
+
// variables — Ruby local variable names can't contain `-`. The capture
|
|
433
|
+
// suffix is purely counter-based (never derived from the prop name); the
|
|
434
|
+
// hash KEY passed to `render_child` still carries the real name, quoted
|
|
435
|
+
// via `rubySymbolKey`.
|
|
436
|
+
test('a hyphenated prop name does not appear in the capture variables', () => {
|
|
437
|
+
const { template } = compileAndGenerate(`
|
|
438
|
+
function Card(props) { return null }
|
|
439
|
+
export function Parent() {
|
|
440
|
+
return <Card data-slot={<strong>Title</strong>}>text</Card>
|
|
441
|
+
}
|
|
442
|
+
`)
|
|
443
|
+
expect(template).toContain('__bf_len_0 = _erbout.length')
|
|
444
|
+
expect(template).toContain('__bf_prop_0 = bf.backend.mark_raw(__bf_praw_0)')
|
|
445
|
+
expect(template).toContain('"data-slot": __bf_prop_0')
|
|
446
|
+
expect(template).not.toContain('bf_prop_data')
|
|
447
|
+
})
|
|
448
|
+
})
|