diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index 116813a6d1ff..e7c1d7034b60 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -3,7 +3,7 @@ import selectorParser from 'postcss-selector-parser' import parseObjectStyles from '../util/parseObjectStyles' import isPlainObject from '../util/isPlainObject' import prefixSelector from '../util/prefixSelector' -import { updateAllClasses } from '../util/pluginUtils' +import { updateAllClasses, typeMap } from '../util/pluginUtils' import log from '../util/log' import * as sharedState from './sharedState' import { formatVariantSelector, finalizeSelector } from '../util/formatVariantSelector' @@ -535,68 +535,135 @@ function* resolveMatches(candidate, context, original = candidate) { } if (matchesPerPlugin.length > 0) { - typesByMatches.set(matchesPerPlugin, sort.options?.type) + let matchingTypes = (sort.options?.types ?? []) + .map(({ type }) => type) + // Only track the types for this plugin that resulted in some result + .filter((type) => { + return Boolean( + typeMap[type](modifier, sort.options, { + tailwindConfig: context.tailwindConfig, + }) + ) + }) + + if (matchingTypes.length > 0) { + typesByMatches.set(matchesPerPlugin, matchingTypes) + } + matches.push(matchesPerPlugin) } } if (isArbitraryValue(modifier)) { - // When generated arbitrary values are ambiguous, we can't know - // which to pick so don't generate any utilities for them if (matches.length > 1) { - let typesPerPlugin = matches.map((match) => new Set([...(typesByMatches.get(match) ?? [])])) + // Partition plugins in 2 categories so that we can start searching in the plugins that + // don't have `any` as a type first. + let [withAny, withoutAny] = matches.reduce( + (group, plugin) => { + let hasAnyType = plugin.some(([{ options }]) => + options.types.some(({ type }) => type === 'any') + ) - // Remove duplicates, so that we can detect proper unique types for each plugin. - for (let pluginTypes of typesPerPlugin) { - for (let type of pluginTypes) { - let removeFromOwnGroup = false + if (hasAnyType) { + group[0].push(plugin) + } else { + group[1].push(plugin) + } + return group + }, + [[], []] + ) - for (let otherGroup of typesPerPlugin) { - if (pluginTypes === otherGroup) continue + function findFallback(matches) { + // If only a single plugin matches, let's take that one + if (matches.length === 1) { + return matches[0] + } - if (otherGroup.has(type)) { - otherGroup.delete(type) - removeFromOwnGroup = true + // Otherwise, find the plugin that creates a valid rule given the arbitrary value, and + // also has the correct type which disambiguates the plugin in case of clashes. + return matches.find((rules) => { + let matchingTypes = typesByMatches.get(rules) + return rules.some(([{ options }, rule]) => { + if (!isParsableNode(rule)) { + return false } - } - if (removeFromOwnGroup) pluginTypes.delete(type) - } + return options.types.some( + ({ type, disambiguate }) => matchingTypes.includes(type) && disambiguate + ) + }) + }) } - let messages = [] - - for (let [idx, group] of typesPerPlugin.entries()) { - for (let type of group) { - let rules = matches[idx] - .map(([, rule]) => rule) - .flat() - .map((rule) => - rule - .toString() - .split('\n') - .slice(1, -1) // Remove selector and closing '}' - .map((line) => line.trim()) - .map((x) => ` ${x}`) // Re-indent - .join('\n') - ) - .join('\n\n') + // Try to find a fallback plugin, because we already know that multiple plugins matched for + // the given arbitrary value. + let fallback = findFallback(withoutAny) ?? findFallback(withAny) + if (fallback) { + matches = [fallback] + } - messages.push( - ` Use \`${candidate.replace('[', `[${type}:`)}\` for \`${rules.trim()}\`` - ) - break + // We couldn't find a fallback plugin which means that there are now multiple plugins that + // generated css for the current candidate. This means that the result is ambiguous and this + // should not happen. We won't generate anything right now, so let's report this to the user + // by logging some options about what they can do. + else { + let typesPerPlugin = matches.map( + (match) => new Set([...(typesByMatches.get(match) ?? [])]) + ) + + // Remove duplicates, so that we can detect proper unique types for each plugin. + for (let pluginTypes of typesPerPlugin) { + for (let type of pluginTypes) { + let removeFromOwnGroup = false + + for (let otherGroup of typesPerPlugin) { + if (pluginTypes === otherGroup) continue + + if (otherGroup.has(type)) { + otherGroup.delete(type) + removeFromOwnGroup = true + } + } + + if (removeFromOwnGroup) pluginTypes.delete(type) + } } - } - log.warn([ - `The class \`${candidate}\` is ambiguous and matches multiple utilities.`, - ...messages, - `If this is content and not a class, replace it with \`${candidate - .replace('[', '[') - .replace(']', ']')}\` to silence this warning.`, - ]) - continue + let messages = [] + + for (let [idx, group] of typesPerPlugin.entries()) { + for (let type of group) { + let rules = matches[idx] + .map(([, rule]) => rule) + .flat() + .map((rule) => + rule + .toString() + .split('\n') + .slice(1, -1) // Remove selector and closing '}' + .map((line) => line.trim()) + .map((x) => ` ${x}`) // Re-indent + .join('\n') + ) + .join('\n\n') + + messages.push( + ` Use \`${candidate.replace('[', `[${type}:`)}\` for \`${rules.trim()}\`` + ) + break + } + } + + log.warn([ + `The class \`${candidate}\` is ambiguous and matches multiple utilities.`, + ...messages, + `If this is content and not a class, replace it with \`${candidate + .replace('[', '[') + .replace(']', ']')}\` to silence this warning.`, + ]) + continue + } } matches = matches.map((list) => list.filter((match) => isParsableNode(match[1]))) diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js index d15e8db8c70c..3898951429e4 100644 --- a/src/lib/setupContextUtils.js +++ b/src/lib/setupContextUtils.js @@ -30,6 +30,20 @@ function prefix(context, selector) { return typeof prefix === 'function' ? prefix(selector) : prefix + selector } +function normalizeOptionTypes({ type = 'any', ...options }) { + let types = [].concat(type) + + return { + ...options, + types: types.map((type) => { + if (Array.isArray(type)) { + return { type: type[0], ...type[1] } + } + return { type, disambiguate: false } + }), + } +} + function parseVariantFormatString(input) { if (input.includes('{')) { if (!isBalanced(input)) throw new Error(`Your { and } are unbalanced.`) @@ -346,7 +360,7 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs respectImportant: true, } - options = { ...defaultOptions, ...options } + options = normalizeOptionTypes({ ...defaultOptions, ...options }) let offset = offsets.create('utilities') @@ -357,16 +371,24 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs classList.add([prefixedIdentifier, options]) function wrapped(modifier, { isOnlyPlugin }) { - let { type = 'any' } = options - type = [].concat(type) - let [value, coercedType] = coerceValue(type, modifier, options, tailwindConfig) + let [value, coercedType] = coerceValue(options.types, modifier, options, tailwindConfig) if (value === undefined) { return [] } - if (!type.includes(coercedType) && !isOnlyPlugin) { - return [] + if (!options.types.some(({ type }) => type === coercedType)) { + if (isOnlyPlugin) { + log.warn([ + `Unnecessary typehint \`${coercedType}\` in \`${identifier}-${modifier}\`.`, + `You can safely update it to \`${identifier}-${modifier.replace( + coercedType + ':', + '' + )}\`.`, + ]) + } else { + return [] + } } if (!isValidArbitraryValue(value)) { @@ -398,7 +420,7 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs respectImportant: false, } - options = { ...defaultOptions, ...options } + options = normalizeOptionTypes({ ...defaultOptions, ...options }) let offset = offsets.create('components') @@ -409,15 +431,13 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs classList.add([prefixedIdentifier, options]) function wrapped(modifier, { isOnlyPlugin }) { - let { type = 'any' } = options - type = [].concat(type) - let [value, coercedType] = coerceValue(type, modifier, options, tailwindConfig) + let [value, coercedType] = coerceValue(options.types, modifier, options, tailwindConfig) if (value === undefined) { return [] } - if (!type.includes(coercedType)) { + if (!options.types.some(({ type }) => type === coercedType)) { if (isOnlyPlugin) { log.warn([ `Unnecessary typehint \`${coercedType}\` in \`${identifier}-${modifier}\`.`, @@ -734,7 +754,7 @@ function registerPlugins(plugins, context) { ] } - if ([].concat(options?.type).includes('color')) { + if (options.types.some(({ type }) => type === 'color')) { classes = [ ...classes, ...classes.flatMap((cls) => diff --git a/src/util/pluginUtils.js b/src/util/pluginUtils.js index 61a401822bf1..d056a78cee01 100644 --- a/src/util/pluginUtils.js +++ b/src/util/pluginUtils.js @@ -146,7 +146,7 @@ function guess(validate) { } } -let typeMap = { +export let typeMap = { any: asValue, color: asColor, url: guess(url), @@ -195,7 +195,7 @@ export function coerceValue(types, modifier, options, tailwindConfig) { } // Find first matching type - for (let type of [].concat(types)) { + for (let { type } of types) { let result = typeMap[type](modifier, options, { tailwindConfig }) if (result !== undefined) return [result, type] }