@mui/internal-code-infra 0.0.3-canary.1 → 0.0.3-canary.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mui/internal-code-infra",
3
- "version": "0.0.3-canary.1",
3
+ "version": "0.0.3-canary.10",
4
4
  "description": "Infra scripts and configs to be used across MUI repos.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -14,31 +14,33 @@
14
14
  "./package.json": "./package.json",
15
15
  "./prettier": "./src/prettier.mjs",
16
16
  "./eslint": "./src/eslint/index.mjs",
17
- "./babel-config": "./src/babel-config.mjs"
17
+ "./babel-config": "./src/babel-config.mjs",
18
+ "./markdownlint": "./src/markdownlint/index.mjs"
18
19
  },
19
20
  "bin": {
20
21
  "code-infra": "./bin/code-infra.mjs"
21
22
  },
22
23
  "dependencies": {
23
- "@argos-ci/core": "^4.1.0",
24
+ "@argos-ci/core": "^4.1.4",
24
25
  "@babel/cli": "^7.28.3",
25
- "@babel/core": "^7.28.3",
26
+ "@babel/core": "^7.28.4",
27
+ "@babel/plugin-syntax-jsx": "^7.27.1",
26
28
  "@babel/plugin-syntax-typescript": "^7.27.1",
27
29
  "@babel/plugin-transform-runtime": "^7.28.3",
28
30
  "@babel/preset-env": "^7.28.3",
29
31
  "@babel/preset-react": "^7.27.1",
30
32
  "@babel/preset-typescript": "^7.27.1",
31
33
  "@eslint/compat": "^1.3.2",
32
- "@eslint/js": "^9.34.0",
33
- "@next/eslint-plugin-next": "^15.5.0",
34
+ "@eslint/js": "^9.35.0",
35
+ "@next/eslint-plugin-next": "^15.5.3",
34
36
  "@octokit/auth-action": "^6.0.1",
35
37
  "@octokit/rest": "^22.0.0",
36
- "@pnpm/find-workspace-dir": "^1000.1.2",
38
+ "@pnpm/find-workspace-dir": "^1000.1.3",
37
39
  "babel-plugin-optimize-clsx": "^2.6.2",
38
40
  "babel-plugin-transform-inline-environment-variables": "^0.4.4",
39
41
  "babel-plugin-transform-react-remove-prop-types": "^0.4.24",
40
42
  "babel-plugin-transform-remove-imports": "^1.8.0",
41
- "chalk": "^5.6.0",
43
+ "chalk": "^5.6.2",
42
44
  "eslint-config-prettier": "^10.1.8",
43
45
  "eslint-import-resolver-typescript": "^4.4.4",
44
46
  "eslint-module-utils": "^2.12.1",
@@ -48,19 +50,19 @@
48
50
  "eslint-plugin-react": "^7.37.5",
49
51
  "eslint-plugin-react-compiler": "^19.1.0-rc.2",
50
52
  "eslint-plugin-react-hooks": "^6.0.0-rc1",
51
- "eslint-plugin-testing-library": "^7.6.6",
53
+ "eslint-plugin-testing-library": "^7.7.0",
52
54
  "execa": "^9.6.0",
53
55
  "git-url-parse": "^16.1.0",
54
- "globals": "^16.3.0",
56
+ "globals": "^16.4.0",
55
57
  "globby": "^14.1.0",
56
58
  "lodash-es": "^4.17.21",
57
59
  "minimatch": "^10.0.3",
58
60
  "semver": "^7.7.2",
59
- "typescript-eslint": "^8.40.0",
61
+ "typescript-eslint": "^8.43.0",
60
62
  "yargs": "^18.0.0",
61
- "@mui/internal-babel-plugin-minify-errors": "2.0.8-canary.7",
62
- "@mui/internal-babel-plugin-display-name": "1.0.4-canary.6",
63
- "@mui/internal-babel-plugin-resolve-imports": "2.0.7-canary.19"
63
+ "@mui/internal-babel-plugin-display-name": "1.0.4-canary.7",
64
+ "@mui/internal-babel-plugin-minify-errors": "2.0.8-canary.10",
65
+ "@mui/internal-babel-plugin-resolve-imports": "2.0.7-canary.21"
64
66
  },
65
67
  "peerDependencies": {
66
68
  "eslint": "^9.0.0",
@@ -75,11 +77,11 @@
75
77
  "@types/estree-jsx": "^1.0.5",
76
78
  "@types/lodash-es": "^4.17.12",
77
79
  "@types/yargs": "^17.0.33",
78
- "@typescript-eslint/parser": "^8.40.0",
79
- "@typescript-eslint/rule-tester": "^8.40.0",
80
- "eslint": "^9.34.0",
80
+ "@typescript-eslint/parser": "^8.43.0",
81
+ "@typescript-eslint/rule-tester": "^8.43.0",
82
+ "eslint": "^9.35.0",
81
83
  "prettier": "^3.6.2",
82
- "typescript-eslint": "^8.40.0"
84
+ "typescript-eslint": "^8.43.0"
83
85
  },
