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
```
370 lines
12 KiB
TypeScript
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]]
|
|
}
|