diff --git a/packages/wxt/e2e/tests/output-structure.test.ts b/packages/wxt/e2e/tests/output-structure.test.ts index 2a691a9fe..0b2d44a5c 100644 --- a/packages/wxt/e2e/tests/output-structure.test.ts +++ b/packages/wxt/e2e/tests/output-structure.test.ts @@ -291,6 +291,41 @@ describe('Output Directory Structure', () => { `); }); + it('should not duplicate content script CSS in assets/ directory', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/content.content/index.ts', + `import './style.css'; + export default defineContentScript({ + matches: ["*://*/*"], + main: () => {}, + })`, + ); + project.addFile( + 'entrypoints/content.content/style.css', + `body { color: blue; }`, + ); + + await project.build(); + + // Verify CSS only exists in content-scripts/, not in assets/ + expect( + await project.pathExists( + '.output/chrome-mv3/content-scripts/content.css', + ), + ).toBe(true); + expect( + await project.pathExists('.output/chrome-mv3/assets/content.css'), + ).toBe(false); + + // Verify the manifest references the correct CSS file + const manifest = await project.serializeFile( + '.output/chrome-mv3/manifest.json', + ); + expect(manifest).toContain('content-scripts/content.css'); + expect(manifest).not.toContain('assets/content.css'); + }); + it("should output to a custom directory when overriding 'outDir'", async () => { const project = new TestProject(); project.addFile('entrypoints/unlisted.html', ''); diff --git a/packages/wxt/src/core/utils/building/__tests__/deduplicate-css.test.ts b/packages/wxt/src/core/utils/building/__tests__/deduplicate-css.test.ts new file mode 100644 index 000000000..c77ac9c91 --- /dev/null +++ b/packages/wxt/src/core/utils/building/__tests__/deduplicate-css.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { deduplicateCss } from '../deduplicate-css'; +import { BuildOutput, OutputAsset } from '../../../../types'; +import { mkdir, writeFile, readFile, rm } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { setFakeWxt } from '../../testing/fake-objects'; + +describe('deduplicateCss', () => { + const testOutDir = resolve(__dirname, '.test-output'); + + beforeEach(async () => { + // Setup test output directory + await rm(testOutDir, { recursive: true, force: true }); + await mkdir(testOutDir, { recursive: true }); + await mkdir(resolve(testOutDir, 'content-scripts'), { recursive: true }); + await mkdir(resolve(testOutDir, 'assets'), { recursive: true }); + + // Mock wxt with test output directory + setFakeWxt({ + config: { + outDir: testOutDir, + }, + }); + }); + + afterEach(async () => { + // Cleanup + await rm(testOutDir, { recursive: true, force: true }); + }); + + it('should remove duplicate CSS files from assets/', async () => { + // Setup: Create identical CSS files in both locations + const cssContent = '.example { color: red; }'; + const csPath = resolve(testOutDir, 'content-scripts/content.css'); + const assetPath = resolve(testOutDir, 'assets/content.css'); + + await writeFile(csPath, cssContent, 'utf-8'); + await writeFile(assetPath, cssContent, 'utf-8'); + + const output: Omit = { + steps: [ + { + entrypoints: [] as any, + chunks: [ + { + type: 'asset', + fileName: 'content-scripts/content.css', + } as OutputAsset, + { + type: 'asset', + fileName: 'assets/content.css', + } as OutputAsset, + ], + }, + ], + publicAssets: [], + }; + + await deduplicateCss(output); + + // Verify the duplicate in assets/ was removed + await expect(readFile(csPath, 'utf-8')).resolves.toBe(cssContent); + await expect(readFile(assetPath, 'utf-8')).rejects.toThrow(); + + // Verify it was removed from output chunks + expect(output.steps[0].chunks).toHaveLength(1); + expect(output.steps[0].chunks[0].fileName).toBe( + 'content-scripts/content.css', + ); + }); + + it('should not remove non-duplicate CSS files from assets/', async () => { + // Setup: Create different CSS files with different names + const csContent = '.content { color: red; }'; + const assetContent = '.other { color: blue; }'; + const csPath = resolve(testOutDir, 'content-scripts/content.css'); + const assetPath = resolve(testOutDir, 'assets/other.css'); + + await writeFile(csPath, csContent, 'utf-8'); + await writeFile(assetPath, assetContent, 'utf-8'); + + const output: Omit = { + steps: [ + { + entrypoints: [] as any, + chunks: [ + { + type: 'asset', + fileName: 'content-scripts/content.css', + } as OutputAsset, + { + type: 'asset', + fileName: 'assets/other.css', + } as OutputAsset, + ], + }, + ], + publicAssets: [], + }; + + await deduplicateCss(output); + + // Verify both files still exist (different names, so not duplicates) + await expect(readFile(csPath, 'utf-8')).resolves.toBe(csContent); + await expect(readFile(assetPath, 'utf-8')).resolves.toBe(assetContent); + + // Verify both are still in output chunks + expect(output.steps[0].chunks).toHaveLength(2); + }); + + it('should not remove files with same name but different content', async () => { + // Setup: Create files with same name but different content + const csContent = '.content { color: red; }'; + const assetContent = '.content { color: blue; }'; // Different content + const csPath = resolve(testOutDir, 'content-scripts/content.css'); + const assetPath = resolve(testOutDir, 'assets/content.css'); + + await writeFile(csPath, csContent, 'utf-8'); + await writeFile(assetPath, assetContent, 'utf-8'); + + const output: Omit = { + steps: [ + { + entrypoints: [] as any, + chunks: [ + { + type: 'asset', + fileName: 'content-scripts/content.css', + } as OutputAsset, + { + type: 'asset', + fileName: 'assets/content.css', + } as OutputAsset, + ], + }, + ], + publicAssets: [], + }; + + await deduplicateCss(output); + + // Verify both files still exist (different content, so not duplicates) + await expect(readFile(csPath, 'utf-8')).resolves.toBe(csContent); + await expect(readFile(assetPath, 'utf-8')).resolves.toBe(assetContent); + + // Verify both are still in output chunks + expect(output.steps[0].chunks).toHaveLength(2); + }); + + it('should not remove assets with same content but different names', async () => { + // Setup: Different base names, same content (legitimate separate files) + const sameContent = '.shared { color: red; }'; + const cs1Path = resolve(testOutDir, 'content-scripts/content1.css'); + const asset2Path = resolve(testOutDir, 'assets/content2.css'); + + await writeFile(cs1Path, sameContent, 'utf-8'); + await writeFile(asset2Path, sameContent, 'utf-8'); + + const output: Omit = { + steps: [ + { + entrypoints: [] as any, + chunks: [ + { + type: 'asset', + fileName: 'content-scripts/content1.css', + } as OutputAsset, + { + type: 'asset', + fileName: 'assets/content2.css', + } as OutputAsset, + ], + }, + ], + publicAssets: [], + }; + + await deduplicateCss(output); + + // Verify both files still exist (different names, even though content is same) + await expect(readFile(cs1Path, 'utf-8')).resolves.toBe(sameContent); + await expect(readFile(asset2Path, 'utf-8')).resolves.toBe(sameContent); + + // Verify both are still in output chunks + expect(output.steps[0].chunks).toHaveLength(2); + }); + + it('should handle multiple content script CSS files', async () => { + // Setup: Create multiple content scripts with CSS + const css1Content = '.content1 { color: red; }'; + const css2Content = '.content2 { color: blue; }'; + + const cs1Path = resolve(testOutDir, 'content-scripts/content1.css'); + const cs2Path = resolve(testOutDir, 'content-scripts/content2.css'); + const asset1Path = resolve(testOutDir, 'assets/content1.css'); + const asset2Path = resolve(testOutDir, 'assets/content2.css'); + + await writeFile(cs1Path, css1Content, 'utf-8'); + await writeFile(cs2Path, css2Content, 'utf-8'); + await writeFile(asset1Path, css1Content, 'utf-8'); // Duplicate + await writeFile(asset2Path, css2Content, 'utf-8'); // Duplicate + + const output: Omit = { + steps: [ + { + entrypoints: [] as any, + chunks: [ + { + type: 'asset', + fileName: 'content-scripts/content1.css', + } as OutputAsset, + { + type: 'asset', + fileName: 'content-scripts/content2.css', + } as OutputAsset, + { + type: 'asset', + fileName: 'assets/content1.css', + } as OutputAsset, + { + type: 'asset', + fileName: 'assets/content2.css', + } as OutputAsset, + ], + }, + ], + publicAssets: [], + }; + + await deduplicateCss(output); + + // Verify duplicates in assets/ were removed + await expect(readFile(cs1Path, 'utf-8')).resolves.toBe(css1Content); + await expect(readFile(cs2Path, 'utf-8')).resolves.toBe(css2Content); + await expect(readFile(asset1Path, 'utf-8')).rejects.toThrow(); + await expect(readFile(asset2Path, 'utf-8')).rejects.toThrow(); + + // Verify only content-script CSS remains in output + expect(output.steps[0].chunks).toHaveLength(2); + expect(output.steps[0].chunks[0].fileName).toBe( + 'content-scripts/content1.css', + ); + expect(output.steps[0].chunks[1].fileName).toBe( + 'content-scripts/content2.css', + ); + }); + + it('should do nothing when no content script CSS exists', async () => { + // Setup: Only assets CSS, no content-scripts CSS + const assetContent = '.asset { color: blue; }'; + const assetPath = resolve(testOutDir, 'assets/styles.css'); + + await writeFile(assetPath, assetContent, 'utf-8'); + + const output: Omit = { + steps: [ + { + entrypoints: [] as any, + chunks: [ + { + type: 'asset', + fileName: 'assets/styles.css', + } as OutputAsset, + ], + }, + ], + publicAssets: [], + }; + + await deduplicateCss(output); + + // Verify asset CSS is still there + await expect(readFile(assetPath, 'utf-8')).resolves.toBe(assetContent); + expect(output.steps[0].chunks).toHaveLength(1); + }); + + it('should handle missing files gracefully', async () => { + // Setup: Reference files that don't exist + const output: Omit = { + steps: [ + { + entrypoints: [] as any, + chunks: [ + { + type: 'asset', + fileName: 'content-scripts/nonexistent.css', + } as OutputAsset, + { + type: 'asset', + fileName: 'assets/also-nonexistent.css', + } as OutputAsset, + ], + }, + ], + publicAssets: [], + }; + + // Should not throw + await expect(deduplicateCss(output)).resolves.toBeUndefined(); + }); + + it('should handle output with multiple steps', async () => { + // Setup: Multiple build steps with CSS + const css1Content = '.step1 { color: red; }'; + const css2Content = '.step2 { color: blue; }'; + + const cs1Path = resolve(testOutDir, 'content-scripts/step1.css'); + const cs2Path = resolve(testOutDir, 'content-scripts/step2.css'); + const asset1Path = resolve(testOutDir, 'assets/step1.css'); + const asset2Path = resolve(testOutDir, 'assets/step2.css'); + + await writeFile(cs1Path, css1Content, 'utf-8'); + await writeFile(cs2Path, css2Content, 'utf-8'); + await writeFile(asset1Path, css1Content, 'utf-8'); + await writeFile(asset2Path, css2Content, 'utf-8'); + + const output: Omit = { + steps: [ + { + entrypoints: [] as any, + chunks: [ + { + type: 'asset', + fileName: 'content-scripts/step1.css', + } as OutputAsset, + { + type: 'asset', + fileName: 'assets/step1.css', + } as OutputAsset, + ], + }, + { + entrypoints: [] as any, + chunks: [ + { + type: 'asset', + fileName: 'content-scripts/step2.css', + } as OutputAsset, + { + type: 'asset', + fileName: 'assets/step2.css', + } as OutputAsset, + ], + }, + ], + publicAssets: [], + }; + + await deduplicateCss(output); + + // Verify duplicates were removed from both steps + expect(output.steps[0].chunks).toHaveLength(1); + expect(output.steps[0].chunks[0].fileName).toBe( + 'content-scripts/step1.css', + ); + expect(output.steps[1].chunks).toHaveLength(1); + expect(output.steps[1].chunks[0].fileName).toBe( + 'content-scripts/step2.css', + ); + }); +}); diff --git a/packages/wxt/src/core/utils/building/deduplicate-css.ts b/packages/wxt/src/core/utils/building/deduplicate-css.ts new file mode 100644 index 000000000..7e03cc7b7 --- /dev/null +++ b/packages/wxt/src/core/utils/building/deduplicate-css.ts @@ -0,0 +1,96 @@ +import { readFile, rm } from 'node:fs/promises'; +import { basename, resolve } from 'node:path'; +import { BuildOutput, OutputAsset } from '../../../types'; +import { wxt } from '../../wxt'; + +/** + * Deduplicates CSS files in the build output by removing identical files from + * the assets/ directory that are already present in content-scripts/. + * + * This handles the case where content script CSS files are output to both: + * + * 1. Content-scripts/${name}.css (referenced in manifest) + * 2. Assets/${name}.css (duplicate, not needed) + * + * Only removes files with the same base name AND identical content. + * + * @param output The build output containing all generated files + */ +export async function deduplicateCss( + output: Omit, +): Promise { + const allAssets = output.steps.flatMap((step) => + step.chunks.filter((chunk): chunk is OutputAsset => chunk.type === 'asset'), + ); + + // Find all CSS files in content-scripts/ directory + const contentScriptCss = allAssets.filter( + (asset) => + asset.fileName.startsWith('content-scripts/') && + asset.fileName.endsWith('.css'), + ); + + if (contentScriptCss.length === 0) { + return; // No content script CSS to deduplicate + } + + // Find potential duplicates in assets/ directory + const assetsCss = allAssets.filter( + (asset) => + asset.fileName.startsWith('assets/') && asset.fileName.endsWith('.css'), + ); + + // Check each asset CSS file to see if it's a duplicate + for (const assetFile of assetsCss) { + const assetBaseName = basename(assetFile.fileName); + const assetPath = resolve(wxt.config.outDir, assetFile.fileName); + let assetContent: string; + + try { + assetContent = await readFile(assetPath, 'utf-8'); + } catch { + // File doesn't exist or can't be read, skip it + continue; + } + + // Compare with content-script CSS files that have the same base name + for (const csFile of contentScriptCss) { + const csBaseName = basename(csFile.fileName); + + // Only compare files with matching base names + if (assetBaseName !== csBaseName) { + continue; + } + + const csPath = resolve(wxt.config.outDir, csFile.fileName); + let csContent: string; + + try { + csContent = await readFile(csPath, 'utf-8'); + } catch { + continue; + } + + // If base names match AND contents are identical, remove the duplicate from assets/ + if (assetContent === csContent) { + wxt.logger.debug( + `Removing duplicate CSS: ${assetFile.fileName} (identical to ${csFile.fileName})`, + ); + + await rm(assetPath, { force: true }); + + // Remove from output chunks + for (const step of output.steps) { + const index = step.chunks.findIndex( + (chunk) => chunk.fileName === assetFile.fileName, + ); + if (index !== -1) { + step.chunks.splice(index, 1); + } + } + + break; // Found and removed duplicate, move to next asset + } + } + } +} diff --git a/packages/wxt/src/core/utils/building/rebuild.ts b/packages/wxt/src/core/utils/building/rebuild.ts index b04eacace..aeac18b63 100644 --- a/packages/wxt/src/core/utils/building/rebuild.ts +++ b/packages/wxt/src/core/utils/building/rebuild.ts @@ -5,6 +5,7 @@ import { generateWxtDir } from '../../generate-wxt-dir'; import { generateManifest, writeManifest } from '../../utils/manifest'; import { wxt } from '../../wxt'; import { buildEntrypoints } from './build-entrypoints'; +import { deduplicateCss } from './deduplicate-css'; /** * Given a configuration, list of entrypoints, and an existing, partial output, @@ -53,6 +54,9 @@ export async function rebuild( publicAssets: newOutput.publicAssets, }; + // Deduplicate CSS files from assets/ that are identical to content-scripts/ + await deduplicateCss(mergedOutput); + const { manifest: newManifest, warnings: manifestWarnings } = await generateManifest(allEntrypoints, mergedOutput);