diff --git a/scripts/build-single-packages.js b/scripts/build-single-packages.js index 800c9a9976a..de836c3ad17 100644 --- a/scripts/build-single-packages.js +++ b/scripts/build-single-packages.js @@ -1,65 +1,73 @@ /* eslint-disable no-console */ const fse = require('fs-extra'); +const path = require('path'); const glob = require('glob'); -const path = require('path') +const getDynamicModuleMap = require('./parse-dynamic-modules'); const root = process.cwd(); const packageJson = require(`${root}/package.json`); -if (!(process.argv.includes('--config') && process.argv.indexOf('--config') + 1 < process.argv.length)) { +if (!(process.argv.includes('--config') && process.argv.indexOf('--config') + 1 < process.argv.length)) { console.log('--config is required followed by the config file name'); process.exit(1); } const configJson = require(`${root}/${process.argv[process.argv.indexOf('--config') + 1]}`); -const foldersExclude = configJson.exclude ? configJson.exclude : [] +const foldersExclude = configJson.exclude ? configJson.exclude : []; -let moduleGlob = configJson.moduleGlob -if(moduleGlob && !Array.isArray(moduleGlob)) { - moduleGlob = [moduleGlob] +let moduleGlob = configJson.moduleGlob; + +if (moduleGlob && !Array.isArray(moduleGlob)) { + moduleGlob = [moduleGlob]; } else if (!moduleGlob) { - moduleGlob = ['/dist/esm/*/*/**/index.js'] + moduleGlob = ['/dist/esm/*/*/**/index.js']; } + const components = { // need the /*/*/ to avoid grabbing top level index files /** * We don't want the /index.js or /components/index.js to be have packages * These files will not help with tree shaking in module federation environments */ - files: moduleGlob.map(pattern => glob - .sync(`${root}${pattern}`) - .filter((item) => !foldersExclude.some((name) => item.includes(name))) - .map((name) => name.replace(/\/$/, ''))) - .flat(), - } - - + files: moduleGlob + .map((pattern) => + glob + .sync(`${root}${pattern}`) + .filter((item) => !foldersExclude.some((name) => item.includes(name))) + .map((name) => name.replace(/\/$/, '')) + ) + .flat() +}; async function createPackage(component) { const cmds = []; let destFile = component.replace(/[^/]+\.js$/g, 'package.json').replace('/dist/esm/', '/dist/dynamic/'); - if(component.match(/index\.js$/)) { + + if (component.match(/index\.js$/)) { destFile = component.replace(/[^/]+\.js$/g, 'package.json').replace('/dist/esm/', '/dist/dynamic/'); } else { destFile = component.replace(/\.js$/g, '/package.json').replace('/dist/esm/', '/dist/dynamic/'); } + const pathAsArray = component.split('/'); - const destDir = destFile.replace(/package\.json$/, '') - const esmRelative = path.relative(destDir, component) - const cjsRelative = path.relative(destDir, component.replace('/dist/esm/', '/dist/js/')) - const typesRelative = path.relative(destDir, component.replace(/\.js$/, '.d.ts')) + const destDir = destFile.replace(/package\.json$/, ''); + const esmRelative = path.relative(destDir, component); + const cjsRelative = path.relative(destDir, component.replace('/dist/esm/', '/dist/js/')); + const typesRelative = path.relative(destDir, component.replace(/\.js$/, '.d.ts')); const packageName = configJson.packageName; + if (!packageName) { - console.log("packageName is required!") + console.log('packageName is required!'); process.exit(1); } let componentName = pathAsArray[pathAsArray.length - (component.match(/index\.js$/) ? 2 : 1)]; - if (pathAsArray.includes("next")) { + + if (pathAsArray.includes('next')) { componentName = `${componentName.toLowerCase()}-next-dynamic`; - } else if (pathAsArray.includes("deprecated")) { + } else if (pathAsArray.includes('deprecated')) { componentName = `${componentName.toLowerCase()}-deprecated-dynamic`; } else { componentName = `${componentName.toLowerCase()}-dynamic`; @@ -75,25 +83,39 @@ async function createPackage(component) { }; // use ensureFile to not having to create all the directories - fse.ensureDirSync(destDir) - cmds.push(fse.writeJSON(destFile, content)) + fse.ensureDirSync(destDir); + cmds.push(fse.writeJSON(destFile, content)); return Promise.all(cmds); } -async function generatePackages(components, dist) { - const cmds = components.map((component) => createPackage(component, dist)); +async function generatePackages(components) { + const cmds = components.map((component) => createPackage(component)); return Promise.all(cmds); } -async function run(components) { +async function generateDynamicModuleMap() { + const moduleMap = getDynamicModuleMap(root); + + if (Object.keys(moduleMap).length === 0) { + return Promise.resolve(); + } + + const moduleMapSorted = Object.keys(moduleMap) + .sort() + .reduce((acc, key) => ({ ...acc, [key]: moduleMap[key] }), {}); + + return fse.writeJSON(path.resolve(root, 'dist/dynamic-modules.json'), moduleMapSorted, { spaces: 2 }); +} + +async function run() { try { - await generatePackages(components); + await generatePackages(components.files); + await generateDynamicModuleMap(); } catch (error) { - console.log(error) + console.log(error); process.exit(1); } } -run(components.files); - +run(); diff --git a/scripts/parse-dynamic-modules.js b/scripts/parse-dynamic-modules.js new file mode 100644 index 00000000000..7e29c05ff64 --- /dev/null +++ b/scripts/parse-dynamic-modules.js @@ -0,0 +1,159 @@ +/* eslint-disable no-console */ +const fs = require('fs-extra'); +const path = require('path'); +const glob = require('glob'); +const ts = require('typescript'); + +/** @type {ts.CompilerOptions} */ +const tsConfigBase = require(path.resolve(__dirname, '../packages/tsconfig.base.json')); + +/** @type {ts.CompilerOptions} */ +const defaultCompilerOptions = { + target: tsConfigBase.target, + module: tsConfigBase.module, + moduleResolution: tsConfigBase.moduleResolution, + esModuleInterop: tsConfigBase.esModuleInterop, + allowJs: true, + strict: false, + skipLibCheck: true, + noEmit: true +}; + +/** + * Map all exports of the given index module to their corresponding dynamic modules. + * + * Example: `@patternfly/react-core` package provides ESModules index at `dist/esm/index.js` + * which exports Alert component related code & types via `dist/esm/components/Alert/index.js` + * module. + * + * Given the example above, this function should return a mapping like so: + * ```js + * { + * Alert: 'dist/dynamic/components/Alert', + * AlertProps: 'dist/dynamic/components/Alert', + * AlertContext: 'dist/dynamic/components/Alert', + * // ... + * } + * ``` + * + * The above mapping can be used when generating import statements like so: + * ```ts + * import { Alert } from '@patternfly/react-core/dist/dynamic/components/Alert'; + * ``` + * + * It may happen that the same export is provided by multiple dynamic modules; + * in such case, the resolution favors modules with most specific file paths, for example + * `dist/dynamic/components/Wizard/hooks` is favored over `dist/dynamic/components/Wizard`. + * + * Dynamic modules nested under `deprecated` or `next` directories are ignored. + * + * If the referenced index module does not exist, an empty object is returned. + * + * @param {string} basePath + * @param {string} indexModule + * @param {string} resolutionField + * @param {ts.CompilerOptions} tsCompilerOptions + * @returns {Record} + */ +const getDynamicModuleMap = ( + basePath, + indexModule = 'dist/esm/index.js', + resolutionField = 'module', + tsCompilerOptions = defaultCompilerOptions +) => { + if (!path.isAbsolute(basePath)) { + throw new Error('Package base path must be absolute'); + } + + const indexModulePath = path.resolve(basePath, indexModule); + + if (!fs.existsSync(indexModulePath)) { + return {}; + } + + /** @type {Record} */ + const dynamicModulePathToPkgDir = glob.sync(`${basePath}/dist/dynamic/**/package.json`).reduce((acc, pkgFile) => { + const pkg = require(pkgFile); + const pkgModule = pkg[resolutionField]; + + if (!pkgModule) { + throw new Error(`Missing field ${resolutionField} in ${pkgFile}`); + } + + const pkgResolvedPath = path.resolve(path.dirname(pkgFile), pkgModule); + const pkgRelativePath = path.dirname(path.relative(basePath, pkgFile)); + + acc[pkgResolvedPath] = pkgRelativePath; + + return acc; + }, {}); + + const dynamicModulePaths = Object.keys(dynamicModulePathToPkgDir); + const compilerHost = ts.createCompilerHost(tsCompilerOptions); + const program = ts.createProgram([indexModulePath, ...dynamicModulePaths], tsCompilerOptions, compilerHost); + const errorDiagnostics = ts.getPreEmitDiagnostics(program).filter((d) => d.category === ts.DiagnosticCategory.Error); + + if (errorDiagnostics.length > 0) { + const { getCanonicalFileName, getCurrentDirectory, getNewLine } = compilerHost; + + console.error( + ts.formatDiagnostics(errorDiagnostics, { + getCanonicalFileName, + getCurrentDirectory, + getNewLine + }) + ); + + throw new Error(`Detected TypeScript errors while parsing modules at ${basePath}`); + } + + const typeChecker = program.getTypeChecker(); + + /** @param {ts.SourceFile} sourceFile */ + const getExportNames = (sourceFile) => + typeChecker.getExportsOfModule(typeChecker.getSymbolAtLocation(sourceFile)).map((symbol) => symbol.getName()); + + const indexModuleExports = getExportNames(program.getSourceFile(indexModulePath)); + + /** @type {Record} */ + const dynamicModuleExports = dynamicModulePaths.reduce((acc, modulePath) => { + acc[modulePath] = getExportNames(program.getSourceFile(modulePath)); + return acc; + }, {}); + + /** @param {string[]} modulePaths */ + const getMostSpecificModulePath = (modulePaths) => + modulePaths.reduce((acc, p) => { + const pathSpecificity = p.split(path.sep).length; + const currSpecificity = acc.split(path.sep).length; + + if (pathSpecificity > currSpecificity) { + return p; + } + + if (pathSpecificity === currSpecificity) { + return !p.endsWith('index.js') && acc.endsWith('index.js') ? p : acc; + } + + return acc; + }, ''); + + return indexModuleExports.reduce((acc, exportName) => { + const foundModulePaths = Object.keys(dynamicModuleExports).filter((modulePath) => + dynamicModuleExports[modulePath].includes(exportName) + ); + + const filteredModulePaths = foundModulePaths.filter((modulePath) => { + const dirNames = path.dirname(modulePath).split(path.sep); + return !dirNames.includes('deprecated') && !dirNames.includes('next'); + }); + + if (filteredModulePaths.length > 0) { + acc[exportName] = dynamicModulePathToPkgDir[getMostSpecificModulePath(filteredModulePaths)]; + } + + return acc; + }, {}); +}; + +module.exports = getDynamicModuleMap;