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() {

Right-to-left language handling tests

I'm aligned right, but left in RTL.

+ ) } diff --git a/test-project/src/ParagonPreview.jsx b/test-project/src/ParagonPreview.jsx new file mode 100644 index 00000000..9d01fdec --- /dev/null +++ b/test-project/src/ParagonPreview.jsx @@ -0,0 +1,66 @@ +import { Button, Stack } from '@openedx/paragon'; + +const ParagonPreview = () => { + if (!PARAGON_THEME) { + return

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. + +

+ +
+
+

Contents of PARAGON_THEME global variable

+
{JSON.stringify(PARAGON_THEME, null, 2)}
+
+
+ + ); +}; + +export default ParagonPreview; diff --git a/tools/eslint/.eslintrc.js b/tools/eslint/.eslintrc.js index dff76a96..8de3b720 100644 --- a/tools/eslint/.eslintrc.js +++ b/tools/eslint/.eslintrc.js @@ -16,6 +16,7 @@ module.exports = { ], globals: { newrelic: false, + PARAGON_THEME: false, }, ignorePatterns: [ 'module.config.js', diff --git a/tools/webpack/webpack.build.config.ts b/tools/webpack/webpack.build.config.ts index 41aa045b..25fe5d47 100644 --- a/tools/webpack/webpack.build.config.ts +++ b/tools/webpack/webpack.build.config.ts @@ -15,9 +15,20 @@ import { Configuration, WebpackError } from 'webpack'; import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; import 'webpack-dev-server'; // Required to get devServer types added to Configuration +import RemoveEmptyScriptsPlugin from 'webpack-remove-empty-scripts'; + +import ParagonWebpackPlugin from '../lib/plugins/paragon-webpack-plugin/ParagonWebpackPlugin'; +import { + getParagonCacheGroups, + getParagonEntryPoints, + getParagonThemeCss, +} from './data/paragonUtils'; import getLocalAliases from './getLocalAliases'; import getSharedDependencies from './getSharedDependencies'; +const paragonThemeCss = getParagonThemeCss(process.cwd()); +const brandThemeCss = getParagonThemeCss(process.cwd(), { isBrandOverride: true }); + const PUBLIC_PATH = process.env.PUBLIC_PATH || '/'; const aliases = getLocalAliases(); @@ -27,6 +38,22 @@ const config: Configuration = { devtool: 'source-map', entry: { app: path.resolve(__dirname, '../../shell/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: { filename: '[name].[chunkhash].js', @@ -118,8 +145,8 @@ const config: Configuration = { plugins: [ PostCssAutoprefixerPlugin(), PostCssRTLCSS(), - CssNano(), PostCssCustomMediaCSS(), + CssNano(), ], }, }, @@ -168,6 +195,10 @@ const config: Configuration = { runtimeChunk: 'single', splitChunks: { chunks: 'all', + cacheGroups: { + ...getParagonCacheGroups(paragonThemeCss), + ...getParagonCacheGroups(brandThemeCss), + }, }, minimizer: [ '...', @@ -190,7 +221,13 @@ const config: Configuration = { }, // Specify additional processing or side-effects done on the Webpack output bundles as a whole. 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(), // Writes the extracted CSS from each entry to a file in the output directory. + new ParagonWebpackPlugin(), new MiniCssExtractPlugin({ filename: '[name].[chunkhash].css', }), @@ -198,6 +235,7 @@ const config: Configuration = { 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/tools/webpack/webpack.dev.config.ts b/tools/webpack/webpack.dev.config.ts index 81fd79a1..d5b88ceb 100644 --- a/tools/webpack/webpack.dev.config.ts +++ b/tools/webpack/webpack.dev.config.ts @@ -3,6 +3,7 @@ import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'; import PostCssAutoprefixerPlugin from 'autoprefixer'; import HtmlWebpackPlugin from 'html-webpack-plugin'; import ImageMinimizerPlugin from 'image-minimizer-webpack-plugin'; +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); import path from 'path'; import PostCssCustomMediaCSS from 'postcss-custom-media'; import PostCssRTLCSS from 'postcss-rtlcss'; @@ -10,15 +11,82 @@ import { Configuration, WebpackError } from 'webpack'; import 'webpack-dev-server'; // Required to get devServer types added to Configuration import { ModuleFederationPlugin } from '@module-federation/enhanced'; +import RemoveEmptyScriptsPlugin from 'webpack-remove-empty-scripts'; + +import ParagonWebpackPlugin from '../lib/plugins/paragon-webpack-plugin/ParagonWebpackPlugin'; +import { + getParagonThemeCss, + getParagonCacheGroups, + getParagonEntryPoints, +} from './data/paragonUtils'; import getLocalAliases from './getLocalAliases'; import getSharedDependencies from './getSharedDependencies'; +const paragonThemeCss = getParagonThemeCss(process.cwd()); +const brandThemeCss = getParagonThemeCss(process.cwd(), { isBrandOverride: true }); const aliases = getLocalAliases(); const PUBLIC_PATH = process.env.PUBLIC_PATH || '/'; +function getStyleUseConfig() { + return [ + { + loader: require.resolve('css-loader'), // translates CSS into CommonJS + options: { + sourceMap: true, + modules: { + compileType: 'icss', + }, + }, + }, + { + loader: require.resolve('postcss-loader'), + options: { + postcssOptions: { + plugins: [ + PostCssAutoprefixerPlugin(), + PostCssRTLCSS(), + PostCssCustomMediaCSS(), + ], + }, + }, + }, + require.resolve('resolve-url-loader'), + { + loader: require.resolve('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, + }, + }, + }, + ]; +} + const config: Configuration = { entry: { app: path.resolve(__dirname, '../../shell/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'), @@ -80,43 +148,19 @@ const config: Configuration = { // flash-of-unstyled-content issues in development. { test: /(.scss|.css)$/, - use: [ - require.resolve('style-loader'), // creates style nodes from JS strings + oneOf: [ { - loader: require.resolve('css-loader'), // translates CSS into CommonJS - options: { - sourceMap: true, - modules: { - compileType: 'icss', - }, - }, + resource: /(@openedx\/paragon|@(open)?edx\/brand)/, + use: [ + MiniCssExtractPlugin.loader, + ...getStyleUseConfig(), + ], }, { - loader: require.resolve('postcss-loader'), - options: { - postcssOptions: { - plugins: [ - PostCssAutoprefixerPlugin(), - PostCssRTLCSS(), - PostCssCustomMediaCSS(), - ], - }, - }, - }, - require.resolve('resolve-url-loader'), - { - loader: require.resolve('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: [ + require.resolve('style-loader'), // creates style nodes from JS strings + ...getStyleUseConfig(), + ], }, ], }, @@ -142,6 +186,13 @@ const config: Configuration = { ], }, optimization: { + splitChunks: { + chunks: 'all', + cacheGroups: { + ...getParagonCacheGroups(paragonThemeCss), + ...getParagonCacheGroups(brandThemeCss), + }, + }, minimizer: [ '...', new ImageMinimizerPlugin({ @@ -163,10 +214,21 @@ const config: Configuration = { }, // Specify additional processing or side-effects done on the Webpack output bundles as a whole. 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(), + // 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,