@barefootjs/jsx 0.17.1 → 0.18.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 (79) hide show
  1. package/dist/adapters/interface.d.ts +20 -4
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/parsed-expr-emitter.d.ts +3 -1
  4. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  5. package/dist/analyzer-context.d.ts.map +1 -1
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/augment-inherited-props.d.ts +19 -0
  8. package/dist/augment-inherited-props.d.ts.map +1 -1
  9. package/dist/compiler.d.ts.map +1 -1
  10. package/dist/expression-parser.d.ts +1 -0
  11. package/dist/expression-parser.d.ts.map +1 -1
  12. package/dist/index.d.ts +19 -3
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +815 -251
  15. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/compute-inlinability.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  19. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  22. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/emit-registration.d.ts +4 -2
  24. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/html-template.d.ts +41 -0
  26. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/types.d.ts +22 -1
  28. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  29. package/dist/jsx-to-ir.d.ts.map +1 -1
  30. package/dist/loop-destructure.d.ts +55 -18
  31. package/dist/loop-destructure.d.ts.map +1 -1
  32. package/dist/lowering-registry.d.ts +13 -0
  33. package/dist/lowering-registry.d.ts.map +1 -1
  34. package/dist/relocate.d.ts +28 -0
  35. package/dist/relocate.d.ts.map +1 -1
  36. package/dist/ssr-defaults.d.ts.map +1 -1
  37. package/dist/types.d.ts +68 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +2 -2
  40. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +284 -12
  41. package/src/__tests__/augment-inherited-props.test.ts +96 -0
  42. package/src/__tests__/compiler-runtime-contract.test.ts +11 -1
  43. package/src/__tests__/compiler-stress-1244.test.ts +13 -4
  44. package/src/__tests__/csr-substitution-safety-divergence.test.ts +137 -0
  45. package/src/__tests__/destructured-map-params.test.ts +11 -1
  46. package/src/__tests__/expression-parser.test.ts +63 -2
  47. package/src/__tests__/ir-sort-comparator.test.ts +261 -0
  48. package/src/__tests__/loop-destructure.test.ts +313 -0
  49. package/src/__tests__/loop-hoisted-template.test.ts +235 -0
  50. package/src/__tests__/props-destructuring.test.ts +110 -0
  51. package/src/__tests__/serialize-parsed-expr.test.ts +70 -3
  52. package/src/__tests__/ssr-defaults.test.ts +20 -0
  53. package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +231 -1
  54. package/src/__tests__/tagged-template-interleave.test.ts +268 -0
  55. package/src/__tests__/unsupported-expression.test.ts +194 -7
  56. package/src/adapters/interface.ts +20 -4
  57. package/src/adapters/parsed-expr-emitter.ts +19 -2
  58. package/src/analyzer-context.ts +20 -0
  59. package/src/analyzer.ts +74 -1
  60. package/src/augment-inherited-props.ts +139 -9
  61. package/src/compiler.ts +4 -0
  62. package/src/expression-parser.ts +237 -56
  63. package/src/index.ts +23 -1
  64. package/src/ir-to-client-js/collect-elements.ts +15 -1
  65. package/src/ir-to-client-js/compute-inlinability.ts +6 -1
  66. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  67. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  68. package/src/ir-to-client-js/control-flow/stringify/loop.ts +30 -8
  69. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +30 -0
  70. package/src/ir-to-client-js/emit-registration.ts +4 -2
  71. package/src/ir-to-client-js/html-template.ts +198 -1
  72. package/src/ir-to-client-js/index.ts +1 -0
  73. package/src/ir-to-client-js/types.ts +22 -0
  74. package/src/jsx-to-ir.ts +409 -24
  75. package/src/loop-destructure.ts +89 -36
  76. package/src/lowering-registry.ts +16 -0
  77. package/src/relocate.ts +201 -14
  78. package/src/ssr-defaults.ts +34 -32
  79. package/src/types.ts +65 -0
@@ -1,31 +1,60 @@
1
1
  import type { IRLoop, IRNode, AttrValue, IRTemplatePart } from './types.ts'
2
2
 
