@barefootjs/jsx 0.6.0 → 0.7.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 (38) hide show
  1. package/dist/adapters/parsed-expr-emitter.d.ts +14 -1
  2. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  3. package/dist/expression-parser.d.ts +137 -0
  4. package/dist/expression-parser.d.ts.map +1 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +335 -5
  8. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/plan/build-static-array-child-init.d.ts +4 -0
  10. package/dist/ir-to-client-js/plan/build-static-array-child-init.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +46 -2
  12. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/stringify/static-array-child-init.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/types.d.ts +8 -1
  15. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  16. package/dist/types.d.ts +9 -0
  17. package/dist/types.d.ts.map +1 -1
  18. package/package.json +2 -2
  19. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +7 -7
  20. package/src/__tests__/child-components-in-map.test.ts +84 -0
  21. package/src/__tests__/client-js-generation.test.ts +51 -0
  22. package/src/__tests__/compiler-stress-1244.test.ts +43 -0
  23. package/src/__tests__/expression-parser.test.ts +109 -1
  24. package/src/__tests__/foreach-client-only.test.ts +80 -0
  25. package/src/__tests__/ir-async.test.ts +64 -0
  26. package/src/__tests__/ir-dynamic-tag.test.ts +104 -0
  27. package/src/__tests__/ir-reduce-op.test.ts +51 -0
  28. package/src/__tests__/reduce-op.test.ts +201 -0
  29. package/src/adapters/parsed-expr-emitter.ts +43 -1
  30. package/src/expression-parser.ts +570 -4
  31. package/src/index.ts +1 -1
  32. package/src/ir-to-client-js/collect-elements.ts +27 -4
  33. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +55 -1
  34. package/src/ir-to-client-js/plan/static-array-child-init.ts +47 -1
  35. package/src/ir-to-client-js/stringify/static-array-child-init.ts +69 -0
  36. package/src/ir-to-client-js/types.ts +8 -1
  37. package/src/jsx-to-ir.ts +69 -0
  38. package/src/types.ts +9 -0
@@ -73,6 +73,49 @@ export type ParsedExpr =
73
73
  args: []
74
74
  comparator: SortComparator
75
75
  }
76
+ // `.reduce(fn, init)` (#1448 Tier C). Like sort, the reducer is
77
+ // extracted into a structured `ReduceOp` at parse time — the
78
+ // two-param arrow never reaches `args`, so adapters fold via a
79
+ // runtime helper instead of re-walking the callback. The accepted
80
+ // catalogue is the arithmetic-fold family only (`acc + key` /
81
+ // `acc * key`, numeric or string-concat); any other reducer body,
82
+ // or a missing initial value, falls through to `unsupported` so
83
+ // adapters surface BF101 with an @client suggestion. See
84
+ // `extractReduceOpFromTS` below.
85
+ | {
86
+ kind: 'array-method'
87
+ method: 'reduce' | 'reduceRight'
88
+ object: ParsedExpr
89
+ args: []
90
+ reduceOp: ReduceOp
91
+ }
92
+ // `.flat(depth?)` (#1448 Tier C). The flatten depth is validated and
93
+ // normalised into a structured `FlatDepth` at parse time — the literal
94
+ // never reaches `args`, so adapters fold via a runtime helper instead of
95
+ // re-inspecting the depth argument. A non-literal depth refuses with
96
+ // BF101 (the depth must be known at template time). See the `.flat` arm
97
+ // in `convertNode`.
98
+ | {
99
+ kind: 'array-method'
100
+ method: 'flat'
101
+ object: ParsedExpr
102
+ args: []
103
+ flatDepth: FlatDepth
104
+ }
105
+ // `.flatMap(fn)` value-returning field projection (#1448 Tier C). The
106
+ // callback is extracted into a structured `FlatMapOp` (self / field
107
+ // projection) at parse time, mirroring sort / reduce. The projected
108
+ // per-item value is flattened one level (flatMap = map + flat(1)).
109
+ // Array-literal / complex callbacks fall through to `unsupported`; the
110
+ // JSX-returning `.flatMap` is handled as an `IRLoop` upstream and never
111
+ // reaches here. See `extractFlatMapOpFromTS` below.
112
+ | {
113
+ kind: 'array-method'
114
+ method: 'flatMap'
115
+ object: ParsedExpr
116
+ args: []
117
+ flatMapOp: FlatMapOp
118
+ }
76
119
  | { kind: 'unsupported'; raw: string; reason: string }
77
120
 
