@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.
- package/dist/adapter/erb-adapter.d.ts +15 -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 +4 -3
- package/dist/adapter/expr/emitters.d.ts.map +1 -1
- package/dist/adapter/index.js +146 -37
- package/dist/adapter/lib/constants.d.ts.map +1 -1
- package/dist/adapter/lib/static-value.d.ts +17 -0
- package/dist/adapter/lib/static-value.d.ts.map +1 -0
- package/dist/build.js +146 -37
- package/dist/conformance-pins.d.ts.map +1 -1
- package/dist/index.js +148 -55
- 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 +142 -0
- package/src/adapter/erb-adapter.ts +175 -23
- package/src/adapter/expr/array-method.ts +22 -0
- package/src/adapter/expr/emitters.ts +29 -3
- package/src/adapter/lib/constants.ts +3 -0
- package/src/adapter/lib/static-value.ts +43 -0
- package/src/conformance-pins.ts +23 -28
- package/src/render-divergences.ts +4 -18
- package/src/test-render.ts +13 -136
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
900
|
-
? [param]
|
|
901
|
-
:
|
|
902
|
-
? [
|
|
903
|
-
:
|
|
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
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
//
|
|
1017
|
-
//
|
|
1018
|
-
//
|
|
1019
|
-
|
|
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
|
|
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
|
|
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')
|
|
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
|
|
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
|
+
}
|
package/src/conformance-pins.ts
CHANGED
|
@@ -12,16 +12,17 @@
|
|
|
12
12
|
import type { ConformancePins } from '@barefootjs/jsx'
|
|
13
13
|
|
|
14
14
|
export const conformancePins: ConformancePins = {
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
}
|