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.
Files changed (92) hide show
  1. package/dist/node/bin.js +3 -1
  2. package/dist/node/build.d.ts.map +1 -1
  3. package/dist/node/build.js +23 -5
  4. package/dist/node/builtin-plugins/pinceau-tokens.js +35 -35
  5. package/dist/node/builtin-plugins/tailwind-tokens.js +38 -38
  6. package/dist/node/builtin-plugins/vanilla-support/RenderStory.d.ts.map +1 -1
  7. package/dist/node/builtin-plugins/vanilla-support/RenderStory.js +7 -4
  8. package/dist/node/builtin-plugins/vanilla-support/types.d.ts +6 -5
  9. package/dist/node/builtin-plugins/vanilla-support/types.d.ts.map +1 -1
  10. package/dist/node/commands/preview.d.ts +2 -0
  11. package/dist/node/commands/preview.d.ts.map +1 -1
  12. package/dist/node/commands/preview.js +6 -3
  13. package/dist/node/config.d.ts.map +1 -1
  14. package/dist/node/config.js +1 -0
  15. package/dist/node/context.js +2 -2
  16. package/dist/node/dom/env.d.ts.map +1 -1
  17. package/dist/node/dom/env.js +26 -1
  18. package/dist/node/markdown.js +1 -1
  19. package/dist/node/preview.d.ts +7 -1
  20. package/dist/node/preview.d.ts.map +1 -1
  21. package/dist/node/preview.js +22 -39
  22. package/dist/node/style-isolation/entry-css-merger.d.ts +9 -0
  23. package/dist/node/style-isolation/entry-css-merger.d.ts.map +1 -0
  24. package/dist/node/style-isolation/entry-css-merger.js +128 -0
  25. package/dist/node/style-isolation/global-styles.d.ts +8 -0
  26. package/dist/node/style-isolation/global-styles.d.ts.map +1 -0
  27. package/dist/node/style-isolation/global-styles.js +31 -0
  28. package/dist/node/style-isolation/index.d.ts +6 -0
  29. package/dist/node/style-isolation/index.d.ts.map +1 -0
  30. package/dist/node/style-isolation/index.js +5 -0
  31. package/dist/node/style-isolation/scope-chrome.d.ts +6 -0
  32. package/dist/node/style-isolation/scope-chrome.d.ts.map +1 -0
  33. package/dist/node/style-isolation/scope-chrome.js +37 -0
  34. package/dist/node/style-isolation/selectors.d.ts +7 -0
  35. package/dist/node/style-isolation/selectors.d.ts.map +1 -0
  36. package/dist/node/style-isolation/selectors.js +15 -0
  37. package/dist/node/style-isolation/transforms.d.ts +11 -0
  38. package/dist/node/style-isolation/transforms.d.ts.map +1 -0
  39. package/dist/node/style-isolation/transforms.js +75 -0
  40. package/dist/node/style-isolation/vite-plugin.d.ts +15 -0
  41. package/dist/node/style-isolation/vite-plugin.d.ts.map +1 -0
  42. package/dist/node/style-isolation/vite-plugin.js +62 -0
  43. package/dist/node/util/head.d.ts +3 -0
  44. package/dist/node/util/head.d.ts.map +1 -0
  45. package/dist/node/util/head.js +10 -0
  46. package/dist/node/virtual/markdown.js +1 -1
  47. package/dist/node/virtual/noop.d.ts +2 -1
  48. package/dist/node/virtual/noop.d.ts.map +1 -1
  49. package/dist/node/virtual/noop.js +7 -1
  50. package/dist/node/virtual/resolved-generated-global-setup.js +3 -3
  51. package/dist/node/virtual/resolved-markdown-files.js +2 -2
  52. package/dist/node/virtual/util.d.ts +15 -0
  53. package/dist/node/virtual/util.d.ts.map +1 -1
  54. package/dist/node/virtual/util.js +18 -0
  55. package/dist/node/virtual/vite-plugin.js +1 -1
  56. package/dist/node/vite.d.ts +1 -1
  57. package/dist/node/vite.d.ts.map +1 -1
  58. package/dist/node/vite.js +51 -9
  59. package/package.json +7 -5
  60. package/src/node/__tests__/dom-env.spec.ts +38 -0
  61. package/src/node/__tests__/head.spec.ts +32 -0
  62. package/src/node/__tests__/virtual-setup.spec.ts +51 -0
  63. package/src/node/bin.ts +3 -1
  64. package/src/node/build.ts +31 -5
  65. package/src/node/builtin-plugins/pinceau-tokens.ts +35 -35
  66. package/src/node/builtin-plugins/tailwind-tokens.ts +38 -38
  67. package/src/node/builtin-plugins/vanilla-support/RenderStory.ts +8 -5
  68. package/src/node/builtin-plugins/vanilla-support/types.ts +3 -1
  69. package/src/node/commands/preview.ts +8 -3
  70. package/src/node/config.ts +1 -0
  71. package/src/node/context.ts +2 -2
  72. package/src/node/dom/env.ts +26 -1
  73. package/src/node/markdown.ts +1 -1
  74. package/src/node/preview.ts +30 -47
  75. package/src/node/style-isolation/__tests__/entry-css-merger.spec.ts +126 -0
  76. package/src/node/style-isolation/__tests__/transforms.spec.ts +92 -0
  77. package/src/node/style-isolation/__tests__/vite-plugin.spec.ts +39 -0
  78. package/src/node/style-isolation/entry-css-merger.ts +133 -0
  79. package/src/node/style-isolation/global-styles.ts +37 -0
  80. package/src/node/style-isolation/index.ts +5 -0
  81. package/src/node/style-isolation/scope-chrome.ts +42 -0
  82. package/src/node/style-isolation/selectors.ts +16 -0
  83. package/src/node/style-isolation/transforms.ts +90 -0
  84. package/src/node/style-isolation/vite-plugin.ts +75 -0
  85. package/src/node/util/head.ts +12 -0
  86. package/src/node/virtual/markdown.ts +1 -1
  87. package/src/node/virtual/noop.ts +9 -1
  88. package/src/node/virtual/resolved-generated-global-setup.ts +3 -3
  89. package/src/node/virtual/resolved-markdown-files.ts +2 -2
  90. package/src/node/virtual/util.ts +22 -0
  91. package/src/node/virtual/vite-plugin.ts +1 -1
  92. package/src/node/vite.ts +57 -9
