Skip to content

Commit

Permalink
[code-infra] Babel plugin to fully resolve imported paths (#43294)
Browse files Browse the repository at this point in the history
  • Loading branch information
Janpot authored Aug 22, 2024
1 parent af6a748 commit f4a7047
Show file tree
Hide file tree
Showing 8 changed files with 248 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .codesandbox/ci.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"packages/mui-system",
"packages/mui-types",
"packages/mui-utils",
"packages-internal/babel-plugin-resolve-imports",
"packages-internal/docs-utils",
"packages-internal/scripts",
"packages-internal/test-utils"
Expand All @@ -36,6 +37,7 @@
"@mui/internal-docs-utils": "packages-internal/docs-utils",
"@mui/internal-markdown": "packages/markdown",
"@mui/internal-scripts": "packages-internal/scripts",
"@mui/internal-babel-plugin-resolve-imports": "packages-internal/babel-plugin-resolve-imports",
"@mui/lab": "packages/mui-lab/build",
"@mui/material-nextjs": "packages/mui-material-nextjs/build",
"@mui/material": "packages/mui-material/build",
Expand Down
37 changes: 37 additions & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
// @ts-check
const path = require('path');

/**
* @typedef {import('@babel/core')} babel
*/

const errorCodesPath = path.resolve(__dirname, './docs/public/static/error-codes.json');
const missingError = process.env.MUI_EXTRACT_ERROR_CODES === 'true' ? 'write' : 'annotate';

/**
* @param {string} relativeToBabelConf
* @returns {string}
*/
function resolveAliasPath(relativeToBabelConf) {
const resolvedPath = path.relative(process.cwd(), path.resolve(__dirname, relativeToBabelConf));
return `./${resolvedPath.replace('\\', '/')}`;
}

/** @type {babel.PluginItem[]} */
const productionPlugins = [
['babel-plugin-react-remove-properties', { properties: ['data-mui-test'] }],
];

/** @type {babel.ConfigFunction} */
module.exports = function getBabelConfig(api) {
const useESModules = api.env(['regressions', 'modern', 'stable']);

Expand Down Expand Up @@ -56,6 +67,16 @@ module.exports = function getBabelConfig(api) {
'@babel/preset-typescript',
];

const usesAliases =
// in this config:
api.env(['coverage', 'development', 'test', 'benchmark']) ||
process.env.NODE_ENV === 'test' ||
// in webpack config:
api.env(['regressions']);

const outFileExtension = '.js';

/** @type {babel.PluginItem[]} */
const plugins = [
[
'babel-plugin-macros',
Expand Down Expand Up @@ -94,6 +115,18 @@ module.exports = function getBabelConfig(api) {
],
},
],
...(useESModules
? [
[
'@mui/internal-babel-plugin-resolve-imports',
{
// Don't replace the extension when we're using aliases.
// Essentially only replace in production builds.
outExtension: usesAliases ? null : outFileExtension,
},
],
]
: []),
];

if (process.env.NODE_ENV === 'production') {
Expand Down Expand Up @@ -121,6 +154,10 @@ module.exports = function getBabelConfig(api) {
exclude: /\.test\.(js|ts|tsx)$/,
plugins: ['@babel/plugin-transform-react-constant-elements'],
},
{
test: /(\.test\.[^.]+$|\.test\/)/,
plugins: [['@mui/internal-babel-plugin-resolve-imports', false]],
},
],
env: {
coverage: {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
"@octokit/rest": "^21.0.2",
"@pigment-css/react": "0.0.20",
"@playwright/test": "1.46.1",
"@types/babel__core": "^7.20.5",
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.7",
"@types/mocha": "^10.0.7",
Expand Down
33 changes: 33 additions & 0 deletions packages-internal/babel-plugin-resolve-imports/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# @mui/internal-babel-plugin-resolve-imports

A babel plugin that resolves import specifiers that are created under the Node.js resolution algorithm to specifiers that adhere to ESM resolution algorithm.

See https://nodejs.org/docs/v20.16.0/api/esm.html#mandatory-file-extensions

> A file extension must be provided when using the import keyword to resolve relative or absolute specifiers. Directory indexes (For example './startup/index.js') must also be fully specified.
>
> This behavior matches how import behaves in browser environments, assuming a typically configured server.
This changes imports in the build output from

```tsx
// packages/mui-material/build/index.js
export * from './Accordion';

// packages/mui-material/build/Breadcrumbs/BreadcrumbCollapsed.js
import MoreHorizIcon from '../internal/svg-icons/MoreHoriz';
```

to

```tsx
// packages/mui-material/build/index.js
export * from './Accordion/index.js';

// packages/mui-material/build/Breadcrumbs/BreadcrumbCollapsed.js
import MoreHorizIcon from '../internal/svg-icons/MoreHoriz.js';
```

## options

- `outExtension`: The extension to use when writing the output. Careful: if not specified, this plugin does not replace extensions at all, your bundles will likely be broken. We left this optional to allow for using this plugin together with the aliasing to source that we do everywhere. That way we can keep it in the pipeline even when not strictly necessary.
115 changes: 115 additions & 0 deletions packages-internal/babel-plugin-resolve-imports/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// @ts-check
/// <reference path="./resolve.d.ts" />

const nodePath = require('path');
const resolve = require('resolve/sync');

/**
* @typedef {import('@babel/core')} babel
*/

/**
* Normalize a file path to POSIX in order for it to be platform-agnostic.
* @param {string} importPath
* @returns {string}
*/
function toPosixPath(importPath) {
return nodePath.normalize(importPath).split(nodePath.sep).join(nodePath.posix.sep);
}

/**
* Converts a file path to a node import specifier.
* @param {string} importPath
* @returns {string}
*/
function pathToNodeImportSpecifier(importPath) {
const normalized = toPosixPath(importPath);
return normalized.startsWith('/') || normalized.startsWith('.') ? normalized : `./${normalized}`;
}

/**
* @typedef {{ outExtension?: string }} Options
*/

/**
* @param {babel} file
* @param {Options} options
* @returns {babel.PluginObj}
*/
module.exports = function plugin({ types: t }, { outExtension }) {
/** @type {Map<string, string>} */
const cache = new Map();
const extensions = ['.ts', '.tsx', '.js', '.jsx'];
const extensionsSet = new Set(extensions);
return {
visitor: {
ImportOrExportDeclaration(path, state) {
if (path.isExportDefaultDeclaration()) {
// Can't export default from an import specifier
return;
}

if (
(path.isExportDeclaration() && path.node.exportKind === 'type') ||
(path.isImportDeclaration() && path.node.importKind === 'type')
) {
// Ignore type imports, they will get compiled away anyway
return;
}

const source =
/** @type {babel.NodePath<babel.types.StringLiteral | null | undefined> } */ (
path.get('source')
);

if (!source.node) {
// Ignore import without source
return;
}

const importedPath = source.node.value;

if (!importedPath.startsWith('.')) {
// Only handle relative imports
return;
}

if (!state.filename) {
throw new Error('filename is not defined');
}

const importerPath = state.filename;
const importerDir = nodePath.dirname(importerPath);
// start from fully resolved import path
const absoluteImportPath = nodePath.resolve(importerDir, importedPath);

let resolvedPath = cache.get(absoluteImportPath);

if (!resolvedPath) {
// resolve to actual file
resolvedPath = resolve(absoluteImportPath, { extensions });

if (!resolvedPath) {
throw new Error(`could not resolve "${importedPath}" from "${state.filename}"`);
}

const resolvedExtension = nodePath.extname(resolvedPath);
if (outExtension && extensionsSet.has(resolvedExtension)) {
// replace extension
resolvedPath = nodePath.resolve(
nodePath.dirname(resolvedPath),
nodePath.basename(resolvedPath, resolvedExtension) + outExtension,
);
}

cache.set(absoluteImportPath, resolvedPath);
}

const relativeResolvedPath = nodePath.relative(importerDir, resolvedPath);
const importSpecifier = pathToNodeImportSpecifier(relativeResolvedPath);

source.replaceWith(t.stringLiteral(importSpecifier));
},
},
};
};
30 changes: 30 additions & 0 deletions packages-internal/babel-plugin-resolve-imports/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@mui/internal-babel-plugin-resolve-imports",
"version": "1.0.16",
"author": "MUI Team",
"description": "babel plugin that resolves import specifiers to their actual output file.",
"main": "./index.js",
"exports": {
".": "./index.js"
},
"repository": {
"type": "git",
"url": "https://github.com/mui/material-ui.git",
"directory": "packages-internal/babel-plugin-resolve-imports"
},
"license": "MIT",
"scripts": {},
"dependencies": {
"resolve": "^1.22.8"
},
"devDependencies": {
"@types/resolve": "^1.20.6",
"@types/babel__core": "^7.20.5"
},
"peerDependencies": {
"@babel/core": "7"
},
"publishConfig": {
"access": "public"
}
}
6 changes: 6 additions & 0 deletions packages-internal/babel-plugin-resolve-imports/resolve.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
declare module 'resolve/sync' {
import { Opts } from 'resolve';

function resolve(id: string, options?: Opts): string;
export = resolve;
}
24 changes: 24 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit f4a7047

Please sign in to comment.