import postCSSConfig from "@delement/configs/postcss"; import fg from "fast-glob"; import { atRule } from "postcss"; import type { Plugin } from "rollup"; import type { PostCSSPluginConf } from "rollup-plugin-postcss"; import postcss from "rollup-plugin-postcss"; import path from "path"; import { getFilesWithFS } from "#package/utils/node/getFilesWithFS"; interface IPostCSSBundlePluginOptions extends PostCSSPluginConf { base?: string; scopeToRootClass?: boolean; scopeSelector?: string; useCascadeLayer?: boolean; layerName?: string; } const defaultParams: IPostCSSBundlePluginOptions = { autoModules: false, base: process.cwd(), extract: true, inject: false, minimize: false, sourceMap: false, config: false, scopeToRootClass: false, scopeSelector: ":where(.deUI)", useCascadeLayer: false, layerName: "deUI", }; const ROOT_CLASS = ".deUI"; const ROOT_STATE_CLASSES = [ ".isDomReady", ".isPageLoaded", ".isTouchscreen", ".isMobileDevice", ]; const getPrefixedSelector = (prefix: string, selector: string) => { // Selector already scoped via root class. if (selector.includes(ROOT_CLASS)) { const cleanedSelector = selector .replace(new RegExp(`\\s*${ROOT_CLASS}\\s*`, "g"), " ") .replace(/\s+/g, " ") .trim(); return cleanedSelector.length ? `${prefix} ${cleanedSelector}` : prefix; } return `${prefix} ${selector}`.replace(/\s+/g, " ").trim(); }; const normalizeRootRules = (selector: string, prefix: string) => { let normalizedSelector = selector; // :where(.deUI) :root => :root:where(.deUI) if (normalizedSelector.includes(`${prefix} :root`)) { normalizedSelector = normalizedSelector.replace(`${prefix} :root`, `:root${prefix}`); } // :where(.deUI) .isDomReady ... => :where(.deUI).isDomReady ... ROOT_STATE_CLASSES.forEach((stateClass) => { if (normalizedSelector.startsWith(`${prefix} ${stateClass}`)) { normalizedSelector = normalizedSelector.replace(`${prefix} ${stateClass}`, `${prefix}${stateClass}`); } }); return normalizedSelector; }; const createScopePlugin = (prefix: string) => { return { postcssPlugin: "deui-scope-prefixer", Once(root: import("postcss").Root) { root.walkRules((rule) => { const parentName = rule.parent?.type === "atrule" ? rule.parent.name : ""; const keyframeRules = [ "keyframes", "-webkit-keyframes", "-moz-keyframes", "-o-keyframes", "-ms-keyframes", ]; if (keyframeRules.includes(parentName)) { return; } rule.selectors = rule.selectors.map((selector) => { return normalizeRootRules( getPrefixedSelector(prefix, selector), prefix ); }); }); }, }; }; const createLayerPlugin = (layerName: string) => { return { postcssPlugin: "deui-layer-wrapper", Once(root: import("postcss").Root) { const nodes = root.nodes ?? []; const charsets = nodes.filter((node) => { return node.type === "atrule" && node.name === "charset"; }); const contentNodes = nodes.filter((node) => { return !(node.type === "atrule" && node.name === "charset"); }); if (!contentNodes.length) { return; } if ( contentNodes.length === 1 && contentNodes[0].type === "atrule" && contentNodes[0].name === "layer" && contentNodes[0].params.trim() === layerName ) { return; } const layer = atRule({ name: "layer", params: layerName, }); contentNodes.forEach((node) => { node.remove(); layer.append(node); }); root.removeAll(); charsets.forEach((node) => root.append(node)); root.append(layer); }, }; }; /** * Bundle PostCSS chunks into file * @param options{Object} * @returns {Promise} */ export default async function PostCSSBundlePlugin(options: IPostCSSBundlePluginOptions = {}): Promise { const defaultPlugins = await postCSSConfig.utils.getPlugins(); const params = { ...defaultParams, plugins: [ ...defaultPlugins, ], ...options, }; if (params.scopeToRootClass) { params.plugins.push(createScopePlugin(params.scopeSelector ?? defaultParams.scopeSelector!)); } if (params.useCascadeLayer && params.layerName) { params.plugins.push(createLayerPlugin(params.layerName)); } const getCleanConfig: ((params: IPostCSSBundlePluginOptions, path?: string) => PostCSSPluginConf) = (params = {}, file = "") => { const options = { ...params }; if (typeof params.extract === "string" && file.length) { const { name } = path.parse(file); const parent = path.basename(path.dirname(file)); options.extract = fg.convertPathToPattern(params.extract .replaceAll("{name}", name) .replaceAll("{parent}", parent)); options.include = file; } if ("base" in options) { delete options["base"]; } return options; }; if (params.include instanceof RegExp) { const files: string[] = await getFilesWithFS(params.base, ((file: string) => { const pattern = fg.convertPathToPattern(file); return (params.include as RegExp).test(pattern); })) .catch((err: Error) => { console.error(`[PostCSSBundlePlugin] Error from "getFilesWithFS": ${err.message}`); return []; }); return files.map((file) => { const cfg = getCleanConfig(params, file); // console.debug(`[PostCSSBundlePlugin] Chunk from "${cfg.include}" to "${cfg.extract}"`); const instance = postcss(cfg); return { ...instance, name: "css-chunks", }; }); } else { const cfg = getCleanConfig(params); // console.debug(`[PostCSSBundlePlugin] Bundle to "${cfg.extract}"`); const instance = postcss(cfg); return [ { ...instance, name: "css-bundle", }, ]; } }