From c3e496a39770e3dab7d8b89ae2e2c79ad37aad03 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Wed, 14 Aug 2024 07:21:29 +1000 Subject: [PATCH] feat: add `ParagonWebpackPlugin` to support design tokens (#546) * feat: expose PARAGON_VERSION as a global variable fix: rely on paragon-theme.json from @edx/paragon chore: clean up and add typedefs fix: create undefined PARAGON global variable if no compatible paragon version fix: moar better error handling fix: updates fix: update based on new schema for paragon-theme.json fix: update setupTest.js chore: clean up chore: remove compressionplugin chore: quality fix: rename paragon-theme.json to theme-urls.json chore: uninstall unused node_module feat: add @edx/brand version and urls to PARAGON_THEME global variable chore: update snapshot * fix: PR feedback fix: add comment to ParagonWebpackPlugin and update snapshots feat: preload links from PARAGON_THEME_URLS config fix: handle undefined this.paragonMetadata fix: remove fallbackUrls chore: snapshots and resolve .eslintrc error * fix: updates fix: typo in `alpha` CDN url within example env.config.js Co-authored-by: Diana Olarte * chore: update dependecies in example app * chore: update add webpack-remonve-empty-scripts and parse5 dependencies * fix: install paragon plugins and fix css compiler * refactor: change paragon package name for openedx * refactor: remove runtime config * revert: example changes * fix: add a try/catch to config loading in Paragon Plugin to avoid errors during the build * refactor: split Paragon Plugin Utils file * docs: update JSDoc in config/data/paragonUtils.js Co-authored-by: Peter Kulko <93188219+PKulkoRaccoonGang@users.noreply.github.com> * refactor: use @openedx/brand scope this following the update here https://github.com/openedx/frontend-build/pull/490/files#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R158 * refactor: add utf-8 to readFileSync in paragonUtils * perf: replace filter for reduce in getParagonThemeCss * refactor: change entryPoints and CacheGroups variable definition. * docs: add comment to describe the use of RemoveEmptyScriptsPlugin * revert: restore env.development processing un webpack.dev.config.js * refactor: remove process.env references in ParagonWebpackPlugin, no supported * fix: use openedx scope for webpack.dev.config * fix: allow processing edx and openedx brand scope * refactor: support process.env.PARAGON_THEME_URLS * fix: make PARAGON_THEME_URLS be defined only as env --------- Co-authored-by: Adam Stankiewicz Co-authored-by: Peter Kulko <93188219+PKulkoRaccoonGang@users.noreply.github.com> --- README.md | 2 +- config/.eslintrc.js | 1 + config/data/paragonUtils.js | 171 ++++++++++++++++++ config/jest/setupTest.js | 35 ++++ config/webpack.common.config.js | 44 +++++ config/webpack.dev-stage.config.js | 1 + config/webpack.dev.config.js | 90 +++++---- config/webpack.prod.config.js | 3 +- .../ParagonWebpackPlugin.js | 126 +++++++++++++ lib/plugins/paragon-webpack-plugin/index.js | 3 + .../utils/assetUtils.js | 75 ++++++++ .../paragon-webpack-plugin/utils/htmlUtils.js | 69 +++++++ .../paragon-webpack-plugin/utils/index.js | 9 + .../utils/paragonStylesheetUtils.js | 120 ++++++++++++ .../utils/scriptUtils.js | 144 +++++++++++++++ .../utils/stylesheetUtils.js | 106 +++++++++++ .../paragon-webpack-plugin/utils/tagUtils.js | 58 ++++++ package-lock.json | 55 +++++- package.json | 4 +- 19 files changed, 1074 insertions(+), 42 deletions(-) create mode 100644 config/data/paragonUtils.js create mode 100644 lib/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js create mode 100644 lib/plugins/paragon-webpack-plugin/index.js create mode 100644 lib/plugins/paragon-webpack-plugin/utils/assetUtils.js create mode 100644 lib/plugins/paragon-webpack-plugin/utils/htmlUtils.js create mode 100644 lib/plugins/paragon-webpack-plugin/utils/index.js create mode 100644 lib/plugins/paragon-webpack-plugin/utils/paragonStylesheetUtils.js create mode 100644 lib/plugins/paragon-webpack-plugin/utils/scriptUtils.js create mode 100644 lib/plugins/paragon-webpack-plugin/utils/stylesheetUtils.js create mode 100644 lib/plugins/paragon-webpack-plugin/utils/tagUtils.js diff --git a/README.md b/README.md index 814bbcf0f..9d299bfa3 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ frontend-platform: dist: The sub-directory of the source code where it puts its build artifact. Often "dist". */ localModules: [ - { moduleName: '@openedx/brand', dir: '../src/brand-openedx' }, // replace with your brand checkout + { moduleName: '@edx/brand', dir: '../src/brand-openedx' }, // replace with your brand checkout { moduleName: '@openedx/paragon/scss/core', dir: '../src/paragon', dist: 'scss/core' }, { moduleName: '@openedx/paragon/icons', dir: '../src/paragon', dist: 'icons' }, { moduleName: '@openedx/paragon', dir: '../src/paragon', dist: 'dist' }, diff --git a/config/.eslintrc.js b/config/.eslintrc.js index 78149d727..910b8f8b0 100644 --- a/config/.eslintrc.js +++ b/config/.eslintrc.js @@ -38,6 +38,7 @@ module.exports = { }, globals: { newrelic: false, + PARAGON_THEME: false, }, ignorePatterns: [ 'module.config.js', diff --git a/config/data/paragonUtils.js b/config/data/paragonUtils.js new file mode 100644 index 000000000..c48f6c3e8 --- /dev/null +++ b/config/data/paragonUtils.js @@ -0,0 +1,171 @@ +const path = require('path'); +const fs = require('fs'); + +/** + * Retrieves the name of the brand package from the given directory. + * + * @param {string} dir - The directory path containing the package.json file. + * @return {string} The name of the brand package, or an empty string if not found. + */ +function getBrandPackageName(dir) { + const appDependencies = JSON.parse(fs.readFileSync(path.resolve(dir, 'package.json'), 'utf-8')).dependencies; + return Object.keys(appDependencies).find((key) => key.match(/@(open)?edx\/brand/)) || ''; +} + +/** + * Attempts to extract the Paragon version from the `node_modules` of + * the consuming application. + * + * @param {string} dir Path to directory containing `node_modules`. + * @returns {string} Paragon dependency version of the consuming application + */ +function getParagonVersion(dir, { isBrandOverride = false } = {}) { + const npmPackageName = isBrandOverride ? getBrandPackageName(dir) : '@openedx/paragon'; + const pathToPackageJson = `${dir}/node_modules/${npmPackageName}/package.json`; + if (!fs.existsSync(pathToPackageJson)) { + return undefined; + } + return JSON.parse(fs.readFileSync(pathToPackageJson, 'utf-8')).version; +} + +/** + * @typedef {Object} ParagonThemeCssAsset + * @property {string} filePath + * @property {string} entryName + * @property {string} outputChunkName + */ + +/** + * @typedef {Object} ParagonThemeVariantCssAsset + * @property {string} filePath + * @property {string} entryName + * @property {string} outputChunkName + */ + +/** + * @typedef {Object} ParagonThemeCss + * @property {ParagonThemeCssAsset} core The metadata about the core Paragon theme CSS + * @property {Object.} variants A collection of theme variants. + */ + +/** + * Attempts to extract the Paragon theme CSS from the locally installed `@openedx/paragon` package. + * @param {string} dir Path to directory containing `node_modules`. + * @param {boolean} isBrandOverride + * @returns {ParagonThemeCss} + */ +function getParagonThemeCss(dir, { isBrandOverride = false } = {}) { + const npmPackageName = isBrandOverride ? getBrandPackageName(dir) : '@openedx/paragon'; + const pathToParagonThemeOutput = path.resolve(dir, 'node_modules', npmPackageName, 'dist', 'theme-urls.json'); + + if (!fs.existsSync(pathToParagonThemeOutput)) { + return undefined; + } + const paragonConfig = JSON.parse(fs.readFileSync(pathToParagonThemeOutput, 'utf-8')); + const { + core: themeCore, + variants: themeVariants, + defaults, + } = paragonConfig?.themeUrls || {}; + + const pathToCoreCss = path.resolve(dir, 'node_modules', npmPackageName, 'dist', themeCore.paths.minified); + const coreCssExists = fs.existsSync(pathToCoreCss); + + const themeVariantResults = Object.entries(themeVariants || {}).reduce((themeVariantAcc, [themeVariant, value]) => { + const themeVariantCssDefault = path.resolve(dir, 'node_modules', npmPackageName, 'dist', value.paths.default); + const themeVariantCssMinified = path.resolve(dir, 'node_modules', npmPackageName, 'dist', value.paths.minified); + + if (!fs.existsSync(themeVariantCssDefault) && !fs.existsSync(themeVariantCssMinified)) { + return themeVariantAcc; + } + + return ({ + ...themeVariantAcc, + [themeVariant]: { + filePath: themeVariantCssMinified, + entryName: isBrandOverride ? `brand.theme.variants.${themeVariant}` : `paragon.theme.variants.${themeVariant}`, + outputChunkName: isBrandOverride ? `brand-theme-variants-${themeVariant}` : `paragon-theme-variants-${themeVariant}`, + }, + }); + }, {}); + + if (!coreCssExists || themeVariantResults.length === 0) { + return undefined; + } + + const coreResult = { + filePath: path.resolve(dir, pathToCoreCss), + entryName: isBrandOverride ? 'brand.theme.core' : 'paragon.theme.core', + outputChunkName: isBrandOverride ? 'brand-theme-core' : 'paragon-theme-core', + }; + + return { + core: fs.existsSync(pathToCoreCss) ? coreResult : undefined, + variants: themeVariantResults, + defaults, + }; +} + +/** + * @typedef CacheGroup + * @property {string} type The type of cache group. + * @property {string|function} name The name of the cache group. + * @property {function} chunks A function that returns true if the chunk should be included in the cache group. + * @property {boolean} enforce If true, this cache group will be created even if it conflicts with default cache groups. + */ + +/** + * @param {ParagonThemeCss} paragonThemeCss The Paragon theme CSS metadata. + * @returns {Object.} The cache groups for the Paragon theme CSS. + */ +function getParagonCacheGroups(paragonThemeCss) { + if (!paragonThemeCss) { + return {}; + } + const cacheGroups = { + [paragonThemeCss.core.outputChunkName]: { + type: 'css/mini-extract', + name: paragonThemeCss.core.outputChunkName, + chunks: chunk => chunk.name === paragonThemeCss.core.entryName, + enforce: true, + }, + }; + + Object.values(paragonThemeCss.variants).forEach(({ entryName, outputChunkName }) => { + cacheGroups[outputChunkName] = { + type: 'css/mini-extract', + name: outputChunkName, + chunks: chunk => chunk.name === entryName, + enforce: true, + }; + }); + return cacheGroups; +} + +/** + * @param {ParagonThemeCss} paragonThemeCss The Paragon theme CSS metadata. + * @returns {Object.} The entry points for the Paragon theme CSS. Example: ``` + * { + * "paragon.theme.core": "/path/to/node_modules/@openedx/paragon/dist/core.min.css", + * "paragon.theme.variants.light": "/path/to/node_modules/@openedx/paragon/dist/light.min.css" + * } + * ``` + */ +function getParagonEntryPoints(paragonThemeCss) { + if (!paragonThemeCss) { + return {}; + } + + const entryPoints = { [paragonThemeCss.core.entryName]: path.resolve(process.cwd(), paragonThemeCss.core.filePath) }; + Object.values(paragonThemeCss.variants).forEach(({ filePath, entryName }) => { + entryPoints[entryName] = path.resolve(process.cwd(), filePath); + }); + return entryPoints; +} + +module.exports = { + getParagonVersion, + getParagonThemeCss, + getParagonCacheGroups, + getParagonEntryPoints, +}; diff --git a/config/jest/setupTest.js b/config/jest/setupTest.js index 6787604b9..2a844d24c 100644 --- a/config/jest/setupTest.js +++ b/config/jest/setupTest.js @@ -8,3 +8,38 @@ const testEnvFile = path.resolve(process.cwd(), '.env.test'); if (fs.existsSync(testEnvFile)) { dotenv.config({ path: testEnvFile }); } + +global.PARAGON_THEME = { + paragon: { + version: '1.0.0', + themeUrls: { + core: { + fileName: 'core.min.css', + }, + defaults: { + light: 'light', + }, + variants: { + light: { + fileName: 'light.min.css', + }, + }, + }, + }, + brand: { + version: '1.0.0', + themeUrls: { + core: { + fileName: 'core.min.css', + }, + defaults: { + light: 'light', + }, + variants: { + light: { + fileName: 'light.min.css', + }, + }, + }, + }, +}; diff --git a/config/webpack.common.config.js b/config/webpack.common.config.js index 871e2b937..bf1b9a323 100644 --- a/config/webpack.common.config.js +++ b/config/webpack.common.config.js @@ -1,8 +1,35 @@ const path = require('path'); +const RemoveEmptyScriptsPlugin = require('webpack-remove-empty-scripts'); + +const ParagonWebpackPlugin = require('../lib/plugins/paragon-webpack-plugin/ParagonWebpackPlugin'); +const { + getParagonThemeCss, + getParagonCacheGroups, + getParagonEntryPoints, +} = require('./data/paragonUtils'); + +const paragonThemeCss = getParagonThemeCss(process.cwd()); +const brandThemeCss = getParagonThemeCss(process.cwd(), { isBrandOverride: true }); module.exports = { entry: { app: path.resolve(process.cwd(), './src/index'), + /** + * The entry points for the Paragon theme CSS. Example: ``` + * { + * "paragon.theme.core": "/path/to/node_modules/@openedx/paragon/dist/core.min.css", + * "paragon.theme.variants.light": "/path/to/node_modules/@openedx/paragon/dist/light.min.css" + * } + */ + ...getParagonEntryPoints(paragonThemeCss), + /** + * The entry points for the brand theme CSS. Example: ``` + * { + * "paragon.theme.core": "/path/to/node_modules/@(open)edx/brand/dist/core.min.css", + * "paragon.theme.variants.light": "/path/to/node_modules/@(open)edx/brand/dist/light.min.css" + * } + */ + ...getParagonEntryPoints(brandThemeCss), }, output: { path: path.resolve(process.cwd(), './dist'), @@ -19,6 +46,23 @@ module.exports = { }, extensions: ['.js', '.jsx'], }, + optimization: { + splitChunks: { + chunks: 'all', + cacheGroups: { + ...getParagonCacheGroups(paragonThemeCss), + ...getParagonCacheGroups(brandThemeCss), + }, + }, + }, + plugins: [ + // RemoveEmptyScriptsPlugin get rid of empty scripts generated by webpack when using mini-css-extract-plugin + // This helps to clean up the final bundle application + // See: https://www.npmjs.com/package/webpack-remove-empty-scripts#usage-with-mini-css-extract-plugin + + new RemoveEmptyScriptsPlugin(), + new ParagonWebpackPlugin(), + ], ignoreWarnings: [ // Ignore warnings raised by source-map-loader. // some third party packages may ship miss-configured sourcemaps, that interrupts the build diff --git a/config/webpack.dev-stage.config.js b/config/webpack.dev-stage.config.js index 57dfcf351..1a13e0312 100644 --- a/config/webpack.dev-stage.config.js +++ b/config/webpack.dev-stage.config.js @@ -157,6 +157,7 @@ module.exports = merge(commonConfig, { new HtmlWebpackPlugin({ inject: true, // Appends script tags linking to the webpack bundles at the end of the body template: path.resolve(process.cwd(), 'public/index.html'), + chunks: ['app'], FAVICON_URL: process.env.FAVICON_URL || null, OPTIMIZELY_PROJECT_ID: process.env.OPTIMIZELY_PROJECT_ID || null, NODE_ENV: process.env.NODE_ENV || null, diff --git a/config/webpack.dev.config.js b/config/webpack.dev.config.js index 5ce771608..eb7eac230 100644 --- a/config/webpack.dev.config.js +++ b/config/webpack.dev.config.js @@ -1,7 +1,7 @@ // This is the dev Webpack config. All settings here should prefer a fast build // time at the expense of creating larger, unoptimized bundles. const ImageMinimizerPlugin = require('image-minimizer-webpack-plugin'); - +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const { merge } = require('webpack-merge'); const Dotenv = require('dotenv-webpack'); const dotenv = require('dotenv'); @@ -31,6 +31,45 @@ resolvePrivateEnvConfig('.env.private'); const aliases = getLocalAliases(); const PUBLIC_PATH = process.env.PUBLIC_PATH || '/'; +function getStyleUseConfig() { + return [ + { + loader: 'css-loader', // translates CSS into CommonJS + options: { + sourceMap: true, + modules: { + compileType: 'icss', + }, + }, + }, + { + loader: 'postcss-loader', + options: { + postcssOptions: { + plugins: [ + PostCssAutoprefixerPlugin(), + PostCssRTLCSS(), + PostCssCustomMediaCSS(), + ], + }, + }, + }, + 'resolve-url-loader', + { + loader: 'sass-loader', // compiles Sass to CSS + options: { + sourceMap: true, + sassOptions: { + includePaths: [ + path.join(process.cwd(), 'node_modules'), + path.join(process.cwd(), 'src'), + ], + }, + }, + }, + ]; +} + module.exports = merge(commonConfig, { mode: 'development', devtool: 'eval-source-map', @@ -68,43 +107,19 @@ module.exports = merge(commonConfig, { // flash-of-unstyled-content issues in development. { test: /(.scss|.css)$/, - use: [ - 'style-loader', // creates style nodes from JS strings + oneOf: [ { - loader: 'css-loader', // translates CSS into CommonJS - options: { - sourceMap: true, - modules: { - compileType: 'icss', - }, - }, - }, - { - loader: 'postcss-loader', - options: { - postcssOptions: { - plugins: [ - PostCssAutoprefixerPlugin(), - PostCssRTLCSS(), - PostCssCustomMediaCSS(), - ], - }, - }, + resource: /(@openedx\/paragon|@(open)?edx\/brand)/, + use: [ + MiniCssExtractPlugin.loader, + ...getStyleUseConfig(), + ], }, - 'resolve-url-loader', { - loader: 'sass-loader', // compiles Sass to CSS - options: { - sourceMap: true, - sassOptions: { - includePaths: [ - path.join(process.cwd(), 'node_modules'), - path.join(process.cwd(), 'src'), - ], - // silences compiler warnings regarding deprecation warnings - quietDeps: true, - }, - }, + use: [ + 'style-loader', // creates style nodes from JS strings + ...getStyleUseConfig(), + ], }, ], }, @@ -156,10 +171,15 @@ module.exports = merge(commonConfig, { }, // Specify additional processing or side-effects done on the Webpack output bundles as a whole. plugins: [ + // Writes the extracted CSS from each entry to a file in the output directory. + new MiniCssExtractPlugin({ + filename: '[name].css', + }), // Generates an HTML file in the output directory. new HtmlWebpackPlugin({ inject: true, // Appends script tags linking to the webpack bundles at the end of the body template: path.resolve(process.cwd(), 'public/index.html'), + chunks: ['app'], FAVICON_URL: process.env.FAVICON_URL || null, OPTIMIZELY_PROJECT_ID: process.env.OPTIMIZELY_PROJECT_ID || null, NODE_ENV: process.env.NODE_ENV || null, diff --git a/config/webpack.prod.config.js b/config/webpack.prod.config.js index 2879dd90b..c05200fad 100644 --- a/config/webpack.prod.config.js +++ b/config/webpack.prod.config.js @@ -114,8 +114,8 @@ module.exports = merge(commonConfig, { plugins: [ PostCssAutoprefixerPlugin(), PostCssRTLCSS(), - CssNano(), PostCssCustomMediaCSS(), + CssNano(), ...extraPostCssPlugins, ], }, @@ -202,6 +202,7 @@ module.exports = merge(commonConfig, { new HtmlWebpackPlugin({ inject: true, // Appends script tags linking to the webpack bundles at the end of the body template: path.resolve(process.cwd(), 'public/index.html'), + chunks: ['app'], FAVICON_URL: process.env.FAVICON_URL || null, OPTIMIZELY_PROJECT_ID: process.env.OPTIMIZELY_PROJECT_ID || null, NODE_ENV: process.env.NODE_ENV || null, diff --git a/lib/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js b/lib/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js new file mode 100644 index 000000000..5369ebfe1 --- /dev/null +++ b/lib/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js @@ -0,0 +1,126 @@ +const { Compilation, sources } = require('webpack'); +const { + getParagonVersion, + getParagonThemeCss, +} = require('../../../config/data/paragonUtils'); +const { + injectMetadataIntoDocument, + getParagonStylesheetUrls, + injectParagonCoreStylesheets, + injectParagonThemeVariantStylesheets, +} = require('./utils'); + +// Get Paragon and brand versions / CSS files from disk. +const paragonVersion = getParagonVersion(process.cwd()); +const paragonThemeCss = getParagonThemeCss(process.cwd()); +const brandVersion = getParagonVersion(process.cwd(), { isBrandOverride: true }); +const brandThemeCss = getParagonThemeCss(process.cwd(), { isBrandOverride: true }); + +/** + * 1. Injects `PARAGON_THEME` global variable into the HTML document during the Webpack compilation process. + * 2. Injects `` element(s) for the Paragon and brand CSS into the HTML document. + */ +class ParagonWebpackPlugin { + constructor({ processAssetsHandlers = [] } = {}) { + this.pluginName = 'ParagonWebpackPlugin'; + this.paragonThemeUrlsConfig = {}; + this.paragonMetadata = {}; + + // List of handlers to be executed after processing assets during the Webpack compilation. + this.processAssetsHandlers = [ + this.resolveParagonThemeUrlsFromConfig, + this.injectParagonMetadataIntoDocument, + this.injectParagonStylesheetsIntoDocument, + ...processAssetsHandlers, + ].map(handler => handler.bind(this)); + } + + /** + * Resolves the MFE configuration from ``PARAGON_THEME_URLS`` in the environment variables. ` + * + * @returns {Object} Metadata about the Paragon and brand theme URLs from configuration. + */ + async resolveParagonThemeUrlsFromConfig() { + try { + this.paragonThemeUrlsConfig = JSON.parse(process.env.PARAGON_THEME_URLS); + } catch (error) { + console.info('Paragon Plugin cannot load PARAGON_THEME_URLS env variable, skipping.'); + } + } + + /** + * Generates `PARAGON_THEME` global variable in HTML document. + * @param {Object} compilation Webpack compilation object. + */ + injectParagonMetadataIntoDocument(compilation) { + const paragonMetadata = injectMetadataIntoDocument(compilation, { + paragonThemeCss, + paragonVersion, + brandThemeCss, + brandVersion, + }); + if (paragonMetadata) { + this.paragonMetadata = paragonMetadata; + } + } + + injectParagonStylesheetsIntoDocument(compilation) { + const file = compilation.getAsset('index.html'); + + // If the `index.html` hasn't loaded yet, or there are no Paragon theme URLs, then there is nothing to do yet. + if (!file || Object.keys(this.paragonThemeUrlsConfig || {}).length === 0) { + return; + } + + // Generates `` element(s) for the Paragon and brand CSS files. + const paragonStylesheetUrls = getParagonStylesheetUrls({ + paragonThemeUrls: this.paragonThemeUrlsConfig, + paragonVersion, + brandVersion, + }); + const { + core: paragonCoreCss, + variants: paragonThemeVariantCss, + } = paragonStylesheetUrls; + + const originalSource = file.source.source(); + + // Inject core CSS + let newSource = injectParagonCoreStylesheets({ + source: originalSource, + paragonCoreCss, + paragonThemeCss, + brandThemeCss, + }); + + // Inject theme variant CSS + newSource = injectParagonThemeVariantStylesheets({ + source: newSource.source(), + paragonThemeVariantCss, + paragonThemeCss, + brandThemeCss, + }); + + compilation.updateAsset('index.html', new sources.RawSource(newSource.source())); + } + + apply(compiler) { + compiler.hooks.thisCompilation.tap(this.pluginName, (compilation) => { + compilation.hooks.processAssets.tap( + { + name: this.pluginName, + stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, + additionalAssets: true, + }, + () => { + // Iterate through each configured handler, passing the compilation to each. + this.processAssetsHandlers.forEach(async (handler) => { + await handler(compilation); + }); + }, + ); + }); + } +} + +module.exports = ParagonWebpackPlugin; diff --git a/lib/plugins/paragon-webpack-plugin/index.js b/lib/plugins/paragon-webpack-plugin/index.js new file mode 100644 index 000000000..ac2486f89 --- /dev/null +++ b/lib/plugins/paragon-webpack-plugin/index.js @@ -0,0 +1,3 @@ +const ParagonWebpackPlugin = require('./ParagonWebpackPlugin'); + +module.exports = ParagonWebpackPlugin; diff --git a/lib/plugins/paragon-webpack-plugin/utils/assetUtils.js b/lib/plugins/paragon-webpack-plugin/utils/assetUtils.js new file mode 100644 index 000000000..eca27e3a2 --- /dev/null +++ b/lib/plugins/paragon-webpack-plugin/utils/assetUtils.js @@ -0,0 +1,75 @@ +/** + * Finds the core CSS asset from the given array of Paragon assets. + * + * @param {Array} paragonAssets - An array of Paragon assets. + * @return {Object|undefined} The core CSS asset, or undefined if not found. + */ +function findCoreCssAsset(paragonAssets) { + return paragonAssets?.find((asset) => asset.name.includes('core') && asset.name.endsWith('.css')); +} + +/** + * Finds the theme variant CSS assets from the given Paragon assets based on the provided options. + * + * @param {Array} paragonAssets - An array of Paragon assets. + * @param {Object} options - The options for finding the theme variant CSS assets. + * @param {boolean} [options.isBrandOverride=false] - Indicates if the theme variant is a brand override. + * @param {Object} [options.brandThemeCss] - The brand theme CSS object. + * @param {Object} [options.paragonThemeCss] - The Paragon theme CSS object. + * @return {Object} - The theme variant CSS assets. + */ +function findThemeVariantCssAssets(paragonAssets, { + isBrandOverride = false, + brandThemeCss, + paragonThemeCss, +}) { + const themeVariantsSource = isBrandOverride ? brandThemeCss?.variants : paragonThemeCss?.variants; + const themeVariantCssAssets = {}; + Object.entries(themeVariantsSource || {}).forEach(([themeVariant, value]) => { + const foundThemeVariantAsset = paragonAssets.find((asset) => asset.name.includes(value.outputChunkName)); + if (!foundThemeVariantAsset) { + return; + } + themeVariantCssAssets[themeVariant] = { + fileName: foundThemeVariantAsset.name, + }; + }); + return themeVariantCssAssets; +} + +/** + * Retrieves the CSS assets from the compilation based on the provided options. + * + * @param {Object} compilation - The compilation object. + * @param {Object} options - The options for retrieving the CSS assets. + * @param {boolean} [options.isBrandOverride=false] - Indicates if the assets are for a brand override. + * @param {Object} [options.brandThemeCss] - The brand theme CSS object. + * @param {Object} [options.paragonThemeCss] - The Paragon theme CSS object. + * @return {Object} - The CSS assets, including the core CSS asset and theme variant CSS assets. + */ +function getCssAssetsFromCompilation(compilation, { + isBrandOverride = false, + brandThemeCss, + paragonThemeCss, +}) { + const assetSubstring = isBrandOverride ? 'brand' : 'paragon'; + const paragonAssets = compilation.getAssets().filter(asset => asset.name.includes(assetSubstring) && asset.name.endsWith('.css')); + const coreCssAsset = findCoreCssAsset(paragonAssets); + const themeVariantCssAssets = findThemeVariantCssAssets(paragonAssets, { + isBrandOverride, + paragonThemeCss, + brandThemeCss, + }); + return { + coreCssAsset: { + fileName: coreCssAsset?.name, + }, + themeVariantCssAssets, + }; +} + +module.exports = { + findCoreCssAsset, + findThemeVariantCssAssets, + getCssAssetsFromCompilation, +}; diff --git a/lib/plugins/paragon-webpack-plugin/utils/htmlUtils.js b/lib/plugins/paragon-webpack-plugin/utils/htmlUtils.js new file mode 100644 index 000000000..2923951e0 --- /dev/null +++ b/lib/plugins/paragon-webpack-plugin/utils/htmlUtils.js @@ -0,0 +1,69 @@ +const { sources } = require('webpack'); + +const { getCssAssetsFromCompilation } = require('./assetUtils'); +const { generateScriptContents, insertScriptContentsIntoDocument } = require('./scriptUtils'); + +/** + * Injects metadata into the HTML document by modifying the 'index.html' asset in the compilation. + * + * @param {Object} compilation - The Webpack compilation object. + * @param {Object} options - The options object. + * @param {Object} options.paragonThemeCss - The Paragon theme CSS object. + * @param {string} options.paragonVersion - The version of the Paragon theme. + * @param {Object} options.brandThemeCss - The brand theme CSS object. + * @param {string} options.brandVersion - The version of the brand theme. + * @return {Object|undefined} The script contents object if the 'index.html' asset exists, otherwise undefined. + */ +function injectMetadataIntoDocument(compilation, { + paragonThemeCss, + paragonVersion, + brandThemeCss, + brandVersion, +}) { + const file = compilation.getAsset('index.html'); + if (!file) { + return undefined; + } + const { + coreCssAsset: paragonCoreCssAsset, + themeVariantCssAssets: paragonThemeVariantCssAssets, + } = getCssAssetsFromCompilation(compilation, { + brandThemeCss, + paragonThemeCss, + }); + const { + coreCssAsset: brandCoreCssAsset, + themeVariantCssAssets: brandThemeVariantCssAssets, + } = getCssAssetsFromCompilation(compilation, { + isBrandOverride: true, + brandThemeCss, + paragonThemeCss, + }); + + const scriptContents = generateScriptContents({ + paragonCoreCssAsset, + paragonThemeVariantCssAssets, + brandCoreCssAsset, + brandThemeVariantCssAssets, + paragonThemeCss, + paragonVersion, + brandThemeCss, + brandVersion, + }); + + const originalSource = file.source.source(); + const newSource = insertScriptContentsIntoDocument({ + originalSource, + coreCssAsset: paragonCoreCssAsset, + themeVariantCssAssets: paragonThemeVariantCssAssets, + scriptContents, + }); + + compilation.updateAsset('index.html', new sources.RawSource(newSource.source())); + + return scriptContents; +} + +module.exports = { + injectMetadataIntoDocument, +}; diff --git a/lib/plugins/paragon-webpack-plugin/utils/index.js b/lib/plugins/paragon-webpack-plugin/utils/index.js new file mode 100644 index 000000000..439b5bc3a --- /dev/null +++ b/lib/plugins/paragon-webpack-plugin/utils/index.js @@ -0,0 +1,9 @@ +const { getParagonStylesheetUrls, injectParagonCoreStylesheets, injectParagonThemeVariantStylesheets } = require('./paragonStylesheetUtils'); +const { injectMetadataIntoDocument } = require('./htmlUtils'); + +module.exports = { + injectMetadataIntoDocument, + getParagonStylesheetUrls, + injectParagonCoreStylesheets, + injectParagonThemeVariantStylesheets, +}; diff --git a/lib/plugins/paragon-webpack-plugin/utils/paragonStylesheetUtils.js b/lib/plugins/paragon-webpack-plugin/utils/paragonStylesheetUtils.js new file mode 100644 index 000000000..2b8272072 --- /dev/null +++ b/lib/plugins/paragon-webpack-plugin/utils/paragonStylesheetUtils.js @@ -0,0 +1,120 @@ +const { insertStylesheetsIntoDocument } = require('./stylesheetUtils'); +const { handleVersionSubstitution } = require('./tagUtils'); + +/** + * Injects Paragon core stylesheets into the document. + * + * @param {Object} options - The options object. + * @param {string|object} options.source - The source HTML document. + * @param {Object} options.paragonCoreCss - The Paragon core CSS object. + * @param {Object} options.paragonThemeCss - The Paragon theme CSS object. + * @param {Object} options.brandThemeCss - The brand theme CSS object. + * @return {string|object} The modified HTML document with Paragon core stylesheets injected. + */ +function injectParagonCoreStylesheets({ + source, + paragonCoreCss, + paragonThemeCss, + brandThemeCss, +}) { + return insertStylesheetsIntoDocument({ + source, + urls: paragonCoreCss.urls, + paragonThemeCss, + brandThemeCss, + }); +} + +/** + * Injects Paragon theme variant stylesheets into the document. + * + * @param {Object} options - The options object. + * @param {string|object} options.source - The source HTML document. + * @param {Object} options.paragonThemeVariantCss - The Paragon theme variant CSS object. + * @param {Object} options.paragonThemeCss - The Paragon theme CSS object. + * @param {Object} options.brandThemeCss - The brand theme CSS object. + * @return {string|object} The modified HTML document with Paragon theme variant stylesheets injected. + */ +function injectParagonThemeVariantStylesheets({ + source, + paragonThemeVariantCss, + paragonThemeCss, + brandThemeCss, +}) { + let newSource = source; + Object.values(paragonThemeVariantCss).forEach(({ urls }) => { + newSource = insertStylesheetsIntoDocument({ + source: typeof newSource === 'object' ? newSource.source() : newSource, + urls, + paragonThemeCss, + brandThemeCss, + }); + }); + return newSource; +} +/** + * Retrieves the URLs of the Paragon stylesheets based on the provided theme URLs, Paragon version, and brand version. + * + * @param {Object} options - The options object. + * @param {Object} options.paragonThemeUrls - The URLs of the Paragon theme. + * @param {string} options.paragonVersion - The version of the Paragon theme. + * @param {string} options.brandVersion - The version of the brand theme. + * @return {Object} An object containing the URLs of the Paragon stylesheets. + */ +function getParagonStylesheetUrls({ paragonThemeUrls, paragonVersion, brandVersion }) { + const paragonCoreCssUrl = typeof paragonThemeUrls.core.urls === 'object' ? paragonThemeUrls.core.urls.default : paragonThemeUrls.core.url; + const brandCoreCssUrl = typeof paragonThemeUrls.core.urls === 'object' ? paragonThemeUrls.core.urls.brandOverride : undefined; + + const defaultThemeVariants = paragonThemeUrls.defaults || {}; + + const coreCss = { + urls: { + default: handleVersionSubstitution({ url: paragonCoreCssUrl, wildcardKeyword: '$paragonVersion', localVersion: paragonVersion }), + brandOverride: handleVersionSubstitution({ url: brandCoreCssUrl, wildcardKeyword: '$brandVersion', localVersion: brandVersion }), + }, + }; + + const themeVariantsCss = {}; + const themeVariantsEntries = Object.entries(paragonThemeUrls.variants || {}); + themeVariantsEntries.forEach(([themeVariant, { url, urls }]) => { + const themeVariantMetadata = { urls: null }; + if (url) { + themeVariantMetadata.urls = { + default: handleVersionSubstitution({ + url, + wildcardKeyword: '$paragonVersion', + localVersion: paragonVersion, + }), + // If there is no brand override URL, then we don't need to do any version substitution + // but we still need to return the property. + brandOverride: undefined, + }; + } else { + themeVariantMetadata.urls = { + default: handleVersionSubstitution({ + url: urls.default, + wildcardKeyword: '$paragonVersion', + localVersion: paragonVersion, + }), + brandOverride: handleVersionSubstitution({ + url: urls.brandOverride, + wildcardKeyword: '$brandVersion', + localVersion: brandVersion, + }), + }; + } + themeVariantsCss[themeVariant] = themeVariantMetadata; + }); + + return { + core: coreCss, + variants: themeVariantsCss, + defaults: defaultThemeVariants, + }; +} + +module.exports = { + injectParagonCoreStylesheets, + injectParagonThemeVariantStylesheets, + getParagonStylesheetUrls, +}; diff --git a/lib/plugins/paragon-webpack-plugin/utils/scriptUtils.js b/lib/plugins/paragon-webpack-plugin/utils/scriptUtils.js new file mode 100644 index 000000000..11005014a --- /dev/null +++ b/lib/plugins/paragon-webpack-plugin/utils/scriptUtils.js @@ -0,0 +1,144 @@ +const { sources } = require('webpack'); +const parse5 = require('parse5'); + +const { getDescendantByTag, minifyScript } = require('./tagUtils'); + +/** + * Finds the insertion point for a script in an HTML document. + * + * @param {Object} options - The options object. + * @param {Object} options.document - The parsed HTML document. + * @param {string} options.originalSource - The original source code of the HTML document. + * @throws {Error} If the body element is missing in the HTML document. + * @return {number} The insertion point for the script in the HTML document. + */ +function findScriptInsertionPoint({ document, originalSource }) { + const bodyElement = getDescendantByTag(document, 'body'); + if (!bodyElement) { + throw new Error('Missing body element in index.html.'); + } + + // determine script insertion point + if (bodyElement.sourceCodeLocation?.endTag) { + return bodyElement.sourceCodeLocation.endTag.startOffset; + } + + // less accurate fallback + return originalSource.indexOf(''); +} + +/** + * Inserts the given script contents into the HTML document and returns a new source with the modified content. + * + * @param {Object} options - The options object. + * @param {string} options.originalSource - The original HTML source. + * @param {Object} options.scriptContents - The contents of the script to be inserted. + * @return {sources.ReplaceSource} The new source with the modified HTML content. + */ +function insertScriptContentsIntoDocument({ + originalSource, + scriptContents, +}) { + // parse file as html document + const document = parse5.parse(originalSource, { + sourceCodeLocationInfo: true, + }); + + // find the body element + const scriptInsertionPoint = findScriptInsertionPoint({ + document, + originalSource, + }); + + // create Paragon script to inject into the HTML document + const paragonScript = ``; + + // insert the Paragon script into the HTML document + const newSource = new sources.ReplaceSource( + new sources.RawSource(originalSource), + 'index.html', + ); + newSource.insert(scriptInsertionPoint, minifyScript(paragonScript)); + return newSource; +} + +/** + * Creates an object with the provided version, defaults, coreCssAsset, and themeVariantCssAssets + * and returns it. The returned object has the following structure: + * { + * version: The provided version, + * themeUrls: { + * core: The provided coreCssAsset, + * variants: The provided themeVariantCssAssets, + * defaults: The provided defaults + * } + * } + * + * @param {Object} options - The options object. + * @param {string} options.version - The version to be added to the returned object. + * @param {Object} options.defaults - The defaults to be added to the returned object. + * @param {Object} options.coreCssAsset - The coreCssAsset to be added to the returned object. + * @param {Object} options.themeVariantCssAssets - The themeVariantCssAssets to be added to the returned object. + * @return {Object} The object with the provided version, defaults, coreCssAsset, and themeVariantCssAssets. + */ +function addToScriptContents({ + version, + defaults, + coreCssAsset, + themeVariantCssAssets, +}) { + return { + version, + themeUrls: { + core: coreCssAsset, + variants: themeVariantCssAssets, + defaults, + }, + }; +} + +/** + * Generates the script contents object based on the provided assets and versions. + * + * @param {Object} options - The options object. + * @param {Object} options.paragonCoreCssAsset - The asset for the Paragon core CSS. + * @param {Object} options.paragonThemeVariantCssAssets - The assets for the Paragon theme variants. + * @param {Object} options.brandCoreCssAsset - The asset for the brand core CSS. + * @param {Object} options.brandThemeVariantCssAssets - The assets for the brand theme variants. + * @param {Object} options.paragonThemeCss - The Paragon theme CSS. + * @param {string} options.paragonVersion - The version of the Paragon theme. + * @param {Object} options.brandThemeCss - The brand theme CSS. + * @param {string} options.brandVersion - The version of the brand theme. + * @return {Object} The script contents object. + */ +function generateScriptContents({ + paragonCoreCssAsset, + paragonThemeVariantCssAssets, + brandCoreCssAsset, + brandThemeVariantCssAssets, + paragonThemeCss, + paragonVersion, + brandThemeCss, + brandVersion, +}) { + const scriptContents = {}; + scriptContents.paragon = addToScriptContents({ + version: paragonVersion, + coreCssAsset: paragonCoreCssAsset, + themeVariantCssAssets: paragonThemeVariantCssAssets, + defaults: paragonThemeCss?.defaults, + }); + scriptContents.brand = addToScriptContents({ + version: brandVersion, + coreCssAsset: brandCoreCssAsset, + themeVariantCssAssets: brandThemeVariantCssAssets, + defaults: brandThemeCss?.defaults, + }); + return scriptContents; +} + +module.exports = { + addToScriptContents, + insertScriptContentsIntoDocument, + generateScriptContents, +}; diff --git a/lib/plugins/paragon-webpack-plugin/utils/stylesheetUtils.js b/lib/plugins/paragon-webpack-plugin/utils/stylesheetUtils.js new file mode 100644 index 000000000..78ab0c1e2 --- /dev/null +++ b/lib/plugins/paragon-webpack-plugin/utils/stylesheetUtils.js @@ -0,0 +1,106 @@ +const parse5 = require('parse5'); +const { sources } = require('webpack'); + +const { getDescendantByTag } = require('./tagUtils'); + +/** + * Finds the insertion point for a stylesheet in an HTML document. + * + * @param {Object} options - The options object. + * @param {Object} options.document - The parsed HTML document. + * @param {string} options.source - The original source code of the HTML document. + * @throws {Error} If the head element is missing in the HTML document. + * @return {number} The insertion point for the stylesheet in the HTML document. + */ +function findStylesheetInsertionPoint({ document, source }) { + const headElement = getDescendantByTag(document, 'head'); + if (!headElement) { + throw new Error('Missing head element in index.html.'); + } + + // determine script insertion point + if (headElement.sourceCodeLocation?.startTag) { + return headElement.sourceCodeLocation.startTag.endOffset; + } + + // less accurate fallback + const headTagString = ''; + const headTagIndex = source.indexOf(headTagString); + return headTagIndex + headTagString.length; +} + +/** + * Inserts stylesheets into an HTML document. + * + * @param {object} options - The options for inserting stylesheets. + * @param {string} options.source - The HTML source code. + * @param {object} options.urls - The URLs of the stylesheets to be inserted. + * @param {string} options.urls.default - The URL of the default stylesheet. + * @param {string} options.urls.brandOverride - The URL of the brand override stylesheet. + * @return {object} The new source code with the stylesheets inserted. + */ +function insertStylesheetsIntoDocument({ + source, + urls, +}) { + // parse file as html document + const document = parse5.parse(source, { + sourceCodeLocationInfo: true, + }); + if (!getDescendantByTag(document, 'head')) { + return undefined; + } + + const newSource = new sources.ReplaceSource( + new sources.RawSource(source), + 'index.html', + ); + + // insert the brand overrides styles into the HTML document + const stylesheetInsertionPoint = findStylesheetInsertionPoint({ + document, + source: newSource, + }); + + /** + * Creates a new stylesheet link element. + * + * @param {string} url - The URL of the stylesheet. + * @return {string} The HTML code for the stylesheet link element. + */ + function createNewStylesheet(url) { + const baseLink = ``; + return baseLink; + } + + if (urls.default) { + const existingDefaultLink = getDescendantByTag(`link[href='${urls.default}']`); + if (!existingDefaultLink) { + // create link to inject into the HTML document + const stylesheetLink = createNewStylesheet(urls.default); + newSource.insert(stylesheetInsertionPoint, stylesheetLink); + } + } + + if (urls.brandOverride) { + const existingBrandLink = getDescendantByTag(`link[href='${urls.brandOverride}']`); + if (!existingBrandLink) { + // create link to inject into the HTML document + const stylesheetLink = createNewStylesheet(urls.brandOverride); + newSource.insert(stylesheetInsertionPoint, stylesheetLink); + } + } + + return newSource; +} + +module.exports = { + findStylesheetInsertionPoint, + insertStylesheetsIntoDocument, +}; diff --git a/lib/plugins/paragon-webpack-plugin/utils/tagUtils.js b/lib/plugins/paragon-webpack-plugin/utils/tagUtils.js new file mode 100644 index 000000000..9f4d346d1 --- /dev/null +++ b/lib/plugins/paragon-webpack-plugin/utils/tagUtils.js @@ -0,0 +1,58 @@ +/** + * Recursively searches for a descendant node with the specified tag name. + * + * @param {Object} node - The root node to start the search from. + * @param {string} tag - The tag name to search for. + * @return {Object|null} The first descendant node with the specified tag name, or null if not found. + */ +function getDescendantByTag(node, tag) { + for (let i = 0; i < node.childNodes?.length; i++) { + if (node.childNodes[i].tagName === tag) { + return node.childNodes[i]; + } + const result = getDescendantByTag(node.childNodes[i], tag); + if (result) { + return result; + } + } + return null; +} + +/** + * Replaces a wildcard keyword in a URL with a local version. + * + * @param {Object} options - The options object. + * @param {string} options.url - The URL to substitute the keyword in. + * @param {string} options.wildcardKeyword - The wildcard keyword to replace. + * @param {string} options.localVersion - The local version to substitute the keyword with. + * @return {string} The URL with the wildcard keyword substituted with the local version, + * or the original URL if no substitution is needed. + */ +function handleVersionSubstitution({ url, wildcardKeyword, localVersion }) { + if (!url || !url.includes(wildcardKeyword) || !localVersion) { + return url; + } + return url.replaceAll(wildcardKeyword, localVersion); +} + +/** + * Minifies a script by removing unnecessary whitespace and line breaks. + * + * @param {string} script - The script to be minified. + * @return {string} The minified script. + */ +function minifyScript(script) { + return script + .replace(/>[\r\n ]+<') + .replace(/(<.*?>)|\s+/g, (m, $1) => { + if ($1) { return $1; } + return ' '; + }) + .trim(); +} + +module.exports = { + getDescendantByTag, + handleVersionSubstitution, + minifyScript, +}; diff --git a/package-lock.json b/package-lock.json index cdeda61a0..93b000a19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "image-minimizer-webpack-plugin": "3.8.3", "jest": "26.6.3", "mini-css-extract-plugin": "1.6.2", + "parse5": "7.1.2", "postcss": "8.4.38", "postcss-custom-media": "10.0.4", "postcss-loader": "7.3.4", @@ -66,7 +67,8 @@ "webpack-bundle-analyzer": "^4.10.1", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1", - "webpack-merge": "^5.10.0" + "webpack-merge": "^5.10.0", + "webpack-remove-empty-scripts": "1.0.4" }, "bin": { "fedx-scripts": "bin/fedx-scripts.js" @@ -4094,6 +4096,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansis": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-1.5.2.tgz", + "integrity": "sha512-T3vUABrcgSj/HXv27P+A/JxGk5b/ydx0JjN3lgjBTC2iZUFxQGjh43zCzLSbU4C1QTgmx9oaPeWNJFM+auI8qw==", + "license": "ISC", + "engines": { + "node": ">=12.13" + }, + "funding": { + "type": "patreon", + "url": "https://patreon.com/biodiscus" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -10363,6 +10378,12 @@ } } }, + "node_modules/jsdom/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "license": "MIT" + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -11498,9 +11519,16 @@ } }, "node_modules/parse5": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", - "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "license": "MIT", + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } }, "node_modules/parseurl": { "version": "1.3.3", @@ -16053,6 +16081,25 @@ "node": ">=10.0.0" } }, + "node_modules/webpack-remove-empty-scripts": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/webpack-remove-empty-scripts/-/webpack-remove-empty-scripts-1.0.4.tgz", + "integrity": "sha512-W/Vd94oNXMsQam+W9G+aAzGgFlX1aItcJpkG3byuHGDaxyK3H17oD/b5RcqS/ZHzStIKepksdLDznejDhDUs+Q==", + "license": "ISC", + "dependencies": { + "ansis": "1.5.2" + }, + "engines": { + "node": ">=12.14" + }, + "funding": { + "type": "patreon", + "url": "https://patreon.com/biodiscus" + }, + "peerDependencies": { + "webpack": ">=5.32.0" + } + }, "node_modules/webpack-sources": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", diff --git a/package.json b/package.json index ce2c4ace3..8ab0bf212 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "image-minimizer-webpack-plugin": "3.8.3", "jest": "26.6.3", "mini-css-extract-plugin": "1.6.2", + "parse5": "7.1.2", "postcss": "8.4.38", "postcss-custom-media": "10.0.4", "postcss-loader": "7.3.4", @@ -82,7 +83,8 @@ "webpack-bundle-analyzer": "^4.10.1", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1", - "webpack-merge": "^5.10.0" + "webpack-merge": "^5.10.0", + "webpack-remove-empty-scripts": "1.0.4" }, "devDependencies": { "@babel/preset-typescript": "^7.18.6",