@barefootjs/erb 0.18.4 → 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.
- package/dist/adapter/erb-adapter.d.ts +8 -0
- package/dist/adapter/erb-adapter.d.ts.map +1 -1
- package/dist/adapter/expr/array-method.d.ts.map +1 -1
- package/dist/adapter/expr/emitters.d.ts +2 -2
- package/dist/adapter/expr/emitters.d.ts.map +1 -1
- package/dist/adapter/index.js +71 -27
- package/dist/adapter/lib/constants.d.ts.map +1 -1
- package/dist/build.js +71 -27
- package/dist/conformance-pins.d.ts.map +1 -1
- package/dist/index.js +73 -40
- package/dist/render-divergences.d.ts.map +1 -1
- package/lib/barefoot_js/backend/erb.rb +24 -15
- package/lib/barefoot_js/evaluator.rb +14 -1
- package/lib/barefoot_js.rb +111 -6
- package/package.json +3 -3
- package/src/__tests__/erb-adapter.test.ts +21 -0
- package/src/adapter/erb-adapter.ts +81 -11
- package/src/adapter/expr/array-method.ts +22 -0
- package/src/adapter/expr/emitters.ts +15 -2
- package/src/adapter/lib/constants.ts +3 -0
- package/src/conformance-pins.ts +0 -5
- package/src/render-divergences.ts +1 -20
|
@@ -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
|
-
|
|
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.
|
|
900
|
-
? [param]
|
|
901
|
-
:
|
|
902
|
-
? [
|
|
903
|
-
:
|
|
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
|
|
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
|
|
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')
|
|
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
|
/**
|
package/src/conformance-pins.ts
CHANGED
|
@@ -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,23 +14,4 @@
|
|
|
14
14
|
|
|
15
15
|
import type { RenderDivergences } from '@barefootjs/jsx'
|
|
16
16
|
|
|
17
|
-
export const renderDivergences: RenderDivergences = {
|
|
18
|
-
'html-entity-text':
|
|
19
|
-
'`©` 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)',
|
|
36
|
-
}
|
|
17
|
+
export const renderDivergences: RenderDivergences = {}
|