@adobe/helix-config 2.14.0 → 2.15.1

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
+ ## [2.15.1](https://github.com/adobe/helix-config/compare/v2.15.0...v2.15.1) (2024-05-07)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * do not load org config in resolveConfig ([ccedbfd](https://github.com/adobe/helix-config/commit/ccedbfd4c7c465b842e25f9021fd5a06021e63d4))
7
+
8
+ # [2.15.0](https://github.com/adobe/helix-config/compare/v2.14.0...v2.15.0) (2024-05-07)
9
+
10
+
11
+ ### Features
12
+
13
+ * support profile for site ([#67](https://github.com/adobe/helix-config/issues/67)) ([55c09c9](https://github.com/adobe/helix-config/commit/55c09c95919ec06f047393335bdbcbcc2d187eb2))
14
+
1
15
  # [2.14.0](https://github.com/adobe/helix-config/compare/v2.13.1...v2.14.0) (2024-05-07)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/helix-config",
3
- "version": "2.14.0",
3
+ "version": "2.15.1",
4
4
  "description": "Helix Config",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -0,0 +1,143 @@
1
+ /*
2
+ * Copyright 2023 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
+
14
+ /**
15
+ * @typedef RootProperty
16
+ * @property {boolean} atomic whether the property should be overwritten atomically
17
+ * @property {boolean} isModified whether the property need special merging
18
+ */
19
+
20
+ const ROOT_PROPERTIES = {
21
+ name: { atomic: true },
22
+ title: { atomic: true },
23
+ description: { atomic: true },
24
+ content: { atomic: true },
25
+ code: { atomic: true },
26
+ folders: {},
27
+ headers: { isModifier: true },
28
+ cdn: {},
29
+ access: {},
30
+ sidekick: {},
31
+ metadata: { isModifier: true },
32
+ robots: {},
33
+ extends: {},
34
+ tokens: {},
35
+ };
36
+
37
+ const FORCED_TYPES = {
38
+ '.cdn.prod.route': 'array',
39
+ };
40
+
41
+ /**
42
+ * Merges the `src` object into the `dst` object.
43
+ * - if the dst property is a scalar, it will be overwritten by the dst property
44
+ * - if the src property is an empty string, it will delete the dst property
45
+ * - if the dst property is an array, it will be expanded by the src property uniquely
46
+ * - some well known paths are forced to be arrays
47
+ * @param {object} dst
48
+ * @param {object} src
49
+ * @param {boolean} isModifier specifies that the objects are modifiers sheets and need special
50
+ * merging
51
+ */
52
+ function merge(dst, src, path, isModifier) {
53
+ for (const [key, value] of Object.entries(src)) {
54
+ const itemPath = `${path}.${key}`;
55
+ if (value === '') {
56
+ // remove key if value is empty string (keep false, 0)
57
+ delete dst[key];
58
+ } else if (typeof value !== 'object') {
59
+ // handle non object
60
+ if (key in dst) {
61
+ let dstValue = dst[key];
62
+ if (!Array.isArray(dstValue) && FORCED_TYPES[itemPath] === 'array') {
63
+ dstValue = [dst[key]];
64
+ dst[key] = dstValue;
65
+ }
66
+ if (!Array.isArray(dstValue)) {
67
+ dst[key] = value;
68
+ } else if (!dstValue.includes(value)) {
69
+ // only add to array if not exist yet
70
+ dstValue.push(value);
71
+ }
72
+ } else {
73
+ dst[key] = value;
74
+ }
75
+ } else if (Array.isArray(value)) {
76
+ // handle array
77
+ if (key in dst) {
78
+ if (!Array.isArray(dst[key])) {
79
+ dst[key] = [dst[key]];
80
+ }
81
+ } else {
82
+ dst[key] = [];
83
+ }
84
+ const dstArray = dst[key];
85
+ for (const item of value) {
86
+ if (typeof item === 'object') {
87
+ // todo: handle arrays in arrays eventually
88
+ // special case for modifier sheets
89
+ if (isModifier && item.key) {
90
+ const idx = dstArray.findIndex((i) => i.key === item.key);
91
+ if (idx >= 0) {
92
+ dstArray.splice(idx, 1);
93
+ }
94
+ if (!item.value) {
95
+ // don't set empty modifier
96
+ continue;
97
+ }
98
+ }
99
+ const dstObj = Object.create(null);
100
+ dstArray.push(dstObj);
101
+ merge(dstObj, item, itemPath, isModifier);
102
+ } else if (!dstArray.includes(item)) {
103
+ dstArray.push(item);
104
+ }
105
+ }
106
+ } else {
107
+ // handle plain object
108
+ // eslint-disable-next-line no-use-before-define
109
+ mergeObject(dst, key, value, `${path}.${key}`, isModifier);
110
+ }
111
+ }
112
+ }
113
+
114
+ function mergeObject(dst, key, value, path, isModifier) {
115
+ const dstObj = dst[key] ?? Object.create(null);
116
+ if (!dst[key]) {
117
+ dst[key] = dstObj;
118
+ }
119
+ merge(dstObj, value, path, isModifier);
120
+ }
121
+
122
+ function mergeConfig(dst, src) {
123
+ Object.entries(ROOT_PROPERTIES).forEach(([key, { atomic, isModifier }]) => {
124
+ if (src[key]) {
125
+ if (atomic) {
126
+ dst[key] = src[key];
127
+ } else {
128
+ mergeObject(dst, key, src[key], `.${key}`, isModifier);
129
+ }
130
+ }
131
+ });
132
+ }
133
+
134
+ export function getMergedConfig(site, profile) {
135
+ if (!profile) {
136
+ return site;
137
+ }
138
+ const ret = Object.create(null);
139
+ ret.version = site.version;
140
+ mergeConfig(ret, profile);
141
+ mergeConfig(ret, site);
142
+ return ret;
143
+ }
@@ -19,6 +19,7 @@ import {
19
19
  SCOPE_RAW,
20
20
  } from './ConfigContext.js';
21
21
  import { resolveLegacyConfig, fetchRobotsTxt, toArray } from './config-legacy.js';
22
+ import { getMergedConfig } from './config-merge.js';
22
23
 
23
24
  /**
24
25
  * @typedef Config
@@ -81,7 +82,7 @@ export function getAccessConfig(config, partition) {
81
82
  // if an allow is defined but no apiKeyId, create a fake one so that auth is still
82
83
  // enforced. later we can remove the allow and the apiKeyId in favor of the tokenHash
83
84
  if (allow.length && !cfg.apiKeyId.length) {
84
- cfg.apiKeyId.push('fake');
85
+ cfg.apiKeyId.push('dummy');
85
86
  }
86
87
  // if an apiKeyId is defined but no tokenHash, create a fake one so that auth is still
87
88
  // enforced.
@@ -167,7 +168,7 @@ function retainProperty(obj, prop) {
167
168
  */
168
169
  async function resolveConfig(ctx, rso, scope) {
169
170
  // try to load site config from config-bus
170
- let key = `orgs/${rso.org}/sites/${rso.site}.json`;
171
+ const key = `orgs/${rso.org}/sites/${rso.site}.json`;
171
172
  let res = await ctx.loader.getObject(HELIX_CONFIG_BUS, key);
172
173
  if (!res.body) {
173
174
  if (scope !== SCOPE_ADMIN) {
@@ -176,7 +177,17 @@ async function resolveConfig(ctx, rso, scope) {
176
177
  return null;
177
178
  }
178
179
  }
179
- const config = res.json();
180
+ const site = res.json();
181
+ let profile;
182
+
183
+ if (site.extends?.profile) {
184
+ const profileKey = `orgs/${rso.org}/profiles/${site.extends.profile}.json`;
185
+ res = await ctx.loader.getObject(HELIX_CONFIG_BUS, profileKey);
186
+ if (res.body) {
187
+ profile = res.json();
188
+ }
189
+ }
190
+ const config = getMergedConfig(site, profile);
180
191
  if (scope === SCOPE_PIPELINE) {
181
192
  config.metadata = {
182
193
  preview: await loadMetadata(ctx, config, 'preview'),
@@ -192,24 +203,25 @@ async function resolveConfig(ctx, rso, scope) {
192
203
  if (scope === SCOPE_PIPELINE || scope === SCOPE_DELIVERY) {
193
204
  config.head = await loadHeadHtml(ctx, config, rso.ref);
194
205
  }
195
-
196
- // check for org config
197
- key = `orgs/${rso.org}/config.json`;
198
- res = await ctx.loader.getObject(HELIX_CONFIG_BUS, key);
199
- if (res.body) {
200
- const orgConfig = res.json();
201
- if (orgConfig.tokens) {
202
- config.tokens = orgConfig.tokens;
203
- }
204
- }
205
206
  return config;
206
207
  }
207
208
 
208
- async function getSurrogateKey(opts) {
209
+ async function getSurrogateKey(opts, profile) {
209
210
  const { site, org } = opts;
210
- const orgKey = await computeSurrogateKey(`${org}_config.json`);
211
- const siteKey = await computeSurrogateKey(`${site}--${org}_config.json`);
212
- return `${orgKey} ${siteKey}`;
211
+ const keys = [
212
+ await computeSurrogateKey(`${org}_config.json`),
213
+ await computeSurrogateKey(`${site}--${org}_config.json`),
214
+ ];
215
+ if (profile) {
216
+ keys.push(await computeSurrogateKey(`p:${profile}--${org}_config.json`));
217
+ }
218
+ return keys.join(' ');
219
+ }
220
+
221
+ async function loadOrgConfig(ctx, org) {
222
+ const key = `orgs/${org}/config.json`;
223
+ const res = await ctx.loader.getObject(HELIX_CONFIG_BUS, key);
224
+ return res.body ? res.json() : null;
213
225
  }
214
226
 
215
227
  export async function getConfigResponse(ctx, opts) {
@@ -219,7 +231,7 @@ export async function getConfigResponse(ctx, opts) {
219
231
  const rso = { ref, site, org };
220
232
  const config = await resolveConfig(ctx, rso, scope);
221
233
  const surrogateHeaders = {
222
- 'x-surrogate-key': await getSurrogateKey(opts),
234
+ 'x-surrogate-key': await getSurrogateKey(opts, config?.extends?.profile),
223
235
  };
224
236
  if (!config) {
225
237
  return new PipelineResponse('', {
@@ -230,6 +242,14 @@ export async function getConfigResponse(ctx, opts) {
230
242
  },
231
243
  });
232
244
  }
245
+ if (config.extends && scope !== SCOPE_RAW) {
246
+ delete config.extends;
247
+ }
248
+
249
+ const orgConfig = await loadOrgConfig(ctx, org);
250
+ if (orgConfig?.tokens) {
251
+ config.tokens = orgConfig.tokens;
252
+ }
233
253
 
234
254
  if (config.access) {
235
255
  // normalize access config
@@ -54,6 +54,12 @@
54
54
  },
55
55
  "robots": {
56
56
  "$ref": "https://ns.adobe.com/helix/config/robots"
57
+ },
58
+ "extends": {
59
+ "profile": {
60
+ "type": "string",
61
+ "pattern": "^[0-9a-zA-Z-_]+$"
62
+ }
57
63
  }
58
64
  },
59
65
  "required": [
@@ -47,7 +47,10 @@ const FRAGMENTS_COMMON = {
47
47
  };
48
48
 
49
49
  const FRAGMENTS = {
50
- sites: FRAGMENTS_COMMON,
50
+ sites: {
51
+ ...FRAGMENTS_COMMON,
52
+ extends: 'object',
53
+ },
51
54
  profiles: FRAGMENTS_COMMON,
52
55
  org: {
53
56
  tokens: {
@@ -35,6 +35,9 @@ export interface HelixSiteConfig {
35
35
  sidekick?: SidekickConfig;
36
36
  metadata?: Metadata;
37
37
  robots?: Robots;
38
+ extends?: {
39
+ [k: string]: unknown;
40
+ };
38
41
  }
39
42
  /**
40
43
  * Defines the content bus location and source.