@barefootjs/mojolicious 0.1.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/__tests__/boolean-result.test.d.ts +2 -0
- package/dist/adapter/__tests__/boolean-result.test.d.ts.map +1 -0
- package/dist/adapter/boolean-result.d.ts +42 -0
- package/dist/adapter/boolean-result.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 +1143 -0
- package/dist/adapter/mojo-adapter.d.ts +219 -0
- package/dist/adapter/mojo-adapter.d.ts.map +1 -0
- package/dist/build.d.ts +28 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +1163 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1143 -0
- package/dist/test-render.d.ts +38 -0
- package/dist/test-render.d.ts.map +1 -0
- package/lib/BarefootJS.pm +745 -0
- package/lib/Mojolicious/Plugin/BarefootJS/DevReload.pm +150 -0
- package/lib/Mojolicious/Plugin/BarefootJS.pm +104 -0
- package/package.json +65 -0
- package/src/__tests__/mojo-adapter.test.ts +940 -0
- package/src/__tests__/mojo-streaming.test.ts +136 -0
- package/src/__tests__/scaffold.test.ts +224 -0
- package/src/__tests__/template-base-name.test.ts +26 -0
- package/src/adapter/__tests__/boolean-result.test.ts +106 -0
- package/src/adapter/boolean-result.ts +126 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/mojo-adapter.ts +1931 -0
- package/src/build.ts +37 -0
- package/src/index.ts +8 -0
- package/src/test-render.ts +704 -0
|
@@ -0,0 +1,1931 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BarefootJS Mojolicious EP Template Adapter
|
|
3
|
+
*
|
|
4
|
+
* Generates Mojolicious EP template files (.html.ep) from BarefootJS IR.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
ComponentIR,
|
|
9
|
+
IRNode,
|
|
10
|
+
IRElement,
|
|
11
|
+
IRText,
|
|
12
|
+
IRExpression,
|
|
13
|
+
IRConditional,
|
|
14
|
+
IRLoop,
|
|
15
|
+
IRComponent,
|
|
16
|
+
IRFragment,
|
|
17
|
+
IRSlot,
|
|
18
|
+
IRIfStatement,
|
|
19
|
+
IRProvider,
|
|
20
|
+
IRAsync,
|
|
21
|
+
IRProp,
|
|
22
|
+
IRTemplatePart,
|
|
23
|
+
AttrValue,
|
|
24
|
+
CompilerError,
|
|
25
|
+
TemplatePrimitiveRegistry,
|
|
26
|
+
} from '@barefootjs/jsx'
|
|
27
|
+
import {
|
|
28
|
+
BaseAdapter,
|
|
29
|
+
type AdapterOutput,
|
|
30
|
+
type AdapterGenerateOptions,
|
|
31
|
+
type TemplateSections,
|
|
32
|
+
type ParsedExprEmitter,
|
|
33
|
+
type HigherOrderMethod,
|
|
34
|
+
type ArrayMethod,
|
|
35
|
+
type LiteralType,
|
|
36
|
+
type IRNodeEmitter,
|
|
37
|
+
type EmitIRNode,
|
|
38
|
+
type AttrValueEmitter,
|
|
39
|
+
isBooleanAttr,
|
|
40
|
+
parseExpression,
|
|
41
|
+
isSupported,
|
|
42
|
+
containsHigherOrder,
|
|
43
|
+
exprToString,
|
|
44
|
+
identifierPath,
|
|
45
|
+
stringifyParsedExpr,
|
|
46
|
+
emitParsedExpr,
|
|
47
|
+
emitIRNode,
|
|
48
|
+
emitAttrValue,
|
|
49
|
+
} from '@barefootjs/jsx'
|
|
50
|
+
import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Mojo adapter's IRNode render context. Mojo's lowering currently
|
|
54
|
+
* doesn't consume any render-position flags (`isRootOfClientComponent`
|
|
55
|
+
* is handled differently here than in Hono/Go), so the Ctx is empty.
|
|
56
|
+
* Kept as a named alias so future flags can extend it without changing
|
|
57
|
+
* the `IRNodeEmitter` interface.
|
|
58
|
+
*/
|
|
59
|
+
type MojoRenderCtx = Record<string, never>
|
|
60
|
+
import type { ParsedExpr, ParsedStatement, SortComparator, TemplatePart } from '@barefootjs/jsx'
|
|
61
|
+
import { BF_SLOT, BF_COND } from '@barefootjs/shared'
|
|
62
|
+
|
|
63
|
+
interface PrimitiveSpec {
|
|
64
|
+
arity: number
|
|
65
|
+
emit: (args: string[]) => string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Single source of truth for the Mojolicious adapter's
|
|
70
|
+
* template-primitive surface. Each entry pairs the expected arity
|
|
71
|
+
* with the emit function. Adding / removing a primitive is a
|
|
72
|
+
* one-line change.
|
|
73
|
+
*
|
|
74
|
+
* The emit fn returns a Perl expression (no surrounding `<%= %>`)
|
|
75
|
+
* suitable for embedding inside the Mojo template action —
|
|
76
|
+
* `bf->json($val)`, `bf->floor($val)`, etc. Args arrive already
|
|
77
|
+
* Perl-rendered via `convertExpressionToPerl` recursion, so a
|
|
78
|
+
* caller passing `props.config` reaches the emit fn as `$config`.
|
|
79
|
+
*/
|
|
80
|
+
const MOJO_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
|
|
81
|
+
'JSON.stringify': { arity: 1, emit: (args) => `bf->json(${args[0]})` },
|
|
82
|
+
'String': { arity: 1, emit: (args) => `bf->string(${args[0]})` },
|
|
83
|
+
'Number': { arity: 1, emit: (args) => `bf->number(${args[0]})` },
|
|
84
|
+
'Math.floor': { arity: 1, emit: (args) => `bf->floor(${args[0]})` },
|
|
85
|
+
'Math.ceil': { arity: 1, emit: (args) => `bf->ceil(${args[0]})` },
|
|
86
|
+
'Math.round': { arity: 1, emit: (args) => `bf->round(${args[0]})` },
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Cheap substring pre-check: skip the (expensive) `parseExpression`
|
|
91
|
+
* call when no primitive callee path appears in the source string.
|
|
92
|
+
* The common case is "no primitive present"; building the regex
|
|
93
|
+
* once from the registry keys keeps the gate in sync as new
|
|
94
|
+
* primitives land.
|
|
95
|
+
*/
|
|
96
|
+
const PRIMITIVE_SUBSTRING_RE = new RegExp(
|
|
97
|
+
Object.keys(MOJO_TEMPLATE_PRIMITIVES)
|
|
98
|
+
.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
99
|
+
.join('|')
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Module-scope `templatePrimitives` map derived once from the spec
|
|
104
|
+
* record. Per-instance derivation would re-build the same Map on
|
|
105
|
+
* every `new MojoAdapter()` call.
|
|
106
|
+
*/
|
|
107
|
+
const MOJO_PRIMITIVE_EMIT_MAP: Record<string, (args: string[]) => string> =
|
|
108
|
+
Object.fromEntries(
|
|
109
|
+
Object.entries(MOJO_TEMPLATE_PRIMITIVES).map(([k, v]) => [k, v.emit])
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Find the `children` prop's `jsx-children` payload (#1326). Narrowed
|
|
114
|
+
* via the AttrValue `kind` discriminator so adapter code stays type-
|
|
115
|
+
* safe if the IR shape evolves — adding a new AttrValue variant or
|
|
116
|
+
* renaming `children` to `jsxChildren` becomes a TS compile error
|
|
117
|
+
* here instead of silently dropping the children at runtime.
|
|
118
|
+
*/
|
|
119
|
+
function resolveJsxChildrenProp(props: readonly IRProp[]): IRNode[] {
|
|
120
|
+
const prop = props.find(p => p.name === 'children')
|
|
121
|
+
if (!prop) return []
|
|
122
|
+
if (prop.value.kind !== 'jsx-children') return []
|
|
123
|
+
return prop.value.children
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface MojoAdapterOptions {
|
|
127
|
+
/** Base path for client JS files (default: '/static/components/') */
|
|
128
|
+
clientJsBasePath?: string
|
|
129
|
+
|
|
130
|
+
/** Path to barefoot.js runtime (default: '/static/components/barefoot.js') */
|
|
131
|
+
barefootJsPath?: string
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRenderCtx> {
|
|
135
|
+
name = 'mojolicious'
|
|
136
|
+
extension = '.html.ep'
|
|
137
|
+
templatesPerComponent = true
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Identifier-path callees the Mojo runtime can render in template
|
|
141
|
+
* scope. The relocate pass consults this map to mark matching
|
|
142
|
+
* calls as template-safe so the surrounding expression stays
|
|
143
|
+
* inlinable; the SSR template emitter substitutes the JS call
|
|
144
|
+
* with the registered Perl helper invocation.
|
|
145
|
+
*
|
|
146
|
+
* The per-callee arity is read directly off `MOJO_TEMPLATE_PRIMITIVES`
|
|
147
|
+
* at substitution time, so this exposed shape stays as the
|
|
148
|
+
* `TemplateAdapter` interface expects (`emit`-only) without
|
|
149
|
+
* carrying a parallel arity map.
|
|
150
|
+
*/
|
|
151
|
+
templatePrimitives: TemplatePrimitiveRegistry = MOJO_PRIMITIVE_EMIT_MAP
|
|
152
|
+
|
|
153
|
+
private componentName: string = ''
|
|
154
|
+
private options: Required<MojoAdapterOptions>
|
|
155
|
+
private errors: CompilerError[] = []
|
|
156
|
+
private inLoop: boolean = false
|
|
157
|
+
/**
|
|
158
|
+
* Re-entry guard for `convertHigherOrderExpr` (#1421).
|
|
159
|
+
*
|
|
160
|
+
* `MojoTopLevelEmitter.unsupported` falls back to the regex pipeline
|
|
161
|
+
* via `_convertExpressionToPerlPublic`, which re-detects the
|
|
162
|
+
* `.filter|every|some` short-circuit and re-enters
|
|
163
|
+
* `convertHigherOrderExpr` with the same raw text. When the parser
|
|
164
|
+
* carries the full original expression down to every nested
|
|
165
|
+
* `unsupported` node (e.g. an array-literal callee that the AST
|
|
166
|
+
* can't classify), the cycle has no terminator and the JS stack
|
|
167
|
+
* blows. The guard records the expression on entry, emits BF101 on
|
|
168
|
+
* second visit, and bails out — so the user sees an actionable
|
|
169
|
+
* diagnostic instead of `RangeError: Maximum call stack size`.
|
|
170
|
+
*/
|
|
171
|
+
private higherOrderInFlight: Set<string> = new Set()
|
|
172
|
+
/**
|
|
173
|
+
* SolidJS-style props identifier (`function(props: P)`) and the
|
|
174
|
+
* analyzer-extracted prop names. Stashed at `generate()` entry so
|
|
175
|
+
* the per-attribute `emitSpread` callback can build a propsObject
|
|
176
|
+
* spread bag as an inline Perl hashref literal without re-walking
|
|
177
|
+
* the IR (#1407 follow-up).
|
|
178
|
+
*/
|
|
179
|
+
private propsObjectName: string | null = null
|
|
180
|
+
private propsParams: { name: string }[] = []
|
|
181
|
+
|
|
182
|
+
constructor(options: MojoAdapterOptions = {}) {
|
|
183
|
+
super()
|
|
184
|
+
this.options = {
|
|
185
|
+
clientJsBasePath: options.clientJsBasePath ?? '/static/components/',
|
|
186
|
+
barefootJsPath: options.barefootJsPath ?? '/static/components/barefoot.js',
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
generate(ir: ComponentIR, options?: AdapterGenerateOptions): AdapterOutput {
|
|
191
|
+
this.componentName = ir.metadata.componentName
|
|
192
|
+
this.propsObjectName = ir.metadata.propsObjectName ?? null
|
|
193
|
+
this.propsParams = ir.metadata.propsParams.map(p => ({ name: p.name }))
|
|
194
|
+
this.errors = []
|
|
195
|
+
this.higherOrderInFlight = new Set()
|
|
196
|
+
this.childrenCaptureCounter = 0
|
|
197
|
+
|
|
198
|
+
// Mirror of the Go adapter's BF103 check (#1266): when a child
|
|
199
|
+
// component referenced inside a loop body is imported from a
|
|
200
|
+
// sibling .tsx, the Mojo adapter emits a `<%== bf->render(...)
|
|
201
|
+
// %>`-style cross-template call that resolves only if the user
|
|
202
|
+
// has compiled the sibling file and registered the resulting
|
|
203
|
+
// template alongside the parent. When that doesn't happen the
|
|
204
|
+
// failure is silent at build time and surfaces at request time —
|
|
205
|
+
// surface it loudly here so the user can act on it. Suppressed
|
|
206
|
+
// when the caller (e.g. the barefoot CLI) guarantees that all
|
|
207
|
+
// sibling templates are registered on the same template instance
|
|
208
|
+
// at render time.
|
|
209
|
+
if (!options?.siblingTemplatesRegistered) {
|
|
210
|
+
this.checkImportedLoopChildComponents(ir)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const templateBody = ir.root.type === 'if-statement'
|
|
214
|
+
? this.renderIfStatement(ir.root as IRIfStatement)
|
|
215
|
+
: this.renderNode(ir.root)
|
|
216
|
+
|
|
217
|
+
// Generate script registration
|
|
218
|
+
const scriptReg = options?.skipScriptRegistration
|
|
219
|
+
? ''
|
|
220
|
+
: this.generateScriptRegistrations(ir, options?.scriptBaseName)
|
|
221
|
+
|
|
222
|
+
const template = `${scriptReg}${templateBody}\n`
|
|
223
|
+
|
|
224
|
+
// Merge collected errors into IR errors
|
|
225
|
+
if (this.errors.length > 0) {
|
|
226
|
+
ir.errors.push(...this.errors)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Mojo templates have no JS-style imports / types / default-export sections.
|
|
230
|
+
// The `templatesPerComponent` mode emits one file per component using the
|
|
231
|
+
// raw `template` value; sections are populated for contract uniformity so
|
|
232
|
+
// the compiler never has to fall back to string-parsing the template.
|
|
233
|
+
const sections: TemplateSections = {
|
|
234
|
+
imports: '',
|
|
235
|
+
types: '',
|
|
236
|
+
component: template,
|
|
237
|
+
defaultExport: '',
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
template,
|
|
242
|
+
sections,
|
|
243
|
+
extension: this.extension,
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ===========================================================================
|
|
248
|
+
// Script Registration
|
|
249
|
+
// ===========================================================================
|
|
250
|
+
|
|
251
|
+
private generateScriptRegistrations(ir: ComponentIR, scriptBaseName?: string): string {
|
|
252
|
+
const hasInteractivity = this.hasClientInteractivity(ir)
|
|
253
|
+
if (!hasInteractivity) return ''
|
|
254
|
+
|
|
255
|
+
const name = scriptBaseName ?? ir.metadata.componentName
|
|
256
|
+
const runtimePath = this.options.barefootJsPath
|
|
257
|
+
const clientJsPath = `${this.options.clientJsBasePath}${name}.client.js`
|
|
258
|
+
|
|
259
|
+
const lines: string[] = []
|
|
260
|
+
lines.push(`% bf->register_script('${runtimePath}');`)
|
|
261
|
+
lines.push(`% bf->register_script('${clientJsPath}');`)
|
|
262
|
+
lines.push('')
|
|
263
|
+
return lines.join('\n')
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private hasClientInteractivity(ir: ComponentIR): boolean {
|
|
267
|
+
return (
|
|
268
|
+
ir.metadata.signals.length > 0 ||
|
|
269
|
+
ir.metadata.effects.length > 0 ||
|
|
270
|
+
ir.metadata.onMounts.length > 0 ||
|
|
271
|
+
(ir.metadata.clientAnalysis?.needsInit ?? false)
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ===========================================================================
|
|
276
|
+
// Node Rendering
|
|
277
|
+
// ===========================================================================
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Public entry point for node rendering. Delegates to the shared
|
|
281
|
+
* `IRNodeEmitter` dispatcher (#1290 step 1); per-kind logic lives in
|
|
282
|
+
* the `IRNodeEmitter` methods below.
|
|
283
|
+
*/
|
|
284
|
+
renderNode(node: IRNode): string {
|
|
285
|
+
return emitIRNode<MojoRenderCtx>(node, this, {} as MojoRenderCtx)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ===========================================================================
|
|
289
|
+
// IRNodeEmitter implementation (Mojo / Perl)
|
|
290
|
+
// ===========================================================================
|
|
291
|
+
|
|
292
|
+
emitElement(node: IRElement, _ctx: MojoRenderCtx, _emit: EmitIRNode<MojoRenderCtx>): string {
|
|
293
|
+
return this.renderElement(node)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
emitText(node: IRText): string {
|
|
297
|
+
return node.value
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
emitExpression(node: IRExpression): string {
|
|
301
|
+
return this.renderExpression(node)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
emitConditional(node: IRConditional, _ctx: MojoRenderCtx, _emit: EmitIRNode<MojoRenderCtx>): string {
|
|
305
|
+
return this.renderConditional(node)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
emitLoop(node: IRLoop, _ctx: MojoRenderCtx, _emit: EmitIRNode<MojoRenderCtx>): string {
|
|
309
|
+
return this.renderLoop(node)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
emitComponent(node: IRComponent, _ctx: MojoRenderCtx, _emit: EmitIRNode<MojoRenderCtx>): string {
|
|
313
|
+
return this.renderComponent(node)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
emitFragment(node: IRFragment, _ctx: MojoRenderCtx, _emit: EmitIRNode<MojoRenderCtx>): string {
|
|
317
|
+
return this.renderFragment(node)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
emitSlot(node: IRSlot): string {
|
|
321
|
+
return this.renderSlot(node)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
emitIfStatement(node: IRIfStatement, _ctx: MojoRenderCtx, _emit: EmitIRNode<MojoRenderCtx>): string {
|
|
325
|
+
return this.renderIfStatement(node)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
emitProvider(node: IRProvider, _ctx: MojoRenderCtx, _emit: EmitIRNode<MojoRenderCtx>): string {
|
|
329
|
+
return this.renderChildren(node.children)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
emitAsync(node: IRAsync, _ctx: MojoRenderCtx, _emit: EmitIRNode<MojoRenderCtx>): string {
|
|
333
|
+
return this.renderAsync(node)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ===========================================================================
|
|
337
|
+
// Element Rendering
|
|
338
|
+
// ===========================================================================
|
|
339
|
+
|
|
340
|
+
renderElement(element: IRElement): string {
|
|
341
|
+
const tag = element.tag
|
|
342
|
+
const attrs = this.renderAttributes(element)
|
|
343
|
+
const children = this.renderChildren(element.children)
|
|
344
|
+
|
|
345
|
+
let hydrationAttrs = ''
|
|
346
|
+
if (element.needsScope) {
|
|
347
|
+
hydrationAttrs += ` ${this.renderScopeMarker('')}`
|
|
348
|
+
}
|
|
349
|
+
if (element.slotId) {
|
|
350
|
+
hydrationAttrs += ` ${this.renderSlotMarker(element.slotId)}`
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const voidElements = [
|
|
354
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
355
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
356
|
+
]
|
|
357
|
+
|
|
358
|
+
if (voidElements.includes(tag.toLowerCase())) {
|
|
359
|
+
return `<${tag}${attrs}${hydrationAttrs}>`
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ===========================================================================
|
|
366
|
+
// Expression Rendering
|
|
367
|
+
// ===========================================================================
|
|
368
|
+
|
|
369
|
+
renderExpression(expr: IRExpression): string {
|
|
370
|
+
if (expr.clientOnly) {
|
|
371
|
+
if (expr.slotId) {
|
|
372
|
+
return `<%== bf->comment("client:${expr.slotId}") %>`
|
|
373
|
+
}
|
|
374
|
+
return ''
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const perlExpr = this.convertExpressionToPerl(expr.expr)
|
|
378
|
+
|
|
379
|
+
if (expr.slotId) {
|
|
380
|
+
return `<%== bf->text_start("${expr.slotId}") %><%= ${perlExpr} %><%== bf->text_end %>`
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return `<%= ${perlExpr} %>`
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ===========================================================================
|
|
387
|
+
// Conditional Rendering
|
|
388
|
+
// ===========================================================================
|
|
389
|
+
|
|
390
|
+
renderConditional(cond: IRConditional): string {
|
|
391
|
+
if (cond.clientOnly && cond.slotId) {
|
|
392
|
+
return `<%== bf->comment("cond-start:${cond.slotId}") %><%== bf->comment("cond-end:${cond.slotId}") %>`
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const condition = this.convertExpressionToPerl(cond.condition)
|
|
396
|
+
const whenTrue = this.renderNode(cond.whenTrue)
|
|
397
|
+
const whenFalse = this.renderNodeOrNull(cond.whenFalse)
|
|
398
|
+
|
|
399
|
+
// When slotId is present, add bf-c marker.
|
|
400
|
+
// Use comment markers for fragments (multiple sibling elements), attribute for single elements.
|
|
401
|
+
const isFragmentBranch = cond.whenTrue.type === 'fragment' || cond.whenFalse.type === 'fragment'
|
|
402
|
+
const useCommentMarkers = cond.slotId && isFragmentBranch
|
|
403
|
+
|
|
404
|
+
let markedTrue = whenTrue
|
|
405
|
+
let markedFalse = whenFalse
|
|
406
|
+
if (cond.slotId && !useCommentMarkers) {
|
|
407
|
+
markedTrue = this.addCondMarkerToFirstElement(whenTrue, cond.slotId)
|
|
408
|
+
markedFalse = whenFalse ? this.addCondMarkerToFirstElement(whenFalse, cond.slotId) : whenFalse
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
let result: string
|
|
412
|
+
if (useCommentMarkers) {
|
|
413
|
+
// Fragment branches: use comment markers
|
|
414
|
+
const inner = whenFalse
|
|
415
|
+
? `\n% if (${condition}) {\n${whenTrue}\n% } else {\n${whenFalse}\n% }\n`
|
|
416
|
+
: `\n% if (${condition}) {\n${whenTrue}\n% }\n`
|
|
417
|
+
result = `<%== bf->comment("cond-start:${cond.slotId}") %>${inner}<%== bf->comment("cond-end:${cond.slotId}") %>`
|
|
418
|
+
} else if (markedFalse) {
|
|
419
|
+
result = `\n% if (${condition}) {\n${markedTrue}\n% } else {\n${markedFalse}\n% }\n`
|
|
420
|
+
} else if (cond.slotId) {
|
|
421
|
+
// Conditional with no else: wrap with comment markers for client hydration
|
|
422
|
+
result = `<%== bf->comment("cond-start:${cond.slotId}") %>\n% if (${condition}) {\n${whenTrue}\n% }\n<%== bf->comment("cond-end:${cond.slotId}") %>`
|
|
423
|
+
} else {
|
|
424
|
+
result = `\n% if (${condition}) {\n${whenTrue}\n% }\n`
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return result
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private renderNodeOrNull(node: IRNode): string | null {
|
|
431
|
+
if (node.type === 'expression' && (node.expr === 'null' || node.expr === 'undefined')) {
|
|
432
|
+
return null
|
|
433
|
+
}
|
|
434
|
+
return this.renderNode(node)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Add bf-c attribute to the first HTML element in a branch.
|
|
439
|
+
* If no element found, wrap with comment markers.
|
|
440
|
+
*/
|
|
441
|
+
private addCondMarkerToFirstElement(content: string, condId: string): string {
|
|
442
|
+
// Match first HTML open tag
|
|
443
|
+
const match = content.match(/^(<\w+)([\s>])/)
|
|
444
|
+
if (match) {
|
|
445
|
+
return content.replace(/^(<\w+)([\s>])/, `$1 ${BF_COND}="${condId}"$2`)
|
|
446
|
+
}
|
|
447
|
+
// Fall back to comment markers for non-element content
|
|
448
|
+
return `<%== bf->comment("cond-start:${condId}") %>${content}<%== bf->comment("cond-end:${condId}") %>`
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ===========================================================================
|
|
452
|
+
// Imported-component-in-loop check (BF103, #1266)
|
|
453
|
+
// ===========================================================================
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Push a `BF103` diagnostic for every component reference inside a
|
|
457
|
+
* loop body whose name is imported from a relative-path module.
|
|
458
|
+
* Mirror of the Go adapter's check — the Mojo adapter has the same
|
|
459
|
+
* cross-template-registration constraint at request time.
|
|
460
|
+
*/
|
|
461
|
+
private checkImportedLoopChildComponents(ir: ComponentIR): void {
|
|
462
|
+
// Collect every name imported from a relative-path module (no
|
|
463
|
+
// case filter — `IRComponent` nodes only exist for PascalCase JSX
|
|
464
|
+
// usages, so a lowercase utility import in the set can't match
|
|
465
|
+
// anyway, and any heuristic on the import name itself would be
|
|
466
|
+
// strictly less robust than the structural IR check below).
|
|
467
|
+
const relativeImports = new Set<string>()
|
|
468
|
+
for (const imp of ir.metadata.templateImports ?? ir.metadata.imports ?? []) {
|
|
469
|
+
if (!imp.source.startsWith('./') && !imp.source.startsWith('../')) continue
|
|
470
|
+
if (imp.isTypeOnly) continue
|
|
471
|
+
for (const spec of imp.specifiers) {
|
|
472
|
+
relativeImports.add(spec.alias ?? spec.name)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (relativeImports.size === 0) return
|
|
476
|
+
|
|
477
|
+
const loc = { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } }
|
|
478
|
+
const visit = (node: IRNode, inLoop: boolean): void => {
|
|
479
|
+
switch (node.type) {
|
|
480
|
+
case 'component': {
|
|
481
|
+
const comp = node as IRComponent
|
|
482
|
+
if (inLoop && relativeImports.has(comp.name)) {
|
|
483
|
+
this.errors.push({
|
|
484
|
+
code: 'BF103',
|
|
485
|
+
severity: 'error',
|
|
486
|
+
message: `Component <${comp.name}> is imported from a sibling module and used inside a loop. The Mojo adapter emits a cross-template call; the child template must be registered alongside the parent at render time.`,
|
|
487
|
+
loc: comp.loc ?? loc,
|
|
488
|
+
suggestion: {
|
|
489
|
+
message:
|
|
490
|
+
`Options:\n` +
|
|
491
|
+
` 1. Compile '${comp.name}' (its source file) with the same adapter and register the resulting Mojo template alongside the parent at render time.\n` +
|
|
492
|
+
` 2. Inline <${comp.name}> directly inside the loop body so no cross-file template lookup is needed.\n` +
|
|
493
|
+
` 3. Mark the loop position as @client-only so the template is materialised on the client instead of at SSR time.`,
|
|
494
|
+
},
|
|
495
|
+
})
|
|
496
|
+
}
|
|
497
|
+
for (const child of comp.children) visit(child, inLoop)
|
|
498
|
+
break
|
|
499
|
+
}
|
|
500
|
+
case 'element':
|
|
501
|
+
for (const child of (node as IRElement).children) visit(child, inLoop)
|
|
502
|
+
break
|
|
503
|
+
case 'fragment':
|
|
504
|
+
for (const child of (node as IRFragment).children) visit(child, inLoop)
|
|
505
|
+
break
|
|
506
|
+
case 'conditional': {
|
|
507
|
+
const cond = node as IRConditional
|
|
508
|
+
visit(cond.whenTrue, inLoop)
|
|
509
|
+
if (cond.whenFalse) visit(cond.whenFalse, inLoop)
|
|
510
|
+
break
|
|
511
|
+
}
|
|
512
|
+
case 'loop':
|
|
513
|
+
for (const child of (node as IRLoop).children) visit(child, true)
|
|
514
|
+
break
|
|
515
|
+
case 'if-statement': {
|
|
516
|
+
const stmt = node as IRIfStatement
|
|
517
|
+
visit(stmt.consequent, inLoop)
|
|
518
|
+
if (stmt.alternate) visit(stmt.alternate, inLoop)
|
|
519
|
+
break
|
|
520
|
+
}
|
|
521
|
+
case 'provider':
|
|
522
|
+
for (const child of (node as IRProvider).children) visit(child, inLoop)
|
|
523
|
+
break
|
|
524
|
+
case 'async': {
|
|
525
|
+
const a = node as IRAsync
|
|
526
|
+
visit(a.fallback, inLoop)
|
|
527
|
+
for (const child of a.children) visit(child, inLoop)
|
|
528
|
+
break
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
visit(ir.root, false)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ===========================================================================
|
|
536
|
+
// Loop Rendering
|
|
537
|
+
// ===========================================================================
|
|
538
|
+
|
|
539
|
+
renderLoop(loop: IRLoop): string {
|
|
540
|
+
// Client-only loops: skip SSR rendering entirely
|
|
541
|
+
if (loop.clientOnly) return ''
|
|
542
|
+
|
|
543
|
+
// An array/object-destructure loop param (`([emoji, users]) => ...`
|
|
544
|
+
// or `({ name, age }) => ...`) lowers to invalid Perl — the adapter
|
|
545
|
+
// would otherwise emit `% my $[emoji, users] = $entries->[$_i];`,
|
|
546
|
+
// which is a parse error. Surface this at build time (#1266)
|
|
547
|
+
// instead of shipping the broken template line for the user to
|
|
548
|
+
// discover at request time.
|
|
549
|
+
//
|
|
550
|
+
// Check the IR's structured `paramBindings` field rather than
|
|
551
|
+
// string-matching `loop.param`: Phase 1 populates `paramBindings`
|
|
552
|
+
// iff the param is a destructure pattern (array or object); a
|
|
553
|
+
// simple identifier leaves it `undefined`. The structured check is
|
|
554
|
+
// robust to whitespace / formatting variants in the source.
|
|
555
|
+
if (loop.paramBindings && loop.paramBindings.length > 0) {
|
|
556
|
+
this.errors.push({
|
|
557
|
+
code: 'BF104',
|
|
558
|
+
severity: 'error',
|
|
559
|
+
message: `Loop callback uses an array/object destructure pattern (\`${loop.param}\`) that the Mojo adapter cannot lower — Perl scalar bindings can't unpack a tuple in a single \`my\` declaration.`,
|
|
560
|
+
loc: loop.loc ?? { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
561
|
+
suggestion: {
|
|
562
|
+
message:
|
|
563
|
+
`Options:\n` +
|
|
564
|
+
` 1. Rename the parameter to a single name and access tuple elements with index syntax in the body (e.g. \`entry => entry->[0]\` instead of \`([k, v]) => ...\`).\n` +
|
|
565
|
+
` 2. Mark the loop position as @client-only so the destructure runs in JS on the client.\n` +
|
|
566
|
+
` 3. Move the loop into a primitive that the adapter registers explicitly.`,
|
|
567
|
+
},
|
|
568
|
+
})
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const rawArray = this.convertExpressionToPerl(loop.array)
|
|
572
|
+
// Apply sort if present (#1448 Tier B): wrap the loop array in
|
|
573
|
+
// the shared `bf->sort` helper. The same `renderSortMethod`
|
|
574
|
+
// feeds both this loop-chain hoist and the standalone
|
|
575
|
+
// `sortMethod()` arm on the emitter, so a regression in either
|
|
576
|
+
// path surfaces with the identical emit shape.
|
|
577
|
+
//
|
|
578
|
+
// Sort hoist: the loop bound (`0..$#{…}`) and the per-item
|
|
579
|
+
// lookup (`…->[$_i]`) both reference the same array — if the
|
|
580
|
+
// expression is a method call like `bf->sort(...)`, naive
|
|
581
|
+
// splicing would call the helper twice per render. Bind the
|
|
582
|
+
// sorted result to a `my` local so the helper runs once.
|
|
583
|
+
let sortedHoist: string | null = null
|
|
584
|
+
let array = rawArray
|
|
585
|
+
if (loop.sortComparator) {
|
|
586
|
+
sortedHoist = `bf_iter_${perlIdentifierFromMarkerId(loop.markerId)}`
|
|
587
|
+
array = `$${sortedHoist}`
|
|
588
|
+
}
|
|
589
|
+
const param = loop.param
|
|
590
|
+
const indexVar = loop.index ? `$${loop.index}` : '$_i'
|
|
591
|
+
const prevInLoop = this.inLoop
|
|
592
|
+
this.inLoop = true
|
|
593
|
+
const children = this.renderChildren(loop.children)
|
|
594
|
+
this.inLoop = prevInLoop
|
|
595
|
+
|
|
596
|
+
const lines: string[] = []
|
|
597
|
+
// Scoped per-call-site marker so sibling `.map()`s under the same parent
|
|
598
|
+
// each get their own reconciliation range (#1087).
|
|
599
|
+
lines.push(`<%== bf->comment("loop:${loop.markerId}") %>`)
|
|
600
|
+
if (sortedHoist && loop.sortComparator) {
|
|
601
|
+
lines.push(`% my $${sortedHoist} = ${renderSortMethod(rawArray, loop.sortComparator)};`)
|
|
602
|
+
}
|
|
603
|
+
lines.push(`% for my ${indexVar} (0..$#{${array}}) {`)
|
|
604
|
+
lines.push(`% my $${param} = ${array}->[${indexVar}];`)
|
|
605
|
+
|
|
606
|
+
// Handle filter().map() pattern by wrapping children in if-condition
|
|
607
|
+
if (loop.filterPredicate) {
|
|
608
|
+
let filterCond: string
|
|
609
|
+
if (loop.filterPredicate.blockBody) {
|
|
610
|
+
filterCond = this.renderBlockBodyCondition(
|
|
611
|
+
loop.filterPredicate.blockBody,
|
|
612
|
+
loop.filterPredicate.param
|
|
613
|
+
)
|
|
614
|
+
} else if (loop.filterPredicate.predicate) {
|
|
615
|
+
filterCond = this.renderPerlFilterExpr(
|
|
616
|
+
loop.filterPredicate.predicate,
|
|
617
|
+
loop.filterPredicate.param
|
|
618
|
+
)
|
|
619
|
+
} else {
|
|
620
|
+
filterCond = '1'
|
|
621
|
+
}
|
|
622
|
+
// Map filter param to loop param (e.g., $t → $todo)
|
|
623
|
+
if (loop.filterPredicate.param !== param) {
|
|
624
|
+
filterCond = filterCond.replace(
|
|
625
|
+
new RegExp(`\\$${loop.filterPredicate.param}\\b`, 'g'),
|
|
626
|
+
`$${param}`
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
lines.push(`% if (${filterCond}) {`)
|
|
630
|
+
lines.push(children)
|
|
631
|
+
lines.push(`% }`)
|
|
632
|
+
} else {
|
|
633
|
+
lines.push(children)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
lines.push(`% }`)
|
|
637
|
+
lines.push(`<%== bf->comment("/loop:${loop.markerId}") %>`)
|
|
638
|
+
|
|
639
|
+
return lines.join('\n')
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ===========================================================================
|
|
643
|
+
// Component Rendering
|
|
644
|
+
// ===========================================================================
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* AttrValue lowering for component invocation props (Mojo / Perl
|
|
648
|
+
* named-arg form). Routed through the shared dispatcher so a new
|
|
649
|
+
* AttrValue kind becomes a TS compile error here (#1290 step 2).
|
|
650
|
+
*
|
|
651
|
+
* `jsx-children` returns empty — children are captured via Mojo's
|
|
652
|
+
* `begin %>…<% end` block below, not threaded through the
|
|
653
|
+
* `render_child` named-arg list.
|
|
654
|
+
*/
|
|
655
|
+
private readonly componentPropEmitter: AttrValueEmitter = {
|
|
656
|
+
emitLiteral: (value, name) => `${name} => '${value.value}'`,
|
|
657
|
+
emitExpression: (value, name) => {
|
|
658
|
+
// The IR producer collapses component-prop `template` kinds
|
|
659
|
+
// into `expression` for client-runtime reasons but preserves
|
|
660
|
+
// the parsed parts on `v.parts`. Prefer the structured form
|
|
661
|
+
// when available — the bare-expression path can't handle
|
|
662
|
+
// `${MAP[KEY]}` shapes (the JS object literal leaks into the
|
|
663
|
+
// Perl template).
|
|
664
|
+
if (value.parts) {
|
|
665
|
+
return `${name} => ${this.convertTemplateLiteralPartsToPerl(value.parts)}`
|
|
666
|
+
}
|
|
667
|
+
return `${name} => ${this.convertExpressionToPerl(value.expr)}`
|
|
668
|
+
},
|
|
669
|
+
emitSpread: (value) => {
|
|
670
|
+
// Perl has no JS-style spread — emit the source as a hash
|
|
671
|
+
// dereference so its entries flatten into the named-arg list
|
|
672
|
+
// `render_child` accepts. `$props` already comes in as a hashref
|
|
673
|
+
// by `render_child`'s calling convention; deref with `%{}`. If
|
|
674
|
+
// `convertExpressionToPerl` already produced a hash variable
|
|
675
|
+
// (`%foo`), leave as-is.
|
|
676
|
+
const perlExpr = this.convertExpressionToPerl(value.expr)
|
|
677
|
+
return perlExpr.startsWith('%') ? perlExpr : `%{${perlExpr}}`
|
|
678
|
+
},
|
|
679
|
+
emitTemplate: (value, name) =>
|
|
680
|
+
`${name} => ${this.convertTemplateLiteralPartsToPerl(value.parts)}`,
|
|
681
|
+
emitBooleanAttr: (_value, name) => `${name} => 1`,
|
|
682
|
+
emitBooleanShorthand: (_value, name) => `${name} => 1`,
|
|
683
|
+
// JSX children flow through Mojo's `begin %>…<% end` capture
|
|
684
|
+
// below; they're not part of the named-arg list.
|
|
685
|
+
emitJsxChildren: () => '',
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
renderComponent(comp: IRComponent): string {
|
|
689
|
+
const propParts: string[] = []
|
|
690
|
+
for (const p of comp.props) {
|
|
691
|
+
// Skip callback props (onXxx) — event handlers are client-only for SSR.
|
|
692
|
+
if (p.name.match(/^on[A-Z]/) && p.value.kind === 'expression') continue
|
|
693
|
+
const lowered = emitAttrValue(p.value, this.componentPropEmitter, p.name)
|
|
694
|
+
if (lowered) propParts.push(lowered)
|
|
695
|
+
}
|
|
696
|
+
// Pass slot ID so the child renderer can set correct scope ID for hydration
|
|
697
|
+
// Skip for loop children — they use ComponentName_random pattern instead
|
|
698
|
+
if (comp.slotId && !this.inLoop) {
|
|
699
|
+
propParts.push(`_bf_slot => '${comp.slotId}'`)
|
|
700
|
+
}
|
|
701
|
+
const propsStr = propParts.length > 0 ? ', ' + propParts.join(', ') : ''
|
|
702
|
+
const tplName = this.toTemplateName(comp.name)
|
|
703
|
+
// Resolve the effective children: a nested `<Box>…</Box>` populates
|
|
704
|
+
// `comp.children`; an attribute-form `<Box children={<jsx/>} />`
|
|
705
|
+
// lands in a `jsx-children` AttrValue on the corresponding prop
|
|
706
|
+
// (#1326). The parent's scope marker is already attached to each
|
|
707
|
+
// hoisted root by the IR collector (`needsScope: true`), so the
|
|
708
|
+
// adapter just needs to render the IR through the same children
|
|
709
|
+
// pipeline as the nested form. Narrow the prop value via the
|
|
710
|
+
// `kind` discriminator instead of casting to a hand-written shape;
|
|
711
|
+
// any future change to the `jsx-children` AttrValue surface will
|
|
712
|
+
// surface here as a TS compile error.
|
|
713
|
+
const effectiveChildren: IRNode[] = comp.children.length > 0
|
|
714
|
+
? comp.children
|
|
715
|
+
: resolveJsxChildrenProp(comp.props)
|
|
716
|
+
if (effectiveChildren.length > 0) {
|
|
717
|
+
// Forward JSX children via Mojo's `begin %>...<% end` capture so
|
|
718
|
+
// dynamic segments inside the children (signals, conditionals)
|
|
719
|
+
// get evaluated in the parent's template scope before reaching
|
|
720
|
+
// the child renderer. The capture has to live in a separate
|
|
721
|
+
// action — embedding it inside the `<%== ... %>` that wraps
|
|
722
|
+
// `render_child` would let the inner `%>` close the outer tag.
|
|
723
|
+
// `render_child` materializes the resulting CODE ref into the
|
|
724
|
+
// captured Mojo::ByteStream.
|
|
725
|
+
const childrenBody = this.renderChildren(effectiveChildren)
|
|
726
|
+
const varName = `$bf_children_${comp.slotId ?? 'c' + this.childrenCaptureCounter++}`
|
|
727
|
+
return `<% my ${varName} = begin %>${childrenBody}<% end %><%== bf->render_child('${tplName}'${propsStr}, children => ${varName}) %>`
|
|
728
|
+
}
|
|
729
|
+
return `<%== bf->render_child('${tplName}'${propsStr}) %>`
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
private childrenCaptureCounter = 0
|
|
733
|
+
|
|
734
|
+
private toTemplateName(componentName: string): string {
|
|
735
|
+
// Convert PascalCase to snake_case for Mojo template naming
|
|
736
|
+
return componentName
|
|
737
|
+
.replace(/([A-Z])/g, '_$1')
|
|
738
|
+
.toLowerCase()
|
|
739
|
+
.replace(/^_/, '')
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// ===========================================================================
|
|
743
|
+
// If-Statement (Conditional Return) Rendering
|
|
744
|
+
// ===========================================================================
|
|
745
|
+
|
|
746
|
+
private renderIfStatement(ifStmt: IRIfStatement): string {
|
|
747
|
+
const condition = this.convertExpressionToPerl(ifStmt.condition)
|
|
748
|
+
const consequent = ifStmt.consequent.type === 'if-statement'
|
|
749
|
+
? this.renderIfStatement(ifStmt.consequent as IRIfStatement)
|
|
750
|
+
: this.renderNode(ifStmt.consequent)
|
|
751
|
+
let result = `% if (${condition}) {\n${consequent}\n`
|
|
752
|
+
|
|
753
|
+
if (ifStmt.alternate) {
|
|
754
|
+
if (ifStmt.alternate.type === 'if-statement') {
|
|
755
|
+
const altResult = this.renderIfStatement(ifStmt.alternate as IRIfStatement)
|
|
756
|
+
// Replace leading "% if" with "% } elsif"
|
|
757
|
+
result += altResult.replace(/^% if/, '% } elsif')
|
|
758
|
+
} else {
|
|
759
|
+
const alternate = this.renderNode(ifStmt.alternate)
|
|
760
|
+
result += `% } else {\n${alternate}\n`
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
result += `% }`
|
|
765
|
+
return result
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// ===========================================================================
|
|
769
|
+
// Fragment & Slot Rendering
|
|
770
|
+
// ===========================================================================
|
|
771
|
+
|
|
772
|
+
private renderFragment(fragment: IRFragment): string {
|
|
773
|
+
const children = this.renderChildren(fragment.children)
|
|
774
|
+
if (fragment.needsScopeComment) {
|
|
775
|
+
return `<%== bf->scope_comment %>${children}`
|
|
776
|
+
}
|
|
777
|
+
return children
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
private renderSlot(_slot: IRSlot): string {
|
|
781
|
+
return `<%= content %>`
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
renderAsync(node: IRAsync): string {
|
|
785
|
+
const fallback = this.renderNode(node.fallback)
|
|
786
|
+
const children = this.renderChildren(node.children)
|
|
787
|
+
// Use the BarefootJS.pm streaming helpers for OOS streaming.
|
|
788
|
+
// bf->async_boundary() wraps the fallback in a <div bf-async="aX"> placeholder.
|
|
789
|
+
// The resolved content is rendered below for non-streaming fallback;
|
|
790
|
+
// in streaming mode, Mojo's write_chunk delivers it as a resolve chunk.
|
|
791
|
+
//
|
|
792
|
+
// The fallback is captured into a CODE ref via `begin %>…<% end` in
|
|
793
|
+
// its own action — embedding the `begin/end` inside the `<%== ... %>`
|
|
794
|
+
// that wraps `async_boundary` would let the inner `%>` close the
|
|
795
|
+
// outer tag, leaving the trailing `)` in plain template text and
|
|
796
|
+
// breaking Mojo's lexer (#1298). Same shape as `renderComponent`'s
|
|
797
|
+
// children capture.
|
|
798
|
+
const fallbackVar = `$bf_async_fallback_${node.id}`
|
|
799
|
+
return `<% my ${fallbackVar} = begin %>${fallback}<% end %><%== bf->async_boundary('${node.id}', ${fallbackVar}) %>\n${children}`
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ===========================================================================
|
|
803
|
+
// Attribute Rendering
|
|
804
|
+
// ===========================================================================
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* AttrValue lowering for intrinsic-element attributes (Mojo / EP
|
|
808
|
+
* template). Routed through the shared dispatcher (#1290 step 2).
|
|
809
|
+
*/
|
|
810
|
+
private readonly elementAttrEmitter: AttrValueEmitter = {
|
|
811
|
+
emitLiteral: (value, name) => `${name}="${value.value}"`,
|
|
812
|
+
emitExpression: (value, name) => {
|
|
813
|
+
// Refuse shapes that the regex pipeline silently mangles into
|
|
814
|
+
// invalid Perl (#1322). Object literals (`style={{...}}`) and
|
|
815
|
+
// tagged-template-literal call expressions (`cn\`base \${tone()}\``)
|
|
816
|
+
// have no idiomatic Mojo template form; the Go adapter raises
|
|
817
|
+
// BF101 here via `convertExpressionToGo` + `isSupported`. Lift the
|
|
818
|
+
// same gate so the user gets a clear diagnostic instead of broken
|
|
819
|
+
// output. The check runs before `convertExpressionToPerl` so the
|
|
820
|
+
// regex pipeline never produces template-text fragments for a
|
|
821
|
+
// shape we've already rejected.
|
|
822
|
+
if (this.refuseUnsupportedAttrExpression(value.expr, name)) {
|
|
823
|
+
return ''
|
|
824
|
+
}
|
|
825
|
+
if (isBooleanAttr(name) || value.presenceOrUndefined) {
|
|
826
|
+
// Boolean attributes: render conditionally (present or absent).
|
|
827
|
+
return `<%= ${this.convertExpressionToPerl(value.expr)} ? '${name}' : '' %>`
|
|
828
|
+
}
|
|
829
|
+
// Boolean-result handling (#1466 follow-up). Two trigger paths:
|
|
830
|
+
//
|
|
831
|
+
// - `isBooleanResultExpr(expr)` — the JS source structurally
|
|
832
|
+
// evaluates to a boolean (comparison, `!`, literal,
|
|
833
|
+
// both-sides-boolean logical / conditional).
|
|
834
|
+
// - `isAriaBooleanAttr(name)` — the attribute is one of the
|
|
835
|
+
// ARIA tri-state / boolean-state names whose spec values are
|
|
836
|
+
// `"true" | "false" (| "mixed")`. The expression itself can
|
|
837
|
+
// be opaque (e.g. `accepted()` — a call expression we can't
|
|
838
|
+
// classify from source text), so we lean on the attribute
|
|
839
|
+
// name as the type witness.
|
|
840
|
+
//
|
|
841
|
+
// Without either, Perl's auto-stringification turns a JS-false
|
|
842
|
+
// comparison into `''` (and a JS-true comparison into `'1'`),
|
|
843
|
+
// which renders as `attr=""` / `attr="1"` — diverging from
|
|
844
|
+
// Hono's / Go's `attr="false"` / `attr="true"`. Routing through
|
|
845
|
+
// the `bf->bool_str` Perl helper realigns the wire bytes with
|
|
846
|
+
// JS `String(boolean)` semantics.
|
|
847
|
+
const perl = this.convertExpressionToPerl(value.expr)
|
|
848
|
+
if (isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name)) {
|
|
849
|
+
return `${name}="<%= bf->bool_str(${perl}) %>"`
|
|
850
|
+
}
|
|
851
|
+
return `${name}="<%= ${perl} %>"`
|
|
852
|
+
},
|
|
853
|
+
emitBooleanAttr: (_value, name) => name,
|
|
854
|
+
emitTemplate: (value, name) =>
|
|
855
|
+
`${name}="<%= ${this.convertTemplateLiteralPartsToPerl(value.parts)} %>"`,
|
|
856
|
+
// Spread attributes (`<div {...attrs()} />`) lower through the
|
|
857
|
+
// `bf->spread_attrs` Perl runtime helper (#1407), mirroring the
|
|
858
|
+
// Go adapter's `bf_spread_attrs` and the JS `spreadAttrs` from
|
|
859
|
+
// `@barefootjs/client/runtime`. The bag's source JS expression
|
|
860
|
+
// is translated to a Perl expression via `convertExpressionToPerl`
|
|
861
|
+
// (e.g. `attrs()` → `$attrs`, `props.bag` → `$bag`); the helper
|
|
862
|
+
// accepts a hashref and emits a Mojo::ByteStream with sorted,
|
|
863
|
+
// escaped `key="value"` pairs.
|
|
864
|
+
//
|
|
865
|
+
// No struct-field plumbing is needed on Perl: templates evaluate
|
|
866
|
+
// expressions inline against the props hash, so the spread
|
|
867
|
+
// identifier resolves directly. `IRAttribute.slotId` is set by
|
|
868
|
+
// the IR pass but the Mojo adapter ignores it — the slot field
|
|
869
|
+
// exists only for the Go adapter's static-typed Props struct.
|
|
870
|
+
//
|
|
871
|
+
// Gate unsupported shapes (object literals, tagged-template
|
|
872
|
+
// literals, etc.) up front via `refuseUnsupportedAttrExpression`
|
|
873
|
+
// so a spread like `{...{id: 'x'}}` surfaces BF101 instead of
|
|
874
|
+
// letting `convertExpressionToPerl` emit invalid Embedded Perl
|
|
875
|
+
// that would crash at render time (#1413 review).
|
|
876
|
+
emitSpread: (value) => {
|
|
877
|
+
if (this.refuseUnsupportedAttrExpression(value.expr, '...')) {
|
|
878
|
+
return ''
|
|
879
|
+
}
|
|
880
|
+
// SolidJS-style props identifier (`(props: P) { <el {...props}/> }`)
|
|
881
|
+
// has no matching `$props` variable in Mojo's template scope —
|
|
882
|
+
// Perl props arrive as a flat hash with one key per `propsParams`
|
|
883
|
+
// entry, not as a single nested object. Emit an inline hashref
|
|
884
|
+
// literal that enumerates the analyzer-extracted props params
|
|
885
|
+
// so `$bf->spread_attrs(...)` gets a real hashref (#1407
|
|
886
|
+
// follow-up; matches the Go adapter's same-shape map-literal
|
|
887
|
+
// path). For `restPropsName` and other identifier shapes, the
|
|
888
|
+
// standard `convertExpressionToPerl` translation handles it
|
|
889
|
+
// (rest binding name → `$<name>` resolves against the hashref
|
|
890
|
+
// the caller / harness placed under that key).
|
|
891
|
+
const trimmed = value.expr.trim()
|
|
892
|
+
if (this.propsObjectName && this.propsObjectName === trimmed) {
|
|
893
|
+
const entries = this.propsParams.map(p =>
|
|
894
|
+
`${JSON.stringify(p.name)} => $${p.name}`,
|
|
895
|
+
)
|
|
896
|
+
return `<%== bf->spread_attrs({${entries.join(', ')}}) %>`
|
|
897
|
+
}
|
|
898
|
+
const perlExpr = this.convertExpressionToPerl(value.expr)
|
|
899
|
+
return `<%== bf->spread_attrs(${perlExpr}) %>`
|
|
900
|
+
},
|
|
901
|
+
// Neither variant is legal on intrinsic elements.
|
|
902
|
+
emitBooleanShorthand: () => '',
|
|
903
|
+
emitJsxChildren: () => '',
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
private renderAttributes(element: IRElement): string {
|
|
907
|
+
const parts: string[] = []
|
|
908
|
+
|
|
909
|
+
for (const attr of element.attrs) {
|
|
910
|
+
// Rewrite JSX special-prop names to their HTML-attribute
|
|
911
|
+
// counterparts (#1475). `className` → `class` was already
|
|
912
|
+
// wired in; the `key` → `data-key` rewrite matches the
|
|
913
|
+
// canonical Hono attribute name the client runtime
|
|
914
|
+
// reconciles against. Hono SSR strips raw `key` via its JSX
|
|
915
|
+
// runtime; the Mojo template path has no such layer so the
|
|
916
|
+
// rewrite happens at attribute-emit time.
|
|
917
|
+
let attrName: string
|
|
918
|
+
if (attr.name === 'className') attrName = 'class'
|
|
919
|
+
else if (attr.name === 'key') attrName = 'data-key'
|
|
920
|
+
else attrName = attr.name
|
|
921
|
+
const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
|
|
922
|
+
if (lowered) parts.push(lowered)
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
return parts.length > 0 ? ' ' + parts.join(' ') : ''
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// ===========================================================================
|
|
929
|
+
// Hydration Markers
|
|
930
|
+
// ===========================================================================
|
|
931
|
+
|
|
932
|
+
renderScopeMarker(_instanceIdExpr: string): string {
|
|
933
|
+
// bf-s is the addressable scope id (#1249 — bare, no `~` prefix).
|
|
934
|
+
// hydration_attrs adds bf-h / bf-m / bf-r conditionally.
|
|
935
|
+
return `bf-s="<%= bf->scope_attr %>" <%== bf->hydration_attrs %> <%== bf->props_attr %>`
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
renderSlotMarker(slotId: string): string {
|
|
939
|
+
return `${BF_SLOT}="${slotId}"`
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
renderCondMarker(condId: string): string {
|
|
943
|
+
return `${BF_COND}="${condId}"`
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// ===========================================================================
|
|
947
|
+
// Filter Predicate Rendering (ParsedExpr → Perl)
|
|
948
|
+
// ===========================================================================
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Convert a ParsedExpr AST to Perl expression string for filter
|
|
952
|
+
* predicates. Wraps the shared ParsedExpr dispatcher with a
|
|
953
|
+
* `MojoFilterEmitter` carrying the predicate's loop param and
|
|
954
|
+
* any block-body local var aliases (#1250 phase 1B).
|
|
955
|
+
*/
|
|
956
|
+
private renderPerlFilterExpr(
|
|
957
|
+
expr: ParsedExpr,
|
|
958
|
+
param: string,
|
|
959
|
+
localVarMap: Map<string, string> = new Map(),
|
|
960
|
+
): string {
|
|
961
|
+
// Nested higher-order in filter predicates was refused outright
|
|
962
|
+
// until #1443 PR4 because the `member` emit produced
|
|
963
|
+
// `[ ... ]->{length}` for `.length` on a `[grep ...]` anonymous
|
|
964
|
+
// array ref — undef at runtime. With `MojoFilterEmitter.member`
|
|
965
|
+
// now lowering `.length` on a higher-order object to
|
|
966
|
+
// `scalar(@{...})`, the canonical
|
|
967
|
+
// `x.tags.filter(t => t.active).length > 0` shape lowers
|
|
968
|
+
// cleanly. Predicates that combine a nested higher-order with
|
|
969
|
+
// something OTHER than `.length` (e.g. `.includes`, `.join`)
|
|
970
|
+
// still fall back to whatever the emitter produces — most of
|
|
971
|
+
// those would yield runtime errors in Perl, which is the user's
|
|
972
|
+
// signal to refactor. Wholesale refusal would also block the
|
|
973
|
+
// canonical case the issue exists to enable.
|
|
974
|
+
return emitParsedExpr(expr, new MojoFilterEmitter(param, localVarMap))
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Render a complex block body filter into a Perl condition.
|
|
979
|
+
* Handles patterns like: filter(t => { const f = filter(); if (...) return ...; })
|
|
980
|
+
*/
|
|
981
|
+
private renderBlockBodyCondition(
|
|
982
|
+
statements: ParsedStatement[],
|
|
983
|
+
param: string
|
|
984
|
+
): string {
|
|
985
|
+
const localVarMap = new Map<string, string>()
|
|
986
|
+
const paths = this.collectReturnPaths(statements, [], localVarMap, param)
|
|
987
|
+
|
|
988
|
+
if (paths.length === 0) return '1'
|
|
989
|
+
if (paths.length === 1) return this.buildSinglePathCondition(paths[0], param, localVarMap)
|
|
990
|
+
|
|
991
|
+
// Multiple paths: build OR condition
|
|
992
|
+
const parts: string[] = []
|
|
993
|
+
for (const path of paths) {
|
|
994
|
+
if (path.result.kind === 'literal' && path.result.literalType === 'boolean' && path.result.value === false) continue
|
|
995
|
+
const cond = this.buildSinglePathCondition(path, param, localVarMap)
|
|
996
|
+
if (cond !== '0') parts.push(cond)
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (parts.length === 0) return '0'
|
|
1000
|
+
if (parts.length === 1) return parts[0]
|
|
1001
|
+
return `(${parts.join(' || ')})`
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
private collectReturnPaths(
|
|
1005
|
+
statements: ParsedStatement[],
|
|
1006
|
+
currentConditions: ParsedExpr[],
|
|
1007
|
+
localVarMap: Map<string, string>,
|
|
1008
|
+
param: string
|
|
1009
|
+
): Array<{ conditions: ParsedExpr[]; result: ParsedExpr }> {
|
|
1010
|
+
const paths: Array<{ conditions: ParsedExpr[]; result: ParsedExpr }> = []
|
|
1011
|
+
|
|
1012
|
+
for (const stmt of statements) {
|
|
1013
|
+
if (stmt.kind === 'var-decl') {
|
|
1014
|
+
if (stmt.init.kind === 'call' && stmt.init.callee.kind === 'identifier') {
|
|
1015
|
+
localVarMap.set(stmt.name, stmt.init.callee.name)
|
|
1016
|
+
}
|
|
1017
|
+
} else if (stmt.kind === 'return') {
|
|
1018
|
+
paths.push({ conditions: [...currentConditions], result: stmt.value })
|
|
1019
|
+
break
|
|
1020
|
+
} else if (stmt.kind === 'if') {
|
|
1021
|
+
const thenPaths = this.collectReturnPaths(stmt.consequent, [...currentConditions, stmt.condition], localVarMap, param)
|
|
1022
|
+
paths.push(...thenPaths)
|
|
1023
|
+
|
|
1024
|
+
if (stmt.alternate) {
|
|
1025
|
+
const negated: ParsedExpr = { kind: 'unary', op: '!', argument: stmt.condition }
|
|
1026
|
+
const elsePaths = this.collectReturnPaths(stmt.alternate, [...currentConditions, negated], localVarMap, param)
|
|
1027
|
+
paths.push(...elsePaths)
|
|
1028
|
+
} else {
|
|
1029
|
+
currentConditions.push({ kind: 'unary', op: '!', argument: stmt.condition })
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
return paths
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
private buildSinglePathCondition(
|
|
1038
|
+
path: { conditions: ParsedExpr[]; result: ParsedExpr },
|
|
1039
|
+
param: string,
|
|
1040
|
+
localVarMap: Map<string, string>
|
|
1041
|
+
): string {
|
|
1042
|
+
if (path.result.kind === 'literal' && path.result.literalType === 'boolean') {
|
|
1043
|
+
if (path.result.value === true) {
|
|
1044
|
+
if (path.conditions.length === 0) return '1'
|
|
1045
|
+
return this.renderConditionsAnd(path.conditions, param, localVarMap)
|
|
1046
|
+
}
|
|
1047
|
+
return '0'
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
if (path.conditions.length === 0) {
|
|
1051
|
+
return this.renderPerlFilterExpr(path.result, param, localVarMap)
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const condPart = this.renderConditionsAnd(path.conditions, param, localVarMap)
|
|
1055
|
+
const resultPart = this.renderPerlFilterExpr(path.result, param, localVarMap)
|
|
1056
|
+
return `(${condPart} && ${resultPart})`
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
private renderConditionsAnd(
|
|
1060
|
+
conditions: ParsedExpr[],
|
|
1061
|
+
param: string,
|
|
1062
|
+
localVarMap: Map<string, string>
|
|
1063
|
+
): string {
|
|
1064
|
+
if (conditions.length === 0) return '1'
|
|
1065
|
+
if (conditions.length === 1) return this.renderPerlFilterExpr(conditions[0], param, localVarMap)
|
|
1066
|
+
const parts = conditions.map(c => this.renderPerlFilterExpr(c, param, localVarMap))
|
|
1067
|
+
return `(${parts.join(' && ')})`
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// ===========================================================================
|
|
1071
|
+
// Expression Conversion: JS → Perl
|
|
1072
|
+
// ===========================================================================
|
|
1073
|
+
|
|
1074
|
+
private convertTemplateLiteralPartsToPerl(literalParts: IRTemplatePart[]): string {
|
|
1075
|
+
const parts: string[] = []
|
|
1076
|
+
for (const part of literalParts) {
|
|
1077
|
+
if (part.type === 'string') {
|
|
1078
|
+
// The IR producer may leave `${ident}` / `${_p.ident}`
|
|
1079
|
+
// interpolations in `string` parts when it can't statically
|
|
1080
|
+
// inline them (typically a destructured prop the caller will
|
|
1081
|
+
// supply at hydrate time, e.g. `${className}` in shadcn-style
|
|
1082
|
+
// composition). Substitute those to their Perl variable form
|
|
1083
|
+
// before quoting, otherwise the single-quoted literal here
|
|
1084
|
+
// passes the JS-shape interpolation through verbatim into the
|
|
1085
|
+
// rendered HTML.
|
|
1086
|
+
parts.push(this.substituteJsInterpolationsToPerl(part.value))
|
|
1087
|
+
} else if (part.type === 'ternary') {
|
|
1088
|
+
const cond = this.convertExpressionToPerl(part.condition)
|
|
1089
|
+
parts.push(`(${cond} ? '${part.whenTrue}' : '${part.whenFalse}')`)
|
|
1090
|
+
} else if (part.type === 'lookup') {
|
|
1091
|
+
// `${MAP[KEY]}` against a Record<T, string> literal — emit a
|
|
1092
|
+
// Perl anonymous hash with an immediate `->{ $key } // ''`
|
|
1093
|
+
// lookup. The `//''` guard turns a miss into an empty string,
|
|
1094
|
+
// matching the go-template adapter's "empty when no case
|
|
1095
|
+
// matches" semantics. Pass `key` through `convertExpressionToPerl`
|
|
1096
|
+
// so its top-level-identifier tail (`variant` → `$variant`) and
|
|
1097
|
+
// existing `props.x` rule apply uniformly.
|
|
1098
|
+
const keyExpr = this.convertExpressionToPerl(part.key)
|
|
1099
|
+
const entries = Object.entries(part.cases)
|
|
1100
|
+
.map(([k, v]) => `'${k}' => '${v}'`)
|
|
1101
|
+
.join(', ')
|
|
1102
|
+
parts.push(`({ ${entries} }->{${keyExpr}} // '')`)
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
// Join with Perl string concatenation
|
|
1106
|
+
return parts.length === 1 ? parts[0] : parts.join(' . ')
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Translate `${EXPR}` interpolations in a static template-part string
|
|
1111
|
+
* into Perl variable references and concatenate them with the
|
|
1112
|
+
* surrounding literal text. Used by `convertTemplateLiteralPartsToPerl`
|
|
1113
|
+
* when a `string` part still carries unresolved interpolations (e.g.
|
|
1114
|
+
* `${className}` from a destructured prop the IR analyzer couldn't
|
|
1115
|
+
* inline statically).
|
|
1116
|
+
*/
|
|
1117
|
+
private substituteJsInterpolationsToPerl(s: string): string {
|
|
1118
|
+
const segments: string[] = []
|
|
1119
|
+
const re = /\$\{([^}]+)\}/g
|
|
1120
|
+
let lastIndex = 0
|
|
1121
|
+
let m: RegExpExecArray | null
|
|
1122
|
+
while ((m = re.exec(s)) !== null) {
|
|
1123
|
+
if (m.index > lastIndex) {
|
|
1124
|
+
segments.push(`'${s.slice(lastIndex, m.index)}'`)
|
|
1125
|
+
}
|
|
1126
|
+
segments.push(this.convertExpressionToPerl(m[1].trim()))
|
|
1127
|
+
lastIndex = re.lastIndex
|
|
1128
|
+
}
|
|
1129
|
+
if (lastIndex < s.length) {
|
|
1130
|
+
segments.push(`'${s.slice(lastIndex)}'`)
|
|
1131
|
+
}
|
|
1132
|
+
if (segments.length === 0) return `''`
|
|
1133
|
+
return segments.length === 1 ? segments[0] : `(${segments.join(' . ')})`
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* Refuse JS expression shapes that have no idiomatic Mojo template
|
|
1138
|
+
* representation (#1322). Currently catches:
|
|
1139
|
+
*
|
|
1140
|
+
* - Object literals (`style={{ background: bg(), color: fg() }}`):
|
|
1141
|
+
* the regex pipeline strips signal calls but leaves the
|
|
1142
|
+
* surrounding `{ k: v, ... }` syntax intact, producing invalid
|
|
1143
|
+
* Perl inside `<%= ... %>`.
|
|
1144
|
+
* - Tagged-template-literal call expressions
|
|
1145
|
+
* (`className={cn\`base \${tone()}\`}`): regex translation
|
|
1146
|
+
* produces malformed Perl with no callable target.
|
|
1147
|
+
*
|
|
1148
|
+
* Records `BF101` with the same shape the Go adapter emits via
|
|
1149
|
+
* `convertExpressionToGo`, so cross-adapter diagnostics stay
|
|
1150
|
+
* consistent. Returns `true` when the shape was rejected (caller
|
|
1151
|
+
* should drop the attribute / skip the emit).
|
|
1152
|
+
*/
|
|
1153
|
+
private refuseUnsupportedAttrExpression(expr: string, attrName: string): boolean {
|
|
1154
|
+
// Strip leading parens / whitespace so wrapped forms reach the
|
|
1155
|
+
// shape pre-check — `style={({ a: b() })}` and
|
|
1156
|
+
// `className={(cn`base ${tone()}`)}` are the same logical shape
|
|
1157
|
+
// as their unwrapped variants and should hit the same gate.
|
|
1158
|
+
let probe = expr.trim()
|
|
1159
|
+
while (probe.startsWith('(')) probe = probe.slice(1).trimStart()
|
|
1160
|
+
const startsAsObjectLiteral = probe.startsWith('{')
|
|
1161
|
+
const hasTaggedTemplate = /[A-Za-z_$][\w$]*\s*`/.test(probe)
|
|
1162
|
+
if (!startsAsObjectLiteral && !hasTaggedTemplate) return false
|
|
1163
|
+
const parsed = parseExpression(expr.trim())
|
|
1164
|
+
const support = isSupported(parsed)
|
|
1165
|
+
if (parsed.kind !== 'unsupported' && support.supported) return false
|
|
1166
|
+
// Surface the `isSupported` reason so the diagnostic is as
|
|
1167
|
+
// actionable as the Go adapter's BF101 — keeps cross-adapter
|
|
1168
|
+
// diagnostics aligned on the same expression-shape gate.
|
|
1169
|
+
const reason = support.reason ?? (parsed.kind === 'unsupported' ? parsed.reason : undefined)
|
|
1170
|
+
const reasonLine = reason ? `\n${reason}` : ''
|
|
1171
|
+
this.errors.push({
|
|
1172
|
+
code: 'BF101',
|
|
1173
|
+
severity: 'error',
|
|
1174
|
+
message: `Expression not supported on attribute '${attrName}': ${expr.trim()}${reasonLine}`,
|
|
1175
|
+
loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
1176
|
+
suggestion: {
|
|
1177
|
+
message: 'The Mojo adapter cannot lower JS object literals or tagged-template-literal expressions into Embedded Perl. Move the expression into a `\'use client\'` component (so hydration computes it), or expand it into discrete attributes whose values are values the adapter can lower.',
|
|
1178
|
+
},
|
|
1179
|
+
})
|
|
1180
|
+
return true
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
private convertExpressionToPerl(expr: string): string {
|
|
1185
|
+
// Handle higher-order array methods via ParsedExpr AST.
|
|
1186
|
+
// `filter|every|some` lower to Embedded Perl (grep). The rest
|
|
1187
|
+
// (`reduce|reduceRight|forEach|flatMap|flat|findLast|findLastIndex`)
|
|
1188
|
+
// can't lower to EP at all — route them through the same AST path
|
|
1189
|
+
// so `convertHigherOrderExpr`'s `isSupported` gate emits BF101
|
|
1190
|
+
// instead of falling into the regex pipeline that mangles
|
|
1191
|
+
// `$items->{reduce}->{...}` etc.
|
|
1192
|
+
if (/\.\s*(?:filter|every|some|reduce|reduceRight|forEach|flatMap|flat|findLast|findLastIndex)\s*\(/.test(expr)) {
|
|
1193
|
+
return this.convertHigherOrderExpr(expr)
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// #1448 Tier A — JS Array / String methods that the regex
|
|
1197
|
+
// pipeline silently mangles into `${obj}->{<method>}(...)` hash
|
|
1198
|
+
// lookups that fail at render time. Route them through the same
|
|
1199
|
+
// AST path so `isSupported`'s `UNSUPPORTED_METHODS` gate fires
|
|
1200
|
+
// BF101 with the offending expression, matching Go's behaviour.
|
|
1201
|
+
// Each method name drops off the regex as its lowering lands
|
|
1202
|
+
// (the regex stays in sync with `UNSUPPORTED_METHODS` —
|
|
1203
|
+
// `convertHigherOrderExpr` intercepts via `isSupported`).
|
|
1204
|
+
if (/\.\s*(?:includes|indexOf|lastIndexOf|at|concat|slice|reverse|toReversed|toLowerCase|toUpperCase|trim)\s*\(/.test(expr)) {
|
|
1205
|
+
return this.convertHigherOrderExpr(expr)
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// #1443/#1448: `.join(sep)` is lifted by the parser to the
|
|
1209
|
+
// `array-method` IR kind, and `renderArrayMethod`'s `case 'join'`
|
|
1210
|
+
// already emits the correct `join(sep, @{arr})`. Route the
|
|
1211
|
+
// text-expression form through the same AST path so the
|
|
1212
|
+
// regex pipeline below doesn't mangle it into a
|
|
1213
|
+
// `${arr}->{join}(sep)` Perl hash-lookup that errors at render.
|
|
1214
|
+
if (/\.\s*join\s*\(/.test(expr)) {
|
|
1215
|
+
return this.convertHigherOrderExpr(expr)
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// #1448 catalog — Mojo-specific gap: `.find` / `.findIndex`
|
|
1219
|
+
// have no AST lowering yet (no `array-method` IR variant, no
|
|
1220
|
+
// emitter), and the regex pipeline silently mangles them into
|
|
1221
|
+
// `${obj}->{find}(...)` hash lookups. Emit BF101 here until
|
|
1222
|
+
// either a parser-level `array-method` extension or a
|
|
1223
|
+
// `convertHigherOrderExpr` carve-out lands.
|
|
1224
|
+
const mojoOnlyMatch = /\.\s*(?<method>find|findIndex)\s*\(/.exec(expr)
|
|
1225
|
+
if (mojoOnlyMatch) {
|
|
1226
|
+
const methodName = mojoOnlyMatch.groups!.method!
|
|
1227
|
+
this.errors.push({
|
|
1228
|
+
code: 'BF101',
|
|
1229
|
+
severity: 'error',
|
|
1230
|
+
message: `Mojo adapter has not lowered Array.prototype.${methodName} yet: ${expr.trim()}`,
|
|
1231
|
+
loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
1232
|
+
suggestion: {
|
|
1233
|
+
message: 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Perl',
|
|
1234
|
+
},
|
|
1235
|
+
})
|
|
1236
|
+
return "''"
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// templatePrimitives substitution (#1189): rewrite identifier-path
|
|
1240
|
+
// calls like `JSON.stringify(props.config)` / `Math.floor(x)` to
|
|
1241
|
+
// their Mojo helper-call form (`bf->json($config)` etc.) BEFORE
|
|
1242
|
+
// the regex pipeline below runs. Using the AST avoids fighting
|
|
1243
|
+
// the existing regex transforms — a registered call's args go
|
|
1244
|
+
// back through `convertExpressionToPerl` recursively so prop
|
|
1245
|
+
// refs / signal calls / member access in the args still get the
|
|
1246
|
+
// standard transforms.
|
|
1247
|
+
expr = this.rewriteTemplatePrimitives(expr)
|
|
1248
|
+
|
|
1249
|
+
// Signal getter calls: count() → $count
|
|
1250
|
+
let result = expr.replace(/\b([a-z_]\w*)\(\)/g, (_, name) => `$${name}`)
|
|
1251
|
+
|
|
1252
|
+
// Props access: props.xxx → $xxx
|
|
1253
|
+
result = result.replace(/\bprops\.(\w+)/g, (_, prop) => `$${prop}`)
|
|
1254
|
+
|
|
1255
|
+
// Bare identifier property access: item.field → $item->{field}
|
|
1256
|
+
// Must run before $-prefixed property access to catch bare identifiers
|
|
1257
|
+
// Use negative lookbehind to skip $-prefixed variables (avoid $$var double-prefix)
|
|
1258
|
+
result = result.replace(/(?<!\$)\b([a-z_]\w*)\.(\w+)/g, (match, obj, field) => {
|
|
1259
|
+
if (match.startsWith('$')) return match
|
|
1260
|
+
return `$${obj}->{${field}}`
|
|
1261
|
+
})
|
|
1262
|
+
|
|
1263
|
+
// $-prefixed property access: $item.field → $item->{field}
|
|
1264
|
+
result = result.replace(/\$(\w+)\.(\w+)/g, (_, obj, field) => `$${obj}->{${field}}`)
|
|
1265
|
+
|
|
1266
|
+
// Chained property access: $item->{field}.sub → $item->{field}->{sub}
|
|
1267
|
+
result = result.replace(/\}->\{(\w+)\}\.(\w+)/g, (_, f1, f2) => `}->{${f1}}->{${f2}}`)
|
|
1268
|
+
|
|
1269
|
+
// .length → scalar(@{...})
|
|
1270
|
+
result = result.replace(/\$(\w+)->\{length\}/g, (_, arr) => `scalar(@{$${arr}})`)
|
|
1271
|
+
|
|
1272
|
+
// Nullish coalescing: a ?? b → a // b (Perl defined-or)
|
|
1273
|
+
result = result.replace(/\?\?/g, '//')
|
|
1274
|
+
|
|
1275
|
+
// String comparison: expr === 'str' → expr eq 'str', expr !== 'str' → expr ne 'str'
|
|
1276
|
+
result = result.replace(/\s*===\s*(['"])/g, ' eq $1')
|
|
1277
|
+
result = result.replace(/\s*!==\s*(['"])/g, ' ne $1')
|
|
1278
|
+
// Also handle: 'str' === expr
|
|
1279
|
+
result = result.replace(/(['"])\s*===\s*/g, '$1 eq ')
|
|
1280
|
+
result = result.replace(/(['"])\s*!==\s*/g, '$1 ne ')
|
|
1281
|
+
|
|
1282
|
+
// Numeric comparison (remaining === / !==)
|
|
1283
|
+
result = result.replace(/===/g, '==')
|
|
1284
|
+
result = result.replace(/!==/g, '!=')
|
|
1285
|
+
|
|
1286
|
+
// Logical not: !expr → !expr (works in Perl too)
|
|
1287
|
+
// No conversion needed
|
|
1288
|
+
|
|
1289
|
+
// Template literals: `str ${expr}` → "str $expr"
|
|
1290
|
+
result = result.replace(/`([^`]*)`/g, (_, content) => {
|
|
1291
|
+
const perlStr = content.replace(/\$\{([^}]+)\}/g, (_: string, e: string) => `${this.convertExpressionToPerl(e)}`)
|
|
1292
|
+
return `"${perlStr}"`
|
|
1293
|
+
})
|
|
1294
|
+
|
|
1295
|
+
// Ensure top-level identifiers become variables
|
|
1296
|
+
if (/^[a-z_]\w*$/i.test(result) && !result.startsWith('$')) {
|
|
1297
|
+
result = `$${result}`
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
return result
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Walk the parsed AST of `expr` and substitute each registered
|
|
1304
|
+
* primitive call (e.g. `JSON.stringify(props.config)`) with its
|
|
1305
|
+
* Mojo helper-call equivalent (e.g. `bf->json($config)`). All
|
|
1306
|
+
* other shapes round-trip back to source text via
|
|
1307
|
+
* `stringifyParsedExpr`, so the result is still a JS-shaped
|
|
1308
|
+
* string that the existing regex pipeline in
|
|
1309
|
+
* `convertExpressionToPerl` can finish translating.
|
|
1310
|
+
*
|
|
1311
|
+
* Bails out (returns the input unchanged) when:
|
|
1312
|
+
* - the expression doesn't parse cleanly,
|
|
1313
|
+
* - no primitive call is found in the AST, or
|
|
1314
|
+
* - a primitive's arity doesn't match the registered shape
|
|
1315
|
+
* (BF101 is recorded so the user sees the diagnostic).
|
|
1316
|
+
*
|
|
1317
|
+
* Identifier-path-only matching (#1187 R1) — same constraint the
|
|
1318
|
+
* Go adapter applies in #1188.
|
|
1319
|
+
*/
|
|
1320
|
+
private rewriteTemplatePrimitives(expr: string): string {
|
|
1321
|
+
// Common case: no registered primitive substring — skip the
|
|
1322
|
+
// TS parser entirely. `parseExpression` invokes
|
|
1323
|
+
// `ts.createSourceFile`, which is the dominant compile-hot-path
|
|
1324
|
+
// cost added by this PR.
|
|
1325
|
+
if (!PRIMITIVE_SUBSTRING_RE.test(expr)) return expr
|
|
1326
|
+
|
|
1327
|
+
const parsed = parseExpression(expr)
|
|
1328
|
+
if (parsed.kind === 'unsupported') return expr
|
|
1329
|
+
|
|
1330
|
+
let mutated = false
|
|
1331
|
+
const walk = (n: ParsedExpr): ParsedExpr => {
|
|
1332
|
+
if (n.kind === 'call') {
|
|
1333
|
+
const path = identifierPath(n.callee)
|
|
1334
|
+
const spec = path ? MOJO_TEMPLATE_PRIMITIVES[path] : undefined
|
|
1335
|
+
if (path && spec) {
|
|
1336
|
+
if (n.args.length !== spec.arity) {
|
|
1337
|
+
this.errors.push({
|
|
1338
|
+
code: 'BF101',
|
|
1339
|
+
severity: 'error',
|
|
1340
|
+
message: `templatePrimitive '${path}' expects ${spec.arity} arg(s), got ${n.args.length}`,
|
|
1341
|
+
loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
1342
|
+
suggestion: {
|
|
1343
|
+
message: `Call '${path}' with exactly ${spec.arity} argument(s), or wrap the JSX expression in /* @client */ to defer evaluation.`,
|
|
1344
|
+
},
|
|
1345
|
+
})
|
|
1346
|
+
return { kind: 'call', callee: walk(n.callee), args: n.args.map(walk) }
|
|
1347
|
+
}
|
|
1348
|
+
// Render each arg through the AST-aware sub-pipeline:
|
|
1349
|
+
// walk for nested primitive substitution, then pass the
|
|
1350
|
+
// resulting AST node directly to convertExpressionToPerl
|
|
1351
|
+
// via stringification. The substring pre-check above
|
|
1352
|
+
// guards against re-parsing strings that don't carry a
|
|
1353
|
+
// primitive, so the recursive cost stays bounded.
|
|
1354
|
+
const renderedArgs = n.args.map(a => this.convertExpressionToPerl(stringifyParsedExpr(walk(a))))
|
|
1355
|
+
mutated = true
|
|
1356
|
+
return { kind: 'identifier', name: spec.emit(renderedArgs) }
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
switch (n.kind) {
|
|
1360
|
+
case 'call':
|
|
1361
|
+
return { kind: 'call', callee: walk(n.callee), args: n.args.map(walk) }
|
|
1362
|
+
case 'member':
|
|
1363
|
+
return { kind: 'member', object: walk(n.object), property: n.property, computed: n.computed }
|
|
1364
|
+
case 'binary':
|
|
1365
|
+
return { kind: 'binary', op: n.op, left: walk(n.left), right: walk(n.right) }
|
|
1366
|
+
case 'unary':
|
|
1367
|
+
return { kind: 'unary', op: n.op, argument: walk(n.argument) }
|
|
1368
|
+
case 'logical':
|
|
1369
|
+
return { kind: 'logical', op: n.op, left: walk(n.left), right: walk(n.right) }
|
|
1370
|
+
case 'conditional':
|
|
1371
|
+
return { kind: 'conditional', test: walk(n.test), consequent: walk(n.consequent), alternate: walk(n.alternate) }
|
|
1372
|
+
default:
|
|
1373
|
+
return n
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const transformed = walk(parsed)
|
|
1378
|
+
if (!mutated) return expr
|
|
1379
|
+
return stringifyParsedExpr(transformed)
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* Convert expressions containing higher-order array methods to Perl.
|
|
1384
|
+
* Parses the full expression as AST and renders recursively.
|
|
1385
|
+
*
|
|
1386
|
+
* Handles patterns like:
|
|
1387
|
+
* - todos().filter(t => !t.done).length → scalar(grep { !$_->{done} } @{$todos})
|
|
1388
|
+
* - todos().every(t => t.done) → !(grep { !$_->{done} } @{$todos})
|
|
1389
|
+
* - todos().filter(t => t.done).length > 0 → scalar(grep { $_->{done} } @{$todos}) > 0
|
|
1390
|
+
*/
|
|
1391
|
+
private convertHigherOrderExpr(expr: string): string {
|
|
1392
|
+
if (this.higherOrderInFlight.has(expr)) {
|
|
1393
|
+
this.errors.push({
|
|
1394
|
+
code: 'BF101',
|
|
1395
|
+
severity: 'error',
|
|
1396
|
+
message: `Cannot lower higher-order chain to Embedded Perl: ${expr.trim()}`,
|
|
1397
|
+
loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
1398
|
+
suggestion: {
|
|
1399
|
+
message: "The Mojo adapter cannot lower this `.filter()` / `.every()` / `.some()` chain — typically because the array source is a JS array literal or a non-signal expression the AST classifier doesn't recognise. Move the expression into a `'use client'` component (so hydration computes it client-side), or rewrite it to operate on a signal getter or a prop directly.",
|
|
1400
|
+
},
|
|
1401
|
+
})
|
|
1402
|
+
// Return a Perl empty-string literal — safe in every context the
|
|
1403
|
+
// result might land in (`<%= '' %>`, `% if ('') {`, attribute
|
|
1404
|
+
// interpolation, template-literal substitution). Returning a raw
|
|
1405
|
+
// empty string here would produce `<%= %>`, which Embedded Perl
|
|
1406
|
+
// rejects as a syntax error and would mask the BF101 diagnostic
|
|
1407
|
+
// behind an opaque template-compilation failure.
|
|
1408
|
+
return "''"
|
|
1409
|
+
}
|
|
1410
|
+
this.higherOrderInFlight.add(expr)
|
|
1411
|
+
try {
|
|
1412
|
+
const parsed = parseExpression(expr)
|
|
1413
|
+
// Parity gate with the Go adapter's `convertExpressionToGo`: if the
|
|
1414
|
+
// parsed expression isn't supported (e.g. `.reduce()` / `.forEach()`,
|
|
1415
|
+
// destructured filter param, function-keyword callback) we cannot
|
|
1416
|
+
// lower it to Embedded Perl. Emit BF101 and return a safe Perl
|
|
1417
|
+
// empty-string literal so downstream concatenation doesn't blow up.
|
|
1418
|
+
const support = isSupported(parsed)
|
|
1419
|
+
if (!support.supported) {
|
|
1420
|
+
this.errors.push({
|
|
1421
|
+
code: 'BF101',
|
|
1422
|
+
severity: 'error',
|
|
1423
|
+
message: `Cannot lower higher-order chain to Embedded Perl: ${expr.trim()}`,
|
|
1424
|
+
loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
1425
|
+
suggestion: {
|
|
1426
|
+
message: support.reason
|
|
1427
|
+
? `${support.reason}\n\nOptions:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Perl`
|
|
1428
|
+
: 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Perl',
|
|
1429
|
+
},
|
|
1430
|
+
})
|
|
1431
|
+
return "''"
|
|
1432
|
+
}
|
|
1433
|
+
return this.renderParsedExprToPerl(parsed)
|
|
1434
|
+
} finally {
|
|
1435
|
+
this.higherOrderInFlight.delete(expr)
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Render a full ParsedExpr tree to Perl for top-level (non-filter)
|
|
1441
|
+
* expressions where identifiers are signals / stash vars. Delegates
|
|
1442
|
+
* to the shared ParsedExpr dispatcher with `MojoTopLevelEmitter`
|
|
1443
|
+
* (#1250 phase 1B).
|
|
1444
|
+
*/
|
|
1445
|
+
private renderParsedExprToPerl(expr: ParsedExpr): string {
|
|
1446
|
+
return emitParsedExpr(expr, new MojoTopLevelEmitter(this))
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/** Internal hook exposed to the top-level emitter for unsupported nodes. */
|
|
1450
|
+
_convertExpressionToPerlPublic(raw: string): string {
|
|
1451
|
+
return this.convertExpressionToPerl(raw)
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/** Internal hook for higher-order: predicate body re-uses the filter emitter. */
|
|
1455
|
+
_renderPerlFilterExprPublic(expr: ParsedExpr, param: string): string {
|
|
1456
|
+
return this.renderPerlFilterExpr(expr, param)
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// ===========================================================================
|
|
1461
|
+
// ParsedExpr emitters (#1250 phase 1B)
|
|
1462
|
+
// ===========================================================================
|
|
1463
|
+
|
|
1464
|
+
/**
|
|
1465
|
+
* Lowering for `array-method` IR nodes (#1443) — shared between the
|
|
1466
|
+
* filter and top-level emitters so the Embedded Perl form stays
|
|
1467
|
+
* consistent regardless of which context the chain lands in.
|
|
1468
|
+
*
|
|
1469
|
+
* The exhaustive switch on `method` paired with `assertNever` makes
|
|
1470
|
+
* adding a new variant to `ArrayMethod` a TS compile error here, not
|
|
1471
|
+
* a silent runtime no-op — the drift defence we already apply to
|
|
1472
|
+
* `ParsedExpr.kind` extended to its sub-discriminator.
|
|
1473
|
+
*/
|
|
1474
|
+
function renderArrayMethod(
|
|
1475
|
+
method: ArrayMethod,
|
|
1476
|
+
object: ParsedExpr,
|
|
1477
|
+
args: ParsedExpr[],
|
|
1478
|
+
emit: (e: ParsedExpr) => string,
|
|
1479
|
+
): string {
|
|
1480
|
+
switch (method) {
|
|
1481
|
+
case 'join': {
|
|
1482
|
+
// arr.join(sep) → join(sep, @{arr}). The default `${obj}->{join}`
|
|
1483
|
+
// hash-lookup fallback would emit invalid Perl, which is why the
|
|
1484
|
+
// IR carves out a dedicated method node instead of routing
|
|
1485
|
+
// through the generic call dispatcher.
|
|
1486
|
+
const obj = emit(object)
|
|
1487
|
+
const sep = emit(args[0])
|
|
1488
|
+
return `join(${sep}, @{${obj}})`
|
|
1489
|
+
}
|
|
1490
|
+
case 'includes': {
|
|
1491
|
+
// Both `arr.includes(x)` and `str.includes(sub)` route here —
|
|
1492
|
+
// the parser can't disambiguate the receiver type. The Mojo
|
|
1493
|
+
// runtime's `bf->includes($recv, $elem)` inspects `ref($recv)`
|
|
1494
|
+
// and dispatches: ARRAY ref scans the list with `eq`, scalar
|
|
1495
|
+
// falls back to `index(..., ...) != -1`. Helper lives in
|
|
1496
|
+
// packages/adapter-mojolicious/lib/BarefootJS.pm.
|
|
1497
|
+
//
|
|
1498
|
+
// The `bf->` (no `$`) form matches every other helper emit —
|
|
1499
|
+
// in real Mojolicious `bf` is a controller helper; the
|
|
1500
|
+
// standalone test-render in test-render.ts rewrites the bare
|
|
1501
|
+
// `bf->` to `$bf->` so both render paths stay consistent.
|
|
1502
|
+
const obj = emit(object)
|
|
1503
|
+
const needle = emit(args[0])
|
|
1504
|
+
return `bf->includes(${obj}, ${needle})`
|
|
1505
|
+
}
|
|
1506
|
+
case 'indexOf':
|
|
1507
|
+
case 'lastIndexOf': {
|
|
1508
|
+
// Array `.indexOf(x)` / `.lastIndexOf(x)` value-equality
|
|
1509
|
+
// search. The Perl helpers (`bf->index_of`, `bf->last_index_of`)
|
|
1510
|
+
// walk the array forward / backward and compare with `eq`
|
|
1511
|
+
// (with defined/undef parity). The existing `.find` lowering
|
|
1512
|
+
// uses Perl `grep` for struct-field find — disjoint surface,
|
|
1513
|
+
// disjoint helpers.
|
|
1514
|
+
const fn = method === 'indexOf' ? 'index_of' : 'last_index_of'
|
|
1515
|
+
const obj = emit(object)
|
|
1516
|
+
const needle = emit(args[0])
|
|
1517
|
+
return `bf->${fn}(${obj}, ${needle})`
|
|
1518
|
+
}
|
|
1519
|
+
case 'at': {
|
|
1520
|
+
// `.at(i)` with negative-index support — `.at(-1)` is the
|
|
1521
|
+
// last element. The Mojo helper wraps the same `length + i`
|
|
1522
|
+
// arithmetic the Go `bf_at` does so the lowering stays
|
|
1523
|
+
// symmetric across adapters.
|
|
1524
|
+
const obj = emit(object)
|
|
1525
|
+
const idx = emit(args[0])
|
|
1526
|
+
return `bf->at(${obj}, ${idx})`
|
|
1527
|
+
}
|
|
1528
|
+
case 'concat': {
|
|
1529
|
+
// `.concat(other)` merges two arrays. Returns a new ARRAY
|
|
1530
|
+
// ref so the result composes with `.join(...)` / other
|
|
1531
|
+
// array-shape methods downstream (the canonical Tier A
|
|
1532
|
+
// conformance fixture chains `.concat(...).join(' ')`).
|
|
1533
|
+
const a = emit(object)
|
|
1534
|
+
const b = emit(args[0])
|
|
1535
|
+
return `bf->concat(${a}, ${b})`
|
|
1536
|
+
}
|
|
1537
|
+
case 'slice': {
|
|
1538
|
+
// `.slice(start)` / `.slice(start, end)`. The Mojo helper
|
|
1539
|
+
// mirrors the Go arithmetic (negative-index normalisation,
|
|
1540
|
+
// out-of-bounds clamping, empty result on start >= end).
|
|
1541
|
+
// Absent `end` lowers as `undef`, which the helper treats as
|
|
1542
|
+
// "to length". Returns a new ARRAY ref so the result composes
|
|
1543
|
+
// with `.join(...)` downstream.
|
|
1544
|
+
const recv = emit(object)
|
|
1545
|
+
const start = emit(args[0])
|
|
1546
|
+
const end = args.length === 2 ? emit(args[1]) : 'undef'
|
|
1547
|
+
return `bf->slice(${recv}, ${start}, ${end})`
|
|
1548
|
+
}
|
|
1549
|
+
case 'reverse':
|
|
1550
|
+
case 'toReversed': {
|
|
1551
|
+
// Both shapes share a lowering — see the parser arm + Go
|
|
1552
|
+
// emit for the SSR-mutation-rationale. Returns a new ARRAY
|
|
1553
|
+
// ref so the result composes with `.join(...)` downstream.
|
|
1554
|
+
const recv = emit(object)
|
|
1555
|
+
return `bf->reverse(${recv})`
|
|
1556
|
+
}
|
|
1557
|
+
case 'toLowerCase': {
|
|
1558
|
+
// Perl's native `lc` is the obvious lowering — no helper
|
|
1559
|
+
// method needed. The receiver flows through `emit` so any
|
|
1560
|
+
// upstream coercion (`$value`, `$bf->string(...)`, etc.)
|
|
1561
|
+
// composes naturally.
|
|
1562
|
+
const recv = emit(object)
|
|
1563
|
+
return `lc(${recv})`
|
|
1564
|
+
}
|
|
1565
|
+
case 'toUpperCase': {
|
|
1566
|
+
// Perl's native `uc` — mirrors `toLowerCase` exactly.
|
|
1567
|
+
const recv = emit(object)
|
|
1568
|
+
return `uc(${recv})`
|
|
1569
|
+
}
|
|
1570
|
+
case 'trim': {
|
|
1571
|
+
// No Perl native `trim`; route through the `bf->trim`
|
|
1572
|
+
// helper so the regex stays in one place (and so an undef
|
|
1573
|
+
// receiver doesn't trigger a warning about applying `s///`
|
|
1574
|
+
// to undef).
|
|
1575
|
+
const recv = emit(object)
|
|
1576
|
+
return `bf->trim(${recv})`
|
|
1577
|
+
}
|
|
1578
|
+
default: {
|
|
1579
|
+
// TS-level exhaustiveness guard. If this throws at runtime, the
|
|
1580
|
+
// IR was constructed against a newer `ArrayMethod` variant that
|
|
1581
|
+
// this adapter hasn't been updated for — loud failure is better
|
|
1582
|
+
// than emitting a silent empty string downstream.
|
|
1583
|
+
const _exhaustive: never = method
|
|
1584
|
+
throw new Error(
|
|
1585
|
+
`renderArrayMethod: unhandled ArrayMethod '${(_exhaustive as string)}'`,
|
|
1586
|
+
)
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/**
|
|
1592
|
+
* Shared Mojo emit for `.sort(cmp)` / `.toSorted(cmp)` (#1448 Tier B).
|
|
1593
|
+
* Used by both the filter-context emitter and the top-level emitter,
|
|
1594
|
+
* plus the loop-hoist path in `renderLoop` — same emit shape across
|
|
1595
|
+
* all three so a regression in any one path surfaces consistently.
|
|
1596
|
+
*
|
|
1597
|
+
* The Perl helper accepts a hash-ref opts bag (room for a future
|
|
1598
|
+
* `nulls` knob without arity churn), and returns a fresh ARRAY ref
|
|
1599
|
+
* so downstream composition (`@{bf->sort(...)}` in `join(...)`, etc.)
|
|
1600
|
+
* stays straightforward.
|
|
1601
|
+
*/
|
|
1602
|
+
/**
|
|
1603
|
+
* Encode an `IRLoop.markerId` into a Perl-identifier-safe suffix
|
|
1604
|
+
* for the `bf_iter_…` hoist var. Collision-free for marker ids
|
|
1605
|
+
* that differ in any character — `-` and `_` map to distinct
|
|
1606
|
+
* encodings (`_x2d` vs `__`) so `l-0` and `l_0` stay distinct.
|
|
1607
|
+
*
|
|
1608
|
+
* Today the IR only emits `l<digits>` so the encoding is mostly
|
|
1609
|
+
* an identity, but pinning collision-freeness up front avoids a
|
|
1610
|
+
* silent variable-shadow bug if a future marker generator widens
|
|
1611
|
+
* the alphabet.
|
|
1612
|
+
*/
|
|
1613
|
+
function perlIdentifierFromMarkerId(markerId: string): string {
|
|
1614
|
+
return markerId.replace(/[^a-zA-Z0-9]/g, (ch) =>
|
|
1615
|
+
ch === '_' ? '__' : `_x${ch.charCodeAt(0).toString(16)}`
|
|
1616
|
+
)
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
function renderSortMethod(recv: string, c: SortComparator): string {
|
|
1620
|
+
const keyEntry =
|
|
1621
|
+
c.key.kind === 'self'
|
|
1622
|
+
? `key_kind => 'self'`
|
|
1623
|
+
: `key_kind => 'field', key => '${c.key.field}'`
|
|
1624
|
+
return `bf->sort(${recv}, { ${keyEntry}, compare_type => '${c.type}', direction => '${c.direction}' })`
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
/**
|
|
1628
|
+
* Lowering for the predicate body of a filter / every / some / find,
|
|
1629
|
+
* plus the same shape used by `renderBlockBodyCondition` for complex
|
|
1630
|
+
* block-body filters. Identifiers resolve against:
|
|
1631
|
+
* - the predicate's loop param (`$param`),
|
|
1632
|
+
* - `localVarMap` aliases declared inside the block body, then
|
|
1633
|
+
* - a bare `$name` fallback for signals captured by the closure.
|
|
1634
|
+
*
|
|
1635
|
+
* Methods that have no filter-context meaning (template-literal,
|
|
1636
|
+
* arrow-fn, conditional, unsupported) fall back to the `'1'` literal
|
|
1637
|
+
* the original switch's `default` arm returned — those shapes never
|
|
1638
|
+
* arose inside the predicates the adapter actually accepts.
|
|
1639
|
+
*/
|
|
1640
|
+
class MojoFilterEmitter implements ParsedExprEmitter {
|
|
1641
|
+
constructor(
|
|
1642
|
+
private readonly param: string,
|
|
1643
|
+
private readonly localVarMap: Map<string, string>,
|
|
1644
|
+
) {}
|
|
1645
|
+
|
|
1646
|
+
identifier(name: string): string {
|
|
1647
|
+
if (name === this.param) return `$${this.param}`
|
|
1648
|
+
const signal = this.localVarMap.get(name)
|
|
1649
|
+
if (signal) return `$${signal}`
|
|
1650
|
+
return `$${name}`
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
literal(value: string | number | boolean | null, literalType: LiteralType): string {
|
|
1654
|
+
if (literalType === 'string') return `'${value}'`
|
|
1655
|
+
if (literalType === 'boolean') return value ? '1' : '0'
|
|
1656
|
+
if (literalType === 'null') return 'undef'
|
|
1657
|
+
return String(value)
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
|
|
1661
|
+
// `.length` on a higher-order result (e.g.
|
|
1662
|
+
// `x.tags.filter(t => t.active).length > 0` inside the outer
|
|
1663
|
+
// filter predicate, #1443). The higher-order emit produces an
|
|
1664
|
+
// anonymous array ref `[grep ...]`; reading `->{length}` on that
|
|
1665
|
+
// is undef at runtime, which is why the pre-#1443 `containsHigherOrder`
|
|
1666
|
+
// gate refused this shape outright. Lowering `.length` to
|
|
1667
|
+
// `scalar(@{...})` makes the result a real Perl integer.
|
|
1668
|
+
if (property === 'length' && (object.kind === 'higher-order' || object.kind === 'array-literal')) {
|
|
1669
|
+
return `scalar(@{${emit(object)}})`
|
|
1670
|
+
}
|
|
1671
|
+
return `${emit(object)}->{${property}}`
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
|
|
1675
|
+
// Signal getter calls: filter() → $filter
|
|
1676
|
+
if (callee.kind === 'identifier' && args.length === 0) {
|
|
1677
|
+
return `$${callee.name}`
|
|
1678
|
+
}
|
|
1679
|
+
return emit(callee)
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
1683
|
+
const arg = emit(argument)
|
|
1684
|
+
if (op === '!') {
|
|
1685
|
+
// Wrap binary/logical operands in parens to dodge Perl precedence surprises.
|
|
1686
|
+
const needsParens = argument.kind === 'binary' || argument.kind === 'logical'
|
|
1687
|
+
return needsParens ? `!(${arg})` : `!${arg}`
|
|
1688
|
+
}
|
|
1689
|
+
if (op === '-') return `-${arg}`
|
|
1690
|
+
return arg
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
1694
|
+
const l = emit(left)
|
|
1695
|
+
const r = emit(right)
|
|
1696
|
+
if ((op === '===' || op === '==') && right.kind === 'literal' && right.literalType === 'string') {
|
|
1697
|
+
return `${l} eq ${r}`
|
|
1698
|
+
}
|
|
1699
|
+
if ((op === '!==' || op === '!=') && right.kind === 'literal' && right.literalType === 'string') {
|
|
1700
|
+
return `${l} ne ${r}`
|
|
1701
|
+
}
|
|
1702
|
+
const opMap: Record<string, string> = {
|
|
1703
|
+
'===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
|
|
1704
|
+
'+': '+', '-': '-', '*': '*', '/': '/',
|
|
1705
|
+
}
|
|
1706
|
+
return `${l} ${opMap[op] ?? op} ${r}`
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
1710
|
+
const l = emit(left)
|
|
1711
|
+
const r = emit(right)
|
|
1712
|
+
if (op === '&&') return `(${l} && ${r})`
|
|
1713
|
+
if (op === '||') return `(${l} || ${r})`
|
|
1714
|
+
return `(${l} // ${r})`
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
higherOrder(
|
|
1718
|
+
method: HigherOrderMethod,
|
|
1719
|
+
object: ParsedExpr,
|
|
1720
|
+
param: string,
|
|
1721
|
+
predicate: ParsedExpr,
|
|
1722
|
+
emit: (e: ParsedExpr) => string,
|
|
1723
|
+
): string {
|
|
1724
|
+
// The predicate body is also a filter context, but with this
|
|
1725
|
+
// higher-order's own `param` (potentially shadowing the outer one),
|
|
1726
|
+
// so we spin up a nested emitter with the inner param.
|
|
1727
|
+
const arrayExpr = emit(object)
|
|
1728
|
+
const predBody = emitParsedExpr(predicate, new MojoFilterEmitter(param, this.localVarMap))
|
|
1729
|
+
const grepBody = predBody.replace(new RegExp(`\\$${param}\\b`, 'g'), '$_')
|
|
1730
|
+
if (method === 'filter') return `[grep { ${grepBody} } @{${arrayExpr}}]`
|
|
1731
|
+
if (method === 'every') return `!(grep { !(${grepBody}) } @{${arrayExpr}})`
|
|
1732
|
+
if (method === 'some') return `!!(grep { ${grepBody} } @{${arrayExpr}})`
|
|
1733
|
+
return arrayExpr
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
|
|
1737
|
+
// Perl array ref: `[$a, $b]`. Filter-context use is rare (the
|
|
1738
|
+
// outer emitter routes most array-literal arrivals via
|
|
1739
|
+
// MojoTopLevelEmitter), but #1443's chain
|
|
1740
|
+
// `[a, b].filter(Boolean).join(' ')` can land here when the
|
|
1741
|
+
// outer `.filter()` recurses into a nested filter whose own
|
|
1742
|
+
// source is an array literal.
|
|
1743
|
+
return `[${elements.map(emit).join(', ')}]`
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
arrayMethod(
|
|
1747
|
+
method: ArrayMethod,
|
|
1748
|
+
object: ParsedExpr,
|
|
1749
|
+
args: ParsedExpr[],
|
|
1750
|
+
emit: (e: ParsedExpr) => string,
|
|
1751
|
+
): string {
|
|
1752
|
+
// Filter-context array methods are vanishingly rare — predicates
|
|
1753
|
+
// operate on scalars, not arrays. Defer to the top-level rendering
|
|
1754
|
+
// (`join(sep, @{...})`) for any case that does land here so the
|
|
1755
|
+
// emission stays consistent across contexts.
|
|
1756
|
+
return renderArrayMethod(method, object, args, emit)
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
sortMethod(
|
|
1760
|
+
_method: 'sort' | 'toSorted',
|
|
1761
|
+
object: ParsedExpr,
|
|
1762
|
+
comparator: SortComparator,
|
|
1763
|
+
emit: (e: ParsedExpr) => string,
|
|
1764
|
+
): string {
|
|
1765
|
+
return renderSortMethod(emit(object), comparator)
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
conditional(_test: ParsedExpr, _consequent: ParsedExpr, _alternate: ParsedExpr): string {
|
|
1769
|
+
return '1'
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
templateLiteral(_parts: TemplatePart[]): string {
|
|
1773
|
+
return '1'
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
arrowFn(_param: string, _body: ParsedExpr): string {
|
|
1777
|
+
return '1'
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
unsupported(_raw: string, _reason: string): string {
|
|
1781
|
+
return '1'
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/**
|
|
1786
|
+
* Lowering for top-level expressions whose identifiers resolve against
|
|
1787
|
+
* the Mojo template's stash (signals, props, locals introduced by
|
|
1788
|
+
* `% my $x = ...;` lines). Differs from the filter emitter mainly in
|
|
1789
|
+
* - `.length` → `scalar(@{...})` (filter contexts never see arrays
|
|
1790
|
+
* in lvalue position),
|
|
1791
|
+
* - `conditional` is supported (filter predicates can't return
|
|
1792
|
+
* ternaries),
|
|
1793
|
+
* - the `unsupported` fallback drops to the regex pipeline so legacy
|
|
1794
|
+
* shapes the AST can't classify still emit something coherent.
|
|
1795
|
+
*/
|
|
1796
|
+
class MojoTopLevelEmitter implements ParsedExprEmitter {
|
|
1797
|
+
constructor(private readonly adapter: MojoAdapter) {}
|
|
1798
|
+
|
|
1799
|
+
identifier(name: string): string {
|
|
1800
|
+
return `$${name}`
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
literal(value: string | number | boolean | null, literalType: LiteralType): string {
|
|
1804
|
+
if (literalType === 'string') return `'${value}'`
|
|
1805
|
+
if (literalType === 'boolean') return value ? '1' : '0'
|
|
1806
|
+
if (literalType === 'null') return 'undef'
|
|
1807
|
+
return String(value)
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
|
|
1811
|
+
const obj = emit(object)
|
|
1812
|
+
if (property === 'length') return `scalar(@{${obj}})`
|
|
1813
|
+
return `${obj}->{${property}}`
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
|
|
1817
|
+
// Signal getter: count() → $count
|
|
1818
|
+
if (callee.kind === 'identifier' && args.length === 0) {
|
|
1819
|
+
return `$${callee.name}`
|
|
1820
|
+
}
|
|
1821
|
+
// Array methods (`.join` and any others added to ArrayMethod, #1443)
|
|
1822
|
+
// are lifted into the `array-method` IR kind at parse time, so they
|
|
1823
|
+
// never reach this dispatcher. Per-method detection here would mix
|
|
1824
|
+
// value-builtin lowering with signal-call lowering — keeping them
|
|
1825
|
+
// separated forces every adapter to declare the full array-method
|
|
1826
|
+
// surface in one place (the `arrayMethod` emitter below).
|
|
1827
|
+
return emit(callee)
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
1831
|
+
const arg = emit(argument)
|
|
1832
|
+
if (op === '!') return `!${arg}`
|
|
1833
|
+
if (op === '-') return `-${arg}`
|
|
1834
|
+
return arg
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
1838
|
+
const l = emit(left)
|
|
1839
|
+
const r = emit(right)
|
|
1840
|
+
if ((op === '===' || op === '==') && right.kind === 'literal' && right.literalType === 'string') {
|
|
1841
|
+
return `${l} eq ${r}`
|
|
1842
|
+
}
|
|
1843
|
+
if ((op === '!==' || op === '!=') && right.kind === 'literal' && right.literalType === 'string') {
|
|
1844
|
+
return `${l} ne ${r}`
|
|
1845
|
+
}
|
|
1846
|
+
const opMap: Record<string, string> = {
|
|
1847
|
+
'===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
|
|
1848
|
+
'+': '+', '-': '-', '*': '*',
|
|
1849
|
+
}
|
|
1850
|
+
return `${l} ${opMap[op] ?? op} ${r}`
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
1854
|
+
const l = emit(left)
|
|
1855
|
+
const r = emit(right)
|
|
1856
|
+
if (op === '&&') return `(${l} && ${r})`
|
|
1857
|
+
if (op === '||') return `(${l} || ${r})`
|
|
1858
|
+
return `(${l} // ${r})`
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
higherOrder(
|
|
1862
|
+
method: HigherOrderMethod,
|
|
1863
|
+
object: ParsedExpr,
|
|
1864
|
+
param: string,
|
|
1865
|
+
predicate: ParsedExpr,
|
|
1866
|
+
emit: (e: ParsedExpr) => string,
|
|
1867
|
+
): string {
|
|
1868
|
+
const arrayExpr = emit(object)
|
|
1869
|
+
const predBody = this.adapter._renderPerlFilterExprPublic(predicate, param)
|
|
1870
|
+
const grepBody = predBody.replace(new RegExp(`\\$${param}\\b`, 'g'), '$_')
|
|
1871
|
+
if (method === 'filter') return `[grep { ${grepBody} } @{${arrayExpr}}]`
|
|
1872
|
+
if (method === 'every') return `!(grep { !(${grepBody}) } @{${arrayExpr}})`
|
|
1873
|
+
if (method === 'some') return `!!(grep { ${grepBody} } @{${arrayExpr}})`
|
|
1874
|
+
return arrayExpr
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
|
|
1878
|
+
// Perl array ref. Identifiers inside elements resolve through the
|
|
1879
|
+
// top-level emitter so `[className, childClass]` becomes
|
|
1880
|
+
// `[$className, $childClass]` (the registry Slot's chain in
|
|
1881
|
+
// #1443). Empty `[]` stays as `[]` — a valid empty Perl array
|
|
1882
|
+
// ref that grep/join handle naturally.
|
|
1883
|
+
return `[${elements.map(emit).join(', ')}]`
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
arrayMethod(
|
|
1887
|
+
method: ArrayMethod,
|
|
1888
|
+
object: ParsedExpr,
|
|
1889
|
+
args: ParsedExpr[],
|
|
1890
|
+
emit: (e: ParsedExpr) => string,
|
|
1891
|
+
): string {
|
|
1892
|
+
return renderArrayMethod(method, object, args, emit)
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
sortMethod(
|
|
1896
|
+
_method: 'sort' | 'toSorted',
|
|
1897
|
+
object: ParsedExpr,
|
|
1898
|
+
comparator: SortComparator,
|
|
1899
|
+
emit: (e: ParsedExpr) => string,
|
|
1900
|
+
): string {
|
|
1901
|
+
return renderSortMethod(emit(object), comparator)
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
conditional(
|
|
1905
|
+
test: ParsedExpr,
|
|
1906
|
+
consequent: ParsedExpr,
|
|
1907
|
+
alternate: ParsedExpr,
|
|
1908
|
+
emit: (e: ParsedExpr) => string,
|
|
1909
|
+
): string {
|
|
1910
|
+
return `(${emit(test)} ? ${emit(consequent)} : ${emit(alternate)})`
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
templateLiteral(_parts: TemplatePart[]): string {
|
|
1914
|
+
// Template literals don't appear at top level inside Mojo expressions
|
|
1915
|
+
// — they're handled by `convertTemplateLiteralPartsToPerl` at the
|
|
1916
|
+
// attribute / interpolation layer, not the expression dispatcher.
|
|
1917
|
+
return ''
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
arrowFn(_param: string, _body: ParsedExpr): string {
|
|
1921
|
+
return ''
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
unsupported(raw: string, _reason: string): string {
|
|
1925
|
+
// Legacy fallback: the regex pipeline handles shapes the AST can't
|
|
1926
|
+
// classify (mostly hand-written JS that pre-dates the parser).
|
|
1927
|
+
return this.adapter._convertExpressionToPerlPublic(raw)
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
export const mojoAdapter = new MojoAdapter()
|