UNPKG

apostrophe

Version:
603 lines (538 loc) • 16.3 kB
const fs = require('fs-extra'); const path = require('path'); module.exports = { checkModulesWebpackConfig(modules, t) { const allowedProperties = [ 'extensions', 'extensionOptions', 'bundles' ]; for (const mod of Object.values(modules)) { const webpackConfig = mod.__meta.webpack[mod.__meta.name]; if (!webpackConfig) { continue; } if ( typeof webpackConfig !== 'object' || webpackConfig === null || Array.isArray(webpackConfig) || Object.keys(webpackConfig).some((prop) => !allowedProperties.includes(prop)) ) { const error = t('apostrophe:assetWebpackConfigWarning', { module: mod.__meta.name, properties: allowedProperties.join(', ') }); throw new Error(error); } if (webpackConfig && webpackConfig.bundles) { const bundles = Object.values(webpackConfig.bundles); bundles.forEach(bundle => { const bundleProps = Object.keys(bundle); if ( bundleProps.length > 1 || (bundleProps.length === 1 && !bundle.templates) || (bundle.templates && !Array.isArray(bundle.templates)) ) { const error = t('apostrophe:assetWebpackBundlesWarning', { module: mod.__meta.name }); throw new Error(error); } }); } } }, formatRebundleConfig, verifyRebundleConfig, verifyBundlesEntryPoints, formatExtensionsOptions, fillExtensionsOptions, flattenBundles, transformRebundledFor, async getWebpackExtensions ({ getMetadata, modulesToInstantiate, rebundleModulesConfig = {} }) { const modulesMeta = []; // NOTE: this is important, it must be sequential due to getMetadata side // effects for (const name of modulesToInstantiate) { modulesMeta.push(await getMetadata(name)); } const rebundleModules = formatRebundleConfig(rebundleModulesConfig); const { extensions, extensionOptions, foundBundles } = getModulesWebpackConfigs( modulesMeta, rebundleModules ); const verifiedBundles = await verifyBundlesEntryPoints(foundBundles); return { extensions, extensionOptions, verifiedBundles, rebundleModules }; }, fillExtraBundles (verifiedBundles = {}) { const res = Object.entries(verifiedBundles).reduce( (acc, [ bundleName, entry ]) => { const { js, scss, main } = entry; if (main) { return acc; } return { js: [ ...acc.js, ...(js.length && !acc.js.includes(bundleName)) ? [ bundleName ] : [] ], css: [ ...acc.css, ...(scss.length && !acc.css.includes(bundleName)) ? [ bundleName ] : [] ] }; }, { js: [], css: [] }); return res; }, getBundlesNames (bundles, es5 = false) { return Object.entries(bundles).reduce((acc, [ ext, bundlesNames ]) => { const nameExtension = ext === 'css' ? '-bundle' : '-module-bundle'; const es5Bundles = es5 && ext === 'js' ? bundlesNames.map((name) => `${name}-nomodule-bundle.${ext}`) : []; return [ ...acc, ...bundlesNames.map((name) => `${name}${nameExtension}.${ext}`), ...es5Bundles ]; }, []); }, writeBundlesImportFiles ({ name, buildDir, mainBundleName, verifiedBundles, getImportFileOutput, writeImportFile }) { if (!name.includes('src')) { return []; } const bundlesOutputs = Object.entries(verifiedBundles) .map(([ bundleName, paths ]) => { return { bundleName, importFile: `${buildDir}/${bundleName}-import.js`, js: getImportFileOutput(paths.js, { invokeApps: true, enumerateImports: true, requireDefaultExport: true }), scss: getImportFileOutput(paths.scss, { enumerateImports: true, importSuffix: 'Stylesheet' }) }; }); for (const output of bundlesOutputs) { writeImportFile({ importFile: output.importFile, indexJs: output.js, indexSass: output.scss }); } return bundlesOutputs.reduce((acc, { bundleName, importFile }) => { return { ...acc, [bundleName]: { import: importFile, dependOn: mainBundleName } }; }, {}); }, // Find all symlinks in node modules. // This would find both `module-name` and `@company/module-name` // package symlinks findNodeModulesSymlinks(rootDir) { return findSymlinks(path.join(rootDir, 'node_modules')); } }; async function findSymlinks(where, sub = '') { let result = []; const handle = await fs.promises.opendir(path.join(where, sub)); let mod = await handle.read(); while (mod) { if (mod.isSymbolicLink()) { result.push(sub + mod.name); } else if (!sub && mod.name.startsWith('@')) { const dres = await findSymlinks(where, `${mod.name}/`); result = [ ...result, ...dres ]; } mod = await handle.read(); } await handle.close(); return result; } function getModulesWebpackConfigs (modulesMeta, rebundleModules) { const { extensions, extensionOptions, bundles } = modulesMeta.reduce((modulesAcc, meta) => { const { webpack, __meta } = meta; const configs = formatConfigs( __meta.chain, webpack, rebundleModules ); if (!configs.length) { return modulesAcc; } const reduce = (list, prop) => { return list.reduce((acc, cur) => ({ ...acc, ...cur[prop] || {} }), {}); }; const extensionOptions = configs.reduce((acc, { extensionOptions = {} }) => { return [ ...acc, extensionOptions ]; }, []); return { extensions: { ...modulesAcc.extensions, ...reduce(configs, 'extensions') }, extensionOptions: [ ...modulesAcc.extensionOptions, ...extensionOptions ], bundles: { ...modulesAcc.bundles, ...reduce(configs, 'bundles') } }; }, { extensions: {}, extensionOptions: [], bundles: {} }); const formattedOptions = formatExtensionsOptions(extensionOptions); const { exts, options } = fillExtensionsOptions(extensions, formattedOptions); return { extensions: exts, extensionOptions: options, foundBundles: flattenBundles(bundles) }; }; async function verifyBundlesEntryPoints (bundles) { const checkPathsPromises = bundles.map(async ({ bundleName, modulePath, bundleRemapping }) => { const jsPath = `${modulePath}/ui/src/${bundleName}.js`; const scssPath = `${modulePath}/ui/src/${bundleName}.scss`; const jsIndexPath = `${modulePath}/ui/src/index.js`; const scssIndexPath = `${modulePath}/ui/src/index.scss`; let main = false; let withIndex; const jsFileExists = await fs.pathExists(jsPath); const scssFileExists = await fs.pathExists(scssPath); for (const remapping of bundleRemapping) { main = remapping.main; // - catch all to "main" or new bundle name, // already verified it's unique for the given module if (!remapping.source) { // Bundle name for "main" "doesn't matter - it will be ignored and // never built, we want to achieve a free from collision name. What // matters is main = true. Target is 'main' by convention: // `main.bundleName` bundleName = `${remapping.target}.${bundleName}`; if (!remapping.main) { // move "ui/src/index.*" to the bundle, // verify existence later, better performance withIndex = { jsPath: jsIndexPath, scssPath: scssIndexPath }; bundleName = remapping.target; } break; } // - not a catch-all statement from this point on // - "main" for a concrete bunbdle if (remapping.main && remapping.source === bundleName) { bundleName = `${remapping.target}.${bundleName}`; break; } // - not "main", concrete bundle remapping if (remapping.source === bundleName) { bundleName = remapping.target; break; } } return { bundleName, modulePath, main, withIndex, ...jsFileExists && { jsPath }, ...scssFileExists && { scssPath } }; }); const bundlesPaths = await Promise.all(checkPathsPromises); // Verify and squash withIndex data const seen = {}; const bundlesPathsWithIndex = []; for (const entry of bundlesPaths) { if (!entry.withIndex) { bundlesPathsWithIndex.push(entry); continue; } if (seen[entry.bundleName]) { delete entry.withIndex; bundlesPathsWithIndex.push(entry); continue; } seen[entry.bundleName] = true; const { jsPath, scssPath } = entry.withIndex; const jsFileExists = await fs.pathExists(jsPath); const scssFileExists = await fs.pathExists(scssPath); if (!jsFileExists && !scssFileExists) { delete entry.withIndex; bundlesPathsWithIndex.push(entry); continue; } bundlesPathsWithIndex.push({ ...entry, withIndex: { ...jsFileExists && { jsPath }, ...scssFileExists && { scssPath } } }); } const packedFilesByBundle = bundlesPathsWithIndex.reduce((acc, { bundleName, modulePath, jsPath, scssPath, main, withIndex }) => { if (!jsPath && !scssPath) { return acc; } return { ...acc, [bundleName]: { main, // Boolean indicating if the "main" index.js is included // caused by a remapping. It's not yet used anywhere but // it's an useful information that we keep. withIndex: !!withIndex || acc[bundleName]?.withIndex || false, modulePath, js: [ ...withIndex?.jsPath ? [ withIndex?.jsPath ] : [], ...acc[bundleName] ? acc[bundleName].js : [], ...jsPath ? [ jsPath ] : [] ], scss: [ ...withIndex?.scssPath ? [ withIndex?.scssPath ] : [], ...acc[bundleName] ? acc[bundleName].scss : [], ...scssPath ? [ scssPath ] : [] ] } }; }, {}); return packedFilesByBundle; }; // Normalize config: // - from { moduleName: 'main' } // to [{ main: true, name: 'moduleName', target: 'main' }] // - from { moduleName: bundleName } // to [{ main: false, name: 'moduleName', target: bundleName }] // - from { 'moduleName:sourceBundle': 'main' } // to [{ main: true, name: 'moduleName', source: 'sourceBundle', target: 'main' // }] - from { 'moduleName:sourceBundle': targetBundle } to [{ main: false, // name: 'moduleName', source: 'sourceBundle', target: targetBundle }] function formatRebundleConfig(mappingConfig = {}) { const result = Object.keys(mappingConfig).reduce((transformed, key) => { const main = mappingConfig[key] === 'main'; const [ moduleName, sourceBundle ] = key.split(':'); return [ ...transformed, { name: moduleName, source: sourceBundle, target: mappingConfig[key], main } ]; }, []); // Better panic than sorry! verifyRebundleConfig(result); return result; } // This function is used to detect re-bundled and moved to the main build // bundles. It returns filtered configuration containing the proper bundle // names. See usage in `template/lib/bundlesLoader.js` and // `widget-type/index.js`. Expected arguments: - moduleName: the module owning // the bundleConfig - bundleConfig: the bundle configuration ({ bundles: {...} // }) - rebundleConfigs: the normalized output of // `formatRebundleConfig(asset.options.rebundleModules)` function transformRebundledFor(moduleName, bundleConfigs, rebundleConfigs) { const rebundle = rebundleConfigs .filter(entry => entry.name === moduleName); let result = { ...bundleConfigs }; for (const entry of rebundle) { // 1. CatchAll to "main", already bundled in the main build - skip. if (!entry.source && entry.main) { result = {}; break; } // 2. CatchAll to a new bundle name, preserve merged options. if (!entry.source && !entry.main) { const options = Object.values(bundleConfigs) .reduce((all, opts) => ({ ...all, ...opts }), {}); result = { [entry.target]: options }; break; } // 3. Rename a single bundle. if (entry.source) { // 3.1. ... but it's sent to the main build if (entry.main) { delete result[entry.source]; continue; } // 3.2. rename it, preserve the options if (bundleConfigs[entry.source]) { result[entry.target] = { ...bundleConfigs[entry.source] }; delete result[entry.source]; } } } return result; } // Expects formatted by formatRebundleConfig() `asset.options.rebundleModules` function verifyRebundleConfig(config = []) { const targeted = []; const catchAllModules = {}; for (const entry of config) { if (!entry.source) { catchAllModules[entry.name] = `${entry.name}: ${entry.target}`; } else { targeted.push(entry); } } for (const entry of targeted) { if (catchAllModules[entry.name]) { const conflicting = `${entry.name}${entry.source ? `:${entry.source}` : ''}: ${entry.target}`; throw new Error( 'Invalid apos.asset.options.rebundleModules: ' + `"${catchAllModules[entry.name]}" conflicts with ` + `"${conflicting}"` ); } } } // Gather and transform all available webpack configs function formatConfigs (chain, webpackConfigs, rebundleModules) { return Object.entries(webpackConfigs) .map(([ name, config ], i) => { if (!config) { return null; } const { bundles = {}, extensions = {}, extensionOptions = {} } = config; const bundleNames = Object.keys(bundles); // Remapping only for the explicitly defined by the module bundle // configurations const bundleRemapping = rebundleModules .filter(entry => entry.name === chain[i].name); return { name: chain[i].name, extensions, extensionOptions, bundles: { [name]: { bundleNames, modulePath: chain[i].dirname, bundleRemapping } } }; }).filter((config) => config); } function flattenBundles (bundles) { return Object.values(bundles) .reduce((acc, { bundleNames, modulePath, bundleRemapping }) => { return [ ...acc, ...bundleNames.map((bundleName) => ({ bundleName, bundleRemapping, modulePath })) ]; }, []); } function fillExtensionsOptions (extensions, options) { const isObject = (val) => val && typeof val === 'object' && !Array.isArray(val); return Object.entries(extensions).reduce((acc, [ name, config ]) => { if (isObject(config)) { return { ...acc, exts: { ...acc.exts, [name]: config } }; } if (typeof config !== 'function') { return acc; } const computedOptions = computeOptions(options[name] || [], isObject); return { exts: { ...acc.exts, [name]: config(computedOptions) }, options: { ...acc.options, [name]: computedOptions } }; }, { exts: {}, options: {} }); function computeOptions (options, isObject) { return options.reduce((acc, option) => { if (!isObject(option) && typeof option !== 'function') { return acc; } return { ...acc, ...isObject(option) ? option : option(acc) }; }, {}); } } function formatExtensionsOptions (options) { return options.reduce( (acc, current) => { return { ...acc, ...Object.fromEntries(Object.entries(current) .map(([ ext, option ]) => [ ext, [ option, ...(acc[ext] || []) ] ])) }; }, {} ); }