@barefootjs/erb 0.18.3 → 0.18.5

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.
@@ -91,7 +91,7 @@ import {
91
91
  } from '@barefootjs/jsx'
92
92
  import { isAriaBooleanAttr, isBooleanResultExpr, isExplicitStringCall } from './boolean-result.ts'
93
93
  import type { ParsedExpr, LoweringMatcher, LoopBindingPathSegment } from '@barefootjs/jsx'
94
- import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
94
+ import { BF_SLOT, BF_COND, BF_REGION, escapeHtml } from '@barefootjs/shared'
95
95
 
96
96
  import type { ErbRenderCtx } from './lib/types.ts'
97
97
  import { ERB_PRIMITIVE_EMIT_MAP } from './lib/constants.ts'
@@ -177,6 +177,14 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
177
177
  private options: Required<ErbAdapterOptions>
178
178
  private errors: CompilerError[] = []
179
179
  private inLoop: boolean = false
180
+ /**
181
+ * `IRLoop.depth` of the loop currently being rendered (save/restore
182
+ * around `renderChildren(loop.children)`, mirroring `inLoop` above).
183
+ * `renderAttributes` reads this to derive the `key` → `data-key`/
184
+ * `data-key-N` suffix — the depth is IR-computed (jsx-to-ir.ts), not
185
+ * re-derived here (#2168 nested-loop-outer-binding).
186
+ */
187
+ private currentLoopKeyDepth = 0
180
188
  /**
181
189
  * SolidJS-style props identifier (`function(props: P)`) and the
182
190
  * analyzer-extracted prop names. Stashed at `generate()` entry so the
@@ -499,7 +507,9 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
499
507
  }
500
508
 
501
509
  emitText(node: IRText): string {
502
- return node.value
510
+ // IRText carries the entity-DECODED value (Phase 1 decodes JSX
511
+ // character references); re-escape for direct HTML emission.
512
+ return escapeHtml(node.value)
503
513
  }
504
514
 
505
515
  emitExpression(node: IRExpression): string {
@@ -896,17 +906,22 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
896
906
  // whole body (children + key + filter) so a same-named loop variable
897
907
  // isn't replaced by the const literal / a vars-Hash read. Ref-counted
898
908
  // 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']
909
+ const loopBound = loop.objectIteration === 'entries'
910
+ ? [param, loop.index ?? '_k']
911
+ : loop.objectIteration === 'keys' || loop.objectIteration === 'values' || loop.iterationShape === 'keys'
912
+ ? [param]
913
+ : supportableDestructure
914
+ ? ['__bf_item', ...(loop.paramBindings ?? []).map(b => b.name), loop.index ?? '_i']
915
+ : [param, loop.index ?? '_i']
904
916
  for (const n of loopBound) {
905
917
  this.loopBoundNames.set(n, (this.loopBoundNames.get(n) ?? 0) + 1)
906
918
  }
907
919
  const prevInLoop = this.inLoop
908
920
  this.inLoop = true
921
+ const prevLoopKeyDepth = this.currentLoopKeyDepth
922
+ this.currentLoopKeyDepth = loop.depth
909
923
  const renderedChildren = this.renderChildren(loop.children)
924
+ this.currentLoopKeyDepth = prevLoopKeyDepth
910
925
  this.inLoop = prevInLoop
911
926
 
912
927
  // Whole-item conditional: prepend an always-present
@@ -966,6 +981,21 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
966
981
  }
967
982
  lines.push(`<%- ${sortedHoist} = ${sorted} -%>`)
968
983
  }
984
+ if (loop.objectIteration) {
985
+ // `objectIteration` (#2168 object-entries-map): Ruby's `Hash`
986
+ // preserves the source object's insertion order natively (unlike
987
+ // Go's `map`/Perl's hash), so this bypasses the index-range form
988
+ // above entirely and uses Ruby's own native block-param binding
989
+ // (`each_pair`/`each_key`/`each_value`) — no `array[index]` lookup
990
+ // needed, since the block directly yields the key/value.
991
+ const method = loop.objectIteration === 'entries'
992
+ ? 'each_pair'
993
+ : loop.objectIteration === 'keys' ? 'each_key' : 'each_value'
994
+ const blockParams = loop.objectIteration === 'entries'
995
+ ? `${rubyLocal(loop.index ?? param)}, ${rubyLocal(param)}`
996
+ : rubyLocal(param)
997
+ lines.push(`<%- ${array}.${method} do |${blockParams}| -%>`)
998
+ } else {
969
999
  lines.push(`<%- (0...${array}.length).each do |${indexVar}| -%>`)
970
1000
  if (loop.iterationShape !== 'keys') {
971
1001
  if (supportableDestructure) {
@@ -1004,6 +1034,7 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1004
1034
  lines.push(`<%- ${rubyLocal(param)} = ${array}[${indexVar}] -%>`)
1005
1035
  }
1006
1036
  }
1037
+ }
1007
1038
 
1008
1039
  // Handle filter().map() pattern by wrapping children in if-condition
1009
1040
  if (loop.filterPredicate) {
@@ -1095,10 +1126,46 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1095
1126
 
1096
1127
  renderComponent(comp: IRComponent): string {
1097
1128
  const propParts: string[] = []
1129
+ // Named JSX-valued props OTHER than the reserved `children`
1130
+ // (`header={<strong>Title</strong>}`, #2168 jsx-element-prop) each get
1131
+ // their own buffer-slice capture, prepended to the final returned
1132
+ // string below — same mechanism as the reserved children capture,
1133
+ // just keyed by the prop's own name instead of `children`. Unlike the
1134
+ // reserved `children` value (which the child template reads back
1135
+ // through a structural bypass, `isChildrenValueExpr`), a named slot
1136
+ // is read by the child through the GENERIC `bf.h(v[:name])`
1137
+ // text-expression path — there's no name to special-case there, since
1138
+ // any prop name is possible. So the captured HTML is wrapped in
1139
+ // `bf.backend.mark_raw(...)` here (`BarefootJS::SafeString`,
1140
+ // `barefoot_js.rb`) and `Context#h` unwraps it to skip re-escaping —
1141
+ // giving ERB the same "value carries its own safety" bypass Twig's
1142
+ // `Markup` / Kolon's `mark_raw` get from their auto-escaping `{{ }}`.
1143
+ const namedSlotCaptures: string[] = []
1098
1144
  for (const p of comp.props) {
1099
1145
  // Skip callback props (onXxx) and `ref` — both are client-only for
1100
1146
  // SSR (Hono renders neither; the client JS wires them at hydration).
1101
1147
  if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
1148
+ if (p.value.kind === 'jsx-children' && p.name !== 'children') {
1149
+ const prevInLoop = this.inLoop
1150
+ this.inLoop = false
1151
+ const slotBody = this.renderChildren(p.value.children)
1152
+ this.inLoop = prevInLoop
1153
+ // Purely counter-based — NOT derived from `p.name` or `comp.slotId`.
1154
+ // A JSX prop name can contain characters (`data-slot`) that aren't
1155
+ // valid in a Ruby local variable name, and `comp.slotId` alone
1156
+ // would collide across two named-slot props on the same component
1157
+ // invocation (unlike the reserved children slot, there's only ever
1158
+ // one of those per invocation).
1159
+ const suffix = `${this.childrenCaptureCounter++}`
1160
+ const lenVar = `__bf_len_${suffix}`
1161
+ const rawVar = `__bf_praw_${suffix}`
1162
+ const capVar = `__bf_prop_${suffix}`
1163
+ namedSlotCaptures.push(
1164
+ `<% ${lenVar} = _erbout.length %>${slotBody}<% ${rawVar} = _erbout.slice!(${lenVar}..); ${capVar} = bf.backend.mark_raw(${rawVar}) %>`,
1165
+ )
1166
+ propParts.push(`${rubySymbolKey(p.name)} ${capVar}`)
1167
+ continue
1168
+ }
1102
1169
  const lowered = emitAttrValue(p.value, this.componentPropEmitter, p.name)
1103
1170
  if (lowered) propParts.push(lowered)
1104
1171
  }
@@ -1138,10 +1205,10 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1138
1205
  const lenVar = `__bf_len_${suffix}`
1139
1206
  const capVar = `__bf_children_${suffix}`
1140
1207
  const propsHash = `{ ${[...propParts, `children: ${capVar}`].join(', ')} }`
1141
- return `<% ${lenVar} = _erbout.length %>${childrenBody}<% ${capVar} = _erbout.slice!(${lenVar}..) %><%= bf.render_child('${tplName}', ${propsHash}) %>`
1208
+ return `${namedSlotCaptures.join('')}<% ${lenVar} = _erbout.length %>${childrenBody}<% ${capVar} = _erbout.slice!(${lenVar}..) %><%= bf.render_child('${tplName}', ${propsHash}) %>`
1142
1209
  }
1143
1210
  const propsHash = propParts.length > 0 ? `{ ${propParts.join(', ')} }` : '{}'
1144
- return `<%= bf.render_child('${tplName}', ${propsHash}) %>`
1211
+ return `${namedSlotCaptures.join('')}<%= bf.render_child('${tplName}', ${propsHash}) %>`
1145
1212
  }
1146
1213
 
1147
1214
  private childrenCaptureCounter = 0
@@ -1227,7 +1294,7 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1227
1294
  * Routed through the shared dispatcher.
1228
1295
  */
