plugins_release_iso.js

/**
 * @file plugin/release/iso.js
 * @copyright @spmhome @_2025
 * @author Scott Meesseman @spmeesseman
 *//** */

const WpwPlugin = require("../base");
const { asArray, apply, cleanPrototype, pluralize, safeStringify } = require("@spmhome/type-utils");
const { existsSync, resolvePath, writeFileAsync, joinPath, findDotJsonFileUpSync } = require("@spmhome/cmn-utils");


/**
 * @abstract
 * @augments WpwPlugin
 */
class WpwIsoReleasePlugin extends WpwPlugin
{
	/**
	 * @private
	 */
	static releasePrepared = { npm: false, vsce: false };

	/**
	 * @private
	 * @type {WpwIsoReleaseType}
	 */
	isoReleaseType;


	/**
	 * @param {WpwIsoReleasePluginOptions} options Plugin options to be applied
	 */
	constructor(options)
	{
		super(options);
		this.isoReleaseType = options.isoReleaseType;
	}


    /**
     * @override
     * @returns {WpwPluginTapOptions<any, any, boolean> | void}
     */
    onApply()
    {
		return {
			prepareIsolatedReleaseDirectory: {
				hook: "afterEmit",
				async: true,
				callback: this.prepareIsolatedReleaseDirectory.bind(this)
			}
		};
    }


	/**
	 * @param {WebpackCompilation} compilation
	 * @param {any} pkgJso
	 * @returns {any}
	 */
	applyEntryExportedPaths(compilation, pkgJso)
	{
		const b = this.build, l = this.logger,
			  bo = this.buildOptions,  basePath = b.getBasePath(),
			  distPath = bo.dist ? this._path_.absPath(bo.dist) : b.getDistPath(),
		  	  assetsHashed = !!b.options.hash?.enabled && !b.options.hash.emitNoHash,
			  distPathRel = this._path_.relativePathEx(basePath, distPath, { psx: true });

		let pkgJson = this._json_.safeStringify(pkgJso);

		compilation.getAssets().filter((a) => this.isOutputAsset(a.name, true, false, true, assetsHashed, true))
		.forEach((asset) =>
		{
			const chunk = this.fileNameStrip(asset.name, true);
			l.write("   apply modifications to entry file path and filename", 1);
			l.value("      chunk name", chunk, 2);
			l.value("      asset filename ", asset.name, 2);
			if (distPathRel && distPathRel !== ".")
			{
				const pathRgx = new RegExp(`" *: *"(?:\\.\\/)?${distPathRel}\\/${chunk}\\.`, "g");
				l.write("      apply relative path mod", 1);
				l.value("         relative dist directory", distPathRel || ".", 2);
				pkgJson = pkgJson.replace(pathRgx, (m) => m.replace(`${distPathRel}/`, ""));
			}
			if (assetsHashed)
			{
				const hash = asset.info.contenthash;
				if (this._types_.isString(hash))
				{
					const ext = this._path_.extname(asset.name).split(".").reverse()[0],
							hashRgx = new RegExp(`" *: *"(?:\\.\\/)?${chunk}.${ext}`, "g");
					l.write("      apply filename contenthash mod", 1);
					l.value("         asset contenthash", hash, 2);
					l.value("         asset extension", ext, 2);
					pkgJson = pkgJson.replace(hashRgx, (m) => m.replace(`${chunk}.${ext}`, `${chunk}.${hash}.${ext}`));
				}
				else {
					throw new Error("contenthash is not a string, unable to process entry path and filename mods");
				}
			}
		});

		return this._json_.safeParse(pkgJson);
	}


