plugins_basetask.js

/**
 * @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;