@pyreon/vite-plugin 0.18.0 → 0.20.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
@@ -34,9 +34,32 @@
34
34
 
35
35
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
36
36
  import { dirname, join as pathJoin } from 'node:path'
37
- import { generateContext, transformDeferInline, transformJSX } from '@pyreon/compiler'
37
+ import {
38
+ type CollapsibleSite,
39
+ generateContext,
40
+ scanCollapsibleSites,
41
+ transformDeferInline,
42
+ transformJSX,
43
+ } from '@pyreon/compiler'
44
+ import type { CollapseResolver } from './rocketstyle-collapse'
38
45
  import type { Plugin, ViteDevServer } from 'vite'
39
46
 
47
+ // Lazy — the resolver module (and its `vite` SSR machinery) must NOT be
48
+ // on the static import path of this cheap entry. It loads ONLY when
49
+ // `pyreon({ collapse })` is enabled AND a collapsible site is scanned;
50
+ // collapse-off consumers never pull it (bundle-budget + cold-load).
51
+ let _createCollapseResolver:
52
+ | ((root: string) => Promise<CollapseResolver>)
53
+ | null = null
54
+ async function loadCreateCollapseResolver(): Promise<
55
+ (root: string) => Promise<CollapseResolver>
56
+ > {
57
+ if (!_createCollapseResolver) {
58
+ _createCollapseResolver = (await import('./rocketstyle-collapse')).createCollapseResolver
59
+ }
60
+ return _createCollapseResolver
61
+ }
62
+
40
63
  // Virtual module ID for the HMR runtime
41
64
  const HMR_RUNTIME_ID = '\0pyreon/hmr-runtime'
42
65
  const HMR_RUNTIME_IMPORT = 'virtual:pyreon/hmr-runtime'
@@ -47,7 +70,7 @@ const HMR_RUNTIME_IMPORT = 'virtual:pyreon/hmr-runtime'
47
70
  const ISLANDS_REGISTRY_ID = '\0pyreon/islands-registry'
48
71
  const ISLANDS_REGISTRY_IMPORT = 'virtual:pyreon/islands-registry'
49
72
 
50
- export type CompatFramework = 'react' | 'preact' | 'vue' | 'solid'
73
+ export type CompatFramework = 'react' | 'preact' | 'vue' | 'solid' | 'svelte'
51
74
 
52
75
  export interface PyreonPluginOptions {
53
76
  /**
@@ -60,6 +83,7 @@ export interface PyreonPluginOptions {
60
83
  * pyreon({ compat: "react" }) // react + react-dom → @pyreon/react-compat
61
84
  * pyreon({ compat: "vue" }) // vue → @pyreon/vue-compat
62
85
  * pyreon({ compat: "solid" }) // solid-js → @pyreon/solid-compat
86
+ * pyreon({ compat: "svelte" }) // svelte + svelte/store → @pyreon/svelte-compat
63
87
  * pyreon({ compat: "preact" }) // preact + hooks + signals → @pyreon/preact-compat
64
88
  */
65
89
  compat?: CompatFramework
@@ -105,6 +129,45 @@ export interface PyreonPluginOptions {
105
129
  * hydrateIslandsAuto()
106
130
  */
107
131
  islands?: boolean
132
+
133
+ /**
134
+ * P0 — opt-in compile-time rocketstyle wrapper collapse. `true` uses
135
+ * the default provider/theme/mode wiring (PyreonUI + theme +
136
+ * useMode from @pyreon/ui-core / @pyreon/ui-theme). Pass an object to
137
+ * override. OFF by default (zero behaviour change). When on, the
138
+ * plugin SSR-resolves every literal-prop call site of a candidate
139
+ * component (real component, light + dark) and the compiler collapses
140
+ * the 5-layer wrapper mount into a single `_rsCollapse` cloneNode.
141
+ * Only the CLIENT graph is collapsed — the SSR graph keeps the normal
142
+ * mount (and the resolver itself uses SSR render).
143
+ *
144
+ * @example pyreon({ collapse: true })
145
+ * @example pyreon({ collapse: { components: ['Button', 'Badge'] } })
146
+ */
147
+ collapse?: boolean | PyreonCollapseOptions
148
+ }
149
+
150
+ export interface PyreonCollapseOptions {
151
+ /**
152
+ * Import sources whose components may collapse. Default:
153
+ * `['@pyreon/ui-components']`. The compiler's AST scan only considers
154
+ * a call site whose component was imported from one of these sources;
155
+ * the conservative bail catalogue + the SSR resolver are the real
156
+ * gate beyond that.
157
+ */
158
+ sources?: string[]
159
+ /**
160
+ * Optional local-name allowlist applied AFTER the source scan
161
+ * (e.g. `['Button']`). Omit to collapse every collapsible component
162
+ * from the configured sources.
163
+ */
164
+ components?: string[]
165
+ /** Override the theme/mode provider. Default PyreonUI@@pyreon/ui-core. */
166
+ provider?: { name: string; source: string }
167
+ /** Override the theme object. Default theme@@pyreon/ui-theme. */
168
+ theme?: { name: string; source: string }
169
+ /** Override the live mode accessor. Default useMode@@pyreon/ui-core. */
170
+ mode?: { name: string; source: string }
108
171
  }
109
172
 
110
173
  // ── Compat alias maps ─────────────────────────────────────────────────────────
@@ -134,6 +197,13 @@ const COMPAT_ALIASES: Record<CompatFramework, Record<string, string>> = {
134
197
  'solid-js/jsx-runtime': '@pyreon/solid-compat/jsx-runtime',
135
198
  'solid-js/jsx-dev-runtime': '@pyreon/solid-compat/jsx-runtime',
136
199
  },
200
+ svelte: {
201
+ svelte: '@pyreon/svelte-compat',
202
+ 'svelte/store': '@pyreon/svelte-compat/store',
203
+ 'svelte/internal': '@pyreon/svelte-compat',
204
+ 'svelte/jsx-runtime': '@pyreon/svelte-compat/jsx-runtime',
205
+ 'svelte/jsx-dev-runtime': '@pyreon/svelte-compat/jsx-runtime',
206
+ },
137
207
  }
