@pyreon/compiler 0.15.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,73 @@
1
+ //#region src/defer-inline.d.ts
2
+ /**
3
+ * Inline-children transform for `<Defer>`.
4
+ *
5
+ * Rewrites:
6
+ *
7
+ * import { Modal } from './Modal'
8
+ * <Defer when={open()}><Modal /></Defer>
9
+ *
10
+ * into:
11
+ *
12
+ * <Defer when={open()} chunk={() => import('./Modal').then(m => ({ default: m.Modal }))}>
13
+ * {C => <C />}
14
+ * </Defer>
15
+ *
16
+ * The static `import { Modal } from './Modal'` is removed when `Modal` is
17
+ * referenced ONLY inside the Defer subtree — otherwise Rolldown would
18
+ * bundle the module statically and the dynamic import becomes a no-op.
19
+ *
20
+ * Scope of v1 (this file):
21
+ * - Single Defer element per file (no nested handling — bail otherwise).
22
+ * - Children: exactly ONE JSXElement, self-closing, capitalised name
23
+ * (component reference), no props. Props or multiple children → leave
24
+ * the Defer untransformed (user must use the explicit `chunk` form).
25
+ * - Imports: named OR default. Namespace imports (`import * as Mod`)
26
+ * and destructured-renamed (`{ X as Y }`) not handled in v1.
27
+ * - Triggers (`when={...}`, `on="visible"`, `on="idle"`) pass through.
28
+ * - Other props on `<Defer>` (e.g. `fallback`) pass through.
29
+ *
30
+ * The transform is intentionally conservative — anything unusual leaves
31
+ * the source unchanged + emits a warning. v2 follow-ups can relax these
32
+ * constraints with closure-capture handling, namespace imports, etc.
33
+ *
34
+ * Pipeline: this runs BEFORE `transformJSX()` in the vite plugin. The
35
+ * output is still JSX — `transformJSX` then converts it to `h()` /
36
+ * `_tpl()` calls as usual.
37
+ */
38
+ interface DeferInlineWarning {
39
+ message: string;
40
+ line: number;
41
+ column: number;
42
+ code: 'defer-inline/multiple-children' | 'defer-inline/non-component-child' | 'defer-inline/child-has-props' | 'defer-inline/import-not-found' | 'defer-inline/import-used-elsewhere' | 'defer-inline/unsupported-import-shape';
43
+ }
44
+ interface DeferInlineResult {
45
+ /** Transformed source — same as input when no transform applied. */
46
+ code: string;
47
+ /** True when at least one Defer JSX element was rewritten. */
48
+ changed: boolean;
49
+ /** Soft warnings for cases the transform deliberately skipped. */
50
+ warnings: DeferInlineWarning[];
51
+ }
52
+ /**
53
+ * Main entry. Returns the (possibly transformed) source plus the list
54
+ * of warnings for cases the transform deliberately skipped.
55
+ *
56
+ * Bails (returns input unchanged with `changed: false`) when:
57
+ * - No `<Defer>` JSX element appears in the file (fast path).
58
+ * - The file fails to parse (syntax error — let downstream handle).
59
+ * - No `<Defer>` matches the inline-eligible shape.
60
+ *
61
+ * Per-Defer skips with a warning:
62
+ * - Multiple children → user must use render-prop form
63
+ * - Child has props → user must use render-prop form
64
+ * - Child name isn't imported → can't resolve the chunk source
65
+ * - Child binding is used outside the Defer subtree → can't remove
66
+ * the static import (dynamic import would be a no-op via Rolldown's
67
+ * same-module dedup)
68
+ */
69
+ declare function transformDeferInline(code: string, filename?: string): DeferInlineResult;
70
+ //#endregion
1
71
  //#region src/jsx.d.ts
