From 452b3e86a986568f40aaf3bcf1617dfc0b89854c Mon Sep 17 00:00:00 2001
From: Adam Stankiewicz
Date: Mon, 5 Jun 2023 01:39:11 -0400
Subject: [PATCH 01/23] feat: expose PARAGON_VERSION as a global variable
fix: rely on paragon-theme.json from @edx/paragon
chore: clean up and add typedefs
fix: create undefined PARAGON global variable if no compatible paragon version
fix: moar better error handling
fix: updates
fix: update based on new schema for paragon-theme.json
fix: update setupTest.js
chore: clean up
chore: remove compressionplugin
chore: quality
fix: rename paragon-theme.json to theme-urls.json
chore: uninstall unused node_module
feat: add @edx/brand version and urls to PARAGON_THEME global variable
chore: update snapshot
---
config/.eslintrc.js | 1 +
config/data/paragonUtils.js | 165 +++++++++++++
config/jest/setupTest.js | 33 +++
config/webpack.common.config.js | 27 ++
config/webpack.dev-stage.config.js | 1 +
config/webpack.dev.config.js | 61 ++++-
config/webpack.prod.config.js | 3 +-
example/.env.development | 1 +
example/src/App.jsx | 2 +
example/src/ParagonPreview.jsx | 39 +++
example/src/__snapshots__/App.test.jsx.snap | 85 +++++++
.../ParagonWebpackPlugin.js | 230 ++++++++++++++++++
lib/plugins/paragon-webpack-plugin/index.js | 3 +
13 files changed, 640 insertions(+), 11 deletions(-)
create mode 100644 config/data/paragonUtils.js
create mode 100644 example/src/ParagonPreview.jsx
create mode 100644 lib/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js
create mode 100644 lib/plugins/paragon-webpack-plugin/index.js
diff --git a/config/.eslintrc.js b/config/.eslintrc.js
index 0646ebca4..69a4e0a13 100644
--- a/config/.eslintrc.js
+++ b/config/.eslintrc.js
@@ -39,6 +39,7 @@ module.exports = {
},
globals: {
newrelic: false,
+ PARAGON_THEME: false,
},
ignorePatterns: [
'module.config.js',
diff --git a/config/data/paragonUtils.js b/config/data/paragonUtils.js
new file mode 100644
index 000000000..653f6227e
--- /dev/null
+++ b/config/data/paragonUtils.js
@@ -0,0 +1,165 @@
+const path = require('path');
+const fs = require('fs');
+
+/**
+ * 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 ? '@edx/brand' : '@edx/paragon';
+ const pathToPackageJson = `${dir}/node_modules/${npmPackageName}/package.json`;
+ if (!fs.existsSync(pathToPackageJson)) {
+ return undefined;
+ }
+ return JSON.parse(fs.readFileSync(pathToPackageJson)).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
+ * @property {boolean} default
+ * @property {boolean} dark
+ */
+
+/**
+ * @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 `@edx/paragon` package.
+ * @param {string} dir Path to directory containing `node_modules`.
+ * @returns {ParagonThemeCss}
+ */
+function getParagonThemeCss(dir, { isBrandOverride = false } = {}) {
+ const npmPackageName = isBrandOverride ? '@edx/brand' : '@edx/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));
+ const {
+ core: themeCore,
+ variants: themeVariants,
+ } = paragonConfig?.themeUrls || {};
+
+ const pathToCoreCss = path.resolve(dir, 'node_modules', npmPackageName, 'dist', themeCore.paths.minified);
+ const coreCssExists = fs.existsSync(pathToCoreCss);
+
+ const validThemeVariantPaths = Object.entries(themeVariants || {}).filter(([, 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);
+ return fs.existsSync(themeVariantCssDefault) && fs.existsSync(themeVariantCssMinified);
+ });
+
+ if (!coreCssExists || validThemeVariantPaths.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',
+ };
+
+ const themeVariantResults = {};
+ validThemeVariantPaths.forEach(([themeVariant, value]) => {
+ themeVariantResults[themeVariant] = {
+ filePath: path.resolve(dir, 'node_modules', npmPackageName, 'dist', value.paths.minified),
+ entryName: isBrandOverride ? `brand.theme.variants.${themeVariant}` : `paragon.theme.variants.${themeVariant}`,
+ outputChunkName: isBrandOverride ? `brand-theme-variant-${themeVariant}` : `paragon-theme-variant-${themeVariant}`,
+ default: value.default,
+ dark: value.dark,
+ };
+ });
+
+ return {
+ core: fs.existsSync(pathToCoreCss) ? coreResult : undefined,
+ variants: themeVariantResults,
+ };
+}
+
+/**
+ * Replaces all periods in a string with hyphens.
+ * @param {string} str A string containing periods to replace with hyphens.
+ * @returns The input string with periods replaced with hyphens.
+ */
+function replacePeriodsWithHyphens(str) {
+ return str.replaceAll('.', '-');
+}
+
+/**
+ * @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) {
+ const cacheGroups = {};
+ if (!paragonThemeCss) {
+ return cacheGroups;
+ }
+ cacheGroups[paragonThemeCss.core.entryName] = {
+ type: 'css/mini-extract',
+ name: replacePeriodsWithHyphens(paragonThemeCss.core.entryName),
+ chunks: chunk => chunk.name === paragonThemeCss.core.entryName,
+ enforce: true,
+ };
+ Object.values(paragonThemeCss.variants).forEach(({ entryName }) => {
+ cacheGroups[entryName] = {
+ type: 'css/mini-extract',
+ name: replacePeriodsWithHyphens(entryName),
+ 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/@edx/paragon/dist/core.min.css",
+ * "paragon.theme.variants.light": "/path/to/node_modules/@edx/paragon/dist/light.min.css"
+ * }
+ * ```
+ */
+function getParagonEntryPoints(paragonThemeCss) {
+ const entryPoints = {};
+ if (!paragonThemeCss) {
+ return entryPoints;
+ }
+ 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,
+ replacePeriodsWithHyphens,
+};
diff --git a/config/jest/setupTest.js b/config/jest/setupTest.js
index 6787604b9..c2a381a6d 100644
--- a/config/jest/setupTest.js
+++ b/config/jest/setupTest.js
@@ -8,3 +8,36 @@ const testEnvFile = path.resolve(process.cwd(), '.env.test');
if (fs.existsSync(testEnvFile)) {
dotenv.config({ path: testEnvFile });
}
+
+global.PARAGON_THEME = {
+ paragon: {
+ version: '1.0.0',
+ themeUrls: {
+ core: {
+ fileName: 'core.min.css',
+ },
+ variants: {
+ light: {
+ fileName: 'light.min.css',
+ default: true,
+ dark: false,
+ },
+ },
+ },
+ },
+ brand: {
+ version: '1.0.0',
+ themeUrls: {
+ core: {
+ fileName: 'core.min.css',
+ },
+ variants: {
+ light: {
+ fileName: 'light.min.css',
+ default: true,
+ dark: false,
+ },
+ },
+ },
+ },
+};
diff --git a/config/webpack.common.config.js b/config/webpack.common.config.js
index 944fdf62c..07893d33f 100644
--- a/config/webpack.common.config.js
+++ b/config/webpack.common.config.js
@@ -1,8 +1,35 @@
const path = require('path');
+const RemoveEmptyScriptsPlugin = require('webpack-remove-empty-scripts');
+
+const ParagonWebpackPlugin = require('../lib/plugins/paragon-webpack-plugin/ParagonWebpackPlugin');
+const {
+ getParagonThemeCss,
+ getParagonCacheGroups,
+ getParagonEntryPoints,
+} = require('./data/paragonUtils');
+
+const paragonThemeCss = getParagonThemeCss(process.cwd());
+const brandThemeCss = getParagonThemeCss(process.cwd(), { isBrandOverride: true });
module.exports = {
entry: {
app: path.resolve(process.cwd(), './src/index'),
+ /**
+ * The entry points for the Paragon theme CSS. Example: ```
+ * {
+ * "paragon.theme.core": "/path/to/node_modules/@edx/paragon/dist/core.min.css",
+ * "paragon.theme.variants.light": "/path/to/node_modules/@edx/paragon/dist/light.min.css"
+ * }
+ */
+ ...getParagonEntryPoints(paragonThemeCss),
+ /**
+ * The entry points for the brand theme CSS. Example: ```
+ * {
+ * "paragon.theme.core": "/path/to/node_modules/@edx/brand/dist/core.min.css",
+ * "paragon.theme.variants.light": "/path/to/node_modules/@edx/brand/dist/light.min.css"
+ * }
+ */
+ ...getParagonEntryPoints(brandThemeCss),
},
output: {
path: path.resolve(process.cwd(), './dist'),
diff --git a/config/webpack.dev-stage.config.js b/config/webpack.dev-stage.config.js
index 57dfcf351..1a13e0312 100644
--- a/config/webpack.dev-stage.config.js
+++ b/config/webpack.dev-stage.config.js
@@ -157,6 +157,7 @@ module.exports = merge(commonConfig, {
new HtmlWebpackPlugin({
inject: true, // Appends script tags linking to the webpack bundles at the end of the body
template: path.resolve(process.cwd(), 'public/index.html'),
+ chunks: ['app'],
FAVICON_URL: process.env.FAVICON_URL || null,
OPTIMIZELY_PROJECT_ID: process.env.OPTIMIZELY_PROJECT_ID || null,
NODE_ENV: process.env.NODE_ENV || null,
diff --git a/config/webpack.dev.config.js b/config/webpack.dev.config.js
index 5ce771608..3319b8f4d 100644
--- a/config/webpack.dev.config.js
+++ b/config/webpack.dev.config.js
@@ -1,7 +1,7 @@
// This is the dev Webpack config. All settings here should prefer a fast build
// time at the expense of creating larger, unoptimized bundles.
const ImageMinimizerPlugin = require('image-minimizer-webpack-plugin');
-
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { merge } = require('webpack-merge');
const Dotenv = require('dotenv-webpack');
const dotenv = require('dotenv');
@@ -31,6 +31,45 @@ resolvePrivateEnvConfig('.env.private');
const aliases = getLocalAliases();
const PUBLIC_PATH = process.env.PUBLIC_PATH || '/';
+function getStyleUseConfig() {
+ return [
+ {
+ loader: 'css-loader', // translates CSS into CommonJS
+ options: {
+ sourceMap: true,
+ modules: {
+ compileType: 'icss',
+ },
+ },
+ },
+ {
+ loader: 'postcss-loader',
+ options: {
+ postcssOptions: {
+ plugins: [
+ PostCssAutoprefixerPlugin(),
+ PostCssRTLCSS(),
+ PostCssCustomMediaCSS(),
+ ],
+ },
+ },
+ },
+ 'resolve-url-loader',
+ {
+ loader: 'sass-loader', // compiles Sass to CSS
+ options: {
+ sourceMap: true,
+ sassOptions: {
+ includePaths: [
+ path.join(process.cwd(), 'node_modules'),
+ path.join(process.cwd(), 'src'),
+ ],
+ },
+ },
+ },
+ ];
+}
+
module.exports = merge(commonConfig, {
mode: 'development',
devtool: 'eval-source-map',
@@ -68,16 +107,13 @@ module.exports = merge(commonConfig, {
// flash-of-unstyled-content issues in development.
{
test: /(.scss|.css)$/,
- use: [
- 'style-loader', // creates style nodes from JS strings
+ oneOf: [
{
- loader: 'css-loader', // translates CSS into CommonJS
- options: {
- sourceMap: true,
- modules: {
- compileType: 'icss',
- },
- },
+ resource: /(@edx\/paragon|@edx\/brand)/,
+ use: [
+ MiniCssExtractPlugin.loader,
+ ...getStyleUseConfig(),
+ ],
},
{
loader: 'postcss-loader',
@@ -156,10 +192,15 @@ module.exports = merge(commonConfig, {
},
// Specify additional processing or side-effects done on the Webpack output bundles as a whole.
plugins: [
+ // Writes the extracted CSS from each entry to a file in the output directory.
+ new MiniCssExtractPlugin({
+ filename: '[name].css',
+ }),
// Generates an HTML file in the output directory.
new HtmlWebpackPlugin({
inject: true, // Appends script tags linking to the webpack bundles at the end of the body
template: path.resolve(process.cwd(), 'public/index.html'),
+ chunks: ['app'],
FAVICON_URL: process.env.FAVICON_URL || null,
OPTIMIZELY_PROJECT_ID: process.env.OPTIMIZELY_PROJECT_ID || null,
NODE_ENV: process.env.NODE_ENV || null,
diff --git a/config/webpack.prod.config.js b/config/webpack.prod.config.js
index 16f6d170e..5d3778abd 100644
--- a/config/webpack.prod.config.js
+++ b/config/webpack.prod.config.js
@@ -114,8 +114,8 @@ module.exports = merge(commonConfig, {
plugins: [
PostCssAutoprefixerPlugin(),
PostCssRTLCSS(),
- CssNano(),
PostCssCustomMediaCSS(),
+ CssNano(),
...extraPostCssPlugins,
],
},
@@ -202,6 +202,7 @@ module.exports = merge(commonConfig, {
new HtmlWebpackPlugin({
inject: true, // Appends script tags linking to the webpack bundles at the end of the body
template: path.resolve(process.cwd(), 'public/index.html'),
+ chunks: ['app'],
FAVICON_URL: process.env.FAVICON_URL || null,
OPTIMIZELY_PROJECT_ID: process.env.OPTIMIZELY_PROJECT_ID || null,
NODE_ENV: process.env.NODE_ENV || null,
diff --git a/example/.env.development b/example/.env.development
index 6dc023e15..cffa57110 100644
--- a/example/.env.development
+++ b/example/.env.development
@@ -1,3 +1,4 @@
PORT=3000
+BASE_URL='http://localhost:8080'
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
TEST_VARIABLE='foo'
diff --git a/example/src/App.jsx b/example/src/App.jsx
index 8ef1e4417..219dda4b7 100644
--- a/example/src/App.jsx
+++ b/example/src/App.jsx
@@ -4,6 +4,7 @@ import appleUrl, { ReactComponent as Apple } from './apple.svg';
import appleImg from './apple.jpg';
import './style.scss';
+import ParagonPreview from './ParagonPreview';
// eslint-disable-next-line react/function-component-definition
export default function App() {
@@ -38,6 +39,7 @@ export default function App() {
env.config.js integer test: {Number.isInteger(config.INTEGER_VALUE) ? 'It was an integer. Great.' : 'It was not an integer! Why not? '}
Right-to-left language handling tests
I'm aligned right, but left in RTL.
+
);
}
diff --git a/example/src/ParagonPreview.jsx b/example/src/ParagonPreview.jsx
new file mode 100644
index 000000000..118c72e59
--- /dev/null
+++ b/example/src/ParagonPreview.jsx
@@ -0,0 +1,39 @@
+import React from 'react';
+
+const ParagonPreview = () => {
+ if (!PARAGON_THEME) {
+ return null;
+ }
+ return (
+ <>
+ Paragon
+ Exposed theme CSS files
+
+ Contents of PARAGON_THEME
global variable
+ {JSON.stringify(PARAGON_THEME, null, 2)}
+ >
+ );
+};
+
+export default ParagonPreview;
diff --git a/example/src/__snapshots__/App.test.jsx.snap b/example/src/__snapshots__/App.test.jsx.snap
index 05540a1f1..fb5065c86 100644
--- a/example/src/__snapshots__/App.test.jsx.snap
+++ b/example/src/__snapshots__/App.test.jsx.snap
@@ -116,5 +116,90 @@ exports[`Basic test should render 1`] = `
>
I'm aligned right, but left in RTL.
+
+ Paragon
+
+
+ Exposed theme CSS files
+
+
+
+ Contents of
+
+ PARAGON_THEME
+
+ global variable
+
+
+ {
+ "paragon": {
+ "version": "1.0.0",
+ "themeUrls": {
+ "core": {
+ "fileName": "core.min.css"
+ },
+ "variants": {
+ "light": {
+ "fileName": "light.min.css",
+ "default": true,
+ "dark": false
+ }
+ }
+ }
+ },
+ "brand": {
+ "version": "1.0.0",
+ "themeUrls": {
+ "core": {
+ "fileName": "core.min.css"
+ },
+ "variants": {
+ "light": {
+ "fileName": "light.min.css",
+ "default": true,
+ "dark": false
+ }
+ }
+ }
+ }
+}
+
`;
diff --git a/lib/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js b/lib/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js
new file mode 100644
index 000000000..7f75fe457
--- /dev/null
+++ b/lib/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js
@@ -0,0 +1,230 @@
+const { Compilation, sources } = require('webpack');
+const parse5 = require('parse5');
+const {
+ getParagonVersion,
+ getParagonThemeCss,
+ replacePeriodsWithHyphens,
+} = require('../../../config/data/paragonUtils');
+
+const paragonVersion = getParagonVersion(process.cwd());
+const paragonThemeCss = getParagonThemeCss(process.cwd());
+
+const brandVersion = getParagonVersion(process.cwd(), { isBrandOverride: true });
+const brandThemeCss = getParagonThemeCss(process.cwd(), { isBrandOverride: true });
+
+class ParagonWebpackPlugin {
+ constructor() {
+ this.paragon = {
+ version: paragonVersion,
+ coreEntryName: undefined,
+ themeVariantEntryNames: {},
+ };
+
+ this.brand = {
+ version: brandVersion,
+ coreEntryName: undefined,
+ themeVariantEntryNames: {},
+ };
+
+ if (!paragonThemeCss && !brandThemeCss) {
+ return;
+ }
+
+ if (paragonThemeCss) {
+ // Core Paragon
+ this.paragon.coreEntryName = replacePeriodsWithHyphens(paragonThemeCss.core.entryName);
+ Object.entries(paragonThemeCss.variants).forEach(([key, value]) => {
+ this.paragon.themeVariantEntryNames[key] = {
+ entryName: replacePeriodsWithHyphens(value.entryName),
+ default: value.default,
+ dark: value.dark,
+ };
+ });
+ }
+
+ if (brandThemeCss) {
+ // `@edx/brand` overrides
+ this.brand.coreEntryName = replacePeriodsWithHyphens(brandThemeCss.core.entryName);
+ Object.entries(brandThemeCss.variants).forEach(([key, value]) => {
+ this.brand.themeVariantEntryNames[key] = {
+ entryName: replacePeriodsWithHyphens(value.entryName),
+ default: value.default,
+ dark: value.dark,
+ };
+ });
+ }
+ }
+
+ logger(message) {
+ console.log('[ParagonWebpackPlugin]', message);
+ }
+
+ getDescendantByTag(node, tag) {
+ for (let i = 0; i < node.childNodes?.length; i++) {
+ if (node.childNodes[i].tagName === tag) {
+ return node.childNodes[i];
+ }
+ const result = this.getDescendantByTag(node.childNodes[i], tag);
+ if (result) {
+ return result;
+ }
+ }
+ return null;
+ }
+
+ getParagonCssAssetsFromCompilation(compilation, { isBrandOverride = false } = {}) {
+ const assetSubstring = isBrandOverride ? 'brand' : 'paragon';
+ const paragonAssets = compilation.getAssets().filter(asset => asset.name.includes(assetSubstring) && asset.name.endsWith('.css'));
+ const coreCssAsset = paragonAssets.find((asset) => asset.name.includes(this[assetSubstring].coreEntryName));
+
+ const themeVariantCssAssets = {};
+ Object.entries(this[assetSubstring].themeVariantEntryNames).forEach(([themeVariant, value]) => {
+ const foundThemeVariantAsset = paragonAssets.find((asset) => asset.name.includes(value.entryName));
+ if (!foundThemeVariantAsset) {
+ return;
+ }
+ themeVariantCssAssets[themeVariant] = {
+ fileName: foundThemeVariantAsset.name,
+ default: value.default,
+ dark: value.dark,
+ };
+ });
+
+ if (!coreCssAsset || !Object.keys(themeVariantCssAssets).length === 0) {
+ return {
+ coreCssAsset: undefined,
+ themeVariantCssAssets: {},
+ };
+ }
+
+ return {
+ coreCssAsset: {
+ fileName: coreCssAsset?.name,
+ },
+ themeVariantCssAssets,
+ };
+ }
+
+ findScriptInsertionPoint({ document, originalSource }) {
+ const bodyElement = this.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('