diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 63f79fa10..75ca2dfb9 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,48 +1,12 @@ # Contributing -## Requirements - -Before getting started, ensure your system has access to the following tools: - -- [Node.js](https://nodejs.org/) -- [Rustup](https://rustup.rs/) -- [pnpm](https://pnpm.io/) - -## 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. +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. **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. @@ -50,7 +14,7 @@ If you open a pull request for a new feature, we're likely to close it not becau ## Coding standards -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: +Our code formatting rules are defined in the `"prettier"` section of [package.json](https://github.com/tailwindcss/tailwindcss/blob/next/package.json). You can check your code against these standards by running: ```sh pnpm run lint @@ -64,40 +28,10 @@ pnpm run format ## Running tests -You can run the TypeScript and Rust test suites using the following command: +You can run the test suite using the following commands: ```sh -pnpm test +pnpm build && pnpm test ``` -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 +Please ensure that the tests are passing when submitting a pull request. If you're adding new features to Tailwind, please include tests. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 848c851b1..000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -custom: ['https://tailwindcss.com/sponsor'] diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md deleted file mode 100644 index 1b8a310c6..000000000 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -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 index 10ee9a6b8..58166a010 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -6,6 +6,9 @@ contact_links: - name: Feature Request url: https://github.com/tailwindlabs/tailwindcss/discussions/new?category=ideas about: 'Suggest any ideas you have using our discussion forums.' + - name: Bug Report + url: https://github.com/tailwindlabs/tailwindcss/issues/new?body=%3C%21--%20Please%20provide%20all%20of%20the%20information%20requested%20below.%20We%27re%20a%20small%20team%20and%20without%20all%20of%20this%20information%20it%27s%20not%20possible%20for%20us%20to%20help%20and%20your%20bug%20report%20will%20be%20closed.%20--%3E%0A%0A%2A%2AWhat%20version%20of%20Tailwind%20CSS%20are%20you%20using%3F%2A%2A%0A%0AFor%20example%3A%20v2.0.4%0A%0A%2A%2AWhat%20build%20tool%20%28or%20framework%20if%20it%20abstracts%20the%20build%20tool%29%20are%20you%20using%3F%2A%2A%0A%0AFor%20example%3A%20postcss-cli%208.3.1%2C%20Next.js%2010.0.9%2C%20webpack%205.28.0%0A%0A%2A%2AWhat%20version%20of%20Node.js%20are%20you%20using%3F%2A%2A%0A%0AFor%20example%3A%20v12.0.0%0A%0A%2A%2AWhat%20browser%20are%20you%20using%3F%2A%2A%0A%0AFor%20example%3A%20Chrome%2C%20Safari%2C%20or%20N%2FA%0A%0A%2A%2AWhat%20operating%20system%20are%20you%20using%3F%2A%2A%0A%0AFor%20example%3A%20macOS%2C%20Windows%0A%0A%2A%2AReproduction%20URL%2A%2A%0A%0AA%20Tailwind%20Play%20link%20or%20public%20GitHub%20repo%20that%20includes%20a%20minimal%20reproduction%20of%20the%20bug.%20%2A%2APlease%20do%20not%20link%20to%20your%20actual%20project%2A%2A%2C%20what%20we%20need%20instead%20is%20a%20_minimal_%20reproduction%20in%20a%20fresh%20project%20without%20any%20unnecessary%20code.%20This%20means%20it%20doesn%27t%20matter%20if%20your%20real%20project%20is%20private%2Fconfidential%2C%20since%20we%20want%20a%20link%20to%20a%20separate%2C%20isolated%20reproduction%20anyways.%0A%0AA%20reproduction%20is%20%2A%2Arequired%2A%2A%20when%20filing%20an%20issue%20%E2%80%94%20any%20issue%20opened%20without%20a%20reproduction%20will%20be%20closed%20and%20you%27ll%20be%20asked%20to%20create%20a%20new%20issue%20that%20includes%20a%20reproduction.%20We%27re%20a%20small%20team%20and%20we%20can%27t%20keep%20up%20with%20the%20volume%20of%20issues%20we%20receive%20if%20we%20need%20to%20reproduce%20each%20issue%20from%20scratch%20ourselves.%0A%0A%2A%2ADescribe%20your%20issue%2A%2A%0A%0ADescribe%20the%20problem%20you%27re%20seeing%2C%20any%20important%20steps%20to%20reproduce%20and%20what%20behavior%20you%20expect%20instead. + about: If you've already asked for help with a problem and confirmed something is broken with Tailwind CSS itself, create a bug report. - 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 aa0793aa3..ce7fafb97 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,26 +4,8 @@ **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 a discussion to first discuss 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. -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/workflows/ci.yml b/.github/workflows/ci.yml index 5ae59e80f..d28a1e1e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,68 +2,47 @@ name: CI on: push: - branches: [main] + branches: [next] 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 - - # Playwright 1.62+ dropped WebKit support for macOS 14, and hangs - # instead of failing when launching WebKit on a macos-14 runner. - - name: macOS - os: macos-15 - + node-version: [20] + runner: [namespace-profile-default, windows-latest, 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]' }} + on-next-branch: + - ${{ github.ref == 'refs/heads/next' }} exclude: - - run-all: false - runner: - name: Windows - - run-all: false - runner: - name: macOS + - on-next-branch: false + runner: windows-latest + - on-next-branch: false + runner: macos-14 - runs-on: ${{ matrix.runner.os }} + runs-on: ${{ matrix.runner }} timeout-minutes: 30 - name: ${{ matrix.runner.name }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 - - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version: ${{ matrix.node-version }} + cache: 'pnpm' # Cargo already skips downloading dependencies if they already exist - name: Cache cargo - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@v4 with: path: | + ~/.cargo/bin/ ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ @@ -72,25 +51,17 @@ jobs: # Cache the `oxide` Rust build - name: Cache oxide build - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@v4 with: path: | + ./target/ ./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 + ./crates/node/index.d.ts key: ${{ runner.os }}-oxide-${{ hashFiles('./crates/**/*') }} - - name: Setup WASM target - run: rustup target add wasm32-wasip1-threads - - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install - name: Build run: pnpm run build @@ -101,24 +72,25 @@ jobs: - name: Lint run: pnpm run lint # Only lint on linux to avoid \r\n line ending errors - if: matrix.runner.os == 'ubuntu-latest' + if: matrix.runner == 'ubuntu-latest' - name: Test run: pnpm run test + - name: Integration Tests + run: pnpm run test:integrations + env: + GITHUB_WORKSPACE: ${{ github.workspace }} + - 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@5c7149c81a83146e5d01f142be1bf87a61831c4d # v2 + if: failure() && github.ref == 'refs/heads/next' + 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.' + message: 'The [most recent build](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) on the `next` branch has failed.' diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml deleted file mode 100644 index 457c38424..000000000 --- a/.github/workflows/integration-tests.yml +++ /dev/null @@ -1,125 +0,0 @@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - - 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version: ${{ env.NODE_VERSION }} - - # Cargo already skips downloading dependencies if they already exist - - name: Cache cargo - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - with: - path: | - ~/.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@27d5ce7f107fe9357f9df03efb73ab90386fccae # 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@5c7149c81a83146e5d01f142be1bf87a61831c4d # 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 index 24e829afd..0c4979b2b 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -2,306 +2,21 @@ name: Prepare Release on: workflow_dispatch: - inputs: - dry_run: - description: Skip creating the draft GitHub release - required: false - default: true - type: boolean - push: - tags: - - 'v*' env: - APP_NAME: tailwindcss-oxide - NODE_VERSION: 24 - PNPM_VERSION: '11.9.0' - OXIDE_LOCATION: ./crates/node + CI: true permissions: contents: read -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - jobs: build: + runs-on: macos-12 + timeout-minutes: 15 + 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 - build-flags: --use-napi-cross - - os: ubuntu-latest - target: aarch64-unknown-linux-gnu - strip: aarch64-linux-gnu-strip - build-flags: --use-napi-cross - - os: ubuntu-latest - target: armv7-unknown-linux-gnueabihf - strip: arm-linux-gnueabihf-strip - build-flags: --use-napi-cross - - os: ubuntu-latest - target: aarch64-unknown-linux-musl - strip-zig: true - build-flags: -x - - os: ubuntu-latest - target: x86_64-unknown-linux-musl - strip: strip - build-flags: -x - - name: Build ${{ matrix.target }} (oxide) - runs-on: ${{ matrix.os }} - timeout-minutes: 15 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - version: ${{ env.PNPM_VERSION }} - - - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version: ${{ env.NODE_VERSION }} - package-manager-cache: false - - - 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 - - - name: Install binutils-aarch64-linux-gnu - if: ${{ matrix.target == 'aarch64-unknown-linux-gnu' }} - run: | - sudo apt-get update - sudo apt-get install binutils-aarch64-linux-gnu -y - - - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - if: ${{ contains(matrix.target, 'musl') }} - with: - version: 0.14.1 - use-cache: false - - - name: Install cargo-zigbuild - uses: taiki-e/install-action@65851e10cd6c377f11a60e600abc07cb08643468 # v2 - if: ${{ contains(matrix.target, 'musl') }} - env: - GITHUB_TOKEN: ${{ github.token }} - with: - tool: cargo-zigbuild - - - name: Setup rust target - run: rustup target add ${{ matrix.target }} - - - name: Install dependencies - run: pnpm install --ignore-scripts --frozen-lockfile --filter=!./playgrounds/* - - - name: Build release - run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }} ${{ matrix.build-flags }} - 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 || matrix.strip-zig }} - env: - STRIP_COMMAND: ${{ matrix.strip }} - STRIP_ZIG: ${{ matrix.strip-zig }} - run: | - if [ "$STRIP_ZIG" = "true" ]; then - for file in ${{ env.OXIDE_LOCATION }}/*.node; do - zig objcopy --strip-all "$file" "$file.stripped" - mv "$file.stripped" "$file" - done - exit 0 - fi - - eval "$STRIP_COMMAND ${{ env.OXIDE_LOCATION }}/*.node" - - - name: Upload artifacts - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - name: Build FreeBSD - uses: cross-platform-actions/action@cdc9ee69ef84a5f2e59c9058335d9c57bcb4ac86 # 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@${{ env.PNPM_VERSION }} --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 --frozen-lockfile --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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # 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 # Required for creating releases - - needs: - - build - - build-freebsd + node-version: [16] steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - fetch-depth: 20 - persist-credentials: false - - - 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@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - version: ${{ env.PNPM_VERSION }} - - - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version: ${{ env.NODE_VERSION }} - registry-url: 'https://registry.npmjs.org' - package-manager-cache: false - - - name: Setup WASM target - run: rustup target add wasm32-wasip1-threads - - - name: Install dependencies - run: pnpm --filter=!./playgrounds/* install --frozen-lockfile - - - name: Download artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: tailwindcss-standalone - path: packages/@tailwindcss-standalone/dist/ - - - name: Upload npm package tarballs - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: npm-package-tarballs - path: dist/*.tgz - - - name: Prepare GitHub Release - if: ${{ !inputs.dry_run }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create "${TAG_NAME}" \ - --draft \ - --title "${TAG_NAME}" \ - --notes "${RELEASE_NOTES}" \ - 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 + - run: echo "stub" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c957d3af..f58660ae1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,34 +1,22 @@ name: Release on: - push: - branches: [main] - release: - types: [published] workflow_dispatch: inputs: - channel: - description: Release channel to publish - required: true - default: insiders - type: choice - options: - - insiders - - release + release_channel: + description: 'Release channel' + required: false + default: 'next' + type: string permissions: contents: read env: APP_NAME: tailwindcss-oxide - NODE_VERSION: 24 - PNPM_VERSION: '11.9.0' + NODE_VERSION: 20 OXIDE_LOCATION: ./crates/node -concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: true - jobs: build: strategy: @@ -58,207 +46,163 @@ jobs: - os: ubuntu-latest target: x86_64-unknown-linux-gnu strip: strip - build-flags: --use-napi-cross + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian - os: ubuntu-latest target: aarch64-unknown-linux-gnu - strip: aarch64-linux-gnu-strip - build-flags: --use-napi-cross + 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: arm-linux-gnueabihf-strip - build-flags: --use-napi-cross + 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-zig: true - build-flags: -x + 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 - build-flags: -x + download: true + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine - name: Build ${{ matrix.target }} (oxide) + name: Build ${{ matrix.target }} (OXIDE) runs-on: ${{ matrix.os }} + container: ${{ matrix.container }} timeout-minutes: 15 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - version: ${{ env.PNPM_VERSION }} + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} - package-manager-cache: false + 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 - - - name: Install binutils-aarch64-linux-gnu - if: ${{ matrix.target == 'aarch64-unknown-linux-gnu' }} - run: | - sudo apt-get update - sudo apt-get install binutils-aarch64-linux-gnu -y - - - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - if: ${{ contains(matrix.target, 'musl') }} + # Cargo already skips downloading dependencies if they already exist + - name: Cache cargo + uses: actions/cache@v4 with: - version: 0.14.1 - use-cache: false + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - name: Install cargo-zigbuild - uses: taiki-e/install-action@65851e10cd6c377f11a60e600abc07cb08643468 # v2 - if: ${{ contains(matrix.target, 'musl') }} - env: - GITHUB_TOKEN: ${{ github.token }} + # Cache the `oxide` Rust build + - name: Cache oxide build + uses: actions/cache@v4 with: - tool: cargo-zigbuild + path: | + ./oxide/target/ + ./crates/node/*.node + ./crates/node/index.js + ./crates/node/index.d.ts + key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }} + + - name: Install Node.JS + uses: actions/setup-node@v4 + 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 --frozen-lockfile --filter=!./playgrounds/* + run: pnpm install --ignore-scripts --filter=!./playgrounds/* - name: Build release - run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }} ${{ matrix.build-flags }} + run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build 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 || matrix.strip-zig }} - env: - STRIP_COMMAND: ${{ matrix.strip }} - STRIP_ZIG: ${{ matrix.strip-zig }} - run: | - if [ "$STRIP_ZIG" = "true" ]; then - for file in ${{ env.OXIDE_LOCATION }}/*.node; do - zig objcopy --strip-all "$file" "$file.stripped" - mv "$file.stripped" "$file" - done - exit 0 - fi - - eval "$STRIP_COMMAND ${{ env.OXIDE_LOCATION }}/*.node" + if: ${{ matrix.strip }} + run: ${{ matrix.strip }} ${{ env.OXIDE_LOCATION }}/*.node - name: Upload artifacts - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + uses: actions/upload-artifact@v4 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - name: Build FreeBSD - uses: cross-platform-actions/action@cdc9ee69ef84a5f2e59c9058335d9c57bcb4ac86 # 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@${{ env.PNPM_VERSION }} --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 --frozen-lockfile --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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: bindings-x86_64-unknown-freebsd - path: ${{ env.OXIDE_LOCATION }}/*.node - release: runs-on: macos-14 timeout-minutes: 15 - name: Build and publish Tailwind CSS + name: Build and release Tailwind CSS permissions: - contents: read + 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@v4 with: + fetch-tags: true fetch-depth: 20 - persist-credentials: false - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - version: ${{ env.PNPM_VERSION }} + - 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' registry-url: 'https://registry.npmjs.org' - package-manager-cache: false - # npm trusted publishing validates the caller workflow filename, so all npm publishes live here. - # This workflow rebuilds the publish artifacts instead of depending on prepare-release.yml. - - name: Resolve release metadata - env: - INPUT_CHANNEL: ${{ github.event.inputs.channel || '' }} - run: | - if [[ "${{ github.event_name }}" == "release" || "$INPUT_CHANNEL" == "release" ]]; then - release_channel=$(node ./scripts/release-channel.js) + # Cargo already skips downloading dependencies if they already exist + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} - echo "RELEASE_KIND=release" >> $GITHUB_ENV - echo "RELEASE_CHANNEL=$release_channel" >> $GITHUB_ENV - echo "FEATURES_ENV=stable" >> $GITHUB_ENV - else - sha_short=$(git rev-parse --short HEAD) - - echo "RELEASE_KIND=insiders" >> $GITHUB_ENV - echo "RELEASE_CHANNEL=insiders" >> $GITHUB_ENV - echo "SHA_SHORT=$sha_short" >> $GITHUB_ENV - echo "INSIDERS_VERSION=0.0.0-insiders.$sha_short" >> $GITHUB_ENV - fi - - - name: Setup WASM target - run: rustup target add wasm32-wasip1-threads + # Cache the `oxide` Rust build + - name: Cache oxide build + uses: actions/cache@v4 + with: + path: | + ./oxide/target/ + ./crates/node/*.node + ./crates/node/index.js + ./crates/node/index.d.ts + key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }} - name: Install dependencies - run: pnpm --filter=!./playgrounds/* install --frozen-lockfile + run: pnpm --filter=!./playgrounds/* install - name: Download artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + uses: actions/download-artifact@v4 with: path: ${{ env.OXIDE_LOCATION }} @@ -276,83 +220,45 @@ jobs: 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: ${{ env.INSIDERS_VERSION }}' - if: env.RELEASE_KIND == 'insiders' - run: pnpm run version-packages ${INSIDERS_VERSION} - name: Build Tailwind CSS - if: env.RELEASE_KIND == 'insiders' run: pnpm run build - - name: Build Tailwind CSS - if: env.RELEASE_KIND == 'release' - run: pnpm run build - env: - FEATURES_ENV: ${{ env.FEATURES_ENV }} - - 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + - 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@v4 with: - name: npm-package-tarballs - path: dist/*.tgz + name: tailwindcss-standalone + path: packages/@tailwindcss-standalone/dist/ - name: Publish - run: | - pnpm --recursive --filter="!@tailwindcss/oxide-wasm32-wasi" publish --tag ${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 ${RELEASE_CHANNEL} --no-git-checks --config.node-linker=hoisted; popd; - - - name: Trigger Tailwind Play update - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # 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' - }) - - notify: - if: ${{ always() && (needs.build.result == 'failure' || needs.build-freebsd.result == 'failure' || needs.release.result == 'failure') }} - needs: - - build - - build-freebsd - - release - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - - name: Resolve release label - id: release + run: pnpm --recursive publish --tag ${{ inputs.release_channel }} --no-git-checks env: - INPUT_CHANNEL: ${{ github.event.inputs.channel || '' }} - RELEASE_TAG: ${{ github.event.release.tag_name || '' }} - run: | - if [[ "${{ github.event_name }}" == "release" ]]; then - tag_name="${RELEASE_TAG:-${GITHUB_REF_NAME}}" - echo "label=release ${tag_name}" >> $GITHUB_OUTPUT - elif [[ "$INPUT_CHANNEL" == "release" ]]; then - version=$(node -p "require('./packages/tailwindcss/package.json').version") - echo "label=release v${version}" >> $GITHUB_OUTPUT - else - sha_short=$(git rev-parse --short HEAD) - echo "label=insiders release 0.0.0-insiders.${sha_short}" >> $GITHUB_OUTPUT - fi + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Notify Discord - uses: discord-actions/message@5c7149c81a83146e5d01f142be1bf87a61831c4d # v2 + - name: Release + uses: softprops/action-gh-release@v2 with: - webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }} - message: 'The [most recent ${{ github.workflow }} workflow](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) for `${{ steps.release.outputs.label }}` has failed.' + 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-x64 + 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/.gitignore b/.gitignore index bbac7d227..9d75bba27 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,4 @@ playwright-report/ blob-report/ playwright/.cache/ target/ -.debug/ +.debug diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..ded82e2f6 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +auto-install-peers = true diff --git a/.prettierignore b/.prettierignore index 4f50b932f..3de4530dd 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,6 +4,5 @@ pnpm-lock.yaml target/ crates/node/index.d.ts crates/node/index.js -crates/ignore/ .next .fingerprint diff --git a/CHANGELOG.md b/CHANGELOG.md index e5e6f6f89..d712fef8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,941 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added - -- Add `@tailwindcss/turbopack` package to run Tailwind CSS with Next.js ([20367](https://github.com/tailwindlabs/tailwindcss/pull/20367)) - ### Fixed -- Ensure watch mode detects changes to symlinked `@source` files whose real paths aren't otherwise scanned ([#20356](https://github.com/tailwindlabs/tailwindcss/pull/20356)) -- Ensure custom variants using `@scope` wrap the generated utilities instead of nesting inside them ([#20369](https://github.com/tailwindlabs/tailwindcss/pull/20369)) -- Fix flattening of `@scope` at-rules ([#20369](https://github.com/tailwindlabs/tailwindcss/pull/20369)) -- Fix standalone declarations in `@scope`, wrap them in `:where(:scope)` ([#20369](https://github.com/tailwindlabs/tailwindcss/pull/20369)) -- Always emit a space for empty fallback values in CSS variables (e.g. `var(--tw-blur,)` → `var(--tw-blur, )`) ([#20373](https://github.com/tailwindlabs/tailwindcss/pull/20373)) -- Canonicalization: convert arbitrary breakpoint and container query variants to named equivalents (e.g. `max-[64rem]` → `max-lg`) ([#20380](https://github.com/tailwindlabs/tailwindcss/pull/20380)) -- Prevent `@tailwindcss/vite` from crashing on every edit under Vite's experimental `bundledDev` mode ([#20379](https://github.com/tailwindlabs/tailwindcss/pull/20379)) -- Ensure `@tailwindcss/oxide` falls back to WASM on platforms without native bindings ([#20383](https://github.com/tailwindlabs/tailwindcss/pull/20383)) -- Detect classes in Ruby percent literals using angle brackets or custom delimiters (e.g. `%w`, `%w|flex|`), including in Slim and Haml templates ([#20387](https://github.com/tailwindlabs/tailwindcss/pull/20387)) -- Preserve whitespace in `--default(…)` values in custom functional utilities (e.g. `--default(box alphabetic)` no longer becomes `boxalphabetic`) ([#20392](https://github.com/tailwindlabs/tailwindcss/pull/20392)) -- Don't scan gitignored directories (e.g. `node_modules` and `.git`) when the project uses a safelist-style `.gitignore` (e.g. `/*` followed by `!/…` negations) ([#20397](https://github.com/tailwindlabs/tailwindcss/pull/20397)) -- Ensure root `theme('…')` namespace lookups in JavaScript plugins and config files return the full namespace object instead of the value of its `DEFAULT` key ([#20399](https://github.com/tailwindlabs/tailwindcss/pull/20399)) -- Skip ignored directories entirely when computing watch globs (`scanner.globs`), instead of walking their full contents on every rebuild ([#20408](https://github.com/tailwindlabs/tailwindcss/pull/20408)) -- Oxide: drop invalid UTF-8 candidates ([#20389](https://github.com/tailwindlabs/tailwindcss/pull/20389)) -- `@tailwindcss/vite` no longer forces a full page reload for external files (e.g.: `.php` files) ([#20414](https://github.com/tailwindlabs/tailwindcss/issues/20414)) -- Canonicalization: don't merge utilities that reference different theme variables set to CSS-wide keywords like `unset` ([#20417](https://github.com/tailwindlabs/tailwindcss/pull/20417)) -- Don't generate utilities when a modifier is used that would otherwise be silently ignored (e.g. `rounded-sm/[5]`, `shadow-sm/foo`, `stroke-2/50`) ([#20419](https://github.com/tailwindlabs/tailwindcss/pull/20419)) -- Only normalize top-level `and`, `or`, and `not` keywords in `supports-[…]` variants (e.g. `selector(a: not (.foo))` → `selector(a:not(.foo))`) ([#20420](https://github.com/tailwindlabs/tailwindcss/pull/20420)) -- Don't warn about Angular's `::ng-deep` and `:host-context()` when optimizing CSS ([#20434](https://github.com/tailwindlabs/tailwindcss/pull/20434)) -- Don't generate CSS for candidates containing an empty additional modifier (e.g. `bg-red-500/50/` and `group-hover/foo//bar:flex`) ([#20466](https://github.com/tailwindlabs/tailwindcss/pull/20466)) -- Sort `min-*`, `max-*`, and container query variants with decimal values numerically (e.g. `min-[40.25rem]` before `min-[40.5rem]`) ([#20512](https://github.com/tailwindlabs/tailwindcss/pull/20512)) -- Ensure CSS comments ending with `\*/` are closed correctly instead of swallowing the CSS that follows (e.g. `/* C:\temp\*/`) ([#20508](https://github.com/tailwindlabs/tailwindcss/pull/20508)) -- Improve style invalidation performance of `group-*` and `peer-*` variants ([#20513](https://github.com/tailwindlabs/tailwindcss/pull/20513)) - -## [4.3.3] - 2026-07-16 - -### Fixed - -- Support `--watch --poll[=ms]` in `@tailwindcss/cli` when filesystem events are unreliable or unavailable ([#20297](https://github.com/tailwindlabs/tailwindcss/pull/20297)) -- Canonicalization: match arbitrary hex colors against theme colors case-insensitively (e.g. `bg-[#fff]` and `bg-[#FFF]` → `bg-white`) ([#20298](https://github.com/tailwindlabs/tailwindcss/pull/20298)) -- Prevent Preflight from overriding Firefox's native `iframe:focus-visible` outline styles ([#20292](https://github.com/tailwindlabs/tailwindcss/pull/20292)) -- Ensure `theme('colors.foo')` in JS plugins resolves correctly when both `--color-foo` and `--color-foo-bar` exist ([#20299](https://github.com/tailwindlabs/tailwindcss/pull/20299)) -- Ensure fractional opacity modifiers work with named shadow sizes like `shadow-sm/12.5`, `text-shadow-sm/12.5`, `drop-shadow-sm/12.5`, and `inset-shadow-sm/12.5` ([#20302](https://github.com/tailwindlabs/tailwindcss/pull/20302)) -- Parse selectors like `[data-foo]div` as two selectors instead of one ([#20303](https://github.com/tailwindlabs/tailwindcss/pull/20303)) -- Ensure `@tailwindcss/postcss` rebuilds when a preprocessor like Sass changes the input CSS without changing the input file on disk ([#20310](https://github.com/tailwindlabs/tailwindcss/pull/20310)) -- Ensure CSS nesting is handled even when Lightning CSS isn't run, such as in `@tailwindcss/browser` and Tailwind Play ([#20124](https://github.com/tailwindlabs/tailwindcss/pull/20124)) -- Prevent achromatic theme colors from shifting hue when mixed in polar color spaces like `oklch` ([#20314](https://github.com/tailwindlabs/tailwindcss/pull/20314)) -- Ensure `--spacing(0)` is optimized to `0px` instead of `0` so it remains a `` when used in `calc(…)` ([#20319](https://github.com/tailwindlabs/tailwindcss/pull/20319)) -- Load `@parcel/watcher` only when needed in `@tailwindcss/cli --watch` mode, so one-off builds and `--watch --poll` work when `@parcel/watcher` can't be loaded ([#20325](https://github.com/tailwindlabs/tailwindcss/pull/20325)) -- Use explicit platform fonts instead of `system-ui` and `ui-sans-serif` so CJK text respects the page's `lang` attribute on Windows ([#20318](https://github.com/tailwindlabs/tailwindcss/pull/20318)) -- Prevent `@tailwindcss/upgrade` from rewriting ignored files when run from a subdirectory ([#20329](https://github.com/tailwindlabs/tailwindcss/pull/20329)) -- Ensure earlier `@source` rules pointing to nested files are scanned when later `@source` rules point to files in parent folders ([#20335](https://github.com/tailwindlabs/tailwindcss/pull/20335)) -- Prevent `@tailwindcss/vite` from triggering full page reloads when scanned files are processed by Vite but haven't been loaded as modules yet ([#20336](https://github.com/tailwindlabs/tailwindcss/pull/20336)) - -## [4.3.2] - 2026-06-26 - -### Fixed - -- Support bare spacing values for `auto-rows-*` and `auto-cols-*` utilities (e.g. `auto-rows-12` and `auto-cols-16`) ([#20229](https://github.com/tailwindlabs/tailwindcss/pull/20229)) -- Prevent `@tailwindcss/cli` in `--watch` mode from crashing on Windows when `@source` points to a directory that doesn't exist ([#20242](https://github.com/tailwindlabs/tailwindcss/pull/20242)) -- Prevent `@tailwindcss/vite` from crashing in Deno v2.8.x when `context.parentURL` is not a valid URL ([#20245](https://github.com/tailwindlabs/tailwindcss/pull/20245)) -- Ensure `@tailwindcss/cli` in `--watch` mode rebuilds when the input CSS file changes in an ignored directory ([#20246](https://github.com/tailwindlabs/tailwindcss/pull/20246)) -- Allow `@variant` rules used in `addBase(…)` to use custom variants defined later ([#20247](https://github.com/tailwindlabs/tailwindcss/pull/20247)) -- Prevent `@tailwindcss/vite` from crashing during HMR when scanned files or directories are deleted ([#20259](https://github.com/tailwindlabs/tailwindcss/pull/20259)) -- Generate `font-size` instead of `color` declarations for `text-[--spacing(…)]` ([#20260](https://github.com/tailwindlabs/tailwindcss/pull/20260)) -- Prevent `@source` patterns from scanning unrelated sibling files and folders ([#20263](https://github.com/tailwindlabs/tailwindcss/pull/20263)) -- Extract class candidates adjacent to Template Toolkit delimiters like `%]…[%` in `.tt`, `.tt2`, and `.tx` files ([#20269](https://github.com/tailwindlabs/tailwindcss/pull/20269)) -- Extract class candidates from conditional Maud syntax like `p.text-black[condition]` ([#20269](https://github.com/tailwindlabs/tailwindcss/pull/20269)) -- Prevent `@position-try` rules from triggering unknown at-rule warnings when optimizing CSS ([#20277](https://github.com/tailwindlabs/tailwindcss/pull/20277)) -- Support class suggestions for named opacity modifiers from `--opacity` theme values ([#20287](https://github.com/tailwindlabs/tailwindcss/pull/20287)) -- Prevent type errors in `@tailwindcss/postcss` when used with newer PostCSS patch releases ([#20289](https://github.com/tailwindlabs/tailwindcss/pull/20289)) - -## [4.3.1] - 2026-06-12 - -### Added - -- Add `--silent` option to suppress output in `@tailwindcss/cli` ([#20100](https://github.com/tailwindlabs/tailwindcss/pull/20100)) - -### Fixed - -- Remove deprecation warnings by using `Module#registerHooks` instead of `Module#register` on Node 26+ ([#20028](https://github.com/tailwindlabs/tailwindcss/pull/20028)) -- Canonicalization: don't crash when plugin utilities throw for unsupported values ([#20052](https://github.com/tailwindlabs/tailwindcss/pull/20052)) -- Allow `@apply` to be used with CSS mixins ([#19427](https://github.com/tailwindlabs/tailwindcss/pull/19427)) -- Ensure `not-*` correctly negates `@container` queries, including `style(…)` queries ([#20059](https://github.com/tailwindlabs/tailwindcss/pull/20059)) -- Ensure `drop-shadow-*` color utilities work with custom shadow values containing `calc(…)` ([#20080](https://github.com/tailwindlabs/tailwindcss/pull/20080)) -- Fix 'Sourcemap is likely to be incorrect' warnings when using `@tailwindcss/vite` ([#20103](https://github.com/tailwindlabs/tailwindcss/pull/20103)) -- Ensure `@tailwindcss/webpack` can be installed in Rspack projects without requiring `webpack` as a peer dependency ([#20027](https://github.com/tailwindlabs/tailwindcss/pull/20027)) -- Canonicalization: don't suggest invalid `calc(…)` expressions (e.g. `px-[calc(1rem+0px)]` → `px-[calc(1rem+0)]`) ([#20127](https://github.com/tailwindlabs/tailwindcss/pull/20127)) -- Canonicalization: avoid suggesting large spacing-scale values for arbitrary lengths (e.g. `left-[99999px]` → `left-[99999px]`, not `left-24999.75`) ([#20130](https://github.com/tailwindlabs/tailwindcss/pull/20130)) -- Ensure `@tailwindcss/cli` in `--watch` mode recovers when a tracked dependency is deleted and restored ([#20137](https://github.com/tailwindlabs/tailwindcss/pull/20137)) -- Ensure standalone `@tailwindcss/cli` binaries are ignored when scanning for class candidates ([#20139](https://github.com/tailwindlabs/tailwindcss/pull/20139)) -- Ensure class candidates are extracted from Twig `addClass(…)` and `removeClass(…)` calls ([#20198](https://github.com/tailwindlabs/tailwindcss/pull/20198)) -- Don't crash in the Ruby or Vue preprocessors when scanning files containing invalid UTF-8 bytes ([#19588](https://github.com/tailwindlabs/tailwindcss/pull/19588)) -- Allow `@variant` to be used inside `addBase` ([#19480](https://github.com/tailwindlabs/tailwindcss/pull/19480)) -- Ensure `@source` globs with symlinks are preserved ([#20203](https://github.com/tailwindlabs/tailwindcss/pull/20203)) -- Ensure later `@source` rules can re-include files excluded by earlier `@source not` rules ([#20203](https://github.com/tailwindlabs/tailwindcss/pull/20203)) -- Upgrade: don't migrate empty class rules to invalid `@utility` rules ([#20205](https://github.com/tailwindlabs/tailwindcss/pull/20205)) -- Ensure transitions between `inset-shadow-none` and other inset shadows work correctly ([#20208](https://github.com/tailwindlabs/tailwindcss/pull/20208)) -- Ensure explicitly referenced `@source` directories are scanned even when ignored by git ([#20214](https://github.com/tailwindlabs/tailwindcss/pull/20214)) -- Ensure `@source` globs ending in `**/*` preserve dynamic path segments to avoid scanning too many files ([#20217](https://github.com/tailwindlabs/tailwindcss/pull/20217)) -- Canonicalization: don't fold `calc(…)` divisions when the result would require high precision (e.g. `w-[calc(100%/3.5)]` → `w-[calc(100%/3.5)]`, not `w-[28.571428571428573%]`) ([#20221](https://github.com/tailwindlabs/tailwindcss/pull/20221)) -- Serve ESM type declarations to ESM importers of `@tailwindcss/postcss` ([#20228](https://github.com/tailwindlabs/tailwindcss/pull/20228)) - -### Changed - -- Generate `0` instead of `calc(var(--spacing) * 0)` for spacing utilities like `m-0` and `left-0` ([#20196](https://github.com/tailwindlabs/tailwindcss/pull/20196)) -- Generate `var(--spacing)` instead of `calc(var(--spacing) * 1)` for spacing utilities like `m-1` and `left-1` ([#20196](https://github.com/tailwindlabs/tailwindcss/pull/20196)) - -## [4.3.0] - 2026-05-08 - -### Added - -- Add `@container-size` utility ([#18901](https://github.com/tailwindlabs/tailwindcss/pull/18901)) -- Add `scrollbar-{auto,thin,none}` utilities for `scrollbar-width`, and `scrollbar-thumb-*` / `scrollbar-track-*` color utilities for `scrollbar-color` ([#19981](https://github.com/tailwindlabs/tailwindcss/pull/19981), [#20019](https://github.com/tailwindlabs/tailwindcss/pull/20019)) -- Add `scrollbar-gutter-*` utilities ([#20018](https://github.com/tailwindlabs/tailwindcss/pull/20018)) -- Add `zoom-*` utilities ([#20020](https://github.com/tailwindlabs/tailwindcss/pull/20020)) -- Add `tab-*` utilities ([#20022](https://github.com/tailwindlabs/tailwindcss/pull/20022)) -- Allow using `@variant` with stacked variants (e.g. `@variant hover:focus { … }`) ([#19996](https://github.com/tailwindlabs/tailwindcss/pull/19996)) -- Allow using `@variant` with compound variants (e.g. `@variant hover, focus { … }`) ([#19996](https://github.com/tailwindlabs/tailwindcss/pull/19996)) -- Support `--default(…)` in `--value(…)` and `--modifier(…)` for functional `@utility` definitions ([#19989](https://github.com/tailwindlabs/tailwindcss/pull/19989)) - -### Fixed - -- Ensure `@plugin` resolves package JavaScript entries instead of browser CSS entries when using `@tailwindcss/vite` ([#19949](https://github.com/tailwindlabs/tailwindcss/pull/19949)) -- Fix relative `@import` and `@plugin` paths resolving from the wrong directory when using `@tailwindcss/vite` ([#19965](https://github.com/tailwindlabs/tailwindcss/pull/19965)) -- Ensure CSS files containing `@variant` are processed by `@tailwindcss/vite` ([#19966](https://github.com/tailwindlabs/tailwindcss/pull/19966)) -- Resolve imports relative to `base` when `result.opts.from` is not provided when using `@tailwindcss/postcss` ([#19980](https://github.com/tailwindlabs/tailwindcss/pull/19980)) -- Canonicalization: preserve significant `_` whitespace in arbitrary values ([#19986](https://github.com/tailwindlabs/tailwindcss/pull/19986)) -- Canonicalization: add parentheses when removing whitespace from arbitrary values would hurt readability (e.g. `w-[calc(100%---spacing(60))]` → `w-[calc(100%-(--spacing(60)))]`) ([#19986](https://github.com/tailwindlabs/tailwindcss/pull/19986)) -- Canonicalization: preserve the original unit in arbitrary values instead of normalizing to base units (e.g. `-mt-[20in]` → `mt-[-20in]`, not `mt-[-1920px]`) ([#19988](https://github.com/tailwindlabs/tailwindcss/pull/19988)) -- Canonicalization: migrate arbitrary `:has()` variants from `[&:has(…)]` to `has-[…]` ([#19991](https://github.com/tailwindlabs/tailwindcss/pull/19991)) -- Upgrade: don’t migrate inline `style` attributes (e.g. `style="flex-grow: 1"` → `style="flex-grow: 1"`, not `style="grow: 1"`) ([#19918](https://github.com/tailwindlabs/tailwindcss/pull/19918)) -- Allow multiple `@utility` definitions with the same name but different value types ([#19777](https://github.com/tailwindlabs/tailwindcss/pull/19777)) -- Export missing `PluginWithConfig` type from `tailwindcss/plugin` to fix errors when inferring plugin config types ([#19707](https://github.com/tailwindlabs/tailwindcss/pull/19707)) -- Ensure `start` and `end` legacy utilities without values do not generate CSS ([#20003](https://github.com/tailwindlabs/tailwindcss/pull/20003)) -- Ensure `--value(…)` is required in functional `@utility` definitions ([#20005](https://github.com/tailwindlabs/tailwindcss/pull/20005)) -- Canonicalization: preserve required whitespace around operators in negated arbitrary values (e.g. `-left-[(var(--a)+var(--b))]`) ([#20011](https://github.com/tailwindlabs/tailwindcss/pull/20011)) - -## [4.2.4] - 2026-04-21 - -### Fixed - -- Ensure imports in `@import` and `@plugin` still resolve correctly when using Vite aliases in `@tailwindcss/vite` ([#19947](https://github.com/tailwindlabs/tailwindcss/pull/19947)) - -## [4.2.3] - 2026-04-20 - -### Fixed - -- Canonicalization: improve canonicalization for `tracking-*` utilities by preferring non-negative utilities (e.g. `-tracking-tighter` → `tracking-wider`) ([#19827](https://github.com/tailwindlabs/tailwindcss/pull/19827)) -- Fix crash due to invalid characters in candidate (exceeding valid unicode code point range) ([#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)) -- Canonicalization: collapse arbitrary values into shorthand utilities (e.g. `px-[1.2rem] py-[1.2rem]` → `p-[1.2rem]`) ([#19837](https://github.com/tailwindlabs/tailwindcss/pull/19837)) -- Canonicalization: collapse `border-{t,b}-*` into `border-y-*`, `border-{l,r}-*` into `border-x-*`, and `border-{t,r,b,l}-*` into `border-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842)) -- Canonicalization: collapse `scroll-m{t,b}-*` into `scroll-my-*`, `scroll-m{l,r}-*` into `scroll-mx-*`, and `scroll-m{t,r,b,l}-*` into `scroll-m-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842)) -- Canonicalization: collapse `scroll-p{t,b}-*` into `scroll-py-*`, `scroll-p{l,r}-*` into `scroll-px-*`, and `scroll-p{t,r,b,l}-*` into `scroll-p-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842)) -- Canonicalization: collapse `overflow-{x,y}-*` into `overflow-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842)) -- Canonicalization: collapse `overscroll-{x,y}-*` into `overscroll-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842)) -- Read from `--placeholder-color` instead of `--background-color` for `placeholder-*` utilities ([#19843](https://github.com/tailwindlabs/tailwindcss/pull/19843)) -- Upgrade: ensure files are not emptied out when killing the upgrade process while it's running ([#19846](https://github.com/tailwindlabs/tailwindcss/pull/19846)) -- Upgrade: use `config.content` when migrating from Tailwind CSS v3 to Tailwind CSS v4 ([#19846](https://github.com/tailwindlabs/tailwindcss/pull/19846)) -- Upgrade: never migrate files that are ignored by git ([#19846](https://github.com/tailwindlabs/tailwindcss/pull/19846)) -- Add `.env` and `.env.*` to default ignored content files ([#19846](https://github.com/tailwindlabs/tailwindcss/pull/19846)) -- Canonicalization: migrate `overflow-ellipsis` into `text-ellipsis` ([#19849](https://github.com/tailwindlabs/tailwindcss/pull/19849)) -- Canonicalization: migrate `start-full` → `inset-s-full`, `start-auto` → `inset-s-auto`, `start-px` → `inset-s-px`, and `start-` → `inset-s-` as well as negative versions ([#19849](https://github.com/tailwindlabs/tailwindcss/pull/19849)) -- Canonicalization: migrate `end-full` → `inset-e-full`, `end-auto` → `inset-e-auto`, `end-px` → `inset-e-px`, and `end-` → `inset-e-` as well as negative versions ([#19849](https://github.com/tailwindlabs/tailwindcss/pull/19849)) -- Canonicalization: move the `-` sign inside the arbitrary value `-left-[9rem]` → `left-[-9rem]` ([#19858](https://github.com/tailwindlabs/tailwindcss/pull/19858)) -- Canonicalization: move the `-` sign outside the arbitrary value `ml-[calc(-1*var(--width))]` → `-ml-(--width)` ([#19858](https://github.com/tailwindlabs/tailwindcss/pull/19858)) -- Improve performance when scanning JSONL / NDJSON files ([#19862](https://github.com/tailwindlabs/tailwindcss/pull/19862)) -- Support `NODE_PATH` environment variable in standalone CLI ([#19617](https://github.com/tailwindlabs/tailwindcss/pull/19617)) - -## [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('pnpm exec 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 index cab0d3d62..e0173bcf3 100644 --- a/integrations/upgrade/js-config.test.ts +++ b/integrations/upgrade/js-config.test.ts @@ -1,6 +1,6 @@ import path from 'node:path' -import { describe } from 'vitest' -import { css, html, json, test, ts, txt } from '../utils' +import { describe, expect } from 'vitest' +import { css, html, json, test, ts } from '../utils' test( `upgrade JS config files with flat theme values, darkMode, and content fields`, @@ -30,23 +30,13 @@ test( 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', @@ -147,14 +137,12 @@ test( '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`
@@ -163,23 +151,22 @@ test( `, }, }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + async ({ exec, fs }) => { + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.{css,js,html}')).toMatchInlineSnapshot(` " --- src/index.html ---
-
+ class="[letter-spacing:var(--tracking-super-wide)] [line-height:var(--leading-super-loose)]" + >
--- src/input.css --- @import 'tailwindcss'; @source '../node_modules/my-external-lib/**/*.{html}'; - @custom-variant dark (&:where(.dark, .dark *)); + @variant dark (&:where(.dark, .dark *)); @theme { --shadow-*: initial; @@ -190,15 +177,9 @@ test( --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; @@ -206,11 +187,6 @@ test( --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%; @@ -261,18 +237,17 @@ test( --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'; + --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; + --tracking-super-wide: 0.25em; - --leading-superLoose: 3; + --leading-super-loose: 3; @keyframes spin-clockwise { 0% { @@ -293,7 +268,7 @@ test( } /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, + 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. @@ -306,14 +281,13 @@ test( ::before, ::backdrop, ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); + border-color: var(--color-gray-200, currentColor); } } --- src/test.js --- export default { 'shouldNotMigrate': !border.test + '', - 'filter': 'drop-shadow(0 0 0.5rem #000)', } " `) @@ -322,105 +296,6 @@ test( }, ) -test( - 'skips gitignored template files even when they are explicitly referenced in `content`', - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "^3", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - '.gitignore': txt` - node_modules - src/ignored - `, - 'tailwind.config.ts': ts` - import { type Config } from 'tailwindcss' - - module.exports = { - content: [ - './src/migrate-me.html', - './src/ignored/do-not-migrate-me.html', - './node_modules/my-external-lib/template.html', - ], - theme: {}, - plugins: [], - } satisfies Config - `, - 'src/input.css': css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, - 'src/migrate-me.html': html` -
- `, - 'src/ignored/do-not-migrate-me.html': html` -
- `, - 'node_modules/my-external-lib/template.html': html` -
- `, - }, - }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') - - expect( - await fs.dumpFiles( - '{src/**/*.{css,html},node_modules/my-external-lib/template.html,.gitignore}', - ), - ).toMatchInlineSnapshot(` - " - --- .gitignore --- - node_modules - src/ignored - - --- src/input.css --- - @import 'tailwindcss'; - - @source './ignored/do-not-migrate-me.html'; - @source '../node_modules/my-external-lib/template.html'; - - /* - 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/migrate-me.html --- -
- - --- node_modules/my-external-lib/template.html --- -
- - --- src/ignored/do-not-migrate-me.html --- -
- " - `) - }, -) - test( 'upgrades JS config files with plugins', { @@ -440,14 +315,6 @@ test( import customPlugin from './custom-plugin' export default { - darkMode: [ - 'variant', - [ - '@media not print { .dark & }', - '@media not eink { .dark & }', - '&:where(.dark, .dark *)', - ], - ], plugins: [ typography, customPlugin({ @@ -483,8 +350,8 @@ test( `, }, }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + async ({ exec, fs }) => { + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` " @@ -509,24 +376,8 @@ test( 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, + 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. @@ -539,7 +390,7 @@ test( ::before, ::backdrop, ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); + border-color: var(--color-gray-200, currentColor); } } " @@ -585,8 +436,8 @@ test( `, }, }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + async ({ exec, fs }) => { + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.{css,ts}')).toMatchInlineSnapshot(` " @@ -594,21 +445,21 @@ test( @import 'tailwindcss'; @theme { - --color-gray-50: oklch(98.5% 0 none); - --color-gray-100: oklch(97% 0 none); - --color-gray-200: oklch(92.2% 0 none); - --color-gray-300: oklch(87% 0 none); - --color-gray-400: oklch(70.8% 0 none); - --color-gray-500: oklch(55.6% 0 none); - --color-gray-600: oklch(43.9% 0 none); - --color-gray-700: oklch(37.1% 0 none); - --color-gray-800: oklch(26.9% 0 none); - --color-gray-900: oklch(20.5% 0 none); - --color-gray-950: oklch(14.5% 0 none); + --color-gray-50: oklch(0.985 0 0); + --color-gray-100: oklch(0.97 0 0); + --color-gray-200: oklch(0.922 0 0); + --color-gray-300: oklch(0.87 0 0); + --color-gray-400: oklch(0.708 0 0); + --color-gray-500: oklch(0.556 0 0); + --color-gray-600: oklch(0.439 0 0); + --color-gray-700: oklch(0.371 0 0); + --color-gray-800: oklch(0.269 0 0); + --color-gray-900: oklch(0.205 0 0); + --color-gray-950: oklch(0.145 0 0); } /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, + 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. @@ -621,7 +472,7 @@ test( ::before, ::backdrop, ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); + border-color: var(--color-gray-200, currentColor); } } " @@ -671,8 +522,8 @@ test( `, }, }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + async ({ exec, fs }) => { + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` " @@ -682,7 +533,7 @@ test( @config '../tailwind.config.ts'; /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, + 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. @@ -695,7 +546,7 @@ test( ::before, ::backdrop, ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); + border-color: var(--color-gray-200, currentColor); } } " @@ -749,8 +600,8 @@ test( `, }, }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + async ({ exec, fs }) => { + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` " @@ -760,7 +611,7 @@ test( @config '../tailwind.config.ts'; /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, + 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. @@ -773,7 +624,7 @@ test( ::before, ::backdrop, ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); + border-color: var(--color-gray-200, currentColor); } } " @@ -823,8 +674,8 @@ test( `, }, }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + async ({ exec, fs }) => { + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` " @@ -834,7 +685,7 @@ test( @config '../tailwind.config.ts'; /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, + 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. @@ -847,7 +698,7 @@ test( ::before, ::backdrop, ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); + border-color: var(--color-gray-200, currentColor); } } " @@ -933,8 +784,8 @@ test( 'project-b/src/index.html': html`
`, }, }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + async ({ exec, fs }) => { + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('project-{a,b}/**/*.{css,ts}')).toMatchInlineSnapshot(` " @@ -946,7 +797,7 @@ test( } /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, + 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. @@ -959,7 +810,7 @@ test( ::before, ::backdrop, ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); + border-color: var(--color-gray-200, currentColor); } } @@ -971,7 +822,7 @@ test( } /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, + 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. @@ -984,7 +835,7 @@ test( ::before, ::backdrop, ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); + border-color: var(--color-gray-200, currentColor); } } " @@ -1031,8 +882,8 @@ test( 'backend/mails/welcome.blade.php': html`
`, }, }, - async ({ root, exec, fs, expect }) => { - await exec('pnpm exec upgrade', { + async ({ root, exec, fs }) => { + await exec('npx @tailwindcss/upgrade', { cwd: path.join(root, 'frontend'), }) @@ -1048,7 +899,7 @@ test( } /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, + 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. @@ -1061,253 +912,7 @@ test( ::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('pnpm exec 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('pnpm exec 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('pnpm exec 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); + border-color: var(--color-gray-200, currentColor); } } " @@ -1345,8 +950,8 @@ describe('border compatibility', () => { `, }, }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + async ({ exec, fs }) => { + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` " @@ -1354,7 +959,7 @@ describe('border compatibility', () => { @import 'tailwindcss'; /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, + 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. @@ -1367,7 +972,7 @@ describe('border compatibility', () => { ::before, ::backdrop, ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); + border-color: var(--color-gray-200, currentColor); } } " @@ -1407,8 +1012,8 @@ describe('border compatibility', () => { `, }, }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + async ({ exec, fs }) => { + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` " @@ -1416,7 +1021,7 @@ describe('border compatibility', () => { @import 'tailwindcss'; /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, + 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. @@ -1429,7 +1034,7 @@ describe('border compatibility', () => { ::before, ::backdrop, ::file-selector-button { - border-color: oklch(62.3% 0.214 259.815); + border-color: oklch(0.623 0.214 259.815); } } " @@ -1456,7 +1061,7 @@ describe('border compatibility', () => { theme: { extend: { borderColor: ({ colors }) => ({ - DEFAULT: 'currentcolor', + DEFAULT: 'currentColor', }), }, }, @@ -1469,8 +1074,8 @@ describe('border compatibility', () => { `, }, }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + async ({ exec, fs }) => { + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` " @@ -1508,8 +1113,8 @@ describe('border compatibility', () => { `, }, }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + async ({ exec, fs }) => { + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` " @@ -1520,7 +1125,7 @@ describe('border compatibility', () => { @import 'tailwindcss'; /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, + 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. @@ -1533,7 +1138,7 @@ describe('border compatibility', () => { ::before, ::backdrop, ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); + border-color: var(--color-gray-200, currentColor); } } " @@ -1579,8 +1184,8 @@ describe('border compatibility', () => { `, }, }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + async ({ exec, fs }) => { + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` " @@ -1589,7 +1194,7 @@ describe('border compatibility', () => { @import 'tailwindcss/preflight' layer(base); /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, + 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. @@ -1602,7 +1207,7 @@ describe('border compatibility', () => { ::before, ::backdrop, ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); + border-color: var(--color-gray-200, currentColor); } } @@ -1682,19 +1287,19 @@ describe('border compatibility', () => { `, }, }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + async ({ exec, fs }) => { + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(` " --- src/index.html ---
--- src/input.css --- @@ -1707,7 +1312,7 @@ describe('border compatibility', () => { } /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, + 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. @@ -1720,15 +1325,15 @@ describe('border compatibility', () => { ::before, ::backdrop, ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); + border-color: var(--color-gray-200, currentColor); } } .container { - width: --spacing(2); - width: --spacing(4.5); + width: calc(var(--spacing) * 2); + width: calc(var(--spacing) * 4.5); width: var(--spacing-5_5); - width: --spacing(13); + width: calc(var(--spacing) * 13); width: var(--spacing-100); width: var(--spacing-miami); } @@ -1792,19 +1397,19 @@ describe('border compatibility', () => { `, }, }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + async ({ exec, fs }) => { + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(` " --- src/index.html ---
--- src/input.css --- @@ -1821,7 +1426,7 @@ describe('border compatibility', () => { } /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, + 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. @@ -1834,7 +1439,7 @@ describe('border compatibility', () => { ::before, ::backdrop, ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); + border-color: var(--color-gray-200, currentColor); } } @@ -1894,8 +1499,8 @@ describe('border compatibility', () => { `, }, }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + async ({ exec, fs }) => { + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(` " @@ -1908,7 +1513,7 @@ describe('border compatibility', () => { @utility container { margin-inline: auto; padding-inline: 2rem; - @media (width >= --theme(--breakpoint-sm)) { + @media (width >= theme(--breakpoint-sm)) { max-width: none; } @media (width >= 48rem) { @@ -1924,7 +1529,7 @@ describe('border compatibility', () => { } /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, + 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. @@ -1937,7 +1542,7 @@ describe('border compatibility', () => { ::before, ::backdrop, ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); + border-color: var(--color-gray-200, currentColor); } } " @@ -1945,243 +1550,3 @@ describe('border compatibility', () => { }, ) }) - -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('pnpm exec 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('pnpm exec 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('pnpm exec 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 deleted file mode 100644 index fdc60828e..000000000 --- a/integrations/upgrade/upgrade-errors.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -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; - `, - }, - retry: 0, - }, - 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('pnpm exec 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('pnpm exec 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: pnpm exec 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; - `, - }, - retry: 0, - }, - 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; - `, - }, - retry: 0, - }, - 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 index 9863918d1..9e356c089 100644 --- a/integrations/utils.ts +++ b/integrations/utils.ts @@ -1,28 +1,20 @@ import dedent from 'dedent' import fastGlob from 'fast-glob' -import { exec, execFile, spawn, type ChildProcess } from 'node:child_process' +import killPort from 'kill-port' +import { exec, spawn } from 'node:child_process' import fs from 'node:fs/promises' -import { createServer } from 'node:net' +import net from 'node:net' import { platform, tmpdir } from 'node:os' import path from 'node:path' -import { promisify, stripVTControlCharacters } from 'node:util' -import { RawSourceMap, SourceMapConsumer } from 'source-map-js' -import { test as defaultTest, type ExpectStatic } from 'vitest' -import * as Yaml from 'yaml' -import { createLineTable } from '../packages/tailwindcss/src/source-maps/line-table' -import { escape } from '../packages/tailwindcss/src/utils/escape' +import { test as defaultTest, expect } from 'vitest' const REPO_ROOT = path.join(__dirname, '..') -const ROOT_PNPM_WORKSPACE = Yaml.parse( - await fs.readFile(path.join(REPO_ROOT, 'pnpm-workspace.yaml'), 'utf8'), -) const PUBLIC_PACKAGES = (await fs.readdir(path.join(REPO_ROOT, 'dist'))).map((name) => name.replace('tailwindcss-', '@tailwindcss/').replace('.tgz', ''), ) interface SpawnedProcess { - dispose: () => Promise - flush: () => void + dispose: () => void onStdout: (predicate: (message: string) => boolean) => Promise onStderr: (predicate: (message: string) => boolean) => Promise } @@ -34,52 +26,40 @@ interface ChildProcessOptions { interface ExecOptions { ignoreStdErr?: boolean - stdin?: string } interface TestConfig { fs: { [filePath: string]: string | Uint8Array } - - timeout?: number - installDependencies?: boolean - retry?: number } 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 + getFreePort(): Promise fs: { - write(filePath: string, content: string, encoding?: BufferEncoding): Promise - symlink(dst: string, src: string): Promise + write(filePath: string, content: string): Promise create(filePaths: string[]): Promise read(filePath: string): Promise - delete(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[]): Promise + expectFileNotToContain(filePath: string, contents: string | string[]): Promise } } type TestCallback = (context: TestContext) => Promise | void interface TestFlags { only?: boolean - skip?: boolean debug?: boolean - concurrent?: boolean } type SpawnActor = { predicate: (message: string) => boolean; resolve: () => void } -export const IS_WINDOWS = platform() === 'win32' - -const execFileAsync = promisify(execFile) +const IS_WINDOWS = platform() === 'win32' const TEST_TIMEOUT = IS_WINDOWS ? 120000 : 60000 const ASSERTION_TIMEOUT = IS_WINDOWS ? 10000 : 5000 @@ -93,17 +73,11 @@ export function test( name: string, config: TestConfig, testCallback: TestCallback, - { only = false, skip = false, debug = false, concurrent = false }: TestFlags = {}, + { only = false, debug = false }: TestFlags = {}, ) { - return defaultTest( + return (only || (!process.env.CI && debug) ? defaultTest.only : defaultTest)( name, - { - timeout: config.timeout ?? TEST_TIMEOUT, - retry: config.retry ?? (process.env.CI ? 2 : 0), - only: only || (!process.env.CI && debug), - skip, - concurrent, - }, + { timeout: TEST_TIMEOUT, retry: process.env.CI ? 2 : 0 }, async (options) => { let rootDir = debug ? path.join(REPO_ROOT, '.debug') : TMP_ROOT await fs.mkdir(rootDir, { recursive: true }) @@ -118,47 +92,12 @@ export function test( let context = { root, - expect: options.expect, - parseSourceMap, async exec( command: string, childProcessOptions: ChildProcessOptions = {}, execOptions: ExecOptions = {}, ) { let cwd = childProcessOptions.cwd ?? root - let originalCommand = command - - // Avoid `pnpm exec upgrade` on Windows because the upgrader runs - // `pnpm add`, which rewrites Windows `.cmd` shims while the current - // shim is still executing. - // - // Pretty sure this is a pnpm v11 bug on Windows. - if (IS_WINDOWS && command.includes('pnpm exec upgrade')) { - for (let base = cwd; ; base = path.dirname(base)) { - let upgradeBin = path.join( - base, - 'node_modules', - '@tailwindcss', - 'upgrade', - 'dist', - 'index.mjs', - ) - - try { - await fs.access(upgradeBin) - command = command.replace('pnpm exec upgrade', `node "${upgradeBin}"`) - break - } catch (error: any) { - if (error?.code !== 'ENOENT') throw error - } - - let parent = path.dirname(base) - if (parent === base) { - throw new Error(`Unable to resolve @tailwindcss/upgrade from ${cwd}`) - } - } - } - if (debug && cwd !== root) { let relative = path.relative(root, cwd) if (relative[0] !== '.') relative = `./${relative}` @@ -166,23 +105,15 @@ export function test( } if (debug) console.log(`> ${command}`) return new Promise((resolve, reject) => { - let child = exec( + exec( command, { cwd, ...childProcessOptions, - env: { - ...process.env, - ...childProcessOptions.env, - }, + env: childProcessOptions.env, }, (error, stdout, stderr) => { if (error) { - if (command !== originalCommand) { - error.message = error.message.replace(command, originalCommand) - let mappedError = error as any - mappedError.cmd = originalCommand - } if (execOptions.ignoreStdErr !== true) console.error(stderr) if (only || debug) { console.error(stdout) @@ -196,10 +127,6 @@ export function test( } }, ) - if (execOptions.stdin) { - child.stdin?.write(execOptions.stdin) - child.stdin?.end() - } }) }, async spawn(command: string, childProcessOptions: ChildProcessOptions = {}) { @@ -219,7 +146,6 @@ export function test( if (debug) console.log(`>& ${command}`) let child = spawn(command, { cwd, - detached: !IS_WINDOWS, shell: true, ...childProcessOptions, env: { @@ -228,26 +154,20 @@ export function test( }, }) - let disposed = false + function dispose() { + child.kill() - async function dispose() { - if (disposed) return disposePromise - disposed = true - - await killProcessTree(child) - - let timer = setTimeout(() => { - forceKillProcessTree(child) - rejectDisposal?.(new Error(`spawned process (${command}) did not exit in time`)) - }, ASSERTION_TIMEOUT) - disposePromise.then( - () => clearTimeout(timer), - () => clearTimeout(timer), + 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?.() } @@ -279,18 +199,14 @@ export function test( 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)) - } + stdoutMessages.push(content) 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)) - } + stderrMessages.push(content) notifyNext(stderrActors, stderrMessages) }) child.on('exit', onExit) @@ -316,13 +232,6 @@ export function test( 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 }) @@ -337,8 +246,44 @@ export function test( }, } }, + async getFreePort(): Promise { + return new Promise((resolve, reject) => { + let server = net.createServer() + server.listen(0, () => { + let address = server.address() + let port = address === null || typeof address === 'string' ? null : address.port + + server.close(() => { + if (port === null) { + reject(new Error(`Failed to get a free port: address is ${address}`)) + } else { + disposables.push(async () => { + // Wait for 10ms in case the process was just killed + await new Promise((resolve) => setTimeout(resolve, 10)) + + // kill-port uses `lsof` on macOS which is expensive and can + // block for multiple seconds. In order to avoid that for a + // server that is no longer running, we check if the port is + // still in use first. + let isPortTaken = await testIfPortTaken(port) + if (!isPortTaken) { + return + } + + try { + await killPort(port) + } catch { + // If the process can not be killed, we can't do anything + } + }) + resolve(port) + } + }) + }) + }) + }, fs: { - async write(filename, content, encoding = 'utf8') { + async write(filename: string, content: string | Uint8Array): Promise { let full = path.join(root, filename) let dir = path.dirname(full) await fs.mkdir(dir, { recursive: true }) @@ -349,8 +294,6 @@ export function test( if (filename.endsWith('package.json')) { content = await overwriteVersionsInPackageJson(content) - } else if (filename.endsWith('pnpm-workspace.yaml')) { - content = overwriteVersionsInPnpmWorkspace(content) } // Ensure that files written on Windows use \r\n line ending @@ -358,22 +301,10 @@ export function test( content = content.replace(/\n/g, '\r\n') } - await fs.writeFile(full, content, encoding) + await fs.writeFile(full, content, 'utf-8') }, - async symlink(target, src) { - let targetAbsolute = path.join(root, target) - let targetParent = path.dirname(targetAbsolute) - await fs.mkdir(targetParent, { recursive: true }) - - let srcAbsolute = path.join(root, src) - let srcParent = path.dirname(srcAbsolute) - await fs.mkdir(srcParent, { recursive: true }) - - await fs.symlink(targetAbsolute, srcAbsolute) - }, - - async create(filenames) { + async create(filenames: string[]): Promise { for (let filename of filenames) { let full = path.join(root, filename) @@ -383,11 +314,7 @@ export function test( } }, - async delete(filename) { - await fs.unlink(path.join(root, filename)) - }, - - async read(filePath) { + 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 @@ -397,7 +324,7 @@ export function test( return content }, - async glob(pattern) { + async glob(pattern: string) { let files = await fastGlob(pattern, { cwd: root }) return Promise.all( files.map(async (file) => { @@ -405,12 +332,12 @@ export function test( return [ file, // Drop license comment - content.replace(/[\s\n]*\/\*![\s\S]*?\*\/[\s\n]*/g, ''), + content.replace(/[\s\n]*\/\*! tailwindcss .*? \*\/[\s\n]*/g, ''), ] }), ) }, - async dumpFiles(pattern) { + async dumpFiles(pattern: string) { let files = await context.fs.glob(pattern) return `\n${files .slice() @@ -447,9 +374,9 @@ export function test( let fileContent = await this.read(filePath) for (let content of Array.isArray(contents) ? contents : [contents]) { if (content instanceof RegExp) { - options.expect(fileContent).toMatch(content) + expect(fileContent).toMatch(content) } else { - options.expect(fileContent).toContain(content) + expect(fileContent).toContain(content) } } }) @@ -458,7 +385,7 @@ export function test( return retryAssertion(async () => { let fileContent = await this.read(filePath) for (let content of contents) { - options.expect(fileContent).not.toContain(content) + expect(fileContent).not.toContain(content) } }) }, @@ -469,45 +396,18 @@ export function test( node_modules/ ` - // Disable Git's automatic line ending conversion in these throwaway test - // repositories. Tools like pnpm write files (e.g. `pnpm-lock.yaml`) with - // LF endings, which on Windows (where `core.autocrlf=true` is common) - // makes Git emit noisy "LF will be replaced by CRLF" warnings when it - // touches those files. - config.fs['.gitattributes'] ??= txt` - * -text - ` - - // Ensure there is always a root `pnpm-workspace.yaml` so that transitive - // dependency overrides (and the build allow list) are injected into it. - // As of pnpm v10, these can no longer live in the `pnpm.overrides` field - // of `package.json`. - config.fs['pnpm-workspace.yaml'] ??= '' - for (let [filename, content] of Object.entries(config.fs)) { - if (content.toString().startsWith('symlink:')) { - // The symlink path is relative to the target destination's path - let target = path.join( - filename, - content.toString().slice('symlink:'.length), // Relative path - ) - - await context.fs.symlink(target, filename) - } else { - await context.fs.write(filename, content) - } + await context.fs.write(filename, content) } - let shouldInstallDependencies = config.installDependencies ?? true - try { - // Every test project gets its own root `pnpm-workspace.yaml` (see - // above). This makes the test directory its own workspace root, so even - // in debug mode — where the directory lives inside this repository's - // pnpm workspace — `pnpm install` won't attach to the parent workspace. - if (shouldInstallDependencies) { - await context.exec(`pnpm install`) - } + // 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. + 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()) @@ -518,18 +418,11 @@ export function test( let disposables: (() => Promise)[] = [] async function dispose() { - let results = await Promise.allSettled(disposables.map((dispose) => dispose())) + await Promise.all(disposables.map((dispose) => dispose())) if (!debug) { await gracefullyRemove(root) } - - let errors = results.flatMap((result) => - result.status === 'rejected' ? [result.reason] : [], - ) - if (errors.length > 0) { - throw new AggregateError(errors, 'Failed to clean up spawned processes') - } } options.onTestFinished(dispose) @@ -539,7 +432,7 @@ export function test( try { await context.exec('git init', { cwd: root }) await context.exec('git add --all', { cwd: root }) - await context.exec('git commit -m "before migration" --allow-empty', { cwd: root }) + await context.exec('git commit -m "before migration"', { cwd: root }) } catch (error: any) { console.error(error) console.error(error.stdout?.toString()) @@ -555,12 +448,6 @@ export function test( 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.concurrent = (name: string, config: TestConfig, testCallback: TestCallback) => { - return test(name, config, testCallback, { concurrent: true }) -} test.debug = (name: string, config: TestConfig, testCallback: TestCallback) => { return test(name, config, testCallback, { debug: true }) } @@ -575,52 +462,38 @@ 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) + ;['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].forEach( + (key) => { + let dependencies = json[key] || {} + for (let dependency in dependencies) { + if (dependencies[dependency] === 'workspace:^') { + dependencies[dependency] = resolveVersion(dependency) + } } - } - } - - return JSON.stringify(json, null, 2) -} - -function overwriteVersionsInPnpmWorkspace(content: string): string { - let workspace = content.trim() === '' ? {} : Yaml.parse(content) - - // Inherit information from the root workspace - workspace.allowBuilds = { - ...ROOT_PNPM_WORKSPACE.allowBuilds, - ...workspace.allowBuilds, - } + }, + ) // 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. - // - // As of pnpm v10, the `pnpm.overrides` field in `package.json` is no longer - // read. Overrides have to be defined in `pnpm-workspace.yaml` instead. - workspace.overrides ||= {} + 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. - workspace.overrides['@tailwindcss/node>tailwindcss'] = resolveVersion(pkg) - workspace.overrides['@tailwindcss/upgrade>tailwindcss'] = resolveVersion(pkg) - workspace.overrides['@tailwindcss/cli>tailwindcss'] = resolveVersion(pkg) - workspace.overrides['@tailwindcss/postcss>tailwindcss'] = resolveVersion(pkg) - workspace.overrides['@tailwindcss/turbopack>tailwindcss'] = resolveVersion(pkg) - workspace.overrides['@tailwindcss/vite>tailwindcss'] = resolveVersion(pkg) - workspace.overrides['@tailwindcss/webpack>tailwindcss'] = resolveVersion(pkg) + 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) } else { - workspace.overrides[pkg] = resolveVersion(pkg) + json.pnpm.overrides[pkg] = resolveVersion(pkg) } } - return Yaml.stringify(workspace) + return JSON.stringify(json, null, 2) } function resolveVersion(dependency: string) { @@ -632,12 +505,30 @@ export function stripTailwindComment(content: string) { return content.replace(/\/\*! tailwindcss .*? \*\//g, '').trim() } +function testIfPortTaken(port: number): Promise { + return new Promise((resolve) => { + let client = new net.Socket() + client.once('connect', () => { + resolve(true) + client.end() + }) + client.once('error', (error: any) => { + if (error.code !== 'ECONNREFUSED') { + resolve(true) + } else { + resolve(false) + } + client.end() + }) + client.connect({ port: port, host: 'localhost' }) + }) +} + 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 @@ -660,6 +551,80 @@ export function candidate(strings: TemplateStringsArray, ...values: any[]) { return `.${escape(output.join('').trim())}` } +// https://drafts.csswg.org/cssom/#serialize-an-identifier +export function escape(value: string) { + if (arguments.length == 0) { + throw new TypeError('`CSS.escape` requires an argument.') + } + var string = String(value) + var length = string.length + var index = -1 + var codeUnit + var result = '' + var firstCodeUnit = string.charCodeAt(0) + + if ( + // If the character is the first character and is a `-` (U+002D), and + // there is no second character, […] + length == 1 && + firstCodeUnit == 0x002d + ) { + return '\\' + string + } + + while (++index < length) { + codeUnit = string.charCodeAt(index) + // Note: there’s no need to special-case astral symbols, surrogate + // pairs, or lone surrogates. + + // If the character is NULL (U+0000), then the REPLACEMENT CHARACTER + // (U+FFFD). + if (codeUnit == 0x0000) { + result += '\uFFFD' + continue + } + + if ( + // If the character is in the range [\1-\1F] (U+0001 to U+001F) or is + // U+007F, […] + (codeUnit >= 0x0001 && codeUnit <= 0x001f) || + codeUnit == 0x007f || + // If the character is the first character and is in the range [0-9] + // (U+0030 to U+0039), […] + (index == 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) || + // If the character is the second character and is in the range [0-9] + // (U+0030 to U+0039) and the first character is a `-` (U+002D), […] + (index == 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && firstCodeUnit == 0x002d) + ) { + // https://drafts.csswg.org/cssom/#escape-a-character-as-code-point + result += '\\' + codeUnit.toString(16) + ' ' + continue + } + + // If the character is not handled by one of the above rules and is + // greater than or equal to U+0080, is `-` (U+002D) or `_` (U+005F), or + // is in one of the ranges [0-9] (U+0030 to U+0039), [A-Z] (U+0041 to + // U+005A), or [a-z] (U+0061 to U+007A), […] + if ( + codeUnit >= 0x0080 || + codeUnit == 0x002d || + codeUnit == 0x005f || + (codeUnit >= 0x0030 && codeUnit <= 0x0039) || + (codeUnit >= 0x0041 && codeUnit <= 0x005a) || + (codeUnit >= 0x0061 && codeUnit <= 0x007a) + ) { + // the character itself + result += string.charAt(index) + continue + } + + // Otherwise, the escaped character. + // https://drafts.csswg.org/cssom/#escape-a-character + result += '\\' + string.charAt(index) + } + return result +} + export async function retryAssertion( fn: () => Promise, { timeout = ASSERTION_TIMEOUT, delay = 5 }: { timeout?: number; delay?: number } = {}, @@ -670,7 +635,6 @@ export async function retryAssertion( try { return await fn() } catch (err) { - Error.captureStackTrace(err, retryAssertion) error = err await new Promise((resolve) => setTimeout(resolve, delay)) } @@ -678,15 +642,11 @@ export async function retryAssertion( throw error } -export async function fetchStyles(base: string, path = '/'): Promise { - while (base.endsWith('/')) { - base = base.slice(0, -1) - } - - let index = await fetch(`${base}${path}`) +export async function fetchStyles(port: number, path = '/'): Promise { + let index = await fetch(`http://localhost:${port}${path}`) let html = await index.text() - let linkRegex = /]*>([\s\S]*?)<\/style>/gi let stylesheets: string[] = [] @@ -702,7 +662,7 @@ export async function fetchStyles(base: string, path = '/'): Promise { stylesheets.push( ...(await Promise.all( paths.map(async (path) => { - let css = await fetch(`${base}${path}`, { + let css = await fetch(`http://localhost:${port}${path}`, { headers: { Accept: 'text/css', }, @@ -717,155 +677,13 @@ export async function fetchStyles(base: string, path = '/'): Promise { } return stylesheets.reduce((acc, css) => { - if (acc.length > 0) acc += '\n' - acc += css - return acc + return acc + '\n' + css }, '') } -export async function getRandomPort() { - return new Promise((resolve, reject) => { - let server = createServer() - server.unref() - server.on('error', reject) - server.listen(0, '127.0.0.1', () => { - let address = server.address() - server.close(() => { - if (address && typeof address === 'object') { - resolve(address.port) - } else { - reject(new Error('Unable to allocate random port')) - } - }) - }) - }) -} - -async function killProcessTree(child: ChildProcess) { - if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined) { - return - } - - if (IS_WINDOWS) { - await execFileAsync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { - timeout: ASSERTION_TIMEOUT, - windowsHide: true, - }).catch(() => {}) - return - } - - try { - process.kill(-child.pid, 'SIGTERM') - } catch (error: any) { - if (error?.code !== 'ESRCH') { - child.kill() - } - } -} - -function forceKillProcessTree(child: ChildProcess) { - if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined) { - return - } - - if (IS_WINDOWS) { - execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true }, () => {}) - return - } - - try { - process.kill(-child.pid, 'SIGKILL') - } catch (error: any) { - if (error?.code !== 'ESRCH') { - child.kill('SIGKILL') - } - } -} - 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() + '...', - } - }, + await fs.rm(dir, { recursive: true, force: true }) } } diff --git a/integrations/vite/astro.test.ts b/integrations/vite/astro.test.ts index f828b5071..188d6e9f4 100644 --- a/integrations/vite/astro.test.ts +++ b/integrations/vite/astro.test.ts @@ -1,4 +1,5 @@ -import { candidate, css, fetchStyles, html, js, json, retryAssertion, test, ts } from '../utils' +import { expect } from 'vitest' +import { candidate, fetchStyles, html, json, retryAssertion, test, ts } from '../utils' test( 'dev mode', @@ -20,8 +21,9 @@ test( // https://astro.build/config export default defineConfig({ - vite: { plugins: [tailwindcss()] }, - build: { inlineStylesheets: 'never' }, + vite: { + plugins: [tailwindcss()], + }, }) `, 'src/pages/index.astro': html` @@ -33,21 +35,12 @@ test( `, }, }, - async ({ fs, spawn, expect }) => { - let process = await spawn('pnpm astro dev') - await process.onStdout((m) => m.includes('ready in')) - - let url = '' - await process.onStdout((m) => { - let match = /Local\s*(http.*)\//.exec(m) - if (match) url = match[1] - return Boolean(url) - }) - - await process.onStdout((m) => m.includes('watching for file changes')) + async ({ fs, spawn, getFreePort }) => { + let port = await getFreePort() + await spawn(`pnpm astro dev --port ${port}`) await retryAssertion(async () => { - let css = await fetchStyles(url) + let css = await fetchStyles(port) expect(css).toContain(candidate`underline`) }) @@ -63,139 +56,9 @@ test( `, ) - let css = await fetchStyles(url) + let css = await fetchStyles(port) expect(css).toContain(candidate`underline`) expect(css).toContain(candidate`font-bold`) }) }, ) - -test( - 'build mode', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "astro": "^4.15.2", - "react": "^19", - "react-dom": "^19", - "@astrojs/react": "^4", - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - } - } - `, - 'astro.config.mjs': ts` - import tailwindcss from '@tailwindcss/vite' - import react from '@astrojs/react' - import { defineConfig } from 'astro/config' - - // https://astro.build/config - export default defineConfig({ - vite: { plugins: [tailwindcss()] }, - integrations: [react()], - build: { inlineStylesheets: 'never' }, - }) - `, - // prettier-ignore - 'src/pages/index.astro': html` - --- - import ClientOnly from './client-only'; - --- - -
Hello, world!
- - - - - `, - 'src/pages/client-only.jsx': js` - export default function ClientOnly() { - return
Hello, world!
- } - `, - }, - }, - async ({ fs, exec, expect }) => { - await exec('pnpm astro build') - - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - - await fs.expectFileToContain(files[0][0], [candidate`underline`, candidate`overline`]) - }, -) - -// https://github.com/tailwindlabs/tailwindcss/issues/19677 -test( - 'import aliases should work in - - -

Astro

- - - `, - 'src/styles/global.css': css`@import 'tailwindcss';`, - }, - }, - async ({ fs, exec, expect }) => { - await exec('pnpm astro build') - - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - - await fs.expectFileToContain(files[0][0], [candidate`underline`]) - }, -) diff --git a/integrations/vite/bundled-dev.test.ts b/integrations/vite/bundled-dev.test.ts deleted file mode 100644 index 8ba1b6b73..000000000 --- a/integrations/vite/bundled-dev.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import path from 'node:path' -import { candidate, css, html, json, retryAssertion, test, ts, txt, yaml } from '../utils' - -// Vite's experimental `bundledDev` mode invokes the `hotUpdate` hook without a -// `server`, which used to crash the plugin on every file edit. -// -// - https://github.com/tailwindlabs/tailwindcss/issues/20378 -// - https://vite.dev/blog/announcing-vite8-1#experimental-bundled-dev-mode -test( - 'dev mode (experimental `bundledDev`)', - { - fs: { - 'package.json': json`{}`, - 'pnpm-workspace.yaml': yaml` - # - packages: - - project-a - `, - 'project-a/package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "^8.1" - } - } - `, - 'project-a/vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - experimental: { - bundledDev: true, - }, - plugins: [tailwindcss()], - }) - `, - 'project-a/index.html': html` - - - - -
Hello, world!
- - `, - 'project-a/src/index.css': css` - @reference 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - @source '../../project-b/src/**/*.html'; - `, - 'project-b/src/index.html': html` -
- `, - }, - }, - async ({ root, spawn, fs, expect }) => { - let process = await spawn('pnpm vite dev', { - cwd: path.join(root, 'project-a'), - env: { - // Some CI machines have an upstream rolldown bug where its file watcher - // never delivers a single event. The debug output is the only way to - // tell that apart from a real regression (see below). - DEBUG: 'vite:full-bundle-mode', - }, - }) - - // Any debug output beyond the "INITIAL:" startup lines proves rolldown's - // watcher delivered at least one file event. - let watcherAlive = false - - // `hotUpdate` errors don't kill the dev server, they are only printed to - // stderr. Track them explicitly so a crash fails the test even if the - // rebuild happens to succeed anyway. - let pluginErrors: string[] = [] - process.onStderr((message) => { - if (message.includes('vite:full-bundle-mode')) { - if (!message.includes('INITIAL:')) watcherAlive = true - } else if (message.includes('@tailwindcss/vite')) { - pluginErrors.push(message) - } - return false - }) - - let url = '' - await process.onStdout((m) => { - let match = /Local:\s*(http.*)\//.exec(m) - if (match) url = match[1] - return Boolean(url) - }) - - // In `bundledDev` mode the stylesheet is not served separately. Instead the - // generated CSS is embedded in the bundled JS and injected at runtime, so - // extract the bundle from the served HTML. While the bundle is being built, - // Vite serves a temporary fallback page instead. - // - // The bundle can be split into multiple chunks (e.g. the HMR client runtime - // and the app itself), and the chunk containing the CSS is not always the - // first one, so fetch every referenced script and stylesheet. - async function fetchBundledStyles(): Promise { - let index = await fetch(`${url}/`) - let html = await index.text() - if (html.includes('__vite_is_fallback_page__')) { - throw new Error('Bundling still in progress') - } - - let sources = [ - ...html.matchAll(/]*\ssrc="([^"]+)"/g), - ...html.matchAll(/]*\srel="stylesheet"[^>]*\shref="([^"]+)"/g), - ].map((match) => match[1]) - if (sources.length === 0) throw new Error(`No scripts or stylesheets found in:\n\n${html}`) - - let contents = await Promise.all( - sources.map(async (src) => { - let response = await fetch(new URL(src, `${url}/`)) - return await response.text() - }), - ) - return contents.join('\n') - } - - await retryAssertion( - async () => { - let styles = await fetchBundledStyles() - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`flex`) - }, - { timeout: 10_000, delay: 100 }, - ) - - // Edit a module file and a manually `@source`d file. Without a connected - // HMR websocket client (this test only uses HTTP fetches), a file change - // does not trigger an eager rebuild — it only marks the bundle as stale, - // and the next fetch kicks off the rebuild. So poll with fetches without - // touching the files again: rewriting would re-mark the bundle stale right - // before each fetch and the rebuild could never be observed. - await fs.write( - 'project-a/index.html', - html` - - - - -
Hello, world!
- - `, - ) - - await fs.write('project-b/src/index.html', html` -
- `) - - try { - await retryAssertion( - async () => { - let styles = await fetchBundledStyles() - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`flex`) - expect(styles).toContain(candidate`m-2`) - expect(styles).toContain(candidate`font-bold`) - }, - { timeout: 15_000, delay: 100 }, - ) - } catch (error) { - // If rolldown's watcher delivered any file event, the updates were - // genuinely dropped somewhere along the way — a real failure. - if (watcherAlive) throw error - - // Otherwise the watcher never delivered a single event, so the updates - // can never be observed no matter how long we wait — an upstream - // rolldown bug on some CI machines, not a plugin regression. The crash - // regression this test guards is still covered: `hotUpdate` is driven - // by Vite's own (working) chokidar watcher and asserted via - // `pluginErrors` below. - // - // Skipping is only sound while the server still healthily serves the - // original bundle. If it stopped serving (crashed, or wedged on the - // fallback page), that's real breakage a silent watcher must not mask. - console.log(error) - let styles = await fetchBundledStyles() - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`flex`) - console.warn( - 'Skipping update assertions: rolldown’s file watcher delivered no events on this machine.', - ) - } - - expect(pluginErrors).toEqual([]) - }, -) diff --git a/integrations/vite/config.test.ts b/integrations/vite/config.test.ts index 0ac68f5e9..3e0401f9e 100644 --- a/integrations/vite/config.test.ts +++ b/integrations/vite/config.test.ts @@ -1,3 +1,4 @@ +import { expect } from 'vitest' import { candidate, css, fetchStyles, html, js, json, retryAssertion, test, ts } from '../utils' test( @@ -12,7 +13,7 @@ test( "tailwindcss": "workspace:^" }, "devDependencies": { - "vite": "^7" + "vite": "^6" } } `, @@ -50,7 +51,7 @@ test( `, }, }, - async ({ fs, exec, expect }) => { + async ({ fs, exec }) => { await exec('pnpm vite build') let files = await fs.glob('dist/**/*.css') @@ -76,7 +77,7 @@ test( "tailwindcss": "workspace:^" }, "devDependencies": { - "vite": "^7" + "vite": "^6" } } `, @@ -114,7 +115,7 @@ test( `, }, }, - async ({ fs, exec, expect }) => { + async ({ fs, exec }) => { await exec('pnpm vite build') let files = await fs.glob('dist/**/*.css') @@ -140,7 +141,7 @@ test( "tailwindcss": "workspace:^" }, "devDependencies": { - "vite": "^7" + "vite": "^6" } } `, @@ -180,19 +181,12 @@ test( `, }, }, - async ({ fs, spawn, expect }) => { - let process = await spawn('pnpm vite dev') - await process.onStdout((m) => m.includes('ready in')) - - let url = '' - await process.onStdout((m) => { - let match = /Local:\s*(http.*)\//.exec(m) - if (match) url = match[1] - return Boolean(url) - }) + async ({ fs, getFreePort, spawn }) => { + let port = await getFreePort() + await spawn(`pnpm vite dev --port ${port}`) await retryAssertion(async () => { - let css = await fetchStyles(url, '/index.html') + let css = await fetchStyles(port, '/index.html') expect(css).toContain(candidate`text-primary`) expect(css).toContain('color: blue') }) @@ -200,7 +194,7 @@ test( await retryAssertion(async () => { await fs.write('my-color.cjs', js`module.exports = 'red'`) - let css = await fetchStyles(url, '/index.html') + let css = await fetchStyles(port, '/index.html') expect(css).toContain(candidate`text-primary`) expect(css).toContain('color: red') }) @@ -219,7 +213,7 @@ test( "tailwindcss": "workspace:^" }, "devDependencies": { - "vite": "^7" + "vite": "^6" } } `, @@ -259,19 +253,12 @@ test( `, }, }, - async ({ fs, spawn, expect }) => { - let process = await spawn('pnpm vite dev') - await process.onStdout((m) => m.includes('ready in')) - - let url = '' - await process.onStdout((m) => { - let match = /Local:\s*(http.*)\//.exec(m) - if (match) url = match[1] - return Boolean(url) - }) + async ({ fs, getFreePort, spawn }) => { + let port = await getFreePort() + await spawn(`pnpm vite dev --port ${port}`) await retryAssertion(async () => { - let css = await fetchStyles(url, '/index.html') + let css = await fetchStyles(port, '/index.html') expect(css).toContain(candidate`text-primary`) expect(css).toContain('color: blue') }) @@ -279,7 +266,7 @@ test( await retryAssertion(async () => { await fs.write('my-color.mjs', js`export default 'red'`) - let css = await fetchStyles(url, '/index.html') + let css = await fetchStyles(port, '/index.html') expect(css).toContain(candidate`text-primary`) expect(css).toContain('color: red') }) diff --git a/integrations/vite/css-modules.test.ts b/integrations/vite/css-modules.test.ts index a680ab9b7..31a4456a5 100644 --- a/integrations/vite/css-modules.test.ts +++ b/integrations/vite/css-modules.test.ts @@ -1,65 +1,67 @@ -import { describe } from 'vitest' +import { describe, expect } from 'vitest' import { css, html, test, ts, txt } from '../utils' -describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { - test( - `dev mode`, - { - fs: { - 'package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''} - "vite": "^7" +for (let transformer of ['postcss', 'lightningcss']) { + describe(transformer, () => { + test( + `dev mode`, + { + fs: { + 'package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} + "vite": "^6" + } } - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' + `, + 'vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' - export default defineConfig({ - css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'index.html': html` - - - - -
- - `, - 'src/component.ts': ts` - import { foo } from './component.module.css' - let root = document.getElementById('root') - root.className = foo - root.innerText = 'Hello, world!' - `, - 'src/component.module.css': css` - @import 'tailwindcss/utilities'; + export default defineConfig({ + css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, + build: { cssMinify: false }, + plugins: [tailwindcss()], + }) + `, + 'index.html': html` + + + + +
+ + `, + 'src/component.ts': ts` + import { foo } from './component.module.css' + let root = document.getElementById('root') + root.className = foo + root.innerText = 'Hello, world!' + `, + 'src/component.module.css': css` + @import 'tailwindcss/utilities'; - .foo { - @apply underline; - } - `, + .foo { + @apply underline; + } + `, + }, }, - }, - async ({ exec, fs, expect }) => { - await exec(`pnpm vite build`) + async ({ exec, fs }) => { + await exec(`pnpm vite build`) - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - let [filename] = files[0] + let files = await fs.glob('dist/**/*.css') + expect(files).toHaveLength(1) + let [filename] = files[0] - await fs.expectFileToContain(filename, [/text-decoration-line: underline;/gi]) - }, - ) -}) + await fs.expectFileToContain(filename, [/text-decoration-line: underline;/gi]) + }, + ) + }) +} diff --git a/integrations/vite/html-style-blocks.test.ts b/integrations/vite/html-style-blocks.test.ts deleted file mode 100644 index 22bb91d78..000000000 --- a/integrations/vite/html-style-blocks.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { html, json, test, ts } from '../utils' - -test( - 'transforms html style blocks', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "@tailwindcss/vite": "workspace:^", - "vite": "^7" - } - } - `, - 'vite.config.ts': ts` - import { defineConfig } from 'vite' - import tailwindcss from '@tailwindcss/vite' - - export default defineConfig({ - plugins: [tailwindcss()], - }) - `, - 'index.html': html` - - - -
- - - - `, - }, - }, - async ({ fs, exec, expect }) => { - await exec('pnpm vite build') - - expect(await fs.dumpFiles('dist/*.html')).toMatchInlineSnapshot(` - " - --- dist/index.html --- - - - -
- - - - " - `) - }, -) diff --git a/integrations/vite/ignored-packages.test.ts b/integrations/vite/ignored-packages.test.ts deleted file mode 100644 index 1efa9dc61..000000000 --- a/integrations/vite/ignored-packages.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { candidate, css, fetchStyles, html, js, retryAssertion, test, ts, txt } from '../utils' - -const WORKSPACE = { - fs: { - 'package.json': txt` - { - "type": "module", - "dependencies": { - "tailwind-merge": "^2", - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "^7" - } - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'index.html': html` - - - - - `, - 'src/index.js': js` - import { twMerge } from 'tailwind-merge' - - twMerge('underline') - - console.log('underline') - `, - 'src/index.css': css`@import 'tailwindcss/utilities' layer(utilities);`, - }, -} - -test( - 'does not scan tailwind-merge in production builds', - WORKSPACE, - async ({ fs, exec, expect }) => { - await exec('pnpm vite build') - - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - let [, content] = files[0] - - expect(content).toMatchInlineSnapshot(` - "@layer utilities { - .underline { - text-decoration-line: underline; - } - } - " - `) - }, -) - -test('does not scan tailwind-merge in dev builds', WORKSPACE, async ({ spawn, expect }) => { - let process = await spawn('pnpm vite dev') - await process.onStdout((m) => m.includes('ready in')) - - let url = '' - await process.onStdout((m) => { - let match = /Local:\s*(http.*)\//.exec(m) - if (match) url = match[1] - return Boolean(url) - }) - - await retryAssertion(async () => { - let styles = await fetchStyles(url, '/index.html') - - expect(styles).not.toContain(candidate`flex`) - }) -}) diff --git a/integrations/vite/index.test.ts b/integrations/vite/index.test.ts index c346f8e37..80a2a50c4 100644 --- a/integrations/vite/index.test.ts +++ b/integrations/vite/index.test.ts @@ -1,5 +1,5 @@ import path from 'node:path' -import { describe } from 'vitest' +import { describe, expect } from 'vitest' import { candidate, css, @@ -7,7 +7,6 @@ import { html, js, json, - jsx, retryAssertion, test, ts, @@ -15,585 +14,19 @@ import { yaml, } from '../utils' -describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { - test( - `production build`, - { - fs: { - 'package.json': json`{}`, - 'pnpm-workspace.yaml': yaml` - # - packages: - - project-a - `, - 'project-a/package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''} - "vite": "^7" - } - } - `, - 'project-a/vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'project-a/index.html': html` - - - - -
Hello, world!
- - `, - 'project-a/tailwind.config.js': js` - export default { - content: ['../project-b/src/**/*.js'], - } - `, - 'project-a/src/index.css': css` - @reference 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - @config '../tailwind.config.js'; - @source '../../project-b/src/**/*.html'; - `, - 'project-b/src/index.html': html` -
- `, - 'project-b/src/index.js': js` - const className = "content-['project-b/src/index.js']" - module.exports = { className } - `, - }, - }, - async ({ root, fs, exec, expect }) => { - await exec('pnpm vite build', { cwd: path.join(root, 'project-a') }) - - let files = await fs.glob('project-a/dist/**/*.css') - expect(files).toHaveLength(1) - let [filename] = files[0] - - await fs.expectFileToContain(filename, [ - candidate`underline`, - candidate`m-2`, - candidate`flex`, - candidate`content-['project-b/src/index.js']`, - ]) - }, - ) - - test( - 'dev mode', - { - fs: { - 'package.json': json`{}`, - 'pnpm-workspace.yaml': yaml` - # - packages: - - project-a - `, - 'project-a/package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''} - "vite": "^7" - } - } - `, - 'project-a/vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'project-a/index.html': html` - - - - -
Hello, world!
- - `, - 'project-a/about.html': html` - - - - -
Tailwind Labs
- - `, - 'project-a/tailwind.config.js': js` - export default { - content: ['../project-b/src/**/*.js'], - } - `, - 'project-a/src/index.css': css` - @reference 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - @import './imported.css'; - @config '../tailwind.config.js'; - @source '../../project-b/src/**/*.html'; - `, - 'project-a/src/imported.css': css` - .imported { - color: red; - } - `, - 'project-b/src/index.html': html` -
- `, - 'project-b/src/index.js': js` - const className = "content-['project-b/src/index.js']" - module.exports = { className } - `, - }, - }, - async ({ root, spawn, fs, expect }) => { - let process = await spawn('pnpm vite dev', { - cwd: path.join(root, 'project-a'), - }) - await process.onStdout((m) => m.includes('ready in')) - - let url = '' - await process.onStdout((m) => { - let match = /Local:\s*(http.*)\//.exec(m) - if (match) url = match[1] - return Boolean(url) - }) - - await retryAssertion(async () => { - let styles = await fetchStyles(url, '/index.html') - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`flex`) - expect(styles).toContain(candidate`font-bold`) - expect(styles).toContain(candidate`imported`) - }) - - await retryAssertion(async () => { - // Updates are additive and cause new candidates to be added. - await fs.write( - 'project-a/index.html', - html` - - - - -
Hello, world!
- +for (let transformer of ['postcss', 'lightningcss']) { + describe(transformer, () => { + test( + `production build`, + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a `, - ) - - let styles = await fetchStyles(url) - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`flex`) - expect(styles).toContain(candidate`font-bold`) - expect(styles).toContain(candidate`imported`) - expect(styles).toContain(candidate`m-2`) - }) - - await retryAssertion(async () => { - // Manually added `@source`s are watched and trigger a rebuild - await fs.write( - 'project-b/src/index.js', - js` - const className = "[.changed_&]:content-['project-b/src/index.js']" - module.exports = { className } - `, - ) - - let styles = await fetchStyles(url) - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`flex`) - expect(styles).toContain(candidate`font-bold`) - expect(styles).toContain(candidate`imported`) - expect(styles).toContain(candidate`m-2`) - expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) - }) - - await retryAssertion(async () => { - // After updates to the CSS file, all previous candidates should still be in - // the generated CSS - await fs.write( - 'project-a/src/index.css', - css` - ${await fs.read('project-a/src/index.css')} - - .red { - color: red; - } - `, - ) - - let styles = await fetchStyles(url) - expect(styles).toContain(candidate`red`) - expect(styles).toContain(candidate`flex`) - expect(styles).toContain(candidate`imported`) - expect(styles).toContain(candidate`m-2`) - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) - expect(styles).toContain(candidate`font-bold`) - }) - - await retryAssertion(async () => { - // Trigger a partial rebuild for the next test - await fs.write( - 'project-a/index.html', - html` - - - - -
Hello, world!
- - `, - ) - let styles = await fetchStyles(url) - expect(styles).toContain(candidate`m-4`) - }) - - await retryAssertion(async () => { - // Changing an `@imported` CSS file after a partial rebuild also triggers the correct update - await fs.write( - 'project-a/src/imported.css', - css` - .imported-updated { - color: red; - } - `, - ) - - let styles = await fetchStyles(url) - expect(styles).toContain(candidate`red`) - expect(styles).toContain(candidate`flex`) - expect(styles).toContain(candidate`m-2`) - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) - expect(styles).toContain(candidate`font-bold`) - expect(styles).toContain(candidate`imported-updated`) - }) - }, - ) - - // https://github.com/tailwindlabs/tailwindcss/issues/20346 - test( - 'dev mode + symlinked `@source` files pick up changes to the real file', - { - fs: { - 'package.json': json`{}`, - 'pnpm-workspace.yaml': yaml` - # - packages: - - project-a - - packages/* - `, - 'project-a/package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "repro-package": "workspace:*", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''} - "vite": "^7" - } - } - `, - 'project-a/vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'project-a/index.html': html` - - - - -
Hello, world!
- - `, - - // The `@source` points through the `node_modules` symlink that pnpm - // creates for the workspace package. Vite's file watcher ignores - // `node_modules` by default, so the symlinked path can not be watched. - // The real file (in `packages/repro-package`) has to be watched - // instead. - 'project-a/src/index.css': css` - @reference 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - @source '../node_modules/repro-package/source.html'; - `, - 'packages/repro-package/package.json': json` - { - "name": "repro-package", - "private": true, - "version": "1.0.0" - } - `, - 'packages/repro-package/source.html': html` -
- Hello, world! -
- `, - }, - }, - async ({ root, spawn, fs, expect }) => { - let process = await spawn('pnpm vite dev', { - cwd: path.join(root, 'project-a'), - }) - await process.onStdout((m) => m.includes('ready in')) - - let url = '' - await process.onStdout((m) => { - let match = /Local:\s*(http.*)\//.exec(m) - if (match) url = match[1] - return Boolean(url) - }) - - await retryAssertion(async () => { - let styles = await fetchStyles(url, '/index.html') - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`content-['v1']`) - }) - - await retryAssertion(async () => { - // Changes to the real file (behind the symlink) should be picked up - await fs.write( - 'packages/repro-package/source.html', - html` -
- Hello, world! -
- `, - ) - - let styles = await fetchStyles(url) - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`content-['v1']`) - expect(styles).toContain(candidate`content-['v2']`) - }) - }, - ) - - test( - 'watch mode', - { - fs: { - 'package.json': json`{}`, - 'pnpm-workspace.yaml': yaml` - # - packages: - - project-a - `, - 'project-a/package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''} - "vite": "^7" - } - } - `, - 'project-a/vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'project-a/index.html': html` - - - - -
Hello, world!
- - `, - 'project-a/tailwind.config.js': js` - export default { - content: ['../project-b/src/**/*.js'], - } - `, - 'project-a/src/index.css': css` - @reference 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - @import './custom-theme.css'; - @config '../tailwind.config.js'; - @source '../../project-b/src/**/*.html'; - `, - 'project-a/src/custom-theme.css': css` - /* Will be overwritten later */ - @theme { - --color-primary: black; - } - `, - 'project-b/src/index.html': html` -
- `, - 'project-b/src/index.js': js` - const className = "content-['project-b/src/index.js']" - module.exports = { className } - `, - }, - }, - async ({ root, spawn, fs, expect }) => { - let process = await spawn('pnpm vite build --watch', { - cwd: path.join(root, 'project-a'), - }) - await process.onStdout((m) => m.includes('built in')) - - let filename = '' - await retryAssertion(async () => { - let files = await fs.glob('project-a/dist/**/*.css') - expect(files).toHaveLength(1) - filename = files[0][0] - }) - - await fs.expectFileToContain(filename, [ - candidate`underline`, - candidate`flex`, - css` - .text-primary { - color: var(--color-primary); - } - `, - ]) - - await retryAssertion(async () => { - await fs.write( - 'project-a/src/custom-theme.css', - css` - /* Overriding the primary color */ - @theme { - --color-primary: red; - } - `, - ) - - let files = await fs.glob('project-a/dist/**/*.css') - expect(files).toHaveLength(1) - let [, styles] = files[0] - - expect(styles).toContain(css` - .text-primary { - color: var(--color-primary); - } - `) - }) - - await retryAssertion(async () => { - // Updates are additive and cause new candidates to be added. - await fs.write( - 'project-a/index.html', - html` - - - - -
Hello, world!
- - `, - ) - - let files = await fs.glob('project-a/dist/**/*.css') - expect(files).toHaveLength(1) - let [, styles] = files[0] - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`flex`) - expect(styles).toContain(candidate`m-2`) - }) - - await retryAssertion(async () => { - // Manually added `@source`s are watched and trigger a rebuild - await fs.write( - 'project-b/src/index.js', - js` - const className = "[.changed_&]:content-['project-b/src/index.js']" - module.exports = { className } - `, - ) - - let files = await fs.glob('project-a/dist/**/*.css') - expect(files).toHaveLength(1) - let [, styles] = files[0] - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`flex`) - expect(styles).toContain(candidate`m-2`) - expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) - }) - - await retryAssertion(async () => { - // After updates to the CSS file, all previous candidates should still be in - // the generated CSS - await fs.write( - 'project-a/src/index.css', - css` - ${await fs.read('project-a/src/index.css')} - - .red { - color: red; - } - `, - ) - - let files = await fs.glob('project-a/dist/**/*.css') - expect(files).toHaveLength(1) - let [, styles] = files[0] - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`flex`) - expect(styles).toContain(candidate`m-2`) - expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) - expect(styles).toContain(candidate`red`) - }) - }, - ) - - describe.each([['^6'], ['7.0.8'], ['7.1.12'], ['7.3.1'], ['8.0.0']])( - 'Using Vite %s', - (version) => { - test( - 'external source file changes update the CSS', - { - fs: { - 'package.json': json`{}`, - 'pnpm-workspace.yaml': yaml` - # - packages: - - project-a - `, - 'project-a/package.json': json` + 'project-a/package.json': txt` { "type": "module", "dependencies": { @@ -601,673 +34,663 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { "tailwindcss": "workspace:^" }, "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''} - "vite": "${version}" + ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} + "vite": "^6" } } `, - 'project-a/vite.config.ts': ts` - import fs from 'node:fs' - import path from 'node:path' - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' + 'project-a/vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' - export default defineConfig({ - css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, - build: { cssMinify: false }, - plugins: [tailwindcss()], - logLevel: 'info', - }) - `, - 'project-a/index.html': html` - - - - - -
- - - - `, - 'project-a/src/main.ts': jsx`import { classes } from './app'`, - 'project-a/src/app.ts': jsx`export let classes = "content-['project-a/src/app.ts']"`, - 'project-a/src/index.css': css` - @import 'tailwindcss'; - @source '../../project-b/**/*.php'; - `, - 'project-b/src/index.php': html` -
- `, - }, - }, - async ({ root, spawn, fs, expect }) => { - let process = await spawn('pnpm vite dev --debug hmr', { - cwd: path.join(root, 'project-a'), - }) - await process.onStdout((m) => m.includes('ready in')) - - let url = '' - await process.onStdout((m) => { - let match = /Local:\s*(http.*)\//.exec(m) - if (match) url = match[1] - return Boolean(url) - }) - - await retryAssertion(async () => { - let styles = await fetchStyles(url, '/index.html') - expect(styles).toContain(candidate`content-['project-b/src/index.php']`) - }) - - // Flush all messages so that we can be sure the next messages are - // from the file changes we're about to make - process.flush() - - // Changing an external .php file hot-updates the generated CSS - { - await fs.write( - 'project-b/src/index.php', - txt`
`, - ) - - // On Vite < 7.1, Vite itself hard-invalidates watched files that - // aren't part of the module graph and reloads the page. - // - // On newer versions nothing reloads the page: the CSS hot-updates - // through the regular pipeline because the changed file is a - // watch dependency of the CSS root. - // - // Reloading the page for external template changes is the - // responsibility of the backend integration (e.g. `laravel-vite-plugin`'s `refresh` option, or `vite-plugin-full-reload`). - // - // https://github.com/tailwindlabs/tailwindcss/issues/20411 - if (version === '^6' || version === '7.0.8') { - await process.onStdout((m) => m.includes('page reload') && m.includes('index.php')) - } else { - await process.onStdout((m) => m.includes('hmr update') && m.includes('index.css')) - } - - // Ensure the styles were regenerated with the new content - let styles = await fetchStyles(url, '/index.html') - expect(styles).toContain(candidate`content-['updated:project-b/src/index.php']`) - } - }, - ) - }, - ) - - // https://github.com/tailwindlabs/tailwindcss/issues/20320 - // https://github.com/tailwindlabs/tailwindcss/issues/19903 - test( - 'editing scanned files that Vite can process as modules does not trigger a full reload', - { - fs: { - 'package.json': json`{}`, - 'pnpm-workspace.yaml': yaml` - # - packages: - - project-a - `, - 'project-a/package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''} - "vite": "^8" - } - } - `, - 'project-a/vite.config.ts': ts` - import fs from 'node:fs' - import path from 'node:path' - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, - build: { cssMinify: false }, - plugins: [ - tailwindcss(), - { - // A plugin that processes a custom file type into a JS - // module, similar to e.g. \`.vue\` or \`.svelte\` files - name: 'custom-file-type', - transform(code, id) { - if (id.endsWith('.custom')) { - return { code: 'export default ' + JSON.stringify(code), map: null } - } - }, - }, - { - // Log all HMR payloads to a file so the test can assert on - // them - name: 'hmr-wiretap', - configureServer(server) { - let logFile = path.resolve('hmr.log') - fs.writeFileSync(logFile, '') - for (let environment of Object.values(server.environments)) { - let send = environment.hot.send.bind(environment.hot) - environment.hot.send = (payload) => { - fs.appendFileSync(logFile, JSON.stringify(payload) + '\\n') - return send(payload) - } - } - }, - }, - ], - }) - `, - 'project-a/index.html': html` - + export default defineConfig({ + css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, + build: { cssMinify: false }, + plugins: [tailwindcss()], + }) + `, + 'project-a/index.html': html` -
- +
Hello, world!
- - `, - 'project-a/src/main.ts': ts` - import compA from './comp-a.custom' - import snippet from './snippet.php?raw' - console.log(compA, snippet) - `, - 'project-a/src/snippet.php': html` -
- `, - 'project-a/src/comp-a.custom': html` -
- `, - 'project-a/src/comp-b.custom': html` -
- `, - 'project-a/src/lazy.tsx': jsx` - export default function Lazy() { - return
- } - `, - 'project-a/src/unimported.css': css` - .unimported { - color: red; - } - `, - 'project-a/src/index.css': css` - @import 'tailwindcss'; - @source '../../project-b/**/*.php'; - `, - 'project-b/src/index.php': html` -
- `, - }, - }, - async ({ root, spawn, fs, expect }) => { - let process = await spawn('pnpm vite dev', { - cwd: path.join(root, 'project-a'), - }) - await process.onStdout((m) => m.includes('ready in')) - - let url = '' - await process.onStdout((m) => { - let match = /Local:\s*(http.*)\//.exec(m) - if (match) url = match[1] - return Boolean(url) - }) - - await retryAssertion(async () => { - let styles = await fetchStyles(url, '/index.html') - expect(styles).toContain(candidate`content-['src/lazy.tsx']`) - expect(styles).toContain(candidate`content-['src/comp-b.custom']`) - expect(styles).toContain(candidate`content-['project-b/src/index.php']`) - }) - - // Load `main.ts`, `comp-a.custom`, and `snippet.php?raw` as real - // modules, like a browser visiting the page would - await fetch(`${url}/src/main.ts`) - await fetch(`${url}/src/comp-a.custom?import`) - await fetch(`${url}/src/snippet.php?raw`) - - // Changing a scanned `.tsx` file that is not part of the module graph - // (e.g. a lazily-loaded route that hasn't been visited yet) should not - // trigger a full reload, but new classes should still apply - // - // https://github.com/tailwindlabs/tailwindcss/issues/20320 - { - await fs.write( - 'project-a/src/lazy.tsx', - jsx` - export default function Lazy() { - return
+ `, + 'project-a/tailwind.config.js': js` + export default { + content: ['../project-b/src/**/*.js'], } `, - ) + 'project-a/src/index.css': css` + @import 'tailwindcss/theme' theme(reference); + @import 'tailwindcss/utilities'; + @config '../tailwind.config.js'; + @source '../../project-b/src/**/*.html'; + `, + 'project-b/src/index.html': html` +
+ `, + 'project-b/src/index.js': js` + const className = "content-['project-b/src/index.js']" + module.exports = { className } + `, + }, + }, + async ({ root, fs, exec }) => { + await exec('pnpm vite build', { cwd: path.join(root, 'project-a') }) + + let files = await fs.glob('project-a/dist/**/*.css') + expect(files).toHaveLength(1) + let [filename] = files[0] + + await fs.expectFileToContain(filename, [ + candidate`underline`, + candidate`m-2`, + candidate`flex`, + candidate`content-['project-b/src/index.js']`, + ]) + }, + ) + + test( + `dev mode`, + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} + "vite": "^6" + } + } + `, + 'project-a/vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' + + export default defineConfig({ + css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, + build: { cssMinify: false }, + plugins: [tailwindcss()], + }) + `, + 'project-a/index.html': html` + + + + +
Hello, world!
+ + `, + 'project-a/about.html': html` + + + + +
Tailwind Labs
+ + `, + 'project-a/tailwind.config.js': js` + export default { + content: ['../project-b/src/**/*.js'], + } + `, + 'project-a/src/index.css': css` + @import 'tailwindcss/theme' theme(reference); + @import 'tailwindcss/utilities'; + @config '../tailwind.config.js'; + @source '../../project-b/src/**/*.html'; + `, + 'project-b/src/index.html': html` +
+ `, + 'project-b/src/index.js': js` + const className = "content-['project-b/src/index.js']" + module.exports = { className } + `, + }, + }, + async ({ root, spawn, getFreePort, fs }) => { + let port = await getFreePort() + await spawn(`pnpm vite dev --port ${port}`, { + cwd: path.join(root, 'project-a'), + }) + + // Candidates are resolved lazily, so the first visit of index.html + // will only have candidates from this file. + await retryAssertion(async () => { + let styles = await fetchStyles(port, '/index.html') + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + expect(styles).not.toContain(candidate`font-bold`) + }) + + // Going to about.html will extend the candidate list to include + // candidates from about.html. + await retryAssertion(async () => { + let styles = await fetchStyles(port, '/about.html') + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`font-bold`) + }) await retryAssertion(async () => { - let styles = await fetchStyles(url, '/index.html') - expect(styles).toContain(candidate`content-['updated:src/lazy.tsx']`) - }) - } + // Updates are additive and cause new candidates to be added. + await fs.write( + 'project-a/index.html', + html` + + + + +
Hello, world!
+ + `, + ) - // The same holds for a custom file type as long as some file of the - // same type was processed as a module before - { - await fs.write( - 'project-a/src/comp-b.custom', - html`
`, - ) + let styles = await fetchStyles(port) + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`font-bold`) + expect(styles).toContain(candidate`m-2`) + }) await retryAssertion(async () => { - let styles = await fetchStyles(url, '/index.html') - expect(styles).toContain(candidate`content-['updated:src/comp-b.custom']`) + // Manually added `@source`s are watched and trigger a rebuild + await fs.write( + 'project-b/src/index.js', + js` + const className = "[.changed_&]:content-['project-b/src/index.js']" + module.exports = { className } + `, + ) + + let styles = await fetchStyles(port) + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`font-bold`) + expect(styles).toContain(candidate`m-2`) + expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) }) - } - // Changing a scanned stylesheet that is not part of the module graph - // (e.g. a component stylesheet that a framework plugin compiles into - // the component) should not trigger a full reload either - // - // https://github.com/tailwindlabs/tailwindcss/issues/19903 + await retryAssertion(async () => { + // After updates to the CSS file, all previous candidates should still be in + // the generated CSS + await fs.write( + 'project-a/src/index.css', + css` + ${await fs.read('project-a/src/index.css')} + + .red { + color: red; + } + `, + ) + + let styles = await fetchStyles(port) + expect(styles).toContain(candidate`red`) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`m-2`) + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) + expect(styles).toContain(candidate`font-bold`) + }) + }, + ) + + test( + 'watch mode', { - let updates = (await fs.read('project-a/hmr.log')).split('"type":"update"').length + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} + "vite": "^6" + } + } + `, + 'project-a/vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' - await fs.write( - 'project-a/src/unimported.css', + export default defineConfig({ + build: { cssMinify: false }, + plugins: [tailwindcss()], + }) + `, + 'project-a/index.html': html` + + + + +
Hello, world!
+ + `, + 'project-a/tailwind.config.js': js` + export default { + content: ['../project-b/src/**/*.js'], + } + `, + 'project-a/src/index.css': css` + @import 'tailwindcss/theme' theme(reference); + @import 'tailwindcss/utilities'; + @import './custom-theme.css'; + @config '../tailwind.config.js'; + @source '../../project-b/src/**/*.html'; + `, + 'project-a/src/custom-theme.css': css` + /* Will be overwritten later */ + @theme { + --color-primary: black; + } + `, + 'project-b/src/index.html': html` +
+ `, + 'project-b/src/index.js': js` + const className = "content-['project-b/src/index.js']" + module.exports = { className } + `, + }, + }, + async ({ root, spawn, fs }) => { + await spawn(`pnpm vite build --watch`, { + cwd: path.join(root, 'project-a'), + }) + + let filename = '' + await retryAssertion(async () => { + let files = await fs.glob('project-a/dist/**/*.css') + expect(files).toHaveLength(1) + filename = files[0][0] + }) + + await fs.expectFileToContain(filename, [ + candidate`underline`, + candidate`flex`, css` - .unimported { - color: blue; + .text-primary { + color: var(--color-primary); } `, - ) + ]) - // Wait until the change was handled and an update was pushed await retryAssertion(async () => { - let log = await fs.read('project-a/hmr.log') - expect(log.split('"type":"update"').length).toBeGreaterThan(updates) - }) - } + await fs.write( + 'project-a/src/custom-theme.css', + css` + /* Overriding the primary color */ + @theme { + --color-primary: red; + } + `, + ) - // Changing an external file (e.g. a PHP template) hot-updates the - // generated CSS but does not trigger a full reload either. Reloading the - // page for external template changes is the responsibility of the backend - // integration (e.g. `laravel-vite-plugin`'s `refresh` option, or - // `vite-plugin-full-reload`). - // - // https://github.com/tailwindlabs/tailwindcss/issues/20411 + let files = await fs.glob('project-a/dist/**/*.css') + expect(files).toHaveLength(1) + let [, styles] = files[0] + + expect(styles).toContain(css` + .text-primary { + color: var(--color-primary); + } + `) + }) + + await retryAssertion(async () => { + // Updates are additive and cause new candidates to be added. + await fs.write( + 'project-a/index.html', + html` + + + + +
Hello, world!
+ + `, + ) + + let files = await fs.glob('project-a/dist/**/*.css') + expect(files).toHaveLength(1) + let [, styles] = files[0] + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`m-2`) + }) + + await retryAssertion(async () => { + // Manually added `@source`s are watched and trigger a rebuild + await fs.write( + 'project-b/src/index.js', + js` + const className = "[.changed_&]:content-['project-b/src/index.js']" + module.exports = { className } + `, + ) + + let files = await fs.glob('project-a/dist/**/*.css') + expect(files).toHaveLength(1) + let [, styles] = files[0] + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`m-2`) + expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) + }) + + await retryAssertion(async () => { + // After updates to the CSS file, all previous candidates should still be in + // the generated CSS + await fs.write( + 'project-a/src/index.css', + css` + ${await fs.read('project-a/src/index.css')} + + .red { + color: red; + } + `, + ) + + let files = await fs.glob('project-a/dist/**/*.css') + expect(files).toHaveLength(1) + let [, styles] = files[0] + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`m-2`) + expect(styles).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`) + expect(styles).toContain(candidate`red`) + }) + }, + ) + + test( + `source(none) disables looking at the module graph`, { - let updates = (await fs.read('project-a/hmr.log')).split('"type":"update"').length - - await fs.write( - 'project-b/src/index.php', - html`
`, - ) - - await retryAssertion(async () => { - let log = await fs.read('project-a/hmr.log') - expect(log.split('"type":"update"').length).toBeGreaterThan(updates) - }) - - let styles = await fetchStyles(url, '/index.html') - expect(styles).toContain(candidate`content-['updated:project-b/src/index.php']`) - } - }, - ) - - test( - `source(none) disables looking at the module graph`, - { - fs: { - 'package.json': json`{}`, - 'pnpm-workspace.yaml': yaml` - # - packages: - - project-a - `, - 'project-a/package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''} - "vite": "^7" + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} + "vite": "^6" + } } - } - `, - 'project-a/vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' + `, + 'project-a/vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' - export default defineConfig({ - css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'project-a/index.html': html` - - - - -
Hello, world!
- - `, - 'project-a/src/index.css': css` - @import 'tailwindcss' source(none); - @source '../../project-b/src/**/*.html'; - `, - 'project-b/src/index.html': html` -
- `, - 'project-b/src/index.js': js` - const className = "content-['project-b/src/index.js']" - module.exports = { className } - `, + export default defineConfig({ + css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, + build: { cssMinify: false }, + plugins: [tailwindcss()], + }) + `, + 'project-a/index.html': html` + + + + +
Hello, world!
+ + `, + 'project-a/src/index.css': css` + @import 'tailwindcss' source(none); + @source '../../project-b/src/**/*.html'; + `, + 'project-b/src/index.html': html` +
+ `, + 'project-b/src/index.js': js` + const className = "content-['project-b/src/index.js']" + module.exports = { className } + `, + }, }, - }, - async ({ root, fs, exec, expect }) => { - await exec('pnpm vite build', { cwd: path.join(root, 'project-a') }) + async ({ root, fs, exec }) => { + await exec('pnpm vite build', { cwd: path.join(root, 'project-a') }) - let files = await fs.glob('project-a/dist/**/*.css') - expect(files).toHaveLength(1) - let [filename] = files[0] + let files = await fs.glob('project-a/dist/**/*.css') + expect(files).toHaveLength(1) + let [filename] = files[0] - // `underline` and `m-2` are only present from files in the module graph - // which we've explicitly disabled with source(none) so they should not - // be present - await fs.expectFileNotToContain(filename, [ - // - candidate`underline`, - candidate`m-2`, - ]) + // `underline` and `m-2` are only present from files in the module graph + // which we've explicitly disabled with source(none) so they should not + // be present + await fs.expectFileNotToContain(filename, [ + // + candidate`underline`, + candidate`m-2`, + ]) - // The files from `project-b` should be included because there is an - // explicit `@source` directive for it - await fs.expectFileToContain(filename, [ - // - candidate`flex`, - ]) + // The files from `project-b` should be included because there is an + // explicit `@source` directive for it + await fs.expectFileToContain(filename, [ + // + candidate`flex`, + ]) - // The explicit source directive only covers HTML files, so the JS file - // should not be included - await fs.expectFileNotToContain(filename, [ - // - candidate`content-['project-b/src/index.js']`, - ]) - }, - ) + // The explicit source directive only covers HTML files, so the JS file + // should not be included + await fs.expectFileNotToContain(filename, [ + // + candidate`content-['project-b/src/index.js']`, + ]) + }, + ) - test( - `source("…") filters the module graph`, - { - fs: { - 'package.json': json`{}`, - 'pnpm-workspace.yaml': yaml` - # - packages: - - project-a - `, - 'project-a/package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''} - "vite": "^7" + test( + `source("…") filters the module graph`, + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} + "vite": "^6" + } } - } - `, - 'project-a/vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' + `, + 'project-a/vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' - export default defineConfig({ - css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'project-a/index.html': html` - - - - -
Hello, world!
- - - `, - 'project-a/app/index.js': js` - const className = "content-['project-a/app/index.js']" - export default { className } - `, - 'project-a/src/index.css': css` - @import 'tailwindcss' source('../app'); - @source '../../project-b/src/**/*.html'; - `, - 'project-b/src/index.html': html` -
- `, - 'project-b/src/index.js': js` - const className = "content-['project-b/src/index.js']" - module.exports = { className } - `, + export default defineConfig({ + css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, + build: { cssMinify: false }, + plugins: [tailwindcss()], + }) + `, + 'project-a/index.html': html` + + + + +
Hello, world!
+ + + `, + 'project-a/app/index.js': js` + const className = "content-['project-a/app/index.js']" + export default { className } + `, + 'project-a/src/index.css': css` + @import 'tailwindcss' source('../app'); + @source '../../project-b/src/**/*.html'; + `, + 'project-b/src/index.html': html` +
+ `, + 'project-b/src/index.js': js` + const className = "content-['project-b/src/index.js']" + module.exports = { className } + `, + }, }, - }, - async ({ root, fs, exec, expect }) => { - await exec('pnpm vite build', { cwd: path.join(root, 'project-a') }) + async ({ root, fs, exec }) => { + await exec('pnpm vite build', { cwd: path.join(root, 'project-a') }) - let files = await fs.glob('project-a/dist/**/*.css') - expect(files).toHaveLength(1) - let [filename] = files[0] + let files = await fs.glob('project-a/dist/**/*.css') + expect(files).toHaveLength(1) + let [filename] = files[0] - // `underline` and `m-2` are present in files in the module graph but - // we've filtered the module graph such that we only look in - // `./app/**/*` so they should not be present - await fs.expectFileNotToContain(filename, [ - // - candidate`underline`, - candidate`m-2`, - candidate`content-['project-a/index.html']`, - ]) + // `underline` and `m-2` are present in files in the module graph but + // we've filtered the module graph such that we only look in + // `./app/**/*` so they should not be present + await fs.expectFileNotToContain(filename, [ + // + candidate`underline`, + candidate`m-2`, + candidate`content-['project-a/index.html']`, + ]) - // We've filtered the module graph to only look in ./app/**/* so the - // candidates from that project should be present - await fs.expectFileToContain(filename, [ - // - candidate`content-['project-a/app/index.js']`, - ]) + // We've filtered the module graph to only look in ./app/**/* so the + // candidates from that project should be present + await fs.expectFileToContain(filename, [ + // + candidate`content-['project-a/app/index.js']`, + ]) - // Even through we're filtering the module graph explicit sources are - // additive and as such files from `project-b` should be included - // because there is an explicit `@source` directive for it - await fs.expectFileToContain(filename, [ - // - candidate`content-['project-b/src/index.html']`, - ]) + // Even through we're filtering the module graph explicit sources are + // additive and as such files from `project-b` should be included + // because there is an explicit `@source` directive for it + await fs.expectFileToContain(filename, [ + // + candidate`content-['project-b/src/index.html']`, + ]) - // The explicit source directive only covers HTML files, so the JS file - // should not be included - await fs.expectFileNotToContain(filename, [ - // - candidate`content-['project-b/src/index.js']`, - ]) - }, - ) + // The explicit source directive only covers HTML files, so the JS file + // should not be included + await fs.expectFileNotToContain(filename, [ + // + candidate`content-['project-b/src/index.js']`, + ]) + }, + ) - test( - `source("…") must be a directory`, - { - fs: { - 'package.json': json`{}`, - 'pnpm-workspace.yaml': yaml` - # - packages: - - project-a - `, - 'project-a/package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''} - "vite": "^7" + test( + `source("…") must be a directory`, + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''} + "vite": "^6" + } } - } - `, - 'project-a/vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' + `, + 'project-a/vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' - export default defineConfig({ - css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'project-a/index.html': html` - - - - -
Hello, world!
- - - `, - 'project-a/app/index.js': js` - const className = "content-['project-a/app/index.js']" - export default { className } - `, - 'project-a/src/index.css': css` - @import 'tailwindcss' source('../i-do-not-exist'); - @source '../../project-b/src/**/*.html'; - `, - 'project-b/src/index.html': html` -
- `, - 'project-b/src/index.js': js` - const className = "content-['project-b/src/index.js']" - module.exports = { className } - `, + export default defineConfig({ + css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, + build: { cssMinify: false }, + plugins: [tailwindcss()], + }) + `, + 'project-a/index.html': html` + + + + +
Hello, world!
+ + + `, + 'project-a/app/index.js': js` + const className = "content-['project-a/app/index.js']" + export default { className } + `, + 'project-a/src/index.css': css` + @import 'tailwindcss' source('../i-do-not-exist'); + @source '../../project-b/src/**/*.html'; + `, + 'project-b/src/index.html': html` +
+ `, + 'project-b/src/index.js': js` + const className = "content-['project-b/src/index.js']" + module.exports = { className } + `, + }, }, - }, - async ({ root, fs, exec, expect }) => { - await expect(() => - exec('pnpm vite build', { cwd: path.join(root, 'project-a') }, { ignoreStdErr: true }), - ).rejects.toThrow('The `source(../i-do-not-exist)` does not exist') + async ({ root, fs, exec }) => { + await expect(() => + exec('pnpm vite build', { cwd: path.join(root, 'project-a') }, { ignoreStdErr: true }), + ).rejects.toThrowError('The `source(../i-do-not-exist)` does not exist') - let files = await fs.glob('project-a/dist/**/*.css') - expect(files).toHaveLength(0) - }, - ) - - test( - 'source(…) and `@source` are relative to the file they are in', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''} - "vite": "^7" - } - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'index.html': html` - - - - - `, - 'index.css': css` @import './project-a/src/index.css'; `, - - 'project-a/src/index.css': css` - /* Run auto-content detection in ../../project-b */ - @import 'tailwindcss/utilities' source('../../project-b'); - - /* Explicitly using node_modules in the @source allows git ignored folders */ - @source '../../project-c'; - `, - - // Project A is the current folder, but we explicitly configured - // `source(project-b)`, therefore project-a should not be included in - // the output. - 'project-a/src/index.html': html` -
- `, - - // Project B is the configured `source(…)`, therefore auto source - // detection should include known extensions and folders in the output. - 'project-b/src/index.html': html` -
- `, - - // Project C should apply auto source detection, therefore known - // extensions and folders should be included in the output. - 'project-c/src/index.html': html` -
- `, + let files = await fs.glob('project-a/dist/**/*.css') + expect(files).toHaveLength(0) }, - }, - async ({ fs, exec, spawn, root, expect }) => { - await exec('pnpm vite build', { cwd: root }) - - let content = await fs.dumpFiles('./dist/assets/*.css') - - expect(content).not.toContain(candidate`content-['project-a/src/index.html']`) - expect(content).toContain(candidate`content-['project-b/src/index.html']`) - expect(content).toContain(candidate`content-['project-c/src/index.html']`) - }, - ) -}) + ) + }) +} test( - `demote Tailwind roots to regular CSS files and back to Tailwind roots`, + `demote Tailwind roots to regular CSS files and back to Tailwind roots while restoring all candidates`, { fs: { 'package.json': json` @@ -1278,7 +701,7 @@ test( "tailwindcss": "workspace:^" }, "devDependencies": { - "vite": "^7" + "vite": "^6" } } `, @@ -1310,19 +733,22 @@ test( 'src/index.css': css`@import 'tailwindcss';`, }, }, - async ({ spawn, fs, expect }) => { - let process = await spawn('pnpm vite dev') - await process.onStdout((m) => m.includes('ready in')) + async ({ spawn, getFreePort, fs }) => { + let port = await getFreePort() + await spawn(`pnpm vite dev --port ${port}`) - let url = '' - await process.onStdout((m) => { - let match = /Local:\s*(http.*)\//.exec(m) - if (match) url = match[1] - return Boolean(url) + // Candidates are resolved lazily, so the first visit of index.html + // will only have candidates from this file. + await retryAssertion(async () => { + let styles = await fetchStyles(port, '/index.html') + expect(styles).toContain(candidate`underline`) + expect(styles).not.toContain(candidate`font-bold`) }) + // Going to about.html will extend the candidate list to include + // candidates from about.html. await retryAssertion(async () => { - let styles = await fetchStyles(url, '/index.html') + let styles = await fetchStyles(port, '/about.html') expect(styles).toContain(candidate`underline`) expect(styles).toContain(candidate`font-bold`) }) @@ -1331,7 +757,7 @@ test( // We change the CSS file so it is no longer a valid Tailwind root. await fs.write('src/index.css', css`@import 'tailwindcss';`) - let styles = await fetchStyles(url) + let styles = await fetchStyles(port) expect(styles).toContain(candidate`underline`) expect(styles).toContain(candidate`font-bold`) }) @@ -1350,7 +776,7 @@ test( "tailwindcss": "workspace:^" }, "devDependencies": { - "vite": "^7" + "vite": "^6" } } `, @@ -1375,25 +801,18 @@ test( 'src/index.css': css`@import 'tailwindcss';`, }, }, - async ({ spawn, expect }) => { - let process = await spawn('pnpm vite dev') - await process.onStdout((m) => m.includes('ready in')) - - let baseUrl = '' - await process.onStdout((m) => { - let match = /Local:\s*(http.*)\//.exec(m) - if (match) baseUrl = match[1] - return Boolean(baseUrl) - }) + async ({ spawn, getFreePort }) => { + let port = await getFreePort() + await spawn(`pnpm vite dev --port ${port}`) await retryAssertion(async () => { // We have to load the .js file first so that the static assets are // resolved - await fetch(`${baseUrl}/src/index.js`).then((r) => r.text()) + await fetch(`http://localhost:${port}/src/index.js`).then((r) => r.text()) let [raw, url] = await Promise.all([ - fetch(`${baseUrl}/src/index.css?raw`).then((r) => r.text()), - fetch(`${baseUrl}/src/index.css?url`).then((r) => r.text()), + fetch(`http://localhost:${port}/src/index.css?raw`).then((r) => r.text()), + fetch(`http://localhost:${port}/src/index.css?url`).then((r) => r.text()), ]) expect(firstLine(raw)).toBe(`export default "@import 'tailwindcss';"`) @@ -1402,325 +821,6 @@ test( }, ) -test( - `does not interfere with ?commonjs-proxy modules`, - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^", - "plotly.js": "^3", - "vite": "^7" - } - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'index.html': html` - - - - `, - 'src/index.js': js`import Plotly from 'plotly.js/lib/core'`, - - // `plotly.js` pulls in `es5-ext`, which has a build script. As of pnpm - // v10, build scripts only run when explicitly allowed. - 'pnpm-workspace.yaml': yaml` - # - allowBuilds: - es5-ext: true - `, - }, - }, - async ({ exec, expect, fs }) => { - await exec('pnpm vite build') - - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - let [filename] = files[0] - - await fs.expectFileToContain(filename, [candidate`maplibregl-map`]) - }, -) - function firstLine(str: string) { return str.split('\n')[0] } - -test( - 'optimize option: disabled', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "^7" - } - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - build: { cssMinify: false }, - plugins: [tailwindcss({ optimize: false })], - }) - `, - 'index.html': html` - - - - -
Hello, world!
- - `, - 'src/index.css': css` - @reference 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - `, - }, - }, - async ({ exec, expect, fs }) => { - await exec('pnpm vite build') - - // Should not be minified when optimize is disabled - expect((await fs.dumpFiles('dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-.css')) - .toMatchInlineSnapshot(` - " - --- dist/assets/index-.css --- - .focus\\:text-\\[black\\]:focus { - color: black; - } - " - `) - }, -) - -test( - 'optimize option: enabled with minify disabled', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "^7" - } - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - build: { cssMinify: false }, - plugins: [tailwindcss({ optimize: { minify: false } })], - }) - `, - 'index.html': html` - - - - -
Hello, world!
- - `, - 'src/index.css': css` - @reference 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - `, - }, - }, - async ({ exec, expect, fs }) => { - await exec('pnpm vite build') - - expect((await fs.dumpFiles('dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-.css')) - .toMatchInlineSnapshot(` - " - --- dist/assets/index-.css --- - @media (hover: hover) { - .hover\\:text-\\[black\\]:hover { - color: #000; - } - } - " - `) - }, -) - -test( - `the plugin works when using the environment API`, - { - fs: { - 'package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "^7" - } - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - plugins: [tailwindcss()], - builder: {}, - environments: { - server: { - build: { - cssMinify: false, - emitAssets: true, - rollupOptions: { input: './src/server.ts' }, - }, - }, - }, - }) - `, - // Has to exist or the build fails - 'index.html': html` -
- `, - 'src/server.ts': js` - // Import the stylesheet in the server build - import a from './index.css?url' - console.log(a) - `, - 'src/index.css': css` - @reference 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - `, - }, - }, - async ({ root, fs, exec, expect }) => { - await exec('pnpm vite build', { cwd: root }) - - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - let [filename] = files[0] - - await fs.expectFileToContain(filename, [candidate`content-['index.html']`]) - }, -) - -// https://github.com/tailwindlabs/tailwindcss/issues/17532 -test( - 'deleting a file should not crash Vite', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "6.2.5" - } - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - plugins: [tailwindcss()], - }) - `, - 'index.html': html` - -
- - - `, - 'src/index.css': css`@import 'tailwindcss';`, - 'src/main.js': js` - import iconUrl from './assets/icon.svg?url' - import './index.css' - - document.querySelector('#app').innerHTML = iconUrl - `, - 'src/assets/icon.svg': html` - - - - `, - }, - }, - async ({ fs, spawn, expect }) => { - let process = await spawn('pnpm vite dev') - await process.onStdout((m) => m.includes('ready in')) - - let url = '' - await process.onStdout((m) => { - let match = /Local:\s*(http.*)\//.exec(m) - if (match) url = match[1] - return Boolean(url) - }) - - await retryAssertion(async () => { - let [main, css] = await Promise.all([ - fetch(`${url}/src/main.js`).then((r) => r.text()), - fetch(`${url}/src/index.css`).then((r) => r.text()), - ]) - - expect(main).toContain('/src/assets/icon.svg?import&url') - expect(main).toContain('/src/index.css') - expect(css).toContain('.fill-red-500') - }) - - process.flush() - - let unexpectedError = process.onStderr((m) => { - return /error|crash|panic/i.test(m) - }) - - await fs.write( - 'src/main.js', - js` - import './index.css' - document.querySelector('#app').innerHTML = 'Hello World' - `, - ) - - await fs.delete('src/assets/icon.svg') - - let result = await Promise.race([ - unexpectedError.then(() => 'stderr'), - new Promise((resolve) => setTimeout(() => resolve('timeout'), 500)), - ]) - - expect(result).toBe('timeout') - - await retryAssertion(async () => { - let response = await fetch(`${url}/src/index.css?t=${Date.now()}`) - expect(response.status).toBe(200) - }) - }, -) diff --git a/integrations/vite/multi-root.test.ts b/integrations/vite/multi-root.test.ts index 4020aff0f..d2d59f50e 100644 --- a/integrations/vite/multi-root.test.ts +++ b/integrations/vite/multi-root.test.ts @@ -1,3 +1,4 @@ +import { expect } from 'vitest' import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils' test( @@ -12,7 +13,7 @@ test( "tailwindcss": "workspace:^" }, "devDependencies": { - "vite": "^7" + "vite": "^6" } } `, @@ -43,12 +44,12 @@ test( `, 'src/shared.css': css` - @reference 'tailwindcss/theme'; + @import 'tailwindcss/theme' theme(reference); @import 'tailwindcss/utilities'; `, 'src/root1.css': css` @import './shared.css'; - @custom-variant one (&:is([data-root='1'])); + @variant one (&:is([data-root='1'])); `, 'root2.html': html` @@ -60,11 +61,11 @@ test( `, 'src/root2.css': css` @import './shared.css'; - @custom-variant two (&:is([data-root='2'])); + @variant two (&:is([data-root='2'])); `, }, }, - async ({ fs, exec, expect }) => { + async ({ fs, exec }) => { await exec('pnpm vite build') let files = await fs.glob('dist/**/*.css') @@ -85,7 +86,7 @@ test( ) test( - 'dev mode', + `dev mode`, { fs: { 'package.json': json` @@ -96,7 +97,7 @@ test( "tailwindcss": "workspace:^" }, "devDependencies": { - "vite": "^7" + "vite": "^6" } } `, @@ -119,12 +120,12 @@ test( `, 'src/shared.css': css` - @reference 'tailwindcss/theme'; + @import 'tailwindcss/theme' theme(reference); @import 'tailwindcss/utilities'; `, 'src/root1.css': css` @import './shared.css'; - @custom-variant one (&:is([data-root='1'])); + @variant one (&:is([data-root='1'])); `, 'root2.html': html` @@ -136,25 +137,18 @@ test( `, 'src/root2.css': css` @import './shared.css'; - @custom-variant two (&:is([data-root='2'])); + @variant two (&:is([data-root='2'])); `, }, }, - async ({ spawn, expect }) => { - let process = await spawn('pnpm vite dev') - await process.onStdout((m) => m.includes('ready in')) - - let url = '' - await process.onStdout((m) => { - let match = /Local:\s*(http.*)\//.exec(m) - if (match) url = match[1] - return Boolean(url) - }) + async ({ root, spawn, getFreePort, fs }) => { + let port = await getFreePort() + await spawn(`pnpm vite dev --port ${port}`) // Candidates are resolved lazily, so the first visit of index.html // will only have candidates from this file. await retryAssertion(async () => { - let styles = await fetchStyles(url, '/root1.html') + let styles = await fetchStyles(port, '/root1.html') expect(styles).toContain(candidate`one:underline`) expect(styles).not.toContain(candidate`two:underline`) }) @@ -162,7 +156,7 @@ test( // Going to about.html will extend the candidate list to include // candidates from about.html. await retryAssertion(async () => { - let styles = await fetchStyles(url, '/root2.html') + let styles = await fetchStyles(port, '/root2.html') expect(styles).not.toContain(candidate`one:underline`) expect(styles).toContain(candidate`two:underline`) }) diff --git a/integrations/vite/nuxt.test.ts b/integrations/vite/nuxt.test.ts index 20f367020..cfd521e48 100644 --- a/integrations/vite/nuxt.test.ts +++ b/integrations/vite/nuxt.test.ts @@ -1,15 +1,5 @@ -import { - candidate, - css, - fetchStyles, - getRandomPort, - html, - json, - retryAssertion, - test, - ts, - yaml, -} from '../utils' +import { expect } from 'vitest' +import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils' const SETUP = { fs: { @@ -18,18 +8,12 @@ const SETUP = { "type": "module", "dependencies": { "@tailwindcss/vite": "workspace:^", - "nuxt": "3.16.0", - "nitropack": "2.11.0", + "nuxt": "^3.13.1", "tailwindcss": "workspace:^", "vue": "latest" } } `, - 'pnpm-workspace.yaml': yaml` - # - overrides: - nuxi: 3.28.0 - `, 'nuxt.config.ts': ts` import tailwindcss from '@tailwindcss/vite' @@ -53,25 +37,12 @@ const SETUP = { }, } -test('dev mode', SETUP, async ({ fs, spawn, expect }) => { - let process = await spawn('pnpm nuxt dev', { - env: { - TEST: 'false', // VERY IMPORTANT OTHERWISE YOU WON'T GET OUTPUT - NODE_ENV: 'development', - }, - }) - - let url = '' - await process.onStdout((m) => { - let match = /Local:\s*(http.*)\//.exec(m) - if (match) url = match[1] - return Boolean(url) - }) - - await process.onStdout((m) => m.includes('server warmed up in')) +test('dev mode', SETUP, async ({ fs, spawn, getFreePort }) => { + let port = await getFreePort() + await spawn(`pnpm nuxt dev --port ${port}`) await retryAssertion(async () => { - let css = await fetchStyles(url) + let css = await fetchStyles(port) expect(css).toContain(candidate`underline`) }) @@ -79,39 +50,29 @@ test('dev mode', SETUP, async ({ fs, spawn, expect }) => { await fs.write( 'app.vue', html` -