@barefootjs/xslate 0.16.0 → 0.17.1

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.
Files changed (50) hide show
  1. package/dist/adapter/analysis/component-tree.d.ts +30 -0
  2. package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
  3. package/dist/adapter/emit-context.d.ts +94 -0
  4. package/dist/adapter/emit-context.d.ts.map +1 -0
  5. package/dist/adapter/expr/array-method.d.ts +73 -0
  6. package/dist/adapter/expr/array-method.d.ts.map +1 -0
  7. package/dist/adapter/expr/emitters.d.ts +96 -0
  8. package/dist/adapter/expr/emitters.d.ts.map +1 -0
  9. package/dist/adapter/expr/operand.d.ts +25 -0
  10. package/dist/adapter/expr/operand.d.ts.map +1 -0
  11. package/dist/adapter/index.js +1412 -1323
  12. package/dist/adapter/lib/constants.d.ts +22 -0
  13. package/dist/adapter/lib/constants.d.ts.map +1 -0
  14. package/dist/adapter/lib/ir-scope.d.ts +29 -0
  15. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  16. package/dist/adapter/lib/kolon-naming.d.ts +22 -0
  17. package/dist/adapter/lib/kolon-naming.d.ts.map +1 -0
  18. package/dist/adapter/lib/types.d.ts +27 -0
  19. package/dist/adapter/lib/types.d.ts.map +1 -0
  20. package/dist/adapter/memo/seed.d.ts +38 -0
  21. package/dist/adapter/memo/seed.d.ts.map +1 -0
  22. package/dist/adapter/props/prop-classes.d.ts +32 -0
  23. package/dist/adapter/props/prop-classes.d.ts.map +1 -0
  24. package/dist/adapter/spread/spread-codegen.d.ts +69 -0
  25. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  26. package/dist/adapter/value/parsed-literal.d.ts +32 -0
  27. package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
  28. package/dist/adapter/xslate-adapter.d.ts +38 -92
  29. package/dist/adapter/xslate-adapter.d.ts.map +1 -1
  30. package/dist/build.js +1412 -1323
  31. package/dist/index.js +1412 -1323
  32. package/lib/BarefootJS/Backend/Xslate.pm +1 -1
  33. package/package.json +3 -3
  34. package/src/__tests__/query-href.test.ts +96 -0
  35. package/src/__tests__/xslate-adapter.test.ts +200 -8
  36. package/src/adapter/analysis/component-tree.ts +123 -0
  37. package/src/adapter/emit-context.ts +104 -0
  38. package/src/adapter/expr/array-method.ts +332 -0
  39. package/src/adapter/expr/emitters.ts +548 -0
  40. package/src/adapter/expr/operand.ts +35 -0
  41. package/src/adapter/lib/constants.ts +34 -0
  42. package/src/adapter/lib/ir-scope.ts +53 -0
  43. package/src/adapter/lib/kolon-naming.ts +27 -0
  44. package/src/adapter/lib/types.ts +30 -0
  45. package/src/adapter/memo/seed.ts +78 -0
  46. package/src/adapter/props/prop-classes.ts +64 -0
  47. package/src/adapter/spread/spread-codegen.ts +179 -0
  48. package/src/adapter/value/parsed-literal.ts +80 -0
  49. package/src/adapter/xslate-adapter.ts +190 -1236
  50. package/src/test-render.ts +3 -0
@@ -46,26 +46,21 @@ import {
46
46
  type AdapterOutput,
47
47
  type AdapterGenerateOptions,
48
48
  type TemplateSections,
49
- type ParsedExprEmitter,
50
- type HigherOrderMethod,
51
- type ArrayMethod,
52
- type LiteralType,
53
49
  type IRNodeEmitter,
54
50
  type EmitIRNode,
55
51
  type AttrValueEmitter,
56
52
  isBooleanAttr,
57
53
  parseExpression,
54
+ stringifyParsedExpr,
58
55
  exprToString,
59
56
  parseProviderObjectLiteral,
60
57
  parseStyleObjectEntries,
61
58
  isSupported,
62
- identifierPath,
63
59
  emitParsedExpr,
64
60
  emitIRNode,
65
61
  emitAttrValue,
66
62
  augmentInheritedPropAccesses,
67
63
  parseRecordIndexAccess,
68
- evalStringArrayJoin,
69
64
  collectModuleStringConsts,
70
65
  extractArrowBodyExpression,
71
66
  collectContextConsumers,
@@ -73,127 +68,45 @@ import {
73
68
  type ContextConsumer,
74
69
  lookupStaticRecordLiteral,
75
70
  searchParamsLocalNames,
76
- matchSearchParamsMethodCall
71
+ prepareLoweringMatchers,
72
+ queryHrefArgs,
73
+ sortComparatorFromArrow,
77
74
  } from '@barefootjs/jsx'
78
75
  import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result.ts'
79
76
  import ts from 'typescript'
80
-
81
- /**
82
- * Xslate adapter's IRNode render context. Like the Mojo adapter, Kolon's
83
- * lowering doesn't consume any render-position flags, so the Ctx is empty.
84
- * Kept as a named alias so future flags can extend it without changing the
85
- * `IRNodeEmitter` interface.
86
- */
87
- type XslateRenderCtx = Record<string, never>
88
- import type { ParsedExpr, ParsedStatement, SortComparator, ReduceOp, FlatDepth, FlatMapOp, TemplatePart } from '@barefootjs/jsx'
77
+ import type { ParsedExpr, LoweringMatcher } from '@barefootjs/jsx'
89
78
  import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
90
79
 
91
- interface PrimitiveSpec {
92
- arity: number
93
- emit: (args: string[]) => string
94
- }
95
-
96
- /**
97
- * Single source of truth for the Xslate adapter's template-primitive
98
- * surface. Each entry pairs the expected arity with the emit function.
99
- *
100
- * The emit fn returns a Kolon expression (no surrounding `<: :>`) suitable
101
- * for embedding inside an interpolation — `$bf.json($val)`,
102
- * `$bf.floor($val)`, etc. The same primitive names as the Mojo adapter, but
103
- * invoked as `$bf.NAME(args)` on the runtime instance instead of `bf->NAME`.
104
- */
105
- const XSLATE_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
106
- 'JSON.stringify': { arity: 1, emit: (args) => `$bf.json(${args[0]})` },
107
- 'String': { arity: 1, emit: (args) => `$bf.string(${args[0]})` },
108
- 'Number': { arity: 1, emit: (args) => `$bf.number(${args[0]})` },
109
- 'Math.floor': { arity: 1, emit: (args) => `$bf.floor(${args[0]})` },
110
- 'Math.ceil': { arity: 1, emit: (args) => `$bf.ceil(${args[0]})` },
111
- 'Math.round': { arity: 1, emit: (args) => `$bf.round(${args[0]})` },
112
- }
113
-
114
- /**
115
- * Module-scope `templatePrimitives` map derived once from the spec record.
116
- */
117
- const XSLATE_PRIMITIVE_EMIT_MAP: Record<string, (args: string[]) => string> =
118
- Object.fromEntries(
119
- Object.entries(XSLATE_TEMPLATE_PRIMITIVES).map(([k, v]) => [k, v.emit])
120
- )
121
-
122
- /**
123
- * Find the `children` prop's `jsx-children` payload. Narrowed via the
124
- * AttrValue `kind` discriminator so adapter code stays type-safe if the IR
125
- * shape evolves.
126
- */
127
- /**
128
- * Escape a string for a Kolon/Perl single-quoted literal: backslash first
129
- * (so it doesn't double-escape the quote we add next), then the quote. Used
130
- * by every `'…'` hashref key/value emitter below.
131
- */
132
- function escapeKolonSingleQuoted(s: string): string {
133
- return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
134
- }
135
-
136
- /**
137
- * Quote a hashref KEY for Kolon when it isn't a bare-identifier-safe name.
138
- * Kolon parses `data-slot` as `data - slot` (subtraction) and faults on the
139
- * undefined `data` symbol, so a hyphenated key (`data-slot`, `aria-label`)
140
- * must be single-quoted: `'data-slot'`. Bare identifiers pass through unquoted.
141
- */
142
- function kolonHashKey(name: string): string {
143
- return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : `'${escapeKolonSingleQuoted(name)}'`
144
- }
145
-
146
- function resolveJsxChildrenProp(props: readonly IRProp[]): IRNode[] {
147
- const prop = props.find(p => p.name === 'children')
148
- if (!prop) return []
149
- if (prop.value.kind !== 'jsx-children') return []
150
- return prop.value.children
151
- }
152
-
153
- /**
154
- * Collect the component's root scope element node(s) — the elements that
155
- * become the rendered root and so carry `data-key` for a keyed loop item. A
156
- * plain element root is itself; an `if-statement` (early-return) root
157
- * contributes the top element of each branch, since exactly one renders at
158
- * runtime. (#1297)
159
- */
160
- function collectRootScopeNodes(node: IRNode): Set<IRNode> {
161
- const out = new Set<IRNode>()
162
- const visit = (n: IRNode | null): void => {
163
- if (!n) return
164
- if (n.type === 'element') { out.add(n); return }
165
- if (n.type === 'if-statement') {
166
- const s = n as IRIfStatement
167
- visit(s.consequent)
168
- visit(s.alternate)
169
- return
170
- }
171
- if (n.type === 'fragment') {
172
- for (const c of (n as IRFragment).children) visit(c)
173
- }
174
- }
175
- visit(node)
176
- return out
177
- }
178
-
179
- /**
180
- * True when every `$var` the lowered Kolon expression references is already in
181
- * scope — guards in-template memo seeding against an out-of-scope binding. (#1297)
182
- */
183
- function referencedVarsAreAvailable(expr: string, available: ReadonlySet<string>): boolean {
184
- for (const m of expr.matchAll(/\$([A-Za-z_]\w*)/g)) {
185
- if (!available.has(m[1])) return false
186
- }
187
- return true
188
- }
80
+ import type { XslateRenderCtx } from './lib/types.ts'
81
+ import { XSLATE_PRIMITIVE_EMIT_MAP } from './lib/constants.ts'
82
+ import { kolonHashKey } from './lib/kolon-naming.ts'
83
+ import {
84
+ resolveJsxChildrenProp,
85
+ collectRootScopeNodes,
86
+ } from './lib/ir-scope.ts'
87
+ import { renderSortMethod, renderSortEval } from './expr/array-method.ts'
88
+ import { XslateFilterEmitter, XslateTopLevelEmitter } from './expr/emitters.ts'
89
+ import type { XslateEmitContext, XslateSpreadContext, XslateMemoContext } from './emit-context.ts'
90
+ import {
91
+ hasClientInteractivity,
92
+ collectImportedLoopChildComponentErrors,
93
+ } from './analysis/component-tree.ts'
94
+ import {
95
+ conditionalSpreadToKolon,
96
+ objectLiteralExprToKolonHashref,
97
+ } from './spread/spread-codegen.ts'
98
+ import {
99
+ generateContextConsumerSeed,
100
+ generateDerivedMemoSeed,
101
+ } from './memo/seed.ts'
102
+ import {
103
+ collectBooleanTypedProps,
104
+ collectNullableOptionalProps,
105
+ collectStringValueNames,
106
+ } from './props/prop-classes.ts'
189
107
 