78
121
  /**
@@ -132,6 +175,95 @@ export type SortComparator = {
132
175
  method: 'sort' | 'toSorted'
133
176
  }
134
177
 
178
+ /**
179
+ * Structured form of a JS `.reduce((acc, item) => …, init)` call,
180
+ * built once at parse time and consumed by both template adapters'
181
+ * `reduceMethod()` emit (#1448 Tier C). The shape is intentionally
182
+ * finite — only the arithmetic-fold family is lowerable in a
183
+ * declarative template; arbitrary accumulator bodies are not. See
184
+ * `extractReduceOpFromTS` for the accepted catalogue.
185
+ */
186
+ export type ReduceOp = {
187
+ // The fold operator between accumulator and per-item value. `+`
188
+ // covers numeric sums and (with a string init) string concatenation;
189
+ // `*` covers numeric products. Subtraction / division are excluded —
190
+ // they're order-sensitive and rarely written as a `.reduce`.
191
+ op: '+' | '*'
192
+ // What value each item contributes to the fold:
193
+ // { kind: 'self' } → `acc + item` (primitive array)
194
+ // { kind: 'field', field } → `acc + item.field` (struct-field accessor)
195
+ key: { kind: 'self' } | { kind: 'field'; field: string }
196
+ // Numeric fold vs string concatenation. Determined by the init
197
+ // literal's type: a number init folds numerically; a string init
198
+ // (only valid with `+`) concatenates. Both template runtimes apply
199
+ // the same coercion so their output stays byte-equal; this can
200
+ // diverge from JS for floating-point sums whose decimal expansion
201
+ // differs by runtime (rare in SSR data — integer sums agree).
202
+ type: 'numeric' | 'string'
203
+ // Decoded initial-accumulator value (never raw source). For a numeric
204
+ // fold this is TypeScript's canonical decimal form (`1_000` -> `1000`,
205
+ // `0x10` -> `16`) so `strconv.ParseFloat` / Perl agree; for a concat
206
+ // fold it's the contents of a quoted string literal (only single- or
207
+ // double-quoted `ts.StringLiteral` seeds are accepted — template
208
+ // literals and escape-carrying literals are refused at parse time, so
209
+ // the value is an escape-free single-line string, e.g. the empty
210
+ // string "" or a separator like ", "). Round-trip emitters re-quote a
211
+ // string init via `JSON.stringify`; a numeric init re-emits as-is.
212
+ init: string
213
+ // Original JS source of the reducer body (the returned expression
214
+ // for block bodies). Lets the `@client` fallback ship the user's
215
+ // exact arrow to the JS runtime.
216
+ raw: string
217
+ // The two parameter names the user wrote (e.g. `acc`/`item`, or
218
+ // `sum`/`t`). Only the `@client` fallback reads them — it binds them
219
+ // in a synthetic `(acc, item) => raw` arrow. Server-side lowering
220
+ // works off `op` / `key` / `init` and ignores them.
221
+ paramAcc: string
222
+ paramItem: string
223
+ }
224
+
225
+ /**
226
+ * Flatten depth for `.flat(depth?)` (#1448 Tier C). A finite non-negative
227
+ * integer flattens that many levels (`.flat()` defaults to `1`; a `0` or
228
+ * negative JS depth means "no flatten" → a shallow copy, normalised to
229
+ * `0` here); `'infinity'` is the `.flat(Infinity)` full-depth form. The
230
+ * depth must be a literal — a non-literal argument can't be resolved at
231
+ * template time and refuses with BF101.
232
+ */
233
+ export type FlatDepth = number | 'infinity'
234
+
235
+ /**
236
+ * A single non-computed projection leaf on the flatMap callback param —
237
+ * the item itself (`i`) or one of its fields (`i.field`). Shared by the
238
+ * scalar and tuple `FlatMapOp` projections.
239
+ */
240
+ export type FlatMapLeaf = { kind: 'self' } | { kind: 'field'; field: string }
241
+
242
+ /**
243
+ * Structured form of a value-returning `.flatMap(fn)` callback (#1448
244
+ * Tier C). The accepted catalogue:
245
+ *
246
+ * i => i → self projection (flatten one level)
247
+ * i => i.field → field projection (flatten a per-item array field)
248
+ * i => [i.a, i.b] → tuple projection (gather per-item leaves)
249
+ *
250
+ * The scalar `self` / `field` projections return a value that is then
251
+ * flattened one level (flatMap = map + flat(1)) — a non-array value is
252
+ * kept as-is, matching JS. The `tuple` projection returns an array
253
+ * literal; flat(1) removes only that literal's wrapper, so each leaf is
254
+ * appended verbatim (an array-valued leaf is NOT spread). Leaves outside
255
+ * self / field (literals, `i.a + 1`, calls, deep access) refuse with
256
+ * BF101. See `extractFlatMapOpFromTS`.
257
+ */
258
+ export type FlatMapOp = {
259
+ // What each item projects to before the one-level flatten.
260
+ projection: FlatMapLeaf | { kind: 'tuple'; elements: FlatMapLeaf[] }
261
+ // The callback param name the user wrote (for the `@client` round-trip).
262
+ param: string
263
+ // Original JS source of the callback body (for the `@client` fallback).
264
+ raw: string
265
+ }
266
+
135
267
  export type TemplatePart =
136
268
  | { type: 'string'; value: string }
137
269
  | { type: 'expression'; expr: ParsedExpr }
@@ -166,6 +298,13 @@ export interface SupportResult {
166
298
  supported: boolean
167
299
  level?: SupportLevel
168
300
  reason?: string
301
+ /**
302
+ * The `reason` already spells out the fix (the pre-compute / `@client`
303
+ * hint, or a tailored message like the `forEach` diagnostic), so adapters
304
+ * surface it as-is instead of appending their own remediation block.
305
+ * Low-level reasons (operators, comparators, predicates) leave this unset.
306
+ */
307
+ selfContained?: boolean
169
308
  }
