diff --git a/integrations/utils.ts b/integrations/utils.ts index 70b97c111d06..c52271bdb83b 100644 --- a/integrations/utils.ts +++ b/integrations/utils.ts @@ -28,8 +28,9 @@ interface ExecOptions { } interface TestConfig { + todo?: boolean fs: { - [filePath: string]: string + [filePath: string]: string | Uint8Array } } interface TestContext { @@ -74,6 +75,10 @@ export function test( testCallback: TestCallback, { only = false, debug = false }: TestFlags = {}, ) { + if (config.todo) { + return defaultTest.todo(name) + } + return (only || (!process.env.CI && debug) ? defaultTest.only : defaultTest)( name, { timeout: TEST_TIMEOUT, retry: process.env.CI ? 2 : 0 }, @@ -279,8 +284,14 @@ export function test( }) }, fs: { - async write(filename: string, content: string): Promise { + async write(filename: string, content: string | Uint8Array): Promise { let full = path.join(root, filename) + let dir = path.dirname(full) + await fs.mkdir(dir, { recursive: true }) + + if (typeof content !== 'string') { + return await fs.writeFile(full, content) + } if (filename.endsWith('package.json')) { content = await overwriteVersionsInPackageJson(content) @@ -291,8 +302,6 @@ export function test( content = content.replace(/\n/g, '\r\n') } - let dir = path.dirname(full) - await fs.mkdir(dir, { recursive: true }) await fs.writeFile(full, content) }, @@ -494,6 +503,12 @@ export let json = dedent export let yaml = dedent export let txt = dedent +export function binary(str: string | TemplateStringsArray, ...values: unknown[]): Uint8Array { + let base64 = typeof str === 'string' ? str : String.raw(str, ...values) + + return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)) +} + export function candidate(strings: TemplateStringsArray, ...values: any[]) { let output: string[] = [] for (let i = 0; i < strings.length; i++) { diff --git a/integrations/vite/url-rewriting.test.ts b/integrations/vite/url-rewriting.test.ts new file mode 100644 index 000000000000..77a131f80287 --- /dev/null +++ b/integrations/vite/url-rewriting.test.ts @@ -0,0 +1,76 @@ +import { describe, expect } from 'vitest' +import { binary, css, html, test, ts, txt } from '../utils' + +const SIMPLE_IMAGE = `iVBORw0KGgoAAAANSUhEUgAAADAAAAAlAQAAAAAsYlcCAAAACklEQVR4AWMYBQABAwABRUEDtQAAAABJRU5ErkJggg==` + +for (let transformer of ['postcss', 'lightningcss']) { + describe(transformer, () => { + test( + 'can rewrite urls in production builds', + { + todo: transformer === 'lightningcss', + fs: { + 'package.json': txt` + { + "type": "module", + "dependencies": { + "tailwindcss": "workspace:^" + }, + "devDependencies": { + ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} + "@tailwindcss/vite": "workspace:^", + "vite": "^5.3.5" + } + } + `, + 'vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' + + export default defineConfig({ + plugins: [tailwindcss()], + build: { cssMinify: false }, + css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, + }) + `, + 'index.html': html` + + + + + + +
+ + + + `, + 'src/main.ts': ts``, + 'src/app.css': css` + @import './dir-1/bar.css'; + @import './dir-1/dir-2/baz.css'; + `, + 'src/dir-1/bar.css': css` + .bar { + background-image: url('../../resources/image.png'); + } + `, + 'src/dir-1/dir-2/baz.css': css` + .baz { + background-image: url('../../../resources/image.png'); + } + `, + 'resources/image.png': binary(SIMPLE_IMAGE), + }, + }, + async ({ fs, exec }) => { + await exec('pnpm vite build') + + let files = await fs.glob('dist/**/*.css') + expect(files).toHaveLength(1) + + await fs.expectFileToContain(files[0][0], [SIMPLE_IMAGE]) + }, + ) + }) +} diff --git a/integrations/vite/vue.test.ts b/integrations/vite/vue.test.ts index 4f582a99fcdc..35ed5de77ba6 100644 --- a/integrations/vite/vue.test.ts +++ b/integrations/vite/vue.test.ts @@ -51,9 +51,15 @@ test( @apply text-red-500; } - + `, }, @@ -65,5 +71,7 @@ test( expect(files).toHaveLength(1) await fs.expectFileToContain(files[0][0], [candidate`underline`, candidate`foo`]) + + await fs.expectFileNotToContain(files[0][0], [':deep(.bar)']) }, ) diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index 57eaf42affb5..014755d60a4d 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -133,6 +133,10 @@ export default function tailwindcss(): Plugin[] { return css } + function isUsingLightningCSS() { + return config?.css.transformer === 'lightningcss' + } + return [ { // Step 1: Scan source files for candidates @@ -206,12 +210,84 @@ export default function tailwindcss(): Plugin[] { }, { + // Step 2 (full build): Generate CSS + name: '@tailwindcss/vite:generate:build', + apply: 'build', + + // NOTE: + // We used to use `enforce: 'pre'` here because Tailwind CSS can handle + // imports itself. However, this caused two problems: + // + // - Relative asset URL rewriting for was not happening so things like + // `background-image: url(../image.png)` could break if done in an + // imported CSS file. + // + // - Processing of Vue scoped style blocks didn't happen at the right time + // which caused `:deep(…)` to end up in the generated CSS rather than + // appropriately handled by Vue. + // + // This does mean that Tailwind is no longer handling the imports itself + // which is not ideal but it's a reasonable tradeoff until we can resolve + // both of these issues with Tailwind's own import handling. + + async transform(src, id) { + if (isUsingLightningCSS()) return + if (!isPotentialCssRootFile(id)) return + + let root = roots.get(id) + + // We do a first pass to generate valid CSS for the downstream plugins. + // However, since not all candidates are guaranteed to be extracted by + // this time, we have to re-run a transform for the root later. + let generated = await root.generate(src, (file) => this.addWatchFile(file)) + if (!generated) { + roots.delete(id) + return src + } + return { code: generated } + }, + + // `renderStart` runs in the bundle generation stage after all transforms. + // We must run before `enforce: post` so the updated chunks are picked up + // by vite:css-post. + async renderStart() { + if (isUsingLightningCSS()) return + + for (let [id, root] of roots.entries()) { + let generated = await regenerateOptimizedCss( + root, + // During the renderStart phase, we can not add watch files since + // those would not be causing a refresh of the right CSS file. This + // should not be an issue since we did already process the CSS file + // before and the dependencies should not be changed (only the + // candidate list might have) + () => {}, + ) + if (!generated) { + roots.delete(id) + continue + } + + // These plugins have side effects which, during build, results in CSS + // being written to the output dir. We need to run them here to ensure + // the CSS is written before the bundle is generated. + await transformWithPlugins(this, id, generated) + } + }, + }, + + { + // NOTE: This is an exact copy of the above plugin but with `enforce: pre` + // when using Lightning CSS. + // Step 2 (full build): Generate CSS name: '@tailwindcss/vite:generate:build', apply: 'build', enforce: 'pre', async transform(src, id) { + if (!isUsingLightningCSS()) return + if (!isPotentialCssRootFile(id)) return let root = roots.get(id) @@ -231,6 +307,8 @@ export default function tailwindcss(): Plugin[] { // We must run before `enforce: post` so the updated chunks are picked up // by vite:css-post. async renderStart() { + if (!isUsingLightningCSS()) return + for (let [id, root] of roots.entries()) { let generated = await regenerateOptimizedCss( root,