@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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +113 -6
- package/lib/rocketstyle-collapse-C4eMAnwR.js +113 -0
- package/lib/types/index.d.ts +49 -2
- package/package.json +2 -2
- package/src/index.ts +257 -6
- package/src/rocketstyle-collapse.ts +199 -0
- package/src/tests/rocketstyle-collapse-dev.test.ts +119 -0
- package/src/tests/rocketstyle-collapse.test.ts +352 -0
- package/src/tests/vite-plugin.test.ts +13 -2
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 {
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|