@openedx/frontend-build 15.0.0-alpha.13 → 15.0.0-alpha.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -155,7 +155,7 @@ frontend-platform:
155
155
  dist: The sub-directory of the source code where it puts its build artifact. Often "dist".
156
156
  */
157
157
  localModules: [
158
- { moduleName: '@openedx/brand', dir: '../src/brand-openedx' }, // replace with your brand checkout
158
+ { moduleName: '@edx/brand', dir: '../src/brand-openedx' }, // replace with your brand checkout
159
159
  { moduleName: '@openedx/paragon/scss/core', dir: '../src/paragon', dist: 'scss/core' },
160
160
  { moduleName: '@openedx/paragon/icons', dir: '../src/paragon', dist: 'icons' },
161
161
  { moduleName: '@openedx/paragon', dir: '../src/paragon', dist: 'dist' },
@@ -39,6 +39,7 @@ module.exports = {
39
39
  },
40
40
  globals: {
41
41
  newrelic: false,
42
+ PARAGON_THEME: false,
42
43
  },
43
44
  ignorePatterns: [
44
45
  'module.config.js',
@@ -0,0 +1,171 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+
4
+ /**
5
+ * Retrieves the name of the brand package from the given directory.
6
+ *
7
+ * @param {string} dir - The directory path containing the package.json file.
8
+ * @return {string} The name of the brand package, or an empty string if not found.
9
+ */
10
+ function getBrandPackageName(dir) {
11
+ const appDependencies = JSON.parse(fs.readFileSync(path.resolve(dir, 'package.json'), 'utf-8')).dependencies;
12
+ return Object.keys(appDependencies).find((key) => key.match(/@(open)?edx\/brand/)) || '';
13
+ }
14
+
15
+ /**
16
+ * Attempts to extract the Paragon version from the `node_modules` of
17
+ * the consuming application.
18
+ *
19
+ * @param {string} dir Path to directory containing `node_modules`.
20
+ * @returns {string} Paragon dependency version of the consuming application
21
+ */
22
+ function getParagonVersion(dir, { isBrandOverride = false } = {}) {
23
+ const npmPackageName = isBrandOverride ? getBrandPackageName(dir) : '@openedx/paragon';
24
+ const pathToPackageJson = `${dir}/node_modules/${npmPackageName}/package.json`;
25
+ if (!fs.existsSync(pathToPackageJson)) {
26
+ return undefined;
27
+ }
28
+ return JSON.parse(fs.readFileSync(pathToPackageJson, 'utf-8')).version;
29
+ }
30
+
31
+ /**
32
+ * @typedef {Object} ParagonThemeCssAsset
33
+ * @property {string} filePath
34
+ * @property {string} entryName
35
+ * @property {string} outputChunkName
36
+ */
37
+
38
+ /**
39
+ * @typedef {Object} ParagonThemeVariantCssAsset
40
+ * @property {string} filePath
41
+ * @property {string} entryName
42
+ * @property {string} outputChunkName
43
+ */
44
+
45
+ /**
46
+ * @typedef {Object} ParagonThemeCss
47
+ * @property {ParagonThemeCssAsset} core The metadata about the core Paragon theme CSS
48
+ * @property {Object.<string, ParagonThemeVariantCssAsset>} variants A collection of theme variants.
49
+ */
50
+
51
+ /**
52
+ * Attempts to extract the Paragon theme CSS from the locally installed `@openedx/paragon` package.
53
+ * @param {string} dir Path to directory containing `node_modules`.
54
+ * @param {boolean} isBrandOverride
55
+ * @returns {ParagonThemeCss}
56
+ */
57
+ function getParagonThemeCss(dir, { isBrandOverride = false } = {}) {
58
+ const npmPackageName = isBrandOverride ? getBrandPackageName(dir) : '@openedx/paragon';
59
+ const pathToParagonThemeOutput = path.resolve(dir, 'node_modules', npmPackageName, 'dist', 'theme-urls.json');
60
+
61
+ if (!fs.existsSync(pathToParagonThemeOutput)) {
62
+ return undefined;
63
+ }
64
+ const paragonConfig = JSON.parse(fs.readFileSync(pathToParagonThemeOutput, 'utf-8'));
65
+ const {
66
+ core: themeCore,
67
+ variants: themeVariants,
68
+ defaults,
69
+ } = paragonConfig?.themeUrls || {};
70
+
71
+ const pathToCoreCss = path.resolve(dir, 'node_modules', npmPackageName, 'dist', themeCore.paths.minified);
72
+ const coreCssExists = fs.existsSync(pathToCoreCss);
73
+
74
+ const themeVariantResults = Object.entries(themeVariants || {}).reduce((themeVariantAcc, [themeVariant, value]) => {
75
+ const themeVariantCssDefault = path.resolve(dir, 'node_modules', npmPackageName, 'dist', value.paths.default);
76
+ const themeVariantCssMinified = path.resolve(dir, 'node_modules', npmPackageName, 'dist', value.paths.minified);
77
+
78
+ if (!fs.existsSync(themeVariantCssDefault) && !fs.existsSync(themeVariantCssMinified)) {
79
+ return themeVariantAcc;
80
+ }
81
+
82
+ return ({
83
+ ...themeVariantAcc,
84
+ [themeVariant]: {
85
+ filePath: themeVariantCssMinified,
86
+ entryName: isBrandOverride ? `brand.theme.variants.${themeVariant}` : `paragon.theme.variants.${themeVariant}`,
87
+ outputChunkName: isBrandOverride ? `brand-theme-variants-${themeVariant}` : `paragon-theme-variants-${themeVariant}`,
88
+ },
89
+ });
90
+ }, {});
91
+
92
+ if (!coreCssExists || themeVariantResults.length === 0) {
93
+ return undefined;
94
+ }
95
+
96
+ const coreResult = {
97
+ filePath: path.resolve(dir, pathToCoreCss),
98
+ entryName: isBrandOverride ? 'brand.theme.core' : 'paragon.theme.core',
99
+ outputChunkName: isBrandOverride ? 'brand-theme-core' : 'paragon-theme-core',
100
+ };
101
+
102
+ return {
103
+ core: fs.existsSync(pathToCoreCss) ? coreResult : undefined,
104
+ variants: themeVariantResults,
105
+ defaults,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * @typedef CacheGroup
111
+ * @property {string} type The type of cache group.
112
+ * @property {string|function} name The name of the cache group.
113
+ * @property {function} chunks A function that returns true if the chunk should be included in the cache group.
114
+ * @property {boolean} enforce If true, this cache group will be created even if it conflicts with default cache groups.
115
+ */
116
+
117
+ /**
118
+ * @param {ParagonThemeCss} paragonThemeCss The Paragon theme CSS metadata.
119
+ * @returns {Object.<string, CacheGroup>} The cache groups for the Paragon theme CSS.
120
+ */
121
+ function getParagonCacheGroups(paragonThemeCss) {
122
+ if (!paragonThemeCss) {
123
+ return {};
124
+ }
125
+ const cacheGroups = {
126
+ [paragonThemeCss.core.outputChunkName]: {
127
+ type: 'css/mini-extract',
128
+ name: paragonThemeCss.core.outputChunkName,
129
+ chunks: chunk => chunk.name === paragonThemeCss.core.entryName,
130
+ enforce: true,
131
+ },
132
+ };
133
+
134
+ Object.values(paragonThemeCss.variants).forEach(({ entryName, outputChunkName }) => {
135
+ cacheGroups[outputChunkName] = {
136
+ type: 'css/mini-extract',
137
+ name: outputChunkName,
138
+ chunks: chunk => chunk.name === entryName,
139
+ enforce: true,
140
+ };
141
+ });
142
+ return cacheGroups;
143
+ }
144
+
145
+ /**
146
+ * @param {ParagonThemeCss} paragonThemeCss The Paragon theme CSS metadata.
147
+ * @returns {Object.<string, string>} The entry points for the Paragon theme CSS. Example: ```
148
+ * {
149
+ * "paragon.theme.core": "/path/to/node_modules/@openedx/paragon/dist/core.min.css",
150
+ * "paragon.theme.variants.light": "/path/to/node_modules/@openedx/paragon/dist/light.min.css"
151
+ * }
152
+ * ```
153
+ */
154
+ function getParagonEntryPoints(paragonThemeCss) {
155
+ if (!paragonThemeCss) {
156
+ return {};
157
+ }
158
+
159
+ const entryPoints = { [paragonThemeCss.core.entryName]: path.resolve(process.cwd(), paragonThemeCss.core.filePath) };
160
+ Object.values(paragonThemeCss.variants).forEach(({ filePath, entryName }) => {
161
+ entryPoints[entryName] = path.resolve(process.cwd(), filePath);
162
+ });
163
+ return entryPoints;
164
+ }
165
+
166
+ module.exports = {
167
+ getParagonVersion,
168
+ getParagonThemeCss,
169
+ getParagonCacheGroups,
170
+ getParagonEntryPoints,
171
+ };
@@ -8,3 +8,38 @@ const testEnvFile = path.resolve(process.cwd(), '.env.test');
8
8
  if (fs.existsSync(testEnvFile)) {
9
9
  dotenv.config({ path: testEnvFile });
10
10
  }
11
+
12
+ global.PARAGON_THEME = {
13
+ paragon: {
14
+ version: '1.0.0',
15
+ themeUrls: {
16
+ core: {
17
+ fileName: 'core.min.css',
18
+ },
19
+ defaults: {
20
+ light: 'light',
21
+ },
22
+ variants: {
23
+ light: {
24
+ fileName: 'light.min.css',
25
+ },
26
+ },
27
+ },
28
+ },
29
+ brand: {
30
+ version: '1.0.0',
31
+ themeUrls: {
32
+ core: {
33
+ fileName: 'core.min.css',
34
+ },
35
+ defaults: {
36
+ light: 'light',
37
+ },
38
+ variants: {
39
+ light: {
40
+ fileName: 'light.min.css',
41
+ },
42
+ },
43
+ },
44
+ },
45
+ };
@@ -27,7 +27,7 @@ module.exports = {
27
27
  'env.config': envConfigPath,
28
28
  },
29
29
  collectCoverageFrom: [
30
- 'src/**/*.{js,jsx}',
30
+ 'src/**/*.{js,jsx,ts,tsx}',
31
31
  ],
32
32
  coveragePathIgnorePatterns: [
33
33
  '/node_modules/',
@@ -1,8 +1,35 @@
1
1
  const path = require('path');
2
+ const RemoveEmptyScriptsPlugin = require('webpack-remove-empty-scripts');
3
+
4
+ const ParagonWebpackPlugin = require('../lib/plugins/paragon-webpack-plugin/ParagonWebpackPlugin');
5
+ const {
6
+ getParagonThemeCss,
7
+ getParagonCacheGroups,
8
+ getParagonEntryPoints,
9
+ } = require('./data/paragonUtils');
10
+
11
+ const paragonThemeCss = getParagonThemeCss(process.cwd());
12
+ const brandThemeCss = getParagonThemeCss(process.cwd(), { isBrandOverride: true });
2
13
 
3
14
  module.exports = {
4
15
  entry: {
5
16
  app: path.resolve(process.cwd(), './src/index'),
17
+ /**
18
+ * The entry points for the Paragon theme CSS. Example: ```
19
+ * {
20
+ * "paragon.theme.core": "/path/to/node_modules/@openedx/paragon/dist/core.min.css",
21
+ * "paragon.theme.variants.light": "/path/to/node_modules/@openedx/paragon/dist/light.min.css"
22
+ * }
23
+ */
24
+ ...getParagonEntryPoints(paragonThemeCss),
25
+ /**
26
+ * The entry points for the brand theme CSS. Example: ```
27
+ * {
28
+ * "paragon.theme.core": "/path/to/node_modules/@(open)edx/brand/dist/core.min.css",
29
+ * "paragon.theme.variants.light": "/path/to/node_modules/@(open)edx/brand/dist/light.min.css"
30
+ * }
31
+ */
32
+ ...getParagonEntryPoints(brandThemeCss),
6
33
  },
7
34
  output: {
8
35
  path: path.resolve(process.cwd(), './dist'),
@@ -19,6 +46,23 @@ module.exports = {
19
46
  },
20
47
  extensions: ['.js', '.jsx', '.ts', '.tsx'],
21
48
  },
49
+ optimization: {
50
+ splitChunks: {
51
+ chunks: 'all',
52
+ cacheGroups: {
53
+ ...getParagonCacheGroups(paragonThemeCss),
54
+ ...getParagonCacheGroups(brandThemeCss),
55
+ },
56
+ },
57
+ },
58
+ plugins: [
59
+ // RemoveEmptyScriptsPlugin get rid of empty scripts generated by webpack when using mini-css-extract-plugin
60
+ // This helps to clean up the final bundle application
61
+ // See: https://www.npmjs.com/package/webpack-remove-empty-scripts#usage-with-mini-css-extract-plugin
62
+
63
+ new RemoveEmptyScriptsPlugin(),
64
+ new ParagonWebpackPlugin(),
65
+ ],
22
66
  ignoreWarnings: [
23
67
  // Ignore warnings raised by source-map-loader.
24
68
  // some third party packages may ship miss-configured sourcemaps, that interrupts the build
@@ -157,6 +157,7 @@ module.exports = merge(commonConfig, {
157
157
  new HtmlWebpackPlugin({
158
158
  inject: true, // Appends script tags linking to the webpack bundles at the end of the body
159
159
  template: path.resolve(process.cwd(), 'public/index.html'),
160
+ chunks: ['app'],
160
161
  FAVICON_URL: process.env.FAVICON_URL || null,
161
162
  OPTIMIZELY_PROJECT_ID: process.env.OPTIMIZELY_PROJECT_ID || null,
162
163
  NODE_ENV: process.env.NODE_ENV || null,
@@ -1,7 +1,7 @@
1
1
  // This is the dev Webpack config. All settings here should prefer a fast build
2
2
  // time at the expense of creating larger, unoptimized bundles.
3
3
  const ImageMinimizerPlugin = require('image-minimizer-webpack-plugin');
4
-
4
+ const MiniCssExtractPlugin = require('mini-css-extract-plugin');
5
5
  const { merge } = require('webpack-merge');
6
6
  const Dotenv = require('dotenv-webpack');
7
7
  const dotenv = require('dotenv');
@@ -31,6 +31,45 @@ resolvePrivateEnvConfig('.env.private');
31
31
  const aliases = getLocalAliases();
32
32
  const PUBLIC_PATH = process.env.PUBLIC_PATH || '/';
33
33
 
34
+ function getStyleUseConfig() {
35
+ return [
36
+ {
37
+ loader: 'css-loader', // translates CSS into CommonJS
38
+ options: {
39
+ sourceMap: true,
40
+ modules: {
41
+ compileType: 'icss',
42
+ },
43
+ },
44
+ },
45
+ {
46
+ loader: 'postcss-loader',
47
+ options: {
48
+ postcssOptions: {
49
+ plugins: [
50
+ PostCssAutoprefixerPlugin(),
51
+ PostCssRTLCSS(),
52
+ PostCssCustomMediaCSS(),
53
+ ],
54
+ },
55
+ },
56
+ },
57
+ 'resolve-url-loader',
58
+ {
59
+ loader: 'sass-loader', // compiles Sass to CSS
60
+ options: {
61
+ sourceMap: true,
62
+ sassOptions: {
63
+ includePaths: [
64
+ path.join(process.cwd(), 'node_modules'),
65
+ path.join(process.cwd(), 'src'),
66
+ ],
67
+ },
68
+ },
69
+ },
70
+ ];
71
+ }
72
+
34
73
  module.exports = merge(commonConfig, {
35
74
  mode: 'development',
36
75
  devtool: 'eval-source-map',
@@ -68,43 +107,19 @@ module.exports = merge(commonConfig, {
68
107
  // flash-of-unstyled-content issues in development.
69
108
  {
70
109
  test: /(.scss|.css)$/,
71
- use: [
72
- 'style-loader', // creates style nodes from JS strings
110
+ oneOf: [
73
111
  {
74
- loader: 'css-loader', // translates CSS into CommonJS
75
- options: {
76
- sourceMap: true,
77
- modules: {
78
- compileType: 'icss',
79
- },
80
- },
81
- },
82
- {
83
- loader: 'postcss-loader',
84
- options: {
85
- postcssOptions: {
86
- plugins: [
87
- PostCssAutoprefixerPlugin(),
88
- PostCssRTLCSS(),
89
- PostCssCustomMediaCSS(),
90
- ],
91
- },
92
- },
112
+ resource: /(@openedx\/paragon|@(open)?edx\/brand)/,
113
+ use: [
114
+ MiniCssExtractPlugin.loader,
115
+ ...getStyleUseConfig(),
116
+ ],
93
117
  },
94
- 'resolve-url-loader',
95
118
  {
96
- loader: 'sass-loader', // compiles Sass to CSS
97
- options: {
98
- sourceMap: true,
99
- sassOptions: {
100
- includePaths: [
101
- path.join(process.cwd(), 'node_modules'),
102
- path.join(process.cwd(), 'src'),
103
- ],
104
- // silences compiler warnings regarding deprecation warnings
105
- quietDeps: true,
106
- },
107
- },
119
+ use: [
120
+ 'style-loader', // creates style nodes from JS strings
121
+ ...getStyleUseConfig(),
122
+ ],
108
123
  },
109
124
  ],
110
125
  },
@@ -156,10 +171,15 @@ module.exports = merge(commonConfig, {
156
171
  },
157
172
  // Specify additional processing or side-effects done on the Webpack output bundles as a whole.
158
173
  plugins: [
174
+ // Writes the extracted CSS from each entry to a file in the output directory.
175
+ new MiniCssExtractPlugin({
176
+ filename: '[name].css',
177
+ }),
159
178
  // Generates an HTML file in the output directory.
160
179
  new HtmlWebpackPlugin({
161
180
  inject: true, // Appends script tags linking to the webpack bundles at the end of the body
162
181
  template: path.resolve(process.cwd(), 'public/index.html'),
182
+ chunks: ['app'],
163
183
  FAVICON_URL: process.env.FAVICON_URL || null,
164
184
  OPTIMIZELY_PROJECT_ID: process.env.OPTIMIZELY_PROJECT_ID || null,
165
185
  NODE_ENV: process.env.NODE_ENV || null,
@@ -114,8 +114,8 @@ module.exports = merge(commonConfig, {
114
114
  plugins: [
115
115
  PostCssAutoprefixerPlugin(),
116
116
  PostCssRTLCSS(),
117
- CssNano(),
118
117
  PostCssCustomMediaCSS(),
118
+ CssNano(),
119
119
  ...extraPostCssPlugins,
120
120
  ],
121
121
  },
@@ -202,6 +202,7 @@ module.exports = merge(commonConfig, {
202
202
  new HtmlWebpackPlugin({
203
203
  inject: true, // Appends script tags linking to the webpack bundles at the end of the body
204
204
  template: path.resolve(process.cwd(), 'public/index.html'),
205
+ chunks: ['app'],
205
206
  FAVICON_URL: process.env.FAVICON_URL || null,
206
207
  OPTIMIZELY_PROJECT_ID: process.env.OPTIMIZELY_PROJECT_ID || null,
207
208
  NODE_ENV: process.env.NODE_ENV || null,
@@ -0,0 +1,126 @@
1
+ const { Compilation, sources } = require('webpack');
2
+ const {
3
+ getParagonVersion,
4
+ getParagonThemeCss,
5
+ } = require('../../../config/data/paragonUtils');
6
+ const {
7
+ injectMetadataIntoDocument,
8
+ getParagonStylesheetUrls,
9
+ injectParagonCoreStylesheets,
10
+ injectParagonThemeVariantStylesheets,
11
+ } = require('./utils');
12
+
13
+ // Get Paragon and brand versions / CSS files from disk.
14
+ const paragonVersion = getParagonVersion(process.cwd());
15
+ const paragonThemeCss = getParagonThemeCss(process.cwd());
16
+ const brandVersion = getParagonVersion(process.cwd(), { isBrandOverride: true });
17
+ const brandThemeCss = getParagonThemeCss(process.cwd(), { isBrandOverride: true });
18
+
19
+ /**
20
+ * 1. Injects `PARAGON_THEME` global variable into the HTML document during the Webpack compilation process.
21
+ * 2. Injects `<link rel="preload" as="style">` element(s) for the Paragon and brand CSS into the HTML document.
22
+ */
23
+ class ParagonWebpackPlugin {
24
+ constructor({ processAssetsHandlers = [] } = {}) {
25
+ this.pluginName = 'ParagonWebpackPlugin';
26
+ this.paragonThemeUrlsConfig = {};
27
+ this.paragonMetadata = {};
28
+
29
+ // List of handlers to be executed after processing assets during the Webpack compilation.
30
+ this.processAssetsHandlers = [
31
+ this.resolveParagonThemeUrlsFromConfig,
32
+ this.injectParagonMetadataIntoDocument,
33
+ this.injectParagonStylesheetsIntoDocument,
34
+ ...processAssetsHandlers,
35
+ ].map(handler => handler.bind(this));
36
+ }
37
+
38
+ /**
39
+ * Resolves the MFE configuration from ``PARAGON_THEME_URLS`` in the environment variables. `
40
+ *
41
+ * @returns {Object} Metadata about the Paragon and brand theme URLs from configuration.
42
+ */
43
+ async resolveParagonThemeUrlsFromConfig() {
44
+ try {
45
+ this.paragonThemeUrlsConfig = JSON.parse(process.env.PARAGON_THEME_URLS);
46
+ } catch (error) {
47
+ console.info('Paragon Plugin cannot load PARAGON_THEME_URLS env variable, skipping.');
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Generates `PARAGON_THEME` global variable in HTML document.
53
+ * @param {Object} compilation Webpack compilation object.
54
+ */
55
+ injectParagonMetadataIntoDocument(compilation) {
56
+ const paragonMetadata = injectMetadataIntoDocument(compilation, {
57
+ paragonThemeCss,
58
+ paragonVersion,
59
+ brandThemeCss,
60
+ brandVersion,
61
+ });
62
+ if (paragonMetadata) {
63
+ this.paragonMetadata = paragonMetadata;
64
+ }
65
+ }
66
+
67
+ injectParagonStylesheetsIntoDocument(compilation) {
68
+ const file = compilation.getAsset('index.html');
69
+
70
+ // If the `index.html` hasn't loaded yet, or there are no Paragon theme URLs, then there is nothing to do yet.
71
+ if (!file || Object.keys(this.paragonThemeUrlsConfig || {}).length === 0) {
72
+ return;
73
+ }
74
+
75
+ // Generates `<link rel="preload" as="style">` element(s) for the Paragon and brand CSS files.
76
+ const paragonStylesheetUrls = getParagonStylesheetUrls({
77
+ paragonThemeUrls: this.paragonThemeUrlsConfig,
78
+ paragonVersion,
79
+ brandVersion,
80
+ });
81
+ const {
82
+ core: paragonCoreCss,
83
+ variants: paragonThemeVariantCss,
84
+ } = paragonStylesheetUrls;
85
+
86
+ const originalSource = file.source.source();
87
+
88
+ // Inject core CSS
89
+ let newSource = injectParagonCoreStylesheets({
90
+ source: originalSource,
91
+ paragonCoreCss,
92
+ paragonThemeCss,
93
+ brandThemeCss,
94
+ });
95
+
96
+ // Inject theme variant CSS
97
+ newSource = injectParagonThemeVariantStylesheets({
98
+ source: newSource.source(),
99
+ paragonThemeVariantCss,
100
+ paragonThemeCss,
101
+ brandThemeCss,
102
+ });
103
+
104
+ compilation.updateAsset('index.html', new sources.RawSource(newSource.source()));
105
+ }
106
+
107
+ apply(compiler) {
108
+ compiler.hooks.thisCompilation.tap(this.pluginName, (compilation) => {
109
+ compilation.hooks.processAssets.tap(
110
+ {
111
+ name: this.pluginName,
112
+ stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
113
+ additionalAssets: true,
114
+ },
115
+ () => {
116
+ // Iterate through each configured handler, passing the compilation to each.
117
+ this.processAssetsHandlers.forEach(async (handler) => {
118
+ await handler(compilation);
119
+ });
120
+ },
121
+ );
122
+ });
123
+ }
124
+ }
125
+
126
+ module.exports = ParagonWebpackPlugin;
@@ -0,0 +1,3 @@
1
+ const ParagonWebpackPlugin = require('./ParagonWebpackPlugin');
2
+
3
+ module.exports = ParagonWebpackPlugin;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Finds the core CSS asset from the given array of Paragon assets.
3
+ *
4
+ * @param {Array} paragonAssets - An array of Paragon assets.
5
+ * @return {Object|undefined} The core CSS asset, or undefined if not found.
6
+ */
7
+ function findCoreCssAsset(paragonAssets) {
8
+ return paragonAssets?.find((asset) => asset.name.includes('core') && asset.name.endsWith('.css'));
9
+ }
10
+
11
+ /**
12
+ * Finds the theme variant CSS assets from the given Paragon assets based on the provided options.
13
+ *
14
+ * @param {Array} paragonAssets - An array of Paragon assets.
15
+ * @param {Object} options - The options for finding the theme variant CSS assets.
16
+ * @param {boolean} [options.isBrandOverride=false] - Indicates if the theme variant is a brand override.
17
+ * @param {Object} [options.brandThemeCss] - The brand theme CSS object.
18
+ * @param {Object} [options.paragonThemeCss] - The Paragon theme CSS object.
19
+ * @return {Object} - The theme variant CSS assets.
20
+ */
21
+ function findThemeVariantCssAssets(paragonAssets, {
22
+ isBrandOverride = false,
23
+ brandThemeCss,
24
+ paragonThemeCss,
25
+ }) {
26
+ const themeVariantsSource = isBrandOverride ? brandThemeCss?.variants : paragonThemeCss?.variants;
27
+ const themeVariantCssAssets = {};
28
+ Object.entries(themeVariantsSource || {}).forEach(([themeVariant, value]) => {
29
+ const foundThemeVariantAsset = paragonAssets.find((asset) => asset.name.includes(value.outputChunkName));
30
+ if (!foundThemeVariantAsset) {
31
+ return;
32
+ }
33
+ themeVariantCssAssets[themeVariant] = {
34
+ fileName: foundThemeVariantAsset.name,
35
+ };
36
+ });
37
+ return themeVariantCssAssets;
38
+ }
39
+
40
+ /**
41
+ * Retrieves the CSS assets from the compilation based on the provided options.
42
+ *
43
+ * @param {Object} compilation - The compilation object.
44
+ * @param {Object} options - The options for retrieving the CSS assets.
45
+ * @param {boolean} [options.isBrandOverride=false] - Indicates if the assets are for a brand override.
46
+ * @param {Object} [options.brandThemeCss] - The brand theme CSS object.
47
+ * @param {Object} [options.paragonThemeCss] - The Paragon theme CSS object.
48
+ * @return {Object} - The CSS assets, including the core CSS asset and theme variant CSS assets.
49
+ */
50
+ function getCssAssetsFromCompilation(compilation, {
51
+ isBrandOverride = false,
52
+ brandThemeCss,
53
+ paragonThemeCss,
54
+ }) {
55
+ const assetSubstring = isBrandOverride ? 'brand' : 'paragon';
56
+ const paragonAssets = compilation.getAssets().filter(asset => asset.name.includes(assetSubstring) && asset.name.endsWith('.css'));
57
+ const coreCssAsset = findCoreCssAsset(paragonAssets);
58
+ const themeVariantCssAssets = findThemeVariantCssAssets(paragonAssets, {
59
+ isBrandOverride,
60
+ paragonThemeCss,
61
+ brandThemeCss,
62
+ });
63
+ return {
64
+ coreCssAsset: {
65
+ fileName: coreCssAsset?.name,
66
+ },
67
+ themeVariantCssAssets,
68
+ };
69
+ }
70
+
71
+ module.exports = {
72
+ findCoreCssAsset,
73
+ findThemeVariantCssAssets,
74
+ getCssAssetsFromCompilation,
75
+ };
@@ -0,0 +1,69 @@
1
+ const { sources } = require('webpack');
2
+
3
+ const { getCssAssetsFromCompilation } = require('./assetUtils');
4
+ const { generateScriptContents, insertScriptContentsIntoDocument } = require('./scriptUtils');
5
+
6
+ /**
7
+ * Injects metadata into the HTML document by modifying the 'index.html' asset in the compilation.
8
+ *
9
+ * @param {Object} compilation - The Webpack compilation object.
10
+ * @param {Object} options - The options object.
11
+ * @param {Object} options.paragonThemeCss - The Paragon theme CSS object.
12
+ * @param {string} options.paragonVersion - The version of the Paragon theme.
13
+ * @param {Object} options.brandThemeCss - The brand theme CSS object.
14
+ * @param {string} options.brandVersion - The version of the brand theme.
15
+ * @return {Object|undefined} The script contents object if the 'index.html' asset exists, otherwise undefined.
16
+ */
17
+ function injectMetadataIntoDocument(compilation, {
18
+ paragonThemeCss,
19
+ paragonVersion,
20
+ brandThemeCss,
21
+ brandVersion,
22
+ }) {
23
+ const file = compilation.getAsset('index.html');
24
+ if (!file) {
25
+ return undefined;
26
+ }
27
+ const {
28
+ coreCssAsset: paragonCoreCssAsset,
29
+ themeVariantCssAssets: paragonThemeVariantCssAssets,
30
+ } = getCssAssetsFromCompilation(compilation, {
31
+ brandThemeCss,
32
+ paragonThemeCss,
33
+ });
34
+ const {
35
+ coreCssAsset: brandCoreCssAsset,
36
+ themeVariantCssAssets: brandThemeVariantCssAssets,
37
+ } = getCssAssetsFromCompilation(compilation, {
38
+ isBrandOverride: true,
39
+ brandThemeCss,
40
+ paragonThemeCss,
41
+ });
42
+
43
+ const scriptContents = generateScriptContents({
44
+ paragonCoreCssAsset,
45
+ paragonThemeVariantCssAssets,
46
+ brandCoreCssAsset,
47
+ brandThemeVariantCssAssets,
48
+ paragonThemeCss,
49
+ paragonVersion,
50
+ brandThemeCss,
51
+ brandVersion,
52
+ });
53
+
54
+ const originalSource = file.source.source();
55
+ const newSource = insertScriptContentsIntoDocument({
56
+ originalSource,
57
+ coreCssAsset: paragonCoreCssAsset,
58
+ themeVariantCssAssets: paragonThemeVariantCssAssets,
59
+ scriptContents,
60
+ });
61
+
62
+ compilation.updateAsset('index.html', new sources.RawSource(newSource.source()));
63
+
64
+ return scriptContents;
65
+ }
66
+
67
+ module.exports = {
68
+ injectMetadataIntoDocument,
69
+ };
@@ -0,0 +1,9 @@
1
+ const { getParagonStylesheetUrls, injectParagonCoreStylesheets, injectParagonThemeVariantStylesheets } = require('./paragonStylesheetUtils');
2
+ const { injectMetadataIntoDocument } = require('./htmlUtils');
3
+
4
+ module.exports = {
5
+ injectMetadataIntoDocument,
6
+ getParagonStylesheetUrls,
7
+ injectParagonCoreStylesheets,
8
+ injectParagonThemeVariantStylesheets,
9
+ };
@@ -0,0 +1,120 @@
1
+ const { insertStylesheetsIntoDocument } = require('./stylesheetUtils');
2
+ const { handleVersionSubstitution } = require('./tagUtils');
3
+
4
+ /**
5
+ * Injects Paragon core stylesheets into the document.
6
+ *
7
+ * @param {Object} options - The options object.
8
+ * @param {string|object} options.source - The source HTML document.
9
+ * @param {Object} options.paragonCoreCss - The Paragon core CSS object.
10
+ * @param {Object} options.paragonThemeCss - The Paragon theme CSS object.
11
+ * @param {Object} options.brandThemeCss - The brand theme CSS object.
12
+ * @return {string|object} The modified HTML document with Paragon core stylesheets injected.
13
+ */
14
+ function injectParagonCoreStylesheets({
15
+ source,
16
+ paragonCoreCss,
17
+ paragonThemeCss,
18
+ brandThemeCss,
19
+ }) {
20
+ return insertStylesheetsIntoDocument({
21
+ source,
22
+ urls: paragonCoreCss.urls,
23
+ paragonThemeCss,
24
+ brandThemeCss,
25
+ });
26
+ }
27
+
28
+ /**
29
+ * Injects Paragon theme variant stylesheets into the document.
30
+ *
31
+ * @param {Object} options - The options object.
32
+ * @param {string|object} options.source - The source HTML document.
33
+ * @param {Object} options.paragonThemeVariantCss - The Paragon theme variant CSS object.
34
+ * @param {Object} options.paragonThemeCss - The Paragon theme CSS object.
35
+ * @param {Object} options.brandThemeCss - The brand theme CSS object.
36
+ * @return {string|object} The modified HTML document with Paragon theme variant stylesheets injected.
37
+ */
38
+ function injectParagonThemeVariantStylesheets({
39
+ source,
40
+ paragonThemeVariantCss,
41
+ paragonThemeCss,
42
+ brandThemeCss,
43
+ }) {
44
+ let newSource = source;
45
+ Object.values(paragonThemeVariantCss).forEach(({ urls }) => {
46
+ newSource = insertStylesheetsIntoDocument({
47
+ source: typeof newSource === 'object' ? newSource.source() : newSource,
48
+ urls,
49
+ paragonThemeCss,
50
+ brandThemeCss,
51
+ });
52
+ });
53
+ return newSource;
54
+ }
55
+ /**
56
+ * Retrieves the URLs of the Paragon stylesheets based on the provided theme URLs, Paragon version, and brand version.
57
+ *
58
+ * @param {Object} options - The options object.
59
+ * @param {Object} options.paragonThemeUrls - The URLs of the Paragon theme.
60
+ * @param {string} options.paragonVersion - The version of the Paragon theme.
61
+ * @param {string} options.brandVersion - The version of the brand theme.
62
+ * @return {Object} An object containing the URLs of the Paragon stylesheets.
63
+ */
64
+ function getParagonStylesheetUrls({ paragonThemeUrls, paragonVersion, brandVersion }) {
65
+ const paragonCoreCssUrl = typeof paragonThemeUrls.core.urls === 'object' ? paragonThemeUrls.core.urls.default : paragonThemeUrls.core.url;
66
+ const brandCoreCssUrl = typeof paragonThemeUrls.core.urls === 'object' ? paragonThemeUrls.core.urls.brandOverride : undefined;
67
+
68
+ const defaultThemeVariants = paragonThemeUrls.defaults || {};
69
+
70
+ const coreCss = {
71
+ urls: {
72
+ default: handleVersionSubstitution({ url: paragonCoreCssUrl, wildcardKeyword: '$paragonVersion', localVersion: paragonVersion }),
73
+ brandOverride: handleVersionSubstitution({ url: brandCoreCssUrl, wildcardKeyword: '$brandVersion', localVersion: brandVersion }),
74
+ },
75
+ };
76
+
77
+ const themeVariantsCss = {};
78
+ const themeVariantsEntries = Object.entries(paragonThemeUrls.variants || {});
79
+ themeVariantsEntries.forEach(([themeVariant, { url, urls }]) => {
80
+ const themeVariantMetadata = { urls: null };
81
+ if (url) {
82
+ themeVariantMetadata.urls = {
83
+ default: handleVersionSubstitution({
84
+ url,
85
+ wildcardKeyword: '$paragonVersion',
86
+ localVersion: paragonVersion,
87
+ }),
88
+ // If there is no brand override URL, then we don't need to do any version substitution
89
+ // but we still need to return the property.
90
+ brandOverride: undefined,
91
+ };
92
+ } else {
93
+ themeVariantMetadata.urls = {
94
+ default: handleVersionSubstitution({
95
+ url: urls.default,
96
+ wildcardKeyword: '$paragonVersion',
97
+ localVersion: paragonVersion,
98
+ }),
99
+ brandOverride: handleVersionSubstitution({
100
+ url: urls.brandOverride,
101
+ wildcardKeyword: '$brandVersion',
102
+ localVersion: brandVersion,
103
+ }),
104
+ };
105
+ }
106
+ themeVariantsCss[themeVariant] = themeVariantMetadata;
107
+ });
108
+
109
+ return {
110
+ core: coreCss,
111
+ variants: themeVariantsCss,
112
+ defaults: defaultThemeVariants,
113
+ };
114
+ }
115
+
116
+ module.exports = {
117
+ injectParagonCoreStylesheets,
118
+ injectParagonThemeVariantStylesheets,
119
+ getParagonStylesheetUrls,
120
+ };
@@ -0,0 +1,144 @@
1
+ const { sources } = require('webpack');
2
+ const parse5 = require('parse5');
3
+
4
+ const { getDescendantByTag, minifyScript } = require('./tagUtils');
5
+
6
+ /**
7
+ * Finds the insertion point for a script in an HTML document.
8
+ *
9
+ * @param {Object} options - The options object.
10
+ * @param {Object} options.document - The parsed HTML document.
11
+ * @param {string} options.originalSource - The original source code of the HTML document.
12
+ * @throws {Error} If the body element is missing in the HTML document.
13
+ * @return {number} The insertion point for the script in the HTML document.
14
+ */
15
+ function findScriptInsertionPoint({ document, originalSource }) {
16
+ const bodyElement = getDescendantByTag(document, 'body');
17
+ if (!bodyElement) {
18
+ throw new Error('Missing body element in index.html.');
19
+ }
20
+
21
+ // determine script insertion point
22
+ if (bodyElement.sourceCodeLocation?.endTag) {
23
+ return bodyElement.sourceCodeLocation.endTag.startOffset;
24
+ }
25
+
26
+ // less accurate fallback
27
+ return originalSource.indexOf('</body>');
28
+ }
29
+
30
+ /**
31
+ * Inserts the given script contents into the HTML document and returns a new source with the modified content.
32
+ *
33
+ * @param {Object} options - The options object.
34
+ * @param {string} options.originalSource - The original HTML source.
35
+ * @param {Object} options.scriptContents - The contents of the script to be inserted.
36
+ * @return {sources.ReplaceSource} The new source with the modified HTML content.
37
+ */
38
+ function insertScriptContentsIntoDocument({
39
+ originalSource,
40
+ scriptContents,
41
+ }) {
42
+ // parse file as html document
43
+ const document = parse5.parse(originalSource, {
44
+ sourceCodeLocationInfo: true,
45
+ });
46
+
47
+ // find the body element
48
+ const scriptInsertionPoint = findScriptInsertionPoint({
49
+ document,
50
+ originalSource,
51
+ });
52
+
53
+ // create Paragon script to inject into the HTML document
54
+ const paragonScript = `<script type="text/javascript">var PARAGON_THEME = ${JSON.stringify(scriptContents, null, 2)};</script>`;
55
+
56
+ // insert the Paragon script into the HTML document
57
+ const newSource = new sources.ReplaceSource(
58
+ new sources.RawSource(originalSource),
59
+ 'index.html',
60
+ );
61
+ newSource.insert(scriptInsertionPoint, minifyScript(paragonScript));
62
+ return newSource;
63
+ }
64
+
65
+ /**
66
+ * Creates an object with the provided version, defaults, coreCssAsset, and themeVariantCssAssets
67
+ * and returns it. The returned object has the following structure:
68
+ * {
69
+ * version: The provided version,
70
+ * themeUrls: {
71
+ * core: The provided coreCssAsset,
72
+ * variants: The provided themeVariantCssAssets,
73
+ * defaults: The provided defaults
74
+ * }
75
+ * }
76
+ *
77
+ * @param {Object} options - The options object.
78
+ * @param {string} options.version - The version to be added to the returned object.
79
+ * @param {Object} options.defaults - The defaults to be added to the returned object.
80
+ * @param {Object} options.coreCssAsset - The coreCssAsset to be added to the returned object.
81
+ * @param {Object} options.themeVariantCssAssets - The themeVariantCssAssets to be added to the returned object.
82
+ * @return {Object} The object with the provided version, defaults, coreCssAsset, and themeVariantCssAssets.
83
+ */
84
+ function addToScriptContents({
85
+ version,
86
+ defaults,
87
+ coreCssAsset,
88
+ themeVariantCssAssets,
89
+ }) {
90
+ return {
91
+ version,
92
+ themeUrls: {
93
+ core: coreCssAsset,
94
+ variants: themeVariantCssAssets,
95
+ defaults,
96
+ },
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Generates the script contents object based on the provided assets and versions.
102
+ *
103
+ * @param {Object} options - The options object.
104
+ * @param {Object} options.paragonCoreCssAsset - The asset for the Paragon core CSS.
105
+ * @param {Object} options.paragonThemeVariantCssAssets - The assets for the Paragon theme variants.
106
+ * @param {Object} options.brandCoreCssAsset - The asset for the brand core CSS.
107
+ * @param {Object} options.brandThemeVariantCssAssets - The assets for the brand theme variants.
108
+ * @param {Object} options.paragonThemeCss - The Paragon theme CSS.
109
+ * @param {string} options.paragonVersion - The version of the Paragon theme.
110
+ * @param {Object} options.brandThemeCss - The brand theme CSS.
111
+ * @param {string} options.brandVersion - The version of the brand theme.
112
+ * @return {Object} The script contents object.
113
+ */
114
+ function generateScriptContents({
115
+ paragonCoreCssAsset,
116
+ paragonThemeVariantCssAssets,
117
+ brandCoreCssAsset,
118
+ brandThemeVariantCssAssets,
119
+ paragonThemeCss,
120
+ paragonVersion,
121
+ brandThemeCss,
122
+ brandVersion,
123
+ }) {
124
+ const scriptContents = {};
125
+ scriptContents.paragon = addToScriptContents({
126
+ version: paragonVersion,
127
+ coreCssAsset: paragonCoreCssAsset,
128
+ themeVariantCssAssets: paragonThemeVariantCssAssets,
129
+ defaults: paragonThemeCss?.defaults,
130
+ });
131
+ scriptContents.brand = addToScriptContents({
132
+ version: brandVersion,
133
+ coreCssAsset: brandCoreCssAsset,
134
+ themeVariantCssAssets: brandThemeVariantCssAssets,
135
+ defaults: brandThemeCss?.defaults,
136
+ });
137
+ return scriptContents;
138
+ }
139
+
140
+ module.exports = {
141
+ addToScriptContents,
142
+ insertScriptContentsIntoDocument,
143
+ generateScriptContents,
144
+ };
@@ -0,0 +1,106 @@
1
+ const parse5 = require('parse5');
2
+ const { sources } = require('webpack');
3
+
4
+ const { getDescendantByTag } = require('./tagUtils');
5
+
6
+ /**
7
+ * Finds the insertion point for a stylesheet in an HTML document.
8
+ *
9
+ * @param {Object} options - The options object.
10
+ * @param {Object} options.document - The parsed HTML document.
11
+ * @param {string} options.source - The original source code of the HTML document.
12
+ * @throws {Error} If the head element is missing in the HTML document.
13
+ * @return {number} The insertion point for the stylesheet in the HTML document.
14
+ */
15
+ function findStylesheetInsertionPoint({ document, source }) {
16
+ const headElement = getDescendantByTag(document, 'head');
17
+ if (!headElement) {
18
+ throw new Error('Missing head element in index.html.');
19
+ }
20
+
21
+ // determine script insertion point
22
+ if (headElement.sourceCodeLocation?.startTag) {
23
+ return headElement.sourceCodeLocation.startTag.endOffset;
24
+ }
25
+
26
+ // less accurate fallback
27
+ const headTagString = '<head>';
28
+ const headTagIndex = source.indexOf(headTagString);
29
+ return headTagIndex + headTagString.length;
30
+ }
31
+
32
+ /**
33
+ * Inserts stylesheets into an HTML document.
34
+ *
35
+ * @param {object} options - The options for inserting stylesheets.
36
+ * @param {string} options.source - The HTML source code.
37
+ * @param {object} options.urls - The URLs of the stylesheets to be inserted.
38
+ * @param {string} options.urls.default - The URL of the default stylesheet.
39
+ * @param {string} options.urls.brandOverride - The URL of the brand override stylesheet.
40
+ * @return {object} The new source code with the stylesheets inserted.
41
+ */
42
+ function insertStylesheetsIntoDocument({
43
+ source,
44
+ urls,
45
+ }) {
46
+ // parse file as html document
47
+ const document = parse5.parse(source, {
48
+ sourceCodeLocationInfo: true,
49
+ });
50
+ if (!getDescendantByTag(document, 'head')) {
51
+ return undefined;
52
+ }
53
+
54
+ const newSource = new sources.ReplaceSource(
55
+ new sources.RawSource(source),
56
+ 'index.html',
57
+ );
58
+
59
+ // insert the brand overrides styles into the HTML document
60
+ const stylesheetInsertionPoint = findStylesheetInsertionPoint({
61
+ document,
62
+ source: newSource,
63
+ });
64
+
65
+ /**
66
+ * Creates a new stylesheet link element.
67
+ *
68
+ * @param {string} url - The URL of the stylesheet.
69
+ * @return {string} The HTML code for the stylesheet link element.
70
+ */
71
+ function createNewStylesheet(url) {
72
+ const baseLink = `<link
73
+ type="text/css"
74
+ rel="preload"
75
+ as="style"
76
+ href="${url}"
77
+ onerror="this.remove();"
78
+ />`;
79
+ return baseLink;
80
+ }
81
+
82
+ if (urls.default) {
83
+ const existingDefaultLink = getDescendantByTag(`link[href='${urls.default}']`);
84
+ if (!existingDefaultLink) {
85
+ // create link to inject into the HTML document
86
+ const stylesheetLink = createNewStylesheet(urls.default);
87
+ newSource.insert(stylesheetInsertionPoint, stylesheetLink);
88
+ }
89
+ }
90
+
91
+ if (urls.brandOverride) {
92
+ const existingBrandLink = getDescendantByTag(`link[href='${urls.brandOverride}']`);
93
+ if (!existingBrandLink) {
94
+ // create link to inject into the HTML document
95
+ const stylesheetLink = createNewStylesheet(urls.brandOverride);
96
+ newSource.insert(stylesheetInsertionPoint, stylesheetLink);
97
+ }
98
+ }
99
+
100
+ return newSource;
101
+ }
102
+
103
+ module.exports = {
104
+ findStylesheetInsertionPoint,
105
+ insertStylesheetsIntoDocument,
106
+ };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Recursively searches for a descendant node with the specified tag name.
3
+ *
4
+ * @param {Object} node - The root node to start the search from.
5
+ * @param {string} tag - The tag name to search for.
6
+ * @return {Object|null} The first descendant node with the specified tag name, or null if not found.
7
+ */
8
+ function getDescendantByTag(node, tag) {
9
+ for (let i = 0; i < node.childNodes?.length; i++) {
10
+ if (node.childNodes[i].tagName === tag) {
11
+ return node.childNodes[i];
12
+ }
13
+ const result = getDescendantByTag(node.childNodes[i], tag);
14
+ if (result) {
15
+ return result;
16
+ }
17
+ }
18
+ return null;
19
+ }
20
+
21
+ /**
22
+ * Replaces a wildcard keyword in a URL with a local version.
23
+ *
24
+ * @param {Object} options - The options object.
25
+ * @param {string} options.url - The URL to substitute the keyword in.
26
+ * @param {string} options.wildcardKeyword - The wildcard keyword to replace.
27
+ * @param {string} options.localVersion - The local version to substitute the keyword with.
28
+ * @return {string} The URL with the wildcard keyword substituted with the local version,
29
+ * or the original URL if no substitution is needed.
30
+ */
31
+ function handleVersionSubstitution({ url, wildcardKeyword, localVersion }) {
32
+ if (!url || !url.includes(wildcardKeyword) || !localVersion) {
33
+ return url;
34
+ }
35
+ return url.replaceAll(wildcardKeyword, localVersion);
36
+ }
37
+
38
+ /**
39
+ * Minifies a script by removing unnecessary whitespace and line breaks.
40
+ *
41
+ * @param {string} script - The script to be minified.
42
+ * @return {string} The minified script.
43
+ */
44
+ function minifyScript(script) {
45
+ return script
46
+ .replace(/>[\r\n ]+</g, '><')
47
+ .replace(/(<.*?>)|\s+/g, (m, $1) => {
48
+ if ($1) { return $1; }
49
+ return ' ';
50
+ })
51
+ .trim();
52
+ }
53
+
54
+ module.exports = {
55
+ getDescendantByTag,
56
+ handleVersionSubstitution,
57
+ minifyScript,
58
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openedx/frontend-build",
3
- "version": "15.0.0-alpha.13",
3
+ "version": "15.0.0-alpha.15",
4
4
  "description": "Build tools, setup and config for frontend apps",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -41,7 +41,7 @@
41
41
  "@babel/preset-react": "7.24.7",
42
42
  "@edx/eslint-config": "4.1.0",
43
43
  "@edx/new-relic-source-map-webpack-plugin": "2.1.0",
44
- "@edx/typescript-config": "1.0.1",
44
+ "@edx/typescript-config": "1.1.0",
45
45
  "@formatjs/cli": "^6.0.3",
46
46
  "@fullhuman/postcss-purgecss": "5.0.0",
47
47
  "@pmmmwh/react-refresh-webpack-plugin": "0.5.15",
@@ -77,6 +77,7 @@
77
77
  "jest": "29.6.1",
78
78
  "jest-environment-jsdom": "29.6.1",
79
79
  "mini-css-extract-plugin": "1.6.2",
80
+ "parse5": "7.1.2",
80
81
  "postcss": "8.4.39",
81
82
  "postcss-custom-media": "10.0.8",
82
83
  "postcss-loader": "7.3.4",
@@ -96,7 +97,8 @@
96
97
  "webpack-bundle-analyzer": "^4.10.1",
97
98
  "webpack-cli": "^5.1.4",
98
99
  "webpack-dev-server": "^4.15.1",
99
- "webpack-merge": "^5.10.0"
100
+ "webpack-merge": "^5.10.0",
101
+ "webpack-remove-empty-scripts": "1.0.4"
100
102
  },
101
103
  "devDependencies": {
102
104
  "@babel/preset-typescript": "^7.18.6",