190
- export interface XslateAdapterOptions {
191
- /** Base path for client JS files (default: '/static/components/') */
192
- clientJsBasePath?: string
193
-
194
- /** Path to barefoot.js runtime (default: '/static/components/barefoot.js') */
195
- barefootJsPath?: string
196
- }
108
+ export type { XslateAdapterOptions } from './lib/types.ts'
109
+ import type { XslateAdapterOptions } from './lib/types.ts'
197
110
 
198
111
  export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRenderCtx> {
199
112
  name = 'xslate'
@@ -230,10 +143,13 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
230
143
  private propsParams: { name: string }[] = []
231
144
  private booleanTypedProps: Set<string> = new Set()
232
145
  /**
233
- * Names (signal getters + props) whose value is a string, so `===`/`!==`
234
- * against them lowers to Perl `eq`/`ne` rather than numeric `==`/`!=`.
235
- * Kolon comparison operators delegate to Perl semantics, so the same
236
- * string-vs-numeric distinction the Mojo adapter makes applies here.
146
+ * Names (signal getters + props) whose value is a string. In the Mojo
147
+ * adapter this drives choosing Perl `eq`/`ne` over numeric `==`/`!=` for a
148
+ * string `===`/`!==`. The Kolon emitters do NOT consume this: Kolon's
149
+ * `==`/`!=` are value-equality operators that compare strings and numbers
150
+ * correctly, so `===`/`!==` always map to `==`/`!=`. The set is populated
151
+ * and threaded for parity with the Mojo adapter (and as groundwork for a
152
+ * shared Perl-family codegen surface), not because Kolon needs it today.
237
153
  */
238
154
  private stringValueNames: Set<string> = new Set()
239
155
 
@@ -255,7 +171,15 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
255
171
  * the generic dot deref. Set at `generate()` entry from `ir.metadata.imports`;
256
172
  * read by the top-level ParsedExpr emitter.
257
173
  */
258
- _searchParamsLocals: Set<string> = new Set()
174
+ private _searchParamsLocals: Set<string> = new Set()
175
+
176
+ /**
177
+ * Call-lowering matchers active for this component (#2057). Bound at
178
+ * `generate()` entry via `prepareLoweringMatchers` and read by the top-level
179
+ * emitter. Covers both userland plugins and the compiler's built-in plugins
180
+ * (e.g. `queryHref` → `$bf.query`, #2042) — one uniform path, no per-API branch.
181
+ */
182
+ private _loweringMatchers: LoweringMatcher[] = []
259
183
 
260
184
  /**
261
185
  * Local + module constants from the IR, used by the conditional-spread and
@@ -294,40 +218,14 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
294
218
  // (`data-active={props.isActive}`) must stringify as JS
295
219
  // `String(boolean)` ("true"/"false"), not Perl's native `1`/`''`
296
220
  // (#1897, pagination's data-active).
297
- this.booleanTypedProps = new Set(
298
- ir.metadata.propsParams
299
- .filter(prop => prop.type?.primitive === 'boolean' || prop.type?.raw === 'boolean')
300
- .map(prop => prop.name),
301
- )
221
+ // Per-compile prop classifications (see `props/prop-classes.ts`).
222
+ this.booleanTypedProps = collectBooleanTypedProps(ir)
302
223
  this.localConstants = ir.metadata.localConstants ?? []
303
- // Bare references to optional, no-default, non-primitive props (e.g.
304
- // textarea's `rows`) are `undef` when omitted → `defined`-guarded in
305
- // `emitExpression`. See the `nullableOptionalProps` field docstring.
306
- this.nullableOptionalProps = new Set(
307
- ir.metadata.propsParams
308
- .filter(
309
- p =>
310
- p.defaultValue === undefined &&
311
- !p.isRest &&
312
- p.type?.kind !== 'primitive',
313
- )
314
- .map(p => p.name),
315
- )
316
- // Record string-typed signals and props so equality comparisons against
317
- // them lower to `eq`/`ne`. A signal is string-typed when its inferred
318
- // type is `string` (or, defensively, when its initial value is a bare
319
- // string literal); a prop when its annotated type is `string`.
320
- this.stringValueNames = new Set<string>()
321
- for (const s of ir.metadata.signals) {
322
- if (isStringTypeInfo(s.type) || isBareStringLiteral(s.initialValue)) {
323
- this.stringValueNames.add(s.getter)
324
- }
325
- }
326
- for (const p of ir.metadata.propsParams) {
327
- if (isStringTypeInfo(p.type)) this.stringValueNames.add(p.name)
328
- }
224
+ this.nullableOptionalProps = collectNullableOptionalProps(ir)
225
+ this.stringValueNames = collectStringValueNames(ir)
329
226
  this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
330
227
  this._searchParamsLocals = searchParamsLocalNames(ir.metadata)
228
+ this._loweringMatchers = prepareLoweringMatchers(ir.metadata)
331
229
  this.errors = []
332
230
  this.childrenCaptureCounter = 0
333
231
 
@@ -338,7 +236,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
338
236
  // Surface it loudly here. Suppressed when the caller guarantees that all
339
237
  // sibling templates are registered on the same instance at render time.
340
238
  if (!options?.siblingTemplatesRegistered) {
341
- this.checkImportedLoopChildComponents(ir)
239
+ this.errors.push(...collectImportedLoopChildComponentErrors(ir, this.componentName))
342
240
  }
343
241
 
344
242
  this.rootScopeNodes = collectRootScopeNodes(ir.root)
@@ -354,13 +252,13 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
354
252
  // SSR context consumers (`const x = useContext(Ctx)`): seed each local
355
253
  // from the active provider value (or the `createContext` default). The
356
254
  // provider side pushes the value via `emitProvider`. (#1297)
357
- const ctxSeed = this.generateContextConsumerSeed(ir)
255
+ const ctxSeed = generateContextConsumerSeed(ir)
358
256
 
359
257
  // Prop/signal-derived memos with a `null` static SSR default (e.g.
360
258
  // `createMemo(() => props.value * 10)`) are computed in-template from the
361
259
  // already-seeded prop/signal vars — mirroring Go's generated child
362
260
  // constructor. (#1297)
363
- const memoSeed = this.generateDerivedMemoSeed(ir)
261
+ const memoSeed = generateDerivedMemoSeed(this.memoCtx, ir)
364
262
 
365
263
  const template = `${scriptReg}${ctxSeed}${memoSeed}${templateBody}\n`
366
264
 
@@ -392,7 +290,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
392
290
  // ===========================================================================
393
291
 
394
292
  private generateScriptRegistrations(ir: ComponentIR, scriptBaseName?: string): string {
395
- const hasInteractivity = this.hasClientInteractivity(ir)
293
+ const hasInteractivity = hasClientInteractivity(ir)
396
294
  if (!hasInteractivity) return ''
397
295
 
398
296
  const name = scriptBaseName ?? ir.metadata.componentName
@@ -412,15 +310,6 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
412
310
  return lines.join('\n')
413
311
  }
414
312
 
415
- private hasClientInteractivity(ir: ComponentIR): boolean {
416
- return (
417
- ir.metadata.signals.length > 0 ||
418
- ir.metadata.effects.length > 0 ||
419
- ir.metadata.onMounts.length > 0 ||
420
- (ir.metadata.clientAnalysis?.needsInit ?? false)
421
- )
422
- }
423
-
424
313
  // ===========================================================================
425
314
  // Node Rendering
426
315
  // ===========================================================================
@@ -541,105 +430,6 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
541
430
  return `{ ${entries.join(', ')} }`
542
431
  }
543
432
 
544
- /** Kolon literal for a context-consumer's `createContext` default. */
545
- private contextDefaultKolon(c: ContextConsumer): string {
546
- const d = c.defaultValue
547
- if (d === null || d === undefined) return 'nil'
548
- if (typeof d === 'string') return `'${d.replace(/[\\']/g, m => `\\${m}`)}'`
549
- if (typeof d === 'boolean') return d ? '1' : '0'
550
- return String(d)
551
- }
552
-
553
- /**
554
- * Emit one `: my $<local> = $bf.use_context(...)` line-statement per
555
- * context consumer so the body's bare `$<local>` resolves to the active
556
- * provider value (or the `createContext` default). (#1297)
557
- */
558
- private generateContextConsumerSeed(ir: ComponentIR): string {
559
- const consumers = collectContextConsumers(ir.metadata)
560
- if (consumers.length === 0) return ''
561
- return (
562
- consumers
563
- .map(
564
- c =>
565
- `: my $${c.localName} = $bf.use_context('${c.contextName}', ${this.contextDefaultKolon(c)});`,
566
- )
567
- .join('\n') + '\n'
568
- )
569
- }
570
-
571
- /**
572
- * Seed memos whose SSR default is `null` (not statically evaluable) by
573
- * computing them in-template from the already-seeded prop / signal vars
574
- * (`createMemo(() => props.value * 10)` → `: my $x = $value * 10;`). Without
575
- * this the memo's `$x` renders empty — the reason
576
- * `props-reactivity-comparison` was skipped. Only emitted when every var the
577
- * lowering references is already in scope. (#1297)
578
- */
579
- private generateDerivedMemoSeed(ir: ComponentIR): string {
580
- const memos = ir.metadata.memos ?? []
581
- const signals = ir.metadata.signals ?? []
582
- if (memos.length === 0 && signals.length === 0) return ''
583
- // Props seed first; each signal/memo adds its own name as it lands.
584
- const available = new Set<string>(ir.metadata.propsParams.map(p => p.name))
585
- const lines: string[] = []
586
-
587
- // Prop/signal-derived signals (`createSignal(props.defaultOn ?? false)`):
588
- // a loop-child render gets no stash seed, so its `$on` would render nil;
589
- // and the static default can't capture the per-call prop. Seed it
590
- // in-template when the init lowers cleanly AND references an in-scope var.
591
- // Object/array/constant inits keep the existing ssr-defaults seeding.
592
- for (const signal of signals) {
593
- const kolon = this.tryLowerToKolon(signal.initialValue, available)
594
- // Kolon can't express `: my $x = … $x …` — declaring `my $x` makes the
595
- // RHS `$x` an undefined lexical rather than the render var. A same-name
596
- // signal (`createSignal(props.x ?? d)`, getter == prop) is just the prop
597
- // with a default, which the harness already seeds correctly from the
598
- // passed prop — skip the in-template seed for it. (Different-name
599
- // prop-derived signals like toggle's `on` from `defaultOn` are unaffected.)
600
- const refsSelf = kolon !== null && new RegExp(`\\$${signal.getter}\\b`).test(kolon)
601
- if (kolon !== null && !refsSelf) lines.push(`: my $${signal.getter} = ${kolon};`)
602
- available.add(signal.getter)
603
- }
604
-
605
- for (const memo of memos) {
606
- // Seed every memo whose body lowers cleanly — not just the ones whose
607
- // static SSR default is null. A statically-foldable prop-derived memo
608
- // (`createMemo(() => props.disabled ?? false)` → default `false`)
609
- // still depends on the per-call prop: the static stash seed bakes in
610
- // the absent-prop fold, so a caller passing `disabled => 1` would
611
- // render the default branch (#1897, select's disabled item). The
612
- // in-template recomputation reads the prop lexical already in scope;
613
- // block-bodied arrows / out-of-scope references fall back to the
614
- // static ssr-defaults seed. Same self-reference guard as the signal
615
- // loop above — Kolon's `my` shadows the render var on the RHS.
616
- const body = extractArrowBodyExpression(memo.computation)
617
- if (body !== null) {
618
- const kolon = this.tryLowerToKolon(body, available)
619
- const refsSelf = kolon !== null && new RegExp(`\\$${memo.name}\\b`).test(kolon)
620
- if (kolon !== null && !refsSelf) lines.push(`: my $${memo.name} = ${kolon};`)
621
- }
622
- available.add(memo.name)
623
- }
624
- return lines.length > 0 ? lines.join('\n') + '\n' : ''
625
- }
626
-
627
- /**
628
- * Lower a signal init / memo body to Kolon for an in-template SSR seed, or
629
- * `null` when it shouldn't be seeded this way: not a supported shape
630
- * (`isSupported` pre-check, so object/array literals don't fail the build),
631
- * references no in-scope var (a constant — keep ssr-defaults seeding), or
632
- * references an out-of-scope binding. (#1297)
633
- */
634
- private tryLowerToKolon(expr: string, available: ReadonlySet<string>): string | null {
635
- const trimmed = expr.trim()
636
- if (!trimmed) return null
637
- if (!isSupported(parseExpression(trimmed)).supported) return null
638
- const kolon = this.convertExpressionToKolon(trimmed)
639
- if (kolon === '' || !/\$[A-Za-z_]\w*/.test(kolon)) return null
640
- return referencedVarsAreAvailable(kolon, available) ? kolon : null
641
- }
642
-
643
433
  emitAsync(node: IRAsync, _ctx: XslateRenderCtx, _emit: EmitIRNode<XslateRenderCtx>): string {
644
434
  return this.renderAsync(node)
645
435
  }
@@ -773,92 +563,20 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
773
563
  return `<: $bf.comment("cond-start:${condId}") | mark_raw :>${content}<: $bf.comment("cond-end:${condId}") | mark_raw :>`
774
564
  }
