plugins_hash.js

/**
 * @file plugins/hash.js
 * @copyright @spmhome @_2025
 * @author Scott Meesseman @spmeesseman\
 *//** */

const WpwPlugin = require("./base");
const { dirname, resolve, basename } = require("path");
const { copyFileAsync, createDirAsync } = require("@spmhome/cmn-utils");
const { isString, isObjectEmpty, merge, escapeRegExp } = require("@spmhome/type-utils");


/**
 * @augments WpwPlugin
 */
class WpwHashPlugin extends WpwPlugin
{
    /**
     * @param {WpwPluginOptions} options Plugin options to be applied
     */
	constructor(options)
    {
        super(options);
        this.buildOptions = /** @type {WpwBuildOptionsPluginConfig<"hash">} */(this.buildOptions);
        if (this.buildOptions.emitNoHash) {
            this.buildOptions.emitAppNoHash = false;
        }
    }


	/**
     * @override
     * @param {WpwBuild} build
     */
	// static create = WpwHashPlugin.wrap.bind(this);
	static create = (build) =>
        WpwHashPlugin.wrap.call(this, build, !!(build.options.hash.emitNoHash || build.options.hash.emitAppNoHash));


    /**
     * @override
     * @returns {WpwPluginTapOptions<any, any, boolean>}
     */
    onApply()
    {
        return {
            copyModulesWithoutFilenameHash:
            {
                async: true,
                hook: "afterEmit",
                callback: this.copyEntryModulesNoHash.bind(this)
            },
            emitModulesWithoutFilenameHash:
            {
                hook: "compilation",
                statsProperty: "copied",
                hookCompilation: "afterProcessAssets",
                callback: this.emitModulesWithoutFilenameHash.bind(this)
            },
            readPreviousContenthashState:
            {
                async: false,
                hook: "beforeCompile",
                callback: this.readAssetState.bind(this)
            },
            saveContenthashState:
            {
                async: true,
                hook: "done",
                callback: this.saveAssetHash.bind(this)
            },
            updateAssetContenthash:
            {
                logLevel: 3,
                async: false,
                // hook: "afterEmit",
                hook: "assetEmitted",
                callback: this.updateAssetContentHash.bind(this)
            },
            verifyPreviousContenthashState:
            {
                async: false,
                hook: "finishMake",
                callback: this.verifyPreviousContentHashState.bind(this)
            }
        };
    }


	/**
	 * @private
	 * @param {WebpackCompilation} compilation
     * param {WebpackAssetEmittedInfo} assetInfo
	 */
    // async copyEntryModulesNoHash(file, _assetInfo)
	async copyEntryModulesNoHash(compilation)
	{
		const build = this.build,
              logger = this.hookstart(),
			  distPath = build.getDistPath(),
			  buildDir = build.virtualEntry.dirBuild, // this.compilation.options.output.path  && !!
		      currentAssets = Object.keys(compilation.getAssets()).filter(((f) => this.isOutputAsset(f)));
		logger.value("   distribution directory", distPath, 3);
		logger.value("   total # of output assets", currentAssets.length, 3);
        // const asset = this.compilation.getAsset(file);
        // if (asset?.info.sourceFilename && this.isOutputAsset(file, false, false, false, true))
        // {   const hkMsg = `copy immutable asset italic(${file}) to dist w/o filename hash`,
        //           build = this.build, logger = this.hookstart(hkMsg),
        //           distPath = build.getDistPath(), destFile = resolve(distPath, dirname(file) || ".");
        //     logger.write(`   ${asset.name} -> italic(${file})`, 1);
        //     await copyFileAsync(file, destFile);
        //     this.hookstart(hkMsg);
        // }
		for (const asset of compilation.getAssets().filter((a) => this._isOutputAsset(a.name) || a.name.startsWith("LICENSE.")))
        {
			const ccFile = this.fileNameStrip(asset.name);
			if (this.compilation.getAsset(ccFile))
			{   const file = resolve(buildDir, ccFile);
				let destDir = resolve(distPath, dirname(ccFile) || ".");
                if (build.isMultiTarget && ccFile.endsWith(".LICENSE") && destDir.endsWith(`${build.target}`)) {
                    destDir = resolve(destDir, "..");
                }
				logger.write(`   immutable asset '${asset.name}' copied -> ${ccFile}`, 2);
				await createDirAsync(destDir);
				await copyFileAsync(file, resolve(destDir, basename(ccFile) || "."));
			}
		}
		this.hookdone();
	}


