From 541d84a3bb737711b0f99d7e2cecff830856bade Mon Sep 17 00:00:00 2001
From: Robin Malfait
Date: Wed, 7 Aug 2024 16:38:44 +0200
Subject: [PATCH] Add `@source` support (#14078)
This PR is an umbrella PR where we will add support for the new
`@source` directive. This will allow you to add explicit content glob
patterns if you want to look for Tailwind classes in other files that
are not automatically detected yet.
Right now this is an addition to the existing auto content detection
that is automatically enabled in the `@tailwindcss/postcss` and
`@tailwindcss/cli` packages. The `@tailwindcss/vite` package doesn't use
the auto content detection, but uses the module graph instead.
From an API perspective there is not a lot going on. There are only a
few things that you have to know when using the `@source` directive, and
you probably already know the rules:
1. You can use multiple `@source` directives if you want.
2. The `@source` accepts a glob pattern so that you can match multiple
files at once
3. The pattern is relative to the current file you are in
4. The pattern includes all files it is matching, even git ignored files
1. The motivation for this is so that you can explicitly point to a
`node_modules` folder if you want to look at `node_modules` for whatever
reason.
6. Right now we don't support negative globs (starting with a `!`) yet,
that will be available in the near future.
Usage example:
```css
/* ./src/input.css */
@import "tailwindcss";
@source "../laravel/resources/views/**/*.blade.php";
@source "../../packages/monorepo-package/**/*.js";
```
It looks like the PR introduced a lot of changes, but this is a side
effect of all the other plumbing work we had to do to make this work.
For example:
1. We added dedicated integration tests that run on Linux and Windows in
CI (just to make sure that all the `path` logic is correct)
2. We Have to make sure that the glob patterns are always correct even
if you are using `@import` in your CSS and use `@source` in an imported
file. This is because we receive the flattened CSS contents where all
`@import`s are inlined.
3. We have to make sure that we also listen for changes in the files
that match any of these patterns and trigger a rebuild.
PRs:
- [x] https://github.com/tailwindlabs/tailwindcss/pull/14063
- [x] https://github.com/tailwindlabs/tailwindcss/pull/14085
- [x] https://github.com/tailwindlabs/tailwindcss/pull/14079
- [x] https://github.com/tailwindlabs/tailwindcss/pull/14067
- [x] https://github.com/tailwindlabs/tailwindcss/pull/14076
- [x] https://github.com/tailwindlabs/tailwindcss/pull/14080
- [x] https://github.com/tailwindlabs/tailwindcss/pull/14127
- [x] https://github.com/tailwindlabs/tailwindcss/pull/14135
Once all the PRs are merged, then this umbrella PR can be merged.
> [!IMPORTANT]
> Make sure to merge this without rebasing such that each individual PR
ends up on the main branch.
---------
Co-authored-by: Philipp Spiess
Co-authored-by: Jordan Pittman
Co-authored-by: Adam Wathan
---
CHANGELOG.md | 1 +
Cargo.lock | 271 ++++++++-
crates/node/src/lib.rs | 86 ++-
crates/oxide/Cargo.toml | 3 +
crates/oxide/src/glob.rs | 103 ++--
crates/oxide/src/lib.rs | 73 ++-
.../tests/{auto_content.rs => scan_dir.rs} | 77 ++-
integrations/cli/index.test.ts | 142 +++++
integrations/postcss/index.test.ts | 164 ++++++
integrations/utils.ts | 534 ++++++++++++------
integrations/vite/index.test.ts | 109 ++--
packages/@tailwindcss-cli/package.json | 5 +-
.../src/commands/build/index.ts | 276 ++++++---
.../@tailwindcss-cli/src/utils/disposables.ts | 45 ++
packages/@tailwindcss-cli/tsup.config.ts | 9 +
packages/@tailwindcss-postcss/package.json | 5 +-
.../example-project/src/relative-import.css | 1 +
.../@tailwindcss-postcss/src/index.test.ts | 34 +-
packages/@tailwindcss-postcss/src/index.ts | 196 ++++---
packages/@tailwindcss-postcss/tsup.config.ts | 12 +
packages/@tailwindcss-vite/package.json | 6 +-
packages/@tailwindcss-vite/src/index.ts | 139 ++++-
packages/@tailwindcss-vite/tsup.config.ts | 10 +
.../package.json | 27 +
.../fixtures/example-project/src/index.css | 4 +
.../fixtures/example-project/src/invalid.css | 4 +
.../fixtures/external-import/src/index.css | 1 +
.../fixtures/external-import/src/invalid.css | 1 +
.../external-import/src/plugins-in-root.css | 5 +
.../src/plugins-in-sibling.css | 3 +
.../src/index.test.ts | 58 ++
.../src/index.ts | 77 +++
.../src/normalize-path.ts | 47 ++
.../tsconfig.json | 3 +
packages/tailwindcss/src/candidate.bench.ts | 2 +-
packages/tailwindcss/src/index.bench.ts | 2 +-
packages/tailwindcss/src/index.test.ts | 19 +
packages/tailwindcss/src/index.ts | 26 +
pnpm-lock.yaml | 33 ++
scripts/pack-packages.mjs | 12 +
40 files changed, 2111 insertions(+), 514 deletions(-)
rename crates/oxide/tests/{auto_content.rs => scan_dir.rs} (86%)
create mode 100644 integrations/cli/index.test.ts
create mode 100644 integrations/postcss/index.test.ts
create mode 100644 packages/@tailwindcss-cli/src/utils/disposables.ts
create mode 100644 packages/@tailwindcss-cli/tsup.config.ts
create mode 100644 packages/@tailwindcss-postcss/src/fixtures/example-project/src/relative-import.css
create mode 100644 packages/@tailwindcss-postcss/tsup.config.ts
create mode 100644 packages/@tailwindcss-vite/tsup.config.ts
create mode 100644 packages/internal-postcss-fix-relative-paths/package.json
create mode 100644 packages/internal-postcss-fix-relative-paths/src/fixtures/example-project/src/index.css
create mode 100644 packages/internal-postcss-fix-relative-paths/src/fixtures/example-project/src/invalid.css
create mode 100644 packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/index.css
create mode 100644 packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/invalid.css
create mode 100644 packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/plugins-in-root.css
create mode 100644 packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/plugins-in-sibling.css
create mode 100644 packages/internal-postcss-fix-relative-paths/src/index.test.ts
create mode 100644 packages/internal-postcss-fix-relative-paths/src/index.ts
create mode 100644 packages/internal-postcss-fix-relative-paths/src/normalize-path.ts
create mode 100644 packages/internal-postcss-fix-relative-paths/tsconfig.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7929a669..b51071521 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add support for `inline` option when defining `@theme` values ([#14095](https://github.com/tailwindlabs/tailwindcss/pull/14095))
- Add `inert` variant ([#14129](https://github.com/tailwindlabs/tailwindcss/pull/14129))
+- Add support for explicitly registering content paths using new `@source` at-rule ([#14078](https://github.com/tailwindlabs/tailwindcss/pull/14078))
## [4.0.0-alpha.18] - 2024-07-25
diff --git a/Cargo.lock b/Cargo.lock
index 1b67330dc..014a33c0d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -34,9 +34,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
-version = "2.3.1"
+version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6776fc96284a0bb647b615056fc496d1fe1644a7ab01829818a6d91cae888b84"
+checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
[[package]]
name = "bstr"
@@ -154,6 +154,12 @@ dependencies = [
"syn 2.0.18",
]
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
[[package]]
name = "either"
version = "1.8.1"
@@ -196,6 +202,83 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+[[package]]
+name = "futures"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
+
+[[package]]
+name = "futures-task"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
+
+[[package]]
+name = "futures-util"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "pin-utils",
+ "slab",
+]
+
[[package]]
name = "fxhash"
version = "0.2.1"
@@ -205,6 +288,12 @@ dependencies = [
"byteorder",
]
+[[package]]
+name = "glob-match"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d"
+
[[package]]
name = "globset"
version = "0.4.10"
@@ -309,6 +398,16 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519"
+[[package]]
+name = "lock_api"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
+dependencies = [
+ "autocfg",
+ "scopeguard",
+]
+
[[package]]
name = "log"
version = "0.4.18"
@@ -345,7 +444,7 @@ version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7f0a2e93526dd9c8c522d72a4d0c88678be8966fabe9fb8f2947fde6339b682"
dependencies = [
- "bitflags 2.3.1",
+ "bitflags 2.6.0",
"ctor",
"napi-derive",
"napi-sys",
@@ -418,9 +517,9 @@ dependencies = [
[[package]]
name = "once_cell"
-version = "1.17.2"
+version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9670a07f94779e00908f3e686eab508878ebb390ba6e604d3a284c00e8d0487b"
+checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "overload"
@@ -428,6 +527,29 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
+[[package]]
+name = "parking_lot"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.5.3",
+ "smallvec",
+ "windows-targets 0.52.6",
+]
+
[[package]]
name = "pin-project-lite"
version = "0.2.9"
@@ -435,10 +557,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116"
[[package]]
-name = "proc-macro2"
-version = "1.0.59"
+name = "pin-utils"
+version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6aeca18b86b413c660b781aa319e4e2648a3e6f9eadc9b47e9038e6fe9f3451b"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.86"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
dependencies = [
"unicode-ident",
]
@@ -483,6 +611,15 @@ dependencies = [
"bitflags 1.3.2",
]
+[[package]]
+name = "redox_syscall"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4"
+dependencies = [
+ "bitflags 2.6.0",
+]
+
[[package]]
name = "regex"
version = "1.8.3"
@@ -538,12 +675,27 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "scc"
+version = "2.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05ccfb12511cdb770157ace92d7dda771e498445b78f9886e8cdbc5140a4eced"
+dependencies = [
+ "sdd",
+]
+
[[package]]
name = "scopeguard"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
+[[package]]
+name = "sdd"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "177258b64c0faaa9ffd3c65cd3262c2bc7e2588dbbd9c1641d0346145c1bbda8"
+
[[package]]
name = "semver"
version = "1.0.17"
@@ -556,6 +708,31 @@ version = "1.0.163"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2113ab51b87a539ae008b5c6c02dc020ffa39afd2d83cffcb3f4eb2722cebec2"
+[[package]]
+name = "serial_test"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b4b487fe2acf240a021cf57c6b2b4903b1e78ca0ecd862a71b71d2a51fed77d"
+dependencies = [
+ "futures",
+ "log",
+ "once_cell",
+ "parking_lot",
+ "scc",
+ "serial_test_derive",
+]
+
+[[package]]
+name = "serial_test_derive"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82fe9db325bcef1fbcde82e078a5cc4efdf787e96b3b9cf45b50b529f2083d67"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.18",
+]
+
[[package]]
name = "sharded-slab"
version = "0.1.4"
@@ -565,6 +742,15 @@ dependencies = [
"lazy_static",
]
+[[package]]
+name = "slab"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
+dependencies = [
+ "autocfg",
+]
+
[[package]]
name = "smallvec"
version = "1.10.0"
@@ -610,12 +796,15 @@ version = "0.1.0"
dependencies = [
"bstr",
"crossbeam",
+ "dunce",
"fxhash",
+ "glob-match",
"globwalk",
"ignore",
"lazy_static",
"log",
"rayon",
+ "serial_test",
"tempfile",
"tracing",
"tracing-subscriber",
@@ -630,7 +819,7 @@ checksum = "b9fbec84f381d5795b08656e4912bec604d162bff9291d6189a78f4c8ab87998"
dependencies = [
"cfg-if",
"fastrand",
- "redox_syscall",
+ "redox_syscall 0.3.5",
"rustix",
"windows-sys 0.45.0",
]
@@ -814,6 +1003,22 @@ dependencies = [
"windows_x86_64_msvc 0.48.0",
]
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
@@ -826,6 +1031,12 @@ version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc"
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -838,6 +1049,12 @@ version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3"
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -850,6 +1067,18 @@ version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241"
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -862,6 +1091,12 @@ version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00"
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -874,6 +1109,12 @@ version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1"
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -886,6 +1127,12 @@ version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953"
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -897,3 +1144,9 @@ name = "windows_x86_64_msvc"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs
index a7bfe0532..54860da97 100644
--- a/crates/node/src/lib.rs
+++ b/crates/node/src/lib.rs
@@ -1,5 +1,5 @@
use napi::bindgen_prelude::{FromNapiValue, ToNapiValue};
-use std::path::PathBuf;
+use std::{collections::HashSet, path::PathBuf};
#[macro_use]
extern crate napi_derive;
@@ -22,50 +22,104 @@ impl From for tailwindcss_oxide::ChangedContent {
}
#[derive(Debug, Clone)]
-#[napi(object)]
+#[napi]
pub struct ScanResult {
+ // Private information necessary for incremental rebuilds. Note: these fields are not exposed
+ // to JS
+ base: Option,
+ sources: Vec,
+
+ // Public API:
pub globs: Vec,
pub files: Vec,
pub candidates: Vec,
}
+#[napi]
+impl ScanResult {
+ #[napi]
+ pub fn scan_files(&self, input: Vec) -> Vec {
+ let result = tailwindcss_oxide::scan_dir(tailwindcss_oxide::ScanOptions {
+ base: self.base.clone(),
+ sources: self.sources.clone().into_iter().map(Into::into).collect(),
+ });
+
+ let mut unique_candidates: HashSet = HashSet::from_iter(result.candidates);
+ let candidates_from_files: HashSet = HashSet::from_iter(tailwindcss_oxide::scan_files(
+ input.into_iter().map(Into::into).collect(),
+ IO::Parallel as u8 | Parsing::Parallel as u8,
+ ));
+
+ unique_candidates.extend(candidates_from_files);
+
+ unique_candidates
+ .into_iter()
+ .map(|x| x.to_string())
+ .collect()
+ }
+}
+
#[derive(Debug, Clone)]
#[napi(object)]
pub struct GlobEntry {
pub base: String,
- pub glob: String,
+ pub pattern: String,
+}
+
+impl From for tailwindcss_oxide::GlobEntry {
+ fn from(glob: GlobEntry) -> Self {
+ tailwindcss_oxide::GlobEntry {
+ base: glob.base,
+ pattern: glob.pattern,
+ }
+ }
+}
+
+impl From for GlobEntry {
+ fn from(glob: tailwindcss_oxide::GlobEntry) -> Self {
+ GlobEntry {
+ base: glob.base,
+ pattern: glob.pattern,
+ }
+ }
}
#[derive(Debug, Clone)]
#[napi(object)]
pub struct ScanOptions {
- pub base: String,
- pub globs: Option,
+ /// Base path to start scanning from
+ pub base: Option,
+ /// Glob sources
+ pub sources: Option>,
}
#[napi]
pub fn clear_cache() {
- tailwindcss_oxide::clear_cache();
+ tailwindcss_oxide::clear_cache();
}
#[napi]
pub fn scan_dir(args: ScanOptions) -> ScanResult {
let result = tailwindcss_oxide::scan_dir(tailwindcss_oxide::ScanOptions {
- base: args.base,
- globs: args.globs.unwrap_or(false),
+ base: args.base.clone(),
+ sources: args
+ .sources
+ .clone()
+ .unwrap_or_default()
+ .into_iter()
+ .map(Into::into)
+ .collect(),
});
ScanResult {
+ // Private
+ base: args.base,
+ sources: args.sources.unwrap_or_default(),
+
+ // Public
files: result.files,
candidates: result.candidates,
- globs: result
- .globs
- .into_iter()
- .map(|g| GlobEntry {
- base: g.base,
- glob: g.glob,
- })
- .collect(),
+ globs: result.globs.into_iter().map(Into::into).collect(),
}
}
diff --git a/crates/oxide/Cargo.toml b/crates/oxide/Cargo.toml
index 3629f57f5..be6525056 100644
--- a/crates/oxide/Cargo.toml
+++ b/crates/oxide/Cargo.toml
@@ -15,6 +15,9 @@ tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }
walkdir = "2.3.3"
ignore = "0.4.20"
lazy_static = "1.4.0"
+glob-match = "0.2.1"
+serial_test = "3.1.1"
+dunce = "1.0.5"
[dev-dependencies]
tempfile = "3.5.0"
diff --git a/crates/oxide/src/glob.rs b/crates/oxide/src/glob.rs
index 6d688f421..d74b60b4d 100644
--- a/crates/oxide/src/glob.rs
+++ b/crates/oxide/src/glob.rs
@@ -1,11 +1,13 @@
+use glob_match::glob_match;
use std::iter;
use std::path::{Path, PathBuf};
+use crate::GlobEntry;
+
pub fn fast_glob(
- base_path: &Path,
- patterns: &Vec,
+ patterns: &Vec,
) -> Result, std::io::Error> {
- Ok(get_fast_patterns(base_path, patterns)
+ Ok(get_fast_patterns(patterns)
.into_iter()
.flat_map(|(base_path, patterns)| {
globwalk::GlobWalkerBuilder::from_patterns(base_path, &patterns)
@@ -40,10 +42,13 @@ pub fn fast_glob(
/// tailwind --pwd ./project/pages --content "**/*.js"
/// tailwind --pwd ./project/components --content "**/*.js"
/// ```
-fn get_fast_patterns(base_path: &Path, patterns: &Vec) -> Vec<(PathBuf, Vec)> {
+pub fn get_fast_patterns(patterns: &Vec) -> Vec<(PathBuf, Vec)> {
let mut optimized_patterns: Vec<(PathBuf, Vec)> = vec![];
for pattern in patterns {
+ let base_path = PathBuf::from(&pattern.base);
+ let pattern = &pattern.pattern;
+
let is_negated = pattern.starts_with('!');
let mut pattern = pattern.clone();
if is_negated {
@@ -54,13 +59,13 @@ fn get_fast_patterns(base_path: &Path, patterns: &Vec) -> Vec<(PathBuf,
if folders.len() <= 1 {
// No paths we can simplify, so let's use it as-is.
- optimized_patterns.push((base_path.to_path_buf(), vec![pattern]));
+ optimized_patterns.push((base_path, vec![pattern]));
} else {
// We do have folders because `/` exists. Let's try to simplify the globs!
// Safety: We know that the length is greater than 1, so we can safely unwrap.
let file_pattern = folders.pop().unwrap();
let all_folders = folders.clone();
- let mut temp_paths = vec![base_path.to_path_buf()];
+ let mut temp_paths = vec![base_path];
let mut bail = false;
@@ -131,6 +136,14 @@ fn get_fast_patterns(base_path: &Path, patterns: &Vec) -> Vec<(PathBuf,
optimized_patterns
}
+pub fn path_matches_globs(path: &Path, globs: &[GlobEntry]) -> bool {
+ let path = path.to_string_lossy();
+
+ globs
+ .iter()
+ .any(|g| glob_match(&format!("{}/{}", g.base, g.pattern), &path))
+}
+
/// Given this input: a-{b,c}-d-{e,f}
/// We will get:
/// [
@@ -228,11 +241,15 @@ fn expand_braces(input: &str) -> Vec {
#[cfg(test)]
mod tests {
use super::get_fast_patterns;
+ use crate::GlobEntry;
use std::path::PathBuf;
#[test]
fn it_should_keep_globs_that_start_with_file_wildcards_as_is() {
- let actual = get_fast_patterns(&PathBuf::from("/projects"), &vec!["*.html".to_string()]);
+ let actual = get_fast_patterns(&vec![GlobEntry {
+ base: "/projects".to_string(),
+ pattern: "*.html".to_string(),
+ }]);
let expected = vec![(PathBuf::from("/projects"), vec!["*.html".to_string()])];
assert_eq!(actual, expected,);
@@ -240,7 +257,11 @@ mod tests {
#[test]
fn it_should_keep_globs_that_start_with_folder_wildcards_as_is() {
- let actual = get_fast_patterns(&PathBuf::from("/projects"), &vec!["**/*.html".to_string()]);
+ let actual = get_fast_patterns(&vec![GlobEntry {
+ base: "/projects".to_string(),
+ pattern: "**/*.html".to_string(),
+ }]);
+
let expected = vec![(PathBuf::from("/projects"), vec!["**/*.html".to_string()])];
assert_eq!(actual, expected,);
@@ -248,10 +269,10 @@ mod tests {
#[test]
fn it_should_move_the_starting_folder_to_the_path() {
- let actual = get_fast_patterns(
- &PathBuf::from("/projects"),
- &vec!["example/*.html".to_string()],
- );
+ let actual = get_fast_patterns(&vec![GlobEntry {
+ base: "/projects".to_string(),
+ pattern: "example/*.html".to_string(),
+ }]);
let expected = vec![(
PathBuf::from("/projects/example"),
vec!["*.html".to_string()],
@@ -262,10 +283,10 @@ mod tests {
#[test]
fn it_should_move_the_starting_folders_to_the_path() {
- let actual = get_fast_patterns(
- &PathBuf::from("/projects"),
- &vec!["example/other/*.html".to_string()],
- );
+ let actual = get_fast_patterns(&vec![GlobEntry {
+ base: "/projects".to_string(),
+ pattern: "example/other/*.html".to_string(),
+ }]);
let expected = vec![(
PathBuf::from("/projects/example/other"),
vec!["*.html".to_string()],
@@ -276,10 +297,11 @@ mod tests {
#[test]
fn it_should_branch_expandable_folders() {
- let actual = get_fast_patterns(
- &PathBuf::from("/projects"),
- &vec!["{foo,bar}/*.html".to_string()],
- );
+ let actual = get_fast_patterns(&vec![GlobEntry {
+ base: "/projects".to_string(),
+ pattern: "{foo,bar}/*.html".to_string(),
+ }]);
+
let expected = vec![
(PathBuf::from("/projects/foo"), vec!["*.html".to_string()]),
(PathBuf::from("/projects/bar"), vec!["*.html".to_string()]),
@@ -290,10 +312,10 @@ mod tests {
#[test]
fn it_should_expand_multiple_expansions_in_the_same_folder() {
- let actual = get_fast_patterns(
- &PathBuf::from("/projects"),
- &vec!["a-{b,c}-d-{e,f}-g/*.html".to_string()],
- );
+ let actual = get_fast_patterns(&vec![GlobEntry {
+ base: "/projects".to_string(),
+ pattern: "a-{b,c}-d-{e,f}-g/*.html".to_string(),
+ }]);
let expected = vec![
(
PathBuf::from("/projects/a-b-d-e-g"),
@@ -318,10 +340,10 @@ mod tests {
#[test]
fn multiple_expansions_per_folder_starting_at_the_root() {
- let actual = get_fast_patterns(
- &PathBuf::from("/projects"),
- &vec!["{a,b}-c-{d,e}-f/{b,c}-d-{e,f}-g/*.html".to_string()],
- );
+ let actual = get_fast_patterns(&vec![GlobEntry {
+ base: "/projects".to_string(),
+ pattern: "{a,b}-c-{d,e}-f/{b,c}-d-{e,f}-g/*.html".to_string(),
+ }]);
let expected = vec![
(
PathBuf::from("/projects/a-c-d-f/b-d-e-g"),
@@ -394,10 +416,11 @@ mod tests {
#[test]
fn it_should_stop_expanding_once_we_hit_a_wildcard() {
- let actual = get_fast_patterns(
- &PathBuf::from("/projects"),
- &vec!["{foo,bar}/example/**/{baz,qux}/*.html".to_string()],
- );
+ let actual = get_fast_patterns(&vec![GlobEntry {
+ base: "/projects".to_string(),
+ pattern: "{foo,bar}/example/**/{baz,qux}/*.html".to_string(),
+ }]);
+
let expected = vec![
(
PathBuf::from("/projects/foo/example"),
@@ -414,10 +437,10 @@ mod tests {
#[test]
fn it_should_keep_the_negation_symbol_for_all_new_patterns() {
- let actual = get_fast_patterns(
- &PathBuf::from("/projects"),
- &vec!["!{foo,bar}/*.html".to_string()],
- );
+ let actual = get_fast_patterns(&vec![GlobEntry {
+ base: "/projects".to_string(),
+ pattern: "!{foo,bar}/*.html".to_string(),
+ }]);
let expected = vec![
(PathBuf::from("/projects/foo"), vec!["!*.html".to_string()]),
(PathBuf::from("/projects/bar"), vec!["!*.html".to_string()]),
@@ -428,10 +451,10 @@ mod tests {
#[test]
fn it_should_expand_a_complex_example() {
- let actual = get_fast_patterns(
- &PathBuf::from("/projects"),
- &vec!["a/{b,c}/d/{e,f}/g/*.html".to_string()],
- );
+ let actual = get_fast_patterns(&vec![GlobEntry {
+ base: "/projects".to_string(),
+ pattern: "a/{b,c}/d/{e,f}/g/*.html".to_string(),
+ }]);
let expected = vec![
(
PathBuf::from("/projects/a/b/d/e/g"),
diff --git a/crates/oxide/src/lib.rs b/crates/oxide/src/lib.rs
index ae49d0c91..ca8593e35 100644
--- a/crates/oxide/src/lib.rs
+++ b/crates/oxide/src/lib.rs
@@ -2,6 +2,8 @@ use crate::parser::Extractor;
use bstr::ByteSlice;
use cache::Cache;
use fxhash::FxHashSet;
+use glob::fast_glob;
+use glob::get_fast_patterns;
use ignore::DirEntry;
use ignore::WalkBuilder;
use lazy_static::lazy_static;
@@ -39,8 +41,10 @@ pub struct ChangedContent {
#[derive(Debug, Clone)]
pub struct ScanOptions {
- pub base: String,
- pub globs: bool,
+ /// Base path to start scanning from
+ pub base: Option,
+ /// Glob sources
+ pub sources: Vec,
}
#[derive(Debug, Clone)]
@@ -53,7 +57,7 @@ pub struct ScanResult {
#[derive(Debug, Clone)]
pub struct GlobEntry {
pub base: String,
- pub glob: String,
+ pub pattern: String,
}
pub fn clear_cache() {
@@ -64,16 +68,61 @@ pub fn clear_cache() {
pub fn scan_dir(opts: ScanOptions) -> ScanResult {
init_tracing();
- let root = Path::new(&opts.base);
+ let (mut files, mut globs) = match opts.base {
+ Some(base) => {
+ // Only enable auto content detection when `base` is provided.
+ let base = Path::new(&base);
+ let (files, dirs) = resolve_files(base);
+ let globs = resolve_globs(base, dirs);
- let (files, dirs) = resolve_files(root);
-
- let globs = if opts.globs {
- resolve_globs(root, dirs)
- } else {
- vec![]
+ (files, globs)
+ }
+ None => (vec![], vec![]),
};
+ // If we have additional sources, then we have to resolve them as well.
+ if !opts.sources.is_empty() {
+ let resolved_files: Vec<_> = match fast_glob(&opts.sources) {
+ Ok(matches) => matches
+ .filter_map(|x| dunce::canonicalize(&x).ok())
+ .collect(),
+ Err(err) => {
+ event!(tracing::Level::ERROR, "Failed to resolve glob: {:?}", err);
+ vec![]
+ }
+ };
+
+ files.extend(resolved_files);
+
+ let optimized_incoming_globs = get_fast_patterns(&opts.sources)
+ .iter()
+ .flat_map(|(root, globs)| {
+ globs.iter().filter_map(|glob| {
+ let root = match dunce::canonicalize(root.clone()) {
+ Ok(root) => root,
+ Err(error) => {
+ event!(
+ tracing::Level::ERROR,
+ "Failed to canonicalize base path {:?}",
+ error
+ );
+ return None;
+ }
+ };
+
+ let base = root.display().to_string();
+ let glob = glob.to_string();
+ Some(GlobEntry {
+ base,
+ pattern: glob,
+ })
+ })
+ })
+ .collect::>();
+
+ globs.extend(optimized_incoming_globs);
+ }
+
let mut cache = GLOBAL_CACHE.lock().unwrap();
let modified_files = cache.find_modified_files(&files);
@@ -259,12 +308,12 @@ fn resolve_globs(root: &Path, dirs: Vec) -> Vec {
// Build the globs for all globable directories.
let shallow_globs = shallow_globable_directories.iter().map(|path| GlobEntry {
base: path.display().to_string(),
- glob: format!("*/*.{{{}}}", extension_list),
+ pattern: format!("*/*.{{{}}}", extension_list),
});
let deep_globs = deep_globable_directories.iter().map(|path| GlobEntry {
base: path.display().to_string(),
- glob: format!("**/*.{{{}}}", extension_list),
+ pattern: format!("**/*.{{{}}}", extension_list),
});
shallow_globs.chain(deep_globs).collect::>()
diff --git a/crates/oxide/tests/auto_content.rs b/crates/oxide/tests/scan_dir.rs
similarity index 86%
rename from crates/oxide/tests/auto_content.rs
rename to crates/oxide/tests/scan_dir.rs
index 68fb809f9..7f875ea4a 100644
--- a/crates/oxide/tests/auto_content.rs
+++ b/crates/oxide/tests/scan_dir.rs
@@ -1,12 +1,19 @@
#[cfg(test)]
-mod auto_content {
+mod scan_dir {
+ use serial_test::serial;
use std::process::Command;
use std::{fs, path};
use tailwindcss_oxide::*;
use tempfile::tempdir;
- fn scan(paths_with_content: &[(&str, Option<&str>)]) -> (Vec, Vec) {
+ fn scan_with_globs(
+ paths_with_content: &[(&str, Option<&str>)],
+ globs: Vec<&str>,
+ ) -> (Vec, Vec) {
+ // Ensure that every test truly runs in isolation without any cache
+ clear_cache();
+
// Create a temporary working directory
let dir = tempdir().unwrap().into_path();
@@ -15,8 +22,8 @@ mod auto_content {
// Create the necessary files
for (path, contents) in paths_with_content {
- // Ensure we use the right path seperator for the current platform
- let path = dir.join(path.replace("/", path::MAIN_SEPARATOR.to_string().as_str()));
+ // Ensure we use the right path separator for the current platform
+ let path = dir.join(path.replace('/', path::MAIN_SEPARATOR.to_string().as_str()));
let parent = path.parent().unwrap();
if !parent.exists() {
fs::create_dir_all(parent).unwrap();
@@ -32,8 +39,14 @@ mod auto_content {
// Resolve all content paths for the (temporary) current working directory
let result = scan_dir(ScanOptions {
- base: base.clone(),
- globs: true,
+ base: Some(base.clone()),
+ sources: globs
+ .iter()
+ .map(|x| GlobEntry {
+ base: base.clone(),
+ pattern: x.to_string(),
+ })
+ .collect(),
});
let mut paths: Vec<_> = result
@@ -47,7 +60,7 @@ mod auto_content {
"{}{}{}",
glob.base,
path::MAIN_SEPARATOR,
- glob.glob
+ glob.pattern
));
}
@@ -57,7 +70,7 @@ mod auto_content {
let parent_dir = format!("{}{}", &base.to_string(), path::MAIN_SEPARATOR);
x.replace(&parent_dir, "")
// Normalize paths to use unix style separators
- .replace("\\", "/")
+ .replace('\\', "/")
})
.collect();
@@ -68,11 +81,16 @@ mod auto_content {
(paths, result.candidates)
}
+ fn scan(paths_with_content: &[(&str, Option<&str>)]) -> (Vec, Vec) {
+ scan_with_globs(paths_with_content, vec![])
+ }
+
fn test(paths_with_content: &[(&str, Option<&str>)]) -> Vec {
scan(paths_with_content).0
}
#[test]
+ #[serial]
fn it_should_work_with_a_set_of_root_files() {
let globs = test(&[
("index.html", None),
@@ -84,6 +102,7 @@ mod auto_content {
}
#[test]
+ #[serial]
fn it_should_work_with_a_set_of_root_files_and_ignore_ignored_files() {
let globs = test(&[
(".gitignore", Some("b.html")),
@@ -96,6 +115,7 @@ mod auto_content {
}
#[test]
+ #[serial]
fn it_should_list_all_files_in_the_public_folder_explicitly() {
let globs = test(&[
("index.html", None),
@@ -115,6 +135,7 @@ mod auto_content {
}
#[test]
+ #[serial]
fn it_should_list_nested_folders_explicitly_in_the_public_folder() {
let globs = test(&[
("index.html", None),
@@ -144,6 +165,7 @@ mod auto_content {
}
#[test]
+ #[serial]
fn it_should_list_all_files_in_the_public_folder_explicitly_except_ignored_files() {
let globs = test(&[
(".gitignore", Some("public/b.html\na.html")),
@@ -156,6 +178,7 @@ mod auto_content {
}
#[test]
+ #[serial]
fn it_should_use_a_glob_for_top_level_folders() {
let globs = test(&[
("index.html", None),
@@ -173,6 +196,7 @@ mod auto_content {
}
#[test]
+ #[serial]
fn it_should_ignore_binary_files() {
let globs = test(&[
("index.html", None),
@@ -184,6 +208,7 @@ mod auto_content {
}
#[test]
+ #[serial]
fn it_should_ignore_known_extensions() {
let globs = test(&[
("index.html", None),
@@ -195,6 +220,7 @@ mod auto_content {
}
#[test]
+ #[serial]
fn it_should_ignore_known_files() {
let globs = test(&[
("index.html", None),
@@ -205,6 +231,7 @@ mod auto_content {
}
#[test]
+ #[serial]
fn it_should_ignore_and_expand_nested_ignored_folders() {
let globs = test(&[
// Explicitly listed root files
@@ -291,6 +318,7 @@ mod auto_content {
}
#[test]
+ #[serial]
fn it_should_scan_for_utilities() {
let mut ignores = String::new();
ignores.push_str("# md:font-bold\n");
@@ -315,4 +343,37 @@ mod auto_content {
vec!["condition", "div", "font-bold", "md:flex", "px-4"]
);
}
+
+ #[test]
+ #[serial]
+ fn it_should_scan_content_paths() {
+ let candidates = scan_with_globs(
+ &[
+ // We know that `.styl` extensions are ignored, so they are not covered by auto content
+ // detection.
+ ("foo.styl", Some("content-['foo.styl']")),
+ ],
+ vec!["*.styl"],
+ )
+ .1;
+
+ assert_eq!(candidates, vec!["content-['foo.styl']"]);
+ }
+
+ #[test]
+ #[serial]
+ fn it_should_scan_content_paths_even_when_they_are_git_ignored() {
+ let candidates = scan_with_globs(
+ &[
+ (".gitignore", Some("foo.styl")),
+ // We know that `.styl` extensions are ignored, so they are not covered by auto content
+ // detection.
+ ("foo.styl", Some("content-['foo.styl']")),
+ ],
+ vec!["*.styl"],
+ )
+ .1;
+
+ assert_eq!(candidates, vec!["content-['foo.styl']"]);
+ }
}
diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts
new file mode 100644
index 000000000..7ee7ea689
--- /dev/null
+++ b/integrations/cli/index.test.ts
@@ -0,0 +1,142 @@
+import path from 'node:path'
+import { candidate, css, html, js, json, test, yaml } from '../utils'
+
+test(
+ 'production build',
+ {
+ fs: {
+ 'package.json': json`{}`,
+ 'pnpm-workspace.yaml': yaml`
+ #
+ packages:
+ - project-a
+ `,
+ 'project-a/package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "workspace:^",
+ "@tailwindcss/cli": "workspace:^"
+ }
+ }
+ `,
+ 'project-a/index.html': html`
+
+ `,
+ 'project-a/plugin.js': js`
+ module.exports = function ({ addVariant }) {
+ addVariant('inverted', '@media (inverted-colors: inverted)')
+ addVariant('hocus', ['&:focus', '&:hover'])
+ }
+ `,
+ 'project-a/src/index.css': css`
+ @import 'tailwindcss/utilities';
+ @source '../../project-b/src/**/*.js';
+ @plugin '../plugin.js';
+ `,
+ 'project-a/src/index.js': js`
+ const className = "content-['project-a/src/index.js']"
+ module.exports = { className }
+ `,
+ 'project-b/src/index.js': js`
+ const className = "content-['project-b/src/index.js']"
+ module.exports = { className }
+ `,
+ },
+ },
+ async ({ root, fs, exec }) => {
+ await exec('pnpm tailwindcss --input src/index.css --output dist/out.css', {
+ cwd: path.join(root, 'project-a'),
+ })
+
+ await fs.expectFileToContain('project-a/dist/out.css', [
+ candidate`underline`,
+ candidate`content-['project-a/src/index.js']`,
+ candidate`content-['project-b/src/index.js']`,
+ candidate`inverted:flex`,
+ candidate`hocus:underline`,
+ ])
+ },
+)
+
+test(
+ 'watch mode',
+ {
+ fs: {
+ 'package.json': json`{}`,
+ 'pnpm-workspace.yaml': yaml`
+ #
+ packages:
+ - project-a
+ `,
+ 'project-a/package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "workspace:^",
+ "@tailwindcss/cli": "workspace:^"
+ }
+ }
+ `,
+ 'project-a/index.html': html`
+
+ `,
+ 'project-a/plugin.js': js`
+ module.exports = function ({ addVariant }) {
+ addVariant('inverted', '@media (inverted-colors: inverted)')
+ addVariant('hocus', ['&:focus', '&:hover'])
+ }
+ `,
+ 'project-a/src/index.css': css`
+ @import 'tailwindcss/utilities';
+ @source '../../project-b/src/**/*.js';
+ @plugin '../plugin.js';
+ `,
+ 'project-a/src/index.js': js`
+ const className = "content-['project-a/src/index.js']"
+ module.exports = { className }
+ `,
+ 'project-b/src/index.js': js`
+ const className = "content-['project-b/src/index.js']"
+ module.exports = { className }
+ `,
+ },
+ },
+ async ({ root, fs, spawn }) => {
+ await spawn('pnpm tailwindcss --input src/index.css --output dist/out.css --watch', {
+ cwd: path.join(root, 'project-a'),
+ })
+
+ await fs.expectFileToContain('project-a/dist/out.css', [
+ candidate`underline`,
+ candidate`content-['project-a/src/index.js']`,
+ candidate`content-['project-b/src/index.js']`,
+ candidate`inverted:flex`,
+ candidate`hocus:underline`,
+ ])
+
+ await fs.write(
+ 'project-a/src/index.js',
+ js`
+ const className = "[.changed_&]:content-['project-a/src/index.js']"
+ module.exports = { className }
+ `,
+ )
+ await fs.expectFileToContain('project-a/dist/out.css', [
+ candidate`[.changed_&]:content-['project-a/src/index.js']`,
+ ])
+
+ await fs.write(
+ 'project-b/src/index.js',
+ js`
+ const className = "[.changed_&]:content-['project-b/src/index.js']"
+ module.exports = { className }
+ `,
+ )
+ await fs.expectFileToContain('project-a/dist/out.css', [
+ candidate`[.changed_&]:content-['project-b/src/index.js']`,
+ ])
+ },
+)
diff --git a/integrations/postcss/index.test.ts b/integrations/postcss/index.test.ts
new file mode 100644
index 000000000..6ec7c749f
--- /dev/null
+++ b/integrations/postcss/index.test.ts
@@ -0,0 +1,164 @@
+import path from 'node:path'
+import { candidate, css, html, js, json, test, yaml } from '../utils'
+
+test(
+ 'production build',
+ {
+ fs: {
+ 'package.json': json`{}`,
+ 'pnpm-workspace.yaml': yaml`
+ #
+ packages:
+ - project-a
+ `,
+ 'project-a/package.json': json`
+ {
+ "dependencies": {
+ "postcss": "^8",
+ "postcss-cli": "^10",
+ "tailwindcss": "workspace:^",
+ "@tailwindcss/postcss": "workspace:^"
+ }
+ }
+ `,
+ 'project-a/postcss.config.js': js`
+ module.exports = {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
+ }
+ `,
+ 'project-a/index.html': html`
+
+ `,
+ 'project-a/plugin.js': js`
+ module.exports = function ({ addVariant }) {
+ addVariant('inverted', '@media (inverted-colors: inverted)')
+ addVariant('hocus', ['&:focus', '&:hover'])
+ }
+ `,
+ 'project-a/src/index.css': css`
+ @import 'tailwindcss/utilities';
+ @source '../../project-b/src/**/*.js';
+ @plugin '../plugin.js';
+ `,
+ 'project-a/src/index.js': js`
+ const className = "content-['a/src/index.js']"
+ module.exports = { className }
+ `,
+ 'project-b/src/index.js': js`
+ const className = "content-['b/src/index.js']"
+ module.exports = { className }
+ `,
+ },
+ },
+ async ({ root, fs, exec }) => {
+ await exec('pnpm postcss src/index.css --output dist/out.css', {
+ cwd: path.join(root, 'project-a'),
+ })
+
+ await fs.expectFileToContain('project-a/dist/out.css', [
+ candidate`underline`,
+ candidate`content-['a/src/index.js']`,
+ candidate`content-['b/src/index.js']`,
+ candidate`inverted:flex`,
+ candidate`hocus:underline`,
+ ])
+ },
+)
+
+test(
+ 'watch mode',
+ {
+ fs: {
+ 'package.json': json`{}`,
+ 'pnpm-workspace.yaml': yaml`
+ #
+ packages:
+ - project-a
+ `,
+ 'project-a/package.json': json`
+ {
+ "dependencies": {
+ "postcss": "^8",
+ "postcss-cli": "^10",
+ "tailwindcss": "workspace:^",
+ "@tailwindcss/postcss": "workspace:^"
+ }
+ }
+ `,
+ 'project-a/postcss.config.js': js`
+ module.exports = {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
+ }
+ `,
+ 'project-a/index.html': html`
+
+ `,
+ 'project-a/plugin.js': js`
+ module.exports = function ({ addVariant }) {
+ addVariant('inverted', '@media (inverted-colors: inverted)')
+ addVariant('hocus', ['&:focus', '&:hover'])
+ }
+ `,
+ 'project-a/src/index.css': css`
+ @import 'tailwindcss/utilities';
+ @source '../../project-b/src/**/*.js';
+ @plugin '../plugin.js';
+ `,
+ 'project-a/src/index.js': js`
+ const className = "content-['a/src/index.js']"
+ module.exports = { className }
+ `,
+ 'project-b/src/index.js': js`
+ const className = "content-['b/src/index.js']"
+ module.exports = { className }
+ `,
+ },
+ },
+ async ({ root, fs, spawn }) => {
+ let process = await spawn(
+ 'pnpm postcss src/index.css --output dist/out.css --watch --verbose',
+ { cwd: path.join(root, 'project-a') },
+ )
+ await process.onStderr((message) => message.includes('Waiting for file changes...'))
+
+ await fs.expectFileToContain('project-a/dist/out.css', [
+ candidate`underline`,
+ candidate`content-['a/src/index.js']`,
+ candidate`content-['b/src/index.js']`,
+ candidate`inverted:flex`,
+ candidate`hocus:underline`,
+ ])
+
+ await fs.write(
+ 'project-a/src/index.js',
+ js`
+ const className = "[.changed_&]:content-['project-a/src/index.js']"
+ module.exports = { className }
+ `,
+ )
+
+ await fs.expectFileToContain('project-a/dist/out.css', [
+ candidate`[.changed_&]:content-['project-a/src/index.js']`,
+ ])
+
+ await fs.write(
+ 'project-b/src/index.js',
+ js`
+ const className = "[.changed_&]:content-['project-b/src/index.js']"
+ module.exports = { className }
+ `,
+ )
+
+ await fs.expectFileToContain('project-a/dist/out.css', [
+ candidate`[.changed_&]:content-['project-b/src/index.js']`,
+ ])
+ },
+)
diff --git a/integrations/utils.ts b/integrations/utils.ts
index e601353b1..52ae3572a 100644
--- a/integrations/utils.ts
+++ b/integrations/utils.ts
@@ -6,14 +6,12 @@ import fs from 'node:fs/promises'
import net from 'node:net'
import { homedir, platform, tmpdir } from 'node:os'
import path from 'node:path'
-import { test as defaultTest } from 'vitest'
-
-export let css = dedent
-export let html = dedent
-export let ts = dedent
-export let json = dedent
+import { test as defaultTest, expect } from 'vitest'
const REPO_ROOT = path.join(__dirname, '..')
+const PUBLIC_PACKAGES = (await fs.readdir(path.join(REPO_ROOT, 'dist'))).map((name) =>
+ name.replace('tailwindcss-', '@tailwindcss/').replace('.tgz', ''),
+)
interface SpawnedProcess {
dispose: () => void
@@ -21,216 +19,267 @@ interface SpawnedProcess {
onStderr: (predicate: (message: string) => boolean) => Promise
}
+interface ChildProcessOptions {
+ cwd?: string
+}
+
interface TestConfig {
fs: {
[filePath: string]: string
}
}
interface TestContext {
- exec(command: string): Promise
- spawn(command: string): Promise
+ root: string
+ exec(command: string, options?: ChildProcessOptions): Promise
+ spawn(command: string, options?: ChildProcessOptions): Promise
getFreePort(): Promise
fs: {
write(filePath: string, content: string): Promise
+ read(filePath: string): Promise
glob(pattern: string): Promise<[string, string][]>
+ expectFileToContain(filePath: string, contents: string | string[]): Promise
}
}
type TestCallback = (context: TestContext) => Promise | void
type SpawnActor = { predicate: (message: string) => boolean; resolve: () => void }
+const TEST_TIMEOUT = 30000
+const ASSERTION_TIMEOUT = 5000
+
export function test(
name: string,
config: TestConfig,
testCallback: TestCallback,
{ only = false } = {},
) {
- return (only ? defaultTest.only : defaultTest)(name, { timeout: 30000 }, async (options) => {
- let root = await fs.mkdtemp(
- // On Windows CI, tmpdir returns a path containing a weird RUNNER~1 folder
- // that apparently causes the vite builds to not work.
- path.join(
- process.env.CI && platform() === 'win32' ? homedir() : tmpdir(),
- 'tailwind-integrations',
- ),
- )
+ return (only ? defaultTest.only : defaultTest)(
+ name,
+ { timeout: TEST_TIMEOUT },
+ async (options) => {
+ let root = await fs.mkdtemp(
+ // On Windows CI, tmpdir returns a path containing a weird RUNNER~1 folder
+ // that apparently causes the vite builds to not work.
+ path.join(
+ process.env.CI && platform() === 'win32' ? homedir() : tmpdir(),
+ 'tailwind-integrations',
+ ),
+ )
- async function write(filename: string, content: string): Promise {
- let full = path.join(root, filename)
+ async function write(filename: string, content: string): Promise {
+ let full = path.join(root, filename)
- if (filename.endsWith('package.json')) {
- content = overwriteVersionsInPackageJson(content)
- }
-
- // Ensure that files written on Windows use \r\n line ending
- if (platform() === 'win32') {
- content = content.replace(/\n/g, '\r\n')
- }
-
- let dir = path.dirname(full)
- await fs.mkdir(dir, { recursive: true })
- await fs.writeFile(full, content)
- }
-
- for (let [filename, content] of Object.entries(config.fs)) {
- await write(filename, content)
- }
-
- try {
- execSync('pnpm install', { cwd: root })
- } catch (error: any) {
- console.error(error.stdout.toString())
- console.error(error.stderr.toString())
- throw error
- }
-
- let disposables: (() => Promise)[] = []
- async function dispose() {
- await Promise.all(disposables.map((dispose) => dispose()))
- await fs.rm(root, { recursive: true, maxRetries: 3, force: true })
- }
- options.onTestFinished(dispose)
-
- let context = {
- async exec(command: string) {
- return execSync(command, { cwd: root }).toString()
- },
- async spawn(command: string) {
- let resolveDisposal: (() => void) | undefined
- let rejectDisposal: ((error: Error) => void) | undefined
- let disposePromise = new Promise((resolve, reject) => {
- resolveDisposal = resolve
- rejectDisposal = reject
- })
-
- let child = spawn(command, {
- cwd: root,
- shell: true,
- env: {
- ...process.env,
- },
- })
-
- function dispose() {
- child.kill()
-
- let timer = setTimeout(
- () => rejectDisposal?.(new Error(`spawned process (${command}) did not exit in time`)),
- 1000,
- )
- disposePromise.finally(() => clearTimeout(timer))
- return disposePromise
- }
- disposables.push(dispose)
- function onExit() {
- resolveDisposal?.()
+ if (filename.endsWith('package.json')) {
+ content = await overwriteVersionsInPackageJson(content)
}
- let stdoutMessages: string[] = []
- let stderrMessages: string[] = []
+ // Ensure that files written on Windows use \r\n line ending
+ if (platform() === 'win32') {
+ content = content.replace(/\n/g, '\r\n')
+ }
- let stdoutActors: SpawnActor[] = []
- let stderrActors: SpawnActor[] = []
+ let dir = path.dirname(full)
+ await fs.mkdir(dir, { recursive: true })
+ await fs.writeFile(full, content)
+ }
- function notifyNext(actors: SpawnActor[], messages: string[]) {
- if (actors.length <= 0) return
- let [next] = actors
+ for (let [filename, content] of Object.entries(config.fs)) {
+ await write(filename, content)
+ }
- for (let [idx, message] of messages.entries()) {
- if (next.predicate(message)) {
- messages.splice(0, idx + 1)
- let actorIdx = actors.indexOf(next)
- actors.splice(actorIdx, 1)
- next.resolve()
- break
+ try {
+ execSync('pnpm install', { cwd: root })
+ } catch (error: any) {
+ console.error(error.stdout.toString())
+ console.error(error.stderr.toString())
+ throw error
+ }
+
+ let disposables: (() => Promise)[] = []
+
+ async function dispose() {
+ await Promise.all(disposables.map((dispose) => dispose()))
+ try {
+ await fs.rm(root, { recursive: true, maxRetries: 5, force: true })
+ } catch (err) {
+ if (!process.env.CI) {
+ throw err
+ }
+ }
+ }
+
+ options.onTestFinished(dispose)
+
+ let context = {
+ root,
+ async exec(command: string, childProcessOptions: ChildProcessOptions = {}) {
+ return execSync(command, {
+ cwd: root,
+ stdio: 'pipe',
+ ...childProcessOptions,
+ }).toString()
+ },
+ async spawn(command: string, childProcessOptions: ChildProcessOptions = {}) {
+ let resolveDisposal: (() => void) | undefined
+ let rejectDisposal: ((error: Error) => void) | undefined
+ let disposePromise = new Promise((resolve, reject) => {
+ resolveDisposal = resolve
+ rejectDisposal = reject
+ })
+
+ let child = spawn(command, {
+ cwd: root,
+ shell: true,
+ env: {
+ ...process.env,
+ },
+ ...childProcessOptions,
+ })
+
+ function dispose() {
+ child.kill()
+
+ let timer = setTimeout(
+ () =>
+ rejectDisposal?.(new Error(`spawned process (${command}) did not exit in time`)),
+ ASSERTION_TIMEOUT,
+ )
+ disposePromise.finally(() => clearTimeout(timer))
+ return disposePromise
+ }
+ disposables.push(dispose)
+ function onExit() {
+ resolveDisposal?.()
+ }
+
+ let stdoutMessages: string[] = []
+ let stderrMessages: string[] = []
+
+ let stdoutActors: SpawnActor[] = []
+ let stderrActors: SpawnActor[] = []
+
+ function notifyNext(actors: SpawnActor[], messages: string[]) {
+ if (actors.length <= 0) return
+ let [next] = actors
+
+ for (let [idx, message] of messages.entries()) {
+ if (next.predicate(message)) {
+ messages.splice(0, idx + 1)
+ let actorIdx = actors.indexOf(next)
+ actors.splice(actorIdx, 1)
+ next.resolve()
+ break
+ }
}
}
- }
- child.stdout.on('data', (result) => {
- stdoutMessages.push(result.toString())
- notifyNext(stdoutActors, stdoutMessages)
- })
- child.stderr.on('data', (result) => {
- stderrMessages.push(result.toString())
- notifyNext(stderrActors, stderrMessages)
- })
- child.on('exit', onExit)
- child.on('error', (error) => {
- if (error.name !== 'AbortError') {
- throw error
- }
- })
+ let combined: ['stdout' | 'stderr', string][] = []
- options.onTestFailed(() => {
- stdoutMessages.map((message) => console.log(message))
- stderrMessages.map((message) => console.error(message))
- })
+ child.stdout.on('data', (result) => {
+ let content = result.toString()
+ combined.push(['stdout', content])
+ stdoutMessages.push(content)
+ notifyNext(stdoutActors, stdoutMessages)
+ })
+ child.stderr.on('data', (result) => {
+ let content = result.toString()
+ combined.push(['stderr', content])
+ stderrMessages.push(content)
+ notifyNext(stderrActors, stderrMessages)
+ })
+ child.on('exit', onExit)
+ child.on('error', (error) => {
+ if (error.name !== 'AbortError') {
+ throw error
+ }
+ })
- return {
- dispose,
- onStdout(predicate: (message: string) => boolean) {
- return new Promise((resolve) => {
- stdoutActors.push({ predicate, resolve })
- notifyNext(stdoutActors, stdoutMessages)
- })
- },
- onStderr(predicate: (message: string) => boolean) {
- return new Promise((resolve) => {
- stderrActors.push({ predicate, resolve })
- notifyNext(stderrActors, stderrMessages)
- })
- },
- }
- },
- async getFreePort(): Promise {
- return new Promise((resolve, reject) => {
- let server = net.createServer()
- server.listen(0, () => {
- let address = server.address()
- let port = address === null || typeof address === 'string' ? null : address.port
-
- server.close(() => {
- if (port === null) {
- reject(new Error(`Failed to get a free port: address is ${address}`))
+ options.onTestFailed(() => {
+ for (let [type, message] of combined) {
+ if (type === 'stdout') {
+ console.log(message)
} else {
- disposables.push(async () => {
- // Wait for 10ms in case the process was just killed
- await new Promise((resolve) => setTimeout(resolve, 10))
-
- // kill-port uses `lsof` on macOS which is expensive and can
- // block for multiple seconds. In order to avoid that for a
- // server that is no longer running, we check if the port is
- // still in use first.
- let isPortTaken = await testIfPortTaken(port)
- if (!isPortTaken) {
- return
- }
-
- await killPort(port)
- })
- resolve(port)
+ console.error(message)
}
+ }
+ })
+
+ return {
+ dispose,
+ onStdout(predicate: (message: string) => boolean) {
+ return new Promise((resolve) => {
+ stdoutActors.push({ predicate, resolve })
+ notifyNext(stdoutActors, stdoutMessages)
+ })
+ },
+ onStderr(predicate: (message: string) => boolean) {
+ return new Promise((resolve) => {
+ stderrActors.push({ predicate, resolve })
+ notifyNext(stderrActors, stderrMessages)
+ })
+ },
+ }
+ },
+ async getFreePort(): Promise {
+ return new Promise((resolve, reject) => {
+ let server = net.createServer()
+ server.listen(0, () => {
+ let address = server.address()
+ let port = address === null || typeof address === 'string' ? null : address.port
+
+ server.close(() => {
+ if (port === null) {
+ reject(new Error(`Failed to get a free port: address is ${address}`))
+ } else {
+ disposables.push(async () => {
+ // Wait for 10ms in case the process was just killed
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ // kill-port uses `lsof` on macOS which is expensive and can
+ // block for multiple seconds. In order to avoid that for a
+ // server that is no longer running, we check if the port is
+ // still in use first.
+ let isPortTaken = await testIfPortTaken(port)
+ if (!isPortTaken) {
+ return
+ }
+
+ await killPort(port)
+ })
+ resolve(port)
+ }
+ })
})
})
- })
- },
- fs: {
- write,
- async glob(pattern: string) {
- let files = await fastGlob(pattern, { cwd: root })
- return Promise.all(
- files.map(async (file) => {
- let content = await fs.readFile(path.join(root, file), 'utf8')
- return [file, content]
- }),
- )
},
- },
- } satisfies TestContext
+ fs: {
+ write,
+ read(filePath: string) {
+ return fs.readFile(path.resolve(root, filePath), 'utf8')
+ },
+ async glob(pattern: string) {
+ let files = await fastGlob(pattern, { cwd: root })
+ return Promise.all(
+ files.map(async (file) => {
+ let content = await fs.readFile(path.join(root, file), 'utf8')
+ return [file, content]
+ }),
+ )
+ },
+ async expectFileToContain(filePath, contents) {
+ return retryUntil(async () => {
+ let fileContent = await this.read(filePath)
+ for (let content of contents) {
+ expect(fileContent).toContain(content)
+ }
+ })
+ },
+ },
+ } satisfies TestContext
- await testCallback(context)
- })
+ await testCallback(context)
+ },
+ )
}
test.only = (name: string, config: TestConfig, testCallback: TestCallback) => {
return test(name, config, testCallback, { only: true })
@@ -242,18 +291,20 @@ function pkgToFilename(name: string) {
return `${name.replace('@', '').replace('/', '-')}.tgz`
}
-function overwriteVersionsInPackageJson(content: string): string {
+async function overwriteVersionsInPackageJson(content: string): Promise {
let json = JSON.parse(content)
// Resolve all workspace:^ versions to local tarballs
- ;['dependencies', 'devDependencies', 'peerDependencies'].forEach((key) => {
- let dependencies = json[key] || {}
- for (let dependency in dependencies) {
- if (dependencies[dependency] === 'workspace:^') {
- dependencies[dependency] = resolveVersion(dependency)
+ ;['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].forEach(
+ (key) => {
+ let dependencies = json[key] || {}
+ for (let dependency in dependencies) {
+ if (dependencies[dependency] === 'workspace:^') {
+ dependencies[dependency] = resolveVersion(dependency)
+ }
}
- }
- })
+ },
+ )
// Inject transitive dependency overwrite. This is necessary because
// @tailwindcss/vite internally depends on a specific version of
@@ -261,7 +312,9 @@ function overwriteVersionsInPackageJson(content: string): string {
// version.
json.pnpm ||= {}
json.pnpm.overrides ||= {}
- json.pnpm.overrides['@tailwindcss/oxide'] = resolveVersion('@tailwindcss/oxide')
+ for (let pkg of PUBLIC_PACKAGES) {
+ json.pnpm.overrides[pkg] = resolveVersion(pkg)
+ }
return JSON.stringify(json, null, 2)
}
@@ -293,3 +346,114 @@ function testIfPortTaken(port: number): Promise {
client.connect({ port: port, host: 'localhost' })
})
}
+
+export let css = dedent
+export let html = dedent
+export let ts = dedent
+export let js = dedent
+export let json = dedent
+export let yaml = dedent
+export let txt = dedent
+
+export function candidate(strings: TemplateStringsArray, ...values: any[]) {
+ let output: string[] = []
+ for (let i = 0; i < strings.length; i++) {
+ output.push(strings[i])
+ if (i < values.length) {
+ output.push(values[i])
+ }
+ }
+
+ return `.${escape(output.join('').trim())}`
+}
+
+// https://drafts.csswg.org/cssom/#serialize-an-identifier
+export function escape(value: string) {
+ if (arguments.length == 0) {
+ throw new TypeError('`CSS.escape` requires an argument.')
+ }
+ var string = String(value)
+ var length = string.length
+ var index = -1
+ var codeUnit
+ var result = ''
+ var firstCodeUnit = string.charCodeAt(0)
+
+ if (
+ // If the character is the first character and is a `-` (U+002D), and
+ // there is no second character, […]
+ length == 1 &&
+ firstCodeUnit == 0x002d
+ ) {
+ return '\\' + string
+ }
+
+ while (++index < length) {
+ codeUnit = string.charCodeAt(index)
+ // Note: there’s no need to special-case astral symbols, surrogate
+ // pairs, or lone surrogates.
+
+ // If the character is NULL (U+0000), then the REPLACEMENT CHARACTER
+ // (U+FFFD).
+ if (codeUnit == 0x0000) {
+ result += '\uFFFD'
+ continue
+ }
+
+ if (
+ // If the character is in the range [\1-\1F] (U+0001 to U+001F) or is
+ // U+007F, […]
+ (codeUnit >= 0x0001 && codeUnit <= 0x001f) ||
+ codeUnit == 0x007f ||
+ // If the character is the first character and is in the range [0-9]
+ // (U+0030 to U+0039), […]
+ (index == 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
+ // If the character is the second character and is in the range [0-9]
+ // (U+0030 to U+0039) and the first character is a `-` (U+002D), […]
+ (index == 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && firstCodeUnit == 0x002d)
+ ) {
+ // https://drafts.csswg.org/cssom/#escape-a-character-as-code-point
+ result += '\\' + codeUnit.toString(16) + ' '
+ continue
+ }
+
+ // If the character is not handled by one of the above rules and is
+ // greater than or equal to U+0080, is `-` (U+002D) or `_` (U+005F), or
+ // is in one of the ranges [0-9] (U+0030 to U+0039), [A-Z] (U+0041 to
+ // U+005A), or [a-z] (U+0061 to U+007A), […]
+ if (
+ codeUnit >= 0x0080 ||
+ codeUnit == 0x002d ||
+ codeUnit == 0x005f ||
+ (codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
+ (codeUnit >= 0x0041 && codeUnit <= 0x005a) ||
+ (codeUnit >= 0x0061 && codeUnit <= 0x007a)
+ ) {
+ // the character itself
+ result += string.charAt(index)
+ continue
+ }
+
+ // Otherwise, the escaped character.
+ // https://drafts.csswg.org/cssom/#escape-a-character
+ result += '\\' + string.charAt(index)
+ }
+ return result
+}
+
+async function retryUntil(
+ fn: () => Promise,
+ { timeout = ASSERTION_TIMEOUT, delay = 5 }: { timeout?: number; delay?: number } = {},
+) {
+ let end = Date.now() + timeout
+ let error: any
+ while (Date.now() < end) {
+ try {
+ return await fn()
+ } catch (err) {
+ error = err
+ await new Promise((resolve) => setTimeout(resolve, delay))
+ }
+ }
+ throw error
+}
diff --git a/integrations/vite/index.test.ts b/integrations/vite/index.test.ts
index 56552b0ad..ab636eb46 100644
--- a/integrations/vite/index.test.ts
+++ b/integrations/vite/index.test.ts
@@ -1,5 +1,6 @@
+import path from 'node:path'
import { expect } from 'vitest'
-import { css, html, json, stripTailwindComment, test, ts } from '../utils'
+import { candidate, css, html, js, json, test, ts, yaml } from '../utils'
async function fetchCSS(pathname: string, port: number) {
// We need to fetch the main index.html file to populate the list of
@@ -17,10 +18,16 @@ async function fetchCSS(pathname: string, port: number) {
}
test(
- 'works with production builds',
+ 'production build',
{
fs: {
- 'package.json': json`
+ 'package.json': json`{}`,
+ 'pnpm-workspace.yaml': yaml`
+ #
+ packages:
+ - project-a
+ `,
+ 'project-a/package.json': json`
{
"type": "module",
"dependencies": {
@@ -32,7 +39,7 @@ test(
}
}
`,
- 'vite.config.ts': ts`
+ 'project-a/vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
@@ -41,7 +48,7 @@ test(
plugins: [tailwindcss()],
})
`,
- 'index.html': html`
+ 'project-a/index.html': html`
@@ -49,37 +56,43 @@ test(
Hello, world!
`,
- 'src/index.css': css`
- @import 'tailwindcss/theme' reference;
+ 'project-a/src/index.css': css`
+ @import 'tailwindcss/theme' theme(reference);
@import 'tailwindcss/utilities';
+ @source '../../project-b/src/**/*.js';
+ `,
+ 'project-b/src/index.js': js`
+ const className = "content-['project-b/src/index.js']"
+ module.exports = { className }
`,
},
},
- async ({ fs, exec }) => {
- await exec('pnpm vite build')
+ async ({ root, fs, exec }) => {
+ await exec('pnpm vite build', { cwd: path.join(root, 'project-a') })
- let files = await fs.glob('dist/**/*.css')
+ let files = await fs.glob('project-a/dist/**/*.css')
expect(files).toHaveLength(1)
- let [, content] = files[0]
- expect(stripTailwindComment(content)).toMatchInlineSnapshot(
- `
- ".m-2 {
- margin: var(--spacing-2, .5rem);
- }
+ let [filename] = files[0]
- .underline {
- text-decoration-line: underline;
- }"
- `,
- )
+ await fs.expectFileToContain(filename, [
+ candidate`underline`,
+ candidate`m-2`,
+ candidate`content-['project-b/src/index.js']`,
+ ])
},
)
test(
- 'works with dev builds and live reloads',
+ 'dev mode',
{
fs: {
- 'package.json': json`
+ 'package.json': json`{}`,
+ 'pnpm-workspace.yaml': yaml`
+ #
+ packages:
+ - project-a
+ `,
+ 'project-a/package.json': json`
{
"type": "module",
"dependencies": {
@@ -91,7 +104,7 @@ test(
}
}
`,
- 'vite.config.ts': ts`
+ 'project-a/vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
@@ -100,7 +113,7 @@ test(
plugins: [tailwindcss()],
})
`,
- 'index.html': html`
+ 'project-a/index.html': html`
@@ -108,29 +121,30 @@ test(
Hello, world!
`,
- 'src/index.css': css`
- @import 'tailwindcss/theme' reference;
+ 'project-a/src/index.css': css`
+ @import 'tailwindcss/theme' theme(reference);
@import 'tailwindcss/utilities';
+ @source '../../project-b/src/**/*.js';
+ `,
+ 'project-b/src/index.js': js`
+ const className = "content-['project-b/src/index.js']"
+ module.exports = { className }
`,
},
},
- async ({ spawn, getFreePort, fs }) => {
+ async ({ root, spawn, getFreePort, fs }) => {
let port = await getFreePort()
- let process = await spawn(`pnpm vite dev --port ${port}`)
+ let process = await spawn(`pnpm vite dev --port ${port}`, {
+ cwd: path.join(root, 'project-a'),
+ })
await process.onStdout((message) => message.includes('ready in'))
let css = await fetchCSS('/src/index.css', port)
- expect(stripTailwindComment(css)).toMatchInlineSnapshot(
- `
- ".underline {
- text-decoration-line: underline;
- }"
- `,
- )
+ expect(css).toContain(candidate`underline`)
await fs.write(
- 'index.html',
+ 'project-a/index.html',
html`
@@ -143,15 +157,18 @@ test(
await process.onStdout((message) => message.includes('page reload'))
css = await fetchCSS('/src/index.css', port)
- expect(stripTailwindComment(css)).toMatchInlineSnapshot(
- `
- ".m-2 {
- margin: var(--spacing-2, 0.5rem);
- }
- .underline {
- text-decoration-line: underline;
- }"
- `,
+ expect(css).toContain(candidate`m-2`)
+
+ await fs.write(
+ 'project-b/src/index.js',
+ js`
+ const className = "[.changed_&]:content-['project-b/src/index.js']"
+ module.exports = { className }
+ `,
)
+ await process.onStdout((message) => message.includes('page reload'))
+
+ css = await fetchCSS('/src/index.css', port)
+ expect(css).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`)
},
)
diff --git a/packages/@tailwindcss-cli/package.json b/packages/@tailwindcss-cli/package.json
index 53344e308..0c77e1bba 100644
--- a/packages/@tailwindcss-cli/package.json
+++ b/packages/@tailwindcss-cli/package.json
@@ -12,7 +12,7 @@
"homepage": "https://tailwindcss.com",
"scripts": {
"lint": "tsc --noEmit",
- "build": "tsup-node ./src/index.ts --format esm --minify --clean",
+ "build": "tsup-node",
"dev": "pnpm run build -- --watch"
},
"bin": {
@@ -39,6 +39,7 @@
"tailwindcss": "workspace:^"
},
"devDependencies": {
- "@types/postcss-import": "^14.0.3"
+ "@types/postcss-import": "^14.0.3",
+ "internal-postcss-fix-relative-paths": "workspace:^"
}
}
diff --git a/packages/@tailwindcss-cli/src/commands/build/index.ts b/packages/@tailwindcss-cli/src/commands/build/index.ts
index 62084ec45..33202859d 100644
--- a/packages/@tailwindcss-cli/src/commands/build/index.ts
+++ b/packages/@tailwindcss-cli/src/commands/build/index.ts
@@ -1,6 +1,8 @@
import watcher from '@parcel/watcher'
-import { IO, Parsing, scanDir, scanFiles, type ChangedContent } from '@tailwindcss/oxide'
+import { clearCache, scanDir, type ChangedContent } from '@tailwindcss/oxide'
+import fixRelativePathsPlugin from 'internal-postcss-fix-relative-paths'
import { Features, transform } from 'lightningcss'
+import { createRequire } from 'module'
import { existsSync } from 'node:fs'
import fs from 'node:fs/promises'
import path from 'node:path'
@@ -8,6 +10,7 @@ import postcss from 'postcss'
import atImport from 'postcss-import'
import * as tailwindcss from 'tailwindcss'
import type { Arg, Result } from '../../utils/args'
+import { Disposables } from '../../utils/disposables'
import {
eprintln,
formatDuration,
@@ -18,6 +21,7 @@ import {
} from '../../utils/renderer'
import { resolve } from '../../utils/resolve'
import { drainStdin, outputFile } from './utils'
+const require = createRequire(import.meta.url)
const css = String.raw
@@ -79,7 +83,6 @@ export async function handle(args: Result>) {
}
let start = process.hrtime.bigint()
- let { candidates } = scanDir({ base })
// Resolve the input
let [input, cssImportPaths] = await handleImports(
@@ -125,14 +128,13 @@ export async function handle(args: Result>) {
}
let inputFile = args['--input'] && args['--input'] !== '-' ? args['--input'] : process.cwd()
-
- let basePath = path.dirname(path.resolve(inputFile))
+ let inputBasePath = path.dirname(path.resolve(inputFile))
function compile(css: string) {
return tailwindcss.compile(css, {
loadPlugin: (pluginPath) => {
if (pluginPath[0] === '.') {
- return require(path.resolve(basePath, pluginPath))
+ return require(path.resolve(inputBasePath, pluginPath))
}
return require(pluginPath)
@@ -141,100 +143,119 @@ export async function handle(args: Result>) {
}
// Compile the input
- let { build } = compile(input)
-
- await write(build(candidates), args)
-
- let end = process.hrtime.bigint()
- eprintln(header())
- eprintln()
- eprintln(`Done in ${formatDuration(end - start)}`)
+ let compiler = compile(input)
+ let scanDirResult = scanDir({
+ base, // Root directory, mainly used for auto content detection
+ sources: compiler.globs.map((pattern) => ({
+ base: inputBasePath, // Globs are relative to the input.css file
+ pattern,
+ })),
+ })
// Watch for changes
if (args['--watch']) {
- await watcher.subscribe(base, async (err, events) => {
- if (err) {
- console.error(err)
- return
- }
+ let cleanupWatchers = await createWatchers(
+ watchDirectories(base, scanDirResult),
+ async function handle(files) {
+ try {
+ // If the only change happened to the output file, then we don't want to
+ // trigger a rebuild because that will result in an infinite loop.
+ if (files.length === 1 && files[0] === args['--output']) return
- try {
- // If the only change happened to the output file, then we don't want to
- // trigger a rebuild because that will result in an infinite loop.
- if (events.length === 1 && events[0].path === args['--output']) return
+ let changedFiles: ChangedContent[] = []
+ let rebuildStrategy: 'incremental' | 'full' = 'incremental'
- let changedFiles: ChangedContent[] = []
- let rebuildStrategy: 'incremental' | 'full' = 'incremental'
+ for (let file of files) {
+ // If one of the changed files is related to the input CSS files, then
+ // we need to do a full rebuild because the theme might have changed.
+ if (cssImportPaths.includes(file)) {
+ rebuildStrategy = 'full'
- for (let event of events) {
- // Track new and updated files for incremental rebuilds.
- if (event.type === 'create' || event.type === 'update') {
+ // No need to check the rest of the events, because we already know we
+ // need to do a full rebuild.
+ break
+ }
+
+ // Track new and updated files for incremental rebuilds.
changedFiles.push({
- file: event.path,
- extension: path.extname(event.path).slice(1),
+ file,
+ extension: path.extname(file).slice(1),
} satisfies ChangedContent)
}
- // If one of the changed files is related to the input CSS files, then
- // we need to do a full rebuild because the theme might have changed.
- if (cssImportPaths.includes(event.path)) {
- rebuildStrategy = 'full'
+ // Re-compile the input
+ let start = process.hrtime.bigint()
- // No need to check the rest of the events, because we already know we
- // need to do a full rebuild.
- break
+ // Track the compiled CSS
+ let compiledCss = ''
+
+ // Scan the entire `base` directory for full rebuilds.
+ if (rebuildStrategy === 'full') {
+ // Clear all watchers
+ cleanupWatchers()
+
+ // Clear cached candidates
+ clearCache()
+
+ // Collect the new `input` and `cssImportPaths`.
+ ;[input, cssImportPaths] = await handleImports(
+ args['--input']
+ ? await fs.readFile(args['--input'], 'utf-8')
+ : css`
+ @import '${resolve('tailwindcss/index.css')}';
+ `,
+ args['--input'] ?? base,
+ )
+
+ // Create a new compiler, given the new `input`
+ compiler = compile(input)
+
+ // Re-scan the directory to get the new `candidates`
+ scanDirResult = scanDir({
+ base, // Root directory, mainly used for auto content detection
+ sources: compiler.globs.map((pattern) => ({
+ base: inputBasePath, // Globs are relative to the input.css file
+ pattern,
+ })),
+ })
+
+ // Setup new watchers
+ cleanupWatchers = await createWatchers(watchDirectories(base, scanDirResult), handle)
+
+ // Re-compile the CSS
+ compiledCss = compiler.build(scanDirResult.candidates)
+ }
+
+ // Scan changed files only for incremental rebuilds.
+ else if (rebuildStrategy === 'incremental') {
+ let candidates = scanDirResult.scanFiles(changedFiles)
+
+ // No candidates found which means we don't need to rebuild. This can
+ // happen if a file is detected but doesn't match any of the globs.
+ if (candidates.length === 0) return
+
+ compiledCss = compiler.build(candidates)
+ }
+
+ await write(compiledCss, args)
+
+ let end = process.hrtime.bigint()
+ eprintln(`Done in ${formatDuration(end - start)}`)
+ } catch (err) {
+ // Catch any errors and print them to stderr, but don't exit the process
+ // and keep watching.
+ if (err instanceof Error) {
+ eprintln(err.toString())
}
}
-
- // Re-compile the input
- let start = process.hrtime.bigint()
-
- // Track the compiled CSS
- let compiledCss = ''
-
- // Scan the entire `base` directory for full rebuilds.
- if (rebuildStrategy === 'full') {
- // Re-scan the directory to get the new `candidates`.
- candidates = scanDir({ base }).candidates
-
- // Collect the new `input` and `cssImportPaths`.
- ;[input, cssImportPaths] = await handleImports(
- args['--input']
- ? await fs.readFile(args['--input'], 'utf-8')
- : css`
- @import '${resolve('tailwindcss/index.css')}';
- `,
- args['--input'] ?? base,
- )
-
- build = compile(input).build
- compiledCss = build(candidates)
- }
-
- // Scan changed files only for incremental rebuilds.
- else if (rebuildStrategy === 'incremental') {
- let newCandidates = scanFiles(changedFiles, IO.Sequential | Parsing.Sequential)
-
- compiledCss = build(newCandidates)
- }
-
- await write(compiledCss, args)
-
- let end = process.hrtime.bigint()
- eprintln(`Done in ${formatDuration(end - start)}`)
- } catch (err) {
- // Catch any errors and print them to stderr, but don't exit the process
- // and keep watching.
- if (err instanceof Error) {
- eprintln(err.toString())
- }
- }
- })
+ },
+ )
// Abort the watcher if `stdin` is closed to avoid zombie processes. You can
// disable this behavior with `--watch=always`.
if (args['--watch'] !== 'always') {
process.stdin.on('end', () => {
+ cleanupWatchers()
process.exit(0)
})
}
@@ -242,6 +263,98 @@ export async function handle(args: Result>) {
// Keep the process running
process.stdin.resume()
}
+
+ await write(compiler.build(scanDirResult.candidates), args)
+
+ let end = process.hrtime.bigint()
+ eprintln(header())
+ eprintln()
+ eprintln(`Done in ${formatDuration(end - start)}`)
+}
+
+function watchDirectories(base: string, scanDirResult: ReturnType) {
+ return [base].concat(
+ scanDirResult.globs.flatMap((globEntry) => {
+ // We don't want a watcher for negated globs.
+ if (globEntry.pattern[0] === '!') return []
+
+ // We don't want a watcher for nested directories, these will be covered
+ // by the `base` directory already.
+ if (globEntry.base.startsWith(base)) return []
+
+ return globEntry.base
+ }),
+ )
+}
+
+async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
+ // Track all Parcel watchers for each glob.
+ //
+ // When we encounter a change in a CSS file, we need to setup new watchers and
+ // we want to cleanup the old ones we captured here.
+ let watchers = new Disposables()
+
+ // Track all files that were added or changed.
+ let files = new Set()
+
+ // Keep track of the debounce queue to avoid multiple rebuilds.
+ let debounceQueue = new Disposables()
+
+ // A changed file can be watched by multiple watchers, but we only want to
+ // handle the file once. We debounce the handle function with the collected
+ // files to handle them in a single batch and to avoid multiple rebuilds.
+ function enqueueCallback() {
+ // Dispose all existing macrotask.
+ debounceQueue.dispose()
+
+ // Setup a new macrotask to handle the files in batch.
+ debounceQueue.queueMacrotask(() => {
+ cb(Array.from(files))
+ files.clear()
+ })
+ }
+
+ // Setup a watcher for every directory.
+ for (let dir of dirs) {
+ let { unsubscribe } = await watcher.subscribe(dir, async (err, events) => {
+ // Whenever an error occurs we want to let the user know about it but we
+ // want to keep watching for changes.
+ if (err) {
+ console.error(err)
+ return
+ }
+
+ await Promise.all(
+ events.map(async (event) => {
+ // We currently don't handle deleted files because it doesn't influence
+ // the CSS output. This is because we currently keep all scanned
+ // candidates in a cache for performance reasons.
+ if (event.type === 'delete') return
+
+ // Ignore directory changes. We only care about file changes
+ let stats = await fs.lstat(event.path)
+ if (stats.isDirectory()) {
+ return
+ }
+
+ // Track the changed file.
+ files.add(event.path)
+ }),
+ )
+
+ // Handle the tracked files at some point in the future.
+ enqueueCallback()
+ })
+
+ // Ensure we cleanup the watcher when we're done.
+ watchers.add(unsubscribe)
+ }
+
+ // Cleanup
+ return () => {
+ watchers.dispose()
+ debounceQueue.dispose()
+ }
}
function handleImports(
@@ -259,6 +372,7 @@ function handleImports(
return postcss()
.use(atImport())
+ .use(fixRelativePathsPlugin())
.process(input, { from: file })
.then((result) => [
result.css,
diff --git a/packages/@tailwindcss-cli/src/utils/disposables.ts b/packages/@tailwindcss-cli/src/utils/disposables.ts
new file mode 100644
index 000000000..cb0d982c1
--- /dev/null
+++ b/packages/@tailwindcss-cli/src/utils/disposables.ts
@@ -0,0 +1,45 @@
+/**
+ * Disposables allow you to manage resources that can be cleaned up. Each helper
+ * function returns a dispose function to clean up the resource.
+ *
+ * The `dispose` method can be called to clean up all resources at once.
+ */
+export class Disposables {
+ // Track all disposables
+ #disposables = new Set([])
+
+ /**
+ * Enqueue a callback in the macrotasks queue.
+ */
+ queueMacrotask(cb: () => void) {
+ let timer = setTimeout(cb, 0)
+
+ return this.add(() => {
+ clearTimeout(timer)
+ })
+ }
+
+ /**
+ * General purpose disposable function that can be cleaned up.
+ */
+ add(dispose: () => void) {
+ this.#disposables.add(dispose)
+
+ return () => {
+ this.#disposables.delete(dispose)
+
+ dispose()
+ }
+ }
+
+ /**
+ * Dispose all disposables at once.
+ */
+ dispose() {
+ for (let dispose of this.#disposables) {
+ dispose()
+ }
+
+ this.#disposables.clear()
+ }
+}
diff --git a/packages/@tailwindcss-cli/tsup.config.ts b/packages/@tailwindcss-cli/tsup.config.ts
new file mode 100644
index 000000000..236281270
--- /dev/null
+++ b/packages/@tailwindcss-cli/tsup.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'tsup'
+
+export default defineConfig({
+ format: ['esm'],
+ clean: true,
+ minify: true,
+ entry: ['src/index.ts'],
+ noExternal: ['internal-postcss-fix-relative-paths'],
+})
diff --git a/packages/@tailwindcss-postcss/package.json b/packages/@tailwindcss-postcss/package.json
index a5cc2100e..f287d2f6c 100644
--- a/packages/@tailwindcss-postcss/package.json
+++ b/packages/@tailwindcss-postcss/package.json
@@ -12,7 +12,7 @@
"homepage": "https://tailwindcss.com",
"scripts": {
"lint": "tsc --noEmit",
- "build": "tsup-node ./src/index.ts --format cjs,esm --dts --cjsInterop --splitting --minify --clean",
+ "build": "tsup-node",
"dev": "pnpm run build -- --watch"
},
"files": [
@@ -39,6 +39,7 @@
"@types/node": "catalog:",
"@types/postcss-import": "^14.0.3",
"postcss": "8.4.24",
- "internal-example-plugin": "workspace:*"
+ "internal-example-plugin": "workspace:*",
+ "internal-postcss-fix-relative-paths": "workspace:^"
}
}
diff --git a/packages/@tailwindcss-postcss/src/fixtures/example-project/src/relative-import.css b/packages/@tailwindcss-postcss/src/fixtures/example-project/src/relative-import.css
new file mode 100644
index 000000000..48a30ab4d
--- /dev/null
+++ b/packages/@tailwindcss-postcss/src/fixtures/example-project/src/relative-import.css
@@ -0,0 +1 @@
+@plugin '../plugin.js';
diff --git a/packages/@tailwindcss-postcss/src/index.test.ts b/packages/@tailwindcss-postcss/src/index.test.ts
index 7aeae5c6c..ce4699586 100644
--- a/packages/@tailwindcss-postcss/src/index.test.ts
+++ b/packages/@tailwindcss-postcss/src/index.test.ts
@@ -13,7 +13,7 @@ const INPUT_CSS_PATH = `${__dirname}/fixtures/example-project/input.css`
const css = String.raw
beforeEach(async () => {
- const { clearCache } = await import('@tailwindcss/oxide')
+ let { clearCache } = await import('@tailwindcss/oxide')
clearCache()
})
@@ -144,7 +144,7 @@ describe('plugins', () => {
let result = await processor.process(
css`
@import 'tailwindcss/utilities';
- @plugin 'internal-example-plugin';
+ @plugin './plugin.js';
`,
{ from: INPUT_CSS_PATH },
)
@@ -166,6 +166,36 @@ describe('plugins', () => {
`)
})
+ test('local CJS plugin from `@import`-ed file', async () => {
+ let processor = postcss([
+ tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }),
+ ])
+
+ let result = await processor.process(
+ css`
+ @import 'tailwindcss/utilities';
+ @import '../example-project/src/relative-import.css';
+ `,
+ { from: `${__dirname}/fixtures/another-project/input.css` },
+ )
+
+ expect(result.css.trim()).toMatchInlineSnapshot(`
+ ".underline {
+ text-decoration-line: underline;
+ }
+
+ @media (inverted-colors: inverted) {
+ .inverted\\:flex {
+ display: flex;
+ }
+ }
+
+ .hocus\\:underline:focus, .hocus\\:underline:hover {
+ text-decoration-line: underline;
+ }"
+ `)
+ })
+
test('published CJS plugin', async () => {
let processor = postcss([
tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }),
diff --git a/packages/@tailwindcss-postcss/src/index.ts b/packages/@tailwindcss-postcss/src/index.ts
index 30a0828a2..700250527 100644
--- a/packages/@tailwindcss-postcss/src/index.ts
+++ b/packages/@tailwindcss-postcss/src/index.ts
@@ -1,8 +1,9 @@
import { scanDir } from '@tailwindcss/oxide'
import fs from 'fs'
+import fixRelativePathsPlugin from 'internal-postcss-fix-relative-paths'
import { Features, transform } from 'lightningcss'
import path from 'path'
-import postcss, { type AcceptedPlugin, type PluginCreator } from 'postcss'
+import postcss, { AtRule, type AcceptedPlugin, type PluginCreator } from 'postcss'
import postcssImport from 'postcss-import'
import { compile } from 'tailwindcss'
@@ -42,120 +43,137 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
let cache = new DefaultMap(() => {
return {
mtimes: new Map(),
- build: null as null | ReturnType['build'],
+ compiler: null as null | ReturnType,
css: '',
optimizedCss: '',
}
})
+ let hasApply: boolean, hasTailwind: boolean
+
return {
postcssPlugin: '@tailwindcss/postcss',
plugins: [
// We need to run `postcss-import` first to handle `@import` rules.
postcssImport(),
+ fixRelativePathsPlugin(),
- (root, result) => {
- let inputFile = result.opts.from ?? ''
- let context = cache.get(inputFile)
-
- let rebuildStrategy: 'full' | 'incremental' = 'incremental'
-
- // Track file modification times to CSS files
- {
- let files = result.messages.flatMap((message) => {
- if (message.type !== 'dependency') return []
- return message.file
- })
- files.push(inputFile)
- for (let file of files) {
- let changedTime = fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? null
- if (changedTime === null) {
- if (file === inputFile) {
- rebuildStrategy = 'full'
- }
- continue
- }
-
- let prevTime = context.mtimes.get(file)
- if (prevTime === changedTime) continue
-
- rebuildStrategy = 'full'
- context.mtimes.set(file, changedTime)
- }
- }
-
- let hasApply = false
- let hasTailwind = false
-
- root.walkAtRules((rule) => {
+ {
+ postcssPlugin: 'tailwindcss',
+ Once() {
+ // Reset some state between builds
+ hasApply = false
+ hasTailwind = false
+ },
+ AtRule(rule: AtRule) {
if (rule.name === 'apply') {
hasApply = true
} else if (rule.name === 'tailwind') {
hasApply = true
hasTailwind = true
- // If we've found `@tailwind` then we already
- // know we have to run a "full" build
- return false
}
- })
+ },
+ OnceExit(root, { result }) {
+ let inputFile = result.opts.from ?? ''
+ let context = cache.get(inputFile)
+ let inputBasePath = path.dirname(path.resolve(inputFile))
- // Do nothing if neither `@tailwind` nor `@apply` is used
- if (!hasTailwind && !hasApply) return
+ function createCompiler() {
+ return compile(root.toString(), {
+ loadPlugin: (pluginPath) => {
+ if (pluginPath[0] === '.') {
+ return require(path.resolve(inputBasePath, pluginPath))
+ }
- let css = ''
+ return require(pluginPath)
+ },
+ })
+ }
- // Look for candidates used to generate the CSS
- let { candidates, files, globs } = scanDir({ base, globs: true })
+ // Setup the compiler if it doesn't exist yet. This way we can
+ // guarantee a `build()` function is available.
+ context.compiler ??= createCompiler()
- // Add all found files as direct dependencies
- for (let file of files) {
- result.messages.push({
- type: 'dependency',
- plugin: '@tailwindcss/postcss',
- file,
- parent: result.opts.from,
- })
- }
+ let rebuildStrategy: 'full' | 'incremental' = 'incremental'
- // Register dependencies so changes in `base` cause a rebuild while
- // giving tools like Vite or Parcel a glob that can be used to limit
- // the files that cause a rebuild to only those that match it.
- for (let { base, glob } of globs) {
- result.messages.push({
- type: 'dir-dependency',
- plugin: '@tailwindcss/postcss',
- dir: base,
- glob,
- parent: result.opts.from,
- })
- }
-
- if (rebuildStrategy === 'full') {
- let basePath = path.dirname(path.resolve(inputFile))
- let { build } = compile(root.toString(), {
- loadPlugin: (pluginPath) => {
- if (pluginPath[0] === '.') {
- return require(path.resolve(basePath, pluginPath))
+ // Track file modification times to CSS files
+ {
+ let files = result.messages.flatMap((message) => {
+ if (message.type !== 'dependency') return []
+ return message.file
+ })
+ files.push(inputFile)
+ for (let file of files) {
+ let changedTime = fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? null
+ if (changedTime === null) {
+ if (file === inputFile) {
+ rebuildStrategy = 'full'
+ }
+ continue
}
- return require(pluginPath)
- },
- })
- context.build = build
- css = build(hasTailwind ? candidates : [])
- } else if (rebuildStrategy === 'incremental') {
- css = context.build!(candidates)
- }
+ let prevTime = context.mtimes.get(file)
+ if (prevTime === changedTime) continue
- // Replace CSS
- if (css !== context.css && optimize) {
- context.optimizedCss = optimizeCss(css, {
- minify: typeof optimize === 'object' ? optimize.minify : true,
+ rebuildStrategy = 'full'
+ context.mtimes.set(file, changedTime)
+ }
+ }
+
+ // Do nothing if neither `@tailwind` nor `@apply` is used
+ if (!hasTailwind && !hasApply) return
+
+ let css = ''
+
+ // Look for candidates used to generate the CSS
+ let scanDirResult = scanDir({
+ base, // Root directory, mainly used for auto content detection
+ sources: context.compiler.globs.map((pattern) => ({
+ base: inputBasePath, // Globs are relative to the input.css file
+ pattern,
+ })),
})
- }
- context.css = css
- root.removeAll()
- root.append(postcss.parse(optimize ? context.optimizedCss : context.css, result.opts))
+
+ // Add all found files as direct dependencies
+ for (let file of scanDirResult.files) {
+ result.messages.push({
+ type: 'dependency',
+ plugin: '@tailwindcss/postcss',
+ file,
+ parent: result.opts.from,
+ })
+ }
+
+ // Register dependencies so changes in `base` cause a rebuild while
+ // giving tools like Vite or Parcel a glob that can be used to limit
+ // the files that cause a rebuild to only those that match it.
+ for (let { base, pattern } of scanDirResult.globs) {
+ result.messages.push({
+ type: 'dir-dependency',
+ plugin: '@tailwindcss/postcss',
+ dir: base,
+ glob: pattern,
+ parent: result.opts.from,
+ })
+ }
+
+ if (rebuildStrategy === 'full') {
+ context.compiler = createCompiler()
+ css = context.compiler.build(hasTailwind ? scanDirResult.candidates : [])
+ } else if (rebuildStrategy === 'incremental') {
+ css = context.compiler.build!(scanDirResult.candidates)
+ }
+
+ // Replace CSS
+ if (css !== context.css && optimize) {
+ context.optimizedCss = optimizeCss(css, {
+ minify: typeof optimize === 'object' ? optimize.minify : true,
+ })
+ }
+ context.css = css
+ root.removeAll()
+ root.append(postcss.parse(optimize ? context.optimizedCss : context.css, result.opts))
+ },
},
],
}
diff --git a/packages/@tailwindcss-postcss/tsup.config.ts b/packages/@tailwindcss-postcss/tsup.config.ts
new file mode 100644
index 000000000..08ec8092a
--- /dev/null
+++ b/packages/@tailwindcss-postcss/tsup.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from 'tsup'
+
+export default defineConfig({
+ format: ['esm', 'cjs'],
+ clean: true,
+ minify: true,
+ splitting: true,
+ cjsInterop: true,
+ dts: true,
+ entry: ['src/index.ts'],
+ noExternal: ['internal-postcss-fix-relative-paths'],
+})
diff --git a/packages/@tailwindcss-vite/package.json b/packages/@tailwindcss-vite/package.json
index 020a557c6..acbf1a152 100644
--- a/packages/@tailwindcss-vite/package.json
+++ b/packages/@tailwindcss-vite/package.json
@@ -11,7 +11,7 @@
"bugs": "https://github.com/tailwindlabs/tailwindcss/issues",
"homepage": "https://tailwindcss.com",
"scripts": {
- "build": "tsup-node ./src/index.ts --format esm --dts --minify --clean",
+ "build": "tsup-node",
"dev": "pnpm run build -- --watch"
},
"files": [
@@ -30,11 +30,13 @@
"dependencies": {
"@tailwindcss/oxide": "workspace:^",
"lightningcss": "^1.25.1",
+ "postcss-load-config": "^6.0.1",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"@types/node": "catalog:",
- "vite": "catalog:"
+ "vite": "catalog:",
+ "internal-postcss-fix-relative-paths": "workspace:^"
},
"peerDependencies": {
"vite": "^5.2.0"
diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts
index d8a0924c5..e0bd810c0 100644
--- a/packages/@tailwindcss-vite/src/index.ts
+++ b/packages/@tailwindcss-vite/src/index.ts
@@ -1,12 +1,18 @@
-import { IO, Parsing, scanFiles } from '@tailwindcss/oxide'
+import { scanDir } from '@tailwindcss/oxide'
+import fixRelativePathsPlugin, { normalizePath } from 'internal-postcss-fix-relative-paths'
import { Features, transform } from 'lightningcss'
import path from 'path'
+import postcssrc from 'postcss-load-config'
import { compile } from 'tailwindcss'
-import type { Plugin, Rollup, Update, ViteDevServer } from 'vite'
+import type { Plugin, ResolvedConfig, Rollup, Update, ViteDevServer } from 'vite'
export default function tailwindcss(): Plugin[] {
let server: ViteDevServer | null = null
+ let config: ResolvedConfig | null = null
let candidates = new Set()
+ let scanDirResult: ReturnType | null = null
+ let changedContent: { content: string; extension: string }[] = []
+
// In serve mode this is treated as a set — the content doesn't matter.
// In build mode, we store file contents to use them in renderChunk.
let cssModules: Record<
@@ -56,11 +62,14 @@ export default function tailwindcss(): Plugin[] {
function scan(src: string, extension: string) {
let updated = false
+
+ if (scanDirResult === null) {
+ changedContent.push({ content: src, extension })
+ return updated
+ }
+
// Parse all candidates given the resolved files
- for (let candidate of scanFiles(
- [{ content: src, extension }],
- IO.Sequential | Parsing.Sequential,
- )) {
+ for (let candidate of scanDirResult?.scanFiles([{ content: src, extension }]) ?? []) {
// On an initial or full build, updated becomes true immediately so we
// won't be making extra checks.
if (!updated) {
@@ -72,22 +81,62 @@ export default function tailwindcss(): Plugin[] {
return updated
}
- function generateCss(css: string, inputPath: string) {
- let basePath = path.dirname(path.resolve(inputPath))
-
- return compile(css, {
+ function generateCss(css: string, inputPath: string, addWatchFile: (file: string) => void) {
+ let inputBasePath = path.dirname(path.resolve(inputPath))
+ let { build, globs } = compile(css, {
loadPlugin: (pluginPath) => {
if (pluginPath[0] === '.') {
- return require(path.resolve(basePath, pluginPath))
+ return require(path.resolve(inputBasePath, pluginPath))
}
return require(pluginPath)
},
- }).build(Array.from(candidates))
+ })
+
+ scanDirResult = scanDir({
+ sources: globs.map((pattern) => ({
+ base: inputBasePath, // Globs are relative to the input.css file
+ pattern,
+ })),
+ })
+
+ if (changedContent.length > 0) {
+ scanDirResult.candidates = scanDirResult.scanFiles(changedContent.splice(0))
+ }
+
+ for (let candidate of scanDirResult.candidates) {
+ candidates.add(candidate)
+ }
+
+ // Watch individual files
+ for (let file of scanDirResult.files) {
+ addWatchFile(file)
+ }
+
+ // Watch globs
+ for (let glob of scanDirResult.globs) {
+ if (glob.pattern[0] === '!') continue
+
+ let relative = path.relative(config!.root, glob.base)
+ if (relative[0] !== '.') {
+ relative = './' + relative
+ }
+ // Ensure relative is a posix style path since we will merge it with
+ // the glob.
+ relative = normalizePath(relative)
+
+ addWatchFile(path.posix.join(relative, glob.pattern))
+ }
+
+ return build(Array.from(candidates))
}
- function generateOptimizedCss(css: string, inputPath: string) {
- return optimizeCss(generateCss(css, inputPath), { minify })
+ function generateOptimizedCss(
+ css: string,
+ inputPath: string,
+ addWatchFile: (file: string) => void,
+ ) {
+ return optimizeCss(generateCss(css, inputPath, addWatchFile), { minify })
}
// Manually run the transform functions of non-Tailwind plugins on the given CSS
@@ -101,7 +150,7 @@ export default function tailwindcss(): Plugin[] {
for (let plugin of cssPlugins) {
if (!plugin.transform) continue
- const transformHandler =
+ let transformHandler =
'handler' in plugin.transform! ? plugin.transform.handler : plugin.transform!
try {
@@ -133,7 +182,8 @@ export default function tailwindcss(): Plugin[] {
server = _server
},
- async configResolved(config) {
+ async configResolved(_config) {
+ config = _config
minify = config.build.cssMinify !== false
isSSR = config.build.ssr !== false && config.build.ssr !== undefined
@@ -152,6 +202,55 @@ export default function tailwindcss(): Plugin[] {
})
},
+ // Append the postcss-fix-relative-paths plugin
+ async config(config) {
+ let postcssConfig = config.css?.postcss
+
+ if (typeof postcssConfig === 'string') {
+ // We expand string configs to their PostCSS config object similar to
+ // how Vite does it.
+ // See: https://github.com/vitejs/vite/blob/440783953a55c6c63cd09ec8d13728dc4693073d/packages/vite/src/node/plugins/css.ts#L1580
+ let searchPath = typeof postcssConfig === 'string' ? postcssConfig : config.root
+ let parsedConfig = await postcssrc({}, searchPath).catch((e: Error) => {
+ if (!e.message.includes('No PostCSS Config found')) {
+ if (e instanceof Error) {
+ let { name, message, stack } = e
+ e.name = 'Failed to load PostCSS config'
+ e.message = `Failed to load PostCSS config (searchPath: ${searchPath}): [${name}] ${message}\n${stack}`
+ e.stack = '' // add stack to message to retain stack
+ throw e
+ } else {
+ throw new Error(`Failed to load PostCSS config: ${e}`)
+ }
+ }
+ return null
+ })
+ if (parsedConfig !== null) {
+ postcssConfig = {
+ options: parsedConfig.options,
+ plugins: parsedConfig.plugins,
+ } as any
+ } else {
+ postcssConfig = {}
+ }
+ config.css = { postcss: postcssConfig }
+ }
+
+ // postcssConfig is no longer a string after the above. This test is to
+ // avoid TypeScript errors below.
+ if (typeof postcssConfig === 'string') {
+ return
+ }
+
+ if (!postcssConfig || !postcssConfig?.plugins) {
+ config.css = config.css || {}
+ config.css.postcss = postcssConfig || {}
+ config.css.postcss.plugins = [fixRelativePathsPlugin() as any]
+ } else {
+ postcssConfig.plugins.push(fixRelativePathsPlugin() as any)
+ }
+ },
+
// Scan index.html for candidates
transformIndexHtml(html) {
let updated = scan(html, 'html')
@@ -199,7 +298,11 @@ export default function tailwindcss(): Plugin[] {
await server?.waitForRequestsIdle?.(id)
}
- let code = await transformWithPlugins(this, id, generateCss(src, id))
+ let code = await transformWithPlugins(
+ this,
+ id,
+ generateCss(src, id, (file) => this.addWatchFile(file)),
+ )
return { code }
},
},
@@ -223,7 +326,7 @@ export default function tailwindcss(): Plugin[] {
continue
}
- let css = generateOptimizedCss(file.content, id)
+ let css = generateOptimizedCss(file.content, id, (file) => this.addWatchFile(file))
// These plugins have side effects which, during build, results in CSS
// being written to the output dir. We need to run them here to ensure
diff --git a/packages/@tailwindcss-vite/tsup.config.ts b/packages/@tailwindcss-vite/tsup.config.ts
new file mode 100644
index 000000000..eaf99e82a
--- /dev/null
+++ b/packages/@tailwindcss-vite/tsup.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'tsup'
+
+export default defineConfig({
+ format: ['esm'],
+ clean: true,
+ minify: true,
+ dts: true,
+ entry: ['src/index.ts'],
+ noExternal: ['internal-postcss-fix-relative-paths'],
+})
diff --git a/packages/internal-postcss-fix-relative-paths/package.json b/packages/internal-postcss-fix-relative-paths/package.json
new file mode 100644
index 000000000..2ffb3c7dd
--- /dev/null
+++ b/packages/internal-postcss-fix-relative-paths/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "internal-postcss-fix-relative-paths",
+ "version": "0.0.0",
+ "private": true,
+ "scripts": {
+ "lint": "tsc --noEmit",
+ "build": "tsup-node ./src/index.ts --format cjs,esm --dts --cjsInterop --splitting --minify --clean",
+ "dev": "pnpm run build -- --watch"
+ },
+ "files": [
+ "dist/"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.mjs",
+ "require": "./dist/index.js"
+ }
+ },
+ "dependencies": {},
+ "devDependencies": {
+ "@types/node": "^20.12.12",
+ "@types/postcss-import": "^14.0.3",
+ "postcss": "8.4.24",
+ "postcss-import": "^16.1.0"
+ }
+}
diff --git a/packages/internal-postcss-fix-relative-paths/src/fixtures/example-project/src/index.css b/packages/internal-postcss-fix-relative-paths/src/fixtures/example-project/src/index.css
new file mode 100644
index 000000000..b8c4fc5f1
--- /dev/null
+++ b/packages/internal-postcss-fix-relative-paths/src/fixtures/example-project/src/index.css
@@ -0,0 +1,4 @@
+@source "./**/*.ts";
+@source "!./**/*.ts";
+@plugin "./plugin.js";
+@plugin "./what\"s-this.js";
diff --git a/packages/internal-postcss-fix-relative-paths/src/fixtures/example-project/src/invalid.css b/packages/internal-postcss-fix-relative-paths/src/fixtures/example-project/src/invalid.css
new file mode 100644
index 000000000..9eeb9353e
--- /dev/null
+++ b/packages/internal-postcss-fix-relative-paths/src/fixtures/example-project/src/invalid.css
@@ -0,0 +1,4 @@
+@plugin "/absolute/paths";
+@plugin "C:\Program Files\HAL 9000";
+@plugin "\\Media\Pictures\Worth\1000 words";
+@plugin "some-node-dep";
diff --git a/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/index.css b/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/index.css
new file mode 100644
index 000000000..2c014767b
--- /dev/null
+++ b/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/index.css
@@ -0,0 +1 @@
+@import '../../example-project/src/index.css';
diff --git a/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/invalid.css b/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/invalid.css
new file mode 100644
index 000000000..b69d455c0
--- /dev/null
+++ b/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/invalid.css
@@ -0,0 +1 @@
+@import '../../example-project/src/invalid.css';
diff --git a/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/plugins-in-root.css b/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/plugins-in-root.css
new file mode 100644
index 000000000..d6d5f082c
--- /dev/null
+++ b/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/plugins-in-root.css
@@ -0,0 +1,5 @@
+@import './plugins-in-sibling.css';
+
+@plugin './plugin-in-root.ts';
+@plugin '../plugin-in-root.ts';
+@plugin 'plugin-in-root';
diff --git a/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/plugins-in-sibling.css b/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/plugins-in-sibling.css
new file mode 100644
index 000000000..5df3cb061
--- /dev/null
+++ b/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/plugins-in-sibling.css
@@ -0,0 +1,3 @@
+@plugin './plugin-in-sibling.ts';
+@plugin '../plugin-in-sibling.ts';
+@plugin 'plugin-in-sibling';
diff --git a/packages/internal-postcss-fix-relative-paths/src/index.test.ts b/packages/internal-postcss-fix-relative-paths/src/index.test.ts
new file mode 100644
index 000000000..d2f72f664
--- /dev/null
+++ b/packages/internal-postcss-fix-relative-paths/src/index.test.ts
@@ -0,0 +1,58 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import postcss from 'postcss'
+import atImport from 'postcss-import'
+import { describe, expect, test } from 'vitest'
+import fixRelativePathsPlugin from '.'
+
+describe('fixRelativePathsPlugin', () => {
+ test('rewrites @source and @plugin to be relative to the initial css file', async () => {
+ let cssPath = path.join(__dirname, 'fixtures', 'external-import', 'src', 'index.css')
+ let css = fs.readFileSync(cssPath, 'utf-8')
+
+ let processor = postcss([atImport(), fixRelativePathsPlugin()])
+
+ let result = await processor.process(css, { from: cssPath })
+
+ expect(result.css.trim()).toMatchInlineSnapshot(`
+ "@source "../../example-project/src/**/*.ts";
+ @source "!../../example-project/src/**/*.ts";
+ @plugin "../../example-project/src/plugin.js";
+ @plugin "../../example-project/src/what\\"s-this.js";"
+ `)
+ })
+
+ test('should not rewrite non-relative paths', async () => {
+ let cssPath = path.join(__dirname, 'fixtures', 'external-import', 'src', 'invalid.css')
+ let css = fs.readFileSync(cssPath, 'utf-8')
+
+ let processor = postcss([atImport(), fixRelativePathsPlugin()])
+
+ let result = await processor.process(css, { from: cssPath })
+
+ expect(result.css.trim()).toMatchInlineSnapshot(`
+ "@plugin "/absolute/paths";
+ @plugin "C:\\Program Files\\HAL 9000";
+ @plugin "\\\\Media\\Pictures\\Worth\\1000 words";
+ @plugin "some-node-dep";"
+ `)
+ })
+
+ test('should return relative paths even if the file is resolved in the same basedir as the root stylesheet', async () => {
+ let cssPath = path.join(__dirname, 'fixtures', 'external-import', 'src', 'plugins-in-root.css')
+ let css = fs.readFileSync(cssPath, 'utf-8')
+
+ let processor = postcss([atImport(), fixRelativePathsPlugin()])
+
+ let result = await processor.process(css, { from: cssPath })
+
+ expect(result.css.trim()).toMatchInlineSnapshot(`
+ "@plugin './plugin-in-sibling.ts';
+ @plugin '../plugin-in-sibling.ts';
+ @plugin 'plugin-in-sibling';
+ @plugin './plugin-in-root.ts';
+ @plugin '../plugin-in-root.ts';
+ @plugin 'plugin-in-root';"
+ `)
+ })
+})
diff --git a/packages/internal-postcss-fix-relative-paths/src/index.ts b/packages/internal-postcss-fix-relative-paths/src/index.ts
new file mode 100644
index 000000000..3f35f4f9f
--- /dev/null
+++ b/packages/internal-postcss-fix-relative-paths/src/index.ts
@@ -0,0 +1,77 @@
+import path from 'node:path'
+import type { AtRule, Plugin } from 'postcss'
+import { normalizePath } from './normalize-path'
+
+const SINGLE_QUOTE = "'"
+const DOUBLE_QUOTE = '"'
+
+export { normalizePath }
+
+export default function fixRelativePathsPlugin(): Plugin {
+ // Retain a list of touched at-rules to avoid infinite loops
+ let touched: WeakSet = new WeakSet()
+
+ function fixRelativePath(atRule: AtRule) {
+ let rootPath = atRule.root().source?.input.file
+ if (!rootPath) {
+ return
+ }
+
+ let inputFilePath = atRule.source?.input.file
+ if (!inputFilePath) {
+ return
+ }
+
+ if (touched.has(atRule)) {
+ return
+ }
+
+ let value = atRule.params[0]
+
+ let quote =
+ value[0] === DOUBLE_QUOTE && value[value.length - 1] === DOUBLE_QUOTE
+ ? DOUBLE_QUOTE
+ : value[0] === SINGLE_QUOTE && value[value.length - 1] === SINGLE_QUOTE
+ ? SINGLE_QUOTE
+ : null
+ if (!quote) {
+ return
+ }
+ let glob = atRule.params.slice(1, -1)
+
+ // Handle eventual negative rules. We only support one level of negation.
+ let negativePrefix = ''
+ if (glob.startsWith('!')) {
+ glob = glob.slice(1)
+ negativePrefix = '!'
+ }
+
+ // We only want to rewrite relative paths.
+ if (!glob.startsWith('./') && !glob.startsWith('../')) {
+ return
+ }
+
+ let absoluteGlob = path.posix.join(normalizePath(path.dirname(inputFilePath)), glob)
+ let absoluteRootPosixPath = path.posix.dirname(normalizePath(rootPath))
+
+ let relative = path.posix.relative(absoluteRootPosixPath, absoluteGlob)
+
+ // If the path points to a file in the same directory, `path.relative` will
+ // remove the leading `./` and we need to add it back in order to still
+ // consider the path relative
+ if (!relative.startsWith('.')) {
+ relative = './' + relative
+ }
+
+ atRule.params = quote + negativePrefix + relative + quote
+ touched.add(atRule)
+ }
+
+ return {
+ postcssPlugin: 'tailwindcss-postcss-fix-relative-paths',
+ AtRule: {
+ source: fixRelativePath,
+ plugin: fixRelativePath,
+ },
+ }
+}
diff --git a/packages/internal-postcss-fix-relative-paths/src/normalize-path.ts b/packages/internal-postcss-fix-relative-paths/src/normalize-path.ts
new file mode 100644
index 000000000..a8184ef23
--- /dev/null
+++ b/packages/internal-postcss-fix-relative-paths/src/normalize-path.ts
@@ -0,0 +1,47 @@
+// Inlined version of `normalize-path`
+// Copyright (c) 2014-2018, Jon Schlinkert.
+// Released under the MIT License.
+function normalizePathBase(path: string, stripTrailing?: boolean) {
+ if (typeof path !== 'string') {
+ throw new TypeError('expected path to be a string')
+ }
+
+ if (path === '\\' || path === '/') return '/'
+
+ var len = path.length
+ if (len <= 1) return path
+
+ // ensure that win32 namespaces has two leading slashes, so that the path is
+ // handled properly by the win32 version of path.parse() after being normalized
+ // https://msdn.microsoft.com/library/windows/desktop/aa365247(v=vs.85).aspx#namespaces
+ var prefix = ''
+ if (len > 4 && path[3] === '\\') {
+ var ch = path[2]
+ if ((ch === '?' || ch === '.') && path.slice(0, 2) === '\\\\') {
+ path = path.slice(2)
+ prefix = '//'
+ }
+ }
+
+ var segs = path.split(/[/\\]+/)
+ if (stripTrailing !== false && segs[segs.length - 1] === '') {
+ segs.pop()
+ }
+ return prefix + segs.join('/')
+}
+
+export function normalizePath(originalPath: string) {
+ let normalized = normalizePathBase(originalPath)
+
+ // Make sure Windows network share paths are normalized properly
+ // They have to begin with two slashes or they won't resolve correctly
+ if (
+ originalPath.startsWith('\\\\') &&
+ normalized.startsWith('/') &&
+ !normalized.startsWith('//')
+ ) {
+ return `/${normalized}`
+ }
+
+ return normalized
+}
diff --git a/packages/internal-postcss-fix-relative-paths/tsconfig.json b/packages/internal-postcss-fix-relative-paths/tsconfig.json
new file mode 100644
index 000000000..6ae022f65
--- /dev/null
+++ b/packages/internal-postcss-fix-relative-paths/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "../tsconfig.base.json",
+}
diff --git a/packages/tailwindcss/src/candidate.bench.ts b/packages/tailwindcss/src/candidate.bench.ts
index f46dec7ec..10700b3a3 100644
--- a/packages/tailwindcss/src/candidate.bench.ts
+++ b/packages/tailwindcss/src/candidate.bench.ts
@@ -8,7 +8,7 @@ import { Theme } from './theme'
const root = process.env.FOLDER || process.cwd()
// Auto content detection
-const result = scanDir({ base: root, globs: true })
+const result = scanDir({ base: root })
const designSystem = buildDesignSystem(new Theme())
diff --git a/packages/tailwindcss/src/index.bench.ts b/packages/tailwindcss/src/index.bench.ts
index 6ec69f2f6..e90f49e15 100644
--- a/packages/tailwindcss/src/index.bench.ts
+++ b/packages/tailwindcss/src/index.bench.ts
@@ -7,7 +7,7 @@ const root = process.env.FOLDER || process.cwd()
const css = String.raw
bench('compile', async () => {
- let { candidates } = scanDir({ base: root, globs: true })
+ let { candidates } = scanDir({ base: root })
compile(css`
@tailwind utilities;
diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts
index 70d239deb..2601edf56 100644
--- a/packages/tailwindcss/src/index.test.ts
+++ b/packages/tailwindcss/src/index.test.ts
@@ -1514,6 +1514,25 @@ describe('plugins', () => {
})
})
+describe('@source', () => {
+ test('emits @source files', () => {
+ let { globs } = compile(css`
+ @source "./foo/bar/*.ts";
+ `)
+
+ expect(globs).toEqual(['./foo/bar/*.ts'])
+ })
+
+ test('emits multiple @source files', () => {
+ let { globs } = compile(css`
+ @source "./foo/**/*.ts";
+ @source "./php/secr3t/smarty.php";
+ `)
+
+ expect(globs).toEqual(['./foo/**/*.ts', './php/secr3t/smarty.php'])
+ })
+})
+
describe('@variant', () => {
test('@variant must be top-level and cannot be nested', () => {
expect(() =>
diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts
index 8b61e65b2..a542c72ca 100644
--- a/packages/tailwindcss/src/index.ts
+++ b/packages/tailwindcss/src/index.ts
@@ -52,6 +52,7 @@ export function compile(
css: string,
{ loadPlugin = throwOnPlugin }: CompileOptions = {},
): {
+ globs: string[]
build(candidates: string[]): string
} {
let ast = CSS.parse(css)
@@ -73,6 +74,7 @@ export function compile(
let customUtilities: ((designSystem: DesignSystem) => void)[] = []
let firstThemeRule: Rule | null = null
let keyframesRules: Rule[] = []
+ let globs: string[] = []
walk(ast, (node, { parent, replaceWith }) => {
if (node.kind !== 'rule') return
@@ -119,6 +121,29 @@ export function compile(
return
}
+ // Collect paths from `@source` at-rules
+ if (node.selector.startsWith('@source ')) {
+ if (node.nodes.length > 0) {
+ throw new Error('`@source` cannot have a body.')
+ }
+
+ if (parent !== null) {
+ throw new Error('`@source` cannot be nested.')
+ }
+
+ let path = node.selector.slice(8)
+ if (
+ (path[0] === '"' && path[path.length - 1] !== '"') ||
+ (path[0] === "'" && path[path.length - 1] !== "'") ||
+ (path[0] !== "'" && path[0] !== '"')
+ ) {
+ throw new Error('`@source` paths must be quoted.')
+ }
+ globs.push(path.slice(1, -1))
+ replaceWith([])
+ return
+ }
+
// Register custom variants from `@variant` at-rules
if (node.selector.startsWith('@variant ')) {
if (parent !== null) {
@@ -374,6 +399,7 @@ export function compile(
let previousAstNodeCount = 0
return {
+ globs,
build(newRawCandidates: string[]) {
let didChange = false
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 888eee519..f722556ed 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -152,6 +152,9 @@ importers:
'@types/postcss-import':
specifier: ^14.0.3
version: 14.0.3
+ internal-postcss-fix-relative-paths:
+ specifier: workspace:^
+ version: link:../internal-postcss-fix-relative-paths
packages/@tailwindcss-postcss:
dependencies:
@@ -177,6 +180,9 @@ importers:
internal-example-plugin:
specifier: workspace:*
version: link:../internal-example-plugin
+ internal-postcss-fix-relative-paths:
+ specifier: workspace:^
+ version: link:../internal-postcss-fix-relative-paths
postcss:
specifier: 8.4.24
version: 8.4.24
@@ -189,6 +195,9 @@ importers:
lightningcss:
specifier: ^1.25.1
version: 1.25.1
+ postcss-load-config:
+ specifier: ^6.0.1
+ version: 6.0.1(postcss@8.4.40)
tailwindcss:
specifier: workspace:^
version: link:../tailwindcss
@@ -196,12 +205,30 @@ importers:
'@types/node':
specifier: 'catalog:'
version: 20.14.13
+ internal-postcss-fix-relative-paths:
+ specifier: workspace:^
+ version: link:../internal-postcss-fix-relative-paths
vite:
specifier: 'catalog:'
version: 5.3.5(@types/node@20.14.13)(lightningcss@1.25.1)
packages/internal-example-plugin: {}
+ packages/internal-postcss-fix-relative-paths:
+ devDependencies:
+ '@types/node':
+ specifier: ^20.12.12
+ version: 20.14.13
+ '@types/postcss-import':
+ specifier: ^14.0.3
+ version: 14.0.3
+ postcss:
+ specifier: 8.4.24
+ version: 8.4.24
+ postcss-import:
+ specifier: ^16.1.0
+ version: 16.1.0(postcss@8.4.24)
+
packages/tailwindcss:
devDependencies:
'@tailwindcss/oxide':
@@ -4948,6 +4975,12 @@ snapshots:
optionalDependencies:
postcss: 8.4.24
+ postcss-load-config@6.0.1(postcss@8.4.40):
+ dependencies:
+ lilconfig: 3.1.2
+ optionalDependencies:
+ postcss: 8.4.40
+
postcss-value-parser@4.2.0: {}
postcss@8.4.24:
diff --git a/scripts/pack-packages.mjs b/scripts/pack-packages.mjs
index 8791f39c4..aa845c3e8 100644
--- a/scripts/pack-packages.mjs
+++ b/scripts/pack-packages.mjs
@@ -24,6 +24,18 @@ for (let path of paths) {
workspaces.set(pkg.name, { version: pkg.version ?? '', dir: dirname(path) })
}
+// Move napi artifacts into sub packages
+const tailwindcssOxideRoot = path.join(root, 'crates', 'node')
+for (let file of await fs.readdir(tailwindcssOxideRoot)) {
+ if (file.startsWith('tailwindcss-oxide.') && file.endsWith('.node')) {
+ let target = file.split('.')[1]
+ await fs.cp(
+ path.join(tailwindcssOxideRoot, file),
+ path.join(tailwindcssOxideRoot, 'npm', target, file),
+ )
+ }
+}
+
// Clean dist folder
await fs.rm(path.join(root, 'dist'), { recursive: true, force: true })