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",