From bb5782bff296805f95c6bae0ce434be314bd1580 Mon Sep 17 00:00:00 2001 From: Floriel Date: Sun, 19 Feb 2023 22:57:57 +0100 Subject: [PATCH] feat(purgecss): add support for :where and :is #978 --- packages/purgecss-from-html/src/index.ts | 8 ++-- .../purgecss/__tests__/pseudo-class.test.ts | 34 ++++++++++++++++ .../test_examples/pseudo-class/is.css | 24 +++++++++++ .../test_examples/pseudo-class/is.html | 8 ++++ .../test_examples/pseudo-class/where.css | 24 +++++++++++ .../test_examples/pseudo-class/where.html | 8 ++++ packages/purgecss/src/index.ts | 40 ++++++++++++++++++- 7 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 packages/purgecss/__tests__/test_examples/pseudo-class/is.css create mode 100644 packages/purgecss/__tests__/test_examples/pseudo-class/is.html create mode 100644 packages/purgecss/__tests__/test_examples/pseudo-class/where.css create mode 100644 packages/purgecss/__tests__/test_examples/pseudo-class/where.html diff --git a/packages/purgecss-from-html/src/index.ts b/packages/purgecss-from-html/src/index.ts index 1e20d016..f27d9cab 100644 --- a/packages/purgecss-from-html/src/index.ts +++ b/packages/purgecss-from-html/src/index.ts @@ -35,7 +35,7 @@ const mergedExtractorResults = ( }; const getSelectorsInElement = ( - element: htmlparser2.Htmlparser2TreeAdapterMap['element'] + element: htmlparser2.Htmlparser2TreeAdapterMap["element"] ): ExtractorResultDetailed => { const result: ExtractorResultDetailed = { attributes: { @@ -63,7 +63,9 @@ const getSelectorsInElement = ( }; const getSelectorsInNodes = ( - node: htmlparser2.Htmlparser2TreeAdapterMap['document'] | htmlparser2.Htmlparser2TreeAdapterMap['element'] + node: + | htmlparser2.Htmlparser2TreeAdapterMap["document"] + | htmlparser2.Htmlparser2TreeAdapterMap["element"] ): ExtractorResultDetailed => { let result: ExtractorResultDetailed = { attributes: { @@ -103,7 +105,7 @@ const getSelectorsInNodes = ( */ const purgecssFromHtml = (content: string): ExtractorResultDetailed => { const tree = parse5.parse(content, { - treeAdapter: htmlparser2.adapter + treeAdapter: htmlparser2.adapter, }); return getSelectorsInNodes(tree); diff --git a/packages/purgecss/__tests__/pseudo-class.test.ts b/packages/purgecss/__tests__/pseudo-class.test.ts index 63819a4c..ef5cd9dc 100644 --- a/packages/purgecss/__tests__/pseudo-class.test.ts +++ b/packages/purgecss/__tests__/pseudo-class.test.ts @@ -100,3 +100,37 @@ describe("pseudo classes", () => { expect(purgedCSS.includes("row:after")).toBe(false); }); }); + +describe(":where pseudo class", () => { + let purgedCSS: string; + beforeAll(async () => { + const resultsPurge = await new PurgeCSS().purge({ + content: [`${ROOT_TEST_EXAMPLES}pseudo-class/where.html`], + css: [`${ROOT_TEST_EXAMPLES}pseudo-class/where.css`], + }); + purgedCSS = resultsPurge[0].css; + }); + + it("removes unused selectors", () => { + expect(purgedCSS.includes(".unused")).toBe(false); + expect(purgedCSS.includes(".root :where(.a) .c {")).toBe(true); + expect(purgedCSS.includes(".root:where(.a) .c {")).toBe(true); + }); +}); + +describe(":is pseudo class", () => { + let purgedCSS: string; + beforeAll(async () => { + const resultsPurge = await new PurgeCSS().purge({ + content: [`${ROOT_TEST_EXAMPLES}pseudo-class/is.html`], + css: [`${ROOT_TEST_EXAMPLES}pseudo-class/is.css`], + }); + purgedCSS = resultsPurge[0].css; + }); + + it("removes unused selectors", () => { + expect(purgedCSS.includes(".unused")).toBe(false); + expect(purgedCSS.includes(".root :is(.a) .c {")).toBe(true); + expect(purgedCSS.includes(".root:is(.a) .c {")).toBe(true); + }); +}); diff --git a/packages/purgecss/__tests__/test_examples/pseudo-class/is.css b/packages/purgecss/__tests__/test_examples/pseudo-class/is.css new file mode 100644 index 00000000..5f22ab8e --- /dev/null +++ b/packages/purgecss/__tests__/test_examples/pseudo-class/is.css @@ -0,0 +1,24 @@ +.root :is(.a, .b) .unused { + color: red; +} + +.root :is(.a, .unused) .c { + color: blue; +} + +.root :is(.a, .b) .c :is(.unused, .unused2) { + color: green; +} + +.root:is(.unused) .c { + color: rebeccapurple; +} + +.root:is(.a) .c { + color: cyan; +} + +.root :is(.unused) .c, +.root :is(.a, .b) .c :is(.unused, .unused2) { + display: flex; +} \ No newline at end of file diff --git a/packages/purgecss/__tests__/test_examples/pseudo-class/is.html b/packages/purgecss/__tests__/test_examples/pseudo-class/is.html new file mode 100644 index 00000000..fd573c34 --- /dev/null +++ b/packages/purgecss/__tests__/test_examples/pseudo-class/is.html @@ -0,0 +1,8 @@ +
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/packages/purgecss/__tests__/test_examples/pseudo-class/where.css b/packages/purgecss/__tests__/test_examples/pseudo-class/where.css new file mode 100644 index 00000000..d920bcbc --- /dev/null +++ b/packages/purgecss/__tests__/test_examples/pseudo-class/where.css @@ -0,0 +1,24 @@ +.root :where(.a, .b) .unused { + color: red; +} + +.root :where(.a, .unused) .c { + color: blue; +} + +.root :where(.a, .b) .c :where(.unused, .unused2) { + color: green; +} + +.root:where(.unused) .c { + color: rebeccapurple; +} + +.root:where(.a) .c { + color: cyan; +} + +.root :where(.unused) .c, +.root :where(.a, .b) .c :where(.unused, .unused2) { + display: flex; +} \ No newline at end of file diff --git a/packages/purgecss/__tests__/test_examples/pseudo-class/where.html b/packages/purgecss/__tests__/test_examples/pseudo-class/where.html new file mode 100644 index 00000000..fd573c34 --- /dev/null +++ b/packages/purgecss/__tests__/test_examples/pseudo-class/where.html @@ -0,0 +1,8 @@ +
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/packages/purgecss/src/index.ts b/packages/purgecss/src/index.ts index 05e6516b..28970474 100644 --- a/packages/purgecss/src/index.ts +++ b/packages/purgecss/src/index.ts @@ -289,6 +289,20 @@ function isInPseudoClass(selector: selectorParser.Node): boolean { ); } +/** + * Returns true if the selector is inside the pseudo classes :where() or :is() + * @param selector - selector + */ +function isInPseudoClassWhereOrIs(selector: selectorParser.Node): boolean { + return ( + (selector.parent && + selector.parent.type === "pseudo" && + (selector.parent.value === ":where" || + selector.parent.value === ":is")) || + false + ); +} + function isPostCSSAtRule(node?: postcss.Node): node is postcss.AtRule { return node?.type === "atrule"; } @@ -511,6 +525,13 @@ class PurgeCSS { let keepSelector = true; const selectorsRemovedFromRule: string[] = []; + + // selector transformer, walk over the list of the parsed selectors twice. + // First pass will remove the unused selectors. It goes through + // pseudo-classes like :where() and :is() and remove the unused + // selectors inside of them, but will not remove the pseudo-classes + // themselves. Second pass will remove selectors containing empty + // :where and :is. node.selector = selectorParser((selectorsParsed) => { selectorsParsed.walk((selector) => { if (selector.type !== "selector") { @@ -529,6 +550,19 @@ class PurgeCSS { selector.remove(); } }); + + selectorsParsed.walk((selector) => { + if (selector.type !== "selector") { + return; + } + + if ( + selector.toString() && + /(:where$)|(:is$)|(:where[^(])|(:is[^(])/.test(selector.toString()) + ) { + selector.remove(); + } + }); }).processSync(node.selector); // declarations @@ -815,8 +849,10 @@ class PurgeCSS { selector: selectorParser.Selector, selectorsFromExtractor: ExtractorResultSets ): boolean { - // ignore the selector if it is inside a pseudo class - if (isInPseudoClass(selector)) return true; + // selectors in pseudo classes are ignored except :where() and :is(). For those pseudo-classes, we are treating the selectors inside the same way as they would be outside. + if (isInPseudoClass(selector) && !isInPseudoClassWhereOrIs(selector)) { + return true; + } // if there is any greedy safelist pattern, run all the selector parts through them // if there is any match, return true