@barefootjs/erb 0.18.5 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -187308,7 +187308,12 @@ import {
187308
187308
  prepareLoweringMatchers,
187309
187309
  queryHrefArgs,
187310
187310
  isValidHelperId,
187311
- sortComparatorFromArrow as sortComparatorFromArrow2
187311
+ sortComparatorFromArrow as sortComparatorFromArrow2,
187312
+ isDangerousInnerHtmlAttr,
187313
+ resolveDangerousInnerHtml,
187314
+ dangerousInnerHtmlMetacharViolation,
187315
+ dangerousInnerHtmlDiagnostic,
187316
+ resolveStaticLoopSource
187312
187317
  } from "@barefootjs/jsx";
187313
187318
 
187314
187319
  // src/adapter/boolean-result.ts
@@ -187693,6 +187698,39 @@ function renderFlatMethod(recv, depth, emit) {
187693
187698
  return `bf.flat(${recv}, ${d})`;
187694
187699
  }
187695
187700
 
187701
+ // src/adapter/lib/static-value.ts
187702
+ function staticValueToRuby(value) {
187703
+ if (value === null || value === undefined)
187704
+ return "nil";
187705
+ if (typeof value === "boolean")
187706
+ return value ? "true" : "false";
187707
+ if (typeof value === "number")
187708
+ return String(value);
187709
+ if (typeof value === "string")
187710
+ return rubyStringLiteral(value);
187711
+ if (Array.isArray(value)) {
187712
+ const items = [];
187713
+ for (const el of value) {
187714
+ const serialized = staticValueToRuby(el);
187715
+ if (serialized === null)
187716
+ return null;
187717
+ items.push(serialized);
187718
+ }
187719
+ return `[${items.join(", ")}]`;
187720
+ }
187721
+ if (typeof value === "object") {
187722
+ const entries = [];
187723
+ for (const [key, val] of Object.entries(value)) {
187724
+ const serialized = staticValueToRuby(val);
187725
+ if (serialized === null)
187726
+ return null;
187727
+ entries.push(`${rubySymbolKey(key)} ${serialized}`);
187728
+ }
187729
+ return `{ ${entries.join(", ")} }`;
187730
+ }
187731
+ return null;
187732
+ }
187733
+
187696
187734
  // src/adapter/expr/emitters.ts
