Repository: tailwindlabs/prettier-plugin-tailwindcss Branch: main Commit: 3997fbd5ddf6 Files: 142 Total size: 202.0 KB Directory structure: gitextract_s76hpf38/ ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report.md │ │ └── config.yml │ └── workflows/ │ ├── ci.yml │ ├── prepare-release.yml │ ├── release-insiders.yml │ └── release.yml ├── .gitignore ├── .oxlintrc.json ├── .prettierignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── knip.json ├── package.json ├── prettier.config.js ├── scripts/ │ ├── copy-licenses.js │ ├── install-fixture-deps.js │ ├── release-channel.js │ └── release-notes.js ├── src/ │ ├── config.ts │ ├── console.ts │ ├── create-plugin.ts │ ├── expiring-map.ts │ ├── index.ts │ ├── internal.d.ts │ ├── options.ts │ ├── resolve.ts │ ├── sorter.ts │ ├── sorting.ts │ ├── transform.ts │ ├── types.ts │ ├── utils.bench.ts │ ├── utils.test.ts │ ├── utils.ts │ └── versions/ │ ├── assets.ts │ ├── v3.ts │ └── v4.ts ├── tests/ │ ├── fixtures/ │ │ ├── basic/ │ │ │ ├── index.html │ │ │ ├── output.html │ │ │ ├── prettier.config.js │ │ │ └── tailwind.config.js │ │ ├── cjs/ │ │ │ ├── index.html │ │ │ ├── output.html │ │ │ ├── prettier.config.js │ │ │ └── tailwind.config.cjs │ │ ├── custom-jsx/ │ │ │ ├── index.jsx │ │ │ ├── output.jsx │ │ │ ├── prettier.config.js │ │ │ └── tailwind.config.js │ │ ├── custom-pkg-name-v3/ │ │ │ ├── config.js │ │ │ ├── index.html │ │ │ ├── output.html │ │ │ └── package.json │ │ ├── custom-pkg-name-v4/ │ │ │ ├── app.css │ │ │ ├── index.html │ │ │ ├── output.html │ │ │ └── package.json │ │ ├── custom-vue/ │ │ │ ├── index.vue │ │ │ ├── output.vue │ │ │ ├── prettier.config.js │ │ │ └── tailwind.config.js │ │ ├── esm/ │ │ │ ├── index.html │ │ │ ├── output.html │ │ │ ├── prettier.config.js │ │ │ └── tailwind.config.mjs │ │ ├── esm-explicit/ │ │ │ ├── config.mjs │ │ │ ├── index.html │ │ │ ├── output.html │ │ │ └── prettier.config.js │ │ ├── monorepo/ │ │ │ ├── .prettierrc │ │ │ ├── package-1/ │ │ │ │ ├── app.css │ │ │ │ ├── index.jsx │ │ │ │ ├── output.jsx │ │ │ │ └── package.json │ │ │ ├── package-2/ │ │ │ │ ├── index.jsx │ │ │ │ ├── output.jsx │ │ │ │ ├── package.json │ │ │ │ └── tailwind.config.js │ │ │ └── package.json │ │ ├── no-local-version/ │ │ │ ├── app.css │ │ │ ├── index.html │ │ │ ├── output.html │ │ │ └── package.json │ │ ├── no-prettier-config/ │ │ │ ├── index.html │ │ │ ├── output.html │ │ │ └── tailwind.config.js │ │ ├── no-stylesheet-given/ │ │ │ ├── index.html │ │ │ └── output.html │ │ ├── package.json │ │ ├── plugins/ │ │ │ ├── index.html │ │ │ ├── output.html │ │ │ ├── prettier.config.js │ │ │ └── tailwind.config.js │ │ ├── ts/ │ │ │ ├── index.html │ │ │ ├── output.html │ │ │ ├── prettier.config.js │ │ │ └── tailwind.config.ts │ │ ├── ts-explicit/ │ │ │ ├── config.ts │ │ │ ├── index.html │ │ │ ├── output.html │ │ │ └── prettier.config.js │ │ ├── v3-2/ │ │ │ ├── index.html │ │ │ ├── output.html │ │ │ ├── package.json │ │ │ ├── prettier.config.js │ │ │ └── tailwind.config.js │ │ ├── v3-jiti-reexport/ │ │ │ ├── index.html │ │ │ ├── output.html │ │ │ ├── package.json │ │ │ ├── packages/ │ │ │ │ └── tailwind-config/ │ │ │ │ ├── dist/ │ │ │ │ │ └── index.mjs │ │ │ │ ├── package.json │ │ │ │ └── src/ │ │ │ │ └── index.ts │ │ │ └── tailwind.config.mjs │ │ └── v4/ │ │ ├── basic/ │ │ │ ├── app.css │ │ │ ├── index.html │ │ │ ├── output.html │ │ │ └── package.json │ │ ├── css-loading-js/ │ │ │ ├── app.css │ │ │ ├── cjs/ │ │ │ │ ├── my-config.cjs │ │ │ │ └── my-plugin.cjs │ │ │ ├── esm/ │ │ │ │ ├── my-config.mjs │ │ │ │ └── my-plugin.mjs │ │ │ ├── index.html │ │ │ ├── output.html │ │ │ ├── package.json │ │ │ └── ts/ │ │ │ ├── my-config.ts │ │ │ └── my-plugin.ts │ │ └── subpath-imports/ │ │ ├── app.css │ │ ├── index.html │ │ ├── output.html │ │ ├── package.json │ │ └── theme.css │ ├── fixtures.test.ts │ ├── format.test.ts │ ├── plugins.test.ts │ ├── sorter.test.ts │ ├── tests.ts │ └── utils.ts ├── tsconfig.json ├── tsdown.config.ts └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ custom: ['https://tailwindcss.com/sponsor'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report.md ================================================ --- name: Bug report about: If you've already asked for help with a problem and confirmed something is broken with prettier-plugin-tailwindcss itself, create a bug report. title: '' labels: '' assignees: '' --- **What version of `prettier-plugin-tailwindcss` are you using?** For example: v0.1.7 **What version of Tailwind CSS are you using?** For example: v3.0.22 **What version of Node.js are you using?** For example: v12.0.0 **What package manager are you using?** For example: npm, Yarn **What operating system are you using?** For example: macOS, Windows **Reproduction URL** A 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. Unfortunately we can't provide support without a reproduction, and your issue will be closed with no comment if this is not provided. **Describe your issue** Describe the problem you're seeing, any important steps to reproduce and what behavior you expect instead ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Get Help url: https://github.com/tailwindlabs/tailwindcss/discussions/new?category=help&title=%5BPrettier%20Plugin%5D%20 about: If you can't get something to work the way you expect, open a question in our discussion forums. - name: Feature Request url: https://github.com/tailwindlabs/tailwindcss/discussions/new?category=ideas&title=%5BPrettier%20Plugin%5D%20 about: 'Suggest any ideas you have using our discussion forums.' ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: [main] pull_request: branches: [main] permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true env: CI: true NODE_VERSION: 22 jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm - name: Install dependencies run: pnpm install - name: Check formatting run: pnpm run format --check - name: Lint run: pnpm run lint - name: Build Prettier Plugin run: pnpm run build - name: Test against Prettier v3 run: pnpm run test ================================================ FILE: .github/workflows/prepare-release.yml ================================================ name: Prepare Release on: workflow_dispatch: push: tags: - 'v*' env: CI: true NODE_VERSION: 22 permissions: contents: read jobs: build: permissions: contents: write # for softprops/action-gh-release to create GitHub release runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: git fetch --tags -f - name: Resolve version id: vars run: | echo "TAG_NAME=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm registry-url: 'https://registry.npmjs.org' - name: Install dependencies run: pnpm install - name: Get release notes run: | RELEASE_NOTES=$(pnpm run release-notes --silent) echo "RELEASE_NOTES<> $GITHUB_ENV echo "$RELEASE_NOTES" >> $GITHUB_ENV echo "EOF" >> $GITHUB_ENV - name: Release uses: softprops/action-gh-release@v2 with: draft: true tag_name: ${{ env.TAG_NAME }} body: | ${{ env.RELEASE_NOTES }} ================================================ FILE: .github/workflows/release-insiders.yml ================================================ name: Release Insiders on: workflow_dispatch: permissions: contents: read id-token: write concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true env: CI: true NODE_VERSION: 22 RELEASE_CHANNEL: insiders jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm registry-url: "https://registry.npmjs.org" - name: Install dependencies run: pnpm install - name: Build Prettier Plugin run: pnpm run build - name: Test run: pnpm run test - name: Resolve version id: vars run: | echo "SHA_SHORT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV - name: "Version based on commit: 0.0.0-${{ env.RELEASE_CHANNEL }}.${{ env.SHA_SHORT }}" run: pnpm version 0.0.0-${{ env.RELEASE_CHANNEL }}.${{ env.SHA_SHORT }} --force --no-git-tag-version - name: Publish run: pnpm publish --provenance --tag ${{ env.RELEASE_CHANNEL }} --no-git-checks env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: release: types: [published] permissions: contents: read id-token: write concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true env: CI: true NODE_VERSION: 22 jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm registry-url: "https://registry.npmjs.org" - name: Install dependencies run: pnpm install - name: Build Prettier Plugin run: pnpm run build - name: Test run: pnpm run test - name: Calculate environment variables run: | echo "RELEASE_CHANNEL=$(pnpm run release-channel --silent)" >> $GITHUB_ENV - name: Publish run: pnpm publish --provenance --tag ${{ env.RELEASE_CHANNEL }} --no-git-checks env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ================================================ FILE: .gitignore ================================================ node_modules /dist ================================================ FILE: .oxlintrc.json ================================================ { "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json", "rules": { "typescript/unbound-method": "off" } } ================================================ FILE: .prettierignore ================================================ tests/fixtures .github/ dist/ README.md ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Changed - Remove top-level await ([#420](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/420)) - Improve load-time performance ([#420](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/420)) ### Fixed - Collapse whitespace in template literals with adjacent quasis ([#427](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/427)) ## [0.7.2] - 2025-12-01 ### Fixed - Load compatible plugins sequentially to work around race conditions in Node.js ([#412](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/412)) - Fix compatibility with `prettier-plugin-svelte` when using Prettier v3.7+ ([#418](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/418)) ## [0.7.1] - 2025-10-17 ### Fixed - Match against correct name of dynamic attributes when using regexes ([#410](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/410)) ## [0.7.0] - 2025-10-14 ### Added - Format quotes in `@source`, `@plugin`, and `@config` ([#387](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/387)) - Sort in function calls in Twig ([#358](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/358)) - Sort in callable template literals ([#367](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/367)) - Sort in function calls mixed with property accesses ([#367](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/367)) - Support regular expression patterns for attributes ([#405](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/405)) - Support regular expression patterns for function names ([#405](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/405)) ### Changed - Improved monorepo support by loading Tailwind CSS relative to the input file instead of prettier config file ([#386](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/386)) - Improved monorepo support by loading v3 configs relative to the input file instead of prettier config file ([#386](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/386)) - Fallback to Tailwind CSS v4 instead of v3 by default ([#390](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/390)) - Don't augment global Prettier `ParserOptions` and `RequiredOptions` types ([#354](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/354)) - Drop support for `prettier-plugin-import-sort` ([#385](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/385)) ### Fixed - Handle quote escapes in LESS when sorting `@apply` ([#392](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/392)) - Fix whitespace removal inside nested concat and template expressions ([#396](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/396)) ## [0.6.14] - 2025-07-09 - Add support for OXC + Hermes Prettier plugins ([#376](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/376), [#380](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/380)) - Sort template literals in Angular expressions ([#377](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/377)) - Don't repeatedly add backslashes to escape sequences when formatting ([#381](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/381)) ## [0.6.13] - 2025-06-19 - Prevent Svelte files from breaking when there are duplicate classes ([#359](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/359)) - Ensure `prettier-plugin-multiline-arrays` and `prettier-plugin-jsdoc` work when used together with this plugin ([#372](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/372)) ## [0.6.12] - 2025-05-30 - Add internal (unsupported) option to load Tailwind CSS using a different package name ([#366](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/366)) ## [0.6.11] - 2025-01-23 - Support TypeScript configs and plugins when using v4 ([#342](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/342)) ## [0.6.10] - 2025-01-15 - Add support for `@zackad/prettier-plugin-twig` ([#327](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/327)) - Drop support for `@zackad/prettier-plugin-twig-melody` ([#327](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/327)) - Update Prettier options types ([#325](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/325)) - Don't remove whitespace inside template literals in Svelte ([#332](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/332)) ## [0.6.9] - 2024-11-19 - Introduce `tailwindStylesheet` option to replace `tailwindEntryPoint` ([#330](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/330)) ## [0.6.8] - 2024-09-24 - Fix crash ([#320](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/320)) ## [0.6.7] - 2024-09-24 - Improved performance with large Svelte, Liquid, and Angular files ([#312](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/312)) - Add support for `@plugin` and `@config` in v4 ([#316](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/316)) - Add support for Tailwind CSS v4.0.0-alpha.25 ([#317](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/317)) ## [0.6.6] - 2024-08-09 - Add support for `prettier-plugin-multiline-arrays` ([#299](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/299)) - Add resolution cache for known plugins ([#301](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/301)) - Support Tailwind CSS `v4.0.0-alpha.19` ([#310](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/310)) ## [0.6.5] - 2024-06-17 - Only re-apply string escaping when necessary ([#295](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/295)) ## [0.6.4] - 2024-06-12 - Export `PluginOptions` type ([#292](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/292)) ## [0.6.3] - 2024-06-11 - Improve detection of string concatenation ([#288](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/288)) ## [0.6.2] - 2024-06-07 ### Changed - Only remove duplicate Tailwind classes ([#277](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/277)) - Make sure escapes in classes are preserved in string literals ([#286](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/286)) ## [0.6.1] - 2024-05-31 ### Added - Add new `tailwindPreserveDuplicates` option to disable removal of duplicate classes ([#276](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/276)) ### Fixed - Improve handling of whitespace removal when concatenating strings ([#276](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/276)) - Fix a bug where Angular expressions may produce invalid code after sorting ([#276](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/276)) - Disabled whitespace and duplicate class removal for Liquid and Svelte ([#276](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/276)) ## [0.6.0] - 2024-05-30 ### Changed - Remove duplicate classes ([#272](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/272)) - Remove extra whitespace around classes ([#272](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/272)) ## [0.5.14] - 2024-04-15 ### Fixed - Fix detection of v4 projects on Windows ([#265](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/265)) ## [0.5.13] - 2024-03-27 ### Added - Add support for `@zackad/prettier-plugin-twig-melody` ([#255](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/255)) ## [0.5.12] - 2024-03-06 ### Added - Add support for `prettier-plugin-sort-imports` ([#241](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/241)) - Add support for Tailwind CSS v4.0 ([#249](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/249)) ## [0.5.11] - 2024-01-05 ### Changed - Bumped bundled version of Tailwind CSS to v3.4.1 ([#240](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/240)) ## [0.5.10] - 2023-12-28 ### Changed - Bumped bundled version of Tailwind CSS to v3.4 ([#235](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/235)) ## [0.5.9] - 2023-12-05 ### Fixed - Fixed location of embedded preflight CSS file ([#231](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/231)) ## [0.5.8] - 2023-12-05 ### Added - Re-enable support for `prettier-plugin-marko` ([#229](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/229)) ## [0.5.7] - 2023-11-08 ### Fixed - Fix sorting inside dynamic custom attributes ([#225](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/225)) ## [0.5.6] - 2023-10-12 ### Fixed - Fix sorting inside `{{ … }}` expressions when using `@shopify/prettier-plugin-liquid` v1.3+ ([#222](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/222)) ## [0.5.5] - 2023-10-03 ### Fixed - Sort classes inside `className` in Astro ([#215](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/215)) - Support member access on function calls ([#218](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/218)) ## [0.5.4] - 2023-08-31 ### Fixed - Type `tailwindFunctions` and `tailwindAttributes` as optional ([#206](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/206)) - Don’t break `@apply … #{'!important'}` sorting in SCSS ([#212](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/212)) ## [0.5.3] - 2023-08-15 ### Fixed - Fix CJS `__dirname` interop on Windows ([#204](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/204)) ## [0.5.2] - 2023-08-11 ### Fixed - Fix intertop with bundled CJS dependencies ([#199](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/199)) ## [0.5.1] - 2023-08-10 ### Fixed - Updated Prettier peer dependency ([#197](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/197)) ## [0.5.0] - 2023-08-10 ### Added - Sort expressions in Astro's `class:list` attribute ([#192](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/192)) - Re-enabled support for plugins when using Prettier v3+ ([#195](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/195)) ## [0.4.1] - 2023-07-14 ### Fixed - Don't move partial classes inside Twig attributes ([#184](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/184)) ## [0.4.0] - 2023-07-11 ### Added - Export types for Prettier config ([#162](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/162)) - Add Prettier v3 support ([#179](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/179)) ### Fixed - Don't move partial classes inside Liquid script attributes ([#164](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/164)) - Do not split classes by non-ASCII whitespace ([#166](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/166)) - Match tagged template literals with tag expressions ([#169](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/169)) ## [0.3.0] - 2023-05-15 ### Added - Added support for `prettier-plugin-marko` ([#151](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/151)) - Allow sorting of custom attributes, functions, and tagged template literals ([#155](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/155)) ### Fixed - Speed up formatting ([#153](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/153)) - Fix plugin compatibility when loaded with require ([#159](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/159)) ## [0.2.8] - 2023-04-28 ### Changed - Remove support for `@prettier/plugin-php` ([#152](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/152)) ## [0.2.7] - 2023-04-05 ### Fixed - Don't break liquid tags inside attributes when sorting classes ([#143](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/143)) ## [0.2.6] - 2023-03-29 ### Added - Support ESM and TS config files ([#137](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/137)) ### Fixed - Load `tailwindcss` modules from nearest instance only ([#139](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/139)) ## [0.2.5] - 2023-03-17 ### Fixed - Fix class sorting in `capture` liquid tag ([#131](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/131)) ## [0.2.4] - 2023-03-02 ### Fixed - Sort `class` attribute on components and custom elements in Astro ([#129](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/129)) ## [0.2.3] - 2023-02-15 ### Fixed - Don't sort classes in Glimmer `concat` helper ([#119](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/119)) - Add support for `@ianvs/prettier-plugin-sort-imports` ([#122](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/122)) ## [0.2.2] - 2023-01-24 ### Fixed - Add prettier plugins to peer dependencies ([#114](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/114)) - Traverse await expression blocks in Svelte ([#118](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/118)) ## [0.2.1] - 2022-12-08 ### Fixed - Fix support for latest Shopify Liquid plugin ([#109](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/109)) ## [0.2.0] - 2022-11-25 ### Changed - Don't bundle `prettier-plugin-svelte` ([#101](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/101)) ### Added - Improve compatibility with other Prettier plugins ([#101](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/101), [#102](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/102)) ## [0.1.13] - 2022-07-25 ### Fixed - Fix error when using Angular pipes ([#86](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/86)) ## [0.1.12] - 2022-07-07 ### Added - Add support for Glimmer / Handlebars ([#83](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/83)) ## [0.1.11] - 2022-05-16 ### Changed - Update `prettier-plugin-svelte` to `v2.7.0` ([#77](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/77)) ### Fixed - Fix sorting in Svelte `:else` blocks ([#79](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/79)) ## [0.1.10] - 2022-04-20 ### Removed - Remove whitespace tidying and duplicate class removal due to [issues](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/issues/71) with whitespace removal ([#72](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/72)) ## [0.1.9] - 2022-04-19 ### Added - Add license file ([#64](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/64)) - Add whitespace tidying and duplicate class removal ([#70](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/70)) ## [0.1.8] - 2022-02-24 ### Changed - Use Tailwind's `getClassOrder` API when available ([#57](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/57)) ### Fixed - Fix Tailwind config file resolution when Prettier config file is not present ([#62](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/62)) ## [0.1.7] - 2022-02-09 ### Fixed - Fix single quotes being converted to double quotes ([#51](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/51)) ## [0.1.6] - 2022-02-08 ### Fixed - Fix error when no Prettier options provided ([#46](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/46)) ## [0.1.5] - 2022-02-04 ### Added - Add support for MDX ([#30](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/30)) ### Fixed - Fix error when formatting Svelte files that contain `let:class` attributes ([#24](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/24)) ## [0.1.4] - 2022-01-25 ### Fixed - Handle empty class attributes ([#17](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/17)) - Handle TypeScript syntax in Vue/Angular class attributes ([#18](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/18)) ## [0.1.3] - 2022-01-24 ### Fixed - Ignore `!important` when sorting `@apply` classes ([#4](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/4)) ## [0.1.2] - 2022-01-24 ### Fixed - Fix error when using nullish coalescing operator in Vue/Angular ([#2](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/2)) [unreleased]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.7.2...HEAD [0.7.2]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.7.1...v0.7.2 [0.7.1]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.7.0...v0.7.1 [0.7.0]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.6.14...v0.7.0 [0.6.14]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.6.13...v0.6.14 [0.6.13]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.6.12...v0.6.13 [0.6.12]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.6.11...v0.6.12 [0.6.11]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.6.10...v0.6.11 [0.6.10]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.6.9...v0.6.10 [0.6.9]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.6.8...v0.6.9 [0.6.8]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.6.7...v0.6.8 [0.6.7]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.6.6...v0.6.7 [0.6.6]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.6.5...v0.6.6 [0.6.5]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.6.4...v0.6.5 [0.6.4]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.6.3...v0.6.4 [0.6.3]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.6.2...v0.6.3 [0.6.2]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.6.1...v0.6.2 [0.6.1]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.6.0...v0.6.1 [0.6.0]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.5.14...v0.6.0 [0.5.14]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.5.13...v0.5.14 [0.5.13]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.5.12...v0.5.13 [0.5.12]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.5.11...v0.5.12 [0.5.11]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.5.10...v0.5.11 [0.5.10]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.5.9...v0.5.10 [0.5.9]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.5.8...v0.5.9 [0.5.8]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.5.7...v0.5.8 [0.5.7]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.5.6...v0.5.7 [0.5.6]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.5.5...v0.5.6 [0.5.5]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.5.4...v0.5.5 [0.5.4]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.5.3...v0.5.4 [0.5.3]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.5.2...v0.5.3 [0.5.2]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.5.1...v0.5.2 [0.5.1]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.5.0...v0.5.1 [0.5.0]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.4.1...v0.5.0 [0.4.1]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.4.0...v0.4.1 [0.4.0]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.2.8...v0.3.0 [0.2.8]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.2.7...v0.2.8 [0.2.7]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.2.6...v0.2.7 [0.2.6]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.2.5...v0.2.6 [0.2.5]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.2.4...v0.2.5 [0.2.4]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.2.3...v0.2.4 [0.2.3]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.2.2...v0.2.3 [0.2.2]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.2.1...v0.2.2 [0.2.1]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.1.13...v0.2.0 [0.1.13]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.1.12...v0.1.13 [0.1.12]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.1.11...v0.1.12 [0.1.11]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.1.10...v0.1.11 [0.1.10]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.1.9...v0.1.10 [0.1.9]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.1.8...v0.1.9 [0.1.8]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.1.7...v0.1.8 [0.1.7]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.1.6...v0.1.7 [0.1.6]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.1.5...v0.1.6 [0.1.5]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.1.4...v0.1.5 [0.1.4]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.1.3...v0.1.4 [0.1.3]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.1.2...v0.1.3 [0.1.2]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/d9c27f07a69bf9feec7f9d889426ad2ba76e1b09...v0.1.2 ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) Tailwind Labs Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ prettier-plugin-tailwindcss A [Prettier v3+](https://prettier.io/) plugin for Tailwind CSS v3.0+ that automatically sorts classes based on [our recommended class order](https://tailwindcss.com/blog/automatic-class-sorting-with-prettier#how-classes-are-sorted). ## Installation To get started, install `prettier-plugin-tailwindcss` as a dev-dependency: ```sh npm install -D prettier prettier-plugin-tailwindcss ``` Then add the plugin to your [Prettier configuration](https://prettier.io/docs/en/configuration.html): ```json5 // .prettierrc { "plugins": ["prettier-plugin-tailwindcss"] } ``` When using a JavaScript config, you can import the types for IntelliSense: ```js // prettier.config.js /** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */ export default { plugins: ["prettier-plugin-tailwindcss"], } ``` ## Upgrading to v0.5.x As of v0.5.x, this plugin now requires Prettier v3 and is ESM-only. This means it cannot be loaded via `require()`. For more information see our [upgrade guide](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/issues/207#issuecomment-1698071122). ## Options ### Specifying your Tailwind stylesheet path (Tailwind CSS v4+) When using Tailwind CSS v4 you must specify your CSS file entry point, which includes your theme, custom utilities, and other Tailwind configuration options. To do this, use the `tailwindStylesheet` option in your Prettier configuration. Note that paths are resolved relative to the Prettier configuration file. ```json5 // .prettierrc { "tailwindStylesheet": "./resources/css/app.css" } ``` ### Specifying your Tailwind JavaScript config path (Tailwind CSS v3) To ensure that the class sorting takes into consideration any of your project's Tailwind customizations, it needs access to your [Tailwind configuration file](https://tailwindcss.com/docs/configuration) (`tailwind.config.js`). By default the plugin will look for this file in the same directory as your Prettier configuration file. However, if your Tailwind configuration is somewhere else, you can specify this using the `tailwindConfig` option in your Prettier configuration. Note that paths are resolved relative to the Prettier configuration file. ```json5 // .prettierrc { "tailwindConfig": "./styles/tailwind.config.js" } ``` If a local configuration file cannot be found the plugin will fallback to the default Tailwind configuration. ## Sorting non-standard attributes By default this plugin sorts classes in the `class` attribute, any framework-specific equivalents like `className`, `:class`, `[ngClass]`, and any Tailwind `@apply` directives. You can sort additional attributes using the `tailwindAttributes` option, which takes an array of attribute names: ```json5 // .prettierrc { "tailwindAttributes": ["myClassList"] } ``` With this configuration, any classes found in the `myClassList` attribute will be sorted: ```jsx function MyButton({ children }) { return ( ); } ``` ### Using regex patterns You can also use regular expressions to match multiple attributes. Patterns should be enclosed in forward slashes. Note that JS regex literals are not supported with Prettier: ```json5 // .prettierrc { "tailwindAttributes": ["myClassList", "/data-.*/"] } ``` This example will sort classes in the `myClassList` attribute as well as any attribute starting with `data-`: ```jsx function MyButton({ children }) { return ( ); } ``` ## Sorting classes in function calls In addition to sorting classes in attributes, you can also sort classes in strings provided to function calls. This is useful when working with libraries like [clsx](https://github.com/lukeed/clsx) or [cva](https://cva.style/). You can sort classes in function calls using the `tailwindFunctions` option, which takes a list of function names: ```json5 // .prettierrc { "tailwindFunctions": ["clsx"] } ``` With this configuration, any classes in `clsx()` function calls will be sorted: ```jsx import clsx from 'clsx' function MyButton({ isHovering, children }) { let classes = clsx( 'rounded bg-blue-500 px-4 py-2 text-base text-white', { 'bg-blue-700 text-gray-100': isHovering, }, ) return ( ) } ``` ## Sorting classes in template literals This plugin also enables sorting of classes in tagged template literals. You can sort classes in template literals using the `tailwindFunctions` option, which takes a list of function names: ```json5 // .prettierrc { "tailwindFunctions": ["tw"], } ``` With this configuration, any classes in template literals tagged with `tw` will automatically be sorted: ```jsx import { View, Text } from 'react-native' import tw from 'twrnc' function MyScreen() { return ( Hello World ) } ``` This feature can be used with third-party libraries like `twrnc` or you can create your own tagged template by defining this "identity" function: ```js const tw = (strings, ...values) => String.raw({ raw: strings }, ...values) ``` Once added, tag your strings with the function and the plugin will sort them: ```js const mySortedClasses = tw`bg-white p-4 dark:bg-black` ``` ## Public API If you want to use the Tailwind class sorting logic outside of Prettier, import from the `sorter` entrypoint: ```js import { createSorter } from 'prettier-plugin-tailwindcss/sorter' let sorter = await createSorter({ base: '/path/to/project', stylesheetPath: './app.css', }) // Sort HTML class attributes (space-separated strings) let sorted = sorter.sortClassAttributes([ 'sm:bg-tomato bg-red-500', 'p-4 m-2' ]) // Returns: ['bg-red-500 sm:bg-tomato', 'm-2 p-4'] // Sort class lists (arrays of class names) let sortedLists = sorter.sortClassLists([ ['sm:bg-tomato', 'bg-red-500'], ['p-4', 'm-2'] ]) // Returns: [['bg-red-500', 'sm:bg-tomato'], ['m-2', 'p-4']] ``` ### API Options The `createSorter` function accepts the following options: - **`base`** (optional): The directory used to resolve relative file paths. Defaults to the current working directory. - **`filepath`** (optional): The path to the file being formatted. When provided, Tailwind CSS is resolved relative to this path. - **`configPath`** (optional): Path to the Tailwind CSS config file (v3). Paths are resolved relative to `base`. - **`stylesheetPath`** (optional): Path to the CSS stylesheet used by Tailwind CSS (v4+). Paths are resolved relative to `base`. - **`preserveWhitespace`** (optional): Whether to preserve whitespace around classes. Default: `false`. - **`preserveDuplicates`** (optional): Whether to preserve duplicate classes. Default: `false`. ### Sorter Methods The sorter object returned by `createSorter` has two methods: - **`sortClassAttributes(classes: string[]): string[]`** Sorts one or more HTML class attributes. Each element should be a space-separated string of class names (like the value of an HTML `class` attribute). - **`sortClassLists(classes: string[][]): (string | null)[][]`** Sorts one or more class lists. Each element should be an array of individual class names. When removing duplicates (default behavior), duplicate classes are replaced with `null` in the output. ### Using regex patterns Like the `tailwindAttributes` option, the `tailwindFunctions` option also supports regular expressions to match multiple function names. Patterns should be enclosed in forward slashes. Note that JS regex literals are not supported with Prettier. ## Preserving whitespace This plugin automatically removes unnecessary whitespace between classes to ensure consistent formatting. If you prefer to preserve whitespace, you can use the `tailwindPreserveWhitespace` option: ```json5 // .prettierrc { "tailwindPreserveWhitespace": true, } ``` With this configuration, any whitespace surrounding classes will be preserved: ```jsx import clsx from 'clsx' function MyButton({ isHovering, children }) { return ( ) } ``` ## Preserving duplicate classes This plugin automatically removes duplicate classes from your class lists. However, this can cause issues in some templating languages, like Fluid or Blade, where we can't distinguish between classes and the templating syntax. If removing duplicate classes is causing issues in your project, you can use the `tailwindPreserveDuplicates` option to disable this behavior: ```json5 // .prettierrc { "tailwindPreserveDuplicates": true, } ``` With this configuration, anything we perceive as duplicate classes will be preserved: ```html
``` ## Compatibility with other Prettier plugins This plugin uses Prettier APIs that can only be used by one plugin at a time, making it incompatible with other Prettier plugins implemented the same way. To solve this we've added explicit per-plugin workarounds that enable compatibility with the following Prettier plugins: - `@ianvs/prettier-plugin-sort-imports` - `@prettier/plugin-pug` - `@shopify/prettier-plugin-liquid` - `@trivago/prettier-plugin-sort-imports` - `prettier-plugin-astro` - `prettier-plugin-css-order` - `prettier-plugin-jsdoc` - `prettier-plugin-multiline-arrays` - `prettier-plugin-organize-attributes` - `prettier-plugin-organize-imports` - `prettier-plugin-svelte` - `prettier-plugin-sort-imports` One limitation with this approach is that `prettier-plugin-tailwindcss` *must* be loaded last. ```json5 // .prettierrc { // .. "plugins": [ "prettier-plugin-svelte", "prettier-plugin-organize-imports", "prettier-plugin-tailwindcss" // MUST come last ] } ``` ================================================ FILE: knip.json ================================================ { "$schema": "https://unpkg.com/knip@5/schema.json", "entry": ["scripts/*.js"], "project": ["src/**/*.ts", "scripts/**/*.js", "tests/**/*.ts"], "ignore": ["src/**/*.d.ts", "tests/fixtures/**"], "ignoreDependencies": [ "@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-oxc", "@prettier/plugin-hermes", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte", "import-sort-style-module", "marko", "postcss", "postcss-import" ], "rules": { "exports": "off", "types": "off" } } ================================================ FILE: package.json ================================================ { "type": "module", "name": "prettier-plugin-tailwindcss", "version": "0.7.2", "description": "A Prettier plugin for sorting Tailwind CSS classes.", "license": "MIT", "main": "dist/index.mjs", "module": "dist/index.mjs", "types": "dist/index.d.mts", "files": [ "dist" ], "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs", "default": "./dist/index.mjs" }, "./sorter": { "types": "./dist/sorter.d.mts", "import": "./dist/sorter.mjs", "default": "./dist/sorter.mjs" } }, "repository": { "type": "git", "url": "https://github.com/tailwindlabs/prettier-plugin-tailwindcss" }, "bugs": { "url": "https://github.com/tailwindlabs/prettier-plugin-tailwindcss/issues" }, "scripts": { "build": "tsdown", "dev": "tsdown --watch", "pretest": "node scripts/install-fixture-deps.js", "test": "vitest", "prepublishOnly": "npm run build && node scripts/copy-licenses.js", "format": "prettier \"src/**/*.ts\" \"scripts/**/*.js\" \"tests/*.ts\" --write --print-width 100 --single-quote --no-semi", "lint": "knip && oxlint --type-aware src tests/*.ts", "release-channel": "node ./scripts/release-channel.js", "release-notes": "node ./scripts/release-notes.js" }, "devDependencies": { "@babel/types": "^7.28.2", "@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@marko/compiler": "^5.39.49", "@prettier/plugin-hermes": "^0.1.2", "@prettier/plugin-oxc": "^0.1.2", "@prettier/plugin-pug": "^3.4.2", "@shopify/prettier-plugin-liquid": "^1.10.0", "@trivago/prettier-plugin-sort-imports": "^6.0.0", "@types/node": "^24.3.0", "@zackad/prettier-plugin-twig": "^0.16.0", "clear-module": "^4.1.2", "dedent": "^1.6.0", "enhanced-resolve": "^5.18.3", "escalade": "^3.2.0", "fast-glob": "^3.3.3", "import-sort-style-module": "^6.0.0", "jiti": "^2.6.0", "knip": "^5.83.0", "license-checker": "^25.0.1", "line-column": "^1.0.2", "marko": "^5.37.46", "oxlint": "^1.43.0", "oxlint-tsgolint": "^0.11.4", "postcss": "^8.5.6", "postcss-import": "^16.1.1", "prettier": "^3.7.3", "prettier-plugin-astro": "^0.14.1", "prettier-plugin-css-order": "^2.1.2", "prettier-plugin-jsdoc": "^1.3.3", "prettier-plugin-marko": "^3.3.2", "prettier-plugin-multiline-arrays": "^4.0.3", "prettier-plugin-organize-attributes": "^1.0.0", "prettier-plugin-organize-imports": "^4.2.0", "prettier-plugin-sort-imports": "^1.8.8", "prettier-plugin-svelte": "^3.4.0", "pug-lexer": "^5.0.1", "svelte": "^5.38.2", "tailwindcss-v3": "npm:tailwindcss@^3.4.18", "tailwindcss-v4": "npm:tailwindcss@^4.1.14", "tsdown": "^0.20.1", "vitest": "^3.2.4" }, "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "peerDependenciesMeta": { "@ianvs/prettier-plugin-sort-imports": { "optional": true }, "@prettier/plugin-hermes": { "optional": true }, "@prettier/plugin-oxc": { "optional": true }, "@prettier/plugin-pug": { "optional": true }, "@shopify/prettier-plugin-liquid": { "optional": true }, "@trivago/prettier-plugin-sort-imports": { "optional": true }, "@zackad/prettier-plugin-twig": { "optional": true }, "prettier-plugin-astro": { "optional": true }, "prettier-plugin-css-order": { "optional": true }, "prettier-plugin-jsdoc": { "optional": true }, "prettier-plugin-marko": { "optional": true }, "prettier-plugin-multiline-arrays": { "optional": true }, "prettier-plugin-organize-attributes": { "optional": true }, "prettier-plugin-organize-imports": { "optional": true }, "prettier-plugin-sort-imports": { "optional": true }, "prettier-plugin-svelte": { "optional": true } }, "engines": { "node": ">=20.19" }, "importSort": { ".js, .jsx, .ts, .tsx": { "style": "module" } }, "jest": { "testTimeout": 15000 }, "overrides": { "@ianvs/prettier-plugin-sort-imports": { "@prettier/plugin-oxc": "^0.1.2" } }, "packageManager": "pnpm@10.28.2" } ================================================ FILE: prettier.config.js ================================================ /** @type {import('prettier').Config} */ export default { semi: false, singleQuote: true, trailingComma: 'all', plugins: ['@ianvs/prettier-plugin-sort-imports'], importOrder: ['^@', '^[a-zA-Z0-9-]+', '^[./]'], printWidth: 120, } ================================================ FILE: scripts/copy-licenses.js ================================================ import * as fs from 'node:fs/promises' import * as path from 'node:path' import { fileURLToPath } from 'node:url' import checker from 'license-checker' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const pkg = JSON.parse(await fs.readFile(path.resolve(__dirname, '../package.json'), 'utf8')) let exclude = ['vitest', 'license-checker', 'prettier', 'svelte', 'knip'] /** @type {checker.ModuleInfo} */ let packages = await new Promise((resolve, reject) => { checker.init({ start: path.resolve(__dirname, '..') }, (_err, packages) => { if (_err) { reject(_err) } else { resolve(packages) } }) }) for (let key in packages) { let dep = packages[key] let name = key.split(/(?<=.)@/)[0] if (exclude.includes(name)) continue if (!dep.licenseFile) continue if (!(name in pkg.devDependencies)) continue let dir = path.resolve(__dirname, '../dist/licenses', name) await fs.mkdir(dir, { recursive: true }) await fs.copyFile(dep.licenseFile, path.resolve(dir, path.basename(dep.licenseFile))) } ================================================ FILE: scripts/install-fixture-deps.js ================================================ import { exec } from 'node:child_process' import * as path from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import glob from 'fast-glob' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const fixtures = glob.sync( [ 'tests/fixtures/*/package.json', 'tests/fixtures/v4/*/package.json', 'tests/fixtures/monorepo/*/package.json', ], { cwd: path.resolve(__dirname, '..'), }, ) const execAsync = promisify(exec) await Promise.all( fixtures.map(async (fixture) => { console.log(`Installing dependencies for ${fixture}`) await execAsync('npm install', { cwd: path.dirname(fixture) }) }), ) ================================================ FILE: scripts/release-channel.js ================================================ // Given a version, figure out what the release channel is so that we can publish to the correct // channel on npm. // // E.g.: // // 1.2.3 -> latest (default) // 0.0.0-insiders.ffaa88 -> insiders // 4.1.0-alpha.4 -> alpha import * as fs from 'node:fs/promises' import * as path from 'node:path' import { fileURLToPath } from 'node:url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const pkg = JSON.parse(await fs.readFile(path.resolve(__dirname, '../package.json'), 'utf8')) let version = process.argv[2] || process.env.npm_package_version || pkg.version let match = /\d+\.\d+\.\d+-(.*)\.\d+/g.exec(version) if (match) { console.log(match[1]) } else { console.log('latest') } ================================================ FILE: scripts/release-notes.js ================================================ // Given a version, figure out what the release notes are so that we can use this to pre-fill the // relase notes on a GitHub release for the current version. import * as fs from 'node:fs/promises' import * as path from 'node:path' import { fileURLToPath } from 'node:url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const pkg = JSON.parse(await fs.readFile(path.resolve(__dirname, '../package.json'), 'utf8')) let version = process.argv[2] || process.env.npm_package_version || pkg.version let changelog = await fs.readFile(path.resolve(__dirname, '..', 'CHANGELOG.md'), 'utf8') let match = new RegExp( `## \\[${version}\\] - (.*)\\n\\n([\\s\\S]*?)\\n(?:(?:##\\s)|(?:\\[))`, 'g', ).exec(changelog) if (match) { let [, , notes] = match console.log(notes.trim()) } else { console.log(`Placeholder release notes for version: v${version}`) } ================================================ FILE: src/config.ts ================================================ // @ts-check import * as path from 'node:path' import prettier from 'prettier' import type { ParserOptions } from 'prettier' import * as console from './console' import { expiringMap } from './expiring-map.js' import { getTailwindConfig as getTailwindConfigFromLib } from './sorter.js' import type { UnifiedApi } from './types' import { cacheForDirs } from './utils.js' let prettierConfigCache = expiringMap(10_000) async function resolvePrettierConfigDir( filePath: string, inputDir: string, ): Promise { // Check cache for this directory let cached = prettierConfigCache.get(inputDir) if (cached !== undefined) { return cached ?? process.cwd() } const resolve = async () => { try { return await prettier.resolveConfigFile(filePath) } catch (err) { console.error('prettier-config-not-found', 'Failed to resolve Prettier Config') console.error('prettier-config-not-found-err', err) return null } } let prettierConfig = await resolve() // Cache all directories from inputDir up to config location if (prettierConfig) { let configDir = path.dirname(prettierConfig) cacheForDirs(prettierConfigCache, inputDir, configDir, configDir) return configDir } else { prettierConfigCache.set(inputDir, null) return process.cwd() } } export async function getTailwindConfig(options: ParserOptions): Promise { let cwd = process.cwd() let inputDir = options.filepath ? path.dirname(options.filepath) : cwd // Only resolve prettier config dir if we need it for relative path resolution let needsPrettierConfig = (options.tailwindConfig && !path.isAbsolute(options.tailwindConfig)) || (options.tailwindStylesheet && !path.isAbsolute(options.tailwindStylesheet)) || (options.tailwindEntryPoint && !path.isAbsolute(options.tailwindEntryPoint)) let configDir: string if (needsPrettierConfig) { configDir = await resolvePrettierConfigDir(options.filepath, inputDir) } else { configDir = cwd } let configPath = options.tailwindConfig && !options.tailwindConfig.endsWith('.css') ? options.tailwindConfig : undefined let stylesheetPath = options.tailwindStylesheet if (!stylesheetPath && options.tailwindEntryPoint) { console.warn( 'entrypoint-is-deprecated', configDir, 'Deprecated: Use the `tailwindStylesheet` option for v4 projects instead of `tailwindEntryPoint`.', ) stylesheetPath = options.tailwindEntryPoint } if (!stylesheetPath && options.tailwindConfig && options.tailwindConfig.endsWith('.css')) { console.warn( 'config-as-css-is-deprecated', configDir, 'Deprecated: Use the `tailwindStylesheet` option for v4 projects instead of `tailwindConfig`.', ) stylesheetPath = options.tailwindConfig } return getTailwindConfigFromLib({ base: configDir, filepath: options.filepath, configPath, stylesheetPath, packageName: options.tailwindPackageName, }) } ================================================ FILE: src/console.ts ================================================ let seen = new Set() export function log(key: string, arg: unknown, ...args: unknown[]) { if (seen.has(key)) return seen.add(key) console.log(arg, ...args) } export function warn(key: string, arg: unknown, ...args: unknown[]) { if (seen.has(key)) return seen.add(key) console.warn(arg, ...args) } export function error(key: string, arg: unknown, ...args: unknown[]) { if (seen.has(key)) return seen.add(key) console.error(arg, ...args) } ================================================ FILE: src/create-plugin.ts ================================================ import { isAbsolute } from 'path' import type { Parser, ParserOptions, Plugin, Printer } from 'prettier' import { getTailwindConfig } from './config' import { createMatcher } from './options' import { loadIfExists, maybeResolve } from './resolve' import type { PluginLoad, TransformOptions } from './transform' import type { TransformerEnv } from './types' export function createPlugin(transforms: TransformOptions[]) { // Prettier parsers and printers may be async functions at definition time. // They'll be awaited when the plugin is loaded but must also be swapped out // with the resolved value before returning as later Prettier internals // assume that parsers and printers are objects and not functions. type Init = (() => Promise) | T | undefined let parsers: Record>> = Object.create(null) let printers: Record>> = Object.create(null) for (let opts of transforms) { for (let [name, meta] of Object.entries(opts.parsers)) { parsers[name] = async () => { let plugin = await loadPlugins(meta.load ?? opts.load ?? []) let original = plugin.parsers?.[name] if (!original) return parsers[name] = await createParser({ name, original, opts, }) return parsers[name] } } for (let [name, _meta] of Object.entries(opts.printers ?? {})) { printers[name] = async () => { let plugin = await loadPlugins(opts.load ?? []) let original = plugin.printers?.[name] if (!original) return printers[name] = createPrinter({ original, opts, }) return printers[name] } } } return { parsers, printers } } async function createParser({ name, original, opts, }: { name: string original: Parser opts: TransformOptions }) { let parser: Parser = { ...original } async function load(options: ParserOptions) { let parser: Parser = { ...original } for (const pluginName of opts.compatible || []) { let plugin = await findEnabledPlugin(options, pluginName) if (plugin?.parsers?.[name]) Object.assign(parser, plugin.parsers[name]) } return parser } parser.preprocess = async (code: string, options: ParserOptions) => { let parser = await load(options) return parser.preprocess ? parser.preprocess(code, options) : code } parser.parse = async (code, options) => { let original = await load(options) // @ts-expect-error: `options` is passed twice for compat with older plugins that were written // for Prettier v2 but still work with v3. // // Currently only the Twig plugin requires this. let ast = await original.parse(code, options, options) let env = await loadTailwindCSS({ opts, options }) transformAst({ ast, env, opts, options, }) options.__tailwindcss__ = env return ast } return parser } function createPrinter({ original, opts, }: { original: Printer opts: TransformOptions }) { let printer: Printer = { ...original } let reprint = opts.reprint // Hook into the preprocessing phase to load the config if (reprint) { printer.print = new Proxy(original.print, { apply(target, thisArg, args) { let [path, options] = args as Parameters let env = options.__tailwindcss__ as TransformerEnv reprint(path, { ...env, options: options }) return Reflect.apply(target, thisArg, args) }, }) if (original.embed) { printer.embed = new Proxy(original.embed, { apply(target, thisArg, args) { let [path, options] = args as Parameters let env = options.__tailwindcss__ as TransformerEnv reprint(path, { ...env, options: options as any }) return Reflect.apply(target, thisArg, args) }, }) } } return printer } async function loadPlugins(fns: PluginLoad[]) { let plugin: Plugin = { parsers: Object.create(null), printers: Object.create(null), options: Object.create(null), defaultOptions: Object.create(null), languages: [], } for (let source of fns) { let loaded = await loadPlugin(source) Object.assign(plugin.parsers!, loaded.parsers ?? {}) Object.assign(plugin.printers!, loaded.printers ?? {}) Object.assign(plugin.options!, loaded.options ?? {}) Object.assign(plugin.defaultOptions!, loaded.defaultOptions ?? {}) plugin.languages = [...(plugin.languages ?? []), ...(loaded.languages ?? [])] } return plugin } const EMPTY_PLUGIN: Plugin = { parsers: {}, printers: {}, languages: [], options: {}, defaultOptions: {}, } async function loadPlugin(source: PluginLoad): Promise> { if ('importer' in source && typeof source.importer === 'function') { return normalizePlugin(await source.importer()) } return source } function normalizePlugin(source: unknown): Plugin { if (source === null || typeof source !== 'object') return EMPTY_PLUGIN let maybeModule = source as { default?: unknown } let plugin = maybeModule.default return (plugin && typeof plugin === 'object' ? plugin : source) as Plugin } function findEnabledPlugin(options: ParserOptions, name: string) { for (let plugin of options.plugins) { if (plugin instanceof URL) { if (plugin.protocol !== 'file:') continue if (plugin.hostname !== '') continue plugin = plugin.pathname } if (typeof plugin !== 'string') { if (!plugin.name) { continue } plugin = plugin.name } if ( plugin === name || (isAbsolute(plugin) && plugin.includes(name) && maybeResolve(name) === plugin) ) { return loadIfExists>(name) } } } async function loadTailwindCSS({ options, opts, }: { options: ParserOptions opts: TransformOptions }): Promise { let parsers = opts.parsers let parser = options.parser as string let context = await getTailwindConfig(options) let matcher = createMatcher(options, parser, { staticAttrs: new Set(parsers[parser]?.staticAttrs ?? opts.staticAttrs ?? []), dynamicAttrs: new Set(parsers[parser]?.dynamicAttrs ?? opts.dynamicAttrs ?? []), functions: new Set(), staticAttrsRegex: [], dynamicAttrsRegex: [], functionsRegex: [], }) return { context, matcher, options, changes: [], } } function transformAst({ ast, env, opts, }: { ast: T env: TransformerEnv options: ParserOptions opts: TransformOptions }) { let transform = opts.transform if (transform) transform(ast, env) } ================================================ FILE: src/expiring-map.ts ================================================ interface ExpiringMap { get(key: K): V | undefined remember(key: K, factory: () => V): V set(key: K, value: V): void } export function expiringMap(duration: number): ExpiringMap { let map = new Map() return { get(key: K) { let result = map.get(key) if (result && result.expiration > new Date()) { return result.value } map.delete(key) return undefined }, remember(key: K, factory: () => V) { let result = map.get(key) if (result && result.expiration > new Date()) { return result.value } map.delete(key) let value = factory() this.set(key, value) return value }, set(key: K, value: V) { let expiration = new Date() expiration.setMilliseconds(expiration.getMilliseconds() + duration) map.set(key, { value, expiration, }) }, } } ================================================ FILE: src/index.ts ================================================ // @ts-ignore import type * as Liquid from '@shopify/prettier-plugin-liquid/dist/types.js' // @ts-ignore import lineColumn from 'line-column' import * as prettierParserAngular from 'prettier/plugins/angular' import * as prettierParserBabel from 'prettier/plugins/babel' import * as prettierParserCss from 'prettier/plugins/postcss' // @ts-ignore import { createPlugin } from './create-plugin.js' import type { Matcher } from './options.js' import { sortClasses, sortClassList } from './sorting.js' import { defineTransform } from './transform.js' import type { StringChange, TransformerEnv } from './types' import { spliceChangesIntoString, visit, type Path } from './utils.js' const ESCAPE_SEQUENCE_PATTERN = /\\(['"\\nrtbfv0-7xuU])/g function tryParseAngularAttribute(value: string, env: TransformerEnv) { try { return prettierParserAngular.parsers.__ng_directive.parse(value, env.options) } catch (err) { console.warn('prettier-plugin-tailwindcss: Unable to parse angular directive') console.warn(err) return null } } function transformDynamicAngularAttribute(attr: any, env: TransformerEnv) { let directiveAst = tryParseAngularAttribute(attr.value, env) // If we've reached this point we couldn't parse the expression we we should bail // `tryParseAngularAttribute` will display some warnings/errors // But we shouldn't fail outright — just miss parsing some attributes if (!directiveAst) { return } let changes: StringChange[] = [] visit(directiveAst, { StringLiteral(node, path) { if (!node.value) return let collapseWhitespace = canCollapseWhitespaceIn(path, env) changes.push({ start: node.start + 1, end: node.end - 1, before: node.value, after: sortClasses(node.value, { env, collapseWhitespace, }), }) }, TemplateLiteral(node, path) { if (!node.quasis.length) return let collapseWhitespace = canCollapseWhitespaceIn(path, env) for (let i = 0; i < node.quasis.length; i++) { let quasi = node.quasis[i] changes.push({ start: quasi.start, end: quasi.end, before: quasi.value.raw, after: sortClasses(quasi.value.raw, { env, // Is not the first "item" and does not start with a space ignoreFirst: i > 0 && !/^\s/.test(quasi.value.raw), // Is between two expressions // And does not end with a space ignoreLast: i < node.expressions.length && !/\s$/.test(quasi.value.raw), collapseWhitespace: collapseWhitespace ? { start: collapseWhitespace.start && i === 0, end: collapseWhitespace.end && i >= node.expressions.length, } : false, }), }) } }, }) attr.value = spliceChangesIntoString(attr.value, changes) } function transformDynamicJsAttribute(attr: any, env: TransformerEnv) { let { matcher } = env let expressionPrefix = 'let __prettier_temp__ = ' let source = `${expressionPrefix}${attr.value}` let ast = prettierParserBabel.parsers['babel-ts'].parse(source, env.options) let didChange = false let changes: StringChange[] = [] function findConcatEntry(path: Path) { return path.find( (entry) => entry.parent?.type === 'BinaryExpression' && entry.parent.operator === '+', ) } function addChange( start: number | null | undefined, end: number | null | undefined, after: string, ) { if (start == null || end == null) return let offsetStart = start - expressionPrefix.length let offsetEnd = end - expressionPrefix.length if (offsetStart < 0 || offsetEnd < 0) return didChange = true changes.push({ start: offsetStart, end: offsetEnd, before: attr.value.slice(offsetStart, offsetEnd), after, }) } visit(ast, { StringLiteral(node, path) { let concat = findConcatEntry(path) let sorted = sortStringLiteral(node, { env, collapseWhitespace: { start: concat?.key !== 'right', end: concat?.key !== 'left', }, }) if (sorted) { // @ts-ignore let raw = node.extra?.raw ?? node.raw if (typeof raw === 'string') { addChange(node.start, node.end, raw) } } }, Literal(node: any, path) { if (!isStringLiteral(node)) return let concat = findConcatEntry(path) let sorted = sortStringLiteral(node, { env, collapseWhitespace: { start: concat?.key !== 'right', end: concat?.key !== 'left', }, }) if (sorted) { // @ts-ignore let raw = node.extra?.raw ?? node.raw if (typeof raw === 'string') { addChange(node.start, node.end, raw) } } }, TemplateLiteral(node, path) { let concat = findConcatEntry(path) let originalQuasis = node.quasis.map((quasi) => quasi.value.raw) let sorted = sortTemplateLiteral(node, { env, collapseWhitespace: { start: concat?.key !== 'right', end: concat?.key !== 'left', }, }) if (sorted) { for (let i = 0; i < node.quasis.length; i++) { let quasi = node.quasis[i] if (quasi.value.raw !== originalQuasis[i]) { addChange(quasi.start, quasi.end, quasi.value.raw) } } } }, TaggedTemplateExpression(node, path) { if (!isSortableTemplateExpression(node, matcher)) { return } let concat = findConcatEntry(path) let originalQuasis = node.quasi.quasis.map((quasi) => quasi.value.raw) let sorted = sortTemplateLiteral(node.quasi, { env, collapseWhitespace: { start: concat?.key !== 'right', end: concat?.key !== 'left', }, }) if (sorted) { for (let i = 0; i < node.quasi.quasis.length; i++) { let quasi = node.quasi.quasis[i] if (quasi.value.raw !== originalQuasis[i]) { addChange(quasi.start, quasi.end, quasi.value.raw) } } } }, }) if (didChange) { attr.value = spliceChangesIntoString(attr.value, changes) } } function transformHtml(ast: any, env: TransformerEnv) { let { matcher } = env let { parser } = env.options for (let attr of ast.attrs ?? []) { if (matcher.hasStaticAttr(attr.name)) { attr.value = sortClasses(attr.value, { env }) } else if (matcher.hasDynamicAttr(attr.name)) { if (!/[`'"]/.test(attr.value)) { continue } if (parser === 'angular') { transformDynamicAngularAttribute(attr, env) } else { transformDynamicJsAttribute(attr, env) } } } for (let child of ast.children ?? []) { transformHtml(child, env) } } function transformGlimmer(ast: any, env: TransformerEnv) { let { matcher } = env visit(ast, { AttrNode(attr, _path, meta) { if (matcher.hasStaticAttr(attr.name) && attr.value) { meta.sortTextNodes = true } }, TextNode(node, path, meta) { if (!meta.sortTextNodes) { return } let concat = path.find((entry) => { return entry.parent && entry.parent.type === 'ConcatStatement' }) let siblings = { prev: concat?.parent.parts[concat.index! - 1], next: concat?.parent.parts[concat.index! + 1], } node.chars = sortClasses(node.chars, { env, ignoreFirst: siblings.prev && !/^\s/.test(node.chars), ignoreLast: siblings.next && !/\s$/.test(node.chars), collapseWhitespace: { start: !siblings.prev, end: !siblings.next, }, }) }, StringLiteral(node, path, meta) { if (!meta.sortTextNodes) { return } let concat = path.find((entry) => { return ( entry.parent && entry.parent.type === 'SubExpression' && entry.parent.path.original === 'concat' ) }) node.value = sortClasses(node.value, { env, ignoreLast: Boolean(concat) && !/[^\S\r\n]$/.test(node.value), collapseWhitespace: { start: false, end: !concat, }, }) }, }) } function transformLiquid(ast: any, env: TransformerEnv) { let { matcher } = env function isClassAttr(node: { name: string | { type: string; value: string }[] }) { return Array.isArray(node.name) ? node.name.every((n) => n.type === 'TextNode' && matcher.hasStaticAttr(n.value)) : matcher.hasStaticAttr(node.name) } function hasSurroundingQuotes(str: string) { let start = str[0] let end = str[str.length - 1] return start === end && (start === '"' || start === "'" || start === '`') } let sources: { type: string; source: string }[] = [] let changes: StringChange[] = [] function sortAttribute(attr: Liquid.AttrSingleQuoted | Liquid.AttrDoubleQuoted) { for (let i = 0; i < attr.value.length; i++) { let node = attr.value[i] if (node.type === 'TextNode') { let after = sortClasses(node.value, { env, ignoreFirst: i > 0 && !/^\s/.test(node.value), ignoreLast: i < attr.value.length - 1 && !/\s$/.test(node.value), removeDuplicates: false, collapseWhitespace: false, }) changes.push({ start: node.position.start, end: node.position.end, before: node.value, after, }) } else if ( // @ts-ignore: `LiquidDrop` is for older versions of the liquid plugin (1.2.x) (node.type === 'LiquidDrop' || node.type === 'LiquidVariableOutput') && typeof node.markup === 'object' && node.markup.type === 'LiquidVariable' ) { visit(node.markup.expression, { String(node: any) { let pos = { ...node.position } // We have to offset the position ONLY when quotes are part of the String node // This is because `value` does NOT include quotes if (hasSurroundingQuotes(node.source.slice(pos.start, pos.end))) { pos.start += 1 pos.end -= 1 } let after = sortClasses(node.value, { env }) changes.push({ start: pos.start, end: pos.end, before: node.value, after, }) }, }) } } } visit(ast, { LiquidTag(node: any) { sources.push(node) }, HtmlElement(node: any) { sources.push(node) }, AttrSingleQuoted(node: any) { if (isClassAttr(node)) { sources.push(node) sortAttribute(node) } }, AttrDoubleQuoted(node: any) { if (isClassAttr(node)) { sources.push(node) sortAttribute(node) } }, }) for (let node of sources) { node.source = spliceChangesIntoString(node.source, changes) } } function sortStringLiteral( node: any, { env, removeDuplicates, collapseWhitespace = { start: true, end: true }, }: { env: TransformerEnv removeDuplicates?: false collapseWhitespace?: false | { start: boolean; end: boolean } }, ) { let result = sortClasses(node.value, { env, removeDuplicates, collapseWhitespace, }) let didChange = result !== node.value if (!didChange) return false node.value = result // Preserve the original escaping level for the new content let raw = node.extra?.raw ?? node.raw let quote = raw[0] let originalRawContent = raw.slice(1, -1) let originalValue = node.extra?.rawValue ?? node.value if (node.extra) { // The original list has ecapes so we ensure that the sorted list also // maintains those by replacing backslashes from escape sequences. // // It seems that TypeScript-based ASTs don't need this special handling // which is why this is guarded inside the `node.extra` check if (originalRawContent !== originalValue && originalValue.includes('\\')) { result = result.replace(ESCAPE_SEQUENCE_PATTERN, '\\\\$1') } // JavaScript (StringLiteral) node.extra = { ...node.extra, rawValue: result, raw: quote + result + quote, } } else { // TypeScript (Literal) node.raw = quote + result + quote } return true } function isStringLiteral(node: any) { return ( node.type === 'StringLiteral' || (node.type === 'Literal' && typeof node.value === 'string') ) } function sortTemplateLiteral( node: any, { env, removeDuplicates, collapseWhitespace = { start: true, end: true }, }: { env: TransformerEnv removeDuplicates?: false collapseWhitespace?: false | { start: boolean; end: boolean } }, ) { let didChange = false for (let i = 0; i < node.quasis.length; i++) { let quasi = node.quasis[i] let same = quasi.value.raw === quasi.value.cooked let originalRaw = quasi.value.raw let originalCooked = quasi.value.cooked quasi.value.raw = sortClasses(quasi.value.raw, { env, removeDuplicates, // Is not the first "item" and does not start with a space ignoreFirst: i > 0 && !/^\s/.test(quasi.value.raw), // Is between two expressions // And does not end with a space ignoreLast: i < node.expressions.length && !/\s$/.test(quasi.value.raw), collapseWhitespace: collapseWhitespace && { start: collapseWhitespace && collapseWhitespace.start && i === 0, end: collapseWhitespace && collapseWhitespace.end && i >= node.expressions.length, }, }) quasi.value.cooked = same ? quasi.value.raw : sortClasses(quasi.value.cooked, { env, ignoreFirst: i > 0 && !/^\s/.test(quasi.value.cooked), ignoreLast: i < node.expressions.length && !/\s$/.test(quasi.value.cooked), removeDuplicates, collapseWhitespace: collapseWhitespace && { start: collapseWhitespace && collapseWhitespace.start && i === 0, end: collapseWhitespace && collapseWhitespace.end && i >= node.expressions.length, }, }) if (quasi.value.raw !== originalRaw || quasi.value.cooked !== originalCooked) { didChange = true } } return didChange } function isSortableTemplateExpression( node: import('@babel/types').TaggedTemplateExpression, matcher: Matcher, ): boolean { return isSortableExpression(node.tag, matcher) } function isSortableCallExpression( node: import('@babel/types').CallExpression, matcher: Matcher, ): boolean { if (!node.arguments?.length) return false return isSortableExpression(node.callee, matcher) } function isSortableExpression( node: import('@babel/types').Expression | import('@babel/types').V8IntrinsicIdentifier, matcher: Matcher, ): boolean { // Traverse property accesses and function calls to find the leading ident while (node.type === 'CallExpression' || node.type === 'MemberExpression') { if (node.type === 'CallExpression') { node = node.callee } else if (node.type === 'MemberExpression') { node = node.object } } if (node.type === 'Identifier') { return matcher.hasFunction(node.name) } return false } function canCollapseWhitespaceIn( path: Path, env: TransformerEnv, ): false | { start: boolean; end: boolean } { if (env.options.tailwindPreserveWhitespace) { return false } let start = true let end = true for (let entry of path) { if (!entry.parent) continue // Nodes inside concat expressions shouldn't collapse whitespace // depending on which side they're part of. if (entry.parent.type === 'BinaryExpression' && entry.parent.operator === '+') { start &&= entry.key !== 'right' end &&= entry.key !== 'left' } // This is probably expression *inside* of a template literal. To collapse whitespace // `Expression`s adjacent-before a quasi must start with whitespace // `Expression`s adjacent-after a quasi must end with whitespace // // Note this check will bail out on more than it really should as it // could be reset somewhere along the way by having whitespace around a // string further up but not at the "root" but that complicates things if (entry.parent.type === 'TemplateLiteral') { let nodeStart = entry.node.start ?? null let nodeEnd = entry.node.end ?? null for (let quasi of entry.parent.quasis) { let quasiStart = quasi.start ?? null let quasiEnd = quasi.end ?? null if (nodeStart !== null && quasiEnd !== null && nodeStart - quasiEnd <= 2) { start &&= /^\s/.test(quasi.value.raw) } if (nodeEnd !== null && quasiStart !== null && nodeEnd - quasiStart <= 2) { end &&= /\s$/.test(quasi.value.raw) } } } } return { start, end } } // TODO: The `ast` types here aren't strictly correct. // // We cross several parsers that share roughly the same shape so things are // good enough. The actual AST we should be using is probably estree + ts. function transformJavaScript(ast: import('@babel/types').Node, env: TransformerEnv) { let { matcher } = env function sortInside(ast: import('@babel/types').Node) { visit(ast, (node, path) => { let collapseWhitespace = canCollapseWhitespaceIn(path, env) if (isStringLiteral(node)) { sortStringLiteral(node, { env, collapseWhitespace }) } else if (node.type === 'TemplateLiteral') { sortTemplateLiteral(node, { env, collapseWhitespace }) } else if (node.type === 'TaggedTemplateExpression') { if (isSortableTemplateExpression(node, matcher)) { sortTemplateLiteral(node.quasi, { env, collapseWhitespace }) } } }) } visit(ast, { JSXAttribute(node) { node = node as import('@babel/types').JSXAttribute if (!node.value) { return } // We don't want to support namespaced attributes (e.g. `somens:class`) // React doesn't support them and most tools don't either if (typeof node.name.name !== 'string') { return } if (!matcher.hasStaticAttr(node.name.name)) { return } if (isStringLiteral(node.value)) { sortStringLiteral(node.value, { env }) } else if (node.value.type === 'JSXExpressionContainer') { sortInside(node.value) } }, CallExpression(node) { node = node as import('@babel/types').CallExpression if (!isSortableCallExpression(node, matcher)) { return } node.arguments.forEach((arg) => sortInside(arg)) }, TaggedTemplateExpression(node, path) { node = node as import('@babel/types').TaggedTemplateExpression if (!isSortableTemplateExpression(node, matcher)) { return } let collapseWhitespace = canCollapseWhitespaceIn(path, env) sortTemplateLiteral(node.quasi, { env, collapseWhitespace, }) }, }) } function transformCss(ast: any, env: TransformerEnv) { // `parseValue` inside Prettier's CSS parser is private API so we have to // produce the same result by parsing an import statement with the same params function tryParseAtRuleParams(name: string, params: any) { // It might already be an object or array. Could happen in the future if // Prettier decides to start parsing these. if (typeof params !== 'string') return params // Otherwise we let prettier re-parse the params into its custom value AST // based on postcss-value parser. try { let parser = prettierParserCss.parsers.css let root = parser.parse(`@import ${params};`, { // We can't pass env.options directly because css.parse overwrites // options.originalText which is used during the printing phase ...env.options, }) return root.nodes[0].params } catch (err) { console.warn(`[prettier-plugin-tailwindcss] Unable to parse at rule`) console.warn({ name, params }) console.warn(err) } return params } ast.walk((node: any) => { if (node.name === 'plugin' || node.name === 'config' || node.name === 'source') { node.params = tryParseAtRuleParams(node.name, node.params) } if (node.type === 'css-atrule' && node.name === 'apply') { let isImportant = /\s+(?:!important|#{(['"]*)!important\1})\s*$/.test(node.params) let classList = node.params let prefix = '' let suffix = '' if (classList.startsWith('~"') && classList.endsWith('"')) { prefix = '~"' suffix = '"' classList = classList.slice(2, -1) isImportant = false } else if (classList.startsWith("~'") && classList.endsWith("'")) { prefix = "~'" suffix = "'" classList = classList.slice(2, -1) isImportant = false } classList = sortClasses(classList, { env, ignoreLast: isImportant, collapseWhitespace: { start: false, end: !isImportant, }, }) node.params = `${prefix}${classList}${suffix}` } }) } function transformAstro(ast: any, env: TransformerEnv) { let { matcher } = env if (ast.type === 'element' || ast.type === 'custom-element' || ast.type === 'component') { for (let attr of ast.attributes ?? []) { if (matcher.hasStaticAttr(attr.name) && attr.type === 'attribute' && attr.kind === 'quoted') { attr.value = sortClasses(attr.value, { env, }) } else if ( matcher.hasDynamicAttr(attr.name) && attr.type === 'attribute' && attr.kind === 'expression' && typeof attr.value === 'string' ) { transformDynamicJsAttribute(attr, env) } } } for (let child of ast.children ?? []) { transformAstro(child, env) } } function transformMarko(ast: any, env: TransformerEnv) { let { matcher } = env const nodesToVisit = [ast] while (nodesToVisit.length > 0) { const currentNode = nodesToVisit.pop() switch (currentNode.type) { case 'File': nodesToVisit.push(currentNode.program) break case 'Program': nodesToVisit.push(...currentNode.body) break case 'MarkoTag': nodesToVisit.push(...currentNode.attributes) nodesToVisit.push(currentNode.body) break case 'MarkoTagBody': nodesToVisit.push(...currentNode.body) break case 'MarkoAttribute': if (!matcher.hasStaticAttr(currentNode.name)) break switch (currentNode.value.type) { case 'ArrayExpression': const classList = currentNode.value.elements for (const node of classList) { if (node.type === 'StringLiteral') { node.value = sortClasses(node.value, { env }) } } break case 'StringLiteral': currentNode.value.value = sortClasses(currentNode.value.value, { env, }) break } break } } } function transformTwig(ast: any, env: TransformerEnv) { let { matcher } = env for (let child of ast.expressions ?? []) { transformTwig(child, env) } visit(ast, { Attribute(node, _path, meta) { if (!matcher.hasStaticAttr(node.name.name)) return meta.sortTextNodes = true }, CallExpression(node, _path, meta) { // Traverse property accesses and function calls to find the *trailing* ident while (node.type === 'CallExpression' || node.type === 'MemberExpression') { if (node.type === 'CallExpression') { node = node.callee } else if (node.type === 'MemberExpression') { // TODO: This is *different* than `isSortableExpression` and that doesn't feel right // but they're mutually exclusive implementations // // This is to handle foo.fnNameHere(…) where `isSortableExpression` is intentionally // handling `fnNameHere.foo(…)`. node = node.property } } if (node.type === 'Identifier') { if (!matcher.hasFunction(node.name)) return } meta.sortTextNodes = true }, StringLiteral(node, path, meta) { if (!meta.sortTextNodes) { return } const concat = path.find((entry) => { return ( entry.parent && (entry.parent.type === 'BinaryConcatExpression' || entry.parent.type === 'BinaryAddExpression') ) }) node.value = sortClasses(node.value, { env, ignoreFirst: concat?.key === 'right' && !/^[^\S\r\n]/.test(node.value), ignoreLast: concat?.key === 'left' && !/[^\S\r\n]$/.test(node.value), collapseWhitespace: { start: concat?.key !== 'right', end: concat?.key !== 'left', }, }) }, }) } function transformPug(ast: any, env: TransformerEnv) { let { matcher } = env // This isn't optimal // We should merge the classes together across class attributes and class tokens // And then we sort them // But this is good enough for now // First sort the classes in attributes for (const token of ast.tokens) { if (token.type === 'attribute' && matcher.hasStaticAttr(token.name)) { token.val = [ token.val.slice(0, 1), sortClasses(token.val.slice(1, -1), { env }), token.val.slice(-1), ].join('') } } // Collect lists of consecutive class tokens let startIdx = -1 let endIdx = -1 let ranges: [number, number][] = [] for (let i = 0; i < ast.tokens.length; i++) { const token = ast.tokens[i] if (token.type === 'class') { startIdx = startIdx === -1 ? i : startIdx endIdx = i } else if (startIdx !== -1) { ranges.push([startIdx, endIdx]) startIdx = -1 endIdx = -1 } } if (startIdx !== -1) { ranges.push([startIdx, endIdx]) startIdx = -1 endIdx = -1 } // Sort the lists of class tokens for (const [startIdx, endIdx] of ranges) { const classes = ast.tokens.slice(startIdx, endIdx + 1).map((token: any) => token.val) const { classList } = sortClassList({ classList: classes, api: env.context, removeDuplicates: false, }) for (let i = startIdx; i <= endIdx; i++) { ast.tokens[i].val = classList[i - startIdx] } } } function transformSvelte(ast: any, env: TransformerEnv) { let { matcher, changes } = env for (let attr of ast.attributes ?? []) { if (!matcher.hasStaticAttr(attr.name) || attr.type !== 'Attribute') { continue } for (let i = 0; i < attr.value.length; i++) { let value = attr.value[i] if (value.type === 'Text') { let same = value.raw === value.data value.raw = sortClasses(value.raw, { env, ignoreFirst: i > 0 && !/^\s/.test(value.raw), ignoreLast: i < attr.value.length - 1 && !/\s$/.test(value.raw), removeDuplicates: true, collapseWhitespace: false, }) value.data = same ? value.raw : sortClasses(value.data, { env, ignoreFirst: i > 0 && !/^\s/.test(value.data), ignoreLast: i < attr.value.length - 1 && !/\s$/.test(value.data), removeDuplicates: true, collapseWhitespace: false, }) } else if (value.type === 'MustacheTag') { visit(value.expression, { Literal(node) { if (isStringLiteral(node)) { let before = node.raw let sorted = sortStringLiteral(node, { env, removeDuplicates: false, collapseWhitespace: false, }) if (sorted) { changes.push({ before, after: node.raw, start: node.loc.start, end: node.loc.end, }) } } }, TemplateLiteral(node) { let before = node.quasis.map((quasi: any) => quasi.value.raw) let sorted = sortTemplateLiteral(node, { env, removeDuplicates: false, collapseWhitespace: false, }) if (sorted) { for (let [idx, quasi] of node.quasis.entries()) { changes.push({ before: before[idx], after: quasi.value.raw, start: quasi.loc.start, end: quasi.loc.end, }) } } }, }) } } } for (let child of ast.children ?? []) { transformSvelte(child, env) } if (ast.type === 'IfBlock') { for (let child of ast.else?.children ?? []) { transformSvelte(child, env) } } if (ast.type === 'AwaitBlock') { let nodes = [ast.pending, ast.then, ast.catch] for (let child of nodes) { transformSvelte(child, env) } } if (ast.html) { transformSvelte(ast.html, env) } } export { options } from './options.js' type HtmlNode = | { type: 'attribute'; name: string; value: string } | { kind: 'attribute'; name: string; value: string } let html = defineTransform({ staticAttrs: ['class'], load: [{ name: 'prettier/plugins/html', importer: () => import('prettier/plugins/html') }], compatible: ['prettier-plugin-organize-attributes'], parsers: { html: {}, lwc: {}, angular: { dynamicAttrs: ['[ngClass]'] }, vue: { dynamicAttrs: [':class', 'v-bind:class'] }, }, transform: transformHtml, }) type GlimmerNode = | { type: 'TextNode'; chars: string } | { type: 'StringLiteral'; value: string } | { type: 'ConcatStatement'; parts: GlimmerNode[] } | { type: 'SubExpression'; path: { original: string } } | { type: 'AttrNode'; name: string; value: GlimmerNode } let glimmer = defineTransform({ staticAttrs: ['class'], load: [{ name: 'prettier/plugins/glimmer', importer: () => import('prettier/plugins/glimmer') }], parsers: { glimmer: {}, }, transform: transformGlimmer, }) type CssValueNode = { type: 'value-*'; name: string; params: string } type CssNode = { type: 'css-atrule' name: string params: string | CssValueNode } let css = defineTransform({ load: [prettierParserCss], compatible: ['prettier-plugin-css-order'], parsers: { css: {}, scss: {}, less: {}, }, transform: transformCss, }) let js = defineTransform({ staticAttrs: ['class', 'className'], compatible: [ // The following plugins must come *before* the jsdoc plugin for it to // function correctly. Additionally `multiline-arrays` usually needs to be // placed before import sorting plugins. // // https://github.com/electrovir/prettier-plugin-multiline-arrays#compatibility 'prettier-plugin-multiline-arrays', '@ianvs/prettier-plugin-sort-imports', '@trivago/prettier-plugin-sort-imports', 'prettier-plugin-organize-imports', 'prettier-plugin-sort-imports', 'prettier-plugin-jsdoc', ], parsers: { babel: { load: [prettierParserBabel] }, 'babel-flow': { load: [prettierParserBabel] }, 'babel-ts': { load: [prettierParserBabel] }, __js_expression: { load: [prettierParserBabel] }, typescript: { load: [ { name: 'prettier/plugins/typescript', importer: () => import('prettier/plugins/typescript'), }, ], }, meriyah: { load: [ { name: 'prettier/plugins/meriyah', importer: () => import('prettier/plugins/meriyah') }, ], }, acorn: { load: [{ name: 'prettier/plugins/acorn', importer: () => import('prettier/plugins/acorn') }], }, flow: { load: [{ name: 'prettier/plugins/flow', importer: () => import('prettier/plugins/flow') }], }, oxc: { load: [{ name: '@prettier/plugin-oxc', importer: () => import('@prettier/plugin-oxc') }], }, 'oxc-ts': { load: [{ name: '@prettier/plugin-oxc', importer: () => import('@prettier/plugin-oxc') }], }, hermes: { load: [ { name: '@prettier/plugin-hermes', importer: () => import('@prettier/plugin-hermes') }, ], }, astroExpressionParser: { load: [ { name: 'prettier-plugin-astro', importer: () => { // @ts-expect-error - This plugin doesn't have types return import('prettier-plugin-astro') }, }, ], staticAttrs: ['class'], dynamicAttrs: ['class:list'], }, }, transform: transformJavaScript, }) type SvelteNode = import('svelte/compiler').AST.SvelteNode & { changes: StringChange[] } let svelte = defineTransform({ staticAttrs: ['class'], load: [{ name: 'prettier-plugin-svelte', importer: () => import('prettier-plugin-svelte') }], parsers: { svelte: {}, }, printers: { 'svelte-ast': {}, }, transform: transformSvelte, reprint(path, { options, changes }) { if (options.__mutatedOriginalText) return options.__mutatedOriginalText = true if (!changes?.length) return let finder = lineColumn(options.originalText) let stringChanges: StringChange[] = changes.map((change) => ({ ...change, start: finder.toIndex(change.start.line, change.start.column + 1), end: finder.toIndex(change.end.line, change.end.column + 1), })) options.originalText = spliceChangesIntoString(options.originalText, stringChanges) }, }) type AstroNode = | { type: 'element'; attributes: Extract[] } | { type: 'custom-element' attributes: Extract[] } | { type: 'component' attributes: Extract[] } | { type: 'attribute'; kind: 'quoted'; name: string; value: string } | { type: 'attribute'; kind: 'expression'; name: string; value: unknown } let astro = defineTransform({ staticAttrs: ['class', 'className'], dynamicAttrs: ['class:list', 'className'], load: [ { name: 'prettier-plugin-astro', importer: () => { // @ts-expect-error - This plugin doesn't have types return import('prettier-plugin-astro') }, }, ], parsers: { astro: {}, }, transform: transformAstro, }) type MarkoNode = import('@marko/compiler').types.Node let marko = defineTransform({ staticAttrs: ['class'], load: [{ name: 'prettier-plugin-marko', importer: () => import('prettier-plugin-marko') }], parsers: { marko: {}, }, transform: transformMarko, }) type TwigIdentifier = { type: 'Identifier'; name: string } type TwigMemberExpression = { type: 'MemberExpression' property: TwigIdentifier | TwigCallExpression | TwigMemberExpression } type TwigCallExpression = { type: 'CallExpression' callee: TwigIdentifier | TwigCallExpression | TwigMemberExpression } type TwigNode = | { type: 'Attribute'; name: TwigIdentifier } | { type: 'StringLiteral'; value: string } | { type: 'BinaryConcatExpression' } | { type: 'BinaryAddExpression' } | TwigIdentifier | TwigMemberExpression | TwigCallExpression let twig = defineTransform({ staticAttrs: ['class'], load: [ { name: '@zackad/prettier-plugin-twig', importer: () => { // @ts-expect-error - This plugin doesn't have types return import('@zackad/prettier-plugin-twig') }, }, ], parsers: { twig: {}, }, transform: transformTwig, }) interface PugNode { content: string tokens: import('pug-lexer').Token[] } let pug = defineTransform({ staticAttrs: ['class'], load: [{ name: '@prettier/plugin-pug', importer: () => import('@prettier/plugin-pug') }], parsers: { pug: {}, }, transform: transformPug, }) type LiquidNode = | Liquid.TextNode | Liquid.AttributeNode | Liquid.LiquidTag | Liquid.HtmlElement | Liquid.DocumentNode | Liquid.LiquidExpression let liquid = defineTransform({ staticAttrs: ['class'], load: [ { name: '@shopify/prettier-plugin-liquid', importer: () => import('@shopify/prettier-plugin-liquid'), }, ], parsers: { 'liquid-html': {} }, transform: transformLiquid, }) export const { parsers, printers } = createPlugin([ // html, glimmer, css, js, svelte, astro, marko, twig, pug, liquid, ]) export interface PluginOptions { /** * Path to the Tailwind config file. */ tailwindConfig?: string /** * Path to the CSS stylesheet used by Tailwind CSS (v4+) */ tailwindStylesheet?: string /** * Path to the CSS stylesheet used by Tailwind CSS (v4+) * * @deprecated Use `tailwindStylesheet` instead */ tailwindEntryPoint?: string /** * List of custom function and tag names that contain classes. * * Default: [] */ tailwindFunctions?: string[] /** * List of custom attributes that contain classes. * * Default: [] */ tailwindAttributes?: string[] /** * Preserve whitespace around Tailwind classes when sorting. * * Default: false */ tailwindPreserveWhitespace?: boolean /** * Preserve duplicate classes inside a class list when sorting. * * Default: false */ tailwindPreserveDuplicates?: boolean } ================================================ FILE: src/internal.d.ts ================================================ import type { PluginOptions } from '.' export interface InternalOptions extends PluginOptions { printer: Printer /** * The package name to use when loading Tailwind CSS */ tailwindPackageName?: string } export interface InternalPlugin { name?: string } declare module 'prettier' { interface RequiredOptions extends InternalOptions {} interface ParserOptions extends InternalOptions {} interface Plugin extends InternalPlugin {} } ================================================ FILE: src/options.ts ================================================ import type { RequiredOptions, SupportOption } from 'prettier' import type { Customizations } from './types' export const options: Record = { tailwindConfig: { type: 'string', category: 'Tailwind CSS', description: 'Path to Tailwind configuration file', }, tailwindEntryPoint: { type: 'string', category: 'Tailwind CSS', description: 'Path to the CSS entrypoint in your Tailwind project (v4+)', // Can't include this otherwise the option is not passed to parsers // deprecated: "This option is deprecated. Use 'tailwindStylesheet' instead.", }, tailwindStylesheet: { type: 'string', category: 'Tailwind CSS', description: 'Path to the CSS stylesheet in your Tailwind project (v4+)', }, tailwindAttributes: { type: 'string', array: true, default: [{ value: [] }], category: 'Tailwind CSS', description: 'List of attributes/props that contain sortable Tailwind classes', }, tailwindFunctions: { type: 'string', array: true, default: [{ value: [] }], category: 'Tailwind CSS', description: 'List of functions and tagged templates that contain sortable Tailwind classes', }, tailwindPreserveWhitespace: { type: 'boolean', default: false, category: 'Tailwind CSS', description: 'Preserve whitespace around Tailwind classes when sorting', }, tailwindPreserveDuplicates: { type: 'boolean', default: false, category: 'Tailwind CSS', description: 'Preserve duplicate classes inside a class list when sorting', }, tailwindPackageName: { type: 'string', default: 'tailwindcss', category: 'Tailwind CSS', description: 'The package name to use when loading Tailwind CSS', }, } export interface Matcher { hasStaticAttr(name: string): boolean hasDynamicAttr(name: string): boolean hasFunction(name: string): boolean } export function createMatcher( options: RequiredOptions, parser: string, defaults: Customizations, ): Matcher { let staticAttrs = new Set(defaults.staticAttrs) let dynamicAttrs = new Set(defaults.dynamicAttrs) let functions = new Set(defaults.functions) let staticAttrsRegex: RegExp[] = [...defaults.staticAttrsRegex] let functionsRegex: RegExp[] = [...defaults.functionsRegex] // Create a list of "static" attributes for (let attr of options.tailwindAttributes ?? []) { let regex = parseRegex(attr) if (regex) { staticAttrsRegex.push(regex) } else if (parser === 'vue' && attr.startsWith(':')) { staticAttrs.add(attr.slice(1)) } else if (parser === 'vue' && attr.startsWith('v-bind:')) { staticAttrs.add(attr.slice(7)) } else if (parser === 'vue' && attr.startsWith('v-')) { dynamicAttrs.add(attr) } else if (parser === 'angular' && attr.startsWith('[') && attr.endsWith(']')) { staticAttrs.add(attr.slice(1, -1)) } else { staticAttrs.add(attr) } } // Generate a list of dynamic attributes for (let attr of staticAttrs) { if (parser === 'vue') { dynamicAttrs.add(`:${attr}`) dynamicAttrs.add(`v-bind:${attr}`) } else if (parser === 'angular') { dynamicAttrs.add(`[${attr}]`) } } // Generate a list of supported functions for (let fn of options.tailwindFunctions ?? []) { let regex = parseRegex(fn) if (regex) { functionsRegex.push(regex) } else { functions.add(fn) } } return { hasStaticAttr: (name: string) => { // If the name looks like a dynamic attribute we're not a static attr // Only applies to Vue and Angular let newName = nameFromDynamicAttr(name, parser) if (newName) return false return hasMatch(name, staticAttrs, staticAttrsRegex) }, hasDynamicAttr: (name: string) => { // This is definitely a dynamic attribute if (hasMatch(name, dynamicAttrs, [])) return true // If the name looks like a dynamic attribute compare the actual name // Only applies to Vue and Angular let newName = nameFromDynamicAttr(name, parser) if (!newName) return false return hasMatch(newName, staticAttrs, staticAttrsRegex) }, hasFunction: (name: string) => hasMatch(name, functions, functionsRegex), } } function nameFromDynamicAttr(name: string, parser: string) { if (parser === 'vue') { if (name.startsWith(':')) return name.slice(1) if (name.startsWith('v-bind:')) return name.slice(7) if (name.startsWith('v-')) return name return null } if (parser === 'angular') { if (name.startsWith('[') && name.endsWith(']')) return name.slice(1, -1) return null } return null } /** * Check for matches against a static list or possible regex patterns */ function hasMatch(name: string, list: Set, patterns: RegExp[]): boolean { if (list.has(name)) return true for (let regex of patterns) { if (regex.test(name)) return true } return false } function parseRegex(str: string): RegExp | null { if (!str.startsWith('/')) return null let lastSlash = str.lastIndexOf('/') if (lastSlash <= 0) return null try { let pattern = str.slice(1, lastSlash) let flags = str.slice(lastSlash + 1) return new RegExp(pattern, flags) } catch { return null } } ================================================ FILE: src/resolve.ts ================================================ import fs from 'node:fs' import { fileURLToPath } from 'node:url' import { CachedInputFileSystem, ResolverFactory } from 'enhanced-resolve' import { expiringMap } from './expiring-map' const fileSystem = new CachedInputFileSystem(fs, 30_000) const esmResolver = ResolverFactory.createResolver({ fileSystem, useSyncFileSystemCalls: true, extensions: ['.mjs', '.js'], mainFields: ['module'], conditionNames: ['node', 'import'], }) const cjsResolver = ResolverFactory.createResolver({ fileSystem, useSyncFileSystemCalls: true, extensions: ['.js', '.cjs'], mainFields: ['main'], conditionNames: ['node', 'require'], }) const cssResolver = ResolverFactory.createResolver({ fileSystem, useSyncFileSystemCalls: true, extensions: ['.css'], mainFields: ['style'], conditionNames: ['style'], }) // This is a long-lived cache for resolved modules whether they exist or not // Because we're compatible with a large number of plugins, we need to check // for the existence of a module before attempting to import it. This cache // is used to mitigate the cost of that check because Node.js does not cache // failed module resolutions making repeated checks very expensive. const resolveCache = expiringMap(30_000) export function maybeResolve(name: string) { let modpath = resolveCache.get(name) if (modpath === undefined) { try { modpath = resolveJsFrom(fileURLToPath(import.meta.url), name) resolveCache.set(name, modpath) } catch { resolveCache.set(name, null) return null } } return modpath } export async function loadIfExists(name: string): Promise { let modpath = maybeResolve(name) if (modpath) { let mod = await import(name) return mod.default ?? mod } return null } export function resolveJsFrom(base: string, id: string): string { try { return esmResolver.resolveSync({}, base, id) || id } catch { return cjsResolver.resolveSync({}, base, id) || id } } export function resolveCssFrom(base: string, id: string) { return cssResolver.resolveSync({}, base, id) || id } ================================================ FILE: src/sorter.ts ================================================ import * as path from 'node:path' import { pathToFileURL } from 'node:url' import escalade from 'escalade/sync' import * as console from './console' import { expiringMap } from './expiring-map.js' import { resolveJsFrom } from './resolve' import { sortClasses, sortClassList } from './sorting.js' import type { TransformerEnv, UnifiedApi } from './types' import { cacheForDirs } from './utils.js' export interface SorterOptions { /** * The directory used to resolve relative file paths. * * When not provided this will be: * - The current working directory */ base?: string /** * The path to the file being formatted. * * When provided, Tailwind CSS is resolved relative to this path; otherwise, * it is resolved relative to `base`. */ filepath?: string /** * Path to the Tailwind CSS config file (v3). * * Paths are resolved relative to `base`. */ configPath?: string /** * Path to the CSS stylesheet used by Tailwind CSS (v4+). * * Paths are resolved relative to `base`. */ stylesheetPath?: string /** * Whether or not to preserve whitespace around classes. * * Default: false */ preserveWhitespace?: boolean /** * Whether or not to preserve duplicate classes. * * Default: false */ preserveDuplicates?: boolean /** * The package name to use when loading Tailwind CSS. * * Useful when multiple versions are installed in the same project. * * Default: `tailwindcss` * * @internal */ packageName?: string } export interface Sorter { /** * Sort one or more class attributes. * * Each element is the value of an HTML `class` attribute (or similar). i.e. a * space separated list of class names as a string. */ sortClassAttributes(classes: string[]): string[] /** * Sort one or more class lists. * * Each element is an array of class names. Passing a space separated class * list in each element is not supported. * * Duplicates are removed by default unless `preserveDuplicates` is enabled. */ sortClassLists(classes: string[][]): string[][] } type TailwindConfigOptions = { base?: string filepath?: string configPath?: string stylesheetPath?: string packageName?: string } function resolveIfRelative(base: string, filePath?: string) { if (!filePath) return null return path.isAbsolute(filePath) ? filePath : path.resolve(base, filePath) } let pathToApiMap = expiringMap>(10_000) /** * Get a Tailwind CSS API instance based on the provided options. * @internal */ export async function getTailwindConfig(options: TailwindConfigOptions): Promise { let base = options.base ?? process.cwd() let inputDir = options.filepath ? path.dirname(options.filepath) : base let configPath = resolveIfRelative(base, options.configPath) let stylesheetPath = resolveIfRelative(base, options.stylesheetPath) // Locate Tailwind CSS itself // // We resolve this like we're in `inputDir` for better monorepo support as // Prettier may be configured at the workspace root but Tailwind CSS is // installed for a workspace package rather than the entire monorepo let [mod, pkgDir] = await resolveTailwindPath({ packageName: options.packageName }, inputDir) // Locate project stylesheet relative to the formatter config file // // We resolve this relative to the config file because it is *required* // to work with a project's custom config. Given that, resolving it // relative to where the path is defined makes the most sense. let stylesheet = resolveStylesheet(stylesheetPath, base) // Locate *explicit* v3 configs relative to the formatter config file // // We use this as a signal that we should always use v3 to format files even // when the local install is v4 — which means we'll use the bundled v3. let jsConfig = resolveJsConfigPath(configPath) // Locate the closest v3 config file // // Note: // We only need to do this when a stylesheet has not been provided otherwise // we'd know for sure this was a v4 project regardless of what local Tailwind // CSS installation is present. Additionally, if the local version is v4 we // skip this as we assume that the user intends to use that version. // // The config path is resolved in one of two ways: // // 1. When automatic, relative to the input file // // This ensures monorepos can load the "closest" JS config for a given file // which is important when a workspace package includes Tailwind CSS *and* // Prettier is configured globally instead of per-package. // // 2. When explicit via `configPath`, relative to `base` if (!stylesheet && !mod?.__unstable__loadDesignSystem) { jsConfig = jsConfig ?? findClosestJsConfig(inputDir) } // We've found a JS config either because it was specified by the user // or because it was automatically located. This means we should use v3. if (jsConfig) { if (!stylesheet) { return pathToApiMap.remember(`${pkgDir}:${jsConfig}`, async () => { const { loadV3 } = await import('./versions/v3') return loadV3(pkgDir, jsConfig) }) } // In this case the user explicitly gave us a stylesheet and a config. // Warn them about this and use the bundled v4. console.error( 'explicit-stylesheet-and-config-together', base, `You have specified a Tailwind CSS stylesheet and a Tailwind CSS config at the same time. Use stylesheetPath unless you are using v3. Preferring the stylesheet.`, ) } if (mod && !mod.__unstable__loadDesignSystem) { if (!stylesheet) { return pathToApiMap.remember(`${pkgDir}:${jsConfig}`, async () => { const { loadV3 } = await import('./versions/v3') return loadV3(pkgDir, jsConfig) }) } // In this case the user explicitly gave us a stylesheet but their local // installation is not v4. We'll fallback to the bundled v4 in this case. mod = null console.error( 'stylesheet-unsupported', base, 'You have specified a Tailwind CSS stylesheet but your installed version of Tailwind CSS does not support this feature.', ) } // If we've detected a local version of v4 then we should fallback to using // its included theme as the stylesheet if the user didn't give us one. if (mod && mod.__unstable__loadDesignSystem && pkgDir) { stylesheet ??= `${pkgDir}/theme.css` } return pathToApiMap.remember(`${pkgDir}:${stylesheet}`, async () => { const { loadV4 } = await import('./versions/v4') return loadV4(mod, stylesheet) }) } let resolvedModCache = expiringMap(10_000) async function resolveTailwindPath( options: { packageName?: string }, baseDir: string, ): Promise<[any, string | null]> { let pkgName = options.packageName ?? 'tailwindcss' let makeKey = (dir: string) => `${pkgName}:${dir}` // Check cache for this directory let cached = resolvedModCache.get(makeKey(baseDir)) if (cached !== undefined) { return cached } let resolve = async () => { let pkgDir: string | null = null let mod: any = null try { let pkgPath = resolveJsFrom(baseDir, pkgName) mod = await import(pathToFileURL(pkgPath).toString()) let pkgFile = resolveJsFrom(baseDir, `${pkgName}/package.json`) pkgDir = path.dirname(pkgFile) } catch {} return [mod, pkgDir] as [any, string | null] } let result = await resolve() // Cache all directories from baseDir up to package location let [, pkgDir] = result if (pkgDir) { cacheForDirs(resolvedModCache, baseDir, result, pkgDir, makeKey) } else { resolvedModCache.set(makeKey(baseDir), result) } return result } function resolveJsConfigPath(configPath: string | null): string | null { if (!configPath) return null if (configPath.endsWith('.css')) return null return configPath } let configPathCache = new Map() function findClosestJsConfig(inputDir: string): string | null { // Check cache for this directory let cached = configPathCache.get(inputDir) if (cached !== undefined) { return cached } // Resolve let configPath: string | null = null try { let foundPath = escalade(inputDir, (_, names) => { if (names.includes('tailwind.config.js')) return 'tailwind.config.js' if (names.includes('tailwind.config.cjs')) return 'tailwind.config.cjs' if (names.includes('tailwind.config.mjs')) return 'tailwind.config.mjs' if (names.includes('tailwind.config.ts')) return 'tailwind.config.ts' }) configPath = foundPath ?? null } catch {} // Cache all directories from inputDir up to config location if (configPath) { cacheForDirs(configPathCache, inputDir, configPath, path.dirname(configPath)) } else { configPathCache.set(inputDir, null) } return configPath } function resolveStylesheet(stylesheetPath: string | null, base: string): string | null { if (!stylesheetPath) return null if ( stylesheetPath.endsWith('.js') || stylesheetPath.endsWith('.mjs') || stylesheetPath.endsWith('.cjs') || stylesheetPath.endsWith('.ts') || stylesheetPath.endsWith('.mts') || stylesheetPath.endsWith('.cts') ) { console.error( 'stylesheet-is-js-file', base, "Your `stylesheetPath` option points to a JS/TS config file. You must point to your project's `.css` file for v4 projects.", ) } else if ( stylesheetPath.endsWith('.sass') || stylesheetPath.endsWith('.scss') || stylesheetPath.endsWith('.less') || stylesheetPath.endsWith('.styl') ) { console.error( 'stylesheet-is-preprocessor-file', base, 'Your `stylesheetPath` option points to a preprocessor file. This is unsupported and you may get unexpected results.', ) } else if (!stylesheetPath.endsWith('.css')) { console.error( 'stylesheet-is-not-css-file', base, 'Your `stylesheetPath` option does not point to a CSS file. This is unsupported and you may get unexpected results.', ) } return stylesheetPath } /** * Creates a sorter instance for sorting Tailwind CSS classes. * * This function initializes a sorter with the specified Tailwind CSS configuration. * The sorter can be used to sort class attributes (space-separated strings) or * class lists (arrays of class names). * @example * ```ts * const sorter = await createSorter({}) * * // Sort class lists * const sorted = sorter.sortClassLists([['p-4', 'm-2']]) * // Returns: [['m-2', 'p-4']] * ``` */ export async function createSorter(opts: SorterOptions): Promise { let preserveDuplicates = opts.preserveDuplicates ?? false let preserveWhitespace = opts.preserveWhitespace ?? false let api = await getTailwindConfig({ base: opts.base, filepath: opts.filepath, configPath: opts.configPath, stylesheetPath: opts.stylesheetPath, packageName: opts.packageName, }) let env: TransformerEnv = { context: api, changes: [], options: { tailwindPreserveWhitespace: preserveWhitespace, tailwindPreserveDuplicates: preserveDuplicates, tailwindPackageName: opts.packageName, } as any, matcher: undefined as any, } return { sortClassLists(classes) { return classes.map((list) => { let result = sortClassList({ api, classList: list, removeDuplicates: !preserveDuplicates, }) return result.classList }) }, sortClassAttributes(classes) { return classes.map((list) => sortClasses(list, { env })) }, } } ================================================ FILE: src/sorting.ts ================================================ import type { TransformerEnv, UnifiedApi } from './types' import { bigSign } from './utils' export interface SortOptions { ignoreFirst?: boolean ignoreLast?: boolean removeDuplicates?: boolean collapseWhitespace?: false | { start: boolean; end: boolean } } export function sortClasses( classStr: string, { env, ignoreFirst = false, ignoreLast = false, removeDuplicates = true, collapseWhitespace = { start: true, end: true }, }: SortOptions & { env: TransformerEnv }, ): string { if (typeof classStr !== 'string' || classStr === '') { return classStr } // Ignore class attributes containing `{{`, to match Prettier behaviour: // https://github.com/prettier/prettier/blob/8a88cdce6d4605f206305ebb9204a0cabf96a070/src/language-html/embed/class-names.js#L9 if (classStr.includes('{{')) { return classStr } if (env.options.tailwindPreserveWhitespace) { collapseWhitespace = false } if (env.options.tailwindPreserveDuplicates) { removeDuplicates = false } // This class list is purely whitespace // Collapse it to a single space if the option is enabled if (collapseWhitespace && /^[\t\r\f\n ]+$/.test(classStr)) { return ' ' } let result = '' let parts = classStr.split(/([\t\r\f\n ]+)/) let classes = parts.filter((_, i) => i % 2 === 0) let whitespace = parts.filter((_, i) => i % 2 !== 0) if (classes[classes.length - 1] === '') { classes.pop() } if (collapseWhitespace) { whitespace = whitespace.map(() => ' ') } let prefix = '' if (ignoreFirst) { prefix = `${classes.shift() ?? ''}${whitespace.shift() ?? ''}` } let suffix = '' if (ignoreLast) { suffix = `${whitespace.pop() ?? ''}${classes.pop() ?? ''}` } let { classList, removedIndices } = sortClassList({ classList: classes, api: env.context, removeDuplicates, }) // Remove whitespace that appeared before a removed classes whitespace = whitespace.filter((_, index) => !removedIndices.has(index + 1)) for (let i = 0; i < classList.length; i++) { result += `${classList[i]}${whitespace[i] ?? ''}` } if (collapseWhitespace) { prefix = prefix.replace(/\s+$/g, ' ') suffix = suffix.replace(/^\s+/g, ' ') result = result .replace(/^\s+/, collapseWhitespace.start ? '' : ' ') .replace(/\s+$/, collapseWhitespace.end ? '' : ' ') } return prefix + result + suffix } export function sortClassList({ classList, api, removeDuplicates, }: { classList: string[] api: UnifiedApi removeDuplicates: boolean }) { // Re-order classes based on the Tailwind CSS configuration let orderedClasses = api.getClassOrder(classList) orderedClasses.sort(([nameA, a], [nameZ, z]) => { // Move `...` to the end of the list if (nameA === '...' || nameA === '…') return 1 if (nameZ === '...' || nameZ === '…') return -1 if (a === z) return 0 if (a === null) return -1 if (z === null) return 1 return bigSign(a - z) }) // Remove duplicate Tailwind classes let removedIndices = new Set() if (removeDuplicates) { let seenClasses = new Set() orderedClasses = orderedClasses.filter(([cls, order], index) => { if (seenClasses.has(cls)) { removedIndices.add(index) return false } // Only consider known classes when removing duplicates if (order !== null) { seenClasses.add(cls) } return true }) } return { classList: orderedClasses.map(([className]) => className), removedIndices, } } ================================================ FILE: src/transform.ts ================================================ import type { AstPath, Plugin } from 'prettier' import type { TransformerEnv } from './types' export function defineTransform(opts: TransformOptions) { return opts } export interface LazyPluginLoad { name: string importer: () => Promise } export type PluginLoad = LazyPluginLoad | Plugin export interface TransformOptions { /** * Static attributes that are supported by default */ staticAttrs?: string[] /** * Dynamic / expression attributes that are supported by default */ dynamicAttrs?: string[] /** * Load the given plugins for the parsers and printers */ load?: PluginLoad[] /** * A list of compatible, third-party plugins for this transformation step * * The loading of these is delayed until the actual parse call as * using the parse() function from these plugins may cause errors * if they haven't already been loaded by Prettier. */ compatible?: string[] /** * A list of supported parser names */ parsers: Record< string, { /** * Load the given plugins for the parsers and printers */ load?: PluginLoad[] /** * Static attributes that are supported by default */ staticAttrs?: string[] /** * Dynamic / expression attributes that are supported by default */ dynamicAttrs?: string[] } > /** * A list of supported parser names */ printers?: Record /** * Transform entire ASTs * * @param ast The AST to transform * @param env Provides options and mechanisms to sort classes */ transform?(ast: T, env: TransformerEnv): void /** * Transform entire ASTs * * @param ast The AST to transform * @param env Provides options and mechanisms to sort classes */ reprint?(path: AstPath, options: TransformerEnv): void } ================================================ FILE: src/types.ts ================================================ import type { ParserOptions } from 'prettier' import type { Matcher } from './options' export interface TransformerMetadata { // Default customizations for a given transformer functions?: string[] staticAttrs?: string[] dynamicAttrs?: string[] } export interface Customizations { functions: Set staticAttrs: Set dynamicAttrs: Set staticAttrsRegex: RegExp[] dynamicAttrsRegex: RegExp[] functionsRegex: RegExp[] } export interface UnifiedApi { getClassOrder(classList: string[]): [string, bigint | null][] } export interface TransformerEnv { context: UnifiedApi matcher: Matcher options: ParserOptions changes: StringChangePositional[] } export interface StringChangePositional { start: { line: number; column: number } end: { line: number; column: number } before: string after: string } export interface StringChange { start: number end: number before: string after: string } ================================================ FILE: src/utils.bench.ts ================================================ import { bench, describe } from 'vitest' import type { StringChange } from './types' import { spliceChangesIntoString } from './utils' describe('spliceChangesIntoString', () => { // 44 bytes let strTemplate = 'the quick brown fox jumps over the lazy dog ' let changesTemplate: StringChange[] = [ { start: 10, end: 15, before: 'brown', after: 'purple' }, { start: 4, end: 9, before: 'quick', after: 'slow' }, ] function buildFixture(repeatCount: number, changeCount: number) { // A large set of changes across random places in the string let indxes = new Set( Array.from({ length: changeCount }, () => Math.ceil(Math.random() * repeatCount)), ) let changes: StringChange[] = Array.from(indxes).flatMap((idx) => { return changesTemplate.map((change) => ({ start: change.start + strTemplate.length * idx, end: change.end + strTemplate.length * idx, before: change.before, after: change.after, })) }) return [strTemplate.repeat(repeatCount), changes] as const } let [strS, changesS] = buildFixture(5, 2) bench('small string', () => { spliceChangesIntoString(strS, changesS) }) let [strM, changesM] = buildFixture(100, 5) bench('medium string', () => { spliceChangesIntoString(strM, changesM) }) let [strL, changesL] = buildFixture(1_000, 50) bench('large string', () => { spliceChangesIntoString(strL, changesL) }) let [strXL, changesXL] = buildFixture(100_000, 500) bench('extra large string', () => { spliceChangesIntoString(strXL, changesXL) }) let [strXL2, changesXL2] = buildFixture(100_000, 5_000) bench('extra large string (5k changes)', () => { spliceChangesIntoString(strXL2, changesXL2) }) }) ================================================ FILE: src/utils.test.ts ================================================ import { describe, test } from 'vitest' import type { StringChange } from './types' import { spliceChangesIntoString } from './utils' describe('spliceChangesIntoString', () => { test('can apply changes to a string', ({ expect }) => { let str = 'the quick brown fox jumps over the lazy dog' let changes: StringChange[] = [ // { start: 10, end: 15, before: 'brown', after: 'purple' }, ] expect(spliceChangesIntoString(str, changes)).toBe( 'the quick purple fox jumps over the lazy dog', ) }) test('changes are applied in order', ({ expect }) => { let str = 'the quick brown fox jumps over the lazy dog' let changes: StringChange[] = [ // { start: 10, end: 15, before: 'brown', after: 'purple' }, { start: 4, end: 9, before: 'quick', after: 'slow' }, ] expect(spliceChangesIntoString(str, changes)).toBe( 'the slow purple fox jumps over the lazy dog', ) }) }) ================================================ FILE: src/utils.ts ================================================ import * as path from 'node:path' import type { StringChange } from './types' // For loading prettier plugins only if they exist export function loadIfExists(name: string): any { try { if (require.resolve(name)) { return require(name) } } catch { return null } } interface PathEntry { node: T parent: T | null key: string | null index: number | null meta: Meta } export type Path = PathEntry[] type Visitor> = ( node: T, path: Path, meta: Partial, ) => void | false type Visitors> = Record> function isNodeLike(value: any): value is { type: string } { return typeof value?.type === 'string' } // https://lihautan.com/manipulating-ast-with-javascript/ export function visit>( ast: T, callbackMap: Visitors | Visitor, ) { function _visit(node: any, path: Path, meta: Meta) { if (typeof callbackMap === 'function') { if (callbackMap(node, path, meta) === false) { return } } else if (node.type in callbackMap) { if (callbackMap[node.type](node, path, meta) === false) { return } } const keys = Object.keys(node) for (let i = 0; i < keys.length; i++) { const child = node[keys[i]] if (Array.isArray(child)) { for (let j = 0; j < child.length; j++) { if (isNodeLike(child[j])) { let newMeta = { ...meta } let newPath = [ { node: child[j], parent: node, key: keys[i], index: j, meta: newMeta, }, ...path, ] _visit(child[j], newPath, newMeta) } } } else if (isNodeLike(child)) { let newMeta = { ...meta } let newPath = [ { node: child, parent: node, key: keys[i], index: i, meta: newMeta, }, ...path, ] _visit(child, newPath, newMeta) } } } let newMeta: Meta = {} as any let newPath: Path = [ { node: ast, parent: null, key: null, index: null, meta: newMeta, }, ] _visit(ast, newPath, newMeta) } /** * Apply the changes to the string such that a change in the length * of the string does not break the indexes of the subsequent changes. */ export function spliceChangesIntoString(str: string, changes: StringChange[]) { // If there are no changes, return the original string if (!changes[0]) return str // Sort all changes in order to make it easier to apply them changes.sort((a, b) => { return a.end - b.end || a.start - b.start }) // Append original string between each chunk, and then the chunk itself // This is sort of a String Builder pattern, thus creating less memory pressure let result = '' let previous = changes[0] result += str.slice(0, previous.start) result += previous.after for (let i = 1; i < changes.length; ++i) { let change = changes[i] result += str.slice(previous.end, change.start) result += change.after previous = change } // Add leftover string from last chunk to end result += str.slice(previous.end) return result } export function bigSign(bigIntValue: bigint) { return Number(bigIntValue > 0n) - Number(bigIntValue < 0n) } /** * Cache a value for all directories from `inputDir` up to `targetDir` (inclusive). * Stops early if an existing cache entry is found. * * How it works: * * For a file at '/repo/packages/ui/src/Button.tsx' with config at '/repo/package.json' * * `cacheForDirs(cache, '/repo/packages/ui/src', '/repo/package.json', '/repo')` * * Caches: * - '/repo/packages/ui/src' -> '/repo/package.json' * - '/repo/packages/ui' -> '/repo/package.json' * - '/repo/packages' -> '/repo/package.json' * - '/repo' -> '/repo/package.json' */ export function cacheForDirs( cache: { set(key: string, value: V): void; get(key: string): V | undefined }, inputDir: string, value: V, targetDir: string, makeKey: (dir: string) => string = (dir) => dir, ): void { let dir = inputDir while (dir !== path.dirname(dir) && dir.length >= targetDir.length) { const key = makeKey(dir) // Stop caching if we hit an existing entry if (cache.get(key) !== undefined) break cache.set(key, value) if (dir === targetDir) break dir = path.dirname(dir) } } ================================================ FILE: src/versions/assets.ts ================================================ // @ts-ignore import index from 'tailwindcss-v4/index.css' // @ts-ignore import preflight from 'tailwindcss-v4/preflight.css' // @ts-ignore import theme from 'tailwindcss-v4/theme.css' // @ts-ignore import utilities from 'tailwindcss-v4/utilities.css' export const assets: Record = { tailwindcss: index, 'tailwindcss/index': index, 'tailwindcss/index.css': index, 'tailwindcss/preflight': preflight, 'tailwindcss/preflight.css': preflight, 'tailwindcss/theme': theme, 'tailwindcss/theme.css': theme, 'tailwindcss/utilities': utilities, 'tailwindcss/utilities.css': utilities, } ================================================ FILE: src/versions/v3.ts ================================================ // @ts-check import * as path from 'node:path' import { pathToFileURL } from 'node:url' import clearModule from 'clear-module' import { createJiti } from 'jiti' // @ts-ignore import { generateRules as generateRulesFallback } from 'tailwindcss-v3/lib/lib/generateRules' // @ts-ignore import { createContext as createContextFallback } from 'tailwindcss-v3/lib/lib/setupContextUtils' import resolveConfigFallback from 'tailwindcss-v3/resolveConfig' import type { RequiredConfig } from 'tailwindcss-v3/types/config.js' import type { UnifiedApi } from '../types' import { bigSign } from '../utils' interface LegacyTailwindContext { tailwindConfig: { prefix: string | ((selector: string) => string) } getClassOrder?: (classList: string[]) => [string, bigint | null][] layerOrder: { components: bigint } } interface GenerateRules { (classes: Iterable, context: LegacyTailwindContext): [bigint][] } function prefixCandidate(context: LegacyTailwindContext, selector: string): string { let prefix = context.tailwindConfig.prefix return typeof prefix === 'function' ? prefix(selector) : prefix + selector } export async function loadV3(pkgDir: string | null, jsConfig: string | null): Promise { let createContext = createContextFallback let generateRules: GenerateRules = generateRulesFallback let resolveConfig = resolveConfigFallback let tailwindConfig: RequiredConfig = { content: [] } try { if (pkgDir) { resolveConfig = require(path.join(pkgDir, 'resolveConfig')) createContext = require(path.join(pkgDir, 'lib/lib/setupContextUtils')).createContext generateRules = require(path.join(pkgDir, 'lib/lib/generateRules')).generateRules } } catch {} try { if (jsConfig) { clearModule(jsConfig) let jiti = createJiti(import.meta.url, { moduleCache: false, fsCache: false, interopDefault: true, }) let url = pathToFileURL(jsConfig) tailwindConfig = await jiti.import(url.href, { default: true }) } } catch (err) { console.error(`Unable to load your Tailwind CSS v3 config: ${jsConfig}`) throw err } // suppress "empty content" warning tailwindConfig.content = ['no-op'] // Create the context let context: LegacyTailwindContext = createContext(resolveConfig(tailwindConfig)) // Polyfill for older Tailwind CSS versions function getClassOrderPolyfill(classes: string[]): [string, bigint | null][] { // A list of utilities that are used by certain Tailwind CSS utilities but // that don't exist on their own. This will result in them "not existing" and // sorting could be weird since you still require them in order to make the // host utitlies work properly. (Thanks Biology) let parasiteUtilities = new Set([ prefixCandidate(context, 'group'), prefixCandidate(context, 'peer'), ]) let classNamesWithOrder: [string, bigint | null][] = [] for (let className of classes) { let order: bigint | null = generateRules(new Set([className]), context).sort(([a], [z]) => bigSign(z - a))[0]?.[0] ?? null if (order === null && parasiteUtilities.has(className)) { // This will make sure that it is at the very beginning of the // `components` layer which technically means 'before any // components'. order = context.layerOrder.components } classNamesWithOrder.push([className, order]) } return classNamesWithOrder } context.getClassOrder ??= getClassOrderPolyfill return { getClassOrder: (classList: string[]) => { return context.getClassOrder ? context.getClassOrder(classList) : getClassOrderPolyfill(classList) }, } } ================================================ FILE: src/versions/v4.ts ================================================ import * as fs from 'node:fs/promises' import * as path from 'node:path' import { pathToFileURL } from 'node:url' import { createJiti, type Jiti } from 'jiti' import * as v4 from 'tailwindcss-v4' import { resolveCssFrom, resolveJsFrom } from '../resolve' import type { UnifiedApi } from '../types' import { assets } from './assets' interface DesignSystem { getClassOrder(classList: string[]): [string, bigint | null][] } interface LoadOptions { base: string loadModule?( id: string, base: string, resourceType: string, ): Promise<{ base: string module: unknown }> loadPlugin?(id: string, base: string, resourceType: string): Promise loadConfig?(id: string, base: string, resourceType: string): Promise loadStylesheet?( id: string, base: string, ): Promise<{ base: string content: string }> } interface ApiV4 { __unstable__loadDesignSystem(css: string, options: LoadOptions): Promise } export async function loadV4(mod: ApiV4 | null, stylesheet: string | null): Promise { // This is not Tailwind v4 let isFallback = false if (!mod || !mod.__unstable__loadDesignSystem) { mod = v4 as ApiV4 isFallback = true } // Create a Jiti instance that can be used to load plugins and config files let jiti = createJiti(import.meta.url, { moduleCache: false, fsCache: false, }) let css: string let importBasePath: string if (stylesheet) { // Resolve imports in the entrypoint to a flat CSS tree css = await fs.readFile(stylesheet, 'utf-8') importBasePath = path.dirname(stylesheet) } else { importBasePath = process.cwd() stylesheet = path.join(importBasePath, 'fake.css') css = assets['tailwindcss/theme.css'] } // Load the design system and set up a compatible context object that is // usable by the rest of the plugin let design = await mod.__unstable__loadDesignSystem(css, { base: importBasePath, // v4.0.0-alpha.25+ loadModule: createLoader({ legacy: false, jiti, filepath: stylesheet, onError: (id, err, resourceType) => { console.error(`Unable to load ${resourceType}: ${id}`, err) if (resourceType === 'config') { return {} } else if (resourceType === 'plugin') { return () => {} } }, }), loadStylesheet: async (id: string, base: string) => { try { let resolved = resolveCssFrom(base, id) return { base: path.dirname(resolved), content: await fs.readFile(resolved, 'utf-8'), } } catch (err) { if (isFallback && id in assets) { return { base, content: assets[id] } } throw err } }, // v4.0.0-alpha.24 and below loadPlugin: createLoader({ legacy: true, jiti, filepath: stylesheet, onError(id, err) { console.error(`Unable to load plugin: ${id}`, err) return () => {} }, }), loadConfig: createLoader({ legacy: true, jiti, filepath: stylesheet, onError(id, err) { console.error(`Unable to load config: ${id}`, err) return {} }, }), }) return { getClassOrder: (classList: string[]) => { return design.getClassOrder(classList) }, } } function createLoader({ legacy, jiti, filepath, onError, }: { legacy: true jiti: Jiti filepath: string onError: (id: string, error: unknown, resourceType: string) => T }): (id: string) => Promise function createLoader({ legacy, jiti, filepath, onError, }: { legacy: false jiti: Jiti filepath: string onError: (id: string, error: unknown, resourceType: string) => T }): ( id: string, base: string, resourceType: string, ) => Promise<{ base: string module: unknown }> /** * Create a loader function that can load plugins and config files relative to * the CSS file that uses them. However, we don't want missing files to prevent * everything from working so we'll let the error handler decide how to proceed. */ function createLoader({ legacy, jiti, filepath, onError, }: { legacy: boolean jiti: Jiti filepath: string onError: (id: string, error: unknown, resourceType: string) => T }) { let cacheKey = `${+Date.now()}` async function loadFile(id: string, base: string, resourceType: string) { try { let resolved = resolveJsFrom(base, id) let url = pathToFileURL(resolved) url.searchParams.append('t', cacheKey) return await jiti.import(url.href, { default: true }) } catch (err) { return onError(id, err, resourceType) } } if (legacy) { let baseDir = path.dirname(filepath) return (id: string) => loadFile(id, baseDir, 'module') } return async (id: string, base: string, resourceType: string) => { return { base, module: await loadFile(id, base, resourceType), } } } ================================================ FILE: tests/fixtures/basic/index.html ================================================
================================================ FILE: tests/fixtures/basic/output.html ================================================
================================================ FILE: tests/fixtures/basic/prettier.config.js ================================================ module.exports = {}; ================================================ FILE: tests/fixtures/basic/tailwind.config.js ================================================ module.exports = { theme: { extend: { colors: { tomato: 'tomato', }, }, }, } ================================================ FILE: tests/fixtures/cjs/index.html ================================================
================================================ FILE: tests/fixtures/cjs/output.html ================================================
================================================ FILE: tests/fixtures/cjs/prettier.config.js ================================================ module.exports = {}; ================================================ FILE: tests/fixtures/cjs/tailwind.config.cjs ================================================ module.exports = { theme: { extend: { colors: { hotpink: 'hotpink', }, }, }, } ================================================ FILE: tests/fixtures/custom-jsx/index.jsx ================================================ const a = sortMeFn("sm:p-1 p-2"); const b = sortMeFn({ foo: "sm:p-1 p-2", }); const c = dontSortFn("sm:p-1 p-2"); const d = sortMeTemplate`sm:p-1 p-2`; const e = dontSortMeTemplate`sm:p-1 p-2`; const f = tw.foo`sm:p-1 p-2`; const g = tw.foo.bar`sm:p-1 p-2`; const h = no.foo`sm:p-1 p-2`; const i = no.tw`sm:p-1 p-2`; const k = tw.foo('sm:p-1 p-2'); const l = tw.foo.bar('sm:p-1 p-2'); const m = no.foo('sm:p-1 p-2'); const n = no.tw('sm:p-1 p-2'); const o = tw(Foo)`sm:p-1 p-2`; const p = tw(Foo)(Bar)`sm:p-1 p-2`; const q = no(Foo)`sm:p-1 p-2`; const r = no.tw(Foo)`sm:p-1 p-2`; const s = tw(Foo)('sm:p-1 p-2'); const t = tw(Foo)(Bar)('sm:p-1 p-2'); const u = no(Foo)('sm:p-1 p-2'); const v = no.tw(Foo)('sm:p-1 p-2'); const w = tw.div(Foo)`sm:p-1 p-2`; const x = tw(Foo).div`sm:p-1 p-2`; const y = no.tw(Foo)`sm:p-1 p-2`; const z = no(Foo).tw`sm:p-1 p-2`; const A = (props) =>
; const B = () => ; ================================================ FILE: tests/fixtures/custom-jsx/output.jsx ================================================ const a = sortMeFn("p-2 sm:p-1"); const b = sortMeFn({ foo: "p-2 sm:p-1", }); const c = dontSortFn("sm:p-1 p-2"); const d = sortMeTemplate`p-2 sm:p-1`; const e = dontSortMeTemplate`sm:p-1 p-2`; const f = tw.foo`p-2 sm:p-1`; const g = tw.foo.bar`p-2 sm:p-1`; const h = no.foo`sm:p-1 p-2`; const i = no.tw`sm:p-1 p-2`; const k = tw.foo("p-2 sm:p-1"); const l = tw.foo.bar("p-2 sm:p-1"); const m = no.foo("sm:p-1 p-2"); const n = no.tw("sm:p-1 p-2"); const o = tw(Foo)`p-2 sm:p-1`; const p = tw(Foo)(Bar)`p-2 sm:p-1`; const q = no(Foo)`sm:p-1 p-2`; const r = no.tw(Foo)`sm:p-1 p-2`; const s = tw(Foo)("p-2 sm:p-1"); const t = tw(Foo)(Bar)("p-2 sm:p-1"); const u = no(Foo)("sm:p-1 p-2"); const v = no.tw(Foo)("sm:p-1 p-2"); const w = tw.div(Foo)`p-2 sm:p-1`; const x = tw(Foo).div`p-2 sm:p-1`; const y = no.tw(Foo)`sm:p-1 p-2`; const z = no(Foo).tw`sm:p-1 p-2`; const A = (props) =>
; const B = () => ; ================================================ FILE: tests/fixtures/custom-jsx/prettier.config.js ================================================ module.exports = { tailwindFunctions: ['sortMeFn', 'sortMeTemplate', 'tw'], tailwindAttributes: ['sortMe'], }; ================================================ FILE: tests/fixtures/custom-jsx/tailwind.config.js ================================================ module.exports = { theme: {}, } ================================================ FILE: tests/fixtures/custom-pkg-name-v3/config.js ================================================ module.exports = { theme: { extend: { colors: { 'tomato': 'tomato', } } } } ================================================ FILE: tests/fixtures/custom-pkg-name-v3/index.html ================================================
================================================ FILE: tests/fixtures/custom-pkg-name-v3/output.html ================================================
================================================ FILE: tests/fixtures/custom-pkg-name-v3/package.json ================================================ { "dependencies": { "tailwindcss-v3": "npm:tailwindcss@^3.4.17" }, "prettier": { "tailwindPackageName": "tailwindcss-v3", "tailwindConfig": "./config.js" } } ================================================ FILE: tests/fixtures/custom-pkg-name-v4/app.css ================================================ @import 'tailwindcss-v4'; @theme { --color-tomato: tomato; } ================================================ FILE: tests/fixtures/custom-pkg-name-v4/index.html ================================================
================================================ FILE: tests/fixtures/custom-pkg-name-v4/output.html ================================================
================================================ FILE: tests/fixtures/custom-pkg-name-v4/package.json ================================================ { "dependencies": { "tailwindcss-v4": "npm:tailwindcss@^4.1.7" }, "prettier": { "tailwindPackageName": "tailwindcss-v4", "tailwindStylesheet": "./app.css" } } ================================================ FILE: tests/fixtures/custom-vue/index.vue ================================================ ================================================ FILE: tests/fixtures/custom-vue/output.vue ================================================ ================================================ FILE: tests/fixtures/custom-vue/prettier.config.js ================================================ module.exports = { tailwindFunctions: ['sortMeFn', 'sortMeTemplate'], tailwindAttributes: ['sortMe'], }; ================================================ FILE: tests/fixtures/custom-vue/tailwind.config.js ================================================ module.exports = { theme: {}, } ================================================ FILE: tests/fixtures/esm/index.html ================================================
================================================ FILE: tests/fixtures/esm/output.html ================================================
================================================ FILE: tests/fixtures/esm/prettier.config.js ================================================ module.exports = {}; ================================================ FILE: tests/fixtures/esm/tailwind.config.mjs ================================================ export default { theme: { extend: { colors: { hotpink: "hotpink", }, }, }, }; ================================================ FILE: tests/fixtures/esm-explicit/config.mjs ================================================ export default { theme: { extend: { colors: { hotpink: "hotpink", }, }, }, }; ================================================ FILE: tests/fixtures/esm-explicit/index.html ================================================
================================================ FILE: tests/fixtures/esm-explicit/output.html ================================================
================================================ FILE: tests/fixtures/esm-explicit/prettier.config.js ================================================ module.exports = { tailwindConfig: './config.mjs' }; ================================================ FILE: tests/fixtures/monorepo/.prettierrc ================================================ { "tailwindFunctions": ["tw"], "overrides": [ { "files": "package-1/**", "options": { "tailwindStylesheet": "./package-1/app.css" } }, { "files": "package-2/**", "options": {} } ] } ================================================ FILE: tests/fixtures/monorepo/package-1/app.css ================================================ @import 'tailwindcss'; @theme { --color-tomato: tomato; } ================================================ FILE: tests/fixtures/monorepo/package-1/index.jsx ================================================ const a = tw`sm:bg-tomato bg-red-500`; ================================================ FILE: tests/fixtures/monorepo/package-1/output.jsx ================================================ const a = tw`bg-red-500 sm:bg-tomato`; ================================================ FILE: tests/fixtures/monorepo/package-1/package.json ================================================ { "dependencies": { "tailwindcss": "^4.0.0" } } ================================================ FILE: tests/fixtures/monorepo/package-2/index.jsx ================================================ const a = tw`sm:bg-tomato bg-red-500`; ================================================ FILE: tests/fixtures/monorepo/package-2/output.jsx ================================================ const a = tw`bg-red-500 sm:bg-tomato`; ================================================ FILE: tests/fixtures/monorepo/package-2/package.json ================================================ { "dependencies": { "tailwindcss": "^3.4.17" } } ================================================ FILE: tests/fixtures/monorepo/package-2/tailwind.config.js ================================================ module.exports = { theme: { extend: { colors: { tomato: 'tomato' } } } } ================================================ FILE: tests/fixtures/monorepo/package.json ================================================ {} ================================================ FILE: tests/fixtures/no-local-version/app.css ================================================ @import "tailwindcss"; @theme { --color-tomato: tomato; } ================================================ FILE: tests/fixtures/no-local-version/index.html ================================================
================================================ FILE: tests/fixtures/no-local-version/output.html ================================================
================================================ FILE: tests/fixtures/no-local-version/package.json ================================================ { "prettier": { "tailwindStylesheet": "./app.css" } } ================================================ FILE: tests/fixtures/no-prettier-config/index.html ================================================
================================================ FILE: tests/fixtures/no-prettier-config/output.html ================================================
================================================ FILE: tests/fixtures/no-prettier-config/tailwind.config.js ================================================ module.exports = { theme: { extend: { colors: { tomato: 'tomato', }, }, }, } ================================================ FILE: tests/fixtures/no-stylesheet-given/index.html ================================================
================================================ FILE: tests/fixtures/no-stylesheet-given/output.html ================================================
================================================ FILE: tests/fixtures/package.json ================================================ { "type": "commonjs" } ================================================ FILE: tests/fixtures/plugins/index.html ================================================
================================================ FILE: tests/fixtures/plugins/output.html ================================================
================================================ FILE: tests/fixtures/plugins/prettier.config.js ================================================ const prettier = require("prettier") module.exports = { plugins: ['../../../dist/index.mjs'], } ================================================ FILE: tests/fixtures/plugins/tailwind.config.js ================================================ const plugin = require("tailwindcss-v3/plugin"); module.exports = { plugins: [ plugin(function ({ addUtilities }) { addUtilities({ ".foo": { color: "red" }, ".bar": { color: "blue" }, }); }), ], }; ================================================ FILE: tests/fixtures/ts/index.html ================================================
================================================ FILE: tests/fixtures/ts/output.html ================================================
================================================ FILE: tests/fixtures/ts/prettier.config.js ================================================ module.exports = {}; ================================================ FILE: tests/fixtures/ts/tailwind.config.ts ================================================ import type { Config } from "tailwindcss"; export default { content: ["index.html"], theme: { extend: { colors: { hotpink: "hotpink", }, }, }, } satisfies Config; ================================================ FILE: tests/fixtures/ts-explicit/config.ts ================================================ import type { Config } from "tailwindcss"; export default { content: ["index.html"], theme: { extend: { colors: { hotpink: "hotpink", }, }, }, } satisfies Config; ================================================ FILE: tests/fixtures/ts-explicit/index.html ================================================
================================================ FILE: tests/fixtures/ts-explicit/output.html ================================================
================================================ FILE: tests/fixtures/ts-explicit/prettier.config.js ================================================ module.exports = { tailwindConfig: './config.ts' }; ================================================ FILE: tests/fixtures/v3-2/index.html ================================================
================================================ FILE: tests/fixtures/v3-2/output.html ================================================
================================================ FILE: tests/fixtures/v3-2/package.json ================================================ { "dependencies": { "tailwindcss": "3.2.7" } } ================================================ FILE: tests/fixtures/v3-2/prettier.config.js ================================================ module.exports = {}; ================================================ FILE: tests/fixtures/v3-2/tailwind.config.js ================================================ module.exports = { theme: { extend: { colors: { tomato: 'tomato', }, }, }, } ================================================ FILE: tests/fixtures/v3-jiti-reexport/index.html ================================================
================================================ FILE: tests/fixtures/v3-jiti-reexport/output.html ================================================
================================================ FILE: tests/fixtures/v3-jiti-reexport/package.json ================================================ { "private": true, "dependencies": { "@repo/tailwind-config": "file:./packages/tailwind-config" } } ================================================ FILE: tests/fixtures/v3-jiti-reexport/packages/tailwind-config/dist/index.mjs ================================================ import { createJiti } from 'jiti' const jiti = createJiti(import.meta.url, { interopDefault: true, }) const _module = await jiti.import(new URL('../src/index.ts', import.meta.url).href, { default: true, }) export default _module?.default ?? _module ================================================ FILE: tests/fixtures/v3-jiti-reexport/packages/tailwind-config/package.json ================================================ { "name": "@repo/tailwind-config", "version": "1.0.0", "type": "module", "main": "./dist/index.mjs", "module": "./dist/index.mjs", "exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.mjs", "default": "./dist/index.mjs" } }, "dependencies": { "jiti": "^2.6.1" } } ================================================ FILE: tests/fixtures/v3-jiti-reexport/packages/tailwind-config/src/index.ts ================================================ export default { content: ['./index.html'], } ================================================ FILE: tests/fixtures/v3-jiti-reexport/tailwind.config.mjs ================================================ export { default } from '@repo/tailwind-config' ================================================ FILE: tests/fixtures/v4/basic/app.css ================================================ @import 'tailwindcss'; @theme { --color-tomato: tomato; } ================================================ FILE: tests/fixtures/v4/basic/index.html ================================================
================================================ FILE: tests/fixtures/v4/basic/output.html ================================================
================================================ FILE: tests/fixtures/v4/basic/package.json ================================================ { "dependencies": { "tailwindcss": "^4.0.0" }, "prettier": { "tailwindStylesheet": "./app.css" } } ================================================ FILE: tests/fixtures/v4/css-loading-js/app.css ================================================ @import 'tailwindcss'; /* Load ESM versions */ @config './esm/my-config.mjs'; @plugin './esm/my-plugin.mjs'; /* Load Common JS versions */ @config './cjs/my-config.cjs'; @plugin './cjs/my-plugin.cjs'; /* Load TypeScript versions */ @config './ts/my-config.ts'; @plugin './ts/my-plugin.ts'; /* Attempt to load files that do not exist */ @config './missing-confg.mjs'; @plugin './missing-plugin.mjs'; @theme { --color-tomato: tomato; } ================================================ FILE: tests/fixtures/v4/css-loading-js/cjs/my-config.cjs ================================================ module.exports = { theme: { extend: { colors: { 'cjs-from-config': 'black', }, }, }, } ================================================ FILE: tests/fixtures/v4/css-loading-js/cjs/my-plugin.cjs ================================================ const plugin = require('tailwindcss/plugin') module.exports = plugin( ({ addUtilities }) => { addUtilities({ '.utility-cjs-from-plugin': { color: 'black' }, '.utility-cjs-from-plugin-2': { width: '100%', height: '100%', }, }) }, { theme: { extend: { colors: { 'cjs-from-plugin': 'black', }, }, }, }, ) ================================================ FILE: tests/fixtures/v4/css-loading-js/esm/my-config.mjs ================================================ export default { theme: { extend: { colors: { 'esm-from-config': 'black', }, }, }, } ================================================ FILE: tests/fixtures/v4/css-loading-js/esm/my-plugin.mjs ================================================ import plugin from 'tailwindcss/plugin' export default plugin( ({ addUtilities }) => { addUtilities({ '.utility-esm-from-plugin': { color: 'black' }, '.utility-esm-from-plugin-2': { width: '100%', height: '100%', }, }) }, { theme: { extend: { colors: { 'esm-from-plugin': 'black', }, }, }, }, ) ================================================ FILE: tests/fixtures/v4/css-loading-js/index.html ================================================
================================================ FILE: tests/fixtures/v4/css-loading-js/output.html ================================================
================================================ FILE: tests/fixtures/v4/css-loading-js/package.json ================================================ { "dependencies": { "tailwindcss": "^4.0.0" }, "prettier": { "tailwindStylesheet": "./app.css" } } ================================================ FILE: tests/fixtures/v4/css-loading-js/ts/my-config.ts ================================================ import type { Config } from 'tailwindcss' export default { theme: { extend: { colors: { 'ts-from-config': 'black', }, }, }, } satisfies Config ================================================ FILE: tests/fixtures/v4/css-loading-js/ts/my-plugin.ts ================================================ import plugin from 'tailwindcss/plugin' export default plugin( ({ addUtilities }) => { addUtilities({ '.utility-ts-from-plugin': { color: 'black' }, '.utility-ts-from-plugin-2': { width: '100%', height: '100%', }, }) }, { theme: { extend: { colors: { 'ts-from-plugin': 'black', }, }, }, }, ) ================================================ FILE: tests/fixtures/v4/subpath-imports/app.css ================================================ @import 'tailwindcss'; @import '#theme'; ================================================ FILE: tests/fixtures/v4/subpath-imports/index.html ================================================
================================================ FILE: tests/fixtures/v4/subpath-imports/output.html ================================================
================================================ FILE: tests/fixtures/v4/subpath-imports/package.json ================================================ { "dependencies": { "tailwindcss": "^4.0.0" }, "prettier": { "tailwindStylesheet": "./app.css" }, "imports": { "#theme": "./theme.css" } } ================================================ FILE: tests/fixtures/v4/subpath-imports/theme.css ================================================ @theme { --color-tomato: tomato; } ================================================ FILE: tests/fixtures.test.ts ================================================ import { exec } from 'node:child_process' import * as fs from 'node:fs/promises' import * as path from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { afterAll, beforeAll, describe, test } from 'vitest' import { format, pluginPath } from './utils' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const execAsync = promisify(exec) let fixtures = [ { name: 'no prettier config', dir: 'no-prettier-config', ext: 'html', }, { name: 'no local install of Tailwind CSS (uses v4)', dir: 'no-local-version', ext: 'html', }, { name: 'no stylesheet given (uses v4)', dir: 'no-stylesheet-given', ext: 'html', }, { name: 'inferred config path', dir: 'basic', ext: 'html', }, { name: 'inferred config path (.cjs)', dir: 'cjs', ext: 'html', }, { name: 'using esm config', dir: 'esm', ext: 'html', }, { name: 'using esm config (explicit path)', dir: 'esm-explicit', ext: 'html', }, { name: 'using ts config', dir: 'ts', ext: 'html', }, { name: 'using ts config (explicit path)', dir: 'ts-explicit', ext: 'html', }, { name: 'using v3.2.7', dir: 'v3-2', ext: 'html', }, { name: 'v3: re-exported config with jiti', dir: 'v3-jiti-reexport', ext: 'html', }, { name: 'plugins', dir: 'plugins', ext: 'html', }, { name: 'customizations: js/jsx', dir: 'custom-jsx', ext: 'jsx', }, { name: 'v4: basic formatting', dir: 'v4/basic', ext: 'html', }, { name: 'v4: configs and plugins', dir: 'v4/css-loading-js', ext: 'html', }, { name: 'v4: subpath imports', dir: 'v4/subpath-imports', ext: 'html', }, { name: 'custom npm package name: v3', dir: 'custom-pkg-name-v3', ext: 'html', }, { name: 'custom npm package name: v4', dir: 'custom-pkg-name-v4', ext: 'html', }, { name: 'monorepo / v4', dir: 'monorepo/package-1', ext: 'jsx', }, { name: 'monorepo / v3', dir: 'monorepo/package-2', ext: 'jsx', }, ] let configs = [ { from: __dirname + '/../.prettierignore', to: __dirname + '/../.prettierignore.testing', }, { from: __dirname + '/../prettier.config.js', to: __dirname + '/../prettier.config.js.testing', }, ] test.concurrent('explicit config path', async ({ expect }) => { expect( await format('
', { tailwindConfig: path.resolve(__dirname, 'fixtures/basic/tailwind.config.js'), }), ).toEqual('
') }) describe('fixtures', () => { // Temporarily move config files out of the way so they don't interfere with the tests beforeAll(async () => { await Promise.all(configs.map(({ from, to }) => fs.rename(from, to))) }) afterAll(async () => { await Promise.all(configs.map(({ from, to }) => fs.rename(to, from))) }) let binPath = path.resolve(__dirname, '../node_modules/.bin/prettier') for (const { ext, dir, name } of fixtures) { let fixturePath = path.resolve(__dirname, `fixtures/${dir}`) let inputPath = path.resolve(fixturePath, `index.${ext}`) let outputPath = path.resolve(fixturePath, `output.${ext}`) let cmd = `${binPath} ${inputPath} --plugin ${pluginPath}` test.concurrent(name, async ({ expect }) => { let results = await execAsync(cmd) let formatted = results.stdout.replace(/\r\n/g, '\n') let expected = await fs.readFile(outputPath, 'utf-8').then((c) => c.replace(/\r\n/g, '\n')) expect(formatted.trim()).toEqual(expected.trim()) }) } }) ================================================ FILE: tests/format.test.ts ================================================ import { describe, test } from 'vitest' import { tests } from './tests.js' import { format } from './utils.js' describe('parsers', async () => { for (let parser in tests) { test(parser, async ({ expect }) => { for (let [input, expected, options] of tests[parser]) { expect(await format(input, { ...options, parser })).toEqual(expected) } }) } }) describe('other', () => { test('non-tailwind classes are sorted to the front', async ({ expect }) => { let result = await format('
') expect(result).toEqual('
') }) test('parasite utilities (v3)', async ({ expect }) => { let result = await format('
', { tailwindPackageName: 'tailwindcss-v3', }) expect(result).toEqual('
') }) test('parasite utilities (v4)', async ({ expect }) => { let result = await format('
', { tailwindPackageName: 'tailwindcss-v4', }) expect(result).toEqual('
') }) test('parasite utilities (no install == v4)', async ({ expect }) => { let result = await format('
') expect(result).toEqual('
') }) }) describe('whitespace', () => { test('class lists containing interpolation are ignored', async ({ expect }) => { let result = await format('
') expect(result).toEqual('
') }) test('whitespace can be preserved around classes', async ({ expect }) => { let result = await format(`;
`, { parser: 'babel', tailwindPreserveWhitespace: true, }) expect(result).toEqual(`;
`) }) test('whitespace can be collapsed around classes', async ({ expect }) => { let result = await format('
') expect(result).toEqual('
') }) test('whitespace is collapsed but not trimmed when ignored', async ({ expect }) => { let result = await format(';
', { parser: 'babel', }) expect(result).toEqual(';
') }) test('whitespace is not trimmed inside concat expressions', async ({ expect }) => { let result = await format(";
", { parser: 'babel', }) expect(result).toEqual(";
") }) test('whitespace is not trimmed inside concat expressions (angular)', async ({ expect }) => { let result = await format( `
    `, { parser: 'angular', }, ) expect(result).toEqual(`
      `) }) test('whitespace is not trimmed inside adjacent-before/after template expressions', async ({ expect, }) => { let result = await format( ";
      ", { parser: 'babel', }, ) expect(result).toEqual( ";
      ", ) }) test('whitespace is not trimmed before template literal quasis without leading space', async ({ expect, }) => { let result = await format(";
      ", { parser: 'babel', }) expect(result).toEqual(";
      ") }) test('duplicate classes are dropped', async ({ expect }) => { let result = await format('
      ') expect(result).toEqual('
      ') }) }) describe('errors', () => { test('when the given JS config does not exist', async ({ expect }) => { let result = format('
      ', { tailwindConfig: 'i-do-not-exist.js', tailwindPackageName: 'tailwindcss-v3', }) await expect(result).rejects.toThrowError(/Cannot find module/) }) test('when the given stylesheet does not exist', async ({ expect }) => { let result = format('
      ', { tailwindStylesheet: 'i-do-not-exist.css', tailwindPackageName: 'tailwindcss-v4', }) await expect(result).rejects.toThrowError(/no such file or directory/) }) test('when using a stylesheet and the local install is not v4', async ({ expect }) => { let result = format('
      ', { tailwindStylesheet: 'i-do-not-exist.css', tailwindPackageName: 'tailwindcss-v3', }) await expect(result).rejects.toThrowError(/no such file or directory/) }) }) describe('regex matching', () => { test('attribute name exact matches', async ({ expect }) => { let result = await format('
      ', { tailwindAttributes: ['myClass'], }) expect(result).toEqual('
      ') }) test('function name exact matches', async ({ expect }) => { let result = await format('let classList = tw`sm:p-0 p-0`', { parser: 'babel', tailwindFunctions: ['tw'], }) expect(result).toEqual('let classList = tw`p-0 sm:p-0`') }) test('attribute name regex matches', async ({ expect }) => { let result = await format( `
      `, { tailwindAttributes: ['/data-.*/'], }, ) expect(result).toEqual( `
      `, ) }) test('function name regex matches', async ({ expect }) => { let result = await format( 'let classList1 = twClasses`sm:p-0 p-0`\nlet classList2 = myClasses`sm:p-0 p-0`', { parser: 'babel', tailwindFunctions: ['/.*Classes/'], }, ) expect(result).toEqual( 'let classList1 = twClasses`p-0 sm:p-0`\nlet classList2 = myClasses`p-0 sm:p-0`', ) }) test('regex flags are supported', async ({ expect }) => { let result = await format(`;
      `, { parser: 'babel', tailwindAttributes: ['/myclass/i'], tailwindFunctions: ['/myfn/i'], }) expect(result).toEqual(`;
      `) }) test('anchors are supported', async ({ expect }) => { let result = await format( `;
      `, { parser: 'babel', tailwindAttributes: ['/.*List$/'], tailwindFunctions: ['/.*List$/'], }, ) expect(result).toEqual( `;
      `, ) }) test('works with Vue dynamic bindings', async ({ expect }) => { let result = await format('
      ', { parser: 'vue', tailwindAttributes: ['/data-.*/'], }) expect(result).toEqual('
      ') }) test('works with Angular property bindings', async ({ expect }) => { let result = await format('
      ', { parser: 'angular', tailwindAttributes: ['/data.*/i'], }) expect(result).toEqual('
      ') }) test('invalid regex patterns do nothing', async ({ expect }) => { let result = await format('
      ', { tailwindAttributes: ['/data-[/'], }) expect(result).toEqual('
      ') }) test('dynamic attributes are not matched as static attributes', async ({ expect }) => { let result = await format(`
      `, { parser: 'vue', tailwindAttributes: ['/.*-class/'], }) expect(result).toEqual(`
      `) }) test('dynamic attributes are not matched as static attributes (2)', async ({ expect }) => { let result = await format(`
      `, { parser: 'vue', tailwindAttributes: ['/:custom-class/'], }) expect(result).toEqual(`
      `) }) // These tests pass but that is a side-effect of the implementation // If these change in the future to no longer pass that is a good thing describe('dynamic attribute matching quirks', () => { test('Vue', async ({ expect }) => { let result = await format('
      ', { parser: 'vue', tailwindAttributes: ['/:data-.*/'], }) expect(result).toEqual('
      ') }) test('Angular', async ({ expect }) => { let result = await format('
      ', { parser: 'angular', tailwindAttributes: ['/\\[data.*\\]/i'], }) expect(result).toEqual('
      ') }) }) }) ================================================ FILE: tests/plugins.test.ts ================================================ import { createRequire } from 'node:module' import dedent from 'dedent' import { test } from 'vitest' import { javascript } from './tests.js' import type { TestEntry } from './utils.js' import { format, no, pluginPath, t, yes } from './utils.js' const require = createRequire(import.meta.url) interface PluginTest { plugins: string[] options?: Record tests: Record } let tests: PluginTest[] = [ { plugins: ['@trivago/prettier-plugin-sort-imports'], options: { importOrder: ['^@one/(.*)$', '^@two/(.*)$', '^[./]'], importOrderSortSpecifiers: true, }, tests: { babel: [ [ `import './three'\nimport '@two/file'\nimport '@one/file'`, `import '@one/file'\nimport '@two/file'\nimport './three'`, ], ], typescript: [ [ `import './three'\nimport '@two/file'\nimport '@one/file'`, `import '@one/file'\nimport '@two/file'\nimport './three'`, ], ], // This plugin does not support babel-ts 'babel-ts': [ [ `import './three'\nimport '@two/file'\nimport '@one/file'`, `import './three'\nimport '@two/file'\nimport '@one/file'`, ], ], }, }, { plugins: ['@ianvs/prettier-plugin-sort-imports'], options: { importOrder: ['^@tailwindcss/(.*)$', '^@babel/(.*)$', '^[./]'], importOrderSortSpecifiers: true, }, tests: { babel: [ [ `import './i-haz-side-effects'\nimport i3 from './three'\nimport i2 from '@two/file'\nimport i1 from '@one/file'`, `import './i-haz-side-effects'\nimport i1 from '@one/file'\nimport i2 from '@two/file'\nimport i3 from './three'`, ], ], typescript: [ [ `import './i-haz-side-effects'\nimport i3 from './three'\nimport i2 from '@two/file'\nimport i1 from '@one/file'`, `import './i-haz-side-effects'\nimport i1 from '@one/file'\nimport i2 from '@two/file'\nimport i3 from './three'`, ], ], // This plugin does not support babel-ts 'babel-ts': [ [ `import './three'\nimport '@two/file'\nimport '@one/file'`, `import './three'\nimport '@two/file'\nimport '@one/file'`, ], ], }, }, { plugins: ['prettier-plugin-sort-imports'], options: { sortingMethod: 'alphabetical', }, tests: { babel: [ [ `import './three'\nimport '@two/file'\nimport '@one/file'`, `import './three'\nimport '@one/file'\nimport '@two/file'`, ], ], typescript: [ [ `import './three'\nimport '@two/file'\nimport '@one/file'`, `import './three'\nimport '@one/file'\nimport '@two/file'`, ], ], // This plugin does not support babel-ts 'babel-ts': [ [ `import './three'\nimport '@two/file'\nimport '@one/file'`, `import './three'\nimport '@two/file'\nimport '@one/file'`, ], ], }, }, { plugins: ['prettier-plugin-multiline-arrays'], tests: { babel: [[`const array = [\n'one']`, `const array = [\n 'one',\n]`]], typescript: [[`const array = [\n'one']`, `const array = [\n 'one',\n]`]], 'babel-ts': [[`const array = [\n'one']`, `const array = [\n 'one',\n]`]], }, }, { plugins: ['prettier-plugin-organize-imports'], options: {}, tests: { babel: [ [ `import './three'\nimport '@two/file'\nimport '@one/file'`, `import '@one/file'\nimport '@two/file'\nimport './three'`, ], ], typescript: [ [ `import './three'\nimport '@two/file'\nimport '@one/file'`, `import '@one/file'\nimport '@two/file'\nimport './three'`, ], ], 'babel-ts': [ [ `import './three'\nimport '@two/file'\nimport '@one/file'`, `import '@one/file'\nimport '@two/file'\nimport './three'`, ], ], }, }, { plugins: ['@zackad/prettier-plugin-twig'], options: { twigAlwaysBreakObjects: false, tailwindFunctions: ['addClass', 'tw'], }, tests: { twig: [ [ `
      `, `
      `, ], t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, // text-center is used because it's placed between p-0 and sm:p-0 t`
      `, t`
      `, t`
      `, t`
      `, [ `
      `, `
      `, ], [ `
      `, `
      `, ], // Function call tests t`
      `, t`
      `, t`{{ tw('${yes}') }}`, t`{{ attributes.addClass('${yes}') }}`, t`{{ tw('${yes}').tw('${yes}').tw('${yes}') }}`, t`{{ attributes.addClass('${yes}').addClass('${yes}').addClass('${yes}') }}`, t`{% set className = '${no}' %} {{ attributes.addClass(className) }}`, [ `{{ attributes.addClass("sm:p-0 " ~ variant ~ " p-0") }}`, `{{ attributes.addClass('sm:p-0 ' ~ variant ~ ' p-0') }}`, ], ], }, }, { plugins: ['@prettier/plugin-hermes'], tests: { hermes: javascript, }, }, { plugins: ['@prettier/plugin-oxc'], tests: { oxc: javascript, 'oxc-ts': javascript, }, }, { plugins: ['@prettier/plugin-pug'], tests: { pug: [ [ `a(class='md:p-4 sm:p-0 p-4 bg-blue-600' href='//example.com') Example`, `a.bg-blue-600.p-4(class='sm:p-0 md:p-4', href='//example.com') Example`, ], [ `a.p-4.bg-blue-600(class='sm:p-0 md:p-4', href='//example.com') Example`, `a.bg-blue-600.p-4(class='sm:p-0 md:p-4', href='//example.com') Example`, ], [ `a.p-4.bg-blue-600(class=' sm:p-0 md:p-4 ', href='//example.com') Example`, `a.bg-blue-600.p-4(class='sm:p-0 md:p-4', href='//example.com') Example`, ], // These two tests show how our sorting the two class lists separately is suboptimal // Two consecutive saves will result in different output // Where the second save is the most correct [ `a.p-4(class='bg-blue-600 sm:p-0 md:p-4', href='//example.com') Example`, `a.p-4.bg-blue-600(class='sm:p-0 md:p-4', href='//example.com') Example`, ], [ `a.p-4.bg-blue-600(class='sm:p-0 md:p-4', href='//example.com') Example`, `a.bg-blue-600.p-4(class='sm:p-0 md:p-4', href='//example.com') Example`, ], ], }, }, { plugins: ['prettier-plugin-jsdoc'], tests: { babel: [ [ `/**\n * @param { string } param0 description\n */\n export default function Foo(param0) { return
      }`, `/** @param {string} param0 Description */\nexport default function Foo(param0) {\n return
      \n}`, ], ], }, }, { plugins: ['prettier-plugin-css-order'], tests: { css: [ [ `.foo {\n color: red;\n background-color: blue;\n @apply sm:p-0 p-4 bg-blue-600;\n}`, `.foo {\n background-color: blue;\n color: red;\n @apply bg-blue-600 p-4 sm:p-0;\n}`, ], ], }, }, { plugins: ['prettier-plugin-organize-attributes'], tests: { html: [ [ `
      Example`, `Example`, ], ], }, }, { plugins: ['@shopify/prettier-plugin-liquid'], tests: { 'liquid-html': [ t`Example`, t`{% if state == true %}\n Example\n{% endif %}`, t`{%- capture class_ordering -%}
      {%- endcapture -%}`, t`{%- capture class_ordering -%}
      {%- endcapture -%}`, t`{%- capture class_ordering -%}
      {%- endcapture -%}`, t`

      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, // Whitespace removal is disabled for Liquid // due to the way Liquid prints the AST // (the length of the output MUST NOT change) [ `
      `, `
      `, ], [ `
      `, `
      `, ], ], }, }, { plugins: ['prettier-plugin-marko'], tests: { marko: [ t`
      `, t``, t`
      `, t`
      `, t`
      `, t`

      `, t`style { h1 { @apply ${yes}; } }`, t`
      `, t`
      `, [`
      `, `
      `], // TODO: An improvement to the plugin would be to remove the whitespace // in this scenario: [ `
      `, `
      `, ], ], }, }, { plugins: ['prettier-plugin-astro'], options: { tailwindAttributes: ['/(styles|classes)/'], }, tests: { astro: [ // ...html, // TODO: [ '
      ', '
      ', ], [ `{
      }`, `{(
      )}`, ], [ ``, ``, ], t`--- import Layout from '../layouts/Layout.astro' import Custom from '../components/Custom.astro' ---
      `, t`
      `, t`
      `, t``, t``, [ `
      `, `
      `, ], [ `
      `, `
      `, ], ], }, }, { plugins: ['prettier-plugin-svelte'], tests: { svelte: [ t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`{#if something}
      {:else}
      {/if}`, [ `
      `, `
      `, ], [ `
      `, `
      `, ], [ `
      `, `
      `, ], t`{#await promise()}
      {:then}
      {/await}`, t`{#await promise() then}
      {/await}`, // Whitespace removal is applied by Svelte itself [ `
      `, `
      `, ], // Whitespace removal does not work in Svelte // due to how Svelte's parser and printer work // (the length of the text MUST NOT change) [ `
      `, `
      `, ], // Escapes t`
      `, // Preserve whitespace in template strings // This test has lots of whitespace to ensure that the Svelte // parser doesn't produce invalid syntax as output since it breaks // when changing the length of the text. [ `
      `, `
      `, ], // Duplicates can be removed in simple attributes [`
      `, `
      `], // Duplicates cannot be removed in string literals otherwise invalid // code will be produced during printing. [`
      `, `
      `], // Duplicates cannot be removed in template literals otherwise invalid // code will be produced during printing. [ `
      `, `
      `, ], ], }, }, // This test ensures that our plugin works with the multiline array, JSDoc, // and import sorting plugins when used together. // // The plugins actually have to be *imported* in a specific order for // them to function correctly *together*. { plugins: [ 'prettier-plugin-multiline-arrays', '@trivago/prettier-plugin-sort-imports', 'prettier-plugin-jsdoc', ], options: { multilineArraysWrapThreshold: 0, importOrder: ['^@one/(.*)$', '^@two/(.*)$', '^[./]'], importOrderSortSpecifiers: true, }, tests: { babel: [ [ dedent` import './three' import '@two/file' import '@one/file' /** * - Position */ const position = {} const arr = ['a', 'b', 'c', 'd', 'e', 'f'] `, dedent` import '@one/file' import '@two/file' import './three' /** - Position */ const position = {} const arr = [ 'a', 'b', 'c', 'd', 'e', 'f', ] `, ], ], }, }, ] for (const group of tests) { let name = group.plugins.join(', ') for (let parser in group.tests) { test(`parsing ${parser} works with: ${name}`, async ({ expect, skip }) => { if (group.plugins.includes('prettier-plugin-multiline-arrays')) { return skip( 'The `prettier-plugin-multiline-arrays` plugin does not work with Prettier v3.7+', ) } // Hide logs from Pug's prettier plugin if (parser === 'pug') { let pug = await import('@prettier/plugin-pug') // @ts-ignore pug.logger.level = 'off' } let plugins = [...group.plugins.map((name) => require.resolve(name)), pluginPath] for (const [input, expected] of group.tests[parser]) { let output = await format(input, { parser, plugins, ...group.options }) expect(output).toEqual(expected) } }) } } ================================================ FILE: tests/sorter.test.ts ================================================ import * as path from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, test } from 'vitest' import { createSorter } from '../src/sorter' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) describe('createSorter', () => { describe('sortClassAttributes', () => { test('sorts with base + relative configPath (v3)', async () => { let fixtureDir = path.resolve(__dirname, 'fixtures/basic') let sorter = await createSorter({ base: fixtureDir, filepath: path.join(fixtureDir, 'index.html'), configPath: './tailwind.config.js', }) let [sorted] = sorter.sortClassAttributes(['sm:bg-tomato bg-red-500']) expect(sorted).toBe('bg-red-500 sm:bg-tomato') }) test('sorts with base + absolute configPath', async () => { let fixtureDir = path.resolve(__dirname, 'fixtures/basic') let configPath = path.join(fixtureDir, 'tailwind.config.js') let sorter = await createSorter({ base: fixtureDir, configPath, }) let sorted = sorter.sortClassAttributes(['p-4 m-2', 'hover:text-red-500 text-blue-500']) expect(sorted).toEqual(['m-2 p-4', 'text-blue-500 hover:text-red-500']) }) test('sorts with v4 stylesheet', async () => { let fixtureDir = path.resolve(__dirname, 'fixtures/custom-pkg-name-v4') let sorter = await createSorter({ base: fixtureDir, stylesheetPath: './app.css', }) let [sorted] = sorter.sortClassAttributes(['sm:bg-tomato bg-red-500']) expect(sorted).toBe('bg-red-500 sm:bg-tomato') }) test('preserves whitespace when option is enabled', async () => { let fixtureDir = path.resolve(__dirname, 'fixtures/basic') let sorter = await createSorter({ base: fixtureDir, configPath: './tailwind.config.js', preserveWhitespace: true, }) let [sorted] = sorter.sortClassAttributes([' sm:bg-tomato bg-red-500 ']) expect(sorted).toBe(' bg-red-500 sm:bg-tomato ') }) test('collapses whitespace by default', async () => { let fixtureDir = path.resolve(__dirname, 'fixtures/basic') let sorter = await createSorter({ base: fixtureDir, configPath: './tailwind.config.js', }) let [sorted] = sorter.sortClassAttributes([' sm:bg-tomato bg-red-500 ']) expect(sorted).toBe('bg-red-500 sm:bg-tomato') }) test('removes duplicates by default', async () => { let fixtureDir = path.resolve(__dirname, 'fixtures/basic') let sorter = await createSorter({ base: fixtureDir, configPath: './tailwind.config.js', }) let [sorted] = sorter.sortClassAttributes(['bg-red-500 sm:bg-tomato bg-red-500']) expect(sorted).toBe('bg-red-500 sm:bg-tomato') }) test('preserves duplicates when option is enabled', async () => { let fixtureDir = path.resolve(__dirname, 'fixtures/basic') let sorter = await createSorter({ base: fixtureDir, configPath: './tailwind.config.js', preserveDuplicates: true, }) let [sorted] = sorter.sortClassAttributes(['bg-red-500 sm:bg-tomato bg-red-500']) expect(sorted).toBe('bg-red-500 bg-red-500 sm:bg-tomato') }) }) describe('sortClassLists', () => { test('sorts class lists (arrays of class names)', async () => { let fixtureDir = path.resolve(__dirname, 'fixtures/basic') let sorter = await createSorter({ base: fixtureDir, configPath: './tailwind.config.js', }) let sorted = sorter.sortClassLists([ ['sm:bg-tomato', 'bg-red-500'], ['p-4', 'm-2'], ]) expect(sorted).toEqual([ ['bg-red-500', 'sm:bg-tomato'], ['m-2', 'p-4'], ]) }) test('removes duplicates by default', async () => { let fixtureDir = path.resolve(__dirname, 'fixtures/basic') let sorter = await createSorter({ base: fixtureDir, configPath: './tailwind.config.js', }) let [sorted] = sorter.sortClassLists([['bg-red-500', 'sm:bg-tomato', 'bg-red-500']]) expect(sorted).toEqual(['bg-red-500', 'sm:bg-tomato']) }) test('preserves duplicates when option is enabled', async () => { let fixtureDir = path.resolve(__dirname, 'fixtures/basic') let sorter = await createSorter({ base: fixtureDir, configPath: './tailwind.config.js', preserveDuplicates: true, }) let [sorted] = sorter.sortClassLists([['bg-red-500', 'sm:bg-tomato', 'bg-red-500']]) expect(sorted).toEqual(['bg-red-500', 'bg-red-500', 'sm:bg-tomato']) }) }) describe('error handling', () => { test('handles auto-detection without explicit config', async () => { let fixtureDir = path.resolve(__dirname, 'fixtures/basic') let sorter = await createSorter({ base: fixtureDir, filepath: path.join(fixtureDir, 'index.html'), }) let [sorted] = sorter.sortClassAttributes(['sm:bg-tomato bg-red-500']) expect(sorted).toBe('bg-red-500 sm:bg-tomato') }) test('works with no tailwind installation (uses bundled)', async () => { let fixtureDir = path.resolve(__dirname, 'fixtures/no-local-version') let sorter = await createSorter({ base: fixtureDir, stylesheetPath: './app.css', }) let [sorted] = sorter.sortClassAttributes(['sm:bg-tomato bg-red-500']) expect(sorted).toBe('bg-red-500 sm:bg-tomato') }) test('works without a config file (uses default Tailwind config)', async () => { let fixtureDir = path.resolve(__dirname, 'fixtures/no-stylesheet-given') let sorter = await createSorter({ base: fixtureDir, }) // Should still sort using default Tailwind order let [sorted] = sorter.sortClassAttributes(['p-4 m-2']) expect(sorted).toBe('m-2 p-4') }) }) describe('monorepo support', () => { test('resolves tailwind relative to filepath in monorepo', async () => { let fixtureDir = path.resolve(__dirname, 'fixtures/monorepo') let package1Path = path.join(fixtureDir, 'package-1', 'index.html') let sorter = await createSorter({ base: path.join(fixtureDir, 'package-1'), filepath: package1Path, stylesheetPath: './app.css', }) let [sorted] = sorter.sortClassAttributes(['sm:bg-tomato bg-red-500']) expect(sorted).toBe('bg-red-500 sm:bg-tomato') }) }) }) ================================================ FILE: tests/tests.ts ================================================ import type { TestEntry } from './utils.js' import { no, t, yes } from './utils.js' let html: TestEntry[] = [ t`
      `, t``, t`
      `, t`
      `, ['
      ', '
      '], t`
      `, t`
      `, // Ensure duplicate classes are removed ['
      ', '
      '], // Duplicates are not removed for unknown classes [ '
      ', '
      ', ], // Ensure duplicate can be kept [ '
      ', '
      ', { tailwindPreserveDuplicates: true, }, ], // … is moved to the end of the list ['
      ', '
      '], ['
      ', '
      '], ['
      ', '
      '], ['
      ', '
      '], ['
      ', '
      '], ['
      ', '
      '], ] let css: TestEntry[] = [ t`@apply ${yes};`, t`/* @apply ${no}; */`, t`@not-apply ${no};`, ['@apply sm:p-0\n p-0;', '@apply p-0\n sm:p-0;', { tailwindPreserveWhitespace: true }], // Quote conversion for custom at-rules [`@import "./file.css";`, `@import './file.css';`], [`@plugin "./file.js";`, `@plugin './file.js';`], [`@config "./file.js";`, `@config './file.js';`], [`@source "./file.js";`, `@source './file.js';`], [`@source not "./file.js";`, `@source not './file.js';`], [`@source inline("flex");`, `@source inline('flex');`], [ `@import "tailwindcss";\n\n@import "./theme.css";\n\n@source "./file.js";\n\n.foo {\n color: red;\n}`, `@import 'tailwindcss';\n\n@import './theme.css';\n\n@source './file.js';\n\n.foo {\n color: red;\n}`, ], [ `@import "tailwindcss";\n\n@import "./theme.css";\n\n@plugin "./file.js";\n\n.foo {\n color: red;\n}`, `@import 'tailwindcss';\n\n@import './theme.css';\n\n@plugin './file.js';\n\n.foo {\n color: red;\n}`, ], [ `@import "tailwindcss";\n\n@import "./theme.css";\n\n@config "./file.js";\n\n.foo {\n color: red;\n}`, `@import 'tailwindcss';\n\n@import './theme.css';\n\n@config './file.js';\n\n.foo {\n color: red;\n}`, ], ] export let javascript: TestEntry[] = [ t`;
      `, t`;
      `, t`/*
      */`, t`//
      `, t`;
      `, t`;
      `, t`;
      `, t`;
      `, t`;
      `, t`;
      `, t`;
      `, t`;
      `, t`;
      `, t`;
      `, t`;
      `, [ `;
      `, `;
      `, ], [ `;
      `, `;
      `, ], [ `;
      `, `;
      `, ], [`;
      `, `;
      `], // Whitespace is normalized and duplicates are removed [';
      ', ';
      '], [";
      ", ";
      "], [';
      ', ';
      '], [';
      ', ';
      '], [';
      ', ';
      '], [ ';
      ', ';
      ', ], [';
      ', ';
      '], [ ';
      ', ';
      ', ], t`;
      `, t`;
      `, t`;
      `, [ `;
      `, `;
      `, ], [ `;
      `, `;
      `, ], ] javascript = javascript.concat( javascript.map((test) => [ test[0].replace(/class/g, 'className'), test[1].replace(/class/g, 'className'), test[2], ]), ) let vue: TestEntry[] = [ ...html, t`
      `, t``, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, // ts t`
      `, // ts [ `
      `, `
      `, ], [ `
      `, `
      `, ], [ `
      `, `
      `, ], [`
      `, `
      `], [`
      `, `
      `], [`
      `, `
      `], [ `
      `, `
      `, ], [ `
      `, `
      `, ], ] let glimmer: TestEntry[] = [ t`
      `, t``, t`
      `, t`
      `, t`
      `, t`
      `, [ `
      `, `
      `, ], [ `
      `, `
      `, ], t`
      `, ["
      ", "
      "], t`
      `, t`
      `, t`{{link 'Some page' href=person.url class='${no}'}}`, t`
      `, [ `
      `, `
      `, ], [ `
      `, `
      `, ], [ `
      `, `
      `, ], [`
      `, `
      `], [ `
      `, `
      `, ], ] export let tests: Record = { html, glimmer, lwc: html, vue: [ // ...vue, t`
      `, ], angular: [ ...html, t`
      `, t``, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, t`
      `, [ `
      `, `
      `, ], // TODO: Enable this test — it causes console noise but not a failure // t`
      `, t`
      `, ], css: [ // ...css, t`@apply ${yes} !important;`, ], scss: [ ...css, t`@apply ${yes} #{!important};`, t`@apply ${yes} #{'!important'};`, t`@apply ${yes} #{"!important"};`, // These shouldn't ever be used but they are valid // syntax so we might as well not break them t`@apply ${yes} #{""!important""};`, t`@apply ${yes} #{'''!important'''};`, t`@apply ${yes} #{"'"'"!important"'"'"};`, ], less: [ // ...css, t`@apply ${yes} !important;`, t`@apply ~"${yes}";`, t`@apply ~'${yes}';`, ], babel: javascript, typescript: javascript, 'babel-ts': javascript, flow: javascript, 'babel-flow': javascript, acorn: javascript, meriyah: javascript, mdx: javascript .filter((test) => !test[0].startsWith('/*') && !test[1].startsWith('/*')) .map((test) => [test[0].replace(/^;/, ''), test[1].replace(/^;/, ''), test[2]]), } ================================================ FILE: tests/utils.ts ================================================ import * as path from 'node:path' import { fileURLToPath } from 'node:url' import * as prettier from 'prettier' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) let testClassName = 'sm:p-0 p-0' let testClassNameSorted = 'p-0 sm:p-0' export let yes = '__YES__' export let no = '__NO__' export type TestEntry = [ input: string, output: string, options?: { tailwindPreserveWhitespace?: boolean tailwindPreserveDuplicates?: boolean }, ] export function t(strings: TemplateStringsArray, ...values: string[]): TestEntry { let input = '' strings.forEach((string, i) => { input += string + (values[i] ? testClassName : '') }) let output = '' strings.forEach((string, i) => { let value = values[i] || '' if (value === yes) value = testClassNameSorted else if (value === no) value = testClassName output += string + value }) return [input, output, { tailwindPreserveWhitespace: true }] } export let pluginPath = path.resolve(__dirname, '../dist/index.mjs') export async function format(str: string, options: prettier.Options = {}) { let result = await prettier.format(str, { semi: false, singleQuote: true, printWidth: 9999, parser: 'html', ...options, plugins: [ // ...(options.plugins ?? []), // plugin, pluginPath, ], }) return result.trim() } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "lib": ["ESNext", "DOM"], "target": "ESNext", "module": "ESNext", "moduleDetection": "force", "allowJs": true, "moduleResolution": "Bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "isolatedModules": true, "preserveConstEnums": true, "noEmit": true, "skipLibCheck": true, "strict": true, "noFallthroughCasesInSwitch": true, "forceConsistentCasingInFileNames": true , "stripInternal": true, }, "include": ["src/*.ts"], "exclude": ["node_modules"] } ================================================ FILE: tsdown.config.ts ================================================ import { readFile } from 'node:fs/promises' import * as path from 'node:path' import { defineConfig, Rolldown } from 'tsdown' /** * Patches jiti to use require for babel import. */ function patchJiti(): Rolldown.Plugin { return { name: 'patch-jiti', async load(id) { if (!/jiti[\\/]lib[\\/]jiti\.mjs$/.test(id)) { return null } let original = await readFile(id, 'utf8') return { code: original.replace( 'createRequire(import.meta.url)("../dist/babel.cjs")', 'require("../dist/babel.cjs")', ), } }, } } /** * Inlines CSS imports as JavaScript strings. */ function inlineCssImports(): Rolldown.Plugin { return { name: 'inline-css-imports', async load(id) { // Inline CSS imports if (id.endsWith('.css')) { let content = await readFile(id, 'utf-8') return { code: `export default ${JSON.stringify(content)}`, moduleType: 'js', } } // Inline preflight in v3 if (id.endsWith('corePlugins.js')) { let preflightPath = path.resolve(path.dirname(id), './css/preflight.css') let preflightContent = await readFile(preflightPath, 'utf-8') let content = await readFile(id, 'utf-8') // This is a bit fragile but this is to inline preflight for the // *bundled* version which means a failing test should be enough content = content.replace( `_fs.default.readFileSync(_path.join(__dirname, "./css/preflight.css"), "utf8")`, JSON.stringify(preflightContent), ) return { code: content, } } return null }, } } export default defineConfig({ entry: ['./src/index.ts', './src/sorter.ts'], format: 'esm', platform: 'node', target: 'node14.21.3', external: ['prettier'], dts: true, sourcemap: false, fixedExtension: true, minify: 'dce-only', inlineOnly: false, shims: true, plugins: [patchJiti(), inlineCssImports()], }) ================================================ FILE: vitest.config.ts ================================================ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { testTimeout: 10000, css: true, }, plugins: [ { name: 'force-inline-css', enforce: 'pre', resolveId(id) { if (!id.endsWith('.css')) return if (id.includes('?raw')) return return this.resolve(`${id}?raw`) }, }, ], })