@adobe/helix-config 2.14.0 → 2.15.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,10 @@
1
+ # [2.15.0](https://github.com/adobe/helix-config/compare/v2.14.0...v2.15.0) (2024-05-07)
2
+
3
+
4
+ ### Features
5
+
6
+ * support profile for site ([#67](https://github.com/adobe/helix-config/issues/67)) ([55c09c9](https://github.com/adobe/helix-config/commit/55c09c95919ec06f047393335bdbcbcc2d187eb2))
7
+
1
8
  # [2.14.0](https://github.com/adobe/helix-config/compare/v2.13.1...v2.14.0) (2024-05-07)
2
9
 
3
10
 
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.0",
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'),
@@ -194,8 +205,8 @@ async function resolveConfig(ctx, rso, scope) {
194
205
  }
195
206
 
196
207
  // check for org config
197
- key = `orgs/${rso.org}/config.json`;
198
- res = await ctx.loader.getObject(HELIX_CONFIG_BUS, key);
208
+ const orgKey = `orgs/${rso.org}/config.json`;
209
+ res = await ctx.loader.getObject(HELIX_CONFIG_BUS, orgKey);
199
210
  if (res.body) {
200
211
  const orgConfig = res.json();
201
212
  if (orgConfig.tokens) {
@@ -205,11 +216,22 @@ async function resolveConfig(ctx, rso, scope) {
205
216
  return config;
206
217
  }
207
218
 
208
- async function getSurrogateKey(opts) {
219
+ async function getSurrogateKey(opts, profile) {
209
220
  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}`;
221
+ const keys = [
222
+ await computeSurrogateKey(`${org}_config.json`),
223
+ await computeSurrogateKey(`${site}--${org}_config.json`),
224
+ ];
225
+ if (profile) {
226
+ keys.push(await computeSurrogateKey(`p:${profile}--${org}_config.json`));
227
+ }
228
+ return keys.join(' ');
229
+ }
230
+
231
+ async function loadOrgConfig(ctx, org) {
232
+ const key = `orgs/${org}/config.json`;
233
+ const res = await ctx.loader.getObject(HELIX_CONFIG_BUS, key);
234
+ return res.body ? res.json() : null;
213
235
  }
214
236
 
215
237
  export async function getConfigResponse(ctx, opts) {
@@ -219,7 +241,7 @@ export async function getConfigResponse(ctx, opts) {
219
241
  const rso = { ref, site, org };
220
242
  const config = await resolveConfig(ctx, rso, scope);
221
243
  const surrogateHeaders = {
222
- 'x-surrogate-key': await getSurrogateKey(opts),
244
+ 'x-surrogate-key': await getSurrogateKey(opts, config?.extends?.profile),
223
245
  };
224
246
  if (!config) {
225
247
  return new PipelineResponse('', {
@@ -230,6 +252,14 @@ export async function getConfigResponse(ctx, opts) {
230
252
  },
231
253
  });
232
254
  }
255
+ if (config.extends && scope !== SCOPE_RAW) {
256
+ delete config.extends;
257
+ }
258
+
259
+ const orgConfig = await loadOrgConfig(ctx, org);
260
+ if (orgConfig?.tokens) {
261
+ config.tokens = orgConfig.tokens;
262
+ }
233
263
 
234
264
  if (config.access) {
235
265
  // 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.