Compare commits

...
Sign in to create a new pull request.

5 commits

Author SHA1 Message Date
Robin Malfait
1d1535af90
ensure \n\n are kept as \n\n
When splitting `'foo\n\nbar'` by `\n`, you will get `['foo', '', 'bar']`.

The `''` value will result in `[]` after the word wrapping. This
information gets lost when we `flatMap`, so let's keep the newline using
`['']` as the fallback.
2025-08-28 16:56:56 +02:00
Robin Malfait
be10cf4474
categorize JS migration issues
Co-Authored-By: Jordan Pittman <thecrypticace@gmail.com>
2025-08-28 16:56:51 +02:00
Robin Malfait
a3e5813eec
show object keys that result in failing JS migration
Before this, we would just show "Cannot migrate"-like error messages.
But this will show a bit more detail about which theme keys are the
culprit.

Co-Authored-By: Jordan Pittman <thecrypticace@gmail.com>
2025-08-28 16:55:05 +02:00
Robin Malfait
8bda1db8c8
do not assume source is an object
Otherwise `Reflect.ownKeys` would crash on non-object values.

Co-Authored-By: Jordan Pittman <thecrypticace@gmail.com>
2025-08-28 16:53:54 +02:00
Robin Malfait
81c883d035
update error messages when using addComponents or matchComponents
Nothing new here, but this improves the error messages a bit when using
`addComponents` or `matchComponents` because otherwise the error shows
`addUtilities` or `matchUtilities`.

These also add an additional note:

> Note: in Tailwind CSS v4, `matchComponents` is an alias for `matchUtilities`.
2025-08-28 16:53:54 +02:00
5 changed files with 180 additions and 40 deletions

View file

@ -19,6 +19,7 @@ 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,
@ -26,7 +27,7 @@ import {
} 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)
@ -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
}
@ -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
@ -413,27 +468,25 @@ 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 = [
@ -441,21 +494,20 @@ const ALLOWED_THEME_KEYS = [
// 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 {

View file

@ -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(' ')

View file

@ -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
}

View file

@ -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()', () => {
@ -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()', () => {

View file

@ -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\`.`,
)
}
}
@ -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\`.`,
)
}
@ -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(