@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.
@@ -88,10 +88,15 @@ 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'
94
- import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
99
+ import { BF_SLOT, BF_COND, BF_REGION, escapeHtml } from '@barefootjs/shared'
95
100
 
96
101
  import type { ErbRenderCtx } from './lib/types.ts'
97
102
  import { ERB_PRIMITIVE_EMIT_MAP } from './lib/constants.ts'
@@ -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 {
@@ -177,6 +183,14 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
177
183
  private options: Required<ErbAdapterOptions>
178
184
  private errors: CompilerError[] = []
179
185
  private inLoop: boolean = false
186
+ /**
187
+ * `IRLoop.depth` of the loop currently being rendered (save/restore
188
+ * around `renderChildren(loop.children)`, mirroring `inLoop` above).
189
+ * `renderAttributes` reads this to derive the `key` → `data-key`/
190
+ * `data-key-N` suffix — the depth is IR-computed (jsx-to-ir.ts), not
191
+ * re-derived here (#2168 nested-loop-outer-binding).
192
+ */
193
+ private currentLoopKeyDepth = 0
180
194
  /**
181
195
  * SolidJS-style props identifier (`function(props: P)`) and the
182
196
  * analyzer-extracted prop names. Stashed at `generate()` entry so the
@@ -499,7 +513,9 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
499
513
  }
500
514
 
501
515
  emitText(node: IRText): string {
502
- return node.value
516
+ // IRText carries the entity-DECODED value (Phase 1 decodes JSX
517
+ // character references); re-escape for direct HTML emission.
518
+ return escapeHtml(node.value)
503
519
  }
504
520
 
505
521
  emitExpression(node: IRExpression): string {
@@ -626,7 +642,8 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
626
642
  renderElement(element: IRElement): string {
627
643
  const tag = element.tag
628
644
  const attrs = this.renderAttributes(element)
629
- const children = this.renderChildren(element.children)
645
+ const dangerousHtml = this.renderDangerousInnerHtml(element)
646
+ const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children)
630
647
 
631
648
  let hydrationAttrs = ''
632
649
  if (element.needsScope) {
@@ -663,6 +680,28 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
663
680
  return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
664
681
  }
665
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
+
666
705
  // ===========================================================================
667
706
  // Expression Rendering
668
707
  // ===========================================================================
@@ -675,7 +714,12 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
675
714
  return ''
676
715
  }
677
716
 
678
- 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)
679
723
 
680
724
  // A bare read of the `children` prop (`{children}` / `{props.children}`,
681
725
  // optionally `?? fallback`) is pre-rendered HTML — captured via the
@@ -859,7 +903,23 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
859
903
  // reproduces identically with a non-destructured param, so it is NOT a
860
904
  // destructure-lowering limitation. Surface BF101 honestly instead of
861
905
  // emitting a loop bound that silently crashes / renders empty.
862
- 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') {
863
923
  const arrayName = loop.arrayParsed.name
864
924
  const isUnresolvableLocalConst =
865
925
  !this.loopBoundNames.has(arrayName) &&
@@ -874,7 +934,7 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
874
934
  }
875
935
  }
876
936
 
877
- const rawArray = this.convertExpressionToRuby(loop.array)
937
+ const rawArray = staticArray ?? this.convertExpressionToRuby(loop.array)
878
938
  // Apply sort if present: hoist the (possibly sorted) array into a Ruby
879
939
  // local BEFORE the index loop, so both the loop bound and the per-item
880
940
  // lookup reference the same materialised array — otherwise a sort
@@ -896,17 +956,22 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
896
956
  // whole body (children + key + filter) so a same-named loop variable
897
957
  // isn't replaced by the const literal / a vars-Hash read. Ref-counted
898
958
  // for nested loops; released after the body lines are assembled below.
899
- const loopBound = loop.iterationShape === 'keys'
900
- ? [param]
901
- : supportableDestructure
902
- ? ['__bf_item', ...(loop.paramBindings ?? []).map(b => b.name), loop.index ?? '_i']
903
- : [param, loop.index ?? '_i']
959
+ const loopBound = loop.objectIteration === 'entries'
960
+ ? [param, loop.index ?? '_k']
961
+ : loop.objectIteration === 'keys' || loop.objectIteration === 'values' || loop.iterationShape === 'keys'
962
+ ? [param]
963
+ : supportableDestructure
964
+ ? ['__bf_item', ...(loop.paramBindings ?? []).map(b => b.name), loop.index ?? '_i']
965
+ : [param, loop.index ?? '_i']
904
966
  for (const n of loopBound) {
905
967
  this.loopBoundNames.set(n, (this.loopBoundNames.get(n) ?? 0) + 1)
906
968
  }
907
969
  const prevInLoop = this.inLoop
908
970
  this.inLoop = true
971
+ const prevLoopKeyDepth = this.currentLoopKeyDepth
972
+ this.currentLoopKeyDepth = loop.depth
909
973
  const renderedChildren = this.renderChildren(loop.children)
974
+ this.currentLoopKeyDepth = prevLoopKeyDepth
910
975
  this.inLoop = prevInLoop
911
976
 
912
977
  // Whole-item conditional: prepend an always-present
@@ -966,6 +1031,21 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
966
1031
  }
967
1032
  lines.push(`<%- ${sortedHoist} = ${sorted} -%>`)
968
1033
  }
1034
+ if (loop.objectIteration) {
1035
+ // `objectIteration` (#2168 object-entries-map): Ruby's `Hash`
1036
+ // preserves the source object's insertion order natively (unlike
1037
+ // Go's `map`/Perl's hash), so this bypasses the index-range form
1038
+ // above entirely and uses Ruby's own native block-param binding
1039
+ // (`each_pair`/`each_key`/`each_value`) — no `array[index]` lookup
1040
+ // needed, since the block directly yields the key/value.
1041
+ const method = loop.objectIteration === 'entries'
1042
+ ? 'each_pair'
1043
+ : loop.objectIteration === 'keys' ? 'each_key' : 'each_value'
1044
+ const blockParams = loop.objectIteration === 'entries'
1045
+ ? `${rubyLocal(loop.index ?? param)}, ${rubyLocal(param)}`
1046
+ : rubyLocal(param)
1047
+ lines.push(`<%- ${array}.${method} do |${blockParams}| -%>`)
1048
+ } else {
969
1049
  lines.push(`<%- (0...${array}.length).each do |${indexVar}| -%>`)
970
1050
  if (loop.iterationShape !== 'keys') {
971
1051
  if (supportableDestructure) {
@@ -1004,19 +1084,37 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1004
1084
  lines.push(`<%- ${rubyLocal(param)} = ${array}[${indexVar}] -%>`)
1005
1085
  }
1006
1086
  }
1087
+ }
1007
1088
 
1008
1089
  // Handle filter().map() pattern by wrapping children in if-condition
1009
1090
  if (loop.filterPredicate) {
1010
1091
  let filterCond: string
1011
1092
  if (loop.filterPredicate.predicate) {
1012
- // The loop's own (possibly destructure-adjusted) param name IS the
1013
- // filter predicate's parameter for rendering purposes — pass it
1014
- // straight through as the filter emitter's bound param. Ruby's
1015
- // named-block-param model makes the Mojo original's regex
1016
- // `$filterParam $loopParam` rename unnecessary here: the emitter
1017
- // just renders every reference to the predicate's own arrow
1018
- // parameter AS `param` from the start.
1019
- 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))
1020
1118
  } else {
1021
1119
  filterCond = 'true'
1022
1120
  }
@@ -1095,10 +1193,46 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1095
1193
 
1096
1194
  renderComponent(comp: IRComponent): string {
1097
1195
  const propParts: string[] = []
1196
+ // Named JSX-valued props OTHER than the reserved `children`
1197
+ // (`header={<strong>Title</strong>}`, #2168 jsx-element-prop) each get
1198
+ // their own buffer-slice capture, prepended to the final returned
1199
+ // string below — same mechanism as the reserved children capture,
1200
+ // just keyed by the prop's own name instead of `children`. Unlike the
1201
+ // reserved `children` value (which the child template reads back
1202
+ // through a structural bypass, `isChildrenValueExpr`), a named slot
1203
+ // is read by the child through the GENERIC `bf.h(v[:name])`
1204
+ // text-expression path — there's no name to special-case there, since
1205
+ // any prop name is possible. So the captured HTML is wrapped in
1206
+ // `bf.backend.mark_raw(...)` here (`BarefootJS::SafeString`,
1207
+ // `barefoot_js.rb`) and `Context#h` unwraps it to skip re-escaping —
1208
+ // giving ERB the same "value carries its own safety" bypass Twig's
1209
+ // `Markup` / Kolon's `mark_raw` get from their auto-escaping `{{ }}`.
1210
+ const namedSlotCaptures: string[] = []
1098
1211
  for (const p of comp.props) {
1099
1212
  // Skip callback props (onXxx) and `ref` — both are client-only for
1100
1213
  // SSR (Hono renders neither; the client JS wires them at hydration).
1101
1214
  if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
1215
+ if (p.value.kind === 'jsx-children' && p.name !== 'children') {
1216
+ const prevInLoop = this.inLoop
1217
+ this.inLoop = false
1218
+ const slotBody = this.renderChildren(p.value.children)
1219
+ this.inLoop = prevInLoop
1220
+ // Purely counter-based — NOT derived from `p.name` or `comp.slotId`.
1221
+ // A JSX prop name can contain characters (`data-slot`) that aren't
1222
+ // valid in a Ruby local variable name, and `comp.slotId` alone
1223
+ // would collide across two named-slot props on the same component
1224
+ // invocation (unlike the reserved children slot, there's only ever
1225
+ // one of those per invocation).
1226
+ const suffix = `${this.childrenCaptureCounter++}`
1227
+ const lenVar = `__bf_len_${suffix}`
1228
+ const rawVar = `__bf_praw_${suffix}`
1229
+ const capVar = `__bf_prop_${suffix}`
1230
+ namedSlotCaptures.push(
1231
+ `<% ${lenVar} = _erbout.length %>${slotBody}<% ${rawVar} = _erbout.slice!(${lenVar}..); ${capVar} = bf.backend.mark_raw(${rawVar}) %>`,
1232
+ )
1233
+ propParts.push(`${rubySymbolKey(p.name)} ${capVar}`)
1234
+ continue
1235
+ }
1102
1236
  const lowered = emitAttrValue(p.value, this.componentPropEmitter, p.name)
1103
1237
  if (lowered) propParts.push(lowered)
1104
1238
  }
@@ -1138,10 +1272,10 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1138
1272
  const lenVar = `__bf_len_${suffix}`
1139
1273
  const capVar = `__bf_children_${suffix}`
1140
1274
  const propsHash = `{ ${[...propParts, `children: ${capVar}`].join(', ')} }`
1141
- return `<% ${lenVar} = _erbout.length %>${childrenBody}<% ${capVar} = _erbout.slice!(${lenVar}..) %><%= bf.render_child('${tplName}', ${propsHash}) %>`
1275
+ return `${namedSlotCaptures.join('')}<% ${lenVar} = _erbout.length %>${childrenBody}<% ${capVar} = _erbout.slice!(${lenVar}..) %><%= bf.render_child('${tplName}', ${propsHash}) %>`
1142
1276
  }
1143
1277
  const propsHash = propParts.length > 0 ? `{ ${propParts.join(', ')} }` : '{}'
1144
- return `<%= bf.render_child('${tplName}', ${propsHash}) %>`
1278
+ return `${namedSlotCaptures.join('')}<%= bf.render_child('${tplName}', ${propsHash}) %>`
1145
1279
  }
1146
1280
 
1147
1281
  private childrenCaptureCounter = 0
@@ -1227,7 +1361,7 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1227
1361
  * Routed through the shared dispatcher.
1228
1362
  */
1229
1363
  private readonly elementAttrEmitter: AttrValueEmitter = {
1230
- emitLiteral: (value, name) => `${name}="${value.value}"`,
1364
+ emitLiteral: (value, name) => `${name}="${escapeHtml(value.value)}"`,
1231
1365
  emitExpression: (value, name) => {
1232
1366
  // `style={{ … }}` object literal → a CSS string with dynamic values
1233
1367
  // interpolated, instead of refusing the bare object with BF101.
@@ -1442,6 +1576,12 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1442
1576
  // the unsupported-expression lowering is never reached for a
1443
1577
  // deferred predicate (no BF101 / BF102).
1444
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
1445
1585
  // Rewrite JSX special-prop names to their HTML-attribute
1446
1586
  // counterparts. `className` → `class`; `key` → `data-key` matches
1447
1587
  // the canonical Hono attribute name the client runtime reconciles
@@ -1450,7 +1590,10 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1450
1590
  // attribute-emit time.
1451
1591
  let attrName: string
1452
1592
  if (attr.name === 'className') attrName = 'class'
1453
- else if (attr.name === 'key') attrName = 'data-key'
1593
+ else if (attr.name === 'key') {
1594
+ const depth = this.currentLoopKeyDepth
1595
+ attrName = depth > 0 ? `data-key-${depth}` : 'data-key'
1596
+ }
1454
1597
  else attrName = attr.name
1455
1598
  const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
1456
1599
  if (lowered) parts.push(lowered)
@@ -1493,6 +1636,14 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1493
1636
  expr: ParsedExpr,
1494
1637
  param: string,
1495
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,
1496
1647
  ): string {
1497
1648
  return emitParsedExpr(
1498
1649
  expr,
@@ -1502,6 +1653,7 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1502
1653
  n => this.isLoopBoundName(n),
1503
1654
  n => this._isStringValueName(n),
1504
1655
  (message, reason) => this._recordExprBF101(message, reason),
1656
+ renderParamAs,
1505
1657
  ),
1506
1658
  )
1507
1659
  }
@@ -125,6 +125,15 @@ export function renderArrayMethod(
125
125
  const recv = emit(object)
126
126
  return `bf.trim(${recv})`
127
127
  }
128
+ case 'trimStart':
129
+ case 'trimEnd': {
130
+ // `.trimStart()` / `.trimEnd()` — the one-sided siblings of
131
+ // `.trim()` (#2183 follow-up). Dedicated `bf.trim_start` /
132
+ // `bf.trim_end` helpers, not `bf.trim` with a flag.
133
+ const fn = method === 'trimStart' ? 'trim_start' : 'trim_end'
134
+ const recv = emit(object)
135
+ return `bf.${fn}(${recv})`
136
+ }
128
137
  case 'toFixed': {
129
138
  // `.toFixed(digits?)` — Number → fixed-decimal string. `bf.to_fixed`
130
139
  // mirrors JS rounding + zero-padding (default 0 digits).
@@ -178,6 +187,19 @@ export function renderArrayMethod(
178
187
  const newS = emit(args[1])
179
188
  return `bf.replace(${recv}, ${oldS}, ${newS})`
180
189
  }
190
+ case 'replaceAll': {
191
+ // `.replaceAll(old, new)` — string-pattern form, EVERY occurrence,
192
+ // via the dedicated `bf.replace_all` helper (NOT Ruby's `gsub`,
193
+ // which interprets `\1` / `\&` backreference syntax in the
194
+ // replacement even for a literal string pattern — that would
195
+ // diverge from `.replace`'s literal splice above). The
196
+ // regex-pattern form is refused upstream at the parser, same as
197
+ // `.replace`. See #2182.
198
+ const recv = emit(object)
199
+ const oldS = emit(args[0])
200
+ const newS = emit(args[1])
201
+ return `bf.replace_all(${recv}, ${oldS}, ${newS})`
202
+ }
181
203
  case 'repeat': {
182
204
  // `.repeat(n)` — string repeated `n` times. The `bf.repeat` helper
183
205
  // wraps Ruby's `*` string-repeat operator with the same
@@ -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)
@@ -123,11 +136,19 @@ export class ErbFilterEmitter implements ParsedExprEmitter {
123
136
  return String(value)
124
137
  }
125
138
 
126
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
139
+ member(object: ParsedExpr, property: string, _computed: boolean, optional: boolean, emit: (e: ParsedExpr) => string): string {
127
140
  // `.length` needs no special higher-order form here — see the file
128
141
  // docstring's simplification (2): `.select { ... }.length` just works
129
142
  // in Ruby, unlike Perl's anonymous-arrayref `scalar(@{...})` detour.
130
143
  if (property === 'length') return `${emit(object)}.length`
144
+ // A `?.`-written access (`user?.name`, #2168 optional-chaining-prop):
145
+ // Ruby's own `nil[:key]` raises `NoMethodError` (unlike Hash#[] on a
146
+ // present Hash) — `&.` is Ruby's native safe-navigation operator, and
147
+ // `&.[](...)` is its explicit-method form for indexing rather than a
148
+ // dotted method call. Only guards the single written `?.` hop, not a
149
+ // JS-style whole-chain short-circuit — see the `ParsedExpr` `member`
150
+ // variant's docstring for the multi-hop caveat.
151
+ if (optional) return `${emit(object)}&.[](${rubySymbolLiteral(property)})`
131
152
  return `${emit(object)}[${rubySymbolLiteral(property)}]`
132
153
  }
133
154
 
@@ -323,7 +344,7 @@ export class ErbTopLevelEmitter implements ParsedExprEmitter {
323
344
  return String(value)
324
345
  }
325
346
 
326
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
347
+ member(object: ParsedExpr, property: string, _computed: boolean, optional: boolean, emit: (e: ParsedExpr) => string): string {
327
348
  // `props.x` flattens to the `v[:x]` the ERB SSR caller seeds each prop
328
349
  // under (props arrive as vars-Hash entries, not a nested `props` Hash).
329
350
  if (object.kind === 'identifier' && object.name === 'props') {
@@ -339,6 +360,11 @@ export class ErbTopLevelEmitter implements ParsedExprEmitter {
339
360
  }
340
361
  const obj = emit(object)
341
362
  if (property === 'length') return `${obj}.length`
363
+ // A `?.`-written access (`user?.name`, #2168 optional-chaining-prop):
364
+ // see `ErbFilterEmitter.member()`'s comment above for why `&.[](...)`
365
+ // (not a dotted `&.name`) is the right safe-nav form for a Hash-keyed
366
+ // prop, and for the single-hop caveat.
367
+ if (optional) return `${obj}&.[](${rubySymbolLiteral(property)})`
342
368
  return `${obj}[${rubySymbolLiteral(property)}]`
343
369
  }
344
370
 
@@ -24,6 +24,9 @@ export const ERB_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
24
24
  'Math.floor': { arity: 1, emit: (args) => `bf.floor(${args[0]})` },
25
25
  'Math.ceil': { arity: 1, emit: (args) => `bf.ceil(${args[0]})` },
26
26
  'Math.round': { arity: 1, emit: (args) => `bf.round(${args[0]})` },
27
+ 'Math.min': { arity: 2, emit: (args) => `bf.min(${args[0]}, ${args[1]})` },
28
+ 'Math.max': { arity: 2, emit: (args) => `bf.max(${args[0]}, ${args[1]})` },
29
+ 'Math.abs': { arity: 1, emit: (args) => `bf.abs(${args[0]})` },
27
30
  }
28
31
 
29
32
  /**
@@ -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
+ }
@@ -12,16 +12,17 @@
12
12
  import type { ConformancePins } from '@barefootjs/jsx'
13
13
 
14
14
  export const conformancePins: ConformancePins = {
15
- // Sibling-imported child component in a loop body: emits a
16
- // cross-template call needing separate registration. BF103 makes
17
- // the requirement loud (same as mojo).
18
- 'static-array-children': [{ code: 'BF103', severity: 'error' }],
19
- // TodoApp / TodoAppSSR import `TodoItem` from a sibling file and
20
- // call it inside a keyed `.map`. Same BF103 surface as the
21
- // synthetic `static-array-children` above pinned at adapter
22
- // level so the shared-component corpus stays adapter-neutral.
23
- 'todo-app': [{ code: 'BF103', severity: 'error' }],
24
- 'todo-app-ssr': [{ code: 'BF103', severity: 'error' }],
15
+ // `todo-app` / `todo-app-ssr` no longer pinned (#2205) the conformance
16
+ // harness now passes `siblingTemplatesRegistered: true` for fixtures with
17
+ // sibling `components`, matching `bf build`'s real semantics, so the
18
+ // BF103 loop-body cross-template check no longer fires spuriously. (Both
19
+ // fixtures are still skipped on this adapter via `render-divergences.ts`
20
+ // #2209 for an unrelated signal-seeding gap.)
21
+ // `static-array-children` no longer pinned (#2208) — `items`'s
22
+ // array-literal initializer is now recognized as fully-static
23
+ // (`resolveStaticLoopSource`) and inlined as a native Ruby array/hash
24
+ // literal in the loop-bound expression, the same way a module-scope
25
+ // const's value is already seeded.
25
26
  // `static-array-from-props` / `static-array-from-props-with-component`:
26
27
  // the `.map(([emoji, users]) => …)` / `.map(([id, t]) => …)` callback is
27
28
  // a plain array-index destructure (the `.filter(...)` runs on a
@@ -45,8 +46,9 @@ export const conformancePins: ConformancePins = {
45
46
  'static-array-from-props': [
46
47
  { code: 'BF101', severity: 'error', issue: 'https://github.com/piconic-ai/barefootjs/issues/2087' },
47
48
  ],
49
+ // BF103 (imported child in the loop body) no longer fires now that the
50
+ // conformance harness passes `siblingTemplatesRegistered: true` (#2205).
48
51
  'static-array-from-props-with-component': [
49
- { code: 'BF103', severity: 'error' },
50
52
  { code: 'BF101', severity: 'error', issue: 'https://github.com/piconic-ai/barefootjs/issues/2087' },
51
53
  ],
52
54
  // #2087 Phase B: `isLowerableLoopDestructure` now admits every fixed-
@@ -91,21 +93,14 @@ export const conformancePins: ConformancePins = {
91
93
  // `/* @client */` keyed-map slot-id elision contract only (same as
92
94
  // `todo-app`), not a render or BF101 gap.
93
95
  //
94
- // #2073 follow-up: a function-reference `.map(format)` callback has no
95
- // arrow body to serialize not a CALLBACK_METHODS shape — so the
96
- // UNSUPPORTED_METHODS gate (shared `@barefootjs/jsx` code) refuses it
97
- // with BF101 rather than emitting a broken template. Same pin as
98
- // mojo/xslate.
99
- 'array-map-function-reference': [{ code: 'BF101', severity: 'error' }],
100
- // Edge-case sweep (Priority 12): `dangerouslySetInnerHTML` requires a
101
- // deliberate raw-HTML (unescaped) output affordance in the target
102
- // template language. No lowering exists yet, so the compiler refuses
103
- // the shape loudly instead of emitting entity-escaped markup that
104
- // silently renders tags as text.
105
- 'dangerous-inner-html': [{ code: 'BF101', severity: 'error' }],
106
- // Edge-case sweep (Priority 12): `.replaceAll` has no lowering yet —
107
- // only first-occurrence `.replace` is wired to the runtime helpers.
108
- // Refused with BF101 rather than reusing the first-only lowering,
109
- // which would silently change semantics.
110
- 'string-replaceall': [{ code: 'BF101', severity: 'error' }],
96
+ // `array-map-function-reference` no longer pinned — a bare-identifier
97
+ // `.map(format)` callback now resolves one hop to its declaration
98
+ // (`resolveCallbackMethodFunctionReferences`, #2206), the same mechanism
99
+ // #2090 established for `.sort(fnref)`.
100
+ // `dangerous-inner-html` no longer pinned — a compile-time string-literal
101
+ // `dangerouslySetInnerHTML={{ __html: '...' }}` is spliced directly into
102
+ // the template as trusted raw text (`resolveDangerousInnerHtml`, #2207).
103
+ // A dynamic/signal-derived value still refuses with BF101 — see the
104
+ // `dangerous-inner-html-dynamic` fixture/pin below (tracked: #2215).
105
+ 'dangerous-inner-html-dynamic': [{ code: 'BF101', severity: 'error', issue: 'https://github.com/piconic-ai/barefootjs/issues/2215' }],
111
106
  }
@@ -15,22 +15,8 @@
15
15
  import type { RenderDivergences } from '@barefootjs/jsx'
16
16
 
17
17
  export const renderDivergences: RenderDivergences = {
18
- 'html-entity-text':
19
- '`&copy;` in JSX literal text: Hono decodes to `©`, this adapter re-emits the raw entity — same DOM, different bytes',
20
- 'optional-chaining-prop':
21
- '`user?.name ?? …` on an object prop: the Ruby render exits 1 (optional chaining into a Hash prop has no lowering)',
22
- 'math-methods':
23
- 'Math.min/max/abs over a signal render empty (only Math.floor is in the template-primitive registry)',
24
- 'static-attr-escape':
25
- 'static attribute values are not HTML-escaped (`title="Fish & Chips"` emitted raw; Hono escapes)',
26
- 'object-entries-map':
27
- '`Object.entries(prop).map(([k, v]) => …)` renders but its loop item keys diverge from the reference serialisation',
28
- 'nested-loop-outer-binding':
29
- 'nested-loop inner items carry `data-key` where the reference emits the depth-suffixed `data-key-1`',
30
- 'jsx-element-prop':
31
- 'a JSX element passed as a NON-children prop renders an empty slot — the element value is silently dropped',
32
- 'string-slice':
33
- '`.slice()` on a STRING lowers through the array slice helper and renders "[]" instead of the substring',
34
- 'string-trim-sided':
35
- '`.trimStart()` / `.trimEnd()` render empty (no lowering; only both-sides `.trim` is wired)',
18
+ // `todo-app` / `todo-app-ssr` no longer diverge (#2209) — the shared
19
+ // `evaluateSignalInit` (`@barefootjs/jsx`, sandboxed real-JS evaluation
20
+ // instead of a fixed regex-shape catalogue) now correctly seeds `todos`
21
+ // from `(props.initialTodos ?? []).map(t => ({ ...t, editing: false }))`.
36
22
  }