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