	/**
	 * @private
	 * @param {WebpackCompilation} compilation
	 */
	async prepareIsolatedReleaseDirectory(compilation)
	{
		try
		{   //
			// TODO - processing of additional builds for iso release
			//        what to do for app / webapp combo?
			//
			if (WpwIsoReleasePlugin.releasePrepared[this.isoReleaseType])
			{
				await this.processAdditionalBuild();
				return;
			}

			const b = this.build,
				  bo = this.buildOptions,
				  basePath = b.getBasePath(),
				  pkgJso = this._json_.safeParse(safeStringify(b.pkgJson)),
			      distPath = bo.dist ? this._path_.absPath(bo.dist) : b.getDistPath(),
			      l = this.hookstart(`prepare dist directory for isolated ${this.isoReleaseType} publish`, true);

			l.value("   output directory", distPath, 1);
			if (b.options.vsceiso?.enabled && pkgJso.name.startsWith("@"))
			{
				l.write("   remove @scope from package.json.name", 1);
				pkgJso.name = pkgJso.scopedName.name;
			}

			const { deps, dDeps } = this.processExternalDependencies(pkgJso);
			if (b.isAnyAppOrLib)
			{
				deps.push(...this.processExternalPeerDependencies(pkgJso));
			}
			// else if (b.isLib)
			// {
			// 	merge(pkgJso, { peerDependencies: deps.splice(0).reduce((p, c) => this._obj_.apply(p, { [c[0]]: c[1] }), {}) });
			// }
			if (deps.length === 0)
			{
				const nmDistPath = this._path_.joinPath(distPath, "node_modules");
				cleanPrototype(pkgJso, "bundleDependencies", "bundledDependencies");
				if (existsSync(nmDistPath)) {
					await this._fs_.deleteDir(nmDistPath);
				}
			}

			apply(pkgJso, { dependencies: {}, devDependencies: {} });
			deps.sort((x, y) => x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0)
			     .forEach(([ k, v ]) => { pkgJso.dependencies[k] = v; });
			dDeps.sort((x, y) => x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0)
			     .forEach(([ k, v ]) => { pkgJso.devDependencies[k] = v; });

			//
			// copy anything listed in package.json.'files'
			//
			const files = this._arr_.asArray(/** @type {string[]} */(pkgJso.files))
			              .map((f) => this._path_.absPath(f, false, basePath));
			for (const path of files.filter((f) => this._fs_.existsSync(f)))
			{
				if (this._fs_.isDirectory(path)) {
					await this._fs_.copyDirAsync(path, distPath);
				} else { await this._fs_.copyFileAsync(path, distPath); }
			}

			//
			// remove insecure properties and properties with an empty value type i.e. {} and/or []
			//
			let defCt = Object.keys(pkgJso).length;
			cleanPrototype(
				pkgJso, ...this._arr_.uniq([
					"ap", "babel", "chai", "devDependencies", "files", "greenkeeper", "mocha", "nyc", "overrides",
					"release", "scopedName", "scripts", "wpw", ...asArray(bo.removeProperties)
				])
			);
			let rmvCt = defCt - Object.keys(pkgJso).length;
			l.write(`   removed ${rmvCt} non-required ${pluralize("property", rmvCt)} from package.json`, 1);
			defCt = Object.keys(pkgJso).length;
			this._obj_.removeEmpty(pkgJso, { obj: true, arr: true });
			rmvCt = defCt - Object.keys(pkgJso).length;
			l.write(`   removed ${rmvCt} empty ${pluralize("property", rmvCt)} from package.json`, 1);

			//
			// rc property overrides
			//
			if (bo.packageJson)
			{
				l.write("   apply rc configured property overrides", 1);
				apply(pkgJso, bo.packageJson);
			}

			//
			// entry file path mutations - contenthash  | relative path change
			//
			apply(pkgJso, this.applyEntryExportedPaths(compilation, pkgJso));

			//
			// write minimal package.json file to iso dist dir
			//
			await this.writePackageJson(pkgJso);

			//
			// write an .npmignore or .vscodeignore file to iso dist dir base on iso-release type
			//
			await this.writeIgnoreFile();

			//
			// maybe run npm install for 'vsce' type release & set package.json.bundleDependencies
			//
			if (this.isoReleaseType === "vsce" && pkgJso.dependencies && !this._types_.isObjectEmpty(pkgJso.dependencies))
			{
				l.write("   run npm install @dist for vsix bundling of external packages", 1);
				await this.exec("npm install", "npm", true, { cwd: distPath });
				await this._fs_.deleteFile("package.lock.json");
				if (!pkgJso.bundleDependencies)
				{   l.write("   set package.json 'bundleDependencies' property", 2);
					pkgJso.bundleDependencies = true;
				}
			}

			//
			// copy configured static resources
			//
			await this.writeResourceFiles();

			WpwIsoReleasePlugin.releasePrepared[this.isoReleaseType] = true;
		}
		catch(e)
		{   this.addMessage({
				exception: e,
				code: this.MsgCode.ERROR_PLUGIN_FAILED,
				message: `preparation of isolated ${this.isoReleaseType} publish directory failed`
			});
		}
		finally {
			this.hookdone(`completed preparation of dist directory for isolated ${this.isoReleaseType} publish`);
		}
	}


