UNPKG

mini-css-themes-plugin

Version:

Webpack plugin to generate separate themes for css modules.

291 lines (248 loc) 12.6 kB
const path = require('path'); const fs = require('fs'); const loaderUtils = require('loader-utils'); class MiniCssThemesWebpackPlugin { constructor(config) { this.validate(config); this.init(config); } validate({themes, defaultTheme}) { if (!themes) { throw new Error(`You must provide the list of themes.`); } if (!defaultTheme) { throw new Error(`You must provide the default theme key.`); } if (!themes[defaultTheme]) { throw new Error(`Default theme '${defaultTheme}' missing from themes definition.`); } const areThemesSingleEntry = typeof themes[defaultTheme] === 'string'; const themeEntryKeys = areThemesSingleEntry ? [] : Object.keys(themes[defaultTheme]); const validateThemeFile = (themePath, themeKey, entry = null) => { const fullPath = path.resolve(themePath); if (!fs.existsSync(fullPath)) { throw new Error(`Theme '${entry ? `${themeKey}.${entry}` : themeKey}' file not found: ${fullPath}`); } }; Object.entries(themes).forEach(([themeKey, fileOrFilesObject]) => { if (areThemesSingleEntry) { if (typeof fileOrFilesObject !== 'string') { throw new Error(`All themes value must be a string (path to theme file) as the default theme is also string.`); } return validateThemeFile(fileOrFilesObject, themeKey); } if (typeof fileOrFilesObject !== 'object') { throw new Error(`Themes value must be either object or string.`); } const currentThemeEntryKeys = Object.keys(fileOrFilesObject); if (currentThemeEntryKeys.length !== themeEntryKeys.length || themeEntryKeys.filter((k) => !currentThemeEntryKeys.includes(k)).length) { throw new Error(`Missing or additional theme entries in '${themeKey}'. All themes must match schema of default theme.`); } Object.entries(fileOrFilesObject).forEach(([entry, path]) => { validateThemeFile(path, themeKey, entry); }); }); } init({themes, defaultTheme, chunkPrefix}) { this.pluginName = this.constructor.name; this.themes = themes; this.defaultTheme = defaultTheme; this.chunkPrefix = chunkPrefix || 'theme__'; this.nonDefaultThemeKeys = Object.keys(this.themes).filter(t => t !== this.defaultTheme); this.themeChunkNames = this.nonDefaultThemeKeys.map(theme => `${this.chunkPrefix}${theme}`); const areThemesSingleEntry = typeof themes[defaultTheme] === 'string'; this.absoluteThemePaths = {}; Object.entries(themes).forEach(([key, fileOrFilesObject]) => { if (areThemesSingleEntry) { this.absoluteThemePaths[key] = [path.resolve(fileOrFilesObject)]; } else { this.absoluteThemePaths[key] = Object.entries(fileOrFilesObject) .sort((a, b) => a.key > b.key ? -1 : 1) .reduce((reduced, [,current]) => reduced.push(current) && reduced, []); } }); this.defaultImportFilenames = this.absoluteThemePaths[this.defaultTheme].map((themePath) => path.basename(themePath).replace(/\.[a-zA-Z0-9]+$/, '')); } apply(compiler) { const filterThemeChunks = chunks => chunks.filter(chunk => this.themeChunkNames.find(c => chunk.chunkReason && chunk.chunkReason.indexOf(`(cache group: ${c})`) !== -1) ); compiler.hooks.thisCompilation.tap(this.pluginName, (compilation) => { // Finds import dependencies targeted to sass files and duplicates them for each theme. compilation.hooks.succeedModule.tap(this.pluginName, module => { const findSassDependencies = () => module.dependencies.filter(dep => dep.request && dep.request.match(/\.scss$/)); const cloneDependencyForTheme = (theme, dep) => { const cloneDependency = Object.assign(Object.create(Object.getPrototypeOf(dep)), dep); // Mark for which theme we are cloning the sass dependency. cloneDependency.themed = theme; // Need to modify the identifier so that it is recorded as a separate dependency. // Otherwise webpack will consider them the same deps and not attempt creating // different modules for them. const oldGetResourceIdentifier = cloneDependency.getResourceIdentifier.bind(cloneDependency); cloneDependency.getResourceIdentifier = () => oldGetResourceIdentifier() + `?theme=${theme}`; module.dependencies.push(cloneDependency); }; findSassDependencies().forEach((dep) => { this.nonDefaultThemeKeys.forEach(theme => cloneDependencyForTheme(theme, dep)); }); }); }); // Makes sure that modules contain theme information and that they are // duplicated for compilation as well. compiler.hooks.beforeCompile.tap(this.pluginName, (params) => { params.normalModuleFactory.hooks.beforeResolve.tap(this.pluginName, (module) => { const theme = module.dependencies[0].themed; const isSassModuleAndIsThemed = module.request.match(/\.scss$/) && theme; if (isSassModuleAndIsThemed) { // Need to update the request as otherwise webpack will consider it the same // module and will reuse the same compilation of it. module.request = `${module.request}??theme:${theme}`; } }); }); // Create special loaders for css modules that were marked for themes so that // imports can be switched to the different theme one. compiler.hooks.beforeCompile.tap(this.pluginName, (params) => { params.normalModuleFactory.hooks.module.tap(this.pluginName, (module) => { const theme = module.request.match(/\?\?.*theme:([^:?!]+)/); if (theme) { module.themed = theme[1]; module.loaders.push({ loader: require.resolve('./loader.js'), options: { defaultImportFilenames: this.defaultImportFilenames, defaultImportPaths: this.absoluteThemePaths[this.defaultTheme], targetImportPaths: this.absoluteThemePaths[theme[1]], } }); } }); }); // Make sure to not generate different hashes for classes loaded from // theme files as composes. compiler.hooks.environment.tap(this.pluginName, () => { const fileToThemeFileTypeMap = {}; const classNamesPerThemeFileTypes = {}; Object.entries(this.themes).forEach(([_, fileOrFiles]) => { if (typeof fileOrFiles === 'string') { fileToThemeFileTypeMap[fileOrFiles] = 'default'; } else { Object.entries(fileOrFiles).forEach(([type, file]) => { fileToThemeFileTypeMap[path.resolve(file)] = type; }); } }); compiler.options.module.rules.forEach((rule) => { (Array.isArray(rule.use) ? rule.use : [rule.use]) .filter((loader) => loader.loader === 'css-loader' && loader.options && loader.options.modules) .forEach((loader) => { const isCssLoaderOldVersion = typeof loader.options.modules === 'boolean'; let originalGetLocalIdent; if (isCssLoaderOldVersion) { originalGetLocalIdent = loader.options.getLocalIdent || require('css-loader/dist/utils').getLocalIdent; } else { originalGetLocalIdent = loader.options.modules.getLocalIdent || ((loaderContext, localIdentName, localName, options) =>{ const {context, hashPrefix} = options; const {resourcePath} = loaderContext; const request = path.relative(context, resourcePath); options.content = `${hashPrefix + request}\x00${localName}`; return loaderUtils.interpolateName(loaderContext, localIdentName, options) .replace(/\[local]/gi, localName); }); } const getLocalIdent = (...args) => { const [module,,className] = args; const themeFileType = fileToThemeFileTypeMap[module.resourcePath]; const hasClassName = themeFileType && classNamesPerThemeFileTypes[themeFileType] && classNamesPerThemeFileTypes[themeFileType][className]; if (hasClassName) { return classNamesPerThemeFileTypes[themeFileType][className]; } const ident = originalGetLocalIdent(...args); if (!hasClassName && themeFileType) { classNamesPerThemeFileTypes[themeFileType] = classNamesPerThemeFileTypes[themeFileType] || {}; classNamesPerThemeFileTypes[themeFileType][className] = ident; } return ident; }; if (isCssLoaderOldVersion) { loader.options.getLocalIdent = getLocalIdent; } else { loader.options.modules.getLocalIdent = getLocalIdent; } }); }); }); // Separate theme based css modules in their own chunks. compiler.hooks.entryOption.tap(this.pluginName, () => { compiler.options.optimization.splitChunks = compiler.options.optimization.splitChunks || {}; compiler.options.optimization.splitChunks.cacheGroups = compiler.options.optimization.splitChunks.cacheGroups || {}; const getModuleReasonTheme = (module) => { const moduleReason = module.reasons[0].module; return moduleReason.themed || null; }; const addThemeChunk = (themeKey) => { compiler.options.optimization.splitChunks.cacheGroups[`${this.chunkPrefix}${themeKey}`] = { test: (m) => m.constructor.name === 'CssModule' && getModuleReasonTheme(m) === themeKey, chunks: 'all', enforce: true, }; }; this.nonDefaultThemeKeys.forEach(theme => addThemeChunk(theme)); }); let moduleDependencyHistory = []; let moduleChunkHistory = []; // Required cleanup for functional final compilation. compiler.hooks.thisCompilation.tap(this.pluginName, (compilation) => { compilation.hooks.optimizeChunkModules.tap(this.pluginName, chunks => { chunks.forEach(chunk => { chunk.getModules().forEach(module => { // Remove duplicate dependency links for the same css module as otherwise // webpack will concatenate IDs on the imports of these modules which will // produce an id to a module that doesn't in fact exist. if (module.dependencies && module.dependencies.find(dep => !dep.themed)) { moduleDependencyHistory.push({module, dependencies: module.dependencies}); module.dependencies = module.dependencies.filter(dep => !dep.themed); } // Remove from built JS duplicates of css modules classes generated by themes. if (module.themed) { moduleChunkHistory.push({module, chunk}); module.removeChunk(chunk); } }); }); // Remove theme chunks from the entry points so that they are not recognized in // a dependency tree and thus we can safely remove the empty JS files generated // by these chunks. filterThemeChunks(chunks).forEach(chunk => chunk._groups.forEach(entry => entry.removeChunk(chunk))); }); }); // Remove assets generated by theme chunks that are not css. compiler.hooks.thisCompilation.tap(this.pluginName, (compilation) => { // @TODO: Deprecated hook in latest webpack version. compilation.hooks.optimizeChunkAssets.tap(this.pluginName, chunks => { filterThemeChunks(chunks).forEach(chunk => { const nonCssAssets = chunk.files.filter(file => !file.match(/\.css$/)); nonCssAssets.forEach(file => delete compilation.assets[file]); chunk.files = chunk.files.filter(file => file.match(/\.css$/)); }); }); }); // When in watch mode, we need to revert previous cleanup as otherwise dependency // links will be lost and the themes won't be included in future compilations. compiler.hooks.watchRun.tap(this.pluginName, () => { compiler.hooks.afterCompile.tap(this.pluginName, () => { if (moduleDependencyHistory.length) { moduleDependencyHistory.forEach((history) => history.module.dependencies = history.dependencies); moduleDependencyHistory = []; } if (moduleChunkHistory.length) { moduleChunkHistory.forEach((history) => history.chunk.addModule(history.module)); moduleChunkHistory = []; } }); }); } } module.exports = MiniCssThemesWebpackPlugin;