Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: tailwindlabs/prettier-plugin-tailwindcss
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v0.5.6
Choose a base ref
...
head repository: tailwindlabs/prettier-plugin-tailwindcss
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: main
Choose a head ref

Commits on Nov 8, 2023

  1. Correctly populate dynamicAttrs for custom attributes (#225)

    * Fix `dynamicAttrs` in `options.js`
    
    * Add test
    
    ---------
    
    Co-authored-by: Jordan Pittman <jordan@cryptica.me>
    UfukUstali and thecrypticace authored Nov 8, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    15c1196 View commit details
  2. Update changelog

    thecrypticace committed Nov 8, 2023
    Copy the full SHA
    92f10af View commit details
  3. update changelog

    thecrypticace committed Nov 8, 2023
    Copy the full SHA
    c0f6185 View commit details
  4. 0.5.7

    thecrypticace committed Nov 8, 2023
    Copy the full SHA
    38f2be3 View commit details

Commits on Dec 5, 2023

  1. Re-enable Marko support (#229)

    * Re-enable Marko support
    
    * Update changelog
    
    ---------
    
    Co-authored-by: Jordan Pittman <jordan@cryptica.me>
    AngusMorton and thecrypticace authored Dec 5, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    cf301ca View commit details
  2. Update deps

    thecrypticace committed Dec 5, 2023
    Copy the full SHA
    4cc6319 View commit details
  3. 0.5.8

    thecrypticace committed Dec 5, 2023
    Copy the full SHA
    dd4bfce View commit details
  4. Fix embedded preflight location (#231)

    * Fix embedded preflight location
    
    * Update changelog
    thecrypticace authored Dec 5, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    fa38999 View commit details
  5. 0.5.9

    thecrypticace committed Dec 5, 2023
    Copy the full SHA
    e3229c2 View commit details

Commits on Dec 19, 2023

  1. Bump bundled version of Tailwind CSS to v3.4 (#235)

    * Bump bundled version of Tailwind CSS to v3.4
    
    * Update changelog
    thecrypticace authored Dec 19, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    9f8780c View commit details

Commits on Dec 28, 2023

  1. 0.5.10

    thecrypticace committed Dec 28, 2023
    Copy the full SHA
    885b14f View commit details

Commits on Jan 5, 2024

  1. Bump bundled version of tailwindcss to v3.4.1 (#240)

    * Bump bundled version of `tailwindcss` to `v3.4.1`
    
    * Update changelog
    thecrypticace authored Jan 5, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    08f5c4c View commit details
  2. 0.5.11

    thecrypticace committed Jan 5, 2024
    Copy the full SHA
    bf40417 View commit details

Commits on Jan 8, 2024

  1. Update readme

    thecrypticace committed Jan 8, 2024
    Copy the full SHA
    4338bd0 View commit details

Commits on Jan 12, 2024

  1. Add support for prettier-plugin-sort-imports (#241)

    * Update plugins.js
    
    * Update README
    
    * Update tests and peer deps
    
    * Update Prettier
    
    * Update changelog
    
    ---------
    
    Co-authored-by: Jordan Pittman <jordan@cryptica.me>
    VladimirMikulic and thecrypticace authored Jan 12, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    1fa24c2 View commit details

Commits on Feb 28, 2024

  1. Add preliminary support for v4 (#249)

    * Refactor
    
    * Add support for loading v4
    
    * Update changelog
    thecrypticace authored Feb 28, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    81c446e View commit details

Commits on Mar 4, 2024

  1. Rename API

    thecrypticace committed Mar 4, 2024
    Copy the full SHA
    b019552 View commit details

Commits on Mar 6, 2024

  1. 0.5.12

    thecrypticace committed Mar 6, 2024
    Copy the full SHA
    f563336 View commit details

Commits on Mar 22, 2024

  1. ci: add provenance to npm packages (#252)

    This commit adds provenance for all published packages. See the NPM documentation [0].
    
    Provenance will allow people to verify that the packages were actually built on GH Actions and with the content of the corresponding commit. This will help with supply chain security.
    
    For this to work, the `id-token` permission was added only where necessary.
    
    [0]: https://docs.npmjs.com/generating-provenance-statements
    saibotk authored Mar 22, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    10e0c19 View commit details

Commits on Mar 26, 2024

  1. Add test verifying that namedspaced JSX attributes are ignored

    thecrypticace committed Mar 26, 2024
    Copy the full SHA
    54f0d43 View commit details

Commits on Mar 27, 2024

  1. Re-enable support for Twig / Melody (#255)

    * Support plugins meant to work with Prettier 2 _and_ 3
    
    * Re-enable `prettier-plugin-twig-melody`
    
    * Update lockfile
    
    * Update changelog
    
    * Update wording
    
    Co-authored-by: Jonathan Reinink <jonathan@reinink.ca>
    
    ---------
    
    Co-authored-by: Jonathan Reinink <jonathan@reinink.ca>
    thecrypticace and reinink authored Mar 27, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    35fe358 View commit details
  2. Update fallback version of Tailwind to v3.4.2 (#256)

    thecrypticace authored Mar 27, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    d66a6d5 View commit details
  3. 0.5.13

    thecrypticace committed Mar 27, 2024
    Copy the full SHA
    3201e5f View commit details

Commits on Apr 10, 2024

  1. Add example for Prettier ESM config (#263)

    * Add example for Prettier ESM config
    
    Thought this might be useful for folks since we are in a shift towards an ESM config future.
    
    * Update README.md
    
    ---------
    
    Co-authored-by: Adam Wathan <adam.wathan@gmail.com>
    valtism and adamwathan authored Apr 10, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    b4aa821 View commit details
  2. Update README.md

    adamwathan authored Apr 10, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    360cb77 View commit details

Commits on Apr 15, 2024

  1. Fix detection of v4 projects on Windows (#265)

    * Use file url for dynamic imports
    
    * Update changelog
    thecrypticace authored Apr 15, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    3df9921 View commit details
  2. 0.5.14

    thecrypticace committed Apr 15, 2024
    Copy the full SHA
    3c9ce4e View commit details

Commits on May 30, 2024

  1. Remove duplicate classes and excess whitespace (#272)

    * Add docblock
    
    * Update comments
    
    * Remove duplicate classes
    
    * Add option to collapse whitespace
    
    * Update tests
    
    * Don’t trim entirely empty class lists
    
    We leave one space in when a class list consists of just whitespace
    
    * Remove whitespace by default
    
    * Rename option to `tailwindPreserveWhitespace`
    
    * Update changelog
    thecrypticace authored May 30, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    1f83aae View commit details
  2. 0.6.0

    thecrypticace committed May 30, 2024
    Copy the full SHA
    afbc487 View commit details

Commits on May 31, 2024

  1. Improve handling of whitespace and duplicate class removal (#276)

    * Add tests
    
    * Be more careful about whitespace removal in concat expressions
    
    * Detect liquid concat expressions in newer ASTs
    
    * Make sure string splicing handles changing string length
    
    * Add option to preserve duplicate classes
    
    This is important when using templating languages
    
    * Disable whitespace removal in Svelte
    
    We’d have to modify the start/end locations of many nodes in the AST. Technically, only AST nodes appearing _after_ the string on the same line but that might actually be sibling nodes and ancestor nodes. It’s a better option for now to disable it.
    
    * Disable whitespace removal in Liquid
    
    * Disable duplicate removal in Liquid and Svelte
    
    * Update changelog
    
    * Update readme
    
    * Update CHANGELOG.md
    
    * Update README.md
    
    ---------
    
    Co-authored-by: Jonathan Reinink <jonathan@reinink.ca>
    thecrypticace and reinink authored May 31, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    8a9094f View commit details
  2. 0.6.1

    thecrypticace committed May 31, 2024
    Copy the full SHA
    731ae22 View commit details

Commits on Jun 3, 2024

  1. Only remove duplicate Tailwind classes (#277)

    * improve remove duplicate classes
    
    * replace indexOf by Set
    
    * Simplify code
    
    * Refactor
    
    * Remove duplicates after sorting
    
    * Only remove duplicates of known classes
    
    * Rename vars
    
    * Tweak comment
    
    * Tweak var names
    
    * wip
    
    * Move duplicate removal to sorting routine
    
    * Refactor
    
    * Refactor
    
    * Tweak comment
    
    * Cleanup
    
    * Update changelog
    
    ---------
    
    Co-authored-by: Jordan Pittman <jordan@cryptica.me>
    WooWan and thecrypticace authored Jun 3, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    82ea71a View commit details

Commits on Jun 6, 2024

  1. Make sure escapes in classes are preserved in string literals (#286)

    * Add tests for escapes
    
    * Make sure escapes in classes are preserved in string literals
    
    * Update changelog
    thecrypticace authored Jun 6, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    57afde1 View commit details

Commits on Jun 7, 2024

  1. 0.6.2

    thecrypticace committed Jun 7, 2024
    Copy the full SHA
    aa6ec0a View commit details

Commits on Jun 11, 2024

  1. Convert to typescript (#287)

    * Convert expiring map to JS
    
    * Conver option list to TypeScript
    
    * Fix ESM plugin loading
    
    This doesn’t seem to actually have any side-effects but typescript has revealed possible issues here
    
    * Convert plugin loading to typescript
    
    * Convert config loading to typescript
    
    * Convert sorting code to typescript
    
    * Convert utils to typescript
    
    * Add types
    
    * Convert main file to typescript
    
    * wip
    
    * Make package type: module
    
    * Switch to vitest
    
    * Update lockfile
    
    * Run fixture tests concurrently
    
    * Use tsup for building dts files
    
    Can’t use it to bundle just yet
    
    * Bump test timeout
    
    * Use Node 18 in CI
    
    * Use Node v22 in CI
    
    Worth seeing if it makes things faster
    
    * Remove expired data from maps to prevent memory leaks
    
    * Update comments
    
    * Remove comments
    
    Probably isn’t any reason for this actually
    
    * Add better types to `prefixCandidate`
    thecrypticace authored Jun 11, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    5e7b8b5 View commit details
  2. Fix publishing and testing scripts (#289)

    * Fix publishing and testing scripts
    
    * Update license copying code
    thecrypticace authored Jun 11, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    6ded534 View commit details
  3. Improve detection of string concatenation (#288)

    * Improve `visit()` types
    
    * Check for ancestor concat expressions
    
    * Rename global usage of `createRequire`
    
    * Update changelog
    
    * Update src/index.ts
    
    Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
    
    * Update src/index.ts
    
    Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
    
    * Update src/index.ts
    
    Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
    
    ---------
    
    Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
    thecrypticace and RobinMalfait authored Jun 11, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    b047e02 View commit details
  4. Update changelog

    thecrypticace committed Jun 11, 2024
    Copy the full SHA
    1010b51 View commit details
  5. 0.6.3

    thecrypticace committed Jun 11, 2024
    Copy the full SHA
    bd3d5b0 View commit details
  6. Use Node 22 in all CI workflows (#290)

    thecrypticace authored Jun 11, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    9b9bfe4 View commit details

Commits on Jun 12, 2024

  1. Export plugin options type (#292)

    thecrypticace authored Jun 12, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    0ea2dc0 View commit details
  2. Update changelog

    thecrypticace committed Jun 12, 2024
    Copy the full SHA
    a13021c View commit details
  3. 0.6.4

    thecrypticace committed Jun 12, 2024
    Copy the full SHA
    2a9d702 View commit details

Commits on Jun 13, 2024

  1. Tweak readme

    thecrypticace committed Jun 13, 2024
    Copy the full SHA
    ec92b76 View commit details

Commits on Jun 17, 2024

  1. Only re-apply string escaping when necessary (#295)

    * Simplify test
    
    * Only apply escaping when necessary
    thecrypticace authored Jun 17, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    a5506a9 View commit details
  2. Update changelog

    thecrypticace committed Jun 17, 2024
    Copy the full SHA
    642a97c View commit details
  3. 0.6.5

    thecrypticace committed Jun 17, 2024
    Copy the full SHA
    efea6f9 View commit details

Commits on Jun 18, 2024

  1. Add ast-types and @babel/types to dev dependencies (#296)

    akx authored Jun 18, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    031e5f1 View commit details
  2. Move fixtures' expected outputs to the fixture directories (#298)

    * Move fixtures' expected outputs to the fixture directories
    
    This makes them easier to diff with conventional tools.
    
    * Tweak code a bit
    
    ---------
    
    Co-authored-by: Jordan Pittman <jordan@cryptica.me>
    akx and thecrypticace authored Jun 18, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    f101793 View commit details

Commits on Jun 24, 2024

  1. Add compatibility with prettier-plugin-multiline-arrays (#299)

    * Add compatibility with `prettier-plugin-multiline-arrays`
    
    * Update changelog
    
    ---------
    
    Co-authored-by: Jordan Pittman <jordan@cryptica.me>
    jrouleau and thecrypticace authored Jun 24, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    c5eed72 View commit details
Showing with 7,437 additions and 8,740 deletions.
  1. +37 −0 .github/ISSUE_TEMPLATE/bug-report.md
  2. +0 −3 .github/ISSUE_TEMPLATE/config.yml
  3. +1 −1 .github/workflows/ci.yml
  4. +1 −1 .github/workflows/prepare-release.yml
  5. +3 −2 .github/workflows/release-insiders.yml
  6. +3 −2 .github/workflows/release.yml
  7. +139 −1 CHANGELOG.md
  8. +89 −23 README.md
  9. +6 −23 build.mjs
  10. +4,713 −7,662 package-lock.json
  11. +43 −23 package.json
  12. +1 −1 prettier.config.js
  13. +37 −20 scripts/copy-licenses.js
  14. +24 −14 scripts/install-fixture-deps.js
  15. +10 −4 scripts/release-channel.js
  16. +11 −7 scripts/release-notes.js
  17. +0 −180 src/config.js
  18. +366 −0 src/config.ts
  19. +0 −38 src/expiring-map.js
  20. +31 −0 src/expiring-map.ts
  21. +0 −27 src/index.d.ts
  22. +464 −216 src/{index.js → index.ts}
  23. +13 −0 src/internal.d.ts
  24. +46 −25 src/{options.js → options.ts}
  25. +39 −46 src/{plugins.js → plugins.ts}
  26. +76 −0 src/resolve.ts
  27. +0 −99 src/sorting.js
  28. +193 −0 src/sorting.ts
  29. +0 −42 src/types.d.ts
  30. +54 −0 src/types.ts
  31. +57 −0 src/utils.bench.ts
  32. +0 −40 src/utils.js
  33. +30 −0 src/utils.test.ts
  34. +139 −0 src/utils.ts
  35. +0 −143 tests/fixtures.test.js
  36. +126 −0 tests/fixtures.test.ts
  37. +1 −0 tests/fixtures/basic/output.html
  38. +1 −0 tests/fixtures/cjs/output.html
  39. +19 −0 tests/fixtures/custom-jsx/output.jsx
  40. +1 −0 tests/fixtures/custom-vue/index.vue
  41. +12 −0 tests/fixtures/custom-vue/output.vue
  42. +1 −0 tests/fixtures/esm-explicit/output.html
  43. +1 −0 tests/fixtures/esm/output.html
  44. +1 −0 tests/fixtures/no-prettier-config/output.html
  45. +3 −0 tests/fixtures/package.json
  46. +1 −0 tests/fixtures/plugins/output.html
  47. +1 −0 tests/fixtures/ts-explicit/output.html
  48. +1 −0 tests/fixtures/ts/output.html
  49. +1 −0 tests/fixtures/v3-2/output.html
  50. +5 −0 tests/fixtures/v4/basic/app.css
  51. +1 −0 tests/fixtures/v4/basic/index.html
  52. +1 −0 tests/fixtures/v4/basic/output.html
  53. +18 −0 tests/fixtures/v4/basic/package-lock.json
  54. +8 −0 tests/fixtures/v4/basic/package.json
  55. +21 −0 tests/fixtures/v4/css-loading-js/app.css
  56. +9 −0 tests/fixtures/v4/css-loading-js/cjs/my-config.cjs
  57. +24 −0 tests/fixtures/v4/css-loading-js/cjs/my-plugin.cjs
  58. +9 −0 tests/fixtures/v4/css-loading-js/esm/my-config.mjs
  59. +24 −0 tests/fixtures/v4/css-loading-js/esm/my-plugin.mjs
  60. +3 −0 tests/fixtures/v4/css-loading-js/index.html
  61. +3 −0 tests/fixtures/v4/css-loading-js/output.html
  62. +18 −0 tests/fixtures/v4/css-loading-js/package-lock.json
  63. +8 −0 tests/fixtures/v4/css-loading-js/package.json
  64. +11 −0 tests/fixtures/v4/css-loading-js/ts/my-config.ts
  65. +24 −0 tests/fixtures/v4/css-loading-js/ts/my-plugin.ts
  66. +168 −16 tests/{format.test.js → format.test.ts}
  67. +132 −36 tests/{plugins.test.js → plugins.test.ts}
  68. +0 −45 tests/utils.js
  69. +57 −0 tests/utils.ts
  70. +23 −0 tsconfig.json
  71. +67 −0 tsup.config.ts
  72. +7 −0 vitest.config.ts
37 changes: 37 additions & 0 deletions .github/ISSUE_TEMPLATE/bug-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
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: ''
---

<!-- Please provide all of the information requested below. We're a small team and without all of this information it's not possible for us to help and your bug report will be closed. -->

**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
3 changes: 0 additions & 3 deletions .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
@@ -6,6 +6,3 @@ contact_links:
- 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.'
- name: Bug Report
url: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/issues/new?body=%3C%21--%20Please%20provide%20all%20of%20the%20information%20requested%20below.%20We%27re%20a%20small%20team%20and%20without%20all%20of%20this%20information%20it%27s%20not%20possible%20for%20us%20to%20help%20and%20your%20bug%20report%20will%20be%20closed.%20--%3E%0A%0A%2A%2AWhat%20version%20of%20%60prettier-plugin-tailwindcss%60%20are%20you%20using%3F%2A%2A%0A%0AFor%20example%3A%20v0.1.7%0A%0A%2A%2AWhat%20version%20of%20Tailwind%20CSS%20are%20you%20using%3F%2A%2A%0A%0AFor%20example%3A%20v3.0.22%0A%0A%2A%2AWhat%20version%20of%20Node.js%20are%20you%20using%3F%2A%2A%0A%0AFor%20example%3A%20v12.0.0%0A%0A%2A%2AWhat%20package%20manager%20are%20you%20using%3F%2A%2A%0A%0AFor%20example%3A%20npm%2C%20Yarn%0A%0A%2A%2AWhat%20operating%20system%20are%20you%20using%3F%2A%2A%0A%0AFor%20example%3A%20macOS%2C%20Windows%0A%0A%2A%2AReproduction%20URL%2A%2A%0A%0AA%20public%20GitHub%20repo%20that%20includes%20a%20minimal%20reproduction%20of%20the%20bug.%20%2A%2APlease%20do%20not%20link%20to%20your%20actual%20project%2A%2A%2C%20what%20we%20need%20instead%20is%20a%20_minimal_%20reproduction%20in%20a%20fresh%20project%20without%20any%20unnecessary%20code.%20This%20means%20it%20doesn%27t%20matter%20if%20your%20real%20project%20is%20private%2Fconfidential%2C%20since%20we%20want%20a%20link%20to%20a%20separate%2C%20isolated%20reproduction%20anyways.%20Unfortunately%20we%20can%27t%20provide%20support%20without%20a%20reproduction%2C%20and%20your%20issue%20will%20be%20closed%20with%20no%20comment%20if%20this%20is%20not%20provided.%0A%0A%2A%2ADescribe%20your%20issue%2A%2A%0A%0ADescribe%20the%20problem%20you%27re%20seeing%2C%20any%20important%20steps%20to%20reproduce%20and%20what%20behavior%20you%20expect%20instead.
about: If you've already asked for help with a problem and confirmed something is broken with prettier-plugin-tailwindcss itself, create a bug report.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -16,7 +16,7 @@ concurrency:
env:
CI: true
CACHE_PREFIX: stable
NODE_VERSION: 16
NODE_VERSION: 22

jobs:
build:
2 changes: 1 addition & 1 deletion .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
@@ -21,7 +21,7 @@ jobs:

strategy:
matrix:
node-version: [18]
node-version: [22]

steps:
- uses: actions/checkout@v3
5 changes: 3 additions & 2 deletions .github/workflows/release-insiders.yml
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@ on:

permissions:
contents: read
id-token: write

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
@@ -14,7 +15,7 @@ concurrency:
env:
CI: true
CACHE_PREFIX: stable
NODE_VERSION: 16
NODE_VERSION: 22
RELEASE_CHANNEL: insiders

jobs:
@@ -57,6 +58,6 @@ jobs:
run: npm version 0.0.0-${{ env.RELEASE_CHANNEL }}.${{ env.SHA_SHORT }} --force --no-git-tag-version

- name: Publish
run: npm publish --tag ${{ env.RELEASE_CHANNEL }}
run: npm publish --provenance --tag ${{ env.RELEASE_CHANNEL }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
5 changes: 3 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@ on:

permissions:
contents: read
id-token: write

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
@@ -14,7 +15,7 @@ concurrency:
env:
CI: true
CACHE_PREFIX: stable
NODE_VERSION: 16
NODE_VERSION: 22

jobs:
build:
@@ -52,6 +53,6 @@ jobs:
echo "RELEASE_CHANNEL=$(npm run release-channel --silent)" >> $GITHUB_ENV
- name: Publish
run: npm publish --tag ${{ env.RELEASE_CHANNEL }}
run: npm publish --provenance --tag ${{ env.RELEASE_CHANNEL }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
140 changes: 139 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -9,6 +9,124 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Nothing yet!

## [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
@@ -235,7 +353,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- 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.5.6...HEAD
[unreleased]: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.6.11...HEAD
[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
112 changes: 89 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
@@ -10,12 +10,12 @@ To get started, install `prettier-plugin-tailwindcss` as a dev-dependency:
npm install -D prettier prettier-plugin-tailwindcss
```

Then add the plugin to your Prettier config:
Then add the plugin to your [Prettier configuration](https://prettier.io/docs/en/configuration.html):

```js
// prettier.config.js
module.exports = {
plugins: ['prettier-plugin-tailwindcss'],
```json5
// .prettierrc
{
"plugins": ["prettier-plugin-tailwindcss"]
}
```

@@ -25,33 +25,46 @@ As of v0.5.x, this plugin now requires Prettier v3 and is ESM-only. This means i

## Options

### Customizing your Tailwind config path
### Specifying your Tailwind stylesheet path

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

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.

```js
// prettier.config.js
module.exports = {
tailwindConfig: './styles/tailwind.config.js',
```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 only sorts classes in the `class` attribute as well as any framework-specific equivalents like `class`, `className`, `:class`, `[ngClass]`, etc.
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:

```js
// prettier.config.js
module.exports = {
tailwindAttributes: ['myClassList'],
```json5
// .prettierrc
{
"tailwindAttributes": ["myClassList"]
}
```

@@ -73,10 +86,10 @@ In addition to sorting classes in attributes, you can also sort classes in strin

You can sort classes in function calls using the `tailwindFunctions` option, which takes a list of function names:

```js
// prettier.config.js
module.exports = {
tailwindFunctions: ['clsx'],
```json5
// .prettierrc
{
"tailwindFunctions": ["clsx"]
}
```

@@ -107,10 +120,10 @@ 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:

```js
// prettier.config.js
module.exports = {
tailwindFunctions: ['tw'],
```json5
// .prettierrc
{
"tailwindFunctions": ["tw"],
}
```

@@ -141,6 +154,57 @@ Once added, tag your strings with the function and the plugin will sort them:
const mySortedClasses = tw`bg-white p-4 dark:bg-black`
```

## 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 (
<button className=" rounded bg-blue-500 px-4 py-2 text-base text-white ">
{children}
</button>
)
}
```

## 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
<div
class="
{f:if(condition: isCompact, then: 'grid-cols-3', else: 'grid-cols-5')}
{f:if(condition: isDark, then: 'bg-black/50', else: 'bg-white/50')}
grid gap-4 p-4
"
>
</div>
```

## 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:
@@ -153,10 +217,12 @@ This plugin uses Prettier APIs that can only be used by one plugin at a time, ma
- `prettier-plugin-css-order`
- `prettier-plugin-import-sort`
- `prettier-plugin-jsdoc`
- `prettier-plugin-multiline-arrays`
- `prettier-plugin-organize-attributes`
- `prettier-plugin-organize-imports`
- `prettier-plugin-style-order`
- `prettier-plugin-svelte`
- `prettier-plugin-sort-imports`

One limitation with this approach is that `prettier-plugin-tailwindcss` *must* be loaded last.

29 changes: 6 additions & 23 deletions build.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import * as fs from 'node:fs'
import * as path from 'node:path'
import { fileURLToPath } from 'node:url'
import esbuild from 'esbuild'

/**
@@ -44,12 +44,12 @@ function patchCjsInterop() {

// Prepend `createRequire`
let code = [
`import {createRequire} from 'module'`,
`import {createRequire as __global__createRequire__} from 'module'`,
`import {dirname as __global__dirname__} from 'path'`,
`import {fileURLToPath} from 'url'`,

// CJS interop fixes
`const require=createRequire(import.meta.url)`,
`const require=__global__createRequire__(import.meta.url)`,
`const __filename=fileURLToPath(import.meta.url)`,
`const __dirname=__global__dirname__(__filename)`,
]
@@ -62,23 +62,6 @@ function patchCjsInterop() {
}
}

/**
* @returns {import('esbuild').Plugin}
*/
function copyTypes() {
return {
name: 'copy-types',
setup(build) {
build.onEnd(() =>
fs.promises.copyFile(
path.resolve(__dirname, './src/index.d.ts'),
path.resolve(__dirname, './dist/index.d.ts'),
),
)
},
}
}

const __dirname = path.dirname(fileURLToPath(import.meta.url))

let context = await esbuild.context({
@@ -90,7 +73,7 @@ let context = await esbuild.context({
entryPoints: [path.resolve(__dirname, './src/index.js')],
outfile: path.resolve(__dirname, './dist/index.mjs'),
format: 'esm',
plugins: [patchRecast(), patchCjsInterop(), copyTypes()],
plugins: [patchRecast(), patchCjsInterop()],
})

await context.rebuild()
12,375 changes: 4,713 additions & 7,662 deletions package-lock.json

Large diffs are not rendered by default.

66 changes: 43 additions & 23 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"type": "module",
"name": "prettier-plugin-tailwindcss",
"version": "0.5.6",
"version": "0.6.11",
"description": "A Prettier plugin for sorting Tailwind CSS classes.",
"license": "MIT",
"main": "dist/index.mjs",
@@ -17,63 +18,79 @@
"url": "https://github.com/tailwindlabs/prettier-plugin-tailwindcss/issues"
},
"scripts": {
"_pre": "rimraf dist && cpy node_modules/tailwindcss/lib/css dist/css",
"_pre": "rimraf dist && cpy 'node_modules/tailwindcss/lib/css/*' dist/css",
"_esbuild": "node build.mjs",
"prebuild": "npm run _pre",
"build": "npm run _esbuild -- --minify",
"postbuild": "tsup src/index.ts",
"predev": "npm run _pre",
"dev": "npm run _esbuild -- --watch",
"pretest": "node scripts/install-fixture-deps.js",
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
"test": "vitest",
"prepublishOnly": "npm run build && node scripts/copy-licenses.js",
"format": "prettier \"src/**/*.js\" \"scripts/**/*.js\" \"tests/test.js\" --write --print-width 100 --single-quote --no-semi --plugin-search-dir ./tests",
"release-channel": "node ./scripts/release-channel.js",
"release-notes": "node ./scripts/release-notes.js"
},
"devDependencies": {
"@babel/types": "^7.24.7",
"@ianvs/prettier-plugin-sort-imports": "^4.1.0",
"@marko/translator-default": "^5.30.1",
"@microsoft/api-extractor": "^7.47.0",
"@prettier/plugin-pug": "^3.0",
"@shopify/prettier-plugin-liquid": "^1.2.2",
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
"@shopify/prettier-plugin-liquid": "^1.4.0",
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/node": "^22.10.9",
"@zackad/prettier-plugin-twig": "^0.14.1",
"ast-types": "^0.14.2",
"clear-module": "^4.1.2",
"cpy-cli": "^3.1.1",
"esbuild": "^0.18.0",
"cpy-cli": "^5.0.0",
"enhanced-resolve": "^5.17.1",
"esbuild": "^0.19.8",
"escalade": "^3.1.1",
"import-sort-style-module": "^6.0.0",
"jest": "^29.6.2",
"jiti": "^2.4.2",
"jsesc": "^2.5.2",
"license-checker": "^25.0.1",
"line-column": "^1.0.2",
"object-hash": "^2.2.0",
"prettier": "3.0",
"prettier-plugin-astro": "^0.11.0",
"marko": "^5.31.18",
"postcss": "^8.4.35",
"postcss-import": "^16.0.1",
"prettier": "^3.2",
"prettier-plugin-astro": "^0.12.2",
"prettier-plugin-css-order": "^2.0.0",
"prettier-plugin-import-sort": "^0.0.7",
"prettier-plugin-jsdoc": "^1.0.1",
"prettier-plugin-marko": "^3.1.1",
"prettier-plugin-multiline-arrays": "^3.0.6",
"prettier-plugin-organize-attributes": "^1.0.0",
"prettier-plugin-organize-imports": "^3.2.3",
"prettier-plugin-organize-imports": "^3.2.4",
"prettier-plugin-sort-imports": "^1.8.1",
"prettier-plugin-style-order": "^0.2.2",
"prettier-plugin-svelte": "^3.0.3",
"prettier-plugin-svelte": "^3.1.2",
"recast": "0.20.5",
"resolve-from": "^5.0.0",
"rimraf": "^3.0.2",
"svelte": "^4.1.2",
"tailwindcss": "^3.3.3"
"rimraf": "^5.0.5",
"svelte": "^4.2.8",
"tailwindcss": "^3.4.2",
"tsup": "^8.1.0",
"vitest": "^1.6.0"
},
"peerDependencies": {
"@ianvs/prettier-plugin-sort-imports": "*",
"@prettier/plugin-pug": "*",
"@shopify/prettier-plugin-liquid": "*",
"@shufo/prettier-plugin-blade": "*",
"@trivago/prettier-plugin-sort-imports": "*",
"@zackad/prettier-plugin-twig": "*",
"prettier": "^3.0",
"prettier-plugin-astro": "*",
"prettier-plugin-css-order": "*",
"prettier-plugin-import-sort": "*",
"prettier-plugin-jsdoc": "*",
"prettier-plugin-marko": "*",
"prettier-plugin-multiline-arrays": "*",
"prettier-plugin-organize-attributes": "*",
"prettier-plugin-organize-imports": "*",
"prettier-plugin-sort-imports": "*",
"prettier-plugin-style-order": "*",
"prettier-plugin-svelte": "*"
},
@@ -87,10 +104,10 @@
"@shopify/prettier-plugin-liquid": {
"optional": true
},
"@shufo/prettier-plugin-blade": {
"@trivago/prettier-plugin-sort-imports": {
"optional": true
},
"@trivago/prettier-plugin-sort-imports": {
"@zackad/prettier-plugin-twig": {
"optional": true
},
"prettier-plugin-astro": {
@@ -108,19 +125,22 @@
"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-style-order": {
"prettier-plugin-sort-imports": {
"optional": true
},
"prettier-plugin-svelte": {
"prettier-plugin-style-order": {
"optional": true
},
"prettier-plugin-twig-melody": {
"prettier-plugin-svelte": {
"optional": true
}
},
2 changes: 1 addition & 1 deletion prettier.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** @type {import('prettier').Config} */
module.exports = {
export default {
semi: false,
singleQuote: true,
trailingComma: 'all',
57 changes: 37 additions & 20 deletions scripts/copy-licenses.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,49 @@
const checker = require('license-checker')
const { devDependencies } = require('../package.json')
const fs = require('fs')
const path = require('path')
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 = [
'cpy-cli',
'esbuild',
'jest',
'vitest',
'license-checker',
'prettier',
'rimraf',
'svelte',
'tsup',
'@microsoft/api-extractor',
]

checker.init({ start: path.resolve(__dirname, '..') }, (_err, packages) => {
for (let key in packages) {
let name = key.split(/(?<=.)@/)[0]
if (
name in devDependencies &&
!exclude.includes(name) &&
packages[key].licenseFile
) {
let dir = path.resolve(__dirname, '../dist/licenses', name)
fs.mkdirSync(dir, { recursive: true })
fs.copyFileSync(
packages[key].licenseFile,
path.resolve(dir, path.basename(packages[key].licenseFile)),
)
/** @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)),
)
}
38 changes: 24 additions & 14 deletions scripts/install-fixture-deps.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
const fs = require('fs')
const path = require('path')
const { execSync } = require('child_process')

let fixturesDir = path.resolve(__dirname, '../tests/fixtures')
let fixtures = fs
.readdirSync(fixturesDir)
.map((name) => path.join(fixturesDir, name))

for (let fixture of fixtures) {
if (fs.existsSync(path.join(fixture, 'package.json'))) {
execSync('npm install', { cwd: fixture })
}
}
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'],
{
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) })
}),
)
14 changes: 10 additions & 4 deletions scripts/release-channel.js
Original file line number Diff line number Diff line change
@@ -6,11 +6,17 @@
// 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'

let version =
process.argv[2] ||
process.env.npm_package_version ||
require('../package.json').version
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) {
18 changes: 11 additions & 7 deletions scripts/release-notes.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
// 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.

let path = require('path')
let fs = require('fs')
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { fileURLToPath } from 'node:url'

let version =
process.argv[2] ||
process.env.npm_package_version ||
require('../package.json').version
const __dirname = path.dirname(fileURLToPath(import.meta.url))

let changelog = fs.readFileSync(
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',
)
180 changes: 0 additions & 180 deletions src/config.js

This file was deleted.

366 changes: 366 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,366 @@
// @ts-check
import * as fs from 'fs/promises'
import * as path from 'path'
import { pathToFileURL } from 'url'
import clearModule from 'clear-module'
import escalade from 'escalade/sync'
import { createJiti, type Jiti } from 'jiti'
import postcss from 'postcss'
// @ts-ignore
import postcssImport from 'postcss-import'
import prettier from 'prettier'
import type { ParserOptions } from 'prettier'
// @ts-ignore
import { generateRules as generateRulesFallback } from 'tailwindcss/lib/lib/generateRules'
// @ts-ignore
import { createContext as createContextFallback } from 'tailwindcss/lib/lib/setupContextUtils'
import loadConfigFallback from 'tailwindcss/loadConfig'
import resolveConfigFallback from 'tailwindcss/resolveConfig'
import type { RequiredConfig } from 'tailwindcss/types/config.js'
import { expiringMap } from './expiring-map.js'
import { resolveCssFrom, resolveJsFrom } from './resolve'
import type { ContextContainer } from './types'

let sourceToPathMap = new Map<string, string | null>()
let sourceToEntryMap = new Map<string, string | null>()
let pathToContextMap = expiringMap<string | null, ContextContainer>(10_000)
let prettierConfigCache = expiringMap<string, string | null>(10_000)

export async function getTailwindConfig(
options: ParserOptions,
): Promise<ContextContainer> {
let key = [
options.filepath,
options.tailwindStylesheet ?? '',
options.tailwindEntryPoint ?? '',
options.tailwindConfig ?? '',
].join(':')
let baseDir = await getBaseDir(options)

// Map the source file to it's associated Tailwind config file
let configPath = sourceToPathMap.get(key)
if (configPath === undefined) {
configPath = getConfigPath(options, baseDir)
sourceToPathMap.set(key, configPath)
}

let entryPoint = sourceToEntryMap.get(key)
if (entryPoint === undefined) {
entryPoint = getEntryPoint(options, baseDir)
sourceToEntryMap.set(key, entryPoint)
}

// Now see if we've loaded the Tailwind config file before (and it's still valid)
let contextKey = `${configPath}:${entryPoint}`
let existing = pathToContextMap.get(contextKey)
if (existing) {
return existing
}

// By this point we know we need to load the Tailwind config file
let result = await loadTailwindConfig(baseDir, configPath, entryPoint)

pathToContextMap.set(contextKey, result)

return result
}

async function getPrettierConfigPath(
options: ParserOptions,
): Promise<string | null> {
// Locating the config file can be mildly expensive so we cache it temporarily
let existingPath = prettierConfigCache.get(options.filepath)
if (existingPath !== undefined) {
return existingPath
}

let path = await prettier.resolveConfigFile(options.filepath)
prettierConfigCache.set(options.filepath, path)

return path
}

async function getBaseDir(options: ParserOptions): Promise<string> {
let prettierConfigPath = await getPrettierConfigPath(options)

if (options.tailwindConfig) {
return prettierConfigPath ? path.dirname(prettierConfigPath) : process.cwd()
}

if (options.tailwindEntryPoint) {
return prettierConfigPath ? path.dirname(prettierConfigPath) : process.cwd()
}

return prettierConfigPath
? path.dirname(prettierConfigPath)
: options.filepath
? path.dirname(options.filepath)
: process.cwd()
}

async function loadTailwindConfig(
baseDir: string,
tailwindConfigPath: string | null,
entryPoint: string | null,
): Promise<ContextContainer> {
let createContext = createContextFallback
let generateRules = generateRulesFallback
let resolveConfig = resolveConfigFallback
let loadConfig = loadConfigFallback
let tailwindConfig: RequiredConfig = { content: [] }

try {
let pkgFile = resolveJsFrom(baseDir, 'tailwindcss/package.json')
let pkgDir = path.dirname(pkgFile)

try {
let v4 = await loadV4(baseDir, pkgDir, entryPoint)
if (v4) {
return v4
}
} catch {}

resolveConfig = require(path.join(pkgDir, 'resolveConfig'))
createContext = require(
path.join(pkgDir, 'lib/lib/setupContextUtils'),
).createContext
generateRules = require(
path.join(pkgDir, 'lib/lib/generateRules'),
).generateRules

// Prior to `tailwindcss@3.3.0` this won't exist so we load it last
loadConfig = require(path.join(pkgDir, 'loadConfig'))
} catch {}

if (tailwindConfigPath) {
clearModule(tailwindConfigPath)
const loadedConfig = loadConfig(tailwindConfigPath)
tailwindConfig = loadedConfig.default ?? loadedConfig
}

// suppress "empty content" warning
tailwindConfig.content = ['no-op']

// Create the context
let context = createContext(resolveConfig(tailwindConfig))

return {
context,
generateRules,
}
}

/**
* 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<T>({
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),
}
}
}

async function loadV4(
baseDir: string,
pkgDir: string,
entryPoint: string | null,
) {
// Import Tailwind — if this is v4 it'll have APIs we can use directly
let pkgPath = resolveJsFrom(baseDir, 'tailwindcss')

let tw = await import(pathToFileURL(pkgPath).toString())

// This is not Tailwind v4
if (!tw.__unstable__loadDesignSystem) {
return null
}

// If the user doesn't define an entrypoint then we use the default theme
entryPoint = entryPoint ?? `${pkgDir}/theme.css`

// 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 importBasePath = path.dirname(entryPoint)

// Resolve imports in the entrypoint to a flat CSS tree
let css = await fs.readFile(entryPoint, 'utf-8')

// Determine if the v4 API supports resolving `@import`
let supportsImports = false
try {
await tw.__unstable__loadDesignSystem('@import "./empty";', {
loadStylesheet: () => {
supportsImports = true
return {
base: importBasePath,
content: '',
}
},
})
} catch {}

if (!supportsImports) {
let resolveImports = postcss([postcssImport()])
let result = await resolveImports.process(css, { from: entryPoint })
css = result.css
}

// Load the design system and set up a compatible context object that is
// usable by the rest of the plugin
let design = await tw.__unstable__loadDesignSystem(css, {
base: importBasePath,

// v4.0.0-alpha.25+
loadModule: createLoader({
legacy: false,
jiti,
filepath: entryPoint,
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) => {
let resolved = resolveCssFrom(base, id)

return {
base: path.dirname(resolved),
content: await fs.readFile(resolved, 'utf-8'),
}
},

// v4.0.0-alpha.24 and below
loadPlugin: createLoader({
legacy: true,
jiti,
filepath: entryPoint,
onError(id, err) {
console.error(`Unable to load plugin: ${id}`, err)

return () => {}
},
}),

loadConfig: createLoader({
legacy: true,
jiti,
filepath: entryPoint,
onError(id, err) {
console.error(`Unable to load config: ${id}`, err)

return {}
},
}),
})

return {
context: {
getClassOrder: (classList: string[]) => design.getClassOrder(classList),
},

// Stubs that are not needed for v4
generateRules: () => [],
}
}

function getConfigPath(options: ParserOptions, baseDir: string): string | null {
if (options.tailwindConfig) {
if (options.tailwindConfig.endsWith('.css')) {
return null
}

return path.resolve(baseDir, options.tailwindConfig)
}

let configPath: string | void = undefined
try {
configPath = escalade(baseDir, (_dir, 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'
}
})
} catch {}

if (configPath) {
return configPath
}

return null
}

function getEntryPoint(options: ParserOptions, baseDir: string): string | null {
if (options.tailwindStylesheet) {
return path.resolve(baseDir, options.tailwindStylesheet)
}

if (options.tailwindEntryPoint) {
console.warn(
'Use the `tailwindStylesheet` option for v4 projects instead of `tailwindEntryPoint`.',
)

return path.resolve(baseDir, options.tailwindEntryPoint)
}

if (options.tailwindConfig && options.tailwindConfig.endsWith('.css')) {
console.warn(
'Use the `tailwindStylesheet` option for v4 projects instead of `tailwindConfig`.',
)

return path.resolve(baseDir, options.tailwindConfig)
}

return null
}
38 changes: 0 additions & 38 deletions src/expiring-map.js

This file was deleted.

31 changes: 31 additions & 0 deletions src/expiring-map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
interface ExpiringMap<K, V> {
get(key: K): V | undefined
set(key: K, value: V): void
}

export function expiringMap<K, V>(duration: number): ExpiringMap<K, V> {
let map = new Map<K, { value: V; expiration: Date }>()

return {
get(key: K) {
let result = map.get(key)
if (!result) return undefined
if (result.expiration <= new Date()) {
map.delete(key)
return undefined
}

return result.value
},

set(key: K, value: V) {
let expiration = new Date()
expiration.setMilliseconds(expiration.getMilliseconds() + duration)

map.set(key, {
value,
expiration,
})
},
}
}
27 changes: 0 additions & 27 deletions src/index.d.ts

This file was deleted.

680 changes: 464 additions & 216 deletions src/index.js → src/index.ts

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions src/internal.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export interface InternalOptions {
printer: Printer<any>
}

export interface InternalPlugin {
name?: string
}

declare module 'prettier' {
interface RequiredOptions extends InternalOptions {}
interface ParserOptions extends InternalOptions {}
interface Plugin<T = any> extends InternalPlugin {}
}
71 changes: 46 additions & 25 deletions src/options.js → src/options.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,70 @@
/** @type {Record<string, import('prettier').SupportOption>} */
export const options = {
import type { RequiredOptions, SupportOption } from 'prettier'
import type { Customizations } from './types'
import './index'

export const options: Record<string, SupportOption> = {
tailwindConfig: {
since: '0.0.0',
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: {
since: '0.3.0',
type: 'string',
array: true,
default: [{ value: [] }],
category: 'Tailwind CSS',
description:
'List of attributes/props that contain sortable Tailwind classes',
},

tailwindFunctions: {
since: '0.3.0',
type: 'string',
array: true,
default: [{ value: [] }],
category: 'Tailwind CSS',
description:
'List of functions and tagged templates that contain sortable Tailwind classes',
},
}

/** @typedef {import('prettier').RequiredOptions} RequiredOptions */
/** @typedef {import('./types').Customizations} Customizations */

/**
* @param {RequiredOptions} options
* @param {string} parser
* @param {Customizations} defaults
* @returns {Customizations}
*/
export function getCustomizations(options, parser, defaults) {
/** @type {Set<string>} */
let staticAttrs = new Set(defaults.staticAttrs)
tailwindPreserveWhitespace: {
type: 'boolean',
default: false,
category: 'Tailwind CSS',
description: 'Preserve whitespace around Tailwind classes when sorting',
},

/** @type {Set<string>} */
let dynamicAttrs = new Set(defaults.dynamicAttrs)
tailwindPreserveDuplicates: {
type: 'boolean',
default: false,
category: 'Tailwind CSS',
description: 'Preserve duplicate classes inside a class list when sorting',
},
}

/** @type {Set<string>} */
let functions = new Set(defaults.functions)
export function getCustomizations(
options: RequiredOptions,
parser: string,
defaults: Customizations,
): Customizations {
let staticAttrs = new Set<string>(defaults.staticAttrs)
let dynamicAttrs = new Set<string>(defaults.dynamicAttrs)
let functions = new Set<string>(defaults.functions)

// Create a list of "static" attributes
for (let attr of options.tailwindAttributes ?? []) {
@@ -67,10 +88,10 @@ export function getCustomizations(options, parser, defaults) {
// Generate a list of dynamic attributes
for (let attr of staticAttrs) {
if (parser === 'vue') {
dynamicAttrs.add(`:${attr.name}`)
dynamicAttrs.add(`v-bind:${attr.name}`)
dynamicAttrs.add(`:${attr}`)
dynamicAttrs.add(`v-bind:${attr}`)
} else if (parser === 'angular') {
dynamicAttrs.add(`[${attr.name}]`)
dynamicAttrs.add(`[${attr}]`)
}
}

85 changes: 39 additions & 46 deletions src/plugins.js → src/plugins.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createRequire as req } from 'module'
import type { Parser, ParserOptions, Plugin, Printer } from 'prettier'
import './types'
import * as prettierParserAcorn from 'prettier/plugins/acorn'
import * as prettierParserBabel from 'prettier/plugins/babel'
import * as prettierParserFlow from 'prettier/plugins/flow'
@@ -7,28 +8,22 @@ import * as prettierParserHTML from 'prettier/plugins/html'
import * as prettierParserMeriyah from 'prettier/plugins/meriyah'
import * as prettierParserPostCSS from 'prettier/plugins/postcss'
import * as prettierParserTypescript from 'prettier/plugins/typescript'
import { loadIfExists, maybeResolve } from './resolve'

/**
* @typedef {object} PluginDetails
* @property {Record<string, import('prettier').Parser<any>>} parsers
* @property {Record<string, import('prettier').Printer<any>>} printers
*/

/**
* @returns {Promise<import('prettier').Plugin<any>>}
*/
async function loadIfExistsESM(name) {
try {
if (createRequire(import.meta.url).resolve(name)) {
let mod = await import(name)
return mod.default ?? mod
}
} catch (e) {
return {
parsers: {},
printers: {},
}
interface PluginDetails {
parsers: Record<string, Parser<any>>
printers: Record<string, Printer<any>>
}

async function loadIfExistsESM(name: string): Promise<Plugin<any>> {
let mod = await loadIfExists<Plugin<any>>(name)

mod ??= {
parsers: {},
printers: {},
}

return mod
}

export async function loadPlugins() {
@@ -46,18 +41,22 @@ export async function loadPlugins() {
...thirdparty.printers,
}

function maybeResolve(name) {
try {
return req.resolve(name)
} catch (err) {
return null
}
}

function findEnabledPlugin(options, name, mod) {
function findEnabledPlugin(
options: ParserOptions<any>,
name: string,
mod: any,
) {
let path = maybeResolve(name)

for (let plugin of options.plugins) {
if (typeof plugin === 'string') {
if (plugin === name || plugin === path) {
return mod
}

continue
}

// options.plugins.*.name == name
if (plugin.name === name) {
return mod
@@ -82,7 +81,7 @@ export async function loadPlugins() {
parsers,
printers,

originalParser(format, options) {
originalParser(format: string, options: ParserOptions) {
if (!options.plugins) {
return parsers[format]
}
@@ -102,11 +101,7 @@ export async function loadPlugins() {
}
}

/**
*
* @returns {Promise<PluginDetails}>}
*/
async function loadBuiltinPlugins() {
async function loadBuiltinPlugins(): Promise<PluginDetails> {
return {
parsers: {
html: prettierParserHTML.parsers.html,
@@ -132,26 +127,22 @@ async function loadBuiltinPlugins() {
}
}

/**
* @returns {Promise<PluginDetails}>}
*/
async function loadThirdPartyPlugins() {
async function loadThirdPartyPlugins(): Promise<PluginDetails> {
// Commented out plugins do not currently work with Prettier v3.0
let [astro, liquid, pug, svelte] = await Promise.all([
let [astro, liquid, marko, twig, pug, svelte] = await Promise.all([
loadIfExistsESM('prettier-plugin-astro'),
loadIfExistsESM('@shopify/prettier-plugin-liquid'),
// loadIfExistsESM('prettier-plugin-marko'),
// loadIfExistsESM('prettier-plugin-twig-melody'),
loadIfExistsESM('prettier-plugin-marko'),
loadIfExistsESM('@zackad/prettier-plugin-twig'),
loadIfExistsESM('@prettier/plugin-pug'),
loadIfExistsESM('prettier-plugin-svelte'),
])

return {
parsers: {
...astro.parsers,
...liquid.parsers,
// ...marko.parsers,
// ...melody.parsers,
...marko.parsers,
...twig.parsers,
...pug.parsers,
...svelte.parsers,
},
@@ -170,8 +161,10 @@ async function loadCompatiblePlugins() {
'prettier-plugin-css-order',
'prettier-plugin-import-sort',
'prettier-plugin-jsdoc',
'prettier-plugin-multiline-arrays',
'prettier-plugin-organize-attributes',
'prettier-plugin-style-order',
'prettier-plugin-sort-imports',
]

// Load all the available compatible plugins up front
76 changes: 76 additions & 0 deletions src/resolve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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<string, string | null>(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 (err) {
resolveCache.set(name, null)
return null
}
}

return modpath
}

export async function loadIfExists<T>(name: string): Promise<T | null> {
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 (err) {
return cjsResolver.resolveSync({}, base, id) || id
}
}

export function resolveCssFrom(base: string, id: string) {
return cssResolver.resolveSync({}, base, id) || id
}
99 changes: 0 additions & 99 deletions src/sorting.js

This file was deleted.

193 changes: 193 additions & 0 deletions src/sorting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import type { LegacyTailwindContext, TransformerEnv } from './types'
import './index'

export function bigSign(bigIntValue: bigint) {
return Number(bigIntValue > 0n) - Number(bigIntValue < 0n)
}

function prefixCandidate(
context: LegacyTailwindContext,
selector: string,
): string {
let prefix = context.tailwindConfig.prefix
return typeof prefix === 'function' ? prefix(selector) : prefix + selector
}

// Polyfill for older Tailwind CSS versions
function getClassOrderPolyfill(
classes: string[],
{ env }: { env: TransformerEnv },
): [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(env.context, 'group'),
prefixCandidate(env.context, 'peer'),
])

let classNamesWithOrder: [string, bigint | null][] = []

for (let className of classes) {
let order: bigint | null =
env
.generateRules(new Set([className]), env.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 = env.context.layerOrder.components
}

classNamesWithOrder.push([className, order])
}

return classNamesWithOrder
}

function reorderClasses(classList: string[], { env }: { env: TransformerEnv }) {
let orderedClasses = env.context.getClassOrder
? env.context.getClassOrder(classList)
: getClassOrderPolyfill(classList, { env })

return 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)
})
}

export function sortClasses(
classStr: string,
{
env,
ignoreFirst = false,
ignoreLast = false,
removeDuplicates = true,
collapseWhitespace = { start: true, end: true },
}: {
env: TransformerEnv
ignoreFirst?: boolean
ignoreLast?: boolean
removeDuplicates?: boolean
collapseWhitespace?: false | { start: boolean; end: boolean }
},
): 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
}

// This class list is purely whitespace
// Collapse it to a single space if the option is enabled
if (/^[\t\r\f\n ]+$/.test(classStr) && collapseWhitespace) {
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(classes, {
env,
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: string[],
{
env,
removeDuplicates,
}: {
env: TransformerEnv
removeDuplicates: boolean
},
) {
// Re-order classes based on the Tailwind CSS configuration
let orderedClasses = reorderClasses(classList, { env })

// Remove duplicate Tailwind classes
if (env.options.tailwindPreserveDuplicates) {
removeDuplicates = false
}

let removedIndices = new Set<number>()

if (removeDuplicates) {
let seenClasses = new Set<string>()

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,
}
}
42 changes: 0 additions & 42 deletions src/types.d.ts

This file was deleted.

54 changes: 54 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { ParserOptions } from 'prettier'

export interface TransformerMetadata {
// Default customizations for a given transformer
staticAttrs?: string[]
dynamicAttrs?: string[]
functions?: string[]
}

export interface Customizations {
functions: Set<string>
staticAttrs: Set<string>
dynamicAttrs: Set<string>
}

export interface TransformerContext {
env: TransformerEnv
changes: StringChange[]
}

export interface LegacyTailwindContext {
tailwindConfig: {
prefix: string | ((selector: string) => string)
}

getClassOrder?: (classList: string[]) => [string, bigint | null][]

layerOrder: {
components: bigint
}
}

export interface TransformerEnv {
context: LegacyTailwindContext
customizations: Customizations
generateRules: (
classes: Iterable<string>,
context: LegacyTailwindContext,
) => [bigint][]
parsers: any
options: ParserOptions
}

export interface ContextContainer {
context: any
generateRules: () => any
}

export interface StringChange {
start: number
end: number
before: string
after: string
}
57 changes: 57 additions & 0 deletions src/utils.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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 }, (_, i) =>
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)
})
})
40 changes: 0 additions & 40 deletions src/utils.js

This file was deleted.

30 changes: 30 additions & 0 deletions src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { bench, 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',
)
})
})
139 changes: 139 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
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 (e) {
return null
}
}

interface PathEntry<T, Meta> {
node: T
parent: T | null
key: string | null
index: number | null
meta: Meta
}

type Path<T, Meta> = PathEntry<T, Meta>[]

type Visitor<T, Meta extends Record<string, unknown>> = (
node: T,
path: Path<T, Meta>,
meta: Partial<Meta>,
) => void | false

type Visitors<T, Meta extends Record<string, unknown>> = Record<
string,
Visitor<T, Meta>
>

// https://lihautan.com/manipulating-ast-with-javascript/
export function visit<T extends {}, Meta extends Record<string, unknown>>(
ast: T,
callbackMap: Visitors<T, Meta> | Visitor<T, Meta>,
) {
function _visit(node: any, path: Path<T, Meta>, 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 (child[j] !== null) {
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 (typeof child?.type === 'string') {
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<T, Meta> = [
{
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
}
143 changes: 0 additions & 143 deletions tests/fixtures.test.js

This file was deleted.

126 changes: 126 additions & 0 deletions tests/fixtures.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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: '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: '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',
},
]

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('<div class="sm:bg-tomato bg-red-500"></div>', {
tailwindConfig: path.resolve(
__dirname,
'fixtures/basic/tailwind.config.js',
),
}),
).toEqual('<div class="bg-red-500 sm:bg-tomato"></div>')
})

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
let expected = await fs.readFile(outputPath, 'utf-8')

expect(formatted.trim()).toEqual(expected.trim())
})
}
})
1 change: 1 addition & 0 deletions tests/fixtures/basic/output.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="bg-red-500 sm:bg-tomato"></div>
1 change: 1 addition & 0 deletions tests/fixtures/cjs/output.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="bg-red-500 sm:bg-hotpink"></div>
19 changes: 19 additions & 0 deletions tests/fixtures/custom-jsx/output.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
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 A = (props) => <div className={props.sortMe} />;
const B = () => <A sortMe="p-2 sm:p-1" dontSort="sm:p-1 p-2" />;
1 change: 1 addition & 0 deletions tests/fixtures/custom-vue/index.vue
Original file line number Diff line number Diff line change
@@ -8,4 +8,5 @@
<template>
<div class="sm:p-1 p-2" sortMe="sm:p-1 p-2" dontSortMe="sm:p-1 p-2"></div>
<div :class="{'sm:p-1 p-2': true}"></div>
<div :sortMe="{'sm:p-1 p-2': true}"></div>
</template>
12 changes: 12 additions & 0 deletions tests/fixtures/custom-vue/output.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script setup>
let a = sortMeFn("p-2 sm:p-1");
let b = sortMeFn({ "p-2 sm:p-1": true });
let c = dontSortFn("sm:p-1 p-2");
let d = sortMeTemplate`p-2 sm:p-1`;
let e = dontSortMeTemplate`sm:p-1 p-2`;
</script>
<template>
<div class="p-2 sm:p-1" sortMe="p-2 sm:p-1" dontSortMe="sm:p-1 p-2"></div>
<div :class="{ 'p-2 sm:p-1': true }"></div>
<div :sortMe="{ 'p-2 sm:p-1': true }"></div>
</template>
1 change: 1 addition & 0 deletions tests/fixtures/esm-explicit/output.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="bg-red-500 sm:bg-hotpink"></div>
1 change: 1 addition & 0 deletions tests/fixtures/esm/output.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="bg-red-500 sm:bg-hotpink"></div>
1 change: 1 addition & 0 deletions tests/fixtures/no-prettier-config/output.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="bg-red-500 sm:bg-tomato"></div>
3 changes: 3 additions & 0 deletions tests/fixtures/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"type": "commonjs"
}
1 change: 1 addition & 0 deletions tests/fixtures/plugins/output.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="uppercase foo sm:bar"></div>
1 change: 1 addition & 0 deletions tests/fixtures/ts-explicit/output.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="bg-red-500 sm:bg-hotpink"></div>
1 change: 1 addition & 0 deletions tests/fixtures/ts/output.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="bg-red-500 sm:bg-hotpink"></div>
1 change: 1 addition & 0 deletions tests/fixtures/v3-2/output.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="bg-red-500 sm:bg-tomato"></div>
5 changes: 5 additions & 0 deletions tests/fixtures/v4/basic/app.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@import 'tailwindcss';

@theme {
--color-tomato: tomato;
}
1 change: 1 addition & 0 deletions tests/fixtures/v4/basic/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="sm:bg-tomato bg-red-500"></div>
1 change: 1 addition & 0 deletions tests/fixtures/v4/basic/output.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="bg-red-500 sm:bg-tomato"></div>
18 changes: 18 additions & 0 deletions tests/fixtures/v4/basic/package-lock.json
8 changes: 8 additions & 0 deletions tests/fixtures/v4/basic/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"dependencies": {
"tailwindcss": "^4.0.0"
},
"prettier": {
"tailwindStylesheet": "./app.css"
}
}
21 changes: 21 additions & 0 deletions tests/fixtures/v4/css-loading-js/app.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
@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;
}
9 changes: 9 additions & 0 deletions tests/fixtures/v4/css-loading-js/cjs/my-config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module.exports = {
theme: {
extend: {
colors: {
'cjs-from-config': 'black',
},
},
},
}
24 changes: 24 additions & 0 deletions tests/fixtures/v4/css-loading-js/cjs/my-plugin.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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',
},
},
},
},
)
9 changes: 9 additions & 0 deletions tests/fixtures/v4/css-loading-js/esm/my-config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export default {
theme: {
extend: {
colors: {
'esm-from-config': 'black',
},
},
},
}
24 changes: 24 additions & 0 deletions tests/fixtures/v4/css-loading-js/esm/my-plugin.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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',
},
},
},
},
)
3 changes: 3 additions & 0 deletions tests/fixtures/v4/css-loading-js/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div
class="sm:bg-tomato sm:utility-cjs-from-plugin sm:utility-cjs-from-plugin-2 sm:utility-esm-from-plugin sm:utility-esm-from-plugin-2 sm:utility-ts-from-plugin sm:utility-ts-from-plugin-2 sm:bg-cjs-from-config sm:bg-cjs-from-plugin sm:bg-esm-from-config sm:bg-esm-from-plugin sm:bg-ts-from-config sm:bg-ts-from-plugin bg-red-500 utility-cjs-from-plugin utility-esm-from-plugin utility-ts-from-plugin"
></div>
3 changes: 3 additions & 0 deletions tests/fixtures/v4/css-loading-js/output.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div
class="bg-red-500 utility-cjs-from-plugin utility-esm-from-plugin utility-ts-from-plugin sm:utility-cjs-from-plugin-2 sm:utility-esm-from-plugin-2 sm:utility-ts-from-plugin-2 sm:bg-cjs-from-config sm:bg-cjs-from-plugin sm:bg-esm-from-config sm:bg-esm-from-plugin sm:bg-tomato sm:bg-ts-from-config sm:bg-ts-from-plugin sm:utility-cjs-from-plugin sm:utility-esm-from-plugin sm:utility-ts-from-plugin"
></div>
18 changes: 18 additions & 0 deletions tests/fixtures/v4/css-loading-js/package-lock.json
8 changes: 8 additions & 0 deletions tests/fixtures/v4/css-loading-js/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"dependencies": {
"tailwindcss": "^4.0.0"
},
"prettier": {
"tailwindStylesheet": "./app.css"
}
}
11 changes: 11 additions & 0 deletions tests/fixtures/v4/css-loading-js/ts/my-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Config } from 'tailwindcss'

export default {
theme: {
extend: {
colors: {
'ts-from-config': 'black',
},
},
},
} satisfies Config
24 changes: 24 additions & 0 deletions tests/fixtures/v4/css-loading-js/ts/my-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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',
},
},
},
},
)
184 changes: 168 additions & 16 deletions tests/format.test.js → tests/format.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,54 @@
const { t, yes, no, format } = require('./utils')
import { describe, test } from 'vitest'
import type { TestEntry } from './utils.js'
import { format, no, t, yes } from './utils.js'

let html = [
let html: TestEntry[] = [
t`<div class="${yes}"></div>`,
t`<!-- <div class="${no}"></div> -->`,
t`<div class="${no} {{ 'p-0 sm:p-0 m-0' }}"></div>`,
t`<div not-class="${no}"></div>`,
['<div class=" sm:p-0 p-0 "></div>', '<div class="p-0 sm:p-0"></div>'],
t`<div class></div>`,
t`<div class=""></div>`,
// Ensure duplicate classes are removed
['<div class="sm:p-0 p-0 p-0"></div>', '<div class="p-0 sm:p-0"></div>'],
// Duplicates are not removed for unknown classes
[
'<div class="idonotexist sm:p-0 p-0 idonotexist p-0 idonotexist"></div>',
'<div class="idonotexist idonotexist idonotexist p-0 sm:p-0"></div>',
],
// Ensure duplicate can be kept
[
'<div class="sm:p-0 p-0 p-0"></div>',
'<div class="p-0 p-0 sm:p-0"></div>',
{
tailwindPreserveDuplicates: true,
},
],

// … is moved to the end of the list
['<div class="... sm:p-0 p-0"></div>', '<div class="p-0 sm:p-0 ..."></div>'],
['<div class="… sm:p-0 p-0"></div>', '<div class="p-0 sm:p-0 …"></div>'],
['<div class="sm:p-0 ... p-0"></div>', '<div class="p-0 sm:p-0 ..."></div>'],
['<div class="sm:p-0 … p-0"></div>', '<div class="p-0 sm:p-0 …"></div>'],
['<div class="sm:p-0 p-0 ..."></div>', '<div class="p-0 sm:p-0 ..."></div>'],
['<div class="sm:p-0 p-0 …"></div>', '<div class="p-0 sm:p-0 …"></div>'],
]

let css = [
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;'],
[
'@apply sm:p-0\n p-0;',
'@apply p-0\n sm:p-0;',
{ tailwindPreserveWhitespace: true },
],
]

let javascript = [
let javascript: TestEntry[] = [
t`;<div class="${yes}" />`,
t`;<div ns:class="${no}" />`,
t`/* <div class="${no}" /> */`,
t`// <div class="${no}" />`,
t`;<div not-class="${no}" />`,
@@ -48,12 +78,50 @@ let javascript = [
`;<div class="block px-1\u3000py-2" />`,
`;<div class="px-1\u3000py-2 block" />`,
],

// Whitespace is normalized and duplicates are removed
[
';<div class=" m-0 sm:p-0 p-0 " />',
';<div class="m-0 p-0 sm:p-0" />',
],
[
";<div class={' m-0 sm:p-0 p-0 '} />",
";<div class={'m-0 p-0 sm:p-0'} />",
],
[';<div class={` sm:p-0\n p-0 `} />', ';<div class={`p-0 sm:p-0`} />'],
[';<div class="flex flex" />', ';<div class="flex" />'],
[';<div class={` flex flex `} />', ';<div class={`flex`} />'],
[
';<div class={` flex flex flex${someVar}block block`} />',
';<div class={`flex flex${someVar}block block`} />',
],
[
';<div class={`flex ` + `text-red-500`} />',
';<div class={`flex ` + `text-red-500`} />',
],
[
';<div class={`flex ` + ` ` + `text-red-500`} />',
';<div class={`flex ` + ` ` + `text-red-500`} />',
],

t`;<div class={"before:content-['\\\\2248']"} />`,
t`;<div class={\`before:content-['\\\\2248']\`} />`,
t`;<div class="before:content-['\\\\2248']" />`,

[
`;<div class={'object-cover' + (standalone ? ' aspect-square w-full' : ' min-h-0 grow basis-0')}></div>`,
`;<div class={'object-cover' + (standalone ? ' aspect-square w-full' : ' min-h-0 grow basis-0')}></div>`,
],
]
javascript = javascript.concat(
javascript.map((test) => test.map((t) => t.replace(/class/g, 'className'))),
javascript.map((test) => [
test[0].replace(/class/g, 'className'),
test[1].replace(/class/g, 'className'),
test[2],
]),
)

let vue = [
let vue: TestEntry[] = [
...html,
t`<div :class="'${yes}'"></div>`,
t`<!-- <div :class="'${no}'"></div> -->`,
@@ -81,9 +149,25 @@ let vue = [
`<div :class="\`sm:p-0 p-0 \${someVar}sm:block md:inline flex\`"></div>`,
`<div :class="\`p-0 sm:p-0 \${someVar}sm:block flex md:inline\`"></div>`,
],

[`<div :class="' flex flex '"></div>`, `<div :class="'flex'"></div>`],
[`<div :class="\` flex flex \`"></div>`, `<div :class="\`flex\`"></div>`],
[
`<div :class="' flex ' + ' underline '"></div>`,
`<div :class="'flex ' + ' underline'"></div>`,
],
[
`<div :class="' sm:p-5 ' + ' flex ' + ' underline ' + ' sm:m-5 '"></div>`,
`<div :class="'sm:p-5 ' + ' flex' + ' underline' + ' sm:m-5'"></div>`,
],

[
`<div :class="'before:content-[\\'\\\\2248\\']'" />`,
`<div :class="'before:content-[\\'\\\\2248\\']'" />`,
],
]

let glimmer = [
let glimmer: TestEntry[] = [
t`<div class='${yes}'></div>`,
t`<!-- <div class='${no}'></div> -->`,
t`<div class='${yes} {{"${yes}"}}'></div>`,
@@ -116,9 +200,16 @@ let glimmer = [
`<div class='{{if @isTrue (nope "border-l-4 border-" @borderColor)}}'></div>`,
`<div class='{{if @isTrue (nope "border- border-l-4" @borderColor)}}'></div>`,
],

[`<div class='flex flex '></div>`, `<div class='flex'></div>`],

[
`<div class='sm:p-0 p-0 p-0 {{someVar}}sm:block flex md:inline flex '></div>`,
`<div class='p-0 sm:p-0 {{someVar}}sm:block flex md:inline'></div>`,
],
]

let tests = {
let tests: Record<string, TestEntry[]> = {
html,
glimmer,
lwc: html,
@@ -141,6 +232,11 @@ let tests = {
t`<div [ngClass]="{ '${yes}': (some.thing | urlPipe: { option: true } | async), '${yes}': true }"></div>`,
t`<div [ngClass]="{ '${yes}': foo && bar?.['baz'] }" class="${yes}"></div>`,

[
`<div [ngClass]="' flex ' + ' underline ' + ' block '"></div>`,
`<div [ngClass]="'flex ' + ' underline' + ' block'"></div>`,
],

// TODO: Enable this test — it causes console noise but not a failure
// t`<div [ngClass]="{ '${no}': foo && definitely&a:syntax*error }" class="${yes}"></div>`,
],
@@ -166,32 +262,88 @@ let tests = {
acorn: javascript,
meriyah: javascript,
mdx: javascript
.filter((test) => !test.find((t) => /^\/\*/.test(t)))
.map((test) => test.map((t) => t.replace(/^;/, ''))),
.filter((test) => {
return !/^\/\*/.test(test[0]) && !/^\/\*/.test(test[1])
})
.map((test) => [
test[0].replace(/^;/, ''),
test[1].replace(/^;/, ''),
test[2],
]),
}

describe('parsers', () => {
for (let parser in tests) {
test(parser, async () => {
for (let [input, expected] of tests[parser]) {
expect(await format(input, { parser })).toEqual(expected)
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', async () => {
test('non-tailwind classes', async ({ expect }) => {
expect(
await format('<div class="sm:lowercase uppercase potato text-sm"></div>'),
).toEqual('<div class="potato text-sm uppercase sm:lowercase"></div>')
})

test('parasite utilities', async () => {
test('parasite utilities', async ({ expect }) => {
expect(
await format(
'<div class="group peer unknown-class p-0 container"></div>',
),
).toEqual('<div class="unknown-class group peer container p-0"></div>')
})
})

describe('whitespace', () => {
test('class lists containing interpolation are ignored', async ({
expect,
}) => {
let result = await format('<div class="{{ this is ignored }}"></div>')
expect(result).toEqual('<div class="{{ this is ignored }}"></div>')
})

test('whitespace can be preserved around classes', async ({ expect }) => {
let result = await format(
`;<div className={' underline text-red-500 flex '}></div>`,
{
parser: 'babel',
tailwindPreserveWhitespace: true,
},
)
expect(result).toEqual(
`;<div className={' flex text-red-500 underline '}></div>`,
)
})

test('whitespace can be collapsed around classes', async ({ expect }) => {
let result = await format(
'<div class=" underline text-red-500 flex "></div>',
)
expect(result).toEqual('<div class="flex text-red-500 underline"></div>')
})

test('whitespace is collapsed but not trimmed when ignored', async ({
expect,
}) => {
let result = await format(
';<div className={`underline text-red-500 ${foo}-bar flex`}></div>',
{
parser: 'babel',
},
)
expect(result).toEqual(
';<div className={`text-red-500 underline ${foo}-bar flex`}></div>',
)
})

test('duplicate classes are dropped', async ({ expect }) => {
let result = await format(
'<div class="underline line-through underline flex"></div>',
)
expect(result).toEqual('<div class="flex underline line-through"></div>')
})
})
Loading