From 0cfb98484b47717d35146153daf40733616b750a Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Fri, 11 Oct 2024 15:27:53 +0200 Subject: [PATCH] Add simple JS config migration (#14639) This PR implements the first version of JS config file migration to CSS. It is based on the most simple config setups we are using in the Tailwind UI templates Commit, Primer, Radiant, and Studio. The example we use in the integration test is a config that looks like this: ```js import { type Config } from 'tailwindcss' import defaultTheme from 'tailwindcss/defaultTheme' module.exports = { darkMode: 'selector', content: ['./src/**/*.{html,js}'], theme: { boxShadow: { sm: '0 2px 6px rgb(15 23 42 / 0.08)', }, colors: { red: { 500: '#ef4444', }, }, fontSize: { xs: ['0.75rem', { lineHeight: '1rem' }], sm: ['0.875rem', { lineHeight: '1.5rem' }], base: ['1rem', { lineHeight: '2rem' }], }, extend: { colors: { red: { 600: '#dc2626', }, }, fontFamily: { sans: 'Inter, system-ui, sans-serif', display: ['Cabinet Grotesk', ...defaultTheme.fontFamily.sans], }, borderRadius: { '4xl': '2rem', }, }, }, plugins: [], } satisfies Config ``` As you can see, this file only has a `darkMode` selector, custom `content` globs, a `theme` (with some theme keys being overwriting the default theme and some others extending the defaults). Note that it does not support `plugins` and/or `presets` yet. In the case above, we will find the CSS file containing the existing `@tailwind` directives and are migrating it to the following: ```css @import 'tailwindcss'; @source './**/*.{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-500: #ef4444; --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; --color-red-600: #dc2626; --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"; --border-radius-4xl: 2rem; } ``` This replicates all features of the JS config so we can even delete the existing JS config in this case. --------- Co-authored-by: Robin Malfait --- CHANGELOG.md | 1 + integrations/upgrade/index.test.ts | 41 ++-- integrations/upgrade/js-config.test.ts | 152 ++++++++++++++ integrations/utils.ts | 2 +- .../src/codemods/migrate-at-config.ts | 101 ---------- .../src/codemods/migrate-config.ts | 140 +++++++++++++ .../@tailwindcss-upgrade/src/index.test.ts | 1 + packages/@tailwindcss-upgrade/src/index.ts | 23 ++- .../src/migrate-js-config.ts | 185 ++++++++++++++++++ packages/@tailwindcss-upgrade/src/migrate.ts | 6 +- .../src/template/prepare-config.ts | 3 +- .../src/compat/apply-config-to-theme.ts | 5 +- .../src/compat/config/resolve-config.ts | 2 +- packages/tailwindcss/src/compat/dark-mode.ts | 2 +- 14 files changed, 528 insertions(+), 136 deletions(-) create mode 100644 integrations/upgrade/js-config.test.ts delete mode 100644 packages/@tailwindcss-upgrade/src/codemods/migrate-at-config.ts create mode 100644 packages/@tailwindcss-upgrade/src/codemods/migrate-config.ts create mode 100644 packages/@tailwindcss-upgrade/src/migrate-js-config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 88314a4ff..a5d28326e 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 195c5e20d..97409d53d 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 000000000..5ef1f55b2 --- /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 2afc35ed0..d9242ab04 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 6cfc6fd56..000000000 --- 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 000000000..398f612e2 --- /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 e45507e70..655eae25b 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 6bf078df4..2c2973c4a 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 000000000..23b2fc7d9 --- /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 7ce0c1bb1..450fc528c 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 b76f8990b..9cdb95d49 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 c80e8259c..2e26697d2 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 81aa4daca..2d8a86ff6 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 0f9bc2cdf..eceac1498 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]