tailwindcss/packages/@tailwindcss-upgrade/src/migrate-js-config.ts
Philipp Spiess 5edf6c7dc0
Ensure clients pin the tailwindcss version (#15011)
We noticed that in the current alpha 34 release, the `package.json` file
of the `@tailwindcss/node` package only defines `tailwindcss` as a dev
dependency. This makes it very easy for version mismatches to happen
when a v3 version (or an earlier v4 alpha for that matter) was installed
in the same project:

```json
{
  "name": "@tailwindcss/node",
  "version": "4.0.0-alpha.34",
  "description": "A utility-first CSS framework for rapidly building custom user interfaces.",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/tailwindlabs/tailwindcss.git",
    "directory": "packages/@tailwindcss-node"
  },
  "bugs": "https://github.com/tailwindlabs/tailwindcss/issues",
  "homepage": "https://tailwindcss.com",
  "files": [
    "dist/"
  ],
  "publishConfig": {
    "provenance": true,
    "access": "public"
  },
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.js"
    },
    "./require-cache": {
      "types": "./dist/require-cache.d.ts",
      "default": "./dist/require-cache.js"
    },
    "./esm-cache-loader": {
      "types": "./dist/esm-cache.loader.d.mts",
      "default": "./dist/esm-cache.loader.mjs"
    }
  },
  "devDependencies": {
    "tailwindcss": "4.0.0-alpha.34"
  },
  "dependencies": {
    "enhanced-resolve": "^5.17.1",
    "jiti": "^2.0.0-beta.3"
  },
  "scripts": {
    "build": "tsup-node",
    "dev": "pnpm run build -- --watch"
  }
}
```

Furthermore, we were trying to fix issues where our integration test
setup could not install `tailwindcss@3` because of how we did pnpm
overrides.

This PR fixes this by:

- Ensuring every client that calls into `tailwindcss` core marks it as a
version-pinned dependency. You are still required to install
`tailwindcss` in your project along side a client (e.g.
`@tailwindcss/vite`) but we now only use your installed version for
importing the respective `.css` files. For the core logic, we are now
requiring each package to use `tailwindcss` at the same version. This
should help resolve issues like
https://github.com/tailwindlabs/tailwindcss/discussions/14652
- We tried to eliminate the dependency on `tailwindcss` from the
`@tailwindcss/upgrade` package. Unfortunately this is not possible to do
right now because we need to load the CSS files from v4 to create the
right environment. In a future version we could bundle the required CSS
files with `@tailwidncss/upgrade` but it doesn't seem necessary for now.
- We then changed our integration test overrides to only override the
`tailwindcss` package that are dependencies of the known list of
packages that we have `tailwindcss` dependencies on: `@tailwindcss/node`
and `@tailwindcss/upgrade`. This ensures that we can install v3 of
`tailwindcss` in the integration tests and it will work. Something we
want to do for some upgrade tests.

# Test plan

Integration work again. Furthermore we added a quick setup with the CLI
using the local tarballs and ensured it works:

```bash
pnpm init
pnpm install ../../tailwindcss/dist/tailwindcss-cli.tgz 
pnpm install ../../tailwindcss/dist/tailwindcss.tgz 
echo '@import "tailwindcss";' > index.css
echo '<div class="underline"></div>' > index.html
pnpm tailwindcss -i index.css -o out.css
cat out.css
```
2024-11-15 17:18:48 +01:00

370 lines
12 KiB
TypeScript

import { Scanner } from '@tailwindcss/oxide'
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { loadModule } from '../../@tailwindcss-node/src/compile'
import defaultTheme from '../../tailwindcss/dist/default-theme'
import { atRule, toCss, type AstNode } from '../../tailwindcss/src/ast'
import {
keyPathToCssProperty,
themeableValues,
} from '../../tailwindcss/src/compat/apply-config-to-theme'
import { keyframesToRules } from '../../tailwindcss/src/compat/apply-keyframes-to-theme'
import { resolveConfig, type ConfigFile } from '../../tailwindcss/src/compat/config/resolve-config'
import type { ResolvedConfig, ThemeConfig } from '../../tailwindcss/src/compat/config/types'
import { buildCustomContainerUtilityRules } from '../../tailwindcss/src/compat/container'
import { darkModePlugin } from '../../tailwindcss/src/compat/dark-mode'
import type { Config } from '../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../tailwindcss/src/design-system'
import { escape } from '../../tailwindcss/src/utils/escape'
import { isValidSpacingMultiplier } from '../../tailwindcss/src/utils/infer-data-type'
import { findStaticPlugins, type StaticPluginOptions } from './utils/extract-static-plugins'
import { info } from './utils/renderer'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.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 }[]
plugins: { base: string; path: string; options: null | StaticPluginOptions }[]
css: string
}
export async function migrateJsConfig(
designSystem: DesignSystem,
fullConfigPath: string,
base: string,
): Promise<JSConfigMigration> {
let [unresolvedConfig, source] = await Promise.all([
loadModule(fullConfigPath, __dirname, () => {}).then((result) => result.module) as Config,
fs.readFile(fullConfigPath, 'utf-8'),
])
if (!canMigrateConfig(unresolvedConfig, source)) {
info(
'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
}
let sources: { base: string; pattern: string }[] = []
let plugins: { base: string; path: string; options: null | StaticPluginOptions }[] = []
let cssConfigs: string[] = []
if ('darkMode' in unresolvedConfig) {
cssConfigs.push(migrateDarkMode(unresolvedConfig as any))
}
if ('content' in unresolvedConfig) {
sources = await migrateContent(unresolvedConfig as any, fullConfigPath, base)
}
if ('theme' in unresolvedConfig) {
let themeConfig = await migrateTheme(designSystem, unresolvedConfig, base)
if (themeConfig) cssConfigs.push(themeConfig)
}
if ('corePlugins' in unresolvedConfig) {
info(
`The \`corePlugins\` option is no longer supported as of Tailwind CSS v4.0, so it's been removed from your configuration.`,
)
}
let simplePlugins = findStaticPlugins(source)
if (simplePlugins !== null) {
for (let [path, options] of simplePlugins) {
plugins.push({ base, path, options })
}
}
return {
sources,
plugins,
css: cssConfigs.join('\n'),
}
}
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 resetNamespaces = new Map<string, boolean>(
Array.from(replacedThemeKeys.entries()).map(([key]) => [key, false]),
)
removeUnnecessarySpacingKeys(designSystem, resolvedConfig, replacedThemeKeys)
let prevSectionKey = ''
let css = '\n@tw-bucket theme {\n'
css += `\n@theme {\n`
let containsThemeKeys = false
for (let [key, value] of themeableValues(resolvedConfig.theme)) {
if (typeof value !== 'string' && typeof value !== 'number') {
continue
}
if (key[0] === 'keyframes') {
continue
}
containsThemeKeys = true
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)
let property = keyPathToCssProperty([key[0]])
if (property !== null) {
css += ` ${escape(`--${property}`)}-*: initial;\n`
}
}
let property = keyPathToCssProperty(key)
if (property !== null) {
css += ` ${escape(`--${property}`)}: ${value};\n`
}
}
if ('keyframes' in resolvedConfig.theme) {
containsThemeKeys = true
css += '\n' + keyframesToCss(resolvedConfig.theme.keyframes)
}
if (!containsThemeKeys) {
return null
}
css += '}\n' // @theme
if ('container' in resolvedConfig.theme) {
let rules = buildCustomContainerUtilityRules(resolvedConfig.theme.container, designSystem)
if (rules.length > 0) {
css += '\n' + toCss([atRule('@utility', 'container', rules)])
}
}
css += '}\n' // @tw-bucket
return css
}
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 `\n@tw-bucket variant {\n@variant dark (${variant});\n}\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('-')
}
async function migrateContent(
unresolvedConfig: Config,
configPath: string,
base: string,
): Promise<{ base: string; pattern: string }[]> {
let autoContentFiles = autodetectedSourceFiles(base)
let sources = []
let contentIsRelative = (() => {
if (!unresolvedConfig.content) return false
if (Array.isArray(unresolvedConfig.content)) return false
if (unresolvedConfig.content.relative) return true
if (unresolvedConfig.future === 'all') return false
return unresolvedConfig.future?.relativeContentPathsByDefault ?? false
})()
let sourceGlobs = Array.isArray(unresolvedConfig.content)
? unresolvedConfig.content.map((pattern) => ({ base, pattern }))
: (unresolvedConfig.content?.files ?? []).map((pattern) => {
if (typeof pattern === 'string' && contentIsRelative) {
return { base: path.dirname(configPath), pattern: pattern }
}
return { base, pattern }
})
for (let { base, pattern } of sourceGlobs) {
if (typeof pattern !== 'string') {
throw new Error('Unsupported content value: ' + pattern)
}
let sourceFiles = patternSourceFiles({ base, pattern })
let autoContentContainsAllSourceFiles = true
for (let sourceFile of sourceFiles) {
if (!autoContentFiles.includes(sourceFile)) {
autoContentContainsAllSourceFiles = false
break
}
}
if (!autoContentContainsAllSourceFiles) {
sources.push({ base, pattern })
}
}
return sources
}
// Applies heuristics to determine if we can attempt to migrate the config
function canMigrateConfig(unresolvedConfig: Config, source: string): boolean {
// 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)
}
// `theme` and `plugins` are handled separately and allowed to be more complex
let { plugins, theme, ...remainder } = unresolvedConfig
if (!isSimpleValue(remainder)) {
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
'corePlugins',
]
if (Object.keys(unresolvedConfig).some((key) => !knownProperties.includes(key))) {
return false
}
if (findStaticPlugins(source) === null) {
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
if (theme && typeof theme === 'object') {
if (theme.extend && !onlyAllowedThemeValues(theme.extend)) return false
let { extend: _extend, ...themeCopy } = theme
if (!onlyAllowedThemeValues(themeCopy)) return false
}
return true
}
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 (!ALLOWED_THEME_KEYS.includes(key)) {
return false
}
if (BLOCKED_THEME_KEYS.includes(key)) {
return false
}
}
if ('screens' in theme && typeof theme.screens === 'object' && theme.screens !== null) {
for (let screen of Object.values(theme.screens)) {
if (typeof screen === 'object' && screen !== null && ('max' in screen || 'raw' in screen)) {
return false
}
}
}
return true
}
function keyframesToCss(keyframes: Record<string, unknown>): string {
let ast: AstNode[] = keyframesToRules({ theme: { keyframes } })
return toCss(ast).trim() + '\n'
}
function autodetectedSourceFiles(base: string) {
let scanner = new Scanner({ sources: [{ base, pattern: '**/*' }] })
scanner.scan()
return scanner.files
}
function patternSourceFiles(source: { base: string; pattern: string }): string[] {
let scanner = new Scanner({ sources: [source] })
scanner.scan()
return scanner.files
}
function removeUnnecessarySpacingKeys(
designSystem: DesignSystem,
resolvedConfig: ResolvedConfig,
replacedThemeKeys: Set<string>,
) {
// We want to keep the spacing scale as-is if the user is overwriting
if (replacedThemeKeys.has('spacing')) return
// Ensure we have a spacing multiplier
let spacingScale = designSystem.theme.get(['--spacing'])
if (!spacingScale) return
let [spacingMultiplier, spacingUnit] = splitNumberAndUnit(spacingScale)
if (!spacingMultiplier || !spacingUnit) return
if (spacingScale && !replacedThemeKeys.has('spacing')) {
for (let [key, value] of Object.entries(resolvedConfig.theme.spacing ?? {})) {
let [multiplier, unit] = splitNumberAndUnit(value as string)
if (multiplier === null) continue
if (!isValidSpacingMultiplier(key)) continue
if (unit !== spacingUnit) continue
if (parseFloat(multiplier) === Number(key) * parseFloat(spacingMultiplier)) {
delete resolvedConfig.theme.spacing[key]
designSystem.theme.clearNamespace(escape(`--spacing-${key.replaceAll('.', '_')}`), 0)
}
}
}
}
function splitNumberAndUnit(value: string): [string, string] | [null, null] {
let match = value.match(/^([0-9.]+)(.*)$/)
if (!match) {
return [null, null]
}
return [match[1], match[2]]
}