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)