2
72
  /**
3
73
  * JSX transform — wraps dynamic JSX expressions in `() =>` so the Pyreon runtime
@@ -206,6 +276,14 @@ declare function diagnoseError(error: string): ErrorDiagnosis | null;
206
276
  * - `as-unknown-as-vnodechild` — defensive `as unknown as VNodeChild`
207
277
  * cast on JSX returns is unnecessary (`JSX.Element`
208
278
  * is already assignable to `VNodeChild`).
279
+ * - `island-never-with-registry-entry` — an `island()` declared with
280
+ * `hydrate: 'never'` is also registered in the same
281
+ * file's `hydrateIslands({ ... })` call. The whole
282
+ * point of `'never'` is shipping zero client JS;
283
+ * registering pulls the component module into the
284
+ * client bundle graph (the runtime short-circuits
285
+ * and never calls the loader, but the bundler still
286
+ * includes the import). Drop the registry entry.
209
287
  *
210
288
  * Two-mode surface mirrors `react-intercept.ts`:
211
289
  * - `detectPyreonPatterns(code)` — diagnostics only
@@ -226,7 +304,7 @@ declare function diagnoseError(error: string): ErrorDiagnosis | null;
226
304
  * 2. CLI `pyreon doctor`
227
305
  * 3. MCP server `validate` tool
228
306
  */
