Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Add simple JS config migration
  • Loading branch information
philipp-spiess committed Oct 11, 2024
commit cb2e7e7d771e5a2ef51213168582db0094bba192
66 changes: 66 additions & 0 deletions integrations/upgrade/js-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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 = {
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
`,
'src/input.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
},
},
async ({ exec, fs }) => {
console.log(await exec('npx @tailwindcss/upgrade'))

await fs.expectFileToContain('src/input.css', css` @import 'tailwindcss'; `)
expect(fs.read('tailwind.config.ts')).rejects.toMatchInlineSnapshot()
},
)
9 changes: 9 additions & 0 deletions packages/@tailwindcss-upgrade/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -81,6 +82,14 @@ async function run() {
success('Template migration complete.')
}

{
// Migrate JS config

info('Migrating JavaScript configuration files using the provided configuration file.')

await migrateJsConfig(config.configFilePath)
}

{
// Stylesheet migrations

Expand Down
121 changes: 121 additions & 0 deletions packages/@tailwindcss-upgrade/src/migrate-js-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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 { info } from './utils/renderer'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

export async function migrateJsConfig(fullConfigPath: string): Promise<void> {
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
}

let cssConfigs: string[] = []

if ('content' in unresolvedConfig) {
cssConfigs.push(migrateContent(unresolvedConfig as any))
}

if ('theme' in unresolvedConfig) {
cssConfigs.push(await migrateTheme(unresolvedConfig as any))
}

console.log(cssConfigs.join('\n'))
}

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

let resetNamespaces = new Set()

let css = `@theme reference inline {\n`
for (let [key, value] of themeableValues(overwriteTheme)) {
if (typeof value !== 'string' && typeof value !== 'number') {
continue
}

if (!resetNamespaces.has(key[0])) {
resetNamespaces.add(key[0])
css += ` --${keyPathToCssProperty([key[0]])}-*: initial;\n`
}

css += ` --${keyPathToCssProperty(key)}: ${value};\n`
}

for (let [key, value] of themeableValues(extendTheme)) {
if (typeof value !== 'string' && typeof value !== 'number') {
continue
}

css += ` --${keyPathToCssProperty(key)}: ${value};\n`
}

return css + '}\n'
}

function migrateContent(unresolvedConfig: Config & { content: any }): string {
let css = ''
for (let content of unresolvedConfig.content) {
if (typeof content !== 'string') {
throw new Error('Unsupported content value: ' + content)
}

css += `@source "${content}";\n`
}
return css
}

// Applies heuristics to determine if we can attempt to migrate the config
async function isSimpleConfig(unresolvedConfig: Config, source: string): Promise<boolean> {
// The file may not contain any functions
if (source.includes('function') || source.includes(' => ')) {
return false
}

// The file may not contain non-serializable values
const 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 high-level properties
const knownProperties = ['content', 'theme', 'plugins', 'presets']
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
}

// The file may not contain any imports
if (source.includes('import') || source.includes('require')) {
return false
}

return true
}
3 changes: 1 addition & 2 deletions packages/@tailwindcss-upgrade/src/template/prepare-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -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) => {
Expand Down Expand Up @@ -110,7 +110,7 @@ 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'

Expand Down