@pyreon/vite-plugin 0.13.1 → 0.15.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 +213 -15
- package/package.json +3 -2
- package/src/index.ts +342 -6
- package/src/tests/compat-resolve.test.ts +260 -0
- package/src/tests/cross-module-signals.test.ts +425 -0
- package/src/tests/dev-server.test.ts +167 -0
- package/src/tests/vite-plugin.test.ts +94 -54
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
package/src/index.ts
CHANGED
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
* vite build --ssr src/entry-server.ts --outDir dist/server # server bundle
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
36
|
-
import { join as pathJoin } from 'node:path'
|
|
35
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
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
|
|
|
@@ -112,6 +112,73 @@ const COMPAT_ALIASES: Record<CompatFramework, Record<string, string>> = {
|
|
|
112
112
|
},
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Detect whether a file id resolves to a `@pyreon/*` framework-package source
|
|
117
|
+
* (i.e. a published Pyreon package whose .tsx is being pulled in via the
|
|
118
|
+
* `bun` condition workspace-link, NOT user code, NOT an example app).
|
|
119
|
+
*
|
|
120
|
+
* Why this exists: in compat mode, OXC's per-project `importSource` is set
|
|
121
|
+
* to `@pyreon/core` and the resolveId hook redirects `@pyreon/core/jsx-runtime`
|
|
122
|
+
* to the compat package. That's correct for user code (the whole point of
|
|
123
|
+
* compat mode) but WRONG for framework-internal sources like
|
|
124
|
+
* `@pyreon/zero/src/link.tsx`, which need the real `@pyreon/core` runtime.
|
|
125
|
+
* The fix skips the redirect when the importer is a `@pyreon/*` framework
|
|
126
|
+
* file. Result: published-package consumers (where `@pyreon/zero` resolves
|
|
127
|
+
* to its pre-built `lib/`) and workspace-dev consumers (where it resolves
|
|
128
|
+
* to source) both get correct JSX runtime resolution.
|
|
129
|
+
*
|
|
130
|
+
* Detection heuristic: walk to nearest `package.json`, require BOTH:
|
|
131
|
+
* 1. `name` starts with `@pyreon/` (workspace member of the @pyreon scope)
|
|
132
|
+
* 2. file path contains `/packages/` AND NOT `/examples/`
|
|
133
|
+
*
|
|
134
|
+
* Step 2 excludes the existing `@pyreon/example-{react,vue,solid,preact}-compat`
|
|
135
|
+
* apps under `examples/`. Without it, user code in those apps would skip the
|
|
136
|
+
* compat-mode JSX-runtime redirect and import `@pyreon/core/jsx-runtime`
|
|
137
|
+
* directly — breaking the React/Vue/Solid/Preact compat layer's contract.
|
|
138
|
+
*
|
|
139
|
+
* Result cached per directory. The `/packages/` + `/examples/` check is a
|
|
140
|
+
* structural property of the monorepo (workspace layout), not the package
|
|
141
|
+
* name — so it's robust against renames.
|
|
142
|
+
*/
|
|
143
|
+
function isPyreonWorkspaceFile(id: string, cache: Map<string, boolean>): boolean {
|
|
144
|
+
// Strip query strings (e.g. `?vue&type=script`) to get the bare path.
|
|
145
|
+
const queryIdx = id.indexOf('?')
|
|
146
|
+
const filePath = queryIdx === -1 ? id : id.slice(0, queryIdx)
|
|
147
|
+
if (!filePath || filePath[0] === '\0') return false
|
|
148
|
+
|
|
149
|
+
// Path-based filter first (cheap): file must live under `<root>/packages/`
|
|
150
|
+
// and not under `<root>/examples/`. This excludes example apps even when
|
|
151
|
+
// they have `@pyreon/example-*` names.
|
|
152
|
+
if (!filePath.includes('/packages/') || filePath.includes('/examples/')) {
|
|
153
|
+
return false
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let dir = dirname(filePath)
|
|
157
|
+
// Walk up at most ~12 levels — enough for any realistic monorepo depth.
|
|
158
|
+
for (let i = 0; i < 12; i++) {
|
|
159
|
+
const cached = cache.get(dir)
|
|
160
|
+
if (cached !== undefined) return cached
|
|
161
|
+
|
|
162
|
+
const pkgPath = pathJoin(dir, 'package.json')
|
|
163
|
+
if (existsSync(pkgPath)) {
|
|
164
|
+
let isPyreon = false
|
|
165
|
+
try {
|
|
166
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { name?: string }
|
|
167
|
+
isPyreon = typeof pkg.name === 'string' && pkg.name.startsWith('@pyreon/')
|
|
168
|
+
} catch {
|
|
169
|
+
// Malformed package.json — treat as not-pyreon.
|
|
170
|
+
}
|
|
171
|
+
cache.set(dir, isPyreon)
|
|
172
|
+
return isPyreon
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const parent = dirname(dir)
|
|
176
|
+
if (parent === dir) break // reached filesystem root
|
|
177
|
+
dir = parent
|
|
178
|
+
}
|
|
179
|
+
return false
|
|
180
|
+
}
|
|
181
|
+
|
|
115
182
|
/**
|
|
116
183
|
* Return the Pyreon compat target for an import specifier, or undefined if
|
|
117
184
|
* the import should not be redirected.
|
|
@@ -137,6 +204,17 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
137
204
|
let isBuild = false
|
|
138
205
|
let projectRoot = ''
|
|
139
206
|
|
|
207
|
+
// ── Cross-module signal export registry ─────────────────────────────────
|
|
208
|
+
// Tracks which modules export signal() declarations so imported signals
|
|
209
|
+
// can be auto-called in JSX across file boundaries.
|
|
210
|
+
// Key: normalized module ID, Value: set of exported signal names
|
|
211
|
+
const signalExportRegistry = new Map<string, Set<string>>()
|
|
212
|
+
// Cache resolved import specifiers to avoid redundant resolution calls
|
|
213
|
+
const resolveCache = new Map<string, string | null>()
|
|
214
|
+
// Cache `isPyreonWorkspaceFile` lookups by directory — package.json reads
|
|
215
|
+
// happen at most once per containing directory across the build.
|
|
216
|
+
const pyreonWorkspaceDirCache = new Map<string, boolean>()
|
|
217
|
+
|
|
140
218
|
return {
|
|
141
219
|
name: 'pyreon',
|
|
142
220
|
enforce: 'pre',
|
|
@@ -150,7 +228,13 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
150
228
|
// they resolve to workspace packages via our resolveId hook, not node_modules.
|
|
151
229
|
const optimizeDepsExclude = compat ? Object.keys(COMPAT_ALIASES[compat]) : []
|
|
152
230
|
|
|
153
|
-
|
|
231
|
+
// Always set OXC's JSX importSource to `@pyreon/core`. In compat mode,
|
|
232
|
+
// we redirect `@pyreon/core/jsx-runtime` imports to the compat package
|
|
233
|
+
// VIA `resolveId` — but ONLY for user code, never for `@pyreon/*`
|
|
234
|
+
// workspace-package files (zero, router, runtime-dom, etc.). Setting
|
|
235
|
+
// OXC's importSource directly to the compat package would force the
|
|
236
|
+
// compat runtime on framework internals too, which they cannot handle.
|
|
237
|
+
const jsxSource = '@pyreon/core'
|
|
154
238
|
|
|
155
239
|
return {
|
|
156
240
|
// Use "bun" condition for workspace resolution — source .ts/.tsx files
|
|
@@ -180,9 +264,34 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
180
264
|
}
|
|
181
265
|
},
|
|
182
266
|
|
|
267
|
+
// ── Pre-scan all source files for signal exports ──────────────────────
|
|
268
|
+
async buildStart() {
|
|
269
|
+
// Pre-scan all source files for signal exports so the registry
|
|
270
|
+
// is complete before any transforms run. This solves the build
|
|
271
|
+
// ordering problem where component.tsx is transformed before
|
|
272
|
+
// store.ts — without pre-scanning, the registry would be empty.
|
|
273
|
+
await prescanSignalExports(projectRoot, signalExportRegistry)
|
|
274
|
+
},
|
|
275
|
+
|
|
183
276
|
// ── Virtual module + compat alias resolution ─────────────────────────────
|
|
184
277
|
async resolveId(id, importer) {
|
|
185
278
|
if (id === HMR_RUNTIME_IMPORT) return HMR_RUNTIME_ID
|
|
279
|
+
|
|
280
|
+
// `@pyreon/core/jsx-runtime` resolves to the compat package only for
|
|
281
|
+
// user code — never for `@pyreon/*` framework files (zero, router,
|
|
282
|
+
// runtime-dom, etc.). Without this importer guard, every JSX file in
|
|
283
|
+
// the build (including framework internals resolved via the `bun`
|
|
284
|
+
// workspace condition) would get redirected to a compat runtime that
|
|
285
|
+
// doesn't match the framework's JSX shape. Caught by `cpa-smoke-app-*-compat`.
|
|
286
|
+
if (
|
|
287
|
+
compat &&
|
|
288
|
+
(id === '@pyreon/core/jsx-runtime' || id === '@pyreon/core/jsx-dev-runtime') &&
|
|
289
|
+
importer &&
|
|
290
|
+
isPyreonWorkspaceFile(importer, pyreonWorkspaceDirCache)
|
|
291
|
+
) {
|
|
292
|
+
return // let Vite resolve to the real `@pyreon/core/jsx-runtime`
|
|
293
|
+
}
|
|
294
|
+
|
|
186
295
|
const target = getCompatTarget(compat, id)
|
|
187
296
|
if (!target) return
|
|
188
297
|
|
|
@@ -198,7 +307,7 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
198
307
|
}
|
|
199
308
|
},
|
|
200
309
|
|
|
201
|
-
transform(code, id, transformOptions) {
|
|
310
|
+
async transform(code, id, transformOptions) {
|
|
202
311
|
const ext = getExt(id)
|
|
203
312
|
if (ext !== '.tsx' && ext !== '.jsx' && ext !== '.pyreon') return
|
|
204
313
|
|
|
@@ -213,11 +322,22 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
213
322
|
return
|
|
214
323
|
}
|
|
215
324
|
|
|
325
|
+
// ── Scan for exported signal declarations (populate registry) ──────
|
|
326
|
+
// This runs on every .tsx/.jsx file so the registry is built
|
|
327
|
+
// incrementally. buildStart pre-scans all files, but this handles
|
|
328
|
+
// files created/modified after buildStart (dev mode HMR).
|
|
329
|
+
scanSignalExports(code, normalizeModuleId(id), signalExportRegistry)
|
|
330
|
+
|
|
331
|
+
// ── Resolve imported signals from the registry ─────────────────────
|
|
332
|
+
// Check each import in this file: if the imported module has signal
|
|
333
|
+
// exports in the registry, pass them as knownSignals to the compiler.
|
|
334
|
+
const knownSignals = await resolveImportedSignals(code, id, signalExportRegistry, this, resolveCache)
|
|
335
|
+
|
|
216
336
|
// Vite passes `ssr: true` when transforming for the SSR module graph
|
|
217
337
|
// (both build --ssr and dev `ssrLoadModule`). The compiler emits plain
|
|
218
338
|
// `h()` calls in that mode so `runtime-server` can render to a string.
|
|
219
339
|
const isSsr = transformOptions?.ssr === true
|
|
220
|
-
const result = transformJSX(code, id, { ssr: isSsr })
|
|
340
|
+
const result = transformJSX(code, id, { ssr: isSsr, knownSignals })
|
|
221
341
|
// Surface compiler warnings in the terminal
|
|
222
342
|
for (const w of result.warnings) {
|
|
223
343
|
this.warn(`${w.message} (${id}:${w.line}:${w.column})`)
|
|
@@ -333,8 +453,19 @@ function generateProjectContext(root: string): void {
|
|
|
333
453
|
* The arguments are extracted via balanced-paren matching in `injectHmr`.
|
|
334
454
|
* A brace-depth check filters out matches inside functions/blocks — only
|
|
335
455
|
* module-scope (depth 0) signals are rewritten for HMR state preservation.
|
|
456
|
+
*
|
|
457
|
+
* The optional `<...>` group accepts a TypeScript type parameter so that
|
|
458
|
+
* `signal<T>(initial)` declarations are also rewritten — without it, any
|
|
459
|
+
* generic-typed module-scope signal silently skipped HMR preservation.
|
|
460
|
+
*
|
|
461
|
+
* The inner `(?:[^<>]|<[^<>]*>)*` permits one level of generic nesting
|
|
462
|
+
* (e.g. `signal<Array<Row>>([])`, `signal<Map<string, number>>(m)`).
|
|
463
|
+
* Deeper nesting (`signal<Array<{ id: T<U> }>>(...)`) falls back to
|
|
464
|
+
* not-rewritten — tracked as a follow-up if real consumers need it,
|
|
465
|
+
* but unlikely at module scope where generics are usually shallow.
|
|
336
466
|
*/
|
|
337
|
-
const SIGNAL_PREFIX_RE =
|
|
467
|
+
const SIGNAL_PREFIX_RE =
|
|
468
|
+
/^((?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*)signal(?:<(?:[^<>]|<[^<>]*>)*>)?\(/gm
|
|
338
469
|
|
|
339
470
|
/**
|
|
340
471
|
* Detect whether the module exports any component-like functions
|
|
@@ -552,6 +683,211 @@ function isAssetRequest(url: string): boolean {
|
|
|
552
683
|
// Inlined here so it's available without a filesystem read. This is the
|
|
553
684
|
// compiled-to-JS version of hmr-runtime.ts — kept in sync manually.
|
|
554
685
|
|
|
686
|
+
// ─── Cross-module signal auto-call helpers ──────────────────────────────────
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Normalize a Vite module ID by stripping query strings (?v=..., ?t=...)
|
|
690
|
+
* and resolving to an absolute path for consistent registry lookups.
|
|
691
|
+
*/
|
|
692
|
+
function normalizeModuleId(id: string): string {
|
|
693
|
+
const queryIndex = id.indexOf('?')
|
|
694
|
+
return queryIndex >= 0 ? id.slice(0, queryIndex) : id
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Pre-scan all source files in the project for signal exports.
|
|
699
|
+
*
|
|
700
|
+
* Called from `buildStart` so the registry is fully populated before any
|
|
701
|
+
* transforms run. This solves the build ordering problem where component.tsx
|
|
702
|
+
* is transformed before store.ts — without pre-scanning, the registry would
|
|
703
|
+
* be empty and imported signals would not be auto-called.
|
|
704
|
+
*/
|
|
705
|
+
async function prescanSignalExports(root: string, registry: Map<string, Set<string>>): Promise<void> {
|
|
706
|
+
const files: string[] = []
|
|
707
|
+
|
|
708
|
+
function walk(dir: string) {
|
|
709
|
+
try {
|
|
710
|
+
for (const entry of readdirSync(dir)) {
|
|
711
|
+
if (entry.startsWith('.') || entry === 'node_modules' || entry === 'dist' || entry === 'lib' || entry === 'build') continue
|
|
712
|
+
const full = pathJoin(dir, entry)
|
|
713
|
+
try {
|
|
714
|
+
const stat = statSync(full)
|
|
715
|
+
if (stat.isDirectory()) walk(full)
|
|
716
|
+
else if (/\.(ts|tsx|js|jsx)$/.test(entry)) files.push(full)
|
|
717
|
+
} catch {
|
|
718
|
+
/* permission error, etc. */
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
} catch {
|
|
722
|
+
/* dir doesn't exist */
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
walk(root)
|
|
727
|
+
|
|
728
|
+
for (const file of files) {
|
|
729
|
+
try {
|
|
730
|
+
const code = readFileSync(file, 'utf-8')
|
|
731
|
+
scanSignalExports(code, file, registry)
|
|
732
|
+
} catch {
|
|
733
|
+
/* read error */
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Scan a module's source for exported signal declarations and register them.
|
|
740
|
+
*
|
|
741
|
+
* Detects patterns:
|
|
742
|
+
* 1. `export const x = signal(...)` or `export const x = computed(...)` — inline export
|
|
743
|
+
* 2. `const x = signal(...); export { x }` — separate declaration + named export
|
|
744
|
+
* 3. `export default signal(...)` — default export (tracked as 'default')
|
|
745
|
+
*
|
|
746
|
+
* Re-exports (`export { x } from './signals'`) are NOT detected — the source
|
|
747
|
+
* module must be scanned directly. This is a known limitation.
|
|
748
|
+
*
|
|
749
|
+
* Uses simple regex — no AST parse needed.
|
|
750
|
+
*/
|
|
751
|
+
function scanSignalExports(code: string, moduleId: string, registry: Map<string, Set<string>>): void {
|
|
752
|
+
const normalizedId = normalizeModuleId(moduleId)
|
|
753
|
+
let match: RegExpExecArray | null
|
|
754
|
+
const signals = new Set<string>()
|
|
755
|
+
|
|
756
|
+
// Pattern 1: export const x = signal(...) or export const x = computed(...)
|
|
757
|
+
const EXPORT_CONST_RE = /export\s+const\s+(\w+)\s*=\s*(?:signal|computed)\s*[<(]/g
|
|
758
|
+
while ((match = EXPORT_CONST_RE.exec(code)) !== null) {
|
|
759
|
+
signals.add(match[1]!)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Pattern 2: const x = signal(...) followed by export { x }
|
|
763
|
+
// First, find all local `const x = signal(` or `const x = computed(` declarations
|
|
764
|
+
const localSignals = new Set<string>()
|
|
765
|
+
const LOCAL_SIGNAL_RE = /(?:^|[\s;])const\s+(\w+)\s*=\s*(?:signal|computed)\s*[<(]/gm
|
|
766
|
+
while ((match = LOCAL_SIGNAL_RE.exec(code)) !== null) {
|
|
767
|
+
localSignals.add(match[1]!)
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Then check named exports: export { x, y as z }
|
|
771
|
+
if (localSignals.size > 0) {
|
|
772
|
+
const NAMED_EXPORT_RE = /export\s*\{([^}]+)\}/g
|
|
773
|
+
while ((match = NAMED_EXPORT_RE.exec(code)) !== null) {
|
|
774
|
+
// Skip re-exports (export { x } from '...')
|
|
775
|
+
const afterBrace = code.slice(match.index + match[0].length).trimStart()
|
|
776
|
+
if (afterBrace.startsWith('from')) continue
|
|
777
|
+
|
|
778
|
+
for (const spec of match[1]!.split(',')) {
|
|
779
|
+
const trimmed = spec.trim()
|
|
780
|
+
if (!trimmed) continue
|
|
781
|
+
const parts = trimmed.split(/\s+as\s+/)
|
|
782
|
+
const localName = parts[0]!.trim()
|
|
783
|
+
const exportedName = (parts[1] ?? parts[0])!.trim()
|
|
784
|
+
if (localSignals.has(localName)) {
|
|
785
|
+
signals.add(exportedName)
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Pattern 3: export default signal(...) or export default computed(...) — tracked as 'default'
|
|
792
|
+
if (/export\s+default\s+(?:signal|computed)\s*[<(]/.test(code)) {
|
|
793
|
+
signals.add('default')
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (signals.size > 0) {
|
|
797
|
+
registry.set(normalizedId, signals)
|
|
798
|
+
} else {
|
|
799
|
+
// Clean up if module no longer exports signals (e.g. after edit)
|
|
800
|
+
registry.delete(normalizedId)
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Resolve imported signal names from the signal export registry.
|
|
806
|
+
*
|
|
807
|
+
* For each import in the source, resolves the module and checks if it has
|
|
808
|
+
* signal exports in the registry. Returns the local names of imported signals.
|
|
809
|
+
*
|
|
810
|
+
* Handles named imports (`import { x } from ...`) and default imports
|
|
811
|
+
* (`import x from ...` — matched against 'default' in the registry).
|
|
812
|
+
*/
|
|
813
|
+
async function resolveImportedSignals(
|
|
814
|
+
code: string,
|
|
815
|
+
_moduleId: string,
|
|
816
|
+
registry: Map<string, Set<string>>,
|
|
817
|
+
pluginCtx: { resolve: (id: string, importer?: string, options?: { skipSelf: boolean }) => Promise<{ id: string } | null> },
|
|
818
|
+
resolveCache: Map<string, string | null>,
|
|
819
|
+
): Promise<string[]> {
|
|
820
|
+
if (registry.size === 0) return []
|
|
821
|
+
|
|
822
|
+
const knownSignals: string[] = []
|
|
823
|
+
let match: RegExpExecArray | null
|
|
824
|
+
|
|
825
|
+
/** Resolve a source specifier to a normalized module ID, using the cache. */
|
|
826
|
+
async function resolveSource(source: string): Promise<string | null> {
|
|
827
|
+
const cacheKey = `${_moduleId}::${source}`
|
|
828
|
+
if (resolveCache.has(cacheKey)) return resolveCache.get(cacheKey) ?? null
|
|
829
|
+
let resolvedId: string | null = null
|
|
830
|
+
try {
|
|
831
|
+
const resolved = await pluginCtx.resolve(source, _moduleId, { skipSelf: true })
|
|
832
|
+
resolvedId = resolved?.id ? normalizeModuleId(resolved.id) : null
|
|
833
|
+
} catch {
|
|
834
|
+
/* resolve error */
|
|
835
|
+
}
|
|
836
|
+
resolveCache.set(cacheKey, resolvedId)
|
|
837
|
+
return resolvedId
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Named imports: import { name1, name2 as alias } from 'source'
|
|
841
|
+
// Excludes `import type { ... }` — type-only imports have no runtime value
|
|
842
|
+
const IMPORT_RE = /import\s+(?!type\s)\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g
|
|
843
|
+
while ((match = IMPORT_RE.exec(code)) !== null) {
|
|
844
|
+
const specifiers = match[1]!
|
|
845
|
+
const source = match[2]!
|
|
846
|
+
|
|
847
|
+
const resolvedId = await resolveSource(source)
|
|
848
|
+
if (!resolvedId) continue
|
|
849
|
+
const exportedSignals = registry.get(resolvedId)
|
|
850
|
+
if (!exportedSignals) continue
|
|
851
|
+
|
|
852
|
+
// Parse import specifiers: "count, theme as t, other"
|
|
853
|
+
for (const spec of specifiers.split(',')) {
|
|
854
|
+
const trimmed = spec.trim()
|
|
855
|
+
if (!trimmed) continue
|
|
856
|
+
|
|
857
|
+
const parts = trimmed.split(/\s+as\s+/)
|
|
858
|
+
const importedName = parts[0]!.trim()
|
|
859
|
+
const localName = (parts[1] ?? parts[0])!.trim()
|
|
860
|
+
|
|
861
|
+
if (exportedSignals.has(importedName)) {
|
|
862
|
+
knownSignals.push(localName)
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Default imports: import count from './store'
|
|
868
|
+
// Excludes: `import { ... }`, `import type X`, `import * as X`
|
|
869
|
+
const DEFAULT_IMPORT_RE = /import\s+(?!type\s)(\w+)\s+from\s*['"]([^'"]+)['"]/g
|
|
870
|
+
while ((match = DEFAULT_IMPORT_RE.exec(code)) !== null) {
|
|
871
|
+
// Skip if this is actually a `import type X from` pattern
|
|
872
|
+
const fullMatch = match[0]
|
|
873
|
+
if (/import\s+type\s+/.test(fullMatch)) continue
|
|
874
|
+
|
|
875
|
+
const localName = match[1]!
|
|
876
|
+
const source = match[2]!
|
|
877
|
+
|
|
878
|
+
const resolvedId = await resolveSource(source)
|
|
879
|
+
if (!resolvedId) continue
|
|
880
|
+
const exportedSignals = registry.get(resolvedId)
|
|
881
|
+
if (!exportedSignals) continue
|
|
882
|
+
|
|
883
|
+
if (exportedSignals.has('default')) {
|
|
884
|
+
knownSignals.push(localName)
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return knownSignals
|
|
889
|
+
}
|
|
890
|
+
|
|
555
891
|
const HMR_RUNTIME_SOURCE = `
|
|
556
892
|
const REGISTRY_KEY = "__pyreon_hmr_registry__";
|
|
557
893
|
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compat-mode `resolveId` and `getCompatTarget` coverage for
|
|
3
|
+
* @pyreon/vite-plugin (PR #323). The existing test file covers
|
|
4
|
+
* compat-mode `transform` short-circuiting; this covers the
|
|
5
|
+
* resolveId hook + the JSX-runtime aliasing branch.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { resolve } from 'node:path'
|
|
9
|
+
import { describe, expect, it } from 'vitest'
|
|
10
|
+
import pyreonPlugin, { type PyreonPluginOptions } from '../index'
|
|
11
|
+
|
|
12
|
+
type ConfigHook = (
|
|
13
|
+
userConfig: Record<string, unknown>,
|
|
14
|
+
env: { command: string; isSsrBuild?: boolean },
|
|
15
|
+
) => Record<string, unknown>
|
|
16
|
+
|
|
17
|
+
type ResolveIdCtx = {
|
|
18
|
+
resolve: (
|
|
19
|
+
id: string,
|
|
20
|
+
importer?: string,
|
|
21
|
+
options?: { skipSelf: boolean },
|
|
22
|
+
) => Promise<{ id: string } | null>
|
|
23
|
+
}
|
|
24
|
+
type ResolveIdHook = (
|
|
25
|
+
this: ResolveIdCtx,
|
|
26
|
+
id: string,
|
|
27
|
+
importer?: string,
|
|
28
|
+
) => Promise<string | undefined>
|
|
29
|
+
|
|
30
|
+
function bootstrap(opts?: PyreonPluginOptions) {
|
|
31
|
+
const plugin = pyreonPlugin(opts)
|
|
32
|
+
;(plugin.config as unknown as ConfigHook)({}, { command: 'serve' })
|
|
33
|
+
return plugin
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function callResolveId(
|
|
37
|
+
plugin: ReturnType<typeof pyreonPlugin>,
|
|
38
|
+
id: string,
|
|
39
|
+
resolveMap: Record<string, string> = {},
|
|
40
|
+
importer?: string,
|
|
41
|
+
): Promise<string | undefined> {
|
|
42
|
+
const hook = plugin.resolveId as ResolveIdHook
|
|
43
|
+
return hook.call(
|
|
44
|
+
{
|
|
45
|
+
resolve: async (specifier: string) => {
|
|
46
|
+
const resolved = resolveMap[specifier]
|
|
47
|
+
return resolved ? { id: resolved } : null
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
id,
|
|
51
|
+
importer,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('compat-mode resolveId — react', () => {
|
|
56
|
+
it('redirects "react" → @pyreon/react-compat', async () => {
|
|
57
|
+
const plugin = bootstrap({ compat: 'react' })
|
|
58
|
+
const resolved = await callResolveId(plugin, 'react', {
|
|
59
|
+
'@pyreon/react-compat': '/abs/react-compat/index.ts',
|
|
60
|
+
})
|
|
61
|
+
expect(resolved).toBe('/abs/react-compat/index.ts')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('redirects "react/jsx-runtime" → @pyreon/react-compat/jsx-runtime', async () => {
|
|
65
|
+
const plugin = bootstrap({ compat: 'react' })
|
|
66
|
+
const resolved = await callResolveId(plugin, 'react/jsx-runtime', {
|
|
67
|
+
'@pyreon/react-compat/jsx-runtime': '/abs/react-compat/jsx-runtime.ts',
|
|
68
|
+
})
|
|
69
|
+
expect(resolved).toBe('/abs/react-compat/jsx-runtime.ts')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('redirects @pyreon/core/jsx-runtime → @pyreon/react-compat/jsx-runtime in react compat', async () => {
|
|
73
|
+
const plugin = bootstrap({ compat: 'react' })
|
|
74
|
+
const resolved = await callResolveId(plugin, '@pyreon/core/jsx-runtime', {
|
|
75
|
+
'@pyreon/react-compat/jsx-runtime': '/abs/react-compat/jsx-runtime.ts',
|
|
76
|
+
})
|
|
77
|
+
expect(resolved).toBe('/abs/react-compat/jsx-runtime.ts')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('redirects @pyreon/core/jsx-dev-runtime in react compat', async () => {
|
|
81
|
+
const plugin = bootstrap({ compat: 'react' })
|
|
82
|
+
const resolved = await callResolveId(plugin, '@pyreon/core/jsx-dev-runtime', {
|
|
83
|
+
'@pyreon/react-compat/jsx-runtime': '/abs/react-compat/jsx-runtime.ts',
|
|
84
|
+
})
|
|
85
|
+
expect(resolved).toBe('/abs/react-compat/jsx-runtime.ts')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('returns undefined for non-aliased imports', async () => {
|
|
89
|
+
const plugin = bootstrap({ compat: 'react' })
|
|
90
|
+
const resolved = await callResolveId(plugin, 'lodash', {})
|
|
91
|
+
expect(resolved).toBeUndefined()
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('compat-mode resolveId — preact', () => {
|
|
96
|
+
it('redirects "preact" → @pyreon/preact-compat', async () => {
|
|
97
|
+
const plugin = bootstrap({ compat: 'preact' })
|
|
98
|
+
const resolved = await callResolveId(plugin, 'preact', {
|
|
99
|
+
'@pyreon/preact-compat': '/abs/preact-compat/index.ts',
|
|
100
|
+
})
|
|
101
|
+
expect(resolved).toBe('/abs/preact-compat/index.ts')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('redirects "preact/hooks" → @pyreon/preact-compat/hooks', async () => {
|
|
105
|
+
const plugin = bootstrap({ compat: 'preact' })
|
|
106
|
+
const resolved = await callResolveId(plugin, 'preact/hooks', {
|
|
107
|
+
'@pyreon/preact-compat/hooks': '/abs/preact-compat/hooks.ts',
|
|
108
|
+
})
|
|
109
|
+
expect(resolved).toBe('/abs/preact-compat/hooks.ts')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('redirects @pyreon/core/jsx-runtime in preact compat', async () => {
|
|
113
|
+
const plugin = bootstrap({ compat: 'preact' })
|
|
114
|
+
const resolved = await callResolveId(plugin, '@pyreon/core/jsx-runtime', {
|
|
115
|
+
'@pyreon/preact-compat/jsx-runtime': '/abs/preact-compat/jsx-runtime.ts',
|
|
116
|
+
})
|
|
117
|
+
expect(resolved).toBe('/abs/preact-compat/jsx-runtime.ts')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('redirects @preact/signals → @pyreon/preact-compat/signals', async () => {
|
|
121
|
+
const plugin = bootstrap({ compat: 'preact' })
|
|
122
|
+
const resolved = await callResolveId(plugin, '@preact/signals', {
|
|
123
|
+
'@pyreon/preact-compat/signals': '/abs/preact-compat/signals.ts',
|
|
124
|
+
})
|
|
125
|
+
expect(resolved).toBe('/abs/preact-compat/signals.ts')
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('compat-mode resolveId — vue', () => {
|
|
130
|
+
it('redirects "vue" → @pyreon/vue-compat', async () => {
|
|
131
|
+
const plugin = bootstrap({ compat: 'vue' })
|
|
132
|
+
const resolved = await callResolveId(plugin, 'vue', {
|
|
133
|
+
'@pyreon/vue-compat': '/abs/vue-compat/index.ts',
|
|
134
|
+
})
|
|
135
|
+
expect(resolved).toBe('/abs/vue-compat/index.ts')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('redirects @pyreon/core/jsx-runtime in vue compat', async () => {
|
|
139
|
+
const plugin = bootstrap({ compat: 'vue' })
|
|
140
|
+
const resolved = await callResolveId(plugin, '@pyreon/core/jsx-runtime', {
|
|
141
|
+
'@pyreon/vue-compat/jsx-runtime': '/abs/vue-compat/jsx-runtime.ts',
|
|
142
|
+
})
|
|
143
|
+
expect(resolved).toBe('/abs/vue-compat/jsx-runtime.ts')
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('compat-mode resolveId — solid', () => {
|
|
148
|
+
it('redirects "solid-js" → @pyreon/solid-compat', async () => {
|
|
149
|
+
const plugin = bootstrap({ compat: 'solid' })
|
|
150
|
+
const resolved = await callResolveId(plugin, 'solid-js', {
|
|
151
|
+
'@pyreon/solid-compat': '/abs/solid-compat/index.ts',
|
|
152
|
+
})
|
|
153
|
+
expect(resolved).toBe('/abs/solid-compat/index.ts')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('redirects @pyreon/core/jsx-runtime in solid compat', async () => {
|
|
157
|
+
const plugin = bootstrap({ compat: 'solid' })
|
|
158
|
+
const resolved = await callResolveId(plugin, '@pyreon/core/jsx-runtime', {
|
|
159
|
+
'@pyreon/solid-compat/jsx-runtime': '/abs/solid-compat/jsx-runtime.ts',
|
|
160
|
+
})
|
|
161
|
+
expect(resolved).toBe('/abs/solid-compat/jsx-runtime.ts')
|
|
162
|
+
})
|
|
163
|
+
})
|
|
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
|
+
|
|
244
|
+
describe('compat-mode resolveId — no compat', () => {
|
|
245
|
+
it('returns undefined for any framework alias when compat is unset', async () => {
|
|
246
|
+
const plugin = bootstrap()
|
|
247
|
+
expect(await callResolveId(plugin, 'react', {})).toBeUndefined()
|
|
248
|
+
expect(await callResolveId(plugin, 'vue', {})).toBeUndefined()
|
|
249
|
+
expect(await callResolveId(plugin, 'preact', {})).toBeUndefined()
|
|
250
|
+
expect(await callResolveId(plugin, 'solid-js', {})).toBeUndefined()
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('still resolves the HMR runtime virtual id (independent of compat)', async () => {
|
|
254
|
+
const plugin = bootstrap()
|
|
255
|
+
const resolved = await callResolveId(plugin, 'virtual:pyreon/hmr-runtime', {})
|
|
256
|
+
// Internal ID — has the leading '\0' marker convention or similar
|
|
257
|
+
expect(resolved).toBeDefined()
|
|
258
|
+
expect(typeof resolved).toBe('string')
|
|
259
|
+
})
|
|
260
|
+
})
|