	/**
	 * @private
	 * @param {WebpackCompilationAssets} assets
	 */
	emitModulesWithoutFilenameHash(assets)
	{
		const l = this.build.logger,
              assetKeys = Object.keys(assets);
		l.hookstart("emit entry assets without filename hash", 1);
        l.value("   # of current entry assets processed", assetKeys.length, 2);
		for (const file of assetKeys.filter((a) => this._isOutputAsset(a)))
		{
			const ccFile = this.fileNameStrip(file),
                  dstAsset = this.compilation.getAsset(ccFile);
			if (!dstAsset)
			{
				const srcAsset = this.compilation.getAsset(file);
				if (srcAsset)
				{
                    const currentState = this.store.data.current,
                          chunkName = this.fileNameStrip(file, true),
                          previousHash = currentState[chunkName],
                          currentHashSrc = srcAsset.info.contenthash,
                          // currentHashDst = dstAsset.info.contenthash,
                          unchanged = currentHashSrc === previousHash;

					l.write(`   compare current vs. previous contenthash for asset '${file}'`, 1);
					l.values([
                        [ "previous contenthash (state)", previousHash ], [ "current contenthash (src)", currentHashSrc ]
                       //  [ "current contenthash (dst)", currentHashDst ]
                    ], 1, "      ");
                    l.write(`   content italic(${unchanged ? "un" : ""}changed) since previous build`, 1);

					if (!unchanged)
                    {
					    l.write(`   save new contenthash ${currentHashSrc} to state store`, 2);
                        // srcAsset.info.related = { related: ccFile };
						currentState[chunkName] = currentHashSrc;
						this.store.save();
					}

					// const newInfo = apply({},
                    // {   sourceFilename: srcAsset.name,
                    //     copied: true, immutable: false, // unchanged,
                    //     javascriptModule: this.build.isModule,
                    //     contenthash: unchanged ? currentHash : undefined
                    // }, srcAsset.info);

                    const newInfo = {
                        sourceFilename: srcAsset.name,
                        copied: true, immutable: unchanged,
                        minimized: srcAsset.info.minimized,
                        javascriptModule: this.build.isModule,
                        contenthash: unchanged ? srcAsset.info.contenthash : undefined
                    };

                    if (l.level >= 3)
                    {   l.values([
                            [ "asset info", JSON.stringify(newInfo) ],
                            [ "immutable src asset info", JSON.stringify(srcAsset.info), 4 ]
                        ], 3, "   ");
                    }

					let src = srcAsset.source.source().toString();

					for (const asset of this.compilation.getAssets().filter((a) => this._isOutputAsset(a.name)))
					{
                        const assetNm = asset.name;
						src = src.replace(
                            new RegExp(escapeRegExp(assetNm), "g"), this.fileNameStrip(assetNm)
                        );
						const aFileParts = assetNm.split("."); // optimization.chunking = 'named'
						if (aFileParts.length > 2)
						{
							aFileParts.shift();
							src = src.replace(
                                new RegExp(escapeRegExp(`.${aFileParts.join(".")}`), "g"),
                                `.${aFileParts[aFileParts.length - 1]}`
                            );
						}
					}

					l.value("   emit copied asset", `${file} -> ${ccFile}`, 1);
                    const ccSource = new this.build.wp.sources.RawSource(src);
                    // const ccHash = this.build.ssCache.getContentHash(ccSource.buffer());
					this.compilation.emitAsset(ccFile, ccSource, newInfo);
				}
			}
		}
		l.hookdone("completed emit of entry assets without filename hash", 1, this.build.errorCount > 0);
	}


    /**
     * @private
     * @param {string} name
     * @returns {boolean}
     */
    _isOutputAsset(name) { return this.isOutputAsset(name, this.buildOptions.emitAppNoHash, false, false, true); }


    /**
     * @private
     * @param {Record<string, string>} prv
     * @param {Record<string, string>} cur
     * @param {string} lblPrv
     * @param {string} lblCur
     * @param {boolean} rot
     * @param {string} lPad
     */
    _logAssetInfo(prv, cur, lblPrv, lblCur, rot, lPad)
    {
        const l = this.build.logger.write(`${lblCur}:`, 2, lPad);
        if (!isObjectEmpty(cur))
        {
            Object.keys(cur).forEach((k) => l.writeMsgTag(`   ${k}`, cur[k], 2, lPad));
        }
        else if (prv && !isObjectEmpty(prv) && rot === true)
        {
            l.write(`   content hash values cleared and copied to '${lblPrv}'`, 2, lPad);
        }
        else {
            l.write("   0 stored content hash values", 2, lPad);
        }
    }


