exports_rules.js

/**
 * @file src/exports/rules.js
 * @copyright @spmhome @_2025
 * @author Scott Meesseman @spmeesseman
 *
 * @description @see {@link https://webpack.js.org/configuration/rules webpack.js.org/rules}
 *
 *//** */

const WpwWebpackExport = require("./base");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");


/**
 * @augments WpwWebpackExport
 * @implements {WpwWebpackExport}
 */
class WpwRulesExport extends WpwWebpackExport
{
	/**
     * @param {WpwExportOptions} options
     */
	constructor(options)
	{
		super(options);
        this.buildOptions = /** @type {WpwBuildOptionsExportConfig<"rules">} */(this.buildOptions); // reset for typings
	}


	get rules() { return this.build.wpc.module.rules; }


	/**
     * @override
     */
	static create = WpwRulesExport.wrap.bind(this);


	/**
	 * @override
     * @param {WpwBuild} build
	 */
	app(build)
	{
		const b = this.build,
		      loader = this.getSourceLoader(),
			  srcRgx = build.getSrcPathRegExp(),
			  jsTsRgx = build.isJsTs ? /\.[cm]?[jt]sx?$/ : (build.isTs || build.isTsJs ? /\.[cm]?tsx?$/ : /\.[cm]?jsx?$/),
			  incNodeModules =  this._arr_.asArray(build.wpc.resolve.modules)
			                    .map((m) => this._path_.isAbsPath(m) ? m : this._path_.resolvePath(build.getContextPath(), m));

		build.logger.write(`add '${build.type}' build rules`, 1);
		build.logger.value("   configured loader", loader.loader, 2);

		if (build.resources) {
			this.appResources(build);
		}
		if (build.debug) {
			this.appDebug(build);
		}

		if (loader.loader === "ts-loader" && build.options.types?.enabled)
		{
			const outFilename = build.options.types.bundle?.enabled ?
				                (b.options.output?.name || build.name).replace(/\.d\.ts$/, "") : undefined;
			loader.options.compilerOptions = this._obj_.apply(
				b.tsc.getCompilerOptions(),
				{   declaration: true,
					declarationMap: false,
					emitDeclarationsOnly: false,
					outFile: outFilename ? this._path_.joinPath(
						build.virtualEntry.dirDistRelToProj, `${outFilename}.d.ts`
					) : undefined,
					declarationDir: !outFilename ? build.virtualEntry.dirBuild : undefined
				}
			);
		}

		this.rules.push(
		{
			use: loader, include: incNodeModules, test: /\.[\w-]{1,6}$/,
			exclude: [ srcRgx,...this.getExcludes(false, true) ]
		}, {
			use: loader, test: jsTsRgx, include: srcRgx, exclude: this.getExcludes()
		});
	}


