vite-plugin-tailwind-purgecss
Version:
Vite plugin for PurgeCSS
358 lines (354 loc) • 12.9 kB
JavaScript
// src/index.ts
import fs2 from "fs";
import path3 from "path";
import fg from "fast-glob";
import color2 from "chalk";
import * as css from "css-tree";
import htmlExtractor from "purgecss-from-html";
import { normalizePath } from "vite";
import { PurgeCSS, mergeExtractorSelectors, standardizeSafelist } from "purgecss";
// src/tailwind.ts
import path from "path";
import fs from "fs";
import loadConfig from "tailwindcss/loadConfig.js";
import resolveConfig from "tailwindcss/resolveConfig.js";
import { defaultExtractor as createDefaultExtractor } from "tailwindcss/lib/lib/defaultExtractor.js";
import ctxPkg from "tailwindcss/lib/lib/setupContextUtils.js";
var defaultConfigFiles = [
"tailwind.config.js",
"tailwind.config.cjs",
"tailwind.config.mjs",
"tailwind.config.ts"
];
function resolveDefaultConfigPath() {
for (const configFile of defaultConfigFiles) {
try {
const configPath = path.resolve(configFile);
fs.accessSync(configPath);
return configPath;
} catch (err) {
}
}
return null;
}
function resolveTailwindConfigPath(configPath) {
if (configPath === void 0) {
return resolveDefaultConfigPath();
}
try {
const resolvedPath = path.resolve(configPath);
fs.accessSync(resolvedPath);
return resolvedPath;
} catch (err) {
}
return null;
}
function resolveTailwindConfig(configPath) {
const resolvedConfigPath = resolveTailwindConfigPath(configPath);
if (resolvedConfigPath === null)
throw new Error(
"[vite-plugin-tailwind-purgecss]: Unable to find a tailwind config. Specify a path to the tailwind config in the plugin option `tailwindConfigPath`."
);
const loadedConfig = loadConfig(resolvedConfigPath);
const config = resolveConfig(loadedConfig);
return config;
}
var { createContext: createTWContext } = ctxPkg;
function getTailwindClasses(config) {
const tailwindClasses = /* @__PURE__ */ new Set();
const ctx = createTWContext(config);
const classes = ctx.getClassList();
for (const className of classes) {
tailwindClasses.add(className);
}
return {
classes: tailwindClasses,
isClass: (selector) => {
const parts = selector.replaceAll("!", "").split(config.separator);
const className = parts.at(-1);
return tailwindClasses.has(className) || isArbitrary(className) || isColorOpacity(className);
}
};
}
var ARBITRARY_CLASS_REGEX = /-\[.+\]$/i;
function isArbitrary(selector) {
return ARBITRARY_CLASS_REGEX.test(selector);
}
var OPACITY_COLOR_CLASS_REGEX = /\/(\[.+\]|\d+)$/i;
function isColorOpacity(selector) {
return OPACITY_COLOR_CLASS_REGEX.test(selector);
}
var defaultExtractor = (tailwindConfig) => createDefaultExtractor({ tailwindConfig });
function getContentPaths(config) {
if (Array.isArray(config)) {
return config.filter((p) => typeof p === "string");
}
return config.files.filter((p) => typeof p === "string");
}
function standardizeTWSafelist(tailwindConfig) {
var _a;
return ((_a = tailwindConfig.safelist) == null ? void 0 : _a.flatMap((item) => {
if (typeof item === "string") {
return item;
}
const { pattern, variants } = item;
if (variants === void 0)
return pattern;
const flags = pattern.flags;
const stringifiedPattern = pattern.toString().slice(0, flags.length * -1).slice(1, -1);
const patterns = variants.map(
(v) => new RegExp(v + tailwindConfig.separator + stringifiedPattern, flags)
);
return patterns;
})) ?? [];
}
// src/logger.ts
import color from "chalk";
import path2 from "path";
function createLogger(viteConfig) {
const PREFIX = color.cyan("[vite-plugin-tailwind-purgecss]: ");
const logger = {
info: (msg) => viteConfig.logger.info(PREFIX + msg),
warn: (msg) => viteConfig.logger.warn(PREFIX + color.yellow(msg)),
error: (msg) => viteConfig.logger.error(PREFIX + color.red(msg)),
clear: () => viteConfig.logger.clearScreen("info"),
colorFile: (filepath) => {
const fp = path2.parse(filepath);
const colored = color.gray(fp.dir + "/") + fp.base;
return colored;
}
};
return logger;
}
// src/purgecss-options.ts
var getDefaultPurgeOptions = () => ({
css: [],
content: [],
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: []
});
// src/index.ts
var EXT_CSS = /\.(css)$/;
var contentFiles = /* @__PURE__ */ new Set();
var htmlFiles = [];
function purgeCss(purgeOptions) {
var _a, _b;
const DEBUG = (purgeOptions == null ? void 0 : purgeOptions.debug) ?? false;
const LEGACY = (purgeOptions == null ? void 0 : purgeOptions.legacy) ?? false;
let log;
let viteConfig;
const tailwindConfig = resolveTailwindConfig(purgeOptions == null ? void 0 : purgeOptions.tailwindConfigPath);
const tw = getTailwindClasses(tailwindConfig);
const extractor = ((_a = purgeOptions == null ? void 0 : purgeOptions.purgecss) == null ? void 0 : _a.defaultExtractor) ?? defaultExtractor(tailwindConfig);
const twSafelist = standardizeTWSafelist(tailwindConfig);
const safelist = {
...purgeOptions == null ? void 0 : purgeOptions.safelist,
standard: [
/^\:[-a-z]+$/,
...((_b = purgeOptions == null ? void 0 : purgeOptions.safelist) == null ? void 0 : _b.standard) ?? [],
...twSafelist
]
};
const moduleIds = /* @__PURE__ */ new Set();
return {
name: "vite-plugin-tailwind-purgecss",
apply: "build",
enforce: "post",
configResolved(config) {
viteConfig = config;
log = createLogger(viteConfig);
if (contentFiles.size === 0) {
const contentGlobs = getContentPaths(tailwindConfig.content).map((p) => normalizePath(p));
for (const file of fg.globSync(contentGlobs, { cwd: viteConfig.root, absolute: true })) {
if (file.endsWith(".html"))
htmlFiles.push(file);
contentFiles.add(file);
}
}
},
load(id) {
if (!contentFiles.has(id))
return;
moduleIds.add(id);
},
async generateBundle(options, bundle) {
var _a2;
const includedModules = [];
const includedAssets = [];
const extensions = /* @__PURE__ */ new Set();
const savedTWClasses = /* @__PURE__ */ new Set();
const generatedTWClasses = /* @__PURE__ */ new Set();
log.clear();
if (DEBUG)
log.info(`${color2.greenBright("DEBUG mode activated")}.`);
if (LEGACY) {
log.info(`${color2.yellowBright("LEGACY mode activated")}. Purging all unused CSS...`);
} else {
log.info("Purging unused tailwindcss styles...");
}
const purgecss = new PurgeCSS();
purgecss.options = {
...getDefaultPurgeOptions(),
defaultExtractor: extractor,
safelist: standardizeSafelist(safelist),
rejected: DEBUG,
rejectedCss: DEBUG
};
const baseSelectors = {
attributes: { names: [], values: [] },
classes: [],
ids: [],
tags: [],
undetermined: []
};
for (const [filename, chunkOrAsset] of Object.entries(bundle)) {
if (chunkOrAsset.type === "asset" && EXT_CSS.test(filename)) {
const source = String(chunkOrAsset.source);
includedAssets.push({ raw: source, name: filename });
if (LEGACY)
continue;
const ast = css.parse(source);
css.walk(ast, {
enter(node) {
if (node.type === "AttributeSelector") {
baseSelectors.attributes.names.push(node.name.name);
if (node.value === null)
return;
if (node.value.type === "Identifier") {
baseSelectors.attributes.values.push(node.value.name);
} else if (node.value.type === "String") {
baseSelectors.attributes.values.push(node.value.value);
}
}
if (node.type === "IdSelector") {
baseSelectors.ids.push(node.name);
}
if (node.type === "TypeSelector") {
baseSelectors.tags.push(node.name);
}
if (node.type === "ClassSelector") {
const escapedCN = unescapeCSS(node.name);
baseSelectors.classes.push(escapedCN);
if (tw.isClass(escapedCN)) {
generatedTWClasses.add(escapedCN);
}
}
}
});
}
}
for (const id of moduleIds) {
const info = this.getModuleInfo(id);
if ((info == null ? void 0 : info.isIncluded) !== true || info.code === null)
continue;
const source = fs2.readFileSync(id, { encoding: "utf8" });
includedModules.push({ raw: source, extension: "tw" }, { raw: info.code, extension: "tw" });
if (LEGACY) {
const extension = path3.parse(id).ext.slice(1);
extensions.add(extension);
includedModules.push({ raw: source, extension });
}
}
const possibleSelectors = /* @__PURE__ */ new Set();
for (const mod of includedModules) {
if (mod.extension !== "tw")
continue;
for (const selector of extractor(mod.raw)) {
if (generatedTWClasses.delete(selector)) {
savedTWClasses.add(selector);
} else {
possibleSelectors.add(selector);
}
}
}
const htmlSelectors = await purgecss.extractSelectorsFromFiles(htmlFiles, [
{ extractor: htmlExtractor, extensions: ["html"] }
]);
htmlSelectors.classes.forEach((cn) => generatedTWClasses.delete(cn));
purgecss.options.blocklist.push(...generatedTWClasses, ...tailwindConfig.blocklist ?? []);
if (LEGACY)
purgecss.options.safelist.standard.push(...possibleSelectors);
extensions.delete("tw");
const moduleSelectors = await purgecss.extractSelectorsFromString(includedModules, [
{ extractor, extensions: Array.from(extensions) },
...((_a2 = purgeOptions == null ? void 0 : purgeOptions.purgecss) == null ? void 0 : _a2.extractors) ?? []
]);
const mergedSelectors = mergeExtractorSelectors(
htmlSelectors,
moduleSelectors,
baseSelectors
);
const purgeResults = await purgecss.getPurgedCSS(includedAssets, mergedSelectors);
if (DEBUG) {
console.dir(
{
possible_selectors: mergedSelectors,
tailwind_classes_to_remove: generatedTWClasses,
tailwind_classes_to_keep: savedTWClasses,
purgecss_results: purgeResults
},
{ maxArrayLength: Infinity, maxStringLength: Infinity, depth: Infinity }
);
}
const stats = [];
for (const result of purgeResults) {
const filename = result.file;
const asset = bundle[filename];
const originalFileSize = new Blob([asset.source]).size / 1e3;
const finalFileSize = new Blob([result.css]).size / 1e3;
const stat = {
filename: log.colorFile(filename),
original: originalFileSize.toFixed(2),
final: finalFileSize.toFixed(2)
};
stats.push(stat);
asset.source = result.css;
}
log.info(`Calculating bundle size savings: ${color2.gray(`(not minified)`)}`);
const finalSizes = stats.map((stat) => stat.final.length);
const originalSizes = stats.map((stat) => stat.original.length);
const nameSizes = stats.map((stat) => stat.filename.length);
const namePadding = Math.max(...nameSizes);
const finalPadding = Math.max(...finalSizes);
const originalPadding = Math.max(...originalSizes);
for (const { filename, final, original } of stats) {
const fp = log.colorFile(filename).padEnd(namePadding + 25);
const og = original.padStart(originalPadding) + " kB";
const changed = original !== final;
const result = changed ? color2.green(final.padStart(finalPadding) + " kB") : final.padStart(finalPadding) + " kB";
const sizes = color2.bold.gray(`${og} -> ${result}`);
viteConfig.logger.info(fp + sizes);
}
viteConfig.logger.info("\n");
}
};
}
function unescapeCSS(str, options = { slashZero: true }) {
const string = (options == null ? void 0 : options.slashZero) ? str.replaceAll("\uFFFD", "\0") : str;
return string.replaceAll(/\\([\dA-Fa-f]{1,6}[\t\n\f\r ]?|[\S\s])/g, (match) => {
return match.length > 2 ? String.fromCodePoint(Number.parseInt(match.slice(1).trim(), 16)) : match[1];
});
}
var src_default = purgeCss;
export {
src_default as default,
purgeCss
};
//# sourceMappingURL=index.js.map