@barefootjs/xslate 0.8.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/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 +1204 -0
- package/dist/adapter/xslate-adapter.d.ts +176 -0
- package/dist/adapter/xslate-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 +1224 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1204 -0
- package/lib/BarefootJS/Backend/Xslate.pm +152 -0
- package/package.json +82 -0
- package/src/__tests__/xslate-counter.test.ts +142 -0
- package/src/adapter/boolean-result.ts +126 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/xslate-adapter.ts +1721 -0
- package/src/build.ts +37 -0
- package/src/index.ts +8 -0
|
@@ -0,0 +1,1721 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BarefootJS Text::Xslate (Kolon) Template Adapter
|
|
3
|
+
*
|
|
4
|
+
* Generates Text::Xslate Kolon template files (.tx) from BarefootJS IR.
|
|
5
|
+
*
|
|
6
|
+
* Near-mechanical port of the Mojolicious EP adapter
|
|
7
|
+
* (packages/adapter-mojolicious/src/adapter/mojo-adapter.ts) from Mojo EP
|
|
8
|
+
* syntax to Kolon syntax. The expression-lowering pipeline (JS → Perl
|
|
9
|
+
* scalars / `$bf.helper(...)` calls) is shared in spirit with the Mojo
|
|
10
|
+
* adapter; only the surrounding template syntax differs:
|
|
11
|
+
*
|
|
12
|
+
* Mojo `<%= EXPR %>` → Kolon `<: EXPR :>` (HTML-escaped)
|
|
13
|
+
* Mojo `<%== EXPR %>` → Kolon `<: EXPR | mark_raw :>` (raw)
|
|
14
|
+
* Mojo `bf->method(args)` → Kolon `$bf.method(args)`
|
|
15
|
+
* Mojo `% if (C) { ... % }` → Kolon `: if (C) { ... : }` (line statements)
|
|
16
|
+
* Mojo `% for ... { ... % }`→ Kolon `: for $arr -> $x { ... : }`
|
|
17
|
+
*
|
|
18
|
+
* Kolon auto-escapes `<: ... :>` interpolations (the backend builds the
|
|
19
|
+
* engine with `type => 'html'`); helpers that emit markup are piped through
|
|
20
|
+
* `| mark_raw` so they survive verbatim — mirroring Mojo EP's `<%==` vs `<%=`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
ComponentIR,
|
|
25
|
+
IRNode,
|
|
26
|
+
IRElement,
|
|
27
|
+
IRText,
|
|
28
|
+
IRExpression,
|
|
29
|
+
IRConditional,
|
|
30
|
+
IRLoop,
|
|
31
|
+
IRComponent,
|
|
32
|
+
IRFragment,
|
|
33
|
+
IRSlot,
|
|
34
|
+
IRIfStatement,
|
|
35
|
+
IRProvider,
|
|
36
|
+
IRAsync,
|
|
37
|
+
IRProp,
|
|
38
|
+
IRTemplatePart,
|
|
39
|
+
CompilerError,
|
|
40
|
+
TypeInfo,
|
|
41
|
+
TemplatePrimitiveRegistry,
|
|
42
|
+
} from '@barefootjs/jsx'
|
|
43
|
+
import {
|
|
44
|
+
BaseAdapter,
|
|
45
|
+
type AdapterOutput,
|
|
46
|
+
type AdapterGenerateOptions,
|
|
47
|
+
type TemplateSections,
|
|
48
|
+
type ParsedExprEmitter,
|
|
49
|
+
type HigherOrderMethod,
|
|
50
|
+
type ArrayMethod,
|
|
51
|
+
type LiteralType,
|
|
52
|
+
type IRNodeEmitter,
|
|
53
|
+
type EmitIRNode,
|
|
54
|
+
type AttrValueEmitter,
|
|
55
|
+
isBooleanAttr,
|
|
56
|
+
parseExpression,
|
|
57
|
+
isSupported,
|
|
58
|
+
identifierPath,
|
|
59
|
+
emitParsedExpr,
|
|
60
|
+
emitIRNode,
|
|
61
|
+
emitAttrValue,
|
|
62
|
+
} from '@barefootjs/jsx'
|
|
63
|
+
import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result'
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Xslate adapter's IRNode render context. Like the Mojo adapter, Kolon's
|
|
67
|
+
* lowering doesn't consume any render-position flags, so the Ctx is empty.
|
|
68
|
+
* Kept as a named alias so future flags can extend it without changing the
|
|
69
|
+
* `IRNodeEmitter` interface.
|
|
70
|
+
*/
|
|
71
|
+
type XslateRenderCtx = Record<string, never>
|
|
72
|
+
import type { ParsedExpr, ParsedStatement, SortComparator, ReduceOp, FlatDepth, FlatMapOp, TemplatePart } from '@barefootjs/jsx'
|
|
73
|
+
import { BF_SLOT, BF_COND } from '@barefootjs/shared'
|
|
74
|
+
|
|
75
|
+
interface PrimitiveSpec {
|
|
76
|
+
arity: number
|
|
77
|
+
emit: (args: string[]) => string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Single source of truth for the Xslate adapter's template-primitive
|
|
82
|
+
* surface. Each entry pairs the expected arity with the emit function.
|
|
83
|
+
*
|
|
84
|
+
* The emit fn returns a Kolon expression (no surrounding `<: :>`) suitable
|
|
85
|
+
* for embedding inside an interpolation — `$bf.json($val)`,
|
|
86
|
+
* `$bf.floor($val)`, etc. The same primitive names as the Mojo adapter, but
|
|
87
|
+
* invoked as `$bf.NAME(args)` on the runtime instance instead of `bf->NAME`.
|
|
88
|
+
*/
|
|
89
|
+
const XSLATE_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
|
|
90
|
+
'JSON.stringify': { arity: 1, emit: (args) => `$bf.json(${args[0]})` },
|
|
91
|
+
'String': { arity: 1, emit: (args) => `$bf.string(${args[0]})` },
|
|
92
|
+
'Number': { arity: 1, emit: (args) => `$bf.number(${args[0]})` },
|
|
93
|
+
'Math.floor': { arity: 1, emit: (args) => `$bf.floor(${args[0]})` },
|
|
94
|
+
'Math.ceil': { arity: 1, emit: (args) => `$bf.ceil(${args[0]})` },
|
|
95
|
+
'Math.round': { arity: 1, emit: (args) => `$bf.round(${args[0]})` },
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Module-scope `templatePrimitives` map derived once from the spec record.
|
|
100
|
+
*/
|
|
101
|
+
const XSLATE_PRIMITIVE_EMIT_MAP: Record<string, (args: string[]) => string> =
|
|
102
|
+
Object.fromEntries(
|
|
103
|
+
Object.entries(XSLATE_TEMPLATE_PRIMITIVES).map(([k, v]) => [k, v.emit])
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Find the `children` prop's `jsx-children` payload. Narrowed via the
|
|
108
|
+
* AttrValue `kind` discriminator so adapter code stays type-safe if the IR
|
|
109
|
+
* shape evolves.
|
|
110
|
+
*/
|
|
111
|
+
function resolveJsxChildrenProp(props: readonly IRProp[]): IRNode[] {
|
|
112
|
+
const prop = props.find(p => p.name === 'children')
|
|
113
|
+
if (!prop) return []
|
|
114
|
+
if (prop.value.kind !== 'jsx-children') return []
|
|
115
|
+
return prop.value.children
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface XslateAdapterOptions {
|
|
119
|
+
/** Base path for client JS files (default: '/static/components/') */
|
|
120
|
+
clientJsBasePath?: string
|
|
121
|
+
|
|
122
|
+
/** Path to barefoot.js runtime (default: '/static/components/barefoot.js') */
|
|
123
|
+
barefootJsPath?: string
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRenderCtx> {
|
|
127
|
+
name = 'xslate'
|
|
128
|
+
extension = '.tx'
|
|
129
|
+
templatesPerComponent = true
|
|
130
|
+
// Template-string target with no component layer: `bf build` emits a static
|
|
131
|
+
// import-map HTML snippet to include into the page <head>.
|
|
132
|
+
importMapInjection = 'html-snippet' as const
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Identifier-path callees the Xslate runtime can render in template scope.
|
|
136
|
+
* The relocate pass consults this map to mark matching calls as
|
|
137
|
+
* template-safe; the SSR template emitter substitutes the JS call with the
|
|
138
|
+
* registered `$bf.NAME(...)` helper invocation.
|
|
139
|
+
*/
|
|
140
|
+
templatePrimitives: TemplatePrimitiveRegistry = XSLATE_PRIMITIVE_EMIT_MAP
|
|
141
|
+
|
|
142
|
+
private componentName: string = ''
|
|
143
|
+
private options: Required<XslateAdapterOptions>
|
|
144
|
+
private errors: CompilerError[] = []
|
|
145
|
+
private inLoop: boolean = false
|
|
146
|
+
/**
|
|
147
|
+
* SolidJS-style props identifier (`function(props: P)`) and the
|
|
148
|
+
* analyzer-extracted prop names. Stashed at `generate()` entry so the
|
|
149
|
+
* per-attribute `emitSpread` callback can build a propsObject spread bag as
|
|
150
|
+
* an inline Kolon hashref literal without re-walking the IR.
|
|
151
|
+
*/
|
|
152
|
+
private propsObjectName: string | null = null
|
|
153
|
+
private propsParams: { name: string }[] = []
|
|
154
|
+
/**
|
|
155
|
+
* Names (signal getters + props) whose value is a string, so `===`/`!==`
|
|
156
|
+
* against them lowers to Perl `eq`/`ne` rather than numeric `==`/`!=`.
|
|
157
|
+
* Kolon comparison operators delegate to Perl semantics, so the same
|
|
158
|
+
* string-vs-numeric distinction the Mojo adapter makes applies here.
|
|
159
|
+
*/
|
|
160
|
+
private stringValueNames: Set<string> = new Set()
|
|
161
|
+
|
|
162
|
+
constructor(options: XslateAdapterOptions = {}) {
|
|
163
|
+
super()
|
|
164
|
+
this.options = {
|
|
165
|
+
clientJsBasePath: options.clientJsBasePath ?? '/static/components/',
|
|
166
|
+
barefootJsPath: options.barefootJsPath ?? '/static/components/barefoot.js',
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
generate(ir: ComponentIR, options?: AdapterGenerateOptions): AdapterOutput {
|
|
171
|
+
this.componentName = ir.metadata.componentName
|
|
172
|
+
this.propsObjectName = ir.metadata.propsObjectName ?? null
|
|
173
|
+
this.propsParams = ir.metadata.propsParams.map(p => ({ name: p.name }))
|
|
174
|
+
// Record string-typed signals and props so equality comparisons against
|
|
175
|
+
// them lower to `eq`/`ne`. A signal is string-typed when its inferred
|
|
176
|
+
// type is `string` (or, defensively, when its initial value is a bare
|
|
177
|
+
// string literal); a prop when its annotated type is `string`.
|
|
178
|
+
this.stringValueNames = new Set<string>()
|
|
179
|
+
for (const s of ir.metadata.signals) {
|
|
180
|
+
if (isStringTypeInfo(s.type) || isBareStringLiteral(s.initialValue)) {
|
|
181
|
+
this.stringValueNames.add(s.getter)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
for (const p of ir.metadata.propsParams) {
|
|
185
|
+
if (isStringTypeInfo(p.type)) this.stringValueNames.add(p.name)
|
|
186
|
+
}
|
|
187
|
+
this.errors = []
|
|
188
|
+
this.childrenCaptureCounter = 0
|
|
189
|
+
|
|
190
|
+
// Mirror of the Mojo adapter's BF103 check: a child component referenced
|
|
191
|
+
// inside a loop body that is imported from a sibling .tsx emits a
|
|
192
|
+
// cross-template `$bf.render_child(...)` call that resolves only if the
|
|
193
|
+
// sibling template is registered alongside the parent at render time.
|
|
194
|
+
// Surface it loudly here. Suppressed when the caller guarantees that all
|
|
195
|
+
// sibling templates are registered on the same instance at render time.
|
|
196
|
+
if (!options?.siblingTemplatesRegistered) {
|
|
197
|
+
this.checkImportedLoopChildComponents(ir)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const templateBody = ir.root.type === 'if-statement'
|
|
201
|
+
? this.renderIfStatement(ir.root as IRIfStatement)
|
|
202
|
+
: this.renderNode(ir.root)
|
|
203
|
+
|
|
204
|
+
// Generate script registration
|
|
205
|
+
const scriptReg = options?.skipScriptRegistration
|
|
206
|
+
? ''
|
|
207
|
+
: this.generateScriptRegistrations(ir, options?.scriptBaseName)
|
|
208
|
+
|
|
209
|
+
const template = `${scriptReg}${templateBody}\n`
|
|
210
|
+
|
|
211
|
+
// Merge collected errors into IR errors
|
|
212
|
+
if (this.errors.length > 0) {
|
|
213
|
+
ir.errors.push(...this.errors)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Kolon templates have no JS-style imports / types / default-export
|
|
217
|
+
// sections. The `templatesPerComponent` mode emits one file per component
|
|
218
|
+
// using the raw `template` value; sections are populated for contract
|
|
219
|
+
// uniformity so the compiler never has to string-parse the template.
|
|
220
|
+
const sections: TemplateSections = {
|
|
221
|
+
imports: '',
|
|
222
|
+
types: '',
|
|
223
|
+
component: template,
|
|
224
|
+
defaultExport: '',
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
template,
|
|
229
|
+
sections,
|
|
230
|
+
extension: this.extension,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ===========================================================================
|
|
235
|
+
// Script Registration
|
|
236
|
+
// ===========================================================================
|
|
237
|
+
|
|
238
|
+
private generateScriptRegistrations(ir: ComponentIR, scriptBaseName?: string): string {
|
|
239
|
+
const hasInteractivity = this.hasClientInteractivity(ir)
|
|
240
|
+
if (!hasInteractivity) return ''
|
|
241
|
+
|
|
242
|
+
const name = scriptBaseName ?? ir.metadata.componentName
|
|
243
|
+
const runtimePath = this.options.barefootJsPath
|
|
244
|
+
const clientJsPath = `${this.options.clientJsBasePath}${name}.client.js`
|
|
245
|
+
|
|
246
|
+
// Kolon's `:` line marker PRINTS the statement's value, so a bare
|
|
247
|
+
// `: $bf.register_script(...)` would leak `register_script`'s return value
|
|
248
|
+
// (the new script count) into the rendered HTML. Bind the result to a
|
|
249
|
+
// throwaway `my` local — `: my $_ = EXPR;` evaluates the call for its
|
|
250
|
+
// side effect without emitting anything. (Kolon forbids re-`my` of the
|
|
251
|
+
// same name in one scope, so each registration gets a distinct var.)
|
|
252
|
+
const lines: string[] = []
|
|
253
|
+
lines.push(`: my $_bf_reg0 = $bf.register_script('${runtimePath}');`)
|
|
254
|
+
lines.push(`: my $_bf_reg1 = $bf.register_script('${clientJsPath}');`)
|
|
255
|
+
lines.push('')
|
|
256
|
+
return lines.join('\n')
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private hasClientInteractivity(ir: ComponentIR): boolean {
|
|
260
|
+
return (
|
|
261
|
+
ir.metadata.signals.length > 0 ||
|
|
262
|
+
ir.metadata.effects.length > 0 ||
|
|
263
|
+
ir.metadata.onMounts.length > 0 ||
|
|
264
|
+
(ir.metadata.clientAnalysis?.needsInit ?? false)
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ===========================================================================
|
|
269
|
+
// Node Rendering
|
|
270
|
+
// ===========================================================================
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Public entry point for node rendering. Delegates to the shared
|
|
274
|
+
* `IRNodeEmitter` dispatcher; per-kind logic lives in the `IRNodeEmitter`
|
|
275
|
+
* methods below.
|
|
276
|
+
*/
|
|
277
|
+
renderNode(node: IRNode): string {
|
|
278
|
+
return emitIRNode<XslateRenderCtx>(node, this, {} as XslateRenderCtx)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ===========================================================================
|
|
282
|
+
// IRNodeEmitter implementation (Xslate / Kolon)
|
|
283
|
+
// ===========================================================================
|
|
284
|
+
|
|
285
|
+
emitElement(node: IRElement, _ctx: XslateRenderCtx, _emit: EmitIRNode<XslateRenderCtx>): string {
|
|
286
|
+
return this.renderElement(node)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
emitText(node: IRText): string {
|
|
290
|
+
return node.value
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
emitExpression(node: IRExpression): string {
|
|
294
|
+
return this.renderExpression(node)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
emitConditional(node: IRConditional, _ctx: XslateRenderCtx, _emit: EmitIRNode<XslateRenderCtx>): string {
|
|
298
|
+
return this.renderConditional(node)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
emitLoop(node: IRLoop, _ctx: XslateRenderCtx, _emit: EmitIRNode<XslateRenderCtx>): string {
|
|
302
|
+
return this.renderLoop(node)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
emitComponent(node: IRComponent, _ctx: XslateRenderCtx, _emit: EmitIRNode<XslateRenderCtx>): string {
|
|
306
|
+
return this.renderComponent(node)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
emitFragment(node: IRFragment, _ctx: XslateRenderCtx, _emit: EmitIRNode<XslateRenderCtx>): string {
|
|
310
|
+
return this.renderFragment(node)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
emitSlot(node: IRSlot): string {
|
|
314
|
+
return this.renderSlot(node)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
emitIfStatement(node: IRIfStatement, _ctx: XslateRenderCtx, _emit: EmitIRNode<XslateRenderCtx>): string {
|
|
318
|
+
return this.renderIfStatement(node)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
emitProvider(node: IRProvider, _ctx: XslateRenderCtx, _emit: EmitIRNode<XslateRenderCtx>): string {
|
|
322
|
+
return this.renderChildren(node.children)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
emitAsync(node: IRAsync, _ctx: XslateRenderCtx, _emit: EmitIRNode<XslateRenderCtx>): string {
|
|
326
|
+
return this.renderAsync(node)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ===========================================================================
|
|
330
|
+
// Element Rendering
|
|
331
|
+
// ===========================================================================
|
|
332
|
+
|
|
333
|
+
renderElement(element: IRElement): string {
|
|
334
|
+
const tag = element.tag
|
|
335
|
+
const attrs = this.renderAttributes(element)
|
|
336
|
+
const children = this.renderChildren(element.children)
|
|
337
|
+
|
|
338
|
+
let hydrationAttrs = ''
|
|
339
|
+
if (element.needsScope) {
|
|
340
|
+
hydrationAttrs += ` ${this.renderScopeMarker('')}`
|
|
341
|
+
}
|
|
342
|
+
if (element.slotId) {
|
|
343
|
+
hydrationAttrs += ` ${this.renderSlotMarker(element.slotId)}`
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const voidElements = [
|
|
347
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
348
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
349
|
+
]
|
|
350
|
+
|
|
351
|
+
if (voidElements.includes(tag.toLowerCase())) {
|
|
352
|
+
return `<${tag}${attrs}${hydrationAttrs}>`
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ===========================================================================
|
|
359
|
+
// Expression Rendering
|
|
360
|
+
// ===========================================================================
|
|
361
|
+
|
|
362
|
+
renderExpression(expr: IRExpression): string {
|
|
363
|
+
if (expr.clientOnly) {
|
|
364
|
+
if (expr.slotId) {
|
|
365
|
+
return `<: $bf.comment("client:${expr.slotId}") | mark_raw :>`
|
|
366
|
+
}
|
|
367
|
+
return ''
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const perlExpr = this.convertExpressionToKolon(expr.expr)
|
|
371
|
+
|
|
372
|
+
if (expr.slotId) {
|
|
373
|
+
return `<: $bf.text_start("${expr.slotId}") | mark_raw :><: ${perlExpr} :><: $bf.text_end() | mark_raw :>`
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return `<: ${perlExpr} :>`
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ===========================================================================
|
|
380
|
+
// Conditional Rendering
|
|
381
|
+
// ===========================================================================
|
|
382
|
+
|
|
383
|
+
renderConditional(cond: IRConditional): string {
|
|
384
|
+
if (cond.clientOnly && cond.slotId) {
|
|
385
|
+
return `<: $bf.comment("cond-start:${cond.slotId}") | mark_raw :><: $bf.comment("cond-end:${cond.slotId}") | mark_raw :>`
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const condition = this.convertExpressionToKolon(cond.condition)
|
|
389
|
+
const whenTrue = this.renderNode(cond.whenTrue)
|
|
390
|
+
const whenFalse = this.renderNodeOrNull(cond.whenFalse)
|
|
391
|
+
|
|
392
|
+
// When slotId is present, add bf-c marker.
|
|
393
|
+
// Use comment markers for fragments (multiple sibling elements), attribute
|
|
394
|
+
// for single elements.
|
|
395
|
+
const isFragmentBranch = cond.whenTrue.type === 'fragment' || cond.whenFalse.type === 'fragment'
|
|
396
|
+
const useCommentMarkers = cond.slotId && isFragmentBranch
|
|
397
|
+
|
|
398
|
+
let markedTrue = whenTrue
|
|
399
|
+
let markedFalse = whenFalse
|
|
400
|
+
if (cond.slotId && !useCommentMarkers) {
|
|
401
|
+
markedTrue = this.addCondMarkerToFirstElement(whenTrue, cond.slotId)
|
|
402
|
+
markedFalse = whenFalse ? this.addCondMarkerToFirstElement(whenFalse, cond.slotId) : whenFalse
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
let result: string
|
|
406
|
+
if (useCommentMarkers) {
|
|
407
|
+
// Fragment branches: use comment markers
|
|
408
|
+
const inner = whenFalse
|
|
409
|
+
? `\n: if (${condition}) {\n${whenTrue}\n: } else {\n${whenFalse}\n: }\n`
|
|
410
|
+
: `\n: if (${condition}) {\n${whenTrue}\n: }\n`
|
|
411
|
+
result = `<: $bf.comment("cond-start:${cond.slotId}") | mark_raw :>${inner}<: $bf.comment("cond-end:${cond.slotId}") | mark_raw :>`
|
|
412
|
+
} else if (markedFalse) {
|
|
413
|
+
result = `\n: if (${condition}) {\n${markedTrue}\n: } else {\n${markedFalse}\n: }\n`
|
|
414
|
+
} else if (cond.slotId) {
|
|
415
|
+
// Conditional with no else: wrap with comment markers for client hydration
|
|
416
|
+
result = `<: $bf.comment("cond-start:${cond.slotId}") | mark_raw :>\n: if (${condition}) {\n${whenTrue}\n: }\n<: $bf.comment("cond-end:${cond.slotId}") | mark_raw :>`
|
|
417
|
+
} else {
|
|
418
|
+
result = `\n: if (${condition}) {\n${whenTrue}\n: }\n`
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return result
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private renderNodeOrNull(node: IRNode): string | null {
|
|
425
|
+
if (node.type === 'expression' && (node.expr === 'null' || node.expr === 'undefined')) {
|
|
426
|
+
return null
|
|
427
|
+
}
|
|
428
|
+
return this.renderNode(node)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Add bf-c attribute to the first HTML element in a branch.
|
|
433
|
+
* If no element found, wrap with comment markers.
|
|
434
|
+
*/
|
|
435
|
+
private addCondMarkerToFirstElement(content: string, condId: string): string {
|
|
436
|
+
// Match first HTML open tag
|
|
437
|
+
const match = content.match(/^(<\w+)([\s>])/)
|
|
438
|
+
if (match) {
|
|
439
|
+
return content.replace(/^(<\w+)([\s>])/, `$1 ${BF_COND}="${condId}"$2`)
|
|
440
|
+
}
|
|
441
|
+
// Fall back to comment markers for non-element content
|
|
442
|
+
return `<: $bf.comment("cond-start:${condId}") | mark_raw :>${content}<: $bf.comment("cond-end:${condId}") | mark_raw :>`
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ===========================================================================
|
|
446
|
+
// Imported-component-in-loop check (BF103)
|
|
447
|
+
// ===========================================================================
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Push a `BF103` diagnostic for every component reference inside a loop body
|
|
451
|
+
* whose name is imported from a relative-path module. Mirror of the Mojo
|
|
452
|
+
* adapter's check — the Xslate adapter has the same cross-template-
|
|
453
|
+
* registration constraint at request time.
|
|
454
|
+
*/
|
|
455
|
+
private checkImportedLoopChildComponents(ir: ComponentIR): void {
|
|
456
|
+
const relativeImports = new Set<string>()
|
|
457
|
+
for (const imp of ir.metadata.templateImports ?? ir.metadata.imports ?? []) {
|
|
458
|
+
if (!imp.source.startsWith('./') && !imp.source.startsWith('../')) continue
|
|
459
|
+
if (imp.isTypeOnly) continue
|
|
460
|
+
for (const spec of imp.specifiers) {
|
|
461
|
+
relativeImports.add(spec.alias ?? spec.name)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (relativeImports.size === 0) return
|
|
465
|
+
|
|
466
|
+
const loc = { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } }
|
|
467
|
+
const visit = (node: IRNode, inLoop: boolean): void => {
|
|
468
|
+
switch (node.type) {
|
|
469
|
+
case 'component': {
|
|
470
|
+
const comp = node as IRComponent
|
|
471
|
+
if (inLoop && relativeImports.has(comp.name)) {
|
|
472
|
+
this.errors.push({
|
|
473
|
+
code: 'BF103',
|
|
474
|
+
severity: 'error',
|
|
475
|
+
message: `Component <${comp.name}> is imported from a sibling module and used inside a loop. The Xslate adapter emits a cross-template call; the child template must be registered alongside the parent at render time.`,
|
|
476
|
+
loc: comp.loc ?? loc,
|
|
477
|
+
suggestion: {
|
|
478
|
+
message:
|
|
479
|
+
`Options:\n` +
|
|
480
|
+
` 1. Compile '${comp.name}' (its source file) with the same adapter and register the resulting Xslate template alongside the parent at render time.\n` +
|
|
481
|
+
` 2. Inline <${comp.name}> directly inside the loop body so no cross-file template lookup is needed.\n` +
|
|
482
|
+
` 3. Mark the loop position as @client-only so the template is materialised on the client instead of at SSR time.`,
|
|
483
|
+
},
|
|
484
|
+
})
|
|
485
|
+
}
|
|
486
|
+
for (const child of comp.children) visit(child, inLoop)
|
|
487
|
+
break
|
|
488
|
+
}
|
|
489
|
+
case 'element':
|
|
490
|
+
for (const child of (node as IRElement).children) visit(child, inLoop)
|
|
491
|
+
break
|
|
492
|
+
case 'fragment':
|
|
493
|
+
for (const child of (node as IRFragment).children) visit(child, inLoop)
|
|
494
|
+
break
|
|
495
|
+
case 'conditional': {
|
|
496
|
+
const cond = node as IRConditional
|
|
497
|
+
visit(cond.whenTrue, inLoop)
|
|
498
|
+
if (cond.whenFalse) visit(cond.whenFalse, inLoop)
|
|
499
|
+
break
|
|
500
|
+
}
|
|
501
|
+
case 'loop':
|
|
502
|
+
for (const child of (node as IRLoop).children) visit(child, true)
|
|
503
|
+
break
|
|
504
|
+
case 'if-statement': {
|
|
505
|
+
const stmt = node as IRIfStatement
|
|
506
|
+
visit(stmt.consequent, inLoop)
|
|
507
|
+
if (stmt.alternate) visit(stmt.alternate, inLoop)
|
|
508
|
+
break
|
|
509
|
+
}
|
|
510
|
+
case 'provider':
|
|
511
|
+
for (const child of (node as IRProvider).children) visit(child, inLoop)
|
|
512
|
+
break
|
|
513
|
+
case 'async': {
|
|
514
|
+
const a = node as IRAsync
|
|
515
|
+
visit(a.fallback, inLoop)
|
|
516
|
+
for (const child of a.children) visit(child, inLoop)
|
|
517
|
+
break
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
visit(ir.root, false)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ===========================================================================
|
|
525
|
+
// Loop Rendering
|
|
526
|
+
// ===========================================================================
|
|
527
|
+
|
|
528
|
+
renderLoop(loop: IRLoop): string {
|
|
529
|
+
// Client-only loops: skip SSR rendering entirely
|
|
530
|
+
if (loop.clientOnly) return ''
|
|
531
|
+
|
|
532
|
+
// An array/object-destructure loop param (`([emoji, users]) => ...` or
|
|
533
|
+
// `({ name, age }) => ...`) lowers to invalid Kolon — Kolon's `for LIST
|
|
534
|
+
// -> $item` binds a single scalar and can't unpack a tuple. Surface this
|
|
535
|
+
// at build time instead of shipping a broken template line.
|
|
536
|
+
if (loop.paramBindings && loop.paramBindings.length > 0) {
|
|
537
|
+
this.errors.push({
|
|
538
|
+
code: 'BF104',
|
|
539
|
+
severity: 'error',
|
|
540
|
+
message: `Loop callback uses an array/object destructure pattern (\`${loop.param}\`) that the Xslate adapter cannot lower — Kolon \`for LIST -> $item\` binds a single scalar and can't unpack a tuple.`,
|
|
541
|
+
loc: loop.loc ?? { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
542
|
+
suggestion: {
|
|
543
|
+
message:
|
|
544
|
+
`Options:\n` +
|
|
545
|
+
` 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` +
|
|
546
|
+
` 2. Mark the loop position as @client-only so the destructure runs in JS on the client.\n` +
|
|
547
|
+
` 3. Move the loop into a primitive that the adapter registers explicitly.`,
|
|
548
|
+
},
|
|
549
|
+
})
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const rawArray = this.convertExpressionToKolon(loop.array)
|
|
553
|
+
// Apply sort if present: wrap the loop array in the shared `$bf.sort`
|
|
554
|
+
// helper, binding the sorted result to a per-iteration local so the
|
|
555
|
+
// helper runs once.
|
|
556
|
+
let array = rawArray
|
|
557
|
+
if (loop.sortComparator) {
|
|
558
|
+
array = renderSortMethod(rawArray, loop.sortComparator)
|
|
559
|
+
}
|
|
560
|
+
const param = loop.param
|
|
561
|
+
// Kolon binds the item directly via `for LIST -> $item`. The index, when
|
|
562
|
+
// needed (`.keys().map(k => ...)` or an explicit `index` param), comes
|
|
563
|
+
// from Text::Xslate's loop variable `$~param.index`.
|
|
564
|
+
const renderedChildren = this.renderChildren(loop.children)
|
|
565
|
+
|
|
566
|
+
// For `keys`-shape iterations the callback param IS the index. We iterate
|
|
567
|
+
// the array but bind the loop var to a throwaway and expose the index as
|
|
568
|
+
// `$param`. Kolon's `$~loopvar.index` provides the 0-based index.
|
|
569
|
+
const loopVar = loop.iterationShape === 'keys' ? '__bf_item' : param
|
|
570
|
+
|
|
571
|
+
// Index alias: when an explicit `index` param is present (`.map((x, i) =>
|
|
572
|
+
// ...)`) or the iteration is `keys`-shaped, expose it via a `: my` Kolon
|
|
573
|
+
// local bound to the loop variable's `.index` accessor.
|
|
574
|
+
const indexLocalLines: string[] = []
|
|
575
|
+
if (loop.iterationShape === 'keys') {
|
|
576
|
+
indexLocalLines.push(`: my $${param} = $~${loopVar}.index;`)
|
|
577
|
+
} else if (loop.index) {
|
|
578
|
+
indexLocalLines.push(`: my $${loop.index} = $~${loopVar}.index;`)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const prevInLoop = this.inLoop
|
|
582
|
+
this.inLoop = true
|
|
583
|
+
// Re-render children now that inLoop is set (so nested components use the
|
|
584
|
+
// loop-child naming convention). renderedChildren above was computed with
|
|
585
|
+
// the previous flag; recompute under the loop flag.
|
|
586
|
+
const childrenUnderLoop = this.renderChildren(loop.children)
|
|
587
|
+
this.inLoop = prevInLoop
|
|
588
|
+
void renderedChildren
|
|
589
|
+
|
|
590
|
+
// Whole-item conditional: prepend an always-present `<!--bf-loop-i:KEY-->`
|
|
591
|
+
// anchor before each item's (possibly empty) conditional content so the
|
|
592
|
+
// client's `mapArrayAnchored` can hydrate every SSR-rendered item by its
|
|
593
|
+
// anchor.
|
|
594
|
+
const bodyChildren =
|
|
595
|
+
loop.bodyIsItemConditional && loop.key
|
|
596
|
+
? `<: $bf.comment("loop-i:" ~ ${this.convertExpressionToKolon(loop.key)}) | mark_raw :>\n${childrenUnderLoop}`
|
|
597
|
+
: childrenUnderLoop
|
|
598
|
+
|
|
599
|
+
const lines: string[] = []
|
|
600
|
+
// Scoped per-call-site marker so sibling `.map()`s under the same parent
|
|
601
|
+
// each get their own reconciliation range.
|
|
602
|
+
lines.push(`<: $bf.comment("loop:${loop.markerId}") | mark_raw :>`)
|
|
603
|
+
lines.push(`: for ${array} -> $${loopVar} {`)
|
|
604
|
+
for (const il of indexLocalLines) lines.push(il)
|
|
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.renderKolonFilterExpr(
|
|
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(bodyChildren)
|
|
631
|
+
lines.push(`: }`)
|
|
632
|
+
} else {
|
|
633
|
+
lines.push(bodyChildren)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
lines.push(`: }`)
|
|
637
|
+
lines.push(`<: $bf.comment("/loop:${loop.markerId}") | mark_raw :>`)
|
|
638
|
+
|
|
639
|
+
return lines.join('\n')
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ===========================================================================
|
|
643
|
+
// Component Rendering
|
|
644
|
+
// ===========================================================================
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* AttrValue lowering for component invocation props (Kolon hashref-entry
|
|
648
|
+
* form). Kolon CANNOT splat a hash into positional args, so every prop is
|
|
649
|
+
* emitted as a `key => value` entry that the caller collects into ONE
|
|
650
|
+
* hashref literal passed to `$bf.render_child(name, { ... })`.
|
|
651
|
+
*
|
|
652
|
+
* `jsx-children` returns empty — children are captured via a Kolon macro
|
|
653
|
+
* below, not threaded through the hashref entry list.
|
|
654
|
+
*/
|
|
655
|
+
private readonly componentPropEmitter: AttrValueEmitter = {
|
|
656
|
+
emitLiteral: (value, name) => `${name} => '${value.value}'`,
|
|
657
|
+
emitExpression: (value, name) => {
|
|
658
|
+
if (value.parts) {
|
|
659
|
+
return `${name} => ${this.convertTemplateLiteralPartsToKolon(value.parts)}`
|
|
660
|
+
}
|
|
661
|
+
return `${name} => ${this.convertExpressionToKolon(value.expr)}`
|
|
662
|
+
},
|
|
663
|
+
emitSpread: (value) => {
|
|
664
|
+
// Kolon hashrefs can't be splatted into the entry list the way Perl
|
|
665
|
+
// `%{...}` flattens into a list. The propsObject case is handled in
|
|
666
|
+
// `renderComponent` (it enumerates the analyzer's props params); any
|
|
667
|
+
// other spread shape is refused there via the unsupported gate. Emit
|
|
668
|
+
// the lowered expression so a downstream consumer sees something
|
|
669
|
+
// coherent, but renderComponent only routes the enumerated case here.
|
|
670
|
+
return this.convertExpressionToKolon(value.expr)
|
|
671
|
+
},
|
|
672
|
+
emitTemplate: (value, name) =>
|
|
673
|
+
`${name} => ${this.convertTemplateLiteralPartsToKolon(value.parts)}`,
|
|
674
|
+
emitBooleanAttr: (_value, name) => `${name} => 1`,
|
|
675
|
+
emitBooleanShorthand: (_value, name) => `${name} => 1`,
|
|
676
|
+
// JSX children flow through the Kolon macro capture below; they're not
|
|
677
|
+
// part of the hashref entry list.
|
|
678
|
+
emitJsxChildren: () => '',
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
renderComponent(comp: IRComponent): string {
|
|
682
|
+
const propParts: string[] = []
|
|
683
|
+
for (const p of comp.props) {
|
|
684
|
+
// Skip callback props (onXxx) — event handlers are client-only for SSR.
|
|
685
|
+
if (p.name.match(/^on[A-Z]/) && p.value.kind === 'expression') continue
|
|
686
|
+
// Spread props: enumerate the analyzer's props params into hashref
|
|
687
|
+
// entries (the propsObject case) — Kolon can't flatten a hashref into
|
|
688
|
+
// the entry list. Other spread shapes are refused with BF101.
|
|
689
|
+
if (p.value.kind === 'spread') {
|
|
690
|
+
const trimmed = p.value.expr.trim()
|
|
691
|
+
if (this.propsObjectName && this.propsObjectName === trimmed) {
|
|
692
|
+
for (const pp of this.propsParams) {
|
|
693
|
+
propParts.push(`${pp.name} => $${pp.name}`)
|
|
694
|
+
}
|
|
695
|
+
continue
|
|
696
|
+
}
|
|
697
|
+
this.errors.push({
|
|
698
|
+
code: 'BF101',
|
|
699
|
+
severity: 'error',
|
|
700
|
+
message: `Spread props (\`{...${trimmed}}\`) on a child component cannot be lowered to Kolon — Kolon hashref method args can't splat a runtime hash into named entries.`,
|
|
701
|
+
loc: comp.loc ?? { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
702
|
+
suggestion: {
|
|
703
|
+
message: 'Pass the child component its props explicitly rather than spreading a runtime object.',
|
|
704
|
+
},
|
|
705
|
+
})
|
|
706
|
+
continue
|
|
707
|
+
}
|
|
708
|
+
const lowered = emitAttrValue(p.value, this.componentPropEmitter, p.name)
|
|
709
|
+
if (lowered) propParts.push(lowered)
|
|
710
|
+
}
|
|
711
|
+
// Pass slot ID so the child renderer can set correct scope ID for
|
|
712
|
+
// hydration. Skip for loop children — they use ComponentName_random.
|
|
713
|
+
if (comp.slotId && !this.inLoop) {
|
|
714
|
+
propParts.push(`_bf_slot => '${comp.slotId}'`)
|
|
715
|
+
}
|
|
716
|
+
const tplName = this.toTemplateName(comp.name)
|
|
717
|
+
|
|
718
|
+
// Resolve the effective children: a nested `<Box>…</Box>` populates
|
|
719
|
+
// `comp.children`; an attribute-form `<Box children={<jsx/>} />` lands in
|
|
720
|
+
// a `jsx-children` AttrValue on the corresponding prop.
|
|
721
|
+
const effectiveChildren: IRNode[] = comp.children.length > 0
|
|
722
|
+
? comp.children
|
|
723
|
+
: resolveJsxChildrenProp(comp.props)
|
|
724
|
+
|
|
725
|
+
if (effectiveChildren.length > 0) {
|
|
726
|
+
// Forward JSX children via a Kolon macro. The macro body is evaluated in
|
|
727
|
+
// the parent's template scope (signals, conditionals) and produces the
|
|
728
|
+
// children HTML; the macro call result is passed as the `children` entry
|
|
729
|
+
// of the render_child hashref. `render_child` materializes a CODE-ref
|
|
730
|
+
// children value through the backend before handing it to the child.
|
|
731
|
+
const childrenBody = this.renderChildren(effectiveChildren)
|
|
732
|
+
const macroName = `bf_children_${comp.slotId ?? 'c' + this.childrenCaptureCounter++}`
|
|
733
|
+
const childrenEntry = `children => ${macroName}()`
|
|
734
|
+
const allParts = [...propParts, childrenEntry]
|
|
735
|
+
return `<: macro ${macroName} -> () { :>${childrenBody}<: } :><: $bf.render_child('${tplName}', { ${allParts.join(', ')} }) | mark_raw :>`
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const hashEntries = propParts.length > 0 ? `, { ${propParts.join(', ')} }` : ''
|
|
739
|
+
return `<: $bf.render_child('${tplName}'${hashEntries}) | mark_raw :>`
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private childrenCaptureCounter = 0
|
|
743
|
+
|
|
744
|
+
private toTemplateName(componentName: string): string {
|
|
745
|
+
// Convert PascalCase to snake_case for template naming.
|
|
746
|
+
return componentName
|
|
747
|
+
.replace(/([A-Z])/g, '_$1')
|
|
748
|
+
.toLowerCase()
|
|
749
|
+
.replace(/^_/, '')
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ===========================================================================
|
|
753
|
+
// If-Statement (Conditional Return) Rendering
|
|
754
|
+
// ===========================================================================
|
|
755
|
+
|
|
756
|
+
private renderIfStatement(ifStmt: IRIfStatement): string {
|
|
757
|
+
const condition = this.convertExpressionToKolon(ifStmt.condition)
|
|
758
|
+
const consequent = ifStmt.consequent.type === 'if-statement'
|
|
759
|
+
? this.renderIfStatement(ifStmt.consequent as IRIfStatement)
|
|
760
|
+
: this.renderNode(ifStmt.consequent)
|
|
761
|
+
let result = `: if (${condition}) {\n${consequent}\n`
|
|
762
|
+
|
|
763
|
+
if (ifStmt.alternate) {
|
|
764
|
+
if (ifStmt.alternate.type === 'if-statement') {
|
|
765
|
+
const altResult = this.renderIfStatement(ifStmt.alternate as IRIfStatement)
|
|
766
|
+
// Replace leading ": if" with ": } elsif"
|
|
767
|
+
result += altResult.replace(/^: if/, ': } elsif')
|
|
768
|
+
} else {
|
|
769
|
+
const alternate = this.renderNode(ifStmt.alternate)
|
|
770
|
+
result += `: } else {\n${alternate}\n`
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
result += `: }`
|
|
775
|
+
return result
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ===========================================================================
|
|
779
|
+
// Fragment & Slot Rendering
|
|
780
|
+
// ===========================================================================
|
|
781
|
+
|
|
782
|
+
private renderFragment(fragment: IRFragment): string {
|
|
783
|
+
const children = this.renderChildren(fragment.children)
|
|
784
|
+
if (fragment.needsScopeComment) {
|
|
785
|
+
return `<: $bf.scope_comment() | mark_raw :>${children}`
|
|
786
|
+
}
|
|
787
|
+
return children
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
private renderSlot(_slot: IRSlot): string {
|
|
791
|
+
// Captured children arrive under the `children` key (see renderComponent's
|
|
792
|
+
// macro capture + render_child call), so the var is `$children`, not
|
|
793
|
+
// `$content`. The content is already-rendered markup, so emit it raw —
|
|
794
|
+
// otherwise Kolon's html-escape would entity-escape the child tags.
|
|
795
|
+
// (The IR producer doesn't currently emit `slot` nodes — `{children}`
|
|
796
|
+
// lowers to an expression whose macro-captured value is already raw — so
|
|
797
|
+
// this is defensive correctness for if/when a slot node is produced.)
|
|
798
|
+
return `<: $children | mark_raw :>`
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
renderAsync(node: IRAsync): string {
|
|
802
|
+
const fallback = this.renderNode(node.fallback)
|
|
803
|
+
const children = this.renderChildren(node.children)
|
|
804
|
+
// Capture the fallback into a Kolon macro and pass its rendered HTML to
|
|
805
|
+
// `$bf.async_boundary`, which wraps it in a `<div bf-async="aX">`
|
|
806
|
+
// placeholder. Same shape as `renderComponent`'s children capture.
|
|
807
|
+
const macroName = `bf_async_fallback_${node.id}`
|
|
808
|
+
return `<: macro ${macroName} -> () { :>${fallback}<: } :><: $bf.async_boundary('${node.id}', ${macroName}()) | mark_raw :>\n${children}`
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// ===========================================================================
|
|
812
|
+
// Attribute Rendering
|
|
813
|
+
// ===========================================================================
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* AttrValue lowering for intrinsic-element attributes (Kolon).
|
|
817
|
+
*/
|
|
818
|
+
private readonly elementAttrEmitter: AttrValueEmitter = {
|
|
819
|
+
emitLiteral: (value, name) => `${name}="${value.value}"`,
|
|
820
|
+
emitExpression: (value, name) => {
|
|
821
|
+
// Refuse shapes that the lowering pipeline can't represent in Kolon —
|
|
822
|
+
// object literals (`style={{...}}`) and tagged-template-literal call
|
|
823
|
+
// expressions (`cn\`base \${tone()}\``). Same gate as the Mojo adapter.
|
|
824
|
+
if (this.refuseUnsupportedAttrExpression(value.expr, name)) {
|
|
825
|
+
return ''
|
|
826
|
+
}
|
|
827
|
+
if (isBooleanAttr(name) || value.presenceOrUndefined) {
|
|
828
|
+
// Boolean attributes: render conditionally (present or absent).
|
|
829
|
+
return `<: ${this.convertExpressionToKolon(value.expr)} ? '${name}' : '' :>`
|
|
830
|
+
}
|
|
831
|
+
// Boolean-result handling: route boolean-shaped values through
|
|
832
|
+
// `$bf.bool_str` so the wire bytes match JS `String(boolean)`.
|
|
833
|
+
const perl = this.convertExpressionToKolon(value.expr)
|
|
834
|
+
if (isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name)) {
|
|
835
|
+
return `${name}="<: $bf.bool_str(${perl}) :>"`
|
|
836
|
+
}
|
|
837
|
+
return `${name}="<: ${perl} :>"`
|
|
838
|
+
},
|
|
839
|
+
emitBooleanAttr: (_value, name) => name,
|
|
840
|
+
emitTemplate: (value, name) =>
|
|
841
|
+
`${name}="<: ${this.convertTemplateLiteralPartsToKolon(value.parts)} :>"`,
|
|
842
|
+
// Spread attributes (`<div {...attrs()} />`) lower through the
|
|
843
|
+
// `$bf.spread_attrs` runtime helper, mirroring the Mojo adapter.
|
|
844
|
+
emitSpread: (value) => {
|
|
845
|
+
if (this.refuseUnsupportedAttrExpression(value.expr, '...')) {
|
|
846
|
+
return ''
|
|
847
|
+
}
|
|
848
|
+
// SolidJS-style props identifier (`(props: P) { <el {...props}/> }`) has
|
|
849
|
+
// no matching `$props` variable in Kolon's scope — props arrive as a
|
|
850
|
+
// flat set of top-level vars. Emit an inline hashref literal enumerating
|
|
851
|
+
// the analyzer-extracted props params.
|
|
852
|
+
const trimmed = value.expr.trim()
|
|
853
|
+
if (this.propsObjectName && this.propsObjectName === trimmed) {
|
|
854
|
+
const entries = this.propsParams.map(p =>
|
|
855
|
+
`${JSON.stringify(p.name)} => $${p.name}`,
|
|
856
|
+
)
|
|
857
|
+
return `<: $bf.spread_attrs({${entries.join(', ')}}) | mark_raw :>`
|
|
858
|
+
}
|
|
859
|
+
const perlExpr = this.convertExpressionToKolon(value.expr)
|
|
860
|
+
return `<: $bf.spread_attrs(${perlExpr}) | mark_raw :>`
|
|
861
|
+
},
|
|
862
|
+
// Neither variant is legal on intrinsic elements.
|
|
863
|
+
emitBooleanShorthand: () => '',
|
|
864
|
+
emitJsxChildren: () => '',
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
private renderAttributes(element: IRElement): string {
|
|
868
|
+
const parts: string[] = []
|
|
869
|
+
|
|
870
|
+
for (const attr of element.attrs) {
|
|
871
|
+
// Rewrite JSX special-prop names to their HTML-attribute counterparts.
|
|
872
|
+
let attrName: string
|
|
873
|
+
if (attr.name === 'className') attrName = 'class'
|
|
874
|
+
else if (attr.name === 'key') attrName = 'data-key'
|
|
875
|
+
else attrName = attr.name
|
|
876
|
+
const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
|
|
877
|
+
if (lowered) parts.push(lowered)
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return parts.length > 0 ? ' ' + parts.join(' ') : ''
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// ===========================================================================
|
|
884
|
+
// Hydration Markers
|
|
885
|
+
// ===========================================================================
|
|
886
|
+
|
|
887
|
+
renderScopeMarker(_instanceIdExpr: string): string {
|
|
888
|
+
// bf-s is the addressable scope id. hydration_attrs adds bf-h / bf-m /
|
|
889
|
+
// bf-r conditionally; props_attr adds bf-p when props are present.
|
|
890
|
+
return `bf-s="<: $bf.scope_attr() :>" <: $bf.hydration_attrs() | mark_raw :> <: $bf.props_attr() | mark_raw :>`
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
renderSlotMarker(slotId: string): string {
|
|
894
|
+
return `${BF_SLOT}="${slotId}"`
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
renderCondMarker(condId: string): string {
|
|
898
|
+
return `${BF_COND}="${condId}"`
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// ===========================================================================
|
|
902
|
+
// Filter Predicate Rendering (ParsedExpr → Kolon)
|
|
903
|
+
// ===========================================================================
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Convert a ParsedExpr AST to a Kolon expression string for filter
|
|
907
|
+
* predicates. Wraps the shared ParsedExpr dispatcher with an
|
|
908
|
+
* `XslateFilterEmitter` carrying the predicate's loop param and any
|
|
909
|
+
* block-body local var aliases.
|
|
910
|
+
*/
|
|
911
|
+
private renderKolonFilterExpr(
|
|
912
|
+
expr: ParsedExpr,
|
|
913
|
+
param: string,
|
|
914
|
+
localVarMap: Map<string, string> = new Map(),
|
|
915
|
+
): string {
|
|
916
|
+
return emitParsedExpr(expr, new XslateFilterEmitter(param, localVarMap, n => this._isStringValueName(n)))
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Render a complex block body filter into a Kolon condition.
|
|
921
|
+
* Handles patterns like: filter(t => { const f = filter(); if (...) return ...; })
|
|
922
|
+
*/
|
|
923
|
+
private renderBlockBodyCondition(
|
|
924
|
+
statements: ParsedStatement[],
|
|
925
|
+
param: string
|
|
926
|
+
): string {
|
|
927
|
+
const localVarMap = new Map<string, string>()
|
|
928
|
+
const paths = this.collectReturnPaths(statements, [], localVarMap, param)
|
|
929
|
+
|
|
930
|
+
if (paths.length === 0) return '1'
|
|
931
|
+
if (paths.length === 1) return this.buildSinglePathCondition(paths[0], param, localVarMap)
|
|
932
|
+
|
|
933
|
+
// Multiple paths: build OR condition
|
|
934
|
+
const parts: string[] = []
|
|
935
|
+
for (const path of paths) {
|
|
936
|
+
if (path.result.kind === 'literal' && path.result.literalType === 'boolean' && path.result.value === false) continue
|
|
937
|
+
const cond = this.buildSinglePathCondition(path, param, localVarMap)
|
|
938
|
+
if (cond !== '0') parts.push(cond)
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (parts.length === 0) return '0'
|
|
942
|
+
if (parts.length === 1) return parts[0]
|
|
943
|
+
return `(${parts.join(' || ')})`
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
private collectReturnPaths(
|
|
947
|
+
statements: ParsedStatement[],
|
|
948
|
+
currentConditions: ParsedExpr[],
|
|
949
|
+
localVarMap: Map<string, string>,
|
|
950
|
+
param: string
|
|
951
|
+
): Array<{ conditions: ParsedExpr[]; result: ParsedExpr }> {
|
|
952
|
+
const paths: Array<{ conditions: ParsedExpr[]; result: ParsedExpr }> = []
|
|
953
|
+
|
|
954
|
+
for (const stmt of statements) {
|
|
955
|
+
if (stmt.kind === 'var-decl') {
|
|
956
|
+
if (stmt.init.kind === 'call' && stmt.init.callee.kind === 'identifier') {
|
|
957
|
+
localVarMap.set(stmt.name, stmt.init.callee.name)
|
|
958
|
+
}
|
|
959
|
+
} else if (stmt.kind === 'return') {
|
|
960
|
+
paths.push({ conditions: [...currentConditions], result: stmt.value })
|
|
961
|
+
break
|
|
962
|
+
} else if (stmt.kind === 'if') {
|
|
963
|
+
const thenPaths = this.collectReturnPaths(stmt.consequent, [...currentConditions, stmt.condition], localVarMap, param)
|
|
964
|
+
paths.push(...thenPaths)
|
|
965
|
+
|
|
966
|
+
if (stmt.alternate) {
|
|
967
|
+
const negated: ParsedExpr = { kind: 'unary', op: '!', argument: stmt.condition }
|
|
968
|
+
const elsePaths = this.collectReturnPaths(stmt.alternate, [...currentConditions, negated], localVarMap, param)
|
|
969
|
+
paths.push(...elsePaths)
|
|
970
|
+
} else {
|
|
971
|
+
currentConditions.push({ kind: 'unary', op: '!', argument: stmt.condition })
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
return paths
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
private buildSinglePathCondition(
|
|
980
|
+
path: { conditions: ParsedExpr[]; result: ParsedExpr },
|
|
981
|
+
param: string,
|
|
982
|
+
localVarMap: Map<string, string>
|
|
983
|
+
): string {
|
|
984
|
+
if (path.result.kind === 'literal' && path.result.literalType === 'boolean') {
|
|
985
|
+
if (path.result.value === true) {
|
|
986
|
+
if (path.conditions.length === 0) return '1'
|
|
987
|
+
return this.renderConditionsAnd(path.conditions, param, localVarMap)
|
|
988
|
+
}
|
|
989
|
+
return '0'
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (path.conditions.length === 0) {
|
|
993
|
+
return this.renderKolonFilterExpr(path.result, param, localVarMap)
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const condPart = this.renderConditionsAnd(path.conditions, param, localVarMap)
|
|
997
|
+
const resultPart = this.renderKolonFilterExpr(path.result, param, localVarMap)
|
|
998
|
+
return `(${condPart} && ${resultPart})`
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
private renderConditionsAnd(
|
|
1002
|
+
conditions: ParsedExpr[],
|
|
1003
|
+
param: string,
|
|
1004
|
+
localVarMap: Map<string, string>
|
|
1005
|
+
): string {
|
|
1006
|
+
if (conditions.length === 0) return '1'
|
|
1007
|
+
if (conditions.length === 1) return this.renderKolonFilterExpr(conditions[0], param, localVarMap)
|
|
1008
|
+
const parts = conditions.map(c => this.renderKolonFilterExpr(c, param, localVarMap))
|
|
1009
|
+
return `(${parts.join(' && ')})`
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// ===========================================================================
|
|
1013
|
+
// Expression Conversion: JS → Kolon
|
|
1014
|
+
// ===========================================================================
|
|
1015
|
+
|
|
1016
|
+
private convertTemplateLiteralPartsToKolon(literalParts: IRTemplatePart[]): string {
|
|
1017
|
+
const parts: string[] = []
|
|
1018
|
+
for (const part of literalParts) {
|
|
1019
|
+
if (part.type === 'string') {
|
|
1020
|
+
parts.push(this.substituteJsInterpolationsToKolon(part.value))
|
|
1021
|
+
} else if (part.type === 'ternary') {
|
|
1022
|
+
const cond = this.convertExpressionToKolon(part.condition)
|
|
1023
|
+
parts.push(`(${cond} ? '${part.whenTrue}' : '${part.whenFalse}')`)
|
|
1024
|
+
} else if (part.type === 'lookup') {
|
|
1025
|
+
// `${MAP[KEY]}` against a Record<T, string> literal — emit a Kolon
|
|
1026
|
+
// hash literal with an immediate `{ ... }[$key]` lookup. Kolon's `//`
|
|
1027
|
+
// turns a miss into an empty string, matching the go-template
|
|
1028
|
+
// adapter's "empty when no case matches" semantics.
|
|
1029
|
+
const keyExpr = this.convertExpressionToKolon(part.key)
|
|
1030
|
+
const entries = Object.entries(part.cases)
|
|
1031
|
+
.map(([k, v]) => `'${k}' => '${v}'`)
|
|
1032
|
+
.join(', ')
|
|
1033
|
+
parts.push(`({ ${entries} }[${keyExpr}] // '')`)
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
// Join with Kolon string concatenation (`~`).
|
|
1037
|
+
return parts.length === 1 ? parts[0] : parts.join(' ~ ')
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Translate `${EXPR}` interpolations in a static template-part string into
|
|
1042
|
+
* Kolon variable references and concatenate them with the surrounding
|
|
1043
|
+
* literal text.
|
|
1044
|
+
*/
|
|
1045
|
+
private substituteJsInterpolationsToKolon(s: string): string {
|
|
1046
|
+
const segments: string[] = []
|
|
1047
|
+
const re = /\$\{([^}]+)\}/g
|
|
1048
|
+
let lastIndex = 0
|
|
1049
|
+
let m: RegExpExecArray | null
|
|
1050
|
+
while ((m = re.exec(s)) !== null) {
|
|
1051
|
+
if (m.index > lastIndex) {
|
|
1052
|
+
segments.push(`'${s.slice(lastIndex, m.index)}'`)
|
|
1053
|
+
}
|
|
1054
|
+
segments.push(this.convertExpressionToKolon(m[1].trim()))
|
|
1055
|
+
lastIndex = re.lastIndex
|
|
1056
|
+
}
|
|
1057
|
+
if (lastIndex < s.length) {
|
|
1058
|
+
segments.push(`'${s.slice(lastIndex)}'`)
|
|
1059
|
+
}
|
|
1060
|
+
if (segments.length === 0) return `''`
|
|
1061
|
+
return segments.length === 1 ? segments[0] : `(${segments.join(' ~ ')})`
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Refuse JS expression shapes that have no idiomatic Kolon representation:
|
|
1066
|
+
* object literals (`style={{...}}`) and tagged-template-literal call
|
|
1067
|
+
* expressions (`cn\`base \${tone()}\``). Records `BF101`. Returns `true`
|
|
1068
|
+
* when the shape was rejected (caller should drop the attribute).
|
|
1069
|
+
*/
|
|
1070
|
+
private refuseUnsupportedAttrExpression(expr: string, attrName: string): boolean {
|
|
1071
|
+
let probe = expr.trim()
|
|
1072
|
+
while (probe.startsWith('(')) probe = probe.slice(1).trimStart()
|
|
1073
|
+
const startsAsObjectLiteral = probe.startsWith('{')
|
|
1074
|
+
const hasTaggedTemplate = /[A-Za-z_$][\w$]*\s*`/.test(probe)
|
|
1075
|
+
if (!startsAsObjectLiteral && !hasTaggedTemplate) return false
|
|
1076
|
+
const parsed = parseExpression(expr.trim())
|
|
1077
|
+
const support = isSupported(parsed)
|
|
1078
|
+
if (parsed.kind !== 'unsupported' && support.supported) return false
|
|
1079
|
+
const reason = support.reason ?? (parsed.kind === 'unsupported' ? parsed.reason : undefined)
|
|
1080
|
+
const reasonLine = reason ? `\n${reason}` : ''
|
|
1081
|
+
this.errors.push({
|
|
1082
|
+
code: 'BF101',
|
|
1083
|
+
severity: 'error',
|
|
1084
|
+
message: `Expression not supported on attribute '${attrName}': ${expr.trim()}${reasonLine}`,
|
|
1085
|
+
loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
1086
|
+
suggestion: {
|
|
1087
|
+
message: 'The Xslate adapter cannot lower JS object literals or tagged-template-literal expressions into Kolon. 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.',
|
|
1088
|
+
},
|
|
1089
|
+
})
|
|
1090
|
+
return true
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
private convertExpressionToKolon(expr: string): string {
|
|
1094
|
+
// Parse-first lowering — parity with the Mojo adapter's
|
|
1095
|
+
// `convertExpressionToPerl`. Parse the JS expression once, gate it on the
|
|
1096
|
+
// shared `isSupported`, and render every supported shape through the AST
|
|
1097
|
+
// emitter. Unsupported shapes surface as BF101.
|
|
1098
|
+
const trimmed = expr.trim()
|
|
1099
|
+
if (trimmed === '') return "''"
|
|
1100
|
+
|
|
1101
|
+
const parsed = parseExpression(trimmed)
|
|
1102
|
+
const support = isSupported(parsed)
|
|
1103
|
+
if (!support.supported) {
|
|
1104
|
+
this.errors.push({
|
|
1105
|
+
code: 'BF101',
|
|
1106
|
+
severity: 'error',
|
|
1107
|
+
message: `Expression not supported: ${trimmed}`,
|
|
1108
|
+
loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
1109
|
+
suggestion: {
|
|
1110
|
+
message: support.reason
|
|
1111
|
+
? `${support.reason}\n\nOptions:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in the backend`
|
|
1112
|
+
: 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in the backend',
|
|
1113
|
+
},
|
|
1114
|
+
})
|
|
1115
|
+
// Safe Kolon empty-string literal — valid in every context the result
|
|
1116
|
+
// might land in.
|
|
1117
|
+
return "''"
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
return this.renderParsedExprToKolon(parsed)
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Render a full ParsedExpr tree to Kolon for top-level (non-filter)
|
|
1125
|
+
* expressions where identifiers are signals / template vars.
|
|
1126
|
+
*/
|
|
1127
|
+
private renderParsedExprToKolon(expr: ParsedExpr): string {
|
|
1128
|
+
return emitParsedExpr(expr, new XslateTopLevelEmitter(this))
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** Whether `name` (a signal getter or prop) holds a string value, so an
|
|
1132
|
+
* equality comparison against it should use Perl `eq`/`ne`. */
|
|
1133
|
+
_isStringValueName(name: string): boolean {
|
|
1134
|
+
return this.stringValueNames.has(name)
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
_recordExprBF101(message: string, reason?: string): void {
|
|
1138
|
+
this.errors.push({
|
|
1139
|
+
code: 'BF101',
|
|
1140
|
+
severity: 'error',
|
|
1141
|
+
message,
|
|
1142
|
+
loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
1143
|
+
suggestion: {
|
|
1144
|
+
message: reason
|
|
1145
|
+
? `${reason}\n\nOptions:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in the backend`
|
|
1146
|
+
: 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in the backend',
|
|
1147
|
+
},
|
|
1148
|
+
})
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** Internal hook for higher-order: predicate body re-uses the filter emitter. */
|
|
1152
|
+
_renderKolonFilterExprPublic(expr: ParsedExpr, param: string): string {
|
|
1153
|
+
return this.renderKolonFilterExpr(expr, param)
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// ===========================================================================
|
|
1158
|
+
// ParsedExpr emitters
|
|
1159
|
+
// ===========================================================================
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* Lowering for `array-method` IR nodes — shared between the filter and
|
|
1163
|
+
* top-level emitters so the emitted Kolon form stays consistent regardless of
|
|
1164
|
+
* which context the chain lands in. The receiver/array helpers are the same
|
|
1165
|
+
* runtime methods the Mojo adapter calls, invoked as `$bf.NAME(...)` on the
|
|
1166
|
+
* Kolon `$bf` object instead of `bf->NAME`.
|
|
1167
|
+
*
|
|
1168
|
+
* Perl-native string ops the Mojo adapter inlines (`lc`, `uc`) have no Kolon
|
|
1169
|
+
* builtin, so they route through dedicated runtime helpers — but those
|
|
1170
|
+
* helpers aren't part of the validated v1 surface, so they're emitted as
|
|
1171
|
+
* `$bf.NAME(...)` calls consistent with the rest. Array methods whose Mojo
|
|
1172
|
+
* form relied on Perl `@{...}` deref (`join`) route through `$bf` helpers.
|
|
1173
|
+
*/
|
|
1174
|
+
function renderArrayMethod(
|
|
1175
|
+
method: ArrayMethod,
|
|
1176
|
+
object: ParsedExpr,
|
|
1177
|
+
args: ParsedExpr[],
|
|
1178
|
+
emit: (e: ParsedExpr) => string,
|
|
1179
|
+
): string {
|
|
1180
|
+
switch (method) {
|
|
1181
|
+
case 'join': {
|
|
1182
|
+
const obj = emit(object)
|
|
1183
|
+
const sep = args.length >= 1 ? emit(args[0]) : `','`
|
|
1184
|
+
return `$bf.join(${obj}, ${sep})`
|
|
1185
|
+
}
|
|
1186
|
+
case 'includes': {
|
|
1187
|
+
const obj = emit(object)
|
|
1188
|
+
const needle = emit(args[0])
|
|
1189
|
+
return `$bf.includes(${obj}, ${needle})`
|
|
1190
|
+
}
|
|
1191
|
+
case 'indexOf':
|
|
1192
|
+
case 'lastIndexOf': {
|
|
1193
|
+
const fn = method === 'indexOf' ? 'index_of' : 'last_index_of'
|
|
1194
|
+
const obj = emit(object)
|
|
1195
|
+
const needle = emit(args[0])
|
|
1196
|
+
return `$bf.${fn}(${obj}, ${needle})`
|
|
1197
|
+
}
|
|
1198
|
+
case 'at': {
|
|
1199
|
+
const obj = emit(object)
|
|
1200
|
+
const idx = args.length >= 1 ? emit(args[0]) : '0'
|
|
1201
|
+
return `$bf.at(${obj}, ${idx})`
|
|
1202
|
+
}
|
|
1203
|
+
case 'concat': {
|
|
1204
|
+
if (args.length === 0) {
|
|
1205
|
+
return emit(object)
|
|
1206
|
+
}
|
|
1207
|
+
const a = emit(object)
|
|
1208
|
+
const b = emit(args[0])
|
|
1209
|
+
return `$bf.concat(${a}, ${b})`
|
|
1210
|
+
}
|
|
1211
|
+
case 'slice': {
|
|
1212
|
+
const recv = emit(object)
|
|
1213
|
+
const start = args.length >= 1 ? emit(args[0]) : '0'
|
|
1214
|
+
const end = args.length >= 2 ? emit(args[1]) : 'undef'
|
|
1215
|
+
return `$bf.slice(${recv}, ${start}, ${end})`
|
|
1216
|
+
}
|
|
1217
|
+
case 'reverse':
|
|
1218
|
+
case 'toReversed': {
|
|
1219
|
+
const recv = emit(object)
|
|
1220
|
+
return `$bf.reverse(${recv})`
|
|
1221
|
+
}
|
|
1222
|
+
case 'toLowerCase': {
|
|
1223
|
+
const recv = emit(object)
|
|
1224
|
+
return `$bf.lc(${recv})`
|
|
1225
|
+
}
|
|
1226
|
+
case 'toUpperCase': {
|
|
1227
|
+
const recv = emit(object)
|
|
1228
|
+
return `$bf.uc(${recv})`
|
|
1229
|
+
}
|
|
1230
|
+
case 'trim': {
|
|
1231
|
+
const recv = emit(object)
|
|
1232
|
+
return `$bf.trim(${recv})`
|
|
1233
|
+
}
|
|
1234
|
+
case 'split': {
|
|
1235
|
+
const recv = emit(object)
|
|
1236
|
+
if (args.length === 0) {
|
|
1237
|
+
return `$bf.split(${recv})`
|
|
1238
|
+
}
|
|
1239
|
+
const sep = emit(args[0])
|
|
1240
|
+
if (args.length === 1) {
|
|
1241
|
+
return `$bf.split(${recv}, ${sep})`
|
|
1242
|
+
}
|
|
1243
|
+
const limit = emit(args[1])
|
|
1244
|
+
return `$bf.split(${recv}, ${sep}, ${limit})`
|
|
1245
|
+
}
|
|
1246
|
+
case 'startsWith':
|
|
1247
|
+
case 'endsWith': {
|
|
1248
|
+
const fn = method === 'startsWith' ? 'starts_with' : 'ends_with'
|
|
1249
|
+
const recv = emit(object)
|
|
1250
|
+
const arg = emit(args[0])
|
|
1251
|
+
if (args.length >= 2) {
|
|
1252
|
+
return `$bf.${fn}(${recv}, ${arg}, ${emit(args[1])})`
|
|
1253
|
+
}
|
|
1254
|
+
return `$bf.${fn}(${recv}, ${arg})`
|
|
1255
|
+
}
|
|
1256
|
+
case 'replace': {
|
|
1257
|
+
const recv = emit(object)
|
|
1258
|
+
const oldS = emit(args[0])
|
|
1259
|
+
const newS = emit(args[1])
|
|
1260
|
+
return `$bf.replace(${recv}, ${oldS}, ${newS})`
|
|
1261
|
+
}
|
|
1262
|
+
case 'repeat': {
|
|
1263
|
+
const recv = emit(object)
|
|
1264
|
+
const count = args.length === 0 ? '0' : emit(args[0])
|
|
1265
|
+
return `$bf.repeat(${recv}, ${count})`
|
|
1266
|
+
}
|
|
1267
|
+
case 'padStart':
|
|
1268
|
+
case 'padEnd': {
|
|
1269
|
+
const fn = method === 'padStart' ? 'pad_start' : 'pad_end'
|
|
1270
|
+
const recv = emit(object)
|
|
1271
|
+
if (args.length === 0) {
|
|
1272
|
+
return `$bf.${fn}(${recv}, 0)`
|
|
1273
|
+
}
|
|
1274
|
+
const target = emit(args[0])
|
|
1275
|
+
if (args.length === 1) {
|
|
1276
|
+
return `$bf.${fn}(${recv}, ${target})`
|
|
1277
|
+
}
|
|
1278
|
+
const pad = emit(args[1])
|
|
1279
|
+
return `$bf.${fn}(${recv}, ${target}, ${pad})`
|
|
1280
|
+
}
|
|
1281
|
+
default: {
|
|
1282
|
+
// TS-level exhaustiveness guard.
|
|
1283
|
+
const _exhaustive: never = method
|
|
1284
|
+
throw new Error(
|
|
1285
|
+
`renderArrayMethod: unhandled ArrayMethod '${(_exhaustive as string)}'`,
|
|
1286
|
+
)
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* Shared Kolon emit for `.sort(cmp)` / `.toSorted(cmp)`. Used by both the
|
|
1293
|
+
* filter-context emitter and the top-level emitter, plus the loop-array
|
|
1294
|
+
* wrap in `renderLoop`. The runtime `$bf.sort` accepts a hashref opts bag and
|
|
1295
|
+
* returns a fresh array ref.
|
|
1296
|
+
*/
|
|
1297
|
+
function renderSortMethod(recv: string, c: SortComparator): string {
|
|
1298
|
+
const keyHashes = c.keys.map((k) => {
|
|
1299
|
+
const keyEntry =
|
|
1300
|
+
k.key.kind === 'self'
|
|
1301
|
+
? `key_kind => 'self'`
|
|
1302
|
+
: `key_kind => 'field', key => '${k.key.field}'`
|
|
1303
|
+
return `{ ${keyEntry}, compare_type => '${k.type}', direction => '${k.direction}' }`
|
|
1304
|
+
})
|
|
1305
|
+
return `$bf.sort(${recv}, { keys => [${keyHashes.join(', ')}] })`
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* Render a `.reduce(fn, init)` arithmetic fold as a `$bf.reduce(...)` call.
|
|
1310
|
+
*/
|
|
1311
|
+
function renderReduceMethod(recv: string, op: ReduceOp, direction: 'left' | 'right'): string {
|
|
1312
|
+
const keyEntry =
|
|
1313
|
+
op.key.kind === 'self'
|
|
1314
|
+
? `key_kind => 'self'`
|
|
1315
|
+
: `key_kind => 'field', key => '${op.key.field}'`
|
|
1316
|
+
const init =
|
|
1317
|
+
op.type === 'string'
|
|
1318
|
+
? `'${op.init.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
|
|
1319
|
+
: op.init
|
|
1320
|
+
return `$bf.reduce(${recv}, { op => '${op.op}', ${keyEntry}, type => '${op.type}', init => ${init}, direction => '${direction}' })`
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// `.flat(depth?)` → `$bf.flat($recv, $depth)`.
|
|
1324
|
+
function renderFlatMethod(recv: string, depth: FlatDepth): string {
|
|
1325
|
+
const d = depth === 'infinity' ? -1 : depth
|
|
1326
|
+
return `$bf.flat(${recv}, ${d})`
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// `.flatMap(...)` → `$bf.flat_map(...)` / `$bf.flat_map_tuple(...)`.
|
|
1330
|
+
function renderFlatMapMethod(recv: string, op: FlatMapOp): string {
|
|
1331
|
+
const proj = op.projection
|
|
1332
|
+
if (proj.kind === 'tuple') {
|
|
1333
|
+
const specs = proj.elements
|
|
1334
|
+
.map(l => (l.kind === 'self' ? `['self', '']` : `['field', '${l.field}']`))
|
|
1335
|
+
.join(', ')
|
|
1336
|
+
return `$bf.flat_map_tuple(${recv}, ${specs})`
|
|
1337
|
+
}
|
|
1338
|
+
if (proj.kind === 'self') return `$bf.flat_map(${recv}, 'self', '')`
|
|
1339
|
+
return `$bf.flat_map(${recv}, 'field', '${proj.field}')`
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/** True when `type` is the `string` primitive. */
|
|
1343
|
+
function isStringTypeInfo(type: TypeInfo | undefined): boolean {
|
|
1344
|
+
return type?.kind === 'primitive' && type.primitive === 'string'
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/** True when `initialValue` is a bare string-literal expression. */
|
|
1348
|
+
function isBareStringLiteral(initialValue: string | undefined): boolean {
|
|
1349
|
+
if (!initialValue) return false
|
|
1350
|
+
const v = initialValue.trim()
|
|
1351
|
+
return (v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* Whether a comparison operand is string-typed, so JS `===`/`!==` against it
|
|
1356
|
+
* must lower to Perl `eq`/`ne` instead of numeric `==`/`!=`.
|
|
1357
|
+
*/
|
|
1358
|
+
function isStringTypedOperand(expr: ParsedExpr, isStringName: (n: string) => boolean): boolean {
|
|
1359
|
+
if (expr.kind === 'literal' && expr.literalType === 'string') return true
|
|
1360
|
+
if (expr.kind === 'call' && expr.callee.kind === 'identifier' && expr.args.length === 0) {
|
|
1361
|
+
return isStringName(expr.callee.name)
|
|
1362
|
+
}
|
|
1363
|
+
if (expr.kind === 'member' && expr.object.kind === 'identifier' && expr.object.name === 'props') {
|
|
1364
|
+
return isStringName(expr.property)
|
|
1365
|
+
}
|
|
1366
|
+
return false
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* Lowering for the predicate body of a filter / every / some / find, plus the
|
|
1371
|
+
* same shape used by `renderBlockBodyCondition` for complex block-body
|
|
1372
|
+
* filters. Higher-order predicates are emitted using Kolon's own scalar
|
|
1373
|
+
* comparison operators (which delegate to Perl semantics).
|
|
1374
|
+
*
|
|
1375
|
+
* NOTE: Kolon has no `grep { } @{...}` form, so nested higher-order chains
|
|
1376
|
+
* (`x.tags.filter(...).length`) inside a predicate route through the
|
|
1377
|
+
* top-level emitter's `$bf`-helper higher-order lowering. This emitter keeps
|
|
1378
|
+
* the scalar-comparison surface the predicates the adapter accepts actually
|
|
1379
|
+
* use; richer nested shapes fall back to the helper or surface as BF101 via
|
|
1380
|
+
* the top-level emitter.
|
|
1381
|
+
*/
|
|
1382
|
+
class XslateFilterEmitter implements ParsedExprEmitter {
|
|
1383
|
+
constructor(
|
|
1384
|
+
private readonly param: string,
|
|
1385
|
+
private readonly localVarMap: Map<string, string>,
|
|
1386
|
+
private readonly isStringName: (n: string) => boolean = () => false,
|
|
1387
|
+
) {}
|
|
1388
|
+
|
|
1389
|
+
identifier(name: string): string {
|
|
1390
|
+
if (name === this.param) return `$${this.param}`
|
|
1391
|
+
const signal = this.localVarMap.get(name)
|
|
1392
|
+
if (signal) return `$${signal}`
|
|
1393
|
+
return `$${name}`
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
literal(value: string | number | boolean | null, literalType: LiteralType): string {
|
|
1397
|
+
if (literalType === 'string') return `'${value}'`
|
|
1398
|
+
if (literalType === 'boolean') return value ? '1' : '0'
|
|
1399
|
+
if (literalType === 'null') return 'undef'
|
|
1400
|
+
return String(value)
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
|
|
1404
|
+
// `.length` on an array — Kolon's array length is `$arr.size()`.
|
|
1405
|
+
if (property === 'length') {
|
|
1406
|
+
return `${emit(object)}.size()`
|
|
1407
|
+
}
|
|
1408
|
+
// Hash field access — Kolon dot works on hash refs.
|
|
1409
|
+
return `${emit(object)}.${property}`
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
|
|
1413
|
+
// Signal getter calls: filter() → $filter
|
|
1414
|
+
if (callee.kind === 'identifier' && args.length === 0) {
|
|
1415
|
+
return `$${callee.name}`
|
|
1416
|
+
}
|
|
1417
|
+
return emit(callee)
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
1421
|
+
const arg = emit(argument)
|
|
1422
|
+
if (op === '!') {
|
|
1423
|
+
const needsParens = argument.kind === 'binary' || argument.kind === 'logical'
|
|
1424
|
+
return needsParens ? `!(${arg})` : `!${arg}`
|
|
1425
|
+
}
|
|
1426
|
+
if (op === '-') return `-${arg}`
|
|
1427
|
+
return arg
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
1431
|
+
const l = emit(left)
|
|
1432
|
+
const r = emit(right)
|
|
1433
|
+
const isStr = (e: ParsedExpr) => isStringTypedOperand(e, this.isStringName)
|
|
1434
|
+
const stringCmp = isStr(left) || isStr(right)
|
|
1435
|
+
if ((op === '===' || op === '==') && stringCmp) {
|
|
1436
|
+
return `${l} eq ${r}`
|
|
1437
|
+
}
|
|
1438
|
+
if ((op === '!==' || op === '!=') && stringCmp) {
|
|
1439
|
+
return `${l} ne ${r}`
|
|
1440
|
+
}
|
|
1441
|
+
const opMap: Record<string, string> = {
|
|
1442
|
+
'===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
|
|
1443
|
+
'+': '+', '-': '-', '*': '*', '/': '/',
|
|
1444
|
+
}
|
|
1445
|
+
return `${l} ${opMap[op] ?? op} ${r}`
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
1449
|
+
const l = emit(left)
|
|
1450
|
+
const r = emit(right)
|
|
1451
|
+
if (op === '&&') return `(${l} && ${r})`
|
|
1452
|
+
if (op === '||') return `(${l} || ${r})`
|
|
1453
|
+
return `(${l} // ${r})`
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
higherOrder(
|
|
1457
|
+
method: HigherOrderMethod,
|
|
1458
|
+
object: ParsedExpr,
|
|
1459
|
+
param: string,
|
|
1460
|
+
predicate: ParsedExpr,
|
|
1461
|
+
emit: (e: ParsedExpr) => string,
|
|
1462
|
+
): string {
|
|
1463
|
+
// Nested higher-order inside a filter predicate has no Kolon scalar form;
|
|
1464
|
+
// defer to the receiver so the predicate at least references a real value
|
|
1465
|
+
// (a richer chain would surface its own diagnostic at the top level).
|
|
1466
|
+
void method
|
|
1467
|
+
void param
|
|
1468
|
+
void predicate
|
|
1469
|
+
return emit(object)
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
|
|
1473
|
+
return `[${elements.map(emit).join(', ')}]`
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
arrayMethod(
|
|
1477
|
+
method: ArrayMethod,
|
|
1478
|
+
object: ParsedExpr,
|
|
1479
|
+
args: ParsedExpr[],
|
|
1480
|
+
emit: (e: ParsedExpr) => string,
|
|
1481
|
+
): string {
|
|
1482
|
+
return renderArrayMethod(method, object, args, emit)
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
sortMethod(
|
|
1486
|
+
_method: 'sort' | 'toSorted',
|
|
1487
|
+
object: ParsedExpr,
|
|
1488
|
+
comparator: SortComparator,
|
|
1489
|
+
emit: (e: ParsedExpr) => string,
|
|
1490
|
+
): string {
|
|
1491
|
+
return renderSortMethod(emit(object), comparator)
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
reduceMethod(method: 'reduce' | 'reduceRight', object: ParsedExpr, reduceOp: ReduceOp, emit: (e: ParsedExpr) => string): string {
|
|
1495
|
+
return renderReduceMethod(emit(object), reduceOp, method === 'reduceRight' ? 'right' : 'left')
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
|
|
1499
|
+
return renderFlatMethod(emit(object), depth)
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
flatMapMethod(object: ParsedExpr, op: FlatMapOp, emit: (e: ParsedExpr) => string): string {
|
|
1503
|
+
return renderFlatMapMethod(emit(object), op)
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
conditional(_test: ParsedExpr, _consequent: ParsedExpr, _alternate: ParsedExpr): string {
|
|
1507
|
+
return '1'
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
templateLiteral(_parts: TemplatePart[]): string {
|
|
1511
|
+
return '1'
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
arrowFn(_param: string, _body: ParsedExpr): string {
|
|
1515
|
+
return '1'
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
unsupported(_raw: string, _reason: string): string {
|
|
1519
|
+
return '1'
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* Lowering for top-level expressions whose identifiers resolve against the
|
|
1525
|
+
* Kolon template's per-render vars (signals, props, locals introduced by `:
|
|
1526
|
+
* my $x = ...` lines). Differs from the filter emitter mainly in
|
|
1527
|
+
* - `.length` → `.size()` (Kolon array length),
|
|
1528
|
+
* - `conditional` is supported (filter predicates can't return ternaries),
|
|
1529
|
+
* - higher-order methods route through `$bf` array helpers.
|
|
1530
|
+
*/
|
|
1531
|
+
class XslateTopLevelEmitter implements ParsedExprEmitter {
|
|
1532
|
+
constructor(private readonly adapter: XslateAdapter) {}
|
|
1533
|
+
|
|
1534
|
+
identifier(name: string): string {
|
|
1535
|
+
return `$${name}`
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
literal(value: string | number | boolean | null, literalType: LiteralType): string {
|
|
1539
|
+
if (literalType === 'string') return `'${value}'`
|
|
1540
|
+
if (literalType === 'boolean') return value ? '1' : '0'
|
|
1541
|
+
if (literalType === 'null') return 'undef'
|
|
1542
|
+
return String(value)
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
|
|
1546
|
+
// `props.x` flattens to the bare `$x` the SSR caller binds each prop to
|
|
1547
|
+
// (props arrive as individual top-level vars, not a `$props` hashref).
|
|
1548
|
+
if (object.kind === 'identifier' && object.name === 'props') {
|
|
1549
|
+
return `$${property}`
|
|
1550
|
+
}
|
|
1551
|
+
const obj = emit(object)
|
|
1552
|
+
// Kolon array length is `$arr.size()`.
|
|
1553
|
+
if (property === 'length') return `${obj}.size()`
|
|
1554
|
+
// Kolon dot access works for hash refs.
|
|
1555
|
+
return `${obj}.${property}`
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
|
|
1559
|
+
// Signal getter: count() → $count
|
|
1560
|
+
if (callee.kind === 'identifier' && args.length === 0) {
|
|
1561
|
+
return `$${callee.name}`
|
|
1562
|
+
}
|
|
1563
|
+
// Identifier-path templatePrimitive: `JSON.stringify(x)` / `Math.floor(x)`
|
|
1564
|
+
// → `$bf.json($x)` / `$bf.floor($x)`. Args render recursively through this
|
|
1565
|
+
// same emitter. A wrong-arity call records BF101 and returns `''`.
|
|
1566
|
+
const path = identifierPath(callee)
|
|
1567
|
+
const spec = path ? XSLATE_TEMPLATE_PRIMITIVES[path] : undefined
|
|
1568
|
+
if (path && spec) {
|
|
1569
|
+
if (args.length === spec.arity) {
|
|
1570
|
+
return spec.emit(args.map(emit))
|
|
1571
|
+
}
|
|
1572
|
+
this.adapter._recordExprBF101(
|
|
1573
|
+
`templatePrimitive '${path}' expects ${spec.arity} arg(s), got ${args.length}`,
|
|
1574
|
+
`Call '${path}' with exactly ${spec.arity} argument(s).`,
|
|
1575
|
+
)
|
|
1576
|
+
return "''"
|
|
1577
|
+
}
|
|
1578
|
+
return emit(callee)
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
1582
|
+
const arg = emit(argument)
|
|
1583
|
+
if (op === '!') return `!${arg}`
|
|
1584
|
+
if (op === '-') return `-${arg}`
|
|
1585
|
+
return arg
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
1589
|
+
const l = emit(left)
|
|
1590
|
+
const r = emit(right)
|
|
1591
|
+
const isStr = (e: ParsedExpr) => isStringTypedOperand(e, n => this.adapter._isStringValueName(n))
|
|
1592
|
+
const stringCmp = isStr(left) || isStr(right)
|
|
1593
|
+
if ((op === '===' || op === '==') && stringCmp) {
|
|
1594
|
+
return `${l} eq ${r}`
|
|
1595
|
+
}
|
|
1596
|
+
if ((op === '!==' || op === '!=') && stringCmp) {
|
|
1597
|
+
return `${l} ne ${r}`
|
|
1598
|
+
}
|
|
1599
|
+
const opMap: Record<string, string> = {
|
|
1600
|
+
'===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
|
|
1601
|
+
'+': '+', '-': '-', '*': '*',
|
|
1602
|
+
}
|
|
1603
|
+
return `${l} ${opMap[op] ?? op} ${r}`
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
1607
|
+
const l = emit(left)
|
|
1608
|
+
const r = emit(right)
|
|
1609
|
+
if (op === '&&') return `(${l} && ${r})`
|
|
1610
|
+
if (op === '||') return `(${l} || ${r})`
|
|
1611
|
+
return `(${l} // ${r})`
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
higherOrder(
|
|
1615
|
+
method: HigherOrderMethod,
|
|
1616
|
+
object: ParsedExpr,
|
|
1617
|
+
param: string,
|
|
1618
|
+
predicate: ParsedExpr,
|
|
1619
|
+
emit: (e: ParsedExpr) => string,
|
|
1620
|
+
): string {
|
|
1621
|
+
// `.filter` / `.every` / `.some` route through `$bf` array helpers that
|
|
1622
|
+
// accept a Kolon code-ref predicate. `.find*` have no lowering yet.
|
|
1623
|
+
if (method === 'find' || method === 'findIndex' || method === 'findLast' || method === 'findLastIndex') {
|
|
1624
|
+
this.adapter._recordExprBF101(
|
|
1625
|
+
`Xslate adapter has not lowered Array.prototype.${method} yet`,
|
|
1626
|
+
)
|
|
1627
|
+
return "''"
|
|
1628
|
+
}
|
|
1629
|
+
// Standalone `.filter` / `.every` / `.some` would need v1 runtime array
|
|
1630
|
+
// helpers that accept a Kolon code-ref predicate, which the Xslate runtime
|
|
1631
|
+
// doesn't expose. Refuse with a clear diagnostic rather than emit a call to
|
|
1632
|
+
// a non-existent helper. The common `.filter(...).map(...)` *loop* form is
|
|
1633
|
+
// handled separately by renderLoop's inline predicate, so it still works.
|
|
1634
|
+
if (method === 'filter' || method === 'every' || method === 'some') {
|
|
1635
|
+
this.adapter._recordExprBF101(
|
|
1636
|
+
`Xslate adapter does not lower a standalone Array.prototype.${method} yet ` +
|
|
1637
|
+
`(the .filter(...).map(...) loop form is supported). ` +
|
|
1638
|
+
`Use /* @client */ or precompute the value.`,
|
|
1639
|
+
)
|
|
1640
|
+
return "''"
|
|
1641
|
+
}
|
|
1642
|
+
void predicate
|
|
1643
|
+
void param
|
|
1644
|
+
return emit(object)
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
|
|
1648
|
+
return `[${elements.map(emit).join(', ')}]`
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
arrayMethod(
|
|
1652
|
+
method: ArrayMethod,
|
|
1653
|
+
object: ParsedExpr,
|
|
1654
|
+
args: ParsedExpr[],
|
|
1655
|
+
emit: (e: ParsedExpr) => string,
|
|
1656
|
+
): string {
|
|
1657
|
+
return renderArrayMethod(method, object, args, emit)
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
sortMethod(
|
|
1661
|
+
_method: 'sort' | 'toSorted',
|
|
1662
|
+
object: ParsedExpr,
|
|
1663
|
+
comparator: SortComparator,
|
|
1664
|
+
emit: (e: ParsedExpr) => string,
|
|
1665
|
+
): string {
|
|
1666
|
+
return renderSortMethod(emit(object), comparator)
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
reduceMethod(method: 'reduce' | 'reduceRight', object: ParsedExpr, reduceOp: ReduceOp, emit: (e: ParsedExpr) => string): string {
|
|
1670
|
+
return renderReduceMethod(emit(object), reduceOp, method === 'reduceRight' ? 'right' : 'left')
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
|
|
1674
|
+
return renderFlatMethod(emit(object), depth)
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
flatMapMethod(object: ParsedExpr, op: FlatMapOp, emit: (e: ParsedExpr) => string): string {
|
|
1678
|
+
return renderFlatMapMethod(emit(object), op)
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
conditional(
|
|
1682
|
+
test: ParsedExpr,
|
|
1683
|
+
consequent: ParsedExpr,
|
|
1684
|
+
alternate: ParsedExpr,
|
|
1685
|
+
emit: (e: ParsedExpr) => string,
|
|
1686
|
+
): string {
|
|
1687
|
+
return `(${emit(test)} ? ${emit(consequent)} : ${emit(alternate)})`
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
templateLiteral(parts: TemplatePart[], emit: (e: ParsedExpr) => string): string {
|
|
1691
|
+
// `` `n=${count() + 1}` `` → Kolon string concatenation (`~`):
|
|
1692
|
+
// `'n=' ~ ($count + 1)`. Kolon's `~` is the explicit concat operator.
|
|
1693
|
+
const terms: string[] = []
|
|
1694
|
+
for (const part of parts) {
|
|
1695
|
+
if (part.type === 'string') {
|
|
1696
|
+
if (part.value !== '') {
|
|
1697
|
+
terms.push(`'${part.value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`)
|
|
1698
|
+
}
|
|
1699
|
+
} else {
|
|
1700
|
+
const rendered = emit(part.expr)
|
|
1701
|
+
const needsParens =
|
|
1702
|
+
part.expr.kind === 'binary' ||
|
|
1703
|
+
part.expr.kind === 'logical' ||
|
|
1704
|
+
part.expr.kind === 'conditional'
|
|
1705
|
+
terms.push(needsParens ? `(${rendered})` : rendered)
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
if (terms.length === 0) return `''`
|
|
1709
|
+
return terms.join(' ~ ')
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
arrowFn(_param: string, _body: ParsedExpr): string {
|
|
1713
|
+
return "''"
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
unsupported(_raw: string, _reason: string): string {
|
|
1717
|
+
return "''"
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
export const xslateAdapter = new XslateAdapter()
|