Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions packages/purgecss/__tests__/pseudo-class.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> => {
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<string> => {
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}", "<h2>x</h2>")).toContain(
"h2",
);
});

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

it("keeps :is(body) when body is present", async () => {
expect(
await purge(":is(body){background:white}", "<body></body>"),
).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}",
"<button>b</button>",
),
).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}",
"<div>d</div>",
),
).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, "<div class='is-layout-constrained'><p>x</p></div>"),
).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, "<div><p>x</p></div>")).toBe("");
});

it("keeps :where(.x) > :first-child when the class is present", async () => {
expect(
await purge(
":where(.wp-site-blocks) > :first-child{margin:1px}",
"<div class='wp-site-blocks'><p>x</p></div>",
),
).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}",
"<div><p>x</p></div>",
),
).toBe("");
});

it("keeps a bare :hover rule", async () => {
expect(await purge(":hover{color:red}", "<div>x</div>")).toContain(
":hover",
);
});

it("keeps a bare :not(:hover) rule", async () => {
expect(await purge(":not(:hover){color:red}", "<div>x</div>")).toContain(
":not(:hover)",
);
});
});

describe(":is pseudo class", () => {
let purgedCSS: string;
beforeAll(async () => {
Expand Down
44 changes: 7 additions & 37 deletions packages/purgecss/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down Expand Up @@ -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) {
Expand All @@ -924,8 +893,6 @@ class PurgeCSS {
}
}

let isPresent = false;

for (const selectorNode of selector.nodes) {
const selectorValue = this.getSelectorValue(selectorNode);

Expand All @@ -941,7 +908,6 @@ class PurgeCSS {
(CSS_SAFELIST.includes(selectorValue) ||
this.isSelectorSafelisted(selectorValue))
) {
isPresent = true;
continue;
}

Expand All @@ -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
Expand All @@ -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;
}

/**
Expand Down