@barefootjs/jsx 0.16.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/adapters/env-signal.d.ts +73 -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/compiler.d.ts.map +1 -1
  12. package/dist/expression-parser.d.ts +264 -163
  13. package/dist/expression-parser.d.ts.map +1 -1
  14. package/dist/index.d.ts +10 -4
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +7839 -7019
  17. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/plan/build-declaration-emit.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/plan/declaration-emit.d.ts +9 -0
  20. package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
  21. package/dist/jsx-to-ir.d.ts.map +1 -1
  22. package/dist/lowering-registry.d.ts +122 -0
  23. package/dist/lowering-registry.d.ts.map +1 -0
  24. package/dist/query-href-lowering.d.ts +63 -0
  25. package/dist/query-href-lowering.d.ts.map +1 -0
  26. package/dist/ssr-defaults.d.ts.map +1 -1
  27. package/dist/ssr-seed-plan.d.ts +84 -0
  28. package/dist/ssr-seed-plan.d.ts.map +1 -0
  29. package/dist/types.d.ts +180 -11
  30. package/dist/types.d.ts.map +1 -1
  31. package/package.json +2 -2
  32. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +68 -3
  33. package/src/__tests__/analyzer.test.ts +53 -0
  34. package/src/__tests__/expression-parser.test.ts +714 -392
  35. package/src/__tests__/free-identifiers.test.ts +55 -0
  36. package/src/__tests__/ir-reduce-op.test.ts +18 -21
  37. package/src/__tests__/ir-sort-comparator.test.ts +19 -20
  38. package/src/__tests__/lowering-registry.test.ts +141 -0
  39. package/src/__tests__/materialize-getter-calls.test.ts +58 -0
  40. package/src/__tests__/primitive-resolver-alias.test.ts +23 -0
  41. package/src/__tests__/query-href-recognition.test.ts +58 -0
  42. package/src/__tests__/serialize-parsed-expr.test.ts +223 -0
  43. package/src/__tests__/ssr-seed-plan.test.ts +212 -0
  44. package/src/__tests__/unsupported-expression.test.ts +98 -4
  45. package/src/adapters/env-signal.ts +108 -21
  46. package/src/adapters/jsx-adapter.ts +17 -0
  47. package/src/adapters/parsed-expr-emitter.ts +39 -41
  48. package/src/analyzer-context.ts +72 -27
  49. package/src/analyzer.ts +226 -9
  50. package/src/builtin-lowering-plugins.ts +54 -0
  51. package/src/compiler.ts +6 -1
  52. package/src/expression-parser.ts +1375 -929
  53. package/src/index.ts +31 -3
  54. package/src/ir-to-client-js/csr-substitute.ts +5 -0
  55. package/src/ir-to-client-js/plan/build-declaration-emit.ts +16 -0
  56. package/src/ir-to-client-js/plan/declaration-emit.ts +9 -0
  57. package/src/ir-to-client-js/stringify/declaration-emit.ts +11 -0
  58. package/src/jsx-to-ir.ts +182 -43
  59. package/src/lowering-registry.ts +160 -0
  60. package/src/query-href-lowering.ts +147 -0
  61. package/src/ssr-defaults.ts +5 -1
  62. package/src/ssr-seed-plan.ts +146 -0
  63. package/src/types.ts +182 -12
  64. package/src/__tests__/flatmap-support.test.ts +0 -218
  65. package/src/__tests__/reduce-op.test.ts +0 -201
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Backend-neutral destructuring of a recognised `queryHref(base, { … })` call
3
+ * (#2042) into a base expression plus include triples, shared by the SSR
4
+ * adapters' query lowering.
5
+ *
6
+ * `queryHref` is the pure functional URL-query builder (the counterpart to
7
+ * `searchParams()`); its call + object literal are already structured IR, so an
8
+ * adapter lowers it to its query helper without any block-body recognition or
9
+ * re-parse. This module only does the structural match — turning the object
10
+ * literal's properties into `{ guard, key, value }` triples — leaving each
11
+ * adapter to format the include condition and the helper call in its own
12
+ * template language.
13
+ *
14
+ * Inclusion is truthy-omit over string values (matching the client `queryHref`'s
15
+ * `if (value)`): a plain `key: v` is included iff `v` is a non-empty string
16
+ * (`guard: null`); a conditional `key: cond ? a : <undefined|null|''>` is
17
+ * included iff `cond` AND `a` is non-empty (`guard: cond`, `value: a`).
18
+ */
19
+
20
+ import type { ParsedExpr } from './expression-parser.ts'
21
+
22
+ export interface QueryHrefTriple {
23
+ /**
24
+ * The conditional test of a `key: cond ? a : <omit>` include, or null for a
25
+ * plain `key: v` (which is included purely on value-truthiness). An adapter
26
+ * combines this with the value's non-emptiness to form the include condition.
27
+ */
28
+ guard: ParsedExpr | null
29
+ /** The literal search-param key. */
30
+ key: string
31
+ /** The value expression (the consequent for a conditional include). */
32
+ value: ParsedExpr
33
+ }
34
+
35
+ export interface QueryHrefCall {
36
+ base: ParsedExpr
37
+ triples: QueryHrefTriple[]
38
+ }
39
+
40
+ /**
41
+ * Match a `queryHref(base, { … })` call from its callee + args, returning the
42
+ * base and include triples, or null when it isn't a `queryHref` call with a
43
+ * plain object-literal second argument (→ the adapter falls back to its generic
44
+ * lowering). `localNames` are the bindings `queryHref` is imported under (from
45
+ * `queryHrefLocalNames`).
46
+ */
47
+ export function matchQueryHrefCall(
48
+ callee: ParsedExpr,
49
+ args: readonly ParsedExpr[],
50
+ localNames: ReadonlySet<string>,
51
+ ): QueryHrefCall | null {
52
+ if (callee.kind !== 'identifier' || !localNames.has(callee.name)) return null
53
+ if (args.length !== 2) return null
54
+ const [base, obj] = args
55
+ // A dynamic (non-literal) params object can't be lowered to static include
56
+ // triples — fall back to the generic lowering.
57
+ if (obj.kind !== 'object-literal') return null
58
+
59
+ const triples: QueryHrefTriple[] = []
60
+ for (const p of obj.properties) {
61
+ const v = p.value
62
+ if (v.kind === 'conditional' && isOmitBranch(v.alternate)) {
63
+ triples.push({ guard: v.test, key: p.key, value: v.consequent })
64
+ } else {
65
+ triples.push({ guard: null, key: p.key, value: v })
66
+ }
67
+ }
68
+ return { base, triples }
69
+ }
70
+
71
+ /**
72
+ * Format a {@link QueryHrefCall} as the flat argument list for a guard-list
73
+ * query helper (`bf->query(base, guard, key, value, …)` in Mojo / `$bf.query(…)`
74
+ * in Xslate — the two adapters whose helper does the non-empty check itself).
75
+ * Each triple contributes a guard (`'1'` for a plain include, or the lowered
76
+ * condition for a conditional one), the key as a string literal, and the value —
77
+ * all lowered through the adapter's `emit`. The caller wraps the result in its
78
+ * own `<helper>(…)` call. (The go-template adapter folds the non-empty check
79
+ * into the include condition itself, so it formats its own form instead.)
80
+ *
81
+ * A conditional guard that is NOT already boolean-shaped (a bare value, a member
82
+ * access, `&&`/`||`) is JS *string* truthiness — `'0'` is a truthy string in JS
83
+ * but false under Perl's `unless`. To keep SSR byte-identical to the client (and
84
+ * to the go adapter, whose `lowerUrlGuard` does the same), such a guard is
85
+ * normalised to a `guard !== ''` test, emitted against a string literal so each
86
+ * adapter renders string `ne`, not numeric `!=`. Comparisons / `!x` / boolean
87
+ * literals already yield a real boolean and pass through unchanged.
88
+ */
89
+ export function queryHrefArgs(q: QueryHrefCall, emit: (e: ParsedExpr) => string): string[] {
90
+ const out = [emit(q.base)]
91
+ for (const t of q.triples) {
92
+ if (t.guard === null) {
93
+ out.push('1')
94
+ } else if (isBoolShapeGuard(t.guard)) {
95
+ out.push(`(${emit(t.guard)})`)
96
+ } else {
97
+ const test: ParsedExpr = {
98
+ kind: 'binary',
99
+ op: '!==',
100
+ left: t.guard,
101
+ right: { kind: 'literal', value: '', literalType: 'string' },
102
+ }
103
+ out.push(`(${emit(test)})`)
104
+ }
105
+ out.push(emit({ kind: 'literal', value: t.key, literalType: 'string' }))
106
+ out.push(emit(t.value))
107
+ }
108
+ return out
109
+ }
110
+
111
+ const GUARD_BOOL_OPS: ReadonlySet<string> = new Set([
112
+ '==',
113
+ '===',
114
+ '!=',
115
+ '!==',
116
+ '<',
117
+ '>',
118
+ '<=',
119
+ '>=',
120
+ ])
121
+
122
+ /**
123
+ * Whether a conditional-include guard already evaluates to a real boolean, so it
124
+ * can be emitted as-is rather than wrapped in a `!== ''` string-truthiness test.
125
+ * A comparison, a `!negation`, or a boolean literal qualifies; a bare value /
126
+ * member / `&&` / `||` does not. Mirrors the go adapter's `lowerUrlGuard`
127
+ * `isBoolShape` so the four backends agree on which guards need normalising.
128
+ */
129
+ function isBoolShapeGuard(g: ParsedExpr): boolean {
130
+ return (
131
+ (g.kind === 'binary' && GUARD_BOOL_OPS.has(g.op)) ||
132
+ (g.kind === 'unary' && g.op === '!') ||
133
+ (g.kind === 'literal' && g.literalType === 'boolean')
134
+ )
135
+ }
136
+
137
+ /**
138
+ * The falsy "omit" branch of a conditional include — `undefined` (an identifier),
139
+ * `null`, or `''` — which makes `cond ? v : <omit>` a conditional include.
140
+ */
141
+ function isOmitBranch(node: ParsedExpr): boolean {
142
+ if (node.kind === 'identifier') return node.name === 'undefined'
143
+ if (node.kind === 'literal') {
144
+ return node.literalType === 'null' || (node.literalType === 'string' && node.value === '')
145
+ }
146
+ return false
147
+ }
@@ -143,6 +143,10 @@ export function extractSsrDefaults(metadata: IRMetadata): Record<string, SsrDefa
143
143
  }
