From 56c0980b58b1cbecadfaa19ea56ef54006b9ac9c Mon Sep 17 00:00:00 2001 From: Vojtech Szocs Date: Fri, 26 Jan 2024 17:45:41 +0000 Subject: [PATCH 1/3] Generate dist/dynamic-modules.json for relevant packages --- scripts/build-single-packages.js | 82 +++++++++------- scripts/parse-dynamic-modules.js | 157 +++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 32 deletions(-) create mode 100644 scripts/parse-dynamic-modules.js diff --git a/scripts/build-single-packages.js b/scripts/build-single-packages.js index 800c9a9976a..c3009cfd92e 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,35 @@ 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); + + 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..79edcc0cd33 --- /dev/null +++ b/scripts/parse-dynamic-modules.js @@ -0,0 +1,157 @@ +/* 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 defaultCompilerOptions = { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + allowJs: true, + strict: false, + esModuleInterop: true, + 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 non-deprecated modules with most specific file paths, for example + * `dist/dynamic/components/Wizard/hooks` is favored over `dist/dynamic/components/Wizard`. + * + * 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) + ); + + if (foundModulePaths.length > 0) { + const nonDeprecatedModulePaths = foundModulePaths.filter( + (modulePath) => !modulePath.split(path.sep).includes('deprecated') + ); + + const targetModulePath = getMostSpecificModulePath( + nonDeprecatedModulePaths.length > 0 ? nonDeprecatedModulePaths : foundModulePaths + ); + + acc[exportName] = dynamicModulePathToPkgDir[targetModulePath]; + } + + return acc; + }, {}); +}; + +module.exports = getDynamicModuleMap; From 6c3bf4f173eb120afc34f466ebe37ed7c0b3df92 Mon Sep 17 00:00:00 2001 From: Vojtech Szocs Date: Thu, 18 Apr 2024 19:31:14 +0200 Subject: [PATCH 2/3] Address review comments --- scripts/parse-dynamic-modules.js | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/scripts/parse-dynamic-modules.js b/scripts/parse-dynamic-modules.js index 79edcc0cd33..7e29c05ff64 100644 --- a/scripts/parse-dynamic-modules.js +++ b/scripts/parse-dynamic-modules.js @@ -4,14 +4,17 @@ 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: ts.ScriptTarget.ES2020, - module: ts.ModuleKind.ESNext, - moduleResolution: ts.ModuleResolutionKind.NodeJs, + target: tsConfigBase.target, + module: tsConfigBase.module, + moduleResolution: tsConfigBase.moduleResolution, + esModuleInterop: tsConfigBase.esModuleInterop, allowJs: true, strict: false, - esModuleInterop: true, skipLibCheck: true, noEmit: true }; @@ -38,10 +41,12 @@ const defaultCompilerOptions = { * 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 non-deprecated modules with most specific file paths, for example + * 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 @@ -138,16 +143,13 @@ const getDynamicModuleMap = ( dynamicModuleExports[modulePath].includes(exportName) ); - if (foundModulePaths.length > 0) { - const nonDeprecatedModulePaths = foundModulePaths.filter( - (modulePath) => !modulePath.split(path.sep).includes('deprecated') - ); - - const targetModulePath = getMostSpecificModulePath( - nonDeprecatedModulePaths.length > 0 ? nonDeprecatedModulePaths : foundModulePaths - ); + const filteredModulePaths = foundModulePaths.filter((modulePath) => { + const dirNames = path.dirname(modulePath).split(path.sep); + return !dirNames.includes('deprecated') && !dirNames.includes('next'); + }); - acc[exportName] = dynamicModulePathToPkgDir[targetModulePath]; + if (filteredModulePaths.length > 0) { + acc[exportName] = dynamicModulePathToPkgDir[getMostSpecificModulePath(filteredModulePaths)]; } return acc; From 991fb49622a2d0dab12301015b37f3bb90df4a34 Mon Sep 17 00:00:00 2001 From: Vojtech Szocs Date: Thu, 18 Apr 2024 20:01:20 +0200 Subject: [PATCH 3/3] Skip writing dynamic-modules.json when the module map is empty --- scripts/build-single-packages.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/build-single-packages.js b/scripts/build-single-packages.js index c3009cfd92e..de836c3ad17 100644 --- a/scripts/build-single-packages.js +++ b/scripts/build-single-packages.js @@ -97,6 +97,10 @@ async function generatePackages(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] }), {});