Repository: tailwindlabs/tailwindcss

Problem statement:
[4 alpha] breaks the default Vite `url(...)` references in imported CSS files.
**What version of Tailwind CSS are you using?**

For example: alpha.29 (latest)

**What build tool (or framework if it abstracts the build tool) are you using?**

For example: Vite 5.4.10 (latest)

**What version of Node.js are you using?**

For example: v20.9.0

**What browser are you using?**

Mainly Safari, however it's not related to the issue 🙃

**What operating system are you using?**

MacOS

**Reproduction URL**

https://github.com/slavarazum/tailwind4-alpha-vite/

**Describe your issue**

Vite provides [@import inline and rebasing](https://vite.dev/guide/features.html#import-inlining-and-rebasing) for CSS.
I'm trying to import font from the Fontsource library: https://fontsource.org/fonts/nunito which references font files using `url(...)`.

As a result we have such rebased urls:
![CleanShot 2024-10-24 at 17 32 22@2x](https://github.com/user-attachments/assets/3fad2d36-a1ab-48c2-89d1-34484a90edf4)

After installing Tailwind, importing it, and enabling the Vite plugin, the URLs remain with the default related paths:
![CleanShot 2024-10-24 at 17 35 46@2x](https://github.com/user-attachments/assets/c24fa7a4-707c-45c7-842a-efef23990390)



Hints:
@slavarazum Thanks for the report! We'll look into it. You can work around this for now by using the `@tailwindcss/postcss` client rather than the Vite client which should preserve the existing logic.
Wow, Tailwind team are really quick nowdays :)
Thanx for the suggestion, have already temporary migrated.

Patch:
diff --git a/integrations/utils.ts b/integrations/utils.ts
--- 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<void> {
+          async write(filename: string, content: string | Uint8Array): Promise<void> {
             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<boolean> {
   })
 }
 
+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/packages/@tailwindcss-node/src/compile.ts b/packages/@tailwindcss-node/src/compile.ts
--- 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.ts b/packages/@tailwindcss-node/src/urls.ts
new file mode 100644
--- /dev/null
+++ b/packages/@tailwindcss-node/src/urls.ts
@@ -0,0 +1,201 @@
+// Inlined version of code from Vite <https://github.com/vitejs/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 =
+  /(?<!@import\s+)(?<=^|[^\w\-\u0080-\uffff])url\((\s*('[^']+'|"[^"]+")\s*|[^'")]+)\)/
+const cssImageSetRE = /(?<=image-set\()((?:[\w-]{1,256}\([^)]*\)|[^)])*)(?=\))/
+const cssNotProcessedRE = /(?:gradient|element|cross-fade|image)\(/
+
+const dataUrlRE = /^\s*data:/i
+const externalRE = /^([a-z]+:)?\/\//
+const functionCallRE = /^[A-Z_][.\w-]*\(/i
+
+const imageCandidateRE =
+  /(?:^|\s)(?<url>[\w-]+\([^)]*\)|"[^"]*"|'[^']*'|[^,]\S*[^,])\s*(?:\s(?<descriptor>\w[^,]+))?(?:,|$)/g
+const nonEscapedDoubleQuoteRE = /(?<!\\)"/g
+const escapedSpaceCharactersRE = /(?: |\\t|\\n|\\f|\\r)+/g
+
+const isDataUrl = (url: string): boolean => dataUrlRE.test(url)
+const isExternalUrl = (url: string): boolean => externalRE.test(url)
+
+type CssUrlReplacer = (url: string, importer?: string) => string | Promise<string>
+
+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<void>[] = []
+
+  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<string> {
+  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<string> {
+  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<string>,
+): Promise<string> {
+  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<string>,
+): Promise<string> {
+  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
--- 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
--- 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
--- 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)
 


Test patch:
diff --git a/integrations/vite/url-rewriting.test.ts b/integrations/vite/url-rewriting.test.ts
new file mode 100644
--- /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`
+            <!doctype html>
+            <html>
+              <head>
+                <link rel="stylesheet" href="./src/app.css" />
+              </head>
+              <body>
+                <div id="app"></div>
+                <script type="module" src="./src/main.ts"></script>
+              </body>
+            </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`
+            <svg width="400" height="400" xmlns="http://www.w3.org/2000/svg">
+              <rect width="100%" height="100%" fill="red" />
+              <circle cx="200" cy="100" r="80" fill="green" />
+              <rect width="100%" height="100%" fill="red" />
+              <circle cx="200" cy="100" r="80" fill="green" />
+            </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/urls.test.ts b/packages/@tailwindcss-node/src/urls.test.ts
new file mode 100644
--- /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");
+    }
+    "
+  `)
+})