	//
	// TODO - processing of additional builds for iso release
	//        what to do for app / webapp combo?
	//
	/**
	 * @private
	 */
	async processAdditionalBuild()
	{
		// const b = this.build, l = this.logger, bo = this.buildOptions,
		// 	  pkgJso = this._json_.safeParse(safeStringify(b.pkgJson)),
		// 	  pkgJsoDist = await this._fs_.readJsonAsync(this._path_.joinPath(bo.dist, "package.json"));
		// l.write(`   process additional build for isolated ${this.isoReleaseType} publish`, 1);
		// const { deps } = this.processExternalDependencies(pkgJso, true);
		// if (deps.length > 0)
		// {   if (!pkgJsoDist.dependencies) {
		// 		pkgJsoDist.dependencies = {};
		// 	}
		// 	deps.sort((x, y) => x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0)
		// 	    .forEach(([ k, v ]) => { pkgJsoDist.dependencies[k] ||= v; });
		// 	await this.writePackageJson(pkgJsoDist);
		// }
		this.build.logger.write(`   skip isolated ${this.isoReleaseType} publish, already configured and processed`, 1);
	}


	/**
	 * @private
	 * @param {any} pkgJso
	 * @param {boolean} [excludeNoImport]
	 * @returns {{ deps: WpwDependencyNameVersionPair, dDeps: WpwDependencyNameVersionPair }}
	 */
	processExternalDependencies(pkgJso, excludeNoImport)
	{
		const b = this.build, l = this.logger,
			  /** @type {WpwDependencyNameVersionPair} */deps = [],
			  extOptions =this._obj_.applyIf(b.options.externals || {}, {
				  enabled: false, noImportAll: false, ignored: [], noImportBundled: [], noImportExternal: []
			  }),
			  all = !!extOptions.noImportAll,
			  ignoredRgxStr = extOptions.ignored.join("|"),
			  bndRgxStr = extOptions.noImportBundled.join("|"),
			  externalRgxStr = extOptions.noImportExternal.join("|"),
			  dDeps = Object.entries(this._obj_.asObject(pkgJso.devDependencies)),
			  ignoredRgx = !all && ignoredRgxStr ? new RegExp(ignoredRgxStr) : undefined;

		l.write("   process package dependencies", 1);
		l.value("      is all external", all, 1);
		l.value("      ignored regex", ignoredRgxStr, 2);
		l.value("      non-imported bundled regex", bndRgxStr, 2);
		l.value("      non-imported external regex", externalRgxStr, 2);
		l.value("      all-inclusive external regex", b.externalsRgx, 2);

		for (const [ k, v ] of Object.entries(this._obj_.asObject(pkgJso.dependencies)))
		{
			if ((!ignoredRgx || !ignoredRgx.test(k)))
			{
				const rgxKey = this._rgx_.escapeRegExp(k),
					  bRgx = !all && bndRgxStr ? new RegExp(bndRgxStr) : undefined,
					  eRgx = !all && externalRgxStr ? new RegExp(bndRgxStr) : undefined,
					  rgxFind = !all ? new RegExp(`^${k}[\\\\/][\\w\\-]+[\\\\/]\\.[cm]?[jt]sx?$`) : undefined,
					  nM = !all ? b.nodeModules.find((e) => e.name === k || rgxFind.test(this._rgx_.escapeRegExp(e.name))) : {};
				l.write(`   process package '${k}'`, 2);
				l.value("      context", nM?.ctx, 2);
				l.value("      request", nM?.req, 2);
				if (all || nM?.external || b.externalsRgx.test(rgxKey) || (!nM && (eRgx?.test(rgxKey) || !bRgx?.test(rgxKey))))
				{
					let ignNm = "";
					if (!nM && !excludeNoImport)
					{
						const c = b.buildConfigs.filter((c2) =>
							[ "lib", "app", "webapp" ].includes(c2.type) && (c2.paths?.ctx) && c2.paths.ctx !== b.getContextPath()
						);
						for (const c2 of c)
						{
							const rgx = new RegExp(`[\\( ] *["']${k}(?:[\\\\/][\\w\\-\\\\/]+)?["']`),
								  files = this._fs_.findFilesSync("**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}", { cwd: c2.paths.src });
							for (const file of files) {
								if (rgx.test(this._fs_.readFileSync(file))) { ignNm = c2.name; break; }
							}
							if (ignNm) { break; };
						}
					}
					if (!ignNm || (excludeNoImport && nM?.external))
					{
						l.write(`   add package '${k}' to dependencies`, 2);
						deps.push([ k, v ]);
					}
					else {
						l.write(`   remove package '${k}' [import of build '${ignNm}'] from dependencies`, 2);
					}
				}
				else
				{   l.write(`   move package '${k}' to devDependencies`, 3);
					dDeps.push([ k, v ]);
				}
			}
			else { l.write(`   remove ignored package '${k}' from dependencies`, 2); }
		}

		return { deps, dDeps };
	}


