@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.
@@ -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()