From 9a44db95ca92fa4778788842c0af1ca7473e62a4 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 5 Jun 2026 16:14:49 +0200 Subject: [PATCH 01/12] infer types from the definition --- integrations/utils.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/integrations/utils.ts b/integrations/utils.ts index 7769d8f5ed10..9c2c9c068690 100644 --- a/integrations/utils.ts +++ b/integrations/utils.ts @@ -294,11 +294,7 @@ export function test( } }, fs: { - async write( - filename: string, - content: string | Uint8Array, - encoding: BufferEncoding = 'utf8', - ): Promise { + async write(filename, content, encoding = 'utf8') { let full = path.join(root, filename) let dir = path.dirname(full) await fs.mkdir(dir, { recursive: true }) @@ -319,7 +315,7 @@ export function test( await fs.writeFile(full, content, encoding) }, - async create(filenames: string[]): Promise { + async create(filenames) { for (let filename of filenames) { let full = path.join(root, filename) @@ -329,11 +325,11 @@ export function test( } }, - async delete(filename: string): Promise { + async delete(filename) { await fs.unlink(path.join(root, filename)) }, - async read(filePath: string) { + async read(filePath) { let content = await fs.readFile(path.resolve(root, filePath), 'utf8') // Ensure that files read on Windows have \r\n line endings removed @@ -343,7 +339,7 @@ export function test( return content }, - async glob(pattern: string) { + async glob(pattern) { let files = await fastGlob(pattern, { cwd: root }) return Promise.all( files.map(async (file) => { @@ -356,7 +352,7 @@ export function test( }), ) }, - async dumpFiles(pattern: string) { + async dumpFiles(pattern) { let files = await context.fs.glob(pattern) return `\n${files .slice() From 01ca8393964c50679c1de155450faa4ca4e84788 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 5 Jun 2026 15:47:00 +0200 Subject: [PATCH 02/12] add failing integration test --- integrations/cli/index.test.ts | 197 ++++++++++++++++++++++++++++++++- integrations/utils.ts | 25 ++++- 2 files changed, 220 insertions(+), 2 deletions(-) diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts index af5406a8f8a6..bef57a9a19d3 100644 --- a/integrations/cli/index.test.ts +++ b/integrations/cli/index.test.ts @@ -1938,7 +1938,7 @@ test( `, }, }, - async ({ fs, exec, spawn, root, expect }) => { + async ({ fs, exec, root, expect }) => { await exec('pnpm tailwindcss --input ./index.css --output dist/out.css', { cwd: root }) let content = await fs.dumpFiles('./dist/*.css') @@ -1949,6 +1949,201 @@ test( }, ) +test( + '@source order is important (referencing sibling project)', + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^" + } + } + `, + 'project-a/src/index.css': css` + @import 'tailwindcss/utilities' source(none); + + @source '../../project-b'; + @source not '../../project-b/ignored'; + @source '../../project-b/ignored/except.html'; + `, + + 'project-b/keep/keep.html': html`
`, + 'project-b/ignored/ignored.html': html`
`, + 'project-b/ignored/except.html': html`
`, + }, + }, + async ({ fs, root, exec, expect }) => { + await exec('pnpm tailwindcss --input src/index.css --output dist/out.css', { + cwd: path.join(root, 'project-a'), + }) + + expect(await fs.dumpFiles('./project-a/dist/*.css')).toMatchInlineSnapshot(` + " + --- ./project-a/dist/out.css --- + @layer properties; + .content-\\[\\'GOOD-1\\'\\] { + --tw-content: 'GOOD-1'; + content: var(--tw-content); + } + .content-\\[\\'GOOD-2\\'\\] { + --tw-content: 'GOOD-2'; + content: var(--tw-content); + } + @property --tw-content { + syntax: "*"; + inherits: false; + initial-value: ""; + } + @layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-content: ""; + } + } + } + " + `) + }, +) + +test( + '@source works with symlinks (referencing sibling project)', + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^" + } + } + `, + 'project-a/src/index.css': css` + @import 'tailwindcss/utilities' source(none); + + /* Same as the previous test, but using symlinks instead */ + @source '../../project-b'; + @source not '../../project-b/ignored'; + @source '../../project-b/ignored/except.html'; + `, + + 'project-b/keep/keep.html': html`
`, + 'project-c/ignored/ignored.html': html`
`, + 'project-c/ignored/except.html': html`
`, + + // Symlink the ignored folder to another project + 'project-b/ignored': 'symlink:../../project-c/ignored/', + }, + }, + async ({ fs, root, exec, expect }) => { + await exec('pnpm tailwindcss --input src/index.css --output dist/out.css', { + cwd: path.join(root, 'project-a'), + }) + + expect(await fs.dumpFiles('./project-a/dist/*.css')).toMatchInlineSnapshot(` + " + --- ./project-a/dist/out.css --- + @layer properties; + .content-\\[\\'GOOD-1\\'\\] { + --tw-content: 'GOOD-1'; + content: var(--tw-content); + } + .content-\\[\\'GOOD-2\\'\\] { + --tw-content: 'GOOD-2'; + content: var(--tw-content); + } + @property --tw-content { + syntax: "*"; + inherits: false; + initial-value: ""; + } + @layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-content: ""; + } + } + } + " + `) + }, +) + +test( + '@source works with symlinks (referencing folder in current folder)', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^" + } + } + `, + 'src/index.css': css` + @import 'tailwindcss/utilities' source(none); + + /* Same as the previous test, but using symlinks instead */ + @source '../lib'; + @source not '../lib/ignored'; + @source '../lib/ignored/except.html'; + `, + + 'lib/keep/keep.html': html`
`, + 'vendor/ignored/ignored.html': html`
`, + 'vendor/ignored/except.html': html`
`, + + // Symlink the ignored folder to another folder + 'lib/ignored': 'symlink:../../vendor/ignored/', + }, + }, + async ({ fs, exec, expect }) => { + await exec('pnpm tailwindcss --input src/index.css --output dist/out.css') + + expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` + " + --- ./dist/out.css --- + @layer properties; + .content-\\[\\'GOOD-1\\'\\] { + --tw-content: 'GOOD-1'; + content: var(--tw-content); + } + .content-\\[\\'GOOD-2\\'\\] { + --tw-content: 'GOOD-2'; + content: var(--tw-content); + } + @property --tw-content { + syntax: "*"; + inherits: false; + initial-value: ""; + } + @layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-content: ""; + } + } + } + " + `) + }, +) + test( 'auto source detection disabled', { diff --git a/integrations/utils.ts b/integrations/utils.ts index 9c2c9c068690..84f543a659ae 100644 --- a/integrations/utils.ts +++ b/integrations/utils.ts @@ -49,6 +49,7 @@ interface TestContext { parseSourceMap(opts: string | SourceMapOptions): SourceMap fs: { write(filePath: string, content: string, encoding?: BufferEncoding): Promise + symlink(dst: string, src: string): Promise create(filePaths: string[]): Promise read(filePath: string): Promise delete(filePath: string): Promise @@ -315,6 +316,18 @@ export function test( await fs.writeFile(full, content, encoding) }, + async symlink(target, src) { + let targetAbsolute = path.join(root, target) + let targetParent = path.dirname(targetAbsolute) + await fs.mkdir(targetParent, { recursive: true }) + + let srcAbsolute = path.join(root, src) + let srcParent = path.dirname(srcAbsolute) + await fs.mkdir(srcParent, { recursive: true }) + + await fs.symlink(targetAbsolute, srcAbsolute) + }, + async create(filenames) { for (let filename of filenames) { let full = path.join(root, filename) @@ -412,7 +425,17 @@ export function test( ` for (let [filename, content] of Object.entries(config.fs)) { - await context.fs.write(filename, content) + if (content.toString().startsWith('symlink:')) { + // The symlink path is relative to the target destination's path + let target = path.join( + filename, + content.toString().slice('symlink:'.length), // Relative path + ) + + await context.fs.symlink(target, filename) + } else { + await context.fs.write(filename, content) + } } let shouldInstallDependencies = config.installDependencies ?? true From 836cb4c4d46c086c27a17385f21a0afec0dc8f29 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 5 Jun 2026 17:51:29 +0200 Subject: [PATCH 03/12] add failing scanner test --- crates/oxide/tests/scanner.rs | 92 +++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/crates/oxide/tests/scanner.rs b/crates/oxide/tests/scanner.rs index 4003ff253048..9d45c8e2601a 100644 --- a/crates/oxide/tests/scanner.rs +++ b/crates/oxide/tests/scanner.rs @@ -20,6 +20,16 @@ mod scanner { result } + fn symlink_file, Q: AsRef>(original: P, link: Q) -> std::io::Result<()> { + #[cfg(not(windows))] + let result = std::os::unix::fs::symlink(original, link); + + #[cfg(windows)] + let result = std::os::windows::fs::symlink_file(original, link); + + result + } + fn public_source_entry_from_pattern(dir: PathBuf, pattern: &str) -> PublicSourceEntry { let mut parts = pattern.split_whitespace(); let _ = parts.next().unwrap_or_default(); @@ -1761,6 +1771,88 @@ mod scanner { assert_eq!(candidates, vec!["content-['abcd/xyz.html']"]); } + #[test] + fn test_symlinked_sources_within_the_scanned_tree_can_be_ignored() { + let dir = tempdir().unwrap().into_path(); + create_files_in( + &dir, + &[ + ("src/keep.html", "content-['src/keep.html']"), + ( + "actual-dir/ignore.html", + "content-['actual-dir/ignore.html']", + ), + ("actual-file.html", "content-['actual-file.html']"), + ], + ); + + let _ = symlink(dir.join("actual-dir"), dir.join("linked-dir")); + let _ = symlink_file(dir.join("actual-file.html"), dir.join("linked-file.html")); + + let base = dir.join("src"); + let mut scanner = Scanner::new(vec![ + public_source_entry_from_pattern(base.clone(), "@source '../**/*'"), + public_source_entry_from_pattern(base.clone(), "@source not '../actual-dir'"), + public_source_entry_from_pattern(base.clone(), "@source not '../linked-dir'"), + public_source_entry_from_pattern(base.clone(), "@source not '../actual-file.html'"), + public_source_entry_from_pattern(base.clone(), "@source not '../linked-file.html'"), + ]); + let candidates = scanner.scan(); + + assert_eq!(candidates, vec!["content-['src/keep.html']"]); + + let mut scanner = Scanner::new(vec![ + public_source_entry_from_pattern(base.clone(), "@source '../**/*'"), + public_source_entry_from_pattern(base.clone(), "@source not '../actual-dir/**/*.html'"), + public_source_entry_from_pattern(base.clone(), "@source not '../linked-dir/**/*.html'"), + public_source_entry_from_pattern(base.clone(), "@source not '../actual-file.html'"), + public_source_entry_from_pattern(base.clone(), "@source not '../linked-file.html'"), + ]); + let candidates = scanner.scan(); + + assert_eq!(candidates, vec!["content-['src/keep.html']"]); + } + + #[test] + fn test_symlinked_sources_outside_the_scanned_tree_can_be_ignored() { + let dir = tempdir().unwrap().into_path(); + create_files_in( + &dir, + &[ + ( + "actual-dir/ignore.html", + "content-['actual-dir/ignore.html']", + ), + ("project/keep.html", "content-['project/keep.html']"), + ], + ); + + let _ = symlink(dir.join("actual-dir"), dir.join("project/linked-dir")); + + let base = dir.join("project"); + let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( + base.clone(), + "@source '**/*'", + )]); + let candidates = scanner.scan(); + + assert_eq!( + candidates, + vec![ + "content-['actual-dir/ignore.html']", + "content-['project/keep.html']", + ] + ); + + let mut scanner = Scanner::new(vec![ + public_source_entry_from_pattern(base.clone(), "@source '**/*'"), + public_source_entry_from_pattern(base.clone(), "@source not 'linked-dir'"), + ]); + let candidates = scanner.scan(); + + assert_eq!(candidates, vec!["content-['project/keep.html']"]); + } + #[test] fn test_extract_used_css_variables_from_css() { let dir = tempdir().unwrap().into_path(); From 92fd246c20f4765f0d452f611796ccce34082369 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 5 Jun 2026 23:18:49 +0200 Subject: [PATCH 04/12] keep patterns as-is without canonicalization When we are dealing with `@source` patterns, we want to optimize them by moving as many static parts from the pattern to the base path. E.g.: ``` @source "./foo/bar/baz/*.html"; ``` Will look like: ``` SourceEntry { base = "/projects/project-a", pattern = "/foo/bar/baz/*.html", } ``` And becomes: ``` SourceEntry { base = "/projects/project-a/foo/bar/baz", pattern = "/*.html", } ``` But with this change, we don't canonicalize them, meaning that if we were referencing a symlink then we keep using the symlink in the pattern. We won't use the resolved canonical path all of a sudden. --- crates/oxide/src/scanner/sources.rs | 115 +++++++++++++++------------- 1 file changed, 60 insertions(+), 55 deletions(-) diff --git a/crates/oxide/src/scanner/sources.rs b/crates/oxide/src/scanner/sources.rs index f28b16a49865..5fe721f6737f 100644 --- a/crates/oxide/src/scanner/sources.rs +++ b/crates/oxide/src/scanner/sources.rs @@ -1,7 +1,6 @@ -use crate::glob::split_pattern; use crate::GlobEntry; use bexpand::Expression; -use std::path::PathBuf; +use std::path::{Component, Path, PathBuf}; use tracing::{event, Level}; use super::auto_source_detection::IGNORED_CONTENT_DIRS; @@ -102,72 +101,78 @@ impl PublicSourceEntry { /// resolved path. pub fn optimize(&mut self) { // Resolve base path immediately - let Ok(base) = dunce::canonicalize(&self.base) else { + let Ok(mut base) = dunce::canonicalize(&self.base) else { event!(Level::ERROR, "Failed to resolve base: {:?}", self.base); return; }; - self.base = base.to_string_lossy().to_string(); - - // No dynamic part, figure out if we are dealing with a file or a directory. - if !self.pattern.contains('*') { - let combined_path = if self.pattern.starts_with("/") { - PathBuf::from(&self.pattern) - } else { - PathBuf::from(&self.base).join(&self.pattern) - }; - match dunce::canonicalize(combined_path) { - Ok(resolved_path) if resolved_path.is_dir() => { - self.base = resolved_path.to_string_lossy().to_string(); - self.pattern = "**/*".to_owned(); - } - Ok(resolved_path) if resolved_path.is_file() => { - self.base = resolved_path - .parent() - .unwrap() - .to_string_lossy() - .to_string(); - // Ensure leading slash, otherwise it will match against all files in all folders/ - self.pattern = - format!("/{}", resolved_path.file_name().unwrap().to_string_lossy()); - } - _ => {} - } - return; + let mut new_pattern = PathBuf::new(); + enum ComponentStage { + Base, + Pattern, } + let mut stage = ComponentStage::Base; + + let mut components = Path::new(&self.pattern).components().peekable(); + while let Some(component) = components.next() { + match stage { + ComponentStage::Base => { + match component { + // Ignore the current dir, e.g. `.` + Component::CurDir => {} + + // Go up a directory, e.g. `..` + Component::ParentDir => { + base.pop(); + } + + // Once we hit a component that contains a wildcard character, then we + // can't change the base anymore and we must move to the pattern part. + Component::Normal(part) if part.to_string_lossy().contains("*") => { + new_pattern.push(component); + stage = ComponentStage::Pattern; + } - // Contains dynamic part - let (static_part, dynamic_part) = split_pattern(&self.pattern); + // File or folder, but not the last component + Component::Normal(part) if components.peek().is_some() => { + base.push(part); + } - let base: PathBuf = self.base.clone().into(); - let base = match static_part { - Some(static_part) => { - // TODO: If the base does not exist on disk, try removing the last slash and try - // again. - match dunce::canonicalize(base.join(static_part)) { - Ok(base) => base, - Err(err) => { - event!(tracing::Level::ERROR, "Failed to resolve glob: {:?}", err); - return; + // Last file or folder. If it's a folder, we move it to the base, + // otherwise we move it to the pattern. + Component::Normal(part) => { + let full_path = base.join(part); + if full_path.is_dir() { + base.push(part); + + // Ensure we have a pattern since the last component is a + // directory. + new_pattern.push("**/*"); + } else { + new_pattern.push("/"); + new_pattern.push(part); + } + } + + Component::Prefix(_) | Component::RootDir => { + new_pattern.push(component); + stage = ComponentStage::Pattern; + } } } - } - None => base, - }; - - let pattern = match dynamic_part { - Some(dynamic_part) => dynamic_part, - None => { - if base.is_dir() { - "**/*".to_owned() - } else { - "".to_owned() + ComponentStage::Pattern => { + new_pattern.push(component); } } - }; + } + + // Ensure we have `**/*` when the base is a folder and we don't have a pattern at all + if new_pattern.as_os_str().is_empty() && base.is_dir() { + new_pattern.push("**/*"); + } self.base = base.to_string_lossy().to_string(); - self.pattern = pattern; + self.pattern = new_pattern.to_string_lossy().to_string(); } } From f5a49c78abd98cdaf57e4401fbff90d44e3fdeed Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sun, 7 Jun 2026 19:23:19 +0200 Subject: [PATCH 05/12] add `@source` order scanner failing test --- crates/oxide/tests/scanner.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/oxide/tests/scanner.rs b/crates/oxide/tests/scanner.rs index 9d45c8e2601a..993e7a8cd37d 100644 --- a/crates/oxide/tests/scanner.rs +++ b/crates/oxide/tests/scanner.rs @@ -556,6 +556,36 @@ mod scanner { assert_eq!(normalized_sources, vec!["**/*", "foo/bar/**/*"]); } + #[test] + fn it_should_preserve_source_order_when_referencing_a_sibling_project() { + // Create a temporary working directory + let dir = tempdir().unwrap().into_path(); + + // Create files + create_files_in( + &dir, + &[ + ("project-a/src/index.css", ""), + ("project-b/keep/keep.html", "content-['GOOD-1']"), + ("project-b/ignored/ignored.html", "content-['BAD-1']"), + ("project-b/ignored/except.html", "content-['GOOD-2']"), + ], + ); + + let base = dir.join("project-a/src"); + let mut scanner = Scanner::new(vec![ + public_source_entry_from_pattern(base.clone(), "@source '../../project-b'"), + public_source_entry_from_pattern(base.clone(), "@source not '../../project-b/ignored'"), + public_source_entry_from_pattern( + base.clone(), + "@source '../../project-b/ignored/except.html'", + ), + ]); + let candidates = scanner.scan(); + + assert_eq!(candidates, vec!["content-['GOOD-1']", "content-['GOOD-2']"]); + } + #[test] fn it_should_scan_files_without_extensions() { // These look like folders, but they are files From 81a5987d89d3925bd574d594a39fbbb9f056ec7f Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sun, 7 Jun 2026 16:47:38 +0200 Subject: [PATCH 06/12] ensure patterns order is the consistent with the `@source` order --- crates/oxide/src/scanner/mod.rs | 34 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/crates/oxide/src/scanner/mod.rs b/crates/oxide/src/scanner/mod.rs index a7f1f646176a..cb24700ad66d 100644 --- a/crates/oxide/src/scanner/mod.rs +++ b/crates/oxide/src/scanner/mod.rs @@ -17,7 +17,6 @@ use fxhash::{FxHashMap, FxHashSet}; use ignore::{gitignore::GitignoreBuilder, WalkBuilder}; use init_tracing::{init_tracing, SHOULD_TRACE}; use rayon::prelude::*; -use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::SystemTime; @@ -637,7 +636,16 @@ fn walk_parallel(walker: &mut WalkBuilder) -> Vec { fn create_walker(sources: &Sources) -> Option { let mut other_roots: FxHashSet<&PathBuf> = FxHashSet::default(); let mut first_root: Option<&PathBuf> = None; - let mut ignores: BTreeMap<&PathBuf, BTreeSet> = Default::default(); + + let mut ignores: Vec<(&PathBuf, Vec)> = Default::default(); + let mut emit = |base, pattern| match ignores.last_mut() { + Some((prev_base, patterns)) if *prev_base == base => { + patterns.push(pattern); + } + _ => { + ignores.push((base, vec![pattern])); + } + }; for source in sources.iter() { match source { @@ -664,19 +672,13 @@ fn create_walker(sources: &Sources) -> Option { } // Specific patterns should take precedence even over git-ignored files: - ignores - .entry(base) - .or_default() - .insert(format!("!{}", pattern)); + 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 - ignores - .entry(base) - .or_default() - .insert(format!("!*.{}", extension.to_string_lossy())); + emit(base, format!("!*.{}", extension.to_string_lossy())); } } } @@ -686,7 +688,7 @@ fn create_walker(sources: &Sources) -> Option { if !pattern.starts_with("/") { pattern = format!("/{pattern}"); } - ignores.entry(base).or_default().insert(pattern); + emit(base, pattern); } SourceEntry::External { base } => { if first_root.is_none() { @@ -696,16 +698,10 @@ fn create_walker(sources: &Sources) -> Option { } // External sources should take precedence even over git-ignored files: - ignores - .entry(base) - .or_default() - .insert(format!("!{}", "/**/*")); + emit(base, format!("!{}", "/**/*")); // External sources should still disallow binary extensions: - ignores - .entry(base) - .or_default() - .insert(BINARY_EXTENSIONS_GLOB.clone()); + emit(base, BINARY_EXTENSIONS_GLOB.clone()); } } } From 4c00827fada19801bcce5babb51aeb2b96f9ef8d Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sun, 7 Jun 2026 19:12:50 +0200 Subject: [PATCH 07/12] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bd4f34e6541..86f42c6b6468 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure class candidates are extracted from Twig `addClass(…)` and `removeClass(…)` calls ([#20198](https://github.com/tailwindlabs/tailwindcss/pull/20198)) - Don't crash in the Ruby or Vue preprocessors when scanning files containing invalid UTF-8 bytes ([#19588](https://github.com/tailwindlabs/tailwindcss/pull/19588)) - Allow `@variant` to be used inside `addBase` ([#19480](https://github.com/tailwindlabs/tailwindcss/pull/19480)) +- Ensure `@source` globs with symlinks are preserved ([#20203](https://github.com/tailwindlabs/tailwindcss/pull/20203)) ### Changed From c0f6178fd5f891f5663d0402415561b462315343 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sun, 7 Jun 2026 19:40:21 +0200 Subject: [PATCH 08/12] simplify optimization + move pinned pattern check (leading `/`) to the optimize step as well. --- crates/oxide/src/scanner/mod.rs | 14 ++------------ crates/oxide/src/scanner/sources.rs | 19 +++++++++---------- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/crates/oxide/src/scanner/mod.rs b/crates/oxide/src/scanner/mod.rs index cb24700ad66d..52f6bc0043ab 100644 --- a/crates/oxide/src/scanner/mod.rs +++ b/crates/oxide/src/scanner/mod.rs @@ -657,7 +657,7 @@ fn create_walker(sources: &Sources) -> Option { } } SourceEntry::Pattern { base, pattern } => { - let mut pattern = pattern.to_string(); + let pattern = pattern.to_owned(); if first_root.is_none() { first_root = Some(base); @@ -666,11 +666,6 @@ fn create_walker(sources: &Sources) -> Option { } if !pattern.contains("**") { - // Ensure that the pattern is pinned to the base path. - if !pattern.starts_with("/") { - pattern = format!("/{pattern}"); - } - // Specific patterns should take precedence even over git-ignored files: emit(base, format!("!{}", pattern)); } else { @@ -683,12 +678,7 @@ fn create_walker(sources: &Sources) -> Option { } } SourceEntry::Ignored { base, pattern } => { - let mut pattern = pattern.to_string(); - // Ensure that the pattern is pinned to the base path. - if !pattern.starts_with("/") { - pattern = format!("/{pattern}"); - } - emit(base, pattern); + emit(base, pattern.to_owned()); } SourceEntry::External { base } => { if first_root.is_none() { diff --git a/crates/oxide/src/scanner/sources.rs b/crates/oxide/src/scanner/sources.rs index 5fe721f6737f..d0c24d1954ef 100644 --- a/crates/oxide/src/scanner/sources.rs +++ b/crates/oxide/src/scanner/sources.rs @@ -144,12 +144,7 @@ impl PublicSourceEntry { let full_path = base.join(part); if full_path.is_dir() { base.push(part); - - // Ensure we have a pattern since the last component is a - // directory. - new_pattern.push("**/*"); } else { - new_pattern.push("/"); new_pattern.push(part); } } @@ -166,13 +161,17 @@ impl PublicSourceEntry { } } - // Ensure we have `**/*` when the base is a folder and we don't have a pattern at all - if new_pattern.as_os_str().is_empty() && base.is_dir() { - new_pattern.push("**/*"); - } - self.base = base.to_string_lossy().to_string(); self.pattern = new_pattern.to_string_lossy().to_string(); + + // Ensure we have `**/*` when the base is a folder and we don't have a pattern at all + if self.pattern == "" { + self.pattern = "/**/*".to_owned(); + } + // Ensure that the pattern is pinned to the base path. + else if !self.pattern.starts_with("/") { + self.pattern = format!("/{}", self.pattern); + } } } From 36df63b2cf999db54f9704e38ca35e81a8f83df7 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sun, 7 Jun 2026 19:56:40 +0200 Subject: [PATCH 09/12] ensure the `pattern` is in posix style --- crates/oxide/src/scanner/sources.rs | 37 ++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/oxide/src/scanner/sources.rs b/crates/oxide/src/scanner/sources.rs index d0c24d1954ef..364121063bec 100644 --- a/crates/oxide/src/scanner/sources.rs +++ b/crates/oxide/src/scanner/sources.rs @@ -162,7 +162,7 @@ impl PublicSourceEntry { } self.base = base.to_string_lossy().to_string(); - self.pattern = new_pattern.to_string_lossy().to_string(); + self.pattern = path_to_posix_string(&new_pattern); // Ensure we have `**/*` when the base is a folder and we don't have a pattern at all if self.pattern == "" { @@ -175,6 +175,41 @@ impl PublicSourceEntry { } } +fn path_to_posix_string(path: &Path) -> String { + let mut parts = Vec::new(); + let mut is_rooted = false; + + for component in path.components() { + match component { + Component::Prefix(prefix) => { + parts.push(prefix.as_os_str().to_string_lossy().to_string()); + } + Component::RootDir => { + is_rooted = true; + if parts.is_empty() { + parts.push(String::new()); + } + } + Component::CurDir => { + parts.push(".".to_string()); + } + Component::ParentDir => { + parts.push("..".to_string()); + } + Component::Normal(part) => { + parts.push(part.to_string_lossy().to_string()); + } + } + } + + let result = parts.join("/"); + if result.is_empty() && is_rooted { + "/".to_string() + } else { + result + } +} + /// For each public source entry: /// /// 1. Perform brace expansion From 506e6a9dca3c90b0d99ffe6da5e65145ebf0d40f Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sun, 7 Jun 2026 20:00:16 +0200 Subject: [PATCH 10/12] add a few tests --- crates/oxide/src/scanner/sources.rs | 96 +++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/crates/oxide/src/scanner/sources.rs b/crates/oxide/src/scanner/sources.rs index 364121063bec..09c6ebfa6a2d 100644 --- a/crates/oxide/src/scanner/sources.rs +++ b/crates/oxide/src/scanner/sources.rs @@ -210,6 +210,102 @@ fn path_to_posix_string(path: &Path) -> String { } } +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::fs; + use tempfile::tempdir; + + #[test] + fn path_to_posix_string_serializes_relative_paths() { + let path = PathBuf::from("src").join("**").join("*.html"); + + assert_eq!(path_to_posix_string(&path), "src/**/*.html"); + } + + #[test] + fn path_to_posix_string_serializes_rooted_paths() { + let path = PathBuf::from(std::path::MAIN_SEPARATOR.to_string()) + .join("src") + .join("**") + .join("*.html"); + + assert_eq!(path_to_posix_string(&path), "/src/**/*.html"); + } + + #[test] + fn path_to_posix_string_serializes_empty_paths() { + assert_eq!(path_to_posix_string(&PathBuf::new()), ""); + } + + #[test] + fn optimize_hoists_static_directories_and_keeps_files_in_the_pattern() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join("src").join("examples")).unwrap(); + + let mut source = PublicSourceEntry { + base: dir.path().to_string_lossy().to_string(), + pattern: "src/examples/index.html".to_string(), + negated: false, + }; + + source.optimize(); + + assert_eq!( + source.base, + dunce::canonicalize(dir.path().join("src").join("examples")) + .unwrap() + .to_string_lossy() + ); + assert_eq!(source.pattern, "/index.html"); + } + + #[test] + fn optimize_hoists_folder_patterns() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join("src").join("examples")).unwrap(); + + let mut source = PublicSourceEntry { + base: dir.path().to_string_lossy().to_string(), + pattern: "src/examples".to_string(), + negated: false, + }; + + source.optimize(); + + assert_eq!( + source.base, + dunce::canonicalize(dir.path().join("src").join("examples")) + .unwrap() + .to_string_lossy() + ); + assert_eq!(source.pattern, "/**/*"); + } + + #[test] + fn optimize_keeps_wildcards_in_the_pattern() { + 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/**/*.html".to_string(), + negated: false, + }; + + source.optimize(); + + assert_eq!( + source.base, + dunce::canonicalize(dir.path().join("src")) + .unwrap() + .to_string_lossy() + ); + assert_eq!(source.pattern, "/**/*.html"); + } +} + /// For each public source entry: /// /// 1. Perform brace expansion From 76fdf97ef4b2ecca409e69f07c8fda2088536171 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sun, 7 Jun 2026 20:27:22 +0200 Subject: [PATCH 11/12] handle absolute paths --- crates/oxide/src/scanner/sources.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/oxide/src/scanner/sources.rs b/crates/oxide/src/scanner/sources.rs index 09c6ebfa6a2d..4c9767b1ad77 100644 --- a/crates/oxide/src/scanner/sources.rs +++ b/crates/oxide/src/scanner/sources.rs @@ -149,9 +149,16 @@ impl PublicSourceEntry { } } - Component::Prefix(_) | Component::RootDir => { - new_pattern.push(component); - stage = ComponentStage::Pattern; + // When we're dealing with an absolute path, then we have to bypass the + // `base` entirely. + Component::Prefix(_) => { + base.clear(); + base.push(component); + } + Component::RootDir => { + #[cfg(not(windows))] + base.clear(); + base.push(component); } } } From 8522472d0270b1f6f67bbab4aae61810854fac3e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sun, 7 Jun 2026 20:28:46 +0200 Subject: [PATCH 12/12] udpate changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86f42c6b6468..0495d254af4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Don't crash in the Ruby or Vue preprocessors when scanning files containing invalid UTF-8 bytes ([#19588](https://github.com/tailwindlabs/tailwindcss/pull/19588)) - Allow `@variant` to be used inside `addBase` ([#19480](https://github.com/tailwindlabs/tailwindcss/pull/19480)) - Ensure `@source` globs with symlinks are preserved ([#20203](https://github.com/tailwindlabs/tailwindcss/pull/20203)) +- Ensure later `@source` rules can re-include files excluded by earlier `@source not` rules ([#20203](https://github.com/tailwindlabs/tailwindcss/pull/20203)) ### Changed