1229
1296
  private readonly elementAttrEmitter: AttrValueEmitter = {
1230
- emitLiteral: (value, name) => `${name}="${value.value}"`,
1297
+ emitLiteral: (value, name) => `${name}="${escapeHtml(value.value)}"`,
1231
1298
  emitExpression: (value, name) => {
1232
1299
  // `style={{ … }}` object literal → a CSS string with dynamic values
1233
1300
  // interpolated, instead of refusing the bare object with BF101.
@@ -1450,7 +1517,10 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1450
1517
  // attribute-emit time.
1451
1518
  let attrName: string
1452
1519
  if (attr.name === 'className') attrName = 'class'
1453
- else if (attr.name === 'key') attrName = 'data-key'
1520
+ else if (attr.name === 'key') {
1521
+ const depth = this.currentLoopKeyDepth
1522
+ attrName = depth > 0 ? `data-key-${depth}` : 'data-key'
1523
+ }
1454
1524
  else attrName = attr.name
1455
1525
  const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
1456
1526
  if (lowered) parts.push(lowered)
@@ -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
@@ -123,11 +123,19 @@ export class ErbFilterEmitter implements ParsedExprEmitter {
123
123
  return String(value)
124
124
  }
125
125
 
126
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
126
+ member(object: ParsedExpr, property: string, _computed: boolean, optional: boolean, emit: (e: ParsedExpr) => string): string {
127
127
  // `.length` needs no special higher-order form here — see the file
128
128
  // docstring's simplification (2): `.select { ... }.length` just works
129
129
  // in Ruby, unlike Perl's anonymous-arrayref `scalar(@{...})` detour.
130
130
  if (property === 'length') return `${emit(object)}.length`
131
+ // A `?.`-written access (`user?.name`, #2168 optional-chaining-prop):
132
+ // Ruby's own `nil[:key]` raises `NoMethodError` (unlike Hash#[] on a
133
+ // present Hash) — `&.` is Ruby's native safe-navigation operator, and
134
+ // `&.[](...)` is its explicit-method form for indexing rather than a
135
+ // dotted method call. Only guards the single written `?.` hop, not a
136
+ // JS-style whole-chain short-circuit — see the `ParsedExpr` `member`
137
+ // variant's docstring for the multi-hop caveat.
138
+ if (optional) return `${emit(object)}&.[](${rubySymbolLiteral(property)})`
131
139
  return `${emit(object)}[${rubySymbolLiteral(property)}]`
132
140
  }
133
141
 
@@ -323,7 +331,7 @@ export class ErbTopLevelEmitter implements ParsedExprEmitter {
323
331
  return String(value)
324
332
  }
325
333
 
326
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
334
+ member(object: ParsedExpr, property: string, _computed: boolean, optional: boolean, emit: (e: ParsedExpr) => string): string {
327
335
  // `props.x` flattens to the `v[:x]` the ERB SSR caller seeds each prop
328
336
  // under (props arrive as vars-Hash entries, not a nested `props` Hash).
329
337
  if (object.kind === 'identifier' && object.name === 'props') {
@@ -339,6 +347,11 @@ export class ErbTopLevelEmitter implements ParsedExprEmitter {
339
347
  }
340
348
  const obj = emit(object)
341
349
  if (property === 'length') return `${obj}.length`
350
+ // A `?.`-written access (`user?.name`, #2168 optional-chaining-prop):
351
+ // see `ErbFilterEmitter.member()`'s comment above for why `&.[](...)`
352
+ // (not a dotted `&.name`) is the right safe-nav form for a Hash-keyed
353
+ // prop, and for the single-hop caveat.
354
+ if (optional) return `${obj}&.[](${rubySymbolLiteral(property)})`
342
355
  return `${obj}[${rubySymbolLiteral(property)}]`
343
356
  }
344
357
 
@@ -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
  /**
@@ -103,9 +103,4 @@ export const conformancePins: ConformancePins = {
103
103
  // the shape loudly instead of emitting entity-escaped markup that
104
104
  // silently renders tags as text.
105
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' }],
111
106
  }
@@ -14,29 +14,4 @@
14
14
 
15
15
  import type { RenderDivergences } from '@barefootjs/jsx'
16
16
 
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
- 'boolean-attr-literals':
25
- 'camelCase boolean alias `readOnly`: Hono SSRs `readOnly="true"`, this adapter emits bare presence',
26
- 'camelcase-attributes':
27
- '`htmlFor` is not lowered to `for` (Hono maps it)',
28
- 'static-attr-escape':
29
- 'static attribute values are not HTML-escaped (`title="Fish & Chips"` emitted raw; Hono escapes)',
30
- 'svg-icon':
31
- 'SVG camelCase presentation attrs (`strokeWidth`, `strokeLinecap`) pass through unmapped; Hono lowers to kebab-case',
32
- 'object-entries-map':
33
- '`Object.entries(prop).map(([k, v]) => …)` renders but its loop item keys diverge from the reference serialisation',
34
- 'nested-loop-outer-binding':
35
- 'nested-loop inner items carry `data-key` where the reference emits the depth-suffixed `data-key-1`',
36
- 'jsx-element-prop':
37
- 'a JSX element passed as a NON-children prop renders an empty slot — the element value is silently dropped',
38
- 'string-slice':
39
- '`.slice()` on a STRING lowers through the array slice helper and renders "[]" instead of the substring',
40
- 'string-trim-sided':
41
- '`.trimStart()` / `.trimEnd()` render empty (no lowering; only both-sides `.trim` is wired)',
42
- }
17
+ export const renderDivergences: RenderDivergences = {}