775
565
 
776
- // ===========================================================================
777
- // Imported-component-in-loop check (BF103)
778
- // ===========================================================================
779
-
780
- /**
781
- * Push a `BF103` diagnostic for every component reference inside a loop body
782
- * whose name is imported from a relative-path module. Mirror of the Mojo
783
- * adapter's check — the Xslate adapter has the same cross-template-
784
- * registration constraint at request time.
785
- */
786
- private checkImportedLoopChildComponents(ir: ComponentIR): void {
787
- const relativeImports = new Set<string>()
788
- for (const imp of ir.metadata.templateImports ?? ir.metadata.imports ?? []) {
789
- if (!imp.source.startsWith('./') && !imp.source.startsWith('../')) continue
790
- if (imp.isTypeOnly) continue
791
- for (const spec of imp.specifiers) {
792
- relativeImports.add(spec.alias ?? spec.name)
793
- }
794
- }
795
- if (relativeImports.size === 0) return
796
-
797
- const loc = { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } }
798
- const visit = (node: IRNode, inLoop: boolean): void => {
799
- switch (node.type) {
800
- case 'component': {
801
- const comp = node as IRComponent
802
- if (inLoop && relativeImports.has(comp.name)) {
803
- this.errors.push({
804
- code: 'BF103',
805
- severity: 'error',
806
- 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.`,
807
- loc: comp.loc ?? loc,
808
- suggestion: {
809
- message:
810
- `Options:\n` +
811
- ` 1. Compile '${comp.name}' (its source file) with the same adapter and register the resulting Xslate template alongside the parent at render time.\n` +
812
- ` 2. Inline <${comp.name}> directly inside the loop body so no cross-file template lookup is needed.\n` +
813
- ` 3. Mark the loop position as @client-only so the template is materialised on the client instead of at SSR time.`,
814
- },
815
- })
816
- }
817
- for (const child of comp.children) visit(child, inLoop)
818
- break
819
- }
820
- case 'element':
821
- for (const child of (node as IRElement).children) visit(child, inLoop)
822
- break
823
- case 'fragment':
824
- for (const child of (node as IRFragment).children) visit(child, inLoop)
825
- break
826
- case 'conditional': {
827
- const cond = node as IRConditional
828
- visit(cond.whenTrue, inLoop)
829
- if (cond.whenFalse) visit(cond.whenFalse, inLoop)
830
- break
831
- }
832
- case 'loop':
833
- for (const child of (node as IRLoop).children) visit(child, true)
834
- break
835
- case 'if-statement': {
836
- const stmt = node as IRIfStatement
837
- visit(stmt.consequent, inLoop)
838
- if (stmt.alternate) visit(stmt.alternate, inLoop)
839
- break
840
- }
841
- case 'provider':
842
- for (const child of (node as IRProvider).children) visit(child, inLoop)
843
- break
844
- case 'async': {
845
- const a = node as IRAsync
846
- visit(a.fallback, inLoop)
847
- for (const child of a.children) visit(child, inLoop)
848
- break
849
- }
850
- }
851
- }
852
- visit(ir.root, false)
853
- }
854
-
855
566
  // ===========================================================================
856
567
  // Loop Rendering
857
568
  // ===========================================================================
858
569
 
859
570
  renderLoop(loop: IRLoop): string {
860
- // Client-only loops: skip SSR rendering entirely
861
- if (loop.clientOnly) return ''
571
+ // clientOnly loops must not render items at SSR time, but must still emit
572
+ // the `loop:`/`/loop:` boundary marker pair (Hono and Go parity) so the
573
+ // client runtime's mapArray() can locate the insertion anchor when
574
+ // hydrating the array. Without the markers, mapArray() resolves
575
+ // anchor = null and appends after sibling markers (#872). The marker id
576
+ // disambiguates sibling `.map()` calls under the same parent (#1087).
577
+ if (loop.clientOnly) {
578
+ return `<: $bf.comment("loop:${loop.markerId}") | mark_raw :><: $bf.comment("/loop:${loop.markerId}") | mark_raw :>`
579
+ }
862
580
 
863
581
  // An array/object-destructure loop param (`([emoji, users]) => ...` or
864
582
  // `({ name, age }) => ...`) lowers to invalid Kolon — Kolon's `for LIST
@@ -893,7 +611,20 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
893
611
  // helper runs once.
894
612
  let array = rawArray
895
613
  if (loop.sortComparator) {
896
- array = renderSortMethod(rawArray, loop.sortComparator)
614
+ // Evaluator-first (#2018 P3): serialize the comparator arrow body + emit
615
+ // `$bf.sort_eval`; fall back to the structured `$bf.sort` for a
616
+ // comparator the evaluator can't model (e.g. `localeCompare`). The
617
+ // comparator now arrives as an `IRLoopSort` carrying the generic
618
+ // `arrow` + its params.
619
+ const sort = loop.sortComparator
620
+ const sortEmit = (e: ParsedExpr) => this.convertExpressionToKolon('', e)
621
+ const arrow = sort.arrow
622
+ const params =
623
+ arrow.kind === 'arrow' ? arrow.params : [sort.paramA, sort.paramB]
624
+ const structured = sortComparatorFromArrow(arrow)
625
+ array =
626
+ renderSortEval(rawArray, arrow.kind === 'arrow' ? arrow.body : arrow, params, sortEmit) ??
627
+ (structured !== null ? renderSortMethod(rawArray, structured) : rawArray)
897
628
  }
898
629
  const param = loop.param
899
630
  // Kolon binds the item directly via `for LIST -> $item`. The index, when
@@ -957,12 +688,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
957
688
  // Handle filter().map() pattern by wrapping children in if-condition
958
689
  if (loop.filterPredicate) {
959
690
  let filterCond: string
960
- if (loop.filterPredicate.blockBody) {
961
- filterCond = this.renderBlockBodyCondition(
962
- loop.filterPredicate.blockBody,
963
- loop.filterPredicate.param
964
- )
965
- } else if (loop.filterPredicate.predicate) {
691
+ if (loop.filterPredicate.predicate) {
966
692
  filterCond = this.renderKolonFilterExpr(
967
693
  loop.filterPredicate.predicate,
968
694
  loop.filterPredicate.param
@@ -1009,6 +735,17 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1009
735
  if (value.parts) {
1010
736
  return `${kolonHashKey(name)} => ${this.convertTemplateLiteralPartsToKolon(value.parts)}`
1011
737
  }
738
+ // Inline object-literal child prop (carousel's `opts={{ align: 'start' }}`):
739
+ // lower to a Kolon hashref so the child can serialize it (`data-opts`),
740
+ // instead of refusing the bare object with BF101. (#1971 Perl) Read the
741
+ // IR-carried structured `ParsedExpr` tree (#2018) instead of re-parsing
742
+ // `value.expr` with `ts.createSourceFile`; the lowering returns null for
743
+ // any non-object-literal shape, so the common non-object case falls
744
+ // straight through to the bare-expression path below.
745
+ if (value.parsed) {
746
+ const hashref = objectLiteralExprToKolonHashref(this.spreadCtx, value.parsed)
747
+ if (hashref !== null) return `${kolonHashKey(name)} => ${hashref}`
748
+ }
1012
749
  return `${kolonHashKey(name)} => ${this.convertExpressionToKolon(value.expr)}`
1013
750
  },
1014
751
  emitSpread: (value) => {
@@ -1284,7 +1021,9 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1284
1021
  // Emit a Kolon inline ternary of hashrefs — Perl truthiness handles the
1285
1022
  // condition for free, and the falsy `{}` branch OMITS the key
1286
1023
  // (`spread_attrs` does NOT emit empty hashref entries).
1287
- const ternaryHashref = this.conditionalSpreadToKolon(trimmed)
1024
+ // Read the spread's IR-carried `ParsedExpr` tree (#2018) instead of
1025
+ // re-parsing `trimmed` with `ts.createSourceFile`.
1026
+ const ternaryHashref = conditionalSpreadToKolon(this.spreadCtx, value.parsed)
1288
1027
  if (ternaryHashref !== null) {
1289
1028
  return `<: $bf.spread_attrs(${ternaryHashref}) | mark_raw :>`
1290
1029
  }
@@ -1301,7 +1040,15 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1301
1040
  if (localConst?.value !== undefined) {
1302
1041
  const initTrimmed = localConst.value.trim()
1303
1042
  if (!/^[A-Za-z_$][\w$]*$/.test(initTrimmed)) {
1304
- const resolved = this.conditionalSpreadToKolon(initTrimmed)
1043
+ // The local const's initializer text isn't carried as a structured
1044
+ // tree on the spread attr, so parse it once via the shared
1045
+ // `parseExpression` (the analyzer's own entry) — not
1046
+ // `ts.createSourceFile` — mirroring go-template's same local-const
1047
+ // resolution path.
1048
+ const resolved = conditionalSpreadToKolon(
1049
+ this.spreadCtx,
1050
+ parseExpression(initTrimmed),
1051
+ )
1305
1052
  if (resolved !== null) {
1306
1053
  return `<: $bf.spread_attrs(${resolved}) | mark_raw :>`
1307
1054
  }
@@ -1407,100 +1154,18 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1407
1154
  param: string,
1408
1155
  localVarMap: Map<string, string> = new Map(),
1409
1156
  ): string {
1410
- return emitParsedExpr(expr, new XslateFilterEmitter(param, localVarMap, n => this._isStringValueName(n)))
1411
- }
1412
-
1413
- /**
1414
- * Render a complex block body filter into a Kolon condition.
1415
- * Handles patterns like: filter(t => { const f = filter(); if (...) return ...; })
1416
- */
1417
- private renderBlockBodyCondition(
1418
- statements: ParsedStatement[],
1419
- param: string
1420
- ): string {
1421
- const localVarMap = new Map<string, string>()
1422
- const paths = this.collectReturnPaths(statements, [], localVarMap, param)
1423
-
1424
- if (paths.length === 0) return '1'
1425
- if (paths.length === 1) return this.buildSinglePathCondition(paths[0], param, localVarMap)
1426
-
1427
- // Multiple paths: build OR condition
1428
- const parts: string[] = []
1429
- for (const path of paths) {
1430
- if (path.result.kind === 'literal' && path.result.literalType === 'boolean' && path.result.value === false) continue
1431
- const cond = this.buildSinglePathCondition(path, param, localVarMap)
1432
- if (cond !== '0') parts.push(cond)
1433
- }
1434
-
1435
- if (parts.length === 0) return '0'
1436
- if (parts.length === 1) return parts[0]
1437
- return `(${parts.join(' || ')})`
1438
- }
1439
-
1440
- private collectReturnPaths(
1441
- statements: ParsedStatement[],
1442
- currentConditions: ParsedExpr[],
1443
- localVarMap: Map<string, string>,
1444
- param: string
1445
- ): Array<{ conditions: ParsedExpr[]; result: ParsedExpr }> {
1446
- const paths: Array<{ conditions: ParsedExpr[]; result: ParsedExpr }> = []
1447
-
1448
- for (const stmt of statements) {
1449
- if (stmt.kind === 'var-decl') {
1450
- if (stmt.init.kind === 'call' && stmt.init.callee.kind === 'identifier') {
1451
- localVarMap.set(stmt.name, stmt.init.callee.name)
1452
- }
1453
- } else if (stmt.kind === 'return') {
1454
- paths.push({ conditions: [...currentConditions], result: stmt.value })
1455
- break
1456
- } else if (stmt.kind === 'if') {
1457
- const thenPaths = this.collectReturnPaths(stmt.consequent, [...currentConditions, stmt.condition], localVarMap, param)
1458
- paths.push(...thenPaths)
1459
-
1460
- if (stmt.alternate) {
1461
- const negated: ParsedExpr = { kind: 'unary', op: '!', argument: stmt.condition }
1462
- const elsePaths = this.collectReturnPaths(stmt.alternate, [...currentConditions, negated], localVarMap, param)
1463
- paths.push(...elsePaths)
1464
- } else {
1465
- currentConditions.push({ kind: 'unary', op: '!', argument: stmt.condition })
1466
- }
1467
- }
1468
- }
1469
-
1470
- return paths
1471
- }
1472
-
1473
- private buildSinglePathCondition(
1474
- path: { conditions: ParsedExpr[]; result: ParsedExpr },
1475
- param: string,
1476
- localVarMap: Map<string, string>
1477
- ): string {
1478
- if (path.result.kind === 'literal' && path.result.literalType === 'boolean') {
1479
- if (path.result.value === true) {
1480
- if (path.conditions.length === 0) return '1'
1481
- return this.renderConditionsAnd(path.conditions, param, localVarMap)
1482
- }
1483
- return '0'
1484
- }
1485
-
1486
- if (path.conditions.length === 0) {
1487
- return this.renderKolonFilterExpr(path.result, param, localVarMap)
1488
- }
1489
-
1490
- const condPart = this.renderConditionsAnd(path.conditions, param, localVarMap)
1491
- const resultPart = this.renderKolonFilterExpr(path.result, param, localVarMap)
1492
- return `(${condPart} && ${resultPart})`
1493
- }
1494
-
1495
- private renderConditionsAnd(
1496
- conditions: ParsedExpr[],
1497
- param: string,
1498
- localVarMap: Map<string, string>
1499
- ): string {
1500
- if (conditions.length === 0) return '1'
1501
- if (conditions.length === 1) return this.renderKolonFilterExpr(conditions[0], param, localVarMap)
1502
- const parts = conditions.map(c => this.renderKolonFilterExpr(c, param, localVarMap))
1503
- return `(${parts.join(' && ')})`
1157
+ return emitParsedExpr(
1158
+ expr,
1159
+ new XslateFilterEmitter(
1160
+ param,
1161
+ localVarMap,
1162
+ n => this._isStringValueName(n),
1163
+ // A nested callback method inside the predicate has no Kolon scalar
1164
+ // form — surface BF101 (#2038) instead of silently degrading it to
1165
+ // its receiver.
1166
+ (message, reason) => this._recordExprBF101(message, reason),
1167
+ ),
1168
+ )
1504
1169
  }
1505
1170
 
1506
1171
  // ===========================================================================
@@ -1585,137 +1250,92 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1585
1250
  }
1586
1251
 
1587
1252
  /**
1588
- * Lower a conditional inline-object spread
1589
- * `COND ? { 'aria-describedby': describedBy } : {}`
1590
- * to a Kolon inline ternary of hashrefs
1591
- * `$describedBy ? { 'aria-describedby' => $describedBy } : {}`.
1592
- * Both branches must be object literals; the condition + values route through
1593
- * `convertExpressionToKolon`. Returns `null` for any other shape so the caller
1594
- * falls back to its normal lowering. Mirror of `conditionalSpreadToPerl`.
1253
+ * Build the EmitContext seam the top-level `ParsedExpr` emitter depends on.
1254
+ * Built as a private object (the adapter does NOT `implements XslateEmitContext`)
1255
+ * so the wrapped bookkeeping `_searchParamsLocals`, the const/record
1256
+ * resolvers, BF101 recording, the filter-predicate entry stays private and
1257
+ * off the exported adapter's public type, matching the Go adapter's
1258
+ * `emitCtx` and the `spreadCtx` / `memoCtx` seams below.
1595
1259
  */
1596
- private conditionalSpreadToKolon(expr: string): string | null {
1597
- const sf = ts.createSourceFile('__spread.ts', `(${expr})`, ts.ScriptTarget.Latest, true)
1598
- if (sf.statements.length !== 1) return null
1599
- const stmt = sf.statements[0]
1600
- if (!ts.isExpressionStatement(stmt)) return null
1601
- let node: ts.Expression = stmt.expression
1602
- while (ts.isParenthesizedExpression(node)) node = node.expression
1603
- if (!ts.isConditionalExpression(node)) return null
1604
- const unwrap = (e: ts.Expression): ts.Expression => {
1605
- let n = e
1606
- while (ts.isParenthesizedExpression(n)) n = n.expression
1607
- return n
1608
- }
1609
- const whenTrue = unwrap(node.whenTrue)
1610
- const whenFalse = unwrap(node.whenFalse)
1611
- if (!ts.isObjectLiteralExpression(whenTrue) || !ts.isObjectLiteralExpression(whenFalse)) {
1612
- return null
1260
+ private get emitCtx(): XslateEmitContext {
1261
+ return {
1262
+ _searchParamsLocals: this._searchParamsLocals,
1263
+ _resolveModuleStringConst: (name) => this._resolveModuleStringConst(name),
1264
+ _resolveLiteralConst: (name) => this._resolveLiteralConst(name),
1265
+ _resolveStaticRecordLiteral: (o, k) => this._resolveStaticRecordLiteral(o, k),
1266
+ _recordExprBF101: (message, reason) => this._recordExprBF101(message, reason),
1267
+ _renderKolonFilterExprPublic: (e, p) => this._renderKolonFilterExprPublic(e, p),
1613
1268
  }
1614
- const condPerl = this.convertExpressionToKolon(node.condition.getText(sf))
1615
- const truePerl = this.objectLiteralToKolonHashref(whenTrue, sf)
1616
- const falsePerl = this.objectLiteralToKolonHashref(whenFalse, sf)
1617
- if (truePerl === null || falsePerl === null) return null
1618
- return `${condPerl} ? ${truePerl} : ${falsePerl}`
1619
1269
  }
1620
1270
 
1621
1271
  /**
1622
- * Convert a static object literal into a Kolon hashref string for a
1623
- * conditional spread. Only static string/identifier keys are allowed; values
1624
- * resolve via `convertExpressionToKolon` (or the `Record[propKey]` index
1625
- * lowering). Returns `null` for any computed/spread/dynamic key. Empty object
1626
- * → `{}`. Mirror of `objectLiteralToPerlHashref`.
1272
+ * Build the narrow context the extracted spread lowering depends on. Passing
1273
+ * a purpose-built object (rather than `this`) keeps the adapter's bookkeeping
1274
+ * members private they stay internal implementation detail, not part of the
1275
+ * exported class's public surface.
1627
1276
  */
1628
- private objectLiteralToKolonHashref(
1629
- obj: ts.ObjectLiteralExpression,
1630
- sf: ts.SourceFile,
1631
- ): string | null {
1632
- const entries: string[] = []
1633
- for (const prop of obj.properties) {
1634
- if (!ts.isPropertyAssignment(prop)) return null
1635
- let key: string
1636
- if (ts.isIdentifier(prop.name)) {
1637
- key = prop.name.text
1638
- } else if (ts.isStringLiteral(prop.name) || ts.isNoSubstitutionTemplateLiteral(prop.name)) {
1639
- key = prop.name.text
1640
- } else {
1641
- return null
1642
- }
1643
- const initNode = (() => {
1644
- let n: ts.Expression = prop.initializer
1645
- while (ts.isParenthesizedExpression(n)) n = n.expression
1646
- return n
1647
- })()
1648
- const indexed = this.recordIndexAccessToKolon(initNode)
1649
- if (
1650
- indexed === null &&
1651
- ts.isElementAccessExpression(initNode) &&
1652
- initNode.argumentExpression &&
1653
- !ts.isNumericLiteral(initNode.argumentExpression) &&
1654
- !ts.isStringLiteral(initNode.argumentExpression)
1655
- ) {
1656
- // Variable-index record access (`sizeMap[size]`) the static-inline
1657
- // path couldn't resolve (non-scalar value / non-const receiver).
1658
- // Since #1897 made variable indices parseable (`index-access`),
1659
- // the generic value lowering would emit `$sizeMap[$size]` against
1660
- // an UNBOUND module const instead of refusing — record BF101 and
1661
- // bail so the spread surfaces the out-of-shape diagnostic,
1662
- // matching pre-#1897 behaviour. (Mirrors the Mojo adapter.)
1663
- this.errors.push({
1664
- code: 'BF101',
1665
- severity: 'error',
1666
- message: `Spread object value '${initNode.getText(sf)}' indexes a record map whose values aren't scalar literals — it can't lower to an inline Kolon hashref.`,
1667
- loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1668
- suggestion: {
1669
- message: 'Index a record whose values are number/string literals, or move the spread into a `\'use client\'` component so hydration computes it.',
1670
- },
1671
- })
1672
- return null
1673
- }
1674
- const valPerl =
1675
- indexed !== null
1676
- ? indexed
1677
- : this.convertExpressionToKolon(prop.initializer.getText(sf))
1678
- entries.push(`'${escapeKolonSingleQuoted(key)}' => ${valPerl}`)
1277
+ private get spreadCtx(): XslateSpreadContext {
1278
+ return {
1279
+ componentName: this.componentName,
1280
+ errors: this.errors,
1281
+ localConstants: this.localConstants,
1282
+ propsParams: this.propsParams,
1283
+ convertExpressionToKolon: (e, preParsed) => this.convertExpressionToKolon(e, preParsed),
1679
1284
  }
1680
- return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`
1681
1285
  }
1682
1286
 
1683
- /**
1684
- * Lower a spread-object VALUE of the form `IDENT[KEY]` (CheckIcon's
1685
- * `sizeMap[size]`) to an inline indexed Kolon hashref
1686
- * `{ 'sm' => 16, 'md' => 20, ... }[$size]`.
1687
- * Reuses the shared structural parse (`parseRecordIndexAccess`); this wrapper
1688
- * only does the single-quote escaping + Kolon index emit. NB: Kolon indexes a
1689
- * hashref literal with bracket syntax `{…}[$key]`, NOT Perl's arrow-deref
1690
- * `{…}->{$key}` (which Kolon's parser rejects) — this is the one divergence
1691
- * from the Mojo `recordIndexAccessToPerl` emit.
1692
- */
1693
- private recordIndexAccessToKolon(val: ts.Expression): string | null {
1694
- const parsed = parseRecordIndexAccess(val, this.localConstants ?? [], this.propsParams)
1695
- if (!parsed) return null
1696
- const entries = parsed.entries.map(e => {
1697
- const mapVal =
1698
- e.value.kind === 'number' ? e.value.text : `'${escapeKolonSingleQuoted(e.value.text)}'`
1699
- return `'${escapeKolonSingleQuoted(e.key)}' => ${mapVal}`
1700
- })
1701
- return `{ ${entries.join(', ')} }[$${parsed.indexPropName}]`
1287
+ /** Build the narrow context the extracted memo seeding depends on. */
1288
+ private get memoCtx(): XslateMemoContext {
1289
+ return { convertExpressionToKolon: (e, preParsed) => this.convertExpressionToKolon(e, preParsed) }
1702
1290
  }
1703
1291
 
1704
- private convertExpressionToKolon(expr: string): string {
1292
+ private convertExpressionToKolon(expr: string, preParsed?: ParsedExpr): string {
1705
1293
  // Parse-first lowering — parity with the Mojo adapter's
1706
1294
  // `convertExpressionToPerl`. Parse the JS expression once, gate it on the
1707
1295
  // shared `isSupported`, and render every supported shape through the AST
1708
1296
  // emitter. Unsupported shapes surface as BF101.
1709
- const trimmed = expr.trim()
1710
- if (trimmed === '') return "''"
1297
+ //
1298
+ // `preParsed` is the IR-carried `ParsedExpr` tree (cf. go-template's
1299
+ // `convertExpressionToGo(jsExpr, out?, preParsed?)`); when present it is
1300
+ // used directly instead of re-parsing `expr`, so spread condition/value
1301
+ // lowering threads the carried tree through without a stringify→re-parse
1302
+ // round-trip. The diagnostic text is then derived from the tree
1303
+ // (`stringifyParsedExpr`) so callers can pass `''` for `expr`.
1304
+ let parsed: ParsedExpr
1305
+ if (preParsed) {
1306
+ parsed = preParsed
1307
+ } else {
1308
+ const trimmed = expr.trim()
1309
+ if (trimmed === '') return "''"
1310
+ parsed = parseExpression(trimmed)
1311
+ }
1312
+
1313
+ // Registered call lowerings (#2057) — including the built-in `queryHref`
1314
+ // plugin (#2042), which lowers `queryHref(base, { … })` to a neutral
1315
+ // `guard-list` on the `query` helper → `$bf.query(base, <triples>)`.
1316
+ // Recognised before the support gate because the object-literal arg is
1317
+ // otherwise `unsupported` (BF101). The `query` helper includes a pair iff its
1318
+ // guard is truthy AND its value is a non-empty string (the client's
1319
+ // `if (value)`): a plain `key: v` passes guard `1`, a conditional
1320
+ // `key: cond ? v : undefined` passes the lowered cond. Only the `query`
1321
+ // helper renders to `$bf.query`; another guard-list helper must not be
1322
+ // silently mis-rendered as a query.
1323
+ if (parsed.kind === 'call') {
1324
+ for (const matcher of this._loweringMatchers) {
1325
+ const node = matcher(parsed.callee, parsed.args)
1326
+ if (node?.kind === 'guard-list' && node.helper === 'query') {
1327
+ const qArgs = queryHrefArgs(node, n => this.renderParsedExprToKolon(n))
1328
+ return `$bf.query(${qArgs.join(', ')})`
1329
+ }
1330
+ }
1331
+ }
1711
1332
 
1712
- const parsed = parseExpression(trimmed)
1713
1333
  const support = isSupported(parsed)
1714
1334
  if (!support.supported) {
1715
1335
  this.errors.push({
1716
1336
  code: 'BF101',
1717
1337
  severity: 'error',
1718
- message: `Expression not supported: ${trimmed}`,
1338
+ message: `Expression not supported: ${preParsed ? stringifyParsedExpr(parsed) : expr.trim()}`,
1719
1339
  loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1720
1340
  suggestion: {
1721
1341
  message: support.reason
@@ -1736,12 +1356,12 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1736
1356
  * expressions where identifiers are signals / template vars.
1737
1357
  */
1738
1358
  private renderParsedExprToKolon(expr: ParsedExpr): string {
1739
- return emitParsedExpr(expr, new XslateTopLevelEmitter(this))
1359
+ return emitParsedExpr(expr, new XslateTopLevelEmitter(this.emitCtx))
1740
1360
  }
1741
1361
 
1742
1362
  /** Whether `name` (a signal getter or prop) holds a string value, so an
1743
1363
  * equality comparison against it should use Perl `eq`/`ne`. */
1744
- _isStringValueName(name: string): boolean {
1364
+ private _isStringValueName(name: string): boolean {
1745
1365
  return this.stringValueNames.has(name)
1746
1366
  }
1747
1367
 
@@ -1803,7 +1423,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1803
1423
  * pagination) — function-scope consts never reach the per-render
1804
1424
  * stash, so a bare `$totalPages` renders empty.
1805
1425
  */
1806
- _resolveLiteralConst(name: string): string | null {
1426
+ private _resolveLiteralConst(name: string): string | null {
1807
1427
  const c = (this.localConstants ?? []).find(lc => lc.name === name)
1808
1428
  if (c?.value === undefined) return null
1809
1429
  const v = c.value.trim()
@@ -1813,7 +1433,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1813
1433
  return null
1814
1434
  }
1815
1435
 
1816
- _resolveStaticRecordLiteral(objectName: string, key: string): string | null {
1436
+ private _resolveStaticRecordLiteral(objectName: string, key: string): string | null {
1817
1437
  const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
1818
1438
  if (!hit) return null
1819
1439
  return hit.kind === 'number'
@@ -1821,7 +1441,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1821
1441
  : `'${hit.text.replace(/[\\']/g, m => `\\${m}`)}'`
1822
1442
  }
1823
1443
 
1824
- _resolveModuleStringConst(name: string): string | null {
1444
+ private _resolveModuleStringConst(name: string): string | null {
1825
1445
  // A loop body may bind `my $<param>` that shadows a module const of the
1826
1446
  // same name; never inline inside one (conservative — drop to `$name`).
1827
1447
  if (this.inLoop) return null
@@ -1830,7 +1450,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1830
1450
  return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
1831
1451
  }
1832
1452
 
1833
- _recordExprBF101(message: string, reason?: string): void {
1453
+ private _recordExprBF101(message: string, reason?: string): void {
1834
1454
  this.errors.push({
1835
1455
  code: 'BF101',
1836
1456
  severity: 'error',
@@ -1845,675 +1465,9 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1845
1465
  }
1846
1466
 
1847
1467
  /** Internal hook for higher-order: predicate body re-uses the filter emitter. */
1848
- _renderKolonFilterExprPublic(expr: ParsedExpr, param: string): string {
1468
+ private _renderKolonFilterExprPublic(expr: ParsedExpr, param: string): string {
1849
1469
  return this.renderKolonFilterExpr(expr, param)
1850
1470
  }
1851
1471
  }
1852
1472
 
1853
- // ===========================================================================
1854
- // ParsedExpr emitters
1855
- // ===========================================================================
1856
-
1857
- /**
1858
- * Lowering for `array-method` IR nodes — shared between the filter and
1859
- * top-level emitters so the emitted Kolon form stays consistent regardless of
1860
- * which context the chain lands in. The receiver/array helpers are the same
1861
- * runtime methods the Mojo adapter calls, invoked as `$bf.NAME(...)` on the
1862
- * Kolon `$bf` object instead of `bf->NAME`.
1863
- *
1864
- * Perl-native string ops the Mojo adapter inlines (`lc`, `uc`) have no Kolon
1865
- * builtin, so they route through dedicated runtime helpers — but those
1866
- * helpers aren't part of the validated v1 surface, so they're emitted as
1867
- * `$bf.NAME(...)` calls consistent with the rest. Array methods whose Mojo
1868
- * form relied on Perl `@{...}` deref (`join`) route through `$bf` helpers.
1869
- */
1870
- function renderArrayMethod(
1871
- method: ArrayMethod,
1872
- object: ParsedExpr,
1873
- args: ParsedExpr[],
1874
- emit: (e: ParsedExpr) => string,
1875
- ): string {
1876
- switch (method) {
1877
- case 'join': {
1878
- // Route through the runtime (`$bf.join`) rather than Kolon's builtin
1879
- // `.join`, so the JS-compat element handling (undef → empty, default
1880
- // separator) is applied consistently — same reasoning as $bf.lc / etc.
1881
- const obj = emit(object)
1882
- const sep = args.length >= 1 ? emit(args[0]) : `','`
1883
- return `$bf.join(${obj}, ${sep})`
1884
- }
1885
- case 'includes': {
1886
- const obj = emit(object)
1887
- const needle = emit(args[0])
1888
- return `$bf.includes(${obj}, ${needle})`
1889
- }
1890
- case 'indexOf':
1891
- case 'lastIndexOf': {
1892
- const fn = method === 'indexOf' ? 'index_of' : 'last_index_of'
1893
- const obj = emit(object)
1894
- const needle = emit(args[0])
1895
- return `$bf.${fn}(${obj}, ${needle})`
1896
- }
1897
- case 'at': {
1898
- const obj = emit(object)
1899
- const idx = args.length >= 1 ? emit(args[0]) : '0'
1900
- return `$bf.at(${obj}, ${idx})`
1901
- }
1902
- case 'concat': {
1903
- if (args.length === 0) {
1904
- return emit(object)
1905
- }
1906
- const a = emit(object)
1907
- const b = emit(args[0])
1908
- return `$bf.concat(${a}, ${b})`
1909
- }
1910
- case 'slice': {
1911
- const recv = emit(object)
1912
- const start = args.length >= 1 ? emit(args[0]) : '0'
1913
- // Kolon's undefined literal is `nil`, not Perl's `undef` — the
1914
- // runtime `slice` treats it as "to end".
1915
- const end = args.length >= 2 ? emit(args[1]) : 'nil'
1916
- return `$bf.slice(${recv}, ${start}, ${end})`
1917
- }
1918
- case 'reverse':
1919
- case 'toReversed': {
1920
- const recv = emit(object)
1921
- return `$bf.reverse(${recv})`
1922
- }
1923
- case 'toLowerCase': {
1924
- // Kolon has no builtin string `lc` / `uc`, so these go through the
1925
- // runtime object (consistent with $bf.includes / $bf.slice / etc.).
1926
- const recv = emit(object)
1927
- return `$bf.lc(${recv})`
1928
- }
1929
- case 'toUpperCase': {
1930
- const recv = emit(object)
1931
- return `$bf.uc(${recv})`
1932
- }
1933
- case 'trim': {
1934
- const recv = emit(object)
1935
- return `$bf.trim(${recv})`
1936
- }
1937
- case 'toFixed': {
1938
- // `.toFixed(digits?)` — `$bf.to_fixed` mirrors JS rounding +
1939
- // zero-padding (default 0 digits). #1897.
1940
- const recv = emit(object)
1941
- const digits = args.length >= 1 ? emit(args[0]) : '0'
1942
- return `$bf.to_fixed(${recv}, ${digits})`
1943
- }
1944
- case 'split': {
1945
- const recv = emit(object)
1946
- if (args.length === 0) {
1947
- return `$bf.split(${recv})`
1948
- }
1949
- const sep = emit(args[0])
1950
- if (args.length === 1) {
1951
- return `$bf.split(${recv}, ${sep})`
1952
- }
1953
- const limit = emit(args[1])
1954
- return `$bf.split(${recv}, ${sep}, ${limit})`
1955
- }
1956
- case 'startsWith':
1957
- case 'endsWith': {
1958
- const fn = method === 'startsWith' ? 'starts_with' : 'ends_with'
1959
- const recv = emit(object)
1960
- const arg = emit(args[0])
1961
- if (args.length >= 2) {
1962
- return `$bf.${fn}(${recv}, ${arg}, ${emit(args[1])})`
1963
- }
1964
- return `$bf.${fn}(${recv}, ${arg})`
1965
- }
1966
- case 'replace': {
1967
- const recv = emit(object)
1968
- const oldS = emit(args[0])
1969
- const newS = emit(args[1])
1970
- return `$bf.replace(${recv}, ${oldS}, ${newS})`
1971
- }
1972
- case 'repeat': {
1973
- const recv = emit(object)
1974
- const count = args.length === 0 ? '0' : emit(args[0])
1975
- return `$bf.repeat(${recv}, ${count})`
1976
- }
1977
- case 'padStart':
1978
- case 'padEnd': {
1979
- const fn = method === 'padStart' ? 'pad_start' : 'pad_end'
1980
- const recv = emit(object)
1981
- if (args.length === 0) {
1982
- return `$bf.${fn}(${recv}, 0)`
1983
- }
1984
- const target = emit(args[0])
1985
- if (args.length === 1) {
1986
- return `$bf.${fn}(${recv}, ${target})`
1987
- }
1988
- const pad = emit(args[1])
1989
- return `$bf.${fn}(${recv}, ${target}, ${pad})`
1990
- }
1991
- default: {
1992
- // TS-level exhaustiveness guard.
1993
- const _exhaustive: never = method
1994
- throw new Error(
1995
- `renderArrayMethod: unhandled ArrayMethod '${(_exhaustive as string)}'`,
1996
- )
1997
- }
1998
- }
1999
- }
2000
-
2001
- /**
2002
- * Shared Kolon emit for `.sort(cmp)` / `.toSorted(cmp)`. Used by both the
2003
- * filter-context emitter and the top-level emitter, plus the loop-array
2004
- * wrap in `renderLoop`. The runtime `$bf.sort` accepts a hashref opts bag and
2005
- * returns a fresh array ref.
2006
- */
2007
- function renderSortMethod(recv: string, c: SortComparator): string {
2008
- const keyHashes = c.keys.map((k) => {
2009
- const keyEntry =
2010
- k.key.kind === 'self'
2011
- ? `key_kind => 'self'`
2012
- : `key_kind => 'field', key => '${k.key.field}'`
2013
- return `{ ${keyEntry}, compare_type => '${k.type}', direction => '${k.direction}' }`
2014
- })
2015
- return `$bf.sort(${recv}, { keys => [${keyHashes.join(', ')}] })`
2016
- }
2017
-
2018
- /**
2019
- * Render a `.reduce(fn, init)` arithmetic fold as a `$bf.reduce(...)` call.
2020
- */
2021
- function renderReduceMethod(recv: string, op: ReduceOp, direction: 'left' | 'right'): string {
2022
- const keyEntry =
2023
- op.key.kind === 'self'
2024
- ? `key_kind => 'self'`
2025
- : `key_kind => 'field', key => '${op.key.field}'`
2026
- const init =
2027
- op.type === 'string'
2028
- ? `'${op.init.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
2029
- : op.init
2030
- return `$bf.reduce(${recv}, { op => '${op.op}', ${keyEntry}, type => '${op.type}', init => ${init}, direction => '${direction}' })`
2031
- }
2032
-
2033
- // `.flat(depth?)` → `$bf.flat($recv, $depth)`.
2034
- function renderFlatMethod(recv: string, depth: FlatDepth): string {
2035
- const d = depth === 'infinity' ? -1 : depth
2036
- return `$bf.flat(${recv}, ${d})`
2037
- }
2038
-
2039
- // `.flatMap(...)` → `$bf.flat_map(...)` / `$bf.flat_map_tuple(...)`.
2040
- function renderFlatMapMethod(recv: string, op: FlatMapOp): string {
2041
- const proj = op.projection
2042
- if (proj.kind === 'tuple') {
2043
- const specs = proj.elements
2044
- .map(l => (l.kind === 'self' ? `['self', '']` : `['field', '${l.field}']`))
2045
- .join(', ')
2046
- return `$bf.flat_map_tuple(${recv}, ${specs})`
2047
- }
2048
- if (proj.kind === 'self') return `$bf.flat_map(${recv}, 'self', '')`
2049
- return `$bf.flat_map(${recv}, 'field', '${proj.field}')`
2050
- }
2051
-
2052
- /**
2053
- * Parse a const initializer's source text. Returns the unescaped string value
2054
- * when the whole initializer is a single pure string literal — single/double
2055
- * quoted, or a no-substitution backtick template (no `${}`) — else `null`.
2056
- * Only such a value can be inlined byte-for-byte; template literals with
2057
- * interpolation, numbers, objects, and `Record<T,string>` maps are excluded.
2058
- */
2059
- function parsePureStringLiteral(source: string): string | null {
2060
- let s = source.trim()
2061
- // Peel a single layer of wrapping parens.
2062
- while (s.startsWith('(') && s.endsWith(')')) s = s.slice(1, -1).trim()
2063
- const quote = s[0]
2064
- if ((quote === "'" || quote === '"') && s[s.length - 1] === quote) {
2065
- const body = s.slice(1, -1)
2066
- // Reject if an unescaped matching quote appears inside (not a single
2067
- // literal then).
2068
- if (containsUnescaped(body, quote)) return null
2069
- return unescapeStringLiteralBody(body)
2070
- }
2071
- if (quote === '`' && s[s.length - 1] === '`') {
2072
- const body = s.slice(1, -1)
2073
- if (body.includes('${')) return null
2074
- if (containsUnescaped(body, '`')) return null
2075
- return unescapeStringLiteralBody(body)
2076
- }
2077
- // `[<literals>].join(' ')` module consts (e.g. Switch's `trackStateClasses`)
2078
- // → inline the flattened string byte-for-byte. See `evalStringArrayJoin`.
2079
- return evalStringArrayJoin(source)
2080
- }
2081
-
2082
- /** Whether `s` contains an unescaped occurrence of `ch`. */
2083
- function containsUnescaped(s: string, ch: string): boolean {
2084
- for (let i = 0; i < s.length; i++) {
2085
- if (s[i] === '\\') { i++; continue }
2086
- if (s[i] === ch) return true
2087
- }
2088
- return false
2089
- }
2090
-
2091
- /** Unescape a JS string-literal body's common escape sequences. */
2092
- function unescapeStringLiteralBody(s: string): string {
2093
- return s.replace(/\\(.)/g, (_, c) => {
2094
- switch (c) {
2095
- case 'n': return '\n'
2096
- case 'r': return '\r'
2097
- case 't': return '\t'
2098
- case '0': return '\0'
2099
- default: return c
2100
- }
2101
- })
2102
- }
2103
-
2104
-
2105
- /** True when `type` is the `string` primitive. */
2106
- function isStringTypeInfo(type: TypeInfo | undefined): boolean {
2107
- return type?.kind === 'primitive' && type.primitive === 'string'
2108
- }
2109
-
2110
- /** True when `initialValue` is a bare string-literal expression. */
2111
- function isBareStringLiteral(initialValue: string | undefined): boolean {
2112
- if (!initialValue) return false
2113
- const v = initialValue.trim()
2114
- return (v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))
2115
- }
2116
-
2117
- /**
2118
- * Whether a comparison operand is string-typed, so JS `===`/`!==` against it
2119
- * must lower to Perl `eq`/`ne` instead of numeric `==`/`!=`.
2120
- */
2121
- function isStringTypedOperand(expr: ParsedExpr, isStringName: (n: string) => boolean): boolean {
2122
- if (expr.kind === 'literal' && expr.literalType === 'string') return true
2123
- if (expr.kind === 'call' && expr.callee.kind === 'identifier' && expr.args.length === 0) {
2124
- return isStringName(expr.callee.name)
2125
- }
2126
- if (expr.kind === 'member' && expr.object.kind === 'identifier' && expr.object.name === 'props') {
2127
- return isStringName(expr.property)
2128
- }
2129
- return false
2130
- }
2131
-
2132
- /**
2133
- * Lowering for the predicate body of a filter / every / some / find, plus the
2134
- * same shape used by `renderBlockBodyCondition` for complex block-body
2135
- * filters. Higher-order predicates are emitted using Kolon's own scalar
2136
- * comparison operators (which delegate to Perl semantics).
2137
- *
2138
- * NOTE: Kolon has no `grep { } @{...}` form, so nested higher-order chains
2139
- * (`x.tags.filter(...).length`) inside a predicate route through the
2140
- * top-level emitter's `$bf`-helper higher-order lowering. This emitter keeps
2141
- * the scalar-comparison surface the predicates the adapter accepts actually
2142
- * use; richer nested shapes fall back to the helper or surface as BF101 via
2143
- * the top-level emitter.
2144
- */
2145
- class XslateFilterEmitter implements ParsedExprEmitter {
2146
- constructor(
2147
- private readonly param: string,
2148
- private readonly localVarMap: Map<string, string>,
2149
- private readonly isStringName: (n: string) => boolean = () => false,
2150
- ) {}
2151
-
2152
- identifier(name: string): string {
2153
- if (name === this.param) return `$${this.param}`
2154
- const signal = this.localVarMap.get(name)
2155
- if (signal) return `$${signal}`
2156
- return `$${name}`
2157
- }
2158
-
2159
- literal(value: string | number | boolean | null, literalType: LiteralType): string {
2160
- if (literalType === 'string') return `'${value}'`
2161
- if (literalType === 'boolean') return value ? '1' : '0'
2162
- if (literalType === 'null') return 'nil'
2163
- return String(value)
2164
- }
2165
-
2166
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
2167
- // `.length` — route through `$bf.length` (handles both array element
2168
- // count and string char count, JS-compatibly). Kolon's builtin `.size()`
2169
- // is array-only and faults on a string.
2170
- if (property === 'length') {
2171
- return `$bf.length(${emit(object)})`
2172
- }
2173
- // Hash field access — Kolon dot works on hash refs.
2174
- return `${emit(object)}.${property}`
2175
- }
2176
-
2177
- indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2178
- // Kolon's `[]` postfix is polymorphic (array index or hash key),
2179
- // mirroring JS — no array/hash split is needed (unlike Perl's
2180
- // `->[]` vs `->{}`). #1897 (data-table's `selected()[index]`).
2181
- return `${emit(object)}[${emit(index)}]`
2182
- }
2183
-
2184
- call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
2185
- // Signal getter calls: filter() → $filter
2186
- if (callee.kind === 'identifier' && args.length === 0) {
2187
- return `$${callee.name}`
2188
- }
2189
- return emit(callee)
2190
- }
2191
-
2192
- unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2193
- const arg = emit(argument)
2194
- if (op === '!') {
2195
- const needsParens = argument.kind === 'binary' || argument.kind === 'logical'
2196
- return needsParens ? `!(${arg})` : `!${arg}`
2197
- }
2198
- if (op === '-') return `-${arg}`
2199
- return arg
2200
- }
2201
-
2202
- binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2203
- const l = emit(left)
2204
- const r = emit(right)
2205
- // Kolon's `==` / `!=` are value-equality operators that compare strings
2206
- // and numbers correctly — unlike Perl's numeric `==` (which the Mojo
2207
- // adapter must steer around with `eq`/`ne`). Kolon has no `eq`/`ne`
2208
- // operator at all, so string comparisons stay on `==` / `!=` here.
2209
- const opMap: Record<string, string> = {
2210
- '===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
2211
- '+': '+', '-': '-', '*': '*', '/': '/',
2212
- }
2213
- return `${l} ${opMap[op] ?? op} ${r}`
2214
- }
2215
-
2216
- logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2217
- const l = emit(left)
2218
- const r = emit(right)
2219
- if (op === '&&') return `(${l} && ${r})`
2220
- if (op === '||') return `(${l} || ${r})`
2221
- return `(${l} // ${r})`
2222
- }
2223
-
2224
- higherOrder(
2225
- method: HigherOrderMethod,
2226
- object: ParsedExpr,
2227
- param: string,
2228
- predicate: ParsedExpr,
2229
- emit: (e: ParsedExpr) => string,
2230
- ): string {
2231
- // Nested higher-order inside a filter predicate has no Kolon scalar form;
2232
- // defer to the receiver so the predicate at least references a real value
2233
- // (a richer chain would surface its own diagnostic at the top level).
2234
- void method
2235
- void param
2236
- void predicate
2237
- return emit(object)
2238
- }
2239
-
2240
- arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
2241
- return `[${elements.map(emit).join(', ')}]`
2242
- }
2243
-
2244
- arrayMethod(
2245
- method: ArrayMethod,
2246
- object: ParsedExpr,
2247
- args: ParsedExpr[],
2248
- emit: (e: ParsedExpr) => string,
2249
- ): string {
2250
- return renderArrayMethod(method, object, args, emit)
2251
- }
2252
-
2253
- sortMethod(
2254
- _method: 'sort' | 'toSorted',
2255
- object: ParsedExpr,
2256
- comparator: SortComparator,
2257
- emit: (e: ParsedExpr) => string,
2258
- ): string {
2259
- return renderSortMethod(emit(object), comparator)
2260
- }
2261
-
2262
- reduceMethod(method: 'reduce' | 'reduceRight', object: ParsedExpr, reduceOp: ReduceOp, emit: (e: ParsedExpr) => string): string {
2263
- return renderReduceMethod(emit(object), reduceOp, method === 'reduceRight' ? 'right' : 'left')
2264
- }
2265
-
2266
- flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
2267
- return renderFlatMethod(emit(object), depth)
2268
- }
2269
-
2270
- flatMapMethod(object: ParsedExpr, op: FlatMapOp, emit: (e: ParsedExpr) => string): string {
2271
- return renderFlatMapMethod(emit(object), op)
2272
- }
2273
-
2274
- conditional(_test: ParsedExpr, _consequent: ParsedExpr, _alternate: ParsedExpr): string {
2275
- return '1'
2276
- }
2277
-
2278
- templateLiteral(_parts: TemplatePart[]): string {
2279
- return '1'
2280
- }
2281
-
2282
- arrowFn(_param: string, _body: ParsedExpr): string {
2283
- return '1'
2284
- }
2285
-
2286
- unsupported(_raw: string, _reason: string): string {
2287
- return '1'
2288
- }
2289
- }
2290
-
2291
- /**
2292
- * Lowering for top-level expressions whose identifiers resolve against the
2293
- * Kolon template's per-render vars (signals, props, locals introduced by `:
2294
- * my $x = ...` lines). Differs from the filter emitter mainly in
2295
- * - `.length` → `.size()` (Kolon array length),
2296
- * - `conditional` is supported (filter predicates can't return ternaries),
2297
- * - higher-order methods route through `$bf` array helpers.
2298
- */
2299
- class XslateTopLevelEmitter implements ParsedExprEmitter {
2300
- constructor(private readonly adapter: XslateAdapter) {}
2301
-
2302
- identifier(name: string): string {
2303
- // `undefined` / `null` nested inside a larger expression tree —
2304
- // Kolon `nil` (#1897).
2305
- if (name === 'undefined' || name === 'null') return 'nil'
2306
- // Inline a module-scope pure-string const (`const x = 'literal'`) — it
2307
- // never reaches the per-render stash, so a bare `$x` would render empty.
2308
- const inlined = this.adapter._resolveModuleStringConst(name)
2309
- if (inlined !== null) return inlined
2310
- // Same for a literal const of any scope (`const totalPages = 5`,
2311
- // #1897 pagination's `Page {currentPage()} of {totalPages}`).
2312
- const literalConst = this.adapter._resolveLiteralConst(name)
2313
- if (literalConst !== null) return literalConst
2314
- return `$${name}`
2315
- }
2316
-
2317
- literal(value: string | number | boolean | null, literalType: LiteralType): string {
2318
- if (literalType === 'string') return `'${value}'`
2319
- if (literalType === 'boolean') return value ? '1' : '0'
2320
- if (literalType === 'null') return 'nil'
2321
- return String(value)
2322
- }
2323
-
2324
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
2325
- // `props.x` flattens to the bare `$x` the SSR caller binds each prop to
2326
- // (props arrive as individual top-level vars, not a `$props` hashref).
2327
- if (object.kind === 'identifier' && object.name === 'props') {
2328
- return `$${property}`
2329
- }
2330
- // Static property access on a module object-literal const
2331
- // (`variantClasses.ghost`, #1897) resolves at compile time — the
2332
- // generic dot lowering below would reference a Kolon var that
2333
- // doesn't exist server-side and silently render ''.
2334
- if (object.kind === 'identifier') {
2335
- const staticValue = this.adapter._resolveStaticRecordLiteral(object.name, property)
2336
- if (staticValue !== null) return staticValue
2337
- }
2338
- const obj = emit(object)
2339
- // `.length` → `$bf.length` (array count or string char count, JS-compat);
2340
- // Kolon's builtin `.size()` is array-only and faults on a string.
2341
- if (property === 'length') return `$bf.length(${obj})`
2342
- // Kolon dot access works for hash refs.
2343
- return `${obj}.${property}`
2344
- }
2345
-
2346
- indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2347
- // Kolon's `[]` postfix is polymorphic (array index or hash key),
2348
- // mirroring JS. #1897 (data-table's `selected()[index]`).
2349
- return `${emit(object)}[${emit(index)}]`
2350
- }
2351
-
2352
- call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
2353
- // Signal getter: count() → $count
2354
- if (callee.kind === 'identifier' && args.length === 0) {
2355
- return `$${callee.name}`
2356
- }
2357
- // Env-signal method call (#1922): `searchParams().get('sort')` is a real
2358
- // method call on the per-request `$searchParams` reader object, not the
2359
- // generic dot deref `member` would emit (`$searchParams.get`, which drops
2360
- // the arg). Matches the local import binding (incl. an alias).
2361
- if (this.adapter._searchParamsLocals.size > 0) {
2362
- const sp = matchSearchParamsMethodCall(callee, args, this.adapter._searchParamsLocals)
2363
- if (sp) {
2364
- return `$searchParams.${sp.method}(${sp.args.map(emit).join(', ')})`
2365
- }
2366
- }
2367
- // Identifier-path templatePrimitive: `JSON.stringify(x)` / `Math.floor(x)`
2368
- // → `$bf.json($x)` / `$bf.floor($x)`. Args render recursively through this
2369
- // same emitter. A wrong-arity call records BF101 and returns `''`.
2370
- const path = identifierPath(callee)
2371
- const spec = path ? XSLATE_TEMPLATE_PRIMITIVES[path] : undefined
2372
- if (path && spec) {
2373
- if (args.length === spec.arity) {
2374
- return spec.emit(args.map(emit))
2375
- }
2376
- this.adapter._recordExprBF101(
2377
- `templatePrimitive '${path}' expects ${spec.arity} arg(s), got ${args.length}`,
2378
- `Call '${path}' with exactly ${spec.arity} argument(s).`,
2379
- )
2380
- return "''"
2381
- }
2382
- return emit(callee)
2383
- }
2384
-
2385
- unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2386
- const arg = emit(argument)
2387
- if (op === '!') return `!${arg}`
2388
- if (op === '-') return `-${arg}`
2389
- return arg
2390
- }
2391
-
2392
- binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2393
- const l = emit(left)
2394
- const r = emit(right)
2395
- // Kolon's `==` / `!=` are value-equality operators handling both strings
2396
- // and numbers (unlike Perl's numeric `==`, which the Mojo adapter must
2397
- // route around with `eq`/`ne`). Kolon has no `eq`/`ne` operator, so all
2398
- // equality comparisons — string or numeric — stay on `==` / `!=`.
2399
- const opMap: Record<string, string> = {
2400
- '===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
2401
- '+': '+', '-': '-', '*': '*',
2402
- }
2403
- return `${l} ${opMap[op] ?? op} ${r}`
2404
- }
2405
-
2406
- logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2407
- const l = emit(left)
2408
- const r = emit(right)
2409
- if (op === '&&') return `(${l} && ${r})`
2410
- if (op === '||') return `(${l} || ${r})`
2411
- return `(${l} // ${r})`
2412
- }
2413
-
2414
- higherOrder(
2415
- method: HigherOrderMethod,
2416
- object: ParsedExpr,
2417
- param: string,
2418
- predicate: ParsedExpr,
2419
- emit: (e: ParsedExpr) => string,
2420
- ): string {
2421
- // Higher-order array methods all take a JS arrow predicate, lowered to a
2422
- // Kolon lambda `-> $param { PRED }` (callable from Perl as a code ref), and
2423
- // go through the runtime object — consistent with the other array helpers
2424
- // ($bf.includes / $bf.slice / ...). `.find*` map to snake_case runtime
2425
- // methods (like index_of / last_index_of). The `.filter(...).map(...)`
2426
- // *loop* form is handled separately by renderLoop's inline predicate.
2427
- const arrayExpr = emit(object)
2428
- const predBody = this.adapter._renderKolonFilterExprPublic(predicate, param)
2429
- const lambda = `-> $${param} { ${predBody} }`
2430
- const fn: Record<string, string> = {
2431
- filter: 'filter',
2432
- every: 'every',
2433
- some: 'some',
2434
- find: 'find',
2435
- findIndex: 'find_index',
2436
- findLast: 'find_last',
2437
- findLastIndex: 'find_last_index',
2438
- }
2439
- if (fn[method]) return `$bf.${fn[method]}(${arrayExpr}, ${lambda})`
2440
- void predicate
2441
- void param
2442
- return emit(object)
2443
- }
2444
-
2445
- arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
2446
- return `[${elements.map(emit).join(', ')}]`
2447
- }
2448
-
2449
- arrayMethod(
2450
- method: ArrayMethod,
2451
- object: ParsedExpr,
2452
- args: ParsedExpr[],
2453
- emit: (e: ParsedExpr) => string,
2454
- ): string {
2455
- return renderArrayMethod(method, object, args, emit)
2456
- }
2457
-
2458
- sortMethod(
2459
- _method: 'sort' | 'toSorted',
2460
- object: ParsedExpr,
2461
- comparator: SortComparator,
2462
- emit: (e: ParsedExpr) => string,
2463
- ): string {
2464
- return renderSortMethod(emit(object), comparator)
2465
- }
2466
-
2467
- reduceMethod(method: 'reduce' | 'reduceRight', object: ParsedExpr, reduceOp: ReduceOp, emit: (e: ParsedExpr) => string): string {
2468
- return renderReduceMethod(emit(object), reduceOp, method === 'reduceRight' ? 'right' : 'left')
2469
- }
2470
-
2471
- flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
2472
- return renderFlatMethod(emit(object), depth)
2473
- }
2474
-
2475
- flatMapMethod(object: ParsedExpr, op: FlatMapOp, emit: (e: ParsedExpr) => string): string {
2476
- return renderFlatMapMethod(emit(object), op)
2477
- }
2478
-
2479
- conditional(
2480
- test: ParsedExpr,
2481
- consequent: ParsedExpr,
2482
- alternate: ParsedExpr,
2483
- emit: (e: ParsedExpr) => string,
2484
- ): string {
2485
- return `(${emit(test)} ? ${emit(consequent)} : ${emit(alternate)})`
2486
- }
2487
-
2488
- templateLiteral(parts: TemplatePart[], emit: (e: ParsedExpr) => string): string {
2489
- // `` `n=${count() + 1}` `` → Kolon string concatenation (`~`):
2490
- // `'n=' ~ ($count + 1)`. Kolon's `~` is the explicit concat operator.
2491
- const terms: string[] = []
2492
- for (const part of parts) {
2493
- if (part.type === 'string') {
2494
- if (part.value !== '') {
2495
- terms.push(`'${part.value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`)
2496
- }
2497
- } else {
2498
- const rendered = emit(part.expr)
2499
- const needsParens =
2500
- part.expr.kind === 'binary' ||
2501
- part.expr.kind === 'logical' ||
2502
- part.expr.kind === 'conditional'
2503
- terms.push(needsParens ? `(${rendered})` : rendered)
2504
- }
2505
- }
2506
- if (terms.length === 0) return `''`
2507
- return terms.join(' ~ ')
2508
- }
2509
-
2510
- arrowFn(_param: string, _body: ParsedExpr): string {
2511
- return "''"
2512
- }
2513
-
2514
- unsupported(_raw: string, _reason: string): string {
2515
- return "''"
2516
- }
2517
- }
2518
-
2519
1473
  export const xslateAdapter = new XslateAdapter()