170
309
 
171
310
  // JS Array / String prototype methods that the template-language
@@ -188,10 +327,30 @@ const UNSUPPORTED_METHODS = new Set([
188
327
  // Higher-order array methods. Seven of these (`filter`, `every`,
189
328
  // `some`, `find`, `findIndex`, `findLast`, `findLastIndex`) are
190
329
  // intercepted as `higher-order` IR before reaching this gate;
191
- // `map` is intercepted as an IRLoop. The rest stay refused — see
192
- // #1448 Tier C for the design questions.
330
+ // `map` is intercepted as an IRLoop. `reduce` / `reduceRight` stay
331
+ // listed here so the shapes the Tier C catalogue can't lower still
332
+ // refuse loudly: the `convertNode` call branch intercepts a matching
333
+ // `.reduce(fn, init)` / `.reduceRight(fn, init)` into the structured
334
+ // `array-method` + `ReduceOp` form *before* this gate (returning
335
+ // early), so only the unlowerable fall-throughs (a `.reduce(fn)` with
336
+ // no initial value, or a bare method reference) reach the gate and
337
+ // refuse. A 2-arg call whose reducer/init shape is off-catalogue
338
+ // returns an explicit `unsupported` from the call branch with a richer
339
+ // message. The rest stay refused — see #1448 Tier C for the design
340
+ // questions. `forEach` carries a tailored reason (see
341
+ // `UNSUPPORTED_METHOD_REASONS`).
342
+ // `flat` is no longer here — `.flat(depth?)` lowers via the
343
+ // `array-method` IR (structured `FlatDepth`) + `bf_flat` (Go) /
344
+ // `bf->flat` (Mojo). `flatMap` stays listed as a fallback: the
345
+ // field-projection form (`i => i` / `i => i.field`) lowers via a
346
+ // structured `FlatMapOp`. The convertNode arm intercepts EVERY
347
+ // `.flatMap(...)` call before this gate — matching shapes lower, and the
348
+ // off-catalogue / wrong-arity forms get a tailored `unsupported` reason
349
+ // there — so only a bare method *reference* (`arr.flatMap` uncalled)
350
+ // falls through to this gate. The JSX-returning `.flatMap` lowers as an
351
+ // `IRLoop` upstream. See #1448.
193
352
  'filter', 'map', 'reduce', 'reduceRight', 'every', 'some',
194
- 'forEach', 'flatMap', 'flat',
353
+ 'forEach', 'flatMap',
195
354
  // #1448 Tier A — Array methods. Each method PR adds the lowering
196
355
  // (typically a new `array-method` variant or runtime helper) and
197
356
  // removes its row here. See packages/adapter-tests/fixtures/methods/.
@@ -255,6 +414,20 @@ const UNSUPPORTED_METHODS = new Set([
255
414
  'substring', 'substr', 'match', 'matchAll', 'search',
256
415
  ])
257
416
 
417
+ // Per-method override reasons for the BF101 refusal. A method here is still
418
+ // refused via `UNSUPPORTED_METHODS` above; this only swaps the generic hint
419
+ // for a tailored one. Add a row here rather than a branch in the support gate
420
+ // when a method needs special wording.
421
+ const UNSUPPORTED_METHOD_REASONS: Record<string, string> = {
422
+ // `forEach` returns `undefined`, so the generic pre-compute / @client hint
423
+ // is misleading (renders nothing either way) — steer to `.map(...)` /
424
+ // `createEffect`. Rationale pinned in foreach-client-only.test.ts.
425
+ forEach:
426
+ `'.forEach()' returns undefined and has no template-position meaning. ` +
427
+ `Use it for side effects inside an event handler or createEffect callback ` +
428
+ `(client JS), or use '.map(...)' if you meant to render each item.`,
429
+ }
430
+
258
431
  // Methods that lower at their single-argument form but whose EXTRA
259
432
  // argument is meaningful and NOT yet lowered: the `fromIndex` of
260
433
  // `.includes` / `.indexOf` / `.lastIndexOf` (the 2-arg form) and the
@@ -446,6 +619,45 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
446
619
  if (callee.property === 'reverse' || callee.property === 'toReversed') {
447
620
  return { kind: 'array-method', method: callee.property, object: callee.object, args }
448
621
  }
622
+ // `.flat(depth?)` — flatten nested arrays `depth` levels deep
623
+ // (default 1). The depth is validated to a literal and normalised
624
+ // into a structured `FlatDepth` here: `Infinity` becomes the
625
+ // full-depth form, a 0 / negative JS depth normalises to 0 ("no
626
+ // flatten" → shallow copy), and a fractional literal truncates
627
+ // toward zero (JS ToIntegerOrInfinity). A NON-literal depth can't be
628
+ // resolved at template time, so it refuses with BF101 rather than
629
+ // emitting a broken helper call. Go uses `bf_flat`; Mojo uses
630
+ // `bf->flat`. See #1448 Tier C.
631
+ if (callee.property === 'flat') {
632
+ const depthNode = node.arguments[0]
633
+ let flatDepth: FlatDepth
634
+ if (depthNode === undefined) {
635
+ flatDepth = 1
636
+ } else if (ts.isIdentifier(depthNode) && depthNode.text === 'Infinity') {
637
+ flatDepth = 'infinity'
638
+ } else {
639
+ let n: number | undefined
640
+ if (ts.isNumericLiteral(depthNode)) {
641
+ n = Number(depthNode.text)
642
+ } else if (
643
+ ts.isPrefixUnaryExpression(depthNode) &&
644
+ depthNode.operator === ts.SyntaxKind.MinusToken &&
645
+ ts.isNumericLiteral(depthNode.operand)
646
+ ) {
647
+ n = -Number(depthNode.operand.text)
648
+ }
649
+ if (n === undefined || Number.isNaN(n)) {
650
+ return {
651
+ kind: 'unsupported',
652
+ raw,
653
+ reason: `\`.flat(depth)\` needs a literal integer or \`Infinity\` depth — a computed depth can't be resolved at template time. Use a literal depth, or pre-compute the value before the template.`,
654
+ }
655
+ }
656
+ const truncated = Math.trunc(n)
657
+ flatDepth = truncated < 0 ? 0 : truncated
658
+ }
659
+ return { kind: 'array-method', method: 'flat', object: callee.object, args: [], flatDepth }
660
+ }
449
661
  // `.toLowerCase()` — string-only (the IR carries a value-builtin
450
662
  // tag, not a receiver-type discriminator, so the `array-method`
451
663
  // label is a misnomer for string methods but the mechanical
@@ -633,6 +845,85 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
633
845
  `Wrap the call in /* @client */ to evaluate at hydration.`,
634
846
  }
635
847
  }
