@barefootjs/xslate 0.15.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +63 -0
  6. package/dist/adapter/expr/array-method.d.ts.map +1 -0
  7. package/dist/adapter/expr/emitters.d.ts +97 -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 +1428 -1324
  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 +34 -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 +1428 -1324
  31. package/dist/index.js +1428 -1324
  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 +94 -2
  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 +311 -0
  39. package/src/adapter/expr/emitters.ts +530 -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 +64 -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 +125 -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 +176 -1233
  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,85 +563,6 @@ 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
  // ===========================================================================
@@ -893,7 +604,20 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
893
604
  // helper runs once.
894
605
  let array = rawArray
895
606
  if (loop.sortComparator) {
896
- array = renderSortMethod(rawArray, loop.sortComparator)
607
+ // Evaluator-first (#2018 P3): serialize the comparator arrow body + emit
608
+ // `$bf.sort_eval`; fall back to the structured `$bf.sort` for a
609
+ // comparator the evaluator can't model (e.g. `localeCompare`). The
610
+ // comparator now arrives as an `IRLoopSort` carrying the generic
611
+ // `arrow` + its params.
612
+ const sort = loop.sortComparator
613
+ const sortEmit = (e: ParsedExpr) => this.convertExpressionToKolon('', e)
614
+ const arrow = sort.arrow
615
+ const params =
616
+ arrow.kind === 'arrow' ? arrow.params : [sort.paramA, sort.paramB]
617
+ const structured = sortComparatorFromArrow(arrow)
618
+ array =
619
+ renderSortEval(rawArray, arrow.kind === 'arrow' ? arrow.body : arrow, params, sortEmit) ??
620
+ (structured !== null ? renderSortMethod(rawArray, structured) : rawArray)
897
621
  }
898
622
  const param = loop.param
899
623
  // Kolon binds the item directly via `for LIST -> $item`. The index, when
@@ -957,12 +681,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
957
681
  // Handle filter().map() pattern by wrapping children in if-condition
