From 331e23bff742f699c01ca367bd34f1bc0236231f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 19 Oct 2026 01:31:11 +0800 Subject: [PATCH] fix(pages): link CSS that island and Markdown pages reach through components In production, island and Markdown pages only got the CSS their page and layout files import directly. A stylesheet imported by a component that a layout renders was missing, and when no file imported CSS directly the islands client entry was not built at all, so no page got a stylesheet. - Build the islands client entry for every app with island or Markdown pages, replacing the text scan in needsIslandClientBundle. - Record the stylesheets that island pages and their layouts reach through static imports from the server build's module graph, and import them in the islands client entry. This covers aliases, packages, CSS modules and Vue/Svelte style blocks, and keeps the CSS order of dev. - Keep CSS modules that the entry imports (Vite marks them side-effect free), keep Vue scoped styles whose component is not in the client bundle, and load Svelte styles through vite-plugin-svelte's own id so a rule is not emitted twice. - Without islands, the entry only holds the CSS imports, so no hydration runtime is bundled. The React and Solid entry is now added only to the client build. Fixes #452 Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/react/src/plugin-islands.ts | 66 ++++-- .../test/integration/island-css-build.test.ts | 81 +++++++ packages/solid/src/plugin-islands.ts | 64 ++++-- .../test/integration/island-css-build.test.ts | 30 +++ packages/svelte/src/plugin-islands.ts | 83 ++++++-- .../test/integration/island-css-build.test.ts | 64 ++++++ packages/void/src/pages/islands-plugin.ts | 199 ++++++++++++++---- .../void/test/unit/island-css-rewrite.test.ts | 79 +++++++ packages/vue/src/plugin-islands.ts | 59 +++++- .../test/integration/island-css-build.test.ts | 44 ++++ test/island-css-build.ts | 127 +++++++++++ 11 files changed, 790 insertions(+), 106 deletions(-) create mode 100644 packages/react/test/integration/island-css-build.test.ts create mode 100644 packages/solid/test/integration/island-css-build.test.ts create mode 100644 packages/svelte/test/integration/island-css-build.test.ts create mode 100644 packages/vue/test/integration/island-css-build.test.ts create mode 100644 test/island-css-build.ts diff --git a/packages/react/src/plugin-islands.ts b/packages/react/src/plugin-islands.ts index cacb8db78..c7bcce16c 100644 --- a/packages/react/src/plugin-islands.ts +++ b/packages/react/src/plugin-islands.ts @@ -5,7 +5,10 @@ import { generateComponentManifest, scanPages, resolveEffectiveLayoutChain } fro import type { PageScanResult } from 'void/pages'; import { resolveSpecifier, - needsIslandClientBundle, + hasIslandPages, + recordIslandStyles, + recordedIslandStyles, + islandStylesPlugin, computePagesNeedingClientJS, watchPagesForRescan, rewriteCssImportForVirtualModule, @@ -121,6 +124,10 @@ export function islandsPlugin( } }, + buildEnd() { + recordIslandStyles(this, pageScan, pagesDir); + }, + resolveId: { filter: { id: /^virtual:void-islands-(renderer|client)$/ }, handler(source) { @@ -151,7 +158,14 @@ export function islandsPlugin( if (!pageScan) { return ''; } - return generateIslandClientEntry(islandManifest, islandCssFiles, isDev); + return generateIslandClientEntry( + islandManifest, + islandCssFiles, + recordedIslandStyles(pagesDir), + islandManifest.size > 0 || + computePagesNeedingClientJS(pageScan, pagesDir, resolvedConfig).size > 0, + isDev, + ); } }, }, @@ -211,26 +225,24 @@ export function islandsPlugin( }, }, - config(_, env) { - if (!existsSync(pagesDir)) { - return; - } - // Only add the islands client entry when content needs it - if ( - env.command !== 'serve' && - needsIslandClientBundle(pagesDir, /\.island\.(tsx|jsx)$/, false) - ) { + async config(_, env) { + if (env.command !== 'serve' && (await hasIslandPages(root, pagesDir))) { return { - build: { - manifest: true, - rollupOptions: { - input: { 'islands-client': ISLANDS_CLIENT_ENTRY_ID }, + environments: { + client: { + build: { + manifest: true, + rollupOptions: { + input: { 'islands-client': ISLANDS_CLIENT_ENTRY_ID }, + }, + }, }, }, }; } }, }, + islandStylesPlugin(pagesDir), ]; } @@ -319,6 +331,8 @@ export async function renderIslandPage(pageObj, assetTags) { function generateIslandClientEntry( manifest: Map, cssFiles: Set, + styles: ReadonlyArray | null, + needsRuntime: boolean, isDev: boolean, ): string { const entries = [...manifest.values()]; @@ -329,13 +343,16 @@ function generateIslandClientEntry( const lines: Array = []; - lines.push('import { initIslands } from "void/pages-client";'); - // In dev, first-paint CSS is provided by Void's shared dev CSS collector. // Skip CSS imports here to avoid duplicate client injection. - // In production, the tags aren't used — the imports below ensure - // the CSS is included in the bundled output. - if (!isDev) { + // In production, the imports below bundle the CSS that island pages link. + // `styles` comes from the server build; without one, only the direct + // imports of the pages and layouts are known. + if (!isDev && styles) { + for (const id of styles) { + lines.push(`import ${JSON.stringify(id)};`); + } + } else if (!isDev) { for (const file of cssFiles) { const content = readFileSync(file, 'utf-8'); for (const item of findStaticCssImports(content, file)) { @@ -344,6 +361,15 @@ function generateIslandClientEntry( } } + // Without islands, no built page gets this script (see + // computePagesNeedingClientJS), so the entry only gives the styles a chunk to + // link. Dev keeps the runtime: it does not reload this module when a page + // gains an island. + if (!needsRuntime && !isDev) { + return lines.join('\n'); + } + + lines.push('import { initIslands } from "void/pages-client";'); lines.push('const islands = {'); for (const [id, path] of uniqueIslands) { lines.push(` ${JSON.stringify(id)}: () => import(${JSON.stringify(path)}),`); diff --git a/packages/react/test/integration/island-css-build.test.ts b/packages/react/test/integration/island-css-build.test.ts new file mode 100644 index 000000000..97149f117 --- /dev/null +++ b/packages/react/test/integration/island-css-build.test.ts @@ -0,0 +1,81 @@ +import { join } from 'node:path'; +import { expect, it } from 'vite-plus/test'; +import { buildIslandApp, defineIslandCssBuildTest } from '../../../../test/island-css-build.ts'; + +const packageDir = join(import.meta.dirname, '../..'); +const tsconfig = JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } }); + +defineIslandCssBuildTest({ + packageDir, + pluginName: 'voidReact', + files: { + 'tsconfig.json': tsconfig, + 'src/shell.css': '.shell { outline-offset: 7px; }', + 'src/card.module.css': '.card { text-indent: 9px; }', + 'src/badge.css': '.badge { word-spacing: 5px; }', + 'src/Badge.tsx': `import './badge.css'; +export default function Badge() { return Badge; }`, + 'src/Shell.tsx': `import './shell.css'; +import styles from './card.module.css'; +import Badge from './Badge'; +export default function Shell({ children }) { + return
{children}
; +}`, + 'pages/layout.tsx': `import Shell from '../src/Shell'; +export default function Layout({ children }) { return {children}; }`, + 'pages/index.island.tsx': 'export default function Home() { return

Home

; }', + // The layout renders Badge without hydrating it; this page hydrates it. + 'pages/badge.island.tsx': `import Badge from '../src/Badge' with { island: 'load' }; +export default function BadgePage() { return ; }`, + }, + expectedCss: ['.shell{outline-offset:7px}', '{text-indent:9px}', '.badge{word-spacing:5px}'], +}); + +it('builds island pages without CSS or JavaScript with no stylesheet, script or client runtime', () => { + const build = buildIslandApp({ + packageDir, + pluginName: 'voidReact', + files: { + 'tsconfig.json': tsconfig, + 'pages/layout.tsx': + 'export default function Layout({ children }) { return
{children}
; }', + 'pages/index.island.tsx': 'export default function Home() { return

Home

; }', + }, + }); + try { + for (const file of ['index.html', 'about.html']) { + const { html } = build.page(file); + expect(html, file).toContain(''); + expect(html, file).not.toContain(' { + const build = buildIslandApp({ + packageDir, + pluginName: 'voidReact', + files: { + 'tsconfig.json': tsconfig, + 'src/Counter.tsx': 'export default function Counter() { return ; }', + 'pages/index.island.tsx': `import Counter from '../src/Counter' with { island: 'load' }; +export default function Home() { return ; }`, + }, + }); + try { + expect(build.page('index.html').html).toContain(' 0 || + computePagesNeedingClientJS(pageScan, pagesDir, resolvedConfig).size > 0, + isDev, + ); } if (id.startsWith(WRAPPER_PREFIX)) { const meta = wrapperMeta.get(id); @@ -214,25 +228,24 @@ export function islandsPlugin( }, }, - config(_, env) { - if (!existsSync(pagesDir)) { - return; - } - if ( - env.command !== 'serve' && - needsIslandClientBundle(pagesDir, /\.island\.(tsx|jsx)$/, false) - ) { + async config(_, env) { + if (env.command !== 'serve' && (await hasIslandPages(root, pagesDir))) { return { - build: { - manifest: true, - rollupOptions: { - input: { 'islands-client': ISLANDS_CLIENT_ENTRY_ID }, + environments: { + client: { + build: { + manifest: true, + rollupOptions: { + input: { 'islands-client': ISLANDS_CLIENT_ENTRY_ID }, + }, + }, }, }, }; } }, }, + islandStylesPlugin(pagesDir), ]; } @@ -337,6 +350,8 @@ export async function renderIslandPage(pageObj, assetTags) { function generateIslandClientEntry( manifest: Map, cssFiles: Set, + styles: ReadonlyArray | null, + needsRuntime: boolean, isDev: boolean, ): string { const entries = [...manifest.values()]; @@ -346,13 +361,17 @@ function generateIslandClientEntry( } const lines: Array = []; - lines.push('import { initIslands } from "void/pages-client";'); // In dev, first-paint CSS is provided by Void's shared dev CSS collector. // Skip CSS imports here to avoid duplicate client injection. - // In production, the tags aren't used — the imports below ensure - // the CSS is included in the bundled output. - if (!isDev) { + // In production, the imports below bundle the CSS that island pages link. + // `styles` comes from the server build; without one, only the direct + // imports of the pages and layouts are known. + if (!isDev && styles) { + for (const id of styles) { + lines.push(`import ${JSON.stringify(id)};`); + } + } else if (!isDev) { for (const file of cssFiles) { const content = readFileSync(file, 'utf-8'); for (const item of findStaticCssImports(content, file)) { @@ -361,6 +380,15 @@ function generateIslandClientEntry( } } + // Without islands, no built page gets this script (see + // computePagesNeedingClientJS), so the entry only gives the styles a chunk to + // link. Dev keeps the runtime: it does not reload this module when a page + // gains an island. + if (!needsRuntime && !isDev) { + return lines.join('\n'); + } + + lines.push('import { initIslands } from "void/pages-client";'); lines.push('const islands = {'); for (const [id, path] of uniqueIslands) { lines.push(` ${JSON.stringify(id)}: () => import(${JSON.stringify(path)}),`); diff --git a/packages/solid/test/integration/island-css-build.test.ts b/packages/solid/test/integration/island-css-build.test.ts new file mode 100644 index 000000000..0df086474 --- /dev/null +++ b/packages/solid/test/integration/island-css-build.test.ts @@ -0,0 +1,30 @@ +import { join } from 'node:path'; +import { defineIslandCssBuildTest } from '../../../../test/island-css-build.ts'; + +defineIslandCssBuildTest({ + packageDir: join(import.meta.dirname, '../..'), + pluginName: 'voidSolid', + files: { + 'tsconfig.json': JSON.stringify({ + compilerOptions: { jsx: 'preserve', jsxImportSource: 'solid-js' }, + }), + 'src/shell.css': '.shell { outline-offset: 7px; }', + 'src/card.module.css': '.card { text-indent: 9px; }', + 'src/badge.css': '.badge { word-spacing: 5px; }', + 'src/Badge.tsx': `import './badge.css'; +export default function Badge() { return Badge; }`, + 'src/Shell.tsx': `import './shell.css'; +import styles from './card.module.css'; +import Badge from './Badge'; +export default function Shell(props) { + return
{props.children}
; +}`, + 'pages/layout.tsx': `import Shell from '../src/Shell'; +export default function Layout(props) { return {props.children}; }`, + 'pages/index.island.tsx': 'export default function Home() { return

Home

; }', + // The layout renders Badge without hydrating it; this page hydrates it. + 'pages/badge.island.tsx': `import Badge from '../src/Badge' with { island: 'load' }; +export default function BadgePage() { return ; }`, + }, + expectedCss: ['.shell{outline-offset:7px}', '{text-indent:9px}', '.badge{word-spacing:5px}'], +}); diff --git a/packages/svelte/src/plugin-islands.ts b/packages/svelte/src/plugin-islands.ts index 215609919..af30c975f 100644 --- a/packages/svelte/src/plugin-islands.ts +++ b/packages/svelte/src/plugin-islands.ts @@ -6,7 +6,10 @@ import { generateComponentManifest, scanPages, resolveEffectiveLayoutChain } fro import type { PageScanResult } from 'void/pages'; import { resolveSpecifier, - needsIslandClientBundle, + hasIslandPages, + recordIslandStyles, + recordedIslandStyles, + islandStylesPlugin, rewriteCssImportForVirtualModule, computePagesNeedingClientJS, watchPagesForRescan, @@ -138,6 +141,10 @@ export function islandsPlugin( } }, + buildEnd() { + recordIslandStyles(this, pageScan, pagesDir); + }, + resolveId: { filter: { id: /^(virtual:void-islands-(renderer|client)|\/@void-island-wrap\/)/, @@ -177,7 +184,14 @@ export function islandsPlugin( if (!pageScan) { return ''; } - return generateIslandClientEntry(islandManifest, islandCssFiles, isDev); + return generateIslandClientEntry( + islandManifest, + islandCssFiles, + recordedIslandStyles(pagesDir), + islandManifest.size > 0 || + computePagesNeedingClientJS(pageScan, pagesDir, resolvedConfig).size > 0, + isDev, + ); } if (id.startsWith(WRAPPER_PREFIX)) { return generateWrapperComponent(id); @@ -227,14 +241,14 @@ export function islandsPlugin( }, }, - config(_, env) { + async config(_, env) { if (!existsSync(pagesDir)) { return; } if (env.command === 'serve') { return { optimizeDeps: { include: ['svelte'] } }; } - if (needsIslandClientBundle(pagesDir, /\.island\.svelte$/, true)) { + if (await hasIslandPages(root, pagesDir)) { return { environments: { client: { @@ -252,18 +266,21 @@ export function islandsPlugin( }, { name: 'void-svelte:islands-style', - // Only the build's client entry imports `.svelte.css?island-style` - // (dev CSS comes from Void's dev CSS collector). - // - The id stays next to the component, so CSS url() resolves as in the - // component, and it misses vite-plugin-svelte's `.svelte?` filter. + // Loads the component styles that the build's client entry imports (dev + // CSS comes from Void's dev CSS collector): vite-plugin-svelte's + // `.svelte?svelte&type=style` ids that the server build recorded, + // or `.svelte.css?island-style` without a server build. // - The CSS comes from vite-plugin-svelte's own preprocess options and // compile, so the scope hashes match the SSR markup. // - The component is compiled here, not loaded with `this.load`: island - // pages and layouts render only on the server, and loading them would - // resolve their server-only imports in the client build. - // - vite-plugin-svelte's `?svelte&type=style` id is not used: it only - // reads CSS already cached on the module, and its cssScopeTo drops the - // CSS when the component is not imported by the client build. + // pages, their layouts and the components they import render only on + // the server, and loading them would resolve their server-only imports + // in the client build. + // - vite-plugin-svelte's own load only reads CSS already cached on the + // compiled component, and its cssScopeTo drops the CSS when the client + // build does not import the component. Loading its id here keeps one + // module, so a component that the client build also imports does not + // bring the same rules twice. apply: 'build', resolveId: { @@ -274,9 +291,20 @@ export function islandsPlugin( }, load: { - filter: { id: /\.svelte\.css\?island-style$/ }, + filter: { id: [/\.svelte\.css\?island-style$/, /\.svelte\?svelte&type=style/] }, + order: 'pre', async handler(id) { - const file = id.slice(0, -'.css?island-style'.length); + const islandStyle = id.endsWith('.css?island-style'); + if ( + !islandStyle && + (this.environment.config.consumer !== 'client' || + !recordedIslandStyles(pagesDir)?.includes(id)) + ) { + return; + } + const file = islandStyle + ? id.slice(0, -'.css?island-style'.length) + : id.slice(0, id.indexOf('?')); const api = resolvedConfig?.plugins.find( (plugin): plugin is SvelteConfigPlugin => plugin.name === 'vite-plugin-svelte:config', )?.api; @@ -309,6 +337,7 @@ export function islandsPlugin( }, }, }, + islandStylesPlugin(pagesDir), ]; } @@ -398,6 +427,8 @@ export async function renderIslandPage(pageObj, assetTags) { function generateIslandClientEntry( manifest: Map, cssFiles: Set, + styles: ReadonlyArray | null, + needsRuntime: boolean, isDev: boolean, ): string { const entries = [...manifest.values()]; @@ -408,13 +439,16 @@ function generateIslandClientEntry( const lines: Array = []; - lines.push('import { initIslands } from "void/pages-client";'); - // In dev, first-paint CSS is provided by Void's shared dev CSS collector. // Skip CSS imports here to avoid duplicate client injection. - // In production, the tags aren't used — the imports below ensure - // the CSS is included in the bundled output. - if (!isDev) { + // In production, the imports below bundle the CSS that island pages link. + // `styles` comes from the server build; without one, only the direct + // imports and style blocks of the pages and layouts are known. + if (!isDev && styles) { + for (const id of styles) { + lines.push(`import ${JSON.stringify(id)};`); + } + } else if (!isDev) { for (const file of cssFiles) { const content = readFileSync(file, 'utf-8'); @@ -429,6 +463,15 @@ function generateIslandClientEntry( } } + // Without islands, no built page gets this script (see + // computePagesNeedingClientJS), so the entry only gives the styles a chunk to + // link. Dev keeps the runtime: it does not reload this module when a page + // gains an island. + if (!needsRuntime && !isDev) { + return lines.join('\n'); + } + + lines.push('import { initIslands } from "void/pages-client";'); lines.push('const islands = {'); for (const [id, path] of uniqueIslands) { lines.push(` ${JSON.stringify(id)}: () => import(${JSON.stringify(path)}),`); diff --git a/packages/svelte/test/integration/island-css-build.test.ts b/packages/svelte/test/integration/island-css-build.test.ts new file mode 100644 index 000000000..aafd2458b --- /dev/null +++ b/packages/svelte/test/integration/island-css-build.test.ts @@ -0,0 +1,64 @@ +import { join } from 'node:path'; +import { expect, it } from 'vite-plus/test'; +import { + buildIslandApp, + countOf, + defineIslandCssBuildTest, +} from '../../../../test/island-css-build.ts'; + +const packageDir = join(import.meta.dirname, '../..'); +const files = { + 'src/shell.css': '.shell { outline-offset: 7px; }', + 'src/card.module.css': '.card { text-indent: 9px; }', + 'src/Badge.svelte': 'Badge\n', + 'src/Shell.svelte': ` +
{@render children()}
+`, + 'pages/layout.svelte': ` +{@render children()}`, + 'pages/index.island.svelte': '

Home

', + // The layout renders Badge without hydrating it; this page hydrates it. + 'pages/badge.island.svelte': ` +`, +}; + +defineIslandCssBuildTest({ + packageDir, + pluginName: 'voidSvelte', + files, + expectedCss: ['.shell{outline-offset:7px}', '{text-indent:9px}', '{word-spacing:5px}'], + check({ file, html, css }) { + // The style block's rule carries the scope class that the server markup has. + const start = html.indexOf('
name.startsWith('svelte-')); + expect(css, file).toContain(`main.${scope}{outline-width:9px}`); + }, +}); + +it('links component styles once when a regular page also uses the layout', () => { + const build = buildIslandApp({ + packageDir, + pluginName: 'voidSvelte', + files: { ...files, 'pages/regular.svelte': '

Regular

' }, + }); + try { + for (const file of ['index.html', 'about.html', 'regular.html']) { + const { css } = build.page(file); + expect(countOf(css, '{outline-width:9px}'), file).toBe(1); + } + } finally { + build.cleanup(); + } +}, 120_000); diff --git a/packages/void/src/pages/islands-plugin.ts b/packages/void/src/pages/islands-plugin.ts index 3e5443c0e..2aab46682 100644 --- a/packages/void/src/pages/islands-plugin.ts +++ b/packages/void/src/pages/islands-plugin.ts @@ -1,6 +1,12 @@ -import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'pathslash'; -import { normalizePath, type ResolvedConfig, type ViteDevServer } from 'vite'; +import { + isCSSRequest, + normalizePath, + type Plugin, + type ResolvedConfig, + type ViteDevServer, +} from 'vite'; import { findIslandImports, findStaticCssImports } from './island-imports.ts'; import { resolveEffectiveLayoutChain, scanPages } from './scan.ts'; import type { PageScanResult } from './types.ts'; @@ -53,48 +59,163 @@ export function resolveSpecifier( } /** - * Check whether the islands client bundle is needed by scanning file contents. - * Returns true if any island/layout file has island imports or CSS, - * or any .md file has a ` + + + +`, + 'pages/layout.vue': ` +`, + 'pages/index.island.vue': '', + // The layout renders Badge without hydrating it; this page hydrates it. + 'pages/badge.island.vue': ` +`, + }, + expectedCss: [ + '.shell{outline-offset:7px}', + 'main{outline-width:9px}', + '{text-indent:9px}', + '.badge{word-spacing:5px}', + ], + check({ file, html, css }) { + // The scoped rule carries the attribute that the server markup has. + const start = html.indexOf('data-v-'); + expect(start, file).toBeGreaterThan(-1); + const scope = html.slice(start, html.indexOf('>', start)).split(' ')[0]; + expect(css, file).toContain(`.note[${scope}]{outline-width:11px}`); + }, +}); diff --git a/test/island-css-build.ts b/test/island-css-build.ts new file mode 100644 index 000000000..cbc670f09 --- /dev/null +++ b/test/island-css-build.ts @@ -0,0 +1,127 @@ +import { execSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { expect, it } from 'vite-plus/test'; + +const MD_PLUGIN = join(import.meta.dirname, '../packages/md/dist/plugin.mjs'); + +type IslandCssApp = { + /** The adapter package directory, e.g. `packages/react`. */ + packageDir: string; + /** The adapter's plugin export in `dist/plugin.mjs`, e.g. `voidReact`. */ + pluginName: string; + /** App files by path. `index` and `about` must ship no JavaScript. */ + files: Record; + /** Rules (as minified CSS) every page must link, found once in the client build. */ + expectedCss: Array; + /** Extra assertions for each page. */ + check?: (page: { file: string; html: string; css: string }) => void; +}; + +/** The stylesheet links of a page, without a regular expression. */ +function stylesheetHrefs(html: string): Array { + const marker = '` resolves to the adapter package itself. + */ +export function buildIslandApp(app: Omit): { + root: string; + page(file: string): { html: string; css: string }; + /** The text of every built file under `dist/`, by path. */ + output(dir: 'client' | 'ssr'): Map; + cleanup(): void; +} { + const root = mkdtempSync(join(app.packageDir, 'test/integration/__tmp_island_css_')); + try { + const plugin = pathToFileURL(join(app.packageDir, 'dist/plugin.mjs')).href; + const files: Record = { + 'vite.config.mjs': `import { voidPlugin } from 'void'; +import { ${app.pluginName} } from ${JSON.stringify(plugin)}; +import { voidMarkdown } from ${JSON.stringify(pathToFileURL(MD_PLUGIN).href)}; +export default { plugins: [voidPlugin(), ${app.pluginName}(), voidMarkdown()] }; +`, + 'void.config.ts': `import { defineConfig } from 'void/config'; +export default defineConfig({ output: 'static', worker: { compatibility_date: '2026-02-24' } }); +`, + 'pages/about.md': '---\ntitle: About\n---\n\n# About\n', + ...app.files, + }; + for (const [file, content] of Object.entries(files)) { + mkdirSync(dirname(join(root, file)), { recursive: true }); + writeFileSync(join(root, file), content); + } + // The native loader keeps the config's imports in the adapter package scope. + execSync('vp build --configLoader native', { cwd: root, stdio: 'pipe' }); + } catch (error) { + rmSync(root, { recursive: true, force: true }); + throw error; + } + const client = join(root, 'dist/client'); + return { + root, + page(file) { + const html = readFileSync(join(client, file), 'utf-8'); + const css = stylesheetHrefs(html) + .map((href) => readFileSync(join(client, href), 'utf-8')) + .join('\n'); + return { html, css }; + }, + output(dir) { + const out = join(root, 'dist', dir); + const files = new Map(); + for (const file of readdirSync(out, { recursive: true, encoding: 'utf-8' })) { + if (file.endsWith('.js') || file.endsWith('.css') || file.endsWith('.html')) { + files.set(file, readFileSync(join(out, file), 'utf-8')); + } + } + return files; + }, + cleanup() { + rmSync(root, { recursive: true, force: true }); + }, + }; +} + +/** How many times `text` contains `part`. */ +export function countOf(text: string, part: string): number { + return text.split(part).length - 1; +} + +/** + * Shared by the four adapters: a layout renders a component that imports CSS. + * Production must link that CSS on island and Markdown pages, as dev does, + * even though the pages ship no JavaScript. + */ +export function defineIslandCssBuildTest(app: IslandCssApp): void { + it('links the CSS that a layout component imports on island and Markdown pages', () => { + const build = buildIslandApp(app); + try { + for (const file of ['index.html', 'about.html']) { + const { html, css } = build.page(file); + for (const rule of app.expectedCss) { + expect(countOf(css, rule), `${file}: ${rule}`).toBe(1); + } + expect(html, file).not.toContain(' file.endsWith('.css')) + .map(([, text]) => text) + .join('\n'); + for (const rule of app.expectedCss) { + expect(countOf(clientCss, rule), rule).toBe(1); + } + } finally { + build.cleanup(); + } + }, 120_000); +}