UNPKG

@ima/cli

Version:

IMA.js CLI tool to build, develop and work with IMA.js applications.

385 lines (384 loc) 14.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.IMA_CONF_FILENAME = void 0; exports.resolveEnvironment = resolveEnvironment; exports.createPolyfillEntry = createPolyfillEntry; exports.createDevServerConfig = createDevServerConfig; exports.createCacheKey = createCacheKey; exports.requireImaConfig = requireImaConfig; exports.resolveImaConfig = resolveImaConfig; exports.cleanup = cleanup; exports.runImaPluginsHook = runImaPluginsHook; exports.createContexts = createContexts; exports.createWebpackConfig = createWebpackConfig; exports.getCurrentCoreJsVersion = getCurrentCoreJsVersion; const crypto_1 = require("crypto"); const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const logger_1 = require("@ima/dev-utils/logger"); const server_1 = require("@ima/server"); const chalk_1 = __importDefault(require("chalk")); const config_1 = __importDefault(require("../config")); exports.IMA_CONF_FILENAME = 'ima.config.js'; const TS_CONFIG_PATHS = ['tsconfig.build.json', 'tsconfig.json']; /** * Loads application IMA.js environment from server/config/environment.js * * @param {ImaCliArgs['rootDir']} rootDir Application root directory * @returns {Environment} Loaded environment */ function resolveEnvironment(rootDir = process.cwd()) { return (0, server_1.environmentFactory)({ applicationFolder: rootDir }); } /** * Returns polyfill entry point for current es version if the file exists. * The function looks for app/polyfill.js and app/polyfill.es.js files. * * @param {ImaConfigurationContext} ctx Current configuration context. * @returns {Record<string, string>} Entry object or empty object. */ function createPolyfillEntry(ctx) { const { isClientES, rootDir } = ctx; const fileName = `polyfill${isClientES ? '.es' : ''}.js`; const polyfillPath = path_1.default.join(rootDir, 'app', fileName); if (!fs_1.default.existsSync(polyfillPath)) { return {}; } return { polyfill: `app/${fileName}` }; } /** * Creates hmr dev server configuration from provided contexts * and arguments with this priority args -> ctx -> imaConfig -> [defaults]. */ function createDevServerConfig({ args, ctx, imaConfig, }) { const port = args?.port ?? ctx?.port ?? imaConfig?.devServer?.port ?? 3101; const hostname = args?.hostname ?? ctx?.hostname ?? imaConfig?.devServer?.hostname ?? 'localhost'; let publicUrl = args?.publicUrl ?? ctx?.publicUrl ?? imaConfig?.devServer?.publicUrl; // Clean public url (remove last slash) publicUrl = publicUrl ?? `${hostname}:${port}`; publicUrl = publicUrl?.replace(/\/$/, ''); // Preppend http if (!publicUrl?.startsWith('http')) { publicUrl = `http://${publicUrl}`; } return { port, hostname, publicUrl, }; } /** * Creates hash representing current webpack environment. * * @param {ImaConfigurationContext} ctx Current configuration context. * @param {ImaConfig} imaConfig ima configuration * @returns {string} */ function createCacheKey(ctx, imaConfig, additionalData = {}) { const hash = (0, crypto_1.createHash)('md5'); // Get Plugins CLI args const pluginsEnv = {}; const pluginsCtxArgs = imaConfig?.plugins ?.map(plugin => Object.keys(plugin?.cliArgs?.[ctx.command] || {})) .flat(); // Generate additional env cache dependencies from plugin cli args if (pluginsCtxArgs) { for (const pluginArgName of pluginsCtxArgs) { // @ts-expect-error these args are not in interface pluginsEnv[pluginArgName] = ctx[pluginArgName]; } } /** * Use only variables that don't change webpack config in any way * but require clean cache state. Variables that change webpack config * are handled by webpack itself sine it caches the config file. */ hash.update(JSON.stringify({ command: ctx.command, legacy: ctx.legacy, forceLegacy: ctx.forceLegacy, profile: ctx.profile, rootDir: ctx.rootDir, environment: ctx.environment, reactRefresh: ctx.reactRefresh, verbose: ctx.verbose, ...additionalData, ...pluginsEnv, })); return hash.digest('hex'); } /** * Requires imaConfig from given root directory (default to cwd). * * @param {string} [rootDir=process.cwd()] App root directory. * @returns {ImaConfig | null} Config or null in case the config file doesn't exits. */ function requireImaConfig(rootDir = process.cwd()) { const imaConfigPath = path_1.default.join(rootDir, exports.IMA_CONF_FILENAME); return fs_1.default.existsSync(imaConfigPath) ? require(imaConfigPath) : null; } /** * Resolves ima.config.js from rootDir base path with DEFAULTS. * * @param {ImaCliArgs} args CLI args. * @returns {Promise<ImaConfig>} Ima config or empty object. */ async function resolveImaConfig(args) { const defaultImaConfig = { publicPath: '/', compress: true, languages: { cs: ['./app/**/*CS.json'], en: ['./app/**/*EN.json'], }, imageInlineSizeLimit: 8192, watchOptions: { ignored: ['**/node_modules'], aggregateTimeout: 5, }, swc: async (config) => config, swcVendor: async (config) => config, postcss: async (config) => config, cssBrowsersTarget: '>0.3%, not dead, not op_mini all', }; const imaConfig = requireImaConfig(args.rootDir); const imaConfigWithDefaults = { ...defaultImaConfig, ...imaConfig, watchOptions: { ...defaultImaConfig.watchOptions, ...imaConfig?.watchOptions, }, experiments: { ...defaultImaConfig.experiments, ...imaConfig?.experiments, }, }; // Print loaded plugins info if (Array.isArray(imaConfigWithDefaults.plugins) && imaConfigWithDefaults.plugins.length) { const pluginNames = []; logger_1.logger.info(`Loaded CLI plugins: `, { newLine: false }); for (const plugin of imaConfigWithDefaults.plugins) { pluginNames.push(chalk_1.default.blue(plugin.name)); } logger_1.logger.write(pluginNames.join(', ')); } // Normalize publicPath imaConfigWithDefaults.publicPath += !imaConfigWithDefaults.publicPath.endsWith('/') ? '/' : ''; return imaConfigWithDefaults; } /** * Takes care of cleaning build directory and node_modules/.cache * directory based on passed cli arguments. */ async function cleanup(args) { // Clear cache before doing anything else if (args.clearCache) { const cacheDir = path_1.default.join(args.rootDir, '/node_modules/.cache'); logger_1.logger.info(`Clearing cache at ${chalk_1.default.magenta(cacheDir.replace(args.rootDir, '.'))}...`, { trackTime: true }); await fs_1.default.promises.rm(cacheDir, { force: true, recursive: true }); logger_1.logger.endTracking(); } // Clear output directory if (args.clean) { logger_1.logger.info('Cleaning the build directory...', { trackTime: true }); const outputDir = path_1.default.join(args.rootDir, 'build'); if (!fs_1.default.existsSync(outputDir)) { logger_1.logger.info('The build directory is already empty'); return; } await fs_1.default.promises.rm(outputDir, { recursive: true }); logger_1.logger.endTracking(); } else { // Clean at least hot directory silently await fs_1.default.promises.rm(path_1.default.join(args.rootDir, 'build/hot'), { recursive: true, force: true, }); } } /** * Runs one of optional ima plugin hooks defined on existing plugins. * * @param {ImaCliArgs} args Parsed CLI and build arguments. * @param {ImaConfig} imaConfig Loaded ima config. * @param hook */ async function runImaPluginsHook(args, imaConfig, hook) { if (!Array.isArray(imaConfig.plugins) || !imaConfig.plugins.length) { return; } // Filter plugins with given hook const filteredPlugins = imaConfig.plugins.filter(plugin => typeof plugin[hook] === 'function'); if (!filteredPlugins.length) { return; } logger_1.logger.info(`Running ${chalk_1.default.magenta(hook)} hook on ima plugins...`); // Run plugin hook for (const plugin of filteredPlugins) { await plugin?.[hook]?.(args, imaConfig); } } /** * Generate configuration contexts for given array of configuration names. * Contexts are generated based on ima.config.js file and CLI arguments. * * @param {ImaConfigurationContext['name'][]} configurationNames * @param {ImaCliArgs} args * @param {ImaConfig} imaConfig * @returns {ImaConfigurationContext[]} */ function createContexts(configurationNames, args, imaConfig) { const { rootDir, environment, command } = args; const useSourceMaps = !!imaConfig.sourceMaps || args.environment === 'development'; const imaEnvironment = resolveEnvironment(rootDir); const appDir = path_1.default.join(rootDir, 'app'); const lessGlobalsPath = path_1.default.join(rootDir, 'app/less/globals.less'); const isDevEnv = environment === 'development'; const mode = environment === 'production' ? 'production' : 'development'; const devtool = useSourceMaps ? typeof imaConfig.sourceMaps === 'string' ? imaConfig.sourceMaps : 'source-map' : false; let tsconfigPath = undefined; // Find tsconfig path in rootDir based on priority set in TS_CONFIG_PATHS for (const fileName of TS_CONFIG_PATHS) { if (fs_1.default.existsSync(path_1.default.join(rootDir, fileName))) { tsconfigPath = path_1.default.join(rootDir, fileName); break; } } // targets (taken from 'browserslist-generator') const es2018Targets = [ 'and_chr >= 63', 'chrome >= 63', 'and_ff >= 58', 'android >= 103', 'edge >= 79', 'samsung >= 8.2', 'safari >= 11.1', 'ios_saf >= 11.4', 'opera >= 50', 'firefox >= 58', ]; return configurationNames.map(name => ({ ...args, name, isServer: name === 'server', isClient: name === 'client', isClientES: name === 'client.es', processCss: name === 'client.es', outputFolders: { hot: 'static/hot', public: 'static/public', media: 'static/media', css: 'static/css', js: name === 'server' ? 'server' : name === 'client' ? 'static/js' : 'static/js.es', }, typescript: { enabled: !!tsconfigPath, tsconfigPath, }, imaEnvironment, appDir, useHMR: command === 'dev' && name === 'client.es', mode, isDevEnv, lessGlobalsPath, useSourceMaps, devtool, targets: name === 'client' ? es2018Targets : [], })); } /** * Creates webpack configurations contexts from current config and cli args. * Additionally it applies all existing configuration overrides from cli plugins * and app overrides in this order cli -> plugins -> app. * * @param args Parsed CLI and build arguments. * @param imaConfig Loaded ima config. */ async function createWebpackConfig(args, imaConfig) { // Create configuration contexts logger_1.logger.info(`Parsing config files for ${chalk_1.default.magenta(process.env.NODE_ENV)}...`, { trackTime: true }); // Create array of webpack build configurations based on current context. const configurationNames = [ 'server', (args.command === 'build' || args.legacy) && !imaConfig.disableLegacyBuild && 'client', 'client.es', ].filter(Boolean); // Create configuration contexts let contexts = createContexts(configurationNames, args, imaConfig); // Call configuration overrides on plugins if (Array.isArray(imaConfig.plugins)) { for (const plugin of imaConfig.plugins) { if (!plugin.prepareConfigurations) { continue; } contexts = await plugin.prepareConfigurations(contexts, imaConfig, args); } } // Call configuration overrides on ima.config.js if (imaConfig.prepareConfigurations) { contexts = await imaConfig.prepareConfigurations(contexts, imaConfig, args); } /** * Process configuration contexts with optional webpack function extensions * from ima plugins and imaConfig. */ return Promise.all(contexts.map(async (ctx) => { // Create webpack config for given configuration context let config = await (0, config_1.default)(ctx, imaConfig); // Run webpack function overrides from ima plugins if (Array.isArray(imaConfig.plugins)) { for (const plugin of imaConfig.plugins) { if (typeof plugin?.webpack !== 'function') { continue; } try { config = await plugin.webpack(config, ctx, imaConfig); } catch (error) { logger_1.logger.error(`There was an error while running webpack config for '${plugin.name}' plugin.`); console.error(error); process.exit(1); } } } // Run webpack function overrides from imaConfig if (typeof imaConfig.webpack === 'function') { config = await imaConfig.webpack(config, ctx, imaConfig); } return config; })).then(config => { // Print elapsed time logger_1.logger.endTracking(); return config; }); } /** * Extracts major.minor version string of currently resolved * core-js from node_modules. */ async function getCurrentCoreJsVersion() { return JSON.parse((await fs_1.default.promises.readFile(path_1.default.resolve(require.resolve('core-js'), '../package.json'))).toString()) .version.split('.') .slice(0, 2) .join('.'); }