Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ import { buildCustomContainerUtilityRules } from '../../../../tailwindcss/src/co
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 { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import { escape } from '../../../../tailwindcss/src/utils/escape'
import {
isValidOpacityValue,
isValidSpacingMultiplier,
} from '../../../../tailwindcss/src/utils/infer-data-type'
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
import { findStaticPlugins, type StaticPluginOptions } from '../../utils/extract-static-plugins'
import { highlight, info, relative } from '../../utils/renderer'
import { highlight, info, relative, warn } from '../../utils/renderer'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
Expand All @@ -49,11 +50,51 @@ export async function migrateJsConfig(
fs.readFile(fullConfigPath, 'utf-8'),
])

if (!canMigrateConfig(unresolvedConfig, source)) {
let canMigrateConfigResult = canMigrateConfig(unresolvedConfig, source)
if (!canMigrateConfigResult.valid) {
info(
`The configuration file at ${highlight(relative(fullConfigPath, base))} could not be automatically migrated to the new CSS configuration format, so your CSS has been updated to load your existing configuration file.`,
{ prefix: '↳ ' },
)
for (let [category, messages] of canMigrateConfigResult.errors.entries()) {
switch (category) {
case 'general': {
for (let msg of messages) warn(msg, { prefix: ' ↳ ' })
break
}
case 'top-level': {
warn(
`Cannot migrate unknown top-level keys:\n${Array.from(messages)
.map((key) => ` - \`${key}\``)
.join(
'\n',
)}\n\nThese are non-standard Tailwind CSS options, so we don't know how to migrate them to CSS.`,
{ prefix: ' ↳ ' },
)
break
}
case 'unknown-theme-keys': {
warn(
`Cannot migrate unknown theme keys:\n${Array.from(messages)
.map((key) => ` - \`${key}\``)
.join(
'\n',
)}\n\nThese are non-standard Tailwind CSS theme keys, so we don't know how to migrate them to CSS.`,
{ prefix: ' ↳ ' },
)
break
}
case 'complex-screens': {
warn(
`Cannot migrate complex screen configuration (min/max/raw):\n${Array.from(messages)
.map((key) => ` - \`${key}\``)
.join('\n')}`,
{ prefix: ' ↳ ' },
)
break
}
}
}
return null
}

Expand Down Expand Up @@ -384,22 +425,36 @@ async function migrateContent(
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)
const JS_IDENTIFIER_REGEX = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/
function stringifyPath(path: (string | number)[]): string {
let result = ''

for (let segment of path) {
if (typeof segment === 'number') {
result += `[${segment}]`
} else if (!JS_IDENTIFIER_REGEX.test(segment)) {
result += `[\`${segment}\`]`
} else {
result += result.length > 0 ? `.${segment}` : segment
}
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
return result
}

// Keep track of issues given a category and a set of messages
const MIGRATION_ISSUES = new DefaultMap(() => new Set<string>())

// Applies heuristics to determine if we can attempt to migrate the config
function canMigrateConfig(
unresolvedConfig: Config,
source: string,
): { valid: true } | { valid: false; errors: typeof MIGRATION_ISSUES } {
let theme = unresolvedConfig.theme

// Migrating presets are not supported
if (unresolvedConfig.presets && unresolvedConfig.presets.length > 0) {
MIGRATION_ISSUES.get('general').add('Cannot migrate config files that use presets')
}

// The file may only contain known-migratable top-level properties
Expand All @@ -413,49 +468,46 @@ function canMigrateConfig(unresolvedConfig: Config, source: string): boolean {
'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
for (let key of Object.keys(unresolvedConfig)) {
if (!knownProperties.includes(key)) {
MIGRATION_ISSUES.get('top-level').add(key)
}
}

// 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
let { extend, ...themeCopy } = theme
onlyAllowedThemeValues(themeCopy, ['theme'])
if (extend) onlyAllowedThemeValues(extend, ['theme', 'extend'])
}

return true
// TODO: findStaticPlugins already logs errors for unsupported plugins, maybe
// it should return them instead?
findStaticPlugins(source)

return MIGRATION_ISSUES.size <= 0 ? { valid: true } : { valid: false, errors: MIGRATION_ISSUES }
}

