@barefootjs/jsx 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.
- package/dist/adapters/env-signal.d.ts +38 -15
- package/dist/adapters/env-signal.d.ts.map +1 -1
- package/dist/adapters/jsx-adapter.d.ts.map +1 -1
- package/dist/adapters/parsed-expr-emitter.d.ts +7 -6
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/analyzer-context.d.ts +29 -1
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/builtin-lowering-plugins.d.ts +34 -0
- package/dist/builtin-lowering-plugins.d.ts.map +1 -0
- package/dist/expression-parser.d.ts +219 -163
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +9 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6892 -6118
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/build-declaration-emit.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts +9 -0
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/lowering-registry.d.ts +122 -0
- package/dist/lowering-registry.d.ts.map +1 -0
- package/dist/profiler.d.ts +115 -0
- package/dist/profiler.d.ts.map +1 -1
- package/dist/query-href-lowering.d.ts +63 -0
- package/dist/query-href-lowering.d.ts.map +1 -0
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/types.d.ts +169 -11
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +68 -3
- package/src/__tests__/analyzer.test.ts +53 -0
- package/src/__tests__/expression-parser.test.ts +703 -391
- package/src/__tests__/ir-reduce-op.test.ts +18 -21
- package/src/__tests__/ir-sort-comparator.test.ts +19 -20
- package/src/__tests__/lowering-registry.test.ts +141 -0
- package/src/__tests__/primitive-resolver-alias.test.ts +23 -0
- package/src/__tests__/profiler.test.ts +149 -0
- package/src/__tests__/query-href-recognition.test.ts +58 -0
- package/src/__tests__/serialize-parsed-expr.test.ts +204 -0
- package/src/__tests__/unsupported-expression.test.ts +98 -4
- package/src/adapters/env-signal.ts +60 -21
- package/src/adapters/jsx-adapter.ts +17 -0
- package/src/adapters/parsed-expr-emitter.ts +39 -41
- package/src/analyzer-context.ts +72 -27
- package/src/analyzer.ts +226 -9
- package/src/builtin-lowering-plugins.ts +54 -0
- package/src/expression-parser.ts +1183 -927
- package/src/index.ts +35 -3
- package/src/ir-to-client-js/csr-substitute.ts +5 -0
- package/src/ir-to-client-js/plan/build-declaration-emit.ts +16 -0
- package/src/ir-to-client-js/plan/declaration-emit.ts +9 -0
- package/src/ir-to-client-js/stringify/declaration-emit.ts +11 -0
- package/src/jsx-to-ir.ts +182 -43
- package/src/lowering-registry.ts +160 -0
- package/src/profiler.ts +328 -0
- package/src/query-href-lowering.ts +147 -0
- package/src/ssr-defaults.ts +5 -1
- package/src/types.ts +171 -12
- package/src/__tests__/flatmap-support.test.ts +0 -218
- 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,
|
|
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'
|
|
@@ -308,6 +331,7 @@ export {
|
|
|
308
331
|
formatWastedReReruns,
|
|
309
332
|
analyzeBatchAdvisor,
|
|
310
333
|
formatBatchAdvisor,
|
|
334
|
+
evaluateProfileGates,
|
|
311
335
|
} from './profiler.ts'
|
|
312
336
|
export type {
|
|
313
337
|
StaticBudget,
|
|
@@ -335,6 +359,14 @@ export type {
|
|
|
335
359
|
BatchAdvisorResult,
|
|
336
360
|
BatchCandidate,
|
|
337
361
|
BatchSafety,
|
|
362
|
+
ProfileSeverity,
|
|
363
|
+
ProfileStatus,
|
|
364
|
+
AgentFinding,
|
|
365
|
+
ScenarioGuidance,
|
|
366
|
+
GateName,
|
|
367
|
+
GateConfig,
|
|
368
|
+
GateCheck,
|
|
369
|
+
GateResult,
|
|
338
370
|
} from './profiler.ts'
|
|
339
371
|
|
|
340
372
|
// Reactive profile — findings layer (#1690 dogfood: Bug A/C/D fixes, batch-candidate dedup,
|
|
@@ -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,
|
|
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
|
-
|
|
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:
|
|
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.
|
|
2323
|
-
*
|
|
2324
|
-
*
|
|
2325
|
-
*
|
|
2326
|
-
*
|
|
2327
|
-
*
|
|
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
|
-
|
|
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
|
|
2342
|
-
if (
|
|
2343
|
-
//
|
|
2344
|
-
//
|
|
2345
|
-
|
|
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:
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
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:
|
|
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.
|