This commit is contained in:
Jordan Pittman 2022-05-18 10:11:06 -04:00
parent 3cb47aaab6
commit 5bcd9d2f69
5 changed files with 175 additions and 145 deletions

View file

@ -7,10 +7,9 @@ import { updateAllClasses } from '../util/pluginUtils'
import log from '../util/log'
import { formatVariantSelector, finalizeSelector } from '../util/formatVariantSelector'
import { asClass } from '../util/nameClass'
import { normalize } from '../util/dataTypes'
import { isValidVariantFormatString, parseVariant } from './setupContextUtils'
import { isParsableNode } from '../util/css-validation.js'
import { parseCandidate } from '../lib/candidate.js'
import { isParsableNode } from '../util/css-validation'
import { parseCandidate } from './candidate'
let classNameParser = selectorParser((selectors) => {
return selectors.first.filter(({ type }) => type === 'class').pop().value
@ -20,42 +19,6 @@ function getClassNameFromSelector(selector) {
return classNameParser.transformSync(selector)
}
// Generate match permutations for a class candidate, like:
// ['ring-offset-blue', '100']
// ['ring-offset', 'blue-100']
// ['ring', 'offset-blue-100']
// Example with dynamic classes:
// ['grid-cols', '[[linename],1fr,auto]']
// ['grid', 'cols-[[linename],1fr,auto]']
function* candidatePermutations(candidate) {
let lastIndex = Infinity
while (lastIndex >= 0) {
let dashIdx
if (lastIndex === Infinity && candidate.endsWith(']')) {
let bracketIdx = candidate.indexOf('[')
// If character before `[` isn't a dash or a slash, this isn't a dynamic class
// eg. string[]
dashIdx = ['-', '/'].includes(candidate[bracketIdx - 1]) ? bracketIdx - 1 : -1
} else {
dashIdx = candidate.lastIndexOf('-', lastIndex)
}
if (dashIdx < 0) {
break
}
let prefix = candidate.slice(0, dashIdx)
let modifier = candidate.slice(dashIdx + 1)
yield [prefix, modifier]
lastIndex = dashIdx - 1
}
}
function applyPrefix(matches, context) {
if (matches.length === 0 || context.tailwindConfig.prefix === '') {
return matches
@ -314,21 +277,21 @@ function extractArbitraryProperty(parsed, context) {
]
}
/**
*
* @param {import('./candidate').Candidate} parsed
* @param {any} context
*/
function* resolveMatchedPlugins(parsed, context) {
let classCandidate = parsed.withoutVariants
if (context.candidateRuleMap.has(classCandidate)) {
yield [context.candidateRuleMap.get(classCandidate), 'DEFAULT']
}
if (parsed.type === 'custom') {
yield [extractArbitraryProperty(parsed, context), 'DEFAULT']
}
if (parsed.type !== 'constrained' && parsed.type !== 'partial') {
if (parsed.type !== 'constrained') {
return
}
// TODO: Can this be dropped?
let candidatePrefix = parsed.negative
? parsed.prefix + parsed.withoutVariants.slice(parsed.prefix.length + 1)
: parsed.withoutVariants
@ -337,9 +300,13 @@ function* resolveMatchedPlugins(parsed, context) {
yield [context.candidateRuleMap.get(candidatePrefix), '-DEFAULT']
}
for (let [prefix, modifier] of candidatePermutations(candidatePrefix)) {
if (context.candidateRuleMap.has(prefix)) {
yield [context.candidateRuleMap.get(prefix), parsed.negative ? `-${modifier}` : modifier]
for (let plugin of parsed.plugins) {
let ruleGenerator =
context.candidateRuleMap.get(parsed.prefix + plugin.plugin) ||
context.candidateRuleMap.get(plugin.plugin)
if (ruleGenerator) {
yield [ruleGenerator, plugin.value, plugin]
}
}
}
@ -358,15 +325,15 @@ function* recordCandidates(matches, candidate) {
}
}
function* resolveMatches(candidate, context) {
let parsed = parseCandidate(candidate, context)
if (parsed === null) {
return
function* resolveMatches(candidateRaw, context) {
for (const candidate of parseCandidate(candidateRaw, context)) {
yield* resolveMatchesForCandidate(candidate, context)
}
}
let important = parsed.important
let variants = parsed.variants
function* resolveMatchesForCandidate(candidate, context) {
let important = candidate.important
let variants = candidate.variants
// TODO: Reintroduce this in ways that doesn't break on false positives
// function sortAgainst(toSort, against) {
@ -380,18 +347,20 @@ function* resolveMatches(candidate, context) {
// throw new Error(`Class ${candidate} should be written as ${corrected}`)
// }
for (let matchedPlugins of resolveMatchedPlugins(parsed, context)) {
for (let matchedPlugins of resolveMatchedPlugins(candidate, context)) {
let matches = []
let typesByMatches = new Map()
let [plugins, modifier] = matchedPlugins
let [plugins, modifier, candidatePlugin] = matchedPlugins
let isOnlyPlugin = plugins.length === 1
for (let [sort, plugin] of plugins) {
let matchesPerPlugin = []
if (typeof plugin === 'function') {
for (let ruleSet of [].concat(plugin(modifier, { isOnlyPlugin }))) {
for (let ruleSet of [].concat(
plugin(modifier, { isOnlyPlugin, candidate, candidatePlugin })
)) {
let [rules, options] = parseRules(ruleSet, context.postCssNodeCache)
for (let rule of rules) {
matchesPerPlugin.push([{ ...sort, options: { ...sort.options, ...options } }, rule])
@ -413,7 +382,7 @@ function* resolveMatches(candidate, context) {
}
}
if (isArbitraryValue(modifier)) {
if (typeof modifier === 'object') {
// 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) {
@ -456,16 +425,16 @@ function* resolveMatches(candidate, context) {
.join('\n\n')
messages.push(
` Use \`${candidate.replace('[', `[${type}:`)}\` for \`${rules.trim()}\``
` Use \`${candidate.raw.replace('[', `[${type}:`)}\` for \`${rules.trim()}\``
)
break
}
}
log.warn([
`The class \`${candidate}\` is ambiguous and matches multiple utilities.`,
`The class \`${candidate.raw}\` is ambiguous and matches multiple utilities.`,
...messages,
`If this is content and not a class, replace it with \`${candidate
`If this is content and not a class, replace it with \`${candidate.raw
.replace('[', '&lsqb;')
.replace(']', '&rsqb;')}\` to silence this warning.`,
])
@ -476,19 +445,22 @@ function* resolveMatches(candidate, context) {
}
matches = matches.flat()
matches = Array.from(recordCandidates(matches, parsed))
matches = Array.from(recordCandidates(matches, candidate))
matches = applyPrefix(matches, context)
if (important) {
matches = applyImportant(matches, parsed)
matches = applyImportant(matches, candidate)
}
for (let variant of variants) {
matches = applyVariant(variant, matches, context)
}
let foundMatches = false
for (let match of matches) {
match[1].raws.tailwind = { ...match[1].raws.tailwind, candidate }
foundMatches = true
match[1].raws.tailwind = { ...match[1].raws.tailwind, candidate: candidate.raw }
// Apply final format selector
if (match[0].collectedFormats) {
@ -499,7 +471,7 @@ function* resolveMatches(candidate, context) {
rule.selector = finalizeSelector(finalFormat, {
selector: rule.selector,
candidate,
candidate: candidate.raw,
context,
})
})
@ -508,6 +480,25 @@ function* resolveMatches(candidate, context) {
yield match
}
// We always want to emit matches for `DEFAULT`-ish values
// so we'll continue matching in that case
if (
candidatePlugin &&
(candidatePlugin.value === 'DEFAULT' || candidatePlugin.value === '-DEFAULT')
) {
continue
}
// If we've emitted matches we're done searching
// This is because the order of matched values is as follows:
// - Real values from the config
// - Real values from the config accounting for modifiers
// - Arbitrary values
// - Arbitrary values accounting for modifiers
if (foundMatches) {
break
}
}
}
@ -579,8 +570,4 @@ function generateRules(candidates, context) {
})
}
function isArbitraryValue(input) {
return input.startsWith('[') && input.endsWith(']')
}
export { resolveMatches, generateRules }

View file

@ -371,10 +371,17 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
classList.add([prefixedIdentifier, options])
function wrapped(modifier, { isOnlyPlugin }) {
function wrapped(modifier, { isOnlyPlugin, candidate, candidatePlugin }) {
let { type = 'any' } = options
type = [].concat(type)
let [value, coercedType] = coerceValue(type, modifier, options, tailwindConfig)
let [value, coercedType] = coerceValue(
type,
modifier,
options,
tailwindConfig,
candidate,
candidatePlugin
)
if (value === undefined) {
return []
@ -384,15 +391,36 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
return []
}
// TODO: Can we remove this?
// This check is done in the candidate section
if (!isValidArbitraryValue(value)) {
return []
}
let tempIdentifier = identifier
if (typeof modifier === 'object') {
modifier = modifier.raw
if (candidate.negative && !tempIdentifier.startsWith('-')) {
tempIdentifier = `-${tempIdentifier}`
}
}
// TODO: Remove and use .className
let alphaModifierValue = candidatePlugin?.modifiers[0]?.value
if (alphaModifierValue !== undefined) {
if (typeof alphaModifierValue === 'object') {
alphaModifierValue = alphaModifierValue.raw
}
modifier += `/${alphaModifierValue}`
}
let ruleSets = []
.concat(rule(value))
.filter(Boolean)
.map((declaration) => ({
[nameClass(identifier, modifier)]: declaration,
[nameClass(tempIdentifier, modifier)]: declaration,
}))
return ruleSets
@ -423,10 +451,17 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
classList.add([prefixedIdentifier, options])
function wrapped(modifier, { isOnlyPlugin }) {
function wrapped(modifier, { isOnlyPlugin, candidate, candidatePlugin }) {
let { type = 'any' } = options
type = [].concat(type)
let [value, coercedType] = coerceValue(type, modifier, options, tailwindConfig)
let [value, coercedType] = coerceValue(
type,
modifier,
options,
tailwindConfig,
candidate,
candidatePlugin
)
if (value === undefined) {
return []
@ -446,15 +481,26 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
}
}
// TODO: Can we remove this?
// This check is done in the candidate section
if (!isValidArbitraryValue(value)) {
return []
}
let tempIdentifier = identifier
if (typeof modifier === 'object') {
modifier = modifier.raw
if (candidate.negative && !tempIdentifier.startsWith('-')) {
tempIdentifier = `-${tempIdentifier}`
}
}
let ruleSets = []
.concat(rule(value))
.filter(Boolean)
.map((declaration) => ({
[nameClass(identifier, modifier)]: declaration,
[nameClass(tempIdentifier, modifier)]: declaration,
}))
return ruleSets

View file

@ -6,7 +6,7 @@ let cssFunctions = ['min', 'max', 'clamp', 'calc']
// Ref: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Types
let COMMA = /,(?![^(]*\))/g // Comma separator that is not located between brackets. E.g.: `cubiz-bezier(a, b, c)` these don't count.
let UNDERSCORE = /_(?![^(]*\))/g // Underscore separator that is not located between brackets. E.g.: `rgba(255,_255,_255)_black` these don't count.
let SPACE = /[ ](?![^(]*\))/g // space separator that is not located between brackets. E.g.: `rgba(255, 255, 255) black` these don't count.
// This is not a data type, but rather a function that can normalize the
// correct values.
@ -63,7 +63,7 @@ export function number(value) {
}
export function percentage(value) {
return value.split(UNDERSCORE).every((part) => {
return value.split(SPACE).every((part) => {
return /%$/g.test(part) || cssFunctions.some((fn) => new RegExp(`^${fn}\\(.+?%`).test(part))
})
}
@ -88,7 +88,7 @@ let lengthUnits = [
]
let lengthUnitsPattern = `(?:${lengthUnits.join('|')})`
export function length(value) {
return value.split(UNDERSCORE).every((part) => {
return value.split(SPACE).every((part) => {
return (
part === '0' ||
new RegExp(`${lengthUnitsPattern}$`).test(part) ||
@ -117,7 +117,7 @@ export function shadow(value) {
export function color(value) {
let colors = 0
let result = value.split(UNDERSCORE).every((part) => {
let result = value.split(SPACE).every((part) => {
part = normalize(part)
if (part.startsWith('var(')) return true
@ -173,7 +173,7 @@ export function gradient(value) {
let validPositions = new Set(['center', 'top', 'right', 'bottom', 'left'])
export function position(value) {
let positions = 0
let result = value.split(UNDERSCORE).every((part) => {
let result = value.split(SPACE).every((part) => {
part = normalize(part)
if (part.startsWith('var(')) return true

View file

@ -6,6 +6,10 @@ export function asClass(name) {
}
export default function nameClass(classPrefix, key) {
if (typeof key === 'object') {
key = key.raw
}
return asClass(formatClass(classPrefix, key))
}

View file

@ -36,17 +36,15 @@ export function updateAllClasses(selectors, updateClass) {
}
function resolveArbitraryValue(modifier, validate) {
if (!isArbitraryValue(modifier)) {
return undefined
if (isArbitraryValue(modifier)) {
if (!validate(modifier.value)) {
return undefined
}
return modifier.value
}
let value = modifier.slice(1, -1)
if (!validate(value)) {
return undefined
}
return normalize(value)
return undefined
}
function asNegativeValue(modifier, lookup = {}, validate) {
@ -55,7 +53,9 @@ function asNegativeValue(modifier, lookup = {}, validate) {
if (positiveValue !== undefined) {
return negateValue(positiveValue)
}
}
export function asValue(modifier, options = {}, { candidate, validate = () => true } = {}) {
if (isArbitraryValue(modifier)) {
let resolved = resolveArbitraryValue(modifier, validate)
@ -63,11 +63,13 @@ function asNegativeValue(modifier, lookup = {}, validate) {
return undefined
}
return negateValue(resolved)
}
}
if (options.supportsNegativeValues && candidate.negative) {
return negateValue(resolved)
}
return resolved
}
export function asValue(modifier, options = {}, { validate = () => true } = {}) {
let value = options.values?.[modifier]
if (value !== undefined) {
@ -78,50 +80,56 @@ export function asValue(modifier, options = {}, { validate = () => true } = {})
return asNegativeValue(modifier.slice(1), options.values, validate)
}
return resolveArbitraryValue(modifier, validate)
return undefined
}
function isArbitraryValue(input) {
return input.startsWith('[') && input.endsWith(']')
return typeof input === 'object'
}
function splitAlpha(modifier) {
let slashIdx = modifier.lastIndexOf('/')
if (slashIdx === -1 || slashIdx === modifier.length - 1) {
return [modifier]
}
return [modifier.slice(0, slashIdx), modifier.slice(slashIdx + 1)]
}
export function asColor(modifier, options = {}, { tailwindConfig = {} } = {}) {
if (options.values?.[modifier] !== undefined) {
/**
*
* @param {string} modifier
* @param {any} options
* @param {object} param2
* @param {any} param2.tailwindConfig
* @param {import('../lib/candidate').Candidate} param2.candidate
* @param {import('../lib/candidate').Plugin} param2.candidatePlugin
* @returns
*/
export function asColor(
modifier,
options = {},
{ tailwindConfig = {}, candidate, candidatePlugin } = {}
) {
if (options.values?.[modifier] !== undefined && candidatePlugin.modifiers.length === 0) {
return options.values?.[modifier]
}
let [color, alpha] = splitAlpha(modifier)
let alpha = candidatePlugin.modifiers[0]?.value ?? undefined
if (typeof alpha === 'string') {
alpha = tailwindConfig.theme?.opacity?.[alpha] ?? undefined
if (alpha === undefined) {
return undefined
}
} else if (typeof alpha === 'object') {
alpha = alpha.value
}
if (alpha !== undefined) {
let normalizedColor =
options.values?.[color] ?? (isArbitraryValue(color) ? color.slice(1, -1) : undefined)
options.values?.[modifier] ?? (isArbitraryValue(modifier) ? modifier.value : undefined)
if (normalizedColor === undefined) {
return undefined
}
if (isArbitraryValue(alpha)) {
return withAlphaValue(normalizedColor, alpha.slice(1, -1))
}
if (tailwindConfig.theme?.opacity?.[alpha] === undefined) {
return undefined
}
return withAlphaValue(normalizedColor, tailwindConfig.theme.opacity[alpha])
return withAlphaValue(normalizedColor, alpha)
}
return asValue(modifier, options, { validate: validateColor })
return asValue(modifier, options, { candidate, validate: validateColor })
}
export function asLookupValue(modifier, options = {}) {
@ -129,8 +137,8 @@ export function asLookupValue(modifier, options = {}) {
}
function guess(validate) {
return (modifier, options) => {
return asValue(modifier, options, { validate })
return (modifier, options, { candidate }) => {
return asValue(modifier, options, { candidate, validate })
}
}
@ -154,37 +162,22 @@ let typeMap = {
let supportedTypes = Object.keys(typeMap)
function splitAtFirst(input, delim) {
let idx = input.indexOf(delim)
if (idx === -1) return [undefined, input]
return [input.slice(0, idx), input.slice(idx + 1)]
}
export function coerceValue(types, modifier, options, tailwindConfig) {
export function coerceValue(types, modifier, options, tailwindConfig, candidate, candidatePlugin) {
if (isArbitraryValue(modifier)) {
let arbitraryValue = modifier.slice(1, -1)
let [explicitType, value] = splitAtFirst(arbitraryValue, ':')
// It could be that this resolves to `url(https` which is not a valid
// identifier. We currently only support "simple" words with dashes or
// underscores. E.g.: family-name
if (!/^[\w-_]+$/g.test(explicitType)) {
value = arbitraryValue
}
//
else if (explicitType !== undefined && !supportedTypes.includes(explicitType)) {
if (!supportedTypes.includes(modifier.dataType)) {
return []
}
if (value.length > 0 && supportedTypes.includes(explicitType)) {
return [asValue(`[${value}]`, options), explicitType]
// 'any' is the default data type for arbitrary values
// TODO: We should probably guess them when parsing candidates
if (modifier.dataType !== 'any') {
return [asValue(modifier, options), modifier.dataType]
}
}
// Find first matching type
for (let type of [].concat(types)) {
let result = typeMap[type](modifier, options, { tailwindConfig })
let result = typeMap[type](modifier, options, { tailwindConfig, candidate, candidatePlugin })
if (result !== undefined) return [result, type]
}