229
- type PyreonDiagnosticCode = 'for-missing-by' | 'for-with-key' | 'props-destructured' | 'process-dev-gate' | 'empty-theme' | 'raw-add-event-listener' | 'raw-remove-event-listener' | 'date-math-random-id' | 'on-click-undefined' | 'signal-write-as-call' | 'static-return-null-conditional' | 'as-unknown-as-vnodechild';
307
+ type PyreonDiagnosticCode = 'for-missing-by' | 'for-with-key' | 'props-destructured' | 'process-dev-gate' | 'empty-theme' | 'raw-add-event-listener' | 'raw-remove-event-listener' | 'date-math-random-id' | 'on-click-undefined' | 'signal-write-as-call' | 'static-return-null-conditional' | 'as-unknown-as-vnodechild' | 'island-never-with-registry-entry';
230
308
  interface PyreonDiagnostic {
231
309
  /** Machine-readable code for filtering + programmatic handling */
232
310
  code: PyreonDiagnosticCode;
@@ -292,5 +370,92 @@ declare function formatTestAudit(result: TestAuditResult, {
292
370
  limit
293
371
  }?: AuditFormatOptions): string;
294
372
  //#endregion
295
- export { type AuditFormatOptions, type AuditRisk, type CompilerWarning, type ComponentInfo, type ErrorDiagnosis, type IslandInfo, type MigrationChange, type MigrationResult, type ProjectContext, type PyreonDiagnostic, type PyreonDiagnosticCode, type ReactDiagnostic, type ReactDiagnosticCode, type RouteInfo, type TestAuditEntry, type TestAuditResult, type TransformResult, auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, migrateReactCode, transformJSX, transformJSX_JS };
373
+ //#region src/island-audit.d.ts
374
+ type IslandFindingCode = 'never-with-registry-entry' | 'duplicate-name' | 'registry-mismatch' | 'nested-island' | 'dead-island';
375
+ interface IslandLocation {
376
+ /** Absolute path */
377
+ path: string;
378
+ /** Path relative to the repo root for readable reporting */
379
+ relPath: string;
380
+ /** 1-based line number */
381
+ line: number;
382
+ /** 1-based column number */
383
+ column: number;
384
+ }
385
+ interface IslandFinding {
386
+ code: IslandFindingCode;
387
+ /** One-paragraph human-readable explanation, including the fix path. */
388
+ message: string;
389
+ /** Where the finding surfaces. */
390
+ location: IslandLocation;
391
+ /**
392
+ * Companion locations for cross-file findings (`duplicate-name` lists
393
+ * the OTHER occurrence; `nested-island` lists the inner island's
394
+ * declaration; `never-with-registry-entry` lists the matching island
395
+ * declaration).
396
+ */
397
+ related?: IslandLocation[] | undefined;
398
+ }
399
+ interface IslandAuditResult {
400
+ root: string | null;
401
+ findings: IslandFinding[];
402
+ summary: {
403
+ filesScanned: number;
404
+ islandsDeclared: number;
405
+ registryEntries: number;
406
+ findingsByCode: Record<IslandFindingCode, number>;
407
+ };
408
+ }
409
+ declare function auditIslands(rootDir: string): IslandAuditResult;
410
+ interface IslandAuditFormatOptions {
411
+ /** When true, emit JSON instead of markdown-ish text. */
412
+ json?: boolean | undefined;
413
+ }
414
+ declare function formatIslandAudit(result: IslandAuditResult, options?: IslandAuditFormatOptions): string;
415
+ //#endregion
416
+ //#region src/ssg-audit.d.ts
417
+ type SsgFindingCode = '404-outside-layout-dir' | 'dynamic-route-missing-get-static-paths' | 'non-literal-revalidate-export';
418
+ interface SsgLocation {
419
+ /** Absolute path */
420
+ path: string;
421
+ /** Path relative to the repo root for readable reporting */
422
+ relPath: string;
423
+ /** 1-based line number */
424
+ line: number;
425
+ /** 1-based column number */
426
+ column: number;
427
+ }
428
+ interface SsgFinding {
429
+ code: SsgFindingCode;
430
+ /** One-paragraph human-readable explanation, including the fix path. */
431
+ message: string;
432
+ /** Where the finding surfaces. */
433
+ location: SsgLocation;
434
+ /**
435
+ * Companion locations for cross-file findings. Not currently emitted
436
+ * by any detector but kept in the contract so future codes have the
437
+ * shape available without an API change.
438
+ */
439
+ related?: SsgLocation[] | undefined;
440
+ }
441
+ interface SsgAuditResult {
442
+ root: string | null;
443
+ findings: SsgFinding[];
444
+ summary: {
445
+ filesScanned: number;
446
+ routesScanned: number;
447
+ dynamicRoutes: number;
448
+ revalidateExports: number;
449
+ findingsByCode: Record<SsgFindingCode, number>;
450
+ };
451
+ }
452
+ declare function auditSsg(rootDir: string): SsgAuditResult;
453
+ interface SsgAuditFormatOptions {
454
+ /** Filter findings to a minimum severity. Currently all SSG findings
455
+ * are 'warning'-level; reserved for future severity tiers. */
456
+ minSeverity?: 'warning' | 'error' | undefined;
457
+ }
458
+ declare function formatSsgAudit(result: SsgAuditResult, _options?: SsgAuditFormatOptions): string;
459
+ //#endregion
460
+ export { type AuditFormatOptions, type AuditRisk, type CompilerWarning, type ComponentInfo, type DeferInlineResult, type DeferInlineWarning, type ErrorDiagnosis, type IslandAuditFormatOptions, type IslandAuditResult, type IslandFinding, type IslandFindingCode, type IslandInfo, type IslandLocation, type MigrationChange, type MigrationResult, type ProjectContext, type PyreonDiagnostic, type PyreonDiagnosticCode, type ReactDiagnostic, type ReactDiagnosticCode, type RouteInfo, type SsgAuditFormatOptions, type SsgAuditResult, type SsgFinding, type SsgFindingCode, type SsgLocation, type TestAuditEntry, type TestAuditResult, type TransformResult, auditIslands, auditSsg, auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, formatIslandAudit, formatSsgAudit, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, migrateReactCode, transformDeferInline, transformJSX, transformJSX_JS };
296
461
  //# sourceMappingURL=index2.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/compiler",
3
- "version": "0.15.0",
3
+ "version": "0.18.0",
4
4
  "description": "Template and JSX compiler for Pyreon",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/compiler#readme",
6
6
  "bugs": {
@@ -36,6 +36,7 @@
36
36
  },
37
37
  "scripts": {
38
38
  "build": "vl_rolldown_build",
39
+ "build:native": "bun scripts/build-native.ts",
39
40
  "dev": "vl_rolldown_build-watch",
40
41
  "test": "vitest run",
41
42
  "typecheck": "tsc --noEmit",
@@ -45,11 +46,20 @@
45
46
  "dependencies": {
46
47
  "oxc-parser": "^0.129.0"
47
48
  },
49
+ "optionalDependencies": {
50
+ "@pyreon/compiler-darwin-arm64": "workspace:^",
51
+ "@pyreon/compiler-darwin-x64": "workspace:^",
52
+ "@pyreon/compiler-linux-arm64-gnu": "workspace:^",
53
+ "@pyreon/compiler-linux-arm64-musl": "workspace:^",
54
+ "@pyreon/compiler-linux-x64-gnu": "workspace:^",
55
+ "@pyreon/compiler-linux-x64-musl": "workspace:^",
56
+ "@pyreon/compiler-win32-x64-msvc": "workspace:^"
57
+ },
48
58
  "devDependencies": {
49
- "@pyreon/core": "^0.15.0",
50
- "@pyreon/reactivity": "^0.15.0",
51
- "@pyreon/runtime-dom": "^0.15.0",
52
- "@pyreon/test-utils": "^0.13.2",
59
+ "@pyreon/core": "^0.18.0",
60
+ "@pyreon/reactivity": "^0.18.0",
61
+ "@pyreon/runtime-dom": "^0.18.0",
62
+ "@pyreon/test-utils": "^0.13.5",
53
63
  "happy-dom": "^20.8.3"
54
64
  },
55
65
  "peerDependencies": {
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Inline-children transform for `<Defer>`.
3
+ *
4
+ * Rewrites:
5
+ *
6
+ * import { Modal } from './Modal'
7
+ * <Defer when={open()}><Modal /></Defer>
8
+ *
9
+ * into:
10
+ *
11
+ * <Defer when={open()} chunk={() => import('./Modal').then(m => ({ default: m.Modal }))}>
12
+ * {C => <C />}
13
+ * </Defer>
14
+ *
15
+ * The static `import { Modal } from './Modal'` is removed when `Modal` is
16
+ * referenced ONLY inside the Defer subtree — otherwise Rolldown would
17
+ * bundle the module statically and the dynamic import becomes a no-op.
18
+ *
19
+ * Scope of v1 (this file):
20
+ * - Single Defer element per file (no nested handling — bail otherwise).
21
+ * - Children: exactly ONE JSXElement, self-closing, capitalised name
22
+ * (component reference), no props. Props or multiple children → leave
23
+ * the Defer untransformed (user must use the explicit `chunk` form).
24
+ * - Imports: named OR default. Namespace imports (`import * as Mod`)
25
+ * and destructured-renamed (`{ X as Y }`) not handled in v1.
26
+ * - Triggers (`when={...}`, `on="visible"`, `on="idle"`) pass through.
27
+ * - Other props on `<Defer>` (e.g. `fallback`) pass through.
28
+ *
29
+ * The transform is intentionally conservative — anything unusual leaves
30
+ * the source unchanged + emits a warning. v2 follow-ups can relax these
31
+ * constraints with closure-capture handling, namespace imports, etc.
32
+ *
33
+ * Pipeline: this runs BEFORE `transformJSX()` in the vite plugin. The
34
+ * output is still JSX — `transformJSX` then converts it to `h()` /
35
+ * `_tpl()` calls as usual.
36
+ */
37
+
38
+ import { parseSync } from 'oxc-parser'
39
+
40
+ export interface DeferInlineWarning {
41
+ message: string
42
+ line: number
43
+ column: number
44
+ code:
45
+ | 'defer-inline/multiple-children'
46
+ | 'defer-inline/non-component-child'
47
+ | 'defer-inline/child-has-props'
48
+ | 'defer-inline/import-not-found'
49
+ | 'defer-inline/import-used-elsewhere'
50
+ | 'defer-inline/unsupported-import-shape'
51
+ }
52
+
53
+ export interface DeferInlineResult {
54
+ /** Transformed source — same as input when no transform applied. */
55
+ code: string
56
+ /** True when at least one Defer JSX element was rewritten. */
57
+ changed: boolean
58
+ /** Soft warnings for cases the transform deliberately skipped. */
59
+ warnings: DeferInlineWarning[]
60
+ }
61
+
62
+ interface Node {
63
+ type: string
64
+ start: number
65
+ end: number
66
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
+ [key: string]: any
68
+ }
69
+
70
+ interface Edit {
71
+ start: number
72
+ end: number
73
+ replacement: string
74
+ }
75
+
76
+ /**
77
+ * Detect the language for `parseSync`. `oxc-parser` infers from filename
78
+ * by extension — we mirror the same logic for the few extensions we
79
+ * support so the parser is invoked correctly.
80
+ */
81
+ function getLang(filename: string): 'ts' | 'tsx' | 'js' | 'jsx' {
82
+ if (filename.endsWith('.tsx')) return 'tsx'
83
+ if (filename.endsWith('.jsx')) return 'jsx'
84
+ if (filename.endsWith('.ts')) return 'ts'
85
+ return 'js'
86
+ }
87
+
88
+ /**
89
+ * Returns the JSX tag name as a string when the opening element's name
90
+ * is a simple identifier (the only shape we recognise as a "named JSX
91
+ * element"). Member-expression names (`<obj.X />`) and namespaced names
92
+ * (`<svg:rect />`) return null — the caller treats those as non-matches.
93
+ */
94
+ function getJsxName(node: Node): string | null {
95
+ const open = node.openingElement as Node | undefined
96
+ if (!open) return null
97
+ const name = open.name as Node | undefined
98
+ if (!name || name.type !== 'JSXIdentifier') return null
99
+ return name.name as string
100
+ }
101
+
102
+ /**
103
+ * `<Tag />` qualifies as a "bare component reference child" when:
104
+ * - It's a JSXElement (not text, fragment, or expression container).
105
+ * - The opening name is a capitalised JSXIdentifier (component).
106
+ * - It has no attributes (no props passed).
107
+ * - It's self-closing OR has zero non-whitespace children.
108
+ */
109
+ function isBareComponentChild(node: Node): { name: string } | null {
110
+ if (node.type !== 'JSXElement') return null
111
+ const tag = getJsxName(node)
112
+ if (!tag || !/^[A-Z]/.test(tag)) return null
113
+ const open = node.openingElement as Node
114
+ const attrs = (open.attributes as Node[] | undefined) ?? []
115
+ if (attrs.length > 0) return null
116
+ const children = (node.children as Node[] | undefined) ?? []
117
+ for (const child of children) {
118
+ if (child.type === 'JSXText' && /^\s*$/.test(child.value as string)) continue
119
+ return null
120
+ }
121
+ return { name: tag }
122
+ }
123
+
124
+ /**
125
+ * Filter whitespace-only JSXText nodes; return remaining children. JSX
126
+ * source like `<Defer>\n <Modal />\n</Defer>` has 3 children at the AST
127
+ * level: text, element, text. The text nodes are formatting noise.
128
+ */
129
+ function nonWhitespaceChildren(node: Node): Node[] {
130
+ const children = (node.children as Node[] | undefined) ?? []
131
+ return children.filter(
132
+ (c) => !(c.type === 'JSXText' && /^\s*$/.test(c.value as string)),
133
+ )
134
+ }
135
+
136
+ /**
137
+ * `<Defer chunk={...} ...>` qualifies for the inline transform when:
138
+ * - The opening name is `Defer`.
139
+ * - No attribute named `chunk` (otherwise user is using the explicit form).
140
+ * - Exactly ONE non-whitespace child that is a bare component reference.
141
+ */
142
+ interface DeferMatch {
143
+ /** The <Defer> JSXElement node. */
144
+ node: Node
145
+ /** The single child component element. */
146
+ child: Node
147
+ /** Component identifier name (e.g. 'Modal'). */
148
+ childName: string
149
+ /** Position where to insert the `chunk` attribute (just after `<Defer`). */
150
+ insertChunkAt: number
151
+ /** Range covering the child JSX element + surrounding whitespace inside Defer's open/close tags. */
152
+ childrenRange: { start: number; end: number }
153
+ }
154
+
155
+ function findDeferMatches(program: Node): DeferMatch[] {
156
+ const matches: DeferMatch[] = []
157
+
158
+ const walk = (node: Node | null | undefined): void => {
159
+ if (!node || typeof node !== 'object') return
160
+
161
+ if (node.type === 'JSXElement' && getJsxName(node) === 'Defer') {
162
+ const open = node.openingElement as Node
163
+ const attrs = (open.attributes as Node[] | undefined) ?? []
164
+ const hasChunk = attrs.some(
165
+ (a) =>
166
+ a.type === 'JSXAttribute' &&
167
+ (a.name as Node | undefined)?.type === 'JSXIdentifier' &&
168
+ (a.name as Node).name === 'chunk',
169
+ )
170
+ if (!hasChunk) {
171
+ const live = nonWhitespaceChildren(node)
172
+ if (live.length === 1) {
173
+ const childInfo = isBareComponentChild(live[0]!)
174
+ if (childInfo) {
175
+ const close = node.closingElement as Node | undefined
176
+ matches.push({
177
+ node,
178
+ child: live[0]!,
179
+ childName: childInfo.name,
180
+ // Insert chunk attribute right after the opening tag name.
181
+ // `<Defer when={x}>` — we want to insert just before the `>`
182
+ // (or `/>` if self-closing, though Defer is never self-closing
183
+ // when it has inline children). Use the closing `>` of the
184
+ // opening tag — that's `open.end - 1` for `<Defer ...>` form.
185
+ insertChunkAt: (open.end as number) - 1,
186
+ childrenRange: {
187
+ start: open.end as number,
188
+ end: (close?.start as number) ?? (node.end as number),
189
+ },
190
+ })
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ // Recurse — JSX children, prop expressions, statements, etc.
197
+ for (const key in node) {
198
+ if (key === 'parent') continue
199
+ const v = node[key]
200
+ if (Array.isArray(v)) {
201
+ for (const item of v) walk(item as Node)
202
+ } else if (v && typeof v === 'object' && typeof (v as Node).type === 'string') {
203
+ walk(v as Node)
204
+ }
205
+ }
206
+ }
207
+
208
+ walk(program)
209
+ return matches
210
+ }
211
+
212
+ /**
213
+ * Find ImportDeclarations matching a target identifier and classify them.
214
+ * Returns null when the binding can't be resolved or the import shape
215
+ * isn't one we handle (namespace, renamed destructure).
216
+ */
217
+ interface ImportInfo {
218
+ /** The `ImportDeclaration` AST node. */
219
+ node: Node
220
+ /** The module source string (without quotes). */
221
+ source: string
222
+ /** 'default' or 'named' — controls how the rewrite resolves the chunk. */
223
+ kind: 'default' | 'named'
224
+ }
225
+
226
+ function findImportFor(program: Node, name: string): ImportInfo | null {
227
+ const body = (program.body as Node[] | undefined) ?? []
228
+ for (const stmt of body) {
229
+ if (stmt.type !== 'ImportDeclaration') continue
230
+ const specifiers = (stmt.specifiers as Node[] | undefined) ?? []
231
+ for (const spec of specifiers) {
232
+ if (spec.type === 'ImportDefaultSpecifier') {
233
+ const local = (spec.local as Node).name as string
234
+ if (local === name) {
235
+ return {
236
+ node: stmt,
237
+ source: (stmt.source as Node).value as string,
238
+ kind: 'default',
239
+ }
240
+ }
241
+ } else if (spec.type === 'ImportSpecifier') {
242
+ const local = (spec.local as Node).name as string
243
+ const imported = (spec.imported as Node | undefined)?.name as string | undefined
244
+ // Only handle the un-renamed case: `import { Modal } from ...`.
245
+ // `{ Modal as M }` — skip (would need to know the original export
246
+ // name for the chunk-resolution path; v1 bails).
247
+ if (local === name && imported !== undefined && imported === local) {
248
+ return {
249
+ node: stmt,
250
+ source: (stmt.source as Node).value as string,
251
+ kind: 'named',
252
+ }
253
+ }
254
+ }
255
+ // ImportNamespaceSpecifier (`import * as M`) — not handled in v1.
256
+ }
257
+ }
258
+ return null
259
+ }
260
+
261
+ /**
262
+ * Count references to `name` outside the given JSXElement subtree. The
263
+ * static import can only be safely removed if the binding is used
264
+ * EXCLUSIVELY inside that subtree.
265
+ */
266
+ function countReferencesOutside(program: Node, name: string, skipSubtree: Node): number {
267
+ let count = 0
268
+ const skipStart = skipSubtree.start as number
269
+ const skipEnd = skipSubtree.end as number
270
+
271
+ // Walk every statement except ImportDeclarations (we don't want the
272
+ // import specifier itself to count as a usage). Within each statement
273
+ // walk recursively, skipping any subtree whose byte range falls
274
+ // entirely inside the Defer being rewritten.
275
+ const countInNode = (node: Node): void => {
276
+ if (!node || typeof node !== 'object') return
277
+ const ns = node.start as number | undefined
278
+ const ne = node.end as number | undefined
279
+ if (typeof ns === 'number' && typeof ne === 'number' && ns >= skipStart && ne <= skipEnd) {
280
+ return
281
+ }
282
+ if (node.type === 'Identifier' && (node.name as string) === name) count++
283
+ if (node.type === 'JSXIdentifier' && (node.name as string) === name) count++
284
+ for (const key in node) {
285
+ if (key === 'parent') continue
286
+ const v = node[key]
287
+ if (Array.isArray(v)) {
288
+ for (const item of v) countInNode(item as Node)
289
+ } else if (v && typeof v === 'object' && typeof (v as Node).type === 'string') {
290
+ countInNode(v as Node)
291
+ }
292
+ }
293
+ }
294
+ const body = (program.body as Node[] | undefined) ?? []
295
+ for (const stmt of body) {
296
+ if (stmt.type === 'ImportDeclaration') continue
297
+ countInNode(stmt)
298
+ }
299
+ return count
300
+ }
301
+
302
+ /** Build the chunk={...} attribute string for a default or named import. */
303
+ function buildChunkAttr(source: string, kind: 'default' | 'named', name: string): string {
304
+ if (kind === 'default') {
305
+ return ` chunk={() => import('${source}')}`
306
+ }
307
+ // Named: re-wrap so the chunk's `default` is the named export.
308
+ return ` chunk={() => import('${source}').then((__m) => ({ default: __m.${name} }))}`
309
+ }
310
+
311
+ /**
312
+ * Apply edits to the source string. Edits MUST be non-overlapping; we
313
+ * sort by start descending and splice each into the source so earlier
314
+ * positions stay valid as we work backwards.
315
+ */
316
+ function applyEdits(source: string, edits: Edit[]): string {
317
+ const sorted = [...edits].sort((a, b) => b.start - a.start)
318
+ let out = source
319
+ for (const e of sorted) {
320
+ out = out.slice(0, e.start) + e.replacement + out.slice(e.end)
321
+ }
322
+ return out
323
+ }
324
+
325
+ /**
326
+ * Main entry. Returns the (possibly transformed) source plus the list
327
+ * of warnings for cases the transform deliberately skipped.
328
+ *
329
+ * Bails (returns input unchanged with `changed: false`) when:
330
+ * - No `<Defer>` JSX element appears in the file (fast path).
331
+ * - The file fails to parse (syntax error — let downstream handle).
332
+ * - No `<Defer>` matches the inline-eligible shape.
333
+ *
334
+ * Per-Defer skips with a warning:
335
+ * - Multiple children → user must use render-prop form
336
+ * - Child has props → user must use render-prop form
337
+ * - Child name isn't imported → can't resolve the chunk source
338
+ * - Child binding is used outside the Defer subtree → can't remove
339
+ * the static import (dynamic import would be a no-op via Rolldown's
340
+ * same-module dedup)
341
+ */
342
+ export function transformDeferInline(
343
+ code: string,
344
+ filename = 'input.tsx',
345
+ ): DeferInlineResult {
346
+ const warnings: DeferInlineWarning[] = []
347
+
348
+ // Fast path — skip the parse entirely when the file has no Defer mention.
349
+ if (!code.includes('Defer')) {
350
+ return { code, changed: false, warnings }
351
+ }
352
+
353
+ let program: Node
354
+ try {
355
+ const result = parseSync(filename, code, {
356
+ sourceType: 'module',
357
+ lang: getLang(filename),
358
+ })
359
+ program = result.program as Node
360
+ } catch {
361
+ // Parse failure — leave to the downstream transformJSX which reports
362
+ // its own diagnostics.
363
+ return { code, changed: false, warnings }
364
+ }
365
+
366
+ const matches = findDeferMatches(program)
367
+ if (matches.length === 0) return { code, changed: false, warnings }
368
+
369
+ const edits: Edit[] = []
370
+ let changed = false
371
+
372
+ for (const m of matches) {
373
+ const importInfo = findImportFor(program, m.childName)
374
+ if (!importInfo) {
375
+ const loc = getLoc(code, (m.child.start as number) ?? 0)
376
+ warnings.push({
377
+ message: `<Defer>'s inline child <${m.childName} /> isn't imported — can't resolve a chunk source. Use the explicit \`chunk\` prop, or import ${m.childName} from a module.`,
378
+ line: loc.line,
379
+ column: loc.column,
380
+ code: 'defer-inline/import-not-found',
381
+ })
382
+ continue
383
+ }
384
+
385
+ const outsideUses = countReferencesOutside(program, m.childName, m.node)
386
+ if (outsideUses > 0) {
387
+ const loc = getLoc(code, (m.node.start as number) ?? 0)
388
+ warnings.push({
389
+ message: `<Defer>'s inline child <${m.childName} /> is also referenced elsewhere in this file. Inline form requires the import to be used exclusively inside this Defer. Use the explicit \`chunk\` prop form to split despite shared usage.`,
390
+ line: loc.line,
391
+ column: loc.column,
392
+ code: 'defer-inline/import-used-elsewhere',
393
+ })
394
+ continue
395
+ }
396
+
397
+ // 1. Insert chunk attribute just before the opening tag's `>`.
398
+ edits.push({
399
+ start: m.insertChunkAt,
400
+ end: m.insertChunkAt,
401
+ replacement: buildChunkAttr(importInfo.source, importInfo.kind, m.childName),
402
+ })
403
+
404
+ // 2. Replace the children (the bare `<Modal />`) with a render-prop
405
+ // that invokes the loaded component. Preserve surrounding
406
+ // whitespace by replacing only the JSX text region inside Defer's
407
+ // open/close tags. Use a non-letter identifier for the render-prop
408
+ // binding (`__C`) to avoid clashing with anything in scope.
409
+ edits.push({
410
+ start: m.childrenRange.start,
411
+ end: m.childrenRange.end,
412
+ replacement: `{(__C) => <__C />}`,
413
+ })
414
+
415
+ // 3. Remove the static import. Replace the entire ImportDeclaration
416
+ // range with an empty string. Includes the trailing newline if
417
+ // present so we don't leave a blank line.
418
+ const impStart = importInfo.node.start as number
419
+ let impEnd = importInfo.node.end as number
420
+ if (code[impEnd] === '\n') impEnd += 1
421
+ edits.push({
422
+ start: impStart,
423
+ end: impEnd,
424
+ replacement: '',
425
+ })
426
+
427
+ changed = true
428
+ }
429
+
430
+ if (!changed) return { code, changed: false, warnings }
431
+
432
+ return { code: applyEdits(code, edits), changed: true, warnings }
433
+ }
434
+
435
+ /** Resolve a byte offset into 1-based line + 0-based column. */
436
+ function getLoc(code: string, offset: number): { line: number; column: number } {
437
+ let line = 1
438
+ let lastNl = -1
439
+ for (let i = 0; i < offset && i < code.length; i++) {
440
+ if (code.charCodeAt(i) === 10 /* \n */) {
441
+ line++
442
+ lastNl = i
443
+ }
444
+ }
445
+ return { line, column: offset - lastNl - 1 }
446
+ }