diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af6d0fddd28..4157e87c511e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,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)_: Automatically discover JavaScript config files ([#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)) +- _Upgrade (experimental)_: Migrate static JS configurations to CSS ([#14639](https://github.com/tailwindlabs/tailwindcss/pull/14639), [#14650](https://github.com/tailwindlabs/tailwindcss/pull/14650)) - _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)) - _Upgrade (experimental)_: Migrate `aria-*`, `data-*`, and `supports-*` variants from arbitrary values to bare values ([#14644](https://github.com/tailwindlabs/tailwindcss/pull/14644)) diff --git a/integrations/upgrade/js-config.test.ts b/integrations/upgrade/js-config.test.ts index 87f95ccd0a69..347e08e93063 100644 --- a/integrations/upgrade/js-config.test.ts +++ b/integrations/upgrade/js-config.test.ts @@ -2,7 +2,7 @@ import { expect } from 'vitest' import { css, json, test, ts } from '../utils' test( - `upgrades a simple JS config file to CSS`, + `upgrade JS config files with flat theme values, darkMode, and content fields`, { fs: { 'package.json': json` @@ -103,7 +103,196 @@ test( ) test( - `does not upgrade a complex JS config file to CSS`, + 'does not upgrade JS config files with functions in the theme config', + { + fs: { + 'package.json': json` + { + "dependencies": { + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'tailwind.config.ts': ts` + import { type Config } from 'tailwindcss' + + export default { + theme: { + extend: { + colors: ({ colors }) => ({ + gray: colors.neutral, + }), + }, + }, + } 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,ts}')).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 { + theme: { + extend: { + colors: ({ colors }) => ({ + gray: colors.neutral, + }), + }, + }, + } satisfies Config + " + `) + }, +) + +test( + 'does not upgrade JS config files with theme keys contributed to by plugins in the theme config', + { + fs: { + 'package.json': json` + { + "dependencies": { + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'tailwind.config.ts': ts` + import { type Config } from 'tailwindcss' + + export default { + theme: { + typography: { + DEFAULT: { + css: { + '--tw-prose-body': 'red', + color: 'var(--tw-prose-body)', + }, + }, + }, + }, + } satisfies Config + `, + 'src/input.css': css` + @tailwind base; + @tailwind components; + @tailwind utilities; + @config '../tailwind.config.ts'; + `, + }, + }, + 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 { + theme: { + typography: { + DEFAULT: { + css: { + '--tw-prose-body': 'red', + color: 'var(--tw-prose-body)', + }, + }, + }, + }, + } satisfies Config + " + `) + }, +) + +test( + 'does not upgrade JS config files with plugins', + { + fs: { + 'package.json': json` + { + "dependencies": { + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'tailwind.config.ts': ts` + import { type Config } from 'tailwindcss' + import typography from '@tailwindcss/typography' + import customPlugin from './custom-plugin' + + export default { + plugins: [typography, customPlugin], + } satisfies Config + `, + 'custom-plugin.js': ts` + export default function ({ addVariant }) { + addVariant('inverted', '@media (inverted-colors: inverted)') + addVariant('hocus', ['&:focus', '&:hover']) + } + `, + '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' + import typography from '@tailwindcss/typography' + import customPlugin from './custom-plugin' + + export default { + plugins: [typography, customPlugin], + } satisfies Config + " + `) + }, +) + +test( + `does not upgrade JS config files with inline plugins`, { fs: { 'package.json': json` diff --git a/packages/@tailwindcss-node/src/compile.ts b/packages/@tailwindcss-node/src/compile.ts index c31868ce5ec6..25a2d037dfb0 100644 --- a/packages/@tailwindcss-node/src/compile.ts +++ b/packages/@tailwindcss-node/src/compile.ts @@ -100,11 +100,8 @@ async function importModule(path: string): Promise { try { return await import(path) } catch (error) { - try { - jiti ??= createJiti(import.meta.url, { moduleCache: false, fsCache: false }) - return await jiti.import(path) - } catch {} - throw error + jiti ??= createJiti(import.meta.url, { moduleCache: false, fsCache: false }) + return await jiti.import(path) } } @@ -144,6 +141,7 @@ async function resolveCssId(id: string, base: string): Promise { diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-config.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-config.ts index 398f612e2514..1aadec96623d 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-config.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-config.ts @@ -63,9 +63,8 @@ export function migrateConfig( 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 + // Inject the `@config` directive after the last `@import` or at the + // top of the file if no `@import` rules are present let locationNode = null as AtRule | null walk(root, (node) => { @@ -99,10 +98,9 @@ export function migrateConfig( 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 a full `@import "tailwindcss"` is present or this is the root + // stylesheet, we can inject the `@config` directive directly into this + // file. if (hasFullTailwindImport || sheet.parents.size <= 0) { injectInto(sheet) return @@ -134,7 +132,7 @@ function relativeToStylesheet(sheet: Stylesheet, absolute: string) { if (relative[0] !== '.') { relative = `./${relative}` } - // Ensure relative is a posix style path since we will merge it with the + // 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/migrate-js-config.ts b/packages/@tailwindcss-upgrade/src/migrate-js-config.ts index 23b2fc7d936c..18e6712c8884 100644 --- a/packages/@tailwindcss-upgrade/src/migrate-js-config.ts +++ b/packages/@tailwindcss-upgrade/src/migrate-js-config.ts @@ -1,6 +1,7 @@ import fs from 'node:fs/promises' import { dirname } from 'path' import type { Config } from 'tailwindcss' +import defaultTheme from 'tailwindcss/defaultTheme' import { fileURLToPath } from 'url' import { loadModule } from '../../@tailwindcss-node/src/compile' import { @@ -9,6 +10,7 @@ import { } 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 type { ThemeConfig } from '../../tailwindcss/src/compat/config/types' import { darkModePlugin } from '../../tailwindcss/src/compat/dark-mode' import { info } from './utils/renderer' @@ -31,9 +33,9 @@ export async function migrateJsConfig( fs.readFile(fullConfigPath, 'utf-8'), ]) - if (!isSimpleConfig(unresolvedConfig, source)) { + if (!canMigrateConfig(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.', + 'Your configuration file could not be automatically migrated to the new CSS configuration format, so your CSS has been updated to load your existing configuration file.', ) return null } @@ -50,7 +52,8 @@ export async function migrateJsConfig( } if ('theme' in unresolvedConfig) { - cssConfigs.push(await migrateTheme(unresolvedConfig as any)) + let themeConfig = await migrateTheme(unresolvedConfig as any) + if (themeConfig) cssConfigs.push(themeConfig) } return { @@ -59,12 +62,12 @@ export async function migrateJsConfig( } } -async function migrateTheme(unresolvedConfig: Config & { theme: any }): Promise { +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 + // Before we merge theme overrides with theme extensions, we capture all + // namespaces that need to be reset. for (let [key, value] of themeableValues(overwriteTheme)) { if (typeof value !== 'string' && typeof value !== 'number') { continue @@ -80,10 +83,12 @@ async function migrateTheme(unresolvedConfig: Config & { theme: any }): Promise< let prevSectionKey = '' let css = `@theme {` + let containsThemeKeys = false for (let [key, value] of themeableValues(themeValues)) { if (typeof value !== 'string' && typeof value !== 'number') { continue } + containsThemeKeys = true let sectionKey = createSectionKey(key) if (sectionKey !== prevSectionKey) { @@ -99,6 +104,10 @@ async function migrateTheme(unresolvedConfig: Config & { theme: any }): Promise< css += ` --${keyPathToCssProperty(key)}: ${value};\n` } + if (!containsThemeKeys) { + return null + } + return css + '}\n' } @@ -119,7 +128,7 @@ function createSectionKey(key: string[]): string { let sectionSegments = [] for (let i = 0; i < key.length - 1; i++) { let segment = key[i] - // ignore tuples + // Ignore tuples if (key[i + 1][0] === '-') { break } @@ -143,7 +152,7 @@ function migrateContent( } // Applies heuristics to determine if we can attempt to migrate the config -function isSimpleConfig(unresolvedConfig: Config, source: string): boolean { +function canMigrateConfig(unresolvedConfig: Config, source: string): boolean { // The file may not contain any functions if (source.includes('function') || source.includes(' => ')) { return false @@ -158,6 +167,7 @@ function isSimpleConfig(unresolvedConfig: Config, source: string): boolean { } return ['string', 'number', 'boolean', 'undefined'].includes(typeof value) } + if (!isSimpleValue(unresolvedConfig)) { return false } @@ -171,15 +181,41 @@ function isSimpleConfig(unresolvedConfig: Config, source: string): boolean { '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 } + // Only migrate the config file if all top-level theme keys are allowed to be + // migrated + let theme = unresolvedConfig.theme + if (theme && typeof theme === 'object') { + if (theme.extend && !onlyUsesAllowedTopLevelKeys(theme.extend)) return false + let { extend: _extend, ...themeCopy } = theme + if (!onlyUsesAllowedTopLevelKeys(themeCopy)) return false + } + + return true +} + +const DEFAULT_THEME_KEYS = [ + ...Object.keys(defaultTheme), + // Used by @tailwindcss/container-queries + 'containers', +] +function onlyUsesAllowedTopLevelKeys(theme: ThemeConfig): boolean { + for (let key of Object.keys(theme)) { + if (!DEFAULT_THEME_KEYS.includes(key)) { + return false + } + } return true } diff --git a/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts b/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts index 41d24d184570..5e37ab9e5b7d 100644 --- a/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts +++ b/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts @@ -74,3 +74,27 @@ test('Config values can be merged into the theme', () => { { '--line-height': '1.5' }, ]) }) + +test('Invalid keys are not merged into the theme', () => { + let theme = new Theme() + let design = buildDesignSystem(theme) + + let resolvedUserConfig = resolveConfig(design, [ + { + config: { + theme: { + colors: { + 'primary color': '#86753099', + }, + }, + }, + base: '/root', + }, + ]) + + applyConfigToTheme(design, resolvedUserConfig) + + let entries = Array.from(theme.entries()) + + expect(entries.length).toEqual(0) +}) diff --git a/packages/tailwindcss/src/compat/apply-config-to-theme.ts b/packages/tailwindcss/src/compat/apply-config-to-theme.ts index f09d1770e7be..67f8ccd9c7a6 100644 --- a/packages/tailwindcss/src/compat/apply-config-to-theme.ts +++ b/packages/tailwindcss/src/compat/apply-config-to-theme.ts @@ -24,7 +24,10 @@ export function applyConfigToTheme(designSystem: DesignSystem, { theme }: Resolv if (typeof value !== 'string' && typeof value !== 'number') { continue } + let name = keyPathToCssProperty(path) + if (!name) continue + designSystem.theme.add( `--${name}`, value as any, @@ -110,6 +113,8 @@ export function themeableValues(config: ResolvedConfig['theme']): [string[], unk return toAdd } +const IS_VALID_KEY = /^[a-zA-Z0-9-_]+$/ + export function keyPathToCssProperty(path: string[]) { if (path[0] === 'colors') path[0] = 'color' if (path[0] === 'screens') path[0] = 'breakpoint' @@ -117,6 +122,10 @@ export function keyPathToCssProperty(path: string[]) { if (path[0] === 'boxShadow') path[0] = 'shadow' if (path[0] === 'animation') path[0] = 'animate' + for (let part of path) { + if (!IS_VALID_KEY.test(part)) return null + } + return ( path // [1] should move into the nested object tuple. To create the CSS variable