3
- const SIMPLE_FIELD = /^\.[A-Za-z_$][\w$]*$/
4
-
5
3
  /**
6
- * True when a loop's destructure param is the single shape the non-JS SSR
7
- * adapters (Go template / Mojolicious / Xslate) can lower today:
4
+ * True when a loop's `.map()` destructure param is one of the shapes this
5
+ * repo's per-adapter emitters can lower to a native accessor, WITHOUT
6
+ * relying on the JS/CSR runtime to evaluate an arbitrary residual object.
7
+ *
8
+ * Admitted (all via `LoopParamBinding.segments` — the structured, non-string
9
+ * accessor path built by `extractLoopParamBindings`; see #2087):
8
10
  *
9
- * `arr.map(({ id, title, ...rest }) => …)`
11
+ * - fixed bindings at any depth / shape: `.field` (`{ id }`), array-index
12
+ * (`[k, v]`), and nested paths through either (`{ cells: [head] }`,
13
+ * `{ user: { name } }`) — anything the IR walker turned into a
14
+ * `segments` path. `segments` is required; a fixed binding with no
15
+ * `segments` is stale/foreign IR and refused conservatively.
16
+ * - array-rest bindings (`[first, ...tail]`): the lowered value IS the
17
+ * exact JS slice (`tail === item.slice(1)`), so there is no way for an
18
+ * adapter to observe a "wrong" value from any use of the name — no
19
+ * use-restriction scan needed, unlike object-rest below.
20
+ * - object-rest bindings (`{ id, ...rest }`) whose every use in the loop
21
+ * subtree is one of:
22
+ * (a) a member-access base (`rest.flag` / `rest?.flag` — the
23
+ * "read one field back off the rest" idiom), or
24
+ * (b) NEW: a spread attr (`{...rest}`) on an intrinsic ELEMENT node
25
+ * (`<li {...rest}>`) whose `expr` is *exactly* the rest name —
26
+ * "forward everything else onto this element" is a residual the
27
+ * adapters can express as "all attrs not already destructured",
28
+ * without ever materializing the residual object itself.
29
+ * A spread on a `component` or `provider` node still refuses — a
30
+ * component's own props lowering is a different code path with its own
31
+ * contract, not something this gate should reach into. A spread whose
32
+ * expr merely *contains* the name (`{...fn(rest)}`) still refuses too —
33
+ * that's an opaque call, not a literal forward.
10
34
  *
11
- * where every binding is a simple `.field` access or an **object-rest read only
12
- * via member access** (`rest.flag`). The adapters lower the rest binding as an
13
- * alias to the whole iteration item, which matches JS rest semantics only for
14
- * member reads of non-consumed keys — so any other use must be refused:
35
+ * Still refused, and why:
15
36
  *
16
- * - array-rest / array-index / nested paths (`[a, ...t]`, `{ cells: [h] }`)
17
- * need index/slice the `range`/`for` can't express inline;
18
- * - spread (`{...rest}`) and bare value uses (`String(rest)`, `{rest}`,
19
- * `fn(rest)`) would observe the consumed keys too they need a residual
20
- * object the templates can't build inline;
21
- * - a chained `.filter().map(destructure)` would need the filter-param
22
- * rewrite to target the synthetic per-item var, so it's refused as well.
37
+ * - any OTHER use of an object-rest name (`String(rest)`, `{rest}` as a
38
+ * text/expression node, `onClick={() => fn(rest)}`, `{...fn(rest)}`)
39
+ * needs the actual residual *object*, which the non-JS template
40
+ * adapters can't build inline only "read one field" (member access)
41
+ * and "spread all remaining attrs onto this element" (the new spread
42
+ * case) have an adapter-side answer that doesn't require constructing
43
+ * the value.
44
+ * - a chained `.filter().map(destructure)` needs the filter-param
45
+ * rewrite to retarget the synthetic per-item var; out of scope here.
46
+ * - a binding name (or `loop.index`) in the reserved `__bf_` namespace
47
+ * would collide with the synthetic per-item loop variable the SSR
48
+ * adapters emit (duplicate locals, ambiguous accessors).
49
+ * - computed property keys (`{ [k]: v }`) can't be expressed as any
50
+ * accessor path at all — they raise `BF025` at Phase 1 and never reach
51
+ * this gate (`loop.paramBindings` is simply absent).
23
52
  *
24
- * Unsupported shapes fall through to the adapters' BF104 diagnostic. The scan
25
- * is conservative: an ambiguous use refuses (false-negative is safe — it keeps
26
- * the existing build-time error rather than shipping wrong output).
53
+ * Unsupported shapes fall through to the adapters' BF104 diagnostic. The
54
+ * scan is conservative: an ambiguous use refuses (false-negative is safe —
55
+ * it keeps the existing build-time error rather than shipping wrong output).
27
56
  */