144
144
  for (const sig of metadata.signals) {
145
145
  if (!sig.getter || sig.isModule) continue
146
+ // Env signals (#2057) have no static SSR default — their value is the
147
+ // request-scoped reader, seeded by the adapter's env-signal binding, not a
148
+ // baked initial value.
149
+ if (sig.envReader) continue
146
150
  const value = tryStaticEval(sig.initialValue, { bindings, propsLike })
147
151
  out[sig.getter] = { value: resultToJsonable(value) }
148
152
  bindings[sig.getter] = value
@@ -169,7 +173,7 @@ export function extractSsrDefaults(metadata: IRMetadata): Record<string, SsrDefa
169
173
  if (metadata.propsObjectName !== null) {
170
174
  const referenced = new Set<string>()
171
175
  for (const sig of metadata.signals) {
172
- if (!sig.getter || sig.isModule) continue
176
+ if (!sig.getter || sig.isModule || sig.envReader) continue
173
177
  collectPropRefs(sig.initialValue, metadata.propsObjectName, referenced)
174
178
  }
175
179
  for (const memo of metadata.memos) {
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Backend-neutral SSR seed plan — which signals/memos an adapter may seed
3
+ * in-template at SSR time, and from what scope.
4
+ *
5
+ * Design principle: the IR/analyzer side ANALYZES and attaches structured
6
+ * information; adapters only EMIT. The "is this binding derivable from names
7
+ * already in template scope" decision used to live (triplicated) in the
8
+ * template adapters' seed paths; this module computes it once, on the IR, so
9
+ * every adapter consumes the same plan and only supplies its own syntax.
10
+ *
11
+ * Ordering / acyclicity guarantee: `steps` lists the component's signals
12
+ * first, then its memos, each group in declaration order (matching
13
+ * `IRMetadata.signals` / `IRMetadata.memos`). A binding's name only enters
14
+ * the scope set AFTER its own step is decided, so a `derived` step's `frees`
15
+ * can only name `baseScope` entries or EARLIER steps — self- and
16
+ * forward-references are rejected by construction, and a consumer emitting
17
+ * the steps top-to-bottom never reads an undeclared local.
18
+ *
19
+ * Module-scope pure-string consts count as in-scope (they are part of
20
+ * `baseScope`) because every adapter compile-time-inlines them to their
21
+ * literal value — `collectModuleStringConsts` is the shared source of that
22
+ * set — so a reference to one is never a template-variable read.
23
+ *
24
+ * A `derived` step with EMPTY `frees` is a constant expression (e.g.
25
+ * `createSignal('b')`): the plan still classifies it as derived because the
26
+ * expression is fully analyzable; emit-side constant-skipping (adapters keep
27
+ * their existing ssr-defaults seeding for such inits) is an adapter concern,
28
+ * not a plan concern. Likewise the plan makes no backend-specific choices —
29
+ * no target-variable checks, no self-shadowing rules, no per-backend shape
30
+ * catalogs — those stay in the adapters.
31
+ */
32
+
33
+ import { collectModuleStringConsts } from './augment-inherited-props.ts'
34
+ import { envSignalReaderFor, type EnvSignalReader } from './adapters/env-signal.ts'
35
+ import {
36
+ extractArrowBodyExpression,
37
+ freeIdentifiers,
38
+ isSupported,
39
+ parseExpression,
40
+ type ParsedExpr,
41
+ } from './expression-parser.ts'
42
+ import type { IRMetadata } from './types.ts'
43
+
44
+ /**
45
+ * One binding in component declaration order (signals first, then memos —
46
+ * matching `IRMetadata` order and the adapters' iteration).
47
+ *
48
+ * - `env-reader`: an env signal whose `envReader` key resolves in the shared
49
+ * registry (`envSignalReaderFor`). The runtime provides the per-request
50
+ * reader, so there is nothing to seed; the name still enters scope so a
51
+ * later derived step may reference it. An `envReader` key UNKNOWN to the
52
+ * registry falls through to the normal derived/opaque rules instead.
53
+ * - `derived`: the binding's value expression is a supported shape whose free
54
+ * identifiers are all in scope at this point (baseScope + earlier steps) —
55
+ * an adapter may seed it in-template by lowering `parsed`/`expr`.
56
+ * - `opaque`: not seedable this way (empty init, unsupported shape,
57
+ * unanalyzable free set, out-of-scope reference, or a block-bodied memo).
58
+ * The name still enters scope for later steps; adapters keep their static
59
+ * ssr-defaults seeding for it.
60
+ */
61
+ export type SsrSeedStep =
62
+ | { kind: 'env-reader'; name: string; reader: EnvSignalReader }
63
+ | { kind: 'derived'; name: string; origin: 'signal' | 'memo'; expr: string; parsed: ParsedExpr; frees: string[] }
64
+ | { kind: 'opaque'; name: string; origin: 'signal' | 'memo' }
65
+
66
+ export interface SsrSeedPlan {
67
+ /**
68
+ * Names in scope before any step: props params, the props-object name
69
+ * (when the component takes an undestructured props object), and module
70
+ * pure-string consts (compile-time inlined by every adapter).
71
+ */
72
+ baseScope: string[]
73
+ steps: SsrSeedStep[]
74
+ }
75
+
76
+ /**
77
+ * Classify one value expression against the current scope: `derived` when it
78
+ * parses to a supported shape whose free identifiers are all `available`
79
+ * (an unanalyzable free set — `freeIdentifiers` → null — fails safe to
80
+ * opaque). The scope check runs over the parsed SOURCE tree, so a shadowed
81
+ * name (`items.filter((p) => p.ok) && p`, where the trailing `p` is a
82
+ * different, unbound reference from the callback's own param) is rejected.
83
+ */
84
+ function classify(
85
+ name: string,
86
+ origin: 'signal' | 'memo',
87
+ expr: string,
88
+ parsed: ParsedExpr,
89
+ available: ReadonlySet<string>,
90
+ ): SsrSeedStep {
91
+ if (!isSupported(parsed).supported) return { kind: 'opaque', name, origin }
92
+ const frees = freeIdentifiers(parsed)
93
+ if (frees === null) return { kind: 'opaque', name, origin }
94
+ for (const free of frees) {
95
+ if (!available.has(free)) return { kind: 'opaque', name, origin }
96
+ }
97
+ return { kind: 'derived', name, origin, expr, parsed, frees: [...frees] }
98
+ }
99
+
100
+ /**
101
+ * Compute the component's SSR seed plan from its metadata. See the module
102
+ * doc for the contract. Memo steps are gated to EXPRESSION-BODIED memos
103
+ * (`extractArrowBodyExpression` returns the body): a block-bodied memo is
104
+ * `opaque` even when the analyzer folded it to a `parsed` expression.
105
+ */
106
+ export function computeSsrSeedPlan(metadata: IRMetadata): SsrSeedPlan {
107
+ const baseScope: string[] = metadata.propsParams.map(p => p.name)
108
+ if (metadata.propsObjectName) baseScope.push(metadata.propsObjectName)
109
+ for (const name of collectModuleStringConsts(metadata.localConstants).keys()) {
110
+ baseScope.push(name)
111
+ }
112
+
113
+ const available = new Set<string>(baseScope)
114
+ const steps: SsrSeedStep[] = []
115
+
116
+ for (const signal of metadata.signals) {
117
+ if (signal.envReader) {
118
+ const reader = envSignalReaderFor(signal.envReader)
119
+ if (reader) {
120
+ steps.push({ kind: 'env-reader', name: signal.getter, reader })
121
+ available.add(signal.getter)
122
+ continue
123
+ }
124
+ }
125
+ const expr = signal.initialValue.trim()
126
+ steps.push(
127
+ expr === ''
128
+ ? { kind: 'opaque', name: signal.getter, origin: 'signal' }
129
+ : classify(signal.getter, 'signal', expr, parseExpression(expr), available),
130
+ )
131
+ available.add(signal.getter)
132
+ }
133
+
134
+ for (const memo of metadata.memos) {
135
+ const body = extractArrowBodyExpression(memo.computation)
136
+ const expr = body?.trim() ?? ''
137
+ steps.push(
138
+ expr === ''
139
+ ? { kind: 'opaque', name: memo.name, origin: 'memo' }
140
+ : classify(memo.name, 'memo', expr, memo.parsed ?? parseExpression(expr), available),
141
+ )
142
+ available.add(memo.name)
143
+ }
144
+
145
+ return { baseScope, steps }
146
+ }
package/src/types.ts CHANGED
@@ -4,7 +4,26 @@
4
4
  * JSX-independent intermediate representation for multi-backend support.
5
5
  */
6
6
 
7
- import type { ParsedExpr, ParsedStatement, SortComparator } from './expression-parser.ts'
7
+ import type { ParsedExpr, ParsedStatement } from './expression-parser.ts'
8
+ import type { SsrSeedPlan } from './ssr-seed-plan.ts'
9
+
10
+ /**
11
+ * Loop-hoisted sort comparator for the `.sort().map()` / `.toSorted().map()`
12
+ * pattern (#2018 P5). Carries the generic comparator `arrow` (params + body)
13
+ * that the SSR adapter serializes to the runtime evaluator (eval-first) or, for
14
+ * a `localeCompare` comparator the evaluator can't model, recovers a structured
15
+ * comparator from via `sortComparatorFromArrow`. The `paramA` / `paramB` / `raw`
16
+ * fields round-trip the comparator to native JS for the client / CSR path
17
+ * (`(paramA, paramB) => raw`), so the client is untouched.
18
+ */
19
+ export type IRLoopSort = {
20
+ // Always the comparator arrow itself — narrowed so consumers read
21
+ // `.params` / `.body` without a defensive `kind` check or non-null assertion.
22
+ arrow: Extract<ParsedExpr, { kind: 'arrow' }>
23
+ paramA: string
24
+ paramB: string
25
+ raw: string
26
+ }
8
27
 
9
28
  // =============================================================================
10
29
  // Source Location (for Error Reporting)
@@ -323,6 +342,20 @@ export interface IRExpression {
323
342
  expr: string
324
343
  /** Pre-transformed expr with destructured prop refs rewritten to _p.xxx (for client JS templates). */
325
344
  templateExpr?: string
345
+ /**
346
+ * Structured parse of `expr` (`parseExpression(expr.trim())`), attached once
347
+ * during IR construction so SSR adapters emit from the tree instead of each
348
+ * re-parsing the string at emit time (and so a multi-adapter build parses it
349
+ * once, not per adapter). Plain serializable data.
350
+ *
351
+ * OPTIONAL by design — consumers MUST fall back to parsing `expr` when it is
352
+ * missing. It is absent for an empty/whitespace `expr`, and may also be
353
+ * absent for a node the IR-build walk doesn't reach (the walk is best-effort;
354
+ * under-coverage is a missed optimization, never a behavioural change). When
355
+ * present, an unparsable expression is a `{ kind: 'unsupported' }` node (the
356
+ * adapter's own support gate handles it).
357
+ */
358
+ parsed?: ParsedExpr
326
359
  typeInfo: TypeInfo | null
327
360
  reactive: boolean
328
361
  slotId: string | null
@@ -347,6 +380,13 @@ export interface IRConditional {
347
380
  condition: string
348
381
  /** Pre-transformed condition with destructured prop refs rewritten to _p.xxx. */
349
382
  templateCondition?: string
383
+ /**
384
+ * Structured parse of `condition` (`parseExpression(condition.trim())`),
385
+ * attached during IR construction so adapters lower the condition from the
386
+ * tree instead of re-parsing the string. Optional/best-effort — see
387
+ * `IRExpression.parsed`; consumers fall back to parsing `condition`.
388
+ */
389
+ parsedCondition?: ParsedExpr
350
390
  conditionType: TypeInfo | null
351
391
  reactive: boolean
352
392
  whenTrue: IRNode
@@ -404,6 +444,14 @@ export interface IRLoop {
404
444
  */
405
445
  method?: 'flatMap'
406
446
  array: string
447
+ /**
448
+ * Structured parse of `array` (`parseExpression(array.trim())`), attached
449
+ * during IR construction so adapters lower the loop's array from the tree
450
+ * instead of re-parsing the string (e.g. the Go adapter's scalar-literal
451
+ * loop typing). Optional/best-effort — mirrors `IRExpression.parsed`;
452
+ * consumers fall back to parsing `array`.
453
+ */
454
+ arrayParsed?: ParsedExpr
407
455
  /** Pre-transformed array expr with destructured prop refs rewritten to _p.xxx. */
408
456
  templateArray?: string
409
457
  arrayType: TypeInfo | null
@@ -471,14 +519,16 @@ export interface IRLoop {
471
519
  * When present, the loop renders with an if-condition wrapping each iteration.
472
520
  * Example: todos.filter(t => !t.done).map(...) stores { param: 't', predicate: ParsedExpr, raw: '!t.done' }
473
521
  *
474
- * For block-body filters like:
522
+ * Block-body filters like
475
523
  * filter(t => { const f = filter(); if (f === 'active') return !t.done; return true })
476
- * The blockBody field contains the parsed statements.
524
+ * are normalized to a single boolean `predicate` expression at IR-build time
525
+ * (#2040, `foldBlockToExpr` + `predicateTernaryToLogical` in `jsx-to-ir`), so
526
+ * adapters only ever see the unified expression form — there is no separate
527
+ * block-statement shape to lower.
477
528
  */
478
529
  filterPredicate?: {
479
530
  param: string
480
- predicate?: ParsedExpr // Expression body
481
- blockBody?: ParsedStatement[] // Block body (mutually exclusive with predicate)
531
+ predicate?: ParsedExpr // Boolean predicate expression (folded from any block body)
482
532
  raw: string // Original string for error messages
483
533
  }
484
534
 
@@ -487,14 +537,14 @@ export interface IRLoop {
487
537
  * When present, the loop array is sorted before iteration.
488
538
  * Example: todos.sort((a, b) => a.priority - b.priority).map(...)
489
539
  *
490
- * The structured shape carries enough info for both adapters to
491
- * emit the same `bf_sort` / `bf->sort` call (`SortComparator` is
492
- * defined in `expression-parser.ts` because the standalone
493
- * `array-method` IR variant uses the same type). The loop-hoist
494
- * path lifts a comparator off a sibling `array-method` node
495
- * during `jsx-to-ir.ts` chain detection — see `extractSortComparator`.
540
+ * The {@link IRLoopSort} struct carries the generic comparator `arrow`
541
+ * (params + body) the SSR adapter serializes to the runtime evaluator
542
+ * (eval-first; `sortComparatorFromArrow` fallback for `localeCompare`), plus
543
+ * the param names + raw body for the client JS round-trip. Lifted off the
544
+ * `.sort()` callback during `jsx-to-ir.ts` chain detection see
545
+ * `extractSortComparator`. (#2018 P5)
496
546
  */
497
- sortComparator?: SortComparator
547
+ sortComparator?: IRLoopSort
498
548
 
499
549
  /**
500
550
  * When both filter and sort are chained, indicates the order of operations.
@@ -746,6 +796,13 @@ export interface IRIfStatement {
746
796
  condition: string
747
797
  /** Pre-transformed condition with destructured prop refs rewritten to _p.xxx. */
748
798
  templateCondition?: string
799
+ /**
800
+ * Structured parse of `condition` (`parseExpression(condition.trim())`),
801
+ * attached during IR construction so adapters lower the condition from the
802
+ * tree instead of re-parsing the string. Optional/best-effort — see
803
+ * `IRExpression.parsed`; consumers fall back to parsing `condition`.
804
+ */
805
+ parsedCondition?: ParsedExpr
749
806
  /** The JSX return in the then branch */
750
807
  consequent: IRNode
751
808
  /** The else branch: either another IRIfStatement (else if) or IRNode (final else) */
@@ -839,6 +896,14 @@ export interface ExpressionAttr {
839
896
  /** `expr` with destructured prop refs rewritten to `_p.xxx`, for SSR
840
897
  * template inlining. Absent when no rewrite was needed. */
841
898
  templateExpr?: string
899
+ /**
900
+ * Structured parse of `expr` (`parseExpression(expr.trim())`), attached
901
+ * during IR construction so adapters lower the attribute value from the tree
902
+ * instead of re-parsing the string (often several times per attribute).
903
+ * Optional/best-effort — see `IRExpression.parsed`; consumers fall back to
904
+ * parsing `expr`.
905
+ */
906
+ parsed?: ParsedExpr
842
907
  /** Set when the producer peeled an `expr || undefined` boolean-presence
843
908
  * pattern; adapters fold this back into `(expr) || undefined` at emit. */
844
909
  presenceOrUndefined?: boolean
@@ -873,6 +938,16 @@ export interface SpreadAttr {
873
938
  kind: 'spread'
874
939
  expr: string
875
940
  templateExpr?: string
941
+ /**
942
+ * Structured parse of `expr` (`parseExpression(expr.trim())`), attached
943
+ * during IR construction so adapters lower the spread bag from the tree
944
+ * instead of re-parsing the string with `ts.createSourceFile`. Optional /
945
+ * best-effort — mirrors `ExpressionAttr.parsed`: it may be absent (a node the
946
+ * attach walk misses, or an empty `expr`), and parsing may yield
947
+ * `{ kind: 'unsupported' }`, which adapters treat as unlowerable and handle
948
+ * via their existing non-conditional spread paths (or BF101).
949
+ */
950
+ parsed?: ParsedExpr
876
951
  /**
877
952
  * Component-scoped, stable slot ID assigned at IR-build time for
878
953
  * adapters that need to plumb the spread bag through a structured
@@ -1039,6 +1114,14 @@ export interface SignalInfo {
1039
1114
  getter: string
1040
1115
  setter: string | null
1041
1116
  initialValue: string
1117
+ /**
1118
+ * `initialValue` parsed into a structured tree (Roadmap A). Attached
1119
+ * best-effort by the analyzer so adapters can lower a literal initial value
1120
+ * (e.g. `useState(['a', 'b'])`) from structure instead of re-parsing the
1121
+ * string with `ts.createSourceFile`. Absent when the shape isn't supported;
1122
+ * consumers fall back to parsing `initialValue`.
1123
+ */
1124
+ parsed?: ParsedExpr
1042
1125
  /** Initial value with TypeScript type annotations preserved, for .tsx output */
1043
1126
  typedInitialValue?: string
1044
1127
  type: TypeInfo
@@ -1075,6 +1158,26 @@ export interface SignalInfo {
1075
1158
  isModule?: boolean
1076
1159
  /** When true, the declaration carries an `export` keyword. */
1077
1160
  isExported?: boolean
1161
+ /**
1162
+ * Request-scoped environment-signal key when this signal was produced by an
1163
+ * env-signal factory (`createSearchParams()` → `'search'`), rather than by
1164
+ * `createSignal`. Set structurally by the analyzer (#2057) — the getter is a
1165
+ * normal reactive getter (so it lands in the fold purity oracle for free, no
1166
+ * name allow-list), but its *value* is a request-scoped reader with methods
1167
+ * (`.get(key)`), which adapters lower to their per-request reader object
1168
+ * instead of a plain template field. This flag is how adapters recognise an
1169
+ * env signal from structure instead of matching the import name.
1170
+ */
1171
+ envReader?: string
1172
+ /**
1173
+ * For an env signal (`envReader` set), the exact callee text of its factory
1174
+ * call as written — `'createSearchParams'`, an alias (`'csp'` for
1175
+ * `import { createSearchParams as csp }`), or a namespace access
1176
+ * (`'bf.createSearchParams'`). Backends that re-emit the declaration (client
1177
+ * JS, JSX/Hono SSR) emit `<envFactory>()` so the call resolves to the binding
1178
+ * actually in scope, not a hardcoded canonical name (#2057).
1179
+ */
1180
+ envFactory?: string
1078
1181
  }
1079
1182
 
1080
1183
  export interface MemoInfo {
@@ -1082,6 +1185,52 @@ export interface MemoInfo {
1082
1185
  computation: string
1083
1186
  /** Computation with TypeScript type annotations preserved, for .tsx output */
1084
1187
  typedComputation?: string
1188
+ /**
1189
+ * Structured parse of the memo's BODY as a single value expression, computed
1190
+ * once at analysis time. Lets adapters pattern-match the memo's shape on the
1191
+ * structured tree instead of re-parsing `computation` with their own AST walks
1192
+ * / regexes.
1193
+ *
1194
+ * Set for an expression-bodied arrow (`() => <body>`) whose body
1195
+ * `parseExpression` supports, AND — since #2040 — for a block-bodied memo
1196
+ * (`() => { … }`) whose statements `foldBlockToExpr` can normalize to one
1197
+ * expression (`let`-inline + value `if` / early `return` → ternary). A block
1198
+ * the fold refuses (imperative residue) or a shape `parseExpression` can't
1199
+ * represent leaves `parsed` undefined, so consumers must still fall back to
1200
+ * `parsedBlock` / `computation` when it's missing. NOTE: a present `parsed`
1201
+ * therefore no longer implies an expression-bodied arrow.
1202
+ */
1203
+ parsed?: ParsedExpr
1204
+ /**
1205
+ * Whether the memo's effective body is a template literal (`() => `…`` or a
1206
+ * block body whose first `return` is one), classified once at analysis time
1207
+ * from the real arrow AST. Lets the Go adapter pick the `string` field type
1208
+ * without re-parsing `computation` with `ts.createSourceFile`. A template
1209
+ * literal — including a no-substitution `` `plain` `` — folds to a plain
1210
+ * string `ParsedExpr` literal, losing the backtick distinction, so this is a
1211
+ * dedicated flag rather than a `parsed.kind` check.
1212
+ */
1213
+ bodyIsTemplateLiteral?: boolean
1214
+ /**
1215
+ * A block-bodied memo's statements, parsed best-effort (tolerant: a statement
1216
+ * the parser can't represent is omitted). Lets the Go adapter pattern-match
1217
+ * block-body memo shapes — e.g. the `const k = getter(); if (!k) return CONST`
1218
+ * guard — on the structured statements instead of re-parsing `computation`
1219
+ * with `ts.createSourceFile`. Absent for expression-bodied memos (those carry
1220
+ * `parsed` instead) and when the arrow has no block body.
1221
+ */
1222
+ parsedBlock?: ParsedStatement[]
1223
+ /**
1224
+ * Whether {@link parsedBlock} represents EVERY statement of the block (true)
1225
+ * or the tolerant parser omitted at least one it couldn't represent (false).
1226
+ * A consumer that must reason about the whole block — e.g. one that bails on
1227
+ * any statement it doesn't recognise (the template-literal memo lowering) —
1228
+ * reads this and falls back when it's `false`, since omitted statements are
1229
+ * otherwise invisible. Consumers that scan for a recognised prefix and ignore
1230
+ * the rest (the guard-and-return-const lowering) can disregard it. Only set
1231
+ * when `parsedBlock` is.
1232
+ */
1233
+ parsedBlockComplete?: boolean
1085
1234
  type: TypeInfo
1086
1235
  deps: string[]
1087
1236
  loc: SourceLocation
@@ -1282,6 +1431,17 @@ export interface FunctionInfo {
1282
1431
  export interface ConstantInfo {
1283
1432
  name: string
1284
1433
  value?: string
1434
+ /**
1435
+ * `value` parsed into a structured tree (Roadmap A). Attached best-effort by
1436
+ * the analyzer (parsed from the parenthesised value so a bare object literal
1437
+ * — which TS reads as a block at statement position — resolves to an
1438
+ * `object-literal` rather than failing). Lets adapters lower a constant value
1439
+ * (e.g. a module-scope record's `{ … }`) from structure instead of
1440
+ * re-parsing the string with `ts.createSourceFile`. Absent when the constant
1441
+ * has no `value` string (e.g. an inlined-JSX const) or when the analyzer
1442
+ * couldn't structure it (best-effort — consumers fall back to the string).
1443
+ */
1444
+ parsed?: ParsedExpr
1285
1445
  /** Value with TypeScript type annotations preserved, for .tsx output */
1286
1446
  typedValue?: string
1287
1447
  valueBranches?: string[]
@@ -1367,6 +1527,16 @@ export interface IRMetadata {
1367
1527
  * resolves the compiled module rather than the source `.tsx`.
1368
1528
  */
1369
1529
  clientSignalImportSources?: Set<string>
1530
+ /**
1531
+ * Backend-neutral SSR seed plan: per-binding derived/opaque/env-reader
1532
+ * classification in declaration order, plus the base scope, computed by
1533
+ * `computeSsrSeedPlan` from this metadata. Attached by `buildMetadata` and
1534
+ * serialized into IR JSON like the rest of the metadata; template adapters
1535
+ * consume it instead of re-deriving scope/derivability themselves (their
1536
+ * target-syntax choices — lowering, self-shadow rules, constant-emit
1537
+ * guards — stay adapter-side).
1538
+ */
1539
+ ssrSeedPlan?: SsrSeedPlan
1370
1540
  }
1371
1541
 
1372
1542
  // =============================================================================