@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.
- package/README.md +17 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +1314 -21
- package/lib/types/index.d.ts +167 -2
- package/package.json +15 -5
- package/src/defer-inline.ts +446 -0
- package/src/index.ts +19 -0
- package/src/island-audit.ts +675 -0
- package/src/jsx.ts +68 -33
- package/src/load-native.ts +155 -0
- package/src/pyreon-intercept.ts +127 -1
- package/src/ssg-audit.ts +513 -0
- package/src/tests/defer-inline.test.ts +199 -0
- package/src/tests/detector-tag-consistency.test.ts +28 -15
- package/src/tests/island-audit.test.ts +524 -0
- package/src/tests/jsx.test.ts +23 -3
- package/src/tests/load-native.test.ts +53 -0
- package/src/tests/pyreon-intercept.test.ts +141 -0
- package/src/tests/ssg-audit.test.ts +402 -0
package/lib/types/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
50
|
-
"@pyreon/reactivity": "^0.
|
|
51
|
-
"@pyreon/runtime-dom": "^0.
|
|
52
|
-
"@pyreon/test-utils": "^0.13.
|
|
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
|
+
}
|