diff --git a/.babelrc b/.babelrc index 332bba6f9..4b5a10170 100644 --- a/.babelrc +++ b/.babelrc @@ -5,6 +5,9 @@ ["@babel/preset-env", { "modules": false }] + ], + "plugins": [ + "./babel-plugin-es-import-extensions" ] }, "development": { diff --git a/babel-plugin-es-import-extensions.js b/babel-plugin-es-import-extensions.js new file mode 100644 index 000000000..ae2d91a92 --- /dev/null +++ b/babel-plugin-es-import-extensions.js @@ -0,0 +1,38 @@ +/* + * Babel plugin used by the ES module build (`npm run build:es`, see `.babelrc`). + * + * Node.js requires relative import paths in ES modules to include the file + * extension, so `import assertString from './util/assertString'` is rewritten + * to `import assertString from './util/assertString.js'`. + */ +const fs = require('fs'); +const path = require('path'); + +function isFile(file) { + try { + return fs.statSync(file).isFile(); + } catch (err) { + return false; + } +} + +module.exports = function esImportExtensions() { + function addExtension(nodePath, state) { + const { source } = nodePath.node; + if (!source || !/^\.\.?\//.test(source.value)) return; + const file = path.resolve(path.dirname(state.filename), source.value); + if (isFile(file)) return; + if (!isFile(`${file}.js`)) { + throw nodePath.buildCodeFrameError(`Cannot resolve "${source.value}" to a file`); + } + source.value = `${source.value}.js`; + } + + return { + visitor: { + ImportDeclaration: addExtension, + ExportNamedDeclaration: addExtension, + ExportAllDeclaration: addExtension, + }, + }; +}; diff --git a/test/esBuild.test.js b/test/esBuild.test.js new file mode 100644 index 000000000..e043fb767 --- /dev/null +++ b/test/esBuild.test.js @@ -0,0 +1,37 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; + +const esDir = path.join(__dirname, '..', 'es'); + +function listFiles(dir) { + return fs.readdirSync(dir).reduce((files, name) => { + const file = path.join(dir, name); + return files.concat(fs.statSync(file).isDirectory() ? listFiles(file) : [file]); + }, []); +} + +describe('ES module build', () => { + it('should use the file extension in relative import paths', () => { + const files = listFiles(esDir).filter(file => path.extname(file) === '.js'); + let checked = 0; + files.forEach((file) => { + const code = fs.readFileSync(file, 'utf8'); + const importRegex = /^(?:import|export)\s[^;]*?\sfrom\s+['"]([^'"]+)['"]/gm; + let match = importRegex.exec(code); + while (match) { + const specifier = match[1]; + if (specifier[0] === '.') { + checked += 1; + const target = path.resolve(path.dirname(file), specifier); + assert.ok( + path.extname(specifier) === '.js' && fs.existsSync(target) && fs.statSync(target).isFile(), + `${path.relative(esDir, file)} imports "${specifier}", which is not a path to a file` + ); + } + match = importRegex.exec(code); + } + }); + assert.ok(checked > 0, 'No relative imports found in the ES module build. Run `npm run build:es`'); + }); +});