848
+
849
+ // `.reduce(fn, init)` / `.reduceRight(fn, init)` (#1448 Tier C).
850
+ // The reducer + init are extracted into a structured `ReduceOp` at
851
+ // parse time; the two-param arrow never reaches the standard
852
+ // convertNode path (which refuses it), so we read the raw TS AST.
853
+ // Only the arithmetic-fold catalogue lowers — anything else, or a
854
+ // missing init, falls through to `unsupported` (BF101 + @client
855
+ // hint). `reduceRight` shares the catalogue; the method name is
856
+ // preserved so adapters fold right-to-left (only observable for
857
+ // string concatenation — numeric sum / product are commutative).
858
+ if (
859
+ (callee.property === 'reduce' || callee.property === 'reduceRight') &&
860
+ node.arguments.length === 2
861
+ ) {
862
+ const reduceOp = extractReduceOpFromTS(node.arguments[0], node.arguments[1])
863
+ if (reduceOp) {
864
+ return {
865
+ kind: 'array-method',
866
+ method: callee.property,
867
+ object: callee.object,
868
+ args: [],
869
+ reduceOp,
870
+ }
871
+ }
872
+ const m = callee.property
873
+ return {
874
+ kind: 'unsupported',
875
+ raw,
876
+ reason:
877
+ `Reduce shape not supported. Accepted (arithmetic fold, explicit init):\n` +
878
+ ` arr.${m}((acc, x) => acc + x, 0)\n` +
879
+ ` arr.${m}((acc, x) => acc + x.field, 0)\n` +
880
+ ` arr.${m}((acc, x) => acc * x.field, 1)\n` +
881
+ ` arr.${m}((acc, x) => acc + x.field, '') (string concat)\n` +
882
+ `The accumulator must be the left operand and the initial ` +
883
+ `value a number / string literal. ` +
884
+ `Wrap the call in /* @client */ to evaluate at hydration.`,
885
+ }
886
+ }
887
+ // A `.reduce(fn)` without an initial value can't be lowered: JS
888
+ // throws on an empty array, which a template can't mirror. Fall
889
+ // through to the BF101 gate with the @client escape hatch.
890
+
891
+ // `.flatMap(fn)` value-returning projection (#1448 Tier C). The
892
+ // callback is extracted into a structured `FlatMapOp` (self / field
893
+ // scalar projection, or an array-literal tuple of self / field
894
+ // leaves) from the raw TS AST. The JSX-returning form is handled as
895
+ // an `IRLoop` upstream and never reaches here; richer callbacks
896
+ // refuse with BF101 + the @client hint. Go uses `bf_flat_map` /
897
+ // `bf_flat_map_tuple`; Mojo uses `bf->flat_map` / `bf->flat_map_tuple`.
898
+ // Intercept EVERY `.flatMap(...)` call (not just the 1-arg form) so
899
+ // the off-catalogue and wrong-arity shapes get this tailored reason
900
+ // rather than the generic "flatMap has no template lowering" gate
901
+ // message, which now misleads (the field-projection form does lower).
902
+ if (callee.property === 'flatMap') {
903
+ const flatMapOp =
904
+ node.arguments.length === 1 ? extractFlatMapOpFromTS(node.arguments[0]) : null
905
+ if (flatMapOp) {
906
+ return {
907
+ kind: 'array-method',
908
+ method: 'flatMap',
909
+ object: callee.object,
910
+ args: [],
911
+ flatMapOp,
912
+ }
913
+ }
914
+ return {
915
+ kind: 'unsupported',
916
+ raw,
917
+ reason:
918
+ `flatMap shape not supported. Accepted (self / field leaves, no thisArg):\n` +
919
+ ` arr.flatMap(i => i) (flatten one level)\n` +
920
+ ` arr.flatMap(i => i.field) (flatten a per-item array field)\n` +
921
+ ` arr.flatMap(i => [i.a, i.b]) (gather per-item fields)\n` +
922
+ `Richer callbacks (computed / nested access, arithmetic, calls, ` +
923
+ `literal elements) and the 2-arg \`flatMap(fn, thisArg)\` form ` +
924
+ `aren't lowered. Wrap the call in /* @client */ to evaluate at hydration.`,
925
+ }
926
+ }
636
927
  }
