Skip to content

Commit

Permalink
Merge remote-tracking branch 'openedx-frontend-build/master'
Browse files Browse the repository at this point in the history
# Conflicts:
#	.npmignore
#	README.md
#	catalog-info.yaml
#	config/.eslintrc.js
#	config/jest.config.js
#	config/jest/setupTest.js
#	config/webpack.common.config.js
#	config/webpack.dev-stage.config.js
#	example/package-lock.json
#	example/package.json
#	example/src/App.jsx
#	example/src/__snapshots__/App.test.jsx.snap
#	example/src/apple.jpg
#	package-lock.json
#	package.json
#	test-project/src/ParagonPreview.jsx
#	tools/webpack/webpack.build.config.ts
#	tools/webpack/webpack.dev.config.ts
  • Loading branch information
davidjoy committed Sep 12, 2024
2 parents 06213ba + 10d9166 commit ac11431
Show file tree
Hide file tree
Showing 18 changed files with 1,161 additions and 37 deletions.
171 changes: 171 additions & 0 deletions config/data/paragonUtils.js
Original file line number Diff line number Diff line change
@@ -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.<string, ParagonThemeVariantCssAsset>} 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.<string, CacheGroup>} 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.<string, string>} 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,
};
126 changes: 126 additions & 0 deletions lib/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js
Original file line number Diff line number Diff line change
@@ -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 `<link rel="preload" as="style">` 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 `<link rel="preload" as="style">` 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;
3 changes: 3 additions & 0 deletions lib/plugins/paragon-webpack-plugin/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const ParagonWebpackPlugin = require('./ParagonWebpackPlugin');

module.exports = ParagonWebpackPlugin;
75 changes: 75 additions & 0 deletions lib/plugins/paragon-webpack-plugin/utils/assetUtils.js
Original file line number Diff line number Diff line change
@@ -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,
};
Loading

0 comments on commit ac11431

Please sign in to comment.