138
208
 
139
209
  /**
@@ -218,6 +288,7 @@ function getCompatTarget(compat: CompatFramework | undefined, id: string): strin
218
288
  if (compat === 'preact') return '@pyreon/preact-compat/jsx-runtime'
219
289
  if (compat === 'vue') return '@pyreon/vue-compat/jsx-runtime'
220
290
  if (compat === 'solid') return '@pyreon/solid-compat/jsx-runtime'
291
+ if (compat === 'svelte') return '@pyreon/svelte-compat/jsx-runtime'
221
292
  }
222
293
  return undefined
223
294
  }
@@ -260,7 +331,60 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
260
331
  // module is harmless if the user has no `island()` calls. Opt out only if
261
332
  // you have a specific reason.
262
333
  const islandsEnabled = options?.islands !== false
334
+
335
+ // ── P0 rocketstyle-collapse config (opt-in) ───────────────────────────────
336
+ const collapseOpt = options?.collapse
337
+ const collapseEnabled = collapseOpt === true || (collapseOpt != null && collapseOpt !== false)
338
+ const collapseUserCfg: PyreonCollapseOptions =
339
+ collapseOpt && collapseOpt !== true ? collapseOpt : {}
340
+ const collapseProvider = collapseUserCfg.provider ?? {
341
+ name: 'PyreonUI',
342
+ source: '@pyreon/ui-core',
343
+ }
344
+ const collapseTheme = collapseUserCfg.theme ?? { name: 'theme', source: '@pyreon/ui-theme' }
345
+ const collapseMode = collapseUserCfg.mode ?? { name: 'useMode', source: '@pyreon/ui-core' }
346
+ const collapseSources = new Set(collapseUserCfg.sources ?? ['@pyreon/ui-components'])
347
+ const collapseComponentFilter = collapseUserCfg.components
348
+ ? (n: string) => collapseUserCfg.components!.includes(n)
349
+ : null
350
+ // Lazily created on first client-graph transform; one Vite SSR server
351
+ // reused for every resolve in the build. Disposed in closeBundle.
352
+ let collapseResolver: import('./rocketstyle-collapse').CollapseResolver | null = null
353
+ let collapseResolverInit: Promise<
354
+ import('./rocketstyle-collapse').CollapseResolver | null
355
+ > | null = null
356
+
357
+ /**
358
+ * Lazily spin ONE programmatic Vite SSR server (bound to the project's
359
+ * own vite config) the first time a client-graph module actually has a
360
+ * collapsible call site. Memoized via `collapseResolverInit` so
361
+ * concurrent transforms share the single server. Returns null if the
362
+ * server fails to start (graceful — every call site then keeps its
363
+ * normal rocketstyle mount).
364
+ */
365
+ function ensureCollapseResolver(): Promise<
366
+ import('./rocketstyle-collapse').CollapseResolver | null
367
+ > {
368
+ if (collapseResolver) return Promise.resolve(collapseResolver)
369
+ if (collapseResolverInit) return collapseResolverInit
370
+ collapseResolverInit = loadCreateCollapseResolver()
371
+ .then((create) => create(projectRoot))
372
+ .then((r) => {
373
+ collapseResolver = r
374
+ return r
375
+ })
376
+ .catch(() => null)
377
+ return collapseResolverInit
378
+ }
379
+
263
380
  let isBuild = false
