@barefootjs/jsx 0.17.0 → 0.18.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 (88) hide show
  1. package/dist/adapters/env-signal.d.ts +42 -7
  2. package/dist/adapters/env-signal.d.ts.map +1 -1
  3. package/dist/adapters/interface.d.ts +20 -4
  4. package/dist/adapters/interface.d.ts.map +1 -1
  5. package/dist/adapters/parsed-expr-emitter.d.ts +3 -1
  6. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  7. package/dist/analyzer-context.d.ts.map +1 -1
  8. package/dist/analyzer.d.ts.map +1 -1
  9. package/dist/augment-inherited-props.d.ts +19 -0
  10. package/dist/augment-inherited-props.d.ts.map +1 -1
  11. package/dist/compiler.d.ts.map +1 -1
  12. package/dist/expression-parser.d.ts +48 -2
  13. package/dist/expression-parser.d.ts.map +1 -1
  14. package/dist/index.d.ts +24 -5
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1728 -969
  17. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/compute-inlinability.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  21. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  24. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/emit-registration.d.ts +4 -2
  26. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/html-template.d.ts +41 -0
  28. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/types.d.ts +22 -1
  30. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  31. package/dist/jsx-to-ir.d.ts.map +1 -1
  32. package/dist/loop-destructure.d.ts +55 -18
  33. package/dist/loop-destructure.d.ts.map +1 -1
  34. package/dist/lowering-registry.d.ts +13 -0
  35. package/dist/lowering-registry.d.ts.map +1 -1
  36. package/dist/relocate.d.ts +28 -0
  37. package/dist/relocate.d.ts.map +1 -1
  38. package/dist/ssr-defaults.d.ts.map +1 -1
  39. package/dist/ssr-seed-plan.d.ts +84 -0
  40. package/dist/ssr-seed-plan.d.ts.map +1 -0
  41. package/dist/types.d.ts +79 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/package.json +2 -2
  44. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +284 -12
  45. package/src/__tests__/augment-inherited-props.test.ts +96 -0
  46. package/src/__tests__/compiler-runtime-contract.test.ts +11 -1
  47. package/src/__tests__/compiler-stress-1244.test.ts +13 -4
  48. package/src/__tests__/csr-substitution-safety-divergence.test.ts +137 -0
  49. package/src/__tests__/destructured-map-params.test.ts +11 -1
  50. package/src/__tests__/expression-parser.test.ts +74 -3
  51. package/src/__tests__/free-identifiers.test.ts +55 -0
  52. package/src/__tests__/ir-sort-comparator.test.ts +261 -0
  53. package/src/__tests__/loop-destructure.test.ts +313 -0
  54. package/src/__tests__/loop-hoisted-template.test.ts +235 -0
  55. package/src/__tests__/materialize-getter-calls.test.ts +58 -0
  56. package/src/__tests__/props-destructuring.test.ts +110 -0
  57. package/src/__tests__/serialize-parsed-expr.test.ts +88 -2
  58. package/src/__tests__/ssr-defaults.test.ts +20 -0
  59. package/src/__tests__/ssr-seed-plan.test.ts +212 -0
  60. package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +231 -1
  61. package/src/__tests__/tagged-template-interleave.test.ts +268 -0
  62. package/src/__tests__/unsupported-expression.test.ts +194 -7
  63. package/src/adapters/env-signal.ts +57 -9
  64. package/src/adapters/interface.ts +20 -4
  65. package/src/adapters/parsed-expr-emitter.ts +19 -2
  66. package/src/analyzer-context.ts +20 -0
  67. package/src/analyzer.ts +74 -1
  68. package/src/augment-inherited-props.ts +139 -9
  69. package/src/compiler.ts +10 -1
  70. package/src/expression-parser.ts +421 -50
  71. package/src/index.ts +30 -3
  72. package/src/ir-to-client-js/collect-elements.ts +15 -1
  73. package/src/ir-to-client-js/compute-inlinability.ts +6 -1
  74. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  75. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  76. package/src/ir-to-client-js/control-flow/stringify/loop.ts +30 -8
  77. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +30 -0
  78. package/src/ir-to-client-js/emit-registration.ts +4 -2
  79. package/src/ir-to-client-js/html-template.ts +198 -1
  80. package/src/ir-to-client-js/index.ts +1 -0
  81. package/src/ir-to-client-js/types.ts +22 -0
  82. package/src/jsx-to-ir.ts +409 -24
  83. package/src/loop-destructure.ts +89 -36
  84. package/src/lowering-registry.ts +16 -0
  85. package/src/relocate.ts +201 -14
  86. package/src/ssr-defaults.ts +34 -32
  87. package/src/ssr-seed-plan.ts +146 -0
  88. package/src/types.ts +76 -0
