diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 4bf4af1ed..000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @tailwindlabs/engineering 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..4dbbe9cbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,68 +2,40 @@ name: CI on: push: - branches: [main] + branches: [master, next] pull_request: + branches: [master, next] permissions: contents: read -env: - NODE_VERSION: 24 - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - jobs: - tests: + build: + runs-on: ubuntu-latest + timeout-minutes: 15 + 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 - - # Exclude windows and macos from being built on feature branches - run-all: - - ${{ github.ref == 'refs/heads/main' || contains(github.event.pull_request.body, '[ci-all]') || github.event.pull_request.user.login == 'depfu[bot]' }} - exclude: - - run-all: false - runner: - name: Windows - - run-all: false - runner: - name: macOS - - runs-on: ${{ matrix.runner.os }} - timeout-minutes: 30 - - name: ${{ matrix.runner.name }} + node-version: [20] steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@v3 + - uses: pnpm/action-setup@v3 with: - persist-credentials: false - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + version: ^8.15.0 - - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 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@v3 with: path: | + ~/.cargo/bin/ ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ @@ -72,36 +44,23 @@ jobs: # Cache the `oxide` Rust build - name: Cache oxide build - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@v3 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 + ./oxide/target/ + ./oxide/crates/node/*.node + ./oxide/crates/node/index.js + ./oxide/crates/node/index.d.ts + key: ${{ runner.os }}-oxide-${{ hashFiles('./oxide/crates/**/*') }} - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install - name: Build run: pnpm run build - env: - CARGO_PROFILE_RELEASE_LTO: 'off' - CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER: 'lld-link' - name: Lint run: pnpm run lint - # Only lint on linux to avoid \r\n line ending errors - if: matrix.runner.os == 'ubuntu-latest' - name: Test run: pnpm run test @@ -112,13 +71,12 @@ jobs: - 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 + - uses: actions/upload-artifact@v3 + if: always() 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.' + name: playwright-report + path: packages/tailwindcss/playwright-report/ + retention-days: 30 + + - name: Bench + run: pnpm run bench 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 deleted file mode 100644 index 24e829afd..000000000 --- a/.github/workflows/prepare-release.yml +++ /dev/null @@ -1,307 +0,0 @@ -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 - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - strategy: - matrix: - include: - # Windows - - os: windows-latest - target: x86_64-pc-windows-msvc - - os: windows-latest - target: aarch64-pc-windows-msvc - # macOS - - os: macos-latest - target: x86_64-apple-darwin - strip: strip -x # Must use -x on macOS. This produces larger results on linux. - - os: macos-latest - target: aarch64-apple-darwin - page-size: 14 - strip: strip -x # Must use -x on macOS. This produces larger results on linux. - # Android - - os: ubuntu-latest - target: aarch64-linux-android - strip: ${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip - - os: ubuntu-latest - target: armv7-linux-androideabi - strip: ${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip - # Linux - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - strip: strip - 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 - - 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 diff --git a/.github/workflows/release-oxide.yml b/.github/workflows/release-oxide.yml new file mode 100644 index 000000000..94a7d90fb --- /dev/null +++ b/.github/workflows/release-oxide.yml @@ -0,0 +1,228 @@ +name: Release + +on: + workflow_dispatch: + inputs: + release_channel: + description: 'Release channel' + required: false + default: 'next' + type: string + +permissions: + contents: read + +env: + APP_NAME: tailwindcss-oxide + NODE_VERSION: 20 + PNPM_VERSION: ^8.15.0 + OXIDE_LOCATION: ./oxide/crates/node + +jobs: + build: + strategy: + matrix: + include: + # Windows + - os: windows-latest + target: x86_64-pc-windows-msvc + # macOS + - os: macos-latest + target: x86_64-apple-darwin + strip: strip -x # Must use -x on macOS. This produces larger results on linux. + - os: macos-latest + target: aarch64-apple-darwin + page-size: 14 + strip: strip -x # Must use -x on macOS. This produces larger results on linux. + # Android + - os: ubuntu-latest + target: aarch64-linux-android + strip: ${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip + - os: ubuntu-latest + target: armv7-linux-androideabi + strip: ${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip + # Linux + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + strip: strip + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + strip: llvm-strip + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64 + - os: ubuntu-latest + target: armv7-unknown-linux-gnueabihf + strip: llvm-strip + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-zig + - os: ubuntu-latest + target: aarch64-unknown-linux-musl + strip: aarch64-linux-musl-strip + download: true + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + strip: strip + download: true + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine + + name: Build ${{ matrix.target }} (OXIDE) + runs-on: ${{ matrix.os }} + container: ${{ matrix.container }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@v3 + - uses: pnpm/action-setup@v3 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Use Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + # Cargo already skips downloading dependencies if they already exist + - name: Cache cargo + uses: actions/cache@v3 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + # Cache the `oxide` Rust build + - name: Cache oxide build + uses: actions/cache@v3 + with: + path: | + ./oxide/target/ + ./oxide/crates/node/*.node + ./oxide/crates/node/index.js + ./oxide/crates/node/index.d.ts + key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./oxide/crates/**/*') }} + + - name: Install Node.JS + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Setup cross compile toolchain + if: ${{ matrix.setup }} + run: ${{ matrix.setup }} + + - name: Install Rust (Stable) + if: ${{ matrix.download }} + run: | + rustup default stable + + - name: Setup rust target + run: rustup target add ${{ matrix.target }} + + - name: Install dependencies + run: pnpm install --ignore-scripts --filter=!./playgrounds/* + + - name: Build release + run: pnpm run build --filter ${{ env.OXIDE_LOCATION }} + env: + RUST_TARGET: ${{ matrix.target }} + JEMALLOC_SYS_WITH_LG_PAGE: ${{ matrix.page-size }} + + - name: Strip debug symbols # https://github.com/rust-lang/rust/issues/46034 + if: ${{ matrix.strip }} + run: ${{ matrix.strip }} ${{ env.OXIDE_LOCATION }}/*.node + + - name: Upload artifacts + uses: actions/upload-artifact@v3 + with: + name: bindings-${{ matrix.target }} + path: ${{ env.OXIDE_LOCATION }}/*.node + + release: + runs-on: ubuntu-latest + timeout-minutes: 15 + name: Build and release Tailwind CSS + + permissions: + contents: read + # https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions + id-token: write + + needs: + - build + + steps: + - uses: actions/checkout@v3 + - uses: pnpm/action-setup@v3 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Use Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + registry-url: 'https://registry.npmjs.org' + + # Cargo already skips downloading dependencies if they already exist + - name: Cache cargo + uses: actions/cache@v3 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + # Cache the `oxide` Rust build + - name: Cache oxide build + uses: actions/cache@v3 + with: + path: | + ./oxide/target/ + ./oxide/crates/node/*.node + ./oxide/crates/node/index.js + ./oxide/crates/node/index.d.ts + key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./oxide/crates/**/*') }} + + - name: Install dependencies + run: pnpm install --ignore-scripts --filter=!./playgrounds/* + + - name: Build Tailwind CSS + run: pnpm run build + + - name: Download artifacts + uses: actions/download-artifact@v3 + 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-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/ + + - name: Lock pre-release versions + run: node ./scripts/lock-pre-release-versions.mjs + + - name: Publish + run: pnpm --recursive publish --tag ${{ inputs.release_channel }} --no-git-checks + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 2c957d3af..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,358 +0,0 @@ -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 - -permissions: - contents: read - -env: - APP_NAME: tailwindcss-oxide - NODE_VERSION: 24 - PNPM_VERSION: '11.9.0' - OXIDE_LOCATION: ./crates/node - -concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - strategy: - matrix: - include: - # Windows - - os: windows-latest - target: x86_64-pc-windows-msvc - - os: windows-latest - target: aarch64-pc-windows-msvc - # macOS - - os: macos-latest - target: x86_64-apple-darwin - strip: strip -x # Must use -x on macOS. This produces larger results on linux. - - os: macos-latest - target: aarch64-apple-darwin - page-size: 14 - strip: strip -x # Must use -x on macOS. This produces larger results on linux. - # Android - - os: ubuntu-latest - target: aarch64-linux-android - strip: ${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip - - os: ubuntu-latest - target: armv7-linux-androideabi - strip: ${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip - # Linux - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - strip: strip - 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" - 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 - - permissions: - contents: read - # 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 - with: - fetch-depth: 20 - 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 }} - 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) - - 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 - - - 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: '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 - with: - name: npm-package-tarballs - path: dist/*.tgz - - - 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 - 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 - - - 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 }}>) for `${{ steps.release.outputs.label }}` has failed.' diff --git a/.gitignore b/.gitignore index bbac7d227..092494627 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,3 @@ test-results/ playwright-report/ blob-report/ playwright/.cache/ -target/ -.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..0333c2dc5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,9 +1,8 @@ coverage/ node_modules/ pnpm-lock.yaml -target/ -crates/node/index.d.ts -crates/node/index.js -crates/ignore/ +oxide/target/ +oxide/crates/node/index.d.ts +oxide/crates/node/index.js .next .fingerprint diff --git a/CHANGELOG.md b/CHANGELOG.md index e5e6f6f89..5390fa10f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,1576 +7,7 @@ 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 deleted file mode 100644 index cab0d3d62..000000000 --- a/integrations/upgrade/js-config.test.ts +++ /dev/null @@ -1,2187 +0,0 @@ -import path from 'node:path' -import { describe } from 'vitest' -import { css, html, json, test, ts, txt } from '../utils' - -test( - `upgrade JS config files with flat theme values, darkMode, and content fields`, - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "^3", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - 'tailwind.config.ts': ts` - import { type Config } from 'tailwindcss' - import defaultTheme from 'tailwindcss/defaultTheme' - - module.exports = { - darkMode: 'selector', - content: ['./src/**/*.{html,js}', './node_modules/my-external-lib/**/*.{html}'], - theme: { - boxShadow: { - sm: '0 2px 6px rgb(15 23 42 / 0.08)', - }, - colors: { - red: { - 400: '#f87171', - 500: 'red', - }, - superRed: '#ff0000', - steel: 'rgb(70 130 180 / )', - smoke: 'rgba(245, 245, 245, var(--smoke-alpha, ))', - }, - ringColor: { - DEFAULT: '#c0ffee', - }, - opacity: { - superOpaque: '0.95', - }, - fontSize: { - xs: ['0.75rem', { lineHeight: '1rem' }], - sm: ['0.875rem', { lineHeight: '1.5rem' }], - base: ['1rem', { lineHeight: '2rem' }], - lg: ['1.125rem', '2.5rem'], - xl: ['1.5rem', '3rem', 'invalid'], - '2xl': ['2rem'], - }, - width: { - px: '1px', - auto: 'auto', - 1: '0.25rem', - 1.5: '0.375rem', - 2: '0.5rem', - 2.5: '0.625rem', - 3: '0.75rem', - 3.5: '0.875rem', - 4: '1rem', - 5: '1.25rem', - 6: '1.5rem', - 8: '2rem', - 10: '2.5rem', - 11: '2.75rem', - 12: '3rem', - 16: '4rem', - 24: '6rem', - 32: '8rem', - 40: '10rem', - 48: '12rem', - 64: '16rem', - 80: '20rem', - 96: '24rem', - 128: '32rem', - - full: '100%', - 0: '0%', - '1/2': '50%', - '1/3': 'calc(100% / 3)', - '2/3': 'calc(100% / 3 * 2)', - '1/4': '25%', - '3/4': '75%', - '1/5': '20%', - '2/5': '40%', - '3/5': '60%', - '4/5': '80%', - '1/6': 'calc(100% / 6)', - '5/6': 'calc(100% / 6 * 5)', - '1/7': 'calc(100% / 7)', - '1/10': 'calc(100% / 10)', - '3/10': 'calc(100% / 10 * 3)', - '7/10': 'calc(100% / 10 * 7)', - '9/10': 'calc(100% / 10 * 9)', - screen: '100vw', - - 'full-minus-80': 'calc(100% - 20rem)', - 'full-minus-96': 'calc(100% - 24rem)', - - '225px': '225px', - }, - extend: { - colors: { - red: { - 500: '#ef4444', - 600: '#dc2626', - }, - }, - fontFamily: { - sans: 'Inter, system-ui, sans-serif', - display: ['Cabinet Grotesk', ...defaultTheme.fontFamily.sans], - }, - borderRadius: { - '4xl': '2rem', - }, - keyframes: { - 'spin-clockwise': { - '0%': { transform: 'rotate(0deg)' }, - '100%': { transform: 'rotate(360deg)' }, - }, - 'spin-counterclockwise': { - '0%': { transform: 'rotate(0deg)' }, - '100%': { transform: 'rotate(-360deg)' }, - }, - }, - animation: { - 'spin-clockwise': 'spin-clockwise 1s linear infinite', - 'spin-counterclockwise': 'spin-counterclockwise 1s linear infinite', - }, - letterSpacing: { - superWide: '0.25em', - }, - lineHeight: { - superLoose: '3', - }, - }, - }, - plugins: [], - } satisfies Config - `, - 'src/input.css': css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, - // prettier-ignore - 'src/test.js': ts` - export default { - 'shouldNotMigrate': !border.test + '', - 'filter': 'drop-shadow(0 0 0.5rem #000)', - } - `, - 'src/index.html': html` -
-
- `, - 'node_modules/my-external-lib/src/template.html': html` -
- Hello world! -
- `, - }, - }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') - - expect(await fs.dumpFiles('src/**/*.{css,js,html}')).toMatchInlineSnapshot(` - " - --- src/index.html --- -
-
- - --- src/input.css --- - @import 'tailwindcss'; - - @source '../node_modules/my-external-lib/**/*.{html}'; - - @custom-variant dark (&:where(.dark, .dark *)); - - @theme { - --shadow-*: initial; - --shadow-sm: 0 2px 6px rgb(15 23 42 / 0.08); - - --color-*: initial; - --color-red-400: #f87171; - --color-red-500: #ef4444; - --color-red-600: #dc2626; - - --color-superRed: #ff0000; - --color-steel: rgb(70 130 180); - --color-smoke: rgba(245, 245, 245, var(--smoke-alpha, 1)); - - --default-ring-color: #c0ffee; - - --opacity-*: initial; - --opacity-superOpaque: 95%; - - --text-*: initial; - --text-xs: 0.75rem; - --text-xs--line-height: 1rem; - --text-sm: 0.875rem; - --text-sm--line-height: 1.5rem; - --text-base: 1rem; - --text-base--line-height: 2rem; - --text-lg: 1.125rem; - --text-lg--line-height: 2.5rem; - --text-xl: 1.5rem; - --text-xl--line-height: 3rem; - --text-2xl: 2rem; - - --width-*: initial; - --width-0: 0%; - --width-1: 0.25rem; - --width-2: 0.5rem; - --width-3: 0.75rem; - --width-4: 1rem; - --width-5: 1.25rem; - --width-6: 1.5rem; - --width-8: 2rem; - --width-10: 2.5rem; - --width-11: 2.75rem; - --width-12: 3rem; - --width-16: 4rem; - --width-24: 6rem; - --width-32: 8rem; - --width-40: 10rem; - --width-48: 12rem; - --width-64: 16rem; - --width-80: 20rem; - --width-96: 24rem; - --width-128: 32rem; - --width-px: 1px; - --width-auto: auto; - --width-1_5: 0.375rem; - --width-2_5: 0.625rem; - --width-3_5: 0.875rem; - --width-full: 100%; - --width-1\\/2: 50%; - --width-1\\/3: calc(100% / 3); - --width-2\\/3: calc(100% / 3 * 2); - --width-1\\/4: 25%; - --width-3\\/4: 75%; - --width-1\\/5: 20%; - --width-2\\/5: 40%; - --width-3\\/5: 60%; - --width-4\\/5: 80%; - --width-1\\/6: calc(100% / 6); - --width-5\\/6: calc(100% / 6 * 5); - --width-1\\/7: calc(100% / 7); - --width-1\\/10: calc(100% / 10); - --width-3\\/10: calc(100% / 10 * 3); - --width-7\\/10: calc(100% / 10 * 7); - --width-9\\/10: calc(100% / 10 * 9); - --width-screen: 100vw; - --width-full-minus-80: calc(100% - 20rem); - --width-full-minus-96: calc(100% - 24rem); - --width-225px: 225px; - - --font-sans: Inter, system-ui, sans-serif; - --font-display: - Cabinet Grotesk, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', - 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - - --radius-4xl: 2rem; - - --animate-spin-clockwise: spin-clockwise 1s linear infinite; - --animate-spin-counterclockwise: spin-counterclockwise 1s linear infinite; - - --tracking-superWide: 0.25em; - - --leading-superLoose: 3; - - @keyframes spin-clockwise { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } - } - @keyframes spin-counterclockwise { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(-360deg); - } - } - } - - /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, - so we've added these compatibility styles to make sure everything still - looks the same as it did with Tailwind CSS v3. - - If we ever want to remove these styles, we need to add an explicit border - color utility to any element that depends on these defaults. - */ - @layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } - } - - --- src/test.js --- - export default { - 'shouldNotMigrate': !border.test + '', - 'filter': 'drop-shadow(0 0 0.5rem #000)', - } - " - `) - - expect((await fs.dumpFiles('tailwind.config.ts')).trim()).toBe('') - }, -) - -test( - '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', - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "^3", - "@tailwindcss/typography": "^0.5.15", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - 'tailwind.config.ts': ts` - import { type Config } from 'tailwindcss' - import typography from '@tailwindcss/typography' - import customPlugin from './custom-plugin' - - export default { - darkMode: [ - 'variant', - [ - '@media not print { .dark & }', - '@media not eink { .dark & }', - '&:where(.dark, .dark *)', - ], - ], - plugins: [ - typography, - customPlugin({ - 'is-null': null, - 'is-true': true, - 'is-false': false, - 'is-int': 1234567, - 'is-float': 1.35, - 'is-sci': 1.35e-5, - 'is-str-null': 'null', - 'is-str-true': 'true', - 'is-str-false': 'false', - 'is-str-int': '1234567', - 'is-str-float': '1.35', - 'is-str-sci': '1.35e-5', - 'is-arr': ['foo', 'bar'], - 'is-arr-mixed': [null, true, false, 1234567, 1.35, 'foo', 'bar', 'true'], - }), - ], - } satisfies Config - `, - 'custom-plugin.js': ts` - import plugin from 'tailwindcss/plugin' - export default plugin.withOptions((_options) => ({ addVariant }) => { - addVariant('inverted', '@media (inverted-colors: inverted)') - addVariant('hocus', ['&:focus', '&:hover']) - }) - `, - 'src/input.css': css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, - }, - }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') - - expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` - " - --- src/input.css --- - @import 'tailwindcss'; - - @plugin '@tailwindcss/typography'; - @plugin '../custom-plugin' { - is-null: null; - is-true: true; - is-false: false; - is-int: 1234567; - is-float: 1.35; - is-sci: 0.0000135; - is-str-null: 'null'; - is-str-true: 'true'; - is-str-false: 'false'; - is-str-int: '1234567'; - is-str-float: '1.35'; - is-str-sci: '1.35e-5'; - is-arr: 'foo', 'bar'; - is-arr-mixed: null, true, false, 1234567, 1.35, 'foo', 'bar', 'true'; - } - - @custom-variant dark { - @media not print { - .dark & { - @slot; - } - } - @media not eink { - .dark & { - @slot; - } - } - &:where(.dark, .dark *) { - @slot; - } - } - - /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, - so we've added these compatibility styles to make sure everything still - looks the same as it did with Tailwind CSS v3. - - If we ever want to remove these styles, we need to add an explicit border - color utility to any element that depends on these defaults. - */ - @layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } - } - " - `) - - expect(await fs.dumpFiles('tailwind.config.ts')).toMatchInlineSnapshot(` - " - - " - `) - }, -) - -test( - 'upgrades JS config files with functions in the theme config', - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "^3", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - 'tailwind.config.ts': ts` - import { type Config } from 'tailwindcss' - - export default { - theme: { - extend: { - colors: ({ colors }) => ({ - gray: colors.neutral, - }), - }, - }, - } satisfies Config - `, - 'src/input.css': css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, - }, - }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') - - expect(await fs.dumpFiles('src/**/*.{css,ts}')).toMatchInlineSnapshot(` - " - --- src/input.css --- - @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); - } - - /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, - so we've added these compatibility styles to make sure everything still - looks the same as it did with Tailwind CSS v3. - - If we ever want to remove these styles, we need to add an explicit border - color utility to any element that depends on these defaults. - */ - @layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } - } - " - `) - - expect(await fs.dumpFiles('tailwind.config.ts')).toMatchInlineSnapshot(` - " - - " - `) - }, -) - -test( - 'does not upgrade JS config files with theme keys contributed to by plugins in the theme config', - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "^3", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - 'tailwind.config.ts': ts` - import { type Config } from 'tailwindcss' - - export default { - theme: { - typography: { - DEFAULT: { - css: { - '--tw-prose-body': 'red', - color: 'var(--tw-prose-body)', - }, - }, - }, - }, - } satisfies Config - `, - 'src/input.css': css` - @tailwind base; - @tailwind components; - @tailwind utilities; - @config '../tailwind.config.ts'; - `, - }, - }, - async ({ exec, fs, expect }) => { - await exec('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')).toMatchInlineSnapshot(` - " - --- tailwind.config.ts --- - import { type Config } from 'tailwindcss' - - export default { - theme: { - typography: { - DEFAULT: { - css: { - '--tw-prose-body': 'red', - color: 'var(--tw-prose-body)', - }, - }, - }, - }, - } satisfies Config - " - `) - }, -) - -test( - `does not upgrade JS config files with inline plugins`, - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "^3", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - 'tailwind.config.ts': ts` - import { type Config } from 'tailwindcss' - - export default { - plugins: [function complexConfig() {}], - } satisfies Config - `, - 'src/input.css': css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, - }, - }, - async ({ exec, fs, expect }) => { - await exec('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')).toMatchInlineSnapshot(` - " - --- tailwind.config.ts --- - import { type Config } from 'tailwindcss' - - export default { - plugins: [function complexConfig() {}], - } satisfies Config - " - `) - }, -) - -test( - `does not upgrade JS config files with non-simple screens object`, - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "^3", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - 'tailwind.config.ts': ts` - import { type Config } from 'tailwindcss' - - export default { - theme: { - screens: { - xl: { min: '1024px', max: '1279px' }, - tall: { raw: '(min-height: 800px)' }, - }, - }, - } satisfies Config - `, - 'src/input.css': css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, - }, - }, - async ({ exec, fs, expect }) => { - await exec('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')).toMatchInlineSnapshot(` - " - --- tailwind.config.ts --- - import { type Config } from 'tailwindcss' - - export default { - theme: { - screens: { - xl: { min: '1024px', max: '1279px' }, - tall: { raw: '(min-height: 800px)' }, - }, - }, - } satisfies Config - " - `) - }, -) - -test( - 'multi-root project', - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "^3", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - - // Project A - 'project-a/tailwind.config.ts': ts` - export default { - content: { - relative: true, - files: ['./src/**/*.html'], - }, - theme: { - extend: { - colors: { - primary: 'red', - }, - }, - }, - } - `, - 'project-a/src/input.css': css` - @tailwind base; - @tailwind components; - @tailwind utilities; - @config "../tailwind.config.ts"; - `, - 'project-a/src/index.html': html`
`, - - // Project B - 'project-b/tailwind.config.ts': ts` - export default { - content: { - relative: true, - files: ['./src/**/*.html'], - }, - theme: { - extend: { - colors: { - primary: 'blue', - }, - }, - }, - } - `, - 'project-b/src/input.css': css` - @tailwind base; - @tailwind components; - @tailwind utilities; - @config "../tailwind.config.ts"; - `, - 'project-b/src/index.html': html`
`, - }, - }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') - - expect(await fs.dumpFiles('project-{a,b}/**/*.{css,ts}')).toMatchInlineSnapshot(` - " - --- project-a/src/input.css --- - @import 'tailwindcss'; - - @theme { - --color-primary: red; - } - - /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, - so we've added these compatibility styles to make sure everything still - looks the same as it did with Tailwind CSS v3. - - If we ever want to remove these styles, we need to add an explicit border - color utility to any element that depends on these defaults. - */ - @layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } - } - - --- project-b/src/input.css --- - @import 'tailwindcss'; - - @theme { - --color-primary: blue; - } - - /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, - so we've added these compatibility styles to make sure everything still - looks the same as it did with Tailwind CSS v3. - - If we ever want to remove these styles, we need to add an explicit border - color utility to any element that depends on these defaults. - */ - @layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } - } - " - `) - }, -) - -test( - 'migrate sources when pointing to folders outside the project root', - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "^3", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - - 'frontend/tailwind.config.ts': ts` - export default { - content: { - relative: true, - files: ['./src/**/*.html', '../backend/mails/**/*.blade.php'], - }, - theme: { - extend: { - colors: { - primary: 'red', - }, - }, - }, - } - `, - 'frontend/src/input.css': css` - @tailwind base; - @tailwind components; - @tailwind utilities; - @config "../tailwind.config.ts"; - `, - 'frontend/src/index.html': html`
`, - - 'backend/mails/welcome.blade.php': html`
`, - }, - }, - async ({ root, exec, fs, expect }) => { - await exec('pnpm exec upgrade', { - cwd: path.join(root, 'frontend'), - }) - - expect(await fs.dumpFiles('frontend/**/*.css')).toMatchInlineSnapshot(` - " - --- frontend/src/input.css --- - @import 'tailwindcss'; - - @source '../../backend/mails/**/*.blade.php'; - - @theme { - --color-primary: red; - } - - /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, - so we've added these compatibility styles to make sure everything still - looks the same as it did with Tailwind CSS v3. - - If we ever want to remove these styles, we need to add an explicit border - color utility to any element that depends on these defaults. - */ - @layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } - } - " - `) - }, -) - -test( - 'migrate aria theme keys to custom variants', - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "^3", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - 'tailwind.config.ts': ts` - export default { - content: { - relative: true, - files: ['./src/**/*.html'], - }, - theme: { - extend: { - aria: { - // Built-in (not really, but visible because of intellisense) - busy: 'busy="true"', - - // Automatically handled by bare values - foo: 'foo="true"', - - // Quotes are optional in CSS for these kinds of attribute - // selectors - bar: 'bar=true', - - // Not automatically handled by bare values because names differ - baz: 'qux="true"', - - // Completely custom - asc: 'sort="ascending"', - desc: 'sort="descending"', - }, - }, - }, - } - `, - 'src/input.css': css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, - }, - }, - async ({ exec, fs, expect }) => { - await exec('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); - } - } - " - `) - }, -) - -describe('border compatibility', () => { - test( - 'migrate border compatibility', - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "^3", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - 'tailwind.config.ts': ts` - import { type Config } from 'tailwindcss' - - // Empty / default config - export default { - theme: { - extend: {}, - }, - } satisfies Config - `, - 'src/input.css': css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, - }, - }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') - - expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` - " - --- src/input.css --- - @import 'tailwindcss'; - - /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, - so we've added these compatibility styles to make sure everything still - looks the same as it did with Tailwind CSS v3. - - If we ever want to remove these styles, we need to add an explicit border - color utility to any element that depends on these defaults. - */ - @layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } - } - " - `) - }, - ) - - test( - 'migrate border compatibility if a custom border color is used', - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "^3", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - 'tailwind.config.ts': ts` - import { type Config } from 'tailwindcss' - - export default { - theme: { - extend: { - borderColor: ({ colors }) => ({ - DEFAULT: colors.blue[500], - }), - }, - }, - } satisfies Config - `, - 'src/input.css': css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, - }, - }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') - - expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` - " - --- src/input.css --- - @import 'tailwindcss'; - - /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, - so we've added these compatibility styles to make sure everything still - looks the same as it did with Tailwind CSS v3. - - If we ever want to remove these styles, we need to add an explicit border - color utility to any element that depends on these defaults. - */ - @layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: oklch(62.3% 0.214 259.815); - } - } - " - `) - }, - ) - - test( - 'migrate border compatibility if a custom border color is used, that matches the default v4 border color', - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "^3", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - 'tailwind.config.ts': ts` - import { type Config } from 'tailwindcss' - - export default { - theme: { - extend: { - borderColor: ({ colors }) => ({ - DEFAULT: 'currentcolor', - }), - }, - }, - } satisfies Config - `, - 'src/input.css': css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, - }, - }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') - - expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` - " - --- src/input.css --- - @import 'tailwindcss'; - " - `) - }, - ) - - test( - 'migrate border compatibility in the file that uses the `@import "tailwindcss"` import', - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "^3", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - 'tailwind.config.ts': ts` - import { type Config } from 'tailwindcss' - - export default { - theme: {}, - } satisfies Config - `, - 'src/input.css': css`@import './tailwind.css';`, - 'src/tailwind.css': css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, - }, - }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') - - expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` - " - --- src/input.css --- - @import './tailwind.css'; - - --- src/tailwind.css --- - @import 'tailwindcss'; - - /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, - so we've added these compatibility styles to make sure everything still - looks the same as it did with Tailwind CSS v3. - - If we ever want to remove these styles, we need to add an explicit border - color utility to any element that depends on these defaults. - */ - @layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } - } - " - `) - }, - ) - - test( - 'migrate border compatibility in the file that uses the `@import "tailwindcss/preflight"` import', - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "^3", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - 'tailwind.config.ts': ts` - import { type Config } from 'tailwindcss' - - export default { - theme: {}, - } satisfies Config - `, - 'src/input.css': css` - @import './base.css'; - @import './my-base.css'; - @import './utilities.css'; - `, - 'src/base.css': css`@tailwind base;`, - 'src/utilities.css': css` - @tailwind components; - @tailwind utilities; - `, - 'src/my-base.css': css` - @layer base { - html { - color: black; - } - } - `, - }, - }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') - - expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` - " - --- src/base.css --- - @import 'tailwindcss/theme' layer(theme); - @import 'tailwindcss/preflight' layer(base); - - /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, - so we've added these compatibility styles to make sure everything still - looks the same as it did with Tailwind CSS v3. - - If we ever want to remove these styles, we need to add an explicit border - color utility to any element that depends on these defaults. - */ - @layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } - } - - --- src/input.css --- - @import './base.css'; - @import './my-base.css'; - @import './utilities.css'; - - --- src/my-base.css --- - @layer base { - html { - color: black; - } - } - - --- src/utilities.css --- - @import 'tailwindcss/utilities' layer(utilities); - " - `) - }, - ) - - test( - 'migrates extended spacing keys', - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "^3", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - 'tailwind.config.ts': ts` - import { type Config } from 'tailwindcss' - - export default { - content: ['./src/**/*.html'], - theme: { - extend: { - spacing: { - 2: '0.5rem', - 4.5: '1.125rem', - 5.5: '1.375em', // Units are different from --spacing scale - 13: '3.25rem', - 100: '100px', - miami: '1337px', - }, - }, - }, - } satisfies Config - `, - 'src/input.css': css` - @tailwind base; - @tailwind components; - @tailwind utilities; - - .container { - width: theme(spacing.2); - width: theme(spacing[4.5]); - width: theme(spacing[5.5]); - width: theme(spacing[13]); - width: theme(spacing[100]); - width: theme(spacing.miami); - } - `, - 'src/index.html': html` -
- `, - }, - }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') - - expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(` - " - --- src/index.html --- -
- - --- src/input.css --- - @import 'tailwindcss'; - - @theme { - --spacing-100: 100px; - --spacing-5_5: 1.375em; - --spacing-miami: 1337px; - } - - /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, - so we've added these compatibility styles to make sure everything still - looks the same as it did with Tailwind CSS v3. - - If we ever want to remove these styles, we need to add an explicit border - color utility to any element that depends on these defaults. - */ - @layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } - } - - .container { - width: --spacing(2); - width: --spacing(4.5); - width: var(--spacing-5_5); - width: --spacing(13); - width: var(--spacing-100); - width: var(--spacing-miami); - } - " - `) - }, - ) - - test( - 'retains overwriting spacing scale', - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "^3", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - 'tailwind.config.ts': ts` - import { type Config } from 'tailwindcss' - - export default { - content: ['./src/**/*.html'], - theme: { - spacing: { - 2: '0.5rem', - 4.5: '1.125rem', - 5.5: '1.375em', - 13: '3.25rem', - 100: '100px', - miami: '1337px', - }, - }, - } satisfies Config - `, - 'src/input.css': css` - @tailwind base; - @tailwind components; - @tailwind utilities; - - .container { - width: theme(spacing.2); - width: theme(spacing[4.5]); - width: theme(spacing[5.5]); - width: theme(spacing[13]); - width: theme(spacing[100]); - width: theme(spacing.miami); - } - `, - 'src/index.html': html` -
- `, - }, - }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') - - expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(` - " - --- src/index.html --- -
- - --- src/input.css --- - @import 'tailwindcss'; - - @theme { - --spacing-*: initial; - --spacing-2: 0.5rem; - --spacing-13: 3.25rem; - --spacing-100: 100px; - --spacing-4_5: 1.125rem; - --spacing-5_5: 1.375em; - --spacing-miami: 1337px; - } - - /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, - so we've added these compatibility styles to make sure everything still - looks the same as it did with Tailwind CSS v3. - - If we ever want to remove these styles, we need to add an explicit border - color utility to any element that depends on these defaults. - */ - @layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } - } - - .container { - width: var(--spacing-2); - width: var(--spacing-4_5); - width: var(--spacing-5_5); - width: var(--spacing-13); - width: var(--spacing-100); - width: var(--spacing-miami); - } - " - `) - }, - ) - - test( - 'migrates `container` component configurations', - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "^3", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - 'tailwind.config.ts': ts` - import { type Config } from 'tailwindcss' - - export default { - content: ['./src/**/*.html'], - theme: { - container: { - center: true, - padding: { - DEFAULT: '2rem', - '2xl': '4rem', - }, - screens: { - md: '48rem', // Matches a default --breakpoint - xl: '1280px', - '2xl': '1536px', - }, - }, - }, - } satisfies Config - `, - 'src/input.css': css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, - 'src/index.html': html` -
- `, - }, - }, - async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') - - expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(` - " - --- src/index.html --- -
- - --- src/input.css --- - @import 'tailwindcss'; - - @utility container { - margin-inline: auto; - padding-inline: 2rem; - @media (width >= --theme(--breakpoint-sm)) { - max-width: none; - } - @media (width >= 48rem) { - max-width: 48rem; - } - @media (width >= 1280px) { - max-width: 1280px; - } - @media (width >= 1536px) { - max-width: 1536px; - padding-inline: 4rem; - } - } - - /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, - so we've added these compatibility styles to make sure everything still - looks the same as it did with Tailwind CSS v3. - - If we ever want to remove these styles, we need to add an explicit border - color utility to any element that depends on these defaults. - */ - @layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } - } - " - `) - }, - ) -}) - -test( - `future and experimental keys are supported`, - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "^3", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - 'tailwind.config.ts': ts` - import { type Config } from 'tailwindcss' - import defaultTheme from 'tailwindcss/defaultTheme' - - module.exports = { - darkMode: 'selector', - content: ['./src/**/*.{html,js}'], - future: { - hoverOnlyWhenSupported: true, - respectDefaultRingColorOpacity: true, - disableColorOpacityUtilitiesByDefault: true, - relativeContentPathsByDefault: true, - }, - experimental: { - generalizedModifiers: true, - }, - theme: { - colors: { - red: { - 400: '#f87171', - 500: 'red', - }, - }, - }, - plugins: [], - } satisfies Config - `, - 'src/input.css': css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, - }, - }, - async ({ exec, fs, expect }) => { - await exec('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 deleted file mode 100644 index 9863918d1..000000000 --- a/integrations/utils.ts +++ /dev/null @@ -1,871 +0,0 @@ -import dedent from 'dedent' -import fastGlob from 'fast-glob' -import { exec, execFile, spawn, type ChildProcess } from 'node:child_process' -import fs from 'node:fs/promises' -import { createServer } 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' - -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 - onStdout: (predicate: (message: string) => boolean) => Promise - onStderr: (predicate: (message: string) => boolean) => Promise -} - -interface ChildProcessOptions { - cwd?: string - env?: Record -} - -interface ExecOptions { - ignoreStdErr?: boolean - stdin?: string -} - -interface TestConfig { - fs: { - [filePath: string]: string | Uint8Array - } - - timeout?: number - installDependencies?: boolean - 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 - fs: { - write(filePath: string, content: string, encoding?: BufferEncoding): Promise - symlink(dst: string, src: 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 - } -} -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 TEST_TIMEOUT = IS_WINDOWS ? 120000 : 60000 -const ASSERTION_TIMEOUT = IS_WINDOWS ? 10000 : 5000 - -// On Windows CI, tmpdir returns a path containing a weird RUNNER~1 folder that -// apparently causes the vite builds to not work. -const TMP_ROOT = - process.env.CI && IS_WINDOWS ? path.dirname(process.env.GITHUB_WORKSPACE!) : tmpdir() - -export function test( - name: string, - config: TestConfig, - testCallback: TestCallback, - { only = false, skip = false, debug = false, concurrent = false }: TestFlags = {}, -) { - return defaultTest( - name, - { - timeout: config.timeout ?? TEST_TIMEOUT, - retry: config.retry ?? (process.env.CI ? 2 : 0), - only: only || (!process.env.CI && debug), - skip, - concurrent, - }, - async (options) => { - let rootDir = debug ? path.join(REPO_ROOT, '.debug') : TMP_ROOT - await fs.mkdir(rootDir, { recursive: true }) - - let root = await fs.mkdtemp(path.join(rootDir, 'tailwind-integrations')) - - if (debug) { - console.log('Running test in debug mode. File system will be written to:') - console.log(root) - console.log() - } - - let context = { - root, - expect: options.expect, - parseSourceMap, - async exec( - command: string, - childProcessOptions: ChildProcessOptions = {}, - execOptions: ExecOptions = {}, - ) { - let cwd = childProcessOptions.cwd ?? root - 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}` - console.log(`> cd ${relative}`) - } - if (debug) console.log(`> ${command}`) - return new Promise((resolve, reject) => { - let child = exec( - command, - { - cwd, - ...childProcessOptions, - env: { - ...process.env, - ...childProcessOptions.env, - }, - }, - (error, stdout, stderr) => { - if (error) { - if (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) - } - reject(error) - } else { - if (only || debug) { - console.log(stdout.toString() + '\n\n' + stderr.toString()) - } - resolve(stdout.toString() + '\n\n' + stderr.toString()) - } - }, - ) - if (execOptions.stdin) { - child.stdin?.write(execOptions.stdin) - child.stdin?.end() - } - }) - }, - async spawn(command: string, childProcessOptions: ChildProcessOptions = {}) { - let resolveDisposal: (() => void) | undefined - let rejectDisposal: ((error: Error) => void) | undefined - let disposePromise = new Promise((resolve, reject) => { - resolveDisposal = resolve - rejectDisposal = reject - }) - - let cwd = childProcessOptions.cwd ?? root - if (debug && cwd !== root) { - let relative = path.relative(root, cwd) - if (relative[0] !== '.') relative = `./${relative}` - console.log(`> cd ${relative}`) - } - if (debug) console.log(`>& ${command}`) - let child = spawn(command, { - cwd, - detached: !IS_WINDOWS, - shell: true, - ...childProcessOptions, - env: { - ...process.env, - ...childProcessOptions.env, - }, - }) - - let disposed = false - - 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), - ) - return disposePromise - } - disposables.push(dispose) - - function onExit() { - resolveDisposal?.() - } - - let stdoutMessages: string[] = [] - let stderrMessages: string[] = [] - - let stdoutActors: SpawnActor[] = [] - let stderrActors: SpawnActor[] = [] - - function notifyNext(actors: SpawnActor[], messages: string[]) { - if (actors.length <= 0) return - let [next] = actors - - for (let [idx, message] of messages.entries()) { - if (next.predicate(message)) { - messages.splice(0, idx + 1) - let actorIdx = actors.indexOf(next) - actors.splice(actorIdx, 1) - next.resolve() - break - } - } - } - - let combined: ['stdout' | 'stderr', string][] = [] - - child.stdout.on('data', (result) => { - let content = result.toString() - if (debug || only) console.log(content) - combined.push(['stdout', content]) - for (let line of content.split('\n')) { - stdoutMessages.push(stripVTControlCharacters(line)) - } - notifyNext(stdoutActors, stdoutMessages) - }) - child.stderr.on('data', (result) => { - let content = result.toString() - if (debug || only) console.error(content) - combined.push(['stderr', content]) - for (let line of content.split('\n')) { - stderrMessages.push(stripVTControlCharacters(line)) - } - notifyNext(stderrActors, stderrMessages) - }) - child.on('exit', onExit) - child.on('error', (error) => { - if (error.name !== 'AbortError') { - throw error - } - }) - - options.onTestFailed(() => { - // In only or debug mode, messages are logged to the console - // immediately. - if (only || debug) return - - for (let [type, message] of combined) { - if (type === 'stdout') { - console.log(message) - } else { - console.error(message) - } - } - }) - - return { - dispose, - flush() { - stdoutActors.splice(0) - stderrActors.splice(0) - - stdoutMessages.splice(0) - stderrMessages.splice(0) - }, - onStdout(predicate: (message: string) => boolean) { - return new Promise((resolve) => { - stdoutActors.push({ predicate, resolve }) - notifyNext(stdoutActors, stdoutMessages) - }) - }, - onStderr(predicate: (message: string) => boolean) { - return new Promise((resolve) => { - stderrActors.push({ predicate, resolve }) - notifyNext(stderrActors, stderrMessages) - }) - }, - } - }, - fs: { - async write(filename, content, encoding = 'utf8') { - let full = path.join(root, filename) - let dir = path.dirname(full) - await fs.mkdir(dir, { recursive: true }) - - if (typeof content !== 'string') { - return await fs.writeFile(full, content) - } - - if (filename.endsWith('package.json')) { - content = await overwriteVersionsInPackageJson(content) - } else if (filename.endsWith('pnpm-workspace.yaml')) { - content = overwriteVersionsInPnpmWorkspace(content) - } - - // Ensure that files written on Windows use \r\n line ending - if (IS_WINDOWS) { - content = content.replace(/\n/g, '\r\n') - } - - await fs.writeFile(full, content, encoding) - }, - - async 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) { - for (let filename of filenames) { - let full = path.join(root, filename) - - let dir = path.dirname(full) - await fs.mkdir(dir, { recursive: true }) - await fs.writeFile(full, '') - } - }, - - async delete(filename) { - await fs.unlink(path.join(root, filename)) - }, - - async read(filePath) { - let content = await fs.readFile(path.resolve(root, filePath), 'utf8') - - // Ensure that files read on Windows have \r\n line endings removed - if (IS_WINDOWS) { - content = content.replace(/\r\n/g, '\n') - } - - return content - }, - async glob(pattern) { - let files = await fastGlob(pattern, { cwd: root }) - return Promise.all( - files.map(async (file) => { - let content = await fs.readFile(path.join(root, file), 'utf8') - return [ - file, - // Drop license comment - content.replace(/[\s\n]*\/\*![\s\S]*?\*\/[\s\n]*/g, ''), - ] - }), - ) - }, - async dumpFiles(pattern) { - let files = await context.fs.glob(pattern) - return `\n${files - .slice() - .sort((a: [string], z: [string]) => { - let aParts = a[0].split('/') - let zParts = z[0].split('/') - - let aFile = aParts.at(-1) - let zFile = zParts.at(-1) - - // Sort by depth, shallow first - if (aParts.length < zParts.length) return -1 - if (aParts.length > zParts.length) return 1 - - // Sort by folder names, alphabetically - for (let i = 0; i < aParts.length - 1; i++) { - let diff = aParts[i].localeCompare(zParts[i]) - if (diff !== 0) return diff - } - - // Sort by filename, sort files named `index` before others - if (aFile?.startsWith('index') && !zFile?.startsWith('index')) return -1 - if (zFile?.startsWith('index') && !aFile?.startsWith('index')) return 1 - - // Sort by filename, alphabetically - return a[0].localeCompare(z[0]) - }) - .map(([file, content]) => `--- ${file} ---\n${content || ''}`) - .join('\n\n') - .trim()}\n` - }, - async expectFileToContain(filePath, contents) { - return retryAssertion(async () => { - let fileContent = await this.read(filePath) - for (let content of Array.isArray(contents) ? contents : [contents]) { - if (content instanceof RegExp) { - options.expect(fileContent).toMatch(content) - } else { - options.expect(fileContent).toContain(content) - } - } - }) - }, - async expectFileNotToContain(filePath, contents) { - return retryAssertion(async () => { - let fileContent = await this.read(filePath) - for (let content of contents) { - options.expect(fileContent).not.toContain(content) - } - }) - }, - }, - } satisfies TestContext - - config.fs['.gitignore'] ??= txt` - node_modules/ - ` - - // 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) - } - } - - 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`) - } - } catch (error: any) { - console.error(error) - console.error(error.stdout?.toString()) - console.error(error.stderr?.toString()) - throw error - } - - let disposables: (() => Promise)[] = [] - - async function dispose() { - let results = await Promise.allSettled(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) - - // Make it a git repository, and commit all files - if (only || debug) { - try { - await context.exec('git init', { cwd: root }) - await context.exec('git add --all', { cwd: root }) - await context.exec('git commit -m "before migration" --allow-empty', { cwd: root }) - } catch (error: any) { - console.error(error) - console.error(error.stdout?.toString()) - console.error(error.stderr?.toString()) - throw error - } - } - - return await testCallback(context) - }, - ) -} -test.only = (name: string, config: TestConfig, testCallback: TestCallback) => { - return test(name, config, testCallback, { only: true }) -} -test.skip = (name: string, config: TestConfig, testCallback: TestCallback) => { - return test(name, config, testCallback, { skip: true }) -} -test.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 }) -} - -// Maps package names to their tarball filenames. See scripts/pack-packages.ts -// for more details. -function pkgToFilename(name: string) { - return `${name.replace('@', '').replace('/', '-')}.tgz` -} - -async function overwriteVersionsInPackageJson(content: string): Promise { - let json = JSON.parse(content) - - // Resolve all workspace:^ versions to local tarballs - for (let key of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { - let dependencies = json[key] || {} - for (let dependency in dependencies) { - if (dependencies[dependency] === 'workspace:^') { - dependencies[dependency] = resolveVersion(dependency) - } - } - } - - 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 ||= {} - 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) - } else { - workspace.overrides[pkg] = resolveVersion(pkg) - } - } - - return Yaml.stringify(workspace) -} - -function resolveVersion(dependency: string) { - let tarball = path.join(REPO_ROOT, 'dist', pkgToFilename(dependency)) - return `file:${tarball}` -} - -export function stripTailwindComment(content: string) { - return content.replace(/\/\*! tailwindcss .*? \*\//g, '').trim() -} - -export let svg = dedent -export let css = dedent -export let html = dedent -export let ts = dedent -export let js = dedent -export let jsx = dedent -export let json = dedent -export let yaml = dedent -export let txt = dedent - -export function binary(str: string | TemplateStringsArray, ...values: unknown[]): Uint8Array { - let base64 = typeof str === 'string' ? str : String.raw(str, ...values) - - return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)) -} - -export function candidate(strings: TemplateStringsArray, ...values: any[]) { - let output: string[] = [] - for (let i = 0; i < strings.length; i++) { - output.push(strings[i]) - if (i < values.length) { - output.push(values[i]) - } - } - - return `.${escape(output.join('').trim())}` -} - -export async function retryAssertion( - fn: () => Promise, - { timeout = ASSERTION_TIMEOUT, delay = 5 }: { timeout?: number; delay?: number } = {}, -) { - let end = Date.now() + timeout - let error: any - while (Date.now() < end) { - try { - return await fn() - } catch (err) { - Error.captureStackTrace(err, retryAssertion) - error = err - await new Promise((resolve) => setTimeout(resolve, delay)) - } - } - throw error -} - -export async function fetchStyles(base: string, path = '/'): Promise { - while (base.endsWith('/')) { - base = base.slice(0, -1) - } - - let index = await fetch(`${base}${path}`) - let html = await index.text() - - let linkRegex = /]*>([\s\S]*?)<\/style>/gi - - let stylesheets: string[] = [] - - let paths: string[] = [] - for (let match of html.matchAll(linkRegex)) { - let path: string = match[1] - if (path.startsWith('./')) { - path = path.slice(1) - } - paths.push(path) - } - stylesheets.push( - ...(await Promise.all( - paths.map(async (path) => { - let css = await fetch(`${base}${path}`, { - headers: { - Accept: 'text/css', - }, - }) - return await css.text() - }), - )), - ) - - for (let match of html.matchAll(styleRegex)) { - stylesheets.push(match[1]) - } - - return stylesheets.reduce((acc, css) => { - if (acc.length > 0) acc += '\n' - acc += css - return acc - }, '') -} - -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() + '...', - } - }, - } -} diff --git a/integrations/vite/astro.test.ts b/integrations/vite/astro.test.ts deleted file mode 100644 index f828b5071..000000000 --- a/integrations/vite/astro.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { candidate, css, fetchStyles, html, js, json, retryAssertion, test, ts } from '../utils' - -test( - 'dev mode', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "astro": "^4.15.2", - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - } - } - `, - 'astro.config.mjs': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'astro/config' - - // https://astro.build/config - export default defineConfig({ - vite: { plugins: [tailwindcss()] }, - build: { inlineStylesheets: 'never' }, - }) - `, - 'src/pages/index.astro': html` -
Hello, world!
- - - `, - }, - }, - 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')) - - await retryAssertion(async () => { - let css = await fetchStyles(url) - expect(css).toContain(candidate`underline`) - }) - - await retryAssertion(async () => { - await fs.write( - 'src/pages/index.astro', - html` -
Hello, world!
- - - `, - ) - - let css = await fetchStyles(url) - 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 deleted file mode 100644 index 0ac68f5e9..000000000 --- a/integrations/vite/config.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { candidate, css, fetchStyles, html, js, json, retryAssertion, test, ts } from '../utils' - -test( - 'Config files (CJS)', - { - 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()], - }) - `, - 'index.html': html` - - - - -
- - `, - 'tailwind.config.cjs': js` - module.exports = { - theme: { - extend: { - colors: { - primary: 'blue', - }, - }, - }, - } - `, - 'src/index.css': css` - @import 'tailwindcss'; - @config '../tailwind.config.cjs'; - `, - }, - }, - async ({ fs, exec, expect }) => { - 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`text-primary`, - ]) - }, -) - -test( - 'Config files (ESM)', - { - 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()], - }) - `, - 'index.html': html` - - - - -
- - `, - 'tailwind.config.js': js` - export default { - theme: { - extend: { - colors: { - primary: 'blue', - }, - }, - }, - } - `, - 'src/index.css': css` - @import 'tailwindcss'; - @config '../tailwind.config.js'; - `, - }, - }, - async ({ fs, exec, expect }) => { - 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`text-primary`, - ]) - }, -) - -test( - 'Config files (CJS, dev mode)', - { - 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()], - }) - `, - 'index.html': html` - - - - -
- - `, - 'tailwind.config.cjs': js` - const myColor = require('./my-color.cjs') - module.exports = { - theme: { - extend: { - colors: { - primary: myColor, - }, - }, - }, - } - `, - 'my-color.cjs': js`module.exports = 'blue'`, - 'src/index.css': css` - @import 'tailwindcss'; - @config '../tailwind.config.cjs'; - `, - }, - }, - 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 css = await fetchStyles(url, '/index.html') - expect(css).toContain(candidate`text-primary`) - expect(css).toContain('color: blue') - }) - - await retryAssertion(async () => { - await fs.write('my-color.cjs', js`module.exports = 'red'`) - - let css = await fetchStyles(url, '/index.html') - expect(css).toContain(candidate`text-primary`) - expect(css).toContain('color: red') - }) - }, -) - -test( - 'Config files (ESM, dev mode)', - { - 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()], - }) - `, - 'index.html': html` - - - - -
- - `, - 'tailwind.config.mjs': js` - import myColor from './my-color.mjs' - export default { - theme: { - extend: { - colors: { - primary: myColor, - }, - }, - }, - } - `, - 'my-color.mjs': js`export default 'blue'`, - 'src/index.css': css` - @import 'tailwindcss'; - @config '../tailwind.config.mjs'; - `, - }, - }, - 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 css = await fetchStyles(url, '/index.html') - expect(css).toContain(candidate`text-primary`) - expect(css).toContain('color: blue') - }) - - await retryAssertion(async () => { - await fs.write('my-color.mjs', js`export default 'red'`) - - let css = await fetchStyles(url, '/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 deleted file mode 100644 index a680ab9b7..000000000 --- a/integrations/vite/css-modules.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe } 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" - } - } - `, - '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'; - - .foo { - @apply underline; - } - `, - }, - }, - async ({ exec, fs, expect }) => { - await exec(`pnpm vite build`) - - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - let [filename] = files[0] - - 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 deleted file mode 100644 index c346f8e37..000000000 --- a/integrations/vite/index.test.ts +++ /dev/null @@ -1,1726 +0,0 @@ -import path from 'node:path' -import { describe } from 'vitest' -import { - candidate, - css, - fetchStyles, - html, - js, - json, - jsx, - retryAssertion, - test, - ts, - txt, - 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!
- - `, - ) - - 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` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''} - "vite": "${version}" - } - } - `, - '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()], - 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` - - - - - -
- - - - `, - '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
- } - `, - ) - - await retryAssertion(async () => { - let styles = await fetchStyles(url, '/index.html') - expect(styles).toContain(candidate`content-['updated:src/lazy.tsx']`) - }) - } - - // 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`
`, - ) - - await retryAssertion(async () => { - let styles = await fetchStyles(url, '/index.html') - expect(styles).toContain(candidate`content-['updated:src/comp-b.custom']`) - }) - } - - // 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 - { - let updates = (await fs.read('project-a/hmr.log')).split('"type":"update"').length - - await fs.write( - 'project-a/src/unimported.css', - css` - .unimported { - color: blue; - } - `, - ) - - // 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) - }) - } - - // 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 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" - } - } - `, - '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 } - `, - }, - }, - 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] - - // `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 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" - } - } - `, - '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 } - `, - }, - }, - 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] - - // `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']`, - ]) - - // 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']`, - ]) - }, - ) - - 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" - } - } - `, - '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 } - `, - }, - }, - 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') - - 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` -
- `, - }, - }, - 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`, - { - 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()], - }) - `, - 'index.html': html` - - - - -
Hello, world!
- - `, - 'about.html': html` - - - - -
Tailwind Labs
- - `, - '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')) - - 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`font-bold`) - }) - - await retryAssertion(async () => { - // 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) - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`font-bold`) - }) - }, -) - -test( - `does not interfere with ?raw and ?url static asset handling`, - { - 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()], - }) - `, - 'index.html': html` - - - - `, - 'src/index.js': js` - import url from './index.css?url' - import raw from './index.css?raw' - `, - '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) - }) - - 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()) - - 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()), - ]) - - expect(firstLine(raw)).toBe(`export default "@import 'tailwindcss';"`) - expect(firstLine(url)).toBe(`export default "/src/index.css"`) - }) - }, -) - -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 deleted file mode 100644 index 4020aff0f..000000000 --- a/integrations/vite/multi-root.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils' - -test( - `production build`, - { - 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 path from 'node:path' - import { defineConfig } from 'vite' - - export default defineConfig({ - build: { - cssMinify: false, - rollupOptions: { - input: { - root1: path.resolve(__dirname, 'root1.html'), - root2: path.resolve(__dirname, 'root2.html'), - }, - }, - }, - plugins: [tailwindcss()], - }) - `, - 'root1.html': html` - - - - -
Hello, world!
- - `, - 'src/shared.css': css` - @reference 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - `, - 'src/root1.css': css` - @import './shared.css'; - @custom-variant one (&:is([data-root='1'])); - `, - 'root2.html': html` - - - - -
Hello, world!
- - `, - 'src/root2.css': css` - @import './shared.css'; - @custom-variant two (&:is([data-root='2'])); - `, - }, - }, - async ({ fs, exec, expect }) => { - await exec('pnpm vite build') - - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(2) - - let root1 = files.find(([filename]) => filename.includes('root1')) - let root2 = files.find(([filename]) => filename.includes('root2')) - - expect(root1).toBeDefined() - expect(root2).toBeDefined() - - expect(root1![1]).toContain(candidate`one:underline`) - expect(root1![1]).not.toContain(candidate`two:underline`) - - expect(root2![1]).not.toContain(candidate`one:underline`) - expect(root2![1]).toContain(candidate`two:underline`) - }, -) - -test( - 'dev mode', - { - 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 path from 'node:path' - import { defineConfig } from 'vite' - - export default defineConfig({ - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'root1.html': html` - - - - -
Hello, world!
- - `, - 'src/shared.css': css` - @reference 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - `, - 'src/root1.css': css` - @import './shared.css'; - @custom-variant one (&:is([data-root='1'])); - `, - 'root2.html': html` - - - - -
Hello, world!
- - `, - 'src/root2.css': css` - @import './shared.css'; - @custom-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) - }) - - // 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') - expect(styles).toContain(candidate`one:underline`) - expect(styles).not.toContain(candidate`two:underline`) - }) - - // 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') - 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 deleted file mode 100644 index 20f367020..000000000 --- a/integrations/vite/nuxt.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { - candidate, - css, - fetchStyles, - getRandomPort, - html, - json, - retryAssertion, - test, - ts, - yaml, -} from '../utils' - -const SETUP = { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "nuxt": "3.16.0", - "nitropack": "2.11.0", - "tailwindcss": "workspace:^", - "vue": "latest" - } - } - `, - 'pnpm-workspace.yaml': yaml` - # - overrides: - nuxi: 3.28.0 - `, - 'nuxt.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - - // https://nuxt.com/docs/api/configuration/nuxt-config - export default defineNuxtConfig({ - vite: { - plugins: [tailwindcss()], - }, - - css: ['~/assets/css/main.css'], - devtools: { enabled: true }, - compatibilityDate: '2024-08-30', - }) - `, - 'app.vue': html` - - `, - 'assets/css/main.css': css`@import 'tailwindcss';`, - }, -} - -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')) - - await retryAssertion(async () => { - let css = await fetchStyles(url) - expect(css).toContain(candidate`underline`) - }) - - await retryAssertion(async () => { - await fs.write( - 'app.vue', - html` - - `, - ) - - let css = await fetchStyles(url) - expect(css).toContain(candidate`underline`) - expect(css).toContain(candidate`font-bold`) - }) -}) - -test('build', SETUP, async ({ spawn, exec, expect }) => { - await exec('pnpm nuxt build') - // The Nuxt preview server does not automatically assign a free port if 3000 - // is taken, so we use a random port instead. - let port = await getRandomPort() - let process = await spawn(`pnpm nuxt preview --port ${port}`, { - env: { - TEST: 'false', - NODE_ENV: 'development', - }, - }) - - let url = '' - await process.onStdout((m) => { - let match = /Listening on\s*(http.*)\/?/.exec(m) - if (match) url = match[1].replace('http://[::]', 'http://127.0.0.1') - return m.includes('Listening on') - }) - - await retryAssertion(async () => { - let css = await fetchStyles(url) - expect(css).toContain(candidate`underline`) - }) -}) diff --git a/integrations/vite/other-transforms.test.ts b/integrations/vite/other-transforms.test.ts deleted file mode 100644 index d56396065..000000000 --- a/integrations/vite/other-transforms.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import dedent from 'dedent' -import { describe } from 'vitest' -import { css, fetchStyles, html, retryAssertion, test, ts, txt } from '../utils' - -function createSetup(transformer: 'postcss' | 'lightningcss') { - return { - fs: { - 'package.json': txt` - { - "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(), - { - name: 'recolor', - transform(code, id) { - if (id.includes('.css')) { - return code.replace(/red;/g, 'blue;') - } - }, - }, - ], - }) - `, - 'index.html': html` - - - - -
Hello, world!
- - `, - 'src/index.css': css` - @reference 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - - .foo { - color: red; - } - `, - }, - } -} - -describe.each(['postcss', 'lightningcss'] as const)('%s', (transformer) => { - test(`production build`, createSetup(transformer), async ({ fs, exec, expect }) => { - await exec('pnpm vite build') - - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - let [filename] = files[0] - - await fs.expectFileToContain(filename, [ - css` - .foo { - color: blue; - } - `, - // Running the transforms on utilities generated by Tailwind might change in the future - dedent` - .\[background-color\:red\] { - background-color: blue; - } - `, - ]) - }) - - test('dev mode', createSetup(transformer), async ({ spawn, fs, 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).toContain(css` - .foo { - color: blue; - } - `) - // Running the transforms on utilities generated by Tailwind might change in the future - expect(styles).toContain(dedent` - .\[background-color\:red\] { - background-color: blue; - } - `) - }) - - await retryAssertion(async () => { - await fs.write( - 'src/index.css', - css` - @reference 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - - .foo { - background-color: red; - } - `, - ) - - let styles = await fetchStyles(url) - expect(styles).toContain(css` - .foo { - background-color: blue; - } - `) - }) - }) - - test('watch mode', createSetup(transformer), async ({ spawn, fs, expect }) => { - let process = await spawn('pnpm vite build --watch') - await process.onStdout((m) => m.includes('built in')) - - await retryAssertion(async () => { - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - let [, styles] = files[0] - - expect(styles).toContain(css` - .foo { - color: blue; - } - `) - // Running the transforms on utilities generated by Tailwind might change in the future - expect(styles).toContain(dedent` - .\[background-color\:red\] { - background-color: blue; - } - `) - }) - - await retryAssertion(async () => { - await fs.write( - 'src/index.css', - css` - @reference 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - - .foo { - background-color: red; - } - `, - ) - - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - let [, styles] = files[0] - - expect(styles).toContain(css` - .foo { - background-color: blue; - } - `) - }) - }) -}) diff --git a/integrations/vite/preact.test.ts b/integrations/vite/preact.test.ts deleted file mode 100644 index 8e4deec2b..000000000 --- a/integrations/vite/preact.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts, txt } from '../utils' - -test( - 'dev mode', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "preact": "^10" - }, - "devDependencies": { - "@preact/preset-vite": "^2", - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^", - "vite": "^8" - } - } - `, - 'vite.config.ts': ts` - import fs from 'node:fs' - import path from 'node:path' - import preact from '@preact/preset-vite' - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - plugins: [ - tailwindcss(), - preact(), - { - // 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) - } - } - }, - }, - ], - }) - `, - 'index.html': html` - - - - - -
- - - - `, - 'src/main.tsx': ts` - import { render } from 'preact' - import { App } from './app' - - render(, document.getElementById('app')!) - `, - 'src/app.tsx': ts` - import { useState } from 'preact/hooks' - - export function App() { - const [count, setCount] = useState(0) - return ( - - ) - } - `, - 'src/index.css': css`@import 'tailwindcss';`, - }, - }, - 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 styles = await fetchStyles(url) - expect(styles).toContain(candidate`underline`) - }) - - // Load the component modules, like a browser visiting the page would - await fetch(`${url}/src/main.tsx`) - await fetch(`${url}/src/app.tsx`) - - // Editing a component keeps HMR intact: new classes are delivered through - // a regular update, not a full page reload (which would lose all state) - { - await fs.write( - 'src/app.tsx', - ts` - import { useState } from 'preact/hooks' - - export function App() { - const [count, setCount] = useState(0) - return ( - - ) - } - `, - ) - - await retryAssertion(async () => { - let styles = await fetchStyles(url) - expect(styles).toContain(candidate`underline`) - expect(styles).toContain(candidate`flex`) - }) - expect(await fs.read('hmr.log')).toContain('"type":"update"') - expect(await fs.read('hmr.log')).not.toContain('full-reload') - } - - // Changing a scanned file that is not part of the module graph (e.g. - // `package.json`, which package managers and other tooling write to while - // the dev server is running) should not trigger a full reload either — - // that would destroy client state. New candidates should still be picked - // up because the file is a watch dependency of the CSS root, so the CSS - // hot-updates through Vite's regular pipeline. - // - // https://github.com/tailwindlabs/tailwindcss/issues/20411 - { - await fs.write( - 'package.json', - txt` - { - "type": "module", - "description": "content-['package.json']", - "dependencies": { - "preact": "^10" - }, - "devDependencies": { - "@preact/preset-vite": "^2", - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^", - "vite": "^8" - } - } - `, - ) - - await retryAssertion(async () => { - let styles = await fetchStyles(url) - expect(styles).toContain(candidate`content-['package.json']`) - }) - expect(await fs.read('hmr.log')).not.toContain('full-reload') - } - }, -) diff --git a/integrations/vite/qwik.test.ts b/integrations/vite/qwik.test.ts deleted file mode 100644 index 42db6563a..000000000 --- a/integrations/vite/qwik.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { candidate, css, fetchStyles, json, retryAssertion, test, ts } from '../utils' - -test( - 'dev mode', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@builder.io/qwik": "^1", - "@builder.io/qwik-city": "^1", - "vite": "^5", - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - } - } - `, - 'vite.config.ts': ts` - import { defineConfig } from 'vite' - import { qwikVite } from '@builder.io/qwik/optimizer' - import { qwikCity } from '@builder.io/qwik-city/vite' - import tailwindcss from '@tailwindcss/vite' - - export default defineConfig(() => { - return { - plugins: [tailwindcss(), qwikCity(), qwikVite()], - } - }) - `, - 'src/root.tsx': ts` - import { component$ } from '@builder.io/qwik' - import { QwikCityProvider, RouterOutlet } from '@builder.io/qwik-city' - - import './global.css' - - export default component$(() => { - return ( - - - - - - - ) - }) - `, - 'src/global.css': css`@import 'tailwindcss/utilities.css';`, - 'src/entry.ssr.tsx': ts` - import { renderToStream, type RenderToStreamOptions } from '@builder.io/qwik/server' - import Root from './root' - - export default function (opts: RenderToStreamOptions) { - return renderToStream(, opts) - } - `, - 'src/routes/index.tsx': ts` - import { component$ } from '@builder.io/qwik' - - export default component$(() => { - return

Hello World!

- }) - `, - }, - }, - async ({ fs, spawn, expect }) => { - let process = await spawn('pnpm vite --mode ssr') - await process.onStdout((m) => m.includes('ready in')) - - let url = '' - await process.onStdout((m) => { - console.log(m) - let match = /Local:\s*(http.*)\//.exec(m) - if (match) url = match[1] - return Boolean(url) - }) - - await retryAssertion(async () => { - let css = await fetchStyles(url) - expect(css).toContain(candidate`underline`) - }) - - await retryAssertion(async () => { - await fs.write( - 'src/routes/index.tsx', - ts` - import { component$ } from '@builder.io/qwik' - - export default component$(() => { - return

Hello World!

- }) - `, - ) - - let css = await fetchStyles(url) - expect(css).toContain(candidate`underline`) - expect(css).toContain(candidate`flex`) - }) - }, -) diff --git a/integrations/vite/react-router.test.ts b/integrations/vite/react-router.test.ts deleted file mode 100644 index 98b0c928d..000000000 --- a/integrations/vite/react-router.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { candidate, css, fetchStyles, json, retryAssertion, test, ts, txt } from '../utils' - -const WORKSPACE = { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@react-router/dev": "^7", - "@react-router/node": "^7", - "@react-router/serve": "^7", - "@tailwindcss/vite": "workspace:^", - "@types/node": "^20", - "@types/react-dom": "^19", - "@types/react": "^19", - "isbot": "^5", - "react-dom": "^19", - "react-router": "^7", - "react": "^19", - "tailwindcss": "workspace:^", - "vite": "^5" - } - } - `, - 'react-router.config.ts': ts` - import type { Config } from '@react-router/dev/config' - export default { ssr: true } satisfies Config - `, - 'vite.config.ts': ts` - import { defineConfig } from 'vite' - import { reactRouter } from '@react-router/dev/vite' - import tailwindcss from '@tailwindcss/vite' - - export default defineConfig({ - plugins: [tailwindcss(), reactRouter()], - }) - `, - 'app/routes/home.tsx': ts` - export default function Home() { - return

Welcome to React Router

- } - `, - 'app/app.css': css`@import 'tailwindcss';`, - 'app/routes.ts': ts` - import { type RouteConfig, index } from '@react-router/dev/routes' - export default [index('routes/home.tsx')] satisfies RouteConfig - `, - 'app/root.tsx': ts` - import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router' - import './app.css' - export function Layout({ children }: { children: React.ReactNode }) { - return ( - - - - - - - - - {children} - - - - - ) - } - - export default function App() { - return - } - `, -} - -test('dev mode', { fs: WORKSPACE }, async ({ fs, spawn, expect }) => { - let process = await spawn('pnpm react-router dev') - - 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 css = await fetchStyles(url) - expect(css).toContain(candidate`font-bold`) - }) - - await retryAssertion(async () => { - await fs.write( - 'app/routes/home.tsx', - ts` - export default function Home() { - return

Welcome to React Router

- } - `, - ) - - let css = await fetchStyles(url) - expect(css).toContain(candidate`underline`) - expect(css).toContain(candidate`font-bold`) - }) -}) - -test( - // cf. https://github.com/remix-run/react-router/blob/00cb4d7b310663b2e84152700c05d3b503005e83/integration/vite-hmr-hdr-test.ts#L311-L318 - 'dev mode, editing a server-only loader dependency triggers HDR instead of a full reload', - { - fs: { - ...WORKSPACE, - 'package.json': json` - { - "type": "module", - "dependencies": { - "@react-router/dev": "^7", - "@react-router/node": "^7", - "@react-router/serve": "^7", - "@tailwindcss/vite": "workspace:^", - "@types/node": "^20", - "@types/react-dom": "^19", - "@types/react": "^19", - "isbot": "^5", - "react-dom": "^19", - "react-router": "^7", - "react": "^19", - "tailwindcss": "workspace:^", - "vite": "^7" - } - } - `, - 'app/routes/home.tsx': ts` - import type { Route } from './+types/home' - import { direct } from '../direct-hdr-dep' - - export async function loader() { - return { message: direct } - } - - export default function Home({ loaderData }: Route.ComponentProps) { - return ( -
-

{loaderData.message}

- -
- ) - } - `, - 'app/direct-hdr-dep.ts': ts` export const direct = 'HDR: 0' `, - }, - }, - async ({ fs, spawn, expect }) => { - let process = await spawn('pnpm react-router dev') - - let url = '' - await process.onStdout((m) => { - let match = /Local:\s*(http.*)\//.exec(m) - if (match) url = match[1] - return Boolean(url) - }) - - // check initial state - await retryAssertion(async () => { - let html = await (await fetch(url)).text() - expect(html).toContain('HDR: 0') - - let css = await fetchStyles(url) - expect(css).toContain(candidate`font-bold`) - }) - - // Flush stdout so we only see messages triggered by the edit below. - process.flush() - - // Edit the server-only module. The client environment watches this file - // but it only exists in the server module graph. Without the fix, the - // Tailwind CSS plugin would trigger a full page reload on the client - // instead of letting react-router handle HDR. - await fs.write('app/direct-hdr-dep.ts', ts` export const direct = 'HDR: 1' `) - - // check update - await retryAssertion(async () => { - let html = await (await fetch(url)).text() - expect(html).toContain('HDR: 1') - - let css = await fetchStyles(url) - expect(css).toContain(candidate`font-bold`) - }) - - // Assert the client receives an HMR update (not a full page reload). - await process.onStdout((m) => m.includes('(client) hmr update')) - }, -) - -test('build mode', { fs: WORKSPACE }, async ({ spawn, exec, expect }) => { - await exec('pnpm react-router build') - let process = await spawn('pnpm react-router-serve ./build/server/index.js') - - let url = '' - await process.onStdout((m) => { - let match = /\[react-router-serve\]\s*(http.*) \/?/.exec(m) - if (match) url = match[1] - return url != '' - }) - - await retryAssertion(async () => { - let css = await fetchStyles(url) - expect(css).toContain(candidate`font-bold`) - }) -}) - -test( - 'build mode using ?url stylesheet imports should only build one stylesheet (requires `file-system` scanner)', - { - fs: { - ...WORKSPACE, - 'app/root.tsx': ts` - import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router' - import styles from './app.css?url' - export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: styles }] - export function Layout({ children }: { children: React.ReactNode }) { - return ( - - - - - - - - - {children} - - - - - ) - } - - export default function App() { - return - } - `, - 'vite.config.ts': ts` - import { defineConfig } from 'vite' - import { reactRouter } from '@react-router/dev/vite' - import tailwindcss from '@tailwindcss/vite' - - export default defineConfig({ - plugins: [tailwindcss(), reactRouter()], - }) - `, - '.gitignore': txt` - node_modules/ - build/ - `, - }, - }, - async ({ fs, exec, expect }) => { - await exec('pnpm react-router build') - - let files = await fs.glob('build/client/assets/**/*.css') - - expect(files).toHaveLength(1) - let [filename] = files[0] - - await fs.expectFileToContain(filename, [candidate`font-bold`]) - }, -) diff --git a/integrations/vite/resolvers.test.ts b/integrations/vite/resolvers.test.ts deleted file mode 100644 index fc9aca5c7..000000000 --- a/integrations/vite/resolvers.test.ts +++ /dev/null @@ -1,804 +0,0 @@ -import { describe } from 'vitest' -import { - candidate, - css, - fetchStyles, - html, - js, - json, - retryAssertion, - test, - ts, - txt, - yaml, -} from '../utils' - -test( - 'resolves tsconfig paths in production build', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "^8" - } - } - `, - 'tsconfig.json': json` - { - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"] - } - } - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - build: { cssMinify: false }, - plugins: [tailwindcss()], - resolve: { - tsconfigPaths: true, - }, - }) - `, - 'index.html': html` - - - - -
Hello, world!
- - `, - 'src/index.css': css` - @import '@/styles/base.css'; - @plugin '@/plugin.js'; - `, - 'src/styles/base.css': css` - @reference 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - `, - 'src/plugin.js': js` - export default function ({ addUtilities }) { - addUtilities({ '.custom-underline': { 'border-bottom': '1px solid green' } }) - } - `, - }, - }, - async ({ fs, exec, expect }) => { - 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`underline`, candidate`custom-underline`]) - }, -) - -test( - 'resolves tsconfig paths in dev mode', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "^8" - } - } - `, - 'tsconfig.json': json` - { - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"] - } - } - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - build: { cssMinify: false }, - plugins: [tailwindcss()], - resolve: { - tsconfigPaths: true, - }, - }) - `, - 'index.html': html` - - - - -
Hello, world!
- - `, - 'src/index.css': css` - @import '@/styles/base.css'; - @plugin '@/plugin.js'; - `, - 'src/styles/base.css': css` - @reference 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - `, - 'src/plugin.js': js` - export default function ({ addUtilities }) { - addUtilities({ '.custom-underline': { 'border-bottom': '1px solid green' } }) - } - `, - }, - }, - 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).toContain(candidate`underline`) - expect(styles).toContain(candidate`custom-underline`) - }) - }, -) - -test( - 'resolves at-sign aliases in production build', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "^8" - } - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - import { fileURLToPath } from 'node:url' - - export default defineConfig({ - build: { cssMinify: false }, - plugins: [tailwindcss()], - resolve: { - alias: [{ find: '@', replacement: fileURLToPath(new URL('.', import.meta.url)) }], - }, - }) - `, - 'index.html': html` - - - - -
Hello, world!
- - `, - 'src/index.css': css` - @import '@/src/styles/base.css'; - @plugin '@/src/plugin.js'; - `, - 'src/styles/base.css': css` - @reference 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - `, - 'src/plugin.js': js` - export default function ({ addUtilities }) { - addUtilities({ '.custom-underline': { 'border-bottom': '1px solid green' } }) - } - `, - }, - }, - async ({ fs, exec, expect }) => { - 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`underline`, candidate`custom-underline`]) - }, -) - -test( - 'resolves package plugins in production build with at-sign aliases', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "tailwind-scrollbar": "^4.0.2", - "tailwindcss-motion": "^1.1.1", - "vite": "^8" - } - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - import { fileURLToPath } from 'node:url' - - export default defineConfig({ - build: { cssMinify: false }, - plugins: [tailwindcss()], - resolve: { - alias: [{ find: '@', replacement: fileURLToPath(new URL('.', import.meta.url)) }], - }, - }) - `, - 'index.html': html` - - - - -
Hello, world!
- - `, - 'src/index.css': css` - @import 'tailwindcss'; - @plugin 'tailwind-scrollbar'; - @plugin 'tailwindcss-motion'; - `, - }, - }, - async ({ fs, exec, expect }) => { - 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`scrollbar`, - candidate`scrollbar-thumb-red-500`, - candidate`motion-preset-fade`, - ]) - }, -) - -test( - 'resolves package plugins to JS entries in production build when browser points to CSS', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "plugin-browser-css": "workspace:*", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "^8" - } - } - `, - 'pnpm-workspace.yaml': yaml` - # - packages: - - packages/* - `, - 'packages/plugin-browser-css/package.json': json` - { - "name": "plugin-browser-css", - "version": "1.0.0", - "type": "module", - "main": "./index.js", - "module": "./index.js", - "browser": "./browser.css" - } - `, - 'packages/plugin-browser-css/index.js': js` - export default function ({ addUtilities }) { - addUtilities({ '.browser-css-plugin': { 'border-bottom': '1px solid green' } }) - } - `, - 'packages/plugin-browser-css/browser.css': css` - .should-not-be-loaded-as-a-plugin { - display: none; - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'index.html': html` - - - - - -
Hello, world!
- - - `, - 'src/index.css': css` - @import 'tailwindcss'; - @plugin 'plugin-browser-css'; - `, - }, - }, - async ({ fs, exec, expect }) => { - 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`browser-css-plugin`]) - }, -) - -test( - 'resolves package plugins to JS entries in dev mode when browser points to CSS', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "plugin-browser-css": "workspace:*", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "^8" - } - } - `, - 'pnpm-workspace.yaml': yaml` - # - packages: - - packages/* - `, - 'packages/plugin-browser-css/package.json': json` - { - "name": "plugin-browser-css", - "version": "1.0.0", - "type": "module", - "main": "./index.js", - "module": "./index.js", - "browser": "./browser.css" - } - `, - 'packages/plugin-browser-css/index.js': js` - export default function ({ addUtilities }) { - addUtilities({ '.browser-css-plugin': { 'border-bottom': '1px solid green' } }) - } - `, - 'packages/plugin-browser-css/browser.css': css` - .should-not-be-loaded-as-a-plugin { - display: none; - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'index.html': html` - - - - - -
Hello, world!
- - - `, - 'src/index.css': css` - @import 'tailwindcss'; - @plugin 'plugin-browser-css'; - `, - }, - }, - 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).toContain(candidate`browser-css-plugin`) - }) - }, -) - -test( - 'resolves package plugins in dev mode when package exports CSS files', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "daisyui": "^5", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "^8" - } - } - `, - '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.css': css` - @import 'tailwindcss'; - @plugin 'daisyui'; - `, - }, - }, - 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).toContain('.btn') - expect(styles).toContain('.btn-primary') - }) - }, -) - -test( - 'resolve relative CSS files correctly', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "^8" - } - } - `, - '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.css': css` - @reference 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - @import './themes/glow.css'; - `, - // References a file in the current folder, which names happens to match a - // file in the parent folder as well. - 'src/themes/glow.css': css`@import './entry.css';`, - 'src/themes/entry.css': css` - .do-include-me { - color: green; - } - `, - - // Never rerefenced, so should not be included - 'src/entry.css': css` - .do-not-include-me { - color: red; - } - `, - }, - }, - async ({ exec, fs, expect }) => { - await exec('pnpm vite build') - - expect( - (await fs.dumpFiles('./dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-.css'), - ).toMatchInlineSnapshot(` - " - --- ./dist/assets/index-.css --- - .do-include-me { - color: green; - } - " - `) - }, -) - -test( - 'resolve relative JS files correctly', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "^8" - } - } - `, - '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.css': css` - @reference 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - @import './themes/glow.css'; - `, - // References a file in the current folder, which names happens to match a - // file in the parent folder as well. - 'src/themes/glow.css': css`@plugin "./my-plugin.js";`, - 'src/themes/my-plugin.js': ts` - export default function ({ addBase }) { - addBase({ '.do-include-me': { color: 'green' } }) - } - `, - - // Never rerefenced, so should not be included - 'src/my-plugin.js': css` - export default function ({ addBase }) { - addBase({ '.do-not-include-me': { 'color': 'red' } }) - } - `, - }, - }, - async ({ exec, fs, expect }) => { - await exec('pnpm vite build') - - expect( - (await fs.dumpFiles('./dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-.css'), - ).toMatchInlineSnapshot(` - " - --- ./dist/assets/index-.css --- - @layer base { - .do-include-me { - color: green; - } - } - " - `) - }, -) - -describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { - test( - 'resolves aliases in production build', - { - fs: { - 'package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''} - "vite": "^5.3.5" - } - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - import { fileURLToPath } from 'node:url' - - export default defineConfig({ - css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, - build: { cssMinify: false }, - plugins: [tailwindcss()], - resolve: { - alias: { - '#css-alias': fileURLToPath(new URL('./src/alias.css', import.meta.url)), - '#js-alias': fileURLToPath(new URL('./src/plugin.js', import.meta.url)), - }, - }, - }) - `, - 'index.html': html` - - - - -
Hello, world!
- - `, - 'src/index.css': css` - @import '#css-alias'; - @plugin '#js-alias'; - `, - 'src/alias.css': css` - @reference 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - `, - 'src/plugin.js': js` - export default function ({ addUtilities }) { - addUtilities({ '.custom-underline': { 'border-bottom': '1px solid green' } }) - } - `, - }, - }, - async ({ fs, exec, expect }) => { - 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`underline`, candidate`custom-underline`]) - }, - ) - - test( - 'resolves aliases in dev mode', - { - fs: { - 'package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''} - "vite": "^5.3.5" - } - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - import { fileURLToPath } from 'node:url' - - export default defineConfig({ - css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, - build: { cssMinify: false }, - plugins: [tailwindcss()], - resolve: { - alias: { - '#css-alias': fileURLToPath(new URL('./src/alias.css', import.meta.url)), - '#js-alias': fileURLToPath(new URL('./src/plugin.js', import.meta.url)), - }, - }, - }) - `, - 'index.html': html` - - - - -
Hello, world!
- - `, - 'src/index.css': css` - @import '#css-alias'; - @plugin '#js-alias'; - `, - 'src/alias.css': css` - @reference 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - `, - 'src/plugin.js': js` - export default function ({ addUtilities }) { - addUtilities({ '.custom-underline': { 'border-bottom': '1px solid green' } }) - } - `, - }, - }, - 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).toContain(candidate`underline`) - expect(styles).toContain(candidate`custom-underline`) - }) - }, - ) -}) diff --git a/integrations/vite/solidstart.test.ts b/integrations/vite/solidstart.test.ts deleted file mode 100644 index bbe9ccc88..000000000 --- a/integrations/vite/solidstart.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { candidate, css, fetchStyles, js, json, retryAssertion, test, ts } from '../utils' - -const WORKSPACE = { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@solidjs/start": "^1", - "solid-js": "^1", - "vinxi": "^0", - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - } - } - `, - 'jsconfig.json': json` - { - "compilerOptions": { - "jsx": "preserve", - "jsxImportSource": "solid-js" - } - } - `, - 'app.config.js': ts` - import { defineConfig } from '@solidjs/start/config' - import tailwindcss from '@tailwindcss/vite' - - export default defineConfig({ - vite: { - plugins: [tailwindcss()], - }, - }) - `, - 'src/entry-server.jsx': js` - // @refresh reload - import { createHandler, StartServer } from '@solidjs/start/server' - - export default createHandler(() => ( - ( - - {assets} - -
{children}
- {scripts} - - - )} - /> - )) - `, - 'src/entry-client.jsx': js` - // @refresh reload - import { mount, StartClient } from '@solidjs/start/client' - - mount(() => , document.getElementById('app')) - `, - 'src/app.jsx': js` - import './app.css' - export default function App() { - return

Hello world!

- } - `, - 'src/app.css': css`@import 'tailwindcss';`, -} - -test( - 'dev mode', - { - fs: WORKSPACE, - }, - async ({ fs, spawn, expect }) => { - let process = await spawn('pnpm vinxi 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 retryAssertion(async () => { - let css = await fetchStyles(url) - expect(css).toContain(candidate`underline`) - }) - - await retryAssertion(async () => { - await fs.write( - 'src/app.jsx', - js` - import './app.css' - export default function App() { - return

Hello world!

- } - `, - ) - - let css = await fetchStyles(url) - expect(css).toContain(candidate`underline`) - expect(css).toContain(candidate`font-bold`) - }) - }, -) diff --git a/integrations/vite/source-maps.test.ts b/integrations/vite/source-maps.test.ts deleted file mode 100644 index fe26d9a7c..000000000 --- a/integrations/vite/source-maps.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils' - -test( - `dev build`, - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "lightningcss": "^1", - "vite": "^7" - } - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - plugins: [tailwindcss()], - css: { - devSourcemap: true, - }, - }) - `, - 'index.html': html` - - - - -
Hello, world!
- - `, - 'src/index.css': css` - @import 'tailwindcss/utilities'; - /* */ - `, - }, - }, - async ({ fs, spawn, expect, parseSourceMap }) => { - // Source maps only work in development mode in Vite - 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) - }) - - let styles = await retryAssertion(async () => { - let styles = await fetchStyles(url, '/index.html') - - // Wait until we have the right CSS - expect(styles).toContain(candidate`flex`) - - return styles - }) - - // Make sure we can find a source map - let map = parseSourceMap(styles) - - expect(map.at(1, 0)).toMatchObject({ - source: null, - original: '(none)', - generated: '/*! tailwi...', - }) - - expect(map.at(2, 0)).toMatchObject({ - source: expect.stringContaining('utilities.css'), - original: '@tailwind...', - generated: '.flex {...', - }) - - expect(map.at(3, 2)).toMatchObject({ - source: expect.stringContaining('utilities.css'), - original: '@tailwind...', - generated: 'display: f...', - }) - - expect(map.at(4, 0)).toMatchObject({ - source: null, - original: '(none)', - generated: '}...', - }) - }, -) - -// https://github.com/tailwindlabs/tailwindcss/issues/19930 -test( - 'production build without Tailwind roots should not result in source map warnings', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "^7" - } - } - `, - 'vite.config.ts': ts` - import { defineConfig } from 'vite' - import tailwindcss from '@tailwindcss/vite' - - export default defineConfig({ - plugins: [ - tailwindcss(), - { - name: 'inspect-source-map-chain', - enforce: 'pre', - transform(_, id) { - if (id.includes('.css')) { - // Force Rollup to collapse the sourcemap chain during this build. - this.getCombinedSourcemap() - } - }, - }, - ], - css: { - devSourcemap: true, - }, - build: { - sourcemap: true, - }, - }) - `, - 'index.html': html` - - -
Hello, world!
- - `, - 'src/index.js': ts` import './index.css' `, - 'src/index.css': css` - body { - color: red; - } - `, - }, - }, - async ({ exec, expect }) => { - let output = await exec('pnpm vite build') - - expect(output).not.toContain('Sourcemap is likely to be incorrect') - }, -) diff --git a/integrations/vite/ssr.test.ts b/integrations/vite/ssr.test.ts deleted file mode 100644 index a1d660816..000000000 --- a/integrations/vite/ssr.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe } from 'vitest' -import { candidate, css, html, json, test, ts } from '../utils' - -describe.each([['^5.3'], ['^6.0'], ['^7'], ['^8']])('Using Vite %s', (version) => { - test( - `SSR build`, - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "${version}" - } - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - build: { - cssMinify: false, - ssrEmitAssets: true, - }, - plugins: [tailwindcss()], - }) - `, - 'index.html': html` - -
- - - `, - 'src/index.css': css`@import 'tailwindcss';`, - 'src/index.ts': ts` - import './index.css' - - document.querySelector('#app').innerHTML = \` -
Hello, world!
- \` - `, - 'server.ts': ts` - import css from './src/index.css?url' - - document.querySelector('#app').innerHTML = \` - -
Hello, world!
- \` - `, - }, - }, - async ({ fs, exec, expect }) => { - await exec('pnpm vite build --ssr server.ts') - - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - let [filename] = files[0] - - await fs.expectFileToContain(filename, [ - candidate`underline`, - candidate`m-2`, - candidate`overline`, - candidate`m-3`, - ]) - }, - ) -}) diff --git a/integrations/vite/svelte.test.ts b/integrations/vite/svelte.test.ts deleted file mode 100644 index e186c3f85..000000000 --- a/integrations/vite/svelte.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { candidate, css, html, json, retryAssertion, test, ts } from '../utils' - -test( - 'production build', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "svelte": "^5", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^5", - "@tailwindcss/vite": "workspace:^", - "vite": "^7" - } - } - `, - 'vite.config.ts': ts` - import { defineConfig } from 'vite' - import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte' - import tailwindcss from '@tailwindcss/vite' - - export default defineConfig({ - plugins: [ - svelte({ - preprocess: [vitePreprocess()], - }), - tailwindcss(), - ], - }) - `, - 'index.html': html` - - - -
- - - - `, - 'src/main.ts': ts` - import App from './App.svelte' - const app = new App({ - target: document.body, - }) - `, - 'src/index.css': css`@import 'tailwindcss';`, - 'src/App.svelte': html` - - -

Hello {name}!

- - - `, - 'src/other.css': css` - .local { - @apply text-red-500; - animation: 2s ease-in-out infinite localKeyframes; - } - - :global(.global) { - @apply text-green-500; - animation: 2s ease-in-out infinite globalKeyframes; - } - - @keyframes -global-globalKeyframes { - 0% { - opacity: 0; - } - 100% { - opacity: 100%; - } - } - - @keyframes localKeyframes { - 0% { - opacity: 0; - } - 100% { - opacity: 100%; - } - } - `, - }, - }, - async ({ exec, fs, expect }) => { - let output = await exec('pnpm vite build') - - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - - await fs.expectFileToContain(files[0][0], [ - candidate`underline`, - '.global{color:var(--color-green-500,oklch(72.3% .219 149.579));animation:2s ease-in-out infinite globalKeyframes}', - /\.local.svelte-.*\{color:var\(--color-red-500,oklch\(63\.7% \.237 25\.331\)\);animation:2s ease-in-out infinite svelte-.*-localKeyframes\}/, - /@keyframes globalKeyframes\{/, - /@keyframes svelte-.*-localKeyframes\{/, - ]) - - // Should not print any warnings - expect(output).not.toContain('vite-plugin-svelte') - }, -) - -test( - 'watch mode', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "svelte": "^5", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^5", - "@tailwindcss/vite": "workspace:^", - "vite": "^7" - } - } - `, - 'vite.config.ts': ts` - import { defineConfig } from 'vite' - import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte' - import tailwindcss from '@tailwindcss/vite' - - export default defineConfig({ - plugins: [ - svelte({ - preprocess: [vitePreprocess()], - }), - tailwindcss(), - ], - }) - `, - 'index.html': html` - - - -
- - - - `, - 'src/main.ts': ts` - import App from './App.svelte' - const app = new App({ - target: document.body, - }) - `, - 'src/App.svelte': html` - - -

Hello {name}!

- - - `, - 'src/index.css': css` @import 'tailwindcss'; `, - 'src/other.css': css` - .local { - @apply text-red-500; - animation: 2s ease-in-out infinite localKeyframes; - } - - :global(.global) { - @apply text-green-500; - animation: 2s ease-in-out infinite globalKeyframes; - } - - @keyframes -global-globalKeyframes { - 0% { - opacity: 0; - } - 100% { - opacity: 100%; - } - } - - @keyframes localKeyframes { - 0% { - opacity: 0; - } - 100% { - opacity: 100%; - } - } - `, - }, - }, - async ({ fs, spawn, expect }) => { - let process = await spawn(`pnpm vite build --watch`) - await process.onStdout((m) => m.includes('built in')) - - await retryAssertion(async () => { - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - let [, css] = files[0] - expect(css).toContain(candidate`underline`) - expect(css).toContain( - '.global{color:var(--color-green-500,oklch(72.3% .219 149.579));animation:2s ease-in-out infinite globalKeyframes}', - ) - expect(css).toMatch( - /\.local.svelte-.*\{color:var\(--color-red-500,oklch\(63\.7% \.237 25\.331\)\);animation:2s ease-in-out infinite svelte-.*-localKeyframes\}/, - ) - expect(css).toMatch(/@keyframes globalKeyframes\{/) - expect(css).toMatch(/@keyframes svelte-.*-localKeyframes\{/) - }) - - await fs.write( - 'src/App.svelte', - (await fs.read('src/App.svelte')).replace('underline', 'font-bold bar'), - ) - - await fs.write( - 'src/other.css', - `${await fs.read('src/other.css')}\n.bar { @apply text-pink-500; }`, - ) - - await retryAssertion(async () => { - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - let [, css] = files[0] - expect(css).toContain(candidate`font-bold`) - expect(css).toContain( - '.global{color:var(--color-green-500,oklch(72.3% .219 149.579));animation:2s ease-in-out infinite globalKeyframes}', - ) - expect(css).toMatch( - /\.local.svelte-.*\{color:var\(--color-red-500,oklch\(63\.7% \.237 25\.331\)\);animation:2s ease-in-out infinite svelte-.*-localKeyframes\}/, - ) - expect(css).toMatch(/@keyframes globalKeyframes\{/) - expect(css).toMatch(/@keyframes svelte-.*-localKeyframes\{/) - expect(css).toMatch( - /\.bar.svelte-.*\{color:var\(--color-pink-500,oklch\(65\.6% \.241 354\.308\)\)\}/, - ) - }) - }, -) diff --git a/integrations/vite/sveltekit.test.ts b/integrations/vite/sveltekit.test.ts deleted file mode 100644 index 92e855da9..000000000 --- a/integrations/vite/sveltekit.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { candidate, css, html, js, json, test, ts } from '../utils' - -test( - 'sveltekit build resolves package plugins', - { - fs: { - 'package.json': json` - { - "type": "module", - "scripts": { - "prepare": "svelte-kit sync || echo ''" - }, - "dependencies": { - "svelte": "^5", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "@sveltejs/adapter-auto": "^6", - "@sveltejs/kit": "^2", - "@tailwindcss/forms": "^0.5.11", - "@tailwindcss/vite": "workspace:^", - "vite": "^8" - } - } - `, - 'svelte.config.js': js` - import adapter from '@sveltejs/adapter-auto' - - export default { - kit: { - adapter: adapter(), - }, - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { sveltekit } from '@sveltejs/kit/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - build: { cssMinify: false }, - plugins: [tailwindcss(), sveltekit()], - }) - `, - 'src/app.html': html` - - - - %sveltekit.head% - - -
%sveltekit.body%
- - - `, - 'src/routes/+layout.svelte': html` - - - - `, - 'src/routes/+page.svelte': html` -
Hello, world!
- `, - 'src/app.css': css` - @import 'tailwindcss'; - @plugin '@tailwindcss/forms'; - `, - }, - }, - async ({ exec, fs, expect }) => { - await exec('pnpm vite build') - - let files = await fs.glob('.svelte-kit/output/client/**/*.css') - expect(files.length).toBeGreaterThan(0) - - await fs.expectFileToContain(files[0][0], [candidate`form-input`]) - }, -) diff --git a/integrations/vite/url-rewriting.test.ts b/integrations/vite/url-rewriting.test.ts deleted file mode 100644 index 500fe6d71..000000000 --- a/integrations/vite/url-rewriting.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe } from 'vitest' -import { binary, css, html, svg, test, ts, txt } from '../utils' - -const SIMPLE_IMAGE = `iVBORw0KGgoAAAANSUhEUgAAADAAAAAlAQAAAAAsYlcCAAAACklEQVR4AWMYBQABAwABRUEDtQAAAABJRU5ErkJggg==` - -describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { - test( - 'can rewrite urls in production builds', - { - fs: { - 'package.json': txt` - { - "type": "module", - "dependencies": { - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''} - "@tailwindcss/vite": "workspace:^", - "vite": "^7" - } - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - plugins: [tailwindcss()], - build: { - assetsInlineLimit: 256, - cssMinify: false, - }, - css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, - }) - `, - 'index.html': html` - - - - - - -
- - - `, - 'src/app.css': css` - @reference 'tailwindcss'; - @import './dir-1/bar.css'; - @import './dir-1/dir-2/baz.css'; - @import './dir-1/dir-2/vector.css'; - `, - 'src/dir-1/bar.css': css` - .test1 { - background-image: url('../../resources/image.png'); - } - `, - 'src/dir-1/dir-2/baz.css': css` - .test2 { - background-image: url('../../../resources/image.png'); - } - `, - 'src/dir-1/dir-2/vector.css': css` - @import './dir-3/vector.css'; - .test3 { - background-image: url('../../../resources/vector.svg'); - } - `, - 'src/dir-1/dir-2/dir-3/vector.css': css` - .test4 { - background-image: url('./vector-2.svg'); - } - `, - 'resources/image.png': binary(SIMPLE_IMAGE), - 'resources/vector.svg': svg` - - - - - - - `, - 'src/dir-1/dir-2/dir-3/vector-2.svg': svg` - - - - - - - `, - }, - }, - async ({ fs, exec, expect }) => { - await exec('pnpm vite build') - - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - - await fs.expectFileToContain(files[0][0], [SIMPLE_IMAGE]) - - let images = await fs.glob('dist/**/*.svg') - expect(images).toHaveLength(2) - - await fs.expectFileToContain(files[0][0], [/\/assets\/vector-.*?\.svg/]) - }, - ) -}) diff --git a/integrations/vite/virtual-modules.test.ts b/integrations/vite/virtual-modules.test.ts deleted file mode 100644 index 12eadb6c7..000000000 Binary files a/integrations/vite/virtual-modules.test.ts and /dev/null differ diff --git a/integrations/vite/vue.test.ts b/integrations/vite/vue.test.ts deleted file mode 100644 index b04f57c6b..000000000 --- a/integrations/vite/vue.test.ts +++ /dev/null @@ -1,380 +0,0 @@ -import { stripVTControlCharacters } from 'node:util' -import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils' - -test( - 'production build', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "vue": "^3.4.37", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "@vitejs/plugin-vue": "^5.1.2", - "@tailwindcss/vite": "workspace:^", - "vite": "^7" - } - } - `, - 'vite.config.ts': ts` - import { defineConfig } from 'vite' - import vue from '@vitejs/plugin-vue' - import tailwindcss from '@tailwindcss/vite' - - export default defineConfig({ - plugins: [vue(), tailwindcss()], - }) - `, - 'index.html': html` - - - -
- - - - `, - 'src/main.ts': ts` - import { createApp } from 'vue' - import App from './App.vue' - - createApp(App).mount('#app') - `, - 'src/App.vue': html` - - - - `, - }, - }, - async ({ fs, exec, expect }) => { - await exec('pnpm vite build') - - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - - await fs.expectFileToContain(files[0][0], [candidate`underline`, candidate`foo`]) - await fs.expectFileToContain(files[0][0], ['.bar{']) - }, -) - -{ - const VUE_COMPONENT_COUNT = 1_000 - - let vueComponentsWithReferences = Object.fromEntries( - Array.from({ length: VUE_COMPONENT_COUNT }, (_, idx) => [ - `src/components/Component${idx}.vue`, - html` - - - - `, - ]), - ) - - let vueComponentImports = Array.from( - { length: VUE_COMPONENT_COUNT }, - (_, idx) => `import Component${idx} from './components/Component${idx}.vue'`, - ).join('\n') - - let vueComponentUsages = Array.from( - { length: VUE_COMPONENT_COUNT }, - (_, idx) => ``, - ).join('\n') - - test( - 'production build with many Vue style blocks referencing the main stylesheet', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "vue": "^3.4.37", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "@vitejs/plugin-vue": "^5.1.2", - "@tailwindcss/vite": "workspace:^", - "vite": "^7" - } - } - `, - 'vite.config.ts': ts` - import { defineConfig } from 'vite' - import vue from '@vitejs/plugin-vue' - import tailwindcss from '@tailwindcss/vite' - - export default defineConfig({ - plugins: [vue(), tailwindcss()], - }) - `, - 'index.html': html` - - - -
- - - - `, - 'src/main.css': css`@import 'tailwindcss';`, - 'src/main.ts': ts` - import { createApp } from 'vue' - import './main.css' - import App from './App.vue' - - createApp(App).mount('#app') - `, - 'src/App.vue': html` - - - - `, - ...vueComponentsWithReferences, - }, - }, - async ({ fs, exec, expect }) => { - await exec('pnpm vite build') - - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - - await fs.expectFileToContain(files[0][0], [ - candidate`content-['component-0']`, - candidate`component-0`, - candidate`content-['component-99']`, - candidate`component-99`, - candidate`content-['component-999']`, - candidate`component-999`, - ]) - }, - ) -} - -test( - 'error when using `@apply` without `@reference`', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "vue": "^3.4.37", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "@vitejs/plugin-vue": "^5.1.2", - "@tailwindcss/vite": "workspace:^", - "vite": "^7" - } - } - `, - 'vite.config.ts': ts` - import { defineConfig } from 'vite' - import vue from '@vitejs/plugin-vue' - import tailwindcss from '@tailwindcss/vite' - - export default defineConfig({ - plugins: [vue(), tailwindcss()], - }) - `, - 'index.html': html` - - - -
- - - - `, - 'src/main.ts': ts` - import { createApp } from 'vue' - import App from './App.vue' - - createApp(App).mount('#app') - `, - 'src/App.vue': html` - - - - `, - }, - }, - async ({ exec, expect }) => { - expect.assertions(1) - - try { - await exec('pnpm vite build', {}, { ignoreStdErr: true }) - } catch (error) { - let [, message] = - /error during build:([\s\S]*?)file:/g.exec( - stripVTControlCharacters(error.message.replace(/\r?\n/g, '\n')), - ) ?? [] - expect(message.trim()).toMatchInlineSnapshot( - `"[@tailwindcss/vite:generate:build] Cannot apply unknown utility class \`text-red-500\`. Are you using CSS modules or similar and missing \`@reference\`? https://tailwindcss.com/docs/functions-and-directives#reference-directive"`, - ) - } - }, -) - -// https://github.com/tailwindlabs/tailwindcss/issues/20320 -test( - 'editing a scanned `.vue` file that is not loaded as a module does not trigger a full reload', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "vue": "^3.4.37", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "@vitejs/plugin-vue": "^6", - "@tailwindcss/vite": "workspace:^", - "vite": "^8" - } - } - `, - 'vite.config.ts': ts` - import fs from 'node:fs' - import path from 'node:path' - import { defineConfig } from 'vite' - import vue from '@vitejs/plugin-vue' - import tailwindcss from '@tailwindcss/vite' - - export default defineConfig({ - plugins: [ - vue(), - tailwindcss(), - { - // Log update and full-reload HMR payloads to a file so the - // test can assert on them. Custom events are not logged - // because \`@vitejs/plugin-vue\` sends a \`file-changed\` event - // for every file change, including changes to the log file - // itself, which would cause an infinite feedback loop. - 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) => { - if (payload.type === 'update' || payload.type === 'full-reload') { - fs.appendFileSync(logFile, JSON.stringify(payload) + '\\n') - } - return send(payload) - } - } - }, - }, - ], - }) - `, - 'index.html': html` - - - - - - -
- - - - `, - 'src/index.css': css`@import 'tailwindcss';`, - 'src/main.ts': ts` - import { createApp } from 'vue' - import App from './App.vue' - - createApp(App).mount('#app') - `, - 'src/App.vue': html` - - `, - - // This file is scanned by Tailwind but never imported, so it is not - // part of the loaded module graph (e.g. a lazy route that hasn't been - // visited yet) - 'src/LazyRoute.vue': html` - - `, - }, - }, - async ({ spawn, fs, 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).toContain(candidate`content-['src/App.vue']`) - expect(styles).toContain(candidate`content-['src/LazyRoute.vue']`) - }) - - // Load `main.ts` and `App.vue` as real modules, like a browser visiting - // the page would - await fetch(`${url}/src/main.ts`) - await fetch(`${url}/src/App.vue`) - - // Changing the scanned but unloaded `.vue` file should not trigger a - // full reload, but new classes should still apply - await fs.write( - 'src/LazyRoute.vue', - html` - - `, - ) - - await retryAssertion(async () => { - let styles = await fetchStyles(url, '/index.html') - expect(styles).toContain(candidate`content-['updated:src/LazyRoute.vue']`) - }) - expect(await fs.read('hmr.log')).not.toContain('full-reload') - }, -) diff --git a/integrations/vitest.config.mts b/integrations/vitest.config.mts deleted file mode 100644 index 6855b146b..000000000 --- a/integrations/vitest.config.mts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineProject } from 'vitest/config' - -export default defineProject({ - test: { - hideSkippedTests: true, - }, -}) diff --git a/integrations/webpack/index.test.ts b/integrations/webpack/index.test.ts deleted file mode 100644 index 2a91411d2..000000000 --- a/integrations/webpack/index.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { css, html, js, json, test } from '../utils' - -test( - 'webpack + PostCSS (watch)', - { - fs: { - 'package.json': json` - { - "main": "./src/index.js", - "browser": "./src/index.js", - "dependencies": { - "css-loader": "^6", - "postcss": "^8", - "postcss-loader": "^7", - "webpack": "^5", - "webpack-cli": "^5", - "mini-css-extract-plugin": "^2", - "tailwindcss": "workspace:^", - "@tailwindcss/postcss": "workspace:^" - } - } - `, - 'postcss.config.js': js` - /** @type {import('postcss-load-config').Config} */ - module.exports = { - plugins: { - '@tailwindcss/postcss': {}, - }, - } - `, - 'webpack.config.js': js` - let MiniCssExtractPlugin = require('mini-css-extract-plugin') - - module.exports = { - output: { - clean: true, - }, - plugins: [new MiniCssExtractPlugin()], - module: { - rules: [ - { - test: /.css$/i, - use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'], - }, - ], - }, - } - `, - 'src/index.js': js`import './index.css'`, - 'src/index.html': html` -
- `, - 'src/index.css': css` - @import 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - `, - 'src/unrelated.module.css': css` - .module { - color: var(--color-blue-500); - } - `, - }, - }, - async ({ fs, spawn, exec, expect }) => { - // Generate the initial build so output CSS files exist on disk - await exec('pnpm webpack --mode=development') - - // NOTE: We are writing to an output CSS file which is not being ignored by - // `.gitignore` nor marked with `@source not`. This should not result in an - // infinite loop. - let process = await spawn('pnpm webpack --mode=development --watch') - await process.onStdout((m) => m.includes('compiled successfully in')) - - expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` - " - --- ./dist/main.css --- - :root, :host { - --color-blue-500: oklch(62.3% 0.214 259.815); - } - .flex { - display: flex; - } - " - `) - - await fs.write( - 'src/unrelated.module.css', - css` - .module { - color: var(--color-blue-500); - background-color: var(--color-red-500); - } - `, - ) - await process.onStdout((m) => m.includes('compiled successfully in')) - - expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` - " - --- ./dist/main.css --- - :root, :host { - --color-red-500: oklch(63.7% 0.237 25.331); - --color-blue-500: oklch(62.3% 0.214 259.815); - } - .flex { - display: flex; - } - " - `) - }, -) diff --git a/integrations/webpack/loader.test.ts b/integrations/webpack/loader.test.ts deleted file mode 100644 index 9c3d870af..000000000 --- a/integrations/webpack/loader.test.ts +++ /dev/null @@ -1,495 +0,0 @@ -import { css, html, js, json, test } from '../utils' - -test( - '@tailwindcss/webpack loader (build)', - { - fs: { - 'package.json': json` - { - "main": "./src/index.js", - "browser": "./src/index.js", - "dependencies": { - "css-loader": "^6", - "webpack": "^5", - "webpack-cli": "^5", - "mini-css-extract-plugin": "^2", - "tailwindcss": "workspace:^", - "@tailwindcss/webpack": "workspace:^" - } - } - `, - 'webpack.config.js': js` - let MiniCssExtractPlugin = require('mini-css-extract-plugin') - - module.exports = { - output: { - clean: true, - }, - plugins: [new MiniCssExtractPlugin()], - module: { - rules: [ - { - test: /.css$/i, - use: [MiniCssExtractPlugin.loader, 'css-loader', '@tailwindcss/webpack'], - }, - ], - }, - } - `, - 'src/index.js': js`import './index.css'`, - 'src/index.html': html` -
- `, - 'src/index.css': css` - @import 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - `, - }, - }, - async ({ fs, exec, expect }) => { - await exec('pnpm webpack --mode=development') - - expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` - " - --- ./dist/main.css --- - .flex { - display: flex; - } - " - `) - }, -) - -test( - '@tailwindcss/webpack loader (watch)', - { - fs: { - 'package.json': json` - { - "main": "./src/index.js", - "browser": "./src/index.js", - "dependencies": { - "css-loader": "^6", - "webpack": "^5", - "webpack-cli": "^5", - "mini-css-extract-plugin": "^2", - "tailwindcss": "workspace:^", - "@tailwindcss/webpack": "workspace:^" - } - } - `, - 'webpack.config.js': js` - let MiniCssExtractPlugin = require('mini-css-extract-plugin') - - module.exports = { - output: { - clean: true, - }, - plugins: [new MiniCssExtractPlugin()], - module: { - rules: [ - { - test: /.css$/i, - use: [MiniCssExtractPlugin.loader, 'css-loader', '@tailwindcss/webpack'], - }, - ], - }, - } - `, - 'src/index.js': js`import './index.css'`, - 'src/index.html': html` -
- `, - 'src/index.css': css` - @import 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - `, - }, - }, - async ({ fs, spawn, exec, expect }) => { - // Generate the initial build so output CSS files exist on disk - await exec('pnpm webpack --mode=development') - - let process = await spawn('pnpm webpack --mode=development --watch') - await process.onStdout((m) => m.includes('compiled successfully in')) - - expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` - " - --- ./dist/main.css --- - .flex { - display: flex; - } - " - `) - - // Add a new Tailwind class to the HTML file - await fs.write('src/index.html', html` -
- `) - await process.onStdout((m) => m.includes('compiled successfully in')) - - expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` - " - --- ./dist/main.css --- - .flex { - display: flex; - } - .underline { - text-decoration-line: underline; - } - " - `) - }, -) - -test( - '@tailwindcss/webpack loader with @apply', - { - fs: { - 'package.json': json` - { - "main": "./src/index.js", - "browser": "./src/index.js", - "dependencies": { - "css-loader": "^6", - "webpack": "^5", - "webpack-cli": "^5", - "mini-css-extract-plugin": "^2", - "tailwindcss": "workspace:^", - "@tailwindcss/webpack": "workspace:^" - } - } - `, - 'webpack.config.js': js` - let MiniCssExtractPlugin = require('mini-css-extract-plugin') - - module.exports = { - output: { - clean: true, - }, - plugins: [new MiniCssExtractPlugin()], - module: { - rules: [ - { - test: /.css$/i, - use: [MiniCssExtractPlugin.loader, 'css-loader', '@tailwindcss/webpack'], - }, - ], - }, - } - `, - 'src/index.js': js`import './index.css'`, - 'src/index.css': css` - @import 'tailwindcss/theme'; - - .btn { - @apply flex items-center px-4 py-2 rounded-md; - } - `, - }, - }, - async ({ fs, exec, expect }) => { - await exec('pnpm webpack --mode=development') - - expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` - " - --- ./dist/main.css --- - :root, :host { - --spacing: 0.25rem; - --radius-md: 0.375rem; - } - .btn { - display: flex; - align-items: center; - border-radius: var(--radius-md); - padding-inline: calc(var(--spacing) * 4); - padding-block: calc(var(--spacing) * 2); - } - " - `) - }, -) - -test( - '@tailwindcss/webpack loader with optimization', - { - fs: { - 'package.json': json` - { - "main": "./src/index.js", - "browser": "./src/index.js", - "dependencies": { - "css-loader": "^6", - "webpack": "^5", - "webpack-cli": "^5", - "mini-css-extract-plugin": "^2", - "tailwindcss": "workspace:^", - "@tailwindcss/webpack": "workspace:^" - } - } - `, - 'webpack.config.js': js` - let MiniCssExtractPlugin = require('mini-css-extract-plugin') - - module.exports = { - output: { - clean: true, - }, - plugins: [new MiniCssExtractPlugin()], - module: { - rules: [ - { - test: /.css$/i, - use: [ - MiniCssExtractPlugin.loader, - 'css-loader', - { - loader: '@tailwindcss/webpack', - options: { - optimize: true, - }, - }, - ], - }, - ], - }, - } - `, - 'src/index.js': js`import './index.css'`, - 'src/index.html': html` -
- `, - 'src/index.css': css` - @import 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - `, - }, - }, - async ({ fs, exec, expect }) => { - await exec('pnpm webpack --mode=development') - - expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` - " - --- ./dist/main.css --- - .flex{display:flex} - " - `) - }, -) - -test( - '@tailwindcss/webpack loader with CSS @import', - { - fs: { - 'package.json': json` - { - "main": "./src/index.js", - "browser": "./src/index.js", - "dependencies": { - "css-loader": "^6", - "webpack": "^5", - "webpack-cli": "^5", - "mini-css-extract-plugin": "^2", - "tailwindcss": "workspace:^", - "@tailwindcss/webpack": "workspace:^" - } - } - `, - 'webpack.config.js': js` - let MiniCssExtractPlugin = require('mini-css-extract-plugin') - - module.exports = { - output: { - clean: true, - }, - plugins: [new MiniCssExtractPlugin()], - module: { - rules: [ - { - test: /.css$/i, - use: [MiniCssExtractPlugin.loader, 'css-loader', '@tailwindcss/webpack'], - }, - ], - }, - } - `, - 'src/index.js': js`import './index.css'`, - 'src/index.html': html` -
- `, - 'src/index.css': css` - @import './custom.css'; - @import 'tailwindcss/theme'; - @import 'tailwindcss/utilities'; - `, - 'src/custom.css': css` - /**/ - @utility custom-util { - color: var(--color-red-500); - } - `, - }, - }, - async ({ fs, exec, expect }) => { - await exec('pnpm webpack --mode=development') - - expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` - " - --- ./dist/main.css --- - :root, :host { - --color-red-500: oklch(63.7% 0.237 25.331); - } - .flex { - display: flex; - } - .custom-util { - color: var(--color-red-500); - } - " - `) - }, -) - -test( - '@tailwindcss/webpack loader with @plugin', - { - fs: { - 'package.json': json` - { - "main": "./src/index.js", - "browser": "./src/index.js", - "dependencies": { - "css-loader": "^6", - "webpack": "^5", - "webpack-cli": "^5", - "mini-css-extract-plugin": "^2", - "tailwindcss": "workspace:^", - "@tailwindcss/webpack": "workspace:^" - } - } - `, - 'webpack.config.js': js` - let MiniCssExtractPlugin = require('mini-css-extract-plugin') - - module.exports = { - output: { - clean: true, - }, - plugins: [new MiniCssExtractPlugin()], - module: { - rules: [ - { - test: /.css$/i, - use: [MiniCssExtractPlugin.loader, 'css-loader', '@tailwindcss/webpack'], - }, - ], - }, - } - `, - 'src/index.js': js`import './index.css'`, - 'src/index.html': html` -
- `, - 'src/index.css': css` - @import 'tailwindcss/utilities'; - @plugin './plugin.js'; - `, - 'src/plugin.js': js` - export default function ({ addUtilities }) { - addUtilities({ - '.custom-underline': { - 'border-bottom': '1px solid green', - }, - }) - } - `, - }, - }, - async ({ fs, exec, expect }) => { - await exec('pnpm webpack --mode=development') - - expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` - " - --- ./dist/main.css --- - .custom-underline { - border-bottom: 1px solid green; - } - " - `) - }, -) - -test( - '@tailwindcss/webpack loader isolates cache by resource including query', - { - fs: { - 'package.json': json` - { - "main": "./src/index.js", - "browser": "./src/index.js", - "dependencies": { - "css-loader": "^6", - "webpack": "^5", - "webpack-cli": "^5", - "mini-css-extract-plugin": "^2", - "tailwindcss": "workspace:^", - "@tailwindcss/webpack": "workspace:^" - } - } - `, - 'webpack.config.js': js` - let MiniCssExtractPlugin = require('mini-css-extract-plugin') - let path = require('node:path') - - module.exports = { - mode: 'development', - entry: { - a: './src/a.js', - b: './src/b.js', - }, - output: { - clean: true, - }, - plugins: [new MiniCssExtractPlugin()], - module: { - rules: [ - { - test: /.css$/i, - use: [ - MiniCssExtractPlugin.loader, - 'css-loader', - '@tailwindcss/webpack', - path.resolve(__dirname, 'query-loader.js'), - ], - }, - ], - }, - } - `, - 'query-loader.js': js` - module.exports = function (source) { - if (this.resourceQuery.includes('a')) { - return '@import "tailwindcss/utilities"; @utility only-a {color: var(--color-red-500);}' - } - - if (this.resourceQuery.includes('b')) { - return '@import "tailwindcss/utilities"; @utility only-b {color: var(--color-blue-500);}' - } - - return source - } - `, - 'src/a.js': js`import './index.css?a'`, - 'src/b.js': js`import './index.css?b'`, - 'src/index.css': css``, - }, - }, - async ({ fs, exec }) => { - await exec('pnpm webpack --mode=development') - - await fs.expectFileToContain('dist/a.css', ['only-a', '--color-red-500']) - await fs.expectFileToContain('dist/b.css', ['only-b', '--color-blue-500']) - await fs.expectFileNotToContain('dist/a.css', ['only-b', '--color-blue-500']) - await fs.expectFileNotToContain('dist/b.css', ['only-a', '--color-red-500']) - }, -) diff --git a/oxide/.gitignore b/oxide/.gitignore new file mode 100644 index 000000000..2f7896d1d --- /dev/null +++ b/oxide/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/oxide/Cargo.lock b/oxide/Cargo.lock new file mode 100644 index 000000000..7404bde1d --- /dev/null +++ b/oxide/Cargo.lock @@ -0,0 +1,1200 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" +dependencies = [ + "memchr", +] + +[[package]] +name = "aho-corasick" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67fc08ce920c31afb70f013dcce1bfc3a3195de6a228474e45e1f145b36f8d04" +dependencies = [ + "memchr", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6776fc96284a0bb647b615056fc496d1fe1644a7ab01829818a6d91cae888b84" + +[[package]] +name = "bstr" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a246e68bb43f6cd9db24bea052a53e40405417c5fb372e3d1a8a7f770a564ef5" +dependencies = [ + "memchr", + "once_cell", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "bitflags 1.3.2", + "textwrap", + "unicode-width", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "criterion" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b01d6de93b2b6c65e17c634a26653a29d107b3c98c607c765bf38d041531cd8f" +dependencies = [ + "atty", + "cast", + "clap", + "criterion-plot", + "csv", + "itertools", + "lazy_static", + "num-traits", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_cbor", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2673cc8207403546f45f5fd319a974b1e6983ad1a3ee7e6041650013be041876" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2801af0d36612ae591caa9568261fddce32ce6e08a7275ea334a06a4ad021a2c" +dependencies = [ + "cfg-if", + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6fd6f855243022dcecf8702fef0c297d4338e226845fe067f6341ad9fa0cef" +dependencies = [ + "cfg-if", + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46bd5f3f85273295a9d14aedfb86f6aadbff6d8f5295c4a9edb08e819dcf5695" +dependencies = [ + "autocfg", + "cfg-if", + "crossbeam-utils", + "memoffset", + "scopeguard", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c063cd8cc95f5c377ed0d4b49a4b21f632396ff690e8470c29b3359b346984b" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "csv" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b015497079b9a9d69c02ad25de6c0a6edef051ea6360a327d0bd05802ef64ad" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctor" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990a40740adf249724a6000c0fc4bd574712f50bb17c2d6f6cec837ae2f0ee75" +dependencies = [ + "quote", + "syn 2.0.18", +] + +[[package]] +name = "either" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" + +[[package]] +name = "errno" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" +dependencies = [ + "errno-dragonfly", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "globset" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "029d74589adefde59de1a0c4f4732695c32805624aec7b68d91503d4dba79afc" +dependencies = [ + "aho-corasick 0.7.20", + "bstr", + "fnv", + "log", + "regex", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + +[[package]] +name = "half" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabb4a44450da02c90444cf74558da904edde8fb4e9035a9a6a4e15445af0bd7" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hermit-abi" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" +dependencies = [ + "libc", +] + +[[package]] +name = "hermit-abi" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286" + +[[package]] +name = "ignore" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbe7873dab538a9a44ad79ede1faf5f30d49f9a5c883ddbab48bce81b64b7492" +dependencies = [ + "globset", + "lazy_static", + "log", + "memchr", + "regex", + "same-file", + "thread_local", + "walkdir", + "winapi-util", +] + +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.1", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" + +[[package]] +name = "js-sys" +version = "0.3.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f37a4a5928311ac501dee68b3c7613a1037d0edb30c8e5427bd832d55d1b790" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.144" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b00cc1c228a6782d0f076e7b232802e0c5689d41bb5df366f2a6b6621cfdfe1" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + +[[package]] +name = "log" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518ef76f2f87365916b142844c16d8fefd85039bc5699050210a7778ee1cd1de" + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "memoffset" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d61c719bcfbcf5d62b3a09efa6088de8c54bc0bfcd3ea7ae39fcc186108b8de1" +dependencies = [ + "autocfg", +] + +[[package]] +name = "napi" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7f0a2e93526dd9c8c522d72a4d0c88678be8966fabe9fb8f2947fde6339b682" +dependencies = [ + "bitflags 2.3.1", + "ctor", + "napi-derive", + "napi-sys", + "once_cell", +] + +[[package]] +name = "napi-build" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a73d9ef23e8dc2ebbffb6a6ae2ef467c0f18ac10711e4cc59c5485d41df0e" + +[[package]] +name = "napi-derive" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da1c6a8fa84d549aa8708fcd062372bf8ec6e849de39016ab921067d21bde367" +dependencies = [ + "cfg-if", + "convert_case", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "napi-derive-backend" +version = "1.0.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20bbc7c69168d06a848f925ec5f0e0997f98e8c8d4f2cc30157f0da51c009e17" +dependencies = [ + "convert_case", + "once_cell", + "proc-macro2", + "quote", + "regex", + "semver", + "syn 1.0.109", +] + +[[package]] +name = "napi-sys" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "166b5ef52a3ab5575047a9fe8d4a030cdd0f63c96f071cd6907674453b07bae3" +dependencies = [ + "libloading", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" +dependencies = [ + "hermit-abi 0.2.6", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9670a07f94779e00908f3e686eab508878ebb390ba6e604d3a284c00e8d0487b" + +[[package]] +name = "oorandom" +version = "11.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1bc2a289d34bd04a330323ac98a1b4bc82c9d9fcb1e66b63caa84da26b575" + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "pin-project-lite" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" + +[[package]] +name = "plotters" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2538b639e642295546c50fcd545198c9d64ee2a38620a628724a3b266d5fbf97" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "193228616381fecdc1224c62e96946dfbc73ff4384fba576e052ff8c1bea8142" + +[[package]] +name = "plotters-svg" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a81d2759aae1dae668f783c308bc5c8ebd191ff4184aaa1b37f65a6ae5a56f" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "proc-macro2" +version = "1.0.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeca18b86b413c660b781aa319e4e2648a3e6f9eadc9b47e9038e6fe9f3451b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2df5196e37bcc87abebc0053e20787d73847bb33134a69841207dd0a47f03b" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b8f95bd6966f5c87776639160a66bd8ab9895d9d4ab01ddba9fc60661aebe8d" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-utils", + "num_cpus", +] + +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "regex" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81ca098a9821bd52d6b24fd8b10bd081f47d39c22778cafaa75a2857a62c6390" +dependencies = [ + "aho-corasick 1.0.1", + "memchr", + "regex-syntax 0.7.2", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436b050e76ed2903236f032a59761c1eb99e1b0aead2c257922771dab1fc8c78" + +[[package]] +name = "rustix" +version = "0.37.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acf8729d8542766f1b2cf77eb034d52f40d375bb8b615d0b147089946e16613d" +dependencies = [ + "bitflags 1.3.2", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys", + "windows-sys 0.48.0", +] + +[[package]] +name = "ryu" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "semver" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed" + +[[package]] +name = "serde" +version = "1.0.163" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2113ab51b87a539ae008b5c6c02dc020ffa39afd2d83cffcb3f4eb2722cebec2" + +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half", + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.163" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c805777e3930c8883389c602315a24224bcc738b63905ef87cd1420353ea93e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.18", +] + +[[package]] +name = "serde_json" +version = "1.0.96" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900fba806f70c630b0a382d0d825e17a0f19fcd059a2ade1ff237bcddf446b31" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "smallvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tailwind-oxide" +version = "0.0.0" +dependencies = [ + "napi", + "napi-build", + "napi-derive", + "rayon", + "tailwindcss-core", +] + +[[package]] +name = "tailwindcss-core" +version = "0.1.0" +dependencies = [ + "bstr", + "criterion", + "crossbeam", + "fxhash", + "globwalk", + "ignore", + "lazy_static", + "log", + "rayon", + "tempfile", + "tracing", + "tracing-subscriber", + "walkdir", +] + +[[package]] +name = "tempfile" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9fbec84f381d5795b08656e4912bec604d162bff9291d6189a78f4c8ab87998" +dependencies = [ + "cfg-if", + "fastrand", + "redox_syscall", + "rustix", + "windows-sys 0.45.0", +] + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "thread_local" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tracing" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" +dependencies = [ + "cfg-if", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f57e3ca2a01450b1a921183a9c9cbfda207fd822cef4ccb00a65402cbba7a74" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.18", +] + +[[package]] +name = "tracing-core" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ddad33d2d10b1ed7eb9d1f518a5674713876e97e5bb9b7345a7984fbb4f922" +dependencies = [ + "lazy_static", + "log", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" + +[[package]] +name = "unicode-segmentation" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" + +[[package]] +name = "unicode-width" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "walkdir" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36df944cda56c7d8d8b7496af378e6b16de9284591917d307c9b4d313c44e698" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bba0e8cb82ba49ff4e229459ff22a191bbe9a1cb3a341610c9c33efc27ddf73" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b04bc93f9d6bdee709f6bd2118f57dd6679cf1176a1af464fca3ab0d66d8fb" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.18", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14d6b024f1a526bb0234f52840389927257beb670610081360e5a03c5df9c258" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e128beba882dd1eb6200e1dc92ae6c5dbaa4311aa7bb211ca035779e5efc39f8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.18", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9d5b4305409d1fc9482fee2d7f9bcbf24b3972bf59817ef757e23982242a93" + +[[package]] +name = "web-sys" +version = "0.3.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bdd9ef4e984da1187bf8110c5cf5b845fbc87a23602cdf912386a76fcd3a7c2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.0", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +dependencies = [ + "windows_aarch64_gnullvm 0.48.0", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm 0.48.0", + "windows_x86_64_msvc 0.48.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" diff --git a/Cargo.toml b/oxide/Cargo.toml similarity index 100% rename from Cargo.toml rename to oxide/Cargo.toml diff --git a/oxide/README.md b/oxide/README.md new file mode 100644 index 000000000..fa0a1e39d --- /dev/null +++ b/oxide/README.md @@ -0,0 +1 @@ +## Tailwind CSS Oxide diff --git a/oxide/crates/core/Cargo.toml b/oxide/crates/core/Cargo.toml new file mode 100644 index 000000000..8f9f26432 --- /dev/null +++ b/oxide/crates/core/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "tailwindcss-core" +version = "0.1.0" +edition = "2021" + +[dependencies] +bstr = "1.0.1" +globwalk = "0.8.1" +log = "0.4" +rayon = "1.5.3" +fxhash = "0.2.1" +crossbeam = "0.8.2" +tracing = { version = "0.1.37", features = [] } +tracing-subscriber = { version = "0.3.16", features = ["env-filter"] } +walkdir = "2.3.3" +ignore = "0.4.20" +lazy_static = "1.4.0" + +[dev-dependencies] +criterion = { version = "0.3", features = ['html_reports'] } +tempfile = "3.5.0" + +[[bench]] +name = "parse_candidates" +harness = false + +[[bench]] +name = "scan_files" +harness = false diff --git a/oxide/crates/core/benches/fixtures/template-000.html b/oxide/crates/core/benches/fixtures/template-000.html new file mode 100644 index 000000000..8d9a823f1 --- /dev/null +++ b/oxide/crates/core/benches/fixtures/template-000.html @@ -0,0 +1,2203 @@ +
+

+ This is text fe914826-0ae1-4507-903a-a4e5b5cb1112 + + This is link d30fe654-03f5-49d1-8cca-ecc40dd6bd52 + + + This is link e2ef44c4-ede1-4f3b-85b6-5358f926c5ab + +

+
+ Profile picture of user 761a1558-6e1d-4ac6-b228-194dfeedec83 + Profile picture of user d6eb4cbe-4a90-48cb-82e7-849f9a233b3d + + +
+
    +
  • +
      +
    • +
        +
      1. +
          +
        1. + This is item bc9cdb26-3bfc-46ee-b19e-cccefdac00c2 +
        2. + +
        3. This is item b4237dc3-38b1-40b7-a7a0-57afaa46a4f8
        4. +
        5. + This is item 25c94631-cd50-4677-bac5-ed0dbc384edb +
        6. +
        +
          +
        • + This is item b21af7e8-f719-4348-abd6-db1ca5ae8624 +
        • +
        • This is item 27c24c5e-d1ff-4e36-b626-6a09c74a9a9f
        • +
        • This is item 4a882789-bb98-4a33-9318-8fe5e1639c92
        • +
        • + This is item 01355b2a-dd32-42e0-9d49-d0df857c54c3 +
        • +
        + + This is link 9af437fd-bf11-44bb-b687-90685adb6441 + + Profile picture of user ba18d60e-288b-40ec-bc25-762722cc8428 +
      2. +
      3. +
          +
        • + This is item d556110b-ffc4-4a8f-a983-9123fc808fe5 +
        • +
        • + This is item 28f695fb-68a4-47de-9f3c-26e263c241f2 +
        • +
        • This is item 606fc1a4-5487-45a7-86d7-f0152ed20c49
        • +
        • This is item edcf7a84-6751-4039-acd3-0bda8440b1e9
        • +
        + Profile picture of user 8834c5a4-2338-4ded-a423-a24c0f2f09be + Profile picture of user 554ef559-610b-449e-988f-12bad5dc63e6 +
          +
        • + This is item 33a4e3d8-0b2f-406c-9ca2-1bb6efd4063c +
        • +
        • This is item 3efe8d6d-6dfa-4fd7-adc1-fce29dba4534
        • +
        • + This is item f61781ad-58ac-416d-967f-55d42b6b86e0 +
        • +
        +
      4. +
      5. +
          +
        • + This is item c759a871-52fa-469e-b924-4097ab79fc13 +
        • + +
        • + This is item cc8f4865-b9a4-46b4-a87d-ab25978709f8 +
        • +
        • This is item 0ecbf096-4c02-4405-b6a4-f6d974891c61
        • +
        +
      6. +
      +
        +
      1. + + This is link fadb4c63-f3bf-43ca-ab40-42ef75f61a44 + +
      2. +
      3. +
          +
        • + This is item 449a8153-4c3d-4080-8acf-27e1c0f7cf03 +
        • +
        • + This is item 62c4844b-15e4-4047-adff-047e897be694 +
        • +
        • + This is item 2c50bb49-9c0b-4e18-9466-44436833f96f +
        • +
        • + This is item a0eb727d-99d7-455d-9972-79e999d4a5ae +
        • +
        • This is item 8648de19-1e74-42e6-a7f1-499a3a6d4ab1
        • +
        + + This is link b5951035-a7c4-4cd2-97ae-3458607e1852 + +
      4. +
      5. +
          +
        1. This is item 07facc3a-b455-44ba-88f0-54b0203c925b
        2. +
        3. + This is item 4e85bcc1-a02f-421b-a937-adf84268d95e +
        4. +
        5. + This is item a0e7c3f7-e7d5-49de-9132-6ee36ccbb04d +
        6. +
        7. + This is item d60fa673-b3b6-4712-a3f1-604f63b7dedd +
        8. +
        +
          +
        • + This is item 8b7991ee-3278-4890-8826-daab4ade64ca +
        • +
        • This is item 2d8fab33-8141-4a56-875c-75e678d54a34
        • +
        • + This is item f8ff678e-d01e-4483-b263-99465725cf13 +
        • +
        • + This is item 69c6fcd5-da1e-46f4-8fed-110e321429d2 +
        • +
        + Profile picture of user 57a8aa4b-f3dc-4995-9e30-c682f46f4565 +
      6. +
      7. + Profile picture of user acf0cc09-3ff5-4137-9300-34e584a8009b + + This is link 5b8781d5-d35d-434a-a232-7ec451c0e33b + +
          + +
        • This is item 8164ee04-8e4f-4df3-bb99-58a9aff71631
        • +
        • + This is item 3f65c94f-d334-4ddc-8c29-5b3b49490dbe +
        • +
        +
          +
        1. + This is item 57548763-3354-456c-b888-23a42bcd873c +
        2. +
        3. + This is item bdc85c06-aabf-48e7-b9fd-fcff7854e5c6 +
        4. +
        +
      8. +
      +
        +
      • + Profile picture of user 53485d3e-3130-4102-aca0-9b4cb93225ee +
      • +
      • + Profile picture of user af77f5b9-2363-46d7-8591-594b5d68d7cc +
          +
        • + This is item 5038b69c-d755-4c02-8a24-ead37cb83711 +
        • +
        • + This is item 2981aca2-1792-456a-bcaf-2a50aca6d12d +
        • +
        • This is item 46f97ba3-8f57-4da5-8278-32e07f2ea600
        • +
        • + This is item ddb64fa7-a990-4fb3-b49c-f0066fb496a3 +
        • +
        • This is item b41fc979-fdb8-423c-aa55-3dfc0b391b7b
        • +
        +
          +
        1. + This is item 51e80955-27d0-4357-8b0b-aa40835780c6 +
        2. +
        3. This is item 992c6df9-23ad-4efd-ad4a-f901a1df1bd4
        4. +
        +
      • +
      • + Profile picture of user 9d4ff7f7-554b-4bea-99b8-ba18efbd34e3 +
          +
        1. This is item e86a32e7-c0b9-46cd-86e7-eeae3fd4de1d
        2. +
        3. This is item ee9f2b5a-28d3-44d3-af80-7c99401b2818
        4. +
        5. + This is item 896b20ae-afaf-4bea-a316-d667d6696d3c +
        6. +
        7. This is item 30015788-f2a4-4f91-a597-210904c9222a
        8. +
        + + This is link 1e9e3a2a-72c6-4bb7-b8f4-cfa25c2a360c + +
      • +
      • +
          +
        • + This is item e3713250-d3a5-4780-b19b-8dd0fb9bff44 +
        • +
        • + This is item b59dff69-8f3a-48e5-a694-74d8321d80b8 +
        • +
        +
      • +
      • +
          +
        • + This is item 9b393dd5-a7f4-452f-ba17-d4f1596bb720 +
        • +
        • + This is item 06c3836f-b84c-432e-819c-a591b7e82a29 +
        • +
        • + This is item 8fd93538-c8aa-4edf-b7ab-164fcc0859f1 +
        • +
        + + This is link d10c99c1-aa9a-488a-8d66-41a59be27b27 + +
      • +
      + Profile picture of user 6747bae5-0254-4f0e-9f6c-4f2b156dc8ea +
    • +
    • + Profile picture of user 0adce5ae-08a5-4253-b21e-28fdf9f6719a +
    • +
    • + +
        +
      1. + Profile picture of user 548c4551-593a-4279-a565-210dd2b2a776 + + This is link dd8af783-b5d1-46f7-a968-755444b66996 + +
          +
        • + This is item 3402852e-8aa7-4334-a8da-0de08161d304 +
        • +
        • + This is item baafa347-1e56-42f5-b151-878ba2cd80dc +
        • +
        +
      2. +
      3. + + + This is link c600f6de-4787-49ec-a750-bdba5d91b460 + +
      4. +
      5. +
          +
        1. + This is item 03a62457-cbdd-4547-ac51-618ad8c44ce1 +
        2. +
        3. + This is item 06e31510-7e82-408b-a565-da90b4beb3b2 +
        4. +
        5. This is item 371a5897-2e97-41c8-aef6-9cce920b51d2
        6. +
        7. + This is item a8e5a983-d107-44bd-8187-b08d04623d42 +
        8. +
        + + This is link 89098f9a-2561-4dc1-be2c-a7db29cbf8fa + +
      6. +
      7. +
          +
        • This is item 30805e9c-58d6-4ebf-8b34-8a71796ca6eb
        • +
        • This is item 2c1ec91b-e9ca-423e-8e93-aa0f71aefe36
        • +
        • This is item 2b31feb5-6f03-4066-853f-16f66319ef8f
        • +
        +
      8. +
      9. +
          +
        • + This is item 304d8ee8-3c20-4b4e-b39a-7a28cd6ff961 +
        • +
        • + This is item 3fd9a7ae-4e57-4b8e-a463-bc254bea783c +
        • +
        • + This is item 90b53b64-c603-45ec-aa67-d02eee93582e +
        • +
        +
          +
        • + This is item eea407b8-3c3c-4d6d-b67a-c97344f6afe5 +
        • +
        • + This is item e9c7bfcf-aad1-4dc6-bb50-2222a94fda89 +
        • +
        • + This is item 76ca6f2b-8e12-4b21-99d8-f18fa14c42e2 +
        • +
        + Profile picture of user afaf4703-c472-4df5-86b6-164504e176a4 + Profile picture of user 9c5b91cd-0d95-4ced-adc5-87fb0b7b907f +
      10. +
      + + This is link 3bafe9bf-3936-4c04-ad0e-1cf0cd4607e7 + +
    • +
    • + + This is link 7f74317c-33e4-4620-bc2b-788c9574779c + +
    • +
    + Profile picture of user 86c49772-b4a2-4f80-91f8-d07faa65a080 +
  • +
  • +
      +
    • +
        +
      1. +
          +
        • + This is item 565bc85c-ee3c-43e7-b5a6-a571e496cc9e +
        • +
        • + This is item 32c563b4-0241-4ac7-a765-31d5bc322c6d +
        • +
        • + This is item a1d7c309-150e-4179-a7d7-af2fc93c81f0 +
        • +
        • + This is item a37f4ee6-1faa-4365-82c8-9e6a280a6278 +
        • +
        • This is item 3071e18f-cfea-4e61-9b64-7abe250bf596
        • +
        +
      2. +
      3. +
          +
        1. + This is item b8f9f096-7739-44b2-a59d-7f8a7a2d54bb +
        2. +
        3. + This is item d8930437-300c-4031-b256-b8c8d3c29f83 +
        4. +
        5. + This is item dac96525-86ea-4fab-aad4-1dc636a18155 +
        6. +
        7. This is item c6e4fcd0-bbfa-4517-b787-7ea909c4f1a9
        8. +
        +
          +
        1. + This is item 4cf8e028-be07-4866-8057-16327097f45c +
        2. +
        3. + This is item b8b89d4d-e944-4938-a5f9-b9796bb270a6 +
        4. +
        5. + This is item 1d712708-3dff-437a-a5c5-2af1f02dd206 +
        6. +
        7. This is item da8b3704-3196-4f08-bdf4-2df56e6e493d
        8. +
        +
      4. +
      5. + +
      6. +
      7. +
          +
        • This is item 2213f615-1215-454f-91d4-73831f0823c1
        • +
        • + This is item 3088f66a-bca3-4583-aba3-718d73169217 +
        • +
        • This is item 4723f5f1-4c58-496a-b0ee-28a5ccd08c82
        • +
        • + This is item b75b13bc-a86c-4c05-915d-7e1addd48e63 +
        • +
        • + This is item acfea0a0-4e7d-404c-b87d-4d23d59fd543 +
        • +
        + Profile picture of user 1d0bbd65-5c79-4378-ad37-8d8b585a6f4a +
      8. +
      + + This is link 8ec00a6e-ef30-4533-8d30-53421cc2566b + + Profile picture of user df82b6da-a8ef-4e38-a07b-bc817693080d +
        +
      1. +
          +
        1. + This is item 93b2f6aa-4e4e-46cf-8b16-f0854e2135f0 +
        2. +
        3. + This is item 8a8dfc86-b30b-4c2b-8d4f-8dd52e3435de +
        4. +
        +
          +
        1. + This is item e1fc2df5-1009-4c87-a32a-2135a1fa8c84 +
        2. +
        3. + This is item 33ac1eca-0aa8-4b14-860d-468f08fa8f2c +
        4. +
        5. This is item 010637d0-8599-4c0b-b2c7-ca16fdffae3a
        6. +
        7. + This is item bc07f54f-2565-43c6-a9bd-e91f42d05518 +
        8. +
        9. This is item db20cec2-f331-4aa4-ae8b-8437f0437da8
        10. +
        + + This is link 1eee3f7c-2128-488d-925a-5efd2ed25d30 + +
      2. +
      3. + + This is link 013ffe90-d794-4fbd-a695-bab5463e75a5 + + + This is link dcf00fef-94a6-4a3f-a55e-d7a87445a475 + +
      4. +
      5. +
          +
        1. + This is item abbd72d7-6c1f-4413-901c-7d59d4b06b8f +
        2. +
        3. + This is item 74187c34-bc86-4a69-b239-345738c3b746 +
        4. +
        5. This is item 705b738d-e2f5-4405-83e4-6e523d809190
        6. +
        7. This is item bb173abe-3ca5-47ad-9a5a-460f52f723b4
        8. +
        9. + This is item 8e3ceef2-91f0-4b0e-a86b-5696d74a0e8a +
        10. +
        + Profile picture of user dc436fc9-6472-4514-bd24-324f3a1d3c4e +
          +
        • + This is item caaea842-e565-4d57-8f33-e45a9d8300cd +
        • +
        • This is item 64ed8f5e-cf1a-47d5-9493-8543a9ffb16c
        • +
        • + This is item 5e59684f-3b2e-4b9d-aa4f-c49bea6b20c5 +
        • +
        • + This is item ad16f756-8722-4d6e-8768-1498fd40337a +
        • +
        +
      6. +
      +
    • +
    • +
        +
      1. +
          +
        • + This is item 11649964-fd28-4884-851e-4310180cdb03 +
        • +
        • + This is item 058bdbf5-2457-4933-9358-5997fffc2ec6 +
        • +
        • + This is item b8c80c54-63bc-47e2-a728-c3d7bd4bd088 +
        • +
        • This is item d3a0d036-8741-4d08-982c-c5a79b37032b
        • +
        • + This is item bc83c76e-932d-4364-ae96-520c703132d1 +
        • +
        +
          +
        1. This is item 0540c750-8a14-49fb-ad64-d9498c6d2cf1
        2. +
        3. + This is item 358e7162-0e6b-4f72-8ae7-33962baf0bb0 +
        4. +
        5. This is item 33f6dc3e-c083-458a-bea1-b5f262bff4ce
        6. +
        +
      2. +
      3. +
          +
        1. This is item 04fa9563-ea91-4d1c-962f-87ef099c04a7
        2. +
        3. + This is item c6539a5b-e70c-4394-8def-cfe933fd2c4c +
        4. +
        +
      4. +
      5. + + This is link 70d9fcb5-6643-4124-9d3f-66016e56a3f4 + + + This is link f781cc24-8863-48fd-b45b-b61e72465dcb + + Profile picture of user 30d53fa6-cd08-4607-9aae-17cc62e5102a + + This is link 3057b37e-58e1-4053-ab95-2a6be43b0be6 + +
      6. +
      7. + + This is link 665f218f-f4a5-452b-9838-274d24c8a9c8 + +
          +
        1. + This is item 44cf6d58-11e2-4d59-b96c-1d29761be2ed +
        2. +
        3. This is item af42d158-7714-47ed-8f75-d7ea082fc47c
        4. +
        5. + This is item 56c9a5b1-9593-4ea0-bae8-656c2471afcd +
        6. +
        7. + This is item 1aa53b2f-f97d-4dd0-8415-bfe1628fac5d +
        8. +
        +
      8. +
      9. +
          +
        1. + This is item 865bb766-7a0b-4940-8bc2-4536bcc79008 +
        2. +
        3. + This is item c4fc6207-bbfa-4412-b28a-2f96959927c2 +
        4. +
        5. + This is item b9ebb4a7-09b5-4c94-9097-8f0b9881f666 +
        6. +
        +
      10. +
      +
    • +
    • + Profile picture of user b4d78fcf-f953-4ce4-88a5-5ff6df6b98a9 +
        +
      • + Profile picture of user 9465b15b-6bfe-496c-a219-7e993df78583 +
      • +
      • + + This is link af9c9c30-0f28-4330-9553-3c9d5a0ea0a8 + +
          +
        1. This is item 49c617f0-2985-4cb3-b8ab-a178ac557c22
        2. +
        3. + This is item 6b4071ec-de68-4f1a-b56e-4ecdf9d8e0d0 +
        4. +
        +
          +
        • + This is item 27c53e02-d146-4558-a72e-38c3fe0237dd +
        • +
        • + This is item 717c3c66-ed52-4b2c-8a0f-1418f531d5db +
        • +
        • + This is item fe288c02-6ef3-4c5f-b7d4-b9b75a1d1abb +
        • +
        • + This is item d4f57b60-ef5c-4288-ba68-6420fefdcbc4 +
        • +
        +
      • +
      +
    • +
    • + + Profile picture of user f837cae7-6f11-46d2-85f3-43c742cc4c07 +
        +
      • +
          +
        • + This is item 672a6b0d-1857-4f12-a052-ac554d72bbb2 +
        • +
        • + This is item 0c2ed2de-46bc-43d3-8d60-d9d1624d5bdb +
        • +
        • + This is item e9f4cb5a-24b4-4ace-9b8a-ec2efbe41f8b +
        • +
        +
          +
        • This is item 86a08eed-6246-475c-ba1f-87e4cf5900ef
        • +
        • + This is item e7482fe2-5b4a-4090-8486-1773a149d323 +
        • +
        • + This is item a423e280-ec76-4028-8dc4-ab64b1733825 +
        • +
        • + This is item 43a96ee5-9fb6-4d3c-8fbb-f2e5431d0bbe +
        • +
        • + This is item 56984aed-47c3-4b25-8b72-0028486e1209 +
        • +
        +
          +
        1. + This is item 40729a01-8998-4f8b-9b80-a8645a61ff69 +
        2. +
        3. + This is item 8c613060-10f7-45d8-b5df-6aa84951962d +
        4. +
        +
      • +
      • +
          +
        • + This is item ae3e4596-138a-4219-9af3-ff742fbc35e9 +
        • +
        • + This is item aa92d299-c6f1-430f-ba68-fe2e7affb82c +
        • +
        + + This is link 3462b57a-046b-47bd-9f8f-0535eaba188c + + + This is link 4b1b16bb-c796-4dab-bb54-c03dae47dd08 + +
      • +
      • +
          +
        • + This is item 4f3fa0e8-d606-4bc0-b6cd-d04fff12a6f6 +
        • +
        • This is item fb8c8ae2-16fe-4088-abf1-83ddbee52a6d
        • +
        • + This is item 11bbc240-a1e8-474f-8b91-6e5beaaa7357 +
        • +
        • + This is item a6a6adda-84ce-4cda-8a0b-c122b3841fa0 +
        • +
        + Profile picture of user da0040d4-7bfd-4c24-bede-d561e77c710f +
      • +
      • +
          +
        1. + This is item 9e8699ea-5a22-435a-9e5a-70ba519a777a +
        2. +
        3. + This is item ea9d13ec-44ad-4955-b417-4920752d4ec5 +
        4. +
        5. This is item d4a1cdcc-0bc1-4c3a-973d-1a86e701919a
        6. +
        7. This is item b1379601-3e64-4d51-9ff1-f9b8f7152a2b
        8. +
        + Profile picture of user 20c97175-0c15-4c2d-95e9-56c49bc1f0c2 + Profile picture of user 596b54e2-dbbb-46cc-8c83-83ab778b8d02 + Profile picture of user aff5418e-fa01-441a-9b71-808c89a4e393 +
      • +
      +
        +
      1. + + This is link 49915c80-8594-4e79-8e41-df251adf44bd + +
      2. +
      3. + Profile picture of user ca13a08a-8459-4fa6-857b-cb51a0f62e11 + Profile picture of user 8e74f40a-b442-43c0-a298-e4c8bb9e1579 + + This is link c37f6503-44a8-4c52-92d1-3cdd0a3ba09c + + +
      4. +
      5. + +
      6. +
      7. + + This is link 686c107d-8556-44e5-997e-ed789480d88e + +
          +
        1. + This is item 36fa6d77-222b-45b0-942a-8922996a2be1 +
        2. +
        3. + This is item f72f337d-4f8e-40e7-b269-d124c8755790 +
        4. +
        + Profile picture of user 46fb96c7-e40c-4712-ba8b-12fa33968146 +
          +
        1. + This is item a6648d10-0dfd-4f4c-870d-2947d301bdbf +
        2. +
        3. This is item bc39bcdc-99f8-480c-bdba-6ce9039679a2
        4. +
        5. This is item d819d242-8304-4503-adea-c2ba76829236
        6. +
        +
      8. +
      +
    • +
    • +
        +
      1. +
          + +
        • This is item 8944ebcb-a186-4a55-8080-27ac13899de7
        • +
        • + This is item 5eb45058-3c8e-4455-8e07-bf4e92339dbf +
        • +
        • This is item fe725603-7f88-4ecf-8870-80960797214e
        • +
        • + This is item f3e47459-145d-445d-b5f9-a46f31c5a2cf +
        • +
        +
          +
        • + This is item 55e1762d-2172-42bf-9247-869c5d77ca83 +
        • +
        • This is item 088d98ac-9c18-40dd-ad4a-d64c2ad41fd4
        • +
        • This is item 8cf223d1-7d6a-4f6e-80e8-7dbf5b4b445d
        • +
        • + This is item 1296b90c-b89d-4b5e-b9e2-6218434d96ed +
        • +
        +
          +
        1. + This is item a137af93-8777-4dd7-898b-dc571ad35aec +
        2. +
        3. + This is item 18cb24c0-a8fe-45cb-b92a-90ae62102bb7 +
        4. +
        +
          +
        1. + This is item f1b9976d-36e8-47cc-9169-e9f6c12a9dac +
        2. +
        3. + This is item 28de5ced-d11e-44f2-b1f2-18600b6a480b +
        4. + +
        +
      2. +
      3. +
          +
        • This is item 94e07da9-cac0-41b1-899c-bff3ae74232a
        • +
        • + This is item b9bae6f8-bdb5-4d37-8a05-865b683e20bf +
        • +
        + Profile picture of user ffb91d1a-14e8-4ea4-8d53-11c95afb27c9 + Profile picture of user ab321cf0-b40f-4c9c-8dd9-fa468bc62df3 + Profile picture of user 3f53b8c8-a9c6-4aae-88b2-53614abdb643 +
      4. +
      5. + + This is link 0715a46a-9f2c-4aa1-ad5d-407231ca8612 + +
      6. +
      +
        +
      • + Profile picture of user 1d4751b7-6618-4515-a03f-3c12dc725d74 +
      • +
      • +
          +
        • + This is item 46c80f21-7b56-43b6-9478-b79ae22fcb2c +
        • +
        • + This is item 76a043c3-7edd-40c4-a1da-f48e93dbf79c +
        • +
        + + This is link ece6204c-b791-493a-a5f4-0e3e48aa9719 + +
          +
        • + This is item 4a9cebe0-189b-49ae-b179-c3419f92f5d4 +
        • +
        • + This is item cf79f029-be80-472e-9a61-0406c632e5b9 +
        • +
        • + This is item 51b8d5cb-c0dc-4fe8-9779-1923b7b9bcf3 +
        • +
        • + This is item d55c211f-3b00-4181-aae0-e99558813605 +
        • +
        +
          +
        • + This is item da0731da-61c9-4f89-baaf-ef0637482687 +
        • +
        • + This is item cbb51df0-76c3-4fcf-adb7-168eb71e4a22 +
        • +
        • + This is item 2475156a-551f-42dc-8703-164683a22593 +
        • +
        • This is item 9f8f765b-f441-47ca-9b7e-695b795ed0ca
        • +
        • + This is item ad5ac533-0703-418f-97fc-7a779246589b +
        • +
        +
      • +
      • + Profile picture of user 6b017c94-e8cc-43dc-b26d-20d808ec9517 +
          +
        • + This is item 153b0cbb-574e-4839-9e98-464107969a4d +
        • +
        • + This is item aae15b18-fb58-4dd5-9691-13de471f883f +
        • +
        • This is item 9dbbce97-59e0-45de-9e37-4e8304a2c521
        • +
        • + This is item e04687f4-eaf9-4ae2-9725-99817e9e77e3 +
        • +
        +
      • +
      • +
          +
        • + This is item 6f4115c1-9187-451f-b454-519e74ad09b3 +
        • +
        • + This is item a78d72e9-c45f-4223-b89d-2c57b23516c5 +
        • +
        • This is item e68fe670-9160-4699-97ce-01f89575fdc2
        • +
        +
          +
        • + This is item 6f662ea1-d133-4bf3-aa7d-4c940c4211c4 +
        • +
        • + This is item e0f53815-4493-4a33-a6f3-ac1052c15efd +
        • +
        • + This is item 02ece24d-c082-4c3c-b2ae-d6e571260eb0 +
        • +
        + Profile picture of user 094c9f00-b70c-46b1-b752-8da4c628a2ff +
      • +
      +
    • +
    + Profile picture of user fcee012e-853d-4df0-846b-998fc8fcedee + Profile picture of user da2579d7-c3a6-4215-8b36-f257f299a68f +
  • +
  • + + This is link efcfce9b-d293-4877-81df-fcd6e1acabdb + +
  • +
  • + Profile picture of user 861a4e65-cdae-4abc-8c29-b08d462612d6 +
  • +
+
+

+ This is heading 3e11960f-d5c3-4bc4-a097-9137acb476e6 +

+

+ This is heading e5129e54-0bd8-4c57-8a61-b956c1daef9a +

+
    +
  1. + + This is link d2210a2b-6731-40b8-af4d-8e3970dbd503 + + Profile picture of user 3021b302-2c1a-4e3e-89a2-48426376d115 +
  2. +
  3. +
      +
    1. + +
    2. +
    3. + Profile picture of user 01366bd5-e915-4583-887a-92aaf60cc962 + +
    4. +
    + + + Profile picture of user be6b3aa8-9174-4c78-85fb-5758b5c9a9c5 +
  4. +
  5. + +
  6. +
+
+
diff --git a/oxide/crates/core/benches/fixtures/template-001.html b/oxide/crates/core/benches/fixtures/template-001.html new file mode 100644 index 000000000..d05b12e6c --- /dev/null +++ b/oxide/crates/core/benches/fixtures/template-001.html @@ -0,0 +1,4400 @@ +
+
+ + This is text d60c61e4-6aeb-4eb3-965b-617cc507dcf8 + + This is link eadc06c7-f2d4-47ae-93f2-3895885e8d32 + + +
+ +
+
+
+
+ + +
+ + + This is link c9359bca-edb7-4f24-b13b-d2103b86bc79 + + +
+
+
    +
  1. + + This is link 1bf332ed-1def-4a8f-9e85-ba41e76809df + +
      +
        + Profile picture of user af753b29-7520-4459-a429-7627b2098bd5 +
      1. +
      2. + + This is link d5ef1507-85e8-45c4-9424-4cdd5be56b5a + +
      3. +
      +

      + This is paragraph 704fe54d-6438-41de-9147-711be237e8f6 +

      + + This is link 68b03134-2b7b-438e-815c-dc696e75398a + + + This is link 2fc751bf-98b0-4445-99d3-945c269505cd + +
      +
      +
      + This is text 347f1818-6424-42c3-8b91-fb1e77ac5800 + +

      + This is paragraph 1f671368-6b0f-4ea9-9173-a9d8d30344c6 +

      +
      + +
      +
      +

      + + This is text 5c23358e-043c-47ab-9556-41591cb3e6f8 + +

      +
        +
      1. +
          +
        1. + This is item 07691c01-d3c1-4ffd-915e-8acc347436e4 +
        2. +
        3. + This is item aa25a485-91d4-4aec-a9d2-62945198e6b4 +
        4. +
        5. + This is item 470b6f06-cdef-48a3-82ab-9b454b9f649e +
        6. +
        7. + This is item a697511b-a0db-4be7-924c-7505624eef18 +
        8. +
        +
      2. +
      3. +
          +
        • + This is item 664e4f6d-7486-4532-a400-9906e5eddaf9 +
        • +
        • + This is item d3bf58aa-18f4-484d-8a00-8d28ee2e1da3 +
        • +
        • This is item 91d5de3d-4d7f-4c3d-af53-43707ba31a56
        • +
        • This is item 3d3a9d5c-968c-4274-901c-47d8f04f74a6
        • +
        +
          +
        1. This is item fd65f1a1-a714-4188-b74d-5d83d032334f
        2. +
        3. + This is item 528b2986-d143-4ace-863d-0a4c2aba89fb +
        4. +
        +
          + +
        • + This is item d43be1d8-6315-4736-a1d7-36cfa0183c02 +
        • +
        • + This is item 9ac222ec-c50d-40d3-a11c-c3e59ca80dff +
        • +
        • + This is item d96195ec-c214-4a44-8f8f-3b5c900309b1 +
        • +
        +
      4. +
      5. +
          +
        1. + This is item 82b417bf-efe9-475c-a252-82b5f63de22d +
        2. +
        3. + This is item 63e03c0c-1e42-412e-860b-7b2b1d73c73a +
        4. +
        5. + This is item 32c1265f-8b56-479f-9a08-8145c79e4986 +
        6. +
        7. + This is item 7976e3cd-14fe-4ddd-bb28-5645d6887f7c +
        8. +
        9. + This is item 35bb085b-9d0e-44e9-8555-2d9ca920bf1e +
        10. +
        +
          +
        1. + This is item 8f25f8c8-0619-4909-9953-9cb906f952be +
        2. +
        3. + This is item 1c91d284-d14c-4fb6-ad18-969178b8d444 +
        4. +
        5. + This is item 5f03c882-eb99-4f71-8c79-ce838def0b4f +
        6. +
        +
          +
        • + This is item 35247dba-6d89-4e6f-b6f0-d30e375947e7 +
        • +
        • + This is item 0976f1fe-37d5-4597-b102-520191196251 +
        • + + +
        • This is item 6a02c64c-2d76-46da-86da-3ab89b2da3fc
        • +
        +
          +
        • + This is item dd3b32d1-6f6c-446c-8568-f4e4e667a029 +
        • +
        • + This is item 20024d36-8a63-44c8-b9d3-655053a4807d +
        • +
        +
      6. +
      + +
        +
      1. + + This is link f7bf48d9-54fc-4d7d-9876-e2cca6fffa9d + +
          +
        • This is item b3d24da5-7b2b-4cee-983e-50c71b83d80a
        • +
        • + This is item 473e4022-92fb-4ac1-a698-0144e4d61909 +
        • +
        • This is item 469e9e37-9223-41c1-a454-9a453f41ec92
        • +
        • This is item 465403a2-c93b-4d0c-b409-b4b16a5f2722
        • +
        +
          +
        • This is item d3543dcd-f381-494e-bbe8-a48c5ef75782
        • +
        • + This is item a1e5c65d-b922-41a9-8893-0425affd0cb6 +
        • +
        • This is item 04e76794-ca77-452d-b38d-953ebd7af2f2
        • +
        • + This is item a0d1e6ff-ef3c-4ae1-9298-18061fcf4b97 +
        • +
        +
      2. + +
      3. + + This is link d7e11d43-8b3c-41cc-b1d8-de8daf81e1eb + +
      4. +
      + Profile picture of user 48d840a2-d618-40a2-9269-058f12953881 +
      + + +
      +
        +
      1. +
          +
        • +
            + +
          1. This is item dcf6da3f-cd0c-42cd-bf3e-ff95f11a42b7
          2. +
          3. + This is item dee3e7ac-c875-4eec-b123-0a428625a69d +
          4. +
          5. + This is item d4befb0b-141f-4d37-b94d-8f1ab68b05be +
          6. +
          7. This is item 1b41f76d-72ac-4acb-a502-c1470961b613
          8. +
          +
            +
          1. This is item bea3b7da-238b-48f6-a914-ceca9bd81531
          2. +
          3. This is item 9a36c6dc-4a33-441e-81fc-0e20358726cb
          4. +
          5. + This is item 86ae9612-9d1e-47e6-b04f-5abce55ab608 +
          6. +
          + Profile picture of user c5c6fea2-9828-47a3-b6d0-5a5c7b6cf60c + + This is link 0a46a84b-a8f0-49ec-a2da-22fdf9626e73 + +
        • +
        • + + This is link 3e5b2529-f139-4d58-bb77-7446e17b28c3 + +
            +
          1. + This is item 3cc144e6-c381-43c1-ad90-d204647ebeba +
          2. +
          3. This is item 567414f0-1e20-4178-9d85-ea59264925ef
          4. +
          +
            +
          1. This is item e2298703-fdbd-4eed-af0e-cdb070e2706b
          2. +
          3. + This is item 35d252da-75d5-4aa5-a11f-c75050e4fef9 +
          4. +
          5. + This is item 16ee79e0-a037-40ed-90e6-dc5ed27dd9a1 +
          6. +
          +
            +
          1. + This is item 61bb31d3-a7a4-493e-9056-f279d01826b4 +
          2. +
          3. This is item 2d40465f-a65c-41fd-8a27-6c652e1b4037
          4. +
          5. This is item 5f0733bb-b419-4a71-abda-83f2286f1d5c
          6. +
          7. + This is item 71770100-a00f-4b8d-a22a-8698b94a7cb7 +
          8. +
          +
        • +
        • +
            +
          1. This is item d032389a-7f9d-443d-a99e-56a8d337ce7b
          2. +
          3. + This is item b5fa59db-df70-4817-9103-b9f5131912f1 +
          4. +
          5. This is item 519e0fd2-e4e8-46c7-9ff2-fc1fc709e8fd
          6. +
          + + This is link 093d8f89-fc9c-4fd6-adf7-69faae1459d1 + +
        • +
        + + This is link 2bf81a67-3359-4e32-bbe7-3be22ccb814e + + Profile picture of user a1d55302-8081-4857-ade1-4c2e3243c56d +
      2. +
      3. +
          +
        • + Profile picture of user 406601b8-eb71-466e-9051-997d07290a5d +
        • +
        • + Profile picture of user 8d3d66b4-edf4-4c3d-970d-9834019514ba + Profile picture of user bdf5c00e-7d03-4b4e-b4cb-4a9132600943 +
            +
          • + This is item 05b7745e-80c1-4dca-8ef6-3849e9527fd8 +
          • +
          • + This is item 9bd66bd1-30aa-469f-8873-f8a632274199 +
          • +
          • + This is item c20e3976-1e89-43b7-9004-73347641b456 +
          • +
          • + This is item e61f6a70-9fbf-4300-9403-d1414f3e857b +
          • +
          • + This is item 10fd4bba-fa7d-4417-8ef0-c149f1c36bc0 +
          • +
          +
            +
          1. + This is item f690de44-c991-4716-9f69-639d6cab5674 +
          2. +
          3. + This is item 997899af-005c-4a03-892c-4320e2e49779 +
          4. +
          5. + This is item 487a2b87-faeb-493a-a902-b9b6be3bc7ae +
          6. +
          7. This is item 751f67dc-0258-455d-8b5f-7924ad276810
          8. +
          +
        • +
        • +
            +
          1. + This is item a0234046-9f6b-4c26-b1fa-b3ab9ee3d1be +
          2. +
          3. This is item 3445c4fe-82fe-4c26-9215-70ebd987a400
          4. +
          5. + This is item 785a55d6-8356-44cd-a9f8-61b65cf1d6e0 +
          6. +
          + Profile picture of user 475128ca-34a7-4ae5-87b1-007fab13027b +
        • +
        • +
            +
          1. + This is item cd767806-2991-471b-8912-b19ce1a244ab +
          2. +
          3. + This is item a3b760d7-4d24-4995-aa57-b4539f9d9099 +
          4. +
          5. + This is item 8bb797af-3568-4d4e-858c-f0556180142b +
          6. +
          7. This is item 39f09b91-8da8-4415-a8dd-6875bf44529f
          8. +
          9. + This is item 2fc0cc16-33a8-4ff9-bc51-afcc0018856f +
          10. +
          +
        • +
        +
      4. +
      5. +
          +
        • + This is link 666a56ff-1492-49ea-be55-cfa03d4b465b +
            +
          1. + This is item 3d8cfd9e-92d6-455c-862d-cae54535cbe6 +
          2. +
          3. + This is item b38dfe93-6090-4e18-90f6-7d01fa966730 +
          4. +
          5. + This is item 23cf4321-a4d0-49ec-8b76-f309bda558df +
          6. +
          7. + This is item f9aa0163-3cec-44e2-b2d0-1b069aed4cc3 +
          8. +
          +
            +
          1. + This is item 8cd3166b-11e2-4510-acfc-e7164291ed60 +
          2. +
          3. This is item cc319df9-9621-4a81-a2cf-08e416d924f4
          4. +
          5. + This is item aca77c09-4b37-4793-8062-e27d2080fe3e +
          6. +
          7. + This is item fb350f75-3b62-41f4-8b47-af0a0fc6b13d +
          8. +
          9. + This is item 6ae0363e-b38d-4f40-8c7f-10eab45f8312 +
          10. +
          +
        • +
        • + + This is link 02711a81-0962-41ee-b004-295c9444da0d + +
            +
          • + This is item 71d26b75-6b13-4e57-86b3-23ee061c52d6 +
          • +
          • This is item a054d31d-18d0-4782-b0f2-0c777d1cab3a
          • +
          +
        • +
        + Profile picture of user 2aa2a0f9-585f-479d-99ff-78cac697f407 +
      6. +
      7. + This is link 83e61d6f-2754-44e3-82d8-f1dfed04a37b +
          +
        1. + Profile picture of user c7fa150f-e78f-454d-bde1-65f0c8fff72f + + This is link f3f00510-6ff9-4c9d-a655-8a4b97e8edfa + + + + This is link 2b4d5045-366f-4f97-aba6-aa8fbfdf0859 + +
        2. +
        3. + Profile picture of user 2f0ab145-954c-4bc2-9c21-9d206011d46b +
            +
          • + This is item 96eb027b-135a-48e9-afc5-a72fe54c2bc6 +
          • +
          • + This is item 157260d3-6dc1-4c21-98ac-bbc8ea3fb381 +
          • +
          • + This is item 6f1f76b9-62de-47c5-84eb-2f5860438f5e +
          • +
          • + This is item 1dff0ac0-c446-4e13-9d4a-50de18b3596f +
          • +
          • This is item 157bdf66-c20c-4c0f-b008-697a56bd9a38
          • +
          +
        4. +
        5. +
            +
          1. This is item 4153d60e-c196-443d-9e91-4d0d21f02007
          2. +
          3. + This is item 63085d0e-ae97-41cb-b2be-2b3c3c8c3dce +
          4. +
          + Profile picture of user 326da026-4632-45be-b6a2-62c1f8f9cc6d +
        6. +
        + +
          +
        • + + This is link 25c6d6fc-f94f-4580-a36f-2a44998b9733 + +
            +
          • + This is item c44c43b8-1d10-4251-aee9-a77385d4ce37 +
          • +
          • + This is item fe8fa393-b198-41d1-825a-7eb12e31eaad +
          • +
          • + This is item bb6f17f7-7f7e-4ec1-ab6e-58dee2a10e53 +
          • +
          • + This is item b10811ae-f86e-4cfb-b2ed-61e9d99864b3 +
          • +
          +
            +
          1. + This is item a3f9e46b-c779-48ca-adf0-d169abd7bc5f +
          2. +
          3. + This is item e3a4b25f-0303-43bc-99f3-965c074b0397 +
          4. +
          +
            +
          • + This is item e931813f-7668-4238-aeda-357a7c9443aa +
          • +
          • This is item a92e9e71-2ea2-44ff-a5d7-0cac09e171f7
          • +
          • This is item a02d64a6-a7e2-4ad3-9e53-c6465be2eb9a
          • +
          • + This is item 07ee939e-9f39-41a4-9e47-080460185bf1 +
          • +
          • + This is item 8c3d9f5d-05ae-4f65-aa64-e2aaeb381d6d +
          • +
          +
        • +
        • +
            +
          • + This is item 9faca138-9bd0-49d4-82d1-b6de3b5b697d +
          • +
          • + This is item 598778a7-b5d3-426f-b43b-4aa40999329c +
          • +
          • + This is item f6755a82-3875-42d6-8a2b-c502338c5e1d +
          • +
          • + This is item 75e1ed85-f225-4a06-a44d-3a1cb66d49e7 +
          • +
          +
            +
          • This is item ef3b02d8-9349-44b4-8d89-148526e07fca
          • +
          • + This is item a8b8f0c2-901c-479e-bec3-4f6a6b5fdf82 +
          • +
          • This is item 240be924-8da5-48a8-99cb-c7809ed6c48f
          • +
          + + This is link 948945c0-2254-4178-9e96-5a14ee275940 + +
        • +
        +
      8. +
      9. +
          +
        1. +
            +
          • + This is item 924e179f-5079-4843-bd5e-51b2a7b855f9 +
          • +
          • + This is item 7199569e-c052-4517-9658-65b02f027df8 +
          • +
          • + This is item 70b5684c-8ebd-4c00-be2a-e86cd4fd6919 +
          • +
          +
            +
          • + This is item 32d42d3e-8d3a-4718-930f-24f16063efc7 +
          • +
          • + This is item c29ecf8a-b16f-4778-9397-d31c646b54c7 +
          • +
          • + This is item f8739f11-3a74-4f7a-ad40-12e78d700cd5 +
          • +
          • This is item 75beed10-0db5-470b-acf6-60f144ca5db1
          • +
          • + This is item f35cb374-5553-49fd-a402-fa60025a297c +
          • +
          +
        2. +
        3. + Profile picture of user a7c517ca-0899-4a2a-8fcf-ba1fc2fb66b5 +
            +
          1. This is item 699b5cda-8940-4809-9b98-050fb4b11c22
          2. +
          3. + This is item aac912f7-6acf-47ea-8d9a-deffb164bfb4 +
          4. +
          +
            +
          1. + This is item 5f574f89-a3f3-4594-9a09-14d5838f8b53 +
          2. +
          3. + This is item d66c13ac-be53-41f1-ab7a-028d47cf4d07 +
          4. +
          +
            + +
          • This is item b8fc0089-7ca7-4eb0-8722-1662da683fb4
          • +
          • + This is item 0bc0a83b-cf91-45e9-871b-07529726f312 +
          • +
          • + This is item 6cd91658-d2e0-4f1f-b002-7ae34ae7eecc +
          • +
          • This is item 5eb4fd90-ab71-4984-ba8d-a6a2a7529fa6
          • +
          +
        4. +
        5. + Profile picture of user d41483d2-7d26-4ef8-98de-d93e5b13dc15 +
            +
          1. + This is item 733ed128-8da0-4086-a8c0-a8d2ad178651 +
          2. +
          3. This is item 10e418dd-d680-489e-819e-20eebe47ca13
          4. +
          + + This is link aa6c77e5-ce06-4b50-8563-3c5335277659 + +
        6. +
        7. + + This is link 55e050ea-be7f-4126-a907-387f4b5cd09a + + + This is link 40058854-4895-4f26-8eea-c10c4b2cb928 + + + This is link 85c6399c-5ef2-4f08-af2b-f37a14617345 + +
        8. +
        +
      10. +
      + + Profile picture of user 6feb5bbe-a224-4b1f-b3c0-f33c7d23b6a3 + Profile picture of user ce98532f-31d6-46dc-ae9b-9a3cb3890eb3 +

      + This is heading 1410e2cf-01ca-452f-9c12-ba3db01acaf4 +

      +
      + +
      +
      + + +

      This is heading f8a5e5dc-f6b2-483b-8ff2-0ac1a20f235d

      +
      + + This is link 1166bd3e-162d-456f-a80f-42b5d7299732 + + Profile picture of user ccad3d15-1a11-4d87-93f3-0c26d0c3cff8 +

      This is heading 9334ab2a-6eff-4bfc-b2a3-0df5a3308e7b

      +
      diff --git a/oxide/crates/core/benches/fixtures/template-002.html b/oxide/crates/core/benches/fixtures/template-002.html new file mode 100644 index 000000000..de34f32b5 --- /dev/null +++ b/oxide/crates/core/benches/fixtures/template-002.html @@ -0,0 +1,9027 @@ +
      + This is link 3ba81bb0-79d3-4665-85c9-b5afb8f0c095 +
        +
      • +
          +
        • +
            +
          • +
              +
            • + This is item 1d9752a2-6a3c-4fde-bbeb-0cd2e9494087 +
            • +
            • + This is item 8ab5fbe5-1a6c-4001-8a9a-addae960c0af +
            • +
            • + This is item 88721283-1b17-4eac-b26f-9b05c564de22 +
            • +
            • This is item 6056d8f9-30ee-4b85-97d9-6564d1762d9c
            • +
            • This is item 312fd964-a837-4a0f-a768-4600729c8f88
            • +
            +
          • +
          • +
              +
            1. + This is item e4987c46-abed-425d-b2c9-5dcda87a60a3 +
            2. +
            3. + This is item f9632c66-620b-43b2-8cc1-6f7b4cd58722 +
            4. +
            + + This is link 2857d7fe-46a0-46d3-9d09-a41f7eb03375 + +
              +
            • + This is item aeadf1ce-9848-4cf9-a798-bd494a924a05 +
            • +
            • + This is item d63421fa-b161-4aa6-8867-ab2ed945b161 +
            • +
            +
              +
            1. + This is item 75968155-df41-435b-9765-7b3577cb6b4c +
            2. +
            3. + This is item 14a47d0a-514e-480c-9243-5dd6264e6d2f +
            4. +
            5. + This is item 22a23886-d195-4cab-a5c0-361883f20e42 +
            6. +
            +
          • +
          • + + This is link a7f95d3b-25ff-4db2-ab94-2fa36e5d524d + + Profile picture of user b44d8637-b85c-4bf6-9acf-b016497069a3 +
              +
            1. + This is item e4f0cb6f-804c-48c3-9049-162a8bfa2a58 +
            2. +
            3. + This is item 9e1a5248-f572-46b2-9336-f2ca5612139e +
            4. +
            5. + This is item d3f13efe-8dd6-407c-a866-f22dee6daf80 +
            6. +
            + + This is link 8038c5e5-ad1d-422f-9d81-8cddd6eeaa00 + +
          • +
          • +
              +
            1. + This is item f73e8cca-18f2-41d1-860a-3df8ff822bef +
            2. +
            3. + This is item 3edd9c90-582a-4c3a-9fdd-cccc3ae580e8 +
            4. +
            5. + This is item f4709056-37ca-4c22-a08c-938ff31d9e16 +
            6. +
            7. + This is item 9fb83569-6949-4791-9113-293a7333648f +
            8. +
            9. + This is item 7dde6001-364d-46a8-a33a-bafac6c314b1 +
            10. +
            +
              +
            1. This is item ac9bc80c-41cf-479c-94f5-473339bb9065
            2. +
            3. + This is item dd2fd6f7-66e0-45a1-8e21-558c1b38452b +
            4. +
            5. This is item 78e30f97-c92c-487a-871a-c06d8f6b4572
            6. +
            +
          • +
          +
            +
          1. +
              +
            • + This is item f7711130-e381-4ee1-91e7-8a159a8f012e +
            • +
            • + This is item 4ec8ec03-e21c-41b3-baf5-b7cbc20bd99d +
            • +
            • + This is item 1ce0b33d-7177-4265-b0cf-2eab6b6b835c +
            • +
            + +
              +
            • + This is item 47882342-5ab4-4480-a6a4-dabf24577179 +
            • +
            • + This is item 7190c573-7d60-49b9-8838-a35d8d1f2eaf +
            • +
            • This is item 253478bc-2370-4ce0-afed-fcce7bccbb71
            • +
            • + This is item c6d8e9b4-fdee-4295-b617-7c333ed46018 +
            • +
            • + This is item 913071a6-1f6e-41a6-9330-71a8122fa8a5 +
            • +
            + + This is link e58161ee-5e80-4d15-a9ae-18a979feac74 + +
          2. +
          3. + Profile picture of user a9a1a043-b5f2-4295-8239-4a69bcb8492a +
          4. +
          5. +
              +
            • This is item 57b01633-26c0-439f-9da4-a41fe597c3f8
            • +
            • This is item 4205203f-4367-4a24-8db7-e1dbd3824704
            • +
            • + This is item b9ce1fb8-ccae-4d32-a12e-45d3ef544c7c +
            • +
            • This is item 6db89219-b326-434d-a809-8b5ec10cf56e
            • +
            • This is item 4220ee0b-1755-41d3-869a-97969a5fb70c
            • +
            +
          6. +
          +
        • +
        • + Profile picture of user f0a79b95-461d-43e0-ae83-a53912e5800f + Profile picture of user 74e2ae86-7c0b-4693-8393-d43612a305b8 +
            +
          1. +
              +
            • + This is item 1e8c4c01-531b-4911-aef2-723aa147e1fb +
            • +
            • + This is item 016c1593-2b7a-43aa-9664-573e344d7b01 +
            • +
            • + This is item 7318f784-33c1-4c13-a237-7be97d98f39e +
            • +
            • + This is item cd27bc06-b9cc-45ec-bf5c-6b1d320e274c +
            • +
            • + This is item 4f34c9e1-1b90-4c8c-96c1-d0cd223a5e59 +
            • +
            +
              +
            • This is item 46f1e2c6-5fc7-4506-9914-4420bbe4ce39
            • +
            • + This is item 65e40ee1-3ce2-4ee0-94da-e75a5194a146 +
            • +
            • + This is item 66ff1cf0-f38e-4804-91c8-395e9abd2b7f +
            • +
            +
              +
            1. + This is item 31ab550d-e342-44f8-ad52-2f369c805d55 +
            2. +
            3. + This is item 1d8620cb-49a5-431e-ab85-ea38f2be534e +
            4. +
            5. + This is item fbcbfdba-ce8b-4406-b57c-24b008056f7f +
            6. +
            7. This is item 80755f96-df51-467e-acbd-8a7f23baad8a
            8. +
            +
          2. +
          3. +
              +
            • + This is item 2caf4c94-2f27-4795-8f53-bdd4a5a88ecb +
            • +
            • This is item bb0ecf84-08a8-4881-b7e0-547df46d5b7f
            • +
            +
          4. +
          5. +
              +
            • + This is item 010ed75e-513c-46e5-9e2f-74d3ae943ce7 +
            • +
            • + This is item fb73c48f-580d-4939-abae-66adce1e2e65 +
            • +
            • + This is item 909c2f90-c46f-48bc-8770-3ceeb2681cb3 +
            • +
            • This is item f486ffae-e700-4709-a0b7-6d4dcaf04ed1
            • +
            +
          6. + +
          7. +
              +
            1. + This is item 2780d00c-384d-4785-a667-e361d0040674 +
            2. +
            3. + This is item b6f9f1e4-7f77-4281-bb09-56b45f765c12 +
            4. +
            + Profile picture of user 0c3a8ba4-28ae-41c7-af8c-5bb32b522931 +
              +
            • + This is item 2d8b9f80-843c-42b4-84ce-2e79a156ba0c +
            • +
            • This is item 70655d8d-73dc-4296-884f-97162ea83dba
            • +
            +
              +
            • + This is item f4863e14-0856-474e-9952-909e4c3b1a36 +
            • +
            • This is item f1e87ceb-86d8-4cc4-a2e2-acd5bea4f0fd
            • +
            • + This is item 05917886-5c3a-49d7-9fc0-fefadd8800d5 +
            • +
            +
          8. +
          +
        • +
        • + Profile picture of user 22ee6ba4-2e3d-40f6-b6d5-effe7507adf3 + Profile picture of user e47e0fce-1bfd-49b4-92d8-67bfda9bdd0f +
        • +
        • + +
            +
          1. +
              +
            • + This is item 26c050a7-742e-45fb-a978-a589a135564a +
            • +
            • This is item cced1bbe-e516-48ba-b41d-34f3407a6a0b
            • + +
            • + This is item 2eea132a-a625-4b35-aeb5-ba64bf5833c2 +
            • +
            + Profile picture of user f75ad9f0-5ef0-4f7c-9a18-0b4016a4b453 +
              +
            • + This is item ba6f529c-4485-4a6e-a1cd-c595f45ea86b +
            • +
            • This is item 93056b40-dd20-4c3b-8c2a-d8ae6af3c2b5
            • +
            • + This is item 526c4c07-d76d-40a9-af41-e24a1a855437 +
            • +
            • + This is item a64a3598-b64e-44b1-99da-cd5b192c1c5c +
            • +
            +
              +
            • + This is item 197d2f5f-341b-448c-b6d7-03cc0c5a8d8c +
            • +
            • + This is item 0210350a-c07a-4729-8783-4bb93487d86c +
            • +
            +
          2. +
          3. +
              +
            1. + This is item c9d712af-237d-4cc8-b3b3-a2393043d896 +
            2. +
            3. + This is item 0f8be103-38f7-4491-aacf-8e6e49cd33b8 +
            4. +
            5. + This is item ec158782-c23c-4208-ac6a-7e4cc0d329a2 +
            6. +
            7. + This is item 5369d4ee-109a-490e-a1ca-7caa25d32c53 +
            8. +
            + + This is link 963caf1b-b863-46df-9326-ed94f9941825 + +
              +
            • + This is item 1c9fba27-8ccf-4a51-802d-2ccd84a99c89 +
            • +
            • + This is item b1e9673d-0869-41b4-bfe9-4fbaf5616f10 +
            • +
            • + This is item 507d43db-fb31-4634-9e1d-0ddd117e3ea8 +
            • +
            • + This is item 104574cb-b5cc-48f0-b04d-47320da67275 +
            • +
            • + This is item 84b979d9-4021-4407-b23e-fc3a4141b13b +
            • +
            + Profile picture of user ffdbf27d-3270-4b5a-a7e2-e5be8659f741 +
          4. +
          5. +
              +
            1. + This is item 735385f0-ca0e-4d0a-a0f1-d8bda842d309 +
            2. +
            3. + This is item 8d365ef3-1430-49fe-87f5-2a7f82838ae1 +
            4. +
            5. This is item 2eb770ea-dcd3-4ecd-84ee-88cef48621b0
            6. +
            7. This is item d5a3f992-f74a-4db3-a2e0-2aa91ffddc57
            8. +
            9. + This is item 24c19d58-2172-4f13-9762-d2ec7537773c +
            10. +
            + Profile picture of user 788476ea-eadf-48ed-aa98-73376176afbb +
              +
            1. + This is item 8271752d-d4ab-4b0f-a267-151c420c064a +
            2. +
            3. + This is item 605f45cd-b819-4638-ab05-3d3223b36e43 +
            4. +
            5. + This is item 46f0359a-15b9-4c6b-bc58-a626bc01c96d +
            6. +
            + Profile picture of user d5bde696-ce57-41b9-b629-5287c7945e6e +
          6. +
          + +
            +
          1. + + This is link aefd9423-f4de-4944-a7e2-262c8be52ea3 + +
              +
            1. + This is item ad0e87d9-f7af-4e3f-bb21-0ab1efbdb91a +
            2. +
            3. + This is item a78c8836-2495-46a5-97e3-d49a8b976f5e +
            4. +
            5. This is item b619479a-25fa-4f48-9c5e-3f77089d23ba
            6. +
            7. + This is item b4d63262-ae95-4e8d-961d-72eb7ce93612 +
            8. +
            + + This is link 1e49b1a4-8956-4069-b0f3-4e826efdc82a + +
          2. +
          3. +
              +
            • + This is item 14f4004a-f3b1-4b2e-a5b7-3677fedb584e +
            • +
            • This is item 30fba329-c1a1-4b00-b738-a1a057c37680
            • +
            • + This is item de4e5c27-05c6-40b2-ad52-5a10727ed14b +
            • +
            • + This is item 25861be0-48b2-4819-aa7b-45eea061862d +
            • +
            +
              +
            • + This is item e0b62228-fa56-4d7d-b548-0ee6a6a9359d +
            • +
            • + This is item 8aa7e970-aa22-4094-8acd-f66db9e830a2 +
            • +
            • + This is item a99f29b1-a833-4a29-8b6a-47146d2312ba +
            • +
            +
              +
            1. + This is item e5945d2a-e170-4edd-92c8-3e079553b84a +
            2. +
            3. This is item 049cd986-6d56-4628-b704-98cef031b398
            4. +
            5. + This is item cbfea0b7-f033-46f0-8d01-7c904427a445 +
            6. +
            7. + This is item 6f997bbf-9db0-4cb6-aa12-830637a23e18 +
            8. +
            9. + This is item de13db4d-72c8-442c-bbeb-917a2f08e7ba +
            10. +
            +
          4. +
          +
        • +
        +
          +
        • + Profile picture of user 8ec4667f-8dfa-4aa2-a476-17efe1c010fd + Profile picture of user 635fe4ea-2919-45eb-83e4-d8271097446e +
            +
          • +
              +
            • + This is item 814098f4-c99e-477d-ae1d-c0e970f26beb +
            • +
            • + This is item b23a8cd4-9a4b-425c-8c9c-707bda3584db +
            • +
            • + This is item c64594c8-c0a8-428e-96bb-2c5eff02f3f8 +
            • +
            +
          • +
          • + + This is link 43447af4-852d-480d-a813-62719e0f0743 + +
          • +
          • +
              +
            1. + This is item a0586145-433d-49b8-b19f-4b48b7abb1f8 +
            2. +
            3. This is item b8009ace-41d6-474a-a131-4d13cb5820c2
            4. +
            5. This is item e31414ec-580f-4947-8ba7-1ca01049bf1c
            6. +
            7. + This is item fde81971-3334-457c-9c5a-229c5f850b18 +
            8. +
            9. + This is item f358ccd5-2d89-41a4-95e5-7a481e6ff53f +
            10. +
            +
              +
            1. + This is item 3b0a6e0e-3079-4dcb-aada-d4180e37bc82 +
            2. +
            3. + This is item 5bd164d8-d1d7-4b87-ba2b-28ee17193d49 +
            4. +
            5. This is item 8e74ff55-b7f9-4c28-8d3c-4706ea629ca7
            6. +
            7. + This is item 886d56e6-0db6-4e28-99fb-320ed6ab1073 +
            8. +
            9. This is item 4114e404-d58c-4f13-8cc4-0f5138c590b7
            10. +
            +
              +
            • + This is item ca3c8b79-9a07-4769-99c8-6dd50ffad4a5 +
            • +
            • + This is item f3d1a7bf-5344-4c36-b9a1-ea0e8c1efcd6 +
            • +
            +
          • +
          • + + This is link 5da67059-d871-4122-927d-97b57808a967 + +
          • +
          • + Profile picture of user e5ae4aaa-6424-438d-8b19-e96169054814 +
          • +
          +
            +
          • +
              +
            1. + This is item 24b0897f-837c-4a35-afe7-ee3c4628ecdc +
            2. +
            3. + This is item a6cbba1c-4b8f-4170-8fbe-c1329a80b55c +
            4. +
            +
              +
            • This is item d623c0e9-ef1b-4619-955f-9429d9b23c61
            • +
            • This is item 467746e2-3e45-4f26-991d-36aa88c4ad6c
            • +
            • + This is item 6aba0127-da82-49ef-b3b8-3347a5bd0ccf +
            • +
            + Profile picture of user 4c573264-284b-4841-b7a3-1463841c57bd + Profile picture of user 887910ff-0112-49d1-b230-c386750c6d81 +
          • +
          • +
              +
            • + This is item 00e5b7df-36a3-4203-9849-0e04f3fc6b92 +
            • +
            • This is item 7ef2ef9d-d635-45ac-85aa-b521c27da03c
            • +
            • + This is item 1b150360-8284-43be-8fc4-1e9ff3042332 +
            • +
            • + This is item 9319575d-51ef-4cdc-b1c8-0397eb6edf09 +
            • +
            • This is item b82de6b8-8ea0-4d37-bfba-49776a4b4d8e
            • +
            +
              +
            1. This is item 7014dbd0-20d2-4a5e-aa54-f57f8ad2e0c1
            2. +
            3. + This is item 4989db0d-2a75-4152-aed7-6e6684924d00 +
            4. +
            5. + This is item 5eb99884-f60d-4c60-b70f-ce10af3e5827 +
            6. +
            + + This is link 4e984f9a-5012-41a1-8a7a-ef57f75a7cd9 + +
          • +
          • + Profile picture of user a910fe6f-6273-499c-b66c-2f5f0cc4e435 +
              +
            1. + This is item 3b375efe-c0e9-4f57-9fab-65acb49e14b9 +
            2. +
            3. This is item cb3d461c-b942-47ab-96e8-479644581059
            4. +
            5. + This is item 6ad13bd2-c833-444a-a3e3-72d42fc0f589 +
            6. +
            7. + This is item 4c669a1e-3d08-4344-af1f-f4163a27e48f +
            8. +
            +
          • +
          • + Profile picture of user 3c5a4a7b-2326-4652-8782-1a1ef90e4446 +
          • +
          • + + This is link 605d29ba-89f0-43d0-a1fd-afab4e6937bc + +
          • +
          +
        • +
        • + + This is link 1750e28c-1a3c-4cca-9398-2be2a29579cd + +
            +
          • +
              +
            1. + This is item 7fb37267-1823-487c-923e-de855da5b276 +
            2. +
            3. + This is item 1d6ba5ec-530b-4517-ad44-c8e63061d9ac +
            4. +
            +
          • +
          • +
              +
            • This is item b99d4e29-2927-4d79-b286-13e1668d31d1
            • +
            • + This is item 9d0eacba-9c3b-4ced-82bd-a69f9ec63297 +
            • +
            + + This is link 61fd992b-a9cd-4b42-a6db-3077f85263bb + +
          • +
          • +
              +
            • + This is item 12a3396f-252e-4c66-9aef-61276740710d +
            • +
            • + This is item f368116c-2f42-4052-8a72-4ced4a5a8fcd +
            • +
            • This is item 77712eff-07fc-4b5a-aa4f-768c4c201eab
            • +
            +
              +
            • + This is item e3fbcd78-5a7b-412c-925b-dbfb5b8450fc +
            • +
            • + This is item 62371d1a-0fc2-46c5-bf59-fd137fb2d369 +
            • +
            • + This is item 2ad07de5-1ef1-43d8-a597-178cda3e57b6 +
            • +
            • This is item 53747a87-bd31-4d22-a5a6-b2f40825655f
            • +
            + + This is link 10e489ef-494a-4258-b5b2-dd2e69b0208c + + Profile picture of user 730095ce-0913-49f8-b172-02b8812aea64 +
          • +
          • +
              +
            1. This is item 1597a27a-a339-47d3-be5a-49d44e97719f
            2. +
            3. This is item 4194d35e-6f14-4f2b-8ce6-6bf82d51378b
            4. +
            5. + This is item a3551818-df64-4b4e-9a86-d0b02b5e61b5 +
            6. +
            +
              +
            1. + This is item 4c23c735-39e3-4bfa-bf36-ceee45051410 +
            2. +
            3. + This is item 6beb081b-b3ca-4245-986f-1a266bb31e57 +
            4. +
            5. + This is item c70579b5-b239-4aae-a1cf-1317bc987c0c +
            6. +
            7. This is item 3e33369f-7fff-4fbe-ae89-45cf538b6587
            8. +
            + Profile picture of user e5aa7162-05d0-4297-bd40-819a6c4eaad5 +
              +
            1. + This is item 6667dbbd-249d-4949-8869-08254c4d3532 +
            2. +
            3. + This is item f3dd9562-ad9c-4446-9751-4a738fec5c2c +
            4. +
            5. + This is item d4090540-2afe-4332-a55f-728c4a9e2eed +
            6. + +
            +
          • +
          • +
              +
            • This is item 924fe7e4-7d53-4938-bf12-8bb737948221
            • +
            • + This is item 30d61253-37a0-4c97-839a-a989b7ee0e6b +
            • +
            • + This is item 6573e7bf-742a-40dc-b4dd-ea482933d85b +
            • +
            • This is item 79eeee22-a096-4e09-a22e-2b89737c1df5
            • +
            • + This is item 274319c9-1013-4696-aeec-ec1b706b773e +
            • +
            +
              +
            1. + This is item a4993e72-4055-4a4c-9a9b-8a4a4aff03fe +
            2. +
            3. This is item 7763a903-c7cd-4439-a821-ba5c68e05eca
            4. +
            5. This is item bfeb2930-6175-4f4d-b898-20e811c6b19c
            6. +
            7. This is item b8451827-4ab6-419b-8202-db3c543e8e0b
            8. +
            + Profile picture of user 61102ceb-1e28-462e-ab38-9774b566ff0c +
          • +
          + Profile picture of user c9bd7da0-2287-4f2b-a4c4-b9087f8e85e5 +
        • +
        • + + This is link 3f4e88d7-435b-40cb-84c7-9a80f92aa863 + +
            +
          1. +
              +
            1. + This is item c489aad2-d06a-47a0-9eaa-9d525b33ae81 +
            2. +
            3. This is item 1d332acc-9702-4557-aa24-561e4e3e14ea
            4. +
            5. This is item f97daf83-623e-4add-8431-b2ffffb0a156
            6. +
            7. + This is item cc2aa40f-c846-4e9e-8c33-0d70494f8446 +
            8. +
            + + This is link d84d15c4-46e3-4901-ba67-6dfff30bd1cc + +
          2. +
          3. + + This is link a68d55ae-b7a1-4073-a699-95e061b00e24 + +
          4. +
          +
            +
          1. +
              + +
            1. + This is item 2b44ad48-70f6-454a-bc66-358123ac782c +
            2. +
            3. This is item d2133271-1cb6-40c6-85d3-a35af5bf3a34
            4. +
            5. + This is item e0d7d87b-bc52-41b2-bbd1-8d5ce8551ac1 +
            6. +
            7. This is item 37ffcb10-7198-4f3f-a6fc-a8a52401db15
            8. +
            +
              +
            • This is item 7dfbe6ed-2be5-461f-ac96-e7dbebf5ab33
            • +
            • This is item 3aba80bd-ae5d-4f1f-a76f-e7ac33d7ee8d
            • +
            • This is item 0be32d9e-c1e2-4f5c-bef0-873355b508e6
            • +
            • + This is item 61ef412a-65f7-4719-b61c-0e934fea09d4 +
            • +
            +
          2. +
          3. + Profile picture of user 3f00a703-96e8-4003-9eb8-692637f3f0e1 +
              +
            1. This is item 850fc236-c688-4182-91f8-7e75007102c3
            2. +
            3. + This is item c88e601a-2a4c-492e-af16-67771ddb81e3 +
            4. +
            5. + This is item ed8e3539-a7b7-40f9-9c89-fd8569430bff +
            6. +
            7. + This is item 68455b0e-76b8-4331-8d87-42ca9ea79cb1 +
            8. +
            9. + This is item 5568bb6e-c1b7-4714-bc08-38b8a0a27476 +
            10. +
            +
              +
            1. + This is item 30afd3b1-7996-400a-bb09-a31f1e7e2eea +
            2. +
            3. + This is item b76c7ab1-d53c-48d1-85dd-40abfbc659ce +
            4. +
            5. This is item 18f92394-f42b-4ab1-b748-988c10559de6
            6. +
            + Profile picture of user f56b6658-b3fb-4f29-9a40-557b15b5966c +
          4. +
          5. + Profile picture of user 13c58e04-25e3-48b5-909d-22061101a27c + Profile picture of user e6f1fb8d-3547-4d7c-bd12-ccd91b5ee28d + Profile picture of user b3064b42-84d8-4ca6-a1c6-556e019cc17e +
              +
            1. + This is item a24d67cd-325e-40b9-94d8-cadbe05f173c +
            2. +
            3. + This is item de9d4c82-628e-4a0d-8ded-4bcd9f9a1792 +
            4. +
            +
          6. +
          7. +
              +
            • + This is item 80077062-1ea7-40f3-897e-cb7373708173 +
            • +
            • + This is item 729a6494-a1bb-4f59-95d5-2ab56fc79226 +
            • +
            • This is item 582c4d63-4058-4026-8140-738b19f483c6
            • +
            +
              +
            1. + This is item 041af07f-a618-418a-9883-041a49c2bdca +
            2. +
            3. + This is item 65d67767-ee86-4108-8f76-5e2b93b3121c +
            4. +
            + + This is link 57c42f41-9b97-41a4-881b-8fc475fa0c70 + +
          8. +
          + +
        • +
        • +
            +
          • +
              +
            1. + This is item d4c99761-2872-4ff2-9e56-990d34cbbe05 +
            2. +
            3. This is item 91e726b3-0730-493c-8956-a275c802a0a7
            4. +
            5. This is item e56110cd-ad04-486c-9576-e3e7042c0d8f
            6. +
            7. This is item 93febb42-4830-48c1-9941-05295fd5d9dd
            8. +
            9. This is item 49ca3ff8-3fec-472a-bec7-53e49d90a3b7
            10. +
            +
              +
            • + This is item e9d9f639-db7a-4c32-9ac1-1938236ec1cb +
            • +
            • This is item 81bdfdc5-d056-4ca8-ae1e-d7e769b7e13b
            • +
            • + This is item be9f5d55-4502-4ded-a5b1-50d0e4dda545 +
            • +
            + + This is link 2b2fd4f4-7b7b-4734-a0e8-0c72600c0632 + +
              +
            1. + This is item d04b41a2-4b47-46c2-bbc8-2382c7aa2280 +
            2. +
            3. + This is item 6b85cda2-6992-493b-a29e-d716808f8cb6 +
            4. +
            +
          • +
          • +
              +
            1. + This is item 758b2cf0-c9fc-4a64-a6d7-0e291fd57e11 +
            2. +
            3. + This is item 0f5965e6-b626-44b9-b22d-1a051003ff69 +
            4. +
            +
          • +
          • +
              +
            • + This is item 71599fcc-bdec-43c3-aaa2-c0ffdc376e3a +
            • +
            • + This is item 4f1db426-556c-45c2-86f1-0ef5398788d8 +
            • +
            + + This is link 96f4fa4c-e6c0-4e71-a119-9353cc308b25 + + Profile picture of user 2d94ff47-6147-48e7-9316-67659a65593c +
              +
            • + This is item 13a345c2-0134-49c9-bf04-fe6391a9e770 +
            • +
            • This is item 8a67297d-f001-447f-988d-64e32de6c303
            • +
            • + This is item 7c888bf4-a4f5-477b-9ca3-879926df6b1b +
            • +
            • This is item ce3f5d7a-5267-4711-a691-0eb876a0377d
            • +
            +
          • +
          • + Profile picture of user eb88c669-7f51-4a62-8590-0698c1b78c23 +
          • +
          • +
              +
            1. + This is item 820ccfad-2a5c-4dc1-990b-cfcf87b64971 +
            2. +
            3. + This is item daf4a475-94fc-490a-a4b3-ac89bea68449 +
            4. +
            5. + This is item 71f0d9c0-b948-4746-83a7-01f62281fe74 +
            6. +
            7. + This is item 10fe916b-7496-4090-98df-799a9ba1cdee +
            8. +
            9. This is item 611a6a92-3680-4a52-9a26-370ab2c11476
            10. +
            + + This is link b0f3dec1-c308-40f7-9590-0f089e33b216 + +
              +
            1. + This is item bc8c3cd0-40c1-476c-aa19-864c527ceff7 +
            2. +
            3. + This is item 26f8cb32-12a7-440e-8d07-e41511eb6638 +
            4. +
            +
          • +
          + + This is link bcf2135b-4ef0-46c8-8133-5e1e88a0cd12 + +
        • +
        + + This is link 1304c937-eb73-448a-9805-bad4fff0b66e + +
      • +
      • +
          +
        1. + Profile picture of user b069426a-c112-478f-94dc-84e5b151bacc +
            +
          1. + Profile picture of user c5668d02-1637-4902-a37c-0aa821a72d33 +
              +
            1. This is item 94e842aa-6c6d-4f51-bb76-a6804baa2239
            2. +
            3. + This is item 81a2c68a-121f-4920-b094-4d2d15088497 +
            4. +
            5. This is item 2e2f6fb0-2b1a-4e7b-81f2-b794048ba8a2
            6. +
            7. This is item 9a97a474-1810-473b-a82b-5087bab50caf
            8. +
            9. This is item 8bd97327-1c73-40e6-a89c-ac9d37465977
            10. +
            +
              +
            1. + This is item 735c0b3b-78e4-4c13-ae78-f5ef2300ddfd +
            2. +
            3. + This is item 9dcb3726-dd4f-42f1-8c9d-c2c9741e2a73 +
            4. +
            + Profile picture of user 7637a190-f800-4beb-b19a-87656323cd3e +
          2. +
          3. + Profile picture of user b1643d07-cf1d-438e-b73f-647d27bcf932 +
              +
            1. + This is item 25f0879f-bf31-46db-b53c-eb74232db3f5 +
            2. +
            3. + This is item 17f449e1-6f2a-4368-990c-15a3a9ad3f39 +
            4. +
            5. + This is item 27dae7da-7945-4a88-add4-42f817ee222f +
            6. + +
            + Profile picture of user 7350cf77-f496-45ee-ac4f-dc33eb3595cc +
              +
            1. + This is item 66c4c3c2-705a-4776-a97e-4f08f63e515c +
            2. +
            3. + This is item cbb365f6-0804-4ded-a0e7-bbf2562d3f1a +
            4. +
            5. + This is item 775b9d37-8870-4ce0-923a-38c5bfb88993 +
            6. +
            7. This is item c387e84a-8f19-44a8-bb53-a781baa35210
            8. +
            9. + This is item 9a1792bf-b38a-4f41-8dfa-968e5220b9d9 +
            10. +
            +
          4. +
          +
            +
          1. + Profile picture of user 4f3a8e49-7cc2-4f9f-8a94-a25009638b9f + Profile picture of user d9fb6e5a-cd03-4003-a149-8140796ee9bf +
              +
            1. + This is item 2b148006-571a-43f3-a87e-f398423b42cf +
            2. +
            3. + This is item a2b81a41-159f-4086-9f6e-5af792bc8e54 +
            4. +
            +
          2. +
          3. +
              +
            • This is item 6c399faf-bd81-4788-bb03-c879122d9a03
            • +
            • + This is item a2cb09c1-d688-4d19-b64b-0406ce9a977e +
            • +
            • This is item 327262da-2318-47da-a01c-9a7a9b39c3df
            • +
            • This is item 6a9d7b12-81c5-44e6-9850-897611f3b1d7
            • +
            +
              +
            1. + This is item 8c1cebc7-e0b7-4a59-82ec-3e9a485f0014 +
            2. +
            3. + This is item 925c353e-43ee-4c9d-a807-178da359cd3d +
            4. +
            5. + This is item 51a614e4-8bc9-4321-adb4-426493be8e97 +
            6. +
            7. This is item 01f2703f-993c-43a9-baa4-a62ea6c255bc
            8. +
            9. + This is item d5fc5b60-7a42-4cfd-89af-056ad9415b31 +
            10. +
            +
              +
            1. + This is item 26813eaf-72b4-49bc-9fab-fe31aa0df40a +
            2. +
            3. This is item 1530041b-7bb9-4e4d-bd29-19ea220902a1
            4. +
            5. + This is item 2c3d66ac-17f5-4821-9760-29c0be5cfa8a +
            6. +
            7. + This is item 4d412085-2638-468e-8d66-1a553d31e3a5 +
            8. +
            +
              +
            1. This is item 92c5f7b4-c7ad-4513-a5a0-f2c5ff3ac457
            2. +
            3. + This is item 4898ead1-2b96-4e4b-8a2f-cb83d31a43e1 +
            4. +
            5. This is item f70674f8-9b36-4a38-bc26-0cc02dcd45ce
            6. +
            7. + This is item e1c7abfc-67fd-4408-b877-8e1580ed8976 +
            8. +
            9. + This is item f6c30e2f-c53e-4840-b006-2fc76e9d0c34 +
            10. +
            +
          4. +
          5. + Profile picture of user 0c075f82-19c6-42fb-a080-e7ce5bed8ede +
              +
            1. This is item 42a87309-a825-4326-b7f6-e89e9b70fa5a
            2. +
            3. + This is item 30ac2a21-8c95-45a9-bb63-1e9fa9daf5f4 +
            4. +
            5. + This is item c6d5348e-4cb0-451d-a493-d63ecbf4f39e +
            6. +
            7. + This is item 655b34f9-f59d-4cd4-ae5b-a4084791f4e0 +
            8. +
            +
          6. +
          7. +
              +
            1. + This is item bb98f70a-6aa6-4fbf-b184-06d19c3470dd +
            2. +
            3. + This is item a5173bdb-8cda-4c1e-a85f-ddc586dcd30b +
            4. +
            +
          8. +
          9. +
              +
            • + This is item 34ac7fe7-5ffa-4573-91c6-d4916bc65d40 +
            • +
            • This is item 46b3ac05-a930-4db8-8498-1757a76dae30
            • +
            • + This is item 8bc8bab3-fb2f-4225-9317-0ca6748d6ae0 +
            • +
            + Profile picture of user 3fc9e3a3-39ff-435c-b90d-088afad2273b +
              +
            1. This is item 3085a74b-96fe-45c5-a362-884db0b86f42
            2. +
            3. This is item 42577904-fbde-461e-98f7-df2784261fff
            4. +
            5. This is item 01415b67-ac34-434b-b081-0a4cbf445def
            6. + +
            + + This is link ce083d41-7056-45ee-9207-70d86af9511b + +
          10. +
          +
        2. +
        3. + + This is link a2b9e344-dfc4-40f0-bdc4-377481044a79 + + + + This is link e04c0136-244e-4027-a4ba-127f6c1c2121 + +
        4. +
        +
          +
        1. + Profile picture of user fcd0f040-2c3a-4f5c-acd6-bf764d423d65 + + This is link 80fa6b7d-44b4-472f-8de1-34ad345c06f2 + + + This is link eeb92aff-b55d-4515-b229-1a91c8a2c43b + + +
        2. +
        3. + Profile picture of user 92c599da-97c2-4453-aa75-911685fe9999 + + This is link 42238130-1323-489d-b725-37042af114e1 + +
        4. +
        5. + +
        6. +
        7. + + This is link 4ac2eb00-4b59-4f7c-b5bd-1452b1d544dc + + Profile picture of user f5d6fcef-5389-44d5-8938-6d6dba461093 +
        8. +
        9. +
            +
          • + Profile picture of user 32100a0f-9e14-4ea4-8050-821811ecf19b +
          • +
          • + + This is link a39fdcbb-63c3-4065-baa3-3492053df7c0 + + + This is link 875d8642-89fd-463c-beeb-2001f7ed8ffc + + + This is link 1e451de3-c64e-4625-b107-20b856e940ca + + Profile picture of user 91f5bb45-b729-4a4c-a088-9a37a5539173 +
          • +
          • + Profile picture of user 3599d98f-e00f-4442-9388-f4f68b3a7c99 + Profile picture of user 2ec0ac15-ed5b-4617-8c84-4a66f7a77723 + + This is link b8f54435-8b65-468c-8a2c-82626d5f1ee5 + +
          • +
          • +
              +
            • + This is item 2bd7835e-9196-4968-a27c-853fa4323975 +
            • +
            • + This is item 8b0fa54f-32cf-47e0-ac2a-1cd769821291 +
            • +
            • This is item 0197a154-be0a-4701-b764-d72b4f592f74
            • +
            • + This is item 93dfed5e-e053-43a1-9664-cbaf14fd80df +
            • +
            +
              +
            1. This is item f63f5fe1-8c90-472e-89e7-75377c8c547a
            2. +
            3. + This is item 841965f3-169d-4c07-a1e3-3c0047f4cf62 +
            4. +
            5. + This is item 4868535c-9b12-49fc-b440-dbc60a9582c0 +
            6. +
            +
              +
            1. + This is item 35aaab85-9867-49a9-afb5-f40472db8eea +
            2. + +
            +
              +
            1. + This is item ff0053d4-19df-4b9e-98a9-407a3ddd8e2b +
            2. +
            3. + This is item 7923e012-d2aa-48c3-8463-20f994e6faaa +
            4. +
            +
          • +
          • +
              +
            1. + This is item 91745cce-1f71-45be-8fbe-110d1c416166 +
            2. +
            3. This is item 1afbde96-2a19-478f-929b-ceec12f6ed00
            4. +
            +
              +
            1. This is item 9d25ad19-1055-4e20-aac6-2aaebf6fa5ef
            2. +
            3. + This is item 05eba93a-c4cd-4d6a-a9bc-5e8124e1e65f +
            4. +
            5. + This is item 4c81fdb6-ccda-4267-8b21-34761ae0aff2 +
            6. +
            7. + This is item 153356f2-1762-41ea-bc3f-b0af2f620b47 +
            8. +
            +
              +
            • + This is item 750a24db-af5b-4db2-abf3-c64ed4d3ae33 +
            • +
            • + This is item 69679321-f30b-4ba9-9a75-889109dce138 +
            • +
            +
          • +
          +
            +
          • + Profile picture of user 500d75e1-a7f2-403f-8064-1247c3f35612 + + This is link 781700fb-ce85-4c50-b88b-d043d368a91c + +
          • +
          • + Profile picture of user c8447bc7-15a6-4b2d-b90a-d16fe2dc4321 +
              +
            • + This is item f09d275a-d1fe-4f33-b299-1e07b0f92c03 +
            • +
            • + This is item a8a9becf-0752-4dd3-ba32-ddca0080d207 +
            • +
            • + This is item a4cdbbaf-bc9c-4032-9631-9a09917abdbc +
            • +
            +
          • +
          • + + This is link 438b9e97-8c98-4443-bb98-4b6281a968b9 + +
              +
            • This is item 1bbc2b6d-6ab1-44a3-9190-d28f5a39f0bc
            • + +
            • + This is item 5fbf2e32-e1fc-4e1d-88ed-e4233cc8243e +
            • +
            • + This is item 50acb19a-0d10-47ca-8924-559d55e3ad3d +
            • +
            • + This is item 1813fb9b-89ee-46ac-a785-ff5e8192865a +
            • +
            +
          • +
          +
        10. +
        +
          +
        1. +
            + +
          • +
              +
            • + This is item 9a0da54f-4a48-48ad-a939-78025b6f09f1 +
            • +
            • + This is item 0738c3b2-1f66-4263-9d3a-78bebc620999 +
            • +
            • + This is item 1b6ab678-6bfd-4979-a7d9-b3b4ce373c12 +
            • +
            • + This is item 28e0290e-1539-4729-b962-29090cd336c7 +
            • +
            + Profile picture of user 93b9dd48-32a0-4b72-ab50-30bbb237421f + Profile picture of user 0ab849df-d64e-4c04-96d1-200ffe8e0e3e +
          • +
          • +
              +
            • + This is item 540ef5f5-527d-47eb-ba92-20d3b22735a9 +
            • +
            • + This is item fba88539-e4f0-4dae-a8f0-223422b3d877 +
            • +
            • + This is item c5c542ac-9682-4dad-bf8d-ccb6afb7d3a4 +
            • +
            • + This is item 4f277638-8823-4dfd-8973-7c2ada6c2da7 +
            • +
            • This is item 61d1655b-7e5a-4737-ae8b-82885cfbee1f
            • +
            +
              +
            1. + This is item 6122fd26-aff6-46a8-87e1-cde523fed879 +
            2. +
            3. + This is item b038b53e-8195-40d7-b842-227d181ebd0c +
            4. +
            +
              +
            • + This is item 62aa7562-8d4d-49a6-8e14-b6418d6ec476 +
            • +
            • + This is item dc2ac1e2-76e7-491d-9fb4-b7c3f6f59164 +
            • + +
            +
          • + +
          + This is link 16c86736-f23a-4a60-ba6e-64f425c3c506 +
            +
          • + Profile picture of user c941dbe5-33ba-4abe-87d2-2e614673f104 + Profile picture of user 52c8f9a3-9409-4925-836b-c41f4fc20769 +
          • +
          • + +
              +
            1. + This is item 59cd0911-069d-48b2-a83e-329a268bc49c +
            2. + +
            3. + This is item 86163d82-1dda-47fb-b2a1-9e8d883ebaa9 +
            4. +
            5. This is item c6b674ce-1187-488b-9f19-2370ae953279
            6. +
            +
          • +
          • + + This is link ff14d219-764d-401d-b616-18436166913b + + + This is link 7ba63a08-9792-4819-a522-2dded1164f46 + + +
              +
            1. This is item 46d3defe-31b6-4f53-9ba0-8a195eec5d12
            2. +
            3. This is item de391e81-482e-4acd-a8db-1ac5e322de6d
            4. +
            5. This is item 5b803026-27db-4f33-9dbd-c138a60678bd
            6. +
            7. + This is item a270e7c3-332e-41fc-b178-e1fc57066f2f +
            8. +
            +
          • +
          • +
              +
            • + This is item b595a285-9929-49fd-9dbb-1cfb72557048 +
            • +
            • This is item 7aa961a1-9509-4eff-8841-abf3868c5871
            • +
            • + This is item 7571b4c7-8101-4955-92c1-c5460c8d1d42 +
            • +
            + Profile picture of user 850afeef-3f60-436b-be2f-f7adb40a8155 +
              +
            • + This is item 44967160-a355-4f5c-a862-013a5c1ec043 +
            • +
            • + This is item 459c7c2d-704c-43d7-badb-420a9d727faa +
            • +
            • This is item b638583b-84f5-493b-841a-02fff6d873dc
            • +
            • This is item 4774b763-4ec2-4b50-a6c9-5a5fc76861d3
            • +
            • + This is item 6f8b439b-154e-473f-b6b1-cb25959a7204 +
            • +
            +
          • +
          +
        2. +
        3. +
            +
          1. +
              +
            1. + This is item 57adad31-dd90-4acb-9df1-bc15271de09e +
            2. +
            3. This is item 83a64aab-ac27-4730-b120-eb7acd076cb7
            4. +
            +
              +
            1. This is item 9c3f4a22-1e5e-46a5-bfaa-e3758ca1c817
            2. +
            3. This is item 8ed22468-5ce7-4378-8db1-d045aa32844a
            4. +
            +
          2. + +
          +
            +
          • + Profile picture of user 50abd1b0-5720-454b-846d-2d93f597e8b4 +
              +
            1. + This is item a6acb5bb-24d4-4cd5-8fa8-eb0979acaf53 +
            2. +
            3. + This is item c9a2a88b-1807-4c56-aa01-e9a1cffb82f8 +
            4. +
            5. + This is item bb738dbc-5365-48ad-88f0-0e79d3fdade2 +
            6. +
            7. + This is item aeff441d-bfe1-4bde-be2d-c0e97a958fef +
            8. +
            9. + This is item 9778adc6-93d3-41eb-be2f-76808eded2e3 +
            10. +
            +
          • +
          • +
              +
            1. + This is item 9fde286d-8df5-479b-8cac-3e3d39e8adc0 +
            2. +
            3. + This is item 1a002425-cb1b-467c-b13c-dad41e76a13b +
            4. +
            5. This is item 20445b40-1bfc-4983-8a8a-c4168d672c40
            6. +
            +
              +
            • + This is item 66fce308-7d70-424c-a0bd-35a9051f0787 +
            • +
            • + This is item 2099afc8-7095-4dd3-8ac7-51a78025c924 +
            • +
            • This is item f9f45923-823e-473e-8745-eadd0f0fa235
            • +
            • + This is item c7a658c5-f091-4077-ab46-5e654a2cc5c5 +
            • +
            +
              +
            1. This is item 17e74bcc-dc26-481c-b63a-738d7cb7543e
            2. +
            3. + This is item b20b4014-c197-4b5f-85f7-b33d5ac159da +
            4. +
            5. + This is item f46b668f-b26b-4a1b-8bc7-15e7398b28ad +
            6. +
            7. + This is item c218864b-14c0-4766-ab5a-6c8a6b148eaa +
            8. +
            9. This is item 71ad4cfa-7190-4044-acf0-a0f6b4cd3d59
            10. +
            +
          • +
          • +
              +
            • This is item ed8e9eee-d0da-4f2b-97aa-3c2b0fe29368
            • +
            • + This is item 43fb0042-c317-46a5-a11e-d1c4d5713095 +
            • +
            • This is item 0cb72b52-5d35-4ad8-a8e5-b5eb879833e0
            • +
            + + This is link 43ccaf70-1ce8-4cdd-8ae5-a44f90a11562 + + + This is link ee182cbe-e995-4a95-b39b-775d27adc934 + +
          • +
          • +
              +
            1. This is item 7d258b9a-0b2e-41cb-98dd-b7dccc916a65
            2. +
            3. + This is item f94e62f8-48d7-4f1f-aa2f-d962b3adbf65 +
            4. +
            5. + This is item 4631386f-418c-4e1c-8098-3517db276424 +
            6. +
            +
          • +
          • +
              +
            • + This is item 85f2811c-2e4b-4cd9-8584-5ee66bbaab23 +
            • +
            • + This is item 46e57b7b-0c7a-4d28-a97f-d1fd5462dbc7 +
            • +
            • + This is item b763a594-b482-435f-ad8f-e065a0887b25 +
            • +
            • + This is item 0be070a3-d0db-4577-b2c9-2f67f41a0360 +
            • +
            • This is item 263862f9-6b29-4de1-9b98-a59d5d526402
            • +
            + Profile picture of user 6c3073e0-3709-4c0a-8840-1ae980be7f0f +
          • +
          +
            +
          1. +
              +
            • + This is item 7690d7ac-78c1-46fd-92f7-783e8e9eb1ce +
            • +
            • This is item f461d4bb-d083-4f0d-90a5-b2c2920c5675
            • +
            • This is item 964abe80-81b1-4f8e-a512-a69a98219fa8
            • +
            + + This is link ca54969b-43b1-43e2-9d1e-13c2d26d9f36 + + + This is link c5dfb2b1-ec11-444a-ba3c-e42b0825a962 + +
          2. +
          3. +
              +
            • + This is item 9c2bcd60-fb29-4211-ac00-bcc3f61e3380 +
            • +
            • This is item 87682c45-0146-4454-a4ec-b582c6c42f0a
            • +
            • + This is item 928ca386-8999-424b-94fb-918c5b9f1df9 +
            • +
            • + This is item 4a760d7d-5236-4df1-9f7e-4340a5fb9baa +
            • +
            • This is item ba971414-4194-40d4-b990-4fe9b48f744e
            • +
            + +
          4. +
          +
        4. +
        5. + Profile picture of user 59d86f4b-7131-4f0d-9c1e-426bc304393f + + This is link 211bdf0c-d9a1-4aa5-bda6-5ecb5fa07a31 + +
            +
          1. +
              +
            1. + This is item 60a99cf6-0cd9-4da9-87d5-fdcac5bd1a02 +
            2. +
            3. + This is item 806191ff-25d2-4ba4-bf44-69289d2dedac +
            4. +
            5. + This is item c695b164-943e-454e-82ac-b789698b1f48 +
            6. +
            7. + This is item 031f44bb-dc23-4882-8b57-7f818011dfcc +
            8. +
            +
              +
            1. + This is item 746fea94-1531-4430-b6b7-669973358b7f +
            2. +
            3. + This is item 3b302137-1238-4e6a-a6a3-33b65dc5b085 +
            4. +
            5. + This is item 7130838b-3490-481f-b219-30b91fa60c7c +
            6. +
            7. This is item a93b413f-5bf3-4c58-81a9-4b510f6354f8
            8. +
            +
          2. +
          3. + Profile picture of user 55495569-2120-4220-a270-9d6aeb8e8b3f +
              +
            • + This is item afa407c1-9920-4c33-a754-b0994ac7ef85 +
            • +
            • + This is item af6dbcd5-7ff7-44d1-9a1a-e656d5a7b562 +
            • +
            • This is item b469991c-5627-4549-8d1a-6eba76da5058
            • +
            • + This is item 04dc958f-2440-4857-91e3-bf326585400d +
            • +
            • + This is item a6143852-63a3-4ae1-8251-c683101d28c5 +
            • +
            +
              +
            • + This is item a48003af-e217-4129-8d6f-14df30792982 +
            • +
            • + This is item 0752b1dd-d980-439d-a48b-0bfd0431d347 +
            • +
            • This is item c72d5144-f426-4dc7-a6af-dcbec9e3322e
            • +
            • This is item edd01c5c-eed0-4508-8114-6b00496d83cd
            • +
            • + This is item d3f9b48a-1212-4cc6-a5d7-20d8124000d1 +
            • +
            +
              +
            1. This is item 99181785-f5b5-4b22-9bf2-61747b910ff9
            2. +
            3. + This is item cd60a888-12a9-42c3-9e73-2cec30b03745 +
            4. +
            5. + This is item eb9693fd-d717-4c97-8108-af80862d4513 +
            6. +
            7. This is item f8aa95e3-52b9-47d4-b24f-3cf5bfcd30cc
            8. +
            +
          4. +
          + + This is link 5b259449-46db-4053-84df-3927cdf989de + +
        6. +
        7. + Profile picture of user 73de469c-642c-4fd2-bbe0-70dfc62d1fc3 +
        8. +
        +
          +
        1. +
            +
          • + Profile picture of user 9f1d3970-0f7a-4507-ad79-6f4ea41d7874 +
              +
            • This is item bf007a81-a654-413f-a8c4-d75de07e1cad
            • +
            • + This is item a6a6604d-d36c-4e41-945d-740efbab2556 +
            • +
            • + This is item 339a0f1f-ea53-4b2c-a1c0-1165f02c34d2 +
            • +
            • + This is item 95ee39d4-1d0d-4373-88e6-411f257ef2a5 +
            • +
            • + This is item a6283234-e2c9-4882-8c51-9618b3c1f489 +
            • +
            + Profile picture of user f7bde420-d706-4a96-8da2-fa20e61fcb0e + This is link 781a450a-cca0-4a64-8e7e-cc1615c59e99 +
          • +
          • + Profile picture of user 9ca6ea3b-e6ec-48ad-b846-af4e8d594eb2 +
              +
            1. + This is item 8672ac1f-6370-4cc9-9c16-9a38f2d48c3d +
            2. +
            3. + This is item 4e2904f4-fd3b-4b2f-bf3f-0d8c677472c5 +
            4. +
            5. + This is item a9a4f01e-c223-419f-9ac5-708c501a6927 +
            6. +
            +
              +
            • + This is item afc33bc3-014f-4ba7-b65d-7a2c70a08562 +
            • +
            • + This is item 4f49ab20-780c-4512-953c-dddd011fb0e3 +
            • +
            +
          • +
          • +
              +
            • This is item 1243590c-f7ef-4c4e-a60d-2ab0063f9a4a
            • +
            • This is item 34e0c9d8-e2db-451c-85e9-9b43c363fd3a
            • +
            • + This is item 0f41ca8d-97bd-4923-aa42-396584ff2a0f +
            • +
            +
          • +
          • + Profile picture of user 31cb12ad-42c8-4e0f-821a-8e3f5f5e18ea +
          • +
          + + This is link 3da86357-a16d-4e78-82c6-45a0b5450bfe + +
        2. +
        3. + + +
        4. +
        5. + Profile picture of user 68287cf5-56d9-479a-b74d-24904b012a24 +
            +
          • +
              +
            • + This is item b76bf85e-1d43-407a-8b70-c259ccc5465c +
            • + +
            +
              +
            1. + This is item ceec80f1-a2bf-431e-8568-ebf02166e82e +
            2. +
            3. This is item 2c57953e-f8ac-40d9-a138-52a6253aab61
            4. +
            5. + This is item 95cfa46c-a747-41f4-8bf9-6719a37acbb6 +
            6. +
            7. + This is item 7e97c30a-55dd-4a81-ac49-594e85c6fac0 +
            8. +
            +
          • +
          • +
              + +
            • + This is item 6997c458-8065-415d-a64a-d4e3b7aa9193 +
            • +
            • + This is item f3b40bcf-d444-45a2-b9f1-99e99507e4fc +
            • +
            +
          • +
          +
        6. +
        +
      • +
      • +
          +
        • +
            +
          • +
              +
            1. + This is item c46fc70c-ceca-4081-9047-c4854febfeef +
            2. +
            3. + This is item 94cc75cd-77a6-4495-bbaa-28985b78ad33 +
            4. +
            5. + This is item a055d012-e1a3-4d09-9bb1-cc71693c8868 +
            6. +
            + Profile picture of user faa5df5b-e51a-4d03-bca5-596052906e0d + Profile picture of user b2d85417-8fa3-4a90-8fdf-b978ddd9a178 + This is link 424dc053-1115-4d25-9108-47fd64546cbe +
          • + +
          • +
              +
            • This is item 7db1e958-0773-4136-ae37-8fe88b7cb3aa
            • +
            • + This is item 741c6dd3-3266-4a6e-8e39-fad4d86c5bd9 +
            • +
            • + This is item d46e2b37-27c2-4d44-bc9b-334468edf2f2 +
            • +
            • This is item de89c021-72f6-4a35-8ac2-3515a4863b25
            • +
            • + This is item 7456df3e-5024-4777-bd0c-a1b6f2cfebea +
            • +
            +
              +
            1. + This is item ef5fd74e-02af-44e9-8530-81061e729348 +
            2. +
            3. This is item 74e78f2d-34d0-4807-9e78-0eaec32126a8
            4. +
            5. + This is item 22b61ff2-67bd-4a57-8e25-6e5d48e261e3 +
            6. + +
            +
              + +
            • + This is item d7b64d07-a8f1-4424-9083-ff02be455b21 +
            • +
            • + This is item 3333067f-1e5e-494b-be50-6d4e3d9b7b7a +
            • +
            +
              +
            1. This is item d686685b-f9f2-4661-afed-e53990c1af1e
            2. +
            3. + This is item c82850f7-091d-4a50-bff5-cc5f7bb1360f +
            4. +
            5. This is item d3df98a9-da63-4e13-b4a6-b4e1ed96614a
            6. +
            7. + This is item 72f1888f-90b0-43a8-888b-627ef47146e0 +
            8. +
            +
          • +
          + +
        • +
        • +
            +
          • +
              +
            1. This is item 3f3fd608-d791-4001-b055-48bcdd67b5c9
            2. +
            3. + This is item 1ffd38c4-6b54-4766-b13b-f1d441fa193f +
            4. +
            5. + This is item e6bfc7db-b965-4ecf-8a36-6d6149fc68ad +
            6. +
            7. This is item adb9177e-d263-4c70-86e5-c95e6d4c4438
            8. +
            9. + This is item 8ba4e935-e5ae-4c0b-8bd0-0582a41666bf +
            10. +
            + Profile picture of user 42db5322-1960-491f-b21c-60a530601ae8 + Profile picture of user 1eda6b9c-f578-4fa5-bc14-ed2d35233173 +
          • +
          • +
              +
            1. + This is item 5aca5624-6cc8-4ec4-9c62-2bdb7e5ba8bd +
            2. +
            3. + This is item 614e3e83-bf1f-45f1-897a-056d925a1d52 +
            4. +
            5. + This is item 0d289522-fea9-4f95-9a82-254932bfc858 +
            6. +
            +
          • +
          • +
              +
            • This is item d8d71c4a-194a-4c04-a411-d05991e09aff
            • +
            • + This is item a484ba2e-0ddc-4740-9dca-faab10bc4c9c +
            • +
            • This is item 0724b22e-f099-4feb-b44c-767791e004d3
            • +
            • + This is item 1eca2abb-27d5-40d2-aa75-fd344ca52f8a +
            • +
            • + This is item 5ffabe21-54fa-44c8-9b6d-9a8c2adcd0fc +
            • +
            + Profile picture of user 1a5a7534-915f-48b8-b4bf-3f8ec6653db2 +
          • +
          + Profile picture of user 5eb039ec-b839-4286-84a5-b85542fa3dd8 + + This is link 1f016c54-a032-435f-beb5-6bb095fe89b6 + +
        • +
        +
          +
        • + + This is link 1c695a1a-b2ce-49ea-b0a1-904711e4f477 + +
            +
          • +
              +
            1. This is item ad28acce-d688-4d24-bef7-604d118cce1c
            2. +
            3. + This is item 56aec4e9-11d3-4929-8dd2-bdf1f530fa20 +
            4. +
            +
              +
            1. + This is item c5789444-d9a7-41c2-8440-985a82ff8be1 +
            2. +
            3. + This is item a961797c-3d47-4b79-bb9b-8634c4ba3579 +
            4. +
            + Profile picture of user ff21c75f-d673-4f4a-98ec-a313d45bc3f2 +
          • +
          • + Profile picture of user 7fb87f5c-6a77-4acc-80f1-0135e00d34d6 + This is link 401854ae-24ae-420f-95e5-06b3e30630f2 +
          • +
          • + Profile picture of user bd8ba873-43e6-4148-ae6c-771d336a0f2a +
          • +
          + +
        • +
        • + + This is link 503e2cf1-fa1e-43df-a593-c150c2bec61c + + + This is link 8e58e91c-8730-4a98-b228-e1059d372bdf + +
        • +
        • +
            +
          • + Profile picture of user 595508c3-92fe-459c-bb5f-6ab5f248be7f +
          • +
          • + Profile picture of user 127a6fe6-96b6-4516-b1be-cd86ca826227 + + This is link 8b30cdf8-ab95-4a4d-975d-ef6aebf581ba + +
          • +
          • + Profile picture of user bae6f06b-2598-479b-9ac5-34554eaf9d41 +
          • +
          • +
              + +
            • + This is item 1a71dad3-22af-49b2-8376-6afd5e55c395 +
            • +
            • + This is item 8cc2d708-27b4-4aa0-8c41-f17da9cf5d9c +
            • +
            + + This is link 7dbe8cbd-c162-4746-910b-e067bccb867d + +
              +
            1. + This is item 64b1c173-9f04-41c7-bf97-229dc3a0f127 +
            2. +
            3. + This is item 36c8f892-5b14-4061-9f3e-7250ad18d71e +
            4. +
            +
              +
            • + This is item b7ff145b-ab54-4ef6-8591-c847a0406543 +
            • +
            • + This is item 178a08b3-5a27-43b4-ab17-0355eddd103c +
            • +
            • This is item c6d31216-a22b-4282-aa63-031eae09b2af
            • +
            • This is item f9aee801-4d33-4e28-a337-0bc2c09775d5
            • +
            +
          • +
          • +
              + +
            1. This is item 6fdcc501-be3d-4921-a1d1-263e72e65bd4
            2. +
            3. + This is item 45eb42ee-18e6-4710-b701-ec2b5d7f3430 +
            4. +
            + + This is link 786255c9-3c07-4d4a-84d7-78f1826b844b + +
              +
            1. This is item 739d0386-dadf-47d6-82b0-bd2ba8e3f953
            2. + +
            3. + This is item bc987077-01f6-41e4-a4f8-73b47af98041 +
            4. +
            +
              +
            1. This is item aabe698b-3ca8-44e1-b26e-19e2680954f9
            2. +
            3. + This is item 12f448ab-41a8-4609-80bd-4063fdbfff41 +
            4. +
            +
          • +
          +
            +
          1. + + This is link 317251d9-dbda-406a-9d0b-d8c0e974328d + +
              +
            1. This is item 781f07a6-2887-4004-b1f1-f8d5445ccc46
            2. +
            3. + This is item 99d4d45f-480f-4886-99bf-4a53870e80b0 +
            4. +
            5. + This is item 3c23c152-4410-404e-ba88-2bfd3fe2dbf7 +
            6. +
            7. + This is item 674d3734-99c3-4f2f-99bd-c35c07df396a +
            8. +
            +
          2. +
          3. + Profile picture of user 7a4f4ca5-acff-4321-bd48-f94412c43907 +
          4. +
          5. +
              +
            • This is item 3a0260c3-aaef-40d2-9af5-d80cd6249465
            • +
            • + This is item 1aa2b464-bcff-485a-82a5-2975a15bbc0e +
            • +
            + + Profile picture of user 8c956d01-a5fc-4068-b556-5072d7770d64 +
          6. +
          7. +
              +
            • + This is item 6bcc25a2-9a42-46a1-b6a0-4d7d7cfec0f9 +
            • +
            • This is item d0cde650-40f2-47b2-9188-e97389535d7f
            • +
            + Profile picture of user 7be423ff-49d1-4381-b52d-b8e1d1fc49d2 +
          8. +
          9. +
              +
            1. + This is item 5d3f0359-0bf5-42c2-bc49-987b65ceaf89 +
            2. +
            3. + This is item 765791c8-75b4-4eb0-ade3-77cde5fac867 +
            4. +
            5. + This is item a31c4b7b-3ce9-467a-abd9-16682630dde3 +
            6. +
            + Profile picture of user 13838112-b81d-4f8d-840e-03ea4ed6adfb + + This is link d406fa5d-2c1a-481b-990c-7e432caade31 + +
          10. +
          + + This is link 08cecac2-1477-416e-82cb-66d19cf9deda + + Profile picture of user e928df5f-58b8-424a-b490-b34bfc9ed77c +
        • +
        • + + This is link 92bb50f6-e30a-4f38-885f-c8ea1592ed92 + +
            +
          • +
              +
            • This is item d32fef0e-07d1-4de1-96e6-c948e828f3ab
            • +
            • + This is item 25aa3dbb-8466-458d-a8f0-b8dde39bf3c2 +
            • +
            • + This is item 2dda561d-9e51-4b79-8503-52bda6b775dd +
            • +
            + Profile picture of user b7d6dcb6-577e-47a0-981b-3268ff632a96 +
          • +
          • +
              +
            • + This is item e17358de-1266-4515-bddb-24d3a45e991e +
            • +
            • + This is item 9ba48ec4-9f52-4229-badb-ba2c133ea2b2 +
            • +
            • + This is item 13235afa-07b6-41a9-b3f5-c92fb1781b6d +
            • +
            • This is item ccb505b1-3547-4d4e-8631-4b745981d9eb
            • +
            + Profile picture of user 8b352cb5-94d4-4cd5-8743-be4550847545 +
          • +
          +
            +
          • +
              +
            • + This is item bec5e033-3260-46ca-b1d3-25e6ea8fb46e +
            • +
            • + This is item 2e364fe5-d727-45b3-8f7a-56e3760f2a7d +
            • +
            • + This is item 785d3483-7b0e-4bf0-bc3f-00a76e860cfd +
            • +
            • + This is item acabf5af-db2c-45e9-9f80-b2b7548e0b52 +
            • +
            • This is item 7d0d447f-1c4b-49fa-8ce2-93c34974cc94
            • +
            +
          • +
          • + Profile picture of user 020efd51-c33e-40fd-ac1f-3c4444caa07a + Profile picture of user 1c56e07b-fe55-48d5-93bc-8be7096066bd +
              +
            • + This is item 4a2a55ca-2420-4375-9485-c508925e2948 +
            • +
            • + This is item 2ac1e9c4-479d-4ff8-89aa-48e836ca0d90 +
            • +
            + + This is link 2980a77f-d5f3-442a-941b-54db439683c3 + +
          • +
          • + Profile picture of user 4d599621-1669-4258-a343-87f2c2c71b0c +
          • +
          • +
              +
            1. This is item 824ddf4b-65b2-45e7-9197-179f033e6168
            2. +
            3. + This is item e792a5dc-82e3-4bb7-a897-1262e2634fec +
            4. +
            5. + This is item aac8030a-e4bc-4263-ab5b-a364dd69a6d4 +
            6. +
            7. This is item 922779fa-700b-40a5-bf61-2507473d38cf
            8. +
            +
              +
            1. + This is item 07b07af8-ee98-47a3-8c9b-741f8cf29473 +
            2. +
            3. + This is item c6243362-2098-4ac0-bf94-fccdd0eb2c03 +
            4. +
            5. + This is item 846ef751-bed4-446c-94cb-8e7cc52c2b65 +
            6. +
            + Profile picture of user 93a1b8d7-e175-4318-a4c2-3748b5ead6e5 +
          • +
          • + Profile picture of user ba860a9b-c6a5-48dc-993f-54e949b9a1a1 +
              + +
            • + This is item bbf58a84-9d3f-4218-80ae-fc29e2d0dcb5 +
            • +
            • This is item 82e9fa45-f8b4-4869-80e1-7a53385d472c
            • +
            +
              +
            • This is item 0cc45abe-f559-43dc-9d7e-9e43138b69ee
            • +
            • + This is item 6db0e361-8812-496d-b99f-bde3ce42cfec +
            • +
            • + This is item 2b1d683a-360b-4dd2-86d7-0fa78a43bcb2 +
            • +
            • + This is item 2a04adb3-4022-4485-ac71-8c7422ad0382 +
            • +
            +
          • +
          +
        • +
        • + Profile picture of user 32fe9e49-f339-4467-8643-9cc4f5410e1f +
        • +
        +
      • +
      • +
          +
        • + Profile picture of user 86ab823e-c61f-4e75-8a40-ece480473180 + + + This is link ac3d52de-8356-497d-8e51-11288d01bdda + +
        • +
        • + + Profile picture of user 1825bbc3-0117-4892-8731-14eee7e93089 +
        • +
        • +
            +
          1. + + This is link 4ed326c8-93d3-49ff-8e14-cc509a6681ed + + Profile picture of user 11670888-b5cd-4c32-a716-84cd625d2bc1 +
          2. +
          3. + + This is link 76771dd0-1d27-467f-8af6-8985669507d9 + + Profile picture of user e8a14051-ed8c-47bf-bacd-b86cbd9d8846 + Profile picture of user c667d5f4-eb0f-4eb3-ad1f-06f86cc9e450 +
          4. +
          5. + + This is link 08811a03-7693-42a5-b2bc-e1133980fe8f + + + This is link 2024f756-5c5d-4276-b57e-4f7d0cb010b4 + +
          6. +
          + Profile picture of user 255f1a0e-c4e7-4d0e-9bf9-108369ec7b32 +
            +
          • +
              +
            • + This is item 8df39723-f9e6-481f-a7e4-060597afc00a +
            • +
            • This is item 74b6a166-cd40-4a1d-912e-6a250f2f025b
            • +
            • This is item b4f25f21-2969-458e-91e0-5ce64b77985d
            • +
            + +
              +
            1. + This is item 01d6eb4f-cb24-4967-a2f6-a1eb828f4bc7 +
            2. +
            3. + This is item ed00abee-a4c8-4045-a8c0-727340ecc922 +
            4. +
            5. This is item 534c4178-dc62-4dc1-9dec-4ba1bfb8012f
            6. +
            7. This is item e7be045d-ff14-47bd-9155-efbd8c26c2a3
            8. +
            +
              +
            1. + This is item ed061e8b-0487-4875-8367-cf4bdbcbf2dd +
            2. +
            3. + This is item f920d4f5-10d1-4c74-a264-5932983769dd +
            4. +
            5. This is item bf71052c-2ab5-4452-aee7-98a4eb362b2b
            6. +
            7. + This is item 4869cc74-0566-4c47-a150-56cbe03c6651 +
            8. +
            9. This is item 49fe7f09-8ff4-4ba6-a415-bc39da29389a
            10. +
            +
          • +
          • +
              +
            1. This is item db4ad3aa-2a57-44a1-94ca-1ed6aa578e7b
            2. +
            3. + This is item 24af17c2-b7c6-4a31-a80c-8db21ec133af +
            4. +
            5. + This is item d63fa296-6a7c-4733-b662-28b5d63af0a8 +
            6. +
            +
              +
            • + This is item 0d51b9b0-6ba1-4c86-8f45-43b0fb38d223 +
            • +
            • + This is item 846113b5-db89-415d-a645-feb1e5d2b983 +
            • +
            • + This is item 1da01374-4d2e-413f-8aac-bf881338898c +
            • +
            • + This is item e0dc9b6c-8919-4cf3-bd45-040ec3e331d2 +
            • +
            • This is item 2c7adba9-32bd-4d18-bb83-2a891aff0b24
            • +
            + + This is link 166ebb7a-b0d9-499e-a8ad-daccb26eef1b + +
          • +
          + This is link 29a2fb31-aa5e-4454-bce4-3760b8d69fc8 +
        • +
        • +
            +
          • +
              +
            • This is item 1e360179-d34a-41b7-aaf4-3bcd236b40ae
            • +
            • + This is item 73b3ad62-1ae5-458a-8928-0ac7a13cbbd8 +
            • +
            • This is item 4f158cad-c67f-4f5b-9485-686ed84ec544
            • +
            • + This is item 3fd7ec26-9e57-41f9-a43b-117b2ac79206 +
            • +
            + Profile picture of user eeec003f-25fa-490c-886b-f43300f8955f +
          • +
          • + + This is link d9f30590-3c9a-49f9-9ada-90bef5df6a17 + + + This is link 15fadd34-8940-41b8-ba7d-357b82df6283 + +
              + +
            • + This is item efc9f7a5-bb34-48f0-9c4b-133f75665dd4 +
            • +
            • + This is item 05e42186-73d7-4f71-bc07-8366e33620b1 +
            • +
            +
              +
            1. + This is item c4cc4b54-5597-4628-8543-c9e21f500db1 +
            2. +
            3. + This is item c00e358e-6b9f-4c68-8d3d-c4601a674384 +
            4. +
            +
          • +
          • +
              +
            • + This is item a4c89850-1208-4734-b4ac-01bcb3488872 +
            • +
            • This is item 3c5e3a71-cb6d-43ce-8306-13b7f7d80aa0
            • +
            • + This is item da2cd611-5e83-41b2-b1c8-a88a4e3bdfaf +
            • +
            + + This is link 3011783f-29c2-4124-a7c0-df134ecd294f + +
          • +
          + + This is link 8d025940-3728-4603-9602-6a00dc543d39 + +
            +
          • + Profile picture of user f5f20fad-9607-49c5-9b31-fc851f4de296 +
              +
            • This is item 97ef9df3-2085-4b9c-b82d-02772d741fb8
            • +
            • + This is item 49a8b97d-cbc8-4f3c-aaf2-47cadec89411 +
            • +
            • + This is item 8330bd16-51f5-4345-93c3-6fe3268a8fb4 +
            • +
            +
              +
            • + This is item 876f1735-f8b2-462e-a34f-3d445e032443 +
            • +
            • + This is item c080cded-e57b-4a53-bf62-08e087c18c6e +
            • +
            • This is item 37d1250c-d792-452f-a011-b251916164e7
            • +
            • + This is item 694fe092-2d9c-4744-8f40-ec7115f1df99 +
            • +
            +
          • +
          • +
              +
            1. + This is item 4de00f8d-810d-4a98-8ef5-c56f7eae1e87 +
            2. +
            3. + This is item afb90421-0dae-4f1e-8cd1-38985c656529 +
            4. +
            5. + This is item f212e3d1-431d-43a1-ac13-fd7f95a21380 +
            6. +
            + Profile picture of user 7d8374b0-6c74-452c-8ba0-7065bd2a516c +
              + +
            • + This is item 261d2dd7-38a6-4cb2-b7e3-ca8522093cf1 +
            • +
            • + This is item 5ac97726-6c77-4681-ba91-72ab4fd1a435 +
            • +
            +
          • +
          • + + This is link fdf4fd88-3f82-476f-8369-cffe3acff91f + +
          • + +
          • +
              +
            1. + This is item 0200bdff-4061-4e04-8d85-84d137ed92b5 +
            2. +
            3. + This is item ede58ba6-520a-47d5-a3e1-62eec30b043a +
            4. +
            5. + This is item c94a9363-fcb1-4cf3-bdee-58ac6a3b9056 +
            6. +
            +
              +
            1. + This is item 9a4715d2-13a9-4cf7-87fc-c982b496c0c7 +
            2. +
            3. + This is item daed189a-6c58-4235-85cc-a144f26cf68f +
            4. +
            5. + This is item 62e84d78-2696-4e08-9e38-51260171aeaa +
            6. +
            +
              +
            • This is item d2b3e285-35d7-4b8d-ba68-5533b588a813
            • +
            • + This is item aa5532b2-a232-40d6-8eb9-979d2e8367c2 +
            • +
            • + This is item 14a4b767-843f-4785-9799-6082d09994de +
            • +
            • This is item 8160528c-1ad2-4713-a7c9-f8f7c291955b
            • +
            • + This is item 3528ed6d-2e83-4570-b7e7-d728bcb51fa9 +
            • +
            +
          • +
          +
            +
          1. +
              +
            1. + This is item d132fb9f-fffe-4447-8821-cf1c2dbf49f6 +
            2. +
            3. + This is item 776406dc-9e0d-45ba-a9b8-7a857a1f3b76 +
            4. +
            5. This is item 92eb4912-a545-4ccb-8155-fc11294b9bd6
            6. +
            7. + This is item ba20a27c-a520-4436-be5f-bd69a5e2d06a +
            8. +
            9. This is item c25c8bab-afa4-40b4-8ff5-3dac1ffbd6c6
            10. +
            +
          2. +
          3. +
              +
            • + This is item dafcb840-33c5-41a1-97b4-7e8c30bccd18 +
            • +
            • + This is item 970bd239-2f33-4160-ab47-d7dd52c5fa13 +
            • +
            • This is item 80a18d5e-80a4-48f8-9395-8e6e8ff914c9
            • +
            • This is item 88349831-8ddf-4c31-b8b1-fc49b42ab080
            • +
            • This is item 52fea8dd-a6c4-450c-8ea7-e6abe5a7fc89
            • +
            +
              +
            1. This is item e2f3aa3b-a971-477d-9987-d557c01127e1
            2. +
            3. + This is item 2ff4da91-85f7-4bac-92bd-15d704ae0849 +
            4. +
            + + This is link fd4021d7-e85e-442a-aab6-27b6f21abbf4 + + Profile picture of user 0314c84c-818f-4f22-a2fe-bd4da99249e9 +
          4. +
          5. +
              +
            • + This is item 4016c028-c5f2-453e-b79c-1967f6caac73 +
            • +
            • + This is item 8cb0dd8f-7448-4c96-b602-6b7647926bfb +
            • +
            • + This is item 1baa244f-8715-4dac-a63d-c7e45610d7bd +
            • +
            • + This is item d5269819-6ec3-4069-a1e8-7d3636b45e3b +
            • +
            • + This is item d3153834-e7ee-4846-83eb-4762847dea18 +
            • +
            +
          6. +
          +
        • +
        + + + This is link d98658c5-d285-42ce-86ee-2fdc243b0405 + +
          +
        1. + Profile picture of user 9b634ecd-f317-4568-bc24-80d02a7c4db4 + Profile picture of user 27de8125-94bb-47e8-81c0-76c0aa412713 +
            +
          1. +
              +
            1. + This is item 31fd67ff-41b8-46bb-a0c8-56a96d59c10d +
            2. + +
            3. + This is item d4254c51-a7d4-4307-9909-c9d25d4edd66 +
            4. +
            5. + This is item a15deaa5-1774-4d75-91e9-aa45342f0b74 +
            6. +
            + Profile picture of user dad780be-2cc8-4798-8f3d-412237563e69 + Profile picture of user c9fa02d6-8927-49d4-9d02-bcdc7ee1ae7b + Profile picture of user 9ae48787-c006-40ea-952e-2c1372826df0 +
          2. +
          3. +
              +
            1. This is item 88608bd6-7d89-4826-8484-1e9f2c7175eb
            2. +
            3. + This is item a36f167c-f49e-4c00-85ed-f205c0884067 +
            4. +
            5. + This is item d2234dcb-fc30-455e-9361-96a76863db91 +
            6. +
            7. This is item 57fdfb3c-38ce-477e-b5d7-0bc4265c1d06
            8. +
            + Profile picture of user e9a996fe-0fc1-4beb-a7de-8be9dc04f863 +
              +
            1. + This is item b2a06076-f9d8-4368-906e-7bb48181ec88 +
            2. + +
            3. This is item 39da06e2-2a36-4edf-acce-7d9bfde29045
            4. +
            5. + This is item 1315d21e-5b56-4ff8-908f-3d89c77fe77e +
            6. +
            +
              +
            • This is item cb6ddf5b-d44d-4354-aa67-911c8845d55d
            • +
            • + This is item 1ed2bbf0-c223-43fc-b2ec-2f41a39bc52e +
            • +
            • This is item 2377627b-e939-4ff4-92d9-268a5728aa9a
            • +
            • + This is item b5e67a67-b613-4325-836c-313e4fb18596 +
            • +
            +
          4. +
          5. + Profile picture of user a8a5d85d-0747-4748-88a8-6bceb4c9921d + Profile picture of user 5d72903c-0db6-4bad-932f-128cbd5277fd + This is link e74fa8c5-cfb3-474e-a8dd-f6ea92ba02e1 +
              +
            • + This is item 92bee578-266c-4fd1-95ca-3fb1c0dc8f96 +
            • +
            • + This is item c2fdb768-6e36-4f1e-8f59-627995fb245a +
            • +
            +
          6. +
          +
            +
          1. + + This is link 273f105e-23da-4069-8eed-12fe8fbeb537 + +
              +
            • + This is item 579efe57-079f-49e5-a44f-144968a78914 +
            • +
            • + This is item fe44a9e8-30f3-4e0a-86e6-d6e2b8a61cd9 +
            • +
            + + This is link a70668bd-3cfa-45a6-9d8c-57e8ed161561 + +
          2. +
          3. +
              +
            1. This is item d771a7ec-cd2e-4fe3-b60d-4e113e0082b6
            2. +
            3. This is item 125a4cb2-3d9e-4387-a7de-24f5e1bd3026
            4. +
            5. This is item 006076fe-68d2-482a-9c81-26935b7683fd
            6. +
            7. + This is item 927e812f-3048-40e5-9c4d-597ce09bf3a7 +
            8. +
            9. This is item ece42509-e0a1-4db6-a3a9-581ea1993f4c
            10. +
            +
              +
            • + This is item 14293408-6796-43dd-8bf4-859fab77695c +
            • +
            • + This is item bf792ad7-a821-468b-95db-a2261454f362 +
            • +
            • + This is item 33376620-ea83-4f93-aa47-9f1614950a2d +
            • +
            • + This is item 02b83ba1-b152-4352-9dc5-f9cfacb3610f +
            • +
            • + This is item c1aa3a5e-0b9d-4fb8-a574-183354aa9ab7 +
            • +
            +
              +
            1. + This is item 7245ed93-bd60-430c-ab3e-c1128b583b29 +
            2. +
            3. This is item c47c5a00-802a-4cf4-b4fa-7db4da305326
            4. +
            5. + This is item 4fc1c6f7-d594-4e2f-bd5d-ab918b5d6f5b +
            6. +
            7. + This is item 285026ce-ff0a-4c71-881b-718dcc596fce +
            8. +
            +
          4. +
          5. +
              +
            • This is item debd3c5c-020c-403b-aa81-6b46331f8c59
            • +
            • + This is item c0d605fc-2eb4-4fb2-859d-3505e0f35ae3 +
            • +
            + + This is link 434dfad3-44f0-434b-bfdc-cba93e97a7e7 + +
              +
            • This is item 35db3caf-bf9f-492a-be14-d8479fc6806f
            • +
            • + This is item c4693afd-e170-413d-8b68-a5cf55498d24 +
            • +
            • + This is item 8266831d-aa5b-4962-8c02-6045532cbde7 +
            • +
            • + This is item 172487dd-cc1c-487e-b8cc-a6eab8e06cb4 +
            • +
            +
          6. +
          7. + Profile picture of user ef825843-3de2-4d21-83dc-052ee13f6f87 + + This is link 5d4793c2-80ab-476e-9ede-a23a27a9d2f1 + +
          8. +
          9. +
              +
            1. This is item cb66331c-574f-4760-9136-e76c2d7232cc
            2. +
            3. This is item d303e596-86b7-4fb5-8b44-aa468e824f48
            4. +
            5. + This is item 0cb06971-975e-4f4c-8cc5-5d4baa8b2125 +
            6. +
            7. This is item b5d1e090-5f2e-4cda-9b9e-70087df41771
            8. +
            9. + This is item 38c77f51-521b-4a42-9c6a-c91c34a65dcb +
            10. +
            + + This is link 5362cfb7-32a4-4cb6-90dd-a4cbe5c265b6 + +
              +
            • This is item 5c890115-3491-437f-a12f-71b3446be07e
            • +
            • + This is item 54b899cf-b046-4756-90e6-80029f895d19 +
            • +
            +
          10. +
          +
        2. +
        3. +
            +
          • + Profile picture of user 670f7cde-403b-4d6a-a82b-dffa9782d034 +
              +
            1. This is item 42bc83b8-0df6-4ddd-b684-1a1f495b558a
            2. +
            3. + This is item 908df639-fa67-4b1c-a63a-e781201cdd57 +
            4. +
            +
          • +
          • + Profile picture of user bbaf8939-dbd0-4573-9d37-1443fd485f33 +
          • +
          + + This is link f0ce1d12-eb71-4d60-b0df-20ade513490c + + + This is link 5fcac56e-4cf1-4380-a1ad-2c63c9d29656 + +
        4. +
        +
      • +
      • + Profile picture of user 4d5bae14-8137-4fb7-bd66-ac6707261f9c +
          +
        • +
            +
          1. + + This is link 1aa92c57-6ece-40fc-9075-c3dd7dccf35f + + + This is link 3adb4e0a-a215-4e20-a2a3-6b4b0be58ac2 + + Profile picture of user 2fc3f4ae-a0f5-472a-9d07-492e8003c3fe +
          2. +
          3. +
              +
            1. + This is item 4c71d9ed-3228-4721-ad51-06277033eb46 +
            2. +
            3. + This is item da0116b5-4d5c-4dbd-8930-f7de0aee9b4f +
            4. +
            5. + This is item 737baf7a-661a-4050-8baa-c63759fb5295 +
            6. + +
            + + This is link c2e3fc57-8baf-4a95-88ad-020e3c2a87f3 + + + This is link 40a1d32c-13ad-4c4b-bfb7-03ebe264496f + +
          4. +
          5. + Profile picture of user 1b36ee1d-1a6e-4c23-bd00-2493961b2e18 + + This is link 4938c511-66f2-4511-b2ae-3c6547af28eb + + Profile picture of user 3f1ffb8e-91d6-466a-8a65-f7f2562dd21e + + This is link 23534fb7-705e-4a7e-8428-b608f5fc4be1 + +
          6. +
          +
            +
          1. +
              +
            • This is item 26bb084a-1f21-4841-830e-f7df1c7e3208
            • +
            • This is item bd55b788-2a31-45b4-85bf-81fca3510bb4
            • +
            • This is item ccf3fc07-bafa-4fd1-8cf4-7d7739372661
            • +
            +
          2. +
          3. +
              +
            • + This is item 8452aea4-45c0-434f-a10f-02fc5f73548f +
            • +
            • + This is item 0cdd5942-ea05-46a8-b917-096be0c76012 +
            • +
            • + This is item 249bcdc8-5ee3-40bd-b302-c8cb7efc8160 +
            • +
            +
          4. +
          5. + + This is link a206d37c-59be-49e3-8983-e2dd16d27ade + +
          6. +
          7. +
              +
            • This is item db9722dd-13fa-4c7f-bd71-1fb4985b36de
            • +
            • + This is item dced7782-5584-446e-a85b-a5d2984cc75f +
            • +
            +
              +
            • + This is item ceb8d458-e94e-4c6c-b00d-f4f52b04d1d8 +
            • +
            • This is item fae93bca-09ae-42a7-80cd-f99a7f3d3bce
            • +
            • + This is item 3e32bb80-4e41-46f8-a7cb-19d494229947 +
            • +
            • + This is item 3de0bae7-a61b-42ab-a58e-453d3d04d409 +
            • +
            • + This is item a3b5eeea-ed5b-4010-a6b7-9a044901e8d6 +
            • +
            + This is link 4bced6ac-3c36-4489-afd5-b0b0b8d9ae83 +
          8. +
          +
            +
          1. +
              +
            • + This is item c1bc5db9-e2f9-4cb3-b4c6-f4e53818189c +
            • +
            • This is item be16b145-ea4f-49ee-b10e-33d4d76e6848
            • +
            • This is item 62dec902-9690-4739-9b48-28c74c703fe6
            • +
            • This is item 67d9e660-9283-4a9a-a7a0-fd2d343687fa
            • +
            +
              +
            • + This is item ad2319fa-c91a-4ac2-a138-e79280d9b3c1 +
            • +
            • + This is item 6a38ac33-5643-4322-abf7-1f5610e9feba +
            • +
            + Profile picture of user 74a1c52e-055b-46fc-9efa-700aa88b489b +
              +
            • + This is item c32ebc0a-42a0-4c5d-80c9-fc9e4e4e4a22 +
            • +
            • + This is item f215a99d-8124-4ce1-91ec-b0fcea021b6e +
            • +
            +
          2. +
          3. +
              +
            1. + This is item c7fb594e-b8e2-44cc-bdf9-a4e4e792dd8d +
            2. +
            3. + This is item b951d767-4392-4cf7-bd35-ef8cab311e06 +
            4. +
            5. + This is item 703aef20-90c2-4526-8da5-73cf4963ee73 +
            6. +
            7. + This is item d5b61145-0da8-4a0a-b5bf-94f625df79b0 +
            8. +
            + Profile picture of user 75f0816c-7b7a-4948-b28e-c45761f1f973 +
          4. +
          5. + + This is link 7d97b0a6-3c4c-4649-a36a-2c6110d0a751 + +
              +
            1. + This is item fb50b380-1ee5-4537-aa22-3e7e850e9bf7 +
            2. +
            3. This is item 66f3c613-631a-495e-a52e-a34e17f5c739
            4. +
            5. + This is item 3dfd20c0-7a6f-4769-bfce-ad9f6624491f +
            6. +
            +
              +
            • This is item f625dc23-e54d-4de9-8a14-dee04565e3b1
            • +
            • + This is item 17a8bc3d-e51f-4e90-9be3-f5f1e5b32a7b +
            • +
            • + This is item 52002c80-99d1-4673-9caf-2d9c5880d267 +
            • +
            +
          6. +
          +
        • +
        • +
            +
          1. + + This is link 58152948-90e7-48ec-96e1-6b05f3d23313 + +
          2. +
          3. + + This is link a84cdc58-37a2-42a4-bae5-5f6e9ca4a3f4 + + + This is link 80326973-81b5-4462-8d8d-e3afe004d74e + +
          4. +
          +
        • +
        • + Profile picture of user 9cbef80d-c81d-4ee4-8a75-d198ded7cdd1 +
            +
          1. +
              +
            • + This is item e2dda6d2-57ae-4c94-b701-11ffb7127aef +
            • +
            • This is item 3a6ddd31-e46d-49d9-95ef-080e44ebccec
            • +
            • This is item 4401f3c6-2efc-4eed-bfbe-fbb4bb6feb8a
            • +
            • + This is item ada777b0-9f9f-4e8a-87f2-b4d31ca848f2 +
            • +
            • + This is item 742f1d4b-b89f-4a98-b46a-8fe581073318 +
            • +
            + + This is link 7d29e76d-c66e-4ec2-8429-1d9533ddbe01 + +
          2. +
          3. + + This is link 6f0d56bb-d41d-444d-9a8b-cdadc1a00f97 + + + This is link e943dd0f-ec70-4cca-98aa-a26075ebffd4 + + + This is link 442d3f24-2ea6-4c7c-9487-b0d65aa57bee + +
          4. +
          5. + + This is link 68d135e2-211c-4fee-b328-eca96d8b781a + +
          6. +
          7. + Profile picture of user 31a88661-31df-458b-9d5c-81038c9d6330 + + This is link 83caf83d-8f36-4757-b6ca-f1ae89ebc283 + +
          8. + +
          + + This is link b67143ef-7f93-4a3c-a766-1743747f5485 + +
        • +
        • +
            +
          1. +
              +
            1. + This is item 09a80ad2-e839-401d-ac45-63aded6f892c +
            2. +
            3. This is item b3705a26-8ab2-4748-8016-13fd3cf95606
            4. +
            5. + This is item 2c082440-e52d-43ea-bdd7-d6c752207f38 +
            6. +
            + +
          2. +
          3. + + This is link 10218dcc-0007-47e9-8f90-e316d81aa2ed + + + This is link 17be3f1b-71eb-4013-a563-75694a76b648 + + Profile picture of user 1a2c40cf-994a-4b51-ba92-972348c26855 +
              +
            • + This is item c50420bf-144a-4930-9c53-0e45458a763a +
            • +
            • This is item a8a60971-007e-468d-815e-c0ed3fd4036b
            • +
            • + This is item 211f3116-f325-4618-8cb7-99228b2b8fb9 +
            • +
            • This is item 088d3537-614e-495e-abb1-f9ae6438ba6a
            • +
            +
          4. +
          5. + Profile picture of user 8eaf08e5-3e2d-41c6-b244-2ed8a2ff5d84 +
              +
            1. + This is item abd6f999-8d83-4e62-b681-a263265d8fc4 +
            2. +
            3. + This is item 470832c4-9711-4ad5-bdc6-398ae955e5eb +
            4. +
            5. + This is item f88b84ca-be4b-4531-b7bd-8d03ec668e19 +
            6. +
            7. + This is item 5e5d9b59-28fa-4728-9586-d3468b34cc53 +
            8. +
            9. + This is item edabd2f6-e9e5-4bc0-b25c-983b5bcef958 +
            10. +
            +
          6. +
          +
            +
          1. +
              +
            1. + This is item 9a24fbe1-3b27-4fc8-ae3d-80451854ffce +
            2. +
            3. This is item 285b433c-0181-44e3-b395-88256c617ba9
            4. +
            5. This is item cded11c6-05a8-4d0c-beb9-ecafd931da0d
            6. +
            7. + This is item 49fe1aeb-b17a-4176-aa8b-38dc4063b85f +
            8. +
            9. + This is item e7302180-50ad-491a-94e0-37d019a7b64b +
            10. +
            +
              +
            1. + This is item b0bf90e5-c44a-40f6-95ef-a411d3eb346f +
            2. +
            3. + This is item 82c1bc29-2961-483d-8283-63c7bbaae235 +
            4. +
            + + This is link f43815dc-3e93-4760-b8b2-cf17f38a5f3f + +
              +
            1. This is item 91c4fa1d-95d4-4ade-82cf-9574f230e92a
            2. +
            3. + This is item 2a627abe-763b-46a2-bcd1-7ca7603098fc +
            4. +
            +
          2. +
          3. +
              + +
            1. + This is item 3d23a966-ef2f-4eff-b73e-cd2b0d3cfa79 +
            2. +
            3. + This is item 7ef7e3d2-f1c1-4126-b787-42c12151176f +
            4. +
            5. + This is item 4b4edd60-33c3-4642-b88a-251a9912d090 +
            6. +
            + Profile picture of user 4b03ca05-ce79-41ae-9999-5bd781247c42 +
          4. +
          5. +
              +
            • This is item 9e341230-4582-437f-9cce-0adb06ecb936
            • +
            • This is item 99708a4a-3c20-4175-b6b8-af2c8cdae427
            • +
            + + This is link 465bfc1f-f82d-4c40-81d2-ec9a7c96b26d + + Profile picture of user 0b03589b-d988-4411-8a1c-df700c18f24d +
          6. +
          7. +
              +
            • + This is item d7307f63-757a-4d17-b3f0-21a7df5d00e3 +
            • +
            • This is item 2721feec-e251-499c-aad0-7a276fa9cf69
            • +
            +
          8. +
          9. + + + This is link 5d7951b4-8742-49cb-80c8-d95ed2b529e5 + + Profile picture of user 3b1eabe3-5306-4702-a69a-ba8c05bc1030 + Profile picture of user 9bc66520-5591-4517-96d5-e55b7b4ace95 +
          10. +
          +
            +
          • +
              +
            1. + This is item c20f8d69-0ba1-4f3e-81bb-73c0fcda1d2b +
            2. +
            3. + This is item f21ecc30-6a36-4b54-8c89-5a97a0975530 +
            4. +
            5. This is item 41e24ed2-fbd2-4120-8134-7cde4a41ce3f
            6. +
            +
              +
            • This is item a879bed7-69ab-42d5-90aa-ae6d91ec97f3
            • +
            • + This is item 08c3e5fa-cfc0-4f2f-b782-2cbf7e71bfa3 +
            • +
            • + This is item 713dfdc0-decf-4171-949b-2fd06c5c7f90 +
            • +
            +
          • +
          • + + This is link 6a48160b-d704-4813-9e10-07af5eabf458 + + + This is link a67bfa12-6022-4d7f-9901-c14b5f92e528 + +
              +
            • This is item 41534f01-a0b9-4966-b9a1-b28555ba67fe
            • +
            • + This is item c9851fb2-7b2c-41c0-8967-6b0e0855143c +
            • +
            +
              +
            1. This is item 4cacaada-54f8-468c-898d-3257ec1825c2
            2. +
            3. + This is item 922d48e9-4693-44d2-9980-369e1a54ea61 +
            4. +
            +
          • +
          • + Profile picture of user 8a9ead07-a87a-4745-8f3c-d7f7c535a5d5 +
              +
            1. This is item 61efe723-9ae4-4e23-b59d-6102d57c310c
            2. +
            3. This is item 1a70b572-0c8e-43d3-937c-0af292310677
            4. +
            5. + This is item ac23c04b-a0df-4daa-bb42-5cdd37cc002a +
            6. +
            7. + This is item a246ab46-e258-416e-b94e-3e2d4686e8dd +
            8. +
            + + This is link a1ede39e-b189-448e-a433-1be16496a351 + +
          • +
          +
            +
          • +
              +
            1. + This is item 3028f6cc-ac40-4e45-a061-a3d3afba51ee +
            2. +
            3. + This is item d7a5ae20-6501-41e0-9825-4cec8dfa9ffc +
            4. +
            5. + This is item cd6bb095-a061-49ea-9998-76c9a1d53df5 +
            6. +
            7. This is item f3e18e7f-0f18-461a-a2ed-9212269633ff
            8. +
            9. + This is item 42aef5b2-189d-4759-9d01-8076bc7fe99c +
            10. +
            +
              +
            • This is item e8614322-7471-4af5-b127-0e26404f30eb
            • +
            • + This is item f13004e2-1d28-4650-95b3-318328845763 +
            • +
            • This is item fdf2a049-76be-4c1e-b074-5f5cb1e3766b
            • +
            +
              +
            • This is item d3cc47c3-bd93-4265-9524-40fbb23f503f
            • +
            • This is item d8322d4f-e37a-431c-9def-4088015bdea0
            • +
            • + This is item d981bb32-6688-4410-b571-328f4cfbe7aa +
            • +
            • + This is item 19a7cd16-cd63-4c07-86ae-3ae976aa7bf7 +
            • +
            + Profile picture of user 86912805-edd0-47c8-bd5e-0c6e974c9735 +
          • +
          • +
              +
            1. + This is item 002c7eca-0d93-4026-9676-147afb1fb909 +
            2. +
            3. This is item b77b041e-c42e-414e-826f-146100a84258
            4. +
            +
              +
            • + This is item 283ad28c-8616-4039-8097-bba2dddcdc59 +
            • +
            • This is item c313f548-9a45-49a9-bb0e-2a0f6366e348
            • +
            • This is item 1ac14e1c-e4d0-4824-879a-2779b3e1aa1c
            • +
            • + This is item 0219dfb3-daab-4894-b898-b9761bd09ce7 +
            • +
            • + This is item 2886dec4-bbda-40bf-8981-06a9d2147b84 +
            • +
            + Profile picture of user e742e332-59b3-4262-8904-079044b04dd4 +
          • +
          • +
              +
            • This is item e7a2ec9e-6b2d-41c6-8698-7b8db8e9269b
            • +
            • + This is item e9110b77-b90a-4436-a963-a1ee04b2530a +
            • +
            • This is item b85878a3-4635-409c-9770-ac5f8b348cef
            • +
            • This is item ac89a060-81b7-4780-a64d-8be5982aeae7
            • +
            +
          • +
          +
        • + +
        +
      • +
      + +
        +
      • + + Profile picture of user 52180165-00e2-4ad5-9cf1-1a10ff6f4c6f +
          +
        • + +
            +
          1. + Profile picture of user e72b522e-b909-49c0-be80-7536852100ad + + This is link 910c9553-f5b9-48bc-aec8-e0c670e1a491 + + Profile picture of user b2ae90df-cdda-4a22-910f-959dc32183c4 +
              +
            • This is item e3f30467-c84b-4278-8bcd-034b7637d935
            • +
            • + This is item 33d45dd4-cc2a-48dd-8c7c-7ab118672ec6 +
            • + +
            • + This is item aa8c5a74-0f2d-43ca-866b-8d607fcdc7c9 +
            • +
            +
          2. +
          3. + + This is link 6d49f795-c574-4737-a64a-8c790944752c + + Profile picture of user 3e240b50-3e5a-492e-8810-97ec5bbabd45 +
          4. +
          5. +
              +
            1. This is item 4315381d-b740-47fa-aa5d-69e2d0eda652
            2. +
            3. + This is item 24eb1716-cf55-41ca-9b3b-a513fc0799fd +
            4. +
            5. + This is item 397d3010-c4a7-4f9a-b2a9-aeb1e58f653f +
            6. +
            +
          6. +
          +
        • +
        • +
            +
          1. + Profile picture of user 84c59c55-563b-46dc-a0ca-a91fd3e22db9 +
              +
            1. + This is item 4cd8be36-3897-48c1-afe8-da97c807f03c +
            2. +
            3. This is item 4954c7eb-41a9-4c0c-a950-317fe51aeced
            4. +
            5. + This is item 6cf15103-15f0-419a-bab0-a16e11702675 +
            6. +
            + Profile picture of user 09879d64-5300-481b-a9ee-f3053d78d77f + + This is link 8dd9a4d2-945d-4444-83d2-4b7bfe1aa30d + +
          2. +
          3. + + This is link 264dd631-a71d-4e31-918c-f8e3f1081796 + +
              +
            • This is item 1a683e50-5162-4335-8f30-b907edd03e0f
            • +
            • This is item 0278c3ce-6404-4d98-ad46-7752bb444ccc
            • +
            • + This is item 60d3e2be-1f5c-4205-90e0-fc693aaf7612 +
            • +
            +
              +
            1. This is item bbebc48b-9ab2-4b1b-82ed-9861125fb1bd
            2. +
            3. This is item 33e67e29-5686-410a-9465-bef31fdf0462
            4. +
            +
          4. +
          5. + Profile picture of user 212defda-a12a-4302-aa96-5cad1ed85efb +
              +
            • + This is item bf046a14-8f7c-44d0-8c81-381cf549f208 +
            • +
            • + This is item f8759c57-69e3-4990-943d-5c04c859e275 +
            • +
            • + This is item c15dcb2d-30d5-48a9-8ed3-f37e2f355220 +
            • +
            • + This is item 84fbd472-1eb6-44aa-a7d2-b98c0338bacf +
            • +
            • This is item a10f5c93-2214-4a49-b673-f12f7a261d8b
            • +
            + + This is link ce467d98-ad6b-4556-a60f-3bf3b1f3cc2c + +
              +
            1. This is item 70cc6df9-a81f-4ca3-95a3-b26c1e64feea
            2. +
            3. + This is item b5903592-2bc9-47c8-86ee-6bdd2af2ecda +
            4. +
            +
          6. +
          7. + Profile picture of user f968d83e-a470-4f9b-b1b7-ef13a16295b3 + + This is link e52af2bc-6476-4d58-ae98-8507c65de9cb + +
              +
            1. + This is item 10d06a53-e77b-4c1e-9ff7-7c9603161334 +
            2. +
            3. + This is item 4c7af633-37d0-41c8-98c0-3c525f77429b +
            4. +
            +
              +
            1. + This is item 43b644c2-1144-4b2a-a9a6-6978faae186b +
            2. +
            3. + This is item d2255653-81f9-4f4a-811e-dc8be3520dae +
            4. +
            5. + This is item 91c927d2-3526-42c1-9671-82d3e5b0032c +
            6. +
            7. This is item 027f1c39-cdd9-4d06-a0d0-9dfb69df527b
            8. +
            9. + This is item 2c20c2b2-65e7-43e6-8004-43a7b4cb7711 +
            10. +
            +
          8. +
          + + This is link 6d2a802a-d2f3-4edb-b33c-360ce15c319a + + Profile picture of user fd797ed5-784d-4c73-b6bc-9a18c08a5a11 +
            +
          1. +
              + +
            1. + This is item 62ba05dc-93f1-426c-9b91-82ab27b24ce0 +
            2. +
            3. + This is item 121bc9a4-1463-4761-b54c-76765a8f9cb0 +
            4. +
            5. + This is item 18526fbe-f99f-4367-86da-a7e260cef15a +
            6. +
            +
              +
            1. + This is item 73e600a8-314f-4fc8-a369-573b6f41f150 +
            2. +
            3. + This is item c17e4f3b-e066-4e42-9595-a8e3c82bee07 +
            4. +
            5. + This is item fc561f61-b0a9-44dd-88f7-ff0b0314f356 +
            6. +
            7. + This is item 7b50c4a4-e115-4a32-8b69-480b724c188b +
            8. +
            9. This is item 47280d39-76e3-453b-83a5-bf736f5855e7
            10. +
            +
          2. +
          3. +
              +
            1. This is item 10d3e54e-4bcb-4b6a-8c42-7ecc6846706b
            2. +
            3. + This is item 91152251-d019-432c-b3f3-9dc7c8f6cb94 +
            4. +
            5. This is item 60766970-570c-47cc-a747-57cd0744cda9
            6. +
            7. This is item c4a12615-b600-4069-bcfd-1f3f2c4d9479
            8. +
            9. + This is item 6b1c7f26-ec1e-49ce-b81c-f497a595c134 +
            10. +
            +
          4. +
          5. + + This is link 994457da-6aae-45e7-a30a-51751afc7e99 + + Profile picture of user bc93e29e-d884-4f6c-ac1a-d00e29b0bb67 +
          6. +
          +
        • +
        +
          +
        • + + This is link 38166f8a-2fe8-484c-a6dd-f48435e141da + + + This is link 57f585bb-ffe2-4feb-829f-b32d13797834 + +
        • +
        • +
            +
          • +
              +
            1. + This is item 88dd01ce-5c54-4d50-804d-b0588f245ea8 +
            2. +
            3. + This is item 079e00d8-b1a1-4791-8037-78fdda17ea31 +
            4. +
            5. + This is item b98b3e82-e528-4a03-a530-1fead947632f +
            6. +
            7. This is item c2a2b957-af3a-4e35-901f-5808345cf69b
            8. +
            + + This is link 6dc9c2d8-3463-494f-ad5c-36b454e805ba + + +
          • +
          • +
              +
            • + This is item b784fd9d-716c-4993-82e9-3a5ba89f06f1 +
            • +
            • This is item 108a643b-3b0e-4b1e-a3f3-538e7c668dcf
            • +
            • + This is item a7f128a5-f160-4a5e-9916-5504dfbe16a4 +
            • +
            • + This is item b12bbd5c-f4ba-4a9c-ae2b-1bc9da956858 +
            • +
            • + This is item c3501253-2ed4-4d1d-9f2b-939b3b32858a +
            • +
            + Profile picture of user bb1eca72-6ad8-44cc-9cad-77602c574cbd +
          • +
          + + This is link 45e4d9f1-b2b9-41e3-b73b-fc5a2c576a77 + +
            +
          • + Profile picture of user 1a63556a-a307-4577-b525-4f1c396a79b1 +
              +
            1. + This is item 31657468-cccd-4c40-ac97-b7bee5f37ac5 +
            2. +
            3. This is item 99f64a05-1490-4384-8b22-50f4a01b16fd
            4. +
            5. + This is item 44bdd4bc-52e2-4385-9251-64defbca7061 +
            6. +
            +
          • +
          • +
              +
            • This is item a4e18caa-3036-4268-82ed-883947c44cc6
            • +
            • This is item d3b52e98-1521-4bdf-9a9c-4f52d1ad0b2f
            • +
            • This is item 9fb6256c-2b9e-4188-86e9-5d2ae12019d8
            • +
            • + This is item b7c2b4b9-f8ee-4b25-9053-f9d5955a1087 +
            • +
            • + This is item d8c4bebf-ef19-405d-aae9-70a9b07116e4 +
            • +
            +
          • +
          +
        • +
        • + This is link 3d40d8a9-426d-4a0b-9d78-93b72c5a1c8c + Profile picture of user 18b2ca1c-4da3-45f1-a4b6-0b1618381a0a +
            +
          1. + + This is link cc63a069-7310-48e0-92d9-d14f4cbeab5b + + Profile picture of user 9dec3fbe-7daa-4a5c-800f-348fd2893548 +
          2. +
          3. + + This is link 25f81072-d0a1-443d-8ebd-1369c83f71c5 + + + This is link 06d2b391-eb1a-4dd0-a953-5141213e5cba + + + This is link 4170e960-5e18-43e0-82c7-0f1563fa435f + +
              +
            1. + This is item 07138548-3f74-47be-b8da-dec2a1dbfc55 +
            2. +
            3. This is item 2920afa7-1c96-41ef-a98f-8ac7d268f2a3
            4. +
            +
          4. +
          5. +
              +
            • + This is item 85549e0b-d716-4142-a804-7503124d3356 +
            • +
            • + This is item 32f0548d-39ab-4ecd-8305-bd8ad278fbe0 +
            • +
            • + This is item 912aa5b0-efb9-4826-be64-da9d2b59a025 +
            • +
            • + This is item cd62cdef-18e0-4289-9fd4-e564184b6db4 +
            • +
            +
              +
            • + This is item ffa33164-0c34-4a5a-a831-f496c4d54289 +
            • +
            • + This is item d21fefa7-1f7d-45c6-8709-ece38f0a5eb1 +
            • +
            • + This is item cc8e961d-b1df-447b-a816-6911cfbd35cf +
            • +
            • + This is item e014d1b4-22f5-47f7-9bc7-df088f58dcbe +
            • +
            • + This is item 826b6a66-349b-402a-a6b9-f741bf9f7fb0 +
            • +
            +
              +
            1. This is item 44f37c51-d3e6-4398-b5fe-c06396b52f8e
            2. +
            3. + This is item 5e0103fc-5e27-4692-a4ca-bf9562aa0683 +
            4. +
            5. + This is item d2d5d442-4347-4d2e-b1f1-91998adad079 +
            6. +
            +
          6. +
          +
        • +
        • + +
        • +
        • +
            +
          • + + This is link 68a9886d-dd37-4c80-946c-bbf6bef62bef + +
              +
            1. This is item 2645a22e-295e-45fc-b73e-f8c11e42271a
            2. +
            3. + This is item ae7bbc6e-713b-4b5d-99fb-3e31f683f83d +
            4. +
            +
              +
            1. + This is item 912a6e5a-0a7f-42b8-9dc1-dd74a8dc9858 +
            2. +
            3. + This is item 6f41c43d-7232-4b9f-9b3d-33d31ec36957 +
            4. +
            5. + This is item fc60d591-7f42-4c8a-ba70-723e50ec9fb1 +
            6. +
            7. + This is item 21c18db1-e9cc-4d0c-bd1d-a2eda64b7c0b +
            8. +
            9. + This is item c6ceb3eb-a39a-440d-9da8-7906d808e3b6 +
            10. +
            +
              +
            • This is item be3ca110-c05c-4a83-a19b-a60bc9ee3357
            • +
            • + This is item 91f61f85-c02e-436f-adc4-906ae8bc8877 +
            • +
            • + This is item 71afbb7e-fbf5-4422-bc70-717acb8a3e44 +
            • +
            • + This is item d33c5f10-b7c1-40e1-9197-393d2a884be2 +
            • +
            • + This is item dd141e3b-462c-4ab9-9436-b2275e921db4 +
            • +
            +
          • +
          • +
              +
            • This is item 54f62eee-bc7e-40d1-9d85-e265abbe48a5
            • +
            • + This is item b59eb8dc-ac99-4c9d-9eee-331e92ff8aa8 +
            • +
            • This is item f015bd48-21df-4197-8278-93b1f1587afb
            • +
            + + This is link b3199a10-2bb3-465a-a3bb-84239bd3ee28 + +
              +
            1. This is item 722203f1-64fd-4d08-8860-2b27b6d8fd48
            2. +
            3. This is item cdc7195c-322f-4071-9941-6b54a9ccdf13
            4. +
            +
          • +
          • +
              +
            1. + This is item 758c2607-ea02-4905-8464-0fc2cac2aedb +
            2. +
            3. + This is item d93d6d42-dbe8-44f6-8e00-0c231e2183d2 +
            4. +
            +
          • +
          • + Profile picture of user 59c432c3-5c02-41de-ab12-6afb7249ccea +
              +
            • This is item 5a3c7735-43b6-42a0-8296-32d7e55c6eec
            • +
            • + This is item 39981567-db9f-4827-965c-60ad7d2097de +
            • +
            + Profile picture of user 84394563-cf85-418b-a65f-e9bf7c151013 +
              +
            • + This is item ea355b89-e610-46d2-8bb2-941a79aea91c +
            • +
            • + This is item 79d2a209-7caf-464a-8b24-a2d30e184e30 +
            • +
            • + This is item cb39961e-7893-4964-ac0b-ab2107db9d34 +
            • +
            +
          • +
          • +
              +
            1. + This is item 218d0c1c-ac3c-4299-a398-60f713d2c0b4 +
            2. +
            3. + This is item 61fe2e1a-06d5-4382-8eb5-ce0878e976b5 +
            4. +
            +
              +
            • + This is item d0183cea-29ef-406a-b572-9a4bc216ede6 +
            • +
            • + This is item 2d000a11-14e6-4832-a039-4e62cf24fd36 +
            • +
            +
              +
            1. + This is item 6b40fae8-1826-4f33-be8d-b228132d8b56 +
            2. +
            3. + This is item 0e1f3147-050f-4756-be82-efd4eff49312 +
            4. +
            5. + This is item 092b88c6-b4f2-4fdb-9c28-808723cafd83 +
            6. +
            7. This is item 68cf1fdf-ea62-4d73-b530-0e267063a289
            8. +
            +
          • +
          +
            +
          1. +
              +
            1. + This is item f4519da5-d63b-4c3a-8b49-1fdea2294334 +
            2. +
            3. This is item f261a405-a06b-48ed-9211-9c29f4093afe
            4. +
            +
          2. +
          3. + Profile picture of user 616f8636-273f-4fa2-b0b8-dab5b2067f37 +
              +
            • + This is item fada2c80-1d26-424c-8794-f82570aa485b +
            • +
            • + This is item 5b86212c-31bf-4000-a2e2-71ce23f2e8f4 +
            • +
            • This is item 9ba7bc3d-f287-4428-bf3f-ad8bc4003396
            • +
            + Profile picture of user 6b72e8ab-9bcb-44a9-98ce-7cbfa830d4ee +
          4. +
          5. + Profile picture of user 1d33ddf0-ae2f-49f5-95b8-5818515a9c6f +
              +
            • + This is item 650d45c7-c19f-416b-9312-aa620ecbd48a +
            • +
            • + This is item 4f2d250d-ea8b-4542-803b-a22f06f33213 +
            • +
            • + This is item 5c68ffb8-4707-4971-8e02-995f8effed12 +
            • +
            • + This is item e808c564-04fb-4b22-b388-702cda9d9fae +
            • +
            • + This is item 37f398dc-394b-4058-b9bc-8ac6d8cb6ecf +
            • +
            + + This is link 95c948c4-79a6-4367-9821-4419b0aff9c8 + + + This is link e8549f8b-dad8-4709-85b3-71b8fa227a57 + +
          6. +
          7. +
              +
            • + This is item 2dac4f29-047e-49d6-832a-163330c05f4a +
            • +
            • + This is item 9e3d4441-2653-46ba-ab55-62fb8954bf00 +
            • +
            • This is item 6dd1d03f-f609-4b06-87d0-8495f2fb6a45
            • +
            • This is item 7cd036c8-9456-4dfe-904b-e2858cde46ad
            • +
            • This is item a637dacd-e9d2-4e22-9862-55bcb96c23ec
            • +
            +
              +
            • + This is item 2354d105-e0ee-4ea6-94fa-5cdadd4ba505 +
            • +
            • This is item ab4616f6-8234-4b83-8fb1-2b3ad15a2abc
            • +
            • This is item a283bb02-5e5a-4665-b7ce-667585affeec
            • +
            + Profile picture of user f075312c-4eb5-4f7c-931d-0acae2d47159 +
              +
            1. + This is item d12311c1-d9c8-4096-a76b-3cb2f8ddf338 +
            2. +
            3. + This is item 47576c67-6fdb-4de0-9aed-f1e3ba08762f +
            4. +
            5. + This is item 4ce69c28-6892-496b-b476-2203490eff9c +
            6. +
            +
          8. + +
          + + This is link a2e1d7fa-dd9b-4070-87ae-eb34293cfa66 + + + This is link fb4727b4-b204-4314-8d64-f1816c801648 + +
        • +
        +
      • +
      • +
          +
        1. + + This is link 71d9e5b1-544b-4e3c-a0b3-e75d81ec5c53 + + Profile picture of user d9cc37e1-1233-46b1-9b00-81e61f2873fd + + This is link ecfe9810-ef2d-4b9b-9de6-ec02bb3b9104 + + Profile picture of user 77e67427-38fe-4cc7-8e6a-6187af2e6f6a +
        2. +
        3. + + This is link c610c86b-4fcf-4ff7-afa2-87c385f7430e + +
        4. +
        5. + + This is link 7959c290-e7fb-436a-b1ea-697c9263f6ba + +
            +
          1. + This is link 491179a9-9d3b-44f8-be70-0afa9bed9424 + Profile picture of user eeee1a2f-23e3-4ba9-8906-f18bdacb7379 + Profile picture of user cc41b938-1ee1-42dc-beb8-302cbbe58851 +
          2. +
          3. + Profile picture of user 39331500-46d4-4e51-b058-c44657a1fbd2 + Profile picture of user f5fcf3f7-d7b9-4200-a607-8057251b6410 +
              +
            • + This is item 9e2188b6-bec1-4939-9c2c-548aa00a3f7f +
            • +
            • This is item f5fc52e3-e6da-419d-acf4-b1eb57e2fea7
            • +
            • + This is item 47ae5494-7bf4-4f27-b86a-de6970e4b0f3 +
            • +
            • + This is item d9cabbaf-5bc2-4b2a-9628-2fb75964ae2b +
            • +
            • + This is item 4c51a137-dbb6-4432-b4f8-5c993c174947 +
            • +
            +
          4. +
          5. + + This is link 9e6fcdec-776c-4a42-a26d-29c8a42b17f0 + + Profile picture of user b084dd9a-ffd4-4a7d-91f4-ae2be7b7f91f +
          6. +
          +
        6. +
        7. + Profile picture of user 4d03a5ed-360d-40bd-8d53-127e06f372c5 +
            +
          • + Profile picture of user 2893083a-a570-4244-b000-2a8100cf3112 +
          • +
          • + Profile picture of user a1ce52b9-d024-49c1-9fcf-3b75fca4d317 +
          • +
          • +
              +
            • This is item 7597feca-8419-40c8-9d52-2928a4d72705
            • + +
            • + This is item 75d8096f-61b9-443d-b27b-d52f2bdedcbd +
            • +
            +
              +
            1. + This is item 3e208f37-a576-4864-9cb1-1bfedbad0555 +
            2. +
            3. + This is item 2107bb14-5190-4143-b543-15a9fb039487 +
            4. +
            5. + This is item 9ceca0bd-37ea-4cd7-8765-fa2bd8708d9d +
            6. +
            7. + This is item 2bea4ccb-fef5-4f3d-8983-fecb844c1693 +
            8. +
            9. + This is item 791be99e-5f50-46dd-8b5f-1f1b2da9dfd6 +
            10. +
            + + This is link 252ccb26-2460-441e-a23e-bbb7f03daf67 + + Profile picture of user 3f526d1f-3a13-4821-be0c-3e061e95e609 +
          • +
          + Profile picture of user 699979a6-6ea7-4ba6-a499-e4a23ae9bcd3 +
        8. +
        9. +
            +
          • +
              +
            • This is item dfb4c63c-e0b1-4063-846f-ff70ef8acd21
            • +
            • + This is item ba43e118-333e-4fc1-9fd4-bc2891f9415e +
            • +
            + + This is link e939d441-3859-4cb7-b400-3cb9aae19ee2 + + + This is link 1d8dabf2-5d78-4deb-9a6a-7355e2eecc7c + +
              +
            • + This is item 1b8b0c3a-41c3-46b1-8ed8-5850f117c86a +
            • +
            • This is item 79fb8b7d-e616-458e-a940-95c99f29b3b4
            • +
            +
          • +
          • +
              +
            • + This is item adb431a4-f5ff-4b20-ab47-af5b5c1b1b34 +
            • +
            • + This is item 0c643ecf-9cc4-416c-a92b-b63044cb0478 +
            • +
            + + This is link ff28fe99-0702-4fc6-af57-7b287f381c50 + +
              +
            1. + This is item 1c21a131-7ab2-4b73-ad81-a7c67eb16129 +
            2. +
            3. This is item 7092f416-e7e2-4b6b-b62d-6ae4d587d292
            4. +
            +
          • +
          • +
              +
            1. + This is item a13bc1c0-1645-4db5-8460-570cf1a70e17 +
            2. +
            3. This is item 706a53de-e173-433b-88ae-bcd09931630e
            4. +
            5. + This is item ae56bdd4-6e27-48b6-a916-6eb7ba24b3e6 +
            6. + +
            7. + This is item 913134e4-70b7-4ec2-a38e-d8d3f8ed9888 +
            8. +
            +
              + +
            1. + This is item 9ed7232a-c8cf-4b43-8cfe-425b66628760 +
            2. + +
            3. This is item 1ad350e4-00b5-4f09-b79c-8cf92722d1e9
            4. +
            5. This is item e9368846-8690-4798-9def-16b9c8074d2f
            6. +
            +
          • +
          +
            +
          1. +
              +
            • + This is item 13fba021-a9d2-4ef4-b28b-812f89910746 +
            • +
            • + This is item 3856ddd2-7afc-4686-be28-bc5ea6c3c837 +
            • +
            + + This is link 6f40dd25-d3c3-422c-bc44-28e555750442 + + + This is link 8a03924e-1e9a-4d48-adf4-b40593f45385 + + + This is link 1d369d70-4b65-40b1-aaa7-b240011c7ec1 + +
          2. +
          3. + This is link 5781d822-1359-4b01-abd9-9987f17ffe08 +
          4. +
          5. +
              +
            • + This is item 9e708d64-a7af-4e0c-b8f4-489bed6a146d +
            • +
            • + This is item 70a43706-c69c-468e-9fb2-0ce872f49cd6 +
            • +
            • + This is item 9eea1c25-6279-47df-a39e-8e5ae93ac904 +
            • +
            +
          6. +
          7. + Profile picture of user 19b53f8e-e4e6-47dc-9563-2de84d9f33eb + + This is link 858f0a19-5ada-4b12-bc53-f929c6a8f893 + + Profile picture of user 2b092344-1ffa-4617-8e17-1171c71f5070 + + This is link 8b7fb772-9da0-4303-9059-e11fbc2ad396 + +
          8. +
          9. + Profile picture of user 6d559391-5b1a-4605-aff3-5f83bf4dd424 + Profile picture of user dda6c966-8a29-4de1-a2a7-94f0147a8544 +
              +
            • + This is item 5dabf9b2-d695-4943-b841-bf13c6f618ee +
            • +
            • This is item 7e406f44-c4e0-4085-84d5-ab5396f07f30
            • +
            • + This is item e78847ac-41cd-4ad4-bf73-ae49d567a123 +
            • +
            • + This is item 6e7f9588-82ad-470e-aa69-4b5b8237a3d4 +
            • +
            + Profile picture of user 6fad8bef-23e2-4445-9c43-84171c15e105 +
          10. +
          +
        10. +
        + + This is link 4a584b67-4ea3-4588-b3c9-e136a6bd0225 + + Profile picture of user e1b7849d-69ce-4322-b040-38aca60dbd00 +
          +
        1. + + This is link 3f15976c-6b5a-4183-a64d-e481b81ed218 + + + This is link cfec2d9c-61f5-4d6e-97ab-98098c53a355 + + Profile picture of user 5c30df26-227b-4d12-962a-0d867a233664 + +
        2. +
        3. + Profile picture of user 387f9f0b-9942-4f14-a8eb-dedb668b5303 + Profile picture of user 0e10a6ad-623f-48f5-bf1f-3515c5fd0f04 + + This is link 17afc43c-8804-4dd5-8fd1-d75bbe45a1d2 + + Profile picture of user 41182902-5b89-4793-9c1f-3499609395fd +
        4. +
        5. + Profile picture of user 686f62d4-f690-47dd-a45f-9c3ed0d0b471 + + This is link 06fc0e0a-11c1-4c28-8815-e5cd940d9790 + +
        6. +
        +
      • +
      • + + This is link 71902123-8c4d-468a-b5af-82f831eceec5 + + Profile picture of user e8ed402d-a130-4878-b271-39be61244d4a +
          +
        1. +
            +
          • +
              +
            • + This is item fe70eff5-e8ff-4e2f-a7f5-72de785af0dc +
            • +
            • + This is item f72b3a28-0c3f-4d41-aead-60d3383d3335 +
            • +
            • + This is item 91853297-613c-46db-84bc-a204d851f19f +
            • +
            +
              +
            1. This is item dbd32ccd-8cad-4264-83cf-886825549ce0
            2. +
            3. + This is item e333be1e-c594-4a00-85ec-8ab650cba984 +
            4. +
            +
              +
            • This is item 0ff030be-dafe-437a-a093-3421b50edd2c
            • + +
            • This is item 6ed8d42a-410c-4a7c-958c-520f23981340
            • +
            • + This is item bf70612b-3305-4c8d-8d88-1532b70eb477 +
            • +
            + + This is link 006b35d1-7ea4-4a52-9654-28a27d5d8429 + +
          • +
          • + Profile picture of user 59e04a06-14fa-4d24-a70a-eb73f0c6a0f3 + + This is link 85977217-dd00-4c91-89e1-7b2f7fa509e8 + + Profile picture of user 09f11b11-a47e-4597-8c1d-2bbb1e1d7c53 +
          • +
          • +
              +
            • + This is item 5e9ab136-ace9-4187-8d1e-1241aa3b5d56 +
            • +
            • + This is item 1c9685ae-482d-470a-993b-1fccede871e3 +
            • +
            • + This is item 29cd1cce-d82a-49c9-99a9-5a2975307eff +
            • +
            + Profile picture of user 9eadb997-d2e7-4d1b-a840-5b173c37a7ab + Profile picture of user c80c773f-4996-434f-be8c-162f6c5cae23 + + This is link 6c318e9d-8857-482f-bff4-d4190e039906 + +
          • +
          • +
              +
            1. + This is item 284d4380-27e2-4eae-85bd-0207efbaff36 +
            2. +
            3. + This is item 90f307ac-212e-4dd5-95ac-3db8a501c657 +
            4. +
            +
          • +
          +
        2. +
        3. + + This is link bdf4a115-fb79-4a34-afda-f1640e09f79b + + + This is link f33b5865-4cac-44ac-ac24-5a6779b99366 + + +
        4. +
        + Profile picture of user 9cdaf293-e82a-4eaa-af91-f766a72ba058 +
      • +
      + +
      diff --git a/oxide/crates/core/benches/fixtures/template-003.html b/oxide/crates/core/benches/fixtures/template-003.html new file mode 100644 index 000000000..e135c0429 --- /dev/null +++ b/oxide/crates/core/benches/fixtures/template-003.html @@ -0,0 +1,1797 @@ +
      +

      + This is text 836e86d8-98b4-4cc0-a1e8-d56d2960c916 + + This is link 9e4bd8d6-5f33-4b19-83ae-1025bf2ee87d + +

      +
        +
      1. + Profile picture of user a0e842b8-d3ae-4ef0-9b48-058444b57fe3 + + This is link 30477fc0-bc3f-4002-a52c-7c23684d5a25 + +
          +
        1. +
            +
          1. + + This is link c8408f7d-789a-4741-8da5-6c06558648f0 + + Profile picture of user 5e352ecc-7e2b-45bd-9c40-be5f325d28ea +
          2. +
          3. + Profile picture of user e5b4fa11-f581-4d3c-a47d-19ed195fd84b + Profile picture of user 587f340d-38db-4961-82a0-cc57b710c6c6 +
          4. +
          5. +
              +
            1. + This is item c057ce5b-9c22-4373-8a90-894e3191b1f7 +
            2. +
            3. + This is item 49196c3d-7bac-456c-ad29-53fa40dc230b +
            4. +
            + + This is link 061e3624-7c72-4d2c-9dc3-5ea37fc9f5f5 + +
          6. +
          7. +
              +
            1. + This is item 395207e4-33bb-42a2-8f61-58a7f521868c +
            2. +
            3. + This is item 5f11bf0c-36bd-45aa-977c-bd438a89b0bc +
            4. +
            5. + This is item 0e1cd426-b4ed-4cd3-8887-948b4a0da354 +
            6. +
            + +
              +
            • + This is item 0715564d-b88f-403d-ab11-67a2a6e86e18 +
            • +
            • + This is item b3ffbcc2-4e3b-452d-8087-1469ed896687 +
            • +
            +
          8. +
          +
            +
          • +
              +
            1. + This is item cb579a90-0215-406f-a53a-cbd8cb9331ce +
            2. +
            3. + This is item 0ecc60ca-98a1-472c-ae9f-aea43fe215ab +
            4. +
            5. + This is item 61215cdf-5505-4ae1-8e3a-b334d5b3b07c +
            6. +
            7. This is item 2a1b833b-b584-45a3-abff-8ad46d5854b9
            8. + +
            + Profile picture of user e6b6607c-0d3d-4971-a913-6b1d823a9837 +
              +
            1. This is item f5ba4434-ae7e-45ad-952e-47cf0ffdfd2a
            2. +
            3. This is item 340e1a85-cd78-4018-9a18-684cba5a81b5
            4. +
            5. This is item 53e5348e-d341-4eba-82dc-d9dc77dc7681
            6. +
            7. + This is item 8fc1cc12-27cb-4e7e-98da-19b8968be762 +
            8. +
            9. + This is item 50541d5a-8191-4c08-857c-86b6d3d6ec14 +
            10. +
            +
          • +
          • +
              +
            1. This is item 9bad4fce-bc73-47da-b830-3669e5535db9
            2. +
            3. + This is item 90531b39-cf93-4e52-a3d4-8f66448b00e4 +
            4. +
            5. + This is item 97306b2c-8c16-4d89-ac20-c869372c6618 +
            6. +
            7. + This is item 78a4c873-89c4-44f5-9139-b722276f3371 +
            8. +
            9. + This is item 510e3438-17dd-4c0e-abcb-e536c33fe39a +
            10. +
            + + This is link 45438ba5-842a-4435-8f1d-bdcaf227cba8 + + Profile picture of user e3a2df48-c057-47cf-ba5d-c36ce5daff6a +
          • +
          + Profile picture of user 97267d42-8a97-499b-9e00-9d9cfa6da07e +
        2. +
        3. +
            +
          • + This is link 10c68f64-3291-43b2-9368-178533d0af8f + + This is link 3bc959ab-5ecd-4cb8-b281-8fef1c49def3 + + This is link 967b2db6-c38f-465f-a256-043db89719d1 +
              +
            1. + This is item 64f7e4a6-8102-448b-82eb-176bbbcd8b5b +
            2. +
            3. This is item cea7356f-b8b9-4bb1-8bf3-b466027c2b73
            4. +
            5. This is item 1d561a0c-b820-4f1e-8f7f-c5acea51e676
            6. +
            7. This is item c59908ef-a8aa-4ac9-a562-aeb5498901ff
            8. +
            +
          • +
          • +
              +
            • + This is item 989954ab-2b75-4bd1-a988-d96160530009 +
            • +
            • This is item a266e290-5eb4-4ccd-a4e1-7cb3b578499e
            • +
            • + This is item 602983e8-a1fc-4452-b02e-6224f9ea1002 +
            • +
            +
          • +
          • + Profile picture of user fd6a3e7e-6f06-4e61-9ac0-46fab81b14bb +
              +
            1. This is item b1c61ed4-2740-48a7-a905-8315ecead010
            2. +
            3. + This is item 184eaf1c-248f-4f1b-becd-fd9ff1afe7e1 +
            4. +
            5. + This is item d18a7d9f-0957-409c-bb81-6fbdbd7915d6 +
            6. +
            7. + This is item c0804123-0c73-4ace-92d6-14c2f86c59f4 +
            8. +
            9. This is item 2cf99604-4831-45a0-b996-ce5aee4c2edd
            10. +
            +
              +
            • + This is item d781aba5-0df3-49c3-914f-923056e061cb +
            • +
            • This is item b53b6af3-2dff-4241-8623-6f46876e1d0a
            • +
            • + This is item 309f238a-402b-457d-ac39-f3213233fa44 +
            • +
            +
          • +
          +
            +
          • + + This is link 7fee9a30-c948-4413-b008-07ccdef01f6a + +
          • +
          • + + This is link cfb757dd-9ad7-4fd1-b4bb-05d8c03f89e7 + + Profile picture of user 5f0c4f37-7d80-4b81-aa03-4a58c9bd8bd0 +
              +
            1. + This is item e81c40f9-879f-450f-97fc-e20e0d64cbcb +
            2. +
            3. + This is item 5e582e0c-4296-4044-b337-9e972e2ad643 +
            4. +
            5. + This is item 0d449017-fc20-4fd5-978c-5273836c7bc4 +
            6. +
            + +
          • +
          • + Profile picture of user 958da973-a646-449c-95d3-0d74b837f30b +
          • +
          • +
              +
            • + This is item bbb1b755-ec35-45c8-b353-f2e7c41030bf +
            • +
            • + This is item aa45ba7d-d709-4656-ae61-5ae5ebc1ae00 +
            • +
            • + This is item 02b590ec-ac10-48b2-87c1-86f5a33d46cf +
            • +
            • + This is item 50cdcd16-49ca-4fa7-9006-0cd2f1028326 +
            • +
            + Profile picture of user 5623c03d-a5d0-45fc-bf68-d67a2e44e634 +
          • +
          +
            +
          1. +
              +
            • + This is item 7b8736e5-bc5c-402d-9fc1-81001075e626 +
            • +
            • + This is item 8de83aa7-e4c7-41fb-8b56-0dae0ea9d19d +
            • +
            + Profile picture of user 98b62066-927a-40af-b29e-80f9beb8d845 + Profile picture of user 6b95579e-24e7-46de-9e7d-5747d477691c +
          2. +
          3. +
              +
            1. This is item 37d8f011-5c8b-4310-b856-f7d3369d4249
            2. +
            3. + This is item b4760918-68e1-49ca-884c-c95db35a06c3 +
            4. +
            5. + This is item 17d3fcb9-ebde-4d33-8551-ff391207fd01 +
            6. +
            7. + This is item 4af8fee1-8328-4bad-b005-0c5148c624d0 +
            8. +
            9. + This is item 9fefc0af-9275-4a57-8369-a12cafeeadf4 +
            10. +
            +
          4. +
          5. + + This is link 28f60217-408b-462f-985b-1c5274960125 + +
              +
            • + This is item a5d486f8-75e1-4bd1-997a-d9bb608b7b58 +
            • +
            • + This is item f9b8ddfb-00b4-4e8a-81d1-425fc607e6ad +
            • +
            • This is item 133e5f91-cf58-4cae-a28f-f5eec3325881
            • +
            • + This is item d93da5f0-aae5-40d1-8bae-8a06d4ee1391 +
            • +
            • This is item e42f0a9c-eab6-4d4c-af19-186577a6197b
            • +
            +
          6. +
          +
        4. +
        5. + + This is link 6f4e2534-d8a3-4e13-aedb-0d637f22bbe8 + +
        6. +
        + Profile picture of user 42a741ec-a46f-4e64-bf60-ae292daba13b +
      2. +
      3. +
          +
        • + Profile picture of user f568f97e-22fb-4a97-82e2-7c2079b1dea8 +
        • +
        • + + This is link e0937178-6d82-4da0-9ca7-c9b4825ff798 + + Profile picture of user 9b0b0356-eeb0-4fb7-b860-f6c8b363d303 +
            +
          1. + + This is link 58676856-1c90-4050-8e95-7b9474cb92cc + + + This is link 9a0d716c-6110-40dd-8e51-800a2a613187 + +
              +
            • + This is item a14fc367-fd50-40ab-b66e-67570fe93857 +
            • +
            • + This is item b5a87c1b-2099-428c-898a-1f8730b9e583 +
            • +
            • + This is item 8bf0e268-d4d9-4256-aa29-3f4bcc33a85b +
            • +
            • + This is item e050379b-819f-4f8d-985f-638222c2471b +
            • +
            • + This is item 5a793a0a-2871-4e96-9bb0-8ee894ecf819 +
            • +
            + + This is link 419bab39-4570-4fba-bd03-0e771e351578 + +
          2. +
          3. + Profile picture of user eb95f7b7-a0b5-434b-b05d-6792544073e3 + Profile picture of user 775195f0-c4d3-4ce0-9153-3f7e4a165423 +
          4. +
          5. +
              +
            • + This is item 70332add-e165-4964-b2de-c708c2d344a1 +
            • +
            • + This is item 53ae5347-7931-4527-8cc8-ddd4ef713d2e +
            • +
            • + This is item 65a8238e-275e-4ed0-878d-ccf9cc64d211 +
            • +
            + + This is link b5203c36-000c-4c7b-9a57-dbd98964cee8 + +
              +
            • + This is item 64856127-4d03-4244-9f6a-c6bb010874a3 +
            • +
            • + This is item 54cc09e6-1641-4654-9d19-dc9d2043d834 +
            • +
            • + This is item 5255680b-ed2a-47fd-a350-9c5c18189adb +
            • +
            • This is item 78d39c9c-97cb-4625-98e4-55a1a9104afe
            • +
            +
          6. +
          7. +
              + +
            1. + This is item dfd99a59-7d22-4ba2-94f3-3a92f89fa886 +
            2. +
            3. + This is item e1b7890a-9a3c-49c9-b7d2-60a4a5de9476 +
            4. +
            5. + This is item f7d9c6b5-4a57-4443-bf2a-5dc0603b89e4 +
            6. +
            +
              +
            • + This is item 144207ad-7633-4eb8-8a9f-783156937897 +
            • +
            • + This is item 45cbaa7d-22eb-4fc4-86c1-5956f03324a5 +
            • +
            • This is item b5eecd7f-0811-43d8-8c45-72d8a52fc9be
            • +
            + Profile picture of user 8c703ccd-ca1c-4f08-b514-99eb940a5720 + + This is link a5619c84-8bb8-4396-8ddf-a14ab872fec8 + +
          8. +
          9. + Profile picture of user e73c41b0-bb7b-4bb7-a0fe-a0b8a4bdf05a +
              +
            • + This is item eebbbde8-c3e6-49b4-929b-65ea75acea6d +
            • +
            • + This is item ec11b5af-e161-457e-9287-fa712a1028e1 +
            • +
            • This is item 24c31676-0530-4a89-9f19-0f940fbf11a7
            • +
            • + This is item 6de989fe-1001-4f70-be86-e09ad36d0757 +
            • +
            • This is item 4da5552b-9f59-4dd9-a871-027b3d51b0ec
            • +
            + + This is link 3c640db0-c0f0-4c2d-aba4-2dfabdc10b0a + +
          10. +
          +
            +
          • +
              +
            • This is item 01df3a76-c945-46d8-9256-686b96d33b57
            • +
            • This is item 798722c0-a16f-4570-8426-86c78b49f5b6
            • +
            • + This is item 5b52281f-5c22-4610-87f9-495d2820f535 +
            • +
            • This is item 1b522ca1-d1fe-4342-b3cf-05a518a0d327
            • +
            +
              +
            • This is item c7fffe7c-ddca-49ef-88c4-1275d796043d
            • +
            • + This is item a6f6058e-9c37-4753-b667-87d4666230d2 +
            • +
            • This is item 7de0a12e-091a-436f-aba3-1f31d348b4e3
            • +
            • + This is item e901c9d4-73a0-49cf-bea0-b938b47ef960 +
            • +
            + + This is link de20ea7c-c436-4a16-8697-e6116d62e702 + +
          • +
          • + + This is link 1b62aa0a-17e0-4eb1-8e13-e12c212ac1d1 + +
          • +
          +
        • +
        • + + This is link 1bb8befc-8e92-498a-8450-22f8f7350ae3 + + Profile picture of user e59b75b6-f39f-47a4-9526-69fdf1b74951 +
            +
          1. +
              +
            • This is item 4fb57671-f456-4b76-bea6-7dac6a1230b2
            • +
            • + This is item 860851f6-7242-4b45-a0d5-9094c58b74db +
            • +
            +
              +
            • This is item 366c314c-f9a3-4766-8db0-73a5a3205821
            • +
            • + This is item c29817dd-62a7-4d87-8aae-946241a0e602 +
            • +
            • + This is item 11edda55-1024-4883-b805-f1acec8018d9 +
            • +
            • + This is item b27c71f7-c949-43de-9bf0-059984bd1b5a +
            • +
            +
          2. +
          3. + Profile picture of user 56494d73-ed7e-43cd-bc19-79540aa301b1 +
          4. +
          5. + + This is link d3c84b88-828f-4b61-8dbc-88406df468cf + +
          6. +
          +
            +
          • +
              +
            • + This is item 416632a2-e5c1-4d83-8c7c-1629f73d3201 +
            • +
            • + This is item 547ae974-2d98-4f0f-9dbb-63157a3be51a +
            • +
            • + This is item 8b0fadce-db0a-4cb0-b835-ef9048976840 +
            • +
            • This is item f9658882-5766-4c8b-8f96-f60b6ba5689b
            • +
            + Profile picture of user 0a65d7ab-4c74-46bb-aafe-527f5010c776 +
              +
            1. + This is item d194ef9b-ddc8-4758-9828-7d6549efaaee +
            2. +
            3. + This is item 86529bed-e772-46fd-a678-e46261f5de10 +
            4. +
            5. + This is item 1f8a1969-4baa-401e-a6c2-7a3c1cd40617 +
            6. +
            7. This is item 094e63c1-065f-450f-92ef-f05c58b494bb
            8. +
            + + This is link 33aee684-c2b4-4275-8484-b73f175b6d0b + +
          • +
          • + + This is link aa8532c7-e4cf-4cc4-9e3b-3a2596f481a0 + +
          • +
          • + Profile picture of user f454c7ec-b4d4-49c3-9f73-0dd9468d9cd5 + + This is link 33520f96-7abc-4988-a09c-3479eacae08c + +
              +
            1. This is item 76213aa9-0b4b-4594-a343-29fedf823483
            2. +
            3. + This is item 500e0ba3-4eea-4652-882c-d285975c65b2 +
            4. +
            5. + This is item 0d50da62-6d17-4ede-ae24-92dffbd6de53 +
            6. +
            +
              +
            1. + This is item e090f68b-9911-4ed3-82cb-91da095236ec +
            2. +
            3. This is item 40780782-a3bf-44f8-9c80-3117ed333483
            4. +
            +
          • +
          • +
              +
            • + This is item f0a6fd4d-3234-4f94-b9d3-a29c26214742 +
            • +
            • This is item cd2cf691-ceb2-48cb-b4b9-8fbaae2d5aaa
            • +
            • This is item 82b87855-4bbb-4132-b59b-b46dd580ff64
            • +
            • + This is item 7dedd7a0-991d-42cc-a620-a4f5915fc685 +
            • +
            +
              +
            • + This is item 03aecbb6-4ff8-47a1-aba2-b47d308e43f5 +
            • +
            • + This is item c7d51352-4b2d-42d6-8bf2-2529116c7756 +
            • +
            • + This is item 9119dcdc-50a3-47f9-bdbb-ee88c95be73d +
            • +
            • + This is item 50cf49c0-111d-47b7-a787-73aef9eec9f9 +
            • +
            • This is item adb1e090-00bf-4f20-8b5f-d65f03523246
            • +
            +
          • +
          +
        • +
        • +
            +
          1. +
              + +
            1. + This is item ece6b694-40e4-49e1-b870-d70e9d98a3eb +
            2. +
            + + + This is link 69e13912-ad10-40a4-8761-d9e72a60c5cf + + + This is link 05271b64-a3a0-42eb-a836-a46161f863f6 + +
          2. +
          3. +
              +
            • This is item 6b4fb5b7-0599-404d-a64b-2a325c920910
            • +
            • + This is item b82e6800-c239-4b14-b54e-50fb7378a4ba +
            • +
            • + This is item c7101118-bf8d-4ec1-96e6-e8047cd34c12 +
            • +
            +
              +
            • + This is item 1b7093fe-f0ca-4170-9aa3-facae456e232 +
            • + +
            • + This is item 93a1ae96-702b-47e2-ac93-a5d7fd275ea9 +
            • +
            • + This is item d2e82235-9f23-4d4b-bdc5-8e42f7ac26b4 +
            • +
            +
              +
            1. + This is item 0b3925d5-5234-4206-9338-53d6da8fb093 +
            2. +
            3. + This is item d3b18b1a-d391-4b93-b8f1-8d6d1472b321 +
            4. +
            5. + This is item 4266f80c-84c3-4aeb-8b76-782043847e25 +
            6. +
            7. This is item bcf0f594-af3b-4211-bb37-3ad1bba89293
            8. +
            +
              +
            1. This is item 77b0a821-f933-4b9b-9222-9cce8365207e
            2. +
            3. + This is item 3b995b79-6f10-4cb3-bbfe-77e35fe5a8a4 +
            4. +
            +
          4. +
          5. +
              +
            1. + This is item 67d5d9c3-8397-49d1-a231-02da836ce4d4 +
            2. +
            3. + This is item 1a340dfd-216d-4e9e-b115-e45c3ebf811c +
            4. +
            5. + This is item 63df0cf3-3d65-4062-99ad-a30b41dc8904 +
            6. +
            + + This is link d57787d9-fba9-4670-87c6-47c431db5350 + +
          6. +
          7. + Profile picture of user 5af06942-50a7-4956-92a9-b152eac252e3 +
              +
            • + This is item 4d8eef66-e20d-433c-b2b9-7fc10ca28d21 +
            • +
            • + This is item 2da73d83-c62a-4904-b98b-e45b95bf9fd5 +
            • + +
            • This is item d086ad53-f39d-411f-986a-0b2c5db001d9
            • +
            • + This is item ce434f3c-11cd-4127-83ce-80b96955b67c +
            • +
            +
          8. +
          +
        • +
        +
      4. +
      5. + + This is link a454857e-44c5-4361-b89d-aa05a85e8435 + + Profile picture of user 56f662ca-63f5-42e9-b367-51a6f1d398fe + + This is link f409a5a0-d473-4011-8334-900443e0f18f + + + This is link 268b4b30-93e8-4f5e-a151-a32833ffe426 + +
      6. +
      7. + Profile picture of user 5430339c-3ce1-4bac-8f74-dc14b61a05bd + + This is link c2c9e114-f9b2-4a5e-8011-28be79a68c71 + +
          +
        • +
            +
          1. + + This is link e45f7944-8675-4fe6-9b82-791950e51983 + +
              +
            • + This is item d0df2580-e6ab-4585-bc13-22c65b489a50 +
            • +
            • This is item a8d5f399-d996-49ef-beb9-e6bd52ef4d33
            • +
            • + This is item cdac711c-5694-4dab-b18b-0d244e0dd63a +
            • +
            • + This is item d2bd1abd-31ee-42bf-a96d-0f34bbc66367 +
            • +
            • + This is item b4dfa43a-baba-4b58-b6b0-f959951c85f1 +
            • +
            +
          2. +
          3. + Profile picture of user daa0f908-37e5-4794-bd58-bf22b0671b4b +
              +
            • + This is item 0c48a48e-40d7-44b4-85ab-f6196279bf43 +
            • +
            • This is item 23379794-c1aa-4876-90e8-32ad1ea7577a
            • +
            • This is item 5064ae15-6411-4f51-a6ec-598be92967a3
            • +
            • This is item 0031da59-a7cd-493d-92af-7a62c259580a
            • +
            +
              +
            • + This is item 7203d4e0-2b5c-4a41-ba62-9c9719b2d6b4 +
            • +
            • + This is item 2fb5ef84-f456-4eef-b6a4-0676646cbadd +
            • +
            • + This is item 23141800-9858-438b-8571-36e6eb91d82b +
            • +
            +
          4. +
          5. + + This is link 9cc104fd-e415-40f9-b763-9e82158fcc9b + +
              +
            • + This is item e8d74548-6565-475e-9a8c-d1f5e55105df +
            • +
            • + This is item c9c52cdf-7244-4432-a788-2e6dfa26f6de +
            • +
            • This is item 66bbe44c-810f-443d-b239-4376389c466d
            • +
            • This is item 10e43390-0ddd-46cf-b721-a1d6dac4101e
            • +
            • + This is item 46ceca10-6552-42b3-9517-08fb49c75a71 +
            • +
            +
              +
            • This is item f600916c-6408-48d6-be1a-fc38a66439e9
            • +
            • This is item b2b6c2c5-0d2e-4e64-94a2-f021584719c3
            • +
            • This is item 0b179919-1d19-420d-b8f3-4ff8baa40e10
            • +
            • + This is item cf003bc1-a051-4ae6-b665-687e194c7f30 +
            • +
            • + This is item d92285fd-d158-4898-9d71-15c6e613de58 +
            • +
            + Profile picture of user 556bf043-22e9-48c4-b617-4d33c8f1cd96 +
          6. +
          7. +
              + +
            • + This is item 680e9155-9cc1-4009-8c36-9573c195b706 +
            • +
            +
          8. +
          + + This is link 3ca462d2-05e5-4cf2-b8a8-a7a4ec66b8bc + +
            +
          • +
              +
            • + This is item 03e1b234-575b-4357-98b4-a9b6f96b5560 +
            • +
            • + This is item f9257907-d82d-46e8-a653-1ae85e6f12cc +
            • +
            + + This is link 10509934-570d-4d14-aebf-254b9b9bf001 + + + This is link ebf14a51-271f-4b8a-9430-def203b04b0e + +
              +
            • + This is item b1309e2b-47b3-40d3-9bd0-641826900e41 +
            • +
            • + This is item b33d3bf7-dfc2-4889-b814-70ad48cb8bcc +
            • +
            • This is item 2b18c2ed-e08a-4eb2-8dba-3b34cb550aa2
            • +
            • + This is item 9e57e9eb-6d6f-4a34-87e9-2f24c5436245 +
            • +
            • + This is item 8569574e-e2ca-4141-897a-379f66dd74cc +
            • +
            +
          • +
          • + Profile picture of user f73aab2b-19fd-4afb-ab46-c25dc9870a88 + + This is link 88253cf6-de69-4709-809b-4481667484de + + Profile picture of user 65cf0c13-0e08-4088-98b4-8cdf2602e754 +
              +
            • This is item 2b3de75d-c11f-44cf-83b9-d3fba1fbfbe4
            • +
            • + This is item 1d18392c-2253-41cd-9474-2619c9a54b7c +
            • +
            • + This is item 5393b275-6d7e-48a1-94a8-2394cef905b0 +
            • +
            • + This is item 400fb703-342a-4115-be65-590b56b8fa50 +
            • +
            • This is item d55df31c-7841-494e-aeab-5c39c2743757
            • +
            +
          • +
          +
        • +
        • + Profile picture of user 5f60a1c1-9e3f-4928-87ec-4787e070f888 + + This is link d1d99f68-5536-4945-be50-66b1d1ce5342 + + Profile picture of user e23e4396-e2ea-4c9b-b476-52dd36bdd7eb +
        • +
        + + This is link 5717a254-b665-4b24-b123-5f08e45f79ca + +
      8. +
      9. +
          +
        • + + This is link ff60b32d-58b1-4453-bd6d-1d1390a83184 + +
            +
          1. +
              +
            1. This is item 972b4b57-82bb-4f0a-a0dd-00af80a28193
            2. +
            3. + This is item 27a5dc22-62e4-4ba2-90be-93a7e28d9b93 +
            4. +
            + + This is link c136f069-3287-4aa7-a34a-c12a071774e0 + +
              +
            1. + This is item 57bb1b4d-fac3-42b7-b56a-d1195136251a +
            2. +
            3. This is item 35cf058d-d111-4f14-b6fb-c03e7356f1be
            4. +
            5. + This is item 048a85de-2f65-4cf1-9e9f-d8d1c9296ac8 +
            6. +
            +
              +
            • This is item 0bb622c2-5f6d-449a-8295-42a95dd4d7e7
            • +
            • + This is item fc46a1a8-7628-42cb-b71b-d4126bb29d62 +
            • +
            +
          2. +
          3. + + This is link 29eb45be-4033-4a2f-a7f4-776a6c4e5d85 + + + This is link cbb38cd9-9ba7-41ac-b360-b0660b8d9c5e + +
          4. +
          5. +
              +
            • This is item 2c2450e7-17fe-4318-9b17-7b8c0a45422c
            • +
            • + This is item 5a0461ac-0adc-49d0-9e41-264fbe5b4911 +
            • +
            +
          6. +
          + +
            +
          1. + Profile picture of user 8d2ee06c-66ec-43a8-b164-58729e086aac + Profile picture of user 7ab72bf8-330a-4bfc-bce7-a4e591fe17f0 +
              +
            1. + This is item 9cbcdb21-bfe5-49d8-9a30-48822a962513 +
            2. +
            3. + This is item 118feca9-6f06-44bb-af28-5ce1c6857a77 +
            4. +
            5. This is item 532b3a46-c994-4062-9de8-c33c52ddff64
            6. +
            +
              +
            1. + This is item 410af27d-f8fb-4e8d-a19a-254a04b9a2e4 +
            2. +
            3. + This is item 802b289c-e3d7-4a9a-b307-3bc5b8fb9fa8 +
            4. +
            5. + This is item fb061d7f-1209-4392-bde8-ed8e7d9c1b58 +
            6. +
            7. + This is item 1e1467eb-5f97-4ad0-9176-e5dc0b622ddb +
            8. +
            9. + This is item 08bfacab-6582-4d66-89a9-14a5dc2a6e13 +
            10. +
            +
          2. +
          3. +
              +
            1. + This is item 607255cd-3b5b-45a0-9e49-2042ceb1d62d +
            2. + +
            3. This is item 219dfc57-42b0-4525-b9e4-af068808bfbc
            4. +
            +
          4. +
          5. + Profile picture of user 39be8647-ca40-4048-8f59-358b350c4cc9 +
          6. +
          7. +
              +
            • + This is item 878e5303-4dfd-4999-a321-5975fc4bdcbe +
            • +
            • + This is item 737593ce-1283-47aa-b995-9a95752e5e33 +
            • +
            • + This is item 69f1612b-7dff-49ae-b421-1f11a84506fd +
            • +
            + + This is link ac7e9b94-d486-445c-8351-1845d379ad54 + +
          8. +
          9. +
              +
            1. + This is item 159a0e92-7304-474f-9c2a-9996a712a416 +
            2. +
            3. This is item 09b3acba-a146-4eeb-897c-16d1e985a382
            4. +
            5. + This is item b241a536-8e2f-4815-b565-d90aca2a2a5c +
            6. +
            + Profile picture of user 02635db4-e4fe-4829-b45c-a2b7693562af +
              +
            • This is item 0ec9a2de-2d46-4942-b27b-a5c00957abb9
            • +
            • + This is item b784f4eb-5a56-479a-8341-1353d90080fa +
            • +
            • + This is item 66fcef9c-156b-4709-bbec-0a42c347b39a +
            • +
            • + This is item 805f9916-1d0b-4210-88fa-4fb494708b22 +
            • +
            +
          10. +
          +
        • +
        • +
            +
          • +
              +
            1. This is item 8e35c97c-0e04-4ef7-9e12-17d364903426
            2. +
            3. + This is item 64ffb17b-cb90-4e8f-8cf8-ce1e8f27918e +
            4. +
            +
          • +
          • +
              +
            1. This is item c06503d0-fda9-4b87-ab19-f534ba28f0cc
            2. +
            3. + This is item ad90924c-bdae-49d2-b4cc-e3de5811657c +
            4. +
            5. + This is item a93b49e5-561b-4e44-8dfb-cf7d2ae642cf +
            6. +
            + Profile picture of user a0a2aee6-7184-4f36-aedc-7334f739bd25 + + This is link 5e06acd9-d479-4695-bcf0-d4666f64f0be + + Profile picture of user e09d31c5-5dc8-4814-9044-d8992c0664fb +
          • + +
          • +
              +
            1. + This is item 189fa4fa-ca21-4d73-b295-383da320ece6 +
            2. +
            3. + This is item 571fb48f-57f8-47dd-826e-925e7b646d84 +
            4. +
            5. + This is item b7da10be-e360-4e28-8ffb-30870f97c7a6 +
            6. +
            +
              +
            • + This is item eeca141c-f2fd-4834-9ed7-5993b0cb65e6 +
            • +
            • This is item 1058035d-b1b3-4efb-873e-0486070b4d7d
            • +
            • This is item 6f271226-cb1f-467e-87b2-e16d9b070f97
            • +
            + + This is link 36819225-12b5-4c11-b2ee-404856bd9878 + +
              +
            1. + This is item e38d5c9c-940f-4878-8da5-a65feafac7ab +
            2. +
            3. + This is item dfc8489e-a4dd-48f7-943b-fba2fabe2ca8 +
            4. +
            5. + This is item e3e26f1c-08e2-4b62-b73e-2d349c5de1bf +
            6. +
            7. + This is item 9f9719ed-a737-4930-a65e-6474781c64fd +
            8. +
            +
          • +
          • + Profile picture of user 1ce02e65-a0f1-4305-9a12-23afd01582cd +
              +
            • + This is item 232d51b9-094b-475c-ad53-89e32bcea259 +
            • +
            • + This is item c1c154fa-3a2d-4109-94d1-b12ba6f0991a +
            • +
            • + This is item 1061b106-4316-4580-b6ff-26e6c0ebb3fe +
            • +
            • This is item 5ec8657f-7020-476e-b43f-86594f4aecce
            • +
            + Profile picture of user 76903155-ca94-4f32-8b5c-33f495e0a291 +
              +
            1. + This is item 34cba3ff-d465-461c-a7bb-4b7d4881e7e2 +
            2. +
            3. + This is item 658e812c-66f0-4c8a-9332-8f2b17f7f592 +
            4. +
            5. + This is item 635e9312-3baf-45f0-a1cf-9615ae21b6e8 +
            6. +
            7. + This is item 311b639e-309f-4ec1-b302-7bb23cd1c71f +
            8. +
            +
          • +
          +
            +
          1. + + This is link 516ad276-f409-459b-9daa-8a7ba3562946 + +
              +
            • + This is item 1ac42b53-615a-4940-aee3-e57aeb34ebdf +
            • +
            • + This is item 8c9c434a-c624-4ee0-b582-3f41ad4340ff +
            • +
            • This is item 9ad8cc2a-afe3-46e6-a519-dde03abe7910
            • +
            • This is item 9902400a-5f06-4ad3-aebc-39d46eb8e3ff
            • +
            +
              +
            • + This is item 460badc8-198f-4d36-bee7-180e75240f27 +
            • +
            • This is item bfeba06a-b690-4d92-8e8b-5198e0f66142
            • +
            • + This is item 9e4dbcc4-4afe-44e0-8b58-f97beb15f526 +
            • +
            • This is item 45486da8-367b-4725-82bd-0da52c7026a4
            • +
            • + This is item f4361c20-472a-4867-80c3-21deddfbeb3d +
            • +
            +
              +
            • + This is item 269f6c03-ccd1-4122-b452-f3d0aa63cb53 +
            • +
            • This is item eb36dd6a-b1f6-4c81-b8b2-e9eff1af9c53
            • +
            +
          2. +
          3. +
              +
            • + This is item d7f1babf-f7de-4e67-9c56-938e31f94fb7 +
            • +
            • + This is item e432dabd-9a91-4277-813a-e196e7c79a2e +
            • +
            • + This is item bf28a174-8700-4c44-8eba-3e1e7abb672c +
            • +
            • + This is item 1d215cde-6de7-4ec6-8ddd-4db2d01b961c +
            • +
            • + This is item 55f92724-681b-4d48-9284-f6ed038c8b5c +
            • +
            +
              +
            1. + This is item 01c29adb-8234-48e8-bea6-22589ea8aaf4 +
            2. +
            3. + This is item 15c0dae2-5be4-4c0d-9827-5fd6a1a857ce +
            4. +
            5. + This is item a857ecee-7d18-4918-a0e3-d6a46748bb2a +
            6. +
            7. + This is item 2c14b1c7-4932-4f30-8c06-6de7a40114f6 +
            8. +
            9. + This is item a526cd9a-8b0f-43bf-a9e8-809538684e2a +
            10. +
            +
              +
            1. + This is item a945d582-2e27-4338-b3bd-2742e7f3b4ef +
            2. +
            3. + This is item d574e4a5-3e93-4c27-adc9-01a4ec8f7683 +
            4. +
            5. + This is item 3c000c0e-900e-4086-9e1c-fda41eff8c81 +
            6. +
            7. + This is item 296b5cc4-2d95-411a-91ec-78e506f0aeaf +
            8. +
            9. This is item e7329e7a-270e-4245-ae59-d2448ba4dd46
            10. +
            +
          4. +
          5. + Profile picture of user 439a9dce-52c7-4958-a6f5-21f0c899527c +
          6. +
          7. +
              +
            1. + This is item 3f7452fd-97aa-4f16-89c5-3c3d5c3e4b67 +
            2. +
            3. + This is item 108c3718-b94d-4aa4-a478-905631d04b0d +
            4. +
            5. This is item 29796ba9-c9f4-43ff-a148-a05d6593c4bc
            6. +
            + + This is link f4fa01df-4632-4eee-a4cf-9159c954412d + +
              +
            1. + This is item aef28e78-2f27-4389-902d-cef41dc6ae94 +
            2. +
            3. This is item ae8744e7-6b3c-4a1f-b26e-2d84807850ce
            4. +
            5. This is item 19f756c8-d168-4ec0-bc18-c6dafd02c99d
            6. +
            +
              +
            • + This is item a87cb38a-6c5e-4ccc-9c09-4377c9b34638 +
            • +
            • + This is item 764c1216-5c36-42e0-b52d-47faf61ba368 +
            • +
            +
          8. +
          9. +
              +
            • This is item 2ba85ffa-e465-4003-8cc2-28669af7be40
            • +
            • + This is item 7db5ffaf-cdf6-404d-986e-5f5a07a880d3 +
            • +
            • + This is item 72cc9ff6-3017-4571-a33e-09d41a7859f5 +
            • +
            • + This is item f5c5967b-cdae-4e04-b8fd-8e87ed9889f8 +
            • +
            +
          10. +
          +
        • +
        • + + This is link 5527db70-73d8-49eb-ad67-b29747b327af + +
            +
          1. + + This is link 7454bed8-7222-42ce-b419-2cf460982321 + + + This is link dcec0cf7-a859-4bdf-bcb0-af5ceb9f6a2b + +
              +
            1. This is item 66938766-65e7-4e0e-a511-339f0a33cb72
            2. +
            3. + This is item cc78f6e4-f2d1-427f-b508-f460ed6c1520 +
            4. +
            5. + This is item 88beaef2-5a33-4678-a525-bd9ef965fdef +
            6. +
            7. + This is item 94982416-f7d6-4399-88b7-e8f03ba29486 +
            8. +
            +
          2. +
          3. + Profile picture of user 999d59e1-3d4c-4a9b-9ead-5411bbbe8c0d + Profile picture of user 563e71df-a830-40fd-8ae2-17243ef47846 +
              +
            1. + This is item a13dcac9-bb50-4b00-9886-39ae13aa1901 +
            2. +
            3. + This is item e24cc336-44d0-45be-aa60-bb67f04e0e29 +
            4. +
            +
          4. +
          5. + Profile picture of user 347bbd2d-9dd1-4aad-a09e-08be08bd9046 +
              +
            1. + This is item f650924f-9eba-49aa-ae11-f6c9f72e9325 +
            2. +
            3. This is item f64a2440-63c7-4240-aaf3-8b6bc964bb1e
            4. +
            5. + This is item 5a42ee1a-dc80-4398-a445-efea6a9f630e +
            6. +
            7. + This is item 7b2d127a-66c6-4093-9b3e-4af7f79ad835 +
            8. +
            +
          6. +
          7. + Profile picture of user 0e0f6b2a-a613-4029-9593-8b72507766ba + +
              +
            1. + This is item 8fc349c6-21fc-49bb-bd83-433fcb5b5a98 +
            2. +
            3. + This is item 21e4ef21-a014-49b5-8132-31414db75c20 +
            4. + +
            +
          8. +
          + Profile picture of user b6ae2012-c398-42f8-a0f1-c8d13847f09b +
        • +
        + Profile picture of user 5ba8da39-2c11-4665-9997-a5c220143192 +
      10. +
      +
      diff --git a/oxide/crates/core/benches/fixtures/template-004.html b/oxide/crates/core/benches/fixtures/template-004.html new file mode 100644 index 000000000..5305278fe --- /dev/null +++ b/oxide/crates/core/benches/fixtures/template-004.html @@ -0,0 +1,1935 @@ +
      + This is link 01c5a0d6-f1d4-4d79-9b17-baac0a7ab391 + +
      + + + +

      + + This is link 49239d36-d46d-424a-9600-701b1cdeb0cb + + + This is link c2f9aed6-a254-4aaa-b175-790352f1d6f1 + +

      + + +
      + +
      diff --git a/oxide/crates/core/benches/fixtures/template-005.html b/oxide/crates/core/benches/fixtures/template-005.html new file mode 100644 index 000000000..c1a4ce491 --- /dev/null +++ b/oxide/crates/core/benches/fixtures/template-005.html @@ -0,0 +1,5 @@ + diff --git a/crates/oxide/src/fixtures/example.html b/oxide/crates/core/benches/fixtures/template-006.html similarity index 100% rename from crates/oxide/src/fixtures/example.html rename to oxide/crates/core/benches/fixtures/template-006.html diff --git a/oxide/crates/core/benches/fixtures/template-007.html b/oxide/crates/core/benches/fixtures/template-007.html new file mode 100644 index 000000000..8d80eb929 --- /dev/null +++ b/oxide/crates/core/benches/fixtures/template-007.html @@ -0,0 +1,6 @@ +
      + +
      diff --git a/oxide/crates/core/benches/fixtures/template-008.html b/oxide/crates/core/benches/fixtures/template-008.html new file mode 100644 index 000000000..cb2a02f9e --- /dev/null +++ b/oxide/crates/core/benches/fixtures/template-008.html @@ -0,0 +1,6 @@ +
      + This is text cd32f5df-af14-4332-9fd3-0e5589f8d457 + + This is link 05289860-74df-49a7-91dd-1a3a0d215bc3 + +
      diff --git a/oxide/crates/core/benches/fixtures/template-009.html b/oxide/crates/core/benches/fixtures/template-009.html new file mode 100644 index 000000000..7c1c29a35 --- /dev/null +++ b/oxide/crates/core/benches/fixtures/template-009.html @@ -0,0 +1,5 @@ +
      + + This is text bec24672-f081-47d1-8130-cc06506974b5 + +
      diff --git a/oxide/crates/core/benches/fixtures/template-010.html b/oxide/crates/core/benches/fixtures/template-010.html new file mode 100644 index 000000000..978745d41 --- /dev/null +++ b/oxide/crates/core/benches/fixtures/template-010.html @@ -0,0 +1,3348 @@ +
      + +
      + + +
      + + This is text 24221d94-bb69-4fd8-9100-2e0bd6662c87 + +
      +

      + This is heading 5d33bc2a-b185-422d-b55d-c17aac47f121 +

      +
      +
        +
      • + + This is link bc8a59bc-26e4-4074-8cee-fcfeae3352a1 + +
          +
        • +
            +
          1. + This is item 693d006d-417c-4e44-97ad-0e76a6509439 +
          2. +
          3. + This is item 8540dd39-382c-4965-a778-78ea1d7e21ca +
          4. + +
          5. + This is item 8d6b675e-0808-4121-8358-f254cce4949f +
          6. +
          7. + This is item 6571937e-3b09-493e-9819-880375d64c61 +
          8. +
          +
            +
          • + This is item cd8a916c-489a-405c-8204-3c7b8bfbe21a +
          • +
          • + This is item fe037e16-beee-4df5-8d56-27bc7c88b861 +
          • +
          • This is item d2c23001-7ecd-4d90-a04a-dffeb9b5e7bd
          • +
          • + This is item f71e889a-bba0-44ee-b8c6-7994ca3feadd +
          • +
          +
        • +
        • + Profile picture of user 72b68307-816c-47bc-8f62-8058a5581811 + + This is link f4f9814c-a3b7-40ab-9001-7b50926725e8 + +
        • +
        • + Profile picture of user 804da6b2-b21b-4a9c-98d6-23c6f59be6b1 +
            +
          1. This is item 488c7c4e-0c2c-4e2a-bedc-3c5ca0a682d8
          2. +
          3. This is item 2b59ac91-bf4f-4288-b3e1-74274b93711f
          4. +
          5. This is item 32b280f2-10de-4c69-ad88-c2df0bd0569a
          6. +
          + + This is link 701be519-d46e-4227-9a64-20b967687002 + + + This is link b50f4b2d-2574-47bc-b2d6-eb870a2afd71 + +
        • +
        • +
            +
          • + This is item 5707a843-bd7d-444e-bf92-e63923c6b0eb +
          • +
          • + This is item 32cbf474-92b1-4fc6-8ae4-8d4a239f9403 +
          • +
          • + This is item 9522936d-9611-4b39-8242-d3ac14572760 +
          • +
          + Profile picture of user 9ee96d5b-e135-44df-9b0e-2a005135a305 +
            +
          1. + This is item 63066c52-90a5-4852-8583-0d504fc75fc3 +
          2. +
          3. This is item 26ffbbca-d601-4e02-a032-c2ccb5f9de57
          4. +
          5. + This is item 81c48939-6eff-476b-baf7-865e09b63b1b +
          6. + +
          +
        • +
        +
          +
        • +
            +
          • + This is item e6cd16be-928b-4129-9296-e7a8252a0404 +
          • +
          • + This is item 699c4ae4-8e51-421c-acc2-8154878f97ac +
          • +
          • This is item b2d30a67-7cd2-4a0d-8dba-19b1492022ff
          • +
          • + This is item d75c4478-7a96-42de-97cd-0026f7db1c00 +
          • +
          +
            +
          • + This is item 8458c078-b26b-43f6-a246-c19286b82514 +
          • +
          • + This is item b1446e9f-603d-49f9-90d7-03966010b3b2 +
          • +
          • + This is item 05f1a3cd-94c9-4414-a2c4-7b945385479b +
          • +
          + Profile picture of user cc90d0b5-a2d0-4636-8827-6ef0351ed134 + + This is link 3075addc-d1f5-4255-84fd-edf691d8893a + +
        • +
        • +
            +
          • + This is item 2cc3ecc3-32ce-4877-9088-c2b0aad37024 +
          • +
          • + This is item cbdd7446-64cb-485e-9374-5011cfdef78a +
          • +
          +
            +
          • + This is item aa6719fc-b2b3-422f-8c31-4c9a339ef6de +
          • + +
          • + This is item 8c96cfa6-6fd2-41eb-a1b1-ead600396d16 +
          • +
          • This is item e11064a1-075d-4abe-bd2c-de8f276ec81d
          • +
          +
        • +
        • + Profile picture of user e35196e7-2786-42b4-96d0-023246295df7 + + This is link fb96df76-d670-4228-bc8f-097a1b131200 + +
            +
          1. + This is item e8920343-7762-49d2-93ec-7c650d39a4c2 +
          2. +
          3. + This is item feef74d5-f076-41e5-9873-ce28c10b8bdd +
          4. +
          5. + This is item bd83b50b-3a59-4c25-844d-4bafba3f7009 +
          6. +
          7. + This is item 5b23946b-05c3-4e20-81d2-3a2cd8836522 +
          8. +
          9. + This is item b8889163-1b40-4f1e-8602-b50e3554fa82 +
          10. +
          +
        • +
        +
      • +
      • +
          +
        1. +
            +
          1. + This is item 5813df3f-67b8-42ad-9940-c4e72ab9077a +
          2. +
          3. + This is item d5b742ac-97bd-4e57-b0d4-65f6371f27ef +
          4. +
          5. + This is item 1d32bc11-41a6-439b-9231-fc3dbd55ebcd +
          6. +
          + + This is link 2453be29-0759-43ca-a80d-6218376ded5f + +
            +
          1. This is item 5467e9de-481d-4427-bf96-21b69ecb0702
          2. +
          3. + This is item b8e42792-6876-4a13-9311-c8f3621478a5 +
          4. +
          5. + This is item c2e09ff4-945e-45b0-9729-190d272322da +
          6. +
          7. This is item 564507ad-8cc8-40d1-ae33-9cacaaeca1f1
          8. +
          9. + This is item eb277196-6a61-44ff-8caf-c20fd0f4281b +
          10. +
          + Profile picture of user 9aac5adb-ddd1-47f9-87c4-7004b29cb1f1 +
        2. +
        3. +
            +
          • + This is item 0ba3007e-dad0-4510-9bf6-1cc142283ea8 +
          • +
          • + This is item b6e71161-e922-44fe-8a10-d2d9f6be8344 +
          • +
          +
            +
          1. + This is item 323670ab-6780-4a25-a1af-378926293081 +
          2. +
          3. This is item 1eec22df-6fd6-4c86-b417-8f4683f3a969
          4. +
          5. + This is item 825ca49f-589c-4b97-bf02-5f4edee7b3c4 +
          6. +
          + + This is link adfc3c22-efe1-4c16-8fe3-17267aa07e7f + +
            +
          1. This is item 136caf51-9340-4b93-ad61-dba2f5a4c6c0
          2. +
          3. + This is item 8aed2aa5-bef5-4f88-be8f-8629c46a0c07 +
          4. +
          +
        4. +
        5. +
            +
          1. + This is item a15f4d46-a55a-44d7-a4f4-2fa3311e3028 +
          2. +
          3. This is item a2446b4e-47c5-42ab-9827-dfb04c7fea38
          4. +
          5. + This is item c35eae4f-7f90-4c5e-985d-da93b98f97c2 +
          6. +
          7. + This is item f4961c8e-7d54-4f97-8e4d-887ee496cf5d +
          8. +
          + + This is link fc7f315c-2827-42c1-aaec-981a64040dfe + + Profile picture of user 9e400cbb-01ee-4f8c-96b3-e1703a95612f +
        6. +
        7. + + This is link 060007bf-65ff-446b-bd2a-712538b318e5 + +
            +
          • This is item f84cf1f7-1875-4d23-8980-dd8dcf5a2ffd
          • +
          • This is item ed01e20a-4e98-401a-a057-1ba5442a454d
          • +
          • + This is item 1ff84e80-ad9a-405b-a255-8d004d908d10 +
          • +
          • + This is item 16d64dab-15e5-4143-8737-ce12060aee17 +
          • +
          +
            +
          • This is item a8bf6c28-c727-4377-ae04-e85d2d91c0b0
          • +
          • This is item 9cbb3e55-f9c6-40db-a17c-963c31073806
          • +
          • + This is item d913a2a7-dcbe-45b2-ab11-6475de76eb7e +
          • +
          • + This is item 76f0258f-d0ac-4303-9049-76bcab5974c1 +
          • +
          • This is item 9429b060-d901-4d1b-831b-d175cfbc62c0
          • +
          +
            +
          1. + This is item 8894dcea-5326-4d85-9e76-8134ea2f779b +
          2. +
          3. + This is item ddfdb4d2-e754-485c-9106-5430116227e9 +
          4. +
          +
        8. +
        9. +
            +
          • + This is item 0a1f9df5-bf6f-48d3-858a-655766f9b593 +
          • +
          • + This is item d247cd63-387d-4886-b8f1-8e325d7c8805 +
          • + +
          • + This is item a46433ec-55e9-4e61-b08e-97e881fd4d4c +
          • +
          + + This is link 8dcf8de6-3aae-48cb-9ac9-854e6f3f762d + + Profile picture of user e45be800-b792-4389-935b-94eb6ef77b88 + + This is link db68407c-90d6-4fba-b9cc-4b500f6abb72 + +
        10. +
        +
          +
        1. +
            +
          1. + This is item e23bfb73-7c53-4c20-9272-9eddac5b1eb2 +
          2. +
          3. This is item f7c0fec6-2908-44ae-9ecb-17c3b34c0b06
          4. +
          +
            +
          1. + This is item 52e95eab-0179-41d6-a05e-dedd8ecf2b7f +
          2. +
          3. This is item 6b71818e-b6fe-4afe-b147-d5fc44c330ba
          4. + + +
          +
            +
          • + This is item 9f08ad1f-7c5a-46be-ba13-75a99b23184b +
          • +
          • This is item 78429ef9-0157-415b-af5c-948aa223d484
          • +
          • This is item 5765c708-ee5c-4695-ad7c-61bd07a599af
          • +
          • This is item 1389803c-5043-4936-80bb-88d0fd6240bd
          • +
          • + This is item 825d5e55-cc34-4431-ba1c-298977a5520d +
          • +
          +
        2. +
        3. +
            +
          1. + This is item fb8d394d-68a4-43fe-b00f-2a6d32bfa2d2 +
          2. +
          3. + This is item 5b4fb812-14da-495d-81be-bc76f52deefd +
          4. +
          5. This is item 105a4a77-4ef5-46cb-bebd-678111d26631
          6. +
          7. + This is item 12931611-03c7-492b-b52c-efca42592600 +
          8. +
          9. + This is item 23acf155-a467-4b83-9beb-6f61e617887d +
          10. +
          + + This is link 433081d7-06a0-487d-95de-bcea62310c88 + +
        4. +
        +
      • +
      • + Profile picture of user f8a6675f-0d50-45a9-98fe-f84a763f0fd9 +
          +
        • +
            +
          1. + This is item 4edfc0f2-be48-48cb-bda7-54f1bc38a2cb +
          2. +
          3. This is item c9225386-0bdc-4916-8ed8-17dde429dc9a
          4. + +
          5. This is item ff35553c-d3cc-4fee-bd54-3bfa5e803822
          6. +
          +
        • +
        • +
            +
          • + This is item c1d2769f-f4fc-4f58-8c5e-474a12741d7a +
          • +
          • + This is item 229c3677-c8d6-40c9-bfdb-8bd793390aea +
          • +
          +
            +
          1. This is item 97049a8f-b905-4310-ae15-60fc4e0a24e6
          2. + +
          3. This is item a23469ca-cbb7-4f90-8257-d91f60f35856
          4. +
          5. + This is item a2cc1674-6132-46df-9e37-a903741952af +
          6. +
          +
            +
          1. + This is item f2d4e4dc-c8c1-4160-ae8f-369304e281cb +
          2. +
          3. + This is item a797b5e7-22dc-4f8e-8339-93433062c0a5 +
          4. +
          + +
        • +
        • + + This is link efef93ac-c91f-4848-ab50-52f9cd2390dc + +
            +
          • + This is item fd518bad-0c90-42a8-b5d4-b109892310cf +
          • +
          • This is item c5279e23-48bd-423d-8373-652e22a1ceae
          • +
          • + This is item da69af84-15a5-45da-9be2-dd83d41e3890 +
          • +
          +
            +
          • This is item f8454342-de20-44ef-82d4-67405bf52279
          • +
          • This is item eadb097a-a49e-456e-866b-e9294be447b9
          • +
          • + This is item eee50dfc-7bc5-4eb3-9e07-7c2def0291cf +
          • +
          +
        • +
        • + + This is link 72c48bb3-665a-47dc-a724-4a58eb316182 + + Profile picture of user 56190809-8655-40fc-9796-a69de7755e56 +
            +
          • + This is item 0130e52f-bae1-4378-b349-1f171e2b6a12 +
          • +
          • + This is item 14579923-68e2-4f38-a467-9406afc562cd +
          • +
          • This is item 14b2a269-b6de-4c63-b632-e610d0c4377a
          • +
          • + This is item 0ab9da94-2772-4445-8f38-ebc2bfe6b719 +
          • +
          • This is item ef187746-f640-4430-8538-d5a7f4ccbbf4
          • +
          +
        • +
        +
          +
        • +
            +
          1. + This is item 7d863aa3-3c42-4cea-8bfd-3727b1732500 +
          2. +
          3. + This is item 2e5ed598-8235-47f1-a25b-ebddc473262f +
          4. +
          5. This is item bac9381b-d0a7-42ed-a7d9-62d339e99de3
          6. +
          +
        • +
        • + Profile picture of user 13ee422c-ee54-4e6e-aaf9-09f9c32f29f4 + + This is link 9e259fd8-71a4-4b00-9b70-b353fb86342e + +
        • +
        • + + Profile picture of user 9e5c63aa-d2a7-4b8e-8bc0-f1da09b8ea0d +
        • +
        • +
            +
          1. This is item d330ab08-2049-43e3-b5b6-438e442240be
          2. + +
          3. + This is item 92b399f1-6b83-41c9-8b9f-1e68b97614aa +
          4. +
          5. This is item dbe4d232-41b5-4acd-b5ce-f3488baa582e
          6. +
          7. + This is item 5a340e20-3c38-4781-98cf-d9c915950b42 +
          8. +
          +
            +
          • + This is item 59b9d368-0f29-48ea-8ea9-754bd6577575 +
          • +
          • + This is item 4e001101-9716-4ac8-955a-6797ee72b2c5 +
          • +
          +
            +
          1. + This is item 2d8d6b81-a5df-495c-a4d2-add24a2f36fe +
          2. +
          3. + This is item 5b908588-6f88-41cd-831b-4f980267cf9a +
          4. +
          +
            +
          1. This is item 5e446ea1-e21d-40cf-8618-09db19dea1d2
          2. +
          3. This is item 880ac8a6-a3ec-42c0-9687-03334343a3f6
          4. +
          5. This is item 11fb3678-dfc8-47bb-8f8d-10952b482227
          6. +
          +
        • +
        +
      • +
      +
      + + This is text 84a258b5-b9ca-4b73-bc0f-eb41c7b40e15 + + + + This is text e1ba7431-52ae-49b1-9f0a-608fcc1e27ce + +
      +

      + This is paragraph a4c8ae07-7ae6-46b8-83a6-0c896ffa3cd3 +

      +
      + +
      +
      +

      This is heading 4fb53825-6907-4a33-9e20-578e1029c887

      + + +
      +
      + +

      + This is heading 3634d4e3-8751-4f22-93b4-da67eebb3b05 +

      + + +
      + + + + This is link aad5bb80-144e-421d-977d-f3d187d22b2e + + This is text 25671534-232f-4b19-a0ef-f0b389de26a0 +
      +
      +
        +
      1. + +
          +
        • + Profile picture of user d780b28a-59f7-41ff-ad18-30aadfd35d44 +
            +
          • This is item e2c194b0-0f42-4e46-af20-71085b154ef8
          • +
          • + This is item e16e778b-dd33-4467-8f36-7e6e63ea35e0 +
          • +
          • + This is item 0475e277-fd09-42d7-9725-3449a1833307 +
          • +
          • + This is item 4722d042-5e29-4e36-b0b0-9e43329ee693 +
          • +
          + Profile picture of user 1204c8dd-13dd-45bc-b4e3-a1e476290357 + + This is link d791afc0-cef6-4ce8-9b66-0195af5b9ea5 + +
        • +
        • + Profile picture of user 3dd3714c-faf1-454c-a6b9-be06b6d47192 + Profile picture of user c3c148cf-3019-4254-a0c3-f73bb121caf9 + Profile picture of user 93013497-22d7-432b-be10-dc8f0a9d503b + + This is link acbc2a94-44ab-4a4b-820b-d6711bc2e374 + +
        • +
        • +
            +
          • + This is item 724f131c-3667-4037-b48a-54a5b11c964a +
          • +
          • + This is item fe54d9d4-550a-443c-82c4-c8f3bb683d6d +
          • +
          +
        • +
        • +
            +
          • + This is item 43376b98-e01a-4304-b401-88f2e8fa5774 +
          • +
          • + This is item 6536af3b-cc07-4daf-8aeb-50a1c0906145 +
          • +
          • + This is item 53f28a1e-55f1-4474-9afe-ee6d5586a79d +
          • +
          +
            +
          • This is item 96a607bf-bf65-48a9-a592-ac2bc08dd77b
          • +
          • This is item 42c30884-cc7e-46ee-9c24-cb1c60986a90
          • +
          • + This is item 8aad1010-9d41-441e-89ab-01c9f652e83a +
          • +
          + Profile picture of user 79b71af1-baed-474f-93be-09c8e1f13b82 +
        • +
        • + Profile picture of user 259d53d5-eb0c-440c-a235-f05e6f3534bc +
        • +
        +
          +
        1. + + This is link 13d5b4f3-51fd-4a05-80dd-8138be83e9ad + +
        2. +
        3. + Profile picture of user 71252e8c-acc5-4bbd-bbf6-d756b683ce2b + + This is link 552d5a72-779e-4f90-be9f-3ceeadfefcfe + +
        4. +
        5. + + This is link 1e2cd04b-6d8b-4a52-9642-e524ef12745d + +
        6. +
        7. +
            +
          • + This is item 8c1f2b68-ec28-4fe4-98d6-14a0303c6782 +
          • +
          • + This is item 18925a0d-3a6b-440e-ad92-abace9442f78 +
          • +
          • + This is item e0cacfc3-c7d7-4558-b043-268e9ff61274 +
          • +
          • + This is item f45a2400-f08e-43cc-ae87-6a9ecf5449c5 +
          • +
          +
            +
          • + This is item 9a676bff-0d21-4fa4-99c4-eb1717439e88 +
          • +
          • This is item 0393f640-dfa0-453c-be34-69dbf36937b8
          • +
          +
            +
          • This is item a7f6e04b-1d75-4f01-9da9-dd664427e83f
          • +
          • + This is item 14757b31-bf98-4486-966c-e7ec8fba30dc +
          • +
          • + This is item c5fc3f2b-2466-4396-bd5d-b39271bd4716 +
          • +
          +
        8. +
        +
      2. +
      3. + +
      4. +
      5. + + This is link d2599ecf-b5a1-4887-af51-0a1932277ef2 + + Profile picture of user 08b2d468-83df-4c19-b98c-017f13473d9d +
          +
        1. + Profile picture of user 6571c381-e1db-48af-8cef-3727e81795eb +
            +
          1. + This is item 4a76062e-f6ab-4b8d-b4e1-1dcb17e4531e +
          2. +
          3. + This is item a67f9538-8c13-4147-a405-218b07232377 +
          4. +
          +
        2. +
        3. +
            +
          • + This is item 0165d9c7-b39c-4db2-b307-a22545c1534c +
          • +
          • + This is item 66370802-d29e-4760-a066-0b58ca86f226 +
          • +
          +
            +
          1. + This is item 29977110-8d73-47e3-b243-d0bbdfb82651 +
          2. +
          3. + This is item 2aa424b1-e0d7-4995-92e8-546869bd9705 +
          4. +
          5. + This is item eaba2295-68c7-4a65-a093-754f298d0f57 +
          6. +
          + Profile picture of user a7f9cd91-ee03-4bdb-a93a-2edb1a399e66 + + This is link bde76eb0-1e26-4afe-9b8d-d92dacc6688d + +
        4. +
        5. +
            +
          • + This is item 114b2be6-0e4f-40c7-abd6-d7f6843a3cb5 +
          • +
          • + This is item e5754c30-c7ae-4631-930c-95383def0a75 +
          • +
          +
            +
          • + This is item 9ce5c3dd-6371-4b36-ac5f-3f312f17535f +
          • +
          • + This is item 2ff10843-13ff-4734-be13-ca5d7d339dec +
          • +
          • + This is item 31cb981a-5f2b-4288-9111-80075b57b52a +
          • +
          + + This is link 78d7a8b5-a738-4455-aa84-8bf3decadad2 + +
            +
          1. This is item 329126e4-a11f-40a6-924e-5945f80cb287
          2. +
          3. + This is item 7f163f33-797b-4e5f-b396-829c33676717 +
          4. +
          5. + This is item 82cc7709-199a-447c-ad81-dccecc6e19a5 +
          6. +
          7. + This is item 347a2ce5-75da-498f-80f9-952ee124ebb4 +
          8. +
          +
        6. +
        7. +
            +
          1. + This is item b1b91632-a2d2-4f30-8a35-c6d9874d2b2d +
          2. +
          3. + This is item 367fa66c-936e-48e3-9cbe-b1e2bc7c8f96 +
          4. +
          5. + This is item 391cbdae-105b-4a64-884a-886fd1abc756 +
          6. +
          7. + This is item db8acafa-d350-4757-8603-5e5cce824414 +
          8. +
          + Profile picture of user 429411aa-e9da-41ae-b2d9-6cc3eb9f3b5e + Profile picture of user 1128aa4e-67cc-400c-96ff-a71851cc0493 +
            +
          • + This is item 1e78bb79-0846-4a98-ae25-b3fd325ffdb9 +
          • +
          • + This is item e562f003-b36c-478b-9ff8-41c3db844dce +
          • +
          • + This is item ea103fd1-0fbb-4443-8739-890937166f7e +
          • +
          +
        8. +
        + Profile picture of user 2b62b56c-21ae-4c84-b639-0b093cbcba0a +
      6. +
      +
        +
      1. + Profile picture of user 3613fdc6-d257-47e9-913a-91b358607c14 + Profile picture of user 2f515104-ea87-4cf3-88c2-19634520ca0a + This is link d3be9080-4676-4809-b745-c5d8879c47c2 +
      2. +
      3. + Profile picture of user 97d9415f-27f8-4ffc-b4dc-eb9672f586fc +
      4. +
      5. + + This is link 0408d7b2-a595-4fd5-9ab0-b302cc6e7418 + + +
      6. +
      7. +
          +
        1. +
            +
          • + This is item 114829a6-a705-4104-8818-76b08982b678 +
          • +
          • + This is item cdb275b7-c27e-4af9-822a-b00687fb5479 +
          • +
          • This is item 248fd6c7-e955-40de-b6b0-865a2e988cd2
          • +
          + + This is link 95194aba-9177-4020-9832-d3c65d839e63 + +
            +
          • + This is item d88c898c-286d-4d87-bf6d-472c7ec111f9 +
          • +
          • This is item cba62412-5c11-43ea-b3fa-caa49c0246f9
          • +
          • + This is item 9cd30f2d-448e-41f9-8c6b-cf58a615297a +
          • +
          +
            +
          1. + This is item a28dd7de-34e4-46ba-81db-6eb59b69d981 +
          2. +
          3. This is item 006b90b3-bfcf-4168-bba8-04f42fb84720
          4. +
          5. This is item c804d57d-9793-4836-a13b-f7248a6d7aef
          6. +
          +
        2. +
        3. + + This is link 7bf56a82-847e-4f1c-a0ca-32610961f0e2 + +
        4. +
        +
          +
        1. + + This is link 8d501524-fd5c-46c2-8100-5aae8f16b819 + + + This is link 1f61391e-0760-47e2-aaa7-1364e485331d + +
            +
          1. + This is item fab91026-b8b8-4373-9e4c-7735987940d9 +
          2. +
          3. + This is item 2e02d6fe-0d26-4aa8-8664-1b8395a11230 +
          4. +
          5. + This is item 7644c78e-9a6a-42ae-89de-e53ea7be3f32 +
          6. +
          +
            +
          • + This is item 72e61f48-0d25-4920-8e72-1a52c2a750a5 +
          • +
          • This is item 337d9aef-6a85-46c6-958b-34e984315f6c
          • +
          • + This is item bcd6fc22-536d-4098-9810-f9287e012b46 +
          • + +
          +
        2. +
        3. + +
            +
          • This is item 3810607b-9173-4570-9493-aad902f2bef2
          • +
          • + This is item e6712919-16a8-406f-ab00-73499cfd72aa +
          • +
          +
            +
          • + This is item d5c9b17f-ffea-4758-9da4-1e82867dd893 +
          • +
          • This is item 4eca4e3d-b5c1-443e-953f-3dceea5749c3
          • +
          • + This is item 409d34fd-317a-4cf0-ab53-1eccc82dcdcc +
          • +
          +
        4. +
        5. + Profile picture of user 602e6b5b-511a-4b28-8493-b60a6fd75cff +
        6. +
        7. + Profile picture of user 7abcf285-084c-4541-a381-e30f48ffaa20 + Profile picture of user 0534350c-0095-4f9e-a37f-6fcec406c581 +
        8. +
        + + This is link c12be547-da06-4a92-a8c5-957ac0c0f3c1 + +
      8. +
      9. +
          +
        1. + + This is link aae63805-2525-4f31-8558-e242f1fcd26b + +
        2. +
        3. +
            +
          1. This is item 671e0f8c-e3f6-4813-a393-5ec420cad226
          2. +
          3. + This is item 3ff28496-0c3e-46c7-a03d-32ee1fd35f9f +
          4. +
          5. + This is item 5f970434-6fdb-43c0-8bf8-356b53d04774 +
          6. +
          +
            +
          1. + This is item af5094cc-7572-48b0-8685-308da8f4b1c4 +
          2. +
          3. + This is item 3de52000-35be-4fef-b8de-02a77a86540f +
          4. +
          5. + This is item a463d1ac-02b1-4f33-8bba-44d5de36c236 +
          6. +
          + + This is link 4b31b90a-8d78-4e0c-8fee-3ca3209504c5 + + + This is link 4bf9c53f-2980-494c-ba71-e549c27d2e17 + +
        4. +
        5. + Profile picture of user eb438e00-ba60-4754-80e0-096e5b68d525 +
        6. +
        7. +
            +
          1. This is item 54668f06-61f1-41da-bbe8-78c81096d40e
          2. +
          3. + This is item 48c32f11-9004-4fc2-8093-f302143b058f +
          4. +
          5. This is item 85830811-dfae-4786-9155-bfd645a60547
          6. + +
          +
        8. +
        + + This is link 3a3031cc-7999-4973-ad65-afd82aebcb43 + + Profile picture of user b1315998-80e4-427a-83e9-443f4b7d585b + +
      10. +
      + + This is link 6354f18e-547d-491e-8175-9a92b0a8a94b + + + +
      + Profile picture of user 7a7e459e-242a-4d25-94c8-55ba28325a19 + + This is link 0179323a-dba5-4f74-8224-9ffc3f2bc303 + + + This is link 59887750-572f-4c76-a829-b253fa34cfcf + +
        +
      1. + +
          +
        • +
            +
              +
            • + +
            + + This is link 0837b434-12c8-41a9-a8eb-0a313bd9c46b + +
          1. +
          2. + +
              + +
            1. + + + This is link 2614d94b-3d5e-4262-897c-28559a915aea + +
            2. +
            3. + + This is link 8fa5f04b-e268-4880-9b3a-494ca3c046bf + +
            4. +
            5. +
                + Profile picture of user 567d51bf-d083-46db-8eb4-0f9788010153 +
              1. +
              2. +
                  +
                    + + This is link fea48002-54fc-405f-b419-3db070403087 + +
                      +
                    1. +
                    + + This is link 9293c169-559c-4794-9f67-a6a2485dc75c + +
                  • +
                  • + Profile picture of user 0b915945-71ce-42ed-b638-018b677a87b6 +
                  • +
                  +
                  +

                  + This is text 495dd7d9-9f9b-480a-b331-8c411d853427 + + This is text 0533e18e-49dc-4d1c-a54f-e0bd121f042e + +

                  +
                  + Profile picture of user 3229ae0c-7e85-4163-a5bb-0eae924b3bf1 +
                    +
                  1. +
                      + Profile picture of user e80623b6-64e5-4b17-a323-cca8b7a08d13 + + This is link a1ec646f-49b2-4e0c-826d-85f6d1e48e4a + + Profile picture of user f27f4d43-d818-4173-b2d1-4daf346cc442 +
                    • +
                    • + Profile picture of user 6ddad74b-9773-4d3b-ab83-d1f437028140 + + This is link 33a4a214-40ce-49f4-b2ac-4df4e98eebb3 + + Profile picture of user b06eabce-8402-4a5f-926a-eb7409a060e3 + Profile picture of user 4f7ab64e-e3a7-4918-886b-917e7733f25c +
                    • + +
                    • + Profile picture of user 3cc6a489-6ce6-4fa7-9c58-a5702f07ad16 +
                        + + This is link 1feeab52-35d9-42f7-a710-85f661ce7ca7 + + Profile picture of user 5cabcc27-35e9-47c3-9738-c10e4f74bd3a +
                      1. +
                      2. +
                          +
                        • +
                        +
                        + + +

                        + + This is text 5b96273a-c758-4ae4-a19b-4d416b21d8c7 + + + This is link 2c2990be-368e-4aa6-8f11-6b24888fbcf3 + +

                        + + +
                        +
                        +
                        + +
                        +
                        +
                        +
                        +

                        + This is heading b1c1981a-61e6-480a-939d-424eb639dfff +

                        +
                        +
                        + Profile picture of user ce70952c-6830-4b53-b638-bc81064d38a9 + +
                          +
                          + This is text fd9fbaa9-86e3-4ffe-954a-5be14de18d54 +
                          + +
                          + +
                            +
                          1. + + This is link 8aa64400-12e8-41eb-b6e6-b26356ad3bfe + +
                          2. +
                          3. +
                              +
                            • + This is item e2131780-3b9c-4578-9918-a463d2361bc0 +
                            • +
                            • + This is item 5ec4c3aa-6159-4e3e-b6a6-771fab824ee8 +
                            • +
                            • This is item 25651510-b575-4293-93e8-92c11296c663
                            • +
                            +
                              +
                            • + This is item b0e9dc7d-a031-41be-a4b5-5cd8f219462c +
                            • +
                            • This is item 7a7548c7-f04e-4723-b51c-851de4417829
                            • +
                            • + This is item d6a6296e-e06b-407f-b5d2-9148b014c4fa +
                            • +
                            • + This is item cab8d7c8-2c34-4d20-a7d2-c72500d2b360 +
                            • +
                            • This is item 88f9eb9a-8c93-46f8-8b5d-81667db0ce7e
                            • +
                            +
                              +
                            1. This is item 0b1d6523-32f0-4270-bc82-094f37c191e0
                            2. +
                            3. + This is item f807b468-b8ff-49d6-a2a6-91d615364ce4 +
                            4. +
                            +
                          4. +
                          5. +
                              +
                            1. This is item 5651f9dc-57e0-46f4-bab8-1af15baef811
                            2. +
                            3. This is item c392f166-a536-4b62-b289-5f0222dff24c
                            4. +
                            5. + This is item 2f37915b-e155-4c73-b754-eb49a391bb69 +
                            6. +
                            +
                          6. +
                          +

                          This is paragraph 290a7a29-9bf3-4364-a2ab-b898457fb3dd

                          +
                          +
                          +
                          +
                          + + +
                          diff --git a/oxide/crates/core/benches/parse_candidates.rs b/oxide/crates/core/benches/parse_candidates.rs new file mode 100644 index 000000000..12e476864 --- /dev/null +++ b/oxide/crates/core/benches/parse_candidates.rs @@ -0,0 +1,46 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use tailwindcss_core::parser::Extractor; + +pub fn criterion_benchmark(c: &mut Criterion) { + fn parse(input: &[u8]) { + // _ = Extractor::all(black_box(input), ExtractorOptions { preserve_spaces_in_arbitrary: false }); + Extractor::unique(black_box(input), Default::default()); + } + + c.bench_function("scan_files (simple)", |b| b.iter(|| parse(b"underline"))); + + c.bench_function("scan_files (with variant)", |b| { + b.iter(|| parse(b"hover:underline")) + }); + + c.bench_function("scan_files (with stacked variants)", |b| { + b.iter(|| parse(b"focus:hover:underline")) + }); + + c.bench_function("scan_files (with arbitrary values)", |b| { + b.iter(|| parse(b"p-[20px]")) + }); + + c.bench_function("scan_files (with variant and arbitrary values)", |b| { + b.iter(|| parse(b"hover:p-[20px]")) + }); + + c.bench_function("scan_files (real world)", |b| { + b.iter(|| parse(include_bytes!("./fixtures/template-000.html"))) + }); + + let mut group = c.benchmark_group("sample-size-example"); + group.sample_size(10); + + group.bench_function("scan_files (fast space skipping)", |b| { + let count = 10_000; + let crazy1 = format!("{}underline", " ".repeat(count)); + let crazy2 = crazy1.repeat(count); + let crazy3 = crazy2.as_bytes(); + + b.iter(|| parse(black_box(crazy3))) + }); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/oxide/crates/core/benches/scan_files.rs b/oxide/crates/core/benches/scan_files.rs new file mode 100644 index 000000000..83af3c720 --- /dev/null +++ b/oxide/crates/core/benches/scan_files.rs @@ -0,0 +1,63 @@ +use criterion::{criterion_group, criterion_main, Criterion}; +use std::path::PathBuf; +use tailwindcss_core::{scan_files, ChangedContent, Parsing, IO}; + +pub fn criterion_benchmark(c: &mut Criterion) { + // current_dir will be set to ./crates/core + let fixtures_path = std::env::current_dir() + .unwrap() + .join("benches") + .join("fixtures"); + + let mut all_files: Vec<(u64, PathBuf)> = std::fs::read_dir(fixtures_path) + .unwrap() + .filter_map(Result::ok) + .map(|dir_entry| dir_entry.path()) + .filter(|path| path.is_file()) + .filter(|path| match path.extension() { + Some(ext) => ext == "html", + _ => false, + }) + .map(|path| (path.metadata().unwrap().len(), path)) + .collect(); + + // Let's sort them first so that we are working with the same files in the same order every + // time. + all_files.sort_by(|a, b| a.0.cmp(&b.0)); + + // Let's work with the first middle X items (so that we can skip outliers and work with more + // interesting files) + let amount = 300; + let mut files: Vec<_> = all_files + .iter() + .skip((all_files.len() - amount) / 2) // Skip the first X, so that we can use the middle + // {amount} files. + .take(amount) + .map(|(_, path)| path) + .collect(); + + // Two (or more) files can technically have the exact same size, but the order is random, so + // now that we are scoped to the middle X files, let's sort these alphabetically to guarantee + // the same order in our benchmarks. + files.sort_by(|a, b| a.file_name().cmp(&b.file_name())); + + let changed_content: Vec = files + .into_iter() + .map(|file| ChangedContent { + file: Some(file.to_path_buf()), + content: None, + }) + .collect(); + + c.bench_function("scan_files", |b| { + b.iter(|| { + scan_files( + changed_content.clone(), + Parsing::Parallel as u8 | IO::Parallel as u8, + ) + }) + }); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/crates/oxide/fuzz/.gitignore b/oxide/crates/core/fuzz/.gitignore similarity index 100% rename from crates/oxide/fuzz/.gitignore rename to oxide/crates/core/fuzz/.gitignore diff --git a/crates/oxide/fuzz/Cargo.lock b/oxide/crates/core/fuzz/Cargo.lock similarity index 99% rename from crates/oxide/fuzz/Cargo.lock rename to oxide/crates/core/fuzz/Cargo.lock index 7c7ebc7a8..4b87adb77 100644 --- a/crates/oxide/fuzz/Cargo.lock +++ b/oxide/crates/core/fuzz/Cargo.lock @@ -382,7 +382,7 @@ dependencies = [ ] [[package]] -name = "tailwindcss-oxide" +name = "tailwindcss-core" version = "0.1.0" dependencies = [ "bstr", @@ -399,11 +399,11 @@ dependencies = [ ] [[package]] -name = "tailwindcss-oxide-fuzz" +name = "tailwindcss-core-fuzz" version = "0.0.0" dependencies = [ "libfuzzer-sys", - "tailwindcss-oxide", + "tailwindcss-core", ] [[package]] diff --git a/crates/oxide/fuzz/Cargo.toml b/oxide/crates/core/fuzz/Cargo.toml similarity index 83% rename from crates/oxide/fuzz/Cargo.toml rename to oxide/crates/core/fuzz/Cargo.toml index b402014a9..221bb40d4 100644 --- a/crates/oxide/fuzz/Cargo.toml +++ b/oxide/crates/core/fuzz/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "tailwindcss-oxide-fuzz" +name = "tailwindcss-core-fuzz" version = "0.0.0" publish = false edition = "2021" @@ -10,7 +10,7 @@ cargo-fuzz = true [dependencies] libfuzzer-sys = "0.4" -[dependencies.tailwindcss-oxide] +[dependencies.tailwindcss-core] path = ".." # Prevent this from interfering with workspaces diff --git a/crates/oxide/fuzz/fuzz_targets/parsing.rs b/oxide/crates/core/fuzz/fuzz_targets/parsing.rs similarity index 83% rename from crates/oxide/fuzz/fuzz_targets/parsing.rs rename to oxide/crates/core/fuzz/fuzz_targets/parsing.rs index 02935a20f..ac7a9fd79 100644 --- a/crates/oxide/fuzz/fuzz_targets/parsing.rs +++ b/oxide/crates/core/fuzz/fuzz_targets/parsing.rs @@ -2,9 +2,9 @@ use libfuzzer_sys::fuzz_target; use std::path::PathBuf; -use tailwindcss_oxide::candidate::scan_files; -use tailwindcss_oxide::candidate::Candidate; -use tailwindcss_oxide::location::Location; +use tailwindcss_core::candidate::scan_files; +use tailwindcss_core::candidate::Candidate; +use tailwindcss_core::location::Location; // fuzz_target!(|data: &[u8]| { // if let Ok(s) = std::str::from_utf8(data) { diff --git a/oxide/crates/core/src/cache.rs b/oxide/crates/core/src/cache.rs new file mode 100644 index 000000000..df0918b5f --- /dev/null +++ b/oxide/crates/core/src/cache.rs @@ -0,0 +1,57 @@ +use std::{path::PathBuf, time::SystemTime}; +use std::fs::{self}; +use fxhash::{FxHashMap, FxHashSet}; + +/// A cache to manage the list of candidates and the last modified time of files +/// in the project. This is used to avoid recompiling files that haven't changed. +#[derive(Default)] +pub struct Cache { + mtimes: FxHashMap, + candidates: FxHashSet, +} + +impl Cache { + pub fn clear(&mut self) { + self.mtimes.clear(); + self.candidates.clear(); + } + + pub fn add_candidates(&mut self, additional_candidates: Vec) { + self.candidates.extend(additional_candidates); + } + + pub fn get_candidates(&self) -> Vec { + let mut result = vec![]; + result.extend(self.candidates.iter().cloned()); + result.sort(); + result + } + + pub fn find_modified_files<'a>(&mut self, paths: &'a Vec) -> Vec<&'a PathBuf> { + // Get a list of the files that have been modified since the last time we checked + let mut modified: Vec<&PathBuf> = vec![]; + + for path in paths { + let curr = fs::metadata(path) + .and_then(|m| m.modified()) + .unwrap_or(SystemTime::now()); + + let prev = self.mtimes.insert(path.clone(), curr); + + match prev { + // Only add the file to the modified list if the mod time has changed + Some(prev) if prev != curr => { + modified.push(path); + }, + + // If the file was already in the cache then we don't need to do anything + Some(_) => (), + + // If the file didn't exist before then it's been modified + None => modified.push(path), + } + } + + modified + } +} diff --git a/oxide/crates/core/src/cursor.rs b/oxide/crates/core/src/cursor.rs new file mode 100644 index 000000000..0c422c6ff --- /dev/null +++ b/oxide/crates/core/src/cursor.rs @@ -0,0 +1,159 @@ +use std::{ascii::escape_default, fmt::Display}; + +#[derive(Debug, Clone)] +pub struct Cursor<'a> { + // The input we're scanning + pub input: &'a [u8], + + // The location of the cursor in the input + pub pos: usize, + + /// Is the cursor at the start of the input + pub at_start: bool, + + /// Is the cursor at the end of the input + pub at_end: bool, + + /// The previously consumed character + /// If `at_start` is true, this will be NUL + pub prev: u8, + + /// The current character + pub curr: u8, + + /// The upcoming character (if any) + /// If `at_end` is true, this will be NUL + pub next: u8, +} + +impl<'a> Cursor<'a> { + pub fn new(input: &'a [u8]) -> Self { + let mut cursor = Self { + input, + pos: 0, + at_start: true, + at_end: false, + prev: 0x00, + curr: 0x00, + next: 0x00, + }; + cursor.move_to(0); + cursor + } + + pub fn rewind_by(&mut self, amount: usize) { + self.move_to(self.pos.saturating_sub(amount)); + } + + pub fn advance_by(&mut self, amount: usize) { + self.move_to(self.pos.saturating_add(amount)); + } + + pub fn move_to(&mut self, pos: usize) { + let len = self.input.len(); + let pos = pos.clamp(0, len); + + self.pos = pos; + self.at_start = pos == 0; + self.at_end = pos + 1 >= len; + + self.prev = if pos > 0 { self.input[pos - 1] } else { 0x00 }; + self.curr = if pos < len { self.input[pos] } else { 0x00 }; + self.next = if pos + 1 < len { + self.input[pos + 1] + } else { + 0x00 + }; + } +} + +impl<'a> Display for Cursor<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let len = self.input.len().to_string(); + + let pos = format!("{: >len_count$}", self.pos, len_count = len.len()); + write!(f, "{}/{} ", pos, len)?; + + if self.at_start { + write!(f, "S ")?; + } else if self.at_end { + write!(f, "E ")?; + } else { + write!(f, "M ")?; + } + + fn to_str(c: u8) -> String { + if c == 0x00 { + "NUL".into() + } else { + format!("{:?}", escape_default(c).to_string()) + } + } + + write!( + f, + "[{} {} {}]", + to_str(self.prev), + to_str(self.curr), + to_str(self.next) + ) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_cursor() { + let mut cursor = Cursor::new(b"hello world"); + assert_eq!(cursor.pos, 0); + assert!(cursor.at_start); + assert!(!cursor.at_end); + assert_eq!(cursor.prev, 0x00); + assert_eq!(cursor.curr, b'h'); + assert_eq!(cursor.next, b'e'); + + cursor.advance_by(1); + assert_eq!(cursor.pos, 1); + assert!(!cursor.at_start); + assert!(!cursor.at_end); + assert_eq!(cursor.prev, b'h'); + assert_eq!(cursor.curr, b'e'); + assert_eq!(cursor.next, b'l'); + + // Advancing too far should stop at the end + cursor.advance_by(10); + assert_eq!(cursor.pos, 11); + assert!(!cursor.at_start); + assert!(cursor.at_end); + assert_eq!(cursor.prev, b'd'); + assert_eq!(cursor.curr, 0x00); + assert_eq!(cursor.next, 0x00); + + // Can't advance past the end + cursor.advance_by(1); + assert_eq!(cursor.pos, 11); + assert!(!cursor.at_start); + assert!(cursor.at_end); + assert_eq!(cursor.prev, b'd'); + assert_eq!(cursor.curr, 0x00); + assert_eq!(cursor.next, 0x00); + + cursor.rewind_by(1); + assert_eq!(cursor.pos, 10); + assert!(!cursor.at_start); + assert!(cursor.at_end); + assert_eq!(cursor.prev, b'l'); + assert_eq!(cursor.curr, b'd'); + assert_eq!(cursor.next, 0x00); + + cursor.rewind_by(10); + assert_eq!(cursor.pos, 0); + assert!(cursor.at_start); + assert!(!cursor.at_end); + assert_eq!(cursor.prev, 0x00); + assert_eq!(cursor.curr, b'h'); + assert_eq!(cursor.next, b'e'); + } +} diff --git a/crates/oxide/src/fast_skip.rs b/oxide/crates/core/src/fast_skip.rs similarity index 97% rename from crates/oxide/src/fast_skip.rs rename to oxide/crates/core/src/fast_skip.rs index 54930548c..488e61bd6 100644 --- a/crates/oxide/src/fast_skip.rs +++ b/oxide/crates/core/src/fast_skip.rs @@ -10,7 +10,7 @@ pub fn fast_skip(cursor: &Cursor) -> Option { return None; } - if !cursor.curr().is_ascii_whitespace() { + if !cursor.curr.is_ascii_whitespace() { return None; } diff --git a/crates/oxide/src/scanner/fixtures/binary-extensions.txt b/oxide/crates/core/src/fixtures/binary-extensions.txt similarity index 94% rename from crates/oxide/src/scanner/fixtures/binary-extensions.txt rename to oxide/crates/core/src/fixtures/binary-extensions.txt index d26c1c222..e4e7af7cf 100644 --- a/crates/oxide/src/scanner/fixtures/binary-extensions.txt +++ b/oxide/crates/core/src/fixtures/binary-extensions.txt @@ -3,6 +3,7 @@ 3g2 3gp 7z +DS_Store a aac adp @@ -37,7 +38,6 @@ cpio cr2 cur dat -db dcm deb dex @@ -51,7 +51,6 @@ docx dot dotm dra -DS_Store dsk dts dtshd @@ -66,7 +65,6 @@ eol eot epub exe -exr f4v fbs fh @@ -79,7 +77,6 @@ fpx fst fvt g3 -geojson gh gif graffle @@ -88,7 +85,6 @@ gzip h261 h263 h264 -hdr icns ico ief @@ -135,12 +131,10 @@ mpg mpga mxu nef -node npx numbers nupkg o -oa odp ods odt @@ -157,7 +151,6 @@ pdf pea pgm pic -plist png pnm pot @@ -203,13 +196,13 @@ snap snk so sqlite +sqlite +sqlite3 sqlite3 stl -storedata sub suo swf -symbolsarchive tar tbz tbz2 @@ -232,7 +225,6 @@ uvu viv vob war -wasm wav wax wbmp diff --git a/crates/oxide/src/scanner/fixtures/ignored-extensions.txt b/oxide/crates/core/src/fixtures/ignored-extensions.txt similarity index 86% rename from crates/oxide/src/scanner/fixtures/ignored-extensions.txt rename to oxide/crates/core/src/fixtures/ignored-extensions.txt index 4dc099e0d..f147c24fe 100644 --- a/crates/oxide/src/scanner/fixtures/ignored-extensions.txt +++ b/oxide/crates/core/src/fixtures/ignored-extensions.txt @@ -1,6 +1,6 @@ +css less lock sass scss styl -log diff --git a/crates/oxide/src/scanner/fixtures/ignored-files.txt b/oxide/crates/core/src/fixtures/ignored-files.txt similarity index 65% rename from crates/oxide/src/scanner/fixtures/ignored-files.txt rename to oxide/crates/core/src/fixtures/ignored-files.txt index 1e8f1029f..45d4ced87 100644 --- a/crates/oxide/src/scanner/fixtures/ignored-files.txt +++ b/oxide/crates/core/src/fixtures/ignored-files.txt @@ -1,6 +1,3 @@ package-lock.json pnpm-lock.yaml bun.lockb -.gitignore -.env -.env.* diff --git a/crates/oxide/src/scanner/fixtures/template-extensions.txt b/oxide/crates/core/src/fixtures/template-extensions.txt similarity index 93% rename from crates/oxide/src/scanner/fixtures/template-extensions.txt rename to oxide/crates/core/src/fixtures/template-extensions.txt index 096932b6e..c059eff3a 100644 --- a/crates/oxide/src/scanner/fixtures/template-extensions.txt +++ b/oxide/crates/core/src/fixtures/template-extensions.txt @@ -2,10 +2,6 @@ html pug -# Glimmer -gjs -gts - # JS astro cjs diff --git a/oxide/crates/core/src/glob.rs b/oxide/crates/core/src/glob.rs new file mode 100644 index 000000000..6d688f421 --- /dev/null +++ b/oxide/crates/core/src/glob.rs @@ -0,0 +1,456 @@ +use std::iter; +use std::path::{Path, PathBuf}; + +pub fn fast_glob( + base_path: &Path, + patterns: &Vec, +) -> Result, std::io::Error> { + Ok(get_fast_patterns(base_path, patterns) + .into_iter() + .flat_map(|(base_path, patterns)| { + globwalk::GlobWalkerBuilder::from_patterns(base_path, &patterns) + .follow_links(true) + .build() + .unwrap() + .filter_map(Result::ok) + .map(|file| file.path().to_path_buf()) + })) +} + +/// This function attempts to optimize the glob patterns to improve performance. The problem is +/// that if you run the following command: +/// ```sh +/// tailwind --pwd ./project --content "{pages,components}/**/*.js" +/// ``` +/// Then the globwalk library will scan every single file and folder in the `./project` folder, +/// then it will check if the file matches the glob pattern and keep it if it does. This is very +/// slow, because if you have vendor folders (like node_modules), then this will take a while... +/// +/// Instead, we will optimize the pattern, and move as many directories as possible to the base +/// path. This will allow us to scope the globwalk library to only scan the directories that we +/// care about. +/// +/// This means, that the following command: +/// ```sh +/// tailwind --pwd ./project --content "{pages,components}/**/*.js" +/// ``` +/// +/// Will now conceptually do this instead behind the scenes: +/// ```sh +/// tailwind --pwd ./project/pages --content "**/*.js" +/// tailwind --pwd ./project/components --content "**/*.js" +/// ``` +fn get_fast_patterns(base_path: &Path, patterns: &Vec) -> Vec<(PathBuf, Vec)> { + let mut optimized_patterns: Vec<(PathBuf, Vec)> = vec![]; + + for pattern in patterns { + let is_negated = pattern.starts_with('!'); + let mut pattern = pattern.clone(); + if is_negated { + pattern.remove(0); + } + + let mut folders = pattern.split('/').collect::>(); + + if folders.len() <= 1 { + // No paths we can simplify, so let's use it as-is. + optimized_patterns.push((base_path.to_path_buf(), vec![pattern])); + } else { + // We do have folders because `/` exists. Let's try to simplify the globs! + // Safety: We know that the length is greater than 1, so we can safely unwrap. + let file_pattern = folders.pop().unwrap(); + let all_folders = folders.clone(); + let mut temp_paths = vec![base_path.to_path_buf()]; + + let mut bail = false; + + for (i, folder) in folders.into_iter().enumerate() { + // There is a wildcard in the folder, so we have to bail now... 😢 But this also + // means that we can skip looking at the rest of the folders, so there is at least + // this small optimization we can apply! + if folder.contains('*') { + // Get all the remaining folders, attach the existing file_pattern so that this + // can now be the final pattern we use. + let mut remaining_folders = all_folders[i..].to_vec(); + remaining_folders.push(file_pattern); + + let pattern = remaining_folders.join("/"); + for path in &temp_paths { + optimized_patterns.push((path.to_path_buf(), vec![pattern.to_string()])); + } + + bail = true; + break; + } + + // The folder is very likely using an expandable pattern which we can expand! + if folder.contains('{') && folder.contains('}') { + let branches = expand_braces(folder); + + let existing_paths = temp_paths; + temp_paths = branches + .iter() + .flat_map(|branch| { + existing_paths + .clone() + .into_iter() + .map(|path| path.join(branch)) + .collect::>() + }) + .collect::>(); + } + // The folder should just be a simple folder name without any glob magic. We should + // be able to safely add it to the existing paths. + else { + temp_paths = temp_paths + .into_iter() + .map(|path| path.join(folder)) + .collect(); + } + } + + // As long as we didn't bail, we can now add the current expanded patterns to the + // optimized patterns. + if !bail { + for path in &temp_paths { + optimized_patterns.push((path.to_path_buf(), vec![file_pattern.to_string()])); + } + } + } + + // Ensure that we re-add all the `!` signs to the patterns. + if is_negated { + for (_, patterns) in &mut optimized_patterns { + for pattern in patterns { + pattern.insert(0, '!'); + } + } + } + } + + optimized_patterns +} + +/// Given this input: a-{b,c}-d-{e,f} +/// We will get: +/// [ +/// a-b-d-e +/// a-b-d-f +/// a-c-d-e +/// a-c-d-f +/// ] +/// TODO: There is probably a way nicer way of doing this, but this works for now. +fn expand_braces(input: &str) -> Vec { + let mut result: Vec = vec![]; + + let mut in_braces = false; + let mut last_char: char = '\0'; + + let mut current = String::new(); + + // Given the input: a-{b,c}-d-{e,f}-g + // The template will look like this: ["a-", "-d-", "g"]. + let mut template: Vec = vec![]; + + // The branches will look like this: [["b", "c"], ["e", "f"]]. + let mut branches: Vec> = vec![]; + + for (i, c) in input.char_indices() { + let is_escaped = i > 0 && last_char == '\\'; + last_char = c; + + match c { + '{' if !is_escaped => { + // Ensure that when a new set of braces is opened, that we at least have 1 + // template. + if template.is_empty() { + template.push(String::new()); + } + + in_braces = true; + branches.push(vec![]); + template.push(String::new()); + } + '}' if !is_escaped => { + in_braces = false; + if let Some(last) = branches.last_mut() { + last.push(current.clone()); + } + current.clear(); + } + ',' if !is_escaped && in_braces => { + if let Some(last) = branches.last_mut() { + last.push(current.clone()); + } + current.clear(); + } + _ if in_braces => current.push(c), + _ => { + if template.is_empty() { + template.push(String::new()); + } + + if let Some(last) = template.last_mut() { + last.push(c); + } + } + }; + } + + // Ensure we have a string that we can start adding information too. + if !template.is_empty() && !branches.is_empty() { + result.push("".to_string()); + } + + // Let's try to generate everything! + for (i, template) in template.into_iter().enumerate() { + // Append current template string to all existing results. + result = result.into_iter().map(|x| x + &template).collect(); + + // Get the results, and copy it for every single branch. + if let Some(branches) = branches.get(i) { + result = branches + .iter() + .flat_map(|branch| { + result + .clone() + .into_iter() + .map(|x| x + branch) + .collect::>() + }) + .collect::>(); + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::get_fast_patterns; + use std::path::PathBuf; + + #[test] + fn it_should_keep_globs_that_start_with_file_wildcards_as_is() { + let actual = get_fast_patterns(&PathBuf::from("/projects"), &vec!["*.html".to_string()]); + let expected = vec![(PathBuf::from("/projects"), vec!["*.html".to_string()])]; + + assert_eq!(actual, expected,); + } + + #[test] + fn it_should_keep_globs_that_start_with_folder_wildcards_as_is() { + let actual = get_fast_patterns(&PathBuf::from("/projects"), &vec!["**/*.html".to_string()]); + let expected = vec![(PathBuf::from("/projects"), vec!["**/*.html".to_string()])]; + + assert_eq!(actual, expected,); + } + + #[test] + fn it_should_move_the_starting_folder_to_the_path() { + let actual = get_fast_patterns( + &PathBuf::from("/projects"), + &vec!["example/*.html".to_string()], + ); + let expected = vec![( + PathBuf::from("/projects/example"), + vec!["*.html".to_string()], + )]; + + assert_eq!(actual, expected,); + } + + #[test] + fn it_should_move_the_starting_folders_to_the_path() { + let actual = get_fast_patterns( + &PathBuf::from("/projects"), + &vec!["example/other/*.html".to_string()], + ); + let expected = vec![( + PathBuf::from("/projects/example/other"), + vec!["*.html".to_string()], + )]; + + assert_eq!(actual, expected,); + } + + #[test] + fn it_should_branch_expandable_folders() { + let actual = get_fast_patterns( + &PathBuf::from("/projects"), + &vec!["{foo,bar}/*.html".to_string()], + ); + let expected = vec![ + (PathBuf::from("/projects/foo"), vec!["*.html".to_string()]), + (PathBuf::from("/projects/bar"), vec!["*.html".to_string()]), + ]; + + assert_eq!(actual, expected,); + } + + #[test] + fn it_should_expand_multiple_expansions_in_the_same_folder() { + let actual = get_fast_patterns( + &PathBuf::from("/projects"), + &vec!["a-{b,c}-d-{e,f}-g/*.html".to_string()], + ); + let expected = vec![ + ( + PathBuf::from("/projects/a-b-d-e-g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/a-c-d-e-g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/a-b-d-f-g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/a-c-d-f-g"), + vec!["*.html".to_string()], + ), + ]; + + assert_eq!(actual, expected,); + } + + #[test] + fn multiple_expansions_per_folder_starting_at_the_root() { + let actual = get_fast_patterns( + &PathBuf::from("/projects"), + &vec!["{a,b}-c-{d,e}-f/{b,c}-d-{e,f}-g/*.html".to_string()], + ); + let expected = vec![ + ( + PathBuf::from("/projects/a-c-d-f/b-d-e-g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/b-c-d-f/b-d-e-g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/a-c-e-f/b-d-e-g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/b-c-e-f/b-d-e-g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/a-c-d-f/c-d-e-g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/b-c-d-f/c-d-e-g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/a-c-e-f/c-d-e-g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/b-c-e-f/c-d-e-g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/a-c-d-f/b-d-f-g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/b-c-d-f/b-d-f-g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/a-c-e-f/b-d-f-g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/b-c-e-f/b-d-f-g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/a-c-d-f/c-d-f-g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/b-c-d-f/c-d-f-g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/a-c-e-f/c-d-f-g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/b-c-e-f/c-d-f-g"), + vec!["*.html".to_string()], + ), + ]; + + assert_eq!(actual, expected,); + } + + #[test] + fn it_should_stop_expanding_once_we_hit_a_wildcard() { + let actual = get_fast_patterns( + &PathBuf::from("/projects"), + &vec!["{foo,bar}/example/**/{baz,qux}/*.html".to_string()], + ); + let expected = vec![ + ( + PathBuf::from("/projects/foo/example"), + vec!["**/{baz,qux}/*.html".to_string()], + ), + ( + PathBuf::from("/projects/bar/example"), + vec!["**/{baz,qux}/*.html".to_string()], + ), + ]; + + assert_eq!(actual, expected,); + } + + #[test] + fn it_should_keep_the_negation_symbol_for_all_new_patterns() { + let actual = get_fast_patterns( + &PathBuf::from("/projects"), + &vec!["!{foo,bar}/*.html".to_string()], + ); + let expected = vec![ + (PathBuf::from("/projects/foo"), vec!["!*.html".to_string()]), + (PathBuf::from("/projects/bar"), vec!["!*.html".to_string()]), + ]; + + assert_eq!(actual, expected,); + } + + #[test] + fn it_should_expand_a_complex_example() { + let actual = get_fast_patterns( + &PathBuf::from("/projects"), + &vec!["a/{b,c}/d/{e,f}/g/*.html".to_string()], + ); + let expected = vec![ + ( + PathBuf::from("/projects/a/b/d/e/g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/a/c/d/e/g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/a/b/d/f/g"), + vec!["*.html".to_string()], + ), + ( + PathBuf::from("/projects/a/c/d/f/g"), + vec!["*.html".to_string()], + ), + ]; + + assert_eq!(actual, expected,); + } +} diff --git a/oxide/crates/core/src/lib.rs b/oxide/crates/core/src/lib.rs new file mode 100644 index 000000000..6f04adae0 --- /dev/null +++ b/oxide/crates/core/src/lib.rs @@ -0,0 +1,490 @@ +use crate::parser::Extractor; +use cache::Cache; +use fxhash::FxHashSet; +use ignore::DirEntry; +use ignore::WalkBuilder; +use lazy_static::lazy_static; +use rayon::prelude::*; +use std::cmp::Ordering; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Mutex; +use tracing::event; +use walkdir::WalkDir; + +pub mod cache; +pub mod cursor; +pub mod fast_skip; +pub mod glob; +pub mod parser; + +fn init_tracing() { + if !*SHOULD_TRACE { + return; + } + + _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_span_events(tracing_subscriber::fmt::format::FmtSpan::ACTIVE) + .compact() + .try_init(); +} + +#[derive(Debug, Clone)] +pub struct ChangedContent { + pub file: Option, + pub content: Option, +} + +#[derive(Debug, Clone)] +pub struct ScanOptions { + pub base: String, + pub globs: bool, +} + +#[derive(Debug, Clone)] +pub struct ScanResult { + pub candidates: Vec, + pub files: Vec, + pub globs: Vec, +} + +#[derive(Debug, Clone)] +pub struct GlobEntry { + pub base: String, + pub glob: String, +} + +pub fn clear_cache() { + let mut cache = GLOBAL_CACHE.lock().unwrap(); + cache.clear(); +} + +pub fn scan_dir(opts: ScanOptions) -> ScanResult { + init_tracing(); + + let root = Path::new(&opts.base); + + let (files, dirs) = resolve_files(root); + + let globs = if opts.globs { + resolve_globs(root, dirs) + } else { + vec![] + }; + + let mut cache = GLOBAL_CACHE.lock().unwrap(); + + let modified_files = cache.find_modified_files(&files); + + let files = files.iter().map(|x| x.display().to_string()).collect(); + + if !modified_files.is_empty() { + let content: Vec<_> = modified_files + .into_iter() + .map(|file| ChangedContent { + file: Some(file.clone()), + content: None, + }) + .collect(); + + let candidates = scan_files(content, IO::Parallel as u8 | Parsing::Parallel as u8); + cache.add_candidates(candidates); + } + + ScanResult { + candidates: cache.get_candidates(), + files, + globs, + } +} + +#[tracing::instrument(skip(root))] +fn resolve_globs(root: &Path, dirs: Vec) -> Vec { + let allowed_paths = FxHashSet::from_iter(dirs); + + // A list of directory names where we can't use globs, but we should track each file + // individually instead. This is because these directories are often used for both source and + // destination files. + let mut forced_static_directories = vec![root.join("public")]; + + // A list of known extensions + a list of extensions we found in the project. + let mut found_extensions = FxHashSet::from_iter( + include_str!("fixtures/template-extensions.txt") + .trim() + .lines() + .filter(|x| !x.starts_with('#')) // Drop commented lines + .filter(|x| !x.is_empty()) // Drop empty lines + .map(|x| x.to_string()), + ); + + // All root directories. + let mut root_directories = FxHashSet::from_iter(vec![root.to_path_buf()]); + + // All directories where we can safely use deeply nested globs to watch all files. + // In other comments we refer to these as "deep glob directories" or similar. + // + // E.g.: `./src/**/*.{html,js}` + let mut deep_globable_directories: FxHashSet = FxHashSet::default(); + + // All directories where we can only use shallow globs to watch all direct files but not + // folders. + // In other comments we refer to these as "shallow glob directories" or similar. + // + // E.g.: `./src/*/*.{html,js}` + let mut shallow_globable_directories: FxHashSet = FxHashSet::default(); + + // Collect all valid paths from the root. This will already filter out ignored files, unknown + // extensions and binary files. + let mut it = WalkDir::new(root) + // Sorting to make sure that we always see the directories before the files. Also sorting + // alphabetically by default. + .sort_by( + |a, z| match (a.file_type().is_dir(), z.file_type().is_dir()) { + (true, false) => Ordering::Less, + (false, true) => Ordering::Greater, + _ => a.file_name().cmp(z.file_name()), + }, + ) + .into_iter(); + + loop { + // We are only interested in valid entries + let entry = match it.next() { + Some(Ok(entry)) => entry, + _ => break, + }; + + // Ignore known directories that we don't want to traverse into. + if entry.file_type().is_dir() && entry.file_name() == ".git" { + it.skip_current_dir(); + continue; + } + + if entry.file_type().is_dir() { + // If we are in a directory where we know that we can't use any globs, then we have to + // track each file individually. + if forced_static_directories.contains(&entry.path().to_path_buf()) { + forced_static_directories.push(entry.path().to_path_buf()); + root_directories.insert(entry.path().to_path_buf()); + continue; + } + + // If we are in a directory where the parent is a forced static directory, then this + // will become a forced static directory as well. + if forced_static_directories.contains(&entry.path().parent().unwrap().to_path_buf()) { + forced_static_directories.push(entry.path().to_path_buf()); + root_directories.insert(entry.path().to_path_buf()); + continue; + } + + // If we are in a directory, and the directory is git ignored, then we don't have to + // descent into the directory. However, we have to make sure that we mark the _parent_ + // directory as a shallow glob directory because using deep globs from any of the + // parent directories will include this ignored directory which should not be the case. + // + // Another important part is that if one of the ignored directories is a deep glob + // directory, then all of its parents (until the root) should be marked as shallow glob + // directories as well. + if !allowed_paths.contains(&entry.path().to_path_buf()) { + let mut parent = entry.path().parent(); + while let Some(parent_path) = parent { + // If the parent is already marked as a valid deep glob directory, then we have + // to mark it as a shallow glob directory instead, because we won't be able to + // use deep globs for this directory anymore. + if deep_globable_directories.contains(parent_path) { + deep_globable_directories.remove(parent_path); + shallow_globable_directories.insert(parent_path.to_path_buf()); + } + + // If we reached the root, then we can stop. + if parent_path == root { + break; + } + + // Mark the parent directory as a shallow glob directory and continue with its + // parent. + shallow_globable_directories.insert(parent_path.to_path_buf()); + parent = parent_path.parent(); + } + + it.skip_current_dir(); + continue; + } + + // If we are in a directory that is not git ignored, then we can mark this directory as + // a valid deep glob directory. This is only necessary if any of its parents aren't + // marked as deep glob directories already. + let mut found_deep_glob_parent = false; + let mut parent = entry.path().parent(); + while let Some(parent_path) = parent { + // If we reached the root, then we can stop. + if parent_path == root { + break; + } + + // If the parent is already marked as a deep glob directory, then we can stop + // because this glob will match the current directory already. + if deep_globable_directories.contains(parent_path) { + found_deep_glob_parent = true; + break; + } + + parent = parent_path.parent(); + } + + // If we didn't find a deep glob directory parent, then we can mark this directory as a + // deep glob directory (unless it is the root). + if !found_deep_glob_parent && entry.path() != root { + deep_globable_directories.insert(entry.path().to_path_buf()); + } + } + + // Handle allowed content paths + if is_allowed_content_path(entry.path()) + && allowed_paths.contains(&entry.path().to_path_buf()) + { + let path = entry.path(); + + // Collect the extension for future use when building globs. + if let Some(extension) = path.extension().and_then(|x| x.to_str()) { + found_extensions.insert(extension.to_string()); + } + } + } + + let extension_list = found_extensions.into_iter().collect::>().join(","); + + // Build the globs for all globable directories. + let shallow_globs = shallow_globable_directories.iter().map(|path| GlobEntry { + base: path.display().to_string(), + glob: format!("*/*.{{{}}}", extension_list), + }); + + let deep_globs = deep_globable_directories.iter().map(|path| GlobEntry { + base: path.display().to_string(), + glob: format!("**/*.{{{}}}", extension_list), + }); + + shallow_globs.chain(deep_globs).collect::>() +} + +#[tracing::instrument(skip(root))] +fn resolve_files(root: &Path) -> (Vec, Vec) { + let mut files: Vec = vec![]; + let mut dirs: Vec = vec![]; + + for entry in resolve_allowed_paths(root) { + let Some(file_type) = entry.file_type() else { + continue; + }; + + if file_type.is_file() { + files.push(entry.into_path()); + } else if file_type.is_dir() { + dirs.push(entry.into_path()); + } + } + + (files, dirs) +} + +#[tracing::instrument(skip(root))] +pub fn resolve_allowed_paths(root: &Path) -> impl Iterator { + WalkBuilder::new(root) + .hidden(false) + .require_git(false) + .filter_entry(|entry| match entry.file_type() { + Some(file_type) if file_type.is_dir() => match entry.file_name().to_str() { + Some(dir) => !IGNORED_CONTENT_DIRS.contains(&dir), + None => false, + }, + Some(file_type) if file_type.is_file() || file_type.is_symlink() => { + is_allowed_content_path(entry.path()) + } + _ => false, + }) + .build() + .filter_map(Result::ok) +} + +lazy_static! { + static ref BINARY_EXTENSIONS: Vec<&'static str> = + include_str!("fixtures/binary-extensions.txt") + .trim() + .lines() + .collect::>(); + static ref IGNORED_EXTENSIONS: Vec<&'static str> = + include_str!("fixtures/ignored-extensions.txt") + .trim() + .lines() + .collect::>(); + static ref IGNORED_FILES: Vec<&'static str> = include_str!("fixtures/ignored-files.txt") + .trim() + .lines() + .collect::>(); + static ref IGNORED_CONTENT_DIRS: Vec<&'static str> = vec![".git"]; + static ref SHOULD_TRACE: bool = { + matches!(std::env::var("DEBUG"), Ok(value) if value.eq("*") || value.eq("1") || value.eq("true") || value.contains("tailwind")) + }; + + /// Track file modification times and cache candidates. This cache lives for the lifetime of + /// the process and simply adds candidates when files are modified. Since candidates aren't + /// removed, incremental builds may contain extra candidates. + static ref GLOBAL_CACHE: Mutex = { + Mutex::new(Cache::default()) + }; +} + +pub fn is_allowed_content_path(path: &Path) -> bool { + let path = PathBuf::from(path); + + // Skip known ignored files + if path + .file_name() + .unwrap() + .to_str() + .map(|s| IGNORED_FILES.contains(&s)) + .unwrap_or(false) + { + return false; + } + + // Skip known ignored extensions + path.extension() + .map(|s| s.to_str().unwrap_or_default()) + .map(|ext| !IGNORED_EXTENSIONS.contains(&ext) && !BINARY_EXTENSIONS.contains(&ext)) + .unwrap_or(false) +} + +#[derive(Debug)] +pub enum IO { + Sequential = 0b0001, + Parallel = 0b0010, +} + +impl From for IO { + fn from(item: u8) -> Self { + match item & 0b0011 { + 0b0001 => IO::Sequential, + 0b0010 => IO::Parallel, + _ => unimplemented!("Unknown 'IO' strategy"), + } + } +} + +#[derive(Debug)] +pub enum Parsing { + Sequential = 0b0100, + Parallel = 0b1000, +} + +impl From for Parsing { + fn from(item: u8) -> Self { + match item & 0b1100 { + 0b0100 => Parsing::Sequential, + 0b1000 => Parsing::Parallel, + _ => unimplemented!("Unknown 'Parsing' strategy"), + } + } +} + +#[tracing::instrument(skip(input, options))] +pub fn scan_files(input: Vec, options: u8) -> Vec { + match (IO::from(options), Parsing::from(options)) { + (IO::Sequential, Parsing::Sequential) => parse_all_blobs_sync(read_all_files_sync(input)), + (IO::Sequential, Parsing::Parallel) => parse_all_blobs(read_all_files_sync(input)), + (IO::Parallel, Parsing::Sequential) => parse_all_blobs_sync(read_all_files(input)), + (IO::Parallel, Parsing::Parallel) => parse_all_blobs(read_all_files(input)), + } +} + +#[tracing::instrument(skip(changed_content))] +fn read_all_files(changed_content: Vec) -> Vec> { + event!( + tracing::Level::INFO, + "Reading {:?} file(s)", + changed_content.len() + ); + + changed_content + .into_par_iter() + .map(|c| match (c.file, c.content) { + (Some(file), None) => match std::fs::read(file) { + Ok(content) => content, + Err(e) => { + event!(tracing::Level::ERROR, "Failed to read file: {:?}", e); + Default::default() + } + }, + (None, Some(content)) => content.into_bytes(), + _ => Default::default(), + }) + .collect() +} + +#[tracing::instrument(skip(changed_content))] +fn read_all_files_sync(changed_content: Vec) -> Vec> { + event!( + tracing::Level::INFO, + "Reading {:?} file(s)", + changed_content.len() + ); + + changed_content + .into_iter() + .filter_map(|c| match (c.file, c.content) { + (Some(file), None) => std::fs::read(file).ok(), + (None, Some(content)) => Some(content.into_bytes()), + _ => Default::default(), + }) + .collect() +} + +#[tracing::instrument(skip(blobs))] +fn parse_all_blobs(blobs: Vec>) -> Vec { + let input: Vec<_> = blobs.iter().map(|blob| &blob[..]).collect(); + let input = &input[..]; + + let mut result: Vec = input + .par_iter() + .map(|input| Extractor::unique(input, Default::default())) + .reduce(Default::default, |mut a, b| { + a.extend(b); + a + }) + .into_iter() + .map(|s| { + // SAFETY: When we parsed the candidates, we already guaranteed that the byte slices + // are valid, therefore we don't have to re-check here when we want to convert it back + // to a string. + unsafe { String::from_utf8_unchecked(s.to_vec()) } + }) + .collect(); + result.sort(); + result +} + +#[tracing::instrument(skip(blobs))] +fn parse_all_blobs_sync(blobs: Vec>) -> Vec { + let input: Vec<_> = blobs.iter().map(|blob| &blob[..]).collect(); + let input = &input[..]; + + let mut result: Vec = input + .iter() + .map(|input| Extractor::unique(input, Default::default())) + .fold(FxHashSet::default(), |mut a, b| { + a.extend(b); + a + }) + .into_iter() + .map(|s| { + // SAFETY: When we parsed the candidates, we already guaranteed that the byte slices + // are valid, therefore we don't have to re-check here when we want to convert it back + // to a string. + unsafe { String::from_utf8_unchecked(s.to_vec()) } + }) + .collect(); + result.sort(); + result +} diff --git a/oxide/crates/core/src/parser.rs b/oxide/crates/core/src/parser.rs new file mode 100644 index 000000000..ad612f902 --- /dev/null +++ b/oxide/crates/core/src/parser.rs @@ -0,0 +1,1427 @@ +use crate::{cursor::Cursor, fast_skip::fast_skip}; +use bstr::ByteSlice; +use fxhash::FxHashSet; +use tracing::trace; + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum ParseAction<'a> { + Consume, + Skip, + RestartAt(usize), + + SingleCandidate(&'a [u8]), + MultipleCandidates(Vec<&'a [u8]>), + Done, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum Bracketing<'a> { + Included(&'a [u8]), + Wrapped(&'a [u8]), + None, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct SplitCandidate<'a> { + variant: &'a [u8], + utility: &'a [u8], +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum ValidationResult { + Invalid, + Valid, + Restart, +} + +#[derive(Default)] +pub struct ExtractorOptions { + pub preserve_spaces_in_arbitrary: bool, +} + +pub struct Extractor<'a> { + opts: ExtractorOptions, + + input: &'a [u8], + cursor: Cursor<'a>, + + idx_start: usize, + idx_end: usize, + idx_last: usize, + idx_arbitrary_start: usize, + + in_arbitrary: bool, + in_candidate: bool, + in_escape: bool, + + discard_next: bool, + + quote_stack: Vec, + bracket_stack: Vec, +} + +impl<'a> Extractor<'a> { + pub fn all(input: &'a [u8], opts: ExtractorOptions) -> Vec<&'a [u8]> { + Self::new(input, opts).flatten().collect() + } + + pub fn unique(input: &'a [u8], opts: ExtractorOptions) -> FxHashSet<&'a [u8]> { + let mut candidates: FxHashSet<&[u8]> = Default::default(); + candidates.reserve(100); + candidates.extend(Self::new(input, opts).flatten()); + candidates + } + + pub fn unique_ord(input: &'a [u8], opts: ExtractorOptions) -> Vec<&'a [u8]> { + // This is an inefficient way to get an ordered, unique + // list as a Vec but it is only meant for testing. + let mut candidates = Self::all(input, opts); + let mut unique_list = FxHashSet::default(); + unique_list.reserve(candidates.len()); + candidates.retain(|c| unique_list.insert(*c)); + + candidates + } +} + +impl<'a> Extractor<'a> { + pub fn new(input: &'a [u8], opts: ExtractorOptions) -> Self { + Self { + opts, + input, + cursor: Cursor::new(input), + + idx_start: 0, + idx_end: 0, + idx_arbitrary_start: 0, + + in_arbitrary: false, + in_candidate: false, + in_escape: false, + + discard_next: false, + + idx_last: input.len(), + quote_stack: Vec::with_capacity(8), + bracket_stack: Vec::with_capacity(8), + } + } +} + +/// Helpers +impl<'a> Extractor<'a> { + #[inline(always)] + fn in_quotes(&self) -> bool { + !self.quote_stack.is_empty() + } + + #[inline(always)] + fn get_current_candidate(&mut self) -> ParseAction<'a> { + if self.discard_next { + return ParseAction::Skip; + } + + let mut candidate = &self.input[self.idx_start..=self.idx_end]; + + // The bracket stack is not empty, which means that we are dealing with unbalanced + // brackets. + if !self.bracket_stack.is_empty() { + return ParseAction::Skip; + } + + while !candidate.is_empty() { + match Extractor::is_valid_candidate_string(candidate) { + ValidationResult::Valid => return ParseAction::SingleCandidate(candidate), + ValidationResult::Restart => return ParseAction::RestartAt(self.idx_start + 1), + _ => {} + } + + match candidate.split_last() { + // At this point the candidate is technically invalid, however it can be that it + // has a few dangling characters attached to it. For example, think about a + // JavaScript object: + // + // ```js + // { underline: true } + // ``` + // + // The candidate at this point will be `underline:`, which is invalid. However, we + // can assume in this case that the `:` should not be there, and therefore we can + // try to slice it off and retry the validation. + Some((b':' | b'/' | b'.', head)) => { + candidate = head; + } + + // It could also be that we have the candidate is nested inside of bracket or quote + // pairs. In this case we want to retrieve the inner part and try to validate that + // inner part instead. For example, in a JavaScript array: + // + // ```js + // let myClasses = ["underline"] + // ``` + // + // The `underline` is nested inside of quotes and in square brackets. Let's try to + // get the inner part and validate that instead. + _ => match Self::slice_surrounding(candidate) { + Some(shorter) if shorter != candidate => { + candidate = shorter; + } + _ => break, + }, + } + } + + ParseAction::Consume + } + + #[inline(always)] + fn split_candidate(candidate: &'a [u8]) -> SplitCandidate { + let mut brackets = 0; + let mut idx_end = 0; + + for (n, c) in candidate.iter().enumerate() { + match c { + b'[' => brackets += 1, + b']' if brackets > 0 => brackets -= 1, + b':' if brackets == 0 => idx_end = n + 1, + _ => {} + } + } + + SplitCandidate { + variant: &candidate[0..idx_end], + utility: &candidate[idx_end..], + } + } + + #[inline(always)] + fn contains_in_constrained(candidate: &'a [u8], bytes: Vec) -> bool { + let mut brackets = 0; + + for c in candidate { + match c { + b'[' => brackets += 1, + b']' if brackets > 0 => brackets -= 1, + _ if brackets == 0 && bytes.contains(c) => return true, + _ => {} + } + } + + false + } + + #[inline(always)] + fn is_valid_candidate_string(candidate: &'a [u8]) -> ValidationResult { + // Reject candidates that start with a capital letter + if candidate[0].is_ascii_uppercase() { + return ValidationResult::Invalid; + } + + // Rejects candidates that end with "-" or "_" + if candidate.ends_with(b"-") || candidate.ends_with(b"_") { + return ValidationResult::Invalid; + } + + // Reject candidates that are single camelCase words, e.g.: `useEffect` + if candidate.iter().all(|c| c.is_ascii_alphanumeric()) + && candidate + .iter() + .any(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) + { + return ValidationResult::Invalid; + } + + // Reject candidates that look like SVG path data, e.g.: `m32.368 m7.5` + if !candidate.contains(&b'-') + && !candidate.contains(&b':') + && candidate.iter().any(|c| c == &b'.' || c.is_ascii_digit()) + { + return ValidationResult::Invalid; + } + + // Reject candidates that look like version constraints or email addresses, e.g.: `next@latest`, `bob@example.com` + if candidate + .iter() + .all(|c| c.is_ascii_alphanumeric() || c == &b'.' || c == &b'-' || c == &b'@') + && candidate[1..].contains(&b'@') + { + return ValidationResult::Invalid; + } + + // Reject candidates that look like URLs + if candidate.starts_with(b"http://") || candidate.starts_with(b"https://") { + return ValidationResult::Invalid; + } + + // Reject candidates that look short markdown links, e.g.: `[https://example.com]` + if candidate.starts_with(b"[http://") || candidate.starts_with(b"[https://") { + return ValidationResult::Invalid; + } + + // Reject candidates that look like imports with path aliases, e.g.: `@/components/button` + if candidate.len() > 1 && candidate[1] == b'/' { + return ValidationResult::Invalid; + } + + // Reject candidates that look like paths, e.g.: `app/assets/stylesheets` + if !candidate.contains(&b':') && !candidate.contains(&b'[') { + let mut count = 0; + for c in candidate { + if c == &b'/' { + count += 1; + } + if count > 1 { + return ValidationResult::Invalid; + } + } + } + + let split_candidate = Extractor::split_candidate(candidate); + + let mut offset = 0; + let mut offset_end = 0; + let utility = &split_candidate.utility; + let original_utility = &utility; + + // Some special cases that we can ignore while validating + if utility.starts_with(b"!-") { + offset += 2; + } else if utility.starts_with(b"!") || utility.starts_with(b"-") { + offset += 1; + } else if utility.ends_with(b"!") { + offset_end += 1; + } + + // These are allowed in arbitrary values and in variants but nowhere else + if Extractor::contains_in_constrained(utility, vec![b'<', b'>']) { + return ValidationResult::Restart; + } + + // It's an arbitrary property + if utility.starts_with(b"[") + && utility.ends_with(b"]") + && (utility.starts_with(b"['") + || utility.starts_with(b"[\"") + || utility.starts_with(b"[`")) + { + return ValidationResult::Restart; + } + + // Pluck out the part that we are interested in. + let utility = &utility[offset..(utility.len() - offset_end)]; + + // Validations + // We should have _something_ + if utility.is_empty() { + return ValidationResult::Invalid; + } + + // = b'0' && utility[0] <= b'9' && !utility.contains(&b':') { + return ValidationResult::Invalid; + } + + // In case of an arbitrary property, we should have at least this structure: [a:b] + if utility.starts_with(b"[") && utility.ends_with(b"]") { + // [a:b] is at least 5 characters long + if utility.len() < 5 { + return ValidationResult::Invalid; + } + + // Now that we validated that the candidate is technically fine, let's ensure that it + // doesn't start with a `-` because that would make it invalid for arbitrary properties. + if original_utility.starts_with(b"-") || original_utility.starts_with(b"!-") { + return ValidationResult::Invalid; + } + + // Make sure an arbitrary property/value pair is valid, otherwise + // we may generate invalid CSS that will cause tools like PostCSS + // to crash when trying to parse the generated CSS. + if !Self::validate_arbitrary_property(utility) { + return ValidationResult::Invalid; + } + + // The ':` must be preceded by a-Z0-9 because it represents a property name. + let colon = utility.find(":").unwrap(); + + if !utility + .chars() + .nth(colon - 1) + .map_or_else(|| false, |c| c.is_ascii_alphanumeric()) + { + return ValidationResult::Invalid; + } + + let property = &utility[1..colon]; + + // The property must match /^[a-zA-Z-][a-zA-Z0-9-_]+$/ + if !property[0].is_ascii_alphabetic() && property[0] != b'-' { + return ValidationResult::Invalid; + } + + if !property + .iter() + .all(|c| c.is_ascii_alphanumeric() || c == &b'-' || c == &b'_') + { + return ValidationResult::Invalid; + } + } + + ValidationResult::Valid + } + + /** + * Make sure an arbitrary property/value pair is valid, otherwise + * PostCSS may crash when trying to parse the generated CSS. + * + * `input` - the full candidate string, including the brackets + */ + fn validate_arbitrary_property(candidate: &[u8]) -> bool { + if !candidate.starts_with(b"[") || !candidate.ends_with(b"]") { + return false; + } + let property = &candidate[1..candidate.len() - 1]; + let is_custom_property = property.starts_with(b"--"); + let Some(colon_pos) = property.find(b":") else { + return false; + }; + if is_custom_property { + return true; + } + + let mut stack = vec![]; + let mut iter = property[colon_pos + 1..].iter(); + while let Some(c) = iter.next() { + match c { + // The value portion cannot contain unquoted colons. + // E.g. `[foo::bar]` leads to "foo::bar; which errors because of the `:`. + b':' | b'{' | b'}' if stack.is_empty() => { + return false; + } + + b'\'' => { + if let Some(b'\'') = stack.last() { + _ = stack.pop() + } else { + stack.push(b'\'') + } + } + b'"' => { + if let Some(b'"') = stack.last() { + _ = stack.pop() + } else { + stack.push(b'"') + } + } + + // Skip escaped characters. + b'\\' => { + iter.next(); + } + + _ => {} + } + } + + true + } + + #[inline(always)] + fn parse_escaped(&mut self) -> ParseAction<'a> { + // If this character is escaped, we don't care about it. + // It gets consumed. + trace!("Escape::Consume"); + + self.in_escape = false; + + ParseAction::Consume + } + + #[inline(always)] + fn parse_arbitrary(&mut self) -> ParseAction<'a> { + // In this we could technically use memchr 6 times (then looped) to find the indexes / bounds of arbitrary valuesq + if self.in_escape { + return self.parse_escaped(); + } + + match self.cursor.curr { + b'\\' => { + // The `\` character is used to escape characters in arbitrary content _and_ to prevent the starting of arbitrary content + trace!("Arbitrary::Escape"); + self.in_escape = true; + } + + b'(' => self.bracket_stack.push(self.cursor.curr), + b')' => match self.bracket_stack.last() { + Some(&b'(') => { + self.bracket_stack.pop(); + } + + // Last bracket is different compared to what we expect, therefore we are not in a + // valid arbitrary value. + _ if !self.in_quotes() => return ParseAction::Skip, + + // We're probably in quotes or nested brackets, so we keep going + _ => {} + }, + + // Make sure the brackets are balanced + b'[' => self.bracket_stack.push(self.cursor.curr), + b']' => match self.bracket_stack.last() { + // We've ended a nested bracket + Some(&b'[') => { + self.bracket_stack.pop(); + } + + // This is the last bracket meaning the end of arbitrary content + _ if !self.in_quotes() => { + if matches!(self.cursor.next, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9') { + return ParseAction::Consume; + } + + trace!("Arbitrary::End\t"); + self.in_arbitrary = false; + + if self.cursor.pos - self.idx_arbitrary_start == 1 { + // We have an empty arbitrary value, which is not allowed + return ParseAction::Skip; + } + } + + // We're probably in quotes or nested brackets, so we keep going + _ => {} + }, + + // Arbitrary values sometimes contain quotes + // These can "escape" the arbitrary value mode + // switching of `[` and `]` characters + b'"' | b'\'' | b'`' => match self.quote_stack.last() { + Some(&last_quote) if last_quote == self.cursor.curr => { + trace!("Quote::End\t"); + self.quote_stack.pop(); + } + _ => { + trace!("Quote::Start\t"); + self.quote_stack.push(self.cursor.curr); + } + }, + + b' ' if !self.opts.preserve_spaces_in_arbitrary => { + trace!("Arbitrary::SkipAndEndEarly\t"); + + // Restart the parser ahead of the arbitrary value + // It may pick up more candidates + return ParseAction::RestartAt(self.idx_arbitrary_start + 1); + } + + // Arbitrary values allow any character inside them + // Except spaces unless you are in loose mode + _ => { + trace!("Arbitrary::Consume\t"); + // No need to move the end index because either the arbitrary value will end properly OR we'll hit invalid characters + } + } + + ParseAction::Consume + } + + #[inline(always)] + fn parse_start(&mut self) -> ParseAction<'a> { + match self.cursor.curr { + // Enter arbitrary value mode + b'[' => { + trace!("Arbitrary::Start\t"); + self.in_arbitrary = true; + self.idx_arbitrary_start = self.cursor.pos; + + ParseAction::Consume + } + + // Allowed first characters. + b'@' | b'!' | b'-' | b'<' | b'>' | b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'*' => { + // TODO: A bunch of characters that we currently support but maybe we only want it behind + // a flag. E.g.: ` ParseAction::Skip, + } + } + + #[inline(always)] + fn parse_continue(&mut self) -> ParseAction<'a> { + match self.cursor.curr { + // Enter arbitrary value mode + b'[' if matches!( + self.cursor.prev, + b'@' | b'-' | b' ' | b':' | b'/' | b'!' | b'\0' + ) => + { + trace!("Arbitrary::Start\t"); + self.in_arbitrary = true; + self.idx_arbitrary_start = self.cursor.pos; + } + + // Can't enter arbitrary value mode + // This can't be a candidate + b'[' => { + trace!("Arbitrary::Skip_Start\t"); + + return ParseAction::Skip; + } + + // A % can only appear at the end of the candidate itself. It can also only be after a + // digit 0-9. This covers the following cases: + // - from-15% + b'%' if self.cursor.prev.is_ascii_digit() => { + return match (self.cursor.at_end, self.cursor.next) { + // End of string == end of candidate == okay + (true, _) => ParseAction::Consume, + + // Looks like the end of a candidate == okay + (_, b' ' | b'\'' | b'"' | b'`') => ParseAction::Consume, + + // Otherwise, not a valid character in a candidate + _ => ParseAction::Skip, + }; + } + b'%' => return ParseAction::Skip, + + // < and > can only be part of a variant and only be the first or last character + b'<' | b'>' | b'*' => { + // Can only be the first or last character + // + // E.g.: + // + // - :underline + // ^ + if self.cursor.pos == self.idx_start || self.cursor.pos == self.idx_last { + trace!("Candidate::Consume\t"); + } + // If it is in the middle, it can only be part of a stacked variant + // - dark::underline + // ^ + else if self.cursor.prev == b':' || self.cursor.next == b':' { + trace!("Candidate::Consume\t"); + } else { + return ParseAction::Skip; + } + } + + // Allowed characters in the candidate itself + // None of these can come after a closing bracket `]` + b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'@' + if self.cursor.prev != b']' => + { + /* TODO: The `b'@'` is necessary for custom separators like _@, maybe we can handle this in a better way... */ + trace!("Candidate::Consume\t"); + } + + // A dot (.) can only appear in the candidate itself (not the arbitrary part), if the previous + // and next characters are both digits. This covers the following cases: + // - p-1.5 + b'.' if self.cursor.prev.is_ascii_digit() => match self.cursor.next { + next if next.is_ascii_digit() => { + trace!("Candidate::Consume\t"); + } + _ => return ParseAction::Skip, + }, + + // Allowed characters in the candidate itself + // These MUST NOT appear at the end of the candidate + b'/' | b':' if !self.cursor.at_end => { + trace!("Candidate::Consume\t"); + } + + // The important character `!`, is allowed at the end of the candidate + b'!' => { + trace!("Candidate::Consume\t"); + } + + _ => return ParseAction::Skip, + } + + ParseAction::Consume + } + + #[inline(always)] + fn can_be_candidate(&mut self) -> bool { + self.in_candidate + && !self.in_arbitrary + && (0..=127).contains(&self.cursor.curr) + && (self.idx_start == 0 || self.input[self.idx_start - 1] <= 127) + } + + #[inline(always)] + fn handle_skip(&mut self) { + // In all other cases, we skip characters and reset everything so we can make new candidates + trace!("Characters::Skip\t"); + self.idx_start = self.cursor.pos; + self.idx_end = self.cursor.pos; + self.in_candidate = false; + self.in_arbitrary = false; + self.in_escape = false; + } + + #[inline(always)] + fn parse_char(&mut self) -> ParseAction<'a> { + if self.in_arbitrary { + self.parse_arbitrary() + } else if self.in_candidate { + self.parse_continue() + } else if self.parse_start() == ParseAction::Consume { + self.in_candidate = true; + self.idx_start = self.cursor.pos; + self.idx_end = self.cursor.pos; + + ParseAction::Consume + } else { + ParseAction::Skip + } + } + + #[inline(always)] + fn yield_candidate(&mut self) -> ParseAction<'a> { + if self.can_be_candidate() { + self.get_current_candidate() + } else { + ParseAction::Consume + } + } + + #[inline(always)] + fn restart(&mut self, pos: usize) { + trace!("Parser::Restart\t{}", pos); + + self.idx_start = pos; + self.idx_end = pos; + self.idx_arbitrary_start = 0; + + self.in_arbitrary = false; + self.in_candidate = false; + self.in_escape = false; + + self.discard_next = false; + + self.quote_stack.clear(); + self.bracket_stack.clear(); + self.cursor.move_to(pos); + } + + #[inline(always)] + fn without_surrounding(&self) -> Bracketing<'a> { + let range = self.idx_start..=self.idx_end; + let clipped = &self.input[range]; + + Self::slice_surrounding(clipped) + .map(Bracketing::Included) + .or_else(|| { + if self.idx_start == 0 || self.idx_end + 1 == self.idx_last { + return None; + } + + let range = self.idx_start - 1..=self.idx_end + 1; + let clipped = &self.input[range]; + Self::slice_surrounding(clipped).map(Bracketing::Wrapped) + }) + .unwrap_or(Bracketing::None) + } + + #[inline(always)] + fn is_balanced(input: &[u8]) -> bool { + let mut depth = 0isize; + + for n in input { + match n { + b'[' | b'{' | b'(' => depth += 1, + b']' | b'}' | b')' => depth -= 1, + _ => continue, + } + + if depth < 0 { + return false; + } + } + + depth == 0 + } + + #[inline(always)] + fn slice_surrounding(input: &[u8]) -> Option<&[u8]> { + let mut prev = None; + let mut input = input; + + loop { + let leading = input.first().unwrap_or(&0x00); + let trailing = input.last().unwrap_or(&0x00); + + let needed = matches!( + (leading, trailing), + (b'(', b')') + | (b'{', b'}') + | (b'[', b']') + | (b'"', b'"') + | (b'`', b'`') + | (b'\'', b'\'') + ); + + if needed { + prev = Some(input); + input = &input[1..input.len() - 1]; + continue; + } + if Self::is_balanced(input) && prev.is_some() { + return Some(input); + } + return prev; + } + } + + #[inline(always)] + fn parse_and_yield(&mut self) -> ParseAction<'a> { + trace!("Cursor {}", self.cursor); + + // Fast skipping of invalid characters + let can_skip_whitespace = false; // if self.opts.preserve_spaces_in_arbitrary { !self.in_arbitrary } else { true }; + if can_skip_whitespace { + if let Some(pos) = fast_skip(&self.cursor) { + trace!("FastSkip::Restart\t{}", pos); + return ParseAction::RestartAt(pos); + } + } + + let action = self.parse_char(); + + match action { + ParseAction::RestartAt(_) => return action, + ParseAction::Consume => { + self.idx_end = self.cursor.pos; + + // If we're still consuming characters, we keep going + // Only exception is if we've hit the end of the input + if !self.cursor.at_end { + return action; + } + } + _ => {} + } + + let action = self.yield_candidate(); + + match (&action, self.cursor.curr) { + (ParseAction::RestartAt(_), _) => action, + (_, 0x00) => ParseAction::Done, + (ParseAction::SingleCandidate(candidate), _) => self.generate_slices(candidate), + _ => ParseAction::RestartAt(self.cursor.pos + 1), + } + } + + /// Peek inside `[]`, `{}`, and `()` pairs + /// to look for an additional candidate + #[inline(always)] + fn generate_slices(&mut self, candidate: &'a [u8]) -> ParseAction<'a> { + match self.without_surrounding() { + Bracketing::None => ParseAction::SingleCandidate(candidate), + Bracketing::Included(sliceable) if sliceable == candidate => { + ParseAction::SingleCandidate(candidate) + } + Bracketing::Included(sliceable) | Bracketing::Wrapped(sliceable) => { + let parts = vec![candidate, sliceable]; + let parts = parts + .into_iter() + .filter(|v| !v.is_empty()) + .collect::>(); + + ParseAction::MultipleCandidates(parts) + } + } + } +} + +impl<'a> Iterator for Extractor<'a> { + type Item = Vec<&'a [u8]>; + + fn next(&mut self) -> Option { + if self.cursor.at_end { + return None; + } + + loop { + let result = self.parse_and_yield(); + + // Cursor control + match result { + ParseAction::RestartAt(pos) => self.restart(pos), + _ => self.cursor.advance_by(1), + } + + // Candidate state control + match result { + ParseAction::SingleCandidate(_) => self.handle_skip(), + ParseAction::MultipleCandidates(_) => self.handle_skip(), + _ => {} + } + + // Iterator results + return match result { + ParseAction::SingleCandidate(candidate) => Some(vec![candidate]), + ParseAction::MultipleCandidates(candidates) => Some(candidates), + ParseAction::Done => None, + _ => continue, + }; + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + fn _please_trace() { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::TRACE) + .with_span_events(tracing_subscriber::fmt::format::FmtSpan::ACTIVE) + .compact() + .init(); + } + + fn run(input: &str, loose: bool) -> Vec<&str> { + Extractor::unique_ord( + input.as_bytes(), + ExtractorOptions { + preserve_spaces_in_arbitrary: loose, + }, + ) + .into_iter() + .map(|s| unsafe { std::str::from_utf8_unchecked(s) }) + .collect() + } + + #[test] + fn it_can_parse_simple_candidates() { + let candidates = run("underline", false); + assert_eq!(candidates, vec!["underline"]); + } + + #[test] + fn it_can_parse_multiple_simple_utilities() { + let candidates = run("font-bold underline", false); + assert_eq!(candidates, vec!["font-bold", "underline"]); + } + + #[test] + fn it_can_parse_simple_candidates_with_variants() { + let candidates = run("hover:underline", false); + assert_eq!(candidates, vec!["hover:underline"]); + } + + #[test] + fn it_can_parse_start_variants() { + let candidates = run("*:underline", false); + assert_eq!(candidates, vec!["*:underline"]); + + let candidates = run("hover:*:underline", false); + assert_eq!(candidates, vec!["hover:*:underline"]); + } + + #[test] + fn it_can_parse_simple_candidates_with_stacked_variants() { + let candidates = run("focus:hover:underline", false); + assert_eq!(candidates, vec!["focus:hover:underline"]); + } + + #[test] + fn it_can_parse_utilities_with_arbitrary_values() { + let candidates = run("m-[2px]", false); + assert_eq!(candidates, vec!["m-[2px]"]); + } + + #[test] + fn it_throws_away_arbitrary_values_that_are_unbalanced() { + let candidates = run("m-[calc(100px*2]", false); + assert!(candidates.is_empty()); + } + + #[test] + fn it_can_parse_utilities_with_arbitrary_values_and_variants() { + let candidates = run("hover:m-[2px]", false); + assert_eq!(candidates, vec!["hover:m-[2px]"]); + } + + #[test] + fn it_can_parse_arbitrary_variants() { + let candidates = run("[@media(min-width:200px)]:underline", false); + assert_eq!(candidates, vec!["[@media(min-width:200px)]:underline"]); + } + + #[test] + fn it_can_parse_matched_variants() { + let candidates = run("group-[&:hover]:underline", false); + assert_eq!(candidates, vec!["group-[&:hover]:underline"]); + } + + #[test] + fn it_should_not_keep_spaces() { + let candidates = run("bg-[rgba(0, 0, 0)]", false); + + assert_eq!(candidates, vec!["rgba"]); + } + + #[test] + fn it_should_keep_spaces_in_loose_mode() { + let candidates = run("bg-[rgba(0, 0, 0)]", true); + assert_eq!(candidates, vec!["bg-[rgba(0, 0, 0)]"]); + } + + #[test] + fn it_should_keep_important_arbitrary_properties_legacy() { + let candidates = run("![foo:bar]", false); + assert_eq!(candidates, vec!["![foo:bar]"]); + } + + #[test] + fn it_should_keep_important_arbitrary_properties() { + let candidates = run("[foo:bar]!", false); + assert_eq!(candidates, vec!["[foo:bar]!"]); + } + + #[test] + fn it_should_keep_important_arbitrary_values() { + let candidates = run("w-[calc(var(--size)/2)]!", false); + assert_eq!(candidates, vec!["w-[calc(var(--size)/2)]!"]); + } + + #[test] + fn it_should_keep_important_candidates_legacy() { + let candidates = run("!w-4", false); + assert_eq!(candidates, vec!["!w-4"]); + } + + #[test] + fn it_should_keep_important_candidates() { + let candidates = run("w-4!", false); + assert_eq!(candidates, vec!["w-4!"]); + } + + #[test] + fn it_should_not_allow_for_bogus_candidates() { + let candidates = run("[0]", false); + assert!(candidates.is_empty()); + + let candidates = run("[something]", false); + assert_eq!(candidates, vec!["something"]); + + let candidates = run(" [feature(slice_as_chunks)]", false); + assert_eq!(candidates, vec!["feature(slice_as_chunks)"]); + + let candidates = run("![feature(slice_as_chunks)]", false); + assert!(candidates.is_empty()); + + let candidates = run("-[feature(slice_as_chunks)]", false); + assert!(candidates.is_empty()); + + let candidates = run("!-[feature(slice_as_chunks)]", false); + assert!(candidates.is_empty()); + + let candidates = run("-[foo:bar]", false); + assert!(candidates.is_empty()); + + let candidates = run("!-[foo:bar]", false); + assert!(candidates.is_empty()); + } + + #[test] + fn it_should_keep_candidates_with_brackets_in_arbitrary_values_inside_quotes() { + let candidates = run("content-['hello_[_]_world']", false); + assert_eq!(candidates, vec!["content-['hello_[_]_world']"]); + } + + #[test] + fn it_should_ignore_leading_spaces() { + let candidates = run(" backdrop-filter-none", false); + assert_eq!(candidates, vec!["backdrop-filter-none"]); + } + + #[test] + fn it_should_ignore_trailing_spaces() { + let candidates = run("backdrop-filter-none ", false); + assert_eq!(candidates, vec!["backdrop-filter-none"]); + } + + #[test] + fn it_should_keep_classes_before_an_ending_newline() { + let candidates = run("backdrop-filter-none\n", false); + assert_eq!(candidates, vec!["backdrop-filter-none"]); + } + + #[test] + fn it_should_parse_out_the_correct_classes_from_tailwind_tests() { + // From: tests/arbitrary-variants.test.js + let candidates = run( + r#" +
                          + +
                          +
                          + "#, + false, + ); + // TODO: it should not include additional (separate) classes: class, hover:, foo: bar, underline + // TODO: Double check the expectations based on above information + assert_eq!( + candidates, + vec![ + "div", + "class", + r#"dark:lg:hover:[&>*]:underline"#, + r#"[&_.foo\_\_bar]:hover:underline"#, + r#"hover:[&_.foo\_\_bar]:underline"# + ] + ); + } + + #[test] + fn potential_candidates_are_skipped_when_hitting_impossible_characters() { + let candidates = run("

                          A new software update is available. See what’s new in version 2.0.4.

                          ", false); + assert_eq!( + candidates, + vec![ + "p", + "class", + "text-sm", + "text-blue-700", + // "A", // Uppercase first letter is not allowed + "new", + "software", + "update", + "is", + "available", + // "See", // Uppercase first letter is not allowed + // "what", // what is dropped because it is followed by the fancy: ’ + // "s", // s is dropped because it is preceded by the fancy: ’ + // "new", // Already seen + "in", + "version", + ] + ); + } + + #[test] + fn ignores_arbitrary_property_ish_things() { + // FIXME: () are only valid in an arbitrary + let candidates = run(" [feature(slice_as_chunks)]", false); + assert_eq!(candidates, vec!["feature(slice_as_chunks)",]); + } + + #[test] + fn foo_bar() { + // w[…] is not a valid pattern for part of candidate + // but @[] is (specifically in the context of a variant) + + let candidates = run("%w[text-[#bada55]]", false); + assert_eq!(candidates, vec!["w", "text-[#bada55]"]); + } + + #[test] + fn crash_001() { + let candidates = run("Aҿɿ[~5", false); + assert!(candidates.is_empty()); + } + + #[test] + fn crash_002() { + let candidates = run("", false); + assert!(candidates.is_empty()); + } + + #[test] + fn bad_001() { + let candidates = run("[杛杛]/", false); + assert_eq!(candidates, vec!["杛杛"]); + } + + #[test] + fn bad_002() { + let candidates = run(r"[\]\\\:[]", false); + assert!(candidates.is_empty()); + } + + #[test] + fn bad_003() { + // TODO: This seems… wrong + let candidates = run(r"[𕤵:]", false); + assert_eq!(candidates, vec!["𕤵", "𕤵:"]); + } + + #[test] + fn classes_in_js_arrays() { + let candidates = run( + r#"let classes = ['bg-black', 'hover:px-0.5', 'text-[13px]', '[--my-var:1_/_2]', '[.foo_&]:px-[0]', '[.foo_&]:[color:red]']">"#, + false, + ); + assert_eq!( + candidates, + vec![ + "let", + "classes", + "bg-black", + "hover:px-0.5", + "text-[13px]", + "[--my-var:1_/_2]", + "--my-var:1_/_2", + "[.foo_&]:px-[0]", + "[.foo_&]:[color:red]", + ] + ); + } + + #[test] + fn classes_in_js_arrays_without_spaces() { + let candidates = run( + r#"let classes = ['bg-black','hover:px-0.5','text-[13px]','[--my-var:1_/_2]','[.foo_&]:px-[0]','[.foo_&]:[color:red]']">"#, + false, + ); + assert_eq!( + candidates, + vec![ + "let", + "classes", + "bg-black", + "hover:px-0.5", + "text-[13px]", + "[--my-var:1_/_2]", + "--my-var:1_/_2", + "[.foo_&]:px-[0]", + "[.foo_&]:[color:red]", + ] + ); + } + + #[test] + fn classes_as_object_keys() { + let candidates = run( + r#"
                          "#, + false, + ); + assert_eq!( + candidates, + vec!["div", "underline", "active", "px-1.5", "online"] + ); + } + + #[test] + fn multiple_nested_candidates() { + let candidates = run(r#"{color:red}"#, false); + assert_eq!(candidates, vec!["color:red"]); + } + + #[test] + fn percent_ended_candidates() { + let candidates = run( + r#""#, + false, + ); + assert_eq!( + candidates, + vec!["should", "work", "underline", "from-50%", "flex",] + ); + } + + #[test] + fn candidate_cannot_start_with_uppercase_character() { + let candidates = run(r#"
                          "#, false); + assert_eq!(candidates, vec!["div", "class", "foo", "baz"]); + } + + #[test] + fn candidate_cannot_end_with_a_dash() { + let candidates = run(r#"
                          "#, false); + assert_eq!(candidates, vec!["div", "class", "foo", "baz"]); + } + + #[test] + fn candidate_cannot_end_with_an_underscore() { + let candidates = run(r#"
                          "#, false); + assert_eq!(candidates, vec!["div", "class", "foo", "baz"]); + } + + #[test] + fn candidate_cannot_be_a_single_camelcase_word() { + let candidates = run(r#"
                          "#, false); + assert_eq!(candidates, vec!["div", "class", "foo", "baz"]); + } + + #[test] + fn candidate_cannot_be_svg_path_data() { + let candidates = run(r#""#, false); + assert_eq!(candidates, vec!["path", "d"]); + } + + #[test] + fn candidate_cannot_be_email_or_version_constraint() { + let candidates = run(r#"
                          next@latest"#, false); + assert_eq!(candidates, vec!["div", "class", "@container/dialog"]); + } + + #[test] + fn candidate_cannot_be_a_url() { + let candidates = run( + r#"Our website is https://example.com or http://example.com if you want a virus"#, + false, + ); + assert_eq!( + candidates, + vec!["website", "is", "com", "or", "if", "you", "want", "a", "virus"] + ); + } + + #[test] + fn candidate_cannot_be_a_paths_with_aliases() { + let candidates = run(r#"import potato from '@/potato';"#, false); + assert_eq!(candidates, vec!["import", "potato", "from"]); + } + + #[test] + fn candidate_cannot_be_a_path() { + let candidates = run( + r#"import potato from 'some/path/to/something'; + import banana from '@/banana';"#, + false, + ); + assert_eq!(candidates, vec!["import", "potato", "from", "banana"]); + } + + #[test] + fn ruby_percent_formatted_strings() { + let candidates = run(r#"%w[hover:flex]"#, false); + assert_eq!(candidates, vec!["w", "hover:flex"]); + } + + #[test] + fn urls_in_arbitrary_values_are_ok() { + let candidates = run(r#"
                          "#, false); + assert_eq!( + candidates, + vec!["div", "class", "bg-[url('/img/hero-pattern.svg')]"] + ); + } + + #[test] + fn colon_in_arbitrary_property_value() { + let candidates = run("[color::] #[test::foo]", false); + assert!(candidates + .iter() + .all(|candidate| !candidate.starts_with('['))); + } + + #[test] + fn braces_in_arbitrary_property_value() { + let candidates = run("[color:${foo}] #[test:{foo}]", false); + assert!(candidates + .iter() + .all(|candidate| !candidate.starts_with('['))); + } + + #[test] + fn quoted_colon_in_arbitrary_property_value() { + let candidates = run("[content:'bar:bar'] [content:\"bar:bar\"]", false); + assert!(candidates + .iter() + .any(|candidate| candidate == &"[content:'bar:bar']")); + assert!(candidates + .iter() + .any(|candidate| candidate == &"[content:\"bar:bar\"]")); + } + + #[test] + fn quoted_braces_in_arbitrary_property_value() { + let candidates = run("[content:'{bar}'] [content:\"{bar}\"]", false); + assert!(candidates + .iter() + .any(|candidate| candidate == &"[content:'{bar}']")); + assert!(candidates + .iter() + .any(|candidate| candidate == &"[content:\"{bar}\"]")); + } + + #[test] + fn colon_in_custom_property_value() { + let candidates = run("[--foo:bar:bar]", false); + assert!(candidates + .iter() + .any(|candidate| candidate == &"[--foo:bar:bar]")); + } + + #[test] + fn braces_in_custom_property_value() { + let candidates = run("[--foo:{bar}]", false); + assert!(candidates + .iter() + .any(|candidate| candidate == &"[--foo:{bar}]")); + } + + #[test] + fn candidate_slicing() { + let result = Extractor::slice_surrounding(&b".foo_&]:px-[0"[..]) + .map(std::str::from_utf8) + .transpose() + .unwrap(); + assert_eq!(result, None); + + let result = Extractor::slice_surrounding(&b"[.foo_&]:px-[0]"[..]) + .map(std::str::from_utf8) + .transpose() + .unwrap(); + assert_eq!(result, Some("[.foo_&]:px-[0]")); + + let result = Extractor::slice_surrounding(&b"{[.foo_&]:px-[0]}"[..]) + .map(std::str::from_utf8) + .transpose() + .unwrap(); + assert_eq!(result, Some("[.foo_&]:px-[0]")); + + let result = Extractor::slice_surrounding(&b"![foo:bar]"[..]) + .map(std::str::from_utf8) + .transpose() + .unwrap(); + assert_eq!(result, None); + + let result = Extractor::slice_surrounding(&b"[\"pt-1.5\"]"[..]) + .map(std::str::from_utf8) + .transpose() + .unwrap(); + assert_eq!(result, Some("pt-1.5")); + + let count = 1_000; + let crazy = format!("{}[.foo_&]:px-[0]{}", "[".repeat(count), "]".repeat(count)); + + let result = Extractor::slice_surrounding(crazy.as_bytes()) + .map(std::str::from_utf8) + .transpose() + .unwrap(); + assert_eq!(result, Some("[.foo_&]:px-[0]")); + } +} diff --git a/oxide/crates/core/tests/auto_content.rs b/oxide/crates/core/tests/auto_content.rs new file mode 100644 index 000000000..6c0217079 --- /dev/null +++ b/oxide/crates/core/tests/auto_content.rs @@ -0,0 +1,305 @@ +#[cfg(test)] +mod auto_content { + use std::fs; + use std::process::Command; + + use tailwindcss_core::*; + use tempfile::tempdir; + + fn scan(paths_with_content: &[(&str, Option<&str>)]) -> (Vec, Vec) { + // Create a temporary working directory + let dir = tempdir().unwrap().into_path(); + + // Initialize this directory as a git repository + let _ = Command::new("git").arg("init").current_dir(&dir).output(); + + // Create the necessary files + for (path, contents) in paths_with_content { + let path = dir.join(path); + let parent = path.parent().unwrap(); + if !parent.exists() { + fs::create_dir_all(parent).unwrap(); + } + + match contents { + Some(contents) => fs::write(path, contents).unwrap(), + None => fs::write(path, "").unwrap(), + } + } + + let base = format!("{}", dir.display()); + + // Resolve all content paths for the (temporary) current working directory + let result = scan_dir(ScanOptions { + base: base.clone(), + globs: true, + }); + + let mut paths: Vec<_> = result + .files + .into_iter() + .map(|x| x.replace(&format!("{}/", &base), "")) + .collect(); + + for glob in result.globs { + paths.push(format!("{}/{}", glob.base, glob.glob)); + } + + paths = paths + .into_iter() + .map(|x| { + let parent_dir = format!("{}/", &base.to_string()); + x.replace(&parent_dir, "") + }) + .collect(); + + // Sort the output for easier comparison (depending on internal datastructure the order + // _could_ be random) + paths.sort(); + + (paths, result.candidates) + } + + fn test(paths_with_content: &[(&str, Option<&str>)]) -> Vec { + scan(paths_with_content).0 + } + + #[test] + fn it_should_work_with_a_set_of_root_files() { + let globs = test(&[ + ("index.html", None), + ("a.html", None), + ("b.html", None), + ("c.html", None), + ]); + assert_eq!(globs, vec!["a.html", "b.html", "c.html", "index.html"]); + } + + #[test] + fn it_should_work_with_a_set_of_root_files_and_ignore_ignored_files() { + let globs = test(&[ + (".gitignore", Some("b.html")), + ("index.html", None), + ("a.html", None), + ("b.html", None), + ("c.html", None), + ]); + assert_eq!(globs, vec!["a.html", "c.html", "index.html"]); + } + + #[test] + fn it_should_list_all_files_in_the_public_folder_explicitly() { + let globs = test(&[ + ("index.html", None), + ("public/a.html", None), + ("public/b.html", None), + ("public/c.html", None), + ]); + assert_eq!( + globs, + vec![ + "index.html", + "public/a.html", + "public/b.html", + "public/c.html", + ] + ); + } + + #[test] + fn it_should_list_nested_folders_explicitly_in_the_public_folder() { + let globs = test(&[ + ("index.html", None), + ("public/a.html", None), + ("public/b.html", None), + ("public/c.html", None), + ("public/nested/a.html", None), + ("public/nested/b.html", None), + ("public/nested/c.html", None), + ("public/nested/again/a.html", None), + ("public/very/deeply/nested/a.html", None), + ]); + assert_eq!( + globs, + vec![ + "index.html", + "public/a.html", + "public/b.html", + "public/c.html", + "public/nested/a.html", + "public/nested/again/a.html", + "public/nested/b.html", + "public/nested/c.html", + "public/very/deeply/nested/a.html", + ] + ); + } + + #[test] + fn it_should_list_all_files_in_the_public_folder_explicitly_except_ignored_files() { + let globs = test(&[ + (".gitignore", Some("public/b.html\na.html")), + ("index.html", None), + ("public/a.html", None), + ("public/b.html", None), + ("public/c.html", None), + ]); + assert_eq!(globs, vec!["index.html", "public/c.html",]); + } + + #[test] + fn it_should_use_a_glob_for_top_level_folders() { + let globs = test(&[ + ("index.html", None), + ("src/a.html", None), + ("src/b.html", None), + ("src/c.html", None), + ]); + assert_eq!(globs, vec![ + "index.html", + "src/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}", + "src/a.html", + "src/b.html", + "src/c.html" + ]); + } + + #[test] + fn it_should_ignore_binary_files() { + let globs = test(&[ + ("index.html", None), + ("a.mp4", None), + ("b.png", None), + ("c.lock", None), + ]); + assert_eq!(globs, vec!["index.html"]); + } + + #[test] + fn it_should_ignore_known_extensions() { + let globs = test(&[ + ("index.html", None), + ("a.css", None), + ("b.sass", None), + ("c.less", None), + ]); + assert_eq!(globs, vec!["index.html"]); + } + + #[test] + fn it_should_ignore_known_files() { + let globs = test(&[ + ("index.html", None), + ("package-lock.json", None), + ("yarn.lock", None), + ]); + assert_eq!(globs, vec!["index.html"]); + } + + #[test] + fn it_should_ignore_and_expand_nested_ignored_folders() { + let globs = test(&[ + // Explicitly listed root files + ("foo.html", None), + ("bar.html", None), + ("baz.html", None), + // Nested folder A, using glob + ("nested-a/foo.html", None), + ("nested-a/bar.html", None), + ("nested-a/baz.html", None), + // Nested folder B, with deeply nested files, using glob + ("nested-b/deeply-nested/foo.html", None), + ("nested-b/deeply-nested/bar.html", None), + ("nested-b/deeply-nested/baz.html", None), + // Nested folder C, with ignored sub-folder + ("nested-c/foo.html", None), + ("nested-c/bar.html", None), + ("nested-c/baz.html", None), + // Ignored folder + ("nested-c/.gitignore", Some("ignored-folder/")), + ("nested-c/ignored-folder/foo.html", None), + ("nested-c/ignored-folder/bar.html", None), + ("nested-c/ignored-folder/baz.html", None), + // Deeply nested, without issues + ("nested-c/sibling-folder/foo.html", None), + ("nested-c/sibling-folder/bar.html", None), + ("nested-c/sibling-folder/baz.html", None), + // Nested folder D, with deeply nested ignored folder + ("nested-d/foo.html", None), + ("nested-d/bar.html", None), + ("nested-d/baz.html", None), + ("nested-d/.gitignore", Some("deep/")), + ("nested-d/very/deeply/nested/deep/foo.html", None), + ("nested-d/very/deeply/nested/deep/bar.html", None), + ("nested-d/very/deeply/nested/deep/baz.html", None), + ("nested-d/very/deeply/nested/foo.html", None), + ("nested-d/very/deeply/nested/bar.html", None), + ("nested-d/very/deeply/nested/baz.html", None), + ("nested-d/very/deeply/nested/directory/foo.html", None), + ("nested-d/very/deeply/nested/directory/bar.html", None), + ("nested-d/very/deeply/nested/directory/baz.html", None), + ("nested-d/very/deeply/nested/directory/again/foo.html", None), + ]); + + assert_eq!( + globs, + vec![ + "bar.html", + "baz.html", + "foo.html", + "nested-a/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}", + "nested-a/bar.html", + "nested-a/baz.html", + "nested-a/foo.html", + "nested-b/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}", + "nested-b/deeply-nested/bar.html", + "nested-b/deeply-nested/baz.html", + "nested-b/deeply-nested/foo.html", + "nested-c/*/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}", + "nested-c/bar.html", + "nested-c/baz.html", + "nested-c/foo.html", + "nested-c/sibling-folder/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}", + "nested-c/sibling-folder/bar.html", + "nested-c/sibling-folder/baz.html", + "nested-c/sibling-folder/foo.html", + "nested-d/*/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}", + "nested-d/bar.html", + "nested-d/baz.html", + "nested-d/foo.html", + "nested-d/very/*/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}", + "nested-d/very/deeply/*/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}", + "nested-d/very/deeply/nested/*/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}", + "nested-d/very/deeply/nested/bar.html", + "nested-d/very/deeply/nested/baz.html", + "nested-d/very/deeply/nested/directory/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}", + "nested-d/very/deeply/nested/directory/again/foo.html", + "nested-d/very/deeply/nested/directory/bar.html", + "nested-d/very/deeply/nested/directory/baz.html", + "nested-d/very/deeply/nested/directory/foo.html", + "nested-d/very/deeply/nested/foo.html" + ] + ); + } + + #[test] + fn it_should_scan_for_utilities() { + let mut ignores = String::new(); + ignores.push_str("# md:font-bold\n"); + ignores.push_str("foo.html\n"); + + let candidates = scan(&[ + // The gitignore file is used to filter out files but not scanned for candidates + (".gitignore", Some(&ignores)), + // A file that should definitely be scanned + ("index.html", Some("font-bold md:flex")), + // A file that should definitely not be scanned + ("foo.jpg", Some("xl:font-bold")), + // A file that is ignored + ("foo.html", Some("lg:font-bold")), + ]) + .1; + + assert_eq!(candidates, vec!["font-bold", "md:flex"]); + } +} diff --git a/oxide/crates/node/.cargo/config.toml b/oxide/crates/node/.cargo/config.toml new file mode 100644 index 000000000..6890aa910 --- /dev/null +++ b/oxide/crates/node/.cargo/config.toml @@ -0,0 +1,7 @@ +[target.aarch64-unknown-linux-gnu] +linker = "aarch64-linux-gnu-gcc" +[target.aarch64-unknown-linux-musl] +linker = "aarch64-linux-musl-gcc" +rustflags = ["-C", "target-feature=-crt-static"] +[target.armv7-unknown-linux-gnueabihf] +linker = "arm-linux-gnueabihf-gcc" \ No newline at end of file diff --git a/crates/node/.gitignore b/oxide/crates/node/.gitignore similarity index 94% rename from crates/node/.gitignore rename to oxide/crates/node/.gitignore index c3f2ec696..2cb3cf980 100644 --- a/crates/node/.gitignore +++ b/oxide/crates/node/.gitignore @@ -121,7 +121,7 @@ dist .AppleDouble .LSOverride -# Icon must end with two +# Icon must end with two Icon @@ -194,14 +194,8 @@ Cargo.lock !.yarn/sdks !.yarn/versions -# Generated *.node -*.wasm + +# Generated index.d.ts index.js -browser.js -tailwindcss-oxide.wasi-browser.js -tailwindcss-oxide.wasi.cjs -tailwindcss-oxide.wasi.d.cts -wasi-worker-browser.mjs -wasi-worker.mjs diff --git a/crates/node/.npmignore b/oxide/crates/node/.npmignore similarity index 100% rename from crates/node/.npmignore rename to oxide/crates/node/.npmignore diff --git a/crates/node/Cargo.toml b/oxide/crates/node/Cargo.toml similarity index 59% rename from crates/node/Cargo.toml rename to oxide/crates/node/Cargo.toml index a3df7b813..0ded64922 100644 --- a/crates/node/Cargo.toml +++ b/oxide/crates/node/Cargo.toml @@ -8,10 +8,11 @@ crate-type = ["cdylib"] [dependencies] # Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix -napi = { version = "3.11.0", default-features = false, features = ["napi4"] } -napi-derive = "3.6.0" -tailwindcss-oxide = { path = "../oxide" } -rayon = "1.12.0" +napi = { version = "2.13.1", default-features = false, features = ["napi4"] } +napi-derive = "2.13.0" +tailwindcss-core = { path = "../core" } +rayon = "1.5.3" [build-dependencies] -napi-build = "2.3.2" +napi-build = "2.0.1" + diff --git a/crates/node/build.rs b/oxide/crates/node/build.rs similarity index 100% rename from crates/node/build.rs rename to oxide/crates/node/build.rs diff --git a/crates/node/npm/android-arm-eabi/README.md b/oxide/crates/node/npm/android-arm-eabi/README.md similarity index 100% rename from crates/node/npm/android-arm-eabi/README.md rename to oxide/crates/node/npm/android-arm-eabi/README.md diff --git a/crates/node/npm/android-arm-eabi/package.json b/oxide/crates/node/npm/android-arm-eabi/package.json similarity index 79% rename from crates/node/npm/android-arm-eabi/package.json rename to oxide/crates/node/npm/android-arm-eabi/package.json index d46ceb237..1425e7d60 100644 --- a/crates/node/npm/android-arm-eabi/package.json +++ b/oxide/crates/node/npm/android-arm-eabi/package.json @@ -1,10 +1,10 @@ { "name": "@tailwindcss/oxide-android-arm-eabi", - "version": "4.3.3", + "version": "0.0.0-internal.b2586d4e", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", - "directory": "crates/node/npm/android-arm-eabi" + "directory": "oxide/crates/node/npm/android-arm-eabi" }, "os": [ "android" @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" } } diff --git a/crates/node/npm/android-arm64/README.md b/oxide/crates/node/npm/android-arm64/README.md similarity index 100% rename from crates/node/npm/android-arm64/README.md rename to oxide/crates/node/npm/android-arm64/README.md diff --git a/crates/node/npm/android-arm64/package.json b/oxide/crates/node/npm/android-arm64/package.json similarity index 79% rename from crates/node/npm/android-arm64/package.json rename to oxide/crates/node/npm/android-arm64/package.json index e6e9d577d..ade255977 100644 --- a/crates/node/npm/android-arm64/package.json +++ b/oxide/crates/node/npm/android-arm64/package.json @@ -1,10 +1,10 @@ { "name": "@tailwindcss/oxide-android-arm64", - "version": "4.3.3", + "version": "0.0.0-internal.b2586d4e", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", - "directory": "crates/node/npm/android-arm64" + "directory": "oxide/crates/node/npm/android-arm64" }, "os": [ "android" @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" } } diff --git a/crates/node/npm/darwin-arm64/README.md b/oxide/crates/node/npm/darwin-arm64/README.md similarity index 100% rename from crates/node/npm/darwin-arm64/README.md rename to oxide/crates/node/npm/darwin-arm64/README.md diff --git a/crates/node/npm/darwin-arm64/package.json b/oxide/crates/node/npm/darwin-arm64/package.json similarity index 79% rename from crates/node/npm/darwin-arm64/package.json rename to oxide/crates/node/npm/darwin-arm64/package.json index 1cb737c2e..a6a345b22 100644 --- a/crates/node/npm/darwin-arm64/package.json +++ b/oxide/crates/node/npm/darwin-arm64/package.json @@ -1,10 +1,10 @@ { "name": "@tailwindcss/oxide-darwin-arm64", - "version": "4.3.3", + "version": "0.0.0-internal.b2586d4e", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", - "directory": "crates/node/npm/darwin-arm64" + "directory": "oxide/crates/node/npm/darwin-arm64" }, "os": [ "darwin" @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" } } diff --git a/crates/node/npm/darwin-x64/README.md b/oxide/crates/node/npm/darwin-x64/README.md similarity index 100% rename from crates/node/npm/darwin-x64/README.md rename to oxide/crates/node/npm/darwin-x64/README.md diff --git a/crates/node/npm/darwin-x64/package.json b/oxide/crates/node/npm/darwin-x64/package.json similarity index 79% rename from crates/node/npm/darwin-x64/package.json rename to oxide/crates/node/npm/darwin-x64/package.json index ffd75e54b..5a25439f9 100644 --- a/crates/node/npm/darwin-x64/package.json +++ b/oxide/crates/node/npm/darwin-x64/package.json @@ -1,10 +1,10 @@ { "name": "@tailwindcss/oxide-darwin-x64", - "version": "4.3.3", + "version": "0.0.0-internal.b2586d4e", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", - "directory": "crates/node/npm/darwin-x64" + "directory": "oxide/crates/node/npm/darwin-x64" }, "os": [ "darwin" @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" } } diff --git a/crates/node/npm/freebsd-x64/README.md b/oxide/crates/node/npm/freebsd-x64/README.md similarity index 100% rename from crates/node/npm/freebsd-x64/README.md rename to oxide/crates/node/npm/freebsd-x64/README.md diff --git a/crates/node/npm/freebsd-x64/package.json b/oxide/crates/node/npm/freebsd-x64/package.json similarity index 79% rename from crates/node/npm/freebsd-x64/package.json rename to oxide/crates/node/npm/freebsd-x64/package.json index e248f39d1..128e4bf65 100644 --- a/crates/node/npm/freebsd-x64/package.json +++ b/oxide/crates/node/npm/freebsd-x64/package.json @@ -1,10 +1,10 @@ { "name": "@tailwindcss/oxide-freebsd-x64", - "version": "4.3.3", + "version": "0.0.0-internal.b2586d4e", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", - "directory": "crates/node/npm/freebsd-x64" + "directory": "oxide/crates/node/npm/freebsd-x64" }, "os": [ "freebsd" @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" } } diff --git a/crates/node/npm/linux-arm-gnueabihf/README.md b/oxide/crates/node/npm/linux-arm-gnueabihf/README.md similarity index 100% rename from crates/node/npm/linux-arm-gnueabihf/README.md rename to oxide/crates/node/npm/linux-arm-gnueabihf/README.md diff --git a/crates/node/npm/linux-arm-gnueabihf/package.json b/oxide/crates/node/npm/linux-arm-gnueabihf/package.json similarity index 78% rename from crates/node/npm/linux-arm-gnueabihf/package.json rename to oxide/crates/node/npm/linux-arm-gnueabihf/package.json index 50d2aef73..ece8844f0 100644 --- a/crates/node/npm/linux-arm-gnueabihf/package.json +++ b/oxide/crates/node/npm/linux-arm-gnueabihf/package.json @@ -1,10 +1,10 @@ { "name": "@tailwindcss/oxide-linux-arm-gnueabihf", - "version": "4.3.3", + "version": "0.0.0-internal.b2586d4e", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", - "directory": "crates/node/npm/linux-arm-gnueabihf" + "directory": "oxide/crates/node/npm/linux-arm-gnueabihf" }, "os": [ "linux" @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" } } diff --git a/crates/node/npm/linux-arm64-gnu/README.md b/oxide/crates/node/npm/linux-arm64-gnu/README.md similarity index 100% rename from crates/node/npm/linux-arm64-gnu/README.md rename to oxide/crates/node/npm/linux-arm64-gnu/README.md diff --git a/crates/node/npm/linux-arm64-gnu/package.json b/oxide/crates/node/npm/linux-arm64-gnu/package.json similarity index 80% rename from crates/node/npm/linux-arm64-gnu/package.json rename to oxide/crates/node/npm/linux-arm64-gnu/package.json index 5875213f8..35c21489e 100644 --- a/crates/node/npm/linux-arm64-gnu/package.json +++ b/oxide/crates/node/npm/linux-arm64-gnu/package.json @@ -1,10 +1,10 @@ { "name": "@tailwindcss/oxide-linux-arm64-gnu", - "version": "4.3.3", + "version": "0.0.0-internal.b2586d4e", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", - "directory": "crates/node/npm/linux-arm64-gnu" + "directory": "oxide/crates/node/npm/linux-arm64-gnu" }, "os": [ "linux" @@ -22,7 +22,7 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" }, "libc": [ "glibc" diff --git a/crates/node/npm/linux-arm64-musl/README.md b/oxide/crates/node/npm/linux-arm64-musl/README.md similarity index 100% rename from crates/node/npm/linux-arm64-musl/README.md rename to oxide/crates/node/npm/linux-arm64-musl/README.md diff --git a/crates/node/npm/linux-arm64-musl/package.json b/oxide/crates/node/npm/linux-arm64-musl/package.json similarity index 80% rename from crates/node/npm/linux-arm64-musl/package.json rename to oxide/crates/node/npm/linux-arm64-musl/package.json index 9da37f3fd..3d4f5db19 100644 --- a/crates/node/npm/linux-arm64-musl/package.json +++ b/oxide/crates/node/npm/linux-arm64-musl/package.json @@ -1,10 +1,10 @@ { "name": "@tailwindcss/oxide-linux-arm64-musl", - "version": "4.3.3", + "version": "0.0.0-internal.b2586d4e", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", - "directory": "crates/node/npm/linux-arm64-musl" + "directory": "oxide/crates/node/npm/linux-arm64-musl" }, "os": [ "linux" @@ -22,7 +22,7 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" }, "libc": [ "musl" diff --git a/crates/node/npm/linux-x64-gnu/README.md b/oxide/crates/node/npm/linux-x64-gnu/README.md similarity index 100% rename from crates/node/npm/linux-x64-gnu/README.md rename to oxide/crates/node/npm/linux-x64-gnu/README.md diff --git a/crates/node/npm/linux-x64-gnu/package.json b/oxide/crates/node/npm/linux-x64-gnu/package.json similarity index 80% rename from crates/node/npm/linux-x64-gnu/package.json rename to oxide/crates/node/npm/linux-x64-gnu/package.json index d409743fa..d43c47f7b 100644 --- a/crates/node/npm/linux-x64-gnu/package.json +++ b/oxide/crates/node/npm/linux-x64-gnu/package.json @@ -1,10 +1,10 @@ { "name": "@tailwindcss/oxide-linux-x64-gnu", - "version": "4.3.3", + "version": "0.0.0-internal.b2586d4e", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", - "directory": "crates/node/npm/linux-x64-gnu" + "directory": "oxide/crates/node/npm/linux-x64-gnu" }, "os": [ "linux" @@ -22,7 +22,7 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" }, "libc": [ "glibc" diff --git a/crates/node/npm/linux-x64-musl/README.md b/oxide/crates/node/npm/linux-x64-musl/README.md similarity index 100% rename from crates/node/npm/linux-x64-musl/README.md rename to oxide/crates/node/npm/linux-x64-musl/README.md diff --git a/crates/node/npm/linux-x64-musl/package.json b/oxide/crates/node/npm/linux-x64-musl/package.json similarity index 80% rename from crates/node/npm/linux-x64-musl/package.json rename to oxide/crates/node/npm/linux-x64-musl/package.json index 5b404fd70..4767c03e3 100644 --- a/crates/node/npm/linux-x64-musl/package.json +++ b/oxide/crates/node/npm/linux-x64-musl/package.json @@ -1,10 +1,10 @@ { "name": "@tailwindcss/oxide-linux-x64-musl", - "version": "4.3.3", + "version": "0.0.0-internal.b2586d4e", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", - "directory": "crates/node/npm/linux-x64-musl" + "directory": "oxide/crates/node/npm/linux-x64-musl" }, "os": [ "linux" @@ -22,7 +22,7 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" }, "libc": [ "musl" diff --git a/crates/node/npm/win32-x64-msvc/README.md b/oxide/crates/node/npm/win32-x64-msvc/README.md similarity index 100% rename from crates/node/npm/win32-x64-msvc/README.md rename to oxide/crates/node/npm/win32-x64-msvc/README.md diff --git a/crates/node/npm/win32-x64-msvc/package.json b/oxide/crates/node/npm/win32-x64-msvc/package.json similarity index 79% rename from crates/node/npm/win32-x64-msvc/package.json rename to oxide/crates/node/npm/win32-x64-msvc/package.json index 82d75c300..36e9e4157 100644 --- a/crates/node/npm/win32-x64-msvc/package.json +++ b/oxide/crates/node/npm/win32-x64-msvc/package.json @@ -1,10 +1,10 @@ { "name": "@tailwindcss/oxide-win32-x64-msvc", - "version": "4.3.3", + "version": "0.0.0-internal.b2586d4e", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", - "directory": "crates/node/npm/win32-x64-msvc" + "directory": "oxide/crates/node/npm/win32-x64-msvc" }, "os": [ "win32" @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" } } diff --git a/oxide/crates/node/package.json b/oxide/crates/node/package.json new file mode 100644 index 000000000..ddfcb6134 --- /dev/null +++ b/oxide/crates/node/package.json @@ -0,0 +1,61 @@ +{ + "name": "@tailwindcss/oxide", + "version": "0.0.0-internal.b2586d4e", + "repository": { + "type": "git", + "url": "git+https://github.com/tailwindlabs/tailwindcss.git", + "directory": "oxide/crates/node" + }, + "main": "index.js", + "types": "index.d.ts", + "napi": { + "name": "tailwindcss-oxide", + "triples": { + "additional": [ + "armv7-linux-androideabi", + "aarch64-linux-android", + "aarch64-apple-darwin", + "aarch64-unknown-linux-gnu", + "aarch64-unknown-linux-musl", + "armv7-unknown-linux-gnueabihf", + "x86_64-unknown-linux-musl", + "x86_64-unknown-freebsd", + "i686-pc-windows-msvc" + ] + } + }, + "license": "MIT", + "devDependencies": { + "@napi-rs/cli": "^2.17.0" + }, + "engines": { + "node": ">= 10" + }, + "files": [ + "index.js", + "index.d.ts" + ], + "publishConfig": { + "provenance": true, + "access": "public" + }, + "scripts": { + "artifacts": "npx napi artifacts", + "build": "npx napi build --platform --release --no-const-enum", + "dev": "cargo watch --quiet --shell 'npm run build'", + "build:debug": "npx napi build --platform --no-const-enum", + "version": "npx napi version" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "workspace:*", + "@tailwindcss/oxide-darwin-arm64": "workspace:*", + "@tailwindcss/oxide-darwin-x64": "workspace:*", + "@tailwindcss/oxide-freebsd-x64": "workspace:*", + "@tailwindcss/oxide-linux-arm-gnueabihf": "workspace:*", + "@tailwindcss/oxide-linux-arm64-gnu": "workspace:*", + "@tailwindcss/oxide-linux-arm64-musl": "workspace:*", + "@tailwindcss/oxide-linux-x64-gnu": "workspace:*", + "@tailwindcss/oxide-linux-x64-musl": "workspace:*", + "@tailwindcss/oxide-win32-x64-msvc": "workspace:*" + } +} diff --git a/crates/node/rustfmt.toml b/oxide/crates/node/rustfmt.toml similarity index 100% rename from crates/node/rustfmt.toml rename to oxide/crates/node/rustfmt.toml diff --git a/oxide/crates/node/src/lib.rs b/oxide/crates/node/src/lib.rs new file mode 100644 index 000000000..2658cdb22 --- /dev/null +++ b/oxide/crates/node/src/lib.rs @@ -0,0 +1,89 @@ +use napi::bindgen_prelude::{FromNapiValue, ToNapiValue}; +use std::path::PathBuf; + +#[macro_use] +extern crate napi_derive; + +#[derive(Debug, Clone)] +#[napi(object)] +pub struct ChangedContent { + pub file: Option, + pub content: Option, + pub extension: String, +} + +impl From for tailwindcss_core::ChangedContent { + fn from(changed_content: ChangedContent) -> Self { + tailwindcss_core::ChangedContent { + file: changed_content.file.map(PathBuf::from), + content: changed_content.content, + } + } +} + +#[derive(Debug, Clone)] +#[napi(object)] +pub struct ScanResult { + pub globs: Vec, + pub files: Vec, + pub candidates: Vec, +} + +#[derive(Debug, Clone)] +#[napi(object)] +pub struct GlobEntry { + pub base: String, + pub glob: String, +} + +#[derive(Debug, Clone)] +#[napi(object)] +pub struct ScanOptions { + pub base: String, + pub globs: Option, +} + +#[napi] +pub fn clear_cache() { + tailwindcss_core::clear_cache(); +} + +#[napi] +pub fn scan_dir(args: ScanOptions) -> ScanResult { + let result = tailwindcss_core::scan_dir(tailwindcss_core::ScanOptions { + base: args.base, + globs: args.globs.unwrap_or(false), + }); + + ScanResult { + files: result.files, + candidates: result.candidates, + globs: result + .globs + .into_iter() + .map(|g| GlobEntry { + base: g.base, + glob: g.glob, + }) + .collect(), + } +} + +#[derive(Debug)] +#[napi] +pub enum IO { + Sequential = 0b0001, + Parallel = 0b0010, +} + +#[derive(Debug)] +#[napi] +pub enum Parsing { + Sequential = 0b0100, + Parallel = 0b1000, +} + +#[napi] +pub fn scan_files(input: Vec, strategy: u8) -> Vec { + tailwindcss_core::scan_files(input.into_iter().map(Into::into).collect(), strategy) +} diff --git a/oxide/package.json b/oxide/package.json new file mode 100644 index 000000000..6372db6c0 --- /dev/null +++ b/oxide/package.json @@ -0,0 +1,20 @@ +{ + "name": "tailwindcss-oxide", + "private": true, + "version": "0.1.0", + "workspaces": [ + "node" + ], + "scripts": { + "test": "cargo test", + "install:cargo": "cargo install cargo-watch cargo-fuzz", + "build": "cargo build --release", + "build:node": "npm --prefix ./crates/node run build", + "dev": "cargo watch --clear --quiet -x 'run --quiet'", + "dev:node": "cargo watch --clear --quiet --shell 'npm --prefix ./crates/node run build:debug'", + "fuzz": "cd ./crates/core; cargo fuzz run parsing; cd -", + "bench": "cargo bench", + "postbench": "open ./target/criterion/report/index.html" + }, + "license": "MIT" +} diff --git a/package.json b/package.json index 7d41a469e..6687e06db 100644 --- a/package.json +++ b/package.json @@ -17,17 +17,6 @@ "options": { "parser": "jsonc" } - }, - { - "files": [ - "integrations/**/*.ts" - ], - "options": { - "plugins": [ - "prettier-plugin-embed", - "prettier-plugin-organize-imports" - ] - } } ] }, @@ -35,12 +24,10 @@ "format": "prettier --write .", "lint": "prettier --check . && turbo lint", "build": "turbo build --filter=!./playgrounds/*", - "postbuild": "node ./scripts/pack-packages.mjs", "dev": "turbo dev --filter=!./playgrounds/*", - "test": "cargo test && vitest run --hideSkippedTests", - "test:integrations": "vitest --root=./integrations", - "test:ui": "pnpm run --filter=tailwindcss test:ui && pnpm run --filter=@tailwindcss/browser test:ui", - "tdd": "vitest --hideSkippedTests", + "test": "pnpm test --prefix=oxide && vitest run", + "test:ui": "pnpm run --filter=tailwindcss test:ui", + "tdd": "vitest", "bench": "vitest bench", "version-packages": "node ./scripts/version-packages.mjs", "vite": "pnpm run --filter=vite-playground dev", @@ -48,17 +35,14 @@ }, "license": "MIT", "devDependencies": { - "@playwright/test": "^1.62.1", - "@types/node": "catalog:", - "postcss": "catalog:", - "postcss-import": "^16.1.1", - "prettier": "catalog:", - "prettier-plugin-embed": "^0.5.1", - "prettier-plugin-organize-imports": "^4.3.0", - "tsup": "^8.5.1", - "turbo": "^2.10.8", - "typescript": "^5.9.3", - "vitest": "^4.1.10" - }, - "packageManager": "pnpm@11.9.0" + "@playwright/test": "^1.41.2", + "@types/node": "^20.11.19", + "@vitest/coverage-v8": "^1.2.1", + "prettier": "^3.2.5", + "prettier-plugin-organize-imports": "^3.2.4", + "tsup": "^8.0.1", + "turbo": "^1.12.4", + "typescript": "^5.3.3", + "vitest": "^1.1.3" + } } diff --git a/packages/@tailwindcss-browser/README.md b/packages/@tailwindcss-browser/README.md deleted file mode 100644 index 5f532607d..000000000 --- a/packages/@tailwindcss-browser/README.md +++ /dev/null @@ -1,36 +0,0 @@ -

                          - - - - - Tailwind CSS - - -

                          - -

                          - A utility-first CSS framework for rapidly building custom user interfaces. -

                          - -

                          - Build Status - Total Downloads - Latest Release - License -

                          - ---- - -## Documentation - -For full documentation, visit [tailwindcss.com](https://tailwindcss.com). - -## Community - -For help, discussion about best practices, or feature ideas: - -[Discuss Tailwind CSS on GitHub](https://github.com/tailwindlabs/tailwindcss/discussions) - -## Contributing - -If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md) **before submitting a pull request**. diff --git a/packages/@tailwindcss-browser/package.json b/packages/@tailwindcss-browser/package.json deleted file mode 100644 index 70af78c1e..000000000 --- a/packages/@tailwindcss-browser/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@tailwindcss/browser", - "version": "4.3.3", - "description": "A utility-first CSS framework for rapidly building custom user interfaces.", - "license": "MIT", - "main": "./dist/index.global.js", - "browser": "./dist/index.global.js", - "repository": { - "type": "git", - "url": "https://github.com/tailwindlabs/tailwindcss.git", - "directory": "packages/@tailwindcss-browser" - }, - "bugs": "https://github.com/tailwindlabs/tailwindcss/issues", - "homepage": "https://tailwindcss.com", - "scripts": { - "lint": "tsc --noEmit", - "build": "tsup-node", - "dev": "pnpm run build -- --watch", - "test:ui": "playwright test" - }, - "exports": { - ".": "./dist/index.global.js", - "./package.json": "./package.json" - }, - "files": [ - "dist" - ], - "publishConfig": { - "provenance": true, - "access": "public" - }, - "devDependencies": { - "h3": "^1.15.11", - "listhen": "^1.10.1", - "tailwindcss": "workspace:*" - } -} diff --git a/packages/@tailwindcss-browser/playwright.config.ts b/packages/@tailwindcss-browser/playwright.config.ts deleted file mode 100644 index c6059dd82..000000000 --- a/packages/@tailwindcss-browser/playwright.config.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { defineConfig, devices } from '@playwright/test' - -/** - * See https://playwright.dev/docs/test-configuration. - */ -export default defineConfig({ - testDir: './tests', - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: [['html', { open: 'never' }]], - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - // baseURL: 'http://127.0.0.1:3000', - - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', - }, - - /* Configure projects for major browsers */ - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - // Playwright 1.60's Windows WebKit worker hangs on shutdown after all tests - // pass, causing CI to fail with a worker force-kill error. - ...(process.platform === 'win32' - ? [] - : [{ name: 'webkit', use: { ...devices['Desktop Safari'] } }]), - { - name: 'firefox', - use: { - ...devices['Desktop Firefox'], - // https://playwright.dev/docs/test-use-options#more-browser-and-context-options - launchOptions: { - // https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-firefox-user-prefs - firefoxUserPrefs: { - // By default, headless Firefox runs as though no pointers - // capabilities are available. - // https://github.com/microsoft/playwright/issues/7769#issuecomment-966098074 - // - // This impacts our `hover` variant implementation which uses an - // '(hover: hover)' media query to determine if hover is available. - // - // Available values for pointer capabilities: - // NO_POINTER = 0x00; - // COARSE_POINTER = 0x01; - // FINE_POINTER = 0x02; - // HOVER_CAPABLE_POINTER = 0x04; - // - // Setting to 0x02 | 0x04 says the system supports a mouse - 'ui.primaryPointerCapabilities': 0x02 | 0x04, - 'ui.allPointerCapabilities': 0x02 | 0x04, - }, - }, - }, - }, - ], -}) diff --git a/packages/@tailwindcss-browser/src/assets.ts b/packages/@tailwindcss-browser/src/assets.ts deleted file mode 100644 index e72d47658..000000000 --- a/packages/@tailwindcss-browser/src/assets.ts +++ /dev/null @@ -1,11 +0,0 @@ -import index from 'tailwindcss/index.css' -import preflight from 'tailwindcss/preflight.css' -import theme from 'tailwindcss/theme.css' -import utilities from 'tailwindcss/utilities.css' - -export const css = { - index, - preflight, - theme, - utilities, -} diff --git a/packages/@tailwindcss-browser/src/index.ts b/packages/@tailwindcss-browser/src/index.ts deleted file mode 100644 index 49c7e4675..000000000 --- a/packages/@tailwindcss-browser/src/index.ts +++ /dev/null @@ -1,294 +0,0 @@ -import * as tailwindcss from 'tailwindcss' -import * as assets from './assets' -import { Instrumentation } from './instrumentation' - -/** - * The type used by ` - `, - body: html`
                          `, - }) - - await expect(page.locator('[data-test]')).toHaveCSS('background-color', 'rgb(255, 0, 0)') - - await page.evaluate(() => { - document.querySelector('[data-css]')!.textContent = ` - .foo { - @apply bg-blue; - } - ` - }) - - await expect(page.locator('[data-test]')).toHaveCSS('background-color', 'rgb(0, 0, 255)') -}) - -test('changes to `@theme`', async ({ page }) => { - await server.render({ - page, - head: html` - - `, - body: html`
                          `, - }) - - await expect(page.locator('[data-test]')).toHaveCSS('background-color', 'rgb(255, 0, 0)') - - await page.evaluate(() => { - document.querySelector('[data-css]')!.textContent = ` - @theme { - --color-primary: #0000ff; - } - ` - }) - - await expect(page.locator('[data-test]')).toHaveCSS('background-color', 'rgb(0, 0, 255)') -}) - -test('no classes', async ({ page }) => { - await server.render({ - page, - body: html`
                          test
                          `, - }) - - await expect(page.locator('body')).toHaveCSS('margin', '0px') -}) - -test('html classes', async ({ page }) => { - await server.render({ - page, - htmlClasses: 'h-4', - }) - - await expect(page.locator('html')).toHaveCSS('height', '16px') -}) - -async function createServer() { - const { createApp, createRouter, defineEventHandler, toNodeListener } = await import('h3') - const { listen } = await import('listhen') - - interface PageOptions { - page: Page - head?: string - body?: string - htmlClasses?: string - } - - async function render({ page, htmlClasses, head, body }: PageOptions) { - let content = html` - - - - - - - Document - - - ${head ?? ''} - - - ${body ?? ''} - - - ` - - router.get( - '/', - defineEventHandler(() => content), - ) - - await page.goto(server.url) - } - - const app = createApp() - const router = createRouter() - - router.get( - '/tailwindcss.js', - defineEventHandler(() => readFile(require.resolve('@tailwindcss/browser'))), - ) - - app.use(router) - - let workerIndex = Number(process.env.TEST_WORKER_INDEX ?? 0) - - let listener = await listen(toNodeListener(app), { - port: 3000 + workerIndex, - showURL: false, - open: false, - }) - - return { - app, - url: listener.url, - close: listener.close, - render, - } -} diff --git a/packages/@tailwindcss-browser/tsconfig.json b/packages/@tailwindcss-browser/tsconfig.json deleted file mode 100644 index 8ea040daf..000000000 --- a/packages/@tailwindcss-browser/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "../tsconfig.base.json", - "compilerOptions": { - "lib": ["es2022", "esnext.disposable", "dom", "dom.iterable"], - }, -} diff --git a/packages/@tailwindcss-browser/tsup.config.ts b/packages/@tailwindcss-browser/tsup.config.ts deleted file mode 100644 index 10a73f2b7..000000000 --- a/packages/@tailwindcss-browser/tsup.config.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig({ - format: ['iife'], - clean: true, - minify: true, - entry: ['src/index.ts'], - noExternal: [/.*/], - loader: { - '.css': 'text', - }, - define: { - 'process.env.NODE_ENV': '"production"', - 'process.env.FEATURES_ENV': '"stable"', - }, - esbuildPlugins: [ - { - name: 'patch-intellisense-apis', - setup(build) { - build.onLoad({ filter: /intellisense.ts$/ }, () => { - return { - contents: ` - export function getClassList() { return [] } - export function getVariants() { return [] } - export function canonicalizeCandidates() { return [] } - `, - } - }) - }, - }, - ], -}) diff --git a/packages/@tailwindcss-cli/README.md b/packages/@tailwindcss-cli/README.md index 5f532607d..95ec9d87d 100644 --- a/packages/@tailwindcss-cli/README.md +++ b/packages/@tailwindcss-cli/README.md @@ -13,10 +13,10 @@

                          - Build Status + Build Status Total Downloads - Latest Release - License + Latest Release + License

                          --- @@ -27,10 +27,14 @@ For full documentation, visit [tailwindcss.com](https://tailwindcss.com). ## Community -For help, discussion about best practices, or feature ideas: +For help, discussion about best practices, or any other conversation that would benefit from being searchable: -[Discuss Tailwind CSS on GitHub](https://github.com/tailwindlabs/tailwindcss/discussions) +[Discuss Tailwind CSS on GitHub](https://github.com/tailwindcss/tailwindcss/discussions) + +For chatting with others using the framework: + +[Join the Tailwind CSS Discord Server](https://discord.gg/7NF8GNe) ## Contributing -If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md) **before submitting a pull request**. +If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindcss/tailwindcss/blob/next/.github/CONTRIBUTING.md) **before submitting a pull request**. diff --git a/packages/@tailwindcss-cli/package.json b/packages/@tailwindcss-cli/package.json index 7fa5b5500..5907d2bfc 100644 --- a/packages/@tailwindcss-cli/package.json +++ b/packages/@tailwindcss-cli/package.json @@ -1,18 +1,18 @@ { "name": "@tailwindcss/cli", - "version": "4.3.3", + "version": "0.0.0-internal.b2586d4e", "description": "A utility-first CSS framework for rapidly building custom user interfaces.", "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/tailwindlabs/tailwindcss.git", + "url": "git+https://github.com/tailwindlabs/tailwindcss.git", "directory": "packages/@tailwindcss-cli" }, "bugs": "https://github.com/tailwindlabs/tailwindcss/issues", "homepage": "https://tailwindcss.com", "scripts": { "lint": "tsc --noEmit", - "build": "tsup-node", + "build": "tsup-node ./src/index.ts --format esm --minify --clean", "dev": "pnpm run build -- --watch" }, "bin": { @@ -29,12 +29,15 @@ "access": "public" }, "dependencies": { - "@parcel/watcher": "catalog:", - "@tailwindcss/node": "workspace:*", - "@tailwindcss/oxide": "workspace:*", - "enhanced-resolve": "catalog:", + "@parcel/watcher": "^2.4.1", + "@tailwindcss/oxide": "workspace:^", "mri": "^1.2.0", - "picocolors": "^1.1.1", - "tailwindcss": "workspace:*" + "picocolors": "^1.0.0", + "postcss": "8.4.24", + "postcss-import": "^16.0.0", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + "@types/postcss-import": "^14.0.3" } } diff --git a/packages/@tailwindcss-cli/src/commands/build/index.ts b/packages/@tailwindcss-cli/src/commands/build/index.ts index ffef2ae8b..e4f65055e 100644 --- a/packages/@tailwindcss-cli/src/commands/build/index.ts +++ b/packages/@tailwindcss-cli/src/commands/build/index.ts @@ -1,18 +1,12 @@ -import { - compile, - env, - Instrumentation, - optimize, - toSourceMap, - type SourceMap, -} from '@tailwindcss/node' -import { clearRequireCache } from '@tailwindcss/node/require-cache' -import { Scanner, type ChangedContent } from '@tailwindcss/oxide' -import { existsSync, type Stats } from 'node:fs' +import watcher from '@parcel/watcher' +import { IO, Parsing, scanDir, scanFiles, type ChangedContent } from '@tailwindcss/oxide' +import { existsSync } from 'node:fs' import fs from 'node:fs/promises' import path from 'node:path' +import postcss from 'postcss' +import atImport from 'postcss-import' +import { compile, optimizeCss } from 'tailwindcss' import type { Arg, Result } from '../../utils/args' -import { Disposables } from '../../utils/disposables' import { eprintln, formatDuration, @@ -20,13 +14,11 @@ import { highlight, println, relative, - wordWrap, } from '../../utils/renderer' +import { resolve } from '../../utils/resolve' import { drainStdin, outputFile } from './utils' const css = String.raw -const DEBUG = env.DEBUG -const DEFAULT_POLL_INTERVAL_MS = 250 export function options() { return { @@ -39,20 +31,11 @@ export function options() { type: 'string', description: 'Output file', alias: '-o', - default: '-', }, '--watch': { type: 'boolean | string', - description: - 'Watch for changes and rebuild as needed, and use `always` to keep watching when stdin is closed', + description: 'Watch for changes and rebuild as needed', alias: '-w', - values: ['always'], - }, - '--poll': { - type: 'boolean | number', - description: 'Use polling instead of filesystem events when watching', - default: false, - values: ['ms'], }, '--minify': { type: 'boolean', @@ -68,73 +51,14 @@ export function options() { description: 'The current working directory', default: '.', }, - '--map': { - type: 'boolean | string', - description: 'Generate a source map', - default: false, - }, - '--silent': { - type: 'boolean', - description: 'Suppress non-error output', - }, } satisfies Arg } -function formatError(error: unknown): string { - let seen = new Set() // Track seen errors to prevent circular errors - function render(err: unknown, depth: number): string[] { - let indent = ' '.repeat(depth) - let width = (process.stderr.columns ?? Infinity) - 2 * (depth + 1) - - let output = [ - `${indent}${red(depth === 0 ? 'Error:' : 'Caused by:')}`, - `${indent}${dim('\u250C')}`, - ] - - for (let line of `${err}`.split('\n')) { - let wrapped = wordWrap(line, width) - if (wrapped.length === 0) wrapped = [''] - for (let chunk of wrapped) { - output.push(`${indent}${dim('\u2502')} ${chunk}`) - } - } - - output.push(`${indent}${dim('\u2514')}`) - - if (typeof err === 'object' && err !== null && 'cause' in err && err.cause != null) { - if (!seen.add(err).has(err.cause)) { - output.push(...render(err.cause, depth + 1)) - } - } - - return output - } - - return render(error, 0).join('\n') -} - -async function handleError(fn: () => T): Promise { - try { - return await fn() - } catch (err) { - eprintln(formatError(err)) - process.exit(1) - } -} - export async function handle(args: Result>) { - if (!args['--silent']) eprintln(header()) - if (!args['--silent']) eprintln() - - using I = new Instrumentation() - DEBUG && I.start('[@tailwindcss/cli] (initial build)') - let base = path.resolve(args['--cwd']) - // Resolve the output as an absolute path. If the output is a `-`, then we - // don't need to resolve it because this is a flag to indicate that we want to - // use `stdout` instead. - if (args['--output'] && args['--output'] !== '-') { + // Resolve the output as an absolute path. + if (args['--output']) { args['--output'] = path.resolve(base, args['--output']) } @@ -146,729 +70,186 @@ export async function handle(args: Result>) { // Ensure the provided `--input` exists. if (!existsSync(args['--input'])) { + eprintln(header()) + eprintln() eprintln(`Specified input file ${highlight(relative(args['--input']))} does not exist.`) process.exit(1) } } - // Check if the input and output file paths are identical, otherwise return an - // error to the user. - if (args['--input'] === args['--output'] && args['--input'] !== '-') { - eprintln( - `Specified input file ${highlight(relative(args['--input']))} and output file ${highlight(relative(args['--output']))} are identical.`, - ) - process.exit(1) - } - - // If the user passes `{bin} build --map -` then this likely means they want to output the map inline - // this is the default behavior of `{bin build} --map` to inform the user of that - if (args['--map'] === '-') { - eprintln(`Use --map without a value to inline the source map.`) - process.exit(1) - } - - if (args['--poll'] === undefined) { - eprintln(`Use --poll with a non-zero value in milliseconds.`) - process.exit(1) - } - let pollInterval = args['--poll'] === true ? DEFAULT_POLL_INTERVAL_MS : args['--poll'] - if (pollInterval !== false && pollInterval <= 0) { - eprintln(`Specified polling interval must be a positive number.`) - process.exit(1) - } - - // Resolve the map as an absolute path. If the output is true then we - // don't need to resolve it because it'll be an inline source map - if (args['--map'] && args['--map'] !== true) { - args['--map'] = path.resolve(base, args['--map']) - } - let start = process.hrtime.bigint() + let { candidates } = scanDir({ base }) - let input = args['--input'] - ? args['--input'] === '-' - ? await drainStdin() - : await fs.readFile(args['--input'], 'utf-8') - : css` - @import 'tailwindcss'; - ` + // Resolve the input + let [input, cssImportPaths] = await handleImports( + args['--input'] + ? args['--input'] === '-' + ? await drainStdin() + : await fs.readFile(args['--input'], 'utf-8') + : css` + @import '${resolve('tailwindcss/index.css')}'; + `, + args['--input'] ?? base, + ) let previous = { css: '', optimizedCss: '', - output: null as string | null, } - async function write( - css: string, - map: SourceMap | null, - args: Result>, - I: Instrumentation, - ) { + async function write(css: string, args: Result>) { let output = css // Optimize the output if (args['--minify'] || args['--optimize']) { if (css !== previous.css) { - DEBUG && I.start('Optimize CSS') - let optimized = optimize(css, { + let optimizedCss = optimizeCss(css, { file: args['--input'] ?? 'input.css', minify: args['--minify'] ?? false, - map: map?.raw ?? undefined, }) - DEBUG && I.end('Optimize CSS') previous.css = css - previous.optimizedCss = optimized.code - if (optimized.map) { - map = toSourceMap(optimized.map) - } - output = optimized.code + previous.optimizedCss = optimizedCss + output = optimizedCss } else { output = previous.optimizedCss } } // Write the output - if (map) { - // Inline the source map - if (args['--map'] === true) { - output += `\n` - output += map.inline - } else if (typeof args['--map'] === 'string') { - let basePath = - args['--output'] && args['--output'] !== '-' - ? path.dirname(path.resolve(args['--output'])) - : process.cwd() - - let mapPath = path.resolve(args['--map']) - - let relativePath = path.relative(basePath, mapPath) - - output += `\n` - output += map.comment(relativePath) - - DEBUG && I.start('Write source map') - await outputFile(args['--map'], map.raw) - DEBUG && I.end('Write source map') - } - } - - let didChange = output !== previous.output - - DEBUG && I.start('Write output') - if (args['--output'] && args['--output'] !== '-') { + if (args['--output']) { await outputFile(args['--output'], output) - } else if (didChange) { + } else { println(output) } - DEBUG && I.end('Write output') - - previous.output = output - return didChange } - let inputFilePath = - args['--input'] && args['--input'] !== '-' ? path.resolve(args['--input']) : null + // Compile the input + let { build } = compile(input) - let inputBasePath = inputFilePath ? path.dirname(inputFilePath) : process.cwd() + await write(build(candidates), args) - let fullRebuildPaths: string[] = inputFilePath ? [inputFilePath] : [] - let backupRebuildPaths = fullRebuildPaths - - async function createCompiler(css: string, I: Instrumentation) { - DEBUG && I.start('Setup compiler') - let compiler = await compile(css, { - from: args['--output'] ? (inputFilePath ?? 'stdin.css') : undefined, - base: inputBasePath, - onDependency(path) { - fullRebuildPaths.push(path) - }, - }) - - let sources = (() => { - // Disable auto source detection - if (compiler.root === 'none') { - return [] - } - - // No root specified, use the base directory - if (compiler.root === null) { - return [{ base, pattern: '**/*', negated: false }] - } - - // Use the specified root - return [{ ...compiler.root, negated: false }] - })().concat(compiler.sources) - - // Do not scan the current executable. Otherwise when using the standalone - // CLI, if the CLI lives in the current repo we would be scanning that file. - // - // This is also immune against renames of the executable file. - sources.push({ - base: path.dirname(process.execPath), - pattern: path.basename(process.execPath), - negated: true, - }) - - if (inputFilePath !== null) { - sources.push({ - base: path.dirname(inputFilePath), - pattern: path.basename(inputFilePath), - negated: false, - }) - } - - let scanner = new Scanner({ sources }) - DEBUG && I.end('Setup compiler') - - return [compiler, scanner] as const - } - - let [compiler, scanner] = await handleError(() => createCompiler(input, I)) - let cleanupWatchers: (() => Promise)[] = [] + let end = process.hrtime.bigint() + eprintln(header()) + eprintln() + eprintln(`Done in ${formatDuration(end - start)}`) // Watch for changes - if (args['--watch'] && pollInterval === false) { - // Ensure the file watcher can be loaded before setting up any watchers, - // such that we can present a helpful error message if needed. - await handleError(() => loadWatcher()) + if (args['--watch']) { + await watcher.subscribe(base, async (err, events) => { + if (err) { + console.error(err) + return + } - cleanupWatchers.push( - await createWatchers(await watchDirectories(scanner), async function handle(files) { - try { - // If the only change happened to the output file, then we don't want to - // trigger a rebuild because that will result in an infinite loop. - if (files.length === 1 && files[0] === args['--output']) return + try { + // If the only change happened to the output file, then we don't want to + // trigger a rebuild because that will result in an infinite loop. + if (events.length === 1 && events[0].path === args['--output']) return - using I = new Instrumentation() - DEBUG && I.start('[@tailwindcss/cli] (watcher)') + let changedFiles: ChangedContent[] = [] + let rebuildStrategy: 'incremental' | 'full' = 'incremental' - // Re-compile the input - let start = process.hrtime.bigint() - - let resolvedFullRebuildPaths = fullRebuildPaths - let rebuildStrategy = getRebuildStrategy(files, resolvedFullRebuildPaths) - - // Track the compiled CSS - let compiledCss = '' - let compiledMap: SourceMap | null = null - - // Scan the entire `base` directory for full rebuilds. - if (rebuildStrategy.kind === 'full') { - // Read the new `input`. - let input = args['--input'] - ? args['--input'] === '-' - ? await drainStdin() - : await fs.readFile(args['--input'], 'utf-8') - : css` - @import 'tailwindcss'; - ` - clearRequireCache(resolvedFullRebuildPaths) - - // Track current rebuild paths in case something goes wrong when - // performing a full rebuild. - backupRebuildPaths = fullRebuildPaths.slice() - - // The `inputFilePath`, if provided, will be the only known full - // rebuild path before the compiler is re-created. - fullRebuildPaths = inputFilePath ? [inputFilePath] : [] - - // Create a new compiler, given the new `input` - ;[compiler, scanner] = await createCompiler(input, I) - - // Successfully created a new compiler, so the `fullRebuildPaths` - // will be updated. If other errors occur, we should be able to - // restore the paths unconditionally. - backupRebuildPaths = fullRebuildPaths.slice() - - // Scan the directory for candidates - DEBUG && I.start('Scan for candidates') - let candidates = scanner.scan() - DEBUG && I.end('Scan for candidates') - - // Setup new watchers - DEBUG && I.start('Setup new watchers') - let newCleanupFunction = await createWatchers(await watchDirectories(scanner), handle) - DEBUG && I.end('Setup new watchers') - - // Clear old watchers - DEBUG && I.start('Cleanup old watchers') - await Promise.all(cleanupWatchers.splice(0).map((cleanup) => cleanup())) - DEBUG && I.end('Cleanup old watchers') - - cleanupWatchers.push(newCleanupFunction) - - // Re-compile the CSS - DEBUG && I.start('Build CSS') - compiledCss = compiler.build(candidates) - DEBUG && I.end('Build CSS') - - if (args['--map']) { - DEBUG && I.start('Build Source Map') - compiledMap = toSourceMap(compiler.buildSourceMap()) - DEBUG && I.end('Build Source Map') - } + for (let event of events) { + // Track new and updated files for incremental rebuilds. + if (event.type === 'create' || event.type === 'update') { + changedFiles.push({ + file: event.path, + extension: path.extname(event.path).slice(1), + } satisfies ChangedContent) } - // Scan changed files only for incremental rebuilds. - else if (rebuildStrategy.kind === 'incremental') { - DEBUG && I.start('Scan for candidates') - let newCandidates = scanner.scanFiles(rebuildStrategy.changedFiles) - DEBUG && I.end('Scan for candidates') + // If one of the changed files is related to the input CSS files, then + // we need to do a full rebuild because the theme might have changed. + if (cssImportPaths.includes(event.path)) { + rebuildStrategy = 'full' - // No new candidates found which means we don't need to write to - // disk, and can return early. - if (newCandidates.length <= 0) { - let end = process.hrtime.bigint() - if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`) - return - } - - DEBUG && I.start('Build CSS') - compiledCss = compiler.build(newCandidates) - DEBUG && I.end('Build CSS') - - if (args['--map']) { - DEBUG && I.start('Build Source Map') - compiledMap = toSourceMap(compiler.buildSourceMap()) - DEBUG && I.end('Build Source Map') - } + // No need to check the rest of the events, because we already know we + // need to do a full rebuild. + break } - - await write(compiledCss, compiledMap, args, I) - - let end = process.hrtime.bigint() - if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`) - } catch (err) { - // It's important that we perform a full rebuild when any of the - // dependencies tracked in `fullRebuildPaths` has been changed. - // - // If we remove one of those files, then a subsequent build will be - // triggered, but it will fail because the dependency is gone. The - // compiler itself will be in a broken state and won't be able to - // register any dependencies therefore we want to restore all the - // dependencies from before. If we don't do that, then we won't be - // able to recover from a bug in a transitive dependency. - // - // E.g.: - // ```css - // /* input.css — known full rebuild path */ - // @import 'tailwindcss'; - // @config "./tailwind.config.js"; - // ``` - // - // ```js - // // tailwind.config.js - // const theme = require('./my-theme.js'); - // - // module.exports = { - // theme - // } - // ``` - // In this case `my-theme.js` is a transitive dependency of - // `input.css` via `tailwind.config.js`. Removing `my-theme.js` will - // result in an error, restoring `my-theme.js` should trigger a - // fresh build even though the compiler didn't restore. - // - // Once the build error is fixed, a fresh full rebuild will happen - // which in turn will fixup the full rebuild paths. - fullRebuildPaths = backupRebuildPaths - - // Catch any errors and print them to stderr, but don't exit the process - // and keep watching. - eprintln(formatError(err)) - - let end = process.hrtime.bigint() - if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`) } - }), - ) + + // Re-compile the input + let start = process.hrtime.bigint() + + // Track the compiled CSS + let compiledCss = '' + + // Scan the entire `base` directory for full rebuilds. + if (rebuildStrategy === 'full') { + // Re-scan the directory to get the new `candidates`. + candidates = scanDir({ base }).candidates + + // Collect the new `input` and `cssImportPaths`. + ;[input, cssImportPaths] = await handleImports( + args['--input'] + ? await fs.readFile(args['--input'], 'utf-8') + : css` + @import '${resolve('tailwindcss/index.css')}'; + `, + args['--input'] ?? base, + ) + + build = compile(input).build + compiledCss = build(candidates) + } + + // Scan changed files only for incremental rebuilds. + else if (rebuildStrategy === 'incremental') { + let newCandidates = scanFiles(changedFiles, IO.Sequential | Parsing.Sequential) + + compiledCss = build(newCandidates) + } + + await write(compiledCss, args) + + let end = process.hrtime.bigint() + eprintln(`Done in ${formatDuration(end - start)}`) + } catch (err) { + // Catch any errors and print them to stderr, but don't exit the process + // and keep watching. + if (err instanceof Error) { + eprintln(err.toString()) + } + } + }) // Abort the watcher if `stdin` is closed to avoid zombie processes. You can // disable this behavior with `--watch=always`. if (args['--watch'] !== 'always') { process.stdin.on('end', () => { - Promise.all(cleanupWatchers.map((fn) => fn())).then( - () => process.exit(0), - () => process.exit(1), - ) + process.exit(0) }) } // Keep the process running process.stdin.resume() } - - DEBUG && I.start('Scan for candidates') - let candidates = scanner.scan() - DEBUG && I.end('Scan for candidates') - DEBUG && I.start('Build CSS') - let output = await handleError(() => compiler.build(candidates)) - DEBUG && I.end('Build CSS') - - let map: SourceMap | null = null - - if (args['--map']) { - DEBUG && I.start('Build Source Map') - map = await handleError(() => toSourceMap(compiler.buildSourceMap())) - DEBUG && I.end('Build Source Map') - } - - await write(output, map, args, I) - - let end = process.hrtime.bigint() - if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`) - - // Watch for changes, using polling - if (args['--watch'] && pollInterval !== false) { - let cleanupPollingIndicator = () => {} - - function logPollingMessage(message: string) { - if (!args['--silent']) { - process.stderr.write('\r\x1B[2K') - } - - eprintln(message) - - if (!args['--silent']) { - process.stderr.write(`\r\x1B[2K${dim(`Polling for changes…`)}`) - } - } - - async function fullRebuild(I: Instrumentation) { - clearRequireCache(fullRebuildPaths) - backupRebuildPaths = fullRebuildPaths.slice() - fullRebuildPaths = inputFilePath ? [inputFilePath] : [] - - let input = args['--input'] - ? args['--input'] === '-' - ? await drainStdin() - : await fs.readFile(args['--input'], 'utf-8') - : css` - @import 'tailwindcss'; - ` - - ;[compiler, scanner] = await createCompiler(input, I) - backupRebuildPaths = fullRebuildPaths.slice() - - DEBUG && I.start('Scan for candidates') - let candidates = scanner.scan() - DEBUG && I.end('Scan for candidates') - - DEBUG && I.start('Build CSS') - let output = compiler.build(candidates) - DEBUG && I.end('Build CSS') - - let map: SourceMap | null = null - - if (args['--map']) { - DEBUG && I.start('Build Source Map') - map = toSourceMap(compiler.buildSourceMap()) - DEBUG && I.end('Build Source Map') - } - - await write(output, map, args, I) - } - - if (!args['--silent']) { - let restored = false - - function restoreCursor() { - if (restored) return - restored = true - process.stderr.write('\r\x1B[2K\x1B[?25h') - process.off('exit', restoreCursor) - process.off('SIGINT', onSigint) - process.off('SIGTERM', onSigterm) - } - - function onSigint() { - restoreCursor() - process.exit(130) - } - - function onSigterm() { - restoreCursor() - process.exit(143) - } - - process.stderr.write('\x1B[?25l') - process.on('exit', restoreCursor) - process.on('SIGINT', onSigint) - process.on('SIGTERM', onSigterm) - - cleanupPollingIndicator = restoreCursor - cleanupWatchers.push(async () => cleanupPollingIndicator()) - } - - cleanupWatchers.push( - createPollingWatcher(async () => { - using I = new Instrumentation() - - DEBUG && I.start('Scan for candidates') - let candidates = scanner.scan() - DEBUG && I.end('Scan for candidates') - - let files = scanner.scannedFiles.filter( - (file) => file !== args['--output'] && file !== args['--map'], - ) - - if (files.length <= 0) return - - let start = process.hrtime.bigint() - - let strategy = getRebuildStrategy(files, fullRebuildPaths) - - if (strategy.kind === 'full') { - try { - await fullRebuild(I) - } catch (err) { - fullRebuildPaths = backupRebuildPaths - - logPollingMessage(formatError(err)) - } - - if (!args['--silent']) { - logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`) - } - return - } - - // No candidates found which means we don't need to write to disk, and - // can return early. - if (candidates.length <= 0) { - if (!args['--silent']) { - logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`) - } - return - } - - DEBUG && I.start('Build CSS') - let output = compiler.build(candidates) - DEBUG && I.end('Build CSS') - - let map: SourceMap | null = null - - if (args['--map']) { - DEBUG && I.start('Build Source Map') - map = toSourceMap(compiler.buildSourceMap()) - DEBUG && I.end('Build Source Map') - } - - await write(output, map, args, I) - - if (!args['--silent']) { - logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`) - } - }, pollInterval), - ) - - if (args['--watch'] !== 'always') { - process.stdin.on('end', () => { - Promise.all(cleanupWatchers.map((fn) => fn())).then( - () => process.exit(0), - () => process.exit(1), - ) - }) - } - - process.stdin.resume() - if (!args['--silent']) { - process.stderr.write(`\r\x1B[2K${dim(`Polling for changes…`)}`) - } - } } -// Load `@parcel/watcher` lazily so a missing or broken native binding only -// affects `--watch` (without `--poll`), instead of crashing one-off builds and -// polling mode as well. -async function loadWatcher(): Promise { - try { - return (await import('@parcel/watcher')).default - } catch (err) { - throw new Error( - `Failed to load the file watcher. Your platform may not be supported by \`@parcel/watcher\`. As a workaround, you can use polling instead by passing the \`--watch --poll\` flags.`, - { cause: err }, - ) - } -} - -async function createWatchers(dirs: string[], cb: (files: string[]) => void) { - let watcher = await loadWatcher() - - // Remove any directories that are children of an already watched directory. - // If we don't we may not get notified of certain filesystem events regardless - // of whether or not they are for the directory that is duplicated. - - // 1. Sort in asc by length - dirs = dirs.sort((a, z) => a.length - z.length) - - // 2. Remove any directories that are children of another directory - let toRemove = [] - - // /project-a 0 - // /project-a/src 1 - - for (let i = 0; i < dirs.length; ++i) { - for (let j = 0; j < i; ++j) { - if (!dirs[i].startsWith(`${dirs[j]}/`)) continue - - toRemove.push(dirs[i]) - } - } - - dirs = dirs.filter((dir) => !toRemove.includes(dir)) - - // Track all Parcel watchers for each glob. +function handleImports( + input: string, + file: string, +): [css: string, paths: string[]] | Promise<[css: string, paths: string[]]> { + // TODO: Should we implement this ourselves instead of relying on PostCSS? // - // When we encounter a change in a CSS file, we need to setup new watchers and - // we want to cleanup the old ones we captured here. - let watchers = new Disposables() + // Relevant specification: + // - CSS Import Resolve: https://csstools.github.io/css-import-resolve/ - // Track all files that were added or changed. - let files = new Set() - - // Keep track of the debounce queue to avoid multiple rebuilds. - let debounceQueue = new Disposables() - - // A changed file can be watched by multiple watchers, but we only want to - // handle the file once. We debounce the handle function with the collected - // files to handle them in a single batch and to avoid multiple rebuilds. - async function enqueueCallback() { - // Dispose all existing macrotasks. - await debounceQueue.dispose() - - // Setup a new macrotask to handle the files in batch. - debounceQueue.queueMacrotask(() => { - cb(Array.from(files)) - files.clear() - }) + if (!input.includes('@import')) { + return [input, [file]] } - // Setup a watcher for every directory. - for (let dir of dirs) { - let { unsubscribe } = await watcher.subscribe(dir, async (err, events) => { - // Whenever an error occurs we want to let the user know about it but we - // want to keep watching for changes. - if (err) { - console.error(err) - return - } + return postcss() + .use(atImport()) + .process(input, { from: file }) + .then((result) => [ + result.css, - await Promise.all( - events.map(async (event) => { - // When a file is deleted, a rebuild should be triggered such that we - // can figure out whether this file must trigger a fresh build or not. - // - // If it must trigger a fresh build, then we will temporarily end up - // in a broken state, but an error will be shown to the user. Once the - // user resolves the issue, the CLI will recover. - if (event.type === 'delete') { - files.add(event.path) - return - } - - // Ignore directory changes. We only care about file changes - let stats: Stats | null = null - try { - stats = await fs.lstat(event.path) - } catch {} - if (!stats?.isFile() && !stats?.isSymbolicLink()) { - return - } - - // Track the changed file. - files.add(event.path) - }), - ) - - // Handle the tracked files at some point in the future. - await enqueueCallback() - }) - - // Ensure we cleanup the watcher when we're done. - watchers.add(unsubscribe) - } - - // Cleanup - return async () => { - await watchers.dispose() - await debounceQueue.dispose() - } -} - -function getRebuildStrategy( - files: string[], - fullRebuildPaths: string[], -): { kind: 'incremental'; changedFiles: ChangedContent[] } | { kind: 'full' } { - let changedFiles: ChangedContent[] = [] - - for (let file of files) { - // If one of the changed files is related to the input CSS or JS - // config/plugin files, then we need to do a full rebuild because - // the theme might have changed. - if (fullRebuildPaths.includes(file)) { - return { kind: 'full' } - } - - // Track new and updated files for incremental rebuilds. - changedFiles.push({ - file, - extension: path.extname(file).slice(1), - } satisfies ChangedContent) - } - - return { kind: 'incremental', changedFiles } -} - -function createPollingWatcher(cb: () => Promise, pollInterval: number) { - let disposed = false - let timer: ReturnType | null = null - - async function poll() { - if (disposed) return - - try { - await cb() - } catch (err) { - console.error(err) - } finally { - if (!disposed) { - timer = setTimeout(poll, pollInterval) - } - } - } - - timer = setTimeout(poll, pollInterval) - - return async () => { - disposed = true - if (timer) clearTimeout(timer) - } -} - -async function watchDirectories(scanner: Scanner) { - let directories = ( - await Promise.all( - scanner.normalizedSources.map(async (globEntry) => { - let resolvedPath = path.resolve(globEntry.base) - let realPath = await fs.realpath(resolvedPath).catch(() => resolvedPath) - - return fs - .stat(realPath) - .then((stat) => (stat.isDirectory() ? [realPath] : [])) - .catch(() => []) - }), - ) - ).flat(1) - - return Array.from(new Set(directories)) -} - -function dim(str: string) { - return `\x1B[2m${str}\x1B[22m` -} - -function red(str: string) { - return `\x1B[31m${str}\x1B[39m` + // Use `result.messages` to get the imported files. This also includes the + // current file itself. + [file].concat( + result.messages.filter((msg) => msg.type === 'dependency').map((msg) => msg.file), + ), + ]) } diff --git a/packages/@tailwindcss-cli/src/commands/build/utils.ts b/packages/@tailwindcss-cli/src/commands/build/utils.ts index ecdd96726..b41a74358 100644 --- a/packages/@tailwindcss-cli/src/commands/build/utils.ts +++ b/packages/@tailwindcss-cli/src/commands/build/utils.ts @@ -13,19 +13,10 @@ export function drainStdin() { } export async function outputFile(file: string, contents: string) { - // Check for special files like `/dev/stdout` or pipes. We don't want to read from these as that - // will hang the process until the file descriptors are closed. - let isSpecialFile = await fs - .stat(file) - .then((stats) => stats.isCharacterDevice() || stats.isFIFO()) - .catch(() => false) - - if (!isSpecialFile) { - try { - let currentContents = await fs.readFile(file, 'utf8') - if (currentContents === contents) return // Skip writing the file - } catch {} - } + try { + let currentContents = await fs.readFile(file, 'utf8') + if (currentContents === contents) return // Skip writing the file + } catch {} // Ensure the parent directories exist await fs.mkdir(path.dirname(file), { recursive: true }) diff --git a/packages/@tailwindcss-cli/src/commands/canonicalize/canonicalize.test.ts b/packages/@tailwindcss-cli/src/commands/canonicalize/canonicalize.test.ts deleted file mode 100644 index 4392380a5..000000000 --- a/packages/@tailwindcss-cli/src/commands/canonicalize/canonicalize.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import path from 'node:path' -import { PassThrough, Readable } from 'node:stream' -import { fileURLToPath } from 'node:url' -import { describe, expect, test } from 'vitest' -import { runCommandLine, streamStdin } from '.' -import { normalizeWindowsSeparators } from '../../utils/test-helpers' - -let css = normalizeWindowsSeparators( - path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'fixtures/input.css'), -) - -describe('runCommandLine', { timeout: 30_000 }, () => { - test('canonicalizes, collapses, and sorts candidate groups from positional arguments', async () => { - let result = await runCommandLine({ - argv: ['--css', css, 'py-3 p-1 px-3'], - stdinIsTTY: true, - stdoutIsTTY: false, - }) - - expect(result).toEqual({ - exitCode: 0, - stdout: 'p-3', - stderr: '', - }) - }) - - test('falls back to the default tailwind import when --css is omitted', async () => { - let result = await runCommandLine({ - argv: ['py-3 p-1 px-3'], - cwd: path.dirname(css), - stdinIsTTY: true, - stdoutIsTTY: false, - }) - - expect(result).toEqual({ - exitCode: 0, - stdout: 'p-3', - stderr: '', - }) - }) - - test('canonicalizes, collapses, and sorts multiple groups from stdin lines', async () => { - let result = await runCommandLine({ - argv: ['--css', css], - stdin: '[display:_flex_] py-3 p-1 px-3\nmt-2 mr-2 mb-2 ml-2 focus:hover:p-3 hover:p-1 py-3\n', - stdinIsTTY: false, - stdoutIsTTY: false, - }) - - expect(result).toEqual({ - exitCode: 0, - stdout: 'flex p-3\nm-2 py-3 hover:p-1 focus:hover:p-3', - stderr: '', - }) - }) - - test('collapses equivalent candidates', async () => { - let result = await runCommandLine({ - argv: ['--css', css, 'mt-2 mr-2 mb-2 ml-2'], - stdinIsTTY: true, - stdoutIsTTY: false, - }) - - expect(result).toEqual({ - exitCode: 0, - stdout: 'm-2', - stderr: '', - }) - }) - - test('renders json output for processed candidate groups', async () => { - let result = await runCommandLine({ - argv: ['--css', css, '--format', 'json', 'py-3 p-1 px-3'], - stdinIsTTY: true, - stdoutIsTTY: false, - }) - - expect(result.exitCode).toBe(0) - expect(JSON.parse(result.stdout)).toEqual([ - { - input: 'py-3 p-1 px-3', - output: 'p-3', - changed: true, - }, - ]) - expect(result.stderr).toBe('') - }) - - test('splits candidate lists with segment-aware spacing', async () => { - let result = await runCommandLine({ - argv: ['--css', css, '--format', 'json', "content-['hello world'] p-1"], - stdinIsTTY: true, - stdoutIsTTY: false, - }) - - expect(result.exitCode).toBe(0) - expect(JSON.parse(result.stdout)).toEqual([ - { - input: "content-['hello world'] p-1", - output: "p-1 content-['hello_world']", - changed: true, - }, - ]) - expect(result.stderr).toBe('') - }) - - test('shows a usage error when no candidate groups are provided', async () => { - let result = await runCommandLine({ - argv: ['--css', css], - stdinIsTTY: true, - stdoutIsTTY: false, - }) - - expect(result.exitCode).toBe(1) - expect(result.stderr).toBe('No candidate groups provided') - expect(result.stdout).toContain('Usage:') - }) - - test('streams canonicalized output line by line', async () => { - let input = Readable.from('py-3 p-1 px-3\nmt-2 mr-2 mb-2 ml-2\n') - let { stream: output, collect: collectOutput } = createOutput() - - await streamStdin({ css, cwd: path.dirname(css), format: 'text', input, output }) - - expect(collectOutput()).toBe('p-3\nm-2\n') - }) - - test('streams empty lines as empty lines', async () => { - let input = Readable.from('py-3 p-1 px-3\n\nmt-2 mr-2 mb-2 ml-2\n') - let { stream: output, collect: collectOutput } = createOutput() - - await streamStdin({ css, cwd: path.dirname(css), format: 'text', input, output }) - - expect(collectOutput()).toBe('p-3\n\nm-2\n') - }) - - test('streams json output when requested', async () => { - let input = Readable.from('py-3 p-1 px-3\nmt-2 mr-2 mb-2 ml-2\n') - let { stream: output, collect: collectOutput } = createOutput() - - await streamStdin({ css, cwd: path.dirname(css), format: 'json', input, output }) - - expect(JSON.parse(collectOutput())).toEqual([ - { - input: 'py-3 p-1 px-3', - output: 'p-3', - changed: true, - }, - { - input: 'mt-2 mr-2 mb-2 ml-2', - output: 'm-2', - changed: true, - }, - ]) - }) - - test('streams jsonl output when requested', async () => { - let input = Readable.from('py-3 p-1 px-3\nmt-2 mr-2 mb-2 ml-2\n') - let { stream: output, collect: collectOutput } = createOutput() - - await streamStdin({ css, cwd: path.dirname(css), format: 'jsonl', input, output }) - - expect(collectOutput()).toBe( - '{"input":"py-3 p-1 px-3","output":"p-3","changed":true}\n' + - '{"input":"mt-2 mr-2 mb-2 ml-2","output":"m-2","changed":true}\n', - ) - }) -}) - -function createOutput() { - let stream = new PassThrough() - let chunks: Buffer[] = [] - stream.on('data', (chunk) => chunks.push(chunk)) - return { stream, collect: () => Buffer.concat(chunks).toString() } -} diff --git a/packages/@tailwindcss-cli/src/commands/canonicalize/index.ts b/packages/@tailwindcss-cli/src/commands/canonicalize/index.ts deleted file mode 100644 index 5aecfe51e..000000000 --- a/packages/@tailwindcss-cli/src/commands/canonicalize/index.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { __unstable__loadDesignSystem } from '@tailwindcss/node' -import fs from 'node:fs/promises' -import path from 'node:path' -import { createInterface } from 'node:readline' -import type { Readable, Writable } from 'node:stream' -import { compare } from '../../../../tailwindcss/src/utils/compare' -import { segment } from '../../../../tailwindcss/src/utils/segment' -import { args, type Arg } from '../../utils/args' -import { help } from '../help' - -const css = String.raw -export type OutputFormat = 'text' | 'json' | 'jsonl' - -export interface CandidateGroupResult { - input: string - output: string - changed: boolean -} - -export interface RunCommandLineOptions { - argv?: string[] - cwd?: string - stdin?: string | null - stdinIsTTY?: boolean - stdoutIsTTY?: boolean -} - -export interface RunCommandLineResult { - exitCode: number - stdout: string - stderr: string -} - -export function usage() { - return 'tailwindcss canonicalize [classes...]' -} - -function usageWithCss() { - return 'tailwindcss canonicalize --css input.css [classes...]' -} - -function usageWithStream() { - return 'tailwindcss canonicalize --stream [--css input.css]' -} - -export function options() { - return { - '--css': { - type: 'string', - description: - 'CSS entry file used to load the Tailwind design system (defaults to `@import "tailwindcss";`)', - }, - '--format': { - type: 'string', - description: 'Output format', - default: 'text', - values: ['text', 'json', 'jsonl'], - }, - '--stream': { - type: 'boolean', - description: 'Read candidate groups from stdin line by line and write results to stdout', - default: false, - }, - } satisfies Arg -} - -const sharedOptions = { - '--help': { - type: 'boolean', - description: 'Display usage information', - alias: '-h', - default: false, - }, -} satisfies Arg - -export async function runCommandLine({ - argv = process.argv.slice(2), - cwd = process.cwd(), - stdin = null, - stdinIsTTY = process.stdin.isTTY, - stdoutIsTTY = process.stdout.isTTY, -}: RunCommandLineOptions = {}): Promise { - try { - let flags = args( - { - ...options(), - ...sharedOptions, - }, - argv, - ) - - let format = parseFormat(flags['--format'] ?? 'text') - - if ((stdoutIsTTY && argv.length === 0) || flags['--help']) { - return { - exitCode: 0, - stdout: helpMessage() ?? '', - stderr: '', - } - } - - if (flags['--stream']) { - await streamStdin({ - css: flags['--css'], - cwd, - format, - input: process.stdin, - output: process.stdout, - }) - return { exitCode: 0, stdout: '', stderr: '' } - } - - let inputs = flags._.length > 0 ? flags._ : await readCandidateGroups({ stdin, stdinIsTTY }) - - if (inputs.length === 0) { - return usageError('No candidate groups provided') - } - - let results = await processCandidateGroups({ - css: flags['--css'], - cwd, - inputs, - }) - - return { - exitCode: 0, - stdout: formatCandidateResults(results, format), - stderr: '', - } - } catch (error) { - return { - exitCode: 1, - stdout: '', - stderr: error instanceof Error ? error.message : String(error), - } - } -} - -export async function streamStdin({ - css: cssFile, - cwd, - format, - input, - output, -}: { - css: string | null - cwd: string - format: OutputFormat - input: Readable - output: Writable -}): Promise { - let designSystem = await loadDesignSystem(cssFile, cwd) - let rl = createInterface({ input }) - let first = true - - if (format === 'json') { - output.write('[') - } - - for await (let line of rl) { - let result = createCandidateGroupResult(designSystem, line) - - switch (format) { - case 'text': { - output.write(result.output + '\n') - break - } - - case 'jsonl': { - output.write(JSON.stringify(result) + '\n') - break - } - - case 'json': { - if (first) { - output.write('\n') - } else { - output.write(',\n') - } - - output.write(indent(JSON.stringify(result, null, 2), 2)) - first = false - break - } - } - } - - if (format === 'json') { - output.write(first ? ']' : '\n]') - } -} - -export function readCandidateGroups({ - stdin, - stdinIsTTY, -}: { - stdin: string | null - stdinIsTTY: boolean -}) { - if (stdin !== null) { - return Promise.resolve(splitCandidateGroups(stdin)) - } - - if (stdinIsTTY) { - return Promise.resolve([]) - } - - return drainStdin().then(splitCandidateGroups) -} - -export async function drainStdin() { - return new Promise((resolve, reject) => { - let result = '' - - process.stdin.on('data', (chunk) => { - result += chunk - }) - - process.stdin.on('end', () => resolve(result)) - process.stdin.on('error', (err) => reject(err)) - }) -} - -export async function processCandidateGroups({ - css, - cwd = process.cwd(), - inputs, -}: { - css: string | null - cwd?: string - inputs: string[] -}): Promise { - let designSystem = await loadDesignSystem(css, cwd) - - return inputs.map((input) => createCandidateGroupResult(designSystem, input)) -} - -export function formatCandidateResults(results: CandidateGroupResult[], format: OutputFormat) { - switch (format) { - case 'json': - return JSON.stringify(results, null, 2) - case 'jsonl': - return results.map((result) => JSON.stringify(result)).join('\n') - case 'text': - return results.map((result) => result.output).join('\n') - } -} - -function helpMessage() { - return help({ - render: false, - usage: [usage(), usageWithCss(), usageWithStream()], - options: { - ...options(), - ...sharedOptions, - }, - }) -} - -async function loadDesignSystem(cssFile: string | null, cwd: string) { - if (cssFile === null) { - return __unstable__loadDesignSystem( - css` - @import 'tailwindcss'; - `, - { base: cwd }, - ) - } - - let resolvedCssFile = path.resolve(cwd, cssFile) - let content = await fs.readFile(resolvedCssFile, 'utf8') - return __unstable__loadDesignSystem(content, { - base: path.dirname(resolvedCssFile), - }) -} - -function splitCandidateGroups(input: string) { - return input - .split(/\r?\n/g) - .map((line) => line.trim()) - .filter((line) => line.length > 0) -} - -function parseFormat(input: string): OutputFormat { - if (input === 'text' || input === 'json' || input === 'jsonl') { - return input - } - - throw new Error(`Invalid value for --format: ${input}`) -} - -function usageError(message: string): RunCommandLineResult { - return { - exitCode: 1, - stdout: helpMessage() ?? '', - stderr: message, - } -} - -function splitCandidates(input: string) { - let trimmedInput = input.trim() - if (trimmedInput.length === 0) return [] - - return segment(trimmedInput, ' ') - .map((candidate) => candidate.trim()) - .filter((candidate) => candidate.length > 0) -} - -function canonicalize( - designSystem: Awaited>, - input: string, -) { - let candidates = splitCandidates(input) - candidates = designSystem.canonicalizeCandidates(candidates, { - collapse: true, - logicalToPhysical: true, - }) - return defaultSort(designSystem.getClassOrder(candidates)).join(' ') -} - -function createCandidateGroupResult( - designSystem: Awaited>, - input: string, -): CandidateGroupResult { - let output = canonicalize(designSystem, input) - - return { - input, - output, - changed: output !== input, - } -} - -function indent(input: string, size: number) { - let prefix = ' '.repeat(size) - return input - .split('\n') - .map((line) => prefix + line) - .join('\n') -} - -function defaultSort(entries: [string, bigint | null][]) { - return entries - .slice() - .sort(([candidateA, orderA], [candidateZ, orderZ]) => { - if (orderA === orderZ) return compare(candidateA, candidateZ) - if (orderA === null) return -1 - if (orderZ === null) return 1 - return bigSign(orderA - orderZ) || compare(candidateA, candidateZ) - }) - .map(([candidate]) => candidate) -} - -function bigSign(value: bigint) { - if (value > 0n) { - return 1 - } else if (value === 0n) { - return 0 - } else { - return -1 - } -} diff --git a/packages/@tailwindcss-cli/src/commands/help/index.ts b/packages/@tailwindcss-cli/src/commands/help/index.ts index b557d03e0..20ee78208 100644 --- a/packages/@tailwindcss-cli/src/commands/help/index.ts +++ b/packages/@tailwindcss-cli/src/commands/help/index.ts @@ -3,34 +3,30 @@ import type { Arg } from '../../utils/args' import { UI, header, highlight, indent, println, wordWrap } from '../../utils/renderer' export function help({ - render = true, invalid, usage, options, }: { - render?: boolean invalid?: string usage?: string[] options?: Arg }) { // Available terminal width - let width = process.stdout.columns ?? 80 - let lines: string[] = [] - let writeLine = render ? println : (value = '') => void lines.push(value) + let width = process.stdout.columns // Render header - writeLine(header()) + println(header()) // Render the invalid command if (invalid) { - writeLine() - writeLine(`${pc.dim('Invalid command:')} ${invalid}`) + println() + println(`${pc.dim('Invalid command:')} ${invalid}`) } // Render usage if (usage && usage.length > 0) { - writeLine() - writeLine(pc.dim('Usage:')) + println() + println(pc.dim('Usage:')) for (let [idx, example] of usage.entries()) { // Split the usage example into the command and its options. This allows // us to wrap the options based on the available width of the terminal. @@ -69,7 +65,7 @@ export function help({ // tailwindcss other [--watch] [options...] // ``` if (lines.length > 1 && idx !== 0) { - writeLine() + println() } // Print the usage examples based on available width of the terminal. @@ -90,9 +86,9 @@ export function help({ // ``` // // > Note how the second line is indented to align with the first line. - writeLine(indent(`${command}${lines.shift()}`)) + println(indent(`${command}${lines.shift()}`)) for (let line of lines) { - writeLine(indent(line, command.length)) + println(indent(line, command.length)) } } } @@ -116,11 +112,7 @@ export function help({ // `alias` followed by `, ` and followed by the `flag`. let maxOptionLength = 0 - for (let [flag, { alias, values }] of Object.entries(options)) { - if (values?.length) { - flag += `[=${values.join(', ')}]` - } - + for (let [flag, { alias }] of Object.entries(options)) { // The option string, which is the combination of the alias and the flag // but already properly indented based on the other aliases to ensure // everything is aligned properly. @@ -135,8 +127,8 @@ export function help({ maxOptionLength = Math.max(maxOptionLength, option.length) } - writeLine() - writeLine(pc.dim('Options:')) + println() + println(pc.dim('Options:')) // The minimum amount of dots between the option and the description. let minimumGap = 8 @@ -164,19 +156,15 @@ export function help({ ) // Print the option, the spacer dots and the start of the description. - writeLine( + println( indent(`${pc.blue(option)} ${pc.dim(pc.gray('\u00B7')).repeat(dotCount)} ${lines.shift()}`), ) // Print the remaining lines of the description, indenting them to align // with the start of the description. for (let line of lines) { - writeLine(indent(`${' '.repeat(option.length + dotCount + spaces)}${line}`)) + println(indent(`${' '.repeat(option.length + dotCount + spaces)}${line}`)) } } } - - if (!render) { - return lines.join('\n') - } } diff --git a/packages/@tailwindcss-cli/src/index.ts b/packages/@tailwindcss-cli/src/index.ts index 1543f7d13..3622a858c 100644 --- a/packages/@tailwindcss-cli/src/index.ts +++ b/packages/@tailwindcss-cli/src/index.ts @@ -3,80 +3,43 @@ import { args, type Arg } from './utils/args' import * as build from './commands/build' -import * as canonicalize from './commands/canonicalize' import { help } from './commands/help' const sharedOptions = { '--help': { type: 'boolean', description: 'Display usage information', alias: '-h' }, } satisfies Arg -function buildUsage(command = 'tailwindcss') { - return `${command} [--input input.css] [--output output.css] [--watch] [--poll=ms] [options…]` -} +const shared = args(sharedOptions) +const command = shared._[0] -function rootHelp({ invalid }: { invalid?: string } = {}) { +// Right now we don't support any sub-commands. Let's show the help message +// instead. +if (command) { help({ - invalid, - usage: [buildUsage('tailwindcss'), buildUsage('tailwindcss build'), canonicalize.usage()], - commands: { - build: 'Build your CSS', - canonicalize: 'Canonicalize candidate lists', - }, + invalid: command, + usage: ['tailwindcss [options]'], options: { ...build.options(), ...sharedOptions }, }) + process.exit(1) } -async function run() { - let argv = process.argv.slice(2) - let command = argv[0] - let rootFlags = args({ ...build.options(), ...sharedOptions }, argv) - - if (command === 'build') { - let flags = args({ ...build.options(), ...sharedOptions }, argv.slice(1)) - - if ((process.stdout.isTTY && argv.length === 1) || flags['--help']) { - help({ - usage: [buildUsage('tailwindcss build')], - options: { ...build.options(), ...sharedOptions }, - }) - process.exit(0) - } - - await build.handle(flags) - return - } - - if (command === 'canonicalize') { - let result = await canonicalize.runCommandLine({ argv: argv.slice(1) }) - - if (result.stdout.length > 0) { - process.stdout.write(`${result.stdout}\n`) - } - - if (result.stderr.length > 0) { - process.stderr.write(`${result.stderr}\n`) - } - - process.exitCode = result.exitCode - return - } - - if ((process.stdout.isTTY && command === undefined) || rootFlags['--help']) { - rootHelp() - process.exit(0) - } - - if (command && !command.startsWith('-')) { - rootHelp({ invalid: command }) - process.exit(1) - } - - let flags = args({ - ...build.options(), - ...sharedOptions, +// Display main help message if no command is being used. +// +// E.g.: +// +// - `tailwindcss` // should show the help message +// +// E.g.: implicit `build` command +// +// - `tailwindcss -o output.css` // should run the build command, not show the help message +// - `tailwindcss > output.css` // should run the build command, not show the help message +if ((process.stdout.isTTY && !process.argv.slice(2).includes('-o')) || shared['--help']) { + help({ + usage: ['tailwindcss [--input input.css] [--output output.css] [--watch] [options…]'], + options: { ...build.options(), ...sharedOptions }, }) - - await build.handle(flags) + process.exit(0) } -await run() +// Handle the build command +build.handle(args(build.options())) diff --git a/packages/@tailwindcss-cli/src/utils/args.test.ts b/packages/@tailwindcss-cli/src/utils/args.test.ts index 249fb23eb..51d2c4747 100644 --- a/packages/@tailwindcss-cli/src/utils/args.test.ts +++ b/packages/@tailwindcss-cli/src/utils/args.test.ts @@ -17,38 +17,6 @@ it('should be possible to parse a single argument', () => { `) }) -it('should only return the last value for duplicate arguments', () => { - expect( - args( - { - '--output': { type: 'string', description: 'Output file' }, - }, - ['--output', 'output.css', '--output', 'override.css'], - ), - ).toMatchInlineSnapshot(` - { - "--output": "override.css", - "_": [], - } - `) -}) - -it('uses last value when flag with "-" is supplied multiple times', () => { - let result = args( - { - '--output': { type: 'string', description: 'Output file', alias: '-o' }, - }, - ['--output', 'output.css', '--output', '-'], - ) - - expect(result).toMatchInlineSnapshot(` - { - "--output": "-", - "_": [], - } - `) -}) - it('should fallback to the default value if no flag is passed', () => { expect( args( @@ -153,34 +121,3 @@ it('should be possible to provide multiple types, and convert the value to that } `) }) - -it('should be possible to provide boolean or number arguments', () => { - let options = { - '--poll': { - type: 'boolean | number', - description: 'Use polling instead of filesystem events when watching', - default: false, - }, - } satisfies Arg - - expect(args(options, ['--poll'])).toMatchInlineSnapshot(` - { - "--poll": true, - "_": [], - } - `) - - expect(args(options, ['--poll=50'])).toMatchInlineSnapshot(` - { - "--poll": 50, - "_": [], - } - `) - - expect(args(options, ['--poll=foo'])).toMatchInlineSnapshot(` - { - "--poll": undefined, - "_": [], - } - `) -}) diff --git a/packages/@tailwindcss-cli/src/utils/args.ts b/packages/@tailwindcss-cli/src/utils/args.ts index 88b306b05..81bd847d5 100644 --- a/packages/@tailwindcss-cli/src/utils/args.ts +++ b/packages/@tailwindcss-cli/src/utils/args.ts @@ -7,7 +7,6 @@ export type Arg = { description: string alias?: `-${string}` default?: Types[keyof Types] - values?: string[] } } @@ -68,28 +67,8 @@ export type Result = { } export function args(options: T, argv = process.argv.slice(2)): Result { - for (let [idx, value] of argv.entries()) { - if (value === '-') { - argv[idx] = '__IO_DEFAULT_VALUE__' - } - } - let parsed = parse(argv) - for (let key in parsed) { - let value = parsed[key] - - if (key !== '_' && Array.isArray(value)) { - value = value[value.length - 1] - } - - if (value === '__IO_DEFAULT_VALUE__') { - value = '-' - } - - parsed[key] = value - } - let result: { _: string[]; [key: string]: unknown } = { _: parsed._, } diff --git a/packages/@tailwindcss-cli/src/utils/disposables.ts b/packages/@tailwindcss-cli/src/utils/disposables.ts deleted file mode 100644 index 737f3ed1e..000000000 --- a/packages/@tailwindcss-cli/src/utils/disposables.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Disposables allow you to manage resources that can be cleaned up. Each helper - * function returns a dispose function to clean up the resource. - * - * The `dispose` method can be called to clean up all resources at once. - */ -export class Disposables { - // Track all disposables - #disposables = new Set([]) - - /** - * Enqueue a callback in the macrotasks queue. - */ - queueMacrotask(cb: () => void) { - let timer = setTimeout(cb, 0) - - return this.add(() => { - clearTimeout(timer) - }) - } - - /** - * General purpose disposable function that can be cleaned up. - */ - add(dispose: () => void) { - this.#disposables.add(dispose) - - return () => { - this.#disposables.delete(dispose) - - dispose() - } - } - - /** - * Dispose all disposables at once. - */ - async dispose() { - for (let dispose of this.#disposables) { - await dispose() - } - - this.#disposables.clear() - } -} diff --git a/packages/@tailwindcss-cli/src/utils/renderer.test.ts b/packages/@tailwindcss-cli/src/utils/renderer.test.ts index 7acef4551..d234be40f 100644 --- a/packages/@tailwindcss-cli/src/utils/renderer.test.ts +++ b/packages/@tailwindcss-cli/src/utils/renderer.test.ts @@ -1,31 +1,24 @@ -import path from 'node:path' +import path from 'path' import { describe, expect, it } from 'vitest' import { relative, wordWrap } from './renderer' -import { normalizeWindowsSeparators } from './test-helpers' describe('relative', () => { it('should print an absolute path relative to the current working directory', () => { - expect(normalizeWindowsSeparators(relative(path.resolve('index.css')))).toMatchInlineSnapshot( - `"./index.css"`, - ) + expect(relative(path.resolve('index.css'))).toMatchInlineSnapshot(`"./index.css"`) }) it('should prefer the shortest value by default', () => { // Shortest between absolute and relative paths - expect(normalizeWindowsSeparators(relative('index.css'))).toMatchInlineSnapshot(`"index.css"`) + expect(relative('index.css')).toMatchInlineSnapshot(`"index.css"`) }) it('should be possible to override the current working directory', () => { - expect(normalizeWindowsSeparators(relative('../utils/index.css', '..'))).toMatchInlineSnapshot( - `"./utils/index.css"`, - ) + expect(relative('../utils/index.css', '..')).toMatchInlineSnapshot(`"./utils/index.css"`) }) it('should be possible to always prefer the relative path', () => { expect( - normalizeWindowsSeparators( - relative('index.css', process.cwd(), { preferAbsoluteIfShorter: false }), - ), + relative('index.css', process.cwd(), { preferAbsoluteIfShorter: false }), ).toMatchInlineSnapshot(`"./index.css"`) }) }) diff --git a/packages/@tailwindcss-cli/src/utils/renderer.ts b/packages/@tailwindcss-cli/src/utils/renderer.ts index da817dda7..35365e8ba 100644 --- a/packages/@tailwindcss-cli/src/utils/renderer.ts +++ b/packages/@tailwindcss-cli/src/utils/renderer.ts @@ -8,8 +8,10 @@ import { formatNanoseconds } from './format-ns' export const UI = { indent: 2, } + export function header() { - return `${pc.italic(pc.bold(pc.blue('\u2248')))} tailwindcss ${pc.blue(`v${getVersion()}`)}` + let { version } = JSON.parse(fs.readFileSync(resolve('tailwindcss/package.json'), 'utf-8')) + return `${pc.italic(pc.bold(pc.blue('\u2248')))} tailwindcss ${pc.blue(`v${version}`)}` } export function highlight(file: string) { @@ -49,9 +51,7 @@ export function wordWrap(text: string, width: number) { for (let word of words) { let wordLength = stripVTControlCharacters(word).length - // A word longer than `width` is kept on its own line rather than - // introducing an empty line before it. - if (lineLength > 0 && lineLength + wordLength + 1 > width) { + if (lineLength + wordLength + 1 > width) { lines.push(line) line = '' lineLength = 0 @@ -94,11 +94,3 @@ export function eprintln(value = '') { export function println(value = '') { process.stdout.write(`${value}\n`) } - -function getVersion(): string { - if (typeof globalThis.__tw_version === 'string') { - return globalThis.__tw_version - } - let { version } = JSON.parse(fs.readFileSync(resolve('tailwindcss/package.json'), 'utf-8')) - return version -} diff --git a/packages/@tailwindcss-cli/src/utils/resolve.ts b/packages/@tailwindcss-cli/src/utils/resolve.ts index e9197e874..4702c2a26 100644 --- a/packages/@tailwindcss-cli/src/utils/resolve.ts +++ b/packages/@tailwindcss-cli/src/utils/resolve.ts @@ -1,32 +1,3 @@ -import EnhancedResolve from 'enhanced-resolve' -import fs from 'node:fs' import { createRequire } from 'node:module' -const localResolve = createRequire(import.meta.url).resolve -export function resolve(id: string) { - if (typeof globalThis.__tw_resolve === 'function') { - let resolved = globalThis.__tw_resolve(id) - if (resolved) { - return resolved - } - } - return localResolve(id) -} - -const resolver = EnhancedResolve.ResolverFactory.createResolver({ - fileSystem: new EnhancedResolve.CachedInputFileSystem(fs, 4000), - useSyncFileSystemCalls: true, - extensions: ['.css'], - mainFields: ['style'], - conditionNames: ['style'], -}) -export function resolveCssId(id: string, base: string) { - if (typeof globalThis.__tw_resolve === 'function') { - let resolved = globalThis.__tw_resolve(id, base) - if (resolved) { - return resolved - } - } - - return resolver.resolveSync({}, base, id) -} +export const resolve = createRequire(import.meta.url).resolve diff --git a/packages/@tailwindcss-cli/src/utils/test-helpers.ts b/packages/@tailwindcss-cli/src/utils/test-helpers.ts deleted file mode 100644 index 30f4fc777..000000000 --- a/packages/@tailwindcss-cli/src/utils/test-helpers.ts +++ /dev/null @@ -1,5 +0,0 @@ -import path from 'node:path' - -export function normalizeWindowsSeparators(p: string) { - return path.sep === '\\' ? p.replaceAll('\\', '/') : p -} diff --git a/packages/@tailwindcss-cli/tsup.config.ts b/packages/@tailwindcss-cli/tsup.config.ts deleted file mode 100644 index 7d82eee2c..000000000 --- a/packages/@tailwindcss-cli/tsup.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig({ - format: ['esm'], - clean: true, - minify: true, - entry: ['src/index.ts'], -}) diff --git a/packages/@tailwindcss-node/README.md b/packages/@tailwindcss-node/README.md deleted file mode 100644 index 5f532607d..000000000 --- a/packages/@tailwindcss-node/README.md +++ /dev/null @@ -1,36 +0,0 @@ -

                          - - - - - Tailwind CSS - - -

                          - -

                          - A utility-first CSS framework for rapidly building custom user interfaces. -

                          - -

                          - Build Status - Total Downloads - Latest Release - License -

                          - ---- - -## Documentation - -For full documentation, visit [tailwindcss.com](https://tailwindcss.com). - -## Community - -For help, discussion about best practices, or feature ideas: - -[Discuss Tailwind CSS on GitHub](https://github.com/tailwindlabs/tailwindcss/discussions) - -## Contributing - -If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md) **before submitting a pull request**. diff --git a/packages/@tailwindcss-node/package.json b/packages/@tailwindcss-node/package.json deleted file mode 100644 index 0eced7366..000000000 --- a/packages/@tailwindcss-node/package.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "name": "@tailwindcss/node", - "version": "4.3.3", - "description": "A utility-first CSS framework for rapidly building custom user interfaces.", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/tailwindlabs/tailwindcss.git", - "directory": "packages/@tailwindcss-node" - }, - "bugs": "https://github.com/tailwindlabs/tailwindcss/issues", - "homepage": "https://tailwindcss.com", - "scripts": { - "build": "tsup-node", - "dev": "pnpm run build -- --watch" - }, - "files": [ - "dist/" - ], - "publishConfig": { - "provenance": true, - "access": "public", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", - "require": "./dist/index.js" - }, - "./require-cache": { - "types": "./dist/require-cache.d.ts", - "default": "./dist/require-cache.js" - }, - "./esm-cache-loader": { - "types": "./dist/esm-cache.loader.d.mts", - "default": "./dist/esm-cache.loader.mjs" - } - } - }, - "exports": { - ".": { - "types": "./src/index.ts", - "import": "./src/index.ts", - "require": "./src/index.cts" - }, - "./require-cache": { - "types": "./src/require-cache.ts", - "import": "./src/require-cache.ts", - "require": "./src/require-cache.cts" - }, - "./esm-cache-loader": { - "types": "./src/esm-cache.loader.mts", - "default": "./src/esm-cache.loader.mts" - } - }, - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "catalog:", - "jiti": "^2.7.0", - "lightningcss": "catalog:", - "magic-string": "^1.1.0", - "source-map-js": "^1.2.1", - "tailwindcss": "workspace:*" - } -} diff --git a/packages/@tailwindcss-node/src/compile.ts b/packages/@tailwindcss-node/src/compile.ts deleted file mode 100644 index e5f8782c8..000000000 --- a/packages/@tailwindcss-node/src/compile.ts +++ /dev/null @@ -1,281 +0,0 @@ -import EnhancedResolve from 'enhanced-resolve' -import { createJiti, type Jiti } from 'jiti' -import fs from 'node:fs' -import fsPromises from 'node:fs/promises' -import path from 'node:path' -import { pathToFileURL } from 'node:url' -import { - __unstable__loadDesignSystem as ___unstable__loadDesignSystem, - compile as _compile, - compileAst as _compileAst, - Features, - Polyfills, -} from 'tailwindcss' -import type { AstNode } from '../../tailwindcss/src/ast' -import { getModuleDependencies } from './get-module-dependencies' -import { rewriteUrls } from './urls' - -export { Features, Polyfills } - -export type Resolver = (id: string, base: string) => Promise - -export interface CompileOptions { - base: string - from?: string - onDependency: (path: string) => void - shouldRewriteUrls?: boolean - polyfills?: Polyfills - - customCssResolver?: Resolver - customJsResolver?: Resolver -} - -function createCompileOptions({ - base, - from, - polyfills, - onDependency, - shouldRewriteUrls, - - customCssResolver, - customJsResolver, -}: CompileOptions) { - return { - base, - polyfills, - from, - async loadModule(id: string, base: string) { - return loadModule(id, base, onDependency, customJsResolver) - }, - async loadStylesheet(id: string, sheetBase: string) { - let sheet = await loadStylesheet(id, sheetBase, onDependency, customCssResolver) - - if (shouldRewriteUrls) { - sheet.content = await rewriteUrls({ - css: sheet.content, - root: base, - base: sheet.base, - }) - } - - return sheet - }, - } -} - -async function ensureSourceDetectionRootExists(compiler: { - root: Awaited>['root'] -}) { - // Verify if the `source(…)` path exists (until the glob pattern starts) - if (compiler.root && compiler.root !== 'none') { - let globSymbols = /[*{]/ - let basePath = [] - for (let segment of compiler.root.pattern.split('/')) { - if (globSymbols.test(segment)) { - break - } - - basePath.push(segment) - } - - let exists = await fsPromises - .stat(path.resolve(compiler.root.base, basePath.join('/'))) - .then((stat) => stat.isDirectory()) - .catch(() => false) - - if (!exists) { - throw new Error( - `The \`source(${compiler.root.pattern})\` does not exist or is not a directory.`, - ) - } - } -} - -export async function compileAst(ast: AstNode[], options: CompileOptions) { - let compiler = await _compileAst(ast, createCompileOptions(options)) - await ensureSourceDetectionRootExists(compiler) - return compiler -} - -export async function compile(css: string, options: CompileOptions) { - let compiler = await _compile(css, createCompileOptions(options)) - await ensureSourceDetectionRootExists(compiler) - return compiler -} - -export async function __unstable__loadDesignSystem(css: string, { base }: { base: string }) { - return ___unstable__loadDesignSystem(css, { - base, - async loadModule(id, base) { - return loadModule(id, base, () => {}) - }, - async loadStylesheet(id, base) { - return loadStylesheet(id, base, () => {}) - }, - }) -} - -export async function loadModule( - id: string, - base: string, - onDependency: (path: string) => void, - customJsResolver?: Resolver, -) { - if (id[0] !== '.') { - let resolvedPath = await resolveJsId(id, base, customJsResolver) - if (!resolvedPath) { - throw new Error(`Could not resolve '${id}' from '${base}'`) - } - - let module = await importModule(pathToFileURL(resolvedPath).href) - return { - path: resolvedPath, - base: path.dirname(resolvedPath), - module: module.default ?? module, - } - } - - let resolvedPath = await resolveJsId(id, base, customJsResolver) - if (!resolvedPath) { - throw new Error(`Could not resolve '${id}' from '${base}'`) - } - - let [module, moduleDependencies] = await Promise.all([ - importModule(pathToFileURL(resolvedPath).href + '?id=' + Date.now()), - getModuleDependencies(resolvedPath), - ]) - - for (let file of moduleDependencies) { - onDependency(file) - } - return { - path: resolvedPath, - base: path.dirname(resolvedPath), - module: module.default ?? module, - } -} - -async function loadStylesheet( - id: string, - base: string, - onDependency: (path: string) => void, - cssResolver?: Resolver, -) { - let resolvedPath = await resolveCssId(id, base, cssResolver) - if (!resolvedPath) throw new Error(`Could not resolve '${id}' from '${base}'`) - - onDependency(resolvedPath) - - let file = await fsPromises.readFile(resolvedPath, 'utf-8') - return { - path: resolvedPath, - base: path.dirname(resolvedPath), - content: file, - } -} - -// Attempts to import the module using the native `import()` function. If this -// fails, it sets up `jiti` and attempts to import this way so that `.ts` files -// can be resolved properly. -let jiti: null | Jiti = null -async function importModule(path: string): Promise { - if (typeof globalThis.__tw_load === 'function') { - let module = await globalThis.__tw_load(path) - if (module) { - return module - } - } - - try { - return await import(path) - } catch (error) { - jiti ??= createJiti(import.meta.url, { moduleCache: false, fsCache: false }) - return await jiti.import(path) - } -} - -const modules = [ - 'node_modules', - ...(process.env.NODE_PATH ? [...process.env.NODE_PATH.split(path.delimiter)] : []), -] - -const cssResolver = EnhancedResolve.ResolverFactory.createResolver({ - fileSystem: new EnhancedResolve.CachedInputFileSystem(fs, 4000), - useSyncFileSystemCalls: true, - extensions: ['.css'], - mainFields: ['style'], - conditionNames: ['style'], - modules, -}) -async function resolveCssId( - id: string, - base: string, - customCssResolver?: Resolver, -): Promise { - if (typeof globalThis.__tw_resolve === 'function') { - let resolved = globalThis.__tw_resolve(id, base) - if (resolved) { - return Promise.resolve(resolved) - } - } - - if (customCssResolver) { - let customResolution = await customCssResolver(id, base) - if (customResolution) { - return customResolution - } - } - - return runResolver(cssResolver, id, base) -} - -const esmResolver = EnhancedResolve.ResolverFactory.createResolver({ - fileSystem: new EnhancedResolve.CachedInputFileSystem(fs, 4000), - useSyncFileSystemCalls: true, - extensions: ['.js', '.json', '.node', '.ts'], - conditionNames: ['node', 'import'], - modules, -}) - -const cjsResolver = EnhancedResolve.ResolverFactory.createResolver({ - fileSystem: new EnhancedResolve.CachedInputFileSystem(fs, 4000), - useSyncFileSystemCalls: true, - extensions: ['.js', '.json', '.node', '.ts'], - conditionNames: ['node', 'require'], - modules, -}) - -async function resolveJsId( - id: string, - base: string, - customJsResolver?: Resolver, -): Promise { - if (typeof globalThis.__tw_resolve === 'function') { - let resolved = globalThis.__tw_resolve(id, base) - if (resolved) { - return Promise.resolve(resolved) - } - } - - if (customJsResolver) { - let customResolution = await customJsResolver(id, base) - if (customResolution) { - return customResolution - } - } - - return runResolver(esmResolver, id, base).catch(() => runResolver(cjsResolver, id, base)) -} - -function runResolver( - resolver: EnhancedResolve.Resolver, - id: string, - base: string, -): Promise { - return new Promise((resolve, reject) => - resolver.resolve({}, base, id, {}, (err, result) => { - if (err) return reject(err) - resolve(result) - }), - ) -} diff --git a/packages/@tailwindcss-node/src/env.ts b/packages/@tailwindcss-node/src/env.ts deleted file mode 100644 index 3b1a1b726..000000000 --- a/packages/@tailwindcss-node/src/env.ts +++ /dev/null @@ -1,44 +0,0 @@ -export const DEBUG = resolveDebug(process.env.DEBUG) - -function resolveDebug(debug: typeof process.env.DEBUG) { - if (typeof debug === 'boolean') { - return debug - } - - if (debug === undefined) { - return false - } - - // Environment variables are strings, so convert to boolean - if (debug === 'true' || debug === '1') { - return true - } - - if (debug === 'false' || debug === '0') { - return false - } - - // Keep the debug convention into account: - // DEBUG=* -> This enables all debug modes - // DEBUG=projectA,projectB,projectC -> This enables debug for projectA, projectB and projectC - // DEBUG=projectA:* -> This enables all debug modes for projectA (if you have sub-types) - // DEBUG=projectA,-projectB -> This enables debug for projectA and explicitly disables it for projectB - - if (debug === '*') { - return true - } - - let debuggers = debug.split(',').map((d) => d.split(':')[0]) - - // Ignoring tailwindcss - if (debuggers.includes('-tailwindcss')) { - return false - } - - // Including tailwindcss - if (debuggers.includes('tailwindcss')) { - return true - } - - return false -} diff --git a/packages/@tailwindcss-node/src/esm-cache.loader.mts b/packages/@tailwindcss-node/src/esm-cache.loader.mts deleted file mode 100644 index 96d297a29..000000000 --- a/packages/@tailwindcss-node/src/esm-cache.loader.mts +++ /dev/null @@ -1,40 +0,0 @@ -import { - isBuiltin, - type ResolveFnOutput, - type ResolveHook, - type ResolveHookContext, - type ResolveHookSync, -} from 'node:module' - -export let resolve: ResolveHook = async (specifier, context, nextResolve) => { - let result = await nextResolve(specifier, context) - return processResolve(context, result) -} - -export let resolveSync: ResolveHookSync = (specifier, context, nextResolve) => { - let result = nextResolve(specifier, context) - return processResolve(context, result) -} - -function processResolve(context: ResolveHookContext, result: ResolveFnOutput) { - try { - if (result.url === import.meta.url) return result - if (isBuiltin(result.url)) return result - if (!context.parentURL) return result - - let parent = new URL(context.parentURL) - - let id = parent.searchParams.get('id') - if (id === null) return result - - let url = new URL(result.url) - url.searchParams.set('id', id) - - return { - ...result, - url: `${url}`, - } - } catch { - return result - } -} diff --git a/packages/@tailwindcss-node/src/get-module-dependencies.ts b/packages/@tailwindcss-node/src/get-module-dependencies.ts deleted file mode 100644 index 34dedff20..000000000 --- a/packages/@tailwindcss-node/src/get-module-dependencies.ts +++ /dev/null @@ -1,106 +0,0 @@ -import fs from 'node:fs/promises' -import path from 'node:path' - -// Patterns we use to match dependencies in a file whether in CJS, ESM, or TypeScript -const DEPENDENCY_PATTERNS = [ - /import[\s\S]*?['"](.{3,}?)['"]/gi, - /import[\s\S]*from[\s\S]*?['"](.{3,}?)['"]/gi, - /export[\s\S]*from[\s\S]*?['"](.{3,}?)['"]/gi, - /require\(['"`](.+)['"`]\)/gi, -] - -// Given the current file `a.ts`, we want to make sure that when importing `b` that we resolve -// `b.ts` before `b.js` -// -// E.g.: -// -// a.ts -// b // .ts -// c // .ts -// a.js -// b // .js or .ts -const JS_EXTENSIONS = ['.js', '.cjs', '.mjs'] -const JS_RESOLUTION_ORDER = ['', '.js', '.cjs', '.mjs', '.ts', '.cts', '.mts', '.jsx', '.tsx'] -const TS_RESOLUTION_ORDER = ['', '.ts', '.cts', '.mts', '.tsx', '.js', '.cjs', '.mjs', '.jsx'] - -async function resolveWithExtension(file: string, extensions: string[]) { - // Try to find `./a.ts`, `./a.cts`, ... from `./a` - for (let ext of extensions) { - let full = `${file}${ext}` - - let stats = await fs.stat(full).catch(() => null) - if (stats?.isFile()) return full - } - - // Try to find `./a/index.js` from `./a` - for (let ext of extensions) { - let full = `${file}/index${ext}` - - let exists = await fs.access(full).then( - () => true, - () => false, - ) - if (exists) { - return full - } - } - - return null -} - -async function traceDependencies( - seen: Set, - filename: string, - base: string, - ext: string, -): Promise { - // Try to find the file - let extensions = JS_EXTENSIONS.includes(ext) ? JS_RESOLUTION_ORDER : TS_RESOLUTION_ORDER - let absoluteFile = await resolveWithExtension(path.resolve(base, filename), extensions) - if (absoluteFile === null) return // File doesn't exist - - // Prevent infinite loops when there are circular dependencies - if (seen.has(absoluteFile)) return // Already seen - - // Mark the file as a dependency - seen.add(absoluteFile) - - // Resolve new base for new imports/requires - base = path.dirname(absoluteFile) - ext = path.extname(absoluteFile) - - let contents = await fs.readFile(absoluteFile, 'utf-8') - - // Recursively trace dependencies in parallel - let promises = [] - - for (let pattern of DEPENDENCY_PATTERNS) { - for (let match of contents.matchAll(pattern)) { - // Bail out if it's not a relative file - if (!match[1].startsWith('.')) continue - - promises.push(traceDependencies(seen, match[1], base, ext)) - } - } - - await Promise.all(promises) -} - -/** - * Trace all dependencies of a module recursively - * - * The result is an unordered set of absolute file paths. Meaning that the order - * is not guaranteed to be equal to source order or across runs. - **/ -export async function getModuleDependencies(absoluteFilePath: string) { - let seen = new Set() - - await traceDependencies( - seen, - absoluteFilePath, - path.dirname(absoluteFilePath), - path.extname(absoluteFilePath), - ) - - return Array.from(seen) -} diff --git a/packages/@tailwindcss-node/src/index.cts b/packages/@tailwindcss-node/src/index.cts deleted file mode 100644 index 9f2788933..000000000 --- a/packages/@tailwindcss-node/src/index.cts +++ /dev/null @@ -1,26 +0,0 @@ -import * as Module from 'node:module' -import { pathToFileURL } from 'node:url' -import * as env from './env' -import { resolveSync } from './esm-cache.loader.mjs' -export * from './compile' -export * from './instrumentation' -export * from './normalize-path' -export * from './optimize' -export * from './source-maps' -export { env } - -// In Bun, ESM modules will also populate `require.cache`, so the module hook is -// not necessary. -if (!process.versions.bun) { - // `Module#register` was added in Node v18.19.0 and v20.6.0 - // `Module#registerHooks` was added in Node v22.15.0 and v23.5.0 and is the preferred API since v25.9.0, - // runtime-deprecating `Module#register` since v26 - // - // Not calling it means that while ESM dependencies don't get reloaded, the - // actual included files will because they cache bust directly via `?id=…` - if (Module.registerHooks) { - Module.registerHooks({ resolve: resolveSync }) - } else { - Module.register?.(pathToFileURL(require.resolve('@tailwindcss/node/esm-cache-loader'))) - } -} diff --git a/packages/@tailwindcss-node/src/index.ts b/packages/@tailwindcss-node/src/index.ts deleted file mode 100644 index 8eff1466a..000000000 --- a/packages/@tailwindcss-node/src/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import * as Module from 'node:module' -import { pathToFileURL } from 'node:url' -import * as env from './env' -import { resolveSync } from './esm-cache.loader.mjs' -export * from './compile' -export * from './instrumentation' -export * from './normalize-path' -export * from './optimize' -export * from './source-maps' -export { env } - -// In Bun, ESM modules will also populate `require.cache`, so the module hook is -// not necessary. -if (!process.versions.bun) { - // `Module#register` was added in Node v18.19.0 and v20.6.0 - // `Module#registerHooks` was added in Node v22.15.0 and v23.5.0 and is the preferred API since v25.9.0, - // runtime-deprecating `Module#register` since v26 - // - // Not calling it means that while ESM dependencies don't get reloaded, the - // actual included files will because they cache bust directly via `?id=…` - if (Module.registerHooks) { - Module.registerHooks({ resolve: resolveSync }) - } else { - let localRequire = Module.createRequire(import.meta.url) - Module.register?.(pathToFileURL(localRequire.resolve('@tailwindcss/node/esm-cache-loader'))) - } -} diff --git a/packages/@tailwindcss-node/src/instrumentation.test.ts b/packages/@tailwindcss-node/src/instrumentation.test.ts deleted file mode 100644 index 102c7ed8e..000000000 --- a/packages/@tailwindcss-node/src/instrumentation.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { stripVTControlCharacters } from 'util' -import { expect, it } from 'vitest' -import { Instrumentation } from './instrumentation' - -it('should add instrumentation using start/end markers', () => { - let I = new Instrumentation() - - I.start('Foo') - let x = 1 - for (let i = 0; i < 100; i++) { - I.start('Bar') - x **= 2 - I.end('Bar') - } - I.end('Foo') - - I.hit('Potato') - I.hit('Potato') - I.hit('Potato') - I.hit('Potato') - - expect.assertions(1) - - I.report((output) => { - expect(stripVTControlCharacters(output).replace(/\[.*\]/g, '[0.xxms]')).toMatchInlineSnapshot(` - " - Hits: - Potato × 4 - - Timers: - [0.xxms] Foo - [0.xxms] ↳ Bar × 100 - " - `) - }) -}) - -it('should measure callbacks via the `span` api', () => { - let I = new Instrumentation() - - I.span('Foo', () => { - let x = 1 - for (let i = 0; i < 100; i++) { - I.span('Bar', () => { - x **= 2 - }) - } - }) - - expect.assertions(1) - - I.report((output) => { - expect(stripVTControlCharacters(output).replace(/\[.*\]/g, '[0.xxms]')).toMatchInlineSnapshot(` - " - [0.xxms] Foo - [0.xxms] ↳ Bar × 100 - " - `) - }) -}) - -it('should measure async callbacks via the `span` api', async () => { - let I = new Instrumentation() - - await I.span('Foo', async () => { - let x = 1 - for (let i = 0; i < 100; i++) { - I.span('Bar', () => { - x **= 2 - }) - } - await new Promise((r) => setTimeout(r, 500)) - }) - - expect.assertions(2) - - I.report((output) => { - expect(stripVTControlCharacters(output).replace(/\[.*\]/g, '[0.xxms]')).toMatchInlineSnapshot(` - " - [0.xxms] Foo - [0.xxms] ↳ Bar × 100 - " - `) - - let [, duration] = stripVTControlCharacters(output).match(/\[(.*)\]/)! - expect(parseFloat(duration)).toBeGreaterThanOrEqual(500 - 2 /* Margin of error */) - }) -}) - -it('should measure blocks until they go out of scope via `using`', () => { - let I = new Instrumentation() - - { - using _ = I.track('Foo') - - let x = 1 - for (let i = 0; i < 100; i++) { - using _ = I.track('Bar') - x **= 2 - } - } - - expect.assertions(1) - - I.report((output) => { - expect(stripVTControlCharacters(output).replace(/\[.*\]/g, '[0.xxms]')).toMatchInlineSnapshot(` - " - [0.xxms] Foo - [0.xxms] ↳ Bar × 100 - " - `) - }) -}) - -it('should auto end pending timers when reporting', () => { - let I = new Instrumentation() - - I.start('Foo') - let x = 1 - for (let i = 0; i < 100; i++) { - I.start('Bar') - x **= 2 - I.end('Bar') - } - I.start('Baz') - - expect.assertions(1) - - I.report((output) => { - expect(stripVTControlCharacters(output).replace(/\[.*\]/g, '[0.xxms]')).toMatchInlineSnapshot(` - " - [0.xxms] Foo - [0.xxms] ↳ Bar × 100 - [0.xxms] ↳ Baz - " - `) - }) -}) diff --git a/packages/@tailwindcss-node/src/instrumentation.ts b/packages/@tailwindcss-node/src/instrumentation.ts deleted file mode 100644 index e454e6744..000000000 --- a/packages/@tailwindcss-node/src/instrumentation.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { DefaultMap } from '../../tailwindcss/src/utils/default-map' -import * as env from './env' - -// See: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#:~:text=Symbol.dispose,-??=%20Symbol(%22Symbol.dispose -// @ts-expect-error — Ensure Symbol.dispose exists -Symbol.dispose ??= Symbol('Symbol.dispose') -// @ts-expect-error — Ensure Symbol.asyncDispose exists -Symbol.asyncDispose ??= Symbol('Symbol.asyncDispose') - -export class Instrumentation implements Disposable { - #hits = new DefaultMap(() => ({ value: 0 })) - #timers = new DefaultMap(() => ({ value: 0n })) - #timerStack: { id: string; label: string; namespace: string; value: bigint }[] = [] - - constructor( - private shouldReport = env.DEBUG, - private defaultFlush = (message: string) => void process.stderr.write(`${message}\n`), - ) {} - - hit(label: string) { - this.#hits.get(label).value++ - } - - start(label: string) { - let namespace = this.#timerStack.map((t) => t.label).join('//') - let id = `${namespace}${namespace.length === 0 ? '' : '//'}${label}` - - this.#hits.get(id).value++ - - // Create the timer if it doesn't exist yet - this.#timers.get(id) - - this.#timerStack.push({ id, label, namespace, value: process.hrtime.bigint() }) - } - - end(label: string) { - let end = process.hrtime.bigint() - - if (this.#timerStack[this.#timerStack.length - 1].label !== label) { - throw new Error( - `Mismatched timer label: \`${label}\`, expected \`${ - this.#timerStack[this.#timerStack.length - 1].label - }\``, - ) - } - - let parent = this.#timerStack.pop()! - let elapsed = end - parent.value - this.#timers.get(parent.id).value += elapsed - } - - track(label: string) { - this.start(label) - let done = false - - return { - [Symbol.dispose]: () => { - if (!done) { - this.end(label) - done = true - } - }, - [Symbol.asyncDispose]: () => { - if (!done) { - this.end(label) - done = true - } - }, - } - } - - span(label: string, fn: () => T): T { - this.start(label) - let isPromise = false - try { - let result = fn() - - isPromise = result && typeof (result as any).then === 'function' - - // @ts-expect-error — TS can't infer that result is a Promise here - return isPromise ? result.finally(() => this.end(label)) : result - } finally { - if (!isPromise) this.end(label) - } - } - - reset() { - this.#hits.clear() - this.#timers.clear() - this.#timerStack.splice(0) - } - - report(flush = this.defaultFlush) { - let output: string[] = [] - let hasHits = false - - // Auto end any pending timers - for (let i = this.#timerStack.length - 1; i >= 0; i--) { - this.end(this.#timerStack[i].label) - } - - for (let [label, { value: count }] of this.#hits.entries()) { - if (this.#timers.has(label)) continue - if (output.length === 0) { - hasHits = true - output.push('Hits:') - } - - let depth = label.split('//').length - output.push(`${' '.repeat(depth)}${label} ${dim(blue(`× ${count}`))}`) - } - - if (this.#timers.size > 0 && hasHits) { - output.push('\nTimers:') - } - - let max = -Infinity - let computed = new Map() - for (let [label, { value }] of this.#timers) { - let x = `${(Number(value) / 1e6).toFixed(2)}ms` - computed.set(label, x) - max = Math.max(max, x.length) - } - - for (let label of this.#timers.keys()) { - let depth = label.split('//').length - output.push( - `${dim(`[${computed.get(label)!.padStart(max, ' ')}]`)}${' '.repeat(depth - 1)}${depth === 1 ? ' ' : dim(' ↳ ')}${label.split('//').pop()} ${ - this.#hits.get(label).value === 1 ? '' : dim(blue(`× ${this.#hits.get(label).value}`)) - }`.trimEnd(), - ) - } - - flush(`\n${output.join('\n')}\n`) - this.reset() - } - - [Symbol.dispose]() { - this.shouldReport && this.report() - } -} - -function dim(input: string) { - return `\u001b[2m${input}\u001b[22m` -} - -function blue(input: string) { - return `\u001b[34m${input}\u001b[39m` -} diff --git a/packages/@tailwindcss-node/src/normalize-path.ts b/packages/@tailwindcss-node/src/normalize-path.ts deleted file mode 100644 index a8184ef23..000000000 --- a/packages/@tailwindcss-node/src/normalize-path.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Inlined version of `normalize-path` -// Copyright (c) 2014-2018, Jon Schlinkert. -// Released under the MIT License. -function normalizePathBase(path: string, stripTrailing?: boolean) { - if (typeof path !== 'string') { - throw new TypeError('expected path to be a string') - } - - if (path === '\\' || path === '/') return '/' - - var len = path.length - if (len <= 1) return path - - // ensure that win32 namespaces has two leading slashes, so that the path is - // handled properly by the win32 version of path.parse() after being normalized - // https://msdn.microsoft.com/library/windows/desktop/aa365247(v=vs.85).aspx#namespaces - var prefix = '' - if (len > 4 && path[3] === '\\') { - var ch = path[2] - if ((ch === '?' || ch === '.') && path.slice(0, 2) === '\\\\') { - path = path.slice(2) - prefix = '//' - } - } - - var segs = path.split(/[/\\]+/) - if (stripTrailing !== false && segs[segs.length - 1] === '') { - segs.pop() - } - return prefix + segs.join('/') -} - -export function normalizePath(originalPath: string) { - let normalized = normalizePathBase(originalPath) - - // Make sure Windows network share paths are normalized properly - // They have to begin with two slashes or they won't resolve correctly - if ( - originalPath.startsWith('\\\\') && - normalized.startsWith('/') && - !normalized.startsWith('//') - ) { - return `/${normalized}` - } - - return normalized -} diff --git a/packages/@tailwindcss-node/src/optimize.ts b/packages/@tailwindcss-node/src/optimize.ts deleted file mode 100644 index 2f0d39589..000000000 --- a/packages/@tailwindcss-node/src/optimize.ts +++ /dev/null @@ -1,155 +0,0 @@ -import remapping from '@jridgewell/remapping' -import { Features, transform } from 'lightningcss' -import MagicString from 'magic-string' - -export interface OptimizeOptions { - /** - * The file being transformed - */ - file?: string - - /** - * Enabled minified output - */ - minify?: boolean - - /** - * The output source map before optimization - * - * If omitted a resulting source map will not be available - */ - map?: string -} - -export interface TransformResult { - code: string - map: string | undefined -} - -export function optimize( - input: string, - { file = 'input.css', minify = false, map }: OptimizeOptions = {}, -): TransformResult { - function optimize(code: Buffer | Uint8Array, map: string | undefined) { - return transform({ - filename: file, - code, - minify, - sourceMap: typeof map !== 'undefined', - inputSourceMap: map, - drafts: { - customMedia: true, - }, - nonStandard: { - deepSelectorCombinator: true, - }, - include: Features.Nesting | Features.MediaQueries, - exclude: Features.LogicalProperties | Features.DirSelector | Features.LightDark, - targets: { - safari: (16 << 16) | (4 << 8), - ios_saf: (16 << 16) | (4 << 8), - firefox: 128 << 16, - chrome: 111 << 16, - }, - errorRecovery: true, - }) - } - - // Running Lightning CSS twice to ensure that adjacent rules are merged after - // nesting is applied. This creates a more optimized output. - let result = optimize(Buffer.from(input), map) - map = result.map?.toString() - - result.warnings = result.warnings.filter((warning) => { - // Ignore warnings about unknown pseudo-classes that are used in frameworks - // such as Vue or Angular and are handled by their own compilers, before - // reaching the browser. - if ( - /'(deep|slotted|global|ng-deep|host-context)' is not recognized as a valid pseudo-/.test( - warning.message, - ) - ) { - return false - } - - return true - }) - - // Because of `errorRecovery: true`, there could be warnings, so let's let the - // user know about them. - if (process.env.NODE_ENV !== 'test' && result.warnings.length > 0) { - let lines = input.split('\n') - - let output = [ - `Found ${result.warnings.length} ${result.warnings.length === 1 ? 'warning' : 'warnings'} while optimizing generated CSS:`, - ] - - for (let [idx, warning] of result.warnings.entries()) { - output.push('') - if (result.warnings.length > 1) { - output.push(`Issue #${idx + 1}:`) - } - - let context = 2 - - let start = Math.max(0, warning.loc.line - context - 1) - let end = Math.min(lines.length, warning.loc.line + context) - - let snippet = lines.slice(start, end).map((line, idx) => { - if (start + idx + 1 === warning.loc.line) { - return `${dim(`\u2502`)} ${line}` - } else { - return dim(`\u2502 ${line}`) - } - }) - - snippet.splice( - warning.loc.line - start, - 0, - `${dim('\u2506')}${' '.repeat(warning.loc.column - 1)} ${yellow(`${dim('^--')} ${warning.message}`)}`, - `${dim('\u2506')}`, - ) - - output.push(...snippet) - } - output.push('') - - console.warn(output.join('\n')) - } - - result = optimize(result.code, map) - map = result.map?.toString() - - let code = result.code.toString() - - // Work around an issue where the media query range syntax transpilation - // generates code that is invalid with `@media` queries level 3. - let magic = new MagicString(code) - magic.replaceAll('@media not (', '@media not all and (') - - // We have to use a source-map-preserving method of replacing the content - // which requires the use of Magic String + remapping(…) to make sure - // the resulting map is correct - if (map !== undefined && magic.hasChanged()) { - let magicMap = magic.generateMap({ source: 'original', hires: 'boundary' }).toString() - - let remapped = remapping([magicMap, map], () => null) - - map = remapped.toString() - } - - code = magic.toString() - - return { - code, - map, - } -} - -function dim(str: string) { - return `\x1B[2m${str}\x1B[22m` -} - -function yellow(str: string) { - return `\x1B[33m${str}\x1B[39m` -} diff --git a/packages/@tailwindcss-node/src/require-cache.cts b/packages/@tailwindcss-node/src/require-cache.cts deleted file mode 100644 index ea0562019..000000000 --- a/packages/@tailwindcss-node/src/require-cache.cts +++ /dev/null @@ -1,5 +0,0 @@ -export function clearRequireCache(files: string[]) { - for (let key of files) { - delete require.cache[key] - } -} diff --git a/packages/@tailwindcss-node/src/require-cache.ts b/packages/@tailwindcss-node/src/require-cache.ts deleted file mode 100644 index ea0562019..000000000 --- a/packages/@tailwindcss-node/src/require-cache.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function clearRequireCache(files: string[]) { - for (let key of files) { - delete require.cache[key] - } -} diff --git a/packages/@tailwindcss-node/src/source-maps.test.ts b/packages/@tailwindcss-node/src/source-maps.test.ts deleted file mode 100644 index 96ff8f05c..000000000 --- a/packages/@tailwindcss-node/src/source-maps.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { expect, it } from 'vitest' -import { toSourceMap } from './source-maps' - -it('should emit source maps', () => { - let map = toSourceMap('{"version":3,"sources":[],"names":[],"mappings":""}') - - expect(map.comment('app.css.map')).toBe('/*# sourceMappingURL=app.css.map */\n') -}) diff --git a/packages/@tailwindcss-node/src/source-maps.ts b/packages/@tailwindcss-node/src/source-maps.ts deleted file mode 100644 index 58b79cb25..000000000 --- a/packages/@tailwindcss-node/src/source-maps.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { SourceMapGenerator } from 'source-map-js' -import type { DecodedSource, DecodedSourceMap } from '../../tailwindcss/src/source-maps/source-map' -import { DefaultMap } from '../../tailwindcss/src/utils/default-map' - -export type { DecodedSource, DecodedSourceMap } -export interface SourceMap { - readonly raw: string - readonly inline: string - comment(url: string): string -} - -function serializeSourceMap(map: DecodedSourceMap): string { - let generator = new SourceMapGenerator() - - let id = 1 - let sourceTable = new DefaultMap< - DecodedSource | null, - { - url: string - content: string - } - >((src) => { - return { - url: src?.url ?? ``, - content: src?.content ?? '', - } - }) - - for (let mapping of map.mappings) { - let original = sourceTable.get(mapping.originalPosition?.source ?? null) - - generator.addMapping({ - generated: mapping.generatedPosition, - original: mapping.originalPosition, - source: original.url, - name: mapping.name, - }) - - generator.setSourceContent(original.url, original.content) - } - - return generator.toString() -} - -export function toSourceMap(map: DecodedSourceMap | string): SourceMap { - let raw = typeof map === 'string' ? map : serializeSourceMap(map) - - function comment(url: string) { - return `/*# sourceMappingURL\x3d${url} */\n` - } - - return { - raw, - get inline() { - let inlined = Buffer.from(raw, 'utf-8').toString('base64') - return comment(`data:application/json;base64,${inlined}`) - }, - comment, - } -} diff --git a/packages/@tailwindcss-node/src/urls.test.ts b/packages/@tailwindcss-node/src/urls.test.ts deleted file mode 100644 index 16ba352a7..000000000 --- a/packages/@tailwindcss-node/src/urls.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { expect, test } from 'vitest' -import { rewriteUrls } from './urls' - -const css = String.raw - -test('URLs can be rewritten', async () => { - let root = '/root' - - let result = await rewriteUrls({ - root, - base: '/root/foo/bar', - // prettier-ignore - css: css` - .foo { - /* Relative URLs: replaced */ - background: url(./image.jpg); - background: url(../image.jpg); - background: url('./image.jpg'); - background: url("./image.jpg"); - - /* Absolute URLs: ignored */ - background: url(/image.jpg); - background: url(/foo/image.jpg); - background: url('/image.jpg'); - background: url("/image.jpg"); - - /* Potentially Vite-aliased URLs: ignored */ - background: url(~/image.jpg); - background: url(~/foo/image.jpg); - background: url('~/image.jpg'); - background: url("~/image.jpg"); - background: url(#/image.jpg); - background: url(#/foo/image.jpg); - background: url('#/image.jpg'); - background: url("#/image.jpg"); - background: url(@/image.jpg); - background: url(@/foo/image.jpg); - background: url('@/image.jpg'); - background: url("@/image.jpg"); - - /* External URL: ignored */ - background: url(http://example.com/image.jpg); - background: url('http://example.com/image.jpg'); - background: url("http://example.com/image.jpg"); - - /* Data URI: ignored */ - /* background: url(data:image/png;base64,abc==); */ - background: url('data:image/png;base64,abc=='); - background: url("data:image/png;base64,abc=="); - - /* Function calls: ignored */ - background: url(var(--foo)); - background: url(var(--foo, './image.jpg')); - background: url(var(--foo, "./image.jpg")); - - /* Fragments: ignored */ - background: url(#dont-touch-this); - - /* Image Sets - Raw URL: replaced */ - background: image-set( - image1.jpg 1x, - image2.jpg 2x - ); - background: image-set( - 'image1.jpg' 1x, - 'image2.jpg' 2x - ); - background: image-set( - "image1.jpg" 1x, - "image2.jpg" 2x - ); - - /* Image Sets - Relative URLs: replaced */ - background: image-set( - url('image1.jpg') 1x, - url('image2.jpg') 2x - ); - background: image-set( - url("image1.jpg") 1x, - url("image2.jpg") 2x - ); - background: image-set( - url('image1.avif') type('image/avif'), - url('image2.jpg') type('image/jpeg') - ); - background: image-set( - url("image1.avif") type('image/avif'), - url("image2.jpg") type('image/jpeg') - ); - - /* Image Sets - Function calls: ignored */ - background: image-set( - linear-gradient(blue, white) 1x, - linear-gradient(blue, green) 2x - ); - - /* Image Sets - Mixed: replaced */ - background: image-set( - linear-gradient(blue, white) 1x, - url("image2.jpg") 2x - ); - } - - /* Fonts - Multiple URLs: replaced */ - @font-face { - font-family: "Newman"; - src: - local("Newman"), - url("newman-COLRv1.otf") format("opentype") tech(color-COLRv1), - url("newman-outline.otf") format("opentype"), - url("newman-outline.woff") format("woff"); - } - `, - }) - - expect(result).toMatchInlineSnapshot(` - ".foo { - background: url(./foo/bar/image.jpg); - background: url(./foo/image.jpg); - background: url('./foo/bar/image.jpg'); - background: url("./foo/bar/image.jpg"); - background: url(/image.jpg); - background: url(/foo/image.jpg); - background: url('/image.jpg'); - background: url("/image.jpg"); - background: url(~/image.jpg); - background: url(~/foo/image.jpg); - background: url('~/image.jpg'); - background: url("~/image.jpg"); - background: url(#/image.jpg); - background: url(#/foo/image.jpg); - background: url('#/image.jpg'); - background: url("#/image.jpg"); - background: url(@/image.jpg); - background: url(@/foo/image.jpg); - background: url('@/image.jpg'); - background: url("@/image.jpg"); - background: url(http://example.com/image.jpg); - background: url('http://example.com/image.jpg'); - background: url("http://example.com/image.jpg"); - background: url('data:image/png;base64,abc=='); - background: url("data:image/png;base64,abc=="); - background: url(var(--foo)); - background: url(var(--foo, './image.jpg')); - background: url(var(--foo, "./image.jpg")); - background: url(#dont-touch-this); - background: image-set(url(./foo/bar/image1.jpg) 1x, url(./foo/bar/image2.jpg) 2x); - background: image-set(url('./foo/bar/image1.jpg') 1x, url('./foo/bar/image2.jpg') 2x); - background: image-set(url("./foo/bar/image1.jpg") 1x, url("./foo/bar/image2.jpg") 2x); - background: image-set(url('./foo/bar/image1.jpg') 1x, url('./foo/bar/image2.jpg') 2x); - background: image-set(url("./foo/bar/image1.jpg") 1x, url("./foo/bar/image2.jpg") 2x); - background: image-set(url('./foo/bar/image1.avif') type('image/avif'), url('./foo/bar/image2.jpg') type('image/jpeg')); - background: image-set(url("./foo/bar/image1.avif") type('image/avif'), url("./foo/bar/image2.jpg") type('image/jpeg')); - background: image-set(linear-gradient(blue, white) 1x, linear-gradient(blue, green) 2x); - background: image-set(linear-gradient(blue, white) 1x, url("./foo/bar/image2.jpg") 2x); - } - @font-face { - font-family: "Newman"; - src: local("Newman"), url("./foo/bar/newman-COLRv1.otf") format("opentype") tech(color-COLRv1), url("./foo/bar/newman-outline.otf") format("opentype"), url("./foo/bar/newman-outline.woff") format("woff"); - } - " - `) -}) diff --git a/packages/@tailwindcss-node/src/urls.ts b/packages/@tailwindcss-node/src/urls.ts deleted file mode 100644 index 61695a9d1..000000000 --- a/packages/@tailwindcss-node/src/urls.ts +++ /dev/null @@ -1,207 +0,0 @@ -// Inlined version of code from Vite -// Copyright (c) 2019-present, VoidZero Inc. and Vite contributors -// Released under the MIT License. -// -// Minor modifications have been made to work with the Tailwind CSS codebase - -import * as path from 'node:path' -import { toCss } from '../../tailwindcss/src/ast' -import { parse } from '../../tailwindcss/src/css-parser' -import { walk } from '../../tailwindcss/src/walk' -import { normalizePath } from './normalize-path' - -const cssUrlRE = - /(?[\w-]+\([^)]*\)|"[^"]*"|'[^']*'|[^,]\S*[^,])\s*(?:\s(?\w[^,]+))?(?:,|$)/g -const nonEscapedDoubleQuoteRE = /(? dataUrlRE.test(url) -const isExternalUrl = (url: string): boolean => externalRE.test(url) - -type CssUrlReplacer = (url: string, importer?: string) => string | Promise - -interface ImageCandidate { - url: string - descriptor: string -} - -export async function rewriteUrls({ - css, - base, - root, -}: { - css: string - base: string - root: string -}) { - if (!css.includes('url(') && !css.includes('image-set(')) { - return css - } - - let ast = parse(css) - - let promises: Promise[] = [] - - function replacerForDeclaration(url: string) { - if (url[0] === '/') return url - - let absoluteUrl = path.posix.join(normalizePath(base), url) - let relativeUrl = path.posix.relative(normalizePath(root), absoluteUrl) - - // If the path points to a file in the same directory, `path.relative` will - // remove the leading `./` and we need to add it back in order to still - // consider the path relative - if (!relativeUrl.startsWith('.')) { - relativeUrl = './' + relativeUrl - } - - return relativeUrl - } - - walk(ast, (node) => { - if (node.kind !== 'declaration') return - if (!node.value) return - - let isCssUrl = cssUrlRE.test(node.value) - let isCssImageSet = cssImageSetRE.test(node.value) - - if (isCssUrl || isCssImageSet) { - let rewriterToUse = isCssImageSet ? rewriteCssImageSet : rewriteCssUrls - - promises.push( - rewriterToUse(node.value, replacerForDeclaration).then((url) => { - node.value = url - }), - ) - } - }) - - if (promises.length) { - await Promise.all(promises) - } - - return toCss(ast) -} - -function rewriteCssUrls(css: string, replacer: CssUrlReplacer): Promise { - return asyncReplace(css, cssUrlRE, async (match) => { - const [matched, rawUrl] = match - return await doUrlReplace(rawUrl.trim(), matched, replacer) - }) -} - -async function rewriteCssImageSet(css: string, replacer: CssUrlReplacer): Promise { - return await asyncReplace(css, cssImageSetRE, async (match) => { - const [, rawUrl] = match - const url = await processSrcSet(rawUrl, async ({ url }) => { - // the url maybe url(...) - if (cssUrlRE.test(url)) { - return await rewriteCssUrls(url, replacer) - } - if (!cssNotProcessedRE.test(url)) { - return await doUrlReplace(url, url, replacer) - } - return url - }) - return url - }) -} - -async function doUrlReplace( - rawUrl: string, - matched: string, - replacer: CssUrlReplacer, - funcName: string = 'url', -) { - let wrap = '' - const first = rawUrl[0] - if (first === `"` || first === `'`) { - wrap = first - rawUrl = rawUrl.slice(1, -1) - } - - if (skipUrlReplacer(rawUrl)) { - return matched - } - - let newUrl = await replacer(rawUrl) - // The new url might need wrapping even if the original did not have it, e.g. if a space was added during replacement - if (wrap === '' && newUrl !== encodeURI(newUrl)) { - wrap = '"' - } - // If wrapping in single quotes and newUrl also contains single quotes, switch to double quotes. - // Give preference to double quotes since SVG inlining converts double quotes to single quotes. - if (wrap === "'" && newUrl.includes("'")) { - wrap = '"' - } - // Escape double quotes if they exist (they also tend to be rarer than single quotes) - if (wrap === '"' && newUrl.includes('"')) { - newUrl = newUrl.replace(nonEscapedDoubleQuoteRE, '\\"') - } - return `${funcName}(${wrap}${newUrl}${wrap})` -} - -function skipUrlReplacer(rawUrl: string, aliases?: string[]) { - return ( - isExternalUrl(rawUrl) || - isDataUrl(rawUrl) || - !rawUrl[0].match(/[.a-zA-Z0-9_]/) || - functionCallRE.test(rawUrl) - ) -} - -function processSrcSet( - srcs: string, - replacer: (arg: ImageCandidate) => Promise, -): Promise { - return Promise.all( - parseSrcset(srcs).map(async ({ url, descriptor }) => ({ - url: await replacer({ url, descriptor }), - descriptor, - })), - ).then(joinSrcset) -} - -function parseSrcset(string: string): ImageCandidate[] { - const matches = string - .trim() - .replace(escapedSpaceCharactersRE, ' ') - .replace(/\r?\n/, '') - .replace(/,\s+/, ', ') - .replaceAll(/\s+/g, ' ') - .matchAll(imageCandidateRE) - return Array.from(matches, ({ groups }) => ({ - url: groups?.url?.trim() ?? '', - descriptor: groups?.descriptor?.trim() ?? '', - })).filter(({ url }) => !!url) -} - -function joinSrcset(ret: ImageCandidate[]) { - return ret.map(({ url, descriptor }) => url + (descriptor ? ` ${descriptor}` : '')).join(', ') -} - -async function asyncReplace( - input: string, - re: RegExp, - replacer: (match: RegExpExecArray) => string | Promise, -): Promise { - let match: RegExpExecArray | null - let remaining = input - let rewritten = '' - while ((match = re.exec(remaining))) { - rewritten += remaining.slice(0, match.index) - rewritten += await replacer(match) - remaining = remaining.slice(match.index + match[0].length) - } - rewritten += remaining - return rewritten -} diff --git a/packages/@tailwindcss-node/tsconfig.json b/packages/@tailwindcss-node/tsconfig.json deleted file mode 100644 index d2131cbeb..000000000 --- a/packages/@tailwindcss-node/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "../tsconfig.base.json", - "compilerOptions": { - "verbatimModuleSyntax": false, - }, -} diff --git a/packages/@tailwindcss-node/tsup.config.ts b/packages/@tailwindcss-node/tsup.config.ts deleted file mode 100644 index 3f2d184d5..000000000 --- a/packages/@tailwindcss-node/tsup.config.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig([ - { - format: ['cjs'], - minify: true, - dts: true, - entry: ['src/index.cts'], - define: { - 'process.env.NODE_ENV': '"production"', - }, - }, - { - format: ['esm'], - minify: true, - dts: true, - entry: ['src/index.ts'], - define: { - 'process.env.NODE_ENV': '"production"', - }, - }, - { - format: ['esm'], - minify: true, - dts: true, - entry: ['src/esm-cache.loader.mts'], - define: { - 'process.env.NODE_ENV': '"production"', - }, - }, - { - format: ['cjs'], - minify: true, - dts: true, - entry: ['src/require-cache.cts'], - define: { - 'process.env.NODE_ENV': '"production"', - }, - }, -]) diff --git a/packages/@tailwindcss-postcss/README.md b/packages/@tailwindcss-postcss/README.md index 3facf2887..95ec9d87d 100644 --- a/packages/@tailwindcss-postcss/README.md +++ b/packages/@tailwindcss-postcss/README.md @@ -13,10 +13,10 @@

                          - Build Status + Build Status Total Downloads - Latest Release - License + Latest Release + License

                          --- @@ -27,85 +27,14 @@ For full documentation, visit [tailwindcss.com](https://tailwindcss.com). ## Community -For help, discussion about best practices, or feature ideas: +For help, discussion about best practices, or any other conversation that would benefit from being searchable: -[Discuss Tailwind CSS on GitHub](https://github.com/tailwindlabs/tailwindcss/discussions) +[Discuss Tailwind CSS on GitHub](https://github.com/tailwindcss/tailwindcss/discussions) + +For chatting with others using the framework: + +[Join the Tailwind CSS Discord Server](https://discord.gg/7NF8GNe) ## Contributing -If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md) **before submitting a pull request**. - ---- - -## `@tailwindcss/postcss` plugin API - -### Changing where the plugin searches for source files - -You can use the `base` option (defaults to the current working directory) to change the directory in which the plugin searches for source files: - -```js -import tailwindcss from '@tailwindcss/postcss' - -export default { - plugins: [ - tailwindcss({ - base: path.resolve(__dirname, './path'), - }), - ], -} -``` - -### Enabling or disabling Lightning CSS - -By default, this plugin detects whether or not the CSS is being built for production by checking the `NODE_ENV` environment variable. When building for production Lightning CSS will be enabled otherwise it is disabled. - -If you want to always enable or disable Lightning CSS the `optimize` option may be used: - -```js -import tailwindcss from '@tailwindcss/postcss' - -export default { - plugins: [ - tailwindcss({ - // Enable or disable Lightning CSS - optimize: false, - }), - ], -} -``` - -It's also possible to keep Lightning CSS enabled but disable minification: - -```js -import tailwindcss from '@tailwindcss/postcss' - -export default { - plugins: [ - tailwindcss({ - optimize: { minify: false }, - }), - ], -} -``` - -### Enabling or disabling `url(…)` rewriting - -Our PostCSS plugin can rewrite `url(…)`s for you since it also handles `@import` (no `postcss-import` is needed). This feature is enabled by default. - -In some situations the bundler or framework you're using may provide this feature itself. In this case you can set `transformAssetUrls` to `false` to disable this feature: - -```js -import tailwindcss from '@tailwindcss/postcss' - -export default { - plugins: [ - tailwindcss({ - // Disable `url(…)` rewriting - transformAssetUrls: false, - - // Enable `url(…)` rewriting (the default) - transformAssetUrls: true, - }), - ], -} -``` +If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindcss/tailwindcss/blob/next/.github/CONTRIBUTING.md) **before submitting a pull request**. diff --git a/packages/@tailwindcss-postcss/package.json b/packages/@tailwindcss-postcss/package.json index 891578509..265f83767 100644 --- a/packages/@tailwindcss-postcss/package.json +++ b/packages/@tailwindcss-postcss/package.json @@ -1,18 +1,18 @@ { "name": "@tailwindcss/postcss", - "version": "4.3.3", + "version": "0.0.0-internal.b2586d4e", "description": "PostCSS plugin for Tailwind CSS, a utility-first CSS framework for rapidly building custom user interfaces", "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/tailwindlabs/tailwindcss.git", + "url": "git+https://github.com/tailwindlabs/tailwindcss.git", "directory": "packages/@tailwindcss-postcss" }, "bugs": "https://github.com/tailwindlabs/tailwindcss/issues", "homepage": "https://tailwindcss.com", "scripts": { "lint": "tsc --noEmit", - "build": "tsup-node", + "build": "tsup-node ./src/index.ts --format cjs --dts --cjsInterop --splitting --minify --clean", "dev": "pnpm run build -- --watch" }, "files": [ @@ -24,28 +24,18 @@ }, "exports": { ".": { - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "require": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } + "types": "./dist/index.d.ts", + "require": "./dist/index.js" } }, "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "workspace:*", - "@tailwindcss/oxide": "workspace:*", - "postcss": "catalog:", - "tailwindcss": "workspace:*" + "@tailwindcss/oxide": "workspace:^", + "postcss-import": "^16.0.0", + "tailwindcss": "workspace:^" }, "devDependencies": { - "@types/node": "catalog:", - "@types/postcss-import": "14.0.3", - "dedent": "catalog:", - "internal-example-plugin": "workspace:*", - "postcss-import": "^16.1.1" + "@types/node": "^20.11.17", + "@types/postcss-import": "^14.0.3", + "postcss": "8.4.24" } } diff --git a/packages/@tailwindcss-postcss/src/__snapshots__/index.test.ts.snap b/packages/@tailwindcss-postcss/src/__snapshots__/index.test.ts.snap index 05087fa39..7bb8174f7 100644 --- a/packages/@tailwindcss-postcss/src/__snapshots__/index.test.ts.snap +++ b/packages/@tailwindcss-postcss/src/__snapshots__/index.test.ts.snap @@ -1,32 +1,408 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`\`@import 'tailwindcss'\` is replaced with the generated CSS 1`] = ` -" -@layer properties { - @supports (((-webkit-hyphens: none)) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))) { - *, :before, :after, ::backdrop { - --tw-font-weight: initial; - } - } -} - -@layer theme { - :root, :host { - --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, - sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", - monospace; +"@layer theme { + :root { + --default-transition-duration: .15s; + --default-transition-timing-function: var(--transition-timing-function-in-out); + --default-font-family: var(--font-family-sans); + --default-font-feature-settings: var(--font-family-sans--font-feature-settings); + --default-font-variation-settings: var(--font-family-sans--font-variation-settings); + --default-mono-font-family: var(--font-family-mono); + --default-mono-font-feature-settings: var(--font-family-mono--font-feature-settings); + --default-mono-font-variation-settings: var(--font-family-mono--font-variation-settings); + --breakpoint-sm: 640px; + --breakpoint-md: 768px; + --breakpoint-lg: 1024px; + --breakpoint-xl: 1280px; + --breakpoint-2xl: 1536px; --color-black: #000; - --text-2xl: 1.5rem; - --text-2xl--line-height: calc(2 / 1.5); - --font-weight-bold: 700; - --default-font-family: var(--font-sans); - --default-mono-font-family: var(--font-mono); + --color-white: #fff; + --color-slate-50: #f8fafc; + --color-slate-100: #f1f5f9; + --color-slate-200: #e2e8f0; + --color-slate-300: #cbd5e1; + --color-slate-400: #94a3b8; + --color-slate-500: #64748b; + --color-slate-600: #475569; + --color-slate-700: #334155; + --color-slate-800: #1e293b; + --color-slate-900: #0f172a; + --color-slate-950: #020617; + --color-gray-50: #f9fafb; + --color-gray-100: #f3f4f6; + --color-gray-200: #e5e7eb; + --color-gray-300: #d1d5db; + --color-gray-400: #9ca3af; + --color-gray-500: #6b7280; + --color-gray-600: #4b5563; + --color-gray-700: #374151; + --color-gray-800: #1f2937; + --color-gray-900: #111827; + --color-gray-950: #030712; + --color-zinc-50: #fafafa; + --color-zinc-100: #f4f4f5; + --color-zinc-200: #e4e4e7; + --color-zinc-300: #d4d4d8; + --color-zinc-400: #a1a1aa; + --color-zinc-500: #71717a; + --color-zinc-600: #52525b; + --color-zinc-700: #3f3f46; + --color-zinc-800: #27272a; + --color-zinc-900: #18181b; + --color-zinc-950: #09090b; + --color-neutral-50: #fafafa; + --color-neutral-100: #f5f5f5; + --color-neutral-200: #e5e5e5; + --color-neutral-300: #d4d4d4; + --color-neutral-400: #a3a3a3; + --color-neutral-500: #737373; + --color-neutral-600: #525252; + --color-neutral-700: #404040; + --color-neutral-800: #262626; + --color-neutral-900: #171717; + --color-neutral-950: #0a0a0a; + --color-stone-50: #fafaf9; + --color-stone-100: #f5f5f4; + --color-stone-200: #e7e5e4; + --color-stone-300: #d6d3d1; + --color-stone-400: #a8a29e; + --color-stone-500: #78716c; + --color-stone-600: #57534e; + --color-stone-700: #44403c; + --color-stone-800: #292524; + --color-stone-900: #1c1917; + --color-stone-950: #0c0a09; + --color-red-50: #fef2f2; + --color-red-100: #fee2e2; + --color-red-200: #fecaca; + --color-red-300: #fca5a5; + --color-red-400: #f87171; + --color-red-500: #ef4444; + --color-red-600: #dc2626; + --color-red-700: #b91c1c; + --color-red-800: #991b1b; + --color-red-900: #7f1d1d; + --color-red-950: #450a0a; + --color-orange-50: #fff7ed; + --color-orange-100: #ffedd5; + --color-orange-200: #fed7aa; + --color-orange-300: #fdba74; + --color-orange-400: #fb923c; + --color-orange-500: #f97316; + --color-orange-600: #ea580c; + --color-orange-700: #c2410c; + --color-orange-800: #9a3412; + --color-orange-900: #7c2d12; + --color-orange-950: #431407; + --color-amber-50: #fffbeb; + --color-amber-100: #fef3c7; + --color-amber-200: #fde68a; + --color-amber-300: #fcd34d; + --color-amber-400: #fbbf24; + --color-amber-500: #f59e0b; + --color-amber-600: #d97706; + --color-amber-700: #b45309; + --color-amber-800: #92400e; + --color-amber-900: #78350f; + --color-amber-950: #451a03; + --color-yellow-50: #fefce8; + --color-yellow-100: #fef9c3; + --color-yellow-200: #fef08a; + --color-yellow-300: #fde047; + --color-yellow-400: #facc15; + --color-yellow-500: #eab308; + --color-yellow-600: #ca8a04; + --color-yellow-700: #a16207; + --color-yellow-800: #854d0e; + --color-yellow-900: #713f12; + --color-yellow-950: #422006; + --color-lime-50: #f7fee7; + --color-lime-100: #ecfccb; + --color-lime-200: #d9f99d; + --color-lime-300: #bef264; + --color-lime-400: #a3e635; + --color-lime-500: #84cc16; + --color-lime-600: #65a30d; + --color-lime-700: #4d7c0f; + --color-lime-800: #3f6212; + --color-lime-900: #365314; + --color-lime-950: #1a2e05; + --color-green-50: #f0fdf4; + --color-green-100: #dcfce7; + --color-green-200: #bbf7d0; + --color-green-300: #86efac; + --color-green-400: #4ade80; + --color-green-500: #22c55e; + --color-green-600: #16a34a; + --color-green-700: #15803d; + --color-green-800: #166534; + --color-green-900: #14532d; + --color-green-950: #052e16; + --color-emerald-50: #ecfdf5; + --color-emerald-100: #d1fae5; + --color-emerald-200: #a7f3d0; + --color-emerald-300: #6ee7b7; + --color-emerald-400: #34d399; + --color-emerald-500: #10b981; + --color-emerald-600: #059669; + --color-emerald-700: #047857; + --color-emerald-800: #065f46; + --color-emerald-900: #064e3b; + --color-emerald-950: #022c22; + --color-teal-50: #f0fdfa; + --color-teal-100: #ccfbf1; + --color-teal-200: #99f6e4; + --color-teal-300: #5eead4; + --color-teal-400: #2dd4bf; + --color-teal-500: #14b8a6; + --color-teal-600: #0d9488; + --color-teal-700: #0f766e; + --color-teal-800: #115e59; + --color-teal-900: #134e4a; + --color-teal-950: #042f2e; + --color-cyan-50: #ecfeff; + --color-cyan-100: #cffafe; + --color-cyan-200: #a5f3fc; + --color-cyan-300: #67e8f9; + --color-cyan-400: #22d3ee; + --color-cyan-500: #06b6d4; + --color-cyan-600: #0891b2; + --color-cyan-700: #0e7490; + --color-cyan-800: #155e75; + --color-cyan-900: #164e63; + --color-cyan-950: #083344; + --color-sky-50: #f0f9ff; + --color-sky-100: #e0f2fe; + --color-sky-200: #bae6fd; + --color-sky-300: #7dd3fc; + --color-sky-400: #38bdf8; + --color-sky-500: #0ea5e9; + --color-sky-600: #0284c7; + --color-sky-700: #0369a1; + --color-sky-800: #075985; + --color-sky-900: #0c4a6e; + --color-sky-950: #082f49; + --color-blue-50: #eff6ff; + --color-blue-100: #dbeafe; + --color-blue-200: #bfdbfe; + --color-blue-300: #93c5fd; + --color-blue-400: #60a5fa; + --color-blue-500: #3b82f6; + --color-blue-600: #2563eb; + --color-blue-700: #1d4ed8; + --color-blue-800: #1e40af; + --color-blue-900: #1e3a8a; + --color-blue-950: #172554; + --color-indigo-50: #eef2ff; + --color-indigo-100: #e0e7ff; + --color-indigo-200: #c7d2fe; + --color-indigo-300: #a5b4fc; + --color-indigo-400: #818cf8; + --color-indigo-500: #6366f1; + --color-indigo-600: #4f46e5; + --color-indigo-700: #4338ca; + --color-indigo-800: #3730a3; + --color-indigo-900: #312e81; + --color-indigo-950: #1e1b4b; + --color-violet-50: #f5f3ff; + --color-violet-100: #ede9fe; + --color-violet-200: #ddd6fe; + --color-violet-300: #c4b5fd; + --color-violet-400: #a78bfa; + --color-violet-500: #8b5cf6; + --color-violet-600: #7c3aed; + --color-violet-700: #6d28d9; + --color-violet-800: #5b21b6; + --color-violet-900: #4c1d95; + --color-violet-950: #2e1065; + --color-purple-50: #faf5ff; + --color-purple-100: #f3e8ff; + --color-purple-200: #e9d5ff; + --color-purple-300: #d8b4fe; + --color-purple-400: #c084fc; + --color-purple-500: #a855f7; + --color-purple-600: #9333ea; + --color-purple-700: #7e22ce; + --color-purple-800: #6b21a8; + --color-purple-900: #581c87; + --color-purple-950: #3b0764; + --color-fuchsia-50: #fdf4ff; + --color-fuchsia-100: #fae8ff; + --color-fuchsia-200: #f5d0fe; + --color-fuchsia-300: #f0abfc; + --color-fuchsia-400: #e879f9; + --color-fuchsia-500: #d946ef; + --color-fuchsia-600: #c026d3; + --color-fuchsia-700: #a21caf; + --color-fuchsia-800: #86198f; + --color-fuchsia-900: #701a75; + --color-fuchsia-950: #4a044e; + --color-pink-50: #fdf2f8; + --color-pink-100: #fce7f3; + --color-pink-200: #fbcfe8; + --color-pink-300: #f9a8d4; + --color-pink-400: #f472b6; + --color-pink-500: #ec4899; + --color-pink-600: #db2777; + --color-pink-700: #be185d; + --color-pink-800: #9d174d; + --color-pink-900: #831843; + --color-pink-950: #500724; + --color-rose-50: #fff1f2; + --color-rose-100: #ffe4e6; + --color-rose-200: #fecdd3; + --color-rose-300: #fda4af; + --color-rose-400: #fb7185; + --color-rose-500: #f43f5e; + --color-rose-600: #e11d48; + --color-rose-700: #be123c; + --color-rose-800: #9f1239; + --color-rose-900: #881337; + --color-rose-950: #4c0519; + --animate-spin: spin 1s linear infinite; + --animate-ping: ping 1s cubic-bezier(0, 0, .2, 1) infinite; + --animate-pulse: pulse 2s cubic-bezier(.4, 0, .6, 1) infinite; + --animate-bounce: bounce 1s infinite; + --blur: 8px; + --blur-sm: 4px; + --blur-md: 12px; + --blur-lg: 16px; + --blur-xl: 24px; + --blur-2xl: 40px; + --blur-3xl: 64px; + --radius: .25rem; + --radius-sm: .125rem; + --radius-md: .375rem; + --radius-lg: .5rem; + --radius-xl: .75rem; + --radius-2xl: 1rem; + --radius-3xl: 1.5rem; + --shadow: 0 1px 3px 0 #0000001a, 0 1px 2px -1px #0000001a; + --shadow-xs: 0 1px #0000000d; + --shadow-sm: 0 1px 2px 0 #0000000d; + --shadow-md: 0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a; + --shadow-lg: 0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a; + --shadow-xl: 0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a; + --shadow-2xl: 0 25px 50px -12px #00000040; + --shadow-inner: inset 0 2px 4px 0 #0000000d; + --inset-shadow-xs: inset 0 1px #0000000d; + --inset-shadow-sm: inset 0 1px 1px #0000000d; + --inset-shadow: inset 0 2px 4px #0000000d; + --drop-shadow: 0 1px 2px #0000001a, 0 1px 1px #0000000f; + --drop-shadow-sm: 0 1px 1px #0000000d; + --drop-shadow-md: 0 4px 3px #00000012, 0 2px 2px #0000000f; + --drop-shadow-lg: 0 10px 8px #0000000a, 0 4px 3px #0000001a; + --drop-shadow-xl: 0 20px 13px #00000008, 0 8px 5px #00000014; + --drop-shadow-2xl: 0 25px 25px #00000026; + --spacing-px: 1px; + --spacing-0: 0px; + --spacing-0_5: .125rem; + --spacing-1: .25rem; + --spacing-1_5: .375rem; + --spacing-2: .5rem; + --spacing-2_5: .625rem; + --spacing-3: .75rem; + --spacing-3_5: .875rem; + --spacing-4: 1rem; + --spacing-5: 1.25rem; + --spacing-6: 1.5rem; + --spacing-7: 1.75rem; + --spacing-8: 2rem; + --spacing-9: 2.25rem; + --spacing-10: 2.5rem; + --spacing-11: 2.75rem; + --spacing-12: 3rem; + --spacing-14: 3.5rem; + --spacing-16: 4rem; + --spacing-20: 5rem; + --spacing-24: 6rem; + --spacing-28: 7rem; + --spacing-32: 8rem; + --spacing-36: 9rem; + --spacing-40: 10rem; + --spacing-44: 11rem; + --spacing-48: 12rem; + --spacing-52: 13rem; + --spacing-56: 14rem; + --spacing-60: 15rem; + --spacing-64: 16rem; + --spacing-72: 18rem; + --spacing-80: 20rem; + --spacing-96: 24rem; + --width-3xs: 16rem; + --width-2xs: 18rem; + --width-xs: 20rem; + --width-sm: 24rem; + --width-md: 28rem; + --width-lg: 32rem; + --width-xl: 36rem; + --width-2xl: 42rem; + --width-3xl: 48rem; + --width-4xl: 56rem; + --width-5xl: 64rem; + --width-6xl: 72rem; + --width-7xl: 80rem; + --width-prose: 65ch; + --font-family-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --font-family-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; + --font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --font-size-xs: .75rem; + --font-size-xs--line-height: 1rem; + --font-size-sm: .875rem; + --font-size-sm--line-height: 1.25rem; + --font-size-base: 1rem; + --font-size-base--line-height: 1.5rem; + --font-size-lg: 1.125rem; + --font-size-lg--line-height: 1.75rem; + --font-size-xl: 1.25rem; + --font-size-xl--line-height: 1.75rem; + --font-size-2xl: 1.5rem; + --font-size-2xl--line-height: 2rem; + --font-size-3xl: 1.875rem; + --font-size-3xl--line-height: 2.25rem; + --font-size-4xl: 2.25rem; + --font-size-4xl--line-height: 2.5rem; + --font-size-5xl: 3rem; + --font-size-5xl--line-height: 1; + --font-size-6xl: 3.75rem; + --font-size-6xl--line-height: 1; + --font-size-7xl: 4.5rem; + --font-size-7xl--line-height: 1; + --font-size-8xl: 6rem; + --font-size-8xl--line-height: 1; + --font-size-9xl: 8rem; + --font-size-9xl--line-height: 1; + --letter-spacing-tighter: -.05em; + --letter-spacing-tight: -.025em; + --letter-spacing-normal: 0em; + --letter-spacing-wide: .025em; + --letter-spacing-wider: .05em; + --letter-spacing-widest: .1em; + --line-height-none: 1; + --line-height-tight: 1.25; + --line-height-snug: 1.375; + --line-height-normal: 1.5; + --line-height-relaxed: 1.625; + --line-height-loose: 2; + --line-height-3: .75rem; + --line-height-4: 1rem; + --line-height-5: 1.25rem; + --line-height-6: 1.5rem; + --line-height-7: 1.75rem; + --line-height-8: 2rem; + --line-height-9: 2.25rem; + --line-height-10: 2.5rem; + --transition-timing-function: cubic-bezier(.4, 0, .2, 1); + --transition-timing-function-linear: linear; + --transition-timing-function-in: cubic-bezier(.4, 0, 1, 1); + --transition-timing-function-out: cubic-bezier(0, 0, .2, 1); + --transition-timing-function-in-out: cubic-bezier(.4, 0, .2, 1); } } @layer base { - *, :after, :before, ::backdrop { + *, :after, :before, ::backdrop, :first-letter { box-sizing: border-box; border: 0 solid; margin: 0; @@ -44,16 +420,20 @@ exports[`\`@import 'tailwindcss'\` is replaced with the generated CSS 1`] = ` -webkit-text-size-adjust: 100%; tab-size: 4; line-height: 1.5; - font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); + font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); font-feature-settings: var(--default-font-feature-settings, normal); font-variation-settings: var(--default-font-variation-settings, normal); -webkit-tap-highlight-color: transparent; } + body { + line-height: inherit; + } + hr { - height: 0; color: inherit; border-top-width: 1px; + height: 0; } abbr:where([title]) { @@ -70,7 +450,6 @@ exports[`\`@import 'tailwindcss'\` is replaced with the generated CSS 1`] = ` color: inherit; -webkit-text-decoration: inherit; -webkit-text-decoration: inherit; - -webkit-text-decoration: inherit; text-decoration: inherit; } @@ -110,14 +489,58 @@ exports[`\`@import 'tailwindcss'\` is replaced with the generated CSS 1`] = ` border-collapse: collapse; } - :-moz-focusring:where(:not(iframe)) { + button, input, optgroup, select, textarea { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + color: inherit; + background: none; + } + + ::file-selector-button { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + color: inherit; + background: none; + } + + input:where(:not([type="button"], [type="reset"], [type="submit"])), select, textarea { + border: 1px solid; + } + + button, input:where([type="button"], [type="reset"], [type="submit"]) { + appearance: button; + } + + ::file-selector-button { + appearance: button; + } + + :-moz-focusring { outline: auto; } + :-moz-ui-invalid { + box-shadow: none; + } + progress { vertical-align: baseline; } + ::-webkit-inner-spin-button { + height: auto; + } + + ::-webkit-outer-spin-button { + height: auto; + } + + ::-webkit-search-decoration { + -webkit-appearance: none; + } + summary { display: list-item; } @@ -126,6 +549,19 @@ exports[`\`@import 'tailwindcss'\` is replaced with the generated CSS 1`] = ` list-style: none; } + textarea { + resize: vertical; + } + + ::placeholder { + opacity: 1; + color: color-mix(in srgb, currentColor 50%, transparent); + } + + :disabled { + cursor: default; + } + img, svg, video, canvas, audio, iframe, embed, object { vertical-align: middle; display: block; @@ -136,138 +572,7 @@ exports[`\`@import 'tailwindcss'\` is replaced with the generated CSS 1`] = ` height: auto; } - button, input, select, optgroup, textarea { - font: inherit; - font-feature-settings: inherit; - font-variation-settings: inherit; - letter-spacing: inherit; - color: inherit; - opacity: 1; - background-color: #0000; - border-radius: 0; - } - - ::file-selector-button { - font: inherit; - font-feature-settings: inherit; - font-variation-settings: inherit; - letter-spacing: inherit; - color: inherit; - opacity: 1; - background-color: #0000; - border-radius: 0; - } - - :where(select:is([multiple], [size])) optgroup { - font-weight: bolder; - } - - :where(select:is([multiple], [size])) optgroup option { - padding-inline-start: 20px; - } - - ::file-selector-button { - margin-inline-end: 4px; - } - - ::placeholder { - opacity: 1; - } - - @supports (not ((-webkit-appearance: -apple-pay-button))) or (contain-intrinsic-size: 1px) { - ::placeholder { - color: currentColor; - } - - @supports (color: color-mix(in lab, red, red)) { - ::placeholder { - color: color-mix(in oklab, currentcolor 50%, transparent); - } - } - } - - textarea { - resize: vertical; - } - - ::-webkit-search-decoration { - -webkit-appearance: none; - } - - ::-webkit-date-and-time-value { - min-height: 1lh; - text-align: inherit; - } - - ::-webkit-datetime-edit { - display: inline-flex; - } - - ::-webkit-datetime-edit-fields-wrapper { - padding: 0; - } - - ::-webkit-datetime-edit { - padding-block: 0; - } - - ::-webkit-datetime-edit-year-field { - padding-block: 0; - } - - ::-webkit-datetime-edit-month-field { - padding-block: 0; - } - - ::-webkit-datetime-edit-day-field { - padding-block: 0; - } - - ::-webkit-datetime-edit-hour-field { - padding-block: 0; - } - - ::-webkit-datetime-edit-minute-field { - padding-block: 0; - } - - ::-webkit-datetime-edit-second-field { - padding-block: 0; - } - - ::-webkit-datetime-edit-millisecond-field { - padding-block: 0; - } - - ::-webkit-datetime-edit-meridiem-field { - padding-block: 0; - } - - ::-webkit-calendar-picker-indicator { - line-height: 1; - } - - :-moz-ui-invalid { - box-shadow: none; - } - - button, input:where([type="button"], [type="reset"], [type="submit"]) { - appearance: button; - } - - ::file-selector-button { - appearance: button; - } - - ::-webkit-inner-spin-button { - height: auto; - } - - ::-webkit-outer-spin-button { - height: auto; - } - - [hidden]:where(:not([hidden="until-found"])) { + [hidden] { display: none !important; } } @@ -276,35 +581,53 @@ exports[`\`@import 'tailwindcss'\` is replaced with the generated CSS 1`] = ` @layer utilities { .text-2xl { - font-size: var(--text-2xl); - line-height: var(--tw-leading, var(--text-2xl--line-height)); + font-size: var(--font-size-2xl, 1.5rem); + line-height: var(--font-size-2xl--line-height, 2rem); } .text-black\\/50 { - color: #00000080; - } - - @supports (color: color-mix(in lab, red, red)) { - .text-black\\/50 { - color: color-mix(in oklab, var(--color-black) 50%, transparent); - } + color: color-mix(in srgb, var(--color-black, #000) 50%, transparent); } .underline { text-decoration-line: underline; } - @media (min-width: 96rem) { + @media (width >= 1536px) { .\\32 xl\\:font-bold { - --tw-font-weight: var(--font-weight-bold); - font-weight: var(--font-weight-bold); + font-weight: 700; } } } -@property --tw-font-weight { - syntax: "*"; - inherits: false +@keyframes spin { + to { + transform: rotate(360deg); + } } -" + +@keyframes ping { + 75%, 100% { + opacity: 0; + transform: scale(2); + } +} + +@keyframes pulse { + 50% { + opacity: .5; + } +} + +@keyframes bounce { + 0%, 100% { + animation-timing-function: cubic-bezier(.8, 0, 1, 1); + transform: translateY(-25%); + } + + 50% { + animation-timing-function: cubic-bezier(0, 0, .2, 1); + transform: none; + } +}" `; diff --git a/packages/@tailwindcss-postcss/src/ast.test.ts b/packages/@tailwindcss-postcss/src/ast.test.ts deleted file mode 100644 index 8a2a5b019..000000000 --- a/packages/@tailwindcss-postcss/src/ast.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import dedent from 'dedent' -import postcss from 'postcss' -import { expect, it } from 'vitest' -import { toCss } from '../../tailwindcss/src/ast' -import { parse } from '../../tailwindcss/src/css-parser' -import { cssAstToPostCssAst, postCssAstToCssAst } from './ast' - -let css = dedent - -it('should convert a PostCSS AST into a Tailwind CSS AST', () => { - let input = css` - @charset "UTF-8"; - - @layer foo, bar, baz; - - @import 'tailwindcss'; - - .foo { - color: red; - - &:hover { - color: blue; - } - - .bar { - color: green !important; - background-color: yellow; - - @media (min-width: 640px) { - color: orange; - } - } - } - ` - - let ast = postcss.parse(input) - let transformedAst = postCssAstToCssAst(ast) - - expect(toCss(transformedAst)).toMatchInlineSnapshot(` - "@charset "UTF-8"; - @layer foo, bar, baz; - @import 'tailwindcss'; - .foo { - color: red; - &:hover { - color: blue; - } - .bar { - color: green !important; - background-color: yellow; - @media (min-width: 640px) { - color: orange; - } - } - } - " - `) -}) - -it('should convert a Tailwind CSS AST into a PostCSS AST', () => { - let input = css` - @charset "UTF-8"; - - @layer foo, bar, baz; - - @import 'tailwindcss'; - - .foo { - color: red; - - &:hover { - color: blue; - } - - .bar { - color: green !important; - background-color: yellow; - - @media (min-width: 640px) { - color: orange; - } - } - } - ` - - let ast = parse(input) - let transformedAst = cssAstToPostCssAst(postcss, ast) - - expect(transformedAst.toString()).toMatchInlineSnapshot(` - "@charset "UTF-8"; - @layer foo, bar, baz; - @import 'tailwindcss'; - .foo { - color: red; - &:hover { - color: blue; - } - .bar { - color: green !important; - background-color: yellow; - @media (min-width: 640px) { - color: orange; - } - } - }" - `) -}) diff --git a/packages/@tailwindcss-postcss/src/ast.ts b/packages/@tailwindcss-postcss/src/ast.ts deleted file mode 100644 index 7ca9e2add..000000000 --- a/packages/@tailwindcss-postcss/src/ast.ts +++ /dev/null @@ -1,189 +0,0 @@ -import type * as postcss from 'postcss' -import { atRule, comment, decl, rule, type AstNode } from '../../tailwindcss/src/ast' -import { createLineTable, type LineTable } from '../../tailwindcss/src/source-maps/line-table' -import type { Source, SourceLocation } from '../../tailwindcss/src/source-maps/source' -import { DefaultMap } from '../../tailwindcss/src/utils/default-map' - -const EXCLAMATION_MARK = 0x21 - -export function cssAstToPostCssAst( - postcss: postcss.Postcss, - ast: AstNode[], - source?: postcss.Source, -): postcss.Root { - let inputMap = new DefaultMap((src) => { - return new postcss.Input(src.code, { - map: source?.input.map, - from: src.file ?? undefined, - }) - }) - - let lineTables = new DefaultMap((src) => createLineTable(src.code)) - - let root = postcss.root() - root.source = source - - function toSource(loc: SourceLocation | undefined): postcss.Source | undefined { - // Use the fallback if this node has no location info in the AST - if (!loc) return - if (!loc[0]) return - - let table = lineTables.get(loc[0]) - let start = table.find(loc[1]) - let end = table.find(loc[2]) - - return { - input: inputMap.get(loc[0]), - start: { - line: start.line, - column: start.column + 1, - offset: loc[1], - }, - end: { - line: end.line, - column: end.column + 1, - offset: loc[2], - }, - } - } - - function updateSource(astNode: postcss.ChildNode, loc: SourceLocation | undefined) { - let source = toSource(loc) - - // The `source` property on PostCSS nodes must be defined if present because - // `toJSON()` reads each property and tries to read from source.input if it - // sees a `source` property. This means for a missing or otherwise absent - // source it must be *missing* from the object rather than just `undefined` - if (source) { - astNode.source = source - } else { - delete astNode.source - } - } - - function transform(node: AstNode, parent: postcss.Container) { - // Declaration - if (node.kind === 'declaration') { - let astNode = postcss.decl({ - prop: node.property, - value: node.value ?? '', - important: node.important, - }) - updateSource(astNode, node.src) - parent.append(astNode) - } - - // Rule - else if (node.kind === 'rule') { - let astNode = postcss.rule({ selector: node.selector }) - updateSource(astNode, node.src) - astNode.raws.semicolon = true - parent.append(astNode) - for (let child of node.nodes) { - transform(child, astNode) - } - } - - // AtRule - else if (node.kind === 'at-rule') { - let astNode = postcss.atRule({ name: node.name.slice(1), params: node.params }) - updateSource(astNode, node.src) - astNode.raws.semicolon = true - parent.append(astNode) - for (let child of node.nodes) { - transform(child, astNode) - } - } - - // Comment - else if (node.kind === 'comment') { - let astNode = postcss.comment({ text: node.value }) - // Spaces are encoded in our node.value already, no need to add additional - // spaces. - astNode.raws.left = '' - astNode.raws.right = '' - updateSource(astNode, node.src) - parent.append(astNode) - } - - // AtRoot & Context should not happen - else if (node.kind === 'at-root' || node.kind === 'context') { - } - - // Unknown - else { - node satisfies never - } - } - - for (let node of ast) { - transform(node, root) - } - - return root -} - -export function postCssAstToCssAst(root: postcss.Root): AstNode[] { - let inputMap = new DefaultMap((input) => ({ - file: input.file ?? input.id ?? null, - code: input.css, - })) - - function toSource(node: postcss.ChildNode): SourceLocation | undefined { - let source = node.source - if (!source) return - - let input = source.input - if (!input) return - if (source.start === undefined) return - if (source.end === undefined) return - - return [inputMap.get(input), source.start.offset, source.end.offset] - } - - function transform( - node: postcss.ChildNode, - parent: Extract['nodes'], - ) { - // Declaration - if (node.type === 'decl') { - let astNode = decl(node.prop, node.value, node.important) - astNode.src = toSource(node) - parent.push(astNode) - } - - // Rule - else if (node.type === 'rule') { - let astNode = rule(node.selector) - astNode.src = toSource(node) - node.each((child) => transform(child, astNode.nodes)) - parent.push(astNode) - } - - // AtRule - else if (node.type === 'atrule') { - let astNode = atRule(`@${node.name}`, node.params) - astNode.src = toSource(node) - node.each((child) => transform(child, astNode.nodes)) - parent.push(astNode) - } - - // Comment - else if (node.type === 'comment') { - if (node.text.charCodeAt(0) !== EXCLAMATION_MARK) return - let astNode = comment(node.text) - astNode.src = toSource(node) - parent.push(astNode) - } - - // Unknown - else { - node satisfies never - } - } - - let ast: AstNode[] = [] - root.each((node) => transform(node, ast)) - - return ast -} diff --git a/packages/@tailwindcss-postcss/src/fixtures/example-project/index.html b/packages/@tailwindcss-postcss/src/fixtures/example-project/index.html index 9d8328479..30d3c6684 100644 --- a/packages/@tailwindcss-postcss/src/fixtures/example-project/index.html +++ b/packages/@tailwindcss-postcss/src/fixtures/example-project/index.html @@ -1 +1 @@ -
                          +
                          diff --git a/packages/@tailwindcss-postcss/src/fixtures/example-project/input.css b/packages/@tailwindcss-postcss/src/fixtures/example-project/input.css deleted file mode 100644 index f1af498fc..000000000 --- a/packages/@tailwindcss-postcss/src/fixtures/example-project/input.css +++ /dev/null @@ -1 +0,0 @@ -/* the content for this file is set in the tests */ diff --git a/packages/@tailwindcss-postcss/src/fixtures/example-project/plugin.js b/packages/@tailwindcss-postcss/src/fixtures/example-project/plugin.js deleted file mode 100644 index 085212671..000000000 --- a/packages/@tailwindcss-postcss/src/fixtures/example-project/plugin.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = function ({ addVariant }) { - addVariant('inverted', '@media (inverted-colors: inverted)') - addVariant('hocus', ['&:focus', '&:hover']) -} diff --git a/packages/@tailwindcss-postcss/src/fixtures/example-project/src/relative-import.css b/packages/@tailwindcss-postcss/src/fixtures/example-project/src/relative-import.css deleted file mode 100644 index 48a30ab4d..000000000 --- a/packages/@tailwindcss-postcss/src/fixtures/example-project/src/relative-import.css +++ /dev/null @@ -1 +0,0 @@ -@plugin '../plugin.js'; diff --git a/packages/@tailwindcss-postcss/src/index.cts b/packages/@tailwindcss-postcss/src/index.cts deleted file mode 100644 index abc7c2e25..000000000 --- a/packages/@tailwindcss-postcss/src/index.cts +++ /dev/null @@ -1,7 +0,0 @@ -import tailwindcss from './index.ts' - -// This is used instead of `export default` to work around a bug in -// `postcss-load-config` - -// @ts-ignore -export = tailwindcss diff --git a/packages/@tailwindcss-postcss/src/index.test.ts b/packages/@tailwindcss-postcss/src/index.test.ts index b393cc382..8896d255c 100644 --- a/packages/@tailwindcss-postcss/src/index.test.ts +++ b/packages/@tailwindcss-postcss/src/index.test.ts @@ -1,10 +1,6 @@ -import dedent from 'dedent' -import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import path from 'path' +import { unlink, writeFile } from 'node:fs/promises' import postcss from 'postcss' -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { pretty } from '../../tailwindcss/src/test-utils/run' +import { afterEach, beforeEach, describe, expect, test } from 'vitest' import tailwindcss from './index' // We give this file path to PostCSS for processing. @@ -12,32 +8,23 @@ import tailwindcss from './index' // We place it in packages/ because Vitest runs in the monorepo root, // and packages/tailwindcss must be a sub-folder for // @import 'tailwindcss' to work. -function inputCssFilePath() { - // Including the current test name to ensure that the cache is invalidated per - // test otherwise the cache will be used across tests. - return `${__dirname}/fixtures/example-project/input.css?test=${expect.getState().currentTestName}` -} +const INPUT_CSS_PATH = `${__dirname}/fixtures/example-project/input.css` -const css = dedent +const css = String.raw -async function run(plugin: any, from: string, input: string): Promise { - let ast = postcss.parse(input) - for (let runner of plugin.plugins) { - if (runner.Once) { - await runner.Once(ast, { postcss, result: { opts: { from }, messages: [] } }) - } - } - return ast.toString() -} +beforeEach(async () => { + const { clearCache } = await import('@tailwindcss/oxide') + clearCache() +}) test("`@import 'tailwindcss'` is replaced with the generated CSS", async () => { let processor = postcss([ tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }), ]) - let result = await processor.process(`@import 'tailwindcss'`, { from: inputCssFilePath() }) + let result = await processor.process(`@import 'tailwindcss'`, { from: INPUT_CSS_PATH }) - expect(pretty(result.css)).toMatchSnapshot() + expect(result.css.trim()).toMatchSnapshot() // Check for dependency messages expect(result.messages).toContainEqual({ @@ -54,7 +41,7 @@ test("`@import 'tailwindcss'` is replaced with the generated CSS", async () => { }) expect(result.messages).toContainEqual({ type: 'dir-dependency', - dir: expect.stringMatching(/example-project[/|\\]src$/g), + dir: expect.stringMatching(/example-project\/src$/g), glob: expect.stringMatching(/^\*\*\/\*/g), parent: expect.any(String), plugin: expect.any(String), @@ -66,6 +53,8 @@ test('output is optimized by Lightning CSS', async () => { tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }), ]) + // `@apply` is used because Lightning is skipped if neither `@tailwind` nor + // `@apply` is used. let result = await processor.process( css` @layer utilities { @@ -80,12 +69,11 @@ test('output is optimized by Lightning CSS', async () => { } } `, - { from: inputCssFilePath() }, + { from: INPUT_CSS_PATH }, ) - expect(pretty(result.css)).toMatchInlineSnapshot(` - " - @layer utilities { + expect(result.css.trim()).toMatchInlineSnapshot(` + "@layer utilities { .foo { color: #000; } @@ -93,8 +81,7 @@ test('output is optimized by Lightning CSS', async () => { .bar { color: red; } - } - " + }" `) }) @@ -103,44 +90,37 @@ test('@apply can be used without emitting the theme in the CSS file', async () = tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }), ]) + // `@apply` is used because Lightning is skipped if neither `@tailwind` nor + // `@apply` is used. let result = await processor.process( css` - @reference 'tailwindcss/theme.css'; + @import 'tailwindcss/theme.css' reference; .foo { @apply text-red-500; } `, - { from: inputCssFilePath() }, + { from: INPUT_CSS_PATH }, ) - expect(pretty(result.css)).toMatchInlineSnapshot(` - " - .foo { - color: var(--color-red-500, oklch(63.7% .237 25.331)); - } - " + expect(result.css.trim()).toMatchInlineSnapshot(` + ".foo { + color: var(--color-red-500, #ef4444); + }" `) }) describe('processing without specifying a base path', () => { - let filepath: string - let dir: string + let filepath = `${process.cwd()}/my-test-file.html` - beforeEach(async () => { - dir = await mkdtemp(path.join(tmpdir(), 'tw-postcss')) - await mkdir(dir, { recursive: true }) - filepath = path.join(dir, 'my-test-file.html') - await writeFile(filepath, `
                          `) - }) + beforeEach(() => + writeFile(filepath, `
                          `), + ) afterEach(() => unlink(filepath)) test('the current working directory is used by default', async () => { - using spy = vi.spyOn(process, 'cwd') - spy.mockReturnValue(dir) - let processor = postcss([tailwindcss({ optimize: { minify: false } })]) - let result = await processor.process(`@import "tailwindcss"`, { from: inputCssFilePath() }) + let result = await processor.process(`@import "tailwindcss"`, { from: INPUT_CSS_PATH }) expect(result.css).toContain( ".md\\:\\[\\&\\:hover\\]\\:content-\\[\\'testing_default_base_path\\'\\]", @@ -154,365 +134,3 @@ describe('processing without specifying a base path', () => { }) }) }) - -test('fallback to `base` directory when `result.opts.from` is not provided', async () => { - using _ = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - let processor = postcss([ - tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }), - ]) - - let result = await processor.process(`@import 'tailwindcss'`) - - expect(result.css.length).toBeGreaterThan(0) -}) - -describe('plugins', () => { - test('local CJS plugin', async () => { - let processor = postcss([ - tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }), - ]) - - let result = await processor.process( - css` - @import 'tailwindcss/utilities'; - @plugin './plugin.js'; - `, - { from: inputCssFilePath() }, - ) - - expect(pretty(result.css)).toMatchInlineSnapshot(` - " - .underline { - text-decoration-line: underline; - } - - @media (inverted-colors: inverted) { - .inverted\\:flex { - display: flex; - } - } - - .hocus\\:underline:focus, .hocus\\:underline:hover { - text-decoration-line: underline; - } - " - `) - }) - - test('local CJS plugin from `@import`-ed file', async () => { - let processor = postcss([ - tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }), - ]) - - let result = await processor.process( - css` - @import 'tailwindcss/utilities'; - @import '../example-project/src/relative-import.css'; - `, - { from: `${__dirname}/fixtures/another-project/input.css` }, - ) - - expect(pretty(result.css)).toMatchInlineSnapshot(` - " - .underline { - text-decoration-line: underline; - } - - @media (inverted-colors: inverted) { - .inverted\\:flex { - display: flex; - } - } - - .hocus\\:underline:focus, .hocus\\:underline:hover { - text-decoration-line: underline; - } - " - `) - }) - - test('published CJS plugin', async () => { - let processor = postcss([ - tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }), - ]) - - let result = await processor.process( - css` - @import 'tailwindcss/utilities'; - @plugin 'internal-example-plugin'; - `, - { from: inputCssFilePath() }, - ) - - expect(pretty(result.css)).toMatchInlineSnapshot(` - " - .underline { - text-decoration-line: underline; - } - - @media (inverted-colors: inverted) { - .inverted\\:flex { - display: flex; - } - } - - .hocus\\:underline:focus, .hocus\\:underline:hover { - text-decoration-line: underline; - } - " - `) - }) -}) - -test('bail early when Tailwind is not used', async () => { - let processor = postcss([ - tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }), - ]) - - let result = await processor.process( - css` - .custom-css { - color: red; - } - `, - { from: inputCssFilePath() }, - ) - - // `fixtures/example-project` includes an `underline` candidate. But since we - // didn't use `@tailwind utilities` we didn't scan for utilities. - expect(result.css).not.toContain('.underline {') - - expect(pretty(result.css)).toMatchInlineSnapshot(` - " - .custom-css { - color: red; - } - " - `) -}) - -test('handle CSS when only using a `@reference` (we should not bail early)', async () => { - let processor = postcss([ - tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }), - ]) - - let result = await processor.process( - css` - @reference "tailwindcss/theme.css"; - - .foo { - @variant md { - bar: baz; - } - } - `, - { from: inputCssFilePath() }, - ) - - expect(pretty(result.css)).toMatchInlineSnapshot(` - " - @media (min-width: 48rem) { - .foo { - bar: baz; - } - } - " - `) -}) - -test('handle CSS when using a `@variant` using variants that do not rely on the `@theme`', async () => { - let processor = postcss([ - tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }), - ]) - - let result = await processor.process( - css` - .foo { - @variant data-is-hoverable { - bar: baz; - } - } - `, - { from: inputCssFilePath() }, - ) - - expect(pretty(result.css)).toMatchInlineSnapshot(` - " - .foo[data-is-hoverable] { - bar: baz; - } - " - `) -}) - -test('runs `Once` plugins in the right order', async () => { - let before = '' - let after = '' - let processor = postcss([ - { - postcssPlugin: 'before', - Once(root) { - before = root.toString() - }, - }, - tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }), - { - postcssPlugin: 'after', - Once(root) { - after = root.toString() - }, - }, - ]) - - let result = await processor.process( - css` - @theme { - --color-red-500: red; - } - .custom-css { - color: theme(--color-red-500); - } - `, - { from: inputCssFilePath() }, - ) - - expect(pretty(result.css)).toMatchInlineSnapshot(` - " - .custom-css { - color: red; - } - " - `) - expect(pretty(before)).toMatchInlineSnapshot(` - " - @theme { - --color-red-500: red; - } - .custom-css { - color: theme(--color-red-500); - } - " - `) - expect(pretty(after)).toMatchInlineSnapshot(` - " - .custom-css { - color: red; - } - " - `) -}) - -describe('concurrent builds', () => { - let dir: string - beforeEach(async () => { - dir = await mkdtemp(path.join(tmpdir(), 'tw-postcss')) - await writeFile(path.join(dir, 'index.html'), `
                          `) - await writeFile( - path.join(dir, 'index.css'), - css` - @import './dependency.css'; - `, - ) - await writeFile( - path.join(dir, 'dependency.css'), - css` - @tailwind utilities; - `, - ) - }) - afterEach(() => rm(dir, { recursive: true, force: true })) - - test('does experience a race-condition when calling the plugin two times for the same change', async () => { - using spy = vi.spyOn(process, 'cwd') - spy.mockReturnValue(dir) - - let from = path.join(dir, 'index.css') - let input = (await readFile(path.join(dir, 'index.css'))).toString() - - let plugin = tailwindcss({ optimize: { minify: false } }) - - let result = await run(plugin, from, input) - - expect(result).toContain('.underline') - - // Ensure that the mtime is updated - await new Promise((resolve) => setTimeout(resolve, 100)) - await writeFile( - path.join(dir, 'dependency.css'), - css` - @tailwind utilities; - .red { - color: red; - } - `, - ) - - let promise1 = run(plugin, from, input) - let promise2 = run(plugin, from, input) - - expect(await promise1).toContain('.red') - expect(await promise2).toContain('.red') - }) -}) - -test('rebuilds when the input CSS changes even if the `from` file on disk did not', async () => { - let dir = await mkdtemp(path.join(tmpdir(), 'tw-postcss')) - // The `from` file exists on disk with a stable mtime. The input CSS is handed - // to the plugin directly (as a preprocessor like Sass would produce it), so it - // can change without `from`'s mtime ever changing. - await writeFile(path.join(dir, 'index.css'), '') - - let from = path.join(dir, 'index.css') - let plugin = tailwindcss({ base: dir, optimize: { minify: false } }) - - let first = await run( - plugin, - from, - css` - @tailwind utilities; - .first { - @apply underline; - } - `, - ) - let second = await run( - plugin, - from, - css` - @tailwind utilities; - .second { - @apply line-through; - } - `, - ) - - expect(first).toContain('.first') - expect(second).toContain('.second') - - await rm(dir, { recursive: true, force: true }) -}) - -test('does not register the input file as a dependency, even if it is passed in as relative path', async () => { - let processor = postcss([ - tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }), - ]) - - let result = await processor.process(`@tailwind utilities`, { from: './input.css' }) - - expect(pretty(result.css)).toMatchInlineSnapshot(` - " - .underline { - text-decoration-line: underline; - } - " - `) - - // Check for dependency messages - expect(result.messages).not.toContainEqual({ - type: 'dependency', - file: expect.stringMatching(/input.css$/g), - parent: expect.any(String), - plugin: expect.any(String), - }) -}) diff --git a/packages/@tailwindcss-postcss/src/index.ts b/packages/@tailwindcss-postcss/src/index.ts index a4a8d9326..fbaeb0bd8 100644 --- a/packages/@tailwindcss-postcss/src/index.ts +++ b/packages/@tailwindcss-postcss/src/index.ts @@ -1,374 +1,150 @@ -import QuickLRU from '@alloc/quick-lru' -import { - compileAst, - env, - Features, - Instrumentation, - optimize as optimizeCss, - Polyfills, -} from '@tailwindcss/node' -import { clearRequireCache } from '@tailwindcss/node/require-cache' -import { Scanner } from '@tailwindcss/oxide' -import fs from 'node:fs' -import path, { relative } from 'node:path' -import type { AcceptedPlugin, PluginCreator, Postcss, Root } from 'postcss' -import { toCss, type AstNode } from '../../tailwindcss/src/ast' -import { cssAstToPostCssAst, postCssAstToCssAst } from './ast' -import fixRelativePathsPlugin from './postcss-fix-relative-paths' +import { scanDir } from '@tailwindcss/oxide' +import fs from 'fs' +import postcss, { type AcceptedPlugin, type PluginCreator } from 'postcss' +import postcssImport from 'postcss-import' +import { compile, optimizeCss } from 'tailwindcss' -const DEBUG = env.DEBUG - -interface CacheEntry { - mtimes: Map - inputCss: string - compiler: null | ReturnType - scanner: null | Scanner - tailwindCssAst: AstNode[] - cachedPostCssAst: Root - optimizedPostCssAst: Root - fullRebuildPaths: string[] -} -const cache = new QuickLRU({ maxSize: 50 }) - -function getContextFromCache(postcss: Postcss, inputFile: string, opts: PluginOptions): CacheEntry { - let key = `${inputFile}:${opts.base ?? ''}:${JSON.stringify(opts.optimize)}` - if (cache.has(key)) return cache.get(key)! - let entry = { - mtimes: new Map(), - inputCss: '', - compiler: null, - scanner: null, - - tailwindCssAst: [], - cachedPostCssAst: postcss.root(), - optimizedPostCssAst: postcss.root(), - - fullRebuildPaths: [] as string[], +/** + * A Map that can generate default values for keys that don't exist. + * Generated default values are added to the map to avoid recomputation. + */ +class DefaultMap extends Map { + constructor(private factory: (key: T, self: DefaultMap) => V) { + super() + } + + get(key: T): V { + let value = super.get(key) + + if (value === undefined) { + value = this.factory(key, this) + this.set(key, value) + } + + return value } - cache.set(key, entry) - return entry } -export type PluginOptions = { - /** - * The base directory to scan for class candidates. - * - * Defaults to the current working directory. - */ +type PluginOptions = { + // The base directory to scan for class candidates. base?: string - /** - * Optimize and minify the output CSS. - */ + // Optimize and minify the output CSS. optimize?: boolean | { minify?: boolean } - - /** - * Enable or disable asset URL rewriting. - * - * Defaults to `true`. - */ - transformAssetUrls?: boolean } function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin { let base = opts.base ?? process.cwd() let optimize = opts.optimize ?? process.env.NODE_ENV === 'production' - let shouldRewriteUrls = opts.transformAssetUrls ?? true + + let cache = new DefaultMap(() => { + return { + mtimes: new Map(), + build: null as null | ReturnType['build'], + css: '', + optimizedCss: '', + } + }) return { postcssPlugin: '@tailwindcss/postcss', plugins: [ - // We need to handle the case where `postcss-import` might have run before - // the Tailwind CSS plugin is run. In this case, we need to manually fix - // relative paths before processing it in core. - fixRelativePathsPlugin(), + // We need to run `postcss-import` first to handle `@import` rules. + postcssImport(), - { - postcssPlugin: 'tailwindcss', - async Once(root, { result, postcss }) { - using I = new Instrumentation() + (root, result) => { + let inputFile = result.opts.from ?? '' + let context = cache.get(inputFile) - let inputFile = result.opts.from ?? '' - let isCSSModuleFile = inputFile.endsWith('.module.css') - - DEBUG && I.start(`[@tailwindcss/postcss] ${relative(base, inputFile)}`) - - // Bail out early if this is guaranteed to be a non-Tailwind CSS file. - { - DEBUG && I.start('Quick bail check') - let canBail = true - root.walkAtRules((node) => { - if ( - node.name === 'import' || - node.name === 'reference' || - node.name === 'theme' || - node.name === 'variant' || - node.name === 'config' || - node.name === 'plugin' || - node.name === 'apply' || - node.name === 'tailwind' - ) { - canBail = false - return false - } - }) - if (canBail) return - DEBUG && I.end('Quick bail check') - } - - let context = getContextFromCache(postcss, inputFile, opts) - let inputBasePath = inputFile ? path.dirname(path.resolve(inputFile)) : base - - // Whether this is the first build or not, if it is, then we can - // optimize the build by not creating the compiler until we need it. - let isInitialBuild = context.compiler === null - - async function createCompiler() { - DEBUG && I.start('Setup compiler') - if (context.fullRebuildPaths.length > 0 && !isInitialBuild) { - clearRequireCache(context.fullRebuildPaths) - } - - context.fullRebuildPaths = [] - - DEBUG && I.start('PostCSS AST -> Tailwind CSS AST') - let ast = postCssAstToCssAst(root) - DEBUG && I.end('PostCSS AST -> Tailwind CSS AST') - - DEBUG && I.start('Create compiler') - let compiler = await compileAst(ast, { - from: result.opts.from, - base: inputBasePath, - shouldRewriteUrls, - onDependency: (path) => context.fullRebuildPaths.push(path), - // In CSS Module files, we have to disable the `@property` polyfill since these will - // emit global `*` rules which are considered to be non-pure and will cause builds - // to fail. - polyfills: isCSSModuleFile ? Polyfills.All ^ Polyfills.AtProperty : Polyfills.All, - }) - DEBUG && I.end('Create compiler') - - DEBUG && I.end('Setup compiler') - return compiler - } - - try { - // Setup the compiler if it doesn't exist yet. This way we can - // guarantee a `build()` function is available. - context.compiler ??= createCompiler() - - if ((await context.compiler).features === Features.None) { - return - } - - let rebuildStrategy: 'full' | 'incremental' = 'incremental' - - // Track file modification times to CSS files - DEBUG && I.start('Register full rebuild paths') - { - for (let file of context.fullRebuildPaths) { - result.messages.push({ - type: 'dependency', - plugin: '@tailwindcss/postcss', - file: path.resolve(file), - parent: result.opts.from, - }) - } - - let files = result.messages.flatMap((message) => { - if (message.type !== 'dependency') return [] - return message.file - }) - files.push(inputFile) - - for (let file of files) { - let changedTime = fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? null - if (changedTime === null) { - if (file === inputFile) { - rebuildStrategy = 'full' - } - continue - } - - let prevTime = context.mtimes.get(file) - if (prevTime === changedTime) continue + let rebuildStrategy: 'full' | 'incremental' = 'incremental' + // Track file modification times to CSS files + { + let files = result.messages.flatMap((message) => { + if (message.type !== 'dependency') return [] + return message.file + }) + files.push(inputFile) + for (let file of files) { + let changedTime = fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? null + if (changedTime === null) { + if (file === inputFile) { rebuildStrategy = 'full' - context.mtimes.set(file, changedTime) } - - // The mtimes above only track files on disk. When the input CSS is - // generated by another tool (e.g. Sass) and passed to `process()`, - // it can change without any tracked file's mtime changing, so also - // rebuild when the input CSS itself differs from the last build. - let inputCss = root.source?.input.css ?? root.toString() - if (context.inputCss !== inputCss) { - rebuildStrategy = 'full' - context.inputCss = inputCss - } - } - DEBUG && I.end('Register full rebuild paths') - - if ( - rebuildStrategy === 'full' && - // We can reuse the compiler if it was created during the - // initial build. If it wasn't, we need to create a new one. - !isInitialBuild - ) { - context.compiler = createCompiler() + continue } - let compiler = await context.compiler + let prevTime = context.mtimes.get(file) + if (prevTime === changedTime) continue - if (context.scanner === null || rebuildStrategy === 'full') { - DEBUG && I.start('Setup scanner') - let sources = (() => { - // Disable auto source detection - if (compiler.root === 'none') { - return [] - } - - // No root specified, use the base directory - if (compiler.root === null) { - return [{ base, pattern: '**/*', negated: false }] - } - - // Use the specified root - return [{ ...compiler.root, negated: false }] - })().concat(compiler.sources) - - // Look for candidates used to generate the CSS - context.scanner = new Scanner({ sources }) - DEBUG && I.end('Setup scanner') - } - - DEBUG && I.start('Scan for candidates') - let candidates = compiler.features & Features.Utilities ? context.scanner.scan() : [] - DEBUG && I.end('Scan for candidates') - - if (compiler.features & Features.Utilities) { - DEBUG && I.start('Register dependency messages') - // Add all found files as direct dependencies - // Note: With Turbopack, the input file might not be a resolved path - let resolvedInputFile = path.resolve(base, inputFile) - for (let file of context.scanner.files) { - let absolutePath = path.resolve(file) - // The CSS file cannot be a dependency of itself - if (absolutePath === resolvedInputFile) { - continue - } - result.messages.push({ - type: 'dependency', - plugin: '@tailwindcss/postcss', - file: absolutePath, - parent: result.opts.from, - }) - } - - // Register dependencies so changes in `base` cause a rebuild while - // giving tools like Vite or Parcel a glob that can be used to limit - // the files that cause a rebuild to only those that match it. - for (let { base: globBase, pattern } of context.scanner.globs) { - // Avoid adding a dependency on the base directory itself, since it - // causes Next.js to start an endless recursion if the `distDir` is - // configured to anything other than the default `.next` dir. - if (pattern === '*' && base === globBase) { - continue - } - - if (pattern === '') { - result.messages.push({ - type: 'dependency', - plugin: '@tailwindcss/postcss', - file: path.resolve(globBase), - parent: result.opts.from, - }) - } else { - result.messages.push({ - type: 'dir-dependency', - plugin: '@tailwindcss/postcss', - dir: path.resolve(globBase), - glob: pattern, - parent: result.opts.from, - }) - } - } - DEBUG && I.end('Register dependency messages') - } - - DEBUG && I.start('Build utilities') - let tailwindCssAst = compiler.build(candidates) - DEBUG && I.end('Build utilities') - - if (context.tailwindCssAst !== tailwindCssAst) { - if (optimize) { - DEBUG && I.start('Optimization') - - DEBUG && I.start('AST -> CSS') - let css = toCss(tailwindCssAst) - DEBUG && I.end('AST -> CSS') - - DEBUG && I.start('Lightning CSS') - let optimized = optimizeCss(css, { - minify: typeof optimize === 'object' ? optimize.minify : true, - }) - DEBUG && I.end('Lightning CSS') - - DEBUG && I.start('CSS -> PostCSS AST') - context.optimizedPostCssAst = postcss.parse(optimized.code, result.opts) - DEBUG && I.end('CSS -> PostCSS AST') - - DEBUG && I.end('Optimization') - } else { - // Convert our AST to a PostCSS AST - DEBUG && I.start('Transform Tailwind CSS AST into PostCSS AST') - context.cachedPostCssAst = cssAstToPostCssAst(postcss, tailwindCssAst, root.source) - DEBUG && I.end('Transform Tailwind CSS AST into PostCSS AST') - } - } - - context.tailwindCssAst = tailwindCssAst - - DEBUG && I.start('Update PostCSS AST') - root.removeAll() - root.append( - optimize - ? context.optimizedPostCssAst.clone().nodes - : context.cachedPostCssAst.clone().nodes, - ) - - // Trick PostCSS into thinking the indent is 2 spaces, so it uses that - // as the default instead of 4. - root.raws.indent = ' ' - DEBUG && I.end('Update PostCSS AST') - - DEBUG && I.end(`[@tailwindcss/postcss] ${relative(base, inputFile)}`) - } catch (error) { - // An error requires a full rebuild to fix - context.compiler = null - - // Ensure all dependencies we have collected thus far are included so that the rebuild - // is correctly triggered - for (let file of context.fullRebuildPaths) { - result.messages.push({ - type: 'dependency', - plugin: '@tailwindcss/postcss', - file: path.resolve(file), - parent: result.opts.from, - }) - } - - // We found that throwing the error will cause PostCSS to no longer watch for changes - // in some situations so we instead log the error and continue with an empty stylesheet. - console.error(error) - - if (error && typeof error === 'object' && 'message' in error) { - throw root.error(`${error.message}`) - } - - throw root.error(`${error}`) + rebuildStrategy = 'full' + context.mtimes.set(file, changedTime) } - }, + } + + let hasApply = false + let hasTailwind = false + + root.walkAtRules((rule) => { + if (rule.name === 'apply') { + hasApply = true + } else if (rule.name === 'tailwind') { + hasApply = true + hasTailwind = true + // If we've found `@tailwind` then we already + // know we have to run a "full" build + return false + } + }) + + // Do nothing if neither `@tailwind` nor `@apply` is used + if (!hasTailwind && !hasApply) return + + let css = '' + + // Look for candidates used to generate the CSS + let { candidates, files, globs } = scanDir({ base, globs: true }) + + // Add all found files as direct dependencies + for (let file of files) { + result.messages.push({ + type: 'dependency', + plugin: '@tailwindcss/postcss', + file, + parent: result.opts.from, + }) + } + + // Register dependencies so changes in `base` cause a rebuild while + // giving tools like Vite or Parcel a glob that can be used to limit + // the files that cause a rebuild to only those that match it. + for (let { base, glob } of globs) { + result.messages.push({ + type: 'dir-dependency', + plugin: '@tailwindcss/postcss', + dir: base, + glob, + parent: result.opts.from, + }) + } + + if (rebuildStrategy === 'full') { + let { build } = compile(root.toString()) + context.build = build + css = build(hasTailwind ? candidates : []) + } else if (rebuildStrategy === 'incremental') { + css = context.build!(candidates) + } + + // Replace CSS + if (css !== context.css && optimize) { + context.optimizedCss = optimizeCss(css, { + minify: typeof optimize === 'object' ? optimize.minify : true, + }) + } + context.css = css + root.removeAll() + root.append(postcss.parse(optimize ? context.optimizedCss : context.css, result.opts)) }, ], } diff --git a/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/fixtures/example-project/src/index.css b/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/fixtures/example-project/src/index.css deleted file mode 100644 index b8c4fc5f1..000000000 --- a/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/fixtures/example-project/src/index.css +++ /dev/null @@ -1,4 +0,0 @@ -@source "./**/*.ts"; -@source "!./**/*.ts"; -@plugin "./plugin.js"; -@plugin "./what\"s-this.js"; diff --git a/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/fixtures/example-project/src/invalid.css b/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/fixtures/example-project/src/invalid.css deleted file mode 100644 index 9eeb9353e..000000000 --- a/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/fixtures/example-project/src/invalid.css +++ /dev/null @@ -1,4 +0,0 @@ -@plugin "/absolute/paths"; -@plugin "C:\Program Files\HAL 9000"; -@plugin "\\Media\Pictures\Worth\1000 words"; -@plugin "some-node-dep"; diff --git a/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/fixtures/external-import/src/index.css b/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/fixtures/external-import/src/index.css deleted file mode 100644 index 2c014767b..000000000 --- a/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/fixtures/external-import/src/index.css +++ /dev/null @@ -1 +0,0 @@ -@import '../../example-project/src/index.css'; diff --git a/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/fixtures/external-import/src/invalid.css b/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/fixtures/external-import/src/invalid.css deleted file mode 100644 index b69d455c0..000000000 --- a/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/fixtures/external-import/src/invalid.css +++ /dev/null @@ -1 +0,0 @@ -@import '../../example-project/src/invalid.css'; diff --git a/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/fixtures/external-import/src/plugins-in-root.css b/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/fixtures/external-import/src/plugins-in-root.css deleted file mode 100644 index d6d5f082c..000000000 --- a/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/fixtures/external-import/src/plugins-in-root.css +++ /dev/null @@ -1,5 +0,0 @@ -@import './plugins-in-sibling.css'; - -@plugin './plugin-in-root.ts'; -@plugin '../plugin-in-root.ts'; -@plugin 'plugin-in-root'; diff --git a/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/fixtures/external-import/src/plugins-in-sibling.css b/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/fixtures/external-import/src/plugins-in-sibling.css deleted file mode 100644 index 5df3cb061..000000000 --- a/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/fixtures/external-import/src/plugins-in-sibling.css +++ /dev/null @@ -1,3 +0,0 @@ -@plugin './plugin-in-sibling.ts'; -@plugin '../plugin-in-sibling.ts'; -@plugin 'plugin-in-sibling'; diff --git a/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/index.test.ts b/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/index.test.ts deleted file mode 100644 index fd90c483e..000000000 --- a/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/index.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' -import postcss from 'postcss' -import atImport from 'postcss-import' -import { describe, expect, test } from 'vitest' -import fixRelativePathsPlugin from '.' -import { pretty } from '../../../tailwindcss/src/test-utils/run' - -describe('fixRelativePathsPlugin', () => { - test('rewrites @source and @plugin to be relative to the initial css file', async () => { - let cssPath = path.join(__dirname, 'fixtures', 'external-import', 'src', 'index.css') - let css = fs.readFileSync(cssPath, 'utf-8') - - let processor = postcss([atImport(), fixRelativePathsPlugin()]) - - let result = await processor.process(css, { from: cssPath }) - - expect(pretty(result.css)).toMatchInlineSnapshot(` - " - @source "../../example-project/src/**/*.ts"; - @source "!../../example-project/src/**/*.ts"; - @plugin "../../example-project/src/plugin.js"; - @plugin "../../example-project/src/what\\"s-this.js"; - " - `) - }) - - test('should not rewrite non-relative paths', async () => { - let cssPath = path.join(__dirname, 'fixtures', 'external-import', 'src', 'invalid.css') - let css = fs.readFileSync(cssPath, 'utf-8') - - let processor = postcss([atImport(), fixRelativePathsPlugin()]) - - let result = await processor.process(css, { from: cssPath }) - - expect(pretty(result.css)).toMatchInlineSnapshot(` - " - @plugin "/absolute/paths"; - @plugin "C:\\Program Files\\HAL 9000"; - @plugin "\\\\Media\\Pictures\\Worth\\1000 words"; - @plugin "some-node-dep"; - " - `) - }) - - test('should return relative paths even if the file is resolved in the same basedir as the root stylesheet', async () => { - let cssPath = path.join(__dirname, 'fixtures', 'external-import', 'src', 'plugins-in-root.css') - let css = fs.readFileSync(cssPath, 'utf-8') - - let processor = postcss([atImport(), fixRelativePathsPlugin()]) - - let result = await processor.process(css, { from: cssPath }) - - expect(pretty(result.css)).toMatchInlineSnapshot(` - " - @plugin './plugin-in-sibling.ts'; - @plugin '../plugin-in-sibling.ts'; - @plugin 'plugin-in-sibling'; - - @plugin './plugin-in-root.ts'; - @plugin '../plugin-in-root.ts'; - @plugin 'plugin-in-root'; - " - `) - }) -}) diff --git a/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/index.ts b/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/index.ts deleted file mode 100644 index 68dcb5551..000000000 --- a/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/index.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { normalizePath } from '@tailwindcss/node' -import path from 'node:path' -import type { AtRule, Plugin } from 'postcss' - -const SINGLE_QUOTE = "'" -const DOUBLE_QUOTE = '"' - -export default function fixRelativePathsPlugin(): Plugin { - // Retain a list of touched at-rules to avoid infinite loops - let touched: WeakSet = new WeakSet() - - function fixRelativePath(atRule: AtRule) { - let rootPath = atRule.root().source?.input.file - if (!rootPath) { - return - } - - let inputFilePath = atRule.source?.input.file - if (!inputFilePath) { - return - } - - if (touched.has(atRule)) { - return - } - - let value = atRule.params[0] - - let quote = - value[0] === DOUBLE_QUOTE && value[value.length - 1] === DOUBLE_QUOTE - ? DOUBLE_QUOTE - : value[0] === SINGLE_QUOTE && value[value.length - 1] === SINGLE_QUOTE - ? SINGLE_QUOTE - : null - if (!quote) { - return - } - let glob = atRule.params.slice(1, -1) - - // Handle eventual negative rules. We only support one level of negation. - let negativePrefix = '' - if (glob.startsWith('!')) { - glob = glob.slice(1) - negativePrefix = '!' - } - - // We only want to rewrite relative paths. - if (!glob.startsWith('./') && !glob.startsWith('../')) { - return - } - - let absoluteGlob = path.posix.join(normalizePath(path.dirname(inputFilePath)), glob) - let absoluteRootPosixPath = path.posix.dirname(normalizePath(rootPath)) - - let relative = path.posix.relative(absoluteRootPosixPath, absoluteGlob) - - // If the path points to a file in the same directory, `path.relative` will - // remove the leading `./` and we need to add it back in order to still - // consider the path relative - if (!relative.startsWith('.')) { - relative = './' + relative - } - - atRule.params = quote + negativePrefix + relative + quote - touched.add(atRule) - } - - return { - postcssPlugin: 'tailwindcss-postcss-fix-relative-paths', - Once(root) { - root.walkAtRules(/source|plugin|config/, fixRelativePath) - }, - } -} diff --git a/packages/@tailwindcss-postcss/tsconfig.json b/packages/@tailwindcss-postcss/tsconfig.json index d2131cbeb..6ae022f65 100644 --- a/packages/@tailwindcss-postcss/tsconfig.json +++ b/packages/@tailwindcss-postcss/tsconfig.json @@ -1,6 +1,3 @@ { "extends": "../tsconfig.base.json", - "compilerOptions": { - "verbatimModuleSyntax": false, - }, } diff --git a/packages/@tailwindcss-postcss/tsup.config.ts b/packages/@tailwindcss-postcss/tsup.config.ts deleted file mode 100644 index 684c072ac..000000000 --- a/packages/@tailwindcss-postcss/tsup.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig([ - { - format: ['esm'], - minify: true, - cjsInterop: true, - dts: true, - entry: ['src/index.ts'], - }, - { - format: ['cjs'], - minify: true, - cjsInterop: true, - dts: true, - entry: ['src/index.cts'], - }, -]) diff --git a/packages/@tailwindcss-standalone/package.json b/packages/@tailwindcss-standalone/package.json deleted file mode 100644 index 24f8c93c7..000000000 --- a/packages/@tailwindcss-standalone/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "@tailwindcss/standalone", - "version": "4.3.3", - "private": true, - "description": "Standalone CLI for Tailwind CSS", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/tailwindlabs/tailwindcss.git", - "directory": "packages/@tailwindcss-standalone" - }, - "bugs": "https://github.com/tailwindlabs/tailwindcss/issues", - "homepage": "https://tailwindcss.com", - "scripts": { - "lint": "tsc --noEmit", - "build": "bun ./scripts/build.ts" - }, - "bin": { - "tailwindcss": "./dist/index.mjs" - }, - "exports": { - "./package.json": "./package.json" - }, - "files": [ - "dist" - ], - "dependencies": { - "@tailwindcss/aspect-ratio": "^0.4.2", - "@tailwindcss/cli": "workspace:*", - "@tailwindcss/forms": "^0.5.11", - "@tailwindcss/typography": "^0.5.20", - "detect-libc": "1.0.3", - "enhanced-resolve": "catalog:", - "tailwindcss": "workspace:*" - }, - "__notes": "These binary packages must be included so Bun can build the CLI for all supported platforms. We also rely on Lightning CSS and Parcel being patched so Bun can statically analyze the executables.", - "devDependencies": { - "@parcel/watcher-darwin-arm64": "catalog:", - "@parcel/watcher-darwin-x64": "catalog:", - "@parcel/watcher-linux-arm64-glibc": "catalog:", - "@parcel/watcher-linux-arm64-musl": "catalog:", - "@parcel/watcher-linux-x64-glibc": "catalog:", - "@parcel/watcher-linux-x64-musl": "catalog:", - "@parcel/watcher-win32-x64": "catalog:", - "@types/bun": "^1.3.14", - "bun": "^1.3.14", - "lightningcss-darwin-arm64": "catalog:", - "lightningcss-darwin-x64": "catalog:", - "lightningcss-linux-arm64-gnu": "catalog:", - "lightningcss-linux-arm64-musl": "catalog:", - "lightningcss-linux-x64-gnu": "catalog:", - "lightningcss-linux-x64-musl": "catalog:", - "lightningcss-win32-x64-msvc": "catalog:" - } -} diff --git a/packages/@tailwindcss-standalone/scripts/build.ts b/packages/@tailwindcss-standalone/scripts/build.ts deleted file mode 100644 index 0aee26435..000000000 --- a/packages/@tailwindcss-standalone/scripts/build.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { createHash } from 'node:crypto' -import { mkdir, readFile, writeFile } from 'node:fs/promises' -import * as path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = fileURLToPath(new URL('.', import.meta.url)) - -// Workaround for Bun binary downloads failing on Windows CI when -// USERPROFILE is passed through by Turborepo. -// -// Unfortunately, setting this at runtime doesn't appear to work so we have to -// spawn a new process without the env var. -if (process.env.NESTED_BUILD !== '1' && process.env.USERPROFILE && process.env.USERPROFILE !== '') { - let result = await Bun.$`bun ${fileURLToPath(import.meta.url)}`.env({ - USERPROFILE: '', - NESTED_BUILD: '1', - }) - - process.exit(result.exitCode) -} - -// We use baseline builds for all x64 platforms to ensure compatibility with -// older hardware. -let builds: { target: Bun.Build.Target; name: string }[] = [ - { name: 'tailwindcss-linux-arm64', target: 'bun-linux-arm64' }, - { name: 'tailwindcss-linux-arm64-musl', target: 'bun-linux-arm64-musl' }, - // @ts-expect-error: Either the types are wrong or the runtime needs to be updated - // to accept a `-glibc` at the end like the types suggest. - { name: 'tailwindcss-linux-x64', target: 'bun-linux-x64-baseline' }, - { name: 'tailwindcss-linux-x64-musl', target: 'bun-linux-x64-baseline-musl' }, - { name: 'tailwindcss-macos-arm64', target: 'bun-darwin-arm64' }, - { name: 'tailwindcss-macos-x64', target: 'bun-darwin-x64-baseline' }, - { name: 'tailwindcss-windows-x64.exe', target: 'bun-windows-x64-baseline' }, -] - -let summary: { target: Bun.Build.Target; name: string; sum: string }[] = [] - -// Build platform binaries and checksum them. -let start = process.hrtime.bigint() -for (let { target, name } of builds) { - let outfile = path.resolve(__dirname, `../dist/${name}`) - - let result = await Bun.build({ - entrypoints: ['./src/index.ts'], - target: 'node', - minify: { - whitespace: false, - syntax: true, - identifiers: false, - keepNames: true, - }, - - define: { - // This ensures only necessary binaries are bundled for linux targets - // It reduces binary size since no runtime selection is necessary - 'process.env.PLATFORM_LIBC': JSON.stringify(target.includes('-musl') ? 'musl' : 'glibc'), - - // This prevents the WASI build from being bundled with the binary - 'process.env.NAPI_RS_FORCE_WASI': JSON.stringify(''), - - // This simplifies the Oxide loading code a small amount - 'process.env.NAPI_RS_NATIVE_LIBRARY_PATH': JSON.stringify(''), - }, - - compile: { - target, - outfile, - - // Disable .env loading - autoloadDotenv: false, - - // Disable bunfig.toml loading - autoloadBunfig: false, - }, - - plugins: [ - { - name: 'tailwindcss-plugin', - setup(build) { - build.onLoad({ filter: /tailwindcss-oxide\.wasi\.cjs$/ }, async (args) => { - return { contents: '' } - }) - }, - }, - ], - }) - - let entry = result.outputs.find((output) => output.kind === 'entry-point') - if (!entry) throw new Error(`Build failed for ${target}`) - - let content = await readFile(outfile) - - summary.push({ - target, - name, - sum: createHash('sha256').update(content).digest('hex'), - }) -} - -await mkdir(path.resolve(__dirname, '../dist'), { recursive: true }) - -// Write the checksums to a file -let sumsFile = path.resolve(__dirname, '../dist/sha256sums.txt') -let sums = summary.map(({ name, sum }) => `${sum} ./${name}`) - -await writeFile(sumsFile, sums.join('\n') + '\n') - -console.table(summary.map(({ target, sum }) => ({ target, sum }))) - -let elapsed = process.hrtime.bigint() - start -console.log(`Build completed in ${(Number(elapsed) / 1e6).toFixed(0)}ms`) diff --git a/packages/@tailwindcss-standalone/src/index.ts b/packages/@tailwindcss-standalone/src/index.ts deleted file mode 100644 index 23a928f8b..000000000 --- a/packages/@tailwindcss-standalone/src/index.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { createRequire } from 'node:module' -import packageJson from 'tailwindcss/package.json' - -import indexCss from 'tailwindcss/index.css' with { type: 'file' } -import preflightCss from 'tailwindcss/preflight.css' with { type: 'file' } -import themeCss from 'tailwindcss/theme.css' with { type: 'file' } -import utilitiesCss from 'tailwindcss/utilities.css' with { type: 'file' } - -const localResolve = createRequire(import.meta.url).resolve - -globalThis.__tw_resolve = (id, baseDir) => { - let isEmbeddedFileBase = baseDir === '/$bunfs/root' || baseDir?.includes(':/~BUN/root') - const likelyEmbeddedFile = - id === 'tailwindcss' || - id.startsWith('tailwindcss/') || - id.startsWith('@tailwindcss/') || - isEmbeddedFileBase - - if (!likelyEmbeddedFile) { - return false - } - - id = id.startsWith('tailwindcss/') - ? id.slice(12) - : isEmbeddedFileBase && id.startsWith('./') - ? id.slice(2) - : id - - switch (id) { - case 'index': - case 'index.css': - case 'tailwindcss': - return localResolve(indexCss) - case 'theme': - case 'theme.css': - return localResolve(themeCss) - case 'preflight': - case 'preflight.css': - return localResolve(preflightCss) - case 'utilities': - case 'utilities.css': - return localResolve(utilitiesCss) - case '@tailwindcss/forms': - case '@tailwindcss/typography': - case '@tailwindcss/aspect-ratio': - return id - default: - return false - } -} -globalThis.__tw_load = async (id) => { - if (id.endsWith('@tailwindcss/forms')) { - return require('@tailwindcss/forms') - } else if (id.endsWith('@tailwindcss/typography')) { - return require('@tailwindcss/typography') - } else if (id.endsWith('@tailwindcss/aspect-ratio')) { - return require('@tailwindcss/aspect-ratio') - } else { - return undefined - } -} -globalThis.__tw_version = packageJson.version - -// We use a plugin to make sure that the JS APIs are bundled with the standalone -// CLI and can be imported inside configs and plugins -Bun.plugin({ - name: 'bundle-tailwindcss-apis', - target: 'bun', - async setup(build) { - // These imports must be static strings otherwise they won't be bundled - let bundled = { - tailwindcss: await import('tailwindcss'), - 'tailwindcss/colors': await import('tailwindcss/colors'), - 'tailwindcss/colors.js': await import('tailwindcss/colors'), - 'tailwindcss/plugin': await import('tailwindcss/plugin'), - 'tailwindcss/plugin.js': await import('tailwindcss/plugin'), - 'tailwindcss/package.json': await import('tailwindcss/package.json'), - 'tailwindcss/lib/util/flattenColorPalette': - await import('tailwindcss/lib/util/flattenColorPalette'), - 'tailwindcss/lib/util/flattenColorPalette.js': - await import('tailwindcss/lib/util/flattenColorPalette'), - 'tailwindcss/defaultTheme': await import('tailwindcss/defaultTheme'), - 'tailwindcss/defaultTheme.js': await import('tailwindcss/defaultTheme'), - } - - for (let [id, exports] of Object.entries(bundled)) { - build.module(id, () => ({ - loader: 'object', - exports: { - ...exports, - __esModule: true, - }, - })) - } - }, -}) - -await import('../../@tailwindcss-cli/src/index.ts') diff --git a/packages/@tailwindcss-standalone/src/types.d.ts b/packages/@tailwindcss-standalone/src/types.d.ts deleted file mode 100644 index 0b5deb6ea..000000000 --- a/packages/@tailwindcss-standalone/src/types.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -declare module '*.css' { - const content: string - export default content -} - -declare var __tw_version: string | undefined -declare var __tw_resolve: undefined | ((id: string, base?: string) => string | false) -declare var __tw_readFile: - undefined | ((path: string, encoding: BufferEncoding) => Promise) -declare var __tw_load: undefined | ((path: string) => Promise) diff --git a/packages/@tailwindcss-standalone/tsconfig.json b/packages/@tailwindcss-standalone/tsconfig.json deleted file mode 100644 index 6ae022f65..000000000 --- a/packages/@tailwindcss-standalone/tsconfig.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "../tsconfig.base.json", -} diff --git a/packages/@tailwindcss-turbopack/README.md b/packages/@tailwindcss-turbopack/README.md deleted file mode 100644 index ee54b4d03..000000000 --- a/packages/@tailwindcss-turbopack/README.md +++ /dev/null @@ -1,99 +0,0 @@ -

                          - - - - - Tailwind CSS - - -

                          - -

                          - A utility-first CSS framework for rapidly building custom user interfaces. -

                          - -

                          - Build Status - Total Downloads - Latest Release - License -

                          - ---- - -## Documentation - -For full documentation, visit [tailwindcss.com](https://tailwindcss.com). - -## Community - -For help, discussion about best practices, or feature ideas: - -[Discuss Tailwind CSS on GitHub](https://github.com/tailwindlabs/tailwindcss/discussions) - -## Contributing - -If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md) **before submitting a pull request**. - ---- - -## @tailwindcss/turbopack - -A Turbopack loader for Tailwind CSS v4. - -## Installation - -```sh -npm install @tailwindcss/turbopack -``` - -### Usage - -```javascript -// next.config.js -module.exports = { - turbopack: { - rules: { - '*.css': { - loaders: ['@tailwindcss/turbopack'], - as: '*.css', - }, - }, - }, -} -``` - -Then create a CSS file that imports Tailwind: - -```css -/* src/index.css */ -@import 'tailwindcss'; -``` - -### Options - -#### `base` - -The base directory to scan for class candidates. Defaults to the current working directory. - -```javascript -{ - loader: '@tailwindcss/turbopack', - options: { - base: process.cwd(), - }, -} -``` - -#### `optimize` - -Whether to optimize and minify the output CSS. Defaults to `true` in production mode. - -```javascript -{ - loader: '@tailwindcss/turbopack', - options: { - optimize: true, // or { minify: true } - }, -} -``` diff --git a/packages/@tailwindcss-turbopack/package.json b/packages/@tailwindcss-turbopack/package.json deleted file mode 100644 index abdbbe90e..000000000 --- a/packages/@tailwindcss-turbopack/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@tailwindcss/turbopack", - "version": "4.3.3", - "description": "A Turbopack loader for Tailwind CSS v4.", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/tailwindlabs/tailwindcss.git", - "directory": "packages/@tailwindcss-turbopack" - }, - "bugs": "https://github.com/tailwindlabs/tailwindcss/issues", - "homepage": "https://tailwindcss.com", - "scripts": { - "build": "tsup-node", - "dev": "pnpm run build -- --watch" - }, - "files": [ - "dist/" - ], - "publishConfig": { - "provenance": true, - "access": "public" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", - "require": "./dist/index.js" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "workspace:*", - "@tailwindcss/oxide": "workspace:*", - "tailwindcss": "workspace:*" - }, - "devDependencies": { - "@types/node": "catalog:", - "webpack": "catalog:" - } -} diff --git a/packages/@tailwindcss-turbopack/src/index.cts b/packages/@tailwindcss-turbopack/src/index.cts deleted file mode 100644 index dbdded748..000000000 --- a/packages/@tailwindcss-turbopack/src/index.cts +++ /dev/null @@ -1,5 +0,0 @@ -import tailwindLoader from './index.ts' - -// CommonJS export for webpack loaders - must be the function directly -// @ts-ignore -export = tailwindLoader diff --git a/packages/@tailwindcss-turbopack/src/index.ts b/packages/@tailwindcss-turbopack/src/index.ts deleted file mode 100644 index a70f28871..000000000 --- a/packages/@tailwindcss-turbopack/src/index.ts +++ /dev/null @@ -1,287 +0,0 @@ -import QuickLRU from '@alloc/quick-lru' -import { - compile, - env, - Features, - Instrumentation, - normalizePath, - optimize, - Polyfills, -} from '@tailwindcss/node' -import { clearRequireCache } from '@tailwindcss/node/require-cache' -import { Scanner } from '@tailwindcss/oxide' -import fs from 'node:fs' -import path from 'node:path' -import type { LoaderContext } from 'webpack' - -const DEBUG = env.DEBUG - -export interface LoaderOptions { - /** - * The base directory to scan for class candidates. - * - * Defaults to the current working directory. - */ - base?: string - - /** - * Optimize and minify the output CSS. - */ - optimize?: boolean | { minify?: boolean } -} - -interface CacheEntry { - mtimes: Map - compiler: null | Awaited> - scanner: null | Scanner - candidates: Set - fullRebuildPaths: string[] -} - -const cache = new QuickLRU({ maxSize: 50 }) - -function getCacheKey(resourceId: string, opts: LoaderOptions): string { - return `${resourceId}:${opts.base ?? ''}:${JSON.stringify(opts.optimize)}` -} - -function getContextFromCache(resourceId: string, opts: LoaderOptions): CacheEntry { - let key = getCacheKey(resourceId, opts) - if (cache.has(key)) return cache.get(key)! - let entry: CacheEntry = { - mtimes: new Map(), - compiler: null, - scanner: null, - candidates: new Set(), - fullRebuildPaths: [], - } - cache.set(key, entry) - return entry -} - -export default async function tailwindLoader( - this: LoaderContext, - source: string, -): Promise { - let callback = this.async() - let options = this.getOptions() ?? {} - let inputFile = this.resourcePath - let resourceId = this.resource - let base = options.base ?? process.cwd() - let shouldOptimize = options.optimize ?? process.env.NODE_ENV === 'production' - let isCSSModuleFile = inputFile.endsWith('.module.css') - - using I = new Instrumentation() - - DEBUG && I.start(`[@tailwindcss/turbopack] ${path.relative(base, inputFile)}`) - - // Bail out early if this is guaranteed to be a non-Tailwind CSS file. - { - DEBUG && I.start('Quick bail check') - let canBail = !/@(import|reference|theme|variant|config|plugin|apply|tailwind)\b/.test(source) - if (canBail) { - DEBUG && I.end('Quick bail check') - DEBUG && I.end(`[@tailwindcss/turbopack] ${path.relative(base, inputFile)}`) - callback(null, source) - return - } - DEBUG && I.end('Quick bail check') - } - - try { - let context = getContextFromCache(resourceId, options) - let inputBasePath = path.dirname(path.resolve(inputFile)) - - // Whether this is the first build or not - let isInitialBuild = context.compiler === null - - async function createCompiler() { - DEBUG && I.start('Setup compiler') - if (context.fullRebuildPaths.length > 0 && !isInitialBuild) { - clearRequireCache(context.fullRebuildPaths) - } - - context.fullRebuildPaths = [] - - DEBUG && I.start('Create compiler') - let compiler = await compile(source, { - from: inputFile, - base: inputBasePath, - shouldRewriteUrls: true, - onDependency: (depPath) => context.fullRebuildPaths.push(depPath), - // In CSS Module files, we have to disable the `@property` polyfill since these will - // emit global `*` rules which are considered to be non-pure and will cause builds - // to fail. - polyfills: isCSSModuleFile ? Polyfills.All ^ Polyfills.AtProperty : Polyfills.All, - }) - DEBUG && I.end('Create compiler') - - DEBUG && I.end('Setup compiler') - return compiler - } - - // Setup the compiler if it doesn't exist yet - context.compiler ??= await createCompiler() - - // Early exit if no Tailwind features are used - if (context.compiler.features === Features.None) { - DEBUG && I.end(`[@tailwindcss/turbopack] ${path.relative(base, inputFile)}`) - callback(null, source) - return - } - - let rebuildStrategy: 'full' | 'incremental' = 'incremental' - - // Track file modification times to CSS files - DEBUG && I.start('Register full rebuild paths') - { - // Report dependencies for config files, plugins, etc. - for (let file of context.fullRebuildPaths) { - this.addDependency(path.resolve(file)) - } - - let files = [...context.fullRebuildPaths, inputFile] - - for (let file of files) { - let changedTime: number | null = null - try { - changedTime = fs.statSync(file)?.mtimeMs ?? null - } catch { - // File might not exist - } - - if (changedTime === null) { - if (file === inputFile) { - rebuildStrategy = 'full' - } - continue - } - - let prevTime = context.mtimes.get(file) - if (prevTime === changedTime) continue - - rebuildStrategy = 'full' - context.mtimes.set(file, changedTime) - } - } - DEBUG && I.end('Register full rebuild paths') - - if (rebuildStrategy === 'full' && !isInitialBuild) { - context.compiler = await createCompiler() - } - - let compiler = context.compiler - - // Check if we need to process this file at all - if ( - !( - compiler.features & - (Features.AtApply | Features.JsPluginCompat | Features.ThemeFunction | Features.Utilities) - ) - ) { - DEBUG && I.end(`[@tailwindcss/turbopack] ${path.relative(base, inputFile)}`) - callback(null, source) - return - } - - // Setup or update scanner if needed - if (context.scanner === null || rebuildStrategy === 'full') { - DEBUG && I.start('Setup scanner') - let sources = (() => { - // Disable auto source detection - if (compiler.root === 'none') { - return [] - } - - // No root specified, use the base directory - if (compiler.root === null) { - return [{ base, pattern: '**/*', negated: false }] - } - - // Use the specified root - return [{ ...compiler.root, negated: false }] - })().concat(compiler.sources) - - context.scanner = new Scanner({ sources }) - DEBUG && I.end('Setup scanner') - } - - // Scan for candidates if utilities are used - if (compiler.features & Features.Utilities) { - DEBUG && I.start('Scan for candidates') - for (let candidate of context.scanner.scan()) { - context.candidates.add(candidate) - } - DEBUG && I.end('Scan for candidates') - - DEBUG && I.start('Register dependency messages') - // Add all found files as direct dependencies - let resolvedInputFile = path.resolve(base, inputFile) - for (let file of context.scanner.files) { - let absolutePath = path.resolve(file) - // The CSS file cannot be a dependency of itself - if (absolutePath === resolvedInputFile) { - continue - } - this.addDependency(absolutePath) - } - - // Register context dependencies for glob patterns - for (let glob of context.scanner.globs) { - // Skip negated patterns - if (glob.pattern[0] === '!') continue - - // Avoid adding a dependency on the base directory itself - if (glob.pattern === '*' && base === glob.base) { - continue - } - - this.addContextDependency(path.resolve(glob.base)) - } - - // Validate that source(...) paths are directories - let root = compiler.root - if (root !== 'none' && root !== null) { - let basePath = normalizePath(path.resolve(root.base, root.pattern)) - try { - let stats = fs.statSync(basePath) - if (!stats.isDirectory()) { - throw new Error( - `The path given to \`source(…)\` must be a directory but got \`source(${basePath})\` instead.`, - ) - } - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - throw err - } - // Directory doesn't exist yet, which is fine - } - } - DEBUG && I.end('Register dependency messages') - } - - DEBUG && I.start('Build utilities') - let css = compiler.build([...context.candidates]) - DEBUG && I.end('Build utilities') - - // Optionally optimize the output - let result = css - if (shouldOptimize) { - DEBUG && I.start('Optimization') - let optimized = optimize(css, { - minify: typeof shouldOptimize === 'object' ? shouldOptimize.minify : true, - }) - result = optimized.code - DEBUG && I.end('Optimization') - } - - DEBUG && I.end(`[@tailwindcss/turbopack] ${path.relative(base, inputFile)}`) - callback(null, result) - } catch (error) { - // Clear the cache entry on error to force a full rebuild next time - let key = getCacheKey(resourceId, options) - cache.delete(key) - - DEBUG && I.end(`[@tailwindcss/turbopack] ${path.relative(base, inputFile)}`) - callback(error as Error) - } -} diff --git a/packages/@tailwindcss-turbopack/tsconfig.json b/packages/@tailwindcss-turbopack/tsconfig.json deleted file mode 100644 index d2131cbeb..000000000 --- a/packages/@tailwindcss-turbopack/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "../tsconfig.base.json", - "compilerOptions": { - "verbatimModuleSyntax": false, - }, -} diff --git a/packages/@tailwindcss-turbopack/tsup.config.ts b/packages/@tailwindcss-turbopack/tsup.config.ts deleted file mode 100644 index 998eebe4f..000000000 --- a/packages/@tailwindcss-turbopack/tsup.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig([ - { - format: ['esm'], - clean: true, - minify: true, - cjsInterop: true, - dts: true, - entry: ['src/index.ts'], - }, - { - format: ['cjs'], - minify: true, - cjsInterop: true, - dts: true, - entry: ['src/index.cts'], - }, -]) diff --git a/packages/@tailwindcss-upgrade/README.md b/packages/@tailwindcss-upgrade/README.md deleted file mode 100644 index 5f532607d..000000000 --- a/packages/@tailwindcss-upgrade/README.md +++ /dev/null @@ -1,36 +0,0 @@ -

                          - - - - - Tailwind CSS - - -

                          - -

                          - A utility-first CSS framework for rapidly building custom user interfaces. -

                          - -

                          - Build Status - Total Downloads - Latest Release - License -

                          - ---- - -## Documentation - -For full documentation, visit [tailwindcss.com](https://tailwindcss.com). - -## Community - -For help, discussion about best practices, or feature ideas: - -[Discuss Tailwind CSS on GitHub](https://github.com/tailwindlabs/tailwindcss/discussions) - -## Contributing - -If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md) **before submitting a pull request**. diff --git a/packages/@tailwindcss-upgrade/package.json b/packages/@tailwindcss-upgrade/package.json deleted file mode 100644 index e960d7677..000000000 --- a/packages/@tailwindcss-upgrade/package.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "name": "@tailwindcss/upgrade", - "version": "4.3.3", - "description": "A utility-first CSS framework for rapidly building custom user interfaces.", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/tailwindlabs/tailwindcss.git", - "directory": "packages/@tailwindcss-cli" - }, - "bugs": "https://github.com/tailwindlabs/tailwindcss/issues", - "homepage": "https://tailwindcss.com", - "scripts": { - "lint": "tsc --noEmit", - "build": "tsup-node", - "dev": "pnpm run build -- --watch" - }, - "bin": "./dist/index.mjs", - "exports": { - "./package.json": "./package.json" - }, - "files": [ - "dist" - ], - "publishConfig": { - "provenance": true, - "access": "public" - }, - "dependencies": { - "@tailwindcss/node": "workspace:*", - "@tailwindcss/oxide": "workspace:*", - "dedent": "catalog:", - "enhanced-resolve": "catalog:", - "globby": "^16.2.2", - "jiti": "^2.7.0", - "mri": "^1.2.0", - "picocolors": "^1.1.1", - "postcss": "catalog:", - "postcss-import": "^16.1.1", - "postcss-selector-parser": "^7.1.4", - "prettier": "catalog:", - "semver": "^7.8.5", - "tailwindcss": "workspace:*", - "tree-sitter": "^0.25.1", - "tree-sitter-typescript": "^0.23.2" - }, - "devDependencies": { - "@types/node": "catalog:", - "@types/postcss-import": "^14.0.3", - "@types/semver": "^7.8.0" - } -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts b/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts deleted file mode 100644 index 6a451333e..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts +++ /dev/null @@ -1,553 +0,0 @@ -import { Scanner } from '@tailwindcss/oxide' -import fs from 'node:fs/promises' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { loadModule } from '../../../../@tailwindcss-node/src/compile' -import defaultTheme from '../../../../tailwindcss/dist/default-theme' -import { atRule, toCss, type AstNode } from '../../../../tailwindcss/src/ast' -import { - keyPathToCssProperty, - themeableValues, -} from '../../../../tailwindcss/src/compat/apply-config-to-theme' -import { keyframesToRules } from '../../../../tailwindcss/src/compat/apply-keyframes-to-theme' -import { - resolveConfig, - type ConfigFile, -} from '../../../../tailwindcss/src/compat/config/resolve-config' -import type { ResolvedConfig, ThemeConfig } from '../../../../tailwindcss/src/compat/config/types' -import { buildCustomContainerUtilityRules } from '../../../../tailwindcss/src/compat/container' -import { darkModePlugin } from '../../../../tailwindcss/src/compat/dark-mode' -import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' -import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { escape } from '../../../../tailwindcss/src/utils/escape' -import { - isValidOpacityValue, - isValidSpacingMultiplier, -} from '../../../../tailwindcss/src/utils/infer-data-type' -import * as ValueParser from '../../../../tailwindcss/src/value-parser' -import { findStaticPlugins, type StaticPluginOptions } from '../../utils/extract-static-plugins' -import { highlight, info, relative } from '../../utils/renderer' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -export type JSConfigMigration = - // Could not convert the config file, need to inject it as-is in a @config directive - null | { - sources: { base: string; pattern: string }[] - plugins: { base: string; path: string; options: null | StaticPluginOptions }[] - css: string - } - -export async function migrateJsConfig( - designSystem: DesignSystem, - fullConfigPath: string, - base: string, -): Promise { - let [unresolvedConfig, source] = await Promise.all([ - loadModule(fullConfigPath, __dirname, () => {}).then((result) => result.module) as Config, - fs.readFile(fullConfigPath, 'utf-8'), - ]) - - if (!canMigrateConfig(unresolvedConfig, source)) { - info( - `The configuration file at ${highlight(relative(fullConfigPath, base))} could not be automatically migrated to the new CSS configuration format, so your CSS has been updated to load your existing configuration file.`, - { prefix: '↳ ' }, - ) - return null - } - - let sources: { base: string; pattern: string }[] = [] - let plugins: { base: string; path: string; options: null | StaticPluginOptions }[] = [] - let cssConfigs: string[] = [] - - if ('darkMode' in unresolvedConfig) { - cssConfigs.push(migrateDarkMode(unresolvedConfig as any)) - } - - if ('content' in unresolvedConfig) { - sources = await migrateContent(unresolvedConfig as any, fullConfigPath, base) - } - - if ('theme' in unresolvedConfig) { - let themeConfig = await migrateTheme(designSystem, unresolvedConfig, base) - if (themeConfig) cssConfigs.push(themeConfig) - } - - if ('corePlugins' in unresolvedConfig) { - info( - `The \`corePlugins\` option is no longer supported as of Tailwind CSS v4.0, so it's been removed from your configuration.`, - ) - } - - let simplePlugins = findStaticPlugins(source) - if (simplePlugins !== null) { - for (let [path, options] of simplePlugins) { - plugins.push({ base, path, options }) - } - } - - return { - sources, - plugins, - css: cssConfigs.join('\n'), - } -} - -async function migrateTheme( - designSystem: DesignSystem, - unresolvedConfig: Config, - base: string, -): Promise { - // Resolve the config file without applying plugins and presets, as these are - // migrated to CSS separately. - let configToResolve: ConfigFile = { - base, - config: { ...unresolvedConfig, plugins: [], presets: undefined }, - reference: false, - src: undefined, - } - let { resolvedConfig, replacedThemeKeys } = resolveConfig(designSystem, [configToResolve]) - - let resetNamespaces = new Map( - Array.from(replacedThemeKeys.entries()).map(([key]) => [key, false]), - ) - - removeUnnecessarySpacingKeys(designSystem, resolvedConfig, replacedThemeKeys) - - let css = '' - let prevSectionKey = '' - let themeSection: string[] = [] - let keyframesCss = '' - let variants = new Map() - - // Special handling of specific theme keys: - { - if ('keyframes' in resolvedConfig.theme) { - keyframesCss += keyframesToCss(resolvedConfig.theme.keyframes) - delete resolvedConfig.theme.keyframes - } - - if ('container' in resolvedConfig.theme) { - let rules = buildCustomContainerUtilityRules(resolvedConfig.theme.container, designSystem) - if (rules.length > 0) { - // Using `theme` instead of `utility` so it sits before the `@layer - // base` with compatibility CSS. While this is technically a utility, it - // makes a bit more sense to emit this closer to the `@theme` values - // since it is needed for backwards compatibility. - css += `\n@tw-bucket theme {\n` - css += toCss([atRule('@utility', 'container', rules)]) - css += '}\n' // @tw-bucket - } - delete resolvedConfig.theme.container - } - - if ('aria' in resolvedConfig.theme) { - for (let [key, value] of Object.entries(resolvedConfig.theme.aria ?? {})) { - // Will be handled by bare values if the names match. - // E.g.: `aria-foo:flex` should produce `[aria-foo="true"]` - if (new RegExp(`^${key}=(['"]?)true\\1$`).test(`${value}`)) continue - - // Create custom variant - variants.set(`aria-${key}`, `&[aria-${value}]`) - } - delete resolvedConfig.theme.aria - } - - if ('data' in resolvedConfig.theme) { - for (let [key, value] of Object.entries(resolvedConfig.theme.data ?? {})) { - // Will be handled by bare values if the names match. - // E.g.: `data-foo:flex` should produce `[data-foo]` - if (key === value) continue - - // Create custom variant - variants.set(`data-${key}`, `&[data-${value}]`) - } - delete resolvedConfig.theme.data - } - - if ('supports' in resolvedConfig.theme) { - for (let [key, value] of Object.entries(resolvedConfig.theme.supports ?? {})) { - // Will be handled by bare values if the value of the declaration is a - // CSS variable. - let parsed = ValueParser.parse(`${value}`) - - // Unwrap the parens, e.g.: `(foo: var(--bar))` → `foo: var(--bar)` - if (parsed.length === 1 && parsed[0].kind === 'function' && parsed[0].value === '') { - parsed = parsed[0].nodes - } - - // Verify structure: `foo: var(--bar)` - // ^^^ ← must match the `key` - if ( - parsed.length === 3 && - parsed[0].kind === 'word' && - parsed[0].value === key && - parsed[2].kind === 'function' && - parsed[2].value === 'var' - ) { - continue - } - - // Create custom variant - variants.set(`supports-${key}`, `{@supports(${value}){@slot;}}`) - } - delete resolvedConfig.theme.supports - } - } - - // Convert theme values to CSS custom properties - for (let [key, value] of themeableValues(resolvedConfig.theme)) { - if (typeof value !== 'string' && typeof value !== 'number') { - continue - } - - if (typeof value === 'string') { - // This is more advanced than the version in core as ideally something - // like `rgba(0 0 0 / )` becomes `rgba(0 0 0)`. Since we know - // from the `/` that it's used in an alpha channel and we can remove it. - // - // In other cases we may not know exactly how its used, so we'll just - // replace it with `1` like core does. - value = value.replace(/\s*\/\s*/, '').replace(//, '1') - } - - // Convert `opacity` namespace from decimal to percentage values. - // Additionally we can drop values that resolve to the same value as the - // named modifier with the same name. - if (key[0] === 'opacity' && (typeof value === 'number' || typeof value === 'string')) { - let numValue = typeof value === 'string' ? parseFloat(value) : value - - if (numValue >= 0 && numValue <= 1) { - value = numValue * 100 + '%' - } - - if ( - typeof value === 'string' && - key[1] === value.replace(/%$/, '') && - isValidOpacityValue(key[1]) - ) { - continue - } - } - - let sectionKey = createSectionKey(key) - if (sectionKey !== prevSectionKey) { - themeSection.push('') - prevSectionKey = sectionKey - } - - let property = keyPathToCssProperty(key) - - if (property !== null) { - if ( - !property.startsWith('default-') && - resetNamespaces.has(key[0]) && - resetNamespaces.get(key[0]) === false - ) { - resetNamespaces.set(key[0], true) - let ns = keyPathToCssProperty([key[0]]) - if (ns !== null) { - themeSection.push(` ${escape(`--${ns}`)}-*: initial;`) - } - } - - themeSection.push(` ${escape(`--${property}`)}: ${value};`) - } - } - - if (keyframesCss) { - themeSection.push('', keyframesCss) - } - - if (themeSection.length > 0) { - css += `\n@tw-bucket theme {\n` - css += `\n@theme {\n` - css += themeSection.join('\n') + '\n' - css += '}\n' // @theme - css += '}\n' // @tw-bucket - } - - if (variants.size > 0) { - css += '\n@tw-bucket custom-variant {\n' - - let previousRoot = '' - for (let [name, selector] of variants) { - let root = name.split('-')[0] - if (previousRoot !== root) css += '\n' - previousRoot = root - - if (selector.startsWith('{')) { - css += `@custom-variant ${name} ${selector}\n` - } else { - css += `@custom-variant ${name} (${selector});\n` - } - } - css += '}\n' - } - - return css -} - -function migrateDarkMode(unresolvedConfig: Config & { darkMode: any }): string { - let variant: string | string[] = '' - let addVariant = (_name: string, _variant: string) => (variant = _variant) - let config = () => unresolvedConfig.darkMode - darkModePlugin({ config, addVariant }) - - if (variant === '') { - return '' - } - - if (!Array.isArray(variant)) { - variant = [variant] - } - - if (variant.length === 1 && !variant[0].includes('{')) { - return `\n@tw-bucket custom-variant {\n@custom-variant dark (${variant[0]});\n}\n` - } - - let customVariant = '' - for (let variantName of variant) { - // Convert to the block syntax if a block is used - if (variantName.includes('{')) { - customVariant += variantName.replace('}', '{ @slot }}') + '\n' - } else { - customVariant += variantName + '{ @slot }\n' - } - } - - if (customVariant !== '') { - return `\n@tw-bucket custom-variant {\n@custom-variant dark {${customVariant}};\n}\n` - } - - return '' -} - -// Returns a string identifier used to section theme declarations -function createSectionKey(key: string[]): string { - let sectionSegments = [] - for (let i = 0; i < key.length - 1; i++) { - let segment = key[i] - // Ignore tuples - if (key[i + 1][0] === '-') { - break - } - sectionSegments.push(segment) - } - return sectionSegments.join('-') -} - -async function migrateContent( - unresolvedConfig: Config, - configPath: string, - base: string, -): Promise<{ base: string; pattern: string }[]> { - let autoContentFiles = autodetectedSourceFiles(base) - - let sources = [] - let contentIsRelative = (() => { - if (!unresolvedConfig.content) return false - if (Array.isArray(unresolvedConfig.content)) return false - if (unresolvedConfig.content.relative) return true - if (unresolvedConfig.future === 'all') return false - return unresolvedConfig.future?.relativeContentPathsByDefault ?? false - })() - - let sourceGlobs = Array.isArray(unresolvedConfig.content) - ? unresolvedConfig.content.map((pattern) => ({ base, pattern })) - : (unresolvedConfig.content?.files ?? []).map((pattern) => { - if (typeof pattern === 'string' && contentIsRelative) { - return { base: path.dirname(configPath), pattern: pattern } - } - return { base, pattern } - }) - - for (let { base, pattern } of sourceGlobs) { - if (typeof pattern !== 'string') { - throw new Error('Unsupported content value: ' + pattern) - } - - let sourceFiles = patternSourceFiles({ - base, - pattern: pattern[0] === '!' ? pattern.slice(1) : pattern, - negated: pattern[0] === '!', - }) - - let autoContentContainsAllSourceFiles = true - for (let sourceFile of sourceFiles) { - if (!autoContentFiles.includes(sourceFile)) { - autoContentContainsAllSourceFiles = false - break - } - } - - if (!autoContentContainsAllSourceFiles) { - sources.push({ base, pattern }) - } - } - return sources -} - -// Applies heuristics to determine if we can attempt to migrate the config -function canMigrateConfig(unresolvedConfig: Config, source: string): boolean { - // The file may not contain non-serializable values - function isSimpleValue(value: unknown): boolean { - if (typeof value === 'function') return false - if (Array.isArray(value)) return value.every(isSimpleValue) - if (typeof value === 'object' && value !== null) { - return Object.values(value).every(isSimpleValue) - } - return ['string', 'number', 'boolean', 'undefined'].includes(typeof value) - } - - // `theme` and `plugins` are handled separately and allowed to be more complex - let { plugins, theme, ...remainder } = unresolvedConfig - if (!isSimpleValue(remainder)) { - return false - } - - // The file may only contain known-migratable top-level properties - let knownProperties = [ - 'darkMode', - 'content', - 'theme', - 'plugins', - 'presets', - 'prefix', // Prefix is handled in the dedicated prefix migrator - 'corePlugins', - 'future', - 'experimental', - ] - - if (Object.keys(unresolvedConfig).some((key) => !knownProperties.includes(key))) { - return false - } - - if (findStaticPlugins(source) === null) { - return false - } - - if (unresolvedConfig.presets && unresolvedConfig.presets.length > 0) { - return false - } - - // If there are unknown "future" flags we should bail - if (unresolvedConfig.future && unresolvedConfig.future !== 'all') { - let knownFlags = [ - 'hoverOnlyWhenSupported', - 'respectDefaultRingColorOpacity', - 'disableColorOpacityUtilitiesByDefault', - 'relativeContentPathsByDefault', - ] - - if (Object.keys(unresolvedConfig.future).some((key) => !knownFlags.includes(key))) { - return false - } - } - - // If there are unknown "experimental" flags we should bail - if (unresolvedConfig.experimental && unresolvedConfig.experimental !== 'all') { - let knownFlags = ['generalizedModifiers'] - - if (Object.keys(unresolvedConfig.experimental).some((key) => !knownFlags.includes(key))) { - return false - } - } - - // Only migrate the config file if all top-level theme keys are allowed to be - // migrated - if (theme && typeof theme === 'object') { - if (theme.extend && !onlyAllowedThemeValues(theme.extend)) return false - let { extend: _extend, ...themeCopy } = theme - if (!onlyAllowedThemeValues(themeCopy)) return false - } - - return true -} - -const ALLOWED_THEME_KEYS = [ - ...Object.keys(defaultTheme), - // Used by @tailwindcss/container-queries - 'containers', -] -function onlyAllowedThemeValues(theme: ThemeConfig): boolean { - for (let key of Object.keys(theme)) { - if (!ALLOWED_THEME_KEYS.includes(key)) { - return false - } - } - - if ('screens' in theme && typeof theme.screens === 'object' && theme.screens !== null) { - for (let screen of Object.values(theme.screens)) { - if (typeof screen === 'object' && screen !== null && ('max' in screen || 'raw' in screen)) { - return false - } - } - } - return true -} - -function keyframesToCss(keyframes: Record): string { - let ast: AstNode[] = keyframesToRules({ theme: { keyframes } }) - return toCss(ast).trim() + '\n' -} - -function autodetectedSourceFiles(base: string) { - let scanner = new Scanner({ - sources: [ - { - base, - pattern: '**/*', - negated: false, - }, - ], - }) - scanner.scan() - return scanner.files -} - -function patternSourceFiles(source: { base: string; pattern: string; negated: boolean }): string[] { - let scanner = new Scanner({ sources: [source] }) - scanner.scan() - return scanner.files -} - -function removeUnnecessarySpacingKeys( - designSystem: DesignSystem, - resolvedConfig: ResolvedConfig, - replacedThemeKeys: Set, -) { - // We want to keep the spacing scale as-is if the user is overwriting - if (replacedThemeKeys.has('spacing')) return - - // Ensure we have a spacing multiplier - let spacingScale = designSystem.theme.get(['--spacing']) - if (!spacingScale) return - - let [spacingMultiplier, spacingUnit] = splitNumberAndUnit(spacingScale) - if (!spacingMultiplier || !spacingUnit) return - - if (spacingScale && !replacedThemeKeys.has('spacing')) { - for (let [key, value] of Object.entries(resolvedConfig.theme.spacing ?? {})) { - let [multiplier, unit] = splitNumberAndUnit(value as string) - if (multiplier === null) continue - - if (!isValidSpacingMultiplier(key)) continue - if (unit !== spacingUnit) continue - - if (parseFloat(multiplier) === Number(key) * parseFloat(spacingMultiplier)) { - delete resolvedConfig.theme.spacing[key] - designSystem.theme.clearNamespace(escape(`--spacing-${key.replaceAll('.', '_')}`), 0) - } - } - } -} - -function splitNumberAndUnit(value: string): [string, string] | [null, null] { - let match = value.match(/^([0-9.]+)(.*)$/) - if (!match) { - return [null, null] - } - return [match[1], match[2]] -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/config/migrate-postcss.ts b/packages/@tailwindcss-upgrade/src/codemods/config/migrate-postcss.ts deleted file mode 100644 index e16f52287..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/config/migrate-postcss.ts +++ /dev/null @@ -1,349 +0,0 @@ -import fs from 'node:fs/promises' -import path from 'node:path' -import { pkg } from '../../utils/packages' -import { highlight, info, relative, success, warn } from '../../utils/renderer' -import { writeFileSafely } from '../../utils/write-file-safely' - -// Migrates simple PostCSS setups. This is to cover non-dynamic config files -// similar to the ones we have all over our docs: -// -// ```js -// module.exports = { -// plugins: { -// 'postcss-import': {}, -// 'tailwindcss/nesting': 'postcss-nesting', -// tailwindcss: {}, -// autoprefixer: {}, -// } -// } -// ``` -export async function migratePostCSSConfig(base: string) { - let ranMigration = false - let didMigrate = false - let didAddPostcssClient = false - let didRemoveAutoprefixer = false - let didRemovePostCSSImport = false - - let packageJsonPath = path.resolve(base, 'package.json') - let packageJson - try { - packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')) - } catch {} - - // Priority 1: Handle JS config files - let jsConfigPath = await detectJSConfigPath(base) - if (jsConfigPath) { - let result = await migratePostCSSJSConfig(jsConfigPath) - ranMigration = true - - if (result) { - didMigrate = true - didAddPostcssClient = result.didAddPostcssClient - didRemoveAutoprefixer = result.didRemoveAutoprefixer - didRemovePostCSSImport = result.didRemovePostCSSImport - } - } - - // Priority 2: Handle package.json config - if (!ranMigration) { - if (packageJson && 'postcss' in packageJson) { - let result = await migratePostCSSJsonConfig(packageJson.postcss) - ranMigration = true - - if (result) { - await writeFileSafely( - packageJsonPath, - JSON.stringify({ ...packageJson, postcss: result?.json }, null, 2), - ) - - didMigrate = true - didAddPostcssClient = result.didAddPostcssClient - didRemoveAutoprefixer = result.didRemoveAutoprefixer - didRemovePostCSSImport = result.didRemovePostCSSImport - } - } - } - - // Priority 3: JSON based postcss config files - if (!ranMigration) { - let jsonConfigPath = await detectJSONConfigPath(base) - let jsonConfig: null | any = null - if (jsonConfigPath) { - try { - jsonConfig = JSON.parse(await fs.readFile(jsonConfigPath, 'utf-8')) - } catch {} - if (jsonConfig) { - let result = await migratePostCSSJsonConfig(jsonConfig) - ranMigration = true - - if (result) { - await writeFileSafely(jsonConfigPath, JSON.stringify(result.json, null, 2)) - - didMigrate = true - didAddPostcssClient = result.didAddPostcssClient - didRemoveAutoprefixer = result.didRemoveAutoprefixer - didRemovePostCSSImport = result.didRemovePostCSSImport - } - } - } - } - - if (!ranMigration) { - info('No PostCSS config found, skipping migration.', { - prefix: '↳ ', - }) - return - } - - if (didAddPostcssClient) { - let location = Object.hasOwn(packageJson?.dependencies ?? {}, 'tailwindcss') - ? ('dependencies' as const) - : Object.hasOwn(packageJson?.devDependencies ?? {}, 'tailwindcss') - ? ('devDependencies' as const) - : null - - if (location !== null) { - try { - await pkg(base).add(['@tailwindcss/postcss@latest'], location) - success(`Installed package: ${highlight('@tailwindcss/postcss')}`, { prefix: '↳ ' }) - } catch {} - } - } - - if (didRemoveAutoprefixer) { - try { - await pkg(base).remove(['autoprefixer']) - success(`Removed package: ${highlight('autoprefixer')}`, { prefix: '↳ ' }) - } catch {} - } - - if (didRemovePostCSSImport) { - try { - await pkg(base).remove(['postcss-import']) - success(`Removed package: ${highlight('postcss-import')}`, { prefix: '↳ ' }) - } catch {} - } - - if (didMigrate && jsConfigPath) { - success(`Migrated PostCSS configuration: ${highlight(relative(jsConfigPath, base))}`, { - prefix: '↳ ', - }) - } -} - -async function migratePostCSSJSConfig(configPath: string): Promise<{ - didAddPostcssClient: boolean - didRemoveAutoprefixer: boolean - didRemovePostCSSImport: boolean -} | null> { - function isTailwindCSSPlugin(line: string) { - return /['"]?tailwindcss['"]?: ?\{\}/.test(line) - } - function isPostCSSImportPlugin(line: string) { - return /['"]?postcss-import['"]?: ?\{\}/.test(line) - } - function isAutoprefixerPlugin(line: string) { - return /['"]?autoprefixer['"]?: ?\{\}/.test(line) - } - function isTailwindCSSNestingPlugin(line: string) { - return /['"]tailwindcss\/nesting['"]: ?(\{\}|['"]postcss-nesting['"])/.test(line) - } - - info('Migrating PostCSS configuration…') - - let isSimpleConfig = await isSimplePostCSSConfig(configPath) - if (!isSimpleConfig) { - warn('The PostCSS config contains dynamic JavaScript and can not be automatically migrated.', { - prefix: '↳ ', - }) - return null - } - - let didAddPostcssClient = false - let didRemoveAutoprefixer = false - let didRemovePostCSSImport = false - - let content = await fs.readFile(configPath, 'utf-8') - let lines = content.split('\n') - let newLines: string[] = [] - for (let i = 0; i < lines.length; i++) { - let line = lines[i] - - if (isTailwindCSSPlugin(line)) { - didAddPostcssClient = true - newLines.push(line.replace('tailwindcss:', `'@tailwindcss/postcss':`)) - } else if (isAutoprefixerPlugin(line)) { - didRemoveAutoprefixer = true - } else if (isPostCSSImportPlugin(line)) { - // Check that there are no unknown plugins before the tailwindcss plugin - let hasUnknownPluginsBeforeTailwindCSS = false - for (let j = i + 1; j < lines.length; j++) { - let nextLine = lines[j] - if (isTailwindCSSPlugin(nextLine)) { - break - } - if (isTailwindCSSNestingPlugin(nextLine)) { - continue - } - hasUnknownPluginsBeforeTailwindCSS = true - break - } - - if (!hasUnknownPluginsBeforeTailwindCSS) { - didRemovePostCSSImport = true - } else { - newLines.push(line) - } - } else if (isTailwindCSSNestingPlugin(line)) { - // Check if the following rule is the tailwindcss plugin - let nextLine = lines[i + 1] - if (isTailwindCSSPlugin(nextLine)) { - // Since this plugin is bundled with `tailwindcss`, we don't need to - // clean up a package when deleting this line. - } else { - newLines.push(line) - } - } else { - newLines.push(line) - } - } - await writeFileSafely(configPath, newLines.join('\n')) - - return { didAddPostcssClient, didRemoveAutoprefixer, didRemovePostCSSImport } -} - -async function migratePostCSSJsonConfig(json: any): Promise<{ - json: any - didAddPostcssClient: boolean - didRemoveAutoprefixer: boolean - didRemovePostCSSImport: boolean -} | null> { - function isTailwindCSSPlugin(plugin: string, options: any) { - return plugin === 'tailwindcss' && isEmptyObject(options) - } - function isPostCSSImportPlugin(plugin: string, options: any) { - return plugin === 'postcss-import' && isEmptyObject(options) - } - function isAutoprefixerPlugin(plugin: string, options: any) { - return plugin === 'autoprefixer' && isEmptyObject(options) - } - function isTailwindCSSNestingPlugin(plugin: string, options: any) { - return ( - plugin === 'tailwindcss/nesting' && (options === 'postcss-nesting' || isEmptyObject(options)) - ) - } - - let didAddPostcssClient = false - let didRemoveAutoprefixer = false - let didRemovePostCSSImport = false - - let plugins = Object.entries(json.plugins || {}) - - let newPlugins: [string, any][] = [] - for (let i = 0; i < plugins.length; i++) { - let [plugin, options] = plugins[i] - - if (isTailwindCSSPlugin(plugin, options)) { - didAddPostcssClient = true - newPlugins.push(['@tailwindcss/postcss', options]) - } else if (isAutoprefixerPlugin(plugin, options)) { - didRemoveAutoprefixer = true - } else if (isPostCSSImportPlugin(plugin, options)) { - // Check that there are no unknown plugins before the tailwindcss plugin - let hasUnknownPluginsBeforeTailwindCSS = false - for (let j = i + 1; j < plugins.length; j++) { - let [nextPlugin, nextOptions] = plugins[j] - if (isTailwindCSSPlugin(nextPlugin, nextOptions)) { - break - } - if (isTailwindCSSNestingPlugin(nextPlugin, nextOptions)) { - continue - } - hasUnknownPluginsBeforeTailwindCSS = true - break - } - - if (!hasUnknownPluginsBeforeTailwindCSS) { - didRemovePostCSSImport = true - } else { - newPlugins.push([plugin, options]) - } - } else if (isTailwindCSSNestingPlugin(plugin, options)) { - // Check if the following rule is the tailwindcss plugin - let [nextPlugin, nextOptions] = plugins[i + 1] - if (isTailwindCSSPlugin(nextPlugin, nextOptions)) { - // Since this plugin is bundled with `tailwindcss`, we don't need to - // clean up a package when deleting this line. - } else { - newPlugins.push([plugin, options]) - } - } else { - newPlugins.push([plugin, options]) - } - } - - return { - json: { ...json, plugins: Object.fromEntries(newPlugins) }, - didAddPostcssClient, - didRemoveAutoprefixer, - didRemovePostCSSImport, - } -} - -const JS_CONFIG_FILE_LOCATIONS = [ - '.postcssrc.js', - '.postcssrc.mjs', - '.postcssrc.cjs', - '.postcssrc.ts', - '.postcssrc.mts', - '.postcssrc.cts', - 'postcss.config.js', - 'postcss.config.mjs', - 'postcss.config.cjs', - 'postcss.config.ts', - 'postcss.config.mts', - 'postcss.config.cts', -] -async function detectJSConfigPath(base: string): Promise { - for (let file of JS_CONFIG_FILE_LOCATIONS) { - let fullPath = path.resolve(base, file) - try { - await fs.access(fullPath) - return fullPath - } catch {} - } - return null -} - -const JSON_CONFIG_FILE_LOCATIONS = [ - '.postcssrc', - '.postcssrc.json', - // yaml syntax is not supported - // '.postcssrc.yml' -] -async function detectJSONConfigPath(base: string): Promise { - for (let file of JSON_CONFIG_FILE_LOCATIONS) { - let fullPath = path.resolve(base, file) - try { - await fs.access(fullPath) - return fullPath - } catch {} - } - return null -} - -async function isSimplePostCSSConfig(configPath: string): Promise { - let content = await fs.readFile(configPath, 'utf-8') - return ( - content.includes('tailwindcss:') && - !( - content.includes('require') || - // Adding a space at the end to not match `'postcss-import'` - content.includes('import ') - ) - ) -} - -function isEmptyObject(obj: any) { - return typeof obj === 'object' && obj !== null && Object.keys(obj).length === 0 -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/analyze.ts b/packages/@tailwindcss-upgrade/src/codemods/css/analyze.ts deleted file mode 100644 index c7058df54..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/analyze.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { isGitIgnored } from 'globby' -import path from 'node:path' -import postcss, { type Result } from 'postcss' -import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' -import { segment } from '../../../../tailwindcss/src/utils/segment' -import { Stylesheet, type StylesheetConnection } from '../../stylesheet' -import { gitRoot } from '../../utils/git' -import { error, highlight, relative } from '../../utils/renderer' -import { resolveCssId } from '../../utils/resolve' - -export async function analyze(stylesheets: Stylesheet[], { base }: { base: string }) { - // Use the project's git root, instead of the `base` to ensure all - // `.gitignore` rules are being used. - let isIgnored = await isGitIgnored({ cwd: gitRoot(base) ?? base }) - let processingQueue: (() => Promise)[] = [] - let stylesheetsByFile = new DefaultMap((file) => { - // We don't want to process ignored files (like node_modules) - try { - if (isIgnored(file)) { - return null - } - } catch { - // If the file is not part of the current working directory (which can - // happen if you import `tailwindcss` and it's loading a shared file from - // pnpm) then this will throw. - return null - } - - try { - let sheet = Stylesheet.loadSync(file) - - // Mutate incoming stylesheets to include the newly discovered sheet - stylesheets.push(sheet) - - // Queue up the processing of this stylesheet - processingQueue.push(() => processor.process(sheet.root, { from: sheet.file! })) - - return sheet - } catch { - return null - } - }) - - // Step 1: Record which `@import` rules point to which stylesheets - // and which stylesheets are parents/children of each other - let processor = postcss([ - { - postcssPlugin: 'mark-import-nodes', - AtRule: { - import(node) { - // Find what the import points to - let id = node.params.match(/['"](.*?)['"]/)?.[1] - if (!id) return - - let basePath = node.source?.input.file - ? path.dirname(node.source.input.file) - : process.cwd() - - // Resolve the import to a file path - let resolvedPath: string | false = false - try { - // We first try to resolve the file as relative to the current file - // to mimic the behavior of `postcss-import` since that's what was - // used to resolve imports in Tailwind CSS v3. - if (id[0] !== '.') { - try { - resolvedPath = resolveCssId(`./${id}`, basePath) - } catch {} - } - - if (!resolvedPath) { - resolvedPath = resolveCssId(id, basePath) - } - } catch (err) { - // Import is a URL, we don't want to process these, but also don't - // want to show an error message for them. - if (id.startsWith('http://') || id.startsWith('https://') || id.startsWith('//')) { - return - } - - // Something went wrong, we can't resolve the import. - error( - `Failed to resolve import: ${highlight(id)} in ${highlight(relative(node.source?.input.file!, basePath))}. Skipping.`, - { prefix: '↳ ' }, - ) - return - } - - if (!resolvedPath) return - - // Find the stylesheet pointing to the resolved path - let stylesheet = stylesheetsByFile.get(resolvedPath) - - // If it _does not_ exist in stylesheets we don't care and skip it - // this is likely because its in node_modules or a workspace package - // that we don't want to modify - if (!stylesheet) return - - // Mark the import node with the ID of the stylesheet it points to - // We will use these later to build lookup tables and modify the AST - node.raws.tailwind_destination_sheet_id = stylesheet.id - - let parent = node.source?.input.file - ? stylesheetsByFile.get(node.source.input.file) - : undefined - - let layers: string[] = [] - - for (let part of segment(node.params, ' ')) { - if (!part.startsWith('layer(')) continue - if (!part.endsWith(')')) continue - - layers.push(part.slice(6, -1).trim()) - } - - // Connect sheets together in a dependency graph - if (parent) { - let meta = { layers } - stylesheet.parents.add({ item: parent, meta }) - parent.children.add({ item: stylesheet, meta }) - } - }, - }, - }, - ]) - - // Seed the map with all the known stylesheets, and queue up the processing of - // each incoming stylesheet. - for (let sheet of stylesheets) { - if (sheet.file) { - stylesheetsByFile.set(sheet.file, sheet) - processingQueue.push(() => processor.process(sheet.root, { from: sheet.file ?? undefined })) - } - } - - // Process all the stylesheets from step 1 - while (processingQueue.length > 0) { - let task = processingQueue.shift()! - await task() - } - - // --- - - let commonPath = process.cwd() - - function pathToString(path: StylesheetConnection[]) { - let parts: string[] = [] - - for (let connection of path) { - if (!connection.item.file) continue - - let filePath = connection.item.file.replace(commonPath, '') - let layers = connection.meta.layers.join(', ') - - if (layers.length > 0) { - parts.push(`${filePath} (layers: ${layers})`) - } else { - parts.push(filePath) - } - } - - return parts.join(' <- ') - } - - let lines: string[] = [] - - for (let sheet of stylesheets) { - if (!sheet.file) continue - - let { convertiblePaths, nonConvertiblePaths } = sheet.analyzeImportPaths() - let isAmbiguous = convertiblePaths.length > 0 && nonConvertiblePaths.length > 0 - - if (!isAmbiguous) continue - - sheet.canMigrate = false - - let filePath = sheet.file.replace(commonPath, '') - - for (let path of convertiblePaths) { - lines.push(`- ${filePath} <- ${pathToString(path)}`) - } - - for (let path of nonConvertiblePaths) { - lines.push(`- ${filePath} <- ${pathToString(path)}`) - } - } - - if (lines.length === 0) { - let tailwindRootLeafs = new Set() - - for (let sheet of stylesheets) { - // If the current file already contains `@config`, then we can assume it's - // a Tailwind CSS root file. - sheet.root.walkAtRules('config', () => { - sheet.isTailwindRoot = true - return false - }) - if (sheet.isTailwindRoot) continue - - // If an `@tailwind` at-rule, or `@import "tailwindcss"` is present, - // then we can assume it's a file where Tailwind CSS might be configured. - // - // However, if 2 or more stylesheets exist with these rules that share a - // common parent, then we want to mark the common parent as the root - // stylesheet instead. - sheet.root.walkAtRules((node) => { - if ( - node.name === 'tailwind' || - (node.name === 'import' && node.params.match(/^["']tailwindcss["']/)) || - (node.name === 'import' && node.params.match(/^["']tailwindcss\/.*?["']$/)) - ) { - sheet.isTailwindRoot = true - tailwindRootLeafs.add(sheet) - } - }) - } - - // Only a single Tailwind CSS root file exists, no need to do anything else. - if (tailwindRootLeafs.size <= 1) { - return - } - - // Mark the common parent as the root file - { - // Group each sheet from tailwindRootLeafs by their common parent - let commonParents = new DefaultMap>(() => new Set()) - - // Seed common parents with leafs - for (let sheet of tailwindRootLeafs) { - commonParents.get(sheet).add(sheet) - } - - // If any 2 common parents come from the same tree, then all children of - // parent A and parent B will be moved to the parent of parent A and - // parent B. Parent A and parent B will be removed. - let repeat = true - repeat: while (repeat) { - repeat = false - - for (let [sheetA, childrenA] of commonParents) { - for (let [sheetB, childrenB] of commonParents) { - if (sheetA === sheetB) continue - - // Ancestors from self to root. Reversed order so we find the - // nearest common parent first - // - // Including self because if you compare a sheet with its parent, - // then the parent is still the common sheet between the two. In - // this case, the parent is the root file. - let ancestorsA = [sheetA].concat(Array.from(sheetA.ancestors()).reverse()) - let ancestorsB = [sheetB].concat(Array.from(sheetB.ancestors()).reverse()) - - for (let parentA of ancestorsA) { - for (let parentB of ancestorsB) { - if (parentA !== parentB) continue - - // Found the parent - let parent = parentA - - commonParents.delete(sheetA) - commonParents.delete(sheetB) - - for (let child of childrenA) { - commonParents.get(parent).add(child) - } - - for (let child of childrenB) { - commonParents.get(parent).add(child) - } - - // Found a common parent between sheet A and sheet B. We can - // stop looking for more common parents between A and B, and - // continue with the next sheet. - repeat = true - continue repeat - } - } - } - } - } - - // Mark the common parent as the Tailwind CSS root file, and remove the - // flag from each leaf. - for (let [parent, children] of commonParents) { - parent.isTailwindRoot = true - - for (let child of children) { - if (parent === child) continue - - child.isTailwindRoot = false - } - } - return - } - } - - { - let error = `You have one or more stylesheets that are imported into a utility layer and non-utility layer.\n` - error += `We cannot convert stylesheets under these conditions. Please look at the following stylesheets:\n` - - throw new Error(error + lines.join('\n')) - } -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/fixtures/test.css b/packages/@tailwindcss-upgrade/src/codemods/css/fixtures/test.css deleted file mode 100644 index a15c877ac..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/fixtures/test.css +++ /dev/null @@ -1,3 +0,0 @@ -.foo { - color: red; -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/format-nodes.test.ts b/packages/@tailwindcss-upgrade/src/codemods/css/format-nodes.test.ts deleted file mode 100644 index 9d6393a02..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/format-nodes.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import postcss, { type Plugin } from 'postcss' -import { expect, it } from 'vitest' -import { formatNodes } from './format-nodes' -import { sortBuckets } from './sort-buckets' - -function markPretty(): Plugin { - return { - postcssPlugin: '@tailwindcss/upgrade/mark-pretty', - OnceExit(root) { - root.walkAtRules('tw-format', (atRule) => { - atRule.raws.tailwind_pretty = true - }) - }, - } -} - -function migrate(input: string) { - return postcss() - .use(markPretty()) - .use(sortBuckets()) - .use(formatNodes()) - .process(input, { from: expect.getState().testPath }) - .then((result) => result.css) -} - -it('should format PostCSS nodes', async () => { - expect(await migrate(`@utility .foo { .foo { color: red; } }`)).toMatchInlineSnapshot(` - "@utility .foo { - .foo { - color: red; - } - }" - `) -}) - -it('should format PostCSS nodes in the `user` bucket', async () => { - expect(await migrate(`@tw-bucket user { @tw-format .bar { .foo { color: red; } } }`)) - .toMatchInlineSnapshot(` - "@tw-format .bar { - .foo { - color: red; - } - }" - `) -}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/format-nodes.ts b/packages/@tailwindcss-upgrade/src/codemods/css/format-nodes.ts deleted file mode 100644 index 489f2d349..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/format-nodes.ts +++ /dev/null @@ -1,84 +0,0 @@ -import postcss, { type ChildNode, type Plugin, type Root } from 'postcss' -import { format, type Options } from 'prettier' -import { walk } from '../../utils/walk' - -const FORMAT_OPTIONS: Options = { - parser: 'css', - semi: true, - singleQuote: true, -} - -// Prettier is used to generate cleaner output, but it's only used on the nodes -// that were marked as `pretty` during the migration. -export function formatNodes(): Plugin { - async function migrate(root: Root) { - // Find the nodes to format - let nodesToFormat: ChildNode[] = [] - walk(root, (child, _idx, parent) => { - // Always print semicolons after at-rules - if (child.type === 'atrule') { - child.raws.semicolon = true - } - - if (child.type === 'atrule' && child.name === 'tw-bucket') { - nodesToFormat.push(child) - } else if (child.raws.tailwind_pretty) { - // @ts-expect-error We might not have a parent - child.parent ??= parent - nodesToFormat.unshift(child) - } - }) - - let output: string[] = [] - - // Format the nodes - for (let node of nodesToFormat) { - let contents = (() => { - if (node.type === 'atrule' && node.name === 'tw-bucket') { - // Remove the `@tw-bucket` wrapping, and use the contents directly. - return node - .toString() - .trim() - .replace(/@tw-bucket(.*?){([\s\S]*)}/, '$2') - } - - return node.toString() - })() - - // Do not format the user bucket to ensure we keep the user's formatting - // intact. - if (node.type === 'atrule' && node.name === 'tw-bucket' && node.params === 'user') { - output.push(contents) - continue - } - - // Format buckets - if (node.type === 'atrule' && node.name === 'tw-bucket') { - output.push(await format(contents, FORMAT_OPTIONS)) - continue - } - - // Format any other nodes - node.replaceWith( - postcss.parse( - `${node.raws.before ?? ''}${(await format(contents, FORMAT_OPTIONS)).trim()}`, - ), - ) - } - - root.removeAll() - root.append( - postcss.parse( - output - .map((bucket) => bucket.trim()) - .filter(Boolean) - .join('\n\n'), - ), - ) - } - - return { - postcssPlugin: '@tailwindcss/upgrade/format-nodes', - OnceExit: migrate, - } -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/link.ts b/packages/@tailwindcss-upgrade/src/codemods/css/link.ts deleted file mode 100644 index 04c2dd770..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/link.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { normalizePath } from '@tailwindcss/node' -import path from 'node:path' -import postcss from 'postcss' -import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' -import { Stylesheet } from '../../stylesheet' -import { error, highlight, relative, success } from '../../utils/renderer' -import { detectConfigPath } from '../template/prepare-config' - -export async function linkConfigs( - stylesheets: Stylesheet[], - { configPath, base }: { configPath: string | null; base: string }, -) { - let rootStylesheets = stylesheets.filter((sheet) => sheet.isTailwindRoot) - if (rootStylesheets.length === 0) { - throw new Error( - `Cannot find any CSS files that reference Tailwind CSS.\nBefore your project can be upgraded you need to create a CSS file that imports Tailwind CSS or uses ${highlight('@tailwind')}.`, - ) - } - let withoutAtConfig = rootStylesheets.filter((sheet) => { - let hasConfig = false - sheet.root.walkAtRules('config', (node) => { - let configPath = path.resolve(path.dirname(sheet.file!), node.params.slice(1, -1)) - sheet.linkedConfigPath = configPath - hasConfig = true - return false - }) - return !hasConfig - }) - - // All stylesheets have a `@config` directives - if (withoutAtConfig.length === 0) return - - // Find the config file path for each stylesheet - let configPathBySheet = new Map() - let sheetByConfigPath = new DefaultMap>(() => new Set()) - for (let sheet of withoutAtConfig) { - if (!sheet.file) continue - - let localConfigPath = configPath as string - if (configPath === null) { - localConfigPath = await detectConfigPath(path.dirname(sheet.file), base) - } else if (!path.isAbsolute(localConfigPath)) { - localConfigPath = path.resolve(base, localConfigPath) - } - - configPathBySheet.set(sheet, localConfigPath) - sheetByConfigPath.get(localConfigPath).add(sheet) - } - - let problematicStylesheets = new Set() - for (let sheets of sheetByConfigPath.values()) { - if (sheets.size > 1) { - for (let sheet of sheets) { - problematicStylesheets.add(sheet) - } - } - } - - // There are multiple "root" files without `@config` directives. Manual - // intervention is needed to link to the correct Tailwind config files. - if (problematicStylesheets.size > 1) { - for (let sheet of problematicStylesheets) { - error( - `Could not determine configuration file for: ${highlight(relative(sheet.file!, base))}\nUpdate your stylesheet to use ${highlight('@config')} to specify the correct configuration file explicitly and then run the upgrade tool again.`, - { prefix: '↳ ' }, - ) - } - - process.exit(1) - } - - let relativePath = relative - for (let [sheet, configPath] of configPathBySheet) { - try { - if (!sheet || !sheet.file) return - success( - `Linked ${highlight(relativePath(configPath, base))} to ${highlight(relativePath(sheet.file, base))}`, - { prefix: '↳ ' }, - ) - - // Link the `@config` directive to the root stylesheets - - // Track the config file path on the stylesheet itself for easy access - // without traversing the CSS ast and finding the corresponding - // `@config` later. - sheet.linkedConfigPath = configPath - - // Create a relative path from the current file to the config file. - let relative = path.relative(path.dirname(sheet.file), configPath) - - // If the path points to a file in the same directory, `path.relative` will - // remove the leading `./` and we need to add it back in order to still - // consider the path relative - if (!relative.startsWith('.') && !path.isAbsolute(relative)) { - relative = './' + relative - } - - relative = normalizePath(relative) - - // Add the `@config` directive to the root stylesheet. - { - let target = sheet.root as postcss.Root | postcss.AtRule - let atConfig = postcss.atRule({ name: 'config', params: `'${relative}'` }) - - sheet.root.walkAtRules((node) => { - if (node.name === 'tailwind' || node.name === 'import') { - target = node - } - }) - - if (target.type === 'root') { - sheet.root.prepend(atConfig) - } else if (target.type === 'atrule') { - target.after(atConfig) - } - } - } catch (e: any) { - error('Could not load the configuration file: ' + e.message, { prefix: '↳ ' }) - process.exit(1) - } - } -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-apply.test.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-apply.test.ts deleted file mode 100644 index 9f6a92008..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-apply.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { __unstable__loadDesignSystem } from '@tailwindcss/node' -import dedent from 'dedent' -import postcss from 'postcss' -import { expect, it, vi } from 'vitest' -import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' -import * as versions from '../../utils/version' -import { migrateAtApply } from './migrate-at-apply' -vi.spyOn(versions, 'isMajor').mockReturnValue(true) - -const css = dedent - -async function migrate(input: string, config: Config = {}) { - let designSystem = await __unstable__loadDesignSystem( - css` - @import 'tailwindcss'; - - /* TODO(perf): Only here to speed up the tests */ - @theme { - --*: initial; - } - `, - { base: __dirname }, - ) - - return postcss() - .use( - migrateAtApply({ - designSystem, - userConfig: config, - }), - ) - .process(input, { from: expect.getState().testPath }) - .then((result) => result.css) -} - -it('should not migrate `@apply`, when there are no issues', async () => { - expect( - await migrate(css` - .foo { - @apply flex flex-col items-center; - } - `), - ).toMatchInlineSnapshot(` - ".foo { - @apply flex flex-col items-center; - }" - `) -}) - -it('should append `!` to each utility, when using `!important`', async () => { - expect( - await migrate(css` - .foo { - @apply flex flex-col !important; - } - `), - ).toMatchInlineSnapshot(` - ".foo { - @apply flex! flex-col!; - }" - `) -}) - -it('should move the legacy `!` prefix, to the new `!` postfix notation', async () => { - expect( - await migrate(css` - .foo { - @apply !flex flex-col! hover:!items-start items-center; - } - `), - ).toMatchInlineSnapshot(` - ".foo { - @apply flex! flex-col! hover:items-start! items-center; - }" - `) -}) - -it( - 'should apply all candidate migration when migrating with a config', - { timeout: 10_000 }, - async () => { - async function migrateWithPrefix(input: string) { - return postcss() - .use( - migrateAtApply({ - designSystem: await __unstable__loadDesignSystem( - css` - @import 'tailwindcss' prefix(tw); - - /* TODO(perf): Only here to speed up the tests */ - @theme { - --*: initial; - } - `, - { base: __dirname }, - ), - userConfig: { - prefix: 'tw_', - }, - }), - ) - .process(input, { from: expect.getState().testPath }) - .then((result) => result.css) - } - - expect( - await migrateWithPrefix(css` - .foo { - @apply !tw_flex [color:--my-color] tw_bg-gradient-to-t; - } - `), - ).toMatchInlineSnapshot(` - ".foo { - @apply tw:flex! tw:text-(--my-color) tw:bg-linear-to-t; - }" - `) - }, -) diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-apply.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-apply.ts deleted file mode 100644 index 69f92688d..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-apply.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { AtRule, Plugin } from 'postcss' -import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' -import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { segment } from '../../../../tailwindcss/src/utils/segment' -import { migrateCandidate } from '../template/migrate' - -export function migrateAtApply({ - designSystem, - userConfig, -}: { - designSystem: DesignSystem | null - userConfig: Config | null -}): Plugin { - function migrate(atRule: AtRule) { - let utilities = atRule.params.split(/(\s+)/) - let important = - utilities[utilities.length - 1] === '!important' || - utilities[utilities.length - 1] === '#{!important}' // Sass/SCSS - - if (important) utilities.pop() // Remove `!important` - - let params = utilities.map((part) => { - // Keep whitespace - if (part.trim() === '') return part - let variants = segment(part, ':') - let utility = variants.pop()! - - // Apply the important modifier to all the rules if necessary - if (important && utility[0] !== '!' && utility[utility.length - 1] !== '!') { - utility += '!' - } - - // Reconstruct the utility with the variants - return [...variants, utility].join(':') - }) - - return async () => { - if (!designSystem) return - - // If we have a valid designSystem and config setup, we can run all - // candidate migrations on each utility - params = await Promise.all( - params.map(async (param) => await migrateCandidate(designSystem, userConfig, param)), - ) - - atRule.params = params.join('').trim() - } - } - - return { - postcssPlugin: '@tailwindcss/upgrade/migrate-at-apply', - async OnceExit(root) { - let migrations: (() => void)[] = [] - root.walkAtRules('apply', (atRule) => { - migrations.push(migrate(atRule)) - }) - - await Promise.allSettled(migrations.map((m) => m())) - }, - } -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-layer-utilities.test.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-layer-utilities.test.ts deleted file mode 100644 index e115951d3..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-layer-utilities.test.ts +++ /dev/null @@ -1,1178 +0,0 @@ -import dedent from 'dedent' -import postcss from 'postcss' -import { describe, expect, it, vi } from 'vitest' -import { Stylesheet } from '../../stylesheet' -import * as versions from '../../utils/version' -import { formatNodes } from './format-nodes' -import { migrateAtLayerUtilities } from './migrate-at-layer-utilities' -import { sortBuckets } from './sort-buckets' -vi.spyOn(versions, 'isMajor').mockReturnValue(true) - -const css = dedent - -async function migrate( - data: - | string - | { - root: postcss.Root - layers?: string[] - }, -) { - let stylesheet: Stylesheet - - if (typeof data === 'string') { - stylesheet = await Stylesheet.fromString(data) - } else { - stylesheet = await Stylesheet.fromRoot(data.root) - - if (data.layers) { - let meta = { layers: data.layers } - let parent = await Stylesheet.fromString('.placeholder {}') - - stylesheet.parents.add({ item: parent, meta }) - parent.children.add({ item: stylesheet, meta }) - } - } - - return postcss() - .use(migrateAtLayerUtilities(stylesheet)) - .use(sortBuckets()) - .use(formatNodes()) - .process(stylesheet.root!, { from: expect.getState().testPath }) - .then((result) => `\n${result.css.trim()}\n`) -} - -it('should migrate simple `@layer utilities` to `@utility`', async () => { - expect( - await migrate(css` - @layer utilities { - .foo { - color: red; - } - } - `), - ).toMatchInlineSnapshot(` - " - @utility foo { - color: red; - } - " - `) -}) - -it('should split multiple selectors in separate utilities', async () => { - expect( - await migrate(css` - @layer utilities { - .foo, - .bar { - color: red; - } - } - `), - ).toMatchInlineSnapshot(` - " - @utility foo { - color: red; - } - - @utility bar { - color: red; - } - " - `) -}) - -it('should merge `@utility` with the same name', async () => { - expect( - await migrate(css` - @layer utilities { - .foo { - color: red; - } - } - - .bar { - color: blue; - } - - @layer utilities { - .foo { - font-weight: bold; - } - } - `), - ).toMatchInlineSnapshot(` - " - @utility foo { - color: red; - font-weight: bold; - } - - .bar { - color: blue; - } - " - `) -}) - -it('should leave non-class utilities alone', async () => { - expect( - await migrate(css` - @layer utilities { - /* 1. */ - #before { - /* 1.1. */ - color: red; - /* 1.2. */ - .bar { - /* 1.2.1. */ - font-weight: bold; - } - } - - /* 2. */ - .foo { - /* 2.1. */ - color: red; - /* 2.2. */ - .bar { - /* 2.2.1. */ - font-weight: bold; - } - } - - /* 3. */ - #after { - /* 3.1. */ - color: blue; - /* 3.2. */ - .bar { - /* 3.2.1. */ - font-weight: bold; - } - } - } - `), - ).toMatchInlineSnapshot(` - " - @utility foo { - /* 2. */ - /* 2.1. */ - color: red; - /* 2.2. */ - .bar { - /* 2.2.1. */ - font-weight: bold; - } - } - - @layer utilities { - /* 1. */ - #before { - /* 1.1. */ - color: red; - /* 1.2. */ - .bar { - /* 1.2.1. */ - font-weight: bold; - } - } - - /* 3. */ - #after { - /* 3.1. */ - color: blue; - /* 3.2. */ - .bar { - /* 3.2.1. */ - font-weight: bold; - } - } - } - " - `) -}) - -it('should not convert empty classes into `@utility` since that would make them invalid', async () => { - expect( - await migrate(css` - @layer utilities { - .good { - color: red; - } - - .bad-empty { - } - - /* Comment above */ - .bad-with-comment-above { - } - - .bad-with-comment-inside { - /* Comment inside */ - } - - .bad-with-comment-below { - } - /* Comment below */ - } - `), - ).toMatchInlineSnapshot(` - " - @utility good { - color: red; - } - - @layer utilities { - .bad-empty { - } - - /* Comment above */ - .bad-with-comment-above { - } - - .bad-with-comment-inside { - /* Comment inside */ - } - - .bad-with-comment-below { - } - /* Comment below */ - } - " - `) -}) - -it('should migrate simple `@layer utilities` with nesting to `@utility`', async () => { - expect( - await migrate(css` - @layer utilities { - .foo { - color: red; - - &:hover { - color: blue; - } - - &:focus { - color: green; - } - } - } - `), - ).toMatchInlineSnapshot(` - " - @utility foo { - color: red; - - &:hover { - color: blue; - } - - &:focus { - color: green; - } - } - " - `) -}) - -it('should migrate multiple simple `@layer utilities` to `@utility`', async () => { - expect( - await migrate(css` - @layer utilities { - .foo { - color: red; - } - - .bar { - color: blue; - } - } - `), - ).toMatchInlineSnapshot(` - " - @utility foo { - color: red; - } - - @utility bar { - color: blue; - } - " - `) -}) - -it('should not migrate Rules inside of Rules to a `@utility`', async () => { - expect( - await migrate(css` - @layer utilities { - .foo { - color: red; - } - - .bar { - color: blue; - - .baz { - color: green; - } - } - } - `), - ).toMatchInlineSnapshot(` - " - @utility foo { - color: red; - } - - @utility bar { - color: blue; - - .baz { - color: green; - } - } - " - `) -}) - -it('should invert at-rules to make them migrate-able', async () => { - expect( - await migrate(css` - @layer utilities { - @media (min-width: 640px) { - .foo { - color: red; - } - } - } - `), - ).toMatchInlineSnapshot(` - " - @utility foo { - @media (min-width: 640px) { - color: red; - } - } - " - `) -}) - -it('should migrate at-rules with multiple utilities and invert them', async () => { - expect( - await migrate(css` - @layer utilities { - @media (min-width: 640px) { - .foo { - color: red; - } - } - } - - @layer utilities { - @media (min-width: 640px) { - .bar { - color: blue; - } - } - } - `), - ).toMatchInlineSnapshot(` - " - @utility foo { - @media (min-width: 640px) { - color: red; - } - } - - @utility bar { - @media (min-width: 640px) { - color: blue; - } - } - " - `) -}) - -it('should migrate deeply nested at-rules with multiple utilities and invert them', async () => { - expect( - await migrate(css` - @layer utilities { - @media (min-width: 640px) { - .foo { - color: red; - } - - .bar { - color: blue; - } - - @media (min-width: 1024px) { - .baz { - color: green; - } - - @media (min-width: 1280px) { - .qux { - color: yellow; - } - } - } - } - } - `), - ).toMatchInlineSnapshot(` - " - @utility foo { - @media (min-width: 640px) { - color: red; - } - } - - @utility bar { - @media (min-width: 640px) { - color: blue; - } - } - - @utility baz { - @media (min-width: 640px) { - @media (min-width: 1024px) { - color: green; - } - } - } - - @utility qux { - @media (min-width: 640px) { - @media (min-width: 1024px) { - @media (min-width: 1280px) { - color: yellow; - } - } - } - } - " - `) -}) - -it('should migrate classes with pseudo elements', async () => { - expect( - await migrate(css` - @layer utilities { - .no-scrollbar::-webkit-scrollbar { - display: none; - } - } - `), - ).toMatchInlineSnapshot(` - " - @utility no-scrollbar { - &::-webkit-scrollbar { - display: none; - } - } - " - `) -}) - -it('should migrate classes with attribute selectors', async () => { - expect( - await migrate(css` - @layer utilities { - .no-scrollbar[data-checked=''] { - display: none; - } - } - `), - ).toMatchInlineSnapshot(` - " - @utility no-scrollbar { - &[data-checked=''] { - display: none; - } - } - " - `) -}) - -it('should migrate classes with element selectors', async () => { - expect( - await migrate(css` - @layer utilities { - .no-scrollbar main { - display: none; - } - } - `), - ).toMatchInlineSnapshot(` - " - @utility no-scrollbar { - & main { - display: none; - } - } - " - `) -}) - -it('should migrate classes attached to an element selector', async () => { - expect( - await migrate(css` - @layer utilities { - main.no-scrollbar { - display: none; - } - } - `), - ).toMatchInlineSnapshot(` - " - @utility no-scrollbar { - &main { - display: none; - } - } - " - `) -}) - -it('should migrate classes with id selectors', async () => { - expect( - await migrate(css` - @layer utilities { - .no-scrollbar#main { - display: none; - } - } - `), - ).toMatchInlineSnapshot(` - " - @utility no-scrollbar { - &#main { - display: none; - } - } - " - `) -}) - -it('should migrate classes with another attached class', async () => { - expect( - await migrate(css` - @layer utilities { - .no-scrollbar.main { - display: none; - } - } - `), - ).toMatchInlineSnapshot(` - " - @utility no-scrollbar { - &.main { - display: none; - } - } - - @utility main { - &.no-scrollbar { - display: none; - } - } - " - `) -}) - -it('should migrate a selector with multiple classes to multiple @utility definitions', async () => { - expect( - await migrate(css` - @layer utilities { - .foo .bar:hover .baz:focus { - display: none; - } - } - `), - ).toMatchInlineSnapshot(` - " - @utility foo { - & .bar:hover .baz:focus { - display: none; - } - } - - @utility bar { - .foo &:hover .baz:focus { - display: none; - } - } - - @utility baz { - .foo .bar:hover &:focus { - display: none; - } - } - " - `) -}) - -it('should merge `@utility` definitions with the same name', async () => { - expect( - await migrate(css` - @layer utilities { - .step { - counter-increment: step; - } - - .step:before { - @apply absolute w-7 h-7 bg-default-100 rounded-full font-medium text-center text-base inline-flex items-center justify-center -indent-px; - @apply ml-[-41px]; - content: counter(step); - } - } - `), - ).toMatchInlineSnapshot(` - " - @utility step { - counter-increment: step; - - &:before { - @apply absolute w-7 h-7 bg-default-100 rounded-full font-medium text-center text-base inline-flex items-center justify-center -indent-px; - @apply ml-[-41px]; - content: counter(step); - } - } - " - `) -}) - -it('should not migrate nested classes inside a `:not(…)`', async () => { - expect( - await migrate(css` - @layer utilities { - .foo .bar:not(.qux):has(.baz) { - display: none; - } - } - `), - ).toMatchInlineSnapshot(` - " - @utility foo { - & .bar:not(.qux):has(.baz) { - display: none; - } - } - - @utility bar { - .foo &:not(.qux):has(.baz) { - display: none; - } - } - - @utility baz { - .foo .bar:not(.qux):has(&) { - display: none; - } - } - " - `) -}) - -it('should migrate advanced combinations', async () => { - expect( - await migrate(css` - @layer utilities { - @media (width >= 100px) { - @supports (display: none) { - .foo .bar:not(.qux):has(.baz) { - display: none; - } - } - - .bar { - color: red; - } - } - - @media (width >= 200px) { - .foo { - &:hover { - @apply bg-red-500; - - .bar { - color: red; - } - } - } - } - } - `), - ).toMatchInlineSnapshot(` - " - @utility foo { - @media (width >= 100px) { - @supports (display: none) { - & .bar:not(.qux):has(.baz) { - display: none; - } - } - } - - @media (width >= 200px) { - &:hover { - @apply bg-red-500; - - .bar { - color: red; - } - } - } - } - - @utility bar { - @media (width >= 100px) { - @supports (display: none) { - .foo &:not(.qux):has(.baz) { - display: none; - } - } - color: red; - } - } - - @utility baz { - @media (width >= 100px) { - @supports (display: none) { - .foo .bar:not(.qux):has(&) { - display: none; - } - } - } - } - " - `) -}) - -describe('comments', () => { - it('should preserve comment location for a simple utility', async () => { - expect( - await migrate(css` - /* Start of utilities: */ - @layer utilities { - /* Utility #1 */ - .foo { - /* Declarations: */ - color: red; - } - } - `), - ).toMatchInlineSnapshot(` - " - /* Start of utilities: */ - - @utility foo { - /* Utility #1 */ - /* Declarations: */ - color: red; - } - " - `) - }) - - it('should copy comments when creating multiple utilities from a single selector', async () => { - expect( - await migrate(css` - /* Start of utilities: */ - @layer utilities { - /* Foo & Bar */ - .foo .bar { - /* Declarations: */ - color: red; - } - } - `), - ).toMatchInlineSnapshot(` - " - /* Start of utilities: */ - - @utility foo { - /* Foo & Bar */ - & .bar { - /* Declarations: */ - color: red; - } - } - - @utility bar { - /* Foo & Bar */ - .foo & { - /* Declarations: */ - color: red; - } - } - " - `) - }) - - it('should preserve comments for utilities wrapped in at-rules', async () => { - expect( - await migrate(css` - /* Start of utilities: */ - @layer utilities { - /* Mobile only */ - @media (width <= 640px) { - /* Utility #1 */ - .foo { - /* Declarations: */ - color: red; - } - } - } - `), - ).toMatchInlineSnapshot(` - " - /* Start of utilities: */ - - @utility foo { - /* Mobile only */ - @media (width <= 640px) { - /* Utility #1 */ - /* Declarations: */ - color: red; - } - } - " - `) - }) - - it('should preserve comment locations as best as possible', async () => { - expect( - await migrate(css` - /* Above */ - .before { - /* Inside */ - } - /* After */ - - /* Tailwind Utilities: */ - @layer utilities { - /* Chrome, Safari and Opera */ - /* Second comment */ - @media (min-width: 640px) { - /* Foobar */ - .no-scrollbar::-webkit-scrollbar { - display: none; - } - } - - /* Firefox, IE and Edge */ - /* Second comment */ - .no-scrollbar { - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ - } - } - - /* Above */ - .after { - /* Inside */ - } - /* After */ - `), - ).toMatchInlineSnapshot(` - " - /* Tailwind Utilities: */ - - @utility no-scrollbar { - /* Chrome, Safari and Opera */ - /* Second comment */ - @media (min-width: 640px) { - /* Foobar */ - &::-webkit-scrollbar { - display: none; - } - } - - /* Firefox, IE and Edge */ - /* Second comment */ - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ - } - - /* Above */ - .before { - /* Inside */ - } - /* After */ - - /* Above */ - .after { - /* Inside */ - } - /* After */ - " - `) - }) -}) - -// Saw this when testing codemods on https://github.com/docker/docs -it('should not lose attribute selectors', async () => { - expect( - await migrate(css` - @layer components { - #TableOfContents { - .toc a { - @apply block max-w-full truncate py-1 pl-2 hover:font-medium hover:no-underline; - &[aria-current='true'], - &:hover { - @apply border-l-2 border-l-gray-light bg-gradient-to-r from-gray-light-100 font-medium text-black dark:border-l-gray-dark dark:from-gray-dark-200 dark:text-white; - } - &:not([aria-current='true']) { - @apply text-gray-light-600 hover:text-black dark:text-gray-dark-700 dark:hover:text-white; - } - } - } - } - `), - ).toMatchInlineSnapshot(` - " - @layer components { - #TableOfContents { - .toc a { - @apply block max-w-full truncate py-1 pl-2 hover:font-medium hover:no-underline; - &[aria-current='true'], - &:hover { - @apply border-l-2 border-l-gray-light bg-gradient-to-r from-gray-light-100 font-medium text-black dark:border-l-gray-dark dark:from-gray-dark-200 dark:text-white; - } - &:not([aria-current='true']) { - @apply text-gray-light-600 hover:text-black dark:text-gray-dark-700 dark:hover:text-white; - } - } - } - } - " - `) -}) - -describe('layered stylesheets', () => { - it('should transform classes to utilities inside a layered stylesheet (utilities)', async () => { - expect( - await migrate({ - root: postcss.parse(css` - /* Utility #1 */ - .foo { - /* Declarations: */ - color: red; - } - `), - layers: ['utilities'], - }), - ).toMatchInlineSnapshot(` - " - @utility foo { - /* Utility #1 */ - /* Declarations: */ - color: red; - } - " - `) - }) - - it('should transform classes to utilities inside a layered stylesheet (components)', async () => { - expect( - await migrate({ - root: postcss.parse(css` - /* Utility #1 */ - .foo { - /* Declarations: */ - color: red; - } - `), - layers: ['components'], - }), - ).toMatchInlineSnapshot(` - " - @utility foo { - /* Utility #1 */ - /* Declarations: */ - color: red; - } - " - `) - }) - - it('should NOT transform classes to utilities inside a non-utility, layered stylesheet', async () => { - expect( - await migrate({ - root: postcss.parse(css` - /* Utility #1 */ - .foo { - /* Declarations: */ - color: red; - } - `), - layers: ['foo'], - }), - ).toMatchInlineSnapshot(` - " - /* Utility #1 */ - .foo { - /* Declarations: */ - color: red; - } - " - `) - }) - - it('should handle non-classes in utility-layered stylesheets', async () => { - expect( - await migrate({ - root: postcss.parse(css` - /* Utility #1 */ - .foo { - /* Declarations: */ - color: red; - } - #main { - color: red; - } - `), - layers: ['utilities'], - }), - ).toMatchInlineSnapshot(` - " - @utility foo { - /* Utility #1 */ - /* Declarations: */ - color: red; - } - - #main { - color: red; - } - " - `) - }) - - it('should handle non-classes in utility-layered stylesheets', async () => { - expect( - await migrate({ - root: postcss.parse(css` - @layer utilities { - @layer utilities { - /* Utility #1 */ - .foo { - /* Declarations: */ - color: red; - } - } - - /* Utility #2 */ - .bar { - /* Declarations: */ - color: red; - } - - #main { - color: red; - } - } - - /* Utility #3 */ - .baz { - /* Declarations: */ - color: red; - } - - #secondary { - color: red; - } - `), - layers: ['utilities'], - }), - ).toMatchInlineSnapshot(` - " - @utility foo { - @layer utilities { - @layer utilities { - /* Utility #1 */ - /* Declarations: */ - color: red; - } - } - } - - @utility bar { - @layer utilities { - /* Utility #2 */ - /* Declarations: */ - color: red; - } - } - - @utility baz { - /* Utility #3 */ - /* Declarations: */ - color: red; - } - - @layer utilities { - - #main { - color: red; - } - } - - #secondary { - color: red; - } - " - `) - }) - - it('imports are preserved in layered stylesheets', async () => { - expect( - await migrate({ - root: postcss.parse(css` - @import 'thing'; - - .foo { - color: red; - } - `), - layers: ['utilities'], - }), - ).toMatchInlineSnapshot(` - " - @import 'thing'; - - @utility foo { - color: red; - } - " - `) - }) - - it('charset is preserved in layered stylesheets', async () => { - expect( - await migrate({ - root: postcss.parse(css` - @charset "utf-8"; - - .foo { - color: red; - } - `), - layers: ['utilities'], - }), - ).toMatchInlineSnapshot(` - " - @charset "utf-8"; - - @utility foo { - color: red; - } - " - `) - }) -}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-layer-utilities.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-layer-utilities.ts deleted file mode 100644 index 6bcb72d47..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-layer-utilities.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { type AtRule, type Comment, type Container, type Plugin, type Rule } from 'postcss' -import SelectorParser from 'postcss-selector-parser' -import { segment } from '../../../../tailwindcss/src/utils/segment' -import { Stylesheet } from '../../stylesheet' -import * as version from '../../utils/version' -import { walk, WalkAction, walkDepth } from '../../utils/walk' - -export function migrateAtLayerUtilities(stylesheet: Stylesheet): Plugin { - function migrate(atRule: AtRule) { - // Migrating `@layer utilities` to `@utility` is only supported in Tailwind - // CSS v3 projects. Tailwind CSS v4 projects could also have `@layer - // utilities` but those aren't actual utilities. - if (!version.isMajor(3)) return - - // Only migrate `@layer utilities` and `@layer components`. - if (atRule.params !== 'utilities' && atRule.params !== 'components') return - - // Keep rules that should not be turned into utilities as is. This will - // include rules with element or ID selectors. - let defaultsAtRule = atRule.clone() - - // Clone each rule with multiple selectors into their own rule with a single - // selector. - walk(atRule, (node) => { - if (node.type !== 'rule') return - - // Clone the node for each selector - let selectors = segment(node.selector, ',') - if (selectors.length > 1) { - let clonedNodes: Rule[] = [] - for (let selector of selectors) { - let clone = node.clone({ selector }) - clonedNodes.push(clone) - } - node.replaceWith(clonedNodes) - } - - return WalkAction.Skip - }) - - // Track all the classes that we want to create an `@utility` for. - let classes = new Set() - - walk(atRule, (node) => { - if (node.type !== 'rule') return - if (isEmpty(node)) return - - // Find all the classes in the selector - SelectorParser((selectors) => { - selectors.each((selector) => { - walk(selector, (selectorNode) => { - // Ignore everything in `:not(…)` - if (selectorNode.type === 'pseudo' && selectorNode.value === ':not') { - return WalkAction.Skip - } - - if (selectorNode.type === 'class') { - classes.add(selectorNode.value) - } - }) - }) - }).processSync(node.selector, { updateSelector: false }) - - return WalkAction.Skip - }) - - // Remove all the nodes from the default `@layer utilities` that we know - // should be turned into `@utility` at-rules. - walk(defaultsAtRule, (node) => { - if (node.type !== 'rule') return - - SelectorParser((selectors) => { - selectors.each((selector) => { - walk(selector, (selectorNode) => { - // Ignore everything in `:not(…)` - if (selectorNode.type === 'pseudo' && selectorNode.value === ':not') { - return WalkAction.Skip - } - - // Remove the node if the class is in the list - if (selectorNode.type === 'class' && classes.has(selectorNode.value)) { - node.remove() - return WalkAction.Stop - } - }) - }) - }).processSync(node, { updateSelector: true }) - }) - - // Upgrade every Rule in `@layer utilities` to an `@utility` at-rule. - let clones: AtRule[] = [defaultsAtRule] - for (let cls of classes) { - let clone = atRule.clone() - clones.push(clone) - - walk(clone, (node) => { - if (node.type === 'atrule') { - if (!node.nodes || node.nodes?.length === 0) { - node.remove() - } - } - - if (node.type !== 'rule') return - - // Fan out each utility into its own rule. - // - // E.g.: - // ```css - // .foo .bar:hover .baz { - // color: red; - // } - // ``` - // - // Becomes: - // ```css - // @utility foo { - // & .bar:hover .baz { - // color: red; - // } - // } - // - // @utility bar { - // .foo &:hover .baz { - // color: red; - // } - // } - // - // @utility baz { - // .foo .bar:hover & { - // color: red; - // } - // } - // ``` - let containsClass = false - SelectorParser((selectors) => { - selectors.each((selector) => { - walk(selector, (selectorNode) => { - // Ignore everything in `:not(…)` - if (selectorNode.type === 'pseudo' && selectorNode.value === ':not') { - return WalkAction.Skip - } - - // Replace the class with `&` and track the new selector - if (selectorNode.type === 'class' && selectorNode.value === cls) { - containsClass = true - - // Find the node in the clone based on the position of the - // original node. - let target = selector.atPosition( - selectorNode.source!.start!.line, - selectorNode.source!.start!.column, - ) - - // Keep moving the target to the front until we hit the start or - // find a combinator. This is to prevent `.foo.bar` from - // becoming `.bar&`. Instead we want `&.bar`. - let parent = target.parent! - let idx = (target.parent?.index(target) ?? 0) - 1 - while (idx >= 0 && parent.at(idx)?.type !== 'combinator') { - let current = parent.at(idx + 1) - let previous = parent.at(idx) - parent.at(idx + 1).replaceWith(previous) - parent.at(idx).replaceWith(current) - - idx-- - } - - // Replace the class with `&` - target.replaceWith(SelectorParser.nesting()) - } - }) - }) - }).processSync(node, { updateSelector: true }) - - // Cleanup all the nodes that should not be part of the `@utility` rule. - if (!containsClass) { - let toRemove: (Comment | Rule)[] = [node] - let idx = node.parent?.index(node) ?? null - if (idx !== null) { - for (let i = idx - 1; i >= 0; i--) { - if (node.parent?.nodes.at(i)?.type === 'rule') { - break - } - if (node.parent?.nodes.at(i)?.type === 'comment') { - toRemove.push(node.parent?.nodes.at(i) as Comment) - } - } - - let commentsAfter: Comment[] = [] - for (let i = idx + 1; i < (node.parent?.nodes.length ?? 0); i++) { - if (node.parent?.nodes.at(i)?.type === 'rule') { - commentsAfter = [] - break - } - if (node.parent?.nodes.at(i)?.type === 'comment') { - commentsAfter.push(node.parent?.nodes.at(i) as Comment) - } - } - toRemove.push(...commentsAfter) - } - for (let node of toRemove) { - node.remove() - } - } - - return WalkAction.Skip - }) - - // Migrate the `@layer utilities` to `@utility ` - clone.name = 'utility' - clone.params = cls - - clone.raws.before = `${clone.raws.before ?? ''}\n\n` - } - - // Cleanup - for (let idx = clones.length - 1; idx >= 0; idx--) { - let clone = clones[idx] - - walkDepth(clone, (node) => { - // Remove comments from the main `@layer utilities` we want to keep, - // that are part of any of the other clones. - if (clone === defaultsAtRule) { - if (node.type === 'comment') { - let found = false - for (let other of clones) { - if (other === defaultsAtRule) continue - - walk(other, (child) => { - if ( - child.type === 'comment' && - child.source?.start?.offset === node.source?.start?.offset - ) { - node.remove() - found = true - return WalkAction.Stop - } - }) - - if (found) { - return WalkAction.Skip - } - } - } - } - - // Remove empty rules from `@utility` clones and empty wrapper at-rules - // from the default `@layer` clone. - if ( - (node.type === 'atrule' && node.nodes?.length === 0) || - (clone !== defaultsAtRule && node.type === 'rule' && node.nodes?.length === 0) - ) { - node.remove() - } - - // Replace `&` selectors with its children - else if (node.type === 'rule' && node.selector === '&') { - interface PostCSSNode { - type: string - parent?: PostCSSNode - } - - let parent: PostCSSNode | undefined = node.parent - let skip = false - while (parent) { - if (parent.type === 'rule') { - skip = true - break - } - - parent = parent.parent - } - - if (!skip) node.replaceWith(node.nodes) - } - }) - - // Remove empty clones entirely - if (clone.nodes?.length === 0) { - clones.splice(idx, 1) - } else if (clone === defaultsAtRule) { - let first = clone.nodes?.[0] - if (first) { - first.raws.before = first.raws.before?.replace(/\n\s*\n/g, '\n') - } - } - } - - // Finally, replace the original `@layer utilities` with the new rules. - atRule.replaceWith(clones) - } - - return { - postcssPlugin: '@tailwindcss/upgrade/migrate-at-layer-utilities', - OnceExit: (root, { atRule }) => { - let layers = stylesheet.layers() - let isUtilityStylesheet = layers.has('utilities') || layers.has('components') - - if (isUtilityStylesheet) { - let rule = atRule({ name: 'layer', params: 'utilities' }) - rule.append(root.nodes) - root.append(rule) - } - - // Migrate `@layer utilities` and `@layer components` into `@utility`. - // Using this instead of the visitor API in case we want to use - // postcss-nesting in the future. - root.walkAtRules('layer', migrate) - - // Merge `@utility ` with the same name into a single rule. This can - // happen when the same classes is used in multiple `@layer utilities` - // blocks. - { - let utilities = new Map() - walk(root, (child) => { - if (child.type === 'atrule' && child.name === 'utility') { - let existing = utilities.get(child.params) - if (existing) { - existing.append(child.nodes!) - child.remove() - } else { - utilities.set(child.params, child) - } - } - }) - } - - // If the stylesheet is inside a layered import then we can remove the top-level layer directive we added - if (isUtilityStylesheet) { - root.each((node) => { - if (node.type !== 'atrule') return - if (node.name !== 'layer') return - if (node.params !== 'utilities') return - - node.replaceWith(node.nodes ?? []) - }) - } - }, - } -} - -function isEmpty(node: Container): boolean { - // We have no body - if (node.nodes === undefined) return false - - // We have a body, but no nodes - if (node.nodes.length === 0) return true - - // We have nodes, but they are only comments which we consider empty - if (node.nodes.every((child) => child.type === 'comment')) return true - - // Not empty - return false -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-config.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-config.ts deleted file mode 100644 index 2004d0969..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-config.ts +++ /dev/null @@ -1,110 +0,0 @@ -import path from 'node:path' -import postcss, { AtRule, type Plugin } from 'postcss' -import { normalizePath } from '../../../../@tailwindcss-node/src/normalize-path' -import type { Stylesheet } from '../../stylesheet' -import type { JSConfigMigration } from '../config/migrate-js-config' - -const ALREADY_INJECTED = new WeakMap() - -export function migrateConfig( - sheet: Stylesheet, - { - configFilePath, - jsConfigMigration, - }: { configFilePath: string | null; jsConfigMigration: JSConfigMigration | null }, -): Plugin { - function migrate() { - if (!sheet.isTailwindRoot) return - if (!configFilePath) return - - let alreadyInjected = ALREADY_INJECTED.get(sheet) - if (alreadyInjected && alreadyInjected.includes(configFilePath)) { - return - } else if (alreadyInjected) { - alreadyInjected.push(configFilePath) - } else { - ALREADY_INJECTED.set(sheet, [configFilePath]) - } - - let root = sheet.root - - // We don't have a sheet with a file path - if (!sheet.file) return - - let cssConfig = new AtRule() - - // Remove the `@config` directive if it exists and we couldn't migrate the - // config file. - if (jsConfigMigration !== null) { - root.walkAtRules('config', (node) => { - node.remove() - }) - - let css = '\n\n' - css += '\n@tw-bucket source {' - for (let source of jsConfigMigration.sources) { - let absolute = path.resolve(source.base, source.pattern) - css += `@source '${relativeToStylesheet(sheet, absolute)}';\n` - } - css += '}\n' - - css += '\n@tw-bucket plugin {\n' - for (let plugin of jsConfigMigration.plugins) { - let relative = - plugin.path[0] === '.' - ? relativeToStylesheet(sheet, path.resolve(plugin.base, plugin.path)) - : plugin.path - - if (plugin.options === null) { - css += `@plugin '${relative}';\n` - } else { - css += `@plugin '${relative}' {\n` - for (let [property, value] of Object.entries(plugin.options)) { - let cssValue = '' - if (typeof value === 'string') { - cssValue = quoteString(value) - } else if (Array.isArray(value)) { - cssValue = value - .map((v) => (typeof v === 'string' ? quoteString(v) : '' + v)) - .join(', ') - } else { - cssValue = '' + value - } - - css += ` ${property}: ${cssValue};\n` - } - css += '}\n' // @plugin - } - } - css += '}\n' // @tw-bucket - - cssConfig.append(postcss.parse(css + jsConfigMigration.css)) - } - - // Inject the `@config` directive - root.append(cssConfig.nodes) - } - - return { - postcssPlugin: '@tailwindcss/upgrade/migrate-config', - OnceExit: migrate, - } -} - -function relativeToStylesheet(sheet: Stylesheet, absolute: string) { - if (!sheet.file) throw new Error('Can not find a path for the stylesheet') - - let sheetPath = sheet.file - - let relative = path.relative(path.dirname(sheetPath), absolute) - if (relative[0] !== '.') { - relative = `./${relative}` - } - // Ensure relative is a POSIX style path since we will merge it with the - // glob. - return normalizePath(relative) -} - -function quoteString(value: string): string { - return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-import.test.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-import.test.ts deleted file mode 100644 index 4fba6de52..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-import.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import dedent from 'dedent' -import postcss from 'postcss' -import { expect, it } from 'vitest' -import { migrateImport } from './migrate-import' - -const css = dedent - -async function migrate(input: string) { - return postcss() - .use(migrateImport()) - .process(input, { from: expect.getState().testPath }) - .then((result) => result.css) -} - -it('prints relative file imports as relative paths', async () => { - expect( - await migrate(css` - @import url('https://example.com'); - - @import 'fixtures/test'; - @import 'fixtures/test.css'; - @import './fixtures/test.css'; - @import './fixtures/test'; - - @import 'fixtures/test' screen; - @import 'fixtures/test.css' screen; - @import './fixtures/test.css' screen; - @import './fixtures/test' screen; - - @import 'fixtures/test' supports(display: grid); - @import 'fixtures/test.css' supports(display: grid); - @import './fixtures/test.css' supports(display: grid); - @import './fixtures/test' supports(display: grid); - - @import 'fixtures/test' layer(utilities); - @import 'fixtures/test.css' layer(utilities); - @import './fixtures/test.css' layer(utilities); - @import './fixtures/test' layer(utilities); - - @import 'fixtures/test' theme(inline); - @import 'fixtures/test.css' theme(inline); - @import './fixtures/test.css' theme(inline); - @import './fixtures/test' theme(inline); - - @import 'fixtures/test' layer(utilities) supports(display: grid) screen and (min-width: 600px); - @import 'fixtures/test.css' layer(utilities) supports(display: grid) screen and - (min-width: 600px); - @import './fixtures/test.css' layer(utilities) supports(display: grid) screen and - (min-width: 600px); - @import './fixtures/test' layer(utilities) supports(display: grid) screen and - (min-width: 600px); - - @import 'tailwindcss'; - @import 'tailwindcss/theme.css'; - @import 'tailwindcss/theme'; - `), - ).toMatchInlineSnapshot(` - "@import url('https://example.com'); - - @import './fixtures/test.css'; - @import './fixtures/test.css'; - @import './fixtures/test.css'; - @import './fixtures/test.css'; - - @import './fixtures/test.css' screen; - @import './fixtures/test.css' screen; - @import './fixtures/test.css' screen; - @import './fixtures/test.css' screen; - - @import './fixtures/test.css' supports(display: grid); - @import './fixtures/test.css' supports(display: grid); - @import './fixtures/test.css' supports(display: grid); - @import './fixtures/test.css' supports(display: grid); - - @import './fixtures/test.css' layer(utilities); - @import './fixtures/test.css' layer(utilities); - @import './fixtures/test.css' layer(utilities); - @import './fixtures/test.css' layer(utilities); - - @import './fixtures/test.css' theme(inline); - @import './fixtures/test.css' theme(inline); - @import './fixtures/test.css' theme(inline); - @import './fixtures/test.css' theme(inline); - - @import './fixtures/test.css' layer(utilities) supports(display: grid) screen and (min-width: 600px); - @import './fixtures/test.css' layer(utilities) supports(display: grid) screen and - (min-width: 600px); - @import './fixtures/test.css' layer(utilities) supports(display: grid) screen and - (min-width: 600px); - @import './fixtures/test.css' layer(utilities) supports(display: grid) screen and - (min-width: 600px); - - @import 'tailwindcss'; - @import 'tailwindcss/theme.css'; - @import 'tailwindcss/theme';" - `) -}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-import.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-import.ts deleted file mode 100644 index 419f5aa32..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-import.ts +++ /dev/null @@ -1,52 +0,0 @@ -import fs from 'node:fs/promises' -import { dirname, resolve } from 'node:path' -import { type Plugin, type Root } from 'postcss' -import { parseImportParams } from '../../../../tailwindcss/src/at-import' -import { segment } from '../../../../tailwindcss/src/utils/segment' -import * as ValueParser from '../../../../tailwindcss/src/value-parser' - -export function migrateImport(): Plugin { - async function migrate(root: Root) { - let file = root.source?.input.file - if (!file) return - - let promises: Promise[] = [] - root.walkAtRules('import', (rule) => { - try { - let [firstParam, ...rest] = segment(rule.params, ' ') - - let params = parseImportParams(ValueParser.parse(firstParam)) - if (!params) return - - let isRelative = params.uri[0] === '.' - let hasCssExtension = params.uri.endsWith('.css') - - if (isRelative && hasCssExtension) { - return - } - - let fullPath = resolve(dirname(file), params.uri) - if (!hasCssExtension) fullPath += '.css' - - promises.push( - fs.stat(fullPath).then(() => { - let ext = hasCssExtension ? '' : '.css' - let path = isRelative ? params.uri : `./${params.uri}` - rule.params = [`'${path}${ext}'`, ...rest].join(' ') - }), - ) - } catch { - // When an error occurs while parsing the `@import` statement, we skip - // the import. This will happen in cases where you import an external - // URL. - } - }) - - await Promise.allSettled(promises) - } - - return { - postcssPlugin: '@tailwindcss/upgrade/migrate-import', - OnceExit: migrate, - } -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-media-screen.test.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-media-screen.test.ts deleted file mode 100644 index 18ce72e8b..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-media-screen.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { __unstable__loadDesignSystem } from '@tailwindcss/node' -import dedent from 'dedent' -import postcss from 'postcss' -import { expect, it } from 'vitest' -import type { UserConfig } from '../../../../tailwindcss/src/compat/config/types' -import { formatNodes } from './format-nodes' -import { migrateMediaScreen } from './migrate-media-screen' -import { sortBuckets } from './sort-buckets' - -const css = dedent - -async function migrate(input: string, userConfig: UserConfig = {}) { - return postcss() - .use( - migrateMediaScreen({ - designSystem: await __unstable__loadDesignSystem(`@import 'tailwindcss';`, { - base: __dirname, - }), - userConfig, - }), - ) - .use(sortBuckets()) - .use(formatNodes()) - .process(input, { from: expect.getState().testPath }) - .then((result) => result.css) -} - -it('should migrate a built-in breakpoint', async () => { - expect( - await migrate(css` - @media screen(md) { - .foo { - color: red; - } - } - `), - ).toMatchInlineSnapshot(` - "@media (width >= theme(--breakpoint-md)) { - .foo { - color: red; - } - }" - `) -}) - -it('should migrate `@screen` with a built-in breakpoint', async () => { - expect( - await migrate(css` - @screen md { - .foo { - color: red; - } - } - `), - ).toMatchInlineSnapshot(` - "@media (width >= theme(--breakpoint-md)) { - .foo { - color: red; - } - }" - `) -}) - -it('should migrate a custom min-width screen (string)', async () => { - expect( - await migrate( - css` - @media screen(foo) { - .foo { - color: red; - } - } - `, - { - theme: { - screens: { - foo: '123px', - }, - }, - }, - ), - ).toMatchInlineSnapshot(` - "@media (width >= theme(--breakpoint-foo)) { - .foo { - color: red; - } - }" - `) -}) - -it('should migrate a custom min-width screen (object)', async () => { - expect( - await migrate( - css` - @media screen(foo) { - .foo { - color: red; - } - } - `, - { - theme: { - screens: { - foo: { min: '123px' }, - }, - }, - }, - ), - ).toMatchInlineSnapshot(` - "@media (width >= theme(--breakpoint-foo)) { - .foo { - color: red; - } - }" - `) -}) - -it('should migrate a custom max-width screen', async () => { - expect( - await migrate( - css` - @media screen(foo) { - .foo { - color: red; - } - } - `, - { - theme: { - screens: { - foo: { max: '123px' }, - }, - }, - }, - ), - ).toMatchInlineSnapshot(` - "@media (123px >= width) { - .foo { - color: red; - } - }" - `) -}) - -it('should migrate a custom min and max-width screen', async () => { - expect( - await migrate( - css` - @media screen(foo) { - .foo { - color: red; - } - } - `, - { - theme: { - screens: { - foo: { min: '100px', max: '123px' }, - }, - }, - }, - ), - ).toMatchInlineSnapshot(` - "@media (123px >= width >= 100px) { - .foo { - color: red; - } - }" - `) -}) - -it('should migrate a raw media query', async () => { - expect( - await migrate( - css` - @media screen(foo) { - .foo { - color: red; - } - } - `, - { - theme: { - screens: { - foo: { raw: 'only screen and (min-width: 123px)' }, - }, - }, - }, - ), - ).toMatchInlineSnapshot(` - "@media only screen and (min-width: 123px) { - .foo { - color: red; - } - }" - `) -}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-media-screen.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-media-screen.ts deleted file mode 100644 index c10e9ee99..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-media-screen.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { type Plugin, type Root } from 'postcss' -import { resolveConfig } from '../../../../tailwindcss/src/compat/config/resolve-config' -import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' -import { buildMediaQuery } from '../../../../tailwindcss/src/compat/screens-config' -import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' - -export function migrateMediaScreen({ - designSystem, - userConfig, -}: { - designSystem?: DesignSystem | null - userConfig?: Config | null -} = {}): Plugin { - function migrate(root: Root) { - if (!designSystem || !userConfig) return - - let { resolvedConfig } = resolveConfig(designSystem, [ - { base: '', config: userConfig, reference: false, src: undefined }, - ]) - let screens = resolvedConfig?.theme?.screens || {} - - let mediaQueries = new DefaultMap((name) => { - let value = designSystem?.resolveThemeValue(`--breakpoint-${name}`, true) ?? screens?.[name] - if (typeof value === 'string') return `(width >= theme(--breakpoint-${name}))` - return value ? buildMediaQuery(value) : null - }) - - // First migrate `@screen md` to `@media screen(md)` - root.walkAtRules('screen', (node) => { - node.name = 'media' - node.params = `screen(${node.params})` - }) - - // Then migrate the `screen(…)` function - root.walkAtRules((rule) => { - if (rule.name !== 'media') return - - let screen = rule.params.match(/screen\(([^)]+)\)/) - if (!screen) return - - let value = mediaQueries.get(screen[1]) - if (!value) return - - rule.params = value - }) - } - - return { - postcssPlugin: '@tailwindcss/upgrade/migrate-media-screen', - OnceExit: migrate, - } -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-missing-layers.test.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-missing-layers.test.ts deleted file mode 100644 index 595275874..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-missing-layers.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import dedent from 'dedent' -import postcss from 'postcss' -import { expect, it } from 'vitest' -import { formatNodes } from './format-nodes' -import { migrateMissingLayers } from './migrate-missing-layers' -import { sortBuckets } from './sort-buckets' - -const css = dedent - -function migrate(input: string) { - return postcss() - .use(migrateMissingLayers()) - .use(sortBuckets()) - .use(formatNodes()) - .process(input, { from: expect.getState().testPath }) - .then((result) => result.css) -} - -it('should not migrate already migrated `@import` at-rules', async () => { - expect( - await migrate(css` - @import 'tailwindcss'; - `), - ).toMatchInlineSnapshot(`"@import 'tailwindcss';"`) -}) - -it('should add missing `layer(…)` to imported files', async () => { - expect( - await migrate(css` - @import 'tailwindcss/utilities'; /* Expected no layer */ - @import './foo.css'; /* Expected layer(utilities) */ - @import './bar.css'; /* Expected layer(utilities) */ - @import 'tailwindcss/components'; /* Expected no layer */ - `), - ).toMatchInlineSnapshot(` - "@import 'tailwindcss/utilities'; /* Expected no layer */ - @import './foo.css' layer(utilities); /* Expected layer(utilities) */ - @import './bar.css' layer(utilities); /* Expected layer(utilities) */ - @import 'tailwindcss/components'; /* Expected no layer */" - `) -}) - -it('should add missing `layer(…)` as the first param after the import itself', async () => { - expect( - await migrate(css` - @import 'tailwindcss/utilities' supports(--foo); /* Expected no layer */ - @import './foo.css' supports(--foo); /* Expected layer(utilities) supports(--foo) */ - @import './bar.css' supports(--foo); /* Expected layer(utilities) supports(--foo) */ - @import 'tailwindcss/components' supports(--foo); /* Expected no layer */ - `), - ).toMatchInlineSnapshot(` - "@import 'tailwindcss/utilities' supports(--foo); /* Expected no layer */ - @import './foo.css' layer(utilities) supports(--foo); /* Expected layer(utilities) supports(--foo) */ - @import './bar.css' layer(utilities) supports(--foo); /* Expected layer(utilities) supports(--foo) */ - @import 'tailwindcss/components' supports(--foo); /* Expected no layer */" - `) -}) - -it('should not migrate anything if no `@tailwind` directives (or imports) are found', async () => { - expect( - await migrate(css` - /* Base */ - html { - color: red; - } - - /* Utilities */ - .foo { - color: blue; - } - `), - ).toMatchInlineSnapshot(` - "/* Base */ - html { - color: red; - } - - /* Utilities */ - .foo { - color: blue; - }" - `) -}) - -it('should not wrap comments in a layer, if they are the only nodes', async () => { - expect( - await migrate(css` - @tailwind base; - - /* BASE */ - - @tailwind components; - - /* COMPONENTS */ - - @tailwind utilities; - - /** UTILITIES */ - /** - Another comment */ - `), - ).toMatchInlineSnapshot(` - "@tailwind base; - - /* BASE */ - - @tailwind components; - - /* COMPONENTS */ - - @tailwind utilities; - - /** UTILITIES */ - /** - Another comment */" - `) -}) - -it('should migrate rules above the `@tailwind base` directive in an `@layer base`', async () => { - expect( - await migrate(css` - @charset "UTF-8"; - @layer foo, bar, baz; - - /**! - * License header - */ - - html { - color: red; - } - - @tailwind base; - @tailwind components; - @tailwind utilities; - `), - ).toMatchInlineSnapshot(` - "@charset "UTF-8"; - @layer foo, bar, baz; - - /**! - * License header - */ - - @tailwind base; - @tailwind components; - @tailwind utilities; - - @layer base { - html { - color: red; - } - }" - `) -}) - -it('should migrate rules between tailwind directives', async () => { - expect( - await migrate(css` - @tailwind base; - - .base { - } - - @tailwind components; - - .component-a { - } - .component-b { - } - - @tailwind utilities; - - .utility-a { - } - .utility-b { - } - `), - ).toMatchInlineSnapshot(` - "@tailwind base; - - @tailwind components; - - @tailwind utilities; - - @layer base { - .base { - } - } - - @layer components { - .component-a { - } - .component-b { - } - } - - .utility-a { - } - .utility-b { - }" - `) -}) - -it('should keep CSS above a layer unlayered', async () => { - expect( - await migrate(css` - .foo { - color: red; - } - - @layer components { - .bar { - color: blue; - } - } - `), - ).toMatchInlineSnapshot(` - ".foo { - color: red; - } - - @layer components { - .bar { - color: blue; - } - }" - `) -}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-missing-layers.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-missing-layers.ts deleted file mode 100644 index 10c9d55de..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-missing-layers.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { AtRule, type ChildNode, type Plugin, type Root } from 'postcss' -import { segment } from '../../../../tailwindcss/src/utils/segment' - -export function migrateMissingLayers(): Plugin { - function migrate(root: Root) { - let lastLayer = '' - let bucket: ChildNode[] = [] - let buckets: [layer: string, bucket: typeof bucket][] = [] - let firstLayerName: string | null = null - - root.each((node) => { - if (node.type === 'atrule') { - // Known Tailwind directives that should not be inside a layer. - if ( - node.name === 'config' || - node.name === 'source' || - node.name === 'theme' || - node.name === 'utility' || - node.name === 'custom-variant' || - node.name === 'variant' - ) { - if (bucket.length > 0) { - buckets.push([lastLayer, bucket.splice(0)]) - } - return - } - - // Base - if ( - (node.name === 'tailwind' && node.params === 'base') || - (node.name === 'import' && node.params.match(/^["']tailwindcss\/base["']/)) - ) { - if (bucket.length > 0) { - buckets.push([lastLayer, bucket.splice(0)]) - } - - firstLayerName ??= 'base' - lastLayer = 'base' - return - } - - // Components - if ( - (node.name === 'tailwind' && node.params === 'components') || - (node.name === 'import' && node.params.match(/^["']tailwindcss\/components["']/)) - ) { - if (bucket.length > 0) { - buckets.push([lastLayer, bucket.splice(0)]) - } - - firstLayerName ??= 'components' - lastLayer = 'components' - return - } - - // Utilities - if ( - (node.name === 'tailwind' && node.params === 'utilities') || - (node.name === 'import' && node.params.match(/^["']tailwindcss\/utilities["']/)) - ) { - if (bucket.length > 0) { - buckets.push([lastLayer, bucket.splice(0)]) - } - - firstLayerName ??= 'utilities' - lastLayer = 'utilities' - return - } - - // Already in a layer - if (node.name === 'layer') { - if (bucket.length > 0) { - buckets.push([lastLayer, bucket.splice(0)]) - } - return - } - - // Add layer to `@import` at-rules - if (node.name === 'import') { - if (bucket.length > 0) { - buckets.push([lastLayer, bucket.splice(0)]) - } - - // Create new bucket just for the import. This way every import exists - // in its own layer which allows us to add the `layer(…)` parameter - // later on. - buckets.push([lastLayer, [node]]) - return - } - } - - // (License) comments, body-less `@layer` and `@charset` can stay at the - // top, when we haven't found any `@tailwind` at-rules yet. - if ( - lastLayer === '' && - (node.type === 'comment' /* Comment */ || - (node.type === 'atrule' && !node.nodes) || // @layer foo, bar, baz; - (node.type === 'atrule' && node.name === 'charset')) // @charset "UTF-8"; - ) { - return - } - - // Track the node - bucket.push(node) - }) - - for (let [layerName, nodes] of buckets) { - let targetLayerName = layerName || firstLayerName || '' - if (targetLayerName === '') { - continue - } - - // Do not wrap comments in a layer, if they are the only nodes. - if (nodes.every((node) => node.type === 'comment')) { - continue - } - - // Add `layer(…)` to `@import` at-rules - if (nodes.every((node) => node.type === 'atrule' && node.name === 'import')) { - for (let node of nodes) { - if (node.type !== 'atrule' || node.name !== 'import') continue - - if (!node.params.includes('layer(')) { - let params = segment(node.params, ' ') - params.splice(1, 0, `layer(${targetLayerName})`) - node.params = params.join(' ') - node.raws.tailwind_injected_layer = true - } - } - continue - } - - // Wrap each bucket in an `@layer` at-rule - let target = nodes[0] - let layerNode = new AtRule({ - name: 'layer', - params: targetLayerName, - nodes: nodes.map((node) => { - // Keep the target node as-is, because we will be replacing that one - // with the new layer node. - if (node === target) { - return node - } - - // Every other node should be removed from its original position. They - // will be added to the new layer node. - return node.remove() - }), - raws: { - tailwind_pretty: true, - }, - }) - target.replaceWith(layerNode) - } - } - - return { - postcssPlugin: '@tailwindcss/upgrade/migrate-missing-layers', - OnceExit: migrate, - } -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-preflight.test.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-preflight.test.ts deleted file mode 100644 index 2d8b70041..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-preflight.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { __unstable__loadDesignSystem } from '@tailwindcss/node' -import dedent from 'dedent' -import postcss from 'postcss' -import { expect, it, vi } from 'vitest' -import * as versions from '../../utils/version' -import { formatNodes } from './format-nodes' -import { migratePreflight } from './migrate-preflight' -import { sortBuckets } from './sort-buckets' -vi.spyOn(versions, 'isMajor').mockReturnValue(true) - -const css = dedent - -async function migrate(input: string) { - let designSystem = await __unstable__loadDesignSystem( - css` - @import 'tailwindcss'; - `, - { base: __dirname }, - ) - - return postcss() - .use(migratePreflight({ designSystem })) - .use(sortBuckets()) - .use(formatNodes()) - .process(input, { from: expect.getState().testPath }) - .then((result) => result.css) -} - -it("should add compatibility CSS after the `@import 'tailwindcss'`", async () => { - expect( - await migrate(css` - @import 'tailwindcss'; - `), - ).toMatchInlineSnapshot(` - "@import 'tailwindcss'; - - /* - The default border color has changed to \`currentcolor\` in Tailwind CSS v4, - so we've added these compatibility styles to make sure everything still - looks the same as it did with Tailwind CSS v3. - - If we ever want to remove these styles, we need to add an explicit border - color utility to any element that depends on these defaults. - */ - @layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } - }" - `) -}) - -it('should add the compatibility CSS after the last `@import`', async () => { - expect( - await migrate(css` - @import 'tailwindcss'; - @import './foo.css'; - @import './bar.css'; - `), - ).toMatchInlineSnapshot(` - "@import 'tailwindcss'; - @import './foo.css'; - @import './bar.css'; - - /* - 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); - } - }" - `) -}) - -it('should add the compatibility CSS after the last import, even if a body-less `@layer` exists', async () => { - expect( - await migrate(css` - @charset "UTF-8"; - @layer foo, bar, baz, base; - - /**! - * License header - */ - - @import 'tailwindcss'; - @import './foo.css'; - @import './bar.css'; - `), - ).toMatchInlineSnapshot(` - "@charset "UTF-8"; - @layer foo, bar, baz, base; - - /**! - * License header - */ - - @import 'tailwindcss'; - @import './foo.css'; - @import './bar.css'; - - /* - 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); - } - }" - `) -}) - -it('should add the compatibility CSS before the first `@layer base` (if the "tailwindcss" import exists)', async () => { - expect( - await migrate(css` - @import 'tailwindcss'; - - @custom-variant foo { - } - - @utility bar { - } - - @layer base { - } - - @utility baz { - } - - @layer base { - } - `), - ).toMatchInlineSnapshot(` - "@import 'tailwindcss'; - - @custom-variant foo { - } - - /* - 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); - } - } - - @utility bar { - } - - @utility baz { - } - - @layer base { - } - - @layer base { - }" - `) -}) - -it('should add the compatibility CSS before the first `@layer base` (if the "tailwindcss/preflight" import exists)', async () => { - expect( - await migrate(css` - @import 'tailwindcss/preflight'; - - @custom-variant foo { - } - - @utility bar { - } - - @layer base { - } - - @utility baz { - } - - @layer base { - } - `), - ).toMatchInlineSnapshot(` - "@import 'tailwindcss/preflight'; - - @custom-variant foo { - } - - /* - 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); - } - } - - @utility bar { - } - - @utility baz { - } - - @layer base { - } - - @layer base { - }" - `) -}) - -it('should not add the backwards compatibility CSS when no `@import "tailwindcss"` or `@import "tailwindcss/preflight"` exists', async () => { - expect( - await migrate(css` - @custom-variant foo { - } - - @utility bar { - } - - @layer base { - } - - @utility baz { - } - - @layer base { - } - `), - ).toMatchInlineSnapshot(` - "@custom-variant foo { - } - - @utility bar { - } - - @utility baz { - } - - @layer base { - } - - @layer base { - }" - `) -}) - -it('should not add the backwards compatibility CSS when another `@import "tailwindcss"` import exists such as theme or utilities', async () => { - expect( - await migrate(css` - @import 'tailwindcss/theme'; - - @custom-variant foo { - } - - @utility bar { - } - - @layer base { - } - - @utility baz { - } - - @layer base { - } - `), - ).toMatchInlineSnapshot(` - "@import 'tailwindcss/theme'; - - @custom-variant foo { - } - - @utility bar { - } - - @utility baz { - } - - @layer base { - } - - @layer base { - }" - `) -}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-preflight.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-preflight.ts deleted file mode 100644 index d32d96979..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-preflight.ts +++ /dev/null @@ -1,184 +0,0 @@ -import dedent from 'dedent' -import postcss, { type Plugin, type Root } from 'postcss' -import { keyPathToCssProperty } from '../../../../tailwindcss/src/compat/apply-config-to-theme' -import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' -import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { toKeyPath } from '../../../../tailwindcss/src/utils/to-key-path' -import * as ValueParser from '../../../../tailwindcss/src/value-parser' -import { walk, WalkAction } from '../../../../tailwindcss/src/walk' -import * as version from '../../utils/version' - -// Defaults in v4 -const DEFAULT_BORDER_COLOR = 'currentcolor' - -const css = dedent -const BORDER_COLOR_COMPATIBILITY_CSS = css` - /* - 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: theme(borderColor.DEFAULT); - } - } -` - -export function migratePreflight({ - designSystem, - userConfig, -}: { - designSystem: DesignSystem | null - userConfig?: Config | null -}): Plugin { - // @ts-expect-error - let defaultBorderColor = userConfig?.theme?.borderColor?.DEFAULT - - function canResolveThemeValue(path: string) { - if (!designSystem) return false - let variable = `--${keyPathToCssProperty(toKeyPath(path))}` as const - return Boolean(designSystem.theme.get([variable])) - } - - function migrate(root: Root) { - // CSS for backwards compatibility with v3 should only injected in v3 - // projects and not v4 projects. - if (!version.isMajor(3)) return - - let isTailwindRoot = false - root.walkAtRules('import', (node) => { - if ( - /['"]tailwindcss['"]/.test(node.params) || - /['"]tailwindcss\/preflight['"]/.test(node.params) - ) { - isTailwindRoot = true - return false - } - }) - - if (!isTailwindRoot) return - - // Figure out the compatibility CSS to inject - let compatibilityCssString = '' - if (defaultBorderColor !== DEFAULT_BORDER_COLOR) { - compatibilityCssString += BORDER_COLOR_COMPATIBILITY_CSS - compatibilityCssString += '\n\n' - } - - compatibilityCssString = `\n@tw-bucket compatibility {\n${compatibilityCssString}\n}\n` - let compatibilityCss = postcss.parse(compatibilityCssString) - - // Replace the `theme(…)` with v3 values if we can't resolve the theme - // value. - compatibilityCss.walkDecls((decl) => { - if (decl.value.includes('theme(')) { - decl.value = substituteFunctionsInValue(ValueParser.parse(decl.value), (path) => { - if (canResolveThemeValue(path)) { - return defaultBorderColor - } else { - if (path === 'borderColor.DEFAULT') { - return 'var(--color-gray-200, currentcolor)' - } - } - return null - }) - } - }) - - // Cleanup `--border-color` definition in `theme(…)` - root.walkAtRules('theme', (node) => { - node.walkDecls('--border-color', (decl) => { - decl.remove() - }) - - if (node.nodes?.length === 0) { - node.remove() - } - }) - - // Inject the compatibility CSS - root.append(compatibilityCss) - } - - return { - postcssPlugin: '@tailwindcss/upgrade/migrate-preflight', - OnceExit: migrate, - } -} - -function substituteFunctionsInValue( - ast: ValueParser.ValueAstNode[], - handle: (value: string, fallback?: string) => string | null, -) { - walk(ast, (node) => { - if (node.kind === 'function' && node.value === 'theme') { - if (node.nodes.length < 1) return - - // Ignore whitespace before the first argument - if (node.nodes[0].kind === 'separator' && node.nodes[0].value.trim() === '') { - node.nodes.shift() - } - - let pathNode = node.nodes[0] - if (pathNode.kind !== 'word') return - - let path = pathNode.value - - // For the theme function arguments, we require all separators to contain - // comma (`,`), spaces alone should be merged into the previous word to - // avoid splitting in this case: - // - // theme(--color-red-500 / 75%) theme(--color-red-500 / 75%, foo, bar) - // - // We only need to do this for the first node, as the fallback values are - // passed through as-is. - let skipUntilIndex = 1 - for (let i = skipUntilIndex; i < node.nodes.length; i++) { - if (node.nodes[i].value.includes(',')) { - break - } - path += ValueParser.toCss([node.nodes[i]]) - skipUntilIndex = i + 1 - } - - path = eventuallyUnquote(path) - let fallbackValues = node.nodes.slice(skipUntilIndex + 1) - - let replacement = - fallbackValues.length > 0 ? handle(path, ValueParser.toCss(fallbackValues)) : handle(path) - if (replacement === null) return - - return WalkAction.Replace(ValueParser.parse(replacement)) - } - }) - - return ValueParser.toCss(ast) -} - -function eventuallyUnquote(value: string) { - if (value[0] !== "'" && value[0] !== '"') return value - - let unquoted = '' - let quoteChar = value[0] - for (let i = 1; i < value.length - 1; i++) { - let currentChar = value[i] - let nextChar = value[i + 1] - - if (currentChar === '\\' && (nextChar === quoteChar || nextChar === '\\')) { - unquoted += nextChar - i++ - } else { - unquoted += currentChar - } - } - - return unquoted -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-tailwind-directives.test.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-tailwind-directives.test.ts deleted file mode 100644 index fdf480b3e..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-tailwind-directives.test.ts +++ /dev/null @@ -1,420 +0,0 @@ -import dedent from 'dedent' -import postcss from 'postcss' -import { expect, it } from 'vitest' -import { formatNodes } from './format-nodes' -import { migrateTailwindDirectives } from './migrate-tailwind-directives' -import { sortBuckets } from './sort-buckets' - -const css = dedent - -function migrate(input: string, options: { newPrefix: string | null } = { newPrefix: null }) { - return postcss() - .use(migrateTailwindDirectives(options)) - .use(sortBuckets()) - .use(formatNodes()) - .process(input, { from: expect.getState().testPath }) - .then((result) => result.css) -} - -it("should not migrate `@import 'tailwindcss'`", async () => { - expect( - await migrate(css` - @import 'tailwindcss'; - `), - ).toEqual(css` - @import 'tailwindcss'; - `) -}) - -it("should append a prefix to `@import 'tailwindcss'`", async () => { - expect( - await migrate( - css` - @import 'tailwindcss'; - `, - { - newPrefix: 'tw', - }, - ), - ).toEqual(css` - @import 'tailwindcss' prefix(tw); - `) -}) - -it('should migrate the tailwind.css import', async () => { - expect( - await migrate(css` - @import 'tailwindcss/tailwind.css'; - `), - ).toEqual(css` - @import 'tailwindcss'; - `) -}) - -it('should migrate the tailwind.css import with a prefix', async () => { - expect( - await migrate( - css` - @import 'tailwindcss/tailwind.css'; - `, - { - newPrefix: 'tw', - }, - ), - ).toEqual(css` - @import 'tailwindcss' prefix(tw); - `) -}) - -it('should migrate the default @tailwind directives to a single import', async () => { - expect( - await migrate(css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `), - ).toEqual(css` - @import 'tailwindcss'; - `) -}) - -it('should migrate the default @tailwind directives to a single import with a prefix', async () => { - expect( - await migrate( - css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, - { - newPrefix: 'tw', - }, - ), - ).toEqual(css` - @import 'tailwindcss' prefix(tw); - `) -}) - -it('should migrate the default @tailwind directives as imports to a single import', async () => { - expect( - await migrate(css` - @import 'tailwindcss/base'; - @import 'tailwindcss/components'; - @import 'tailwindcss/utilities'; - `), - ).toEqual(css` - @import 'tailwindcss'; - `) -}) - -it('should migrate the default @tailwind directives to a single import in a valid location', async () => { - expect( - await migrate(css` - @charset "UTF-8"; - @layer foo, bar, baz; - - /**! - * License header - */ - - html { - color: red; - } - - @tailwind base; - @tailwind components; - @tailwind utilities; - `), - ) - // NOTE: The `html {}` is not wrapped in a `@layer` directive, because that - // is handled by another migration step. See ../index.test.ts for a - // dedicated test. - .toEqual(css` - @charset "UTF-8"; - @layer foo, bar, baz; - - /**! - * License header - */ - - @import 'tailwindcss'; - - html { - color: red; - } - `) -}) - -it('should migrate the default @tailwind directives as imports to a single import in a valid location', async () => { - expect( - await migrate(css` - @charset "UTF-8"; - @layer foo, bar, baz; - - /**! - * License header - */ - - @import 'tailwindcss/base'; - @import 'tailwindcss/components'; - @import 'tailwindcss/utilities'; - `), - ).toEqual(css` - @charset "UTF-8"; - @layer foo, bar, baz; - - /**! - * License header - */ - - @import 'tailwindcss'; - `) -}) - -it('should migrate the default @tailwind directives as imports to a single import with a prefix', async () => { - expect( - await migrate( - css` - @import 'tailwindcss/base'; - @import 'tailwindcss/components'; - @import 'tailwindcss/utilities'; - `, - { - newPrefix: 'tw', - }, - ), - ).toEqual(css` - @import 'tailwindcss' prefix(tw); - `) -}) - -it.each([ - [ - // The default order - css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, - css` - @import 'tailwindcss'; - `, - ], - - // @tailwind components moved, but has no effect in v4. Therefore `base` and - // `utilities` are still in the correct order. - [ - css` - @tailwind base; - @tailwind utilities; - @tailwind components; - `, - css` - @import 'tailwindcss'; - `, - ], - - // Same as previous comment - [ - css` - @tailwind components; - @tailwind base; - @tailwind utilities; - `, - css` - @import 'tailwindcss'; - `, - ], - - // `base` and `utilities` swapped order, thus the `@layer` directives are - // needed. The `components` directive is still ignored. - [ - css` - @tailwind components; - @tailwind utilities; - @tailwind base; - `, - css` - @layer theme, components, utilities, base; - @import 'tailwindcss'; - `, - ], - [ - css` - @tailwind utilities; - @tailwind base; - @tailwind components; - `, - css` - @layer theme, components, utilities, base; - @import 'tailwindcss'; - `, - ], - [ - css` - @tailwind utilities; - @tailwind components; - @tailwind base; - `, - css` - @layer theme, components, utilities, base; - @import 'tailwindcss'; - `, - ], -])( - 'should migrate the default directives (but in different order) to a single import, order %#', - async (input, expected) => { - expect(await migrate(input)).toEqual(expected) - }, -) - -it('should migrate `@tailwind base` to theme and preflight imports', async () => { - expect( - await migrate(css` - @tailwind base; - `), - ).toEqual(css` - @import 'tailwindcss/theme' layer(theme); - @import 'tailwindcss/preflight' layer(base); - `) -}) - -it('should migrate `@tailwind base` to theme and preflight imports with a prefix', async () => { - expect( - await migrate( - css` - @tailwind base; - `, - { - newPrefix: 'tw', - }, - ), - ).toEqual(css` - @import 'tailwindcss/theme' layer(theme) prefix(tw); - @import 'tailwindcss/preflight' layer(base); - `) -}) - -it('should migrate `@import "tailwindcss/base"` to theme and preflight imports', async () => { - expect( - await migrate(css` - @import 'tailwindcss/base'; - `), - ).toEqual(css` - @import 'tailwindcss/theme' layer(theme); - @import 'tailwindcss/preflight' layer(base); - `) -}) - -it('should migrate `@import "tailwindcss/base"` to theme and preflight imports with a prefix', async () => { - expect( - await migrate( - css` - @import 'tailwindcss/base'; - `, - { - newPrefix: 'tw', - }, - ), - ).toEqual(css` - @import 'tailwindcss/theme' layer(theme) prefix(tw); - @import 'tailwindcss/preflight' layer(base); - `) -}) - -it('should migrate `@tailwind utilities` to an import', async () => { - expect( - await migrate(css` - @tailwind utilities; - `), - ).toEqual(css` - @import 'tailwindcss/utilities' layer(utilities); - `) -}) - -it('should migrate `@import "tailwindcss/utilities"` to an import', async () => { - expect( - await migrate(css` - @import 'tailwindcss/utilities'; - `), - ).toEqual(css` - @import 'tailwindcss/utilities' layer(utilities); - `) -}) - -it('should not migrate existing imports using a custom layer', async () => { - expect( - await migrate(css` - @import 'tailwindcss/utilities' layer(my-utilities); - `), - ).toEqual(css` - @import 'tailwindcss/utilities' layer(my-utilities); - `) -}) - -// We don't have a `@layer components` anymore, so omitting it should result -// in the full import as well. Alternatively, we could expand to: -// -// ```css -// @import 'tailwindcss/theme' layer(theme); -// @import 'tailwindcss/preflight' layer(base); -// @import 'tailwindcss/utilities' layer(utilities); -// ``` -it('should migrate `@tailwind base` and `@tailwind utilities` to a single import', async () => { - expect( - await migrate(css` - @tailwind base; - @tailwind utilities; - `), - ).toEqual(css` - @import 'tailwindcss'; - `) -}) - -it('should migrate `@tailwind base` and `@tailwind utilities` to a single import with a prefix', async () => { - expect( - await migrate( - css` - @import 'tailwindcss/base'; - @import 'tailwindcss/utilities'; - `, - { - newPrefix: 'tw', - }, - ), - ).toEqual(css` - @import 'tailwindcss' prefix(tw); - `) -}) - -it('should drop `@tailwind screens;`', async () => { - expect( - await migrate(css` - @tailwind screens; - `), - ).toEqual('') -}) - -it('should drop `@tailwind variants;`', async () => { - expect( - await migrate(css` - @tailwind variants; - `), - ).toEqual('') -}) - -it('should replace `@responsive` with its children', async () => { - expect( - await migrate(css` - @responsive { - .foo { - color: red; - } - } - `), - ).toMatchInlineSnapshot(` - ".foo { - color: red; - }" - `) -}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-tailwind-directives.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-tailwind-directives.ts deleted file mode 100644 index d3d74a65e..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-tailwind-directives.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { AtRule, type ChildNode, type Plugin, type Root } from 'postcss' - -const DEFAULT_LAYER_ORDER = ['theme', 'base', 'components', 'utilities'] - -export function migrateTailwindDirectives(options: { newPrefix: string | null }): Plugin { - let prefixParams = options.newPrefix ? ` prefix(${options.newPrefix})` : '' - - function migrate(root: Root) { - let baseNode = null as AtRule | null - let utilitiesNode = null as AtRule | null - let orderedNodes: AtRule[] = [] - - let defaultImportNode = null as AtRule | null - let utilitiesImportNode = null as AtRule | null - let preflightImportNode = null as AtRule | null - let themeImportNode = null as AtRule | null - - let layerOrder: string[] = [] - - root.walkAtRules((node) => { - // Migrate legacy `@import "tailwindcss/tailwind.css"` - if (node.name === 'import' && node.params.match(/^["']tailwindcss\/tailwind\.css["']$/)) { - node.params = node.params.replace('tailwindcss/tailwind.css', 'tailwindcss') - } - - // Append any new prefix() param to existing `@import 'tailwindcss'` directives - if (node.name === 'import' && node.params.match(/^["']tailwindcss["']/)) { - node.params += prefixParams - } - - // Track old imports and directives - else if ( - (node.name === 'tailwind' && node.params === 'base') || - (node.name === 'import' && node.params.match(/^["']tailwindcss\/base["']$/)) - ) { - layerOrder.push('base') - orderedNodes.push(node) - baseNode = node - } else if ( - (node.name === 'tailwind' && node.params === 'utilities') || - (node.name === 'import' && node.params.match(/^["']tailwindcss\/utilities["']$/)) - ) { - layerOrder.push('utilities') - orderedNodes.push(node) - utilitiesNode = node - } - - // Remove directives that are not needed anymore - else if ( - (node.name === 'tailwind' && node.params === 'components') || - (node.name === 'tailwind' && node.params === 'screens') || - (node.name === 'tailwind' && node.params === 'variants') || - (node.name === 'import' && node.params.match(/^["']tailwindcss\/components["']$/)) - ) { - node.remove() - } - - // Replace Tailwind CSS v2 directives that still worked in v3. - else if (node.name === 'responsive') { - if (node.nodes) { - for (let child of node.nodes) { - child.raws.tailwind_pretty = true - } - node.replaceWith(node.nodes) - } else { - node.remove() - } - } - }) - - // Insert default import if all directives are present - if (baseNode !== null && utilitiesNode !== null) { - if (!defaultImportNode) { - findTargetNode(orderedNodes).before( - new AtRule({ name: 'import', params: `'tailwindcss'${prefixParams}` }), - ) - } - baseNode?.remove() - utilitiesNode?.remove() - } - - // Insert individual imports if not all directives are present - else if (utilitiesNode !== null) { - if (!utilitiesImportNode) { - findTargetNode(orderedNodes).before( - new AtRule({ name: 'import', params: "'tailwindcss/utilities' layer(utilities)" }), - ) - } - utilitiesNode?.remove() - } else if (baseNode !== null) { - if (!themeImportNode) { - findTargetNode(orderedNodes).before( - new AtRule({ name: 'import', params: `'tailwindcss/theme' layer(theme)${prefixParams}` }), - ) - } - - if (!preflightImportNode) { - findTargetNode(orderedNodes).before( - new AtRule({ name: 'import', params: "'tailwindcss/preflight' layer(base)" }), - ) - } - - baseNode?.remove() - } - - // Insert `@layer …;` at the top when the order in the CSS was different - // from the default. - { - // Determine if the order is different from the default. - let sortedLayerOrder = layerOrder.toSorted((a, z) => { - return DEFAULT_LAYER_ORDER.indexOf(a) - DEFAULT_LAYER_ORDER.indexOf(z) - }) - - if (layerOrder.some((layer, index) => layer !== sortedLayerOrder[index])) { - // Create a new `@layer` rule with the sorted order. - let newLayerOrder = DEFAULT_LAYER_ORDER.toSorted((a, z) => { - return layerOrder.indexOf(a) - layerOrder.indexOf(z) - }) - root.prepend({ name: 'layer', params: newLayerOrder.join(', ') }) - } - } - } - - return { - postcssPlugin: '@tailwindcss/upgrade/migrate-tailwind-directives', - OnceExit: migrate, - } -} - -// Finds the location where we can inject the new `@import` at-rule. This -// guarantees that the `@import` is inserted at the most expected location. -// -// Ideally it's replacing the existing Tailwind directives, but we have to -// ensure that the `@import` is valid in this location or not. If not, we move -// the `@import` up until we find a valid location. -function findTargetNode(nodes: AtRule[]) { - // Start at the `base` or `utilities` node (whichever comes first), and find - // the spot where we can insert the new import. - let target: ChildNode = nodes.at(0)! - - // Only allowed nodes before the `@import` are: - // - // - `@charset` at-rule. - // - `@layer foo, bar, baz;` at-rule to define the order of the layers. - // - `@import` at-rule to import other CSS files. - // - Comments. - // - // Nodes that cannot exist before the `@import` are: - // - // - Any other at-rule. - // - Any rule. - let previous = target.prev() - while (previous) { - // Rules are not allowed before the `@import`, so we have to at least inject - // the `@import` before this rule. - if (previous.type === 'rule') { - target = previous - } - - // Some at-rules are allowed before the `@import`. - if (previous.type === 'atrule') { - // `@charset` and `@import` are allowed before the `@import`. - if (previous.name === 'charset' || previous.name === 'import') { - // Allowed - previous = previous.prev() - continue - } - - // `@layer` without any nodes is allowed before the `@import`. - else if (previous.name === 'layer' && !previous.nodes) { - // Allowed - previous = previous.prev() - continue - } - - // Anything other at-rule (`@media`, `@supports`, etc.) is not allowed - // before the `@import`. - else { - target = previous - } - } - - // Keep checking the previous node. - previous = previous.prev() - } - - return target -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-theme-to-var.test.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-theme-to-var.test.ts deleted file mode 100644 index f0b4844ad..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-theme-to-var.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { __unstable__loadDesignSystem } from '@tailwindcss/node' -import dedent from 'dedent' -import postcss from 'postcss' -import { expect, it } from 'vitest' -import { formatNodes } from './format-nodes' -import { migrateThemeToVar } from './migrate-theme-to-var' -import { sortBuckets } from './sort-buckets' - -const css = dedent - -async function migrate(input: string) { - return postcss() - .use( - migrateThemeToVar({ - designSystem: await __unstable__loadDesignSystem(`@import 'tailwindcss';`, { - base: __dirname, - }), - }), - ) - .use(sortBuckets()) - .use(formatNodes()) - .process(input, { from: expect.getState().testPath }) - .then((result) => result.css) -} - -it('should migrate `theme(…)` to `var(…)`', async () => { - expect( - await migrate(css` - @media theme(screens.sm) { - .foo { - background-color: theme(colors.red.900); - color: theme(colors.red.900 / 75%); - border-color: theme(colors.red.200/75%); - } - } - `), - ).toMatchInlineSnapshot(` - "@media --theme(--breakpoint-sm) { - .foo { - background-color: var(--color-red-900); - color: --theme(--color-red-900 / 75%); - border-color: --theme(--color-red-200 / 75%); - } - }" - `) -}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-theme-to-var.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-theme-to-var.ts deleted file mode 100644 index 8285def1e..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-theme-to-var.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { type Plugin } from 'postcss' -import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { Convert, createConverter } from '../template/migrate-theme-to-var' - -export function migrateThemeToVar({ - designSystem, -}: { - designSystem?: DesignSystem | null -} = {}): Plugin { - return { - postcssPlugin: '@tailwindcss/upgrade/migrate-theme-to-var', - OnceExit(root) { - if (!designSystem) return - let convert = createConverter(designSystem, { prettyPrint: true }) - - root.walkDecls((decl) => { - let [newValue] = convert(decl.value) - decl.value = newValue - }) - - root.walkAtRules((atRule) => { - if ( - atRule.name === 'media' || - atRule.name === 'custom-media' || - atRule.name === 'container' || - atRule.name === 'supports' - ) { - let [newValue] = convert(atRule.params, Convert.MigrateThemeOnly) - atRule.params = newValue - } - }) - }, - } -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-variants-directive.test.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-variants-directive.test.ts deleted file mode 100644 index 3521d34f7..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-variants-directive.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import dedent from 'dedent' -import postcss from 'postcss' -import { expect, it } from 'vitest' -import { formatNodes } from './format-nodes' -import { migrateVariantsDirective } from './migrate-variants-directive' -import { sortBuckets } from './sort-buckets' - -const css = dedent - -function migrate(input: string) { - return postcss() - .use(migrateVariantsDirective()) - .use(sortBuckets()) - .use(formatNodes()) - .process(input, { from: expect.getState().testPath }) - .then((result) => result.css) -} - -it('should replace `@variants` with `@layer utilities`', async () => { - expect( - await migrate(css` - @variants hover, focus { - .foo { - color: red; - } - } - `), - ).toMatchInlineSnapshot(` - "@layer utilities { - .foo { - color: red; - } - }" - `) -}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-variants-directive.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-variants-directive.ts deleted file mode 100644 index 1592c0059..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-variants-directive.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { type Plugin, type Root } from 'postcss' - -export function migrateVariantsDirective(): Plugin { - function migrate(root: Root) { - root.walkAtRules('variants', (node) => { - // Migrate `@variants` to `@utility` because `@variants` make the classes - // an actual utility. - // ```css - // @variants hover { - // .foo {} - // } - // ``` - // - // Means that you can do this in your HTML: - // ```html - //
                          - // ``` - // - // Notice the `focus:`, even though we _only_ configured the `hover` - // variant. - // - // This means that we can convert it to an `@layer utilities` rule. Later, - // this will get converted to an `@utility` rule. - if (node.name === 'variants') { - node.name = 'layer' - node.params = 'utilities' - } - }) - } - - return { - postcssPlugin: '@tailwindcss/upgrade/migrate-variants-directive', - OnceExit: migrate, - } -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate.ts deleted file mode 100644 index cb9150851..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate.ts +++ /dev/null @@ -1,57 +0,0 @@ -import postcss from 'postcss' -import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' -import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { Stylesheet } from '../../stylesheet' -import type { JSConfigMigration } from '../config/migrate-js-config' -import { migrateAtApply } from './migrate-at-apply' -import { migrateAtLayerUtilities } from './migrate-at-layer-utilities' -import { migrateConfig } from './migrate-config' -import { migrateImport } from './migrate-import' -import { migrateMediaScreen } from './migrate-media-screen' -import { migrateMissingLayers } from './migrate-missing-layers' -import { migratePreflight } from './migrate-preflight' -import { migrateTailwindDirectives } from './migrate-tailwind-directives' -import { migrateThemeToVar } from './migrate-theme-to-var' -import { migrateVariantsDirective } from './migrate-variants-directive' - -export interface MigrateOptions { - newPrefix: string | null - designSystem: DesignSystem | null - userConfig: Config | null - configFilePath: string | null - jsConfigMigration: JSConfigMigration | null -} - -export async function migrate(stylesheet: Stylesheet, options: MigrateOptions) { - if (!stylesheet.file) { - throw new Error('Cannot migrate a stylesheet without a file path') - } - - if (!stylesheet.canMigrate) return - - await migrateContents(stylesheet, options) -} - -export async function migrateContents( - stylesheet: Stylesheet | string, - options: MigrateOptions, - file?: string, -) { - if (typeof stylesheet === 'string') { - stylesheet = await Stylesheet.fromString(stylesheet) - stylesheet.file = file ?? null - } - - return postcss() - .use(migrateImport()) - .use(migrateAtApply(options)) - .use(migrateMediaScreen(options)) - .use(migrateVariantsDirective()) - .use(migrateAtLayerUtilities(stylesheet)) - .use(migrateMissingLayers()) - .use(migrateTailwindDirectives(options)) - .use(migrateConfig(stylesheet, options)) - .use(migratePreflight(options)) - .use(migrateThemeToVar(options)) - .process(stylesheet.root, { from: stylesheet.file ?? undefined }) -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/sort-buckets.ts b/packages/@tailwindcss-upgrade/src/codemods/css/sort-buckets.ts deleted file mode 100644 index 22b0581dd..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/sort-buckets.ts +++ /dev/null @@ -1,161 +0,0 @@ -import postcss, { type AtRule, type ChildNode, type Comment, type Plugin, type Root } from 'postcss' -import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' -import { walk, WalkAction } from '../../utils/walk' - -const BUCKET_ORDER = [ - // Imports - 'import', // @import - - // Configuration - 'config', // @config - 'plugin', // @plugin - 'source', // @source - 'custom-variant', // @custom-variant - 'theme', // @theme - - // Styles - 'compatibility', // @layer base with compatibility CSS - 'utility', // @utility - - // User CSS - 'user', -] - -export function sortBuckets(): Plugin { - async function migrate(root: Root) { - // 1. Move items that are not in a bucket, into a bucket - { - let comments: Comment[] = [] - - let buckets = new DefaultMap((name) => { - let bucket = postcss.atRule({ name: 'tw-bucket', params: name, nodes: [] }) - root.append(bucket) - return bucket - }) - - // Seed the buckets with existing buckets - root.walkAtRules('tw-bucket', (node) => { - buckets.set(node.params, node) - }) - - let lastLayer = 'user' - function injectInto(name: string, ...nodes: ChildNode[]) { - lastLayer = name - buckets.get(name).nodes?.push(...comments.splice(0), ...nodes) - } - - walk(root, (node) => { - // Already in a bucket, skip it - if (node.type === 'atrule' && node.name === 'tw-bucket') { - return WalkAction.Skip - } - - // Comments belong to the bucket of the nearest node, which is typically - // in the "next" bucket. - if (node.type === 'comment') { - // We already have comments, which means that we already have nodes - // that belong in the next bucket, so we should move the current - // comment into the next bucket as well. - if (comments.length > 0) { - comments.push(node) - return - } - - // Figure out the closest node to the comment - let prevDistance = distance(node.prev(), node) ?? Infinity - let nextDistance = distance(node, node.next()) ?? Infinity - - if (prevDistance < nextDistance) { - buckets.get(lastLayer).nodes?.push(node) - } else { - comments.push(node) - } - } - - // Known at-rules - else if ( - node.type === 'atrule' && - ['config', 'plugin', 'source', 'theme', 'utility', 'custom-variant'].includes(node.name) - ) { - injectInto(node.name, node) - } - - // Imports bucket, which also contains the `@charset` and body-less `@layer` - else if ( - (node.type === 'atrule' && node.name === 'layer' && !node.nodes) || // @layer foo, bar; - (node.type === 'atrule' && node.name === 'import') || - (node.type === 'atrule' && node.name === 'charset') || // @charset "UTF-8"; - (node.type === 'atrule' && node.name === 'tailwind') - ) { - injectInto('import', node) - } - - // User CSS - else if (node.type === 'rule' || node.type === 'atrule') { - injectInto('user', node) - } - - // Fallback - else { - injectInto('user', node) - } - - return WalkAction.Skip - }) - - if (comments.length > 0) { - injectInto(lastLayer) - } - } - - // 2. Merge `@tw-bucket` with the same name together - let firstBuckets = new Map() - root.walkAtRules('tw-bucket', (node) => { - let firstBucket = firstBuckets.get(node.params) - if (!firstBucket) { - firstBuckets.set(node.params, node) - return - } - - if (node.nodes) { - firstBucket.append(...node.nodes) - } - }) - - // 3. Remove empty `@tw-bucket` - root.walkAtRules('tw-bucket', (node) => { - if (!node.nodes?.length) { - node.remove() - } - }) - - // 4. Sort the `@tw-bucket` themselves - { - let sorted = Array.from(firstBuckets.values()).sort((a, z) => { - let aIndex = BUCKET_ORDER.indexOf(a.params) - let zIndex = BUCKET_ORDER.indexOf(z.params) - return aIndex - zIndex - }) - - // Re-inject the sorted buckets - root.removeAll() - root.append(sorted) - } - } - - return { - postcssPlugin: '@tailwindcss/upgrade/sort-buckets', - OnceExit: migrate, - } -} - -function distance(before?: ChildNode, after?: ChildNode): number | null { - if (!before || !after) return null - if (!before.source || !after.source) return null - if (!before.source.start || !after.source.start) return null - if (!before.source.end || !after.source.end) return null - - // Compare end of Before, to start of After - let d = Math.abs(before.source.end.line - after.source.start.line) - return d -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/split.ts b/packages/@tailwindcss-upgrade/src/codemods/css/split.ts deleted file mode 100644 index 146d30733..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/css/split.ts +++ /dev/null @@ -1,257 +0,0 @@ -import postcss from 'postcss' -import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' -import { Stylesheet, type StylesheetId } from '../../stylesheet' -import { walk, WalkAction } from '../../utils/walk' - -export async function split(stylesheets: Stylesheet[]) { - let stylesheetsById = new Map() - let stylesheetsByFile = new Map() - - for (let sheet of stylesheets) { - stylesheetsById.set(sheet.id, sheet) - - if (sheet.file) { - stylesheetsByFile.set(sheet.file, sheet) - } - } - - // Keep track of sheets that contain `@utility` rules - let requiresSplit = new Set() - - for (let sheet of stylesheets) { - // Root files don't need to be split - if (sheet.isTailwindRoot) continue - - let containsUtility = false - let containsUnsafe = sheet.layers().size > 0 - - walk(sheet.root, (node) => { - if (node.type === 'atrule' && node.name === 'utility') { - containsUtility = true - } - - // Safe to keep without splitting - else if ( - // An `@import "…" layer(…)` is safe - (node.type === 'atrule' && node.name === 'import' && node.params.includes('layer(')) || - // @layer blocks are safe - (node.type === 'atrule' && node.name === 'layer') || - // Comments are safe - node.type === 'comment' - ) { - return WalkAction.Skip - } - - // Everything else is not safe, and requires a split - else { - containsUnsafe = true - } - - // We already know we need to split this sheet - if (containsUtility && containsUnsafe) { - return WalkAction.Stop - } - - return WalkAction.Skip - }) - - if (containsUtility && containsUnsafe) { - requiresSplit.add(sheet) - } - } - - // Split every imported stylesheet into two parts - let utilitySheets = new Map() - - for (let sheet of stylesheets) { - // Ignore stylesheets that were not imported - if (!sheet.file) continue - if (sheet.parents.size === 0) continue - - // Skip stylesheets that don't have utilities - // and don't have any children that have utilities - if (!requiresSplit.has(sheet)) { - if (!Array.from(sheet.descendants()).some((child) => requiresSplit.has(child))) { - continue - } - } - - let utilities = postcss.root() - - walk(sheet.root, (node) => { - if (node.type !== 'atrule') return - if (node.name !== 'utility') return - - // `append` will move this node from the original sheet - // to the new utilities sheet - utilities.append(node) - - return WalkAction.Skip - }) - - let newFileName = sheet.file.replace(/\.css$/, '.utilities.css') - - let counter = 0 - - // If we already have a utility sheet with this name, we need to rename it - while (stylesheetsByFile.has(newFileName)) { - counter += 1 - newFileName = sheet.file.replace(/\.css$/, `.utilities.${counter}.css`) - } - - let utilitySheet = await Stylesheet.fromRoot(utilities, newFileName) - - utilitySheet.extension = counter > 0 ? `.utilities.${counter}.css` : `.utilities.css` - - utilitySheets.set(sheet, utilitySheet) - stylesheetsById.set(utilitySheet.id, utilitySheet) - } - - // Make sure the utility sheets are linked to one another - for (let [normalSheet, utilitySheet] of utilitySheets) { - for (let parent of normalSheet.parents) { - let utilityParent = utilitySheets.get(parent.item) - if (!utilityParent) continue - utilitySheet.parents.add({ - item: utilityParent, - meta: parent.meta, - }) - } - - for (let child of normalSheet.children) { - let utilityChild = utilitySheets.get(child.item) - if (!utilityChild) continue - utilitySheet.children.add({ - item: utilityChild, - meta: child.meta, - }) - } - } - - for (let sheet of stylesheets) { - let utilitySheet = utilitySheets.get(sheet) - let utilityImports: Set = new Set() - - for (let node of sheet.importRules) { - let sheetId = node.raws.tailwind_destination_sheet_id as StylesheetId | undefined - - // This import rule does not point to a stylesheet - // which likely means it points to `node_modules` - if (!sheetId) continue - - let originalDestination = stylesheetsById.get(sheetId) - - // This import points to a stylesheet that no longer exists which likely - // means it was removed by the optimizer this will be cleaned up later - if (!originalDestination) continue - - let utilityDestination = utilitySheets.get(originalDestination) - - // A utility sheet doesn't exist for this import so it doesn't need - // to be processed - if (!utilityDestination) continue - - let match = node.params.match(/(['"])(.*)\1/) - if (!match) return - - let quote = match[1] - let id = match[2] - - let newFile = id.replace(/\.css$/, utilityDestination.extension!) - - // The import will just point to the new file without any media queries, - // layers, or other conditions because `@utility` MUST be top-level. - let newImport = node.clone({ - params: `${quote}${newFile}${quote}`, - raws: { - tailwind_injected_layer: node.raws.tailwind_injected_layer, - tailwind_original_params: `${quote}${id}${quote}`, - tailwind_destination_sheet_id: utilityDestination.id, - }, - }) - - if (utilitySheet) { - // If this import is intended to go into the utility sheet - // we'll collect it into a list to add later. If we don't' - // we'll end up adding them in reverse order. - utilityImports.add(newImport) - } else { - // This import will go immediately after the original import - node.after(newImport) - } - } - - // Add imports to the top of the utility sheet if necessary - if (utilitySheet && utilityImports.size > 0) { - utilitySheet.root.prepend(Array.from(utilityImports)) - } - } - - // Tracks the at rules that import a given stylesheet - let importNodes = new DefaultMap>(() => new Set()) - - for (let sheet of stylesheetsById.values()) { - for (let node of sheet.importRules) { - let sheetId = node.raws.tailwind_destination_sheet_id as StylesheetId | undefined - - // This import rule does not point to a stylesheet - if (!sheetId) continue - - let destination = stylesheetsById.get(sheetId) - - // This import rule does not point to a stylesheet that exists - // We'll remove it later - if (!destination) continue - - importNodes.get(destination).add(node) - } - } - - // At this point we've created many `{name}.utilities.css` files. - // If the original file _becomes_ empty after splitting that means that - // dedicated utility file is not required and we can move the utilities - // back to the original file. - // - // This could be done in one step but separating them makes it easier to - // reason about since the stylesheets are in a consistent state before we - // perform any cleanup tasks. - let list: Stylesheet[] = [] - - for (let sheet of stylesheets.slice()) { - for (let child of sheet.descendants()) { - list.push(child) - } - - list.push(sheet) - } - - for (let sheet of list) { - let utilitySheet = utilitySheets.get(sheet) - - // This sheet was not split so there's nothing to do - if (!utilitySheet) continue - - // This sheet did not become empty - if (!sheet.isEmpty) continue - - // We have a sheet that became empty after splitting - // 1. Replace the sheet with it's utility sheet content - sheet.root = utilitySheet.root - - // 2. Rewrite imports in parent sheets to point to the original sheet - // Ideally this wouldn't need to be _undone_ but instead only done once at the end - for (let node of importNodes.get(utilitySheet)) { - node.params = node.raws.tailwind_original_params as any - } - - // 3. Remove the original import from the non-utility sheet - for (let node of importNodes.get(sheet)) { - node.remove() - } - - // 3. Mark the utility sheet for removal - utilitySheets.delete(sheet) - } - - stylesheets.push(...utilitySheets.values()) -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/candidates.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/candidates.test.ts deleted file mode 100644 index 2e4d150aa..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/candidates.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { __unstable__loadDesignSystem } from '@tailwindcss/node' -import { expect, test } from 'vitest' -import { spliceChangesIntoString } from '../../utils/splice-changes-into-string' -import { extractRawCandidates } from './candidates' - -let html = String.raw - -test('extracts candidates with positions from a template', async () => { - let content = html` -
                          - -
                          - ` - let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { - base: __dirname, - }) - - let candidates = await extractRawCandidates(content, 'html') - let validCandidates = candidates.filter( - ({ rawCandidate }) => designSystem.parseCandidate(rawCandidate).length > 0, - ) - - expect(validCandidates).toMatchInlineSnapshot(` - [ - { - "end": 28, - "rawCandidate": "bg-blue-500", - "start": 17, - }, - { - "end": 51, - "rawCandidate": "hover:focus:text-white", - "start": 29, - }, - { - "end": 63, - "rawCandidate": "[color:red]", - "start": 52, - }, - { - "end": 98, - "rawCandidate": "bg-blue-500", - "start": 87, - }, - { - "end": 109, - "rawCandidate": "text-white", - "start": 99, - }, - ] - `) -}) - -test('replaces the right positions for a candidate', async () => { - let content = html` -

                          🤠👋

                          -
                          - ` - - let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', { - base: __dirname, - }) - - let candidates = await extractRawCandidates(content, 'html') - - let candidate = candidates.find( - ({ rawCandidate }) => designSystem.parseCandidate(rawCandidate).length > 0, - )! - - let migrated = spliceChangesIntoString(content, [ - { - start: candidate.start, - end: candidate.end, - replacement: 'flex', - }, - ]) - - expect(migrated).toMatchInlineSnapshot(` - " -

                          🤠👋

                          -
                          - " - `) -}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts b/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts deleted file mode 100644 index 88b1e4e03..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Scanner } from '@tailwindcss/oxide' -import { cloneCandidate, type Candidate } from '../../../../tailwindcss/src/candidate' -import type { DesignSystem } from '../../../../tailwindcss/src/design-system' - -export async function extractRawCandidates( - content: string, - extension: string = 'html', -): Promise<{ rawCandidate: string; start: number; end: number }[]> { - let scanner = new Scanner({}) - let result = scanner.getCandidatesWithPositions({ content, extension }) - - let candidates: { rawCandidate: string; start: number; end: number }[] = [] - for (let { candidate: rawCandidate, position: start } of result) { - candidates.push({ rawCandidate, start, end: start + rawCandidate.length }) - } - return candidates -} - -// Create a basic stripped candidate without variants or important flag -export function baseCandidate(candidate: T) { - let base = cloneCandidate(candidate) - - base.important = false - base.variants = [] - - return base -} - -export function parseCandidate(designSystem: DesignSystem, input: string) { - return designSystem.parseCandidate( - designSystem.theme.prefix && !input.startsWith(`${designSystem.theme.prefix}:`) - ? `${designSystem.theme.prefix}:${input}` - : input, - ) -} - -export function printUnprefixedCandidate(designSystem: DesignSystem, candidate: Candidate) { - let candidateString = designSystem.printCandidate(candidate) - - return designSystem.theme.prefix && candidateString.startsWith(`${designSystem.theme.prefix}:`) - ? candidateString.slice(designSystem.theme.prefix.length + 1) - : candidateString -} diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.test.ts deleted file mode 100644 index 7cfb16c75..000000000 --- a/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { __unstable__loadDesignSystem } from '@tailwindcss/node' -import { describe, expect, test, vi } from 'vitest' -import * as versions from '../../utils/version' -import { migrateCandidate } from './migrate' -vi.spyOn(versions, 'isMajor').mockReturnValue(true) - -const css = String.raw - -describe('is-safe-migration', async () => { - let designSystem = await __unstable__loadDesignSystem( - css` - @import 'tailwindcss'; - - /* TODO(perf): Only here to speed up the tests */ - @theme { - --*: initial; - --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); - --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); - } - `, - { base: __dirname }, - ) - - test.each([ - [`let notBorder = !border \n`, '!border'], - [`{ "foo": !border.something + ""}\n`, '!border'], - [`
                          \n`, '!border'], - [`
                          \n`, '!border'], - [`
                          \n`, '!border'], - [`
                          \n`, '!border'], - [`
                          \n`, '!border'], - [`
                          \n`, '!border'], - [`
                          \n`, '!border'], - [`
                          \n`, '!border'], - [`
                          \n`, '!border'], - [`
                          \n`, '!border'], - - [`let notShadow = shadow \n`, 'shadow'], - [`{ "foo": shadow.something + ""}\n`, 'shadow'], - [`
                          \n`, 'shadow'], - [`
                          \n`, 'shadow'], - [`
                          \n`, 'shadow'], - [`
                          \n`, 'shadow'], - [`
                          \n`, 'shadow'], - [`
                          \n`, 'shadow'], - [`
                          \n`, 'shadow'], - [`
                          \n`, 'shadow'], - [`
                          \n`, 'shadow'], - [`
                          \n`, 'shadow'], - [`
                          \n`, 'shadow'], - [`
                          \n`, 'flex-grow'], - [`
                          \n`, 'flex-shrink'], - [`
                          \n`, 'flex-shrink'], - [`
                          \n`, 'flex-shrink'], - - // Next.js Image placeholder cases - [``, 'blur'], - [``, 'blur'], - [``, 'blur'], - - // https://github.com/tailwindlabs/tailwindcss/issues/17974 - ['
                          ', '!duration'], - ['
                          ', '!duration'], - ['
                          ', '!visible'], - - // Alpine/Livewire wire:… - ['', 'blur'], - - // Vue 3 events - [`emit('blur', props.modelValue)\n`, 'blur'], - [`$emit('blur', props.modelValue)\n`, 'blur'], - - // JavaScript / TypeScript - [`document.addEventListener('blur',handleBlur)`, 'blur'], - [`document.addEventListener('blur', handleBlur)`, 'blur'], - - [`function foo({ outline = true })`, 'outline'], - [`function foo({ before = false, outline = true })`, 'outline'], - [`function foo({before=false,outline=true })`, 'outline'], - [`function foo({outline=true })`, 'outline'], - // https://github.com/tailwindlabs/tailwindcss/issues/18675 - [ - // With default value - `function foo({ size = "1.25rem", digit, outline = true, textClass = "", className = "" })`, - 'outline', - ], - [ - // Without default value - `function foo({ size = "1.25rem", digit, outline, textClass = "", className = "" })`, - 'outline', - ], - [ - // As the last argument - `function foo({ size = "1.25rem", digit, outline })`, - 'outline', - ], - [ - // As the last argument, but there is technically another `"` on the same line - `function foo({ size = "1.25rem", digit, outline }): { return "foo" }`, - 'outline', - ], - [ - // Tricky quote balancing - `function foo({ before = "'", outline, after = "'" }): { return "foo" }`, - 'outline', - ], - - [`function foo(blur, foo)`, 'blur'], - [`function foo(blur,foo)`, 'blur'], - - // shadcn/ui variants - [`