diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ee4ea3aa1be..3b5434b0467c 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 - _Upgrade (experimental)_: Ensure CSS before a layer stays unlayered when running codemods ([#14596](https://github.com/tailwindlabs/tailwindcss/pull/14596)) - _Upgrade (experimental)_: Resolve issues where some prefixed candidates were not properly migrated ([#14600](https://github.com/tailwindlabs/tailwindcss/pull/14600)) - _Upgrade (experimental)_: Migrate `@media screen(…)` when running codemods ([#14603](https://github.com/tailwindlabs/tailwindcss/pull/14603)) +- _Upgrade (experimental)_: Inject `@config "…"` when a `tailwind.config.{js,ts,…}` is detected ([#14635](https://github.com/tailwindlabs/tailwindcss/pull/14635)) ## [4.0.0-alpha.26] - 2024-10-03 diff --git a/integrations/upgrade/index.test.ts b/integrations/upgrade/index.test.ts index 8835c5483f36..b529d7a6299e 100644 --- a/integrations/upgrade/index.test.ts +++ b/integrations/upgrade/index.test.ts @@ -39,7 +39,9 @@ test(
--- ./src/input.css --- - @import 'tailwindcss';" + @import 'tailwindcss'; + @config "../tailwind.config.js"; + " `) let packageJsonContent = await fs.read('package.json') @@ -95,9 +97,12 @@ test( --- ./src/input.css --- @import 'tailwindcss' prefix(tw); + @config "../tailwind.config.js"; + .btn { @apply tw:rounded-md! tw:px-2 tw:py-1 tw:bg-blue-500 tw:text-white; - }" + } + " `) }, ) @@ -140,6 +145,8 @@ test( --- ./src/index.css --- @import 'tailwindcss'; + @config "../tailwind.config.js"; + .a { @apply flex; } @@ -150,7 +157,8 @@ test( .c { @apply flex! flex-col! items-center!; - }" + } + " `) }, ) @@ -193,6 +201,8 @@ test( --- ./src/index.css --- @import 'tailwindcss'; + @config "../tailwind.config.js"; + @layer base { html { color: #333; @@ -203,7 +213,8 @@ test( .btn { color: red; } - }" + } + " `) }, ) @@ -251,6 +262,8 @@ test( --- ./src/index.css --- @import 'tailwindcss'; + @config "../tailwind.config.js"; + @utility btn { @apply rounded-md px-2 py-1 bg-blue-500 text-white; } @@ -261,7 +274,8 @@ test( } -ms-overflow-style: none; scrollbar-width: none; - }" + } + " `) }, ) @@ -533,7 +547,8 @@ test(
--- ./src/other.html --- -
" +
+ " `) }, ) @@ -573,7 +588,8 @@ test(
--- ./src/other.html --- -
" +
+ " `) }, ) @@ -615,6 +631,7 @@ test( --- ./src/index.css --- @import 'tailwindcss'; @import './utilities.css'; + @config "../tailwind.config.js"; --- ./src/utilities.css --- @utility no-scrollbar { @@ -623,7 +640,8 @@ test( } -ms-overflow-style: none; scrollbar-width: none; - }" + } + " `) }, ) @@ -730,6 +748,7 @@ test( @import './c.1.css' layer(utilities); @import './c.1.utilities.css'; @import './d.1.css'; + @config "../tailwind.config.js"; --- ./src/a.1.css --- @import './a.1.utilities.css' @@ -804,7 +823,8 @@ test( --- ./src/d.4.css --- @utility from-a-4 { color: blue; - }" + } + " `) }, ) @@ -862,14 +882,141 @@ test( --- ./src/root.1.css --- @import 'tailwindcss/utilities' layer(utilities); @import './a.1.css' layer(utilities); + @config "../tailwind.config.js"; --- ./src/root.2.css --- @import 'tailwindcss/utilities' layer(utilities); @import './a.1.css' layer(components); + @config "../tailwind.config.js"; + + --- ./src/root.3.css --- + @import 'tailwindcss/utilities' layer(utilities); + @import './a.1.css' layer(utilities); + @config "../tailwind.config.js"; + " + `) + }, +) + +test( + 'injecting `@config` when a tailwind.config.{js,ts,…} is detected', + { + fs: { + 'package.json': json` + { + "dependencies": { + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'tailwind.config.ts': js` + export default { + content: ['./src/**/*.{html,js}'], + } + `, + 'src/index.html': html` +

🤠👋

+
+ `, + 'src/root.1.css': css` + /* Inject missing @config */ + @tailwind base; + @tailwind components; + @tailwind utilities; + `, + 'src/root.2.css': css` + /* Already contains @config */ + @tailwind base; + @tailwind components; + @tailwind utilities; + @config "../tailwind.config.js"; + `, + 'src/root.3.css': css` + /* Inject missing @config above first @theme */ + @tailwind base; + @tailwind components; + @tailwind utilities; + + @variant hocus (&:hover, &:focus); + + @theme { + --color-red-500: #f00; + } + + @theme { + --color-blue-500: #00f; + } + `, + 'src/root.4.css': css` + /* Inject missing @config due to nested imports with tailwind imports */ + @import './root.4/base.css'; + @import './root.4/utilities.css'; + `, + 'src/root.4/base.css': css`@import 'tailwindcss/base';`, + 'src/root.4/utilities.css': css`@import 'tailwindcss/utilities';`, + + 'src/root.5.css': css`@import './root.5/tailwind.css';`, + 'src/root.5/tailwind.css': css` + /* Inject missing @config in this file, due to full import */ + @import 'tailwindcss'; + `, + }, + }, + async ({ exec, fs }) => { + await exec('npx @tailwindcss/upgrade --force') + + expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` + " + --- ./src/index.html --- +

🤠👋

+
+ + --- ./src/root.1.css --- + /* Inject missing @config */ + @import 'tailwindcss'; + @config "../tailwind.config.ts"; + + --- ./src/root.2.css --- + /* Already contains @config */ + @import 'tailwindcss'; + @config "../tailwind.config.js"; --- ./src/root.3.css --- + /* Inject missing @config above first @theme */ + @import 'tailwindcss'; + @config "../tailwind.config.ts"; + + @variant hocus (&:hover, &:focus); + + @theme { + --color-red-500: #f00; + } + + @theme { + --color-blue-500: #00f; + } + + --- ./src/root.4.css --- + /* Inject missing @config due to nested imports with tailwind imports */ + @import './root.4/base.css'; + @import './root.4/utilities.css'; + @config "../tailwind.config.ts"; + + --- ./src/root.5.css --- + @import './root.5/tailwind.css'; + + --- ./src/root.4/base.css --- + @import 'tailwindcss/theme' layer(theme); + @import 'tailwindcss/preflight' layer(base); + + --- ./src/root.4/utilities.css --- @import 'tailwindcss/utilities' layer(utilities); - @import './a.1.css' layer(utilities);" + + --- ./src/root.5/tailwind.css --- + /* Inject missing @config in this file, due to full import */ + @import 'tailwindcss'; + @config "../../tailwind.config.ts"; + " `) }, ) diff --git a/integrations/utils.ts b/integrations/utils.ts index ce700bce7f7a..2afc35ed038a 100644 --- a/integrations/utils.ts +++ b/integrations/utils.ts @@ -330,7 +330,8 @@ export function test( return a[0].localeCompare(z[0]) }) .map(([file, content]) => `--- ${file} ---\n${content || ''}`) - .join('\n\n')}` + .join('\n\n') + .trim()}\n` }, async expectFileToContain(filePath, contents) { return retryAssertion(async () => { diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-config.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-config.ts new file mode 100644 index 000000000000..4e1f10663b27 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-config.ts @@ -0,0 +1,101 @@ +import path from 'node:path' +import { AtRule, type Plugin, type Root } from 'postcss' +import { normalizePath } from '../../../@tailwindcss-node/src/normalize-path' +import type { Stylesheet } from '../stylesheet' +import { walk, WalkAction } from '../utils/walk' + +export function migrateAtConfig( + sheet: Stylesheet, + { configFilePath }: { configFilePath: string }, +): Plugin { + function injectInto(sheet: Stylesheet) { + let root = sheet.root + + // We don't have a sheet with a file path + if (!sheet.file) return + + // Skip if there is already a `@config` directive + { + let hasConfig = false + root.walkAtRules('config', () => { + hasConfig = true + return false + }) + if (hasConfig) return + } + + // Figure out the path to the config file + let sheetPath = sheet.file + let configPath = configFilePath + + let relative = path.relative(path.dirname(sheetPath), configPath) + if (relative[0] !== '.') { + relative = `./${relative}` + } + // Ensure relative is a posix style path since we will merge it with the + // glob. + relative = normalizePath(relative) + + // Inject the `@config` in a sensible place + // 1. Below the last `@import` + // 2. At the top of the file + let locationNode = null as AtRule | null + + walk(root, (node) => { + if (node.type === 'atrule' && node.name === 'import') { + locationNode = node + } + + return WalkAction.Skip + }) + + let configNode = new AtRule({ name: 'config', params: `"${relative}"` }) + + if (!locationNode) { + root.prepend(configNode) + } else if (locationNode.name === 'import') { + locationNode.after(configNode) + } + } + + function migrate(root: Root) { + // We can only migrate if there is an `@import "tailwindcss"` (or sub-import) + let hasTailwindImport = false + let hasFullTailwindImport = false + root.walkAtRules('import', (node) => { + if (node.params.match(/['"]tailwindcss['"]/)) { + hasTailwindImport = true + hasFullTailwindImport = true + return false + } else if (node.params.match(/['"]tailwindcss\/.*?['"]/)) { + hasTailwindImport = true + } + }) + + if (!hasTailwindImport) return + + // - If a full `@import "tailwindcss"` is present, we can inject the + // `@config` directive directly into this stylesheet. + // - If we are the root file (no parents), then we can inject the `@config` + // directive directly into this file as well. + if (hasFullTailwindImport || sheet.parents.size <= 0) { + injectInto(sheet) + return + } + + // Otherwise, if we are not the root file, we need to inject the `@config` + // into the root file. + if (sheet.parents.size > 0) { + for (let parent of sheet.ancestors()) { + if (parent.parents.size === 0) { + injectInto(parent) + } + } + } + } + + return { + postcssPlugin: '@tailwindcss/upgrade/migrate-at-config', + OnceExit: migrate, + } +} diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.ts index 6d3de9fe6c33..35050b932968 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.ts @@ -10,7 +10,13 @@ export function migrateMissingLayers(): Plugin { root.each((node) => { if (node.type === 'atrule') { // Known Tailwind directives that should not be inside a layer. - if (node.name === 'theme' || node.name === 'utility') { + if ( + node.name === 'config' || + node.name === 'source' || + node.name === 'theme' || + node.name === 'utility' || + node.name === 'variant' + ) { if (bucket.length > 0) { buckets.push([lastLayer, bucket.splice(0)]) } diff --git a/packages/@tailwindcss-upgrade/src/index.test.ts b/packages/@tailwindcss-upgrade/src/index.test.ts index fb5457774f88..d4763d375ee8 100644 --- a/packages/@tailwindcss-upgrade/src/index.test.ts +++ b/packages/@tailwindcss-upgrade/src/index.test.ts @@ -1,5 +1,6 @@ import { __unstable__loadDesignSystem } from '@tailwindcss/node' import dedent from 'dedent' +import path from 'node:path' import postcss from 'postcss' import { expect, it } from 'vitest' import { formatNodes } from './codemods/format-nodes' @@ -13,7 +14,13 @@ let designSystem = await __unstable__loadDesignSystem( `, { base: __dirname }, ) -let config = { designSystem, userConfig: {}, newPrefix: null } + +let config = { + designSystem, + userConfig: {}, + newPrefix: null, + configFilePath: path.resolve(__dirname, './tailwind.config.js'), +} function migrate(input: string, config: any) { return migrateContents(input, config, expect.getState().testPath) @@ -87,6 +94,8 @@ it('should migrate a stylesheet', async () => { ).toMatchInlineSnapshot(` "@import 'tailwindcss'; + @config "./tailwind.config.js"; + @layer base { html { overflow: hidden; @@ -138,7 +147,8 @@ it('should migrate a stylesheet (with imports)', async () => { "@import 'tailwindcss'; @import './my-base.css' layer(base); @import './my-components.css' layer(components); - @import './my-utilities.css' layer(utilities);" + @import './my-utilities.css' layer(utilities); + @config "./tailwind.config.js";" `) }) @@ -163,6 +173,7 @@ it('should migrate a stylesheet (with preceding rules that should be wrapped in @layer foo, bar, baz; /**! My license comment */ @import 'tailwindcss'; + @config "./tailwind.config.js"; @layer base { html { color: red; diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index 0b32cace52c0..7ce0c1bb1f6a 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -5,6 +5,7 @@ import type { DesignSystem } from '../../tailwindcss/src/design-system' import { DefaultMap } from '../../tailwindcss/src/utils/default-map' import { segment } from '../../tailwindcss/src/utils/segment' import { migrateAtApply } from './codemods/migrate-at-apply' +import { migrateAtConfig } from './codemods/migrate-at-config' import { migrateAtLayerUtilities } from './codemods/migrate-at-layer-utilities' import { migrateMediaScreen } from './codemods/migrate-media-screen' import { migrateMissingLayers } from './codemods/migrate-missing-layers' @@ -17,6 +18,7 @@ export interface MigrateOptions { newPrefix: string | null designSystem: DesignSystem userConfig: Config + configFilePath: string } export async function migrateContents( @@ -35,6 +37,7 @@ export async function migrateContents( .use(migrateAtLayerUtilities(stylesheet)) .use(migrateMissingLayers()) .use(migrateTailwindDirectives(options)) + .use(migrateAtConfig(stylesheet, options)) .process(stylesheet.root, { from: stylesheet.file ?? undefined }) } diff --git a/packages/@tailwindcss-upgrade/src/template/prepare-config.ts b/packages/@tailwindcss-upgrade/src/template/prepare-config.ts index c9667f455352..b76f8990b2c5 100644 --- a/packages/@tailwindcss-upgrade/src/template/prepare-config.ts +++ b/packages/@tailwindcss-upgrade/src/template/prepare-config.ts @@ -22,6 +22,7 @@ export async function prepareConfig( designSystem: DesignSystem globs: { base: string; pattern: string }[] userConfig: Config + configFilePath: string newPrefix: string | null }> { @@ -57,7 +58,13 @@ export async function prepareConfig( __unstable__loadDesignSystem(input, { base: __dirname }), ]) - return { designSystem, globs: compiler.globs, userConfig, newPrefix } + return { + designSystem, + globs: compiler.globs, + userConfig, + newPrefix, + configFilePath: fullConfigPath, + } } catch (e: any) { error('Could not load the configuration file: ' + e.message) process.exit(1)