@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.
@@ -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":"92b4c3bc-1"}]}],"isRoot":true},"nodeParts":{"92b4c3bc-1":{"renderedLength":19400,"gzipLength":6492,"brotliLength":0,"metaUid":"92b4c3bc-0"}},"nodeMetas":{"92b4c3bc-0":{"id":"/src/index.ts","moduleParts":{"index.js":"92b4c3bc-1"},"imported":[{"uid":"92b4c3bc-2"},{"uid":"92b4c3bc-3"},{"uid":"92b4c3bc-4"}],"importedBy":[],"isEntry":true},"92b4c3bc-2":{"id":"node:fs","moduleParts":{},"imported":[],"importedBy":[{"uid":"92b4c3bc-0"}]},"92b4c3bc-3":{"id":"node:path","moduleParts":{},"imported":[],"importedBy":[{"uid":"92b4c3bc-0"}]},"92b4c3bc-4":{"id":"@pyreon/compiler","moduleParts":{},"imported":[],"importedBy":[{"uid":"92b4c3bc-0"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
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
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { dirname, join } from "node:path";
3
3
  import { generateContext, transformJSX } from "@pyreon/compiler";
4
4
 
5
5
  //#region src/index.ts
@@ -38,12 +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 COMPAT_JSX_SOURCE = {
42
- react: "@pyreon/react-compat",
43
- preact: "@pyreon/preact-compat",
44
- vue: "@pyreon/vue-compat",
45
- solid: "@pyreon/solid-compat"
46
- };
41
+ const ISLANDS_REGISTRY_ID = "\0pyreon/islands-registry";
42
+ const ISLANDS_REGISTRY_IMPORT = "virtual:pyreon/islands-registry";
47
43
  const COMPAT_ALIASES = {
48
44
  react: {
49
45
  react: "@pyreon/react-compat",
@@ -71,6 +67,59 @@ const COMPAT_ALIASES = {
71
67
  }
72
68
  };
73
69
  /**
70
+ * Detect whether a file id resolves to a `@pyreon/*` framework-package source
71
+ * (i.e. a published Pyreon package whose .tsx is being pulled in via the
72
+ * `bun` condition workspace-link, NOT user code, NOT an example app).
73
+ *
74
+ * Why this exists: in compat mode, OXC's per-project `importSource` is set
75
+ * to `@pyreon/core` and the resolveId hook redirects `@pyreon/core/jsx-runtime`
76
+ * to the compat package. That's correct for user code (the whole point of
77
+ * compat mode) but WRONG for framework-internal sources like
78
+ * `@pyreon/zero/src/link.tsx`, which need the real `@pyreon/core` runtime.
79
+ * The fix skips the redirect when the importer is a `@pyreon/*` framework
80
+ * file. Result: published-package consumers (where `@pyreon/zero` resolves
81
+ * to its pre-built `lib/`) and workspace-dev consumers (where it resolves
82
+ * to source) both get correct JSX runtime resolution.
83
+ *
84
+ * Detection heuristic: walk to nearest `package.json`, require BOTH:
85
+ * 1. `name` starts with `@pyreon/` (workspace member of the @pyreon scope)
86
+ * 2. file path contains `/packages/` AND NOT `/examples/`
87
+ *
88
+ * Step 2 excludes the existing `@pyreon/example-{react,vue,solid,preact}-compat`
89
+ * apps under `examples/`. Without it, user code in those apps would skip the
90
+ * compat-mode JSX-runtime redirect and import `@pyreon/core/jsx-runtime`
91
+ * directly — breaking the React/Vue/Solid/Preact compat layer's contract.
92
+ *
93
+ * Result cached per directory. The `/packages/` + `/examples/` check is a
94
+ * structural property of the monorepo (workspace layout), not the package
95
+ * name — so it's robust against renames.
96
+ */
97
+ function isPyreonWorkspaceFile(id, cache) {
98
+ const queryIdx = id.indexOf("?");
99
+ const filePath = queryIdx === -1 ? id : id.slice(0, queryIdx);
100
+ if (!filePath || filePath[0] === "\0") return false;
101
+ if (!filePath.includes("/packages/") || filePath.includes("/examples/")) return false;
102
+ let dir = dirname(filePath);
103
+ for (let i = 0; i < 12; i++) {
104
+ const cached = cache.get(dir);
105
+ if (cached !== void 0) return cached;
106
+ const pkgPath = join(dir, "package.json");
107
+ if (existsSync(pkgPath)) {
108
+ let isPyreon = false;
109
+ try {
110
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
111
+ isPyreon = typeof pkg.name === "string" && pkg.name.startsWith("@pyreon/");
112
+ } catch {}
113
+ cache.set(dir, isPyreon);
114
+ return isPyreon;
115
+ }
116
+ const parent = dirname(dir);
117
+ if (parent === dir) break;
118
+ dir = parent;
119
+ }
120
+ return false;
121
+ }
122
+ /**
74
123
  * Return the Pyreon compat target for an import specifier, or undefined if
75
124
  * the import should not be redirected.
76
125
  */
@@ -85,27 +134,56 @@ function getCompatTarget(compat, id) {
85
134
  if (compat === "solid") return "@pyreon/solid-compat/jsx-runtime";
86
135
  }
87
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
+ }
88
163
  function pyreonPlugin(options) {
89
164
  const ssrConfig = options?.ssr;
90
165
  const compat = options?.compat;
166
+ const islandsEnabled = options?.islands !== false;
91
167
  let isBuild = false;
92
168
  let projectRoot = "";
93
169
  const signalExportRegistry = /* @__PURE__ */ new Map();
94
170
  const resolveCache = /* @__PURE__ */ new Map();
171
+ const pyreonWorkspaceDirCache = /* @__PURE__ */ new Map();
172
+ const islandRegistry = /* @__PURE__ */ new Map();
95
173
  return {
96
174
  name: "pyreon",
97
175
  enforce: "pre",
98
176
  config(userConfig, env) {
99
177
  isBuild = env.command === "build";
100
178
  projectRoot = userConfig.root ?? process.cwd();
101
- const optimizeDepsExclude = compat ? Object.keys(COMPAT_ALIASES[compat]) : [];
102
- const jsxSource = compat ? COMPAT_JSX_SOURCE[compat] : "@pyreon/core";
179
+ const compatExclude = compat ? Object.keys(COMPAT_ALIASES[compat]) : [];
180
+ const pyreonExclude = scanPyreonDeps(projectRoot);
103
181
  return {
104
182
  resolve: { conditions: ["bun"] },
105
- optimizeDeps: { exclude: optimizeDepsExclude },
183
+ optimizeDeps: { exclude: Array.from(new Set([...compatExclude, ...pyreonExclude])) },
106
184
  oxc: { jsx: {
107
185
  runtime: "automatic",
108
- importSource: jsxSource
186
+ importSource: "@pyreon/core"
109
187
  } },
110
188
  ...env.isSsrBuild && ssrConfig ? { build: {
111
189
  ssr: true,
@@ -115,15 +193,19 @@ function pyreonPlugin(options) {
115
193
  },
116
194
  async buildStart() {
117
195
  await prescanSignalExports(projectRoot, signalExportRegistry);
196
+ if (islandsEnabled) await prescanIslandDeclarations(projectRoot, islandRegistry);
118
197
  },
119
198
  async resolveId(id, importer) {
120
199
  if (id === HMR_RUNTIME_IMPORT) return HMR_RUNTIME_ID;
200
+ if (id === ISLANDS_REGISTRY_IMPORT) return ISLANDS_REGISTRY_ID;
201
+ if (compat && (id === "@pyreon/core/jsx-runtime" || id === "@pyreon/core/jsx-dev-runtime") && importer && isPyreonWorkspaceFile(importer, pyreonWorkspaceDirCache)) return;
121
202
  const target = getCompatTarget(compat, id);
122
203
  if (!target) return;
123
204
  return (await this.resolve(target, importer, { skipSelf: true }))?.id;
124
205
  },
125
206
  load(id) {
126
207
  if (id === HMR_RUNTIME_ID) return HMR_RUNTIME_SOURCE;
208
+ if (id === ISLANDS_REGISTRY_ID) return renderIslandsRegistry(islandRegistry, islandsEnabled);
127
209
  },
128
210
  async transform(code, id, transformOptions) {
129
211
  const ext = getExt(id);
@@ -139,6 +221,7 @@ function pyreonPlugin(options) {
139
221
  return;
140
222
  }
141
223
  scanSignalExports(code, normalizeModuleId(id), signalExportRegistry);
224
+ if (islandsEnabled) scanIslandDeclarations(code, id, islandRegistry);
142
225
  const knownSignals = await resolveImportedSignals(code, id, signalExportRegistry, this, resolveCache);
143
226
  const result = transformJSX(code, id, {
144
227
  ssr: transformOptions?.ssr === true,
@@ -222,8 +305,18 @@ function generateProjectContext(root) {
222
305
  * The arguments are extracted via balanced-paren matching in `injectHmr`.
223
306
  * A brace-depth check filters out matches inside functions/blocks — only
224
307
  * module-scope (depth 0) signals are rewritten for HMR state preservation.
308
+ *
309
+ * The optional `<...>` group accepts a TypeScript type parameter so that
310
+ * `signal<T>(initial)` declarations are also rewritten — without it, any
311
+ * generic-typed module-scope signal silently skipped HMR preservation.
312
+ *
313
+ * The inner `(?:[^<>]|<[^<>]*>)*` permits one level of generic nesting
314
+ * (e.g. `signal<Array<Row>>([])`, `signal<Map<string, number>>(m)`).
315
+ * Deeper nesting (`signal<Array<{ id: T<U> }>>(...)`) falls back to
316
+ * not-rewritten — tracked as a follow-up if real consumers need it,
317
+ * but unlikely at module scope where generics are usually shallow.
225
318
  */
226
- const SIGNAL_PREFIX_RE = /^((?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*)signal\(/gm;
319
+ const SIGNAL_PREFIX_RE = /^((?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*)signal(?:<(?:[^<>]|<[^<>]*>)*>)?\(/gm;
227
320
  /**
228
321
  * Detect whether the module exports any component-like functions
229
322
  * (uppercase first letter — standard convention for JSX components).
@@ -385,6 +478,129 @@ function normalizeModuleId(id) {
385
478
  return queryIndex >= 0 ? id.slice(0, queryIndex) : id;
386
479
  }
387
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
+ /**
388
604
  * Pre-scan all source files in the project for signal exports.
389
605
  *
390
606
  * Called from `buildStart` so the registry is fully populated before any
@@ -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.14.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": {
@@ -14,6 +14,7 @@
14
14
  },
15
15
  "files": [
16
16
  "lib",
17
+ "!lib/**/*.map",
17
18
  "src",
18
19
  "README.md",
19
20
  "LICENSE"
@@ -42,7 +43,7 @@
42
43
  "prepublishOnly": "bun run build"
43
44
  },
44
45
  "dependencies": {
45
- "@pyreon/compiler": "^0.14.0"
46
+ "@pyreon/compiler": "^0.16.0"
46
47
  },
47
48
  "devDependencies": {
48
49
  "vite": "^8.0.0"