diff --git a/CHANGELOG.md b/CHANGELOG.md
index 88314a4ffc0f..a5d28326e01b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- _Upgrade (experimental)_: Migrate v3 PostCSS setups to v4 in some cases ([#14612](https://github.com/tailwindlabs/tailwindcss/pull/14612))
- _Upgrade (experimental)_: The upgrade tool now automatically discovers your JavaScript config ([#14597](https://github.com/tailwindlabs/tailwindcss/pull/14597))
- _Upgrade (experimental)_: Migrate legacy classes to the v4 alternative ([#14643](https://github.com/tailwindlabs/tailwindcss/pull/14643))
+- _Upgrade (experimental)_: Fully convert simple JS configs to CSS ([#14639](https://github.com/tailwindlabs/tailwindcss/pull/14639))
### Fixed
diff --git a/integrations/upgrade/index.test.ts b/integrations/upgrade/index.test.ts
index 195c5e20d422..97409d53d4e5 100644
--- a/integrations/upgrade/index.test.ts
+++ b/integrations/upgrade/index.test.ts
@@ -40,7 +40,8 @@ test(
--- ./src/input.css ---
@import 'tailwindcss';
- @config '../tailwind.config.js';
+
+ @source './**/*.{html,js}';
"
`)
@@ -71,8 +72,9 @@ test(
}
`,
'src/index.html': html`
-
🤠👋
-
+
`,
'src/input.css': css`
@tailwind base;
@@ -91,13 +93,14 @@ test(
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
--- ./src/index.html ---
- 🤠👋
-
+
--- ./src/input.css ---
@import 'tailwindcss' prefix(tw);
- @config '../tailwind.config.js';
+ @source './**/*.{html,js}';
.btn {
@apply tw:rounded-md! tw:px-2 tw:py-1 tw:bg-blue-500 tw:text-white;
@@ -145,8 +148,6 @@ test(
--- ./src/index.css ---
@import 'tailwindcss';
- @config '../tailwind.config.js';
-
.a {
@apply flex;
}
@@ -201,8 +202,6 @@ test(
--- ./src/index.css ---
@import 'tailwindcss';
- @config '../tailwind.config.js';
-
@layer base {
html {
color: #333;
@@ -262,8 +261,6 @@ test(
--- ./src/index.css ---
@import 'tailwindcss';
- @config '../tailwind.config.js';
-
@utility btn {
@apply rounded-md px-2 py-1 bg-blue-500 text-white;
}
@@ -631,7 +628,6 @@ test(
--- ./src/index.css ---
@import 'tailwindcss';
@import './utilities.css';
- @config '../tailwind.config.js';
--- ./src/utilities.css ---
@utility no-scrollbar {
@@ -748,7 +744,6 @@ 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'
@@ -882,17 +877,14 @@ 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';
"
`)
},
@@ -912,11 +904,17 @@ test(
'tailwind.config.ts': js`
export default {
content: ['./src/**/*.{html,js}'],
+ plugins: [
+ () => {
+ // custom stuff which is too complicated to migrate to CSS
+ },
+ ],
}
`,
'src/index.html': html`
- 🤠👋
-
+
`,
'src/root.1.css': css`
/* Inject missing @config */
@@ -968,8 +966,9 @@ test(
expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(`
"
--- ./src/index.html ---
- 🤠👋
-
+
--- ./src/root.1.css ---
/* Inject missing @config */
diff --git a/integrations/upgrade/js-config.test.ts b/integrations/upgrade/js-config.test.ts
new file mode 100644
index 000000000000..5ef1f55b2bb4
--- /dev/null
+++ b/integrations/upgrade/js-config.test.ts
@@ -0,0 +1,152 @@
+import { expect } from 'vitest'
+import { css, json, test, ts } from '../utils'
+
+test(
+ `upgrades a simple JS config file to CSS`,
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+ import defaultTheme from 'tailwindcss/defaultTheme'
+
+ module.exports = {
+ darkMode: 'selector',
+ content: ['./src/**/*.{html,js}', './my-app/**/*.{html,js}'],
+ theme: {
+ boxShadow: {
+ sm: '0 2px 6px rgb(15 23 42 / 0.08)',
+ },
+ colors: {
+ red: {
+ 400: '#f87171',
+ 500: 'red',
+ },
+ },
+ fontSize: {
+ xs: ['0.75rem', { lineHeight: '1rem' }],
+ sm: ['0.875rem', { lineHeight: '1.5rem' }],
+ base: ['1rem', { lineHeight: '2rem' }],
+ },
+ extend: {
+ colors: {
+ red: {
+ 500: '#ef4444',
+ 600: '#dc2626',
+ },
+ },
+ fontFamily: {
+ sans: 'Inter, system-ui, sans-serif',
+ display: ['Cabinet Grotesk', ...defaultTheme.fontFamily.sans],
+ },
+ borderRadius: {
+ '4xl': '2rem',
+ },
+ },
+ },
+ plugins: [],
+ } satisfies Config
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ },
+ },
+ async ({ exec, fs }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
+ "
+ --- src/input.css ---
+ @import 'tailwindcss';
+
+ @source './**/*.{html,js}';
+ @source '../my-app/**/*.{html,js}';
+
+ @variant dark (&:where(.dark, .dark *));
+
+ @theme {
+ --box-shadow-*: initial;
+ --box-shadow-sm: 0 2px 6px rgb(15 23 42 / 0.08);
+
+ --color-*: initial;
+ --color-red-400: #f87171;
+ --color-red-500: #ef4444;
+ --color-red-600: #dc2626;
+
+ --font-size-*: initial;
+ --font-size-xs: 0.75rem;
+ --font-size-xs--line-height: 1rem;
+ --font-size-sm: 0.875rem;
+ --font-size-sm--line-height: 1.5rem;
+ --font-size-base: 1rem;
+ --font-size-base--line-height: 2rem;
+
+ --font-family-sans: Inter, system-ui, sans-serif;
+ --font-family-display: Cabinet Grotesk, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+
+ --radius-4xl: 2rem;
+ }
+ "
+ `)
+
+ expect((await fs.dumpFiles('tailwind.config.ts')).trim()).toBe('')
+ },
+)
+
+test(
+ `does not upgrade a complex JS config file to CSS`,
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "@tailwindcss/upgrade": "workspace:^"
+ }
+ }
+ `,
+ 'tailwind.config.ts': ts`
+ import { type Config } from 'tailwindcss'
+
+ export default {
+ plugins: [function complexConfig() {}],
+ } satisfies Config
+ `,
+ 'src/input.css': css`
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
+ `,
+ },
+ },
+ async ({ exec, fs }) => {
+ await exec('npx @tailwindcss/upgrade')
+
+ expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
+ "
+ --- src/input.css ---
+ @import 'tailwindcss';
+ @config '../tailwind.config.ts';
+ "
+ `)
+
+ expect(await fs.dumpFiles('tailwind.config.ts')).toMatchInlineSnapshot(`
+ "
+ --- tailwind.config.ts ---
+ import { type Config } from 'tailwindcss'
+
+ export default {
+ plugins: [function complexConfig() {}],
+ } satisfies Config
+ "
+ `)
+ },
+)
diff --git a/integrations/utils.ts b/integrations/utils.ts
index 2afc35ed038a..d9242ab0425f 100644
--- a/integrations/utils.ts
+++ b/integrations/utils.ts
@@ -75,7 +75,7 @@ export function test(
) {
return (only || (!process.env.CI && debug) ? defaultTest.only : defaultTest)(
name,
- { timeout: TEST_TIMEOUT, retry: debug || only ? 0 : 3 },
+ { timeout: TEST_TIMEOUT, retry: process.env.CI ? 2 : 0 },
async (options) => {
let rootDir = debug ? path.join(REPO_ROOT, '.debug') : TMP_ROOT
await fs.mkdir(rootDir, { recursive: true })
diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-config.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-config.ts
deleted file mode 100644
index 6cfc6fd563c7..000000000000
--- a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-config.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-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-config.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-config.ts
new file mode 100644
index 000000000000..398f612e2514
--- /dev/null
+++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-config.ts
@@ -0,0 +1,140 @@
+import path from 'node:path'
+import postcss, { AtRule, type Plugin, Root } from 'postcss'
+import { normalizePath } from '../../../@tailwindcss-node/src/normalize-path'
+import type { JSConfigMigration } from '../migrate-js-config'
+import type { Stylesheet } from '../stylesheet'
+import { walk, WalkAction } from '../utils/walk'
+
+const ALREADY_INJECTED = new WeakMap()
+
+export function migrateConfig(
+ sheet: Stylesheet,
+ {
+ configFilePath,
+ jsConfigMigration,
+ }: { configFilePath: string; jsConfigMigration: JSConfigMigration },
+): Plugin {
+ function injectInto(sheet: Stylesheet) {
+ let alreadyInjected = ALREADY_INJECTED.get(sheet)
+ if (alreadyInjected && alreadyInjected.includes(configFilePath)) {
+ return
+ } else if (alreadyInjected) {
+ alreadyInjected.push(configFilePath)
+ } else {
+ ALREADY_INJECTED.set(sheet, [configFilePath])
+ }
+
+ let root = sheet.root
+
+ // We don't have a sheet with a file path
+ if (!sheet.file) return
+
+ let cssConfig = new AtRule()
+ cssConfig.raws.tailwind_pretty = true
+
+ if (jsConfigMigration === null) {
+ // Skip if there is already a `@config` directive
+ {
+ let hasConfig = false
+ root.walkAtRules('config', () => {
+ hasConfig = true
+ return false
+ })
+ if (hasConfig) return
+ }
+
+ cssConfig.append(
+ new AtRule({
+ name: 'config',
+ params: `'${relativeToStylesheet(sheet, configFilePath)}'`,
+ }),
+ )
+ } else {
+ let css = '\n\n'
+ for (let source of jsConfigMigration.sources) {
+ let absolute = path.resolve(source.base, source.pattern)
+ css += `@source '${relativeToStylesheet(sheet, absolute)}';\n`
+ }
+
+ if (jsConfigMigration.sources.length > 0) {
+ css = css + '\n'
+ }
+
+ cssConfig.append(postcss.parse(css + jsConfigMigration.css))
+ }
+
+ // 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
+ })
+
+ if (!locationNode) {
+ root.prepend(cssConfig.nodes)
+ } else if (locationNode.name === 'import') {
+ locationNode.after(cssConfig.nodes)
+ }
+ }
+
+ 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-config',
+ OnceExit: migrate,
+ }
+}
+
+function relativeToStylesheet(sheet: Stylesheet, absolute: string) {
+ if (!sheet.file) throw new Error('Can not find a path for the stylesheet')
+
+ let sheetPath = sheet.file
+
+ let relative = path.relative(path.dirname(sheetPath), absolute)
+ if (relative[0] !== '.') {
+ relative = `./${relative}`
+ }
+ // Ensure relative is a posix style path since we will merge it with the
+ // glob.
+ return normalizePath(relative)
+}
diff --git a/packages/@tailwindcss-upgrade/src/index.test.ts b/packages/@tailwindcss-upgrade/src/index.test.ts
index e45507e70219..655eae25bbde 100644
--- a/packages/@tailwindcss-upgrade/src/index.test.ts
+++ b/packages/@tailwindcss-upgrade/src/index.test.ts
@@ -20,6 +20,7 @@ let config = {
userConfig: {},
newPrefix: null,
configFilePath: path.resolve(__dirname, './tailwind.config.js'),
+ jsConfigMigration: null,
}
function migrate(input: string, config: any) {
diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts
index 6bf078df4962..2c2973c4a28a 100644
--- a/packages/@tailwindcss-upgrade/src/index.ts
+++ b/packages/@tailwindcss-upgrade/src/index.ts
@@ -11,6 +11,7 @@ import {
migrate as migrateStylesheet,
split as splitStylesheets,
} from './migrate'
+import { migrateJsConfig } from './migrate-js-config'
import { migratePostCSSConfig } from './migrate-postcss'
import { Stylesheet } from './stylesheet'
import { migrate as migrateTemplate } from './template/migrate'
@@ -37,6 +38,8 @@ if (flags['--help']) {
}
async function run() {
+ let base = process.cwd()
+
eprintln(header())
eprintln()
@@ -50,7 +53,7 @@ async function run() {
}
}
- let config = await prepareConfig(flags['--config'], { base: process.cwd() })
+ let config = await prepareConfig(flags['--config'], { base })
{
// Template migrations
@@ -81,11 +84,16 @@ async function run() {
success('Template migration complete.')
}
+ // Migrate JS config
+
+ info('Migrating JavaScript configuration files using the provided configuration file.')
+ let jsConfigMigration = await migrateJsConfig(config.configFilePath, base)
+
{
// Stylesheet migrations
// Use provided files
- let files = flags._.map((file) => path.resolve(process.cwd(), file))
+ let files = flags._.map((file) => path.resolve(base, file))
// Discover CSS files in case no files were provided
if (files.length === 0) {
@@ -125,7 +133,7 @@ async function run() {
// Migrate each file
let migrateResults = await Promise.allSettled(
- stylesheets.map((sheet) => migrateStylesheet(sheet, config)),
+ stylesheets.map((sheet) => migrateStylesheet(sheet, { ...config, jsConfigMigration })),
)
for (let result of migrateResults) {
@@ -158,14 +166,19 @@ async function run() {
{
// PostCSS config migration
- await migratePostCSSConfig(process.cwd())
+ await migratePostCSSConfig(base)
}
try {
// Upgrade Tailwind CSS
- await pkg('add tailwindcss@next', process.cwd())
+ await pkg('add tailwindcss@next', base)
} catch {}
+ // Remove the JS config if it was fully migrated
+ if (jsConfigMigration !== null) {
+ await fs.rm(config.configFilePath)
+ }
+
// Figure out if we made any changes
if (isRepoDirty()) {
success('Verify the changes and commit them to your repository.')
diff --git a/packages/@tailwindcss-upgrade/src/migrate-js-config.ts b/packages/@tailwindcss-upgrade/src/migrate-js-config.ts
new file mode 100644
index 000000000000..23b2fc7d936c
--- /dev/null
+++ b/packages/@tailwindcss-upgrade/src/migrate-js-config.ts
@@ -0,0 +1,185 @@
+import fs from 'node:fs/promises'
+import { dirname } from 'path'
+import type { Config } from 'tailwindcss'
+import { fileURLToPath } from 'url'
+import { loadModule } from '../../@tailwindcss-node/src/compile'
+import {
+ keyPathToCssProperty,
+ themeableValues,
+} from '../../tailwindcss/src/compat/apply-config-to-theme'
+import { deepMerge } from '../../tailwindcss/src/compat/config/deep-merge'
+import { mergeThemeExtension } from '../../tailwindcss/src/compat/config/resolve-config'
+import { darkModePlugin } from '../../tailwindcss/src/compat/dark-mode'
+import { info } from './utils/renderer'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = dirname(__filename)
+
+export type JSConfigMigration =
+ // Could not convert the config file, need to inject it as-is in a @config directive
+ null | {
+ sources: { base: string; pattern: string }[]
+ css: string
+ }
+
+export async function migrateJsConfig(
+ fullConfigPath: string,
+ base: string,
+): Promise {
+ let [unresolvedConfig, source] = await Promise.all([
+ loadModule(fullConfigPath, __dirname, () => {}).then((result) => result.module) as Config,
+ fs.readFile(fullConfigPath, 'utf-8'),
+ ])
+
+ if (!isSimpleConfig(unresolvedConfig, source)) {
+ info(
+ 'The configuration file is not a simple object. Please refer to the migration guide for how to migrate it fully to Tailwind CSS v4. For now, we will load the configuration file as-is.',
+ )
+ return null
+ }
+
+ let sources: { base: string; pattern: string }[] = []
+ let cssConfigs: string[] = []
+
+ if ('darkMode' in unresolvedConfig) {
+ cssConfigs.push(migrateDarkMode(unresolvedConfig as any))
+ }
+
+ if ('content' in unresolvedConfig) {
+ sources = migrateContent(unresolvedConfig as any, base)
+ }
+
+ if ('theme' in unresolvedConfig) {
+ cssConfigs.push(await migrateTheme(unresolvedConfig as any))
+ }
+
+ return {
+ sources,
+ css: cssConfigs.join('\n'),
+ }
+}
+
+async function migrateTheme(unresolvedConfig: Config & { theme: any }): Promise {
+ let { extend: extendTheme, ...overwriteTheme } = unresolvedConfig.theme
+
+ let resetNamespaces = new Map()
+ // Before we merge the resetting theme values with the `extend` values, we
+ // capture all namespaces that need to be reset
+ for (let [key, value] of themeableValues(overwriteTheme)) {
+ if (typeof value !== 'string' && typeof value !== 'number') {
+ continue
+ }
+
+ if (!resetNamespaces.has(key[0])) {
+ resetNamespaces.set(key[0], false)
+ }
+ }
+
+ let themeValues = deepMerge({}, [overwriteTheme, extendTheme], mergeThemeExtension)
+
+ let prevSectionKey = ''
+
+ let css = `@theme {`
+ for (let [key, value] of themeableValues(themeValues)) {
+ if (typeof value !== 'string' && typeof value !== 'number') {
+ continue
+ }
+
+ let sectionKey = createSectionKey(key)
+ if (sectionKey !== prevSectionKey) {
+ css += `\n`
+ prevSectionKey = sectionKey
+ }
+
+ if (resetNamespaces.has(key[0]) && resetNamespaces.get(key[0]) === false) {
+ resetNamespaces.set(key[0], true)
+ css += ` --${keyPathToCssProperty([key[0]])}-*: initial;\n`
+ }
+
+ css += ` --${keyPathToCssProperty(key)}: ${value};\n`
+ }
+
+ return css + '}\n'
+}
+
+function migrateDarkMode(unresolvedConfig: Config & { darkMode: any }): string {
+ let variant: string = ''
+ let addVariant = (_name: string, _variant: string) => (variant = _variant)
+ let config = () => unresolvedConfig.darkMode
+ darkModePlugin({ config, addVariant })
+
+ if (variant === '') {
+ return ''
+ }
+ return `@variant dark (${variant});\n`
+}
+
+// Returns a string identifier used to section theme declarations
+function createSectionKey(key: string[]): string {
+ let sectionSegments = []
+ for (let i = 0; i < key.length - 1; i++) {
+ let segment = key[i]
+ // ignore tuples
+ if (key[i + 1][0] === '-') {
+ break
+ }
+ sectionSegments.push(segment)
+ }
+ return sectionSegments.join('-')
+}
+
+function migrateContent(
+ unresolvedConfig: Config & { content: any },
+ base: string,
+): { base: string; pattern: string }[] {
+ let sources = []
+ for (let content of unresolvedConfig.content) {
+ if (typeof content !== 'string') {
+ throw new Error('Unsupported content value: ' + content)
+ }
+ sources.push({ base, pattern: content })
+ }
+ return sources
+}
+
+// Applies heuristics to determine if we can attempt to migrate the config
+function isSimpleConfig(unresolvedConfig: Config, source: string): boolean {
+ // The file may not contain any functions
+ if (source.includes('function') || source.includes(' => ')) {
+ return false
+ }
+
+ // The file may not contain non-serializable values
+ function isSimpleValue(value: unknown): boolean {
+ if (typeof value === 'function') return false
+ if (Array.isArray(value)) return value.every(isSimpleValue)
+ if (typeof value === 'object' && value !== null) {
+ return Object.values(value).every(isSimpleValue)
+ }
+ return ['string', 'number', 'boolean', 'undefined'].includes(typeof value)
+ }
+ if (!isSimpleValue(unresolvedConfig)) {
+ return false
+ }
+
+ // The file may only contain known-migrateable top-level properties
+ let knownProperties = [
+ 'darkMode',
+ 'content',
+ 'theme',
+ 'plugins',
+ 'presets',
+ 'prefix', // Prefix is handled in the dedicated prefix migrator
+ ]
+ if (Object.keys(unresolvedConfig).some((key) => !knownProperties.includes(key))) {
+ return false
+ }
+ if (unresolvedConfig.plugins && unresolvedConfig.plugins.length > 0) {
+ return false
+ }
+ if (unresolvedConfig.presets && unresolvedConfig.presets.length > 0) {
+ return false
+ }
+
+ return true
+}
diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts
index 7ce0c1bb1f6a..450fc528c007 100644
--- a/packages/@tailwindcss-upgrade/src/migrate.ts
+++ b/packages/@tailwindcss-upgrade/src/migrate.ts
@@ -5,11 +5,12 @@ 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 { migrateConfig } from './codemods/migrate-config'
import { migrateMediaScreen } from './codemods/migrate-media-screen'
import { migrateMissingLayers } from './codemods/migrate-missing-layers'
import { migrateTailwindDirectives } from './codemods/migrate-tailwind-directives'
+import type { JSConfigMigration } from './migrate-js-config'
import { Stylesheet, type StylesheetConnection, type StylesheetId } from './stylesheet'
import { resolveCssId } from './utils/resolve'
import { walk, WalkAction } from './utils/walk'
@@ -19,6 +20,7 @@ export interface MigrateOptions {
designSystem: DesignSystem
userConfig: Config
configFilePath: string
+ jsConfigMigration: JSConfigMigration
}
export async function migrateContents(
@@ -37,7 +39,7 @@ export async function migrateContents(
.use(migrateAtLayerUtilities(stylesheet))
.use(migrateMissingLayers())
.use(migrateTailwindDirectives(options))
- .use(migrateAtConfig(stylesheet, options))
+ .use(migrateConfig(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 b76f8990b2c5..9cdb95d4980c 100644
--- a/packages/@tailwindcss-upgrade/src/template/prepare-config.ts
+++ b/packages/@tailwindcss-upgrade/src/template/prepare-config.ts
@@ -35,8 +35,7 @@ export async function prepareConfig(
// required so that the base for Tailwind CSS can bet inside the
// @tailwindcss-upgrade package and we can require `tailwindcss` properly.
let fullConfigPath = path.resolve(options.base, configPath)
- let fullFilePath = path.resolve(__dirname)
- let relative = path.relative(fullFilePath, fullConfigPath)
+ let relative = path.relative(__dirname, fullConfigPath)
// If the path points to a file in the same directory, `path.relative` will
// remove the leading `./` and we need to add it back in order to still
diff --git a/packages/tailwindcss/src/compat/apply-config-to-theme.ts b/packages/tailwindcss/src/compat/apply-config-to-theme.ts
index c80e8259cf65..2e26697d28d4 100644
--- a/packages/tailwindcss/src/compat/apply-config-to-theme.ts
+++ b/packages/tailwindcss/src/compat/apply-config-to-theme.ts
@@ -81,7 +81,7 @@ export function applyConfigToTheme(designSystem: DesignSystem, { theme }: Resolv
return theme
}
-function themeableValues(config: ResolvedConfig['theme']): [string[], unknown][] {
+export function themeableValues(config: ResolvedConfig['theme']): [string[], unknown][] {
let toAdd: [string[], unknown][] = []
walk(config as any, [], (value, path) => {
@@ -110,9 +110,10 @@ function themeableValues(config: ResolvedConfig['theme']): [string[], unknown][]
return toAdd
}
-function keyPathToCssProperty(path: string[]) {
+export function keyPathToCssProperty(path: string[]) {
if (path[0] === 'colors') path[0] = 'color'
if (path[0] === 'screens') path[0] = 'breakpoint'
+ if (path[0] === 'borderRadius') path[0] = 'radius'
return (
path
diff --git a/packages/tailwindcss/src/compat/config/resolve-config.ts b/packages/tailwindcss/src/compat/config/resolve-config.ts
index 81aa4dacada8..2d8a86ff6176 100644
--- a/packages/tailwindcss/src/compat/config/resolve-config.ts
+++ b/packages/tailwindcss/src/compat/config/resolve-config.ts
@@ -87,7 +87,7 @@ export function resolveConfig(design: DesignSystem, files: ConfigFile[]): Resolv
}
}
-function mergeThemeExtension(
+export function mergeThemeExtension(
themeValue: ThemeValue | ThemeValue[],
extensionValue: ThemeValue | ThemeValue[],
) {
diff --git a/packages/tailwindcss/src/compat/dark-mode.ts b/packages/tailwindcss/src/compat/dark-mode.ts
index 0f9bc2cdffe1..eceac14984cc 100644
--- a/packages/tailwindcss/src/compat/dark-mode.ts
+++ b/packages/tailwindcss/src/compat/dark-mode.ts
@@ -1,7 +1,7 @@
import type { ResolvedConfig } from './config/types'
import type { PluginAPI } from './plugin-api'
-export function darkModePlugin({ addVariant, config }: PluginAPI) {
+export function darkModePlugin({ addVariant, config }: Pick) {
let darkMode = config('darkMode', null) as ResolvedConfig['darkMode']
let [mode, selector = '.dark'] = Array.isArray(darkMode) ? darkMode : [darkMode]