From 7cf2730402f8ceacea5c3554d7d93805330a8bff Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sun, 6 Apr 2025 15:12:12 +0400 Subject: [PATCH] Reworked build step for the content scripts Main changes: - Scripts are now built in 2 steps instead of building every script and style one at a time; - Scripts are built as AMD modules; - Dependencies are automatically injected into resulting manifest.json file. --- .vite/lib/content-scripts.js | 168 ++++++++++++++++++++++++++++------- .vite/lib/manifest.js | 32 +++++++ .vite/pack-extension.js | 82 ++++++++++------- 3 files changed, 218 insertions(+), 64 deletions(-) diff --git a/.vite/lib/content-scripts.js b/.vite/lib/content-scripts.js index 34f8653..780b612 100644 --- a/.vite/lib/content-scripts.js +++ b/.vite/lib/content-scripts.js @@ -1,5 +1,5 @@ -import {build} from "vite"; -import {createHash} from "crypto"; +import { build } from "vite"; +import { createHash } from "crypto"; import path from "path"; import fs from "fs"; @@ -56,69 +56,167 @@ function makeAliases(rootDir) { } /** - * Build the selected script separately. - * @param {AssetBuildOptions} buildOptions Building options for the script. - * @return {Promise} Result file path. + * @param {import('rollup').OutputChunk} chunk + * @param {import('rollup').OutputBundle} bundle + * @param {Set} processedChunks + * @return string[] */ -export async function buildScript(buildOptions) { - const outputBaseName = createOutputBaseName(buildOptions.input); +function collectChunkDependencies(chunk, bundle, processedChunks = new Set()) { + if (processedChunks.has(chunk) || !chunk.imports) { + return []; + } + processedChunks.add(chunk); + + return chunk.imports.concat( + chunk.imports + .map(importedChunkName => { + const module = bundle[importedChunkName]; + + if (module.type === 'chunk') { + return collectChunkDependencies(module, bundle, processedChunks); + } + + return []; + }) + .flat() + ); +} + +/** + * @param {(fileName: string, dependencies: string[]) => void} onDependencyResolvedCallback + * @returns {import('vite').Plugin} + */ +function collectDependenciesForManifestBuilding(onDependencyResolvedCallback) { + return { + name: 'extract-dependencies-for-content-scripts', + enforce: "post", + /** + * @param {any} options + * @param {import('rollup').OutputBundle} bundle + */ + writeBundle(options, bundle) { + Object.keys(bundle).forEach(fileName => { + const chunk = bundle[fileName]; + + if (chunk.type !== "chunk" || !chunk.facadeModuleId) { + return; + } + + const dependencies = Array.from( + new Set( + collectChunkDependencies(chunk, bundle) + ) + ); + + onDependencyResolvedCallback(fileName, dependencies); + }); + } + } +} + +/** + * Second revision of the building logic for the content scripts. This method tries to address duplication of + * dependencies generated with the previous method, where every single content script was built separately. + * @param {BatchBuildOptions} buildOptions + * @returns {Promise>} + */ +export async function buildScriptsAndStyles(buildOptions) { + /** @type {Map} */ + const pathsReplacement = new Map(); + /** @type {Map} */ + const pathsReplacementByOutputPath = new Map(); + + const amdScriptsInput = {}; + const libsAndStylesInput = {}; + + for (const inputPath of buildOptions.inputs) { + let outputExtension = path.extname(inputPath); + + if (outputExtension === '.scss') { + outputExtension = '.css'; + } + + if (outputExtension === '.ts') { + outputExtension = '.js'; + } + + const outputPath = createOutputBaseName(inputPath); + const replacementsArray = [`${outputPath}${outputExtension}`]; + + pathsReplacement.set(inputPath, replacementsArray); + + if (outputExtension === '.css' || inputPath.includes('/deps/')) { + libsAndStylesInput[outputPath] = inputPath; + continue; + } + + pathsReplacementByOutputPath.set(outputPath + '.js', replacementsArray); + + amdScriptsInput[outputPath] = inputPath; + } + + const aliasesSettings = makeAliases(buildOptions.rootDir); + + // Building all scripts together with AMD loader in mind await build({ configFile: false, publicDir: false, build: { rollupOptions: { - input: { - [outputBaseName]: buildOptions.input - }, + input: amdScriptsInput, output: { dir: buildOptions.outputDir, - entryFileNames: '[name].js' + entryFileNames: '[name].js', + chunkFileNames: 'chunks/[name]-[hash].js', + // ManifestV3 doesn't allow to use modern ES modules syntax, so we build all content scripts as AMD modules. + format: "amd", + inlineDynamicImports: false, + amd: { + // amd-lite requires names even for the entry-point scripts, so we should make sure to add those. + autoId: true, + } } }, emptyOutDir: false, }, resolve: { - alias: makeAliases(buildOptions.rootDir) + alias: aliasesSettings, }, plugins: [ - wrapScriptIntoIIFE() + wrapScriptIntoIIFE(), + collectDependenciesForManifestBuilding((fileName, dependencies) => { + pathsReplacementByOutputPath + .get(fileName) + ?.push(...dependencies); + }), ] }); - return path.resolve(buildOptions.outputDir, `${outputBaseName}.js`); -} - -/** - * Build the selected stylesheet. - * @param {AssetBuildOptions} buildOptions Build options for the stylesheet. - * @return {Promise} Result file path. - */ -export async function buildStyle(buildOptions) { - const outputBaseName = createOutputBaseName(buildOptions.input); - + // Build styles separately because AMD converts styles to JS files. await build({ configFile: false, publicDir: false, build: { rollupOptions: { - input: { - [outputBaseName]: buildOptions.input - }, + input: libsAndStylesInput, output: { dir: buildOptions.outputDir, entryFileNames: '[name].js', assetFileNames: '[name].[ext]', } }, - emptyOutDir: false, + emptyOutDir: false }, resolve: { - alias: makeAliases(buildOptions.rootDir) - } + alias: aliasesSettings, + }, + plugins: [ + wrapScriptIntoIIFE(), + ] }); - return path.resolve(buildOptions.outputDir, `${outputBaseName}.css`); + return pathsReplacement; } /** @@ -127,3 +225,11 @@ export async function buildStyle(buildOptions) { * @property {string} outputDir Destination folder for the script. * @property {string} rootDir Root directory of the repository. */ + +/** + * @typedef {Object} BatchBuildOptions + * @property {Set} inputs Set of all scripts and styles to build. + * @property {string} outputDir Destination folder for the assets. + * @property {string} rootDir Root directory of the repository. + * @property {(fileName: string, dependencies: string[]) => void} onDependenciesResolved Callback for dependencies. + */ diff --git a/.vite/lib/manifest.js b/.vite/lib/manifest.js index 817b5e7..dffef10 100644 --- a/.vite/lib/manifest.js +++ b/.vite/lib/manifest.js @@ -17,6 +17,38 @@ class ManifestProcessor { this.#manifestObject = parsedManifest; } + /** + * Collect all the content scripts & stylesheets for single build action. + * + * @returns {Set} + */ + collectContentScripts() { + const contentScripts = this.#manifestObject.content_scripts; + + if (!contentScripts) { + console.info('No content scripts to collect.'); + return new Set(); + } + + const entryPoints = new Set(); + + for (let entry of contentScripts) { + if (entry.js) { + for (let jsPath of entry.js) { + entryPoints.add(jsPath); + } + } + + if (entry.css) { + for (let cssPath of entry.css) { + entryPoints.add(cssPath); + } + } + } + + return entryPoints; + } + /** * Map over every content script defined in the manifest. If no content scripts defined, no calls will be made to the * callback. diff --git a/.vite/pack-extension.js b/.vite/pack-extension.js index c4d7d91..7b4d0ea 100644 --- a/.vite/pack-extension.js +++ b/.vite/pack-extension.js @@ -1,8 +1,8 @@ -import {loadManifest} from "./lib/manifest.js"; +import { loadManifest } from "./lib/manifest.js"; import path from "path"; -import {buildScript, buildStyle} from "./lib/content-scripts.js"; -import {normalizePath} from "vite"; -import {extractInlineScriptsFromIndex} from "./lib/index-file.js"; +import { buildScriptsAndStyles } from "./lib/content-scripts.js"; +import { extractInlineScriptsFromIndex } from "./lib/index-file.js"; +import { normalizePath } from "vite"; /** * Build addition assets required for the extension and pack it into the directory. @@ -11,45 +11,61 @@ import {extractInlineScriptsFromIndex} from "./lib/index-file.js"; export async function packExtension(settings) { const manifest = loadManifest(path.resolve(settings.rootDir, 'manifest.json')); - // Since we CAN'T really build all scripts and stylesheets in a single build entry, we will run build for every single - // one of them in a row. This way, no chunks will be generated. Thanks, ManifestV3! - await manifest.mapContentScripts(async (entry) => { - if (entry.js) { - for (let scriptIndex = 0; scriptIndex < entry.js.length; scriptIndex++) { - const builtScriptFilePath = await buildScript({ - input: path.resolve(settings.rootDir, entry.js[scriptIndex]), - outputDir: settings.contentScriptsDir, - rootDir: settings.rootDir, - }); + const replacementMapping = await buildScriptsAndStyles({ + inputs: manifest.collectContentScripts(), + outputDir: settings.contentScriptsDir, + rootDir: settings.rootDir, + }); - entry.js[scriptIndex] = normalizePath( - path.relative( - settings.exportDir, - builtScriptFilePath + await manifest.mapContentScripts(async entry => { + if (entry.js) { + entry.js = entry.js + .map(jsSourcePath => { + if (!replacementMapping.has(jsSourcePath)) { + return []; + } + + return replacementMapping.get(jsSourcePath); + }) + .flat(1) + .map(pathName => { + return normalizePath( + path.relative( + settings.exportDir, + path.join( + settings.contentScriptsDir, + pathName + ) + ) ) - ); - } + }); } if (entry.css) { - for (let styleIndex = 0; styleIndex < entry.css.length; styleIndex++) { - const builtStylesheetFilePath = await buildStyle({ - input: path.resolve(settings.rootDir, entry.css[styleIndex]), - outputDir: settings.contentScriptsDir, - rootDir: settings.rootDir - }); + entry.css = entry.css + .map(jsSourcePath => { + if (!replacementMapping.has(jsSourcePath)) { + return []; + } - entry.css[styleIndex] = normalizePath( - path.relative( - settings.exportDir, - builtStylesheetFilePath + return replacementMapping.get(jsSourcePath); + }) + .flat(1) + .map(pathName => { + return normalizePath( + path.relative( + settings.exportDir, + path.join( + settings.contentScriptsDir, + pathName + ) + ) ) - ); - } + }) } return entry; - }); + }) manifest.passVersionFromPackage(path.resolve(settings.rootDir, 'package.json')); manifest.saveTo(path.resolve(settings.exportDir, 'manifest.json'));