@shopgate/webpack 7.32.0-beta.11 → 7.32.0-beta.13

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.
@@ -0,0 +1,5 @@
1
+ {
2
+ "layout": {
3
+ "applyMediaMargin": true
4
+ }
5
+ }
@@ -0,0 +1 @@
1
+ { "layout": { "applyMediaMargins": true
@@ -0,0 +1,5 @@
1
+ {
2
+ "layout": {
3
+ "applyMediaMargins": { "left": true }
4
+ }
5
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "$schema": "../../../../../some/where/widget.config.schema.json",
3
+ "layout": {
4
+ "applyMediaMargins": { "top": true, "bottom": true }
5
+ }
6
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "layout": {
3
+ "applyMediaMargins": true
4
+ }
5
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "widgetsV2": {
3
+ "@broken/ext/EmptyWidget": {
4
+ "path": "ext-broken/frontend/widgets/EmptyWidget"
5
+ }
6
+ }
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "widgetsV2": {
3
+ "@broken/ext/InvalidWidget": {
4
+ "path": "ext-broken/frontend/widgets/InvalidWidget"
5
+ }
6
+ }
7
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "widgets": {
3
+ "@fixture/ext/LegacyWidget": {
4
+ "path": "ext-fixture/frontend/widgets/LegacyWidget"
5
+ }
6
+ },
7
+ "widgetsV2": {
8
+ "@fixture/ext/AlphaWidget": {
9
+ "path": "ext-fixture/frontend/widgets/AlphaWidget"
10
+ },
11
+ "@fixture/ext/BetaWidget": {
12
+ "path": "ext-fixture/frontend/widgets/BetaWidget"
13
+ },
14
+ "@fixture/ext/DistWidget": {
15
+ "path": "ext-fixture/frontend/dist/widgets/DistWidget"
16
+ }
17
+ },
18
+ "portals": {
19
+ "@fixture/ext/SomePortal": {
20
+ "path": "ext-fixture/frontend/portals/Some",
21
+ "target": "app.routes"
22
+ }
23
+ },
24
+ "reducers": {
25
+ "@shopgate/product-reviews/reducer": {
26
+ "path": "ext-product-reviews/frontend/reducer"
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "@fixture/theme/ThemeWidget": { "path": "ThemeWidget" }
3
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "widgetsV2": {
3
+ "@broken/ext/UnparsableWidget": {
4
+ "path": "ext-broken/frontend/widgets/UnparsableWidget"
5
+ }
6
+ }
7
+ }
@@ -1,5 +1,19 @@
1
1
  const fs = require('fs');
2
+ const path = require('path');
3
+ const Ajv = require('ajv');
2
4
  const importFresh = require('import-fresh');
5
+ const { isDev } = require('./variables');
6
+
7
+ const ENGAGE_PACKAGE = '@shopgate/engage';
8
+ const WIDGET_CONFIG_FILE = 'config.json';
9
+ const WIDGET_CONFIG_SCHEMA = '@shopgate/engage/page/widget.config.schema.json';
10
+
11
+ /**
12
+ * Raised when a widget ships a config file that cannot be used. It travels through the catch-all
13
+ * of the exported function, so that a broken config fails the build instead of silently removing
14
+ * every component mapping of the app.
15
+ */
16
+ class WidgetConfigError extends Error {}
3
17
 
4
18
  /**
5
19
  * @typedef {Object} ComponentSettingsEntry
@@ -54,6 +68,136 @@ const filterIgnoredExtensions = (config = {}) => Object.keys(config).reduce((acc
54
68
  [sectionName]: filterIgnoredEntries(config[sectionName]),
55
69
  }), {});
56
70
 
71
+ /**
72
+ * Loads a JSON config file using `import-fresh`, if it exists.
73
+ * @param {string} configPath - The absolute path to the config file.
74
+ * @returns {Object} The imported configuration object, or an empty object if the file doesn't
75
+ * exist.
76
+ */
77
+ const loadConfig = configPath => (
78
+ fs.existsSync(configPath) || configPath.startsWith(ENGAGE_PACKAGE) ?
79
+ importFresh(configPath) :
80
+ {}
81
+ );
82
+
83
+ /**
84
+ * Reads the component mappings of the theme, without the config files that widgets provide.
85
+ * @param {string} themePath The path of the theme.
86
+ * @returns {ComponentSettings}
87
+ */
88
+ const readComponentsConfig = (themePath) => {
89
+ /** @type {ComponentSettings} */
90
+ const defaultConfig = filterIgnoredExtensions(
91
+ importFresh(path.join(themePath, 'config', 'components.json'))
92
+ );
93
+
94
+ return {
95
+ ...defaultConfig,
96
+ widgets: {
97
+ ...defaultConfig.widgets,
98
+ ...loadConfig(path.join(themePath, 'widgets', 'widgets.json')),
99
+ },
100
+ widgetsV2: {
101
+ // To enable backwards compatibility for custom legacy widgets that are provided by
102
+ // extensions we include them in the list of V2 widgets.
103
+ ...defaultConfig.widgets,
104
+ ...defaultConfig.widgetsV2,
105
+ ...loadConfig(`${ENGAGE_PACKAGE}/page/widgets/widgets.json`),
106
+ },
107
+ };
108
+ };
109
+
110
+ const validateWidgetConfig = new Ajv({ allErrors: true })
111
+ .compile(importFresh(WIDGET_CONFIG_SCHEMA));
112
+
113
+ /**
114
+ * Builds the list of locations the config file of a widget can live at. Widgets provided by the
115
+ * PWA are referenced by their package path, widgets provided by extensions relative to the
116
+ * extensions folder of the app.
117
+ * @param {string} componentPath Path of the widget from the components config.
118
+ * @param {string} themePath The path of the theme.
119
+ * @returns {string[]}
120
+ */
121
+ const getWidgetConfigPaths = (componentPath, themePath) => {
122
+ if (componentPath.startsWith(ENGAGE_PACKAGE)) {
123
+ const packageRoot = path.dirname(require.resolve(`${ENGAGE_PACKAGE}/package.json`));
124
+ const subPath = componentPath.slice(ENGAGE_PACKAGE.length + 1);
125
+
126
+ return [path.join(packageRoot, subPath, WIDGET_CONFIG_FILE)];
127
+ }
128
+
129
+ const relativePaths = [componentPath];
130
+
131
+ if (isDev) {
132
+ // Extensions that are attached as a build are read from their sources while developing. The
133
+ // Set keeps a single entry for the extensions that are attached as sources anyway.
134
+ relativePaths.unshift(componentPath.replace('/dist/', '/src/'));
135
+ }
136
+
137
+ return Array.from(new Set(relativePaths)).map(relativePath => path.resolve(
138
+ themePath,
139
+ '..',
140
+ '..',
141
+ 'extensions',
142
+ relativePath,
143
+ WIDGET_CONFIG_FILE
144
+ ));
145
+ };
146
+
147
+ /**
148
+ * Loads the config file of a single widget. Widgets are not required to provide one.
149
+ * @param {string} componentPath Path of the widget from the components config.
150
+ * @param {string} themePath The path of the theme.
151
+ * @returns {Object} Config of the widget, or an empty object.
152
+ */
153
+ const loadWidgetConfig = (componentPath, themePath) => {
154
+ if (!componentPath) {
155
+ return {};
156
+ }
157
+
158
+ // Whether a widget brings a config is decided by the file system. Every file that is there has
159
+ // to be readable and valid, no matter what it contains.
160
+ const configPath = getWidgetConfigPaths(componentPath, themePath)
161
+ .find(candidate => fs.existsSync(candidate));
162
+
163
+ if (!configPath) {
164
+ return {};
165
+ }
166
+
167
+ let config;
168
+
169
+ try {
170
+ config = importFresh(configPath);
171
+ } catch (e) {
172
+ throw new WidgetConfigError(`Widget config "${configPath}" could not be read: ${e.message}`);
173
+ }
174
+
175
+ if (!validateWidgetConfig(config)) {
176
+ throw new WidgetConfigError([
177
+ `Widget config of "${componentPath}" is invalid:`,
178
+ ...validateWidgetConfig.errors.map(error => ` ${error.instancePath || '/'} ${error.message}`),
179
+ ].join('\n'));
180
+ }
181
+
182
+ // Only known sections are taken over, so that editor helpers like "$schema" don't end up in the
183
+ // config that is injected into the app.
184
+ return config.layout ? { config: { layout: config.layout } } : {};
185
+ };
186
+
187
+ /**
188
+ * Adds the config every widget provides via its own config file to the widget entries.
189
+ * @param {ComponentSettingsMap} widgets The widget entries from the components config.
190
+ * @param {string} themePath The path of the theme.
191
+ * @returns {ComponentSettingsMap}
192
+ */
193
+ const withWidgetConfigs = (widgets, themePath) => Object.keys(widgets).reduce((acc, id) => ({
194
+ ...acc,
195
+ [id]: {
196
+ ...widgets[id],
197
+ ...loadWidgetConfig((widgets[id] || {}).path, themePath),
198
+ },
199
+ }), {});
200
+
57
201
  /**
58
202
  * Returns contents of the `config/components.json` file from the theme.
59
203
  *
@@ -70,41 +214,54 @@ const filterIgnoredExtensions = (config = {}) => Object.keys(config).reduce((acc
70
214
  */
71
215
  module.exports = function getComponentsSettings(themePath) {
72
216
  try {
73
- const themeWidgetsPath = `${themePath}/widgets`;
74
-
75
- const themeWidgetsV1Config = `${themeWidgetsPath}/widgets.json`;
76
- const themeWidgetsV2Config = '@shopgate/engage/page/widgets/widgets.json';
77
-
78
- /** @type {ComponentSettings} */
79
- const defaultConfig = filterIgnoredExtensions(importFresh(`${themePath}/config/components.json`));
80
-
81
- /**
82
- * Loads a JSON config file using `import-fresh`, if it exists.
83
- *
84
- * @param {string} path - The absolute path to the config file.
85
- * @returns {Object} The imported configuration object, or an empty object if the file doesn't
86
- * exist.
87
- */
88
- const loadConfig = path => (fs.existsSync(path) || path.startsWith('@shopgate/engage') ?
89
- importFresh(path) :
90
- {}
91
- );
217
+ const components = readComponentsConfig(themePath);
92
218
 
93
219
  return {
94
- ...defaultConfig,
95
- widgets: {
96
- ...defaultConfig.widgets,
97
- ...loadConfig(themeWidgetsV1Config),
98
- },
99
- widgetsV2: {
100
- // To enable backwards compatibility for custom legacy widgets that are provided by
101
- // extensions we include them in the list of V2 widgets.
102
- ...defaultConfig.widgets,
103
- ...defaultConfig.widgetsV2,
104
- ...loadConfig(themeWidgetsV2Config),
105
- },
220
+ ...components,
221
+ widgetsV2: withWidgetConfigs(components.widgetsV2, themePath),
106
222
  };
107
223
  } catch (e) {
224
+ if (e instanceof WidgetConfigError) {
225
+ throw e;
226
+ }
227
+
108
228
  return {};
109
229
  }
110
230
  };
231
+
232
+ /**
233
+ * Returns the widget config files the component settings are built from, so that a build can watch
234
+ * them. Files that do not exist yet are reported separately, because webpack needs to know about
235
+ * them to notice when a widget gets a config for the first time.
236
+ *
237
+ * The configs themselves are not read here. A broken one is supposed to surface while a module is
238
+ * built, not while the webpack config is put together.
239
+ *
240
+ * @param {string} themePath The path of the theme.
241
+ * @returns {{ fileDependencies: string[], missingDependencies: string[] }}
242
+ */
243
+ module.exports.getWidgetConfigDependencies = (themePath) => {
244
+ try {
245
+ const { widgetsV2 = {} } = readComponentsConfig(themePath);
246
+
247
+ const configPaths = Object.keys(widgetsV2).reduce((acc, id) => {
248
+ const { path: componentPath } = widgetsV2[id] || {};
249
+
250
+ if (!componentPath) {
251
+ return acc;
252
+ }
253
+
254
+ return [...acc, ...getWidgetConfigPaths(componentPath, themePath)];
255
+ }, [path.resolve(themePath, 'config', 'components.json')]);
256
+
257
+ return {
258
+ fileDependencies: configPaths.filter(configPath => fs.existsSync(configPath)),
259
+ missingDependencies: configPaths.filter(configPath => !fs.existsSync(configPath)),
260
+ };
261
+ } catch (e) {
262
+ return {
263
+ fileDependencies: [],
264
+ missingDependencies: [],
265
+ };
266
+ }
267
+ };
@@ -0,0 +1,204 @@
1
+ const path = require('path');
2
+ const getComponentsSettings = require('./getComponentsSettings');
3
+
4
+ const fixtureTheme = name => path.resolve(__dirname, '__fixtures__', 'app', 'themes', name);
5
+
6
+ // Paths reach the assertions with the separator of the platform the tests run on.
7
+ const escapeForRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
8
+
9
+ const THEME_PATH = fixtureTheme('theme-test');
10
+
11
+ describe('getComponentsSettings', () => {
12
+ describe('widget configs', () => {
13
+ it('reads the config file that belongs to a widget', () => {
14
+ const { widgetsV2 } = getComponentsSettings(THEME_PATH);
15
+
16
+ expect(widgetsV2['@fixture/ext/AlphaWidget']).toEqual({
17
+ path: 'ext-fixture/frontend/widgets/AlphaWidget',
18
+ config: {
19
+ layout: {
20
+ applyMediaMargins: {
21
+ top: true,
22
+ bottom: true,
23
+ },
24
+ },
25
+ },
26
+ });
27
+ });
28
+
29
+ it('keeps sections it does not know out of the config', () => {
30
+ const { widgetsV2 } = getComponentsSettings(THEME_PATH);
31
+
32
+ // The fixture declares a "$schema" next to its layout, which is an editor helper that has no
33
+ // business in the config that gets injected into the app.
34
+ expect(widgetsV2['@fixture/ext/AlphaWidget']).toHaveProperty('config.layout');
35
+ expect(widgetsV2['@fixture/ext/AlphaWidget'].config).not.toHaveProperty('$schema');
36
+ });
37
+
38
+ it('leaves a widget without a config file alone', () => {
39
+ const { widgetsV2 } = getComponentsSettings(THEME_PATH);
40
+
41
+ expect(widgetsV2['@fixture/ext/BetaWidget']).toEqual({
42
+ path: 'ext-fixture/frontend/widgets/BetaWidget',
43
+ });
44
+ });
45
+
46
+ it('fails on a config file that cannot be parsed', () => {
47
+ expect(() => getComponentsSettings(fixtureTheme('theme-unparsable')))
48
+ .toThrow(new RegExp(`${escapeForRegExp(path.join('UnparsableWidget', 'config.json'))}" could not be read`));
49
+ });
50
+
51
+ it('fails on a config file that holds no object', () => {
52
+ expect(() => getComponentsSettings(fixtureTheme('theme-empty')))
53
+ .toThrow(/must be object/);
54
+ });
55
+
56
+ it('fails on a config file that does not match the schema', () => {
57
+ expect(() => getComponentsSettings(fixtureTheme('theme-invalid')))
58
+ .toThrow(/must NOT have additional properties/);
59
+ });
60
+
61
+ it('names the widget of an invalid config', () => {
62
+ expect(() => getComponentsSettings(fixtureTheme('theme-invalid')))
63
+ .toThrow(/ext-broken\/frontend\/widgets\/InvalidWidget/);
64
+ // The widget is named by its entry from the components config, which always uses forward
65
+ // slashes, so this one is not affected by the separator of the platform.
66
+ });
67
+
68
+ it('reads the config of widgets the PWA provides', () => {
69
+ const { widgetsV2 } = getComponentsSettings(THEME_PATH);
70
+
71
+ expect(widgetsV2['@shopgate/widgets/imageWidget'].config).toEqual({
72
+ layout: { applyMediaMargins: true },
73
+ });
74
+ expect(widgetsV2['@shopgate/widgets/richTextWidget']).not.toHaveProperty('config');
75
+ });
76
+
77
+ it('does not add configs to the widgets of the v1 system', () => {
78
+ const { widgets } = getComponentsSettings(THEME_PATH);
79
+
80
+ expect(widgets['@fixture/ext/LegacyWidget']).toEqual({
81
+ path: 'ext-fixture/frontend/widgets/LegacyWidget',
82
+ });
83
+ });
84
+
85
+ it('adds configs to v1 extension widgets that the v2 system renders', () => {
86
+ const { widgetsV2 } = getComponentsSettings(THEME_PATH);
87
+
88
+ expect(widgetsV2['@fixture/ext/LegacyWidget'].config).toEqual({
89
+ layout: { applyMediaMargins: true },
90
+ });
91
+ });
92
+ });
93
+
94
+ describe('development builds', () => {
95
+ afterEach(() => {
96
+ jest.resetModules();
97
+ });
98
+
99
+ it('prefers the sources of an extension that is attached as a build', () => {
100
+ jest.resetModules();
101
+ jest.doMock('./variables', () => ({ isDev: true }));
102
+
103
+ // eslint-disable-next-line global-require
104
+ const { widgetsV2 } = require('./getComponentsSettings')(THEME_PATH);
105
+
106
+ expect(widgetsV2['@fixture/ext/DistWidget'].config).toEqual({
107
+ layout: { applyMediaMargins: { left: true } },
108
+ });
109
+ });
110
+
111
+ it('ignores the sources outside of development', () => {
112
+ const { widgetsV2 } = getComponentsSettings(THEME_PATH);
113
+
114
+ expect(widgetsV2['@fixture/ext/DistWidget']).not.toHaveProperty('config');
115
+ });
116
+ });
117
+
118
+ describe('getWidgetConfigDependencies', () => {
119
+ const { getWidgetConfigDependencies } = getComponentsSettings;
120
+
121
+ it('lists the config files that exist', () => {
122
+ const { fileDependencies } = getWidgetConfigDependencies(THEME_PATH);
123
+
124
+ expect(fileDependencies).toContain(path.join(
125
+ __dirname,
126
+ '__fixtures__/app/extensions/ext-fixture/frontend/widgets/AlphaWidget/config.json'
127
+ ));
128
+ });
129
+
130
+ it('lists the config files a widget could still get', () => {
131
+ const { missingDependencies } = getWidgetConfigDependencies(THEME_PATH);
132
+
133
+ expect(missingDependencies).toContain(path.join(
134
+ __dirname,
135
+ '__fixtures__/app/extensions/ext-fixture/frontend/widgets/BetaWidget/config.json'
136
+ ));
137
+ });
138
+
139
+ it('watches the components config of the theme', () => {
140
+ const { fileDependencies } = getWidgetConfigDependencies(THEME_PATH);
141
+
142
+ expect(fileDependencies).toContain(path.join(THEME_PATH, 'config', 'components.json'));
143
+ });
144
+
145
+ it('reports absolute paths without duplicates', () => {
146
+ const { fileDependencies, missingDependencies } = getWidgetConfigDependencies(THEME_PATH);
147
+ const all = [...fileDependencies, ...missingDependencies];
148
+
149
+ expect(all.every(dependency => path.isAbsolute(dependency))).toBe(true);
150
+ expect(all).toHaveLength(new Set(all).size);
151
+ });
152
+
153
+ it('does not read the configs, so a broken one does not break the build setup', () => {
154
+ expect(() => getWidgetConfigDependencies(fixtureTheme('theme-invalid'))).not.toThrow();
155
+ expect(getWidgetConfigDependencies(fixtureTheme('theme-invalid')).fileDependencies)
156
+ .toContain(path.join(
157
+ __dirname,
158
+ '__fixtures__/app/extensions/ext-broken/frontend/widgets/InvalidWidget/config.json'
159
+ ));
160
+ });
161
+
162
+ it('reports nothing for a theme without a components config', () => {
163
+ expect(getWidgetConfigDependencies(path.resolve(THEME_PATH, 'nope'))).toEqual({
164
+ fileDependencies: [],
165
+ missingDependencies: [],
166
+ });
167
+ });
168
+ });
169
+
170
+ describe('component mapping', () => {
171
+ it('merges the widgets of the theme into the v1 widgets', () => {
172
+ const { widgets } = getComponentsSettings(THEME_PATH);
173
+
174
+ expect(widgets['@fixture/theme/ThemeWidget']).toEqual({ path: 'ThemeWidget' });
175
+ });
176
+
177
+ it('merges the widgets of the PWA into the v2 widgets', () => {
178
+ const { widgetsV2 } = getComponentsSettings(THEME_PATH);
179
+
180
+ expect(widgetsV2['@shopgate/widgets/htmlWidget']).toEqual({
181
+ path: '@shopgate/engage/page/widgets/HTML',
182
+ });
183
+ });
184
+
185
+ it('passes other component types through', () => {
186
+ const { portals } = getComponentsSettings(THEME_PATH);
187
+
188
+ expect(portals['@fixture/ext/SomePortal']).toEqual({
189
+ path: 'ext-fixture/frontend/portals/Some',
190
+ target: 'app.routes',
191
+ });
192
+ });
193
+
194
+ it('drops the entries of ignored extensions', () => {
195
+ const { reducers } = getComponentsSettings(THEME_PATH);
196
+
197
+ expect(reducers).toEqual({});
198
+ });
199
+
200
+ it('returns nothing for a theme without a components config', () => {
201
+ expect(getComponentsSettings(path.resolve(THEME_PATH, 'does-not-exist'))).toEqual({});
202
+ });
203
+ });
204
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shopgate/webpack",
3
- "version": "7.32.0-beta.11",
3
+ "version": "7.32.0-beta.13",
4
4
  "description": "The webpack configuration for Shopgate's Engage.",
5
5
  "main": "webpack.config.js",
6
6
  "license": "Apache-2.0",
package/webpack.config.js CHANGED
@@ -148,7 +148,13 @@ const config = {
148
148
  'process.env': {
149
149
  NODE_ENV: JSON.stringify(ENV),
150
150
  APP_CONFIG: JSON.stringify(appConfig),
151
- COMPONENTS_CONFIG: JSON.stringify(getComponentsSettings(themePath)),
151
+ // A runtime value, so that a change to a widget config is picked up by a running build.
152
+ // The dependencies make webpack watch the config files, including the ones that do not
153
+ // exist yet.
154
+ COMPONENTS_CONFIG: webpack.DefinePlugin.runtimeValue(
155
+ () => JSON.stringify(getComponentsSettings(themePath)),
156
+ getComponentsSettings.getWidgetConfigDependencies(themePath)
157
+ ),
152
158
  THEME_CONFIG: JSON.stringify(themeConfig),
153
159
  THEME: JSON.stringify(process.env.theme),
154
160
  THEME_PATH: JSON.stringify(themePath),