|
const path = require('path'); |
|
const escapeStringRegExp = require('escape-string-regexp'); |
|
const _debug = require('debug'); |
|
|
|
const PLUGIN_NAME = 'AlternativeFileReplacerPlugin'; |
|
|
|
const debug = _debug(PLUGIN_NAME); |
|
|
|
/** |
|
* Create an "isFileAccessible" function for an `fs` interface that has a `stat` function. |
|
* |
|
* @type {function(fs: { stat: function(string, function), ... }): (function(string): Promise<boolean>)} |
|
*/ |
|
const createIsFileAccessibleForStatableFs = (() => { |
|
const cache = new WeakMap(); |
|
return (fs) => { |
|
if (cache.get(fs)) { |
|
return cache.get(fs); |
|
} |
|
// util.promisify is incompatible with enhanced-resolve's `CachedInputFileSystem` |
|
const isFileAccessible = (filePath) => new Promise((resolve) => { |
|
fs.stat(filePath, (err) => { |
|
resolve(!err); |
|
}); |
|
}); |
|
cache.set(fs, isFileAccessible); |
|
return isFileAccessible; |
|
}; |
|
})(); |
|
|
|
/** |
|
* Replaces files in a build with alternative files alongside them in the same directory with a special extension |
|
* prefix. |
|
* |
|
* For example, the default altExtensionPrefix is `.alt` so if you had the following 2 files: |
|
* - src/index.js |
|
* - src/index.alt.js |
|
* |
|
* And you imported `src/index` somewhere, it would instead bundle the file `src/index.alt.js` if this plugin |
|
* is active. |
|
*/ |
|
class AlternativeFileReplacerPlugin { |
|
constructor( |
|
// The root directory of the project. |
|
projectBaseDir, |
|
{ |
|
// Should we replace? |
|
enabled = true, |
|
// The prefix to look for before the file extension when finding alternate file replacements. |
|
altExtensionPrefix = '.alt', |
|
// A string partial regexp pattern to match file extensions you care about |
|
extensionPattern = 'jsx?|tsx?|(module\\.)?s?css|png|svg|jpe?g|gif', |
|
// Set to false to also replace imported files from node_modules |
|
excludeNodeModules = true, |
|
} = {}, |
|
) { |
|
this.enabled = enabled; |
|
this.altExtensionPrefix = altExtensionPrefix; |
|
this.replaceableFilePattern = new RegExp( |
|
`(?<baseName>.*?)/(?<fileName>[^/]+)(?<!${escapeStringRegExp(altExtensionPrefix)})\\.(?<ext>(${extensionPattern}))(?<query>\\?.*)?$`, |
|
'i', |
|
); |
|
this.projectBaseDir = projectBaseDir.replace(/\/+$/, ''); |
|
this.excludeNodeModules = !!excludeNodeModules; |
|
} |
|
|
|
/** |
|
* @param {string} absolutePath |
|
* @return {boolean} |
|
*/ |
|
shouldExcludeAbsolutePath(absolutePath) { |
|
if (this.excludeNodeModules) { |
|
return absolutePath.startsWith(`${this.projectBaseDir}/node_modules`); |
|
} |
|
|
|
return false; |
|
} |
|
|
|
/** |
|
* @param {string} relativeOrAbsolutePath |
|
* @param {string|null=} context |
|
* @return {string} |
|
*/ |
|
makePathFriendly(relativeOrAbsolutePath, context = null) { |
|
const absolutePath = context |
|
? path.resolve(context, relativeOrAbsolutePath) |
|
: relativeOrAbsolutePath; |
|
return absolutePath.replace( |
|
new RegExp(`^${escapeStringRegExp(this.projectBaseDir)}`), |
|
'.', |
|
); |
|
} |
|
|
|
/** |
|
* @param fs |
|
* @param {string} relativeOrAbsolutePath |
|
* @param {string|null=} context |
|
* @return {Promise<null|string>} |
|
*/ |
|
async rewrite(fs, relativeOrAbsolutePath, context = null) { |
|
const absolutePath = context |
|
? path.resolve(context, relativeOrAbsolutePath) |
|
: relativeOrAbsolutePath; |
|
if (this.shouldExcludeAbsolutePath(absolutePath)) { |
|
return null; |
|
} |
|
const altPathWithQuery = relativeOrAbsolutePath.replace( |
|
this.replaceableFilePattern, |
|
`$<baseName>/$<fileName>${this.altExtensionPrefix}.$<ext>$<query>`, |
|
); |
|
if (altPathWithQuery === relativeOrAbsolutePath) { |
|
return null; |
|
} |
|
const altPath = altPathWithQuery.split('?')[0]; |
|
const altAbsolutePath = context |
|
? path.resolve(context, altPath) |
|
: altPath; |
|
const isFileAccessible = createIsFileAccessibleForStatableFs(fs); |
|
if (await isFileAccessible(altAbsolutePath)) { |
|
return altPathWithQuery; |
|
} |
|
return null; |
|
} |
|
|
|
/** |
|
* @param {string} beforePath |
|
* @param {string} afterPath |
|
* @param {string|null=} context |
|
* @param {string|null=} noun |
|
*/ |
|
logReplace(beforePath, afterPath, { context = null, noun = null } = {}) { |
|
const beforeFriendly = this.makePathFriendly(beforePath, context); |
|
const afterFriendly = this.makePathFriendly(afterPath, context); |
|
debug(`Replacing${noun ? ` ${noun}` : ''}: ${beforeFriendly} ➡ ${afterFriendly}`); |
|
} |
|
|
|
/** |
|
* @param result |
|
* @param fs |
|
* @return {Promise<void>} |
|
*/ |
|
async onBeforeResolve(result, fs) { |
|
if (!result.request || !fs) { |
|
return; |
|
} |
|
|
|
// Requests might include a pipeline of files, we need to operate on each file in the pipeline |
|
const pendingFinalRequestParts = result.request |
|
.split('!') |
|
.map(async (relativeOrAbsolutePath) => { |
|
if (!relativeOrAbsolutePath) { |
|
return relativeOrAbsolutePath; |
|
} |
|
const rewrittenPath = await this.rewrite(fs, relativeOrAbsolutePath, result.context); |
|
return rewrittenPath || relativeOrAbsolutePath; |
|
}); |
|
|
|
const finalRequest = (await Promise.all(pendingFinalRequestParts)).join('!'); |
|
|
|
if (result.request !== finalRequest) { |
|
this.logReplace(result.request, finalRequest, { context: result.context, noun: 'request' }); |
|
result.request = finalRequest; |
|
} |
|
} |
|
|
|
/** |
|
* @param result |
|
* @param fs |
|
* @return {Promise<void>} |
|
*/ |
|
async onAfterResolve(result, fs) { |
|
if (!result.resource || !fs) { |
|
return; |
|
} |
|
|
|
const rewrittenResource = await this.rewrite(fs, result.resource, result.context); |
|
|
|
if (rewrittenResource && rewrittenResource !== result.resource) { |
|
this.logReplace(result.resource, rewrittenResource, { context: result.context, noun: 'resource' }); |
|
result.resource = rewrittenResource; |
|
} |
|
} |
|
|
|
/** |
|
* This is where most requested resources are replaced. This hooks into `enhanced-resolve` for "normal" modules |
|
* that webpack uses to resolve any import to an absolute path. |
|
* |
|
* @param {{ path: string, ... }} resolverResult `path` is always an absolute string |
|
* @param fs |
|
* @return {Promise<void>} |
|
*/ |
|
async onResolve(resolverResult, fs) { |
|
if (!resolverResult || !resolverResult.path || !fs) { |
|
return; |
|
} |
|
|
|
const rewrittenResolverResult = await this.rewrite(fs, resolverResult.path); |
|
|
|
if (rewrittenResolverResult && rewrittenResolverResult !== resolverResult.path) { |
|
this.logReplace(resolverResult.path, rewrittenResolverResult, { noun: 'normal resolver result' }); |
|
resolverResult.path = rewrittenResolverResult; |
|
} |
|
} |
|
|
|
|
|
apply(compiler) { |
|
if (this.enabled) { |
|
const fs = compiler.inputFileSystem; |
|
compiler.hooks.normalModuleFactory.tap( |
|
PLUGIN_NAME, |
|
(nmf) => { |
|
nmf.hooks.beforeResolve.tapPromise( |
|
PLUGIN_NAME, |
|
async (result) => { |
|
if (result) { |
|
await this.onBeforeResolve(result, fs); |
|
} |
|
}, |
|
); |
|
nmf.hooks.afterResolve.tapPromise( |
|
PLUGIN_NAME, |
|
async (result) => { |
|
if (result) { |
|
await this.onAfterResolve(result, fs); |
|
} |
|
}, |
|
); |
|
}, |
|
); |
|
compiler.resolverFactory.hooks.resolver |
|
.for('normal') |
|
.tap(PLUGIN_NAME, (resolver) => { |
|
resolver.hooks.result.tapPromise( |
|
PLUGIN_NAME, |
|
async (resolverResult) => { |
|
await this.onResolve(resolverResult, fs); |
|
}, |
|
); |
|
}); |
|
} |
|
} |
|
} |
|
|
|
module.exports = AlternativeFileReplacerPlugin; |