@barefootjs/jsx 0.16.0 → 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 (57) hide show
  1. package/dist/adapters/env-signal.d.ts +38 -15
  2. package/dist/adapters/env-signal.d.ts.map +1 -1
  3. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  4. package/dist/adapters/parsed-expr-emitter.d.ts +7 -6
  5. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  6. package/dist/analyzer-context.d.ts +29 -1
  7. package/dist/analyzer-context.d.ts.map +1 -1
  8. package/dist/analyzer.d.ts.map +1 -1
  9. package/dist/builtin-lowering-plugins.d.ts +34 -0
  10. package/dist/builtin-lowering-plugins.d.ts.map +1 -0
  11. package/dist/expression-parser.d.ts +219 -163
  12. package/dist/expression-parser.d.ts.map +1 -1
  13. package/dist/index.d.ts +7 -4
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +6754 -6129
  16. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/plan/build-declaration-emit.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/plan/declaration-emit.d.ts +9 -0
  19. package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
  20. package/dist/jsx-to-ir.d.ts.map +1 -1
  21. package/dist/lowering-registry.d.ts +122 -0
  22. package/dist/lowering-registry.d.ts.map +1 -0
  23. package/dist/query-href-lowering.d.ts +63 -0
  24. package/dist/query-href-lowering.d.ts.map +1 -0
  25. package/dist/ssr-defaults.d.ts.map +1 -1
  26. package/dist/types.d.ts +169 -11
  27. package/dist/types.d.ts.map +1 -1
  28. package/package.json +2 -2
  29. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +68 -3
  30. package/src/__tests__/analyzer.test.ts +53 -0
  31. package/src/__tests__/expression-parser.test.ts +703 -391
  32. package/src/__tests__/ir-reduce-op.test.ts +18 -21
  33. package/src/__tests__/ir-sort-comparator.test.ts +19 -20
  34. package/src/__tests__/lowering-registry.test.ts +141 -0
  35. package/src/__tests__/primitive-resolver-alias.test.ts +23 -0
  36. package/src/__tests__/query-href-recognition.test.ts +58 -0
  37. package/src/__tests__/serialize-parsed-expr.test.ts +204 -0
  38. package/src/__tests__/unsupported-expression.test.ts +98 -4
  39. package/src/adapters/env-signal.ts +60 -21
  40. package/src/adapters/jsx-adapter.ts +17 -0
  41. package/src/adapters/parsed-expr-emitter.ts +39 -41
  42. package/src/analyzer-context.ts +72 -27
  43. package/src/analyzer.ts +226 -9
  44. package/src/builtin-lowering-plugins.ts +54 -0
  45. package/src/expression-parser.ts +1183 -927
  46. package/src/index.ts +26 -3
  47. package/src/ir-to-client-js/csr-substitute.ts +5 -0
  48. package/src/ir-to-client-js/plan/build-declaration-emit.ts +16 -0
  49. package/src/ir-to-client-js/plan/declaration-emit.ts +9 -0
  50. package/src/ir-to-client-js/stringify/declaration-emit.ts +11 -0
  51. package/src/jsx-to-ir.ts +182 -43
  52. package/src/lowering-registry.ts +160 -0
  53. package/src/query-href-lowering.ts +147 -0
  54. package/src/ssr-defaults.ts +5 -1
  55. package/src/types.ts +171 -12
  56. package/src/__tests__/flatmap-support.test.ts +0 -218
  57. package/src/__tests__/reduce-op.test.ts +0 -201
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ export type {
41
41
  IRProp,
42
42
  ParamInfo,
43
43
  PropertyInfo,
44
+ MemoInfo,
44
45
  TypeInfo,
45
46
  TypeDefinition,
46
47
  SourceLocation,
@@ -76,7 +77,29 @@ export type { JsxAdapterConfig } from './adapters/jsx-adapter.ts'
76
77
  export { rewriteImportsForTemplate } from './adapters/template-imports.ts'
77
78
  export { emitParsedExpr } from './adapters/parsed-expr-emitter.ts'
78
79
  export type { ParsedExprEmitter, HigherOrderMethod, ArrayMethod, SortMethod, LiteralType } from './adapters/parsed-expr-emitter.ts'
79
- export { importsSearchParams, searchParamsLocalNames, matchSearchParamsMethodCall } from './adapters/env-signal.ts'
80
+ export { importsSearchParams, searchParamsLocalNames, queryHrefLocalNames, matchSearchParamsMethodCall } from './adapters/env-signal.ts'
81
+ export { matchQueryHrefCall, queryHrefArgs, type QueryHrefCall, type QueryHrefTriple } from './query-href-lowering.ts'
82
+ export {
83
+ registerLoweringPlugin,
84
+ getLoweringPlugins,
85
+ prepareLoweringMatchers,
86
+ matchLoweringCall,
87
+ __resetLoweringPluginsForTest,
88
+ type LoweringPlugin,
89
+ type LoweringNode,
90
+ type LoweringTriple,
91
+ type LoweringMatcher,
92
+ } from './lowering-registry.ts'
93
+ export {
94
+ queryHrefPlugin,
95
+ registerBuiltinLoweringPlugins,
96
+ BUILTIN_LOWERING_PLUGINS,
97
+ } from './builtin-lowering-plugins.ts'
98
+ // Register the built-in lowering plugins (queryHref, …) into the shared registry
99
+ // on load, so every adapter that imports @barefootjs/jsx recognises them with no
100
+ // explicit setup — queryHref is a default-applied plugin, not an adapter branch.
101
+ import { registerBuiltinLoweringPlugins as __registerBuiltins } from './builtin-lowering-plugins.ts'
102
+ __registerBuiltins()
80
103
  export { emitIRNode } from './adapters/ir-node-emitter.ts'
81
104
  export type { IRNodeEmitter, EmitIRNode } from './adapters/ir-node-emitter.ts'
82
105
  export { emitAttrValue } from './adapters/attr-value-emitter.ts'
@@ -253,9 +276,9 @@ export {
253
276
  export { ErrorCodes, createError, formatError, generateCodeFrame } from './errors.ts'
254
277
 
255
278
  // Expression Parser
256
- export { parseExpression, isSupported, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, containsHigherOrder, extractArrowBodyExpression, parseStyleObjectEntries, parseProviderObjectLiteral, type ProviderObjectMember } from './expression-parser.ts'
279
+ export { parseExpression, tsNodeToParsedExpr, asCallbackMethodCall, CALLBACK_METHODS, sortComparatorFromArrow, serializeParsedExpr, freeVarsInBody, isSupported, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, parseBlockBodyTolerant, foldBlockToExpr, predicateTernaryToLogical, containsHigherOrder, extractArrowBodyExpression, parseStyleObjectEntries, parseProviderObjectLiteral, type ProviderObjectMember, type FoldBlockOptions } from './expression-parser.ts'
257
280
  export type { StyleObjectEntry } from './expression-parser.ts'
258
- export type { ParsedExpr, ParsedStatement, SortComparator, SortKey, ReduceOp, FlatDepth, FlatMapOp, FlatMapLeaf, SupportLevel, SupportResult, TemplatePart } from './expression-parser.ts'
281
+ export type { ParsedExpr, ObjectLiteralProperty, ParsedStatement, SortComparator, SortKey, FlatDepth, SupportLevel, SupportResult, TemplatePart } from './expression-parser.ts'
259
282
  export { buildLoopChainExpr } from './loop-chain.ts'
260
283
  export type { LoopChainInputs } from './loop-chain.ts'
261
284
  export { isLowerableObjectRestDestructure } from './loop-destructure.ts'
@@ -380,6 +380,11 @@ export function buildSignalMemoEnv(
380
380
  ): CsrEnv {
381
381
  const substitutions = new Map<string, CsrSubstitution>()
382
382
  for (const s of signals) {
383
+ // Env signals (#2057) have no static initial value to bake — their getter
384
+ // is a live request-scoped read (`searchParams().get(k)`). Leave it in the
385
+ // CSR template as a real call, exactly as the pre-#2057 bare `searchParams()`
386
+ // import was (it was never a substitutable signal).
387
+ if (s.envReader) continue
383
388
  substitutions.set(s.getter, {
384
389
  kind: 'call',
385
390
  replacement: normalizeSignalInitial(s, propsObjectName),
@@ -13,6 +13,7 @@ import type { Declaration } from '../declaration-sort.ts'
13
13
  import type { ClientJsContext } from '../types.ts'
14
14
  import type { ParamInfo, SignalInfo } from '../../types.ts'
15
15
  import { inferDefaultValue, PROPS_PARAM } from '../utils.ts'
16
+ import { ENV_SIGNAL_CLIENT_FACTORY } from '../../adapters/env-signal.ts'
16
17
  import type {
17
18
  ControlledSignalEffectPlan,
18
19
  DeclarationEmitPlan,
@@ -85,6 +86,21 @@ function buildSignalPlan(
85
86
  ctx: ClientJsContext,
86
87
  lookups: DeclarationEmitLookups,
87
88
  ): SignalEmitPlan {
89
+ if (signal.envReader) {
90
+ // Emit the factory as written (alias / namespace aware, #2057), falling back
91
+ // to the canonical name if the callee text wasn't captured.
92
+ const factory = signal.envFactory ?? ENV_SIGNAL_CLIENT_FACTORY[signal.envReader]
93
+ if (factory) {
94
+ return {
95
+ kind: 'signal',
96
+ getter: signal.getter,
97
+ setter: signal.setter,
98
+ initialValueExpr: '',
99
+ controlledEffect: null,
100
+ initializerOverride: `${factory}()`,
101
+ }
102
+ }
103
+ }
88
104
  const controlledEffect = buildControlledSignalEffect(signal, lookups)
89
105
  if (controlledEffect && ctx.profile) {
90
106
  controlledEffect.bfId = `${ctx.componentName}#effect:controlled:${signal.setter}`
@@ -54,6 +54,15 @@ export interface SignalEmitPlan {
54
54
  branchCondition?: string
55
55
  /** Profile-mode IR-aligned id, appended as the `createSignal` 2nd arg (#1690). */
56
56
  bfId?: string
57
+ /**
58
+ * When set, the full initializer expression to emit verbatim instead of
59
+ * `createSignal(<initialValueExpr>)`. Env signals (#2057) emit their own
60
+ * factory call — e.g. `createSearchParams()` — with no baked initial value,
61
+ * profile id, controlled effect, or branch condition (the tuple is a stable
62
+ * request-scoped view, not stored state). When present the stringifier emits
63
+ * `const [<getter>, <setter>] = <initializerOverride>` and nothing else.
64
+ */
65
+ initializerOverride?: string
57
66
  }
58
67
 
59
68
  export interface ControlledSignalEffectPlan {
@@ -65,6 +65,17 @@ function bfIdArg(bfId: string | undefined): string {
65
65
  }
66
66
 
67
67
  function emitSignal(lines: string[], plan: SignalEmitPlan): void {
68
+ // Env signal (#2057): emit its own factory call verbatim (`createSearchParams()`)
69
+ // — a stable request-scoped view, so no baked initial value, profile id,
70
+ // controlled effect, or branch condition applies.
71
+ if (plan.initializerOverride) {
72
+ if (plan.setter) {
73
+ lines.push(` const [${plan.getter}, ${plan.setter}] = ${plan.initializerOverride}`)
74
+ } else {
75
+ lines.push(` const [${plan.getter}] = ${plan.initializerOverride}`)
76
+ }
77
+ return
78
+ }
68
79
  const id = bfIdArg(plan.bfId)
69
80
  if (plan.branchCondition) {
70
81
  // #1414 cell #8: signal declared inside an early-return `if`-block.
package/src/jsx-to-ir.ts CHANGED
@@ -32,8 +32,9 @@ import {
32
32
  isReactiveOrigin,
33
33
  AttrValueOf,
34
34
  } from './types.ts'
35
- import { type AnalyzerContext, type MultiReturnJsxInfo, getSourceLocation } from './analyzer-context.ts'
36
- import { parseExpression, isSupported, parseBlockBody, extractSortComparatorFromTS, cssKebabCase, type ParsedExpr, type ParsedStatement, type SortComparator } from './expression-parser.ts'
35
+ import { type AnalyzerContext, type MultiReturnJsxInfo, getSourceLocation, collectReactiveGetterNames } from './analyzer-context.ts'
36
+ import { parseExpression, isSupported, parseBlockBody, foldBlockToExpr, predicateTernaryToLogical, tsNodeToParsedExpr, sortComparatorFromArrow, stringifyParsedExpr, cssKebabCase, type ParsedExpr } from './expression-parser.ts'
37
+ import type { IRLoopSort } from './types.ts'
37
38
  import { createError, ErrorCodes, internalInvariant } from './errors.ts'
38
39
  import { CLIENT_BUILTIN_SOURCE, isClientBuiltinName, type ClientBuiltinTag } from './builtins.ts'
39
40
  import { containsReactiveExpression } from './reactivity-checker.ts'
@@ -186,18 +187,25 @@ function hasLeadingClientDirective(expr: ts.Expression, sourceFile: ts.SourceFil
186
187
  return false
187
188
  }
188
189
 
190
+ /**
191
+ * The set of known reactive getter names (signal accessors + memo names) for the
192
+ * component, built once and cached on `ctx`. These reads are idempotent within a
193
+ * render, so consumers can treat `getter()` as a pure value (e.g. the block-fold
194
+ * purity oracle in {@link extractFilterPredicate}).
195
+ */
196
+ function getReactiveGetterNames(ctx: TransformContext): Set<string> {
197
+ if (!ctx._reactiveGetterNames) {
198
+ ctx._reactiveGetterNames = collectReactiveGetterNames(ctx.analyzer.signals, ctx.analyzer.memos)
199
+ }
200
+ return ctx._reactiveGetterNames
201
+ }
202
+
189
203
  /**
190
204
  * Walk an expression AST to check if it calls any known signal getter or memo.
191
205
  * Uses a pre-built Set for O(1) lookup per call expression.
192
206
  */
193
207
  function exprCallsReactiveGetters(expr: ts.Expression, ctx: TransformContext): boolean {
194
- // Build reactive name set once per component (cached on ctx)
195
- if (!ctx._reactiveGetterNames) {
196
- ctx._reactiveGetterNames = new Set<string>()
197
- for (const s of ctx.analyzer.signals) ctx._reactiveGetterNames.add(s.getter)
198
- for (const m of ctx.analyzer.memos) ctx._reactiveGetterNames.add(m.name)
199
- }
200
- const names = ctx._reactiveGetterNames
208
+ const names = getReactiveGetterNames(ctx)
201
209
 
202
210
  let found = false
203
211
  function visit(n: ts.Node) {
@@ -554,7 +562,112 @@ function makeBindingEnv(ctx: TransformContext): BindingEnvironment {
554
562
  // Main Entry Point
555
563
  // =============================================================================
556
564
 
565
+ /**
566
+ * Parse an attribute / prop / provider value expression. An inline object
567
+ * literal (`opts={{ align: 'start' }}`, `style={{ … }}`) parses as a block
568
+ * statement unless parenthesized, so a bare `{ … }` would land as `unsupported`
569
+ * instead of `object-literal` — the adapters that lower an inline object value
570
+ * (Go `objectLiteralToGoMap`, Perl `objectLiteralExprToPerlHashref`) then refuse
571
+ * it (BF101). Wrap a `{`-leading value in parens so it parses as the
572
+ * `object-literal` they expect; every other expression is unaffected (redundant
573
+ * parens are stripped on parse).
574
+ */
575
+ function parseValueExpr(trimmed: string): ParsedExpr {
576
+ return parseExpression(trimmed.startsWith('{') ? `(${trimmed})` : trimmed)
577
+ }
578
+
579
+ /**
580
+ * Attach `parsed` (`parseExpression(expr.trim())`) to every `expression` node
581
+ * in the tree, so SSR adapters emit from the structured tree instead of each
582
+ * re-parsing the string at emit time. Best-effort: a node this walk misses (or
583
+ * an empty `expr`) simply has no `parsed`, and the adapter falls back to
584
+ * parsing — so under-coverage is safe, never a behavioural change.
585
+ */
586
+ function attachParsedExpressions(node: IRNode): void {
587
+ if (node.type === 'expression') {
588
+ const trimmed = node.expr.trim()
589
+ if (trimmed) node.parsed = parseExpression(trimmed)
590
+ } else if (node.type === 'conditional' || node.type === 'if-statement') {
591
+ const trimmed = node.condition.trim()
592
+ if (trimmed) node.parsedCondition = parseExpression(trimmed)
593
+ }
594
+ // Attach `parsed` to every expression-valued attribute / prop so adapters can
595
+ // lower from the tree instead of re-parsing the string. Element attrs,
596
+ // component props (e.g. `opts={{ … }}` → Go map), and a provider's `value`
597
+ // prop all carry it; only `expression` values do (a `spread` / `template`
598
+ // value can't be the inline object literal the consumers read).
599
+ if (node.type === 'element') {
600
+ for (const attr of node.attrs) {
601
+ if (attr.value.kind === 'expression') {
602
+ const trimmed = attr.value.expr.trim()
603
+ if (trimmed) attr.value.parsed = parseValueExpr(trimmed)
604
+ } else if (attr.value.kind === 'spread') {
605
+ const trimmed = attr.value.expr.trim()
606
+ if (trimmed) attr.value.parsed = parseExpression(trimmed)
607
+ }
608
+ }
609
+ } else if (node.type === 'component') {
610
+ for (const prop of node.props) {
611
+ if (prop.value.kind === 'expression') {
612
+ const trimmed = prop.value.expr.trim()
613
+ if (trimmed) prop.value.parsed = parseValueExpr(trimmed)
614
+ }
615
+ }
616
+ } else if (node.type === 'provider') {
617
+ if (node.valueProp.value.kind === 'expression') {
618
+ const trimmed = node.valueProp.value.expr.trim()
619
+ if (trimmed) node.valueProp.value.parsed = parseValueExpr(trimmed)
620
+ }
621
+ }
622
+ switch (node.type) {
623
+ case 'element':
624
+ case 'component':
625
+ case 'fragment':
626
+ case 'provider':
627
+ for (const child of node.children) attachParsedExpressions(child)
628
+ break
629
+ case 'async':
630
+ attachParsedExpressions(node.fallback)
631
+ for (const child of node.children) attachParsedExpressions(child)
632
+ break
633
+ case 'loop': {
634
+ // Attach the parse of the SAME `array` string the adapters consume
635
+ // (the Go adapter's scalar-literal loop typing reads `loop.array` /
636
+ // `nested.loopArray`, which is exactly `loop.array`), so it can read
637
+ // the tree instead of re-parsing with `ts.createSourceFile`.
638
+ const trimmedArray = node.array.trim()
639
+ if (trimmedArray) node.arrayParsed = parseExpression(trimmedArray)
640
+ for (const child of node.children) attachParsedExpressions(child)
641
+ // Loops also hold expression nodes off the main `children` array.
642
+ if (node.childComponent) {
643
+ for (const child of node.childComponent.children) attachParsedExpressions(child)
644
+ }
645
+ for (const nested of node.nestedComponents ?? []) {
646
+ for (const child of nested.children) attachParsedExpressions(child)
647
+ }
648
+ for (const frag of node.flatMapCallback?.fragments ?? []) {
649
+ attachParsedExpressions(frag.ir)
650
+ }
651
+ break
652
+ }
653
+ case 'conditional':
654
+ attachParsedExpressions(node.whenTrue)
655
+ attachParsedExpressions(node.whenFalse)
656
+ break
657
+ case 'if-statement':
658
+ attachParsedExpressions(node.consequent)
659
+ if (node.alternate) attachParsedExpressions(node.alternate)
660
+ break
661
+ }
662
+ }
663
+
557
664
  export function jsxToIR(analyzer: AnalyzerContext): IRNode | null {
665
+ const root = buildIRRoot(analyzer)
666
+ if (root) attachParsedExpressions(root)
667
+ return root
668
+ }
669
+
670
+ function buildIRRoot(analyzer: AnalyzerContext): IRNode | null {
558
671
  // If there are conditional returns (if statements with JSX returns),
559
672
  // build an if-statement chain instead of a single node
560
673
  if (analyzer.conditionalReturns.length > 0) {
@@ -2313,58 +2426,71 @@ function isIteratorShapeCall(
2313
2426
  }
2314
2427
 
2315
2428
  type SortExtractionResult = {
2316
- result: SortComparator | null
2429
+ result: IRLoopSort | null
2317
2430
  unsupportedReason?: string
2318
2431
  }
2319
2432
 
2320
2433
  /**
2321
2434
  * Extract sort comparator info from a `.sort(cmp)` / `.toSorted(cmp)`
2322
- * callback at the chained `.sort().map()` detection site. Delegates
2323
- * to `extractSortComparatorFromTS` (#1448 Tier B) the shared shape
2324
- * also feeds the standalone `array-method` IR variant emitted by
2325
- * `expression-parser.ts`. Accepted comparator catalogue: simple
2326
- * subtraction (`a.f - b.f`, `a - b`, reverse for desc) and
2327
- * `.localeCompare` (`a.localeCompare(b)`, `a.f.localeCompare(b.f)`,
2328
- * reverse for desc).
2435
+ * callback at the chained `.sort().map()` detection site (#2018 P5). Parses the
2436
+ * callback into a generic `arrow` (params + body) and gates the loop-hoist on
2437
+ * the same finite catalogue `sortComparatorFromArrow` recognises (so a
2438
+ * comparator the localeCompare fallback can't model stays client-side, exactly
2439
+ * as before). The carried {@link IRLoopSort} feeds the SSR adapter's evaluator
2440
+ * (eval-first) and the client's JS round-trip. Accepted catalogue: subtraction
2441
+ * (`a.f - b.f`, `a - b`, reverse for desc), `.localeCompare`, and the
2442
+ * relational-ternary sign forms; any of them `||`-chained for multi-key.
2329
2443
  */
2330
2444
  function extractSortComparator(
2331
2445
  callback: ts.Expression,
2332
- method: 'sort' | 'toSorted',
2446
+ _method: 'sort' | 'toSorted',
2333
2447
  ctx: TransformContext
2334
2448
  ): SortExtractionResult {
2449
+ const unsupported = (): SortExtractionResult => {
2450
+ // Surface the OUTER callback source — users see the string they wrote.
2451
+ const raw = ctx.getJS(callback)
2452
+ return {
2453
+ result: null,
2454
+ unsupportedReason:
2455
+ `Sort comparator '${raw}' is not a supported shape. Accepted:\n` +
2456
+ ` (a, b) => a - b\n` +
2457
+ ` (a, b) => a.field - b.field\n` +
2458
+ ` (a, b) => a.localeCompare(b)\n` +
2459
+ ` (a, b) => a.field.localeCompare(b.field)\n` +
2460
+ ` (a, b) => a.field > b.field ? 1 : a.field < b.field ? -1 : 0\n` +
2461
+ ` any of the above '||'-chained for multi-key tie-breaks\n` +
2462
+ `(reverse the operands for descending order).`,
2463
+ }
2464
+ }
2335
2465
  if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) {
2336
2466
  return {
2337
2467
  result: null,
2338
2468
  unsupportedReason: 'Sort comparator must be an arrow function or function expression',
2339
2469
  }
2340
2470
  }
2341
- const result = extractSortComparatorFromTS(callback, method)
2342
- if (result) return { result }
2343
- // For the unsupported-reason message, the OUTER callback source is
2344
- // more useful to surface than the body users see the same string
2345
- // they wrote. The extractor's own `raw` (body-only) is for the
2346
- // server-side / @client lowering paths, which only run on success.
2347
- const raw = ctx.getJS(callback)
2471
+ const arrow = tsNodeToParsedExpr(callback)
2472
+ if (arrow.kind !== 'arrow' || arrow.params.length !== 2) return unsupported()
2473
+ // Gate on the same catalogue the localeCompare fallback recovers, so the
2474
+ // hoist decision (and thus the SSR/client split) is byte-for-byte unchanged.
2475
+ if (sortComparatorFromArrow(arrow) === null) return unsupported()
2348
2476
  return {
2349
- result: null,
2350
- unsupportedReason:
2351
- `Sort comparator '${raw}' is not a supported shape. Accepted:\n` +
2352
- ` (a, b) => a - b\n` +
2353
- ` (a, b) => a.field - b.field\n` +
2354
- ` (a, b) => a.localeCompare(b)\n` +
2355
- ` (a, b) => a.field.localeCompare(b.field)\n` +
2356
- `(reverse the operands for descending order).`,
2477
+ result: {
2478
+ arrow,
2479
+ paramA: arrow.params[0],
2480
+ paramB: arrow.params[1],
2481
+ raw: stringifyParsedExpr(arrow.body),
2482
+ },
2357
2483
  }
2358
2484
  }
2359
2485
 
2360
2486
  /**
2361
- * Result type for extractFilterPredicate.
2362
- * Either has an expression body (predicate) or block body (blockBody).
2487
+ * Result type for extractFilterPredicate. The predicate is always an expression:
2488
+ * a block body is normalized to one via `foldBlockToExpr` +
2489
+ * `predicateTernaryToLogical` (#2040).
2363
2490
  */
2364
2491
  type FilterPredicateResult = {
2365
2492
  param: string
2366
- predicate?: ParsedExpr // Expression body
2367
- blockBody?: ParsedStatement[] // Block body
2493
+ predicate?: ParsedExpr
2368
2494
  raw: string
2369
2495
  }
2370
2496
 
@@ -2426,7 +2552,7 @@ function extractFilterPredicate(
2426
2552
  if (parsed.kind === 'unsupported') {
2427
2553
  return { result: null, unsupportedReason: parsed.reason }
2428
2554
  }
2429
- if (parsed.kind === 'arrow-fn') {
2555
+ if (parsed.kind === 'arrow') {
2430
2556
  const support = isSupported(parsed.body)
2431
2557
  if (!support.supported) {
2432
2558
  return { result: null, unsupportedReason: support.reason }
@@ -2437,16 +2563,29 @@ function extractFilterPredicate(
2437
2563
 
2438
2564
  const param = firstParam.name.getText(ctx.sourceFile)
2439
2565
 
2440
- // Block body arrow functions: filter(t => { const f = filter(); ... })
2566
+ // Block body arrow functions: filter(t => { const f = filter(); ... }).
2567
+ // Normalize the value-producing block into a single boolean predicate
2568
+ // expression (#2040): let-inline + early-return/`if` → ternary, then the
2569
+ // boolean-context ternary → `&&`/`||` so it flows through the same expression
2570
+ // predicate path as `filter(t => !t.done)` — no per-adapter block-condition
2571
+ // renderer. Idempotent reactive getter reads (`const f = filter()`) are
2572
+ // treated as pure so a signal read on several branches still folds.
2441
2573
  if (ts.isBlock(callback.body)) {
2442
2574
  const raw = ctx.getJS(callback.body)
2443
2575
  const statements = parseBlockBody(callback.body, ctx.sourceFile, (n) => ctx.getJS(n))
2444
2576
  if (!statements) {
2445
2577
  return { result: null, unsupportedReason: 'Block body filter predicate cannot be parsed for server-side rendering' }
2446
2578
  }
2447
- // TODO: Check if all statements are supported for SSR
2448
- // For now, if parseBlockBody succeeds, we assume it's supported
2449
- return { result: { param, blockBody: statements, raw } }
2579
+ const folded = foldBlockToExpr(statements, { pureCallNames: getReactiveGetterNames(ctx) })
2580
+ if (!folded.ok) {
2581
+ return { result: null, unsupportedReason: folded.reason }
2582
+ }
2583
+ const predicate = predicateTernaryToLogical(folded.expr)
2584
+ const support = isSupported(predicate)
2585
+ if (!support.supported) {
2586
+ return { result: null, unsupportedReason: support.reason }
2587
+ }
2588
+ return { result: { param, predicate, raw } }
2450
2589
  }
2451
2590
 
2452
2591
  // Expression body: filter(t => !t.done)
@@ -2996,7 +3135,7 @@ function transformMapCall(
2996
3135
  // matches the fallback path at the bottom of this if/else chain.
2997
3136
  let arrayExpr: ts.Expression = mapSource
2998
3137
  let filterPredicate: FilterPredicateResult | undefined
2999
- let sortComparator: SortComparator | undefined
3138
+ let sortComparator: IRLoopSort | undefined
3000
3139
  let chainOrder: 'filter-sort' | 'sort-filter' | undefined
3001
3140
  let mapPreamble: string | undefined
3002
3141
  let templateMapPreamble: string | undefined
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Call-lowering plugin registry (#2057).
3
+ *
4
+ * The compiler core carries no bespoke per-API recognition branches. Instead, a
5
+ * lowering plugin *recognises* a call — by the import it comes from and its
6
+ * argument shape — and returns a **backend-neutral `LoweringNode`**. Each adapter
7
+ * renders that node in its own template syntax. This is a deliberate two-layer
8
+ * split:
9
+ *
10
+ * - **Layer 1 (this module + plugins):** adapter-agnostic. A plugin matches a
11
+ * call to a neutral node and never mentions Go/Perl/… syntax.
12
+ * - **Layer 2 (adapters):** plugin-agnostic. Each adapter has ONE renderer per
13
+ * node kind, so SSR/CSR parity is enforced once, not per plugin.
14
+ *
15
+ * Everything flows through this one seam — including first-party APIs. A
16
+ * userland package registers its plugin via {@link registerLoweringPlugin}, and
17
+ * a built-in like `queryHref` is registered by the compiler *as a default
18
+ * plugin* (see `builtin-lowering-plugins.ts`), not as an adapter branch. So an
19
+ * adapter can't tell a shipped API from a third-party one: both are just entries
20
+ * in this registry. That uniformity is the point — there is no "special" path to
21
+ * keep in sync.
22
+ *
23
+ * This is NOT the "output-rewriting hook" CLAUDE.md forbids: a plugin returns a
24
+ * structured IR node, never a rewritten output string, so the compiler's output
25
+ * stays determined by the compiler, not by whatever munges the emitted text.
26
+ */
27
+
28
+ import type { ParsedExpr } from './expression-parser.ts'
29
+ import type { IRMetadata } from './types.ts'
30
+
31
+ /**
32
+ * A backend-neutral include triple for a {@link LoweringNode} `guard-list`.
33
+ * `guard` is the conditional test of a `key: cond ? v : <omit>` include, or null
34
+ * for a plain `key: v` (included purely on value-truthiness). An adapter renders
35
+ * the guard to decide inclusion; the *runtime helper* then applies the emptiness
36
+ * / array-append rules to the value. (Structurally identical to the query-href
37
+ * lowering's own triple, which the `queryHref` plugin passes through unchanged.)
38
+ */
39
+ export interface LoweringTriple {
40
+ guard: ParsedExpr | null
41
+ key: string
42
+ value: ParsedExpr
43
+ }
44
+
45
+ /**
46
+ * A backend-neutral lowering result. Adapters render each variant in their own
47
+ * template language; the shapes carry everything a renderer needs and nothing
48
+ * adapter-specific.
49
+ */
50
+ export type LoweringNode =
51
+ /**
52
+ * A guard/key/value include list lowered to a query helper — the shape of
53
+ * `queryHref(base, { … })`. `helper` is the logical helper id (`'query'`),
54
+ * which each adapter maps to its own runtime helper (`bf_query` in go,
55
+ * `bf->query` in mojo, `$bf.query` in xslate). Each triple's `guard` controls
56
+ * inclusion; the runtime helper then applies the non-empty / array-append
57
+ * rules to the value (so an included-but-empty value is dropped and array
58
+ * members are appended), matching the client `queryHref` exactly. Adapters
59
+ * MUST switch on `helper` — a `guard-list` is not implicitly `query`.
60
+ */
61
+ | { kind: 'guard-list'; helper: string; base: ParsedExpr; triples: LoweringTriple[] }
62
+ /**
63
+ * A plain helper call `helper(...args)` — the general escape hatch for a pure
64
+ * builder that lowers to a single runtime-helper invocation. Unused today;
65
+ * present so the neutral vocabulary isn't single-purpose.
66
+ */
67
+ | { kind: 'helper-call'; helper: string; args: readonly ParsedExpr[] }
68
+
69
+ /**
70
+ * A matcher bound to one component's metadata: given a parsed call's callee +
71
+ * args, returns a neutral node or null to decline. Produced by a plugin's
72
+ * {@link LoweringPlugin.prepare} so the per-component import-name resolution runs
73
+ * once (at adapter init), not on every emit.
74
+ */
75
+ export type LoweringMatcher = (
76
+ callee: ParsedExpr,
77
+ args: readonly ParsedExpr[],
78
+ ) => LoweringNode | null
79
+
80
+ /**
81
+ * A lowering plugin. `prepare` resolves the local names its import is bound
82
+ * under in this component and returns a bound {@link LoweringMatcher}, or null
83
+ * when the component doesn't use it (so the adapter skips it entirely). A
84
+ * plugin never emits adapter syntax — only neutral nodes.
85
+ */
86
+ export interface LoweringPlugin {
87
+ /** Stable id, for dedup/diagnostics (e.g. `'my-pkg-url'`). */
88
+ name: string
89
+ prepare(metadata: IRMetadata): LoweringMatcher | null
90
+ }
91
+
92
+ const plugins: LoweringPlugin[] = []
93
+
94
+ /**
95
+ * Register a lowering plugin. Idempotent by `name` — re-registering the same
96
+ * name replaces the prior plugin, so a double side-effect import can't stack
97
+ * duplicates. First-party packages call this at module load.
98
+ */
99
+ export function registerLoweringPlugin(plugin: LoweringPlugin): void {
100
+ const existing = plugins.findIndex(p => p.name === plugin.name)
101
+ if (existing >= 0) plugins[existing] = plugin
102
+ else plugins.push(plugin)
103
+ }
104
+
105
+ /** The registered plugins, in registration order (a copy — mutating the result
106
+ * can't reorder or corrupt the registry). */
107
+ export function getLoweringPlugins(): readonly LoweringPlugin[] {
108
+ return [...plugins]
109
+ }
110
+
111
+ /**
112
+ * Bind every registered plugin to a component's metadata, returning the matchers
113
+ * that are active for it (import present). Adapters call this once at init and
114
+ * store the result, then try each matcher on the calls they lower — replacing a
115
+ * hardcoded per-API recognizer.
116
+ */
117
+ export function prepareLoweringMatchers(metadata: IRMetadata): LoweringMatcher[] {
118
+ const matchers: LoweringMatcher[] = []
119
+ for (const plugin of plugins) {
120
+ const matcher = plugin.prepare(metadata)
121
+ if (matcher) matchers.push(matcher)
122
+ }
123
+ return matchers
124
+ }
125
+
126
+ /**
127
+ * Convenience one-shot match against all registered plugins for a given
128
+ * metadata. Prefer {@link prepareLoweringMatchers} on a hot path (it resolves
129
+ * import names once); this re-resolves per call and is meant for tests / cold
130
+ * call sites.
131
+ */
132
+ export function matchLoweringCall(
133
+ callee: ParsedExpr,
134
+ args: readonly ParsedExpr[],
135
+ metadata: IRMetadata,
136
+ ): LoweringNode | null {
137
+ for (const matcher of prepareLoweringMatchers(metadata)) {
138
+ const node = matcher(callee, args)
139
+ if (node) return node
140
+ }
141
+ return null
142
+ }
143
+
144
+ /**
145
+ * Test-only: replace the registry contents wholesale. The double-underscore
146
+ * prefix marks it as an internal seam — tests use it to restore global state in
147
+ * `afterEach` so a sample plugin can't leak into other suites. Never call from
148
+ * production code.
149
+ */
150
+ export function __resetLoweringPluginsForTest(next: readonly LoweringPlugin[] = []): void {
151
+ plugins.length = 0
152
+ plugins.push(...next)
153
+ }
154
+
155
+ // The compiler ships built-in plugins (e.g. `queryHref`) and registers them here
156
+ // by default — see `builtin-lowering-plugins.ts`, wired up on load in `index.ts`.
157
+ // Both first-party built-ins and userland plugins go through this one registry,
158
+ // so adapters have a single uniform path with no special-cased API branch. See
159
+ // the sample plugin + tests in `__tests__/lowering-registry.test.ts` for the
160
+ // guaranteed contract.