/**
* @file plugin/basetask.js
* @copyright @spmhome @_2025
* @author Scott Meesseman @spmeesseman
*
* @description
*
* Base abstract plugin for builds that do not produce a "module" as per what webpack is basically
* designed for, but for a "task build", such as a types package or jsdoc packaging task. Typical
* tasks that might usually be done by a task runner such as Gulp, Grunt, Ant, etc...
*
* The concept is to use a "fake" or virtual file as the entry point so that the webpack process
* runs as it would normally. The virtual file is removed during compilation while the output of
* the specific task is added and emitted in the (probably) 'process_additional_assets' stage.
*
*//** */
const WpwPlugin = require("./base");
const { SpmhError } = require("@spmhome/log-utils");
const { isWpwWebhookCompilationHookStage } = require("../utils");
const { applyIf, capitalize, apply, pluralize, isEmpty } = require("@spmhome/type-utils");
const { extname, existsAsync, relativePath, writeFileAsync } = require("@spmhome/cmn-utils");
/**
* @augments WpwPlugin
*/
class WpwTaskPlugin extends WpwPlugin
{
/**
* @type {WpwTaskPluginOptions<WebpackCompilationAssets, WpwPluginTaskResult | Error, boolean>}
*/
options;
/**
* @override
* @type {WpwModuleSubType}
*/
subtype = "taskplugin";
/**
* @type {string | function (any): void}
*/
_taskHandler;
/**
* @type {string}
*/
taskName;
/**
* @type {WebpackCompilationHookStage}
*/
taskStage;
/**
* @param {string} taskName
* @param {WpwTaskPluginOptions<WebpackCompilationAssets, WpwPluginTaskResult | Error, boolean>} options
*/
constructor(taskName, options)
{
super(applyIf({ subtype: "taskplugin" }, options));
this.taskName = taskName;
this.options = apply({}, options);
this._taskHandler = this.options.taskHandler;
this.taskStage = this.options.taskStage = (this.options.taskStage || "ADDITIONAL");
this.validateBaseTaskOptions();
}
/**
* @override
*/
static create = WpwTaskPlugin.wrap.bind(this);
set taskHandler(/** @type {string | function(any): void} */fn) { this._taskHandler = fn; }
/**
* @override
* @returns {WpwPluginTapOptions<any, any, any>}
*/
onApply()
{
const taskOwner = capitalize(this.optionsKey);
/** @type {WpwPluginTapOptions<any, any, any>} */
const hooks = apply(
{
[`execute${taskOwner}Build`]: {
async: true,
hook: "compilation",
stage: this.taskStage,
statsProperty: this.optionsKey,
callback: this.buildTask.bind(this)
}
}, this.options.hooks, this.onApplyTask());
if (this.options.injectEntry !== false)
{
hooks[`inject${taskOwner}VirtualTriggerFile`] = {
async: true,
hook: "beforeCompile",
callback: this.injectVirtualTriggerFile.bind(this)
};
hooks[`remove${taskOwner}VirtualTriggerFile`] = {
// hook: "afterCompile",
hook: "compilation",
hookCompilation: "afterProcessAssets",
callback: this.removeVirtualTriggerFile.bind(this)
};
}
return hooks;
}
/**
* @abstract
* @since 1.14.1
* @returns {WpwPluginTapOptions<any, any, any> | undefined}
*/
onApplyTask()
{
return undefined;
}
/**
* @private
* @param {WebpackCompilationAssets} assets
* @returns {Promise<WpwPluginTaskResult | Error>}
*/
async buildTask(assets)
{
/** @type {WpwPluginTaskResult} */
let output;
const build = this.build,
pad = build.logger.staticPad,
failMsg = `${this.taskName} task based build failed`,
l = this.hookstart(`${this.taskName} task-type build`);
l.write(` execute build callback for task-plugin '${this.taskName}'`, 1);
l.staticPad += " ";
try
{ const result = this._types_.isString(this._taskHandler) ?
this[this._taskHandler].call(this, assets) : this._taskHandler(assets);
output = this._types_.isPromise(result) ? await result : result;
}
catch(e) { this.hookdone(failMsg); return e; } finally { l.staticPad = pad; }
if (this._types_.isError(output)) {
return this.hookdone(failMsg, output);
}
if (build.hasError) {
return this.hookdone(failMsg, build.errors[build.errorCount - 1]);
}
if (!output)
{ return this.hookdone(failMsg, this.addMessage({
code: this.MsgCode.ERROR_SCRIPT_FAILED,
message: `failed to build '${this.taskName}' [unknown error | null return value]`
}));
}
try
{ if (output !== true) {
await this.processBuildAssets(output, " ");
}
else { l.write(` 0 build assets to process for task '${this.taskName}'`, 1); }
}
catch(e) { return e;} finally { this.hookdone(`${this.taskName} task-type build`); }
};
/**
* @private
* @async
* @param {WebpackCompilationParams} _compilationParams
*/
async injectVirtualTriggerFile(_compilationParams)
{
const build = this.build,
hkMsg = `inject virtual entry file '${build.virtualEntry.file}' into pre-compile`,
vFile = `${build.virtualEntry.filePathAbs}${build.source.dotext}`,
vFileRelPath = relativePath(build.getContextPath(), vFile);
const l = this.hookstart(hkMsg);
if (!(await existsAsync(vFile)))
{
let source = "";
const dummyCode = "console.log('dummy source');";
// if (build.pkgJson.type === "module")
if (build.isModule)
{
source = `export default () => { ${JSON.stringify(dummyCode)}; }`;
}
else {
source = `module.exports = () => { ${JSON.stringify(dummyCode)}; }`;
}
l.value(" create file with dummy content", vFileRelPath, 1);
await writeFileAsync(vFile, source);
}
else {
l.write(` virtual trigger file '${vFileRelPath}' already exists`, 1);
}
this.hookdone(hkMsg);
}
/**
* @param {string} tag
* @param {string} fsPath file or directory, defaults to build.virtualTrigger.dirBuild
* @param {string} [extGlob] can be glob pattern, defaults to 'js'
* @param {boolean} [immutable] file or directory, defaults to build.virtualTrigger.dirBuild
* @param {string} [lPad]
* @returns {Promise<number>}
* @throws {SpmhError}
*/
async emitAsset(tag, fsPath, extGlob = "**/*", immutable = false, lPad = " ")
{
const build = this.build,
logger = build.logger,
vDir = build.virtualEntry.dir,
distPath = build.getDistPath(),
path = this.findAssetOrDependencyAbsPath(fsPath),
relPath = this._path_.relative(build.getBasePath(), path),
isDistEmit = !build.isScript || build.options.script?.emitType === "dist";
logger.value("emit asset", fsPath, 1, lPad);
logger.value(" absolute path", path, 1, lPad);
// try
// { if (isDir)
// { const cached = await build.ssCache.checkSnapshot(inputPath, null, lPad + " ", transform);
// if (cached.up2date) {
// logger.write("content is unchanged", 1, lPad);
// return;
// } } }
// catch (e) { throw e; }
const isDir = this._fs_.isDir(path);
if (isDir) {
logger.value("asset path is a directory", relPath, 1, lPad);
}
const files = isDir ?
await this._fs_.findFiles(extGlob, { cwd: path, absolute: true, nodir: true, dot: true }) :
[ path ];
for (let i = 0; i < files.length; i++)
{
let filePathRel = this.printAssetList(i, "emit asset", files, lPad);
if (build.isResource) {
filePathRel = filePathRel.replace(
new RegExp(this._arr_.asArray(build.options.resource.input).join("|")),
build.options.resource.output
);
}
await this.emitRawAsset(files[i], tag, filePathRel, immutable, true);
}
if (!isDistEmit && files.length > 0 && path.startsWith(vDir) && !distPath.startsWith(vDir))
{
logger.value("copy emitted assets to distribution directory", build.getDistPath({ rel: true}), 1, lPad);
await this._fs_.copyDirAsync(isDir ? path : this._path_.dirname(path), distPath);
}
return files.length;
};
/**
* @private
* @param {string} fsPath
* @returns {string | null | undefined}
*/
findAssetOrDependencyAbsPath = (fsPath) =>
{
if (!this._path_.isAbsPath(fsPath))
{
const b = this.build;
let path = this._path_.resolve(b.getBasePath(), fsPath);
if (this._fs_.existsSync(path)) {
return path;
}
path = this._path_.resolve(b.getContextPath(), fsPath);
if (this._fs_.existsSync(path)) {
return path;
}
return this._fs_.findExPathSync(fsPath, [
b.virtualEntry.dirDist, b.virtualEntry.dirBuild, b.getContextPath(), b.getBasePath(),
b.getDistPath(), b.getSrcPath(), b.getRootCtxPath(), b.getRootBasePath(), b.getRootSrcPath()
], true);
}
return fsPath;
};
/**
* @private
* @param {WpwPluginTaskObjResult} output
* @param {string} lPad
*/
async processBuildAssets(output, lPad)
{
const l = this.build.logger,
paths = this._arr_.asArray(output.paths),
srcpaths = this._arr_.asArray(output.srcpaths),
searchGlob = output.glob || "**/*.*",
infoTag = output.tag ||this.taskName || this.optionsKey;
l.write("begin post processing of input build/file dependencies and output assets", 1, lPad);
l.value(" search glob", searchGlob, 2, lPad);
l.value(" statitistics tag", infoTag, 2, lPad);
l.value(" # of input paths (directories & files)", srcpaths.length, 2, lPad);
l.value(" # of output paths (directories & files)", paths.length || 1, 2, lPad);
await this.processBuildInputAssets(srcpaths, searchGlob, lPad + " ");
await this.processBuildOutputAssets(paths, srcpaths, searchGlob, infoTag, output.immutable, lPad + " ");
l.ok("finshed post-processing of input build/file dependencies and output assets", 1, lPad);
};
/**
* @private
* @param {string[]} paths
* @param {string} searchGlob
* @param {string} lPad
* @returns {Promise<number>}
* @throws {SpmhError}
*/
async processBuildInputAssets(paths, searchGlob, lPad)
{
let fileCount = 0;
const iPaths = paths.map((p) => this.findAssetOrDependencyAbsPath(p)).filter((p) => !!p);
if (iPaths.length < paths.length)
{
this.addMessage({
lPad, code: this.MsgCode.WARNING_RESOURCE_MISSING,
message: "one or more input resource paths does not exist",
detail: `missing resource paths: ${paths.filter((p) => !iPaths.includes(p)).join(" | ")}`
});
}
const l = this.build.logger,
pathTxt = pluralize("path", iPaths.length);
l.write(`italic(input): process ${paths.length} resource ${pathTxt}`, 1, lPad);
for (const path of iPaths)
{
try
{ const tmpRgx = /\.tmp$/, srcFiles = this._fs_.isDir(path) ?
(await this._fs_.findFiles(searchGlob, { cwd: path, absolute: true, nodir: true, dot: true })) : [ path ];
srcFiles.filter((p) => !tmpRgx.test(p)).forEach((p, i) =>
{
if (!this._types_.isMediaExt(extname(p)) && this.build.type !== "types")
{
this.printAssetList(i, "add build dependency", srcFiles, lPad + " ");
this.compilation.buildDependencies.add(p);
}
else
{ this.printAssetList(i, "add file dependency", srcFiles, lPad + " ");
this.compilation.fileDependencies.add(p);
}
});
fileCount += srcFiles.length;
}
catch(e)
{ throw this.addMessage(
{ exception: e,
code: this.MsgCode.ERROR_BUILD_DEPENDENCIES,
message: `failed trying to process input dependency path '${path}'`
});
}
}
l.write(` processed ${fileCount} total input dependency ${this._str_.pluralize("asset", fileCount)}`, 2, lPad);
l.write(`italic(input): completed processing of input ${pathTxt}`, 1, lPad);
return fileCount;
}
/**
* @private
* @param {string[]} paths
* @param {string[]} srcpaths
* @param {string} searchGlob
* @param {string} tag
* @param {boolean} immutable
* @param {string} lPad
* @returns {Promise<number>}
* @throws {SpmhError}
*/
async processBuildOutputAssets(paths, srcpaths, searchGlob, tag, immutable, lPad)
{ //
let fileCount = 0; //
const b = this.build, l = b.logger, //
pathsSource = !b.isResource ? paths : srcpaths,
oPath = pathsSource.map((p) => this.findAssetOrDependencyAbsPath(p)).filter((p) => !!p);
//
if (oPath.length < pathsSource.length)
{ // \\
this.addMessage({ // \\
lPad, code: this.MsgCode.WARNING_RESOURCE_MISSING,
message: "one or more output resource paths does not exist",
detail: `missing ${paths.length - oPath.length} of ${paths.length} output asset paths: ` +
`${paths.filter((p) => !oPath.includes(p)).join(" | ")}`
}); // \\ \\
} // // /_\
// // // \\
if (isEmpty(paths)) // // // \\
{ // \\ // \\
if (!(await this._fs_.isDirEmpty(b.virtualEntry.dirDist))) {
oPath.push(b.virtualEntry.dirDist);
} // \\ // //
else if (!(await this._fs_.isDirEmpty(b.virtualEntry.dirBuild))) {
oPath.push(b.virtualEntry.dirBuild);
} // \\ \\ //
} // \\ // //
// // // //
const pathTxt = this._str_.pluralize("path", oPath.length);
l.write(`italic(output): process ${oPath.length} output asset ${pathTxt}`, 1, lPad);
if (oPath.length > 0) // //
{ // // // \\
l.value(" build output path", this._fs_.isDir(oPath[0]) ? oPath[0] : this._path_.dirname(oPath[0]), 1, lPad);
l.value(" webpack compiler.output path", this.compiler.outputPath, 1, lPad);
for (const path of oPath)
{ try {
fileCount += await this.emitAsset(tag, path, searchGlob, immutable, lPad + " ");
}
catch(e)
{ throw this.addMessage(
{ exception: e,
code: this.MsgCode.ERROR_BUILD_DEPENDENCIES,
message: `unable to emit build resources for '${this.taskName}' task-type build`
}, true);
}
}
}
l.write(` processed ${fileCount} total file ${pluralize("asset", fileCount)}`, 2, lPad);
l.write(`italic(output): completed processing of all output ${pathTxt}`, 1, lPad);
return fileCount;
}
/**
* @private
* @since 1.8.6
* @param {WebpackCompilationAssets} assets
*/
removeVirtualTriggerFile(assets)
{
const build = this.build,
hkMsg = `remove virtual entry trigger file '${build.virtualEntry.file}' from compilation`,
vEntryFile = Object.keys(assets).map((a) => this.compilation.getAsset(a))
.find((f) => f.name.includes(`${build.virtualEntry.file}.`));
this.hookstart(hkMsg);
// vPath = build.virtualEntry.dir,
// vDistPath = build.virtualEntry.dirDist,
// vBuildPath = build.virtualEntry.dirBuild,
// vdFiles = this._fs_.findFilesSync("**/*", { cwd: vDistPath, absolute: true, nodir: true, dot: true }),
// vbFiles = this._fs_.findFilesSync("**/*", { cwd: vBuildPath, absolute: true, nodir: true, dot: true });
// l.write(" v-build current intermediate output files:", 1);
// l.value(" v-build", `${vbFiles.length} ${this._str_.pluralize("file", vbFiles.length)}`, 1);
// l.value(" v-dist", `${vdFiles.length} ${this._str_.pluralize("file", vdFiles.length)}`, 1);
// l.value(" compilation current assets:", compilation.getAssets().map((a) => a.name).join(" | "), 2);
// const vOutputFiles = this._fs_.findFilesSync(
// `./{build,dist}/**/${build.virtualEntry.file}.{j,cj,mj,t}s`, { absolute: false, cwd: vPath }
// ).map(this._path_.basename)
// l.value(" v-out", `${vOutputFiles.length} ${this._str_.pluralize("file", vOutputFiles.length)}`, 1);
// const vEntryFile = compilation.getAssets().find((f) => f.name.includes(`${build.virtualEntry.file}.`));
if (vEntryFile)
{
this.logger.write(` remove '${vEntryFile.name}' from compilation`, 1);
this.compilation.deleteAsset(vEntryFile.name);
}
else {
this.logger.write(" virtual entry trigger file does not exist", 1);
}
this.hookdone(hkMsg);
}
/**
* @private
* @throws {SpmhError}
*/
validateBaseTaskOptions()
{
const _get = (/** @type {string} */ p, /** @type {string} */v) => new SpmhError({
code: this.MsgCode.ERROR_RESOURCE_MISSING,
message: `taskplugin config validation failed for '${this.taskName}' property ${p} [${v}]`
});
if (this._types_.isString(this._taskHandler) && (!this._types_.isFunction(this[this._taskHandler]))) {
throw _get("task handler", this._taskHandler);
}
else if (!this._types_.isString(this._taskHandler) && !this._types_.isFunction(this._taskHandler)) {
throw _get("task handler", this._taskHandler);
}
if (!isWpwWebhookCompilationHookStage(this.taskStage.toLowerCase())) {
throw _get("task stage", this.taskStage);
// this.taskStage = this.options.taskStage = "ADDITIONAL";
}
}
}
module.exports = WpwTaskPlugin;