diff --git a/CHANGELOG.md b/CHANGELOG.md index f8d6235b5..e5e6f6f89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure `@tailwindcss/oxide` falls back to WASM on platforms without native bindings ([#20383](https://github.com/tailwindlabs/tailwindcss/pull/20383)) - Detect classes in Ruby percent literals using angle brackets or custom delimiters (e.g. `%w`, `%w|flex|`), including in Slim and Haml templates ([#20387](https://github.com/tailwindlabs/tailwindcss/pull/20387)) - Preserve whitespace in `--default(…)` values in custom functional utilities (e.g. `--default(box alphabetic)` no longer becomes `boxalphabetic`) ([#20392](https://github.com/tailwindlabs/tailwindcss/pull/20392)) -- Don't scan gitignored directories (e.g. `node_modules` and `.git`) when the project uses a safelist-style `.gitignore` (e.g. `/*` followed by `!/…` negations) ([#20397](https://github.com/tailwindlabs/tailwindcss/discussions/20397)) +- Don't scan gitignored directories (e.g. `node_modules` and `.git`) when the project uses a safelist-style `.gitignore` (e.g. `/*` followed by `!/…` negations) ([#20397](https://github.com/tailwindlabs/tailwindcss/pull/20397)) - Ensure root `theme('…')` namespace lookups in JavaScript plugins and config files return the full namespace object instead of the value of its `DEFAULT` key ([#20399](https://github.com/tailwindlabs/tailwindcss/pull/20399)) - Skip ignored directories entirely when computing watch globs (`scanner.globs`), instead of walking their full contents on every rebuild ([#20408](https://github.com/tailwindlabs/tailwindcss/pull/20408)) +- Oxide: drop invalid UTF-8 candidates ([#20389](https://github.com/tailwindlabs/tailwindcss/pull/20389)) +- `@tailwindcss/vite` no longer forces a full page reload for external files (e.g.: `.php` files) ([#20414](https://github.com/tailwindlabs/tailwindcss/issues/20414)) +- Canonicalization: don't merge utilities that reference different theme variables set to CSS-wide keywords like `unset` ([#20417](https://github.com/tailwindlabs/tailwindcss/pull/20417)) +- Don't generate utilities when a modifier is used that would otherwise be silently ignored (e.g. `rounded-sm/[5]`, `shadow-sm/foo`, `stroke-2/50`) ([#20419](https://github.com/tailwindlabs/tailwindcss/pull/20419)) +- Only normalize top-level `and`, `or`, and `not` keywords in `supports-[…]` variants (e.g. `selector(a: not (.foo))` → `selector(a:not(.foo))`) ([#20420](https://github.com/tailwindlabs/tailwindcss/pull/20420)) +- Don't warn about Angular's `::ng-deep` and `:host-context()` when optimizing CSS ([#20434](https://github.com/tailwindlabs/tailwindcss/pull/20434)) +- Don't generate CSS for candidates containing an empty additional modifier (e.g. `bg-red-500/50/` and `group-hover/foo//bar:flex`) ([#20466](https://github.com/tailwindlabs/tailwindcss/pull/20466)) +- Sort `min-*`, `max-*`, and container query variants with decimal values numerically (e.g. `min-[40.25rem]` before `min-[40.5rem]`) ([#20512](https://github.com/tailwindlabs/tailwindcss/pull/20512)) +- Ensure CSS comments ending with `\*/` are closed correctly instead of swallowing the CSS that follows (e.g. `/* C:\temp\*/`) ([#20508](https://github.com/tailwindlabs/tailwindcss/pull/20508)) +- Improve style invalidation performance of `group-*` and `peer-*` variants ([#20513](https://github.com/tailwindlabs/tailwindcss/pull/20513)) ## [4.3.3] - 2026-07-16 diff --git a/crates/oxide/src/scanner/auto_source_detection.rs b/crates/oxide/src/scanner/auto_source_detection.rs index 7c0ac0b98..0d723a1ab 100644 --- a/crates/oxide/src/scanner/auto_source_detection.rs +++ b/crates/oxide/src/scanner/auto_source_detection.rs @@ -48,7 +48,7 @@ static IGNORED_EXTENSIONS_GLOB: sync::LazyLock = sync::LazyLock::new(|| ) }); -static BINARY_EXTENSIONS_GLOB: sync::LazyLock = sync::LazyLock::new(|| { +pub static BINARY_EXTENSIONS_GLOB: sync::LazyLock = sync::LazyLock::new(|| { format!( "*.{{{}}}", include_str!("fixtures/binary-extensions.txt") diff --git a/crates/oxide/src/scanner/fixtures/ignored-files.txt b/crates/oxide/src/scanner/fixtures/ignored-files.txt index fe9aeb1bf..1e8f1029f 100644 --- a/crates/oxide/src/scanner/fixtures/ignored-files.txt +++ b/crates/oxide/src/scanner/fixtures/ignored-files.txt @@ -2,6 +2,5 @@ package-lock.json pnpm-lock.yaml bun.lockb .gitignore -.ignore .env .env.* diff --git a/crates/oxide/src/scanner/init_tracing.rs b/crates/oxide/src/scanner/init_tracing.rs index 2a570ff20..dd4c1d071 100644 --- a/crates/oxide/src/scanner/init_tracing.rs +++ b/crates/oxide/src/scanner/init_tracing.rs @@ -37,20 +37,52 @@ pub fn init_tracing() { return; } - let file_path = format!("tailwindcss-{}.log", std::process::id()); - let file = OpenOptions::new() + let root = Path::new(".tailwindcss"); + let logs_dir = root.join("logs"); + if let Err(err) = std::fs::create_dir_all(&logs_dir) { + eprintln!( + "{} Failed to create {}, skipping debug logs ({err})", + dim("[DEBUG]"), + highlight(&logs_dir.display().to_string()) + ); + return; + } + + // Ensure everything inside `.tailwindcss/` is ignored by git. The file is only created if it + // doesn't exist yet, an existing `.gitignore` is left untouched. + if let Ok(mut file) = std::fs::File::create_new(root.join(".gitignore")) { + _ = file.write_all(b"*\n"); + } + + let file_path = logs_dir.join(format!( + "scanner-{}-{}.log", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0), + std::process::id() + )); + let file = match OpenOptions::new() .create(true) .append(true) .open(&file_path) - .unwrap_or_else(|_| panic!("Failed to open {file_path}")); + { + Ok(file) => file, + Err(err) => { + eprintln!( + "{} Failed to create {}, skipping debug logs ({err})", + dim("[DEBUG]"), + highlight(&file_path.display().to_string()) + ); + return; + } + }; - let file_path = Path::new(&file_path); - let absolute_file_path = dunce::canonicalize(file_path) - .unwrap_or_else(|_| panic!("Failed to canonicalize {file_path:?}")); + let absolute_file_path = dunce::canonicalize(&file_path).unwrap_or_else(|_| file_path.clone()); eprintln!( "{} Writing debug info to: {}\n", dim("[DEBUG]"), - highlight(absolute_file_path.as_path().to_str().unwrap()) + highlight(&absolute_file_path.display().to_string()) ); let file = Arc::new(Mutex::new(file)); diff --git a/crates/oxide/src/scanner/mod.rs b/crates/oxide/src/scanner/mod.rs index 2abae99f5..b7da9aa17 100644 --- a/crates/oxide/src/scanner/mod.rs +++ b/crates/oxide/src/scanner/mod.rs @@ -10,13 +10,11 @@ use crate::scanner::sources::{ public_source_entries_to_private_source_entries, PublicSourceEntry, SourceEntry, Sources, }; use crate::GlobEntry; +use auto_source_detection::BINARY_EXTENSIONS_GLOB; use bstr::ByteSlice; use fast_glob::glob_match; use fxhash::{FxHashMap, FxHashSet}; -use ignore::{ - gitignore::{Gitignore, GitignoreBuilder}, - WalkBuilder, -}; +use ignore::{gitignore::GitignoreBuilder, WalkBuilder}; use init_tracing::{init_tracing, SHOULD_TRACE}; use rayon::prelude::*; use std::path::{Path, PathBuf}; @@ -24,71 +22,16 @@ use std::sync::{Arc, Mutex}; use std::time::SystemTime; use tracing::event; -// # `@source` semantics +// @source "some/folder"; // This is auto source detection +// @source "some/folder/**/*"; // This is auto source detection +// @source "some/folder/*.html"; // This is just a glob, but new files matching this should be included +// @source "node_modules/my-ui-lib"; // Auto source detection but since node_modules is explicit we allow it +// // Maybe could be considered `external(…)` automatically if: +// // 1. It's git ignored but listed explicitly +// // 2. It exists outside of the current working directory (do we know that?) // -// Every `@source` directive is classified as one of: -// -// - `Auto`: `@source "some/folder"` or `@source "some/folder/**/*"` — auto source detection. -// The folder is scanned recursively while respecting `.gitignore` files and the default rules -// (skip `node_modules`/`.git`/…, skip binary and irrelevant extensions, skip lock files, …). -// -// - `External`: an `Auto` source whose folder is itself ignored (by a `.gitignore` or because -// it's a default-ignored directory like `node_modules`), e.g. -// `@source "node_modules/my-ui-lib"`. Since the folder was listed explicitly, its ignoredness -// is bypassed: everything inside is scanned as if it were an `Auto` source, except that -// `.gitignore` files from at or above the folder no longer apply — they (including the -// self-ignoring `*` file that generators typically place inside such folders) are what made -// it ignored in the first place. `.gitignore` files *deeper inside* the folder still apply, -// and so do the default rules: nested `node_modules`, binary extensions, etc. stay ignored. -// -// - `Pattern`: `@source "some/folder/*.html"` — an explicit glob. Only files matching the glob -// are scanned. The *static* prefix of the glob (`some/folder`) is the explicit part: it is -// reached even when it is git ignored or hidden behind a default-ignored directory -// (`@source "node_modules/lib/dist/*.html"` works). The *wildcard* part is not explicit: -// while expanding it we still respect `.gitignore` files inside the walked subtree and the -// default-ignored directories (`@source "./**/*.html"` does not descend into `node_modules` -// or a git ignored `dist/`; `@source "./dist/**/*.html"` does descend into `dist/`). -// Individual *files* matching the glob are always included, even when git ignored — you were -// explicit about wanting files of that shape (`@source "git-ignored.html"` and -// `@source "*.styl"` work). Files that are ignored by default are only included when the -// pattern is explicit about them: it names a concrete file (`@source "do-include-me.bin"`, -// `@source ".env"`) or pins an extension (`@source "logo.{jpg,png}"`). A glob that does -// neither (e.g. `@source "blog/*/post/**/*"`) still applies the default file rules. -// -// - `Ignored`: `@source not "…"` — excludes matching files/folders, even when a `.gitignore` -// or another `@source` allows them. -// -// Later directives win over earlier ones on conflict: `@source not "./x"` followed by -// `@source "./x/keep.html"` scans `keep.html`, and vice versa excludes it. The same holds for -// directory sources: `@source not "./x"` followed by `@source "./x"` re-includes `./x` (with -// the normal auto source detection rules applied inside). -// -// # Implementation -// -// All sources are scanned in a single file system walk. The vendored `ignore` crate only -// provides the traversal itself (parallel walking, symlink loop handling); its built-in -// gitignore handling is disabled, because it computes one global verdict per path while the -// `@source` semantics are per source: the same directory can be pruned for an auto source but -// walkable for a pattern source, and a file can be gitignored for an auto source but rescued -// by a glob. -// -// Instead, the [`Resolver`] implements the semantics in the walker's `filter_entry` callback, -// backed by its own lazily-loaded cache of the on-disk ignore files (`.gitignore`, `.ignore`, -// and the repository's `.git/info/exclude`, applied up to the git repository root): -// -// - The walk roots are the "maximal" source bases; nested bases are reached by walking, and -// the resolver keeps the static path towards an explicitly listed base open even through -// ignored directories. -// -// - A directory is entered when at least one source can contribute files inside of it, where -// each source kind applies its own rules: auto sources check the default rules and the full -// gitignore chain, external sources only the default rules, and pattern sources check the -// default rules, the `.gitignore` files at or below their base, and whether the glob can -// match anything inside the directory. Directories that cannot contribute anything are -// never descended into. -// -// - A file is kept when at least one source includes it, honoring the directive order of -// `@source not` (the later directive wins). +// @source "do-include-me.bin"; // `.bin` is typically ignored, but now it's explicit so should be included +// @source "git-ignored.html"; // A git ignored file that is listed explicitly, should be scanned #[derive(Debug, Clone)] pub enum ChangedContent { @@ -120,9 +63,6 @@ pub struct Scanner { /// The walker to detect all files that we have to scan walker: Option, - /// The resolver implementing the `@source` semantics for the walker - resolver: Option>, - /// All found extensions extensions: FxHashSet, @@ -171,13 +111,11 @@ impl Scanner { } } - let resolver = Arc::new(Resolver::new(&sources)); - let walker = create_walker(resolver.clone()); + let walker = create_walker(&sources); Self { sources, walker, - resolver: Some(resolver), ..Default::default() } } @@ -418,15 +356,14 @@ impl Scanner { let i = s.as_ptr() as usize - offset; let original = &original_content[i..i + s.len()]; if original.contains_str("-[]") { - return Some(unsafe { - (String::from_utf8_unchecked(original.to_vec()), i) - }); + return String::from_utf8(original.to_vec()) + .ok() + .map(|candidate| (candidate, i)); } - // SAFETY: When we parsed the candidates, we already guaranteed that the byte - // slices are valid, therefore we don't have to re-check here when we want to - // convert it back to a string. - Some(unsafe { (String::from_utf8_unchecked(s.to_vec()), i) }) + String::from_utf8(s.to_vec()) + .ok() + .map(|candidate| (candidate, i)) } _ => None, @@ -470,16 +407,7 @@ impl Scanner { for entry in all_entries { match entry { WalkEntry::Dir(path) => { - // Directories that are only walked to reach an explicitly listed base are - // not part of any source's content: they must not widen the generated - // file watcher globs. - let contributes = self - .resolver - .as_ref() - .is_some_and(|resolver| resolver.contributes_dir(&path)); - if contributes { - self.dirs.insert(path); - } + self.dirs.insert(path); } WalkEntry::File { path, @@ -688,7 +616,7 @@ where a }) .into_iter() - .map(|s| unsafe { String::from_utf8_unchecked(s.to_vec()) }) + .filter_map(|s| String::from_utf8(s.to_vec()).ok()) .collect() } @@ -778,637 +706,203 @@ fn walk_parallel(walker: &mut WalkBuilder) -> Vec { Arc::try_unwrap(collected).unwrap().into_inner().unwrap() } -/// Sets up the single walker for all sources. +/// Sets up a WalkBuilder with all source roots, gitignore rules, and source pattern matching. /// -/// The walker is only used for the (parallel, symlink aware) traversal itself: all ignore -/// semantics are implemented by the [`Resolver`] in the `filter_entry` callback. The crate's -/// built-in gitignore handling can't be used because it computes one global verdict per path, -/// while the `@source` semantics are per source (see the spec at the top of this file): the -/// same directory can be pruned for an auto source but walkable for a pattern source, and a -/// file can be gitignored for an auto source but rescued by a glob. -/// -/// The walk roots are the "maximal" source bases: a base contained in another base is reached -/// by walking, which the resolver allows even through ignored directories (the static path to -/// an explicit base bypasses ignore rules). -fn create_walker(resolver: Arc) -> Option { - let mut roots = resolver.walk_roots().into_iter(); - let first_root = roots.next()?; +/// This is the common setup shared between the full walker (with mtime tracking for re-scans) +/// and the parallel walker (without mtime tracking for the initial scan). +fn create_walker(sources: &Sources) -> Option { + let mut other_roots: FxHashSet<&PathBuf> = FxHashSet::default(); + let mut first_root: Option<&PathBuf> = None; - let mut builder = WalkBuilder::new(first_root); - for root in roots { - builder.add(root); + let mut ignores: Vec<(&PathBuf, Vec)> = Default::default(); + let mut emit = |base, pattern| match ignores.last_mut() { + Some((prev_base, patterns)) if *prev_base == base => { + patterns.push(pattern); + } + _ => { + ignores.push((base, vec![pattern])); + } + }; + + for source in sources.iter() { + match source { + SourceEntry::Auto { base } => { + if first_root.is_none() { + first_root = Some(base); + } else { + other_roots.insert(base); + } + } + SourceEntry::Pattern { base, pattern } => { + let pattern = pattern.to_owned(); + + if first_root.is_none() { + first_root = Some(base); + } else { + other_roots.insert(base); + } + + if !pattern.contains("**") { + // Specific patterns should take precedence even over git-ignored files: + emit(base, format!("!{}", pattern)); + } else { + // Assumption: the pattern we receive will already be brace expanded. So + // `*.{html,jsx}` will result in two separate patterns: `*.html` and `*.jsx`. + if let Some(extension) = Path::new(&pattern).extension() { + // Extend auto source detection to include the extension + emit(base, format!("!*.{}", extension.to_string_lossy())); + } + } + } + SourceEntry::Ignored { base, pattern } => { + emit(base, pattern.to_owned()); + } + SourceEntry::External { base } => { + if first_root.is_none() { + first_root = Some(base); + } else { + other_roots.insert(base); + } + + // External sources should take precedence even over git-ignored files: + emit(base, "!/**/*".to_owned()); + + // External sources should still disallow binary extensions: + emit(base, BINARY_EXTENSIONS_GLOB.clone()); + } + } } + let mut builder = WalkBuilder::new(first_root?); + // We have to follow symlinks builder.follow_links(true); - // Disable all of the built-in filtering (hidden files, .gitignore files, parent - // directories, global gitignore files, …): the resolver implements the ignore semantics. - builder.standard_filters(false); + // Scan hidden files / directories + builder.hidden(false); - builder.filter_entry(move |entry| resolver.keep(entry)); + // Don't respect global gitignore files + builder.git_global(false); - Some(builder) -} + // By default, allow .gitignore files to be used regardless of whether or not + // a .git directory is present. This is an optimization for when projects + // are first created and may not be in a git repo yet. + builder.require_git(false); -/// Implements the `@source` semantics for a single file system walk. -/// -/// For every walked entry, the resolver computes the union of the per-source verdicts: -/// -/// - a directory is entered when at least one source can contribute files inside of it -/// - a file is kept when at least one source includes it, honoring the directive order of -/// `@source not` (the later directive wins) -/// -/// The per-source verdicts require gitignore decisions relative to different anchors (see -/// [`Boundary`]: each source kind respects a different part of the `.gitignore` chain), so the -/// resolver maintains its own lazily-loaded cache of ignore files instead of using the -/// walker's built-in handling. -#[derive(Debug)] -struct Resolver { - /// `Auto` sources: directive position and base - autos: Vec<(usize, PathBuf)>, - - /// `External` sources: directive position and base - externals: Vec<(usize, PathBuf)>, - - /// `Pattern` sources, grouped by base - patterns: Vec, - - /// `@source not` directives - nots: Vec, - - /// Lazily loaded ignore files (`.gitignore`, `.ignore`, `.git/info/exclude`) per directory - ignore_files: IgnoreFiles, - - /// Memoized "is this directory reachable for an auto source": every directory on the path - /// from an auto base down to it passes the gitignore chain and the default rules - auto_reachable: Mutex>, - - /// Memoized "is this directory reachable for an external source": like `auto_reachable`, - /// but only `.gitignore` files strictly below the external base apply - external_reachable: Mutex>, -} - -/// All `Pattern` sources sharing a base, e.g. `@source "src/*.{html,jsx}"` produces the -/// patterns `/*.html` and `/*.jsx` for the base `src`. Patterns carry the position of their -/// `@source` directive to resolve conflicts with `@source not` directives: the later -/// directive wins. -#[derive(Debug)] -struct PatternGroup { - base: PathBuf, - - /// The glob patterns, relative to `base`, with their directive positions - patterns: Vec<(usize, String)>, - - /// Memoized "is this directory reachable for this group": like - /// `Resolver::auto_reachable`, but only `.gitignore` files at or below the base apply — - /// the static base is explicit, so everything above it is bypassed - reachable: Mutex>, -} - -impl Resolver { - fn new(sources: &Sources) -> Self { - let mut autos = vec![]; - let mut externals = vec![]; - let mut patterns: Vec = vec![]; - let mut nots = vec![]; - - for (idx, source) in sources.iter().enumerate() { - match source { - SourceEntry::Auto { base } => autos.push((idx, base.clone())), - SourceEntry::External { base } => externals.push((idx, base.clone())), - SourceEntry::Pattern { base, pattern } => { - match patterns.iter_mut().find(|group| &group.base == base) { - Some(group) => group.patterns.push((idx, pattern.clone())), - None => patterns.push(PatternGroup { - base: base.clone(), - patterns: vec![(idx, pattern.clone())], - reachable: Default::default(), - }), - } - } - SourceEntry::Ignored { base, pattern } => nots.push(NotRule { - idx, - base: base.clone(), - pattern: pattern.clone(), - }), - } - } - - Self { - autos, - externals, - patterns, - nots, - ignore_files: IgnoreFiles::default(), - auto_reachable: Default::default(), - external_reachable: Default::default(), + // If we are in a git repo then require it to ensure that only rules within + // the repo are used. For example, we don't want to consider a .gitignore file + // in the user's home folder if we're in a git repo. + // + // The alternative is using a call like `.parents(false)` but that will + // prevent looking at parent directories for .gitignore files from within + // the repo and that's not what we want. + // + // For example, in a project with this structure: + // + // home + // .gitignore + // my-project + // .gitignore + // apps + // .gitignore + // web + // {root} + // + // We do want to consider all .gitignore files listed: + // - home/.gitignore + // - my-project/.gitignore + // - my-project/apps/.gitignore + // + // However, if a repo is initialized inside my-project then only the following + // make sense for consideration: + // - my-project/.gitignore + // - my-project/apps/.gitignore + // + // Setting the require_git(true) flag conditionally allows us to do this. + for parent in first_root?.ancestors() { + if parent.join(".git").exists() { + builder.require_git(true); + break; } } - /// All source bases - fn bases(&self) -> impl Iterator { - self.autos - .iter() - .map(|(_, base)| base) - .chain(self.externals.iter().map(|(_, base)| base)) - .chain(self.patterns.iter().map(|group| &group.base)) + for root in other_roots { + builder.add(root); } - /// The walk roots: all bases that are not contained in another base. Nested bases are - /// reached by walking (the resolver keeps the path to an explicit base open). - fn walk_roots(&self) -> Vec { - let mut roots: Vec = vec![]; - for base in self.bases() { - if self - .bases() - .any(|other| other != base && base.starts_with(other)) - { - continue; - } - if !roots.contains(base) { - roots.push(base.clone()); - } - } - roots + // Setup auto source detection rules + for ignore in auto_source_detection::RULES.iter() { + builder.add_gitignore(ignore.clone()); } - /// Whether to keep the given walk entry - fn keep(&self, entry: &ignore::DirEntry) -> bool { - // Always keep the walk roots themselves; they are explicitly listed bases - if entry.depth() == 0 { - return true; - } - - let path = entry.path(); - let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); - - if is_dir { - self.keep_dir(path) - } else { - self.keep_file(path) + // Setup ignores based on `@source` definitions + for (base, patterns) in ignores { + let mut ignore_builder = GitignoreBuilder::new(base); + for pattern in patterns { + ignore_builder.add_line(None, &pattern).unwrap(); } + let ignore = ignore_builder.build().unwrap(); + builder.add_gitignore(ignore); } - /// Whether to keep walking the given directory: either a source can contribute files - /// inside of it, or it is on the static path towards an explicitly listed base. - fn keep_dir(&self, dir: &Path) -> bool { - // A directory on the static path to an explicit base can always be entered (the base - // is explicit, so its ignoredness is bypassed), unless everything below it is excluded - // again by a later `@source not` directive. - let leads_to_base = |idx: usize, base: &PathBuf| { - base != dir - && base.starts_with(dir) - && !self - .nots - .iter() - .any(|not| not.idx > idx && not.matches(base)) - }; - if self.autos.iter().any(|(idx, base)| leads_to_base(*idx, base)) - || self - .externals - .iter() - .any(|(idx, base)| leads_to_base(*idx, base)) - || self.patterns.iter().any(|group| { - group - .patterns - .iter() - .any(|(idx, _)| leads_to_base(*idx, &group.base)) - }) - { - return true; - } - - self.contributes_dir(dir) - } - - /// Whether at least one source can contribute files inside the given directory. Unlike - /// [`Resolver::keep_dir`], directories that are only walked to reach an explicitly listed - /// base don't count: they are not part of any source's content, e.g. for the purpose of - /// generating file watcher globs. - fn contributes_dir(&self, dir: &Path) -> bool { - // Some source must be able to contribute files inside the directory, and not be - // overridden by a later `@source not` directive. - let not_after = |idx: usize| { - self.nots - .iter() - .any(|not| not.idx > idx && not.matches(dir)) - }; - - if self - .autos - .iter() - .any(|(idx, _)| self.auto_reachable(dir) && !not_after(*idx)) - { - return true; - } - - if self - .externals - .iter() - .any(|(idx, _)| self.external_reachable(dir) && !not_after(*idx)) - { - return true; - } - - self.patterns.iter().any(|group| { - dir.strip_prefix(&group.base).is_ok_and(|remainder| { - group.patterns.iter().any(|(idx, pattern)| { - dir_could_contain_matches(pattern, remainder) && !not_after(*idx) - }) && self.pattern_reachable(group, dir) - }) - }) - } - - /// Whether at least one source includes the given file - fn keep_file(&self, file: &Path) -> bool { - let Some(parent) = file.parent() else { - return false; - }; - - let not_after = |idx: usize| { - self.nots - .iter() - .any(|not| not.idx > idx && not.matches(file)) - }; - - // Auto sources: the file must pass the default rules and the gitignore chain - if self.autos.iter().any(|(idx, base)| { - file.starts_with(base) - && self.auto_reachable(parent) - && !is_ignored_by_default_rules(file, false) - && !self - .ignore_files - .is_ignored(file, false, parent, Boundary::None) - && !not_after(*idx) - }) { - return true; - } - - // External sources: like auto sources, but only `.gitignore` files strictly below the - // base apply. Rules from at or above the base are bypassed — they are what made the - // directory ignored, and it was listed explicitly anyway — while `.gitignore` files - // deeper inside the external tree still apply. - if self.externals.iter().any(|(idx, base)| { - file.starts_with(base) - && self.external_reachable(parent) - && !is_ignored_by_default_rules(file, false) - && !self - .ignore_files - .is_ignored(file, false, parent, Boundary::Inside(base)) - && !not_after(*idx) - }) { - return true; - } - - // Pattern sources: the file must match a glob. A match beats file-level gitignore - // rules — you were explicit about wanting files of that shape — and the default file - // rules only apply when the pattern isn't explicit about the file's shape. - self.patterns.iter().any(|group| { - file.strip_prefix(&group.base).is_ok_and(|remainder| { - let remainder = rooted_posix(remainder); - group.patterns.iter().any(|(idx, pattern)| { - glob_match(pattern, remainder.as_bytes()) - && (pattern_bypasses_default_file_rules(pattern) - || !is_ignored_by_default_rules(file, false)) - && !not_after(*idx) - }) && self.pattern_reachable(group, parent) - }) - }) - } - - /// Whether the given directory is reachable for an auto source: every directory on the - /// path from the auto base down to it passes the default rules and the gitignore chain. - fn auto_reachable(&self, dir: &Path) -> bool { - reachable( - &self.auto_reachable, - dir, - |dir| self.autos.iter().any(|(_, base)| base == dir), - |dir| { - !is_ignored_by_default_rules(dir, true) - && dir.parent().is_some_and(|parent| { - !self.ignore_files.is_ignored(dir, true, parent, Boundary::None) - }) - }, - ) - } - - /// Like [`Resolver::auto_reachable`], but for external sources: only `.gitignore` files - /// strictly below the external base apply (see [`Boundary`]). Rules from at or above the - /// base are bypassed — they are what made the directory ignored, and it was listed - /// explicitly anyway. - /// - /// When external bases are nested, the deepest base containing the directory bounds the - /// chain: reachability from an outer base implies reachability from a nested base (the - /// outer chain checks a superset of the ignore files), so this computes the union of the - /// per-base verdicts. - fn external_reachable(&self, dir: &Path) -> bool { - reachable( - &self.external_reachable, - dir, - |dir| self.externals.iter().any(|(_, base)| base == dir), - |dir| { - !is_ignored_by_default_rules(dir, true) - && dir.parent().is_some_and(|parent| { - let boundary = self - .externals - .iter() - .map(|(_, base)| base) - .filter(|base| dir.starts_with(base)) - .max_by_key(|base| base.components().count()) - .map_or(Boundary::None, |base| Boundary::Inside(base)); - !self.ignore_files.is_ignored(dir, true, parent, boundary) - }) - }, - ) - } - - /// Like [`Resolver::auto_reachable`], but for a pattern group: only `.gitignore` files at - /// or below the base apply — the static base is explicit, so everything above it is - /// bypassed. - fn pattern_reachable(&self, group: &PatternGroup, dir: &Path) -> bool { - reachable( - &group.reachable, - dir, - |dir| dir == group.base, - |dir| { - !is_ignored_by_default_rules(dir, true) - && dir.parent().is_some_and(|parent| { - !self - .ignore_files - .is_ignored(dir, true, parent, Boundary::At(&group.base)) - }) - }, - ) - } -} - -/// Whether every directory on the path from a source base down to `dir` passes the source's -/// `enter` rule. Bases themselves are always reachable: they are explicitly listed (and an -/// auto base that is itself ignored would have been promoted to an external source). Verdicts -/// are memoized per directory. -fn reachable( - memo: &Mutex>, - dir: &Path, - is_base: impl Fn(&Path) -> bool, - enter: impl Fn(&Path) -> bool, -) -> bool { - // Walk up to the nearest base or directory with a memoized verdict… - let mut pending = vec![]; - let mut current = dir; - let mut reachable = loop { - if is_base(current) { - break true; - } - if let Some(reachable) = memo.lock().unwrap().get(current) { - break *reachable; - } - pending.push(current.to_path_buf()); - match current.parent() { - Some(parent) => current = parent, - // Reached the file system root without finding a base - None => break false, - } - }; - - // …then fill in the verdicts back down towards `dir` - for dir in pending.into_iter().rev() { - reachable = reachable && enter(&dir); - memo.lock().unwrap().insert(dir, reachable); - } - - reachable -} - -/// A lazily-loaded cache of the on-disk ignore files (`.gitignore`, `.ignore` and the -/// repository's `.git/info/exclude`). -#[derive(Debug, Default)] -struct IgnoreFiles { - /// The matcher chains per directory: all matchers that apply to paths inside the - /// directory, deepest first, from the directory itself up to the repository root (or the - /// file system root outside of a git repository, matching `git init`-less projects where - /// all ancestor `.gitignore` files apply) - chains: Mutex>>>>, -} - -/// Which part of the ignore file chain applies to a source, anchored at its base: -/// -/// - `Auto` sources respect the full chain, up to the git repository root -/// - `Pattern` sources respect ignore files at or below their base (the static base is -/// explicit, everything above it is bypassed) -/// - `External` sources respect ignore files strictly below their base: the base's own -/// ignore file is part of its bypassed ignoredness — inside ignored trees it is typically -/// the self-ignoring `*` file that generators drop into the directory — while deeper ignore -/// files are deliberate signals about specific contents and still apply -#[derive(Debug, Clone, Copy)] -enum Boundary<'a> { - None, - At(&'a Path), - Inside(&'a Path), -} - -impl Boundary<'_> { - /// Whether an ignore file rooted at the given directory applies - fn applies_to(&self, dir: &Path) -> bool { - match self { - Boundary::None => true, - Boundary::At(base) => dir.starts_with(base), - Boundary::Inside(base) => dir != *base && dir.starts_with(base), - } - } -} - -impl IgnoreFiles { - /// Whether the ignore files definitively ignore the given path. `dir` is the directory - /// containing the path, and `boundary` restricts which ignore files of the chain apply. - /// - /// The deepest ignore file with a definitive answer wins, matching git's precedence, so a - /// path that a deeper ignore file re-includes via a `!` pattern is not ignored. - fn is_ignored(&self, path: &Path, is_dir: bool, dir: &Path, boundary: Boundary) -> bool { - for matcher in self.chain(dir).iter() { - if !boundary.applies_to(matcher.path()) { - // Chains are ordered deepest first, so nothing below the boundary can follow - break; - } - - match matcher.matched(path, is_dir) { - ignore::Match::Ignore(_) => return true, - ignore::Match::Whitelist(_) => return false, - ignore::Match::None => {} - } - } - - false - } - - /// The matcher chain for paths inside the given directory: the directory's own matcher - /// first, then its parents' matchers, up to and including the git repository root. - fn chain(&self, dir: &Path) -> Arc>> { - if let Some(chain) = self.chains.lock().unwrap().get(dir) { - return chain.clone(); - } - - let is_repo_root = dir.join(".git").exists(); - - let mut chain = vec![]; - if let Some(matcher) = load_ignore_files(dir, is_repo_root) { - chain.push(matcher); - } - - // Stop at the git repository root so that ignore files outside of the repository are - // not considered. Without a repository, all ancestor ignore files apply. - if !is_repo_root { - if let Some(parent) = dir.parent() { - chain.extend(self.chain(parent).iter().cloned()); - } - } - - let chain = Arc::new(chain); - self.chains - .lock() - .unwrap() - .insert(dir.to_path_buf(), chain.clone()); - chain - } -} - -/// Compile the ignore rules of the given directory, combining (from low to high precedence) -/// the repository's `.git/info/exclude`, the `.gitignore` file, and the `.ignore` file. -fn load_ignore_files(dir: &Path, is_repo_root: bool) -> Option> { - let mut builder = GitignoreBuilder::new(dir); - let mut any = false; - - let mut add = |file: PathBuf| { - if file.is_file() { - // I/O errors and partially invalid ignore files are ignored, matching the - // walker's behavior. - let _ = builder.add(file); - any = true; - } - }; - - if is_repo_root { - add(dir.join(".git").join("info").join("exclude")); - } - add(dir.join(".gitignore")); - add(dir.join(".ignore")); - - if any { - builder.build().ok().map(Arc::new) - } else { - None - } -} - -/// Whether a path is ignored by the default auto source detection rules: directories like -/// `node_modules`, binary and irrelevant extensions, lock files, … For directories only the -/// directory's own name is checked; the path towards it is checked by the reachability -/// helpers one directory at a time. -fn is_ignored_by_default_rules(path: &Path, is_dir: bool) -> bool { - auto_source_detection::RULES + // Pre-compute source matching data to avoid allocations in the hot filter_entry path + let auto_bases: Vec = sources .iter() - .any(|ignore| ignore.matched(path, is_dir).is_ignore()) -} - -/// An `@source not` directive, together with its position. -#[derive(Debug, Clone)] -struct NotRule { - /// Position of the `@source not` directive - idx: usize, - - base: PathBuf, - - /// The glob pattern, relative to `base`, e.g. `/ignored/**/*` - pattern: String, -} - -impl NotRule { - /// Whether this directive excludes the given path: a file, or a directory and thereby - /// everything inside of it. - /// - /// Like a gitignore rule, the pattern excludes a whole subtree when it matches a - /// directory, so besides the path itself every ancestor directory (up to the directive's - /// base) is tested as well. E.g. `@source not "./src/ba*"` excludes `src/bar/index.html` - /// because `/ba*` matches the `src/bar` directory. Note that directory-shaped directives - /// (`@source not "./some/dir"`) are normalized to a `/**/*` pattern with the directory as - /// its base, which matches everything inside the directory directly. - fn matches(&self, path: &Path) -> bool { - let Ok(remainder) = path.strip_prefix(&self.base) else { - return false; - }; - - // A directory-shaped directive (normalized to a `/**/*` pattern) also excludes the - // base directory itself, not just its contents, so the directory can be pruned. - if remainder.as_os_str().is_empty() { - return self.pattern == "/**/*"; - } - - remainder.ancestors().any(|prefix| { - !prefix.as_os_str().is_empty() - && glob_match(&self.pattern, rooted_posix(prefix).as_bytes()) + .filter_map(|source| match source { + SourceEntry::Auto { base } | SourceEntry::External { base } => Some(base.clone()), + _ => None, }) - } -} - -/// Serialize a path relative to some base as a `/`-rooted posix style string, e.g. -/// `/ba*/index.html`, matching how source patterns are stored. -fn rooted_posix(path: &Path) -> String { - let posix = crate::scanner::sources::path_to_posix_string(path); - if posix.starts_with('/') { - posix - } else { - format!("/{posix}") - } -} - -/// Whether a directory (relative to the pattern's base) can contain files matching the pattern. -/// Used to prune directories that can never contribute, e.g. for `/ba*/*.html` only `ba*` -/// directories are entered. -fn dir_could_contain_matches(pattern: &str, dir: &Path) -> bool { - let pattern_components: Vec<&str> = pattern - .trim_start_matches('/') - .split('/') - .filter(|c| !c.is_empty()) .collect(); - for (i, component) in dir.components().enumerate() { - let component = component.as_os_str().to_string_lossy(); + let pattern_sources: Vec<(PathBuf, String)> = sources + .iter() + .filter_map(|source| match source { + SourceEntry::Pattern { base, pattern } => Some((base.into(), pattern.into())), + _ => None, + }) + .collect(); - // Once we see a `**` everything nested can contain matches - match pattern_components.get(i) { - Some(&"**") => return true, - // The last pattern component matches files, not directories. A directory nested - // deeper than the pattern's directory part can never contain matches. - Some(_) if i + 1 >= pattern_components.len() => return false, - Some(pattern_component) => { - if !glob_match(pattern_component, component.as_bytes()) { - return false; + // Source pattern matching filter (lock-free, safe for parallel walking) + builder.filter_entry(move |entry| { + let path = entry.path(); + + // Ensure the entries are matching any of the provided source patterns (this is + // necessary for manual-patterns that can filter the file extension) + if path.is_file() { + let mut matches = false; + + for base in &auto_bases { + if path.starts_with(base) { + matches = true; + break; } } - None => return false, + + if !matches { + for (base, pattern) in &pattern_sources { + let remainder = path.strip_prefix(base); + if remainder.is_ok_and(|remainder| { + let mut path_str = remainder.to_string_lossy().to_string(); + if !path_str.starts_with("/") { + path_str = format!("/{path_str}"); + } + glob_match(pattern, path_str.as_bytes()) + }) { + matches = true; + break; + } + } + } + + if !matches { + return false; + } } - } - true -} + true + }); -/// Whether a pattern is explicit enough to bypass the default file rules. -/// -/// A pattern without any wildcards names a concrete file, e.g. `/.env` or -/// `/do-include-me.bin` — you asked for exactly this file, so the default rules never apply. -/// A pattern that pins a specific extension, e.g. `/*.html` or `/**/*.bin`, bypasses them as -/// well. Patterns that do neither (e.g. `/blog/*/**/*`) keep the default file rules applied. -fn pattern_bypasses_default_file_rules(pattern: &str) -> bool { - // Concrete file, no wildcards (braces have already been expanded away) - if !pattern.contains(['*', '?', '[']) { - return true; - } - - // Pinned extension - match Path::new(pattern).extension().and_then(|ext| ext.to_str()) { - Some(ext) => !ext.contains(['*', '?', '[']), - None => false, - } + Some(builder) } #[cfg(test)] diff --git a/crates/oxide/src/scanner/sources.rs b/crates/oxide/src/scanner/sources.rs index 15ca9ae54..53c09387f 100644 --- a/crates/oxide/src/scanner/sources.rs +++ b/crates/oxide/src/scanner/sources.rs @@ -1,5 +1,6 @@ +use crate::GlobEntry; use bexpand::Expression; -use fxhash::FxHashMap; +use fxhash::{FxHashMap, FxHashSet}; use ignore::gitignore::Gitignore; use std::path::{Component, Path, PathBuf}; use tracing::{event, Level}; @@ -30,24 +31,6 @@ pub enum SourceEntry { /// ``` Auto { base: PathBuf }, - /// An `Auto` source whose directory is itself ignored (by the default rules, e.g. - /// `node_modules`, or by a `.gitignore`) but was explicitly listed anyway. - /// - /// Represented by: - /// - /// ```css - /// @source "../node_modules/my-lib";` - /// @source "../node_modules/my-lib/**/*";` - /// ``` - /// - /// Being explicit bypasses the ignoredness of the directory: everything inside is scanned - /// as if it were a regular auto source, except that `.gitignore` files from at or above - /// the directory no longer apply — they (including the self-ignoring `*` file that - /// generators typically place inside such directories) are what made it ignored in the - /// first place. `.gitignore` files *deeper inside* the directory still apply, and so do - /// the default rules, so e.g. nested `node_modules` stay ignored. - External { base: PathBuf }, - /// Explicit source pattern regardless of any auto source detection rules /// /// Represented by: @@ -65,11 +48,18 @@ pub enum SourceEntry { /// @source not "src";` /// @source not "src/**/*.html";` /// ``` - /// - /// Note that directory-shaped directives (`@source not "src"`) are normalized to - /// `base: "src", pattern: "/**/*"`, which is semantically identical: everything under the - /// directory is ignored. Ignored { base: PathBuf, pattern: String }, + + /// External sources are directories that are ignored (by us or .gitignore rules), but should be + /// included bypassing the default ignore rules. + /// + /// Represented by: + /// + /// ```css + /// @source "../node_modules/my-lib";` + /// @source "../node_modules/my-lib/**/*";` + /// ``` + External { base: PathBuf }, } #[derive(Debug, Clone, Default)] @@ -87,6 +77,164 @@ impl Sources { } } +/// When dealing with a pattern, then it could be that we end up with: +/// +/// ```json +/// { base: '/some/folder', pattern: 'foo.ts' } +/// ``` +/// +/// If we just emit `!foo.ts` for the `/some/folder` path, then _everything_ else in that folder +/// would still be walked (but the result will be ignored). +/// +/// Instead, we have to ensure that we ignore everything in that folder _except_ for the `foo.ts` +/// pattern. +/// +/// This should be equivalent to: +/// ```gitignore +/// * +/// !foo.ts +/// ``` +/// +/// However, we have to be careful that we don't start ignoring files/folders that already exist. +/// ```css +/// @source "./some/folder/foo.ts"; +/// @source "./some/folder/bar.ts"; +/// ``` +/// Would result in: +/// ```json +/// { base: '/some/folder', pattern: 'foo.ts' } +/// { base: '/some/folder', pattern: 'bar.ts' } +/// ``` +/// +/// If we were to blindly emit `*` for each pattern, then the `.gitignore` equivalent would look like +/// this: +/// ```gitignore +/// * +/// !foo.ts +/// * +/// !bar.ts +/// ``` +/// +/// This would result in ignoring the `foo.ts` file as well. Therefore we only want to insert +/// this `*` pattern when nothing else exists yet. +/// +/// There is another problem that we need to solve. Let's say you have a pattern that contains a `*` +/// in the pattern: +/// ```css +/// @source './src/ba*/*.html'; +/// ``` +/// +/// This would result in +/// ```json +/// { base: '/src', pattern: '/ba*/*.html' } +/// ``` +/// +/// If we now inject the `*` pattern for the `/src` folder, then we wouldn't scan any `ba*` folders +/// (e.g. `bar` or `baz`). For this, we have to make sure that we add inverse patterns for these +/// folders. This would essentially result in: +/// ```gitignore +/// * ← ignore everything +/// !/ba*/ ← except for the `ba*/` pattern, so we scan these folders +/// !/ba*/*.html ← then ensure we scan the `*.html` files in it as well +/// ``` +/// +fn expand_restricted_patterns(sources: Vec) -> Vec { + let unrestricted_roots = sources + .iter() + .filter_map(|source| match source { + SourceEntry::Auto { base } | SourceEntry::External { base } => Some(base.clone()), + SourceEntry::Pattern { base, pattern } if pattern.contains("**") => Some(base.clone()), + _ => None, + }) + .collect::>(); + + // Bases of restricted patterns. Each of these becomes its own walk root with its own + // `*` + `!` rules, so an ancestor base must not ignore them recursively. + let pattern_roots = sources + .iter() + .filter_map(|source| match source { + SourceEntry::Pattern { base, .. } => Some(base.clone()), + _ => None, + }) + .collect::>(); + + let mut restricted_roots: FxHashSet = FxHashSet::default(); + let mut expanded = vec![]; + + for source in sources { + let SourceEntry::Pattern { base, pattern } = &source else { + expanded.push(source); + continue; + }; + + // `base` is already included by another `@source` that we know should be walked. This + // includes the case where `base` is _nested_ inside such a root, because everything under + // an unrestricted root is walked already. Restricting it would incorrectly hide siblings + // that the broader source is supposed to pick up. + if unrestricted_roots.iter().any(|root| base.starts_with(root)) { + expanded.push(source); + continue; + } + + // Ignore everything in the directory. We will later add the specific patterns we are + // interested in. + if restricted_roots.insert(base.clone()) { + // When another source root is nested inside this base — an unrestricted root, or the + // base of another restricted pattern (which is walked from its own root with its own + // rules) — only ignore direct children so the nested root can still be walked. + let has_nested_root = unrestricted_roots.iter().any(|root| root.starts_with(base)) + || pattern_roots + .iter() + .any(|root| root != base && root.starts_with(base)); + + let pattern = if has_nested_root { "/*" } else { "*" }; + + expanded.push(SourceEntry::Ignored { + base: base.clone(), + pattern: pattern.to_owned(), + }); + } + + // Ensure to _include_ parent paths, otherwise the `*` from above would block walking the + // folders that need to be walked. + // + // ```css + // @source './src/ba*/*.html'; + // ``` + // + // ```gitignore + // * ← added by the above rule + // !/ba*/ ← this is what we're focusing on in this block + // !/ba*/*.html ← this is added later + // ``` + { + let mut dir = PathBuf::new(); + let mut components = Path::new(pattern).components().peekable(); + + while let Some(component) = components.next() { + if components.peek().is_none() { + break; + } + + match component { + Component::Prefix(_) | Component::RootDir | Component::CurDir => continue, + Component::ParentDir | Component::Normal(_) => dir.push(component), + } + + expanded.push(SourceEntry::Ignored { + base: base.clone(), + pattern: format!("!/{}/", path_to_posix_string(&dir).trim_start_matches('/')), + }); + } + } + + // Track the original source + expanded.push(source); + } + + expanded +} + impl PublicSourceEntry { /// Optimize the PublicSourceEntry by trying to move all the static parts of the pattern to the /// base of the PublicSourceEntry. @@ -192,16 +340,10 @@ impl PublicSourceEntry { else if !self.pattern.starts_with("/") { self.pattern = format!("/{}", self.pattern); } - - // `src/**` means everything underneath `src`, just like `src/**/*` and `src` do. - // Normalize it so all three are classified as auto source detection. - if self.pattern == "/**" { - self.pattern = "/**/*".to_owned(); - } } } -pub(crate) fn path_to_posix_string(path: &Path) -> String { +fn path_to_posix_string(path: &Path) -> String { let mut parts = Vec::new(); let mut is_rooted = false; @@ -332,32 +474,65 @@ mod tests { } #[test] - fn optimize_normalizes_double_star_to_auto_source_detection() { + fn concrete_patterns_are_expanded_to_restrict_their_base() { let dir = tempdir().unwrap(); fs::create_dir_all(dir.path().join("src")).unwrap(); - - let mut source = PublicSourceEntry { - base: dir.path().to_string_lossy().to_string(), - pattern: "src/**".to_string(), - negated: false, - }; - - source.optimize(); - - assert_eq!(source.pattern, "/**/*"); - - // …and therefore `src/**` is classified as an auto source, like `src/**/*` and `src` let base = dunce::canonicalize(dir.path().join("src")).unwrap(); + let sources = public_source_entries_to_private_source_entries(vec![PublicSourceEntry { base: dir.path().to_string_lossy().to_string(), - pattern: "src/**".to_string(), + pattern: "src/foo.html".to_string(), negated: false, }]); - assert_eq!(sources, vec![SourceEntry::Auto { base }]); + + assert_eq!( + sources, + vec![ + SourceEntry::Ignored { + base: base.clone(), + pattern: "*".to_string(), + }, + SourceEntry::Pattern { + base, + pattern: "/foo.html".to_string(), + }, + ] + ); } #[test] - fn sources_are_converted_in_order() { + fn restricted_patterns_include_parent_directory_allow_rules() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join("src")).unwrap(); + let base = dunce::canonicalize(dir.path().join("src")).unwrap(); + + let sources = public_source_entries_to_private_source_entries(vec![PublicSourceEntry { + base: dir.path().to_string_lossy().to_string(), + pattern: "src/ef*/*.html".to_string(), + negated: false, + }]); + + assert_eq!( + sources, + vec![ + SourceEntry::Ignored { + base: base.clone(), + pattern: "*".to_string(), + }, + SourceEntry::Ignored { + base: base.clone(), + pattern: "!/ef*/".to_string(), + }, + SourceEntry::Pattern { + base, + pattern: "/ef*/*.html".to_string(), + }, + ] + ); + } + + #[test] + fn unrestricted_sources_do_not_expand_patterns_for_the_same_base() { let dir = tempdir().unwrap(); fs::create_dir_all(dir.path().join("src")).unwrap(); let base = dunce::canonicalize(dir.path().join("src")).unwrap(); @@ -373,6 +548,72 @@ mod tests { pattern: "src/foo.html".to_string(), negated: false, }, + ]); + + assert_eq!( + sources, + vec![ + SourceEntry::Auto { base: base.clone() }, + SourceEntry::Pattern { + base, + pattern: "/foo.html".to_string(), + }, + ] + ); + } + + #[test] + fn restricted_parent_bases_do_not_open_unrelated_siblings() { + let dir = tempdir().unwrap(); + let project = dir.path().join("Users").join("robin").join("docus-test"); + fs::create_dir_all(&project).unwrap(); + + let users = dunce::canonicalize(dir.path().join("Users")).unwrap(); + let project = dunce::canonicalize(project).unwrap(); + + let sources = public_source_entries_to_private_source_entries(vec![ + PublicSourceEntry { + base: project.to_string_lossy().to_string(), + pattern: "**/*".to_string(), + negated: false, + }, + PublicSourceEntry { + base: project.to_string_lossy().to_string(), + pattern: "../../app.config.ts".to_string(), + negated: false, + }, + ]); + + assert_eq!( + sources, + vec![ + SourceEntry::Auto { + base: project.clone(), + }, + SourceEntry::Ignored { + base: users.clone(), + pattern: "/*".to_string(), + }, + SourceEntry::Pattern { + base: users, + pattern: "/app.config.ts".to_string(), + }, + ] + ); + } + + #[test] + fn restricted_patterns_preserve_source_order() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join("src")).unwrap(); + let base = dunce::canonicalize(dir.path().join("src")).unwrap(); + + let sources = public_source_entries_to_private_source_entries(vec![ + PublicSourceEntry { + base: dir.path().to_string_lossy().to_string(), + pattern: "src/foo.html".to_string(), + negated: false, + }, PublicSourceEntry { base: dir.path().to_string_lossy().to_string(), pattern: "src/foo.html".to_string(), @@ -383,7 +624,10 @@ mod tests { assert_eq!( sources, vec![ - SourceEntry::Auto { base: base.clone() }, + SourceEntry::Ignored { + base: base.clone(), + pattern: "*".to_string(), + }, SourceEntry::Pattern { base: base.clone(), pattern: "/foo.html".to_string(), @@ -416,10 +660,7 @@ mod tests { fs::create_dir_all(&base).unwrap(); let base = dunce::canonicalize(&base).unwrap(); - assert_eq!( - auto_source_entry(&base), - SourceEntry::Auto { base } - ); + assert_eq!(auto_source_entry(&base), SourceEntry::Auto { base }); } #[test] @@ -429,10 +670,7 @@ mod tests { fs::create_dir_all(&base).unwrap(); let base = dunce::canonicalize(&base).unwrap(); - assert_eq!( - auto_source_entry(&base), - SourceEntry::External { base } - ); + assert_eq!(auto_source_entry(&base), SourceEntry::External { base }); } #[test] @@ -446,10 +684,7 @@ mod tests { fs::create_dir_all(&base).unwrap(); let base = dunce::canonicalize(&base).unwrap(); - assert_eq!( - auto_source_entry(&base), - SourceEntry::External { base } - ); + assert_eq!(auto_source_entry(&base), SourceEntry::External { base }); } #[test] @@ -463,54 +698,7 @@ mod tests { fs::create_dir_all(&base).unwrap(); let base = dunce::canonicalize(&base).unwrap(); - assert_eq!( - auto_source_entry(&base), - SourceEntry::External { base } - ); - } - - #[test] - fn folders_reincluded_by_a_deeper_gitignore_stay_auto_sources() { - let dir = tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".git")).unwrap(); - fs::write(dir.path().join(".gitignore"), "generated/\n").unwrap(); - // The deepest `.gitignore` with a definitive answer wins: the re-include is reachable - // (no parent directory of `generated` is excluded), so `generated` is not ignored. - fs::create_dir_all(dir.path().join("packages").join("app")).unwrap(); - fs::write( - dir.path().join("packages").join("app").join(".gitignore"), - "!generated/\n", - ) - .unwrap(); - - let base = dir.path().join("packages").join("app").join("generated"); - fs::create_dir_all(&base).unwrap(); - let base = dunce::canonicalize(&base).unwrap(); - - assert_eq!( - auto_source_entry(&base), - SourceEntry::Auto { base } - ); - } - - #[test] - fn folders_inside_excluded_directories_become_external_sources() { - let dir = tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".git")).unwrap(); - fs::write(dir.path().join(".gitignore"), "parent/\n").unwrap(); - // This whitelist is unreachable: `parent` itself is excluded, so git never descends - // into it and the re-include of `child` has no effect. - fs::create_dir_all(dir.path().join("parent")).unwrap(); - fs::write(dir.path().join("parent").join(".gitignore"), "!child/\n").unwrap(); - - let base = dir.path().join("parent").join("child"); - fs::create_dir_all(&base).unwrap(); - let base = dunce::canonicalize(&base).unwrap(); - - assert_eq!( - auto_source_entry(&base), - SourceEntry::External { base } - ); + assert_eq!(auto_source_entry(&base), SourceEntry::External { base }); } #[test] @@ -523,10 +711,7 @@ mod tests { fs::create_dir_all(&base).unwrap(); let base = dunce::canonicalize(&base).unwrap(); - assert_eq!( - auto_source_entry(&base), - SourceEntry::Auto { base } - ); + assert_eq!(auto_source_entry(&base), SourceEntry::Auto { base }); } } @@ -593,79 +778,74 @@ pub fn public_source_entries_to_private_source_entries( .map(|public_source| { let mut source: SourceEntry = public_source.into(); - // Mark auto sources as external if their directory is gitignored + // Promote auto-sources to external sources if they were gitignored if let SourceEntry::Auto { ref base } = source { let inside_git_repo = base.ancestors().any(|dir| dir.join(".git").exists()); - // The chain of directories whose `.gitignore` files can apply: `base` itself and - // its ancestors, up to and including the git repository root so `.gitignore` - // files outside of the repo are not considered. - // - // Without a git repository there is no repository root to stop at. Stop once the - // directory contains the current working directory instead, so `.gitignore` - // files outside of the project (e.g. in the user's home directory) can never - // promote a source to an external source. Note that the file walker still - // applies those `.gitignore` files when deciding which files to scan. - let mut chain: Vec<&Path> = vec![]; + // Walk up from the folder, applying each `.gitignore` relative to the directory + // that contains it (matching git), and stop at the git repository root so + // `.gitignore` files outside of the repo are not considered. for dir in base.ancestors() { - chain.push(dir); + let gitignore = gitignores.entry(dir.to_path_buf()).or_insert_with(|| { + let path = dir.join(".gitignore"); + // `Gitignore::new` roots the matcher at the directory containing the file, + // so patterns match relative to it. + path.is_file().then(|| Gitignore::new(&path).0) + }); + + // Only `.gitignore` files in ancestors of `base` can ignore `base` itself. + // Patterns in `base`'s own `.gitignore` only match paths _inside_ `base`, never + // `base` itself (the file walker still applies them to `base`'s contents). + // + // Skipping `base`'s own `.gitignore` also prevents a false positive for + // whitelist style `.gitignore` files, because relativizing `base` against + // itself yields the empty path, which incorrectly matches `/*`. + // + // E.g.: + // + // ```gitignore + // /* + // !/.gitignore + // !/app + // !/public + // ``` + // + // Everything inside `base` except `.gitignore`, `app` and `public` is ignored, + // but `base` itself is not. + if dir != base { + if let Some(gitignore) = gitignore { + if gitignore + .matched_path_or_any_parents(&base, true) + .is_ignore() + { + source = SourceEntry::External { base: base.into() }; + break; + } + } + } + + // Stop at the git repository root. if dir.join(".git").exists() { break; } + // Without a git repository there is no repository root to stop at. Stop once + // the directory contains the current working directory instead, so `.gitignore` + // files outside of the project (e.g. in the user's home directory) can never + // promote a source to an external source. Note that the file walker still + // applies those `.gitignore` files when deciding which files to scan. if !inside_git_repo && cwd.as_ref().is_some_and(|cwd| cwd.starts_with(dir)) { break; } } - - // Match git's semantics: a directory is ignored when the directory itself or any - // of its parent directories is excluded, and it is not possible to re-include a - // directory once a parent directory is excluded — git never descends into an - // excluded directory, so whitelist rules inside of it are unreachable. - // - // So walk the path from the top down (`chain` is ordered bottom-up: `base` at - // index 0, the boundary last) and decide for every directory along the way - // whether it is excluded. The first excluded directory settles it. For a single - // directory, only `.gitignore` files in its parent directories can match it (its - // own `.gitignore` only matches paths _inside_ of it), and the deepest - // `.gitignore` with a definitive answer wins, so a directory that is re-included - // by a deeper `!the-directory` pattern is not ignored, even when an ancestor - // `.gitignore` ignores it. - 'prefixes: for i in (0..chain.len().saturating_sub(1)).rev() { - let prefix = chain[i]; - - for dir in &chain[i + 1..] { - let gitignore = gitignores.entry(dir.to_path_buf()).or_insert_with(|| { - let path = dir.join(".gitignore"); - - // `Gitignore::new` roots the matcher at the directory containing the - // file, so patterns match relative to it. - path.is_file().then(|| Gitignore::new(&path).0) - }); - - let Some(gitignore) = gitignore else { - continue; - }; - - match gitignore.matched(prefix, true) { - ignore::Match::Ignore(_) => { - source = SourceEntry::External { base: base.into() }; - break 'prefixes; - } - // Re-included; this directory is reachable, move on to the next one. - ignore::Match::Whitelist(_) => continue 'prefixes, - ignore::Match::None => {} - } - } - } } source }) .collect::>(); - sources + expand_restricted_patterns(sources) } /// Convert a public source entry to a source entry @@ -678,15 +858,8 @@ impl From for SourceEntry { }; } - // After a successful `optimize()` any trailing concrete directory has already been - // hoisted into the base, so a folder source always has the `/**/*` pattern. The - // `is_dir` check only matters when `optimize()` could not canonicalize the base and - // left the entry untouched. Note that the pinned leading `/` has to be stripped, since - // joining an absolute-looking path onto the base would discard the base entirely. - let auto = value.pattern == "/**/*" - || PathBuf::from(&value.base) - .join(value.pattern.trim_start_matches('/')) - .is_dir(); + let auto = + value.pattern == "/**/*" || PathBuf::from(&value.base).join(&value.pattern).is_dir(); if !auto { return SourceEntry::Pattern { @@ -695,8 +868,6 @@ impl From for SourceEntry { }; } - // A directory inside e.g. `node_modules` is ignored by default, so listing it - // explicitly makes it an external source. let inside_ignored_content_dir = IGNORED_CONTENT_DIRS.iter().any(|dir| { value.base.contains(&format!( "{}{}{}", @@ -718,3 +889,50 @@ impl From for SourceEntry { } } } + +impl From for SourceEntry { + fn from(value: GlobEntry) -> Self { + SourceEntry::Pattern { + base: PathBuf::from(value.base), + pattern: value.pattern, + } + } +} + +impl From for GlobEntry { + fn from(value: SourceEntry) -> Self { + match value { + SourceEntry::Auto { base } | SourceEntry::External { base } => GlobEntry { + base: base.to_string_lossy().into(), + pattern: "**/*".into(), + }, + SourceEntry::Pattern { base, pattern } => GlobEntry { + base: base.to_string_lossy().into(), + pattern: pattern.clone(), + }, + SourceEntry::Ignored { base, pattern } => GlobEntry { + base: base.to_string_lossy().into(), + pattern: pattern.clone(), + }, + } + } +} + +impl From<&SourceEntry> for GlobEntry { + fn from(value: &SourceEntry) -> Self { + match value { + SourceEntry::Auto { base } | SourceEntry::External { base } => GlobEntry { + base: base.to_string_lossy().into(), + pattern: "**/*".into(), + }, + SourceEntry::Pattern { base, pattern } => GlobEntry { + base: base.to_string_lossy().into(), + pattern: pattern.clone(), + }, + SourceEntry::Ignored { base, pattern } => GlobEntry { + base: base.to_string_lossy().into(), + pattern: pattern.clone(), + }, + } + } +} diff --git a/crates/oxide/tests/scanner.rs b/crates/oxide/tests/scanner.rs index 42c08d9b3..fb5354a87 100644 --- a/crates/oxide/tests/scanner.rs +++ b/crates/oxide/tests/scanner.rs @@ -59,23 +59,21 @@ mod scanner { tree: String, } - /// Renders the directory as a tree, annotating every file and folder with - /// an indicator that shows whether the scanner picked it up: + /// Renders the directory as a tree, annotating every file and folder with an indicator that + /// shows whether the scanner picked it up: /// /// - `✓` — scanned /// - `✗` — ignored / skipped /// - /// Symlinks are rendered as `link → target`, where the target is shown - /// relative to the folder containing the symlink. The contents of every - /// `.gitignore` file are printed right below the file itself. + /// Symlinks are rendered as `link → target`, where the target is shown relative to the folder + /// containing the symlink. The contents of every `.gitignore` file are printed right below the + /// file itself. /// - /// Folders that are a git repository root (they contain a `.git` folder) - /// are marked with `(git)`, because ignore rules behave differently - /// inside and outside of a repository. + /// Folders that are a git repository root (they contain a `.git` folder) are marked with + /// `(git)`, because ignore rules behave differently inside and outside of a repository. fn fs_tree(root: &Path, scanned: &[String]) -> String { - /// Computes the relative path from `parent` to `target`, where both - /// are relative to the same root, e.g.: `b/c` seen from `b` is `c`, - /// and the root itself seen from `b` is `..`. + /// Computes the relative path from `parent` to `target`, where both are relative to the + /// same root, e.g.: `b/c` seen from `b` is `c`, and the root itself seen from `b` is `..`. fn relative_to(target: &Path, parent: &Path) -> String { let target = target.components().collect::>(); let parent = parent.components().collect::>(); @@ -131,10 +129,9 @@ mod scanner { let file_type = entry.file_type().unwrap(); - // A file is scanned when it shows up in the scanned files - // list. A folder is considered scanned when any scanned file - // lives inside of it. Paths that go through a symlink count - // towards the symlink, not towards the target directory. + // a file is scanned when it shows up in the scanned files list. a folder is + // considered scanned when any scanned file lives inside of it. paths that go + // through a symlink count towards the symlink, not towards the target directory. let dir_prefix = format!("{rel}/"); let is_scanned = scanned .iter() @@ -147,9 +144,8 @@ mod scanner { } if file_type.is_symlink() { if let Ok(target) = fs::read_link(&path) { - // Show the target relative to the folder containing - // the symlink, or as-is when it points outside of the - // tree. + // Show the target relative to the folder containing the symlink, or as-is + // when it points outside of the tree. let target = match (target.strip_prefix(root), dir.strip_prefix(root)) { (Ok(target), Ok(parent)) => relative_to(target, parent), _ => target.to_string_lossy().replace('\\', "/"), @@ -165,8 +161,8 @@ mod scanner { out.push_str(&format!("{prefix}{connector}{indicator} {display_name}\n")); - // Print the contents of `.gitignore` files below the file - // itself, so the ignore rules are visible in the same output. + // Print the contents of `.gitignore` files below the file itself, so the ignore + // rules are visible in the same output. if name == ".gitignore" { if let Ok(contents) = fs::read_to_string(&path) { for line in contents.lines() { @@ -177,10 +173,9 @@ mod scanner { } } - // Descend into directories, including symlinked ones. The - // paths inside a symlinked directory are computed through the - // symlink, so the indicators show what the scanner saw via - // that route. A visited stack prevents symlink cycles from + // Descend into directories, including symlinked ones. The paths inside a symlinked + // directory are computed through the symlink, so the indicators show what the + // scanner saw via that route. A visited stack prevents symlink cycles from // recursing forever. if path.metadata().map(|meta| meta.is_dir()).unwrap_or(false) { let Ok(canonical) = dunce::canonicalize(&path) else { @@ -1081,6 +1076,102 @@ mod scanner { assert_eq!(normalized_sources, vec!["**/*", "*.styl"]); } + #[test] + fn it_should_drop_invalid_utf8_candidates() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("index.html"), b"flex bg-[\x80] block").unwrap(); + + let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( + dir.path().to_path_buf(), + "@source '*.html'", + )]); + + let candidates = scanner + .scan() + .into_iter() + .map(String::into_bytes) + .collect::>(); + + assert_eq!(candidates, vec![b"block".to_vec(), b"flex".to_vec()]); + } + + #[test] + fn it_should_not_store_invalid_utf8_candidates_during_incremental_scans() { + let dir = tempdir().unwrap(); + let file = dir.path().join("index.html"); + fs::write(&file, b"flex bg-[\x80]").unwrap(); + + let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( + dir.path().to_path_buf(), + "@source '*.html'", + )]); + + let candidates = scanner + .scan_content(vec![ChangedContent::File(file, "html".into())]) + .into_iter() + .map(String::into_bytes) + .collect::>(); + assert_eq!(candidates, vec![b"flex".to_vec()]); + + let candidates = + scanner.scan_content(vec![ChangedContent::Content("block".into(), "html".into())]); + assert_eq!(candidates, vec!["block"]); + + let candidates = scanner + .scan() + .into_iter() + .map(String::into_bytes) + .collect::>(); + assert_eq!(candidates, vec![b"block".to_vec(), b"flex".to_vec()]); + } + + #[test] + fn it_should_drop_invalid_utf8_candidates_with_positions() { + let dir = tempdir().unwrap(); + let file = dir.path().join("index.html"); + fs::write( + &file, + b"flex bg-[\x80] group-[]:block group-[]:bg-[\x80] grid", + ) + .unwrap(); + + let mut scanner = Scanner::new(vec![]); + let candidates = scanner + .get_candidates_with_positions(ChangedContent::File(file, "html".into())) + .into_iter() + .map(|(candidate, position)| (candidate.into_bytes(), position)) + .collect::>(); + + assert_eq!( + candidates, + vec![ + (b"flex".to_vec(), 0), + (b"group-[]:block".to_vec(), 12), + (b"grid".to_vec(), 43), + ] + ); + } + + #[test] + fn it_should_preserve_valid_utf8_candidates() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("index.html"), + "before:content-['💩'] bg-[é] font-[中文]".as_bytes(), + ) + .unwrap(); + + let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( + dir.path().to_path_buf(), + "@source '*.html'", + )]); + + assert_eq!( + scanner.scan(), + vec!["before:content-['💩']", "bg-[é]", "font-[中文]"] + ); + } + #[test] fn it_should_preserve_paths_for_sources_ending_in_a_deep_glob() { let ScanResult { @@ -3947,974 +4038,6 @@ mod scanner { ]); } - // https://github.com/tailwindlabs/tailwindcss/issues/18870 - #[test] - fn glob_sources_can_descend_into_directories_ignored_from_within() { - // Laravel's `storage/` directories ship a `.gitignore` that ignores everything inside - // (`*` + `!.gitignore`). An explicit glob through such a directory should still find - // matching files, but nothing else. - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[ - ("storage/.gitignore", "*\n!.gitignore"), - ( - "storage/cms/section.html", - "content-['storage/cms/section.html']", - ), - ( - "storage/cms/nested/deep.html", - "content-['storage/cms/nested/deep.html']", - ), - ("storage/cms/data.json", "content-['storage/cms/data.json']"), - ("storage/other/other.html", "content-['storage/other.html']"), - ], - vec!["@source './storage/cms/**/*.html'"], - ); - - assert_snapshot!(tree, @" - . (git) - └── ✓ storage - ├── ✗ .gitignore - │ * - │ !.gitignore - ├── ✓ cms - │ ├── ✗ data.json - │ ├── ✓ nested - │ │ └── ✓ deep.html - │ └── ✓ section.html - └── ✗ other - └── ✗ other.html - "); - - assert_eq!( - candidates, - vec![ - "content-['storage/cms/nested/deep.html']", - "content-['storage/cms/section.html']", - ] - ); - assert_eq!( - files, - vec!["storage/cms/nested/deep.html", "storage/cms/section.html"] - ); - } - - // https://github.com/tailwindlabs/tailwindcss/issues/18870 - #[test] - fn glob_sources_can_descend_into_directories_ignored_by_an_ancestor() { - // Same as above, but the ignore comes from an ancestor `.gitignore` rather than one - // inside the ignored directory itself. - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[ - (".gitignore", "/storage"), - ( - "storage/cms/section.html", - "content-['storage/cms/section.html']", - ), - ( - "storage/cms/nested/deep.html", - "content-['storage/cms/nested/deep.html']", - ), - ("storage/cms/script.js", "content-['storage/cms/script.js']"), - ], - vec!["@source './storage/cms/**/*.html'"], - ); - - assert_snapshot!(tree, @" - . (git) - ├── ✗ .gitignore - │ /storage - └── ✓ storage - └── ✓ cms - ├── ✓ nested - │ └── ✓ deep.html - ├── ✗ script.js - └── ✓ section.html - "); - - assert_eq!( - candidates, - vec![ - "content-['storage/cms/nested/deep.html']", - "content-['storage/cms/section.html']", - ] - ); - assert_eq!( - files, - vec!["storage/cms/nested/deep.html", "storage/cms/section.html"] - ); - } - - #[test] - fn glob_sources_inside_ignored_directories_do_not_include_other_extensions() { - // `@source "./foo/*.html"` where `foo` is git ignored: only the `.html` files were asked - // for. Other files in `foo` must not be scanned, even when a broader auto source exists. - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[ - (".gitignore", "/foo"), - ("foo/index.html", "content-['foo/index.html']"), - ("foo/script.js", "content-['foo/script.js']"), - ( - "foo/nested/nested.html", - "content-['foo/nested/nested.html']", - ), - ("index.html", "content-['index.html']"), - ], - vec!["@source '**/*'", "@source './foo/*.html'"], - ); - - assert_snapshot!(tree, @" - . (git) - ├── ✗ .gitignore - │ /foo - ├── ✓ foo - │ ├── ✓ index.html - │ ├── ✗ nested - │ │ └── ✗ nested.html - │ └── ✗ script.js - └── ✓ index.html - "); - - assert_eq!( - candidates, - vec!["content-['foo/index.html']", "content-['index.html']"] - ); - assert_eq!(files, vec!["foo/index.html", "index.html"]); - } - - // https://github.com/tailwindlabs/tailwindcss/pull/20406 - #[test] - fn concrete_file_sources_behind_node_modules_do_not_include_siblings() { - // A concrete file `@source` pointing into a default-ignored directory (`node_modules`) - // must only include that file, not its siblings, even when the project root is an auto - // source. - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[ - ( - "node_modules/.generated/ui/button.ts", - "content-['button.ts']", - ), - ("node_modules/.generated/ui/card.ts", "content-['card.ts']"), - ("index.html", "content-['index.html']"), - ], - vec![ - "@source '**/*'", - "@source './node_modules/.generated/ui/button.ts'", - ], - ); - - assert_snapshot!(tree, @" - . (git) - ├── ✓ index.html - └── ✓ node_modules - └── ✓ .generated - └── ✓ ui - ├── ✓ button.ts - └── ✗ card.ts - "); - - assert_eq!( - candidates, - vec!["content-['button.ts']", "content-['index.html']"] - ); - assert_eq!( - files, - vec!["index.html", "node_modules/.generated/ui/button.ts"] - ); - } - - // https://github.com/tailwindlabs/tailwindcss/pull/20406 - #[test] - fn concrete_file_sources_behind_gitignored_directories_do_not_include_siblings() { - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[ - (".gitignore", "/generated"), - ("generated/button.ts", "content-['button.ts']"), - ("generated/card.ts", "content-['card.ts']"), - ("index.html", "content-['index.html']"), - ], - vec!["@source '**/*'", "@source './generated/button.ts'"], - ); - - assert_snapshot!(tree, @" - . (git) - ├── ✗ .gitignore - │ /generated - ├── ✓ generated - │ ├── ✓ button.ts - │ └── ✗ card.ts - └── ✓ index.html - "); - - assert_eq!( - candidates, - vec!["content-['button.ts']", "content-['index.html']"] - ); - assert_eq!(files, vec!["generated/button.ts", "index.html"]); - } - - #[test] - fn glob_sources_do_not_reopen_node_modules() { - // A glob source respects the default auto source detection rules: `node_modules` inside - // the globbed tree stays pruned unless it is targeted explicitly. - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[ - ("src/index.html", "content-['src/index.html']"), - ( - "src/node_modules/lib/index.html", - "content-['src/node_modules/lib/index.html']", - ), - ], - vec!["@source './src/**/*.html'"], - ); - - assert_snapshot!(tree, @" - . (git) - └── ✓ src - ├── ✓ index.html - └── ✗ node_modules - └── ✗ lib - └── ✗ index.html - "); - - assert_eq!(candidates, vec!["content-['src/index.html']"]); - assert_eq!(files, vec!["src/index.html"]); - } - - #[test] - fn wildcards_in_glob_sources_do_not_descend_into_gitignored_directories() { - // The static part of a glob source is the "explicit" part: it bypasses ignore rules. - // The wildcard part does not: `@source "./**/*.html"` does not descend into a git - // ignored directory — you weren't explicit about it. To opt in, name the directory: - // `@source "./dist/**/*.html"`. - // - // Files are different: a git ignored *file* that matches the glob is still included - // (you asked for all `.html` files). - let paths_with_content = &[ - (".gitignore", "dist/\nignored.html"), - ("src/index.html", "content-['src/index.html']"), - ("dist/index.html", "content-['dist/index.html']"), - ("ignored.html", "content-['ignored.html']"), - ]; - - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs(paths_with_content, vec!["@source './**/*.html'"]); - - assert_snapshot!(tree, @" - . (git) - ├── ✗ .gitignore - │ dist/ - │ ignored.html - ├── ✗ dist - │ └── ✗ index.html - ├── ✓ ignored.html - └── ✓ src - └── ✓ index.html - "); - - assert_eq!( - candidates, - vec!["content-['ignored.html']", "content-['src/index.html']"] - ); - assert_eq!(files, vec!["ignored.html", "src/index.html"]); - - // Being explicit about the ignored directory bypasses the ignore rule. - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - paths_with_content, - vec!["@source './**/*.html'", "@source './dist/**/*.html'"], - ); - - assert_snapshot!(tree, @" - . (git) - ├── ✗ .gitignore - │ dist/ - │ ignored.html - ├── ✓ dist - │ └── ✓ index.html - ├── ✓ ignored.html - └── ✓ src - └── ✓ index.html - "); - - assert_eq!( - candidates, - vec![ - "content-['dist/index.html']", - "content-['ignored.html']", - "content-['src/index.html']" - ] - ); - assert_eq!( - files, - vec!["dist/index.html", "ignored.html", "src/index.html"] - ); - } - - #[test] - fn glob_sources_only_rescue_matching_files_from_ignored_directories() { - // When a glob source forces the walker into a git ignored directory, files that do not - // match the glob must stay excluded, even though they are only ignored "via" their - // parent directory. - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[ - (".gitignore", "gen/"), - ("gen/a.html", "content-['gen/a.html']"), - ("gen/b.js", "content-['gen/b.js']"), - ("index.html", "content-['index.html']"), - ], - vec!["@source '**/*'", "@source './gen/**/*.html'"], - ); - - assert_snapshot!(tree, @" - . (git) - ├── ✗ .gitignore - │ gen/ - ├── ✓ gen - │ ├── ✓ a.html - │ └── ✗ b.js - └── ✓ index.html - "); - - assert_eq!( - candidates, - vec!["content-['gen/a.html']", "content-['index.html']"] - ); - assert_eq!(files, vec!["gen/a.html", "index.html"]); - } - - #[test] - fn unpinned_extension_globs_apply_default_extension_rules() { - // A glob that doesn't pin an extension (`**/*`-style tail after a wildcard directory) - // still applies the default extension rules, so binary files are not included. - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[ - ("blog/2024/post/index.html", "content-['index.html']"), - ("blog/2024/post/image.png", "content-['image.png']"), - ("blog/2024/post/styles.scss", "content-['styles.scss']"), - ], - vec!["@source './blog/*/post/**/*'"], - ); - - assert_snapshot!(tree, @" - . (git) - └── ✓ blog - └── ✓ 2024 - └── ✓ post - ├── ✗ image.png - ├── ✓ index.html - └── ✗ styles.scss - "); - - assert_eq!(candidates, vec!["content-['index.html']"]); - assert_eq!(files, vec!["blog/2024/post/index.html"]); - } - - #[test] - fn unpinned_globs_do_not_reinclude_gitignored_directories() { - // The whitelist of an unpinned glob like `./blog/*/**/*` could also match *directory* - // paths (unlike e.g. `./**/*.html`, which only ever matches files). It must not: the - // wildcard part of a glob is not explicit, so git ignored directories inside the - // walked subtree stay pruned, even when the whitelisted glob happens to match them. - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[ - ("blog/.gitignore", "drafts/"), - ("blog/2024/keep.html", "content-['blog/2024/keep.html']"), - ( - "blog/2024/drafts/secret.html", - "content-['blog/2024/drafts/secret.html']", - ), - ], - vec!["@source './blog/*/**/*'"], - ); - - assert_snapshot!(tree, @" - . (git) - └── ✓ blog - ├── ✗ .gitignore - │ drafts/ - └── ✓ 2024 - ├── ✗ drafts - │ └── ✗ secret.html - └── ✓ keep.html - "); - - assert_eq!(candidates, vec!["content-['blog/2024/keep.html']"]); - assert_eq!(files, vec!["blog/2024/keep.html"]); - } - - #[test] - fn external_sources_ignore_nested_default_ignored_directories() { - // An explicitly listed, git ignored directory (external source) still applies the - // default auto source detection rules inside of it: nested `node_modules` are not - // scanned. - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[ - (".gitignore", "node_modules"), - ( - "node_modules/my-lib/dist/index.html", - "content-['node_modules/my-lib/dist/index.html']", - ), - ( - "node_modules/my-lib/node_modules/dep/index.html", - "content-['node_modules/my-lib/node_modules/dep/index.html']", - ), - ( - "node_modules/my-lib/logo.png", - "content-['node_modules/my-lib/logo.png']", - ), - ], - vec!["@source './node_modules/my-lib'"], - ); - - assert_snapshot!(tree, @" - . (git) - ├── ✗ .gitignore - │ node_modules - └── ✓ node_modules - └── ✓ my-lib - ├── ✓ dist - │ └── ✓ index.html - ├── ✗ logo.png - └── ✗ node_modules - └── ✗ dep - └── ✗ index.html - "); - - assert_eq!( - candidates, - vec!["content-['node_modules/my-lib/dist/index.html']"] - ); - assert_eq!(files, vec!["node_modules/my-lib/dist/index.html"]); - } - - #[test] - fn external_sources_respect_gitignore_files_inside_of_them() { - // Listing an ignored directory explicitly bypasses the rules that made it ignored: - // `.gitignore` files above the base, and the base's own `.gitignore` — inside ignored - // trees that is typically the self-ignoring `*` file that generators drop into the - // directory. `.gitignore` files *deeper inside* the external tree were put there - // deliberately by whatever generates those files, so they still apply, just like they - // do for auto and pattern sources. - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[ - (".gitignore", "node_modules"), - // The self-ignoring device at the base: bypassed - ("node_modules/.generated/.gitignore", "*"), - // A deliberate ignore deeper inside: applies - ( - "node_modules/.generated/ui/.gitignore", - "ignored.tsx\ncache/", - ), - ( - "node_modules/.generated/ui/button.tsx", - "content-['button.tsx']", - ), - ( - "node_modules/.generated/ui/input.tsx", - "content-['input.tsx']", - ), - ( - "node_modules/.generated/ui/ignored.tsx", - "content-['ignored.tsx']", - ), - ( - "node_modules/.generated/ui/cache/stale.tsx", - "content-['cache/stale.tsx']", - ), - ], - vec!["@source './node_modules/.generated'"], - ); - - assert_snapshot!(tree, @" - . (git) - ├── ✗ .gitignore - │ node_modules - └── ✓ node_modules - └── ✓ .generated - ├── ✗ .gitignore - │ * - └── ✓ ui - ├── ✗ .gitignore - │ ignored.tsx - │ cache/ - ├── ✓ button.tsx - ├── ✗ cache - │ └── ✗ stale.tsx - ├── ✗ ignored.tsx - └── ✓ input.tsx - "); - - assert_eq!( - candidates, - vec!["content-['button.tsx']", "content-['input.tsx']"] - ); - assert_eq!( - files, - vec![ - "node_modules/.generated/ui/button.tsx", - "node_modules/.generated/ui/input.tsx" - ] - ); - } - - #[test] - fn double_star_sources_are_auto_sources() { - // `@source "./src/**"` means "everything underneath src", just like `./src/**/*` and - // `./src`: auto source detection applies, so `.gitignore` files and the default rules - // are respected instead of the glob rescuing every git ignored file. - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[ - (".gitignore", "src/secret.html"), - ("src/index.html", "content-['src/index.html']"), - ("src/secret.html", "content-['src/secret.html']"), - ("src/logo.png", "content-['src/logo.png']"), - ], - vec!["@source './src/**'"], - ); - - assert_snapshot!(tree, @" - . (git) - ├── ✗ .gitignore - │ src/secret.html - └── ✓ src - ├── ✓ index.html - ├── ✗ logo.png - └── ✗ secret.html - "); - - assert_eq!(candidates, vec!["content-['src/index.html']"]); - assert_eq!(files, vec!["src/index.html"]); - } - - #[test] - fn gitignore_whitelists_do_not_reinclude_default_ignored_content() { - // A `.gitignore` whitelist re-includes files for git, but the default auto source - // detection rules are independent of git: `node_modules`, binary files, etc. stay - // ignored even when a `.gitignore` explicitly whitelists them. - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[ - (".gitignore", "!node_modules/\n!*.png"), - ("index.html", "content-['index.html']"), - ("logo.png", "content-['logo.png']"), - ( - "node_modules/lib/index.html", - "content-['node_modules/lib/index.html']", - ), - ], - vec!["@source '**/*'"], - ); - - assert_snapshot!(tree, @" - . (git) - ├── ✗ .gitignore - │ !node_modules/ - │ !*.png - ├── ✓ index.html - ├── ✗ logo.png - └── ✗ node_modules - └── ✗ lib - └── ✗ index.html - "); - - assert_eq!(candidates, vec!["content-['index.html']"]); - assert_eq!(files, vec!["index.html"]); - } - - #[test] - fn dot_ignore_files_are_respected() { - // `.ignore` files (the gitignore-style files used by ripgrep and friends) work like - // `.gitignore` files and rank above them within the same directory. - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[ - (".gitignore", "by-git.html\nby-both.html"), - (".ignore", "by-ignore.html\n!by-both.html"), - ("keep.html", "content-['keep.html']"), - ("by-git.html", "content-['by-git.html']"), - ("by-ignore.html", "content-['by-ignore.html']"), - // Ignored by the `.gitignore` file, re-included by the `.ignore` file - ("by-both.html", "content-['by-both.html']"), - ], - vec!["@source '**/*'"], - ); - - assert_snapshot!(tree, @" - . (git) - ├── ✗ .gitignore - │ by-git.html - │ by-both.html - ├── ✗ .ignore - ├── ✓ by-both.html - ├── ✗ by-git.html - ├── ✗ by-ignore.html - └── ✓ keep.html - "); - - assert_eq!( - candidates, - vec!["content-['by-both.html']", "content-['keep.html']"] - ); - assert_eq!(files, vec!["by-both.html", "keep.html"]); - } - - #[test] - fn git_info_exclude_is_respected() { - // `.git/info/exclude` works like the repository root's `.gitignore` file, ranking - // below it. - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[ - (".git/info/exclude", "excluded.html\nreincluded.html"), - (".gitignore", "!reincluded.html"), - ("keep.html", "content-['keep.html']"), - ("excluded.html", "content-['excluded.html']"), - // Excluded by `.git/info/exclude`, re-included by the `.gitignore` file - ("reincluded.html", "content-['reincluded.html']"), - ], - vec!["@source '**/*'"], - ); - - assert_snapshot!(tree, @" - . (git) - ├── ✗ .gitignore - │ !reincluded.html - ├── ✗ excluded.html - ├── ✓ keep.html - └── ✓ reincluded.html - "); - - assert_eq!( - candidates, - vec!["content-['keep.html']", "content-['reincluded.html']"] - ); - assert_eq!(files, vec!["keep.html", "reincluded.html"]); - } - - #[test] - fn not_sources_apply_inside_external_sources() { - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[ - (".gitignore", "node_modules"), - ( - "node_modules/lib/src/index.html", - "content-['node_modules/lib/src/index.html']", - ), - ( - "node_modules/lib/dist/index.html", - "content-['node_modules/lib/dist/index.html']", - ), - ], - vec![ - "@source './node_modules/lib'", - "@source not './node_modules/lib/dist'", - ], - ); - - assert_snapshot!(tree, @" - . (git) - ├── ✗ .gitignore - │ node_modules - └── ✓ node_modules - └── ✓ lib - ├── ✗ dist - │ └── ✗ index.html - └── ✓ src - └── ✓ index.html - "); - - assert_eq!( - candidates, - vec!["content-['node_modules/lib/src/index.html']"] - ); - assert_eq!(files, vec!["node_modules/lib/src/index.html"]); - } - - #[test] - fn wildcards_in_glob_sources_respect_gitignores_deeper_in_the_subtree() { - // Like `wildcards_in_glob_sources_do_not_descend_into_gitignored_directories`, but the - // `.gitignore` sits in a directory between the glob's base and the ignored directory, - // not at the base itself. - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[ - ("blog/2024/.gitignore", "drafts/"), - ("blog/2024/keep.html", "content-['blog/2024/keep.html']"), - ( - "blog/2024/drafts/secret.html", - "content-['blog/2024/drafts/secret.html']", - ), - ], - vec!["@source './blog/**/*.html'"], - ); - - assert_snapshot!(tree, @" - . (git) - └── ✓ blog - └── ✓ 2024 - ├── ✗ .gitignore - │ drafts/ - ├── ✗ drafts - │ └── ✗ secret.html - └── ✓ keep.html - "); - - assert_eq!(candidates, vec!["content-['blog/2024/keep.html']"]); - assert_eq!(files, vec!["blog/2024/keep.html"]); - } - - #[test] - fn later_pattern_sources_override_earlier_not_sources() { - // Order matters: a later positive `@source` wins from an earlier `@source not`, and - // vice versa. - let ScanResult { - candidates, tree, .. - } = scan_with_globs( - &[ - ("src/keep.html", "content-['src/keep.html']"), - ("src/other.html", "content-['src/other.html']"), - ], - vec![ - "@source '**/*'", - "@source not './src'", - "@source './src/keep.html'", - ], - ); - - assert_snapshot!(tree, @" - . (git) - └── ✓ src - ├── ✓ keep.html - └── ✗ other.html - "); - - assert_eq!(candidates, vec!["content-['src/keep.html']"]); - - let ScanResult { - candidates, tree, .. - } = scan_with_globs( - &[ - ("src/keep.html", "content-['src/keep.html']"), - ("src/other.html", "content-['src/other.html']"), - ], - vec![ - "@source '**/*'", - "@source './src/keep.html'", - "@source not './src'", - ], - ); - - assert_snapshot!(tree, @" - . (git) - └── ✗ src - ├── ✗ keep.html - └── ✗ other.html - "); - - assert!(candidates.is_empty()); - } - - #[test] - fn later_directory_sources_override_earlier_not_sources() { - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[("src/index.html", "content-['src/index.html']")], - vec!["@source '**/*'", "@source not './src'", "@source './src'"], - ); - - assert_snapshot!(tree, @" - . (git) - └── ✓ src - └── ✓ index.html - "); - - assert_eq!(candidates, vec!["content-['src/index.html']"]); - assert_eq!(files, vec!["src/index.html"]); - } - - #[test] - fn wildcard_not_sources_exclude_matching_directories() { - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[ - ("src/bar/index.html", "content-['src/bar/index.html']"), - ("src/foo/index.html", "content-['src/foo/index.html']"), - ], - vec!["@source './src/**/*.html'", "@source not './src/ba*'"], - ); - - assert_snapshot!(tree, @" - . (git) - └── ✓ src - ├── ✗ bar - │ └── ✗ index.html - └── ✓ foo - └── ✓ index.html - "); - - assert_eq!(candidates, vec!["content-['src/foo/index.html']"]); - assert_eq!(files, vec!["src/foo/index.html"]); - } - - #[test] - fn explicit_file_sources_include_default_ignored_files_without_extensions() { - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[ - (".env", "content-['.env']"), - ("index.html", "content-['index.html']"), - ], - vec!["@source '.env'"], - ); - - assert_snapshot!(tree, @" - . (git) - ├── ✓ .env - └── ✗ index.html - "); - - assert_eq!(candidates, vec!["content-['.env']"]); - assert_eq!(files, vec![".env"]); - } - - #[test] - fn unreachable_whitelists_do_not_reinclude_explicit_source_directories() { - let ScanResult { - candidates, - files, - tree, - .. - } = scan_with_globs( - &[ - (".gitignore", "parent/"), - ("parent/.gitignore", "!child/"), - ("parent/child/.gitignore", "index.html"), - ( - "parent/child/index.html", - "content-['parent/child/index.html']", - ), - ], - vec!["@source './parent/child'"], - ); - - assert_snapshot!(tree, @" - . (git) - ├── ✗ .gitignore - │ parent/ - └── ✓ parent - ├── ✗ .gitignore - │ !child/ - └── ✓ child - ├── ✗ .gitignore - │ index.html - └── ✓ index.html - "); - - assert_eq!(candidates, vec!["content-['parent/child/index.html']"]); - assert_eq!(files, vec!["parent/child/index.html"]); - } - #[test] fn test_extract_used_css_variables_from_css() { let dir = tempdir().unwrap().into_path(); @@ -4940,6 +4063,11 @@ mod scanner { ("src/defined-at-start.css", "--color-defined-at-start: red;"), ], ); + fs::write( + dir.join("src/invalid.css"), + b".button { color: var(--color-\x80); }", + ) + .unwrap(); let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( dir.clone(), diff --git a/integrations/vite/index.test.ts b/integrations/vite/index.test.ts index 9045b7926..c346f8e37 100644 --- a/integrations/vite/index.test.ts +++ b/integrations/vite/index.test.ts @@ -580,11 +580,11 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { }, ) - describe.sequential.each([['^6'], ['7.0.8'], ['7.1.12'], ['7.3.1'], ['8.0.0']])( + describe.each([['^6'], ['7.0.8'], ['7.1.12'], ['7.3.1'], ['8.0.0']])( 'Using Vite %s', (version) => { test( - 'external source file changes trigger a full reload', + 'external source file changes update the CSS', { fs: { 'package.json': json`{}`, @@ -661,26 +661,33 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { expect(styles).toContain(candidate`content-['project-b/src/index.php']`) }) - // Flush all messages so that we can be sure the next messages are from - // the file changes we're about to make + // Flush all messages so that we can be sure the next messages are + // from the file changes we're about to make process.flush() - // Changing an external .php file should trigger a full reload + // Changing an external .php file hot-updates the generated CSS { await fs.write( 'project-b/src/index.php', txt`
`, ) - // Ensure the page reloaded + // On Vite < 7.1, Vite itself hard-invalidates watched files that + // aren't part of the module graph and reloads the page. + // + // On newer versions nothing reloads the page: the CSS hot-updates + // through the regular pipeline because the changed file is a + // watch dependency of the CSS root. + // + // Reloading the page for external template changes is the + // responsibility of the backend integration (e.g. `laravel-vite-plugin`'s `refresh` option, or `vite-plugin-full-reload`). + // + // https://github.com/tailwindlabs/tailwindcss/issues/20411 if (version === '^6' || version === '7.0.8') { await process.onStdout((m) => m.includes('page reload') && m.includes('index.php')) } else { - await process.onStderr( - (m) => m.includes('vite:hmr (client)') && m.includes('index.php'), - ) + await process.onStdout((m) => m.includes('hmr update') && m.includes('index.css')) } - await process.onStderr((m) => m.includes('vite:hmr (ssr)') && m.includes('index.php')) // Ensure the styles were regenerated with the new content let styles = await fetchStyles(url, '/index.html') @@ -853,7 +860,6 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { let styles = await fetchStyles(url, '/index.html') expect(styles).toContain(candidate`content-['updated:src/lazy.tsx']`) }) - expect(await fs.read('project-a/hmr.log')).not.toContain('full-reload') } // The same holds for a custom file type as long as some file of the @@ -868,7 +874,6 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { let styles = await fetchStyles(url, '/index.html') expect(styles).toContain(candidate`content-['updated:src/comp-b.custom']`) }) - expect(await fs.read('project-a/hmr.log')).not.toContain('full-reload') } // Changing a scanned stylesheet that is not part of the module graph @@ -893,23 +898,26 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { let log = await fs.read('project-a/hmr.log') expect(log.split('"type":"update"').length).toBeGreaterThan(updates) }) - expect(await fs.read('project-a/hmr.log')).not.toContain('full-reload') } - // Changing an external file (e.g. a PHP template) should still trigger - // a full reload. This must work even though `snippet.php` is part of - // the module graph via the `?raw` import: a query import only pulls - // the file's contents into the graph (and creates an untransformed - // module node for the underlying file), it is not evidence that Vite - // processes `.php` files as modules. + // Changing an external file (e.g. a PHP template) hot-updates the + // generated CSS but does not trigger a full reload either. Reloading the + // page for external template changes is the responsibility of the backend + // integration (e.g. `laravel-vite-plugin`'s `refresh` option, or + // `vite-plugin-full-reload`). + // + // https://github.com/tailwindlabs/tailwindcss/issues/20411 { + let updates = (await fs.read('project-a/hmr.log')).split('"type":"update"').length + await fs.write( 'project-b/src/index.php', html`
`, ) await retryAssertion(async () => { - expect(await fs.read('project-a/hmr.log')).toContain('full-reload') + let log = await fs.read('project-a/hmr.log') + expect(log.split('"type":"update"').length).toBeGreaterThan(updates) }) let styles = await fetchStyles(url, '/index.html') diff --git a/integrations/vite/preact.test.ts b/integrations/vite/preact.test.ts new file mode 100644 index 000000000..8e4deec2b --- /dev/null +++ b/integrations/vite/preact.test.ts @@ -0,0 +1,165 @@ +import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts, txt } from '../utils' + +test( + 'dev mode', + { + fs: { + 'package.json': json` + { + "type": "module", + "dependencies": { + "preact": "^10" + }, + "devDependencies": { + "@preact/preset-vite": "^2", + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^", + "vite": "^8" + } + } + `, + 'vite.config.ts': ts` + import fs from 'node:fs' + import path from 'node:path' + import preact from '@preact/preset-vite' + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' + + export default defineConfig({ + plugins: [ + tailwindcss(), + preact(), + { + // Log all HMR payloads to a file so the test can assert on them + name: 'hmr-wiretap', + configureServer(server) { + let logFile = path.resolve('hmr.log') + fs.writeFileSync(logFile, '') + for (let environment of Object.values(server.environments)) { + let send = environment.hot.send.bind(environment.hot) + environment.hot.send = (payload) => { + fs.appendFileSync(logFile, JSON.stringify(payload) + '\\n') + return send(payload) + } + } + }, + }, + ], + }) + `, + 'index.html': html` + + + + + +
+ + + + `, + 'src/main.tsx': ts` + import { render } from 'preact' + import { App } from './app' + + render(, document.getElementById('app')!) + `, + 'src/app.tsx': ts` + import { useState } from 'preact/hooks' + + export function App() { + const [count, setCount] = useState(0) + return ( + + ) + } + `, + 'src/index.css': css`@import 'tailwindcss';`, + }, + }, + async ({ fs, spawn, expect }) => { + let process = await spawn('pnpm vite dev') + await process.onStdout((m) => m.includes('ready in')) + + let url = '' + await process.onStdout((m) => { + let match = /Local:\s*(http.*)\//.exec(m) + if (match) url = match[1] + return Boolean(url) + }) + + await retryAssertion(async () => { + let styles = await fetchStyles(url) + expect(styles).toContain(candidate`underline`) + }) + + // Load the component modules, like a browser visiting the page would + await fetch(`${url}/src/main.tsx`) + await fetch(`${url}/src/app.tsx`) + + // Editing a component keeps HMR intact: new classes are delivered through + // a regular update, not a full page reload (which would lose all state) + { + await fs.write( + 'src/app.tsx', + ts` + import { useState } from 'preact/hooks' + + export function App() { + const [count, setCount] = useState(0) + return ( + + ) + } + `, + ) + + await retryAssertion(async () => { + let styles = await fetchStyles(url) + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + }) + expect(await fs.read('hmr.log')).toContain('"type":"update"') + expect(await fs.read('hmr.log')).not.toContain('full-reload') + } + + // Changing a scanned file that is not part of the module graph (e.g. + // `package.json`, which package managers and other tooling write to while + // the dev server is running) should not trigger a full reload either — + // that would destroy client state. New candidates should still be picked + // up because the file is a watch dependency of the CSS root, so the CSS + // hot-updates through Vite's regular pipeline. + // + // https://github.com/tailwindlabs/tailwindcss/issues/20411 + { + await fs.write( + 'package.json', + txt` + { + "type": "module", + "description": "content-['package.json']", + "dependencies": { + "preact": "^10" + }, + "devDependencies": { + "@preact/preset-vite": "^2", + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^", + "vite": "^8" + } + } + `, + ) + + await retryAssertion(async () => { + let styles = await fetchStyles(url) + expect(styles).toContain(candidate`content-['package.json']`) + }) + expect(await fs.read('hmr.log')).not.toContain('full-reload') + } + }, +) diff --git a/packages/@tailwindcss-cli/src/commands/build/index.ts b/packages/@tailwindcss-cli/src/commands/build/index.ts index c85eda28c..ffef2ae8b 100644 --- a/packages/@tailwindcss-cli/src/commands/build/index.ts +++ b/packages/@tailwindcss-cli/src/commands/build/index.ts @@ -376,7 +376,7 @@ export async function handle(args: Result>) { // Create a new compiler, given the new `input` ;[compiler, scanner] = await createCompiler(input, I) - // Succesfully created a new compiler, so the `fullRebuildPaths` + // Successfully created a new compiler, so the `fullRebuildPaths` // will be updated. If other errors occur, we should be able to // restore the paths unconditionally. backupRebuildPaths = fullRebuildPaths.slice() diff --git a/packages/@tailwindcss-node/src/optimize.ts b/packages/@tailwindcss-node/src/optimize.ts index 6e60f8b87..2f0d39589 100644 --- a/packages/@tailwindcss-node/src/optimize.ts +++ b/packages/@tailwindcss-node/src/optimize.ts @@ -61,10 +61,14 @@ export function optimize( map = result.map?.toString() result.warnings = result.warnings.filter((warning) => { - // Ignore warnings about unknown pseudo-classes as they are likely caused - // by the use of `:deep()`, `:slotted()`, and `:global()` which are not - // standard CSS but are commonly used in frameworks like Vue. - if (/'(deep|slotted|global)' is not recognized as a valid pseudo-/.test(warning.message)) { + // Ignore warnings about unknown pseudo-classes that are used in frameworks + // such as Vue or Angular and are handled by their own compilers, before + // reaching the browser. + if ( + /'(deep|slotted|global|ng-deep|host-context)' is not recognized as a valid pseudo-/.test( + warning.message, + ) + ) { return false } diff --git a/packages/@tailwindcss-vite/src/index.test.ts b/packages/@tailwindcss-vite/src/index.test.ts deleted file mode 100644 index ee050e833..000000000 --- a/packages/@tailwindcss-vite/src/index.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { expect, test } from 'vitest' -import tailwindcss from './index' - -// Vite's experimental `bundledDev` mode calls `hotUpdate` without a `server`, -// so the handler must not dereference it. -// -// - https://github.com/vitejs/vite/discussions/22746 -// - https://github.com/tailwindlabs/tailwindcss/issues/20378 -// - https://vite.dev/blog/announcing-vite8-1#experimental-bundled-dev-mode -test('hotUpdate does not crash when Vite omits the server (bundledDev)', () => { - let plugin = tailwindcss().find((plugin) => plugin.name === '@tailwindcss/vite:generate:serve')! - - let hotUpdate = plugin.hotUpdate as unknown as (options: { - file: string - modules: unknown[] - timestamp: number - server: undefined - }) => unknown - - expect(() => - hotUpdate.call(plugin, { - file: '/app/template.html', - modules: [{ type: 'asset', id: undefined }], - timestamp: Date.now(), - server: undefined, - }), - ).not.toThrow() -}) diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index 58c69e1eb..8fc660eaa 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -9,23 +9,15 @@ import { } from '@tailwindcss/node' import { clearRequireCache } from '@tailwindcss/node/require-cache' import { Scanner } from '@tailwindcss/oxide' -import { realpathSync } from 'node:fs' import fs from 'node:fs/promises' import path from 'node:path' -import type { - Environment, - InternalResolveOptions, - Plugin, - ResolvedConfig, - ViteDevServer, -} from 'vite' +import type { Environment, InternalResolveOptions, Plugin, ResolvedConfig } from 'vite' import * as vite from 'vite' const DEBUG = env.DEBUG const SPECIAL_QUERY_RE = /[?&](?:worker|sharedworker|raw|url)\b/ const COMMON_JS_PROXY_RE = /\?commonjs-proxy/ const INLINE_STYLE_ID_RE = /[?&]index=\d+\.css$/ -const JS_EXTENSIONS_RE = /^\.[cm]?[jt]sx?$/ export type PluginOptions = { /** @@ -73,17 +65,9 @@ function createCustomResolver( } export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { - let servers: ViteDevServer[] = [] let config: ResolvedConfig | null = null let rootsByEnv = new DefaultMap>((env: string) => new Map()) - // File extensions that Vite (or one of its plugins) has been seen to process - // as a module. Plugins don't get added or removed while the dev server is - // running (changing the Vite config restarts the server), so once we've seen - // evidence for a file type we don't need to scan the module graphs for it - // again. - let viteProcessedExtensions = new Set() - let isSSR = false let shouldOptimize = true let minify = true @@ -196,10 +180,6 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { name: '@tailwindcss/vite:scan', enforce: 'pre', - configureServer(server) { - servers.push(server) - }, - async configResolved(_config) { config = _config isSSR = config.build.ssr !== false && config.build.ssr !== undefined @@ -256,151 +236,6 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { return result }, }, - - hotUpdate({ file, modules, timestamp, server }) { - // Vite's experimental `bundledDev` mode invokes `hotUpdate` without a - // `server`, so there are no sibling environments to inspect and no - // server-level `hot`/`ws` channel to reload through. Bail out early - // rather than dereferencing `undefined`. - // - // https://github.com/tailwindlabs/tailwindcss/issues/20378 - if (!server) return - - // Ensure full-reloads are triggered for files that are being watched by - // Tailwind but aren't part of the module graph (like PHP or HTML - // files). If we don't do this, then changes to those files won't - // trigger a reload at all since Vite doesn't know about them. - { - // It's a little bit confusing, because due to the `addWatchFile` - // calls, it _is_ part of the module graph but nothing is really - // handling those files. These modules typically have an id of - // undefined and/or have a type of 'asset'. - // - // If we call `addWatchFile` on a file that is part of the actual - // module graph, then we will see a module for it with a type of `js` - // and a type of `asset`. We are only interested if _all_ of them are - // missing an id and/or have a type of 'asset', which is a strong - // signal that the changed file is not being handled by Vite or any of - // the plugins. - // - // Note: in Vite v7.0.6 the modules here will have a type of `js`, not - // 'asset'. But it will also have a `HARD_INVALIDATED` state and will - // do a full page reload already. - // - // Empty modules can be skipped since it means it's not - // `addWatchFile`d and thus irrelevant to Tailwind. - let isExternalFile = - modules.length > 0 && - modules.every((mod) => mod.type === 'asset' || mod.id === undefined) - if (!isExternalFile) return - - // Skip files that Vite (or one of its plugins) processes as a - // module — in this environment (e.g. a lazily-loaded route that - // hasn't been visited yet) or in another one (e.g. an SSR-only - // module). Such a file can only affect the page through Vite's own - // pipeline, so a full reload would only destroy client state. Any - // changes to the generated CSS still go through the regular - // `css-update` flow because the file is registered via - // `addWatchFile`. - // - // If the file exists as a real module in another environment, then - // that environment is responsible for it. E.g. an SSR framework - // has its own server side hmr/reload mechanism when handling - // server only modules. See https://v6.vite.dev/guide/migration.html - // > Updates to an SSR-only module no longer triggers a full page reload in the client. ... - for (let environment of Object.values(server.environments)) { - if (environment.name === this.environment.name) continue - - let modules = environment.moduleGraph.getModulesByFile(file) - if (modules) { - for (let mod of modules) { - if (mod.type !== 'asset') { - return - } - } - } - } - - // Otherwise the file is not loaded as a module anywhere, so - // determine whether its file _type_ would be processed by Vite - // when requested by the browser (in which case the file just isn't - // loaded yet, e.g. a lazily-loaded route that hasn't been visited). - // Vite has no API to answer this without actually running the - // plugin pipeline, so instead: - // - // Files Vite handles natively (the JS/TS and CSS families) are always - // processed by Vite. This includes stylesheets that never show up as - // their own module because a framework plugin compiles them into a - // component (e.g. Angular), in which case that plugin owns their HMR. - let extension = path.extname(file) - if (JS_EXTENSIONS_RE.test(extension) || vite.isCSSRequest(file)) return - - // For any other file type (e.g. `.vue`, `.svelte`, or `.md` with an - // SSG plugin), if a file with the same extension exists as a real - // module in any environment's module graph, then a plugin evidently - // handles this file type and the changed file just isn't loaded - // (yet). - if (extension !== '') { - if (viteProcessedExtensions.has(extension)) return - - for (let environment of Object.values(server.environments)) { - for (let mod of environment.moduleGraph.idToModuleMap.values()) { - if (!mod.file?.endsWith(extension)) continue - if (mod.type === 'asset') continue - - // Only count modules that the plugin pipeline actually - // transformed. Vite also creates untransformed placeholder - // nodes (e.g. for the file underlying a `?raw` import) that - // are not evidence that a plugin handles this file type. - if (mod.transformResult == null) continue - - // Similarly, ignore query imports (e.g. `./template.html?raw`, - // or the `?html-proxy` modules Vite creates for inline - // scripts): they pull a file's _contents_ into the graph - // without a plugin processing the file type. A scanned - // `.html` template must still trigger a full reload even if - // some other `.html` file is imported with `?raw`. - if (!mod.id || mod.id.includes('?')) continue - - viteProcessedExtensions.add(extension) - return - } - } - } - - for (let env of new Set([this.environment.name, 'client'])) { - let roots = rootsByEnv.get(env) - if (roots.size === 0) continue - - // If the file is not being watched by any of the roots, then we can - // skip the reload since it's not relevant to Tailwind CSS. - if (!isScannedFile(file, modules, roots)) { - continue - } - - // https://vite.dev/changes/hotupdate-hook#migration-guide - let invalidatedModules = new Set() - for (let mod of modules) { - this.environment.moduleGraph.invalidateModule( - mod, - invalidatedModules, - timestamp, - true, - ) - } - - if (env === this.environment.name) { - this.environment.hot.send({ type: 'full-reload' }) - } else if (server.hot.send) { - server.hot.send({ type: 'full-reload' }) - } else if (server.ws.send) { - server.ws.send({ type: 'full-reload' }) - } - - return [] - } - } - }, }, { @@ -521,10 +356,6 @@ class Root { private customJsResolver: (id: string, base: string) => Promise, ) {} - get scannedFiles() { - return this.scanner?.files ?? [] - } - // Generate the CSS for the root file. This can return false if the file is // not considered a Tailwind root. When this happened, the root can be GCed. public async generate( @@ -710,54 +541,3 @@ class Root { return false } } - -function isScannedFile( - file: string, - modules: vite.EnvironmentModuleNode[], - roots: Map, -) { - let seen = new Set() - let q = [...modules] - let checks = { - file, - get realpath() { - try { - let realpath = realpathSync(file) - Object.defineProperty(checks, 'realpath', { value: realpath }) - return realpath - } catch { - return null - } - }, - } - - while (q.length > 0) { - let module = q.shift()! - if (seen.has(module)) continue - seen.add(module) - - if (module.id) { - let root = roots.get(module.id) - - if (root) { - // If the file is part of the scanned files for this root, then we know - // for sure that it's being watched by any of the Tailwind CSS roots. It - // doesn't matter which root it is since it's only used to know whether - // we should trigger a full reload or not. - if ( - root.scannedFiles.includes(checks.file) || - (checks.realpath && root.scannedFiles.includes(checks.realpath)) - ) { - return true - } - } - } - - // Keep walking up the tree until we find a root. - for (let importer of module.importers) { - q.push(importer) - } - } - - return false -} diff --git a/packages/tailwindcss/src/candidate.test.ts b/packages/tailwindcss/src/candidate.test.ts index d66bfcd66..b8f914e1b 100644 --- a/packages/tailwindcss/src/candidate.test.ts +++ b/packages/tailwindcss/src/candidate.test.ts @@ -516,6 +516,8 @@ it('should not parse functional utilities with multiple modifiers', () => { utilities.functional('bg', () => []) expect(run('bg-red-1/2/3', { utilities })).toMatchInlineSnapshot(`[]`) + expect(run('bg-red-500/50/', { utilities })).toMatchInlineSnapshot(`[]`) + expect(run('bg-red-500/50//foo', { utilities })).toMatchInlineSnapshot(`[]`) }) it('should parse a utility with an arbitrary value', () => { @@ -1416,6 +1418,19 @@ it('should parse a functional variant with a modifier', () => { `) }) +it('should not parse variants with multiple modifiers', () => { + let utilities = new Utilities() + utilities.static('flex', () => []) + + let variants = new Variants() + variants.static('hover', () => {}) + variants.compound('group', Compounds.StyleRules, () => {}) + + expect(run('group-hover/foo:flex', { utilities, variants })).toHaveLength(1) + expect(run('group-hover/foo/:flex', { utilities, variants })).toEqual([]) + expect(run('group-hover/foo//bar:flex', { utilities, variants })).toEqual([]) +}) + it('should parse a functional variant starting with @', () => { let utilities = new Utilities() utilities.static('flex', () => []) diff --git a/packages/tailwindcss/src/candidate.ts b/packages/tailwindcss/src/candidate.ts index b63588ce7..a389af365 100644 --- a/packages/tailwindcss/src/candidate.ts +++ b/packages/tailwindcss/src/candidate.ts @@ -372,6 +372,14 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter } } + // If there's more than one modifier, the utility is invalid. + // + // E.g.: + // + // - `bg-red-500/50/50` + let parts = segment(base, '/') + if (parts.length > 2) return + // Figure out the new base and the modifier segment if present. // // E.g.: @@ -381,14 +389,7 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter // ^^^^^^^^^^ -> Base without modifier // ^^ -> Modifier segment // ``` - let [baseWithoutModifier, modifierSegment = null, additionalModifier] = segment(base, '/') - - // If there's more than one modifier, the utility is invalid. - // - // E.g.: - // - // - `bg-red-500/50/50` - if (additionalModifier) return + let [baseWithoutModifier, modifierSegment = null] = parts let parsedModifier = modifierSegment === null ? null : parseModifier(modifierSegment) @@ -707,17 +708,18 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia // Static, functional and compound variants { - // group-hover/group-name - // ^^^^^^^^^^^ -> Variant without modifier - // ^^^^^^^^^^ -> Modifier - let [variantWithoutModifier, modifier = null, additionalModifier] = segment(variant, '/') - // If there's more than one modifier, the variant is invalid. // // E.g.: // // - `group-hover/foo/bar` - if (additionalModifier) return null + let parts = segment(variant, '/') + if (parts.length > 2) return null + + // group-hover/group-name + // ^^^^^^^^^^^ -> Variant without modifier + // ^^^^^^^^^^ -> Modifier + let [variantWithoutModifier, modifier = null] = parts let roots = findRoots(variantWithoutModifier, (root) => { return designSystem.variants.has(root) @@ -1096,7 +1098,7 @@ const printArbitraryValueCache = new DefaultMap((input) => { node.value = ',' } - // Wrap custom functions starting with `--`, in parentheses if preceeded by + // Wrap custom functions starting with `--`, in parentheses if preceded by // a symbol. E.g.: `calc(100%---spacing(2))` → `calc(100%-(--spacing(2)))` else if (node.kind === 'function' && node.value.startsWith('--')) { let idx = ctx.index diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index 550a9c9a2..567f0ad0d 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -1638,4 +1638,26 @@ describe('regressions', () => { 'lg:flex', ]) }) + + // https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1610 + test('does not merge utilities whose theme variables resolve to CSS-wide keywords', async () => { + let designSystem = await __unstable__loadDesignSystem( + css` + @tailwind utilities; + @theme { + --foreground: unset; + --default: unset; + } + @theme inline { + --color-foreground: var(--foreground); + --color-default-soft-hover: color-mix(in oklab, var(--default) 60%, transparent); + } + `, + { base: __dirname }, + ) + + expect( + designSystem.canonicalizeCandidates(['text-foreground/60', 'text-default-soft-hover']), + ).toEqual(['text-foreground/60', 'text-default-soft-hover']) + }) }) diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index ad2648e12..d1dfc09de 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -2563,6 +2563,25 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si return ast } +// Variables whose theme value is a CSS-wide keyword (e.g.: `unset`) are never +// inlined. These are typically registered as a placeholder to be re-assigned at +// runtime, so two variables that share such a value are not interchangeable. +// +// E.g.: +// +// ```css +// @theme { +// --foreground: unset; +// --background: unset; +// } +// ``` +// +// Inlining would make `text-(--foreground)` and `text-(--background)` produce +// the same signature `color: unset`, even though they are different at runtime. +// +// https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/Data_types#css-wide_keywords +const CSS_WIDE_KEYWORDS = ['initial', 'inherit', 'revert', 'revert-layer', 'revert-rule', 'unset'] + // Resolve theme values to their inlined value. // // E.g.: @@ -2578,8 +2597,8 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si // } // ``` // -// Which conveniently will be equivalent to: `text-red-500` when we inline -// the value. +// Which conveniently will be equivalent to: `text-red-500` when we inline the +// value. // // Without inlining: // ```css @@ -2595,13 +2614,13 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si // } // ``` // -// Recently we made sure that utilities like `text-red-500` also generate -// the fallback value for usage in `@reference` mode. +// Recently we made sure that utilities like `text-red-500` also generate the +// fallback value for usage in `@reference` mode. // -// The second assumption is that if you use `var(--key, fallback)` that -// happens to match a known variable _and_ its inlined value. Then we can -// replace it with the inlined variable. This allows us to handle custom -// `@theme` and `@theme inline` definitions. +// The second assumption is that if you use `var(--key, fallback)` that happens +// to match a known variable _and_ its inlined value. Then we can replace it +// with the inlined variable. This allows us to handle custom `@theme` and +// `@theme inline` definitions. function resolveVariablesInValue(value: string, designSystem: DesignSystem): string { let changed = false let valueAst = ValueParser.parse(value) @@ -2630,6 +2649,9 @@ function resolveVariablesInValue(value: string, designSystem: DesignSystem): str seen.add(variable) if (variableValue === undefined) return // Couldn't resolve the variable + // CSS-wide keywords are never inlined + if (CSS_WIDE_KEYWORDS.includes(variableValue.toLowerCase())) return + // Inject variable fallbacks when no fallback is present yet. // // A fallback could consist of multiple values. diff --git a/packages/tailwindcss/src/compat/plugin-api.test.ts b/packages/tailwindcss/src/compat/plugin-api.test.ts index dd6928b23..5fcacb5cd 100644 --- a/packages/tailwindcss/src/compat/plugin-api.test.ts +++ b/packages/tailwindcss/src/compat/plugin-api.test.ts @@ -1630,7 +1630,7 @@ describe('addVariant', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-hocus\\:flex:is(:is(:where(.group):hover, :where(.group):focus) *) { + :is(:is(:where(.group):hover, :where(.group):focus) .group-hocus\\:flex) { display: flex; } @@ -1744,7 +1744,7 @@ describe('addVariant', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-hocus\\:flex:is(:where(.group):hover *), .group-hocus\\:flex:is(:where(.group):focus *) { + :is(:where(.group):hover .group-hocus\\:flex), :is(:where(.group):focus .group-hocus\\:flex) { display: flex; } @@ -1784,7 +1784,7 @@ describe('addVariant', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-hocus\\:flex:is(:where(.group):hover *), .group-hocus\\:flex:is(:where(.group):focus *) { + :is(:where(.group):hover .group-hocus\\:flex), :is(:where(.group):focus .group-hocus\\:flex) { display: flex; } @@ -1827,12 +1827,12 @@ describe('addVariant', () => { " @layer utilities { @media (hover: hover) { - .group-hocus\\:flex:is(:where(.group):hover *) { + :is(:where(.group):hover .group-hocus\\:flex) { display: flex; } } - .group-hocus\\:flex:is(:where(.group):focus *) { + :is(:where(.group):focus .group-hocus\\:flex) { display: flex; } @@ -1964,7 +1964,7 @@ describe('addVariant', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-optional\\:flex:is(:where(.group):optional *), .group-optional\\/foo\\:flex:is(:where(.group\\/foo):optional *), .peer-optional\\:flex:is(:where(.peer):optional ~ *), .optional\\:flex:optional { + :is(:where(.group):optional .group-optional\\:flex), :is(:where(.group\\/foo):optional .group-optional\\/foo\\:flex), :is(:where(.peer):optional ~ .peer-optional\\:flex), .optional\\:flex:optional { display: flex; } } @@ -2903,7 +2903,7 @@ describe('matchVariant', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-optional-\\[test\\]\\:flex:is(:where(.group):optional:has(test) :where(.group) *), .group-optional-\\[test\\]\\/foo\\:flex:is(:where(.group\\/foo):optional:has(test) :where(.group\\/foo) *), .peer-optional-\\[test\\]\\:flex:is(:where(.peer):optional:has(test) :where(.peer) ~ *), .optional-\\[test\\]\\:flex:optional:has(test) .optional-\\[test\\]\\:flex { + :is(:where(.group):optional:has(test) :where(.group) .group-optional-\\[test\\]\\:flex), :is(:where(.group\\/foo):optional:has(test) :where(.group\\/foo) .group-optional-\\[test\\]\\/foo\\:flex), :is(:where(.peer):optional:has(test) :where(.peer) ~ .peer-optional-\\[test\\]\\:flex), .optional-\\[test\\]\\:flex:optional:has(test) .optional-\\[test\\]\\:flex { display: flex; } } diff --git a/packages/tailwindcss/src/css-parser.test.ts b/packages/tailwindcss/src/css-parser.test.ts index 8df19f96c..0dcbc0a41 100644 --- a/packages/tailwindcss/src/css-parser.test.ts +++ b/packages/tailwindcss/src/css-parser.test.ts @@ -31,6 +31,23 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { ).toEqual([]) }) + it('should end a comment at `*/` even when it is preceded by a `\\`', () => { + expect( + parse(css` + /* C:\temp\*/ + .foo { + color: red; + } + `), + ).toEqual([ + { + kind: 'rule', + selector: '.foo', + nodes: [{ kind: 'declaration', property: 'color', value: 'red', important: false }], + }, + ]) + }) + it('should parse a comment inside of a selector and ignore it', () => { expect( parse(css` @@ -448,6 +465,28 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { ]) }) + it('should end a comment in a custom property at `*/` even when it is preceded by a `\\`', () => { + expect( + parse(css` + --foo: /* C:\temp\*/ bar; + --bar: /* baz */ qux; + `), + ).toEqual([ + { + kind: 'declaration', + property: '--foo', + value: '/* C:\\temp\\*/ bar', + important: false, + }, + { + kind: 'declaration', + property: '--bar', + value: '/* baz */ qux', + important: false, + }, + ]) + }) + it('should parse empty custom properties', () => { expect( parse(css` diff --git a/packages/tailwindcss/src/css-parser.ts b/packages/tailwindcss/src/css-parser.ts index 3dd5e6ffe..0896881e6 100644 --- a/packages/tailwindcss/src/css-parser.ts +++ b/packages/tailwindcss/src/css-parser.ts @@ -128,19 +128,24 @@ export function parse(input: string, opts?: ParseOptions) { // ^^^^^^^^^^^^^ // } // ``` + // + // The escape character `\` in comments are ignored, this means that a + // the end of a comment preceded by `\` does _not_ mean that the end of the + // comment is escaped and therefore we have to keep parsing. + // + // This is a valid comment: + // ``` + // /*C:\*/ + // ``` + // See: https://www.w3.org/TR/css-syntax-3/#consume-comment else if (currentChar === SLASH && input.charCodeAt(i + 1) === ASTERISK) { let start = i for (let j = i + 2; j < input.length; j++) { peekChar = input.charCodeAt(j) - // Current character is a `\` therefore the next character is escaped. - if (peekChar === BACKSLASH) { - j += 1 - } - - // End of the comment - else if (peekChar === ASTERISK && input.charCodeAt(j + 1) === SLASH) { + // End of the comment. + if (peekChar === ASTERISK && input.charCodeAt(j + 1) === SLASH) { i = j + 1 break } @@ -224,13 +229,9 @@ export function parse(input: string, opts?: ParseOptions) { else if (peekChar === SLASH && input.charCodeAt(j + 1) === ASTERISK) { for (let k = j + 2; k < input.length; k++) { peekChar = input.charCodeAt(k) - // Current character is a `\` therefore the next character is escaped. - if (peekChar === BACKSLASH) { - k += 1 - } - // End of the comment - else if (peekChar === ASTERISK && input.charCodeAt(k + 1) === SLASH) { + // End of the comment. + if (peekChar === ASTERISK && input.charCodeAt(k + 1) === SLASH) { j = k + 1 break } diff --git a/packages/tailwindcss/src/design-system.ts b/packages/tailwindcss/src/design-system.ts index 71d506938..7786790f6 100644 --- a/packages/tailwindcss/src/design-system.ts +++ b/packages/tailwindcss/src/design-system.ts @@ -75,6 +75,8 @@ export function buildDesignSystem( let variants = createVariants(theme) let parsedVariants = new DefaultMap((variant) => parseVariant(variant, designSystem)) + let cachedVariantOrder: Map | null = null + let cachedVariantOrderSize = -1 let parsedCandidates = new DefaultMap((candidate) => Array.from(parseCandidate(candidate, designSystem)), ) @@ -188,6 +190,11 @@ export function buildDesignSystem( }, getVariantOrder() { + // parsedVariants only grows, so the cache should be reset only when the size changes + if (cachedVariantOrder !== null && cachedVariantOrderSize === parsedVariants.size) { + return cachedVariantOrder + } + let variants = Array.from(parsedVariants.values()) variants.sort((a, z) => this.variants.compare(a, z)) @@ -209,6 +216,9 @@ export function buildDesignSystem( prevVariant = variant } + cachedVariantOrder = order + cachedVariantOrderSize = parsedVariants.size + return order }, diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 5eebd1716..7f4a0eef3 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -1444,42 +1444,42 @@ describe('sorting', () => { ).toMatchInlineSnapshot(` " @media (hover: hover) { - .group-hover\\:flex:is(:where(.group):hover *) { + :is(:where(.group):hover .group-hover\\:flex) { display: flex; } } - .group-focus\\:flex:is(:where(.group):focus *) { + :is(:where(.group):focus .group-focus\\:flex) { display: flex; } @media (hover: hover) { - .peer-hover\\:flex:is(:where(.peer):hover ~ *) { + :is(:where(.peer):hover ~ .peer-hover\\:flex) { display: flex; } @media (hover: hover) { - .group-hover\\:peer-hover\\:flex:is(:where(.group):hover *):is(:where(.peer):hover ~ *), .peer-hover\\:group-hover\\:flex:is(:where(.peer):hover ~ *):is(:where(.group):hover *) { + :is(:where(.peer):hover ~ :is(:where(.group):hover .group-hover\\:peer-hover\\:flex)), :is(:where(.group):hover :is(:where(.peer):hover ~ .peer-hover\\:group-hover\\:flex)) { display: flex; } } - .group-focus\\:peer-hover\\:flex:is(:where(.group):focus *):is(:where(.peer):hover ~ *), .peer-hover\\:group-focus\\:flex:is(:where(.peer):hover ~ *):is(:where(.group):focus *) { + :is(:where(.peer):hover ~ :is(:where(.group):focus .group-focus\\:peer-hover\\:flex)), :is(:where(.group):focus :is(:where(.peer):hover ~ .peer-hover\\:group-focus\\:flex)) { display: flex; } } - .peer-focus\\:flex:is(:where(.peer):focus ~ *) { + :is(:where(.peer):focus ~ .peer-focus\\:flex) { display: flex; } @media (hover: hover) { - .group-hover\\:peer-focus\\:flex:is(:where(.group):hover *):is(:where(.peer):focus ~ *), .peer-focus\\:group-hover\\:flex:is(:where(.peer):focus ~ *):is(:where(.group):hover *) { + :is(:where(.peer):focus ~ :is(:where(.group):hover .group-hover\\:peer-focus\\:flex)), :is(:where(.group):hover :is(:where(.peer):focus ~ .peer-focus\\:group-hover\\:flex)) { display: flex; } } - .group-focus\\:peer-focus\\:flex:is(:where(.group):focus *):is(:where(.peer):focus ~ *), .peer-focus\\:group-focus\\:flex:is(:where(.peer):focus ~ *):is(:where(.group):focus *) { + :is(:where(.peer):focus ~ :is(:where(.group):focus .group-focus\\:peer-focus\\:flex)), :is(:where(.group):focus :is(:where(.peer):focus ~ .peer-focus\\:group-focus\\:flex)) { display: flex; } @@ -3601,7 +3601,7 @@ describe('plugins', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-hocus\\:flex:is(:is(:where(.group):hover, :where(.group):focus) *) { + :is(:is(:where(.group):hover, :where(.group):focus) .group-hocus\\:flex) { display: flex; } @@ -3636,7 +3636,7 @@ describe('plugins', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-hocus\\:flex:is(:where(.group):hover *), .group-hocus\\:flex:is(:where(.group):focus *) { + :is(:where(.group):hover .group-hocus\\:flex), :is(:where(.group):focus .group-hocus\\:flex) { display: flex; } @@ -3674,7 +3674,7 @@ describe('plugins', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-hocus\\:flex:is(:where(.group):hover *), .group-hocus\\:flex:is(:where(.group):focus *) { + :is(:where(.group):hover .group-hocus\\:flex), :is(:where(.group):focus .group-hocus\\:flex) { display: flex; } @@ -3715,12 +3715,12 @@ describe('plugins', () => { " @layer utilities { @media (hover: hover) { - .group-hocus\\:flex:is(:where(.group):hover *) { + :is(:where(.group):hover .group-hocus\\:flex) { display: flex; } } - .group-hocus\\:flex:is(:where(.group):focus *) { + :is(:where(.group):focus .group-hocus\\:flex) { display: flex; } @@ -4199,7 +4199,7 @@ describe('@custom-variant', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-hocus\\:flex:is(:is(:where(.group):hover, :where(.group):focus) *) { + :is(:is(:where(.group):hover, :where(.group):focus) .group-hocus\\:flex) { display: flex; } @@ -4294,7 +4294,7 @@ describe('@custom-variant', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-selected\\:underline:is(:where(.group)[data-selected] *), .selected\\:underline[data-selected] { + :is(:where(.group)[data-selected] .group-selected\\:underline), .selected\\:underline[data-selected] { text-decoration-line: underline; } } @@ -4322,7 +4322,7 @@ describe('@custom-variant', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-hocus\\:underline:is(:is(:where(.group):hover, :where(.group):focus) *), .hocus\\:underline:hover, .hocus\\:underline:focus { + :is(:is(:where(.group):hover, :where(.group):focus) .group-hocus\\:underline), .hocus\\:underline:hover, .hocus\\:underline:focus { text-decoration-line: underline; } } @@ -4353,7 +4353,7 @@ describe('@custom-variant', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-hocus\\:underline:is(:where(.group):hover *), .group-hocus\\:underline:is(:where(.group):focus *), .hocus\\:underline:hover, .hocus\\:underline:focus { + :is(:where(.group):hover .group-hocus\\:underline), :is(:where(.group):focus .group-hocus\\:underline), .hocus\\:underline:hover, .hocus\\:underline:focus { text-decoration-line: underline; } } @@ -4458,12 +4458,12 @@ describe('@custom-variant', () => { " @layer utilities { @media (hover: hover) { - .group-hocus\\:underline:is(:where(.group):hover *) { + :is(:where(.group):hover .group-hocus\\:underline) { text-decoration-line: underline; } } - .group-hocus\\:underline:is(:where(.group):focus *) { + :is(:where(.group):focus .group-hocus\\:underline) { text-decoration-line: underline; } @@ -4503,7 +4503,7 @@ describe('@custom-variant', () => { " @layer utilities { @media (hover: hover) { - .group-hocus\\:underline:is(:where(.group):hover *), .hocus\\:underline:hover { + :is(:where(.group):hover .group-hocus\\:underline), .hocus\\:underline:hover { text-decoration-line: underline; } } diff --git a/packages/tailwindcss/src/intellisense.test.ts b/packages/tailwindcss/src/intellisense.test.ts index a31ec5d74..63f9e7576 100644 --- a/packages/tailwindcss/src/intellisense.test.ts +++ b/packages/tailwindcss/src/intellisense.test.ts @@ -97,9 +97,9 @@ test('getVariants compound', async () => { ] expect(list).toEqual([ - ['@media (hover: hover) { &:is(:where(.group):hover *) }'], - ['@media (hover: hover) { &:is(:where(.group\\/sidebar):hover *) }'], - ['@media (hover: hover) { &:is(:where(.group):is(:where(.group):hover *) *) }'], + ['@media (hover: hover) { :is(:where(.group):hover &) }'], + ['@media (hover: hover) { :is(:where(.group\\/sidebar):hover &) }'], + ['@media (hover: hover) { :is(:is(:where(.group):hover :where(.group)) &) }'], [], [], ]) diff --git a/packages/tailwindcss/src/prefix.test.ts b/packages/tailwindcss/src/prefix.test.ts index 799cb22fe..ded304cad 100644 --- a/packages/tailwindcss/src/prefix.test.ts +++ b/packages/tailwindcss/src/prefix.test.ts @@ -38,7 +38,7 @@ test('utilities must be prefixed', async () => { } @media (hover: hover) { - .tw\\:group-hover\\:flex:is(:where(.tw\\:group):hover *), .tw\\:peer-hover\\:flex:is(:where(.tw\\:peer):hover ~ *) { + :is(:where(.tw\\:group):hover .tw\\:group-hover\\:flex), :is(:where(.tw\\:peer):hover ~ .tw\\:peer-hover\\:flex) { display: flex; } diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index c9d4a5ff0..66b051d57 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -12154,18 +12154,28 @@ test('rounded', async () => { " `) expect( - await run([ - '-rounded', - '-rounded-full', - '-rounded-none', - '-rounded-sm', - '-rounded-[4px]', - 'rounded/foo', - 'rounded-full/foo', - 'rounded-none/foo', - 'rounded-sm/foo', - 'rounded-[4px]/foo', - ]), + await run( + [ + '-rounded', + '-rounded-full', + '-rounded-none', + '-rounded-sm', + '-rounded-[4px]', + 'rounded/foo', + 'rounded-full/foo', + 'rounded-none/foo', + 'rounded-sm/foo', + 'rounded-[4px]/foo', + 'rounded-sm/[5]', + 'rounded-sm/(--foo)', + ], + css` + @theme { + --radius-sm: 0.125rem; + } + @tailwind utilities; + `, + ), ).toEqual('') }) @@ -22373,6 +22383,11 @@ test('stroke', async () => { // Width '-stroke-0', 'stroke--1', + 'stroke-0/foo', + 'stroke-1/50', + 'stroke-2/[0.5]', + 'stroke-2/(--my-opacity)', + 'stroke-[12px]/foo', ]), ).toEqual('') }) @@ -24398,82 +24413,90 @@ test('filter', async () => { " `) expect( - await run([ - '-filter', - '-filter-none', - '-filter-[var(--value)]', - '-blur-xl', - '-blur-[4px]', - 'brightness--50', - '-brightness-50', - '-brightness-[1.23]', - 'brightness-unknown', - 'contrast--50', - '-contrast-50', - '-contrast-[1.23]', - 'contrast-unknown', - '-grayscale', - '-grayscale-0', - 'grayscale--1', - '-grayscale-[var(--value)]', - 'grayscale-unknown', - 'hue-rotate--5', - 'hue-rotate-unknown', - '-invert', - 'invert--5', - '-invert-0', - '-invert-[var(--value)]', - 'invert-unknown', - '-drop-shadow-xl', - '-drop-shadow-[0_0_red]', + await run( + [ + '-filter', + '-filter-none', + '-filter-[var(--value)]', + '-blur-xl', + '-blur-[4px]', + 'brightness--50', + '-brightness-50', + '-brightness-[1.23]', + 'brightness-unknown', + 'contrast--50', + '-contrast-50', + '-contrast-[1.23]', + 'contrast-unknown', + '-grayscale', + '-grayscale-0', + 'grayscale--1', + '-grayscale-[var(--value)]', + 'grayscale-unknown', + 'hue-rotate--5', + 'hue-rotate-unknown', + '-invert', + 'invert--5', + '-invert-0', + '-invert-[var(--value)]', + 'invert-unknown', + '-drop-shadow-xl', + '-drop-shadow-[0_0_red]', - 'drop-shadow/foo', - '-drop-shadow/foo', - '-drop-shadow/25', - '-drop-shadow-red-500', - 'drop-shadow-red-500/foo', - '-drop-shadow-red-500/foo', - '-drop-shadow-red-500/50', + 'drop-shadow/foo', + '-drop-shadow/foo', + '-drop-shadow/25', + '-drop-shadow-red-500', + 'drop-shadow-red-500/foo', + '-drop-shadow-red-500/foo', + '-drop-shadow-red-500/50', - '-saturate-0', - 'saturate--5', - '-saturate-[1.75]', - '-saturate-[var(--value)]', - 'saturate-saturate', - '-sepia', - 'sepia--50', - '-sepia-0', - '-sepia-[50%]', - '-sepia-[var(--value)]', - 'sepia-unknown', - 'filter/foo', - 'filter-none/foo', - 'filter-[var(--value)]/foo', - 'blur-xl/foo', - 'blur-none/foo', - 'blur-[4px]/foo', - 'brightness-50/foo', - 'brightness-[1.23]/foo', - 'contrast-50/foo', - 'contrast-[1.23]/foo', - 'grayscale/foo', - 'grayscale-0/foo', - 'grayscale-[var(--value)]/foo', - 'hue-rotate-15/foo', - 'hue-rotate-[45deg]/foo', - 'invert/foo', - 'invert-0/foo', - 'invert-[var(--value)]/foo', - 'drop-shadow-xl/foo', - 'drop-shadow-[0_0_red]/foo', - 'saturate-0/foo', - 'saturate-[1.75]/foo', - 'saturate-[var(--value)]/foo', - 'sepia/foo', - 'sepia-0/foo', - 'sepia-[50%]/foo', - 'sepia-[var(--value)]/foo', - ]), + '-saturate-0', + 'saturate--5', + '-saturate-[1.75]', + '-saturate-[var(--value)]', + 'saturate-saturate', + '-sepia', + 'sepia--50', + '-sepia-0', + '-sepia-[50%]', + '-sepia-[var(--value)]', + 'sepia-unknown', + 'filter/foo', + 'filter-none/foo', + 'filter-[var(--value)]/foo', + 'blur-xl/foo', + 'blur-none/foo', + 'blur-[4px]/foo', + 'brightness-50/foo', + 'brightness-[1.23]/foo', + 'contrast-50/foo', + 'contrast-[1.23]/foo', + 'grayscale/foo', + 'grayscale-0/foo', + 'grayscale-[var(--value)]/foo', + 'hue-rotate-15/foo', + 'hue-rotate-[45deg]/foo', + 'invert/foo', + 'invert-0/foo', + 'invert-[var(--value)]/foo', + 'drop-shadow-xl/foo', + 'drop-shadow-[0_0_red]/foo', + 'saturate-0/foo', + 'saturate-[1.75]/foo', + 'saturate-[var(--value)]/foo', + 'sepia/foo', + 'sepia-0/foo', + 'sepia-[50%]/foo', + 'sepia-[var(--value)]/foo', + ], + css` + @theme { + --drop-shadow: 0 1px 1px rgb(0 0 0 / 0.05); + } + @tailwind utilities; + `, + ), ).toEqual('') expect( @@ -27243,25 +27266,36 @@ test('text-shadow', async () => { " `) expect( - await run([ - '-shadow-xl', - '-shadow-none', - '-shadow-red-500', - '-shadow-red-500/50', - '-shadow-red-500/[0.5]', - '-shadow-red-500/[50%]', - '-shadow-current', - '-shadow-current/50', - '-shadow-current/[0.5]', - '-shadow-current/[50%]', - '-shadow-inherit', - '-shadow-transparent', - '-shadow-[#0088cc]', - '-shadow-[#0088cc]/50', - '-shadow-[#0088cc]/[0.5]', - '-shadow-[#0088cc]/[50%]', - '-shadow-[var(--value)]', - ]), + await run( + [ + '-text-shadow-sm', + '-text-shadow-none', + '-text-shadow-red-500', + '-text-shadow-red-500/50', + '-text-shadow-red-500/[0.5]', + '-text-shadow-red-500/[50%]', + '-text-shadow-current', + '-text-shadow-current/50', + '-text-shadow-current/[0.5]', + '-text-shadow-current/[50%]', + '-text-shadow-inherit', + '-text-shadow-transparent', + '-text-shadow-[#0088cc]', + '-text-shadow-[#0088cc]/50', + '-text-shadow-[#0088cc]/[0.5]', + '-text-shadow-[#0088cc]/[50%]', + '-text-shadow-[var(--value)]', + 'text-shadow/foo', + 'text-shadow-sm/foo', + ], + css` + @theme { + --text-shadow: 0 1px 1px rgb(0 0 0 / 0.05); + --text-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.06); + } + @tailwind utilities; + `, + ), ).toEqual('') }) @@ -27725,25 +27759,38 @@ test('shadow', async () => { " `) expect( - await run([ - '-shadow-xl', - '-shadow-none', - '-shadow-red-500', - '-shadow-red-500/50', - '-shadow-red-500/[0.5]', - '-shadow-red-500/[50%]', - '-shadow-current', - '-shadow-current/50', - '-shadow-current/[0.5]', - '-shadow-current/[50%]', - '-shadow-inherit', - '-shadow-transparent', - '-shadow-[#0088cc]', - '-shadow-[#0088cc]/50', - '-shadow-[#0088cc]/[0.5]', - '-shadow-[#0088cc]/[50%]', - '-shadow-[var(--value)]', - ]), + await run( + [ + '-shadow-sm', + '-shadow-none', + '-shadow-red-500', + '-shadow-red-500/50', + '-shadow-red-500/[0.5]', + '-shadow-red-500/[50%]', + '-shadow-current', + '-shadow-current/50', + '-shadow-current/[0.5]', + '-shadow-current/[50%]', + '-shadow-inherit', + '-shadow-transparent', + '-shadow-[#0088cc]', + '-shadow-[#0088cc]/50', + '-shadow-[#0088cc]/[0.5]', + '-shadow-[#0088cc]/[50%]', + '-shadow-[var(--value)]', + 'shadow/foo', + 'shadow-sm/foo', + 'shadow-[10px_10px]/foo', + 'shadow-[#0088cc]/foo', + ], + css` + @theme { + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); + --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + } + @tailwind utilities; + `, + ), ).toEqual('') }) @@ -28216,25 +28263,37 @@ test('inset-shadow', async () => { " `) expect( - await run([ - '-inset-shadow-sm', - '-inset-shadow-none', - '-inset-shadow-red-500', - '-inset-shadow-red-500/50', - '-inset-shadow-red-500/[0.5]', - '-inset-shadow-red-500/[50%]', - '-inset-shadow-current', - '-inset-shadow-current/50', - '-inset-shadow-current/[0.5]', - '-inset-shadow-current/[50%]', - '-inset-shadow-inherit', - '-inset-shadow-transparent', - '-inset-shadow-[#0088cc]', - '-inset-shadow-[#0088cc]/50', - '-inset-shadow-[#0088cc]/[0.5]', - '-inset-shadow-[#0088cc]/[50%]', - '-inset-shadow-[var(--value)]', - ]), + await run( + [ + '-inset-shadow-sm', + '-inset-shadow-none', + '-inset-shadow-red-500', + '-inset-shadow-red-500/50', + '-inset-shadow-red-500/[0.5]', + '-inset-shadow-red-500/[50%]', + '-inset-shadow-current', + '-inset-shadow-current/50', + '-inset-shadow-current/[0.5]', + '-inset-shadow-current/[50%]', + '-inset-shadow-inherit', + '-inset-shadow-transparent', + '-inset-shadow-[#0088cc]', + '-inset-shadow-[#0088cc]/50', + '-inset-shadow-[#0088cc]/[0.5]', + '-inset-shadow-[#0088cc]/[50%]', + '-inset-shadow-[var(--value)]', + 'inset-shadow/foo', + 'inset-shadow-sm/foo', + 'inset-shadow-[10px_10px]/foo', + ], + css` + @theme { + --inset-shadow: inset 0 1px 3px 0 rgb(0 0 0 / 0.1); + --inset-shadow-sm: inset 0 1px 1px rgb(0 0 0 / 0.05); + } + @tailwind utilities; + `, + ), ).toEqual('') }) diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 6c4a20cbc..477559d7e 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -421,6 +421,13 @@ export function createUtilities(theme: Theme) { desc.themeKeys ?? [], ) + // If the theme value resolved without consuming the modifier, then + // the modifier would be silently ignored and `w-4` and `w-4/foo` + // would generate the same CSS. + // + // The modifier would be consumed if it's a fraction, e.g.: `w-1/2` + if (value !== null && candidate.modifier && !candidate.value.fraction) return + // Automatically handle things like `w-1/2` without requiring `1/2` to // exist as a theme value. if (value === null && desc.supportsFractions && candidate.value.fraction) { @@ -3888,6 +3895,10 @@ export function createUtilities(theme: Theme) { } { + // A modifier is only valid as the opacity of a stroke color. The value + // did not resolve to a color, so a modifier would be silently ignored. + if (candidate.modifier) return + let value = theme.resolve(candidate.value.value, ['--stroke-width']) if (value) { return [decl('stroke-width', value)] @@ -4628,6 +4639,7 @@ export function createUtilities(theme: Theme) { let value = theme.get(['--drop-shadow']) let resolved = theme.resolve(null, ['--drop-shadow']) if (value === null || resolved === null) return + if (candidate.modifier && !alpha) return return [ filterProperties(), @@ -5436,6 +5448,7 @@ export function createUtilities(theme: Theme) { if (!candidate.value) { let value = theme.get(['--text-shadow']) if (value === null) return + if (candidate.modifier && !alpha) return return [ textShadowProperties(), @@ -5463,6 +5476,8 @@ export function createUtilities(theme: Theme) { ] } default: { + if (candidate.modifier && !alpha) return + return [ textShadowProperties(), decl('--tw-text-shadow-alpha', alpha), @@ -5491,6 +5506,8 @@ export function createUtilities(theme: Theme) { { let value = theme.get([`--text-shadow-${candidate.value.value}`]) if (value) { + if (candidate.modifier && !alpha) return + return [ textShadowProperties(), decl('--tw-text-shadow-alpha', alpha), @@ -5582,6 +5599,7 @@ export function createUtilities(theme: Theme) { if (!candidate.value) { let value = theme.get(['--shadow']) if (value === null) return + if (candidate.modifier && !alpha) return return [ boxShadowProperties(), @@ -5611,6 +5629,8 @@ export function createUtilities(theme: Theme) { ] } default: { + if (candidate.modifier && !alpha) return + return [ boxShadowProperties(), decl('--tw-shadow-alpha', alpha), @@ -5644,6 +5664,8 @@ export function createUtilities(theme: Theme) { { let value = theme.get([`--shadow-${candidate.value.value}`]) if (value) { + if (candidate.modifier && !alpha) return + return [ boxShadowProperties(), decl('--tw-shadow-alpha', alpha), @@ -5708,6 +5730,7 @@ export function createUtilities(theme: Theme) { if (!candidate.value) { let value = theme.get(['--inset-shadow']) if (value === null) return + if (candidate.modifier && !alpha) return return [ boxShadowProperties(), @@ -5737,6 +5760,8 @@ export function createUtilities(theme: Theme) { ] } default: { + if (candidate.modifier && !alpha) return + return [ boxShadowProperties(), decl('--tw-inset-shadow-alpha', alpha), @@ -5772,6 +5797,8 @@ export function createUtilities(theme: Theme) { let value = theme.get([`--inset-shadow-${candidate.value.value}`]) if (value) { + if (candidate.modifier && !alpha) return + return [ boxShadowProperties(), decl('--tw-inset-shadow-alpha', alpha), diff --git a/packages/tailwindcss/src/utils/compare-breakpoints.ts b/packages/tailwindcss/src/utils/compare-breakpoints.ts index 08e97998e..4eb562764 100644 --- a/packages/tailwindcss/src/utils/compare-breakpoints.ts +++ b/packages/tailwindcss/src/utils/compare-breakpoints.ts @@ -25,7 +25,7 @@ export function compareBreakpoints(a: string, z: string, direction: 'asc' | 'des // Compare by bucket name (aBucket === zBucket ? 0 : aBucket < zBucket ? -1 : 1) || // If bucket names are the same, compare by value - (direction === 'asc' ? parseInt(a) - parseInt(z) : parseInt(z) - parseInt(a)) + (direction === 'asc' ? parseFloat(a) - parseFloat(z) : parseFloat(z) - parseFloat(a)) // If the groups are the same, and the contents are not numbers, the // `order` will result in `NaN`. In this case, we want to make sorting diff --git a/packages/tailwindcss/src/variants.test.ts b/packages/tailwindcss/src/variants.test.ts index 56b069b31..37f9ebbea 100644 --- a/packages/tailwindcss/src/variants.test.ts +++ b/packages/tailwindcss/src/variants.test.ts @@ -186,7 +186,7 @@ test('after', async () => { test('first', async () => { expect(await run(['first:flex', 'group-first:flex', 'peer-first:flex'])).toMatchInlineSnapshot(` " - .group-first\\:flex:is(:where(.group):first-child *), .peer-first\\:flex:is(:where(.peer):first-child ~ *), .first\\:flex:first-child { + :is(:where(.group):first-child .group-first\\:flex), :is(:where(.peer):first-child ~ .peer-first\\:flex), .first\\:flex:first-child { display: flex; } " @@ -197,7 +197,7 @@ test('first', async () => { test('last', async () => { expect(await run(['last:flex', 'group-last:flex', 'peer-last:flex'])).toMatchInlineSnapshot(` " - .group-last\\:flex:is(:where(.group):last-child *), .peer-last\\:flex:is(:where(.peer):last-child ~ *), .last\\:flex:last-child { + :is(:where(.group):last-child .group-last\\:flex), :is(:where(.peer):last-child ~ .peer-last\\:flex), .last\\:flex:last-child { display: flex; } " @@ -208,7 +208,7 @@ test('last', async () => { test('only', async () => { expect(await run(['only:flex', 'group-only:flex', 'peer-only:flex'])).toMatchInlineSnapshot(` " - .group-only\\:flex:is(:where(.group):only-child *), .peer-only\\:flex:is(:where(.peer):only-child ~ *), .only\\:flex:only-child { + :is(:where(.group):only-child .group-only\\:flex), :is(:where(.peer):only-child ~ .peer-only\\:flex), .only\\:flex:only-child { display: flex; } " @@ -219,7 +219,7 @@ test('only', async () => { test('odd', async () => { expect(await run(['odd:flex', 'group-odd:flex', 'peer-odd:flex'])).toMatchInlineSnapshot(` " - .group-odd\\:flex:is(:where(.group):nth-child(odd) *), .peer-odd\\:flex:is(:where(.peer):nth-child(odd) ~ *), .odd\\:flex:nth-child(odd) { + :is(:where(.group):nth-child(odd) .group-odd\\:flex), :is(:where(.peer):nth-child(odd) ~ .peer-odd\\:flex), .odd\\:flex:nth-child(odd) { display: flex; } " @@ -230,7 +230,7 @@ test('odd', async () => { test('even', async () => { expect(await run(['even:flex', 'group-even:flex', 'peer-even:flex'])).toMatchInlineSnapshot(` " - .group-even\\:flex:is(:where(.group):nth-child(2n) *), .peer-even\\:flex:is(:where(.peer):nth-child(2n) ~ *), .even\\:flex:nth-child(2n) { + :is(:where(.group):nth-child(2n) .group-even\\:flex), :is(:where(.peer):nth-child(2n) ~ .peer-even\\:flex), .even\\:flex:nth-child(2n) { display: flex; } " @@ -242,7 +242,7 @@ test('first-of-type', async () => { expect(await run(['first-of-type:flex', 'group-first-of-type:flex', 'peer-first-of-type:flex'])) .toMatchInlineSnapshot(` " - .group-first-of-type\\:flex:is(:where(.group):first-of-type *), .peer-first-of-type\\:flex:is(:where(.peer):first-of-type ~ *), .first-of-type\\:flex:first-of-type { + :is(:where(.group):first-of-type .group-first-of-type\\:flex), :is(:where(.peer):first-of-type ~ .peer-first-of-type\\:flex), .first-of-type\\:flex:first-of-type { display: flex; } " @@ -254,7 +254,7 @@ test('last-of-type', async () => { expect(await run(['last-of-type:flex', 'group-last-of-type:flex', 'peer-last-of-type:flex'])) .toMatchInlineSnapshot(` " - .group-last-of-type\\:flex:is(:where(.group):last-of-type *), .peer-last-of-type\\:flex:is(:where(.peer):last-of-type ~ *), .last-of-type\\:flex:last-of-type { + :is(:where(.group):last-of-type .group-last-of-type\\:flex), :is(:where(.peer):last-of-type ~ .peer-last-of-type\\:flex), .last-of-type\\:flex:last-of-type { display: flex; } " @@ -266,7 +266,7 @@ test('only-of-type', async () => { expect(await run(['only-of-type:flex', 'group-only-of-type:flex', 'peer-only-of-type:flex'])) .toMatchInlineSnapshot(` " - .group-only-of-type\\:flex:is(:where(.group):only-of-type *), .peer-only-of-type\\:flex:is(:where(.peer):only-of-type ~ *), .only-of-type\\:flex:only-of-type { + :is(:where(.group):only-of-type .group-only-of-type\\:flex), :is(:where(.peer):only-of-type ~ .peer-only-of-type\\:flex), .only-of-type\\:flex:only-of-type { display: flex; } " @@ -278,7 +278,7 @@ test('visited', async () => { expect(await run(['visited:flex', 'group-visited:flex', 'peer-visited:flex'])) .toMatchInlineSnapshot(` " - .group-visited\\:flex:is(:where(.group):visited *), .peer-visited\\:flex:is(:where(.peer):visited ~ *), .visited\\:flex:visited { + :is(:where(.group):visited .group-visited\\:flex), :is(:where(.peer):visited ~ .peer-visited\\:flex), .visited\\:flex:visited { display: flex; } " @@ -290,7 +290,7 @@ test('target', async () => { expect(await run(['target:flex', 'group-target:flex', 'peer-target:flex'])) .toMatchInlineSnapshot(` " - .group-target\\:flex:is(:where(.group):target *), .peer-target\\:flex:is(:where(.peer):target ~ *), .target\\:flex:target { + :is(:where(.group):target .group-target\\:flex), :is(:where(.peer):target ~ .peer-target\\:flex), .target\\:flex:target { display: flex; } " @@ -302,7 +302,7 @@ test('open', async () => { expect(await run(['open:flex', 'group-open:flex', 'peer-open:flex', 'not-open:flex'])) .toMatchInlineSnapshot(` " - .not-open\\:flex:not(:is([open], :popover-open, :open)), .group-open\\:flex:is(:where(.group):is([open], :popover-open, :open) *), .peer-open\\:flex:is(:where(.peer):is([open], :popover-open, :open) ~ *), .open\\:flex:is([open], :popover-open, :open) { + .not-open\\:flex:not(:is([open], :popover-open, :open)), :is(:where(.group):is([open], :popover-open, :open) .group-open\\:flex), :is(:where(.peer):is([open], :popover-open, :open) ~ .peer-open\\:flex), .open\\:flex:is([open], :popover-open, :open) { display: flex; } " @@ -314,7 +314,7 @@ test('default', async () => { expect(await run(['default:flex', 'group-default:flex', 'peer-default:flex'])) .toMatchInlineSnapshot(` " - .group-default\\:flex:is(:where(.group):default *), .peer-default\\:flex:is(:where(.peer):default ~ *), .default\\:flex:default { + :is(:where(.group):default .group-default\\:flex), :is(:where(.peer):default ~ .peer-default\\:flex), .default\\:flex:default { display: flex; } " @@ -326,7 +326,7 @@ test('checked', async () => { expect(await run(['checked:flex', 'group-checked:flex', 'peer-checked:flex'])) .toMatchInlineSnapshot(` " - .group-checked\\:flex:is(:where(.group):checked *), .peer-checked\\:flex:is(:where(.peer):checked ~ *), .checked\\:flex:checked { + :is(:where(.group):checked .group-checked\\:flex), :is(:where(.peer):checked ~ .peer-checked\\:flex), .checked\\:flex:checked { display: flex; } " @@ -338,7 +338,7 @@ test('indeterminate', async () => { expect(await run(['indeterminate:flex', 'group-indeterminate:flex', 'peer-indeterminate:flex'])) .toMatchInlineSnapshot(` " - .group-indeterminate\\:flex:is(:where(.group):indeterminate *), .peer-indeterminate\\:flex:is(:where(.peer):indeterminate ~ *), .indeterminate\\:flex:indeterminate { + :is(:where(.group):indeterminate .group-indeterminate\\:flex), :is(:where(.peer):indeterminate ~ .peer-indeterminate\\:flex), .indeterminate\\:flex:indeterminate { display: flex; } " @@ -355,7 +355,7 @@ test('placeholder-shown', async () => { ]), ).toMatchInlineSnapshot(` " - .group-placeholder-shown\\:flex:is(:where(.group):placeholder-shown *), .peer-placeholder-shown\\:flex:is(:where(.peer):placeholder-shown ~ *), .placeholder-shown\\:flex:placeholder-shown { + :is(:where(.group):placeholder-shown .group-placeholder-shown\\:flex), :is(:where(.peer):placeholder-shown ~ .peer-placeholder-shown\\:flex), .placeholder-shown\\:flex:placeholder-shown { display: flex; } " @@ -367,7 +367,7 @@ test('autofill', async () => { expect(await run(['autofill:flex', 'group-autofill:flex', 'peer-autofill:flex'])) .toMatchInlineSnapshot(` " - .group-autofill\\:flex:is(:where(.group):autofill *), .peer-autofill\\:flex:is(:where(.peer):autofill ~ *) { + :is(:where(.group):autofill .group-autofill\\:flex), :is(:where(.peer):autofill ~ .peer-autofill\\:flex) { display: flex; } @@ -383,7 +383,7 @@ test('optional', async () => { expect(await run(['optional:flex', 'group-optional:flex', 'peer-optional:flex'])) .toMatchInlineSnapshot(` " - .group-optional\\:flex:is(:where(.group):optional *), .peer-optional\\:flex:is(:where(.peer):optional ~ *), .optional\\:flex:optional { + :is(:where(.group):optional .group-optional\\:flex), :is(:where(.peer):optional ~ .peer-optional\\:flex), .optional\\:flex:optional { display: flex; } " @@ -395,7 +395,7 @@ test('required', async () => { expect(await run(['required:flex', 'group-required:flex', 'peer-required:flex'])) .toMatchInlineSnapshot(` " - .group-required\\:flex:is(:where(.group):required *), .peer-required\\:flex:is(:where(.peer):required ~ *), .required\\:flex:required { + :is(:where(.group):required .group-required\\:flex), :is(:where(.peer):required ~ .peer-required\\:flex), .required\\:flex:required { display: flex; } " @@ -406,7 +406,7 @@ test('required', async () => { test('valid', async () => { expect(await run(['valid:flex', 'group-valid:flex', 'peer-valid:flex'])).toMatchInlineSnapshot(` " - .group-valid\\:flex:is(:where(.group):valid *), .peer-valid\\:flex:is(:where(.peer):valid ~ *), .valid\\:flex:valid { + :is(:where(.group):valid .group-valid\\:flex), :is(:where(.peer):valid ~ .peer-valid\\:flex), .valid\\:flex:valid { display: flex; } " @@ -418,7 +418,7 @@ test('invalid', async () => { expect(await run(['invalid:flex', 'group-invalid:flex', 'peer-invalid:flex'])) .toMatchInlineSnapshot(` " - .group-invalid\\:flex:is(:where(.group):invalid *), .peer-invalid\\:flex:is(:where(.peer):invalid ~ *), .invalid\\:flex:invalid { + :is(:where(.group):invalid .group-invalid\\:flex), :is(:where(.peer):invalid ~ .peer-invalid\\:flex), .invalid\\:flex:invalid { display: flex; } " @@ -430,7 +430,7 @@ test('user-valid', async () => { expect(await run(['user-valid:flex', 'group-user-valid:flex', 'peer-user-valid:flex'])) .toMatchInlineSnapshot(` " - .group-user-valid\\:flex:is(:where(.group):user-valid *), .peer-user-valid\\:flex:is(:where(.peer):user-valid ~ *) { + :is(:where(.group):user-valid .group-user-valid\\:flex), :is(:where(.peer):user-valid ~ .peer-user-valid\\:flex) { display: flex; } @@ -446,7 +446,7 @@ test('user-invalid', async () => { expect(await run(['user-invalid:flex', 'group-user-invalid:flex', 'peer-user-invalid:flex'])) .toMatchInlineSnapshot(` " - .group-user-invalid\\:flex:is(:where(.group):user-invalid *), .peer-user-invalid\\:flex:is(:where(.peer):user-invalid ~ *) { + :is(:where(.group):user-invalid .group-user-invalid\\:flex), :is(:where(.peer):user-invalid ~ .peer-user-invalid\\:flex) { display: flex; } @@ -462,7 +462,7 @@ test('in-range', async () => { expect(await run(['in-range:flex', 'group-in-range:flex', 'peer-in-range:flex'])) .toMatchInlineSnapshot(` " - .group-in-range\\:flex:is(:where(.group):in-range *), .peer-in-range\\:flex:is(:where(.peer):in-range ~ *), .in-range\\:flex:in-range { + :is(:where(.group):in-range .group-in-range\\:flex), :is(:where(.peer):in-range ~ .peer-in-range\\:flex), .in-range\\:flex:in-range { display: flex; } " @@ -474,7 +474,7 @@ test('out-of-range', async () => { expect(await run(['out-of-range:flex', 'group-out-of-range:flex', 'peer-out-of-range:flex'])) .toMatchInlineSnapshot(` " - .group-out-of-range\\:flex:is(:where(.group):out-of-range *), .peer-out-of-range\\:flex:is(:where(.peer):out-of-range ~ *), .out-of-range\\:flex:out-of-range { + :is(:where(.group):out-of-range .group-out-of-range\\:flex), :is(:where(.peer):out-of-range ~ .peer-out-of-range\\:flex), .out-of-range\\:flex:out-of-range { display: flex; } " @@ -486,7 +486,7 @@ test('read-only', async () => { expect(await run(['read-only:flex', 'group-read-only:flex', 'peer-read-only:flex'])) .toMatchInlineSnapshot(` " - .group-read-only\\:flex:is(:where(.group):read-only *), .peer-read-only\\:flex:is(:where(.peer):read-only ~ *), .read-only\\:flex:read-only { + :is(:where(.group):read-only .group-read-only\\:flex), :is(:where(.peer):read-only ~ .peer-read-only\\:flex), .read-only\\:flex:read-only { display: flex; } " @@ -497,7 +497,7 @@ test('read-only', async () => { test('empty', async () => { expect(await run(['empty:flex', 'group-empty:flex', 'peer-empty:flex'])).toMatchInlineSnapshot(` " - .group-empty\\:flex:is(:where(.group):empty *), .peer-empty\\:flex:is(:where(.peer):empty ~ *), .empty\\:flex:empty { + :is(:where(.group):empty .group-empty\\:flex), :is(:where(.peer):empty ~ .peer-empty\\:flex), .empty\\:flex:empty { display: flex; } " @@ -509,7 +509,7 @@ test('focus-within', async () => { expect(await run(['focus-within:flex', 'group-focus-within:flex', 'peer-focus-within:flex'])) .toMatchInlineSnapshot(` " - .group-focus-within\\:flex:is(:where(.group):focus-within *), .peer-focus-within\\:flex:is(:where(.peer):focus-within ~ *), .focus-within\\:flex:focus-within { + :is(:where(.group):focus-within .group-focus-within\\:flex), :is(:where(.peer):focus-within ~ .peer-focus-within\\:flex), .focus-within\\:flex:focus-within { display: flex; } " @@ -521,7 +521,7 @@ test('hover', async () => { expect(await run(['hover:flex', 'group-hover:flex', 'peer-hover:flex'])).toMatchInlineSnapshot(` " @media (hover: hover) { - .group-hover\\:flex:is(:where(.group):hover *), .peer-hover\\:flex:is(:where(.peer):hover ~ *), .hover\\:flex:hover { + :is(:where(.group):hover .group-hover\\:flex), :is(:where(.peer):hover ~ .peer-hover\\:flex), .hover\\:flex:hover { display: flex; } } @@ -533,7 +533,7 @@ test('hover', async () => { test('focus', async () => { expect(await run(['focus:flex', 'group-focus:flex', 'peer-focus:flex'])).toMatchInlineSnapshot(` " - .group-focus\\:flex:is(:where(.group):focus *), .peer-focus\\:flex:is(:where(.peer):focus ~ *), .focus\\:flex:focus { + :is(:where(.group):focus .group-focus\\:flex), :is(:where(.peer):focus ~ .peer-focus\\:flex), .focus\\:flex:focus { display: flex; } " @@ -545,12 +545,12 @@ test('group-hover group-focus sorting', async () => { expect(await run(['group-hover:flex', 'group-focus:flex'])).toMatchInlineSnapshot(` " @media (hover: hover) { - .group-hover\\:flex:is(:where(.group):hover *) { + :is(:where(.group):hover .group-hover\\:flex) { display: flex; } } - .group-focus\\:flex:is(:where(.group):focus *) { + :is(:where(.group):focus .group-focus\\:flex) { display: flex; } " @@ -558,23 +558,36 @@ test('group-hover group-focus sorting', async () => { expect(await run(['group-focus:flex', 'group-hover:flex'])).toMatchInlineSnapshot(` " @media (hover: hover) { - .group-hover\\:flex:is(:where(.group):hover *) { + :is(:where(.group):hover .group-hover\\:flex) { display: flex; } } - .group-focus\\:flex:is(:where(.group):focus *) { + :is(:where(.group):focus .group-focus\\:flex) { display: flex; } " `) }) +test('stacking group and peer variants does not duplicate the target selector', async () => { + let candidate = Array.from( + { length: 12 }, + (_, i) => `${i % 2 === 0 ? 'group' : 'peer'}-data-active/name${i}`, + ).join(':') + + let output = await run([`${candidate}:flex`]) + expect(output).toContain('display: flex') + // Repeating `&` in each variant makes the generated selector grow + // exponentially. This many variants should still produce a small rule. + expect(output.length).toBeLessThan(2_000) +}) + test('focus-visible', async () => { expect(await run(['focus-visible:flex', 'group-focus-visible:flex', 'peer-focus-visible:flex'])) .toMatchInlineSnapshot(` " - .group-focus-visible\\:flex:is(:where(.group):focus-visible *), .peer-focus-visible\\:flex:is(:where(.peer):focus-visible ~ *), .focus-visible\\:flex:focus-visible { + :is(:where(.group):focus-visible .group-focus-visible\\:flex), :is(:where(.peer):focus-visible ~ .peer-focus-visible\\:flex), .focus-visible\\:flex:focus-visible { display: flex; } " @@ -586,7 +599,7 @@ test('active', async () => { expect(await run(['active:flex', 'group-active:flex', 'peer-active:flex'])) .toMatchInlineSnapshot(` " - .group-active\\:flex:is(:where(.group):active *), .peer-active\\:flex:is(:where(.peer):active ~ *), .active\\:flex:active { + :is(:where(.group):active .group-active\\:flex), :is(:where(.peer):active ~ .peer-active\\:flex), .active\\:flex:active { display: flex; } " @@ -598,7 +611,7 @@ test('enabled', async () => { expect(await run(['enabled:flex', 'group-enabled:flex', 'peer-enabled:flex'])) .toMatchInlineSnapshot(` " - .group-enabled\\:flex:is(:where(.group):enabled *), .peer-enabled\\:flex:is(:where(.peer):enabled ~ *), .enabled\\:flex:enabled { + :is(:where(.group):enabled .group-enabled\\:flex), :is(:where(.peer):enabled ~ .peer-enabled\\:flex), .enabled\\:flex:enabled { display: flex; } " @@ -610,7 +623,7 @@ test('disabled', async () => { expect(await run(['disabled:flex', 'group-disabled:flex', 'peer-disabled:flex'])) .toMatchInlineSnapshot(` " - .group-disabled\\:flex:is(:where(.group):disabled *), .peer-disabled\\:flex:is(:where(.peer):disabled ~ *), .disabled\\:flex:disabled { + :is(:where(.group):disabled .group-disabled\\:flex), :is(:where(.peer):disabled ~ .peer-disabled\\:flex), .disabled\\:flex:disabled { display: flex; } " @@ -621,7 +634,7 @@ test('disabled', async () => { test('inert', async () => { expect(await run(['inert:flex', 'group-inert:flex', 'peer-inert:flex'])).toMatchInlineSnapshot(` " - .group-inert\\:flex:is(:where(.group):is([inert], [inert] *) *), .peer-inert\\:flex:is(:where(.peer):is([inert], [inert] *) ~ *), .inert\\:flex:is([inert], [inert] *) { + :is(:where(.group):is([inert], [inert] *) .group-inert\\:flex), :is(:where(.peer):is([inert], [inert] *) ~ .peer-inert\\:flex), .inert\\:flex:is([inert], [inert] *) { display: flex; } " @@ -640,17 +653,17 @@ test('group-[...]', async () => { ]), ).toMatchInlineSnapshot(` " - .group-\\[\\&_p\\]\\:flex:is(:where(.group) p *), .group-\\[\\&\\:hover\\]\\:group-\\[\\&_p\\]\\:flex:is(:where(.group):hover *):is(:where(.group) p *) { + :is(:where(.group) p .group-\\[\\&_p\\]\\:flex), :is(:where(.group) p :is(:where(.group):hover .group-\\[\\&\\:hover\\]\\:group-\\[\\&_p\\]\\:flex)) { display: flex; } @media (hover: hover) { - .group-\\[\\&_p\\]\\:hover\\:flex:is(:where(.group) p *):hover, .hover\\:group-\\[\\&_p\\]\\:flex:hover:is(:where(.group) p *) { + :is(:where(.group) p .group-\\[\\&_p\\]\\:hover\\:flex):hover, :is(:where(.group) p .hover\\:group-\\[\\&_p\\]\\:flex:hover) { display: flex; } @media (hover: hover) { - .hover\\:group-\\[\\&_p\\]\\:hover\\:flex:hover:is(:where(.group) p *):hover { + :is(:where(.group) p .hover\\:group-\\[\\&_p\\]\\:hover\\:flex:hover):hover { display: flex; } } @@ -692,22 +705,22 @@ test('group-*', async () => { ).toMatchInlineSnapshot(` " @media (hover: hover) { - .group-hover\\:flex:is(:where(.group):hover *) { + :is(:where(.group):hover .group-hover\\:flex) { display: flex; } } - .group-focus\\:flex:is(:where(.group):focus *) { + :is(:where(.group):focus .group-focus\\:flex) { display: flex; } @media (hover: hover) { - .group-focus\\:group-hover\\:flex:is(:where(.group):focus *):is(:where(.group):hover *), .group-hover\\:group-focus\\:flex:is(:where(.group):hover *):is(:where(.group):focus *) { + :is(:where(.group):hover :is(:where(.group):focus .group-focus\\:group-hover\\:flex)), :is(:where(.group):focus :is(:where(.group):hover .group-hover\\:group-focus\\:flex)) { display: flex; } } - .group-hocus\\:flex:is(:is(:where(.group):hover, :where(.group):focus) *) { + :is(:is(:where(.group):hover, :where(.group):focus) .group-hocus\\:flex) { display: flex; } " @@ -742,12 +755,12 @@ test('peer-[...]', async () => { ]), ).toMatchInlineSnapshot(` " - .peer-\\[\\&_p\\]\\:flex:is(:where(.peer) p ~ *), .peer-\\[\\&\\:hover\\]\\:peer-\\[\\&_p\\]\\:flex:is(:where(.peer):hover ~ *):is(:where(.peer) p ~ *) { + :is(:where(.peer) p ~ .peer-\\[\\&_p\\]\\:flex), :is(:where(.peer) p ~ :is(:where(.peer):hover ~ .peer-\\[\\&\\:hover\\]\\:peer-\\[\\&_p\\]\\:flex)) { display: flex; } @media (hover: hover) { - .hover\\:peer-\\[\\&_p\\]\\:flex:hover:is(:where(.peer) p ~ *), .peer-\\[\\&_p\\]\\:hover\\:flex:is(:where(.peer) p ~ *):hover, .hover\\:peer-\\[\\&_p\\]\\:focus\\:flex:hover:is(:where(.peer) p ~ *):focus { + :is(:where(.peer) p ~ .hover\\:peer-\\[\\&_p\\]\\:flex:hover), :is(:where(.peer) p ~ .peer-\\[\\&_p\\]\\:hover\\:flex):hover, :is(:where(.peer) p ~ .hover\\:peer-\\[\\&_p\\]\\:focus\\:flex:hover):focus { display: flex; } } @@ -782,22 +795,22 @@ test('peer-*', async () => { ).toMatchInlineSnapshot(` " @media (hover: hover) { - .peer-hover\\:flex:is(:where(.peer):hover ~ *) { + :is(:where(.peer):hover ~ .peer-hover\\:flex) { display: flex; } } - .peer-focus\\:flex:is(:where(.peer):focus ~ *) { + :is(:where(.peer):focus ~ .peer-focus\\:flex) { display: flex; } @media (hover: hover) { - .peer-focus\\:peer-hover\\:flex:is(:where(.peer):focus ~ *):is(:where(.peer):hover ~ *), .peer-hover\\:peer-focus\\:flex:is(:where(.peer):hover ~ *):is(:where(.peer):focus ~ *) { + :is(:where(.peer):hover ~ :is(:where(.peer):focus ~ .peer-focus\\:peer-hover\\:flex)), :is(:where(.peer):focus ~ :is(:where(.peer):hover ~ .peer-hover\\:peer-focus\\:flex)) { display: flex; } } - .peer-hocus\\:flex:is(:is(:where(.peer):hover, :where(.peer):focus) ~ *) { + :is(:is(:where(.peer):hover, :where(.peer):focus) ~ .peer-hocus\\:flex) { display: flex; } " @@ -1424,6 +1437,80 @@ test('sorting `min` and `max` should sort by unit, then by value, then alphabeti `) }) +test('sorting `min` and `max` should compare decimal values numerically', async () => { + expect( + await run([ + 'min-[40.5rem]:flex', + 'min-[40.25rem]:flex', + 'max-[40.25rem]:flex', + 'max-[40.5rem]:flex', + ]), + ).toMatchInlineSnapshot(` + " + @media not all and (min-width: 40.5rem) { + .max-\\[40\\.5rem\\]\\:flex { + display: flex; + } + } + + @media not all and (min-width: 40.25rem) { + .max-\\[40\\.25rem\\]\\:flex { + display: flex; + } + } + + @media (min-width: 40.25rem) { + .min-\\[40\\.25rem\\]\\:flex { + display: flex; + } + } + + @media (min-width: 40.5rem) { + .min-\\[40\\.5rem\\]\\:flex { + display: flex; + } + } + " + `) +}) + +test('sorting `@min` and `@max` should compare decimal values numerically', async () => { + expect( + await run([ + '@min-[40.5rem]:flex', + '@min-[40.25rem]:flex', + '@max-[40.25rem]:flex', + '@max-[40.5rem]:flex', + ]), + ).toMatchInlineSnapshot(` + " + @container not (min-width: 40.5rem) { + .\\@max-\\[40\\.5rem\\]\\:flex { + display: flex; + } + } + + @container not (min-width: 40.25rem) { + .\\@max-\\[40\\.25rem\\]\\:flex { + display: flex; + } + } + + @container (min-width: 40.25rem) { + .\\@min-\\[40\\.25rem\\]\\:flex { + display: flex; + } + } + + @container (min-width: 40.5rem) { + .\\@min-\\[40\\.5rem\\]\\:flex { + display: flex; + } + } + " + `) +}) + test('supports', async () => { expect( await run([ @@ -1435,6 +1522,17 @@ test('supports', async () => { 'supports-[font-tech(color-COLRv1)]:flex', 'supports-[var(--test)]:flex', 'supports-[--test]:flex', + + // Only top-level and/or/not should have spaces around them. We should + // ignore and/or/not inside of `selector(…)` + 'supports-[selector(a:not(.foo))]:flex', + 'supports-[selector(a:is(.and,.or))]:flex', + 'supports-[(display:grid)or(display:flex)]:grid', + 'supports-[not(display:grid)]:flex', + 'supports-[foo-not(display:grid)]:flex', + 'supports-[selector([data-foo="("])or(display:grid)]:flex', + 'supports-[selector(.foo\\(bar)or(display:grid)]:flex', + 'supports-[((display:grid)or(display:flex))]:grid', ]), ).toMatchInlineSnapshot(` " @@ -1444,12 +1542,24 @@ test('supports', async () => { } } + @supports (display: grid) or (display: flex) { + .supports-\\[\\(\\(display\\:grid\\)or\\(display\\:flex\\)\\)\\]\\:grid { + display: grid; + } + } + @supports (display: grid) and font-format(opentype) { .supports-\\[\\(display\\:grid\\)_and_font-format\\(opentype\\)\\]\\:grid { display: grid; } } + @supports (display: grid) or (display: flex) { + .supports-\\[\\(display\\:grid\\)or\\(display\\:flex\\)\\]\\:grid { + display: grid; + } + } + @supports (--test: var(--tw)) { .supports-\\[--test\\]\\:flex { display: flex; @@ -1474,12 +1584,48 @@ test('supports', async () => { } } + @supports foo-not(display:grid) { + .supports-\\[foo-not\\(display\\:grid\\)\\]\\:flex { + display: flex; + } + } + + @supports not (display: grid) { + .supports-\\[not\\(display\\:grid\\)\\]\\:flex { + display: flex; + } + } + + @supports selector(.foo\\(bar) or (display: grid) { + .supports-\\[selector\\(\\.foo\\\\\\(bar\\)or\\(display\\:grid\\)\\]\\:flex { + display: flex; + } + } + @supports selector(A > B) { .supports-\\[selector\\(A_\\>_B\\)\\]\\:flex { display: flex; } } + @supports selector([data-foo="("]) or (display: grid) { + .supports-\\[selector\\(\\[data-foo\\=\\"\\(\\"\\]\\)or\\(display\\:grid\\)\\]\\:flex { + display: flex; + } + } + + @supports selector(a:is(.and,.or)) { + .supports-\\[selector\\(a\\:is\\(\\.and\\,\\.or\\)\\)\\]\\:flex { + display: flex; + } + } + + @supports selector(a:not(.foo)) { + .supports-\\[selector\\(a\\:not\\(\\.foo\\)\\)\\]\\:flex { + display: flex; + } + } + @supports var(--test) { .supports-\\[var\\(--test\\)\\]\\:flex { display: flex; @@ -1764,7 +1910,7 @@ test('not', async () => { } } - .group-not-checked\\:flex:is(:where(.group):not(:checked) *), .group-not-hocus\\:flex:is(:where(.group):not(:hover, :focus) *), .group-not-hocus\\/parent-name\\:flex:is(:where(.group\\/parent-name):not(:hover, :focus) *), .group-not-\\[\\:checked\\]\\:flex:is(:where(.group):not(:checked) *), .group-not-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):not(:checked) *), .peer-not-checked\\:flex:is(:where(.peer):not(:checked) ~ *), .peer-not-hocus\\:flex:is(:where(.peer):not(:hover, :focus) ~ *), .peer-not-hocus\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name):not(:hover, :focus) ~ *), .peer-not-\\[\\:checked\\]\\:flex:is(:where(.peer):not(:checked) ~ *), .peer-not-\\[\\:checked\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name):not(:checked) ~ *) { + :is(:where(.group):not(:checked) .group-not-checked\\:flex), :is(:where(.group):not(:hover, :focus) .group-not-hocus\\:flex), :is(:where(.group\\/parent-name):not(:hover, :focus) .group-not-hocus\\/parent-name\\:flex), :is(:where(.group):not(:checked) .group-not-\\[\\:checked\\]\\:flex), :is(:where(.group\\/parent-name):not(:checked) .group-not-\\[\\:checked\\]\\/parent-name\\:flex), :is(:where(.peer):not(:checked) ~ .peer-not-checked\\:flex), :is(:where(.peer):not(:hover, :focus) ~ .peer-not-hocus\\:flex), :is(:where(.peer\\/sibling-name):not(:hover, :focus) ~ .peer-not-hocus\\/sibling-name\\:flex), :is(:where(.peer):not(:checked) ~ .peer-not-\\[\\:checked\\]\\:flex), :is(:where(.peer\\/sibling-name):not(:checked) ~ .peer-not-\\[\\:checked\\]\\/sibling-name\\:flex) { display: flex; } " @@ -1993,7 +2139,7 @@ test('has', async () => { ), ).toMatchInlineSnapshot(` " - .group-has-checked\\:flex:is(:where(.group):has(:checked) *), .group-has-checked\\/parent-name\\:flex:is(:where(.group\\/parent-name):has(:checked) *), .group-has-hocus\\:flex:is(:where(.group):has(:hover, :focus) *), .group-has-hocus\\/parent-name\\:flex:is(:where(.group\\/parent-name):has(:hover, :focus) *), .group-has-\\[\\:checked\\]\\:flex:is(:where(.group):has(:checked) *), .group-has-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):has(:checked) *), .group-has-\\[\\&\\>img\\]\\:flex:is(:where(.group):has(* > img) *), .group-has-\\[\\&\\>img\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):has(* > img) *), .group-has-\\[\\+img\\]\\:flex:is(:where(.group):has( + img) *), .group-has-\\[\\>img\\]\\:flex:is(:where(.group):has( > img) *), .group-has-\\[\\>img\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):has( > img) *), .group-has-\\[\\~img\\]\\:flex:is(:where(.group):has( ~ img) *), .peer-has-checked\\:flex:is(:where(.peer):has(:checked) ~ *), .peer-has-checked\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name):has(:checked) ~ *), .peer-has-hocus\\:flex:is(:where(.peer):has(:hover, :focus) ~ *), .peer-has-hocus\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name):has(:hover, :focus) ~ *), .peer-has-\\[\\:checked\\]\\:flex:is(:where(.peer):has(:checked) ~ *), .peer-has-\\[\\:checked\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name):has(:checked) ~ *), .peer-has-\\[\\&\\>img\\]\\:flex:is(:where(.peer):has(* > img) ~ *), .peer-has-\\[\\&\\>img\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name):has(* > img) ~ *), .peer-has-\\[\\+img\\]\\:flex:is(:where(.peer):has( + img) ~ *), .peer-has-\\[\\>img\\]\\:flex:is(:where(.peer):has( > img) ~ *), .peer-has-\\[\\>img\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name):has( > img) ~ *), .peer-has-\\[\\~img\\]\\:flex:is(:where(.peer):has( ~ img) ~ *), .has-checked\\:flex:has(:checked), .has-hocus\\:flex:has(:hover, :focus), .has-\\[\\:checked\\]\\:flex:has(:checked), .has-\\[\\&\\>img\\]\\:flex:has(* > img), .has-\\[\\+img\\]\\:flex:has( + img), .has-\\[\\>img\\]\\:flex:has( > img), .has-\\[\\~img\\]\\:flex:has( ~ img) { + :is(:where(.group):has(:checked) .group-has-checked\\:flex), :is(:where(.group\\/parent-name):has(:checked) .group-has-checked\\/parent-name\\:flex), :is(:where(.group):has(:hover, :focus) .group-has-hocus\\:flex), :is(:where(.group\\/parent-name):has(:hover, :focus) .group-has-hocus\\/parent-name\\:flex), :is(:where(.group):has(:checked) .group-has-\\[\\:checked\\]\\:flex), :is(:where(.group\\/parent-name):has(:checked) .group-has-\\[\\:checked\\]\\/parent-name\\:flex), :is(:where(.group):has(* > img) .group-has-\\[\\&\\>img\\]\\:flex), :is(:where(.group\\/parent-name):has(* > img) .group-has-\\[\\&\\>img\\]\\/parent-name\\:flex), :is(:where(.group):has( + img) .group-has-\\[\\+img\\]\\:flex), :is(:where(.group):has( > img) .group-has-\\[\\>img\\]\\:flex), :is(:where(.group\\/parent-name):has( > img) .group-has-\\[\\>img\\]\\/parent-name\\:flex), :is(:where(.group):has( ~ img) .group-has-\\[\\~img\\]\\:flex), :is(:where(.peer):has(:checked) ~ .peer-has-checked\\:flex), :is(:where(.peer\\/sibling-name):has(:checked) ~ .peer-has-checked\\/sibling-name\\:flex), :is(:where(.peer):has(:hover, :focus) ~ .peer-has-hocus\\:flex), :is(:where(.peer\\/sibling-name):has(:hover, :focus) ~ .peer-has-hocus\\/sibling-name\\:flex), :is(:where(.peer):has(:checked) ~ .peer-has-\\[\\:checked\\]\\:flex), :is(:where(.peer\\/sibling-name):has(:checked) ~ .peer-has-\\[\\:checked\\]\\/sibling-name\\:flex), :is(:where(.peer):has(* > img) ~ .peer-has-\\[\\&\\>img\\]\\:flex), :is(:where(.peer\\/sibling-name):has(* > img) ~ .peer-has-\\[\\&\\>img\\]\\/sibling-name\\:flex), :is(:where(.peer):has( + img) ~ .peer-has-\\[\\+img\\]\\:flex), :is(:where(.peer):has( > img) ~ .peer-has-\\[\\>img\\]\\:flex), :is(:where(.peer\\/sibling-name):has( > img) ~ .peer-has-\\[\\>img\\]\\/sibling-name\\:flex), :is(:where(.peer):has( ~ img) ~ .peer-has-\\[\\~img\\]\\:flex), .has-checked\\:flex:has(:checked), .has-hocus\\:flex:has(:hover, :focus), .has-\\[\\:checked\\]\\:flex:has(:checked), .has-\\[\\&\\>img\\]\\:flex:has(* > img), .has-\\[\\+img\\]\\:flex:has( + img), .has-\\[\\>img\\]\\:flex:has( > img), .has-\\[\\~img\\]\\:flex:has( ~ img) { display: flex; } " @@ -2046,7 +2192,7 @@ test('aria', async () => { ]), ).toMatchInlineSnapshot(` " - .group-aria-checked\\:flex:is(:where(.group)[aria-checked="true"] *), .group-aria-checked\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-checked="true"] *), .group-aria-\\[modal\\]\\:flex:is(:where(.group)[aria-modal] *), .group-aria-\\[modal\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-modal] *), .group-aria-\\[valuenow\\=1\\]\\:flex:is(:where(.group)[aria-valuenow="1"] *), .group-aria-\\[valuenow\\=1\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-valuenow="1"] *), .peer-aria-checked\\:flex:is(:where(.peer)[aria-checked="true"] ~ *), .peer-aria-checked\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name)[aria-checked="true"] ~ *), .peer-aria-\\[modal\\]\\:flex:is(:where(.peer)[aria-modal] ~ *), .peer-aria-\\[modal\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name)[aria-modal] ~ *), .peer-aria-\\[valuenow\\=1\\]\\:flex:is(:where(.peer)[aria-valuenow="1"] ~ *), .peer-aria-\\[valuenow\\=1\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name)[aria-valuenow="1"] ~ *), .aria-checked\\:flex[aria-checked="true"], .aria-\\[invalid\\=spelling\\]\\:flex[aria-invalid="spelling"], .aria-\\[valuenow_\\=_\\"1\\"\\]\\:flex[aria-valuenow="1"], .aria-\\[valuenow\\=1\\]\\:flex[aria-valuenow="1"] { + :is(:where(.group)[aria-checked="true"] .group-aria-checked\\:flex), :is(:where(.group\\/parent-name)[aria-checked="true"] .group-aria-checked\\/parent-name\\:flex), :is(:where(.group)[aria-modal] .group-aria-\\[modal\\]\\:flex), :is(:where(.group\\/parent-name)[aria-modal] .group-aria-\\[modal\\]\\/parent-name\\:flex), :is(:where(.group)[aria-valuenow="1"] .group-aria-\\[valuenow\\=1\\]\\:flex), :is(:where(.group\\/parent-name)[aria-valuenow="1"] .group-aria-\\[valuenow\\=1\\]\\/parent-name\\:flex), :is(:where(.peer)[aria-checked="true"] ~ .peer-aria-checked\\:flex), :is(:where(.peer\\/sibling-name)[aria-checked="true"] ~ .peer-aria-checked\\/sibling-name\\:flex), :is(:where(.peer)[aria-modal] ~ .peer-aria-\\[modal\\]\\:flex), :is(:where(.peer\\/sibling-name)[aria-modal] ~ .peer-aria-\\[modal\\]\\/sibling-name\\:flex), :is(:where(.peer)[aria-valuenow="1"] ~ .peer-aria-\\[valuenow\\=1\\]\\:flex), :is(:where(.peer\\/sibling-name)[aria-valuenow="1"] ~ .peer-aria-\\[valuenow\\=1\\]\\/sibling-name\\:flex), .aria-checked\\:flex[aria-checked="true"], .aria-\\[invalid\\=spelling\\]\\:flex[aria-invalid="spelling"], .aria-\\[valuenow_\\=_\\"1\\"\\]\\:flex[aria-valuenow="1"], .aria-\\[valuenow\\=1\\]\\:flex[aria-valuenow="1"] { display: flex; } " @@ -2085,7 +2231,7 @@ test('data', async () => { ]), ).toMatchInlineSnapshot(` " - .group-data-\\[disabled\\]\\:flex:is(:where(.group)[data-disabled] *), .group-data-\\[disabled\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-disabled] *), .group-data-\\[foo\\$\\=\\'bar\\'_i\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-foo$="bar" i] *), .group-data-\\[foo\\$\\=bar_baz_i\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-foo$="bar baz" i] *), .group-data-\\[foo\\=1\\]\\:flex:is(:where(.group)[data-foo="1"] *), .group-data-\\[foo\\=1\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-foo="1"] *), .group-data-\\[foo\\=bar\\ baz\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-foo="bar baz"] *), .peer-data-\\[disabled\\]\\:flex:is(:where(.peer)[data-disabled] ~ *), .peer-data-\\[disabled\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name)[data-disabled] ~ *), .peer-data-\\[foo\\$\\=\\'bar\\'_i\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name)[data-foo$="bar" i] ~ *), .peer-data-\\[foo\\$\\=bar_baz_i\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name)[data-foo$="bar baz" i] ~ *), .peer-data-\\[foo\\=1\\]\\:flex:is(:where(.peer)[data-foo="1"] ~ *), .peer-data-\\[foo\\=1\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name)[data-foo="1"] ~ *), .peer-data-\\[foo\\=bar\\ baz\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name)[data-foo="bar baz"] ~ *), .data-disabled\\:flex[data-disabled], .data-\\[foo\\$\\=\\'bar\\'_i\\]\\:flex[data-foo$="bar" i], .data-\\[foo\\$\\=bar_baz_i\\]\\:flex[data-foo$="bar baz" i], .data-\\[foo\\=1\\]\\:flex[data-foo="1"], .data-\\[foo\\=bar_baz\\]\\:flex[data-foo="bar baz"], .data-\\[potato_\\=_\\"salad\\"\\]\\:flex[data-potato="salad"], .data-\\[potato_\\^\\=_\\"salad\\"\\]\\:flex[data-potato^="salad"], .data-\\[potato\\=\\"\\^_\\=\\"\\]\\:flex[data-potato="^ ="], .data-\\[potato\\=salad\\]\\:flex[data-potato="salad"] { + :is(:where(.group)[data-disabled] .group-data-\\[disabled\\]\\:flex), :is(:where(.group\\/parent-name)[data-disabled] .group-data-\\[disabled\\]\\/parent-name\\:flex), :is(:where(.group\\/parent-name)[data-foo$="bar" i] .group-data-\\[foo\\$\\=\\'bar\\'_i\\]\\/parent-name\\:flex), :is(:where(.group\\/parent-name)[data-foo$="bar baz" i] .group-data-\\[foo\\$\\=bar_baz_i\\]\\/parent-name\\:flex), :is(:where(.group)[data-foo="1"] .group-data-\\[foo\\=1\\]\\:flex), :is(:where(.group\\/parent-name)[data-foo="1"] .group-data-\\[foo\\=1\\]\\/parent-name\\:flex), :is(:where(.group\\/parent-name)[data-foo="bar baz"] .group-data-\\[foo\\=bar\\ baz\\]\\/parent-name\\:flex), :is(:where(.peer)[data-disabled] ~ .peer-data-\\[disabled\\]\\:flex), :is(:where(.peer\\/sibling-name)[data-disabled] ~ .peer-data-\\[disabled\\]\\/sibling-name\\:flex), :is(:where(.peer\\/sibling-name)[data-foo$="bar" i] ~ .peer-data-\\[foo\\$\\=\\'bar\\'_i\\]\\/sibling-name\\:flex), :is(:where(.peer\\/sibling-name)[data-foo$="bar baz" i] ~ .peer-data-\\[foo\\$\\=bar_baz_i\\]\\/sibling-name\\:flex), :is(:where(.peer)[data-foo="1"] ~ .peer-data-\\[foo\\=1\\]\\:flex), :is(:where(.peer\\/sibling-name)[data-foo="1"] ~ .peer-data-\\[foo\\=1\\]\\/sibling-name\\:flex), :is(:where(.peer\\/sibling-name)[data-foo="bar baz"] ~ .peer-data-\\[foo\\=bar\\ baz\\]\\/sibling-name\\:flex), .data-disabled\\:flex[data-disabled], .data-\\[foo\\$\\=\\'bar\\'_i\\]\\:flex[data-foo$="bar" i], .data-\\[foo\\$\\=bar_baz_i\\]\\:flex[data-foo$="bar baz" i], .data-\\[foo\\=1\\]\\:flex[data-foo="1"], .data-\\[foo\\=bar_baz\\]\\:flex[data-foo="bar baz"], .data-\\[potato_\\=_\\"salad\\"\\]\\:flex[data-potato="salad"], .data-\\[potato_\\^\\=_\\"salad\\"\\]\\:flex[data-potato^="salad"], .data-\\[potato\\=\\"\\^_\\=\\"\\]\\:flex[data-potato="^ ="], .data-\\[potato\\=salad\\]\\:flex[data-potato="salad"] { display: flex; } " @@ -2584,7 +2730,7 @@ test('variant order', async () => { } @media (hover: hover) { - .group-hover\\:flex:is(:where(.group):hover *), .peer-hover\\:flex:is(:where(.peer):hover ~ *) { + :is(:where(.group):hover .group-hover\\:flex), :is(:where(.peer):hover ~ .peer-hover\\:flex) { display: flex; } } @@ -2857,7 +3003,7 @@ test('move modifier of compound variant to sub-variant if its also a compound va ]), ).toMatchInlineSnapshot(` " - .not-group-focus\\/name\\:flex:not(:is(:where(.group\\/name):focus *)), .group-peer-focus\\/name\\:flex:is(:where(.group\\/name):is(:where(.peer):focus ~ *) *), :where(:is(:where(.group\\/name):focus *)) .in-group-focus\\/name\\:flex, .has-group-focus\\/name\\:flex:has(:is(:where(.group\\/name):focus *)) { + .not-group-focus\\/name\\:flex:not(:is(:where(.group\\/name):focus *)), :is(:is(:where(.peer):focus ~ :where(.group\\/name)) .group-peer-focus\\/name\\:flex), :where(:is(:where(.group\\/name):focus *)) .in-group-focus\\/name\\:flex, .has-group-focus\\/name\\:flex:has(:is(:where(.group\\/name):focus *)) { display: flex; } " diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 79ffe0162..d2695b4d2 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -610,7 +610,13 @@ export function createVariants(theme: Theme): Variants { selector = `:is(${selector})` } - node.selector = `&:is(${selector} *)` + // Put the target inside `:is` so browsers can limit style invalidation to + // it when the group changes. Keep `&` instead of the candidate's class so + // this also works with `@apply` and variants that change the target. + // + // The `:is` wrapper also keeps `has-group-*` from anchoring the group + // itself to the element carrying the utility. + node.selector = `:is(${selector} &)` // Track that the variant was actually applied didApply = true @@ -651,7 +657,7 @@ export function createVariants(theme: Theme): Variants { // For most variants we rely entirely on CSS nesting to build-up the final // selector, but there is no way to use CSS nesting to make `&` refer to - // just the `.group` class the way we'd need to for these variants, so we + // just the `.peer` class the way we'd need to for these variants, so we // need to replace it in the selector ourselves. let selector = node.selector.replaceAll('&', variantSelector) @@ -662,7 +668,8 @@ export function createVariants(theme: Theme): Variants { selector = `:is(${selector})` } - node.selector = `&:is(${selector} ~ *)` + // As with `group`, constrain the target without repeating its selector. + node.selector = `:is(${selector} ~ &)` // Track that the variant was actually applied didApply = true @@ -938,7 +945,22 @@ export function createVariants(theme: Theme): Variants { if (/^[\w-]*\s*\(/.test(value)) { // Chrome has a bug where `(condition1)or(condition2)` is not valid, but // `(condition1) or (condition2)` is supported. - let query = value.replace(/\b(and|or|not)\b/g, ' $1 ') + let changed = false + let ast = ValueParser.parse(value) + walk(ast, (node) => { + if (node.kind !== 'function') return + + // Leave selectors as-is, they could contain `selector(a:not(b))`, and + // in this case we don't want the space around the `not`. + if (node.value === 'selector') return WalkAction.Skip + + if (node.value === 'and' || node.value === 'or' || node.value === 'not') { + changed = true + node.value = ` ${node.value} ` + } + }) + + let query = changed ? ValueParser.toCss(ast) : value ruleNode.nodes = [atRule('@supports', query, ruleNode.nodes)] return diff --git a/packages/tailwindcss/tests/ui.spec.ts b/packages/tailwindcss/tests/ui.spec.ts index 70ba7bd0f..e5da091d8 100644 --- a/packages/tailwindcss/tests/ui.spec.ts +++ b/packages/tailwindcss/tests/ui.spec.ts @@ -2387,6 +2387,120 @@ test('shadow DOM has access to variables', async ({ page }) => { } } +for (let kind of ['group', 'peer']) { + test(`${kind}-* variants`, async ({ page }) => { + let targets = html` +
+
+ ` + + let { getPropertyValue } = await render( + page, + kind === 'group' + ? html`
${targets}
` + : html`
+
+ ${targets} +
`, + css` + .owner > .applied { + @apply ${kind}-focus:z-20; + } + `, + ) + + expect(await getPropertyValue('#target', 'z-index')).toEqual('auto') + expect(await getPropertyValue('#applied', 'z-index')).toEqual('auto') + + await page.locator('#trigger').focus() + + expect(await getPropertyValue('#target', 'z-index')).toEqual('10') + expect(await getPropertyValue('#applied', 'z-index')).toEqual('20') + + await page.locator('#trigger').blur() + + expect(await getPropertyValue('#target', 'z-index')).toEqual('auto') + expect(await getPropertyValue('#applied', 'z-index')).toEqual('auto') + }) +} + +test('group-* variants do not change specificity', async ({ page }) => { + let { getPropertyValue } = await render( + page, + html` +
+
+
+ `, + css` + @layer utilities { + .target { + z-index: 20; + } + .target.equal { + z-index: 30; + } + } + `, + ) + + await page.locator('#trigger').focus() + + expect(await getPropertyValue('#target', 'z-index')).toEqual('10') + + await page.locator('#target').evaluate((element) => element.classList.add('equal')) + + expect(await getPropertyValue('#target', 'z-index')).toEqual('30') +}) + +test('stacked group-* variants match regardless of the order of the groups', async ({ page }) => { + for (let [outer, inner] of [ + ['a', 'b'], + ['b', 'a'], + ]) { + let { getPropertyValue } = await render( + page, + html` +
+
+
+
+
+ `, + ) + + expect(await getPropertyValue('#target', 'z-index')).toEqual('10') + + await page.locator('#inner').evaluate((element) => element.removeAttribute('data-active')) + + expect(await getPropertyValue('#target', 'z-index')).toEqual('auto') + } +}) + +test('compound group-* and peer-* variants', async ({ page }) => { + let { getPropertyValue } = await render( + page, + html` +
+
+
+
+
+
+ `, + ) + + await page.locator('#peer').focus() + + expect(await getPropertyValue('#target', 'z-index')).toEqual('10') + expect(await getPropertyValue('#middle', 'z-index')).toEqual('auto') + + await page.locator('#outer').focus() + + expect(await getPropertyValue('#target', 'z-index')).toEqual('auto') + expect(await getPropertyValue('#middle', 'z-index')).toEqual('20') +}) + // --- const preflight = fs.readFileSync(path.resolve(__dirname, '..', 'preflight.css'), 'utf-8')