package/src/jsx-to-ir.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  type AttrValue,
24
24
  type IRTemplatePart,
25
25
  type LoopParamBinding,
26
+ type LoopBindingPathSegment,
26
27
  type RestExcludeKey,
27
28
  type FlatMapCallback,
28
29
  type FlatMapJsxFragment,
@@ -34,7 +35,8 @@ import {
34
35
  } from './types.ts'
35
36
  import { type AnalyzerContext, type MultiReturnJsxInfo, getSourceLocation, collectReactiveGetterNames } from './analyzer-context.ts'
36
37
  import { parseExpression, isSupported, parseBlockBody, foldBlockToExpr, predicateTernaryToLogical, tsNodeToParsedExpr, sortComparatorFromArrow, stringifyParsedExpr, cssKebabCase, type ParsedExpr } from './expression-parser.ts'
37
- import type { IRLoopSort } from './types.ts'
38
+ import type { IRLoopSort, FunctionInfo } from './types.ts'
39
+ import { formatParamWithType } from './module-exports.ts'
38
40
  import { createError, ErrorCodes, internalInvariant } from './errors.ts'
39
41
  import { CLIENT_BUILTIN_SOURCE, isClientBuiltinName, type ClientBuiltinTag } from './builtins.ts'
40
42
  import { containsReactiveExpression } from './reactivity-checker.ts'