637
928
 
638
929
  return { kind: 'call', callee, args }
@@ -1219,6 +1510,218 @@ function classifySortOperand(
1219
1510
  return null
1220
1511
  }
1221
1512
 
1513
+ /**
1514
+ * Recover a `ReduceOp` from the `(reducer, init)` args of
1515
+ * `.reduce(...)` (#1448 Tier C). Operates on the raw TS AST because the
1516
+ * standard `convertNode` arrow-fn path rejects two-param arrows.
1517
+ *
1518
+ * The accepted catalogue is intentionally finite — only the
1519
+ * arithmetic-fold family lowers to a declarative template:
1520
+ *
1521
+ * (acc, x) => acc + x → self, numeric (init: number)
1522
+ * (acc, x) => acc + x.field → field, numeric (init: number)
1523
+ * (acc, x) => acc * x → self, numeric (init: number)
1524
+ * (acc, x) => acc * x.field → field, numeric (init: number)
1525
+ * (acc, x) => acc + x → self, string (init: string → concat)
1526
+ * (acc, x) => acc + x.field → field, string (init: string → concat)
1527
+ *
1528
+ * The accumulator must be the binary expression's *left* operand
1529
+ * (canonical reduce form; reversed operands change string-concat
1530
+ * order), the per-item value must be the item param itself or a
1531
+ * single non-computed field access on it, and the init must be a
1532
+ * number or string literal (negative numbers via prefix `-` allowed).
1533
+ * String concatenation requires `+`. Block bodies reduce to a single
1534
+ * `return`, mirroring the sort extractor. Anything else returns null
1535
+ * and the caller emits `unsupported` (BF101).
1536
+ */
1537
+ export function extractReduceOpFromTS(
1538
+ reducerNode: ts.Node,
1539
+ initNode: ts.Node,
1540
+ ): ReduceOp | null {
1541
+ const init = classifyReduceInit(initNode)
1542
+ if (!init) return null
1543
+
1544
+ if (!ts.isArrowFunction(reducerNode) && !ts.isFunctionExpression(reducerNode)) return null
1545
+ // Exactly `(acc, item)` — the index / array reducer params can't be
1546
+ // expressed in a template fold, so refuse the 3- / 4-param forms.
1547
+ if (reducerNode.parameters.length !== 2) return null
1548
+ const pAcc = reducerNode.parameters[0]
1549
+ const pItem = reducerNode.parameters[1]
1550
+ if (!ts.isIdentifier(pAcc.name) || !ts.isIdentifier(pItem.name)) return null
1551
+ const paramAcc = pAcc.name.text
1552
+ const paramItem = pItem.name.text
1553
+
1554
+ // Resolve the reducer body: expression-bodied arrow directly; block
1555
+ // bodies (arrow `=> { … }` and function expressions) must reduce to
1556
+ // exactly one `return <expr>;` — mirrors `extractSortComparatorFromTS`.
1557
+ let body: ts.Expression
1558
+ if (ts.isArrowFunction(reducerNode) && !ts.isBlock(reducerNode.body)) {
1559
+ body = reducerNode.body
1560
+ } else {
1561
+ const block = reducerNode.body as ts.Block
1562
+ const stmts = block.statements
1563
+ if (stmts.length !== 1 || !ts.isReturnStatement(stmts[0]) || !stmts[0].expression) return null
1564
+ body = stmts[0].expression
1565
+ }
1566
+ const raw = body.getText()
1567
+
1568
+ const expr = unwrapParens(body)
1569
+ if (!ts.isBinaryExpression(expr)) return null
1570
+ let op: '+' | '*'
1571
+ if (expr.operatorToken.kind === ts.SyntaxKind.PlusToken) op = '+'
1572
+ else if (expr.operatorToken.kind === ts.SyntaxKind.AsteriskToken) op = '*'
1573
+ else return null
1574
+
1575
+ // The accumulator must be the left operand (`acc + x`, not `x + acc`).
1576
+ const left = unwrapParens(expr.left)
1577
+ if (!ts.isIdentifier(left) || left.text !== paramAcc) return null
1578
+
1579
+ const key = classifyReduceKey(unwrapParens(expr.right), paramItem)
1580
+ if (!key) return null
1581
+
1582
+ // String concatenation only makes sense with `+`.
1583
+ const type: 'numeric' | 'string' = init.type
1584
+ if (type === 'string' && op !== '+') return null
1585
+
1586
+ return { op, key, type, init: init.value, raw, paramAcc, paramItem }
1587
+ }
1588
+
1589
+ /**
1590
+ * Recover a `FlatMapOp` from the single-argument callback of a
1591
+ * value-returning `.flatMap(fn)` (#1448 Tier C). Operates on the raw TS
1592
+ * AST, mirroring `extractReduceOpFromTS`.
1593
+ *
1594
+ * The accepted catalogue:
1595
+ *
1596
+ * i => i → self (flatMap(identity) === flat(1))
1597
+ * i => i.field → field (flatten a per-item array field)
1598
+ * i => [i.a, i.b] → tuple (gather per-item self / field leaves)
1599
+ *
1600
+ * The callback must take exactly one identifier param (the index / array
1601
+ * params can't be expressed in a template projection), and the body must
1602
+ * be the param itself, a single non-computed field access on it, or an
1603
+ * array literal whose every element is one of those leaves. Block bodies
1604
+ * reduce to a single `return`, like the reduce / sort extractors. Any
1605
+ * other body (deep access, computed members, calls, arithmetic, a
1606
+ * literal element) returns null and the caller emits `unsupported`
1607
+ * (BF101).
1608
+ */
1609
+ export function extractFlatMapOpFromTS(cbNode: ts.Node): FlatMapOp | null {
1610
+ if (!ts.isArrowFunction(cbNode) && !ts.isFunctionExpression(cbNode)) return null
1611
+ // Exactly `(item)` — a `(item, index)` / `(item, index, array)` callback
1612
+ // can't be lowered to a declarative projection.
1613
+ if (cbNode.parameters.length !== 1) return null
1614
+ const p = cbNode.parameters[0]
1615
+ if (!ts.isIdentifier(p.name)) return null
1616
+ const param = p.name.text
1617
+
1618
+ let body: ts.Expression
1619
+ if (ts.isArrowFunction(cbNode) && !ts.isBlock(cbNode.body)) {
1620
+ body = cbNode.body
1621
+ } else {
1622
+ const block = cbNode.body as ts.Block
1623
+ const stmts = block.statements
1624
+ if (stmts.length !== 1 || !ts.isReturnStatement(stmts[0]) || !stmts[0].expression) return null
1625
+ body = stmts[0].expression
1626
+ }
1627
+ const raw = body.getText()
1628
+ const inner = unwrapParens(body)
1629
+
1630
+ // Array-literal body → tuple projection. Every element must be a
1631
+ // self / field leaf; a literal / computed / nested element refuses the
1632
+ // whole shape (the per-item evaluation of richer expressions isn't
1633
+ // lowered). flat(1) removes only the literal's wrapper, so each leaf is
1634
+ // appended verbatim — handled by the `bf_flat_map_tuple` runtime.
1635
+ if (ts.isArrayLiteralExpression(inner)) {
1636
+ // An empty tuple (`i => []`) is a degenerate no-op projection (always
1637
+ // yields nothing). Refuse it so the emitters never produce a
1638
+ // zero-arg `bf_flat_map_tuple` / `bf->flat_map_tuple(...,)` call.
1639
+ if (inner.elements.length === 0) return null
1640
+ const elements: FlatMapLeaf[] = []
1641
+ for (const el of inner.elements) {
1642
+ // Spread / holes (`[...xs]`, `[, x]`) aren't leaves.
1643
+ if (ts.isSpreadElement(el) || ts.isOmittedExpression(el)) return null
1644
+ const leaf = classifyReduceKey(unwrapParens(el), param)
1645
+ if (!leaf) return null
1646
+ elements.push(leaf)
1647
+ }
1648
+ return { projection: { kind: 'tuple', elements }, param, raw }
1649
+ }
1650
+
1651
+ // Scalar body. Reuse the reduce key classifier — `i` → self,
1652
+ // `i.field` → field, null for anything deeper (`i.a.b`, `i[k]`, a call).
1653
+ const leaf = classifyReduceKey(inner, param)
1654
+ if (!leaf) return null
1655
+
1656
+ return { projection: leaf, param, raw }
1657
+ }
1658
+
1659
+ /**
1660
+ * Classify a reduce per-item operand into a `ReduceOp` key. Accepts
1661
+ * the bare item param (`x` → self) and a single non-computed field
1662
+ * access (`x.field` → field); returns null for anything deeper
1663
+ * (`x.a.b`, `x[k]`, a literal, a call, …).
1664
+ */
1665
+ function classifyReduceKey(
1666
+ expr: ts.Expression,
1667
+ paramItem: string,
1668
+ ): { kind: 'self' } | { kind: 'field'; field: string } | null {
1669
+ if (ts.isIdentifier(expr)) {
1670
+ return expr.text === paramItem ? { kind: 'self' } : null
1671
+ }
1672
+ if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.expression)) {
1673
+ if (expr.expression.text === paramItem) return { kind: 'field', field: expr.name.text }
1674
+ }
1675
+ return null
1676
+ }
1677
+
1678
+ /**
1679
+ * Classify a reduce initial-value node into the *decoded* fold seed.
1680
+ * Accepts a numeric literal (optionally prefixed with `-`) and a string
1681
+ * literal; returns `{ type, value }` where `value` is the canonical
1682
+ * value — never the raw source text. Any other init (a variable, a
1683
+ * call, an object) returns null — the fold start value must be
1684
+ * statically known.
1685
+ *
1686
+ * Numeric: `node.text` is TypeScript's canonical decimal form, so
1687
+ * separators and non-decimal radices fold uniformly across adapters
1688
+ * (`1_000` → `1000`, `0x10` → `16`, `1e3` → `1000`). The Go runtime's
1689
+ * `strconv.ParseFloat` and Perl both accept that decimal string —
1690
+ * passing the raw source (`0x10`, `1_000`) would silently fold to 0 on
1691
+ * Go while Perl accepted it (#1728 review).
1692
+ *
1693
+ * String: `node.text` is the *unescaped* contents. To keep the three
1694
+ * adapters byte-equal without teaching each one to re-decode JS escapes
1695
+ * (`\n`, `\u{…}`, `\\`, an escaped quote), we refuse any string literal
1696
+ * whose contents differ from its raw inner source — i.e. any literal
1697
+ * carrying an escape sequence. Accepted seeds are therefore escape-free
1698
+ * single-line strings (`''`, `', '`, `'-'`), which embed safely in both
1699
+ * the Go-template `"…"` operand and the Perl single-quoted literal. The
1700
+ * realistic concat seed (`''`) is unaffected; richer seeds fall back to
1701
+ * the `@client` escape hatch.
1702
+ */
1703
+ function classifyReduceInit(
1704
+ node: ts.Node,
1705
+ ): { type: 'numeric' | 'string'; value: string } | null {
1706
+ // Unwrap redundant parens (`(0)` / `(-1)`) so they classify like the
1707
+ // bare literal — matches the extractor's `unwrapParens` use elsewhere.
1708
+ let n: ts.Node = unwrapParens(node as ts.Expression)
1709
+ // `-1` parses as a prefix-minus over a numeric literal.
1710
+ if (ts.isPrefixUnaryExpression(n) && n.operator === ts.SyntaxKind.MinusToken) {
1711
+ if (ts.isNumericLiteral(n.operand)) return { type: 'numeric', value: '-' + n.operand.text }
1712
+ return null
1713
+ }
1714
+ if (ts.isNumericLiteral(n)) return { type: 'numeric', value: n.text }
1715
+ if (ts.isStringLiteral(n)) {
1716
+ // Refuse literals carrying escapes so the decoded value equals its
1717
+ // raw inner source (and thus embeds safely + byte-equal everywhere).
1718
+ const raw = n.getText()
1719
+ if (raw.length < 2 || raw.slice(1, -1) !== n.text) return null
1720
+ return { type: 'string', value: n.text }
1721
+ }
1722
+ return null
1723
+ }
1724
+
1222
1725
  /**
1223
1726
  * Per-binding entry stored in `fieldMap`: the dotted path from the
1224
1727
  * synthetic param down to the field, plus an optional default
@@ -1767,6 +2270,22 @@ function substituteDestructuredFields(
1767
2270
  // enclosing destructure). Preserve verbatim.
1768
2271
  return { kind: 'array-method', method: e.method, object: walk(e.object), args: [], comparator: e.comparator }
1769
2272
  }
2273
+ if (e.method === 'reduce' || e.method === 'reduceRight') {
2274
+ // `ReduceOp` is a structured value referencing its own
2275
+ // paramAcc / paramItem, never the enclosing destructure —
2276
+ // preserve verbatim, same as the sort comparator above.
2277
+ return { kind: 'array-method', method: e.method, object: walk(e.object), args: [], reduceOp: e.reduceOp }
2278
+ }
2279
+ if (e.method === 'flat') {
2280
+ // `flatDepth` is a normalised literal — no destructure refs to
2281
+ // substitute. Preserve verbatim, same as sort / reduce above.
2282
+ return { kind: 'array-method', method: 'flat', object: walk(e.object), args: [], flatDepth: e.flatDepth }
2283
+ }
2284
+ if (e.method === 'flatMap') {
2285
+ // `FlatMapOp` references its own callback param, never the
2286
+ // enclosing destructure — preserve verbatim, like reduce / sort.
2287
+ return { kind: 'array-method', method: 'flatMap', object: walk(e.object), args: [], flatMapOp: e.flatMapOp }
2288
+ }
1770
2289
  return { kind: 'array-method', method: e.method, object: walk(e.object), args: e.args.map(walk) }
1771
2290
  case 'literal':
1772
2291
  case 'unsupported':
@@ -1918,11 +2437,17 @@ function checkSupport(expr: ParsedExpr): SupportResult {
1918
2437
  // This handles the case where the pattern wasn't recognized as higher-order
1919
2438
  if (expr.callee.kind === 'member') {
1920
2439
  const methodName = expr.callee.property
2440
+ // No template lowering → BF101. `UNSUPPORTED_METHOD_REASONS` supplies
2441
+ // a tailored reason for some methods (e.g. `forEach`); the rest get the
2442
+ // generic hint. Both already carry next steps, hence `selfContained`.
1921
2443
  if (UNSUPPORTED_METHODS.has(methodName)) {
1922
2444
  return {
1923
2445
  supported: false,
1924
2446
  level: 'L5_UNSUPPORTED',
1925
- reason: `Method '${methodName}()' has no template lowering and requires client-side evaluation. Wrap the expression in /* @client */ to defer it to hydration, or pre-compute the value before rendering.`,
2447
+ selfContained: true,
2448
+ reason:
2449
+ UNSUPPORTED_METHOD_REASONS[methodName] ??
2450
+ `'${methodName}()' can't render on the server. Pre-compute the value, or add /* @client */ for client-only (no SSR).`,
1926
2451
  }
