Repository: tailwindlabs/tailwindcss

Problem statement:
Tailwindcss-cli silently fails when file is not UTF-8
**What version of Tailwind CSS are you using?**

tailwindcss-cli v3.4.16

**What build tool (or framework if it abstracts the build tool) are you using?**

tailwindcss-cli

**What version of Node.js are you using?**

none

**What browser are you using?**

N/A

**What operating system are you using?**

Windows

**Describe your issue**

Working on a legacy project I had problem getting utility classes generated from some files but not others, even when located next to each other. It turned out after a lot of hair pulling that the problematic files where encoded in UTF-16 with BOM. Converting all files to UTF-8 fixed the problem.

Sugestion: At minimum show an error message when a file can't be processed.


Hints:

Patch:
diff --git a/integrations/utils.ts b/integrations/utils.ts
--- a/integrations/utils.ts
+++ b/integrations/utils.ts
@@ -42,7 +42,7 @@ interface TestContext {
   exec(command: string, options?: ChildProcessOptions, execOptions?: ExecOptions): Promise<string>
   spawn(command: string, options?: ChildProcessOptions): Promise<SpawnedProcess>
   fs: {
-    write(filePath: string, content: string): Promise<void>
+    write(filePath: string, content: string, encoding?: BufferEncoding): Promise<void>
     create(filePaths: string[]): Promise<void>
     read(filePath: string): Promise<string>
     glob(pattern: string): Promise<[string, string][]>
@@ -268,7 +268,11 @@ export function test(
           }
         },
         fs: {
-          async write(filename: string, content: string | Uint8Array): Promise<void> {
+          async write(
+            filename: string,
+            content: string | Uint8Array,
+            encoding: BufferEncoding = 'utf8',
+          ): Promise<void> {
             let full = path.join(root, filename)
             let dir = path.dirname(full)
             await fs.mkdir(dir, { recursive: true })
@@ -286,7 +290,7 @@ export function test(
               content = content.replace(/\n/g, '\r\n')
             }
 
-            await fs.writeFile(full, content, 'utf-8')
+            await fs.writeFile(full, content, encoding)
           },
 
           async create(filenames: string[]): Promise<void> {
diff --git a/packages/tailwindcss/src/css-parser.ts b/packages/tailwindcss/src/css-parser.ts
--- a/packages/tailwindcss/src/css-parser.ts
+++ b/packages/tailwindcss/src/css-parser.ts
@@ -31,6 +31,7 @@ const AT_SIGN = 0x40
 const EXCLAMATION_MARK = 0x21
 
 export function parse(input: string) {
+  if (input[0] === '\uFEFF') input = input.slice(1)
   input = input.replaceAll('\r\n', '\n')
 
   let ast: AstNode[] = []


Test patch:
diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts
--- a/integrations/cli/index.test.ts
+++ b/integrations/cli/index.test.ts
@@ -1314,3 +1314,79 @@ test(
     )
   },
 )
+
+test(
+  'can read files with UTF-8 files with BOM',
+  {
+    fs: {
+      'package.json': json`
+        {
+          "dependencies": {
+            "tailwindcss": "workspace:^",
+            "@tailwindcss/cli": "workspace:^"
+          }
+        }
+      `,
+      'index.css': withBOM(css`
+        @reference 'tailwindcss/theme.css';
+        @import 'tailwindcss/utilities';
+      `),
+      'index.html': withBOM(html`
+        <div class="underline"></div>
+      `),
+    },
+  },
+  async ({ fs, exec, expect }) => {
+    await exec('pnpm tailwindcss --input index.css --output dist/out.css')
+
+    expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(`
+      "
+      --- ./dist/out.css ---
+      .underline {
+        text-decoration-line: underline;
+      }
+      "
+    `)
+  },
+)
+
+test(
+  'fails when reading files with UTF-16 files with BOM',
+  {
+    fs: {
+      'package.json': json`
+        {
+          "dependencies": {
+            "tailwindcss": "workspace:^",
+            "@tailwindcss/cli": "workspace:^"
+          }
+        }
+      `,
+    },
+  },
+  async ({ fs, exec, expect }) => {
+    await fs.write(
+      'index.css',
+      withBOM(css`
+        @reference 'tailwindcss/theme.css';
+        @import 'tailwindcss/utilities';
+      `),
+      'utf16le',
+    )
+    await fs.write(
+      'index.html',
+      withBOM(html`
+        <div class="underline"></div>
+      `),
+      'utf16le',
+    )
+
+    await expect(exec('pnpm tailwindcss --input index.css --output dist/out.css')).rejects.toThrow(
+      /Invalid declaration:/,
+    )
+  },
+)
+
+function withBOM(text: string): string {
+  return '\uFEFF' + text
+}
diff --git a/integrations/upgrade/index.test.ts b/integrations/upgrade/index.test.ts
--- a/integrations/upgrade/index.test.ts
+++ b/integrations/upgrade/index.test.ts
@@ -2745,3 +2745,71 @@ test(
     `)
   },
 )
+
+test(
+  `can read files with BOM`,
+  {
+    fs: {
+      'package.json': json`
+        {
+          "dependencies": {
+            "tailwindcss": "^3",
+            "@tailwindcss/upgrade": "workspace:^"
+          },
+          "devDependencies": {
+            "@tailwindcss/cli": "workspace:^"
+          }
+        }
+      `,
+      'tailwind.config.js': js`
+        /** @type {import('tailwindcss').Config} */
+        module.exports = {
+          content: ['./src/**/*.{html,js}'],
+        }
+      `,
+      'src/index.html': withBOM(html`
+        <div class="ring"></div>
+      `),
+      'src/input.css': withBOM(css`
+        @tailwind base;
+        @tailwind components;
+        @tailwind utilities;
+      `),
+    },
+  },
+  async ({ exec, fs, expect }) => {
+    await exec('npx @tailwindcss/upgrade')
+
+    expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
+      "
+      --- ./src/index.html ---
+      ﻿<div class="ring-3"></div>
+
+      --- ./src/input.css ---
+      @import 'tailwindcss';
+
+      /*
+        The default border color has changed to \`currentColor\` in Tailwind CSS v4,
+        so we've added these compatibility styles to make sure everything still
+        looks the same as it did with Tailwind CSS v3.
+
+        If we ever want to remove these styles, we need to add an explicit border
+        color utility to any element that depends on these defaults.
+      */
+      @layer base {
+        *,
+        ::after,
+        ::before,
+        ::backdrop,
+        ::file-selector-button {
+          border-color: var(--color-gray-200, currentColor);
+        }
+      }
+      "
+    `)
+  },
+)
+
+function withBOM(text: string): string {
+  return '\uFEFF' + text
+}
diff --git a/packages/tailwindcss/src/css-parser.test.ts b/packages/tailwindcss/src/css-parser.test.ts
--- a/packages/tailwindcss/src/css-parser.test.ts
+++ b/packages/tailwindcss/src/css-parser.test.ts
@@ -1154,4 +1154,15 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
       )
     })
   })
+
+  it('ignores BOM at the beginning of a file', () => {
+    expect(parse("\uFEFF@reference 'tailwindcss';")).toEqual([
+      {
+        kind: 'at-rule',
+        name: '@reference',
+        nodes: [],
+        params: "'tailwindcss'",
+      },
+    ])
+  })
 })