Skip to content

Commit 9fa6654

Browse files
authored
Generate dist/dynamic-modules.json for relevant packages (#10046)
* Generate dist/dynamic-modules.json for relevant packages * Address review comments * Skip writing dynamic-modules.json when the module map is empty
1 parent 325c0b3 commit 9fa6654

File tree

2 files changed

+213
-32
lines changed

2 files changed

+213
-32
lines changed

scripts/build-single-packages.js

Lines changed: 54 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,73 @@
11
/* eslint-disable no-console */
22
const fse = require('fs-extra');
3+
const path = require('path');
34
const glob = require('glob');
4-
const path = require('path')
5+
const getDynamicModuleMap = require('./parse-dynamic-modules');
56

67
const root = process.cwd();
78
const packageJson = require(`${root}/package.json`);
89

9-
if (!(process.argv.includes('--config') && process.argv.indexOf('--config') + 1 < process.argv.length)) {
10+
if (!(process.argv.includes('--config') && process.argv.indexOf('--config') + 1 < process.argv.length)) {
1011
console.log('--config is required followed by the config file name');
1112
process.exit(1);
1213
}
1314

1415
const configJson = require(`${root}/${process.argv[process.argv.indexOf('--config') + 1]}`);
1516

16-
const foldersExclude = configJson.exclude ? configJson.exclude : []
17+
const foldersExclude = configJson.exclude ? configJson.exclude : [];
1718

18-
let moduleGlob = configJson.moduleGlob
19-
if(moduleGlob && !Array.isArray(moduleGlob)) {
20-
moduleGlob = [moduleGlob]
19+
let moduleGlob = configJson.moduleGlob;
20+
21+
if (moduleGlob && !Array.isArray(moduleGlob)) {
22+
moduleGlob = [moduleGlob];
2123
} else if (!moduleGlob) {
22-
moduleGlob = ['/dist/esm/*/*/**/index.js']
24+
moduleGlob = ['/dist/esm/*/*/**/index.js'];
2325
}
26+
2427
const components = {
2528
// need the /*/*/ to avoid grabbing top level index files
2629
/**
2730
* We don't want the /index.js or /components/index.js to be have packages
2831
* These files will not help with tree shaking in module federation environments
2932
*/
30-
files: moduleGlob.map(pattern => glob
31-
.sync(`${root}${pattern}`)
32-
.filter((item) => !foldersExclude.some((name) => item.includes(name)))
33-
.map((name) => name.replace(/\/$/, '')))
34-
.flat(),
35-
}
36-
37-
33+
files: moduleGlob
34+
.map((pattern) =>
35+
glob
36+
.sync(`${root}${pattern}`)
37+
.filter((item) => !foldersExclude.some((name) => item.includes(name)))
38+
.map((name) => name.replace(/\/$/, ''))
39+
)
40+
.flat()
41+
};
3842

3943
async function createPackage(component) {
4044
const cmds = [];
4145
let destFile = component.replace(/[^/]+\.js$/g, 'package.json').replace('/dist/esm/', '/dist/dynamic/');
42-
if(component.match(/index\.js$/)) {
46+
47+
if (component.match(/index\.js$/)) {
4348
destFile = component.replace(/[^/]+\.js$/g, 'package.json').replace('/dist/esm/', '/dist/dynamic/');
4449
} else {
4550
destFile = component.replace(/\.js$/g, '/package.json').replace('/dist/esm/', '/dist/dynamic/');
4651
}
52+
4753
const pathAsArray = component.split('/');
48-
const destDir = destFile.replace(/package\.json$/, '')
49-
const esmRelative = path.relative(destDir, component)
50-
const cjsRelative = path.relative(destDir, component.replace('/dist/esm/', '/dist/js/'))
51-
const typesRelative = path.relative(destDir, component.replace(/\.js$/, '.d.ts'))
54+
const destDir = destFile.replace(/package\.json$/, '');
55+
const esmRelative = path.relative(destDir, component);
56+
const cjsRelative = path.relative(destDir, component.replace('/dist/esm/', '/dist/js/'));
57+
const typesRelative = path.relative(destDir, component.replace(/\.js$/, '.d.ts'));
5258

5359
const packageName = configJson.packageName;
60+
5461
if (!packageName) {
55-
console.log("packageName is required!")
62+
console.log('packageName is required!');
5663
process.exit(1);
5764
}
5865

5966
let componentName = pathAsArray[pathAsArray.length - (component.match(/index\.js$/) ? 2 : 1)];
60-
if (pathAsArray.includes("next")) {
67+
68+
if (pathAsArray.includes('next')) {
6169
componentName = `${componentName.toLowerCase()}-next-dynamic`;
62-
} else if (pathAsArray.includes("deprecated")) {
70+
} else if (pathAsArray.includes('deprecated')) {
6371
componentName = `${componentName.toLowerCase()}-deprecated-dynamic`;
6472
} else {
6573
componentName = `${componentName.toLowerCase()}-dynamic`;
@@ -75,25 +83,39 @@ async function createPackage(component) {
7583
};
7684

7785
// use ensureFile to not having to create all the directories
78-
fse.ensureDirSync(destDir)
79-
cmds.push(fse.writeJSON(destFile, content))
86+
fse.ensureDirSync(destDir);
87+
cmds.push(fse.writeJSON(destFile, content));
8088

8189
return Promise.all(cmds);
8290
}
8391

84-
async function generatePackages(components, dist) {
85-
const cmds = components.map((component) => createPackage(component, dist));
92+
async function generatePackages(components) {
93+
const cmds = components.map((component) => createPackage(component));
8694
return Promise.all(cmds);
8795
}
8896

89-
async function run(components) {
97+
async function generateDynamicModuleMap() {
98+
const moduleMap = getDynamicModuleMap(root);
99+
100+
if (Object.keys(moduleMap).length === 0) {
101+
return Promise.resolve();
102+
}
103+
104+
const moduleMapSorted = Object.keys(moduleMap)
105+
.sort()
106+
.reduce((acc, key) => ({ ...acc, [key]: moduleMap[key] }), {});
107+
108+
return fse.writeJSON(path.resolve(root, 'dist/dynamic-modules.json'), moduleMapSorted, { spaces: 2 });
109+
}
110+
111+
async function run() {
90112
try {
91-
await generatePackages(components);
113+
await generatePackages(components.files);
114+
await generateDynamicModuleMap();
92115
} catch (error) {
93-
console.log(error)
116+
console.log(error);
94117
process.exit(1);
95118
}
96119
}
97120

98-
run(components.files);
99-
121+
run();

scripts/parse-dynamic-modules.js

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/* eslint-disable no-console */
2+
const fs = require('fs-extra');
3+
const path = require('path');
4+
const glob = require('glob');
5+
const ts = require('typescript');
6+
7+
/** @type {ts.CompilerOptions} */
8+
const tsConfigBase = require(path.resolve(__dirname, '../packages/tsconfig.base.json'));
9+
10+
/** @type {ts.CompilerOptions} */
11+
const defaultCompilerOptions = {
12+
target: tsConfigBase.target,
13+
module: tsConfigBase.module,
14+
moduleResolution: tsConfigBase.moduleResolution,
15+
esModuleInterop: tsConfigBase.esModuleInterop,
16+
allowJs: true,
17+
strict: false,
18+
skipLibCheck: true,
19+
noEmit: true
20+
};
21+
22+
/**
23+
* Map all exports of the given index module to their corresponding dynamic modules.
24+
*
25+
* Example: `@patternfly/react-core` package provides ESModules index at `dist/esm/index.js`
26+
* which exports Alert component related code & types via `dist/esm/components/Alert/index.js`
27+
* module.
28+
*
29+
* Given the example above, this function should return a mapping like so:
30+
* ```js
31+
* {
32+
* Alert: 'dist/dynamic/components/Alert',
33+
* AlertProps: 'dist/dynamic/components/Alert',
34+
* AlertContext: 'dist/dynamic/components/Alert',
35+
* // ...
36+
* }
37+
* ```
38+
*
39+
* The above mapping can be used when generating import statements like so:
40+
* ```ts
41+
* import { Alert } from '@patternfly/react-core/dist/dynamic/components/Alert';
42+
* ```
43+
*
44+
* It may happen that the same export is provided by multiple dynamic modules;
45+
* in such case, the resolution favors modules with most specific file paths, for example
46+
* `dist/dynamic/components/Wizard/hooks` is favored over `dist/dynamic/components/Wizard`.
47+
*
48+
* Dynamic modules nested under `deprecated` or `next` directories are ignored.
49+
*
50+
* If the referenced index module does not exist, an empty object is returned.
51+
*
52+
* @param {string} basePath
53+
* @param {string} indexModule
54+
* @param {string} resolutionField
55+
* @param {ts.CompilerOptions} tsCompilerOptions
56+
* @returns {Record<string, string>}
57+
*/
58+
const getDynamicModuleMap = (
59+
basePath,
60+
indexModule = 'dist/esm/index.js',
61+
resolutionField = 'module',
62+
tsCompilerOptions = defaultCompilerOptions
63+
) => {
64+
if (!path.isAbsolute(basePath)) {
65+
throw new Error('Package base path must be absolute');
66+
}
67+
68+
const indexModulePath = path.resolve(basePath, indexModule);
69+
70+
if (!fs.existsSync(indexModulePath)) {
71+
return {};
72+
}
73+
74+
/** @type {Record<string, string>} */
75+
const dynamicModulePathToPkgDir = glob.sync(`${basePath}/dist/dynamic/**/package.json`).reduce((acc, pkgFile) => {
76+
const pkg = require(pkgFile);
77+
const pkgModule = pkg[resolutionField];
78+
79+
if (!pkgModule) {
80+
throw new Error(`Missing field ${resolutionField} in ${pkgFile}`);
81+
}
82+
83+
const pkgResolvedPath = path.resolve(path.dirname(pkgFile), pkgModule);
84+
const pkgRelativePath = path.dirname(path.relative(basePath, pkgFile));
85+
86+
acc[pkgResolvedPath] = pkgRelativePath;
87+
88+
return acc;
89+
}, {});
90+
91+
const dynamicModulePaths = Object.keys(dynamicModulePathToPkgDir);
92+
const compilerHost = ts.createCompilerHost(tsCompilerOptions);
93+
const program = ts.createProgram([indexModulePath, ...dynamicModulePaths], tsCompilerOptions, compilerHost);
94+
const errorDiagnostics = ts.getPreEmitDiagnostics(program).filter((d) => d.category === ts.DiagnosticCategory.Error);
95+
96+
if (errorDiagnostics.length > 0) {
97+
const { getCanonicalFileName, getCurrentDirectory, getNewLine } = compilerHost;
98+
99+
console.error(
100+
ts.formatDiagnostics(errorDiagnostics, {
101+
getCanonicalFileName,
102+
getCurrentDirectory,
103+
getNewLine
104+
})
105+
);
106+
107+
throw new Error(`Detected TypeScript errors while parsing modules at ${basePath}`);
108+
}
109+
110+
const typeChecker = program.getTypeChecker();
111+
112+
/** @param {ts.SourceFile} sourceFile */
113+
const getExportNames = (sourceFile) =>
114+
typeChecker.getExportsOfModule(typeChecker.getSymbolAtLocation(sourceFile)).map((symbol) => symbol.getName());
115+
116+
const indexModuleExports = getExportNames(program.getSourceFile(indexModulePath));
117+
118+
/** @type {Record<string, string[]>} */
119+
const dynamicModuleExports = dynamicModulePaths.reduce((acc, modulePath) => {
120+
acc[modulePath] = getExportNames(program.getSourceFile(modulePath));
121+
return acc;
122+
}, {});
123+
124+
/** @param {string[]} modulePaths */
125+
const getMostSpecificModulePath = (modulePaths) =>
126+
modulePaths.reduce((acc, p) => {
127+
const pathSpecificity = p.split(path.sep).length;
128+
const currSpecificity = acc.split(path.sep).length;
129+
130+
if (pathSpecificity > currSpecificity) {
131+
return p;
132+
}
133+
134+
if (pathSpecificity === currSpecificity) {
135+
return !p.endsWith('index.js') && acc.endsWith('index.js') ? p : acc;
136+
}
137+
138+
return acc;
139+
}, '');
140+
141+
return indexModuleExports.reduce((acc, exportName) => {
142+
const foundModulePaths = Object.keys(dynamicModuleExports).filter((modulePath) =>
143+
dynamicModuleExports[modulePath].includes(exportName)
144+
);
145+
146+
const filteredModulePaths = foundModulePaths.filter((modulePath) => {
147+
const dirNames = path.dirname(modulePath).split(path.sep);
148+
return !dirNames.includes('deprecated') && !dirNames.includes('next');
149+
});
150+
151+
if (filteredModulePaths.length > 0) {
152+
acc[exportName] = dynamicModulePathToPkgDir[getMostSpecificModulePath(filteredModulePaths)];
153+
}
154+
155+
return acc;
156+
}, {});
157+
};
158+
159+
module.exports = getDynamicModuleMap;

0 commit comments

Comments
 (0)