UNPKG

vite-plugin-tailwind-purgecss

Version:
358 lines (354 loc) 12.9 kB
// 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