    /**
     * @private
     * @param {boolean} rotated `true` indicates that values were read and rotated
     * i.e. `next` values were moved to `current`, and `next` is now blank
     * @param {string} lPad
     */
    logAssetInfo(rotated, lPad)
    {
        const store = this.store.data;
        this._logAssetInfo(null, store.previous, "", "previous", rotated, lPad);
        this._logAssetInfo(store.previous, store.current, "previous", "current", rotated, lPad);
        this._logAssetInfo(store.current, store.next, "current", "next", rotated, lPad);
    }


    /**
     * Reads stored / cached content hashes from file
     *
     * @private
     * @param {WebpackCompilationParams} _params
     */
    readAssetState(_params)
    {
        const store = this.store.data;
        this.hookstart();
        if (!isObjectEmpty(store))
        {
            merge(store.previous, store.current);
            merge(store.current, store.next);
        }
        else
        {   store.current = {};
            store.previous = {};
        }
        store.next = {};
        this.logAssetInfo(true, "   ");
        this.hookdone();
    };


    /**
     * Writes / caches asset content hashes to disk
     *
     * @private
     * @member saveAssetState
     */
    async saveAssetHash()
    {
        this.hookstart();
        Object.keys(this.store.data.current).filter((h) => !this.store.data.next[h]).forEach((h) => {
            this.store.data.next[h] = this.store.data.current[h];
        });
        await this.store.saveAsync();
        this.logAssetInfo(false, "   ");
        this.hookdone();
    }


   /**
     * @private
     * @param {string} file
     * @param {WebpackAssetEmittedInfo} assetInfo
     * @throws {Error}
     */
    updateAssetContentHash =  (file, assetInfo) =>
    {
        const l = this.hookstart(),
              asset = this.compilation.getAsset(file),
              chunkName = this.fileNameStrip(asset.name, true);

        if (this._isOutputAsset(asset.name))
        {
            l.values([
                [ "emitted asset", asset.name ], [ "asset file", asset.name, 2 ], [ "chunk name", chunkName, 2 ],
                [ "contenthash", asset.info.contenthash ], [ "target path", assetInfo.targetPath, 2 ]
            ], 1, "   ");

            if (asset.info.contenthash)
            {
                if (asset.info.contenthash !== this.store.data.current[chunkName])
                {
                    l.write(`   update contenthash for asset '${chunkName}'`, 1);
                    l.value("      new", asset.info.contenthash, 2);
                    l.value("      previous", this.store.data.current[chunkName] || "n/a", 2);
                    this.store.data.next[chunkName] = asset.info.contenthash;
                }
                else {
                    l.write(`   contenthash of asset '${chunkName}' italic(unchanged) since previous build`, 1);
                }
            }
            else {
                l.write(`   ${asset.name} is not hashed, nothing to do`, 1);
            }
        }

        this.hookdone();
   };


    /**
     * Verifies stored / cached content hashes before new compilation
     *
     * @private
     * @param {WebpackCompilation} compilation
     */
    verifyPreviousContentHashState(compilation)
    {
        const storeCurrent = this.store.data.current,
              l = this.hookstart();

        compilation.getAssets().filter((asset) => this._isOutputAsset(asset.name)).forEach((asset) =>
		{
            const chunkName = this.fileNameStrip(asset.name, true);
            l.write(`   check asset ${asset.name} [chunk(${chunkName}]`, 2);
            if (isString(asset.info.contenthash))
            {   if (!storeCurrent[chunkName] || storeCurrent[chunkName] !==  asset.info.contenthash)
                {
                    storeCurrent[chunkName] = asset.info.contenthash;
                    l.write(`   updated ${storeCurrent[chunkName] ? "stale" : ""} contenthash for italic(${asset.name})`, 2);
                    l.value("      previous", storeCurrent[chunkName] || "n/a", 3);
                    l.value("      new", asset.info.contenthash, 3);
            }   }
            else
            {   this.addMessage({
                    plugin: "hash::verify",
                    code: this.MsgCode.WARNING_GENERAL,
                    message: "non-string content hash not supported yet: " + asset.name
                });
            }
        });
        this.logAssetInfo(false, "   ");
        this.hookdone();
    };
}


module.exports = WpwHashPlugin.create;