diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index dc9d7de69..839858f14 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -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('[', '[') .replace(']', ']')}\` 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 } diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js index d572c2137..661926d09 100644 --- a/src/lib/setupContextUtils.js +++ b/src/lib/setupContextUtils.js @@ -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 diff --git a/src/util/dataTypes.js b/src/util/dataTypes.js index 04a5ee901..0af7d2a39 100644 --- a/src/util/dataTypes.js +++ b/src/util/dataTypes.js @@ -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 diff --git a/src/util/nameClass.js b/src/util/nameClass.js index ae7370129..37e27add4 100644 --- a/src/util/nameClass.js +++ b/src/util/nameClass.js @@ -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)) } diff --git a/src/util/pluginUtils.js b/src/util/pluginUtils.js index f3214df46..ef4adf0c5 100644 --- a/src/util/pluginUtils.js +++ b/src/util/pluginUtils.js @@ -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] }