diff --git a/.swcrc b/.swcrc index 956ec9d6a..756a57b27 100644 --- a/.swcrc +++ b/.swcrc @@ -1,4 +1,5 @@ { + "exclude": ".d.ts$", "module": { "type": "commonjs" }, diff --git a/src/lib/candidate.d.ts b/src/lib/candidate.d.ts new file mode 100644 index 000000000..d82627235 --- /dev/null +++ b/src/lib/candidate.d.ts @@ -0,0 +1,65 @@ +type Arbitrary = { + raw: string + value: string + dataType: AnyStringWithAutocomplete +} + +type VariantCommon = { raw: string } +type VariantType = + | { type: 'constrained'; name: string } + | { type: 'partial'; name: string; value: string; dataType: DataType } + | { type: 'custom'; value: string; dataType: DataType } + +type Variant = VariantCommon & VariantType + +type ModifierCommon = { raw: string } +type ModifierType = { value: string | Arbitrary } + +type Modifier = ModifierCommon & ModifierType + +type UtilityCommon = { + raw: string + className: string + withoutVariants: string // TODO: remove this + important: boolean + prefix: string + negative: boolean + + variants: Variant[] +} + +export type Plugin = { + plugin: string + value: string | Arbitrary + modifiers: Modifier[] +} + +type UtilityType = + | { + type: 'constrained' + plugins(): Iterable + } + | { type: 'custom'; name: string; value: string; modifiers: Modifier[] } + +export type Candidate = UtilityCommon & Extract + +export function parseCandidate(raw: string, context: any): Iterable + +export type AnyStringWithAutocomplete = T | (string & Record) + +export type DataType = + | 'any' + | 'color' + | 'url' + | 'image' + | 'length' + | 'percentage' + | 'position' + | 'lookup' + | 'generic-name' + | 'family-name' + | 'number' + | 'line-width' + | 'absolute-size' + | 'relative-size' + | 'shadow' diff --git a/src/lib/candidate.js b/src/lib/candidate.js new file mode 100644 index 000000000..34770b9b1 --- /dev/null +++ b/src/lib/candidate.js @@ -0,0 +1,495 @@ +// @ts-check + +/** @typedef {import('./candidate.d').Plugin} Plugin */ +/** @typedef {import('./candidate.d').Arbitrary} Arbitrary */ +/** @typedef {import('./candidate.d').Candidate} Candidate */ +/** @typedef {import('./candidate.d').DataType} DataType */ +/** @typedef {import('./candidate.d').Variant} Variant */ +/** @typedef {import('./candidate.d').Modifier} Modifier */ + +import { normalize } from '../util/dataTypes.js' +import isValidArbitraryValue from '../util/isValidArbitraryValue.js' +import { splitAtTopLevelOnly } from '../util/splitAtTopLevelOnly.js' +import { isParsableCssValue, isValidPropName, looksLikeUri } from '../util/css-validation.js' +import { candidatePermutations } from '../util/candidatePermutations' +import * as sharedState from './sharedState' +import { asClass } from '../util/nameClass.js' + +let caches = { + /** @type {Map} */ + candidates: new Map(), + + /** @type {Map} */ + variants: new Map(), + + /** @type {Map} */ + modifiers: new Map(), + + /** @type {Map} */ + arbitrary: new Map(), +} + +/** + * + * @param {string} raw + * @param {any} context + * @returns {Iterable} + */ +export function parseCandidate(raw, context) { + if (caches.candidates.has(raw)) { + return caches.candidates.get(raw) + } + + let candidates = Array.from(parseStructure(raw, context)) + .map((candidate) => validateCandidate(candidate)) + .filter(Boolean) + + return put(caches.candidates, raw, candidates) +} + +/** + * + * @param {Candidate} candidate + * @returns {Candidate | null} + */ +function validateCandidate(candidate) { + // Normalize arbitrary values in variants + for (const item of candidate.variants) { + if (item.type === 'partial' || item.type === 'custom') { + item.value = normalize(item.value) + } + } + + candidate = validateCustomCandidate(candidate) + + if (!candidate) { + return candidate + } + + if (candidate.type !== 'constrained') { + return candidate + } + + // Normalize arbitrary values + let plugins = candidate.plugins + candidate.plugins = () => validatePlugins(plugins()) + + return candidate +} + +/** + * + * @param {Candidate} candidate + * @returns {Candidate | null} + */ +function validateCustomCandidate(candidate) { + if (candidate.type !== 'custom') { + return candidate + } + + // Ignore invalid custom property names + if (!isValidPropName(candidate.name)) { + return null + } + + // Ignore url-like custom properties + if (looksLikeUri(`${candidate.name}:${candidate.value}`)) { + return null + } + + if (!isValidArbitraryValue(candidate.value)) { + return null + } + + if (!isParsableCssValue(candidate.name, candidate.value)) { + return null + } + + // Normalize arbitrary values in modifiers + candidate.modifiers = Array.from(normalizeModifiers(candidate.modifiers)) + candidate.value = normalize(candidate.value) + + return candidate +} + +/** + * + * @param {Iterable} modifiers + * @returns {Iterable} + */ +function* normalizeModifiers(modifiers) { + // Normalize arbitrary values in modifiers + for (const modifier of modifiers) { + if (typeof modifier.value === 'string') { + modifier.value = normalize(modifier.value) + } else if (typeof modifier.value === 'object') { + modifier.value.value = normalize(modifier.value.value) + } + + yield modifier + } +} + +/** + * + * @param {Iterable} plugins + */ +function* validatePlugins(plugins) { + for (const plugin of plugins) { + if (typeof plugin.value === 'object') { + if (!isValidArbitraryValue(plugin.value.value)) { + continue + } + + plugin.modifiers = Array.from(normalizeModifiers(plugin.modifiers)) + plugin.value.value = normalize(plugin.value.value) + } + + yield plugin + } +} + +/** + * + * @param {string} raw + * @param {any} context + * @returns {Iterable} + */ +function* parseStructure(raw, context) { + // TODO: Can we remove this? + if (raw === sharedState.NOT_ON_DEMAND) { + yield { + raw, + withoutVariants: raw, + className: `${sharedState.NOT_ON_DEMAND}`, + + // This is always gonna be the same + // but is included for completeness + prefix: context.tailwindConfig.prefix ?? '', + important: false, + variants: [], + negative: false, + + /** @type {'constrained'} */ + type: 'constrained', + + // @ts-ignore + *plugins() { + yield { + // @ts-ignore: TODO Would love to remove the need for the new String('*) stuff if at all ever maybe possible + plugin: sharedState.NOT_ON_DEMAND, + value: 'DEFAULT', + modifiers: [], + } + }, + } + + return + } + + // Parse out the variants + let [candidate, ...rawVariants] = Array.from( + splitAtTopLevelOnly(raw, context.tailwindConfig.separator) + ).reverse() + + let variants = rawVariants.map(parseVariant) + + // Important? + let important = candidate[0] === '!' + if (important) { + candidate = candidate.slice(1) + } + + let withoutVariants = candidate + + // Negative before prefix + let negative = false + if (candidate[0] === '-') { + negative = true + candidate = candidate.slice(1) + } + + // Verify the prefix + // If the candidate isn't prefixed with the configured prefix + // Then we treat the entire thing as the candidate rather than just the prefixed portion + let prefix = context.tailwindConfig.prefix ?? '' + let prefixLength = prefix.length + + if (prefix !== '') { + if (candidate.slice(0, prefixLength) === prefix) { + candidate = candidate.slice(prefixLength) + } else { + prefix = '' + prefixLength = 0 + } + } + + // Negative after prefix + if (prefix !== '' && candidate[0] === '-') { + // We had a negative before the prefix + // This means we're looking at something like -tw--top-1 + // That's weird and invalid + if (negative === true) { + return + } + + negative = true + candidate = candidate.slice(1) + } + + let common = { + raw, + className: asClass(raw), + withoutVariants, + prefix, + important, + variants, + negative, + } + + let arbitraryProperty = parseArbitraryProperty(candidate) + if (arbitraryProperty) { + yield Object.assign({}, common, { + /** @type {'custom'} */ + type: 'custom', + name: arbitraryProperty[0], + value: arbitraryProperty[1], + modifiers: [], + }) + + return + } + + // Scan for the name up to the modifier, opening arbitrary value bracket, or end of string + yield Object.assign({}, common, { + /** @type {'constrained'} */ + type: 'constrained', + plugins: () => { + // Generate each plugin + let plugins = generatePlugins(candidate, negative) + + // Insert modifier versions of each plugin (if necessary) + plugins = insertModifierPlugins(plugins, parseModifiers(candidate)) + + return plugins + } + }) +} + +/** + * + * @param {string} candidate + * @param {boolean} negative + * @returns {Iterable} + */ +function* generatePlugins(candidate, negative) { + for (const plugin of candidatePermutations(candidate, negative)) { + yield plugin + + let arbitrary = extractArbitraryValue(`${plugin.value}`) + if (arbitrary) { + yield { + ...plugin, + value: arbitrary, + } + } + } +} + +/** + * + * @param {Iterable} plugins + * @param {Modifier[]} modifiers + * @returns {Iterable} + */ +function* insertModifierPlugins(plugins, modifiers) { + for (const plugin of plugins) { + if (typeof plugin.value === 'object') { + plugin.modifiers = modifiers + } + + yield plugin + + if (modifiers.length === 0) { + continue + } + + if (typeof plugin.value === 'string' && plugin.value.includes(`/${modifiers[0].raw}`)) { + yield { + ...plugin, + value: plugin.value.slice(0, plugin.value.indexOf(`/${modifiers[0].raw}`)), + modifiers, + } + } + } +} + +/** + * + * @param {string} str + * @returns {[string, string] | null} + */ +function parseArbitraryProperty(str) { + let [, property, value] = str.match(/^\[([a-zA-Z0-9-_]+):(\S+)\]$/) ?? [] + + if (value === undefined) { + return null + } + + return [property, value] +} + +/** + * @param {string} raw + * @returns {Arbitrary | null} + */ +function extractArbitraryValue(raw) { + let arbitraryStart = raw.indexOf('[') + let arbitraryEnd = raw.lastIndexOf(']/') + if (arbitraryEnd === -1) { + arbitraryEnd = raw.lastIndexOf(']') + } + + if (arbitraryStart === -1 || arbitraryEnd === -1) { + return null + } + + let arbitrary = parseArbitraryValue(raw.slice(arbitraryStart, arbitraryEnd + 1)) + + if (typeof arbitrary === 'string') { + return null + } + + return arbitrary +} + +/** + * @param {string} raw + * @returns {string | Arbitrary} + */ +function parseArbitraryValue(raw) { + if (caches.arbitrary.has(raw)) { + return caches.arbitrary.get(raw) + } + + if (raw[0] !== '[' || raw[raw.length - 1] !== ']') { + return raw + } + + let dataTypeSeparator = raw.indexOf(':') + let dataType = raw.slice(1, dataTypeSeparator) + let isValidDataType = dataTypeSeparator !== -1 && /^[\w-_]+$/g.test(dataType) + let value = raw.slice(dataTypeSeparator + 1, -1) + + // It could also be that the user says something like bg-[color:] + // This would be treated as an "empty" arbitrary value with type of `color` which is not what we want + // This this is not really an arbitrary value but just a raw string + if (value === '') { + return raw + } + + // 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 (!isValidDataType) { + return put(caches.arbitrary, raw, { + raw, + value: raw.slice(1, -1), + dataType: 'any', + }) + } + + return put(caches.arbitrary, raw, { + raw, + value, + dataType, + }) +} + +/** + * + * @param {string} raw + * @returns {Variant} + */ +function parseVariant(raw) { + if (caches.variants.has(raw)) { + return caches.variants.get(raw) + } + + if (raw[0] === '[' && raw[raw.length - 1] === ']') { + return put(caches.variants, raw, { + /** @type {'custom'} */ + type: 'custom', + raw, + value: raw.slice(1, -1), + dataType: 'any', + }) + } + + if (raw[0] !== '[' && raw[raw.length - 1] === ']') { + let value = raw.slice(raw.lastIndexOf('[') + 1, -1) + let name = raw.slice(0, raw.indexOf(value) - 1 /* - */ - 1 /* [ */) + + return put(caches.variants, raw, { + type: 'partial', + raw, + name, + value, + dataType: 'any', + }) + } + + return put(caches.variants, raw, { + type: 'constrained', + raw, + name: raw, + }) +} + +/** + * + * @param {string} raw + * @returns {Modifier[]} + */ +function parseModifiers(raw) { + let match = raw.match(/\/(\[[^\[\]]+\])|\/([^\[\]]+)$/) ?? [] + + if (match.length === 0) { + return [] + } + + if (match[1] !== undefined) { + return [ + { + raw: match[1], + value: parseArbitraryValue(match[1]), + }, + ] + } + + if (match[2] !== undefined) { + return [ + { + raw: match[2], + value: match[2], + }, + ] + } + + return null +} + +/** + * @template T + * @param {Map} cache + * @param {string} key + * @param {T} value + * @returns {T} + */ +function put(cache, key, value) { + cache.set(key, value) + + // console.log('CACHE MISS', { key, value }) + + return value +} diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index 929d30156..2053a411e 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -5,13 +5,11 @@ import isPlainObject from '../util/isPlainObject' import prefixSelector from '../util/prefixSelector' import { updateAllClasses } from '../util/pluginUtils' import log from '../util/log' -import * as sharedState from './sharedState' import { formatVariantSelector, finalizeSelector } from '../util/formatVariantSelector' import { asClass } from '../util/nameClass' -import { normalize } from '../util/dataTypes' import { isValidVariantFormatString, parseVariant } from './setupContextUtils' -import isValidArbitraryValue from '../util/isValidArbitraryValue' -import { splitAtTopLevelOnly } from '../util/splitAtTopLevelOnly.js' +import { isParsableNode } from '../util/css-validation' +import { parseCandidate } from './candidate' let classNameParser = selectorParser((selectors) => { return selectors.first.filter(({ type }) => type === 'class').pop().value @@ -21,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 @@ -90,10 +52,13 @@ function applyPrefix(matches, context) { return matches } -function applyImportant(matches, classCandidate) { +function applyImportant(matches, parsed) { if (matches.length === 0) { return matches } + + let classCandidate = parsed.withoutVariants + let result = [] for (let [meta, rule] of matches) { @@ -129,159 +94,159 @@ function applyVariant(variant, matches, context) { let args - // Find partial arbitrary variants - if (variant.endsWith(']') && !variant.startsWith('[')) { - args = variant.slice(variant.lastIndexOf('[') + 1, -1) - variant = variant.slice(0, variant.indexOf(args) - 1 /* - */ - 1 /* [ */) - } + if (variant.type === 'constrained') { + variant = variant.name + } else if (variant.type === 'partial' && variant.value !== '') { + // Find partial arbitrary variants + args = variant.value + variant = variant.name + } else if (variant.type === 'custom' && !context.variantMap.has(variant.raw)) { + // Register arbitrary variants + let fn = parseVariant(variant.value) - // Register arbitrary variants - if (isArbitraryValue(variant) && !context.variantMap.has(variant)) { - let selector = normalize(variant.slice(1, -1)) - - if (!isValidVariantFormatString(selector)) { + if (!isValidVariantFormatString(variant.value)) { return [] } - let fn = parseVariant(selector) - let sort = Array.from(context.variantOrder.values()).pop() << 1n - context.variantMap.set(variant, [[sort, fn]]) - context.variantOrder.set(variant, sort) + context.variantMap.set(variant.raw, [[sort, fn]]) + context.variantOrder.set(variant.raw, sort) + + variant = variant.raw } - if (context.variantMap.has(variant)) { - let variantFunctionTuples = context.variantMap.get(variant) - let result = [] + if (!context.variantMap.has(variant)) { + return [] + } - for (let [meta, rule] of matches) { - // Don't generate variants for user css - if (meta.layer === 'user') { + let variantFunctionTuples = context.variantMap.get(variant) + let result = [] + + for (let [meta, rule] of matches) { + // Don't generate variants for user css + if (meta.layer === 'user') { + continue + } + + let container = postcss.root({ nodes: [rule.clone()] }) + + for (let [variantSort, variantFunction] of variantFunctionTuples) { + let clone = container.clone() + let collectedFormats = [] + + let originals = new Map() + + function prepareBackup() { + if (originals.size > 0) return // Already prepared, chicken out + clone.walkRules((rule) => originals.set(rule, rule.selector)) + } + + function modifySelectors(modifierFunction) { + prepareBackup() + clone.each((rule) => { + if (rule.type !== 'rule') { + return + } + + rule.selectors = rule.selectors.map((selector) => { + return modifierFunction({ + get className() { + return getClassNameFromSelector(selector) + }, + selector, + }) + }) + }) + + return clone + } + + let ruleWithVariant = variantFunction({ + // Public API + get container() { + prepareBackup() + return clone + }, + separator: context.tailwindConfig.separator, + modifySelectors, + + // Private API for now + wrap(wrapper) { + let nodes = clone.nodes + clone.removeAll() + wrapper.append(nodes) + clone.append(wrapper) + }, + format(selectorFormat) { + collectedFormats.push(selectorFormat) + }, + args, + }) + + if (typeof ruleWithVariant === 'string') { + collectedFormats.push(ruleWithVariant) + } + + if (ruleWithVariant === null) { continue } - let container = postcss.root({ nodes: [rule.clone()] }) + // We filled the `originals`, therefore we assume that somebody touched + // `container` or `modifySelectors`. Let's see if they did, so that we + // can restore the selectors, and collect the format strings. + if (originals.size > 0) { + clone.walkRules((rule) => { + if (!originals.has(rule)) return + let before = originals.get(rule) + if (before === rule.selector) return // No mutation happened - for (let [variantSort, variantFunction] of variantFunctionTuples) { - let clone = container.clone() - let collectedFormats = [] + let modified = rule.selector - let originals = new Map() - - function prepareBackup() { - if (originals.size > 0) return // Already prepared, chicken out - clone.walkRules((rule) => originals.set(rule, rule.selector)) - } - - function modifySelectors(modifierFunction) { - prepareBackup() - clone.each((rule) => { - if (rule.type !== 'rule') { - return - } - - rule.selectors = rule.selectors.map((selector) => { - return modifierFunction({ - get className() { - return getClassNameFromSelector(selector) - }, - selector, - }) + // Rebuild the base selector, this is what plugin authors would do + // as well. E.g.: `${variant}${separator}${className}`. + // However, plugin authors probably also prepend or append certain + // classes, pseudos, ids, ... + let rebuiltBase = selectorParser((selectors) => { + selectors.walkClasses((classNode) => { + classNode.value = `${variant}${context.tailwindConfig.separator}${classNode.value}` }) - }) + }).processSync(before) - return clone - } - - let ruleWithVariant = variantFunction({ - // Public API - get container() { - prepareBackup() - return clone - }, - separator: context.tailwindConfig.separator, - modifySelectors, - - // Private API for now - wrap(wrapper) { - let nodes = clone.nodes - clone.removeAll() - wrapper.append(nodes) - clone.append(wrapper) - }, - format(selectorFormat) { - collectedFormats.push(selectorFormat) - }, - args, + // Now that we know the original selector, the new selector, and + // the rebuild part in between, we can replace the part that plugin + // authors need to rebuild with `&`, and eventually store it in the + // collectedFormats. Similar to what `format('...')` would do. + // + // E.g.: + // variant: foo + // selector: .markdown > p + // modified (by plugin): .foo .foo\\:markdown > p + // rebuiltBase (internal): .foo\\:markdown > p + // format: .foo & + collectedFormats.push(modified.replace(rebuiltBase, '&')) + rule.selector = before }) - - if (typeof ruleWithVariant === 'string') { - collectedFormats.push(ruleWithVariant) - } - - if (ruleWithVariant === null) { - continue - } - - // We filled the `originals`, therefore we assume that somebody touched - // `container` or `modifySelectors`. Let's see if they did, so that we - // can restore the selectors, and collect the format strings. - if (originals.size > 0) { - clone.walkRules((rule) => { - if (!originals.has(rule)) return - let before = originals.get(rule) - if (before === rule.selector) return // No mutation happened - - let modified = rule.selector - - // Rebuild the base selector, this is what plugin authors would do - // as well. E.g.: `${variant}${separator}${className}`. - // However, plugin authors probably also prepend or append certain - // classes, pseudos, ids, ... - let rebuiltBase = selectorParser((selectors) => { - selectors.walkClasses((classNode) => { - classNode.value = `${variant}${context.tailwindConfig.separator}${classNode.value}` - }) - }).processSync(before) - - // Now that we know the original selector, the new selector, and - // the rebuild part in between, we can replace the part that plugin - // authors need to rebuild with `&`, and eventually store it in the - // collectedFormats. Similar to what `format('...')` would do. - // - // E.g.: - // variant: foo - // selector: .markdown > p - // modified (by plugin): .foo .foo\\:markdown > p - // rebuiltBase (internal): .foo\\:markdown > p - // format: .foo & - collectedFormats.push(modified.replace(rebuiltBase, '&')) - rule.selector = before - }) - } - - // This tracks the originating layer for the variant - // For example: - // .sm:underline {} is a variant of something in the utilities layer - // .sm:container {} is a variant of the container component - clone.nodes[0].raws.tailwind = { ...clone.nodes[0].raws.tailwind, parentLayer: meta.layer } - - let withOffset = [ - { - ...meta, - sort: variantSort | meta.sort, - collectedFormats: (meta.collectedFormats ?? []).concat(collectedFormats), - }, - clone.nodes[0], - ] - result.push(withOffset) } - } - return result + // This tracks the originating layer for the variant + // For example: + // .sm:underline {} is a variant of something in the utilities layer + // .sm:container {} is a variant of the container component + clone.nodes[0].raws.tailwind = { ...clone.nodes[0].raws.tailwind, parentLayer: meta.layer } + + let withOffset = [ + { + ...meta, + sort: variantSort | meta.sort, + collectedFormats: (meta.collectedFormats ?? []).concat(collectedFormats), + }, + clone.nodes[0], + ] + result.push(withOffset) + } } - return [] + return result } function parseRules(rule, cache, options = {}) { @@ -303,156 +268,76 @@ function parseRules(rule, cache, options = {}) { return [cache.get(rule), options] } -const IS_VALID_PROPERTY_NAME = /^[a-z_-]/ - -function isValidPropName(name) { - return IS_VALID_PROPERTY_NAME.test(name) -} - -/** - * @param {string} declaration - * @returns {boolean} - */ -function looksLikeUri(declaration) { - // Quick bailout for obvious non-urls - // This doesn't support schemes that don't use a leading // but that's unlikely to be a problem - if (!declaration.includes('://')) { - return false - } - - try { - const url = new URL(declaration) - return url.scheme !== '' && url.host !== '' - } catch (err) { - // Definitely not a valid url - return false - } -} - -function isParsableNode(node) { - let isParsable = true - - node.walkDecls((decl) => { - if (!isParsableCssValue(decl.name, decl.value)) { - isParsable = false - return false - } - }) - - return isParsable -} - -function isParsableCssValue(property, value) { - // We don't want to to treat [https://example.com] as a custom property - // Even though, according to the CSS grammar, it's a totally valid CSS declaration - // So we short-circuit here by checking if the custom property looks like a url - if (looksLikeUri(`${property}:${value}`)) { - return false - } - - try { - postcss.parse(`a{${property}:${value}}`).toResult() - return true - } catch (err) { - return false - } -} - -function extractArbitraryProperty(classCandidate, context) { - let [, property, value] = classCandidate.match(/^\[([a-zA-Z0-9-_]+):(\S+)\]$/) ?? [] - - if (value === undefined) { - return null - } - - if (!isValidPropName(property)) { - return null - } - - if (!isValidArbitraryValue(value)) { - return null - } - - let normalized = normalize(value) - - if (!isParsableCssValue(property, normalized)) { - return null - } - +function extractArbitraryProperty(parsed, context) { return [ [ { sort: context.arbitraryPropertiesSort, layer: 'utilities' }, () => ({ - [asClass(classCandidate)]: { - [property]: normalized, + [asClass(parsed.withoutVariants)]: { + [parsed.name]: parsed.value, }, }), ], ] } -function* resolveMatchedPlugins(classCandidate, context) { - if (context.candidateRuleMap.has(classCandidate)) { - yield [context.candidateRuleMap.get(classCandidate), 'DEFAULT'] +/** + * + * @param {import('./candidate').Candidate} parsed + * @param {any} context + */ +function* resolveMatchedPlugins(parsed, context) { + if (parsed.type === 'custom') { + yield [extractArbitraryProperty(parsed, context), 'DEFAULT'] } - yield* (function* (arbitraryPropertyRule) { - if (arbitraryPropertyRule !== null) { - yield [arbitraryPropertyRule, 'DEFAULT'] - } - })(extractArbitraryProperty(classCandidate, context)) - - let candidatePrefix = classCandidate - let negative = false - - const twConfigPrefix = context.tailwindConfig.prefix - - const twConfigPrefixLen = twConfigPrefix.length - - const hasMatchingPrefix = - candidatePrefix.startsWith(twConfigPrefix) || candidatePrefix.startsWith(`-${twConfigPrefix}`) - - if (candidatePrefix[twConfigPrefixLen] === '-' && hasMatchingPrefix) { - negative = true - candidatePrefix = twConfigPrefix + candidatePrefix.slice(twConfigPrefixLen + 1) + if (parsed.type !== 'constrained') { + return } - if (negative && context.candidateRuleMap.has(candidatePrefix)) { + // TODO: Can this be dropped? + let candidatePrefix = parsed.negative + ? parsed.prefix + parsed.withoutVariants.slice(parsed.prefix.length + 1) + : parsed.withoutVariants + + if (parsed.negative && context.candidateRuleMap.has(candidatePrefix)) { yield [context.candidateRuleMap.get(candidatePrefix), '-DEFAULT'] } - for (let [prefix, modifier] of candidatePermutations(candidatePrefix)) { - if (context.candidateRuleMap.has(prefix)) { - yield [context.candidateRuleMap.get(prefix), 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] } } } -function splitWithSeparator(input, separator) { - if (input === sharedState.NOT_ON_DEMAND) { - return [sharedState.NOT_ON_DEMAND] - } - - return Array.from(splitAtTopLevelOnly(input, separator)) -} - -function* recordCandidates(matches, classCandidate) { +function* recordCandidates(matches, candidate) { for (const match of matches) { - match[1].raws.tailwind = { ...match[1].raws.tailwind, classCandidate } + match[1].raws.tailwind = { + ...match[1].raws.tailwind, + classCandidate: candidate.withoutVariants, + + // todo: rename? + parsedCandidate: candidate, + } yield match } } -function* resolveMatches(candidate, context) { - let separator = context.tailwindConfig.separator - let [classCandidate, ...variants] = splitWithSeparator(candidate, separator).reverse() - let important = false - - if (classCandidate.startsWith('!')) { - important = true - classCandidate = classCandidate.slice(1) +function* resolveMatches(candidateRaw, context) { + for (const candidate of parseCandidate(candidateRaw, context)) { + yield* resolveMatchesForCandidate(candidate, context) } +} + +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) { @@ -466,18 +351,20 @@ function* resolveMatches(candidate, context) { // throw new Error(`Class ${candidate} should be written as ${corrected}`) // } - for (let matchedPlugins of resolveMatchedPlugins(classCandidate, 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]) @@ -499,7 +386,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) { @@ -542,16 +429,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.`, ]) @@ -562,19 +449,22 @@ function* resolveMatches(candidate, context) { } matches = matches.flat() - matches = Array.from(recordCandidates(matches, classCandidate)) + matches = Array.from(recordCandidates(matches, candidate)) matches = applyPrefix(matches, context) if (important) { - matches = applyImportant(matches, classCandidate) + 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) { @@ -594,6 +484,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 + } } } @@ -665,8 +574,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/candidatePermutations.js b/src/util/candidatePermutations.js new file mode 100644 index 000000000..7696bb460 --- /dev/null +++ b/src/util/candidatePermutations.js @@ -0,0 +1,58 @@ +// @ts-check + +/** @typedef {import('../lib/candidate.d').Plugin} Plugin */ + +/** + * Generate plugin 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]'] + * + * @param {string} candidate + * @param {boolean} negative + * @return {Iterable} + */ +export function* candidatePermutations(candidate, negative = false) { + let lastIndex = Infinity + + yield { plugin: candidate, value: negative ? '-DEFAULT' : 'DEFAULT', modifiers: [] } + + let SPECIALS = /[\[\]]/g + let indexes = Array.from(candidate.matchAll(SPECIALS)).map((r) => r.index) + let firstOpening = indexes[0] ?? -1 + let lastOpening = indexes[1] ?? -1 + + 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 + } + + if (firstOpening < dashIdx && dashIdx < lastOpening) { + lastIndex = dashIdx - 1 + continue + } + + let plugin = candidate.slice(0, dashIdx) + let value = candidate.slice(dashIdx + 1) + + yield { plugin, value: negative ? `-${value}` : value, modifiers: [] } + + lastIndex = dashIdx - 1 + } +} diff --git a/src/util/css-validation.js b/src/util/css-validation.js new file mode 100644 index 000000000..11f814e8d --- /dev/null +++ b/src/util/css-validation.js @@ -0,0 +1,56 @@ +import postcss from 'postcss' + +const IS_VALID_PROPERTY_NAME = /^[a-z_-]/ + +export function isValidPropName(name) { + return IS_VALID_PROPERTY_NAME.test(name) +} + +/** + * @param {string} declaration + * @returns {boolean} + */ +export function looksLikeUri(declaration) { + // Quick bailout for obvious non-urls + // This doesn't support schemes that don't use a leading // but that's unlikely to be a problem + if (!declaration.includes('://')) { + return false + } + + try { + const url = new URL(declaration) + return url.scheme !== '' && url.host !== '' + } catch (err) { + // Definitely not a valid url + return false + } +} + +export function isParsableNode(node) { + let isParsable = true + + node.walkDecls((decl) => { + if (!isParsableCssValue(decl.name, decl.value)) { + isParsable = false + return false + } + }) + + return isParsable +} + +export function isParsableCssValue(property, value) { + // We don't want to to treat [https://example.com] as a custom property + // Even though, according to the CSS grammar, it's a totally valid CSS declaration + // So we short-circuit here by checking if the custom property looks like a url + if (looksLikeUri(`${property}:${value}`)) { + return false + } + + try { + postcss.parse(`a{${property}:${value}}`).toResult() + return true + } catch (err) { + return false + } +} 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/formatVariantSelector.js b/src/util/formatVariantSelector.js index b3aba6401..6acacf9ed 100644 --- a/src/util/formatVariantSelector.js +++ b/src/util/formatVariantSelector.js @@ -42,13 +42,13 @@ export function finalizeSelector(format, { selector, candidate, context }) { // ╰──┴─────┴─────────────── We will split here // let splitter = new RegExp(`\\${separator}(?![^[]*\\])`) - let base = candidate.split(splitter).pop() + let base = candidate.raw.split(splitter).pop() if (context?.tailwindConfig?.prefix) { format = prefixSelector(context.tailwindConfig.prefix, format) } - format = format.replace(PARENT, `.${escapeClassName(candidate)}`) + format = format.replace(PARENT, `.${escapeClassName(candidate.raw)}`) let formatAst = selectorParser().astSync(format) 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..377cd8bbd 100644 --- a/src/util/pluginUtils.js +++ b/src/util/pluginUtils.js @@ -2,7 +2,6 @@ import selectorParser from 'postcss-selector-parser' import escapeCommas from './escapeCommas' import { withAlphaValue } from './withAlphaVariable' import { - normalize, length, number, percentage, @@ -36,26 +35,18 @@ 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) { - let positiveValue = lookup[modifier] - - if (positiveValue !== undefined) { - return negateValue(positiveValue) - } - +export function asValue(modifier, options = {}, { candidate, validate = () => true } = {}) { if (isArbitraryValue(modifier)) { let resolved = resolveArbitraryValue(modifier, validate) @@ -63,11 +54,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) { @@ -75,53 +68,68 @@ export function asValue(modifier, options = {}, { validate = () => true } = {}) } if (options.supportsNegativeValues && modifier.startsWith('-')) { - return asNegativeValue(modifier.slice(1), options.values, validate) + let positiveValue = options.values[modifier.slice(1)] + + if (positiveValue !== undefined) { + return negateValue(positiveValue) + } } - return resolveArbitraryValue(modifier, validate) + return undefined } +/** + * + * @param {string|import('../lib/candidate').Arbitrary} input + * @returns {input is import('../lib/candidate').Arbitrary} + */ 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] } diff --git a/tests/arbitrary-values.test.js b/tests/arbitrary-values.test.js index baea4ddef..069393b1f 100644 --- a/tests/arbitrary-values.test.js +++ b/tests/arbitrary-values.test.js @@ -416,3 +416,78 @@ it('should correctly validate each part when checking for `percentage` data type `) }) }) + +it('arbitrary-value looking things in the config are preferred', () => { + let config = { + content: [{ raw: html`
` }], + corePlugins: { preflight: false, backgroundOpacity: false }, + plugins: [], + theme: { + colors: { + '[#c0ffee]': '#f0000d', + }, + }, + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .bg-\[\#c0ffee\] { + background-color: #f0000d; + } + `) + }) +}) + +it('modifier-value looking things in the config are preferred', () => { + let config = { + content: [{ raw: html`
` }], + corePlugins: { preflight: false, backgroundOpacity: false }, + plugins: [], + theme: { + colors: { + 'red-500/50': '#f0000d', + }, + }, + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .bg-red-500\/50 { + background-color: #f0000d; + } + `) + }) +}) + +it('modifier-value looking things in the config are preferred', () => { + let config = { + content: [{ raw: html`
` }], + corePlugins: { preflight: false, backgroundOpacity: false }, + plugins: [], + theme: { + colors: { + '[#ff0000]': '#f0000d', + }, + }, + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .bg-\[\#ff0000\]\/\[0\.5\] { + background-color: rgb(240 0 13 / 0.5); + } + `) + }) +}) diff --git a/tests/candidate.test.js b/tests/candidate.test.js new file mode 100644 index 000000000..2d2b7567d --- /dev/null +++ b/tests/candidate.test.js @@ -0,0 +1,567 @@ +const { parseCandidate } = require('../src/lib/candidate.js') + +it.each([ + [ + 'border', + [ + { + raw: 'border', + className: '.border', + withoutVariants: 'border', + important: false, + negative: false, + prefix: '', + variants: [], + type: 'constrained', + plugins: [{ plugin: 'border', value: 'DEFAULT', modifiers: [] }], + }, + ], + ], + [ + 'w-4', + [ + { + raw: 'w-4', + className: '.w-4', + withoutVariants: 'w-4', + important: false, + negative: false, + prefix: '', + variants: [], + type: 'constrained', + plugins: [ + { plugin: 'w-4', value: 'DEFAULT', modifiers: [] }, + { plugin: 'w', value: '4', modifiers: [] }, + ], + }, + ], + ], + [ + '-w-4', + [ + { + raw: '-w-4', + className: '.-w-4', + withoutVariants: '-w-4', + important: false, + negative: true, + prefix: '', + variants: [], + type: 'constrained', + plugins: [ + { plugin: 'w-4', value: '-DEFAULT', modifiers: [] }, + { plugin: 'w', value: '-4', modifiers: [] }, + ], + }, + ], + ], + [ + 'sm:w', + [ + { + raw: 'sm:w', + className: '.sm\\:w', + withoutVariants: 'w', + important: false, + negative: false, + prefix: '', + variants: [{ raw: 'sm', type: 'constrained', name: 'sm' }], + type: 'constrained', + plugins: [{ plugin: 'w', value: 'DEFAULT', modifiers: [] }], + }, + ], + ], + [ + 'w-[2px]', + [ + { + raw: 'w-[2px]', + className: '.w-\\[2px\\]', + withoutVariants: 'w-[2px]', + important: false, + negative: false, + prefix: '', + variants: [], + type: 'constrained', + plugins: [ + { plugin: 'w-[2px]', value: 'DEFAULT', modifiers: [] }, + { plugin: 'w', value: '[2px]', modifiers: [] }, + { + plugin: 'w', + value: { + raw: '[2px]', + value: '2px', + dataType: 'any', + }, + modifiers: [], + }, + ], + }, + ], + ], + [ + '-mt-[10px]', + [ + { + raw: '-mt-[10px]', + className: '.-mt-\\[10px\\]', + withoutVariants: '-mt-[10px]', + important: false, + negative: true, + prefix: '', + variants: [], + type: 'constrained', + plugins: [ + { plugin: 'mt-[10px]', value: '-DEFAULT', modifiers: [] }, + { plugin: 'mt', value: '-[10px]', modifiers: [] }, + { + plugin: 'mt', + value: { + raw: '[10px]', + value: '10px', + dataType: 'any', + }, + modifiers: [], + }, + ], + }, + ], + ], + [ + 'sm:w-[2px]', + [ + { + raw: 'sm:w-[2px]', + className: '.sm\\:w-\\[2px\\]', + withoutVariants: 'w-[2px]', + important: false, + negative: false, + prefix: '', + variants: [{ raw: 'sm', type: 'constrained', name: 'sm' }], + type: 'constrained', + plugins: [ + { plugin: 'w-[2px]', value: 'DEFAULT', modifiers: [] }, + { plugin: 'w', value: '[2px]', modifiers: [] }, + { + plugin: 'w', + value: { + raw: '[2px]', + value: '2px', + dataType: 'any', + }, + modifiers: [], + }, + ], + }, + ], + ], + [ + 'sm:w-[length:2px]', + [ + { + raw: 'sm:w-[length:2px]', + className: '.sm\\:w-\\[length\\:2px\\]', + withoutVariants: 'w-[length:2px]', + important: false, + negative: false, + prefix: '', + variants: [{ raw: 'sm', type: 'constrained', name: 'sm' }], + type: 'constrained', + plugins: [ + { plugin: 'w-[length:2px]', value: 'DEFAULT', modifiers: [] }, + { plugin: 'w', value: '[length:2px]', modifiers: [] }, + { + plugin: 'w', + value: { + raw: '[length:2px]', + value: '2px', + dataType: 'length', + }, + modifiers: [], + }, + ], + }, + ], + ], + [ + 'sm:w-[2px]/20', + [ + { + raw: 'sm:w-[2px]/20', + className: '.sm\\:w-\\[2px\\]\\/20', + withoutVariants: 'w-[2px]/20', + important: false, + negative: false, + prefix: '', + variants: [{ raw: 'sm', type: 'constrained', name: 'sm' }], + type: 'constrained', + plugins: [ + { plugin: 'w-[2px]/20', value: 'DEFAULT', modifiers: [] }, + { plugin: 'w', value: '[2px]/20', modifiers: [] }, + { + plugin: 'w', + value: '[2px]', + modifiers: [{ raw: '20', value: '20' }], + }, + { + plugin: 'w', + value: { + raw: `[2px]`, + value: '2px', + dataType: 'any', + }, + modifiers: [{ raw: '20', value: '20' }], + }, + ], + }, + ], + ], + [ + 'bg-red-500/50', + [ + { + raw: 'bg-red-500/50', + className: '.bg-red-500\\/50', + withoutVariants: 'bg-red-500/50', + important: false, + negative: false, + prefix: '', + variants: [], + type: 'constrained', + plugins: [ + { plugin: 'bg-red-500/50', value: 'DEFAULT', modifiers: [] }, + { plugin: 'bg-red', value: '500/50', modifiers: [] }, + { plugin: 'bg-red', value: '500', modifiers: [{ raw: '50', value: '50' }] }, + { plugin: 'bg', value: 'red-500/50', modifiers: [] }, + { plugin: 'bg', value: 'red-500', modifiers: [{ raw: '50', value: '50' }] }, + ], + }, + ], + ], + [ + 'bg-[url(https://example.com/image.png)]', + [ + { + raw: 'bg-[url(https://example.com/image.png)]', + className: '.bg-\\[url\\(https\\:\\/\\/example\\.com\\/image\\.png\\)\\]', + withoutVariants: 'bg-[url(https://example.com/image.png)]', + important: false, + negative: false, + prefix: '', + variants: [], + type: 'constrained', + plugins: [ + { plugin: 'bg-[url(https://example.com/image.png)]', value: 'DEFAULT', modifiers: [] }, + { plugin: 'bg', value: '[url(https://example.com/image.png)]', modifiers: [] }, + { + plugin: 'bg', + value: { + raw: `[url(https://example.com/image.png)]`, + value: 'url(https://example.com/image.png)', + dataType: 'any', + }, + modifiers: [], + }, + ], + }, + ], + ], + [ + 'bg-[color:]', + [ + { + raw: 'bg-[color:]', + className: '.bg-\\[color\\:\\]', + withoutVariants: 'bg-[color:]', + important: false, + negative: false, + prefix: '', + variants: [], + type: 'constrained', + plugins: [ + { plugin: 'bg-[color:]', value: 'DEFAULT', modifiers: [] }, + { plugin: 'bg', value: '[color:]', modifiers: [] }, + // [color:] is not a valid arbitrary value + ], + }, + ], + ], + [ + '-mt-[10px]', + [ + { + raw: '-mt-[10px]', + className: '.-mt-\\[10px\\]', + withoutVariants: '-mt-[10px]', + important: false, + negative: true, + prefix: '', + variants: [], + type: 'constrained', + plugins: [ + { plugin: 'mt-[10px]', value: '-DEFAULT', modifiers: [] }, + { plugin: 'mt', value: '-[10px]', modifiers: [] }, + { + plugin: 'mt', + value: { + raw: `[10px]`, + value: '10px', + dataType: 'any', + }, + modifiers: [], + }, + ], + }, + ], + ], + [ + 'sm:w-[2px]/[0.55]', + [ + { + raw: 'sm:w-[2px]/[0.55]', + className: '.sm\\:w-\\[2px\\]\\/\\[0\\.55\\]', + withoutVariants: 'w-[2px]/[0.55]', + important: false, + negative: false, + prefix: '', + variants: [{ raw: 'sm', type: 'constrained', name: 'sm' }], + type: 'constrained', + plugins: [ + { plugin: 'w-[2px]/[0.55]', value: 'DEFAULT', modifiers: [] }, + { plugin: 'w', value: '[2px]/[0.55]', modifiers: [] }, + { + plugin: 'w', + value: '[2px]', + modifiers: [ + { + raw: '[0.55]', + value: { + raw: `[0.55]`, + value: '0.55', + dataType: 'any', + }, + }, + ], + }, + { + plugin: 'w', + value: { + raw: `[2px]`, + value: '2px', + dataType: 'any', + }, + modifiers: [ + { + raw: '[0.55]', + value: { + raw: `[0.55]`, + value: '0.55', + dataType: 'any', + }, + }, + ], + }, + ], + }, + ], + ], + [ + 'sm:w-[calc(0.5rem_+_1px)]', + [ + { + raw: 'sm:w-[calc(0.5rem_+_1px)]', + className: '.sm\\:w-\\[calc\\(0\\.5rem_\\+_1px\\)\\]', + withoutVariants: 'w-[calc(0.5rem_+_1px)]', + important: false, + negative: false, + prefix: '', + variants: [{ raw: 'sm', type: 'constrained', name: 'sm' }], + type: 'constrained', + plugins: [ + { plugin: 'w-[calc(0.5rem_+_1px)]', value: 'DEFAULT', modifiers: [] }, + { plugin: 'w', value: '[calc(0.5rem_+_1px)]', modifiers: [] }, + { + plugin: 'w', + value: { + raw: `[calc(0.5rem_+_1px)]`, + value: 'calc(0.5rem + 1px)', + dataType: 'any', + }, + modifiers: [], + }, + ], + }, + ], + ], + [ + '[width:2px]', + [ + { + raw: '[width:2px]', + className: '.\\[width\\:2px\\]', + withoutVariants: '[width:2px]', + important: false, + negative: false, + prefix: '', + variants: [], + modifiers: [], + type: 'custom', + name: 'width', + value: '2px', + }, + ], + ], + [ + 'sm:[width:2px]', + [ + { + raw: 'sm:[width:2px]', + className: '.sm\\:\\[width\\:2px\\]', + withoutVariants: '[width:2px]', + important: false, + negative: false, + prefix: '', + variants: [{ raw: 'sm', type: 'constrained', name: 'sm' }], + modifiers: [], + type: 'custom', + name: 'width', + value: '2px', + }, + ], + ], + [ + 'sm:focus:[width:2px]', + [ + { + raw: 'sm:focus:[width:2px]', + className: '.sm\\:focus\\:\\[width\\:2px\\]', + withoutVariants: '[width:2px]', + important: false, + negative: false, + prefix: '', + variants: [ + { raw: 'focus', type: 'constrained', name: 'focus' }, + { raw: 'sm', type: 'constrained', name: 'sm' }, + ], + modifiers: [], + type: 'custom', + name: 'width', + value: '2px', + }, + ], + ], + [ + 'sm:[@media(min-width:200px)]:[width:2px]', + [ + { + raw: 'sm:[@media(min-width:200px)]:[width:2px]', + className: '.sm\\:\\[\\@media\\(min-width\\:200px\\)\\]\\:\\[width\\:2px\\]', + withoutVariants: '[width:2px]', + important: false, + negative: false, + prefix: '', + variants: [ + { + type: 'custom', + raw: '[@media(min-width:200px)]', + value: '@media(min-width:200px)', + dataType: 'any', + }, + { raw: 'sm', type: 'constrained', name: 'sm' }, + ], + modifiers: [], + type: 'custom', + name: 'width', + value: '2px', + }, + ], + ], + [ + 'sm:group-[foo_bar]:underline', + [ + { + raw: 'sm:group-[foo_bar]:underline', + className: '.sm\\:group-\\[foo_bar\\]\\:underline', + withoutVariants: 'underline', + important: false, + negative: false, + prefix: '', + variants: [ + { + type: 'partial', + raw: 'group-[foo_bar]', + name: 'group', + value: 'foo bar', + dataType: 'any', + }, + { raw: 'sm', type: 'constrained', name: 'sm' }, + ], + type: 'constrained', + plugins: [{ plugin: 'underline', value: 'DEFAULT', modifiers: [] }], + }, + ], + ], + [ + 'bg-[hsl(var(--foo),var(--bar),var(--baz))]/50', + [ + { + raw: 'bg-[hsl(var(--foo),var(--bar),var(--baz))]/50', + className: '.bg-\\[hsl\\(var\\(--foo\\)\\2c var\\(--bar\\)\\2c var\\(--baz\\)\\)\\]\\/50', + withoutVariants: 'bg-[hsl(var(--foo),var(--bar),var(--baz))]/50', + important: false, + negative: false, + prefix: '', + variants: [], + type: 'constrained', + plugins: [ + { + plugin: 'bg-[hsl(var(--foo),var(--bar),var(--baz))]/50', + value: 'DEFAULT', + modifiers: [], + }, + { + plugin: 'bg', + value: '[hsl(var(--foo),var(--bar),var(--baz))]/50', + modifiers: [], + }, + { + plugin: 'bg', + value: '[hsl(var(--foo),var(--bar),var(--baz))]', + modifiers: [{ raw: '50', value: '50' }], + }, + { + plugin: 'bg', + value: { + raw: '[hsl(var(--foo),var(--bar),var(--baz))]', + value: 'hsl(var(--foo),var(--bar),var(--baz))', + dataType: 'any', + }, + modifiers: [{ raw: '50', value: '50' }], + }, + ], + }, + ], + ], + // TODO: test for `[color:#ff0000]/50`? + ['[http://example.com]', []], +])('should be possible to parse: "%s"', (input, expected) => { + let actual = parseCandidate(input, { + tailwindConfig: { + separator: ':', + }, + }) + + + // So we don't mutate the cached versions + actual = actual.map(candidate => ({ + ...candidate, + })) + + for (const candidate of actual) { + if ('plugins' in candidate) { + candidate.plugins = Array.from(candidate.plugins()) + } + } + + expect(actual).toEqual(expected) +}) diff --git a/tests/format-variant-selector.test.js b/tests/format-variant-selector.test.js index 94e86ddfc..7d59a51f7 100644 --- a/tests/format-variant-selector.test.js +++ b/tests/format-variant-selector.test.js @@ -1,8 +1,11 @@ +import { parseCandidate } from '../src/lib/candidate' import { formatVariantSelector, finalizeSelector } from '../src/util/formatVariantSelector' it('should be possible to add a simple variant to a simple selector', () => { let selector = '.text-center' - let candidate = 'hover:text-center' + let candidate = Array.from( + parseCandidate('hover:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = ['&:hover'] @@ -13,7 +16,9 @@ it('should be possible to add a simple variant to a simple selector', () => { it('should be possible to add a multiple simple variants to a simple selector', () => { let selector = '.text-center' - let candidate = 'focus:hover:text-center' + let candidate = Array.from( + parseCandidate('focus:hover:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = ['&:hover', '&:focus'] @@ -24,7 +29,9 @@ it('should be possible to add a multiple simple variants to a simple selector', it('should be possible to add a simple variant to a selector containing escaped parts', () => { let selector = '.bg-\\[rgba\\(0\\,0\\,0\\)\\]' - let candidate = 'hover:bg-[rgba(0,0,0)]' + let candidate = Array.from( + parseCandidate('hover:bg-[rgba(0,0,0)]', { tailwindConfig: { separator: ':' } }) + )[0] let variants = ['&:hover'] @@ -35,7 +42,9 @@ it('should be possible to add a simple variant to a selector containing escaped it('should be possible to add a simple variant to a selector containing escaped parts (escape is slightly different)', () => { let selector = '.bg-\\[rgba\\(0\\2c 0\\2c 0\\)\\]' - let candidate = 'hover:bg-[rgba(0,0,0)]' + let candidate = Array.from( + parseCandidate('hover:bg-[rgba(0,0,0)]', { tailwindConfig: { separator: ':' } }) + )[0] let variants = ['&:hover'] @@ -46,7 +55,9 @@ it('should be possible to add a simple variant to a selector containing escaped it('should be possible to add a simple variant to a more complex selector', () => { let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])' - let candidate = 'hover:space-x-4' + let candidate = Array.from( + parseCandidate('hover:space-x-4', { tailwindConfig: { separator: ':' } }) + )[0] let variants = ['&:hover'] @@ -57,7 +68,9 @@ it('should be possible to add a simple variant to a more complex selector', () = it('should be possible to add multiple simple variants to a more complex selector', () => { let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])' - let candidate = 'disabled:focus:hover:space-x-4' + let candidate = Array.from( + parseCandidate('disabled:focus:hover:space-x-4', { tailwindConfig: { separator: ':' } }) + )[0] let variants = ['&:hover', '&:focus', '&:disabled'] @@ -68,7 +81,9 @@ it('should be possible to add multiple simple variants to a more complex selecto it('should be possible to add a single merge variant to a simple selector', () => { let selector = '.text-center' - let candidate = 'group-hover:text-center' + let candidate = Array.from( + parseCandidate('group-hover:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = [':merge(.group):hover &'] @@ -79,7 +94,9 @@ it('should be possible to add a single merge variant to a simple selector', () = it('should be possible to add multiple merge variants to a simple selector', () => { let selector = '.text-center' - let candidate = 'group-focus:group-hover:text-center' + let candidate = Array.from( + parseCandidate('group-focus:group-hover:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = [':merge(.group):hover &', ':merge(.group):focus &'] @@ -90,7 +107,9 @@ it('should be possible to add multiple merge variants to a simple selector', () it('should be possible to add a single merge variant to a more complex selector', () => { let selector = '.space-x-4 ~ :not([hidden]) ~ :not([hidden])' - let candidate = 'group-hover:space-x-4' + let candidate = Array.from( + parseCandidate('group-hover:space-x-4', { tailwindConfig: { separator: ':' } }) + )[0] let variants = [':merge(.group):hover &'] @@ -101,7 +120,9 @@ it('should be possible to add a single merge variant to a more complex selector' it('should be possible to add multiple merge variants to a more complex selector', () => { let selector = '.space-x-4 ~ :not([hidden]) ~ :not([hidden])' - let candidate = 'group-focus:group-hover:space-x-4' + let candidate = Array.from( + parseCandidate('group-focus:group-hover:space-x-4', { tailwindConfig: { separator: ':' } }) + )[0] let variants = [':merge(.group):hover &', ':merge(.group):focus &'] @@ -112,7 +133,9 @@ it('should be possible to add multiple merge variants to a more complex selector it('should be possible to add multiple unique merge variants to a simple selector', () => { let selector = '.text-center' - let candidate = 'peer-focus:group-hover:text-center' + let candidate = Array.from( + parseCandidate('peer-focus:group-hover:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = [':merge(.group):hover &', ':merge(.peer):focus ~ &'] @@ -123,7 +146,9 @@ it('should be possible to add multiple unique merge variants to a simple selecto it('should be possible to add multiple unique merge variants to a simple selector', () => { let selector = '.text-center' - let candidate = 'group-hover:peer-focus:text-center' + let candidate = Array.from( + parseCandidate('group-hover:peer-focus:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = [':merge(.peer):focus ~ &', ':merge(.group):hover &'] @@ -149,7 +174,9 @@ it('should be possible to use multiple :merge() calls with different "arguments" it('group hover and prose headings combination', () => { let selector = '.text-center' - let candidate = 'group-hover:prose-headings:text-center' + let candidate = Array.from( + parseCandidate('group-hover:prose-headings:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = [ ':where(&) :is(h1, h2, h3, h4)', // Prose Headings ':merge(.group):hover &', // Group Hover @@ -162,7 +189,9 @@ it('group hover and prose headings combination', () => { it('group hover and prose headings combination flipped', () => { let selector = '.text-center' - let candidate = 'prose-headings:group-hover:text-center' + let candidate = Array.from( + parseCandidate('prose-headings:group-hover:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = [ ':merge(.group):hover &', // Group Hover ':where(&) :is(h1, h2, h3, h4)', // Prose Headings @@ -175,7 +204,11 @@ it('group hover and prose headings combination flipped', () => { it('should be possible to handle a complex utility', () => { let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])' - let candidate = 'peer-disabled:peer-first-child:group-hover:group-focus:focus:hover:space-x-4' + let candidate = Array.from( + parseCandidate('peer-disabled:peer-first-child:group-hover:group-focus:focus:hover:space-x-4', { + tailwindConfig: { separator: ':' }, + }) + )[0] let variants = [ '&:hover', // Hover '&:focus', // Focus @@ -193,7 +226,9 @@ it('should be possible to handle a complex utility', () => { describe('real examples', () => { it('example a', () => { let selector = '.placeholder-red-500::placeholder' - let candidate = 'hover:placeholder-red-500' + let candidate = Array.from( + parseCandidate('hover:placeholder-red-500', { tailwindConfig: { separator: ':' } }) + )[0] let variants = ['&:hover'] @@ -204,7 +239,9 @@ describe('real examples', () => { it('example b', () => { let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])' - let candidate = 'group-hover:hover:space-x-4' + let candidate = Array.from( + parseCandidate('group-hover:hover:space-x-4', { tailwindConfig: { separator: ':' } }) + )[0] let variants = ['&:hover', ':merge(.group):hover &'] @@ -215,7 +252,9 @@ describe('real examples', () => { it('should work for group-hover and class dark mode combinations', () => { let selector = '.text-center' - let candidate = 'dark:group-hover:text-center' + let candidate = Array.from( + parseCandidate('dark:group-hover:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = [':merge(.group):hover &', '.dark &'] @@ -226,7 +265,9 @@ describe('real examples', () => { it('should work for group-hover and class dark mode combinations (reversed)', () => { let selector = '.text-center' - let candidate = 'group-hover:dark:text-center' + let candidate = Array.from( + parseCandidate('group-hover:dark:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = ['.dark &', ':merge(.group):hover &'] @@ -238,7 +279,9 @@ describe('real examples', () => { describe('prose-headings', () => { it('should be possible to use hover:prose-headings:text-center', () => { let selector = '.text-center' - let candidate = 'hover:prose-headings:text-center' + let candidate = Array.from( + parseCandidate('hover:prose-headings:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = [':where(&) :is(h1, h2, h3, h4)', '&:hover'] @@ -249,7 +292,9 @@ describe('real examples', () => { it('should be possible to use prose-headings:hover:text-center', () => { let selector = '.text-center' - let candidate = 'prose-headings:hover:text-center' + let candidate = Array.from( + parseCandidate('prose-headings:hover:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = ['&:hover', ':where(&) :is(h1, h2, h3, h4)'] @@ -276,7 +321,7 @@ describe('pseudo elements', () => { `('should translate "$before" into "$after"', ({ before, after }) => { let result = finalizeSelector(formatVariantSelector('&', before), { selector: '.a', - candidate: 'a', + candidate: Array.from(parseCandidate('a', { tailwindConfig: { separator: ':' } }))[0], }) expect(result).toEqual(after.replace('&', '.a'))