28
- export function isLowerableObjectRestDestructure(loop: IRLoop): boolean {
57
+ export function isLowerableLoopDestructure(loop: IRLoop): boolean {
29
58
  const bindings = loop.paramBindings
30
59
  if (!bindings || bindings.length === 0) return false
31
60
  if (loop.filterPredicate) return false
@@ -39,31 +68,49 @@ export function isLowerableObjectRestDestructure(loop: IRLoop): boolean {
39
68
  }
40
69
  for (const b of bindings) {
41
70
  if (b.rest) {
42
- if (b.rest.kind !== 'object') return false
43
- } else if (!SIMPLE_FIELD.test(b.path)) {
71
+ // Both rest kinds require `segments` (the array-rest kind may
72
+ // legitimately have an empty one, at the loop root — `([...rest]) =>`).
73
+ if (!b.segments) return false
74
+ } else if (!b.segments || b.segments.length === 0) {
75
+ // Fixed binding with no structured path: stale/foreign IR built
76
+ // before `segments` existed. Refuse rather than guess from `path`.
44
77
  return false
45
78
  }
46
79
  }
47
- const restNames = bindings.filter(b => b.rest).map(b => b.name)
48
- if (restNames.length === 0) return true
49
- return !restNamesMisused(loop, restNames)
80
+ const objectRestNames = bindings.filter(b => b.rest?.kind === 'object').map(b => b.name)
81
+ if (objectRestNames.length === 0) return true
82
+ return !restNamesMisused(loop, objectRestNames)
50
83
  }
51
84
 
85
+ /** @deprecated Use {@link isLowerableLoopDestructure}. Kept as an alias so
86
+ * existing template-adapter imports keep compiling; the underlying gate now
87
+ * admits more shapes (array-index / nested paths / array-rest / the
88
+ * spread-onto-element case) than the name suggests — see #2087 Phase A.
89
+ */
90
+ export const isLowerableObjectRestDestructure = isLowerableLoopDestructure
91
+
52
92
  /**
53
93
  * Walks the whole loop subtree (every node type, every expression-bearing
54
- * field) and reports whether any rest name is referenced as something other
55
- * than a member-access base (`rest.flag` / `rest?.flag`). A spread expr
56
- * (`{...rest}` → expr `"rest"`) and bare value uses are caught by the same
57
- * regex; a property of an unrelated object (`foo.rest`) is excluded by the
58
- * lookbehind.
94
+ * field) and reports whether any object-rest name is referenced as
95
+ * something other than:
96
+ *
97
+ * - a member-access base (`rest.flag` / `rest?.flag`), or
98
+ * - a spread attr on an intrinsic ELEMENT node whose expr is *exactly*
99
+ * the rest name (`<li {...rest}>`) — the one new admitted shape.
100
+ *
101
+ * A spread expr on a `component` / `provider` node, or a spread whose expr
102
+ * merely contains the name, still counts as misuse (falls through to the
103
+ * generic bare-value-use regex below). A property of an unrelated object
104
+ * (`foo.rest`) is excluded by the lookbehind, same as before.
59
105
  *
60
106
  * The scan covers the gated loop's own non-children expression fields too
61
- * (`array` / `key` / `mapPreamble` / `flatMapCallback` body) — a bare rest use
62
- * can surface there (e.g. `.map(({ ...rest }) => { const x = rest; … })`
107
+ * (`array` / `key` / `mapPreamble` / `flatMapCallback` body) — a bare rest
108
+ * use can surface there (e.g. `.map(({ ...rest }) => { const x = rest; … })`
63
109
  * lifts `const x = rest` into `mapPreamble`), plus intrinsic element event
64
110
  * handlers (`onClick={() => fn(rest)}`).
65
111
  */
66
112
  function restNamesMisused(loop: IRLoop, names: string[]): boolean {
113
+ const nameSet = new Set(names)
67
114
  const valueUse = names.map(
68
115
  n => new RegExp(`(?<![\\w.$])${escapeRe(n)}(?!\\s*\\??\\.)(?![\\w$])`),
69
116
  )
@@ -77,7 +124,13 @@ function restNamesMisused(loop: IRLoop, names: string[]): boolean {
77
124
  }
78
125
  }
79
126
  }
