@pyreon/compiler 0.24.5 → 0.24.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -13
- package/src/defer-inline.ts +0 -686
- package/src/event-names.ts +0 -65
- package/src/index.ts +0 -61
- package/src/island-audit.ts +0 -675
- package/src/jsx.ts +0 -2792
- package/src/load-native.ts +0 -156
- package/src/lpih.ts +0 -270
- package/src/manifest.ts +0 -280
- package/src/project-scanner.ts +0 -214
- package/src/pyreon-intercept.ts +0 -1029
- package/src/react-intercept.ts +0 -1217
- package/src/reactivity-lens.ts +0 -190
- package/src/ssg-audit.ts +0 -513
- package/src/test-audit.ts +0 -435
- package/src/tests/backend-parity-r7-r9.test.ts +0 -91
- package/src/tests/backend-prop-derived-callback-divergence.test.ts +0 -74
- package/src/tests/collapse-bail-census.test.ts +0 -330
- package/src/tests/collapse-key-source-hygiene.test.ts +0 -88
- package/src/tests/component-child-no-wrap.test.ts +0 -204
- package/src/tests/defer-inline.test.ts +0 -387
- package/src/tests/depth-stress.test.ts +0 -16
- package/src/tests/detector-tag-consistency.test.ts +0 -101
- package/src/tests/dynamic-collapse-detector.test.ts +0 -164
- package/src/tests/dynamic-collapse-emit.test.ts +0 -192
- package/src/tests/dynamic-collapse-scan.test.ts +0 -111
- package/src/tests/element-valued-const-child.test.ts +0 -61
- package/src/tests/falsy-child-characterization.test.ts +0 -48
- package/src/tests/island-audit.test.ts +0 -524
- package/src/tests/jsx.test.ts +0 -2908
- package/src/tests/load-native.test.ts +0 -53
- package/src/tests/lpih.test.ts +0 -404
- package/src/tests/malformed-input-resilience.test.ts +0 -50
- package/src/tests/manifest-snapshot.test.ts +0 -55
- package/src/tests/native-equivalence.test.ts +0 -924
- package/src/tests/partial-collapse-detector.test.ts +0 -121
- package/src/tests/partial-collapse-emit.test.ts +0 -104
- package/src/tests/partial-collapse-robustness.test.ts +0 -53
- package/src/tests/project-scanner.test.ts +0 -269
- package/src/tests/prop-derived-shadow.test.ts +0 -96
- package/src/tests/pure-call-reactive-args.test.ts +0 -50
- package/src/tests/pyreon-intercept.test.ts +0 -816
- package/src/tests/r13-callback-stmt-equivalence.test.ts +0 -58
- package/src/tests/r14-ssr-mode-parity.test.ts +0 -51
- package/src/tests/r15-elemconst-propderived.test.ts +0 -47
- package/src/tests/r19-defer-inline-robust.test.ts +0 -54
- package/src/tests/r20-backend-equivalence-sweep.test.ts +0 -50
- package/src/tests/react-intercept.test.ts +0 -1104
- package/src/tests/reactivity-lens.test.ts +0 -170
- package/src/tests/rocketstyle-collapse.test.ts +0 -208
- package/src/tests/runtime/control-flow.test.ts +0 -159
- package/src/tests/runtime/dom-properties.test.ts +0 -138
- package/src/tests/runtime/events.test.ts +0 -301
- package/src/tests/runtime/harness.ts +0 -94
- package/src/tests/runtime/pr-352-shapes.test.ts +0 -121
- package/src/tests/runtime/reactive-props.test.ts +0 -81
- package/src/tests/runtime/signals.test.ts +0 -129
- package/src/tests/runtime/whitespace.test.ts +0 -106
- package/src/tests/signal-autocall-shadow.test.ts +0 -86
- package/src/tests/sourcemap-fidelity.test.ts +0 -77
- package/src/tests/ssg-audit.test.ts +0 -402
- package/src/tests/static-text-baking.test.ts +0 -64
- package/src/tests/test-audit.test.ts +0 -549
- package/src/tests/transform-state-isolation.test.ts +0 -49
package/src/jsx.ts
DELETED
|
@@ -1,2792 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* JSX transform — wraps dynamic JSX expressions in `() =>` so the Pyreon runtime
|
|
3
|
-
* receives reactive getters instead of eagerly-evaluated snapshot values.
|
|
4
|
-
*
|
|
5
|
-
* Rules:
|
|
6
|
-
* - `<div>{expr}</div>` → `<div>{() => expr}</div>` (child)
|
|
7
|
-
* - `<div class={expr}>` → `<div class={() => expr}>` (prop)
|
|
8
|
-
* - `<button onClick={fn}>` → unchanged (event handler)
|
|
9
|
-
* - `<div>{() => expr}</div>` → unchanged (already wrapped)
|
|
10
|
-
* - `<div>{"literal"}</div>` → unchanged (static)
|
|
11
|
-
*
|
|
12
|
-
* Static VNode hoisting:
|
|
13
|
-
* - Fully static JSX in expression containers is hoisted to module scope:
|
|
14
|
-
* `{<span>Hello</span>}` → `const _$h0 = <span>Hello</span>` + `{_$h0}`
|
|
15
|
-
* - Hoisted nodes are created ONCE at module initialisation, not per-instance.
|
|
16
|
-
* - A JSX node is static if: all props are string literals / booleans / static
|
|
17
|
-
* values, and all children are text nodes or other static JSX nodes.
|
|
18
|
-
*
|
|
19
|
-
* Template emission:
|
|
20
|
-
* - JSX element trees with ≥ 1 DOM elements (no components, no spread attrs on
|
|
21
|
-
* inner elements) are compiled to `_tpl(html, bindFn)` calls instead of nested
|
|
22
|
-
* `h()` calls.
|
|
23
|
-
* - The HTML string is parsed once via <template>.innerHTML, then cloneNode(true)
|
|
24
|
-
* for each instance (~5-10x faster than sequential createElement calls).
|
|
25
|
-
* - Static attributes are baked into the HTML string; dynamic attributes and
|
|
26
|
-
* text content use renderEffect in the bind function.
|
|
27
|
-
*
|
|
28
|
-
* Implementation: Rust native binary (napi-rs) when available, JS fallback via oxc-parser.
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
import MagicString from 'magic-string'
|
|
32
|
-
import { parseSync } from 'oxc-parser'
|
|
33
|
-
import { REACT_EVENT_REMAP } from './event-names'
|
|
34
|
-
import { loadNativeBinding } from './load-native'
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* V3 source map shape returned by the JS backend. Structurally exactly
|
|
38
|
-
* magic-string's `SourceMap` (a valid V3 map plus `.toString()`/`.toUrl()`),
|
|
39
|
-
* declared locally so `TransformResult` carries no hard type dependency on
|
|
40
|
-
* magic-string's exported types.
|
|
41
|
-
*/
|
|
42
|
-
export interface GeneratedSourceMap {
|
|
43
|
-
version: number
|
|
44
|
-
file?: string
|
|
45
|
-
sources: string[]
|
|
46
|
-
sourcesContent?: (string | null)[]
|
|
47
|
-
names: string[]
|
|
48
|
-
mappings: string
|
|
49
|
-
toString(): string
|
|
50
|
-
toUrl(): string
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ─── Native binary auto-detection ────────────────────────────────────────────
|
|
54
|
-
// Two-path resolution: in-tree binary first (dev mode), then per-platform
|
|
55
|
-
// npm package (production install via optionalDependencies). Falls through
|
|
56
|
-
// to the JS implementation below when both paths fail (wrong platform, CI
|
|
57
|
-
// environment, WASM runtime like StackBlitz, missing per-platform package).
|
|
58
|
-
//
|
|
59
|
-
// See `load-native.ts` for the resolution logic.
|
|
60
|
-
type NativeTransformFn = (
|
|
61
|
-
code: string,
|
|
62
|
-
filename: string,
|
|
63
|
-
ssr: boolean,
|
|
64
|
-
knownSignals: string[] | null,
|
|
65
|
-
reactivityLens: boolean,
|
|
66
|
-
) => TransformResult
|
|
67
|
-
const nativeBinding = loadNativeBinding(import.meta.url)
|
|
68
|
-
const nativeTransformJsx: NativeTransformFn | null = nativeBinding
|
|
69
|
-
? (nativeBinding.transformJsx as NativeTransformFn)
|
|
70
|
-
: null
|
|
71
|
-
|
|
72
|
-
export interface CompilerWarning {
|
|
73
|
-
/** Warning message */
|
|
74
|
-
message: string
|
|
75
|
-
/** Source file line number (1-based) */
|
|
76
|
-
line: number
|
|
77
|
-
/** Source file column number (0-based) */
|
|
78
|
-
column: number
|
|
79
|
-
/** Warning code for filtering */
|
|
80
|
-
code:
|
|
81
|
-
| 'signal-call-in-jsx'
|
|
82
|
-
| 'missing-key-on-for'
|
|
83
|
-
| 'signal-in-static-prop'
|
|
84
|
-
| 'circular-prop-derived'
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Reactivity-lens kinds. Each is a RECORD of a codegen decision the compiler
|
|
89
|
-
* already made — never an approximation. Positive claims (`reactive*`) are
|
|
90
|
-
* emitted ONLY where the compiler provably wrapped/tracked the span; absence
|
|
91
|
-
* of a span is "not asserted", never an implicit static claim. `static-text`
|
|
92
|
-
* is the high-precision negative: the literal `else` branch of the
|
|
93
|
-
* reactive-vs-static text decision (the "this `{x}` is baked once / dead"
|
|
94
|
-
* footgun signal when the author expected reactivity).
|
|
95
|
-
*/
|
|
96
|
-
export type ReactivityKind =
|
|
97
|
-
| 'reactive' // expression re-evaluates on signal change (_bind/_bindText/`() =>` wrap)
|
|
98
|
-
| 'reactive-prop' // component prop tracked into the child (_rp(() => …))
|
|
99
|
-
| 'reactive-attr' // DOM attribute re-applied on signal change
|
|
100
|
-
| 'static-text' // text expression baked once into the DOM, never re-renders
|
|
101
|
-
| 'hoisted-static' // JSX hoisted to module scope, never re-evaluated
|
|
102
|
-
|
|
103
|
-
export interface ReactivitySpan {
|
|
104
|
-
/** Source byte offset (start) of the spanned expression in the INPUT. */
|
|
105
|
-
start: number
|
|
106
|
-
/** Source byte offset (end). */
|
|
107
|
-
end: number
|
|
108
|
-
/** 1-based start line. */
|
|
109
|
-
line: number
|
|
110
|
-
/** 0-based start column. */
|
|
111
|
-
column: number
|
|
112
|
-
/** 1-based end line. */
|
|
113
|
-
endLine: number
|
|
114
|
-
/** 0-based end column. */
|
|
115
|
-
endColumn: number
|
|
116
|
-
/** Which codegen decision this span records. */
|
|
117
|
-
kind: ReactivityKind
|
|
118
|
-
/** Human-readable, editor-facing one-liner explaining the decision. */
|
|
119
|
-
detail: string
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export interface TransformResult {
|
|
123
|
-
/** Transformed source code (JSX preserved, only expression containers modified) */
|
|
124
|
-
code: string
|
|
125
|
-
/** Whether the output uses _tpl/_re template helpers (needs auto-import) */
|
|
126
|
-
usesTemplates?: boolean
|
|
127
|
-
/** Compiler warnings for common mistakes */
|
|
128
|
-
warnings: CompilerWarning[]
|
|
129
|
-
/**
|
|
130
|
-
* Source map (V3) for the transform — present on the JS backend whenever a
|
|
131
|
-
* transformation actually occurred. `undefined` when nothing changed (the
|
|
132
|
-
* emitted code is byte-identical to the input, so no remapping is needed)
|
|
133
|
-
* and on the native backend (a Rust-side map is a scoped follow-up). The
|
|
134
|
-
* object is magic-string's `SourceMap`: it is a valid V3 map AND has
|
|
135
|
-
* `.toString()` / `.toUrl()`, so Vite/Rollup consume it directly.
|
|
136
|
-
*/
|
|
137
|
-
map?: GeneratedSourceMap
|
|
138
|
-
/**
|
|
139
|
-
* Reactivity-lens spans — populated ONLY when `TransformOptions.reactivityLens`
|
|
140
|
-
* is `true`. Additive: codegen output is byte-identical whether or not this is
|
|
141
|
-
* collected. Each span is a faithful record of a reactivity decision the
|
|
142
|
-
* compiler made for that source range. See {@link ReactivitySpan}.
|
|
143
|
-
*/
|
|
144
|
-
reactivityLens?: ReactivitySpan[]
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Props that should never be wrapped in a reactive getter
|
|
148
|
-
const SKIP_PROPS = new Set(['key', 'ref'])
|
|
149
|
-
// Event handler pattern: onClick, onInput, onMouseEnter, …
|
|
150
|
-
const EVENT_RE = /^on[A-Z]/
|
|
151
|
-
// Events delegated to the container — must match runtime DELEGATED_EVENTS set
|
|
152
|
-
const DELEGATED_EVENTS = new Set([
|
|
153
|
-
'click', 'dblclick', 'contextmenu', 'focusin', 'focusout', 'input',
|
|
154
|
-
'change', 'keydown', 'keyup', 'mousedown', 'mouseup', 'mousemove',
|
|
155
|
-
'mouseover', 'mouseout', 'pointerdown', 'pointerup', 'pointermove',
|
|
156
|
-
'pointerover', 'pointerout', 'touchstart', 'touchend', 'touchmove',
|
|
157
|
-
'submit',
|
|
158
|
-
])
|
|
159
|
-
|
|
160
|
-
export interface TransformOptions {
|
|
161
|
-
/**
|
|
162
|
-
* Compile for server-side rendering. When true, the compiler skips the
|
|
163
|
-
* `_tpl()` template optimization and falls back to plain `h()` calls so
|
|
164
|
-
* `@pyreon/runtime-server` can walk the VNode tree. Default: false.
|
|
165
|
-
*/
|
|
166
|
-
ssr?: boolean
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Known signal variable names from resolved imports.
|
|
170
|
-
* The Vite plugin maintains a cross-module signal export registry and
|
|
171
|
-
* passes imported signal names here so the compiler can auto-call them
|
|
172
|
-
* in JSX even though the `signal()` declaration is in another file.
|
|
173
|
-
*
|
|
174
|
-
* @example
|
|
175
|
-
* // store.ts: export const count = signal(0)
|
|
176
|
-
* // component.tsx: import { count } from './store'
|
|
177
|
-
* transformJSX(code, 'component.tsx', { knownSignals: ['count'] })
|
|
178
|
-
* // {count} in JSX → {() => count()}
|
|
179
|
-
*/
|
|
180
|
-
knownSignals?: string[]
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Collect the {@link ReactivitySpan} sidecar (`TransformResult.reactivityLens`).
|
|
184
|
-
* Default `false`. Purely additive — the emitted `code` is byte-identical
|
|
185
|
-
* whether this is on or off (asserted by the compiler equivalence tests).
|
|
186
|
-
* The lens records reactivity decisions the compiler ALREADY makes for
|
|
187
|
-
* codegen; it never runs a second analysis pass.
|
|
188
|
-
*/
|
|
189
|
-
reactivityLens?: boolean
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* P0 — compile-time rocketstyle wrapper collapse. OFF unless the Vite
|
|
193
|
-
* plugin supplies this (opt-in `pyreon({ collapse: true })`). The plugin
|
|
194
|
-
* scans the module's imports for collapsible component candidates,
|
|
195
|
-
* SSR-resolves each literal-prop call site once (real component, light
|
|
196
|
-
* + dark), and passes the resolved `sites` map keyed by
|
|
197
|
-
* {@link rocketstyleCollapseKey}. The compiler only DETECTS the
|
|
198
|
-
* collapsible shape (bail catalogue — every dimension prop a string
|
|
199
|
-
* literal, no spread, static-text children) and EMITS the collapsed
|
|
200
|
-
* `_rsCollapse` call + the once-per-module rule injection; it never
|
|
201
|
-
* runs the rocketstyle chain itself (RFC decision 2).
|
|
202
|
-
*/
|
|
203
|
-
collapseRocketstyle?: {
|
|
204
|
-
/** Component names imported into this module that MAY collapse. */
|
|
205
|
-
candidates: Set<string>
|
|
206
|
-
/** key → resolved emission data (absent ⇒ bail, keep normal mount). */
|
|
207
|
-
sites: Map<
|
|
208
|
-
string,
|
|
209
|
-
{
|
|
210
|
-
templateHtml: string
|
|
211
|
-
lightClass: string
|
|
212
|
-
darkClass: string
|
|
213
|
-
rules: string[]
|
|
214
|
-
ruleKey: string
|
|
215
|
-
}
|
|
216
|
-
>
|
|
217
|
-
/** Live mode accessor to thread for dual-emit (RFC decision 1). */
|
|
218
|
-
mode: { name: string; source: string }
|
|
219
|
-
/** Module specifier for `_rsCollapse`. Default `@pyreon/runtime-dom`. */
|
|
220
|
-
runtimeDomSource?: string
|
|
221
|
-
/** Module specifier for the styler `sheet`. Default `@pyreon/styler`. */
|
|
222
|
-
stylerSource?: string
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Canonical key for a collapsible rocketstyle call site. The Vite plugin
|
|
228
|
-
* computes this when it resolves a site; the compiler recomputes the
|
|
229
|
-
* IDENTICAL key from the JSX node to look the resolution up. Stable
|
|
230
|
-
* ordering of props so attribute order in source doesn't change the key.
|
|
231
|
-
*/
|
|
232
|
-
export function rocketstyleCollapseKey(
|
|
233
|
-
componentName: string,
|
|
234
|
-
props: Record<string, string>,
|
|
235
|
-
childrenText: string,
|
|
236
|
-
): string {
|
|
237
|
-
const propStr = Object.keys(props)
|
|
238
|
-
.sort()
|
|
239
|
-
.map((k) => `${k}=${props[k]}`)
|
|
240
|
-
.join('\u0001')
|
|
241
|
-
const src = `${componentName}\u0000${propStr}\u0000${childrenText}`
|
|
242
|
-
let h = 2166136261
|
|
243
|
-
for (let i = 0; i < src.length; i++) {
|
|
244
|
-
h ^= src.charCodeAt(i)
|
|
245
|
-
h = Math.imul(h, 16777619)
|
|
246
|
-
}
|
|
247
|
-
return (h >>> 0).toString(36)
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// ─── oxc ESTree helpers ───────────────────────────────────────────────────────
|
|
251
|
-
|
|
252
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
253
|
-
type N = any // ESTree node — untyped for speed, matches the lint package approach
|
|
254
|
-
|
|
255
|
-
function getLang(filename: string): 'tsx' | 'jsx' {
|
|
256
|
-
if (filename.endsWith('.jsx')) return 'jsx'
|
|
257
|
-
// Default to tsx so JSX is always parsed — matches the original TypeScript
|
|
258
|
-
// parser behavior which forced ScriptKind.TSX for all files.
|
|
259
|
-
return 'tsx'
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/** Binary search for line/column from byte offset. */
|
|
263
|
-
function makeLineIndex(code: string): (offset: number) => { line: number; column: number } {
|
|
264
|
-
const lineStarts = [0]
|
|
265
|
-
for (let i = 0; i < code.length; i++) {
|
|
266
|
-
if (code[i] === '\n') lineStarts.push(i + 1)
|
|
267
|
-
}
|
|
268
|
-
return (offset: number) => {
|
|
269
|
-
let lo = 0
|
|
270
|
-
let hi = lineStarts.length - 1
|
|
271
|
-
while (lo <= hi) {
|
|
272
|
-
const mid = (lo + hi) >>> 1
|
|
273
|
-
if (lineStarts[mid]! <= offset) lo = mid + 1
|
|
274
|
-
else hi = mid - 1
|
|
275
|
-
}
|
|
276
|
-
return { line: lo, column: offset - lineStarts[lo - 1]! }
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/** Iterate all direct children of an ESTree node via known property keys. */
|
|
281
|
-
function forEachChild(node: N, cb: (child: N) => void): void {
|
|
282
|
-
if (!node || typeof node !== 'object') return
|
|
283
|
-
const keys = Object.keys(node)
|
|
284
|
-
for (let i = 0; i < keys.length; i++) {
|
|
285
|
-
const key = keys[i]!
|
|
286
|
-
// Skip metadata fields for speed
|
|
287
|
-
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue
|
|
288
|
-
const val = node[key]
|
|
289
|
-
if (Array.isArray(val)) {
|
|
290
|
-
for (let j = 0; j < val.length; j++) {
|
|
291
|
-
const item = val[j]
|
|
292
|
-
if (item && typeof item === 'object' && item.type) cb(item)
|
|
293
|
-
}
|
|
294
|
-
} else if (val && typeof val === 'object' && val.type) {
|
|
295
|
-
cb(val)
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// ─── JSX element helpers ────────────────────────────────────────────────────
|
|
301
|
-
|
|
302
|
-
function jsxTagName(node: N): string {
|
|
303
|
-
const opening = node.openingElement
|
|
304
|
-
if (!opening) return ''
|
|
305
|
-
const name = opening.name
|
|
306
|
-
return name?.type === 'JSXIdentifier' ? name.name : ''
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function isSelfClosing(node: N): boolean {
|
|
310
|
-
return node.type === 'JSXElement' && node.openingElement?.selfClosing === true
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
function jsxAttrs(node: N): N[] {
|
|
314
|
-
return node.openingElement?.attributes ?? []
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function jsxChildren(node: N): N[] {
|
|
318
|
-
return node.children ?? []
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* A collapsible call site found by {@link scanCollapsibleSites}.
|
|
323
|
-
* `componentName` is the LOCAL JSX tag (post-import-alias) — it MUST be
|
|
324
|
-
* what `rocketstyleCollapseKey` is computed from on BOTH sides so the
|
|
325
|
-
* plugin's resolved `sites` map keys match the compiler's lookups.
|
|
326
|
-
*/
|
|
327
|
-
export interface CollapsibleSite {
|
|
328
|
-
/** Local JSX tag name (the key + the compiler's detection use this). */
|
|
329
|
-
componentName: string
|
|
330
|
-
/** Module specifier the component was imported from (for the resolver). */
|
|
331
|
-
source: string
|
|
332
|
-
/** Imported binding name at `source` (may differ from local if aliased). */
|
|
333
|
-
importedName: string
|
|
334
|
-
/** Literal string-valued props (the only shape the slice collapses). */
|
|
335
|
-
props: Record<string, string>
|
|
336
|
-
/** Static text children (trimmed; empty ⇒ none). */
|
|
337
|
-
childrenText: string
|
|
338
|
-
/** `rocketstyleCollapseKey(componentName, props, childrenText)`. */
|
|
339
|
-
key: string
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* Build a `localName → { imported, source }` table from a module's
|
|
344
|
-
* import declarations. Only named imports (`import { X as Y }`) are
|
|
345
|
-
* relevant — the collapsible components are always named exports.
|
|
346
|
-
*/
|
|
347
|
-
function collectImportTable(program: N): Map<string, { imported: string; source: string }> {
|
|
348
|
-
const table = new Map<string, { imported: string; source: string }>()
|
|
349
|
-
for (const stmt of program.body ?? []) {
|
|
350
|
-
if (stmt.type !== 'ImportDeclaration') continue
|
|
351
|
-
const source = stmt.source?.value
|
|
352
|
-
if (typeof source !== 'string') continue
|
|
353
|
-
for (const spec of stmt.specifiers ?? []) {
|
|
354
|
-
if (spec.type !== 'ImportSpecifier') continue
|
|
355
|
-
const local = spec.local?.name
|
|
356
|
-
const imported = spec.imported?.name ?? local
|
|
357
|
-
if (typeof local === 'string') table.set(local, { imported, source })
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
return table
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* Pure detector — finds every collapsible rocketstyle call site in a
|
|
365
|
-
* module. Used by `@pyreon/vite-plugin` to know which (component, props,
|
|
366
|
-
* text) tuples to SSR-resolve. The bail catalogue here MUST stay
|
|
367
|
-
* byte-identical to `tryRocketstyleCollapse`'s (RFC decision 3): a
|
|
368
|
-
* candidate PascalCase tag whose import source is in `collapsibleSources`,
|
|
369
|
-
* every attr a plain string literal (no spread, no `{expr}`, no boolean
|
|
370
|
-
* attr), children empty or static text only. A consistency test asserts
|
|
371
|
-
* the keys this produces equal the keys the compiler looks up.
|
|
372
|
-
*/
|
|
373
|
-
export function scanCollapsibleSites(
|
|
374
|
-
code: string,
|
|
375
|
-
filename: string,
|
|
376
|
-
collapsibleSources: Set<string>,
|
|
377
|
-
): CollapsibleSite[] {
|
|
378
|
-
let program: N
|
|
379
|
-
try {
|
|
380
|
-
program = parseSync(filename, code, { sourceType: 'module', lang: getLang(filename) }).program
|
|
381
|
-
} catch {
|
|
382
|
-
return []
|
|
383
|
-
}
|
|
384
|
-
const imports = collectImportTable(program)
|
|
385
|
-
const out: CollapsibleSite[] = []
|
|
386
|
-
const visit = (node: N): void => {
|
|
387
|
-
if (!node || typeof node !== 'object') return
|
|
388
|
-
if (node.type === 'JSXElement') {
|
|
389
|
-
const tag = jsxTagName(node)
|
|
390
|
-
const imp = tag ? imports.get(tag) : undefined
|
|
391
|
-
if (
|
|
392
|
-
tag &&
|
|
393
|
-
tag.charAt(0) !== tag.charAt(0).toLowerCase() &&
|
|
394
|
-
imp &&
|
|
395
|
-
collapsibleSources.has(imp.source)
|
|
396
|
-
) {
|
|
397
|
-
const site = detectCollapsibleShape(node, tag)
|
|
398
|
-
if (site) {
|
|
399
|
-
out.push({
|
|
400
|
-
componentName: tag,
|
|
401
|
-
source: imp.source,
|
|
402
|
-
importedName: imp.imported,
|
|
403
|
-
props: site.props,
|
|
404
|
-
childrenText: site.childrenText,
|
|
405
|
-
key: rocketstyleCollapseKey(tag, site.props, site.childrenText),
|
|
406
|
-
})
|
|
407
|
-
} else {
|
|
408
|
-
// Dynamic-prop fallthrough: if the full detector bailed but
|
|
409
|
-
// the site matches the ternary-of-two-literals shape, expand
|
|
410
|
-
// into TWO CollapsibleSite entries — one per literal value.
|
|
411
|
-
// Each expanded site is byte-identical to a static-collapse
|
|
412
|
-
// site for that value, so the resolver pre-renders both via
|
|
413
|
-
// the existing SSR pipeline and the compiler emit looks up
|
|
414
|
-
// both by their respective keys to build the dispatcher.
|
|
415
|
-
//
|
|
416
|
-
// No-handler sites route to `__rsCollapseDyn`; handler-bearing
|
|
417
|
-
// sites route to `__rsCollapseDynH` (handlers are orthogonal
|
|
418
|
-
// to the SSR-resolved styler class — see `tryDynamicCollapse`
|
|
419
|
-
// in this file). The scan does NOT distinguish here because
|
|
420
|
-
// the resolver only cares about (componentName, props, text);
|
|
421
|
-
// handlers don't affect the resolution.
|
|
422
|
-
const dyn = detectDynamicCollapsibleShape(node, tag)
|
|
423
|
-
if (dyn) {
|
|
424
|
-
for (const value of [dyn.dynamicProp.valueTruthy, dyn.dynamicProp.valueFalsy]) {
|
|
425
|
-
const expandedProps = { ...dyn.props, [dyn.dynamicProp.name]: value }
|
|
426
|
-
out.push({
|
|
427
|
-
componentName: tag,
|
|
428
|
-
source: imp.source,
|
|
429
|
-
importedName: imp.imported,
|
|
430
|
-
props: expandedProps,
|
|
431
|
-
childrenText: dyn.childrenText,
|
|
432
|
-
key: rocketstyleCollapseKey(tag, expandedProps, dyn.childrenText),
|
|
433
|
-
})
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
for (const k in node) {
|
|
440
|
-
const v = node[k]
|
|
441
|
-
if (Array.isArray(v)) for (const c of v) visit(c)
|
|
442
|
-
else if (v && typeof v === 'object' && typeof v.type === 'string') visit(v)
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
visit(program)
|
|
446
|
-
return out
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* The shared bail catalogue — every attr a string literal (no spread, no
|
|
451
|
-
* `{expr}`, no boolean attr), children empty or static text. Returns the
|
|
452
|
-
* extracted {props, childrenText} or null (bail). `tryRocketstyleCollapse`
|
|
453
|
-
* inlines the identical checks; a consistency test locks them together.
|
|
454
|
-
*/
|
|
455
|
-
function detectCollapsibleShape(
|
|
456
|
-
node: N,
|
|
457
|
-
_tag: string,
|
|
458
|
-
): { props: Record<string, string>; childrenText: string } | null {
|
|
459
|
-
const props: Record<string, string> = {}
|
|
460
|
-
for (const attr of jsxAttrs(node)) {
|
|
461
|
-
if (attr.type !== 'JSXAttribute') return null // spread → bail
|
|
462
|
-
const nm = attr.name?.type === 'JSXIdentifier' ? attr.name.name : null
|
|
463
|
-
if (!nm) return null
|
|
464
|
-
const v = attr.value
|
|
465
|
-
if (!v) return null // boolean attr → bail
|
|
466
|
-
const isStr =
|
|
467
|
-
v.type === 'StringLiteral' || (v.type === 'Literal' && typeof v.value === 'string')
|
|
468
|
-
if (!isStr) return null // `{expr}` / dynamic → bail
|
|
469
|
-
props[nm] = String(v.value)
|
|
470
|
-
}
|
|
471
|
-
let childrenText = ''
|
|
472
|
-
for (const c of jsxChildren(node)) {
|
|
473
|
-
if (c.type === 'JSXText') childrenText += (c.value ?? '') as string
|
|
474
|
-
else return null // element / expression child → bail
|
|
475
|
-
}
|
|
476
|
-
return { props, childrenText: childrenText.trim() }
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/** A residual event handler peeled off a partially-collapsible site. */
|
|
480
|
-
export interface CollapsibleHandler {
|
|
481
|
-
/** JSX attribute name, e.g. `onClick`. */
|
|
482
|
-
name: string
|
|
483
|
-
/** Source span of the handler expression (the `{...}` contents). */
|
|
484
|
-
exprStart: number
|
|
485
|
-
exprEnd: number
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* Partial-collapse detector — PR 1 of the partial-collapse spec
|
|
490
|
-
* (`.claude/plans/open-work-2026-q3.md` → #1). The `on*`-handler-only
|
|
491
|
-
* subset the bail-reason census measured at 7.8% of all
|
|
492
|
-
* `@pyreon/ui-components` call sites (`collapse-bail-census.test.ts`).
|
|
493
|
-
*
|
|
494
|
-
* It is the EXACT `detectCollapsibleShape` bail catalogue with ONE
|
|
495
|
-
* relaxation: a `{expr}`-valued attribute whose name matches `on[A-Z]…`
|
|
496
|
-
* (an event handler) does NOT bail — it is peeled into `handlers[]`
|
|
497
|
-
* instead. Handlers are orthogonal to the SSR-resolved styler class (an
|
|
498
|
-
* event binding never changes rendered CSS), so the literal-prop subset
|
|
499
|
-
* still feeds the UNCHANGED `rocketstyleCollapseKey` and the resolver's
|
|
500
|
-
* pre-resolved `templateHtml` / `lightClass` / `darkClass` are
|
|
501
|
-
* byte-identical to a full-collapse site's. The collapsed runtime node
|
|
502
|
-
* just re-attaches the residual handlers (PR 2 — `_rsCollapseH`).
|
|
503
|
-
*
|
|
504
|
-
* Every OTHER non-literal shape still bails (spread, non-handler
|
|
505
|
-
* `{expr}` prop, boolean attr, element/expression child) — conservative
|
|
506
|
-
* by construction, exactly like the full detector. Returns `null` when
|
|
507
|
-
* there are ZERO handlers so the full-collapse path stays byte-unchanged
|
|
508
|
-
* and the two detectors never both claim the same site (full-collapse
|
|
509
|
-
* sites have no handlers; partial sites have ≥1). A consistency test
|
|
510
|
-
* will lock this catalogue against the plugin scan in PR 3, mirroring
|
|
511
|
-
* the existing `detectCollapsibleShape` ↔ `scanCollapsibleSites`
|
|
512
|
-
* invariant — keys cannot drift.
|
|
513
|
-
*/
|
|
514
|
-
export function detectPartialCollapsibleShape(
|
|
515
|
-
node: N,
|
|
516
|
-
_tag: string,
|
|
517
|
-
): { props: Record<string, string>; childrenText: string; handlers: CollapsibleHandler[] } | null {
|
|
518
|
-
const props: Record<string, string> = {}
|
|
519
|
-
const handlers: CollapsibleHandler[] = []
|
|
520
|
-
for (const attr of jsxAttrs(node)) {
|
|
521
|
-
if (attr.type !== 'JSXAttribute') return null // spread → bail
|
|
522
|
-
const nm = attr.name?.type === 'JSXIdentifier' ? attr.name.name : null
|
|
523
|
-
if (!nm) return null
|
|
524
|
-
const v = attr.value
|
|
525
|
-
if (!v) return null // boolean attr → bail
|
|
526
|
-
const isStr =
|
|
527
|
-
v.type === 'StringLiteral' || (v.type === 'Literal' && typeof v.value === 'string')
|
|
528
|
-
if (isStr) {
|
|
529
|
-
props[nm] = String(v.value)
|
|
530
|
-
continue
|
|
531
|
-
}
|
|
532
|
-
// Non-literal: ONLY an `on[A-Z]…` handler in a `{expr}` container is
|
|
533
|
-
// peelable. Everything else (non-handler dynamic prop, shorthand
|
|
534
|
-
// `onClick` without a container, etc.) is a hard bail — same
|
|
535
|
-
// conservatism as the full detector.
|
|
536
|
-
if (
|
|
537
|
-
/^on[A-Z]/.test(nm) &&
|
|
538
|
-
v.type === 'JSXExpressionContainer' &&
|
|
539
|
-
v.expression &&
|
|
540
|
-
typeof v.expression.start === 'number' &&
|
|
541
|
-
typeof v.expression.end === 'number'
|
|
542
|
-
) {
|
|
543
|
-
handlers.push({ name: nm, exprStart: v.expression.start, exprEnd: v.expression.end })
|
|
544
|
-
continue
|
|
545
|
-
}
|
|
546
|
-
return null // `{expr}` non-handler / dynamic → bail
|
|
547
|
-
}
|
|
548
|
-
let childrenText = ''
|
|
549
|
-
for (const c of jsxChildren(node)) {
|
|
550
|
-
if (c.type === 'JSXText') childrenText += (c.value ?? '') as string
|
|
551
|
-
else return null // element / expression child → bail
|
|
552
|
-
}
|
|
553
|
-
// Zero handlers ⇒ this is the FULL-collapse shape; defer to
|
|
554
|
-
// `detectCollapsibleShape` so the existing path stays byte-unchanged.
|
|
555
|
-
if (handlers.length === 0) return null
|
|
556
|
-
return { props, childrenText: childrenText.trim(), handlers }
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
/**
|
|
560
|
-
* A dynamic dimension prop on a collapsible call site. A ConditionalExpression
|
|
561
|
-
* (ternary) where both branches are string literals — `state={cond ? 'a' : 'b'}`
|
|
562
|
-
* is the canonical shape. Pre-resolution: the prop's value belongs to the
|
|
563
|
-
* enumerable set `[valueA, valueB]`. The compiler emits one collapsed
|
|
564
|
-
* variant per literal value + a dispatcher on the original `cond`.
|
|
565
|
-
*/
|
|
566
|
-
export interface DynamicCollapsibleProp {
|
|
567
|
-
/** JSX attribute name, e.g. `state`. */
|
|
568
|
-
name: string
|
|
569
|
-
/** Source span of the ternary condition (the `cond` part), re-emitted into the runtime dispatcher. */
|
|
570
|
-
condStart: number
|
|
571
|
-
condEnd: number
|
|
572
|
-
/** Literal value for the `cond === truthy` branch (consequent). */
|
|
573
|
-
valueTruthy: string
|
|
574
|
-
/** Literal value for the `cond === falsy` branch (alternate). */
|
|
575
|
-
valueFalsy: string
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
/**
|
|
579
|
-
* Dynamic-prop partial-collapse detector — PR 2 of the dynamic-prop
|
|
580
|
-
* partial-collapse build (`.claude/plans/open-work-2026-q3.md` → #1
|
|
581
|
-
* dynamic-prop bucket = 15.3% of all real-corpus sites; the next-bigger
|
|
582
|
-
* bite after the `on*`-handler partial-collapse).
|
|
583
|
-
*
|
|
584
|
-
* Mirrors `detectPartialCollapsibleShape`'s "extend the bail catalogue
|
|
585
|
-
* with ONE relaxation" pattern (see that detector's docstring + `PR 1`
|
|
586
|
-
* `_rsCollapseDyn` runtime helper, PR #765). The single relaxation: a
|
|
587
|
-
* `JSXExpressionContainer` wrapping a `ConditionalExpression` whose
|
|
588
|
-
* `consequent` AND `alternate` are BOTH `StringLiteral` is acceptable as
|
|
589
|
-
* a "ternary-of-two-literals" dynamic prop — captured as a {@link DynamicCollapsibleProp}
|
|
590
|
-
* with the cond source span + the two literal values.
|
|
591
|
-
*
|
|
592
|
-
* Constraint: **AT MOST ONE** such dynamic prop per site. Multiple
|
|
593
|
-
* ternaries would compound into a 2^N value-set per site at build time
|
|
594
|
-
* and an N-axis dispatcher at runtime — that's a separable scope
|
|
595
|
-
* (potential PR 5+), NOT this PR. Sites with 2+ ternaries bail (return
|
|
596
|
-
* null), keeping the normal mount; same conservative shape as the rest
|
|
597
|
-
* of the detector family.
|
|
598
|
-
*
|
|
599
|
-
* Constraint: the FULL `on*`-handler relaxation is also folded in — a
|
|
600
|
-
* site can have ONE ternary AND `on*` handlers in the same call. This
|
|
601
|
-
* matches the real-corpus shape (a Button with `state={cond ? 'a' : 'b'}`
|
|
602
|
-
* almost always also has an `onClick`). The two relaxations compose
|
|
603
|
-
* cleanly because they're orthogonal at the resolver layer (handlers
|
|
604
|
-
* don't change rendered CSS; the ternary picks among pre-resolved
|
|
605
|
-
* classes). PR 3's emit will use `_rsCollapseDyn` when handlers are
|
|
606
|
-
* absent and a future combined helper when both are present — for THIS
|
|
607
|
-
* PR (detector-only) the structure carries both so PR 3 can dispatch.
|
|
608
|
-
*
|
|
609
|
-
* Every OTHER non-literal shape still bails (spread, non-handler
|
|
610
|
-
* non-ternary `{expr}` prop, multi-literal ternary anywhere, computed-
|
|
611
|
-
* expression ternary, element/expression child, boolean attr) —
|
|
612
|
-
* conservative by construction, exactly like the rest of the family.
|
|
613
|
-
* Returns `null` when there are ZERO ternaries so the on*-only path
|
|
614
|
-
* (`detectPartialCollapsibleShape`) and the full-collapse path
|
|
615
|
-
* (`detectCollapsibleShape`) stay byte-unchanged and no detector both
|
|
616
|
-
* claims the same site.
|
|
617
|
-
*
|
|
618
|
-
* A consistency test (PR 3) will lock this catalogue against the
|
|
619
|
-
* plugin scan, mirroring the `detectCollapsibleShape` ↔ `scanCollapsibleSites`
|
|
620
|
-
* + `detectPartialCollapsibleShape` ↔ scan invariants — keys cannot drift.
|
|
621
|
-
*/
|
|
622
|
-
export function detectDynamicCollapsibleShape(
|
|
623
|
-
node: N,
|
|
624
|
-
_tag: string,
|
|
625
|
-
): {
|
|
626
|
-
props: Record<string, string>
|
|
627
|
-
childrenText: string
|
|
628
|
-
handlers: CollapsibleHandler[]
|
|
629
|
-
dynamicProp: DynamicCollapsibleProp
|
|
630
|
-
} | null {
|
|
631
|
-
const props: Record<string, string> = {}
|
|
632
|
-
const handlers: CollapsibleHandler[] = []
|
|
633
|
-
const dynamicProps: DynamicCollapsibleProp[] = []
|
|
634
|
-
for (const attr of jsxAttrs(node)) {
|
|
635
|
-
if (attr.type !== 'JSXAttribute') return null // spread → bail
|
|
636
|
-
const nm = attr.name?.type === 'JSXIdentifier' ? attr.name.name : null
|
|
637
|
-
if (!nm) return null
|
|
638
|
-
const v = attr.value
|
|
639
|
-
if (!v) return null // boolean attr → bail
|
|
640
|
-
const isStr =
|
|
641
|
-
v.type === 'StringLiteral' || (v.type === 'Literal' && typeof v.value === 'string')
|
|
642
|
-
if (isStr) {
|
|
643
|
-
props[nm] = String(v.value)
|
|
644
|
-
continue
|
|
645
|
-
}
|
|
646
|
-
// Non-literal in a `{expr}` container — three possible relaxations:
|
|
647
|
-
// (a) `on[A-Z]…` handler with any expression → peeled
|
|
648
|
-
// (b) any other prop whose expression is a ternary of two string
|
|
649
|
-
// literals → peeled as a DynamicCollapsibleProp
|
|
650
|
-
// (c) anything else → bail
|
|
651
|
-
if (
|
|
652
|
-
v.type === 'JSXExpressionContainer' &&
|
|
653
|
-
v.expression &&
|
|
654
|
-
typeof v.expression.start === 'number' &&
|
|
655
|
-
typeof v.expression.end === 'number'
|
|
656
|
-
) {
|
|
657
|
-
if (/^on[A-Z]/.test(nm)) {
|
|
658
|
-
handlers.push({ name: nm, exprStart: v.expression.start, exprEnd: v.expression.end })
|
|
659
|
-
continue
|
|
660
|
-
}
|
|
661
|
-
const expr = v.expression
|
|
662
|
-
if (
|
|
663
|
-
expr.type === 'ConditionalExpression' &&
|
|
664
|
-
expr.test &&
|
|
665
|
-
typeof expr.test.start === 'number' &&
|
|
666
|
-
typeof expr.test.end === 'number' &&
|
|
667
|
-
expr.consequent &&
|
|
668
|
-
expr.alternate
|
|
669
|
-
) {
|
|
670
|
-
// Both branches must be StringLiteral. We deliberately do NOT
|
|
671
|
-
// accept TemplateLiteral / `as`-casted literals / any other
|
|
672
|
-
// shape — keep the static-resolvable set narrow + provable.
|
|
673
|
-
const isLitStr = (n: unknown): n is { type: 'StringLiteral'; value: string } => {
|
|
674
|
-
const x = n as { type?: string; value?: unknown }
|
|
675
|
-
return (
|
|
676
|
-
x?.type === 'StringLiteral' ||
|
|
677
|
-
(x?.type === 'Literal' && typeof x.value === 'string')
|
|
678
|
-
)
|
|
679
|
-
}
|
|
680
|
-
if (isLitStr(expr.consequent) && isLitStr(expr.alternate)) {
|
|
681
|
-
dynamicProps.push({
|
|
682
|
-
name: nm,
|
|
683
|
-
condStart: expr.test.start,
|
|
684
|
-
condEnd: expr.test.end,
|
|
685
|
-
valueTruthy: String((expr.consequent as { value: string }).value),
|
|
686
|
-
valueFalsy: String((expr.alternate as { value: string }).value),
|
|
687
|
-
})
|
|
688
|
-
continue
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
return null // `{expr}` non-handler non-ternary, or non-literal ternary branch → bail
|
|
693
|
-
}
|
|
694
|
-
let childrenText = ''
|
|
695
|
-
for (const c of jsxChildren(node)) {
|
|
696
|
-
if (c.type === 'JSXText') childrenText += (c.value ?? '') as string
|
|
697
|
-
else return null // element / expression child → bail
|
|
698
|
-
}
|
|
699
|
-
// Exactly ONE dynamic prop is the scope of this PR. Zero ⇒ defer to
|
|
700
|
-
// the existing detectors (full / on*-handler partial); 2+ ⇒ bail
|
|
701
|
-
// (multi-axis combinatorics is a separable scope).
|
|
702
|
-
if (dynamicProps.length !== 1) return null
|
|
703
|
-
return {
|
|
704
|
-
props,
|
|
705
|
-
childrenText: childrenText.trim(),
|
|
706
|
-
handlers,
|
|
707
|
-
dynamicProp: dynamicProps[0]!,
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
// ─── Main transform ─────────────────────────────────────────────────────────
|
|
712
|
-
|
|
713
|
-
export function transformJSX(
|
|
714
|
-
code: string,
|
|
715
|
-
filename = 'input.tsx',
|
|
716
|
-
options: TransformOptions = {},
|
|
717
|
-
): TransformResult {
|
|
718
|
-
// `collapseRocketstyle` emission lives only in the JS path (the Rust
|
|
719
|
-
// binary doesn't implement it and isn't passed the option). Force the
|
|
720
|
-
// JS path when collapse is requested so it isn't silently skipped —
|
|
721
|
-
// same pattern as `analyzeReactivity` forcing `transformJSX_JS`.
|
|
722
|
-
if (options.collapseRocketstyle) return transformJSX_JS(code, filename, options)
|
|
723
|
-
|
|
724
|
-
// Try Rust native binary first (3.7-8.2x faster).
|
|
725
|
-
// Per-call try/catch: if the native binary panics on an edge case
|
|
726
|
-
// (bad UTF-8, unexpected AST shape), fall back gracefully instead
|
|
727
|
-
// of crashing the Vite dev server.
|
|
728
|
-
if (nativeTransformJsx) {
|
|
729
|
-
try {
|
|
730
|
-
return nativeTransformJsx(
|
|
731
|
-
code,
|
|
732
|
-
filename,
|
|
733
|
-
options.ssr === true,
|
|
734
|
-
options.knownSignals ?? null,
|
|
735
|
-
options.reactivityLens === true,
|
|
736
|
-
)
|
|
737
|
-
} catch {
|
|
738
|
-
// Native transform failed — fall through to JS implementation
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
return transformJSX_JS(code, filename, options)
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
/** JS fallback implementation — used when the native binary isn't available. */
|
|
745
|
-
export function transformJSX_JS(
|
|
746
|
-
code: string,
|
|
747
|
-
filename = 'input.tsx',
|
|
748
|
-
options: TransformOptions = {},
|
|
749
|
-
): TransformResult {
|
|
750
|
-
const ssr = options.ssr === true
|
|
751
|
-
|
|
752
|
-
let program: N
|
|
753
|
-
try {
|
|
754
|
-
const result = parseSync(filename, code, {
|
|
755
|
-
sourceType: 'module',
|
|
756
|
-
lang: getLang(filename),
|
|
757
|
-
})
|
|
758
|
-
program = result.program
|
|
759
|
-
} catch {
|
|
760
|
-
return { code, warnings: [] }
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
const locate = makeLineIndex(code)
|
|
764
|
-
|
|
765
|
-
type Replacement = { start: number; end: number; text: string }
|
|
766
|
-
const replacements: Replacement[] = []
|
|
767
|
-
const warnings: CompilerWarning[] = []
|
|
768
|
-
|
|
769
|
-
function warn(node: N, message: string, warnCode: CompilerWarning['code']): void {
|
|
770
|
-
const { line, column } = locate(node.start as number)
|
|
771
|
-
warnings.push({ message, line, column, code: warnCode })
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
// ── Reactivity lens (opt-in, additive — never affects `result`) ───────────
|
|
775
|
-
const collectLens = options.reactivityLens === true
|
|
776
|
-
const reactivityLens: ReactivitySpan[] = []
|
|
777
|
-
function lens(start: number, end: number, kind: ReactivityKind, detail: string): void {
|
|
778
|
-
if (!collectLens) return
|
|
779
|
-
const a = locate(start)
|
|
780
|
-
const b = locate(end)
|
|
781
|
-
reactivityLens.push({
|
|
782
|
-
start,
|
|
783
|
-
end,
|
|
784
|
-
line: a.line,
|
|
785
|
-
column: a.column,
|
|
786
|
-
endLine: b.line,
|
|
787
|
-
endColumn: b.column,
|
|
788
|
-
kind,
|
|
789
|
-
detail,
|
|
790
|
-
})
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
// ── Parent + children maps (built once, eliminates repeated Object.keys) ──
|
|
794
|
-
const parentMap = new WeakMap<object, N>()
|
|
795
|
-
const childrenMap = new WeakMap<object, N[]>()
|
|
796
|
-
|
|
797
|
-
/** Build parent pointers + cached children arrays for the entire AST. */
|
|
798
|
-
function buildMaps(node: N): void {
|
|
799
|
-
const kids: N[] = []
|
|
800
|
-
const keys = Object.keys(node)
|
|
801
|
-
for (let i = 0; i < keys.length; i++) {
|
|
802
|
-
const key = keys[i]!
|
|
803
|
-
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue
|
|
804
|
-
const val = node[key]
|
|
805
|
-
if (Array.isArray(val)) {
|
|
806
|
-
for (let j = 0; j < val.length; j++) {
|
|
807
|
-
const item = val[j]
|
|
808
|
-
if (item && typeof item === 'object' && item.type) kids.push(item)
|
|
809
|
-
}
|
|
810
|
-
} else if (val && typeof val === 'object' && val.type) {
|
|
811
|
-
kids.push(val)
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
childrenMap.set(node, kids)
|
|
815
|
-
for (let i = 0; i < kids.length; i++) {
|
|
816
|
-
parentMap.set(kids[i]!, node)
|
|
817
|
-
buildMaps(kids[i]!)
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
buildMaps(program)
|
|
821
|
-
|
|
822
|
-
function findParent(node: N): N | undefined {
|
|
823
|
-
return parentMap.get(node)
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
/** Fast child iteration using pre-computed children array. */
|
|
827
|
-
function forEachChildFast(node: N, cb: (child: N) => void): void {
|
|
828
|
-
const kids = childrenMap.get(node)
|
|
829
|
-
if (!kids) return
|
|
830
|
-
for (let i = 0; i < kids.length; i++) cb(kids[i]!)
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
// ── Static hoisting state ─────────────────────────────────────────────────
|
|
834
|
-
type Hoist = { name: string; text: string }
|
|
835
|
-
const hoists: Hoist[] = []
|
|
836
|
-
let hoistIdx = 0
|
|
837
|
-
let needsTplImport = false
|
|
838
|
-
let needsRpImport = false
|
|
839
|
-
let needsWrapSpreadImport = false
|
|
840
|
-
let needsBindTextImportGlobal = false
|
|
841
|
-
let needsBindDirectImportGlobal = false
|
|
842
|
-
let needsBindImportGlobal = false
|
|
843
|
-
let needsApplyPropsImportGlobal = false
|
|
844
|
-
let needsMountSlotImportGlobal = false
|
|
845
|
-
|
|
846
|
-
// ── P0 rocketstyle-collapse state ─────────────────────────────────────────
|
|
847
|
-
let needsCollapse = false
|
|
848
|
-
let needsCollapseH = false
|
|
849
|
-
let needsCollapseDyn = false
|
|
850
|
-
let needsCollapseDynH = false
|
|
851
|
-
const collapseRuleKeys = new Set<string>()
|
|
852
|
-
const collapseRules: Array<{ ruleKey: string; rules: string[] }> = []
|
|
853
|
-
|
|
854
|
-
/**
|
|
855
|
-
* Detect + collapse a literal-prop rocketstyle call site. Conservative
|
|
856
|
-
* bail catalogue (RFC decision 3): PascalCase candidate, every attr a
|
|
857
|
-
* StringLiteral (no spread, no `{expr}`, no boolean attr), children
|
|
858
|
-
* empty or a single static JSXText. The plugin must already have
|
|
859
|
-
* SSR-resolved this exact (component, props, text) tuple — an absent
|
|
860
|
-
* `sites` entry is a hard bail (covers resolver-bailed shapes,
|
|
861
|
-
* cross-package-without-data, anything uncertain). Emits ONE
|
|
862
|
-
* `_rsCollapse(tpl, light, dark, () => mode()==='dark')` (dual-emit)
|
|
863
|
-
* plus a once-per-module idempotent `injectRules`. A false negative is
|
|
864
|
-
* correct-but-slow; a false positive is wrong output — so every
|
|
865
|
-
* uncertain signal returns false.
|
|
866
|
-
*/
|
|
867
|
-
function tryRocketstyleCollapse(node: N): boolean {
|
|
868
|
-
const cfg = options.collapseRocketstyle
|
|
869
|
-
if (!cfg) return false
|
|
870
|
-
const tag = jsxTagName(node)
|
|
871
|
-
if (!tag || tag.charAt(0) === tag.charAt(0).toLowerCase()) return false
|
|
872
|
-
if (!cfg.candidates.has(tag)) return false
|
|
873
|
-
// Shared bail catalogue — IDENTICAL to scanCollapsibleSites (the
|
|
874
|
-
// plugin scans with the same predicate, so its resolved `sites`
|
|
875
|
-
// keys match these lookups exactly; no drift possible).
|
|
876
|
-
const shape = detectCollapsibleShape(node, tag)
|
|
877
|
-
// Fallthrough chain — same conservative discipline at each layer:
|
|
878
|
-
// 1. on*-handler-only partial (literal dim props + handlers)
|
|
879
|
-
// 2. dynamic-prop partial (ternary-of-two-literals on ≤1 dim prop,
|
|
880
|
-
// no handlers — handler-combined dynamic is a future PR's scope)
|
|
881
|
-
if (!shape) return tryPartialCollapse(node, tag) || tryDynamicCollapse(node, tag)
|
|
882
|
-
const { props, childrenText } = shape
|
|
883
|
-
const key = rocketstyleCollapseKey(tag, props, childrenText)
|
|
884
|
-
const site = cfg.sites.get(key)
|
|
885
|
-
if (!site) return false // not resolved → keep normal rocketstyle mount
|
|
886
|
-
const call =
|
|
887
|
-
`__rsCollapse(${JSON.stringify(site.templateHtml)}, ` +
|
|
888
|
-
`${JSON.stringify(site.lightClass)}, ${JSON.stringify(site.darkClass)}, ` +
|
|
889
|
-
`() => __pyrMode() === "dark")`
|
|
890
|
-
const start = node.start as number
|
|
891
|
-
const end = node.end as number
|
|
892
|
-
const parent = findParent(node)
|
|
893
|
-
const needsBraces =
|
|
894
|
-
parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment')
|
|
895
|
-
replacements.push({ start, end, text: needsBraces ? `{${call}}` : call })
|
|
896
|
-
needsCollapse = true
|
|
897
|
-
if (!collapseRuleKeys.has(site.ruleKey)) {
|
|
898
|
-
collapseRuleKeys.add(site.ruleKey)
|
|
899
|
-
collapseRules.push({ ruleKey: site.ruleKey, rules: site.rules })
|
|
900
|
-
}
|
|
901
|
-
return true
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
/**
|
|
905
|
-
* PR 3 of the partial-collapse build (open-work #1). The `on*`-handler-
|
|
906
|
-
* only fallback `tryRocketstyleCollapse` defers to when the FULL
|
|
907
|
-
* `detectCollapsibleShape` bails. Identical site-resolution contract as
|
|
908
|
-
* the full path — handlers are orthogonal to the SSR-resolved styler
|
|
909
|
-
* class, so the literal-prop subset feeds the UNCHANGED
|
|
910
|
-
* `rocketstyleCollapseKey` and the resolver's pre-resolved
|
|
911
|
-
* `templateHtml`/`lightClass`/`darkClass` are byte-identical to a
|
|
912
|
-
* full-collapse site's. The ONLY difference vs the full emit is
|
|
913
|
-
* `__rsCollapseH(...)` with a handlers object literal built from the
|
|
914
|
-
* sliced source spans `detectPartialCollapsibleShape` (PR 1) returned;
|
|
915
|
-
* the runtime helper (`_rsCollapseH`, PR 2 / #681) re-attaches them
|
|
916
|
-
* through the canonical event path. Same conservative discipline: an
|
|
917
|
-
* unresolved key, the option absent, or any non-handler non-literal
|
|
918
|
-
* shape ⇒ keep the normal mount (return false).
|
|
919
|
-
*/
|
|
920
|
-
function tryPartialCollapse(node: N, tag: string): boolean {
|
|
921
|
-
const cfg = options.collapseRocketstyle
|
|
922
|
-
if (!cfg) return false
|
|
923
|
-
const partial = detectPartialCollapsibleShape(node, tag)
|
|
924
|
-
if (!partial) return false
|
|
925
|
-
const { props, childrenText, handlers } = partial
|
|
926
|
-
const key = rocketstyleCollapseKey(tag, props, childrenText)
|
|
927
|
-
const site = cfg.sites.get(key)
|
|
928
|
-
if (!site) return false // not resolved → keep normal rocketstyle mount
|
|
929
|
-
// `{ "onClick": (<sliced expr>), … }` — each handler expression is
|
|
930
|
-
// re-emitted verbatim from its source span (paren-wrapped so an
|
|
931
|
-
// arrow / sequence expr stays a single argument).
|
|
932
|
-
const handlerObj =
|
|
933
|
-
`{ ${handlers
|
|
934
|
-
.map((h) => `${JSON.stringify(h.name)}: (${code.slice(h.exprStart, h.exprEnd)})`)
|
|
935
|
-
.join(', ')} }`
|
|
936
|
-
const call =
|
|
937
|
-
`__rsCollapseH(${JSON.stringify(site.templateHtml)}, ` +
|
|
938
|
-
`${JSON.stringify(site.lightClass)}, ${JSON.stringify(site.darkClass)}, ` +
|
|
939
|
-
`() => __pyrMode() === "dark", ${handlerObj})`
|
|
940
|
-
const start = node.start as number
|
|
941
|
-
const end = node.end as number
|
|
942
|
-
const parent = findParent(node)
|
|
943
|
-
const needsBraces =
|
|
944
|
-
parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment')
|
|
945
|
-
replacements.push({ start, end, text: needsBraces ? `{${call}}` : call })
|
|
946
|
-
needsCollapse = true
|
|
947
|
-
needsCollapseH = true
|
|
948
|
-
if (!collapseRuleKeys.has(site.ruleKey)) {
|
|
949
|
-
collapseRuleKeys.add(site.ruleKey)
|
|
950
|
-
collapseRules.push({ ruleKey: site.ruleKey, rules: site.rules })
|
|
951
|
-
}
|
|
952
|
-
return true
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
/**
|
|
956
|
-
* PR 3 of the dynamic-prop partial-collapse build (open-work #1
|
|
957
|
-
* dynamic-prop bucket = 15.3% of all real-corpus sites; the
|
|
958
|
-
* next-bigger bite after the just-shipped `on*`-handler partial).
|
|
959
|
-
* The dynamic-prop fallback `tryRocketstyleCollapse` defers to when
|
|
960
|
-
* BOTH the full and the on*-handler-partial paths bail.
|
|
961
|
-
*
|
|
962
|
-
* Same site-resolution contract as the full path — the dynamic prop
|
|
963
|
-
* is replaced with EACH literal value to compute TWO keys; the
|
|
964
|
-
* resolver pre-renders both via the existing SSR pipeline; if both
|
|
965
|
-
* lookups succeed AND the structural template is byte-identical
|
|
966
|
-
* across values, emit `__rsCollapseDyn(html, [classes...], () =>
|
|
967
|
-
* cond ? 0 : 1, () => __pyrMode() === "dark")` — the PR 1 runtime
|
|
968
|
-
* helper (#765) dispatches across `(value × mode)` with a stride-2
|
|
969
|
-
* value-major class layout.
|
|
970
|
-
*
|
|
971
|
-
* Conservative discipline:
|
|
972
|
-
* - Either expanded key missing from sites map ⇒ bail (an
|
|
973
|
-
* intermittent resolver failure on one value mustn't half-collapse)
|
|
974
|
-
* - Divergent template HTML across values ⇒ bail (the dispatcher
|
|
975
|
-
* assumes a shared template; deriveCollapseDyn cannot be done
|
|
976
|
-
* across values that produce structurally different markup —
|
|
977
|
-
* this is the cross-value parallel of `deriveCollapse`'s
|
|
978
|
-
* light↔dark template-divergence bail)
|
|
979
|
-
*
|
|
980
|
-
* Handler-combined sites: when the detected dynamic site has `on*`
|
|
981
|
-
* handlers (the most common real-corpus shape — bail-census measured
|
|
982
|
-
* the no-handler subset at 0.2% of all sites; handler-combined is
|
|
983
|
-
* the bulk of the 15.4% dynamic-prop bucket), emit
|
|
984
|
-
* `__rsCollapseDynH(...)` (PR A: runtime helper) instead of
|
|
985
|
-
* `__rsCollapseDyn(...)`. Handlers are orthogonal to the SSR-
|
|
986
|
-
* resolved styler class (the resolver pre-renders both values
|
|
987
|
-
* identically regardless of handlers); the union helper just
|
|
988
|
-
* re-attaches them through the same canonical `_bindEvent` path
|
|
989
|
-
* `tryPartialCollapse` uses.
|
|
990
|
-
*
|
|
991
|
-
* Rule injection unions the rule sets across both values (each value
|
|
992
|
-
* may inject distinct CSS rules — e.g. `state="primary"` and
|
|
993
|
-
* `state="secondary"` produce different background-color rules); the
|
|
994
|
-
* union is the byte-set the dispatcher will need at runtime regardless
|
|
995
|
-
* of which value the cond resolves to. Idempotent by per-value
|
|
996
|
-
* `ruleKey` so a re-resolve / HMR is a no-op.
|
|
997
|
-
*/
|
|
998
|
-
function tryDynamicCollapse(node: N, tag: string): boolean {
|
|
999
|
-
const cfg = options.collapseRocketstyle
|
|
1000
|
-
if (!cfg) return false
|
|
1001
|
-
const dyn = detectDynamicCollapsibleShape(node, tag)
|
|
1002
|
-
if (!dyn) return false
|
|
1003
|
-
const { props, childrenText, dynamicProp, handlers } = dyn
|
|
1004
|
-
// Look up BOTH expanded sites (one per literal value). The scan's
|
|
1005
|
-
// dynamic-prop fallthrough (above in this file) emits a CollapsibleSite
|
|
1006
|
-
// for each value with identical key construction, so these lookups
|
|
1007
|
-
// must succeed iff both resolved.
|
|
1008
|
-
const truthyProps = { ...props, [dynamicProp.name]: dynamicProp.valueTruthy }
|
|
1009
|
-
const falsyProps = { ...props, [dynamicProp.name]: dynamicProp.valueFalsy }
|
|
1010
|
-
const truthyKey = rocketstyleCollapseKey(tag, truthyProps, childrenText)
|
|
1011
|
-
const falsyKey = rocketstyleCollapseKey(tag, falsyProps, childrenText)
|
|
1012
|
-
const truthySite = cfg.sites.get(truthyKey)
|
|
1013
|
-
const falsySite = cfg.sites.get(falsyKey)
|
|
1014
|
-
if (!truthySite || !falsySite) return false // half-resolved ⇒ keep normal mount
|
|
1015
|
-
// Cross-value template parity — the dispatcher reuses ONE `_tpl`
|
|
1016
|
-
// across both values; divergent markup means we'd silently pick
|
|
1017
|
-
// the truthy variant's HTML for falsy too. Bail conservatively.
|
|
1018
|
-
if (truthySite.templateHtml !== falsySite.templateHtml) return false
|
|
1019
|
-
|
|
1020
|
-
// Build the stride-2 value-major class array (consumed by
|
|
1021
|
-
// `_rsCollapseDyn`): `[v0_light, v0_dark, v1_light, v1_dark]` where
|
|
1022
|
-
// v0 = truthy (cond → 0), v1 = falsy (cond → 1).
|
|
1023
|
-
const classes = [
|
|
1024
|
-
truthySite.lightClass,
|
|
1025
|
-
truthySite.darkClass,
|
|
1026
|
-
falsySite.lightClass,
|
|
1027
|
-
falsySite.darkClass,
|
|
1028
|
-
]
|
|
1029
|
-
const condSrc = code.slice(dynamicProp.condStart, dynamicProp.condEnd)
|
|
1030
|
-
|
|
1031
|
-
// Handler-combined sites route to `__rsCollapseDynH(...)` (PR A
|
|
1032
|
-
// runtime helper) — handlers re-attached after the class dispatcher
|
|
1033
|
-
// via the canonical `_bindEvent` path, byte-identical to how
|
|
1034
|
-
// `tryPartialCollapse` re-emits handlers via `__rsCollapseH`.
|
|
1035
|
-
// No-handler sites stay on `__rsCollapseDyn(...)` (lighter — no
|
|
1036
|
-
// handlers parameter, no loop allocation).
|
|
1037
|
-
let call: string
|
|
1038
|
-
if (handlers.length > 0) {
|
|
1039
|
-
const handlerObj =
|
|
1040
|
-
`{ ${handlers
|
|
1041
|
-
.map((h) => `${JSON.stringify(h.name)}: (${code.slice(h.exprStart, h.exprEnd)})`)
|
|
1042
|
-
.join(', ')} }`
|
|
1043
|
-
call =
|
|
1044
|
-
`__rsCollapseDynH(${JSON.stringify(truthySite.templateHtml)}, ` +
|
|
1045
|
-
`${JSON.stringify(classes)}, ` +
|
|
1046
|
-
`() => (${condSrc}) ? 0 : 1, ` +
|
|
1047
|
-
`() => __pyrMode() === "dark", ` +
|
|
1048
|
-
`${handlerObj})`
|
|
1049
|
-
needsCollapseDynH = true
|
|
1050
|
-
} else {
|
|
1051
|
-
call =
|
|
1052
|
-
`__rsCollapseDyn(${JSON.stringify(truthySite.templateHtml)}, ` +
|
|
1053
|
-
`${JSON.stringify(classes)}, ` +
|
|
1054
|
-
`() => (${condSrc}) ? 0 : 1, ` +
|
|
1055
|
-
`() => __pyrMode() === "dark")`
|
|
1056
|
-
needsCollapseDyn = true
|
|
1057
|
-
}
|
|
1058
|
-
const start = node.start as number
|
|
1059
|
-
const end = node.end as number
|
|
1060
|
-
const parent = findParent(node)
|
|
1061
|
-
const needsBraces =
|
|
1062
|
-
parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment')
|
|
1063
|
-
replacements.push({ start, end, text: needsBraces ? `{${call}}` : call })
|
|
1064
|
-
// Union BOTH value's rule bundles into the per-module injection.
|
|
1065
|
-
// De-dupe by ruleKey (the FNV-1a hash from the resolver) so two
|
|
1066
|
-
// dynamic sites sharing a value pay one injection.
|
|
1067
|
-
for (const site of [truthySite, falsySite]) {
|
|
1068
|
-
if (!collapseRuleKeys.has(site.ruleKey)) {
|
|
1069
|
-
collapseRuleKeys.add(site.ruleKey)
|
|
1070
|
-
collapseRules.push({ ruleKey: site.ruleKey, rules: site.rules })
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
return true
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
function maybeHoist(node: N): string | null {
|
|
1077
|
-
if (
|
|
1078
|
-
(node.type === 'JSXElement' || node.type === 'JSXFragment') &&
|
|
1079
|
-
isStaticJSXNode(node)
|
|
1080
|
-
) {
|
|
1081
|
-
const name = `_$h${hoistIdx++}`
|
|
1082
|
-
const text = code.slice(node.start as number, node.end as number)
|
|
1083
|
-
hoists.push({ name, text })
|
|
1084
|
-
lens(
|
|
1085
|
-
node.start as number,
|
|
1086
|
-
node.end as number,
|
|
1087
|
-
'hoisted-static',
|
|
1088
|
-
'static — hoisted once to module scope, never re-evaluated',
|
|
1089
|
-
)
|
|
1090
|
-
return name
|
|
1091
|
-
}
|
|
1092
|
-
return null
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
function wrap(expr: N): void {
|
|
1096
|
-
const start = expr.start as number
|
|
1097
|
-
const end = expr.end as number
|
|
1098
|
-
const sliced = sliceExpr(expr)
|
|
1099
|
-
const text = expr.type === 'ObjectExpression'
|
|
1100
|
-
? `() => (${sliced})`
|
|
1101
|
-
: `() => ${sliced}`
|
|
1102
|
-
replacements.push({ start, end, text })
|
|
1103
|
-
lens(start, end, 'reactive', 'live — re-evaluates whenever its signals change')
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
function hoistOrWrap(expr: N): void {
|
|
1107
|
-
const hoistName = maybeHoist(expr)
|
|
1108
|
-
if (hoistName) {
|
|
1109
|
-
replacements.push({ start: expr.start as number, end: expr.end as number, text: hoistName })
|
|
1110
|
-
} else if (shouldWrap(expr)) {
|
|
1111
|
-
wrap(expr)
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
// ── Template emit ─────────────────────────────────────────────────────────
|
|
1116
|
-
|
|
1117
|
-
function tryTemplateEmit(node: N): boolean {
|
|
1118
|
-
if (ssr) return false
|
|
1119
|
-
if (isSelfClosing(node)) return false
|
|
1120
|
-
const elemCount = templateElementCount(node, true)
|
|
1121
|
-
if (elemCount < 1) return false
|
|
1122
|
-
const tplCall = buildTemplateCall(node)
|
|
1123
|
-
if (!tplCall) return false
|
|
1124
|
-
const start = node.start as number
|
|
1125
|
-
const end = node.end as number
|
|
1126
|
-
const parent = findParent(node)
|
|
1127
|
-
const needsBraces = parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment')
|
|
1128
|
-
replacements.push({ start, end, text: needsBraces ? `{${tplCall}}` : tplCall })
|
|
1129
|
-
needsTplImport = true
|
|
1130
|
-
return true
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
function checkForWarnings(node: N): void {
|
|
1134
|
-
const tagName = jsxTagName(node)
|
|
1135
|
-
if (tagName !== 'For') return
|
|
1136
|
-
const hasBy = jsxAttrs(node).some(
|
|
1137
|
-
(p: N) => p.type === 'JSXAttribute' && p.name?.type === 'JSXIdentifier' && p.name.name === 'by',
|
|
1138
|
-
)
|
|
1139
|
-
if (!hasBy) {
|
|
1140
|
-
warn(
|
|
1141
|
-
node.openingElement?.name ?? node,
|
|
1142
|
-
`<For> without a "by" prop will use index-based diffing, which is slower and may cause bugs with stateful children. Add by={(item) => item.id} for efficient keyed reconciliation.`,
|
|
1143
|
-
'missing-key-on-for',
|
|
1144
|
-
)
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
/**
|
|
1149
|
-
* Wrap component-JSX spread arguments with `_wrapSpread(...)` so
|
|
1150
|
-
* getter-shaped reactive props survive esbuild's JS-level spread emit.
|
|
1151
|
-
*
|
|
1152
|
-
* esbuild compiles `<Comp {...source}>` to `jsx(Comp, { ...source })`.
|
|
1153
|
-
* The JS spread fires every getter on `source` and stores the resolved
|
|
1154
|
-
* values — collapsing compiler-emitted reactive props (`_rp` thunks
|
|
1155
|
-
* later converted to getters by `makeReactiveProps`) to static values
|
|
1156
|
-
* before the receiving component sees them.
|
|
1157
|
-
*
|
|
1158
|
-
* `_wrapSpread` replaces getter descriptors with `_rp`-branded thunks,
|
|
1159
|
-
* so the JS-level spread carries function values instead. The runtime
|
|
1160
|
-
* `makeReactiveProps` step converts them back to getters on the
|
|
1161
|
-
* component's props object — preserving the live signal subscription.
|
|
1162
|
-
*
|
|
1163
|
-
* Lowercase tags (DOM elements) go through the template path's
|
|
1164
|
-
* `_applyProps` which already handles spread reactively — no need to
|
|
1165
|
-
* wrap there.
|
|
1166
|
-
*/
|
|
1167
|
-
function handleJsxSpreadAttribute(attr: N, parentElement: N): void {
|
|
1168
|
-
const tagName = jsxTagName(parentElement)
|
|
1169
|
-
const isComponent =
|
|
1170
|
-
tagName.length > 0 && tagName.charAt(0) !== tagName.charAt(0).toLowerCase()
|
|
1171
|
-
if (!isComponent) return
|
|
1172
|
-
const arg = attr.argument
|
|
1173
|
-
if (!arg) return
|
|
1174
|
-
// Skip already-wrapped sources (idempotent compilation guard).
|
|
1175
|
-
if (
|
|
1176
|
-
arg.type === 'CallExpression' &&
|
|
1177
|
-
arg.callee?.type === 'Identifier' &&
|
|
1178
|
-
arg.callee.name === '_wrapSpread'
|
|
1179
|
-
)
|
|
1180
|
-
return
|
|
1181
|
-
const start = arg.start as number
|
|
1182
|
-
const end = arg.end as number
|
|
1183
|
-
const sliced = sliceExpr(arg)
|
|
1184
|
-
replacements.push({ start, end, text: `_wrapSpread(${sliced})` })
|
|
1185
|
-
needsWrapSpreadImport = true
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
function handleJsxAttribute(node: N, parentElement: N): void {
|
|
1189
|
-
const name = node.name?.type === 'JSXIdentifier' ? node.name.name : ''
|
|
1190
|
-
if (SKIP_PROPS.has(name) || EVENT_RE.test(name)) return
|
|
1191
|
-
if (!node.value || node.value.type !== 'JSXExpressionContainer') return
|
|
1192
|
-
const expr = node.value.expression
|
|
1193
|
-
if (!expr || expr.type === 'JSXEmptyExpression') return
|
|
1194
|
-
|
|
1195
|
-
const tagName = jsxTagName(parentElement)
|
|
1196
|
-
const isComponent = tagName.length > 0 && tagName.charAt(0) !== tagName.charAt(0).toLowerCase()
|
|
1197
|
-
|
|
1198
|
-
if (isComponent) {
|
|
1199
|
-
const isSingleJsx = expr.type === 'JSXElement' || expr.type === 'JSXFragment'
|
|
1200
|
-
if (isSingleJsx) {
|
|
1201
|
-
walkNode(expr)
|
|
1202
|
-
return
|
|
1203
|
-
}
|
|
1204
|
-
const hoistName = maybeHoist(expr)
|
|
1205
|
-
if (hoistName) {
|
|
1206
|
-
replacements.push({ start: expr.start as number, end: expr.end as number, text: hoistName })
|
|
1207
|
-
} else if (shouldWrap(expr)) {
|
|
1208
|
-
const start = expr.start as number
|
|
1209
|
-
const end = expr.end as number
|
|
1210
|
-
const sliced = sliceExpr(expr)
|
|
1211
|
-
const inner = expr.type === 'ObjectExpression' ? `(${sliced})` : sliced
|
|
1212
|
-
replacements.push({ start, end, text: `_rp(() => ${inner})` })
|
|
1213
|
-
needsRpImport = true
|
|
1214
|
-
lens(start, end, 'reactive-prop', 'live prop — signal reads here are tracked into the component')
|
|
1215
|
-
}
|
|
1216
|
-
} else {
|
|
1217
|
-
hoistOrWrap(expr)
|
|
1218
|
-
}
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
function handleJsxExpression(node: N, parentJsx?: N): void {
|
|
1222
|
-
const expr = node.expression
|
|
1223
|
-
if (!expr || expr.type === 'JSXEmptyExpression') return
|
|
1224
|
-
const hoistName = maybeHoist(expr)
|
|
1225
|
-
if (hoistName) {
|
|
1226
|
-
replacements.push({ start: expr.start as number, end: expr.end as number, text: hoistName })
|
|
1227
|
-
return
|
|
1228
|
-
}
|
|
1229
|
-
if (shouldWrap(expr)) {
|
|
1230
|
-
// Skip the accessor wrap for stable references passed as JSX children
|
|
1231
|
-
// of a COMPONENT parent (uppercase tag). The compiler's prop-inlining
|
|
1232
|
-
// pass replaces `{children}` with `() => h.children` for component
|
|
1233
|
-
// parents too (the kinetic Stagger + bokisch.com Intro reproducer);
|
|
1234
|
-
// most consumer libraries (rocketstyle/styler/ui-core/elements) route
|
|
1235
|
-
// children through `mountChild` which handles function children via
|
|
1236
|
-
// `mountReactive`, but libraries that iterate children at the VNode
|
|
1237
|
-
// level (kinetic's StaggerRenderer/TransitionItem) or `cloneVNode`
|
|
1238
|
-
// them directly are silently broken — the function spread produces
|
|
1239
|
-
// `{type: undefined}` and the DOM renders `<undefined>` tags.
|
|
1240
|
-
//
|
|
1241
|
-
// Narrow contract — only stable references are emitted bare:
|
|
1242
|
-
// - Bare Identifier (`{children}` referencing a prop-derived const)
|
|
1243
|
-
// - Simple MemberExpression chain (`{obj.x}`, `{obj.x.y}`)
|
|
1244
|
-
// These shapes evaluate the same way whether called once at JSX-
|
|
1245
|
-
// emit time or repeatedly in a `mountReactive` effect — no
|
|
1246
|
-
// reactivity is lost because the underlying value is just a
|
|
1247
|
-
// property read. Other dynamic shapes (CallExpression, BinaryExpression,
|
|
1248
|
-
// LogicalExpression, etc.) keep the wrap so `<Comp>{count()}</Comp>`
|
|
1249
|
-
// and similar patterns stay reactive end-to-end.
|
|
1250
|
-
//
|
|
1251
|
-
// Without this carve-out, library authors are forced to write
|
|
1252
|
-
// defensive `typeof children === 'function' ? children() : children`
|
|
1253
|
-
// unwraps everywhere they consume `props.children` structurally.
|
|
1254
|
-
if (
|
|
1255
|
-
parentJsx &&
|
|
1256
|
-
isComponentTag(jsxTagName(parentJsx)) &&
|
|
1257
|
-
isStableReference(expr) &&
|
|
1258
|
-
!referencesSignalVar(expr)
|
|
1259
|
-
) {
|
|
1260
|
-
// Skip the carve-out for signal references — `<Comp>{count}</Comp>`
|
|
1261
|
-
// (bare signal identifier) is the user's deliberate "make this
|
|
1262
|
-
// reactive at the call site" pattern. Auto-call + wrap converts to
|
|
1263
|
-
// `() => count()` so the receiving component re-evaluates inside
|
|
1264
|
-
// its mountReactive/mountChild scope. Prop-derived stable refs
|
|
1265
|
-
// (the kinetic / bokisch fix shape) take the bare path.
|
|
1266
|
-
//
|
|
1267
|
-
// Slice the UNWRAPPED expression — TS type-only layers (`as T`,
|
|
1268
|
-
// `satisfies T`, `!`) are stripped because the receiving component
|
|
1269
|
-
// doesn't care about the static type and esbuild strips casts at
|
|
1270
|
-
// the next stage anyway. Also keeps cross-backend equivalence
|
|
1271
|
-
// with the Rust path (whose `accesses_props` doesn't recurse into
|
|
1272
|
-
// TSAsExpression).
|
|
1273
|
-
const start = expr.start as number
|
|
1274
|
-
const end = expr.end as number
|
|
1275
|
-
const unwrapped = unwrapTypeLayers(expr)
|
|
1276
|
-
const sliced = sliceExpr(unwrapped)
|
|
1277
|
-
replacements.push({ start, end, text: sliced })
|
|
1278
|
-
return
|
|
1279
|
-
}
|
|
1280
|
-
wrap(expr)
|
|
1281
|
-
return
|
|
1282
|
-
}
|
|
1283
|
-
walkNode(expr)
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
/** Component tag — uppercase first letter. Lowercase = DOM element. */
|
|
1287
|
-
function isComponentTag(tag: string): boolean {
|
|
1288
|
-
return tag.length > 0 && tag.charAt(0) !== tag.charAt(0).toLowerCase()
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
/**
|
|
1292
|
-
* Stable reference — an expression whose value is a bare property read.
|
|
1293
|
-
* Bare Identifier (`children`) or a non-computed MemberExpression chain
|
|
1294
|
-
* (`obj.x.y`) terminating in an Identifier or `this`. These are the
|
|
1295
|
-
* shapes that survive the no-wrap path without losing reactivity:
|
|
1296
|
-
* reading them once captures the same value as reading them N times,
|
|
1297
|
-
* because the underlying getter (if any) is the source of truth either
|
|
1298
|
-
* way. Excludes CallExpression / TaggedTemplateExpression / BinaryExpression
|
|
1299
|
-
* / LogicalExpression / ConditionalExpression / etc. — those keep the
|
|
1300
|
-
* wrap so consumers can re-evaluate inside reactive scopes.
|
|
1301
|
-
*
|
|
1302
|
-
* TS type-only layers (`as T` / `satisfies T` / non-null `!`) and
|
|
1303
|
-
* parentheses are transparent — they don't change runtime semantics
|
|
1304
|
-
* so we unwrap to look at the underlying expression. Reproducer:
|
|
1305
|
-
* `<Comp>{children as VNode[]}</Comp>` in `createKineticComponent.tsx`
|
|
1306
|
-
* — the TS cast wraps the Identifier as a `TSAsExpression`; without
|
|
1307
|
-
* unwrap the carve-out misses the very pattern it was written for.
|
|
1308
|
-
*/
|
|
1309
|
-
function isStableReference(expr: N): boolean {
|
|
1310
|
-
const u = unwrapTypeLayers(expr)
|
|
1311
|
-
if (u.type === 'Identifier') return true
|
|
1312
|
-
if (u.type === 'MemberExpression') {
|
|
1313
|
-
let cur: N = u
|
|
1314
|
-
while (cur.type === 'MemberExpression') {
|
|
1315
|
-
if (cur.computed) return false
|
|
1316
|
-
if (cur.property?.type !== 'Identifier') return false
|
|
1317
|
-
cur = cur.object
|
|
1318
|
-
}
|
|
1319
|
-
return cur.type === 'Identifier' || cur.type === 'ThisExpression'
|
|
1320
|
-
}
|
|
1321
|
-
return false
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
/** Strip TS type-only layers + parens that don't affect runtime value. */
|
|
1325
|
-
function unwrapTypeLayers(expr: N): N {
|
|
1326
|
-
let cur: N = expr
|
|
1327
|
-
while (
|
|
1328
|
-
cur.type === 'TSAsExpression' ||
|
|
1329
|
-
cur.type === 'TSSatisfiesExpression' ||
|
|
1330
|
-
cur.type === 'TSNonNullExpression' ||
|
|
1331
|
-
cur.type === 'TSTypeAssertion' ||
|
|
1332
|
-
cur.type === 'ParenthesizedExpression'
|
|
1333
|
-
) {
|
|
1334
|
-
cur = cur.expression
|
|
1335
|
-
}
|
|
1336
|
-
return cur
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
// ── Prop-derived variable tracking (collected during the single walk) ─────
|
|
1340
|
-
const propsNames = new Set<string>()
|
|
1341
|
-
const propDerivedVars = new Map<string, { start: number; end: number }>()
|
|
1342
|
-
// Round 9 fix: names of const/let bindings whose initializer is a JSX
|
|
1343
|
-
// element (`const x = <El/>`). A bare `{x}` child of such a binding must be
|
|
1344
|
-
// MOUNTED, not text-coerced — pre-fix it emitted `createTextNode(x)` which
|
|
1345
|
-
// stringifies the NativeItem to "[object Object]". Routing through
|
|
1346
|
-
// `_mountSlot` (the general child-insert `props.children` already uses) is
|
|
1347
|
-
// safe even if a same-named binding is later shadowed by a string/number:
|
|
1348
|
-
// `_mountSlot` renders those correctly too — the only cost of imprecision
|
|
1349
|
-
// is skipping the createTextNode fast path, never a correctness regression.
|
|
1350
|
-
const elementVars = new Set<string>()
|
|
1351
|
-
|
|
1352
|
-
// ── Signal variable tracking (for auto-call in JSX) ──────────────────────
|
|
1353
|
-
// Tracks `const x = signal(...)` declarations. In JSX expressions, bare
|
|
1354
|
-
// references to these identifiers are auto-called: `{x}` → `{x()}`.
|
|
1355
|
-
// This makes signals look like plain JS variables in templates while
|
|
1356
|
-
// maintaining fine-grained reactivity.
|
|
1357
|
-
const signalVars = new Set<string>(options.knownSignals)
|
|
1358
|
-
|
|
1359
|
-
// ── Scope-aware signal shadowing ──────────────────────────────────────────
|
|
1360
|
-
// When a function/block declares a variable with the same name as a signal
|
|
1361
|
-
// (e.g. `const show = 'text'` shadowing module-scope `const show = signal(false)`),
|
|
1362
|
-
// that name is NOT a signal within that scope. The shadowedSignals set tracks
|
|
1363
|
-
// names that are currently shadowed by a closer non-signal declaration.
|
|
1364
|
-
const shadowedSignals = new Set<string>()
|
|
1365
|
-
|
|
1366
|
-
/** Check if an identifier name is an active (non-shadowed) signal variable. */
|
|
1367
|
-
function isActiveSignal(name: string): boolean {
|
|
1368
|
-
return signalVars.has(name) && !shadowedSignals.has(name)
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
/** Find variable declarations and parameters in a function that shadow signal names. */
|
|
1372
|
-
function findShadowingNames(node: N): string[] {
|
|
1373
|
-
const shadows: string[] = []
|
|
1374
|
-
// Check function parameters
|
|
1375
|
-
for (const param of node.params ?? []) {
|
|
1376
|
-
if (param.type === 'Identifier' && signalVars.has(param.name)) {
|
|
1377
|
-
shadows.push(param.name)
|
|
1378
|
-
}
|
|
1379
|
-
// Handle destructured parameters: ({ name }) => ...
|
|
1380
|
-
if (param.type === 'ObjectPattern') {
|
|
1381
|
-
for (const prop of param.properties ?? []) {
|
|
1382
|
-
const val = prop.value ?? prop.key
|
|
1383
|
-
if (val?.type === 'Identifier' && signalVars.has(val.name)) {
|
|
1384
|
-
shadows.push(val.name)
|
|
1385
|
-
}
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
// Handle array destructured parameters: ([a, b]) => ...
|
|
1389
|
-
if (param.type === 'ArrayPattern') {
|
|
1390
|
-
for (const el of param.elements ?? []) {
|
|
1391
|
-
if (el?.type === 'Identifier' && signalVars.has(el.name)) {
|
|
1392
|
-
shadows.push(el.name)
|
|
1393
|
-
}
|
|
1394
|
-
}
|
|
1395
|
-
}
|
|
1396
|
-
}
|
|
1397
|
-
// Check top-level variable declarations in the function body
|
|
1398
|
-
const body = node.body
|
|
1399
|
-
const stmts = body?.body ?? body?.statements
|
|
1400
|
-
if (!Array.isArray(stmts)) return shadows
|
|
1401
|
-
for (const stmt of stmts) {
|
|
1402
|
-
if (stmt.type === 'VariableDeclaration') {
|
|
1403
|
-
for (const decl of stmt.declarations ?? []) {
|
|
1404
|
-
if (decl.id?.type === 'Identifier' && signalVars.has(decl.id.name)) {
|
|
1405
|
-
// Only shadow if it's NOT a signal() call
|
|
1406
|
-
if (!decl.init || !isSignalCall(decl.init)) {
|
|
1407
|
-
shadows.push(decl.id.name)
|
|
1408
|
-
}
|
|
1409
|
-
}
|
|
1410
|
-
}
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
return shadows
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
function readsFromProps(node: N): boolean {
|
|
1417
|
-
if (node.type === 'MemberExpression' && node.object?.type === 'Identifier') {
|
|
1418
|
-
if (propsNames.has(node.object.name)) return true
|
|
1419
|
-
}
|
|
1420
|
-
let found = false
|
|
1421
|
-
forEachChildFast(node, (child) => {
|
|
1422
|
-
if (found) return
|
|
1423
|
-
if (readsFromProps(child)) found = true
|
|
1424
|
-
})
|
|
1425
|
-
return found
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
/** Check if an expression references any prop-derived variable. */
|
|
1429
|
-
function referencesPropDerived(node: N): boolean {
|
|
1430
|
-
if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
|
|
1431
|
-
const p = findParent(node)
|
|
1432
|
-
if (p && p.type === 'MemberExpression' && p.property === node && !p.computed) return false
|
|
1433
|
-
return true
|
|
1434
|
-
}
|
|
1435
|
-
let found = false
|
|
1436
|
-
forEachChildFast(node, (child) => {
|
|
1437
|
-
if (found) return
|
|
1438
|
-
if (referencesPropDerived(child)) found = true
|
|
1439
|
-
})
|
|
1440
|
-
return found
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
/** Collect prop-derived variable info from a VariableDeclaration node.
|
|
1444
|
-
* Called inline during the single-pass walk when we encounter a declaration. */
|
|
1445
|
-
function collectPropDerivedFromDecl(node: N, callbackDepth: number): void {
|
|
1446
|
-
if (node.type !== 'VariableDeclaration') return
|
|
1447
|
-
for (const decl of node.declarations ?? []) {
|
|
1448
|
-
// splitProps: const [own, rest] = splitProps(props, [...])
|
|
1449
|
-
if (decl.id?.type === 'ArrayPattern' && decl.init?.type === 'CallExpression') {
|
|
1450
|
-
const callee = decl.init.callee
|
|
1451
|
-
if (callee?.type === 'Identifier' && callee.name === 'splitProps') {
|
|
1452
|
-
for (const el of decl.id.elements ?? []) {
|
|
1453
|
-
if (el?.type === 'Identifier') propsNames.add(el.name)
|
|
1454
|
-
}
|
|
1455
|
-
}
|
|
1456
|
-
}
|
|
1457
|
-
// Round 9: track element-valued bindings (`const`/`let`, any depth) so
|
|
1458
|
-
// a bare `{x}` child routes to _mountSlot instead of createTextNode.
|
|
1459
|
-
// Tight: only a DIRECT JSX element/fragment initializer (optionally
|
|
1460
|
-
// parenthesized) — conditionals/calls go the existing reactive/text
|
|
1461
|
-
// paths and must not be reclassified here.
|
|
1462
|
-
if ((node.kind === 'const' || node.kind === 'let') && decl.id?.type === 'Identifier' && decl.init) {
|
|
1463
|
-
let initNode = decl.init
|
|
1464
|
-
while (initNode?.type === 'ParenthesizedExpression') initNode = initNode.expression
|
|
1465
|
-
if (initNode?.type === 'JSXElement' || initNode?.type === 'JSXFragment') {
|
|
1466
|
-
elementVars.add(decl.id.name)
|
|
1467
|
-
}
|
|
1468
|
-
}
|
|
1469
|
-
if (node.kind !== 'const') continue
|
|
1470
|
-
if (callbackDepth > 0) continue
|
|
1471
|
-
if (decl.id?.type === 'Identifier' && decl.init) {
|
|
1472
|
-
if (isStatefulCall(decl.init)) {
|
|
1473
|
-
// Track signal() declarations for auto-call in JSX
|
|
1474
|
-
if (isSignalCall(decl.init)) signalVars.add(decl.id.name)
|
|
1475
|
-
continue
|
|
1476
|
-
}
|
|
1477
|
-
// Direct prop read OR transitive (references another prop-derived var)
|
|
1478
|
-
if (readsFromProps(decl.init) || referencesPropDerived(decl.init)) {
|
|
1479
|
-
propDerivedVars.set(decl.id.name, { start: decl.init.start as number, end: decl.init.end as number })
|
|
1480
|
-
}
|
|
1481
|
-
}
|
|
1482
|
-
}
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
/** Detect component functions and register their first param as a props name.
|
|
1486
|
-
* Called inline during the walk when entering a function. */
|
|
1487
|
-
function maybeRegisterComponentProps(node: N): void {
|
|
1488
|
-
if (
|
|
1489
|
-
(node.type === 'FunctionDeclaration' || node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') &&
|
|
1490
|
-
(node.params?.length ?? 0) > 0
|
|
1491
|
-
) {
|
|
1492
|
-
const parent = findParent(node)
|
|
1493
|
-
// Skip callback functions (arguments to calls like .map, .filter)
|
|
1494
|
-
if (parent && parent.type === 'CallExpression' && (parent.arguments ?? []).includes(node)) return
|
|
1495
|
-
const firstParam = node.params[0]
|
|
1496
|
-
if (firstParam?.type === 'Identifier') {
|
|
1497
|
-
let hasJSX = false
|
|
1498
|
-
function checkJSX(n: N): void {
|
|
1499
|
-
if (hasJSX) return
|
|
1500
|
-
if (n.type === 'JSXElement' || n.type === 'JSXFragment') { hasJSX = true; return }
|
|
1501
|
-
forEachChildFast(n, checkJSX)
|
|
1502
|
-
}
|
|
1503
|
-
forEachChildFast(node, checkJSX)
|
|
1504
|
-
if (hasJSX) propsNames.add(firstParam.name)
|
|
1505
|
-
}
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
// ── String-based transitive resolution ─────────────────────────────────────
|
|
1510
|
-
const resolvedCache = new Map<string, string>()
|
|
1511
|
-
const resolving = new Set<string>()
|
|
1512
|
-
const warnedCycles = new Set<string>()
|
|
1513
|
-
|
|
1514
|
-
function resolveVarToString(varName: string, sourceNode?: N): string {
|
|
1515
|
-
if (resolvedCache.has(varName)) return resolvedCache.get(varName)!
|
|
1516
|
-
if (resolving.has(varName)) {
|
|
1517
|
-
const cycleKey = [...resolving, varName].sort().join(',')
|
|
1518
|
-
if (!warnedCycles.has(cycleKey)) {
|
|
1519
|
-
warnedCycles.add(cycleKey)
|
|
1520
|
-
const chain = [...resolving, varName].join(' → ')
|
|
1521
|
-
warn(
|
|
1522
|
-
sourceNode ?? program,
|
|
1523
|
-
`[Pyreon] Circular prop-derived const reference: ${chain}. ` +
|
|
1524
|
-
`The cyclic identifier \`${varName}\` will use its captured value ` +
|
|
1525
|
-
`instead of being reactively inlined. Break the cycle by reading ` +
|
|
1526
|
-
`from \`props.*\` directly or restructuring the derivation chain.`,
|
|
1527
|
-
'circular-prop-derived',
|
|
1528
|
-
)
|
|
1529
|
-
}
|
|
1530
|
-
return varName
|
|
1531
|
-
}
|
|
1532
|
-
resolving.add(varName)
|
|
1533
|
-
const span = propDerivedVars.get(varName)!
|
|
1534
|
-
const rawText = code.slice(span.start, span.end)
|
|
1535
|
-
const resolved = resolveIdentifiersInText(rawText, span.start, sourceNode)
|
|
1536
|
-
resolving.delete(varName)
|
|
1537
|
-
resolvedCache.set(varName, resolved)
|
|
1538
|
-
return resolved
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
function resolveIdentifiersInText(text: string, baseOffset: number, sourceNode?: N): string {
|
|
1542
|
-
const endOffset = baseOffset + text.length
|
|
1543
|
-
const idents: { start: number; end: number; name: string }[] = []
|
|
1544
|
-
|
|
1545
|
-
// ── Scope-aware shadow tracking ──────────────────────────────────────────
|
|
1546
|
-
// Prop-derived consts are only ever COLLECTED at component top level
|
|
1547
|
-
// (callbackDepth === 0), so ANY same-named binding in a deeper lexical
|
|
1548
|
-
// scope necessarily shadows it. Substituting a shadowed reference (or a
|
|
1549
|
-
// binding occurrence) miscompiles idiomatic code — e.g.
|
|
1550
|
-
// `const a = props.x; items.map(a => <li>{a}</li>)` would rewrite the
|
|
1551
|
-
// arrow PARAMETER `a` into `(props.x)` (invalid `(props.x) =>`) and the
|
|
1552
|
-
// body `{a}` (the map item) into `props.x`. The signal-auto-call pass is
|
|
1553
|
-
// already scope-aware via `shadowedSignals`; this mirrors that discipline
|
|
1554
|
-
// for the prop-derived inlining pass.
|
|
1555
|
-
const shadowed = new Set<string>()
|
|
1556
|
-
|
|
1557
|
-
/** Collect identifier names bound by a pattern (params / declarators). */
|
|
1558
|
-
function patternBindingNames(p: N, out: string[]): void {
|
|
1559
|
-
if (!p) return
|
|
1560
|
-
switch (p.type) {
|
|
1561
|
-
case 'Identifier':
|
|
1562
|
-
out.push(p.name)
|
|
1563
|
-
break
|
|
1564
|
-
case 'ObjectPattern':
|
|
1565
|
-
for (const pr of p.properties ?? []) {
|
|
1566
|
-
if (pr.type === 'RestElement') patternBindingNames(pr.argument, out)
|
|
1567
|
-
else patternBindingNames(pr.value ?? pr.key, out)
|
|
1568
|
-
}
|
|
1569
|
-
break
|
|
1570
|
-
case 'ArrayPattern':
|
|
1571
|
-
for (const el of p.elements ?? []) patternBindingNames(el, out)
|
|
1572
|
-
break
|
|
1573
|
-
case 'AssignmentPattern':
|
|
1574
|
-
patternBindingNames(p.left, out)
|
|
1575
|
-
break
|
|
1576
|
-
case 'RestElement':
|
|
1577
|
-
patternBindingNames(p.argument, out)
|
|
1578
|
-
break
|
|
1579
|
-
}
|
|
1580
|
-
}
|
|
1581
|
-
|
|
1582
|
-
/**
|
|
1583
|
-
* Prop-derived names bound by `node` FOR ITS OWN SUBTREE (block-accurate
|
|
1584
|
-
* lexical scoping). Excludes the prop-derived const's own defining
|
|
1585
|
-
* declaration (matched by init span) so the binding we inline FROM is
|
|
1586
|
-
* never mistaken for a shadow of itself.
|
|
1587
|
-
*/
|
|
1588
|
-
function scopeBoundPropDerived(node: N): string[] {
|
|
1589
|
-
const out: string[] = []
|
|
1590
|
-
const t = node.type
|
|
1591
|
-
const declNames = (declNode: N): void => {
|
|
1592
|
-
for (const d of declNode.declarations ?? []) {
|
|
1593
|
-
// The prop-derived defining declaration is NOT a shadow.
|
|
1594
|
-
if (d.id?.type === 'Identifier' && propDerivedVars.has(d.id.name)) {
|
|
1595
|
-
const span = propDerivedVars.get(d.id.name)!
|
|
1596
|
-
if (d.init && (d.init.start as number) === span.start) continue
|
|
1597
|
-
}
|
|
1598
|
-
patternBindingNames(d.id, out)
|
|
1599
|
-
}
|
|
1600
|
-
}
|
|
1601
|
-
if (
|
|
1602
|
-
t === 'ArrowFunctionExpression' ||
|
|
1603
|
-
t === 'FunctionExpression' ||
|
|
1604
|
-
t === 'FunctionDeclaration'
|
|
1605
|
-
) {
|
|
1606
|
-
for (const p of node.params ?? []) patternBindingNames(p, out)
|
|
1607
|
-
} else if (t === 'CatchClause') {
|
|
1608
|
-
patternBindingNames(node.param, out)
|
|
1609
|
-
} else if (t === 'ForStatement') {
|
|
1610
|
-
if (node.init?.type === 'VariableDeclaration') declNames(node.init)
|
|
1611
|
-
} else if (t === 'ForInStatement' || t === 'ForOfStatement') {
|
|
1612
|
-
if (node.left?.type === 'VariableDeclaration') declNames(node.left)
|
|
1613
|
-
} else if (t === 'BlockStatement' || t === 'Program' || t === 'StaticBlock') {
|
|
1614
|
-
const stmts = node.body ?? node.statements
|
|
1615
|
-
if (Array.isArray(stmts)) {
|
|
1616
|
-
for (const s of stmts) {
|
|
1617
|
-
if (s.type === 'VariableDeclaration') declNames(s)
|
|
1618
|
-
else if (s.type === 'FunctionDeclaration' && s.id?.type === 'Identifier') out.push(s.id.name)
|
|
1619
|
-
else if (s.type === 'ClassDeclaration' && s.id?.type === 'Identifier') out.push(s.id.name)
|
|
1620
|
-
}
|
|
1621
|
-
}
|
|
1622
|
-
}
|
|
1623
|
-
return out.filter((n) => propDerivedVars.has(n))
|
|
1624
|
-
}
|
|
1625
|
-
|
|
1626
|
-
// Walk the AST to find identifiers in the span, passing parent context
|
|
1627
|
-
// to skip non-reference positions (property names, declarations, etc.)
|
|
1628
|
-
// and a lexical shadow set so a same-named inner binding is never inlined.
|
|
1629
|
-
function findIdents(node: N, parent: N | null): void {
|
|
1630
|
-
const nodeStart = node.start as number
|
|
1631
|
-
const nodeEnd = node.end as number
|
|
1632
|
-
if (nodeStart >= endOffset || nodeEnd <= baseOffset) return
|
|
1633
|
-
if (
|
|
1634
|
-
node.type === 'Identifier' &&
|
|
1635
|
-
propDerivedVars.has(node.name) &&
|
|
1636
|
-
!shadowed.has(node.name)
|
|
1637
|
-
) {
|
|
1638
|
-
if (parent) {
|
|
1639
|
-
if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) { /* skip */ }
|
|
1640
|
-
else if (parent.type === 'VariableDeclarator' && parent.id === node) { /* skip */ }
|
|
1641
|
-
else if (parent.type === 'Property' && parent.key === node && !parent.computed) { /* skip */ }
|
|
1642
|
-
else if (parent.type === 'Property' && parent.shorthand) { /* skip */ }
|
|
1643
|
-
else if (nodeStart >= baseOffset && nodeEnd <= endOffset) {
|
|
1644
|
-
idents.push({ start: nodeStart, end: nodeEnd, name: node.name })
|
|
1645
|
-
}
|
|
1646
|
-
} else if (nodeStart >= baseOffset && nodeEnd <= endOffset) {
|
|
1647
|
-
idents.push({ start: nodeStart, end: nodeEnd, name: node.name })
|
|
1648
|
-
}
|
|
1649
|
-
}
|
|
1650
|
-
// Names this node binds for its subtree shadow the top-level prop-derived
|
|
1651
|
-
// const within that subtree (and the binding occurrence itself).
|
|
1652
|
-
const introduced = scopeBoundPropDerived(node).filter((n) => !shadowed.has(n))
|
|
1653
|
-
for (const n of introduced) shadowed.add(n)
|
|
1654
|
-
forEachChildFast(node, (child) => findIdents(child, node))
|
|
1655
|
-
for (const n of introduced) shadowed.delete(n)
|
|
1656
|
-
}
|
|
1657
|
-
findIdents(program, null)
|
|
1658
|
-
|
|
1659
|
-
if (idents.length === 0) return text
|
|
1660
|
-
|
|
1661
|
-
idents.sort((a, b) => a.start - b.start)
|
|
1662
|
-
const parts: string[] = []
|
|
1663
|
-
let lastPos = baseOffset
|
|
1664
|
-
for (const id of idents) {
|
|
1665
|
-
parts.push(code.slice(lastPos, id.start))
|
|
1666
|
-
parts.push(`(${resolveVarToString(id.name, sourceNode)})`)
|
|
1667
|
-
lastPos = id.end
|
|
1668
|
-
}
|
|
1669
|
-
parts.push(code.slice(lastPos, endOffset))
|
|
1670
|
-
return parts.join('')
|
|
1671
|
-
}
|
|
1672
|
-
|
|
1673
|
-
// ── Analysis helpers with memoization (Phase 3) ────────────────────────────
|
|
1674
|
-
// Cache results keyed by node.start (unique per node in a file).
|
|
1675
|
-
// Eliminates redundant subtree traversals for containsCall + accessesProps.
|
|
1676
|
-
const _isDynamicCache = new Map<number, boolean>()
|
|
1677
|
-
|
|
1678
|
-
/** Fused isDynamic: checks both containsCall and accessesProps in one traversal. */
|
|
1679
|
-
function isDynamic(node: N): boolean {
|
|
1680
|
-
const key = node.start as number
|
|
1681
|
-
const cached = _isDynamicCache.get(key)
|
|
1682
|
-
if (cached !== undefined) return cached
|
|
1683
|
-
const result = _isDynamicImpl(node)
|
|
1684
|
-
_isDynamicCache.set(key, result)
|
|
1685
|
-
return result
|
|
1686
|
-
}
|
|
1687
|
-
|
|
1688
|
-
function _isDynamicImpl(node: N): boolean {
|
|
1689
|
-
// Call expression (non-pure)
|
|
1690
|
-
if (node.type === 'CallExpression') {
|
|
1691
|
-
if (!isPureStaticCall(node)) return true
|
|
1692
|
-
}
|
|
1693
|
-
if (node.type === 'TaggedTemplateExpression') return true
|
|
1694
|
-
// Props access
|
|
1695
|
-
if (node.type === 'MemberExpression' && !node.computed && node.object?.type === 'Identifier') {
|
|
1696
|
-
if (propsNames.has(node.object.name)) return true
|
|
1697
|
-
}
|
|
1698
|
-
// Prop-derived variable reference
|
|
1699
|
-
if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
|
|
1700
|
-
const parent = findParent(node)
|
|
1701
|
-
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) {
|
|
1702
|
-
// This is a property name position, not a reference — fall through
|
|
1703
|
-
} else {
|
|
1704
|
-
return true
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1707
|
-
// Signal variable reference — treated as dynamic (will be auto-called)
|
|
1708
|
-
if (node.type === 'Identifier' && isActiveSignal(node.name)) {
|
|
1709
|
-
const parent = findParent(node)
|
|
1710
|
-
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) {
|
|
1711
|
-
// Property name position — not a reference
|
|
1712
|
-
} else if (parent && parent.type === 'CallExpression' && parent.callee === node) {
|
|
1713
|
-
// Already being called: signal() — don't double-flag
|
|
1714
|
-
} else {
|
|
1715
|
-
return true
|
|
1716
|
-
}
|
|
1717
|
-
}
|
|
1718
|
-
// Don't recurse into nested functions
|
|
1719
|
-
if (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') return false
|
|
1720
|
-
// Recurse into children
|
|
1721
|
-
let found = false
|
|
1722
|
-
forEachChildFast(node, (child) => {
|
|
1723
|
-
if (found) return
|
|
1724
|
-
if (isDynamic(child)) found = true
|
|
1725
|
-
})
|
|
1726
|
-
return found
|
|
1727
|
-
}
|
|
1728
|
-
|
|
1729
|
-
/** accessesProps — kept for sliceExpr's quick check (does this need resolution?) */
|
|
1730
|
-
function accessesProps(node: N): boolean {
|
|
1731
|
-
if (node.type === 'MemberExpression' && !node.computed && node.object?.type === 'Identifier') {
|
|
1732
|
-
if (propsNames.has(node.object.name)) return true
|
|
1733
|
-
}
|
|
1734
|
-
if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
|
|
1735
|
-
const parent = findParent(node)
|
|
1736
|
-
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return false
|
|
1737
|
-
return true
|
|
1738
|
-
}
|
|
1739
|
-
let found = false
|
|
1740
|
-
forEachChildFast(node, (child) => {
|
|
1741
|
-
if (found) return
|
|
1742
|
-
if (child.type === 'ArrowFunctionExpression' || child.type === 'FunctionExpression') return
|
|
1743
|
-
if (accessesProps(child)) found = true
|
|
1744
|
-
})
|
|
1745
|
-
return found
|
|
1746
|
-
}
|
|
1747
|
-
|
|
1748
|
-
function shouldWrap(node: N): boolean {
|
|
1749
|
-
if (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') return false
|
|
1750
|
-
if (isStatic(node)) return false
|
|
1751
|
-
if (node.type === 'CallExpression' && isPureStaticCall(node)) return false
|
|
1752
|
-
return isDynamic(node)
|
|
1753
|
-
}
|
|
1754
|
-
|
|
1755
|
-
// ── Single unified walk (Phase 2) ─────────────────────────────────────────
|
|
1756
|
-
// Merges the old 3-pass architecture (scanForPropDerivedVars + transitive
|
|
1757
|
-
// resolution + JSX walk) into one top-down traversal. Works because `const`
|
|
1758
|
-
// declarations have a temporal dead zone — they're always before their use.
|
|
1759
|
-
let _callbackDepth = 0
|
|
1760
|
-
|
|
1761
|
-
function walkNode(node: N): void {
|
|
1762
|
-
// ── Component function detection (was pass 1) ──
|
|
1763
|
-
const isFunction = node.type === 'FunctionDeclaration' || node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression'
|
|
1764
|
-
let scopeShadows: string[] | null = null
|
|
1765
|
-
if (isFunction) {
|
|
1766
|
-
// Track callback nesting for prop-derived var exclusion
|
|
1767
|
-
const parent = findParent(node)
|
|
1768
|
-
const isCallbackArg = parent && parent.type === 'CallExpression' && (parent.arguments ?? []).includes(node)
|
|
1769
|
-
if (isCallbackArg) _callbackDepth++
|
|
1770
|
-
// Register component props (only for non-callback functions with JSX)
|
|
1771
|
-
maybeRegisterComponentProps(node)
|
|
1772
|
-
// Track signal name shadowing for scope awareness
|
|
1773
|
-
if (signalVars.size > 0) {
|
|
1774
|
-
scopeShadows = findShadowingNames(node)
|
|
1775
|
-
for (const name of scopeShadows) shadowedSignals.add(name)
|
|
1776
|
-
}
|
|
1777
|
-
}
|
|
1778
|
-
|
|
1779
|
-
// ── Variable declaration collection (was pass 1 + 2) ──
|
|
1780
|
-
if (node.type === 'VariableDeclaration') {
|
|
1781
|
-
collectPropDerivedFromDecl(node, _callbackDepth)
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
// ── JSX processing (was pass 3) ──
|
|
1785
|
-
if (node.type === 'JSXElement') {
|
|
1786
|
-
if (tryRocketstyleCollapse(node)) {
|
|
1787
|
-
// Collapsed to _rsCollapse — children are baked into the SSR-
|
|
1788
|
-
// resolved template; do not recurse into the subtree.
|
|
1789
|
-
return
|
|
1790
|
-
}
|
|
1791
|
-
if (!isSelfClosing(node) && tryTemplateEmit(node)) {
|
|
1792
|
-
// Template emitted — don't recurse into this subtree (JSXElement is never a function)
|
|
1793
|
-
return
|
|
1794
|
-
}
|
|
1795
|
-
checkForWarnings(node)
|
|
1796
|
-
for (const attr of jsxAttrs(node)) {
|
|
1797
|
-
if (attr.type === 'JSXAttribute') handleJsxAttribute(attr, node)
|
|
1798
|
-
else if (attr.type === 'JSXSpreadAttribute') handleJsxSpreadAttribute(attr, node)
|
|
1799
|
-
}
|
|
1800
|
-
for (const child of jsxChildren(node)) {
|
|
1801
|
-
if (child.type === 'JSXExpressionContainer') handleJsxExpression(child, node)
|
|
1802
|
-
else walkNode(child)
|
|
1803
|
-
}
|
|
1804
|
-
// Note: JSXElement is never a function, so no callback depth or scope cleanup needed here
|
|
1805
|
-
return
|
|
1806
|
-
}
|
|
1807
|
-
if (node.type === 'JSXExpressionContainer') {
|
|
1808
|
-
handleJsxExpression(node)
|
|
1809
|
-
// Note: JSXExpressionContainer is never a function, no scope cleanup needed
|
|
1810
|
-
return
|
|
1811
|
-
}
|
|
1812
|
-
|
|
1813
|
-
// Generic descent
|
|
1814
|
-
forEachChildFast(node, walkNode)
|
|
1815
|
-
|
|
1816
|
-
// Restore callback depth after leaving function
|
|
1817
|
-
if (isFunction) {
|
|
1818
|
-
const parent = findParent(node)
|
|
1819
|
-
if (parent && parent.type === 'CallExpression' && (parent.arguments ?? []).includes(node)) _callbackDepth--
|
|
1820
|
-
}
|
|
1821
|
-
// Restore signal shadowing
|
|
1822
|
-
if (scopeShadows) for (const name of scopeShadows) shadowedSignals.delete(name)
|
|
1823
|
-
}
|
|
1824
|
-
|
|
1825
|
-
walkNode(program)
|
|
1826
|
-
|
|
1827
|
-
if (replacements.length === 0 && hoists.length === 0) {
|
|
1828
|
-
return collectLens ? { code, warnings, reactivityLens } : { code, warnings }
|
|
1829
|
-
}
|
|
1830
|
-
|
|
1831
|
-
replacements.sort((a, b) => a.start - b.start)
|
|
1832
|
-
// R12 fix: apply the disjoint, sorted {start,end,text} edits through
|
|
1833
|
-
// MagicString instead of manual slice/join. `toString()` is byte-identical
|
|
1834
|
-
// to the old concatenation (the full 1200-test suite + native-equivalence
|
|
1835
|
-
// assert exact emitted strings), but `generateMap()` now yields a correct
|
|
1836
|
-
// V3 source map — the previous transform emitted none AND shifted line
|
|
1837
|
-
// counts (template emission expands one-line JSX into a multi-line _tpl
|
|
1838
|
-
// factory), so every stack frame / breakpoint in a Pyreon component
|
|
1839
|
-
// mislocated app-wide.
|
|
1840
|
-
const s = new MagicString(code)
|
|
1841
|
-
for (const r of replacements) {
|
|
1842
|
-
if (r.start === r.end) s.appendLeft(r.start, r.text)
|
|
1843
|
-
else s.update(r.start, r.end, r.text)
|
|
1844
|
-
}
|
|
1845
|
-
|
|
1846
|
-
// Build the generated preamble (hoists + auto-imports + collapse prologue)
|
|
1847
|
-
// in the SAME final top-to-bottom order the previous chained `X + output`
|
|
1848
|
-
// produced, then `prepend` it ONCE. magic-string's prepend shifts every
|
|
1849
|
-
// source mapping down by the preamble's line count, so original positions
|
|
1850
|
-
// resolve to the correct OUTPUT lines despite the inserted preamble — the
|
|
1851
|
-
// exact line-shift R12 measured. Innermost (closest to code) first.
|
|
1852
|
-
let preamble = ''
|
|
1853
|
-
|
|
1854
|
-
if (hoists.length > 0) {
|
|
1855
|
-
preamble = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join('') + preamble
|
|
1856
|
-
}
|
|
1857
|
-
|
|
1858
|
-
if (needsTplImport) {
|
|
1859
|
-
const runtimeDomImports = ['_tpl']
|
|
1860
|
-
if (needsBindDirectImportGlobal) runtimeDomImports.push('_bindDirect')
|
|
1861
|
-
if (needsBindTextImportGlobal) runtimeDomImports.push('_bindText')
|
|
1862
|
-
if (needsApplyPropsImportGlobal) runtimeDomImports.push('_applyProps')
|
|
1863
|
-
if (needsMountSlotImportGlobal) runtimeDomImports.push('_mountSlot')
|
|
1864
|
-
const reactivityImports = needsBindImportGlobal
|
|
1865
|
-
? `\nimport { _bind } from "@pyreon/reactivity";`
|
|
1866
|
-
: ''
|
|
1867
|
-
preamble =
|
|
1868
|
-
`import { ${runtimeDomImports.join(', ')} } from "@pyreon/runtime-dom";${reactivityImports}\n` +
|
|
1869
|
-
preamble
|
|
1870
|
-
}
|
|
1871
|
-
|
|
1872
|
-
if (needsRpImport || needsWrapSpreadImport) {
|
|
1873
|
-
const coreImports: string[] = []
|
|
1874
|
-
if (needsRpImport) coreImports.push('_rp')
|
|
1875
|
-
if (needsWrapSpreadImport) coreImports.push('_wrapSpread')
|
|
1876
|
-
preamble = `import { ${coreImports.join(', ')} } from "@pyreon/core";\n` + preamble
|
|
1877
|
-
}
|
|
1878
|
-
|
|
1879
|
-
if (needsCollapse || needsCollapseDyn || needsCollapseDynH) {
|
|
1880
|
-
const cfg = options.collapseRocketstyle!
|
|
1881
|
-
const rd = cfg.runtimeDomSource ?? '@pyreon/runtime-dom'
|
|
1882
|
-
const st = cfg.stylerSource ?? '@pyreon/styler'
|
|
1883
|
-
// One idempotent injectRules per distinct rule bundle — keyed by the
|
|
1884
|
-
// resolver's FNV so a re-eval (HMR) or another module's identical
|
|
1885
|
-
// bundle is a no-op (styler dedupes by key). Runs at module-eval,
|
|
1886
|
-
// before any collapsed site mounts, so the sheet is populated
|
|
1887
|
-
// without a prior runtime mount of the real component.
|
|
1888
|
-
const inj = collapseRules
|
|
1889
|
-
.map(
|
|
1890
|
-
(r) =>
|
|
1891
|
-
`__rsSheet.injectRules(${JSON.stringify(r.rules)},${JSON.stringify(r.ruleKey)});`,
|
|
1892
|
-
)
|
|
1893
|
-
.join('')
|
|
1894
|
-
// Only import the helpers actually emitted into this module — keeps
|
|
1895
|
-
// the bundle bytes per-feature and tree-shakable. needsCollapse
|
|
1896
|
-
// (full) gates `_rsCollapse`; the partial / dynamic flags gate
|
|
1897
|
-
// their respective helpers independently.
|
|
1898
|
-
const rdImports: string[] = []
|
|
1899
|
-
if (needsCollapse) rdImports.push('_rsCollapse as __rsCollapse')
|
|
1900
|
-
if (needsCollapseH) rdImports.push('_rsCollapseH as __rsCollapseH')
|
|
1901
|
-
if (needsCollapseDyn) rdImports.push('_rsCollapseDyn as __rsCollapseDyn')
|
|
1902
|
-
if (needsCollapseDynH) rdImports.push('_rsCollapseDynH as __rsCollapseDynH')
|
|
1903
|
-
preamble =
|
|
1904
|
-
`import { ${rdImports.join(', ')} } from "${rd}";\n` +
|
|
1905
|
-
`import { sheet as __rsSheet } from "${st}";\n` +
|
|
1906
|
-
`import { ${cfg.mode.name} as __pyrMode } from "${cfg.mode.source}";\n` +
|
|
1907
|
-
`${inj}\n` +
|
|
1908
|
-
preamble
|
|
1909
|
-
}
|
|
1910
|
-
|
|
1911
|
-
if (preamble) s.prepend(preamble)
|
|
1912
|
-
|
|
1913
|
-
const output = s.toString()
|
|
1914
|
-
const map = s.generateMap({
|
|
1915
|
-
source: filename,
|
|
1916
|
-
includeContent: true,
|
|
1917
|
-
hires: true,
|
|
1918
|
-
}) as unknown as GeneratedSourceMap
|
|
1919
|
-
|
|
1920
|
-
return collectLens
|
|
1921
|
-
? { code: output, usesTemplates: needsTplImport, warnings, map, reactivityLens }
|
|
1922
|
-
: { code: output, usesTemplates: needsTplImport, warnings, map }
|
|
1923
|
-
|
|
1924
|
-
// ── Template emission helpers ─────────────────────────────────────────────
|
|
1925
|
-
|
|
1926
|
-
function hasBailAttr(node: N, isRoot = false): boolean {
|
|
1927
|
-
for (const attr of jsxAttrs(node)) {
|
|
1928
|
-
if (attr.type === 'JSXSpreadAttribute') {
|
|
1929
|
-
if (isRoot) continue
|
|
1930
|
-
return true
|
|
1931
|
-
}
|
|
1932
|
-
if (attr.type === 'JSXAttribute' && attr.name?.type === 'JSXIdentifier' && attr.name.name === 'key')
|
|
1933
|
-
return true
|
|
1934
|
-
}
|
|
1935
|
-
return false
|
|
1936
|
-
}
|
|
1937
|
-
|
|
1938
|
-
function countChildForTemplate(child: N): number {
|
|
1939
|
-
if (child.type === 'JSXText') return 0
|
|
1940
|
-
if (child.type === 'JSXElement') return templateElementCount(child)
|
|
1941
|
-
if (child.type === 'JSXExpressionContainer') {
|
|
1942
|
-
const expr = child.expression
|
|
1943
|
-
if (!expr || expr.type === 'JSXEmptyExpression') return 0
|
|
1944
|
-
return containsJSXInExpr(expr) ? -1 : 0
|
|
1945
|
-
}
|
|
1946
|
-
if (child.type === 'JSXFragment') return templateFragmentCount(child)
|
|
1947
|
-
return -1
|
|
1948
|
-
}
|
|
1949
|
-
|
|
1950
|
-
function templateElementCount(node: N, isRoot = false): number {
|
|
1951
|
-
const tag = jsxTagName(node)
|
|
1952
|
-
if (!tag || !isLowerCase(tag)) return -1
|
|
1953
|
-
if (hasBailAttr(node, isRoot)) return -1
|
|
1954
|
-
if (isSelfClosing(node)) return 1
|
|
1955
|
-
let count = 1
|
|
1956
|
-
for (const child of jsxChildren(node)) {
|
|
1957
|
-
const c = countChildForTemplate(child)
|
|
1958
|
-
if (c === -1) return -1
|
|
1959
|
-
count += c
|
|
1960
|
-
}
|
|
1961
|
-
return count
|
|
1962
|
-
}
|
|
1963
|
-
|
|
1964
|
-
function templateFragmentCount(frag: N): number {
|
|
1965
|
-
let count = 0
|
|
1966
|
-
for (const child of jsxChildren(frag)) {
|
|
1967
|
-
const c = countChildForTemplate(child)
|
|
1968
|
-
if (c === -1) return -1
|
|
1969
|
-
count += c
|
|
1970
|
-
}
|
|
1971
|
-
return count
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
function buildTemplateCall(node: N): string | null {
|
|
1975
|
-
const bindLines: string[] = []
|
|
1976
|
-
const disposerNames: string[] = []
|
|
1977
|
-
let varIdx = 0
|
|
1978
|
-
let dispIdx = 0
|
|
1979
|
-
const reactiveBindExprs: string[] = []
|
|
1980
|
-
let needsBindTextImport = false
|
|
1981
|
-
let needsBindDirectImport = false
|
|
1982
|
-
let needsApplyPropsImport = false
|
|
1983
|
-
let needsMountSlotImport = false
|
|
1984
|
-
|
|
1985
|
-
function nextVar(): string { return `__e${varIdx++}` }
|
|
1986
|
-
function nextDisp(): string {
|
|
1987
|
-
const name = `__d${dispIdx++}`
|
|
1988
|
-
disposerNames.push(name)
|
|
1989
|
-
return name
|
|
1990
|
-
}
|
|
1991
|
-
function nextTextVar(): string { return `__t${varIdx++}` }
|
|
1992
|
-
|
|
1993
|
-
function resolveElementVar(accessor: string, hasDynamic: boolean): string {
|
|
1994
|
-
if (accessor === '__root') return '__root'
|
|
1995
|
-
if (hasDynamic) {
|
|
1996
|
-
const v = nextVar()
|
|
1997
|
-
bindLines.push(`const ${v} = ${accessor}`)
|
|
1998
|
-
return v
|
|
1999
|
-
}
|
|
2000
|
-
return accessor
|
|
2001
|
-
}
|
|
2002
|
-
|
|
2003
|
-
function emitRef(attr: N, varName: string): void {
|
|
2004
|
-
if (!attr.value || attr.value.type !== 'JSXExpressionContainer') return
|
|
2005
|
-
const expr = attr.value.expression
|
|
2006
|
-
if (!expr || expr.type === 'JSXEmptyExpression') return
|
|
2007
|
-
if (expr.type === 'ArrowFunctionExpression' || expr.type === 'FunctionExpression') {
|
|
2008
|
-
bindLines.push(`(${sliceExpr(expr)})(${varName})`)
|
|
2009
|
-
} else {
|
|
2010
|
-
bindLines.push(
|
|
2011
|
-
`{ const __r = ${sliceExpr(expr)}; if (typeof __r === "function") __r(${varName}); else if (__r) __r.current = ${varName} }`,
|
|
2012
|
-
)
|
|
2013
|
-
}
|
|
2014
|
-
}
|
|
2015
|
-
|
|
2016
|
-
function emitEventListener(attr: N, attrName: string, varName: string): void {
|
|
2017
|
-
// Translate the JSX-style React attribute name (e.g. `onKeyDown`,
|
|
2018
|
-
// `onDoubleClick`) to the canonical DOM event name (`keydown`,
|
|
2019
|
-
// `dblclick`).
|
|
2020
|
-
//
|
|
2021
|
-
// The default rule is "drop the `on` prefix and lowercase" —
|
|
2022
|
-
// covers `onKeyDown` → `keydown`, `onMouseEnter` → `mouseenter`,
|
|
2023
|
-
// `onPointerLeave` → `pointerleave`, `onAnimationStart` →
|
|
2024
|
-
// `animationstart`, etc. Most React event names follow this rule
|
|
2025
|
-
// because the underlying DOM event name is also the lowercased
|
|
2026
|
-
// multi-word form.
|
|
2027
|
-
//
|
|
2028
|
-
// The exception list lives in `REACT_EVENT_REMAP` (event-names.ts).
|
|
2029
|
-
// Every React event-prop in the official component-prop list was
|
|
2030
|
-
// audited against canonical DOM event names — see the JSDoc on
|
|
2031
|
-
// REACT_EVENT_REMAP for the audit. Today exactly one entry:
|
|
2032
|
-
// `onDoubleClick` → `dblclick`
|
|
2033
|
-
// The Rust native backend (`native/src/lib.rs:emit_event_listener`)
|
|
2034
|
-
// mirrors the same table — keep them in sync if a new entry is added.
|
|
2035
|
-
const lowered = attrName.slice(2).toLowerCase()
|
|
2036
|
-
const eventName = REACT_EVENT_REMAP[lowered] ?? lowered
|
|
2037
|
-
if (!attr.value || attr.value.type !== 'JSXExpressionContainer') return
|
|
2038
|
-
const expr = attr.value.expression
|
|
2039
|
-
if (!expr || expr.type === 'JSXEmptyExpression') return
|
|
2040
|
-
const handler = sliceExpr(expr)
|
|
2041
|
-
if (DELEGATED_EVENTS.has(eventName)) {
|
|
2042
|
-
bindLines.push(`${varName}.__ev_${eventName} = ${handler}`)
|
|
2043
|
-
} else {
|
|
2044
|
-
bindLines.push(`${varName}.addEventListener("${eventName}", ${handler})`)
|
|
2045
|
-
}
|
|
2046
|
-
}
|
|
2047
|
-
|
|
2048
|
-
function staticAttrToHtml(exprNode: N, htmlAttrName: string): string | null {
|
|
2049
|
-
if (!isStatic(exprNode)) return null
|
|
2050
|
-
// String literal
|
|
2051
|
-
if ((exprNode.type === 'Literal' || exprNode.type === 'StringLiteral') && typeof exprNode.value === 'string')
|
|
2052
|
-
return ` ${htmlAttrName}="${escapeHtmlAttr(exprNode.value)}"`
|
|
2053
|
-
// Numeric literal
|
|
2054
|
-
if ((exprNode.type === 'Literal' || exprNode.type === 'NumericLiteral') && typeof exprNode.value === 'number')
|
|
2055
|
-
return ` ${htmlAttrName}="${exprNode.value}"`
|
|
2056
|
-
// Boolean true
|
|
2057
|
-
if ((exprNode.type === 'Literal' || exprNode.type === 'BooleanLiteral') && exprNode.value === true)
|
|
2058
|
-
return ` ${htmlAttrName}`
|
|
2059
|
-
return '' // false/null/undefined → omit
|
|
2060
|
-
}
|
|
2061
|
-
|
|
2062
|
-
function tryDirectSignalRef(exprNode: N): string | null {
|
|
2063
|
-
let inner = exprNode
|
|
2064
|
-
if (inner.type === 'ArrowFunctionExpression' && inner.body?.type !== 'BlockStatement') {
|
|
2065
|
-
inner = inner.body
|
|
2066
|
-
}
|
|
2067
|
-
if (inner.type !== 'CallExpression') return null
|
|
2068
|
-
if ((inner.arguments?.length ?? 0) > 0) return null
|
|
2069
|
-
const callee = inner.callee
|
|
2070
|
-
if (callee?.type === 'Identifier') return sliceExpr(callee)
|
|
2071
|
-
return null
|
|
2072
|
-
}
|
|
2073
|
-
|
|
2074
|
-
function unwrapAccessor(exprNode: N): { expr: string; isReactive: boolean } {
|
|
2075
|
-
if (exprNode.type === 'ArrowFunctionExpression' && exprNode.body?.type !== 'BlockStatement') {
|
|
2076
|
-
return { expr: sliceExpr(exprNode.body), isReactive: true }
|
|
2077
|
-
}
|
|
2078
|
-
if (exprNode.type === 'ArrowFunctionExpression' || exprNode.type === 'FunctionExpression') {
|
|
2079
|
-
return { expr: `(${sliceExpr(exprNode)})()`, isReactive: true }
|
|
2080
|
-
}
|
|
2081
|
-
return { expr: sliceExpr(exprNode), isReactive: isDynamic(exprNode) }
|
|
2082
|
-
}
|
|
2083
|
-
|
|
2084
|
-
function attrSetter(htmlAttrName: string, varName: string, expr: string): string {
|
|
2085
|
-
if (htmlAttrName === 'class') return `${varName}.className = ${expr}`
|
|
2086
|
-
if (htmlAttrName === 'style') return `${varName}.style.cssText = ${expr}`
|
|
2087
|
-
if (DOM_PROPS.has(htmlAttrName)) return `${varName}.${htmlAttrName} = ${expr}`
|
|
2088
|
-
return `${varName}.setAttribute("${htmlAttrName}", ${expr})`
|
|
2089
|
-
}
|
|
2090
|
-
|
|
2091
|
-
function emitDynamicAttr(_expr: string, exprNode: N, htmlAttrName: string, varName: string): void {
|
|
2092
|
-
const { expr, isReactive } = unwrapAccessor(exprNode)
|
|
2093
|
-
if (!isReactive) {
|
|
2094
|
-
bindLines.push(attrSetter(htmlAttrName, varName, expr))
|
|
2095
|
-
return
|
|
2096
|
-
}
|
|
2097
|
-
lens(
|
|
2098
|
-
exprNode.start as number,
|
|
2099
|
-
exprNode.end as number,
|
|
2100
|
-
'reactive-attr',
|
|
2101
|
-
`live attribute — \`${htmlAttrName}\` re-applies whenever its signals change`,
|
|
2102
|
-
)
|
|
2103
|
-
const directRef = tryDirectSignalRef(exprNode)
|
|
2104
|
-
if (directRef) {
|
|
2105
|
-
needsBindDirectImport = true
|
|
2106
|
-
const d = nextDisp()
|
|
2107
|
-
const updater =
|
|
2108
|
-
htmlAttrName === 'class'
|
|
2109
|
-
? `(v) => { ${varName}.className = v == null ? "" : String(v) }`
|
|
2110
|
-
: htmlAttrName === 'style'
|
|
2111
|
-
? `(v) => { if (typeof v === "string") ${varName}.style.cssText = v; else if (v) Object.assign(${varName}.style, v) }`
|
|
2112
|
-
: DOM_PROPS.has(htmlAttrName)
|
|
2113
|
-
? `(v) => { ${varName}.${htmlAttrName} = v }`
|
|
2114
|
-
: `(v) => { ${varName}.setAttribute("${htmlAttrName}", v == null ? "" : String(v)) }`
|
|
2115
|
-
bindLines.push(`const ${d} = _bindDirect(${directRef}, ${updater})`)
|
|
2116
|
-
return
|
|
2117
|
-
}
|
|
2118
|
-
reactiveBindExprs.push(attrSetter(htmlAttrName, varName, expr))
|
|
2119
|
-
}
|
|
2120
|
-
|
|
2121
|
-
function emitAttrExpression(exprNode: N, htmlAttrName: string, varName: string): string {
|
|
2122
|
-
const staticHtml = staticAttrToHtml(exprNode, htmlAttrName)
|
|
2123
|
-
if (staticHtml !== null) return staticHtml
|
|
2124
|
-
if (htmlAttrName === 'style' && exprNode.type === 'ObjectExpression') {
|
|
2125
|
-
bindLines.push(`Object.assign(${varName}.style, ${sliceExpr(exprNode)})`)
|
|
2126
|
-
return ''
|
|
2127
|
-
}
|
|
2128
|
-
emitDynamicAttr(sliceExpr(exprNode), exprNode, htmlAttrName, varName)
|
|
2129
|
-
return ''
|
|
2130
|
-
}
|
|
2131
|
-
|
|
2132
|
-
function tryEmitSpecialAttr(attr: N, attrName: string, varName: string): boolean {
|
|
2133
|
-
if (attrName === 'ref') { emitRef(attr, varName); return true }
|
|
2134
|
-
if (EVENT_RE.test(attrName)) { emitEventListener(attr, attrName, varName); return true }
|
|
2135
|
-
return false
|
|
2136
|
-
}
|
|
2137
|
-
|
|
2138
|
-
function attrInitializerToHtml(attr: N, htmlAttrName: string, varName: string): string {
|
|
2139
|
-
if (!attr.value) return ` ${htmlAttrName}`
|
|
2140
|
-
// JSX string attribute: class="foo"
|
|
2141
|
-
if (attr.value.type === 'StringLiteral' || (attr.value.type === 'Literal' && typeof attr.value.value === 'string'))
|
|
2142
|
-
return ` ${htmlAttrName}="${escapeHtmlAttr(attr.value.value)}"`
|
|
2143
|
-
if (attr.value.type === 'JSXExpressionContainer') {
|
|
2144
|
-
const expr = attr.value.expression
|
|
2145
|
-
if (expr && expr.type !== 'JSXEmptyExpression') return emitAttrExpression(expr, htmlAttrName, varName)
|
|
2146
|
-
}
|
|
2147
|
-
return ''
|
|
2148
|
-
}
|
|
2149
|
-
|
|
2150
|
-
function processOneAttr(attr: N, varName: string): string {
|
|
2151
|
-
if (attr.type === 'JSXSpreadAttribute') {
|
|
2152
|
-
const expr = sliceExpr(attr.argument)
|
|
2153
|
-
needsApplyPropsImport = true
|
|
2154
|
-
if (isDynamic(attr.argument)) {
|
|
2155
|
-
reactiveBindExprs.push(`_applyProps(${varName}, ${expr})`)
|
|
2156
|
-
} else {
|
|
2157
|
-
bindLines.push(`_applyProps(${varName}, ${expr})`)
|
|
2158
|
-
}
|
|
2159
|
-
return ''
|
|
2160
|
-
}
|
|
2161
|
-
if (attr.type !== 'JSXAttribute') return ''
|
|
2162
|
-
const attrName = attr.name?.type === 'JSXIdentifier' ? attr.name.name : ''
|
|
2163
|
-
if (attrName === 'key') return ''
|
|
2164
|
-
if (tryEmitSpecialAttr(attr, attrName, varName)) return ''
|
|
2165
|
-
return attrInitializerToHtml(attr, JSX_TO_HTML_ATTR[attrName] ?? attrName, varName)
|
|
2166
|
-
}
|
|
2167
|
-
|
|
2168
|
-
function processAttrs(el: N, varName: string): string {
|
|
2169
|
-
let htmlAttrs = ''
|
|
2170
|
-
for (const attr of jsxAttrs(el)) htmlAttrs += processOneAttr(attr, varName)
|
|
2171
|
-
return htmlAttrs
|
|
2172
|
-
}
|
|
2173
|
-
|
|
2174
|
-
function emitReactiveTextChild(
|
|
2175
|
-
expr: string, exprNode: N, varName: string,
|
|
2176
|
-
parentRef: string, childNodeIdx: number, needsPlaceholder: boolean,
|
|
2177
|
-
): string {
|
|
2178
|
-
const tVar = nextTextVar()
|
|
2179
|
-
bindLines.push(`const ${tVar} = document.createTextNode("")`)
|
|
2180
|
-
if (needsPlaceholder) {
|
|
2181
|
-
bindLines.push(`${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`)
|
|
2182
|
-
} else {
|
|
2183
|
-
bindLines.push(`${varName}.appendChild(${tVar})`)
|
|
2184
|
-
}
|
|
2185
|
-
const directRef = tryDirectSignalRef(exprNode)
|
|
2186
|
-
if (directRef) {
|
|
2187
|
-
needsBindTextImport = true
|
|
2188
|
-
const d = nextDisp()
|
|
2189
|
-
bindLines.push(`const ${d} = _bindText(${directRef}, ${tVar})`)
|
|
2190
|
-
} else {
|
|
2191
|
-
needsBindImportGlobal = true
|
|
2192
|
-
const d = nextDisp()
|
|
2193
|
-
bindLines.push(`const ${d} = _bind(() => { ${tVar}.data = ${expr} })`)
|
|
2194
|
-
}
|
|
2195
|
-
return needsPlaceholder ? '<!>' : ''
|
|
2196
|
-
}
|
|
2197
|
-
|
|
2198
|
-
function emitStaticTextChild(
|
|
2199
|
-
expr: string, varName: string,
|
|
2200
|
-
parentRef: string, childNodeIdx: number, needsPlaceholder: boolean,
|
|
2201
|
-
): string {
|
|
2202
|
-
if (needsPlaceholder) {
|
|
2203
|
-
const tVar = nextTextVar()
|
|
2204
|
-
bindLines.push(`const ${tVar} = document.createTextNode(${expr})`)
|
|
2205
|
-
bindLines.push(`${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`)
|
|
2206
|
-
return '<!>'
|
|
2207
|
-
}
|
|
2208
|
-
bindLines.push(`${varName}.textContent = ${expr}`)
|
|
2209
|
-
return ''
|
|
2210
|
-
}
|
|
2211
|
-
|
|
2212
|
-
type FlatChild =
|
|
2213
|
-
| { kind: 'text'; text: string }
|
|
2214
|
-
| { kind: 'element'; node: N; elemIdx: number }
|
|
2215
|
-
| { kind: 'expression'; expression: N }
|
|
2216
|
-
|
|
2217
|
-
function classifyJsxChild(
|
|
2218
|
-
child: N, out: FlatChild[],
|
|
2219
|
-
elemIdxRef: { value: number },
|
|
2220
|
-
recurse: (kids: N[]) => void,
|
|
2221
|
-
): void {
|
|
2222
|
-
if (child.type === 'JSXText') {
|
|
2223
|
-
const raw = child.value ?? child.raw ?? ''
|
|
2224
|
-
const cleaned = cleanJsxText(raw)
|
|
2225
|
-
if (cleaned) out.push({ kind: 'text', text: cleaned })
|
|
2226
|
-
return
|
|
2227
|
-
}
|
|
2228
|
-
if (child.type === 'JSXElement') {
|
|
2229
|
-
out.push({ kind: 'element', node: child, elemIdx: elemIdxRef.value++ })
|
|
2230
|
-
return
|
|
2231
|
-
}
|
|
2232
|
-
if (child.type === 'JSXExpressionContainer') {
|
|
2233
|
-
const expr = child.expression
|
|
2234
|
-
if (expr && expr.type !== 'JSXEmptyExpression') out.push({ kind: 'expression', expression: expr })
|
|
2235
|
-
return
|
|
2236
|
-
}
|
|
2237
|
-
if (child.type === 'JSXFragment') recurse(jsxChildren(child))
|
|
2238
|
-
}
|
|
2239
|
-
|
|
2240
|
-
function flattenChildren(children: N[]): FlatChild[] {
|
|
2241
|
-
const flatList: FlatChild[] = []
|
|
2242
|
-
const elemIdxRef = { value: 0 }
|
|
2243
|
-
function addChildren(kids: N[]): void {
|
|
2244
|
-
for (const child of kids) classifyJsxChild(child, flatList, elemIdxRef, addChildren)
|
|
2245
|
-
}
|
|
2246
|
-
addChildren(children)
|
|
2247
|
-
return flatList
|
|
2248
|
-
}
|
|
2249
|
-
|
|
2250
|
-
function analyzeChildren(flatChildren: FlatChild[]): { useMixed: boolean; useMultiExpr: boolean } {
|
|
2251
|
-
const hasElem = flatChildren.some((c) => c.kind === 'element')
|
|
2252
|
-
const hasText = flatChildren.some((c) => c.kind === 'text')
|
|
2253
|
-
const exprCount = flatChildren.filter((c) => c.kind === 'expression').length
|
|
2254
|
-
// `useMixed` triggers placeholder-based positional mounting (each
|
|
2255
|
-
// dynamic child gets a `<!>` comment slot in the template that
|
|
2256
|
-
// `replaceChild`-replaces at mount). It must fire whenever ≥2 of
|
|
2257
|
-
// {element, text, expression} are interleaved — otherwise dynamic
|
|
2258
|
-
// text nodes added via `appendChild` land after all static
|
|
2259
|
-
// template content, breaking source-order rendering for shapes
|
|
2260
|
-
// like `<p>foo {x()} bar</p>` (rendered "foo barX" instead of
|
|
2261
|
-
// "foo X bar"). Discovered by Phase B2's whitespace tests.
|
|
2262
|
-
const present =
|
|
2263
|
-
(hasElem ? 1 : 0) + (hasText ? 1 : 0) + (exprCount > 0 ? 1 : 0)
|
|
2264
|
-
return { useMixed: present > 1, useMultiExpr: exprCount > 1 }
|
|
2265
|
-
}
|
|
2266
|
-
|
|
2267
|
-
function attrIsDynamic(attr: N): boolean {
|
|
2268
|
-
if (attr.type !== 'JSXAttribute') return false
|
|
2269
|
-
const name = attr.name?.type === 'JSXIdentifier' ? attr.name.name : ''
|
|
2270
|
-
if (name === 'ref') return true
|
|
2271
|
-
if (EVENT_RE.test(name)) return true
|
|
2272
|
-
if (!attr.value || attr.value.type !== 'JSXExpressionContainer') return false
|
|
2273
|
-
const expr = attr.value.expression
|
|
2274
|
-
return expr && expr.type !== 'JSXEmptyExpression' ? !isStatic(expr) : false
|
|
2275
|
-
}
|
|
2276
|
-
|
|
2277
|
-
function elementHasDynamic(node: N): boolean {
|
|
2278
|
-
if (jsxAttrs(node).some(attrIsDynamic)) return true
|
|
2279
|
-
if (!isSelfClosing(node)) {
|
|
2280
|
-
return jsxChildren(node).some((c: N) =>
|
|
2281
|
-
c.type === 'JSXExpressionContainer' && c.expression && c.expression.type !== 'JSXEmptyExpression',
|
|
2282
|
-
)
|
|
2283
|
-
}
|
|
2284
|
-
return false
|
|
2285
|
-
}
|
|
2286
|
-
|
|
2287
|
-
function processOneChild(
|
|
2288
|
-
child: FlatChild, varName: string, parentRef: string,
|
|
2289
|
-
useMixed: boolean, useMultiExpr: boolean, childNodeIdx: number,
|
|
2290
|
-
): string | null {
|
|
2291
|
-
if (child.kind === 'text') return escapeHtmlText(child.text)
|
|
2292
|
-
if (child.kind === 'element') {
|
|
2293
|
-
const childAccessor = useMixed
|
|
2294
|
-
? `${parentRef}.childNodes[${childNodeIdx}]`
|
|
2295
|
-
: `${parentRef}.children[${child.elemIdx}]`
|
|
2296
|
-
return processElement(child.node, childAccessor)
|
|
2297
|
-
}
|
|
2298
|
-
const needsPlaceholder = useMixed || useMultiExpr
|
|
2299
|
-
const { expr, isReactive } = unwrapAccessor(child.expression)
|
|
2300
|
-
// Round 9 fix: a bare `{el}` where `el` is an element-valued binding
|
|
2301
|
-
// (`const el = <X/>`) must be MOUNTED via _mountSlot, not text-coerced
|
|
2302
|
-
// via createTextNode (which stringifies the NativeItem). Same emission
|
|
2303
|
-
// as the children-slot path; _mountSlot handles every child type.
|
|
2304
|
-
const isElementValuedIdent =
|
|
2305
|
-
(child.expression?.type === 'Identifier' && elementVars.has(child.expression.name)) ||
|
|
2306
|
-
(!isReactive && /^[A-Za-z_$][\w$]*$/.test(expr) && elementVars.has(expr))
|
|
2307
|
-
if (isChildrenExpression(child.expression, expr) || isElementValuedIdent) {
|
|
2308
|
-
needsMountSlotImport = true
|
|
2309
|
-
const placeholder = `${parentRef}.childNodes[${childNodeIdx}]`
|
|
2310
|
-
const d = nextDisp()
|
|
2311
|
-
bindLines.push(`const ${d} = _mountSlot(${expr}, ${parentRef}, ${placeholder})`)
|
|
2312
|
-
return '<!>'
|
|
2313
|
-
}
|
|
2314
|
-
const cx = child.expression
|
|
2315
|
-
if (isReactive) {
|
|
2316
|
-
lens(
|
|
2317
|
-
cx.start as number,
|
|
2318
|
-
cx.end as number,
|
|
2319
|
-
'reactive',
|
|
2320
|
-
'live — this text re-renders whenever its signals change',
|
|
2321
|
-
)
|
|
2322
|
-
return emitReactiveTextChild(expr, child.expression, varName, parentRef, childNodeIdx, needsPlaceholder)
|
|
2323
|
-
}
|
|
2324
|
-
lens(
|
|
2325
|
-
cx.start as number,
|
|
2326
|
-
cx.end as number,
|
|
2327
|
-
'static-text',
|
|
2328
|
-
'baked once into the DOM — never re-renders (no signal read here)',
|
|
2329
|
-
)
|
|
2330
|
-
return emitStaticTextChild(expr, varName, parentRef, childNodeIdx, needsPlaceholder)
|
|
2331
|
-
}
|
|
2332
|
-
|
|
2333
|
-
function processChildren(el: N, varName: string, accessor: string): string | null {
|
|
2334
|
-
const flatChildren = flattenChildren(jsxChildren(el))
|
|
2335
|
-
const { useMixed, useMultiExpr } = analyzeChildren(flatChildren)
|
|
2336
|
-
const parentRef = accessor === '__root' ? '__root' : varName
|
|
2337
|
-
let html = ''
|
|
2338
|
-
let childNodeIdx = 0
|
|
2339
|
-
for (const child of flatChildren) {
|
|
2340
|
-
const childHtml = processOneChild(child, varName, parentRef, useMixed, useMultiExpr, childNodeIdx)
|
|
2341
|
-
if (childHtml === null) return null
|
|
2342
|
-
html += childHtml
|
|
2343
|
-
childNodeIdx++
|
|
2344
|
-
}
|
|
2345
|
-
return html
|
|
2346
|
-
}
|
|
2347
|
-
|
|
2348
|
-
function processElement(el: N, accessor: string): string | null {
|
|
2349
|
-
const tag = jsxTagName(el)
|
|
2350
|
-
if (!tag) return null
|
|
2351
|
-
const varName = resolveElementVar(accessor, elementHasDynamic(el))
|
|
2352
|
-
const htmlAttrs = processAttrs(el, varName)
|
|
2353
|
-
let html = `<${tag}${htmlAttrs}>`
|
|
2354
|
-
if (!isSelfClosing(el)) {
|
|
2355
|
-
const childHtml = processChildren(el, varName, accessor)
|
|
2356
|
-
if (childHtml === null) return null
|
|
2357
|
-
html += childHtml
|
|
2358
|
-
}
|
|
2359
|
-
if (!VOID_ELEMENTS.has(tag)) html += `</${tag}>`
|
|
2360
|
-
return html
|
|
2361
|
-
}
|
|
2362
|
-
|
|
2363
|
-
const html = processElement(node, '__root')
|
|
2364
|
-
if (html === null) return null
|
|
2365
|
-
|
|
2366
|
-
if (needsBindTextImport) needsBindTextImportGlobal = true
|
|
2367
|
-
if (needsBindDirectImport) needsBindDirectImportGlobal = true
|
|
2368
|
-
if (needsApplyPropsImport) needsApplyPropsImportGlobal = true
|
|
2369
|
-
if (needsMountSlotImport) needsMountSlotImportGlobal = true
|
|
2370
|
-
|
|
2371
|
-
const escaped = html.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
2372
|
-
|
|
2373
|
-
if (reactiveBindExprs.length > 0) {
|
|
2374
|
-
needsBindImportGlobal = true
|
|
2375
|
-
const combinedName = nextDisp()
|
|
2376
|
-
const combinedBody = reactiveBindExprs.join('; ')
|
|
2377
|
-
bindLines.push(`const ${combinedName} = _bind(() => { ${combinedBody} })`)
|
|
2378
|
-
}
|
|
2379
|
-
|
|
2380
|
-
if (bindLines.length === 0 && disposerNames.length === 0) {
|
|
2381
|
-
return `_tpl("${escaped}", () => null)`
|
|
2382
|
-
}
|
|
2383
|
-
|
|
2384
|
-
// Append `;` to every bind line so ASI can't merge consecutive
|
|
2385
|
-
// statements when the next line starts with `(`, `[`, etc.
|
|
2386
|
-
// Concrete bug shape (pre-fix): a child element with `hasDynamic=true`
|
|
2387
|
-
// emits `const __e0 = __root.children[N]` followed by a ref-callback
|
|
2388
|
-
// line `((el) => { x = el })(__e0)`. JS does NOT insert ASI here
|
|
2389
|
-
// because `__root.children[N]((el) => ...)` is a valid expression,
|
|
2390
|
-
// so the parser merges them into a single function call:
|
|
2391
|
-
// `const __e0 = __root.children[N]((el) => ...)(__e0)`
|
|
2392
|
-
// — calling `children[N]` as a function with the arrow as argument,
|
|
2393
|
-
// and self-referencing `__e0` before assignment. Adding the `;`
|
|
2394
|
-
// terminates each statement deterministically. Trailing `;` after
|
|
2395
|
-
// a `{...}` block is a harmless empty statement.
|
|
2396
|
-
// Append `;` to every bind line so ASI can't merge consecutive
|
|
2397
|
-
// statements when the next line starts with `(`, `[`, etc.
|
|
2398
|
-
// Concrete bug shape (pre-fix): a child element with `hasDynamic=true`
|
|
2399
|
-
// emits `const __e0 = __root.children[N]` followed by a ref-callback
|
|
2400
|
-
// line `((el) => { x = el })(__e0)`. JS does NOT insert ASI here
|
|
2401
|
-
// because `__root.children[N]((el) => ...)` is a valid expression,
|
|
2402
|
-
// so the parser merges them into a single function call:
|
|
2403
|
-
// `const __e0 = __root.children[N]((el) => ...)(__e0)`
|
|
2404
|
-
// — calling `children[N]` as a function with the arrow as argument,
|
|
2405
|
-
// and self-referencing `__e0` before assignment. Adding the `;`
|
|
2406
|
-
// terminates each statement deterministically. Trailing `;` after
|
|
2407
|
-
// a `{...}` block is a harmless empty statement.
|
|
2408
|
-
let body = bindLines.map((l) => ` ${l};`).join('\n')
|
|
2409
|
-
if (disposerNames.length > 0) {
|
|
2410
|
-
body += `\n return () => { ${disposerNames.map((d) => `${d}()`).join('; ')} }`
|
|
2411
|
-
} else {
|
|
2412
|
-
body += '\n return null'
|
|
2413
|
-
}
|
|
2414
|
-
|
|
2415
|
-
return `_tpl("${escaped}", (__root) => {\n${body}\n})`
|
|
2416
|
-
}
|
|
2417
|
-
|
|
2418
|
-
function sliceExpr(expr: N): string {
|
|
2419
|
-
let result: string
|
|
2420
|
-
if (propDerivedVars.size > 0 && accessesProps(expr)) {
|
|
2421
|
-
const start = expr.start as number
|
|
2422
|
-
const end = expr.end as number
|
|
2423
|
-
result = resolveIdentifiersInText(code.slice(start, end), start, expr)
|
|
2424
|
-
} else {
|
|
2425
|
-
result = code.slice(expr.start as number, expr.end as number)
|
|
2426
|
-
}
|
|
2427
|
-
|
|
2428
|
-
// Auto-call signal variables: replace bare `x` with `x()` in the expression.
|
|
2429
|
-
// Only applies to identifiers that are NOT already being called (not `x()`).
|
|
2430
|
-
if (signalVars.size > 0 && signalVars.size > shadowedSignals.size && referencesSignalVar(expr)) {
|
|
2431
|
-
result = autoCallSignals(result, expr)
|
|
2432
|
-
}
|
|
2433
|
-
|
|
2434
|
-
return result
|
|
2435
|
-
}
|
|
2436
|
-
|
|
2437
|
-
/** Check if an expression references any tracked signal variable. */
|
|
2438
|
-
function referencesSignalVar(node: N): boolean {
|
|
2439
|
-
if (node.type === 'Identifier' && isActiveSignal(node.name)) {
|
|
2440
|
-
const parent = findParent(node)
|
|
2441
|
-
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return false
|
|
2442
|
-
// signal.X(...) — operating on the signal object (calling a method).
|
|
2443
|
-
// Mirrors the same narrow skip in findSignalIdents below.
|
|
2444
|
-
if (
|
|
2445
|
-
parent &&
|
|
2446
|
-
parent.type === 'MemberExpression' &&
|
|
2447
|
-
parent.object === node
|
|
2448
|
-
) {
|
|
2449
|
-
const grand = findParent(parent)
|
|
2450
|
-
if (grand && grand.type === 'CallExpression' && grand.callee === parent) return false
|
|
2451
|
-
}
|
|
2452
|
-
if (parent && parent.type === 'CallExpression' && parent.callee === node) return false // already called
|
|
2453
|
-
return true
|
|
2454
|
-
}
|
|
2455
|
-
let found = false
|
|
2456
|
-
forEachChildFast(node, (child) => {
|
|
2457
|
-
if (found) return
|
|
2458
|
-
if (child.type === 'ArrowFunctionExpression' || child.type === 'FunctionExpression') return
|
|
2459
|
-
if (referencesSignalVar(child)) found = true
|
|
2460
|
-
})
|
|
2461
|
-
return found
|
|
2462
|
-
}
|
|
2463
|
-
|
|
2464
|
-
/** Auto-insert () after signal variable references in the expression source.
|
|
2465
|
-
* Uses the AST to find exact Identifier positions — never scans raw text. */
|
|
2466
|
-
// Recursively collect identifier names bound by a pattern (params /
|
|
2467
|
-
// declarators). Self-contained twin of resolveIdentifiersInText's
|
|
2468
|
-
// `patternBindingNames` (different closure scope; kept local to avoid a
|
|
2469
|
-
// risky shared-helper hoist).
|
|
2470
|
-
function sigPatternNames(p: N, out: string[]): void {
|
|
2471
|
-
if (!p) return
|
|
2472
|
-
switch (p.type) {
|
|
2473
|
-
case 'Identifier':
|
|
2474
|
-
out.push(p.name)
|
|
2475
|
-
break
|
|
2476
|
-
case 'ObjectPattern':
|
|
2477
|
-
for (const pr of p.properties ?? []) {
|
|
2478
|
-
if (pr.type === 'RestElement') sigPatternNames(pr.argument, out)
|
|
2479
|
-
else sigPatternNames(pr.value ?? pr.key, out)
|
|
2480
|
-
}
|
|
2481
|
-
break
|
|
2482
|
-
case 'ArrayPattern':
|
|
2483
|
-
for (const el of p.elements ?? []) sigPatternNames(el, out)
|
|
2484
|
-
break
|
|
2485
|
-
case 'AssignmentPattern':
|
|
2486
|
-
sigPatternNames(p.left, out)
|
|
2487
|
-
break
|
|
2488
|
-
case 'RestElement':
|
|
2489
|
-
sigPatternNames(p.argument, out)
|
|
2490
|
-
break
|
|
2491
|
-
}
|
|
2492
|
-
}
|
|
2493
|
-
|
|
2494
|
-
// Signal names a scope-introducing node binds FOR ITS OWN SUBTREE
|
|
2495
|
-
// (block-accurate lexical scoping). Mirrors scopeBoundPropDerived but
|
|
2496
|
-
// against `signalVars` — a same-named inner binding (callback param,
|
|
2497
|
-
// nested const, catch/loop var) shadows the signal and must NOT be
|
|
2498
|
-
// auto-called (doing so emits `paramValue()` → runtime TypeError).
|
|
2499
|
-
function scopeBoundSignals(node: N): string[] {
|
|
2500
|
-
const out: string[] = []
|
|
2501
|
-
const t = node.type
|
|
2502
|
-
const declNames = (declNode: N): void => {
|
|
2503
|
-
for (const d of declNode.declarations ?? []) {
|
|
2504
|
-
// A `const x = signal(...)` re-declaration is itself a signal, not a
|
|
2505
|
-
// shadow — leave it for the normal signalVars path.
|
|
2506
|
-
if (d.id?.type === 'Identifier' && d.init && isSignalCall(d.init)) continue
|
|
2507
|
-
sigPatternNames(d.id, out)
|
|
2508
|
-
}
|
|
2509
|
-
}
|
|
2510
|
-
if (
|
|
2511
|
-
t === 'ArrowFunctionExpression' ||
|
|
2512
|
-
t === 'FunctionExpression' ||
|
|
2513
|
-
t === 'FunctionDeclaration'
|
|
2514
|
-
) {
|
|
2515
|
-
for (const p of node.params ?? []) sigPatternNames(p, out)
|
|
2516
|
-
} else if (t === 'CatchClause') {
|
|
2517
|
-
sigPatternNames(node.param, out)
|
|
2518
|
-
} else if (t === 'ForStatement') {
|
|
2519
|
-
if (node.init?.type === 'VariableDeclaration') declNames(node.init)
|
|
2520
|
-
} else if (t === 'ForInStatement' || t === 'ForOfStatement') {
|
|
2521
|
-
if (node.left?.type === 'VariableDeclaration') declNames(node.left)
|
|
2522
|
-
} else if (t === 'BlockStatement' || t === 'StaticBlock') {
|
|
2523
|
-
const stmts = node.body ?? node.statements
|
|
2524
|
-
if (Array.isArray(stmts)) {
|
|
2525
|
-
for (const s of stmts) {
|
|
2526
|
-
if (s.type === 'VariableDeclaration') declNames(s)
|
|
2527
|
-
else if (s.type === 'FunctionDeclaration' && s.id?.type === 'Identifier') out.push(s.id.name)
|
|
2528
|
-
else if (s.type === 'ClassDeclaration' && s.id?.type === 'Identifier') out.push(s.id.name)
|
|
2529
|
-
}
|
|
2530
|
-
}
|
|
2531
|
-
}
|
|
2532
|
-
return out.filter((n) => signalVars.has(n))
|
|
2533
|
-
}
|
|
2534
|
-
|
|
2535
|
-
function autoCallSignals(text: string, expr: N): string {
|
|
2536
|
-
const start = expr.start as number
|
|
2537
|
-
// Collect signal identifier positions that need auto-calling
|
|
2538
|
-
const idents: { start: number; end: number }[] = []
|
|
2539
|
-
// Local lexical shadow set — a signal-named binding introduced INSIDE
|
|
2540
|
-
// the rewritten expression (callback param, nested const, …) is NOT the
|
|
2541
|
-
// signal and must not get `()` (R11: scope-blind rewrite emitted
|
|
2542
|
-
// `({x}) => <li>{x()}</li>` → `1()` runtime crash).
|
|
2543
|
-
const shadowed = new Set<string>()
|
|
2544
|
-
|
|
2545
|
-
function findSignalIdents(node: N): void {
|
|
2546
|
-
if ((node.start as number) >= start + text.length || (node.end as number) <= start) return
|
|
2547
|
-
const introduced: string[] = []
|
|
2548
|
-
for (const n of scopeBoundSignals(node)) {
|
|
2549
|
-
if (!shadowed.has(n)) {
|
|
2550
|
-
shadowed.add(n)
|
|
2551
|
-
introduced.push(n)
|
|
2552
|
-
}
|
|
2553
|
-
}
|
|
2554
|
-
if (node.type === 'Identifier' && isActiveSignal(node.name) && !shadowed.has(node.name)) {
|
|
2555
|
-
const parent = findParent(node)
|
|
2556
|
-
// Skip property name positions (obj.name)
|
|
2557
|
-
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return
|
|
2558
|
-
// Skip when the identifier is the OBJECT of a member access AND
|
|
2559
|
-
// the result is being CALLED (signal.set(...), signal.peek(),
|
|
2560
|
-
// signal.update(...)). The user is invoking a method on the
|
|
2561
|
-
// signal OBJECT — auto-calling would produce `signal().set(...)`
|
|
2562
|
-
// which calls the signal, gets its value (string/number/etc),
|
|
2563
|
-
// then `.set` on the value is undefined → TypeError. Every event
|
|
2564
|
-
// handler that did `signal.set(x)` was silently broken.
|
|
2565
|
-
//
|
|
2566
|
-
// Note: bare `signal.value` (member access NOT followed by call)
|
|
2567
|
-
// STILL auto-calls — keeps the existing convention where
|
|
2568
|
-
// `signal({a:1})` followed by `signal.a` reads the signal's
|
|
2569
|
-
// value's property (see "signal as member expression object IS
|
|
2570
|
-
// auto-called" test).
|
|
2571
|
-
if (
|
|
2572
|
-
parent &&
|
|
2573
|
-
parent.type === 'MemberExpression' &&
|
|
2574
|
-
parent.object === node
|
|
2575
|
-
) {
|
|
2576
|
-
const grand = findParent(parent)
|
|
2577
|
-
if (grand && grand.type === 'CallExpression' && grand.callee === parent) return
|
|
2578
|
-
}
|
|
2579
|
-
// Skip if already being called: signal()
|
|
2580
|
-
if (parent && parent.type === 'CallExpression' && parent.callee === node) return
|
|
2581
|
-
// Skip declaration positions
|
|
2582
|
-
if (parent && parent.type === 'VariableDeclarator' && parent.id === node) return
|
|
2583
|
-
// Skip object property keys and shorthand properties ({ name } or { name: val })
|
|
2584
|
-
// Inserting () after a shorthand key produces name() which is a method shorthand — invalid
|
|
2585
|
-
if (parent && (parent.type === 'Property' || parent.type === 'ObjectProperty')) {
|
|
2586
|
-
if (parent.shorthand) return // { name } — can't auto-call without breaking syntax
|
|
2587
|
-
if (parent.key === node && !parent.computed) return // { name: val } — key position
|
|
2588
|
-
}
|
|
2589
|
-
idents.push({ start: node.start as number, end: node.end as number })
|
|
2590
|
-
}
|
|
2591
|
-
forEachChildFast(node, findSignalIdents)
|
|
2592
|
-
for (const n of introduced) shadowed.delete(n)
|
|
2593
|
-
}
|
|
2594
|
-
findSignalIdents(expr)
|
|
2595
|
-
|
|
2596
|
-
if (idents.length === 0) return text
|
|
2597
|
-
|
|
2598
|
-
// Sort by position and insert () after each identifier
|
|
2599
|
-
idents.sort((a, b) => a.start - b.start)
|
|
2600
|
-
const parts: string[] = []
|
|
2601
|
-
let lastPos = start
|
|
2602
|
-
for (const id of idents) {
|
|
2603
|
-
parts.push(code.slice(lastPos, id.end))
|
|
2604
|
-
parts.push('()') // auto-call
|
|
2605
|
-
lastPos = id.end
|
|
2606
|
-
}
|
|
2607
|
-
parts.push(code.slice(lastPos, start + text.length))
|
|
2608
|
-
return parts.join('')
|
|
2609
|
-
}
|
|
2610
|
-
}
|
|
2611
|
-
|
|
2612
|
-
// ─── Module-scope constants and helpers ─────────────────────────────────────
|
|
2613
|
-
|
|
2614
|
-
const VOID_ELEMENTS = new Set([
|
|
2615
|
-
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
2616
|
-
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
2617
|
-
])
|
|
2618
|
-
|
|
2619
|
-
const JSX_TO_HTML_ATTR: Record<string, string> = {
|
|
2620
|
-
className: 'class',
|
|
2621
|
-
htmlFor: 'for',
|
|
2622
|
-
}
|
|
2623
|
-
|
|
2624
|
-
// DOM properties whose live value diverges from the content attribute.
|
|
2625
|
-
// For these, emit property assignment (`el.value = v`) instead of
|
|
2626
|
-
// `setAttribute("value", v)`. Otherwise the property and attribute drift
|
|
2627
|
-
// apart in user-driven flows: typing in a controlled <input> updates the
|
|
2628
|
-
// .value property, but `input.set('')` clearing the signal only resets
|
|
2629
|
-
// the attribute — the stale typed text stays visible. Same for `checked`
|
|
2630
|
-
// on checkboxes (presence of the attribute means checked regardless of
|
|
2631
|
-
// value: `setAttribute("checked", "false")` still checks the box).
|
|
2632
|
-
const DOM_PROPS = new Set([
|
|
2633
|
-
'value',
|
|
2634
|
-
'checked',
|
|
2635
|
-
'selected',
|
|
2636
|
-
'disabled',
|
|
2637
|
-
'multiple',
|
|
2638
|
-
'readOnly',
|
|
2639
|
-
'indeterminate',
|
|
2640
|
-
])
|
|
2641
|
-
|
|
2642
|
-
const STATEFUL_CALLS = new Set([
|
|
2643
|
-
'signal', 'computed', 'effect', 'batch',
|
|
2644
|
-
'createContext', 'createReactiveContext',
|
|
2645
|
-
'useContext', 'useRef', 'createRef',
|
|
2646
|
-
'useForm', 'useQuery', 'useMutation',
|
|
2647
|
-
'defineStore', 'useStore',
|
|
2648
|
-
])
|
|
2649
|
-
|
|
2650
|
-
function isStatefulCall(node: N): boolean {
|
|
2651
|
-
if (node.type !== 'CallExpression') return false
|
|
2652
|
-
const callee = node.callee
|
|
2653
|
-
if (callee?.type === 'Identifier') return STATEFUL_CALLS.has(callee.name)
|
|
2654
|
-
return false
|
|
2655
|
-
}
|
|
2656
|
-
|
|
2657
|
-
/** Check if a call expression creates a callable reactive value (`signal(...)` or `computed(...)`). */
|
|
2658
|
-
function isSignalCall(node: N): boolean {
|
|
2659
|
-
if (node.type !== 'CallExpression') return false
|
|
2660
|
-
const callee = node.callee
|
|
2661
|
-
return callee?.type === 'Identifier' && (callee.name === 'signal' || callee.name === 'computed')
|
|
2662
|
-
}
|
|
2663
|
-
|
|
2664
|
-
function isChildrenExpression(node: N, expr: string): boolean {
|
|
2665
|
-
if (node.type === 'MemberExpression' && !node.computed && node.property?.type === 'Identifier' && node.property.name === 'children') return true
|
|
2666
|
-
if (node.type === 'Identifier' && node.name === 'children') return true
|
|
2667
|
-
if (expr.endsWith('.children') || expr === 'children') return true
|
|
2668
|
-
return false
|
|
2669
|
-
}
|
|
2670
|
-
|
|
2671
|
-
function isLowerCase(s: string): boolean {
|
|
2672
|
-
return s.length > 0 && s[0] === s[0]?.toLowerCase()
|
|
2673
|
-
}
|
|
2674
|
-
|
|
2675
|
-
function containsJSXInExpr(node: N): boolean {
|
|
2676
|
-
if (node.type === 'JSXElement' || node.type === 'JSXFragment') return true
|
|
2677
|
-
let found = false
|
|
2678
|
-
forEachChild(node, (child) => {
|
|
2679
|
-
if (found) return
|
|
2680
|
-
if (containsJSXInExpr(child)) found = true
|
|
2681
|
-
})
|
|
2682
|
-
return found
|
|
2683
|
-
}
|
|
2684
|
-
|
|
2685
|
-
function escapeHtmlAttr(s: string): string {
|
|
2686
|
-
return s.replace(/&/g, '&').replace(/"/g, '"')
|
|
2687
|
-
}
|
|
2688
|
-
|
|
2689
|
-
function escapeHtmlText(s: string): string {
|
|
2690
|
-
return s.replace(/&(?!(?:#\d+|#x[\da-fA-F]+|[a-zA-Z]\w*);)/g, '&').replace(/</g, '<')
|
|
2691
|
-
}
|
|
2692
|
-
|
|
2693
|
-
// React/Babel JSX whitespace algorithm (cleanJSXElementLiteralChild).
|
|
2694
|
-
// Same-line text is preserved verbatim so adjacent expressions keep their
|
|
2695
|
-
// spacing (`<p>doubled: {x}</p>` keeps the trailing space). Multi-line text
|
|
2696
|
-
// strips leading whitespace from non-first lines and trailing whitespace
|
|
2697
|
-
// from non-last lines, drops fully-empty lines, and joins the survivors
|
|
2698
|
-
// with a single space — collapsing JSX indentation without losing
|
|
2699
|
-
// intentional inline spacing.
|
|
2700
|
-
function cleanJsxText(raw: string): string {
|
|
2701
|
-
if (!raw.includes('\n') && !raw.includes('\r')) return raw
|
|
2702
|
-
const lines = raw.split(/\r\n|\n|\r/)
|
|
2703
|
-
let lastNonEmpty = -1
|
|
2704
|
-
for (let i = 0; i < lines.length; i++) {
|
|
2705
|
-
if (/[^ \t]/.test(lines[i] ?? '')) lastNonEmpty = i
|
|
2706
|
-
}
|
|
2707
|
-
let str = ''
|
|
2708
|
-
for (let i = 0; i < lines.length; i++) {
|
|
2709
|
-
let line = (lines[i] ?? '').replace(/\t/g, ' ')
|
|
2710
|
-
if (i !== 0) line = line.replace(/^ +/, '')
|
|
2711
|
-
if (i !== lines.length - 1) line = line.replace(/ +$/, '')
|
|
2712
|
-
if (line) {
|
|
2713
|
-
if (i !== lastNonEmpty) line += ' '
|
|
2714
|
-
str += line
|
|
2715
|
-
}
|
|
2716
|
-
}
|
|
2717
|
-
return str
|
|
2718
|
-
}
|
|
2719
|
-
|
|
2720
|
-
function isStaticJSXNode(node: N): boolean {
|
|
2721
|
-
if (node.type === 'JSXElement' && node.openingElement?.selfClosing) {
|
|
2722
|
-
return isStaticAttrs(node.openingElement.attributes ?? [])
|
|
2723
|
-
}
|
|
2724
|
-
if (node.type === 'JSXFragment') {
|
|
2725
|
-
return (node.children ?? []).every(isStaticChild)
|
|
2726
|
-
}
|
|
2727
|
-
if (node.type === 'JSXElement') {
|
|
2728
|
-
return isStaticAttrs(node.openingElement?.attributes ?? []) && (node.children ?? []).every(isStaticChild)
|
|
2729
|
-
}
|
|
2730
|
-
return false
|
|
2731
|
-
}
|
|
2732
|
-
|
|
2733
|
-
function isStaticAttrs(attrs: N[]): boolean {
|
|
2734
|
-
return attrs.every((prop: N) => {
|
|
2735
|
-
if (prop.type !== 'JSXAttribute') return false
|
|
2736
|
-
if (!prop.value) return true
|
|
2737
|
-
if (prop.value.type === 'StringLiteral' || (prop.value.type === 'Literal' && typeof prop.value.value === 'string')) return true
|
|
2738
|
-
if (prop.value.type === 'JSXExpressionContainer') {
|
|
2739
|
-
const expr = prop.value.expression
|
|
2740
|
-
if (!expr || expr.type === 'JSXEmptyExpression') return true
|
|
2741
|
-
return isStatic(expr)
|
|
2742
|
-
}
|
|
2743
|
-
return false
|
|
2744
|
-
})
|
|
2745
|
-
}
|
|
2746
|
-
|
|
2747
|
-
function isStaticChild(child: N): boolean {
|
|
2748
|
-
if (child.type === 'JSXText') return true
|
|
2749
|
-
if (child.type === 'JSXElement') return isStaticJSXNode(child)
|
|
2750
|
-
if (child.type === 'JSXFragment') return isStaticJSXNode(child)
|
|
2751
|
-
if (child.type === 'JSXExpressionContainer') {
|
|
2752
|
-
const expr = child.expression
|
|
2753
|
-
if (!expr || expr.type === 'JSXEmptyExpression') return true
|
|
2754
|
-
return isStatic(expr)
|
|
2755
|
-
}
|
|
2756
|
-
return false
|
|
2757
|
-
}
|
|
2758
|
-
|
|
2759
|
-
function isStatic(node: N): boolean {
|
|
2760
|
-
if (node.type === 'Literal') return true
|
|
2761
|
-
if (node.type === 'StringLiteral' || node.type === 'NumericLiteral' || node.type === 'BooleanLiteral' || node.type === 'NullLiteral') return true
|
|
2762
|
-
if (node.type === 'TemplateLiteral' && (node.expressions?.length ?? 0) === 0) return true
|
|
2763
|
-
// Note: `undefined` is an Identifier in ESTree, not a keyword literal.
|
|
2764
|
-
// It is NOT treated as static — it goes through the dynamic attr path.
|
|
2765
|
-
return false
|
|
2766
|
-
}
|
|
2767
|
-
|
|
2768
|
-
const PURE_CALLS = new Set([
|
|
2769
|
-
'Math.max', 'Math.min', 'Math.abs', 'Math.floor', 'Math.ceil', 'Math.round',
|
|
2770
|
-
'Math.pow', 'Math.sqrt', 'Math.random', 'Math.trunc', 'Math.sign',
|
|
2771
|
-
'Number.parseInt', 'Number.parseFloat', 'Number.isNaN', 'Number.isFinite',
|
|
2772
|
-
'parseInt', 'parseFloat', 'isNaN', 'isFinite',
|
|
2773
|
-
'String.fromCharCode', 'String.fromCodePoint',
|
|
2774
|
-
'Object.keys', 'Object.values', 'Object.entries', 'Object.assign',
|
|
2775
|
-
'Object.freeze', 'Object.create',
|
|
2776
|
-
'Array.from', 'Array.isArray', 'Array.of',
|
|
2777
|
-
'JSON.stringify', 'JSON.parse',
|
|
2778
|
-
'encodeURIComponent', 'decodeURIComponent', 'encodeURI', 'decodeURI',
|
|
2779
|
-
'Date.now',
|
|
2780
|
-
])
|
|
2781
|
-
|
|
2782
|
-
function isPureStaticCall(node: N): boolean {
|
|
2783
|
-
const callee = node.callee
|
|
2784
|
-
let name = ''
|
|
2785
|
-
if (callee?.type === 'Identifier') {
|
|
2786
|
-
name = callee.name
|
|
2787
|
-
} else if (callee?.type === 'MemberExpression' && !callee.computed && callee.object?.type === 'Identifier' && callee.property?.type === 'Identifier') {
|
|
2788
|
-
name = `${callee.object.name}.${callee.property.name}`
|
|
2789
|
-
}
|
|
2790
|
-
if (!PURE_CALLS.has(name)) return false
|
|
2791
|
-
return (node.arguments ?? []).every((arg: N) => arg.type !== 'SpreadElement' && isStatic(arg))
|
|
2792
|
-
}
|