381
+ // Collapse is build-only by design: the resolver computes each site's
382
+ // class from a SEPARATE nested Vite SSR server's module graph and caches
383
+ // it. In dev that frozen class would NOT react to the user's theme-source
384
+ // HMR edits — strictly worse than the normal mount, which IS reactive.
385
+ // So dev keeps the normal mount; we surface that ONCE so an opted-in
386
+ // consumer running `vite dev` isn't left wondering why nothing collapsed.
387
+ let warnedDevCollapse = false
264
388
  let projectRoot = ''
265
389
 
266
390
  // ── Cross-module signal export registry ─────────────────────────────────
@@ -364,6 +488,16 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
364
488
  }
365
489
  },
366
490
 
491
+ // Tear down the one programmatic Vite SSR server the collapse
492
+ // resolver holds (created lazily on first client-graph transform).
493
+ async closeBundle() {
494
+ if (collapseResolver) {
495
+ await collapseResolver.dispose()
496
+ collapseResolver = null
497
+ collapseResolverInit = null
498
+ }
499
+ },
500
+
367
501
  // ── Virtual module + compat alias resolution ─────────────────────────────
368
502
  async resolveId(id, importer) {
369
503
  if (id === HMR_RUNTIME_IMPORT) return HMR_RUNTIME_ID
@@ -409,7 +543,13 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
409
543
  // In compat mode, skip Pyreon's reactive JSX transform but apply
410
544
  // attribute renames (className → class, htmlFor → for) so source code
411
545
  // that uses React-style attribute names works correctly.
412
- if (compat === 'react' || compat === 'preact' || compat === 'vue' || compat === 'solid') {
546
+ if (
547
+ compat === 'react' ||
548
+ compat === 'preact' ||
549
+ compat === 'vue' ||
550
+ compat === 'solid' ||
551
+ compat === 'svelte'
552
+ ) {
413
553
  if (compat === 'react' || compat === 'preact') {
414
554
  const transformed = transformCompatAttributes(code)
415
555
  if (transformed !== code) return { code: transformed, map: null }
@@ -453,7 +593,77 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
453
593
  // (both build --ssr and dev `ssrLoadModule`). The compiler emits plain
454
594
  // `h()` calls in that mode so `runtime-server` can render to a string.
455
595
  const isSsr = transformOptions?.ssr === true
456
- const result = transformJSX(sourceForJsx, id, { ssr: isSsr, knownSignals })
596
+
597
+ // ── P0 rocketstyle-collapse (opt-in, CLIENT graph only) ────────────
598
+ // Never collapse the SSR graph: renderToString needs the real
599
+ // VNode tree, AND the resolver itself SSR-renders the component —
600
+ // collapsing the SSR graph would be circular. Resolve every
601
+ // scanned literal-prop site once (real component, light + dark)
602
+ // and hand the compiler a key→emission map; the compiler's AST
603
+ // bail catalogue is the real gate, an unresolved key just falls
604
+ // back to the normal mount.
605
+ let collapseRocketstyle:
606
+ | NonNullable<Parameters<typeof transformJSX>[2]>['collapseRocketstyle']
607
+ | undefined
608
+ if (collapseEnabled && !isBuild && !isSsr && !warnedDevCollapse) {
609
+ warnedDevCollapse = true
610
+ this.info(
611
+ '[Pyreon] collapse is build-only — `vite dev` keeps the normal rocketstyle mount so theme-source edits stay HMR-reactive. Production `vite build` collapses the literal-prop sites.',
612
+ )
613
+ }
614
+ if (collapseEnabled && isBuild && !isSsr) {
615
+ const scanned: CollapsibleSite[] = scanCollapsibleSites(
616
+ sourceForJsx,
617
+ id,
618
+ collapseSources,
619
+ ).filter((s) => !collapseComponentFilter || collapseComponentFilter(s.componentName))
620
+ if (scanned.length > 0) {
621
+ const resolver = await ensureCollapseResolver()
622
+ if (resolver) {
623
+ const sites = new Map<
624
+ string,
625
+ {
626
+ templateHtml: string
627
+ lightClass: string
628
+ darkClass: string
629
+ rules: string[]
630
+ ruleKey: string
631
+ }
632
+ >()
633
+ const candidates = new Set<string>()
634
+ for (const s of scanned) {
635
+ const resolved = await resolver.resolve({
636
+ component: { name: s.importedName, source: s.source },
637
+ props: s.props,
638
+ childrenText: s.childrenText,
639
+ config: {
640
+ provider: collapseProvider,
641
+ theme: collapseTheme,
642
+ mode: collapseMode,
643
+ },
644
+ })
645
+ if (!resolved) continue
646
+ candidates.add(s.componentName)
647
+ sites.set(s.key, {
648
+ templateHtml: resolved.templateHtml,
649
+ lightClass: resolved.lightClass,
650
+ darkClass: resolved.darkClass,
651
+ rules: resolved.rules,
652
+ ruleKey: resolved.key,
653
+ })
654
+ }
655
+ if (sites.size > 0) {
656
+ collapseRocketstyle = { candidates, sites, mode: collapseMode }
657
+ }
658
+ }
659
+ }
660
+ }
661
+
662
+ const result = transformJSX(sourceForJsx, id, {
663
+ ssr: isSsr,
664
+ knownSignals,
665
+ ...(collapseRocketstyle ? { collapseRocketstyle } : {}),
666
+ })
457
667
  // Surface compiler warnings in the terminal
458
668
  for (const w of result.warnings) {
459
669
  this.warn(`${w.message} (${id}:${w.line}:${w.column})`)
@@ -468,7 +678,15 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
468
678
  output = injectSignalNames(output)
469
679
  }
470
680
 
471
- return { code: output, map: null }
681
+ // R12: surface the compiler's V3 source map so stack traces /
682
+ // breakpoints in Pyreon components resolve to the right source line
683
+ // (the JS backend now emits one; substitutions shift line counts, so
684
+ // `map: null` previously mislocated every frame app-wide). Exact in
685
+ // build; in dev the small extra HMR / signal-name injections aren't
686
+ // re-mapped (still vastly better than no map). The native backend
687
+ // emits no map yet (its own scoped follow-up) → `null`, unchanged
688
+ // behaviour for that path.
689
+ return { code: output, map: result.map ?? null }
472
690
  },
473
691
 
474
692
  // ── SSR dev middleware ───────────────────────────────────────────────────
@@ -745,7 +963,40 @@ function injectHmr(code: string, moduleId: string): string {
745
963
  lines.push(` import.meta.hot.dispose(() => __hmr_dispose(${escapedId}));`)
746
964
  }
747
965
 
748
- lines.push(` import.meta.hot.accept();`)
966
+ // Self-accept the module, then drive Pyreon's HMR coordinator.
967
+ //
968
+ // The OLD code emitted a bare `import.meta.hot.accept()` (no callback):
969
+ // Vite re-evaluated the module but NOTHING re-rendered the mounted tree,
970
+ // AND the self-accept suppressed Vite's full-reload fallback — so a
971
+ // component/JSX edit produced a silently-stale UI until a MANUAL refresh.
972
+ //
973
+ // Now: the accept callback hands the FRESH module namespace Vite already
974
+ // re-evaluated straight to `globalThis.__pyreon_hmr_swap__` (registered
975
+ // by `@pyreon/router` in a dev browser — zero import coupling, same
976
+ // pattern as the perf-harness counter sink), keyed by THIS module's id.
977
+ // The coordinator finds every active matched route record whose lazy
978
+ // `_hmrId` matches and swaps in the new component, re-rendering ONLY
979
+ // that subtree IN PLACE (no page reload → `__pyreon_hmr_registry__`
980
+ // survives → `__hmr_signal` restores module-scope signal values).
981
+ //
982
+ // Using the namespace Vite passes (not a re-run of the lazy thunk)
983
+ // sidesteps the stale-`?t=` trap: the dynamic-import thunk lives in the
984
+ // virtual routes module, which is NOT invalidated when this leaf route
985
+ // self-accepts — re-importing it would return the OLD module.
986
+ //
987
+ // `__pyreon_hmr_swap__` returns falsy when the edit was outside the
988
+ // active route tree (nested non-route component, unrelated route,
989
+ // signal-only module) OR no coordinator is registered (plain
990
+ // `@pyreon/runtime-dom` app, or module loaded before any router
991
+ // mounted). Then `import.meta.hot.invalidate()` → Vite propagates → an
992
+ // AUTOMATIC full reload. Either way the user never refreshes by hand.
993
+ lines.push(` import.meta.hot.accept((__m) => {`)
994
+ lines.push(` const __s = globalThis.__pyreon_hmr_swap__;`)
995
+ lines.push(
996
+ ` if (typeof __s === "function" && __m && __s(${escapedId}, __m)) return;`,
997
+ )
998
+ lines.push(` import.meta.hot.invalidate();`)
999
+ lines.push(` });`)
749
1000
  lines.push(`}`)
750
1001
 
751
1002
  output = `${output}\n\n${lines.join('\n')}\n`
@@ -0,0 +1,199 @@
1
+ /**
2
+ * P0 — build-time rocketstyle-collapse resolver.
3
+ *
4
+ * For a collapsible call site (`<Button state="primary" size="md">Save</Button>`
5
+ * — every dimension prop a string literal, children static text) this
6
+ * resolves the FULL rocketstyle/styler pipeline ONCE by SSR-rendering the
7
+ * REAL component, light AND dark, and returns: the resolved styler class
8
+ * per mode, the styler rule text, and a class-stripped `_tpl` template.
9
+ *
10
+ * The render runs through a programmatic Vite SSR server bound to the
11
+ * CONSUMER's own `vite.config` — so module resolution is identical to
12
+ * the app's real build (workspace `bun` condition, app aliases,
13
+ * app-local relative imports, whatever). Parity with the runtime-mounted
14
+ * class is then guaranteed BY CONSTRUCTION: it is literally the same
15
+ * `renderToString` + `@pyreon/styler` code path the client uses, and
16
+ * styler's FNV-1a class hashing is identical in SSR and DOM (styler's
17
+ * hydration contract). No reimplementation, no closure re-execution, no
18
+ * drift (RFC decision 2).
19
+ *
20
+ * Every failure returns `null` (graceful bail → the call site keeps its
21
+ * normal rocketstyle mount). Correct-but-slow is acceptable; wrong
22
+ * output is not.
23
+ */
24
+ import type { InlineConfig, ViteDevServer } from 'vite'
25
+
26
+ // Inline FNV-1a (same algorithm as @pyreon/styler/hash) — avoids pulling
27
+ // the styler module graph into the vite-plugin's cheap entry path.
28
+ function fnv1a(str: string): string {
29
+ let h = 2166136261
30
+ for (let i = 0; i < str.length; i++) {
31
+ h ^= str.charCodeAt(i)
32
+ h = Math.imul(h, 16777619)
33
+ }
34
+ return (h >>> 0).toString(36)
35
+ }
36
+
37
+ export interface CollapseImportSpec {
38
+ /** Imported binding name, e.g. `PyreonUI` / `theme` / `useMode`. */
39
+ name: string
40
+ /** Module specifier, e.g. `@pyreon/ui-core` / `@pyreon/ui-theme`. */
41
+ source: string
42
+ }
43
+
44
+ export interface CollapseConfig {
45
+ /** Theme/mode provider component. Default: PyreonUI from @pyreon/ui-core. */
46
+ provider: CollapseImportSpec
47
+ /** Theme object. Default: theme from @pyreon/ui-theme. */
48
+ theme: CollapseImportSpec
49
+ /** Live mode accessor — emitted into the collapsed site for dual-emit. */
50
+ mode: CollapseImportSpec
51
+ }
52
+
53
+ export const DEFAULT_COLLAPSE_CONFIG: CollapseConfig = {
54
+ provider: { name: 'PyreonUI', source: '@pyreon/ui-core' },
55
+ theme: { name: 'theme', source: '@pyreon/ui-theme' },
56
+ mode: { name: 'useMode', source: '@pyreon/ui-core' },
57
+ }
58
+
59
+ export interface ResolveInput {
60
+ /** The collapsible component's import. */
61
+ component: CollapseImportSpec
62
+ /** Literal dimension/HTML props, e.g. `{ state: 'primary', size: 'md' }`. */
63
+ props: Record<string, string>
64
+ /** Static text children (empty ⇒ no children). */
65
+ childrenText: string
66
+ config: CollapseConfig
67
+ }
68
+
69
+ export interface ResolvedCollapse {
70
+ /** Element HTML with the root `class="..."` removed (the `_tpl` template). */
71
+ templateHtml: string
72
+ lightClass: string
73
+ darkClass: string
74
+ /** Pre-resolved styler rule text (full snapshot) for `injectRules`. */
75
+ rules: string[]
76
+ /** FNV over the rule set — `injectRules` idempotency + cross-site dedupe. */
77
+ key: string
78
+ }
79
+
80
+ const FIRST_CLASS_RE = /^(\s*<[a-zA-Z][\w-]*)([^>]*?)\sclass="([^"]*)"([^>]*>)/
81
+
82
+ /** Strip the FIRST element's `class="..."`, returning [stripped, class]. */
83
+ export function stripRootClass(html: string): { stripped: string; cls: string } | null {
84
+ const m = FIRST_CLASS_RE.exec(html)
85
+ if (!m) return null
86
+ const stripped = html.replace(FIRST_CLASS_RE, '$1$2$4')
87
+ return { stripped, cls: m[3] ?? '' }
88
+ }
89
+
90
+ /**
91
+ * Pure extraction half — given the two rendered HTML strings and the
92
+ * styler rule snapshot, derive the ResolvedCollapse (or null on a shape
93
+ * the slice doesn't collapse). Separated for direct unit-testing without
94
+ * spinning Vite.
95
+ */
96
+ export function deriveCollapse(
97
+ lightHtml: string,
98
+ darkHtml: string,
99
+ rules: string[],
100
+ ): ResolvedCollapse | null {
101
+ const light = stripRootClass(lightHtml)
102
+ const dark = stripRootClass(darkHtml)
103
+ if (!light || !dark || !light.cls || !dark.cls) return null
104
+ // The structural template must be identical between modes (only the
105
+ // class differs). Divergent markup ⇒ not a simple single-root
106
+ // collapsible — bail.
107
+ if (light.stripped !== dark.stripped) return null
108
+ return {
109
+ templateHtml: light.stripped.trim(),
110
+ lightClass: light.cls,
111
+ darkClass: dark.cls,
112
+ rules,
113
+ key: fnv1a(rules.join('\u0000')),
114
+ }
115
+ }
116
+
117
+ export interface CollapseResolver {
118
+ resolve(input: ResolveInput): Promise<ResolvedCollapse | null>
119
+ dispose(): Promise<void>
120
+ }
121
+
122
+ /**
123
+ * Create a resolver backed by ONE programmatic Vite SSR server bound to
124
+ * `projectRoot`'s vite config. Reused across every call site in a build;
125
+ * `dispose()` at buildEnd. Module loads are cached by Vite's own SSR
126
+ * module graph (provider/theme/component import once).
127
+ */
128
+ export async function createCollapseResolver(projectRoot: string): Promise<CollapseResolver> {
129
+ const { createServer } = (await import('vite')) as typeof import('vite')
130
+ const inline: InlineConfig = {
131
+ // No `configFile` override — Vite auto-loads the project's own
132
+ // vite.config from `root`, so module resolution (workspace `bun`
133
+ // condition, app aliases) matches the real build exactly.
134
+ root: projectRoot,
135
+ server: { middlewareMode: true },
136
+ appType: 'custom',
137
+ logLevel: 'silent',
138
+ optimizeDeps: { noDiscovery: true, include: [] },
139
+ }
140
+ let server: ViteDevServer | null = await createServer(inline)
141
+
142
+ // Resolved-bundle cache — identical input must hit the same result
143
+ // without a second double-render (deterministic by construction).
144
+ const cache = new Map<string, ResolvedCollapse | null>()
145
+
146
+ async function load(spec: string): Promise<Record<string, unknown>> {
147
+ return (await server!.ssrLoadModule(spec)) as Record<string, unknown>
148
+ }
149
+
150
+ return {
151
+ async resolve(input) {
152
+ const ck = JSON.stringify([
153
+ input.component,
154
+ input.props,
155
+ input.childrenText,
156
+ input.config.provider,
157
+ input.config.theme,
158
+ ])
159
+ if (cache.has(ck)) return cache.get(ck) ?? null
160
+ try {
161
+ if (!server) return null
162
+ const rs = await load('@pyreon/runtime-server')
163
+ const core = await load('@pyreon/core')
164
+ const styler = await load('@pyreon/styler')
165
+ const prov = await load(input.config.provider.source)
166
+ const thm = await load(input.config.theme.source)
167
+ const comp = await load(input.component.source)
168
+ const renderToString = rs.renderToString as (n: unknown) => Promise<string>
169
+ const h = core.h as (t: unknown, p: unknown, ...c: unknown[]) => unknown
170
+ const Provider = prov[input.config.provider.name]
171
+ const themeVal = thm[input.config.theme.name]
172
+ const Component = comp[input.component.name]
173
+ const sheet = styler.sheet as { getStyleRules(): readonly string[] }
174
+ if (typeof Component !== 'function' || Provider == null || themeVal == null) {
175
+ cache.set(ck, null)
176
+ return null
177
+ }
178
+ const childArgs = input.childrenText ? [input.childrenText] : []
179
+ const node = (mode: string) =>
180
+ h(Provider, { theme: themeVal, mode }, h(Component, input.props, ...childArgs))
181
+ const lightHtml = await renderToString(node('light'))
182
+ const darkHtml = await renderToString(node('dark'))
183
+ const rules = sheet.getStyleRules().slice()
184
+ const result = deriveCollapse(lightHtml, darkHtml, rules)
185
+ cache.set(ck, result)
186
+ return result
187
+ } catch {
188
+ cache.set(ck, null)
189
+ return null
190
+ }
191
+ },
192
+ async dispose() {
193
+ const s = server
194
+ server = null
195
+ cache.clear()
196
+ if (s) await s.close()
197
+ },
198
+ }
199
+ }
@@ -0,0 +1,119 @@
1
+ import { join } from 'node:path'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ // Collapse is BUILD-ONLY by design. The resolver freezes each site's class
5
+ // from a SEPARATE nested Vite-SSR module graph and caches it; in `vite dev`
6
+ // that frozen class would NOT react to the user's theme-source HMR edits —
7
+ // strictly worse than the normal mount, which IS reactive. So `command:
8
+ // 'serve'` keeps the normal rocketstyle mount, the resolver never boots,
9
+ // and the plugin surfaces the build-only contract ONCE via `this.info`.
10
+ //
11
+ // The resolver is mocked here with a deterministic stub (no Vite, no
12
+ // workspace `lib/`) PURELY so the bisect is faithful LOCALLY: with the
13
+ // `&& isBuild` gate removed, serve-mode transform would enter the collapse
14
+ // block, call the (stub) resolver, and emit `__rsCollapse(` — failing the
15
+ // `.not.toContain('__rsCollapse(')` assertion. The real-Vite-SSR resolver
16
+ // is exercised (unmocked) by the build-mode specs in the sibling
17
+ // `rocketstyle-collapse.test.ts`.
18
+ vi.mock('../rocketstyle-collapse', async (importOriginal) => {
19
+ const actual = await importOriginal<typeof import('../rocketstyle-collapse')>()
20
+ return {
21
+ ...actual,
22
+ createCollapseResolver: vi.fn(async () => ({
23
+ resolve: vi.fn(async () => ({
24
+ templateHtml: '<button>Save</button>',
25
+ lightClass: 'pyr-stub-light',
26
+ darkClass: 'pyr-stub-dark',
27
+ rules: ['.pyr-stub-light{color:red}', '.pyr-stub-dark{color:blue}'],
28
+ key: 'stub-key',
29
+ })),
30
+ dispose: vi.fn(async () => {}),
31
+ })),
32
+ }
33
+ })
34
+
35
+ // Imported AFTER vi.mock (hoisted) so the plugin's lazy
36
+ // `import('./rocketstyle-collapse')` resolves to the stub.
37
+ const { default: pyreon } = await import('../index')
38
+ const { createCollapseResolver } = await import('../rocketstyle-collapse')
39
+
40
+ type Ctx = {
41
+ warn: (msg: string) => void
42
+ info: (msg: string) => void
43
+ resolve: (id: string, importer?: string, opts?: { skipSelf: boolean }) => Promise<null>
44
+ infos: string[]
45
+ }
46
+ type Plugin = ReturnType<typeof pyreon>
47
+
48
+ function makeCtx(): Ctx {
49
+ const infos: string[] = []
50
+ return { warn: () => {}, info: (m) => infos.push(m), resolve: async () => null, infos }
51
+ }
52
+ function configure(plugin: Plugin, command: 'serve' | 'build'): void {
53
+ ;(plugin.config as unknown as (u: Record<string, unknown>, e: { command: string }) => void)(
54
+ { root: '/tmp/does-not-matter' },
55
+ { command },
56
+ )
57
+ }
58
+ async function transform(
59
+ plugin: Plugin,
60
+ c: Ctx,
61
+ code: string,
62
+ id: string,
63
+ ): Promise<{ code: string } | undefined> {
64
+ const hook = plugin.transform as unknown as (
65
+ this: Ctx,
66
+ c: string,
67
+ i: string,
68
+ o?: { ssr?: boolean },
69
+ ) => Promise<{ code: string } | undefined>
70
+ return hook.call(c, code, id, { ssr: false })
71
+ }
72
+
73
+ const ID = join('/tmp', 'CollapseProbeDev.tsx')
74
+ const SRC = `
75
+ import { Button } from '@pyreon/ui-components'
76
+ export const Save = () => <Button state="primary" size="medium">Save</Button>`
77
+
78
+ describe('pyreon({ collapse }) — dev (serve) keeps the normal mount; build collapses', () => {
79
+ it('command:serve → NO __rsCollapse, normal mount, resolver never even constructed, one-time dev info', async () => {
80
+ const plugin = pyreon({ collapse: true })
81
+ configure(plugin, 'serve')
82
+ const c = makeCtx()
83
+
84
+ const out1 = await transform(plugin, c, SRC, ID)
85
+ // THE contract: dev keeps the normal rocketstyle mount. Bisect-load-
86
+ // bearing — drop the `&& isBuild` gate and the (stub) resolver boots +
87
+ // emits `__rsCollapse(` here, failing this line.
88
+ expect(out1?.code).not.toContain('__rsCollapse(')
89
+ expect(out1?.code).not.toContain('__rsSheet.injectRules(')
90
+ // Resolver (a nested Vite SSR server in production) is NEVER even
91
+ // constructed in serve mode — zero per-dev-process orphan server.
92
+ expect(createCollapseResolver).not.toHaveBeenCalled()
93
+ // One-time, actionable dev info so an opted-in `vite dev` consumer
94
+ // isn't left wondering why nothing collapsed.
95
+ expect(c.infos.filter((m) => m.includes('collapse is build-only'))).toHaveLength(1)
96
+
97
+ // A SECOND transform must NOT re-emit the info (once per plugin instance).
98
+ await transform(plugin, c, SRC, ID)
99
+ expect(c.infos.filter((m) => m.includes('collapse is build-only'))).toHaveLength(1)
100
+
101
+ // closeBundle is a guaranteed no-op in serve mode.
102
+ await (plugin.closeBundle as unknown as () => Promise<void>)()
103
+ })
104
+
105
+ it('command:build (same source, stub resolver) → DOES collapse — proves the gate is the only difference', async () => {
106
+ const plugin = pyreon({ collapse: true })
107
+ configure(plugin, 'build')
108
+ const c = makeCtx()
109
+
110
+ const out = await transform(plugin, c, SRC, ID)
111
+ // Same component, same props — only `command` differs. Build collapses.
112
+ expect(out?.code).toContain('__rsCollapse(')
113
+ expect(out?.code).toContain('__rsSheet.injectRules(')
114
+ // No build-only dev info in build mode.
115
+ expect(c.infos.filter((m) => m.includes('collapse is build-only'))).toHaveLength(0)
116
+
117
+ await (plugin.closeBundle as unknown as () => Promise<void>)()
118
+ })
119
+ })