diff --git a/package-lock.json b/package-lock.json index 8283d43..ba14230 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "lodash.castarray": "^4.4.0", "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2" + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" }, "devDependencies": { "@mdx-js/loader": "^1.0.19", @@ -2645,7 +2646,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, "bin": { "cssesc": "bin/cssesc" }, @@ -7105,10 +7105,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.0.9", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.9.tgz", - "integrity": "sha512-UO3SgnZOVTwu4kyLR22UQ1xZh086RyNZppb7lLAKBFK8a32ttG5i87Y/P3+2bRSjZNyJ1B7hfFNo273tKe9YxQ==", - "dev": true, + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -9218,8 +9217,7 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "node_modules/uuid": { "version": "8.3.2", @@ -11559,8 +11557,7 @@ "cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==" }, "cssom": { "version": "0.4.4", @@ -14886,10 +14883,9 @@ } }, "postcss-selector-parser": { - "version": "6.0.9", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.9.tgz", - "integrity": "sha512-UO3SgnZOVTwu4kyLR22UQ1xZh086RyNZppb7lLAKBFK8a32ttG5i87Y/P3+2bRSjZNyJ1B7hfFNo273tKe9YxQ==", - "dev": true, + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", "requires": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -16515,8 +16511,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "uuid": { "version": "8.3.2", diff --git a/package.json b/package.json index 800ce9a..23ceb22 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "dependencies": { "lodash.castarray": "^4.4.0", "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2" + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" }, "jest": { "setupFilesAfterEnv": [ diff --git a/src/index.js b/src/index.js index 096de2e..9aafa3a 100644 --- a/src/index.js +++ b/src/index.js @@ -2,6 +2,7 @@ const plugin = require('tailwindcss/plugin') const merge = require('lodash.merge') const castArray = require('lodash.castarray') const styles = require('./styles') +const { commonTrailingPseudos } = require('./utils') const computed = { // Reserved for future "magic properties", for example: @@ -12,25 +13,11 @@ function inWhere(selector, { className, prefix }) { let prefixedNot = prefix(`.not-${className}`).slice(1) let selectorPrefix = selector.startsWith('>') ? `.${className} ` : '' - if (selector.endsWith('::before')) { - return `:where(${selectorPrefix}${selector.slice( - 0, - -8 - )}):not(:where([class~="${prefixedNot}"] *))::before` - } - - if (selector.endsWith('::after')) { - return `:where(${selectorPrefix}${selector.slice( - 0, - -7 - )}):not(:where([class~="${prefixedNot}"] *))::after` - } + // Parse the selector, if every component ends in the same pseudo element(s) then move it to the end + let [trailingPseudo, rebuiltSelector] = commonTrailingPseudos(selector) - if (selector.endsWith('::marker')) { - return `:where(${selectorPrefix}${selector.slice( - 0, - -8 - )}):not(:where([class~="${prefixedNot}"] *))::marker` + if (trailingPseudo) { + return `:where(${selectorPrefix}${rebuiltSelector}):not(:where([class~="${prefixedNot}"] *))${trailingPseudo}` } return `:where(${selectorPrefix}${selector}):not(:where([class~="${prefixedNot}"] *))` @@ -118,11 +105,13 @@ module.exports = plugin.withOptions( ]) { selectors = selectors.length === 0 ? [name] : selectors - let selector = target === 'legacy' - ? selectors.map(selector => `& ${selector}`) - : selectors.join(', ') + let selector = + target === 'legacy' ? selectors.map((selector) => `& ${selector}`) : selectors.join(', ') - addVariant(`${className}-${name}`, target === 'legacy' ? selector : `& :is(${inWhere(selector, options)})`) + addVariant( + `${className}-${name}`, + target === 'legacy' ? selector : `& :is(${inWhere(selector, options)})` + ) } addComponents( diff --git a/src/index.test.js b/src/index.test.js index 143ca1d..f8e743d 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -335,7 +335,9 @@ test('modifiers', async () => { test('legacy target', async () => { let config = { plugins: [typographyPlugin({ target: 'legacy' })], - content: [{ raw: html`
` }], + content: [ + { raw: html`
` }, + ], theme: { typography: { DEFAULT: { @@ -712,7 +714,7 @@ test('element variants', async () => { .prose-hr\:border-t-2 :is(:where(hr):not(:where([class~='not-prose'] *))) { border-top-width: 2px; } - .prose-lead\:italic :is(:where([class~="lead"]):not(:where([class~="not-prose"] *))) { + .prose-lead\:italic :is(:where([class~='lead']):not(:where([class~='not-prose'] *))) { font-style: italic; } ` @@ -886,7 +888,7 @@ test('element variants with custom class name', async () => { .markdown-hr\:border-t-2 :is(:where(hr):not(:where([class~='not-markdown'] *))) { border-top-width: 2px; } - .markdown-lead\:italic :is(:where([class~="lead"]):not(:where([class~="not-markdown"] *))) { + .markdown-lead\:italic :is(:where([class~='lead']):not(:where([class~='not-markdown'] *))) { font-style: italic; } ` @@ -1000,3 +1002,198 @@ it('should be possible to specify custom h5 and h6 styles', () => { `) }) }) + +it('should not break with multiple selectors with pseudo elements using variants', () => { + let config = { + darkMode: 'class', + plugins: [typographyPlugin()], + content: [ + { + raw: html`
`, + }, + ], + theme: { + typography: { + DEFAULT: { + css: { + 'ol li::before, ul li::before': { + color: 'red', + }, + }, + }, + }, + }, + } + + return run(config).then((result) => { + expect(result.css).toIncludeCss(css` + .dark .dark\:prose :where(ol li, ul li):not(:where([class~='not-prose'] *))::before { + color: red; + } + `) + }) +}) + +it('lifts all common, trailing pseudo elements when the same across all selectors', () => { + let config = { + darkMode: 'class', + plugins: [typographyPlugin()], + content: [ + { + raw: html`
`, + }, + ], + theme: { + typography: { + DEFAULT: { + css: { + 'ol li::marker::before, ul li::marker::before': { + color: 'red', + }, + }, + }, + }, + }, + } + + return run(config).then((result) => { + expect(result.css).toIncludeCss(css` + .prose :where(ol li, ul li):not(:where([class~='not-prose'] *))::marker::before { + color: red; + } + `) + + // TODO: The output here is a bug in tailwindcss variant selector rewriting + // IT should be ::marker::before + expect(result.css).toIncludeCss(css` + .dark .dark\:prose :where(ol li, ul li):not(:where([class~='not-prose'] *))::before::marker { + color: red; + } + `) + }) +}) + +it('does not modify selectors with differing pseudo elements', () => { + let config = { + darkMode: 'class', + plugins: [typographyPlugin()], + content: [ + { + raw: html`
`, + }, + ], + theme: { + typography: { + DEFAULT: { + css: { + 'ol li::before, ul li::after': { + color: 'red', + }, + }, + }, + }, + }, + } + + return run(config).then((result) => { + expect(result.css).toIncludeCss(css` + .prose :where(ol li::before, ul li::after):not(:where([class~='not-prose'] *)) { + color: red; + } + `) + + // TODO: The output here is a bug in tailwindcss variant selector rewriting + expect(result.css).toIncludeCss(css` + .dark .dark\:prose :where(ol li, ul li):not(:where([class~='not-prose'] *))::before, + ::after { + color: red; + } + `) + }) +}) + +it('lifts only the common, trailing pseudo elements from selectors', () => { + let config = { + darkMode: 'class', + plugins: [typographyPlugin()], + content: [ + { + raw: html`
`, + }, + ], + theme: { + typography: { + DEFAULT: { + css: { + 'ol li::scroll-thumb::before, ul li::scroll-track::before': { + color: 'red', + }, + }, + }, + }, + }, + } + + return run(config).then((result) => { + expect(result.css).toIncludeCss(css` + .prose + :where(ol li::scroll-thumb, ul li::scroll-track):not(:where([class~='not-prose'] + *))::before { + color: red; + } + `) + + // TODO: The output here is a bug in tailwindcss variant selector rewriting + expect(result.css).toIncludeCss(css` + .dark .dark\:prose :where(ol li, ul li):not(:where([class~='not-prose'] *))::scroll-thumb, + ::scroll-track, + ::before { + color: red; + } + `) + }) +}) + +it('ignores common non-trailing pseudo-elements in selectors', () => { + let config = { + darkMode: 'class', + plugins: [typographyPlugin()], + content: [ + { + raw: html`
`, + }, + ], + theme: { + typography: { + DEFAULT: { + css: { + 'ol li::before::scroll-thumb, ul li::before::scroll-track': { + color: 'red', + }, + }, + }, + }, + }, + } + + return run(config).then((result) => { + expect(result.css).toIncludeCss(css` + .prose + :where(ol li::before::scroll-thumb, ul + li::before::scroll-track):not(:where([class~='not-prose'] *)) { + color: red; + } + `) + + // TODO: The output here is a bug in tailwindcss variant selector rewriting + expect(result.css).toIncludeCss(css` + .dark + .dark\:prose + :where(ol li::scroll-thumb, ul li::scroll-track):not(:where([class~='not-prose'] + *))::before, + ::before { + color: red; + } + `) + }) +}) diff --git a/src/utils.js b/src/utils.js index a2f2b3b..085f72e 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,7 +1,62 @@ const isPlainObject = require('lodash.isplainobject') +const parser = require('postcss-selector-parser') +const parseSelector = parser() + module.exports = { isUsableColor(color, values) { return isPlainObject(values) && color !== 'gray' && values[600] }, + + /** + * @param {string} selector + */ + commonTrailingPseudos(selector) { + let ast = parseSelector.astSync(selector) + + /** @type {import('postcss-selector-parser').Pseudo[][]} */ + let matrix = [] + + // Put the pseudo elements in reverse order in a sparse, column-major 2D array + for (let [i, sel] of ast.nodes.entries()) { + for (const [j, child] of [...sel.nodes].reverse().entries()) { + // We only care about pseudo elements + if (child.type !== 'pseudo' || !child.value.startsWith('::')) { + break + } + + matrix[j] = matrix[j] || [] + matrix[j][i] = child + } + } + + let trailingPseudos = parser.selector() + + // At this point the pseudo elements are in a column-major 2D array + // This means each row contains one "column" of pseudo elements from each selector + // We can compare all the pseudo elements in a row to see if they are the same + for (const pseudos of matrix) { + // It's a sparse 2D array so there are going to be holes in the rows + // We skip those + if (!pseudos) { + continue + } + + let values = new Set([...pseudos.map((p) => p.value)]) + + // The pseudo elements are not the same + if (values.size > 1) { + break + } + + pseudos.forEach((pseudo) => pseudo.remove()) + trailingPseudos.prepend(pseudos[0]) + } + + if (trailingPseudos.nodes.length) { + return [trailingPseudos.toString(), ast.toString()] + } + + return [null, selector] + }, }