Skip to content

Instantly share code, notes, and snippets.

@bericp1
Last active March 17, 2021 16:04
Show Gist options
  • Save bericp1/bafa5ebfb422ac5df7b5b491a330ff3a to your computer and use it in GitHub Desktop.
Save bericp1/bafa5ebfb422ac5df7b5b491a330ff3a to your computer and use it in GitHub Desktop.
A webpack plugin that replaces a file with an alternate version alongside it.

AlternativeFileReplacerPlugin

Useful for e.g. rebrands where you want to produce 2 alternate builds in webpack using conditional imports.

For example if you had this plugin enabled in your webpack config and you had the following 2 files:

src/
  index.js
  index.alt.js

Whenever you imported index.js (an any fashion, relative, aliased, etc.) it would get replaced in the final build with index.alt.js.

Requirements

  • Node 10+ (named capture groups)
  • npm install --save-dev debug escape-string-regexp

Only tested against

  • Node 10.22.0
  • webpack@4.44.2
  • node-sass@4.9.2
  • sass-loader@8.0.2
  • next@9.5.5

Example usage

Assumes that you want to replace file.js with file.alt.js whenever ALT_BUILD_ENABLED is set in the environment to be true.

const AlternativeFileReplacerPlugin = require('./path/to/AlternativeFileReplacerPlugin.png');

module.exports = {
  // ... the rest of your webpack config
  plugins: [
    // ... your other plugins
    new AlternativeFileReplacerPlugin(
      // Assumes this webpack.config.js file is in your project's root
      __dirname,
      {
        enabled: process.env.ALT_BUILD_ENABLED === 'true',
        // Customize other options here like `altExtensionPrefix`, `extensionPattern`, and `excludeNodeModules`
      },
    );
  ],
};

To get verbose logging output, set DEBUG=AlternativeFileReplacerPlugin in the environment when you build.

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;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment