Skip to content

Commit

Permalink
implement fallback plugins
Browse files Browse the repository at this point in the history
Whenever an arbitrary value results in css from multiple plugins we
first try to resolve a falback plugin.

The fallback mechanism works like this:

- If A has type `any` and B has type `color`, then B should win.

  > This is because `A` will match *anything*, but the more precise type
    should win instead. E.g.: `backgroundColor` has the type `any` so
    `bg-[100px_200px]` would match both the `backgroundColor` and
    `backgroundSize` but `backgroundSize` matched because of a specific
    type and not because of the `any` type.
- If A has type `length` and B has type `[length, { disambiguate: true }]`, then B should win.
  > This is because `B` marked the `length` as the plugin that should
    win in case a clash happens.
  • Loading branch information
RobinMalfait committed Sep 23, 2022
1 parent b15bde1 commit daf10f9
Show file tree
Hide file tree
Showing 3 changed files with 148 additions and 61 deletions.
161 changes: 114 additions & 47 deletions src/lib/generateRules.js
Expand Up @@ -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'
Expand Down Expand Up @@ -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])))
Expand Down
44 changes: 32 additions & 12 deletions src/lib/setupContextUtils.js
Expand Up @@ -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.`)
Expand Down Expand Up @@ -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')

Expand All @@ -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)) {
Expand Down Expand Up @@ -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')

Expand All @@ -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}\`.`,
Expand Down Expand Up @@ -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) =>
Expand Down
4 changes: 2 additions & 2 deletions src/util/pluginUtils.js
Expand Up @@ -146,7 +146,7 @@ function guess(validate) {
}
}

let typeMap = {
export let typeMap = {
any: asValue,
color: asColor,
url: guess(url),
Expand Down Expand Up @@ -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]
}
Expand Down

0 comments on commit daf10f9

Please sign in to comment.