@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.
@@ -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?)`. Mirrors the Go/Perl `bf_slice` /
567
- # `slice` arithmetic so adapter output stays symmetric.
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 [] if len.zero?
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 [] if s >= e
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('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&#34;').gsub("'", '&#39;')
983
1088
  end
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/erb",
3
- "version": "0.18.4",
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.4"
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.4",
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
+ })