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 <[email protected]>
Co-authored-by: Jordan Pittman <[email protected]>
Co-authored-by: Adam Wathan <[email protected]>
This commit is contained in:
Robin Malfait
2024-08-07 16:38:44 +02:00
committed by GitHub
co-authored by Philipp Spiess Jordan Pittman Adam Wathan
parent 5723481cf2
commit 1ee477eae2
40 changed files with 2111 additions and 514 deletions
+1
View File
@@ -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
Generated
+262 -9
View File
@@ -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"
+70 -16
View File
@@ -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<ChangedContent> 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<String>,
sources: Vec<GlobEntry>,
// Public API:
pub globs: Vec<GlobEntry>,
pub files: Vec<String>,
pub candidates: Vec<String>,
}
#[napi]
impl ScanResult {
#[napi]
pub fn scan_files(&self, input: Vec<ChangedContent>) -> Vec<String> {
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<String> = HashSet::from_iter(result.candidates);
let candidates_from_files: HashSet<String> = 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<GlobEntry> for tailwindcss_oxide::GlobEntry {
fn from(glob: GlobEntry) -> Self {
tailwindcss_oxide::GlobEntry {
base: glob.base,
pattern: glob.pattern,
}
}
}
impl From<tailwindcss_oxide::GlobEntry> 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<bool>,
/// Base path to start scanning from
pub base: Option<String>,
/// Glob sources
pub sources: Option<Vec<GlobEntry>>,
}
#[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(),
}
}
+3
View File
@@ -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"
+63 -40
View File
@@ -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<String>,
patterns: &Vec<GlobEntry>,
) -> Result<impl iter::Iterator<Item = PathBuf>, 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<String>) -> Vec<(PathBuf, Vec<String>)> {
pub fn get_fast_patterns(patterns: &Vec<GlobEntry>) -> Vec<(PathBuf, Vec<String>)> {
let mut optimized_patterns: Vec<(PathBuf, Vec<String>)> = 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<String>) -> 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<String>) -> 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<String> {
#[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"),
+61 -12
View File
@@ -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<String>,
/// Glob sources
pub sources: Vec<GlobEntry>,
}
#[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::<Vec<GlobEntry>>();
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<PathBuf>) -> Vec<GlobEntry> {
// 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::<Vec<_>>()
@@ -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<String>, Vec<String>) {
fn scan_with_globs(
paths_with_content: &[(&str, Option<&str>)],
globs: Vec<&str>,
) -> (Vec<String>, Vec<String>) {
// 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<String>, Vec<String>) {
scan_with_globs(paths_with_content, vec![])
}
fn test(paths_with_content: &[(&str, Option<&str>)]) -> Vec<String> {
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']"]);
}
}
+142
View File
@@ -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`
<div
class="underline 2xl:font-bold hocus:underline inverted:flex"
></div>
`,
'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`
<div
class="underline 2xl:font-bold hocus:underline inverted:flex"
></div>
`,
'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']`,
])
},
)
+164
View File
@@ -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`
<div
class="underline 2xl:font-bold hocus:underline inverted:flex"
></div>
`,
'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`
<div
class="underline 2xl:font-bold hocus:underline inverted:flex"
></div>
`,
'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']`,
])
},
)
+349 -185
View File
@@ -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<void>
}
interface ChildProcessOptions {
cwd?: string
}
interface TestConfig {
fs: {
[filePath: string]: string
}
}
interface TestContext {
exec(command: string): Promise<string>
spawn(command: string): Promise<SpawnedProcess>
root: string
exec(command: string, options?: ChildProcessOptions): Promise<string>
spawn(command: string, options?: ChildProcessOptions): Promise<SpawnedProcess>
getFreePort(): Promise<number>
fs: {
write(filePath: string, content: string): Promise<void>
read(filePath: string): Promise<string>
glob(pattern: string): Promise<[string, string][]>
expectFileToContain(filePath: string, contents: string | string[]): Promise<void>
}
}
type TestCallback = (context: TestContext) => Promise<void> | 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<void> {
let full = path.join(root, filename)
async function write(filename: string, content: string): Promise<void> {
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<void>)[] = []
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<void>((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<void>)[] = []
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<void>((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<void>((resolve) => {
stdoutActors.push({ predicate, resolve })
notifyNext(stdoutActors, stdoutMessages)
})
},
onStderr(predicate: (message: string) => boolean) {
return new Promise<void>((resolve) => {
stderrActors.push({ predicate, resolve })
notifyNext(stderrActors, stderrMessages)
})
},
}
},
async getFreePort(): Promise<number> {
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<void>((resolve) => {
stdoutActors.push({ predicate, resolve })
notifyNext(stdoutActors, stdoutMessages)
})
},
onStderr(predicate: (message: string) => boolean) {
return new Promise<void>((resolve) => {
stderrActors.push({ predicate, resolve })
notifyNext(stderrActors, stderrMessages)
})
},
}
},
async getFreePort(): Promise<number> {
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<string> {
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<boolean> {
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<T>(
fn: () => Promise<T>,
{ 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
}
+63 -46
View File
@@ -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`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
@@ -49,37 +56,43 @@ test(
<div class="underline m-2">Hello, world!</div>
</body>
`,
'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`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
@@ -108,29 +121,30 @@ test(
<div class="underline">Hello, world!</div>
</body>
`,
'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`
<head>
<link rel="stylesheet" href="./src/index.css" />
@@ -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']`)
},
)
+3 -2
View File
@@ -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:^"
}
}
@@ -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<ReturnType<typeof options>>) {
}
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<ReturnType<typeof options>>) {
}
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<ReturnType<typeof options>>) {
}
// 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<ReturnType<typeof options>>) {
// 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<typeof scanDir>) {
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<string>()
// 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,
@@ -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<Function>([])
/**
* 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()
}
}
+9
View File
@@ -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'],
})
+3 -2
View File
@@ -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:^"
}
}
@@ -0,0 +1 @@
@plugin '../plugin.js';
@@ -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 } }),
+107 -89
View File
@@ -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<string, number>(),
build: null as null | ReturnType<typeof compile>['build'],
compiler: null as null | ReturnType<typeof compile>,
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))
},
},
],
}
@@ -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'],
})
+4 -2
View File
@@ -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"
+121 -18
View File
@@ -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<string>()
let scanDirResult: ReturnType<typeof scanDir> | 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
+10
View File
@@ -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'],
})
@@ -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"
}
}
@@ -0,0 +1,4 @@
@source "./**/*.ts";
@source "!./**/*.ts";
@plugin "./plugin.js";
@plugin "./what\"s-this.js";
@@ -0,0 +1,4 @@
@plugin "/absolute/paths";
@plugin "C:\Program Files\HAL 9000";
@plugin "\\Media\Pictures\Worth\1000 words";
@plugin "some-node-dep";
@@ -0,0 +1 @@
@import '../../example-project/src/index.css';
@@ -0,0 +1 @@
@import '../../example-project/src/invalid.css';
@@ -0,0 +1,5 @@
@import './plugins-in-sibling.css';
@plugin './plugin-in-root.ts';
@plugin '../plugin-in-root.ts';
@plugin 'plugin-in-root';
@@ -0,0 +1,3 @@
@plugin './plugin-in-sibling.ts';
@plugin '../plugin-in-sibling.ts';
@plugin 'plugin-in-sibling';
@@ -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';"
`)
})
})
@@ -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<AtRule> = 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,
},
}
}
@@ -0,0 +1,47 @@
// Inlined version of `normalize-path` <https://github.com/jonschlinkert/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
}
@@ -0,0 +1,3 @@
{
"extends": "../tsconfig.base.json",
}
+1 -1
View File
@@ -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())
+1 -1
View File
@@ -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;
+19
View File
@@ -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(() =>
+26
View File
@@ -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
+33
View File
@@ -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([email protected])
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/[email protected])([email protected])
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([email protected])
packages/tailwindcss:
devDependencies:
'@tailwindcss/oxide':
@@ -4948,6 +4975,12 @@ snapshots:
optionalDependencies:
postcss: 8.4.24
[email protected]([email protected]):
dependencies:
lilconfig: 3.1.2
optionalDependencies:
postcss: 8.4.40
[email protected]: {}
[email protected]:
+12
View File
@@ -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 })