	/**
	 * @private
	 * @param {any} pkgJso
	 * @returns {WpwDependencyNameVersionPair}
	 */
	processExternalPeerDependencies(pkgJso)
	{
		const b = this.build, l = b.logger, deps = [],
			  pkgDeps = this._obj_.asObject(pkgJso.dependencies),
			  pkgPeerDeps = this._obj_.asObject(pkgJso.peerDependencies),
			  pkgOptDeps = this._obj_.asObject(pkgJso.optionalDependencies);

		const _mPkgJso = (/** @type { IWpwRuntimeExternal} */m) =>
			findDotJsonFileUpSync("package.json", resolvePath(b.getBasePath(), "node_modules", m.name)).data;

		const _jsoDep = (/** @type { IWpwRuntimeExternal} */m, /** @type { any} */jso) =>
			!!(pkgOptDeps[m.name]) || pkgPeerDeps[m.name] || this._obj_.asObject(jso.dependencies)[m.name];
		//
		// add dynamically loaded runtime dependencies that were either not directly imported
		// or were imported by a bundled module but excluded by the 'alwaysExternal' regex
		//
		b.nodeModules.filter((m) => m.external && m.rt && !_jsoDep(m, pkgJso)).forEach((m) =>
		{
			const version = _mPkgJso(m).version;
			deps.push([ m.name, `${version}` ]);
			l.write(`   add runtime package '${m.name}' [${version}] to dependencies`, 1);
		});

		//
		// add peer dependencies of this build's top-level dependencies
		//
		b.nodeModules.filter((m) =>
			!m.external && !!pkgDeps[m.name] && !pkgPeerDeps[m.name] && !deps.find(([ k ]) => k === m.name)
		).forEach((m) =>
		{
			const mName = m.name, mPkgJso = _mPkgJso(m),
			      vRgx = /^[~^]?[0-9]+\.[0-9]+\.[0-9]+(?:-[a-z]+\.[0-9]+)?$/;

			Object.entries(this._obj_.asObject(mPkgJso.peerDependencies))
			      .filter(([ k ]) => !pkgPeerDeps[k] && !b.nodeModules.find((m) => m.name === k && !m.external))
			      .forEach(([ k, v ]) =>
			{
				let version = v.trim().split(" ").pop();
				if (!vRgx.test(version))
				{
					const vParts = version.replace(/[^.^~0-9]/g, "").split(".");
					if (vParts.length >= 2 && vParts[0] && vParts[1] && !version.startsWith("<"))
					{
						version = `^${vParts[0]}.${vParts[1]}.0`;
					}
					else {
						version = vParts[0] ? `^${vParts[0]}.0.0` : "*";
					}
				}
				if (version && version !== "0.0.0")
				{
					const exDep = deps.find(([ ek ]) => ek === k);
					if (!exDep)
					{
						deps.push([ k, `${version}` ]);
						l.write(`   add imported package '${k}' [${version}] [peer of ${mName}] to dependencies`, 1);
					}
					else if (this._ver_.newerVersion(version, exDep[1]))
					{
						deps.push([ k, `${version}` ]);
						l.write(`   update dependency package '${k}' version to [${version}] [peer of ${mName}]`, 1);
					}
				}
			});
		});

		return deps;
	}