	/**
	 * @private
     * @param {WpwBuild} build
	 */
	appDebug(build)
	{
		const exclude = this.getExcludes(), srcPathRgx = build.getSrcPathRegExp();
		build.logger.write(`add '${build.type}' build rules`, 1);
		build.logger.value("   configured loader", "string-replace-loader", 2);
		this.rules.push(
		{
			issuerLayer: "release",
			exclude, include: srcPathRgx,
			test: new RegExp(`${build.source.dotext}$`),
			// include(resourcePath, issuer) {
			// 	console.log(`  context: ${build.wpc.context} (from ${issuer})`);
			// 	console.log(`  resourcePath: ${resourcePath} (from ${issuer})`);
			// 	console.log(`  included: ${path.relative(build.wpc.context || ".", resourcePath)} (from ${issuer})`);
			// 	return true; // include all
			// },
			loader: "string-replace-loader", options: this.stripLoggingOptions()
		},
		{
			exclude, include: srcPathRgx,
			test: new RegExp(`wrapper${build.source.dotext}$`),
			issuerLayer: "release", loader: "string-replace-loader",
			options: {
				search: /^log\.(?:write2?|error|warn|info|values?|method[A-Z][a-z]+)\]/g,
				replace: "() => {}]"
			}
		});
	}


	/**
	 * @private
     * @param {WpwBuild} build
	 */
	appResources(build)
	{
		const res = build.resources,
			  exclude = this.getExcludes(),
			  sourceAssets = this._arr_.asArray(res.source),
			  inlineAssets = this._arr_.asArray(res.inline),
			  resourceAssets = this._arr_.asArray(res.resource);

		build.logger.write(`add '${build.type}' build rules`, 1);
		build.logger.value("   configured loader", "asset", 2);
		build.logger.value("   # of 'inline' assets", inlineAssets.length, 2);
		build.logger.value("   # of 'resource' assets", resourceAssets.length, 2);
		build.logger.value("   # of 'source' assets", sourceAssets.length, 2);

		if (sourceAssets.length > 0)
		{
			this.rules.push({
				exclude, type: "asset/source", test: /^.+\.(?:[a-zA-Z0-9]{1,6})$/,
				include: new RegExp(this._rgx_.escapeRegExp(sourceAssets.join("|")))
			});
		}
		if (resourceAssets.length > 0)
		{
			this.rules.push({
				exclude, type: "asset/resource", test: /^.+\.(?:[a-zA-Z0-9]{1,6})$/,
				include: new RegExp(this._rgx_.escapeRegExp(resourceAssets.join("|")))
			});
		}
		if (inlineAssets.length > 0)
		{
			this.rules.push({
				exclude, type: "asset/inline", test: /^.+\.(?:[a-zA-Z0-9]{1,6})$/,
				include: new RegExp(this._rgx_.escapeRegExp(inlineAssets.join("|")))
			});
		}
	}


	/**
	 * @override
	 */
	doxygen() { this.task(); }


	/**
	 * @override
	 */
	extjsdoc() { this.task(); }



	/**
     * @since 1.11.0
	 * @param {*} loader
	 * @param {boolean} [allowTest]
	 * @param {boolean} [allowNodeModules]
	 * @param {boolean} [allowTypes]
	 * @param {boolean} [allowDts]
	 * @returns {RegExp[]}
	 */
	getExcludes(loader, allowTest, allowNodeModules, allowTypes, allowDts)
	{
		const ex = [ /\.vscode[\\/]/ ];
		if (allowNodeModules !== true) {
			ex.push(/node_modules/);
		}
		if (allowTest !== true) {
			ex.push(/tests?[\\/]/, /\.test\.[jt]s$/, /\.spec\.[jt]s$/);
		}
		if (allowTypes === false) {
			ex.push(/types[\\/]/);
		}
		if (allowDts === false) {
			ex.push(/\.d\.ts$/);
		}
		if (this.build.isTranspiled && loader === "babel")
		{   try
			{   this._arr_.asArray(this.build.tsc.tsconfig.exclude)
				.filter((exg) => !exg.includes("node_modules"))
				.map((exg) => exg.replace("**/", "[\\w-\\\\/\\.]+\\/").replace("/**", "\\/.+?\\*\\.\\*")
				.replace(/\/(\*|[^*])(\.\*)$/, (_, g, g2) => `.+${g && !g2 ? g : ""}`)
				.replace(/[\\/]/g, this._path_.sep))
				.filter((exg) => !(/^(?:[^/]\..+|\.\.?\*?|)$/).test(exg))
				.forEach((exg) => ex.push(new RegExp(exg)));
			} catch {}
		}
		return ex;
	}


	/**
	 * @private
	 * @param {string} [loaderNm]
	 * @returns {WebpackRuleSetUseItem}
	 */
	getSourceLoader(loaderNm)
	{
		const b = this.build,
			  lNm = loaderNm || b.loader || b.options.rules?.loader ||
			    	(b.isTranspiled ? (b.isTs || b.isTsJs ? "ts" : "babel") : "babel");
		return this.sourceLoaders[lNm](b.isTs || b.isTsJs);
	}


	/**
	 * The `jsdoc` module is a {@link WpwBaseTaskPlugin} type module, using a virtual file so that
	 * Webpack runs through it's normal process as if it going to bundle ts/js code.  This scenario
	 * is used when the build really isn't a Webpack bundling, but more of something that usually
	 * a task runner would handle, e.g this `jsdoc` build or the `types` build.  They are implemented
	 * here in an effort to condense all tasks to Webpack only, where something like Gulp, Grunt, Ant,
	 * etc is not needed, especially for several smaller projects.
	 *
	 * @override
	 * @throws {SpmhError}
	 */
	jsdoc() { this.task(); }


	/**
	 * @override
     * @param {WpwBuild} build
	 */
	lib(build) { this.app(build); }


	/**
	 * @override
     * @param {WpwBuild} build
	 */
	plugin(build) { this.app(build); }


	/**
	 * @override
	 */
	resource()
	{
		this.task();
		// this.rules.push(this.taskRule(this.build.options.resource, "wpw-static-loader");
	}


	/**
	 * @override
	 */
	schema() { this.task(); }


	/**
	 * @override
	 */
	script() { this.task(); }


	sourceLoaders =
	{
		/**
		 * @param {boolean} [ts]
		 * @returns {WebpackRuleSetUseItem}
		 */
		babel: (ts) =>
		{
			const b = this.build, bo = this.buildOptions, presets = [],
				  engineNodeVersion =this._node_.nodejsEnginesVersion(b.pkgJson),
				  nodejsVersion = engineNodeVersion || b.tsc.versionNodeRuntime,
				  babel = this._obj_.asObject(bo.babel), compilerOptions = b.tsc.compilerOptions,
				  esmModulesLib = compilerOptions.target || this._arr_.asArray(compilerOptions.lib)[0],
                  cfg = { loader: "babel-loader", options: { presets, cwd: this._path_.resolvePath(b.nodeModulesPathWpw, "..") }};

			b.logger.values([
				[ "use default presets", !!babel.defaultPresets ], [ "node lts version",  b.tsc.versionNodeLtsMajor ],
                [ "node package.json.engine version",  nodejsVersion ], [ "target node version",  nodejsVersion ],
				[ "is wpw global exec", this.global.isGlobalExec, 2 ], [ "is wpw dev exec", this.global.isDevExec, 2 ],
				[ "is wpw dist exec", this.global.isDevDistExec, 2 ]
			], 1, "", false, "configure babel-loader");

			if (!b.isWeb && babel.defaultPresets !== true) {
                presets.push([ "@babel/preset-env", { targets: { node: nodejsVersion, esmodules: b.isModule }}]);
            } else { presets.push([ "@babel/preset-env" ]); }
			if (b.isModule) {
                cfg.options.presets.push([ "@babel/preset-modules", { targets: { node: nodejsVersion, modules: esmModulesLib }}]);
			}
			if (ts || b.isTs || b.isTsJs || b.isJsTs) {
				cfg.options.presets.push([ "@babel/preset-typescript" ]);
			}

			if (this._types_.isArray(babel.plugins)) {
				cfg.options.plugins = babel.plugins.map((p) => this._arr_.asArray(p));
			}
			if (this._types_.isArray(babel.presets)) {
				cfg.options.presets.push(...babel.presets.map((p) => this._arr_.asArray(p)));
			}

			return cfg;
		},

		/**
		 * @param {boolean} [ts]
		 * @returns {WebpackRuleSetUseItem}
		 */
		esbuild: (ts) =>
		{
			const b = this.build,
				  ecmaVersion = this._arr_.asArray(b.target).find((t) => t.startsWith("es")) ||
				                b.tsconfig.compilerOptions.target ||
								this._arr_.asArray(b.tsconfig.compilerOptions.lib)[0];
			const cfg =
			{  loader: "esbuild-loader",
				options:
				{   color: false,
					loader: ts ? "ts" : "js",
					context: b.getContextPath(),
					tsconfig: b.source.info.path,
					raw: { ...b.tsconfig.compilerOptions },
					target: this._arr_.uniq([
						b.tsc.versionNodeLtsMajor, ecmaVersion,
						...this._arr_.asArray(b.tsconfig.compilerOptions.lib)
					])
				}
			}; return cfg;
		},

		/**
		 * @param {boolean} [_ts]
		 * @returns {WebpackRuleSetUseItem}
		 */
		task: (_ts) =>
		{
			const b = this.build;
			const cfg =
			{   loader: "wpw-task-loader",
				options: {
					build: b,
					input: b.getSrcPath(),
					output: b.getDistPath(),
					// context: this.build.getContextPath(),
					vFile: `${b.virtualEntry.filePathAbs}${b.source.dotext}`
				}
			}; return cfg;
		},

		/**
		 * @param {boolean} [_ts]
		 * @returns {WebpackRuleSetUseItem}
		 */
		ts: (_ts) =>
		{
			const b = this.build;
			const cfg =
			{   loader: "ts-loader",
				options:
				{   transpileOnly: true,
					experimentalWatchApi: false,
					context: b.getContextPath(),
					configFile: b.tsc.configFileRtPath,
					logInfoToStdOut: b.logger.level >= 1,
					compilerOptions: b.tsc.compilerOptions, // b.tsc.getTranspilationCompilerOptions(),
					logLevel: b.log.level >= 2 ? "info" : (b.log.level === 1 ? "warn" : "error")
				}
			}; return cfg;
		}
	};


	/**
	 * @private
	 * @returns {Record<string, any>}
	 */
	stripLoggingOptions = () => ({
		multiple: [
		{   // eslint-disable-next-line stylistic/max-len
			search: /=>\s*(?:this\.wrapper|this|wrapper|w)\._?log\.(?:write2?|info|values?|method[A-Z][a-z]+)\s*\([^]*?\)\s*\}\);/g,
			replace: (/** @type {string} */r) => {
				return "=> {}\r\n" + r.substring(r.slice(0, r.length - 3).lastIndexOf(")") + 1);
			}
		},
		{
			search: /=>\s*(?:this\.wrapper|this|wrapper|w)\._?log\.(?:write2|info|values?|method[A-Z][a-z]+)\s*\([^]*?\),/g,
			replace: "=> {},"
		},
		{
			search: /=>\s*(?:this\.wrapper|this|wrapper|w)\._?log\.(?:write2?|info|values?|method[A-Z][a-z]+)\s*\([^]*?\) *;/g,
			replace: "=> {};"
		},
		{
			// eslint-disable-next-line stylistic/max-len
			search: /(?:this\.wrapper|this|wrapper|w)\._?log\.(?:write2?|info|values?|method[A-Z][a-z]+)\s*\([^]*?\)\s*;\s*?(?:\r\n|$)/g,
			replace: "\r\n"
		},
		{
			search: /this\.wrapper\.log\.(?:write2?|info|values?|method[A-Z][a-z]+),/g,
			replace: "this.wrapper.emptyFn,"
		},
		{
			search: /wrapper\.log\.(?:write2?|info|values?|method[A-Z][a-z]+),/g,
			replace: "wrapper.emptyFn,"
		},
		{
			search: /w\.log\.(?:write2?|info|values?|method[A-Z][a-z]+),/g,
			replace: "w.emptyFn,"
		},
		{
			search: /this\.wrapper\.log\.(?:write2?|info|values?|method[A-Z][a-z]+)\]/g,
			replace: "this.wrapper.emptyFn]"
		},
		{
			search: /wrapper\._?log\.(?:write2?|info|values?|method[A-Z][a-z]+)\]/g,
			replace: "wrapper.emptyFn]"
		},
		{
			search: /w\.log\.(?:write2?|info|values?|method[A-Z][a-z]+)\]/g,
			replace: "w.emptyFn]"
		}]
	});


	/**
	 * @private
	 */
	task()
	{
		const b = this.build,
		      loader = this.getSourceLoader("task"),
			  vFile =  `${b.virtualEntry.file}${b.source.dotext}`,
			  vFilePath = `${b.virtualEntry.filePathAbs}${b.source.dotext}`;
		b.logger.value("add compilation task-plugin trigger rules", 1);
		b.logger.value("   configured loader", loader.loader, 2);
		b.logger.value("   v-build file name", vFile, 2);
		b.logger.value("   v-build file path", vFilePath, 3);
		this.rules.push(
		{
			use: loader, include: vFilePath, exclude: this.getExcludes(loader),
			test: new RegExp(`[\\\\/]${b.virtualEntry.file}${b.source.dotext}$`)
		});
	}


	/**
	 * @override
     * @param {WpwBuild} build
	 */
	tests(build)
	{
		const loader = this.getSourceLoader();
		build.logger.write(`add '${build.type}' build rules`, 1);
		build.logger.value("   configured loader", loader.loader, 2);
		this.rules.push(
		{   use: loader, test: new RegExp(`${build.source.dotext}x?$`),
			include: build.getSrcPathRegExp(), exclude: this.getExcludes(loader, true)
		});
	}


	/**
	 * The `types` module is a {@link WpwBaseTaskPlugin} type module, using a virtual file so that
	 * Webpack runs through it's normal process as if it going to bundle ts/js code.  This scenario
	 * is used when the build really isn't a Webpack bundling, but more of something that usually
	 * a task runner would handle, e.g this `types` build or the `jsdoc` build.  They are implemented
	 * here in an effort to condense all tasks to Webpack only, where something like Gulp, Grunt, Ant,
	 * etc is not needed, especially for several smaller projects.
	 *
	 * @override
     * @param {WpwBuild} build
	 */
	types(build)
	{
		const config = build.options.types,
		      loader = this.getSourceLoader();
		build.logger.write(`add '${build.type}' build rules`, 1);
		build.logger.value("   configured loader", loader.loader, 2);
		if (config && (config.mode === "loader" || config.mode === "plugin")) {
			this.task();
		}
		this.rules.push(
		{   use: loader,
			test: /\.tsx?$/,
			exclude: this.getExcludes(loader),
			include: this._arr_.uniq([
				build.getSrcPath(), build.getSrcPath({ build: "app" })
			], process.platform === "win32")
		});
	}


	/**
	 * @override
     * @param {WpwBuild} build
	 */
	webapp(build)
	{
		const wpCss = build.options.web.css,
			  loader = this.getSourceLoader(),
		      exclude = this.getExcludes(false, true);
		build.logger.write(`add '${build.type}' build rules`, 1);

		this.rules.push(
		{
			exclude,
			use: loader,
			test: /\.[cm]?js/,
			resolve: { fullySpecified: false }
		},
		{
			exclude,
			test: wpCss ? /\.scss$/ : /\.s?css$/,
			use: wpCss ? [
			{ // webpack_config -> experiments -> css:  ENABLED
				loader: "sass-loader",
				options: { api: "modern", sourceMap: build.isDevMode }
			}]
			: // webpack_config -> experiments -> css:  DISABLED
			[{
				loader: build.isProdMode ? MiniCssExtractPlugin.loader : "style-loader"
			},{
				loader: "css-loader",
				options: { sourceMap: build.isDevMode, url: false }
			},{
				loader: "sass-loader",
				options: { api: "modern", sourceMap: build.isDevMode }
			}]
		});

		if (build.isTs || build.isJsTs || build.isTsJs || build.tsc.compilerOptions.jsx)
		{
			const ctxPath = build.getContextPath(),
				  include = ctxPath === build.getBasePath() ? build.getSrcPathRegExp() :
				            [ new RegExp(this._rgx_.escapeRegExp(ctxPath)), build.getSrcPathRegExp() ];
			this.rules.unshift({
				include, exclude,
				test: /\.tsx?$/,
				use: this.sourceLoaders.ts(true).loader
			});
		}
	};

};


module.exports = WpwRulesExport.create;