@@ -1,57 +1,40 @@
1
1
  import type { Context } from './context.js'
2
- import http from 'node:http'
3
- import connect from 'connect'
4
- import sirv from 'sirv'
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(port: number | null, ctx: Context): Promise<ReturnPayload> {
12
- const app = connect()
13
-
14
- app.use(
15
- ctx.resolvedViteConfig.base,
16
- sirv(ctx.config.outDir, {
17
- dev: true,
18
- etag: true,
19
- single: true,
20
- }),
21
- )
22
-
23
- let finalPort = port ?? 6006
24
-
25
- const httpServer = http.createServer(app)
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
- httpServer.on('error', onError)
33
+ const baseUrl = server.resolvedUrls?.local[0] ?? `http://localhost:${port}${ctx.resolvedViteConfig.base}`
38
34
 
39
- httpServer.listen(finalPort, () => {
40
- httpServer.off('error', onError)
41
- const baseUrl = `http://localhost:${finalPort}${ctx.resolvedViteConfig.base}`
42
- resolve({
43
- baseUrl,
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,5 @@
1
+ export * from './entry-css-merger.js'
2
+ export * from './global-styles.js'
3
+ export * from './scope-chrome.js'
4
+ export * from './transforms.js'
5
+ export * from './vite-plugin.js'
@@ -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
+ }