UNPKG

postcss-unique-selectors

Version:

Ensure CSS selectors are unique.

216 lines (195 loc) • 6.37 kB
import { tokenize, TokenType } from '@csstools/css-tokenizer'; /** * @param {string} selectors * @return {{start: number, end: number, comments: {start: number, end: number, text: string, structuralWhitespace: boolean}[]}[]} */ function splitSelectors(selectors) { /** @type {{start: number, end: number, comments: {start: number, end: number, text: string, structuralWhitespace: boolean}[]}[]} */ const parts = []; let start = 0; let depth = 0; /** @type {{start: number, end: number, text: string, structuralWhitespace: boolean}[]} */ let comments = []; const tokens = [...tokenize({ css: selectors })]; for (let index = 0; index < tokens.length; index++) { const token = tokens[index]; const [type, , tokenStart, tokenEnd] = token; if (type === TokenType.Comment) { comments.push({ start: tokenStart, end: tokenEnd + 1, text: token[1], structuralWhitespace: commentProvidesWhitespace(tokens, index), }); } if (type === TokenType.Comma && depth === 0) { parts.push({ start, end: tokenStart, comments }); start = tokenEnd + 1; comments = []; continue; } if ( type === TokenType.Function || type === TokenType.OpenParen || type === TokenType.OpenSquare || type === TokenType.OpenCurly ) { depth++; } else if ( type === TokenType.CloseParen || type === TokenType.CloseSquare || type === TokenType.CloseCurly ) { depth = Math.max(0, depth - 1); } } parts.push({ start, end: selectors.length, comments }); // PostCSS's selector parser associates trailing whitespace with the // preceding selector, while a trailing empty selector is discarded. const last = parts.at(-1); if (last && !selectors.slice(last.start, last.end).trim()) { // The parser preserves an empty selector for comment-free input, even // though it discards one when comments are present in the selector list. if (last.start !== last.end) { if (parts.length > 1) { parts[parts.length - 2].end = last.end; } parts.pop(); } else if (selectors.includes('/*')) { parts.pop(); } } return parts; } /** * @param {ReturnType<typeof tokenize>} tokens * @param {number} index * @return {boolean} */ function commentProvidesWhitespace(tokens, index) { // Comments are whitespace in selector grammar. Retain that whitespace in a // comparison key only when it separates two selector components. if (tokens[index - 1]?.[0] === TokenType.Comment) return false; let previous = index - 1; while (tokens[previous]?.[0] === TokenType.Comment) previous--; let next = index + 1; while (tokens[next]?.[0] === TokenType.Comment) next++; return ( canEndSelectorComponent(tokens[previous]) && canStartSelectorComponent(tokens[next]) ); } /** @param {ReturnType<typeof tokenize>[number] | undefined} token @return {boolean} */ function canEndSelectorComponent(token) { if (!token) return false; if ( [ TokenType.Whitespace, TokenType.Comment, TokenType.Comma, TokenType.Function, TokenType.OpenParen, TokenType.OpenSquare, TokenType.OpenCurly, TokenType.Colon, ].includes(token[0]) ) { return false; } return token[0] !== TokenType.Delim || !['>', '+', '~'].includes(token[1]); } /** @param {ReturnType<typeof tokenize>[number] | undefined} token @return {boolean} */ function canStartSelectorComponent(token) { if (!token) return false; if ( [ TokenType.Whitespace, TokenType.Comment, TokenType.Comma, TokenType.CloseParen, TokenType.CloseSquare, TokenType.CloseCurly, ].includes(token[0]) ) { return false; } return token[0] !== TokenType.Delim || !['>', '+', '~'].includes(token[1]); } /** * @param {string} selectors * @param {{start: number, end: number, comments: {start: number, end: number, text: string, structuralWhitespace: boolean}[]}} part * @return {[string, string]} */ function selectorText(selectors, part) { const source = selectors.slice(part.start, part.end); /** @type {string[]} */ const keyParts = []; let start = part.start; for (const comment of part.comments) { keyParts.push(selectors.slice(start, comment.start)); if (comment.structuralWhitespace) keyParts.push(' '); start = comment.end; } keyParts.push(selectors.slice(start, part.end)); return [keyParts.join('').trim(), source]; } /** * @param {string} selectors * @return {string} */ function generateUniqueSelector(selectors) { // No comma means a single selector; nothing to dedupe or sort. if (!selectors.includes(',')) { return selectors; } /** @type {Map<string, {selector: string, comments: string[]}>} */ const uniqueSelectors = new Map(); for (const part of splitSelectors(selectors)) { const [key, text] = selectorText(selectors, part); const selector = uniqueSelectors.get(key); if (selector === undefined) { uniqueSelectors.set(key, { selector: text, comments: [] }); } else if (part.comments.length) { // Comments on duplicate selectors are concatenated to the first selector. selector.comments.push(...part.comments.map((comment) => comment.text)); } } return [...uniqueSelectors.entries()] .toSorted(([a], [b]) => { if (a > b) { return 1; } else { return a < b ? -1 : 0; } }) .map(([, selector]) => selector.selector + selector.comments.join('')) .join(); } /** * @return {import('postcss').Plugin} */ function pluginCreator() { return { postcssPlugin: 'postcss-unique-selectors', /** * @param {import('postcss').Root} css */ OnceExit(css) { css.walkRules((nodes) => { const source = nodes.raws.selector?.value === nodes.selector ? (nodes.raws.selector.raw ?? nodes.selector) : nodes.selector; const selector = generateUniqueSelector(source); nodes.selector = selector; if (nodes.raws.selector?.raw) { nodes.raws.selector = { raw: selector, value: selector }; } }); }, }; } /** @type {true} */ pluginCreator.postcss = true; const moduleExports = pluginCreator; export { moduleExports as default, moduleExports as 'module.exports' };