diff --git a/.editorconfig b/.editorconfig
deleted file mode 100644
index c6c8b3621938..000000000000
--- a/.editorconfig
+++ /dev/null
@@ -1,9 +0,0 @@
-root = true
-
-[*]
-indent_style = space
-indent_size = 2
-end_of_line = lf
-charset = utf-8
-trim_trailing_whitespace = true
-insert_final_newline = true
diff --git a/.eslintignore b/.eslintignore
deleted file mode 100644
index f7288b4ad2e8..000000000000
--- a/.eslintignore
+++ /dev/null
@@ -1,4 +0,0 @@
-/lib
-/docs
-/__tests__/fixtures/cli-utils.js
-defaultConfig.stub.js
diff --git a/.eslintrc b/.eslintrc
deleted file mode 100644
index 87712fea03fc..000000000000
--- a/.eslintrc
+++ /dev/null
@@ -1,29 +0,0 @@
-{
- "env": {
- "jest": true
- },
- "parserOptions": {
- "ecmaVersion": 6,
- "sourceType": "module",
- "ecmaFeatures": {
- "experimentalObjectRestSpread": true
- }
- },
- "extends": ["eslint-config-postcss", "prettier"],
- "plugins": ["prettier"],
- "rules": {
- "prettier/prettier": [
- "error",
- {
- "semi": false,
- "singleQuote": true,
- "printWidth": 100,
- "tabWidth": 2,
- "useTabs": false,
- "trailingComma": "es5",
- "bracketSpacing": true,
- "parser": "flow"
- }
- ]
- }
-}
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 000000000000..4bf4af1ed22d
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1 @@
+* @tailwindlabs/engineering
diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md
deleted file mode 100644
index 7ce9543c5173..000000000000
--- a/.github/CODE_OF_CONDUCT.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# Tailwind CSS Community Guidelines
-
-The following community guidelines are based on [The Ruby Community Conduct Guidelines](https://www.ruby-lang.org/en/conduct/).
-
-This document provides community guidelines for a respectful, productive, and collaborative place for any person who is willing to contribute to the Tailwind CSS project. It applies to all “collaborative space”, which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.).
-
-- Participants will be tolerant of opposing views.
-- Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
-- When interpreting the words and actions of others, participants should always assume good intentions.
-- Behaviour which can be reasonably considered harassment will not be tolerated.
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index c8025e2f1096..63f79fa100e6 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -1,33 +1,103 @@
# Contributing
-Thanks for your interest in contributing to Tailwind CSS! Please take a moment to review this document **before submitting a pull request**.
+## Requirements
-## Pull requests
+Before getting started, ensure your system has access to the following tools:
-**Please ask first before starting work on any significant new features.**
+- [Node.js](https://nodejs.org/)
+- [Rustup](https://rustup.rs/)
+- [pnpm](https://pnpm.io/)
-It's never a fun experience to have your pull request declined after investing a lot of time and effort into a new feature. To avoid this from happening, we request that contributors create [an issue](https://github.com/tailwindcss/tailwindcss/issues) to first discuss any significant new features. This includes things like adding new utilities, creating new at-rules, etc.
+## Getting started
+
+```sh
+# Install dependencies
+pnpm install
+
+# Install Rust toolchain and WASM targets
+rustup default stable
+rustup target add wasm32-wasip1-threads
+
+# Build the project
+pnpm build
+```
+
+## Development workflow
+
+During development, you can run tests in watch mode:
+
+```sh
+pnpm tdd
+```
+
+The `playgrounds` directory contains example projects you can use to test your changes. To start the Vite playground, use:
+
+```sh
+pnpm build && pnpm vite
+```
+
+## Bug fixes
+
+If you've found a bug in Tailwind that you'd like to fix, [submit a pull request](https://github.com/tailwindlabs/tailwindcss/pulls) with your changes. Include a helpful description of the problem and how your changes address it, and provide tests so we can verify the fix works as expected.
+
+## New features
+
+If there's a new feature you'd like to see added to Tailwind, [share your idea with us](https://github.com/tailwindlabs/tailwindcss/discussions/new?category=ideas) in our discussion forum to get it on our radar as something to consider for a future release before starting work on it.
+
+**Please note that we don't often accept pull requests for new features.** Adding a new feature to Tailwind requires us to think through the entire problem ourselves to make sure we agree with the proposed API, which means the feature needs to be high on our own priority list for us to be able to give it the attention it needs.
+
+If you open a pull request for a new feature, we're likely to close it not because it's a bad idea, but because we aren't ready to prioritize the feature and don't want the PR to sit open for months or even years.
## Coding standards
-Our code formatting rules are defined in [.eslintrc](https://github.com/tailwindcss/tailwindcss/blob/master/.eslintrc). You can check your code against these standards by running:
+Our code formatting rules are defined in the `"prettier"` section of [package.json](https://github.com/tailwindlabs/tailwindcss/blob/main/package.json). You can check your code against these standards by running:
```sh
-npm run style
+pnpm run lint
```
To automatically fix any style violations in your code, you can run:
```sh
-npm run style --fix
+pnpm run format
```
## Running tests
-You can run the test suite using the following commands:
+You can run the TypeScript and Rust test suites using the following command:
```sh
-npm test
+pnpm test
```
-Please ensure that the tests are passing when submitting a pull request. If you're adding new features to Tailwind, please include tests.
+To run the integration tests, use:
+
+```sh
+pnpm build && pnpm test:integrations
+```
+
+Additionally, some features require testing in browsers (i.e. to ensure CSS variable resolution works as expected). These can be run via:
+
+```sh
+pnpm build && pnpm test:ui
+```
+
+Please ensure that all tests are passing when submitting a pull request. If you're adding new features to Tailwind CSS, always include tests.
+
+After a successful build, you can also use the npm package tarballs created inside the `dist/` folder to install your build in other local projects.
+
+## Pull request process
+
+When submitting a pull request:
+
+- Ensure the pull request title and description explain the changes you made and why you made them.
+- Include a test plan section that outlines how you tested your contributions. We do not accept contributions without tests.
+- Ensure all tests pass. You can add the tag `[ci-all]` in your pull request description to run the test suites across all platforms.
+
+When a pull request is created, Tailwind CSS maintainers will be notified automatically.
+
+## Communication
+
+- **GitHub discussions**: For feature ideas and general questions
+- **GitHub issues**: For bug reports
+- **GitHub pull requests**: For code contributions
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 000000000000..848c851b1773
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+custom: ['https://tailwindcss.com/sponsor']
diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
deleted file mode 100644
index c55930488792..000000000000
--- a/.github/ISSUE_TEMPLATE.md
+++ /dev/null
@@ -1,11 +0,0 @@
-
diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md
new file mode 100644
index 000000000000..1b8a310c6f52
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug-report.md
@@ -0,0 +1,39 @@
+---
+name: Bug report
+about: If you've already asked for help with a problem and confirmed something is broken with Tailwind CSS itself, create a bug report.
+title: ''
+labels: ''
+assignees: ''
+---
+
+
+
+**What version of Tailwind CSS are you using?**
+
+For example: v4.0.6
+
+**What build tool (or framework if it abstracts the build tool) are you using?**
+
+For example: postcss-cli 11.0.0, Next.js 15.1.7, Vite 6.1.0
+
+**What version of Node.js are you using?**
+
+For example: v20.0.0
+
+**What browser are you using?**
+
+For example: Chrome, Safari, or N/A
+
+**What operating system are you using?**
+
+For example: macOS, Windows
+
+**Reproduction URL**
+
+A Tailwind Play link or public GitHub repo that includes a minimal reproduction of the bug. **Please do not link to your actual project**, what we need instead is a _minimal_ reproduction in a fresh project without any unnecessary code. This means it doesn't matter if your real project is private/confidential, since we want a link to a separate, isolated reproduction anyways.
+
+A reproduction is **required** when filing an issue — any issue opened without a reproduction will be closed and you'll be asked to create a new issue that includes a reproduction. We're a small team and we can't keep up with the volume of issues we receive if we need to reproduce each issue from scratch ourselves.
+
+**Describe your issue**
+
+Describe the problem you're seeing, any important steps to reproduce and what behavior you expect instead.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 000000000000..10ee9a6b8afb
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,11 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Get Help
+ url: https://github.com/tailwindlabs/tailwindcss/discussions/new?category=help
+ about: If you can't get something to work the way you expect, open a question in our discussion forums.
+ - name: Feature Request
+ url: https://github.com/tailwindlabs/tailwindcss/discussions/new?category=ideas
+ about: 'Suggest any ideas you have using our discussion forums.'
+ - name: Documentation Issue
+ url: https://github.com/tailwindlabs/tailwindcss.com
+ about: 'For documentation issues, suggest changes on our documentation repository.'
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index ce7fafb97dc0..aa0793aa39f8 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -4,8 +4,26 @@
**Please ask first before starting work on any significant new features.**
-It's never a fun experience to have your pull request declined after investing a lot of time and effort into a new feature. To avoid this from happening, we request that contributors create an issue to first discuss any significant new features. This includes things like adding new utilities, creating new at-rules, or adding new component examples to the documentation.
+It's never a fun experience to have your pull request declined after investing a lot of time and effort into a new feature. To avoid this from happening, we request that contributors create a discussion to first discuss any significant new features.
-https://github.com/tailwindcss/tailwindcss/blob/master/.github/CONTRIBUTING.md
+For more info, check out the contributing guide:
+
+https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md
+
+-->
+
+## Summary
+
+
+
+## Test plan
+
+
diff --git a/.github/logo-dark.svg b/.github/logo-dark.svg
new file mode 100644
index 000000000000..fe7f33aead3c
--- /dev/null
+++ b/.github/logo-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/.github/logo-light.svg b/.github/logo-light.svg
new file mode 100644
index 000000000000..a1731300d847
--- /dev/null
+++ b/.github/logo-light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 000000000000..fca477c08bec
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,122 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
+
+env:
+ NODE_VERSION: 24
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ tests:
+ strategy:
+ fail-fast: false
+ matrix:
+ runner:
+ - name: Windows
+ os: windows-latest
+
+ - name: Linux
+ os: namespace-profile-default
+
+ - name: macOS
+ os: macos-14
+
+ # Exclude windows and macos from being built on feature branches
+ run-all:
+ - ${{ github.ref == 'refs/heads/main' || contains(github.event.pull_request.body, '[ci-all]') || github.event.pull_request.user.login == 'depfu[bot]' }}
+ exclude:
+ - run-all: false
+ runner:
+ name: Windows
+ - run-all: false
+ runner:
+ name: macOS
+
+ runs-on: ${{ matrix.runner.os }}
+ timeout-minutes: 30
+
+ name: ${{ matrix.runner.name }}
+
+ steps:
+ - uses: actions/checkout@v6
+ - uses: pnpm/action-setup@v4
+
+ - name: Use Node.js ${{ env.NODE_VERSION }}
+ uses: actions/setup-node@v6
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: 'pnpm'
+
+ # Cargo already skips downloading dependencies if they already exist
+ - name: Cache cargo
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/bin/
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ ~/.cargo/git/db/
+ target/
+ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
+
+ # Cache the `oxide` Rust build
+ - name: Cache oxide build
+ uses: actions/cache@v5
+ with:
+ path: |
+ ./crates/node/*.node
+ ./crates/node/*.wasm
+ ./crates/node/index.d.ts
+ ./crates/node/index.js
+ ./crates/node/browser.js
+ ./crates/node/tailwindcss-oxide.wasi-browser.js
+ ./crates/node/tailwindcss-oxide.wasi.cjs
+ ./crates/node/wasi-worker-browser.mjs
+ ./crates/node/wasi-worker.mjs
+ key: ${{ runner.os }}-oxide-${{ hashFiles('./crates/**/*') }}
+
+ - name: Setup WASM target
+ run: rustup target add wasm32-wasip1-threads
+
+ - name: Install dependencies
+ run: pnpm install
+
+ - name: Build
+ run: pnpm run build
+ env:
+ CARGO_PROFILE_RELEASE_LTO: 'off'
+ CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER: 'lld-link'
+
+ - name: Lint
+ run: pnpm run lint
+ # Only lint on linux to avoid \r\n line ending errors
+ if: matrix.runner.os == 'ubuntu-latest'
+
+ - name: Test
+ run: pnpm run test
+
+ - name: Install Playwright Browsers
+ run: npx playwright install --with-deps
+
+ - name: Run Playwright tests
+ run: npm run test:ui
+
+ notify:
+ if: ${{ always() && github.ref == 'refs/heads/main' && needs.tests.result == 'failure' }}
+ needs: tests
+ runs-on: ubuntu-latest
+ steps:
+ - name: Notify Discord
+ uses: discord-actions/message@v2
+ with:
+ webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }}
+ message: 'The [most recent ${{ github.workflow }} workflow](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) on the `main` branch has failed.'
diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
new file mode 100644
index 000000000000..3b2cf8f1b80a
--- /dev/null
+++ b/.github/workflows/integration-tests.yml
@@ -0,0 +1,125 @@
+name: Integration Tests
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
+
+env:
+ NODE_VERSION: 24
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ tests:
+ strategy:
+ fail-fast: false
+ matrix:
+ runner:
+ - name: Windows
+ os: windows-latest
+
+ - name: Linux
+ os: namespace-profile-default
+
+ - name: macOS
+ os: macos-14
+
+ integration:
+ - upgrade
+ - vite
+ - cli
+ - postcss
+ - oxide
+ - webpack
+
+ # Exclude windows and macos from being built on feature branches
+ run-all:
+ - ${{ github.ref == 'refs/heads/main' || contains(github.event.pull_request.body, '[ci-all]') }}
+ exclude:
+ - run-all: false
+ runner:
+ name: Windows
+ - run-all: false
+ runner:
+ name: macOS
+
+ runs-on: ${{ matrix.runner.os }}
+ timeout-minutes: 30
+
+ name: ${{ matrix.runner.name }} / ${{ matrix.integration }}
+
+ steps:
+ - uses: actions/checkout@v6
+ - uses: pnpm/action-setup@v4
+
+ - run: |
+ git config --global user.name "github-actions[bot]"
+ git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
+
+ - name: Use Node.js ${{ env.NODE_VERSION }}
+ uses: actions/setup-node@v6
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: 'pnpm'
+
+ # Cargo already skips downloading dependencies if they already exist
+ - name: Cache cargo
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/bin/
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ ~/.cargo/git/db/
+ target/
+ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
+
+ # Cache the `oxide` Rust build
+ - name: Cache oxide build
+ uses: actions/cache@v5
+ with:
+ path: |
+ ./crates/node/*.node
+ ./crates/node/*.wasm
+ ./crates/node/index.d.ts
+ ./crates/node/index.js
+ ./crates/node/browser.js
+ ./crates/node/tailwindcss-oxide.wasi-browser.js
+ ./crates/node/tailwindcss-oxide.wasi.cjs
+ ./crates/node/wasi-worker-browser.mjs
+ ./crates/node/wasi-worker.mjs
+ key: ${{ runner.os }}-oxide-${{ hashFiles('./crates/**/*') }}
+
+ - name: Setup WASM target
+ run: rustup target add wasm32-wasip1-threads
+
+ - name: Install dependencies
+ run: pnpm install
+
+ - name: Build
+ run: pnpm run build
+ env:
+ CARGO_PROFILE_RELEASE_LTO: 'off'
+ CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER: 'lld-link'
+
+ - name: Test ${{ matrix.integration }}
+ run: pnpm run test:integrations ./integrations/${{ matrix.integration }}
+ env:
+ GITHUB_WORKSPACE: ${{ github.workspace }}
+
+ notify:
+ if: ${{ always() && github.ref == 'refs/heads/main' && needs.tests.result == 'failure' }}
+ needs: tests
+ runs-on: ubuntu-latest
+ steps:
+ - name: Notify Discord
+ uses: discord-actions/message@v2
+ with:
+ webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }}
+ message: 'The [most recent ${{ github.workflow }} workflow](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) on the `main` branch has failed.'
diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml
new file mode 100644
index 000000000000..d5ad798be455
--- /dev/null
+++ b/.github/workflows/prepare-release.yml
@@ -0,0 +1,334 @@
+name: Prepare Release
+
+on:
+ workflow_dispatch:
+ push:
+ tags:
+ - 'v*'
+
+env:
+ APP_NAME: tailwindcss-oxide
+ NODE_VERSION: 24
+ OXIDE_LOCATION: ./crates/node
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ strategy:
+ matrix:
+ include:
+ # Windows
+ - os: windows-latest
+ target: x86_64-pc-windows-msvc
+ - os: windows-latest
+ target: aarch64-pc-windows-msvc
+ # macOS
+ - os: macos-latest
+ target: x86_64-apple-darwin
+ strip: strip -x # Must use -x on macOS. This produces larger results on linux.
+ - os: macos-latest
+ target: aarch64-apple-darwin
+ page-size: 14
+ strip: strip -x # Must use -x on macOS. This produces larger results on linux.
+ # Android
+ - os: ubuntu-latest
+ target: aarch64-linux-android
+ strip: ${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip
+ - os: ubuntu-latest
+ target: armv7-linux-androideabi
+ strip: ${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip
+ # Linux
+ - os: ubuntu-latest
+ target: x86_64-unknown-linux-gnu
+ strip: strip
+ container:
+ image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian
+ - os: ubuntu-latest
+ target: aarch64-unknown-linux-gnu
+ strip: llvm-strip
+ container:
+ image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64
+ - os: ubuntu-latest
+ target: armv7-unknown-linux-gnueabihf
+ strip: llvm-strip
+ container:
+ image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-zig
+ - os: ubuntu-latest
+ target: aarch64-unknown-linux-musl
+ strip: aarch64-linux-musl-strip
+ download: true
+ container:
+ image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
+ - os: ubuntu-latest
+ target: x86_64-unknown-linux-musl
+ strip: strip
+ download: true
+ container:
+ image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
+
+ name: Build ${{ matrix.target }} (oxide)
+ runs-on: ${{ matrix.os }}
+ container: ${{ matrix.container }}
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v6
+ - uses: pnpm/action-setup@v4
+
+ - name: Use Node.js ${{ env.NODE_VERSION }}
+ uses: actions/setup-node@v6
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: 'pnpm'
+
+ - name: Install gcc-arm-linux-gnueabihf
+ if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }}
+ run: |
+ sudo apt-get update
+ sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y
+
+ # Cargo already skips downloading dependencies if they already exist
+ - name: Cache cargo
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/bin/
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ ~/.cargo/git/db/
+ target/
+ key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
+
+ # Cache the `oxide` Rust build
+ - name: Cache oxide build
+ uses: actions/cache@v5
+ with:
+ path: |
+ ./crates/node/*.node
+ ./crates/node/*.wasm
+ ./crates/node/index.d.ts
+ ./crates/node/index.js
+ ./crates/node/browser.js
+ ./crates/node/tailwindcss-oxide.wasi-browser.js
+ ./crates/node/tailwindcss-oxide.wasi.cjs
+ ./crates/node/wasi-worker-browser.mjs
+ ./crates/node/wasi-worker.mjs
+ key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }}
+
+ - name: Install Node.JS
+ uses: actions/setup-node@v6
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+
+ - name: Install Rust (Stable)
+ if: ${{ matrix.download }}
+ run: |
+ rustup default stable
+
+ - name: Setup rust target
+ run: rustup target add ${{ matrix.target }}
+
+ - name: Install dependencies
+ run: pnpm install --ignore-scripts --filter=!./playgrounds/*
+
+ - name: Build release
+ run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }}
+ env:
+ RUST_TARGET: ${{ matrix.target }}
+ JEMALLOC_SYS_WITH_LG_PAGE: ${{ matrix.page-size }}
+
+ - name: Strip debug symbols # https://github.com/rust-lang/rust/issues/46034
+ if: ${{ matrix.strip }}
+ run: ${{ matrix.strip }} ${{ env.OXIDE_LOCATION }}/*.node
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v6
+ with:
+ name: bindings-${{ matrix.target }}
+ path: ${{ env.OXIDE_LOCATION }}/*.node
+
+ build-freebsd:
+ name: Build x86_64-unknown-freebsd (OXIDE)
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v6
+ - name: Build FreeBSD
+ uses: cross-platform-actions/action@v0.25.0
+ env:
+ DEBUG: napi:*
+ RUSTUP_HOME: /usr/local/rustup
+ CARGO_HOME: /usr/local/cargo
+ RUSTUP_IO_THREADS: 1
+ RUST_TARGET: x86_64-unknown-freebsd
+ with:
+ operating_system: freebsd
+ version: '14.0'
+ memory: 13G
+ cpu_count: 3
+ environment_variables: 'DEBUG RUSTUP_IO_THREADS'
+ shell: bash
+ run: |
+ sudo pkg install -y -f curl node libnghttp2 npm
+ sudo npm install -g pnpm@9.6.0 --unsafe-perm=true
+ curl -sSf https://static.rust-lang.org/rustup/archive/1.27.1/x86_64-unknown-freebsd/rustup-init --output rustup-init
+ chmod +x rustup-init
+ ./rustup-init -y --profile minimal
+ source "$HOME/.cargo/env"
+ pnpm install --ignore-scripts --filter=!./playgrounds/* || true
+ echo "~~~~ rustc --version ~~~~"
+ rustc --version
+ echo "~~~~ node -v ~~~~"
+ node -v
+ echo "~~~~ pnpm --version ~~~~"
+ pnpm --version
+ pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform
+ strip -x ${{ env.OXIDE_LOCATION }}/*.node
+ ls -la ${{ env.OXIDE_LOCATION }}
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v6
+ with:
+ name: bindings-x86_64-unknown-freebsd
+ path: ${{ env.OXIDE_LOCATION }}/*.node
+
+ prepare:
+ runs-on: macos-14
+ timeout-minutes: 15
+ name: Build and release Tailwind CSS
+
+ permissions:
+ contents: write # for softprops/action-gh-release to create GitHub release
+ # https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions
+ id-token: write
+
+ needs:
+ - build
+ - build-freebsd
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 20
+
+ - run: git fetch --tags -f
+
+ - name: Resolve version
+ id: vars
+ run: |
+ echo "TAG_NAME=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
+
+ - uses: pnpm/action-setup@v4
+
+ - name: Use Node.js ${{ env.NODE_VERSION }}
+ uses: actions/setup-node@v6
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: 'pnpm'
+ registry-url: 'https://registry.npmjs.org'
+
+ # Cargo already skips downloading dependencies if they already exist
+ - name: Cache cargo
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/bin/
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ ~/.cargo/git/db/
+ target/
+ key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
+
+ # Cache the `oxide` Rust build
+ - name: Cache oxide build
+ uses: actions/cache@v5
+ with:
+ path: |
+ ./crates/node/*.node
+ ./crates/node/*.wasm
+ ./crates/node/index.d.ts
+ ./crates/node/index.js
+ ./crates/node/browser.js
+ ./crates/node/tailwindcss-oxide.wasi-browser.js
+ ./crates/node/tailwindcss-oxide.wasi.cjs
+ ./crates/node/wasi-worker-browser.mjs
+ ./crates/node/wasi-worker.mjs
+ key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }}
+
+ - name: Setup WASM target
+ run: rustup target add wasm32-wasip1-threads
+
+ - name: Install dependencies
+ run: pnpm --filter=!./playgrounds/* install
+
+ - name: Download artifacts
+ uses: actions/download-artifact@v7
+ with:
+ path: ${{ env.OXIDE_LOCATION }}
+
+ - name: Move artifacts
+ run: |
+ cd ${{ env.OXIDE_LOCATION }}
+ cp bindings-x86_64-pc-windows-msvc/* ./npm/win32-x64-msvc/
+ cp bindings-aarch64-pc-windows-msvc/* ./npm/win32-arm64-msvc/
+ cp bindings-x86_64-apple-darwin/* ./npm/darwin-x64/
+ cp bindings-aarch64-apple-darwin/* ./npm/darwin-arm64/
+ cp bindings-aarch64-linux-android/* ./npm/android-arm64/
+ cp bindings-armv7-linux-androideabi/* ./npm/android-arm-eabi/
+ cp bindings-aarch64-unknown-linux-gnu/* ./npm/linux-arm64-gnu/
+ cp bindings-aarch64-unknown-linux-musl/* ./npm/linux-arm64-musl/
+ cp bindings-armv7-unknown-linux-gnueabihf/* ./npm/linux-arm-gnueabihf/
+ cp bindings-x86_64-unknown-linux-gnu/* ./npm/linux-x64-gnu/
+ cp bindings-x86_64-unknown-linux-musl/* ./npm/linux-x64-musl/
+ cp bindings-x86_64-unknown-freebsd/* ./npm/freebsd-x64/
+
+ - name: Build Tailwind CSS
+ run: pnpm run build
+ env:
+ FEATURES_ENV: stable
+
+ - name: Run pre-publish optimizations scripts
+ run: node ./scripts/pre-publish-optimizations.mjs
+
+ - name: Lock pre-release versions
+ run: node ./scripts/lock-pre-release-versions.mjs
+
+ - name: Get release notes
+ run: |
+ RELEASE_NOTES=$(node ./scripts/release-notes.mjs)
+ echo "RELEASE_NOTES<> $GITHUB_ENV
+ echo "$RELEASE_NOTES" >> $GITHUB_ENV
+ echo "EOF" >> $GITHUB_ENV
+
+ - name: Upload standalone artifacts
+ uses: actions/upload-artifact@v6
+ with:
+ name: tailwindcss-standalone
+ path: packages/@tailwindcss-standalone/dist/
+
+ - name: Upload npm package tarballs
+ uses: actions/upload-artifact@v6
+ with:
+ name: npm-package-tarballs
+ path: dist/*.tgz
+
+ - name: Prepare GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ draft: true
+ tag_name: ${{ env.TAG_NAME }}
+ body: |
+ ${{ env.RELEASE_NOTES }}
+ files: |
+ packages/@tailwindcss-standalone/dist/sha256sums.txt
+ packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64
+ packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64-musl
+ packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64
+ packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64-musl
+ packages/@tailwindcss-standalone/dist/tailwindcss-macos-arm64
+ packages/@tailwindcss-standalone/dist/tailwindcss-macos-x64
+ packages/@tailwindcss-standalone/dist/tailwindcss-windows-x64.exe
diff --git a/.github/workflows/release-insiders.yml b/.github/workflows/release-insiders.yml
new file mode 100644
index 000000000000..4852cce7b7a9
--- /dev/null
+++ b/.github/workflows/release-insiders.yml
@@ -0,0 +1,322 @@
+name: Release Insiders
+
+on:
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+env:
+ APP_NAME: tailwindcss-oxide
+ NODE_VERSION: 24
+ OXIDE_LOCATION: ./crates/node
+ RELEASE_CHANNEL: insiders
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ strategy:
+ matrix:
+ include:
+ # Windows
+ - os: windows-latest
+ target: x86_64-pc-windows-msvc
+ - os: windows-latest
+ target: aarch64-pc-windows-msvc
+ # macOS
+ - os: macos-latest
+ target: x86_64-apple-darwin
+ strip: strip -x # Must use -x on macOS. This produces larger results on linux.
+ - os: macos-latest
+ target: aarch64-apple-darwin
+ page-size: 14
+ strip: strip -x # Must use -x on macOS. This produces larger results on linux.
+ # Android
+ - os: ubuntu-latest
+ target: aarch64-linux-android
+ strip: ${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip
+ - os: ubuntu-latest
+ target: armv7-linux-androideabi
+ strip: ${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip
+ # Linux
+ - os: ubuntu-latest
+ target: x86_64-unknown-linux-gnu
+ strip: strip
+ container:
+ image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian
+ - os: ubuntu-latest
+ target: aarch64-unknown-linux-gnu
+ strip: llvm-strip
+ container:
+ image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64
+ - os: ubuntu-latest
+ target: armv7-unknown-linux-gnueabihf
+ strip: llvm-strip
+ container:
+ image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-zig
+ - os: ubuntu-latest
+ target: aarch64-unknown-linux-musl
+ strip: aarch64-linux-musl-strip
+ download: true
+ container:
+ image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
+ - os: ubuntu-latest
+ target: x86_64-unknown-linux-musl
+ strip: strip
+ download: true
+ container:
+ image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
+
+ name: Build ${{ matrix.target }} (oxide)
+ runs-on: ${{ matrix.os }}
+ container: ${{ matrix.container }}
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v6
+ - uses: pnpm/action-setup@v4
+
+ - name: Use Node.js ${{ env.NODE_VERSION }}
+ uses: actions/setup-node@v6
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: 'pnpm'
+
+ - name: Install gcc-arm-linux-gnueabihf
+ if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }}
+ run: |
+ sudo apt-get update
+ sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y
+
+ # Cargo already skips downloading dependencies if they already exist
+ - name: Cache cargo
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/bin/
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ ~/.cargo/git/db/
+ target/
+ key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
+
+ # Cache the `oxide` Rust build
+ - name: Cache oxide build
+ uses: actions/cache@v5
+ with:
+ path: |
+ ./crates/node/*.node
+ ./crates/node/*.wasm
+ ./crates/node/index.d.ts
+ ./crates/node/index.js
+ ./crates/node/browser.js
+ ./crates/node/tailwindcss-oxide.wasi-browser.js
+ ./crates/node/tailwindcss-oxide.wasi.cjs
+ ./crates/node/wasi-worker-browser.mjs
+ ./crates/node/wasi-worker.mjs
+ key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }}
+
+ - name: Install Node.JS
+ uses: actions/setup-node@v6
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+
+ - name: Install Rust (Stable)
+ if: ${{ matrix.download }}
+ run: |
+ rustup default stable
+
+ - name: Setup rust target
+ run: rustup target add ${{ matrix.target }}
+
+ - name: Install dependencies
+ run: pnpm install --ignore-scripts --filter=!./playgrounds/*
+
+ - name: Build release
+ run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }}
+ env:
+ RUST_TARGET: ${{ matrix.target }}
+ JEMALLOC_SYS_WITH_LG_PAGE: ${{ matrix.page-size }}
+
+ - name: Strip debug symbols # https://github.com/rust-lang/rust/issues/46034
+ if: ${{ matrix.strip }}
+ run: ${{ matrix.strip }} ${{ env.OXIDE_LOCATION }}/*.node
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v6
+ with:
+ name: bindings-${{ matrix.target }}
+ path: ${{ env.OXIDE_LOCATION }}/*.node
+
+ build-freebsd:
+ name: Build x86_64-unknown-freebsd (OXIDE)
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v6
+ - name: Build FreeBSD
+ uses: cross-platform-actions/action@v0.25.0
+ env:
+ DEBUG: napi:*
+ RUSTUP_HOME: /usr/local/rustup
+ CARGO_HOME: /usr/local/cargo
+ RUSTUP_IO_THREADS: 1
+ RUST_TARGET: x86_64-unknown-freebsd
+ with:
+ operating_system: freebsd
+ version: '14.0'
+ memory: 13G
+ cpu_count: 3
+ environment_variables: 'DEBUG RUSTUP_IO_THREADS'
+ shell: bash
+ run: |
+ sudo pkg install -y -f curl node libnghttp2 npm
+ sudo npm install -g pnpm@9.6.0 --unsafe-perm=true
+ curl -sSf https://static.rust-lang.org/rustup/archive/1.27.1/x86_64-unknown-freebsd/rustup-init --output rustup-init
+ chmod +x rustup-init
+ ./rustup-init -y --profile minimal
+ source "$HOME/.cargo/env"
+ echo "~~~~ rustc --version ~~~~"
+ rustc --version
+ echo "~~~~ node -v ~~~~"
+ node -v
+ echo "~~~~ pnpm --version ~~~~"
+ pnpm --version
+ pnpm install --ignore-scripts --filter=!./playgrounds/* || true
+ pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform
+ strip -x ${{ env.OXIDE_LOCATION }}/*.node
+ ls -la ${{ env.OXIDE_LOCATION }}
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v6
+ with:
+ name: bindings-x86_64-unknown-freebsd
+ path: ${{ env.OXIDE_LOCATION }}/*.node
+
+ release:
+ runs-on: macos-14
+ timeout-minutes: 15
+ name: Build and release Tailwind CSS insiders
+
+ permissions:
+ contents: write # for softprops/action-gh-release to create GitHub release
+ # https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions
+ id-token: write
+
+ needs:
+ - build
+ - build-freebsd
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 20
+
+ - name: Resolve version
+ id: vars
+ run: |
+ echo "SHA_SHORT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
+
+ - uses: pnpm/action-setup@v4
+
+ - name: Use Node.js ${{ env.NODE_VERSION }}
+ uses: actions/setup-node@v6
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: 'pnpm'
+ registry-url: 'https://registry.npmjs.org'
+
+ # Cargo already skips downloading dependencies if they already exist
+ - name: Cache cargo
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/bin/
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ ~/.cargo/git/db/
+ target/
+ key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
+
+ # Cache the `oxide` Rust build
+ - name: Cache oxide build
+ uses: actions/cache@v5
+ with:
+ path: |
+ ./crates/node/*.node
+ ./crates/node/*.wasm
+ ./crates/node/index.d.ts
+ ./crates/node/index.js
+ ./crates/node/browser.js
+ ./crates/node/tailwindcss-oxide.wasi-browser.js
+ ./crates/node/tailwindcss-oxide.wasi.cjs
+ ./crates/node/wasi-worker-browser.mjs
+ ./crates/node/wasi-worker.mjs
+ key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }}
+
+ - name: Setup WASM target
+ run: rustup target add wasm32-wasip1-threads
+
+ - name: Install dependencies
+ run: pnpm --filter=!./playgrounds/* install
+
+ - name: Download artifacts
+ uses: actions/download-artifact@v7
+ with:
+ path: ${{ env.OXIDE_LOCATION }}
+
+ - name: Move artifacts
+ run: |
+ cd ${{ env.OXIDE_LOCATION }}
+ cp bindings-x86_64-pc-windows-msvc/* ./npm/win32-x64-msvc/
+ cp bindings-aarch64-pc-windows-msvc/* ./npm/win32-arm64-msvc/
+ cp bindings-x86_64-apple-darwin/* ./npm/darwin-x64/
+ cp bindings-aarch64-apple-darwin/* ./npm/darwin-arm64/
+ cp bindings-aarch64-linux-android/* ./npm/android-arm64/
+ cp bindings-armv7-linux-androideabi/* ./npm/android-arm-eabi/
+ cp bindings-aarch64-unknown-linux-gnu/* ./npm/linux-arm64-gnu/
+ cp bindings-aarch64-unknown-linux-musl/* ./npm/linux-arm64-musl/
+ cp bindings-armv7-unknown-linux-gnueabihf/* ./npm/linux-arm-gnueabihf/
+ cp bindings-x86_64-unknown-linux-gnu/* ./npm/linux-x64-gnu/
+ cp bindings-x86_64-unknown-linux-musl/* ./npm/linux-x64-musl/
+ cp bindings-x86_64-unknown-freebsd/* ./npm/freebsd-x64/
+
+ - name: 'Version based on commit: 0.0.0-${{ env.RELEASE_CHANNEL }}.${{ env.SHA_SHORT }}'
+ run: pnpm run version-packages 0.0.0-${{ env.RELEASE_CHANNEL }}.${{ env.SHA_SHORT }}
+
+ - name: Build Tailwind CSS
+ run: pnpm run build
+
+ - name: Run pre-publish optimizations scripts
+ run: node ./scripts/pre-publish-optimizations.mjs
+
+ - name: Lock pre-release versions
+ run: node ./scripts/lock-pre-release-versions.mjs
+
+ - name: Upload npm package tarballs
+ uses: actions/upload-artifact@v6
+ with:
+ name: npm-package-tarballs
+ path: dist/*.tgz
+
+ - name: Publish
+ run: |
+ pnpm --recursive --filter="!@tailwindcss/oxide-wasm32-wasi" publish --tag ${{ env.RELEASE_CHANNEL }} --no-git-checks
+ # The wasm package needs a special npm config that isn't read when pnpm --recursive is used
+ pushd crates/node/npm/wasm32-wasi; pnpm publish --tag ${{ env.RELEASE_CHANNEL }} --no-git-checks; popd;
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+
+ - name: Trigger Tailwind Play update
+ uses: actions/github-script@v8
+ with:
+ github-token: ${{ secrets.TAILWIND_PLAY_TOKEN }}
+ script: |
+ await github.rest.actions.createWorkflowDispatch({
+ owner: 'tailwindlabs',
+ repo: 'upgrades',
+ ref: 'main',
+ workflow_id: 'upgrade-tailwindcss.yml'
+ })
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 000000000000..a872900ff9e2
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,312 @@
+name: Release
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+env:
+ APP_NAME: tailwindcss-oxide
+ NODE_VERSION: 24
+ OXIDE_LOCATION: ./crates/node
+
+jobs:
+ build:
+ strategy:
+ matrix:
+ include:
+ # Windows
+ - os: windows-latest
+ target: x86_64-pc-windows-msvc
+ - os: windows-latest
+ target: aarch64-pc-windows-msvc
+ # macOS
+ - os: macos-latest
+ target: x86_64-apple-darwin
+ strip: strip -x # Must use -x on macOS. This produces larger results on linux.
+ - os: macos-latest
+ target: aarch64-apple-darwin
+ page-size: 14
+ strip: strip -x # Must use -x on macOS. This produces larger results on linux.
+ # Android
+ - os: ubuntu-latest
+ target: aarch64-linux-android
+ strip: ${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip
+ - os: ubuntu-latest
+ target: armv7-linux-androideabi
+ strip: ${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip
+ # Linux
+ - os: ubuntu-latest
+ target: x86_64-unknown-linux-gnu
+ strip: strip
+ container:
+ image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian
+ - os: ubuntu-latest
+ target: aarch64-unknown-linux-gnu
+ strip: llvm-strip
+ container:
+ image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64
+ - os: ubuntu-latest
+ target: armv7-unknown-linux-gnueabihf
+ strip: llvm-strip
+ container:
+ image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-zig
+ - os: ubuntu-latest
+ target: aarch64-unknown-linux-musl
+ strip: aarch64-linux-musl-strip
+ download: true
+ container:
+ image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
+ - os: ubuntu-latest
+ target: x86_64-unknown-linux-musl
+ strip: strip
+ download: true
+ container:
+ image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
+
+ name: Build ${{ matrix.target }} (oxide)
+ runs-on: ${{ matrix.os }}
+ container: ${{ matrix.container }}
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v6
+ - uses: pnpm/action-setup@v4
+
+ - name: Use Node.js ${{ env.NODE_VERSION }}
+ uses: actions/setup-node@v6
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: 'pnpm'
+
+ - name: Install gcc-arm-linux-gnueabihf
+ if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }}
+ run: |
+ sudo apt-get update
+ sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y
+
+ # Cargo already skips downloading dependencies if they already exist
+ - name: Cache cargo
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/bin/
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ ~/.cargo/git/db/
+ target/
+ key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
+
+ # Cache the `oxide` Rust build
+ - name: Cache oxide build
+ uses: actions/cache@v5
+ with:
+ path: |
+ ./crates/node/*.node
+ ./crates/node/*.wasm
+ ./crates/node/index.d.ts
+ ./crates/node/index.js
+ ./crates/node/browser.js
+ ./crates/node/tailwindcss-oxide.wasi-browser.js
+ ./crates/node/tailwindcss-oxide.wasi.cjs
+ ./crates/node/wasi-worker-browser.mjs
+ ./crates/node/wasi-worker.mjs
+ key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }}
+
+ - name: Install Node.JS
+ uses: actions/setup-node@v6
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+
+ - name: Install Rust (Stable)
+ if: ${{ matrix.download }}
+ run: |
+ rustup default stable
+
+ - name: Setup rust target
+ run: rustup target add ${{ matrix.target }}
+
+ - name: Install dependencies
+ run: pnpm install --ignore-scripts --filter=!./playgrounds/*
+
+ - name: Build release
+ run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }}
+ env:
+ RUST_TARGET: ${{ matrix.target }}
+ JEMALLOC_SYS_WITH_LG_PAGE: ${{ matrix.page-size }}
+
+ - name: Strip debug symbols # https://github.com/rust-lang/rust/issues/46034
+ if: ${{ matrix.strip }}
+ run: ${{ matrix.strip }} ${{ env.OXIDE_LOCATION }}/*.node
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v6
+ with:
+ name: bindings-${{ matrix.target }}
+ path: ${{ env.OXIDE_LOCATION }}/*.node
+
+ build-freebsd:
+ name: Build x86_64-unknown-freebsd (OXIDE)
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v6
+ - name: Build FreeBSD
+ uses: cross-platform-actions/action@v0.25.0
+ env:
+ DEBUG: napi:*
+ RUSTUP_HOME: /usr/local/rustup
+ CARGO_HOME: /usr/local/cargo
+ RUSTUP_IO_THREADS: 1
+ RUST_TARGET: x86_64-unknown-freebsd
+ with:
+ operating_system: freebsd
+ version: '14.0'
+ memory: 13G
+ cpu_count: 3
+ environment_variables: 'DEBUG RUSTUP_IO_THREADS'
+ shell: bash
+ run: |
+ sudo pkg install -y -f curl node libnghttp2 npm
+ sudo npm install -g pnpm@9.6.0 --unsafe-perm=true
+ curl -sSf https://static.rust-lang.org/rustup/archive/1.27.1/x86_64-unknown-freebsd/rustup-init --output rustup-init
+ chmod +x rustup-init
+ ./rustup-init -y --profile minimal
+ source "$HOME/.cargo/env"
+ echo "~~~~ rustc --version ~~~~"
+ rustc --version
+ echo "~~~~ node -v ~~~~"
+ node -v
+ echo "~~~~ pnpm --version ~~~~"
+ pnpm --version
+ pnpm install --ignore-scripts --filter=!./playgrounds/* || true
+ pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform
+ strip -x ${{ env.OXIDE_LOCATION }}/*.node
+ ls -la ${{ env.OXIDE_LOCATION }}
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v6
+ with:
+ name: bindings-x86_64-unknown-freebsd
+ path: ${{ env.OXIDE_LOCATION }}/*.node
+
+ release:
+ runs-on: macos-14
+ timeout-minutes: 15
+ name: Build and release Tailwind CSS
+
+ permissions:
+ contents: write # for softprops/action-gh-release to create GitHub release
+ # https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions
+ id-token: write
+
+ needs:
+ - build
+ - build-freebsd
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 20
+
+ - uses: pnpm/action-setup@v4
+
+ - name: Use Node.js ${{ env.NODE_VERSION }}
+ uses: actions/setup-node@v6
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: 'pnpm'
+ registry-url: 'https://registry.npmjs.org'
+
+ # Cargo already skips downloading dependencies if they already exist
+ - name: Cache cargo
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/bin/
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ ~/.cargo/git/db/
+ target/
+ key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
+
+ # Cache the `oxide` Rust build
+ - name: Cache oxide build
+ uses: actions/cache@v5
+ with:
+ path: |
+ ./crates/node/*.node
+ ./crates/node/*.wasm
+ ./crates/node/index.d.ts
+ ./crates/node/index.js
+ ./crates/node/browser.js
+ ./crates/node/tailwindcss-oxide.wasi-browser.js
+ ./crates/node/tailwindcss-oxide.wasi.cjs
+ ./crates/node/wasi-worker-browser.mjs
+ ./crates/node/wasi-worker.mjs
+ key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }}
+
+ - name: Setup WASM target
+ run: rustup target add wasm32-wasip1-threads
+
+ - name: Install dependencies
+ run: pnpm --filter=!./playgrounds/* install
+
+ - name: Download artifacts
+ uses: actions/download-artifact@v7
+ with:
+ path: ${{ env.OXIDE_LOCATION }}
+
+ - name: Move artifacts
+ run: |
+ cd ${{ env.OXIDE_LOCATION }}
+ cp bindings-x86_64-pc-windows-msvc/* ./npm/win32-x64-msvc/
+ cp bindings-aarch64-pc-windows-msvc/* ./npm/win32-arm64-msvc/
+ cp bindings-x86_64-apple-darwin/* ./npm/darwin-x64/
+ cp bindings-aarch64-apple-darwin/* ./npm/darwin-arm64/
+ cp bindings-aarch64-linux-android/* ./npm/android-arm64/
+ cp bindings-armv7-linux-androideabi/* ./npm/android-arm-eabi/
+ cp bindings-aarch64-unknown-linux-gnu/* ./npm/linux-arm64-gnu/
+ cp bindings-aarch64-unknown-linux-musl/* ./npm/linux-arm64-musl/
+ cp bindings-armv7-unknown-linux-gnueabihf/* ./npm/linux-arm-gnueabihf/
+ cp bindings-x86_64-unknown-linux-gnu/* ./npm/linux-x64-gnu/
+ cp bindings-x86_64-unknown-linux-musl/* ./npm/linux-x64-musl/
+ cp bindings-x86_64-unknown-freebsd/* ./npm/freebsd-x64/
+
+ - name: Build Tailwind CSS
+ run: pnpm run build
+ env:
+ FEATURES_ENV: stable
+
+ - name: Run pre-publish optimizations scripts
+ run: node ./scripts/pre-publish-optimizations.mjs
+
+ - name: Lock pre-release versions
+ run: node ./scripts/lock-pre-release-versions.mjs
+
+ - name: Calculate environment variables
+ run: |
+ echo "RELEASE_CHANNEL=$(node ./scripts/release-channel.js)" >> $GITHUB_ENV
+ echo "TAILWINDCSS_VERSION=$(node -e 'console.log(require(`./packages/tailwindcss/package.json`).version);')" >> $GITHUB_ENV
+
+ - name: Publish
+ run: |
+ pnpm --recursive --filter="!@tailwindcss/oxide-wasm32-wasi" publish --tag ${{ env.RELEASE_CHANNEL }} --no-git-checks
+ # The wasm package needs a special npm config that isn't read when pnpm --recursive is used
+ pushd crates/node/npm/wasm32-wasi; pnpm publish --tag ${{ env.RELEASE_CHANNEL }} --no-git-checks; popd;
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+
+ - name: Trigger Tailwind Play update
+ if: env.RELEASE_CHANNEL == 'latest'
+ uses: actions/github-script@v8
+ with:
+ github-token: ${{ secrets.TAILWIND_PLAY_TOKEN }}
+ script: |
+ await github.rest.actions.createWorkflowDispatch({
+ owner: 'tailwindlabs',
+ repo: 'upgrades',
+ ref: 'main',
+ workflow_id: 'upgrade-tailwindcss.yml'
+ })
diff --git a/.gitignore b/.gitignore
index 60fd5ef5ade3..bbac7d227997 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,10 @@
-/node_modules
-/lib
-/example
-yarn-error.log
+node_modules/
+dist/
+coverage/
+.turbo
+test-results/
+playwright-report/
+blob-report/
+playwright/.cache/
+target/
+.debug/
diff --git a/.npmignore b/.npmignore
deleted file mode 100644
index 1b92e067e5fe..000000000000
--- a/.npmignore
+++ /dev/null
@@ -1,4 +0,0 @@
-/__tests__/
-/jest/
-/src/
-yarn-error.log
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 000000000000..4f50b932fbca
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,9 @@
+coverage/
+node_modules/
+pnpm-lock.yaml
+target/
+crates/node/index.d.ts
+crates/node/index.js
+crates/ignore/
+.next
+.fingerprint
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index bc1e36754b34..000000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-language: node_js
-node_js:
- - "8"
- - "10"
-
-cache:
- directories:
- - $HOME/.cache/yarn
-
-script:
- - yarn
- - yarn run test
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 000000000000..fccae8e6f7a3
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,4270 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Added
+
+- _Experimental_: Add `@container-size` utility ([#18901](https://github.com/tailwindlabs/tailwindcss/pull/18901))
+
+### Fixed
+
+- Improve canonicalizations for `tracking-*` utilities ([#19827](https://github.com/tailwindlabs/tailwindcss/pull/19827))
+- Fix crash due to invalid characters in candidate ([#19829](https://github.com/tailwindlabs/tailwindcss/pull/19829))
+- Ensure query params in imports are considered unique resources when using `@tailwindcss/webpack` ([#19723](https://github.com/tailwindlabs/tailwindcss/pull/19723))
+
+## [4.2.2] - 2026-03-18
+
+### Fixed
+
+- Don't crash when candidates contain prototype properties like `row-constructor` ([#19725](https://github.com/tailwindlabs/tailwindcss/pull/19725))
+- Canonicalize `calc(var(--spacing)*…)` expressions into `--spacing(…)` ([#19769](https://github.com/tailwindlabs/tailwindcss/pull/19769))
+- Fix crash in canonicalization step when handling utilities containing `@property` at-rules (e.g. `shadow-sm border`) ([#19727](https://github.com/tailwindlabs/tailwindcss/pull/19727))
+- Skip full reload for server only modules scanned by client CSS when using `@tailwindcss/vite` ([#19745](https://github.com/tailwindlabs/tailwindcss/pull/19745))
+- Add support for Vite 8 in `@tailwindcss/vite` ([#19790](https://github.com/tailwindlabs/tailwindcss/pull/19790))
+- Improve canonicalization for bare values exceeding default spacing scale suggestions (e.g. `w-1234 h-1234` → `size-1234`) ([#19809](https://github.com/tailwindlabs/tailwindcss/pull/19809))
+- Fix canonicalization resulting in empty list (e.g. `w-5 h-5 size-5` → `''` instead of `size-5`) ([#19812](https://github.com/tailwindlabs/tailwindcss/pull/19812))
+- Resolve tsconfig paths to allow for `@import '@/path/to/file';` when using `@tailwindcss/vite` ([#19803](https://github.com/tailwindlabs/tailwindcss/pull/19803))
+
+## [4.2.1] - 2026-02-23
+
+### Fixed
+
+- Allow trailing dash in functional utility names for backwards compatibility ([#19696](https://github.com/tailwindlabs/tailwindcss/pull/19696))
+- Properly detect classes containing `.` characters within curly braces in MDX files ([#19711](https://github.com/tailwindlabs/tailwindcss/pull/19711))
+
+## [4.2.0] - 2026-02-18
+
+### Added
+
+- Add mauve, olive, mist, and taupe color palettes to the default theme ([#19627](https://github.com/tailwindlabs/tailwindcss/pull/19627))
+- Add `@tailwindcss/webpack` package to run Tailwind CSS as a webpack plugin ([#19610](https://github.com/tailwindlabs/tailwindcss/pull/19610))
+- Add `pbs-*` and `pbe-*` utilities for `padding-block-start` and `padding-block-end` ([#19601](https://github.com/tailwindlabs/tailwindcss/pull/19601))
+- Add `mbs-*` and `mbe-*` utilities for `margin-block-start` and `margin-block-end` ([#19601](https://github.com/tailwindlabs/tailwindcss/pull/19601))
+- Add `scroll-pbs-*` and `scroll-pbe-*` utilities for `scroll-padding-block-start` and `scroll-padding-block-end` ([#19601](https://github.com/tailwindlabs/tailwindcss/pull/19601))
+- Add `scroll-mbs-*` and `scroll-mbe-*` utilities for `scroll-margin-block-start` and `scroll-margin-block-end` ([#19601](https://github.com/tailwindlabs/tailwindcss/pull/19601))
+- Add `border-bs-*` and `border-be-*` utilities for `border-block-start` and `border-block-end` ([#19601](https://github.com/tailwindlabs/tailwindcss/pull/19601))
+- Add `inline-*`, `min-inline-*`, `max-inline-*` utilities for `inline-size`, `min-inline-size`, and `max-inline-size` ([#19612](https://github.com/tailwindlabs/tailwindcss/pull/19612))
+- Add `block-*`, `min-block-*`, `max-block-*` utilities for `block-size`, `min-block-size`, and `max-block-size` ([#19612](https://github.com/tailwindlabs/tailwindcss/pull/19612))
+- Add `inset-s-*`, `inset-e-*`, `inset-bs-*`, `inset-be-*` utilities for `inset-inline-start`, `inset-inline-end`, `inset-block-start`, and `inset-block-end` ([#19613](https://github.com/tailwindlabs/tailwindcss/pull/19613))
+- Add `font-features-*` utility for `font-feature-settings` ([#19623](https://github.com/tailwindlabs/tailwindcss/pull/19623))
+
+### Fixed
+
+- Prevent double `@supports` wrapper for `color-mix` values ([#19450](https://github.com/tailwindlabs/tailwindcss/pull/19450))
+- Allow whitespace around `@source inline()` argument ([#19461](https://github.com/tailwindlabs/tailwindcss/pull/19461))
+- Emit comment when source maps are saved to files when using `@tailwindcss/cli` ([#19447](https://github.com/tailwindlabs/tailwindcss/pull/19447))
+- Detect utilities containing capital letters followed by numbers ([#19465](https://github.com/tailwindlabs/tailwindcss/pull/19465))
+- Fix class extraction for Rails' strict locals ([#19525](https://github.com/tailwindlabs/tailwindcss/pull/19525))
+- Align `@utility` name validation with Oxide scanner rules ([#19524](https://github.com/tailwindlabs/tailwindcss/pull/19524))
+- Fix infinite loop when using `@variant` inside `@custom-variant` ([#19633](https://github.com/tailwindlabs/tailwindcss/pull/19633))
+- Allow multiples of `.25` in `aspect-*` fractions (e.g. `aspect-8.5/11`) ([#19688](https://github.com/tailwindlabs/tailwindcss/pull/19688))
+- Ensure changes to external files listed via `@source` trigger a full page reload when using `@tailwindcss/vite` ([#19670](https://github.com/tailwindlabs/tailwindcss/pull/19670))
+- Improve performance of Oxide scanner in bigger projects by reducing file system walks ([#19632](https://github.com/tailwindlabs/tailwindcss/pull/19632))
+- Ensure import aliases in Astro v5 work without crashing when using `@tailwindcss/vite` ([#19677](https://github.com/tailwindlabs/tailwindcss/issues/19677))
+- Allow escape characters in `@utility` names to improve support with formatters such as Biome ([#19626](https://github.com/tailwindlabs/tailwindcss/pull/19626))
+- Fix incorrect canonicalization results when canonicalizing multiple times ([#19675](https://github.com/tailwindlabs/tailwindcss/pull/19675))
+- Add `.jj` to default ignored content directories ([#19687](https://github.com/tailwindlabs/tailwindcss/pull/19687))
+
+### Deprecated
+
+- Deprecate `start-*` and `end-*` utilities in favor of `inset-s-*` and `inset-e-*` utilities ([#19613](https://github.com/tailwindlabs/tailwindcss/pull/19613))
+
+## [4.1.18] - 2025-12-11
+
+### Fixed
+
+- Ensure validation of `source(…)` happens relative to the file it is in ([#19274](https://github.com/tailwindlabs/tailwindcss/pull/19274))
+- Include filename and line numbers in CSS parse errors ([#19282](https://github.com/tailwindlabs/tailwindcss/pull/19282))
+- Skip comments in Ruby files when checking for class names ([#19243](https://github.com/tailwindlabs/tailwindcss/pull/19243))
+- Skip over arbitrary property utilities with a top-level `!` in the value ([#19243](https://github.com/tailwindlabs/tailwindcss/pull/19243))
+- Support environment API in `@tailwindcss/vite` ([#18970](https://github.com/tailwindlabs/tailwindcss/pull/18970))
+- Preserve case of theme keys from JS configs and plugins ([#19337](https://github.com/tailwindlabs/tailwindcss/pull/19337))
+- Write source maps correctly on the CLI when using `--watch` ([#19373](https://github.com/tailwindlabs/tailwindcss/pull/19373))
+- Handle special defaults (like `ringColor.DEFAULT`) in JS configs ([#19348](https://github.com/tailwindlabs/tailwindcss/pull/19348))
+- Improve backwards compatibility for `content` theme key from JS configs ([#19381](https://github.com/tailwindlabs/tailwindcss/pull/19381))
+- Upgrade: Handle `future` and `experimental` config keys ([#19344](https://github.com/tailwindlabs/tailwindcss/pull/19344))
+- Try to canonicalize any arbitrary utility to a bare value ([#19379](https://github.com/tailwindlabs/tailwindcss/pull/19379))
+- Validate candidates similarly to Oxide ([#19397](https://github.com/tailwindlabs/tailwindcss/pull/19397))
+- Canonicalization: combine `text-*` and `leading-*` classes ([#19396](https://github.com/tailwindlabs/tailwindcss/pull/19396))
+- Correctly handle duplicate CLI arguments ([#19416](https://github.com/tailwindlabs/tailwindcss/pull/19416))
+- Don’t emit color-mix fallback rules inside `@keyframes` ([#19419](https://github.com/tailwindlabs/tailwindcss/pull/19419))
+- CLI: Don't hang when output is `/dev/stdout` ([#19421](https://github.com/tailwindlabs/tailwindcss/pull/19421))
+
+## [3.4.19] - 2025-12-10
+
+### Fixed
+
+- Don’t break `sibling-*()` functions when used inside `calc(…)` ([#19335](https://github.com/tailwindlabs/tailwindcss/pull/19335))
+
+## [4.1.17] - 2025-11-06
+
+### Fixed
+
+- Substitute `@variant` inside legacy JS APIs ([#19263](https://github.com/tailwindlabs/tailwindcss/pull/19263))
+- Prevent occasional crash on Windows when loaded into a worker thread ([#19242](https://github.com/tailwindlabs/tailwindcss/pull/19242))
+
+## [4.1.16] - 2025-10-23
+
+### Fixed
+
+- Discard candidates with an empty data type ([#19172](https://github.com/tailwindlabs/tailwindcss/pull/19172))
+- Fix canonicalization of arbitrary variants with attribute selectors ([#19176](https://github.com/tailwindlabs/tailwindcss/pull/19176))
+- Fix invalid colors due to nested `&` ([#19184](https://github.com/tailwindlabs/tailwindcss/pull/19184))
+- Improve canonicalization for `& > :pseudo` and `& :pseudo` arbitrary variants ([#19178](https://github.com/tailwindlabs/tailwindcss/pull/19178))
+
+## [4.1.15] - 2025-10-20
+
+### Fixed
+
+- Fix Safari devtools rendering issue due to `color-mix` fallback ([#19069](https://github.com/tailwindlabs/tailwindcss/pull/19069))
+- Suppress Lightning CSS warnings about `:deep`, `:slotted`, and `:global` ([#19094](https://github.com/tailwindlabs/tailwindcss/pull/19094))
+- Fix resolving theme keys when starting with the name of another theme key in JS configs and plugins ([#19097](https://github.com/tailwindlabs/tailwindcss/pull/19097))
+- Allow named groups in combination with `not-*`, `has-*`, and `in-*` ([#19100](https://github.com/tailwindlabs/tailwindcss/pull/19100))
+- Prevent important utilities from affecting other utilities ([#19110](https://github.com/tailwindlabs/tailwindcss/pull/19110))
+- Don’t index into strings with the `theme(…)` function ([#19111](https://github.com/tailwindlabs/tailwindcss/pull/19111))
+- Fix parsing issue when `\t` is used in at-rules ([#19130](https://github.com/tailwindlabs/tailwindcss/pull/19130))
+- Upgrade: Canonicalize utilities containing `0` values ([#19095](https://github.com/tailwindlabs/tailwindcss/pull/19095))
+- Upgrade: Migrate deprecated `break-words` to `wrap-break-word` ([#19157](https://github.com/tailwindlabs/tailwindcss/pull/19157))
+
+### Changed
+
+- Remove the `postinstall` script from oxide ([#19149])(https://github.com/tailwindlabs/tailwindcss/pull/19149)
+
+## [4.1.14] - 2025-10-01
+
+### Fixed
+
+- Handle `'` syntax in ClojureScript when extracting classes ([#18888](https://github.com/tailwindlabs/tailwindcss/pull/18888))
+- Handle `@variant` inside `@custom-variant` ([#18885](https://github.com/tailwindlabs/tailwindcss/pull/18885))
+- Merge suggestions when using `@utility` ([#18900](https://github.com/tailwindlabs/tailwindcss/pull/18900))
+- Ensure that file system watchers created when using the CLI are always cleaned up ([#18905](https://github.com/tailwindlabs/tailwindcss/pull/18905))
+- Do not generate `grid-column` utilities when configuring `grid-column-start` or `grid-column-end` ([#18907](https://github.com/tailwindlabs/tailwindcss/pull/18907))
+- Do not generate `grid-row` utilities when configuring `grid-row-start` or `grid-row-end` ([#18907](https://github.com/tailwindlabs/tailwindcss/pull/18907))
+- Prevent duplicate CSS when overwriting a static utility with a theme key ([#18056](https://github.com/tailwindlabs/tailwindcss/pull/18056))
+- Show Lightning CSS warnings (if any) when optimizing/minifying ([#18918](https://github.com/tailwindlabs/tailwindcss/pull/18918))
+- Use `default` export condition for `@tailwindcss/vite` ([#18948](https://github.com/tailwindlabs/tailwindcss/pull/18948))
+- Re-throw errors from PostCSS nodes ([#18373](https://github.com/tailwindlabs/tailwindcss/pull/18373))
+- Detect classes in markdown inline directives ([#18967](https://github.com/tailwindlabs/tailwindcss/pull/18967))
+- Ensure files with only `@theme` produce no output when built ([#18979](https://github.com/tailwindlabs/tailwindcss/pull/18979))
+- Support Maud templates when extracting classes ([#18988](https://github.com/tailwindlabs/tailwindcss/pull/18988))
+- Upgrade: Do not migrate `variant = 'outline'` during upgrades ([#18922](https://github.com/tailwindlabs/tailwindcss/pull/18922))
+- Upgrade: Show version mismatch (if any) when running upgrade tool ([#19028](https://github.com/tailwindlabs/tailwindcss/pull/19028))
+- Upgrade: Ensure first class inside `className` is migrated ([#19031](https://github.com/tailwindlabs/tailwindcss/pull/19031))
+- Upgrade: Migrate classes inside `*ClassName` and `*Class` attributes ([#19031](https://github.com/tailwindlabs/tailwindcss/pull/19031))
+
+## [3.4.18] - 2025-10-01
+
+### Fixed
+
+- Improve support for raw `supports-[…]` queries in arbitrary values ([#13605](https://github.com/tailwindlabs/tailwindcss/pull/13605))
+- Fix `require.cache` error when loaded through a TypeScript file in Node 22.18+ ([#18665](https://github.com/tailwindlabs/tailwindcss/pull/18665))
+- Support `import.meta.resolve(…)` in configs for new enough Node.js versions ([#18938](https://github.com/tailwindlabs/tailwindcss/pull/18938))
+- Allow using newer versions of `postcss-load-config` for better ESM and TypeScript PostCSS config support with the CLI ([#18938](https://github.com/tailwindlabs/tailwindcss/pull/18938))
+- Remove irrelevant utility rules when matching important classes ([#19030](https://github.com/tailwindlabs/tailwindcss/pull/19030))
+
+## [4.1.13] - 2025-09-03
+
+### Changed
+
+- Drop warning from browser build ([#18731](https://github.com/tailwindlabs/tailwindcss/issues/18731))
+- Drop exact duplicate declarations when emitting CSS ([#18809](https://github.com/tailwindlabs/tailwindcss/issues/18809))
+
+### Fixed
+
+- Don't transition `visibility` when using `transition` ([#18795](https://github.com/tailwindlabs/tailwindcss/pull/18795))
+- Discard matched variants with unknown named values ([#18799](https://github.com/tailwindlabs/tailwindcss/pull/18799))
+- Discard matched variants with non-string values ([#18799](https://github.com/tailwindlabs/tailwindcss/pull/18799))
+- Show suggestions for known `matchVariant` values ([#18798](https://github.com/tailwindlabs/tailwindcss/pull/18798))
+- Replace deprecated `clip` with `clip-path` in `sr-only` ([#18769](https://github.com/tailwindlabs/tailwindcss/pull/18769))
+- Hide internal fields from completions in `matchUtilities` ([#18820](https://github.com/tailwindlabs/tailwindcss/pull/18820))
+- Ignore `.vercel` folders by default (can be overridden by `@source …` rules) ([#18855](https://github.com/tailwindlabs/tailwindcss/pull/18855))
+- Consider variants starting with `@-` to be invalid (e.g. `@-2xl:flex`) ([#18869](https://github.com/tailwindlabs/tailwindcss/pull/18869))
+- Do not allow custom variants to start or end with a `-` or `_` ([#18867](https://github.com/tailwindlabs/tailwindcss/pull/18867), [#18872](https://github.com/tailwindlabs/tailwindcss/pull/18872))
+- Upgrade: Migrate `aria` theme keys to `@custom-variant` ([#18815](https://github.com/tailwindlabs/tailwindcss/pull/18815))
+- Upgrade: Migrate `data` theme keys to `@custom-variant` ([#18816](https://github.com/tailwindlabs/tailwindcss/pull/18816))
+- Upgrade: Migrate `supports` theme keys to `@custom-variant` ([#18817](https://github.com/tailwindlabs/tailwindcss/pull/18817))
+
+## [4.1.12] - 2025-08-13
+
+### Fixed
+
+- Don't consider the global important state in `@apply` ([#18404](https://github.com/tailwindlabs/tailwindcss/pull/18404))
+- Add missing suggestions for `flex-` utilities ([#18642](https://github.com/tailwindlabs/tailwindcss/pull/18642))
+- Fix trailing `)` from interfering with extraction in Clojure keywords ([#18345](https://github.com/tailwindlabs/tailwindcss/pull/18345))
+- Detect classes inside Elixir charlist, word list, and string sigils ([#18432](https://github.com/tailwindlabs/tailwindcss/pull/18432))
+- Track source locations through `@plugin` and `@config` ([#18345](https://github.com/tailwindlabs/tailwindcss/pull/18345))
+- Allow boolean values of `process.env.DEBUG` in `@tailwindcss/node` ([#18485](https://github.com/tailwindlabs/tailwindcss/pull/18485))
+- Ignore consecutive semicolons in the CSS parser ([#18532](https://github.com/tailwindlabs/tailwindcss/pull/18532))
+- Center the dropdown icon added to an input with a paired datalist by default ([#18511](https://github.com/tailwindlabs/tailwindcss/pull/18511))
+- Extract candidates in Slang templates ([#18565](https://github.com/tailwindlabs/tailwindcss/pull/18565))
+- Improve error messages when encountering invalid functional utility names ([#18568](https://github.com/tailwindlabs/tailwindcss/pull/18568))
+- Discard CSS AST objects with `false` or `undefined` properties ([#18571](https://github.com/tailwindlabs/tailwindcss/pull/18571))
+- Allow users to disable URL rebasing in `@tailwindcss/postcss` via `transformAssetUrls: false` ([#18321](https://github.com/tailwindlabs/tailwindcss/pull/18321))
+- Fix false-positive migrations in `addEventListener` and JavaScript variable names ([#18718](https://github.com/tailwindlabs/tailwindcss/pull/18718))
+- Fix Standalone CLI showing default Bun help when run via symlink on Windows ([#18723](https://github.com/tailwindlabs/tailwindcss/pull/18723))
+- Read from `--border-color-*` theme keys in `divide-*` utilities for backwards compatibility ([#18704](https://github.com/tailwindlabs/tailwindcss/pull/18704/))
+- Don't scan `.hdr` and `.exr` files for classes by default ([#18734](https://github.com/tailwindlabs/tailwindcss/pull/18734))
+
+## [4.1.11] - 2025-06-26
+
+### Fixed
+
+- Add heuristic to skip candidate migrations inside `emit(…)` ([#18330](https://github.com/tailwindlabs/tailwindcss/pull/18330))
+- Extract candidates with variants in Clojure/ClojureScript keywords ([#18338](https://github.com/tailwindlabs/tailwindcss/pull/18338))
+- Document `--watch=always` in the CLI's usage ([#18337](https://github.com/tailwindlabs/tailwindcss/pull/18337))
+- Add support for Vite 7 to `@tailwindcss/vite` ([#18384](https://github.com/tailwindlabs/tailwindcss/pull/18384))
+
+## [4.1.10] - 2025-06-11
+
+### Fixed
+
+- Fix incorrectly generated CSS when using percentages in arbitrary values with calc (e.g. `w-[calc(100%-var(--offset))]`) ([#18289](https://github.com/tailwindlabs/tailwindcss/pull/18289))
+
+## [4.1.9] - 2025-06-11
+
+### Fixed
+
+- Correctly parse custom properties with strings containing semicolons ([#18251](https://github.com/tailwindlabs/tailwindcss/pull/18251))
+- Upgrade: Migrate arbitrary modifiers without percentage signs to bare values (e.g. `/[0.16]` → `/16`) ([#18184](https://github.com/tailwindlabs/tailwindcss/pull/18184))
+- Upgrade: Migrate CSS variable shorthands where fallback value contains function call ([#18184](https://github.com/tailwindlabs/tailwindcss/pull/18184))
+- Upgrade: Migrate negative arbitrary values to negative bare values (e.g. `mb-[-32rem]` → `-mb-128`) ([#18212](https://github.com/tailwindlabs/tailwindcss/pull/18212))
+- Upgrade: Do not migrate `blur` in `wire:model.blur` ([#18216](https://github.com/tailwindlabs/tailwindcss/pull/18216))
+- Don't add spaces around CSS dashed idents when formatting math expressions ([#18220](https://github.com/tailwindlabs/tailwindcss/pull/18220))
+
+## [4.1.8] - 2025-05-27
+
+### Added
+
+- Improve error messages when `@apply` fails ([#18059](https://github.com/tailwindlabs/tailwindcss/pull/18059))
+
+### Fixed
+
+- Upgrade: Do not migrate declarations that look like candidates in `
+ `,
+ 'src/input.css': css`
+ @import 'tailwindcss';
+
+ .foo {
+ flex-shrink: 1;
+ }
+
+ .bar {
+ @apply !underline;
+ }
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('./src/**/*.{css,vue}')).toMatchInlineSnapshot(`
+ "
+ --- ./src/index.vue ---
+
+
+
+
+
+
+ --- ./src/input.css ---
+ @import 'tailwindcss';
+
+ .foo {
+ flex-shrink: 1;
+ }
+
+ .bar {
+ @apply underline!;
+ }
+ "
+ `)
+ },
+)
+
+function withBOM(text: string): string {
+ return '\uFEFF' + text
+}
diff --git a/integrations/upgrade/js-config.test.ts b/integrations/upgrade/js-config.test.ts
new file mode 100644
index 000000000000..450d8254c97b
--- /dev/null
+++ b/integrations/upgrade/js-config.test.ts
@@ -0,0 +1,2088 @@
+import path from 'node:path'
+import { describe } from 'vitest'
+import { css, html, json, test, ts } from '../utils'
+
+test(
+ `upgrade JS config files with flat theme values, darkMode, and content fields`,
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+ import defaultTheme from 'tailwindcss/defaultTheme'
+
+ module.exports = {
+ darkMode: 'selector',
+ content: ['./src/**/*.{html,js}', './node_modules/my-external-lib/**/*.{html}'],
+ theme: {
+ boxShadow: {
+ sm: '0 2px 6px rgb(15 23 42 / 0.08)',
+ },
+ colors: {
+ red: {
+ 400: '#f87171',
+ 500: 'red',
+ },
+ superRed: '#ff0000',
+ steel: 'rgb(70 130 180 / )',
+ smoke: 'rgba(245, 245, 245, var(--smoke-alpha, ))',
+ },
+ ringColor: {
+ DEFAULT: '#c0ffee',
+ },
+ opacity: {
+ superOpaque: '0.95',
+ },
+ fontSize: {
+ xs: ['0.75rem', { lineHeight: '1rem' }],
+ sm: ['0.875rem', { lineHeight: '1.5rem' }],
+ base: ['1rem', { lineHeight: '2rem' }],
+ lg: ['1.125rem', '2.5rem'],
+ xl: ['1.5rem', '3rem', 'invalid'],
+ '2xl': ['2rem'],
+ },
+ width: {
+ px: '1px',
+ auto: 'auto',
+ 1: '0.25rem',
+ 1.5: '0.375rem',
+ 2: '0.5rem',
+ 2.5: '0.625rem',
+ 3: '0.75rem',
+ 3.5: '0.875rem',
+ 4: '1rem',
+ 5: '1.25rem',
+ 6: '1.5rem',
+ 8: '2rem',
+ 10: '2.5rem',
+ 11: '2.75rem',
+ 12: '3rem',
+ 16: '4rem',
+ 24: '6rem',
+ 32: '8rem',
+ 40: '10rem',
+ 48: '12rem',
+ 64: '16rem',
+ 80: '20rem',
+ 96: '24rem',
+ 128: '32rem',
+
+ full: '100%',
+ 0: '0%',
+ '1/2': '50%',
+ '1/3': 'calc(100% / 3)',
+ '2/3': 'calc(100% / 3 * 2)',
+ '1/4': '25%',
+ '3/4': '75%',
+ '1/5': '20%',
+ '2/5': '40%',
+ '3/5': '60%',
+ '4/5': '80%',
+ '1/6': 'calc(100% / 6)',
+ '5/6': 'calc(100% / 6 * 5)',
+ '1/7': 'calc(100% / 7)',
+ '1/10': 'calc(100% / 10)',
+ '3/10': 'calc(100% / 10 * 3)',
+ '7/10': 'calc(100% / 10 * 7)',
+ '9/10': 'calc(100% / 10 * 9)',
+ screen: '100vw',
+
+ 'full-minus-80': 'calc(100% - 20rem)',
+ 'full-minus-96': 'calc(100% - 24rem)',
+
+ '225px': '225px',
+ },
+ extend: {
+ colors: {
+ red: {
+ 500: '#ef4444',
+ 600: '#dc2626',
+ },
+ },
+ fontFamily: {
+ sans: 'Inter, system-ui, sans-serif',
+ display: ['Cabinet Grotesk', ...defaultTheme.fontFamily.sans],
+ },
+ borderRadius: {
+ '4xl': '2rem',
+ },
+ keyframes: {
+ 'spin-clockwise': {
+ '0%': { transform: 'rotate(0deg)' },
+ '100%': { transform: 'rotate(360deg)' },
+ },
+ 'spin-counterclockwise': {
+ '0%': { transform: 'rotate(0deg)' },
+ '100%': { transform: 'rotate(-360deg)' },
+ },
+ },
+ animation: {
+ 'spin-clockwise': 'spin-clockwise 1s linear infinite',
+ 'spin-counterclockwise': 'spin-counterclockwise 1s linear infinite',
+ },
+ letterSpacing: {
+ superWide: '0.25em',
+ },
+ lineHeight: {
+ superLoose: '3',
+ },
+ },
+ },
+ plugins: [],
+ } satisfies Config
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ // prettier-ignore
+ 'src/test.js': ts`
+ export default {
+ 'shouldNotMigrate': !border.test + '',
+ 'filter': 'drop-shadow(0 0 0.5rem #000)',
+ }
+ `,
+ 'src/index.html': html`
+
+
+ `,
+ 'node_modules/my-external-lib/src/template.html': html`
+
+ Hello world!
+
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.{css,js,html}')).toMatchInlineSnapshot(`
+ "
+ --- src/index.html ---
+
+
+
+ --- src/input.css ---
+ @import 'tailwindcss';
+
+ @source '../node_modules/my-external-lib/**/*.{html}';
+
+ @custom-variant dark (&:where(.dark, .dark *));
+
+ @theme {
+ --shadow-*: initial;
+ --shadow-sm: 0 2px 6px rgb(15 23 42 / 0.08);
+
+ --color-*: initial;
+ --color-red-400: #f87171;
+ --color-red-500: #ef4444;
+ --color-red-600: #dc2626;
+
+ --color-superRed: #ff0000;
+ --color-steel: rgb(70 130 180);
+ --color-smoke: rgba(245, 245, 245, var(--smoke-alpha, 1));
+
+ --default-ring-color: #c0ffee;
+
+ --opacity-*: initial;
+ --opacity-superOpaque: 95%;
+
+ --text-*: initial;
+ --text-xs: 0.75rem;
+ --text-xs--line-height: 1rem;
+ --text-sm: 0.875rem;
+ --text-sm--line-height: 1.5rem;
+ --text-base: 1rem;
+ --text-base--line-height: 2rem;
+ --text-lg: 1.125rem;
+ --text-lg--line-height: 2.5rem;
+ --text-xl: 1.5rem;
+ --text-xl--line-height: 3rem;
+ --text-2xl: 2rem;
+
+ --width-*: initial;
+ --width-0: 0%;
+ --width-1: 0.25rem;
+ --width-2: 0.5rem;
+ --width-3: 0.75rem;
+ --width-4: 1rem;
+ --width-5: 1.25rem;
+ --width-6: 1.5rem;
+ --width-8: 2rem;
+ --width-10: 2.5rem;
+ --width-11: 2.75rem;
+ --width-12: 3rem;
+ --width-16: 4rem;
+ --width-24: 6rem;
+ --width-32: 8rem;
+ --width-40: 10rem;
+ --width-48: 12rem;
+ --width-64: 16rem;
+ --width-80: 20rem;
+ --width-96: 24rem;
+ --width-128: 32rem;
+ --width-px: 1px;
+ --width-auto: auto;
+ --width-1_5: 0.375rem;
+ --width-2_5: 0.625rem;
+ --width-3_5: 0.875rem;
+ --width-full: 100%;
+ --width-1\\/2: 50%;
+ --width-1\\/3: calc(100% / 3);
+ --width-2\\/3: calc(100% / 3 * 2);
+ --width-1\\/4: 25%;
+ --width-3\\/4: 75%;
+ --width-1\\/5: 20%;
+ --width-2\\/5: 40%;
+ --width-3\\/5: 60%;
+ --width-4\\/5: 80%;
+ --width-1\\/6: calc(100% / 6);
+ --width-5\\/6: calc(100% / 6 * 5);
+ --width-1\\/7: calc(100% / 7);
+ --width-1\\/10: calc(100% / 10);
+ --width-3\\/10: calc(100% / 10 * 3);
+ --width-7\\/10: calc(100% / 10 * 7);
+ --width-9\\/10: calc(100% / 10 * 9);
+ --width-screen: 100vw;
+ --width-full-minus-80: calc(100% - 20rem);
+ --width-full-minus-96: calc(100% - 24rem);
+ --width-225px: 225px;
+
+ --font-sans: Inter, system-ui, sans-serif;
+ --font-display:
+ Cabinet Grotesk, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
+ 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
+
+ --radius-4xl: 2rem;
+
+ --animate-spin-clockwise: spin-clockwise 1s linear infinite;
+ --animate-spin-counterclockwise: spin-counterclockwise 1s linear infinite;
+
+ --tracking-superWide: 0.25em;
+
+ --leading-superLoose: 3;
+
+ @keyframes spin-clockwise {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+ }
+ @keyframes spin-counterclockwise {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(-360deg);
+ }
+ }
+ }
+
+ /*
+ 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);
+ }
+ }
+
+ --- src/test.js ---
+ export default {
+ 'shouldNotMigrate': !border.test + '',
+ 'filter': 'drop-shadow(0 0 0.5rem #000)',
+ }
+ "
+ `)
+
+ expect((await fs.dumpFiles('tailwind.config.ts')).trim()).toBe('')
+ },
+)
+
+test(
+ 'upgrades JS config files with plugins',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/typography": "^0.5.15",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+ import typography from '@tailwindcss/typography'
+ import customPlugin from './custom-plugin'
+
+ export default {
+ darkMode: [
+ 'variant',
+ [
+ '@media not print { .dark & }',
+ '@media not eink { .dark & }',
+ '&:where(.dark, .dark *)',
+ ],
+ ],
+ plugins: [
+ typography,
+ customPlugin({
+ 'is-null': null,
+ 'is-true': true,
+ 'is-false': false,
+ 'is-int': 1234567,
+ 'is-float': 1.35,
+ 'is-sci': 1.35e-5,
+ 'is-str-null': 'null',
+ 'is-str-true': 'true',
+ 'is-str-false': 'false',
+ 'is-str-int': '1234567',
+ 'is-str-float': '1.35',
+ 'is-str-sci': '1.35e-5',
+ 'is-arr': ['foo', 'bar'],
+ 'is-arr-mixed': [null, true, false, 1234567, 1.35, 'foo', 'bar', 'true'],
+ }),
+ ],
+ } satisfies Config
+ `,
+ 'custom-plugin.js': ts`
+ import plugin from 'tailwindcss/plugin'
+ export default plugin.withOptions((_options) => ({ addVariant }) => {
+ addVariant('inverted', '@media (inverted-colors: inverted)')
+ addVariant('hocus', ['&:focus', '&:hover'])
+ })
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
+ "
+ --- src/input.css ---
+ @import 'tailwindcss';
+
+ @plugin '@tailwindcss/typography';
+ @plugin '../custom-plugin' {
+ is-null: null;
+ is-true: true;
+ is-false: false;
+ is-int: 1234567;
+ is-float: 1.35;
+ is-sci: 0.0000135;
+ is-str-null: 'null';
+ is-str-true: 'true';
+ is-str-false: 'false';
+ is-str-int: '1234567';
+ is-str-float: '1.35';
+ is-str-sci: '1.35e-5';
+ is-arr: 'foo', 'bar';
+ is-arr-mixed: null, true, false, 1234567, 1.35, 'foo', 'bar', 'true';
+ }
+
+ @custom-variant dark {
+ @media not print {
+ .dark & {
+ @slot;
+ }
+ }
+ @media not eink {
+ .dark & {
+ @slot;
+ }
+ }
+ &:where(.dark, .dark *) {
+ @slot;
+ }
+ }
+
+ /*
+ 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);
+ }
+ }
+ "
+ `)
+
+ expect(await fs.dumpFiles('tailwind.config.ts')).toMatchInlineSnapshot(`
+ "
+
+ "
+ `)
+ },
+)
+
+test(
+ 'upgrades JS config files with functions in the theme config',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+
+ export default {
+ theme: {
+ extend: {
+ colors: ({ colors }) => ({
+ gray: colors.neutral,
+ }),
+ },
+ },
+ } satisfies Config
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.{css,ts}')).toMatchInlineSnapshot(`
+ "
+ --- src/input.css ---
+ @import 'tailwindcss';
+
+ @theme {
+ --color-gray-50: oklch(98.5% 0 0);
+ --color-gray-100: oklch(97% 0 0);
+ --color-gray-200: oklch(92.2% 0 0);
+ --color-gray-300: oklch(87% 0 0);
+ --color-gray-400: oklch(70.8% 0 0);
+ --color-gray-500: oklch(55.6% 0 0);
+ --color-gray-600: oklch(43.9% 0 0);
+ --color-gray-700: oklch(37.1% 0 0);
+ --color-gray-800: oklch(26.9% 0 0);
+ --color-gray-900: oklch(20.5% 0 0);
+ --color-gray-950: oklch(14.5% 0 0);
+ }
+
+ /*
+ 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);
+ }
+ }
+ "
+ `)
+
+ expect(await fs.dumpFiles('tailwind.config.ts')).toMatchInlineSnapshot(`
+ "
+
+ "
+ `)
+ },
+)
+
+test(
+ 'does not upgrade JS config files with theme keys contributed to by plugins in the theme config',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+
+ export default {
+ theme: {
+ typography: {
+ DEFAULT: {
+ css: {
+ '--tw-prose-body': 'red',
+ color: 'var(--tw-prose-body)',
+ },
+ },
+ },
+ },
+ } satisfies Config
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ @config '../tailwind.config.ts';
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
+ "
+ --- src/input.css ---
+ @import 'tailwindcss';
+
+ @config '../tailwind.config.ts';
+
+ /*
+ 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);
+ }
+ }
+ "
+ `)
+
+ expect(await fs.dumpFiles('tailwind.config.ts')).toMatchInlineSnapshot(`
+ "
+ --- tailwind.config.ts ---
+ import { type Config } from 'tailwindcss'
+
+ export default {
+ theme: {
+ typography: {
+ DEFAULT: {
+ css: {
+ '--tw-prose-body': 'red',
+ color: 'var(--tw-prose-body)',
+ },
+ },
+ },
+ },
+ } satisfies Config
+ "
+ `)
+ },
+)
+
+test(
+ `does not upgrade JS config files with inline plugins`,
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+
+ export default {
+ plugins: [function complexConfig() {}],
+ } satisfies Config
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
+ "
+ --- src/input.css ---
+ @import 'tailwindcss';
+
+ @config '../tailwind.config.ts';
+
+ /*
+ 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);
+ }
+ }
+ "
+ `)
+
+ expect(await fs.dumpFiles('tailwind.config.ts')).toMatchInlineSnapshot(`
+ "
+ --- tailwind.config.ts ---
+ import { type Config } from 'tailwindcss'
+
+ export default {
+ plugins: [function complexConfig() {}],
+ } satisfies Config
+ "
+ `)
+ },
+)
+
+test(
+ `does not upgrade JS config files with non-simple screens object`,
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+
+ export default {
+ theme: {
+ screens: {
+ xl: { min: '1024px', max: '1279px' },
+ tall: { raw: '(min-height: 800px)' },
+ },
+ },
+ } satisfies Config
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
+ "
+ --- src/input.css ---
+ @import 'tailwindcss';
+
+ @config '../tailwind.config.ts';
+
+ /*
+ 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);
+ }
+ }
+ "
+ `)
+
+ expect(await fs.dumpFiles('tailwind.config.ts')).toMatchInlineSnapshot(`
+ "
+ --- tailwind.config.ts ---
+ import { type Config } from 'tailwindcss'
+
+ export default {
+ theme: {
+ screens: {
+ xl: { min: '1024px', max: '1279px' },
+ tall: { raw: '(min-height: 800px)' },
+ },
+ },
+ } satisfies Config
+ "
+ `)
+ },
+)
+
+test(
+ 'multi-root project',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+
+ // Project A
+ 'project-a/tailwind.config.ts': ts`
+ export default {
+ content: {
+ relative: true,
+ files: ['./src/**/*.html'],
+ },
+ theme: {
+ extend: {
+ colors: {
+ primary: 'red',
+ },
+ },
+ },
+ }
+ `,
+ 'project-a/src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ @config "../tailwind.config.ts";
+ `,
+ 'project-a/src/index.html': html``,
+
+ // Project B
+ 'project-b/tailwind.config.ts': ts`
+ export default {
+ content: {
+ relative: true,
+ files: ['./src/**/*.html'],
+ },
+ theme: {
+ extend: {
+ colors: {
+ primary: 'blue',
+ },
+ },
+ },
+ }
+ `,
+ 'project-b/src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ @config "../tailwind.config.ts";
+ `,
+ 'project-b/src/index.html': html``,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('project-{a,b}/**/*.{css,ts}')).toMatchInlineSnapshot(`
+ "
+ --- project-a/src/input.css ---
+ @import 'tailwindcss';
+
+ @theme {
+ --color-primary: red;
+ }
+
+ /*
+ 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);
+ }
+ }
+
+ --- project-b/src/input.css ---
+ @import 'tailwindcss';
+
+ @theme {
+ --color-primary: blue;
+ }
+
+ /*
+ 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);
+ }
+ }
+ "
+ `)
+ },
+)
+
+test(
+ 'migrate sources when pointing to folders outside the project root',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+
+ 'frontend/tailwind.config.ts': ts`
+ export default {
+ content: {
+ relative: true,
+ files: ['./src/**/*.html', '../backend/mails/**/*.blade.php'],
+ },
+ theme: {
+ extend: {
+ colors: {
+ primary: 'red',
+ },
+ },
+ },
+ }
+ `,
+ 'frontend/src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ @config "../tailwind.config.ts";
+ `,
+ 'frontend/src/index.html': html``,
+
+ 'backend/mails/welcome.blade.php': html``,
+ },
+ },
+ async ({ root, exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade', {
+ cwd: path.join(root, 'frontend'),
+ })
+
+ expect(await fs.dumpFiles('frontend/**/*.css')).toMatchInlineSnapshot(`
+ "
+ --- frontend/src/input.css ---
+ @import 'tailwindcss';
+
+ @source '../../backend/mails/**/*.blade.php';
+
+ @theme {
+ --color-primary: red;
+ }
+
+ /*
+ 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);
+ }
+ }
+ "
+ `)
+ },
+)
+
+test(
+ 'migrate aria theme keys to custom variants',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ export default {
+ content: {
+ relative: true,
+ files: ['./src/**/*.html'],
+ },
+ theme: {
+ extend: {
+ aria: {
+ // Built-in (not really, but visible because of intellisense)
+ busy: 'busy="true"',
+
+ // Automatically handled by bare values
+ foo: 'foo="true"',
+
+ // Quotes are optional in CSS for these kinds of attribute
+ // selectors
+ bar: 'bar=true',
+
+ // Not automatically handled by bare values because names differ
+ baz: 'qux="true"',
+
+ // Completely custom
+ asc: 'sort="ascending"',
+ desc: 'sort="descending"',
+ },
+ },
+ },
+ }
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(`
+ "
+ --- src/input.css ---
+ @import 'tailwindcss';
+
+ @custom-variant aria-baz (&[aria-qux="true"]);
+ @custom-variant aria-asc (&[aria-sort="ascending"]);
+ @custom-variant aria-desc (&[aria-sort="descending"]);
+
+ /*
+ 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);
+ }
+ }
+ "
+ `)
+ },
+)
+
+test(
+ 'migrate data theme keys to custom variants',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ export default {
+ content: {
+ relative: true,
+ files: ['./src/**/*.html'],
+ },
+ theme: {
+ extend: {
+ data: {
+ // Automatically handled by bare values
+ foo: 'foo',
+
+ // Not automatically handled by bare values because names differ
+ bar: 'baz',
+
+ // Custom
+ checked: 'ui~="checked"',
+ },
+ },
+ },
+ }
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(`
+ "
+ --- src/input.css ---
+ @import 'tailwindcss';
+
+ @custom-variant data-bar (&[data-baz]);
+ @custom-variant data-checked (&[data-ui~="checked"]);
+
+ /*
+ 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);
+ }
+ }
+ "
+ `)
+ },
+)
+
+test(
+ 'migrate supports theme keys to custom variants',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ export default {
+ content: {
+ relative: true,
+ files: ['./src/**/*.html'],
+ },
+ theme: {
+ extend: {
+ supports: {
+ // Automatically handled by bare values (using CSS variable as the value)
+ foo: 'foo: var(--foo)', // parentheses are optional
+ bar: '(bar: var(--bar))',
+
+ // Not automatically handled by bare values because names differ
+ foo: 'bar: var(--foo)', // parentheses are optional
+ bar: '(qux: var(--bar))',
+
+ // Custom
+ grid: 'display: grid',
+ },
+ },
+ },
+ }
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(`
+ "
+ --- src/input.css ---
+ @import 'tailwindcss';
+
+ @custom-variant supports-foo {
+ @supports (bar: var(--foo)) {
+ @slot;
+ }
+ }
+ @custom-variant supports-bar {
+ @supports ((qux: var(--bar))) {
+ @slot;
+ }
+ }
+ @custom-variant supports-grid {
+ @supports (display: grid) {
+ @slot;
+ }
+ }
+
+ /*
+ 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);
+ }
+ }
+ "
+ `)
+ },
+)
+
+describe('border compatibility', () => {
+ test(
+ 'migrate border compatibility',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+
+ // Empty / default config
+ export default {
+ theme: {
+ extend: {},
+ },
+ } satisfies Config
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
+ "
+ --- 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);
+ }
+ }
+ "
+ `)
+ },
+ )
+
+ test(
+ 'migrate border compatibility if a custom border color is used',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+
+ export default {
+ theme: {
+ extend: {
+ borderColor: ({ colors }) => ({
+ DEFAULT: colors.blue[500],
+ }),
+ },
+ },
+ } satisfies Config
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
+ "
+ --- 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: oklch(62.3% 0.214 259.815);
+ }
+ }
+ "
+ `)
+ },
+ )
+
+ test(
+ 'migrate border compatibility if a custom border color is used, that matches the default v4 border color',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+
+ export default {
+ theme: {
+ extend: {
+ borderColor: ({ colors }) => ({
+ DEFAULT: 'currentcolor',
+ }),
+ },
+ },
+ } satisfies Config
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
+ "
+ --- src/input.css ---
+ @import 'tailwindcss';
+ "
+ `)
+ },
+ )
+
+ test(
+ 'migrate border compatibility in the file that uses the `@import "tailwindcss"` import',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+
+ export default {
+ theme: {},
+ } satisfies Config
+ `,
+ 'src/input.css': css`@import './tailwind.css';`,
+ 'src/tailwind.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
+ "
+ --- src/input.css ---
+ @import './tailwind.css';
+
+ --- src/tailwind.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);
+ }
+ }
+ "
+ `)
+ },
+ )
+
+ test(
+ 'migrate border compatibility in the file that uses the `@import "tailwindcss/preflight"` import',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+
+ export default {
+ theme: {},
+ } satisfies Config
+ `,
+ 'src/input.css': css`
+ @import './base.css';
+ @import './my-base.css';
+ @import './utilities.css';
+ `,
+ 'src/base.css': css`@tailwind base;`,
+ 'src/utilities.css': css`
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ 'src/my-base.css': css`
+ @layer base {
+ html {
+ color: black;
+ }
+ }
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
+ "
+ --- src/base.css ---
+ @import 'tailwindcss/theme' layer(theme);
+ @import 'tailwindcss/preflight' layer(base);
+
+ /*
+ 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);
+ }
+ }
+
+ --- src/input.css ---
+ @import './base.css';
+ @import './my-base.css';
+ @import './utilities.css';
+
+ --- src/my-base.css ---
+ @layer base {
+ html {
+ color: black;
+ }
+ }
+
+ --- src/utilities.css ---
+ @import 'tailwindcss/utilities' layer(utilities);
+ "
+ `)
+ },
+ )
+
+ test(
+ 'migrates extended spacing keys',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+
+ export default {
+ content: ['./src/**/*.html'],
+ theme: {
+ extend: {
+ spacing: {
+ 2: '0.5rem',
+ 4.5: '1.125rem',
+ 5.5: '1.375em', // Units are different from --spacing scale
+ 13: '3.25rem',
+ 100: '100px',
+ miami: '1337px',
+ },
+ },
+ },
+ } satisfies Config
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+
+ .container {
+ width: theme(spacing.2);
+ width: theme(spacing[4.5]);
+ width: theme(spacing[5.5]);
+ width: theme(spacing[13]);
+ width: theme(spacing[100]);
+ width: theme(spacing.miami);
+ }
+ `,
+ 'src/index.html': html`
+
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(`
+ "
+ --- src/index.html ---
+
+
+ --- src/input.css ---
+ @import 'tailwindcss';
+
+ @theme {
+ --spacing-100: 100px;
+ --spacing-5_5: 1.375em;
+ --spacing-miami: 1337px;
+ }
+
+ /*
+ 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);
+ }
+ }
+
+ .container {
+ width: --spacing(2);
+ width: --spacing(4.5);
+ width: var(--spacing-5_5);
+ width: --spacing(13);
+ width: var(--spacing-100);
+ width: var(--spacing-miami);
+ }
+ "
+ `)
+ },
+ )
+
+ test(
+ 'retains overwriting spacing scale',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+
+ export default {
+ content: ['./src/**/*.html'],
+ theme: {
+ spacing: {
+ 2: '0.5rem',
+ 4.5: '1.125rem',
+ 5.5: '1.375em',
+ 13: '3.25rem',
+ 100: '100px',
+ miami: '1337px',
+ },
+ },
+ } satisfies Config
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+
+ .container {
+ width: theme(spacing.2);
+ width: theme(spacing[4.5]);
+ width: theme(spacing[5.5]);
+ width: theme(spacing[13]);
+ width: theme(spacing[100]);
+ width: theme(spacing.miami);
+ }
+ `,
+ 'src/index.html': html`
+
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(`
+ "
+ --- src/index.html ---
+
+
+ --- src/input.css ---
+ @import 'tailwindcss';
+
+ @theme {
+ --spacing-*: initial;
+ --spacing-2: 0.5rem;
+ --spacing-13: 3.25rem;
+ --spacing-100: 100px;
+ --spacing-4_5: 1.125rem;
+ --spacing-5_5: 1.375em;
+ --spacing-miami: 1337px;
+ }
+
+ /*
+ 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);
+ }
+ }
+
+ .container {
+ width: var(--spacing-2);
+ width: var(--spacing-4_5);
+ width: var(--spacing-5_5);
+ width: var(--spacing-13);
+ width: var(--spacing-100);
+ width: var(--spacing-miami);
+ }
+ "
+ `)
+ },
+ )
+
+ test(
+ 'migrates `container` component configurations',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+
+ export default {
+ content: ['./src/**/*.html'],
+ theme: {
+ container: {
+ center: true,
+ padding: {
+ DEFAULT: '2rem',
+ '2xl': '4rem',
+ },
+ screens: {
+ md: '48rem', // Matches a default --breakpoint
+ xl: '1280px',
+ '2xl': '1536px',
+ },
+ },
+ },
+ } satisfies Config
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ 'src/index.html': html`
+
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(`
+ "
+ --- src/index.html ---
+
+
+ --- src/input.css ---
+ @import 'tailwindcss';
+
+ @utility container {
+ margin-inline: auto;
+ padding-inline: 2rem;
+ @media (width >= --theme(--breakpoint-sm)) {
+ max-width: none;
+ }
+ @media (width >= 48rem) {
+ max-width: 48rem;
+ }
+ @media (width >= 1280px) {
+ max-width: 1280px;
+ }
+ @media (width >= 1536px) {
+ max-width: 1536px;
+ padding-inline: 4rem;
+ }
+ }
+
+ /*
+ 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);
+ }
+ }
+ "
+ `)
+ },
+ )
+})
+
+test(
+ `future and experimental keys are supported`,
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+ import defaultTheme from 'tailwindcss/defaultTheme'
+
+ module.exports = {
+ darkMode: 'selector',
+ content: ['./src/**/*.{html,js}'],
+ future: {
+ hoverOnlyWhenSupported: true,
+ respectDefaultRingColorOpacity: true,
+ disableColorOpacityUtilitiesByDefault: true,
+ relativeContentPathsByDefault: true,
+ },
+ experimental: {
+ generalizedModifiers: true,
+ },
+ theme: {
+ colors: {
+ red: {
+ 400: '#f87171',
+ 500: 'red',
+ },
+ },
+ },
+ plugins: [],
+ } satisfies Config
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
+ "
+ --- src/input.css ---
+ @import 'tailwindcss';
+
+ @custom-variant dark (&:where(.dark, .dark *));
+
+ @theme {
+ --color-*: initial;
+ --color-red-400: #f87171;
+ --color-red-500: red;
+ }
+
+ /*
+ 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);
+ }
+ }
+ "
+ `)
+
+ expect((await fs.dumpFiles('tailwind.config.ts')).trim()).toBe('')
+ },
+)
+
+test(
+ `unknown future keys dont migrate the config`,
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+ import defaultTheme from 'tailwindcss/defaultTheme'
+
+ module.exports = {
+ darkMode: 'selector',
+ content: ['./src/**/*.{html,js}'],
+ future: {
+ something: true,
+ },
+ } satisfies Config
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
+ "
+ --- src/input.css ---
+ @import 'tailwindcss';
+
+ @config '../tailwind.config.ts';
+
+ /*
+ 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);
+ }
+ }
+ "
+ `)
+
+ expect((await fs.dumpFiles('tailwind.config.ts')).trim()).toMatchInlineSnapshot(`
+ "--- tailwind.config.ts ---
+ import { type Config } from 'tailwindcss'
+ import defaultTheme from 'tailwindcss/defaultTheme'
+
+ module.exports = {
+ darkMode: 'selector',
+ content: ['./src/**/*.{html,js}'],
+ future: {
+ something: true,
+ },
+ } satisfies Config"
+ `)
+ },
+)
+
+test(
+ `unknown experimental keys dont migrate the config`,
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+ import defaultTheme from 'tailwindcss/defaultTheme'
+
+ module.exports = {
+ darkMode: 'selector',
+ content: ['./src/**/*.{html,js}'],
+ experimental: {
+ something: true,
+ },
+ } satisfies Config
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ },
+ },
+ async ({ exec, fs, expect }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
+ "
+ --- src/input.css ---
+ @import 'tailwindcss';
+
+ @config '../tailwind.config.ts';
+
+ /*
+ 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);
+ }
+ }
+ "
+ `)
+
+ expect((await fs.dumpFiles('tailwind.config.ts')).trim()).toMatchInlineSnapshot(`
+ "--- tailwind.config.ts ---
+ import { type Config } from 'tailwindcss'
+ import defaultTheme from 'tailwindcss/defaultTheme'
+
+ module.exports = {
+ darkMode: 'selector',
+ content: ['./src/**/*.{html,js}'],
+ experimental: {
+ something: true,
+ },
+ } satisfies Config"
+ `)
+ },
+)
diff --git a/integrations/upgrade/upgrade-errors.test.ts b/integrations/upgrade/upgrade-errors.test.ts
new file mode 100644
index 000000000000..6733801e087f
--- /dev/null
+++ b/integrations/upgrade/upgrade-errors.test.ts
@@ -0,0 +1,251 @@
+import { stripVTControlCharacters } from 'node:util'
+import { css, html, js, json, test } from '../utils'
+
+test(
+ 'upgrades half-upgraded v3 project to v4 (pnpm)',
+ {
+ 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': html`
+
Hello World
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ },
+ },
+ async ({ exec, expect }) => {
+ // Ensure we are in a git repo
+ await exec('git init')
+ await exec('git add --all')
+ await exec('git commit -m "before migration"')
+
+ // Fully upgrade to v4
+ await exec('npx @tailwindcss/upgrade')
+
+ // Undo all changes to the current repo. This will bring the repo back to a
+ // v3 state, but the `node_modules` will now have v4 installed.
+ await exec('git reset --hard HEAD')
+
+ // Re-running the upgrade should result in an error
+ return expect(() => {
+ return exec('npx @tailwindcss/upgrade', {}, { ignoreStdErr: true }).catch((e) => {
+ // Replacing the current version with a hardcoded `v4` to make it stable
+ // when we release new minor/patch versions.
+ return Promise.reject(
+ stripVTControlCharacters(e.message.replace(/\d+\.\d+\.\d+/g, '4.0.0')),
+ )
+ })
+ }).rejects.toThrowErrorMatchingInlineSnapshot(`
+ "Command failed: npx @tailwindcss/upgrade
+ ≈ tailwindcss v4.0.0
+
+ │ ↳ Upgrading from Tailwind CSS \`v4.0.0\`
+
+ │ ↳ Version mismatch
+ │
+ │ \`\`\`diff
+ │ - "tailwindcss": "^3" (expected version in \`package.json\`)
+ │ + "tailwindcss": "4.0.0" (installed version in \`node_modules\`)
+ │ \`\`\`
+ │
+ │ Make sure to run \`pnpm install\` and try again.
+
+ "
+ `)
+ },
+)
+
+test(
+ 'upgrades half-upgraded v3 project to v4 (bun)',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "^3",
+ "@tailwindcss/upgrade": "workspace:^"
+ },
+ "devDependencies": {
+ "@tailwindcss/cli": "workspace:^",
+ "bun": "^1.0.0"
+ }
+ }
+ `,
+ 'tailwind.config.js': js`
+ /** @type {import('tailwindcss').Config} */
+ module.exports = {
+ content: ['./src/**/*.{html,js}'],
+ }
+ `,
+ 'src/index.html': html`
+
Hello World
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ },
+ },
+ async ({ exec, expect }) => {
+ // Use `bun` to install dependencies
+ await exec('rm ./pnpm-lock.yaml')
+ try {
+ await exec('npx bun install', {}, { ignoreStdErr: true })
+ } catch (e) {
+ // When preparing for a release, the version in `package.json` will point
+ // to a non-existent version because it's not published yet.
+ // TODO: Find a better approach to handle this and actually test it even
+ // on release branches. Note: the pnpm version _does_ work because of
+ // overrides in the package.json file.
+ if (`${e}`.includes('No version matching')) return
+ throw e
+ }
+
+ // Ensure we are in a git repo
+ await exec('git init')
+ await exec('git add --all')
+ await exec('git commit -m "before migration"')
+
+ // Fully upgrade to v4
+ await exec('npx @tailwindcss/upgrade')
+
+ // Undo all changes to the current repo. This will bring the repo back to a
+ // v3 state, but the `node_modules` will now have v4 installed.
+ await exec('git reset --hard HEAD')
+
+ // Re-running the upgrade should result in an error
+ return expect(() => {
+ return exec('npx @tailwindcss/upgrade', {}, { ignoreStdErr: true }).catch((e) => {
+ // Replacing the current version with a hardcoded `v4` to make it stable
+ // when we release new minor/patch versions.
+ return Promise.reject(
+ stripVTControlCharacters(e.message.replace(/\d+\.\d+\.\d+/g, '4.0.0')),
+ )
+ })
+ }).rejects.toThrowErrorMatchingInlineSnapshot(`
+ "Command failed: npx @tailwindcss/upgrade
+ ≈ tailwindcss v4.0.0
+
+ │ ↳ Upgrading from Tailwind CSS \`v4.0.0\`
+
+ │ ↳ Version mismatch
+ │
+ │ \`\`\`diff
+ │ - "tailwindcss": "^3" (expected version in \`package.json\`)
+ │ + "tailwindcss": "4.0.0" (installed version in \`node_modules\`)
+ │ \`\`\`
+ │
+ │ Make sure to run \`bun install\` and try again.
+
+ "
+ `)
+ },
+)
+
+test(
+ 'upgrades half-upgraded v3 project to v4 (npm)',
+ {
+ 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': html`
+
Hello World
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ },
+ },
+ async ({ exec, expect }) => {
+ // Use `npm` to install dependencies
+ await exec('rm ./pnpm-lock.yaml')
+ await exec('rm -rf ./node_modules')
+ try {
+ await exec('npm install', {}, { ignoreStdErr: true })
+ } catch (e) {
+ // When preparing for a release, the version in `package.json` will point
+ // to a non-existent version because it's not published yet.
+ // TODO: Find a better approach to handle this and actually test it even
+ // on release branches. Note: the pnpm version _does_ work because of
+ // overrides in the package.json file.
+ if (`${e}`.includes('npm error code ETARGET')) return
+ throw e
+ }
+
+ // Ensure we are in a git repo
+ await exec('git init')
+ await exec('git add --all')
+ await exec('git commit -m "before migration"')
+
+ // Fully upgrade to v4
+ await exec('npx @tailwindcss/upgrade')
+
+ // Undo all changes to the current repo. This will bring the repo back to a
+ // v3 state, but the `node_modules` will now have v4 installed.
+ await exec('git reset --hard HEAD')
+
+ // Re-running the upgrade should result in an error
+ return expect(() => {
+ return exec('npx @tailwindcss/upgrade', {}, { ignoreStdErr: true }).catch((e) => {
+ // Replacing the current version with a hardcoded `v4` to make it stable
+ // when we release new minor/patch versions.
+ return Promise.reject(
+ stripVTControlCharacters(e.message.replace(/\d+\.\d+\.\d+/g, '4.0.0')),
+ )
+ })
+ }).rejects.toThrowErrorMatchingInlineSnapshot(`
+ "Command failed: npx @tailwindcss/upgrade
+ ≈ tailwindcss v4.0.0
+
+ │ ↳ Upgrading from Tailwind CSS \`v4.0.0\`
+
+ │ ↳ Version mismatch
+ │
+ │ \`\`\`diff
+ │ - "tailwindcss": "^3" (expected version in \`package.json\`)
+ │ + "tailwindcss": "4.0.0" (installed version in \`node_modules\`)
+ │ \`\`\`
+ │
+ │ Make sure to run \`npm install\` and try again.
+
+ "
+ `)
+ },
+)
diff --git a/integrations/utils.ts b/integrations/utils.ts
new file mode 100644
index 000000000000..f9638b8572d7
--- /dev/null
+++ b/integrations/utils.ts
@@ -0,0 +1,695 @@
+import dedent from 'dedent'
+import fastGlob from 'fast-glob'
+import { exec, spawn } from 'node:child_process'
+import fs from 'node:fs/promises'
+import { platform, tmpdir } from 'node:os'
+import path from 'node:path'
+import { stripVTControlCharacters } from 'node:util'
+import { RawSourceMap, SourceMapConsumer } from 'source-map-js'
+import { test as defaultTest, type ExpectStatic } from 'vitest'
+import { createLineTable } from '../packages/tailwindcss/src/source-maps/line-table'
+import { escape } from '../packages/tailwindcss/src/utils/escape'
+
+const REPO_ROOT = path.join(__dirname, '..')
+const PUBLIC_PACKAGES = (await fs.readdir(path.join(REPO_ROOT, 'dist'))).map((name) =>
+ name.replace('tailwindcss-', '@tailwindcss/').replace('.tgz', ''),
+)
+
+interface SpawnedProcess {
+ dispose: () => void
+ flush: () => void
+ onStdout: (predicate: (message: string) => boolean) => Promise
+ onStderr: (predicate: (message: string) => boolean) => Promise
+}
+
+interface ChildProcessOptions {
+ cwd?: string
+ env?: Record
+}
+
+interface ExecOptions {
+ ignoreStdErr?: boolean
+ stdin?: string
+}
+
+interface TestConfig {
+ fs: {
+ [filePath: string]: string | Uint8Array
+ }
+
+ timeout?: number
+ installDependencies?: boolean
+}
+interface TestContext {
+ root: string
+ expect: ExpectStatic
+ exec(command: string, options?: ChildProcessOptions, execOptions?: ExecOptions): Promise
+ spawn(command: string, options?: ChildProcessOptions): Promise
+ parseSourceMap(opts: string | SourceMapOptions): SourceMap
+ fs: {
+ write(filePath: string, content: string, encoding?: BufferEncoding): Promise
+ create(filePaths: string[]): Promise
+ read(filePath: string): Promise
+ glob(pattern: string): Promise<[string, string][]>
+ dumpFiles(pattern: string): Promise
+ expectFileToContain(
+ filePath: string,
+ contents: string | RegExp | (string | RegExp)[],
+ ): Promise
+ expectFileNotToContain(filePath: string, contents: string | string[]): Promise
+ }
+}
+type TestCallback = (context: TestContext) => Promise | void
+interface TestFlags {
+ only?: boolean
+ skip?: boolean
+ debug?: boolean
+}
+
+type SpawnActor = { predicate: (message: string) => boolean; resolve: () => void }
+
+export const IS_WINDOWS = platform() === 'win32'
+
+const TEST_TIMEOUT = IS_WINDOWS ? 120000 : 60000
+const ASSERTION_TIMEOUT = IS_WINDOWS ? 10000 : 5000
+
+// On Windows CI, tmpdir returns a path containing a weird RUNNER~1 folder that
+// apparently causes the vite builds to not work.
+const TMP_ROOT =
+ process.env.CI && IS_WINDOWS ? path.dirname(process.env.GITHUB_WORKSPACE!) : tmpdir()
+
+export function test(
+ name: string,
+ config: TestConfig,
+ testCallback: TestCallback,
+ { only = false, skip = false, debug = false }: TestFlags = {},
+) {
+ return defaultTest(
+ name,
+ {
+ timeout: config.timeout ?? TEST_TIMEOUT,
+ retry: process.env.CI ? 2 : 0,
+ only: only || (!process.env.CI && debug),
+ skip,
+ concurrent: true,
+ },
+ async (options) => {
+ let rootDir = debug ? path.join(REPO_ROOT, '.debug') : TMP_ROOT
+ await fs.mkdir(rootDir, { recursive: true })
+
+ let root = await fs.mkdtemp(path.join(rootDir, 'tailwind-integrations'))
+
+ if (debug) {
+ console.log('Running test in debug mode. File system will be written to:')
+ console.log(root)
+ console.log()
+ }
+
+ let context = {
+ root,
+ expect: options.expect,
+ parseSourceMap,
+ async exec(
+ command: string,
+ childProcessOptions: ChildProcessOptions = {},
+ execOptions: ExecOptions = {},
+ ) {
+ let cwd = childProcessOptions.cwd ?? root
+ if (debug && cwd !== root) {
+ let relative = path.relative(root, cwd)
+ if (relative[0] !== '.') relative = `./${relative}`
+ console.log(`> cd ${relative}`)
+ }
+ if (debug) console.log(`> ${command}`)
+ return new Promise((resolve, reject) => {
+ let child = exec(
+ command,
+ {
+ cwd,
+ ...childProcessOptions,
+ env: {
+ ...process.env,
+ ...childProcessOptions.env,
+ },
+ },
+ (error, stdout, stderr) => {
+ if (error) {
+ if (execOptions.ignoreStdErr !== true) console.error(stderr)
+ if (only || debug) {
+ console.error(stdout)
+ }
+ reject(error)
+ } else {
+ if (only || debug) {
+ console.log(stdout.toString() + '\n\n' + stderr.toString())
+ }
+ resolve(stdout.toString() + '\n\n' + stderr.toString())
+ }
+ },
+ )
+ if (execOptions.stdin) {
+ child.stdin?.write(execOptions.stdin)
+ child.stdin?.end()
+ }
+ })
+ },
+ async spawn(command: string, childProcessOptions: ChildProcessOptions = {}) {
+ let resolveDisposal: (() => void) | undefined
+ let rejectDisposal: ((error: Error) => void) | undefined
+ let disposePromise = new Promise((resolve, reject) => {
+ resolveDisposal = resolve
+ rejectDisposal = reject
+ })
+
+ let cwd = childProcessOptions.cwd ?? root
+ if (debug && cwd !== root) {
+ let relative = path.relative(root, cwd)
+ if (relative[0] !== '.') relative = `./${relative}`
+ console.log(`> cd ${relative}`)
+ }
+ if (debug) console.log(`>& ${command}`)
+ let child = spawn(command, {
+ cwd,
+ shell: true,
+ ...childProcessOptions,
+ env: {
+ ...process.env,
+ ...childProcessOptions.env,
+ },
+ })
+
+ function dispose() {
+ if (!child.kill()) {
+ child.kill('SIGKILL')
+ }
+
+ let timer = setTimeout(
+ () =>
+ rejectDisposal?.(new Error(`spawned process (${command}) did not exit in time`)),
+ ASSERTION_TIMEOUT,
+ )
+ disposePromise.finally(() => {
+ clearTimeout(timer)
+ })
+ return disposePromise
+ }
+ disposables.push(dispose)
+ function onExit() {
+ resolveDisposal?.()
+ }
+
+ let stdoutMessages: string[] = []
+ let stderrMessages: string[] = []
+
+ let stdoutActors: SpawnActor[] = []
+ let stderrActors: SpawnActor[] = []
+
+ function notifyNext(actors: SpawnActor[], messages: string[]) {
+ if (actors.length <= 0) return
+ let [next] = actors
+
+ for (let [idx, message] of messages.entries()) {
+ if (next.predicate(message)) {
+ messages.splice(0, idx + 1)
+ let actorIdx = actors.indexOf(next)
+ actors.splice(actorIdx, 1)
+ next.resolve()
+ break
+ }
+ }
+ }
+
+ let combined: ['stdout' | 'stderr', string][] = []
+
+ child.stdout.on('data', (result) => {
+ let content = result.toString()
+ if (debug || only) console.log(content)
+ combined.push(['stdout', content])
+ for (let line of content.split('\n')) {
+ stdoutMessages.push(stripVTControlCharacters(line))
+ }
+ notifyNext(stdoutActors, stdoutMessages)
+ })
+ child.stderr.on('data', (result) => {
+ let content = result.toString()
+ if (debug || only) console.error(content)
+ combined.push(['stderr', content])
+ for (let line of content.split('\n')) {
+ stderrMessages.push(stripVTControlCharacters(line))
+ }
+ notifyNext(stderrActors, stderrMessages)
+ })
+ child.on('exit', onExit)
+ child.on('error', (error) => {
+ if (error.name !== 'AbortError') {
+ throw error
+ }
+ })
+
+ options.onTestFailed(() => {
+ // In only or debug mode, messages are logged to the console
+ // immediately.
+ if (only || debug) return
+
+ for (let [type, message] of combined) {
+ if (type === 'stdout') {
+ console.log(message)
+ } else {
+ console.error(message)
+ }
+ }
+ })
+
+ return {
+ dispose,
+ flush() {
+ stdoutActors.splice(0)
+ stderrActors.splice(0)
+
+ stdoutMessages.splice(0)
+ stderrMessages.splice(0)
+ },
+ onStdout(predicate: (message: string) => boolean) {
+ return new Promise((resolve) => {
+ stdoutActors.push({ predicate, resolve })
+ notifyNext(stdoutActors, stdoutMessages)
+ })
+ },
+ onStderr(predicate: (message: string) => boolean) {
+ return new Promise((resolve) => {
+ stderrActors.push({ predicate, resolve })
+ notifyNext(stderrActors, stderrMessages)
+ })
+ },
+ }
+ },
+ fs: {
+ async write(
+ filename: string,
+ content: string | Uint8Array,
+ encoding: BufferEncoding = 'utf8',
+ ): 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)
+ }
+
+ // Ensure that files written on Windows use \r\n line ending
+ if (IS_WINDOWS) {
+ content = content.replace(/\n/g, '\r\n')
+ }
+
+ await fs.writeFile(full, content, encoding)
+ },
+
+ async create(filenames: string[]): Promise {
+ for (let filename of filenames) {
+ let full = path.join(root, filename)
+
+ let dir = path.dirname(full)
+ await fs.mkdir(dir, { recursive: true })
+ await fs.writeFile(full, '')
+ }
+ },
+
+ async read(filePath: string) {
+ let content = await fs.readFile(path.resolve(root, filePath), 'utf8')
+
+ // Ensure that files read on Windows have \r\n line endings removed
+ if (IS_WINDOWS) {
+ content = content.replace(/\r\n/g, '\n')
+ }
+
+ return content
+ },
+ async glob(pattern: string) {
+ let files = await fastGlob(pattern, { cwd: root })
+ return Promise.all(
+ files.map(async (file) => {
+ let content = await fs.readFile(path.join(root, file), 'utf8')
+ return [
+ file,
+ // Drop license comment
+ content.replace(/[\s\n]*\/\*![\s\S]*?\*\/[\s\n]*/g, ''),
+ ]
+ }),
+ )
+ },
+ async dumpFiles(pattern: string) {
+ let files = await context.fs.glob(pattern)
+ return `\n${files
+ .slice()
+ .sort((a: [string], z: [string]) => {
+ let aParts = a[0].split('/')
+ let zParts = z[0].split('/')
+
+ let aFile = aParts.at(-1)
+ let zFile = zParts.at(-1)
+
+ // Sort by depth, shallow first
+ if (aParts.length < zParts.length) return -1
+ if (aParts.length > zParts.length) return 1
+
+ // Sort by folder names, alphabetically
+ for (let i = 0; i < aParts.length - 1; i++) {
+ let diff = aParts[i].localeCompare(zParts[i])
+ if (diff !== 0) return diff
+ }
+
+ // Sort by filename, sort files named `index` before others
+ if (aFile?.startsWith('index') && !zFile?.startsWith('index')) return -1
+ if (zFile?.startsWith('index') && !aFile?.startsWith('index')) return 1
+
+ // Sort by filename, alphabetically
+ return a[0].localeCompare(z[0])
+ })
+ .map(([file, content]) => `--- ${file} ---\n${content || ''}`)
+ .join('\n\n')
+ .trim()}\n`
+ },
+ async expectFileToContain(filePath, contents) {
+ return retryAssertion(async () => {
+ let fileContent = await this.read(filePath)
+ for (let content of Array.isArray(contents) ? contents : [contents]) {
+ if (content instanceof RegExp) {
+ options.expect(fileContent).toMatch(content)
+ } else {
+ options.expect(fileContent).toContain(content)
+ }
+ }
+ })
+ },
+ async expectFileNotToContain(filePath, contents) {
+ return retryAssertion(async () => {
+ let fileContent = await this.read(filePath)
+ for (let content of contents) {
+ options.expect(fileContent).not.toContain(content)
+ }
+ })
+ },
+ },
+ } satisfies TestContext
+
+ config.fs['.gitignore'] ??= txt`
+ node_modules/
+ `
+
+ for (let [filename, content] of Object.entries(config.fs)) {
+ await context.fs.write(filename, content)
+ }
+
+ let shouldInstallDependencies = config.installDependencies ?? true
+
+ try {
+ // In debug mode, the directory is going to be inside the pnpm workspace
+ // of the tailwindcss package. This means that `pnpm install` will run
+ // pnpm install on the workspace instead (expect if the root dir defines
+ // a separate workspace). We work around this by using the
+ // `--ignore-workspace` flag.
+ if (shouldInstallDependencies) {
+ let ignoreWorkspace = debug && !config.fs['pnpm-workspace.yaml']
+ await context.exec(`pnpm install${ignoreWorkspace ? ' --ignore-workspace' : ''}`)
+ }
+ } catch (error: any) {
+ console.error(error)
+ console.error(error.stdout?.toString())
+ console.error(error.stderr?.toString())
+ throw error
+ }
+
+ let disposables: (() => Promise)[] = []
+
+ async function dispose() {
+ await Promise.all(disposables.map((dispose) => dispose()))
+
+ if (!debug) {
+ await gracefullyRemove(root)
+ }
+ }
+
+ options.onTestFinished(dispose)
+
+ // Make it a git repository, and commit all files
+ if (only || debug) {
+ try {
+ await context.exec('git init', { cwd: root })
+ await context.exec('git add --all', { cwd: root })
+ await context.exec('git commit -m "before migration"', { cwd: root })
+ } catch (error: any) {
+ console.error(error)
+ console.error(error.stdout?.toString())
+ console.error(error.stderr?.toString())
+ throw error
+ }
+ }
+
+ return await testCallback(context)
+ },
+ )
+}
+test.only = (name: string, config: TestConfig, testCallback: TestCallback) => {
+ return test(name, config, testCallback, { only: true })
+}
+test.skip = (name: string, config: TestConfig, testCallback: TestCallback) => {
+ return test(name, config, testCallback, { skip: true })
+}
+test.debug = (name: string, config: TestConfig, testCallback: TestCallback) => {
+ return test(name, config, testCallback, { debug: true })
+}
+
+// Maps package names to their tarball filenames. See scripts/pack-packages.ts
+// for more details.
+function pkgToFilename(name: string) {
+ return `${name.replace('@', '').replace('/', '-')}.tgz`
+}
+
+async function overwriteVersionsInPackageJson(content: string): Promise {
+ let json = JSON.parse(content)
+
+ // Resolve all workspace:^ versions to local tarballs
+ for (let key of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
+ let dependencies = json[key] || {}
+ for (let dependency in dependencies) {
+ if (dependencies[dependency] === 'workspace:^') {
+ dependencies[dependency] = resolveVersion(dependency)
+ }
+ }
+ }
+
+ // Inject transitive dependency overwrite. This is necessary because
+ // @tailwindcss/vite internally depends on a specific version of
+ // @tailwindcss/oxide and we instead want to resolve it to the locally built
+ // version.
+ json.pnpm ||= {}
+ json.pnpm.overrides ||= {}
+ for (let pkg of PUBLIC_PACKAGES) {
+ if (pkg === 'tailwindcss') {
+ // We want to be explicit about the `tailwindcss` package so our tests can
+ // also import v3 without conflicting v4 tarballs.
+ json.pnpm.overrides['@tailwindcss/node>tailwindcss'] = resolveVersion(pkg)
+ json.pnpm.overrides['@tailwindcss/upgrade>tailwindcss'] = resolveVersion(pkg)
+ json.pnpm.overrides['@tailwindcss/cli>tailwindcss'] = resolveVersion(pkg)
+ json.pnpm.overrides['@tailwindcss/postcss>tailwindcss'] = resolveVersion(pkg)
+ json.pnpm.overrides['@tailwindcss/vite>tailwindcss'] = resolveVersion(pkg)
+ json.pnpm.overrides['@tailwindcss/webpack>tailwindcss'] = resolveVersion(pkg)
+ } else {
+ json.pnpm.overrides[pkg] = resolveVersion(pkg)
+ }
+ }
+
+ return JSON.stringify(json, null, 2)
+}
+
+function resolveVersion(dependency: string) {
+ let tarball = path.join(REPO_ROOT, 'dist', pkgToFilename(dependency))
+ return `file:${tarball}`
+}
+
+export function stripTailwindComment(content: string) {
+ return content.replace(/\/\*! tailwindcss .*? \*\//g, '').trim()
+}
+
+export let svg = dedent
+export let css = dedent
+export let html = dedent
+export let ts = dedent
+export let js = dedent
+export let jsx = dedent
+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++) {
+ output.push(strings[i])
+ if (i < values.length) {
+ output.push(values[i])
+ }
+ }
+
+ return `.${escape(output.join('').trim())}`
+}
+
+export async function retryAssertion(
+ fn: () => Promise,
+ { timeout = ASSERTION_TIMEOUT, delay = 5 }: { timeout?: number; delay?: number } = {},
+) {
+ let end = Date.now() + timeout
+ let error: any
+ while (Date.now() < end) {
+ try {
+ return await fn()
+ } catch (err) {
+ error = err
+ await new Promise((resolve) => setTimeout(resolve, delay))
+ }
+ }
+ throw error
+}
+
+export async function fetchStyles(base: string, path = '/'): Promise {
+ while (base.endsWith('/')) {
+ base = base.slice(0, -1)
+ }
+
+ let index = await fetch(`${base}${path}`)
+ let html = await index.text()
+
+ let linkRegex = /]*>([\s\S]*?)<\/style>/gi
+
+ let stylesheets: string[] = []
+
+ let paths: string[] = []
+ for (let match of html.matchAll(linkRegex)) {
+ let path: string = match[1]
+ if (path.startsWith('./')) {
+ path = path.slice(1)
+ }
+ paths.push(path)
+ }
+ stylesheets.push(
+ ...(await Promise.all(
+ paths.map(async (path) => {
+ let css = await fetch(`${base}${path}`, {
+ headers: {
+ Accept: 'text/css',
+ },
+ })
+ return await css.text()
+ }),
+ )),
+ )
+
+ for (let match of html.matchAll(styleRegex)) {
+ stylesheets.push(match[1])
+ }
+
+ return stylesheets.reduce((acc, css) => {
+ if (acc.length > 0) acc += '\n'
+ acc += css
+ return acc
+ }, '')
+}
+
+async function gracefullyRemove(dir: string) {
+ // Skip removing the directory in CI because it can stall on Windows
+ if (!process.env.CI) {
+ await fs.rm(dir, { recursive: true, force: true }).catch((error) => {
+ console.log(`Failed to remove ${dir}`, error)
+ })
+ }
+}
+
+const SOURCE_MAP_COMMENT = /^\/\*# sourceMappingURL=data:application\/json;base64,(.*) \*\/$/
+
+export interface SourceMap {
+ at(
+ line: number,
+ column: number,
+ ): {
+ source: string | null
+ original: string
+ generated: string
+ }
+}
+
+interface SourceMapOptions {
+ /**
+ * A raw source map
+ *
+ * This may be a string or an object. Strings will be decoded.
+ */
+ map: string | object
+
+ /**
+ * The content of the generated file the source map is for
+ */
+ content: string
+
+ /**
+ * The encoding of the source map
+ *
+ * Can be used to decode a base64 map (e.g. an inline source map URI)
+ */
+ encoding?: BufferEncoding
+}
+
+function parseSourceMap(opts: string | SourceMapOptions): SourceMap {
+ if (typeof opts === 'string') {
+ let lines = opts.trimEnd().split('\n')
+ let comment = lines.at(-1) ?? ''
+ let map = String(comment).match(SOURCE_MAP_COMMENT)?.[1] ?? null
+ if (!map) throw new Error('No source map comment found')
+
+ return parseSourceMap({
+ map,
+ content: lines.slice(0, -1).join('\n'),
+ encoding: 'base64',
+ })
+ }
+
+ let rawMap: RawSourceMap
+ let content = opts.content
+
+ if (typeof opts.map === 'object') {
+ rawMap = opts.map as RawSourceMap
+ } else {
+ rawMap = JSON.parse(Buffer.from(opts.map, opts.encoding ?? 'utf-8').toString())
+ }
+
+ let map = new SourceMapConsumer(rawMap)
+ let generatedTable = createLineTable(content)
+
+ return {
+ at(line: number, column: number) {
+ let pos = map.originalPositionFor({ line, column })
+ let source = pos.source ? map.sourceContentFor(pos.source) : null
+ let originalTable = createLineTable(source ?? '')
+ let originalOffset = originalTable.findOffset(pos)
+ let generatedOffset = generatedTable.findOffset({ line, column })
+
+ return {
+ source: pos.source,
+ original: source
+ ? source.slice(originalOffset, originalOffset + 10).trim() + '...'
+ : '(none)',
+ generated: content.slice(generatedOffset, generatedOffset + 10).trim() + '...',
+ }
+ },
+ }
+}
diff --git a/integrations/vite/astro.test.ts b/integrations/vite/astro.test.ts
new file mode 100644
index 000000000000..f828b5071b60
--- /dev/null
+++ b/integrations/vite/astro.test.ts
@@ -0,0 +1,201 @@
+import { candidate, css, fetchStyles, html, js, json, retryAssertion, test, ts } from '../utils'
+
+test(
+ 'dev mode',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "type": "module",
+ "dependencies": {
+ "astro": "^4.15.2",
+ "@tailwindcss/vite": "workspace:^",
+ "tailwindcss": "workspace:^"
+ }
+ }
+ `,
+ 'astro.config.mjs': ts`
+ import tailwindcss from '@tailwindcss/vite'
+ import { defineConfig } from 'astro/config'
+
+ // https://astro.build/config
+ export default defineConfig({
+ vite: { plugins: [tailwindcss()] },
+ build: { inlineStylesheets: 'never' },
+ })
+ `,
+ 'src/pages/index.astro': html`
+