@ima/cli
Version:
IMA.js CLI tool to build, develop and work with IMA.js applications.
433 lines (432 loc) • 16 kB
JavaScript
"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.findRules = findRules;
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'];
/**
* Helper for finding rules with given loader in webpack config.
*/
function findRules(config, testString, loader) {
const foundRules = [];
const rules = config.module?.rules;
if (!rules) {
return [];
}
(function recurseFindRules(rule) {
if (Array.isArray(rule)) {
for (const r of rule) {
recurseFindRules(r);
}
return;
}
if (rule.oneOf) {
return recurseFindRules(rule.oneOf);
}
if (rule.test &&
((typeof rule.test === 'function' && rule.test(testString)) ||
(rule.test instanceof RegExp && rule.test.test(testString)) ||
(typeof rule.test === 'string' && rule.test === testString))) {
foundRules.push(rule);
}
})(rules);
if (!loader) {
return foundRules;
}
return foundRules.reduce((acc, cur) => {
if ((cur.loader && cur.loader.includes(loader)) ||
(typeof cur.use === 'string' && cur.use.includes(loader))) {
acc.push(cur);
}
cur;
if (Array.isArray(cur.use)) {
cur.use.forEach(r => {
if ((typeof r === 'string' && r.includes(loader)) ||
(typeof r === 'object' && r && r.loader && r.loader.includes(loader))) {
acc.push(r);
}
});
}
return acc;
}, []);
}
/**
* 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;
}
}
// es2018 targets (taken from 'browserslist-generator')
const targets = [
'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' ? targets : [],
}));
}
/**
* 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 {ImaCliArgs} args Parsed CLI and build arguments.
* @param {ImaConfig} imaConfig Loaded ima config.
* @returns {Promise<Configuration[]>}
*/
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('.');
}