@pyreon/vite-plugin 0.19.0 → 0.21.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 +108 -5
- 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 +223 -5
- 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
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, resolve } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
5
|
+
import pyreon from '../index'
|
|
6
|
+
import {
|
|
7
|
+
type CollapseResolver,
|
|
8
|
+
createCollapseResolver,
|
|
9
|
+
DEFAULT_COLLAPSE_CONFIG,
|
|
10
|
+
deriveCollapse,
|
|
11
|
+
} from '../rocketstyle-collapse'
|
|
12
|
+
|
|
13
|
+
// Layer 3: the build-time resolver SSR-renders the REAL
|
|
14
|
+
// @pyreon/ui-components Button (light + dark) through a programmatic
|
|
15
|
+
// Vite SSR bound to examples/ui-showcase's own config, and derives
|
|
16
|
+
// resolved classes + styler rule text + a class-stripped template.
|
|
17
|
+
// Byte-for-byte parity vs the actual runtime-mounted class is asserted
|
|
18
|
+
// at the e2e layer (Phase 4); here we prove the resolver mechanism end
|
|
19
|
+
// to end against the real component (no mocks).
|
|
20
|
+
|
|
21
|
+
function repoRoot(): string {
|
|
22
|
+
let dir = dirname(fileURLToPath(import.meta.url))
|
|
23
|
+
for (let i = 0; i < 12; i++) {
|
|
24
|
+
if (existsSync(join(dir, 'examples', 'ui-showcase', 'package.json'))) return dir
|
|
25
|
+
const parent = resolve(dir, '..')
|
|
26
|
+
if (parent === dir) break
|
|
27
|
+
dir = parent
|
|
28
|
+
}
|
|
29
|
+
throw new Error('repo root with examples/ui-showcase not found')
|
|
30
|
+
}
|
|
31
|
+
const UI_SHOWCASE = join(repoRoot(), 'examples', 'ui-showcase')
|
|
32
|
+
|
|
33
|
+
describe('deriveCollapse (pure extraction)', () => {
|
|
34
|
+
it('strips the root class, keeps inner markup, derives both classes', () => {
|
|
35
|
+
const light = '<button class="A B" data-x="1"><span class="inner">Save</span></button>'
|
|
36
|
+
const dark = '<button class="C D" data-x="1"><span class="inner">Save</span></button>'
|
|
37
|
+
const r = deriveCollapse(light, dark, ['.A{}', '.C{}'])
|
|
38
|
+
expect(r).not.toBeNull()
|
|
39
|
+
expect(r?.templateHtml).toBe(
|
|
40
|
+
'<button data-x="1"><span class="inner">Save</span></button>',
|
|
41
|
+
)
|
|
42
|
+
expect(r?.lightClass).toBe('A B')
|
|
43
|
+
expect(r?.darkClass).toBe('C D')
|
|
44
|
+
expect(r?.key).toMatch(/^[0-9a-z]+$/)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('bails (null) when light/dark markup diverges structurally', () => {
|
|
48
|
+
expect(
|
|
49
|
+
deriveCollapse('<button class="A">x</button>', '<button class="B"><i>x</i></button>', []),
|
|
50
|
+
).toBeNull()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('bails (null) when the root has no class', () => {
|
|
54
|
+
expect(deriveCollapse('<button>x</button>', '<button>x</button>', [])).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('light===dark class is valid (mode-invariant component) — NOT a bail', () => {
|
|
58
|
+
const r = deriveCollapse(
|
|
59
|
+
'<button class="same">x</button>',
|
|
60
|
+
'<button class="same">x</button>',
|
|
61
|
+
['.same{}'],
|
|
62
|
+
)
|
|
63
|
+
expect(r).not.toBeNull()
|
|
64
|
+
expect(r?.lightClass).toBe('same')
|
|
65
|
+
expect(r?.darkClass).toBe('same')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('createCollapseResolver — real @pyreon/ui-components Button via Vite SSR', () => {
|
|
70
|
+
let resolver: CollapseResolver
|
|
71
|
+
beforeAll(async () => {
|
|
72
|
+
resolver = await createCollapseResolver(UI_SHOWCASE)
|
|
73
|
+
}, 60_000)
|
|
74
|
+
afterAll(async () => {
|
|
75
|
+
await resolver?.dispose()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('resolves a single-root <button> template + non-empty classes + rules', async () => {
|
|
79
|
+
const r = await resolver.resolve({
|
|
80
|
+
component: { name: 'Button', source: '@pyreon/ui-components' },
|
|
81
|
+
props: { state: 'primary', size: 'medium' },
|
|
82
|
+
childrenText: 'Save',
|
|
83
|
+
config: DEFAULT_COLLAPSE_CONFIG,
|
|
84
|
+
})
|
|
85
|
+
expect(r).not.toBeNull()
|
|
86
|
+
if (!r) return
|
|
87
|
+
expect(r.templateHtml.startsWith('<button')).toBe(true)
|
|
88
|
+
expect(r.templateHtml).toContain('Save')
|
|
89
|
+
// root class stripped (applied reactively); inner span class stays baked
|
|
90
|
+
expect(/^<button[^>]*\sclass=/.test(r.templateHtml)).toBe(false)
|
|
91
|
+
expect(r.lightClass.length).toBeGreaterThan(0)
|
|
92
|
+
expect(r.darkClass.length).toBeGreaterThan(0)
|
|
93
|
+
expect(r.rules.length).toBeGreaterThan(0)
|
|
94
|
+
const joined = r.rules.join('')
|
|
95
|
+
// The resolved root classes exist in the captured rule text.
|
|
96
|
+
for (const cls of r.lightClass.split(/\s+/)) expect(joined).toContain(cls)
|
|
97
|
+
for (const cls of r.darkClass.split(/\s+/)) expect(joined).toContain(cls)
|
|
98
|
+
}, 60_000)
|
|
99
|
+
|
|
100
|
+
it('is deterministic — same input ⇒ identical result (cached, no drift)', async () => {
|
|
101
|
+
const input = {
|
|
102
|
+
component: { name: 'Button', source: '@pyreon/ui-components' },
|
|
103
|
+
props: { state: 'secondary', size: 'small' },
|
|
104
|
+
childrenText: 'Go',
|
|
105
|
+
config: DEFAULT_COLLAPSE_CONFIG,
|
|
106
|
+
}
|
|
107
|
+
const a = await resolver.resolve(input)
|
|
108
|
+
const b = await resolver.resolve(input)
|
|
109
|
+
expect(a).not.toBeNull()
|
|
110
|
+
expect(a).toEqual(b)
|
|
111
|
+
}, 60_000)
|
|
112
|
+
|
|
113
|
+
it('returns null (graceful bail) for a non-existent component export', async () => {
|
|
114
|
+
const r = await resolver.resolve({
|
|
115
|
+
component: { name: 'NotARealExport', source: '@pyreon/ui-components' },
|
|
116
|
+
props: { state: 'primary' },
|
|
117
|
+
childrenText: 'x',
|
|
118
|
+
config: DEFAULT_COLLAPSE_CONFIG,
|
|
119
|
+
})
|
|
120
|
+
expect(r).toBeNull()
|
|
121
|
+
}, 60_000)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('end-to-end pipeline — real Button through resolver → scanner → compiler', () => {
|
|
125
|
+
let resolver: CollapseResolver
|
|
126
|
+
beforeAll(async () => {
|
|
127
|
+
resolver = await createCollapseResolver(UI_SHOWCASE)
|
|
128
|
+
}, 60_000)
|
|
129
|
+
afterAll(async () => {
|
|
130
|
+
await resolver?.dispose()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('the emitted _rsCollapse embeds the REAL SSR-resolved class + template byte-for-byte', async () => {
|
|
134
|
+
const { transformJSX, scanCollapsibleSites, rocketstyleCollapseKey } = await import(
|
|
135
|
+
'@pyreon/compiler'
|
|
136
|
+
)
|
|
137
|
+
const src = `
|
|
138
|
+
import { Button } from '@pyreon/ui-components'
|
|
139
|
+
export const Save = () => <Button state="primary" size="medium">Save</Button>`
|
|
140
|
+
|
|
141
|
+
// 1. plugin scan finds the site (same key the compiler will look up)
|
|
142
|
+
const sites = scanCollapsibleSites(src, 'Save.tsx', new Set(['@pyreon/ui-components']))
|
|
143
|
+
expect(sites).toHaveLength(1)
|
|
144
|
+
const site = sites[0]!
|
|
145
|
+
expect(site.key).toBe(
|
|
146
|
+
rocketstyleCollapseKey(site.componentName, site.props, site.childrenText),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
// 2. resolver SSR-renders the REAL component (light + dark)
|
|
150
|
+
const resolved = await resolver.resolve({
|
|
151
|
+
component: { name: site.importedName, source: site.source },
|
|
152
|
+
props: site.props,
|
|
153
|
+
childrenText: site.childrenText,
|
|
154
|
+
config: DEFAULT_COLLAPSE_CONFIG,
|
|
155
|
+
})
|
|
156
|
+
expect(resolved).not.toBeNull()
|
|
157
|
+
if (!resolved) return
|
|
158
|
+
|
|
159
|
+
// 3. compiler emits the collapse from the resolver's real data
|
|
160
|
+
const { code } = transformJSX(src, 'Save.tsx', {
|
|
161
|
+
collapseRocketstyle: {
|
|
162
|
+
candidates: new Set([site.componentName]),
|
|
163
|
+
sites: new Map([
|
|
164
|
+
[
|
|
165
|
+
site.key,
|
|
166
|
+
{
|
|
167
|
+
templateHtml: resolved.templateHtml,
|
|
168
|
+
lightClass: resolved.lightClass,
|
|
169
|
+
darkClass: resolved.darkClass,
|
|
170
|
+
rules: resolved.rules,
|
|
171
|
+
ruleKey: resolved.key,
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
]),
|
|
175
|
+
mode: { name: 'useMode', source: '@pyreon/ui-core' },
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// The collapsed call carries the REAL resolved class strings + the
|
|
180
|
+
// real class-stripped template — byte-for-byte, no reimplementation.
|
|
181
|
+
expect(code).toContain(`${JSON.stringify(resolved.templateHtml)}`)
|
|
182
|
+
expect(code).toContain(`${JSON.stringify(resolved.lightClass)}`)
|
|
183
|
+
expect(code).toContain(`${JSON.stringify(resolved.darkClass)}`)
|
|
184
|
+
expect(code).toContain('__rsCollapse(')
|
|
185
|
+
expect(code).toContain('() => __pyrMode() === "dark"')
|
|
186
|
+
// once-per-module idempotent rule injection with the resolver's rules
|
|
187
|
+
expect(code).toContain('__rsSheet.injectRules(')
|
|
188
|
+
expect(code).toContain(JSON.stringify(resolved.rules))
|
|
189
|
+
// the 5-layer <Button> wrapper mount is gone from the client graph
|
|
190
|
+
expect(code).not.toContain('<Button')
|
|
191
|
+
}, 60_000)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// Layer 5: drive the collapse path through the REAL `pyreon()` plugin
|
|
195
|
+
// hooks (config → transform → closeBundle), exercising the lazy resolver
|
|
196
|
+
// init + the transform-hook collapse branch + dispose end-to-end. This is
|
|
197
|
+
// the only test that runs `createCollapseResolver` *through the plugin*
|
|
198
|
+
// rather than directly — closes the index.ts collapse-wiring coverage gap.
|
|
199
|
+
describe('pyreon({ collapse }) plugin — transform hook drives the collapse', () => {
|
|
200
|
+
type Ctx = {
|
|
201
|
+
warn: (msg: string) => void
|
|
202
|
+
info: (msg: string) => void
|
|
203
|
+
resolve: (id: string, importer?: string, opts?: { skipSelf: boolean }) => Promise<null>
|
|
204
|
+
}
|
|
205
|
+
const ctx: Ctx = { warn: () => {}, info: () => {}, resolve: async () => null }
|
|
206
|
+
const ID = join(UI_SHOWCASE, 'src', 'CollapseProbe.tsx')
|
|
207
|
+
const SRC = `
|
|
208
|
+
import { Button } from '@pyreon/ui-components'
|
|
209
|
+
export const Save = () => <Button state="primary" size="medium">Save</Button>`
|
|
210
|
+
|
|
211
|
+
type Plugin = ReturnType<typeof pyreon>
|
|
212
|
+
function configure(plugin: Plugin): void {
|
|
213
|
+
;(plugin.config as unknown as (u: Record<string, unknown>, e: { command: string }) => void)(
|
|
214
|
+
{ root: UI_SHOWCASE },
|
|
215
|
+
{ command: 'build' },
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
async function transform(
|
|
219
|
+
plugin: Plugin,
|
|
220
|
+
code: string,
|
|
221
|
+
id: string,
|
|
222
|
+
ssr: boolean,
|
|
223
|
+
): Promise<{ code: string } | undefined> {
|
|
224
|
+
const hook = plugin.transform as (
|
|
225
|
+
this: Ctx,
|
|
226
|
+
c: string,
|
|
227
|
+
i: string,
|
|
228
|
+
o?: { ssr?: boolean },
|
|
229
|
+
) => Promise<{ code: string } | undefined>
|
|
230
|
+
return hook.call(ctx, code, id, { ssr })
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
it('collapse:true → transform emits __rsCollapse for a literal-prop Button (client graph)', async () => {
|
|
234
|
+
const plugin = pyreon({ collapse: true })
|
|
235
|
+
configure(plugin)
|
|
236
|
+
const out = await transform(plugin, SRC, ID, false)
|
|
237
|
+
expect(out?.code).toContain('__rsCollapse(')
|
|
238
|
+
expect(out?.code).toContain('__rsSheet.injectRules(')
|
|
239
|
+
expect(out?.code).not.toContain('<Button')
|
|
240
|
+
// SSR graph keeps the real mount (no collapse) — the !isSsr guard
|
|
241
|
+
const ssrOut = await transform(plugin, SRC, ID, true)
|
|
242
|
+
expect(ssrOut?.code).not.toContain('__rsCollapse(')
|
|
243
|
+
// dispose the one programmatic Vite SSR server the resolver holds
|
|
244
|
+
await (plugin.closeBundle as unknown as () => Promise<void>)()
|
|
245
|
+
}, 90_000)
|
|
246
|
+
|
|
247
|
+
it('collapse component filter excluding the component → no collapse (cheap, no resolver boot)', async () => {
|
|
248
|
+
const plugin = pyreon({ collapse: { components: ['NotARealComponent'] } })
|
|
249
|
+
configure(plugin)
|
|
250
|
+
const out = await transform(plugin, SRC, ID, false)
|
|
251
|
+
expect(out?.code).not.toContain('__rsCollapse(')
|
|
252
|
+
// closeBundle is a no-op when the resolver was never created
|
|
253
|
+
await (plugin.closeBundle as unknown as () => Promise<void>)()
|
|
254
|
+
}, 30_000)
|
|
255
|
+
|
|
256
|
+
it('collapse:true but no collapsible site in the file → resolver not booted', async () => {
|
|
257
|
+
const plugin = pyreon({ collapse: true })
|
|
258
|
+
configure(plugin)
|
|
259
|
+
const plain = `export const X = () => <div class="x">hi</div>`
|
|
260
|
+
const out = await transform(plugin, plain, ID, false)
|
|
261
|
+
expect(out?.code).not.toContain('__rsCollapse(')
|
|
262
|
+
await (plugin.closeBundle as unknown as () => Promise<void>)()
|
|
263
|
+
}, 30_000)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// Layer 5: drive the WHOLE collapse path through the real `pyreon()`
|
|
267
|
+
// plugin — `config` (projectRoot) → `transform` (scan → lazy
|
|
268
|
+
// `ensureCollapseResolver` → real Vite-SSR resolve → thread
|
|
269
|
+
// `collapseRocketstyle` into the compiler) → `closeBundle` (dispose the
|
|
270
|
+
// one programmatic SSR server). This is the only test that exercises
|
|
271
|
+
// the plugin's transform-hook collapse wiring + lifecycle end to end.
|
|
272
|
+
describe('pyreon({ collapse }) — plugin transform/closeBundle wiring', () => {
|
|
273
|
+
const COLLAPSIBLE = `
|
|
274
|
+
import { Button } from '@pyreon/ui-components'
|
|
275
|
+
export const Save = () => <Button state="primary" size="medium">Save</Button>`
|
|
276
|
+
const PLAIN = `export const Hi = () => <div class="x">hi</div>`
|
|
277
|
+
const ID = join(UI_SHOWCASE, 'src', 'Save.tsx')
|
|
278
|
+
|
|
279
|
+
// Minimal Rollup/Vite transform-hook context. `resolve` returns null
|
|
280
|
+
// (no cross-module signal resolution needed for these specs); `warn`
|
|
281
|
+
// collects compiler/plugin warnings without failing the run.
|
|
282
|
+
function ctx() {
|
|
283
|
+
return { warn: () => {}, resolve: async () => null }
|
|
284
|
+
}
|
|
285
|
+
function callConfig(p: ReturnType<typeof pyreon>, root: string) {
|
|
286
|
+
;(p.config as unknown as (u: unknown, e: unknown) => unknown)(
|
|
287
|
+
{ root },
|
|
288
|
+
{ command: 'build' },
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
function callTransform(
|
|
292
|
+
p: ReturnType<typeof pyreon>,
|
|
293
|
+
code: string,
|
|
294
|
+
id: string,
|
|
295
|
+
ssr: boolean,
|
|
296
|
+
) {
|
|
297
|
+
return (
|
|
298
|
+
p.transform as unknown as (
|
|
299
|
+
this: unknown,
|
|
300
|
+
c: string,
|
|
301
|
+
i: string,
|
|
302
|
+
o: { ssr: boolean },
|
|
303
|
+
) => Promise<{ code: string } | undefined>
|
|
304
|
+
).call(ctx(), code, id, { ssr })
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
it('collapse:true — transform emits __rsCollapse for a literal-prop site (client graph), bails on the SSR graph, and closeBundle disposes the resolver', async () => {
|
|
308
|
+
const plugin = pyreon({ collapse: true })
|
|
309
|
+
callConfig(plugin, UI_SHOWCASE)
|
|
310
|
+
|
|
311
|
+
// CLIENT graph (ssr:false) → real resolver spins up, site collapses.
|
|
312
|
+
const client = await callTransform(plugin, COLLAPSIBLE, ID, false)
|
|
313
|
+
expect(client?.code).toContain('__rsCollapse(')
|
|
314
|
+
expect(client?.code).toContain('__rsSheet.injectRules(')
|
|
315
|
+
expect(client?.code).not.toContain('<Button')
|
|
316
|
+
|
|
317
|
+
// SSR graph (ssr:true) → never collapse (renderToString needs the
|
|
318
|
+
// real VNode tree; the resolver itself SSR-renders).
|
|
319
|
+
const ssr = await callTransform(plugin, COLLAPSIBLE, ID, true)
|
|
320
|
+
expect(ssr?.code ?? '').not.toContain('__rsCollapse(')
|
|
321
|
+
|
|
322
|
+
// A file with no collapsible site → scan is empty, resolver path
|
|
323
|
+
// is skipped entirely (no second SSR boot).
|
|
324
|
+
const plain = await callTransform(plugin, PLAIN, ID, false)
|
|
325
|
+
expect(plain?.code ?? '').not.toContain('__rsCollapse(')
|
|
326
|
+
|
|
327
|
+
// Lifecycle: closeBundle tears down the one programmatic SSR server.
|
|
328
|
+
await (plugin.closeBundle as unknown as () => Promise<void>).call({})
|
|
329
|
+
// Idempotent — second close is a no-op (resolver already null).
|
|
330
|
+
await (plugin.closeBundle as unknown as () => Promise<void>).call({})
|
|
331
|
+
}, 90_000)
|
|
332
|
+
|
|
333
|
+
it('collapse.components filter — a non-matching component name bails before the resolver ever boots', async () => {
|
|
334
|
+
// No SSR server is created: the scanned site is filtered out by the
|
|
335
|
+
// component allow-list before `ensureCollapseResolver()` is reached.
|
|
336
|
+
const plugin = pyreon({ collapse: { components: ['NotARealComponent'] } })
|
|
337
|
+
callConfig(plugin, UI_SHOWCASE)
|
|
338
|
+
const out = await callTransform(plugin, COLLAPSIBLE, ID, false)
|
|
339
|
+
expect(out?.code ?? '').not.toContain('__rsCollapse(')
|
|
340
|
+
// closeBundle with no resolver created → the `if (collapseResolver)`
|
|
341
|
+
// guard is false (cheap path, no dispose).
|
|
342
|
+
await (plugin.closeBundle as unknown as () => Promise<void>).call({})
|
|
343
|
+
}, 30_000)
|
|
344
|
+
|
|
345
|
+
it('collapse off (default) — transform never touches the collapse path', async () => {
|
|
346
|
+
const plugin = pyreon()
|
|
347
|
+
callConfig(plugin, UI_SHOWCASE)
|
|
348
|
+
const out = await callTransform(plugin, COLLAPSIBLE, ID, false)
|
|
349
|
+
expect(out?.code ?? '').not.toContain('__rsCollapse(')
|
|
350
|
+
await (plugin.closeBundle as unknown as () => Promise<void>).call({})
|
|
351
|
+
}, 30_000)
|
|
352
|
+
})
|