plugins_ts_sourcemaps.js

// @ts-check

/**
 * @file src/plugins/sourcemaps.js
 * @version 0.0.1
 * @copyright @spmhome @_2025
 * @author Scott Meesseman @spmeesseman
 *//** */

const WpwPlugin = require("../base");
const { apply } = require("@spmhome/type-utils");


/**
 * @augments WpwPlugin
 */
class WpwSourceMapsPlugin extends WpwPlugin
{
    /**
     * @param {WpwPluginOptions} options
     */
	constructor(options)
	{
        super(apply({ buildOptionsNone: true }, options));
        this.buildOptions = /** @type {WpwBuildOptionsExportConfig<"devtool">} */(this.buildOptions);
	}


	/**
     * @override
     * @param {WpwBuild} b
     */
	static create = (b) => WpwSourceMapsPlugin.wrap.call(this, b, !!b.options.hash?.enabled, !!b.options.devtool?.syncHash);


    /**
     * @override
     * @returns {WpwPluginTapOptions<any, any, boolean> | undefined}
     */
    onApply()
    {
        if (this.buildOptions.syncHash !== false || this.build.options.hash?.enabled !== true)
        {
            return {
                renameSourceMaps: {
                    hook: "compilation",
                    stage: "DEV_TOOLING",
                    hookCompilation: "processAssets",
                    callback: this.renameSourceMaps.bind(this)
                },
				attachSourceMapsToCopiedModules: {
					hook: "compilation",
					stage: "DEV_TOOLING",
					hookCompilation: "processAssets",
					callback: this.attachSourceMaps.bind(this)
				}
            };
        }
    }



    /**
     * @private
     * @param {WebpackCompilationAssets} assets
     */
    attachSourceMaps = (assets) =>
    {
		const l = this.hookstart();
		Object.entries(assets).filter(([ file ]) => this.isOutputAsset(file)).forEach(([ file ]) =>
		{
			const asset = this.compilation.getAsset(file);
			if (asset && asset.info.copied && !asset.info.related?.sourceMap)
			{
				const chunkName = this.fileNameStrip(file, true),
					  srcAssetFile = `${chunkName}.${this.global.hash ? this.global.hash.next[chunkName] : ""}.js`,
					  srcAsset = this.compilation.getAsset(srcAssetFile);
				l.writeMsgTag(file, "chunk: " + chunkName, 1);
				l.value("   source asset filename", srcAssetFile, 3);
				l.value("   source asset found", !!srcAsset, 3);
				l.value("   source asset has sourcemap", !!srcAsset?.info.related?.sourceMap, 3);
				if (srcAsset && srcAsset.info.related?.sourceMap)
				{
					l.write("attaching sourcemap", 3);
					l.value("   copied asset filename", file, 3);
					l.value("   source asset filename", srcAssetFile, 3);
					l.value("   chunk name", chunkName, 4);
					l.value("   source asset found", !!srcAsset, 4);
					l.value("   source asset has sourcemap", !!srcAsset?.info.related?.sourceMap, 4);
					const newInfo = apply({ ...asset.info }, { related: { sourceMap: srcAsset.info.related.sourceMap }});
					this.compilation.updateAsset(file, srcAsset.source, newInfo);
				}
			}
		});
        this.hookdone();
    };


    /**
     * @private
     * @param {WebpackCompilationAssets} assets
     */
    renameSourceMaps = (assets) =>
    {
        const l = this.hookstart(),
              compilation = this.compilation;

        l.write("replace filename hashes with entry module hash", 1);

        Object.entries(assets).filter(([ file ]) => file.endsWith(".map")).forEach(([ file ]) =>
        {
            const asset = compilation.getAsset(file);
            if (asset)
            {
                const hashKey = this.fileNameStrip(file, true),
                      entryHash = this.build.global.runtimeVars.next[hashKey],
                      newFile = this.fileNameStrip(file).replace(/\.([mc]?jsx?)\.map/, (_, g) => `.${entryHash}.${g}.map`);
                l.write(`   found sourcemap asset italic(${asset.name})`, 1);
                l.value("      current filename", file, 2);
                l.value("      new filename", newFile, 2);
                l.value("      asset info", JSON.stringify(asset.info), 3);
                compilation.renameAsset(file, newFile);
                const srcAsset = compilation.getAsset(newFile.replace(".map", ""));
                if (srcAsset && srcAsset.info.related && srcAsset.info.related.sourceMap)
                {
                    const sources = this.build.wp.sources;
                    const { source, map } = srcAsset.source.sourceAndMap();
                    const newInfo = apply(
                        { ...srcAsset.info },
                        { related: { ...srcAsset.info.related, sourceMap: newFile }}
                    );
                    let newSource = source;
                    l.write("   update source entry asset with new sourcemap filename", 2);
                    l.value("   source entry asset info", JSON.stringify(srcAsset.info), 3);
                    newSource = source.toString().replace(file, newFile);
                    compilation.updateAsset(
                        srcAsset.name,
                        new sources.SourceMapSource(newSource, srcAsset.name, map),
                        newInfo
                    );
                }
            }
        });
        this.hookdone();
    };


	/**
	 * @override
     * @param {WebpackCompiler} compiler
     * @param {boolean} [applyFirst]
	 * @returns {WebpackPluginInstance | undefined}
	 */
	getVendorPlugin = (compiler, applyFirst) =>
	{
        if (applyFirst && this.buildOptions.mode === "plugin")
        {
            const ext = this.outputAssetRegex,
                  { SourceMapDevToolPlugin } = this.build.wp || compiler.webpack,
                  hash = this.build.options.hash?.enabled === true,
                  sChk = this._arr_.asArray(this.build.options.optimization?.customCacheGroup)
                                     .reduce((p, c) => `${p}|${c}`, ""),
                  cChk = this.build.options.output?.name ? "|" + this.build.options.output.name : "";

            return new SourceMapDevToolPlugin(
            {
                test: /\.[mc]jsx?($|\?)/i,
                filename: hash ? `[name].[contenthash]${ext}.map` : `[name]${ext}.map`,
                exclude: new RegExp(
                    `(?:node_modules|(?:vendor|spmh[acl]|react|runtime${sChk}${cChk}|workbox|tests)(?:\\.[a-f0-9]{12,32})?${ext})`
                ),
                //
                // The bundled node_modules will produce reference tags within the main entry point
                // files in the form:
                //
                //     external commonjs "vscode"
                //     external-node commonjs "crypto"
                //     ...etc...
                //
                // This breaks the istanbul reporting library when the tests have completed and the
                // coverage report is being built (via nyc.report()).  Replace the quote and space
                // characters in this external reference name with filename friendly characters.
                //
                moduleFilenameTemplate: (/** @type {any} */info) =>
                {
                    if ((/["| ]/).test(info.absoluteResourcePath)) {
                        return info.absoluteResourcePath.replace(/"/g, "").replace(/[ |]/g, "_");
                    }
                    return `${info.absoluteResourcePath}`;
                },
                fallbackModuleFilenameTemplate: hash ? "[absolute-resource-path]?[hash]" : "[absolute-resource-path]"
            });
        }
    };

}


module.exports = WpwSourceMapsPlugin.create;