poveste 0.1.2 → 0.2.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/dist/node/bin.js +3 -1
- package/dist/node/build.d.ts.map +1 -1
- package/dist/node/build.js +23 -5
- package/dist/node/builtin-plugins/pinceau-tokens.js +35 -35
- package/dist/node/builtin-plugins/tailwind-tokens.js +38 -38
- package/dist/node/builtin-plugins/vanilla-support/RenderStory.d.ts.map +1 -1
- package/dist/node/builtin-plugins/vanilla-support/RenderStory.js +7 -4
- package/dist/node/builtin-plugins/vanilla-support/types.d.ts +6 -5
- package/dist/node/builtin-plugins/vanilla-support/types.d.ts.map +1 -1
- package/dist/node/commands/preview.d.ts +2 -0
- package/dist/node/commands/preview.d.ts.map +1 -1
- package/dist/node/commands/preview.js +6 -3
- package/dist/node/config.d.ts.map +1 -1
- package/dist/node/config.js +1 -0
- package/dist/node/context.js +2 -2
- package/dist/node/dom/env.d.ts.map +1 -1
- package/dist/node/dom/env.js +26 -1
- package/dist/node/markdown.js +1 -1
- package/dist/node/preview.d.ts +7 -1
- package/dist/node/preview.d.ts.map +1 -1
- package/dist/node/preview.js +22 -39
- package/dist/node/style-isolation/entry-css-merger.d.ts +9 -0
- package/dist/node/style-isolation/entry-css-merger.d.ts.map +1 -0
- package/dist/node/style-isolation/entry-css-merger.js +128 -0
- package/dist/node/style-isolation/global-styles.d.ts +8 -0
- package/dist/node/style-isolation/global-styles.d.ts.map +1 -0
- package/dist/node/style-isolation/global-styles.js +31 -0
- package/dist/node/style-isolation/index.d.ts +6 -0
- package/dist/node/style-isolation/index.d.ts.map +1 -0
- package/dist/node/style-isolation/index.js +5 -0
- package/dist/node/style-isolation/scope-chrome.d.ts +6 -0
- package/dist/node/style-isolation/scope-chrome.d.ts.map +1 -0
- package/dist/node/style-isolation/scope-chrome.js +37 -0
- package/dist/node/style-isolation/selectors.d.ts +7 -0
- package/dist/node/style-isolation/selectors.d.ts.map +1 -0
- package/dist/node/style-isolation/selectors.js +15 -0
- package/dist/node/style-isolation/transforms.d.ts +11 -0
- package/dist/node/style-isolation/transforms.d.ts.map +1 -0
- package/dist/node/style-isolation/transforms.js +75 -0
- package/dist/node/style-isolation/vite-plugin.d.ts +15 -0
- package/dist/node/style-isolation/vite-plugin.d.ts.map +1 -0
- package/dist/node/style-isolation/vite-plugin.js +62 -0
- package/dist/node/util/head.d.ts +3 -0
- package/dist/node/util/head.d.ts.map +1 -0
- package/dist/node/util/head.js +10 -0
- package/dist/node/virtual/markdown.js +1 -1
- package/dist/node/virtual/noop.d.ts +2 -1
- package/dist/node/virtual/noop.d.ts.map +1 -1
- package/dist/node/virtual/noop.js +7 -1
- package/dist/node/virtual/resolved-generated-global-setup.js +3 -3
- package/dist/node/virtual/resolved-markdown-files.js +2 -2
- package/dist/node/virtual/util.d.ts +15 -0
- package/dist/node/virtual/util.d.ts.map +1 -1
- package/dist/node/virtual/util.js +18 -0
- package/dist/node/virtual/vite-plugin.js +1 -1
- package/dist/node/vite.d.ts +1 -1
- package/dist/node/vite.d.ts.map +1 -1
- package/dist/node/vite.js +51 -9
- package/package.json +7 -5
- package/src/node/__tests__/dom-env.spec.ts +38 -0
- package/src/node/__tests__/head.spec.ts +32 -0
- package/src/node/__tests__/virtual-setup.spec.ts +51 -0
- package/src/node/bin.ts +3 -1
- package/src/node/build.ts +31 -5
- package/src/node/builtin-plugins/pinceau-tokens.ts +35 -35
- package/src/node/builtin-plugins/tailwind-tokens.ts +38 -38
- package/src/node/builtin-plugins/vanilla-support/RenderStory.ts +8 -5
- package/src/node/builtin-plugins/vanilla-support/types.ts +3 -1
- package/src/node/commands/preview.ts +8 -3
- package/src/node/config.ts +1 -0
- package/src/node/context.ts +2 -2
- package/src/node/dom/env.ts +26 -1
- package/src/node/markdown.ts +1 -1
- package/src/node/preview.ts +30 -47
- package/src/node/style-isolation/__tests__/entry-css-merger.spec.ts +126 -0
- package/src/node/style-isolation/__tests__/transforms.spec.ts +92 -0
- package/src/node/style-isolation/__tests__/vite-plugin.spec.ts +39 -0
- package/src/node/style-isolation/entry-css-merger.ts +133 -0
- package/src/node/style-isolation/global-styles.ts +37 -0
- package/src/node/style-isolation/index.ts +5 -0
- package/src/node/style-isolation/scope-chrome.ts +42 -0
- package/src/node/style-isolation/selectors.ts +16 -0
- package/src/node/style-isolation/transforms.ts +90 -0
- package/src/node/style-isolation/vite-plugin.ts +75 -0
- package/src/node/util/head.ts +12 -0
- package/src/node/virtual/markdown.ts +1 -1
- package/src/node/virtual/noop.ts +9 -1
- package/src/node/virtual/resolved-generated-global-setup.ts +3 -3
- package/src/node/virtual/resolved-markdown-files.ts +2 -2
- package/src/node/virtual/util.ts +22 -0
- package/src/node/virtual/vite-plugin.ts +1 -1
- package/src/node/vite.ts +57 -9
package/src/node/preview.ts
CHANGED
|
@@ -1,57 +1,40 @@
|
|
|
1
1
|
import type { Context } from './context.js'
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { preview } from 'vite'
|
|
3
|
+
|
|
4
|
+
export interface StartPreviewOptions {
|
|
5
|
+
port?: number
|
|
6
|
+
host?: string | boolean
|
|
7
|
+
open?: boolean
|
|
8
|
+
}
|
|
5
9
|
|
|
6
10
|
interface ReturnPayload {
|
|
7
11
|
baseUrl: string
|
|
12
|
+
printUrls: () => void
|
|
8
13
|
close: () => Promise<void>
|
|
9
14
|
}
|
|
10
15
|
|
|
11
|
-
export async function startPreview(
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
ctx.resolvedViteConfig.base,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return new Promise((resolve, reject) => {
|
|
28
|
-
function onError(e: Error & { code?: string }) {
|
|
29
|
-
if (e.code === 'EADDRINUSE') {
|
|
30
|
-
httpServer.listen(++finalPort)
|
|
31
|
-
}
|
|
32
|
-
else {
|
|
33
|
-
reject(e)
|
|
34
|
-
}
|
|
35
|
-
}
|
|
16
|
+
export async function startPreview(options: StartPreviewOptions, ctx: Context): Promise<ReturnPayload> {
|
|
17
|
+
const port = options.port ?? 6006
|
|
18
|
+
const server = await preview({
|
|
19
|
+
root: ctx.root,
|
|
20
|
+
base: ctx.resolvedViteConfig.base,
|
|
21
|
+
configFile: false,
|
|
22
|
+
build: {
|
|
23
|
+
outDir: ctx.config.outDir,
|
|
24
|
+
},
|
|
25
|
+
preview: {
|
|
26
|
+
port,
|
|
27
|
+
host: options.host,
|
|
28
|
+
open: options.open,
|
|
29
|
+
strictPort: false,
|
|
30
|
+
},
|
|
31
|
+
})
|
|
36
32
|
|
|
37
|
-
|
|
33
|
+
const baseUrl = server.resolvedUrls?.local[0] ?? `http://localhost:${port}${ctx.resolvedViteConfig.base}`
|
|
38
34
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
close: () => new Promise((resolve, reject) => {
|
|
45
|
-
httpServer.close((err) => {
|
|
46
|
-
if (err) {
|
|
47
|
-
reject(err)
|
|
48
|
-
}
|
|
49
|
-
else {
|
|
50
|
-
resolve()
|
|
51
|
-
}
|
|
52
|
-
})
|
|
53
|
-
}),
|
|
54
|
-
})
|
|
55
|
-
})
|
|
56
|
-
})
|
|
35
|
+
return {
|
|
36
|
+
baseUrl,
|
|
37
|
+
printUrls: () => server.printUrls(),
|
|
38
|
+
close: () => server.close(),
|
|
39
|
+
}
|
|
57
40
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { OutputBundle } from 'rollup'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { entryCssMergerPlugin } from '../entry-css-merger.js'
|
|
4
|
+
import { USER_CSS_MARK_END, USER_CSS_MARK_START } from '../vite-plugin.js'
|
|
5
|
+
|
|
6
|
+
function fakeChunk(name: string, importedCss: string[], code = '', extra: Partial<any> = {}) {
|
|
7
|
+
return {
|
|
8
|
+
type: 'chunk' as const,
|
|
9
|
+
name,
|
|
10
|
+
fileName: `${name}.js`,
|
|
11
|
+
code,
|
|
12
|
+
isEntry: true,
|
|
13
|
+
viteMetadata: { importedCss: new Set(importedCss), importedAssets: new Set() },
|
|
14
|
+
facadeModuleId: null,
|
|
15
|
+
moduleIds: [],
|
|
16
|
+
imports: [],
|
|
17
|
+
dynamicImports: [],
|
|
18
|
+
exports: [],
|
|
19
|
+
map: null,
|
|
20
|
+
modules: {},
|
|
21
|
+
...extra,
|
|
22
|
+
} as any
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fakeAsset(fileName: string, source: string) {
|
|
26
|
+
return { type: 'asset' as const, fileName, source, name: fileName, needsCodeReference: false } as any
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mark(css: string): string {
|
|
30
|
+
return `${USER_CSS_MARK_START}\n${css}\n${USER_CSS_MARK_END}\n`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('entry-css-merger', () => {
|
|
34
|
+
it('produces one CSS asset per entry chunk', async () => {
|
|
35
|
+
const plugin = entryCssMergerPlugin()
|
|
36
|
+
const bundle: OutputBundle = {
|
|
37
|
+
'bundle-main.js': fakeChunk('bundle-main', ['route1.css', 'route2.css']),
|
|
38
|
+
'bundle-sandbox.js': fakeChunk('bundle-sandbox', ['sandbox-route.css']),
|
|
39
|
+
'route1.css': fakeAsset('route1.css', 'a {}'),
|
|
40
|
+
'route2.css': fakeAsset('route2.css', 'b {}'),
|
|
41
|
+
'sandbox-route.css': fakeAsset('sandbox-route.css', 'c {}'),
|
|
42
|
+
}
|
|
43
|
+
await (plugin.generateBundle as any).call({ emitFile: () => '' }, {}, bundle)
|
|
44
|
+
|
|
45
|
+
const cssAssets = Object.values(bundle).filter(a => a.type === 'asset' && a.fileName.endsWith('.css'))
|
|
46
|
+
expect(cssAssets).toHaveLength(2)
|
|
47
|
+
const mainCss = cssAssets.find(a => a.fileName === 'bundle-main.css')
|
|
48
|
+
const sandboxCss = cssAssets.find(a => a.fileName === 'bundle-sandbox.css')
|
|
49
|
+
expect(mainCss?.source).toContain('a {}')
|
|
50
|
+
expect(mainCss?.source).toContain('b {}')
|
|
51
|
+
expect(sandboxCss?.source).toContain('c {}')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('wraps marked user CSS in @scope on the main entry when isolation is enabled', async () => {
|
|
55
|
+
const plugin = entryCssMergerPlugin({
|
|
56
|
+
isolateStyles: true,
|
|
57
|
+
scopeRoot: '.__poveste-render-story',
|
|
58
|
+
mainEntryName: 'bundle-main',
|
|
59
|
+
})
|
|
60
|
+
const bundle: OutputBundle = {
|
|
61
|
+
'bundle-main.js': fakeChunk('bundle-main', ['user.css', 'chrome.css']),
|
|
62
|
+
'user.css': fakeAsset('user.css', mark('body { color: red }')),
|
|
63
|
+
'chrome.css': fakeAsset('chrome.css', '@scope (.poveste-app-root) { .x {} }'),
|
|
64
|
+
}
|
|
65
|
+
await (plugin.generateBundle as any).call({ emitFile: () => '' }, {}, bundle)
|
|
66
|
+
|
|
67
|
+
const main = Object.values(bundle).find(a => a.type === 'asset' && a.fileName === 'bundle-main.css') as any
|
|
68
|
+
expect(main.source).toContain('@scope (.__poveste-render-story)')
|
|
69
|
+
expect(main.source).toContain('color: red')
|
|
70
|
+
expect(main.source).toContain('@scope (.poveste-app-root)')
|
|
71
|
+
expect(main.source).not.toContain(USER_CSS_MARK_START)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('strips markers without wrapping on the sandbox entry', async () => {
|
|
75
|
+
const plugin = entryCssMergerPlugin({
|
|
76
|
+
isolateStyles: true,
|
|
77
|
+
scopeRoot: '.__poveste-render-story',
|
|
78
|
+
mainEntryName: 'bundle-main',
|
|
79
|
+
})
|
|
80
|
+
const bundle: OutputBundle = {
|
|
81
|
+
'bundle-sandbox.js': fakeChunk('bundle-sandbox', ['user.css']),
|
|
82
|
+
'user.css': fakeAsset('user.css', mark('body { color: red }')),
|
|
83
|
+
}
|
|
84
|
+
await (plugin.generateBundle as any).call({ emitFile: () => '' }, {}, bundle)
|
|
85
|
+
|
|
86
|
+
const sandbox = Object.values(bundle).find(a => a.type === 'asset' && a.fileName === 'bundle-sandbox.css') as any
|
|
87
|
+
expect(sandbox.source).toContain('body')
|
|
88
|
+
expect(sandbox.source).toContain('color: red')
|
|
89
|
+
expect(sandbox.source).not.toContain('@scope')
|
|
90
|
+
expect(sandbox.source).not.toContain(USER_CSS_MARK_START)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('attributes CSS from transitively-imported chunks (e.g. vendor)', async () => {
|
|
94
|
+
const plugin = entryCssMergerPlugin()
|
|
95
|
+
const main = fakeChunk('bundle-main', ['app.css'], '', { imports: ['vendor.js'], isEntry: true })
|
|
96
|
+
main.fileName = 'bundle-main.js'
|
|
97
|
+
const vendor = { ...fakeChunk('vendor', ['vendor.css']), isEntry: false, fileName: 'vendor.js' }
|
|
98
|
+
const bundle: OutputBundle = {
|
|
99
|
+
'bundle-main.js': main,
|
|
100
|
+
'vendor.js': vendor,
|
|
101
|
+
'app.css': fakeAsset('app.css', '.app {}'),
|
|
102
|
+
'vendor.css': fakeAsset('vendor.css', '.vendor {}'),
|
|
103
|
+
}
|
|
104
|
+
await (plugin.generateBundle as any).call({ emitFile: () => '' }, {}, bundle)
|
|
105
|
+
const mainCss = Object.values(bundle).find(a => a.type === 'asset' && a.fileName === 'bundle-main.css') as any
|
|
106
|
+
expect(mainCss.source).toContain('.app')
|
|
107
|
+
expect(mainCss.source).toContain('.vendor')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('strips markers without wrapping when isolation is disabled', async () => {
|
|
111
|
+
const plugin = entryCssMergerPlugin({
|
|
112
|
+
isolateStyles: false,
|
|
113
|
+
scopeRoot: '.__poveste-render-story',
|
|
114
|
+
mainEntryName: 'bundle-main',
|
|
115
|
+
})
|
|
116
|
+
const bundle: OutputBundle = {
|
|
117
|
+
'bundle-main.js': fakeChunk('bundle-main', ['user.css']),
|
|
118
|
+
'user.css': fakeAsset('user.css', mark('body { color: red }')),
|
|
119
|
+
}
|
|
120
|
+
await (plugin.generateBundle as any).call({ emitFile: () => '' }, {}, bundle)
|
|
121
|
+
|
|
122
|
+
const main = Object.values(bundle).find(a => a.type === 'asset' && a.fileName === 'bundle-main.css') as any
|
|
123
|
+
expect(main.source).not.toContain('@scope')
|
|
124
|
+
expect(main.source).not.toContain(USER_CSS_MARK_START)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { isGlobalImport, wrapChromeCss, wrapUserCss } from '../transforms.js'
|
|
3
|
+
|
|
4
|
+
describe('wrapUserCss', () => {
|
|
5
|
+
it('wraps a single rule in @scope (.scope-root)', () => {
|
|
6
|
+
const out = wrapUserCss('body { color: red }', { scopeRoot: '.__poveste-render-story' })
|
|
7
|
+
expect(out).toContain('@scope (.__poveste-render-story)')
|
|
8
|
+
expect(out).toContain('body')
|
|
9
|
+
expect(out).toContain('color: red')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('rewrites :root → :scope inside the scope', () => {
|
|
13
|
+
const out = wrapUserCss(':root { --color-primary: blue }', {
|
|
14
|
+
scopeRoot: '.__poveste-render-story',
|
|
15
|
+
})
|
|
16
|
+
expect(out).toContain(':scope')
|
|
17
|
+
expect(out).not.toContain(':root')
|
|
18
|
+
expect(out).toContain('--color-primary: blue')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('leaves :scope alone (idempotent)', () => {
|
|
22
|
+
const out = wrapUserCss(':scope { color: red }', {
|
|
23
|
+
scopeRoot: '.__poveste-render-story',
|
|
24
|
+
})
|
|
25
|
+
expect(out).toContain(':scope')
|
|
26
|
+
expect(out).not.toContain(':scope:scope')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('hoists @import outside the @scope block', () => {
|
|
30
|
+
const css = `@import url('https://fonts.googleapis.com/css2?family=Foo');\nbody { color: red }`
|
|
31
|
+
const out = wrapUserCss(css, { scopeRoot: '.__poveste-render-story' })
|
|
32
|
+
expect(out.indexOf('@import')).toBeLessThan(out.indexOf('@scope'))
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('does not double-wrap CSS that already starts with @scope', () => {
|
|
36
|
+
const input = wrapUserCss('body { color: red }', { scopeRoot: '.__poveste-render-story' })
|
|
37
|
+
const out = wrapUserCss(input, { scopeRoot: '.__poveste-render-story' })
|
|
38
|
+
const occurrences = (out.match(/@scope/g) ?? []).length
|
|
39
|
+
expect(occurrences).toBe(1)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('wrapChromeCss', () => {
|
|
44
|
+
it('wraps in @scope (root) to (lower)', () => {
|
|
45
|
+
const out = wrapChromeCss('body { color: red }', {
|
|
46
|
+
scopeRoot: '.poveste-app-root',
|
|
47
|
+
scopeLower: '.__poveste-render-story',
|
|
48
|
+
})
|
|
49
|
+
expect(out).toContain('@scope (.poveste-app-root) to (.__poveste-render-story)')
|
|
50
|
+
expect(out).toContain('body')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('hoists @import to the top, outside @scope', () => {
|
|
54
|
+
const css = `@import url('reset.css');\nbody { color: red }`
|
|
55
|
+
const out = wrapChromeCss(css, {
|
|
56
|
+
scopeRoot: '.poveste-app-root',
|
|
57
|
+
scopeLower: '.__poveste-render-story',
|
|
58
|
+
})
|
|
59
|
+
const importIdx = out.indexOf('@import')
|
|
60
|
+
const scopeIdx = out.indexOf('@scope')
|
|
61
|
+
expect(importIdx).toBeGreaterThanOrEqual(0)
|
|
62
|
+
expect(scopeIdx).toBeGreaterThan(importIdx)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('hoists @font-face above @scope', () => {
|
|
66
|
+
const css = `@font-face { font-family: A; src: url('a.woff2') }\nbody { font-family: A }`
|
|
67
|
+
const out = wrapChromeCss(css, {
|
|
68
|
+
scopeRoot: '.poveste-app-root',
|
|
69
|
+
scopeLower: '.__poveste-render-story',
|
|
70
|
+
})
|
|
71
|
+
expect(out.indexOf('@font-face')).toBeLessThan(out.indexOf('@scope'))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('preserves an arbitrary scopeLower selector in the @scope prelude', () => {
|
|
75
|
+
const out = wrapChromeCss('body { color: red }', {
|
|
76
|
+
scopeRoot: '.poveste-app-root',
|
|
77
|
+
scopeLower: '.__poveste-render-story:not(.__poveste-render-custom-controls)',
|
|
78
|
+
})
|
|
79
|
+
expect(out).toContain('@scope (.poveste-app-root) to (.__poveste-render-story:not(.__poveste-render-custom-controls))')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('isGlobalImport', () => {
|
|
84
|
+
it('returns true for ids ending in ?global', () => {
|
|
85
|
+
expect(isGlobalImport('/abs/path/foo.css?global')).toBe(true)
|
|
86
|
+
expect(isGlobalImport('foo.css?global&bar')).toBe(true)
|
|
87
|
+
})
|
|
88
|
+
it('returns false otherwise', () => {
|
|
89
|
+
expect(isGlobalImport('/abs/path/foo.css')).toBe(false)
|
|
90
|
+
expect(isGlobalImport('foo.css?inline')).toBe(false)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { GLOBAL_LAYER_QUERY } from '../selectors.js'
|
|
3
|
+
import { USER_CSS_MARK_START, userCssScopePlugin } from '../vite-plugin.js'
|
|
4
|
+
|
|
5
|
+
function transform(id: string, code = 'body { color: red }', enabled = true) {
|
|
6
|
+
const plugin = userCssScopePlugin({ enabled })
|
|
7
|
+
return (plugin.transform as any).call({}, code, id) as { code: string } | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('userCssScopePlugin', () => {
|
|
11
|
+
it('marks user CSS so the merger can wrap it per entry', () => {
|
|
12
|
+
expect(transform('/project/src/poveste.css')?.code).toContain(USER_CSS_MARK_START)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('marks style blocks of single-file components', () => {
|
|
16
|
+
expect(transform('/project/src/Foo.vue?vue&type=style&index=0&lang.css')?.code).toContain(USER_CSS_MARK_START)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('leaves chrome CSS alone', () => {
|
|
20
|
+
expect(transform('/project/node_modules/@poveste/app/dist/style.css')).toBeNull()
|
|
21
|
+
expect(transform('/project/node_modules/@poveste/controls/dist/index.es.css')).toBeNull()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('leaves non-CSS alone', () => {
|
|
25
|
+
expect(transform('/project/src/main.ts')).toBeNull()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('leaves ?global imports unscoped', () => {
|
|
29
|
+
expect(transform('/project/src/tokens.css?global')).toBeNull()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('leaves globalStyles files unscoped — they belong to the chrome page', () => {
|
|
33
|
+
expect(transform(`/project/src/tokens.css?${GLOBAL_LAYER_QUERY}`)).toBeNull()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('does nothing when isolation is disabled', () => {
|
|
37
|
+
expect(transform('/project/src/poveste.css', 'body {}', false)).toBeNull()
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { OutputBundle } from 'rollup'
|
|
2
|
+
import type { Plugin as VitePlugin } from 'vite'
|
|
3
|
+
import { STORY_SCOPE_ROOT } from './selectors.js'
|
|
4
|
+
import { wrapUserCss } from './transforms.js'
|
|
5
|
+
import { USER_CSS_MARK_END, USER_CSS_MARK_START } from './vite-plugin.js'
|
|
6
|
+
|
|
7
|
+
export interface EntryCssMergerOptions {
|
|
8
|
+
isolateStyles: boolean
|
|
9
|
+
scopeRoot?: string
|
|
10
|
+
mainEntryName?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function entryCssMergerPlugin(opts: EntryCssMergerOptions = { isolateStyles: false }): VitePlugin {
|
|
14
|
+
const scopeRoot = opts.scopeRoot ?? STORY_SCOPE_ROOT
|
|
15
|
+
const mainEntryName = opts.mainEntryName ?? 'bundle-main'
|
|
16
|
+
return {
|
|
17
|
+
name: 'poveste:style-isolation:entry-css-merger',
|
|
18
|
+
apply: 'build',
|
|
19
|
+
enforce: 'post',
|
|
20
|
+
generateBundle(_options, bundle: OutputBundle) {
|
|
21
|
+
function collectChunkCss(entryFileName: string, entryName: string, acc: Map<string, string[]>) {
|
|
22
|
+
const seen = new Set<string>()
|
|
23
|
+
const queue = [entryFileName]
|
|
24
|
+
let head = 0
|
|
25
|
+
while (head < queue.length) {
|
|
26
|
+
const cur = queue[head++]
|
|
27
|
+
if (seen.has(cur)) continue
|
|
28
|
+
seen.add(cur)
|
|
29
|
+
const c = bundle[cur]
|
|
30
|
+
if (!c || c.type !== 'chunk') continue
|
|
31
|
+
const meta = (c as any).viteMetadata
|
|
32
|
+
if (meta?.importedCss) {
|
|
33
|
+
for (const cssName of meta.importedCss as Set<string>) {
|
|
34
|
+
const list = acc.get(cssName) ?? []
|
|
35
|
+
if (!list.includes(entryName)) list.push(entryName)
|
|
36
|
+
acc.set(cssName, list)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const imp of c.imports ?? []) queue.push(imp)
|
|
40
|
+
for (const imp of c.dynamicImports ?? []) queue.push(imp)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const cssAssetUsedBy = new Map<string, string[]>()
|
|
45
|
+
for (const [fileName, chunk] of Object.entries(bundle)) {
|
|
46
|
+
if (chunk.type !== 'chunk' || !chunk.isEntry) continue
|
|
47
|
+
collectChunkCss(fileName, chunk.name, cssAssetUsedBy)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const mergedByEntry = new Map<string, string[]>()
|
|
51
|
+
for (const [cssName, entries] of cssAssetUsedBy) {
|
|
52
|
+
const asset = bundle[cssName]
|
|
53
|
+
if (!asset || asset.type !== 'asset') continue
|
|
54
|
+
const source = typeof asset.source === 'string'
|
|
55
|
+
? asset.source
|
|
56
|
+
: new TextDecoder().decode(asset.source as Uint8Array)
|
|
57
|
+
for (const entryName of entries) {
|
|
58
|
+
const list = mergedByEntry.get(entryName) ?? []
|
|
59
|
+
list.push(source)
|
|
60
|
+
mergedByEntry.set(entryName, list)
|
|
61
|
+
}
|
|
62
|
+
delete bundle[cssName]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const [entryName, sources] of mergedByEntry) {
|
|
66
|
+
const fileName = `${entryName}.css`
|
|
67
|
+
const merged = sources.join('\n')
|
|
68
|
+
const finalSource = entryName === mainEntryName && opts.isolateStyles
|
|
69
|
+
? wrapMarkedUserCss(merged, scopeRoot)
|
|
70
|
+
: stripMarkers(merged)
|
|
71
|
+
bundle[fileName] = {
|
|
72
|
+
type: 'asset',
|
|
73
|
+
fileName,
|
|
74
|
+
name: fileName,
|
|
75
|
+
source: finalSource,
|
|
76
|
+
needsCodeReference: false,
|
|
77
|
+
} as any
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Re-point CSS metadata: entries claim their own merged file; non-entry
|
|
81
|
+
// chunks (vendor, route splits) drop their importedCss because the
|
|
82
|
+
// assets they referenced were merged into the entry's CSS and deleted.
|
|
83
|
+
// Without this, Vite's runtime preload helper 404s on the missing
|
|
84
|
+
// per-chunk CSS files.
|
|
85
|
+
const entryCssByEntryName = new Map<string, string>()
|
|
86
|
+
for (const [, chunk] of Object.entries(bundle)) {
|
|
87
|
+
if (chunk.type !== 'chunk' || !chunk.isEntry) continue
|
|
88
|
+
if (mergedByEntry.has(chunk.name)) {
|
|
89
|
+
entryCssByEntryName.set(chunk.name, `${chunk.name}.css`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const [, chunk] of Object.entries(bundle)) {
|
|
93
|
+
if (chunk.type !== 'chunk') continue
|
|
94
|
+
const meta = (chunk as any).viteMetadata
|
|
95
|
+
if (!meta?.importedCss) continue
|
|
96
|
+
if (chunk.isEntry && entryCssByEntryName.has(chunk.name)) {
|
|
97
|
+
meta.importedCss = new Set([entryCssByEntryName.get(chunk.name)!])
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
meta.importedCss = new Set()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function stripMarkers(css: string): string {
|
|
108
|
+
return css.split(USER_CSS_MARK_START).join('').split(USER_CSS_MARK_END).join('')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function wrapMarkedUserCss(css: string, scopeRoot: string): string {
|
|
112
|
+
let out = ''
|
|
113
|
+
let cursor = 0
|
|
114
|
+
while (cursor < css.length) {
|
|
115
|
+
const start = css.indexOf(USER_CSS_MARK_START, cursor)
|
|
116
|
+
if (start === -1) {
|
|
117
|
+
out += css.slice(cursor)
|
|
118
|
+
break
|
|
119
|
+
}
|
|
120
|
+
out += css.slice(cursor, start)
|
|
121
|
+
const end = css.indexOf(USER_CSS_MARK_END, start + USER_CSS_MARK_START.length)
|
|
122
|
+
if (end === -1) {
|
|
123
|
+
out += css.slice(start + USER_CSS_MARK_START.length)
|
|
124
|
+
break
|
|
125
|
+
}
|
|
126
|
+
const inner = css.slice(start + USER_CSS_MARK_START.length, end).trim()
|
|
127
|
+
if (inner.length > 0) {
|
|
128
|
+
out += `${wrapUserCss(inner, { scopeRoot })}\n`
|
|
129
|
+
}
|
|
130
|
+
cursor = end + USER_CSS_MARK_END.length
|
|
131
|
+
}
|
|
132
|
+
return out
|
|
133
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Plugin as VitePlugin } from 'vite'
|
|
2
|
+
import { resolve } from 'pathe'
|
|
3
|
+
import { GLOBAL_LAYER_QUERY, USER_GLOBALS_LAYER } from './selectors.js'
|
|
4
|
+
|
|
5
|
+
const VIRTUAL_ID = 'virtual:$poveste-global-styles'
|
|
6
|
+
const RESOLVED_ID = `\0${VIRTUAL_ID}`
|
|
7
|
+
|
|
8
|
+
export interface GlobalStylesPluginOptions {
|
|
9
|
+
files: string[]
|
|
10
|
+
rootDir: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function globalStylesPlugin(opts: GlobalStylesPluginOptions): VitePlugin {
|
|
14
|
+
return {
|
|
15
|
+
name: 'poveste:style-isolation:global-styles',
|
|
16
|
+
resolveId(id) {
|
|
17
|
+
if (id === VIRTUAL_ID) return RESOLVED_ID
|
|
18
|
+
},
|
|
19
|
+
load(id) {
|
|
20
|
+
if (id !== RESOLVED_ID) return null
|
|
21
|
+
return opts.files
|
|
22
|
+
.map(f => `import ${JSON.stringify(`${resolve(opts.rootDir, f)}?${GLOBAL_LAYER_QUERY}`)}`)
|
|
23
|
+
.join('\n')
|
|
24
|
+
},
|
|
25
|
+
transform(code, id) {
|
|
26
|
+
if (!id.includes(`?${GLOBAL_LAYER_QUERY}`)) return null
|
|
27
|
+
return {
|
|
28
|
+
code: `@layer ${USER_GLOBALS_LAYER} {\n${code}\n}\n`,
|
|
29
|
+
map: null,
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getGlobalStylesVirtualId(): string {
|
|
36
|
+
return VIRTUAL_ID
|
|
37
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Plugin as VitePlugin } from 'vite'
|
|
2
|
+
import { APP_SCOPE_ROOT, DEFAULTS_LAYER, STORY_SCOPE_BOUNDARY } from './selectors.js'
|
|
3
|
+
import { wrapChromeCss } from './transforms.js'
|
|
4
|
+
import { isChromeCss } from './vite-plugin.js'
|
|
5
|
+
|
|
6
|
+
export interface ChromeCssScopePluginOptions {
|
|
7
|
+
enabled: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function chromeCssScopePlugin(opts: ChromeCssScopePluginOptions): VitePlugin {
|
|
11
|
+
return {
|
|
12
|
+
name: 'poveste:style-isolation:chrome-css',
|
|
13
|
+
transform(code, id) {
|
|
14
|
+
if (!opts.enabled) return null
|
|
15
|
+
if (isMainChromeCss(id)) {
|
|
16
|
+
return { code: wrapChromeCss(code, { scopeRoot: APP_SCOPE_ROOT, scopeLower: STORY_SCOPE_BOUNDARY }), map: null }
|
|
17
|
+
}
|
|
18
|
+
if (isSandboxChromeCss(id)) {
|
|
19
|
+
// Sandbox chrome defaults coexist with user CSS inside the iframe.
|
|
20
|
+
// @layer makes user's unlayered rules win the cascade.
|
|
21
|
+
return { code: `@layer ${DEFAULTS_LAYER} {\n${code}\n}\n`, map: null }
|
|
22
|
+
}
|
|
23
|
+
return null
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Stylesheets loaded only by the chrome page (bundle-main).
|
|
29
|
+
function isMainChromeCss(id: string): boolean {
|
|
30
|
+
if (!isChromeCss(id)) return false
|
|
31
|
+
const cleaned = id.split('?')[0]
|
|
32
|
+
return /(?:poveste-app|@poveste\/app)\/(?:dist\/style\.css|dist\/bundled\/[^/]+\.css|src\/app\/style\/main\.(?:css|pcss))$/.test(cleaned)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Stylesheets loaded into the sandbox iframe — must coexist with user CSS,
|
|
36
|
+
// so they get a cascade-layer wrap rather than an @scope wrap.
|
|
37
|
+
function isSandboxChromeCss(id: string): boolean {
|
|
38
|
+
if (!isChromeCss(id)) return false
|
|
39
|
+
const cleaned = id.split('?')[0]
|
|
40
|
+
return /\/(?:poveste-app|@poveste\/app)\/(?:dist|src)\/.*\bsandbox\.css$/.test(cleaned)
|
|
41
|
+
|| /\/(?:poveste-controls|@poveste\/controls)\/dist\/index\.es\.css$/.test(cleaned)
|
|
42
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Centralised CSS scope-root class names used by the style-isolation
|
|
2
|
+
// pipeline. Keep in sync with the runtime markers applied in
|
|
3
|
+
// poveste-app (App.vue's `poveste-app-root` wrapper, GenericRenderStory's
|
|
4
|
+
// `__poveste-render-story` class, sandbox.ts's body class).
|
|
5
|
+
|
|
6
|
+
export const APP_SCOPE_ROOT = '.poveste-app-root'
|
|
7
|
+
export const STORY_SCOPE_ROOT = '.__poveste-render-story'
|
|
8
|
+
// Custom-controls panels carry both classes (.__poveste-render-story and
|
|
9
|
+
// .__poveste-render-custom-controls) but render inside the chrome panel,
|
|
10
|
+
// so the @scope boundary must keep them in chrome scope.
|
|
11
|
+
export const STORY_SCOPE_BOUNDARY = '.__poveste-render-story:not(.__poveste-render-custom-controls)'
|
|
12
|
+
export const DEFAULTS_LAYER = 'poveste-defaults'
|
|
13
|
+
export const USER_GLOBALS_LAYER = 'poveste-user-globals'
|
|
14
|
+
// Import query `globalStyles` files carry so the user-CSS plugin leaves them
|
|
15
|
+
// alone — they belong in the chrome page, not inside the story scope.
|
|
16
|
+
export const GLOBAL_LAYER_QUERY = 'poveste-global-layer'
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { transform as lightningcssTransform } from 'lightningcss'
|
|
3
|
+
|
|
4
|
+
export interface WrapOptions {
|
|
5
|
+
scopeRoot: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface WrapWithLowerOptions {
|
|
9
|
+
scopeRoot: string
|
|
10
|
+
scopeLower: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function wrapUserCss(css: string, opts: WrapOptions): string {
|
|
14
|
+
const trimmed = css.trimStart()
|
|
15
|
+
if (trimmed.startsWith('@scope')) {
|
|
16
|
+
return css
|
|
17
|
+
}
|
|
18
|
+
const { hoisted, remainder } = extractHoistableAtRules(css)
|
|
19
|
+
const body = remainder.includes(':root')
|
|
20
|
+
? rewriteRootToScope(remainder)
|
|
21
|
+
: remainder
|
|
22
|
+
const hoistedBlock = hoisted.length > 0 ? `${hoisted.join('\n')}\n` : ''
|
|
23
|
+
return `${hoistedBlock}@scope (${opts.scopeRoot}) {\n${body}\n}\n`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function wrapChromeCss(css: string, opts: WrapWithLowerOptions): string {
|
|
27
|
+
const trimmed = css.trimStart()
|
|
28
|
+
if (trimmed.startsWith('@scope (')) {
|
|
29
|
+
return css
|
|
30
|
+
}
|
|
31
|
+
const { hoisted, remainder } = extractHoistableAtRules(css)
|
|
32
|
+
// Rewrite `:root` to `:scope` so source-level `@scope (:root) to (...)`
|
|
33
|
+
// rules nest correctly under the outer scope; without this, the inner
|
|
34
|
+
// `:root` resolves to the document root which is outside the outer scope.
|
|
35
|
+
const body = remainder.includes(':root')
|
|
36
|
+
? rewriteRootToScope(remainder)
|
|
37
|
+
: remainder
|
|
38
|
+
const hoistedBlock = hoisted.length > 0 ? `${hoisted.join('\n')}\n` : ''
|
|
39
|
+
return `${hoistedBlock}@scope (${opts.scopeRoot}) to (${opts.scopeLower}) {\n${body}\n}\n`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isGlobalImport(id: string): boolean {
|
|
43
|
+
const q = id.indexOf('?')
|
|
44
|
+
if (q === -1) return false
|
|
45
|
+
const parts = id.slice(q + 1).split('&')
|
|
46
|
+
return parts.includes('global') || parts.some(p => p.startsWith('global='))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function rewriteRootToScope(css: string): string {
|
|
50
|
+
const processed = lightningcssTransform({
|
|
51
|
+
filename: 'user.css',
|
|
52
|
+
code: Buffer.from(css, 'utf8'),
|
|
53
|
+
minify: false,
|
|
54
|
+
visitor: {
|
|
55
|
+
Selector(selector) {
|
|
56
|
+
for (const part of selector) {
|
|
57
|
+
if (part.type === 'pseudo-class' && (part as any).kind === 'root') {
|
|
58
|
+
;(part as any).kind = 'scope'
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return selector
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
return Buffer.from(processed.code).toString('utf8')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Top-level at-rule extraction. Anchored to line start with optional leading
|
|
69
|
+
// whitespace; covers the formatted output produced by Vite/PostCSS. Inputs
|
|
70
|
+
// with rules on the same line as other rules, or strings/comments containing
|
|
71
|
+
// `;`/`}`, are not handled — these are extremely rare in practice and would
|
|
72
|
+
// only mis-classify within a generated poveste-app or vendor stylesheet.
|
|
73
|
+
const HOIST_SEMI_RE = /^[ \t]*@(?:import|charset|namespace)\s[^;]+;[ \t]*\n?/gm
|
|
74
|
+
const HOIST_FONT_FACE_RE = /^[ \t]*@font-face\s*\{[^}]*\}[ \t]*\n?/gm
|
|
75
|
+
|
|
76
|
+
function extractHoistableAtRules(css: string): { hoisted: string[], remainder: string } {
|
|
77
|
+
const hoisted: string[] = []
|
|
78
|
+
let remainder = css
|
|
79
|
+
|
|
80
|
+
remainder = remainder.replace(HOIST_SEMI_RE, (match) => {
|
|
81
|
+
hoisted.push(match.trim())
|
|
82
|
+
return ''
|
|
83
|
+
})
|
|
84
|
+
remainder = remainder.replace(HOIST_FONT_FACE_RE, (match) => {
|
|
85
|
+
hoisted.push(match.trim())
|
|
86
|
+
return ''
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
return { hoisted, remainder }
|
|
90
|
+
}
|