@@ -1568,6 +1570,12 @@ function transformExpressionInner(
1568
1570
  node: ts.JsxExpression,
1569
1571
  isClientOnly: boolean,
1570
1572
  ): IRNode | null {
1573
+ // #2092: `{cn\`base ${tone()}\`}` in JSX-child position — same desugar
1574
+ // as the attribute path (`getAttributeValue`), applied first so every
1575
+ // check below (JSX-constant inlining, the shared dispatcher, the
1576
+ // scalar fallback's `ctx.getJS`) sees the untagged template literal.
1577
+ expr = tryDesugarInterleaveTaggedTemplate(expr, ctx)
1578
+
1571
1579
  // Check for bare signal/memo identifier (BF044)
1572
1580
  checkBareSignalOrMemoIdentifier(expr, ctx)
1573
1581
 
@@ -2440,35 +2448,57 @@ type SortExtractionResult = {
2440
2448
  * (eval-first) and the client's JS round-trip. Accepted catalogue: subtraction
2441
2449
  * (`a.f - b.f`, `a - b`, reverse for desc), `.localeCompare`, and the
2442
2450
  * relational-ternary sign forms; any of them `||`-chained for multi-key.
2451
+ *
2452
+ * A bare identifier callback (`.sort(byPrice)`, #2090) is resolved one hop
2453
+ * through {@link resolveSortComparatorIdentifier} — a module- or
2454
+ * component-scope `const byPrice = (a, b) => …` or `function byPrice(a, b)
2455
+ * {…}` — before falling into the same arrow + catalogue gate below, so a
2456
+ * resolved reference is byte-for-byte equivalent to inlining it. Alias
2457
+ * chains (`const c2 = c1`) and imported/prop identifiers are NOT followed —
2458
+ * they surface a distinct "could not be resolved" BF021.
2443
2459
  */
2444
2460
  function extractSortComparator(
2445
2461
  callback: ts.Expression,
2446
2462
  _method: 'sort' | 'toSorted',
2447
2463
  ctx: TransformContext
2448
2464
  ): 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).`,
2465
+ // Surface the OUTER callback source — users see the string they wrote
2466
+ // (for an identifier callback, `ctx.getJS` returns just the bare name).
2467
+ const outerRaw = ctx.getJS(callback)
2468
+ const unsupported = (): SortExtractionResult => ({
2469
+ result: null,
2470
+ unsupportedReason:
2471
+ `Sort comparator '${outerRaw}' is not a supported shape. Accepted:\n` +
2472
+ ` (a, b) => a - b\n` +
2473
+ ` (a, b) => a.field - b.field\n` +
2474
+ ` (a, b) => a.localeCompare(b)\n` +
2475
+ ` (a, b) => a.field.localeCompare(b.field)\n` +
2476
+ ` (a, b) => a.field > b.field ? 1 : a.field < b.field ? -1 : 0\n` +
2477
+ ` any of the above '||'-chained for multi-key tie-breaks\n` +
2478
+ `(reverse the operands for descending order).`,
2479
+ })
2480
+
2481
+ let resolvedNode: ts.Expression = callback
2482
+ if (ts.isIdentifier(callback)) {
2483
+ const resolved = resolveSortComparatorIdentifier(callback.text, ctx)
2484
+ if (!resolved) {
2485
+ return {
2486
+ result: null,
2487
+ unsupportedReason:
2488
+ `Sort comparator '${outerRaw}' could not be resolved to a local function — ` +
2489
+ `declare it in the same file or inline it.`,
2490
+ }
2463
2491
  }
2492
+ resolvedNode = resolved
2464
2493
  }
2465
- if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) {
2494
+
2495
+ if (!ts.isArrowFunction(resolvedNode) && !ts.isFunctionExpression(resolvedNode)) {
2466
2496
  return {
2467
2497
  result: null,
2468
2498
  unsupportedReason: 'Sort comparator must be an arrow function or function expression',
2469
2499
  }
2470
2500
  }
2471
- const arrow = tsNodeToParsedExpr(callback)
2501
+ const arrow = tsNodeToParsedExpr(resolvedNode)
2472
2502
  if (arrow.kind !== 'arrow' || arrow.params.length !== 2) return unsupported()
2473
2503
  // Gate on the same catalogue the localeCompare fallback recovers, so the
2474
2504
  // hoist decision (and thus the SSR/client split) is byte-for-byte unchanged.
@@ -2483,6 +2513,47 @@ function extractSortComparator(
2483
2513
  }
2484
2514
  }
2485
2515
 
2516
+ /**
2517
+ * Resolve a bare-identifier sort comparator callback (`.sort(byPrice)`,
2518
+ * #2090) to its underlying arrow / function-expression node, ONE HOP only
2519
+ * — no alias chains (`const c2 = c1` is not followed; `c2` resolves to the
2520
+ * identifier `c1`, not a function, and is left unresolved).
2521
+ *
2522
+ * A name bound BOTH as a const and as a `function` declaration is refused
2523
+ * outright. In valid JS that collision only occurs across scopes (a
2524
+ * same-scope redeclaration is a syntax error), and `FunctionInfo` does not
2525
+ * carry the source scope — component-body `function` declarations are
2526
+ * hoisted to module scope for client emission, so its `isModule` reflects
2527
+ * EMISSION placement, not lexical position. Picking either binding would
2528
+ * risk compiling the comparator the call site can't actually see (Copilot
2529
+ * review on #2091), so the ambiguity resolves to the loud unresolved
2530
+ * BF021 instead of a guess. Within a single kind, the existing
2531
+ * shadowing-aware lookups apply (`findLocalConst` / `findLocalFunction`:
2532
+ * component scope beats module scope, last in source order); a binding
2533
+ * that isn't an arrow / function expression fails resolution without any
2534
+ * cross-kind fallback.
2535
+ *
2536
+ * Returns null when the name doesn't resolve to a local arrow /
2537
+ * function-expression — covers the cross-kind ambiguity, a non-function
2538
+ * const, an import, a prop, or a name with no local binding at all. The
2539
+ * caller surfaces BF021 either way; the specific message (off-catalogue
2540
+ * vs. unresolved) is decided by the caller, not here.
2541
+ */
2542
+ function resolveSortComparatorIdentifier(name: string, ctx: TransformContext): ts.Expression | null {
2543
+ const constInfo = findLocalConst(name, ctx)
2544
+ const fnInfo = findLocalFunction(name, ctx)
2545
+ if (constInfo && fnInfo) return null
2546
+ if (constInfo) {
2547
+ const ast = parseConstInitializer(constInfo)
2548
+ return ast && (ts.isArrowFunction(ast) || ts.isFunctionExpression(ast)) ? ast : null
2549
+ }
2550
+ if (fnInfo) {
2551
+ const ast = parseFunctionInfoAsExpr(fnInfo)
2552
+ return ast && (ts.isArrowFunction(ast) || ts.isFunctionExpression(ast)) ? ast : null
2553
+ }
2554
+ return null
2555
+ }
2556
+
2486
2557
  /**
2487
2558
  * Result type for extractFilterPredicate. The predicate is always an expression:
2488
2559
  * a block body is normalized to one via `foldBlockToExpr` +
@@ -2650,7 +2721,11 @@ function extractLoopParamBindings(
2650
2721
  : `${prefix}[${JSON.stringify(key)}]`
2651
2722
  }
2652
2723
 
2653
- const walk = (p: ts.ArrayBindingPattern | ts.ObjectBindingPattern, prefix: string): void => {
2724
+ const walk = (
2725
+ p: ts.ArrayBindingPattern | ts.ObjectBindingPattern,
2726
+ prefix: string,
2727
+ segments: readonly LoopBindingPathSegment[],
2728
+ ): void => {
2654
2729
  if (unsupported) return
2655
2730
  if (ts.isArrayBindingPattern(p)) {
2656
2731
  const elements = p.elements
@@ -2676,14 +2751,16 @@ function extractLoopParamBindings(
2676
2751
  name: el.name.text,
2677
2752
  path: prefix,
2678
2753
  rest: { kind: 'array', from: index },
2754
+ segments,
2679
2755
  })
2680
2756
  return
2681
2757
  }
2682
2758
  const path = `${prefix}[${index}]`
2759
+ const nextSegments = [...segments, { kind: 'index', index } as const]
2683
2760
  if (ts.isIdentifier(el.name)) {
2684
- bindings.push({ name: el.name.text, path })
2761
+ bindings.push({ name: el.name.text, path, segments: nextSegments })
2685
2762
  } else {
2686
- walk(el.name, path)
2763
+ walk(el.name, path, nextSegments)
2687
2764
  }
2688
2765
  }
2689
2766
  return
@@ -2712,6 +2789,7 @@ function extractLoopParamBindings(
2712
2789
  name: el.name.text,
2713
2790
  path: prefix,
2714
2791
  rest: { kind: 'object', exclude: collectedKeys },
2792
+ segments,
2715
2793
  })
2716
2794
  return
2717
2795
  }
@@ -2728,18 +2806,20 @@ function extractLoopParamBindings(
2728
2806
  unsupported = true
2729
2807
  return
2730
2808
  }
2731
- collectedKeys.push({ key: keyText, isIdent: isIdent(keyText) })
2809
+ const keyIsIdent = isIdent(keyText)
2810
+ collectedKeys.push({ key: keyText, isIdent: keyIsIdent })
2732
2811
  const path = appendDotAccess(prefix, keyText)
2812
+ const nextSegments = [...segments, { kind: 'field', key: keyText, isIdent: keyIsIdent } as const]
2733
2813
  if (ts.isIdentifier(el.name)) {
2734
- bindings.push({ name: el.name.text, path })
2814
+ bindings.push({ name: el.name.text, path, segments: nextSegments })
2735
2815
  } else {
2736
- walk(el.name, path)
2816
+ walk(el.name, path, nextSegments)
2737
2817
  }
2738
2818
  }
2739
2819
  }
2740
2820
 
2741
2821
  if (ts.isArrayBindingPattern(pattern) || ts.isObjectBindingPattern(pattern)) {
2742
- walk(pattern, '')
2822
+ walk(pattern, '', [])
2743
2823
  if (unsupported) return { unsupported: true }
2744
2824
  return bindings
2745
2825
  }
@@ -4043,6 +4123,13 @@ function getAttributeValue(attr: ts.JsxAttribute, ctx: TransformContext): AttrVa
4043
4123
  }
4044
4124
  }
4045
4125
 
4126
+ // #2092: `className={cn\`base ${tone()}\`}` — a tagged template whose
4127
+ // tag resolves to a recognized interleave function desugars to the
4128
+ // equivalent untagged template literal, so every check below (static
4129
+ // style object, template-literal parts, ternary, generic expression)
4130
+ // sees it exactly as if the user had written the untagged form.
4131
+ expr = tryDesugarInterleaveTaggedTemplate(expr, ctx)
4132
+
4046
4133
  // BF062: AwaitExpression in attribute position
4047
4134
  if (ts.isAwaitExpression(expr)) {
4048
4135
  ctx.analyzer.errors.push(
@@ -4281,6 +4368,22 @@ function findLocalConst(name: string, ctx: TransformContext) {
4281
4368
  return pool[pool.length - 1]
4282
4369
  }
4283
4370
 
4371
+ /**
4372
+ * Resolve a `function` declaration name with the same shadowing-aware
4373
+ * lookup as {@link findLocalConst} — a component-scope declaration wins
4374
+ * over a module-scope one of the same name, and among several
4375
+ * component-scope declarations the last in source order wins.
4376
+ *
4377
+ * Returns undefined when no local function matches.
4378
+ */
4379
+ function findLocalFunction(name: string, ctx: TransformContext) {
4380
+ const matches = ctx.analyzer.localFunctions.filter(f => f.name === name)
4381
+ if (matches.length === 0) return undefined
4382
+ const fnScoped = matches.filter(f => !f.isModule)
4383
+ const pool = fnScoped.length > 0 ? fnScoped : matches
4384
+ return pool[pool.length - 1]
4385
+ }
4386
+
4284
4387
  /**
4285
4388
  * Detect a PascalCase JSX tag that is really a *dynamic tag* local
4286
4389
  * (`const Tag = children.tag`) rather than a component reference.
@@ -4463,6 +4566,288 @@ function astText(node: ts.Node): string {
4463
4566
  return node.getText(node.getSourceFile())
4464
4567
  }
4465
4568
 
4569
+ /**
4570
+ * Re-parse a `FunctionInfo` (a `function foo(...) {...}` declaration
4571
+ * collected by the analyzer) into an anonymous `ts.FunctionExpression`, for
4572
+ * the same reason `parseConstInitializer` re-parses a const's initializer
4573
+ * text — the analyzer stores source text, not a live AST node tied to
4574
+ * `ctx.sourceFile`.
4575
+ *
4576
+ * Wraps as `const __bf_resolve_fn__ = function(params) body` (an
4577
+ * EXPRESSION position) rather than reparsing a standalone `function foo() {}`
4578
+ * statement, because `convertNode` in expression-parser.ts only recognizes
4579
+ * `ts.isArrowFunction` / `ts.isFunctionExpression` — not
4580
+ * `ts.isFunctionDeclaration`. The returned node's parameters/body are then
4581
+ * structurally identical to a function-expression sort comparator, so it
4582
+ * flows through the existing `tsNodeToParsedExpr` → `sortComparatorFromArrow`
4583
+ * path unchanged. Uses `typedParams`/`typedBody` when present (verbatim
4584
+ * source, may carry type annotations that don't affect `ts.isIdentifier(p.name)`
4585
+ * checks downstream) and falls back to reconstructing from `params`/`body`.
4586
+ *
4587
+ * Returns null when the source doesn't parse cleanly (e.g. no body).
4588
+ * Memoized per `FunctionInfo` object identity — mirrors
4589
+ * `constInitializerCache`.
4590
+ */
4591
+ const functionInfoExprCache = new WeakMap<object, ts.Expression | null>()
4592
+
4593
+ function parseFunctionInfoAsExpr(fn: FunctionInfo): ts.Expression | null {
4594
+ const cached = functionInfoExprCache.get(fn as object)
4595
+ if (cached !== undefined) return cached
4596
+ const result = parseFunctionInfoAsExprImpl(fn)
4597
+ functionInfoExprCache.set(fn as object, result)
4598
+ return result
4599
+ }
4600
+
4601
+ function parseFunctionInfoAsExprImpl(fn: FunctionInfo): ts.Expression | null {
4602
+ if (!fn.body) return null
4603
+ const params = fn.typedParams !== undefined
4604
+ ? fn.typedParams
4605
+ : fn.params.map(formatParamWithType).join(', ')
4606
+ const body = fn.typedBody ?? fn.body
4607
+ const wrapped = `const __bf_resolve_fn__ = function(${params}) ${body}`
4608
+ const sf = ts.createSourceFile(
4609
+ '__bf_resolve_fn.ts',
4610
+ wrapped,
4611
+ ts.ScriptTarget.Latest,
4612
+ /* setParentNodes */ true,
4613
+ ts.ScriptKind.TS,
4614
+ )
4615
+ const stmt = sf.statements[0]
4616
+ if (!stmt || !ts.isVariableStatement(stmt)) return null
4617
+ const decl = stmt.declarationList.declarations[0]
4618
+ if (!decl?.initializer) return null
4619
+ return decl.initializer
4620
+ }
4621
+
4622
+ // =============================================================================
4623
+ // Tagged-Template Interleave-Tag Desugaring (#2092, Refs #2069)
4624
+ // =============================================================================
4625
+
4626
+ /**
4627
+ * Recognize + desugar the classname-tag idiom:
4628
+ *
4629
+ * function cn(parts: TemplateStringsArray, ...args: unknown[]): string {
4630
+ * return parts.reduce<string>((acc, p, i) => acc + p + (args[i] ?? ''), '')
4631
+ * }
4632
+ * className={cn`base ${tone()}`}
4633
+ *
4634
+ * When `expr` is a top-level `ts.TaggedTemplateExpression` whose tag is an
4635
+ * identifier resolving ONE HOP through same-file scope (`findLocalConst` /
4636
+ * `findLocalFunction`, same shadowing preference as #2090's sort-comparator
4637
+ * resolution) to a function structurally proven to be an "interleave tag"
4638
+ * (see {@link isInterleaveTagFunction}), this rewrites the tagged template
4639
+ * to the equivalent UNTAGGED template literal — each span wrapped in
4640
+ * `(span) ?? ''` — and returns the new node. The entire existing pipeline
4641
+ * (dep analysis, template-literal parts, adapter emit, client-JS binding)
4642
+ * then processes the rewritten node exactly as if the user had written an
4643
+ * untagged template literal directly; no adapter changes, no new IR node.
4644
+ *
4645
+ * Returns `expr` UNCHANGED when the tag doesn't resolve, resolves to
4646
+ * something other than an arrow/function expression, or resolves but its
4647
+ * body isn't the exact interleave-reduce shape (imported tags, computed
4648
+ * tags like `obj.cn`, non-rest signatures, a tag that joins with `-`,
4649
+ * etc.) — today's opaque-leaf behavior (adapter BF101) is preserved
4650
+ * byte-for-byte for every other case.
4651
+ *
4652
+ * Top-level only (#2092 scope): a tagged template nested inside a ternary
4653
+ * or map callback is not rewritten by this call — only the two call sites
4654
+ * that receive an attribute value / JSX-child expression directly.
4655
+ */
4656
+ function tryDesugarInterleaveTaggedTemplate(
4657
+ expr: ts.Expression,
4658
+ ctx: TransformContext,
4659
+ ): ts.Expression {
4660
+ if (!ts.isTaggedTemplateExpression(expr)) return expr
4661
+ if (!ts.isIdentifier(expr.tag)) return expr
4662
+
4663
+ const resolvedTag = resolveInterleaveTagIdentifier(expr.tag.text, ctx)
4664
+ if (!resolvedTag) return expr
4665
+ if (!isInterleaveTagFunction(resolvedTag)) return expr
4666
+
4667
+ const rewritten = buildUntaggedTemplateLiteral(expr, ctx)
4668
+ return rewritten ?? expr
4669
+ }
4670
+
4671
+ /**
4672
+ * Resolve a bare-identifier tag reference one hop through same-file scope
4673
+ * — a `const cn = (parts, ...args) => …` or `function cn(parts, ...args)
4674
+ * {…}` — reusing the exact `findLocalConst` / `findLocalFunction` lookup
4675
+ * (and its shadowing preference) that #2090 established for sort
4676
+ * comparators. Alias chains and imported/prop identifiers are NOT
4677
+ * followed, matching that precedent. Returns null when nothing resolves.
4678
+ */
4679
+ function resolveInterleaveTagIdentifier(name: string, ctx: TransformContext): ts.Expression | null {
4680
+ const constInfo = findLocalConst(name, ctx)
4681
+ const fnInfo = findLocalFunction(name, ctx)
4682
+ // A name bound BOTH as a const and as a `function` declaration is the
4683
+ // same cross-kind ambiguity `resolveSortComparatorIdentifier` refuses:
4684
+ // it can only occur across scopes, and `FunctionInfo.isModule` reflects
4685
+ // emission placement rather than lexical position, so picking either
4686
+ // binding could desugar a tag the call site can't actually see. Refuse —
4687
+ // the node stays opaque and keeps today's adapter BF101 (Copilot review
4688
+ // on #2093).
4689
+ if (constInfo && fnInfo) return null
4690
+ if (constInfo) {
4691
+ const ast = parseConstInitializer(constInfo)
4692
+ return ast && (ts.isArrowFunction(ast) || ts.isFunctionExpression(ast)) ? ast : null
4693
+ }
4694
+ if (fnInfo) {
4695
+ const ast = parseFunctionInfoAsExpr(fnInfo)
4696
+ return ast && (ts.isArrowFunction(ast) || ts.isFunctionExpression(ast)) ? ast : null
4697
+ }
4698
+ return null
4699
+ }
4700
+
4701
+ /**
4702
+ * Structural catalogue gate for an "interleave tag" — the classname-cn
4703
+ * idiom's tag function. Kept tight per #2092: a resolved tag that doesn't
4704
+ * match EXACTLY this shape is left unrecognized (caller leaves the node
4705
+ * untouched).
4706
+ *
4707
+ * Signature: exactly 2 params, first a plain (non-rest) identifier
4708
+ * (`parts`), second a REST identifier param (`...args`). Type annotations
4709
+ * are irrelevant — only the parsed structure is matched.
4710
+ *
4711
+ * Body (after the existing block-body → single-`return` folding that
4712
+ * `tsNodeToParsedExpr` already performs):
4713
+ *
4714
+ * parts.reduce((acc, p, i) => acc + p + (args[i] ?? ''), '')
4715
+ *
4716
+ * — receiver is the first param; the reduce callback has 3 plain
4717
+ * identifier params; the callback body is a left-assoc `+` chain
4718
+ * `(acc + p) + X` (any parenthesization — `tsNodeToParsedExpr` already
4719
+ * discards parens) with `acc`/`p` the callback's first two params in
4720
+ * that order; `X` is `args[i] ?? ''`, optionally wrapped in `String(...)`,
4721
+ * with `args` the rest param and `i` the callback's third param; the
4722
+ * reduce init arg is the empty string literal.
4723
+ */
4724
+ function isInterleaveTagFunction(fn: ts.Expression): boolean {
4725
+ if (!ts.isArrowFunction(fn) && !ts.isFunctionExpression(fn)) return false
4726
+ if (fn.parameters.length !== 2) return false
4727
+ const [partsParam, argsParam] = fn.parameters
4728
+ if (!ts.isIdentifier(partsParam.name) || partsParam.dotDotDotToken) return false
4729
+ if (!ts.isIdentifier(argsParam.name) || !argsParam.dotDotDotToken) return false
4730
+
4731
+ const parsed = tsNodeToParsedExpr(fn)
4732
+ if (parsed.kind !== 'arrow') return false
4733
+ return isInterleaveReduceCall(parsed.body, partsParam.name.text, argsParam.name.text)
4734
+ }
4735
+
4736
+ /** Match `<partsName>.reduce((acc, p, i) => …, '')`. */
4737
+ function isInterleaveReduceCall(body: ParsedExpr, partsName: string, argsName: string): boolean {
4738
+ if (body.kind !== 'call' || body.args.length !== 2) return false
4739
+ const { callee, args } = body
4740
+ if (callee.kind !== 'member' || callee.computed || callee.property !== 'reduce') return false
4741
+ if (callee.object.kind !== 'identifier' || callee.object.name !== partsName) return false
4742
+
4743
+ const [callback, init] = args
4744
+ if (init.kind !== 'literal' || init.literalType !== 'string' || init.value !== '') return false
4745
+ if (callback.kind !== 'arrow' || callback.params.length !== 3) return false
4746
+
4747
+ const [acc, p, i] = callback.params
4748
+ return isInterleaveReduceCallbackBody(callback.body, acc, p, i, argsName)
4749
+ }
4750
+
4751
+ /** Match `(acc + p) + X` where X is the per-span interleave expression. */
4752
+ function isInterleaveReduceCallbackBody(
4753
+ body: ParsedExpr,
4754
+ acc: string,
4755
+ p: string,
4756
+ i: string,
4757
+ argsName: string,
4758
+ ): boolean {
4759
+ if (body.kind !== 'binary' || body.op !== '+') return false
4760
+ const { left, right } = body
4761
+ if (left.kind !== 'binary' || left.op !== '+') return false
4762
+ if (left.left.kind !== 'identifier' || left.left.name !== acc) return false
4763
+ if (left.right.kind !== 'identifier' || left.right.name !== p) return false
4764
+ return isInterleaveSpanExpr(right, i, argsName)
4765
+ }
4766
+
4767
+ /** Match `args[i] ?? ''`, optionally wrapped in `String(...)`. */
4768
+ function isInterleaveSpanExpr(expr: ParsedExpr, i: string, argsName: string): boolean {
4769
+ let inner = expr
4770
+ if (
4771
+ inner.kind === 'call' &&
4772
+ inner.args.length === 1 &&
4773
+ inner.callee.kind === 'identifier' &&
4774
+ inner.callee.name === 'String'
4775
+ ) {
4776
+ inner = inner.args[0]
4777
+ }
4778
+ if (inner.kind !== 'logical' || inner.op !== '??') return false
4779
+ if (inner.right.kind !== 'literal' || inner.right.literalType !== 'string' || inner.right.value !== '') {
4780
+ return false
4781
+ }
4782
+ const idx = inner.left
4783
+ if (idx.kind !== 'index-access') return false
4784
+ if (idx.object.kind !== 'identifier' || idx.object.name !== argsName) return false
4785
+ if (idx.index.kind !== 'identifier' || idx.index.name !== i) return false
4786
+ return true
4787
+ }
4788
+
4789
+ /**
4790
+ * Build the untagged template literal equivalent to a recognized
4791
+ * interleave-tag call: `cn\`base ${tone()}\`` → `` `base ${(tone()) ?? ''}` ``.
4792
+ *
4793
+ * Each literal chunk uses its RAW source text (`rawText`, falling back to
4794
+ * the cooked `text` only if `rawText` is unexpectedly absent) so escapes
4795
+ * (a literal backtick, `${`, or backslash inside a chunk) survive
4796
+ * verbatim — pasting the COOKED text back into new template source would
4797
+ * mis-parse or silently change meaning. Each span expression uses
4798
+ * `ctx.getJS` (type-stripped, matching every other raw-text extraction in
4799
+ * this file) wrapped as `${(<expr>) ?? ''}`.
4800
+ *
4801
+ * The assembled text is re-parsed the same way `parseConstInitializer`
4802
+ * re-parses a const initializer (wrapped in `const __bf_… = (…)` so the
4803
+ * result lands in expression position). Returns null if the reparse
4804
+ * doesn't produce a clean template-literal expression (defensive; should
4805
+ * not happen for well-formed input).
4806
+ */
4807
+ function buildUntaggedTemplateLiteral(
4808
+ node: ts.TaggedTemplateExpression,
4809
+ ctx: TransformContext,
4810
+ ): ts.Expression | null {
4811
+ const template = node.template
4812
+
4813
+ let text: string
4814
+ if (ts.isNoSubstitutionTemplateLiteral(template)) {
4815
+ text = '`' + (template.rawText ?? template.text) + '`'
4816
+ } else {
4817
+ let body = template.head.rawText ?? template.head.text
4818
+ for (const span of template.templateSpans) {
4819
+ const spanText = ctx.getJS(span.expression)
4820
+ body += '${(' + spanText + ') ?? \'\'}'
4821
+ body += span.literal.rawText ?? span.literal.text
4822
+ }
4823
+ text = '`' + body + '`'
4824
+ }
4825
+
4826
+ const wrapped = `const __bf_resolve_tagged__ = (${text})`
4827
+ // ScriptKind.TSX (unlike the const/function-resolution re-parses above,
4828
+ // which parse comparator/tag FUNCTION BODIES): the span expressions are
4829
+ // verbatim attribute-position text from a .tsx component, so they were
4830
+ // originally parsed under TSX rules — re-parsing them as plain TS can
4831
+ // mis-parse or reject valid TSX span syntax and silently skip the
4832
+ // rewrite (Copilot review on #2093).
4833
+ const sf = ts.createSourceFile(
4834
+ '__bf_resolve_tagged.tsx',
4835
+ wrapped,
4836
+ ts.ScriptTarget.Latest,
4837
+ /* setParentNodes */ true,
4838
+ ts.ScriptKind.TSX,
4839
+ )
4840
+ const stmt = sf.statements[0]
4841
+ if (!stmt || !ts.isVariableStatement(stmt)) return null
4842
+ const decl = stmt.declarationList.declarations[0]
4843
+ if (!decl?.initializer) return null
4844
+ const result = ts.isParenthesizedExpression(decl.initializer)
4845
+ ? decl.initializer.expression
4846
+ : decl.initializer
4847
+ if (!ts.isTemplateExpression(result) && !ts.isNoSubstitutionTemplateLiteral(result)) return null
4848
+ return result
4849
+ }
4850
+
4466
4851
  /**
4467
4852
  * Parse a conditional (ternary) expression into structured form.
4468
4853
  * Only parses simple ternaries with string literal branches.