Skip to content

Commit

Permalink
Resolve theme keys when migrating JS config to CSS (#14675)
Browse files Browse the repository at this point in the history
With the changes in #14672, it now becomes trivial to actually resolve
the config (while still retaining the reset behavior). This means that
we can now convert JS configs that use _functions_, e.g.:

```ts
import { type Config } from 'tailwindcss'

export default {
  theme: {
    extend: {
      colors: ({ colors }) => ({
        gray: colors.neutral,
      }),
    },
  },
} satisfies Config
```

This becomes:

```css
@import 'tailwindcss';

@theme {
  --color-gray-50: #fafafa;
  --color-gray-100: #f5f5f5;
  --color-gray-200: #e5e5e5;
  --color-gray-300: #d4d4d4;
  --color-gray-400: #a3a3a3;
  --color-gray-500: #737373;
  --color-gray-600: #525252;
  --color-gray-700: #404040;
  --color-gray-800: #262626;
  --color-gray-900: #171717;
  --color-gray-950: #0a0a0a;
}
```

---------

Co-authored-by: Adam Wathan <adam.wathan@gmail.com>
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
  • Loading branch information
3 people authored Oct 17, 2024
1 parent edb066e commit 0971ead
Show file tree
Hide file tree
Showing 5 changed files with 47 additions and 51 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Support linear gradient angles as bare values ([#14707](https://github.com/tailwindlabs/tailwindcss/pull/14707))
- Interpolate gradients in OKLCH by default ([#14708](https://github.com/tailwindlabs/tailwindcss/pull/14708))
- _Upgrade (experimental)_: Migrate `theme(…)` calls to `var(…)` or to the modern `theme(…)` syntax ([#14664](https://github.com/tailwindlabs/tailwindcss/pull/14664), [#14695](https://github.com/tailwindlabs/tailwindcss/pull/14695))
- _Upgrade (experimental)_: Support migrating JS configurations to CSS that contain functions inside the `theme` object ([#14675](https://github.com/tailwindlabs/tailwindcss/pull/14675))

### Fixed

Expand Down
28 changes: 15 additions & 13 deletions integrations/upgrade/js-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ test(
)

test(
'does not upgrade JS config files with functions in the theme config',
'upgrades JS config files with functions in the theme config',
{
fs: {
'package.json': json`
Expand Down Expand Up @@ -230,24 +230,26 @@ test(
"
--- src/input.css ---
@import 'tailwindcss';
@config '../tailwind.config.ts';
@theme {
--color-gray-50: oklch(0.985 0 none);
--color-gray-100: oklch(0.97 0 none);
--color-gray-200: oklch(0.922 0 none);
--color-gray-300: oklch(0.87 0 none);
--color-gray-400: oklch(0.708 0 none);
--color-gray-500: oklch(0.556 0 none);
--color-gray-600: oklch(0.439 0 none);
--color-gray-700: oklch(0.371 0 none);
--color-gray-800: oklch(0.269 0 none);
--color-gray-900: oklch(0.205 0 none);
--color-gray-950: oklch(0.145 0 none);
}
"
`)

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
"
`)
},
Expand Down
2 changes: 1 addition & 1 deletion packages/@tailwindcss-upgrade/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ async function run() {
// Migrate JS config

info('Migrating JavaScript configuration files using the provided configuration file.')
let jsConfigMigration = await migrateJsConfig(config.configFilePath, base)
let jsConfigMigration = await migrateJsConfig(config.designSystem, config.configFilePath, base)

{
// Stylesheet migrations
Expand Down
63 changes: 28 additions & 35 deletions packages/@tailwindcss-upgrade/src/migrate-js-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import {
themeableValues,
} from '../../tailwindcss/src/compat/apply-config-to-theme'
import { keyframesToRules } from '../../tailwindcss/src/compat/apply-keyframes-to-theme'
import { deepMerge } from '../../tailwindcss/src/compat/config/deep-merge'
import { mergeThemeExtension } from '../../tailwindcss/src/compat/config/resolve-config'
import { resolveConfig, type ConfigFile } 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 type { DesignSystem } from '../../tailwindcss/src/design-system'
import { findStaticPlugins } from './utils/extract-static-plugins'
import { info } from './utils/renderer'

Expand All @@ -29,6 +29,7 @@ export type JSConfigMigration =
}

export async function migrateJsConfig(
designSystem: DesignSystem,
fullConfigPath: string,
base: string,
): Promise<JSConfigMigration> {
Expand Down Expand Up @@ -57,7 +58,7 @@ export async function migrateJsConfig(
}

if ('theme' in unresolvedConfig) {
let themeConfig = await migrateTheme(unresolvedConfig as any)
let themeConfig = await migrateTheme(designSystem, unresolvedConfig, base)
if (themeConfig) cssConfigs.push(themeConfig)
}

Expand All @@ -75,33 +76,27 @@ export async function migrateJsConfig(
}
}

async function migrateTheme(unresolvedConfig: Config & { theme: any }): Promise<string | null> {
let { extend: extendTheme, ...overwriteTheme } = unresolvedConfig.theme

let resetNamespaces = new Map<string, boolean>()
// 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
}

if (!resetNamespaces.has(key[0])) {
resetNamespaces.set(key[0], false)
}
async function migrateTheme(
designSystem: DesignSystem,
unresolvedConfig: Config,
base: string,
): Promise<string | null> {
// Resolve the config file without applying plugins and presets, as these are
// migrated to CSS separately.
let configToResolve: ConfigFile = {
base,
config: { ...unresolvedConfig, plugins: [], presets: undefined },
}
let { resolvedConfig, replacedThemeKeys } = resolveConfig(designSystem, [configToResolve])

let themeValues: Record<string, Record<string, unknown>> = deepMerge(
{},
[overwriteTheme, extendTheme],
mergeThemeExtension,
let resetNamespaces = new Map<string, boolean>(
Array.from(replacedThemeKeys.entries()).map(([key]) => [key, false]),
)

let prevSectionKey = ''

let css = `@theme {`
let containsThemeKeys = false
for (let [key, value] of themeableValues(themeValues)) {
for (let [key, value] of themeableValues(resolvedConfig.theme)) {
if (typeof value !== 'string' && typeof value !== 'number') {
continue
}
Expand All @@ -125,9 +120,9 @@ async function migrateTheme(unresolvedConfig: Config & { theme: any }): Promise<
css += ` --${keyPathToCssProperty(key)}: ${value};\n`
}

if ('keyframes' in themeValues) {
if ('keyframes' in resolvedConfig.theme) {
containsThemeKeys = true
css += '\n' + keyframesToCss(themeValues.keyframes)
css += '\n' + keyframesToCss(resolvedConfig.theme.keyframes)
}

if (!containsThemeKeys) {
Expand Down Expand Up @@ -179,11 +174,6 @@ function migrateContent(

// Applies heuristics to determine if we can attempt to migrate the config
function canMigrateConfig(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
Expand All @@ -194,8 +184,8 @@ function canMigrateConfig(unresolvedConfig: Config, source: string): boolean {
return ['string', 'number', 'boolean', 'undefined'].includes(typeof value)
}

// Plugins are more complex, so we have a special heuristics for them.
let { plugins, ...remainder } = unresolvedConfig
// `theme` and `plugins` are handled separately and allowed to be more complex
let { plugins, theme, ...remainder } = unresolvedConfig
if (!isSimpleValue(remainder)) {
return false
}
Expand Down Expand Up @@ -224,7 +214,6 @@ function canMigrateConfig(unresolvedConfig: Config, source: string): boolean {

// 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 && !onlyAllowedThemeValues(theme.extend)) return false
let { extend: _extend, ...themeCopy } = theme
Expand All @@ -234,14 +223,18 @@ function canMigrateConfig(unresolvedConfig: Config, source: string): boolean {
return true
}

const DEFAULT_THEME_KEYS = [
const ALLOWED_THEME_KEYS = [
...Object.keys(defaultTheme),
// Used by @tailwindcss/container-queries
'containers',
]
const BLOCKED_THEME_KEYS = ['supports', 'data', 'aria']
function onlyAllowedThemeValues(theme: ThemeConfig): boolean {
for (let key of Object.keys(theme)) {
if (!DEFAULT_THEME_KEYS.includes(key)) {
if (!ALLOWED_THEME_KEYS.includes(key)) {
return false
}
if (BLOCKED_THEME_KEYS.includes(key)) {
return false
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/tailwindcss/src/compat/apply-config-to-theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ export function applyConfigToTheme(
{ theme }: ResolvedConfig,
replacedThemeKeys: Set<string>,
) {
for (let resetThemeKey of replacedThemeKeys) {
let name = keyPathToCssProperty([resetThemeKey])
for (let replacedThemeKey of replacedThemeKeys) {
let name = keyPathToCssProperty([replacedThemeKey])
if (!name) continue

designSystem.theme.clearNamespace(`--${name}`, ThemeOptions.DEFAULT)
Expand Down

0 comments on commit 0971ead

Please sign in to comment.