const ALLOWED_THEME_KEYS = [
...Object.keys(defaultTheme),
// Used by @tailwindcss/container-queries
'containers',
]
function onlyAllowedThemeValues(theme: ThemeConfig): boolean {
function onlyAllowedThemeValues(theme: ThemeConfig, path: (string | number)[]) {
for (let key of Object.keys(theme)) {
if (!ALLOWED_THEME_KEYS.includes(key)) {
return false
MIGRATION_ISSUES.get('unknown-theme-keys').add(stringifyPath([...path, key]))
}
}

if ('screens' in theme && typeof theme.screens === 'object' && theme.screens !== null) {
for (let screen of Object.values(theme.screens)) {
for (let [name, screen] of Object.entries(theme.screens)) {
if (typeof screen === 'object' && screen !== null && ('max' in screen || 'raw' in screen)) {
return false
MIGRATION_ISSUES.get('complex-screens').add(stringifyPath([...path, 'screens', name]))
}
}
}
return true
}

function keyframesToCss(keyframes: Record<string, unknown>): string {
Expand Down
2 changes: 1 addition & 1 deletion packages/@tailwindcss-upgrade/src/utils/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function wordWrap(text: string, width: number): string[] {
// Handle text with newlines by maintaining the newlines, then splitting
// each line separately.
if (text.includes('\n')) {
return text.split('\n').flatMap((line) => wordWrap(line, width))
return text.split('\n').flatMap((line) => (line ? wordWrap(line, width) : ['']))
}

let words = text.split(' ')
Expand Down
2 changes: 1 addition & 1 deletion packages/tailwindcss/src/compat/config/deep-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function deepMerge<T extends object>(
type Value = T[Key]

for (let source of sources) {
if (source === null || source === undefined) {
if (source === null || source === undefined || typeof source !== 'object') {
continue
}

Expand Down
68 changes: 68 additions & 0 deletions packages/tailwindcss/src/compat/plugin-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4444,6 +4444,43 @@ describe('addComponents()', () => {
}"
`)
})

test('throws on custom static utilities with an invalid name', async () => {
await expect(() => {
return compile(
css`
@plugin "my-plugin";
@layer utilities {
@tailwind utilities;
}

@theme reference {
--breakpoint-lg: 1024px;
}
`,
{
async loadModule(id, base) {
return {
path: '',
base,
module: ({ addComponents }: PluginAPI) => {
addComponents({
':hover > *': {
'text-box-trim': 'both',
'text-box-edge': 'cap alphabetic',
},
})
},
}
},
},
)
}).rejects.toThrowErrorMatchingInlineSnapshot(`
[Error: \`addComponents({ ':hover > *': … })\` defines an invalid utility selector. Components must be a single class name and start with a lowercase letter, eg. \`.scrollbar-none\`.

Note: in Tailwind CSS v4 \`addComponents\` is an alias for \`addUtilities\`.]
`)
})
})

describe('matchComponents()', () => {
Expand Down Expand Up @@ -4490,6 +4527,37 @@ describe('matchComponents()', () => {
}"
`)
})

test('throws on custom utilities with an invalid name', async () => {
await expect(() => {
return compile(
css`
@plugin "my-plugin";
@tailwind utilities;
`,
{
async loadModule(id, base) {
return {
path: '',
base,
module: ({ matchComponents }: PluginAPI) => {
matchComponents({
'.text-trim > *': () => ({
'text-box-trim': 'both',
'text-box-edge': 'cap alphabetic',
}),
})
},
}
},
},
)
}).rejects.toThrowErrorMatchingInlineSnapshot(`
[Error: \`matchComponents({ '.text-trim > *': … })\` defines an invalid utility name. Components should be alphanumeric and start with a lowercase letter, eg. \`scrollbar\`.

Note: in Tailwind CSS v4 \`matchComponents\` is an alias for \`matchUtilities\`.]
`)
})
})

describe('prefix()', () => {
Expand Down
28 changes: 24 additions & 4 deletions packages/tailwindcss/src/compat/plugin-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ export function buildPluginApi({

if (!foundValidUtility) {
throw new Error(
`\`addUtilities({ '${name}' : … })\` defines an invalid utility selector. Utilities must be a single class name and start with a lowercase letter, eg. \`.scrollbar-none\`.`,
`\`addUtilities({ '${name}': … })\` defines an invalid utility selector. Utilities must be a single class name and start with a lowercase letter, eg. \`.scrollbar-none\`.`,
)
}
}
Expand Down Expand Up @@ -347,7 +347,7 @@ export function buildPluginApi({
for (let [name, fn] of Object.entries(utilities)) {
if (!IS_VALID_UTILITY_NAME.test(name)) {
throw new Error(
`\`matchUtilities({ '${name}' : … })\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter, eg. \`scrollbar\`.`,
`\`matchUtilities({ '${name}': … })\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter, eg. \`scrollbar\`.`,
)
}

Expand Down Expand Up @@ -493,11 +493,31 @@ export function buildPluginApi({
},

addComponents(components, options) {
this.addUtilities(components, options)
try {
this.addUtilities(components, options)
} catch (e) {
if (e instanceof Error) {
throw new Error(
`${e.message.replaceAll('addUtilities', 'addComponents').replaceAll('Utilities', 'Components')}\n\nNote: in Tailwind CSS v4 \`addComponents\` is an alias for \`addUtilities\`.`,
)
} else {
throw e
}
}
},

matchComponents(components, options) {
this.matchUtilities(components, options)
try {
this.matchUtilities(components, options)
} catch (e) {
if (e instanceof Error) {
throw new Error(
`${e.message.replaceAll('matchUtilities', 'matchComponents').replaceAll('Utilities', 'Components')}\n\nNote: in Tailwind CSS v4 \`matchComponents\` is an alias for \`matchUtilities\`.`,
)
} else {
throw e
}
}
},

theme: createThemeFn(
Expand Down
Loading