@pyreon/vite-plugin 0.14.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 +228 -12
- package/lib/types/index.d.ts +26 -0
- package/package.json +3 -2
- package/src/index.ts +400 -13
- package/src/tests/compat-resolve.test.ts +82 -0
- package/src/tests/islands-registry.test.ts +236 -0
- package/src/tests/vite-plugin.test.ts +116 -2
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
package/src/index.ts
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
36
|
-
import { join as pathJoin } from 'node:path'
|
|
36
|
+
import { dirname, join as pathJoin } from 'node:path'
|
|
37
37
|
import { generateContext, transformJSX } from '@pyreon/compiler'
|
|
38
38
|
import type { Plugin, ViteDevServer } from 'vite'
|
|
39
39
|
|
|
@@ -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
81
|
|
|
77
|
-
|
|
78
|
-
|
|
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 ─────────────────────────────────────────────────────────
|
|
@@ -112,6 +136,73 @@ const COMPAT_ALIASES: Record<CompatFramework, Record<string, string>> = {
|
|
|
112
136
|
},
|
|
113
137
|
}
|
|
114
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Detect whether a file id resolves to a `@pyreon/*` framework-package source
|
|
141
|
+
* (i.e. a published Pyreon package whose .tsx is being pulled in via the
|
|
142
|
+
* `bun` condition workspace-link, NOT user code, NOT an example app).
|
|
143
|
+
*
|
|
144
|
+
* Why this exists: in compat mode, OXC's per-project `importSource` is set
|
|
145
|
+
* to `@pyreon/core` and the resolveId hook redirects `@pyreon/core/jsx-runtime`
|
|
146
|
+
* to the compat package. That's correct for user code (the whole point of
|
|
147
|
+
* compat mode) but WRONG for framework-internal sources like
|
|
148
|
+
* `@pyreon/zero/src/link.tsx`, which need the real `@pyreon/core` runtime.
|
|
149
|
+
* The fix skips the redirect when the importer is a `@pyreon/*` framework
|
|
150
|
+
* file. Result: published-package consumers (where `@pyreon/zero` resolves
|
|
151
|
+
* to its pre-built `lib/`) and workspace-dev consumers (where it resolves
|
|
152
|
+
* to source) both get correct JSX runtime resolution.
|
|
153
|
+
*
|
|
154
|
+
* Detection heuristic: walk to nearest `package.json`, require BOTH:
|
|
155
|
+
* 1. `name` starts with `@pyreon/` (workspace member of the @pyreon scope)
|
|
156
|
+
* 2. file path contains `/packages/` AND NOT `/examples/`
|
|
157
|
+
*
|
|
158
|
+
* Step 2 excludes the existing `@pyreon/example-{react,vue,solid,preact}-compat`
|
|
159
|
+
* apps under `examples/`. Without it, user code in those apps would skip the
|
|
160
|
+
* compat-mode JSX-runtime redirect and import `@pyreon/core/jsx-runtime`
|
|
161
|
+
* directly — breaking the React/Vue/Solid/Preact compat layer's contract.
|
|
162
|
+
*
|
|
163
|
+
* Result cached per directory. The `/packages/` + `/examples/` check is a
|
|
164
|
+
* structural property of the monorepo (workspace layout), not the package
|
|
165
|
+
* name — so it's robust against renames.
|
|
166
|
+
*/
|
|
167
|
+
function isPyreonWorkspaceFile(id: string, cache: Map<string, boolean>): boolean {
|
|
168
|
+
// Strip query strings (e.g. `?vue&type=script`) to get the bare path.
|
|
169
|
+
const queryIdx = id.indexOf('?')
|
|
170
|
+
const filePath = queryIdx === -1 ? id : id.slice(0, queryIdx)
|
|
171
|
+
if (!filePath || filePath[0] === '\0') return false
|
|
172
|
+
|
|
173
|
+
// Path-based filter first (cheap): file must live under `<root>/packages/`
|
|
174
|
+
// and not under `<root>/examples/`. This excludes example apps even when
|
|
175
|
+
// they have `@pyreon/example-*` names.
|
|
176
|
+
if (!filePath.includes('/packages/') || filePath.includes('/examples/')) {
|
|
177
|
+
return false
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let dir = dirname(filePath)
|
|
181
|
+
// Walk up at most ~12 levels — enough for any realistic monorepo depth.
|
|
182
|
+
for (let i = 0; i < 12; i++) {
|
|
183
|
+
const cached = cache.get(dir)
|
|
184
|
+
if (cached !== undefined) return cached
|
|
185
|
+
|
|
186
|
+
const pkgPath = pathJoin(dir, 'package.json')
|
|
187
|
+
if (existsSync(pkgPath)) {
|
|
188
|
+
let isPyreon = false
|
|
189
|
+
try {
|
|
190
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { name?: string }
|
|
191
|
+
isPyreon = typeof pkg.name === 'string' && pkg.name.startsWith('@pyreon/')
|
|
192
|
+
} catch {
|
|
193
|
+
// Malformed package.json — treat as not-pyreon.
|
|
194
|
+
}
|
|
195
|
+
cache.set(dir, isPyreon)
|
|
196
|
+
return isPyreon
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const parent = dirname(dir)
|
|
200
|
+
if (parent === dir) break // reached filesystem root
|
|
201
|
+
dir = parent
|
|
202
|
+
}
|
|
203
|
+
return false
|
|
204
|
+
}
|
|
205
|
+
|
|
115
206
|
/**
|
|
116
207
|
* Return the Pyreon compat target for an import specifier, or undefined if
|
|
117
208
|
* the import should not be redirected.
|
|
@@ -131,9 +222,44 @@ function getCompatTarget(compat: CompatFramework | undefined, id: string): strin
|
|
|
131
222
|
return undefined
|
|
132
223
|
}
|
|
133
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
|
+
|
|
134
256
|
export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
135
257
|
const ssrConfig = options?.ssr
|
|
136
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
|
|
137
263
|
let isBuild = false
|
|
138
264
|
let projectRoot = ''
|
|
139
265
|
|
|
@@ -144,6 +270,16 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
144
270
|
const signalExportRegistry = new Map<string, Set<string>>()
|
|
145
271
|
// Cache resolved import specifiers to avoid redundant resolution calls
|
|
146
272
|
const resolveCache = new Map<string, string | null>()
|
|
273
|
+
// Cache `isPyreonWorkspaceFile` lookups by directory — package.json reads
|
|
274
|
+
// happen at most once per containing directory across the build.
|
|
275
|
+
const pyreonWorkspaceDirCache = new Map<string, boolean>()
|
|
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[]>()
|
|
147
283
|
|
|
148
284
|
return {
|
|
149
285
|
name: 'pyreon',
|
|
@@ -156,9 +292,31 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
156
292
|
|
|
157
293
|
// Tell Vite's dep scanner not to pre-bundle the aliased framework imports —
|
|
158
294
|
// they resolve to workspace packages via our resolveId hook, not node_modules.
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
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
|
+
)
|
|
312
|
+
|
|
313
|
+
// Always set OXC's JSX importSource to `@pyreon/core`. In compat mode,
|
|
314
|
+
// we redirect `@pyreon/core/jsx-runtime` imports to the compat package
|
|
315
|
+
// VIA `resolveId` — but ONLY for user code, never for `@pyreon/*`
|
|
316
|
+
// workspace-package files (zero, router, runtime-dom, etc.). Setting
|
|
317
|
+
// OXC's importSource directly to the compat package would force the
|
|
318
|
+
// compat runtime on framework internals too, which they cannot handle.
|
|
319
|
+
const jsxSource = '@pyreon/core'
|
|
162
320
|
|
|
163
321
|
return {
|
|
164
322
|
// Use "bun" condition for workspace resolution — source .ts/.tsx files
|
|
@@ -195,11 +353,37 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
195
353
|
// ordering problem where component.tsx is transformed before
|
|
196
354
|
// store.ts — without pre-scanning, the registry would be empty.
|
|
197
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
|
+
}
|
|
198
365
|
},
|
|
199
366
|
|
|
200
367
|
// ── Virtual module + compat alias resolution ─────────────────────────────
|
|
201
368
|
async resolveId(id, importer) {
|
|
202
369
|
if (id === HMR_RUNTIME_IMPORT) return HMR_RUNTIME_ID
|
|
370
|
+
if (id === ISLANDS_REGISTRY_IMPORT) return ISLANDS_REGISTRY_ID
|
|
371
|
+
|
|
372
|
+
// `@pyreon/core/jsx-runtime` resolves to the compat package only for
|
|
373
|
+
// user code — never for `@pyreon/*` framework files (zero, router,
|
|
374
|
+
// runtime-dom, etc.). Without this importer guard, every JSX file in
|
|
375
|
+
// the build (including framework internals resolved via the `bun`
|
|
376
|
+
// workspace condition) would get redirected to a compat runtime that
|
|
377
|
+
// doesn't match the framework's JSX shape. Caught by `cpa-smoke-app-*-compat`.
|
|
378
|
+
if (
|
|
379
|
+
compat &&
|
|
380
|
+
(id === '@pyreon/core/jsx-runtime' || id === '@pyreon/core/jsx-dev-runtime') &&
|
|
381
|
+
importer &&
|
|
382
|
+
isPyreonWorkspaceFile(importer, pyreonWorkspaceDirCache)
|
|
383
|
+
) {
|
|
384
|
+
return // let Vite resolve to the real `@pyreon/core/jsx-runtime`
|
|
385
|
+
}
|
|
386
|
+
|
|
203
387
|
const target = getCompatTarget(compat, id)
|
|
204
388
|
if (!target) return
|
|
205
389
|
|
|
@@ -213,6 +397,9 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
213
397
|
if (id === HMR_RUNTIME_ID) {
|
|
214
398
|
return HMR_RUNTIME_SOURCE
|
|
215
399
|
}
|
|
400
|
+
if (id === ISLANDS_REGISTRY_ID) {
|
|
401
|
+
return renderIslandsRegistry(islandRegistry, islandsEnabled)
|
|
402
|
+
}
|
|
216
403
|
},
|
|
217
404
|
|
|
218
405
|
async transform(code, id, transformOptions) {
|
|
@@ -236,6 +423,12 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
236
423
|
// files created/modified after buildStart (dev mode HMR).
|
|
237
424
|
scanSignalExports(code, normalizeModuleId(id), signalExportRegistry)
|
|
238
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
|
+
|
|
239
432
|
// ── Resolve imported signals from the registry ─────────────────────
|
|
240
433
|
// Check each import in this file: if the imported module has signal
|
|
241
434
|
// exports in the registry, pass them as knownSignals to the compiler.
|
|
@@ -361,8 +554,19 @@ function generateProjectContext(root: string): void {
|
|
|
361
554
|
* The arguments are extracted via balanced-paren matching in `injectHmr`.
|
|
362
555
|
* A brace-depth check filters out matches inside functions/blocks — only
|
|
363
556
|
* module-scope (depth 0) signals are rewritten for HMR state preservation.
|
|
557
|
+
*
|
|
558
|
+
* The optional `<...>` group accepts a TypeScript type parameter so that
|
|
559
|
+
* `signal<T>(initial)` declarations are also rewritten — without it, any
|
|
560
|
+
* generic-typed module-scope signal silently skipped HMR preservation.
|
|
561
|
+
*
|
|
562
|
+
* The inner `(?:[^<>]|<[^<>]*>)*` permits one level of generic nesting
|
|
563
|
+
* (e.g. `signal<Array<Row>>([])`, `signal<Map<string, number>>(m)`).
|
|
564
|
+
* Deeper nesting (`signal<Array<{ id: T<U> }>>(...)`) falls back to
|
|
565
|
+
* not-rewritten — tracked as a follow-up if real consumers need it,
|
|
566
|
+
* but unlikely at module scope where generics are usually shallow.
|
|
364
567
|
*/
|
|
365
|
-
const SIGNAL_PREFIX_RE =
|
|
568
|
+
const SIGNAL_PREFIX_RE =
|
|
569
|
+
/^((?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*)signal(?:<(?:[^<>]|<[^<>]*>)*>)?\(/gm
|
|
366
570
|
|
|
367
571
|
/**
|
|
368
572
|
* Detect whether the module exports any component-like functions
|
|
@@ -591,6 +795,189 @@ function normalizeModuleId(id: string): string {
|
|
|
591
795
|
return queryIndex >= 0 ? id.slice(0, queryIndex) : id
|
|
592
796
|
}
|
|
593
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
|
+
|
|
594
981
|
/**
|
|
595
982
|
* Pre-scan all source files in the project for signal exports.
|
|
596
983
|
*
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* resolveId hook + the JSX-runtime aliasing branch.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { resolve } from 'node:path'
|
|
8
9
|
import { describe, expect, it } from 'vitest'
|
|
9
10
|
import pyreonPlugin, { type PyreonPluginOptions } from '../index'
|
|
10
11
|
|
|
@@ -36,6 +37,7 @@ async function callResolveId(
|
|
|
36
37
|
plugin: ReturnType<typeof pyreonPlugin>,
|
|
37
38
|
id: string,
|
|
38
39
|
resolveMap: Record<string, string> = {},
|
|
40
|
+
importer?: string,
|
|
39
41
|
): Promise<string | undefined> {
|
|
40
42
|
const hook = plugin.resolveId as ResolveIdHook
|
|
41
43
|
return hook.call(
|
|
@@ -46,6 +48,7 @@ async function callResolveId(
|
|
|
46
48
|
},
|
|
47
49
|
},
|
|
48
50
|
id,
|
|
51
|
+
importer,
|
|
49
52
|
)
|
|
50
53
|
}
|
|
51
54
|
|
|
@@ -159,6 +162,85 @@ describe('compat-mode resolveId — solid', () => {
|
|
|
159
162
|
})
|
|
160
163
|
})
|
|
161
164
|
|
|
165
|
+
describe('compat-mode resolveId — framework-importer carve-out', () => {
|
|
166
|
+
// Regression: in compat mode, `@pyreon/core/jsx-runtime` must NOT be
|
|
167
|
+
// redirected to the compat package when the importer is itself a
|
|
168
|
+
// `@pyreon/*` workspace-package source file (zero, router, runtime-dom,
|
|
169
|
+
// etc.). Pre-fix, OXC's project-wide importSource was set to the compat
|
|
170
|
+
// package, so framework-internal JSX got rewritten to import a runtime
|
|
171
|
+
// shape it doesn't speak. The fix sets OXC to `@pyreon/core` always and
|
|
172
|
+
// redirects in `resolveId` only for non-framework importers. Caught by
|
|
173
|
+
// `cpa-smoke-app-*-compat` cells in `scripts/scaffold-smoke.ts`.
|
|
174
|
+
// Bisect-verified: dropping the `isPyreonWorkspaceFile(importer)` guard
|
|
175
|
+
// makes these tests fail with the redirected jsx-runtime path.
|
|
176
|
+
|
|
177
|
+
const repoRoot = resolve(import.meta.dirname, '../../../../..')
|
|
178
|
+
const frameworkImporter = `${repoRoot}/packages/zero/zero/src/link.tsx`
|
|
179
|
+
const userImporter = `${repoRoot}/examples/some-user-app/src/foo.tsx`
|
|
180
|
+
// The 4 existing compat-layer example apps under `examples/` have
|
|
181
|
+
// package.json names like `@pyreon/example-react-compat`. The carve-out
|
|
182
|
+
// helper must NOT treat their source files as framework files — doing so
|
|
183
|
+
// skips the JSX-runtime redirect and breaks the compat layer end-to-end.
|
|
184
|
+
// Bisect-verified: when the helper checked `name.startsWith('@pyreon/')`
|
|
185
|
+
// alone (without the `/examples/` exclusion), all 4 compat-layer e2e
|
|
186
|
+
// suites failed in CI with `section.demo` never rendering.
|
|
187
|
+
const exampleAppImporter = `${repoRoot}/examples/react-compat/src/Foo.tsx`
|
|
188
|
+
|
|
189
|
+
it('does NOT redirect @pyreon/core/jsx-runtime when imported FROM @pyreon/zero workspace source (react)', async () => {
|
|
190
|
+
const plugin = bootstrap({ compat: 'react' })
|
|
191
|
+
const resolved = await callResolveId(
|
|
192
|
+
plugin,
|
|
193
|
+
'@pyreon/core/jsx-runtime',
|
|
194
|
+
{ '@pyreon/react-compat/jsx-runtime': '/abs/react-compat/jsx-runtime.ts' },
|
|
195
|
+
frameworkImporter,
|
|
196
|
+
)
|
|
197
|
+
expect(resolved).toBeUndefined() // pass through to Vite's resolver
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('does NOT redirect @pyreon/core/jsx-dev-runtime when imported FROM framework source (preact)', async () => {
|
|
201
|
+
const plugin = bootstrap({ compat: 'preact' })
|
|
202
|
+
const resolved = await callResolveId(
|
|
203
|
+
plugin,
|
|
204
|
+
'@pyreon/core/jsx-dev-runtime',
|
|
205
|
+
{ '@pyreon/preact-compat/jsx-runtime': '/abs/preact-compat/jsx-runtime.ts' },
|
|
206
|
+
frameworkImporter,
|
|
207
|
+
)
|
|
208
|
+
expect(resolved).toBeUndefined()
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('STILL redirects @pyreon/core/jsx-runtime when imported FROM user code (react)', async () => {
|
|
212
|
+
const plugin = bootstrap({ compat: 'react' })
|
|
213
|
+
const resolved = await callResolveId(
|
|
214
|
+
plugin,
|
|
215
|
+
'@pyreon/core/jsx-runtime',
|
|
216
|
+
{ '@pyreon/react-compat/jsx-runtime': '/abs/react-compat/jsx-runtime.ts' },
|
|
217
|
+
userImporter,
|
|
218
|
+
)
|
|
219
|
+
expect(resolved).toBe('/abs/react-compat/jsx-runtime.ts')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('STILL redirects @pyreon/core/jsx-runtime when no importer (entry point)', async () => {
|
|
223
|
+
const plugin = bootstrap({ compat: 'react' })
|
|
224
|
+
const resolved = await callResolveId(
|
|
225
|
+
plugin,
|
|
226
|
+
'@pyreon/core/jsx-runtime',
|
|
227
|
+
{ '@pyreon/react-compat/jsx-runtime': '/abs/react-compat/jsx-runtime.ts' },
|
|
228
|
+
)
|
|
229
|
+
expect(resolved).toBe('/abs/react-compat/jsx-runtime.ts')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('STILL redirects @pyreon/core/jsx-runtime when imported FROM an example app under examples/ (e.g. @pyreon/example-react-compat)', async () => {
|
|
233
|
+
const plugin = bootstrap({ compat: 'react' })
|
|
234
|
+
const resolved = await callResolveId(
|
|
235
|
+
plugin,
|
|
236
|
+
'@pyreon/core/jsx-runtime',
|
|
237
|
+
{ '@pyreon/react-compat/jsx-runtime': '/abs/react-compat/jsx-runtime.ts' },
|
|
238
|
+
exampleAppImporter,
|
|
239
|
+
)
|
|
240
|
+
expect(resolved).toBe('/abs/react-compat/jsx-runtime.ts')
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
|
|
162
244
|
describe('compat-mode resolveId — no compat', () => {
|
|
163
245
|
it('returns undefined for any framework alias when compat is unset', async () => {
|
|
164
246
|
const plugin = bootstrap()
|