187697
187735
  import {
187698
187736
  emitParsedExpr,
@@ -187738,16 +187776,18 @@ class ErbFilterEmitter {
187738
187776
  isLoopBoundOuter;
187739
187777
  isStringName;
187740
187778
  onUnsupported;
187741
- constructor(param, localVarMap, isLoopBoundOuter = () => false, isStringName = unusedIsStringName, onUnsupported) {
187779
+ renderParamAs;
187780
+ constructor(param, localVarMap, isLoopBoundOuter = () => false, isStringName = unusedIsStringName, onUnsupported, renderParamAs = rubyLocal(param)) {
187742
187781
  this.param = param;
187743
187782
  this.localVarMap = localVarMap;
187744
187783
  this.isLoopBoundOuter = isLoopBoundOuter;
187745
187784
  this.isStringName = isStringName;
187746
187785
  this.onUnsupported = onUnsupported;
187786
+ this.renderParamAs = renderParamAs;
187747
187787
  }
187748
187788
  identifier(name) {
187749
187789
  if (name === this.param)
187750
- return rubyLocal(this.param);
187790
+ return this.renderParamAs;
187751
187791
  const signal = this.localVarMap.get(name);
187752
187792
  if (signal)
187753
187793
  return `v[${rubySymbolLiteral(signal)}]`;
@@ -188299,7 +188339,7 @@ function collectBooleanTypedProps(ir) {
188299
188339
  return new Set(ir.metadata.propsParams.filter((prop) => prop.type?.primitive === "boolean" || prop.type?.raw === "boolean").map((prop) => prop.name));
188300
188340
  }
188301
188341
  function collectNullableOptionalProps(ir) {
188302
- return new Set(ir.metadata.propsParams.filter((p) => p.defaultValue === undefined && !p.isRest && p.type?.kind !== "primitive").map((p) => p.name));
188342
+ return new Set(ir.metadata.propsParams.filter((p) => p.defaultValue === undefined && !p.isRest && (p.type?.kind !== "primitive" || p.optional)).map((p) => p.name));
188303
188343
  }
188304
188344
  function collectStringValueNames(ir) {
188305
188345
  const names = new Set;
@@ -188547,7 +188587,8 @@ class ErbAdapter extends BaseAdapter {
188547
188587
  renderElement(element) {
188548
188588
  const tag = element.tag;
188549
188589
  const attrs = this.renderAttributes(element);
188550
- const children = this.renderChildren(element.children);
188590
+ const dangerousHtml = this.renderDangerousInnerHtml(element);
188591
+ const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children);
188551
188592
  let hydrationAttrs = "";
188552
188593
  if (element.needsScope) {
188553
188594
  hydrationAttrs += ` ${this.renderScopeMarker("")}`;
@@ -188582,6 +188623,22 @@ class ErbAdapter extends BaseAdapter {
188582
188623
  }
188583
188624
  return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`;
188584
188625
  }
188626
+ renderDangerousInnerHtml(element) {
188627
+ const resolution = resolveDangerousInnerHtml(element);
188628
+ if (!resolution)
188629
+ return null;
188630
+ if (resolution.kind === "dynamic") {
188631
+ this.errors.push(dangerousInnerHtmlDiagnostic(resolution.expr, resolution.loc));
188632
+ return "";
188633
+ }
188634
+ const violation = dangerousInnerHtmlMetacharViolation(resolution.html, this.name);
188635
+ if (violation) {
188636
+ const attr = element.attrs.find(isDangerousInnerHtmlAttr);
188637
+ this.errors.push(dangerousInnerHtmlDiagnostic(`{ __html: ${JSON.stringify(resolution.html)} }`, attr.loc, violation));
188638
+ return "";
188639
+ }
188640
+ return resolution.html;
188641
+ }
188585
188642
  renderExpression(expr) {
188586
188643
  if (expr.clientOnly) {
188587
188644
  if (expr.slotId) {
@@ -188589,7 +188646,7 @@ class ErbAdapter extends BaseAdapter {
188589
188646
  }
188590
188647
  return "";
188591
188648
  }
188592
- const rubyExpr = this.convertExpressionToRuby(expr.expr);
188649
+ const rubyExpr = this.convertExpressionToRuby(expr.expr, expr.parsed);
188593
188650
  const wrapped = this.isChildrenValueExpr(expr) ? rubyExpr : `bf.h(${rubyExpr})`;
188594
188651
  if (expr.slotId) {
188595
188652
  return `<%= bf.text_start("${expr.slotId}") %><%= ${wrapped} %><%= bf.text_end %>`;
@@ -188698,7 +188755,11 @@ ${whenTrue}
188698
188755
  }
188699
188756
  });
188700
188757
  }
188701
- if (loop.arrayParsed?.kind === "identifier") {
188758
+ const staticItems = resolveStaticLoopSource(loop.arrayParsed, this.localConstants, {
188759
+ isNameShadowed: (name) => this.loopBoundNames.has(name)
188760
+ });
188761
+ const staticArray = staticItems !== null ? staticValueToRuby(staticItems) : null;
188762
+ if (staticArray === null && loop.arrayParsed?.kind === "identifier") {
188702
188763
  const arrayName = loop.arrayParsed.name;
188703
188764
  const isUnresolvableLocalConst = !this.loopBoundNames.has(arrayName) && this.resolveModuleStringConst(arrayName) === null && this.resolveLiteralConst(arrayName) === null && this.localConstants.some((c) => c.name === arrayName && !c.isModule);
188704
188765
  if (isUnresolvableLocalConst) {
@@ -188708,7 +188769,7 @@ ${whenTrue}
188708
188769
  3. Precompute the value server-side and pass it in as a prop.`);
188709
188770
  }
188710
188771
  }
188711
- const rawArray = this.convertExpressionToRuby(loop.array);
188772
+ const rawArray = staticArray ?? this.convertExpressionToRuby(loop.array);
188712
188773
  let sortedHoist = null;
188713
188774
  let array = rawArray;
188714
188775
  if (loop.sortComparator) {
@@ -188788,7 +188849,9 @@ ${renderedChildren}` : renderedChildren;
188788
188849
  if (loop.filterPredicate) {
188789
188850
  let filterCond;
188790
188851
  if (loop.filterPredicate.predicate) {
188791
- filterCond = this.renderRubyFilterExpr(loop.filterPredicate.predicate, param);
188852
+ const filterOwnParam = loop.filterPredicate.param;
188853
+ const matchParam = filterOwnParam && !filterOwnParam.startsWith("[") && !filterOwnParam.startsWith("{") ? filterOwnParam : param;
188854
+ filterCond = this.renderRubyFilterExpr(loop.filterPredicate.predicate, matchParam, undefined, rubyLocal(param));
188792
188855
  } else {
188793
188856
  filterCond = "true";
188794
188857
  }
@@ -189009,6 +189072,8 @@ ${children}`;
189009
189072
  for (const attr of element.attrs) {
189010
189073
  if (attr.clientOnly)
189011
189074
  continue;
189075
+ if (isDangerousInnerHtmlAttr(attr))
189076
+ continue;
189012
189077
  let attrName;
189013
189078
  if (attr.name === "className")
189014
189079
  attrName = "class";
@@ -189032,8 +189097,8 @@ ${children}`;
189032
189097
  renderCondMarker(condId) {
189033
189098
  return `${BF_COND}="${condId}"`;
189034
189099
  }
189035
- renderRubyFilterExpr(expr, param, localVarMap = new Map) {
189036
- return emitParsedExpr2(expr, new ErbFilterEmitter(param, localVarMap, (n) => this.isLoopBoundName(n), (n) => this._isStringValueName(n), (message, reason) => this._recordExprBF101(message, reason)));
189100
+ renderRubyFilterExpr(expr, param, localVarMap = new Map, renderParamAs) {
189101
+ return emitParsedExpr2(expr, new ErbFilterEmitter(param, localVarMap, (n) => this.isLoopBoundName(n), (n) => this._isStringValueName(n), (message, reason) => this._recordExprBF101(message, reason), renderParamAs));
189037
189102
  }
189038
189103
  convertTemplateLiteralPartsToRuby(literalParts) {
189039
189104
  const parts = [];
@@ -189194,21 +189259,16 @@ Options:
189194
189259
  var erbAdapter = new ErbAdapter;
189195
189260
  // src/conformance-pins.ts
189196
189261
  var conformancePins = {
189197
- "static-array-children": [{ code: "BF103", severity: "error" }],
189198
- "todo-app": [{ code: "BF103", severity: "error" }],
189199
- "todo-app-ssr": [{ code: "BF103", severity: "error" }],
189200
189262
  "static-array-from-props": [
189201
189263
  { code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2087" }
189202
189264
  ],
189203
189265
  "static-array-from-props-with-component": [
189204
- { code: "BF103", severity: "error" },
189205
189266
  { code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2087" }
189206
189267
  ],
189207
189268
  "filter-nested-find-predicate": [
189208
189269
  { code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2038" }
189209
189270
  ],
189210
- "array-map-function-reference": [{ code: "BF101", severity: "error" }],
189211
- "dangerous-inner-html": [{ code: "BF101", severity: "error" }]
189271
+ "dangerous-inner-html-dynamic": [{ code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2215" }]
189212
189272
  };
189213
189273
  // src/render-divergences.ts
189214
189274
  var renderDivergences = {};
@@ -1 +1 @@
1
- {"version":3,"file":"render-divergences.d.ts","sourceRoot":"","sources":["../src/render-divergences.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAExD,eAAO,MAAM,iBAAiB,EAAE,iBAAsB,CAAA"}
1
+ {"version":3,"file":"render-divergences.d.ts","sourceRoot":"","sources":["../src/render-divergences.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAExD,eAAO,MAAM,iBAAiB,EAAE,iBAK/B,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/erb",
3
- "version": "0.18.5",
3
+ "version": "0.19.0",
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.5"
57
+ "@barefootjs/shared": "0.19.0"
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.5",
64
+ "@barefootjs/jsx": "0.19.0",
65
65
  "typescript": "^5.0.0"
66
66
  }
67
67
  }
@@ -70,6 +70,23 @@ runAdapterConformanceTests({
70
70
  // #1467 Phase 2e: same `/* @client */` keyed-map elision (data-table).
71
71
  'data-table',
72
72
  ]),
73
+ skipDataPoints: new Set<string>([
74
+ // #2255 — Ruby String#length counts codepoints; JS counts UTF-16
75
+ // code units, so a surrogate-pair character is 2 in JS, 1 here.
76
+ 'string-length-text:astral',
77
+ // #2260 — controlled boolean props: the SSR seed evaluates only the
78
+ // static fallback of `props.X ?? internal()` chains.
79
+ 'toggle:gen:pressed:true',
80
+ 'switch:gen:checked:true',
81
+ 'checkbox:gen:checked:true',
82
+ // #2261 — invalid dynamic CSS value kept (escaped) where the oracle
83
+ // drops the property.
84
+ 'style-object-dynamic:gen:color:markup',
85
+ // #2262 — dynamic `.flat` depth 0/negative: the unflattened nested
86
+ // result stringifies Ruby-style instead of the JS join.
87
+ 'array-flat-dynamic-depth:gen:depth:zero',
88
+ 'array-flat-dynamic-depth:gen:depth:negative',
89
+ ]),
73
90
  onRenderError: (err, id) => {
74
91
  if (err instanceof ErbNotAvailableError) {
75
92
  console.log(`Skipping [${id}]: ${err.message}`)
@@ -305,6 +322,127 @@ export { Slot }
305
322
  })
306
323
  })
307
324
 
325
+ describe('ErbAdapter - filter().map() predicate matches the FILTER param, not the loop param (#2245)', () => {
326
+ // `todos.filter(t => t.done).map(todo => ...)`: the predicate's own `t`
327
+ // used to be matched against the LOOP's (map's) param `todo` inside
328
+ // `ErbFilterEmitter.identifier()`, so every reference to `t` inside the
329
+ // predicate fell to the `v[:t]` vars-Hash fallback instead of resolving
330
+ // to the loop-bound `todo` local — `v[:t]` is never seeded, and real
331
+ // Ruby raises `NoMethodError: undefined method '[]' for nil` on
332
+ // `v[:t][:done]` at render time (masked in the shipped `todo-app-ssr`
333
+ // corpus by its `'all'`-default filter short-circuiting the buggy
334
+ // branch away — see `filter-wrapper-props-reachable`'s docstring).
335
+ const DIFFERENTLY_NAMED_SOURCE = `
336
+ 'use client'
337
+ import { createSignal } from '@barefootjs/client'
338
+
339
+ type Todo = { id: number; text: string; done: boolean }
340
+
341
+ export function TodoList(props: { initialTodos?: Todo[] }) {
342
+ const [todos] = createSignal<Todo[]>(props.initialTodos ?? [])
343
+ return (
344
+ <ul>
345
+ {todos().filter(t => !t.done).map(todo => (
346
+ <li key={todo.id}>{todo.text}</li>
347
+ ))}
348
+ </ul>
349
+ )
350
+ }
351
+ `
352
+
353
+ function compileToIR(source: string): ComponentIR {
354
+ const result = compileJSX(source.trimStart(), 'test.tsx', {
355
+ adapter: new ErbAdapter(),
356
+ outputIR: true,
357
+ })
358
+ const irFile = result.files.find(f => f.type === 'ir')
359
+ if (!irFile) throw new Error('No IR output')
360
+ return JSON.parse(irFile.content) as ComponentIR
361
+ }
362
+
363
+ test('predicate reference lowers through the loop-bound local, never the filter-param vars-Hash fallback', () => {
364
+ const ir = compileToIR(DIFFERENTLY_NAMED_SOURCE)
365
+ const { template } = new ErbAdapter().generate(ir)
366
+ // The loop-gating `<if>` must reference the loop's actual bound Ruby
367
+ // local (`todo[:done]`, from the MAP callback's param)...
368
+ expect(template).toContain('todo[:done]')
369
+ // ...never the filter callback's own param name resolved as an
370
+ // (unseeded) vars-Hash key — the literal pre-fix bug.
371
+ expect(template).not.toContain('v[:t]')
372
+ })
373
+
374
+ test('same-named filter/map params render byte-identically to the pre-#2245 form (regression pin)', () => {
375
+ const sameNamedSource = DIFFERENTLY_NAMED_SOURCE.replace(
376
+ 'filter(t => !t.done)',
377
+ 'filter(todo => !todo.done)',
378
+ )
379
+ const ir = compileToIR(sameNamedSource)
380
+ const { template } = new ErbAdapter().generate(ir)
381
+ expect(template).toContain('<%- if bf.truthy?(!bf.truthy?(todo[:done])) -%>')
382
+ })
383
+
384
+ test('real Ruby render: reachable predicate on differently-named params renders correctly (pre-fix NoMethodError pin)', async () => {
385
+ // `filter` defaults to `'active'` (never `'all'`) so the predicate
386
+ // branch referencing `t.done` is actually REACHABLE at render time —
387
+ // an `'all'`-style short-circuiting default is exactly what hid this
388
+ // bug in the shipped `todo-app-ssr` fixture. Block-body predicate
389
+ // (folded to one expression by #2040's `foldBlockToExpr` +
390
+ // `predicateTernaryToLogical`) matches the real `TodoAppSSR.tsx` shape.
391
+ const source = `
392
+ 'use client'
393
+ import { createSignal } from '@barefootjs/client'
394
+
395
+ type Todo = { id: number; text: string; done: boolean }
396
+ type Filter = 'all' | 'active'
397
+
398
+ export function TodoList(props: { initialTodos?: Todo[] }) {
399
+ const [todos] = createSignal<Todo[]>(props.initialTodos ?? [])
400
+ const [filter] = createSignal<Filter>('active')
401
+ return (
402
+ <ul>
403
+ {todos().filter(t => {
404
+ const f = filter()
405
+ if (f === 'active') return !t.done
406
+ return true
407
+ }).map(todo => (
408
+ <li key={todo.id}>{todo.text}</li>
409
+ ))}
410
+ </ul>
411
+ )
412
+ }
413
+ `
414
+ let html: string
415
+ try {
416
+ html = await renderErbComponent({
417
+ source: source.trimStart(),
418
+ adapter: new ErbAdapter(),
419
+ props: {
420
+ initialTodos: [
421
+ { id: 1, text: 'Eat breakfast', done: true },
422
+ { id: 2, text: 'Write tests', done: false },
423
+ ],
424
+ },
425
+ })
426
+ } catch (err) {
427
+ if (err instanceof ErbNotAvailableError) {
428
+ console.log('Skipping #2245 filter-param e2e: ruby/erb not available')
429
+ return
430
+ }
431
+ throw err
432
+ }
433
+ // Pre-fix: real Ruby raises `NoMethodError: undefined method '[]' for
434
+ // nil` evaluating `v[:t][:done]` — `renderErbComponent` surfaces that
435
+ // as a thrown "ruby render failed" error, so a `NoMethodError` string
436
+ // anywhere in a caught error would fail this test outright rather than
437
+ // reaching these assertions. Post-fix: only the not-done todo (id 2)
438
+ // survives the 'active' filter.
439
+ expect(html).not.toContain('Eat breakfast')
440
+ expect(html).toContain('Write tests')
441
+ expect(html).toContain('data-key="2"')
442
+ expect(html).not.toContain('data-key="1"')
443
+ })
444
+ })
445
+
308
446
  describe('ErbAdapter - named-slot capture identifier safety (#2168 jsx-element-prop)', () => {
309
447
  // A JSX-valued prop under a hyphenated name (`data-slot`, a valid JSX
310
448
  // attribute name) must not leak into the buffer-slice capture's local
@@ -88,6 +88,11 @@ import {
88
88
  queryHrefArgs,
89
89
  isValidHelperId,
90
90
  sortComparatorFromArrow,
91
+ isDangerousInnerHtmlAttr,
92
+ resolveDangerousInnerHtml,
93
+ dangerousInnerHtmlMetacharViolation,
94
+ dangerousInnerHtmlDiagnostic,
95
+ resolveStaticLoopSource,
91
96
  } from '@barefootjs/jsx'
92
97
  import { isAriaBooleanAttr, isBooleanResultExpr, isExplicitStringCall } from './boolean-result.ts'
93
98
  import type { ParsedExpr, LoweringMatcher, LoopBindingPathSegment } from '@barefootjs/jsx'
@@ -107,6 +112,7 @@ import {
107
112
  collectRootScopeNodes,
108
113
  } from './lib/ir-scope.ts'
109
114
  import { renderSortMethod, renderSortEval } from './expr/array-method.ts'
115
+ import { staticValueToRuby } from './lib/static-value.ts'
110
116
  import { ErbFilterEmitter, ErbTopLevelEmitter } from './expr/emitters.ts'
111
117
  import type { ErbEmitContext, ErbSpreadContext, ErbMemoContext } from './emit-context.ts'
112
118
  import {
@@ -636,7 +642,8 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
636
642
  renderElement(element: IRElement): string {
637
643
  const tag = element.tag
638
644
  const attrs = this.renderAttributes(element)
639
- const children = this.renderChildren(element.children)
645
+ const dangerousHtml = this.renderDangerousInnerHtml(element)
646
+ const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children)
640
647
 
641
648
  let hydrationAttrs = ''
642
649
  if (element.needsScope) {
@@ -673,6 +680,28 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
673
680
  return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
674
681
  }
675
682
 
683
+ /**
684
+ * `dangerouslySetInnerHTML={{ __html: '...' }}` (#2207) — see the Blade
685
+ * adapter's identical helper for the full rationale. `null` means the
686
+ * attribute is absent (caller falls through to normal `renderChildren`);
687
+ * a non-`null` string (possibly `''`) replaces the children outright.
688
+ */
689
+ private renderDangerousInnerHtml(element: IRElement): string | null {
690
+ const resolution = resolveDangerousInnerHtml(element)
691
+ if (!resolution) return null
692
+ if (resolution.kind === 'dynamic') {
693
+ this.errors.push(dangerousInnerHtmlDiagnostic(resolution.expr, resolution.loc))
694
+ return ''
695
+ }
696
+ const violation = dangerousInnerHtmlMetacharViolation(resolution.html, this.name)
697
+ if (violation) {
698
+ const attr = element.attrs.find(isDangerousInnerHtmlAttr)!
699
+ this.errors.push(dangerousInnerHtmlDiagnostic(`{ __html: ${JSON.stringify(resolution.html)} }`, attr.loc, violation))
700
+ return ''
701
+ }
702
+ return resolution.html
703
+ }
704
+
676
705
  // ===========================================================================
677
706
  // Expression Rendering
678
707
  // ===========================================================================
@@ -685,7 +714,12 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
685
714
  return ''
686
715
  }
687
716
 
688
- const rubyExpr = this.convertExpressionToRuby(expr.expr)
717
+ // Thread the IR-carried `.parsed` tree through (mirrors go-template's
718
+ // `convertExpressionToGo(expr.expr, classify, expr.parsed)`) so a
719
+ // resolved bare-identifier `.map`/`.filter`/… callback
720
+ // (`resolveCallbackMethodFunctionReferences`, #2206) isn't lost to a
721
+ // fresh, unresolved re-parse of the raw string.
722
+ const rubyExpr = this.convertExpressionToRuby(expr.expr, expr.parsed)
689
723
 
690
724
  // A bare read of the `children` prop (`{children}` / `{props.children}`,
691
725
  // optionally `?? fallback`) is pre-rendered HTML — captured via the
@@ -869,7 +903,23 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
869
903
  // reproduces identically with a non-destructured param, so it is NOT a
870
904
  // destructure-lowering limitation. Surface BF101 honestly instead of
871
905
  // emitting a loop bound that silently crashes / renders empty.
872
- if (loop.arrayParsed?.kind === 'identifier') {
906
+ // #2208: a loop source that is a fully-static array literal — either
907
+ // inline (`[{ label: 'Alpha' }, ...].map(...)`) or a bare identifier
908
+ // bound to a FUNCTION-scope local const whose initializer has no
909
+ // prop/signal/function-call dependency — inlines as a native Ruby
910
+ // array/hash literal below, the same way a module-scope const's value
911
+ // is already seeded. Previously the INLINE shape wasn't gated here at
912
+ // all (this check only ever inspected an `identifier` array source) —
913
+ // it still ended up refusing via `convertExpressionToRuby`'s generic
914
+ // `unsupported` object-literal path (BF101, "Expression not
915
+ // supported"), which is what this loop-specific check now also does
916
+ // deliberately, up front, for both shapes.
917
+ const staticItems = resolveStaticLoopSource(loop.arrayParsed, this.localConstants, {
918
+ isNameShadowed: name => this.loopBoundNames.has(name),
919
+ })
920
+ const staticArray = staticItems !== null ? staticValueToRuby(staticItems) : null
921
+
922
+ if (staticArray === null && loop.arrayParsed?.kind === 'identifier') {
873
923
  const arrayName = loop.arrayParsed.name
874
924
  const isUnresolvableLocalConst =
875
925
  !this.loopBoundNames.has(arrayName) &&
@@ -884,7 +934,7 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
884
934
  }
885
935
  }
886
936
 
887
- const rawArray = this.convertExpressionToRuby(loop.array)
937
+ const rawArray = staticArray ?? this.convertExpressionToRuby(loop.array)
888
938
  // Apply sort if present: hoist the (possibly sorted) array into a Ruby
889
939
  // local BEFORE the index loop, so both the loop bound and the per-item
890
940
  // lookup reference the same materialised array — otherwise a sort
@@ -1040,14 +1090,31 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1040
1090
  if (loop.filterPredicate) {
1041
1091
  let filterCond: string
1042
1092
  if (loop.filterPredicate.predicate) {
1043
- // The loop's own (possibly destructure-adjusted) param name IS the
1044
- // filter predicate's parameter for rendering purposes — pass it
1045
- // straight through as the filter emitter's bound param. Ruby's
1046
- // named-block-param model makes the Mojo original's regex
1047
- // `$filterParam $loopParam` rename unnecessary here: the emitter
1048
- // just renders every reference to the predicate's own arrow
1049
- // parameter AS `param` from the start.
1050
- filterCond = this.renderRubyFilterExpr(loop.filterPredicate.predicate, param)
1093
+ // The filter predicate's identifiers were parsed against the
1094
+ // FILTER callback's own param (`loop.filterPredicate.param`), which
1095
+ // can differ from the loop's rendered Ruby local (`param`, the MAP
1096
+ // callback's param) whenever the two are named differently —
1097
+ // `todos.filter(t => t.done).map(todo => ...)` (#2245). Ruby's
1098
+ // named-block-param model means there's no Mojo-style regex
1099
+ // `$filterParam $loopParam` TEXT rewrite to do, but the
1100
+ // DISTINCTION it encoded still matters: match identifiers against
1101
+ // the filter's own param, while EMITTING the loop's actual bound
1102
+ // local — `ErbFilterEmitter`'s `renderParamAs` carries that split.
1103
+ // `filterPredicate.param` is only ever a bare identifier in
1104
+ // practice (`extractFilterPredicate` in jsx-to-ir.ts refuses a
1105
+ // destructured filter param outright, leaving `filterPredicate`
1106
+ // unset entirely rather than populating it with pattern text — see
1107
+ // its docstring), but guard defensively instead of relying on that
1108
+ // invariant: a pattern-text param (leading `[`/`{` — the same
1109
+ // prefix check `destructureLoopParam`/#2238 use, NOT an identifier
1110
+ // regex, which would misclassify a Unicode param name) falls back
1111
+ // to `param`, matching pre-#2245 behavior byte-for-byte.
1112
+ const filterOwnParam = loop.filterPredicate.param
1113
+ const matchParam =
1114
+ filterOwnParam && !filterOwnParam.startsWith('[') && !filterOwnParam.startsWith('{')
1115
+ ? filterOwnParam
1116
+ : param
1117
+ filterCond = this.renderRubyFilterExpr(loop.filterPredicate.predicate, matchParam, undefined, rubyLocal(param))
1051
1118
  } else {
1052
1119
  filterCond = 'true'
1053
1120
  }
@@ -1509,6 +1576,12 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1509
1576
  // the unsupported-expression lowering is never reached for a
1510
1577
  // deferred predicate (no BF101 / BF102).
1511
1578
  if (attr.clientOnly) continue
1579
+ // `dangerouslySetInnerHTML` never renders as an HTML attribute — it's
1580
+ // handled by `renderDangerousInnerHtml` instead, which replaces the
1581
+ // element's children. Skip it here so its `{ __html: ... }` object
1582
+ // literal never reaches the generic object-literal BF101 refusal
1583
+ // (which would double-report alongside the purpose-built one).
1584
+ if (isDangerousInnerHtmlAttr(attr)) continue
1512
1585
  // Rewrite JSX special-prop names to their HTML-attribute
1513
1586
  // counterparts. `className` → `class`; `key` → `data-key` matches
1514
1587
  // the canonical Hono attribute name the client runtime reconciles
@@ -1563,6 +1636,14 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1563
1636
  expr: ParsedExpr,
1564
1637
  param: string,
1565
1638
  localVarMap: Map<string, string> = new Map(),
1639
+ // See `ErbFilterEmitter`'s constructor docstring (#2245): the Ruby
1640
+ // local to EMIT for a reference to `param`, when it differs from
1641
+ // `param` itself (the loop-gating call site passes the filter
1642
+ // callback's own param as `param` — the name to MATCH — and this as
1643
+ // the loop's actual bound local). Every other caller omits it, so
1644
+ // `ErbFilterEmitter`'s own default (`rubyLocal(param)`) applies and
1645
+ // match/render stay the same value, unchanged from before #2245.
1646
+ renderParamAs?: string,
1566
1647
  ): string {
1567
1648
  return emitParsedExpr(
1568
1649
  expr,
@@ -1572,6 +1653,7 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1572
1653
  n => this.isLoopBoundName(n),
1573
1654
  n => this._isStringValueName(n),
1574
1655
  (message, reason) => this._recordExprBF101(message, reason),
1656
+ renderParamAs,
1575
1657
  ),
1576
1658
  )
1577
1659
  }
@@ -106,10 +106,23 @@ export class ErbFilterEmitter implements ParsedExprEmitter {
106
106
  // construction stays possible without an adapter; a missing hook keeps
107
107
  // the old silent-degrade emit.
108
108
  private readonly onUnsupported?: (message: string, reason?: string) => void,
109
+ // The Ruby local to EMIT for a reference matching `this.param` — as
110
+ // opposed to `this.param` itself, which is only the name to MATCH.
111
+ // These two are the SAME value everywhere in this file except the
112
+ // `filter().map()` loop-gating `<if>` (erb-adapter.ts's `renderLoop`,
113
+ // #2245): `todos.filter(t => t.done).map(todo => ...)` parses the
114
+ // predicate against the filter callback's OWN param (`t`), but the
115
+ // Ruby local actually bound by the loop is the MAP callback's param
116
+ // (`todo`) — Ruby has no per-callback block scope there (unlike the
117
+ // real nested `.select { |t| ... }` block `callbackMethod` below
118
+ // builds, where match and render are naturally the same param).
119
+ // Defaults to `rubyLocal(this.param)`, i.e. every other construction
120
+ // site is unaffected.
121
+ private readonly renderParamAs: string = rubyLocal(param),
109
122
  ) {}
110
123
 
111
124
  identifier(name: string): string {
112
- if (name === this.param) return rubyLocal(this.param)
125
+ if (name === this.param) return this.renderParamAs
113
126
  const signal = this.localVarMap.get(name)
114
127
  if (signal) return `v[${rubySymbolLiteral(signal)}]`
115
128
  if (this.isLoopBoundOuter(name)) return rubyLocal(name)
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Serialize a compile-time-evaluated JS value (`@barefootjs/jsx`'s
3
+ * `evaluateStaticLiteral`/`resolveStaticLoopSource`, #2208) into a native
4
+ * Ruby literal. Used to inline a fully-static loop source (an inline array
5
+ * literal, or a function-scope local const with a static initializer)
6
+ * directly in the loop-bound expression, rather than requiring a bound
7
+ * template variable.
8
+ *
9
+ * Hash keys render as symbols (`label: 'Alpha'`) to match `item[:label]`,
10
+ * this adapter's existing member-access convention (`rubyLocal`'s
11
+ * companion, `rubySymbolKey`/`rubySymbolLiteral`).
12
+ *
13
+ * Returns `null` for a value this adapter can't represent as a literal —
14
+ * the caller falls back to its existing BF101 refusal instead of guessing.
15
+ */
16
+
17
+ import { rubyStringLiteral, rubySymbolKey } from './ruby-naming.ts'
18
+
19
+ export function staticValueToRuby(value: unknown): string | null {
20
+ if (value === null || value === undefined) return 'nil'
21
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
22
+ if (typeof value === 'number') return String(value)
23
+ if (typeof value === 'string') return rubyStringLiteral(value)
24
+ if (Array.isArray(value)) {
25
+ const items: string[] = []
26
+ for (const el of value) {
27
+ const serialized = staticValueToRuby(el)
28
+ if (serialized === null) return null
29
+ items.push(serialized)
30
+ }
31
+ return `[${items.join(', ')}]`
32
+ }
33
+ if (typeof value === 'object') {
34
+ const entries: string[] = []
35
+ for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
36
+ const serialized = staticValueToRuby(val)
37
+ if (serialized === null) return null
38
+ entries.push(`${rubySymbolKey(key)} ${serialized}`)
39
+ }
40
+ return `{ ${entries.join(', ')} }`
41
+ }
42
+ return null
43
+ }
@@ -43,10 +43,11 @@ export function collectBooleanTypedProps(ir: ComponentIR): Set<string> {
43
43
  * omission). A prop WITH a destructure default (`value = ''`) is never
44
44
  * `nil` in the body and must stay unconditional, so it is excluded. Mirrors
45
45
  * the Go adapter's nillable-field guard: there the witness is the resolved
46
- * `interface{}` field type; here it is the absence of a default. Excludes
47
- * concrete-primitive types (`string`/`number`/`boolean`) to match the Go
48
- * adapter's scope, which guards only nillable fields and leaves concrete
49
- * fields unconditional.
46
+ * `interface{}` field type; here it is the absence of a default. A REQUIRED
47
+ * concrete-primitive prop (`string`/`number`/`boolean`) is excluded the
48
+ * caller always supplies it, matching the Go adapter's unconditional
49
+ * concrete fields — but an OPTIONAL primitive is presence-uncertain and
50
+ * stays guarded (#2259).
50
51
  */
51
52
  export function collectNullableOptionalProps(ir: ComponentIR): Set<string> {
52
53
  return new Set(
@@ -55,7 +56,7 @@ export function collectNullableOptionalProps(ir: ComponentIR): Set<string> {
55
56
  p =>
56
57
  p.defaultValue === undefined &&
57
58
  !p.isRest &&
58
- p.type?.kind !== 'primitive',
59
+ (p.type?.kind !== 'primitive' || p.optional),
59
60
  )
60
61
  .map(p => p.name),
61
62
  )