UNPKG

terra-toolkit

Version:

Utilities to help when developing terra modules.

271 lines (229 loc) 9.82 kB
const fs = require('fs-extra'); const glob = require('glob'); const path = require('path'); const Logger = require('../utils/logger'); const CONFIG = 'terra-theme.config.js'; const DISCLAIMER = fs.readFileSync(path.resolve(__dirname, 'disclaimer.txt'), 'utf8'); const NODE_MODULES = 'node_modules/'; const OUTPUT = 'aggregated-themes.js'; const OUTPUT_DIR = 'generated-themes'; const OUTPUT_PATH = path.resolve(process.cwd(), OUTPUT_DIR); const ROOT = 'root'; const SCOPED = 'scoped'; const ROOT_THEME = `${ROOT}-theme.scss`; const SCOPED_THEME = `${SCOPED}-theme.scss`; const LOG_CONTEXT = '[Terra-Toolkit:theme-aggregator]'; /** * Aggregates and generates theme assets. * Aggregates the above assets into a single file. * * By default the theme will recursively search all dependencies. */ class ThemeAggregator { /** * Aggregates theme assets. * @param {string} theme - The theme to override the default theme. Used for visual regression testing. * @param {object} object.config - Config to override the terra-theme.config.js config. * @param {object} object.aggregateDefaultThemeAsScopedTheme - Bool indicating if aggregate themes should generate the default theme as a root theme or a scoped theme. * @returns {string|null} - The output path of the aggregated theme file. Null if not generated. */ static aggregate(theme, { config, aggregateDefaultThemeAsScopedTheme } = { config: undefined, aggregateDefaultThemeAsScopedTheme: false }) { let themeConfig = {}; if (config) { themeConfig = config; } else { const defaultConfig = path.resolve(process.cwd(), CONFIG); if (fs.existsSync(defaultConfig)) { // eslint-disable-next-line global-require, import/no-dynamic-require themeConfig = require(defaultConfig); } } const themeAssets = ThemeAggregator.aggregateThemes({ ...themeConfig, ...theme && { theme }, aggregateDefaultThemeAsScopedTheme, }); if (themeAssets) { return ThemeAggregator.writeJsFile(themeAssets); } return null; } /** * Aggregates theme assets into a js file. * @param {Object} options - The aggregation options. * @returns {array} - An array of aggregated theme files */ static aggregateThemes(options) { if (!ThemeAggregator.validate(options)) { return null; } // This creates a generated-themes directory to house theme assets. fs.ensureDir(OUTPUT_DIR).catch(err => { Logger.warn(err, { context: LOG_CONTEXT }); }); const { theme: defaultThemeToAggregate, aggregateDefaultThemeAsScopedTheme, } = options; const scopedThemesToAggregate = [ ...(options && options.scoped) ? options.scoped : [], ]; // The default theme is created by the post css theme plugin. if (aggregateDefaultThemeAsScopedTheme && !scopedThemesToAggregate.includes(defaultThemeToAggregate)) { scopedThemesToAggregate.push(defaultThemeToAggregate); } const themeAssets = []; if (!aggregateDefaultThemeAsScopedTheme && defaultThemeToAggregate) { const defaultThemeAsset = ThemeAggregator.triggerAggregationAndGeneration(defaultThemeToAggregate, options, true); if (defaultThemeAsset) { themeAssets.push(...defaultThemeAsset); } } scopedThemesToAggregate.forEach((theme) => { const scopedThemeAssets = ThemeAggregator.triggerAggregationAndGeneration(theme, options, false); if (scopedThemeAssets) { themeAssets.push(...scopedThemeAssets); } }); if (!themeAssets.length) { return null; } return themeAssets; } /** * Triggers aggregation and generation for a given theme. * @param {string} themeName - The theme to aggregate. * @param {Object} options - The aggregation options. * @param {boolean} isRoot - Flag that signifies if the theme asset is a root theme. * @returns {array} - An array of theme files. */ static triggerAggregationAndGeneration(themeName, options = {}, isRoot) { if (!themeName) { return null; } Logger.log(`Aggregating ${themeName} files...`, { context: LOG_CONTEXT }); const aggregatedAssets = ThemeAggregator.aggregateTheme(themeName, isRoot, options); const generatedAsset = ThemeAggregator.generateTheme(themeName, isRoot, options); if (generatedAsset) { // Generated root or scope files should take precedence over existing root or scope files. // Therefore, take advantage of CSS import precedence by adding the generated asset last. aggregatedAssets.push(generatedAsset); } if (!aggregatedAssets.length) { Logger.warn(`No theme files found for ${themeName}.`, { context: LOG_CONTEXT }); return null; } return aggregatedAssets; } /** * Aggregates the theme by root.scss or scoped.scss file * @param {string} themeName - The theme to aggregate. * @param {Object} isRoot - Flag that signifies if the theme asset is a root theme file. * @param {Object} options - The aggregate options. * @returns {array} - An array of aggregated theme files. */ static aggregateTheme(themeName, isRoot, options) { const file = isRoot ? ROOT_THEME : SCOPED_THEME; const assets = ThemeAggregator.find(`**/themes/${themeName}/${file}`, options); // Add the dependency import if it exists. assets.unshift(...ThemeAggregator.find(`${NODE_MODULES}${themeName}/**/${file}`, options)); // Resolve aggregated theme paths. return assets.map(asset => ThemeAggregator.resolve(asset)); } /** * Writes a scss theme file based on generation filename constraints. * @TODO Default to theme generation on next MVB - https://github.com/cerner/terra-toolkit/issues/325 * @param {string} themeName - The theme to aggregate. * @param {Object} isRoot - Flag that signifies if the theme asset is a root theme file. * @param {Object} options - The aggregate options. * @param {string} outputPath - Path to write the scss file to. Used for testing purposes - overrides the default generated themes path. * @returns {string} - A string containing the relative path to the generacted scss file. */ static generateTheme(themeName, isRoot, options, outputPath) { const assets = ThemeAggregator.findThemeVariableFilesForGeneration(themeName, options); if (!assets) { return null; } const scopeSelector = isRoot ? `:${ROOT}` : `.${themeName}`; const intro = `${DISCLAIMER}${scopeSelector}`; let file = assets.reduce((acc, s) => `${acc} @import '../${s}';\n`, ''); file = `${intro} {\n${file}}\n`; const prefix = isRoot ? ROOT : SCOPED; const fileName = `${prefix}-${themeName}.scss`; const filePath = path.resolve(outputPath || OUTPUT_PATH, fileName); fs.writeFileSync(filePath, file); Logger.log(`Successfully generated ${fileName}.`, { context: LOG_CONTEXT }); return `./${path.relative(outputPath || OUTPUT_PATH, filePath)}`; } /** * Finds files and directories matching a pattern. * @param {string} pattern - A regex pattern. * @param {Object} options - The aggregation options. * @returns {string[]} - An array of matching files and directories. */ static find(pattern, options) { const { exclude = [] } = options; return glob.sync(pattern, { ignore: exclude }); } /** * Resolves a file path. * Dependency files will resolve to the node_modules directory. * Local files will resolve relative to the expected output directory. * @param {string} filePath - A file path. * @returns {string} - Resolved file path relative to the home directory or node module path. */ static resolve(filePath) { // Constructs the relative path. const relativePath = path.relative(OUTPUT_PATH, path.resolve(OUTPUT_PATH, filePath)); if (filePath.indexOf(NODE_MODULES) > -1) { const dependencyPath = filePath.substring(filePath.indexOf(NODE_MODULES) + NODE_MODULES.length); return dependencyPath; } return `../${relativePath}`; } /** * Validates the aggregated options. * @param {Object} options - The aggregated options. * @returns {boolean} - Whether the theme config has valid options. */ static validate(options) { const { theme, scoped } = options; if (!theme && !scoped) { Logger.warn('No theme provided.', { context: LOG_CONTEXT }); return false; } return true; } /** * Searches for theme files located within the following directory format: themes->theme-name->theme-name.scss. * @param {string} themeName - The theme to aggregate. * @param {Object} options - The aggregation options. * @returns {array} - An array of ${themeName} files. */ static findThemeVariableFilesForGeneration(themeName, options = {}) { const assets = ThemeAggregator.find(`**/themes/${themeName}/**/${themeName}.scss`, options); // Add the dependency import if it exists. assets.unshift(...ThemeAggregator.find(`${NODE_MODULES}${themeName}/**/${themeName}.scss`, options)); if (!assets.length) { return null; } return assets; } /** * Writes a js file containing theme assets. * @param {Object[]} imports - An array of files to import. * @returns {string} - The filepath of the file. */ static writeJsFile(imports) { if (!imports.length) { Logger.warn(`No themes to import. Skip generating ${OUTPUT}.`, { context: LOG_CONTEXT }); return null; } const file = imports.reduce((acc, s) => `${acc}import '${s}';\n`, ''); const filePath = `${path.resolve(OUTPUT_PATH, OUTPUT)}`; fs.writeFileSync(filePath, `${DISCLAIMER}${file}`); Logger.log(`Successfully generated ${OUTPUT}.`, { context: LOG_CONTEXT }); return filePath; } } module.exports = ThemeAggregator;