diff --git a/config/data/paragonUtils.js b/config/data/paragonUtils.js
new file mode 100644
index 00000000..c48f6c3e
--- /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/lib/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js b/lib/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js
new file mode 100644
index 00000000..5369ebfe
--- /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 00000000..ac2486f8
--- /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 00000000..eca27e3a
--- /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 00000000..2923951e
--- /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 00000000..439b5bc3
--- /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 00000000..2b827207
--- /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 00000000..11005014
--- /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 00000000..107d6e16
--- /dev/null
+++ b/lib/plugins/paragon-webpack-plugin/utils/stylesheetUtils.js
@@ -0,0 +1,107 @@
+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 00000000..9f4d346d
--- /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.json b/package.json
index ce07c7cd..0a61ae2d 100644
--- a/package.json
+++ b/package.json
@@ -62,6 +62,7 @@
"@module-federation/enhanced": "^0.4.0",
"@module-federation/runtime": "^0.2.6",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.15",
+
"@types/gradient-string": "^1.1.6",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
@@ -105,8 +106,9 @@
"lodash.merge": "^4.6.2",
"lodash.snakecase": "^4.1.1",
"mini-css-extract-plugin": "1.6.2",
+ "parse5": "7.1.2",
"postcss": "8.4.40",
- "postcss-custom-media": "10.0.6",
+ "postcss-custom-media": "10.0.8",
"postcss-loader": "7.3.4",
"postcss-rtlcss": "5.1.2",
"prop-types": "^15.8.1",
@@ -130,7 +132,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": {
"@testing-library/dom": "^8.20.1",
diff --git a/runtime/setupTest.js b/runtime/setupTest.js
index 5bae8e8d..29428e21 100644
--- a/runtime/setupTest.js
+++ b/runtime/setupTest.js
@@ -12,3 +12,38 @@ jest.mock('universal-cookie', () => {
});
mergeConfig(siteConfig);
+
+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/shell/setupTest.js b/shell/setupTest.js
index d1b03338..6e7cfc02 100644
--- a/shell/setupTest.js
+++ b/shell/setupTest.js
@@ -12,3 +12,38 @@ jest.mock('universal-cookie', () => {
});
mergeConfig(siteConfig);
+
+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/test-project/src/ExamplePage.tsx b/test-project/src/ExamplePage.tsx
index 6eeb6b1a..4083bd0f 100644
--- a/test-project/src/ExamplePage.tsx
+++ b/test-project/src/ExamplePage.tsx
@@ -14,6 +14,7 @@ import appleImg from './apple.jpg';
import appleUrl from './apple.svg';
import Image from './Image';
import messages from './messages';
+import ParagonPreview from './ParagonPreview';
function printTestResult(value) {
return value ? '✅' : '❌';
@@ -87,6 +88,7 @@ export default function ExamplePage() {
Missing PARAGON_THEME global variable. Depending on configuration, this may be OK.
;
+ }
+ return (
+ <>
+
Paragon
+
+
+
+
Component preview
+
+
+
+
+
+
Exposed theme CSS files
+
+
+ Note: Depending on the versions of @openedx/paragon and/or @edx/brand installed,
+ it is expected that no exposed theme CSS assets be listed here.
+
+