Auto source detection improvements (#14820)

This PR introduces a new `source(…)` argument and improves on the
existing `@source`. The goal of this PR is to make the automatic source
detection configurable, let's dig in.

By default, we will perform automatic source detection starting at the
current working directory. Auto source detection will find plain text
files (no binaries, images, ...) and will ignore git-ignored files.

If you want to start from a different directory, you can use the new
`source(…)` next to the `@import "tailwindcss/utilities"
layer(utilities) source(…)`.

E.g.:

```css
/* ./src/styles/index.css */
@import 'tailwindcss/utilities' layer(utilities) source('../../');
```

Most people won't split their source files, and will just use the simple
`@import "tailwindcss";`, because of this reason, you can use
`source(…)` on the import as well:

E.g.:

```css
/* ./src/styles/index.css */
@import 'tailwindcss' source('../../');
```

Sometimes, you want to rely on auto source detection, but also want to
look in another directory for source files. In this case, yuo can use
the `@source` directive:

```css
/* ./src/index.css */
@import 'tailwindcss';

/* Look for `blade.php` files in `../resources/views` */
@source '../resources/views/**/*.blade.php';
```

However, you don't need to specify the extension, instead you can just
point the directory and all the same automatic source detection rules
will apply.

```css
/* ./src/index.css */
@import 'tailwindcss';

@source '../resources/views';
```

If, for whatever reason, you want to disable the default source
detection feature entirely, and only want to rely on very specific glob
patterns you define, then you can disable it via `source(none)`.

```css
/* Completely disable the default auto source detection */
@import 'tailwindcss' source(none);

/* Only look at .blade.php files, nothing else  */
@source "../resources/views/**/*.blade.php";
```

Note: even with `source(none)`, if your `@source` points to a directory,
then auto source detection will still be performed in that directory. If
you don't want that, then you can simply add explicit files in the globs
as seen in the previous example.

```css
/* Completely disable the default auto source detection */
@import 'tailwindcss' source(none);

/* Run auto source detection in `../resources/views` */
@source "../resources/views";
```

---------

Co-authored-by: Jordan Pittman <jordan@cryptica.me>
Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com>
This commit is contained in:
Robin Malfait 2024-10-29 21:33:34 +01:00 committed by GitHub
parent c439cdf43c
commit d68a780f98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 2341 additions and 606 deletions

View File

@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Support specifying the base path for automatic source detection using a `source(…)` function on `@tailwind utilities` or `@import "tailwindcss"` ([#14820](https://github.com/tailwindlabs/tailwindcss/pull/14820))
- Support disabling automatic source detection with `source(none)` ([#14820](https://github.com/tailwindlabs/tailwindcss/pull/14820))
- Support passing directories to `@source` without needing to pass a complete glob ([#14820](https://github.com/tailwindlabs/tailwindcss/pull/14820))
- _Upgrade (experimental)_: Bump `prettier-plugin-tailwindcss` to latest version during upgrade ([#14808](https://github.com/tailwindlabs/tailwindcss/pull/14808))
### Fixed
@ -18,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Use logical `*-inline` and `*-block` properties for all x/y utilities like `px-*`, `my-*`, `scroll-px-*`, and `inset-y-*` ([#14805](https://github.com/tailwindlabs/tailwindcss/pull/14805))
- Respect automatic source detection heuristics in sources registered with `@source` ([#14820](https://github.com/tailwindlabs/tailwindcss/pull/14820))
## [4.0.0-alpha.30] - 2024-10-24

113
Cargo.lock generated
View File

@ -11,6 +11,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "bexpand"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "045d7d9db8390cf2c59f39f3bd138f1962ef616b096d1b9f5651c7acba19e5a7"
dependencies = [
"itertools",
"nom",
]
[[package]]
name = "bitflags"
version = "2.6.0"
@ -183,6 +193,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "itertools"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
dependencies = [
"either",
]
[[package]]
name = "lazy_static"
version = "1.4.0"
@ -202,7 +221,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4"
dependencies = [
"cfg-if",
"windows-targets 0.48.0",
"windows-targets",
]
[[package]]
@ -232,6 +251,12 @@ version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "napi"
version = "2.16.11"
@ -289,6 +314,16 @@ dependencies = [
"libloading",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
@ -485,6 +520,7 @@ dependencies = [
name = "tailwindcss-oxide"
version = "0.1.0"
dependencies = [
"bexpand",
"bstr",
"crossbeam",
"dunce",
@ -649,7 +685,7 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
"windows-targets",
]
[[package]]
@ -658,22 +694,7 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5"
dependencies = [
"windows_aarch64_gnullvm 0.48.0",
"windows_aarch64_msvc 0.48.0",
"windows_i686_gnu 0.48.0",
"windows_i686_msvc 0.48.0",
"windows_x86_64_gnu 0.48.0",
"windows_x86_64_gnullvm 0.48.0",
"windows_x86_64_msvc 0.48.0",
"windows-targets",
]
[[package]]
@ -682,46 +703,28 @@ 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_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"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",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
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.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@ -734,48 +737,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
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.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.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.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"

View File

@ -18,13 +18,6 @@ pub struct ChangedContent {
pub extension: String,
}
#[derive(Debug, Clone)]
#[napi(object)]
pub struct DetectSources {
/// Base path to start scanning from
pub base: String,
}
#[derive(Debug, Clone)]
#[napi(object)]
pub struct GlobEntry {
@ -62,20 +55,11 @@ impl From<tailwindcss_oxide::GlobEntry> for GlobEntry {
}
}
impl From<DetectSources> for tailwindcss_oxide::scanner::detect_sources::DetectSources {
fn from(detect_sources: DetectSources) -> Self {
Self::new(detect_sources.base.into())
}
}
// ---
#[derive(Debug, Clone)]
#[napi(object)]
pub struct ScannerOptions {
/// Automatically detect sources in the base path
pub detect_sources: Option<DetectSources>,
/// Glob sources
pub sources: Option<Vec<GlobEntry>>,
}
@ -102,7 +86,6 @@ impl Scanner {
pub fn new(opts: ScannerOptions) -> Self {
Self {
scanner: tailwindcss_oxide::Scanner::new(
opts.detect_sources.map(Into::into),
opts
.sources
.map(|x| x.into_iter().map(Into::into).collect()),
@ -128,7 +111,7 @@ impl Scanner {
input: ChangedContent,
) -> Vec<CandidateWithPosition> {
let content = input.content.unwrap_or_else(|| {
std::fs::read_to_string(&input.file.unwrap()).expect("Failed to read file")
std::fs::read_to_string(input.file.unwrap()).expect("Failed to read file")
});
let input = ChangedContent {

View File

@ -31,20 +31,18 @@ impl<'a> IndexConverter<'a> {
// will only ever be incremented up to the length of the input string.
//
// This eliminates a "potential" panic that cannot actually happen
let slice = unsafe {
self.input.get_unchecked(self.curr_utf8..)
};
let slice = unsafe { self.input.get_unchecked(self.curr_utf8..) };
for c in slice.chars() {
if self.curr_utf8 >= pos {
break
break;
}
self.curr_utf8 += c.len_utf8();
self.curr_utf16 += c.len_utf16();
}
return self.curr_utf16 as i64;
self.curr_utf16 as i64
}
}
@ -66,19 +64,16 @@ mod test {
(4, 4),
(5, 5),
(6, 6),
// inside the 🔥
(7, 8),
(8, 8),
(9, 8),
(10, 8),
// inside the 🥳
(11, 10),
(12, 10),
(13, 10),
(14, 10),
// <space>world!
(15, 11),
(16, 12),
@ -87,7 +82,6 @@ mod test {
(19, 15),
(20, 16),
(21, 17),
// Past the end should return the last utf-16 character index
(22, 17),
(100, 17),

View File

@ -14,8 +14,9 @@ tracing = { version = "0.1.40", features = [] }
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
walkdir = "2.5.0"
ignore = "0.4.23"
glob-match = "0.2.1"
dunce = "1.0.5"
bexpand = "1.2.0"
glob-match = "0.2.1"
[dev-dependencies]
tempfile = "3.13.0"

View File

@ -1,22 +1,59 @@
use fxhash::{FxHashMap, FxHashSet};
use glob_match::glob_match;
use std::iter;
use std::path::{Path, PathBuf};
use tracing::event;
use crate::GlobEntry;
pub fn fast_glob(
patterns: &Vec<GlobEntry>,
) -> Result<impl iter::Iterator<Item = PathBuf>, std::io::Error> {
Ok(get_fast_patterns(patterns)
.into_iter()
.flat_map(|(base_path, patterns)| {
globwalk::GlobWalkerBuilder::from_patterns(base_path, &patterns)
.follow_links(true)
.build()
.unwrap()
.filter_map(Result::ok)
.map(|file| file.path().to_path_buf())
}))
pub fn hoist_static_glob_parts(entries: &Vec<GlobEntry>) -> Vec<GlobEntry> {
let mut result = vec![];
for entry in entries {
let (static_part, dynamic_part) = split_pattern(&entry.pattern);
let base: PathBuf = entry.base.clone().into();
let base = match static_part {
Some(static_part) => base.join(static_part),
None => base,
};
let base = match dunce::canonicalize(&base) {
Ok(base) => base,
Err(err) => {
event!(tracing::Level::ERROR, "Failed to resolve glob: {:?}", err);
// If we can't resolve the new base on disk, let's just skip this entry.
continue;
}
};
let pattern = match dynamic_part {
Some(dynamic_part) => dynamic_part,
None => {
if base.is_dir() {
"**/*".to_owned()
} else {
"".to_owned()
}
}
};
// If the base path is a file, then we want to move the file to the pattern, and point the
// directory to the base. This is necessary for file watchers that can only listen to
// folders.
if pattern.is_empty() && base.is_file() {
result.push(GlobEntry {
base: base.parent().unwrap().to_string_lossy().to_string(),
pattern: base.file_name().unwrap().to_string_lossy().to_string(),
});
}
result.push(GlobEntry {
base: base.to_string_lossy().to_string(),
pattern,
});
}
result
}
/// This function attempts to optimize the glob patterns to improve performance. The problem is
@ -42,98 +79,89 @@ pub fn fast_glob(
/// tailwind --pwd ./project/pages --content "**/*.js"
/// tailwind --pwd ./project/components --content "**/*.js"
/// ```
pub fn get_fast_patterns(patterns: &Vec<GlobEntry>) -> Vec<(PathBuf, Vec<String>)> {
let mut optimized_patterns: Vec<(PathBuf, Vec<String>)> = vec![];
pub fn optimize_patterns(entries: &Vec<GlobEntry>) -> Vec<GlobEntry> {
let entries = hoist_static_glob_parts(entries);
for pattern in patterns {
let base_path = PathBuf::from(&pattern.base);
let pattern = &pattern.pattern;
// Track all base paths and their patterns. Later we will turn them back into `GlobalEntry`s.
let mut pattern_map: FxHashMap<String, FxHashSet<String>> = FxHashMap::default();
let is_negated = pattern.starts_with('!');
let mut pattern = pattern.clone();
if is_negated {
pattern.remove(0);
}
for glob_entry in entries {
let entry = pattern_map.entry(glob_entry.base).or_default();
entry.insert(glob_entry.pattern.clone());
}
let mut folders = pattern.split('/').collect::<Vec<_>>();
let mut glob_entries = pattern_map
.into_iter()
.map(|(base, patterns)| {
let size = patterns.len();
if folders.len() <= 1 {
// No paths we can simplify, so let's use it as-is.
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];
let mut patterns = patterns.into_iter();
let mut bail = false;
GlobEntry {
base,
pattern: match size {
1 => patterns.next().unwrap(),
_ => {
let mut patterns = patterns.collect::<Vec<_>>();
for (i, folder) in folders.into_iter().enumerate() {
// There is a wildcard in the folder, so we have to bail now... 😢 But this also
// means that we can skip looking at the rest of the folders, so there is at least
// this small optimization we can apply!
if folder.contains('*') {
// Get all the remaining folders, attach the existing file_pattern so that this
// can now be the final pattern we use.
let mut remaining_folders = all_folders[i..].to_vec();
remaining_folders.push(file_pattern);
// Sort the patterns to ensure stable results.
patterns.sort();
let pattern = remaining_folders.join("/");
for path in &temp_paths {
optimized_patterns.push((path.to_path_buf(), vec![pattern.to_string()]));
// TODO: Right now this will generate something like `{**/*.html,**/*.js}`,
// but maybe we want to generate this instead:`**/*.{html,js}`.
format!("{{{}}}", patterns.join(","))
}
bail = true;
break;
}
// The folder is very likely using an expandable pattern which we can expand!
if folder.contains('{') && folder.contains('}') {
let branches = expand_braces(folder);
let existing_paths = temp_paths;
temp_paths = branches
.iter()
.flat_map(|branch| {
existing_paths
.clone()
.into_iter()
.map(|path| path.join(branch))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
}
// The folder should just be a simple folder name without any glob magic. We should
// be able to safely add it to the existing paths.
else {
temp_paths = temp_paths
.into_iter()
.map(|path| path.join(folder))
.collect();
}
},
}
})
.collect::<Vec<GlobEntry>>();
// As long as we didn't bail, we can now add the current expanded patterns to the
// optimized patterns.
if !bail {
for path in &temp_paths {
optimized_patterns.push((path.to_path_buf(), vec![file_pattern.to_string()]));
}
}
// Sort the entries by base path to ensure we have stable results.
glob_entries.sort_by(|a, z| a.base.cmp(&z.base));
glob_entries
}
// Split a glob pattern into a `static` and `dynamic` part.
//
// Assumption: we assume that all globs are expanded, which means that the only dynamic parts are
// using `*`.
//
// E.g.:
// Original input: `../project-b/**/*.{html,js}`
// Expanded input: `../project-b/**/*.html` & `../project-b/**/*.js`
// Split on first input: ("../project-b", "**/*.html")
// Split on second input: ("../project-b", "**/*.js")
fn split_pattern(pattern: &str) -> (Option<String>, Option<String>) {
// No dynamic parts, so we can just return the input as-is.
if !pattern.contains('*') {
return (Some(pattern.to_owned()), None);
}
let mut last_slash_position = None;
for (i, c) in pattern.char_indices() {
if c == '/' {
last_slash_position = Some(i);
}
// Ensure that we re-add all the `!` signs to the patterns.
if is_negated {
for (_, patterns) in &mut optimized_patterns {
for pattern in patterns {
pattern.insert(0, '!');
}
}
if c == '*' || c == '!' {
break;
}
}
optimized_patterns
// Very first character is a `*`, therefore there is no static part, only a dynamic part.
let Some(last_slash_position) = last_slash_position else {
return (None, Some(pattern.to_owned()));
};
let static_part = pattern[..last_slash_position].to_owned();
let dynamic_part = pattern[last_slash_position + 1..].to_owned();
let static_part = (!static_part.is_empty()).then_some(static_part);
let dynamic_part = (!dynamic_part.is_empty()).then_some(dynamic_part);
(static_part, dynamic_part)
}
pub fn path_matches_globs(path: &Path, globs: &[GlobEntry]) -> bool {
@ -144,167 +172,185 @@ pub fn path_matches_globs(path: &Path, globs: &[GlobEntry]) -> bool {
.any(|g| glob_match(&format!("{}/{}", g.base, g.pattern), &path))
}
/// Given this input: a-{b,c}-d-{e,f}
/// We will get:
/// [
/// a-b-d-e
/// a-b-d-f
/// a-c-d-e
/// a-c-d-f
/// ]
/// TODO: There is probably a way nicer way of doing this, but this works for now.
fn expand_braces(input: &str) -> Vec<String> {
let mut result: Vec<String> = vec![];
let mut in_braces = false;
let mut last_char: char = '\0';
let mut current = String::new();
// Given the input: a-{b,c}-d-{e,f}-g
// The template will look like this: ["a-", "-d-", "g"].
let mut template: Vec<String> = vec![];
// The branches will look like this: [["b", "c"], ["e", "f"]].
let mut branches: Vec<Vec<String>> = vec![];
for (i, c) in input.char_indices() {
let is_escaped = i > 0 && last_char == '\\';
last_char = c;
match c {
'{' if !is_escaped => {
// Ensure that when a new set of braces is opened, that we at least have 1
// template.
if template.is_empty() {
template.push(String::new());
}
in_braces = true;
branches.push(vec![]);
template.push(String::new());
}
'}' if !is_escaped => {
in_braces = false;
if let Some(last) = branches.last_mut() {
last.push(current.clone());
}
current.clear();
}
',' if !is_escaped && in_braces => {
if let Some(last) = branches.last_mut() {
last.push(current.clone());
}
current.clear();
}
_ if in_braces => current.push(c),
_ => {
if template.is_empty() {
template.push(String::new());
}
if let Some(last) = template.last_mut() {
last.push(c);
}
}
};
}
// Ensure we have a string that we can start adding information too.
if !template.is_empty() && !branches.is_empty() {
result.push("".to_string());
}
// Let's try to generate everything!
for (i, template) in template.into_iter().enumerate() {
// Append current template string to all existing results.
result = result.into_iter().map(|x| x + &template).collect();
// Get the results, and copy it for every single branch.
if let Some(branches) = branches.get(i) {
result = branches
.iter()
.flat_map(|branch| {
result
.clone()
.into_iter()
.map(|x| x + branch)
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
}
}
result
}
#[cfg(test)]
mod tests {
use super::get_fast_patterns;
use super::optimize_patterns;
use crate::GlobEntry;
use std::path::PathBuf;
use bexpand::Expression;
use std::process::Command;
use std::{fs, path};
use tempfile::tempdir;
fn create_folders(folders: &[&str]) -> String {
// Create a temporary working directory
let dir = tempdir().unwrap().into_path();
// Initialize this directory as a git repository
let _ = Command::new("git").arg("init").current_dir(&dir).output();
// Create the necessary files
for path in folders {
// 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();
}
fs::write(path, "").unwrap();
}
let base = format!("{}", dir.display());
base
}
fn test(base: &str, sources: &[GlobEntry]) -> Vec<GlobEntry> {
// Resolve all content paths for the (temporary) current working directory
let sources: Vec<GlobEntry> = sources
.iter()
.map(|x| GlobEntry {
base: format!("{}{}", base, x.base),
pattern: x.pattern.clone(),
})
.collect();
// Expand glob patterns into multiple `GlobEntry`s.
let sources = sources
.iter()
.flat_map(|source| {
let expression: Result<Expression, _> = source.pattern[..].try_into();
let Ok(expression) = expression else {
return vec![source.clone()];
};
expression
.into_iter()
.filter_map(Result::ok)
.map(move |pattern| GlobEntry {
base: source.base.clone(),
pattern: pattern.into(),
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
let optimized_sources = optimize_patterns(&sources);
let parent_dir = format!("{}", fs::canonicalize(base).unwrap().display());
// Remove the temporary directory from the base
optimized_sources
.into_iter()
.map(|source| GlobEntry {
// Normalize paths to use unix style separators
base: source.base.replace(&parent_dir, "").replace('\\', "/"),
pattern: source.pattern,
})
.collect()
}
#[test]
fn it_should_keep_globs_that_start_with_file_wildcards_as_is() {
let actual = get_fast_patterns(&vec![GlobEntry {
let base = create_folders(&["projects"]);
let actual = test(
&base,
&[GlobEntry {
base: "/projects".to_string(),
pattern: "*.html".to_string(),
}],
);
let expected = vec![GlobEntry {
base: "/projects".to_string(),
pattern: "*.html".to_string(),
}]);
let expected = vec![(PathBuf::from("/projects"), vec!["*.html".to_string()])];
}];
assert_eq!(actual, expected,);
assert_eq!(actual, expected);
}
#[test]
fn it_should_keep_globs_that_start_with_folder_wildcards_as_is() {
let actual = get_fast_patterns(&vec![GlobEntry {
let base = create_folders(&["projects"]);
let actual = test(
&base,
&[GlobEntry {
base: "/projects".to_string(),
pattern: "**/*.html".to_string(),
}],
);
let expected = vec![GlobEntry {
base: "/projects".to_string(),
pattern: "**/*.html".to_string(),
}]);
let expected = vec![(PathBuf::from("/projects"), vec!["**/*.html".to_string()])];
}];
assert_eq!(actual, expected,);
}
#[test]
fn it_should_move_the_starting_folder_to_the_path() {
let actual = get_fast_patterns(&vec![GlobEntry {
base: "/projects".to_string(),
pattern: "example/*.html".to_string(),
}]);
let expected = vec![(
PathBuf::from("/projects/example"),
vec!["*.html".to_string()],
)];
let base = create_folders(&["projects/example"]);
let actual = test(
&base,
&[GlobEntry {
base: "/projects".to_string(),
pattern: "example/*.html".to_string(),
}],
);
let expected = vec![GlobEntry {
base: "/projects/example".to_string(),
pattern: "*.html".to_string(),
}];
assert_eq!(actual, expected,);
}
#[test]
fn it_should_move_the_starting_folders_to_the_path() {
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()],
)];
let base = create_folders(&["projects/example/other"]);
let actual = test(
&base,
&[GlobEntry {
base: "/projects".to_string(),
pattern: "example/other/*.html".to_string(),
}],
);
let expected = vec![GlobEntry {
base: "/projects/example/other".to_string(),
pattern: "*.html".to_string(),
}];
assert_eq!(actual, expected,);
}
#[test]
fn it_should_branch_expandable_folders() {
let actual = get_fast_patterns(&vec![GlobEntry {
base: "/projects".to_string(),
pattern: "{foo,bar}/*.html".to_string(),
}]);
let base = create_folders(&["projects/foo", "projects/bar"]);
let actual = test(
&base,
&[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()]),
GlobEntry {
base: "/projects/bar".to_string(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/foo".to_string(),
pattern: "*.html".to_string(),
},
];
assert_eq!(actual, expected,);
@ -312,27 +358,38 @@ mod tests {
#[test]
fn it_should_expand_multiple_expansions_in_the_same_folder() {
let actual = get_fast_patterns(&vec![GlobEntry {
base: "/projects".to_string(),
pattern: "a-{b,c}-d-{e,f}-g/*.html".to_string(),
}]);
let base = create_folders(&[
"projects/a-b-d-e-g",
"projects/a-b-d-f-g",
"projects/a-c-d-e-g",
"projects/a-c-d-f-g",
]);
let actual = test(
&base,
&[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"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a-c-d-e-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a-b-d-f-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a-c-d-f-g"),
vec!["*.html".to_string()],
),
GlobEntry {
base: "/projects/a-b-d-e-g".to_string(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/a-b-d-f-g".to_string(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/a-c-d-e-g".to_string(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/a-c-d-f-g".to_string(),
pattern: "*.html".to_string(),
},
];
assert_eq!(actual, expected,);
@ -340,75 +397,98 @@ mod tests {
#[test]
fn multiple_expansions_per_folder_starting_at_the_root() {
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 base = create_folders(&[
"projects/a-c-d-f/b-d-e-g",
"projects/a-c-d-f/b-d-f-g",
"projects/a-c-d-f/c-d-e-g",
"projects/a-c-d-f/c-d-f-g",
"projects/a-c-e-f/b-d-e-g",
"projects/a-c-e-f/b-d-f-g",
"projects/a-c-e-f/c-d-e-g",
"projects/a-c-e-f/c-d-f-g",
"projects/b-c-d-f/b-d-e-g",
"projects/b-c-d-f/b-d-f-g",
"projects/b-c-d-f/c-d-e-g",
"projects/b-c-d-f/c-d-f-g",
"projects/b-c-e-f/b-d-e-g",
"projects/b-c-e-f/b-d-f-g",
"projects/b-c-e-f/c-d-e-g",
"projects/b-c-e-f/c-d-f-g",
]);
let actual = test(
&base,
&[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"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/b-c-d-f/b-d-e-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a-c-e-f/b-d-e-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/b-c-e-f/b-d-e-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a-c-d-f/c-d-e-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/b-c-d-f/c-d-e-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a-c-e-f/c-d-e-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/b-c-e-f/c-d-e-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a-c-d-f/b-d-f-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/b-c-d-f/b-d-f-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a-c-e-f/b-d-f-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/b-c-e-f/b-d-f-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a-c-d-f/c-d-f-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/b-c-d-f/c-d-f-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a-c-e-f/c-d-f-g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/b-c-e-f/c-d-f-g"),
vec!["*.html".to_string()],
),
GlobEntry {
base: "/projects/a-c-d-f/b-d-e-g".into(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/a-c-d-f/b-d-f-g".into(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/a-c-d-f/c-d-e-g".into(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/a-c-d-f/c-d-f-g".into(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/a-c-e-f/b-d-e-g".into(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/a-c-e-f/b-d-f-g".into(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/a-c-e-f/c-d-e-g".into(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/a-c-e-f/c-d-f-g".into(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/b-c-d-f/b-d-e-g".into(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/b-c-d-f/b-d-f-g".into(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/b-c-d-f/c-d-e-g".into(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/b-c-d-f/c-d-f-g".into(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/b-c-e-f/b-d-e-g".into(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/b-c-e-f/b-d-f-g".into(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/b-c-e-f/c-d-e-g".into(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/b-c-e-f/c-d-f-g".into(),
pattern: "*.html".to_string(),
},
];
assert_eq!(actual, expected,);
@ -416,20 +496,25 @@ mod tests {
#[test]
fn it_should_stop_expanding_once_we_hit_a_wildcard() {
let actual = get_fast_patterns(&vec![GlobEntry {
base: "/projects".to_string(),
pattern: "{foo,bar}/example/**/{baz,qux}/*.html".to_string(),
}]);
let base = create_folders(&["projects/bar/example", "projects/foo/example"]);
let actual = test(
&base,
&[GlobEntry {
base: "/projects".to_string(),
pattern: "{foo,bar}/example/**/{baz,qux}/*.html".to_string(),
}],
);
let expected = vec![
(
PathBuf::from("/projects/foo/example"),
vec!["**/{baz,qux}/*.html".to_string()],
),
(
PathBuf::from("/projects/bar/example"),
vec!["**/{baz,qux}/*.html".to_string()],
),
GlobEntry {
base: "/projects/bar/example".to_string(),
pattern: "{**/baz/*.html,**/qux/*.html}".to_string(),
},
GlobEntry {
base: "/projects/foo/example".to_string(),
pattern: "{**/baz/*.html,**/qux/*.html}".to_string(),
},
];
assert_eq!(actual, expected,);
@ -437,41 +522,60 @@ mod tests {
#[test]
fn it_should_keep_the_negation_symbol_for_all_new_patterns() {
let actual = get_fast_patterns(&vec![GlobEntry {
let base = create_folders(&["projects"]);
let actual = test(
&base,
&[GlobEntry {
base: "/projects".to_string(),
pattern: "!{foo,bar}/*.html".to_string(),
}],
);
let expected = 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()]),
];
// TODO: This is wrong, because `!` should be in front. But right now we don't support
// `@source "!../foo/bar";` anyway.
pattern: "{!bar/*.html,!foo/*.html}".to_string(),
}];
assert_eq!(actual, expected,);
}
#[test]
fn it_should_expand_a_complex_example() {
let actual = get_fast_patterns(&vec![GlobEntry {
base: "/projects".to_string(),
pattern: "a/{b,c}/d/{e,f}/g/*.html".to_string(),
}]);
let base = create_folders(&[
"projects/a/b/d/e/g",
"projects/a/b/d/f/g",
"projects/a/c/d/e/g",
"projects/a/c/d/f/g",
]);
let actual = test(
&base,
&[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"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a/c/d/e/g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a/b/d/f/g"),
vec!["*.html".to_string()],
),
(
PathBuf::from("/projects/a/c/d/f/g"),
vec!["*.html".to_string()],
),
GlobEntry {
base: "/projects/a/b/d/e/g".to_string(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/a/b/d/f/g".to_string(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/a/c/d/e/g".to_string(),
pattern: "*.html".to_string(),
},
GlobEntry {
base: "/projects/a/c/d/f/g".to_string(),
pattern: "*.html".to_string(),
},
];
assert_eq!(actual, expected,);

View File

@ -1,9 +1,12 @@
use crate::glob::hoist_static_glob_parts;
use crate::parser::Extractor;
use crate::scanner::allowed_paths::resolve_paths;
use crate::scanner::detect_sources::DetectSources;
use bexpand::Expression;
use bstr::ByteSlice;
use fxhash::{FxHashMap, FxHashSet};
use glob::fast_glob;
use glob::get_fast_patterns;
use glob::optimize_patterns;
use glob_match::glob_match;
use rayon::prelude::*;
use std::fs;
use std::path::PathBuf;
@ -54,7 +57,7 @@ pub struct ScanResult {
pub globs: Vec<GlobEntry>,
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq)]
pub struct GlobEntry {
pub base: String,
pub pattern: String,
@ -62,9 +65,6 @@ pub struct GlobEntry {
#[derive(Debug, Clone, Default)]
pub struct Scanner {
/// Auto content configuration
detect_sources: Option<DetectSources>,
/// Glob sources
sources: Option<Vec<GlobEntry>>,
@ -86,9 +86,8 @@ pub struct Scanner {
}
impl Scanner {
pub fn new(detect_sources: Option<DetectSources>, sources: Option<Vec<GlobEntry>>) -> Self {
pub fn new(sources: Option<Vec<GlobEntry>>) -> Self {
Self {
detect_sources,
sources,
..Default::default()
}
@ -206,21 +205,11 @@ impl Scanner {
return;
}
self.detect_sources();
self.scan_sources();
self.ready = true;
}
#[tracing::instrument(skip_all)]
fn detect_sources(&mut self) {
if let Some(detect_sources) = &self.detect_sources {
let (files, globs) = detect_sources.detect();
self.files.extend(files);
self.globs.extend(globs);
}
}
#[tracing::instrument(skip_all)]
fn scan_sources(&mut self) {
let Some(sources) = &self.sources else {
@ -231,46 +220,110 @@ impl Scanner {
return;
}
let resolved_files: Vec<_> = match fast_glob(sources) {
Ok(matches) => matches
.filter_map(|x| dunce::canonicalize(&x).ok())
.collect(),
Err(err) => {
event!(tracing::Level::ERROR, "Failed to resolve glob: {:?}", err);
vec![]
}
};
self.files.extend(resolved_files);
self.globs.extend(sources.clone());
// Re-optimize the globs to reduce the number of patterns we have to scan.
self.globs = get_fast_patterns(&self.globs)
.into_iter()
.filter_map(|(root, globs)| {
let root = match dunce::canonicalize(root) {
Ok(root) => root,
Err(error) => {
event!(
tracing::Level::ERROR,
"Failed to canonicalize base path {:?}",
error
);
return None;
}
// Expand glob patterns and create new `GlobEntry` instances for each expanded pattern.
let sources = sources
.iter()
.flat_map(|source| {
let expression: Result<Expression, _> = source.pattern[..].try_into();
let Ok(expression) = expression else {
return vec![source.clone()];
};
Some((root, globs))
expression
.into_iter()
.filter_map(Result::ok)
.map(move |pattern| GlobEntry {
base: source.base.clone(),
pattern: pattern.into(),
})
.collect::<Vec<_>>()
})
.flat_map(|(root, globs)| {
let base = root.display().to_string();
.collect::<Vec<_>>();
globs.into_iter().map(move |glob| GlobEntry {
base: base.clone(),
pattern: glob,
})
})
.collect::<Vec<GlobEntry>>();
// Partition sources into sources that should be promoted to auto source detection and
// sources that should be resolved as globs.
let (auto_sources, glob_sources): (Vec<_>, Vec<_>) = sources.iter().partition(|source| {
// If a glob ends with `/**/*`, then we just want to register the base path as a new
// base. Essentially converting it to use auto source detection.
if source.pattern.ends_with("**/*") {
return true;
}
// Directories should be promoted to auto source detection
if PathBuf::from(&source.base).join(&source.pattern).is_dir() {
return true;
}
false
});
for path in auto_sources
.iter()
.map(|source| PathBuf::from(&source.base).join(source.pattern.trim_end_matches("**/*")))
{
// Insert a glob for the base path, so we can see new files/folders in the directory itself.
self.globs.push(GlobEntry {
base: path.to_string_lossy().into(),
pattern: "*".into(),
});
// Detect all files/folders in the directory
let detect_sources = DetectSources::new(path);
let (files, globs) = detect_sources.detect();
self.files.extend(files);
self.globs.extend(globs);
}
// Turn `Vec<&GlobEntry>` in `Vec<GlobEntry>`
let glob_sources: Vec<_> = glob_sources.into_iter().cloned().collect();
let hoisted = hoist_static_glob_parts(&glob_sources);
for source in &hoisted {
// If the pattern is empty, then the base points to a specific file or folder already
// if it doesn't contain any dynamic parts. In that case we can use the base as the
// pattern.
//
// Otherwise we need to combine the base and the pattern, otherwise a pattern that
// looks like `*.html`, will never match a path that looks like
// `/my-project/project-a/index.html`, because it contains `/`.
//
// We can't prepend `**/`, because then `/my-project/project-a/nested/index.html` would
// match as well.
//
// Instead we combine the base and the pattern as a single glob pattern.
let mut full_pattern = source.base.clone();
if !source.pattern.is_empty() {
full_pattern.push('/');
full_pattern.push_str(&source.pattern);
}
let base = PathBuf::from(&source.base);
for entry in resolve_paths(&base) {
let Some(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_file() {
continue;
}
let file_path = entry.into_path();
let Some(file_path_str) = file_path.to_str() else {
continue;
};
if glob_match(&full_pattern, file_path_str) {
self.files.push(file_path);
}
}
}
self.globs.extend(hoisted);
// Re-optimize the globs to reduce the number of patterns we have to scan.
self.globs = optimize_patterns(&self.globs);
}
}

View File

@ -30,7 +30,7 @@ pub fn resolve_allowed_paths(root: &Path) -> impl Iterator<Item = DirEntry> {
WalkBuilder::new(root)
.hidden(false)
.require_git(false)
.filter_entry(|entry| match entry.file_type() {
.filter_entry(move |entry| match entry.file_type() {
Some(file_type) if file_type.is_dir() => match entry.file_name().to_str() {
Some(dir) => !IGNORED_CONTENT_DIRS.contains(&dir),
None => false,
@ -44,6 +44,15 @@ pub fn resolve_allowed_paths(root: &Path) -> impl Iterator<Item = DirEntry> {
.filter_map(Result::ok)
}
#[tracing::instrument(skip(root))]
pub fn resolve_paths(root: &Path) -> impl Iterator<Item = DirEntry> {
WalkBuilder::new(root)
.hidden(false)
.require_git(false)
.build()
.filter_map(Result::ok)
}
pub fn is_allowed_content_path(path: &Path) -> bool {
// Skip known ignored files
if path

View File

@ -1,6 +1,5 @@
#[cfg(test)]
mod scanner {
use scanner::detect_sources::DetectSources;
use std::process::Command;
use std::{fs, path};
@ -35,18 +34,20 @@ mod scanner {
let base = format!("{}", dir.display());
// Resolve all content paths for the (temporary) current working directory
let mut scanner = Scanner::new(
Some(DetectSources::new(base.clone().into())),
Some(
globs
.iter()
.map(|x| GlobEntry {
base: base.clone(),
pattern: x.to_string(),
})
.collect(),
),
);
let mut sources: Vec<GlobEntry> = globs
.iter()
.map(|x| GlobEntry {
base: base.clone(),
pattern: x.to_string(),
})
.collect();
sources.push(GlobEntry {
base: base.clone(),
pattern: "**/*".to_string(),
});
let mut scanner = Scanner::new(Some(sources));
let candidates = scanner.scan();
@ -65,17 +66,20 @@ mod scanner {
));
}
let parent_dir = format!(
"{}{}",
fs::canonicalize(&base).unwrap().display(),
path::MAIN_SEPARATOR
);
paths = paths
.into_iter()
.map(|x| {
let parent_dir = format!("{}{}", &base.to_string(), path::MAIN_SEPARATOR);
x.replace(&parent_dir, "")
// Normalize paths to use unix style separators
.replace('\\', "/")
x.replace(&parent_dir, "").replace('\\', "/") // Normalize paths to use unix style separators
})
.collect();
// Sort the output for easier comparison (depending on internal datastructure the order
// Sort the output for easier comparison (depending on internal data structure the order
// _could_ be random)
paths.sort();
@ -98,7 +102,7 @@ mod scanner {
("b.html", None),
("c.html", None),
]);
assert_eq!(globs, vec!["a.html", "b.html", "c.html", "index.html"]);
assert_eq!(globs, vec!["*", "a.html", "b.html", "c.html", "index.html"]);
}
#[test]
@ -110,7 +114,7 @@ mod scanner {
("b.html", None),
("c.html", None),
]);
assert_eq!(globs, vec!["a.html", "c.html", "index.html"]);
assert_eq!(globs, vec!["*", "a.html", "c.html", "index.html"]);
}
#[test]
@ -124,6 +128,7 @@ mod scanner {
assert_eq!(
globs,
vec![
"*",
"index.html",
"public/a.html",
"public/b.html",
@ -148,6 +153,7 @@ mod scanner {
assert_eq!(
globs,
vec![
"*",
"index.html",
"public/a.html",
"public/b.html",
@ -170,7 +176,7 @@ mod scanner {
("public/b.html", None),
("public/c.html", None),
]);
assert_eq!(globs, vec!["index.html", "public/c.html",]);
assert_eq!(globs, vec!["*", "index.html", "public/c.html",]);
}
#[test]
@ -181,7 +187,7 @@ mod scanner {
("src/b.html", None),
("src/c.html", None),
]);
assert_eq!(globs, vec![
assert_eq!(globs, vec!["*",
"index.html",
"src/**/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}",
"src/a.html",
@ -198,7 +204,7 @@ mod scanner {
("b.png", None),
("c.lock", None),
]);
assert_eq!(globs, vec!["index.html"]);
assert_eq!(globs, vec!["*", "index.html"]);
}
#[test]
@ -209,7 +215,7 @@ mod scanner {
("b.sass", None),
("c.less", None),
]);
assert_eq!(globs, vec!["index.html"]);
assert_eq!(globs, vec!["*", "index.html"]);
}
#[test]
@ -219,7 +225,7 @@ mod scanner {
("package-lock.json", None),
("yarn.lock", None),
]);
assert_eq!(globs, vec!["index.html"]);
assert_eq!(globs, vec!["*", "index.html"]);
}
#[test]
@ -270,6 +276,7 @@ mod scanner {
assert_eq!(
globs,
vec![
"*",
"bar.html",
"baz.html",
"foo.html",
@ -358,7 +365,7 @@ mod scanner {
// detection.
("foo.styl", Some("content-['foo.styl']")),
],
vec!["*.styl"],
vec!["foo.styl"],
)
.1;

View File

@ -1,6 +1,7 @@
import dedent from 'dedent'
import os from 'node:os'
import path from 'node:path'
import { describe } from 'vitest'
import { describe, expect } from 'vitest'
import { candidate, css, html, js, json, test, yaml } from '../utils'
const STANDALONE_BINARY = (() => {
@ -255,3 +256,564 @@ describe.each([
},
)
})
test(
'auto source detection kitchen sink',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'index.css': css`
@import 'tailwindcss/theme' theme(reference);
/* (1) */
/* - Only './src' should be auto-scanned, not the current working directory */
/* - .gitignore'd paths should be ignored (node_modules) */
/* - Binary extensions should be ignored (jpg, zip) */
@import 'tailwindcss/utilities' source('./src');
/* (2) */
/* - All HTML and JSX files in 'ignored/components' should be scanned */
/* - All other extensions should be ignored */
@source "./ignored/components/*.{html,jsx}";
/* (3) */
/* - './components' should be auto-scanned in addition to './src' */
/* - './components/ignored.html' should still be ignored */
/* - Binary extensions in './components' should be ignored */
@source "./components";
/* (4) */
/* - './pages' should be auto-scanned */
/* - Only '.html' files should be included */
/* - './page/ignored.html' should be ignored */
@source "./pages/**/*.html";
`,
'.gitignore': dedent`
/src/ignored
/ignored
/components/ignored.html
/pages/ignored.html
`,
// (1)
'index.html': 'content-["index.html"] content-["BAD"]', // "Root" source is in `./src`
'src/index.html': 'content-["src/index.html"]',
'src/nested/index.html': 'content-["src/nested/index.html"]',
'src/index.jpg': 'content-["src/index.jpg"] content-["BAD"]',
'src/nested/index.tar': 'content-["src/nested/index.tar"] content-["BAD"]',
'src/ignored/index.html': 'content-["src/ignored/index.html"] content-["BAD"]',
// (2)
'ignored/components/my-component.html': 'content-["ignored/components/my-component.html"]',
'ignored/components/my-component.jsx': 'content-["ignored/components/my-component.jsx"]',
// Ignored and not explicitly listed by (2)
'ignored/components/my-component.tsx':
'content-["ignored/components/my-component.tsx"] content-["BAD"]',
'ignored/components/nested/my-component.html':
'content-["ignored/components/nested/my-component.html"] content-["BAD"]',
// (3)
'components/my-component.tsx': 'content-["components/my-component.tsx"]',
'components/nested/my-component.tsx': 'content-["components/nested/my-component.tsx"]',
'components/ignored.html': 'content-["components/ignored.html"] content-["BAD"]',
// (4)
'pages/foo.html': 'content-["pages/foo.html"]',
'pages/nested/foo.html': 'content-["pages/nested/foo.html"]',
'pages/ignored.html': 'content-["pages/ignored.html"] content-["BAD"]',
'pages/foo.jsx': 'content-["pages/foo.jsx"] content-["BAD"]',
'pages/nested/foo.jsx': 'content-["pages/nested/foo.jsx"] content-["BAD"]',
},
},
async ({ fs, exec }) => {
await exec('pnpm tailwindcss --input index.css --output dist/out.css')
expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(`
"
--- ./dist/out.css ---
.content-\\[\\"components\\/my-component\\.tsx\\"\\] {
--tw-content: "components/my-component.tsx";
content: var(--tw-content);
}
.content-\\[\\"components\\/nested\\/my-component\\.tsx\\"\\] {
--tw-content: "components/nested/my-component.tsx";
content: var(--tw-content);
}
.content-\\[\\"ignored\\/components\\/my-component\\.html\\"\\] {
--tw-content: "ignored/components/my-component.html";
content: var(--tw-content);
}
.content-\\[\\"ignored\\/components\\/my-component\\.jsx\\"\\] {
--tw-content: "ignored/components/my-component.jsx";
content: var(--tw-content);
}
.content-\\[\\"pages\\/foo\\.html\\"\\] {
--tw-content: "pages/foo.html";
content: var(--tw-content);
}
.content-\\[\\"pages\\/nested\\/foo\\.html\\"\\] {
--tw-content: "pages/nested/foo.html";
content: var(--tw-content);
}
.content-\\[\\"src\\/index\\.html\\"\\] {
--tw-content: "src/index.html";
content: var(--tw-content);
}
.content-\\[\\"src\\/nested\\/index\\.html\\"\\] {
--tw-content: "src/nested/index.html";
content: var(--tw-content);
}
@supports (-moz-orient: inline) {
@layer base {
*, ::before, ::after, ::backdrop {
--tw-content: "";
}
}
}
@property --tw-content {
syntax: "*";
inherits: false;
initial-value: "";
}
"
`)
},
)
test(
'auto source detection in depth, source(…) and `@source` can be configured to use auto source detection (build + 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/src/index.css': css`
@import 'tailwindcss/theme' theme(reference);
/* Run auto-content detection in ../../project-b */
@import 'tailwindcss/utilities' source('../../project-b');
/* Explicitly using node_modules in the @source allows git ignored folders */
@source '../node_modules/{my-lib-1,my-lib-2}/src/**/*.html';
/* We typically ignore these extensions, but now include them explicitly */
@source './logo.{jpg,png}';
/* Project C should apply auto source detection */
@source '../../project-c';
/* Project D should apply auto source detection rules, such as ignoring node_modules */
@source '../../project-d/**/*.{html,js}';
@source '../../project-d/**/*.bin';
/* Same as above, but my-lib-2 _should_ be includes */
@source '../../project-d/node_modules/my-lib-2/src/*.{html,js}';
/* bar.html is git ignored, but explicitly listed here to scan */
@source '../../project-d/src/bar.html';
`,
// Project A is the current folder, but we explicitly configured
// `source(project-b)`, therefore project-a should not be included in
// the output.
'project-a/src/index.html': html`
<div
class="content-['SHOULD-NOT-EXIST-IN-OUTPUT'] content-['project-a/src/index.html']"
></div>
`,
// Project A explicitly includes an extension we usually ignore,
// therefore it should be included in the output.
'project-a/src/logo.jpg': html`
<div
class="content-['project-a/src/logo.jpg']"
></div>
`,
// Project A explicitly includes node_modules/{my-lib-1,my-lib-2},
// therefore these files should be included in the output.
'project-a/node_modules/my-lib-1/src/index.html': html`
<div
class="content-['project-a/node_modules/my-lib-1/src/index.html']"
></div>
`,
'project-a/node_modules/my-lib-2/src/index.html': html`
<div
class="content-['project-a/node_modules/my-lib-2/src/index.html']"
></div>
`,
// Project B is the configured `source(…)`, therefore auto source
// detection should include known extensions and folders in the output.
'project-b/src/index.html': html`
<div
class="content-['project-b/src/index.html']"
></div>
`,
// Project B is the configured `source(…)`, therefore auto source
// detection should apply and node_modules should not be included in the
// output.
'project-b/node_modules/my-lib-3/src/index.html': html`
<div
class="content-['SHOULD-NOT-EXIST-IN-OUTPUT'] content-['project-b/node_modules/my-lib-3/src/index.html']"
></div>
`,
// Project C should apply auto source detection, therefore known
// extensions and folders should be included in the output.
'project-c/src/index.html': html`
<div
class="content-['project-c/src/index.html']"
></div>
`,
// Project C should apply auto source detection, therefore known ignored
// extensions should not be included in the output.
'project-c/src/logo.jpg': html`
<div
class="content-['SHOULD-NOT-EXIST-IN-OUTPUT'] content-['project-c/src/logo.jpg']"
></div>
`,
// Project C should apply auto source detection, therefore node_modules
// should not be included in the output.
'project-c/node_modules/my-lib-1/src/index.html': html`
<div
class="content-['SHOULD-NOT-EXIST-IN-OUTPUT'] content-['project-c/node_modules/my-lib-1/src/index.html']"
></div>
`,
// Project D should apply auto source detection rules, such as ignoring
// node_modules.
'project-d/node_modules/my-lib-1/src/index.html': html`
<div
class="content-['SHOULD-NOT-EXIST-IN-OUTPUT'] content-['project-d/node_modules/my-lib-1/src/index.html']"
></div>
`,
// Project D has an explicit glob containing node_modules, thus should include the html file
'project-d/node_modules/my-lib-2/src/index.html': html`
<div
class="content-['project-d/node_modules/my-lib-2/src/index.html']"
></div>
`,
'project-d/src/.gitignore': dedent`
foo.html
bar.html
`,
// Project D, foo.html is ignored by the gitignore file.
'project-d/src/foo.html': html`
<div
class="content-['SHOULD-NOT-EXIST-IN-OUTPUT'] content-['project-d/src/foo.html']"
></div>
`,
// Project D, bar.html is ignored by the gitignore file. But explicitly
// listed as a `@source` glob.
'project-d/src/bar.html': html`
<div
class="content-['project-d/src/bar.html']"
></div>
`,
// Project D should look for files with the extensions html and js.
'project-d/src/index.html': html`
<div
class="content-['project-d/src/index.html']"
></div>
`,
// Project D should have a binary file even though we ignore binary files
// by default, but it's explicitly listed.
'project-d/my-binary-file.bin': html`
<div
class="content-['project-d/my-binary-file.bin']"
></div>
`,
},
},
async ({ fs, exec, spawn, root }) => {
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css', {
cwd: path.join(root, 'project-a'),
})
expect(await fs.dumpFiles('./project-a/dist/*.css')).toMatchInlineSnapshot(`
"
--- ./project-a/dist/out.css ---
.content-\\[\\'project-a\\/node_modules\\/my-lib-1\\/src\\/index\\.html\\'\\] {
--tw-content: 'project-a/node modules/my-lib-1/src/index.html';
content: var(--tw-content);
}
.content-\\[\\'project-a\\/node_modules\\/my-lib-2\\/src\\/index\\.html\\'\\] {
--tw-content: 'project-a/node modules/my-lib-2/src/index.html';
content: var(--tw-content);
}
.content-\\[\\'project-a\\/src\\/logo\\.jpg\\'\\] {
--tw-content: 'project-a/src/logo.jpg';
content: var(--tw-content);
}
.content-\\[\\'project-b\\/src\\/index\\.html\\'\\] {
--tw-content: 'project-b/src/index.html';
content: var(--tw-content);
}
.content-\\[\\'project-c\\/src\\/index\\.html\\'\\] {
--tw-content: 'project-c/src/index.html';
content: var(--tw-content);
}
.content-\\[\\'project-d\\/my-binary-file\\.bin\\'\\] {
--tw-content: 'project-d/my-binary-file.bin';
content: var(--tw-content);
}
.content-\\[\\'project-d\\/node_modules\\/my-lib-2\\/src\\/index\\.html\\'\\] {
--tw-content: 'project-d/node modules/my-lib-2/src/index.html';
content: var(--tw-content);
}
.content-\\[\\'project-d\\/src\\/bar\\.html\\'\\] {
--tw-content: 'project-d/src/bar.html';
content: var(--tw-content);
}
.content-\\[\\'project-d\\/src\\/index\\.html\\'\\] {
--tw-content: 'project-d/src/index.html';
content: var(--tw-content);
}
@supports (-moz-orient: inline) {
@layer base {
*, ::before, ::after, ::backdrop {
--tw-content: "";
}
}
}
@property --tw-content {
syntax: "*";
inherits: false;
initial-value: "";
}
"
`)
// Watch mode tests
await spawn('pnpm tailwindcss --input src/index.css --output dist/out.css --watch', {
cwd: path.join(root, 'project-a'),
})
// Changes to project-a should not be included in the output, we changed the
// base folder to project-b.
await fs.write(
'project-a/src/index.html',
html`<div class="[.changed_&]:content-['project-a/src/index.html']"></div>`,
)
await fs.expectFileNotToContain('./project-a/dist/out.css', [
candidate`[.changed_&]:content-['project-a/src/index.html']`,
])
// Changes to this file should be included, because we explicitly listed
// them using `@source`.
await fs.write(
'project-a/src/logo.jpg',
html`<div class="[.changed_&]:content-['project-a/src/logo.jpg']"></div>`,
)
await fs.expectFileToContain('./project-a/dist/out.css', [
candidate`[.changed_&]:content-['project-a/src/logo.jpg']`,
])
// Changes to these files should be included, because we explicitly listed
// them using `@source`.
await fs.write(
'project-a/node_modules/my-lib-1/src/index.html',
html`<div
class="[.changed_&]:content-['project-a/node_modules/my-lib-1/src/index.html']"
></div>`,
)
await fs.expectFileToContain('./project-a/dist/out.css', [
candidate`[.changed_&]:content-['project-a/node_modules/my-lib-1/src/index.html']`,
])
await fs.write(
'project-a/node_modules/my-lib-2/src/index.html',
html`<div
class="[.changed_&]:content-['project-a/node_modules/my-lib-2/src/index.html']"
></div>`,
)
await fs.expectFileToContain('./project-a/dist/out.css', [
candidate`[.changed_&]:content-['project-a/node_modules/my-lib-2/src/index.html']`,
])
// Changes to this file should be included, because we changed the base to
// `project-b`.
await fs.write(
'project-b/src/index.html',
html`<div class="[.changed_&]:content-['project-b/src/index.html']"></div>`,
)
await fs.expectFileToContain('./project-a/dist/out.css', [
candidate`[.changed_&]:content-['project-b/src/index.html']`,
])
// Changes to this file should not be included. We did change the base to
// `project-b`, but we still apply the auto source detection rules which
// ignore `node_modules`.
await fs.write(
'project-b/node_modules/my-lib-3/src/index.html',
html`<div
class="[.changed_&]:content-['project-b/node_modules/my-lib-3/src/index.html']"
></div>`,
)
await fs.expectFileNotToContain('./project-a/dist/out.css', [
candidate`[.changed_&]:content-['project-b/node_modules/my-lib-3/src/index.html']`,
])
// Project C was added explicitly via `@source`, therefore changes to these
// files should be included.
await fs.write(
'project-c/src/index.html',
html`<div class="[.changed_&]:content-['project-c/src/index.html']"></div>`,
)
await fs.expectFileToContain('./project-a/dist/out.css', [
candidate`[.changed_&]:content-['project-c/src/index.html']`,
])
// Except for these files, since they are ignored by the default auto source
// detection rules.
await fs.write(
'project-c/src/logo.jpg',
html`<div class="[.changed_&]:content-['project-c/src/logo.jpg']"></div>`,
)
await fs.expectFileNotToContain('./project-a/dist/out.css', [
candidate`[.changed_&]:content-['project-c/src/logo.jpg']`,
])
await fs.write(
'project-c/node_modules/my-lib-1/src/index.html',
html`<div
class="[.changed_&]:content-['project-c/node_modules/my-lib-1/src/index.html']"
></div>`,
)
await fs.expectFileNotToContain('./project-a/dist/out.css', [
candidate`[.changed_&]:content-['project-c/node_modules/my-lib-1/src/index.html']`,
])
// Creating new files in the "root" of auto source detected folders
await fs.write(
'project-b/new-file.html',
html`<div class="[.created_&]:content-['project-b/new-file.html']"></div>`,
)
await fs.write(
'project-b/new-folder/new-file.html',
html`<div class="[.created_&]:content-['project-b/new-folder/new-file.html']"></div>`,
)
await fs.write(
'project-c/new-file.html',
html`<div class="[.created_&]:content-['project-c/new-file.html']"></div>`,
)
await fs.write(
'project-c/new-folder/new-file.html',
html`<div class="[.created_&]:content-['project-c/new-folder/new-file.html']"></div>`,
)
await fs.expectFileToContain('./project-a/dist/out.css', [
candidate`[.created_&]:content-['project-b/new-file.html']`,
candidate`[.created_&]:content-['project-b/new-folder/new-file.html']`,
candidate`[.created_&]:content-['project-c/new-file.html']`,
candidate`[.created_&]:content-['project-c/new-folder/new-file.html']`,
])
},
)
test(
'auto source detection disabled',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'index.css': css`
@import 'tailwindcss/theme' theme(reference);
/* (1) */
/* - Only './src' should be auto-scanned, not the current working directory */
/* - .gitignore'd paths should be ignored (node_modules) */
/* - Binary extensions should be ignored (jpg, zip) */
@import 'tailwindcss/utilities' source(none);
/* (2) */
/* - './pages' should be auto-scanned */
/* - Only '.html' files should be included */
/* - './page/ignored.html' should be ignored */
@source "./pages/**/*.html";
`,
'.gitignore': dedent`
/src/ignored
/pages/ignored.html
`,
// (1)
'index.html': 'content-["index.html"] content-["BAD"]', // "Root" source is in `./src`
'src/index.html': 'content-["src/index.html"] content-["BAD"]',
'src/nested/index.html': 'content-["src/nested/index.html"] content-["BAD"]',
'src/index.jpg': 'content-["src/index.jpg"] content-["BAD"]',
'src/nested/index.tar': 'content-["src/nested/index.tar"] content-["BAD"]',
'src/ignored/index.html': 'content-["src/ignored/index.html"] content-["BAD"]',
// (4)
'pages/foo.html': 'content-["pages/foo.html"]',
'pages/nested/foo.html': 'content-["pages/nested/foo.html"]',
'pages/ignored.html': 'content-["pages/ignored.html"] content-["BAD"]',
'pages/foo.jsx': 'content-["pages/foo.jsx"] content-["BAD"]',
'pages/nested/foo.jsx': 'content-["pages/nested/foo.jsx"] content-["BAD"]',
},
},
async ({ fs, exec }) => {
await exec('pnpm tailwindcss --input index.css --output dist/out.css')
expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(`
"
--- ./dist/out.css ---
.content-\\[\\"pages\\/foo\\.html\\"\\] {
--tw-content: "pages/foo.html";
content: var(--tw-content);
}
.content-\\[\\"pages\\/nested\\/foo\\.html\\"\\] {
--tw-content: "pages/nested/foo.html";
content: var(--tw-content);
}
@supports (-moz-orient: inline) {
@layer base {
*, ::before, ::after, ::backdrop {
--tw-content: "";
}
}
}
@property --tw-content {
syntax: "*";
inherits: false;
initial-value: "";
}
"
`)
},
)

View File

@ -1,4 +1,6 @@
import dedent from 'dedent'
import path from 'node:path'
import { expect } from 'vitest'
import { candidate, css, html, js, json, test, yaml } from '../utils'
test(
@ -464,3 +466,601 @@ test(
await fs.expectFileToContain('project-a/dist/out.css', [candidate`content-['c/src/index.js']`])
},
)
test(
'auto source detection kitchen sink',
{
fs: {
'package.json': json`
{
"dependencies": {
"postcss": "^8",
"postcss-cli": "^10",
"tailwindcss": "workspace:^",
"@tailwindcss/postcss": "workspace:^"
}
}
`,
'postcss.config.js': js`
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
}
`,
'index.css': css`
@import 'tailwindcss/theme' theme(reference);
/* (1) */
/* - Only './src' should be auto-scanned, not the current working directory */
/* - .gitignore'd paths should be ignored (node_modules) */
/* - Binary extensions should be ignored (jpg, zip) */
@import 'tailwindcss/utilities' source('./src');
/* (2) */
/* - All HTML and JSX files in 'ignored/components' should be scanned */
/* - All other extensions should be ignored */
@source "./ignored/components/*.{html,jsx}";
/* (3) */
/* - './components' should be auto-scanned in addition to './src' */
/* - './components/ignored.html' should still be ignored */
/* - Binary extensions in './components' should be ignored */
@source "./components";
/* (4) */
/* - './pages' should be auto-scanned */
/* - Only '.html' files should be included */
/* - './page/ignored.html' should be ignored */
@source "./pages/**/*.html";
`,
'.gitignore': dedent`
/src/ignored
/ignored
/components/ignored.html
/pages/ignored.html
`,
// (1)
'index.html': 'content-["index.html"] content-["BAD"]', // "Root" source is in `./src`
'src/index.html': 'content-["src/index.html"]',
'src/nested/index.html': 'content-["src/nested/index.html"]',
'src/index.jpg': 'content-["src/index.jpg"] content-["BAD"]',
'src/nested/index.tar': 'content-["src/nested/index.tar"] content-["BAD"]',
'src/ignored/index.html': 'content-["src/ignored/index.html"] content-["BAD"]',
// (2)
'ignored/components/my-component.html': 'content-["ignored/components/my-component.html"]',
'ignored/components/my-component.jsx': 'content-["ignored/components/my-component.jsx"]',
// Ignored and not explicitly listed by (2)
'ignored/components/my-component.tsx':
'content-["ignored/components/my-component.tsx"] content-["BAD"]',
'ignored/components/nested/my-component.html':
'content-["ignored/components/nested/my-component.html"] content-["BAD"]',
// (3)
'components/my-component.tsx': 'content-["components/my-component.tsx"]',
'components/nested/my-component.tsx': 'content-["components/nested/my-component.tsx"]',
'components/ignored.html': 'content-["components/ignored.html"] content-["BAD"]',
// (4)
'pages/foo.html': 'content-["pages/foo.html"]',
'pages/nested/foo.html': 'content-["pages/nested/foo.html"]',
'pages/ignored.html': 'content-["pages/ignored.html"] content-["BAD"]',
'pages/foo.jsx': 'content-["pages/foo.jsx"] content-["BAD"]',
'pages/nested/foo.jsx': 'content-["pages/nested/foo.jsx"] content-["BAD"]',
},
},
async ({ fs, exec }) => {
await exec('pnpm postcss index.css --output dist/out.css')
expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(`
"
--- ./dist/out.css ---
.content-\\[\\"components\\/my-component\\.tsx\\"\\] {
--tw-content: "components/my-component.tsx";
content: var(--tw-content);
}
.content-\\[\\"components\\/nested\\/my-component\\.tsx\\"\\] {
--tw-content: "components/nested/my-component.tsx";
content: var(--tw-content);
}
.content-\\[\\"ignored\\/components\\/my-component\\.html\\"\\] {
--tw-content: "ignored/components/my-component.html";
content: var(--tw-content);
}
.content-\\[\\"ignored\\/components\\/my-component\\.jsx\\"\\] {
--tw-content: "ignored/components/my-component.jsx";
content: var(--tw-content);
}
.content-\\[\\"pages\\/foo\\.html\\"\\] {
--tw-content: "pages/foo.html";
content: var(--tw-content);
}
.content-\\[\\"pages\\/nested\\/foo\\.html\\"\\] {
--tw-content: "pages/nested/foo.html";
content: var(--tw-content);
}
.content-\\[\\"src\\/index\\.html\\"\\] {
--tw-content: "src/index.html";
content: var(--tw-content);
}
.content-\\[\\"src\\/nested\\/index\\.html\\"\\] {
--tw-content: "src/nested/index.html";
content: var(--tw-content);
}
@supports (-moz-orient: inline) {
@layer base {
*, ::before, ::after, ::backdrop {
--tw-content: "";
}
}
}
@property --tw-content {
syntax: "*";
inherits: false;
initial-value: "";
}
"
`)
},
)
test(
'auto source detection in depth, source(…) and `@source` can be configured to use auto source detection (build + 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/src/index.css': css`
@import 'tailwindcss/theme' theme(reference);
/* Run auto-content detection in ../../project-b */
@import 'tailwindcss/utilities' source('../../project-b');
/* Explicitly using node_modules in the @source allows git ignored folders */
@source '../node_modules/{my-lib-1,my-lib-2}/src/**/*.html';
/* We typically ignore these extensions, but now include them explicitly */
@source './logo.{jpg,png}';
/* Project C should apply auto source detection */
@source '../../project-c';
/* Project D should apply auto source detection rules, such as ignoring node_modules */
@source '../../project-d/**/*.{html,js}';
@source '../../project-d/**/*.bin';
/* Same as above, but my-lib-2 _should_ be includes */
@source '../../project-d/node_modules/my-lib-2/src/*.{html,js}';
/* bar.html is git ignored, but explicitly listed here to scan */
@source '../../project-d/src/bar.html';
`,
// Project A is the current folder, but we explicitly configured
// `source(project-b)`, therefore project-a should not be included in
// the output.
'project-a/src/index.html': html`
<div
class="content-['SHOULD-NOT-EXIST-IN-OUTPUT'] content-['project-a/src/index.html']"
></div>
`,
// Project A explicitly includes an extension we usually ignore,
// therefore it should be included in the output.
'project-a/src/logo.jpg': html`
<div
class="content-['project-a/src/logo.jpg']"
></div>
`,
// Project A explicitly includes node_modules/{my-lib-1,my-lib-2},
// therefore these files should be included in the output.
'project-a/node_modules/my-lib-1/src/index.html': html`
<div
class="content-['project-a/node_modules/my-lib-1/src/index.html']"
></div>
`,
'project-a/node_modules/my-lib-2/src/index.html': html`
<div
class="content-['project-a/node_modules/my-lib-2/src/index.html']"
></div>
`,
// Project B is the configured `source(…)`, therefore auto source
// detection should include known extensions and folders in the output.
'project-b/src/index.html': html`
<div
class="content-['project-b/src/index.html']"
></div>
`,
// Project B is the configured `source(…)`, therefore auto source
// detection should apply and node_modules should not be included in the
// output.
'project-b/node_modules/my-lib-3/src/index.html': html`
<div
class="content-['SHOULD-NOT-EXIST-IN-OUTPUT'] content-['project-b/node_modules/my-lib-3/src/index.html']"
></div>
`,
// Project C should apply auto source detection, therefore known
// extensions and folders should be included in the output.
'project-c/src/index.html': html`
<div
class="content-['project-c/src/index.html']"
></div>
`,
// Project C should apply auto source detection, therefore known ignored
// extensions should not be included in the output.
'project-c/src/logo.jpg': html`
<div
class="content-['SHOULD-NOT-EXIST-IN-OUTPUT'] content-['project-c/src/logo.jpg']"
></div>
`,
// Project C should apply auto source detection, therefore node_modules
// should not be included in the output.
'project-c/node_modules/my-lib-1/src/index.html': html`
<div
class="content-['SHOULD-NOT-EXIST-IN-OUTPUT'] content-['project-c/node_modules/my-lib-1/src/index.html']"
></div>
`,
// Project D should apply auto source detection rules, such as ignoring
// node_modules.
'project-d/node_modules/my-lib-1/src/index.html': html`
<div
class="content-['SHOULD-NOT-EXIST-IN-OUTPUT'] content-['project-d/node_modules/my-lib-1/src/index.html']"
></div>
`,
// Project D has an explicit glob containing node_modules, thus should include the html file
'project-d/node_modules/my-lib-2/src/index.html': html`
<div
class="content-['project-d/node_modules/my-lib-2/src/index.html']"
></div>
`,
'project-d/src/.gitignore': dedent`
foo.html
bar.html
`,
// Project D, foo.html is ignored by the gitignore file.
'project-d/src/foo.html': html`
<div
class="content-['SHOULD-NOT-EXIST-IN-OUTPUT'] content-['project-d/src/foo.html']"
></div>
`,
// Project D, bar.html is ignored by the gitignore file. But explicitly
// listed as a `@source` glob.
'project-d/src/bar.html': html`
<div
class="content-['project-d/src/bar.html']"
></div>
`,
// Project D should look for files with the extensions html and js.
'project-d/src/index.html': html`
<div
class="content-['project-d/src/index.html']"
></div>
`,
// Project D should have a binary file even though we ignore binary files
// by default, but it's explicitly listed.
'project-d/my-binary-file.bin': html`
<div
class="content-['project-d/my-binary-file.bin']"
></div>
`,
},
},
async ({ fs, exec, spawn, root }) => {
await exec('pnpm postcss src/index.css --output dist/out.css --verbose', {
cwd: path.join(root, 'project-a'),
})
expect(await fs.dumpFiles('./project-a/dist/*.css')).toMatchInlineSnapshot(`
"
--- ./project-a/dist/out.css ---
.content-\\[\\'project-a\\/node_modules\\/my-lib-1\\/src\\/index\\.html\\'\\] {
--tw-content: 'project-a/node modules/my-lib-1/src/index.html';
content: var(--tw-content);
}
.content-\\[\\'project-a\\/node_modules\\/my-lib-2\\/src\\/index\\.html\\'\\] {
--tw-content: 'project-a/node modules/my-lib-2/src/index.html';
content: var(--tw-content);
}
.content-\\[\\'project-a\\/src\\/logo\\.jpg\\'\\] {
--tw-content: 'project-a/src/logo.jpg';
content: var(--tw-content);
}
.content-\\[\\'project-b\\/src\\/index\\.html\\'\\] {
--tw-content: 'project-b/src/index.html';
content: var(--tw-content);
}
.content-\\[\\'project-c\\/src\\/index\\.html\\'\\] {
--tw-content: 'project-c/src/index.html';
content: var(--tw-content);
}
.content-\\[\\'project-d\\/my-binary-file\\.bin\\'\\] {
--tw-content: 'project-d/my-binary-file.bin';
content: var(--tw-content);
}
.content-\\[\\'project-d\\/node_modules\\/my-lib-2\\/src\\/index\\.html\\'\\] {
--tw-content: 'project-d/node modules/my-lib-2/src/index.html';
content: var(--tw-content);
}
.content-\\[\\'project-d\\/src\\/bar\\.html\\'\\] {
--tw-content: 'project-d/src/bar.html';
content: var(--tw-content);
}
.content-\\[\\'project-d\\/src\\/index\\.html\\'\\] {
--tw-content: 'project-d/src/index.html';
content: var(--tw-content);
}
@supports (-moz-orient: inline) {
@layer base {
*, ::before, ::after, ::backdrop {
--tw-content: "";
}
}
}
@property --tw-content {
syntax: "*";
inherits: false;
initial-value: "";
}
"
`)
// Watch mode tests
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...'))
// Changes to project-a should not be included in the output, we changed the
// base folder to project-b.
await fs.write(
'project-a/src/index.html',
html`<div class="[.changed_&]:content-['project-a/src/index.html']"></div>`,
)
await fs.expectFileNotToContain('./project-a/dist/out.css', [
candidate`[.changed_&]:content-['project-a/src/index.html']`,
])
// Changes to this file should be included, because we explicitly listed
// them using `@source`.
await fs.write(
'project-a/src/logo.jpg',
html`<div class="[.changed_&]:content-['project-a/src/logo.jpg']"></div>`,
)
await fs.expectFileToContain('./project-a/dist/out.css', [
candidate`[.changed_&]:content-['project-a/src/logo.jpg']`,
])
// Changes to these files should be included, because we explicitly listed
// them using `@source`.
await fs.write(
'project-a/node_modules/my-lib-1/src/index.html',
html`<div
class="[.changed_&]:content-['project-a/node_modules/my-lib-1/src/index.html']"
></div>`,
)
await fs.expectFileToContain('./project-a/dist/out.css', [
candidate`[.changed_&]:content-['project-a/node_modules/my-lib-1/src/index.html']`,
])
await fs.write(
'project-a/node_modules/my-lib-2/src/index.html',
html`<div
class="[.changed_&]:content-['project-a/node_modules/my-lib-2/src/index.html']"
></div>`,
)
await fs.expectFileToContain('./project-a/dist/out.css', [
candidate`[.changed_&]:content-['project-a/node_modules/my-lib-2/src/index.html']`,
])
// Changes to this file should be included, because we changed the base to
// `project-b`.
await fs.write(
'project-b/src/index.html',
html`<div class="[.changed_&]:content-['project-b/src/index.html']"></div>`,
)
await fs.expectFileToContain('./project-a/dist/out.css', [
candidate`[.changed_&]:content-['project-b/src/index.html']`,
])
// Changes to this file should not be included. We did change the base to
// `project-b`, but we still apply the auto source detection rules which
// ignore `node_modules`.
await fs.write(
'project-b/node_modules/my-lib-3/src/index.html',
html`<div
class="[.changed_&]:content-['project-b/node_modules/my-lib-3/src/index.html']"
></div>`,
)
await fs.expectFileNotToContain('./project-a/dist/out.css', [
candidate`[.changed_&]:content-['project-b/node_modules/my-lib-3/src/index.html']`,
])
// Project C was added explicitly via `@source`, therefore changes to these
// files should be included.
await fs.write(
'project-c/src/index.html',
html`<div class="[.changed_&]:content-['project-c/src/index.html']"></div>`,
)
await fs.expectFileToContain('./project-a/dist/out.css', [
candidate`[.changed_&]:content-['project-c/src/index.html']`,
])
// Except for these files, since they are ignored by the default auto source
// detection rules.
await fs.write(
'project-c/src/logo.jpg',
html`<div class="[.changed_&]:content-['project-c/src/logo.jpg']"></div>`,
)
await fs.expectFileNotToContain('./project-a/dist/out.css', [
candidate`[.changed_&]:content-['project-c/src/logo.jpg']`,
])
await fs.write(
'project-c/node_modules/my-lib-1/src/index.html',
html`<div
class="[.changed_&]:content-['project-c/node_modules/my-lib-1/src/index.html']"
></div>`,
)
await fs.expectFileNotToContain('./project-a/dist/out.css', [
candidate`[.changed_&]:content-['project-c/node_modules/my-lib-1/src/index.html']`,
])
// Creating new files in the "root" of auto source detected folders
// await fs.write(
// 'project-b/new-file.html',
// html`<div class="[.created_&]:content-['project-b/new-file.html']"></div>`,
// )
// await fs.write(
// 'project-b/new-folder/new-file.html',
// html`<div class="[.created_&]:content-['project-b/new-folder/new-file.html']"></div>`,
// )
// await fs.write(
// 'project-c/new-file.html',
// html`<div class="[.created_&]:content-['project-c/new-file.html']"></div>`,
// )
// await fs.write(
// 'project-c/new-folder/new-file.html',
// html`<div class="[.created_&]:content-['project-c/new-folder/new-file.html']"></div>`,
// )
// await fs.write('project-a/src/index.css', await fs.read('project-a/src/index.css'))
// await new Promise((resolve) => setTimeout(resolve, 1000))
// await fs.expectFileToContain('./project-a/dist/out.css', [
// candidate`[.created_&]:content-['project-b/new-file.html']`,
// candidate`[.created_&]:content-['project-b/new-folder/new-file.html']`,
// candidate`[.created_&]:content-['project-c/new-file.html']`,
// candidate`[.created_&]:content-['project-c/new-folder/new-file.html']`,
// ])
},
)
test(
'auto source detection disabled',
{
fs: {
'package.json': json`
{
"dependencies": {
"postcss": "^8",
"postcss-cli": "^10",
"tailwindcss": "workspace:^",
"@tailwindcss/postcss": "workspace:^"
}
}
`,
'postcss.config.js': js`
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
}
`,
'index.css': css`
@import 'tailwindcss/theme' theme(reference);
/* (1) */
/* - Only './src' should be auto-scanned, not the current working directory */
/* - .gitignore'd paths should be ignored (node_modules) */
/* - Binary extensions should be ignored (jpg, zip) */
@import 'tailwindcss/utilities' source(none);
/* (2) */
/* - './pages' should be auto-scanned */
/* - Only '.html' files should be included */
/* - './page/ignored.html' should be ignored */
@source "./pages/**/*.html";
`,
'.gitignore': dedent`
/src/ignored
/pages/ignored.html
`,
// (1)
'index.html': 'content-["index.html"] content-["BAD"]', // "Root" source is in `./src`
'src/index.html': 'content-["src/index.html"] content-["BAD"]',
'src/nested/index.html': 'content-["src/nested/index.html"] content-["BAD"]',
'src/index.jpg': 'content-["src/index.jpg"] content-["BAD"]',
'src/nested/index.tar': 'content-["src/nested/index.tar"] content-["BAD"]',
'src/ignored/index.html': 'content-["src/ignored/index.html"] content-["BAD"]',
// (4)
'pages/foo.html': 'content-["pages/foo.html"]',
'pages/nested/foo.html': 'content-["pages/nested/foo.html"]',
'pages/ignored.html': 'content-["pages/ignored.html"] content-["BAD"]',
'pages/foo.jsx': 'content-["pages/foo.jsx"] content-["BAD"]',
'pages/nested/foo.jsx': 'content-["pages/nested/foo.jsx"] content-["BAD"]',
},
},
async ({ fs, exec }) => {
await exec('pnpm postcss index.css --output dist/out.css')
expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(`
"
--- ./dist/out.css ---
.content-\\[\\"pages\\/foo\\.html\\"\\] {
--tw-content: "pages/foo.html";
content: var(--tw-content);
}
.content-\\[\\"pages\\/nested\\/foo\\.html\\"\\] {
--tw-content: "pages/nested/foo.html";
content: var(--tw-content);
}
@supports (-moz-orient: inline) {
@layer base {
*, ::before, ::after, ::backdrop {
--tw-content: "";
}
}
}
@property --tw-content {
syntax: "*";
inherits: false;
initial-value: "";
}
"
`)
},
)

View File

@ -112,8 +112,14 @@ export function test(
(error, stdout, stderr) => {
if (error) {
if (execOptions.ignoreStdErr !== true) console.error(stderr)
if (only || debug) {
console.error(stdout)
}
reject(error)
} else {
if (only || debug) {
console.log(stdout.toString() + '\n\n' + stderr.toString())
}
resolve(stdout.toString() + '\n\n' + stderr.toString())
}
},
@ -187,14 +193,14 @@ export function test(
child.stdout.on('data', (result) => {
let content = result.toString()
if (debug) console.log(content)
if (debug || only) console.log(content)
combined.push(['stdout', content])
stdoutMessages.push(content)
notifyNext(stdoutActors, stdoutMessages)
})
child.stderr.on('data', (result) => {
let content = result.toString()
if (debug) console.error(content)
if (debug || only) console.error(content)
combined.push(['stderr', content])
stderrMessages.push(content)
notifyNext(stderrActors, stderrMessages)
@ -303,7 +309,11 @@ export function test(
return Promise.all(
files.map(async (file) => {
let content = await fs.readFile(path.join(root, file), 'utf8')
return [file, content]
return [
file,
// Drop license comment
content.replace(/[\s\n]*\/\*! tailwindcss .*? \*\/[\s\n]*/g, ''),
]
}),
)
},

View File

@ -427,6 +427,265 @@ for (let transformer of ['postcss', 'lightningcss']) {
})
},
)
test(
`source(none) disables looking at the module graph`,
{
fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
`,
'project-a/package.json': txt`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''}
"vite": "^5.3.5"
}
}
`,
'project-a/vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"},
build: { cssMinify: false },
plugins: [tailwindcss()],
})
`,
'project-a/index.html': html`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="underline m-2">Hello, world!</div>
</body>
`,
'project-a/src/index.css': css`
@import 'tailwindcss' source(none);
@source '../../project-b/src/**/*.html';
`,
'project-b/src/index.html': html`
<div class="flex" />
`,
'project-b/src/index.js': js`
const className = "content-['project-b/src/index.js']"
module.exports = { className }
`,
},
},
async ({ root, fs, exec }) => {
await exec('pnpm vite build', { cwd: path.join(root, 'project-a') })
let files = await fs.glob('project-a/dist/**/*.css')
expect(files).toHaveLength(1)
let [filename] = files[0]
// `underline` and `m-2` are only present from files in the module graph
// which we've explicitly disabled with source(none) so they should not
// be present
await fs.expectFileNotToContain(filename, [
//
candidate`underline`,
candidate`m-2`,
])
// The files from `project-b` should be included because there is an
// explicit `@source` directive for it
await fs.expectFileToContain(filename, [
//
candidate`flex`,
])
// The explicit source directive only covers HTML files, so the JS file
// should not be included
await fs.expectFileNotToContain(filename, [
//
candidate`content-['project-b/src/index.js']`,
])
},
)
test(
`source("…") filters the module graph`,
{
fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
`,
'project-a/package.json': txt`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''}
"vite": "^5.3.5"
}
}
`,
'project-a/vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"},
build: { cssMinify: false },
plugins: [tailwindcss()],
})
`,
'project-a/index.html': html`
<head>
<link rel="stylesheet" href="/src/index.css" />
</head>
<body>
<div class="underline m-2 content-['project-a/index.html']">Hello, world!</div>
<script type="module" src="/app/index.js"></script>
</body>
`,
'project-a/app/index.js': js`
const className = "content-['project-a/app/index.js']"
export default { className }
`,
'project-a/src/index.css': css`
@import 'tailwindcss' source('../app');
@source '../../project-b/src/**/*.html';
`,
'project-b/src/index.html': html`
<div
class="content-['project-b/src/index.html']"
/>
`,
'project-b/src/index.js': js`
const className = "content-['project-b/src/index.js']"
module.exports = { className }
`,
},
},
async ({ root, fs, exec }) => {
await exec('pnpm vite build', { cwd: path.join(root, 'project-a') })
let files = await fs.glob('project-a/dist/**/*.css')
expect(files).toHaveLength(1)
let [filename] = files[0]
// `underline` and `m-2` are present in files in the module graph but
// we've filtered the module graph such that we only look in
// `./app/**/*` so they should not be present
await fs.expectFileNotToContain(filename, [
//
candidate`underline`,
candidate`m-2`,
candidate`content-['project-a/index.html']`,
])
// We've filtered the module graph to only look in ./app/**/* so the
// candidates from that project should be present
await fs.expectFileToContain(filename, [
//
candidate`content-['project-a/app/index.js']`,
])
// Even through we're filtering the module graph explicit sources are
// additive and as such files from `project-b` should be included
// because there is an explicit `@source` directive for it
await fs.expectFileToContain(filename, [
//
candidate`content-['project-b/src/index.html']`,
])
// The explicit source directive only covers HTML files, so the JS file
// should not be included
await fs.expectFileNotToContain(filename, [
//
candidate`content-['project-b/src/index.js']`,
])
},
)
test(
`source("…") must be a directory`,
{
fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
`,
'project-a/package.json': txt`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''}
"vite": "^5.3.5"
}
}
`,
'project-a/vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"},
build: { cssMinify: false },
plugins: [tailwindcss()],
})
`,
'project-a/index.html': html`
<head>
<link rel="stylesheet" href="/src/index.css" />
</head>
<body>
<div class="underline m-2 content-['project-a/index.html']">Hello, world!</div>
<script type="module" src="/app/index.js"></script>
</body>
`,
'project-a/app/index.js': js`
const className = "content-['project-a/app/index.js']"
export default { className }
`,
'project-a/src/index.css': css`
@import 'tailwindcss' source('../i-do-not-exist');
@source '../../project-b/src/**/*.html';
`,
'project-b/src/index.html': html`
<div
class="content-['project-b/src/index.html']"
/>
`,
'project-b/src/index.js': js`
const className = "content-['project-b/src/index.js']"
module.exports = { className }
`,
},
},
async ({ root, fs, exec }) => {
await expect(() =>
exec('pnpm vite build', { cwd: path.join(root, 'project-a') }),
).rejects.toThrowError('The `source(../i-do-not-exist)` does not exist')
let files = await fs.glob('project-a/dist/**/*.css')
expect(files).toHaveLength(0)
},
)
})
}

View File

@ -137,21 +137,34 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
fullRebuildPaths.push(path)
},
})
let sources = (() => {
// Disable auto source detection
if (compiler.root === 'none') {
return []
}
// No root specified, use the base directory
if (compiler.root === null) {
return [{ base, pattern: '**/*' }]
}
// Use the specified root
return [compiler.root]
})().concat(compiler.globs)
let scanner = new Scanner({ sources })
env.DEBUG && console.timeEnd('[@tailwindcss/cli] Setup compiler')
return compiler
return [compiler, scanner] as const
}
// Compile the input
let compiler = await createCompiler(input)
let scanner = new Scanner({
detectSources: { base },
sources: compiler.globs,
})
let [compiler, scanner] = await createCompiler(input)
// Watch for changes
if (args['--watch']) {
let cleanupWatchers = await createWatchers(
watchDirectories(base, scanner),
watchDirectories(scanner),
async function handle(files) {
try {
// If the only change happened to the output file, then we don't want to
@ -205,13 +218,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
fullRebuildPaths = inputFilePath ? [inputFilePath] : []
// Create a new compiler, given the new `input`
compiler = await createCompiler(input)
// Re-scan the directory to get the new `candidates`
scanner = new Scanner({
detectSources: { base },
sources: compiler.globs,
})
;[compiler, scanner] = await createCompiler(input)
// Scan the directory for candidates
env.DEBUG && console.time('[@tailwindcss/cli] Scan for candidates')
@ -219,7 +226,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
env.DEBUG && console.timeEnd('[@tailwindcss/cli] Scan for candidates')
// Setup new watchers
cleanupWatchers = await createWatchers(watchDirectories(base, scanner), handle)
cleanupWatchers = await createWatchers(watchDirectories(scanner), handle)
// Re-compile the CSS
env.DEBUG && console.time('[@tailwindcss/cli] Build CSS')
@ -287,19 +294,16 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
eprintln(`Done in ${formatDuration(end - start)}`)
}
function watchDirectories(base: string, scanner: Scanner) {
return [base].concat(
scanner.globs.flatMap((globEntry) => {
// We don't want a watcher for negated globs.
if (globEntry.pattern[0] === '!') return []
function watchDirectories(scanner: Scanner) {
return scanner.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 []
// We don't want a watcher for files, only directories.
if (globEntry.pattern === '') return []
return globEntry.base
}),
)
return globEntry.base
})
}
async function createWatchers(dirs: string[], cb: (files: string[]) => void) {

View File

@ -10,11 +10,11 @@ import {
} from 'tailwindcss'
import { getModuleDependencies } from './get-module-dependencies'
export function compile(
export async function compile(
css: string,
{ base, onDependency }: { base: string; onDependency: (path: string) => void },
) {
return _compile(css, {
let compiler = await _compile(css, {
base,
async loadModule(id, base) {
return loadModule(id, base, onDependency)
@ -23,6 +23,30 @@ export function compile(
return loadStylesheet(id, base, onDependency)
},
})
// Verify if the `source(…)` path exists (until the glob pattern starts)
if (compiler.root && compiler.root !== 'none') {
let globSymbols = /[*{]/
let basePath = []
for (let segment of compiler.root.pattern.split('/')) {
if (globSymbols.test(segment)) {
break
}
basePath.push(segment)
}
let exists = await fsPromises
.stat(path.resolve(base, basePath.join('/')))
.then((stat) => stat.isDirectory())
.catch(() => false)
if (!exists) {
throw new Error(`The \`source(${compiler.root.pattern})\` does not exist`)
}
}
return compiler
}
export async function __unstable__loadDesignSystem(css: string, { base }: { base: string }) {

View File

@ -134,11 +134,23 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
}
if (context.scanner === null || rebuildStrategy === 'full') {
let sources = (() => {
// Disable auto source detection
if (context.compiler.root === 'none') {
return []
}
// No root specified, use the base directory
if (context.compiler.root === null) {
return [{ base, pattern: '**/*' }]
}
// Use the specified root
return [context.compiler.root]
})().concat(context.compiler.globs)
// Look for candidates used to generate the CSS
context.scanner = new Scanner({
detectSources: { base },
sources: context.compiler.globs,
})
context.scanner = new Scanner({ sources })
}
env.DEBUG && console.time('[@tailwindcss/postcss] Scan for candidates')
@ -159,13 +171,22 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
// 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 context.scanner.globs) {
result.messages.push({
type: 'dir-dependency',
plugin: '@tailwindcss/postcss',
dir: base,
glob: pattern,
parent: result.opts.from,
})
if (pattern === '') {
result.messages.push({
type: 'dependency',
plugin: '@tailwindcss/postcss',
file: base,
parent: result.opts.from,
})
} else {
result.messages.push({
type: 'dir-dependency',
plugin: '@tailwindcss/postcss',
dir: base,
glob: pattern,
parent: result.opts.from,
})
}
}
env.DEBUG && console.time('[@tailwindcss/postcss] Build CSS')

View File

@ -285,7 +285,7 @@ function keyframesToCss(keyframes: Record<string, unknown>): string {
}
function autodetectedSourceFiles(base: string) {
let scanner = new Scanner({ detectSources: { base } })
let scanner = new Scanner({ sources: [{ base, pattern: '**/*' }] })
scanner.scan()
return scanner.files
}

View File

@ -2,6 +2,7 @@ import { compile, env, normalizePath } from '@tailwindcss/node'
import { clearRequireCache } from '@tailwindcss/node/require-cache'
import { Scanner } from '@tailwindcss/oxide'
import { Features, transform } from 'lightningcss'
import fs from 'node:fs/promises'
import path from 'path'
import type { Plugin, ResolvedConfig, Rollup, Update, ViteDevServer } from 'vite'
@ -35,7 +36,7 @@ export default function tailwindcss(): Plugin[] {
// Note: To improve performance, we do not remove candidates from this set.
// This means a longer-ongoing dev mode session might contain candidates that
// are no longer referenced in code.
let moduleGraphCandidates = new Set<string>()
let moduleGraphCandidates = new DefaultMap<string, Set<string>>(() => new Set<string>())
let moduleGraphScanner = new Scanner({})
let roots: DefaultMap<string, Root> = new DefaultMap(
@ -46,7 +47,7 @@ export default function tailwindcss(): Plugin[] {
let updated = false
for (let candidate of moduleGraphScanner.scanFiles([{ content, extension }])) {
updated = true
moduleGraphCandidates.add(candidate)
moduleGraphCandidates.get(id).add(candidate)
}
if (updated) {
@ -343,14 +344,16 @@ class Root {
// the lifetime of the root.
private candidates: Set<string> = new Set<string>()
// List of all file dependencies that were captured while generating the root.
// These are retained so we can clear the require cache when we rebuild the
// root.
// List of all dependencies captured while generating the root. These are
// retained so we can clear the require cache when we rebuild the root.
private dependencies = new Set<string>()
// The resolved path given to `source(…)`. When not given this is `null`.
private basePath: string | null = null
constructor(
private id: string,
private getSharedCandidates: () => Set<string>,
private getSharedCandidates: () => Map<string, Set<string>>,
private base: string,
) {}
@ -379,9 +382,22 @@ class Root {
})
env.DEBUG && console.timeEnd('[@tailwindcss/vite] Setup compiler')
this.scanner = new Scanner({
sources: this.compiler.globs,
})
let sources = (() => {
// Disable auto source detection
if (this.compiler.root === 'none') {
return []
}
// No root specified, use the module graph
if (this.compiler.root === null) {
return []
}
// Use the specified root
return [this.compiler.root]
})().concat(this.compiler.globs)
this.scanner = new Scanner({ sources })
}
// This should not be here, but right now the Vite plugin is setup where we
@ -411,14 +427,62 @@ class Root {
relative = normalizePath(relative)
addWatchFile(path.posix.join(relative, glob.pattern))
let root = this.compiler.root
if (root !== 'none' && root !== null) {
let basePath = path.posix.resolve(root.base, root.pattern)
let isDir = await fs.stat(basePath).then(
(stats) => stats.isDirectory(),
() => false,
)
if (!isDir) {
throw new Error(
`The path given to \`source(…)\` must be a directory but got \`source(${basePath})\` instead.`,
)
}
this.basePath = basePath
} else if (root === null) {
this.basePath = null
}
}
this.requiresRebuild = true
env.DEBUG && console.time('[@tailwindcss/vite] Build CSS')
let result = this.compiler.build([...this.getSharedCandidates(), ...this.candidates])
let result = this.compiler.build([...this.sharedCandidates(), ...this.candidates])
env.DEBUG && console.timeEnd('[@tailwindcss/vite] Build CSS')
return result
}
private sharedCandidates(): Set<string> {
if (!this.compiler) return new Set()
if (this.compiler.root === 'none') return new Set()
let shouldIncludeCandidatesFrom = (id: string) => {
if (this.basePath === null) return true
// This a virtual module that's not on the file system
// TODO: What should we do here?
if (!id.startsWith('/')) return true
return id.startsWith(this.basePath)
}
let shared = new Set<string>()
for (let [id, candidates] of this.getSharedCandidates()) {
if (!shouldIncludeCandidatesFrom(id)) continue
for (let candidate of candidates) {
shared.add(candidate)
}
}
return shared
}
}

View File

@ -223,7 +223,10 @@ export function toCss(ast: AstNode[]) {
// AtRule
else if (node.kind === 'at-rule') {
if (node.name === '@tailwind' && node.params === 'utilities') {
if (
node.name === '@tailwind' &&
(node.params === 'utilities' || node.params.startsWith('utilities'))
) {
for (let child of node.nodes) {
css += stringify(child, depth)
}

View File

@ -38,8 +38,12 @@ export async function substituteAtImports(
let ast = CSS.parse(loaded.content)
await substituteAtImports(ast, loaded.base, loadStylesheet, recurseCount + 1)
contextNode.nodes = buildImportNodes(ast, layer, media, supports)
contextNode.context.base = loaded.base
contextNode.nodes = buildImportNodes(
[context({ base: loaded.base }, ast)],
layer,
media,
supports,
)
})(),
)

View File

@ -8,7 +8,7 @@ import { Theme } from './theme'
const root = process.env.FOLDER || process.cwd()
// Auto content detection
const scanner = new Scanner({ detectSources: { base: root } })
const scanner = new Scanner({ sources: [{ base: root, pattern: '**/*' }] })
const candidates = scanner.scan()
const designSystem = buildDesignSystem(new Theme())

View File

@ -7,7 +7,7 @@ const root = process.env.FOLDER || process.cwd()
const css = String.raw
bench('compile', async () => {
let scanner = new Scanner({ detectSources: { base: root } })
let scanner = new Scanner({ sources: [{ base: root, pattern: '**/*' }] })
let candidates = scanner.scan()
let { build } = await compile(css`

View File

@ -4,7 +4,7 @@ import {
atRoot,
atRule,
comment,
context,
context as contextNode,
decl,
rule,
styleRule,
@ -76,21 +76,64 @@ async function parseCss(
loadStylesheet = throwOnLoadStylesheet,
}: CompileOptions = {},
) {
let ast = [context({ base }, CSS.parse(css))] as AstNode[]
let ast = [contextNode({ base }, CSS.parse(css))] as AstNode[]
await substituteAtImports(ast, base, loadStylesheet)
let important: boolean | null = null
let important = null as boolean | null
let theme = new Theme()
let customVariants: ((designSystem: DesignSystem) => void)[] = []
let customUtilities: ((designSystem: DesignSystem) => void)[] = []
let firstThemeRule = null as StyleRule | null
let utilitiesNode = null as AtRule | null
let globs: { base: string; pattern: string }[] = []
let root:
| null // Unknown root
| 'none' // Explicitly no root specified via `source(none)`
// Specified via `source(…)`, relative to the `base`
| { base: string; pattern: string } = null
// Handle at-rules
walk(ast, (node, { parent, replaceWith, context }) => {
if (node.kind !== 'at-rule') return
// Find `@tailwind utilities` so that we can later replace it with the
// actual generated utility class CSS.
if (
utilitiesNode === null &&
node.name === '@tailwind' &&
(node.params === 'utilities' || node.params.startsWith('utilities'))
) {
let params = segment(node.params, ' ')
for (let param of params) {
if (param.startsWith('source(')) {
let path = param.slice(7, -1)
// Keyword: `source(none)`
if (path === 'none') {
root = path
continue
}
// Explicit path: `source('…')`
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.')
}
root = {
base: context.sourceBase ?? context.base,
pattern: path.slice(1, -1),
}
}
}
utilitiesNode = node
}
// Collect custom `@utility` at-rules
if (node.name === '@utility') {
if (parent !== null) {
@ -234,12 +277,27 @@ async function parseCss(
let unknownParams: string[] = []
for (let param of params) {
// Handle `@media source(…)`
if (param.startsWith('source(')) {
let path = param.slice(7, -1)
walk(node.nodes, (child, { replaceWith }) => {
if (child.kind !== 'at-rule') return
if (child.name === '@tailwind' && child.params === 'utilities') {
child.params += ` source(${path})`
replaceWith([contextNode({ sourceBase: context.base }, [child])])
return WalkAction.Stop
}
})
}
// Handle `@media theme(…)`
//
// We support `@import "tailwindcss/theme" theme(reference)` as a way to
// import an external theme file as a reference, which becomes `@media
// theme(reference) { … }` when the `@import` is processed.
if (param.startsWith('theme(')) {
else if (param.startsWith('theme(')) {
let themeParams = param.slice(6, -1)
walk(node.nodes, (child) => {
@ -417,6 +475,8 @@ async function parseCss(
designSystem,
ast,
globs,
root,
utilitiesNode,
}
}
@ -425,24 +485,13 @@ export async function compile(
opts: CompileOptions = {},
): Promise<{
globs: { base: string; pattern: string }[]
root:
| null // Unknown root
| 'none' // Explicitly no root specified via `source(none)`
| { base: string; pattern: string } // Specified via `source(…)`, relative to the `base`
build(candidates: string[]): string
}> {
let { designSystem, ast, globs } = await parseCss(css, opts)
let tailwindUtilitiesNode: AtRule | null = null
// Find `@tailwind utilities` so that we can later replace it with the actual
// generated utility class CSS.
walk(ast, (node) => {
if (node.kind === 'at-rule' && node.name === '@tailwind' && node.params === 'utilities') {
tailwindUtilitiesNode = node
// Stop walking after finding `@tailwind utilities` to avoid walking all
// of the generated CSS. This means `@tailwind utilities` can only appear
// once per file but that's the intended usage at this point in time.
return WalkAction.Stop
}
})
let { designSystem, ast, globs, root, utilitiesNode } = await parseCss(css, opts)
if (process.env.NODE_ENV !== 'test') {
ast.unshift(comment(`! tailwindcss v${version} | MIT License | https://tailwindcss.com `))
@ -462,6 +511,7 @@ export async function compile(
return {
globs,
root,
build(newRawCandidates: string[]) {
let didChange = false
@ -480,7 +530,7 @@ export async function compile(
return compiledCss
}
if (tailwindUtilitiesNode) {
if (utilitiesNode) {
let newNodes = compileCandidates(allValidCandidates, designSystem, {
onInvalidCandidate,
}).astNodes
@ -494,7 +544,7 @@ export async function compile(
previousAstNodeCount = newNodes.length
tailwindUtilitiesNode.nodes = newNodes
utilitiesNode.nodes = newNodes
compiledCss = toCss(ast)
}

View File

@ -10,8 +10,8 @@
"./build.rs",
"./package.json",
"./Cargo.toml",
"../core/src/**/*",
"../core/Cargo.toml",
"../oxide/src/**/*",
"../oxide/Cargo.toml",
"../Cargo.toml",
"../package.json"
]
@ -24,8 +24,8 @@
"./build.rs",
"./package.json",
"./Cargo.toml",
"../core/src/**/*",
"../core/Cargo.toml",
"../oxide/src/**/*",
"../oxide/Cargo.toml",
"../Cargo.toml",
"../package.json"
],