84
86
  "files": [
85
87
  "bin",
@@ -91,7 +93,7 @@
91
93
  "publishConfig": {
92
94
  "access": "public"
93
95
  },
94
- "gitSha": "2159d7ef17d0ea7ff2ba98c5df9f6519fcaccfbe",
96
+ "gitSha": "b5dcc0b6d364d0474376eda1be73c681ffaaa6f8",
95
97
  "scripts": {
96
98
  "typescript": "tsc -p tsconfig.json",
97
99
  "test": "pnpm -w test --project @mui/internal-code-infra",
@@ -13,7 +13,7 @@ import pluginRemovePropTypes from 'babel-plugin-transform-react-remove-prop-type
13
13
  * @param {boolean} [param0.debug]
14
14
  * @param {boolean} [param0.optimizeClsx]
15
15
  * @param {boolean} [param0.removePropTypes]
16
- * @param {boolean} [param0.isTest]
16
+ * @param {boolean} [param0.noResolveImports]
17
17
  * @param {'cjs' | 'esm'} param0.bundle
18
18
  * @param {string | null} param0.outExtension - Specify the output file extension.
19
19
  * @param {string} param0.runtimeVersion
@@ -23,7 +23,7 @@ export function getBaseConfig({
23
23
  debug = false,
24
24
  optimizeClsx = false,
25
25
  removePropTypes = false,
26
- isTest = false,
26
+ noResolveImports = false,
27
27
  bundle,
28
28
  runtimeVersion,
29
29
  outExtension,
@@ -81,7 +81,7 @@ export function getBaseConfig({
81
81
  plugins.push([pluginOptimizeClsx, {}, 'babel-plugin-optimize-clsx']);
82
82
  }
83
83
 
84
- if (bundle === 'esm' && !isTest) {
84
+ if (bundle === 'esm' && !noResolveImports) {
85
85
  plugins.push([
86
86
  pluginResolveImports,
87
87
  { outExtension },
@@ -119,20 +119,39 @@ export function getBaseConfig({
119
119
  }
120
120
 
121
121
  /**
122
- * @type {import('@babel/core').ConfigFunction}
122
+ * @typedef {Object} Options
123
+ * @prop {'esm' | 'cjs'} [Options.bundle]
124
+ * @prop {boolean} [Options.noResolveImports]
125
+ * @prop {undefined} [options.env]
126
+ */
127
+
128
+ /**
129
+ * @param {import('@babel/core').ConfigAPI | Options} api
130
+ * @returns {import('@babel/core').TransformOptions}
123
131
  */
124
132
  export default function getBabelConfig(api) {
125
- const isStable = api.env(['regressions', 'stable']);
126
- const isTest = api.env('test') || process.env.NODE_ENV === 'test';
133
+ /** @type {'esm' | 'cjs'} */
134
+ let bundle;
135
+ /** @type {boolean} */
136
+ let noResolveImports;
137
+
138
+ if (api.env) {
139
+ // legacy
140
+ bundle = api.env(['regressions', 'stable']) ? 'esm' : 'cjs';
141
+ noResolveImports = api.env('test') || process.env.NODE_ENV === 'test';
142
+ } else {
143
+ bundle = api.bundle || 'esm';
144
+ noResolveImports = api.noResolveImports || false;
145
+ }
127
146
 
128
147
  return getBaseConfig({
129
148
  debug: process.env.MUI_BUILD_VERBOSE === 'true',
130
- bundle: isStable ? 'esm' : 'cjs',
149
+ bundle,
131
150
  outExtension: process.env.MUI_OUT_FILE_EXTENSION || null,
132
151
  // any package needs to declare 7.25.0 as a runtime dependency. default is ^7.0.0
133
152
  runtimeVersion: process.env.MUI_BABEL_RUNTIME_VERSION || '^7.25.0',
134
153
  optimizeClsx: process.env.MUI_OPTIMIZE_CLSX === 'true',
135
154
  removePropTypes: process.env.MUI_REMOVE_PROP_TYPES === 'true',
136
- isTest,
155
+ noResolveImports,
137
156
  });
138
157
  }
package/src/cli/babel.mjs CHANGED
@@ -6,6 +6,7 @@ import { globby } from 'globby';
6
6
  import * as fs from 'node:fs/promises';
7
7
  import * as path from 'node:path';
8
8
  import { $ } from 'execa';
9
+ import { BASE_IGNORES } from '../utils/build.mjs';
9
10
 
10
11
  const TO_TRANSFORM_EXTENSIONS = ['.js', '.ts', '.tsx'];
11
12
 
@@ -64,18 +65,6 @@ export async function cjsCopy({ from, to }) {
64
65
  * @property {string} [runtimeModule] - The runtime module to replace the errors with.
65
66
  */
66
67
 
67
- const BASE_IGNORES = [
68
- '**/*.test.js',
69
- '**/*.test.ts',
70
- '**/*.test.tsx',
71
- '**/*.spec.js',
72
- '**/*.spec.ts',
73
- '**/*.spec.tsx',
74
- '**/*.d.ts',
75
- '**/*.test/*.*',
76
- '**/test-cases/*.*',
77
- ];
78
-
79
68
  /**
80
69
  * @param {Object} options
81
70
  * @param {boolean} [options.verbose=false] - Whether to enable verbose logging.
@@ -1,9 +1,13 @@
1
1
  /* eslint-disable no-console */
2
+ import { findWorkspaceDir } from '@pnpm/find-workspace-dir';
2
3
  import { $ } from 'execa';
4
+ import { globby } from 'globby';
3
5
  import set from 'lodash-es/set.js';
4
6
  import * as fs from 'node:fs/promises';
5
7
  import * as path from 'node:path';
6
- import { getOutExtension, isMjsBuild, validatePkgJson } from '../utils/build.mjs';
8
+ import { sep as posixSep } from 'node:path/posix';
9
+
10
+ import { getOutExtension, isMjsBuild, mapConcurrently, validatePkgJson } from '../utils/build.mjs';
7
11
 
8
12
  /**
9
13
  * @typedef {Object} Args
@@ -18,6 +22,7 @@ import { getOutExtension, isMjsBuild, validatePkgJson } from '../utils/build.mjs
18
22
  * @property {boolean} skipPackageJson - Whether to skip generating the package.json file in the bundle output.
19
23
  * @property {boolean} skipMainCheck - Whether to skip checking for main field in package.json.
20
24
  * @property {string[]} ignore - Globs to be ignored by Babel.
25
+ * @property {string[]} [copy] - Files/Directories to be copied. Can be a glob pattern.
21
26
  */
22
27
 
23
28
  const validBundles = [
@@ -298,6 +303,13 @@ export default /** @type {import('yargs').CommandModule<{}, Args>} */ ({
298
303
  type: 'boolean',
299
304
  default: false,
300
305
  description: 'Skip checking for main field in package.json.',
306
+ })
307
+ .option('copy', {
308
+ type: 'string',
309
+ array: true,
310
+ description:
311
+ 'Files/Directories to be copied to the output directory. Can be a glob pattern.',
312
+ default: [],
301
313
  });
302
314
  },
303
315
  async handler(args) {
@@ -442,5 +454,144 @@ export default /** @type {import('yargs').CommandModule<{}, Args>} */ ({
442
454
  outputDir: buildDir,
443
455
  addTypes: buildTypes,
444
456
  });
457
+
458
+ await copyHandler({
459
+ cwd,
460
+ globs: args.copy ?? [],
461
+ buildDir,
462
+ verbose: args.verbose,
463
+ });
445
464
  },
446
465
  });
466
+
467
+ /**
468
+ * @param {Object} param0
469
+ * @param {string} param0.cwd - The current working directory.
470
+ * @param {string[]} [param0.globs=[]] - Extra files to copy, can be specified as `source:target` pairs or just `source`.
471
+ * @param {string} param0.buildDir - The build directory to copy to.
472
+ * @param {boolean} [param0.verbose=false] - Whether to suppress output.
473
+ * @returns {Promise<void>}
474
+ */
475
+ async function copyHandler({ cwd, globs = [], buildDir, verbose = false }) {
476
+ /**
477
+ * @type {(string|{targetPath: string; sourcePath: string})[]}
478
+ */
479
+ const defaultFiles = [];
480
+ const workspaceDir = await findWorkspaceDir(cwd);
481
+ if (!workspaceDir) {
482
+ throw new Error('Workspace directory not found');
483
+ }
484
+
485
+ const localOrRootFiles = [
486
+ [path.join(cwd, 'README.md'), path.join(workspaceDir, 'README.md')],
487
+ [path.join(cwd, 'LICENSE'), path.join(workspaceDir, 'LICENSE')],
488
+ [path.join(cwd, 'CHANGELOG.md'), path.join(workspaceDir, 'CHANGELOG.md')],
489
+ ];
490
+ await Promise.all(
491
+ localOrRootFiles.map(async (filesToCopy) => {
492
+ for (const file of filesToCopy) {
493
+ if (
494
+ // eslint-disable-next-line no-await-in-loop
495
+ await fs.stat(file).then(
496
+ () => true,
497
+ () => false,
498
+ )
499
+ ) {
500
+ defaultFiles.push(file);
501
+ break;
502
+ }
503
+ }
504
+ }),
505
+ );
506
+
507
+ if (globs.length) {
508
+ const res = globs.map((globPattern) => {
509
+ const [pattern, baseDir] = globPattern.split(':');
510
+ return { pattern, baseDir };
511
+ });
512
+ /**
513
+ * Avoids redundant globby calls for the same pattern.
514
+ *
515
+ * @type {Map<string, Promise<string[]>>}
516
+ */
517
+ const globToResMap = new Map();
518
+
519
+ const result = await Promise.all(
520
+ res.map(async ({ pattern, baseDir }) => {
521
+ if (!globToResMap.has(pattern)) {
522
+ const promise = globby(pattern, { cwd });
523
+ globToResMap.set(pattern, promise);
524
+ }
525
+ const files = await globToResMap.get(pattern);
526
+ return { files: files ?? [], baseDir };
527
+ }),
528
+ );
529
+ globToResMap.clear();
530
+
531
+ result.forEach(({ files, baseDir }) => {
532
+ files.forEach((file) => {
533
+ const sourcePath = path.resolve(cwd, file);
534
+ // Use posix separator for the relative paths. So devs can only specify globs with `/` even on Windows.
535
+ const pathSegments = file.split(posixSep);
536
+ const relativePath =
537
+ // Use index 2 (when required) since users can also specify paths like `./src/index.js`
538
+ pathSegments.slice(pathSegments[0] === '.' ? 2 : 1).join(posixSep) || file;
539
+ const targetPath = baseDir
540
+ ? path.resolve(buildDir, baseDir, relativePath)
541
+ : path.resolve(buildDir, relativePath);
542
+ defaultFiles.push({ sourcePath, targetPath });
543
+ });
544
+ });
545
+ }
546
+
547
+ if (!defaultFiles.length) {
548
+ if (verbose) {
549
+ console.log('⓿ No files to copy.');
550
+ }
551
+ }
552
+ await mapConcurrently(
553
+ defaultFiles,
554
+ async (file) => {
555
+ if (typeof file === 'string') {
556
+ const sourcePath = file;
557
+ const fileName = path.basename(file);
558
+ const targetPath = path.join(buildDir, fileName);
559
+ await recursiveCopy({ source: sourcePath, target: targetPath, verbose });
560
+ } else {
561
+ await fs.mkdir(path.dirname(file.targetPath), { recursive: true });
562
+ await recursiveCopy({ source: file.sourcePath, target: file.targetPath, verbose });
563
+ }
564
+ },
565
+ 20,
566
+ );
567
+ console.log(`📋 Copied ${defaultFiles.length} files.`);
568
+ }
569
+
570
+ /**
571
+ * Recursively copies files and directories from a source path to a target path.
572
+ *
573
+ * @async
574
+ * @param {Object} options - The options for copying files.
575
+ * @param {string} options.source - The source path to copy from.
576
+ * @param {string} options.target - The target path to copy to.
577
+ * @param {boolean} [options.verbose=true] - If true, suppresses console output.
578
+ * @returns {Promise<boolean>} Resolves when the copy operation is complete.
579
+ * @throws {Error} Throws if an error occurs other than the source not existing.
580
+ */
581
+ async function recursiveCopy({ source, target, verbose = true }) {
582
+ try {
583
+ await fs.cp(source, target, { recursive: true });
584
+ if (verbose) {
585
+ console.log(`Copied ${source} to ${target}`);
586
+ }
587
+ return true;
588
+ } catch (err) {
589
+ if (/** @type {{ code: string }} */ (err).code !== 'ENOENT') {
590
+ throw err;
591
+ }
592
+ if (verbose) {
593
+ console.warn(`Source does not exist: ${source}`);
594
+ }
595
+ throw err;
596
+ }
597
+ }
@@ -2,6 +2,7 @@ import { findWorkspaceDir } from '@pnpm/find-workspace-dir';
2
2
  import { globby } from 'globby';
3
3
  import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
+ import { mapConcurrently } from '../utils/build.mjs';
5
6
 
6
7
  /**
7
8
  * @typedef {Object} Args
@@ -105,20 +106,13 @@ async function processGlobs({ globs, cwd, silent = true, buildDir }) {
105
106
  });
106
107
  });
107
108
 
108
- const concurrency = filesToProcess.length > 100 ? 100 : filesToProcess.length;
109
- const iterator = filesToProcess[Symbol.iterator]();
110
- const workers = [];
111
- for (let i = 0; i < concurrency; i += 1) {
112
- workers.push(
113
- Promise.resolve().then(async () => {
114
- for (const file of iterator) {
115
- // eslint-disable-next-line no-await-in-loop
116
- await recursiveCopy({ source: file.sourcePath, target: file.targetPath, silent });
117
- }
118
- }),
119
- );
120
- }
121
- await Promise.all(workers);
109
+ await mapConcurrently(
110
+ filesToProcess,
111
+ async (file) => {
112
+ await recursiveCopy({ source: file.sourcePath, target: file.targetPath, silent });
113
+ },
114
+ 50,
115
+ );
122
116
  return filesToProcess.length;
123
117
  }
124
118
 
@@ -0,0 +1,41 @@
1
+ /* eslint-disable no-console */
2
+
3
+ import { markFn, measureFn } from '../utils/build.mjs';
4
+
5
+ /**
6
+ * @typedef {import('../utils/extractErrorCodes.mjs').Args} Args
7
+ */
8
+
9
+ export default /** @type {import('yargs').CommandModule<{}, Args>} */ ({
10
+ command: 'extract-error-codes',
11
+ describe: 'Extracts error codes from package(s).',
12
+ builder(yargs) {
13
+ return yargs
14
+ .option('errorCodesPath', {
15
+ type: 'string',
16
+ describe: 'The output path to a json file to write the extracted error codes.',
17
+ demandOption: true,
18
+ })
19
+ .option('detection', {
20
+ type: 'string',
21
+ describe: 'The detection strategy to use when extracting error codes.',
22
+ choices: ['opt-in', 'opt-out'],
23
+ default: 'opt-in',
24
+ })
25
+ .option('skip', {
26
+ type: 'array',
27
+ describe: 'List of package names to skip.',
28
+ default: [],
29
+ });
30
+ },
31
+ async handler(args) {
32
+ const commandName = /** @type {string} */ (args._[0]);
33
+ await markFn(commandName, async () => {
34
+ const module = await import('../utils/extractErrorCodes.mjs');
35
+ await module.default(args);
36
+ });
37
+ console.log(
38
+ `✅ Extracted error codes in ${(measureFn(commandName).duration / 1000.0).toFixed(3)}s`,
39
+ );
40
+ },
41
+ });
@@ -4,6 +4,7 @@ import chalk from 'chalk';
4
4
  import fs from 'node:fs/promises';
5
5
  import { globby } from 'globby';
6
6
  import path from 'node:path';
7
+ import { mapConcurrently } from '../utils/build.mjs';
7
8
 
8
9
  /**
9
10
  * @typedef {Object} Args
@@ -42,33 +43,25 @@ export default /** @type {import('yargs').CommandModule<{}, Args>} */ ({
42
43
  followSymbolicLinks: false,
43
44
  });
44
45
 
45
- const fileIterator = filenames[Symbol.iterator]();
46
- const concurrency = Math.min(20, filenames.length);
47
46
  let passed = true;
48
- const workers = [];
49
47
 
50
- for (let i = 0; i < concurrency; i += 1) {
51
- // eslint-disable-next-line @typescript-eslint/no-loop-func
52
- const worker = Promise.resolve().then(async () => {
53
- for (const filename of fileIterator) {
54
- // eslint-disable-next-line no-await-in-loop
55
- const content = await fs.readFile(path.join(cwd, filename), { encoding: 'utf8' });
56
- try {
57
- JSON.parse(content);
58
- if (!args.silent) {
59
- // eslint-disable-next-line no-console
60
- console.log(passMessage(filename));
61
- }
62
- } catch (error) {
63
- passed = false;
64
- console.error(failMessage(`Error parsing ${filename}:\n\n${String(error)}`));
48
+ await mapConcurrently(
49
+ filenames,
50
+ async (filename) => {
51
+ const content = await fs.readFile(path.join(cwd, filename), { encoding: 'utf8' });
52
+ try {
53
+ JSON.parse(content);
54
+ if (!args.silent) {
55
+ // eslint-disable-next-line no-console
56
+ console.log(passMessage(filename));
65
57
  }
58
+ } catch (error) {
59
+ passed = false;
60
+ console.error(failMessage(`Error parsing ${filename}:\n\n${String(error)}`));
66
61
  }
67
- });
68
- workers.push(worker);
69
- }
70
-
71
- await Promise.allSettled(workers);
62
+ },
63
+ 20,
64
+ );
72
65
  if (!passed) {
73
66
  throw new Error('❌ At least one file did not pass. Check the console output');
74
67
  }
package/src/cli/index.mjs CHANGED
@@ -5,6 +5,7 @@ import { hideBin } from 'yargs/helpers';
5
5
  import cmdArgosPush from './cmdArgosPush.mjs';
6
6
  import cmdBuild from './cmdBuild.mjs';
7
7
  import cmdCopyFiles from './cmdCopyFiles.mjs';
8
+ import cmdExtractErrorCodes from './cmdExtractErrorCodes.mjs';
8
9
  import cmdJsonLint from './cmdJsonLint.mjs';
9
10
  import cmdListWorkspaces from './cmdListWorkspaces.mjs';
10
11
  import cmdPublish from './cmdPublish.mjs';
@@ -15,14 +16,16 @@ const pkgJson = createRequire(import.meta.url)('../../package.json');
15
16
 
16
17
  yargs()
17
18
  .scriptName('code-infra')
19
+ .usage('$0 <command> [args]')
20
+ .command(cmdArgosPush)
21
+ .command(cmdBuild)
22
+ .command(cmdCopyFiles)
23
+ .command(cmdExtractErrorCodes)
24
+ .command(cmdJsonLint)
25
+ .command(cmdListWorkspaces)
18
26
  .command(cmdPublish)
19
27
  .command(cmdPublishCanary)
20
- .command(cmdListWorkspaces)
21
- .command(cmdJsonLint)
22
- .command(cmdArgosPush)
23
28
  .command(cmdSetVersionOverrides)
24
- .command(cmdCopyFiles)
25
- .command(cmdBuild)
26
29
  .demandCommand(1, 'You need at least one command before moving on')
27
30
  .strict()
28
31
  .help()
@@ -8,6 +8,7 @@ import { globby } from 'globby';
8
8
  import * as fs from 'node:fs/promises';
9
9
  import * as os from 'node:os';
10
10
  import * as path from 'node:path';
11
+ import { mapConcurrently } from '../utils/build.mjs';
11
12
 
12
13
  const $$ = $({ stdio: 'inherit' });
13
14
 
@@ -27,7 +28,8 @@ export async function emitDeclarations(tsconfig, outDir) {
27
28
  --emitDeclarationOnly
28
29
  --noEmit false
29
30
  --composite false
30
- --incremental false`;
31
+ --incremental false
32
+ --declarationMap false`;
31
33
  }
32
34
 
33
35
  /**
@@ -80,8 +82,9 @@ async function postProcessDeclarations({ directory }) {
80
82
  [pluginRemoveImports, { test: /\.css$/ }],
81
83
  ];
82
84
 
83
- await Promise.all(
84
- dtsFiles.map(async (dtsFile) => {
85
+ await mapConcurrently(
86
+ dtsFiles,
87
+ async (dtsFile) => {
85
88
  const result = await babel.transformFileAsync(dtsFile, {
86
89
  configFile: false,
87
90
  plugins: babelPlugins,
@@ -92,7 +95,8 @@ async function postProcessDeclarations({ directory }) {
92
95
  } else {
93
96
  console.error('failed to transform', dtsFile);
94
97
  }
95
- }),
98
+ },
99
+ 20,
96
100
  );
97
101
  }
98
102
 
@@ -107,11 +111,13 @@ async function renameDeclarations({ directory }) {
107
111
  return;
108
112
  }
109
113
  console.log(`Renaming d.ts files to d.mts in ${directory}`);
110
- await Promise.all(
111
- dtsFiles.map(async (dtsFile) => {
114
+ await mapConcurrently(
115
+ dtsFiles,
116
+ async (dtsFile) => {
112
117
  const newFileName = dtsFile.replace(/\.d\.ts$/, '.d.mts');
113
118
  await fs.rename(dtsFile, newFileName);
114
- }),
119
+ },
120
+ 20,
115
121
  );
116
122
  }
117
123
 
@@ -411,7 +411,10 @@ export function createCoreConfig(options = {}) {
411
411
  'material-ui/no-styled-box': 'error',
412
412
  'material-ui/straight-quotes': 'off',
413
413
 
414
- 'react-hooks/exhaustive-deps': ['error', { additionalHooks: 'useEnhancedEffect' }],
414
+ 'react-hooks/exhaustive-deps': [
415
+ 'error',
416
+ { additionalHooks: '(useEnhancedEffect|useIsoLayoutEffect)' },
417
+ ],
415
418
  'react-hooks/rules-of-hooks': 'error',
416
419
 
417
420
  'react/default-props-match-prop-types': [
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @typedef {[string, string]} Attr
3
+ */
4
+
5
+ /**
6
+ * @typedef {Object} Token
7
+ * @property {string} type
8
+ * @property {string} info
9
+ * @property {string} tag
10
+ * @property {string} content
11
+ * @property {number} lineNumber
12
+ * @property {Attr[]} attrs
13
+ */
14
+
15
+ /**
16
+ * @typedef {Object} OnErrorObj
17
+ * @property {number} lineNumber
18
+ * @property {string} [detail]
19
+ */
20
+
21
+ /**
22
+ * @typedef {(err: OnErrorObj) => void} OnError
23
+ */
24
+
25
+ /**
26
+ * @typedef {Object} MdParams
27
+ * @property {string} name
28
+ * @property {string[]} lines
29
+ * @property {Token[]} tokens
30
+ */
31
+
32
+ // This rule is an extension of MD025/no-multiple-top-level-headings.
33
+ // The rule is buggy https://github.com/DavidAnson/markdownlint/pull/1109
34
+ // but also blog headers don't tell you that h1 is already injected.
35
+ export default {
36
+ names: ['duplicateH1'],
37
+ description: 'Multiple top-level headings in the same document.',
38
+ tags: ['headings'],
39
+ /**
40
+ * @param {import('./duplicate-h1.mjs').MdParams} params
41
+ * @param {import('./duplicate-h1.mjs').OnError} onError
42
+ */
43
+ function: (params, onError) => {
44
+ /**
45
+ * @type {number|boolean}
46
+ */
47
+ let hasTopLevelHeading = false;
48
+ params.tokens.forEach((token) => {
49
+ if (token.type === 'heading_open' && token.tag === 'h1') {
50
+ // Avoid duplicate errors with MD025.
51
+ if (hasTopLevelHeading !== false && hasTopLevelHeading !== 1) {
52
+ onError({
53
+ lineNumber: token.lineNumber,
54
+ });
55
+ } else if (params.name.includes('/docs/pages/blog/')) {
56
+ onError({
57
+ lineNumber: token.lineNumber,
58
+ detail: 'In the blog, the h1 is already added using the markdown header.title value.',
59
+ });
60
+ }
61
+
62
+ // Store the first h1 of the page.
63
+ if (hasTopLevelHeading === false) {
64
+ hasTopLevelHeading = token.lineNumber;
65
+ }
66
+ }
67
+ });
68
+ },
69
+ };
@@ -0,0 +1,31 @@
1
+ export default {
2
+ names: ['gitDiff'],
3
+ description: 'Respect the format output of git diff.',
4
+ tags: ['spaces'],
5
+ /**
6
+ * @param {import('./duplicate-h1.mjs').MdParams} params
7
+ * @param {import('./duplicate-h1.mjs').OnError} onError
8
+ */
9
+ function: (params, onError) => {
10
+ params.tokens.forEach((token) => {
11
+ if (token.type === 'fence' && token.info === 'diff') {
12
+ token.content.split('\n').forEach((line, index) => {
13
+ if (
14
+ line[0] !== ' ' &&
15
+ line[0] !== '-' &&
16
+ line[0] !== '+' &&
17
+ line !== '' &&
18
+ line.indexOf('@@ ') !== 0 &&
19
+ line.indexOf('diff --git ') !== 0 &&
20
+ line.indexOf('index ') !== 0
21
+ ) {
22
+ onError({
23
+ lineNumber: token.lineNumber + index + 1,
24
+ detail: `The line start with "+" or "-" or " ": ${line}`,
25
+ });
26
+ }
27
+ });
28
+ }
29
+ });
30
+ },
31
+ };
@@ -0,0 +1,62 @@
1
+ import straightQuotes from './straight-quotes.mjs';
2
+ import gitDiff from './git-diff.mjs';
3
+ import tableAlignment from './table-alignment.mjs';
4
+ import terminalLanguage from './terminal-language.mjs';
5
+ import duplicateH1 from './duplicate-h1.mjs';
6
+
7
+ /**
8
+ * Create a base configuration for markdownlint.
9
+ * @param {Object} options
10
+ * @param {(typeof straightQuotes)[]} [options.rules] - An array of custom rules to include.
11
+ * @param {string[]} [options.ignores] - An array of glob patterns to ignore.
12
+ * @returns
13
+ */
14
+ export function createBaseConfig(options = {}) {
15
+ const { rules = [], ignores = [] } = options;
16
+ // https://github.com/DavidAnson/markdownlint#rules--aliases
17
+ return {
18
+ config: {
19
+ default: true,
20
+ MD004: false, // MD004/ul-style. Buggy
21
+ MD009: {
22
+ // MD009/no-trailing-spaces
23
+ br_spaces: 0,
24
+ strict: true,
25
+ list_item_empty_lines: false,
26
+ },
27
+ MD013: false, // MD013/line-length. Already handled by Prettier.
28
+ MD014: false, // MD014/commands-show-output. It's OK.
29
+ MD024: { siblings_only: true }, // MD024/no-duplicate-heading/no-duplicate-header
30
+ MD025: {
31
+ // Heading level
32
+ level: 1,
33
+ // RegExp for matching title in front matter
34
+ front_matter_title: '',
35
+ },
36
+ MD033: false, // MD033/no-inline-html. We use it from time to time, it's fine.
37
+ MD034: false, // MD034/no-bare-urls. Not a concern for us, our Markdown interpreter supports it.
38
+ MD028: false, // MD028/no-blanks-blockquote prevent double blockquote
39
+ MD029: false, // MD029/ol-prefix. Buggy
40
+ MD031: false, // MD031/blanks-around-fences Some code blocks inside li
41
+ MD036: false, // MD036/no-emphasis-as-heading/no-emphasis-as-header. It's OK.
42
+ MD051: false, // MD051/link-fragments. Many false positives in the changelog.
43
+ MD052: false, // MD052/reference-links-images. Many false positives in the changelog.
44
+ MD059: false, // MD059/descriptive-link-text. Does not allow links on text like "link", whereas we redirect to "Link" component.
45
+ straightQuotes: true,
46
+ gitDiff: true,
47
+ tableAlignment: true,
48
+ terminalLanguage: true,
49
+ duplicateH1: true,
50
+ },
51
+ customRules: [straightQuotes, gitDiff, tableAlignment, terminalLanguage, duplicateH1, ...rules],
52
+ ignores: [
53
+ 'CHANGELOG.old.md',
54
+ '**/node_modules/**',
55
+ '**/build/**',
56
+ '.github/PULL_REQUEST_TEMPLATE.md',
57
+ 'docs/public/**',
58
+ 'docs/export/**',
59
+ ...ignores,
60
+ ],
61
+ };
62
+ }
@@ -0,0 +1,26 @@
1
+ const nonStraightQuotes = /[‘’“”]/;
2
+
3
+ export default {
4
+ names: ['straightQuotes'],
5
+ description: 'Only allow straight quotes.',
6
+ tags: ['spelling'],
7
+ /**
8
+ * @param {import('./duplicate-h1.mjs').MdParams} params
9
+ * @param {import('./duplicate-h1.mjs').OnError} onError
10
+ */
11
+ function: (params, onError) => {
12
+ params.lines.forEach((line, lineNumber) => {
13
+ // It will match
14
+ // opening single quote: \xE2\x80\x98
15
+ // closing single quote: \xE2\x80\x99
16
+ // opening double quote: \xE2\x80\x9C
17
+ // closing double quote: \xE2\x80\x9D
18
+ if (nonStraightQuotes.test(line)) {
19
+ onError({
20
+ lineNumber: lineNumber + 1,
21
+ detail: `For line: ${line}`,
22
+ });
23
+ }
24
+ });
25
+ },
26
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @param {import('./duplicate-h1.mjs').Attr[]} attrs
3
+ * @returns {Record<string, string>}
4
+ */
5
+ function attr(attrs) {
6
+ return (attrs || []).reduce((acc, item) => ({ ...acc, [item[0]]: item[1] }), {});
7
+ }
8
+
9
+ export default {
10
+ names: ['tableAlignment'],
11
+ description: 'Set table alignment.',
12
+ tags: ['table'],
13
+ /**
14
+ * @param {import('./duplicate-h1.mjs').MdParams} params
15
+ * @param {import('./duplicate-h1.mjs').OnError} onError
16
+ */
17
+ function: (params, onError) => {
18
+ params.tokens.forEach((token) => {
19
+ // This is wrong:
20
+ // | Version | Supported |
21
+ // | ------- | ------------------ |
22
+ //
23
+ // The second column should be left aligned because it contains text:
24
+ // | Version | Supported |
25
+ // | ------- | :----------------- |
26
+ //
27
+ // However, columns that includes numbers should be right aligned:
28
+ // | Version | Supported |
29
+ // | ------: | :----------------- |
30
+ //
31
+ // More details: https://ux.stackexchange.com/questions/24066/what-is-the-best-practice-for-data-table-cell-content-alignment
32
+ //
33
+ // In this check we expect the style to be 'text-align:right' or equivalent.
34
+ if (token.type === 'th_open' && attr(token.attrs).style == null) {
35
+ onError({
36
+ lineNumber: token.lineNumber,
37
+ detail: `${params.lines[token.lineNumber - 1]}`,
38
+ });
39
+ }
40
+ });
41
+ },
42
+ };
@@ -0,0 +1,19 @@
1
+ export default {
2
+ names: ['terminalLanguage'],
3
+ description: 'Set the right language for terminal code.',
4
+ tags: ['code'],
5
+ /**
6
+ * @param {import('./duplicate-h1.mjs').MdParams} params
7
+ * @param {import('./duplicate-h1.mjs').OnError} onError
8
+ */
9
+ function: (params, onError) => {
10
+ params.tokens.forEach((token) => {
11
+ if (token.type === 'fence' && token.info === 'sh') {
12
+ onError({
13
+ lineNumber: token.lineNumber,
14
+ detail: `Use "bash" instead of "sh".`,
15
+ });
16
+ }
17
+ });
18
+ },
19
+ };
@@ -102,6 +102,13 @@ declare module '@babel/plugin-transform-runtime' {
102
102
  export default plugin;
103
103
  }
104
104
 
105
+ declare module '@babel/plugin-syntax-jsx' {
106
+ import type { PluginItem } from '@babel/core';
107
+
108
+ declare const plugin: PluginItem;
109
+ export default plugin;
110
+ }
111
+
105
112
  declare module '@babel/plugin-syntax-typescript' {
106
113
  import type { PluginItem } from '@babel/core';
107
114
 
@@ -68,3 +68,82 @@ export function validatePkgJson(packageJson, options = {}) {
68
68
  throw error;
69
69
  }
70
70
  }
71
+
72
+ /**
73
+ * Marks the start and end of a function execution for performance measurement.
74
+ * Uses the Performance API to create marks and measure the duration.
75
+ * @function
76
+ * @template {() => Promise<any>} F
77
+ * @param {string} label
78
+ * @param {() => ReturnType<F>} fn
79
+ * @returns {Promise<ReturnType<F>>}
80
+ */
81
+ export async function markFn(label, fn) {
82
+ const startMark = `${label}-start`;
83
+ const endMark = `${label}-end`;
84
+ performance.mark(startMark);
85
+ const result = await fn();
86
+ performance.mark(endMark);
87
+ performance.measure(label, startMark, endMark);
88
+ return result;
89
+ }
90
+
91
+ /**
92
+ * @param {string} label
93
+ */
94
+ export function measureFn(label) {
95
+ const startMark = `${label}-start`;
96
+ const endMark = `${label}-end`;
97
+ return performance.measure(label, startMark, endMark);
98
+ }
99
+
100
+ export const BASE_IGNORES = [
101
+ '**/*.test.js',
102
+ '**/*.test.ts',
103
+ '**/*.test.tsx',
104
+ '**/*.spec.js',
105
+ '**/*.spec.ts',
106
+ '**/*.spec.tsx',
107
+ '**/*.d.ts',
108
+ '**/*.test/*.*',
109
+ '**/test-cases/*.*',
110
+ ];
111
+
112
+ /**
113
+ * A utility to map a function over an array of items in a worker pool.
114
+ *
115
+ * This function will create a pool of workers and distribute the items to be processed among them.
116
+ * Each worker will process items sequentially, but multiple workers will run in parallel.
117
+ *
118
+ * @function
119
+ * @template T
120
+ * @template R
121
+ * @param {T[]} items
122
+ * @param {(item: T) => Promise<R>} mapper
123
+ * @param {number} concurrency
124
+ * @returns {Promise<(R|Error)[]>}
125
+ */
126
+ export async function mapConcurrently(items, mapper, concurrency) {
127
+ if (!items.length) {
128
+ return Promise.resolve([]); // nothing to do
129
+ }
130
+ const itemIterator = items.entries();
131
+ const count = Math.min(concurrency, items.length);
132
+ const workers = [];
133
+ /**
134
+ * @type {(R|Error)[]}
135
+ */
136
+ const results = new Array(items.length);
137
+ for (let i = 0; i < count; i += 1) {
138
+ const worker = Promise.resolve().then(async () => {
139
+ for (const [index, item] of itemIterator) {
140
+ // eslint-disable-next-line no-await-in-loop
141
+ const res = await mapper(item);
142
+ results[index] = res;
143
+ }
144
+ });
145
+ workers.push(worker);
146
+ }
147
+ await Promise.all(workers);
148
+ return results;
149
+ }
@@ -0,0 +1,168 @@
1
+ /* eslint-disable no-console */
2
+ import { types as babelTypes, parseAsync, traverse } from '@babel/core';
3
+ import babelSyntaxJsx from '@babel/plugin-syntax-jsx';
4
+ import babelSyntaxTypescript from '@babel/plugin-syntax-typescript';
5
+ import { findMessageNode } from '@mui/internal-babel-plugin-minify-errors';
6
+ import { globby } from 'globby';
7
+ import * as fs from 'node:fs/promises';
8
+ import * as path from 'node:path';
9
+
10
+ import { getWorkspacePackages } from '../cli/pnpm.mjs';
11
+ import { BASE_IGNORES, mapConcurrently } from './build.mjs';
12
+
13
+ /**
14
+ * @typedef {Object} Args
15
+ * @property {string} errorCodesPath - The output path to write the extracted error codes.
16
+ * @property {string[]} [skip=[]] - List of package names to skip. By default, all workspace packages are considered.
17
+ * @property {import('@mui/internal-babel-plugin-minify-errors').Options['detection']} [detection='opt-in'] - The detection strategy to use when extracting error codes.
18
+ */
19
+
20
+ /**
21
+ * Gets all relevant files for a package to parse.
22
+ *
23
+ * @param {import('../cli/pnpm.mjs').PublicPackage} pkg
24
+ * @returns {Promise<string[]>} An array of file paths.
25
+ */
26
+ async function getFilesForPackage(pkg) {
27
+ const srcPath = path.join(pkg.path, 'src');
28
+ const srcPathExists = await fs
29
+ .stat(srcPath)
30
+ .then((stat) => stat.isDirectory())
31
+ .catch(() => false);
32
+ // Implementation to extract error codes from all files in the directory
33
+ const cwd = srcPathExists ? srcPath : pkg.path;
34
+ const files = await globby('**/*.{js,ts,jsx,tsx,cjs,mjs,cts}', {
35
+ ignore: [
36
+ '**/node_modules/**',
37
+ '**/dist/**',
38
+ '**/build/**',
39
+ 'scripts',
40
+ '**/__{tests,fixtures,mock,mocks}__/**',
41
+ ...BASE_IGNORES,
42
+ ],
43
+ cwd,
44
+ });
45
+ return files.map((file) => path.join(cwd, file));
46
+ }
47
+
48
+ /**
49
+ * Extracts error codes from all files in a directory.
50
+ * @param {string[]} files
51
+ * @param {Set<string>} errors
52
+ * @param {import('@mui/internal-babel-plugin-minify-errors').Options['detection']} [detection='opt-in']
53
+ */
54
+ async function extractErrorCodesForWorkspace(files, errors, detection = 'opt-in') {
55
+ await mapConcurrently(
56
+ files,
57
+ async (fullPath) => {
58
+ const code = await fs.readFile(fullPath, 'utf8');
59
+ const ast = await parseAsync(code, {
60
+ filename: fullPath,
61
+ sourceType: 'module',
62
+ plugins: [[babelSyntaxTypescript, { isTSX: true }], [babelSyntaxJsx]],
63
+ configFile: false,
64
+ babelrc: false,
65
+ browserslistConfigFile: false,
66
+ code: false,
67
+ });
68
+ if (!ast) {
69
+ throw new Error(`Failed to parse ${fullPath}`);
70
+ }
71
+ traverse(ast, {
72
+ NewExpression(newExpressionPath) {
73
+ const { message } =
74
+ findMessageNode(babelTypes, newExpressionPath, {
75
+ detection,
76
+ missingError: 'annotate',
77
+ }) ?? {};
78
+ if (message) {
79
+ errors.add(message.message);
80
+ }
81
+ },
82
+ });
83
+ },
84
+ 30,
85
+ );
86
+ }
87
+
88
+ /**
89
+ * Extracts error codes from all workspace packages.
90
+ * @param {Args} args
91
+ */
92
+ export default async function extractErrorCodes(args) {
93
+ /**
94
+ * @type {Set<string>}
95
+ */
96
+ const errors = new Set();
97
+
98
+ // Find relevant files
99
+
100
+ const basePackages = await getWorkspacePackages({
101
+ publicOnly: true,
102
+ });
103
+ const { skip: skipPackages = [], errorCodesPath, detection = 'opt-in' } = args;
104
+ const packages = basePackages.filter(
105
+ (pkg) =>
106
+ // Ignore obvious packages that do not have user-facing errors
107
+ !pkg.name.startsWith('@mui/internal-') &&
108
+ !pkg.name.startsWith('@mui-internal/') &&
109
+ !skipPackages.includes(pkg.name),
110
+ );
111
+ const files = await Promise.all(packages.map((pkg) => getFilesForPackage(pkg)));
112
+ packages.forEach((pkg, index) => {
113
+ console.log(
114
+ `🔍 ${pkg.name}: Found ${files[index].length} file${files[index].length > 1 ? 's' : ''}`,
115
+ );
116
+ });
117
+
118
+ // Extract error codes from said files.
119
+ const filesToProcess = files.flat();
120
+ console.log(`🔍 Extracting error codes from ${filesToProcess.length} files...`);
121
+ await extractErrorCodesForWorkspace(filesToProcess, errors, detection);
122
+
123
+ // Write error codes to the specified file.
124
+ const errorCodeFilePath = path.resolve(errorCodesPath);
125
+ const fileExists = await fs
126
+ .stat(errorCodeFilePath)
127
+ .then((stat) => stat.isFile())
128
+ .catch((ex) => {
129
+ if (ex.code === 'ENOENT') {
130
+ return false;
131
+ }
132
+ return new Error(ex.message);
133
+ });
134
+
135
+ if (fileExists instanceof Error) {
136
+ throw fileExists;
137
+ }
138
+ /**
139
+ * @type {Record<string, string>}
140
+ */
141
+ const existingErrorCodes =
142
+ fileExists === true ? JSON.parse(await fs.readFile(errorCodeFilePath, 'utf-8')) : {};
143
+ const inverseLookupCode = new Map(
144
+ Object.entries(existingErrorCodes).map(([key, value]) => [value, Number(key)]),
145
+ );
146
+ const originalErrorCount = inverseLookupCode.size;
147
+ Array.from(errors).forEach((error) => {
148
+ if (!inverseLookupCode.has(error)) {
149
+ inverseLookupCode.set(error, inverseLookupCode.size + 1);
150
+ }
151
+ });
152
+ const finalErrorCodes = Array.from(inverseLookupCode.entries()).reduce((acc, [message, code]) => {
153
+ acc[code] = message;
154
+ return acc;
155
+ }, /** @type {Record<string, string>} */ ({}));
156
+ if (!fileExists) {
157
+ await fs.mkdir(path.dirname(errorCodeFilePath), { recursive: true });
158
+ }
159
+ const newErrorCount = inverseLookupCode.size - originalErrorCount;
160
+ if (newErrorCount === 0) {
161
+ console.log(`✅ No new error codes found.`);
162
+ } else {
163
+ console.log(
164
+ `📝 Wrote ${newErrorCount} new error code${newErrorCount > 1 ? 's' : ''} to "${errorCodesPath}"`,
165
+ );
166
+ await fs.writeFile(errorCodeFilePath, `${JSON.stringify(finalErrorCodes, null, 2)}\n`);
167
+ }
168
+ }