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(''); + } + + minifyScript(script) { + return script + .replace(/>[\r\n ]+<') + .replace(/(<.*?>)|\s+/g, (m, $1) => { + if ($1) { return $1; } + return ' '; + }) + .trim(); + } + + insertParagonScriptIntoDocument({ + originalSource, + scriptContents, + }) { + // parse file as html document + const document = parse5.parse(originalSource, { + sourceCodeLocationInfo: true, + }); + + // find the body element + const scriptInsertionPoint = this.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, this.minifyScript(paragonScript)); + return newSource; + } + + apply(compiler) { + compiler.hooks.thisCompilation.tap('ParagonWebpackPlugin', (compilation) => { + compilation.hooks.processAssets.tap( + { + name: 'ParagonWebpackPlugin', + stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, + additionalAssets: true, + }, + () => { + const file = compilation.getAsset('index.html'); + if (!file) { + return; + } + const { + coreCssAsset: paragonCoreCssAsset, + themeVariantCssAssets: paragonThemeVariantCssAssets, + } = this.getParagonCssAssetsFromCompilation(compilation); + + const { + coreCssAsset: brandCoreCssAsset, + themeVariantCssAssets: brandThemeVariantCssAssets, + } = this.getParagonCssAssetsFromCompilation(compilation, { isBrandOverride: true }); + + let scriptContents; + const createEmptyScriptContentsIfUndefined = () => { + if (!scriptContents) { + scriptContents = {}; + } + }; + const originalSource = file.source.source(); + + if (paragonCoreCssAsset && Object.keys(paragonThemeVariantCssAssets).length > 0) { + createEmptyScriptContentsIfUndefined(); + scriptContents.paragon = { + version: this.paragon.version, + themeUrls: { + core: paragonCoreCssAsset, + variants: paragonThemeVariantCssAssets, + }, + }; + } + + if (brandCoreCssAsset && Object.keys(brandThemeVariantCssAssets).length > 0) { + createEmptyScriptContentsIfUndefined(); + scriptContents.brand = { + version: this.brand.version, + themeUrls: { + core: brandCoreCssAsset, + variants: brandThemeVariantCssAssets, + }, + }; + } + + const newSource = this.insertParagonScriptIntoDocument({ + originalSource, + coreCssAsset: paragonCoreCssAsset, + themeVariantCssAssets: paragonThemeVariantCssAssets, + scriptContents, + }); + compilation.updateAsset('index.html', new sources.RawSource(newSource.source())); + }, + ); + }); + } +} + +module.exports = ParagonWebpackPlugin; diff --git a/lib/plugins/paragon-webpack-plugin/index.js b/lib/plugins/paragon-webpack-plugin/index.js new file mode 100644 index 000000000..ac2486f89 --- /dev/null +++ b/lib/plugins/paragon-webpack-plugin/index.js @@ -0,0 +1,3 @@ +const ParagonWebpackPlugin = require('./ParagonWebpackPlugin'); + +module.exports = ParagonWebpackPlugin; From eb4fe12ac668464c29989a8216f5f06aede0ebc2 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Mon, 24 Jul 2023 00:42:13 -0400 Subject: [PATCH 02/23] fix: PR feedback fix: add comment to ParagonWebpackPlugin and update snapshots feat: preload links from PARAGON_THEME_URLS config fix: handle undefined this.paragonMetadata fix: remove fallbackUrls chore: snapshots and resolve .eslintrc error --- config/data/paragonUtils.js | 28 +- config/jest/setupTest.js | 10 +- example/.env.development | 15 +- example/.eslintrc.js | 3 - example/.gitignore | 1 + example/env.config.js | 25 ++ example/src/ParagonPreview.jsx | 56 ++- example/src/__snapshots__/App.test.jsx.snap | 27 +- example/src/index.jsx | 29 +- .../ParagonWebpackPlugin.js | 296 +++++-------- lib/plugins/paragon-webpack-plugin/utils.js | 388 ++++++++++++++++++ 11 files changed, 634 insertions(+), 244 deletions(-) delete mode 100644 example/.eslintrc.js create mode 100644 example/.gitignore create mode 100644 lib/plugins/paragon-webpack-plugin/utils.js diff --git a/config/data/paragonUtils.js b/config/data/paragonUtils.js index 653f6227e..8cc32825c 100644 --- a/config/data/paragonUtils.js +++ b/config/data/paragonUtils.js @@ -29,8 +29,6 @@ function getParagonVersion(dir, { isBrandOverride = false } = {}) { * @property {string} filePath * @property {string} entryName * @property {string} outputChunkName - * @property {boolean} default - * @property {boolean} dark */ /** @@ -55,6 +53,7 @@ function getParagonThemeCss(dir, { isBrandOverride = false } = {}) { const { core: themeCore, variants: themeVariants, + defaults, } = paragonConfig?.themeUrls || {}; const pathToCoreCss = path.resolve(dir, 'node_modules', npmPackageName, 'dist', themeCore.paths.minified); @@ -80,27 +79,17 @@ function getParagonThemeCss(dir, { isBrandOverride = false } = {}) { 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, + outputChunkName: isBrandOverride ? `brand-theme-variants-${themeVariant}` : `paragon-theme-variants-${themeVariant}`, }; }); return { core: fs.existsSync(pathToCoreCss) ? coreResult : undefined, variants: themeVariantResults, + defaults, }; } -/** - * 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. @@ -118,16 +107,16 @@ function getParagonCacheGroups(paragonThemeCss) { if (!paragonThemeCss) { return cacheGroups; } - cacheGroups[paragonThemeCss.core.entryName] = { + cacheGroups[paragonThemeCss.core.outputChunkName] = { type: 'css/mini-extract', - name: replacePeriodsWithHyphens(paragonThemeCss.core.entryName), + name: paragonThemeCss.core.outputChunkName, chunks: chunk => chunk.name === paragonThemeCss.core.entryName, enforce: true, }; - Object.values(paragonThemeCss.variants).forEach(({ entryName }) => { - cacheGroups[entryName] = { + Object.values(paragonThemeCss.variants).forEach(({ entryName, outputChunkName }) => { + cacheGroups[outputChunkName] = { type: 'css/mini-extract', - name: replacePeriodsWithHyphens(entryName), + name: outputChunkName, chunks: chunk => chunk.name === entryName, enforce: true, }; @@ -161,5 +150,4 @@ module.exports = { getParagonThemeCss, getParagonCacheGroups, getParagonEntryPoints, - replacePeriodsWithHyphens, }; diff --git a/config/jest/setupTest.js b/config/jest/setupTest.js index c2a381a6d..2a844d24c 100644 --- a/config/jest/setupTest.js +++ b/config/jest/setupTest.js @@ -16,11 +16,12 @@ global.PARAGON_THEME = { core: { fileName: 'core.min.css', }, + defaults: { + light: 'light', + }, variants: { light: { fileName: 'light.min.css', - default: true, - dark: false, }, }, }, @@ -31,11 +32,12 @@ global.PARAGON_THEME = { core: { fileName: 'core.min.css', }, + defaults: { + light: 'light', + }, variants: { light: { fileName: 'light.min.css', - default: true, - dark: false, }, }, }, diff --git a/example/.env.development b/example/.env.development index cffa57110..725f7e694 100644 --- a/example/.env.development +++ b/example/.env.development @@ -1,4 +1,17 @@ +APP_ID='example' +MFE_CONFIG_API_URL='http://localhost:18000/api/mfe_config/v1' +BASE_URL='http://localhost:3000' PORT=3000 -BASE_URL='http://localhost:8080' FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico TEST_VARIABLE='foo' +LMS_BASE_URL=http://localhost:18000 +LOGIN_URL=http://localhost:18000/login +LOGOUT_URL=http://localhost:18000/logout +REFRESH_ACCESS_TOKEN_ENDPOINT=http://localhost:18000/login_refresh +LOGO_URL=https://edx-cdn.org/v3/default/logo.svg +LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg +LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg +FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico +USER_INFO_COOKIE_NAME=edx-user-info +LANGUAGE_PREFERENCE_COOKIE_NAME=openedx-language-preference +ACCESS_TOKEN_COOKIE_NAME=edx-jwt-cookie-header-payload diff --git a/example/.eslintrc.js b/example/.eslintrc.js deleted file mode 100644 index 3ef32b0a9..000000000 --- a/example/.eslintrc.js +++ /dev/null @@ -1,3 +0,0 @@ -const config = require('../config/.eslintrc'); - -module.exports = config; diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 000000000..2c7410c57 --- /dev/null +++ b/example/.gitignore @@ -0,0 +1 @@ +module.config.js diff --git a/example/env.config.js b/example/env.config.js index 3df53313a..274add300 100644 --- a/example/env.config.js +++ b/example/env.config.js @@ -3,4 +3,29 @@ module.exports = { CORRECT_BOOL_VALUE: 'Good, false meant false. We did not cast a boolean to a string.', INCORRECT_BOOL_VALUE: 'Why was a false boolean true?', INTEGER_VALUE: 123, + PARAGON_THEME_URLS: { + core: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@alphaa/dist/core.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand-edx.org@alpha/dist/core.min.css', + }, + }, + defaults: { + light: 'light', + }, + variants: { + light: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@alpha/dist/light.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand-edx.org@alpha/dist/light.min.css', + }, + }, + dark: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@v21.0.0-alpha.40/dist/light.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand-edx.org@2.2.0-alpha.13/dist/light.min.css', + }, + }, + }, + }, }; diff --git a/example/src/ParagonPreview.jsx b/example/src/ParagonPreview.jsx index 118c72e59..0d9dbfa4f 100644 --- a/example/src/ParagonPreview.jsx +++ b/example/src/ParagonPreview.jsx @@ -2,33 +2,47 @@ import React from 'react'; const ParagonPreview = () => { if (!PARAGON_THEME) { - return null; + return

Missing PARAGON_THEME global variable. Depending on configuration, this may be OK.

; } return ( <>

Paragon

Exposed theme CSS files

+

+ + Note: Depending on the versions of @edx/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)}
diff --git a/example/src/__snapshots__/App.test.jsx.snap b/example/src/__snapshots__/App.test.jsx.snap index fb5065c86..fab174abe 100644 --- a/example/src/__snapshots__/App.test.jsx.snap +++ b/example/src/__snapshots__/App.test.jsx.snap @@ -122,6 +122,19 @@ exports[`Basic test should render 1`] = `

Exposed theme CSS files

+

+ + Note: Depending on the versions of + + @edx/paragon + + and/or + + @edx/brand + + installed, it is expected that no exposed theme CSS assets be listed here. + +