@adobe/helix-config 5.10.3 → 5.12.0

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.12.0](https://github.com/adobe/helix-config/compare/v5.11.0...v5.12.0) (2026-08-13)
2
+
3
+
4
+ ### Features
5
+
6
+ * return sitemap index list for pipeline scope ([#399](https://github.com/adobe/helix-config/issues/399)) ([e775165](https://github.com/adobe/helix-config/commit/e77516540a941deea93cf63242f823cad2963930))
7
+
8
+ # [5.11.0](https://github.com/adobe/helix-config/compare/v5.10.3...v5.11.0) (2026-06-09)
9
+
10
+
11
+ ### Features
12
+
13
+ * remove unsupported legacy config ([#385](https://github.com/adobe/helix-config/issues/385)) ([deb5e82](https://github.com/adobe/helix-config/commit/deb5e8237e2e36bed1739c9e6701c2b862b60de7))
14
+
1
15
  ## [5.10.3](https://github.com/adobe/helix-config/compare/v5.10.2...v5.10.3) (2026-05-19)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/helix-config",
3
- "version": "5.10.3",
3
+ "version": "5.12.0",
4
4
  "description": "Helix Config",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -35,26 +35,27 @@
35
35
  "reporter-options": "configFile=.mocha-multi.json"
36
36
  },
37
37
  "devDependencies": {
38
- "@adobe/eslint-config-helix": "3.0.27",
39
- "@eslint/config-helpers": "0.5.5",
40
- "@semantic-release/changelog": "6.0.3",
41
- "@semantic-release/git": "10.0.1",
38
+ "@adobe/eslint-config-helix": "3.0.30",
39
+ "@eslint/config-helpers": "0.7.0",
40
+ "@semantic-release/changelog": "7.0.0",
41
+ "@semantic-release/git": "11.0.1",
42
42
  "@semantic-release/npm": "13.1.5",
43
- "c8": "11.0.0",
43
+ "c8": "12.0.0",
44
44
  "eslint": "9.4.0",
45
45
  "husky": "9.1.7",
46
46
  "junit-report-builder": "5.1.2",
47
- "lint-staged": "16.4.0",
48
- "mocha": "11.7.5",
47
+ "lint-staged": "17.2.0",
48
+ "mocha": "11.7.6",
49
49
  "mocha-multi-reporters": "1.5.1",
50
50
  "mocha-suppress-logs": "0.6.0",
51
- "semantic-release": "25.0.3"
51
+ "semantic-release": "25.0.8"
52
52
  },
53
53
  "lint-staged": {
54
54
  "*.js": "eslint",
55
55
  "*.cjs": "eslint"
56
56
  },
57
57
  "dependencies": {
58
+ "@adobe/helix-shared-config": "11.2.0",
58
59
  "@adobe/helix-shared-server-timing": "1.0.0",
59
60
  "@adobe/helix-shared-utils": "3.0.2"
60
61
  }
@@ -30,7 +30,7 @@ export const SCOPE_PIPELINE = 'pipeline';
30
30
  export const SCOPE_ADMIN = 'admin';
31
31
 
32
32
  /**
33
- * Raw config service view, also for legacy sites.
33
+ * Raw config service view.
34
34
  * @type {string}
35
35
  */
36
36
  export const SCOPE_RAW = 'raw';
@@ -9,6 +9,7 @@
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 { SitemapConfig } from '@adobe/helix-shared-config';
12
13
  import { computeSurrogateKey } from '@adobe/helix-shared-utils';
13
14
  // eslint-disable-next-line import/no-unresolved
14
15
  import { PipelineResponse } from './PipelineResponse.js';
@@ -18,7 +19,6 @@ import {
18
19
  SCOPE_DELIVERY,
19
20
  SCOPE_RAW, SCOPE_PUBLIC,
20
21
  } from './ConfigContext.js';
21
- import { resolveLegacyConfig, fetchRobotsTxt } from './config-legacy.js';
22
22
  import {
23
23
  canonicalArrayString, deepGetOrCreate, prune, uniqueArray,
24
24
  } from './utils.js';
@@ -146,11 +146,11 @@ function resolveSecret(object, idProp, dstProp, siteConfig, orgConfig) {
146
146
  /**
147
147
  * Returns the normalized access configuration for the given partition.
148
148
  *
149
- * SecretIds (and legacy apiKeyIds) can be set on access.preview, access.live, and access.site.
150
- * The normalized config only has access.preview and access.live; access.site is not present in
151
- * the final object and is only used during resolution. For each partition (preview or live),
152
- * the effective secretId and allow lists are the merge of the partition's values and
153
- * access.site's values (union, deduplicated).
149
+ * SecretIds (apiKeyId is the deprecated alias of secretId) can be set on access.preview,
150
+ * access.live, and access.site. The normalized config only has access.preview and access.live;
151
+ * access.site is not present in the final object and is only used during resolution. For each
152
+ * partition (preview or live), the effective secretId and allow lists are the merge of the
153
+ * partition's values and access.site's values (union, deduplicated).
154
154
  *
155
155
  * @param {object} ctx - The context
156
156
  * @param {object} config - The config (with access.preview, access.live, access.site)
@@ -163,7 +163,8 @@ function resolveSecret(object, idProp, dstProp, siteConfig, orgConfig) {
163
163
  export async function getAccessConfig(ctx, config, orgConfig, partition, rso) {
164
164
  const { access = {} } = config;
165
165
  const pAccess = access[partition] ?? {};
166
- // merge partition and site secretIds (and legacy apiKeyIds), then allow
166
+
167
+ // union of the partition's and the site-wide secretIds (apiKeyId is the deprecated alias)
167
168
  const secretId = uniqueArray(
168
169
  pAccess.apiKeyId,
169
170
  pAccess.secretId,
@@ -171,37 +172,68 @@ export async function getAccessConfig(ctx, config, orgConfig, partition, rso) {
171
172
  access.site?.secretId,
172
173
  );
173
174
  const allow = uniqueArray(pAccess.allow, access.site?.allow);
174
- const cfg = {
175
- secretId,
176
- allow,
177
- tokenHash: secretId
178
- // token ids are always stored in base64url format, but legacy apiKeyIds are not
179
- .map((jti) => jti.replaceAll('/', '_').replaceAll('+', '-'))
180
- .map((id) => lookupSecret(config, orgConfig, id, true))
181
- .filter((hash) => !!hash),
182
- };
183
- // if an allow is defined but no secretId, create a fake one so that auth is still enforced.
184
- if (allow.length && !cfg.secretId.length) {
185
- cfg.secretId.push('dummy');
186
- }
187
175
 
188
- // if an secretId is defined but no tokenHash, create a fake one so that auth is still enforced.
189
- if (cfg.secretId.length) {
190
- // add global token hash if defined and needed
176
+ // resolve each secretId to its token hash (ids are stored base64url, apiKeyIds may not be)
177
+ const tokenHash = secretId
178
+ .map((id) => id.replaceAll('/', '_').replaceAll('+', '-'))
179
+ .map((id) => lookupSecret(config, orgConfig, id, true))
180
+ .filter((hash) => !!hash);
181
+
182
+ // when auth is required (an allow list or any secretId), always accept the global delivery
183
+ // token if configured; otherwise ensure at least one hash exists so auth stays enforced
184
+ if (allow.length || secretId.length) {
191
185
  const globalTokenHash = await getGlobalTokenHash(ctx, rso);
192
- if (cfg.tokenHash.length && globalTokenHash) {
193
- // augment the list of hashes with the global one if exists
194
- cfg.tokenHash.push(globalTokenHash);
195
- } else if (!cfg.tokenHash.length) {
196
- // add a dummy or global hash if no tokens match the secretIds.
197
- if (!config.legacy || allow.length) {
198
- // but only add for non-legacy sites or legacy with allows
199
- cfg.tokenHash.push(globalTokenHash || 'n/a');
200
- }
186
+ if (globalTokenHash) {
187
+ tokenHash.push(globalTokenHash);
188
+ } else if (!tokenHash.length) {
189
+ tokenHash.push('n/a');
201
190
  }
202
191
  }
203
192
 
204
- return cfg;
193
+ return { secretId, allow, tokenHash };
194
+ }
195
+
196
+ /**
197
+ * Retrieves the robots.txt from the code-bus
198
+ * @param {ConfigContext} ctx the context
199
+ * @param {string} owner
200
+ * @param {string} repo
201
+ * @returns {Promise<ConfigObject>} the robots.txt
202
+ */
203
+ async function fetchRobotsTxt(ctx, owner, repo) {
204
+ const key = `${owner}/${repo}/main/robots.txt`;
205
+ const res = await ctx.loader.getObject(HELIX_CODE_BUS, key);
206
+ const robots = new ConfigObject();
207
+ if (res.body) {
208
+ robots.txt = res.body;
209
+ robots.updateLastModified(res.headers);
210
+ }
211
+ return robots;
212
+ }
213
+
214
+ /**
215
+ * Retrieves the sitemap index from the `sitemap.yaml` on the content-bus.
216
+ *
217
+ * @param {ConfigContext} ctx the context
218
+ * @param {object} rso RSO (ref, site, org)
219
+ * @param {object} config the config
220
+ * @returns {Promise<string[]|null>} the sitemap index list, or `null` if not configured
221
+ */
222
+ async function fetchSitemapIndex(ctx, rso, config) {
223
+ const key = `${config.content.contentBusId}/preview/.helix/sitemap.yaml`;
224
+ const res = await ctx.loader.getObject(HELIX_CONTENT_BUS, key);
225
+
226
+ const sitemap = new ConfigObject();
227
+ if (res.body) {
228
+ try {
229
+ const sitemapConfig = await new SitemapConfig().withSource(res.body).init();
230
+ sitemap.index = sitemapConfig.index;
231
+ sitemap.updateLastModified(res.headers);
232
+ } catch (e) {
233
+ ctx.log.warn(`[${rso.org}/${rso.site}] Unable to process ${key}: ${e.message}`);
234
+ }
235
+ }
236
+ return sitemap;
205
237
  }
206
238
 
207
239
  /**
@@ -318,14 +350,7 @@ async function resolveConfig(ctx, rso, scope) {
318
350
  ctx.log.error('error loading config %s: %d', key, res.status);
319
351
  throw new StatusCodeError('error loading config', res.status);
320
352
  }
321
- const config = await resolveLegacyConfig(ctx, rso, scope);
322
- if (config) {
323
- ctx.timer?.update('profile');
324
- const profile = await loadProfile(ctx, rso, 'default');
325
- config.merge(profile);
326
- config.data.legacy = true;
327
- }
328
- return config;
353
+ return null;
329
354
  }
330
355
  const site = new ConfigObject();
331
356
  site.data = res.json();
@@ -368,6 +393,11 @@ async function resolveConfig(ctx, rso, scope) {
368
393
  config.robots = robots;
369
394
  }
370
395
  }
396
+ ctx.timer?.update('sitemap-index');
397
+ const sitemap = await fetchSitemapIndex(ctx, rso, config);
398
+ if (sitemap.index) {
399
+ config.sitemap = sitemap;
400
+ }
371
401
  }
372
402
  if (scope === SCOPE_PIPELINE || scope === SCOPE_RAW || scope === SCOPE_DELIVERY) {
373
403
  // although not used in raw, or delivery, load in order to set correct last-modified
@@ -649,7 +679,7 @@ export async function getConfigResponse(ctx, opts) {
649
679
  },
650
680
  });
651
681
  }
652
- headers['x-hlx-version'] = siteConfig.data.legacy ? 4 : 5;
682
+ headers['x-hlx-version'] = 5;
653
683
 
654
684
  const config = siteConfig.data;
655
685
  if (scope === SCOPE_PIPELINE || config.extends?.profile === 'default') {
@@ -686,6 +716,7 @@ export async function getConfigResponse(ctx, opts) {
686
716
  siteConfig.updateLastModified(config.head);
687
717
  siteConfig.updateLastModified(siteConfig.sidekick);
688
718
  siteConfig.updateLastModified(config.robots);
719
+ siteConfig.updateLastModified(config.sitemap);
689
720
  siteConfig.updateLastModified(config.metadata?.preview);
690
721
  siteConfig.updateLastModified(config.metadata?.live);
691
722
 
@@ -770,8 +801,8 @@ export async function getConfigResponse(ctx, opts) {
770
801
  folders: config.folders,
771
802
  public: config.public,
772
803
  robots: config.robots,
804
+ sitemap: config.sitemap,
773
805
  access: config.access,
774
- legacy: config.legacy,
775
806
  lastModified: siteConfig.lastModified,
776
807
  created: config.created,
777
808
  trustedHosts: computeTrustedHosts(org, site, config),
@@ -1,228 +0,0 @@
1
- /*
2
- * Copyright 2024 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
- import {
13
- SCOPE_ADMIN, SCOPE_DELIVERY, SCOPE_PIPELINE, SCOPE_RAW,
14
- } from './ConfigContext.js';
15
- import { ConfigObject } from './config-object.js';
16
- import { contentConfigMerge } from './legacy-config-merge.js';
17
- import { prune, toArray } from './utils.js';
18
-
19
- const HELIX_CODE_BUS = 'helix-code-bus';
20
-
21
- /**
22
- * Retrieves the head.html from the code bus
23
- * @param ctx
24
- * @param rso
25
- * @returns {Promise<ConfigObject|{}>}
26
- */
27
- async function fetchHeadHtml(ctx, rso) {
28
- const key = `${rso.org}/${rso.site}/${rso.ref}/head.html`;
29
- const res = await ctx.loader.getObject(HELIX_CODE_BUS, key);
30
- if (res.body) {
31
- const head = new ConfigObject();
32
- head.html = res.body;
33
- head.updateLastModified(res.headers);
34
- return head;
35
- }
36
- return {};
37
- }
38
-
39
- /**
40
- * Retrieves the helix-config.json which is an aggregate from fstab.yaml and head.html.
41
- *
42
- * @param {ConfigContext} ctx the context
43
- * @param {RSO} rso
44
- * @returns {Promise<ConfigObject|null>} the helix-config or {@code null} if optional and not found.
45
- */
46
- async function fetchHelixConfig(ctx, rso) {
47
- const key = `${rso.org}/${rso.site}/main/helix-config.json`;
48
- const res = await ctx.loader.getObject(HELIX_CODE_BUS, key);
49
- if (!res.body) {
50
- return null;
51
- }
52
- const config = res.json();
53
- // set contentbus id, if present in header
54
- const cbid = res.headers.get('x-amz-meta-x-contentbus-id');
55
- if (cbid && !config.content) {
56
- // create the content section if not already present
57
- config.content = {
58
- data: Object.create(null),
59
- };
60
- const content = config.content.data;
61
- const nvps = cbid.split(/;\s+/);
62
- for (const nv of nvps) {
63
- const [path, id] = nv.split('=');
64
- if (!content[path]) {
65
- content[path] = {};
66
- }
67
- content[path].contentBusId = id;
68
- }
69
- }
70
- const cfg = new ConfigObject();
71
- cfg.updateLastModified(res.headers);
72
- cfg.data = config;
73
- return cfg;
74
- }
75
-
76
- /**
77
- * Retrieves the robots.txt from the code-bus
78
- * @param {ConfigContext} ctx the context
79
- * @param {string} owner
80
- * @param {string} repo
81
- * @returns {Promise<ConfigObject>} the robots.txt
82
- */
83
- export async function fetchRobotsTxt(ctx, owner, repo) {
84
- const key = `${owner}/${repo}/main/robots.txt`;
85
- const res = await ctx.loader.getObject(HELIX_CODE_BUS, key);
86
- const robots = new ConfigObject();
87
- if (res.body) {
88
- robots.txt = res.body;
89
- robots.updateLastModified(res.headers);
90
- }
91
- return robots;
92
- }
93
-
94
- async function resolveAdminAccess(ctx, admin) {
95
- const ret = {
96
- ...admin,
97
- };
98
- if (ret.apiKeyId) {
99
- ret.apiKeyId = toArray(ret.apiKeyId);
100
- }
101
- if (ret.defaultRole) {
102
- ret.defaultRole = toArray(ret.defaultRole);
103
- }
104
- for (const [role, users] of Object.entries(ret.role ?? [])) {
105
- // todo: load users.json
106
- ret.role[role] = toArray(users);
107
- }
108
- return ret;
109
- }
110
-
111
- /**
112
- * Loads the content from a helix 4 project.
113
- * @param {import('./ConfigContext.js)'.ConfigContext} ctx
114
- * @param {RSO} rso
115
- * @param {string} scope
116
- * @returns {Promise<ConfigObject|null>} the config object or {@code null} if not found.
117
- */
118
- export async function resolveLegacyConfig(ctx, rso, scope) {
119
- ctx.timer?.update('legacy-helix-config');
120
-
121
- // set owner==org and repo==site and fetch from helix-config for now
122
- const cfg = await fetchHelixConfig(ctx, rso);
123
- if (!cfg) {
124
- return null;
125
- }
126
- const helixConfig = cfg.data;
127
- const { contentBusId } = helixConfig.content.data['/'];
128
- if (!contentBusId) {
129
- return null;
130
- }
131
- const fstab = helixConfig.fstab?.data || helixConfig.fstab;
132
- if (!fstab) {
133
- return null;
134
- }
135
- let source = fstab.mountpoints['/'];
136
- if (!source) {
137
- return null;
138
- }
139
- if (typeof source === 'string') {
140
- source = {
141
- type: source.startsWith('https://drive.google.com/')
142
- ? 'google'
143
- : 'onedrive',
144
- url: source,
145
- };
146
- } else {
147
- delete source.contentBusId;
148
- }
149
-
150
- const head = await fetchHeadHtml(ctx, rso);
151
- const config = {
152
- version: 1,
153
- legacy: true,
154
- code: {
155
- owner: rso.org,
156
- repo: rso.site,
157
- source: {
158
- type: 'github',
159
- url: `https://github.com/${rso.org}/${rso.site}`,
160
- },
161
- },
162
- content: {
163
- contentBusId,
164
- source,
165
- },
166
- folders: fstab.folders ?? {},
167
- head,
168
- };
169
- cfg.data = config;
170
- ctx.timer?.update('legacy-config-all');
171
- const {
172
- preview: configAllPreview,
173
- live: configAllLive,
174
- } = await contentConfigMerge(ctx, config.content.contentBusId, rso);
175
- const { access, admin } = configAllPreview?.config?.data || {};
176
- if (access) {
177
- config.access = {
178
- preview: access.preview,
179
- live: access.live,
180
- };
181
- delete access.preview;
182
- delete access.live;
183
- config.access.site = access;
184
- }
185
- if (admin) {
186
- if (!config.access) {
187
- config.access = { };
188
- }
189
- config.access.admin = await resolveAdminAccess(ctx, admin);
190
- }
191
- if (configAllPreview) {
192
- config.cdn = configAllPreview.config?.data.cdn ?? {};
193
- if (!config.cdn.prod?.host && configAllPreview.config?.data.host) {
194
- config.cdn.prod = {
195
- host: configAllPreview.config.data.host,
196
- };
197
- }
198
- }
199
-
200
- if (scope !== SCOPE_DELIVERY && configAllPreview) {
201
- config.headers = configAllPreview.headers?.data ?? {};
202
- }
203
-
204
- if (scope === SCOPE_PIPELINE) {
205
- config.metadata = {};
206
- if (configAllPreview) {
207
- config.metadata.preview = configAllPreview.metadata;
208
- }
209
- if (configAllLive) {
210
- config.metadata.live = configAllLive.metadata;
211
- }
212
- ctx.timer?.update('robots-txt');
213
- config.robots = await fetchRobotsTxt(ctx, rso.org, rso.site);
214
- if (!config.robots.txt) {
215
- delete config.robots;
216
- } else {
217
- cfg.updateLastModified(config.robots);
218
- }
219
- }
220
- if ((scope === SCOPE_ADMIN || scope === SCOPE_RAW) && configAllPreview?.config?.data?.metadata) {
221
- if (!config.metadata) {
222
- config.metadata = {};
223
- }
224
- config.metadata.source = configAllPreview?.config?.data?.metadata;
225
- }
226
-
227
- return prune(cfg);
228
- }
@@ -1,104 +0,0 @@
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
- }
@@ -1,236 +0,0 @@
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
- }