Skip to content

Commit

Permalink
feat(purgecss): add support for :where and :is #978
Browse files Browse the repository at this point in the history
  • Loading branch information
Ffloriel committed Feb 19, 2023
1 parent 7858b7a commit bb5782b
Show file tree
Hide file tree
Showing 7 changed files with 141 additions and 5 deletions.
8 changes: 5 additions & 3 deletions packages/purgecss-from-html/src/index.ts
Expand Up @@ -35,7 +35,7 @@ const mergedExtractorResults = (
};

const getSelectorsInElement = (
element: htmlparser2.Htmlparser2TreeAdapterMap['element']
element: htmlparser2.Htmlparser2TreeAdapterMap["element"]
): ExtractorResultDetailed => {
const result: ExtractorResultDetailed = {
attributes: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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);
Expand Down
34 changes: 34 additions & 0 deletions packages/purgecss/__tests__/pseudo-class.test.ts
Expand Up @@ -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);
});
});
24 changes: 24 additions & 0 deletions 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;
}
@@ -0,0 +1,8 @@
<div class="root">
<div class="a">
<div class="c"></div>
</div>
<div class="b">
<div class="c"></div>
</div>
</div>
24 changes: 24 additions & 0 deletions 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;
}
@@ -0,0 +1,8 @@
<div class="root">
<div class="a">
<div class="c"></div>
</div>
<div class="b">
<div class="c"></div>
</div>
</div>
40 changes: 38 additions & 2 deletions packages/purgecss/src/index.ts
Expand Up @@ -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";
}
Expand Down Expand Up @@ -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") {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit bb5782b

Please sign in to comment.