diff --git a/packages/purgecss/__tests__/pseudo-class.test.ts b/packages/purgecss/__tests__/pseudo-class.test.ts index 6a55e230..d6c34c44 100644 --- a/packages/purgecss/__tests__/pseudo-class.test.ts +++ b/packages/purgecss/__tests__/pseudo-class.test.ts @@ -131,6 +131,141 @@ describe(":where pseudo class", () => { }); }); +describe(":where/:is combined with other pseudo classes at the root level", () => { + const purge = async (css: string, raw: string): Promise => { + const resultsPurge = await new PurgeCSS().purge({ + content: [{ raw, extension: "html" }], + css: [{ raw: css }], + }); + return resultsPurge[0].css; + }; + + it("keeps :where(.x) when x is used", async () => { + expect(await purge(":where(.aaa){color:red}", "aaa")).toContain("aaa"); + }); + + it("keeps .x:not(_) when x is used", async () => { + expect(await purge(".bbb:not(_){color:red}", "bbb")).toContain("bbb"); + }); + + it("keeps :where(.x):not(_) when x is used", async () => { + expect(await purge(":where(.aaa):not(_){color:red}", "aaa")).toContain( + "aaa", + ); + }); + + it("keeps :where(.x):not(_):not(_) when x is used", async () => { + expect( + await purge(":where(.ccc):not(_):not(_){color:red}", "ccc"), + ).toContain("ccc"); + }); + + it("keeps :is(.x):not(_) when x is used", async () => { + expect(await purge(":is(.ddd):not(_){color:red}", "ddd")).toContain("ddd"); + }); + + it("removes :where(.x):not(_) when x is unused", async () => { + expect(await purge(":where(.unused):not(_){color:red}", "aaa")).toBe(""); + }); + + it("keeps the used class inside :where() and trims the unused one", async () => { + const result = await purge( + ":where(.unused, .aaa):not(_):not(_){color:red}", + "aaa", + ); + expect(result).toContain(".aaa"); + expect(result).not.toContain(".unused"); + }); +}); + +describe("root-level pseudo-class selectors", () => { + const purge = async (css: string, raw: string): Promise => { + const resultsPurge = await new PurgeCSS().purge({ + content: [{ raw, extension: "html" }], + css: [{ raw: css }], + }); + return resultsPurge[0].css; + }; + + it("keeps :is(h1, h2, h3) when an h2 is present", async () => { + expect(await purge(":is(h1, h2, h3){color:red}", "

x

")).toContain( + "h2", + ); + }); + + it("removes :is(h1, h2, h3) when no matching tag is present", async () => { + expect(await purge(":is(h1, h2, h3){color:red}", "

x

")).toBe(""); + }); + + it("keeps :is(body) when body is present", async () => { + expect( + await purge(":is(body){background:white}", ""), + ).toContain("body"); + }); + + it("keeps :is(button, input):not(.x) when a button is present", async () => { + expect( + await purge( + ":is(button, input):not(.some-class){color:red}", + "", + ), + ).toContain("button"); + }); + + it("removes :is(button, input):not(.x) when neither tag is present", async () => { + expect( + await purge( + ":is(button, input):not(.some-class){color:red}", + "
d
", + ), + ).toBe(""); + }); + + it("keeps :where(:not(...):not(...)) combined with a present class", async () => { + const css = + ".is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){margin:1px}"; + expect( + await purge(css, "

x

"), + ).toContain(".is-layout-constrained"); + }); + + it("removes :where(:not(...)) when its class is absent", async () => { + const css = + ".is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){margin:1px}"; + expect(await purge(css, "

x

")).toBe(""); + }); + + it("keeps :where(.x) > :first-child when the class is present", async () => { + expect( + await purge( + ":where(.wp-site-blocks) > :first-child{margin:1px}", + "

x

", + ), + ).toContain(".wp-site-blocks"); + }); + + it("removes :where(.x) > :first-child when the class is absent", async () => { + expect( + await purge( + ":where(.wp-site-blocks) > :first-child{margin:1px}", + "

x

", + ), + ).toBe(""); + }); + + it("keeps a bare :hover rule", async () => { + expect(await purge(":hover{color:red}", "
x
")).toContain( + ":hover", + ); + }); + + it("keeps a bare :not(:hover) rule", async () => { + expect(await purge(":not(:hover){color:red}", "
x
")).toContain( + ":not(:hover)", + ); + }); +}); + describe(":is pseudo class", () => { let purgedCSS: string; beforeAll(async () => { diff --git a/packages/purgecss/src/index.ts b/packages/purgecss/src/index.ts index 8ea76009..f0ab68ac 100644 --- a/packages/purgecss/src/index.ts +++ b/packages/purgecss/src/index.ts @@ -310,33 +310,6 @@ function isInPseudoClassWhereOrIs(selector: selectorParser.Node): boolean { ); } -/** - * Returns true if the selector is a pseudo class at the root level - * Pseudo classes checked: :where, :is, :has, :not - * @param selector - selector - */ -function isPseudoClassAtRootLevel(selector: selectorParser.Node): boolean { - let result = false; - if ( - selector.type === "selector" && - selector.parent?.type === "root" && - selector.nodes.length === 1 - ) { - selector.walk((node) => { - if ( - node.type === "pseudo" && - (node.value === ":where" || - node.value === ":is" || - node.value === ":has" || - node.value === ":not") - ) { - result = true; - } - }); - } - return result; -} - function isPostCSSAtRule(node?: postcss.Node): node is postcss.AtRule { return node?.type === "atrule"; } @@ -906,10 +879,6 @@ class PurgeCSS { return true; } - if (isPseudoClassAtRootLevel(selector)) { - return true; - } - // if there is any greedy safelist pattern, run all the selector parts through them // if there is any match, return true if (this.options.safelist.greedy.length > 0) { @@ -924,8 +893,6 @@ class PurgeCSS { } } - let isPresent = false; - for (const selectorNode of selector.nodes) { const selectorValue = this.getSelectorValue(selectorNode); @@ -941,7 +908,6 @@ class PurgeCSS { (CSS_SAFELIST.includes(selectorValue) || this.isSelectorSafelisted(selectorValue)) ) { - isPresent = true; continue; } @@ -950,6 +916,7 @@ class PurgeCSS { return false; } + let isPresent: boolean; switch (selectorNode.type) { case "attribute": // `value` is a dynamic attribute, highly used in input element @@ -975,17 +942,20 @@ class PurgeCSS { isPresent = isTagFound(selectorNode, selectorsFromExtractor); break; default: + // Pseudo-classes (:where, :is, :not, …) carry no matchable name at + // this level; their inner selectors are evaluated separately. Skip + // them without treating the compound as unused. continue; } - // selector is not safelisted - // and it has not been found as an attribute/class/id/tag + // a matchable part (class/id/tag/attribute) was not found: drop it if (!isPresent) { return false; } } - return isPresent; + // nothing was found missing: keep the selector + return true; } /**