958
682
  if (loop.filterPredicate) {
959
683
  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) {
684
+ if (loop.filterPredicate.predicate) {
966
685
  filterCond = this.renderKolonFilterExpr(
967
686
  loop.filterPredicate.predicate,
968
687
  loop.filterPredicate.param
@@ -1009,6 +728,17 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1009
728
  if (value.parts) {
1010
729
  return `${kolonHashKey(name)} => ${this.convertTemplateLiteralPartsToKolon(value.parts)}`
1011
730
  }
731
+ // Inline object-literal child prop (carousel's `opts={{ align: 'start' }}`):
732
+ // lower to a Kolon hashref so the child can serialize it (`data-opts`),
733
+ // instead of refusing the bare object with BF101. (#1971 Perl) Read the
734
+ // IR-carried structured `ParsedExpr` tree (#2018) instead of re-parsing
735
+ // `value.expr` with `ts.createSourceFile`; the lowering returns null for
736
+ // any non-object-literal shape, so the common non-object case falls
737
+ // straight through to the bare-expression path below.
738
+ if (value.parsed) {
739
+ const hashref = objectLiteralExprToKolonHashref(this.spreadCtx, value.parsed)
740
+ if (hashref !== null) return `${kolonHashKey(name)} => ${hashref}`
741
+ }
1012
742
  return `${kolonHashKey(name)} => ${this.convertExpressionToKolon(value.expr)}`
1013
743
  },
1014
744
  emitSpread: (value) => {
@@ -1284,7 +1014,9 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1284
1014
  // Emit a Kolon inline ternary of hashrefs — Perl truthiness handles the
1285
1015
  // condition for free, and the falsy `{}` branch OMITS the key
1286
1016
  // (`spread_attrs` does NOT emit empty hashref entries).
1287
- const ternaryHashref = this.conditionalSpreadToKolon(trimmed)
1017
+ // Read the spread's IR-carried `ParsedExpr` tree (#2018) instead of
1018
+ // re-parsing `trimmed` with `ts.createSourceFile`.
1019
+ const ternaryHashref = conditionalSpreadToKolon(this.spreadCtx, value.parsed)
1288
1020
  if (ternaryHashref !== null) {
1289
1021
  return `<: $bf.spread_attrs(${ternaryHashref}) | mark_raw :>`
1290
1022
  }
@@ -1301,7 +1033,15 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1301
1033
  if (localConst?.value !== undefined) {
1302
1034
  const initTrimmed = localConst.value.trim()
1303
1035
  if (!/^[A-Za-z_$][\w$]*$/.test(initTrimmed)) {
1304
- const resolved = this.conditionalSpreadToKolon(initTrimmed)
1036
+ // The local const's initializer text isn't carried as a structured
1037
+ // tree on the spread attr, so parse it once via the shared
1038
+ // `parseExpression` (the analyzer's own entry) — not
1039
+ // `ts.createSourceFile` — mirroring go-template's same local-const
1040
+ // resolution path.
1041
+ const resolved = conditionalSpreadToKolon(
1042
+ this.spreadCtx,
1043
+ parseExpression(initTrimmed),
1044
+ )
1305
1045
  if (resolved !== null) {
1306
1046
  return `<: $bf.spread_attrs(${resolved}) | mark_raw :>`
1307
1047
  }
@@ -1355,6 +1095,13 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1355
1095
  const parts: string[] = []
1356
1096
 
1357
1097
  for (const attr of element.attrs) {
1098
+ // `/* @client */` attribute bindings are deferred to hydrate: the
1099
+ // client runtime sets/patches the attribute in a mount effect (the
1100
+ // CSR template omits it; ir-to-client-js emits the setAttribute
1101
+ // effect). Skip SSR emission so the server omits the attribute and
1102
+ // the unsupported-expression lowering is never reached for a deferred
1103
+ // predicate (no BF101 / BF102). #1966
1104
+ if (attr.clientOnly) continue
1358
1105
  // Rewrite JSX special-prop names to their HTML-attribute counterparts.
1359
1106
  let attrName: string
1360
1107
  if (attr.name === 'className') attrName = 'class'
@@ -1403,99 +1150,6 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1403
1150
  return emitParsedExpr(expr, new XslateFilterEmitter(param, localVarMap, n => this._isStringValueName(n)))
1404
1151
  }
1405
1152
 
1406
- /**
1407
- * Render a complex block body filter into a Kolon condition.
1408
- * Handles patterns like: filter(t => { const f = filter(); if (...) return ...; })
1409
- */
1410
- private renderBlockBodyCondition(
1411
- statements: ParsedStatement[],
1412
- param: string
1413
- ): string {
1414
- const localVarMap = new Map<string, string>()
1415
- const paths = this.collectReturnPaths(statements, [], localVarMap, param)
1416
-
1417
- if (paths.length === 0) return '1'
1418
- if (paths.length === 1) return this.buildSinglePathCondition(paths[0], param, localVarMap)
1419
-
1420
- // Multiple paths: build OR condition
1421
- const parts: string[] = []
1422
- for (const path of paths) {
1423
- if (path.result.kind === 'literal' && path.result.literalType === 'boolean' && path.result.value === false) continue
1424
- const cond = this.buildSinglePathCondition(path, param, localVarMap)
1425
- if (cond !== '0') parts.push(cond)
1426
- }
1427
-
1428
- if (parts.length === 0) return '0'
1429
- if (parts.length === 1) return parts[0]
1430
- return `(${parts.join(' || ')})`
1431
- }
1432
-
1433
- private collectReturnPaths(
1434
- statements: ParsedStatement[],
1435
- currentConditions: ParsedExpr[],
1436
- localVarMap: Map<string, string>,
1437
- param: string
1438
- ): Array<{ conditions: ParsedExpr[]; result: ParsedExpr }> {
1439
- const paths: Array<{ conditions: ParsedExpr[]; result: ParsedExpr }> = []
1440
-
1441
- for (const stmt of statements) {
1442
- if (stmt.kind === 'var-decl') {
1443
- if (stmt.init.kind === 'call' && stmt.init.callee.kind === 'identifier') {
1444
- localVarMap.set(stmt.name, stmt.init.callee.name)
1445
- }
1446
- } else if (stmt.kind === 'return') {
1447
- paths.push({ conditions: [...currentConditions], result: stmt.value })
1448
- break
1449
- } else if (stmt.kind === 'if') {
1450
- const thenPaths = this.collectReturnPaths(stmt.consequent, [...currentConditions, stmt.condition], localVarMap, param)
1451
- paths.push(...thenPaths)
1452
-
1453
- if (stmt.alternate) {
1454
- const negated: ParsedExpr = { kind: 'unary', op: '!', argument: stmt.condition }
1455
- const elsePaths = this.collectReturnPaths(stmt.alternate, [...currentConditions, negated], localVarMap, param)
1456
- paths.push(...elsePaths)
1457
- } else {
1458
- currentConditions.push({ kind: 'unary', op: '!', argument: stmt.condition })
1459
- }
1460
- }
1461
- }
1462
-
1463
- return paths
1464
- }
1465
-
1466
- private buildSinglePathCondition(
1467
- path: { conditions: ParsedExpr[]; result: ParsedExpr },
1468
- param: string,
1469
- localVarMap: Map<string, string>
1470
- ): string {
1471
- if (path.result.kind === 'literal' && path.result.literalType === 'boolean') {
1472
- if (path.result.value === true) {
1473
- if (path.conditions.length === 0) return '1'
1474
- return this.renderConditionsAnd(path.conditions, param, localVarMap)
1475
- }
1476
- return '0'
1477
- }
1478
-
1479
- if (path.conditions.length === 0) {
1480
- return this.renderKolonFilterExpr(path.result, param, localVarMap)
1481
- }
1482
-
1483
- const condPart = this.renderConditionsAnd(path.conditions, param, localVarMap)
1484
- const resultPart = this.renderKolonFilterExpr(path.result, param, localVarMap)
1485
- return `(${condPart} && ${resultPart})`
1486
- }
1487
-
1488
- private renderConditionsAnd(
1489
- conditions: ParsedExpr[],
1490
- param: string,
1491
- localVarMap: Map<string, string>
1492
- ): string {
1493
- if (conditions.length === 0) return '1'
1494
- if (conditions.length === 1) return this.renderKolonFilterExpr(conditions[0], param, localVarMap)
1495
- const parts = conditions.map(c => this.renderKolonFilterExpr(c, param, localVarMap))
1496
- return `(${parts.join(' && ')})`
1497
- }
1498
-
1499
1153
  // ===========================================================================
1500
1154
  // Expression Conversion: JS → Kolon
1501
1155
  // ===========================================================================
@@ -1578,137 +1232,92 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1578
1232
  }
1579
1233
 
1580
1234
  /**
1581
- * Lower a conditional inline-object spread
1582
- * `COND ? { 'aria-describedby': describedBy } : {}`
1583
- * to a Kolon inline ternary of hashrefs
1584
- * `$describedBy ? { 'aria-describedby' => $describedBy } : {}`.
1585
- * Both branches must be object literals; the condition + values route through
1586
- * `convertExpressionToKolon`. Returns `null` for any other shape so the caller
1587
- * falls back to its normal lowering. Mirror of `conditionalSpreadToPerl`.
1235
+ * Build the EmitContext seam the top-level `ParsedExpr` emitter depends on.
1236
+ * Built as a private object (the adapter does NOT `implements XslateEmitContext`)
1237
+ * so the wrapped bookkeeping `_searchParamsLocals`, the const/record
1238
+ * resolvers, BF101 recording, the filter-predicate entry stays private and
1239
+ * off the exported adapter's public type, matching the Go adapter's
1240
+ * `emitCtx` and the `spreadCtx` / `memoCtx` seams below.
1588
1241
  */
1589
- private conditionalSpreadToKolon(expr: string): string | null {
1590
- const sf = ts.createSourceFile('__spread.ts', `(${expr})`, ts.ScriptTarget.Latest, true)
1591
- if (sf.statements.length !== 1) return null
1592
- const stmt = sf.statements[0]
1593
- if (!ts.isExpressionStatement(stmt)) return null
1594
- let node: ts.Expression = stmt.expression
1595
- while (ts.isParenthesizedExpression(node)) node = node.expression
1596
- if (!ts.isConditionalExpression(node)) return null
1597
- const unwrap = (e: ts.Expression): ts.Expression => {
1598
- let n = e
1599
- while (ts.isParenthesizedExpression(n)) n = n.expression
1600
- return n
1601
- }
1602
- const whenTrue = unwrap(node.whenTrue)
1603
- const whenFalse = unwrap(node.whenFalse)
1604
- if (!ts.isObjectLiteralExpression(whenTrue) || !ts.isObjectLiteralExpression(whenFalse)) {
1605
- return null
1242
+ private get emitCtx(): XslateEmitContext {
1243
+ return {
1244
+ _searchParamsLocals: this._searchParamsLocals,
1245
+ _resolveModuleStringConst: (name) => this._resolveModuleStringConst(name),
1246
+ _resolveLiteralConst: (name) => this._resolveLiteralConst(name),
1247
+ _resolveStaticRecordLiteral: (o, k) => this._resolveStaticRecordLiteral(o, k),
1248
+ _recordExprBF101: (message, reason) => this._recordExprBF101(message, reason),
1249
+ _renderKolonFilterExprPublic: (e, p) => this._renderKolonFilterExprPublic(e, p),
1606
1250
  }
1607
- const condPerl = this.convertExpressionToKolon(node.condition.getText(sf))
1608
- const truePerl = this.objectLiteralToKolonHashref(whenTrue, sf)
1609
- const falsePerl = this.objectLiteralToKolonHashref(whenFalse, sf)
1610
- if (truePerl === null || falsePerl === null) return null
1611
- return `${condPerl} ? ${truePerl} : ${falsePerl}`
1612
1251
  }
1613
1252
 
1614
1253
  /**
1615
- * Convert a static object literal into a Kolon hashref string for a
1616
- * conditional spread. Only static string/identifier keys are allowed; values
1617
- * resolve via `convertExpressionToKolon` (or the `Record[propKey]` index
1618
- * lowering). Returns `null` for any computed/spread/dynamic key. Empty object
1619
- * → `{}`. Mirror of `objectLiteralToPerlHashref`.
1254
+ * Build the narrow context the extracted spread lowering depends on. Passing
1255
+ * a purpose-built object (rather than `this`) keeps the adapter's bookkeeping
1256
+ * members private they stay internal implementation detail, not part of the
1257
+ * exported class's public surface.
1620
1258
  */
1621
- private objectLiteralToKolonHashref(
1622
- obj: ts.ObjectLiteralExpression,
1623
- sf: ts.SourceFile,
1624
- ): string | null {
1625
- const entries: string[] = []
1626
- for (const prop of obj.properties) {
1627
- if (!ts.isPropertyAssignment(prop)) return null
1628
- let key: string
1629
- if (ts.isIdentifier(prop.name)) {
1630
- key = prop.name.text
1631
- } else if (ts.isStringLiteral(prop.name) || ts.isNoSubstitutionTemplateLiteral(prop.name)) {
1632
- key = prop.name.text
1633
- } else {
1634
- return null
1635
- }
1636
- const initNode = (() => {
1637
- let n: ts.Expression = prop.initializer
1638
- while (ts.isParenthesizedExpression(n)) n = n.expression
1639
- return n
1640
- })()
1641
- const indexed = this.recordIndexAccessToKolon(initNode)
1642
- if (
1643
- indexed === null &&
1644
- ts.isElementAccessExpression(initNode) &&
1645
- initNode.argumentExpression &&
1646
- !ts.isNumericLiteral(initNode.argumentExpression) &&
1647
- !ts.isStringLiteral(initNode.argumentExpression)
1648
- ) {
1649
- // Variable-index record access (`sizeMap[size]`) the static-inline
1650
- // path couldn't resolve (non-scalar value / non-const receiver).
1651
- // Since #1897 made variable indices parseable (`index-access`),
1652
- // the generic value lowering would emit `$sizeMap[$size]` against
1653
- // an UNBOUND module const instead of refusing — record BF101 and
1654
- // bail so the spread surfaces the out-of-shape diagnostic,
1655
- // matching pre-#1897 behaviour. (Mirrors the Mojo adapter.)
1656
- this.errors.push({
1657
- code: 'BF101',
1658
- severity: 'error',
1659
- 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.`,
1660
- loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1661
- suggestion: {
1662
- message: 'Index a record whose values are number/string literals, or move the spread into a `\'use client\'` component so hydration computes it.',
1663
- },
1664
- })
1665
- return null
1666
- }
1667
- const valPerl =
1668
- indexed !== null
1669
- ? indexed
1670
- : this.convertExpressionToKolon(prop.initializer.getText(sf))
1671
- entries.push(`'${escapeKolonSingleQuoted(key)}' => ${valPerl}`)
1259
+ private get spreadCtx(): XslateSpreadContext {
1260
+ return {
1261
+ componentName: this.componentName,
1262
+ errors: this.errors,
1263
+ localConstants: this.localConstants,
1264
+ propsParams: this.propsParams,
1265
+ convertExpressionToKolon: (e, preParsed) => this.convertExpressionToKolon(e, preParsed),
1672
1266
  }
1673
- return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`
1674
1267
  }
1675
1268
 
1676
- /**
1677
- * Lower a spread-object VALUE of the form `IDENT[KEY]` (CheckIcon's
1678
- * `sizeMap[size]`) to an inline indexed Kolon hashref
1679
- * `{ 'sm' => 16, 'md' => 20, ... }[$size]`.
1680
- * Reuses the shared structural parse (`parseRecordIndexAccess`); this wrapper
1681
- * only does the single-quote escaping + Kolon index emit. NB: Kolon indexes a
1682
- * hashref literal with bracket syntax `{…}[$key]`, NOT Perl's arrow-deref
1683
- * `{…}->{$key}` (which Kolon's parser rejects) — this is the one divergence
1684
- * from the Mojo `recordIndexAccessToPerl` emit.
1685
- */
1686
- private recordIndexAccessToKolon(val: ts.Expression): string | null {
1687
- const parsed = parseRecordIndexAccess(val, this.localConstants ?? [], this.propsParams)
1688
- if (!parsed) return null
1689
- const entries = parsed.entries.map(e => {
1690
- const mapVal =
1691
- e.value.kind === 'number' ? e.value.text : `'${escapeKolonSingleQuoted(e.value.text)}'`
1692
- return `'${escapeKolonSingleQuoted(e.key)}' => ${mapVal}`
1693
- })
1694
- return `{ ${entries.join(', ')} }[$${parsed.indexPropName}]`
1269
+ /** Build the narrow context the extracted memo seeding depends on. */
1270
+ private get memoCtx(): XslateMemoContext {
1271
+ return { convertExpressionToKolon: (e, preParsed) => this.convertExpressionToKolon(e, preParsed) }
1695
1272
  }
1696
1273
 
1697
- private convertExpressionToKolon(expr: string): string {
1274
+ private convertExpressionToKolon(expr: string, preParsed?: ParsedExpr): string {
1698
1275
  // Parse-first lowering — parity with the Mojo adapter's
1699
1276
  // `convertExpressionToPerl`. Parse the JS expression once, gate it on the
1700
1277
  // shared `isSupported`, and render every supported shape through the AST
1701
1278
  // emitter. Unsupported shapes surface as BF101.
1702
- const trimmed = expr.trim()
1703
- if (trimmed === '') return "''"
1279
+ //
1280
+ // `preParsed` is the IR-carried `ParsedExpr` tree (cf. go-template's
1281
+ // `convertExpressionToGo(jsExpr, out?, preParsed?)`); when present it is
1282
+ // used directly instead of re-parsing `expr`, so spread condition/value
1283
+ // lowering threads the carried tree through without a stringify→re-parse
1284
+ // round-trip. The diagnostic text is then derived from the tree
1285
+ // (`stringifyParsedExpr`) so callers can pass `''` for `expr`.
1286
+ let parsed: ParsedExpr
1287
+ if (preParsed) {
1288
+ parsed = preParsed
1289
+ } else {
1290
+ const trimmed = expr.trim()
1291
+ if (trimmed === '') return "''"
1292
+ parsed = parseExpression(trimmed)
1293
+ }
1294
+
1295
+ // Registered call lowerings (#2057) — including the built-in `queryHref`
1296
+ // plugin (#2042), which lowers `queryHref(base, { … })` to a neutral
1297
+ // `guard-list` on the `query` helper → `$bf.query(base, <triples>)`.
1298
+ // Recognised before the support gate because the object-literal arg is
1299
+ // otherwise `unsupported` (BF101). The `query` helper includes a pair iff its
1300
+ // guard is truthy AND its value is a non-empty string (the client's
1301
+ // `if (value)`): a plain `key: v` passes guard `1`, a conditional
1302
+ // `key: cond ? v : undefined` passes the lowered cond. Only the `query`
1303
+ // helper renders to `$bf.query`; another guard-list helper must not be
1304
+ // silently mis-rendered as a query.
1305
+ if (parsed.kind === 'call') {
1306
+ for (const matcher of this._loweringMatchers) {
1307
+ const node = matcher(parsed.callee, parsed.args)
1308
+ if (node?.kind === 'guard-list' && node.helper === 'query') {
1309
+ const qArgs = queryHrefArgs(node, n => this.renderParsedExprToKolon(n))
1310
+ return `$bf.query(${qArgs.join(', ')})`
1311
+ }
1312
+ }
1313
+ }
1704
1314
 
1705
- const parsed = parseExpression(trimmed)
1706
1315
  const support = isSupported(parsed)
1707
1316
  if (!support.supported) {
1708
1317
  this.errors.push({
1709
1318
  code: 'BF101',
1710
1319
  severity: 'error',
1711
- message: `Expression not supported: ${trimmed}`,
1320
+ message: `Expression not supported: ${preParsed ? stringifyParsedExpr(parsed) : expr.trim()}`,
1712
1321
  loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1713
1322
  suggestion: {
1714
1323
  message: support.reason
@@ -1729,12 +1338,12 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1729
1338
  * expressions where identifiers are signals / template vars.
1730
1339
  */
1731
1340
  private renderParsedExprToKolon(expr: ParsedExpr): string {
1732
- return emitParsedExpr(expr, new XslateTopLevelEmitter(this))
1341
+ return emitParsedExpr(expr, new XslateTopLevelEmitter(this.emitCtx))
1733
1342
  }
1734
1343
 
1735
1344
  /** Whether `name` (a signal getter or prop) holds a string value, so an
1736
1345
  * equality comparison against it should use Perl `eq`/`ne`. */
1737
- _isStringValueName(name: string): boolean {
1346
+ private _isStringValueName(name: string): boolean {
1738
1347
  return this.stringValueNames.has(name)
1739
1348
  }
1740
1349
 
@@ -1796,7 +1405,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1796
1405
  * pagination) — function-scope consts never reach the per-render
1797
1406
  * stash, so a bare `$totalPages` renders empty.
1798
1407
  */
1799
- _resolveLiteralConst(name: string): string | null {
1408
+ private _resolveLiteralConst(name: string): string | null {
1800
1409
  const c = (this.localConstants ?? []).find(lc => lc.name === name)
1801
1410
  if (c?.value === undefined) return null
1802
1411
  const v = c.value.trim()
@@ -1806,7 +1415,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1806
1415
  return null
1807
1416
  }
1808
1417
 
1809
- _resolveStaticRecordLiteral(objectName: string, key: string): string | null {
1418
+ private _resolveStaticRecordLiteral(objectName: string, key: string): string | null {
1810
1419
  const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
1811
1420
  if (!hit) return null
1812
1421
  return hit.kind === 'number'
@@ -1814,7 +1423,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1814
1423
  : `'${hit.text.replace(/[\\']/g, m => `\\${m}`)}'`
1815
1424
  }
1816
1425
 
1817
- _resolveModuleStringConst(name: string): string | null {
1426
+ private _resolveModuleStringConst(name: string): string | null {
1818
1427
  // A loop body may bind `my $<param>` that shadows a module const of the
1819
1428
  // same name; never inline inside one (conservative — drop to `$name`).
1820
1429
  if (this.inLoop) return null
@@ -1823,7 +1432,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1823
1432
  return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
1824
1433
  }
1825
1434
 
1826
- _recordExprBF101(message: string, reason?: string): void {
1435
+ private _recordExprBF101(message: string, reason?: string): void {
1827
1436
  this.errors.push({
1828
1437
  code: 'BF101',
1829
1438
  severity: 'error',
@@ -1838,675 +1447,9 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1838
1447
  }
1839
1448
 
1840
1449
  /** Internal hook for higher-order: predicate body re-uses the filter emitter. */
1841
- _renderKolonFilterExprPublic(expr: ParsedExpr, param: string): string {
1450
+ private _renderKolonFilterExprPublic(expr: ParsedExpr, param: string): string {
1842
1451
  return this.renderKolonFilterExpr(expr, param)
1843
1452
  }
1844
1453
  }
1845
1454
 
1846
- // ===========================================================================
1847
- // ParsedExpr emitters
1848
- // ===========================================================================
1849
-
1850
- /**
1851
- * Lowering for `array-method` IR nodes — shared between the filter and
1852
- * top-level emitters so the emitted Kolon form stays consistent regardless of
1853
- * which context the chain lands in. The receiver/array helpers are the same
1854
- * runtime methods the Mojo adapter calls, invoked as `$bf.NAME(...)` on the
1855
- * Kolon `$bf` object instead of `bf->NAME`.
1856
- *
1857
- * Perl-native string ops the Mojo adapter inlines (`lc`, `uc`) have no Kolon
1858
- * builtin, so they route through dedicated runtime helpers — but those
1859
- * helpers aren't part of the validated v1 surface, so they're emitted as
1860
- * `$bf.NAME(...)` calls consistent with the rest. Array methods whose Mojo
1861
- * form relied on Perl `@{...}` deref (`join`) route through `$bf` helpers.
1862
- */
1863
- function renderArrayMethod(
1864
- method: ArrayMethod,
1865
- object: ParsedExpr,
1866
- args: ParsedExpr[],
1867
- emit: (e: ParsedExpr) => string,
1868
- ): string {
1869
- switch (method) {
1870
- case 'join': {
1871
- // Route through the runtime (`$bf.join`) rather than Kolon's builtin
1872
- // `.join`, so the JS-compat element handling (undef → empty, default
1873
- // separator) is applied consistently — same reasoning as $bf.lc / etc.
1874
- const obj = emit(object)
1875
- const sep = args.length >= 1 ? emit(args[0]) : `','`
1876
- return `$bf.join(${obj}, ${sep})`
1877
- }
1878
- case 'includes': {
1879
- const obj = emit(object)
1880
- const needle = emit(args[0])
1881
- return `$bf.includes(${obj}, ${needle})`
1882
- }
1883
- case 'indexOf':
1884
- case 'lastIndexOf': {
1885
- const fn = method === 'indexOf' ? 'index_of' : 'last_index_of'
1886
- const obj = emit(object)
1887
- const needle = emit(args[0])
1888
- return `$bf.${fn}(${obj}, ${needle})`
1889
- }
1890
- case 'at': {
1891
- const obj = emit(object)
1892
- const idx = args.length >= 1 ? emit(args[0]) : '0'
1893
- return `$bf.at(${obj}, ${idx})`
1894
- }
1895
- case 'concat': {
1896
- if (args.length === 0) {
1897
- return emit(object)
1898
- }
1899
- const a = emit(object)
1900
- const b = emit(args[0])
1901
- return `$bf.concat(${a}, ${b})`
1902
- }
1903
- case 'slice': {
1904
- const recv = emit(object)
1905
- const start = args.length >= 1 ? emit(args[0]) : '0'
1906
- // Kolon's undefined literal is `nil`, not Perl's `undef` — the
1907
- // runtime `slice` treats it as "to end".
1908
- const end = args.length >= 2 ? emit(args[1]) : 'nil'
1909
- return `$bf.slice(${recv}, ${start}, ${end})`
1910
- }
1911
- case 'reverse':
1912
- case 'toReversed': {
1913
- const recv = emit(object)
1914
- return `$bf.reverse(${recv})`
1915
- }
1916
- case 'toLowerCase': {
1917
- // Kolon has no builtin string `lc` / `uc`, so these go through the
1918
- // runtime object (consistent with $bf.includes / $bf.slice / etc.).
1919
- const recv = emit(object)
1920
- return `$bf.lc(${recv})`
1921
- }
1922
- case 'toUpperCase': {
1923
- const recv = emit(object)
1924
- return `$bf.uc(${recv})`
1925
- }
1926
- case 'trim': {
1927
- const recv = emit(object)
1928
- return `$bf.trim(${recv})`
1929
- }
1930
- case 'toFixed': {
1931
- // `.toFixed(digits?)` — `$bf.to_fixed` mirrors JS rounding +
1932
- // zero-padding (default 0 digits). #1897.
1933
- const recv = emit(object)
1934
- const digits = args.length >= 1 ? emit(args[0]) : '0'
1935
- return `$bf.to_fixed(${recv}, ${digits})`
1936
- }
1937
- case 'split': {
1938
- const recv = emit(object)
1939
- if (args.length === 0) {
1940
- return `$bf.split(${recv})`
1941
- }
1942
- const sep = emit(args[0])
1943
- if (args.length === 1) {
1944
- return `$bf.split(${recv}, ${sep})`
1945
- }
1946
- const limit = emit(args[1])
1947
- return `$bf.split(${recv}, ${sep}, ${limit})`
1948
- }
1949
- case 'startsWith':
1950
- case 'endsWith': {
1951
- const fn = method === 'startsWith' ? 'starts_with' : 'ends_with'
1952
- const recv = emit(object)
1953
- const arg = emit(args[0])
1954
- if (args.length >= 2) {
1955
- return `$bf.${fn}(${recv}, ${arg}, ${emit(args[1])})`
1956
- }
1957
- return `$bf.${fn}(${recv}, ${arg})`
1958
- }
1959
- case 'replace': {
1960
- const recv = emit(object)
1961
- const oldS = emit(args[0])
1962
- const newS = emit(args[1])
1963
- return `$bf.replace(${recv}, ${oldS}, ${newS})`
1964
- }
1965
- case 'repeat': {
1966
- const recv = emit(object)
1967
- const count = args.length === 0 ? '0' : emit(args[0])
1968
- return `$bf.repeat(${recv}, ${count})`
1969
- }
1970
- case 'padStart':
1971
- case 'padEnd': {
1972
- const fn = method === 'padStart' ? 'pad_start' : 'pad_end'
1973
- const recv = emit(object)
1974
- if (args.length === 0) {
1975
- return `$bf.${fn}(${recv}, 0)`
1976
- }
1977
- const target = emit(args[0])
1978
- if (args.length === 1) {
1979
- return `$bf.${fn}(${recv}, ${target})`
1980
- }
1981
- const pad = emit(args[1])
1982
- return `$bf.${fn}(${recv}, ${target}, ${pad})`
1983
- }
1984
- default: {
1985
- // TS-level exhaustiveness guard.
1986
- const _exhaustive: never = method
1987
- throw new Error(
1988
- `renderArrayMethod: unhandled ArrayMethod '${(_exhaustive as string)}'`,
1989
- )
1990
- }
1991
- }
1992
- }
1993
-
1994
- /**
1995
- * Shared Kolon emit for `.sort(cmp)` / `.toSorted(cmp)`. Used by both the
1996
- * filter-context emitter and the top-level emitter, plus the loop-array
1997
- * wrap in `renderLoop`. The runtime `$bf.sort` accepts a hashref opts bag and
1998
- * returns a fresh array ref.
1999
- */
2000
- function renderSortMethod(recv: string, c: SortComparator): string {
2001
- const keyHashes = c.keys.map((k) => {
2002
- const keyEntry =
2003
- k.key.kind === 'self'
2004
- ? `key_kind => 'self'`
2005
- : `key_kind => 'field', key => '${k.key.field}'`
2006
- return `{ ${keyEntry}, compare_type => '${k.type}', direction => '${k.direction}' }`
2007
- })
2008
- return `$bf.sort(${recv}, { keys => [${keyHashes.join(', ')}] })`
2009
- }
2010
-
2011
- /**
2012
- * Render a `.reduce(fn, init)` arithmetic fold as a `$bf.reduce(...)` call.
2013
- */
2014
- function renderReduceMethod(recv: string, op: ReduceOp, direction: 'left' | 'right'): string {
2015
- const keyEntry =
2016
- op.key.kind === 'self'
2017
- ? `key_kind => 'self'`
2018
- : `key_kind => 'field', key => '${op.key.field}'`
2019
- const init =
2020
- op.type === 'string'
2021
- ? `'${op.init.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
2022
- : op.init
2023
- return `$bf.reduce(${recv}, { op => '${op.op}', ${keyEntry}, type => '${op.type}', init => ${init}, direction => '${direction}' })`
2024
- }
2025
-
2026
- // `.flat(depth?)` → `$bf.flat($recv, $depth)`.
2027
- function renderFlatMethod(recv: string, depth: FlatDepth): string {
2028
- const d = depth === 'infinity' ? -1 : depth
2029
- return `$bf.flat(${recv}, ${d})`
2030
- }
2031
-
2032
- // `.flatMap(...)` → `$bf.flat_map(...)` / `$bf.flat_map_tuple(...)`.
2033
- function renderFlatMapMethod(recv: string, op: FlatMapOp): string {
2034
- const proj = op.projection
2035
- if (proj.kind === 'tuple') {
2036
- const specs = proj.elements
2037
- .map(l => (l.kind === 'self' ? `['self', '']` : `['field', '${l.field}']`))
2038
- .join(', ')
2039
- return `$bf.flat_map_tuple(${recv}, ${specs})`
2040
- }
2041
- if (proj.kind === 'self') return `$bf.flat_map(${recv}, 'self', '')`
2042
- return `$bf.flat_map(${recv}, 'field', '${proj.field}')`
2043
- }
2044
-
2045
- /**
2046
- * Parse a const initializer's source text. Returns the unescaped string value
2047
- * when the whole initializer is a single pure string literal — single/double
2048
- * quoted, or a no-substitution backtick template (no `${}`) — else `null`.
2049
- * Only such a value can be inlined byte-for-byte; template literals with
2050
- * interpolation, numbers, objects, and `Record<T,string>` maps are excluded.
2051
- */
2052
- function parsePureStringLiteral(source: string): string | null {
2053
- let s = source.trim()
2054
- // Peel a single layer of wrapping parens.
2055
- while (s.startsWith('(') && s.endsWith(')')) s = s.slice(1, -1).trim()
2056
- const quote = s[0]
2057
- if ((quote === "'" || quote === '"') && s[s.length - 1] === quote) {
2058
- const body = s.slice(1, -1)
2059
- // Reject if an unescaped matching quote appears inside (not a single
2060
- // literal then).
2061
- if (containsUnescaped(body, quote)) return null
2062
- return unescapeStringLiteralBody(body)
2063
- }
2064
- if (quote === '`' && s[s.length - 1] === '`') {
2065
- const body = s.slice(1, -1)
2066
- if (body.includes('${')) return null
2067
- if (containsUnescaped(body, '`')) return null
2068
- return unescapeStringLiteralBody(body)
2069
- }
2070
- // `[<literals>].join(' ')` module consts (e.g. Switch's `trackStateClasses`)
2071
- // → inline the flattened string byte-for-byte. See `evalStringArrayJoin`.
2072
- return evalStringArrayJoin(source)
2073
- }
2074
-
2075
- /** Whether `s` contains an unescaped occurrence of `ch`. */
2076
- function containsUnescaped(s: string, ch: string): boolean {
2077
- for (let i = 0; i < s.length; i++) {
2078
- if (s[i] === '\\') { i++; continue }
2079
- if (s[i] === ch) return true
2080
- }
2081
- return false
2082
- }
2083
-
2084
- /** Unescape a JS string-literal body's common escape sequences. */
2085
- function unescapeStringLiteralBody(s: string): string {
2086
- return s.replace(/\\(.)/g, (_, c) => {
2087
- switch (c) {
2088
- case 'n': return '\n'
2089
- case 'r': return '\r'
2090
- case 't': return '\t'
2091
- case '0': return '\0'
2092
- default: return c
2093
- }
2094
- })
2095
- }
2096
-
2097
-
2098
- /** True when `type` is the `string` primitive. */
2099
- function isStringTypeInfo(type: TypeInfo | undefined): boolean {
2100
- return type?.kind === 'primitive' && type.primitive === 'string'
2101
- }
2102
-
2103
- /** True when `initialValue` is a bare string-literal expression. */
2104
- function isBareStringLiteral(initialValue: string | undefined): boolean {
2105
- if (!initialValue) return false
2106
- const v = initialValue.trim()
2107
- return (v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))
2108
- }
2109
-
2110
- /**
2111
- * Whether a comparison operand is string-typed, so JS `===`/`!==` against it
2112
- * must lower to Perl `eq`/`ne` instead of numeric `==`/`!=`.
2113
- */
2114
- function isStringTypedOperand(expr: ParsedExpr, isStringName: (n: string) => boolean): boolean {
2115
- if (expr.kind === 'literal' && expr.literalType === 'string') return true
2116
- if (expr.kind === 'call' && expr.callee.kind === 'identifier' && expr.args.length === 0) {
2117
- return isStringName(expr.callee.name)
2118
- }
2119
- if (expr.kind === 'member' && expr.object.kind === 'identifier' && expr.object.name === 'props') {
2120
- return isStringName(expr.property)
2121
- }
2122
- return false
2123
- }
2124
-
2125
- /**
2126
- * Lowering for the predicate body of a filter / every / some / find, plus the
2127
- * same shape used by `renderBlockBodyCondition` for complex block-body
2128
- * filters. Higher-order predicates are emitted using Kolon's own scalar
2129
- * comparison operators (which delegate to Perl semantics).
2130
- *
2131
- * NOTE: Kolon has no `grep { } @{...}` form, so nested higher-order chains
2132
- * (`x.tags.filter(...).length`) inside a predicate route through the
2133
- * top-level emitter's `$bf`-helper higher-order lowering. This emitter keeps
2134
- * the scalar-comparison surface the predicates the adapter accepts actually
2135
- * use; richer nested shapes fall back to the helper or surface as BF101 via
2136
- * the top-level emitter.
2137
- */
2138
- class XslateFilterEmitter implements ParsedExprEmitter {
2139
- constructor(
2140
- private readonly param: string,
2141
- private readonly localVarMap: Map<string, string>,
2142
- private readonly isStringName: (n: string) => boolean = () => false,
2143
- ) {}
2144
-
2145
- identifier(name: string): string {
2146
- if (name === this.param) return `$${this.param}`
2147
- const signal = this.localVarMap.get(name)
2148
- if (signal) return `$${signal}`
2149
- return `$${name}`
2150
- }
2151
-
2152
- literal(value: string | number | boolean | null, literalType: LiteralType): string {
2153
- if (literalType === 'string') return `'${value}'`
2154
- if (literalType === 'boolean') return value ? '1' : '0'
2155
- if (literalType === 'null') return 'nil'
2156
- return String(value)
2157
- }
2158
-
2159
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
2160
- // `.length` — route through `$bf.length` (handles both array element
2161
- // count and string char count, JS-compatibly). Kolon's builtin `.size()`
2162
- // is array-only and faults on a string.
2163
- if (property === 'length') {
2164
- return `$bf.length(${emit(object)})`
2165
- }
2166
- // Hash field access — Kolon dot works on hash refs.
2167
- return `${emit(object)}.${property}`
2168
- }
2169
-
2170
- indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2171
- // Kolon's `[]` postfix is polymorphic (array index or hash key),
2172
- // mirroring JS — no array/hash split is needed (unlike Perl's
2173
- // `->[]` vs `->{}`). #1897 (data-table's `selected()[index]`).
2174
- return `${emit(object)}[${emit(index)}]`
2175
- }
2176
-
2177
- call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
2178
- // Signal getter calls: filter() → $filter
2179
- if (callee.kind === 'identifier' && args.length === 0) {
2180
- return `$${callee.name}`
2181
- }
2182
- return emit(callee)
2183
- }
2184
-
2185
- unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2186
- const arg = emit(argument)
2187
- if (op === '!') {
2188
- const needsParens = argument.kind === 'binary' || argument.kind === 'logical'
2189
- return needsParens ? `!(${arg})` : `!${arg}`
2190
- }
2191
- if (op === '-') return `-${arg}`
2192
- return arg
2193
- }
2194
-
2195
- binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2196
- const l = emit(left)
2197
- const r = emit(right)
2198
- // Kolon's `==` / `!=` are value-equality operators that compare strings
2199
- // and numbers correctly — unlike Perl's numeric `==` (which the Mojo
2200
- // adapter must steer around with `eq`/`ne`). Kolon has no `eq`/`ne`
2201
- // operator at all, so string comparisons stay on `==` / `!=` here.
2202
- const opMap: Record<string, string> = {
2203
- '===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
2204
- '+': '+', '-': '-', '*': '*', '/': '/',
2205
- }
2206
- return `${l} ${opMap[op] ?? op} ${r}`
2207
- }
2208
-
2209
- logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2210
- const l = emit(left)
2211
- const r = emit(right)
2212
- if (op === '&&') return `(${l} && ${r})`
2213
- if (op === '||') return `(${l} || ${r})`
2214
- return `(${l} // ${r})`
2215
- }
2216
-
2217
- higherOrder(
2218
- method: HigherOrderMethod,
2219
- object: ParsedExpr,
2220
- param: string,
2221
- predicate: ParsedExpr,
2222
- emit: (e: ParsedExpr) => string,
2223
- ): string {
2224
- // Nested higher-order inside a filter predicate has no Kolon scalar form;
2225
- // defer to the receiver so the predicate at least references a real value
2226
- // (a richer chain would surface its own diagnostic at the top level).
2227
- void method
2228
- void param
2229
- void predicate
2230
- return emit(object)
2231
- }
2232
-
2233
- arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
2234
- return `[${elements.map(emit).join(', ')}]`
2235
- }
2236
-
2237
- arrayMethod(
2238
- method: ArrayMethod,
2239
- object: ParsedExpr,
2240
- args: ParsedExpr[],
2241
- emit: (e: ParsedExpr) => string,
2242
- ): string {
2243
- return renderArrayMethod(method, object, args, emit)
2244
- }
2245
-
2246
- sortMethod(
2247
- _method: 'sort' | 'toSorted',
2248
- object: ParsedExpr,
2249
- comparator: SortComparator,
2250
- emit: (e: ParsedExpr) => string,
2251
- ): string {
2252
- return renderSortMethod(emit(object), comparator)
2253
- }
2254
-
2255
- reduceMethod(method: 'reduce' | 'reduceRight', object: ParsedExpr, reduceOp: ReduceOp, emit: (e: ParsedExpr) => string): string {
2256
- return renderReduceMethod(emit(object), reduceOp, method === 'reduceRight' ? 'right' : 'left')
2257
- }
2258
-
2259
- flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
2260
- return renderFlatMethod(emit(object), depth)
2261
- }
2262
-
2263
- flatMapMethod(object: ParsedExpr, op: FlatMapOp, emit: (e: ParsedExpr) => string): string {
2264
- return renderFlatMapMethod(emit(object), op)
2265
- }
2266
-
2267
- conditional(_test: ParsedExpr, _consequent: ParsedExpr, _alternate: ParsedExpr): string {
2268
- return '1'
2269
- }
2270
-
2271
- templateLiteral(_parts: TemplatePart[]): string {
2272
- return '1'
2273
- }
2274
-
2275
- arrowFn(_param: string, _body: ParsedExpr): string {
2276
- return '1'
2277
- }
2278
-
2279
- unsupported(_raw: string, _reason: string): string {
2280
- return '1'
2281
- }
2282
- }
2283
-
2284
- /**
2285
- * Lowering for top-level expressions whose identifiers resolve against the
2286
- * Kolon template's per-render vars (signals, props, locals introduced by `:
2287
- * my $x = ...` lines). Differs from the filter emitter mainly in
2288
- * - `.length` → `.size()` (Kolon array length),
2289
- * - `conditional` is supported (filter predicates can't return ternaries),
2290
- * - higher-order methods route through `$bf` array helpers.
2291
- */
2292
- class XslateTopLevelEmitter implements ParsedExprEmitter {
2293
- constructor(private readonly adapter: XslateAdapter) {}
2294
-
2295
- identifier(name: string): string {
2296
- // `undefined` / `null` nested inside a larger expression tree —
2297
- // Kolon `nil` (#1897).
2298
- if (name === 'undefined' || name === 'null') return 'nil'
2299
- // Inline a module-scope pure-string const (`const x = 'literal'`) — it
2300
- // never reaches the per-render stash, so a bare `$x` would render empty.
2301
- const inlined = this.adapter._resolveModuleStringConst(name)
2302
- if (inlined !== null) return inlined
2303
- // Same for a literal const of any scope (`const totalPages = 5`,
2304
- // #1897 pagination's `Page {currentPage()} of {totalPages}`).
2305
- const literalConst = this.adapter._resolveLiteralConst(name)
2306
- if (literalConst !== null) return literalConst
2307
- return `$${name}`
2308
- }
2309
-
2310
- literal(value: string | number | boolean | null, literalType: LiteralType): string {
2311
- if (literalType === 'string') return `'${value}'`
2312
- if (literalType === 'boolean') return value ? '1' : '0'
2313
- if (literalType === 'null') return 'nil'
2314
- return String(value)
2315
- }
2316
-
2317
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
2318
- // `props.x` flattens to the bare `$x` the SSR caller binds each prop to
2319
- // (props arrive as individual top-level vars, not a `$props` hashref).
2320
- if (object.kind === 'identifier' && object.name === 'props') {
2321
- return `$${property}`
2322
- }
2323
- // Static property access on a module object-literal const
2324
- // (`variantClasses.ghost`, #1897) resolves at compile time — the
2325
- // generic dot lowering below would reference a Kolon var that
2326
- // doesn't exist server-side and silently render ''.
2327
- if (object.kind === 'identifier') {
2328
- const staticValue = this.adapter._resolveStaticRecordLiteral(object.name, property)
2329
- if (staticValue !== null) return staticValue
2330
- }
2331
- const obj = emit(object)
2332
- // `.length` → `$bf.length` (array count or string char count, JS-compat);
2333
- // Kolon's builtin `.size()` is array-only and faults on a string.
2334
- if (property === 'length') return `$bf.length(${obj})`
2335
- // Kolon dot access works for hash refs.
2336
- return `${obj}.${property}`
2337
- }
2338
-
2339
- indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2340
- // Kolon's `[]` postfix is polymorphic (array index or hash key),
2341
- // mirroring JS. #1897 (data-table's `selected()[index]`).
2342
- return `${emit(object)}[${emit(index)}]`
2343
- }
2344
-
2345
- call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
2346
- // Signal getter: count() → $count
2347
- if (callee.kind === 'identifier' && args.length === 0) {
2348
- return `$${callee.name}`
2349
- }
2350
- // Env-signal method call (#1922): `searchParams().get('sort')` is a real
2351
- // method call on the per-request `$searchParams` reader object, not the
2352
- // generic dot deref `member` would emit (`$searchParams.get`, which drops
2353
- // the arg). Matches the local import binding (incl. an alias).
2354
- if (this.adapter._searchParamsLocals.size > 0) {
2355
- const sp = matchSearchParamsMethodCall(callee, args, this.adapter._searchParamsLocals)
2356
- if (sp) {
2357
- return `$searchParams.${sp.method}(${sp.args.map(emit).join(', ')})`
2358
- }
2359
- }
2360
- // Identifier-path templatePrimitive: `JSON.stringify(x)` / `Math.floor(x)`
2361
- // → `$bf.json($x)` / `$bf.floor($x)`. Args render recursively through this
2362
- // same emitter. A wrong-arity call records BF101 and returns `''`.
2363
- const path = identifierPath(callee)
2364
- const spec = path ? XSLATE_TEMPLATE_PRIMITIVES[path] : undefined
2365
- if (path && spec) {
2366
- if (args.length === spec.arity) {
2367
- return spec.emit(args.map(emit))
2368
- }
2369
- this.adapter._recordExprBF101(
2370
- `templatePrimitive '${path}' expects ${spec.arity} arg(s), got ${args.length}`,
2371
- `Call '${path}' with exactly ${spec.arity} argument(s).`,
2372
- )
2373
- return "''"
2374
- }
2375
- return emit(callee)
2376
- }
2377
-
2378
- unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2379
- const arg = emit(argument)
2380
- if (op === '!') return `!${arg}`
2381
- if (op === '-') return `-${arg}`
2382
- return arg
2383
- }
2384
-
2385
- binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2386
- const l = emit(left)
2387
- const r = emit(right)
2388
- // Kolon's `==` / `!=` are value-equality operators handling both strings
2389
- // and numbers (unlike Perl's numeric `==`, which the Mojo adapter must
2390
- // route around with `eq`/`ne`). Kolon has no `eq`/`ne` operator, so all
2391
- // equality comparisons — string or numeric — stay on `==` / `!=`.
2392
- const opMap: Record<string, string> = {
2393
- '===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
2394
- '+': '+', '-': '-', '*': '*',
2395
- }
2396
- return `${l} ${opMap[op] ?? op} ${r}`
2397
- }
2398
-
2399
- logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2400
- const l = emit(left)
2401
- const r = emit(right)
2402
- if (op === '&&') return `(${l} && ${r})`
2403
- if (op === '||') return `(${l} || ${r})`
2404
- return `(${l} // ${r})`
2405
- }
2406
-
2407
- higherOrder(
2408
- method: HigherOrderMethod,
2409
- object: ParsedExpr,
2410
- param: string,
2411
- predicate: ParsedExpr,
2412
- emit: (e: ParsedExpr) => string,
2413
- ): string {
2414
- // Higher-order array methods all take a JS arrow predicate, lowered to a
2415
- // Kolon lambda `-> $param { PRED }` (callable from Perl as a code ref), and
2416
- // go through the runtime object — consistent with the other array helpers
2417
- // ($bf.includes / $bf.slice / ...). `.find*` map to snake_case runtime
2418
- // methods (like index_of / last_index_of). The `.filter(...).map(...)`
2419
- // *loop* form is handled separately by renderLoop's inline predicate.
2420
- const arrayExpr = emit(object)
2421
- const predBody = this.adapter._renderKolonFilterExprPublic(predicate, param)
2422
- const lambda = `-> $${param} { ${predBody} }`
2423
- const fn: Record<string, string> = {
2424
- filter: 'filter',
2425
- every: 'every',
2426
- some: 'some',
2427
- find: 'find',
2428
- findIndex: 'find_index',
2429
- findLast: 'find_last',
2430
- findLastIndex: 'find_last_index',
2431
- }
2432
- if (fn[method]) return `$bf.${fn[method]}(${arrayExpr}, ${lambda})`
2433
- void predicate
2434
- void param
2435
- return emit(object)
2436
- }
2437
-
2438
- arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
2439
- return `[${elements.map(emit).join(', ')}]`
2440
- }
2441
-
2442
- arrayMethod(
2443
- method: ArrayMethod,
2444
- object: ParsedExpr,
2445
- args: ParsedExpr[],
2446
- emit: (e: ParsedExpr) => string,
2447
- ): string {
2448
- return renderArrayMethod(method, object, args, emit)
2449
- }
2450
-
2451
- sortMethod(
2452
- _method: 'sort' | 'toSorted',
2453
- object: ParsedExpr,
2454
- comparator: SortComparator,
2455
- emit: (e: ParsedExpr) => string,
2456
- ): string {
2457
- return renderSortMethod(emit(object), comparator)
2458
- }
2459
-
2460
- reduceMethod(method: 'reduce' | 'reduceRight', object: ParsedExpr, reduceOp: ReduceOp, emit: (e: ParsedExpr) => string): string {
2461
- return renderReduceMethod(emit(object), reduceOp, method === 'reduceRight' ? 'right' : 'left')
2462
- }
2463
-
2464
- flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
2465
- return renderFlatMethod(emit(object), depth)
2466
- }
2467
-
2468
- flatMapMethod(object: ParsedExpr, op: FlatMapOp, emit: (e: ParsedExpr) => string): string {
2469
- return renderFlatMapMethod(emit(object), op)
2470
- }
2471
-
2472
- conditional(
2473
- test: ParsedExpr,
2474
- consequent: ParsedExpr,
2475
- alternate: ParsedExpr,
2476
- emit: (e: ParsedExpr) => string,
2477
- ): string {
2478
- return `(${emit(test)} ? ${emit(consequent)} : ${emit(alternate)})`
2479
- }
2480
-
2481
- templateLiteral(parts: TemplatePart[], emit: (e: ParsedExpr) => string): string {
2482
- // `` `n=${count() + 1}` `` → Kolon string concatenation (`~`):
2483
- // `'n=' ~ ($count + 1)`. Kolon's `~` is the explicit concat operator.
2484
- const terms: string[] = []
2485
- for (const part of parts) {
2486
- if (part.type === 'string') {
2487
- if (part.value !== '') {
2488
- terms.push(`'${part.value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`)
2489
- }
2490
- } else {
2491
- const rendered = emit(part.expr)
2492
- const needsParens =
2493
- part.expr.kind === 'binary' ||
2494
- part.expr.kind === 'logical' ||
2495
- part.expr.kind === 'conditional'
2496
- terms.push(needsParens ? `(${rendered})` : rendered)
2497
- }
2498
- }
2499
- if (terms.length === 0) return `''`
2500
- return terms.join(' ~ ')
2501
- }
2502
-
2503
- arrowFn(_param: string, _body: ParsedExpr): string {
2504
- return "''"
2505
- }
2506
-
2507
- unsupported(_raw: string, _reason: string): string {
2508
- return "''"
2509
- }
2510
- }
2511
-
2512
1455
  export const xslateAdapter = new XslateAdapter()