From 3ac72b7e7da8aa49ccc6045a4dd9979bed5977d7 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 9 May 2022 16:05:04 -0400 Subject: [PATCH 01/19] Move CSS validation helpers to separate file wip --- src/lib/generateRules.js | 56 +------------------------------------- src/util/css-validation.js | 56 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 55 deletions(-) create mode 100644 src/util/css-validation.js diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index 929d30156..5fabe203b 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -12,6 +12,7 @@ import { normalize } from '../util/dataTypes' import { isValidVariantFormatString, parseVariant } from './setupContextUtils' import isValidArbitraryValue from '../util/isValidArbitraryValue' import { splitAtTopLevelOnly } from '../util/splitAtTopLevelOnly.js' +import { isParsableNode, isParsableCssValue, isValidPropName } from '../util/css-validation.js' let classNameParser = selectorParser((selectors) => { return selectors.first.filter(({ type }) => type === 'class').pop().value @@ -303,61 +304,6 @@ 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+)\]$/) ?? [] 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 + } +} From 39c7a93f22ef3fe115dcea889f9671b32cc16754 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 10 May 2022 11:57:51 -0400 Subject: [PATCH 02/19] De-nest applyVariant --- src/lib/generateRules.js | 238 +++++++++++++++++++-------------------- 1 file changed, 119 insertions(+), 119 deletions(-) diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index 5fabe203b..4cf780f98 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -151,138 +151,138 @@ function applyVariant(variant, matches, context) { context.variantOrder.set(variant, sort) } - 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 = {}) { From 0f98fec7f0d54b1114f1ee42b6013d258f67a0fa Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 9 May 2022 16:06:03 -0400 Subject: [PATCH 03/19] Add new candidate object --- src/lib/candidate.d.ts | 65 ++++ src/lib/candidate.js | 456 ++++++++++++++++++++++++ src/util/candidatePermutations.js | 58 ++++ tests/candidate.test.js | 555 ++++++++++++++++++++++++++++++ 4 files changed, 1134 insertions(+) create mode 100644 src/lib/candidate.d.ts create mode 100644 src/lib/candidate.js create mode 100644 src/util/candidatePermutations.js create mode 100644 tests/candidate.test.js diff --git a/src/lib/candidate.d.ts b/src/lib/candidate.d.ts new file mode 100644 index 000000000..546cef096 --- /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: Plugin[] + } + | { type: 'custom'; name: string; value: string; modifiers: Modifier[] } + +export type Candidate = UtilityCommon & Extract + +export function parseCandidate(raw: string, context: any): Candidate | null + +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..f01b80665 --- /dev/null +++ b/src/lib/candidate.js @@ -0,0 +1,456 @@ +// @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' + +/** @type {Map} */ +let candidateCache = new Map() + +/** + * + * @param {string} raw + * @param {any} context + * @returns {Iterable} + */ +export function parseCandidate(raw, context) { + let candidates = candidateCache.get(raw) + + if (!candidates) { + candidates = Array.from(parseStructure(raw, context)) + .map((candidate) => validateCandidate(candidate)) + .filter(Boolean) + + candidateCache.set(raw, candidates) + } + + return 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 + candidate.plugins = Array.from(validatePlugins(candidate.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', + plugins: [ + { + // @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 + } + + // Generate each plugin + let plugins = generatePlugins(candidate, negative) + + // Insert modifier versions of each plugin (if necessary) + plugins = insertModifierPlugins(plugins, parseModifiers(candidate)) + + // 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: Array.from(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 (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 { + raw, + value: raw.slice(1, -1), + dataType: 'any', + } + } + + return { + raw, + value, + dataType, + } +} + +/** + * + * @param {string} raw + * @returns {Variant} + */ +function parseVariant(raw) { + if (raw[0] === '[' && raw[raw.length - 1] === ']') { + return { + /** @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 { + type: 'partial', + raw, + name, + value, + dataType: 'any', + } + } + + return { + 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 +} 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/tests/candidate.test.js b/tests/candidate.test.js new file mode 100644 index 000000000..6356a90eb --- /dev/null +++ b/tests/candidate.test.js @@ -0,0 +1,555 @@ +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: ':', + }, + }) + + expect(actual).toEqual(expected) +}) From 5fe5a65029a50b595d64f63c326f69a22f327d9c Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 10 May 2022 11:50:27 -0400 Subject: [PATCH 04/19] Use new candidate parsing in resolveMatches --- src/lib/generateRules.js | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index 4cf780f98..6e7ae8dc2 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -5,14 +5,13 @@ 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, isParsableCssValue, isValidPropName } from '../util/css-validation.js' +import { parseCandidate } from '../lib/candidate.js' let classNameParser = selectorParser((selectors) => { return selectors.first.filter(({ type }) => type === 'class').pop().value @@ -374,14 +373,6 @@ function* resolveMatchedPlugins(classCandidate, context) { } } -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) { for (const match of matches) { match[1].raws.tailwind = { ...match[1].raws.tailwind, classCandidate } @@ -391,15 +382,16 @@ function* recordCandidates(matches, classCandidate) { } function* resolveMatches(candidate, context) { - let separator = context.tailwindConfig.separator - let [classCandidate, ...variants] = splitWithSeparator(candidate, separator).reverse() - let important = false + let parsed = parseCandidate(candidate, context) - if (classCandidate.startsWith('!')) { - important = true - classCandidate = classCandidate.slice(1) + if (parsed === null) { + return } + let classCandidate = parsed.withoutVariants + let important = parsed.important + let variants = parsed.variants.map((v) => v.raw) + // TODO: Reintroduce this in ways that doesn't break on false positives // function sortAgainst(toSort, against) { // return toSort.slice().sort((a, z) => { From f66873f35e71dc6716df2df1c0bb28bee793a9cc Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 9 May 2022 16:35:20 -0400 Subject: [PATCH 05/19] Use parsed object in resolveMatchedPlugins --- src/lib/generateRules.js | 39 ++++++++++++++++----------------------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index 6e7ae8dc2..dc4cca1a2 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -336,39 +336,32 @@ function extractArbitraryProperty(classCandidate, context) { ] } -function* resolveMatchedPlugins(classCandidate, context) { +function* resolveMatchedPlugins(parsed, context) { + let classCandidate = parsed.withoutVariants + if (context.candidateRuleMap.has(classCandidate)) { yield [context.candidateRuleMap.get(classCandidate), '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 === 'custom') { + yield [extractArbitraryProperty(classCandidate, context), 'DEFAULT'] } - if (negative && context.candidateRuleMap.has(candidatePrefix)) { + if (parsed.type !== 'constrained' && parsed.type !== 'partial') { + return + } + + 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] + yield [context.candidateRuleMap.get(prefix), parsed.negative ? `-${modifier}` : modifier] } } } @@ -404,7 +397,7 @@ 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(parsed, context)) { let matches = [] let typesByMatches = new Map() From 90209cfda8130d6c0d6e8c64e230231299c4b8da Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 18 May 2022 10:10:11 -0400 Subject: [PATCH 06/19] Use candidate object in extractArbitraryProperty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By this point arbitrary properties have been validated to be parsable. They aren’t valid candidates if they can’t be parsed (because they won’t produce valid CSS). This means all the checks in here can go away because the candidate parsing has handled it already. --- src/lib/generateRules.js | 31 +++++-------------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index dc4cca1a2..836945f16 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -9,8 +9,7 @@ import { formatVariantSelector, finalizeSelector } from '../util/formatVariantSe import { asClass } from '../util/nameClass' import { normalize } from '../util/dataTypes' import { isValidVariantFormatString, parseVariant } from './setupContextUtils' -import isValidArbitraryValue from '../util/isValidArbitraryValue' -import { isParsableNode, isParsableCssValue, isValidPropName } from '../util/css-validation.js' +import { isParsableNode } from '../util/css-validation.js' import { parseCandidate } from '../lib/candidate.js' let classNameParser = selectorParser((selectors) => { @@ -303,33 +302,13 @@ function parseRules(rule, cache, options = {}) { return [cache.get(rule), options] } -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, }, }), ], @@ -344,7 +323,7 @@ function* resolveMatchedPlugins(parsed, context) { } if (parsed.type === 'custom') { - yield [extractArbitraryProperty(classCandidate, context), 'DEFAULT'] + yield [extractArbitraryProperty(parsed, context), 'DEFAULT'] } if (parsed.type !== 'constrained' && parsed.type !== 'partial') { From 3192f2a007e846a7edfb70681effa31699b5e51f Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 9 May 2022 16:45:11 -0400 Subject: [PATCH 07/19] Use object in recordCandidates --- src/lib/generateRules.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index 836945f16..b716f42c5 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -345,9 +345,15 @@ function* resolveMatchedPlugins(parsed, context) { } } -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 } @@ -472,7 +478,7 @@ function* resolveMatches(candidate, context) { } matches = matches.flat() - matches = Array.from(recordCandidates(matches, classCandidate)) + matches = Array.from(recordCandidates(matches, parsed)) matches = applyPrefix(matches, context) if (important) { From c8be9204688dcc9161cc3402e3a386bf8cfaecfe Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 9 May 2022 16:46:50 -0400 Subject: [PATCH 08/19] Use candidate object in applyImportant --- src/lib/generateRules.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index b716f42c5..f55ef75fd 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -89,10 +89,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) { @@ -482,7 +485,7 @@ function* resolveMatches(candidate, context) { matches = applyPrefix(matches, context) if (important) { - matches = applyImportant(matches, classCandidate) + matches = applyImportant(matches, parsed) } for (let variant of variants) { From 92000a582070f46e5591f292916dfdeeea9ecf9d Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 18 May 2022 10:10:40 -0400 Subject: [PATCH 09/19] Stop double-parsing variants --- src/lib/generateRules.js | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index f55ef75fd..dc9d7de69 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -131,25 +131,21 @@ 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 /* [ */) - } - - // Register arbitrary variants - if (isArbitraryValue(variant) && !context.variantMap.has(variant)) { - let selector = normalize(variant.slice(1, -1)) - - if (!isValidVariantFormatString(selector)) { - return [] - } - - let fn = parseVariant(selector) + 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) 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)) { @@ -369,9 +365,8 @@ function* resolveMatches(candidate, context) { return } - let classCandidate = parsed.withoutVariants let important = parsed.important - let variants = parsed.variants.map((v) => v.raw) + let variants = parsed.variants // TODO: Reintroduce this in ways that doesn't break on false positives // function sortAgainst(toSort, against) { From 3cb47aaab6f601157c967954f61cf0c86aac84f1 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 11 May 2022 10:10:20 -0400 Subject: [PATCH 10/19] Add test --- tests/arbitrary-values.test.js | 75 ++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) 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); + } + `) + }) +}) From 5bcd9d2f69f3ef6a7356cb72f005499fc7d3cb5d Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 18 May 2022 10:11:06 -0400 Subject: [PATCH 11/19] WIP --- src/lib/generateRules.js | 129 ++++++++++++++++------------------- src/lib/setupContextUtils.js | 58 ++++++++++++++-- src/util/dataTypes.js | 10 +-- src/util/nameClass.js | 4 ++ src/util/pluginUtils.js | 119 +++++++++++++++----------------- 5 files changed, 175 insertions(+), 145 deletions(-) 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] } From 985e35722b3f02ea9b1a6616541817cb8ff2e372 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 13 May 2022 11:45:04 -0400 Subject: [PATCH 12/19] WIP --- src/util/pluginUtils.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/util/pluginUtils.js b/src/util/pluginUtils.js index ef4adf0c5..88701104a 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, From cd24c5904b79bc96f489fe8c351dd4a804d8baf5 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 13 May 2022 17:21:13 -0400 Subject: [PATCH 13/19] WIP --- src/util/pluginUtils.js | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/util/pluginUtils.js b/src/util/pluginUtils.js index 88701104a..a3361abd7 100644 --- a/src/util/pluginUtils.js +++ b/src/util/pluginUtils.js @@ -46,14 +46,6 @@ function resolveArbitraryValue(modifier, validate) { 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) @@ -76,7 +68,11 @@ export function asValue(modifier, options = {}, { candidate, validate = () => tr } 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 undefined From 16349e74c4aced9248772a14af1af7465b2107d2 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 13 May 2022 17:21:17 -0400 Subject: [PATCH 14/19] WIP --- src/lib/candidate.js | 75 +++++++++++++++++++++++++++++++------------- 1 file changed, 54 insertions(+), 21 deletions(-) diff --git a/src/lib/candidate.js b/src/lib/candidate.js index f01b80665..bf09a58ab 100644 --- a/src/lib/candidate.js +++ b/src/lib/candidate.js @@ -15,8 +15,19 @@ import { candidatePermutations } from '../util/candidatePermutations' import * as sharedState from './sharedState' import { asClass } from '../util/nameClass.js' -/** @type {Map} */ -let candidateCache = new Map() +let caches = { + /** @type {Map} */ + candidates: new Map(), + + /** @type {Map} */ + variants: new Map(), + + /** @type {Map} */ + modifiers: new Map(), + + /** @type {Map} */ + arbitrary: new Map(), +} /** * @@ -25,17 +36,15 @@ let candidateCache = new Map() * @returns {Iterable} */ export function parseCandidate(raw, context) { - let candidates = candidateCache.get(raw) - - if (!candidates) { - candidates = Array.from(parseStructure(raw, context)) - .map((candidate) => validateCandidate(candidate)) - .filter(Boolean) - - candidateCache.set(raw, candidates) + if (caches.candidates.has(raw)) { + return caches.candidates.get(raw) } - return candidates + let candidates = Array.from(parseStructure(raw, context)) + .map((candidate) => validateCandidate(candidate)) + .filter(Boolean) + + return put(caches.candidates, raw, candidates) } /** @@ -178,6 +187,7 @@ function* parseStructure(raw, context) { let [candidate, ...rawVariants] = Array.from( splitAtTopLevelOnly(raw, context.tailwindConfig.separator) ).reverse() + let variants = rawVariants.map(parseVariant) // Important? @@ -352,6 +362,10 @@ function extractArbitraryValue(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 } @@ -372,18 +386,18 @@ function parseArbitraryValue(raw) { // identifier. We currently only support "simple" words with dashes or // underscores. E.g.: family-name if (!isValidDataType) { - return { + return put(caches.arbitrary, raw, { raw, value: raw.slice(1, -1), dataType: 'any', - } + }) } - return { + return put(caches.arbitrary, raw, { raw, value, dataType, - } + }) } /** @@ -392,34 +406,38 @@ function parseArbitraryValue(raw) { * @returns {Variant} */ function parseVariant(raw) { + if (caches.variants.has(raw)) { + return caches.variants.get(raw) + } + if (raw[0] === '[' && raw[raw.length - 1] === ']') { - return { + 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 { + return put(caches.variants, raw, { type: 'partial', raw, name, value, dataType: 'any', - } + }) } - return { + return put(caches.variants, raw, { type: 'constrained', raw, name: raw, - } + }) } /** @@ -454,3 +472,18 @@ function parseModifiers(raw) { 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 +} From b811c698fe055258756adb6cb581ac672c93199e Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 18 May 2022 10:13:51 -0400 Subject: [PATCH 15/19] Fix rebase problem --- src/lib/generateRules.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index 839858f14..8b30b6a22 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -104,6 +104,10 @@ function applyVariant(variant, matches, context) { // Register arbitrary variants let fn = parseVariant(variant.value) + if (!isValidVariantFormatString(variant.value)) { + return [] + } + let sort = Array.from(context.variantOrder.values()).pop() << 1n context.variantMap.set(variant.raw, [[sort, fn]]) context.variantOrder.set(variant.raw, sort) From 52828f0100690ca6af0a70f071fcbb791fdef922 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 18 May 2022 10:45:01 -0400 Subject: [PATCH 16/19] Push candidate object into finalizeSelector --- src/lib/candidate.d.ts | 2 +- src/lib/generateRules.js | 2 +- src/util/formatVariantSelector.js | 4 +-- src/util/pluginUtils.js | 5 +++ tests/format-variant-selector.test.js | 45 ++++++++++++++------------- 5 files changed, 32 insertions(+), 26 deletions(-) diff --git a/src/lib/candidate.d.ts b/src/lib/candidate.d.ts index 546cef096..3f0676538 100644 --- a/src/lib/candidate.d.ts +++ b/src/lib/candidate.d.ts @@ -43,7 +43,7 @@ type UtilityType = export type Candidate = UtilityCommon & Extract -export function parseCandidate(raw: string, context: any): Candidate | null +export function parseCandidate(raw: string, context: any): Iterable export type AnyStringWithAutocomplete = T | (string & Record) diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index 8b30b6a22..8fd7bf199 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -475,7 +475,7 @@ function* resolveMatchesForCandidate(candidate, context) { rule.selector = finalizeSelector(finalFormat, { selector: rule.selector, - candidate: candidate.raw, + candidate, context, }) }) 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/pluginUtils.js b/src/util/pluginUtils.js index a3361abd7..377cd8bbd 100644 --- a/src/util/pluginUtils.js +++ b/src/util/pluginUtils.js @@ -78,6 +78,11 @@ export function asValue(modifier, options = {}, { candidate, validate = () => tr return undefined } +/** + * + * @param {string|import('../lib/candidate').Arbitrary} input + * @returns {input is import('../lib/candidate').Arbitrary} + */ function isArbitraryValue(input) { return typeof input === 'object' } diff --git a/tests/format-variant-selector.test.js b/tests/format-variant-selector.test.js index 94e86ddfc..80373d2c6 100644 --- a/tests/format-variant-selector.test.js +++ b/tests/format-variant-selector.test.js @@ -1,8 +1,9 @@ +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 +14,7 @@ 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 +25,7 @@ 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 +36,7 @@ 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 +47,7 @@ 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 +58,7 @@ 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 +69,7 @@ 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 +80,7 @@ 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 +91,7 @@ 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 +102,7 @@ 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 +113,7 @@ 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 +124,7 @@ 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 +150,7 @@ 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 +163,7 @@ 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 +176,7 @@ 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 +194,7 @@ 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 +205,7 @@ 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 +216,7 @@ 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 +227,7 @@ 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 +239,7 @@ 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 +250,7 @@ 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 +277,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')) From f54152b19ee7df436aa65360bfa982e3763ac37f Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 18 May 2022 11:35:53 -0400 Subject: [PATCH 17/19] Fix CS --- tests/format-variant-selector.test.js | 88 ++++++++++++++++++++------- 1 file changed, 66 insertions(+), 22 deletions(-) diff --git a/tests/format-variant-selector.test.js b/tests/format-variant-selector.test.js index 80373d2c6..7d59a51f7 100644 --- a/tests/format-variant-selector.test.js +++ b/tests/format-variant-selector.test.js @@ -3,7 +3,9 @@ import { formatVariantSelector, finalizeSelector } from '../src/util/formatVaria it('should be possible to add a simple variant to a simple selector', () => { let selector = '.text-center' - let candidate = Array.from(parseCandidate('hover:text-center', { tailwindConfig: { separator: ':' } }))[0] + let candidate = Array.from( + parseCandidate('hover:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = ['&:hover'] @@ -14,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 = Array.from(parseCandidate('focus:hover:text-center', { tailwindConfig: { separator: ':' } }))[0] + let candidate = Array.from( + parseCandidate('focus:hover:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = ['&:hover', '&:focus'] @@ -25,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 = Array.from(parseCandidate('hover:bg-[rgba(0,0,0)]', { tailwindConfig: { separator: ':' } }))[0] + let candidate = Array.from( + parseCandidate('hover:bg-[rgba(0,0,0)]', { tailwindConfig: { separator: ':' } }) + )[0] let variants = ['&:hover'] @@ -36,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 = Array.from(parseCandidate('hover:bg-[rgba(0,0,0)]', { tailwindConfig: { separator: ':' } }))[0] + let candidate = Array.from( + parseCandidate('hover:bg-[rgba(0,0,0)]', { tailwindConfig: { separator: ':' } }) + )[0] let variants = ['&:hover'] @@ -47,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 = Array.from(parseCandidate('hover:space-x-4', { tailwindConfig: { separator: ':' } }))[0] + let candidate = Array.from( + parseCandidate('hover:space-x-4', { tailwindConfig: { separator: ':' } }) + )[0] let variants = ['&:hover'] @@ -58,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 = Array.from(parseCandidate('disabled:focus:hover:space-x-4', { tailwindConfig: { separator: ':' } }))[0] + let candidate = Array.from( + parseCandidate('disabled:focus:hover:space-x-4', { tailwindConfig: { separator: ':' } }) + )[0] let variants = ['&:hover', '&:focus', '&:disabled'] @@ -69,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 = Array.from(parseCandidate('group-hover:text-center', { tailwindConfig: { separator: ':' } }))[0] + let candidate = Array.from( + parseCandidate('group-hover:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = [':merge(.group):hover &'] @@ -80,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 = Array.from(parseCandidate('group-focus:group-hover:text-center', { tailwindConfig: { separator: ':' } }))[0] + let candidate = Array.from( + parseCandidate('group-focus:group-hover:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = [':merge(.group):hover &', ':merge(.group):focus &'] @@ -91,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 = Array.from(parseCandidate('group-hover:space-x-4', { tailwindConfig: { separator: ':' } }))[0] + let candidate = Array.from( + parseCandidate('group-hover:space-x-4', { tailwindConfig: { separator: ':' } }) + )[0] let variants = [':merge(.group):hover &'] @@ -102,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 = Array.from(parseCandidate('group-focus:group-hover:space-x-4', { tailwindConfig: { separator: ':' } }))[0] + let candidate = Array.from( + parseCandidate('group-focus:group-hover:space-x-4', { tailwindConfig: { separator: ':' } }) + )[0] let variants = [':merge(.group):hover &', ':merge(.group):focus &'] @@ -113,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 = Array.from(parseCandidate('peer-focus:group-hover:text-center', { tailwindConfig: { separator: ':' } }))[0] + let candidate = Array.from( + parseCandidate('peer-focus:group-hover:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = [':merge(.group):hover &', ':merge(.peer):focus ~ &'] @@ -124,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 = Array.from(parseCandidate('group-hover:peer-focus:text-center', { tailwindConfig: { separator: ':' } }))[0] + let candidate = Array.from( + parseCandidate('group-hover:peer-focus:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = [':merge(.peer):focus ~ &', ':merge(.group):hover &'] @@ -150,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 = Array.from(parseCandidate('group-hover:prose-headings:text-center', { tailwindConfig: { separator: ':' } }))[0] + 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 @@ -163,7 +189,9 @@ it('group hover and prose headings combination', () => { it('group hover and prose headings combination flipped', () => { let selector = '.text-center' - let candidate = Array.from(parseCandidate('prose-headings:group-hover:text-center', { tailwindConfig: { separator: ':' } }))[0] + 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 @@ -176,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 = Array.from(parseCandidate('peer-disabled:peer-first-child:group-hover:group-focus:focus:hover:space-x-4', { tailwindConfig: { separator: ':' } }))[0] + 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 @@ -194,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 = Array.from(parseCandidate('hover:placeholder-red-500', { tailwindConfig: { separator: ':' } }))[0] + let candidate = Array.from( + parseCandidate('hover:placeholder-red-500', { tailwindConfig: { separator: ':' } }) + )[0] let variants = ['&:hover'] @@ -205,7 +239,9 @@ describe('real examples', () => { it('example b', () => { let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])' - let candidate = Array.from(parseCandidate('group-hover:hover:space-x-4', { tailwindConfig: { separator: ':' } }))[0] + let candidate = Array.from( + parseCandidate('group-hover:hover:space-x-4', { tailwindConfig: { separator: ':' } }) + )[0] let variants = ['&:hover', ':merge(.group):hover &'] @@ -216,7 +252,9 @@ describe('real examples', () => { it('should work for group-hover and class dark mode combinations', () => { let selector = '.text-center' - let candidate = Array.from(parseCandidate('dark:group-hover:text-center', { tailwindConfig: { separator: ':' } }))[0] + let candidate = Array.from( + parseCandidate('dark:group-hover:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = [':merge(.group):hover &', '.dark &'] @@ -227,7 +265,9 @@ describe('real examples', () => { it('should work for group-hover and class dark mode combinations (reversed)', () => { let selector = '.text-center' - let candidate = Array.from(parseCandidate('group-hover:dark:text-center', { tailwindConfig: { separator: ':' } }))[0] + let candidate = Array.from( + parseCandidate('group-hover:dark:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = ['.dark &', ':merge(.group):hover &'] @@ -239,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 = Array.from(parseCandidate('hover:prose-headings:text-center', { tailwindConfig: { separator: ':' } }))[0] + let candidate = Array.from( + parseCandidate('hover:prose-headings:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = [':where(&) :is(h1, h2, h3, h4)', '&:hover'] @@ -250,7 +292,9 @@ describe('real examples', () => { it('should be possible to use prose-headings:hover:text-center', () => { let selector = '.text-center' - let candidate = Array.from(parseCandidate('prose-headings:hover:text-center', { tailwindConfig: { separator: ':' } }))[0] + let candidate = Array.from( + parseCandidate('prose-headings:hover:text-center', { tailwindConfig: { separator: ':' } }) + )[0] let variants = ['&:hover', ':where(&) :is(h1, h2, h3, h4)'] @@ -277,7 +321,7 @@ describe('pseudo elements', () => { `('should translate "$before" into "$after"', ({ before, after }) => { let result = finalizeSelector(formatVariantSelector('&', before), { selector: '.a', - candidate: Array.from(parseCandidate('a', { tailwindConfig: { separator: ':'} }))[0], + candidate: Array.from(parseCandidate('a', { tailwindConfig: { separator: ':' } }))[0], }) expect(result).toEqual(after.replace('&', '.a')) From b756b00ba1fbca7f7f9ec857ac477662d20f2856 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 18 May 2022 11:39:55 -0400 Subject: [PATCH 18/19] Fix build --- .swcrc | 1 + 1 file changed, 1 insertion(+) diff --git a/.swcrc b/.swcrc index 956ec9d6a..756a57b27 100644 --- a/.swcrc +++ b/.swcrc @@ -1,4 +1,5 @@ { + "exclude": ".d.ts$", "module": { "type": "commonjs" }, From 880ceb49c9642c5d69152927a57ee91633275d9f Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 18 May 2022 12:03:26 -0400 Subject: [PATCH 19/19] WIP --- src/lib/candidate.d.ts | 2 +- src/lib/candidate.js | 30 ++++++++++++++++++------------ src/lib/generateRules.js | 2 +- tests/candidate.test.js | 12 ++++++++++++ 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/lib/candidate.d.ts b/src/lib/candidate.d.ts index 3f0676538..d82627235 100644 --- a/src/lib/candidate.d.ts +++ b/src/lib/candidate.d.ts @@ -37,7 +37,7 @@ export type Plugin = { type UtilityType = | { type: 'constrained' - plugins: Plugin[] + plugins(): Iterable } | { type: 'custom'; name: string; value: string; modifiers: Modifier[] } diff --git a/src/lib/candidate.js b/src/lib/candidate.js index bf09a58ab..34770b9b1 100644 --- a/src/lib/candidate.js +++ b/src/lib/candidate.js @@ -71,7 +71,8 @@ function validateCandidate(candidate) { } // Normalize arbitrary values - candidate.plugins = Array.from(validatePlugins(candidate.plugins)) + let plugins = candidate.plugins + candidate.plugins = () => validatePlugins(plugins()) return candidate } @@ -169,15 +170,18 @@ function* parseStructure(raw, context) { variants: [], negative: false, + /** @type {'constrained'} */ type: 'constrained', - plugins: [ - { + + // @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 @@ -256,17 +260,19 @@ function* parseStructure(raw, context) { return } - // Generate each plugin - let plugins = generatePlugins(candidate, negative) - - // Insert modifier versions of each plugin (if necessary) - plugins = insertModifierPlugins(plugins, parseModifiers(candidate)) - // 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: Array.from(plugins), + plugins: () => { + // Generate each plugin + let plugins = generatePlugins(candidate, negative) + + // Insert modifier versions of each plugin (if necessary) + plugins = insertModifierPlugins(plugins, parseModifiers(candidate)) + + return plugins + } }) } diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index 8fd7bf199..2053a411e 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -304,7 +304,7 @@ function* resolveMatchedPlugins(parsed, context) { yield [context.candidateRuleMap.get(candidatePrefix), '-DEFAULT'] } - for (let plugin of parsed.plugins) { + for (let plugin of parsed.plugins()) { let ruleGenerator = context.candidateRuleMap.get(parsed.prefix + plugin.plugin) || context.candidateRuleMap.get(plugin.plugin) diff --git a/tests/candidate.test.js b/tests/candidate.test.js index 6356a90eb..2d2b7567d 100644 --- a/tests/candidate.test.js +++ b/tests/candidate.test.js @@ -551,5 +551,17 @@ it.each([ }, }) + + // 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) })