1927
2452
  }
1928
2453
  }
@@ -2233,6 +2758,24 @@ export function exprToString(expr: ParsedExpr): string {
2233
2758
  const { paramA, paramB, raw } = expr.comparator
2234
2759
  return `${exprToString(expr.object)}.${expr.method}((${paramA},${paramB}) => ${raw})`
2235
2760
  }
2761
+ if (expr.method === 'reduce' || expr.method === 'reduceRight') {
2762
+ const { paramAcc, paramItem, raw, type, init } = expr.reduceOp
2763
+ // `init` is the decoded value: re-quote a string seed, re-emit a
2764
+ // numeric seed as-is (it's already a valid number literal).
2765
+ const initSrc = type === 'string' ? JSON.stringify(init) : init
2766
+ return `${exprToString(expr.object)}.${expr.method}((${paramAcc},${paramItem}) => ${raw}, ${initSrc})`
2767
+ }
2768
+ if (expr.method === 'flat') {
2769
+ // Preserve the normalised depth so diagnostics don't misleadingly
2770
+ // print `.flat()` for a `.flat(2)` / `.flat(Infinity)` source.
2771
+ const d = expr.flatDepth
2772
+ const depthSrc = d === 'infinity' ? 'Infinity' : String(d)
2773
+ return `${exprToString(expr.object)}.flat(${d === 1 ? '' : depthSrc})`
2774
+ }
2775
+ if (expr.method === 'flatMap') {
2776
+ const { param, raw } = expr.flatMapOp
2777
+ return `${exprToString(expr.object)}.flatMap(${param} => ${raw})`
2778
+ }
2236
2779
  return `${exprToString(expr.object)}.${expr.method}(${expr.args.map(exprToString).join(', ')})`
