html-bundler-webpack-plugin
Version:
Generates complete single-page or multi-page website from source assets. Build-in support for Markdown, Eta, EJS, Handlebars, Nunjucks, Pug. Alternative to html-webpack-plugin.
772 lines (645 loc) • 23.5 kB
JavaScript
const path = require('path');
const { isFunction, addQueryParam, deleteQueryParam, getQueryParam } = require('../Common/Helpers');
const { readDirRecursiveSync } = require('../Common/FileUtils');
const { optionEntryPathException } = require('./Messages/Exception');
const PluginService = require('./PluginService');
const loader = require.resolve('../Loader');
/** @typedef {import('webpack').Compilation} Compilation */
/** @typedef {import('webpack').Module} Module */
/** @typedef {import('webpack').Chunk} Chunk */
/** @typedef {import('webpack').EntryPlugin} EntryPlugin */
/** @typedef {import('webpack/Entrypoint').EntryOptions} EntryOptions */
/** @typedef {import('Collection')} Collection */
/**
* @typedef {Object} AssetEntryOptions
* @property {number=} id The unique ID of the entry template.
* The entries added to compilation have not ID.
* Note: the same source file of a template can be defined under many entry names.
* In this case, the 'entry.resource' is not unique und must be used entry.id.
* @property {string} name The webpack entry name used for compilation. Use `originalName` as the real name.
* @property {string} originalName The original name of webpack entry.
* @property {string|(function(PathData, AssetInfo): string)} filenameTemplate The filename template or function.
* @property {string} filename The asset filename.
* The template strings support only these substitutions:
* [name], [base], [path], [ext], [id], [contenthash], [contenthash:nn]
* See https://webpack.js.org/configuration/output/#outputfilename
* @property {Function} filenameFn The function to generate the output filename dynamically.
* @property {string} resource The absolute import file with a query.
* @property {string} importFile The original import entry file.
* @property {string} sourceFile The absolute import file only w/o a query.
* @property {string|undefined} dataFile The absolute file containing variables passed into the entry template.
* @property {string} sourcePath The path where are source files.
* @property {string} outputPath The absolute output path.
* @property {string} publicPath The public path relative to outputPath.
* @property {{name: string, type: string}} library Define the output a js file.
* See https://webpack.js.org/configuration/output/#outputlibrary
* @property {boolean|string} [verbose = false] Show an information by handles of the entry in a postprocess.
* @property {boolean} isTemplate Whether the entry is a template entrypoint.
* @property {boolean} isStyle Whether the entry is a style defined in Webpack entry.
*/
class AssetEntry {
/** @type {Map<string, AssetEntryOptions>} */
entriesByName = new Map();
/** @type {Map<Number, AssetEntryOptions>} */
entriesById = new Map();
compilationEntryNames = new Set();
removedEntries = new Set();
/** @type {Object} */
entryLibrary = null;
/** @type {FileSystem} */
fs = null;
/** @type {Compiler} */
compiler = null;
/** @type {Compilation} */
compilation = null;
/** @type {Collection} */
collection = null;
/** @type {{}} */
pluginOption = null;
assetTrash = null;
/** @type {Map<any, any>} The data passed to the entry template. */
data = new Map();
// the id to bind loader with data passed into template via entry.data
idIndex = 1;
entryIdKey = '_entryId';
// the regexp will be initialized in the init()
entryIdRegexp = null;
// the entry name prefix of html entries to avoid a collision with the same name of a js entrypoint,
// e.g., index.html and index.js have the same base name `index`,
// but must be defined in entry as two different entries, because each type of entry have own options:
// entry: { __prefix__index: './index.html', index: './index.js' }
entryNamePrefix = '__bundler-plugin-entry__';
/**
*
* @param {{}} pluginOption
* @param {Collection} collection
* @param {AssetTrash} assetTrash
* @param {{}} entryLibrary
*/
constructor({ pluginOption, collection, assetTrash, entryLibrary }) {
this.pluginOption = pluginOption;
this.collection = collection;
this.assetTrash = assetTrash;
this.entryLibrary = entryLibrary;
this.entryIdRegexp = new RegExp(`\\?${this.entryIdKey}=(\\d+)`);
}
/**
* Init the asset entry.
*
* @param {{}} pluginOption
*
*
* @param {FileSystem} fs
*/
init({ fs }) {
this.fs = fs;
const { entry } = this.pluginOption.webpackOptions;
let pluginEntry;
if (this.pluginOption.isDynamicEntry()) {
pluginEntry = this.getDynamicEntry();
this.pluginOption.webpackOptions.entry = { ...entry, ...pluginEntry };
return;
}
pluginEntry = { ...entry, ...this.pluginOption.get().entry };
for (let name in pluginEntry) {
const entry = pluginEntry[name];
const entryName = this.createEntryName(name);
if (entry.import == null) {
if (this.pluginOption.isEntry(entry)) {
delete pluginEntry[name];
name = entryName;
}
pluginEntry[name] = { import: [entry] };
continue;
}
if (!Array.isArray(entry.import)) {
entry.import = [entry.import];
}
if (this.pluginOption.isEntry(entry.import[0])) {
pluginEntry[entryName] = entry;
delete pluginEntry[name];
}
}
this.pluginOption.webpackOptions.entry = pluginEntry;
}
/**
* Returns dynamic entries read recursively from the entry path.
*
* @return {Object}
* @throws
*/
getDynamicEntry() {
const { fs } = this;
const dir = this.pluginOption.get().entry;
const entryFilter = this.pluginOption.get().entryFilter;
const isFunctionEntryFilter = typeof entryFilter === 'function';
let includes = [this.pluginOption.get().test];
let excludes = [];
if (entryFilter && !isFunctionEntryFilter) {
if (entryFilter instanceof RegExp) {
includes = [entryFilter];
} else {
if (Array.isArray(entryFilter)) {
includes = entryFilter;
} else {
if ('includes' in entryFilter && Array.isArray(entryFilter.includes)) {
includes = entryFilter.includes;
}
if ('excludes' in entryFilter && Array.isArray(entryFilter.excludes)) {
excludes = entryFilter.excludes;
}
}
}
}
try {
if (!fs.lstatSync(dir).isDirectory()) optionEntryPathException(dir);
} catch (error) {
optionEntryPathException(dir);
}
const files = readDirRecursiveSync(dir, { fs, includes, excludes });
const entry = {};
files.forEach((file) => {
if (isFunctionEntryFilter && entryFilter(file) === false) {
return;
}
const outputFile = path.relative(dir, file);
const name = outputFile.slice(0, outputFile.lastIndexOf('.'));
const entryName = this.createEntryName(name);
entry[entryName] = { import: [file] };
});
return entry;
}
/**
* @param {string} name
* @return {string}
*/
createEntryName(name) {
return `${this.entryNamePrefix}${name}`;
}
/**
* @param {string} name
* @return {string}
*/
getOriginalEntryName(name) {
return name.replace(this.entryNamePrefix, '');
}
/**
* Get current entry files from the cache.
*
* @return {Set<string>}
*/
getEntryFiles() {
const files = new Set();
for (let { sourceFile, isTemplate } of this.entriesByName.values()) {
if (isTemplate && !this.removedEntries.has(sourceFile)) files.add(sourceFile);
}
return files;
}
/**
* Whether the entry is unique.
*
* @param {string} name The name of the entry.
* @param {string} file The source file.
* @return {boolean}
*/
isUnique(name, file) {
const entry = this.entriesByName.get(name);
return !entry || entry.sourceFile === file;
}
/**
* Whether the output filename is a template entrypoint.
*
* @param {string} filename The output filename.
* @return {boolean}
*/
isEntryFilename(filename) {
return this.collection.isTemplate(filename);
}
/**
* Whether the resource is a template entrypoint.
*
* @param {string} resource The resource file.
* @return {boolean}
*/
isEntryResource(resource) {
const [resourceFile] = resource.split('?', 1);
for (let { isTemplate, sourceFile } of this.entriesByName.values()) {
if (isTemplate && sourceFile === resourceFile) return true;
}
return false;
}
/**
* Whether the entry file was deleted or renamed in serve/watch mode.
*
* Webpack doesn't want to remove the entrypoint file from compilation permanently.
* It is needed to ignore deleted file in hooks beforeResolve and renderManifest.
*
* @param {string} file The source entry file.
* @return {boolean}
*/
isDeletedEntryFile(file) {
return this.removedEntries.has(file);
}
/**
* Get an entry object by chunk.
*
* Note: the `chunk.filenameTemplate` will be recovered
* because the webpack sets it as undefined in the watch mode after changes.
*
* Called in `renderManifest` hook.
*
* @param {Chunk} chunk The webpack chunk.
* @returns {AssetEntryOptions|null}
*/
getByChunk(chunk) {
const entry = this.entriesByName.get(chunk.name);
if (entry == null) return null;
if (
PluginService.isWatchMode(this.compiler) &&
this.pluginOption.isDynamicEntry() &&
this.isDeletedEntryFile(entry.sourceFile)
) {
// delete artifacts from compilation in serve/watch mode
this.assetTrash.add(entry.filename);
this.assetTrash.add(`${entry.name}.js`);
return null;
}
if (entry.isTemplate) {
entry.filename = this.compilation.getAssetPath(entry.filenameTemplate, {
// define the structure of the pathData argument with useful data for the filenameTemplate as a function
runtime: entry.originalName,
filename: entry.sourceFile,
filenameTemplate: entry.filenameTemplate,
chunk: {
name: entry.originalName,
runtime: entry.originalName,
},
});
if (entry.publicPath) {
entry.filename = path.posix.join(entry.publicPath, entry.filename);
}
return entry;
}
// set generated output filename for the CSS asset defined as an entrypoint
if (entry.isStyle) {
entry.filename = this.compilation.getPath(chunk.filenameTemplate, {
contentHashType: 'javascript',
chunk,
});
return entry;
}
// fix undefined `pathData.filename` in the watch mode after changes when the `js.filename` is a function
if (chunk.filenameTemplate == null && typeof entry.filenameTemplate === 'function') {
chunk.filenameTemplate = entry.filenameFn;
}
return entry;
}
/**
* @param {string|number} id
* @returns {AssetEntryOptions}
*/
getById(id) {
return this.entriesById.get(Number(id));
}
/**
* Returns the entry of a resource like style or script by its filename, regardless a query.
* Note: To get the entry of a template use the get() by name.
*
* @param {string} resource
* @return {AssetEntryOptions|null}
*/
getByResource(resource) {
const [resourceFile] = resource.split('?', 1);
for (let entry of this.entriesByName.values()) {
if (entry.sourceFile === resourceFile) return entry;
}
return null;
}
/**
* Get entry data.
*
* @param {string|Number} id The entry id.
* @return {Object}
*/
getData(id) {
return this.data.get(Number(id)) || {};
}
/**
* Resolve the entry id in the request and save it in the Webpack data object.
*
* @param {Object} resolveData The data object.
* @return {number|null}
*/
resolveEntryId(resolveData) {
let entryId = getQueryParam(resolveData.request, this.entryIdKey);
if (entryId) {
entryId = Number(entryId);
// delete temporary entry id param form request query of the entry
resolveData.request = deleteQueryParam(resolveData.request, this.entryIdKey);
}
return entryId || null;
}
/**
* Retrieve `entryId` from request of the module.
* The request format is /path/to/loader/index.js?entryId=1!/path/to/template.
*
* @param {Module} module The Webpack module.
* @return {number|undefined}
*/
getEntryId(module) {
const [, entryId] = this.entryIdRegexp.exec(module.request);
return entryId ? Number(entryId) : undefined;
}
/**
* @param {Module} module The Webpack module object.
* @param {Object} resolveData The data object.
*/
connectEntryAndModule(module, resolveData) {
const { entryId, _bundlerPluginMeta: meta } = resolveData;
if (entryId) {
// When used the same template file for many pages,
// add the unique entry id to the query of the loader url to be sure that module request is unique.
// When many Webpack entries have the same module request, then Webpack will not create a new module.
module.request = module.request.replace(loader, loader + `?${this.entryIdKey}=${entryId}`);
}
// Note: the module.resourceResolveData is cached in the serve mode,
// therefore, we can't save the entry id in the resourceResolveData.
// E.g.: when there are many entries with the same template file but with different data,
// then in serve/watch mode the resourceResolveData has the reference to the last object,
// which is unique by module.resource, not by module.request.
module.resourceResolveData._bundlerPluginMeta = meta;
}
/**
* @param {Number} id
* @param {Object} entry
*/
setEntryId(id, entry) {
// add the entry id as the query parameter
entry.import[0] = addQueryParam(entry.import[0], this.entryIdKey, id);
}
/**
* @param {Compiler} compiler The instance of the webpack compiler.
*/
setCompiler(compiler) {
this.compiler = compiler;
}
/**
* @param {Compilation} compilation The instance of the webpack compilation.
*/
setCompilation(compilation) {
this.compilation = compilation;
}
/**
* @param {Array<Object>} entries
*/
addEntries(entries) {
for (let name in entries) {
const entry = entries[name];
const originalName = this.getOriginalEntryName(name);
const importFile = entry.import[0];
let resource = importFile;
let [sourceFile] = resource.split('?', 1);
let options = this.pluginOption.getEntryOptions(sourceFile);
if (options == null) continue;
let { verbose, filename: filenameTemplate, sourcePath, outputPath } = options;
// Note:
// when the entry contains the same source file for many chunks,
// add a unique identifier of the entry to execute a loader for all templates,
// otherwise Webpack call a loader only for the first template.
// See 'webpack/lib/NormalModule.js:identifier()'.
// For example, there is the entry containing many pages based on the same template:
// {
// page1: { import: 'src/template.html', data: { title: 'A'} },
// page2: { import: 'src/template.html', data: { title: 'B'} },
// }
// Note: the id for all entry dependencies must be the same
let id = this.idIndex++;
this.setEntryId(id, entry);
if (!entry.library) entry.library = this.entryLibrary;
if (entry.filename) filenameTemplate = entry.filename;
if (entry.data) this.data.set(id, entry.data);
if (!path.isAbsolute(sourceFile)) {
resource = path.join(sourcePath, resource);
sourceFile = path.join(sourcePath, sourceFile);
entry.import[0] = path.join(sourcePath, entry.import[0]);
}
let dataFile = undefined;
if (typeof entry.data === 'string') {
dataFile = PluginService.resolveFile(this.compiler, entry.data);
}
/** @type {AssetEntryOptions} */
const assetEntryOptions = {
id,
name,
originalName,
filenameTemplate,
filename: undefined,
resource,
importFile,
sourceFile,
dataFile,
sourcePath,
outputPath,
publicPath: '',
library: entry.library,
verbose,
isTemplate: this.pluginOption.isEntry(sourceFile),
isStyle: this.pluginOption.isStyle(sourceFile),
};
this.#add(entry, assetEntryOptions);
}
}
/* istanbul ignore next: this method is called in watch mode after changes */
/**
* Add the entry file to compilation.
*
* Called after changes in serve/watch mode.
*
* @param {string} file
*/
addEntry(file) {
const pluginOptions = this.pluginOption.get();
const context = pluginOptions.sourcePath;
const entryDir = pluginOptions.entry;
if (!path.isAbsolute(file)) {
file = path.join(this.pluginOption.context, file);
}
let outputFile = path.relative(entryDir, file);
let name = outputFile.slice(0, outputFile.lastIndexOf('.'));
this.removedEntries.delete(file);
if (this.#exists(name, file)) return;
const entries = {};
const entrypoint = { import: [file], filename: '[name].html' };
entries[name] = entrypoint;
this.addEntries(entries);
this.pluginOption.webpackOptions.entry = { ...this.pluginOption.webpackOptions.entry, ...entries };
// important: the library must be null
entrypoint.library = null;
const entryOptions = {
name,
runtime: undefined,
dependOn: undefined,
baseUri: undefined,
publicPath: undefined,
chunkLoading: undefined,
asyncChunks: undefined,
wasmLoading: undefined,
library: undefined,
};
const compiler = this.compiler;
const { EntryPlugin } = compiler.webpack;
// the entry request is generated as the `entrypoint.import` property after call the addEntries()
new EntryPlugin(context, entrypoint.import[0], entryOptions).apply(compiler);
}
/* istanbul ignore next: this method is called in watch mode after changes */
/**
* Remove the entry file from cache.
*
* Called after deleting of an entry file in serve/watch mode.
*
* @param {string} file
*/
deleteEntry(file) {
this.removedEntries.add(file);
this.collection.deleteData(file);
// don't delete the entry from 'this.entriesByName', it is used as the cache for the following use case:
// in serve/watch mode after renaming an entry file in another name and rename the same file back in previous name,
// will be used already created entry instead of the new entry
}
deleteMissingFile(file) {
this.removedEntries.delete(file);
}
/**
* Add a script to webpack compilation.
*
* @param {string} name
* @param {string} importFile The resource, including a query.
* @param {string} filenameTemplate
* @param {string} context
* @param {string} issuer
* @return {boolean} Return true if new file was added, if a file exists then return false.
*/
addToCompilation({ name, importFile, filenameTemplate, context, issuer }) {
// skip duplicate entries
if (this.#exists(name, importFile)) {
return false;
}
let [sourceFile] = importFile.split('?', 1);
const entryOptions = {
name,
runtime: undefined,
dependOn: undefined,
baseUri: undefined,
publicPath: undefined,
chunkLoading: undefined,
asyncChunks: undefined,
wasmLoading: undefined,
library: undefined,
};
/** @type {AssetEntryOptions} */
const assetEntryOptions = {
id: undefined,
name,
filenameTemplate,
filename: undefined,
resource: importFile,
importFile,
sourceFile,
sourcePath: context,
outputPath: this.pluginOption.getWebpackOutputPath(),
verbose: false,
isTemplate: false,
};
this.#add(entryOptions, assetEntryOptions);
this.compilationEntryNames.add(name);
const compiler = this.compiler;
const compilation = this.compilation;
const { EntryPlugin } = compiler.webpack;
const entryDependency = EntryPlugin.createDependency(importFile, { name });
compilation.addEntry(context, entryDependency, entryOptions, (err, module) => {
if (err) throw new Error(err);
});
// add missing dependencies after rebuild
if (PluginService.isWatchMode(compiler)) {
new EntryPlugin(context, importFile, { name }).apply(compiler);
}
return true;
}
/**
* @param {EntryOptions} entry The Webpack entry option object.
* @param {AssetEntryOptions} assetEntryOptions
* @private
*/
#add(entry, assetEntryOptions) {
const { name, originalName, filenameTemplate, outputPath } = assetEntryOptions;
if (path.isAbsolute(outputPath)) {
assetEntryOptions.publicPath = path.relative(this.pluginOption.getWebpackOutputPath(), outputPath);
}
const filenameFn = (pathData, assetInfo) => {
// the template filename stays the same after changes in watch mode because have not a hash substitution
if (assetEntryOptions.isTemplate && assetEntryOptions.filename != null) return assetEntryOptions.filename;
let filename = filenameTemplate;
if (isFunction(filenameTemplate)) {
// clone the pathData object to modify the chunk object w/o side effects in the main compilation
const pathDataCloned = { ...pathData };
pathDataCloned.chunk = { ...pathDataCloned.chunk };
if (originalName) {
pathDataCloned.chunk.name = originalName;
pathDataCloned.chunk.runtime = originalName;
}
// the `filename` property of the `PathData` type should be a source file, but in entry this property not exists
if (pathDataCloned.filename == null) {
pathDataCloned.filename = assetEntryOptions.sourceFile;
}
filename = filenameTemplate(pathDataCloned, assetInfo);
}
if (assetEntryOptions.publicPath) {
filename = path.posix.join(assetEntryOptions.publicPath, filename);
}
return filename;
};
entry.filename = filenameFn;
assetEntryOptions.filenameFn = filenameFn;
this.entriesByName.set(name, assetEntryOptions);
this.entriesById.set(assetEntryOptions.id, assetEntryOptions);
}
/**
* Whether the file in the entry already exists.
*
* @param {string} name The name of the entry.
* @param {string} importFile The resource, including a query.
* @return {boolean}
* @private
*/
#exists(name, importFile) {
const entry = this.entriesByName.get(name);
return entry != null && entry.importFile === importFile;
}
/**
* Clear caches.
* Called only once when the plugin is applied.
*/
clear() {
this.idIndex = 1;
this.data.clear();
this.entriesByName.clear();
this.entriesById.clear();
}
/**
* Remove entries added not via webpack entry.
* Called before each new compilation after changes, in the serve/watch mode.
*/
reset() {
for (const entryName of this.compilationEntryNames) {
const entry = this.entriesByName.get(entryName);
if (entry) {
this.entriesById.delete(entry.id);
}
// not remove JS file added as the entry for compilation,
// because after changes any style file imported in the JS file, the JS entry will not be processed
// this.entriesByName.delete(entryName);
}
this.compilationEntryNames.clear();
}
}
module.exports = AssetEntry;