rollup-plugin-lib-style
Version:
A Rollup plugin that converts CSS and extensions for CSS into CSS modules and imports the generated CSS files
216 lines (177 loc) • 7.03 kB
JavaScript
import { createFilter } from 'rollup-pluginutils';
import postcss from 'postcss';
import postcssModules from 'postcss-modules';
import crypto from 'node:crypto';
import fs from 'fs-extra';
import sass from 'sass';
import glob from 'glob';
const hashFormats = ["latin1", "hex", "base64"];
const replaceFormat = (formatString, fileName, cssContent) => {
const hashLengthMatch = formatString.match(/hash:.*:(\d+)/);
const hashFormatMatch = formatString.match(/hash:([^:]*)[:-]?/);
const hashFormat = hashFormatMatch && hashFormats.includes(hashFormatMatch[1]) ? hashFormatMatch[1] : "hex";
const hashLength = hashLengthMatch ? parseInt(hashLengthMatch[1]) : 6;
const hashString = crypto.createHash("md5").update(cssContent).digest(hashFormat);
const hashToUse = hashString.length < hashLength ? hashString : hashString.slice(0, hashLength);
return formatString.replace("[local]", fileName).replace(/\[hash:(.*?)(:\d+)?\]/, hashToUse)
};
/**
* Ensures generated class names are valid CSS identifiers.
* - Replaces invalid characters with `_`
* - Ensures class names do not start with a number
* @param {string} name - Original class name
* @returns {string} - Valid CSS class name
*/
const normalizeClassName = (hash) => {
// Replace invalid characters with '_'
let sanitized = hash.replace(/[^a-zA-Z0-9-_]/g, "_");
if (/^[0-9]/.test(sanitized)) sanitized = `_${sanitized}`;
return sanitized
};
const DEFAULT_SCOPED_NAME = "[local]_[hash:hex:6]";
/**
* @typedef {object} postCssLoaderOptions
* @property {object[]} postCssPlugins
* @property {string} classNamePrefix
* @property {string} scopedName
*/
/**
* @typedef {object} postCssLoaderProps
* @property {postCssLoaderOptions} options
* @property {string} fiePath
* @property {string} code
*/
/**
* Transform CSS into CSS-modules
* @param {postCssLoaderProps}
* @returns
*/
const postCssLoader = async ({code, fiePath, options}) => {
const {scopedName = DEFAULT_SCOPED_NAME, postCssPlugins = [], classNamePrefix = ""} = options;
const modulesExported = {};
const isGlobalStyle = /\.global\.(css|scss|sass|less|stylus)$/.test(fiePath);
const isInNodeModules = /[\\/]node_modules[\\/]/.test(fiePath);
const postCssPluginsWithCssModules = [
postcssModules({
generateScopedName: (name, filename, css) => {
const hashContent = `${filename}:${name}:${css}`;
const rawScopedName = replaceFormat(scopedName, name, hashContent);
const normalizedName = normalizeClassName(rawScopedName);
return isInNodeModules || isGlobalStyle
? name // Use the original name for global or node_modules styles
: classNamePrefix + normalizedName // Apply prefix and normalize
},
getJSON: (cssFileName, json) => (modulesExported[cssFileName] = json),
}),
...postCssPlugins,
];
const postcssOptions = {
from: fiePath,
to: fiePath,
map: false,
};
const result = await postcss(postCssPluginsWithCssModules).process(code, postcssOptions);
// collect dependencies
const dependencies = [];
for (const message of result.messages) {
if (message.type === "dependency") {
dependencies.push(message.file);
}
}
// print postcss warnings
for (const warning of result.warnings()) {
console.warn(`WARNING: ${warning.plugin}:`, warning.text);
}
return {
code: `export default ${JSON.stringify(modulesExported[fiePath])};`,
dependencies,
extracted: {
id: fiePath,
code: result.css,
},
}
};
const PLUGIN_NAME = "rollup-plugin-lib-style";
const MAGIC_PATH_REGEX = /@@_MAGIC_PATH_@@/g;
const MAGIC_PATH = "@@_MAGIC_PATH_@@";
const modulesIds = new Set();
const outputPaths = [];
const defaultLoaders = [
{
name: "sass",
regex: /\.(sass|scss)$/,
process: ({filePath, options}) => ({
code: sass.compile(filePath, options?.sassOptions || {}).css.toString(),
}),
},
{
name: "css",
regex: /\.(css)$/,
process: ({code}) => ({code}),
},
];
const replaceMagicPath = (fileContent, customPath = ".") => fileContent.replace(MAGIC_PATH_REGEX, customPath);
const libStylePlugin = (options = {}) => {
const {customPath, customCSSPath, customCSSInjectedPath, loaders, include, exclude, importCSS = true, sassOptions = {}, ...postCssOptions} = options;
const allLoaders = [...(loaders || []), ...defaultLoaders];
const filter = createFilter(include, exclude);
const getLoader = (filepath) => allLoaders.find((loader) => loader.regex.test(filepath));
return {
name: PLUGIN_NAME,
options(options) {
if (!options.output) console.error("missing output options");
else options.output.forEach((outputOptions) => outputPaths.push(outputOptions.dir));
},
async transform(code, id) {
const loader = getLoader(id);
if (!filter(id) || !loader) return null
modulesIds.add(id);
const rawCss = await loader.process({filePath: id, code, options: {sassOptions}});
const postCssResult = await postCssLoader({code: rawCss.code, fiePath: id, options: postCssOptions});
for (const dependency of postCssResult.dependencies) this.addWatchFile(dependency);
const getFilePath = () => {
return id.replace(process.cwd(), "").replace(/\\/g, "/")
};
const cssFilePath = customCSSPath ? customCSSPath(id) : getFilePath();
const cssFileInjectedPath = customCSSInjectedPath ? customCSSInjectedPath(cssFilePath) : cssFilePath;
const cssFilePathWithoutSlash = cssFilePath.startsWith("/") ? cssFilePath.substring(1) : cssFilePath;
// create a new css file with the generated hash class names
this.emitFile({
type: "asset",
fileName: cssFilePathWithoutSlash.replace(loader.regex, ".css"),
source: postCssResult.extracted.code,
});
const importStr = importCSS ? `import "${MAGIC_PATH}${cssFileInjectedPath.replace(loader.regex, ".css")}";\n` : "";
// create a new js file with css module
return {
code: importStr + postCssResult.code,
map: {mappings: ""},
}
},
async closeBundle() {
if (!importCSS) return
// get all the modules that import CSS files
const importersPaths = outputPaths
.reduce((result, currentPath) => {
result.push(glob.sync(`${currentPath}/**/*.js`));
return result
}, [])
.flat();
// replace magic path with relative path
await Promise.all(
importersPaths.map((currentPath) =>
fs
.readFile(currentPath)
.then((buffer) => buffer.toString())
.then((fileContent) => replaceMagicPath(fileContent, customPath))
.then((fileContent) => fs.writeFile(currentPath, fileContent))
)
);
},
}
};
const onwarn = (warning, warn) => {
if (warning.code === "UNRESOLVED_IMPORT" && warning.message.includes(MAGIC_PATH)) return
if (typeof warn === "function") warn(warning);
};
export { libStylePlugin, onwarn };