@pyreon/vite-plugin 0.13.0 → 0.14.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/src/index.ts CHANGED
@@ -32,7 +32,7 @@
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'
35
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
36
36
  import { join as pathJoin } from 'node:path'
37
37
  import { generateContext, transformJSX } from '@pyreon/compiler'
38
38
  import type { Plugin, ViteDevServer } from 'vite'
@@ -137,6 +137,14 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
137
137
  let isBuild = false
138
138
  let projectRoot = ''
139
139
 
140
+ // ── Cross-module signal export registry ─────────────────────────────────
141
+ // Tracks which modules export signal() declarations so imported signals
142
+ // can be auto-called in JSX across file boundaries.
143
+ // Key: normalized module ID, Value: set of exported signal names
144
+ const signalExportRegistry = new Map<string, Set<string>>()
145
+ // Cache resolved import specifiers to avoid redundant resolution calls
146
+ const resolveCache = new Map<string, string | null>()
147
+
140
148
  return {
141
149
  name: 'pyreon',
142
150
  enforce: 'pre',
@@ -180,6 +188,15 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
180
188
  }
181
189
  },
182
190
 
191
+ // ── Pre-scan all source files for signal exports ──────────────────────
192
+ async buildStart() {
193
+ // Pre-scan all source files for signal exports so the registry
194
+ // is complete before any transforms run. This solves the build
195
+ // ordering problem where component.tsx is transformed before
196
+ // store.ts — without pre-scanning, the registry would be empty.
197
+ await prescanSignalExports(projectRoot, signalExportRegistry)
198
+ },
199
+
183
200
  // ── Virtual module + compat alias resolution ─────────────────────────────
184
201
  async resolveId(id, importer) {
185
202
  if (id === HMR_RUNTIME_IMPORT) return HMR_RUNTIME_ID
@@ -198,7 +215,7 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
198
215
  }
199
216
  },
200
217
 