	/**
	 * @private
	 */
	async writeIgnoreFile()
	{
		const b = this.build,
			  l = this.logger,
			  bo = this.buildOptions,
			  ctxPath = b.getContextPath(),
			  distPath = bo.dist ? this._path_.absPath(bo.dist) : b.getDistPath();

		if (this.isoReleaseType === "vsce")
		{
			const vscIgnoreFile = await this._fs_.findExPath([ ".vscodeignore" ], ctxPath, true);
			if (this._fs_.existsSync(vscIgnoreFile))
			{
				l.write("   copy existing .vscodeignore file", 1);
				await this._fs_.copyFileAsync(vscIgnoreFile, distPath);
			}
			else
			{   l.write("   write .vscodeignore file", 1);
				await writeFileAsync(
					joinPath(distPath, ".vscodeignore"), "*.vsix\npackage-lock.*\nnode_modules\ntest*\nspec\ndev\ndevelopment\n"
				);
			}
		}
		else
		{   l.write("   write .npmignore file", 1);
			await writeFileAsync(joinPath(distPath, ".npmignore"), "**/package.json\n");
		}
	}


	/**
	 * @private
	 * @param {any} pkgJso
	 */
	async writePackageJson(pkgJso)
	{
		this.logger.write("   write minimal package.json file", 1);
		this._obj_.removeEmpty(pkgJso, { obj: true, arr: true });
		const pkgJson = this.buildOptions.packageJsonMinify !== false ?
			            this._json_.safeStringify(pkgJso) : this._json_.safeStringify(pkgJso, null, 4);
		await writeFileAsync(joinPath(this.buildOptions.dist, "package.json"), pkgJson);
	}