2237
2780
  case 'unsupported':
2238
2781
  return `[UNSUPPORTED: ${expr.raw}]`
@@ -2295,6 +2838,29 @@ export function stringifyParsedExpr(expr: ParsedExpr): string {
2295
2838
  const { paramA, paramB, raw } = expr.comparator
2296
2839
  return `${stringifyParsedExpr(expr.object)}.${expr.method}((${paramA},${paramB}) => ${raw})`
2297
2840
  }
2841
+ if (expr.method === 'reduce' || expr.method === 'reduceRight') {
2842
+ // Round-trip the user's param names + init so downstream
2843
+ // re-parsers (the CSR / Hono JS path, templatePrimitive
2844
+ // substitution) see valid JS — `raw` references the names
2845
+ // verbatim. `init` is the decoded value: re-quote a string
2846
+ // seed via JSON.stringify, re-emit a numeric seed as-is.
2847
+ const { paramAcc, paramItem, raw, type, init } = expr.reduceOp
2848
+ const initSrc = type === 'string' ? JSON.stringify(init) : init
2849
+ return `${stringifyParsedExpr(expr.object)}.${expr.method}((${paramAcc},${paramItem}) => ${raw}, ${initSrc})`
2850
+ }
2851
+ if (expr.method === 'flat') {
2852
+ // Round-trip the normalised depth back to JS for the CSR / Hono
2853
+ // path: `'infinity'` → `Infinity`, `1` is left implicit (`.flat()`).
2854
+ const d = expr.flatDepth
2855
+ const depthSrc = d === 'infinity' ? 'Infinity' : String(d)
2856
+ return `${stringifyParsedExpr(expr.object)}.flat(${d === 1 ? '' : depthSrc})`
2857
+ }
2858
+ if (expr.method === 'flatMap') {
2859
+ // Round-trip the user's callback param + body so the CSR / Hono
2860
+ // path re-parses valid JS (`raw` references the param verbatim).
2861
+ const { param, raw } = expr.flatMapOp
2862
+ return `${stringifyParsedExpr(expr.object)}.flatMap(${param} => ${raw})`
2863
+ }
2298
2864
  return `${stringifyParsedExpr(expr.object)}.${expr.method}(${expr.args.map(stringifyParsedExpr).join(', ')})`
2299
2865
  case 'unsupported':
2300
2866
  return expr.raw
package/src/index.ts CHANGED
@@ -245,7 +245,7 @@ export { ErrorCodes, createError, formatError, generateCodeFrame } from './error
245
245
 
246
246
  // Expression Parser
247
247
  export { parseExpression, isSupported, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, containsHigherOrder } from './expression-parser'
248
- export type { ParsedExpr, ParsedStatement, SortComparator, SortKey, SupportLevel, SupportResult, TemplatePart } from './expression-parser'
248
+ export type { ParsedExpr, ParsedStatement, SortComparator, SortKey, ReduceOp, FlatDepth, FlatMapOp, FlatMapLeaf, SupportLevel, SupportResult, TemplatePart } from './expression-parser'
249
249
  export { buildLoopChainExpr } from './loop-chain'
250
250
  export type { LoopChainInputs } from './loop-chain'
251
251