201
- transform(code, id, transformOptions) {
218
+ async transform(code, id, transformOptions) {
202
219
  const ext = getExt(id)
203
220
  if (ext !== '.tsx' && ext !== '.jsx' && ext !== '.pyreon') return
204
221
 
@@ -213,11 +230,22 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
213
230
  return
214
231
  }
215
232
 
233
+ // ── Scan for exported signal declarations (populate registry) ──────
234
+ // This runs on every .tsx/.jsx file so the registry is built
235
+ // incrementally. buildStart pre-scans all files, but this handles
236
+ // files created/modified after buildStart (dev mode HMR).
237
+ scanSignalExports(code, normalizeModuleId(id), signalExportRegistry)
238
+
239
+ // ── Resolve imported signals from the registry ─────────────────────
240
+ // Check each import in this file: if the imported module has signal
241
+ // exports in the registry, pass them as knownSignals to the compiler.
242
+ const knownSignals = await resolveImportedSignals(code, id, signalExportRegistry, this, resolveCache)
243
+
216
244
  // Vite passes `ssr: true` when transforming for the SSR module graph
217
245
  // (both build --ssr and dev `ssrLoadModule`). The compiler emits plain
218
246
  // `h()` calls in that mode so `runtime-server` can render to a string.
219
247
  const isSsr = transformOptions?.ssr === true
220
- const result = transformJSX(code, id, { ssr: isSsr })
248
+ const result = transformJSX(code, id, { ssr: isSsr, knownSignals })
221
249
  // Surface compiler warnings in the terminal
222
250
  for (const w of result.warnings) {
223
251
  this.warn(`${w.message} (${id}:${w.line}:${w.column})`)
@@ -552,6 +580,211 @@ function isAssetRequest(url: string): boolean {
552
580
  // Inlined here so it's available without a filesystem read. This is the
553
581
  // compiled-to-JS version of hmr-runtime.ts — kept in sync manually.
554
582
 
583
+ // ─── Cross-module signal auto-call helpers ──────────────────────────────────
584
+
585
+ /**
586
+ * Normalize a Vite module ID by stripping query strings (?v=..., ?t=...)
587
+ * and resolving to an absolute path for consistent registry lookups.
588
+ */
589
+ function normalizeModuleId(id: string): string {
590
+ const queryIndex = id.indexOf('?')
591
+ return queryIndex >= 0 ? id.slice(0, queryIndex) : id
592
+ }
593
+
594
+ /**
595
+ * Pre-scan all source files in the project for signal exports.
596
+ *
597
+ * Called from `buildStart` so the registry is fully populated before any
598
+ * transforms run. This solves the build ordering problem where component.tsx
599
+ * is transformed before store.ts — without pre-scanning, the registry would
600
+ * be empty and imported signals would not be auto-called.
601
+ */
602
+ async function prescanSignalExports(root: string, registry: Map<string, Set<string>>): Promise<void> {
603
+ const files: string[] = []
604
+
605
+ function walk(dir: string) {
606
+ try {
607
+ for (const entry of readdirSync(dir)) {
608
+ if (entry.startsWith('.') || entry === 'node_modules' || entry === 'dist' || entry === 'lib' || entry === 'build') continue
609
+ const full = pathJoin(dir, entry)
610
+ try {
611
+ const stat = statSync(full)
612
+ if (stat.isDirectory()) walk(full)
613
+ else if (/\.(ts|tsx|js|jsx)$/.test(entry)) files.push(full)
614
+ } catch {
615
+ /* permission error, etc. */
616
+ }
617
+ }
618
+ } catch {
619
+ /* dir doesn't exist */
620
+ }
621
+ }
622
+
623
+ walk(root)
624
+
625
+ for (const file of files) {
626
+ try {
627
+ const code = readFileSync(file, 'utf-8')
628
+ scanSignalExports(code, file, registry)
629
+ } catch {
630
+ /* read error */
631
+ }
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Scan a module's source for exported signal declarations and register them.
637
+ *
638
+ * Detects patterns:
639
+ * 1. `export const x = signal(...)` or `export const x = computed(...)` — inline export
640
+ * 2. `const x = signal(...); export { x }` — separate declaration + named export
641
+ * 3. `export default signal(...)` — default export (tracked as 'default')
642
+ *
643
+ * Re-exports (`export { x } from './signals'`) are NOT detected — the source
644
+ * module must be scanned directly. This is a known limitation.
645
+ *
646
+ * Uses simple regex — no AST parse needed.
647
+ */
648
+ function scanSignalExports(code: string, moduleId: string, registry: Map<string, Set<string>>): void {
649
+ const normalizedId = normalizeModuleId(moduleId)
650
+ let match: RegExpExecArray | null
651
+ const signals = new Set<string>()
652
+
653
+ // Pattern 1: export const x = signal(...) or export const x = computed(...)
654
+ const EXPORT_CONST_RE = /export\s+const\s+(\w+)\s*=\s*(?:signal|computed)\s*[<(]/g
655
+ while ((match = EXPORT_CONST_RE.exec(code)) !== null) {
656
+ signals.add(match[1]!)
657
+ }
658
+
659
+ // Pattern 2: const x = signal(...) followed by export { x }
660
+ // First, find all local `const x = signal(` or `const x = computed(` declarations
661
+ const localSignals = new Set<string>()
662
+ const LOCAL_SIGNAL_RE = /(?:^|[\s;])const\s+(\w+)\s*=\s*(?:signal|computed)\s*[<(]/gm
663
+ while ((match = LOCAL_SIGNAL_RE.exec(code)) !== null) {
664
+ localSignals.add(match[1]!)
665
+ }
666
+
667
+ // Then check named exports: export { x, y as z }
668
+ if (localSignals.size > 0) {
669
+ const NAMED_EXPORT_RE = /export\s*\{([^}]+)\}/g
670
+ while ((match = NAMED_EXPORT_RE.exec(code)) !== null) {
671
+ // Skip re-exports (export { x } from '...')
672
+ const afterBrace = code.slice(match.index + match[0].length).trimStart()
673
+ if (afterBrace.startsWith('from')) continue
674
+
675
+ for (const spec of match[1]!.split(',')) {
676
+ const trimmed = spec.trim()
677
+ if (!trimmed) continue
678
+ const parts = trimmed.split(/\s+as\s+/)
679
+ const localName = parts[0]!.trim()
680
+ const exportedName = (parts[1] ?? parts[0])!.trim()
681
+ if (localSignals.has(localName)) {
682
+ signals.add(exportedName)
683
+ }
684
+ }
685
+ }
686
+ }
687
+
688
+ // Pattern 3: export default signal(...) or export default computed(...) — tracked as 'default'
689
+ if (/export\s+default\s+(?:signal|computed)\s*[<(]/.test(code)) {
690
+ signals.add('default')
691
+ }
692
+
693
+ if (signals.size > 0) {
694
+ registry.set(normalizedId, signals)
695
+ } else {
696
+ // Clean up if module no longer exports signals (e.g. after edit)
697
+ registry.delete(normalizedId)
698
+ }
699
+ }
700
+
701
+ /**
702
+ * Resolve imported signal names from the signal export registry.
703
+ *
704
+ * For each import in the source, resolves the module and checks if it has
705
+ * signal exports in the registry. Returns the local names of imported signals.
706
+ *
707
+ * Handles named imports (`import { x } from ...`) and default imports
708
+ * (`import x from ...` — matched against 'default' in the registry).
709
+ */
710
+ async function resolveImportedSignals(
711
+ code: string,
712
+ _moduleId: string,
713
+ registry: Map<string, Set<string>>,
714
+ pluginCtx: { resolve: (id: string, importer?: string, options?: { skipSelf: boolean }) => Promise<{ id: string } | null> },
715
+ resolveCache: Map<string, string | null>,
716
+ ): Promise<string[]> {
717
+ if (registry.size === 0) return []
718
+
719
+ const knownSignals: string[] = []
720
+ let match: RegExpExecArray | null
721
+
722
+ /** Resolve a source specifier to a normalized module ID, using the cache. */
723
+ async function resolveSource(source: string): Promise<string | null> {
724
+ const cacheKey = `${_moduleId}::${source}`
725
+ if (resolveCache.has(cacheKey)) return resolveCache.get(cacheKey) ?? null
726
+ let resolvedId: string | null = null
727
+ try {
728
+ const resolved = await pluginCtx.resolve(source, _moduleId, { skipSelf: true })
729
+ resolvedId = resolved?.id ? normalizeModuleId(resolved.id) : null
730
+ } catch {
731
+ /* resolve error */
732
+ }
733
+ resolveCache.set(cacheKey, resolvedId)
734
+ return resolvedId
735
+ }
736
+
737
+ // Named imports: import { name1, name2 as alias } from 'source'
738
+ // Excludes `import type { ... }` — type-only imports have no runtime value
739
+ const IMPORT_RE = /import\s+(?!type\s)\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g
740
+ while ((match = IMPORT_RE.exec(code)) !== null) {
741
+ const specifiers = match[1]!
742
+ const source = match[2]!
743
+
744
+ const resolvedId = await resolveSource(source)
745
+ if (!resolvedId) continue
746
+ const exportedSignals = registry.get(resolvedId)
747
+ if (!exportedSignals) continue
748
+
749
+ // Parse import specifiers: "count, theme as t, other"
750
+ for (const spec of specifiers.split(',')) {
751
+ const trimmed = spec.trim()
752
+ if (!trimmed) continue
753
+
754
+ const parts = trimmed.split(/\s+as\s+/)
755
+ const importedName = parts[0]!.trim()
756
+ const localName = (parts[1] ?? parts[0])!.trim()
757
+
758
+ if (exportedSignals.has(importedName)) {
759
+ knownSignals.push(localName)
760
+ }
761
+ }
762
+ }
763
+
764
+ // Default imports: import count from './store'
765
+ // Excludes: `import { ... }`, `import type X`, `import * as X`
766
+ const DEFAULT_IMPORT_RE = /import\s+(?!type\s)(\w+)\s+from\s*['"]([^'"]+)['"]/g
767
+ while ((match = DEFAULT_IMPORT_RE.exec(code)) !== null) {
768
+ // Skip if this is actually a `import type X from` pattern
769
+ const fullMatch = match[0]
770
+ if (/import\s+type\s+/.test(fullMatch)) continue
771
+
772
+ const localName = match[1]!
773
+ const source = match[2]!
774
+
775
+ const resolvedId = await resolveSource(source)
776
+ if (!resolvedId) continue
777
+ const exportedSignals = registry.get(resolvedId)
778
+ if (!exportedSignals) continue
779
+
780
+ if (exportedSignals.has('default')) {
781
+ knownSignals.push(localName)
782
+ }
783
+ }
784
+
785
+ return knownSignals
786
+ }
787
+
555
788
  const HMR_RUNTIME_SOURCE = `
556
789
  const REGISTRY_KEY = "__pyreon_hmr_registry__";
557
790
 
@@ -0,0 +1,178 @@
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 { describe, expect, it } from 'vitest'
9
+ import pyreonPlugin, { type PyreonPluginOptions } from '../index'
10
+
11
+ type ConfigHook = (
12
+ userConfig: Record<string, unknown>,
13
+ env: { command: string; isSsrBuild?: boolean },
14
+ ) => Record<string, unknown>
15
+
16
+ type ResolveIdCtx = {
17
+ resolve: (
18
+ id: string,
19
+ importer?: string,
20
+ options?: { skipSelf: boolean },
21
+ ) => Promise<{ id: string } | null>
22
+ }
23
+ type ResolveIdHook = (
24
+ this: ResolveIdCtx,
25
+ id: string,
26
+ importer?: string,
27
+ ) => Promise<string | undefined>
28
+
29
+ function bootstrap(opts?: PyreonPluginOptions) {
30
+ const plugin = pyreonPlugin(opts)
31
+ ;(plugin.config as unknown as ConfigHook)({}, { command: 'serve' })
32
+ return plugin
33
+ }
34
+
35
+ async function callResolveId(
36
+ plugin: ReturnType<typeof pyreonPlugin>,
37
+ id: string,
38
+ resolveMap: Record<string, string> = {},
39
+ ): Promise<string | undefined> {
40
+ const hook = plugin.resolveId as ResolveIdHook
41
+ return hook.call(
42
+ {
43
+ resolve: async (specifier: string) => {
44
+ const resolved = resolveMap[specifier]
45
+ return resolved ? { id: resolved } : null
46
+ },
47
+ },
48
+ id,
49
+ )
50
+ }
51
+
52
+ describe('compat-mode resolveId — react', () => {
53
+ it('redirects "react" → @pyreon/react-compat', async () => {
54
+ const plugin = bootstrap({ compat: 'react' })
55
+ const resolved = await callResolveId(plugin, 'react', {
56
+ '@pyreon/react-compat': '/abs/react-compat/index.ts',
57
+ })
58
+ expect(resolved).toBe('/abs/react-compat/index.ts')
59
+ })
60
+
61
+ it('redirects "react/jsx-runtime" → @pyreon/react-compat/jsx-runtime', async () => {
62
+ const plugin = bootstrap({ compat: 'react' })
63
+ const resolved = await callResolveId(plugin, 'react/jsx-runtime', {
64
+ '@pyreon/react-compat/jsx-runtime': '/abs/react-compat/jsx-runtime.ts',
65
+ })
66
+ expect(resolved).toBe('/abs/react-compat/jsx-runtime.ts')
67
+ })
68
+
69
+ it('redirects @pyreon/core/jsx-runtime → @pyreon/react-compat/jsx-runtime in react compat', async () => {
70
+ const plugin = bootstrap({ compat: 'react' })
71
+ const resolved = await callResolveId(plugin, '@pyreon/core/jsx-runtime', {
72
+ '@pyreon/react-compat/jsx-runtime': '/abs/react-compat/jsx-runtime.ts',
73
+ })
74
+ expect(resolved).toBe('/abs/react-compat/jsx-runtime.ts')
75
+ })
76
+
77
+ it('redirects @pyreon/core/jsx-dev-runtime in react compat', async () => {
78
+ const plugin = bootstrap({ compat: 'react' })
79
+ const resolved = await callResolveId(plugin, '@pyreon/core/jsx-dev-runtime', {
80
+ '@pyreon/react-compat/jsx-runtime': '/abs/react-compat/jsx-runtime.ts',
81
+ })
82
+ expect(resolved).toBe('/abs/react-compat/jsx-runtime.ts')
83
+ })
84
+
85
+ it('returns undefined for non-aliased imports', async () => {
86
+ const plugin = bootstrap({ compat: 'react' })
87
+ const resolved = await callResolveId(plugin, 'lodash', {})
88
+ expect(resolved).toBeUndefined()
89
+ })
90
+ })
91
+
92
+ describe('compat-mode resolveId — preact', () => {
93
+ it('redirects "preact" → @pyreon/preact-compat', async () => {
94
+ const plugin = bootstrap({ compat: 'preact' })
95
+ const resolved = await callResolveId(plugin, 'preact', {
96
+ '@pyreon/preact-compat': '/abs/preact-compat/index.ts',
97
+ })
98
+ expect(resolved).toBe('/abs/preact-compat/index.ts')
99
+ })
100
+
101
+ it('redirects "preact/hooks" → @pyreon/preact-compat/hooks', async () => {
102
+ const plugin = bootstrap({ compat: 'preact' })
103
+ const resolved = await callResolveId(plugin, 'preact/hooks', {
104
+ '@pyreon/preact-compat/hooks': '/abs/preact-compat/hooks.ts',
105
+ })
106
+ expect(resolved).toBe('/abs/preact-compat/hooks.ts')
107
+ })
108
+
109
+ it('redirects @pyreon/core/jsx-runtime in preact compat', async () => {
110
+ const plugin = bootstrap({ compat: 'preact' })
111
+ const resolved = await callResolveId(plugin, '@pyreon/core/jsx-runtime', {
112
+ '@pyreon/preact-compat/jsx-runtime': '/abs/preact-compat/jsx-runtime.ts',
113
+ })
114
+ expect(resolved).toBe('/abs/preact-compat/jsx-runtime.ts')
115
+ })
116
+
117
+ it('redirects @preact/signals → @pyreon/preact-compat/signals', async () => {
118
+ const plugin = bootstrap({ compat: 'preact' })
119
+ const resolved = await callResolveId(plugin, '@preact/signals', {
120
+ '@pyreon/preact-compat/signals': '/abs/preact-compat/signals.ts',
121
+ })
122
+ expect(resolved).toBe('/abs/preact-compat/signals.ts')
123
+ })
124
+ })
125
+
126
+ describe('compat-mode resolveId — vue', () => {
127
+ it('redirects "vue" → @pyreon/vue-compat', async () => {
128
+ const plugin = bootstrap({ compat: 'vue' })
129
+ const resolved = await callResolveId(plugin, 'vue', {
130
+ '@pyreon/vue-compat': '/abs/vue-compat/index.ts',
131
+ })
132
+ expect(resolved).toBe('/abs/vue-compat/index.ts')
133
+ })
134
+
135
+ it('redirects @pyreon/core/jsx-runtime in vue compat', async () => {
136
+ const plugin = bootstrap({ compat: 'vue' })
137
+ const resolved = await callResolveId(plugin, '@pyreon/core/jsx-runtime', {
138
+ '@pyreon/vue-compat/jsx-runtime': '/abs/vue-compat/jsx-runtime.ts',
139
+ })
140
+ expect(resolved).toBe('/abs/vue-compat/jsx-runtime.ts')
141
+ })
142
+ })
143
+
144
+ describe('compat-mode resolveId — solid', () => {
145
+ it('redirects "solid-js" → @pyreon/solid-compat', async () => {
146
+ const plugin = bootstrap({ compat: 'solid' })
147
+ const resolved = await callResolveId(plugin, 'solid-js', {
148
+ '@pyreon/solid-compat': '/abs/solid-compat/index.ts',
149
+ })
150
+ expect(resolved).toBe('/abs/solid-compat/index.ts')
151
+ })
152
+
153
+ it('redirects @pyreon/core/jsx-runtime in solid compat', async () => {
154
+ const plugin = bootstrap({ compat: 'solid' })
155
+ const resolved = await callResolveId(plugin, '@pyreon/core/jsx-runtime', {
156
+ '@pyreon/solid-compat/jsx-runtime': '/abs/solid-compat/jsx-runtime.ts',
157
+ })
158
+ expect(resolved).toBe('/abs/solid-compat/jsx-runtime.ts')
159
+ })
160
+ })
161
+
162
+ describe('compat-mode resolveId — no compat', () => {
163
+ it('returns undefined for any framework alias when compat is unset', async () => {
164
+ const plugin = bootstrap()
165
+ expect(await callResolveId(plugin, 'react', {})).toBeUndefined()
166
+ expect(await callResolveId(plugin, 'vue', {})).toBeUndefined()
167
+ expect(await callResolveId(plugin, 'preact', {})).toBeUndefined()
168
+ expect(await callResolveId(plugin, 'solid-js', {})).toBeUndefined()
169
+ })
170
+
171
+ it('still resolves the HMR runtime virtual id (independent of compat)', async () => {
172
+ const plugin = bootstrap()
173
+ const resolved = await callResolveId(plugin, 'virtual:pyreon/hmr-runtime', {})
174
+ // Internal ID — has the leading '\0' marker convention or similar
175
+ expect(resolved).toBeDefined()
176
+ expect(typeof resolved).toBe('string')
177
+ })
178
+ })