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