diff --git a/CHANGELOG.md b/CHANGELOG.md index bee7ca749dad..241f3d6c7278 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure `--inset-ring=*` and `--inset-shadow-*` variables are ignored by `inset-*` utilities ([#14855](https://github.com/tailwindlabs/tailwindcss/pull/14855)) - Ensure `url(…)` containing special characters such as `;` or `{}` end up in one declaration ([#14879](https://github.com/tailwindlabs/tailwindcss/pull/14879)) - Ensure adjacent rules are merged together after handling nesting when generating optimized CSS ([#14873](https://github.com/tailwindlabs/tailwindcss/pull/14873)) +- Rebase `url()` inside imported CSS files when using Vite ([#14877](https://github.com/tailwindlabs/tailwindcss/pull/14877)) - _Upgrade (experimental)_: Install `@tailwindcss/postcss` next to `tailwindcss` ([#14830](https://github.com/tailwindlabs/tailwindcss/pull/14830)) - _Upgrade (experimental)_: Remove whitespace around `,` separator when print arbitrary values ([#14838](https://github.com/tailwindlabs/tailwindcss/pull/14838)) - _Upgrade (experimental)_: Fix crash during upgrade when content globs escape root of project ([#14896](https://github.com/tailwindlabs/tailwindcss/pull/14896)) diff --git a/integrations/utils.ts b/integrations/utils.ts index 277b3bda4b29..9b06d8dc1a18 100644 --- a/integrations/utils.ts +++ b/integrations/utils.ts @@ -29,7 +29,7 @@ interface ExecOptions { interface TestConfig { fs: { - [filePath: string]: string + [filePath: string]: string | Uint8Array } } interface TestContext { @@ -280,8 +280,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) @@ -292,8 +298,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, 'utf-8') }, @@ -487,6 +491,7 @@ function testIfPortTaken(port: number): Promise { }) } +export let svg = dedent export let css = dedent export let html = dedent export let ts = dedent @@ -495,6 +500,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..bb4b4b5047e2 --- /dev/null +++ b/integrations/vite/url-rewriting.test.ts @@ -0,0 +1,97 @@ +import { describe, expect } from 'vitest' +import { binary, css, html, svg, test, ts, txt } from '../utils' + +const SIMPLE_IMAGE = `iVBORw0KGgoAAAANSUhEUgAAADAAAAAlAQAAAAAsYlcCAAAACklEQVR4AWMYBQABAwABRUEDtQAAAABJRU5ErkJggg==` + +for (let transformer of ['postcss', 'lightningcss']) { + describe(transformer, () => { + test( + 'can rewrite urls in production builds', + { + 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: { + assetsInlineLimit: 256, + 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'; + @import './dir-1/dir-2/vector.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'); + } + `, + 'src/dir-1/dir-2/vector.css': css` + .baz { + background-image: url('../../../resources/vector.svg'); + } + `, + 'resources/image.png': binary(SIMPLE_IMAGE), + 'resources/vector.svg': svg` + + + + + + + `, + }, + }, + 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]) + + let images = await fs.glob('dist/**/*.svg') + expect(images).toHaveLength(1) + + await fs.expectFileToContain(files[0][0], [/\/assets\/vector-.*?\.svg/]) + }, + ) + }) +} diff --git a/packages/@tailwindcss-node/src/compile.ts b/packages/@tailwindcss-node/src/compile.ts index 40bb9ef1fea4..8a5f8ee20cc5 100644 --- a/packages/@tailwindcss-node/src/compile.ts +++ b/packages/@tailwindcss-node/src/compile.ts @@ -9,10 +9,19 @@ import { compile as _compile, } from 'tailwindcss' import { getModuleDependencies } from './get-module-dependencies' +import { rewriteUrls } from './urls' export async function compile( css: string, - { base, onDependency }: { base: string; onDependency: (path: string) => void }, + { + base, + onDependency, + shouldRewriteUrls, + }: { + base: string + onDependency: (path: string) => void + shouldRewriteUrls?: boolean + }, ) { let compiler = await _compile(css, { base, @@ -20,7 +29,17 @@ export async function compile( return loadModule(id, base, onDependency) }, async loadStylesheet(id, base) { - return loadStylesheet(id, base, onDependency) + let sheet = await loadStylesheet(id, base, onDependency) + + if (shouldRewriteUrls) { + sheet.content = await rewriteUrls({ + css: sheet.content, + root: base, + base: sheet.base, + }) + } + + return sheet }, }) diff --git a/packages/@tailwindcss-node/src/urls.test.ts b/packages/@tailwindcss-node/src/urls.test.ts new file mode 100644 index 000000000000..d6d9fcb3a3d6 --- /dev/null +++ b/packages/@tailwindcss-node/src/urls.test.ts @@ -0,0 +1,127 @@ +import { expect, test } from 'vitest' +import { rewriteUrls } from './urls' + +const css = String.raw + +test('URLs can be rewritten', async () => { + let root = '/root' + + let result = await rewriteUrls({ + root, + base: '/root/foo/bar', + // prettier-ignore + css: css` + .foo { + /* Relative URLs: replaced */ + background: url(./image.jpg); + background: url(../image.jpg); + background: url('./image.jpg'); + background: url("./image.jpg"); + + /* External URL: ignored */ + background: url(http://example.com/image.jpg); + background: url('http://example.com/image.jpg'); + background: url("http://example.com/image.jpg"); + + /* Data URI: ignored */ + /* background: url(data:image/png;base64,abc==); */ + background: url('data:image/png;base64,abc=='); + background: url("data:image/png;base64,abc=="); + + /* Function calls: ignored */ + background: url(var(--foo)); + background: url(var(--foo, './image.jpg')); + background: url(var(--foo, "./image.jpg")); + + /* Fragments: ignored */ + background: url(#dont-touch-this); + + /* Image Sets - Raw URL: replaced */ + background: image-set( + image1.jpg 1x, + image2.jpg 2x + ); + background: image-set( + 'image1.jpg' 1x, + 'image2.jpg' 2x + ); + background: image-set( + "image1.jpg" 1x, + "image2.jpg" 2x + ); + + /* Image Sets - Relative URLs: replaced */ + background: image-set( + url('image1.jpg') 1x, + url('image2.jpg') 2x + ); + background: image-set( + url("image1.jpg") 1x, + url("image2.jpg") 2x + ); + background: image-set( + url('image1.avif') type('image/avif'), + url('image2.jpg') type('image/jpeg') + ); + background: image-set( + url("image1.avif") type('image/avif'), + url("image2.jpg") type('image/jpeg') + ); + + /* Image Sets - Function calls: ignored */ + background: image-set( + linear-gradient(blue, white) 1x, + linear-gradient(blue, green) 2x + ); + + /* Image Sets - Mixed: replaced */ + background: image-set( + linear-gradient(blue, white) 1x, + url("image2.jpg") 2x + ); + } + + /* Fonts - Multiple URLs: replaced */ + @font-face { + font-family: "Newman"; + src: + local("Newman"), + url("newman-COLRv1.otf") format("opentype") tech(color-COLRv1), + url("newman-outline.otf") format("opentype"), + url("newman-outline.woff") format("woff"); + } + `, + }) + + expect(result).toMatchInlineSnapshot(` + ".foo { + background: url(./foo/bar/image.jpg); + background: url(./foo/image.jpg); + background: url('./foo/bar/image.jpg'); + background: url("./foo/bar/image.jpg"); + background: url(http://example.com/image.jpg); + background: url('http://example.com/image.jpg'); + background: url("http://example.com/image.jpg"); + background: url('data:image/png;base64,abc=='); + background: url("data:image/png;base64,abc=="); + background: url(var(--foo)); + background: url(var(--foo, './image.jpg')); + background: url(var(--foo, "./image.jpg")); + background: url(#dont-touch-this); + background: image-set(url(./foo/bar/image1.jpg) 1x, url(./foo/bar/image2.jpg) 2x); + background: image-set(url('./foo/bar/image1.jpg') 1x, url('./foo/bar/image2.jpg') 2x); + background: image-set(url("./foo/bar/image1.jpg") 1x, url("./foo/bar/image2.jpg") 2x); + background: image-set(url('./foo/bar/image1.jpg') 1x, url('./foo/bar/image2.jpg') 2x); + background: image-set(url("./foo/bar/image1.jpg") 1x, url("./foo/bar/image2.jpg") 2x); + background: image-set(url('./foo/bar/image1.avif') type('image/avif'), url('./foo/bar/image2.jpg') type('image/jpeg')); + background: image-set(url("./foo/bar/image1.avif") type('image/avif'), url("./foo/bar/image2.jpg") type('image/jpeg')); + background: image-set(linear-gradient(blue, white) 1x, linear-gradient(blue, green) 2x); + background: image-set(linear-gradient(blue, white) 1x, url("./foo/bar/image2.jpg") 2x); + } + @font-face { + font-family: "Newman"; + src: local("Newman"), url("./foo/bar/newman-COLRv1.otf") format("opentype") tech(color-COLRv1), url("./foo/bar/newman-outline.otf") format("opentype"), url("./foo/bar/newman-outline.woff") format("woff"); + } + " + `) +}) diff --git a/packages/@tailwindcss-node/src/urls.ts b/packages/@tailwindcss-node/src/urls.ts new file mode 100644 index 000000000000..c6009a927105 --- /dev/null +++ b/packages/@tailwindcss-node/src/urls.ts @@ -0,0 +1,201 @@ +// Inlined version of code from Vite +// Copyright (c) 2019-present, VoidZero Inc. and Vite contributors +// Released under the MIT License. +// +// Minor modifications have been made to work with the Tailwind CSS codebase + +import * as path from 'node:path' +import { toCss, walk } from '../../tailwindcss/src/ast' +import { parse } from '../../tailwindcss/src/css-parser' +import { normalizePath } from './normalize-path' + +const cssUrlRE = + /(?[\w-]+\([^)]*\)|"[^"]*"|'[^']*'|[^,]\S*[^,])\s*(?:\s(?\w[^,]+))?(?:,|$)/g +const nonEscapedDoubleQuoteRE = /(? dataUrlRE.test(url) +const isExternalUrl = (url: string): boolean => externalRE.test(url) + +type CssUrlReplacer = (url: string, importer?: string) => string | Promise + +interface ImageCandidate { + url: string + descriptor: string +} + +export async function rewriteUrls({ + css, + base, + root, +}: { + css: string + base: string + root: string +}) { + if (!css.includes('url(') && !css.includes('image-set(')) { + return css + } + + let ast = parse(css) + + let promises: Promise[] = [] + + function replacerForDeclaration(url: string) { + let absoluteUrl = path.posix.join(normalizePath(base), url) + let relativeUrl = path.posix.relative(normalizePath(root), absoluteUrl) + + // If the path points to a file in the same directory, `path.relative` will + // remove the leading `./` and we need to add it back in order to still + // consider the path relative + if (!relativeUrl.startsWith('.')) { + relativeUrl = './' + relativeUrl + } + + return relativeUrl + } + + walk(ast, (node) => { + if (node.kind !== 'declaration') return + if (!node.value) return + + let isCssUrl = cssUrlRE.test(node.value) + let isCssImageSet = cssImageSetRE.test(node.value) + + if (isCssUrl || isCssImageSet) { + let rewriterToUse = isCssImageSet ? rewriteCssImageSet : rewriteCssUrls + + promises.push( + rewriterToUse(node.value, replacerForDeclaration).then((url) => { + node.value = url + }), + ) + } + }) + + if (promises.length) { + await Promise.all(promises) + } + + return toCss(ast) +} + +function rewriteCssUrls(css: string, replacer: CssUrlReplacer): Promise { + return asyncReplace(css, cssUrlRE, async (match) => { + const [matched, rawUrl] = match + return await doUrlReplace(rawUrl.trim(), matched, replacer) + }) +} + +async function rewriteCssImageSet(css: string, replacer: CssUrlReplacer): Promise { + return await asyncReplace(css, cssImageSetRE, async (match) => { + const [, rawUrl] = match + const url = await processSrcSet(rawUrl, async ({ url }) => { + // the url maybe url(...) + if (cssUrlRE.test(url)) { + return await rewriteCssUrls(url, replacer) + } + if (!cssNotProcessedRE.test(url)) { + return await doUrlReplace(url, url, replacer) + } + return url + }) + return url + }) +} + +async function doUrlReplace( + rawUrl: string, + matched: string, + replacer: CssUrlReplacer, + funcName: string = 'url', +) { + let wrap = '' + const first = rawUrl[0] + if (first === `"` || first === `'`) { + wrap = first + rawUrl = rawUrl.slice(1, -1) + } + + if (skipUrlReplacer(rawUrl)) { + return matched + } + + let newUrl = await replacer(rawUrl) + // The new url might need wrapping even if the original did not have it, e.g. if a space was added during replacement + if (wrap === '' && newUrl !== encodeURI(newUrl)) { + wrap = '"' + } + // If wrapping in single quotes and newUrl also contains single quotes, switch to double quotes. + // Give preference to double quotes since SVG inlining converts double quotes to single quotes. + if (wrap === "'" && newUrl.includes("'")) { + wrap = '"' + } + // Escape double quotes if they exist (they also tend to be rarer than single quotes) + if (wrap === '"' && newUrl.includes('"')) { + newUrl = newUrl.replace(nonEscapedDoubleQuoteRE, '\\"') + } + return `${funcName}(${wrap}${newUrl}${wrap})` +} + +function skipUrlReplacer(rawUrl: string) { + return ( + isExternalUrl(rawUrl) || isDataUrl(rawUrl) || rawUrl[0] === '#' || functionCallRE.test(rawUrl) + ) +} + +function processSrcSet( + srcs: string, + replacer: (arg: ImageCandidate) => Promise, +): Promise { + return Promise.all( + parseSrcset(srcs).map(async ({ url, descriptor }) => ({ + url: await replacer({ url, descriptor }), + descriptor, + })), + ).then(joinSrcset) +} + +function parseSrcset(string: string): ImageCandidate[] { + const matches = string + .trim() + .replace(escapedSpaceCharactersRE, ' ') + .replace(/\r?\n/, '') + .replace(/,\s+/, ', ') + .replaceAll(/\s+/g, ' ') + .matchAll(imageCandidateRE) + return Array.from(matches, ({ groups }) => ({ + url: groups?.url?.trim() ?? '', + descriptor: groups?.descriptor?.trim() ?? '', + })).filter(({ url }) => !!url) +} + +function joinSrcset(ret: ImageCandidate[]) { + return ret.map(({ url, descriptor }) => url + (descriptor ? ` ${descriptor}` : '')).join(', ') +} + +async function asyncReplace( + input: string, + re: RegExp, + replacer: (match: RegExpExecArray) => string | Promise, +): Promise { + let match: RegExpExecArray | null + let remaining = input + let rewritten = '' + while ((match = re.exec(remaining))) { + rewritten += remaining.slice(0, match.index) + rewritten += await replacer(match) + remaining = remaining.slice(match.index + match[0].length) + } + rewritten += remaining + return rewritten +} diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index 4f02e60f290c..e6085562d903 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -381,6 +381,7 @@ class Root { env.DEBUG && console.time('[@tailwindcss/vite] Setup compiler') this.compiler = await compile(content, { base: inputBase, + shouldRewriteUrls: true, onDependency: (path) => { addWatchFile(path) this.dependencies.add(path) diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index 9f31105891f3..7315bb09bfe0 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -223,16 +223,6 @@ export function toCss(ast: AstNode[]) { // AtRule else if (node.kind === 'at-rule') { - if ( - node.name === '@tailwind' && - (node.params === 'utilities' || node.params.startsWith('utilities')) - ) { - for (let child of node.nodes) { - css += stringify(child, depth) - } - return css - } - // Print at-rules without nodes with a `;` instead of an empty block. // // E.g.: @@ -240,7 +230,7 @@ export function toCss(ast: AstNode[]) { // ```css // @layer base, components, utilities; // ``` - else if (node.nodes.length === 0) { + if (node.nodes.length === 0) { return `${indent}${node.name} ${node.params};\n` } diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 402411861e48..40663f4883b8 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -13,6 +13,7 @@ import { WalkAction, type AstNode, type AtRule, + type Context, type StyleRule, } from './ast' import { substituteAtImports } from './at-import' @@ -100,10 +101,15 @@ async function parseCss( // Find `@tailwind utilities` so that we can later replace it with the // actual generated utility class CSS. if ( - utilitiesNode === null && node.name === '@tailwind' && (node.params === 'utilities' || node.params.startsWith('utilities')) ) { + // Any additional `@tailwind utilities` nodes can be removed + if (utilitiesNode !== null) { + replaceWith([]) + return + } + let params = segment(node.params, ' ') for (let param of params) { if (param.startsWith('source(')) { @@ -452,6 +458,14 @@ async function parseCss( firstThemeRule.nodes = nodes } + // Replace the `@tailwind utilities` node with a context since it prints + // children directly. + if (utilitiesNode) { + let node = utilitiesNode as AstNode as Context + node.kind = 'context' + node.context = {} + } + // Replace `@apply` rules with the actual utility classes. substituteAtApply(ast, designSystem)