@adobe/helix-config 5.6.5 → 5.6.7

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/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [5.6.7](https://github.com/adobe/helix-config/compare/v5.6.6...v5.6.7) (2025-09-29)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * ensure no node dependencies ([#320](https://github.com/adobe/helix-config/issues/320)) ([d1dec47](https://github.com/adobe/helix-config/commit/d1dec472f67bae6ebfb92105e4e1324c6fb37056))
7
+
8
+ ## [5.6.6](https://github.com/adobe/helix-config/compare/v5.6.5...v5.6.6) (2025-09-25)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * legacy config w/o config-all ([#255](https://github.com/adobe/helix-config/issues/255)) ([0251471](https://github.com/adobe/helix-config/commit/0251471209439df447fb93bbe3cb327d8be98c12))
14
+
1
15
  ## [5.6.5](https://github.com/adobe/helix-config/compare/v5.6.4...v5.6.5) (2025-09-18)
2
16
 
3
17
 
package/eslint.config.js CHANGED
@@ -18,10 +18,6 @@ export default defineConfig([
18
18
  'coverage/*',
19
19
  ]),
20
20
  {
21
- rules: {
22
- // see https://github.com/import-js/eslint-plugin-import/issues/1868
23
- 'import/no-unresolved': ['error', { ignore: ['@adobe/helix-shared-config/modifiers'] }]
24
- },
25
21
  plugins: {
26
22
  import: recommended.plugins.import,
27
23
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/helix-config",
3
- "version": "5.6.5",
3
+ "version": "5.6.7",
4
4
  "description": "Helix Config",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -55,7 +55,6 @@
55
55
  "*.cjs": "eslint"
56
56
  },
57
57
  "dependencies": {
58
- "@adobe/helix-shared-config": "11.1.10",
59
58
  "@adobe/helix-shared-server-timing": "1.0.0",
60
59
  "@adobe/helix-shared-utils": "3.0.2"
61
60
  }
@@ -0,0 +1,177 @@
1
+ /*
2
+ * Copyright 2022 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ /**
14
+ * Converts all non-valid characters to `-`.
15
+ * @param {string} text input text
16
+ * @returns {string} the meta name
17
+ */
18
+ function toMetaName(text) {
19
+ return text
20
+ .toLowerCase()
21
+ .replace(/[^0-9a-z:_]/gi, '-');
22
+ }
23
+
24
+ /**
25
+ * The modifiers class help manage the metadata and headers modifiers.
26
+ */
27
+ export class ModifiersConfig {
28
+ /**
29
+ * Converts a globbing expression to regexp. Note that only `*` and `**` are supported yet.
30
+ * @param {string} glob
31
+ * @returns {RegExp}
32
+ */
33
+ static globToRegExp(glob) {
34
+ const reString = glob
35
+ .replaceAll('**', '|')
36
+ .replaceAll('*', '[^/]*')
37
+ .replaceAll('|', '.*');
38
+ return new RegExp(`^${reString}$`);
39
+ }
40
+
41
+ /**
42
+ * Converts all keys in a row object to lowercase
43
+ * @param {Object} obj A row of data from a sheet
44
+ * @returns {Object} A row with all keys converted to lowercase
45
+ */
46
+ static toLowerKeys(obj) {
47
+ return Object.keys(obj).reduce((prev, key) => {
48
+ // eslint-disable-next-line no-param-reassign
49
+ prev[key.toLowerCase()] = obj[key];
50
+ return prev;
51
+ }, Object.create(null));
52
+ }
53
+
54
+ /**
55
+ * Empty modifiers
56
+ * @type {ModifiersConfig}
57
+ */
58
+ static EMPTY = new ModifiersConfig({});
59
+
60
+ /**
61
+ * Parses a sheet that is in a modifier format into a list of key/value pairs
62
+ *
63
+ * @example
64
+ *
65
+ * | url | key | value | Title | Description |
66
+ * |-------|-----|-------|---------|----------------|
67
+ * | "/*" | "A" | "B" | "" | "" |
68
+ * | "/*" | "C" | "D" | "" | "" |
69
+ * | "/f" | "" | "" | "Hero" | "Once upon..." |
70
+ *
71
+ * becomes:
72
+ *
73
+ * {
74
+ * "/*": [
75
+ * { "key": "A", "value": "B" },
76
+ * { "key": "C", "value": "D" },
77
+ * ],
78
+ * "/f": [
79
+ * { "key": "title", "value": "Hero" },
80
+ * { "key": "description", "value": "Once upon..." },
81
+ * ]
82
+ * }
83
+ *
84
+ *
85
+ * @param {object[]} sheet The sheet to parse
86
+ * @param {ModifierKeyFilter} keyFilter filter to apply on keys
87
+ * @returns {ModifierMap} An object containing an array of key/value pairs for every glob
88
+ */
89
+ static parseModifierSheet(sheet, keyFilter = () => true) {
90
+ /** @type ModifierMap */
91
+ const res = Object.create(null);
92
+ for (let row of sheet) {
93
+ row = ModifiersConfig.toLowerKeys(row);
94
+ const {
95
+ url, key, value, ...rest
96
+ } = row;
97
+ if (url) {
98
+ const put = (k, v) => {
99
+ if (keyFilter(k)) {
100
+ let entry = res[url];
101
+ if (!entry) {
102
+ entry = Object.create(null);
103
+ res[url] = entry;
104
+ }
105
+ entry[k] = v;
106
+ }
107
+ };
108
+
109
+ // note that all values are strings, i.e. never another falsy value
110
+ if ('key' in row && 'value' in row && key && value) {
111
+ put(key.toLowerCase(), value);
112
+ } else {
113
+ Object.entries(rest).forEach(([k, v]) => {
114
+ if (k && v) {
115
+ put(k, v);
116
+ }
117
+ });
118
+ }
119
+ }
120
+ }
121
+ // convert res back to key/value pairs
122
+ for (const [url, mods] of Object.entries(res)) {
123
+ res[url] = Object.entries(mods).map(([key, value]) => ({ key, value }));
124
+ }
125
+ return res;
126
+ }
127
+
128
+ /**
129
+ * Creates a new `ModifiersConfig` from the given sheet.
130
+ *
131
+ * @see ModifiersConfig.parseModifierSheet
132
+ * @param {object[]} sheet The sheet to parse
133
+ * @param {ModifierKeyFilter} keyFilter filter to apply on keys
134
+ * @returns {ModifiersConfig} A ModifiersConfig instance.
135
+ */
136
+ static fromModifierSheet(sheet, keyFilter) {
137
+ return new ModifiersConfig(ModifiersConfig.parseModifierSheet(sheet, keyFilter));
138
+ }
139
+
140
+ /**
141
+ * Creates a new ModifiersConfig class.
142
+ * @param {ModifierMap} [map] The modifier map.
143
+ * @param {ModifierKeyFilter} keyFilter filter to apply on modifier keys
144
+ */
145
+ constructor(map, keyFilter = () => true) {
146
+ if (!map) {
147
+ return;
148
+ }
149
+ this.modifiers = Object.entries(map).map(([url, mods]) => {
150
+ const pat = url.indexOf('*') >= 0 ? ModifiersConfig.globToRegExp(url) : url;
151
+ return {
152
+ pat,
153
+ mods: mods.filter(({ key }) => keyFilter(key)),
154
+ };
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Returns the modifier object for the given path.
160
+ * @param {string} path
161
+ * @return {object} the modifiers
162
+ */
163
+ getModifiers(path) {
164
+ if (!this.modifiers) {
165
+ return Object.create(null);
166
+ }
167
+ const modifiers = Object.create(null);
168
+ for (const { pat, mods } of this.modifiers) {
169
+ if (pat === path || (pat instanceof RegExp && pat.test(path))) {
170
+ for (const { key, value } of mods) {
171
+ modifiers[toMetaName(key)] = value;
172
+ }
173
+ }
174
+ }
175
+ return modifiers;
176
+ }
177
+ }
@@ -13,11 +13,11 @@ import {
13
13
  SCOPE_ADMIN, SCOPE_DELIVERY, SCOPE_PIPELINE, SCOPE_RAW,
14
14
  } from './ConfigContext.js';
15
15
  import { ConfigObject } from './config-object.js';
16
+ import { contentConfigMerge } from './legacy-config-merge.js';
17
+ import { prune } from './utils.js';
16
18
 
17
19
  const HELIX_CODE_BUS = 'helix-code-bus';
18
20
 
19
- const HELIX_CONTENT_BUS = 'helix-content-bus';
20
-
21
21
  export function toArray(v) {
22
22
  if (!v) {
23
23
  return [];
@@ -62,23 +62,6 @@ async function fetchHelixConfig(ctx, rso) {
62
62
  return cfg;
63
63
  }
64
64
 
65
- /**
66
- * Retrieves the project config from the underlying storage
67
- * and stores it in the context as projectConfig.
68
- * @param {ConfigContext} ctx the context
69
- * @param {string} contentBusId
70
- * @param {string} partition
71
- * @returns {Promise<ConfigAll|null>} the project configuration
72
- */
73
- async function fetchConfigAll(ctx, contentBusId, partition) {
74
- const key = `${contentBusId}/${partition}/.helix/config-all.json`;
75
- const res = await ctx.loader.getObject(HELIX_CONTENT_BUS, key);
76
- if (!res.body) {
77
- return null;
78
- }
79
- return res.json();
80
- }
81
-
82
65
  /**
83
66
  * Retrieves the robots.txt from the code-bus
84
67
  * @param {ConfigContext} ctx the context
@@ -170,8 +153,10 @@ export async function resolveLegacyConfig(ctx, rso, scope) {
170
153
  };
171
154
  cfg.data = config;
172
155
  ctx.timer?.update('legacy-config-all');
173
- const configAllPreview = await fetchConfigAll(ctx, config.content.contentBusId, 'preview');
174
- const configAllLive = await fetchConfigAll(ctx, config.content.contentBusId, 'live');
156
+ const {
157
+ preview: configAllPreview,
158
+ live: configAllLive,
159
+ } = await contentConfigMerge(ctx, config.content.contentBusId, rso);
175
160
  const { access, admin } = configAllPreview?.config?.data || {};
176
161
  if (access) {
177
162
  config.access = {
@@ -204,9 +189,9 @@ export async function resolveLegacyConfig(ctx, rso, scope) {
204
189
  if (scope === SCOPE_PIPELINE) {
205
190
  config.metadata = {};
206
191
  if (configAllPreview) {
207
- config.metadata.preview = configAllPreview.metadata ?? {};
192
+ config.metadata.preview = configAllPreview.metadata;
208
193
  }
209
- if (configAllLive?.metadata) {
194
+ if (configAllLive) {
210
195
  config.metadata.live = configAllLive.metadata;
211
196
  }
212
197
  ctx.timer?.update('robots-txt');
@@ -222,5 +207,5 @@ export async function resolveLegacyConfig(ctx, rso, scope) {
222
207
  config.metadata.source = configAllPreview?.config?.data?.metadata;
223
208
  }
224
209
 
225
- return cfg;
210
+ return prune(cfg);
226
211
  }
@@ -9,7 +9,6 @@
9
9
  * OF ANY KIND, either express or implied. See the License for the specific language
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
- import { ModifiersConfig } from '@adobe/helix-shared-config/modifiers';
13
12
  import { computeSurrogateKey } from '@adobe/helix-shared-utils';
14
13
  // eslint-disable-next-line import/no-unresolved
15
14
  import { PipelineResponse } from './PipelineResponse.js';
@@ -22,6 +21,7 @@ import {
22
21
  import { resolveLegacyConfig, fetchRobotsTxt, toArray } from './config-legacy.js';
23
22
  import { deepGetOrCreate, prune } from './utils.js';
24
23
  import { ConfigObject } from './config-object.js';
24
+ import { ModifiersConfig } from './ModifiersConfig.js';
25
25
 
26
26
  /**
27
27
  * @typedef Config
@@ -0,0 +1,104 @@
1
+ /*
2
+ * Copyright 2022 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ /**
14
+ * Converts a config row-based name/value pair array with dotted key notation to an object.
15
+ *
16
+ * example:
17
+ *
18
+ * ```
19
+ * host: www.adobe.com
20
+ * document.level: 42,
21
+ * document.title: hello
22
+ * document.body.status: 200
23
+ * acl.allow: /foo
24
+ * acl.allow: /bar
25
+ * cnd.0.type: fastly
26
+ * cnd.0.host: www.adobe.com
27
+ * cnd.1.type: cloudflare
28
+ * ```
29
+ *
30
+ * becomes:
31
+ *
32
+ * ```
33
+ * {
34
+ * host: 'www.adobe.com',
35
+ * document: {
36
+ * level: 42,
37
+ * title: 'hello',
38
+ * body: {
39
+ * status: 200,
40
+ * }
41
+ * },
42
+ * acl: {
43
+ * allow: ['/foo', '/bar],
44
+ * }
45
+ * cnd: [
46
+ * { type: 'fastly', host: 'www.adobe.com' },
47
+ * { type: 'cloudflare' },
48
+ * ]
49
+ * }
50
+ * ```
51
+ *
52
+ * @param {object[]} rows array of columns
53
+ * @param {string} [keyName = 'key'] name of the key column.
54
+ * @param {string} [valueName = 'value'] name of the value column.
55
+ */
56
+ export function flatJson2object(rows, keyName = 'key', valueName = 'value') {
57
+ const arrays = [];
58
+ const update = (o, segs, value) => {
59
+ const seg = segs.shift();
60
+ if (segs.length) {
61
+ if (seg in o) {
62
+ update(o[seg], segs, value);
63
+ return o;
64
+ }
65
+ const n = Number.parseInt(segs[0], 10);
66
+ if (Number.isNaN(n)) {
67
+ // eslint-disable-next-line no-param-reassign
68
+ o[seg] = update(Object.create(null), segs, value);
69
+ } else {
70
+ // eslint-disable-next-line no-param-reassign
71
+ o[seg] = update([], segs, value);
72
+ arrays.push(o[seg]);
73
+ }
74
+ return o;
75
+ }
76
+ if (!(seg in o)) {
77
+ // eslint-disable-next-line no-param-reassign
78
+ o[seg] = value;
79
+ } else if (Array.isArray(o[seg])) {
80
+ // eslint-disable-next-line no-param-reassign
81
+ o[seg].push(value);
82
+ } else {
83
+ // eslint-disable-next-line no-param-reassign
84
+ o[seg] = [o[seg], value];
85
+ }
86
+ return o;
87
+ };
88
+
89
+ const obj = Object.create(null);
90
+ rows.forEach((row) => {
91
+ update(obj, String(row[keyName]).trim().split('.'), String(row[valueName]).trim());
92
+ });
93
+
94
+ // compact arrays
95
+ arrays.forEach((a) => {
96
+ for (let i = 0; i < a.length; i += 1) {
97
+ if (!(i in a)) {
98
+ a.splice(i, 1);
99
+ i -= 1;
100
+ }
101
+ }
102
+ });
103
+ return obj;
104
+ }
@@ -0,0 +1,236 @@
1
+ /*
2
+ * Copyright 2022 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ /* eslint-disable no-continue,no-param-reassign */
13
+ import { logLevelForStatusCode, propagateStatusCode } from '@adobe/helix-shared-utils';
14
+ import { flatJson2object } from './flatJson2Object.js';
15
+ import { ModifiersConfig } from './ModifiersConfig.js';
16
+
17
+ export const CONFIG_JSON_PATH = '/.helix/config.json';
18
+
19
+ export const HEADERS_JSON_PATH = '/.helix/headers.json';
20
+
21
+ export const METADATA_JSON_PATH = '/metadata.json';
22
+
23
+ /**
24
+ * Coerces the given value to an array. if the value is null or undefined, an empty array is
25
+ * returned.
26
+ * @param {*} value
27
+ * @param {boolean} [unique = false] if true, the resulting array will contain only unique values
28
+ * @return {[]}
29
+ */
30
+ export function coerceArray(value, unique = false) {
31
+ if (value === null || value === undefined) {
32
+ return [];
33
+ }
34
+ const array = Array.isArray(value) ? value : [value];
35
+ if (unique) {
36
+ return Array.from(new Set(array));
37
+ }
38
+ return array;
39
+ }
40
+
41
+ /**
42
+ * From a JSON response, retrieves the `data` sheet if this is a single sheet,
43
+ * or it returns the first existing sheet given by a list of names, if it is a
44
+ * multisheet.
45
+ * Returns `null` if there is neither.
46
+ *
47
+ * @param {any} json JSON object
48
+ * @param {String[]} names names to check in a multi sheet
49
+ */
50
+ export function getSheetData(json, names) {
51
+ /* c8 ignore next 3 */
52
+ if (Array.isArray(json.data)) {
53
+ return json.data;
54
+ }
55
+ let sheet;
56
+
57
+ const match = names.find((name) => !!json[name]);
58
+ if (match) {
59
+ sheet = json[match];
60
+ }
61
+ if (Array.isArray(sheet?.data)) {
62
+ return sheet.data;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ /**
68
+ * Helper class to load and generate the aggregate configs
69
+ */
70
+ export class ConfigMerger {
71
+ constructor(log, contentBusId, partition) {
72
+ Object.assign(this, {
73
+ log,
74
+ contentBusId,
75
+ partition,
76
+ lastModifiedTime: null,
77
+ lastModified: null,
78
+ all: {
79
+ version: 1,
80
+ },
81
+ });
82
+ }
83
+
84
+ toJSON() {
85
+ return this.all;
86
+ }
87
+
88
+ /**
89
+ * Loads a file from the content bus
90
+ * @param {ConfigContext} ctx
91
+ * @param {string} opts.path
92
+ * @param {string} opts.property
93
+ * @param {function} opts.mapping
94
+ * @returns {Promise<>}
95
+ */
96
+ async loadFile(ctx, opts) {
97
+ const key = `${this.contentBusId}/${this.partition}${opts.path}`;
98
+ const fileResponse = await ctx.loader.getObject('helix-content-bus', key);
99
+ const fileText = fileResponse.body;
100
+
101
+ let data;
102
+ if (fileResponse.status === 200) {
103
+ try {
104
+ data = getSheetData(JSON.parse(fileText), ['default']);
105
+ // eslint-disable-next-line no-empty
106
+ } catch {
107
+ }
108
+ if (!data) {
109
+ this.log.info(`[config-all] Error loading ${key}: empty or invalid json.`);
110
+ } else {
111
+ this.log.info(`[config-all] loaded: ${key}`);
112
+ }
113
+ } else if (fileResponse.status === 404) {
114
+ this.log.info(`[config-all] Error loading ${key}: no such object`);
115
+ } else {
116
+ this.log[logLevelForStatusCode(fileResponse.status)](`Invalid response (${fileResponse.status}) when loading json from ${key}`);
117
+ const err = new Error(`Unable to fetch json: ${fileResponse.status} ${fileText}`);
118
+ err.status = propagateStatusCode(fileResponse.status);
119
+ throw err;
120
+ }
121
+ if (data) {
122
+ // get the config property for this file
123
+ let config = this.all[opts.property];
124
+ if (!config) {
125
+ config = {};
126
+ this.all[opts.property] = config;
127
+ }
128
+ // update the data of the config
129
+ config.data = opts.mapping(data, config);
130
+ this.updateLastModified(config, fileResponse.headers);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Update the last modified in the config object and tracks the most recent one
136
+ * @param {object} obj
137
+ * @param {Map} headers
138
+ */
139
+ updateLastModified(obj, headers) {
140
+ const httpDate = headers.get('x-source-last-modified') ?? headers.get('last-modified');
141
+ /* c8 ignore next 3 */
142
+ if (!httpDate) {
143
+ return;
144
+ }
145
+ const time = new Date(httpDate).getTime();
146
+ /* c8 ignore next 4 */
147
+ if (Number.isNaN(time)) {
148
+ this.log.warn(`[config-all] updateLastModified date is invalid: ${httpDate}`);
149
+ return;
150
+ }
151
+ obj.lastModified = httpDate;
152
+ if (time > (this.lastModifiedTime ?? 0)) {
153
+ this.lastModifiedTime = time;
154
+ this.lastModified = httpDate;
155
+ }
156
+ }
157
+
158
+ clone(partition) {
159
+ const c = new ConfigMerger(this.log, this.contentBusId, partition);
160
+ c.lastModified = this.lastModified;
161
+ c.lastModifiedTime = this.lastModifiedTime;
162
+ // copy .helix/config.json and .helix/headers.json since it's the same for both partitions
163
+ c.all.config = this.all.config;
164
+ if (this.all.headers) {
165
+ c.all.headers = this.all.headers;
166
+ }
167
+ return c;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Handles merging content bus configuration files. The metadata and headers are specific to
173
+ * their partition, whereas the config is always loaded from preview.
174
+ *
175
+ * this means that config-all is always updated in both partitions
176
+ *
177
+ * @param {ConfigContext} ctx the universal context
178
+ * @param {string} contentBusId the content bus id
179
+ * @param {Response} res the response of the preceding content bus operation (preview/publish)
180
+ * @returns {Promise<void>}
181
+ * @implements ResponseHook
182
+ */
183
+ export async function contentConfigMerge(ctx, contentBusId) {
184
+ const { log } = ctx;
185
+
186
+ // load config always from preview
187
+ const cfgPreview = new ConfigMerger(log, contentBusId, 'preview');
188
+ await cfgPreview.loadFile(ctx, {
189
+ path: CONFIG_JSON_PATH,
190
+ property: 'config',
191
+ mapping: (data) => flatJson2object(data),
192
+ });
193
+
194
+ // load headers always from preview
195
+ await cfgPreview.loadFile(ctx, {
196
+ path: HEADERS_JSON_PATH,
197
+ property: 'headers',
198
+ mapping: (data) => ModifiersConfig.parseModifierSheet(data),
199
+ });
200
+
201
+ const cfgLive = cfgPreview.clone('live');
202
+ const updates = [cfgPreview, cfgLive];
203
+
204
+ // generate preview and live versions
205
+ await Promise.all(updates.map((async (cfg) => {
206
+ // generate the metadata-all.json first
207
+ const metadataPaths = coerceArray(cfg.all.config?.data?.metadata || [METADATA_JSON_PATH], true);
208
+ for (const path of metadataPaths) {
209
+ const loadOpts = {
210
+ path,
211
+ property: 'metadata',
212
+ mapping: (data, metadata) => {
213
+ if (!Array.isArray(metadata.data)) {
214
+ metadata.data = [];
215
+ }
216
+ metadata.data.push(...data);
217
+ return metadata.data;
218
+ },
219
+ };
220
+ // eslint-disable-next-line no-await-in-loop
221
+ await cfg.loadFile(ctx, loadOpts);
222
+ }
223
+
224
+ // convert to modifiers map
225
+ if (cfg.all.metadata?.data) {
226
+ cfg.all.metadata.data = ModifiersConfig.parseModifierSheet(cfg.all.metadata.data);
227
+ } else {
228
+ cfg.all.metadata = {};
229
+ }
230
+ })));
231
+
232
+ return {
233
+ preview: cfgPreview.toJSON(),
234
+ live: cfgLive.toJSON(),
235
+ };
236
+ }