80
- const attr = (v: AttrValue): void => {
127
+ // `isIntrinsicElementAttrs` distinguishes `<li {...rest}>` (element,
128
+ // admitted) from `<Child {...rest} />` / a provider's value prop
129
+ // (still refused) — same spread AttrValue shape, different node kind.
130
+ const attr = (v: AttrValue, isIntrinsicElementAttrs: boolean): void => {
131
+ if (v.kind === 'spread' && isIntrinsicElementAttrs && nameSet.has(v.expr.trim())) {
132
+ return
133
+ }
81
134
  if (v.kind === 'expression' || v.kind === 'spread') {
82
135
  check(v.expr)
83
136
  check(v.templateExpr)
@@ -134,16 +187,16 @@ function restNamesMisused(loop: IRLoop, names: string[]): boolean {
134
187
  if (node.alternate) visit(node.alternate)
135
188
  break
136
189
  case 'element':
137
- node.attrs.forEach(a => attr(a.value))
190
+ node.attrs.forEach(a => attr(a.value, true))
138
191
  node.events.forEach(e => check(e.handler))
139
192
  node.children.forEach(visit)
140
193
  break
141
194
  case 'component':
142
- node.props.forEach(p => attr(p.value))
195
+ node.props.forEach(p => attr(p.value, false))
143
196
  node.children.forEach(visit)
144
197
  break
145
198
  case 'provider':
146
- attr(node.valueProp.value)
199
+ attr(node.valueProp.value, false)
147
200
  node.children.forEach(visit)
148
201
  break
149
202
  case 'fragment':
@@ -141,6 +141,22 @@ export function matchLoweringCall(
141
141
  return null
142
142
  }
143
143
 
144
+ /**
145
+ * Whether a `LoweringNode.helper` id is safe to splice directly into an
146
+ * adapter's runtime-helper naming convention (`bf_${helper}` in Go,
147
+ * `bf->${helper}` in Perl/Mojolicious, `$bf.${helper}` in Xslate/Kolon,
148
+ * `bf.${helper}` in Ruby/ERB and the Jinja-family adapters — Jinja,
149
+ * MiniJinja/Rust, Twig). Every adapter derives its helper's target-language
150
+ * name FROM the helper id (#2069) rather than keeping a per-id lookup
151
+ * table, so a plugin author could otherwise inject arbitrary syntax via a
152
+ * malformed id. A valid id is a plain identifier fragment —
153
+ * letters/digits/underscore, not starting with a digit — which is the only
154
+ * shape every target language's function/method-name grammar accepts.
155
+ */
156
+ export function isValidHelperId(helper: string): boolean {
157
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(helper)
158
+ }
159
+
144
160
  /**
145
161
  * Test-only: replace the registry contents wholesale. The double-underscore
146
162
  * prefix marks it as an internal seam — tests use it to restore global state in
package/src/relocate.ts CHANGED
@@ -19,6 +19,8 @@ import type {
19
19
  TemplatePrimitiveRegistry,
20
20
  TemplateCallAcceptor,
21
21
  } from './adapters/interface.ts'
22
+ import { tsNodeToParsedExpr, type ParsedExpr } from './expression-parser.ts'
23
+ import { prepareLoweringMatchers, type LoweringMatcher } from './lowering-registry.ts'
22
24
 
23
25
  export interface RelocateEnv {
24
26
  /**
@@ -65,6 +67,33 @@ export interface RelocateEnv {
65
67
  * in `templatePrimitives`. Returning true marks the call as accepted.
66
68
  */
67
69
  acceptsTemplateCall?: TemplateCallAcceptor
70
+ /**
71
+ * Lowering-registry matchers (#2057) bound to this component's metadata
72
+ * (`prepareLoweringMatchers`), consulted by `isCallAcceptedByAdapter` as a
73
+ * third acceptance path alongside `templatePrimitives` /
74
+ * `acceptsTemplateCall`. A call recognised by ANY matcher (returns a
75
+ * non-null `LoweringNode`) is treated as adapter-accepted — the matcher
76
+ * result itself isn't used here (relocate only needs a boolean; the
77
+ * adapter's own render pass re-runs the matcher to get the node). Bound
78
+ * once per component (`buildRelocateEnvFromIR`), so checking it per call
79
+ * site is cheap. Undefined/empty behaves like pre-#2069 (no matcher path).
80
+ */
81
+ loweringMatchers?: readonly LoweringMatcher[]
82
+ /**
83
+ * One-hop alias resolution table (#2069 R2): name → the bare identifier
84
+ * or dotted identifier-path text of its initializer, for local constants
85
+ * whose value is itself just another identifier/path reference
86
+ * (`const fmt = customSerialize`, `const f = Math.floor`) — NOT a call,
87
+ * object, or any other expression shape. `isCallAcceptedByAdapter` uses
88
+ * this to resolve a call's callee ONE hop before keying
89
+ * `templatePrimitives` / dispatching matchers, so a locally-aliased
90
+ * import or global still resolves to a registrable name. Deliberately
91
+ * shallow — an alias-to-alias chain (`const g = f` where `f` is itself
92
+ * an alias) is NOT resolved transitively; the shadow guard naturally
93
+ * rejects it because the one-hop target's own binding kind is
94
+ * `init-local`/`module-local` with no further resolution.
95
+ */
96
+ aliasTargets?: ReadonlyMap<string, string>
68
97
  }
69
98
 
70
99
  export interface RelocateResult {
@@ -155,6 +184,34 @@ function decideAction(
155
184
  }
156
185
 
157
186
  if ((kind === 'init-local' || kind === 'sub-init-local') && toScope === 'template') {
187
+ // One-hop alias resolution (#2069 R2): `const fmt = customSerialize`
188
+ // is a pure identifier/path alias — its bare reference is
189
+ // interchangeable with the target text (checked BEFORE `env.inlinable`,
190
+ // which isn't populated yet when this runs from inside
191
+ // `computeInlinability`'s own Stage-1 classification loop; the alias
192
+ // table is built once at env-construction time so it's always ready).
193
+ // This resolves the CALLEE identifier itself (`fmt` → `customSerialize`
194
+ // wherever it's referenced) — a separate concern from
195
+ // `isCallAcceptedByAdapter`'s own one-hop resolution, which only
196
+ // decides whether a *call* is safe to inline, not how its callee text
197
+ // renders.
198
+ //
199
+ // Only substitute when the alias TARGET's own leftmost identifier is
200
+ // itself bare-visible at `toScope` (module-import / module-local /
201
+ // global) — this is what keeps the resolution to exactly one hop: an
202
+ // alias-of-an-alias (`const g = f` where `f` is itself just another
203
+ // local) has a target whose kind is `init-local`, which fails the
204
+ // visibility check below, so `g` falls through to the normal
205
+ // `env.inlinable` / fallback path instead of being blindly rewritten
206
+ // to the still-unreachable `f`.
207
+ const aliasTarget = env.aliasTargets?.get(name)
208
+ if (aliasTarget !== undefined) {
209
+ const targetLeftmost = aliasTarget.includes('.') ? aliasTarget.split('.')[0]! : aliasTarget
210
+ const targetKind = classify(targetLeftmost, env)
211
+ if (isVisibleIn(toScope, targetKind)) {
212
+ return { action: 'inline', rewrittenAs: aliasTarget }
213
+ }
214
+ }
158
215
  // Try inline; fall back if not eligible.
159
216
  const inlineForm = env.inlinable.get(name)
160
217
  if (inlineForm !== undefined) {
@@ -341,10 +398,31 @@ export function isInlinableInTemplate(
341
398
  * `obj` is a value of a particular TypeScript type, so method calls on
342
399
  * arbitrary receivers (`props.name.toUpperCase()`) cannot be matched
343
400
  * against type-anchored registry keys like `String.prototype.toUpperCase`.
344
- * This is the V1 limitation #1187 R1 records — users fall back to
401
+ * This remains out of scope in V2 (#1187 R1) — users fall back to
345
402
  * `/* @client *\/` for those cases.
403
+ *
404
+ * V2 (#2069): the registry is still identifier-path-only, but two things
405
+ * widen it beyond the raw path this function returns:
406
+ * 1. One-hop alias resolution (`isCallAcceptedByAdapter`) — a callee whose
407
+ * leftmost identifier is a local const aliasing another identifier/path
408
+ * (`const fmt = customSerialize`) resolves through that one hop before
409
+ * the path returned here is keyed against `templatePrimitives`.
410
+ * 2. A THIRD acceptance path alongside `templatePrimitives` /
411
+ * `acceptsTemplateCall`: `RelocateEnv.loweringMatchers` (#2057). A
412
+ * user-imported helper is never added to the string-keyed registry —
413
+ * it's recognised structurally by a `LoweringPlugin` (import-aware via
414
+ * `prepare(metadata)`), which accepts calls this function's textual
415
+ * path can't key at all (the plugin matches on the parsed callee/args
416
+ * shape, not a string).
346
417
  */
347
418
  function getCalleeIdentifierPath(callee: ts.Expression): string | null {
419
+ // Unwrap `(expr)` — `csrSubstitute`'s AST splicer wraps every identifier
420
+ // substitution in parens (`(customSerialize)(x)` after a one-hop alias
421
+ // resolves `fmt` → `customSerialize`), so a callee that started as a bare
422
+ // identifier can arrive here re-parsed as a `ParenthesizedExpression`.
423
+ // Unwrapping is always safe: a paren never changes *which* identifier
424
+ // path a callee resolves to, only re-parse shape.
425
+ if (ts.isParenthesizedExpression(callee)) return getCalleeIdentifierPath(callee.expression)
348
426
  if (ts.isIdentifier(callee)) return callee.text
349
427
  if (ts.isPropertyAccessExpression(callee)) {
350
428
  const left = getCalleeIdentifierPath(callee.expression)
@@ -363,6 +441,7 @@ function getCalleeIdentifierPath(callee: ts.Expression): string | null {
363
441
  * (the shadowing case — the registry must not fire then).
364
442
  */
365
443
  function getCalleeLeftmostIdentifier(callee: ts.Expression): string | null {
444
+ if (ts.isParenthesizedExpression(callee)) return getCalleeLeftmostIdentifier(callee.expression)
366
445
  if (ts.isIdentifier(callee)) return callee.text
367
446
  if (ts.isPropertyAccessExpression(callee)) {
368
447
  return getCalleeLeftmostIdentifier(callee.expression)
@@ -386,33 +465,94 @@ const REGISTRY_SAFE_BINDING_KINDS: ReadonlySet<BindingKind> = new Set([
386
465
 
387
466
  /**
388
467
  * Whether `env`'s adapter promises it can render this call in template
389
- * scope. Resolves the callee path and consults `templatePrimitives` first,
390
- * then `acceptsTemplateCall`. Either match returns true.
468
+ * scope. Three independent acceptance paths, any of which is sufficient:
391
469
  *
392
- * Shadow guard: rejects when the leftmost identifier of the callee
393
- * resolves to a local-ish binding kind. This prevents a local variable
394
- * named after a registered primitive (e.g. `const JSON = props.config`)
395
- * from accidentally activating the registry.
470
+ * 1. `templatePrimitives` string-keyed identifier-path registry
471
+ * (`JSON.stringify`, `Math.floor`).
472
+ * 2. `acceptsTemplateCall` broad predicate for full-JS-runtime adapters.
473
+ * 3. `loweringMatchers` (#2057/#2069) structural `LoweringPlugin`
474
+ * recognition, for user-imported helpers that were never (and can
475
+ * never be) added to the string-keyed registry.
476
+ *
477
+ * Before any of the three checks, the callee path is resolved through
478
+ * `aliasTargets` ONE hop (#2069 R2): `const fmt = customSerialize; fmt(x)`
479
+ * keys/matches as `customSerialize`, not `fmt`. Resolution only replaces a
480
+ * BARE-identifier leftmost segment with a bare-identifier target — a
481
+ * dotted alias target (`Math.floor`) only feeds the string-keyed path
482
+ * (matchers structurally expect a real callee shape, not a synthesised
483
+ * dotted identifier).
484
+ *
485
+ * Shadow guard: rejects when the leftmost identifier of the (possibly
486
+ * alias-resolved) callee resolves to a local-ish binding kind. This
487
+ * prevents a local variable named after a registered primitive (e.g.
488
+ * `const JSON = props.config`) from accidentally activating the registry,
489
+ * and — because the guard runs AFTER alias resolution — also rejects an
490
+ * alias-to-alias chain (`const g = f` where `f` is itself an unresolved
491
+ * local) without needing special-case transitive-chain detection: the
492
+ * one-hop target `f` is still `init-local`/`module-local`-shadowed.
396
493
  */
397
494
  function isCallAcceptedByAdapter(
398
495
  call: ts.CallExpression,
399
496
  env: RelocateEnv,
400
497
  ): boolean {
401
- const name = getCalleeIdentifierPath(call.expression)
402
- if (name === null) return false
498
+ const originalPath = getCalleeIdentifierPath(call.expression)
499
+ if (originalPath === null) return false
403
500
 
404
- // Shadow guard. `undefined` (not in bindings) means truly global —
405
- // safe; we let it through. A tracked binding must be in the safe set.
406
501
  const leftmost = getCalleeLeftmostIdentifier(call.expression)
502
+
503
+ // One-hop alias resolution: replace the callee's LEFTMOST SEGMENT with
504
+ // its alias target when eligible. This resolves both a bare-identifier
505
+ // callee (`fmt(x)` → `customSerialize(x)`) and an aliased-namespace
506
+ // member callee (`m.floor(x)` → `Math.floor(x)` when `const m = Math`)
507
+ // — the target text is spliced in place of the leftmost segment and any
508
+ // remaining `.path` tail is carried over. Matcher dispatch below is
509
+ // narrower (bare identifiers only); the string-keyed registry path uses
510
+ // the full spliced text.
511
+ let resolvedPath = originalPath
512
+ let resolvedLeftmost = leftmost
407
513
  if (leftmost !== null) {
408
- const kind = env.bindings.get(leftmost)
514
+ const aliasTarget = env.aliasTargets?.get(leftmost)
515
+ if (aliasTarget !== undefined) {
516
+ resolvedPath =
517
+ originalPath === leftmost
518
+ ? aliasTarget
519
+ : `${aliasTarget}${originalPath.slice(leftmost.length)}`
520
+ resolvedLeftmost = aliasTarget.includes('.') ? aliasTarget.split('.')[0] : aliasTarget
521
+ }
522
+ }
523
+
524
+ // Shadow guard, applied to the RESOLVED leftmost identifier. `undefined`
525
+ // (not in bindings) means truly global — safe; we let it through. A
526
+ // tracked binding must be in the safe set.
527
+ if (resolvedLeftmost !== null) {
528
+ const kind = env.bindings.get(resolvedLeftmost)
409
529
  if (kind !== undefined && !REGISTRY_SAFE_BINDING_KINDS.has(kind)) {
410
530
  return false
411
531
  }
412
532
  }
413
533
 
414
- if (env.templatePrimitives && env.templatePrimitives[name]) return true
415
- if (env.acceptsTemplateCall && env.acceptsTemplateCall(name)) return true
534
+ if (env.templatePrimitives && env.templatePrimitives[resolvedPath]) return true
535
+ if (env.acceptsTemplateCall && env.acceptsTemplateCall(resolvedPath)) return true
536
+
537
+ if (env.loweringMatchers && env.loweringMatchers.length > 0) {
538
+ const parsed = tsNodeToParsedExpr(call)
539
+ if (parsed.kind === 'call') {
540
+ // Only substitute the callee for matcher dispatch when the alias
541
+ // target is itself a bare identifier (no dots) — matchers expect a
542
+ // real parsed callee shape (`kind: 'identifier'`), not a synthesised
543
+ // dotted path.
544
+ const calleeForMatch =
545
+ resolvedPath !== originalPath &&
546
+ !resolvedPath.includes('.') &&
547
+ parsed.callee.kind === 'identifier'
548
+ ? { kind: 'identifier' as const, name: resolvedPath }
549
+ : parsed.callee
550
+ for (const matcher of env.loweringMatchers) {
551
+ if (matcher(calleeForMatch, parsed.args)) return true
552
+ }
553
+ }
554
+ }
555
+
416
556
  return false
417
557
  }
418
558
 
@@ -626,6 +766,15 @@ export function buildRelocateEnvFromIR(
626
766
  const env = buildRelocateEnvFromFields(metadata)
627
767
  if (options?.templatePrimitives) env.templatePrimitives = options.templatePrimitives
628
768
  if (options?.acceptsTemplateCall) env.acceptsTemplateCall = options.acceptsTemplateCall
769
+ // Bind every registered LoweringPlugin (built-in + userland, #2057) to
770
+ // THIS component's metadata once here, so `isCallAcceptedByAdapter`'s
771
+ // per-call check is just an array iteration + matcher call — no
772
+ // per-call import resolution (#2069). `metadata.imports` must be the
773
+ // REAL import list for plugin `prepare()` to resolve local names
774
+ // correctly; callers that reconstruct a synthetic `IRMetadata` (e.g.
775
+ // `compute-inlinability.ts`'s `buildEnvFromCtx`) must populate `imports`
776
+ // from the real component, not `[]`.
777
+ env.loweringMatchers = prepareLoweringMatchers(metadata)
629
778
  return env
630
779
  }
631
780
 
@@ -711,11 +860,49 @@ function buildRelocateEnvFromFields(src: EnvFields): RelocateEnv {
711
860
  if (kind === 'prop') propsForLift.add(name)
712
861
  }
713
862
 
863
+ // aliasTargets (#2069 R2): one-hop alias resolution table for
864
+ // `isCallAcceptedByAdapter`. A const whose FINAL resolved binding kind
865
+ // is `init-local` or `module-local` (i.e. not a signal/memo/prop-alias
866
+ // override) AND whose initializer is nothing but a bare identifier or
867
+ // dotted identifier path (`customSerialize`, `Math.floor` — no calls,
868
+ // no operators, no literals) is eligible. Eligibility is decided on the
869
+ // analyzer's structured `ConstantInfo.parsed` tree (never on the raw
870
+ // source text — see CLAUDE.md's no-regex-parsing rule); a const whose
871
+ // initializer didn't parse is simply not an alias. Built AFTER
872
+ // `bindings` is fully resolved (signals/memos have already overridden
873
+ // same-named consts) so eligibility reflects the FINAL kind, not a
874
+ // shadowed one.
875
+ const aliasTargets = new Map<string, string>()
876
+ for (const c of src.localConstants) {
877
+ const kind = bindings.get(c.name)
878
+ if (kind !== 'init-local' && kind !== 'module-local') continue
879
+ const target = identifierPathFromParsed(c.parsed)
880
+ if (target !== null) aliasTargets.set(c.name, target)
881
+ }
882
+
714
883
  return {
715
884
  bindings,
716
885
  inlinable: new Map(), // populated by compute-inlinability after analyzer runs
717
886
  propsForLift,
718
887
  propsObjectName,
719
888
  allowFallback: true,
889
+ aliasTargets,
720
890
  }
721
891
  }
892
+
893
+ /**
894
+ * The dotted identifier-path text of a parsed expression that is nothing
895
+ * but a bare identifier or a chain of non-computed member accesses on one
896
+ * (`foo`, `foo.bar`, `Math.floor`) — or null for every other shape
897
+ * (calls, literals, computed access, operators…). The structured
898
+ * counterpart of `getCalleeIdentifierPath` for `ParsedExpr` trees.
899
+ */
900
+ function identifierPathFromParsed(expr: ParsedExpr | undefined): string | null {
901
+ if (!expr) return null
902
+ if (expr.kind === 'identifier') return expr.name
903
+ if (expr.kind === 'member' && !expr.computed) {
904
+ const object = identifierPathFromParsed(expr.object)
905
+ return object === null ? null : `${object}.${expr.property}`
906
+ }
907
+ return null
908
+ }
@@ -93,27 +93,29 @@ export function extractSsrDefaults(metadata: IRMetadata): Record<string, SsrDefa
93
93
  if (metadata.propsObjectName) propsLike.add(metadata.propsObjectName)
94
94
  for (const p of metadata.propsParams) propsLike.add(p.name)
95
95
 
96
- // Prop destructure defaults. Only emit entries for the
97
- // destructured-prop form (`function Foo({ variant = 'default' })`)
98
- // where each `propsParam` name corresponds to a template-stash
99
- // variable. The bare-props-arg form (`function Foo(props: Props)`)
100
- // also populates `propsParams` from the type, but those names are
101
- // never referenced as bare scalars in the generated template
102
- // accesses go through `$props->{X}` so seeding them would just
103
- // crowd the manifest with no-op entries.
104
- if (metadata.propsObjectName === null) {
105
- for (const p of metadata.propsParams) {
106
- if (p.isRest) continue
107
- if (p.defaultValue !== undefined) {
108
- const value = tryStaticEval(p.defaultValue, { bindings: {}, propsLike })
109
- out[p.name] = { propName: p.name, value: resultToJsonable(value) }
110
- } else {
111
- // No destructure default the template will read it as-is, source
112
- // from props and let Perl's `//` operator decide what `undef`
113
- // becomes. We still register the entry so consumers can supply
114
- // the propName even with no static fallback.
115
- out[p.name] = { propName: p.name, value: null }
116
- }
96
+ // Prop entries. Both parameter forms need one entry per prop:
97
+ // - Destructured form (`function Foo({ variant = 'default' })`):
98
+ // each `propsParam` is a template-stash variable; a literal
99
+ // destructure default becomes the static fallback value.
100
+ // - Bare-props form (`function Foo(props: Props)`): template-stash
101
+ // adapters flatten `props.X` to the same bare scalar (`$X` see
102
+ // the Mojo emitter's `member()`), NOT a `$props->{X}` hash read,
103
+ // so an unseeded prop the caller forgets to pass is a strict-mode
104
+ // compile error, not a soft `undef` (#2126). Seed every declared
105
+ // prop with a `null` fallback ( undef; the template-side `// …`
106
+ // recompute supplies the real default, and a caller-passed prop
107
+ // wins via `propName`).
108
+ for (const p of metadata.propsParams) {
109
+ if (p.isRest) continue
110
+ if (metadata.propsObjectName === null && p.defaultValue !== undefined) {
111
+ const value = tryStaticEval(p.defaultValue, { bindings: {}, propsLike })
112
+ out[p.name] = { propName: p.name, value: resultToJsonable(value) }
113
+ } else {
114
+ // No destructure default — the template reads the prop as-is;
115
+ // Perl's `//` operator decides what `undef` becomes. The entry
116
+ // still matters so the template variable exists and consumers
117
+ // can supply the propName even with no static fallback.
118
+ out[p.name] = { propName: p.name, value: null }
117
119
  }
118
120
  }
119
121
  // Rest-props bag (`...props`) — tracked separately from `propsParams`
@@ -159,17 +161,17 @@ export function extractSsrDefaults(metadata: IRMetadata): Record<string, SsrDefa
159
161
  bindings[memo.name] = value
160
162
  }
161
163
 
162
- // Bare-props-arg form (`function Foo(props: Props)`): a signal / memo
163
- // whose initializer reads `props.X` is lowered by template-stash
164
- // adapters to a *bare scalar* recompute (`my $count = ($initial // 0)`,
165
- // the #1297 prop-derived seeding) not a `$props->{X}` hash read. That
166
- // bare `$initial` has to exist in the stash or Perl's strict mode aborts
167
- // the render with `Global symbol "$initial" requires explicit package
168
- // name`. The top block skips bare-props props on the assumption they're
169
- // only ever read via `$props->{X}`, which the prop-derived seeding
170
- // violates — so seed every prop a signal / memo initializer references.
171
- // Value is `null` (→ undef): the recompute's own `?? <literal>` supplies
172
- // the real fallback, and a caller-passed prop still wins via `propName`.
164
+ // Bare-props-arg safety net: the prop block above covers every prop
165
+ // *declared* on the props type, but `propsParams` can miss props read
166
+ // through an untyped / inline-typed `props` object. A signal / memo
167
+ // initializer's `props.X` read is lowered by template-stash adapters
168
+ // to a *bare scalar* recompute (`my $count = ($initial // 0)`, the
169
+ // #1297 prop-derived seeding), and that bare `$initial` has to exist
170
+ // in the stash or Perl's strict mode aborts the render with `Global
171
+ // symbol "$initial" requires explicit package name` so also seed
172
+ // every prop a signal / memo initializer references. Value is `null`
173
+ // (→ undef): the recompute's own `?? <literal>` supplies the real
174
+ // fallback, and a caller-passed prop still wins via `propName`.
173
175
  if (metadata.propsObjectName !== null) {
174
176
  const referenced = new Set<string>()
175
177
  for (const sig of metadata.signals) {