@pyreon/vite-plugin 0.15.0 → 0.16.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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +160 -1
- package/lib/types/index.d.ts +26 -0
- package/package.json +2 -2
- package/src/index.ts +293 -9
- package/src/tests/islands-registry.test.ts +236 -0
- package/src/tests/vite-plugin.test.ts +81 -0
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src/index.ts","uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src/index.ts","uid":"8d2ce63a-1"}]}],"isRoot":true},"nodeParts":{"8d2ce63a-1":{"renderedLength":28850,"gzipLength":9498,"brotliLength":0,"metaUid":"8d2ce63a-0"}},"nodeMetas":{"8d2ce63a-0":{"id":"/src/index.ts","moduleParts":{"index.js":"8d2ce63a-1"},"imported":[{"uid":"8d2ce63a-2"},{"uid":"8d2ce63a-3"},{"uid":"8d2ce63a-4"}],"importedBy":[],"isEntry":true},"8d2ce63a-2":{"id":"node:fs","moduleParts":{},"imported":[],"importedBy":[{"uid":"8d2ce63a-0"}]},"8d2ce63a-3":{"id":"node:path","moduleParts":{},"imported":[],"importedBy":[{"uid":"8d2ce63a-0"}]},"8d2ce63a-4":{"id":"@pyreon/compiler","moduleParts":{},"imported":[],"importedBy":[{"uid":"8d2ce63a-0"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
|
|
5390
5390
|
|
|
5391
5391
|
const run = () => {
|
|
5392
5392
|
const width = window.innerWidth;
|
package/lib/index.js
CHANGED
|
@@ -38,6 +38,8 @@ import { generateContext, transformJSX } from "@pyreon/compiler";
|
|
|
38
38
|
*/
|
|
39
39
|
const HMR_RUNTIME_ID = "\0pyreon/hmr-runtime";
|
|
40
40
|
const HMR_RUNTIME_IMPORT = "virtual:pyreon/hmr-runtime";
|
|
41
|
+
const ISLANDS_REGISTRY_ID = "\0pyreon/islands-registry";
|
|
42
|
+
const ISLANDS_REGISTRY_IMPORT = "virtual:pyreon/islands-registry";
|
|
41
43
|
const COMPAT_ALIASES = {
|
|
42
44
|
react: {
|
|
43
45
|
react: "@pyreon/react-compat",
|
|
@@ -132,23 +134,53 @@ function getCompatTarget(compat, id) {
|
|
|
132
134
|
if (compat === "solid") return "@pyreon/solid-compat/jsx-runtime";
|
|
133
135
|
}
|
|
134
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* Scan the consumer's package.json for `@pyreon/*` deps. Result is the
|
|
139
|
+
* list of names to exclude from Vite's deps optimizer (avoids
|
|
140
|
+
* `.vite/deps/@pyreon_*.js: File does not exist` runtime errors caused
|
|
141
|
+
* by esbuild trying to pre-bundle TypeScript source files exposed via
|
|
142
|
+
* the `bun` resolve condition).
|
|
143
|
+
*
|
|
144
|
+
* Reads dependencies + devDependencies + peerDependencies. Best-effort:
|
|
145
|
+
* missing/malformed package.json returns an empty list so a typo in
|
|
146
|
+
* the consumer's manifest doesn't break the build.
|
|
147
|
+
*/
|
|
148
|
+
function scanPyreonDeps(root) {
|
|
149
|
+
const pkgPath = join(root, "package.json");
|
|
150
|
+
if (!existsSync(pkgPath)) return [];
|
|
151
|
+
try {
|
|
152
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
153
|
+
const all = {
|
|
154
|
+
...pkg.dependencies,
|
|
155
|
+
...pkg.devDependencies,
|
|
156
|
+
...pkg.peerDependencies
|
|
157
|
+
};
|
|
158
|
+
return Object.keys(all).filter((name) => name.startsWith("@pyreon/"));
|
|
159
|
+
} catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
135
163
|
function pyreonPlugin(options) {
|
|
136
164
|
const ssrConfig = options?.ssr;
|
|
137
165
|
const compat = options?.compat;
|
|
166
|
+
const islandsEnabled = options?.islands !== false;
|
|
138
167
|
let isBuild = false;
|
|
139
168
|
let projectRoot = "";
|
|
140
169
|
const signalExportRegistry = /* @__PURE__ */ new Map();
|
|
141
170
|
const resolveCache = /* @__PURE__ */ new Map();
|
|
142
171
|
const pyreonWorkspaceDirCache = /* @__PURE__ */ new Map();
|
|
172
|
+
const islandRegistry = /* @__PURE__ */ new Map();
|
|
143
173
|
return {
|
|
144
174
|
name: "pyreon",
|
|
145
175
|
enforce: "pre",
|
|
146
176
|
config(userConfig, env) {
|
|
147
177
|
isBuild = env.command === "build";
|
|
148
178
|
projectRoot = userConfig.root ?? process.cwd();
|
|
179
|
+
const compatExclude = compat ? Object.keys(COMPAT_ALIASES[compat]) : [];
|
|
180
|
+
const pyreonExclude = scanPyreonDeps(projectRoot);
|
|
149
181
|
return {
|
|
150
182
|
resolve: { conditions: ["bun"] },
|
|
151
|
-
optimizeDeps: { exclude:
|
|
183
|
+
optimizeDeps: { exclude: Array.from(new Set([...compatExclude, ...pyreonExclude])) },
|
|
152
184
|
oxc: { jsx: {
|
|
153
185
|
runtime: "automatic",
|
|
154
186
|
importSource: "@pyreon/core"
|
|
@@ -161,9 +193,11 @@ function pyreonPlugin(options) {
|
|
|
161
193
|
},
|
|
162
194
|
async buildStart() {
|
|
163
195
|
await prescanSignalExports(projectRoot, signalExportRegistry);
|
|
196
|
+
if (islandsEnabled) await prescanIslandDeclarations(projectRoot, islandRegistry);
|
|
164
197
|
},
|
|
165
198
|
async resolveId(id, importer) {
|
|
166
199
|
if (id === HMR_RUNTIME_IMPORT) return HMR_RUNTIME_ID;
|
|
200
|
+
if (id === ISLANDS_REGISTRY_IMPORT) return ISLANDS_REGISTRY_ID;
|
|
167
201
|
if (compat && (id === "@pyreon/core/jsx-runtime" || id === "@pyreon/core/jsx-dev-runtime") && importer && isPyreonWorkspaceFile(importer, pyreonWorkspaceDirCache)) return;
|
|
168
202
|
const target = getCompatTarget(compat, id);
|
|
169
203
|
if (!target) return;
|
|
@@ -171,6 +205,7 @@ function pyreonPlugin(options) {
|
|
|
171
205
|
},
|
|
172
206
|
load(id) {
|
|
173
207
|
if (id === HMR_RUNTIME_ID) return HMR_RUNTIME_SOURCE;
|
|
208
|
+
if (id === ISLANDS_REGISTRY_ID) return renderIslandsRegistry(islandRegistry, islandsEnabled);
|
|
174
209
|
},
|
|
175
210
|
async transform(code, id, transformOptions) {
|
|
176
211
|
const ext = getExt(id);
|
|
@@ -186,6 +221,7 @@ function pyreonPlugin(options) {
|
|
|
186
221
|
return;
|
|
187
222
|
}
|
|
188
223
|
scanSignalExports(code, normalizeModuleId(id), signalExportRegistry);
|
|
224
|
+
if (islandsEnabled) scanIslandDeclarations(code, id, islandRegistry);
|
|
189
225
|
const knownSignals = await resolveImportedSignals(code, id, signalExportRegistry, this, resolveCache);
|
|
190
226
|
const result = transformJSX(code, id, {
|
|
191
227
|
ssr: transformOptions?.ssr === true,
|
|
@@ -442,6 +478,129 @@ function normalizeModuleId(id) {
|
|
|
442
478
|
return queryIndex >= 0 ? id.slice(0, queryIndex) : id;
|
|
443
479
|
}
|
|
444
480
|
/**
|
|
481
|
+
* Pre-scan all source files in the project for `island()` declarations.
|
|
482
|
+
*
|
|
483
|
+
* Called from `buildStart` (when `islands: true`) so the registry is fully
|
|
484
|
+
* populated before any transforms run. Mirrors `prescanSignalExports` shape;
|
|
485
|
+
* the per-file regex pattern matches:
|
|
486
|
+
*
|
|
487
|
+
* island(() => import('PATH'), { name: 'NAME', hydrate: 'STRATEGY' })
|
|
488
|
+
*
|
|
489
|
+
* Edge cases the regex deliberately doesn't cover (user falls back to manual
|
|
490
|
+
* `hydrateIslands({ ... })`):
|
|
491
|
+
* - Loader is a variable, not an inline arrow: `island(myLoader, { name })`
|
|
492
|
+
* - Name is a variable: `island(() => import('./X'), { name: NAME_CONST })`
|
|
493
|
+
* - Options come from a spread: `island(loader, { ...opts })`
|
|
494
|
+
*/
|
|
495
|
+
async function prescanIslandDeclarations(root, registry) {
|
|
496
|
+
const files = [];
|
|
497
|
+
function walk(dir) {
|
|
498
|
+
try {
|
|
499
|
+
for (const entry of readdirSync(dir)) {
|
|
500
|
+
if (entry.startsWith(".") || entry === "node_modules" || entry === "dist" || entry === "lib" || entry === "build") continue;
|
|
501
|
+
const full = join(dir, entry);
|
|
502
|
+
try {
|
|
503
|
+
if (statSync(full).isDirectory()) walk(full);
|
|
504
|
+
else if (/\.(ts|tsx|js|jsx)$/.test(entry)) files.push(full);
|
|
505
|
+
} catch {}
|
|
506
|
+
}
|
|
507
|
+
} catch {}
|
|
508
|
+
}
|
|
509
|
+
walk(root);
|
|
510
|
+
for (const file of files) try {
|
|
511
|
+
scanIslandDeclarations(readFileSync(file, "utf-8"), file, registry);
|
|
512
|
+
} catch {}
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Scan a single source file for `island()` declarations and record them.
|
|
516
|
+
*
|
|
517
|
+
* The regex captures:
|
|
518
|
+
* - Group 1: dynamic-import path (`./components/Counter`)
|
|
519
|
+
* - Group 2: options block contents
|
|
520
|
+
*
|
|
521
|
+
* Then a follow-up regex pulls `name: 'X'` and `hydrate: 'Y'` from the
|
|
522
|
+
* options block. Single-line and multi-line forms both work.
|
|
523
|
+
*
|
|
524
|
+
* Resolves the loader path relative to the file where the call lives so
|
|
525
|
+
* the emitted virtual-module registry gets an absolute path Vite's resolver
|
|
526
|
+
* can find.
|
|
527
|
+
*/
|
|
528
|
+
function scanIslandDeclarations(code, filePath, registry) {
|
|
529
|
+
const ISLAND_CALL_RE = /island\s*\(\s*\(\s*\)\s*=>\s*import\s*\(\s*['"]([^'"]+)['"]\s*\)\s*,\s*\{([\s\S]*?)\}\s*\)/g;
|
|
530
|
+
const decls = [];
|
|
531
|
+
let match;
|
|
532
|
+
while ((match = ISLAND_CALL_RE.exec(code)) !== null) {
|
|
533
|
+
const importPath = match[1];
|
|
534
|
+
const optsBlock = match[2];
|
|
535
|
+
const nameMatch = /(?:^|[\s,{])name\s*:\s*['"]([^'"]+)['"]/.exec(optsBlock);
|
|
536
|
+
if (!nameMatch) continue;
|
|
537
|
+
const hydrateMatch = /(?:^|[\s,{])hydrate\s*:\s*['"]([^'"]+)['"]/.exec(optsBlock);
|
|
538
|
+
const hydrate = hydrateMatch ? hydrateMatch[1] : "load";
|
|
539
|
+
const loaderAbsPath = importPath.startsWith(".") ? resolveRelative(filePath, importPath) : importPath;
|
|
540
|
+
decls.push({
|
|
541
|
+
name: nameMatch[1],
|
|
542
|
+
hydrate,
|
|
543
|
+
loaderAbsPath
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
if (decls.length > 0) registry.set(normalizeModuleId(filePath), decls);
|
|
547
|
+
else registry.delete(normalizeModuleId(filePath));
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Resolve a dynamic-import specifier to an absolute path, mirroring how Node
|
|
551
|
+
* / Vite resolve `import('./X')` from the source file's directory.
|
|
552
|
+
*/
|
|
553
|
+
function resolveRelative(fromFile, relPath) {
|
|
554
|
+
return join(dirname(fromFile), relPath);
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Render the auto-generated `virtual:pyreon/islands-registry` source. Emits:
|
|
558
|
+
*
|
|
559
|
+
* export const __pyreonIslandRegistry = {
|
|
560
|
+
* Counter: () => import('/abs/path/to/components/Counter'),
|
|
561
|
+
* IdleClock: () => import('/abs/path/to/components/IdleClock'),
|
|
562
|
+
* // never-strategy islands deliberately omitted
|
|
563
|
+
* }
|
|
564
|
+
*
|
|
565
|
+
* `hydrate: 'never'` islands are skipped — registering a loader for them
|
|
566
|
+
* would defeat the strategy by pulling the component module into the
|
|
567
|
+
* client bundle graph. `hydrateIslandsAuto()` short-circuits never-islands
|
|
568
|
+
* at runtime regardless; emitting here would still create the dynamic-
|
|
569
|
+
* import chunk.
|
|
570
|
+
*
|
|
571
|
+
* Duplicate `name` across declarations: the LAST one wins. Documented as
|
|
572
|
+
* an anti-pattern (caught by the planned `pyreon doctor --check-islands`).
|
|
573
|
+
*/
|
|
574
|
+
function renderIslandsRegistry(registry, enabled) {
|
|
575
|
+
if (!enabled) return [
|
|
576
|
+
`// pyreon plugin: islands feature is disabled (pyreon({ islands: false })).`,
|
|
577
|
+
`// hydrateIslandsAuto() will throw at runtime — re-enable via vite.config.ts`,
|
|
578
|
+
`// or use manual hydrateIslands({ ... }) instead.`,
|
|
579
|
+
`export const __pyreonIslandRegistry = {};`,
|
|
580
|
+
`export const __pyreonIslandsEnabled = false;`
|
|
581
|
+
].join("\n");
|
|
582
|
+
const entries = [];
|
|
583
|
+
const seen = /* @__PURE__ */ new Set();
|
|
584
|
+
const all = Array.from(registry.values()).flat();
|
|
585
|
+
all.sort((a, b) => a.name.localeCompare(b.name));
|
|
586
|
+
for (const { name, hydrate, loaderAbsPath } of all) {
|
|
587
|
+
if (hydrate === "never") continue;
|
|
588
|
+
if (seen.has(name)) continue;
|
|
589
|
+
seen.add(name);
|
|
590
|
+
entries.push(` ${JSON.stringify(name)}: () => import(${JSON.stringify(loaderAbsPath)}),`);
|
|
591
|
+
}
|
|
592
|
+
return [
|
|
593
|
+
`// Auto-generated by @pyreon/vite-plugin (islands: true). Do not edit.`,
|
|
594
|
+
`// Sourced from island() declarations in your project. Never-strategy`,
|
|
595
|
+
`// islands are intentionally omitted — registering a loader for them`,
|
|
596
|
+
`// would defeat the zero-JS contract.`,
|
|
597
|
+
`export const __pyreonIslandRegistry = {`,
|
|
598
|
+
...entries,
|
|
599
|
+
`};`,
|
|
600
|
+
`export const __pyreonIslandsEnabled = true;`
|
|
601
|
+
].join("\n");
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
445
604
|
* Pre-scan all source files in the project for signal exports.
|
|
446
605
|
*
|
|
447
606
|
* Called from `buildStart` so the registry is fully populated before any
|
package/lib/types/index.d.ts
CHANGED
|
@@ -29,6 +29,32 @@ interface PyreonPluginOptions {
|
|
|
29
29
|
ssr?: {
|
|
30
30
|
/** Server entry file path (e.g. "./src/entry-server.ts") */entry: string;
|
|
31
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* Auto-discover `island()` declarations and expose them as
|
|
34
|
+
* `virtual:pyreon/islands-registry` for `hydrateIslandsAuto()` in
|
|
35
|
+
* `@pyreon/server/client`.
|
|
36
|
+
*
|
|
37
|
+
* Eliminates the manual sync between `island()` declarations and the
|
|
38
|
+
* client-side `hydrateIslands({ ... })` registry — typo / forgotten entry /
|
|
39
|
+
* registry drift is the #1 author foot-gun for islands.
|
|
40
|
+
*
|
|
41
|
+
* Defaults to `true`. The prescan is cheap (regex over the same files
|
|
42
|
+
* already walked by `prescanSignalExports`); set to `false` only if you
|
|
43
|
+
* have a reason not to support `hydrateIslandsAuto()`.
|
|
44
|
+
*
|
|
45
|
+
* `hydrate: 'never'` islands are deliberately OMITTED from the auto-
|
|
46
|
+
* registry — the whole point of the strategy is shipping zero client JS,
|
|
47
|
+
* so registering a loader (which would pull the component module into the
|
|
48
|
+
* client bundle graph) defeats it.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* pyreon({ islands: true })
|
|
52
|
+
*
|
|
53
|
+
* // src/entry-client.ts
|
|
54
|
+
* import { hydrateIslandsAuto } from '@pyreon/server/client'
|
|
55
|
+
* hydrateIslandsAuto()
|
|
56
|
+
*/
|
|
57
|
+
islands?: boolean;
|
|
32
58
|
}
|
|
33
59
|
declare function pyreonPlugin(options?: PyreonPluginOptions): Plugin;
|
|
34
60
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/vite-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Vite plugin for Pyreon — .pyreon SFC support, HMR, compiler integration",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/vite-plugin#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"prepublishOnly": "bun run build"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@pyreon/compiler": "^0.
|
|
46
|
+
"@pyreon/compiler": "^0.16.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"vite": "^8.0.0"
|
package/src/index.ts
CHANGED
|
@@ -41,6 +41,12 @@ import type { Plugin, ViteDevServer } from 'vite'
|
|
|
41
41
|
const HMR_RUNTIME_ID = '\0pyreon/hmr-runtime'
|
|
42
42
|
const HMR_RUNTIME_IMPORT = 'virtual:pyreon/hmr-runtime'
|
|
43
43
|
|
|
44
|
+
// Virtual module ID for the auto-generated islands registry. See
|
|
45
|
+
// `prescanIslandDeclarations` + the `load` hook for emit shape. Consumed by
|
|
46
|
+
// `hydrateIslandsAuto()` in `@pyreon/server/client`.
|
|
47
|
+
const ISLANDS_REGISTRY_ID = '\0pyreon/islands-registry'
|
|
48
|
+
const ISLANDS_REGISTRY_IMPORT = 'virtual:pyreon/islands-registry'
|
|
49
|
+
|
|
44
50
|
export type CompatFramework = 'react' | 'preact' | 'vue' | 'solid'
|
|
45
51
|
|
|
46
52
|
export interface PyreonPluginOptions {
|
|
@@ -72,15 +78,33 @@ export interface PyreonPluginOptions {
|
|
|
72
78
|
/** Server entry file path (e.g. "./src/entry-server.ts") */
|
|
73
79
|
entry: string
|
|
74
80
|
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// ── Compat JSX import sources ─────────────────────────────────────────────────
|
|
78
81
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Auto-discover `island()` declarations and expose them as
|
|
84
|
+
* `virtual:pyreon/islands-registry` for `hydrateIslandsAuto()` in
|
|
85
|
+
* `@pyreon/server/client`.
|
|
86
|
+
*
|
|
87
|
+
* Eliminates the manual sync between `island()` declarations and the
|
|
88
|
+
* client-side `hydrateIslands({ ... })` registry — typo / forgotten entry /
|
|
89
|
+
* registry drift is the #1 author foot-gun for islands.
|
|
90
|
+
*
|
|
91
|
+
* Defaults to `true`. The prescan is cheap (regex over the same files
|
|
92
|
+
* already walked by `prescanSignalExports`); set to `false` only if you
|
|
93
|
+
* have a reason not to support `hydrateIslandsAuto()`.
|
|
94
|
+
*
|
|
95
|
+
* `hydrate: 'never'` islands are deliberately OMITTED from the auto-
|
|
96
|
+
* registry — the whole point of the strategy is shipping zero client JS,
|
|
97
|
+
* so registering a loader (which would pull the component module into the
|
|
98
|
+
* client bundle graph) defeats it.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* pyreon({ islands: true })
|
|
102
|
+
*
|
|
103
|
+
* // src/entry-client.ts
|
|
104
|
+
* import { hydrateIslandsAuto } from '@pyreon/server/client'
|
|
105
|
+
* hydrateIslandsAuto()
|
|
106
|
+
*/
|
|
107
|
+
islands?: boolean
|
|
84
108
|
}
|
|
85
109
|
|
|
86
110
|
// ── Compat alias maps ─────────────────────────────────────────────────────────
|
|
@@ -198,9 +222,44 @@ function getCompatTarget(compat: CompatFramework | undefined, id: string): strin
|
|
|
198
222
|
return undefined
|
|
199
223
|
}
|
|
200
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Scan the consumer's package.json for `@pyreon/*` deps. Result is the
|
|
227
|
+
* list of names to exclude from Vite's deps optimizer (avoids
|
|
228
|
+
* `.vite/deps/@pyreon_*.js: File does not exist` runtime errors caused
|
|
229
|
+
* by esbuild trying to pre-bundle TypeScript source files exposed via
|
|
230
|
+
* the `bun` resolve condition).
|
|
231
|
+
*
|
|
232
|
+
* Reads dependencies + devDependencies + peerDependencies. Best-effort:
|
|
233
|
+
* missing/malformed package.json returns an empty list so a typo in
|
|
234
|
+
* the consumer's manifest doesn't break the build.
|
|
235
|
+
*/
|
|
236
|
+
function scanPyreonDeps(root: string): string[] {
|
|
237
|
+
const pkgPath = pathJoin(root, 'package.json')
|
|
238
|
+
if (!existsSync(pkgPath)) return []
|
|
239
|
+
try {
|
|
240
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as {
|
|
241
|
+
dependencies?: Record<string, string>
|
|
242
|
+
devDependencies?: Record<string, string>
|
|
243
|
+
peerDependencies?: Record<string, string>
|
|
244
|
+
}
|
|
245
|
+
const all = {
|
|
246
|
+
...pkg.dependencies,
|
|
247
|
+
...pkg.devDependencies,
|
|
248
|
+
...pkg.peerDependencies,
|
|
249
|
+
}
|
|
250
|
+
return Object.keys(all).filter((name) => name.startsWith('@pyreon/'))
|
|
251
|
+
} catch {
|
|
252
|
+
return []
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
201
256
|
export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
202
257
|
const ssrConfig = options?.ssr
|
|
203
258
|
const compat = options?.compat
|
|
259
|
+
// Default islands support to enabled — the prescan is cheap and the virtual
|
|
260
|
+
// module is harmless if the user has no `island()` calls. Opt out only if
|
|
261
|
+
// you have a specific reason.
|
|
262
|
+
const islandsEnabled = options?.islands !== false
|
|
204
263
|
let isBuild = false
|
|
205
264
|
let projectRoot = ''
|
|
206
265
|
|
|
@@ -215,6 +274,13 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
215
274
|
// happen at most once per containing directory across the build.
|
|
216
275
|
const pyreonWorkspaceDirCache = new Map<string, boolean>()
|
|
217
276
|
|
|
277
|
+
// ── Island declaration registry ─────────────────────────────────────────
|
|
278
|
+
// Tracks every `island(() => import('PATH'), { name: 'X', hydrate: 'Y' })`
|
|
279
|
+
// call across the source tree. Keyed by absolute source-file path of the
|
|
280
|
+
// declaration site so HMR can invalidate per-file. Each entry's loader path
|
|
281
|
+
// is resolved relative to the file where the call was written.
|
|
282
|
+
const islandRegistry = new Map<string, IslandDecl[]>()
|
|
283
|
+
|
|
218
284
|
return {
|
|
219
285
|
name: 'pyreon',
|
|
220
286
|
enforce: 'pre',
|
|
@@ -226,7 +292,23 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
226
292
|
|
|
227
293
|
// Tell Vite's dep scanner not to pre-bundle the aliased framework imports —
|
|
228
294
|
// they resolve to workspace packages via our resolveId hook, not node_modules.
|
|
229
|
-
const
|
|
295
|
+
const compatExclude = compat ? Object.keys(COMPAT_ALIASES[compat]) : []
|
|
296
|
+
// Auto-detect `@pyreon/*` deps in the consumer's package.json and add
|
|
297
|
+
// them to optimizeDeps.exclude. Vite's deps optimizer pre-bundles
|
|
298
|
+
// node_modules deps via esbuild, but the plugin's `bun` resolve
|
|
299
|
+
// condition redirects every `@pyreon/*` import to source `.ts(x)`
|
|
300
|
+
// files. Esbuild's pre-bundler can't process raw TS source from a
|
|
301
|
+
// published package and silently produces broken bundles in
|
|
302
|
+
// `.vite/deps/`, surfacing as `File does not exist at
|
|
303
|
+
// .../node_modules/.vite/deps/@pyreon_styler.js` errors at runtime.
|
|
304
|
+
// Excluding them sidesteps the optimizer entirely — they're resolved
|
|
305
|
+
// on demand via the plugin's resolveId hook + Vite's normal source
|
|
306
|
+
// pipeline. Workspace-linked apps in this monorepo aren't affected
|
|
307
|
+
// because Vite never tries to pre-bundle workspace deps.
|
|
308
|
+
const pyreonExclude = scanPyreonDeps(projectRoot)
|
|
309
|
+
const optimizeDepsExclude = Array.from(
|
|
310
|
+
new Set([...compatExclude, ...pyreonExclude]),
|
|
311
|
+
)
|
|
230
312
|
|
|
231
313
|
// Always set OXC's JSX importSource to `@pyreon/core`. In compat mode,
|
|
232
314
|
// we redirect `@pyreon/core/jsx-runtime` imports to the compat package
|
|
@@ -271,11 +353,21 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
271
353
|
// ordering problem where component.tsx is transformed before
|
|
272
354
|
// store.ts — without pre-scanning, the registry would be empty.
|
|
273
355
|
await prescanSignalExports(projectRoot, signalExportRegistry)
|
|
356
|
+
|
|
357
|
+
// Mirror prescan for `island()` declarations. The result populates
|
|
358
|
+
// `virtual:pyreon/islands-registry`, consumed by `hydrateIslandsAuto()`
|
|
359
|
+
// in `@pyreon/server/client`. Eliminates the manual sync between
|
|
360
|
+
// `island()` source-of-truth and the client `hydrateIslands({ ... })`
|
|
361
|
+
// call — the #1 author foot-gun for islands.
|
|
362
|
+
if (islandsEnabled) {
|
|
363
|
+
await prescanIslandDeclarations(projectRoot, islandRegistry)
|
|
364
|
+
}
|
|
274
365
|
},
|
|
275
366
|
|
|
276
367
|
// ── Virtual module + compat alias resolution ─────────────────────────────
|
|
277
368
|
async resolveId(id, importer) {
|
|
278
369
|
if (id === HMR_RUNTIME_IMPORT) return HMR_RUNTIME_ID
|
|
370
|
+
if (id === ISLANDS_REGISTRY_IMPORT) return ISLANDS_REGISTRY_ID
|
|
279
371
|
|
|
280
372
|
// `@pyreon/core/jsx-runtime` resolves to the compat package only for
|
|
281
373
|
// user code — never for `@pyreon/*` framework files (zero, router,
|
|
@@ -305,6 +397,9 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
305
397
|
if (id === HMR_RUNTIME_ID) {
|
|
306
398
|
return HMR_RUNTIME_SOURCE
|
|
307
399
|
}
|
|
400
|
+
if (id === ISLANDS_REGISTRY_ID) {
|
|
401
|
+
return renderIslandsRegistry(islandRegistry, islandsEnabled)
|
|
402
|
+
}
|
|
308
403
|
},
|
|
309
404
|
|
|
310
405
|
async transform(code, id, transformOptions) {
|
|
@@ -328,6 +423,12 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
328
423
|
// files created/modified after buildStart (dev mode HMR).
|
|
329
424
|
scanSignalExports(code, normalizeModuleId(id), signalExportRegistry)
|
|
330
425
|
|
|
426
|
+
// ── Same incremental update for island() declarations ──────────────
|
|
427
|
+
// HMR: when a user adds/renames/removes an island() call, the
|
|
428
|
+
// virtual:pyreon/islands-registry module needs to reflect it on the
|
|
429
|
+
// next dev-server module reload.
|
|
430
|
+
if (islandsEnabled) scanIslandDeclarations(code, id, islandRegistry)
|
|
431
|
+
|
|
331
432
|
// ── Resolve imported signals from the registry ─────────────────────
|
|
332
433
|
// Check each import in this file: if the imported module has signal
|
|
333
434
|
// exports in the registry, pass them as knownSignals to the compiler.
|
|
@@ -694,6 +795,189 @@ function normalizeModuleId(id: string): string {
|
|
|
694
795
|
return queryIndex >= 0 ? id.slice(0, queryIndex) : id
|
|
695
796
|
}
|
|
696
797
|
|
|
798
|
+
// ─── Island declaration scanner ────────────────────────────────────────────
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* One island() call site discovered in source.
|
|
802
|
+
*
|
|
803
|
+
* `loaderAbsPath` is the dynamic-import target resolved relative to the
|
|
804
|
+
* source file where the call was written. Vite's resolver finds the actual
|
|
805
|
+
* file (.tsx / .jsx / .ts / .js extension auto-added) when the registry
|
|
806
|
+
* module emits `() => import('<loaderAbsPath>')`.
|
|
807
|
+
*/
|
|
808
|
+
interface IslandDecl {
|
|
809
|
+
name: string
|
|
810
|
+
hydrate: string
|
|
811
|
+
loaderAbsPath: string
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Pre-scan all source files in the project for `island()` declarations.
|
|
816
|
+
*
|
|
817
|
+
* Called from `buildStart` (when `islands: true`) so the registry is fully
|
|
818
|
+
* populated before any transforms run. Mirrors `prescanSignalExports` shape;
|
|
819
|
+
* the per-file regex pattern matches:
|
|
820
|
+
*
|
|
821
|
+
* island(() => import('PATH'), { name: 'NAME', hydrate: 'STRATEGY' })
|
|
822
|
+
*
|
|
823
|
+
* Edge cases the regex deliberately doesn't cover (user falls back to manual
|
|
824
|
+
* `hydrateIslands({ ... })`):
|
|
825
|
+
* - Loader is a variable, not an inline arrow: `island(myLoader, { name })`
|
|
826
|
+
* - Name is a variable: `island(() => import('./X'), { name: NAME_CONST })`
|
|
827
|
+
* - Options come from a spread: `island(loader, { ...opts })`
|
|
828
|
+
*/
|
|
829
|
+
async function prescanIslandDeclarations(
|
|
830
|
+
root: string,
|
|
831
|
+
registry: Map<string, IslandDecl[]>,
|
|
832
|
+
): Promise<void> {
|
|
833
|
+
const files: string[] = []
|
|
834
|
+
|
|
835
|
+
function walk(dir: string) {
|
|
836
|
+
try {
|
|
837
|
+
for (const entry of readdirSync(dir)) {
|
|
838
|
+
if (
|
|
839
|
+
entry.startsWith('.') ||
|
|
840
|
+
entry === 'node_modules' ||
|
|
841
|
+
entry === 'dist' ||
|
|
842
|
+
entry === 'lib' ||
|
|
843
|
+
entry === 'build'
|
|
844
|
+
)
|
|
845
|
+
continue
|
|
846
|
+
const full = pathJoin(dir, entry)
|
|
847
|
+
try {
|
|
848
|
+
const stat = statSync(full)
|
|
849
|
+
if (stat.isDirectory()) walk(full)
|
|
850
|
+
else if (/\.(ts|tsx|js|jsx)$/.test(entry)) files.push(full)
|
|
851
|
+
} catch {
|
|
852
|
+
/* permission error, etc. */
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
} catch {
|
|
856
|
+
/* dir doesn't exist */
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
walk(root)
|
|
861
|
+
|
|
862
|
+
for (const file of files) {
|
|
863
|
+
try {
|
|
864
|
+
const code = readFileSync(file, 'utf-8')
|
|
865
|
+
scanIslandDeclarations(code, file, registry)
|
|
866
|
+
} catch {
|
|
867
|
+
/* read error */
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Scan a single source file for `island()` declarations and record them.
|
|
874
|
+
*
|
|
875
|
+
* The regex captures:
|
|
876
|
+
* - Group 1: dynamic-import path (`./components/Counter`)
|
|
877
|
+
* - Group 2: options block contents
|
|
878
|
+
*
|
|
879
|
+
* Then a follow-up regex pulls `name: 'X'` and `hydrate: 'Y'` from the
|
|
880
|
+
* options block. Single-line and multi-line forms both work.
|
|
881
|
+
*
|
|
882
|
+
* Resolves the loader path relative to the file where the call lives so
|
|
883
|
+
* the emitted virtual-module registry gets an absolute path Vite's resolver
|
|
884
|
+
* can find.
|
|
885
|
+
*/
|
|
886
|
+
function scanIslandDeclarations(
|
|
887
|
+
code: string,
|
|
888
|
+
filePath: string,
|
|
889
|
+
registry: Map<string, IslandDecl[]>,
|
|
890
|
+
): void {
|
|
891
|
+
// `[\s\S]` lets the options block span multiple lines. The lazy `?` after
|
|
892
|
+
// the options block prevents over-matching when several `island()` calls
|
|
893
|
+
// appear in the same file.
|
|
894
|
+
const ISLAND_CALL_RE =
|
|
895
|
+
/island\s*\(\s*\(\s*\)\s*=>\s*import\s*\(\s*['"]([^'"]+)['"]\s*\)\s*,\s*\{([\s\S]*?)\}\s*\)/g
|
|
896
|
+
const decls: IslandDecl[] = []
|
|
897
|
+
let match: RegExpExecArray | null
|
|
898
|
+
while ((match = ISLAND_CALL_RE.exec(code)) !== null) {
|
|
899
|
+
const importPath = match[1]!
|
|
900
|
+
const optsBlock = match[2]!
|
|
901
|
+
const nameMatch = /(?:^|[\s,{])name\s*:\s*['"]([^'"]+)['"]/.exec(optsBlock)
|
|
902
|
+
if (!nameMatch) continue // can't auto-register without a name
|
|
903
|
+
const hydrateMatch = /(?:^|[\s,{])hydrate\s*:\s*['"]([^'"]+)['"]/.exec(optsBlock)
|
|
904
|
+
const hydrate = hydrateMatch ? hydrateMatch[1]! : 'load'
|
|
905
|
+
const loaderAbsPath = importPath.startsWith('.')
|
|
906
|
+
? resolveRelative(filePath, importPath)
|
|
907
|
+
: importPath
|
|
908
|
+
decls.push({ name: nameMatch[1]!, hydrate, loaderAbsPath })
|
|
909
|
+
}
|
|
910
|
+
if (decls.length > 0) {
|
|
911
|
+
registry.set(normalizeModuleId(filePath), decls)
|
|
912
|
+
} else {
|
|
913
|
+
// Clean up if file no longer declares islands (e.g. after edit)
|
|
914
|
+
registry.delete(normalizeModuleId(filePath))
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Resolve a dynamic-import specifier to an absolute path, mirroring how Node
|
|
920
|
+
* / Vite resolve `import('./X')` from the source file's directory.
|
|
921
|
+
*/
|
|
922
|
+
function resolveRelative(fromFile: string, relPath: string): string {
|
|
923
|
+
return pathJoin(dirname(fromFile), relPath)
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Render the auto-generated `virtual:pyreon/islands-registry` source. Emits:
|
|
928
|
+
*
|
|
929
|
+
* export const __pyreonIslandRegistry = {
|
|
930
|
+
* Counter: () => import('/abs/path/to/components/Counter'),
|
|
931
|
+
* IdleClock: () => import('/abs/path/to/components/IdleClock'),
|
|
932
|
+
* // never-strategy islands deliberately omitted
|
|
933
|
+
* }
|
|
934
|
+
*
|
|
935
|
+
* `hydrate: 'never'` islands are skipped — registering a loader for them
|
|
936
|
+
* would defeat the strategy by pulling the component module into the
|
|
937
|
+
* client bundle graph. `hydrateIslandsAuto()` short-circuits never-islands
|
|
938
|
+
* at runtime regardless; emitting here would still create the dynamic-
|
|
939
|
+
* import chunk.
|
|
940
|
+
*
|
|
941
|
+
* Duplicate `name` across declarations: the LAST one wins. Documented as
|
|
942
|
+
* an anti-pattern (caught by the planned `pyreon doctor --check-islands`).
|
|
943
|
+
*/
|
|
944
|
+
function renderIslandsRegistry(
|
|
945
|
+
registry: Map<string, IslandDecl[]>,
|
|
946
|
+
enabled: boolean,
|
|
947
|
+
): string {
|
|
948
|
+
if (!enabled) {
|
|
949
|
+
return [
|
|
950
|
+
`// pyreon plugin: islands feature is disabled (pyreon({ islands: false })).`,
|
|
951
|
+
`// hydrateIslandsAuto() will throw at runtime — re-enable via vite.config.ts`,
|
|
952
|
+
`// or use manual hydrateIslands({ ... }) instead.`,
|
|
953
|
+
`export const __pyreonIslandRegistry = {};`,
|
|
954
|
+
`export const __pyreonIslandsEnabled = false;`,
|
|
955
|
+
].join('\n')
|
|
956
|
+
}
|
|
957
|
+
const entries: string[] = []
|
|
958
|
+
const seen = new Set<string>()
|
|
959
|
+
// Deterministic order: sort by name for stable output / predictable HMR.
|
|
960
|
+
const all = Array.from(registry.values()).flat()
|
|
961
|
+
all.sort((a, b) => a.name.localeCompare(b.name))
|
|
962
|
+
for (const { name, hydrate, loaderAbsPath } of all) {
|
|
963
|
+
if (hydrate === 'never') continue
|
|
964
|
+
if (seen.has(name)) continue
|
|
965
|
+
seen.add(name)
|
|
966
|
+
// JSON.stringify gives proper escaping for both name (object key) and path.
|
|
967
|
+
entries.push(` ${JSON.stringify(name)}: () => import(${JSON.stringify(loaderAbsPath)}),`)
|
|
968
|
+
}
|
|
969
|
+
return [
|
|
970
|
+
`// Auto-generated by @pyreon/vite-plugin (islands: true). Do not edit.`,
|
|
971
|
+
`// Sourced from island() declarations in your project. Never-strategy`,
|
|
972
|
+
`// islands are intentionally omitted — registering a loader for them`,
|
|
973
|
+
`// would defeat the zero-JS contract.`,
|
|
974
|
+
`export const __pyreonIslandRegistry = {`,
|
|
975
|
+
...entries,
|
|
976
|
+
`};`,
|
|
977
|
+
`export const __pyreonIslandsEnabled = true;`,
|
|
978
|
+
].join('\n')
|
|
979
|
+
}
|
|
980
|
+
|
|
697
981
|
/**
|
|
698
982
|
* Pre-scan all source files in the project for signal exports.
|
|
699
983
|
*
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-discovered island-registry tests for @pyreon/vite-plugin.
|
|
3
|
+
*
|
|
4
|
+
* Exercises the `pyreon({ islands: true })` path:
|
|
5
|
+
* 1. Materialize synthetic source files containing `island()` calls
|
|
6
|
+
* 2. Drive plugin.config() + plugin.buildStart() to populate the registry
|
|
7
|
+
* 3. Drive plugin.load('\0pyreon/islands-registry') + assert the emitted
|
|
8
|
+
* source contains the expected loader entries (and excludes
|
|
9
|
+
* hydrate: 'never' islands)
|
|
10
|
+
*
|
|
11
|
+
* Companion to `cross-module-signals.test.ts` — same harness shape.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { tmpdir } from 'node:os'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
|
18
|
+
import pyreonPlugin, { type PyreonPluginOptions } from '../index'
|
|
19
|
+
|
|
20
|
+
type ConfigHook = (
|
|
21
|
+
userConfig: Record<string, unknown>,
|
|
22
|
+
env: { command: string; isSsrBuild?: boolean },
|
|
23
|
+
) => Record<string, unknown>
|
|
24
|
+
|
|
25
|
+
type BuildStartHook = (this: unknown) => Promise<void>
|
|
26
|
+
type LoadHook = (id: string) => string | undefined
|
|
27
|
+
type ResolveIdHook = (
|
|
28
|
+
this: unknown,
|
|
29
|
+
id: string,
|
|
30
|
+
importer?: string,
|
|
31
|
+
) => Promise<string | null | undefined>
|
|
32
|
+
|
|
33
|
+
let root: string
|
|
34
|
+
|
|
35
|
+
beforeAll(() => {
|
|
36
|
+
root = mkdtempSync(join(tmpdir(), 'pyreon-islands-registry-'))
|
|
37
|
+
})
|
|
38
|
+
afterAll(() => {
|
|
39
|
+
rmSync(root, { recursive: true, force: true })
|
|
40
|
+
})
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
rmSync(root, { recursive: true, force: true })
|
|
43
|
+
mkdirSync(root, { recursive: true })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
function writeFile(rel: string, contents: string): string {
|
|
47
|
+
const full = join(root, rel)
|
|
48
|
+
const dir = full.slice(0, full.lastIndexOf('/'))
|
|
49
|
+
mkdirSync(dir, { recursive: true })
|
|
50
|
+
writeFileSync(full, contents)
|
|
51
|
+
return full
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function bootstrap(opts?: PyreonPluginOptions) {
|
|
55
|
+
const plugin = pyreonPlugin(opts)
|
|
56
|
+
;(plugin.config as unknown as ConfigHook)({ root }, { command: 'build' })
|
|
57
|
+
return plugin
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function runBuildStart(plugin: ReturnType<typeof pyreonPlugin>) {
|
|
61
|
+
const buildStart = plugin.buildStart as BuildStartHook
|
|
62
|
+
await buildStart.call({})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function runLoad(plugin: ReturnType<typeof pyreonPlugin>, id: string): string {
|
|
66
|
+
const result = (plugin.load as LoadHook)(id)
|
|
67
|
+
if (typeof result !== 'string') {
|
|
68
|
+
throw new Error(`load('${id}') returned ${typeof result}, expected string`)
|
|
69
|
+
}
|
|
70
|
+
return result
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function runResolveId(
|
|
74
|
+
plugin: ReturnType<typeof pyreonPlugin>,
|
|
75
|
+
id: string,
|
|
76
|
+
): Promise<string | null | undefined> {
|
|
77
|
+
return (plugin.resolveId as ResolveIdHook).call({}, id)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ISLANDS_REGISTRY_IMPORT = 'virtual:pyreon/islands-registry'
|
|
81
|
+
const ISLANDS_REGISTRY_ID = '\0pyreon/islands-registry'
|
|
82
|
+
|
|
83
|
+
describe('vite-plugin — islands virtual module', () => {
|
|
84
|
+
it('resolveId redirects virtual:pyreon/islands-registry to the \\0-prefixed id', async () => {
|
|
85
|
+
const plugin = bootstrap()
|
|
86
|
+
expect(await runResolveId(plugin, ISLANDS_REGISTRY_IMPORT)).toBe(ISLANDS_REGISTRY_ID)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('emits an empty registry when no island() calls exist', async () => {
|
|
90
|
+
writeFile('src/App.tsx', `export const App = () => null`)
|
|
91
|
+
const plugin = bootstrap()
|
|
92
|
+
await runBuildStart(plugin)
|
|
93
|
+
const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
|
|
94
|
+
expect(source).toContain('__pyreonIslandsEnabled = true')
|
|
95
|
+
expect(source).toContain('__pyreonIslandRegistry = {')
|
|
96
|
+
// No entries beyond the opening and closing braces
|
|
97
|
+
expect(source).not.toMatch(/import\(.+\)/)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('discovers `island(() => import("./X"), { name, hydrate: "load" })` calls', async () => {
|
|
101
|
+
writeFile(
|
|
102
|
+
'src/islands.ts',
|
|
103
|
+
`import { island } from '@pyreon/server'
|
|
104
|
+
export const Counter = island(() => import('./components/Counter'), {
|
|
105
|
+
name: 'Counter',
|
|
106
|
+
hydrate: 'load',
|
|
107
|
+
})`,
|
|
108
|
+
)
|
|
109
|
+
const plugin = bootstrap()
|
|
110
|
+
await runBuildStart(plugin)
|
|
111
|
+
const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
|
|
112
|
+
expect(source).toContain('"Counter":')
|
|
113
|
+
// Loader path was resolved relative to the file where the call lives
|
|
114
|
+
expect(source).toContain(`/components/Counter`)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('omits hydrate: "never" islands from the registry', async () => {
|
|
118
|
+
writeFile(
|
|
119
|
+
'src/islands.ts',
|
|
120
|
+
`import { island } from '@pyreon/server'
|
|
121
|
+
export const Counter = island(() => import('./Counter'), { name: 'Counter', hydrate: 'load' })
|
|
122
|
+
export const StaticBadge = island(() => import('./StaticBadge'), { name: 'StaticBadge', hydrate: 'never' })`,
|
|
123
|
+
)
|
|
124
|
+
const plugin = bootstrap()
|
|
125
|
+
await runBuildStart(plugin)
|
|
126
|
+
const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
|
|
127
|
+
expect(source).toContain('"Counter":')
|
|
128
|
+
expect(source).not.toContain('"StaticBadge":')
|
|
129
|
+
expect(source).not.toContain('StaticBadge')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('handles `media(...)` and `interaction` strategy strings without omitting them', async () => {
|
|
133
|
+
writeFile(
|
|
134
|
+
'src/islands.ts',
|
|
135
|
+
`import { island } from '@pyreon/server'
|
|
136
|
+
export const Mobile = island(() => import('./Mobile'), { name: 'Mobile', hydrate: 'media((max-width: 768px))' })
|
|
137
|
+
export const Idle = island(() => import('./Idle'), { name: 'Idle', hydrate: 'idle' })
|
|
138
|
+
export const Visible = island(() => import('./Visible'), { name: 'Visible', hydrate: 'visible' })`,
|
|
139
|
+
)
|
|
140
|
+
const plugin = bootstrap()
|
|
141
|
+
await runBuildStart(plugin)
|
|
142
|
+
const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
|
|
143
|
+
expect(source).toContain('"Mobile":')
|
|
144
|
+
expect(source).toContain('"Idle":')
|
|
145
|
+
expect(source).toContain('"Visible":')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('discovers island() calls across multiple source files', async () => {
|
|
149
|
+
writeFile(
|
|
150
|
+
'src/foo/A.ts',
|
|
151
|
+
`import { island } from '@pyreon/server'
|
|
152
|
+
export const A = island(() => import('./component'), { name: 'A', hydrate: 'load' })`,
|
|
153
|
+
)
|
|
154
|
+
writeFile(
|
|
155
|
+
'src/bar/B.ts',
|
|
156
|
+
`import { island } from '@pyreon/server'
|
|
157
|
+
export const B = island(() => import('./component'), { name: 'B', hydrate: 'idle' })`,
|
|
158
|
+
)
|
|
159
|
+
const plugin = bootstrap()
|
|
160
|
+
await runBuildStart(plugin)
|
|
161
|
+
const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
|
|
162
|
+
expect(source).toContain('"A":')
|
|
163
|
+
expect(source).toContain('"B":')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('skips node_modules / dist / lib / build during the prescan walk', async () => {
|
|
167
|
+
writeFile(
|
|
168
|
+
'node_modules/some-pkg/island.ts',
|
|
169
|
+
`island(() => import('./X'), { name: 'IgnoreMe', hydrate: 'load' })`,
|
|
170
|
+
)
|
|
171
|
+
writeFile(
|
|
172
|
+
'dist/build-output.ts',
|
|
173
|
+
`island(() => import('./X'), { name: 'AlsoIgnoreMe', hydrate: 'load' })`,
|
|
174
|
+
)
|
|
175
|
+
writeFile(
|
|
176
|
+
'src/Real.ts',
|
|
177
|
+
`import { island } from '@pyreon/server'
|
|
178
|
+
export const Real = island(() => import('./X'), { name: 'Real', hydrate: 'load' })`,
|
|
179
|
+
)
|
|
180
|
+
const plugin = bootstrap()
|
|
181
|
+
await runBuildStart(plugin)
|
|
182
|
+
const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
|
|
183
|
+
expect(source).toContain('"Real":')
|
|
184
|
+
expect(source).not.toContain('"IgnoreMe":')
|
|
185
|
+
expect(source).not.toContain('"AlsoIgnoreMe":')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('emits a stub registry when islands: false is set', async () => {
|
|
189
|
+
writeFile(
|
|
190
|
+
'src/islands.ts',
|
|
191
|
+
`import { island } from '@pyreon/server'
|
|
192
|
+
export const Counter = island(() => import('./Counter'), { name: 'Counter', hydrate: 'load' })`,
|
|
193
|
+
)
|
|
194
|
+
const plugin = bootstrap({ islands: false })
|
|
195
|
+
await runBuildStart(plugin)
|
|
196
|
+
const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
|
|
197
|
+
// Stub flips the enabled flag so hydrateIslandsAuto() throws at runtime
|
|
198
|
+
// with a clear message — better than a silent empty registry.
|
|
199
|
+
expect(source).toContain('__pyreonIslandsEnabled = false')
|
|
200
|
+
expect(source).not.toContain('"Counter":')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('deduplicates duplicate names (last-wins order)', async () => {
|
|
204
|
+
writeFile(
|
|
205
|
+
'src/a.ts',
|
|
206
|
+
`import { island } from '@pyreon/server'
|
|
207
|
+
export const A = island(() => import('./a-comp'), { name: 'Same', hydrate: 'load' })`,
|
|
208
|
+
)
|
|
209
|
+
writeFile(
|
|
210
|
+
'src/b.ts',
|
|
211
|
+
`import { island } from '@pyreon/server'
|
|
212
|
+
export const B = island(() => import('./b-comp'), { name: 'Same', hydrate: 'load' })`,
|
|
213
|
+
)
|
|
214
|
+
const plugin = bootstrap()
|
|
215
|
+
await runBuildStart(plugin)
|
|
216
|
+
const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
|
|
217
|
+
// Only one entry for "Same" emitted — registry can't have duplicate keys.
|
|
218
|
+
const matches = source.match(/"Same":/g) ?? []
|
|
219
|
+
expect(matches).toHaveLength(1)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('skips island() calls without a name field (auto-registry has nothing to key on)', async () => {
|
|
223
|
+
writeFile(
|
|
224
|
+
'src/islands.ts',
|
|
225
|
+
`import { island } from '@pyreon/server'
|
|
226
|
+
// Anomaly: island() without a name option. Auto-registry can't include this.
|
|
227
|
+
export const Bad = island(() => import('./X'), { hydrate: 'load' } as any)
|
|
228
|
+
export const Good = island(() => import('./Y'), { name: 'Good', hydrate: 'load' })`,
|
|
229
|
+
)
|
|
230
|
+
const plugin = bootstrap()
|
|
231
|
+
await runBuildStart(plugin)
|
|
232
|
+
const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
|
|
233
|
+
expect(source).toContain('"Good":')
|
|
234
|
+
expect(source).not.toContain('"Bad":')
|
|
235
|
+
})
|
|
236
|
+
})
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* These test the plugin's transform logic directly (no Vite required).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { join as pathJoin } from 'node:path'
|
|
8
10
|
import { describe, expect, it } from 'vitest'
|
|
9
11
|
|
|
10
12
|
// ── Import internals ─────────────────────────────────────────────────────────
|
|
@@ -349,6 +351,85 @@ describe('plugin config', () => {
|
|
|
349
351
|
expect(config.optimizeDeps.exclude).toContain('react-dom')
|
|
350
352
|
})
|
|
351
353
|
|
|
354
|
+
// Regression: pre-fix, the plugin's `bun` resolve condition redirected
|
|
355
|
+
// every `@pyreon/*` import to source `.ts(x)` files. In a non-monorepo
|
|
356
|
+
// consumer app, Vite's deps optimizer (esbuild) tried to pre-bundle
|
|
357
|
+
// those packages from `node_modules` and silently produced broken
|
|
358
|
+
// bundles in `.vite/deps/`, surfacing as
|
|
359
|
+
// `File does not exist at .../node_modules/.vite/deps/@pyreon_styler.js`
|
|
360
|
+
// at runtime. Fix scans the consumer's package.json for `@pyreon/*`
|
|
361
|
+
// deps and adds them to optimizeDeps.exclude so the optimizer skips
|
|
362
|
+
// them (resolution then goes through the plugin's own resolveId hook
|
|
363
|
+
// and Vite's normal source pipeline).
|
|
364
|
+
it("auto-excludes consumer's @pyreon/* deps from optimizeDeps (Vite optimizer fix)", async () => {
|
|
365
|
+
// Build a fake consumer package.json with a few @pyreon/* deps.
|
|
366
|
+
const tmpRoot = pathJoin(import.meta.dirname, 'fixtures', 'pyreon-deps-consumer')
|
|
367
|
+
rmSync(tmpRoot, { recursive: true, force: true })
|
|
368
|
+
mkdirSync(tmpRoot, { recursive: true })
|
|
369
|
+
writeFileSync(
|
|
370
|
+
pathJoin(tmpRoot, 'package.json'),
|
|
371
|
+
JSON.stringify({
|
|
372
|
+
name: 'fake-consumer',
|
|
373
|
+
dependencies: {
|
|
374
|
+
'@pyreon/core': '^0.15.0',
|
|
375
|
+
'@pyreon/styler': '^0.15.0',
|
|
376
|
+
'@pyreon/runtime-dom': '^0.15.0',
|
|
377
|
+
// Non-@pyreon dep MUST NOT leak into the exclude list.
|
|
378
|
+
react: '^19.0.0',
|
|
379
|
+
},
|
|
380
|
+
devDependencies: {
|
|
381
|
+
'@pyreon/vite-plugin': '^0.15.0',
|
|
382
|
+
},
|
|
383
|
+
}),
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
const plugin = pyreonPlugin()
|
|
387
|
+
const config = getConfigHook(plugin)({ root: tmpRoot }, { command: 'serve' }) as {
|
|
388
|
+
optimizeDeps: { exclude: string[] }
|
|
389
|
+
}
|
|
390
|
+
expect(config.optimizeDeps.exclude).toContain('@pyreon/core')
|
|
391
|
+
expect(config.optimizeDeps.exclude).toContain('@pyreon/styler')
|
|
392
|
+
expect(config.optimizeDeps.exclude).toContain('@pyreon/runtime-dom')
|
|
393
|
+
expect(config.optimizeDeps.exclude).toContain('@pyreon/vite-plugin')
|
|
394
|
+
expect(config.optimizeDeps.exclude).not.toContain('react')
|
|
395
|
+
|
|
396
|
+
rmSync(tmpRoot, { recursive: true, force: true })
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it("merges @pyreon/* deps with compat aliases without dup'ing", async () => {
|
|
400
|
+
const tmpRoot = pathJoin(import.meta.dirname, 'fixtures', 'pyreon-deps-compat')
|
|
401
|
+
rmSync(tmpRoot, { recursive: true, force: true })
|
|
402
|
+
mkdirSync(tmpRoot, { recursive: true })
|
|
403
|
+
writeFileSync(
|
|
404
|
+
pathJoin(tmpRoot, 'package.json'),
|
|
405
|
+
JSON.stringify({ dependencies: { '@pyreon/core': '^0.15.0' } }),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
const plugin = pyreonPlugin({ compat: 'react' })
|
|
409
|
+
const config = getConfigHook(plugin)({ root: tmpRoot }, { command: 'serve' }) as {
|
|
410
|
+
optimizeDeps: { exclude: string[] }
|
|
411
|
+
}
|
|
412
|
+
// Compat list still present
|
|
413
|
+
expect(config.optimizeDeps.exclude).toContain('react')
|
|
414
|
+
// Pyreon list also present
|
|
415
|
+
expect(config.optimizeDeps.exclude).toContain('@pyreon/core')
|
|
416
|
+
// Deduplicated (Set)
|
|
417
|
+
const occurrences = config.optimizeDeps.exclude.filter((d) => d === 'react').length
|
|
418
|
+
expect(occurrences).toBe(1)
|
|
419
|
+
|
|
420
|
+
rmSync(tmpRoot, { recursive: true, force: true })
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('handles missing/malformed consumer package.json gracefully', async () => {
|
|
424
|
+
const plugin = pyreonPlugin()
|
|
425
|
+
// Point at a directory that doesn't exist — should not throw.
|
|
426
|
+
const config = getConfigHook(plugin)(
|
|
427
|
+
{ root: '/nonexistent/path/that/does/not/exist' },
|
|
428
|
+
{ command: 'serve' },
|
|
429
|
+
) as { optimizeDeps: { exclude: string[] } }
|
|
430
|
+
expect(config.optimizeDeps.exclude).toEqual([])
|
|
431
|
+
})
|
|
432
|
+
|
|
352
433
|
it('adds SSR build config when isSsrBuild', async () => {
|
|
353
434
|
const plugin = pyreonPlugin({ ssr: { entry: './src/entry-server.ts' } })
|
|
354
435
|
const config = getConfigHook(plugin)({}, { command: 'build', isSsrBuild: true }) as {
|