Compare commits
16 commits
chore/impr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa81d697fe | ||
|
|
9798a8ab5b | ||
|
|
722acdfebf | ||
|
|
86f3daf912 | ||
|
|
41d9cae8e5 | ||
|
|
f723e834ad | ||
|
|
9f451eead8 | ||
|
|
90f8ff41c8 | ||
|
|
7a7f386648 | ||
|
|
021b7fe6f2 | ||
|
|
f7f58f0806 | ||
|
|
de9e71c599 | ||
|
|
00ef99df3d | ||
|
|
b9286a7346 | ||
|
|
84bea42b6c | ||
|
|
606438c6c3 |
30 changed files with 1706 additions and 2417 deletions
12
CHANGELOG.md
12
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<flex>`, `%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
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ static IGNORED_EXTENSIONS_GLOB: sync::LazyLock<String> = sync::LazyLock::new(||
|
|||
)
|
||||
});
|
||||
|
||||
static BINARY_EXTENSIONS_GLOB: sync::LazyLock<String> = sync::LazyLock::new(|| {
|
||||
pub static BINARY_EXTENSIONS_GLOB: sync::LazyLock<String> = sync::LazyLock::new(|| {
|
||||
format!(
|
||||
"*.{{{}}}",
|
||||
include_str!("fixtures/binary-extensions.txt")
|
||||
|
|
|
|||
|
|
@ -2,6 +2,5 @@ package-lock.json
|
|||
pnpm-lock.yaml
|
||||
bun.lockb
|
||||
.gitignore
|
||||
.ignore
|
||||
.env
|
||||
.env.*
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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<WalkBuilder>,
|
||||
|
||||
/// The resolver implementing the `@source` semantics for the walker
|
||||
resolver: Option<Arc<Resolver>>,
|
||||
|
||||
/// All found extensions
|
||||
extensions: FxHashSet<String>,
|
||||
|
||||
|
|
@ -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<WalkEntry> {
|
|||
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<Resolver>) -> Option<WalkBuilder> {
|
||||
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<WalkBuilder> {
|
||||
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<String>)> = 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<PatternGroup>,
|
||||
|
||||
/// `@source not` directives
|
||||
nots: Vec<NotRule>,
|
||||
|
||||
/// 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<FxHashMap<PathBuf, bool>>,
|
||||
|
||||
/// 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<FxHashMap<PathBuf, bool>>,
|
||||
}
|
||||
|
||||
/// 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<FxHashMap<PathBuf, bool>>,
|
||||
}
|
||||
|
||||
impl Resolver {
|
||||
fn new(sources: &Sources) -> Self {
|
||||
let mut autos = vec![];
|
||||
let mut externals = vec![];
|
||||
let mut patterns: Vec<PatternGroup> = 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<Item = &PathBuf> {
|
||||
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<PathBuf> {
|
||||
let mut roots: Vec<PathBuf> = 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<FxHashMap<PathBuf, bool>>,
|
||||
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<FxHashMap<PathBuf, Arc<Vec<Arc<Gitignore>>>>>,
|
||||
}
|
||||
|
||||
/// 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<Vec<Arc<Gitignore>>> {
|
||||
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<Arc<Gitignore>> {
|
||||
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<PathBuf> = 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)]
|
||||
|
|
|
|||
|
|
@ -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<SourceEntry>) -> Vec<SourceEntry> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
// Bases of restricted patterns. Each of these becomes its own walk root with its own
|
||||
// `*` + `!<pattern>` 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::<Vec<_>>();
|
||||
|
||||
let mut restricted_roots: FxHashSet<PathBuf> = 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::<Vec<SourceEntry>>();
|
||||
|
||||
sources
|
||||
expand_restricted_patterns(sources)
|
||||
}
|
||||
|
||||
/// Convert a public source entry to a source entry
|
||||
|
|
@ -678,15 +858,8 @@ impl From<PublicSourceEntry> 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<PublicSourceEntry> 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<PublicSourceEntry> for SourceEntry {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GlobEntry> for SourceEntry {
|
||||
fn from(value: GlobEntry) -> Self {
|
||||
SourceEntry::Pattern {
|
||||
base: PathBuf::from(value.base),
|
||||
pattern: value.pattern,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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`<div class="content-['updated:project-b/src/index.php']"></div>`,
|
||||
)
|
||||
|
||||
// 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`<div class="content-['updated:project-b/src/index.php']"></div>`,
|
||||
)
|
||||
|
||||
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')
|
||||
|
|
|
|||
165
integrations/vite/preact.test.ts
Normal file
165
integrations/vite/preact.test.ts
Normal file
|
|
@ -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`
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="./src/index.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
'src/main.tsx': ts`
|
||||
import { render } from 'preact'
|
||||
import { App } from './app'
|
||||
|
||||
render(<App />, document.getElementById('app')!)
|
||||
`,
|
||||
'src/app.tsx': ts`
|
||||
import { useState } from 'preact/hooks'
|
||||
|
||||
export function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
return (
|
||||
<button className="underline" onClick={() => setCount((c) => c + 1)}>
|
||||
Count: {count}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
`,
|
||||
'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 (
|
||||
<button className="underline flex" onClick={() => setCount((c) => c + 1)}>
|
||||
Count: {count}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
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')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -376,7 +376,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
|
|||
// 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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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<string, Map<string, Root>>((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<string>()
|
||||
|
||||
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<vite.EnvironmentModuleNode>()
|
||||
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<string | false | undefined>,
|
||||
) {}
|
||||
|
||||
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<string, Root>,
|
||||
) {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => [])
|
||||
|
|
|
|||
|
|
@ -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<string, string>((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
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ export function buildDesignSystem(
|
|||
let variants = createVariants(theme)
|
||||
|
||||
let parsedVariants = new DefaultMap((variant) => parseVariant(variant, designSystem))
|
||||
let cachedVariantOrder: Map<Variant, number> | 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
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) &) }'],
|
||||
[],
|
||||
[],
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
<div id="target" class="${kind}-focus:z-10"></div>
|
||||
<div id="applied" class="applied"></div>
|
||||
`
|
||||
|
||||
let { getPropertyValue } = await render(
|
||||
page,
|
||||
kind === 'group'
|
||||
? html`<div id="trigger" tabindex="0" class="group owner">${targets}</div>`
|
||||
: html`<div class="owner">
|
||||
<div id="trigger" tabindex="0" class="peer"></div>
|
||||
${targets}
|
||||
</div>`,
|
||||
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`
|
||||
<div id="trigger" tabindex="0" class="group">
|
||||
<div id="target" class="target group-focus:z-10"></div>
|
||||
</div>
|
||||
`,
|
||||
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`
|
||||
<div class="group/${outer}" data-active>
|
||||
<div id="inner" class="group/${inner}" data-active>
|
||||
<div id="target" class="group-data-active/a:group-data-active/b:z-10"></div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
|
||||
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`
|
||||
<div id="outer" tabindex="0" class="group">
|
||||
<div id="peer" tabindex="0" class="peer"></div>
|
||||
<div id="middle" class="group peer-group-focus:z-20">
|
||||
<div id="target" class="group-peer-focus:z-10"></div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
|
||||
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')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue