@barefootjs/erb 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter/analysis/component-tree.d.ts +24 -0
- package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
- package/dist/adapter/boolean-result.d.ts +66 -0
- package/dist/adapter/boolean-result.d.ts.map +1 -0
- package/dist/adapter/emit-context.d.ts +107 -0
- package/dist/adapter/emit-context.d.ts.map +1 -0
- package/dist/adapter/erb-adapter.d.ts +374 -0
- package/dist/adapter/erb-adapter.d.ts.map +1 -0
- package/dist/adapter/expr/array-method.d.ts +87 -0
- package/dist/adapter/expr/array-method.d.ts.map +1 -0
- package/dist/adapter/expr/emitters.d.ts +102 -0
- package/dist/adapter/expr/emitters.d.ts.map +1 -0
- package/dist/adapter/expr/operand.d.ts +34 -0
- package/dist/adapter/expr/operand.d.ts.map +1 -0
- package/dist/adapter/index.d.ts +6 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/index.js +189154 -0
- package/dist/adapter/lib/constants.d.ts +25 -0
- package/dist/adapter/lib/constants.d.ts.map +1 -0
- package/dist/adapter/lib/ir-scope.d.ts +27 -0
- package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
- package/dist/adapter/lib/ruby-naming.d.ts +65 -0
- package/dist/adapter/lib/ruby-naming.d.ts.map +1 -0
- package/dist/adapter/lib/types.d.ts +28 -0
- package/dist/adapter/lib/types.d.ts.map +1 -0
- package/dist/adapter/memo/seed.d.ts +35 -0
- package/dist/adapter/memo/seed.d.ts.map +1 -0
- package/dist/adapter/props/prop-classes.d.ts +50 -0
- package/dist/adapter/props/prop-classes.d.ts.map +1 -0
- package/dist/adapter/spread/spread-codegen.d.ts +63 -0
- package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
- package/dist/adapter/value/parsed-literal.d.ts +21 -0
- package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
- package/dist/build.d.ts +28 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +189174 -0
- package/dist/conformance-pins.d.ts +13 -0
- package/dist/conformance-pins.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +189172 -0
- package/lib/barefoot_js/backend/erb.rb +123 -0
- package/lib/barefoot_js/dev_reload.rb +159 -0
- package/lib/barefoot_js/evaluator.rb +714 -0
- package/lib/barefoot_js/search_params.rb +63 -0
- package/lib/barefoot_js.rb +1155 -0
- package/package.json +67 -0
- package/src/__tests__/erb-adapter.test.ts +298 -0
- package/src/adapter/analysis/component-tree.ts +122 -0
- package/src/adapter/boolean-result.ts +157 -0
- package/src/adapter/emit-context.ts +119 -0
- package/src/adapter/erb-adapter.ts +1768 -0
- package/src/adapter/expr/array-method.ts +424 -0
- package/src/adapter/expr/emitters.ts +629 -0
- package/src/adapter/expr/operand.ts +55 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/lib/constants.ts +37 -0
- package/src/adapter/lib/ir-scope.ts +51 -0
- package/src/adapter/lib/ruby-naming.ts +127 -0
- package/src/adapter/lib/types.ts +31 -0
- package/src/adapter/memo/seed.ts +75 -0
- package/src/adapter/props/prop-classes.ts +89 -0
- package/src/adapter/spread/spread-codegen.ts +176 -0
- package/src/adapter/value/parsed-literal.ts +29 -0
- package/src/build.ts +37 -0
- package/src/conformance-pins.ts +100 -0
- package/src/index.ts +9 -0
- package/src/test-render.ts +668 -0
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ParsedExpr → Ruby emitters for the ERB template adapter.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the Mojolicious adapter's `expr/emitters.ts` (issue #2018
|
|
5
|
+
* track D lineage), retargeted at Ruby / ERB's two-locals variable model
|
|
6
|
+
* (`bf`, `v`). Two `ParsedExprEmitter` implementations:
|
|
7
|
+
*
|
|
8
|
+
* - `ErbFilterEmitter` — filter/predicate context (loop param + local
|
|
9
|
+
* aliases + `v[:name]` / loop-bound-local fallback for other
|
|
10
|
+
* identifiers); self-contained aside from a couple of adapter-supplied
|
|
11
|
+
* predicates, reads no other adapter state.
|
|
12
|
+
* - `ErbTopLevelEmitter` — top-level / vars-Hash context; depends on the
|
|
13
|
+
* adapter only through the narrow `ErbEmitContext` seam.
|
|
14
|
+
*
|
|
15
|
+
* Two structural simplifications fall out of Ruby's richer surface
|
|
16
|
+
* relative to Perl (documented at each site below, not scattered as
|
|
17
|
+
* special cases):
|
|
18
|
+
*
|
|
19
|
+
* 1. **Predicate callbacks compile to real Ruby blocks** (`.select { |x|
|
|
20
|
+
* ... }`) instead of Perl's regex `$param → $_` substitution into a
|
|
21
|
+
* `grep { ... }` block body — Ruby blocks take named parameters, so
|
|
22
|
+
* no textual substitution is needed.
|
|
23
|
+
* 2. **`.length` on a higher-order chain result needs no special form**
|
|
24
|
+
* (`arr.select { ... }.length` just works) — Perl's anonymous-arrayref
|
|
25
|
+
* `scalar(@{[grep ...]})` workaround has no ERB analog to port.
|
|
26
|
+
*
|
|
27
|
+
* The one thing EP does NOT need that ERB does: JS `&&` / `||` / `!` /
|
|
28
|
+
* ternary tests must be wrapped in `bf.truthy?(...)`. Perl's own falsy set
|
|
29
|
+
* (`undef`, `0`, `''`, `'0'`) already tracks JS's closely enough that Mojo's
|
|
30
|
+
* native `&&`/`||`/`!`/`?:` work unwrapped; Ruby's falsy set is only
|
|
31
|
+
* `nil`/`false`, so `0`, `''`, and `NaN` are Ruby-truthy but JS-falsy. Every
|
|
32
|
+
* JS truthiness test in this file goes through `bf.truthy?` for that reason.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
type ParsedExprEmitter,
|
|
37
|
+
type HigherOrderMethod,
|
|
38
|
+
type ArrayMethod,
|
|
39
|
+
type LiteralType,
|
|
40
|
+
type ParsedExpr,
|
|
41
|
+
type ObjectLiteralProperty,
|
|
42
|
+
type FlatDepth,
|
|
43
|
+
type TemplatePart,
|
|
44
|
+
emitParsedExpr,
|
|
45
|
+
identifierPath,
|
|
46
|
+
matchSearchParamsMethodCall,
|
|
47
|
+
sortComparatorFromArrow,
|
|
48
|
+
} from '@barefootjs/jsx'
|
|
49
|
+
|
|
50
|
+
import type { ErbEmitContext } from '../emit-context.ts'
|
|
51
|
+
import { ERB_TEMPLATE_PRIMITIVES } from '../lib/constants.ts'
|
|
52
|
+
import { rubyLocal, rubyStringLiteral, rubySymbolLiteral } from '../lib/ruby-naming.ts'
|
|
53
|
+
import { emitIndexAccessRuby } from './operand.ts'
|
|
54
|
+
import {
|
|
55
|
+
renderArrayMethod,
|
|
56
|
+
renderSortMethod,
|
|
57
|
+
renderSortEval,
|
|
58
|
+
renderReduceEval,
|
|
59
|
+
renderPredicateEval,
|
|
60
|
+
renderFlatMethod,
|
|
61
|
+
renderFlatMapEval,
|
|
62
|
+
renderMapEval,
|
|
63
|
+
} from './array-method.ts'
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Local shape for the predicate-method fallback chain. The higher-order
|
|
67
|
+
* callback arrives as a generic `call`; `callbackMethod` recovers
|
|
68
|
+
* `{ method, object, param, predicate }` from it before threading it
|
|
69
|
+
* through the Ruby block / `bf.find*` lowering.
|
|
70
|
+
*/
|
|
71
|
+
type PredicateCall = {
|
|
72
|
+
method: HigherOrderMethod
|
|
73
|
+
object: ParsedExpr
|
|
74
|
+
param: string
|
|
75
|
+
predicate: ParsedExpr
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Predicate-shaped higher-order methods (filter/every/some land here through
|
|
79
|
+
// nested `.filter(...)` chains). Module-level so `callbackMethod`, which runs
|
|
80
|
+
// for many nodes during template generation, reuses one set per process.
|
|
81
|
+
const PREDICATE_METHODS: ReadonlySet<string> = new Set([
|
|
82
|
+
'filter', 'find', 'findIndex', 'findLast', 'findLastIndex', 'every', 'some',
|
|
83
|
+
])
|
|
84
|
+
|
|
85
|
+
/** `void` referencing an unused parameter without a lint complaint. */
|
|
86
|
+
function unusedIsStringName(_n: string): boolean {
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class ErbFilterEmitter implements ParsedExprEmitter {
|
|
91
|
+
constructor(
|
|
92
|
+
private readonly param: string,
|
|
93
|
+
private readonly localVarMap: Map<string, string>,
|
|
94
|
+
// Whether `name` currently names a bare Ruby local bound by an
|
|
95
|
+
// ENCLOSING loop/block (distinct from `this.param`, which is this
|
|
96
|
+
// predicate's own — possibly nested — loop param). See
|
|
97
|
+
// `ErbEmitContext.isLoopBoundName`'s docstring for why ERB needs this
|
|
98
|
+
// and EP does not.
|
|
99
|
+
private readonly isLoopBoundOuter: (n: string) => boolean = () => false,
|
|
100
|
+
// Reports whether a getter/prop name is string-typed, for the Hash-vs-
|
|
101
|
+
// Array index-access split (#operand.ts). Defaults to "never" for
|
|
102
|
+
// callers that don't thread it through.
|
|
103
|
+
private readonly isStringName: (n: string) => boolean = unusedIsStringName,
|
|
104
|
+
// Records a BF101 for nested callback shapes this emitter can only
|
|
105
|
+
// degrade — `find*` and the non-predicate methods. Optional so emitter
|
|
106
|
+
// construction stays possible without an adapter; a missing hook keeps
|
|
107
|
+
// the old silent-degrade emit.
|
|
108
|
+
private readonly onUnsupported?: (message: string, reason?: string) => void,
|
|
109
|
+
) {}
|
|
110
|
+
|
|
111
|
+
identifier(name: string): string {
|
|
112
|
+
if (name === this.param) return rubyLocal(this.param)
|
|
113
|
+
const signal = this.localVarMap.get(name)
|
|
114
|
+
if (signal) return `v[${rubySymbolLiteral(signal)}]`
|
|
115
|
+
if (this.isLoopBoundOuter(name)) return rubyLocal(name)
|
|
116
|
+
return `v[${rubySymbolLiteral(name)}]`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
literal(value: string | number | boolean | null, literalType: LiteralType): string {
|
|
120
|
+
if (literalType === 'string') return rubyStringLiteral(String(value))
|
|
121
|
+
if (literalType === 'boolean') return value ? 'true' : 'false'
|
|
122
|
+
if (literalType === 'null') return 'nil'
|
|
123
|
+
return String(value)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
|
|
127
|
+
// `.length` needs no special higher-order form here — see the file
|
|
128
|
+
// docstring's simplification (2): `.select { ... }.length` just works
|
|
129
|
+
// in Ruby, unlike Perl's anonymous-arrayref `scalar(@{...})` detour.
|
|
130
|
+
if (property === 'length') return `${emit(object)}.length`
|
|
131
|
+
return `${emit(object)}[${rubySymbolLiteral(property)}]`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
135
|
+
return emitIndexAccessRuby(object, index, emit, this.isStringName)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
|
|
139
|
+
// Signal getter calls: filter() → v[:filter]
|
|
140
|
+
if (callee.kind === 'identifier' && args.length === 0) {
|
|
141
|
+
return `v[${rubySymbolLiteral(callee.name)}]`
|
|
142
|
+
}
|
|
143
|
+
return emit(callee)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
147
|
+
const arg = emit(argument)
|
|
148
|
+
// JS `!x` tests JS truthiness, not Ruby's — wrap. See file docstring.
|
|
149
|
+
if (op === '!') return `!bf.truthy?(${arg})`
|
|
150
|
+
if (op === '-') return `-(${arg})`
|
|
151
|
+
return arg
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
155
|
+
const l = emit(left)
|
|
156
|
+
const r = emit(right)
|
|
157
|
+
// Unlike Perl's numeric `==` (which coerces a non-numeric string
|
|
158
|
+
// operand to 0, making `"b" == "a"` erroneously true), Ruby's `==`
|
|
159
|
+
// never coerces between mismatched types — `"b" == "a"` and
|
|
160
|
+
// `"b" == 0` are both correctly `false` with no operator selection
|
|
161
|
+
// needed. JS `===`/`!==` map straight onto Ruby `==`/`!=`.
|
|
162
|
+
const opMap: Record<string, string> = {
|
|
163
|
+
'===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
|
|
164
|
+
'+': '+', '-': '-', '*': '*', '/': '/',
|
|
165
|
+
}
|
|
166
|
+
return `(${l} ${opMap[op] ?? op} ${r})`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
170
|
+
const l = emit(left)
|
|
171
|
+
const r = emit(right)
|
|
172
|
+
// JS `&&`/`||` are operand-returning under JS truthiness, which
|
|
173
|
+
// diverges from Ruby's own `&&`/`||` (Ruby only treats nil/false as
|
|
174
|
+
// falsy) — see file docstring. `??` only ever needs a null check, which
|
|
175
|
+
// Ruby and JS agree on.
|
|
176
|
+
if (op === '&&') return `(bf.truthy?(${l}) ? ${r} : ${l})`
|
|
177
|
+
if (op === '||') return `(bf.truthy?(${l}) ? ${l} : ${r})`
|
|
178
|
+
return `((${l}).nil? ? ${r} : ${l})`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
callbackMethod(
|
|
182
|
+
method: string,
|
|
183
|
+
object: ParsedExpr,
|
|
184
|
+
arrow: Extract<ParsedExpr, { kind: 'arrow' }>,
|
|
185
|
+
_restArgs: ParsedExpr[],
|
|
186
|
+
emit: (e: ParsedExpr) => string,
|
|
187
|
+
): string {
|
|
188
|
+
// Filter context only meaningfully handles the predicate methods
|
|
189
|
+
// (filter / every / some land here through nested `.filter(...)`
|
|
190
|
+
// chains). Sort / reduce / flatMap have no scalar Ruby form here —
|
|
191
|
+
// surface BF101 instead of silently rewriting the predicate.
|
|
192
|
+
if (!PREDICATE_METHODS.has(method)) {
|
|
193
|
+
this.onUnsupported?.(
|
|
194
|
+
`Filter predicate contains a nested '.${method}(...)' callback, which has no Ruby scalar form`,
|
|
195
|
+
`Rewrite the predicate without a nested callback method, or add /* @client */ for client-only evaluation (no SSR).`,
|
|
196
|
+
)
|
|
197
|
+
return 'true'
|
|
198
|
+
}
|
|
199
|
+
// The predicate body is also a filter context, but with this
|
|
200
|
+
// callback's own `param` (potentially shadowing the outer one), so we
|
|
201
|
+
// spin up a nested emitter with the inner param. The outer param/alias
|
|
202
|
+
// become part of "loop-bound outer" for the nested emitter — a real
|
|
203
|
+
// Ruby block nests lexically, so the substitution-free block-param
|
|
204
|
+
// approach composes for free (file docstring simplification 1).
|
|
205
|
+
const param = arrow.params[0]
|
|
206
|
+
const predicate = arrow.body
|
|
207
|
+
const arrayExpr = emit(object)
|
|
208
|
+
const nested = new ErbFilterEmitter(
|
|
209
|
+
param,
|
|
210
|
+
this.localVarMap,
|
|
211
|
+
(n) => n === this.param || this.isLoopBoundOuter(n),
|
|
212
|
+
this.isStringName,
|
|
213
|
+
this.onUnsupported,
|
|
214
|
+
)
|
|
215
|
+
const predBody = emitParsedExpr(predicate, nested)
|
|
216
|
+
const blockParam = rubyLocal(param)
|
|
217
|
+
if (method === 'filter') return `${arrayExpr}.select { |${blockParam}| bf.truthy?(${predBody}) }`
|
|
218
|
+
if (method === 'every') return `${arrayExpr}.all? { |${blockParam}| bf.truthy?(${predBody}) }`
|
|
219
|
+
if (method === 'some') return `${arrayExpr}.any? { |${blockParam}| bf.truthy?(${predBody}) }`
|
|
220
|
+
// `find` / `findIndex` / `findLast` / `findLastIndex` return an element
|
|
221
|
+
// (or index), not a boolean — there is no inline predicate form.
|
|
222
|
+
// Degrading to the receiver silently changes predicate semantics, so be
|
|
223
|
+
// loud.
|
|
224
|
+
this.onUnsupported?.(
|
|
225
|
+
`Filter predicate contains a nested '.${method}(...)' callback, which has no Ruby scalar form`,
|
|
226
|
+
`Rewrite the predicate without a nested callback method, or add /* @client */ for client-only evaluation (no SSR).`,
|
|
227
|
+
)
|
|
228
|
+
return arrayExpr
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
|
|
232
|
+
return `[${elements.map(emit).join(', ')}]`
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
arrayMethod(
|
|
236
|
+
method: ArrayMethod,
|
|
237
|
+
object: ParsedExpr,
|
|
238
|
+
args: ParsedExpr[],
|
|
239
|
+
emit: (e: ParsedExpr) => string,
|
|
240
|
+
): string {
|
|
241
|
+
return renderArrayMethod(method, object, args, emit)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
flatMethod(
|
|
245
|
+
object: ParsedExpr,
|
|
246
|
+
depth: FlatDepth | { expr: ParsedExpr },
|
|
247
|
+
emit: (e: ParsedExpr) => string,
|
|
248
|
+
): string {
|
|
249
|
+
return renderFlatMethod(emit(object), depth, emit)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
conditional(_test: ParsedExpr, _consequent: ParsedExpr, _alternate: ParsedExpr): string {
|
|
253
|
+
return 'true'
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
templateLiteral(_parts: TemplatePart[]): string {
|
|
257
|
+
return 'true'
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
arrow(_params: string[], _body: ParsedExpr, _emit: (e: ParsedExpr) => string): string {
|
|
261
|
+
// A standalone arrow only reaches here outside a callback position,
|
|
262
|
+
// which never arises in a predicate context — emit the truthy sentinel.
|
|
263
|
+
return 'true'
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
regex(_raw: string): string {
|
|
267
|
+
return 'true'
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
unsupported(_raw: string, _reason: string): string {
|
|
271
|
+
return 'true'
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
objectLiteral(_properties: ObjectLiteralProperty[], _raw: string, _emit: (e: ParsedExpr) => string): string {
|
|
275
|
+
// Filter-predicate context: an object literal is not a boolean leaf, so
|
|
276
|
+
// emit the truthy sentinel exactly as `unsupported` does. Object
|
|
277
|
+
// *values* are lowered to Ruby Hashes in the conditional/attr paths,
|
|
278
|
+
// not here.
|
|
279
|
+
return 'true'
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Lowering for top-level expressions whose identifiers resolve against the
|
|
285
|
+
* ERB template's vars Hash (`v`) — signals, props, module consts — or,
|
|
286
|
+
* inside a loop body, a bare Ruby local. Differs from the filter emitter
|
|
287
|
+
* mainly in:
|
|
288
|
+
* - `conditional` is supported (filter predicates can't return ternaries),
|
|
289
|
+
* - templatePrimitive / searchParams / eval-catalogue call dispatch,
|
|
290
|
+
* - the `unsupported` fallback returns the safe empty-string Ruby literal.
|
|
291
|
+
*/
|
|
292
|
+
export class ErbTopLevelEmitter implements ParsedExprEmitter {
|
|
293
|
+
constructor(private readonly ctx: ErbEmitContext) {}
|
|
294
|
+
|
|
295
|
+
identifier(name: string): string {
|
|
296
|
+
// `undefined` / `null` nested inside a larger expression tree (e.g.
|
|
297
|
+
// `props.isActive ? 'page' : undefined`) — the top-level short-circuits
|
|
298
|
+
// don't see them.
|
|
299
|
+
if (name === 'undefined' || name === 'null') return 'nil'
|
|
300
|
+
// A loop-bound name (this identifier resolves to a bare Ruby local
|
|
301
|
+
// introduced by an enclosing loop, not a vars-Hash entry) takes
|
|
302
|
+
// priority over const inlining — mirrors the Mojo adapter's
|
|
303
|
+
// `loopBoundNames` shadow guard, but here it ALSO decides the
|
|
304
|
+
// fundamental v[:name]-vs-bare-local rendering, not just the const
|
|
305
|
+
// fast path (ERB's two-locals variable model needs this distinction;
|
|
306
|
+
// Perl's uniform `$name` sigil does not — see `ErbEmitContext`).
|
|
307
|
+
if (this.ctx.isLoopBoundName(name)) return rubyLocal(name)
|
|
308
|
+
// Module pure-string const (e.g. `const baseClasses = '...'` used in a
|
|
309
|
+
// className template literal): inline the literal value rather than
|
|
310
|
+
// emit `v[:baseClasses]` against a vars-Hash key that is never seeded.
|
|
311
|
+
const inlined = this.ctx.resolveModuleStringConst(name)
|
|
312
|
+
if (inlined !== null) return inlined
|
|
313
|
+
// Same for a literal const of any scope (`const totalPages = 5`).
|
|
314
|
+
const literalConst = this.ctx.resolveLiteralConst(name)
|
|
315
|
+
if (literalConst !== null) return literalConst
|
|
316
|
+
return `v[${rubySymbolLiteral(name)}]`
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
literal(value: string | number | boolean | null, literalType: LiteralType): string {
|
|
320
|
+
if (literalType === 'string') return rubyStringLiteral(String(value))
|
|
321
|
+
if (literalType === 'boolean') return value ? 'true' : 'false'
|
|
322
|
+
if (literalType === 'null') return 'nil'
|
|
323
|
+
return String(value)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
|
|
327
|
+
// `props.x` flattens to the `v[:x]` the ERB SSR caller seeds each prop
|
|
328
|
+
// under (props arrive as vars-Hash entries, not a nested `props` Hash).
|
|
329
|
+
if (object.kind === 'identifier' && object.name === 'props') {
|
|
330
|
+
return `v[${rubySymbolLiteral(property)}]`
|
|
331
|
+
}
|
|
332
|
+
// Static property access on a module object-literal const
|
|
333
|
+
// (`variantClasses.ghost`) resolves at compile time — the generic Hash
|
|
334
|
+
// lowering below would read a vars-Hash key that doesn't exist
|
|
335
|
+
// server-side.
|
|
336
|
+
if (object.kind === 'identifier') {
|
|
337
|
+
const staticValue = this.ctx.resolveStaticRecordLiteral(object.name, property)
|
|
338
|
+
if (staticValue !== null) return staticValue
|
|
339
|
+
}
|
|
340
|
+
const obj = emit(object)
|
|
341
|
+
if (property === 'length') return `${obj}.length`
|
|
342
|
+
return `${obj}[${rubySymbolLiteral(property)}]`
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
346
|
+
return emitIndexAccessRuby(object, index, emit, n => this.ctx._isStringValueName(n))
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
|
|
350
|
+
// Signal getter: count() → v[:count]
|
|
351
|
+
if (callee.kind === 'identifier' && args.length === 0) {
|
|
352
|
+
return `v[${rubySymbolLiteral(callee.name)}]`
|
|
353
|
+
}
|
|
354
|
+
// Env-signal method call: `searchParams().get('sort')` is a real method
|
|
355
|
+
// call on the per-request search-params reader object (seeded under the
|
|
356
|
+
// reserved `v[:search_params]` key regardless of the local import
|
|
357
|
+
// alias), not the generic Hash-lookup `member` would emit. Matches the
|
|
358
|
+
// local import binding (incl. an alias).
|
|
359
|
+
if (this.ctx._searchParamsLocals.size > 0) {
|
|
360
|
+
const sp = matchSearchParamsMethodCall(callee, args, this.ctx._searchParamsLocals)
|
|
361
|
+
if (sp) {
|
|
362
|
+
return `v[:search_params].${sp.method}(${sp.args.map(emit).join(', ')})`
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
// Identifier-path templatePrimitive: `JSON.stringify(x)` /
|
|
366
|
+
// `Math.floor(x)` → `bf.json(v[:x])` / `bf.floor(v[:x])`. Args render
|
|
367
|
+
// recursively through this same emitter so prop refs / signal calls
|
|
368
|
+
// inside them get the standard transforms. A wrong-arity call records
|
|
369
|
+
// BF101 and returns the safe `''` placeholder (never silently emits a
|
|
370
|
+
// bad call).
|
|
371
|
+
const path = identifierPath(callee)
|
|
372
|
+
const spec = path ? ERB_TEMPLATE_PRIMITIVES[path] : undefined
|
|
373
|
+
if (path && spec) {
|
|
374
|
+
if (args.length === spec.arity) {
|
|
375
|
+
return spec.emit(args.map(emit))
|
|
376
|
+
}
|
|
377
|
+
this.ctx._recordExprBF101(
|
|
378
|
+
`templatePrimitive '${path}' expects ${spec.arity} arg(s), got ${args.length}`,
|
|
379
|
+
`Call '${path}' with exactly ${spec.arity} argument(s).`,
|
|
380
|
+
)
|
|
381
|
+
// Don't fall through to the generic `emit(callee)` below — for a
|
|
382
|
+
// member callee (`JSON.stringify`) that emits an invalid Ruby
|
|
383
|
+
// Hash-lookup (`v[:JSON][:stringify]`). Return the same safe
|
|
384
|
+
// empty-string placeholder the other BF101 paths use.
|
|
385
|
+
return "''"
|
|
386
|
+
}
|
|
387
|
+
// Array methods (`.join` and any others added to ArrayMethod) are
|
|
388
|
+
// lifted into the `array-method` IR kind at parse time, so they never
|
|
389
|
+
// reach this dispatcher.
|
|
390
|
+
return emit(callee)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
394
|
+
const arg = emit(argument)
|
|
395
|
+
if (op === '!') return `!bf.truthy?(${arg})`
|
|
396
|
+
if (op === '-') return `-(${arg})`
|
|
397
|
+
return arg
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
401
|
+
const l = emit(left)
|
|
402
|
+
const r = emit(right)
|
|
403
|
+
const opMap: Record<string, string> = {
|
|
404
|
+
'===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
|
|
405
|
+
'+': '+', '-': '-', '*': '*',
|
|
406
|
+
}
|
|
407
|
+
return `(${l} ${opMap[op] ?? op} ${r})`
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
411
|
+
const l = emit(left)
|
|
412
|
+
const r = emit(right)
|
|
413
|
+
if (op === '&&') return `(bf.truthy?(${l}) ? ${r} : ${l})`
|
|
414
|
+
if (op === '||') return `(bf.truthy?(${l}) ? ${l} : ${r})`
|
|
415
|
+
return `((${l}).nil? ? ${r} : ${l})`
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
callbackMethod(
|
|
419
|
+
method: string,
|
|
420
|
+
object: ParsedExpr,
|
|
421
|
+
arrow: Extract<ParsedExpr, { kind: 'arrow' }>,
|
|
422
|
+
restArgs: ParsedExpr[],
|
|
423
|
+
emit: (e: ParsedExpr) => string,
|
|
424
|
+
): string {
|
|
425
|
+
const recv = emit(object)
|
|
426
|
+
const body = arrow.body
|
|
427
|
+
const params = arrow.params
|
|
428
|
+
|
|
429
|
+
// sort / toSorted: eval-first, then the structured `bf.sort` fallback
|
|
430
|
+
// (recovered from the arrow by `sortComparatorFromArrow`, e.g. a
|
|
431
|
+
// `localeCompare` comparator), then BF101.
|
|
432
|
+
if (method === 'sort' || method === 'toSorted') {
|
|
433
|
+
const evalForm = renderSortEval(recv, body, params, emit)
|
|
434
|
+
if (evalForm !== null) return evalForm
|
|
435
|
+
const c = sortComparatorFromArrow(arrow)
|
|
436
|
+
if (c !== null) return renderSortMethod(recv, c)
|
|
437
|
+
this.ctx._recordExprBF101(
|
|
438
|
+
`.${method}(...) comparator is not lowerable to a template sort`,
|
|
439
|
+
`Pre-sort the array in the route handler, or mark the loop @client-only.`,
|
|
440
|
+
)
|
|
441
|
+
return "''"
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// reduce / reduceRight: eval-only (the arithmetic catalogue always
|
|
445
|
+
// serializes); BF101 when the body is outside the evaluator surface or
|
|
446
|
+
// the seed isn't a literal.
|
|
447
|
+
if (method === 'reduce' || method === 'reduceRight') {
|
|
448
|
+
const direction = method === 'reduceRight' ? 'right' : 'left'
|
|
449
|
+
const init = restArgs[0]
|
|
450
|
+
const evalForm =
|
|
451
|
+
init !== undefined ? renderReduceEval(recv, body, params, init, direction, emit) : null
|
|
452
|
+
if (evalForm !== null) return evalForm
|
|
453
|
+
this.ctx._recordExprBF101(
|
|
454
|
+
`.${method}(...) is not lowerable to a template fold`,
|
|
455
|
+
`Pre-compute the fold in the route handler, or mark the loop @client-only.`,
|
|
456
|
+
)
|
|
457
|
+
return "''"
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// flatMap: eval-only; BF101 when the projection is outside the surface.
|
|
461
|
+
if (method === 'flatMap') {
|
|
462
|
+
const evalForm = renderFlatMapEval(recv, body, params[0], emit)
|
|
463
|
+
if (evalForm !== null) return evalForm
|
|
464
|
+
this.ctx._recordExprBF101(
|
|
465
|
+
`.flatMap(...) projection is not lowerable to a template flat-map`,
|
|
466
|
+
`Pre-compute the projection in the route handler, or mark the loop @client-only.`,
|
|
467
|
+
)
|
|
468
|
+
return "''"
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Value-producing map: eval-only; BF101 when the projection is outside
|
|
472
|
+
// the surface. (The JSX-returning `.map` is an IRLoop upstream.)
|
|
473
|
+
if (method === 'map') {
|
|
474
|
+
const evalForm = renderMapEval(recv, body, params[0], emit)
|
|
475
|
+
if (evalForm !== null) return evalForm
|
|
476
|
+
this.ctx._recordExprBF101(
|
|
477
|
+
`.map(...) projection is not lowerable to a template map`,
|
|
478
|
+
`Pre-compute the projection in the route handler, or mark the position @client-only.`,
|
|
479
|
+
)
|
|
480
|
+
return "''"
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Predicate methods: filter / find / every / some / findIndex /
|
|
484
|
+
// findLast / findLastIndex. Eval-first, then the Ruby block / `bf.find*`
|
|
485
|
+
// fallback for a predicate the evaluator can't model.
|
|
486
|
+
const cb: PredicateCall = {
|
|
487
|
+
method: method as HigherOrderMethod,
|
|
488
|
+
object,
|
|
489
|
+
param: params[0],
|
|
490
|
+
predicate: body,
|
|
491
|
+
}
|
|
492
|
+
return this.renderPredicate(cb, recv, emit)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private renderPredicate(
|
|
496
|
+
cb: PredicateCall,
|
|
497
|
+
arrayExpr: string,
|
|
498
|
+
emit: (e: ParsedExpr) => string,
|
|
499
|
+
): string {
|
|
500
|
+
const { method, param, predicate } = cb
|
|
501
|
+
// Evaluator path: serialize the predicate body + emit the matching
|
|
502
|
+
// `bf.*_eval` helper (isomorphic with the Go/Perl adapters). Falls back
|
|
503
|
+
// to the inline Ruby-block / `bf.find*` lowering below for a predicate
|
|
504
|
+
// the evaluator can't model (e.g. a method-call predicate).
|
|
505
|
+
const evalFn: Record<string, [string, boolean?]> = {
|
|
506
|
+
filter: ['filter_eval'], every: ['every_eval'], some: ['some_eval'],
|
|
507
|
+
find: ['find_eval', true], findLast: ['find_eval', false],
|
|
508
|
+
findIndex: ['find_index_eval', true], findLastIndex: ['find_index_eval', false],
|
|
509
|
+
}
|
|
510
|
+
// `.filter(Boolean)` (identity predicate `_t => _t`) keeps the inline
|
|
511
|
+
// `.select { |x| bf.truthy?(x) }` form — it composes through the
|
|
512
|
+
// array-method chain (`.filter(Boolean).join(' ')`) and renders
|
|
513
|
+
// identically to a truthiness filter.
|
|
514
|
+
const isIdentity =
|
|
515
|
+
method === 'filter' && predicate.kind === 'identifier' && predicate.name === param
|
|
516
|
+
const spec = evalFn[method]
|
|
517
|
+
if (spec && !isIdentity) {
|
|
518
|
+
const evalForm = renderPredicateEval(spec[0], arrayExpr, predicate, param, emit, spec[1])
|
|
519
|
+
if (evalForm !== null) return evalForm
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const predBody = this.ctx._renderRubyFilterExprPublic(predicate, param)
|
|
523
|
+
const blockParam = rubyLocal(param)
|
|
524
|
+
if (method === 'filter') return `${arrayExpr}.select { |${blockParam}| bf.truthy?(${predBody}) }`
|
|
525
|
+
if (method === 'every') return `${arrayExpr}.all? { |${blockParam}| bf.truthy?(${predBody}) }`
|
|
526
|
+
if (method === 'some') return `${arrayExpr}.any? { |${blockParam}| bf.truthy?(${predBody}) }`
|
|
527
|
+
// `.find` / `.findIndex` / `.findLast` / `.findLastIndex` → the runtime
|
|
528
|
+
// helpers (`bf.find` / `find_index` / `find_last` / `find_last_index`),
|
|
529
|
+
// which call the predicate as a Ruby block per element (Ruby's native
|
|
530
|
+
// callable form — the direct analog of Xslate's Kolon lambda / Perl's
|
|
531
|
+
// coderef). The JS camelCase names map to the snake_case helpers.
|
|
532
|
+
const findHelper: Record<string, string> = {
|
|
533
|
+
find: 'find',
|
|
534
|
+
findIndex: 'find_index',
|
|
535
|
+
findLast: 'find_last',
|
|
536
|
+
findLastIndex: 'find_last_index',
|
|
537
|
+
}
|
|
538
|
+
if (findHelper[method]) {
|
|
539
|
+
return `bf.${findHelper[method]}(${arrayExpr}) { |${blockParam}| bf.truthy?(${predBody}) }`
|
|
540
|
+
}
|
|
541
|
+
return arrayExpr
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
|
|
545
|
+
// Ruby array literal. Identifiers inside elements resolve through the
|
|
546
|
+
// top-level emitter so `[className, childClass]` becomes
|
|
547
|
+
// `[v[:className], v[:childClass]]`. Empty `[]` stays as `[]`.
|
|
548
|
+
return `[${elements.map(emit).join(', ')}]`
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
arrayMethod(
|
|
552
|
+
method: ArrayMethod,
|
|
553
|
+
object: ParsedExpr,
|
|
554
|
+
args: ParsedExpr[],
|
|
555
|
+
emit: (e: ParsedExpr) => string,
|
|
556
|
+
): string {
|
|
557
|
+
return renderArrayMethod(method, object, args, emit)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
flatMethod(
|
|
561
|
+
object: ParsedExpr,
|
|
562
|
+
depth: FlatDepth | { expr: ParsedExpr },
|
|
563
|
+
emit: (e: ParsedExpr) => string,
|
|
564
|
+
): string {
|
|
565
|
+
return renderFlatMethod(emit(object), depth, emit)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
conditional(
|
|
569
|
+
test: ParsedExpr,
|
|
570
|
+
consequent: ParsedExpr,
|
|
571
|
+
alternate: ParsedExpr,
|
|
572
|
+
emit: (e: ParsedExpr) => string,
|
|
573
|
+
): string {
|
|
574
|
+
// JS ternary tests JS truthiness — wrap (see file docstring).
|
|
575
|
+
return `(bf.truthy?(${emit(test)}) ? ${emit(consequent)} : ${emit(alternate)})`
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
templateLiteral(parts: TemplatePart[], emit: (e: ParsedExpr) => string): string {
|
|
579
|
+
// `` `n=${count() + 1}` `` → Ruby string concatenation
|
|
580
|
+
// (`'n=' + bf.string(v[:count] + 1)`), NOT string interpolation — a
|
|
581
|
+
// literal chunk could itself contain `#{...}`-shaped text, and every
|
|
582
|
+
// dynamic term needs JS ToString semantics (`bf.string`, not Ruby's
|
|
583
|
+
// own `#{}` / `to_s`, e.g. JS `1.0.toString()` is `"1"`) rather than
|
|
584
|
+
// Ruby's native stringification.
|
|
585
|
+
const terms: string[] = []
|
|
586
|
+
for (const part of parts) {
|
|
587
|
+
if (part.type === 'string') {
|
|
588
|
+
if (part.value !== '') terms.push(rubyStringLiteral(part.value))
|
|
589
|
+
} else {
|
|
590
|
+
terms.push(`bf.string(${emit(part.expr)})`)
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (terms.length === 0) return "''"
|
|
594
|
+
return terms.join(' + ')
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
arrow(_params: string[], _body: ParsedExpr, _emit: (e: ParsedExpr) => string): string {
|
|
598
|
+
// A bare arrow function never stands alone at a render position (it's
|
|
599
|
+
// only meaningful as a callback argument, handled by `callbackMethod`).
|
|
600
|
+
// Return the safe Ruby empty-string literal — consistent with the
|
|
601
|
+
// BF101 / `unsupported` paths — so a stray emit can't produce an
|
|
602
|
+
// `<%= %>` syntax error.
|
|
603
|
+
return "''"
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
regex(_raw: string): string {
|
|
607
|
+
return "''"
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
unsupported(_raw: string, _reason: string): string {
|
|
611
|
+
// Unreachable in the parse-first flow: `convertExpressionToRuby` gates
|
|
612
|
+
// on `isSupported` before dispatching, and `isSupported` recurses, so a
|
|
613
|
+
// top-level supported expression never contains an `unsupported` node.
|
|
614
|
+
return "''"
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
objectLiteral(properties: ObjectLiteralProperty[], _raw: string, _emit: (e: ParsedExpr) => string): string {
|
|
618
|
+
// The shared `isSupported` gate only ever lets this dispatcher see an
|
|
619
|
+
// object literal as the EMPTY (`?? {}`) fallback operand of `??`
|
|
620
|
+
// (expression-parser.ts, `logical` case) — any other object literal is
|
|
621
|
+
// refused before reaching here. Emit Ruby's real empty Hash literal,
|
|
622
|
+
// matching the `'{}'` convention `objectLiteralToRubyHash` already uses
|
|
623
|
+
// for the zero-property case in the spread path. A populated literal is
|
|
624
|
+
// structurally unreachable given the gate, but still degrades safely to
|
|
625
|
+
// the pre-existing empty-string sentinel rather than silently dropping
|
|
626
|
+
// keys.
|
|
627
|
+
return properties.length === 0 ? '{}' : "''"
|
|
628
|
+
}
|
|
629
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operand-type classification + index-access lowering for the ERB template
|
|
3
|
+
* adapter.
|
|
4
|
+
*
|
|
5
|
+
* Ported from the Mojolicious adapter's `expr/operand.ts` (issue #2018
|
|
6
|
+
* track D lineage). Pure functions over `ParsedExpr` — they take an
|
|
7
|
+
* `isStringName` predicate (supplied by the emitter from adapter state)
|
|
8
|
+
* rather than reading adapter instance state directly.
|
|
9
|
+
*
|
|
10
|
+
* `isStringTypedOperand` is byte-identical to the Mojo/Xslate adapters'
|
|
11
|
+
* copy. `emitIndexAccessRuby` is ERB-specific: Ruby's `[]` operator is
|
|
12
|
+
* syntactically the same for Array and Hash access (unlike Perl's
|
|
13
|
+
* `->[]`/`->{}` split), but this runtime's object values are JSON-shaped
|
|
14
|
+
* Ruby Hashes with SYMBOL keys — so a string-typed index still needs a
|
|
15
|
+
* `.to_sym` conversion to become a valid Hash key, while a non-string
|
|
16
|
+
* (numeric / loop-index) index passes straight through as an Array index.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ParsedExpr } from '@barefootjs/jsx'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether a comparison/index operand is string-typed. Covers a string
|
|
23
|
+
* literal, a string-signal getter call (`sel()`), and a string prop access
|
|
24
|
+
* (`props.x`). `isStringName` reports whether a getter/prop name is
|
|
25
|
+
* known-string. Loop-element fields (`t.id`) on untyped arrays have no known
|
|
26
|
+
* type and stay undetected — a separate, narrower gap.
|
|
27
|
+
*/
|
|
28
|
+
export function isStringTypedOperand(expr: ParsedExpr, isStringName: (n: string) => boolean): boolean {
|
|
29
|
+
if (expr.kind === 'literal' && expr.literalType === 'string') return true
|
|
30
|
+
if (expr.kind === 'call' && expr.callee.kind === 'identifier' && expr.args.length === 0) {
|
|
31
|
+
return isStringName(expr.callee.name)
|
|
32
|
+
}
|
|
33
|
+
if (expr.kind === 'member' && expr.object.kind === 'identifier' && expr.object.name === 'props') {
|
|
34
|
+
return isStringName(expr.property)
|
|
35
|
+
}
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Lower `arr[index]` to a Ruby `[]` access. A string-typed index reads a
|
|
41
|
+
* JSON-shaped Hash by symbol key (`.to_sym`); any other index (the common
|
|
42
|
+
* loop-index / arithmetic case, e.g. `selected()[index]`) reads an Array by
|
|
43
|
+
* its (already-numeric) value.
|
|
44
|
+
*/
|
|
45
|
+
export function emitIndexAccessRuby(
|
|
46
|
+
object: ParsedExpr,
|
|
47
|
+
index: ParsedExpr,
|
|
48
|
+
emit: (e: ParsedExpr) => string,
|
|
49
|
+
isStringName: (n: string) => boolean,
|
|
50
|
+
): string {
|
|
51
|
+
const i = emit(index)
|
|
52
|
+
return isStringTypedOperand(index, isStringName)
|
|
53
|
+
? `${emit(object)}[(${i}).to_sym]`
|
|
54
|
+
: `${emit(object)}[${i}]`
|
|
55
|
+
}
|