purgecss
Version:
Remove unused css selectors
1,071 lines (1,060 loc) • 37.5 kB
JavaScript
;
var fs = require('fs');
var glob = require('fast-glob');
var path = require('path');
var postcss = require('postcss');
var selectorParser = require('postcss-selector-parser');
var util = require('util');
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
var glob__namespace = /*#__PURE__*/_interopNamespaceDefault(glob);
var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
var postcss__namespace = /*#__PURE__*/_interopNamespaceDefault(postcss);
const IGNORE_ANNOTATION_CURRENT = "purgecss ignore current";
const IGNORE_ANNOTATION_NEXT = "purgecss ignore";
const IGNORE_ANNOTATION_START = "purgecss start ignore";
const IGNORE_ANNOTATION_END = "purgecss end ignore";
const CONFIG_FILENAME = "purgecss.config.js";
// Error Message
const ERROR_CONFIG_FILE_LOADING = "Error loading the config file";
function mergeSets(into, from) {
if (from) {
from.forEach(into.add, into);
}
}
/**
* @public
*/
class ExtractorResultSets {
constructor(er) {
this.undetermined = new Set();
this.attrNames = new Set();
this.attrValues = new Set();
this.classes = new Set();
this.ids = new Set();
this.tags = new Set();
this.merge(er);
}
merge(that) {
if (Array.isArray(that)) {
mergeSets(this.undetermined, that);
}
else if (that instanceof ExtractorResultSets) {
mergeSets(this.undetermined, that.undetermined);
mergeSets(this.attrNames, that.attrNames);
mergeSets(this.attrValues, that.attrValues);
mergeSets(this.classes, that.classes);
mergeSets(this.ids, that.ids);
mergeSets(this.tags, that.tags);
}
else {
// ExtractorResultDetailed:
mergeSets(this.undetermined, that.undetermined);
if (that.attributes) {
mergeSets(this.attrNames, that.attributes.names);
mergeSets(this.attrValues, that.attributes.values);
}
mergeSets(this.classes, that.classes);
mergeSets(this.ids, that.ids);
mergeSets(this.tags, that.tags);
}
return this;
}
hasAttrName(name) {
return this.attrNames.has(name) || this.undetermined.has(name);
}
someAttrValue(predicate) {
for (const val of this.attrValues) {
if (predicate(val))
return true;
}
for (const val of this.undetermined) {
if (predicate(val))
return true;
}
return false;
}
hasAttrPrefix(prefix) {
return this.someAttrValue((value) => value.startsWith(prefix));
}
hasAttrSuffix(suffix) {
return this.someAttrValue((value) => value.endsWith(suffix));
}
hasAttrSubstr(substr) {
const wordSubstr = substr.trim().split(" ");
return wordSubstr.every((word) => this.someAttrValue((value) => value.includes(word)));
}
hasAttrValue(value) {
return this.attrValues.has(value) || this.undetermined.has(value);
}
hasClass(name) {
return this.classes.has(name) || this.undetermined.has(name);
}
hasId(id) {
return this.ids.has(id) || this.undetermined.has(id);
}
hasTag(tag) {
return this.tags.has(tag) || this.undetermined.has(tag);
}
}
const CSS_SAFELIST = ["*", ":root", ":after", ":before"];
/**
* @public
*/
const defaultOptions = {
css: [],
content: [],
defaultExtractor: (content) => content.match(/[A-Za-z0-9_-]+/g) || [],
extractors: [],
fontFace: false,
keyframes: false,
rejected: false,
rejectedCss: false,
sourceMap: false,
stdin: false,
stdout: false,
variables: false,
safelist: {
standard: [],
deep: [],
greedy: [],
variables: [],
keyframes: [],
},
blocklist: [],
skippedContentGlobs: [],
dynamicAttributes: [],
};
/**
* @public
*/
class VariableNode {
constructor(declaration) {
this.nodes = [];
this.isUsed = false;
this.value = declaration;
}
}
/**
* @public
*/
class VariablesStructure {
constructor() {
this.nodes = new Map();
this.usedVariables = new Set();
this.safelist = [];
}
addVariable(declaration) {
const { prop } = declaration;
if (!this.nodes.has(prop)) {
const node = new VariableNode(declaration);
this.nodes.set(prop, [node]);
}
else {
const node = new VariableNode(declaration);
const variableNodes = this.nodes.get(prop) || [];
this.nodes.set(prop, [...variableNodes, node]);
}
}
addVariableUsage(declaration, matchedVariables) {
const { prop } = declaration;
const nodes = this.nodes.get(prop);
for (const variableMatch of matchedVariables) {
// capturing group containing the variable is in index 1
const variableName = variableMatch[1];
if (this.nodes.has(variableName)) {
const usedVariableNodes = this.nodes.get(variableName);
nodes === null || nodes === void 0 ? void 0 : nodes.forEach((node) => {
usedVariableNodes === null || usedVariableNodes === void 0 ? void 0 : usedVariableNodes.forEach((usedVariableNode) => node.nodes.push(usedVariableNode));
});
}
}
}
addVariableUsageInProperties(matchedVariables) {
for (const variableMatch of matchedVariables) {
// capturing group containing the variable is in index 1
const variableName = variableMatch[1];
this.usedVariables.add(variableName);
}
}
setAsUsed(variableName) {
const nodes = this.nodes.get(variableName);
if (nodes) {
const queue = [...nodes];
while (queue.length !== 0) {
const currentNode = queue.pop();
if (currentNode && !currentNode.isUsed) {
currentNode.isUsed = true;
queue.push(...currentNode.nodes);
}
}
}
}
removeUnused() {
// check unordered usage
for (const used of this.usedVariables) {
const usedNodes = this.nodes.get(used);
if (usedNodes) {
for (const usedNode of usedNodes) {
const usedVariablesMatchesInDeclaration = usedNode.value.value.matchAll(/var\((.+?)[,)]/g);
for (const usage of usedVariablesMatchesInDeclaration) {
if (!this.usedVariables.has(usage[1])) {
this.usedVariables.add(usage[1]);
}
}
}
}
}
for (const used of this.usedVariables) {
this.setAsUsed(used);
}
for (const [name, declarations] of this.nodes) {
for (const declaration of declarations) {
if (!declaration.isUsed && !this.isVariablesSafelisted(name)) {
declaration.value.remove();
}
}
}
}
isVariablesSafelisted(variable) {
return this.safelist.some((safelistItem) => {
return typeof safelistItem === "string"
? safelistItem === variable
: safelistItem.test(variable);
});
}
}
/**
* Core package of PurgeCSS
*
* Contains the core methods to analyze the files, remove unused CSS.
*
* @packageDocumentation
*/
const asyncFs = {
access: util.promisify(fs__namespace.access),
readFile: util.promisify(fs__namespace.readFile),
};
/**
* Format the user defined safelist into a standardized safelist object
*
* @param userDefinedSafelist - the user defined safelist
* @returns the formatted safelist object that can be used in the PurgeCSS options
*
* @public
*/
function standardizeSafelist(userDefinedSafelist = []) {
if (Array.isArray(userDefinedSafelist)) {
return {
...defaultOptions.safelist,
standard: userDefinedSafelist,
};
}
return {
...defaultOptions.safelist,
...userDefinedSafelist,
};
}
/**
* Load the configuration file from the path
*
* @param configFile - Path of the config file
* @returns The options from the configuration file
*
* @throws Error
* This exception is thrown if the configuration file was not imported
*
* @public
*/
async function setOptions(configFile = CONFIG_FILENAME) {
let options;
try {
const t = path__namespace.resolve(process.cwd(), configFile);
const importedConfig = await import(t);
// Handle both ES modules (direct export) and CommonJS (default export)
options =
importedConfig.default && typeof importedConfig.default === "object"
? importedConfig.default
: importedConfig;
}
catch (err) {
if (err instanceof Error) {
throw new Error(`${ERROR_CONFIG_FILE_LOADING} ${err.message}`);
}
throw new Error();
}
return {
...defaultOptions,
...options,
safelist: standardizeSafelist(options.safelist),
};
}
/**
* Use the extract function to get the list of selectors
*
* @param content - content (e.g. html file)
* @param extractor - PurgeCSS extractor used to extract the selectors
* @returns the sets containing the result of the extractor function
*/
async function extractSelectors(content, extractor) {
return new ExtractorResultSets(await extractor(content));
}
/**
* Check if the node is a css comment indication to ignore the selector rule
*
* @param node - node of postcss AST
* @param type - type of css comment
* @returns true if the node is a PurgeCSS ignore comment
*/
function isIgnoreAnnotation(node, type) {
switch (type) {
case "next":
return node.text.includes(IGNORE_ANNOTATION_NEXT);
case "start":
return node.text.includes(IGNORE_ANNOTATION_START);
case "end":
return node.text.includes(IGNORE_ANNOTATION_END);
}
}
/**
* Check if the node correspond to an empty css rule
*
* @param node - node of postcss AST
* @returns true if the rule is empty
*/
function isRuleEmpty(node) {
if ((isPostCSSRule(node) && !node.selector) ||
((node === null || node === void 0 ? void 0 : node.nodes) && !node.nodes.length) ||
(isPostCSSAtRule(node) &&
((!node.nodes && !node.params) ||
(!node.params && node.nodes && !node.nodes.length)))) {
return true;
}
return false;
}
/**
* Check if the node has a css comment indicating to ignore the current selector rule
*
* @param rule - rule of postcss AST
*/
function hasIgnoreAnnotation(rule) {
let found = false;
rule.walkComments((node) => {
if (node &&
node.type === "comment" &&
node.text.includes(IGNORE_ANNOTATION_CURRENT)) {
found = true;
node.remove();
}
});
return found;
}
/**
* Merge two extractor selectors
*
* @param extractorSelectorsA - extractor selectors A
* @param extractorSelectorsB - extractor selectors B
* @returns the merged extractor result sets
*
* @public
*/
function mergeExtractorSelectors(...extractors) {
const result = new ExtractorResultSets([]);
extractors.forEach(result.merge, result);
return result;
}
/**
* Strips quotes of a string
*
* @param str - string to be stripped
*/
function stripQuotes(str) {
return str.replace(/(^["'])|(["']$)/g, "");
}
/**
* Returns true if the attribute is found in the extractor selectors
*
* @param attributeNode - node of type `attribute`
* @param selectors - extractor selectors
*/
function isAttributeFound(attributeNode, selectors) {
if (!selectors.hasAttrName(attributeNode.attribute)) {
return false;
}
if (typeof attributeNode.value === "undefined") {
return true;
}
switch (attributeNode.operator) {
case "$=":
return selectors.hasAttrSuffix(attributeNode.value);
case "~=":
case "*=":
return selectors.hasAttrSubstr(attributeNode.value);
case "=":
return selectors.hasAttrValue(attributeNode.value);
case "|=":
case "^=":
return selectors.hasAttrPrefix(attributeNode.value);
default:
return true;
}
}
/**
* Returns true if the class is found in the extractor selectors
*
* @param classNode - node of type `class`
* @param selectors - extractor selectors
*/
function isClassFound(classNode, selectors) {
return selectors.hasClass(classNode.value);
}
/**
* Returns true if the identifier is found in the extractor selectors
*
* @param identifierNode - node of type `identifier`
* @param selectors - extractor selectors
*/
function isIdentifierFound(identifierNode, selectors) {
return selectors.hasId(identifierNode.value);
}
/**
* Returns true if the tag is found in the extractor selectors
*
* @param tagNode - node of type `tag`
* @param selectors - extractor selectors
*/
function isTagFound(tagNode, selectors) {
return selectors.hasTag(tagNode.value);
}
/**
* Returns true if the selector is inside a pseudo class
* (e.g. :nth-child, :nth-of-type, :only-child, :not)
*
* @param selector - selector
*/
function isInPseudoClass(selector) {
return ((selector.parent &&
selector.parent.type === "pseudo" &&
selector.parent.value.startsWith(":")) ||
false);
}
/**
* Returns true if the selector is inside the pseudo classes :where() or :is()
* @param selector - selector
*/
function isInPseudoClassWhereOrIs(selector) {
return ((selector.parent &&
selector.parent.type === "pseudo" &&
(selector.parent.value === ":where" ||
selector.parent.value === ":is")) ||
false);
}
/**
* 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) {
var _a;
let result = false;
if (selector.type === "selector" &&
((_a = selector.parent) === null || _a === void 0 ? void 0 : _a.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) {
return (node === null || node === void 0 ? void 0 : node.type) === "atrule";
}
function isPostCSSRule(node) {
return (node === null || node === void 0 ? void 0 : node.type) === "rule";
}
function isPostCSSComment(node) {
return (node === null || node === void 0 ? void 0 : node.type) === "comment";
}
/**
* Class used to instantiate PurgeCSS and can then be used
* to purge CSS files.
*
* @example
* ```ts
* await new PurgeCSS().purge({
* content: ['index.html'],
* css: ['css/app.css']
* })
* ```
*
* @public
*/
class PurgeCSS {
constructor() {
this.ignore = false;
this.atRules = {
fontFace: [],
keyframes: [],
};
this.usedAnimations = new Set();
this.usedFontFaces = new Set();
this.selectorsRemoved = new Set();
this.removedNodes = [];
this.variablesStructure = new VariablesStructure();
this.options = defaultOptions;
}
collectDeclarationsData(declaration) {
const { prop, value } = declaration;
// collect css properties data
if (this.options.variables) {
const usedVariablesMatchesInDeclaration = value.matchAll(/var\((.+?)[,)]/g);
if (prop.startsWith("--")) {
this.variablesStructure.addVariable(declaration);
this.variablesStructure.addVariableUsage(declaration, usedVariablesMatchesInDeclaration);
}
else {
this.variablesStructure.addVariableUsageInProperties(usedVariablesMatchesInDeclaration);
}
}
// collect keyframes data
if (this.options.keyframes) {
if (prop === "animation" || prop === "animation-name") {
for (const word of value.split(/[\s,]+/)) {
this.usedAnimations.add(word);
}
return;
}
// Also check CSS custom properties for animation names
// e.g., --my-animation: fadeIn 0.4s;
if (prop.startsWith("--")) {
for (const word of value.split(/[\s,]+/)) {
this.usedAnimations.add(word);
}
}
}
// collect font faces data
if (this.options.fontFace) {
if (prop === "font-family") {
for (const fontName of value.split(",")) {
const cleanedFontFace = stripQuotes(fontName.trim());
this.usedFontFaces.add(cleanedFontFace);
}
}
return;
}
}
/**
* Get the extractor corresponding to the extension file
* @param filename - Name of the file
* @param extractors - Array of extractors definition
*/
getFileExtractor(filename, extractors) {
const extractorObj = extractors.find((extractor) => extractor.extensions.find((ext) => filename.endsWith(ext)));
return typeof extractorObj === "undefined"
? this.options.defaultExtractor
: extractorObj.extractor;
}
/**
* Extract the selectors present in the files using a PurgeCSS extractor
*
* @param files - Array of files path or glob pattern
* @param extractors - Array of extractors
*/
async extractSelectorsFromFiles(files, extractors) {
const selectors = new ExtractorResultSets([]);
const filesNames = [];
for (const globFile of files) {
try {
await asyncFs.access(globFile, fs__namespace.constants.F_OK);
filesNames.push(globFile);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
}
catch (err) {
filesNames.push(...glob__namespace.sync(globFile, {
onlyFiles: true,
ignore: this.options.skippedContentGlobs.map((glob) => glob.replace(/^\.\//, "")),
}));
}
}
if (files.length > 0 && filesNames.length === 0) {
console.warn("No files found from the passed PurgeCSS option 'content'.");
}
for (const file of filesNames) {
const content = await asyncFs.readFile(file, "utf-8");
const extractor = this.getFileExtractor(file, extractors);
const extractedSelectors = await extractSelectors(content, extractor);
selectors.merge(extractedSelectors);
}
return selectors;
}
/**
* Extract the selectors present in the passed string using a PurgeCSS extractor
*
* @param content - Array of content
* @param extractors - Array of extractors
*/
async extractSelectorsFromString(content, extractors) {
const selectors = new ExtractorResultSets([]);
for (const { raw, extension } of content) {
const extractor = this.getFileExtractor(`.${extension}`, extractors);
const extractedSelectors = await extractSelectors(raw, extractor);
selectors.merge(extractedSelectors);
}
return selectors;
}
/**
* Evaluate at-rule and register it for future reference
* @param node - node of postcss AST
*/
evaluateAtRule(node) {
// keyframes
if (this.options.keyframes && node.name.endsWith("keyframes")) {
this.atRules.keyframes.push(node);
return;
}
// font-face
if (this.options.fontFace && node.name === "font-face" && node.nodes) {
for (const childNode of node.nodes) {
if (childNode.type === "decl" && childNode.prop === "font-family") {
this.atRules.fontFace.push({
name: stripQuotes(childNode.value),
node,
});
}
}
}
}
/**
* Evaluate css selector and decide if it should be removed or not
*
* @param node - node of postcss AST
* @param selectors - selectors used in content files
*/
evaluateRule(node, selectors) {
// exit if is in ignoring state activated by an ignore comment
if (this.ignore) {
return;
}
// exit if the previous annotation is a ignore next line comment
const annotation = node.prev();
if (isPostCSSComment(annotation) &&
isIgnoreAnnotation(annotation, "next")) {
annotation.remove();
return;
}
// exit if it is inside a keyframes
if (node.parent &&
isPostCSSAtRule(node.parent) &&
node.parent.name.endsWith("keyframes")) {
return;
}
// exit if it is not a rule
if (!isPostCSSRule(node)) {
return;
}
// exit if it has an ignore rule comment inside
if (hasIgnoreAnnotation(node)) {
return;
}
const selectorsRemovedFromRule = [];
// 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") {
return;
}
const keepSelector = this.shouldKeepSelector(selector, selectors);
if (!keepSelector) {
if (this.options.rejected) {
this.selectorsRemoved.add(selector.toString());
}
if (this.options.rejectedCss) {
selectorsRemovedFromRule.push(selector.toString());
}
selector.remove();
}
});
// removes selectors containing empty :where and :is
selectorsParsed.walk((selector) => {
if (selector.type !== "selector") {
return;
}
if (selector.toString() && /(:where)|(:is)/.test(selector.toString())) {
selector.walk((node) => {
if (node.type !== "pseudo")
return;
if (node.value !== ":where" && node.value !== ":is")
return;
if (node.nodes.length === 0) {
selector.remove();
}
});
}
});
}).processSync(node.selector);
// declarations
if (node.selector && typeof node.nodes !== "undefined") {
for (const childNode of node.nodes) {
if (childNode.type !== "decl")
continue;
this.collectDeclarationsData(childNode);
}
}
// remove empty rules
const parent = node.parent;
if (!node.selector) {
node.remove();
}
if (isRuleEmpty(parent))
parent === null || parent === void 0 ? void 0 : parent.remove();
// rebuild the rule with the removed selectors and optionally its parent
if (this.options.rejectedCss) {
if (selectorsRemovedFromRule.length > 0) {
const clone = node.clone();
const parentClone = parent === null || parent === void 0 ? void 0 : parent.clone().removeAll().append(clone);
clone.selectors = selectorsRemovedFromRule;
const nodeToPreserve = parentClone ? parentClone : clone;
this.removedNodes.push(nodeToPreserve);
}
}
}
/**
* Get the purged version of the css based on the files
*
* @param cssOptions - css options, files or raw strings
* @param selectors - set of extracted css selectors
*/
async getPurgedCSS(cssOptions, selectors) {
var _a;
const sources = [];
// resolve any globs
const processedOptions = [];
for (const option of cssOptions) {
if (typeof option === "string") {
processedOptions.push(...glob__namespace.sync(option, {
onlyFiles: true,
ignore: this.options.skippedContentGlobs,
}));
}
else {
processedOptions.push(option);
}
}
for (const option of processedOptions) {
const cssContent = typeof option === "string"
? this.options.stdin
? option
: await asyncFs.readFile(option, "utf-8")
: option.raw;
const isFromFile = typeof option === "string" && !this.options.stdin;
const root = postcss__namespace.parse(cssContent, {
from: isFromFile ? option : undefined,
});
// purge unused selectors
this.walkThroughCSS(root, selectors);
if (this.options.fontFace)
this.removeUnusedFontFaces();
if (this.options.keyframes)
this.removeUnusedKeyframes();
if (this.options.variables)
this.removeUnusedCSSVariables();
const postCSSResult = root.toResult({
map: this.options.sourceMap,
to: typeof this.options.sourceMap === "object" &&
this.options.sourceMap.to
? this.options.sourceMap.to
: isFromFile
? option
: undefined,
});
const result = {
css: postCSSResult.toString(),
file: typeof option === "string" ? option : option.name,
};
if (this.options.sourceMap) {
result.sourceMap = (_a = postCSSResult.map) === null || _a === void 0 ? void 0 : _a.toString();
}
if (this.options.rejected) {
result.rejected = Array.from(this.selectorsRemoved);
this.selectorsRemoved.clear();
}
if (this.options.rejectedCss) {
result.rejectedCss = postcss__namespace
.root({ nodes: this.removedNodes })
.toString();
}
sources.push(result);
}
return sources;
}
/**
* Check if the keyframe is safelisted with the option safelist keyframes
*
* @param keyframesName - name of the keyframe animation
*/
isKeyframesSafelisted(keyframesName) {
return this.options.safelist.keyframes.some((safelistItem) => {
return typeof safelistItem === "string"
? safelistItem === keyframesName
: safelistItem.test(keyframesName);
});
}
/**
* Check if the selector is blocklisted with the option blocklist
*
* @param selector - css selector
*/
isSelectorBlocklisted(selector) {
return this.options.blocklist.some((blocklistItem) => {
return typeof blocklistItem === "string"
? blocklistItem === selector
: blocklistItem.test(selector);
});
}
/**
* Check if the selector is safelisted with the option safelist standard
*
* @param selector - css selector
*/
isSelectorSafelisted(selector) {
const isSafelisted = this.options.safelist.standard.some((safelistItem) => {
return typeof safelistItem === "string"
? safelistItem === selector
: safelistItem.test(selector);
});
const isPseudoElement = /^::.*/.test(selector);
return CSS_SAFELIST.includes(selector) || isPseudoElement || isSafelisted;
}
/**
* Check if the selector is safelisted with the option safelist deep
*
* @param selector - selector
*/
isSelectorSafelistedDeep(selector) {
return this.options.safelist.deep.some((safelistItem) => safelistItem.test(selector));
}
/**
* Check if the selector is safelisted with the option safelist greedy
*
* @param selector - selector
*/
isSelectorSafelistedGreedy(selector) {
return this.options.safelist.greedy.some((safelistItem) => safelistItem.test(selector));
}
/**
* Remove unused CSS
*
* @param userOptions - PurgeCSS options or path to the configuration file
* @returns an array of object containing the filename and the associated CSS
*
* @example Using a configuration file named purgecss.config.js
* ```ts
* const purgeCSSResults = await new PurgeCSS().purge()
* ```
*
* @example Using a custom path to the configuration file
* ```ts
* const purgeCSSResults = await new PurgeCSS().purge('./purgecss.config.js')
* ```
*
* @example Using the PurgeCSS options
* ```ts
* const purgeCSSResults = await new PurgeCSS().purge({
* content: ['index.html', '**\/*.js', '**\/*.html', '**\/*.vue'],
* css: ['css/app.css']
* })
* ```
*/
async purge(userOptions) {
this.options =
typeof userOptions !== "object"
? await setOptions(userOptions)
: {
...defaultOptions,
...userOptions,
safelist: standardizeSafelist(userOptions.safelist),
};
const { content, css, extractors, safelist } = this.options;
if (this.options.variables) {
this.variablesStructure.safelist = safelist.variables || [];
}
const fileFormatContents = content.filter((o) => typeof o === "string");
const rawFormatContents = content.filter((o) => typeof o === "object");
const cssFileSelectors = await this.extractSelectorsFromFiles(fileFormatContents, extractors);
const cssRawSelectors = await this.extractSelectorsFromString(rawFormatContents, extractors);
return this.getPurgedCSS(css, mergeExtractorSelectors(cssFileSelectors, cssRawSelectors));
}
/**
* Remove unused CSS variables
*/
removeUnusedCSSVariables() {
this.variablesStructure.removeUnused();
}
/**
* Remove unused font-faces
*/
removeUnusedFontFaces() {
for (const { name, node } of this.atRules.fontFace) {
if (!this.usedFontFaces.has(name)) {
node.remove();
}
}
}
/**
* Remove unused keyframes
*/
removeUnusedKeyframes() {
for (const node of this.atRules.keyframes) {
if (!this.usedAnimations.has(node.params) &&
!this.isKeyframesSafelisted(node.params)) {
node.remove();
}
}
}
/**
* Transform a selector node into a string
*/
getSelectorValue(selector) {
return ((selector.type === "attribute" && selector.attribute) || selector.value);
}
/**
* Determine if the selector should be kept, based on the selectors found in the files
*
* @param selector - set of css selectors found in the content files or string
* @param selectorsFromExtractor - selectors in the css rule
*
* @returns true if the selector should be kept in the processed CSS
*/
shouldKeepSelector(selector, selectorsFromExtractor) {
// 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 (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) {
const selectorParts = selector.nodes.map(this.getSelectorValue);
if (selectorParts.some((selectorPart) => selectorPart && this.isSelectorSafelistedGreedy(selectorPart))) {
return true;
}
}
let isPresent = false;
for (const selectorNode of selector.nodes) {
const selectorValue = this.getSelectorValue(selectorNode);
// if the selector is safelisted with children
// returns true to keep all children selectors
if (selectorValue && this.isSelectorSafelistedDeep(selectorValue)) {
return true;
}
// The selector is found in the internal and user-defined safelist
if (selectorValue &&
(CSS_SAFELIST.includes(selectorValue) ||
this.isSelectorSafelisted(selectorValue))) {
isPresent = true;
continue;
}
// The selector is present in the blocklist
if (selectorValue && this.isSelectorBlocklisted(selectorValue)) {
return false;
}
switch (selectorNode.type) {
case "attribute":
// `value` is a dynamic attribute, highly used in input element
// the choice is to always leave `value` as it can change based on the user
// idem for `checked`, `selected`, `open`
isPresent = [
...this.options.dynamicAttributes,
"value",
"checked",
"selected",
"open",
].includes(selectorNode.attribute)
? true
: isAttributeFound(selectorNode, selectorsFromExtractor);
break;
case "class":
isPresent = isClassFound(selectorNode, selectorsFromExtractor);
break;
case "id":
isPresent = isIdentifierFound(selectorNode, selectorsFromExtractor);
break;
case "tag":
isPresent = isTagFound(selectorNode, selectorsFromExtractor);
break;
default:
continue;
}
// selector is not safelisted
// and it has not been found as an attribute/class/id/tag
if (!isPresent) {
return false;
}
}
return isPresent;
}
/**
* Walk through the CSS AST and remove unused CSS
*
* @param root - root node of the postcss AST
* @param selectors - selectors used in content files
*/
walkThroughCSS(root, selectors) {
root.walk((node) => {
if (node.type === "rule") {
return this.evaluateRule(node, selectors);
}
if (node.type === "atrule") {
return this.evaluateAtRule(node);
}
if (node.type === "comment") {
if (isIgnoreAnnotation(node, "start")) {
this.ignore = true;
// remove ignore annotation
node.remove();
}
else if (isIgnoreAnnotation(node, "end")) {
this.ignore = false;
// remove ignore annotation
node.remove();
}
}
});
}
}
exports.ExtractorResultSets = ExtractorResultSets;
exports.PurgeCSS = PurgeCSS;
exports.VariableNode = VariableNode;
exports.VariablesStructure = VariablesStructure;
exports.defaultOptions = defaultOptions;
exports.mergeExtractorSelectors = mergeExtractorSelectors;
exports.setOptions = setOptions;
exports.standardizeSafelist = standardizeSafelist;