	/**
	 * @private
	 */
	async writeResourceFiles()
	{
		const l = this.logger,
			  b = this.build,
			  bo = this.buildOptions,
			  basePath = b.getBasePath();
			  // targets = WebpackTargets.filter((t) => b.buildConfigs.find((b) => b.target === t)),
		      // nms = b.nodeModules.filter((nm) => !nm.builtin && !nm.external && !/^\./.test(nm.req)),
			  // distPath = this.buildOptions.dist ? this._path_.absPath(this.buildOptions.dist) : b.getDistPath();

		this.logger.write("   write static resource files file", 1);
		if (bo.changelog !== false)
		{
			const changelogFile = this._types_.isString(bo.changelog, true) ? this._path_.absPath(bo.changelog, true) :
								  await this._fs_.findExPath([ "CHANGELOG", "CHANGELOG.txt", "CHANGELOG.md" ], basePath, true);
			if (changelogFile)
			{
				l.write("      copy changelog file", 1);
				await this._fs_.copyFileAsync(changelogFile, bo.dist);
			}
		}
		if (bo.license !== false)
		{
			const licenseFile = this._types_.isString(bo.license, true) ? this._path_.absPath(bo.license, true) :
								await this._fs_.findExPath([ "LICENSE", "LICENSE.txt", "LICENSE.md" ], basePath, true);
			if (licenseFile)
			{
				l.write("      copy license file", 1);
				await this._fs_.copyFileAsync(licenseFile, bo.dist);
			}
		}
		if (bo.nls !== false)
		{
			const nlsFile = this._types_.isString(bo.nls, true) ? this._path_.absPath(bo.nls, true) :
							await this._fs_.findExPath([ "package.nls.json" ], basePath, true);
			if (nlsFile)
			{
				l.write("      copy license file", 1);
				await this._fs_.copyFileAsync(nlsFile, bo.dist);
			}
		}
		if (bo.readme !== false)
		{
			const readmeFile = this._types_.isString(bo.readme, true) ? this._path_.absPath(bo.readme, true) :
							   await this._fs_.findExPath([ "README", "README.txt", "README.md" ], basePath, true);
			if (readmeFile)
			{
				l.write("      copy readme file", 1);
				await this._fs_.copyFileAsync(readmeFile, bo.dist);
			}
		}

		// this.logger.write(`      check ${nms.length} node_modules for vendor.license files`, 1);
		// const licFile = await this._fs_.findExPath(
		// 	[ "vendor.LICENSE", "LICENSE", "LICENSE.txt", "LICENSE.md" ], this._arr_.uniq([ distPath, b.getDistPath() ]),
		// 	true, this._path_.resolvePath(distPath, "LICENSE")
		// );
		// const mainLicEx = await this._fs_.existsAsync(licFile),
		//       mainLicContent = mainLicEx ? await this._fs_.readFileAsync(licFile) : "";
		// for (const nm of nms.filter((nm) => !mainLicContent.includes(`--- VENDOR | DEPENDENCY [${nm.name}] ---`)))
		// {
		// 	const vLicenseFile = await this._fs_.findExPath([ "vendor.LICENSE", ...targets.map((t) => `vendor.${t}.LICENSE`) ], [
		// 		this._path_.resolvePath(b.nodeModulesPath, nm.name),
		// 		...targets.map((t) => this._path_.resolvePath(b.nodeModulesPath, nm.name, `${t}`))
		// 	], true);
		// 	if (vLicenseFile)
		// 	{   // const fNm = `${nm.name.replace(/\//g, ".").replace(/[^\w]/g, "")}.${this._path_.basename(vLicenseFile)}`;
		// 		l.write(`         add vendor.license from pkg '${nm.name}' to '${this._path_.basename(licFile)}'`, 1);
		// 		const vLic = await this._fs_.readFileAsync(vLicenseFile);
		// 		if (mainLicEx) {
		// 			await this._fs_.appendFileAsync(licFile, `\n--- VENDOR | DEPENDENCY [${nm.name}] ---\n\n${vLic.trim()}\n`);
		// 		}
		// 		else {
		// 			await this._fs_.writeFileAsync(licFile, `--- VENDOR | DEPENDENCY [${nm.name}] ---\n\n${vLic.trim()}\n`);
		// 		}
		// 	}
		// }
		// if (b.isAnyLib)
		// {   licFile = await this._fs_.findExPath([ "LICENSE", "LICENSE.txt", "LICENSE.md" ], distPath, true);
		// 	const vLicFile = this._path_.resolvePath(distPath, "vendor.LICENSE");
		// 	if (this._fs_.existsSync(licFile) && this._fs_.existsSync(vLicFile))
		// 	{   const vLic = this._fs_.readFileSync(vLicFile);
		// 		await this._fs_.appendFileAsync(licFile, `\n--- VENDORS / DEPENDENCIES ---\n\n${vLic.trim()}\n`);
		// 		await deleteFile(vLicFile);
		// 	}
		// }
		this.logger.write("   static resource files check completed", 1);
	}
}


module.exports = WpwIsoReleasePlugin;