next-transpile-modules
Version:
Next.js plugin to transpile code from node_modules
286 lines (248 loc) • 11 kB
JavaScript
/**
* disclaimer:
*
* THIS PLUGIN IS A BIG HACK.
*
* Don't even try to reason about the quality of the following lines of code.
*
* ---
*
* We intentionally do not use experimental.transpilePackages (yet) as its API
* may change and we want to avoid breaking changes while the Next.js figures
* this all out.
*/
const path = require('path');
const process = require('process');
const enhancedResolve = require('enhanced-resolve');
// Use me when needed
// const util = require('util');
// const inspect = (object) => {
// console.log(util.inspect(object, { showHidden: false, depth: null }));
// };
const CWD = process.cwd();
/**
* Check if two regexes are equal
* Stolen from https://stackoverflow.com/questions/10776600/testing-for-equality-of-regular-expressions
*
* @param {RegExp} x
* @param {RegExp} y
* @returns {boolean}
*/
const regexEqual = (x, y) => {
return (
x instanceof RegExp &&
y instanceof RegExp &&
x.source === y.source &&
x.global === y.global &&
x.ignoreCase === y.ignoreCase &&
x.multiline === y.multiline
);
};
/**
* Logger for the debug mode
* @param {boolean} enable enable the logger or not
* @returns {(message: string, force: boolean) => void}
*/
const createLogger = (enable) => {
return (message, force) => {
if (enable || force) console.info(`next-transpile-modules - ${message}`);
};
};
/**
* Matcher function for webpack to decide which modules to transpile
* TODO: could be simplified
*
* @param {string[]} modulesToTranspile
* @param {function} logger
* @returns {(path: string) => boolean}
*/
const createWebpackMatcher = (modulesToTranspile, logger = createLogger(false)) => {
// create an array of tuples with each passed in module to transpile and its node_modules depth
// example: ['/full/path/to/node_modules/button/node_modules/icon', 2]
const modulePathsWithDepth = modulesToTranspile.map((modulePath) => [
modulePath,
(modulePath.match(/node_modules/g) || []).length,
]);
return (filePath) => {
const nodeModulesDepth = (filePath.match(/node_modules/g) || []).length;
return modulePathsWithDepth.some(([modulePath, moduleDepth]) => {
// Ensure we aren't implicitly transpiling nested dependencies by comparing depths of modules to be transpiled and the module being checked
const transpiled = filePath.startsWith(modulePath) && nodeModulesDepth === moduleDepth;
if (transpiled) logger(`transpiled: ${filePath}`);
return transpiled;
});
};
};
/**
* Transpile modules with Next.js Babel configuration
* @param {string[]} modules
* @param {{resolveSymlinks?: boolean, debug?: boolean, __unstable_matcher?: (path: string) => boolean}} options
*/
const withTmInitializer = (modules = [], options = {}) => {
/**
* @template T
* @param { T } nextConfig
* @returns { T | undefined }
*/
const withTM = (nextConfig = {}) => {
if (modules.length === 0) return nextConfig;
const resolveSymlinks = 'resolveSymlinks' in options ? options.resolveSymlinks : true;
const debug = options.debug || false;
const logger = createLogger(debug);
/**
* Our own Node.js resolver that can ignore symlinks resolution and can support
* PnP
*/
const resolve = enhancedResolve.create.sync({
symlinks: resolveSymlinks,
extensions: ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.css', '.scss', '.sass'],
mainFields: ['main', 'module', 'source'],
// Is it right? https://github.com/webpack/enhanced-resolve/issues/283#issuecomment-775162497
conditionNames: ['require'],
exportsFields: [], // we do that because 'package.json' is usually not present in exports
});
/**
* Return the root path (package.json directory) of a given module
* @param {string} module
* @returns {string}
*/
const getPackageRootDirectory = (module) => {
try {
const packageLookupDirectory = resolve(CWD, path.join(module, 'package.json'));
return path.dirname(packageLookupDirectory);
} catch (err) {
throw new Error(
`next-transpile-modules - an unexpected error happened when trying to resolve "${module}". Are you sure the name of the module you are trying to transpile is correct, and it has a package.json with a "main" or an "exports" field?\n${err}`,
);
}
};
logger(`trying to resolve the following modules:\n${modules.map((mod) => ` - ${mod}`).join('\n')}`);
// Resolve modules to their real paths
const modulesPaths = modules.map(getPackageRootDirectory);
logger(`the following paths will get transpiled:\n${modulesPaths.map((mod) => ` - ${mod}`).join('\n')}`);
// Generate Webpack condition for the passed modules
// https://webpack.js.org/configuration/module/#ruleinclude
const matcher = options.__unstable_matcher || createWebpackMatcher(modulesPaths, logger);
return Object.assign({}, nextConfig, {
webpack(config, options) {
// Safecheck for Next < 5.0
if (!options.defaultLoaders) {
throw new Error(
'This plugin is not compatible with Next.js versions below 5.0.0 https://err.sh/next-plugins/upgrade',
);
}
if (resolveSymlinks !== undefined) {
// Avoid Webpack to resolve transpiled modules path to their real path as
// we want to test modules from node_modules only. If it was enabled,
// modules in node_modules installed via symlink would then not be
// transpiled.
config.resolve.symlinks = resolveSymlinks;
}
// Since Next.js 8.1.0, config.externals is undefined
if (Array.isArray(config.externals)) {
config.externals = config.externals.map((external) => {
if (typeof external !== 'function') return external;
return async (options) => {
const externalResult = await external(options);
if (externalResult) {
try {
const resolve = options.getResolve();
const resolved = await resolve(options.context, options.request);
if (modulesPaths.some((mod) => resolved.startsWith(mod))) return;
} catch (e) {}
}
return externalResult;
};
});
}
// Add a rule to include and parse all modules (js & ts)
config.module.rules.push({
test: /\.+(js|jsx|mjs|ts|tsx)$/,
use: options.defaultLoaders.babel,
include: matcher,
type: 'javascript/auto',
});
if (resolveSymlinks === false) {
// IMPROVE ME: we are losing all the cache on node_modules, which is terrible
// The problem is managedPaths does not allow to isolate specific specific folders
config.snapshot = Object.assign(config.snapshot || {}, {
managedPaths: [],
});
}
// Support CSS modules + global in node_modules
// TODO ask Next.js maintainer to expose the css-loader via defaultLoaders
const nextCssLoaders = config.module.rules.find((rule) => typeof rule.oneOf === 'object');
// .module.css
if (nextCssLoaders) {
const nextCssLoader = nextCssLoaders.oneOf.find(
(rule) => rule.sideEffects === false && regexEqual(rule.test, /\.module\.css$/),
);
const nextSassLoader = nextCssLoaders.oneOf.find(
(rule) => rule.sideEffects === false && regexEqual(rule.test, /\.module\.(scss|sass)$/),
);
// backwards compatibility with Next.js 13.0 (broke in 13.0.1)
// https://github.com/vercel/next.js/pull/42106/files
if (nextCssLoader && nextCssLoader.issuer != null) {
nextCssLoader.issuer.or = nextCssLoader.issuer.and ? nextCssLoader.issuer.and.concat(matcher) : matcher;
delete nextCssLoader.issuer.not;
delete nextCssLoader.issuer.and;
}
// backwards compatibility with Next.js 13.0 (broke in 13.0.1)
// https://github.com/vercel/next.js/pull/42106/files
if (nextSassLoader && nextSassLoader.issuer != null) {
nextSassLoader.issuer.or = nextSassLoader.issuer.and ? nextSassLoader.issuer.and.concat(matcher) : matcher;
delete nextSassLoader.issuer.not;
delete nextSassLoader.issuer.and;
}
}
// Add support for Global CSS imports in transpiled modules
if (nextCssLoaders) {
const nextGlobalCssLoader = nextCssLoaders.oneOf.find(
(rule) => rule.sideEffects === true && regexEqual(rule.test, /(?<!\.module)\.css$/),
);
if (nextGlobalCssLoader) {
nextGlobalCssLoader.issuer = {
or: nextGlobalCssLoader.issuer ? [matcher, nextGlobalCssLoader.issuer] : [matcher],
};
nextGlobalCssLoader.include = {
or: nextGlobalCssLoader.include ? [...modulesPaths, nextGlobalCssLoader.include] : modulesPaths,
};
} else if (!options.isServer) {
// Note that Next.js ignores global CSS imports on the server
console.warn('next-transpile-modules - could not find default CSS rule, global CSS imports may not work');
}
const nextGlobalSassLoader = nextCssLoaders.oneOf.find(
(rule) => rule.sideEffects === true && regexEqual(rule.test, /(?<!\.module)\.(scss|sass)$/),
);
// FIXME: SASS works only when using a custom _app.js file.
// See https://github.com/vercel/next.js/blob/24c3929ec46edfef8fb7462a17edc767a90b5d2b/packages/next/build/webpack/config/blocks/css/index.ts#L211
if (nextGlobalSassLoader) {
nextGlobalSassLoader.issuer = {
or: nextGlobalSassLoader.issuer ? [matcher, nextGlobalSassLoader.issuer] : [matcher],
};
} else if (!options.isServer) {
// Note that Next.js ignores global SASS imports on the server
console.info('next-transpile-modules - global SASS imports only work with a custom _app.js file');
}
}
// Make hot reloading work!
// FIXME: not working on Wepback 5
// https://github.com/vercel/next.js/issues/13039
const watchOptionsIgnored = Array.isArray(config.watchOptions.ignored)
? config.watchOptions.ignored
: [config.watchOptions.ignored];
config.watchOptions.ignored = [
...watchOptionsIgnored.filter((pattern) => pattern !== '**/node_modules/**'),
`**node_modules/{${modules.map((mod) => `!(${mod})`).join(',')}}/**/*`,
];
// Overload the Webpack config if it was already overloaded
if (typeof nextConfig.webpack === 'function') {
return nextConfig.webpack(config, options);
}
return config;
},
});
};
return withTM;
};
module.exports = withTmInitializer;