postcss-pseudo-companion-classes
Version:
PostCSS plugin to add companion classes to pseudo-classes for testing purposes (works with css modules)
186 lines (159 loc) • 5.46 kB
JavaScript
/**
* @typedef {object} Options
* @property {string[]} [exclude] - pseudo-classes to skip
* @property {string[]} [restrictTo] - only create classes for a restricted list of selectors
* @property {boolean} [allCombinations] - when multiple pseudo-classes are present (ie `:hover:focus`),
* output classes for each combination
* @property {boolean} [isModule] - wrap companion classes in `:global()` to prevent them from being renamed
* @property {string} [prefix] - prefix for the companion class
*/
/**
* @type {import('postcss').PluginCreator<Options>}
*/
module.exports = (options = {}) => {
const {
exclude = [':before', ':after'],
restrictTo,
allCombinations = false,
isModule = false,
prefix = '\\:',
} = options
/**
* Adds a leading colon if missing and removes any content in ()
* @param {string} pseudoClass
* @returns string
*/
const standardizePseudoClass = (pseudoClass) =>
(pseudoClass.charAt(0) === ':' ? '' : ':') + pseudoClass.replace(/\(.*/g, '')
/**
* Reduces an array to an object with the array item as the key
* @param {Object.<string, boolean>} dict
* @param {string} pseudoClass
* @returns {Object.<string, boolean>}
*/
const toDict = (dict, pseudoClass) => {
dict[standardizePseudoClass(pseudoClass)] = true
return dict
}
/**
* Is the item in the excluded dictionary
* @param {string} item
* @returns boolean
*/
const isExcluded = (item) => !!excludeDict[item]
const excludeDict = (exclude || []).reduce(toDict, {
':root': true,
':host': true,
':host-context': true,
':global': true,
})
const restrictToDict = Array.isArray(restrictTo) && restrictTo.length
? restrictTo.reduce(toDict, {})
: null
return {
postcssPlugin: 'postcss-pseudo-companion-classes',
Once (css) {
css.walkRules(rule => {
rule.selectors.forEach((selector) => {
if (isExcluded(selector)) {
return
}
const selectorParts = selector.split(' ')
const pseudoedSelectorParts = []
selectorParts.forEach((selectorPart, index) => {
const pseudos = selectorPart.match(/::?([^:]+)/g)
if (!pseudos) {
pseudoedSelectorParts.push(allCombinations ? [selectorPart] : selectorPart)
return
}
const baseSelector = selectorPart.slice(
0,
selectorPart.length - pseudos.join('').length,
)
const classPseudos = pseudos.map((pseudo) => {
const pseudoToCheck = pseudo
.replace(/\(.*/g, '')
.replace(/\)/g, '')
.replace('::', ':')
// restrictTo a subset of pseudo-classes
if (
excludeDict[pseudoToCheck] ||
pseudoToCheck.split('.').some(isExcluded) ||
pseudoToCheck.split('#').some(isExcluded) ||
restrictToDict &&
!restrictToDict[pseudoToCheck]
) {
return pseudo
}
const prefixedPseudoClass = `.${prefix}${pseudo.replace(/^::?/, '')}`
// Wrap class in :global() to prevent css module rename
return isModule ? `:global(${prefixedPseudoClass})` : prefixedPseudoClass
})
// Add all combinations of pseudo-classes/pseudo styles given a selector with multiple pseudo styles.
if (allCombinations) {
const combinations = createCombinations(pseudos, classPseudos)
pseudoedSelectorParts[index] = []
combinations.forEach((combination) => {
pseudoedSelectorParts[index].push(baseSelector + combination)
})
} else {
pseudoedSelectorParts.push(baseSelector + classPseudos.join(''))
}
})
if (allCombinations) {
const serialCombinations = createSerialCombinations(pseudoedSelectorParts)
serialCombinations.forEach((combination) => {
addSelector(combination)
})
} else {
addSelector(pseudoedSelectorParts.join(' '))
}
function addSelector(newSelector) {
if (newSelector && newSelector !== selector) {
rule.selector += ',\n' + newSelector
}
}
})
})
},
}
}
// a.length === b.length
function createCombinations(a, b) {
let combinations = ['']
let newCombinations
for (let i = 0, len = a.length; i < len; i += 1) {
newCombinations = []
combinations.forEach((combination) => {
newCombinations.push(combination + a[i])
// Don't repeat work.
if (a[i] !== b[i]) {
newCombinations.push(combination + b[i])
}
})
combinations = newCombinations
}
return combinations
}
/**
*
* @param {string[][]} selectorParts
* @param {string} [selector]
* @returns string[]
*/
function createSerialCombinations(selectorParts, selector = '') {
if (!selectorParts.length) {
return selector
}
return selectorParts[0].flatMap((value) => (
createSerialCombinations(selectorParts.slice(1), concatWithSpace(selector, value))
), [])
}
/**
* @param {string[]} items
* @returns string
*/
function concatWithSpace(...items) {
return items.filter(Boolean).join(' ')
}
module.exports.postcss = true