diff --git a/CHANGELOG.md b/CHANGELOG.md
index a9e1e15394b3..bee7ca749dad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure adjacent rules are merged together after handling nesting when generating optimized CSS ([#14873](https://github.com/tailwindlabs/tailwindcss/pull/14873))
- _Upgrade (experimental)_: Install `@tailwindcss/postcss` next to `tailwindcss` ([#14830](https://github.com/tailwindlabs/tailwindcss/pull/14830))
- _Upgrade (experimental)_: Remove whitespace around `,` separator when print arbitrary values ([#14838](https://github.com/tailwindlabs/tailwindcss/pull/14838))
+- _Upgrade (experimental)_: Fix crash during upgrade when content globs escape root of project ([#14896](https://github.com/tailwindlabs/tailwindcss/pull/14896))
### Changed
diff --git a/integrations/upgrade/js-config.test.ts b/integrations/upgrade/js-config.test.ts
index bee8044cbad7..398402139853 100644
--- a/integrations/upgrade/js-config.test.ts
+++ b/integrations/upgrade/js-config.test.ts
@@ -1,3 +1,4 @@
+import path from 'node:path'
import { describe, expect } from 'vitest'
import { css, html, json, test, ts } from '../utils'
@@ -938,6 +939,98 @@ test(
},
)
+test(
+ 'migrate sources when pointing to folders outside the project root',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+
+ 'frontend/tailwind.config.ts': ts`
+ export default {
+ content: {
+ relative: true,
+ files: ['./src/**/*.html', '../backend/mails/**/*.blade.php'],
+ },
+ theme: {
+ extend: {
+ colors: {
+ primary: 'red',
+ },
+ },
+ },
+ }
+ `,
+ 'frontend/src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ @config "../tailwind.config.ts";
+ `,
+ 'frontend/src/index.html': html`
`,
+
+ 'backend/mails/welcome.blade.php': html``,
+ },
+ },
+ async ({ root, exec, fs }) => {
+ await exec('npx @tailwindcss/upgrade', {
+ cwd: path.join(root, 'frontend'),
+ })
+
+ expect(await fs.dumpFiles('frontend/**/*.css')).toMatchInlineSnapshot(`
+ "
+ --- frontend/src/input.css ---
+ @import 'tailwindcss';
+
+ @source '../../backend/mails/**/*.blade.php';
+
+ @theme {
+ --color-primary: red;
+ }
+
+ /*
+ The default border color has changed to \`currentColor\` in Tailwind CSS v4,
+ so we've added these compatibility styles to make sure everything still
+ looks the same as it did with Tailwind CSS v3.
+
+ If we ever want to remove these styles, we need to add an explicit border
+ color utility to any element that depends on these defaults.
+ */
+ @layer base {
+ *,
+ ::after,
+ ::before,
+ ::backdrop,
+ ::file-selector-button {
+ border-color: var(--color-gray-200, currentColor);
+ }
+ }
+
+ /*
+ Form elements have a 1px border by default in Tailwind CSS v4, so we've
+ added these compatibility styles to make sure everything still looks the
+ same as it did with Tailwind CSS v3.
+
+ If we ever want to remove these styles, we need to add \`border-0\` to
+ any form elements that shouldn't have a border.
+ */
+ @layer base {
+ input:where(:not([type='button'], [type='reset'], [type='submit'])),
+ select,
+ textarea {
+ border-width: 0;
+ }
+ }
+ "
+ `)
+ },
+)
+
describe('border compatibility', () => {
test(
'migrate border compatibility',
diff --git a/packages/@tailwindcss-upgrade/package.json b/packages/@tailwindcss-upgrade/package.json
index b57b7b0c6307..217824d8e546 100644
--- a/packages/@tailwindcss-upgrade/package.json
+++ b/packages/@tailwindcss-upgrade/package.json
@@ -29,6 +29,7 @@
"dependencies": {
"@tailwindcss/node": "workspace:^",
"@tailwindcss/oxide": "workspace:^",
+ "braces": "^3.0.3",
"dedent": "1.5.3",
"enhanced-resolve": "^5.17.1",
"globby": "^14.0.2",
@@ -44,6 +45,7 @@
"tree-sitter-typescript": "^0.23.0"
},
"devDependencies": {
+ "@types/braces": "^3.0.4",
"@types/node": "catalog:",
"@types/postcss-import": "^14.0.3"
}
diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts
index b2c9df5cc966..616ce65bb1a1 100644
--- a/packages/@tailwindcss-upgrade/src/index.ts
+++ b/packages/@tailwindcss-upgrade/src/index.ts
@@ -21,6 +21,7 @@ import { migrate as migrateTemplate } from './template/migrate'
import { prepareConfig } from './template/prepare-config'
import { args, type Arg } from './utils/args'
import { isRepoDirty } from './utils/git'
+import { hoistStaticGlobParts } from './utils/hoist-static-glob-parts'
import { pkg } from './utils/packages'
import { eprintln, error, header, highlight, info, success } from './utils/renderer'
@@ -143,11 +144,11 @@ async function run() {
info('Migrating templates using the provided configuration file.')
for (let config of configBySheet.values()) {
let set = new Set()
- for (let { pattern, base } of config.globs) {
- let files = await globby([pattern], {
+ for (let globEntry of config.globs.flatMap((entry) => hoistStaticGlobParts(entry))) {
+ let files = await globby([globEntry.pattern], {
absolute: true,
gitignore: true,
- cwd: base,
+ cwd: globEntry.base,
})
for (let file of files) {
diff --git a/packages/@tailwindcss-upgrade/src/utils/hoist-static-glob-parts.test.ts b/packages/@tailwindcss-upgrade/src/utils/hoist-static-glob-parts.test.ts
new file mode 100644
index 000000000000..1e6d8b1e861e
--- /dev/null
+++ b/packages/@tailwindcss-upgrade/src/utils/hoist-static-glob-parts.test.ts
@@ -0,0 +1,48 @@
+import { expect, it } from 'vitest'
+import { hoistStaticGlobParts } from './hoist-static-glob-parts'
+
+it.each([
+ // A basic glob
+ [
+ { base: '/projects/project-a', pattern: './src/**/*.html' },
+ [{ base: '/projects/project-a/src', pattern: '**/*.html' }],
+ ],
+
+ // A glob pointing to a folder should result in `**/*`
+ [
+ { base: '/projects/project-a', pattern: './src' },
+ [{ base: '/projects/project-a/src', pattern: '**/*' }],
+ ],
+
+ // A glob pointing to a file, should result in the file as the pattern
+ [
+ { base: '/projects/project-a', pattern: './src/index.html' },
+ [{ base: '/projects/project-a/src', pattern: 'index.html' }],
+ ],
+
+ // A glob going up a directory, should result in the new directory as the base
+ [
+ { base: '/projects/project-a', pattern: '../project-b/src/**/*.html' },
+ [{ base: '/projects/project-b/src', pattern: '**/*.html' }],
+ ],
+
+ // A glob with curlies, should be expanded to multiple globs
+ [
+ { base: '/projects/project-a', pattern: '../project-{b,c}/src/**/*.html' },
+ [
+ { base: '/projects/project-b/src', pattern: '**/*.html' },
+ { base: '/projects/project-c/src', pattern: '**/*.html' },
+ ],
+ ],
+ [
+ { base: '/projects/project-a', pattern: '../project-{b,c}/src/**/*.{js,html}' },
+ [
+ { base: '/projects/project-b/src', pattern: '**/*.js' },
+ { base: '/projects/project-b/src', pattern: '**/*.html' },
+ { base: '/projects/project-c/src', pattern: '**/*.js' },
+ { base: '/projects/project-c/src', pattern: '**/*.html' },
+ ],
+ ],
+])('should hoist the static parts of the glob: %s', (input, output) => {
+ expect(hoistStaticGlobParts(input)).toEqual(output)
+})
diff --git a/packages/@tailwindcss-upgrade/src/utils/hoist-static-glob-parts.ts b/packages/@tailwindcss-upgrade/src/utils/hoist-static-glob-parts.ts
new file mode 100644
index 000000000000..0c8ab4629fd6
--- /dev/null
+++ b/packages/@tailwindcss-upgrade/src/utils/hoist-static-glob-parts.ts
@@ -0,0 +1,79 @@
+import braces from 'braces'
+import path from 'node:path'
+
+interface GlobEntry {
+ base: string
+ pattern: string
+}
+
+export function hoistStaticGlobParts(entry: GlobEntry): GlobEntry[] {
+ return braces(entry.pattern, { expand: true }).map((pattern) => {
+ let clone = { ...entry }
+ let [staticPart, dynamicPart] = splitPattern(pattern)
+
+ // Move static part into the `base`.
+ if (staticPart !== null) {
+ clone.base = path.resolve(entry.base, staticPart)
+ } else {
+ clone.base = path.resolve(entry.base)
+ }
+
+ // Move dynamic part into the `pattern`.
+ if (dynamicPart === null) {
+ clone.pattern = '**/*'
+ } else {
+ clone.pattern = dynamicPart
+ }
+
+ // If the pattern looks like a file, move the file name from the `base` to
+ // the `pattern`.
+ let file = path.basename(clone.base)
+ if (file.includes('.')) {
+ clone.pattern = file
+ clone.base = path.dirname(clone.base)
+ }
+
+ return clone
+ })
+}
+
+// Split a glob pattern into a `static` and `dynamic` part.
+//
+// Assumption: we assume that all globs are expanded, which means that the only
+// dynamic parts are using `*`.
+//
+// E.g.:
+// Original input: `../project-b/**/*.{html,js}`
+// Expanded input: `../project-b/**/*.html` & `../project-b/**/*.js`
+// Split on first input: ("../project-b", "**/*.html")
+// Split on second input: ("../project-b", "**/*.js")
+function splitPattern(pattern: string): [staticPart: string | null, dynamicPart: string | null] {
+ // No dynamic parts, so we can just return the input as-is.
+ if (!pattern.includes('*')) {
+ return [pattern, null]
+ }
+
+ let lastSlashPosition: number | null = null
+
+ for (let i = 0; i < pattern.length; i++) {
+ let c = pattern[i];
+ if (c === '/') {
+ lastSlashPosition = i
+ }
+
+ if (c === '*' || c === '!') {
+ break
+ }
+ }
+
+ // Very first character is a `*`, therefore there is no static part, only a
+ // dynamic part.
+ if (lastSlashPosition === null) {
+ return [null, pattern]
+ }
+
+ let staticPart = pattern.slice(0, lastSlashPosition).trim()
+ let dynamicPart = pattern.slice(lastSlashPosition + 1).trim()
+
+ return [staticPart || null, dynamicPart || null]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8505e5b5c405..4545fe4cddc4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -10,7 +10,7 @@ catalogs:
specifier: ^20.14.8
version: 20.14.13
lightningcss:
- specifier: ^1.26.0
+ specifier: ^1.28.1
version: 1.26.0
vite:
specifier: ^5.4.0
@@ -284,6 +284,9 @@ importers:
'@tailwindcss/oxide':
specifier: workspace:^
version: link:../../crates/node
+ braces:
+ specifier: ^3.0.3
+ version: 3.0.3
dedent:
specifier: 1.5.3
version: 1.5.3
@@ -324,6 +327,9 @@ importers:
specifier: ^0.23.0
version: 0.23.0(tree-sitter@0.22.0)
devDependencies:
+ '@types/braces':
+ specifier: ^3.0.4
+ version: 3.0.4
'@types/node':
specifier: 'catalog:'
version: 20.14.13
@@ -1304,6 +1310,9 @@ packages:
'@types/babel__traverse@7.20.6':
resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==}
+ '@types/braces@3.0.4':
+ resolution: {integrity: sha512-0WR3b8eaISjEW7RpZnclONaLFDf7buaowRHdqLp4vLj54AsSAYWfh3DRbfiYJY9XDxMgx1B4sE1Afw2PGpuHOA==}
+
'@types/bun@1.1.11':
resolution: {integrity: sha512-0N7D/H/8sbf9JMkaG5F3+I/cB4TlhKTkO9EskEWP8XDr8aVcDe4EywSnU4cnyZy6tar1dq70NeFNkqMEUigthw==}
@@ -4120,6 +4129,8 @@ snapshots:
dependencies:
'@babel/types': 7.25.2
+ '@types/braces@3.0.4': {}
+
'@types/bun@1.1.11':
dependencies:
bun-types: 1.1.30